Files
OmniRoute/open-sse/translator/deepseekWebTools.ts
Diego Rodrigues de Sa e Souza 19d91d82e2 Release v3.8.34 (#4614)
* chore(release): open v3.8.34 development cycle

* chore(quality): release-green pre-flight validator + nightly signal (C+D) (#4622)

C — scripts/quality/validate-release-green.mjs (npm run check:release-green):
reproduces the release-equivalent validation (typecheck, eslint, db-rules,
public-creds, full unit, vitest, ratchets, optional --with-build package-artifact)
against the current working tree and classifies each red as HARD (real defect,
exit 1) vs DRIFT (ratchet — reported, never affects exit / never blocks). Pure
helpers exported + orchestration behind a direct-run guard; unit-tested.

D — .github/workflows/nightly-release-green.yml: runs C on the active release
branch nightly (and on workflow_dispatch) and opens/updates a single tracking
issue on HARD failures. Never a required check, never touches a contributor PR.

Closes the gap where the full gate (ci.yml) only ran on the release PR, so reds
accrued silently on release/** and surfaced in 40-min layers at release time.
Non-blocking by construction; drift is the maintainer's to rebaseline at release.

Co-authored-by: Diego Rodrigues de Sa e Souza <diego.souza@cdwasolutions.com.br>

* fix(providers): show revealed connection API keys (#4583)

Integrated into release/v3.8.34

* fix(resilience): respect upstream retry hint toggle (#4585)

Integrated into release/v3.8.34

* feat(settings): expose stream recovery feature flags (#4586)

Integrated into release/v3.8.34

* fix(logs): make active request stale sweep configurable (#4599)

Integrated into release/v3.8.34

* fix(plugin): auto-prefix providerId with 'opencode-' for OC 1.17.8+ native gate (#4527)

Integrated into release/v3.8.34 (supersedes #4445)

* fix(models): treat unknown output caps as unset (#4584)

Integrated into release/v3.8.34

* fix(executors): strip temperature for GitHub Copilot gpt-5.4 family (#4564)

Integrated into release/v3.8.34 (rebuilt onto tip)

* fix(oauth): update Qwen OAuth URLs from chat.qwen.ai to qwen.ai (#4561)

Integrated into release/v3.8.34 (rebuilt onto tip)

* fix(api/settings): prevent cached /api/settings responses (port from 9router#951) (#4566)

Integrated into release/v3.8.34 (rebuilt onto tip)

* feat(audio): MiniMax T2A v2 TTS dispatch in audioSpeech (port #1043) (#4553)

Integrated into release/v3.8.34 (rebuilt onto tip)

* fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails (#4562)

Integrated into release/v3.8.34 (rebuilt onto tip)

* feat(providers): optional model ID for custom API-key validation (#4555)

Integrated into release/v3.8.34 (rebuilt onto tip)

* fix(cli): align data dir and env loading with runtime (#4607)

Integrated into release/v3.8.34 (rebuilt onto tip)

* fix(quota): expose Bailian quota windows (#4610)

Integrated into release/v3.8.34 (rebuilt onto tip)

* fix: retain provider cooldowns for configured max window (#4588)

Integrated into release/v3.8.34 (rebuilt — bundled commits stripped)

* fix: reject invalid provider cooldown bounds (#4589)

Integrated into release/v3.8.34 (rebuilt — bundled commits stripped)

* fix: preserve production combo metrics on shadow eviction (#4590)

Integrated into release/v3.8.34 (rebuilt — bundled commits stripped)

* fix(stream): estimate input tokens when upstream reports prompt_tokens=0 (#4615)

Integrated into release/v3.8.34 (rebuilt onto tip)

* fix(catalog): shorten no-thinking gateway prefix to no-think/ (#4525)

Integrated into release/v3.8.34 (rebuilt — kept only the prefix rename, dropped stale-base reverts)

* fix(relay): apply IP rate limit to bifrost sidecar (#4593)

Integrated into release/v3.8.34 (rebuilt onto tip; merge before #4612)

* fix(bifrost): finalize SSE relay usage after stream (#4612)

Integrated into release/v3.8.34 (rebuilt + reconciled with #4593)

* feat(compression): per-request `x-omniroute-compression` header (Phase 3) (#4645)

* docs(compression): Phase 3 per-request header design spec

Approved brainstorming output for the x-omniroute-compression header:
header-first precedence, name-first combo matching (Decision A), explicit
value bypasses auto-trigger (Decision B), DerivedPlan.source, and the
X-OmniRoute-Compression response header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(compression): Phase 3 per-request header implementation plan

4-task TDD plan (resolver header-first + source, parser, chatCore wiring +
response header, docs/file-size) with full code and exact commands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(compression): header-first resolver + plan source (Phase 3 core)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(compression): resolveCompressionHeader parser (Phase 3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(compression): wire x-omniroute-compression header + response header (Phase 3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(compression): extract plan-resolution leaf (planResolution.ts) under size cap (Phase 3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(compression): document x-omniroute-compression header (Phase 3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(compression): harden named-combo map + trim engine: header id (Phase 3 review)

Addresses gemini-code-assist review on #4645:
- Extract buildNamedComboLookup (pure) so a blank/whitespace/null combo name
  contributes only its id key (no '' key, no throw that disables all combos).
- Trim the engine:<id> header value so 'engine: rtk' resolves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <diego.souza@cdwasolutions.com.br>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* fix: exclude exhausted connections from auto scoring (#4592)

Integrated into release/v3.8.34 (rebuilt + opt-in gate fix)

* fix(dashboard): memoize compatible provider groups (#4613)

Integrated into release/v3.8.34 (rebuilt + test added)

* fix(dashboard): isolate quota widget refresh clock (#4611)

Integrated into release/v3.8.34 (rebuilt + jsdom test)

* fix(dashboard): gate topology side effects behind widget visibility (#4606)

Integrated into release/v3.8.34 (rebuilt + jsdom test)

* fix(dashboard): keep play_arrow spinning on provider Test All buttons (#4563)

Integrated into release/v3.8.34 (rebuilt onto tip; UI-cosmetic per owner)

* fix(db): schedule retention cleanup + fix cleanup table/column names (extracted from #4428) (#4691)

Integrated into release/v3.8.34 (cleanup core extracted from #4428, credit @oyi77)

* fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable (#4604) (#4687)

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* fix(api): serve GET /v1/models/{model} as JSON, not the HTML dashboard (#4674) (#4677)

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* feat(opencode): add go deepseek reasoning variants (#4647)

Integrated into release/v3.8.34

* fix(executors): robust deepseek-web tool-call parsing and agentic context retention (#4644)

Integrated into release/v3.8.34

* fix(cli): authenticate `omniroute logs` and honor active context (#4638)

Integrated into release/v3.8.34 (authored by Rahul Sharma, AI co-author trailer stripped per project policy)

* fix(proxy): apply pipelining:0 + connections cap to the direct dispatcher (#4580) (#4684)

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* fix(executors): Firecrawl web_fetch 500 with include_metadata=true (#4692)

Integrated into release/v3.8.34

* fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template (#4621)

Integrated into release/v3.8.34 (dead getFirstRegistryModelId dropped, rebuilt onto tip)

* fix(dashboard): gate home topology live-WS networking (#4596) (#4618)

Integrated into release/v3.8.34 (adapted onto #4606's extracted topology section: default-hidden flip + enabled gate on useLiveDashboard)

* fix(cli): align `omniroute` env loading with the runtime data dir (#4597) (#4619)

Integrated into release/v3.8.34 (data-dir.mjs refactor reconciled with #4607; loadEnvFile aligned to getDefaultDataDir)

* chore(quality): reconcile file-size baseline for #4644 (deepseek-web.ts 1117->1125) (#4695)

file-size reconcile for #4644

* Support quota scraping for OpenCode Go and Ollama Cloud (#4642)

Integrated into release/v3.8.34 (Ollama Cloud + OpenCode Go dashboard quota scraping; rebuilt onto tip, gates green: typecheck/public-creds/file-size/lint/docs-sync + 31 tests)

* feat(executors): land M365 Copilot pure framing + connection helpers (#4042) (#4696)

Land M365 pure modules ahead of draft #4400

* deps: bump production + development groups; migrate js-yaml to v5 ESM (#4697)

Incorporates Dependabot #4667 + #4668 + js-yaml v5 ESM migration into release/v3.8.34

* fix: noAuth provider validation + kimi executor routing (#4699)

Integrated into release/v3.8.34 (noAuth in NOAUTH_PROVIDERS dynamic check + remove misrouted kimi web alias; 9 tests)

* refactor(imageGeneration): extract 8 provider families to co-located files (#4609)

Integrated into release/v3.8.34 (extraction completed: added missing imports/exports per module, main imports handlers locally; 145 image-gen tests pass, typecheck/cycles/file-size green)

* chore(release): v3.8.34 — finalize changelog, rebaseline drift, fix release-green reds

- Finalize CHANGELOG [3.8.34] (43 bullets, full contributor attribution) + seed i18n mirrors
- Rebaseline inherited cycle drift surfaced by release-green pre-flight: eslint warnings
  3900->3907, cognitive-complexity 797->801 (release-finalize touches no prod code; all
  drift is from this cycle's contributor merges)
- fix(providers): keep reka-flash-3 as the Reka provider default. #4621 inserted reka-flash
  at the head of the model list, silently changing the default from reka-flash-3 (the
  free-tier model) to reka-flash; reorder so reka-flash-3 stays default, reka-flash retained.
- test: align provider-models-config / provider-models-route / web-cookie-providers-new with
  #4621 (reka-flash now in the Reka catalog) and #4699 (the `kimi` API-key provider correctly
  falls through to DefaultExecutor instead of KimiWebExecutor)
- chore(quality): allowlist the COMPRESSION_GUIDE doc name in check-fabricated-docs
  (false-positive env-var match; docs/compression/COMPRESSION_GUIDE.md exists)

* fix(release-green): resolve release-PR full-CI reds for v3.8.34

Surfaced only on the release PR (these gates don't run on PR->release fast-gates):

- fix(quota): complete HTML-comment sanitization in opencodeOllamaUsage SSR reset-time
  parsing — strip any <!--...--> generically instead of the two literal React hydration
  markers, so no partial "<!--" can survive (CodeQL js/incomplete-multi-character-
  sanitization, HIGH, introduced by #4642). Regression test added.
- test(codex): correct the Codex-fingerprint body key order assertion to match the
  canonical bodyFieldOrder (prompt_cache_key precedes include); #4584 flipped the two
  and integration tests don't run on fast-gates so it never executed until the release PR.
- chore(quality): rebaseline inherited cycle drift surfaced by full CI —
  zizmorFindings 152->155 (+3 unpinned-uses in nightly-release-green.yml from #4622,
  same @vN convention as ci.yml) and openapiCoverage.pct 38.4->37.8 (-0.6, contributor
  routes added faster than openapi docs). Release-finalize touches no prod routes.

* fix(release-green): complete CodeQL sanitization + rebaseline complexity drift

- fix(quota): handle unterminated HTML comments in opencodeOllamaUsage SSR reset-time
  parsing — the `(?:-->|$)` arm consumes a trailing "<!--" with no closing "-->", so no
  partial "<!--" can survive (CodeQL js/incomplete-multi-character-sanitization persisted
  with the plain <!--...--> form because an unclosed comment could still leave "<!--").
- chore(quality): rebaseline cyclomatic complexity 1915->1916 (+1) — inherited v3.8.34
  cycle drift (contributor feature branches); check:complexity does not run on PR->release
  fast-gates so it surfaced only on the release PR. Release-finalize adds 0 complexity
  (measured 1916 with/without the regex tweak). dead-code/cognitive/type-coverage/
  compression-budget/codeql ratchets all pass.

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <diego.souza@cdwasolutions.com.br>
Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
Co-authored-by: Hernan Javier Ardila Sanchez <hjasgr@gmail.com>
Co-authored-by: KooshaPari <42529354+KooshaPari@users.noreply.github.com>
Co-authored-by: Abhishek Divekar <adivekar@utexas.edu>
Co-authored-by: Rahul sharma <sharmaR0810@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>
Co-authored-by: Ronald Estacion <DevEstacion@users.noreply.github.com>
Co-authored-by: Igor <60442260+BugsBag@users.noreply.github.com>
Co-authored-by: Oonishi <275808243+ponkcore@users.noreply.github.com>
Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com>
Co-authored-by: Jan Leon <Jan.gaschler@gmail.com>
2026-06-23 03:08:29 -03:00

442 lines
17 KiB
TypeScript

// DeepSeek-web-specific tool-call translation.
//
// chat.deepseek.com has no native function calling, so OmniRoute serializes the OpenAI
// `tools[]` into a prompt contract and parses the model's text reply back into OpenAI
// `tool_calls`. The canonical `webTools.ts` parser handles the well-behaved
// `<tool>{json}</tool>` / bare-JSON shapes used by most web-cookie providers, and it MUST
// stay untouched (it works for the others).
//
// DeepSeek, however, emits a much wider zoo of ad-hoc shapes:
// <tool:todowrite>{json}</tool> name in the tag suffix, body is the arguments
// <tool_call>{id,type,params}</tool_call> alternate key names (type → name, params → arguments)
// <tool name="x">{json}</tool> name in an attribute
// <tool id="todo_write">{json}</tool> tool name in the id attribute
// <tool><tool ...>{json}</tool></tool> doubled / nested wrappers
// <tool id="1"><name>x</name><arguments>{json}</arguments></tool> XML children
// <tool:write><parameter name="content" content="..."> parameter style
//
// 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
// proven JSON-normalization / fuzzy-name-matching / range-stripping helpers from webTools.ts
// rather than duplicating them.
import {
parseToolCallsFromText,
parseLooseJsonObject,
getRequestedToolNames,
resolveRequestedToolName,
toArgumentsString,
stripRanges,
type OpenAIToolCall,
type RequestedToolName,
} from "./webTools.ts";
interface OpenAIToolDef {
type?: string;
function?: { name?: string; description?: string; parameters?: unknown };
}
// ── Stricter, compact tool-use prompt ───────────────────────────────────────
/**
* Serialize an OpenAI `tools` array into a DeepSeek-specific system-prompt block.
*
* It is deliberately stricter than the generic `serializeToolsToPrompt`: DeepSeek tends to
* (a) invent its own wrappers and (b) merely *describe* a plan instead of emitting a call.
* The wording forces the single canonical `<tool>{json}</tool>` shape and forbids the
* alternatives, while staying short to avoid wasting tokens.
*/
export function serializeDeepSeekToolPrompt(tools: unknown): string {
if (!Array.isArray(tools) || tools.length === 0) return "";
const lines: string[] = [];
for (const t of tools as OpenAIToolDef[]) {
const fn = t?.function;
if (!fn?.name) continue;
const desc = typeof fn.description === "string" && fn.description ? fn.description : "";
let params = "";
try {
params = fn.parameters ? JSON.stringify(fn.parameters) : "";
} catch {
params = "";
}
lines.push(
`- ${fn.name}${desc ? `: ${desc}` : ""}${params ? `\n parameters: ${params}` : ""}`
);
}
if (lines.length === 0) return "";
return [
"You can call tools. To call a tool, output ONLY this exact block (no markdown fence):",
'<tool>{"name": "<tool_name>", "arguments": { ... }}</tool>',
"Rules:",
"- Use exactly <tool>...</tool>. Do NOT use <tool:name>, <tool_call>, <name>, <parameter>, id=/name= attributes, or code fences.",
'- "name" must be one of the tools below; "arguments" must be a JSON object.',
"- When a tool is needed, emit the <tool> block instead of only describing the plan.",
"- Emit one <tool> block per call; you may put several blocks back to back.",
"- If no tool is needed, just answer normally without any <tool> block.",
"",
"Available tools:",
...lines,
].join("\n");
}
// ── Tool-aware conversation prompt ───────────────────────────────────────────
interface ChatMessage {
role: string;
content?: unknown;
tool_calls?: Array<{ id?: string; function?: { name?: string; arguments?: unknown } }>;
tool_call_id?: string;
name?: string;
}
function extractText(content: unknown): string {
if (Array.isArray(content)) {
return (content as Array<{ type?: string; text?: string }>)
.filter((item) => item?.type === "text")
.map((item) => item?.text ?? "")
.join("\n");
}
return content == null ? "" : String(content);
}
/**
* Build the single `prompt` string for an agentic (tool-using) DeepSeek-web turn.
*
* The web endpoint takes a flat prompt with no `messages[]`, so the legacy `messagesToPrompt`
* only forwarded the last user message — which makes an agent loop amnesiac: on every turn the
* follow-up messages carry no new *user* text, so DeepSeek only ever saw the original task and
* kept restarting (re-creating todos, re-listing files…). This builder instead replays the WHOLE
* trajectory — including the assistant's prior `<tool>` calls and each `role:"tool"` result — so
* the model continues from where it left off instead of starting over.
*/
export function buildToolConversationPrompt(
messages: ChatMessage[],
toolSystemPrompt: string
): string {
const systemParts: string[] = [];
if (toolSystemPrompt) systemParts.push(toolSystemPrompt);
const lines: string[] = [];
const callNameById = new Map<string, string>();
let sawToolActivity = false;
for (const m of messages) {
if (m.role === "system") {
const t = extractText(m.content).trim();
if (t) systemParts.push(t);
} else if (m.role === "user") {
const t = extractText(m.content).trim();
if (t) lines.push(`User: ${t}`);
} else if (m.role === "assistant") {
const t = extractText(m.content).trim();
const calls = Array.isArray(m.tool_calls) ? m.tool_calls : [];
const parts: string[] = [];
if (t) parts.push(t);
for (const c of calls) {
const name = typeof c?.function?.name === "string" ? c.function.name : "";
const rawArgs = c?.function?.arguments;
const args =
typeof rawArgs === "string" && rawArgs ? rawArgs : JSON.stringify(rawArgs ?? {});
if (c?.id) callNameById.set(c.id, name);
parts.push(`<tool>{"name": ${JSON.stringify(name)}, "arguments": ${args}}</tool>`);
sawToolActivity = true;
}
if (parts.length) lines.push(`Assistant: ${parts.join("\n")}`);
} else if (m.role === "tool") {
const t = extractText(m.content).trim();
const name = (m.tool_call_id && callNameById.get(m.tool_call_id)) || m.name || "tool";
lines.push(`Tool result (${name}): ${t || "(no output)"}`);
sawToolActivity = true;
}
}
const parts: string[] = [];
if (systemParts.length) parts.push(systemParts.join("\n\n"));
if (lines.length) parts.push(lines.join("\n\n"));
if (sawToolActivity) {
// Anchor the model to the work already done so it advances instead of repeating it.
parts.push(
"Continue the task using the tool results above. Do NOT repeat tool calls that already " +
"succeeded; perform the next step or give the final answer."
);
}
return parts.join("\n\n").replace(/!\[.*?\]\(.*?\)/g, "");
}
// ── Tag tokenizer ────────────────────────────────────────────────────────────
interface TagToken {
start: number;
end: number;
closing: boolean;
suffix: string; // tool name after ':' in the tag (e.g. `<tool:bash>` → "bash")
attrs: string; // raw attribute text inside the tag
}
// Matches an opening/closing <tool .../> or <tool_call .../> tag, optionally with a `:name`
// suffix and an attribute list. `tool_call` is listed first so it wins the alternation.
const TAG_TOKEN_RE = /<(\/?)(?:tool_call|tool)(:[A-Za-z0-9_.+-]+)?((?:\s[^>]*)?)\/?>/g;
function tokenizeToolTags(text: string): TagToken[] {
const tokens: TagToken[] = [];
let m: RegExpExecArray | null;
TAG_TOKEN_RE.lastIndex = 0;
while ((m = TAG_TOKEN_RE.exec(text)) !== null) {
tokens.push({
start: m.index,
end: TAG_TOKEN_RE.lastIndex,
closing: m[1] === "/",
suffix: m[2] ? m[2].slice(1) : "",
attrs: m[3] || "",
});
}
return tokens;
}
interface ToolBlock {
open: TagToken;
close: TagToken;
innerStart: number;
innerEnd: number;
}
// Pair tags with a stack: every closing tag pairs with the nearest unmatched open. An open
// left unmatched at the end (e.g. the stray outer `<tool>` of a doubled wrapper, or a
// never-closed `<tool:write>` followed by `<parameter ...>`) gets a synthetic close at the end
// of the text so its body is still parsed; the doubled-wrapper outer is then dropped by the
// leaf filter.
function pairToolBlocks(tokens: TagToken[], textLen: number): ToolBlock[] {
const blocks: ToolBlock[] = [];
const stack: TagToken[] = [];
for (const tok of tokens) {
if (!tok.closing) {
stack.push(tok);
continue;
}
const open = stack.pop();
if (!open) continue;
blocks.push({ open, close: tok, innerStart: open.end, innerEnd: tok.start });
}
for (const open of stack) {
const synthetic: TagToken = {
start: textLen,
end: textLen,
closing: true,
suffix: "",
attrs: "",
};
blocks.push({ open, close: synthetic, innerStart: open.end, innerEnd: textLen });
}
return blocks;
}
// ── Attribute / XML-child helpers ────────────────────────────────────────────
/** Read an attribute value, tolerating backslash-escaped quotes inside the value. */
function getAttr(attrs: string, name: string): string | null {
const re = new RegExp(`\\b${name}\\s*=\\s*("|')`);
const m = re.exec(attrs);
if (!m) return null;
const quote = m[1];
let j = m.index + m[0].length;
let out = "";
while (j < attrs.length) {
const ch = attrs[j];
if (ch === "\\") {
out += attrs[j + 1] ?? "";
j += 2;
continue;
}
if (ch === quote) break;
out += ch;
j += 1;
}
return out;
}
function getXmlChild(inner: string, tag: string): string | null {
const m = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i").exec(inner);
return m ? m[1].trim() : null;
}
// The body group is a tempered greedy token: `(?:(?!<parameter\b)[\s\S])*?` so an
// attribute-only `<parameter ...>` (no closing tag) cannot let the body matcher swallow a
// following `<parameter>...</parameter>` and drop that parameter.
const PARAM_TAG_RE = /<parameter\b([^>]*?)\/?>(?:((?:(?!<parameter\b)[\s\S])*?)<\/parameter>)?/gi;
/** Collect `<parameter name="x" content="y">` / `<parameter name="x">y</parameter>` into an object. */
function buildArgsFromParameters(inner: string): Record<string, unknown> | null {
const out: Record<string, unknown> = {};
let found = false;
let m: RegExpExecArray | null;
PARAM_TAG_RE.lastIndex = 0;
while ((m = PARAM_TAG_RE.exec(inner)) !== null) {
const attrs = m[1] || "";
const body = m[2];
const name = getAttr(attrs, "name");
if (!name) continue;
const value = getAttr(attrs, "content") ?? (typeof body === "string" ? body.trim() : "");
out[name] = value;
found = true;
}
return found ? out : null;
}
// ── Single-block extraction ──────────────────────────────────────────────────
interface ExtractedCall {
name: string;
arguments: string;
}
function asString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
/**
* Turn one tool block (tag name + inner text) into a name + JSON-string arguments.
* Returns null when no plausible tool name can be recovered.
*/
function extractCall(
tagName: string,
innerRaw: string,
requested: RequestedToolName[]
): ExtractedCall | null {
const inner = innerRaw.trim();
const nameChild = getXmlChild(inner, "name");
const argsChild = getXmlChild(inner, "arguments") ?? getXmlChild(inner, "parameters");
const paramObj = argsChild ? null : buildArgsFromParameters(inner);
const hasXmlChildren = !!nameChild || !!argsChild || !!paramObj;
const json = hasXmlChildren ? null : parseLooseJsonObject(inner);
const jsonName = json ? (asString(json.name) ?? asString(json.type)) : null;
const childResolved = nameChild ? resolveRequestedToolName(nameChild, requested) : null;
const jsonResolved = jsonName ? resolveRequestedToolName(jsonName, requested) : null;
const tagResolved = tagName ? resolveRequestedToolName(tagName, requested) : null;
// Prefer a name that maps to a requested tool. The JSON body wins over the tag attribute
// because DeepSeek sometimes emits a bogus tag name (e.g. name="skill", #3260).
let name: string | null = null;
let nameFromTag = false;
const pick = (val: string | null, fromTag: boolean) => {
if (!name && val) {
name = val;
nameFromTag = fromTag;
}
};
pick(childResolved, false);
pick(jsonResolved, false);
pick(tagResolved, true);
pick(nameChild, false);
pick(jsonName, false);
pick(tagName, true);
// Shell-style `{ "command": "..." }` with no tag name: treat command as the tool name only
// if it actually resolves to a requested tool (the value is otherwise the command itself).
if (!name && !tagName && json) {
const command = asString(json.command);
const resolved = command ? resolveRequestedToolName(command, requested) : null;
if (resolved) {
name = resolved;
nameFromTag = false;
}
}
if (!name) return null;
let argsValue: unknown;
if (argsChild) {
argsValue = parseLooseJsonObject(argsChild) ?? argsChild;
} else if (paramObj) {
argsValue = paramObj;
} else if (json) {
if (json.arguments !== undefined) argsValue = json.arguments;
else if (json.params !== undefined) argsValue = json.params;
else if (nameFromTag) {
// `<tool:bash>{"command": ...}` — the whole JSON object is the arguments payload.
argsValue = json;
} else {
// Name came from the JSON body — the remaining keys are the arguments.
const { name: _n, type: _t, id: _i, command: _c, arguments: _a, params: _p, ...rest } = json;
argsValue = rest;
}
} else {
argsValue = {};
}
return { name, arguments: toArgumentsString(argsValue) };
}
// ── Public parser ─────────────────────────────────────────────────────────────
/**
* Parse a DeepSeek-web text reply into OpenAI `tool_calls`. Returns the surrounding text with the recognized blocks stripped (so it can
* still be streamed to the client) plus the parsed calls, or `null` when none are present.
*
* Falls back to the canonical `webTools.parseToolCallsFromText` for tag-free replies so that
* bare-JSON and plain `<tool>` behavior stays identical to the shared implementation.
*/
export function parseDeepSeekToolCalls(
text: string,
idSeed = "call",
requestedTools?: unknown
): { content: string; toolCalls: OpenAIToolCall[] | null } {
if (typeof text !== "string" || text.length === 0) {
return { content: text ?? "", toolCalls: null };
}
const tokens = tokenizeToolTags(text);
if (tokens.length === 0) {
// No DeepSeek-specific tags — defer to the proven canonical parser (bare JSON, etc.).
return parseToolCallsFromText(text, idSeed, requestedTools);
}
const requested = getRequestedToolNames(requestedTools);
const blocks = pairToolBlocks(tokens, text.length);
// Only extract from leaf blocks (no other block nested inside), so a doubled
// `<tool><tool>...</tool></tool>` wrapper yields a single call from the inner block.
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 }> = [];
for (const block of blocks.filter(isLeaf).sort((a, b) => a.open.start - b.open.start)) {
const tagName =
block.open.suffix ||
getAttr(block.open.attrs, "name") ||
getAttr(block.open.attrs, "id") ||
"";
const inner = text.slice(block.innerStart, block.innerEnd);
const call = extractCall(tagName, inner, requested);
if (!call) continue;
toolCalls.push({
id: `${idSeed}_${toolCalls.length}`,
type: "function",
function: { name: call.name, arguments: call.arguments },
});
acceptedRanges.push({ start: block.open.start, end: block.close.end });
}
if (toolCalls.length === 0) {
// Tags were present but none parsed (e.g. malformed) — try the canonical bare-JSON path.
return parseToolCallsFromText(text, idSeed, requestedTools);
}
// Strip the accepted blocks plus any stray tool tags left outside them (the unmatched outer
// `<tool>` of a doubled wrapper, leftover `</tool>` of a non-leaf wrapper, etc.).
const within = (tok: TagToken) =>
acceptedRanges.some((r) => tok.start >= r.start && tok.end <= r.end);
const ranges = [
...acceptedRanges,
...tokens.filter((t) => !within(t)).map((t) => ({ start: t.start, end: t.end })),
];
return { content: stripRanges(text, ranges), toolCalls };
}