fix(sse): parse <tool_call name=...> wrapper from web-cookie providers (#3260) (#3275)

ds-web/deepseek-v4-pro emits tool calls wrapped as
<tool_call name="skill">{"name":"customize-opencode"}</tool_call> instead of the
canonical <tool>{json}</tool>. webTools.ts only matched <tool>...</tool>, 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).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-06 02:44:44 -03:00
committed by GitHub
parent 30ebe0ae2e
commit 41eb0091a2
3 changed files with 76 additions and 1 deletions

View File

@@ -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/<combo>` 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 `<tool_call name="...">{json}</tool_call>` 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))
---

View File

@@ -22,6 +22,10 @@ interface OpenAIToolDef {
}
const TOOL_BLOCK_RE = /<tool>\s*([\s\S]*?)\s*<\/tool>/g;
// Some web-cookie models (e.g. ds-web) wrap calls as `<tool_call name="...">{json}</tool_call>`
// instead of the canonical `<tool>{json}</tool>`. Capture the JSON body — the real tool name
// lives there, never in the tag's `name="..."` attribute (#3260).
const TOOL_CALL_TAG_RE = /<tool_call(?:\s+[^>]*)?\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("<tool>") && !canParseBareJson)) {
if (
typeof text !== "string" ||
(!text.includes("<tool>") && !text.includes("<tool_call") && !canParseBareJson)
) {
return { content: text ?? "", toolCalls: null };
}
@@ -394,6 +401,18 @@ export function parseToolCallsFromText(
});
}
TOOL_CALL_TAG_RE.lastIndex = 0;
while ((blockMatch = TOOL_CALL_TAG_RE.exec(text)) !== null) {
const range = { start: blockMatch.index, end: TOOL_CALL_TAG_RE.lastIndex };
toolBlockRanges.push(range);
candidates.push({
raw: blockMatch[1].trim(),
start: range.start,
end: range.end,
requireRequestedTool: false,
});
}
if (canParseBareJson) {
for (const candidate of findBareJsonCandidates(text)) {
if (!toolBlockRanges.some((range) => rangesOverlap(range, candidate))) {

View File

@@ -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 `<tool_call name="...">{json}</tool_call>` instead of the
// canonical `<tool>{json}</tool>`. 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 <tool_call name=...> wrapper (#3260)", () => {
test("uses the JSON body name, not the tag attribute, and does not drop the call", () => {
const text = '<tool_call name="skill">{"name": "customize-opencode"}</tool_call>';
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("<tool_call"), "the wrapper must be stripped from content");
});
test("parses arguments inside the <tool_call> body", () => {
const text =
'<tool_call name="function">{"name": "get_weather", "arguments": {"city": "Paris"}}</tool_call>';
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 <tool> block (no regression)", () => {
const text = '<tool>{"name": "get_weather", "arguments": {"city": "SP"}}</tool>';
const { toolCalls } = parseToolCallsFromText(text, "call", WEATHER_TOOL);
assert.ok(toolCalls && toolCalls.length === 1);
assert.equal(toolCalls[0].function.name, "get_weather");
});
});