From 3d8bc326de3d8a55528d97a60b875f2d1ffee001 Mon Sep 17 00:00:00 2001 From: espokaos-ops <264269740+espokaos-ops@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:30:55 +0200 Subject: [PATCH 1/2] fix(deepseek-web): support native DSML tool calls --- open-sse/translator/deepseekWebTools.ts | 220 ++++++++++++- .../translator/deepseekWebTools.dsml.test.ts | 298 ++++++++++++++++++ 2 files changed, 507 insertions(+), 11 deletions(-) create mode 100644 tests/unit/translator/deepseekWebTools.dsml.test.ts diff --git a/open-sse/translator/deepseekWebTools.ts b/open-sse/translator/deepseekWebTools.ts index 3e2c7a179253..eb16c4aae242 100644 --- a/open-sse/translator/deepseekWebTools.ts +++ b/open-sse/translator/deepseekWebTools.ts @@ -14,6 +14,9 @@ // {json} doubled / nested wrappers // x{json} XML children // parameter style +// <||DSML|| calls><||DSML|| invoke name="bash">… DeepSeek native DSML dialect +// <||DSML|| parameter name="command">… (full-width | U+FF5C, +// closers are sloppy: or bare <||DSML|| invoke>) // // A single regex cannot robustly cover all of these (nesting + attributes + XML children), // so this parser tokenizes the tool tags and walks them with a stack instead. It reuses the @@ -77,7 +80,7 @@ export function serializeDeepSeekToolPrompt(tools: unknown): string { "You can call tools. To call a tool, output ONLY this exact block (no markdown fence):", `{"name": "", "arguments": { ... }, "_nonce": "${nonce}"}`, "Rules:", - "- Use exactly .... Do NOT use , , , , id=/name= attributes, or code fences.", + "- Use exactly .... Do NOT use , , , , id=/name= attributes, <||DSML||> markers, or code fences.", `- Include the secret binding "_nonce": "${nonce}" exactly as shown.`, '- "name" must be one of the tools below; "arguments" must be a JSON object.', "- When a tool is needed, emit the block instead of only describing the plan.", @@ -423,6 +426,170 @@ function extractCall( return { name, arguments: toArgumentsString(argsValue) }; } +// ── DeepSeek native DSML dialect ───────────────────────────────────────────── +// The web model sometimes ignores the `` contract above and emits its +// native pretraining-time tool dialect instead (observed live — without this +// the reply degrades to plain text and the client never executes anything): +// <||DSML|| calls> +// <||DSML|| invoke name="bash"> +// <||DSML|| parameter name="command" string="true">free -h +// <||DSML|| invoke> +// +// Markers use full-width pipes (|, U+FF5C); ASCII pipes are accepted +// defensively. Closers are sloppy in the wild (`` but bare +// `<||DSML|| invoke>` / `<||DSML|| parameter>` with no slash), so a tag +// carrying `name="…"` opens a block and the next same-kind tag closes it. +// Like the XML-children/tag-suffix shapes, DSML blocks carry no JSON body with +// a `_nonce`, so the #9343 nonce check does not apply to them. + +const DSML_PIPE = "[||]"; +const DSML_MARK = `${DSML_PIPE}{2}DSML${DSML_PIPE}{2}`; + +// Matches one DSML tag of the given logical kind (calls|invoke|parameter), +// tolerating the slash before `<`, after the mark, or missing entirely. +// Group 1 captures the raw attribute text when the tag carries any. +function dsmlTagSource(kind: string): string { + return `(?:]*)>|<${DSML_MARK}\\s*/\\s*${kind}\\s*>)`; +} + +function hasDsmlTags(text: string): boolean { + return new RegExp(`` blocks for the hybrid shape the model occasionally +// emits: `{json}…` (JSON body with a valid +// nonce, but DSML closers instead of ``). +const DSML_ANY_TAG_RE = new RegExp( + `]*>|<${DSML_MARK}\\s*/\\s*(?:calls|invoke|parameter)\\s*>`, + "gi" +); + +interface DsmlCall { + start: number; + end: number; + name: string; + args: Record; +} + +interface DsmlParse { + calls: DsmlCall[]; + // Wrapper `…` tag ranges so the envelope itself is stripped + // from the visible content together with the parsed invokes. + strip: Array<{ start: number; end: number }>; +} + +// Extract DSML calls in document order. Reuses the shared getAttr / +// resolveRequestedToolName helpers so name resolution behaves exactly like the +// `` shapes (including the unknown-tool fallthrough). +// +// Two invocation sites share one builder: `` blocks inside a +// `` envelope, and bare root-level `` blocks with no envelope +// (P7 — same conversion either way). Fail-safe rule (L2): an invoke that +// yields no parameter with a non-empty value produces NO call — an empty, +// nameless or otherwise ambiguous invoke is skipped instead of emitting a +// partially-built executable call. +interface DsmlTag { + start: number; + end: number; + attrs: string; +} + +function collectDsmlTags(source: string, kind: string, base: number): DsmlTag[] { + const out: DsmlTag[] = []; + const re = new RegExp(dsmlTagSource(kind), "gi"); + let m: RegExpExecArray | null; + while ((m = re.exec(source)) !== null) { + out.push({ start: base + m.index, end: base + re.lastIndex, attrs: m[1] ?? "" }); + } + return out; +} + +function buildDsmlCall( + open: DsmlTag, + close: DsmlTag | undefined, + text: string, + textEnd: number, + requested: RequestedToolName[] +): DsmlCall | null { + const rawName = getAttr(open.attrs, "name"); + if (!rawName) return null; // stray closer or nameless invoke — ignore + const innerEnd = close ? close.start : textEnd; + const inner = text.slice(open.end, innerEnd); + const ptags = collectDsmlTags(inner, "parameter", open.end); + const args: Record = {}; + // End of the last consumed parameter: when the invoke has no closing tag, + // the strip range ends here instead of swallowing trailing legitimate text. + let consumedEnd = open.end; + for (let k = 0; k < ptags.length; k += 1) { + const pOpen = ptags[k]; + const pName = getAttr(pOpen.attrs, "name"); + if (!pName) continue; + const pClose = ptags[k + 1]; + // Multiline-safe: value is everything up to the next parameter tag. + // Auxiliary attributes (string="true", …) are ignored; a content="…" + // attribute is only a fallback when the body is empty. + const rawBody = text.slice(pOpen.end, pClose ? pClose.start : innerEnd).trim(); + args[pName] = rawBody !== "" ? rawBody : (getAttr(pOpen.attrs, "content") ?? ""); + if (pClose) consumedEnd = pClose.end; + else consumedEnd = innerEnd; + } + // L2 fail-safe: without at least one non-empty argument value the call is + // ambiguous (empty invoke, nameless/valueless parameters) — skip it rather + // than emitting a partially-built executable call. + if (!Object.values(args).some((v) => v !== "")) return null; + const name = resolveRequestedToolName(rawName, requested) ?? rawName; + return { start: open.start, end: close ? close.end : consumedEnd, name, args }; +} + +function parseDsmlBlocks(text: string, requested: RequestedToolName[]): DsmlParse { + const out: DsmlCall[] = []; + const strip: Array<{ start: number; end: number }> = []; + const callsRe = new RegExp( + `<${DSML_MARK}\\s*calls\\s*>([\\s\\S]*?)(?:|<${DSML_MARK}\\s*/\\s*calls\\s*>)`, + "gi" + ); + const closeRe = new RegExp( + `(?:|<${DSML_MARK}\\s*/\\s*calls\\s*>)\\s*$`, + "i" + ); + const envelopeRanges: Array<{ start: number; end: number }> = []; + let cm: RegExpExecArray | null; + while ((cm = callsRe.exec(text)) !== null) { + const envelope = { start: cm.index, end: callsRe.lastIndex }; + envelopeRanges.push(envelope); + const body = cm[1]; + const bodyBase = cm.index + cm[0].indexOf(body); + const closeM = closeRe.exec(cm[0]); + strip.push({ start: cm.index, end: bodyBase }); + strip.push({ + start: cm.index + (closeM ? closeM.index : cm[0].length), + end: cm.index + cm[0].length, + }); + const tags = collectDsmlTags(body, "invoke", bodyBase); + for (let i = 0; i < tags.length; i += 1) { + // A nameless tag is a stray closer, not an opener: skip it WITHOUT + // consuming the next tag (pre-harden pairing semantics). + if (!getAttr(tags[i].attrs, "name")) continue; + const call = buildDsmlCall(tags[i], tags[i + 1], text, bodyBase + body.length, requested); + if (call) out.push(call); + if (tags[i + 1]) i += 1; // consume the paired closer + } + } + // P7: bare root-level invokes outside any envelope convert identically. + // Tags already consumed inside envelopes are excluded so nothing is parsed twice. + const rootTags = collectDsmlTags(text, "invoke", 0).filter( + (t) => !envelopeRanges.some((r) => t.start >= r.start && t.end <= r.end) + ); + for (let i = 0; i < rootTags.length; i += 1) { + if (!getAttr(rootTags[i].attrs, "name")) continue; + const call = buildDsmlCall(rootTags[i], rootTags[i + 1], text, text.length, requested); + if (call) out.push(call); + if (rootTags[i + 1]) i += 1; // consume the paired closer + } + return { calls: out.sort((a, b) => a.start - b.start), strip }; +} + // ── Public parser ───────────────────────────────────────────────────────────── /** @@ -442,7 +609,8 @@ export function parseDeepSeekToolCalls( } const tokens = tokenizeToolTags(text); - if (tokens.length === 0) { + const dsmlPresent = hasDsmlTags(text); + if (tokens.length === 0 && !dsmlPresent) { // No DeepSeek-specific tags — defer to the proven canonical parser (bare JSON, etc.). return parseToolCallsFromText(text, idSeed, requestedTools); } @@ -456,8 +624,7 @@ export function parseDeepSeekToolCalls( const isLeaf = (b: ToolBlock) => !blocks.some((o) => o !== b && o.open.start >= b.innerStart && o.close.end <= b.innerEnd); - const toolCalls: OpenAIToolCall[] = []; - const acceptedRanges: Array<{ start: number; end: number }> = []; + const found: Array<{ start: number; end: number; call: ExtractedCall }> = []; const nonce = getToolNonce(requestedTools); for (const block of blocks.filter(isLeaf).sort((a, b) => a.open.start - b.open.start)) { @@ -466,7 +633,7 @@ export function parseDeepSeekToolCalls( getAttr(block.open.attrs, "name") || getAttr(block.open.attrs, "id") || ""; - const inner = text.slice(block.innerStart, block.innerEnd); + const inner = text.slice(block.innerStart, block.innerEnd).replace(DSML_ANY_TAG_RE, ""); const call = extractCall(tagName, inner, requested, schemaMap); if (!call) continue; @@ -482,15 +649,29 @@ export function parseDeepSeekToolCalls( if (parsed && typeof parsed.name === "string" && parsed._nonce !== undefined && parsed._nonce !== nonce) continue; } - toolCalls.push({ - id: `${idSeed}_${toolCalls.length}`, - type: "function", - function: { name: call.name, arguments: call.arguments }, + found.push({ + start: block.open.start, + end: block.close.end, + call, }); - acceptedRanges.push({ start: block.open.start, end: block.close.end }); } - if (toolCalls.length === 0) { + // Native DSML dialect (no nonce concept — same policy as the XML/tag-suffix + // shapes, which likewise carry no JSON body to bind). + let dsmlStrip: Array<{ start: number; end: number }> = []; + if (dsmlPresent) { + const dsml = parseDsmlBlocks(text, requested); + dsmlStrip = dsml.strip; + for (const d of dsml.calls) { + found.push({ + start: d.start, + end: d.end, + call: { name: d.name, arguments: toArgumentsString(d.args) }, + }); + } + } + + if (found.length === 0) { // Tags were present but none parsed (e.g. malformed or nonce-rejected). // Do NOT fall back to parseToolCallsFromText — that would re-process content // already seen by this parser and potentially promote rejected tagged output @@ -498,12 +679,29 @@ export function parseDeepSeekToolCalls( return { content: text, toolCalls: null }; } + // Document order across both dialects so mixed DSML + replies keep a + // stable, deterministic id sequence. + found.sort((a, b) => a.start - b.start); + const toolCalls: OpenAIToolCall[] = []; + const acceptedRanges: Array<{ start: number; end: number }> = []; + for (const f of found) { + toolCalls.push({ + id: `${idSeed}_${toolCalls.length}`, + type: "function", + function: { name: f.call.name, arguments: f.call.arguments }, + }); + acceptedRanges.push({ start: f.start, end: f.end }); + } + // Strip the accepted blocks plus any stray tool tags left outside them (the unmatched outer // `` of a doubled wrapper, leftover `` of a non-leaf wrapper, etc.). + // DSML wrapper tags are stripped via dsmlStrip (invoke ranges already cover + // the calls they belong to). const within = (tok: TagToken) => acceptedRanges.some((r) => tok.start >= r.start && tok.end <= r.end); const ranges = [ ...acceptedRanges, + ...dsmlStrip, ...tokens.filter((t) => !within(t)).map((t) => ({ start: t.start, end: t.end })), ]; diff --git a/tests/unit/translator/deepseekWebTools.dsml.test.ts b/tests/unit/translator/deepseekWebTools.dsml.test.ts new file mode 100644 index 000000000000..46879206aa15 --- /dev/null +++ b/tests/unit/translator/deepseekWebTools.dsml.test.ts @@ -0,0 +1,298 @@ +// Permanent tests for DeepSeek Web's native DSML dialect. +// Canonical project location: tests/unit/** (see package.json → test:unit). +// Run: node --import tsx --test tests/unit/translator/deepseekWebTools.dsml.test.ts +// (workdir = omniroute package root). +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { parseDeepSeekToolCalls } from "../../../open-sse/translator/deepseekWebTools.ts"; + +const bashTools = [ + { + type: "function", + function: { + name: "bash", + description: "run shell", + parameters: { + type: "object", + properties: { command: { type: "string" } }, + required: ["command"], + }, + }, + }, +]; + +const multiTools = [ + { + type: "function", + function: { + name: "write_file", + description: "write", + parameters: { + type: "object", + properties: { + path: { type: "string" }, + content: { type: "string" }, + }, + required: ["path", "content"], + }, + }, + }, +]; + +const DSML_FREE = `<||DSML|| calls> +<||DSML|| invoke name="bash"> +<||DSML|| parameter name="command" string="true">free -h +<||DSML|| invoke> +`; + +function argsOf(r: { toolCalls: Array<{ function: { arguments: string } }> | null }, i = 0) { + assert.ok(r.toolCalls, "expected toolCalls, got null"); + return JSON.parse(r.toolCalls[i].function.arguments) as Record; +} + +describe("deepseekWebTools DSML", () => { + it("1. canonical + bash + echo HOLA (regression)", () => { + const r = parseDeepSeekToolCalls( + `{"name":"bash","arguments":{"command":"echo HOLA"}}`, + "t1", + bashTools + ); + assert.equal(r.toolCalls?.length, 1); + assert.equal(r.toolCalls![0].function.name, "bash"); + assert.deepEqual(argsOf(r), { command: "echo HOLA" }); + assert.equal(r.content.trim(), ""); + }); + + it("2. DSML + bash + free -h", () => { + const r = parseDeepSeekToolCalls(DSML_FREE, "t2", bashTools); + assert.equal(r.toolCalls?.length, 1); + assert.equal(r.toolCalls![0].type, "function"); + assert.equal(r.toolCalls![0].function.name, "bash"); + assert.deepEqual(argsOf(r), { command: "free -h" }); + assert.equal(r.content.trim(), ""); + }); + + it("3. DSML + bash + uname -a", () => { + const r = parseDeepSeekToolCalls( + DSML_FREE.replace("free -h", "uname -a"), + "t3", + bashTools + ); + assert.deepEqual(argsOf(r), { command: "uname -a" }); + }); + + it("4. DSML with multiple parameters", () => { + const r = parseDeepSeekToolCalls( + `<||DSML|| calls>\n<||DSML|| invoke name="write_file">\n` + + `<||DSML|| parameter name="path" string="true">/tmp/x.txt\n` + + `<||DSML|| parameter name="content" string="true">hola mundo\n` + + `<||DSML|| invoke>\n`, + "t4", + multiTools + ); + assert.equal(r.toolCalls?.length, 1); + assert.equal(r.toolCalls![0].function.name, "write_file"); + assert.deepEqual(argsOf(r), { path: "/tmp/x.txt", content: "hola mundo" }); + }); + + it("5. DSML with multiple invokes", () => { + const r = parseDeepSeekToolCalls( + `<||DSML|| calls>\n` + + `<||DSML|| invoke name="bash">\n<||DSML|| parameter name="command">free -h\n<||DSML|| invoke>\n` + + `<||DSML|| invoke name="bash">\n<||DSML|| parameter name="command">uname -a\n<||DSML|| invoke>\n` + + ``, + "t5", + bashTools + ); + assert.equal(r.toolCalls?.length, 2); + assert.equal(r.toolCalls![0].id, "t5_0"); + assert.equal(r.toolCalls![1].id, "t5_1"); + assert.deepEqual(argsOf(r, 0), { command: "free -h" }); + assert.deepEqual(argsOf(r, 1), { command: "uname -a" }); + assert.equal(r.content.trim(), ""); + }); + + it("6. DSML multiline values", () => { + const script = "echo línea1\necho línea2\necho fin"; + const r = parseDeepSeekToolCalls( + `<||DSML|| calls>\n<||DSML|| invoke name="bash">\n` + + `<||DSML|| parameter name="command" string="true">${script}\n` + + `<||DSML|| invoke>\n`, + "t6", + bashTools + ); + assert.deepEqual(argsOf(r), { command: script }); + }); + + it("7. plain text without tools → null", () => { + const r = parseDeepSeekToolCalls("Tienes 14 GiB de RAM.", "t7", bashTools); + assert.equal(r.toolCalls, null); + assert.equal(r.content, "Tienes 14 GiB de RAM."); + }); + + it("8. unknown tool: DSML ↔ canonical parity", () => { + const canon = parseDeepSeekToolCalls( + `{"name":"frobnicate","arguments":{"x":1}}`, + "t8a", + bashTools + ); + const dsml = parseDeepSeekToolCalls( + `<||DSML|| calls>\n<||DSML|| invoke name="frobnicate">\n` + + `<||DSML|| parameter name="x" string="true">1\n` + + `<||DSML|| invoke>\n`, + "t8b", + bashTools + ); + // Paridad con el path : el nombre desconocido se propaga tal cual. + assert.equal(dsml.toolCalls?.[0]?.function?.name, canon.toolCalls?.[0]?.function?.name); + assert.equal(dsml.toolCalls?.length, 1); + }); + + it("9. DSML + coexistence in document order", () => { + const r = parseDeepSeekToolCalls( + `${DSML_FREE}\n{"name":"bash","arguments":{"command":"echo HOLA"}}`, + "t9", + bashTools + ); + assert.equal(r.toolCalls?.length, 2); + assert.deepEqual(argsOf(r, 0), { command: "free -h" }); + assert.deepEqual(argsOf(r, 1), { command: "echo HOLA" }); + assert.equal(r.content.trim(), ""); + }); + + it("10. JSON escaping in values", () => { + const cmd = `echo "hola \\ mundo" && ls 'C:\\tmp'`; + const r = parseDeepSeekToolCalls( + `<||DSML|| calls>\n<||DSML|| invoke name="bash">\n` + + `<||DSML|| parameter name="command" string="true">${cmd}\n` + + `<||DSML|| invoke>\n`, + "t10", + bashTools + ); + // Debe sobrevivir como JSON válido y redondo. + assert.deepEqual(argsOf(r), { command: cmd }); + assert.doesNotThrow(() => JSON.parse(r.toolCalls![0].function.arguments)); + }); + + it("11. ASCII marker variant", () => { + const ascii = DSML_FREE.replace(/|/g, "|"); + assert.ok(ascii.includes("<||DSML|| calls>")); + const r = parseDeepSeekToolCalls(ascii, "t11", bashTools); + assert.equal(r.toolCalls?.length, 1); + assert.deepEqual(argsOf(r), { command: "free -h" }); + }); + + it("12. hybrid {json} + DSML closers (observed live)", () => { + const hybrid = + `{"name": "bash", "arguments": {"command": "uname -a"}}` + + `\n\n`; + const r = parseDeepSeekToolCalls(hybrid, "t12", bashTools); + assert.equal(r.toolCalls?.length, 1); + assert.equal(r.toolCalls![0].function.name, "bash"); + assert.deepEqual(argsOf(r), { command: "uname -a" }); + assert.ok(!r.content.includes("DSML"), r.content); + }); + + it("13. DSML invoke without envelope", () => { + const r = parseDeepSeekToolCalls( + `<||DSML|| invoke name="bash">\n` + + `<||DSML|| parameter name="command" string="true">echo DOS\n` + + `<||DSML|| invoke>`, + "t13", + bashTools + ); + assert.equal(r.toolCalls?.length, 1); + assert.equal(r.toolCalls![0].function.name, "bash"); + assert.deepEqual(argsOf(r), { command: "echo DOS" }); + assert.ok(!r.content.includes("DSML"), r.content); + }); + + it("14. envelopeless DSML + in order", () => { + const r = parseDeepSeekToolCalls( + `<||DSML|| invoke name="bash">\n` + + `<||DSML|| parameter name="command" string="true">echo DOS\n` + + `<||DSML|| invoke>\n` + + `{"name":"bash","arguments":{"command":"echo UNO"}}`, + "t14", + bashTools + ); + assert.equal(r.toolCalls?.length, 2); + assert.deepEqual(argsOf(r, 0), { command: "echo DOS" }); + assert.deepEqual(argsOf(r, 1), { command: "echo UNO" }); + assert.equal(r.content.trim(), ""); + }); + + it("15. + envelopeless DSML in reverse order", () => { + const r = parseDeepSeekToolCalls( + `{"name":"bash","arguments":{"command":"echo UNO"}}\n` + + `<||DSML|| invoke name="bash">\n` + + `<||DSML|| parameter name="command" string="true">echo DOS\n` + + `<||DSML|| invoke>`, + "t15", + bashTools + ); + assert.equal(r.toolCalls?.length, 2); + assert.deepEqual(argsOf(r, 0), { command: "echo UNO" }); + assert.deepEqual(argsOf(r, 1), { command: "echo DOS" }); + assert.equal(r.content.trim(), ""); + }); + + it("16. invoke without valid parameters → no tool call (L2 fail-safe)", () => { + const r = parseDeepSeekToolCalls( + `<||DSML|| calls>\n<||DSML|| invoke name="bash">\n<||DSML|| invoke>\n`, + "t16", + bashTools + ); + assert.equal(r.toolCalls, null); + }); + + it("17. parameter without name → no tool call for that invoke (L2 fail-safe)", () => { + const r = parseDeepSeekToolCalls( + `<||DSML|| calls>\n<||DSML|| invoke name="bash">\n` + + `<||DSML|| parameter string="true">echo HOLA\n` + + `<||DSML|| invoke>\n`, + "t17", + bashTools + ); + assert.equal(r.toolCalls, null); + }); + + it("18. empty parameter → fail-safe, no tool call (L2)", () => { + const r = parseDeepSeekToolCalls( + `<||DSML|| calls>\n<||DSML|| invoke name="bash">\n` + + `<||DSML|| parameter name="command">\n` + + `<||DSML|| invoke>\n`, + "t18", + bashTools + ); + assert.equal(r.toolCalls, null); + }); + + it("19. envelope + root invoke coexist in order", () => { + const r = parseDeepSeekToolCalls( + `${DSML_FREE}\n` + + `<||DSML|| invoke name="bash">\n` + + `<||DSML|| parameter name="command" string="true">uname -a\n` + + `<||DSML|| invoke>`, + "t19", + bashTools + ); + assert.equal(r.toolCalls?.length, 2); + assert.deepEqual(argsOf(r, 0), { command: "free -h" }); + assert.deepEqual(argsOf(r, 1), { command: "uname -a" }); + assert.equal(r.content.trim(), ""); + }); + + it("20. unclosed root invoke keeps trailing text (audit C)", () => { + const r = parseDeepSeekToolCalls( + `<||DSML|| invoke name="bash">\n` + + `<||DSML|| parameter name="command">echo X\n` + + `Texto legítimo después.`, + "t20", + bashTools + ); + assert.equal(r.toolCalls?.length, 1); + assert.deepEqual(argsOf(r), { command: "echo X" }); + assert.equal(r.content.trim(), "Texto legítimo después."); + }); +}); From dbce901bffa1aae31cd3e73c80612665d6ede025 Mon Sep 17 00:00:00 2001 From: espokaos-ops <264269740+espokaos-ops@users.noreply.github.com> Date: Fri, 18 Sep 2026 06:13:47 +0100 Subject: [PATCH 2/2] docs: add changelog for DeepSeek Web DSML tool calls --- changelog.d/fixes/13345-deepseek-web-dsml-tool-calls.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/fixes/13345-deepseek-web-dsml-tool-calls.md diff --git a/changelog.d/fixes/13345-deepseek-web-dsml-tool-calls.md b/changelog.d/fixes/13345-deepseek-web-dsml-tool-calls.md new file mode 100644 index 000000000000..230521557580 --- /dev/null +++ b/changelog.d/fixes/13345-deepseek-web-dsml-tool-calls.md @@ -0,0 +1 @@ +- **fix(deepseek-web):** handle native DeepSeek Web DSML tool calls (`` / `` / ``, including root-level invokes) so they parse into tool calls instead of leaking as plain text ([#13345](https://github.com/diegosouzapw/OmniRoute/pull/13345)) — thanks @espokaos-ops