/** * Claude Code inbound: Anthropic Messages API request -> internal /v1/responses body. * * Design (devlog/260711_claude_inbound/010, 003_evidence.md + hardening slice): * - translate-and-replay: the produced body MUST pass the real responsesRequestSchema * parse so routing/OAuth/pool/failover are inherited unchanged. * - thinking/redacted_thinking blocks are preserved as Responses reasoning items via * the existing ocxr1 envelope (src/responses/reasoning-envelope.ts), keeping * multiple-block order and interleaving with tool_use; malformed ocxr1 signatures * (value starting with ocxr1: but failing decode) return 400. * - thinking.budget_tokens is NEVER forwarded raw; it maps to an effort tier. * - top_k is accepted and silently dropped (no Responses equivalent, CCR parity). */ import type { OcxClaudeCodeConfig } from "../types"; import { isClaudeWebSearchToolName } from "./outbound"; import { decodeReasoningEnvelope, encodeReasoningEnvelope, OCX_REASONING_PREFIX } from "../responses/reasoning-envelope"; import { verifyDirectiveSignature } from "./directive-sign"; import { createHash } from "node:crypto"; export { AnthropicRequestError, DesktopModelMappingUnavailableError } from "./inbound-records"; export { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, extractOcxRouteDirective, extractOcxEffortDirective } from "./inbound-model-options"; import { AnthropicRequestError, isRec, type Rec } from "./inbound-records"; import { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, formatFromOutputConfig } from "./inbound-model-options"; import { systemToInstructions } from "./inbound-content-options"; import { createTranslatorBudget, type TranslatorBudget } from "../lib/translator-budget"; function imageBlockToInputImage(block: Rec): Rec | null { const source = block.source; if (!isRec(source)) return null; if (source.type === "file") { throw new AnthropicRequestError( "File-backed images require native Anthropic passthrough; use base64 or URL images on translated routes.", ); } if (source.type === "base64" && typeof source.data === "string") { const media = typeof source.media_type === "string" ? source.media_type : "image/png"; return { type: "input_image", image_url: `data:${media};base64,${source.data}` }; } if (source.type === "url" && typeof source.url === "string") { return { type: "input_image", image_url: source.url }; } return null; } function toolResultOutput(block: Rec): string | Rec[] { const isError = block.is_error === true; const content = block.content; if (typeof content === "string") return isError ? `[tool error] ${content}` : content; if (Array.isArray(content)) { const out: Rec[] = []; for (const item of content) { if (!isRec(item)) continue; if (item.type === "text" && typeof item.text === "string") { out.push({ type: "input_text", text: item.text }); } else if (item.type === "image") { const img = imageBlockToInputImage(item); if (img) out.push(img); } else if (item.type === "document") { // Same marker as the user-message document case below: the model should see the // attachment happened instead of an empty tool output. out.push({ type: "input_text", text: `[document${typeof item.title === "string" ? `: ${item.title}` : ""}]` }); } } if (isError) out.unshift({ type: "input_text", text: "[tool error]" }); if (out.length === 0) return isError ? "[tool error]" : ""; return out; } return isError ? "[tool error]" : ""; } function pushUserMessage(input: Rec[], blocks: Rec[]): void { if (blocks.length === 0) return; input.push({ type: "message", role: "user", content: blocks }); } function isToolSearchName(value: unknown): value is string { return value === "tool_search" || (typeof value === "string" && value.startsWith("tool_search_tool_")); } function functionToolToResponses(raw: Rec): Rec | null { if (typeof raw.name !== "string" || raw.name.length === 0 || !isRec(raw.input_schema)) return null; return { type: "function", name: raw.name, ...(typeof raw.description === "string" ? { description: raw.description } : {}), parameters: raw.input_schema, ...(raw.defer_loading === true ? { defer_loading: true } : {}), ...(typeof raw.strict === "boolean" ? { strict: raw.strict } : {}), }; } function toolDefinitionsByName(tools: unknown): ReadonlyMap { const definitions = new Map(); if (!Array.isArray(tools)) return definitions; for (const raw of tools) { if (!isRec(raw)) continue; const mapped = functionToolToResponses(raw); if (mapped && typeof mapped.name === "string") definitions.set(mapped.name, mapped); } return definitions; } function toolSearchOutputItem(raw: Rec, definitions: ReadonlyMap): Rec | null { if (typeof raw.tool_use_id !== "string" || raw.tool_use_id.length === 0) { throw new AnthropicRequestError("tool_search_tool_result requires tool_use_id"); } const content = isRec(raw.content) ? raw.content : {}; const failed = content.type === "tool_search_tool_result_error"; const names = Array.isArray(content.tool_references) ? content.tool_references.flatMap(ref => isRec(ref) && typeof ref.tool_name === "string" ? [ref.tool_name] : []) : []; return { type: "tool_search_output", call_id: raw.tool_use_id, status: failed ? "failed" : "completed", execution: "client", tools: names.flatMap(name => definitions.get(name) ?? []), }; } /** * Bundled-skill elision for routed models (devlog 060). Claude Code loads a skill * by calling the `Skill` tool; the ~136k-token document bundle then rides the * paired tool_result on EVERY subsequent turn. Third-party models are not trained * on these Anthropic bundles, so for blocked skills we substitute the result body * with a short stub — the function_call_output item itself stays (pairing intact). * Native Anthropic passthrough never reaches this translation. */ export const DEFAULT_BLOCKED_SKILLS = ["claude-api"]; /** Shared effective policy for proxy elision and generated routed-agent guards. */ export function effectiveBlockedSkillNames(cc?: Pick): string[] { const names = cc?.blockedSkills ?? DEFAULT_BLOCKED_SKILLS; return [...new Set(names .filter((name): name is string => typeof name === "string") .map(name => name.trim().toLowerCase()) .filter(name => name.length > 0))]; } interface ScannedDirectives { routes: string[]; efforts: string[]; sigs: string[]; } function getSystemBlocks(body: unknown): string[] { if (!isRec(body)) return []; const system = body.system; if (typeof system === "string") return [system]; if (Array.isArray(system)) { return system .filter((b): b is Rec => isRec(b) && b.type === "text" && typeof b.text === "string") .map(b => b.text as string); } return []; } function scanSystemDirectives(body: unknown): ScannedDirectives { const blocks = getSystemBlocks(body); const routes: string[] = []; const efforts: string[] = []; const sigs: string[] = []; const re = //g; for (const block of blocks) { re.lastIndex = 0; let match: RegExpExecArray | null; while ((match = re.exec(block)) !== null) { const kind = match[1]; const val = match[2]?.trim() ?? ""; if (kind === "route") routes.push(val); else if (kind === "effort") efforts.push(val); else if (kind === "sig") sigs.push(val); } } return { routes, efforts, sigs }; } export function extractSignedDirective(body: unknown): { route: string | null; effort: string | null; signature: string | null; version: string | null; } { const scanned = scanSystemDirectives(body); const route = scanned.routes.length > 0 && scanned.routes[0] ? scanned.routes[0] : null; const effort = scanned.efforts.length > 0 && scanned.efforts[0] ? scanned.efforts[0] : null; let signature: string | null = null; let version: string | null = null; if (scanned.sigs.length > 0) { const m = /^(v[0-9]+):([0-9a-fA-F]+)$/.exec(scanned.sigs[0].trim()); if (m) { version = m[1] ?? null; signature = m[2] ?? null; } } return { route, effort, signature, version }; } export function verifyAndExtractDirectives( body: unknown, key: string, allowLegacyDirective?: ( route: string, effort: NonNullable | null, ) => boolean, ): { route: string | null; effort: NonNullable | null; isSigned: boolean; isLegacyMatch?: boolean; } { const scanned = scanSystemDirectives(body); if (scanned.routes.length > 1 || scanned.efforts.length > 1 || scanned.sigs.length > 1) { throw new AnthropicRequestError("conflicting subagent directives in system prompt"); } if (scanned.sigs.length === 1) { const rawSig = scanned.sigs[0].trim(); const sigMatch = /^(v[0-9]+):([0-9a-fA-F]+)$/.exec(rawSig); if (!sigMatch) { throw new AnthropicRequestError("malformed signed subagent directive: invalid format"); } const version = sigMatch[1]; const signature = sigMatch[2]; if (version !== "v1") { throw new AnthropicRequestError(`unsupported signed subagent directive version: ${version}`); } if (signature.length !== 64 || !/^[0-9a-f]{64}$/i.test(signature)) { throw new AnthropicRequestError("malformed signed subagent directive: invalid signature length or encoding"); } if (scanned.routes.length === 0 || !scanned.routes[0].trim()) { throw new AnthropicRequestError("malformed signed subagent directive: missing route"); } const route = scanned.routes[0].trim(); const effort = scanned.efforts.length > 0 && scanned.efforts[0].trim() ? scanned.efforts[0].trim() : null; const valid = verifyDirectiveSignature(route, effort, signature, key); if (!valid) { throw new AnthropicRequestError("invalid signed subagent directive: signature verification failed"); } const validEffort = effort && ["low", "medium", "high", "xhigh", "max"].includes(effort) ? (effort as NonNullable) : null; return { route, effort: validEffort, isSigned: true, }; } // Unsigned path // If unsigned ocx-effort is present without ocx-route: it is ignored and does not override effort or routing. if (scanned.routes.length === 0 || !scanned.routes[0].trim()) { return { route: null, effort: null, isSigned: false, isLegacyMatch: false }; } const route = scanned.routes[0].trim(); const rawEffort = scanned.efforts.length > 0 && scanned.efforts[0].trim() ? scanned.efforts[0].trim() : null; const effort = rawEffort && ["low", "medium", "high", "xhigh", "max"].includes(rawEffort) ? (rawEffort as NonNullable) : null; // Unsigned compatibility is opt-in and caller-authorized. Without the // active-roster predicate, an untrusted prompt directive is ignored. if (!allowLegacyDirective?.(route, effort)) { return { route: null, effort: null, isSigned: false, isLegacyMatch: false }; } return { route, effort, isSigned: false, isLegacyMatch: true, }; } /** Injected-skill payloads below this size are never stubbed (not worth it). */ const SKILL_ELISION_MIN_CHARS = 10_000; const SKILL_TEXT_MARKER = "Base directory for this skill: "; interface SkillElisionContext { /** Skill-tool call ids whose input names a blocked skill (result-body carrier). */ callIds: ReadonlySet; /** Lowercased blocked skill names (text-block carrier). */ names: readonly string[]; } const NO_ELISION: SkillElisionContext = { callIds: new Set(), names: [] }; /** * Claude Code 2.1.207 (live capture, devlog 060 follow-up): the Skill tool_result is * a tiny "Launching skill: " note; the actual ~570k-char document bundle rides * as a SEPARATE text block in the same user message, whose first line is * `Base directory for this skill: /`. Stub that block when the * directory basename matches a blocked skill. */ function maybeElideSkillText(text: string, names: readonly string[]): string { if (names.length === 0 || text.length < SKILL_ELISION_MIN_CHARS) return text; if (!text.startsWith(SKILL_TEXT_MARKER)) return text; const firstLineEnd = text.indexOf("\n"); const dir = text.slice(SKILL_TEXT_MARKER.length, firstLineEnd === -1 ? text.length : firstLineEnd).trim(); // Windows clients send `C:\Users\...\claude-api`; normalize separators before // basenaming (repo precedent: src/codex/inject.ts isOpencodexCatalogPath). const base = dir.replace(/\\/g, "/").split("/").filter(Boolean).pop()?.toLowerCase() ?? ""; if (!names.includes(base)) return text; return `[opencodex] '${base}' skill document bundle (${text.length} chars) elided for routed models ` + "(claudeCode.blockedSkills). The skill is loaded; answer from general knowledge instead of citing the bundle."; } function skillElisionStub(callId: string): string { return "[opencodex] Skill document bundle elided for routed models (claudeCode.blockedSkills). " + `The skill loaded, but its reference documents were removed to save context (call ${callId}). ` + "Answer from general knowledge instead of citing the bundle."; } /** Collect Skill-tool call ids whose input names a blocked skill. */ function blockedSkillCallIds(messages: readonly unknown[], blocked: readonly string[]): Set { const ids = new Set(); if (blocked.length === 0) return ids; const needles = blocked.map(name => name.toLowerCase()).filter(name => name.length > 0); if (needles.length === 0) return ids; for (const msg of messages) { if (!isRec(msg) || msg.role !== "assistant" || !Array.isArray(msg.content)) continue; for (const block of msg.content) { if (!isRec(block) || block.type !== "tool_use" || block.name !== "Skill") continue; if (typeof block.id !== "string" || block.id.length === 0) continue; const inputJson = JSON.stringify(block.input ?? {}).toLowerCase(); if (needles.some(name => inputJson.includes(name))) ids.add(block.id); } } return ids; } /** * Claude Code (observed 2026-07-11, real CLI smoke) sends `role:"system"` entries in * `messages` despite the published API having no system role. Map them to Responses * instructions text: the native ChatGPT backend rejects system message items in * `input` ("System messages are not allowed", verified live), so folding into * `instructions` is the only shape that works on every route. */ function systemMessageText(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; const parts: string[] = []; for (const raw of content) { if (isRec(raw) && raw.type === "text" && typeof raw.text === "string") parts.push(raw.text); } return parts.join("\n\n"); } function userMessageToItems( content: unknown, input: Rec[], elide: SkillElisionContext = NO_ELISION, definitions: ReadonlyMap = new Map(), ): void { if (typeof content === "string") { if (content.length > 0) pushUserMessage(input, [{ type: "input_text", text: content }]); return; } if (!Array.isArray(content)) return; // Preserve block order: tool_result blocks become standalone function_call_output // items; contiguous text/image runs become one user message. let pending: Rec[] = []; for (const raw of content) { if (!isRec(raw)) continue; switch (raw.type) { case "text": if (typeof raw.text === "string") pending.push({ type: "input_text", text: maybeElideSkillText(raw.text, elide.names) }); break; case "image": { const img = imageBlockToInputImage(raw); if (img) pending.push(img); break; } case "tool_result": { pushUserMessage(input, pending); pending = []; if (typeof raw.tool_use_id !== "string" || raw.tool_use_id.length === 0) { throw new AnthropicRequestError("tool_result requires tool_use_id"); } input.push({ type: "function_call_output", call_id: raw.tool_use_id, // Blocked-skill bundles are stubbed out for routed models (devlog 060). output: elide.callIds.has(raw.tool_use_id) ? skillElisionStub(raw.tool_use_id) : toolResultOutput(raw), }); break; } case "tool_search_tool_result": { pushUserMessage(input, pending); pending = []; const item = toolSearchOutputItem(raw, definitions); if (item) input.push(item); break; } case "document": // No Responses equivalent for raw document blocks; surface the title so the // model at least sees the attachment happened. pending.push({ type: "input_text", text: `[document${typeof raw.title === "string" ? `: ${raw.title}` : ""}]` }); break; default: break; // thinking/redacted_thinking never appear in user messages; ignore unknowns } } pushUserMessage(input, pending); } function assistantMessageToItems( content: unknown, input: Rec[], definitions: ReadonlyMap, budget: TranslatorBudget, ): void { if (typeof content === "string") { if (content.length > 0) input.push({ type: "message", role: "assistant", content: [{ type: "output_text", text: content }] }); return; } if (!Array.isArray(content)) return; let pendingText: Rec[] = []; const flush = () => { if (pendingText.length > 0) input.push({ type: "message", role: "assistant", content: pendingText }); pendingText = []; }; for (const raw of content) { if (!isRec(raw)) continue; switch (raw.type) { case "text": if (typeof raw.text === "string") pendingText.push({ type: "output_text", text: raw.text }); break; case "tool_use": { flush(); if (typeof raw.id !== "string" || raw.id.length === 0 || typeof raw.name !== "string" || raw.name.length === 0) { throw new AnthropicRequestError("tool_use requires id and name"); } // Lossless mapping for tool_search (Responses private tool_search_call) — reuse existing // function_call wire where direct would collapse the tool identity. if (isToolSearchName(raw.name) && !definitions.has(raw.name)) { let args: string; try { args = JSON.stringify(raw.input ?? {}); } catch { args = "{}"; } input.push({ type: "tool_search_call", call_id: raw.id, arguments: args }); break; } input.push({ type: "function_call", call_id: raw.id, name: raw.name, arguments: JSON.stringify(raw.input ?? {}) }); break; } case "server_tool_use": { if (!isToolSearchName(raw.name)) break; flush(); if (typeof raw.id !== "string" || raw.id.length === 0) { throw new AnthropicRequestError("server_tool_use requires id"); } let args: string; try { args = JSON.stringify(raw.input ?? {}); } catch { args = "{}"; } input.push({ type: "tool_search_call", call_id: raw.id, arguments: args }); break; } case "tool_search_tool_result": { flush(); const item = toolSearchOutputItem(raw, definitions); if (item) input.push(item); break; } case "thinking": { flush(); const thinking = typeof raw.thinking === "string" ? raw.thinking : ""; const signature = typeof raw.signature === "string" ? raw.signature : ""; if (signature.startsWith(OCX_REASONING_PREFIX)) { const owned = decodeReasoningEnvelope(signature, budget); if (!owned) throw new AnthropicRequestError("malformed ocxr1 reasoning signature"); if (Object.hasOwn(owned, "sig")) throw new AnthropicRequestError("OpenCodex reasoning continuity cannot be replayed as an Anthropic signature"); } // Always emit a reasoning item to preserve block order with interleaved tool_use; // empty thinking with a signature still carries replay continuity. const encrypted = signature.length === 0 ? undefined : signature.startsWith(OCX_REASONING_PREFIX) ? signature : encodeReasoningEnvelope({ sig: signature }, budget); if (encrypted) budget.chargeRetained(2 * encrypted.length, { kind: "reasoning" }); if (thinking.length === 0 && !encrypted) break; input.push({ type: "reasoning", id: `rs_${crypto.randomUUID().replace(/-/g, "")}`, summary: thinking.length > 0 ? [{ type: "summary_text", text: thinking }] : [], ...(encrypted ? { encrypted_content: encrypted } : {}) }); break; } case "redacted_thinking": { flush(); const data = typeof raw.data === "string" ? raw.data : ""; if (data.length > 0) { const encrypted = encodeReasoningEnvelope({ red: [data] }, budget); budget.chargeRetained(2 * encrypted.length, { kind: "reasoning" }); input.push({ type: "reasoning", id: `rs_${crypto.randomUUID().replace(/-/g, "")}`, summary: [], encrypted_content: encrypted }); } break; } default: break; } } flush(); } function toolsToResponses(tools: unknown): Rec[] | undefined { if (!Array.isArray(tools) || tools.length === 0) return undefined; const out: Rec[] = []; for (const raw of tools) { if (!isRec(raw)) continue; const type = typeof raw.type === "string" ? raw.type : ""; if (type.startsWith("web_search")) { out.push({ type: "web_search" }); // hosted sidecar path continue; } if (type === "tool_search" || type.startsWith("tool_search_tool_")) { out.push({ type: "tool_search" }); continue; } const mapped = functionToolToResponses(raw); if (mapped) { out.push(mapped); continue; } // Other server tools (bash_*, text_editor_*, ...) have no routed equivalent: drop. } return out.length > 0 ? out : undefined; } function findDeclaredTool(tools: unknown, name: string): Rec | undefined { if (!Array.isArray(tools)) return undefined; for (const raw of tools) { if (!isRec(raw)) continue; if (raw.name === name) return raw; } for (const raw of tools) { if (!isRec(raw)) continue; const type = typeof raw.type === "string" ? raw.type : ""; if (type.startsWith("web_search") && isClaudeWebSearchToolName(name)) return raw; if ((type === "tool_search" || type.startsWith("tool_search_tool_")) && isToolSearchName(name)) return raw; } return undefined; } function toolChoiceToResponses(choice: unknown, body: Rec, rawTools?: unknown): void { if (!isRec(choice)) return; if (choice.disable_parallel_tool_use === true) body.parallel_tool_calls = false; switch (choice.type) { case "auto": body.tool_choice = "auto"; break; case "none": body.tool_choice = "none"; break; case "any": body.tool_choice = "required"; break; case "tool": { if (typeof choice.name !== "string" || choice.name.length === 0) { throw new AnthropicRequestError("tool_choice.tool requires a name"); } // Anthropic represents hosted WebSearch as a named tool choice, while // Responses requires the choice type to match the hosted declaration. // Preserve forced-tool intent rather than weakening it to `auto`. const declared = findDeclaredTool(rawTools, choice.name); const declType = declared && typeof declared.type === "string" ? declared.type : ""; if (declType.startsWith("web_search")) { body.tool_choice = { type: "web_search" }; } else if (declType === "tool_search" || declType.startsWith("tool_search_tool_")) { body.tool_choice = { type: "tool_search" }; } else { body.tool_choice = { type: "function", name: choice.name }; } break; } default: break; } } /** Recursive canonical JSON (keys sorted at every depth) — stable cache-cohort input. */ function canonicalJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; if (value && typeof value === "object") { const entries = Object.entries(value as Rec).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0); return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`; } return JSON.stringify(value) ?? "null"; } /** Provenance of the generated prompt_cache_key (never serialized into the wire body). */ export type ClaudeCacheKeySource = "metadata" | "system" | null; export interface ClaudeInboundTranslation { body: Rec; cacheKeySource: ClaudeCacheKeySource; } /** * Translate an Anthropic Messages request body into a /v1/responses request body. * Throws AnthropicRequestError (-> 400 invalid_request_error) on malformed input. */ export function anthropicToResponsesBody(raw: unknown, cc?: OcxClaudeCodeConfig): Rec { return anthropicToResponsesTranslation(raw, cc).body; } /** * Full translation result: the wire body plus the prompt-cache-key provenance as an * OUT-OF-BODY tuple (audit 133 R3#1 — an in-body marker would leak upstream through * the native Responses forward and 400). */ export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCodeConfig, budget?: TranslatorBudget): ClaudeInboundTranslation { const activeBudget = budget ?? createTranslatorBudget(); try { return translateAnthropicRequest(raw, cc, activeBudget); } finally { if (!budget) activeBudget.dispose(); } } function translateAnthropicRequest(raw: unknown, cc: OcxClaudeCodeConfig | undefined, budget: TranslatorBudget): ClaudeInboundTranslation { if (!isRec(raw)) throw new AnthropicRequestError("request body must be a JSON object"); if (typeof raw.model !== "string" || raw.model.length === 0) { throw new AnthropicRequestError("model is required"); } if (!Array.isArray(raw.messages) || raw.messages.length === 0) { throw new AnthropicRequestError("messages must be a non-empty array"); } const input: Rec[] = []; const systemParts: string[] = []; const topLevelSystem = systemToInstructions(raw.system); if (topLevelSystem !== undefined) systemParts.push(topLevelSystem); const blockedNames = effectiveBlockedSkillNames(cc); const elide: SkillElisionContext = { callIds: blockedSkillCallIds(raw.messages, blockedNames), names: blockedNames, }; const definitions = toolDefinitionsByName(raw.tools); for (const msg of raw.messages) { if (!isRec(msg)) throw new AnthropicRequestError("each message must be an object"); if (msg.role === "user") userMessageToItems(msg.content, input, elide, definitions); else if (msg.role === "assistant") assistantMessageToItems(msg.content, input, definitions, budget); else if (msg.role === "system") { const text = systemMessageText(msg.content); if (text.length > 0) systemParts.push(text); } else throw new AnthropicRequestError(`unsupported message role: ${String(msg.role)}`); } const body: Rec = { model: resolveInboundModel(raw.model, cc), input, store: false, stream: raw.stream === true, }; if (systemParts.length > 0) body.instructions = systemParts.join("\n\n"); const tools = toolsToResponses(raw.tools); if (tools) body.tools = tools; toolChoiceToResponses(raw.tool_choice, body, raw.tools); if (typeof raw.service_tier === "string" && raw.service_tier.length > 0) body.service_tier = raw.service_tier; if (typeof raw.max_tokens === "number") body.max_output_tokens = raw.max_tokens; if (typeof raw.temperature === "number") body.temperature = raw.temperature; if (typeof raw.top_p === "number") body.top_p = raw.top_p; // top_k: accepted and dropped (no Responses equivalent). if (Array.isArray(raw.stop_sequences) && raw.stop_sequences.length > 0) { body.stop = raw.stop_sequences.filter((s): s is string => typeof s === "string"); } const outputConfigFormat = formatFromOutputConfig(raw.output_config, raw.output_format); if (outputConfigFormat) body.text = { format: outputConfigFormat }; let cacheKeySource: ClaudeCacheKeySource = null; if (isRec(raw.metadata) && typeof raw.metadata.user_id === "string") { body.user = raw.metadata.user_id; // OpenAI-side prompt caching is routed by prompt_cache_key (Codex clients send // their session id; without it consecutive /v1/messages turns reported // cached_tokens: 0 on the ChatGPT backend — devlog 090). Claude Code's // metadata.user_id embeds the session uuid, so hashing it yields a stable // per-session key with a bounded length/charset. body.prompt_cache_key = createHash("sha256").update(raw.metadata.user_id).digest("hex").slice(0, 32); cacheKeySource = "metadata"; } else if (systemParts.length > 0) { // Claude Desktop sends no metadata.user_id (H1, devlog 130): without any key the // ChatGPT/OpenAI backends reported cached_tokens:0 on every turn. Fall back to a // cache-cohort hash (devlog 260712 B4 + Pro review 012): fingerprint what the // upstream actually receives — resolved model, post-translation system, and the // FULL translated tool definitions in WIRE ORDER (sorting the hash while sending // a different order would break the key↔prefix correspondence). canonical JSON // (recursive key sort) + a version field so future normalization changes never // mix cohorts. system-only keys herded different models/toolsets into one key // and burned OpenAI's ~15 RPM per-key routing budget (audit R1#4/R2#5/R1#10). // Exact-prefix matching still isolates content; the key only steers routing // affinity. Callers must NOT synthesize a session_id header from this fallback // (audit 133 R2#3). body.prompt_cache_key = createHash("sha256") .update(canonicalJson({ version: 2, model: body.model, system: systemParts, tools: Array.isArray(body.tools) ? body.tools : [], })) .digest("hex").slice(0, 32); cacheKeySource = "system"; } const thinking = raw.thinking; const outputConfigEffort = effortFromOutputConfig(raw.output_config); const thinkingDisabled = isRec(thinking) && thinking.type === "disabled"; if (thinkingDisabled) { // An explicit "disabled" is an instruction, not an absence. Dropping it made this // indistinguishable from a request that never mentioned thinking — and for models that // think by default, omission means thinking is ON, sharing the caller's max_tokens (#545). // `none` is the effort disable sentinel. It is not a valid OpenAI summary // value, so do not attach the similarly named internal catalog sentinel. body.reasoning = { effort: "none" }; } else if (isRec(thinking) || outputConfigEffort !== undefined) { const reasoning: Rec = { summary: "auto" }; if (outputConfigEffort !== undefined) { // Adaptive wire: /effort arrives as output_config.effort (devlog 080). reasoning.effort = outputConfigEffort; } else if (isRec(thinking) && thinking.type === "enabled" && typeof thinking.budget_tokens === "number") { reasoning.effort = effortForThinkingBudget(thinking.budget_tokens); } body.reasoning = reasoning; } return { body, cacheKeySource }; }