mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 10:43:43 +03:00
* 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>
442 lines
17 KiB
TypeScript
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 };
|
|
}
|