From 41eb0091a2e797d86601b9633a88d3fb357c7631 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 6 Jun 2026 02:44:44 -0300 Subject: [PATCH] fix(sse): parse wrapper from web-cookie providers (#3260) (#3275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ds-web/deepseek-v4-pro emits tool calls wrapped as {"name":"customize-opencode"} instead of the canonical {json}. webTools.ts only matched ..., so the block was silently dropped (and when arguments were present, the surrounding tag leaked into content). Add TOOL_CALL_TAG_RE to capture the JSON body — the real tool name comes from the body, never the tag's name= attribute — and extend the early-exit + range stripping. Regression test: tests/unit/web-tools-translation-3260.test.ts (RED before, GREEN after). Existing web-tools suites stay green (26/26). --- CHANGELOG.md | 1 + open-sse/translator/webTools.ts | 21 ++++++- tests/unit/web-tools-translation-3260.test.ts | 55 +++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 tests/unit/web-tools-translation-3260.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b05e541e55..70f9d8e135 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ _Development cycle in progress — entries are added as work merges into `releas ### 🔧 Bug Fixes - **api/responses:** combo names without a slash (e.g. `paid-premium`, `n8n-text`) are no longer force-rewritten to `codex/` on `/v1/responses` — `resolveResponsesApiModel` now returns the request unchanged when the model resolves to a combo (regression from the v3.8.9 Codex WS→HTTP fallback) ([#3242](https://github.com/diegosouzapw/OmniRoute/pull/3242) — thanks @wilsonicdev; the same fix shipped via #3244, closing #3227 / #3233) +- **sse/web-tools:** web-cookie providers (e.g. `ds-web`) that wrap tool calls as `{json}` are now parsed correctly — the real tool name is read from the JSON body instead of the tag attribute, and the call is no longer silently dropped when `arguments` is absent ([#3260](https://github.com/diegosouzapw/OmniRoute/issues/3260)) --- diff --git a/open-sse/translator/webTools.ts b/open-sse/translator/webTools.ts index 43897927f4..28a94219d9 100644 --- a/open-sse/translator/webTools.ts +++ b/open-sse/translator/webTools.ts @@ -22,6 +22,10 @@ interface OpenAIToolDef { } const TOOL_BLOCK_RE = /\s*([\s\S]*?)\s*<\/tool>/g; +// Some web-cookie models (e.g. ds-web) wrap calls as `{json}` +// instead of the canonical `{json}`. Capture the JSON body — the real tool name +// lives there, never in the tag's `name="..."` attribute (#3260). +const TOOL_CALL_TAG_RE = /]*)?\s*>\s*([\s\S]*?)\s*<\/tool_call>/g; interface ToolParseCandidate { raw: string; @@ -374,7 +378,10 @@ export function parseToolCallsFromText( ): { content: string; toolCalls: OpenAIToolCall[] | null } { const requestedToolNames = getRequestedToolNames(requestedTools); const canParseBareJson = requestedToolNames.length > 0; - if (typeof text !== "string" || (!text.includes("") && !canParseBareJson)) { + if ( + typeof text !== "string" || + (!text.includes("") && !text.includes(" rangesOverlap(range, candidate))) { diff --git a/tests/unit/web-tools-translation-3260.test.ts b/tests/unit/web-tools-translation-3260.test.ts new file mode 100644 index 0000000000..4d814da43f --- /dev/null +++ b/tests/unit/web-tools-translation-3260.test.ts @@ -0,0 +1,55 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { parseToolCallsFromText } from "../../open-sse/translator/webTools.ts"; + +// Regression coverage for #3260: web-cookie providers (e.g. ds-web/deepseek-v4-pro) +// emit tool calls wrapped as `{json}` instead of the +// canonical `{json}`. The parser must read the REAL tool name from the JSON +// body, never from the tag's `name="..."` attribute, and must not silently drop the call. + +const OPENCODE_TOOL = [ + { type: "function", function: { name: "customize-opencode" } }, +]; + +const WEATHER_TOOL = [ + { + type: "function", + function: { + name: "get_weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, +]; + +describe("webTools — parseToolCallsFromText wrapper (#3260)", () => { + test("uses the JSON body name, not the tag attribute, and does not drop the call", () => { + const text = '{"name": "customize-opencode"}'; + const { content, toolCalls } = parseToolCallsFromText(text, "call", OPENCODE_TOOL); + + assert.ok(toolCalls && toolCalls.length === 1, "the tool call must not be dropped"); + assert.equal( + toolCalls[0].function.name, + "customize-opencode", + "name must come from the JSON body, not the tag attribute (\"skill\")" + ); + assert.equal(toolCalls[0].function.arguments, "{}", "missing arguments default to {}"); + assert.ok(!content.includes(" body", () => { + const text = + '{"name": "get_weather", "arguments": {"city": "Paris"}}'; + const { toolCalls } = parseToolCallsFromText(text, "call", WEATHER_TOOL); + + assert.ok(toolCalls && toolCalls.length === 1); + assert.equal(toolCalls[0].function.name, "get_weather"); + assert.deepEqual(JSON.parse(toolCalls[0].function.arguments), { city: "Paris" }); + }); + + test("still parses the canonical block (no regression)", () => { + const text = '{"name": "get_weather", "arguments": {"city": "SP"}}'; + const { toolCalls } = parseToolCallsFromText(text, "call", WEATHER_TOOL); + assert.ok(toolCalls && toolCalls.length === 1); + assert.equal(toolCalls[0].function.name, "get_weather"); + }); +});