mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
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:
committed by
GitHub
parent
30ebe0ae2e
commit
41eb0091a2
@@ -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))
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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))) {
|
||||
|
||||
55
tests/unit/web-tools-translation-3260.test.ts
Normal file
55
tests/unit/web-tools-translation-3260.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user