From e4b3478f470958ff1977ea80dc853dc2625343e8 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:57:57 -0300 Subject: [PATCH] fix(sse): resolve nameless deepseek-web tool blocks via parameter-schema match (#5154) (#5173) Integrated into release/v3.8.39. Schema-based nameless deepseek-web tool-block resolution (#5154); 6/6 tests pass on merge result (incl. ambiguous/no-match negatives + named-tag no-regression). --- CHANGELOG.md | 1 + open-sse/translator/deepseekWebTools.ts | 49 +++++- .../deepseek-web-tools-nameless-5154.test.ts | 151 ++++++++++++++++++ 3 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 tests/unit/deepseek-web-tools-nameless-5154.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4381fe077a..ffef0c5587 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ _In development — bullets added per PR; finalized at release._ ### 🔧 Bug Fixes +- **fix(sse): resolve nameless deepseek-web `` blocks via parameter-schema match** — when `chat.deepseek.com` emits a `` block with no tag suffix, no `` child, and no JSON body (only `` children), every existing name-resolution path returned `null` and the raw XML leaked to the client instead of being converted to a `tool_call`. `extractCall` now falls back to a conservative schema-based match: if the extracted parameter names are a subset of exactly one requested tool's declared schema keys, that tool name is used; zero or ambiguous (>1) schema matches still return `null` so no calls are misattributed. ([#5154](https://github.com/diegosouzapw/OmniRoute/issues/5154)) - **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) - **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) - **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) diff --git a/open-sse/translator/deepseekWebTools.ts b/open-sse/translator/deepseekWebTools.ts index 34723839f2..bc384ac5bb 100644 --- a/open-sse/translator/deepseekWebTools.ts +++ b/open-sse/translator/deepseekWebTools.ts @@ -296,6 +296,27 @@ function asString(value: unknown): string | null { return typeof value === "string" && value.length > 0 ? value : null; } +/** + * Build a map of tool name → set of parameter property keys from the requested tools array. + * Used by the nameless-block fallback to do conservative schema-based name resolution. + */ +function buildSchemaParamMap(requestedTools: unknown): Map> { + const map = new Map>(); + if (!Array.isArray(requestedTools)) return map; + for (const tool of requestedTools as OpenAIToolDef[]) { + const fn = tool?.function; + if (!fn?.name) continue; + const params = fn.parameters as Record | undefined; + const props = params?.properties; + if (props && typeof props === "object" && !Array.isArray(props)) { + map.set(fn.name, new Set(Object.keys(props as Record))); + } else { + map.set(fn.name, new Set()); + } + } + return map; +} + /** * Turn one tool block (tag name + inner text) into a name + JSON-string arguments. * Returns null when no plausible tool name can be recovered. @@ -303,7 +324,8 @@ function asString(value: unknown): string | null { function extractCall( tagName: string, innerRaw: string, - requested: RequestedToolName[] + requested: RequestedToolName[], + schemaMap?: Map> ): ExtractedCall | null { const inner = innerRaw.trim(); @@ -346,6 +368,28 @@ function extractCall( nameFromTag = false; } } + + // Nameless-block fallback (#5154): when all explicit name-resolution paths fail but the + // block has children, try a conservative schema-based match. If exactly ONE + // requested tool's parameter-schema keys are a superset of every extracted param name, + // adopt that tool name. Zero matches or ambiguous (>1) → keep returning null to avoid + // misattributing calls. + if (!name && paramObj && schemaMap && schemaMap.size > 0) { + const extractedKeys = Object.keys(paramObj); + if (extractedKeys.length > 0) { + const candidates: string[] = []; + for (const [toolName, schemaKeys] of schemaMap) { + if (schemaKeys.size > 0 && extractedKeys.every((k) => schemaKeys.has(k))) { + candidates.push(toolName); + } + } + if (candidates.length === 1) { + name = candidates[0]; + nameFromTag = false; + } + } + } + if (!name) return null; let argsValue: unknown; @@ -396,6 +440,7 @@ export function parseDeepSeekToolCalls( } const requested = getRequestedToolNames(requestedTools); + const schemaMap = buildSchemaParamMap(requestedTools); const blocks = pairToolBlocks(tokens, text.length); // Only extract from leaf blocks (no other block nested inside), so a doubled @@ -413,7 +458,7 @@ export function parseDeepSeekToolCalls( getAttr(block.open.attrs, "id") || ""; const inner = text.slice(block.innerStart, block.innerEnd); - const call = extractCall(tagName, inner, requested); + const call = extractCall(tagName, inner, requested, schemaMap); if (!call) continue; toolCalls.push({ id: `${idSeed}_${toolCalls.length}`, diff --git a/tests/unit/deepseek-web-tools-nameless-5154.test.ts b/tests/unit/deepseek-web-tools-nameless-5154.test.ts new file mode 100644 index 0000000000..9d2d766a3c --- /dev/null +++ b/tests/unit/deepseek-web-tools-nameless-5154.test.ts @@ -0,0 +1,151 @@ +// Regression test for #5154 — nameless blocks with children +// are silently dropped (raw XML leaks to client) when no child, no JSON body, +// and no tag-name suffix are present. +// +// Fix: when name resolution fails but tags extracted params, attempt +// a conservative fuzzy-name resolution using the requested tools' parameter schemas. +// If exactly one requested tool's schema keys are a superset of the extracted param names, +// adopt that tool name. If zero or >1 match, keep returning null (no misattribution). + +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { parseDeepSeekToolCalls } from "../../open-sse/translator/deepseekWebTools.ts"; + +// Tools that define parameter schemas — needed so the schema-match can fire. +const TOOLS_WITH_SCHEMAS = [ + { + type: "function", + function: { + name: "read", + description: "Read a file", + parameters: { + type: "object", + properties: { + filePath: { type: "string" }, + offset: { type: "number" }, + limit: { type: "number" }, + }, + required: ["filePath"], + }, + }, + }, + { + type: "function", + function: { + name: "bash", + description: "Run a shell command", + parameters: { + type: "object", + properties: { + command: { type: "string" }, + }, + required: ["command"], + }, + }, + }, +]; + +// Two tools that share parameter names — used to test the ambiguous case. +const AMBIGUOUS_TOOLS = [ + { + type: "function", + function: { + name: "read_file", + parameters: { + type: "object", + properties: { + filePath: { type: "string" }, + offset: { type: "number" }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "write_file", + parameters: { + type: "object", + properties: { + filePath: { type: "string" }, + offset: { type: "number" }, + }, + }, + }, + }, +]; + +describe("deepseekWebTools — nameless blocks (#5154)", () => { + test("nameless with children resolves to the unique schema match", () => { + // Reproduces issue #5154: model emits with no name, no JSON body, no tag suffix. + // Previously: extractCall returned null → raw XML leaked to the client. + // After fix: params filePath+offset uniquely match the 'read' tool schema → name recovered. + const text = `"x"675`; + const { toolCalls, content } = parseDeepSeekToolCalls(text, "call", TOOLS_WITH_SCHEMAS); + + assert.ok(toolCalls && toolCalls.length === 1, "expected exactly one tool call, got null/empty"); + assert.equal(toolCalls![0].function.name, "read", "tool name resolved via schema match"); + const args = JSON.parse(toolCalls![0].function.arguments); + assert.ok("filePath" in args, "filePath argument present"); + assert.ok("offset" in args, "offset argument present"); + assert.ok(!content.includes(" with only one matching param resolves when that param is unique to one tool", () => { + // 'command' uniquely identifies 'bash' tool. + const text = `echo hello`; + const { toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS_WITH_SCHEMAS); + + assert.ok(toolCalls && toolCalls.length === 1, "expected exactly one tool call"); + assert.equal(toolCalls![0].function.name, "bash"); + assert.deepEqual(JSON.parse(toolCalls![0].function.arguments), { command: "echo hello" }); + }); + + test("nameless is NOT resolved when params are ambiguous (>1 schema match)", () => { + // Both 'read_file' and 'write_file' have the same filePath+offset schema. + // The parser must NOT misattribute — it should return null (no tool call). + const text = `/etc/hosts0`; + const { toolCalls } = parseDeepSeekToolCalls(text, "call", AMBIGUOUS_TOOLS); + + assert.ok(!toolCalls || toolCalls.length === 0, "must not emit a call when params are ambiguous"); + }); + + test("nameless is NOT resolved when no tool schema matches the extracted params", () => { + // Param 'unknownKey' doesn't appear in any tool schema. + const text = `value`; + const { toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS_WITH_SCHEMAS); + + assert.ok(!toolCalls || toolCalls.length === 0, "must not emit a call when no schema matches"); + }); + + test("existing named blocks still work (no regression)", () => { + // Verify the pre-existing named-tag path is unaffected. + const tools = [ + { + type: "function", + function: { + name: "read", + parameters: { + type: "object", + properties: { filePath: { type: "string" } }, + }, + }, + }, + ]; + const text = `{"filePath": "/tmp/test.py"}`; + const { toolCalls } = parseDeepSeekToolCalls(text, "call", tools); + + assert.ok(toolCalls && toolCalls.length === 1); + assert.equal(toolCalls![0].function.name, "read"); + assert.deepEqual(JSON.parse(toolCalls![0].function.arguments), { filePath: "/tmp/test.py" }); + }); + + test("existing {json} canonical blocks still work (no regression)", () => { + const text = `{"name": "bash", "arguments": {"command": "ls -la"}}`; + const { toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS_WITH_SCHEMAS); + + assert.ok(toolCalls && toolCalls.length === 1); + assert.equal(toolCalls![0].function.name, "bash"); + assert.deepEqual(JSON.parse(toolCalls![0].function.arguments), { command: "ls -la" }); + }); +});