From 4ac22d01df0679877d9291ce4dc3967f6e203515 Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 21 Oct 2026 12:50:31 +0800 Subject: [PATCH] fix(agent): support DeepSeek official multimodal tool conversations --- lib/ai/deepseek-compatible-fetch.ts | 21 ++++++++++++ lib/ai/dynamic-provider.ts | 2 ++ lib/ai/zcode-agent/model-gateway.ts | 4 ++- tests/integration/agent-model-gateway.test.ts | 34 +++++++++++++++++++ 4 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 lib/ai/deepseek-compatible-fetch.ts diff --git a/lib/ai/deepseek-compatible-fetch.ts b/lib/ai/deepseek-compatible-fetch.ts new file mode 100644 index 000000000..801b6f23e --- /dev/null +++ b/lib/ai/deepseek-compatible-fetch.ts @@ -0,0 +1,21 @@ +/** Native Agent history does not retain provider-specific reasoning fields. + * DeepSeek defaults to thinking mode, which requires those fields on subsequent + * tool rounds. Use its documented non-thinking tool protocol by default. */ +export function isDeepSeekOfficialUrl(value?: string | null) { + try { return new URL(value || "").hostname === "api.deepseek.com"; } + catch { return false; } +} + +export function createDeepSeekCompatibleFetch(baseFetch: typeof fetch = fetch): typeof fetch { + return async (input, init) => { + if (typeof init?.body !== "string" || !isDeepSeekOfficialUrl(String(input instanceof Request ? input.url : input))) + return baseFetch(input, init); + let body: Record; + try { body = JSON.parse(init.body); } catch { return baseFetch(input, init); } + // An explicit thinking configuration belongs to its caller. Agent requests + // use the default non-thinking mode; ordinary callers can opt in explicitly. + if (!body || typeof body !== "object" || Array.isArray(body) || body.thinking !== undefined) + return baseFetch(input, init); + return baseFetch(input, { ...init, body: JSON.stringify({ ...body, thinking: { type: "disabled" } }) }); + }; +} diff --git a/lib/ai/dynamic-provider.ts b/lib/ai/dynamic-provider.ts index 87adec6ec..8d561841b 100644 --- a/lib/ai/dynamic-provider.ts +++ b/lib/ai/dynamic-provider.ts @@ -43,6 +43,7 @@ import { } from "./detail-agent-kimi-profile"; import { isBltcyVideoBaseUrl } from "./bltcy-video-models"; import { resolveModelRuntimeId } from "./model-runtime"; +import { createDeepSeekCompatibleFetch, isDeepSeekOfficialUrl } from "./deepseek-compatible-fetch"; // 缓存已创建的 provider 实例 const providerCache = new Map { + 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" }); + 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 first = await gateway.forward({ ...body, 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, 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); + } 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; }