mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-24 16:42:35 +03:00
Compare commits
1 Commits
fix/cli-co
...
fix/14208-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd1df1f9ff |
@@ -0,0 +1 @@
|
||||
- fix(sse): parse deepseek-web double-pipe DSML invoke/parameter tool-call markup (#14208)
|
||||
@@ -470,6 +470,40 @@ function extractCall(
|
||||
return { name, arguments: toArgumentsString(argsValue) };
|
||||
}
|
||||
|
||||
// ── DSML invoke-markup normalization ────────────────────────────────────────
|
||||
//
|
||||
// Some DeepSeek-web harness builds emit tool calls wrapped in a different, well-formed
|
||||
// grammar using doubled full-width-pipe "DSML" namespace markers with space-separated
|
||||
// structural words instead of the `<tool>`/`<tool_call>` vocabulary above:
|
||||
// <||DSML|| calls> <||DSML|| invoke name="write">
|
||||
// <||DSML|| parameter name="file_path" string="true">...</||DSML|| parameter>
|
||||
// </||DSML|| invoke> </||DSML|| calls>
|
||||
// (see #14208). Neither TAG_TOKEN_RE above nor the generic single-pipe `dsmlToolCalls.ts`
|
||||
// normalizer recognize this double-pipe shape. Rather than teach every downstream consumer
|
||||
// this namespace, rewrite it into the canonical `<tool>`/`<parameter>` tags this file's own
|
||||
// tokenizer already understands (same pattern the `dsmlToolCalls.ts` module documents for
|
||||
// DeepSeek's single-pipe `<|DSML|:Tool>` shape), before tokenization runs.
|
||||
const DSML_INVOKE_OPEN_RE = /<[||]{1,2}DSML[||]{1,2}\s+(calls|invoke|parameter)([^>]*)>/gi;
|
||||
const DSML_INVOKE_CLOSE_RE = /<\/[||]{1,2}DSML[||]{1,2}\s+(calls|invoke|parameter)\s*>/gi;
|
||||
|
||||
function normalizeDsmlInvokeMarkup(text: string): string {
|
||||
if (!text.includes("DSML")) return text;
|
||||
|
||||
const withOpens = text.replace(DSML_INVOKE_OPEN_RE, (_full, word: string, attrs: string) => {
|
||||
const tag = word.toLowerCase();
|
||||
if (tag === "calls") return "";
|
||||
const name = getAttr(attrs, "name");
|
||||
const nameAttr = name !== null ? ` name="${name}"` : "";
|
||||
return tag === "invoke" ? `<tool${nameAttr}>` : `<parameter${nameAttr}>`;
|
||||
});
|
||||
|
||||
return withOpens.replace(DSML_INVOKE_CLOSE_RE, (_full, word: string) => {
|
||||
const tag = word.toLowerCase();
|
||||
if (tag === "calls") return "";
|
||||
return tag === "invoke" ? "</tool>" : "</parameter>";
|
||||
});
|
||||
}
|
||||
|
||||
// ── Public parser ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -488,6 +522,8 @@ export function parseDeepSeekToolCalls(
|
||||
return { content: text ?? "", toolCalls: null };
|
||||
}
|
||||
|
||||
text = normalizeDsmlInvokeMarkup(text);
|
||||
|
||||
const tokens = tokenizeToolTags(text);
|
||||
if (tokens.length === 0) {
|
||||
// No DeepSeek-specific tags — defer to the proven canonical parser (bare JSON, etc.).
|
||||
|
||||
63
tests/unit/deepseek-web-tools-dsml-invoke.test.ts
Normal file
63
tests/unit/deepseek-web-tools-dsml-invoke.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// Coverage for the #14208 DSML invoke/parameter normalization in
|
||||
// open-sse/translator/deepseekWebTools.ts::normalizeDsmlInvokeMarkup (applied at the top of
|
||||
// parseDeepSeekToolCalls). See issue-14208-deepseek-web-dsml-invoke-format.test.ts for the
|
||||
// exact reported-payload repro; this file covers the surrounding contract.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseDeepSeekToolCalls } from "../../open-sse/translator/deepseekWebTools.ts";
|
||||
|
||||
const WRITE_TOOL = [
|
||||
{
|
||||
type: "function",
|
||||
function: { name: "write", parameters: { properties: { file_path: {}, content: {} } } },
|
||||
},
|
||||
];
|
||||
|
||||
test("deepseek-web DSML invoke: double-pipe DSML markup with Cyrillic content and a Windows path resolves to a write call", () => {
|
||||
const raw = `<||DSML|| calls> <||DSML|| invoke name="write"> <||DSML|| parameter name="file_path" string="true">D:\\Проекты\\файл.txt</||DSML|| parameter> <||DSML|| parameter name="content" string="true">Привет, мир.</||DSML|| parameter> </||DSML|| invoke> </||DSML|| calls>`;
|
||||
|
||||
const { content, toolCalls } = parseDeepSeekToolCalls(raw, "call", WRITE_TOOL);
|
||||
|
||||
assert.ok(toolCalls, "expected a parsed tool call");
|
||||
assert.equal(toolCalls!.length, 1);
|
||||
assert.equal(toolCalls![0].function.name, "write");
|
||||
const args = JSON.parse(toolCalls![0].function.arguments);
|
||||
assert.equal(args.file_path, "D:\\Проекты\\файл.txt");
|
||||
assert.equal(args.content, "Привет, мир.");
|
||||
assert.ok(!content.includes("DSML"), `raw DSML markup leaked into content: ${content}`);
|
||||
});
|
||||
|
||||
test("deepseek-web DSML invoke: single-pipe defensive variant also resolves to a write call", () => {
|
||||
const raw = `<|DSML| calls> <|DSML| invoke name="write"> <|DSML| parameter name="file_path" string="true">notes.txt</|DSML| parameter> <|DSML| parameter name="content" string="true">hello</|DSML| parameter> </|DSML| invoke> </|DSML| calls>`;
|
||||
|
||||
const { content, toolCalls } = parseDeepSeekToolCalls(raw, "call", WRITE_TOOL);
|
||||
|
||||
assert.ok(toolCalls, "expected a parsed tool call for the single-pipe DSML variant");
|
||||
assert.equal(toolCalls!.length, 1);
|
||||
assert.equal(toolCalls![0].function.name, "write");
|
||||
const args = JSON.parse(toolCalls![0].function.arguments);
|
||||
assert.equal(args.file_path, "notes.txt");
|
||||
assert.equal(args.content, "hello");
|
||||
assert.ok(!content.includes("DSML"), `raw DSML markup leaked into content: ${content}`);
|
||||
});
|
||||
|
||||
test("deepseek-web DSML invoke: an invoke naming a tool that was never requested degrades gracefully (no throw) and matches the canonical <tool> tag's existing contract", () => {
|
||||
// The DSML normalizer must not change the pre-existing name-resolution contract of the
|
||||
// canonical `<tool>` tag it rewrites into: an unresolved name still falls through via the
|
||||
// same raw-tag-name fallback `extractCall` already applies today (see #3260) — normalizing
|
||||
// must reproduce that behavior exactly, not invent a stricter one.
|
||||
const dsmlRaw = `<||DSML|| calls> <||DSML|| invoke name="delete_everything"> <||DSML|| parameter name="path" string="true">/</||DSML|| parameter> </||DSML|| invoke> </||DSML|| calls>`;
|
||||
const canonicalRaw = `<tool name="delete_everything"><parameter name="path">/</parameter></tool>`;
|
||||
|
||||
let dsmlResult: ReturnType<typeof parseDeepSeekToolCalls> | undefined;
|
||||
assert.doesNotThrow(() => {
|
||||
dsmlResult = parseDeepSeekToolCalls(dsmlRaw, "call", WRITE_TOOL);
|
||||
});
|
||||
const canonicalResult = parseDeepSeekToolCalls(canonicalRaw, "call", WRITE_TOOL);
|
||||
|
||||
assert.deepEqual(
|
||||
dsmlResult!.toolCalls?.map((c) => ({ name: c.function.name, args: c.function.arguments })),
|
||||
canonicalResult.toolCalls?.map((c) => ({ name: c.function.name, args: c.function.arguments })),
|
||||
"normalized DSML markup must resolve identically to the equivalent canonical <tool> tag"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// Repro for issue #14208 — deepseek-web "DSML" invoke/parameter markup is not parsed into
|
||||
// tool_calls. Unlike the malformed-trailing-garbage shape fixed by PR #13226
|
||||
// (`<tool>{json}` immediately followed by corrupted pseudo-tags), this shape is a
|
||||
// COMPLETE, well-nested block using a different tag vocabulary entirely:
|
||||
// <||DSML|| calls> <||DSML|| invoke name="write">
|
||||
// <||DSML|| parameter name="file_path" string="true">...</||DSML|| parameter>
|
||||
// <||DSML|| parameter name="content" string="true">...</||DSML|| parameter>
|
||||
// </||DSML|| invoke> </||DSML|| calls>
|
||||
//
|
||||
// `parseDeepSeekToolCalls` only recognizes tags literally named `tool`/`tool_call`
|
||||
// (TAG_TOKEN_RE in deepseekWebTools.ts), so it never tokenizes this text and falls back to
|
||||
// the canonical `parseToolCallsFromText`, which requires a literal `<tool>`/`<tool_call`
|
||||
// substring — also absent here. Net result: toolCalls stays null and the raw DSML text is
|
||||
// returned as content, exactly matching the issue's reported symptom.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseDeepSeekToolCalls } from "../../open-sse/translator/deepseekWebTools.ts";
|
||||
|
||||
const RAW = `<||DSML|| calls> <||DSML|| invoke name="write"> <||DSML|| parameter name="file_path" string="true">D:\\Projects\\maxnadeev.ru\\1.txt</||DSML|| parameter> <||DSML|| parameter name="content" string="true">Random text.</||DSML|| parameter> </||DSML|| invoke> </||DSML|| calls>`;
|
||||
|
||||
test("issue #14208: deepseek-web DSML invoke/parameter markup is parsed into a tool call", () => {
|
||||
const requestedTools = [
|
||||
{
|
||||
type: "function",
|
||||
function: { name: "write", parameters: { properties: { file_path: {}, content: {} } } },
|
||||
},
|
||||
];
|
||||
const { content, toolCalls } = parseDeepSeekToolCalls(RAW, "call", requestedTools);
|
||||
|
||||
assert.ok(toolCalls, "expected the DSML invoke block to be parsed into tool_calls, got null");
|
||||
assert.equal(toolCalls!.length, 1);
|
||||
assert.equal(toolCalls![0].function.name, "write");
|
||||
const args = JSON.parse(toolCalls![0].function.arguments);
|
||||
assert.equal(args.file_path, "D:\\Projects\\maxnadeev.ru\\1.txt");
|
||||
assert.equal(args.content, "Random text.");
|
||||
|
||||
// The raw DSML markers must not leak into the content shown to the user.
|
||||
assert.ok(!content.includes("DSML"), `raw DSML markup leaked into content: ${content}`);
|
||||
});
|
||||
Reference in New Issue
Block a user