From ec82cde61891329b1f322fa5e55abf2e5f8a3066 Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 21 Oct 2026 13:39:35 +0800 Subject: [PATCH] fix(agent): keep official DeepSeek available across task rounds --- lib/ai/canvas-agent-runtime/review.ts | 7 ++++-- lib/ai/runtime/agent-model-failover.ts | 24 +++++++++++++++---- lib/ai/zcode-agent/model-gateway-protocol.ts | 8 +++---- lib/ai/zcode-agent/model-gateway.ts | 7 ++++++ tests/integration/agent-model-gateway.test.ts | 7 ++++-- .../agent-multimodal-failover.test.ts | 24 +++++++++++++++++++ 6 files changed, 64 insertions(+), 13 deletions(-) diff --git a/lib/ai/canvas-agent-runtime/review.ts b/lib/ai/canvas-agent-runtime/review.ts index 77569ff99..cfdff6d02 100644 --- a/lib/ai/canvas-agent-runtime/review.ts +++ b/lib/ai/canvas-agent-runtime/review.ts @@ -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; - 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), diff --git a/lib/ai/runtime/agent-model-failover.ts b/lib/ai/runtime/agent-model-failover.ts index 9180ec5fb..0448028a7 100644 --- a/lib/ai/runtime/agent-model-failover.ts +++ b/lib/ai/runtime/agent-model-failover.ts @@ -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>[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(execute: (entry: AgentModelEntry, signal: AbortSignal) => Promise, signal?: AbortSignal): Promise { 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 多模态通道暂不可用,故障通道正在冷却,请稍后重试"); diff --git a/lib/ai/zcode-agent/model-gateway-protocol.ts b/lib/ai/zcode-agent/model-gateway-protocol.ts index 38d5ed678..a231d987b 100644 --- a/lib/ai/zcode-agent/model-gateway-protocol.ts +++ b/lib/ai/zcode-agent/model-gateway-protocol.ts @@ -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(); 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) }; diff --git a/lib/ai/zcode-agent/model-gateway.ts b/lib/ai/zcode-agent/model-gateway.ts index 852cf1aec..06a1ab18b 100644 --- a/lib/ai/zcode-agent/model-gateway.ts +++ b/lib/ai/zcode-agent/model-gateway.ts @@ -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}` }, diff --git a/tests/integration/agent-model-gateway.test.ts b/tests/integration/agent-model-gateway.test.ts index 50b369eb9..fc554f10c 100644 --- a/tests/integration/agent-model-gateway.test.ts +++ b/tests/integration/agent-model-gateway.test.ts @@ -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; } }); diff --git a/tests/integration/agent-multimodal-failover.test.ts b/tests/integration/agent-multimodal-failover.test.ts index c21530d2b..aef904eb3 100644 --- a/tests/integration/agent-multimodal-failover.test.ts +++ b/tests/integration/agent-multimodal-failover.test.ts @@ -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 }); }));