// Tool-call translation for web-cookie providers (deepseek-web, chatgpt-web, ...). // // The web UIs accept only a single plain prompt string and have no native function // calling — they reply with tool invocations as raw text. To let agentic clients use // these providers we (a) serialize the OpenAI `tools` array into a system-prompt // contract on the request side, and (b) parse the upstream `{...}` text // back into OpenAI `tool_calls` on the response side. (#2820) export interface OpenAIToolCall { id: string; type: "function"; function: { name: string; arguments: string }; } interface OpenAIToolDef { type?: string; function?: { name?: string; description?: string; parameters?: unknown; }; } const TOOL_BLOCK_RE = /\s*([\s\S]*?)\s*<\/tool>/g; // Some web-cookie models (e.g. ds-web) wrap calls as `{json}` // instead of the canonical `{json}`. Capture the JSON body — the real tool name // lives there, never in the tag's `name="..."` attribute (#3260). const TOOL_CALL_TAG_RE = /]*)?\s*>\s*([\s\S]*?)\s*<\/tool_call>/g; // Per-request nonce binding for tool envelopes (#9343). Associates a random nonce // with each tools[] array reference so the serializer and parser can share it // without threading extra parameters through executor call chains. const toolNonceMap = new WeakMap(); export function getToolNonce(tools: unknown): string { if (!Array.isArray(tools) || tools.length === 0) return ""; let nonce = toolNonceMap.get(tools); if (!nonce) { nonce = Math.random().toString(36).slice(2, 10); toolNonceMap.set(tools, nonce); } return nonce; } interface ToolParseCandidate { raw: string; start: number; end: number; requireRequestedTool: boolean; } export interface RequestedToolName { original: string; normalized: string; } function toRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; } export function getRequestedToolNames(tools: unknown): RequestedToolName[] { if (!Array.isArray(tools)) return []; const names: RequestedToolName[] = []; const seen = new Set(); for (const tool of tools) { const record = toRecord(tool); const fn = toRecord(record?.function); const name = typeof fn?.name === "string" ? fn.name.trim() : ""; if (!name || seen.has(name)) continue; seen.add(name); names.push({ original: name, normalized: normalizeToolName(name) }); } return names; } function normalizeToolName(name: string): string { return name.toLowerCase().replace(/[^a-z0-9]/g, ""); } function levenshteinDistance(a: string, b: string): number { if (a === b) return 0; if (!a) return b.length; if (!b) return a.length; let previous = Array.from({ length: b.length + 1 }, (_, i) => i); let current = Array(b.length + 1); for (let i = 1; i <= a.length; i += 1) { current[0] = i; for (let j = 1; j <= b.length; j += 1) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + cost); } const temp = previous; previous = current; current = temp; } return previous[b.length]; } function scoreToolName(emitted: string, requested: RequestedToolName): number { if (emitted === requested.original) return 1; const normalized = normalizeToolName(emitted); if (!normalized || !requested.normalized) return 0; if (normalized === requested.normalized) return 0.98; const shorter = Math.min(normalized.length, requested.normalized.length); const longer = Math.max(normalized.length, requested.normalized.length); if (shorter >= 4) { if (normalized.includes(requested.normalized) || requested.normalized.includes(normalized)) { return 0.86 - (longer - shorter) / Math.max(longer, 1) / 4; } } const distance = levenshteinDistance(normalized, requested.normalized); const similarity = 1 - distance / Math.max(longer, 1); return similarity >= 0.72 ? similarity : 0; } export function resolveRequestedToolName( emitted: string, requestedTools: RequestedToolName[] ): string | null { if (requestedTools.length === 0) return emitted; let best: { name: string; score: number } | null = null; let secondBest = 0; for (const requested of requestedTools) { const score = scoreToolName(emitted, requested); if (!best || score > best.score) { secondBest = best?.score ?? 0; best = { name: requested.original, score }; } else if (score > secondBest) { secondBest = score; } } if (!best || best.score < 0.72) return null; // Avoid correcting to an arbitrary tool when the fuzzy match is ambiguous. if (best.score < 0.98 && best.score - secondBest < 0.08) return null; return best.name; } function stripCodeFence(value: string): string { return value .trim() .replace(/^```(?:json|javascript|js|python)?\s*/i, "") .replace(/\s*```$/i, "") .trim(); } function convertSingleQuotedStrings(value: string): string { let result = ""; let inSingle = false; let inDouble = false; let escaped = false; for (const ch of value) { if (escaped) { result += ch === '"' && inSingle ? '\\"' : ch; escaped = false; continue; } if (ch === "\\") { result += ch; escaped = true; continue; } if (ch === '"') { if (inSingle) { result += '\\"'; } else { inDouble = !inDouble; result += ch; } continue; } if (ch === "'" && !inDouble) { inSingle = !inSingle; result += '"'; continue; } result += ch; } return result; } function replacePythonLiterals(value: string): string { let result = ""; let inString = false; let escaped = false; let token = ""; const flushToken = () => { if (token === "True") result += "true"; else if (token === "False") result += "false"; else if (token === "None") result += "null"; else result += token; token = ""; }; for (const ch of value) { if (escaped) { if (token) flushToken(); result += ch; escaped = false; continue; } if (ch === "\\") { if (token) flushToken(); result += ch; escaped = inString; continue; } if (ch === '"') { if (token) flushToken(); inString = !inString; result += ch; continue; } if (!inString && /[A-Za-z]/.test(ch)) { token += ch; continue; } if (token) flushToken(); result += ch; } if (token) flushToken(); return result; } function normalizeLooseJson(value: string): string { return replacePythonLiterals(convertSingleQuotedStrings(value)) .replace(/([{,]\s*)([A-Za-z_][A-Za-z0-9_-]*)(\s*:)/g, '$1"$2"$3') .replace(/,\s*([}\]])/g, "$1"); } export function parseLooseJsonObject(raw: string): Record | null { const trimmed = stripCodeFence(raw); for (const candidate of [trimmed, normalizeLooseJson(trimmed)]) { try { return toRecord(JSON.parse(candidate)); } catch { // Try the next, more permissive form. } } return null; } function findBareJsonCandidates(text: string): ToolParseCandidate[] { const candidates: ToolParseCandidate[] = []; let start = -1; let depth = 0; let quote: '"' | "'" | "" = ""; let escaped = false; for (let i = 0; i < text.length; i += 1) { const ch = text[i]; if (depth === 0 && ch !== "{") { continue; } if (escaped) { escaped = false; continue; } if (quote) { if (ch === "\\") { escaped = true; } else if (ch === quote) { quote = ""; } continue; } if (ch === '"' || ch === "'") { quote = ch; continue; } if (ch === "{") { if (depth === 0) start = i; depth += 1; continue; } if (ch === "}" && depth > 0) { depth -= 1; if (depth === 0 && start >= 0) { const raw = text.slice(start, i + 1); if ( /[{,]\s*["']?(name|command)["']?\s*:/i.test(raw) && /[{,]\s*["']?arguments["']?\s*:/i.test(raw) ) { candidates.push({ raw, start, end: i + 1, requireRequestedTool: true }); } start = -1; } } } return candidates; } function rangesOverlap( a: { start: number; end: number }, b: { start: number; end: number } ): boolean { return a.start < b.end && b.start < a.end; } export function stripRanges(text: string, ranges: Array<{ start: number; end: number }>): string { let content = text; const sorted = [...ranges].sort((a, b) => b.start - a.start); for (const range of sorted) { const lineStart = content.lastIndexOf("\n", range.start - 1) + 1; const nextLineBreak = content.indexOf("\n", range.end); const lineEnd = nextLineBreak === -1 ? content.length : nextLineBreak; const beforeOnLine = content.slice(lineStart, range.start); const afterOnLine = content.slice(range.end, lineEnd); const removeWholeLine = beforeOnLine.trim() === "" && afterOnLine.trim() === ""; const start = removeWholeLine ? lineStart : range.start; const end = removeWholeLine && nextLineBreak !== -1 ? nextLineBreak + 1 : removeWholeLine ? lineEnd : range.end; content = `${content.slice(0, start)}${content.slice(end)}`; } return content.replace(/\n{3,}/g, "\n\n").trim(); } export function toArgumentsString(value: unknown): string { if (value === undefined) return "{}"; if (typeof value === "string") { const parsed = parseLooseJsonObject(value); return parsed ? JSON.stringify(parsed) : value; } try { return JSON.stringify(value); } catch { return "{}"; } } /** * Serialize an OpenAI `tools` array into a system-prompt block that instructs the * web UI model how to invoke a tool (emit a `{...}` block). Returns an * empty string when there are no usable tools. * * Each invocation generates a per-request nonce that is embedded in the tool format * instructions. The parser (parseToolCallsFromText) requires this nonce in the model's * `` JSON to distinguish legitimate tool calls from bare JSON, code-fenced JSON, * or copy-attacked envelopes (#9343). */ export function serializeToolsToPrompt(tools: unknown): string { if (!Array.isArray(tools) || tools.length === 0) return ""; const nonce = getToolNonce(tools); if (!nonce) 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 [ "The client application provides tools beyond your built-in ones. They are NOT in your " + "native tool registry; they are invoked via a plain-text protocol: the client parses " + "your reply and executes the tool on the user machine. Treat these client tools as " + "fully available to you; never claim they are unavailable. To invoke one, reply with " + "a single line containing a block", `with JSON that includes the secret binding "_nonce": "${nonce}":`, `{"name": "", "arguments": { ... }, "_nonce": "${nonce}"}`, "These client tools ARE available to you in this conversation. Only emit the " + "block when you actually want to call a tool; otherwise answer normally.", "", "Available tools:", ...lines, ].join("\n"); } /** * Parse `{...}` or `{...}` blocks out of * upstream text into OpenAI `tool_calls`. * * **Security hardening (#9343):** Bare JSON with name+arguments keys is NEVER * promoted to tool_calls — only explicit `` or `` envelopes are * accepted. When a nonce was embedded via serializeToolsToPrompt (stored from the * same tools[] reference), it MUST be present in the parsed JSON body as `_nonce`. * This prevents code-fenced JSON, prose JSON, and copy-attacked user envelopes from * triggering tool execution. * * Returns the content with the recognized blocks stripped, plus the tool calls * (or null when there are none). `arguments` is always a JSON *string*, matching * the OpenAI API. * * `idSeed` makes generated ids deterministic for callers that need stability; when * omitted, ids are still unique within a single call (index-based). */ export function parseToolCallsFromText( text: string, idSeed = "call", requestedTools?: unknown ): { content: string; toolCalls: OpenAIToolCall[] | null } { const requestedToolNames = getRequestedToolNames(requestedTools); if (typeof text !== "string" || (!text.includes("") && !text.includes(" a.start - b.start); const toolCalls: OpenAIToolCall[] = []; const acceptedRanges: Array<{ start: number; end: number }> = []; for (const candidate of candidates) { const parsed = parseLooseJsonObject(candidate.raw); const emittedName = parsed && typeof parsed.name === "string" ? parsed.name : parsed && typeof parsed.command === "string" ? parsed.command : null; if (!emittedName) continue; // Nonce binding check (#9343): when the tool prompt embedded a nonce, check // that any _nonce present in the JSON body matches. A wrong nonce (present but // does not match) means this is a copy-attack or hallucination — treat it as text // instead of executing it. A missing _nonce is tolerated for backward compatibility // with models that do not (yet) follow the nonce instruction. if (nonce && parsed && parsed._nonce !== undefined && parsed._nonce !== nonce) continue; const name = resolveRequestedToolName(emittedName, requestedToolNames) || (candidate.requireRequestedTool ? null : emittedName); if (!name || (candidate.requireRequestedTool && requestedToolNames.length === 0)) continue; const args = toArgumentsString(parsed?.arguments); toolCalls.push({ id: `${idSeed}_${toolCalls.length}`, type: "function", function: { name, arguments: args }, }); acceptedRanges.push({ start: candidate.start, end: candidate.end }); } if (toolCalls.length === 0) { return { content: text, toolCalls: null }; } const content = stripRanges(text, acceptedRanges); return { content, toolCalls }; } // ── Shared helpers for web-cookie executors ──────────────────────────────── interface ToolPrepResult { hasTools: boolean; requestedTools: unknown; effectiveMessages: Array<{ role: string; content: unknown }>; } /** One-line nudge appended to the latest user message. Web-UI models weigh the * current user turn far more heavily than a large system block, and ChatGPT's * injection heuristics distrust long instructions embedded in user content — * so the full contract stays in the system block (trailing, see below) and the * user turn only carries a short pointer back to it, naming the tools. */ function buildToolReminder(toolPrompt: string): string { const names = (toolPrompt.match(/^- [^:\n]+/gm) || []).map((s) => s.slice(2).trim()).join(", "); return ( "\n\n[Client protocol reminder: the client-tool contract in the system instructions " + "is active in this conversation. These client tools ARE available via the " + "block protocol" + (names ? ": " + names : "") + ".]" ); } /** * Extract tools from an OpenAI request body and inject the tool contract when * tools are present. Every web-cookie executor that wants tool-call support * calls this once before building its upstream request body. * * Placement matters: the contract used to be PREPENDED as the first system * message. Executors fold all system messages into one block, so with agentic * clients whose system prompts exceed ~28K chars the contract sat at the head * of a huge block and web models (chatgpt-web observed) ignored it, answering * "tool X is not in my tool set" instead of emitting blocks. Dual * placement fixes it: the full contract goes AFTER the client messages (folds * to the tail of the system block) and a one-line reminder rides at the end of * the latest user message. Measured on cgpt-web/gpt-5.5-thinking with a * 30K-char system prompt: prepend 0/3 tool calls, dual placement 16/17 across * 30K-250K prompts, 30-tool sets, multi-turn tool history, and streaming. */ export function prepareToolMessages( bodyObj: Record, messages: Array<{ role: string; content: unknown }> ): ToolPrepResult { const requestedTools = bodyObj.tools; const hasTools = Array.isArray(requestedTools) && requestedTools.length > 0; if (!hasTools) return { hasTools: false, requestedTools, effectiveMessages: messages }; const toolPrompt = serializeToolsToPrompt(requestedTools); if (!toolPrompt) return { hasTools: true, requestedTools, effectiveMessages: messages }; const effectiveMessages = [...messages]; const reminder = buildToolReminder(toolPrompt); for (let i = effectiveMessages.length - 1; i >= 0; i--) { const msg = effectiveMessages[i]; if (msg?.role !== "user") continue; if (typeof msg.content === "string") { effectiveMessages[i] = { ...msg, content: msg.content + reminder }; } else if (Array.isArray(msg.content)) { effectiveMessages[i] = { ...msg, content: [...msg.content, { type: "text", text: reminder }], }; } break; } effectiveMessages.push({ role: "system", content: toolPrompt }); return { hasTools: true, requestedTools, effectiveMessages }; } interface ToolCompletionResult { content: string; toolCalls: OpenAIToolCall[] | null; finishReason: string; } /** * Parse tool calls from a model's text response. Returns the cleaned content * (with `` blocks stripped), the parsed tool calls (or null), and the * appropriate finish_reason. Every web-cookie executor calls this on the * collected response text when `hasTools` is true. */ export function buildToolAwareResult( rawContent: string, requestedTools: unknown, idSeed = "call" ): ToolCompletionResult { const { content, toolCalls } = parseToolCallsFromText( rawContent, `${idSeed}-${Date.now()}`, requestedTools ); return { content, toolCalls, finishReason: toolCalls ? "tool_calls" : "stop", }; }