/** * Claude Code inbound: Anthropic Messages API request -> internal /v1/responses body. * * Design (devlog/260711_claude_inbound/010, 003_evidence.md): * - translate-and-replay: the produced body MUST pass the real responsesRequestSchema * parse so routing/OAuth/pool/failover are inherited unchanged. * - thinking/redacted_thinking replay is preserved in Responses reasoning items; * signatures and redacted payloads travel in bounded ocxr1 envelopes. * - 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 { 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, toolsToResponses, toolChoiceToResponses } from "./inbound-content-options"; import { stabilizeClaudeInstructionsForPromptCache } from "./inbound-cache-stabilize"; import { decodeReasoningEnvelope, encodeReasoningEnvelope, OCX_REASONING_PREFIX } from "../responses/reasoning-envelope"; import { inlineDocumentMarker } from "../responses/inline-document"; import { createTranslatorBudget, type TranslatorBudget } from "../lib/translator-budget"; import { collectAnthropicCacheTtls } from "../adapters/anthropic/cache-retention"; /** * The reference an Anthropic image block carries, as the Responses `image_url` string the bridge * builds from it, or `null` for a source this route does not read. A file-backed source refuses. * Shared with the direct Messages decoder (`src/protocols/codecs/messages-ir.ts`). */ export function anthropicImageBlockUrl(block: Rec): string | 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 `data:${media};base64,${source.data}`; } if (source.type === "url" && typeof source.url === "string") { return source.url; } return null; } function imageBlockToInputImage(block: Rec): Rec | null { const url = anthropicImageBlockUrl(block); return url === null ? null : { type: "input_image", image_url: url }; } export function documentTitle(block: Rec): string | undefined { return typeof block.title === "string" && block.title.length > 0 ? block.title : undefined; } /** A base64 document's bytes as a `data:` URL plus its title, or `null` for any other source. */ export function anthropicDocumentData(block: Rec): { fileData: string; title?: string } | null { const source = block.source; if (!isRec(source) || source.type !== "base64") return null; const mediaType = typeof source.media_type === "string" && source.media_type.length > 0 ? source.media_type : "application/octet-stream"; if (typeof source.data !== "string" || source.data.length === 0) return null; const title = documentTitle(block); return { fileData: `data:${mediaType};base64,${source.data}`, ...(title !== undefined ? { title } : {}) }; } /** An Anthropic base64 document as the Responses `input_file` block that carries its bytes. */ function documentBlockToInputFile(block: Rec): Rec | null { const document = anthropicDocumentData(block); if (!document) return null; return { type: "input_file", file_data: document.fileData, ...(document.title !== undefined ? { filename: document.title } : {}), }; } 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 === "tool_reference" && typeof item.tool_name === "string" && item.tool_name.length > 0) { // Responses has no tool_reference result carrier. Preserve ToolSearch's loaded-name // evidence as result text so a reference-only reply does not become empty (#6647). // This describes client output; it must not create tools or widen authorization. // Text-only tool output is later joined without separators, so the marker carries its // own line breaks to keep each loaded name distinct from its neighbours. const prev = out[out.length - 1]; const lead = prev?.type === "input_text" && typeof prev.text === "string" && !prev.text.endsWith("\n") ? "\n" : ""; out.push({ type: "input_text", text: `${lead}Tool loaded: ${item.tool_name}\n` }); } else if (item.type === "image") { const img = imageBlockToInputImage(item); if (img) out.push(img); } else if (item.type === "document") { // Tool output has no structured document carrier on this route — the Responses tool // output vocabulary has no input_file block, and every adapter's tool-result path // flattens to text — so this keeps the #939 marker. The user-message branch below is // where bytes survive. Recorded as the remaining half of #5212. out.push({ type: "input_text", text: inlineDocumentMarker(documentTitle(item)) }); } } 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 }); } /** * 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))]; } /** 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: "; const SKILL_TEXT_PATH_MAX_CHARS = 4_096; 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. */ export 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 pathStart = SKILL_TEXT_MARKER.length; const pathPrefix = text.slice(pathStart, pathStart + SKILL_TEXT_PATH_MAX_CHARS + 1); const firstLineEnd = pathPrefix.indexOf("\n"); if (firstLineEnd === -1 && pathPrefix.length > SKILL_TEXT_PATH_MAX_CHARS) return text; const dir = pathPrefix.slice(0, firstLineEnd === -1 ? pathPrefix.length : firstLineEnd).trim(); // Windows clients send `C:\Users\...\claude-api`; normalize separators before // basenaming (repo precedent: src/codex/inject.ts isOpencodexCatalogPath). const normalizedDir = dir.replace(/\\/g, "/").replace(/\/+$/, ""); const base = normalizedDir.slice(normalizedDir.lastIndexOf("/") + 1).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."; } export 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. */ export 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; } /** * Whether translating this Messages body would elide a blocked skill bundle: a user text block * `maybeElideSkillText` would stub, or a tool_result answering a blocked Skill call. Pure; the * managed native Messages lane asks it so a request whose bundle the operator blocked keeps the * translated path that applies the block. */ export function anthropicBodyElidesBlockedSkill(body: unknown, cc?: Pick): boolean { if (!isRec(body) || !Array.isArray(body.messages)) return false; const names = effectiveBlockedSkillNames(cc); if (names.length === 0) return false; const callIds = blockedSkillCallIds(body.messages, names); for (const msg of body.messages) { if (!isRec(msg) || msg.role !== "user" || !Array.isArray(msg.content)) continue; for (const block of msg.content) { if (!isRec(block)) continue; if (block.type === "text" && typeof block.text === "string" && maybeElideSkillText(block.text, names) !== block.text) return true; if (block.type === "tool_result" && typeof block.tool_use_id === "string" && callIds.has(block.tool_use_id)) return true; } } return false; } /** * Claude Code (observed 2026-07-11, real CLI smoke) sends `role:"system"` entries in * `messages` despite the published API having no system role. They are emitted as * chronological `role:"developer"` input items, which keeps the timeline intact and * leaves `instructions` owned solely by the top-level Anthropic `system` field. * * The original mapping folded them into `instructions` because the native ChatGPT * backend rejects `role:"system"` items in `input` ("System messages are not allowed", * verified live). That constraint is real and still respected — but it only rules out * `system`, not `developer`, which every Responses route accepts. Folding meant each * mid-conversation reminder mutated the prompt head, invalidating the upstream KV * prefix and rotating the Desktop `prompt_cache_key` fallback below on every turn * (#4148). */ export 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): 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 "document": // A base64 document now rides the Responses input_file block, so a target with a // counterpart receives the bytes instead of a sentence about them (#5212). Every other // source is a reference this route cannot dereference, and keeps the marker #939 // introduced — which is also what a target with no document representation still sees. pending.push(documentBlockToInputFile(raw) ?? { type: "input_text", text: inlineDocumentMarker(documentTitle(raw)) }); break; default: break; // thinking/redacted_thinking never appear in user messages; ignore unknowns } } pushUserMessage(input, pending); } function assistantMessageToItems(content: unknown, input: Rec[], budget: TranslatorBudget, requestedModel: string, nativeReasoningReplay: Map): 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"); } input.push({ type: "function_call", call_id: raw.id, name: raw.name, arguments: JSON.stringify(raw.input ?? {}) }); 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"); if (owned.nat) { // The provider's own blob goes back only to the model that minted it; any other // model gets the visible text alone, as before this envelope field existed. const nat = owned.nat.model === requestedModel ? owned.nat : undefined; if (nat) { const previous = nativeReasoningReplay.get(nat.enc); nativeReasoningReplay.set(nat.enc, previous === undefined || previous === nat.tag ? nat.tag : ""); } if (nat) budget.chargeRetained(2 * nat.enc.length, { kind: "reasoning" }); if (thinking.length === 0 && !nat) break; input.push({ type: "reasoning", id: nat?.id ?? `rs_${crypto.randomUUID().replace(/-/g, "")}`, summary: thinking.length > 0 ? [{ type: "summary_text", text: thinking }] : [], ...(nat ? { encrypted_content: nat.enc } : {}), }); break; } } 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(); } /** 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; /** * The translated lane places its own prompt-cache breakpoints, so the caller's are dropped; * their TTL is the one thing worth keeping. When every caller breakpoint asks for `ttl: "1h"` * (Claude Code on a subscription marks them so) this maps to `cacheRetention: "long"`. A caller * that pins any breakpoint to an explicit non-1h TTL ("5m") maps to "short": "long" would also * make the moving per-turn breakpoint 1h and bill writes the caller asked to keep at 5m at the * 1h rate. Breakpoints without a TTL express no preference, so they fall through to the * destination default (1h on first-party Anthropic). Read out of body: the Responses body has * no field for it. */ export function callerCacheRetention(raw: unknown): "long" | "short" | undefined { // Top-level automatic caching is a breakpoint too, and content blocks nest (tool_result, // search_result, document sources), so every block container is walked. const ttls = collectAnthropicCacheTtls(raw); if (ttls.some(ttl => ttl !== undefined && ttl !== "1h")) return "short"; return ttls.length > 0 && ttls.every(ttl => ttl === "1h") ? "long" : undefined; } export interface ClaudeInboundTranslation { body: Rec; cacheKeySource: ClaudeCacheKeySource; nativeReasoningReplay: ReadonlyMap; } /** * 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 { assertAnthropicRequestShape(raw); const input: Rec[] = []; const nativeReasoningReplay = new Map(); const blockedNames = effectiveBlockedSkillNames(cc); const elide: SkillElisionContext = { callIds: blockedSkillCallIds(raw.messages, blockedNames), names: blockedNames, }; 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); else if (msg.role === "assistant") assistantMessageToItems(msg.content, input, budget, raw.model, nativeReasoningReplay); else if (msg.role === "system") { const text = systemMessageText(msg.content); // Keep it where the client put it. `developer` is first-class in the Responses // schema and survives parseRequest as a chronological message, where `system` // would be re-hoisted back onto the system prompt and defeat the point. if (text.length > 0) { input.push({ type: "message", role: "developer", content: [{ type: "input_text", 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, }; const system = anthropicSystemLayout(raw.system, cc); if (system.instructions !== undefined) body.instructions = system.instructions; if (system.dynamicNotice !== undefined) { input.push({ type: "message", role: "user", content: [{ type: "input_text", text: system.dynamicNotice }], }); } const tools = toolsToResponses(raw.tools); if (tools) body.tools = tools; const cacheKeySource = applyAnthropicSettings(raw, body, system.cacheSystem, tools); return { body, cacheKeySource, nativeReasoningReplay }; } function assertAnthropicRequestShape(raw: unknown): asserts raw is Rec & { model: string; messages: unknown[] } { 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"); } } /** Where the top-level `system` lands on the bridge body, and what the cache-cohort key hashes. */ export interface AnthropicSystemLayout { /** The Responses `instructions`, when there are any. */ instructions?: string; /** A dynamic notice `stabilizePromptCache` moved out of the system text into a trailing user turn. */ dynamicNotice?: string; cacheSystem: string | string[]; } /** The system text placement `claudeCode.stabilizePromptCache` decides; shared with the direct decoder. */ export function anthropicSystemLayout(system: unknown, cc: OcxClaudeCodeConfig | undefined): AnthropicSystemLayout { const systemParts: string[] = []; const topLevelSystem = systemToInstructions(system); if (topLevelSystem !== undefined) systemParts.push(topLevelSystem); const joinedSystem = systemParts.length > 0 ? systemParts.join("\n\n") : ""; const stabilizePromptCache = cc?.stabilizePromptCache === true; // Desktop fallback hashes raw systemParts unless the caller opted into // harness cleanup. Opt-in then hashes the same string as body.instructions. if (!joinedSystem) return { cacheSystem: systemParts }; if (!stabilizePromptCache) return { instructions: joinedSystem, cacheSystem: systemParts }; // Claude Code appends growing N tokens left // footers (and occasional TaskCreate nudges) into system text. That churn // breaks Muse/Go prefix cache on the Responses instructions prefix even // when tools stay stable. Relocation is caller-opted, not inferred from // a matching suffix or metadata.user_id. const stabilized = stabilizeClaudeInstructionsForPromptCache(joinedSystem); return { ...(stabilized.instructions ? { instructions: stabilized.instructions } : {}), // Token footers are dropped; append nothing if no other notice remains. ...(stabilized.dynamicNotice ? { dynamicNotice: stabilized.dynamicNotice } : {}), cacheSystem: stabilized.instructions, }; } /** * The request settings of a Messages body as a Responses body with no conversation: `input` is * empty and there are no `instructions` or `tools`, but the prompt-cache key is still derived * from the system text and translated tool catalog exactly as the bridge derives it. The * Antigravity direct lane hands this to the Responses core while the conversation is decoded * straight into the IR (`src/protocols/codecs/messages-ir.ts`); both bodies are completed by * `applyAnthropicSettings`. It validates the same request shape, but a conversation error the * bridge would report first is not looked for here: a caller that gets a throw must translate * the full body to learn the bridge's answer. */ export function anthropicSettingsTranslation( raw: unknown, cc?: OcxClaudeCodeConfig, ): { body: Rec; cacheKeySource: ClaudeCacheKeySource } { assertAnthropicRequestShape(raw); const body: Rec = { model: resolveInboundModel(raw.model, cc), input: [], store: false, stream: raw.stream === true, }; const system = anthropicSystemLayout(raw.system, cc); const cacheKeySource = applyAnthropicSettings(raw, body, system.cacheSystem, toolsToResponses(raw.tools)); return { body, cacheKeySource }; } /** Everything after the conversation and tool catalog: choice, limits, format, cache key, reasoning. */ function applyAnthropicSettings( raw: Rec, body: Rec, cacheSystem: string | string[], tools: Rec[] | undefined, ): ClaudeCacheKeySource { toolChoiceToResponses(raw.tool_choice, body); 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, body.model as string); if (outputConfigFormat) body.text = { format: outputConfigFormat }; let cacheKeySource: ClaudeCacheKeySource = null; if (isRec(raw.metadata) && typeof raw.metadata.user_id === "string") { const userIdHash = createHash("sha256").update(raw.metadata.user_id).digest("hex"); // OpenAI and Azure reject `user` longer than 64 chars, and Claude Code's metadata.user_id // is a JSON blob well past that; send its 64-char hash instead of the raw value. body.user = raw.metadata.user_id.length <= 64 ? raw.metadata.user_id : userIdHash; // 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 = userIdHash.slice(0, 32); cacheKeySource = "metadata"; } else if (typeof cacheSystem === "string" || cacheSystem.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). // Outside opt-in, hash the raw systemParts array (pre-stabilize Desktop // key). Opt-in hashes the same string used for body.instructions so the // key tracks the cacheable prefix after peel. body.prompt_cache_key = createHash("sha256") .update(canonicalJson({ version: 2, model: body.model, system: cacheSystem, tools: 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", enabled: false }; } else if (isRec(thinking) || outputConfigEffort !== undefined) { const reasoning: Rec = { summary: isRec(thinking) && thinking.display === "omitted" ? "none" : "auto" }; if (isRec(thinking) && thinking.type === "enabled") reasoning.enabled = true; 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; // store:false returns a provider's encrypted reasoning only on request. The outbound // envelope (`nat`) carries it to the client so the next turn can hand it back. body.include = ["reasoning.encrypted_content"]; } return cacheKeySource; } /** * Remove proxy-owned native blobs before sending a Messages body to native Anthropic. * * Every decode, and every re-encode on top of it, is reserved against the request's translator * budget first (eight bytes per code unit, as `decodeReasoningEnvelope` does), and every new * signature is charged as a retained copy, so a body of near-limit envelopes cannot allocate past the per-turn bound. * Throws TranslatorBudgetExceededError when it would; callers answer 413. */ export function nativeAnthropicProjection(body: Rec, budget: TranslatorBudget): Rec { if (!Array.isArray(body.messages)) return body; let changed = false; const messages = body.messages.flatMap(message => { if (!isRec(message) || message.role !== "assistant" || !Array.isArray(message.content)) return [message]; let localChanged = false; const content = message.content.flatMap(block => { if (!isRec(block) || block.type !== "thinking" || typeof block.signature !== "string" || !block.signature.startsWith(OCX_REASONING_PREFIX)) return [block]; // The decoded object stays alive while it is re-encoded, so its reservation is held until // the new signature exists; the encode copies get their own reservation on top of it. const decodeReservation = budget.reserveTransient(8 * block.signature.length, { kind: "reasoning" }); try { let decoded: unknown; try { decoded = JSON.parse(Buffer.from(block.signature.slice(OCX_REASONING_PREFIX.length), "base64").toString("utf8")); } catch { decoded = undefined; } if (!isRec(decoded)) { localChanged = true; return []; } if (!Object.hasOwn(decoded, "nat")) return [block]; localChanged = true; const { nat: _nat, ...rest } = decoded; if (!rest.sig && !rest.red && !rest.krc && !(typeof rest.txt === "string" && rest.txt.length > 0)) return []; // The re-encoded envelope is never larger than the decoded one (a field was removed), so a // decode-sized reservation bounds the stringify, buffer and base64 copies before they exist. const encodeReservation = budget.reserveTransient(8 * block.signature.length, { kind: "reasoning" }); let signature: string; try { signature = OCX_REASONING_PREFIX + Buffer.from(JSON.stringify(rest), "utf8").toString("base64"); } finally { encodeReservation.release(); } budget.chargeRetained(2 * signature.length, { kind: "reasoning" }); return [{ ...block, signature }]; } finally { decodeReservation.release(); } }); if (!localChanged) return [message]; changed = true; return content.length ? [{ ...message, content }] : []; }); return changed ? { ...body, messages } : body; }