ai-chatbot/tests/integration/agent-model-gateway.test.ts

102 lines
7.2 KiB
TypeScript

import assert from "node:assert/strict";
import { test } from "node:test";
import { createZcodeModelGateway } from "@/lib/ai/zcode-agent/model-gateway";
import type { AgentModelEntry, AgentCircuitStore } from "@/lib/ai/runtime/agent-model-failover";
import { createDeepSeekCompatibleFetch, isDeepSeekOfficialUrl } from "@/lib/ai/deepseek-compatible-fetch";
const body = { model: "primary", stream: true, messages: [{ role: "user", content: "request" }], tools: [{ type: "function", function: { name: "readCanvas", parameters: { type: "object" } } }] };
test("DeepSeek official tool rounds retain images and use the compatible non-thinking default", async () => {
const model = entry("deepseek-flash"); model.provider.baseUrl = "https://api.deepseek.com/v1";
const original = global.fetch; const requests: any[] = [];
const call = { id: "vision", type: "function", function: { name: "readCanvas", arguments: "{}" } };
global.fetch = async (url, init) => {
assert.equal(String(url), "https://api.deepseek.com/v1/chat/completions");
const request = JSON.parse(String(init?.body)); requests.push(request);
assert.deepEqual(request.thinking, { type: "disabled" });
assert.equal(request.reasoning_effort, undefined);
return Response.json({ choices: [{ finish_reason: requests.length === 1 ? "tool_calls" : "stop", message: requests.length === 1 ? { role: "assistant", content: "", tool_calls: [call] } : { role: "assistant", content: "red" } }] });
};
try {
const gateway = await createZcodeModelGateway(model, undefined, [model], fixture().circuit);
const messages: any[] = [{ role: "user", content: [{ type: "text", text: "inspect" }, { type: "image_url", image_url: { url: "data:image/png;base64,fixture" } }] }];
const nativeSettings = { thinking: { type: "enabled" }, reasoning_effort: "max", max_completion_tokens: 384000 };
const first = await gateway.forward({ ...body, ...nativeSettings, stream: false, messages }, "/v1/chat/completions", new AbortController().signal);
assert.equal(first.status, 200); const reply = await first.json();
messages.push(reply.choices[0].message, { role: "tool", tool_call_id: "vision", content: "red" });
const second = await gateway.forward({ ...body, ...nativeSettings, stream: false, messages }, "/v1/chat/completions", new AbortController().signal);
assert.equal(second.status, 200); assert.equal((await second.json()).choices[0].message.content, "red");
assert.equal(requests.length, 2); assert.deepEqual(requests[1].messages, messages);
assert.equal((body as any).thinking, undefined);
assert.deepEqual(nativeSettings.thinking, { type: "enabled" });
} finally { global.fetch = original; }
});
test("DeepSeek compatibility respects explicit thinking and never changes other endpoints", async () => {
const requests: any[] = [];
const wrapped = createDeepSeekCompatibleFetch(async (_url, init) => { requests.push(JSON.parse(String(init?.body))); return Response.json({}); });
await wrapped("https://api.deepseek.com/v1/chat/completions", { body: JSON.stringify({ thinking: { type: "enabled" } }) });
await wrapped("https://api.deepseek.com.other.invalid/v1/chat/completions", { body: "{}" });
assert.deepEqual(requests, [{ thinking: { type: "enabled" } }, {}]);
assert(!isDeepSeekOfficialUrl("https://api.deepseek.com.other.invalid"));
assert(!isDeepSeekOfficialUrl("not-a-url"));
});
function entry(id: string, responses = false): AgentModelEntry {
return { model: { id, modelId: id, capabilities: responses ? { vip98k: { invocation: { path: "/v1/responses", headers: { "X-Relay-Route-Option": "route-proof" } } } } : null }, provider: { type: responses ? "vip98k" : "openai", baseUrl: "https://fixture.invalid", apiKey: "synthetic-only" } } as AgentModelEntry;
}
function fixture() {
const paused = new Set<string>(); const calls: Array<{ url: string; request: any }> = [];
const circuit: AgentCircuitStore = { isPaused: async e => paused.has(e.model.id), fail: async e => { paused.add(e.model.id); } };
return { paused, calls, circuit };
}
test("a broken primary and a Responses-only backup work through one stable native protocol", async () => {
const s = fixture(); const primary = entry("primary"); const backup = entry("backup", true);
const original = global.fetch;
global.fetch = async (url, init) => {
const request = JSON.parse(String(init?.body)); s.calls.push({ url: String(url), request });
if (request.model === "primary") return new Response("invalid authentication", { status: 401 });
assert.equal(request.stream, false); assert.equal(request.tools[0].name, "readCanvas");
assert.equal((init?.headers as Record<string, string>)["X-Relay-Route-Option"], "route-proof");
return Response.json({ status: "completed", output: [{ type: "function_call", call_id: "once", name: "readCanvas", arguments: "{}" }] });
};
try {
const gateway = await createZcodeModelGateway(primary, undefined, [primary, backup], s.circuit);
assert.equal(gateway.apiType, "openai-chat-completions");
const response = await gateway.forward(body, "/v1/chat/completions", new AbortController().signal);
assert.equal(response.status, 200); assert.match(await response.text(), /"id":"once"/);
assert.deepEqual([...s.paused], ["primary"]);
const nextGateway = await createZcodeModelGateway(primary, undefined, [primary, backup], s.circuit);
await nextGateway.forward(body, "/v1/chat/completions", new AbortController().signal);
assert.deepEqual(s.calls.map(x => x.request.model), ["primary", "backup", "backup"]);
assert.equal(s.calls[1].url, "https://fixture.invalid/v1/responses");
assert.deepEqual(body.messages, [{ role: "user", content: "request" }]);
} finally { global.fetch = original; }
});
test("truncated tool output cannot escape before fallback supplies a complete replacement", async () => {
const s = fixture(); const primary = entry("primary"); const backup = entry("backup");
const original = global.fetch;
global.fetch = async (_url, init) => {
const request = JSON.parse(String(init?.body)); s.calls.push({ url: "", request });
return Response.json({ choices: [{ finish_reason: "tool_calls", message: { tool_calls: [{ id: "once", type: "function", function: { name: "readCanvas", arguments: request.model === "primary" ? "{bad" : "{}" } }] } }] });
};
try {
const gateway = await createZcodeModelGateway(primary, undefined, [primary, backup], s.circuit);
const response = await gateway.forward(body, "/v1/chat/completions", new AbortController().signal);
const output = await response.text();
assert.equal(response.status, 200); assert(!output.includes("{bad"));
assert.deepEqual(s.calls.map(x => x.request.model), ["primary", "backup"]);
assert.deepEqual([...s.paused], ["primary"]);
} finally { global.fetch = original; }
});
test("already paused models receive no HTTP call even in a new gateway", async () => {
const s = fixture(); s.paused.add("primary");
const original = global.fetch; global.fetch = async () => { throw new Error("must not contact paused model"); };
try {
const gateway = await createZcodeModelGateway(entry("primary"), undefined, [entry("primary")], s.circuit);
assert.equal((await gateway.forward(body, "/v1/chat/completions", new AbortController().signal)).status, 503);
} finally { global.fetch = original; }
});