mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-20 05:42:19 +03:00
fix(sse): deepseek-web resilience — premature session close + malformed tool-call recovery (#13226)
* fix(sse): deepseek-web collectSSEContent no longer returns a silent partial stub on premature session close
collectSSEContent() (used for the deepseek-web tool-calling / non-stream path)
drained the upstream SSE body and returned whatever content it had once the
reader reported done, with no check that DeepSeek had actually signalled
completion via response/status: "FINISHED".
When the upstream cookie session drops mid-generation (expired session,
anti-bot challenge, network interruption), the HTTP body simply closes
early. That was indistinguishable from a real completion: execute()
returned HTTP 200 with finish_reason "stop" and whatever partial stub text
had arrived so far. Observed in production call logs: a lone "I'll check
that..." / "Vou verificar..." with no continuation, reported as a
successful completion.
collectSSEContent now tracks whether the FINISHED status event was seen. If
the stream ends without it, it throws instead of returning the stub -
execute()'s existing try/catch turns that into a proper 502 that the
client, or a combo's retry/fallback logic, can react to.
Added tests/unit/deepseek-web-premature-close.test.ts covering both the
premature-close error path and the normal FINISHED completion path. Full
deepseek-web unit suite (97 tests) still passes.
* fix(sse): recover malformed deepseek-web tool-call replies and retry when unrecoverable
Two related failure modes on the deepseek-web tool-calling path, both
observed in production call logs from real agentic (VS Code Copilot-style)
usage of the deepseek combo:
1. DeepSeek's web session occasionally leaks malformed/internal formatting
tokens right after an otherwise-complete <tool>{json} body, instead of
a clean </tool> close (observed: a fully valid create_file JSON call
immediately followed by corrupted pseudo-tags). parseLooseJsonObject's
strict JSON.parse rejected the whole block over that trailing garbage,
even though a perfectly valid object sat at the start - so the call was
silently dropped and the raw tagged text was shown to the user instead
of the file being created.
deepseekWebTools.ts: added salvageLeadingJsonObject(), a quote/escape
aware balanced-brace scanner that recovers just the leading JSON object
when the strict parse fails, reusing the same salvage idea already used
elsewhere in this file (findBareJsonCandidates) for bare-JSON detection.
2. When even that salvage cannot recover a call (genuinely truncated JSON,
garbled beyond repair), execute() previously gave up on the first try.
Since this is a scraped, non-deterministic web session rather than a
real API, simply asking again is usually enough to get a clean reply.
deepseek-web.ts: the hasTools branch now detects an unparsed <tool...>
tag surviving in the cleaned content and retries with a brand-new
session, bounded to MAX_TOOL_PARSE_ATTEMPTS (2) - never an unbounded
retry loop, and a reply that parses cleanly on the first try costs no
extra latency.
Builds on the collectSSEContent premature-close fix from the same PR -
that one covers the upstream session dropping mid-stream; this one covers
the session completing but returning malformed tool-call content.
Testing:
- tests/unit/deepseek-web-tools-salvage-leading-json.test.ts (4 tests):
recovery from the exact production-observed corruption pattern, escaped
quotes/nested braces before the garbage, correct non-promotion of
genuinely truncated JSON, and no regression on well-formed blocks.
- tests/unit/deepseek-web-tool-call-retry.test.ts (3 tests): retry
succeeds on a fresh session, retry is bounded (gives up after
MAX_TOOL_PARSE_ATTEMPTS and surfaces the raw content rather than
looping forever), and a clean first reply never triggers a retry.
- Full deepseek-web unit suite: 104/104 passing, no regressions.
- The salvage fix was additionally verified directly against the exact
malformed content captured from a live production call log (not just
the hand-written test fixture).
---------
Co-authored-by: VictorRP7 <187780317+VictorRP7@users.noreply.github.com>
This commit is contained in:
@@ -377,6 +377,12 @@ async function collectSSEContent(
|
||||
let content = "";
|
||||
let reasoningContent = "";
|
||||
let currentPath: "thinking" | "content" | "" = "";
|
||||
// Track whether DeepSeek actually signalled completion (`response/status: "FINISHED"`).
|
||||
// Without this, an upstream session drop (expired cookie, anti-bot challenge, network
|
||||
// hiccup) mid-stream was silently reported as a normal "stop" completion with whatever
|
||||
// partial content had arrived so far — e.g. just "I'll check that..." with no follow-up,
|
||||
// HTTP 200, finish_reason "stop". Confirmed in production call logs.
|
||||
let sawFinished = false;
|
||||
const streamModel = model || "deepseek-web";
|
||||
const thinkingModel = isThinkingModel(streamModel);
|
||||
const searchResults: DeepSeekSearchResult[] = [];
|
||||
@@ -423,6 +429,8 @@ async function collectSSEContent(
|
||||
const p = data?.p;
|
||||
const v = data?.v;
|
||||
|
||||
if (p === "response/status" && v === "FINISHED") sawFinished = true;
|
||||
|
||||
if (v && typeof v === "object" && v.response) {
|
||||
if (v.response.thinking_enabled === true) currentPath = "thinking";
|
||||
else if (v.response.thinking_enabled === false) currentPath = "content";
|
||||
@@ -484,6 +492,18 @@ async function collectSSEContent(
|
||||
const citations = appendSearchCitations(searchResults, streamModel);
|
||||
if (citations) content += `\n\n${citations}`;
|
||||
|
||||
// The upstream HTTP body closed without ever sending `response/status: "FINISHED"`.
|
||||
// That means the DeepSeek web session was cut off mid-generation (expired cookie,
|
||||
// anti-bot challenge, network drop, etc.) rather than genuinely completing. Surface
|
||||
// this as an error (caught by execute()'s try/catch -> 502) instead of returning the
|
||||
// partial stub as a successful "stop" response.
|
||||
if (!sawFinished) {
|
||||
throw new Error(
|
||||
"DeepSeek web session ended before completion (no FINISHED signal received) — " +
|
||||
"likely a dropped cookie session or network interruption upstream. Retry the request."
|
||||
);
|
||||
}
|
||||
|
||||
return { content, reasoningContent };
|
||||
}
|
||||
|
||||
@@ -1079,13 +1099,49 @@ export class DeepSeekWebExecutor extends BaseExecutor {
|
||||
// OpenAI tool_calls. Buffering (even for stream clients) is acceptable because
|
||||
// tool invocations are short and need the complete block to parse. (#2820)
|
||||
if (hasTools) {
|
||||
const { content, reasoningContent } = await collectSSEContent(resp.body!, clientModel);
|
||||
// The scraped web session occasionally returns a malformed reply where DeepSeek
|
||||
// clearly attempted a tool call (a literal <tool...> tag is present) but the block
|
||||
// could not be parsed even with salvageLeadingJsonObject's recovery (genuinely
|
||||
// truncated JSON, garbled beyond repair, etc). Unlike a real API, this upstream is
|
||||
// non-deterministic enough that simply asking again with a fresh session usually
|
||||
// succeeds — so retry a bounded number of times before giving up and surfacing the
|
||||
// raw (still-tagged) text to the caller.
|
||||
const MAX_TOOL_PARSE_ATTEMPTS = 2;
|
||||
let content = "";
|
||||
let reasoningContent = "";
|
||||
let cleanedContent = "";
|
||||
let toolCalls: ReturnType<typeof parseDeepSeekToolCalls>["toolCalls"] = null;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_TOOL_PARSE_ATTEMPTS; attempt += 1) {
|
||||
({ content, reasoningContent } = await collectSSEContent(resp.body!, clientModel));
|
||||
({ content: cleanedContent, toolCalls } = parseDeepSeekToolCalls(
|
||||
content,
|
||||
`call-${Date.now()}`,
|
||||
requestedTools
|
||||
));
|
||||
|
||||
const unparsedToolTagRemains =
|
||||
!toolCalls && /<tool(?:_call)?[\s:>]/i.test(cleanedContent);
|
||||
if (!unparsedToolTagRemains || attempt === MAX_TOOL_PARSE_ATTEMPTS) break;
|
||||
|
||||
log?.warn?.(
|
||||
"DEEPSEEK-WEB",
|
||||
`Malformed tool-call reply on attempt ${attempt}/${MAX_TOOL_PARSE_ATTEMPTS} — retrying with a fresh session`
|
||||
);
|
||||
if (persistSession) sessionCache.delete(userToken);
|
||||
sessionId = await createSession(accessToken, signal);
|
||||
if (persistSession) {
|
||||
evictOldest(sessionCache);
|
||||
sessionCache.set(userToken, { sessionId, createdAt: Date.now() });
|
||||
}
|
||||
const retried = await performCompletion(sessionId);
|
||||
resp = retried.resp;
|
||||
reqHeaders = retried.reqHeaders;
|
||||
requestPayload = retried.requestPayload;
|
||||
if (!resp.ok) break; // fall through — final content/toolCalls stay from the last successful attempt
|
||||
}
|
||||
|
||||
await cleanupFn();
|
||||
const { content: cleanedContent, toolCalls } = parseDeepSeekToolCalls(
|
||||
content,
|
||||
`call-${Date.now()}`,
|
||||
requestedTools
|
||||
);
|
||||
return buildToolAwareResult({
|
||||
stream: stream !== false,
|
||||
clientModel,
|
||||
|
||||
@@ -325,6 +325,47 @@ function buildSchemaParamMap(requestedTools: unknown): Map<string, Set<string>>
|
||||
return map;
|
||||
}
|
||||
|
||||
// DeepSeek's web session occasionally leaks malformed/internal formatting tokens right
|
||||
// after an otherwise-complete JSON tool call body (observed in production: a valid
|
||||
// `{"name": ..., "arguments": {...}}` object immediately followed by corrupted
|
||||
// pseudo-tags instead of a clean `</tool>` close). `parseLooseJsonObject` uses a strict
|
||||
// `JSON.parse`, which rejects the whole string over that trailing garbage even though a
|
||||
// perfectly valid object sits right at the start. This scans for the first balanced
|
||||
// `{...}` object (quote/escape aware) and returns just that slice, so it can still be
|
||||
// parsed on its own.
|
||||
function salvageLeadingJsonObject(text: string): string | null {
|
||||
const start = text.indexOf("{");
|
||||
if (start === -1) return null;
|
||||
let depth = 0;
|
||||
let quote: '"' | "'" | "" = "";
|
||||
let escaped = false;
|
||||
for (let i = start; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (ch === "\\") escaped = true;
|
||||
else if (ch === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch as '"' | "'";
|
||||
continue;
|
||||
}
|
||||
if (ch === "{") {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return text.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
return null; // never balanced — genuinely truncated, nothing to salvage
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn one tool block (tag name + inner text) into a name + JSON-string arguments.
|
||||
* Returns null when no plausible tool name can be recovered.
|
||||
@@ -342,7 +383,13 @@ function extractCall(
|
||||
const paramObj = argsChild ? null : buildArgsFromParameters(inner);
|
||||
const hasXmlChildren = !!nameChild || !!argsChild || !!paramObj;
|
||||
|
||||
const json = hasXmlChildren ? null : parseLooseJsonObject(inner);
|
||||
let json = hasXmlChildren ? null : parseLooseJsonObject(inner);
|
||||
if (!json && !hasXmlChildren) {
|
||||
// Strict parse failed — try salvaging a complete JSON object from the start of the
|
||||
// block even if trailing content after it is malformed (see salvageLeadingJsonObject).
|
||||
const salvaged = salvageLeadingJsonObject(inner);
|
||||
if (salvaged) json = parseLooseJsonObject(salvaged);
|
||||
}
|
||||
const jsonName = json ? (asString(json.name) ?? asString(json.type)) : null;
|
||||
|
||||
const childResolved = nameChild ? resolveRequestedToolName(nameChild, requested) : null;
|
||||
@@ -479,7 +526,13 @@ export function parseDeepSeekToolCalls(
|
||||
// A missing _nonce is tolerated for backward compatibility.
|
||||
if (nonce) {
|
||||
const parsed = parseLooseJsonObject(inner);
|
||||
if (parsed && typeof parsed.name === "string" && parsed._nonce !== undefined && parsed._nonce !== nonce) continue;
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed.name === "string" &&
|
||||
parsed._nonce !== undefined &&
|
||||
parsed._nonce !== nonce
|
||||
)
|
||||
continue;
|
||||
}
|
||||
|
||||
toolCalls.push({
|
||||
|
||||
160
tests/unit/deepseek-web-premature-close.test.ts
Normal file
160
tests/unit/deepseek-web-premature-close.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
// @ts-nocheck
|
||||
// deepseek-web's non-stream/tool-call path (collectSSEContent) drains the upstream SSE
|
||||
// body and returns whatever content it collected once the reader reports `done` — with
|
||||
// no check that DeepSeek actually signalled completion via `response/status: "FINISHED"`.
|
||||
// When the upstream cookie session drops mid-generation (expired session, anti-bot
|
||||
// challenge, network interruption), the HTTP body simply closes early. Before this fix,
|
||||
// that premature close was indistinguishable from a real completion: execute() returned
|
||||
// HTTP 200 with `finish_reason: "stop"` and whatever partial stub text had arrived so far
|
||||
// (observed in production: a lone "I'll check that..." with no continuation). The caller
|
||||
// has no way to know the task was never actually finished, so it looks like the model just
|
||||
// stopped mid-task.
|
||||
//
|
||||
// Fix: collectSSEContent now tracks whether the FINISHED status event was seen. If the
|
||||
// stream ends without it, it throws instead of returning the stub — execute()'s existing
|
||||
// try/catch turns that into a proper 502 the client (or a combo's retry/fallback logic)
|
||||
// can react to.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const dsMod = await import("../../open-sse/executors/deepseek-web.ts");
|
||||
const { DeepSeekWebExecutor } = dsMod;
|
||||
|
||||
const POW_CHALLENGE = {
|
||||
algorithm: "DeepSeekHashV1",
|
||||
challenge: "311b26ae1e0fe7375e242958ce46db5552a6c67fea3f96880dcd846c63a74286",
|
||||
salt: "1122334455667788",
|
||||
signature: "sig123",
|
||||
difficulty: 1,
|
||||
expire_at: 1778891543095,
|
||||
expire_after: 300000,
|
||||
target_path: "/api/v0/chat/completion",
|
||||
};
|
||||
|
||||
// Same shape as a real completion, but the upstream body closes right after the partial
|
||||
// text fragment — no `response/status: "FINISHED"` line ever arrives. This is what a
|
||||
// dropped cookie session / anti-bot cutoff / network interruption looks like on the wire.
|
||||
function sseWithPrematureClose(text) {
|
||||
return [
|
||||
"event: ready\n",
|
||||
'data: {"request_message_id":1,"response_message_id":2}\n',
|
||||
"\n",
|
||||
`data: ${JSON.stringify({ v: { response: { message_id: 2, fragments: [{ id: 1, type: "RESPONSE", content: text }] } } })}\n`,
|
||||
"\n",
|
||||
// (no response/status FINISHED event, no close event — body just ends here)
|
||||
].join("");
|
||||
}
|
||||
|
||||
function sseWithFinished(text) {
|
||||
return [
|
||||
"event: ready\n",
|
||||
'data: {"request_message_id":1,"response_message_id":2}\n',
|
||||
"\n",
|
||||
`data: ${JSON.stringify({ v: { response: { message_id: 2, fragments: [{ id: 1, type: "RESPONSE", content: text }] } } })}\n`,
|
||||
"\n",
|
||||
'data: {"p":"response/status","o":"SET","v":"FINISHED"}\n',
|
||||
"\n",
|
||||
"event: close\n",
|
||||
'data: {"click_behavior":"none"}\n',
|
||||
].join("");
|
||||
}
|
||||
|
||||
function installMock(sseBody) {
|
||||
const original = globalThis.fetch;
|
||||
dsMod.tokenCache?.clear();
|
||||
dsMod.sessionCache?.clear();
|
||||
globalThis.fetch = async (url, _opts = {}) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/users/current"))
|
||||
return new Response(
|
||||
JSON.stringify({ code: 0, data: { biz_data: { token: "access-token-xyz" } } }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
if (u.includes("/chat_session/create"))
|
||||
return new Response(
|
||||
JSON.stringify({ code: 0, data: { biz_data: { chat_session: { id: "s-1" } } } }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
if (u.includes("/chat_session/delete"))
|
||||
return new Response(JSON.stringify({ code: 0 }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (u.includes("/create_pow_challenge"))
|
||||
return new Response(
|
||||
JSON.stringify({ code: 0, data: { biz_data: { challenge: POW_CHALLENGE } } }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
if (u.includes("/chat/completion")) {
|
||||
return new Response(new TextEncoder().encode(sseBody), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
};
|
||||
return {
|
||||
restore: () => {
|
||||
globalThis.fetch = original;
|
||||
dsMod.tokenCache?.clear();
|
||||
dsMod.sessionCache?.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
description: "Get weather",
|
||||
parameters: { type: "object", properties: { city: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
test("execute (tools[], non-stream) returns an error instead of a silent partial stub when the upstream session drops before FINISHED", async () => {
|
||||
const mock = installMock(sseWithPrematureClose("I'll check the weather for you..."));
|
||||
try {
|
||||
const executor = new DeepSeekWebExecutor();
|
||||
const result = await executor.execute({
|
||||
model: "default",
|
||||
body: { messages: [{ role: "user", content: "weather in Paris?" }], tools: TOOLS },
|
||||
stream: false,
|
||||
credentials: { apiKey: "tkn-premature-close" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
assert.equal(
|
||||
result.response.status,
|
||||
502,
|
||||
"a session that closes before FINISHED must surface as an error, not HTTP 200"
|
||||
);
|
||||
const body = await result.response.text();
|
||||
assert.ok(
|
||||
/finished|premature|dropped|retry/i.test(body),
|
||||
"error message should explain the session ended before completion"
|
||||
);
|
||||
} finally {
|
||||
mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("execute (tools[], non-stream) still succeeds normally when FINISHED is received", async () => {
|
||||
const mock = installMock(sseWithFinished("Just a normal answer, no tool needed."));
|
||||
try {
|
||||
const executor = new DeepSeekWebExecutor();
|
||||
const result = await executor.execute({
|
||||
model: "default",
|
||||
body: { messages: [{ role: "user", content: "hi" }], tools: TOOLS },
|
||||
stream: false,
|
||||
credentials: { apiKey: "tkn-normal-finish" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
assert.ok(result.response.ok);
|
||||
const json = JSON.parse(await result.response.text());
|
||||
assert.equal(json.choices[0].finish_reason, "stop");
|
||||
assert.ok(json.choices[0].message.content.includes("normal answer"));
|
||||
} finally {
|
||||
mock.restore();
|
||||
}
|
||||
});
|
||||
176
tests/unit/deepseek-web-tool-call-retry.test.ts
Normal file
176
tests/unit/deepseek-web-tool-call-retry.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
// @ts-nocheck
|
||||
// When DeepSeek's web session returns a reply where a `<tool...>` tag is present but the
|
||||
// block is genuinely unparseable (even after salvageLeadingJsonObject's recovery — e.g. the
|
||||
// JSON itself is truncated), execute() now retries with a brand-new session (bounded to
|
||||
// MAX_TOOL_PARSE_ATTEMPTS) before giving up. This is the scraped-web-session equivalent of
|
||||
// retrying a flaky upstream call, since unlike a real API this provider is non-deterministic
|
||||
// enough that asking again usually just works.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const dsMod = await import("../../open-sse/executors/deepseek-web.ts");
|
||||
const { DeepSeekWebExecutor } = dsMod;
|
||||
|
||||
const POW_CHALLENGE = {
|
||||
algorithm: "DeepSeekHashV1",
|
||||
challenge: "311b26ae1e0fe7375e242958ce46db5552a6c67fea3f96880dcd846c63a74286",
|
||||
salt: "1122334455667788",
|
||||
signature: "sig123",
|
||||
difficulty: 1,
|
||||
expire_at: 1778891543095,
|
||||
expire_after: 300000,
|
||||
target_path: "/api/v0/chat/completion",
|
||||
};
|
||||
|
||||
function sseWithContent(text) {
|
||||
return [
|
||||
"event: ready\n",
|
||||
'data: {"request_message_id":1,"response_message_id":2}\n',
|
||||
"\n",
|
||||
`data: ${JSON.stringify({ v: { response: { message_id: 2, fragments: [{ id: 1, type: "RESPONSE", content: text }] } } })}\n`,
|
||||
"\n",
|
||||
'data: {"p":"response/status","o":"SET","v":"FINISHED"}\n',
|
||||
"\n",
|
||||
"event: close\n",
|
||||
'data: {"click_behavior":"none"}\n',
|
||||
].join("");
|
||||
}
|
||||
|
||||
// installMock returns replies from `replies` in order, one per /chat/completion call — so
|
||||
// the Nth upstream request (including retries) gets `replies[N-1]`.
|
||||
function installMock(replies) {
|
||||
const original = globalThis.fetch;
|
||||
const calls = { completions: 0, sessionCreates: 0 };
|
||||
dsMod.tokenCache?.clear();
|
||||
dsMod.sessionCache?.clear();
|
||||
globalThis.fetch = async (url) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/users/current"))
|
||||
return new Response(
|
||||
JSON.stringify({ code: 0, data: { biz_data: { token: "access-token-xyz" } } }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
if (u.includes("/chat_session/create")) {
|
||||
calls.sessionCreates += 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
data: { biz_data: { chat_session: { id: `s-${calls.sessionCreates}` } } },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
if (u.includes("/chat_session/delete"))
|
||||
return new Response(JSON.stringify({ code: 0 }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (u.includes("/create_pow_challenge"))
|
||||
return new Response(
|
||||
JSON.stringify({ code: 0, data: { biz_data: { challenge: POW_CHALLENGE } } }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
if (u.includes("/chat/completion")) {
|
||||
const text = replies[Math.min(calls.completions, replies.length - 1)];
|
||||
calls.completions += 1;
|
||||
return new Response(new TextEncoder().encode(sseWithContent(text)), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
restore: () => {
|
||||
globalThis.fetch = original;
|
||||
dsMod.tokenCache?.clear();
|
||||
dsMod.sessionCache?.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
parameters: { type: "object", properties: { city: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Genuinely truncated — no balanced closing brace, so even salvageLeadingJsonObject cannot
|
||||
// recover it. This is what a reply the retry must fix looks like.
|
||||
const TRUNCATED = '<tool>{"name": "get_weather", "arguments": {"city": "Pa';
|
||||
const GOOD_REPLY = '<tool>{"name": "get_weather", "arguments": {"city": "Paris"}}</tool>';
|
||||
|
||||
test("retries with a fresh session when the first reply's tool block is unparseable, and succeeds on the second attempt", async () => {
|
||||
const mock = installMock([TRUNCATED, GOOD_REPLY]);
|
||||
try {
|
||||
const executor = new DeepSeekWebExecutor();
|
||||
const result = await executor.execute({
|
||||
model: "default",
|
||||
body: { messages: [{ role: "user", content: "weather in Paris?" }], tools: TOOLS },
|
||||
stream: false,
|
||||
credentials: { apiKey: "tkn-retry-success" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
assert.ok(result.response.ok);
|
||||
const json = JSON.parse(await result.response.text());
|
||||
const choice = json.choices[0];
|
||||
assert.equal(choice.finish_reason, "tool_calls", "second attempt's valid reply must win");
|
||||
assert.equal(choice.message.tool_calls[0].function.name, "get_weather");
|
||||
assert.equal(mock.calls.completions, 2, "exactly one retry (2 completions total)");
|
||||
assert.equal(mock.calls.sessionCreates, 2, "retry uses a brand-new session, not the stale one");
|
||||
} finally {
|
||||
mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("gives up after MAX_TOOL_PARSE_ATTEMPTS and returns the raw (still-tagged) content, not an infinite retry", async () => {
|
||||
const mock = installMock([TRUNCATED, TRUNCATED, TRUNCATED]);
|
||||
try {
|
||||
const executor = new DeepSeekWebExecutor();
|
||||
const result = await executor.execute({
|
||||
model: "default",
|
||||
body: { messages: [{ role: "user", content: "weather?" }], tools: TOOLS },
|
||||
stream: false,
|
||||
credentials: { apiKey: "tkn-retry-exhausted" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
assert.ok(result.response.ok, "still HTTP 200 — a best-effort text answer, not a hard failure");
|
||||
const json = JSON.parse(await result.response.text());
|
||||
const choice = json.choices[0];
|
||||
assert.equal(choice.finish_reason, "stop");
|
||||
assert.ok(!choice.message.tool_calls, "no tool_calls on an unrecoverable reply");
|
||||
assert.ok(
|
||||
choice.message.content.includes("<tool>"),
|
||||
"raw unparsed content is surfaced, not silently dropped"
|
||||
);
|
||||
assert.equal(mock.calls.completions, 2, "bounded to MAX_TOOL_PARSE_ATTEMPTS (2), never more");
|
||||
} finally {
|
||||
mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not retry at all when the first reply parses cleanly (no wasted latency)", async () => {
|
||||
const mock = installMock([GOOD_REPLY, GOOD_REPLY, GOOD_REPLY]);
|
||||
try {
|
||||
const executor = new DeepSeekWebExecutor();
|
||||
const result = await executor.execute({
|
||||
model: "default",
|
||||
body: { messages: [{ role: "user", content: "weather?" }], tools: TOOLS },
|
||||
stream: false,
|
||||
credentials: { apiKey: "tkn-no-retry-needed" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
assert.ok(result.response.ok);
|
||||
const json = JSON.parse(await result.response.text());
|
||||
assert.equal(json.choices[0].finish_reason, "tool_calls");
|
||||
assert.equal(mock.calls.completions, 1, "a clean first reply must not trigger any retry");
|
||||
assert.equal(mock.calls.sessionCreates, 1);
|
||||
} finally {
|
||||
mock.restore();
|
||||
}
|
||||
});
|
||||
71
tests/unit/deepseek-web-tools-salvage-leading-json.test.ts
Normal file
71
tests/unit/deepseek-web-tools-salvage-leading-json.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseDeepSeekToolCalls } from "../../open-sse/translator/deepseekWebTools.ts";
|
||||
|
||||
// DeepSeek's web session occasionally leaks malformed/internal formatting tokens right after
|
||||
// an otherwise-complete `<tool>{json}` body, instead of a clean `</tool>` close. The strict
|
||||
// `JSON.parse` inside `parseLooseJsonObject` rejects the whole block over that trailing
|
||||
// garbage even though a perfectly valid object sits at the start. `salvageLeadingJsonObject`
|
||||
// recovers it by scanning for the first balanced `{...}` (quote/escape aware) and parsing
|
||||
// just that slice.
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "create_file",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { filePath: { type: "string" }, content: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("deepseekWebTools — salvage leading JSON on malformed close", () => {
|
||||
test("recovers a valid <tool>{json} block whose closing tag was replaced by garbled tokens", () => {
|
||||
// Reproduces production content observed from the deepseek-web provider: valid JSON
|
||||
// immediately followed by corrupted pseudo-tags instead of `</tool>`.
|
||||
const text =
|
||||
'Let me create that file.\n\n<tool>{"name": "create_file", "arguments": ' +
|
||||
'{"filePath":"C:\\\\Users\\\\me\\\\script.mjs","content":"console.log(1)"}}' +
|
||||
"<||DSML|| parameter>\n</||DSML|| invoke>\n</||DSML|| calls>" +
|
||||
"This response is AI-generated, for reference only.";
|
||||
|
||||
const { toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS);
|
||||
assert.ok(toolCalls && toolCalls.length === 1, "expected the malformed block to be recovered");
|
||||
assert.equal(toolCalls![0].function.name, "create_file");
|
||||
const args = JSON.parse(toolCalls![0].function.arguments);
|
||||
assert.equal(args.filePath, "C:\\Users\\me\\script.mjs");
|
||||
assert.equal(args.content, "console.log(1)");
|
||||
});
|
||||
|
||||
test("recovers a valid block even with escaped quotes and nested braces before the garbage", () => {
|
||||
const text =
|
||||
'<tool>{"name": "create_file", "arguments": {"filePath":"a.txt",' +
|
||||
'"content":"line one\\nline \\"two\\" {not json}"}}' +
|
||||
"<||DSML|| calls>trailing junk that is not valid JSON at all {{{";
|
||||
|
||||
const { toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS);
|
||||
assert.ok(toolCalls && toolCalls.length === 1);
|
||||
const args = JSON.parse(toolCalls![0].function.arguments);
|
||||
assert.equal(args.content, 'line one\nline "two" {not json}');
|
||||
});
|
||||
|
||||
test("still returns null (no promotion) when the JSON itself is genuinely truncated", () => {
|
||||
// No balanced closing brace anywhere — nothing to salvage, must not be promoted.
|
||||
const text = '<tool>{"name": "create_file", "arguments": {"filePath":"a.txt able to nev';
|
||||
const { toolCalls, content } = parseDeepSeekToolCalls(text, "call", TOOLS);
|
||||
assert.equal(toolCalls, null, "a truly truncated object must not be salvaged into a call");
|
||||
assert.equal(content, text, "unrecovered content is returned unchanged");
|
||||
});
|
||||
|
||||
test("normal, well-formed <tool>{json}</tool> blocks are unaffected (no regression)", () => {
|
||||
const text =
|
||||
'<tool>{"name": "create_file", "arguments": {"filePath":"a.txt","content":"x"}}</tool>';
|
||||
const { toolCalls, content } = parseDeepSeekToolCalls(text, "call", TOOLS);
|
||||
assert.equal(toolCalls?.length, 1);
|
||||
assert.equal(toolCalls![0].function.name, "create_file");
|
||||
assert.ok(!content.includes("<tool>"), "well-formed block is still stripped from content");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user