From 8288a4a0128da7c3ae145eb3512e3105d61d230a Mon Sep 17 00:00:00 2001 From: VictorRP7 Date: Sat, 19 Sep 2026 00:03:34 -0300 Subject: [PATCH] =?UTF-8?q?fix(sse):=20deepseek-web=20resilience=20?= =?UTF-8?q?=E2=80=94=20premature=20session=20close=20+=20malformed=20tool-?= =?UTF-8?q?call=20recovery=20(#13226)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 {json} body, instead of a clean 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 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> --- open-sse/executors/deepseek-web.ts | 68 ++++++- open-sse/translator/deepseekWebTools.ts | 57 +++++- .../unit/deepseek-web-premature-close.test.ts | 160 ++++++++++++++++ .../unit/deepseek-web-tool-call-retry.test.ts | 176 ++++++++++++++++++ ...eek-web-tools-salvage-leading-json.test.ts | 71 +++++++ 5 files changed, 524 insertions(+), 8 deletions(-) create mode 100644 tests/unit/deepseek-web-premature-close.test.ts create mode 100644 tests/unit/deepseek-web-tool-call-retry.test.ts create mode 100644 tests/unit/deepseek-web-tools-salvage-leading-json.test.ts diff --git a/open-sse/executors/deepseek-web.ts b/open-sse/executors/deepseek-web.ts index 914024be82..da6ca56cee 100644 --- a/open-sse/executors/deepseek-web.ts +++ b/open-sse/executors/deepseek-web.ts @@ -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 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["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 && /]/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, diff --git a/open-sse/translator/deepseekWebTools.ts b/open-sse/translator/deepseekWebTools.ts index 3e2c7a1792..eb4b36f0ae 100644 --- a/open-sse/translator/deepseekWebTools.ts +++ b/open-sse/translator/deepseekWebTools.ts @@ -325,6 +325,47 @@ function buildSchemaParamMap(requestedTools: unknown): Map> 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 `` 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({ diff --git a/tests/unit/deepseek-web-premature-close.test.ts b/tests/unit/deepseek-web-premature-close.test.ts new file mode 100644 index 0000000000..c93d56c051 --- /dev/null +++ b/tests/unit/deepseek-web-premature-close.test.ts @@ -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(); + } +}); diff --git a/tests/unit/deepseek-web-tool-call-retry.test.ts b/tests/unit/deepseek-web-tool-call-retry.test.ts new file mode 100644 index 0000000000..52b6afbd94 --- /dev/null +++ b/tests/unit/deepseek-web-tool-call-retry.test.ts @@ -0,0 +1,176 @@ +// @ts-nocheck +// When DeepSeek's web session returns a reply where a `` 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 = '{"name": "get_weather", "arguments": {"city": "Pa'; +const GOOD_REPLY = '{"name": "get_weather", "arguments": {"city": "Paris"}}'; + +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(""), + "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(); + } +}); diff --git a/tests/unit/deepseek-web-tools-salvage-leading-json.test.ts b/tests/unit/deepseek-web-tools-salvage-leading-json.test.ts new file mode 100644 index 0000000000..d53597f498 --- /dev/null +++ b/tests/unit/deepseek-web-tools-salvage-leading-json.test.ts @@ -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 `{json}` body, instead of a clean `` 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 {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 ``. + const text = + 'Let me create that file.\n\n{"name": "create_file", "arguments": ' + + '{"filePath":"C:\\\\Users\\\\me\\\\script.mjs","content":"console.log(1)"}}' + + "<||DSML|| parameter>\n\n" + + "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 = + '{"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 = '{"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 {json} blocks are unaffected (no regression)", () => { + const text = + '{"name": "create_file", "arguments": {"filePath":"a.txt","content":"x"}}'; + const { toolCalls, content } = parseDeepSeekToolCalls(text, "call", TOOLS); + assert.equal(toolCalls?.length, 1); + assert.equal(toolCalls![0].function.name, "create_file"); + assert.ok(!content.includes(""), "well-formed block is still stripped from content"); + }); +});