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).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-27 12:57:57 -03:00
committed by GitHub
parent 10ae17975d
commit e4b3478f47
3 changed files with 199 additions and 2 deletions

View File

@@ -37,6 +37,7 @@ _In development — bullets added per PR; finalized at release._
### 🔧 Bug Fixes
- **fix(sse): resolve nameless deepseek-web `<tool>` blocks via parameter-schema match** — when `chat.deepseek.com` emits a `<tool>` block with no tag suffix, no `<name>` child, and no JSON body (only `<parameter name="…">` 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))

View File

@@ -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<string, Set<string>> {
const map = new Map<string, Set<string>>();
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<string, unknown> | undefined;
const props = params?.properties;
if (props && typeof props === "object" && !Array.isArray(props)) {
map.set(fn.name, new Set(Object.keys(props as Record<string, unknown>)));
} 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<string, Set<string>>
): 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 <parameter> 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}`,

View File

@@ -0,0 +1,151 @@
// Regression test for #5154 — nameless <tool> blocks with <parameter> children
// are silently dropped (raw XML leaks to client) when no <name> child, no JSON body,
// and no tag-name suffix are present.
//
// Fix: when name resolution fails but <parameter> 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 <tool> blocks (#5154)", () => {
test("nameless <tool> with <parameter> children resolves to the unique schema match", () => {
// Reproduces issue #5154: model emits <tool> 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 = `<tool><parameter name="filePath">"x"</parameter><parameter name="offset">675</parameter></tool>`;
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("<tool"), "tool block stripped from content");
});
test("nameless <tool> with only one matching param resolves when that param is unique to one tool", () => {
// 'command' uniquely identifies 'bash' tool.
const text = `<tool><parameter name="command">echo hello</parameter></tool>`;
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 <tool> 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 = `<tool><parameter name="filePath">/etc/hosts</parameter><parameter name="offset">0</parameter></tool>`;
const { toolCalls } = parseDeepSeekToolCalls(text, "call", AMBIGUOUS_TOOLS);
assert.ok(!toolCalls || toolCalls.length === 0, "must not emit a call when params are ambiguous");
});
test("nameless <tool> is NOT resolved when no tool schema matches the extracted params", () => {
// Param 'unknownKey' doesn't appear in any tool schema.
const text = `<tool><parameter name="unknownKey">value</parameter></tool>`;
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 <tool:read> 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 = `<tool:read>{"filePath": "/tmp/test.py"}</tool>`;
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 <tool>{json}</tool> canonical blocks still work (no regression)", () => {
const text = `<tool>{"name": "bash", "arguments": {"command": "ls -la"}}</tool>`;
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" });
});
});