fix(agent): support DeepSeek official multimodal tool conversations

This commit is contained in:
134 2026-10-21 12:50:31 +08:00
parent 72fb4ec4be
commit 4ac22d01df
4 changed files with 60 additions and 1 deletions

View File

@ -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<string, unknown>;
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" } }) });
};
}

View File

@ -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<string, {
@ -56,6 +57,7 @@ function isVolcanoMultimodalBaseUrl(baseUrl?: string | null) {
}
function getOpenAICompatibleFetch(baseUrl?: string | null) {
if (isDeepSeekOfficialUrl(baseUrl)) return createDeepSeekCompatibleFetch();
return isMoonshotOfficialBaseUrl(baseUrl) ? createMoonshotKimiFetch() : undefined;
}

View File

@ -5,6 +5,7 @@ import { toResponsesRequest, responsesCompletion, completionResponse } from "./m
import { resolveModelRuntimeId } from "@/lib/ai/model-runtime";
import { selectVip98kChatInvocation } from "@/lib/ai/vip98k-chat-contract";
import { normalizeVip98kBaseUrl } from "@/lib/ai/vip98k-models";
import { createDeepSeekCompatibleFetch, isDeepSeekOfficialUrl } from "@/lib/ai/deepseek-compatible-fetch";
type ConfiguredModel = AgentModelEntry;
const OPENAI_COMPATIBLE = new Set(["openai", "openai-compatible", "alibaba", "qwen", "local-qwen", "local", "zhipu", "moonshot", "deepseek", "xai", "siliconflow", "custom"]);
@ -69,7 +70,8 @@ export async function createZcodeModelGateway(configured: ConfiguredModel, onErr
attemptSignal.throwIfAborted();
const request = target.apiType === "openai-responses" ? toResponsesRequest(body) : { ...body };
delete request.stream_options;
const response = await fetch(target.endpoint, { method: "POST", redirect: "error",
const providerFetch = isDeepSeekOfficialUrl(target.endpoint.href) ? createDeepSeekCompatibleFetch() : fetch;
const response = await providerFetch(target.endpoint, { method: "POST", redirect: "error",
headers: { "content-type": "application/json", ...target.contractHeaders, Authorization: `Bearer ${target.apiKey}` },
body: JSON.stringify({ ...request, model: target.modelId, stream: false }), signal: attemptSignal });
if (!response.ok) {

View File

@ -2,8 +2,42 @@ 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" });
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;
}