fix(agent): support DeepSeek official multimodal tool conversations
This commit is contained in:
parent
72fb4ec4be
commit
4ac22d01df
21
lib/ai/deepseek-compatible-fetch.ts
Normal file
21
lib/ai/deepseek-compatible-fetch.ts
Normal 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" } }) });
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user