Files
OmniRoute/open-sse/translator/deepseekWebTools.ts
Diego Rodrigues de Sa e Souza dc40911583 Release v3.8.39 (#5164)
* chore(release): open v3.8.39 development cycle

* docs(changelog): backfill 5 v3.8.38 bullets merged after release finalize

These PRs squash-merged into release/v3.8.38 between the CHANGELOG finalize
(ff57be32f) and the merge-to-main (ae6e2342d), so they shipped in the v3.8.38
tag but had no bullet:

- feat(compression): Ionizer engine (lossy JSON-array sampling + CCR) (#5148)
- fix(sse): preserve non-stream reasoning fields (#5155, @rdself)
- fix(i18n): add missing English UI labels (#5153, @rdself)
- test(combo): gated live smoke (#5151) + release-expectations refresh (#5150, @KooshaPari)

(#5129 exact-host Anthropic baseUrl is already covered by the #5130 bullet — same CodeQL #674.)
Synced 41 i18n CHANGELOG mirrors.

* feat(compression): TOON best-of-N candidate encoder + encoder A/B table (#5163)

Integrated into release/v3.8.39. TOON best-of-N candidate encoder (GCF default, fail-open). 17/17 unit tests pass on merge result; CI reds were base-stale + Quality Ratchet DRIFT.

* fix(zenmux): normalize vendor-prefixed GLM system roles (#5158)

Integrated into release/v3.8.39. ZenMux vendor-prefixed GLM system-role normalization; 12/12 role-normalizer tests pass on merge result. CI reds base-stale.

* [codex] fix xAI OAuth test and reasoning effort (#5157)

Integrated into release/v3.8.39. xAI reasoning-effort normalization (max/xhigh→high) + OAuth test config; 46/46 xai-translator tests pass on merge result. CI reds base-stale.

* docs(i18n): add Traditional Chinese (zh-TW) README and update zh-CN to latest (#5162)

Integrated into release/v3.8.39. Traditional Chinese (zh-TW) README + zh-CN refresh; docs-only.

* test(security): guard PII redaction stays opt-in (default off) + Hard Rule #20 (#5159)

Integrated into release/v3.8.39. PII opt-in regression guard + Hard Rule #20; rebased to strip base-drift (+81/-1). 5/5 guard tests pass; flip-proof verified.

* test(combo): deterministic context-relay universal-handoff coverage (closes phase-2 TODO) (#5168)

Integrated into release/v3.8.39. Deterministic context-relay universal-handoff coverage (3 tests); 3/3 pass on merge result.

* docs(i18n): full sync zh-TW and zh-CN README with canonical English v3.8.39 (#5171)

Integrated into release/v3.8.39. Full zh-TW docs tree + zh-CN sync with canonical English v3.8.39; docs-only.

* fix(serve): honour HOSTNAME from .env instead of hardcoding 0.0.0.0 (#5134) (#5170)

Integrated into release/v3.8.39. HOSTNAME env override in serve (#5134) + regression test (4/4, TDD flip-proof verified).

* 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).

* fix(sse): normalize array user content for Command Code to avoid upstream 400 (#5166) (#5174)

Integrated into release/v3.8.39. Normalize array user content for Command Code (#5166, user-array/400 symptom); 4/4 tests pass on merge result.

* fix(sse): defer </think> close so it never leaks before tool_calls (#5123) (#5175)

Integrated into release/v3.8.39. Defer </think> close so it never leaks before tool_calls (#5123); 4/4 tests pass (incl. #4633 no-regression). CHANGELOG synced to keep all 3 v3.8.39 fixes.

* fix(dashboard): use amber for home update-step warning icon (#5176)

Integrated into release/v3.8.39. Amber for home update-step warning icon; 1/1 UI test.

* fix(api): LAN/Tailscale dashboard — host-aware CSP + GET-exempt version route + combo field errors (#5083) (#5177)

Integrated into release/v3.8.39. Host-aware CSP (ReDoS/injection-safe host validation) + GET-exempt /api/system/version (POST/spawn stays LOCAL_ONLY, exact-match safe-methods-only) + COMBO_002 firstField. 44/44 tests + route-guard membership gate green. CHANGELOG synced to keep all 4 v3.8.39 fixes.

* fix(api): replace #5083 global middleware CSP with declarative ws: scheme (#5083)

Follow-up to PR #5177 (merged): that version implemented the LAN-CSP fix (Bug 1)
with a new global `src/middleware.ts` + `src/server/csp.ts`, which contradicts the
project's documented architecture — 'No global Next.js middleware — interception is
route-specific' (CLAUDE.md / AGENTS.md) — and was merged unverified (middleware vs
next.config header precedence was never confirmed in a real build).

This replaces that approach with the minimal, declarative equivalent:
  • next.config.mjs: connect-src now permits the bare `ws:` scheme (symmetric with the
    bare `wss:` already allowed) so the dashboard can reach its own Live WS server from
    a LAN/Tailscale host. No middleware.
  • Removes src/middleware.ts, src/server/csp.ts, and tests/unit/csp-host-aware.test.ts.
  • Adds tests/unit/csp-lan-ws-5083.test.ts (incl. a guard asserting src/middleware.ts
    does NOT exist, so the global-middleware approach cannot silently return).

Bugs 2 (GET-exempt /api/system/version) and 3 (COMBO_002 field surfacing) from #5177
are unaffected and remain in place.

Co-authored-by: KooshaPari <KooshaPari@users.noreply.github.com>

* test(combo): end-to-end quota-share DRR routing-decision coverage (matrix parity) (#5179)

Integrated into release/v3.8.39. Quota-share DRR routing-decision coverage (matrix parity); 2/2 pass on merge result.

* feat(agent-bridge): graceful cert-install fallback with manual guide for containers (#4546) (#5178)

Integrated into release/v3.8.39. Agent-bridge graceful cert-install fallback + manual guide (#4546); 6/6 tests pass on merge result.

* fix(antigravity): family-scoped quota lockout (gemini/claude buckets) (#5180)

Integrated into release/v3.8.39 — family-scoped antigravity quota lockout. Rebased from v3.8.37 + validated (vitest 5/5, typecheck clean, full combo-matrix green, model-lockout 99/0). Same-model cross-account retry (chat.ts) deferred pending live antigravity VPS validation.

* fix(cli): force NODE_ENV to match dev/start run mode in custom Next server (#5189)

Integrated into release/v3.8.39. Force NODE_ENV to match dev/start run mode in custom Next server; 2/2 source-scan+ordering tests pass on merge result.

* feat(compression): CCR ranged/grep/stats retrieval (ReDoS-safe, backward-compat) (#5187)

Integrated into release/v3.8.39. CCR ranged/grep/stats retrieval (safe-regex ReDoS guard + length/match caps); 17/17 tests pass on merge result.

* docs(combo): sync all combo/routing-strategy docs to current state + document test coverage (#5185)

Integrated into release/v3.8.39. Combo/routing-strategy docs sync; docs-only.

* fix(mcp): return 404 (not 400) for unknown Streamable HTTP session id (#5169) (#5191)

* fix(api): respect blocked Auto (Zero-Config) provider in /v1/models catalog (#5192) (#5194)

* test(combo): deterministic context-relay codex quota-handoff coverage (closes last gap) (#5195)

* test(ci): wire antigravity-quota-family under test:vitest (fix test-discovery orphan) (#5196)

* fix(oauth): antigravity login no longer hangs — fire-and-forget onboarding + bounded post-exchange (#5193)

Antigravity OAuth hang fix (no-PKCE/no-openid + bounded post-exchange + exchange-500 fix). Includes #5200 (Koosha) revert + owner rebaseline to keep documented comments. Integrated into release/v3.8.39.

* feat(oauth): remote Antigravity login via local helper + paste-credentials (#5203)

Remote Antigravity login: local helper (omniroute login antigravity) + paste-credentials. Integrated into release/v3.8.39.

* fix(translator): accept Claude Messages shape in non-stream malformed-200 guard (#5156)

Integrated into release/v3.8.39

* fix(cli): default dev bundler to Turbopack (16.2.x panic no longer reproduces) (#5206)

Integrated into release/v3.8.39

* fix(cli): auto-calibrate server V8 heap from physical RAM (#5172) (#5213)

The server was spawned with a fixed --max-old-space-size=512 (omniroute serve)
or no heap flag at all (Electron), so RAM-rich boxes still OOM-crashed under
load (Ineffective mark-compacts near heap limit ~500MB) with many providers/
accounts and large model catalogs. New calibrateHeapFallbackMb(os.totalmem())
defaults the heap to ~35% of RAM clamped [512,4096], wired into serve.mjs and
electron/main.js. Explicit OMNIROUTE_MEMORY_MB still wins (#2939 unchanged).

Also addresses #5160 (same OOM root); #5152 (docker) benefits via the same knob.

Closes #5172

* fix(proxy): coalesce fast-fail health probes (#5208)

Integrated into release/v3.8.39

* fix(proxy): close dispatchers when clearing cache (#5202)

Integrated into release/v3.8.39

* fix(cli): raise dev server Node heap limit to 8GB to prevent OOM (#5198)

Integrated into release/v3.8.39

* fix(auth): allow synthetic no-auth fallback for mimocode (#5205)

Integrated into release/v3.8.39

* fix(oauth): preserve Antigravity refresh_token on empty/omitted upstream response (#3850) (#5214)

Google's OAuth refresh tokens are non-rotating: the refresh response usually
omits refresh_token and occasionally returns it as an empty string. The
Antigravity executor used `typeof tokens.refresh_token === "string" ? ... `
which accepts "" (typeof "" === "string") and overwrote the stored token with
empty, nulling it on first refresh. Now treats non-string OR empty as absent and
preserves credentials.refreshToken, matching refreshGoogleToken semantics.

Closes #3850

* fix(responses): normalize non-array input (#5204)

Integrated into release/v3.8.39

* fix(stream): normalize safety finish reasons via shared helper (#5197)

Integrated into release/v3.8.39

* fix(request-logger): never render negative '(-100%)' compression badge (#5201)

Integrated into release/v3.8.39

* fix(combo): reject empty responses api output (#5207)

Integrated into release/v3.8.39 — combo failover now rejects empty Responses API output (validateQuality). Baseline rebaseline dropped (main-measured drift; maintainer rebaselines at release).

* fix(pwa): prefer cached navigation before offline page (#5209)

Integrated into release/v3.8.39 — PWA service worker prefers cached navigation before offline page (#5165).

* chore(release): v3.8.39 — 2026-06-28

* chore(release): rebaseline openapi+i18n coverage ratchet drift for v3.8.39

---------

Co-authored-by: Arthur Bodera <abodera@gmail.com>
Co-authored-by: Nguyen Minh <lop123thcs@gmail.com>
Co-authored-by: lunkerchen <labanchen@gmail.com>
Co-authored-by: Ankit <177378174+anki1kr@users.noreply.github.com>
Co-authored-by: KooshaPari <KooshaPari@users.noreply.github.com>
Co-authored-by: Ardem2025 <ardemb22@gmail.com>
Co-authored-by: backryun <bakryun0718@proton.me>
Co-authored-by: Anton <39598727+NomenAK@users.noreply.github.com>
Co-authored-by: KooshaPari <42529354+KooshaPari@users.noreply.github.com>
Co-authored-by: Wilson <pedbookmed@gmail.com>
Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
2026-06-28 06:58:29 -03:00

487 lines
19 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;
}
/**
* 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.
*/
function extractCall(
tagName: string,
innerRaw: string,
requested: RequestedToolName[],
schemaMap?: Map<string, Set<string>>
): 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;
}
}
// 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;
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 schemaMap = buildSchemaParamMap(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, schemaMap);
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 };
}