fix(agent): keep official DeepSeek available across task rounds

This commit is contained in:
134 2026-10-21 13:39:35 +08:00
parent 4ac22d01df
commit ec82cde618
6 changed files with 64 additions and 13 deletions

View File

@ -89,8 +89,11 @@ export async function analyzeRuntimeImages(params: {
});
if (answer.finishReason === "content-filter") throw new Error("content policy rejection");
const raw = answer.text.trim().replace(/^```(?:json)?\s*|\s*```$/g, "");
let review: z.infer<typeof reviewSchema>;
try { review = reviewSchema.parse(JSON.parse(raw)); } catch { throw new AgentModelProtocolError(); }
let parsed: unknown;
try { parsed = JSON.parse(raw); } catch { throw new AgentModelProtocolError("vision_json"); }
const checked = reviewSchema.safeParse(parsed);
if (!checked.success) throw new AgentModelProtocolError("vision_schema");
const review = checked.data;
return {
...review,
images: images.map(({ preview: _preview, ...item }) => item),

View File

@ -1,5 +1,7 @@
import { is3DModelDescriptor, isImageGenerationModelDescriptor, isVideoModelDescriptor } from "../model-classification";
import { isMediaInputFailure } from "./request-failure-classification";
import { isDeepSeekOfficialUrl } from "../deepseek-compatible-fetch";
import { createLogger } from "@/lib/observability/logger";
import type { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
export type AgentModelEntry = Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>[number];
@ -12,8 +14,11 @@ export interface AgentCircuitStore {
}
export class AgentModelProtocolError extends Error {
constructor() { super("Agent model returned an invalid response"); this.name = "AgentModelProtocolError"; }
constructor(readonly diagnostic: "invalid_response" | "vision_json" | "vision_schema" | "completion_envelope" | "completion_empty" | "tool_envelope" | "tool_arguments" = "invalid_response") {
super("Agent model returned an invalid response"); this.name = "AgentModelProtocolError";
}
}
const logger = createLogger("canvas-agent-model");
const COMPATIBLE = new Set(["vip98k", "openai", "openai-compatible", "alibaba", "qwen", "local-qwen", "local", "zhipu", "moonshot", "deepseek", "xai", "siliconflow", "custom"]);
export function isAgentMultimodalCandidate(entry: AgentModelEntry) {
@ -65,11 +70,14 @@ export function createAgentModelFailover(entries: AgentModelEntry[], circuit: Ag
return async function run<T>(execute: (entry: AgentModelEntry, signal: AbortSignal) => Promise<T>, signal?: AbortSignal): Promise<T> {
const deadline = AbortSignal.timeout(150_000);
const totalSignal = signal ? AbortSignal.any([signal, deadline]) : deadline;
const ordered = [...entries].sort((a, b) => Number(b.model.id === preferred) - Number(a.model.id === preferred));
// The official DeepSeek channel is always tried first, including after a
// previous round used a backup. It is explicitly exempt from quarantine.
const official = (entry: AgentModelEntry) => isDeepSeekOfficialUrl(entry.provider.baseUrl);
const ordered = [...entries].sort((a, b) => Number(official(b)) - Number(official(a)) || Number(b.model.id === preferred) - Number(a.model.id === preferred));
let last: unknown;
for (const entry of ordered) {
totalSignal.throwIfAborted();
if (await circuit.isPaused(entry)) continue;
if (!official(entry) && await circuit.isPaused(entry)) continue;
const attemptSignal = AbortSignal.any([totalSignal, AbortSignal.timeout(AGENT_MODEL_ATTEMPT_MS)]);
try {
const result = await execute(entry, attemptSignal);
@ -81,8 +89,14 @@ export function createAgentModelFailover(entries: AgentModelEntry[], circuit: Ag
const fault = agentModelFault(attemptSignal.aborted && !totalSignal.aborted
? Object.assign(new Error("Agent model timed out"), { name: "TimeoutError" }) : error, totalSignal);
if (!fault) throw error;
await circuit.fail(entry, fault);
last = new Error(`Agent 模型通道暂不可用(${fault.reason}),故障通道已暂停 30 分钟`);
// Record only fixed diagnostic codes, never upstream text, prompts or keys.
logger.warn("model_attempt_incomplete", { modelId: entry.model.id, status: fault.status,
diagnostic: error instanceof AgentModelProtocolError ? error.diagnostic : fault.reason,
quarantined: !official(entry) });
// A single failed request can still use a backup, but must neither
// classify nor suspend the official channel for subsequent requests.
if (!official(entry)) await circuit.fail(entry, fault);
last = new Error("Agent 模型请求暂未完成,正在尝试可用通道");
}
}
throw last || new Error("Agent 多模态通道暂不可用,故障通道正在冷却,请稍后重试");

View File

@ -41,16 +41,16 @@ export function completionResponse(payload: RecordValue, request: RecordValue) {
const choice = payload?.choices?.[0];
const message = choice?.message;
if (choice?.finish_reason === "content_filter" || message?.refusal) throw new Error("content policy rejection");
if (payload?.error || !message || !["stop", "tool_calls", "function_call"].includes(choice.finish_reason)) throw new AgentModelProtocolError();
if (payload?.error || !message || !["stop", "tool_calls", "function_call"].includes(choice.finish_reason)) throw new AgentModelProtocolError("completion_envelope");
const calls = message.tool_calls || [];
if ((message.content != null && typeof message.content !== "string") || !Array.isArray(calls) || (!message.content && !calls.length)) throw new AgentModelProtocolError();
if ((message.content != null && typeof message.content !== "string") || !Array.isArray(calls) || (!message.content && !calls.length)) throw new AgentModelProtocolError("completion_empty");
const allowed = new Set((request.tools || []).map((tool: RecordValue) => tool.function?.name));
const seenIds = new Set<string>();
for (const call of calls) {
if (!call || typeof call.id !== "string" || !call.id || seenIds.has(call.id) || call.type !== "function" || !allowed.has(call.function?.name) || typeof call.function?.arguments !== "string") throw new AgentModelProtocolError();
if (!call || typeof call.id !== "string" || !call.id || seenIds.has(call.id) || call.type !== "function" || !allowed.has(call.function?.name) || typeof call.function?.arguments !== "string") throw new AgentModelProtocolError("tool_envelope");
seenIds.add(call.id);
try { const args = JSON.parse(call.function.arguments); if (!args || typeof args !== "object" || Array.isArray(args)) throw new Error(); }
catch { throw new AgentModelProtocolError(); }
catch { throw new AgentModelProtocolError("tool_arguments"); }
}
if (!request.stream) return Response.json(payload);
const base = { id: payload.id || `chatcmpl-${crypto.randomUUID()}`, model: payload.model || request.model, object: "chat.completion.chunk", created: payload.created || Math.floor(Date.now() / 1000) };

View File

@ -70,6 +70,13 @@ export async function createZcodeModelGateway(configured: ConfiguredModel, onErr
attemptSignal.throwIfAborted();
const request = target.apiType === "openai-responses" ? toResponsesRequest(body) : { ...body };
delete request.stream_options;
if (isDeepSeekOfficialUrl(target.endpoint.href)) {
// The native core explicitly sends thinking=enabled, overriding
// the provider default. Its history omits reasoning_content, so
// enforce the compatible tool protocol at this Agent boundary.
request.thinking = { type: "disabled" };
delete request.reasoning_effort;
}
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}` },

View File

@ -14,18 +14,21 @@ test("DeepSeek official tool rounds retain images and use the compatible non-thi
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 first = await gateway.forward({ ...body, stream: false, messages }, "/v1/chat/completions", new AbortController().signal);
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, stream: false, messages }, "/v1/chat/completions", new AbortController().signal);
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; }
});

View File

@ -54,6 +54,30 @@ test("invalid input, content policy and cancellation neither quarantine nor retr
assert.equal(s.faults.size, 0);
});
test("official DeepSeek ignores old cooldowns, is never quarantined, and remains first after a backup round", async () => {
const primary = entry("official", {}, { type: "custom", baseUrl: "https://api.deepseek.com/v1" });
const backup = entry("backup"); const calls: string[] = []; const faults: string[] = [];
const run = createAgentModelFailover([backup, primary], {
isPaused: async e => { assert.notEqual(e.model.id, "official"); return false; },
fail: async e => { faults.push(e.model.id); },
});
const execute = async (e: AgentModelEntry) => { calls.push(e.model.id); if (e === primary) throw new AgentModelProtocolError(); return "ok"; };
assert.equal(await run(execute), "ok");
assert.equal(await run(execute), "ok");
assert.deepEqual(calls, ["official", "backup", "official", "backup"]);
assert.deepEqual(faults, []);
calls.length = 0;
assert.equal(await run(async e => { calls.push(e.model.id); return "recovered"; }), "recovered");
assert.deepEqual(calls, ["official"]);
});
test("lookalike domains receive no official DeepSeek exemption", async () => {
const s = store();
const fake = entry("fake", {}, { baseUrl: "https://api.deepseek.com.other.invalid/v1" });
await assert.rejects(createAgentModelFailover([fake], s.circuit)(async () => { throw new AgentModelProtocolError(); }));
assert(s.faults.has("fake"));
});
test("every eligible backup is considered once; a fixed small retry count cannot strand a healthy later model", async () => {
const s = store(); const calls: string[] = [];
await assert.rejects(createAgentModelFailover(["a", "b", "c", "d", "e"].map(id => entry(id)), s.circuit)(async e => { calls.push(e.model.id); throw Object.assign(new Error("busy"), { status: 503 }); }));