mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 07:42:13 +03:00
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:
committed by
GitHub
parent
10ae17975d
commit
e4b3478f47
@@ -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))
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
151
tests/unit/deepseek-web-tools-nameless-5154.test.ts
Normal file
151
tests/unit/deepseek-web-tools-nameless-5154.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user