import { isOpenCodeGo, normalizeOpenCodeGoAgentMessages } from "./opencode-go"; import { createHash } from "node:crypto"; import type { IncomingMeta, ProviderAdapter } from "./base"; import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../types"; import { catalogModelSupportsReasoningSummaries } from "../codex/catalog"; import { applyCodexRoutingHint, CODEX_RESPONSES_LITE_HEADER, CODEX_ROUTING_HINT_HEADER } from "../codex/forward-transport-headers"; import { COMPACT_PROMPT, compactionItemToText, decodeCompactionSummary, isCompactionItemType } from "../responses/compaction"; import { collectResponsesToolGroups } from "../responses/tool-groups"; import { isHostedToolUnsupportedForModel } from "../responses/hosted-tool-policy"; import { decodeServerSentEvents } from "../lib/sse-decoder"; import { debugProviderDiagnostic } from "../lib/debug"; import { CODEX_FORWARD_BASE_URL, destinationDecodesNativeCompactionBlob, isCanonicalOpenAiForwardProvider, isOpenAiOperatedResponsesDestination, } from "../providers/openai-tiers"; import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope"; import { configuredReasoningEfforts, mapReasoningEffort, modelRecordValue } from "../reasoning-effort"; import type { TranslatorBudget } from "../lib/translator-budget"; import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-compat"; import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-compat"; import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat"; import { openaiResponsesUrl } from "./openai-responses-url"; import { normalizeResponsesCodeMode } from "./responses-code-mode"; import { injectXaiResponsesXSearch, normalizeXaiResponsesWebSearch } from "./xai-web-search"; import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "./empty-tool-output-annotation"; import { isXaiSchemaTarget, normalizeXaiToolParameters, XaiToolSchemaCompatibilityError, } from "./xai-tool-schema"; import { createAdapterTierMetadata, } from "../providers/fastwire"; // Headers relayed verbatim from the caller in OAuth-passthrough ("forward") mode. // Exported so the web-search sidecar reuses the exact same forwarded-auth set for its ChatGPT call. export const FORWARD_HEADERS = [ "authorization", "chatgpt-account-id", "openai-beta", "originator", "session_id", "session-id", "thread-id", "x-client-request-id", "x-codex-beta-features", "x-codex-installation-id", "x-codex-parent-thread-id", "x-session-id", "x-codex-turn-metadata", "x-codex-turn-state", "x-codex-window-id", "x-oai-attestation", "x-openai-subagent", "x-responsesapi-include-timing-metrics", CODEX_RESPONSES_LITE_HEADER, ]; /** * Sanitize reasoning input by field policy, not by preserving each item's shape. Retaining a * native `encrypted_content` guarantees only that blob value: `status` is always removed; * proxy-owned `ocxr1:` envelopes are always removed; and native blobs are removed when the caller * requests stripping after a route-identity change or opaque-blob recovery. On routed/non-OpenAI * destinations, a present non-array `content` field is omitted. Otherwise non-empty array content * is blanked unless raw reasoning preservation is enabled; removing an `ocxr1:` envelope selects * the same blanking path when non-array omission is not active. */ export function sanitizeReasoningInputContent( body: unknown, opts?: { preserveRawReasoningContent?: boolean; dropNullContentChannel?: boolean; stripEncryptedContent?: boolean; }, ): unknown { if (!body || typeof body !== "object" || Array.isArray(body)) return body; const raw = body as Record; if (!Array.isArray(raw.input)) return body; let changed = false; const input = raw.input.map(item => { if (!item || typeof item !== "object" || Array.isArray(item)) return item; const rec = item as Record; if (rec.type !== "reasoning") return item; const hasRawContent = Array.isArray(rec.content) && rec.content.length > 0; // ocxr1 envelopes are proxy-minted (Anthropic signatures), not OpenAI encryption — the native // backend cannot decrypt them and would reject the request. Strip regardless of content shape. const hasOcxEnvelope = typeof rec.encrypted_content === "string" && rec.encrypted_content.startsWith(OCX_REASONING_PREFIX); const hasOutputStatus = Object.prototype.hasOwnProperty.call(rec, "status"); const hasEncryptedContent = Object.prototype.hasOwnProperty.call(rec, "encrypted_content"); const stripEncryptedContent = hasOcxEnvelope || (opts?.stripEncryptedContent === true && hasEncryptedContent); // Codex serializes an absent reasoning content channel as `"content": null`. The field is // optional and null carries nothing, but a strict gateway rejects the item on its declared type // — xAI answers `Could not decode the compaction blob`, naming the sibling `encrypted_content` // rather than the field it actually refused, which is why this reads as a blob failure. Drop the // key so the item matches the shape the upstream issued. // // Gated to routed destinations. An OpenAI-operated backend rejects a blob-bearing item when its // null `content` channel is deleted (`The encrypted content ... could not be verified`); that // live result establishes this channel constraint, not whole-item shape preservation. The gate // is also why this drop may touch an item that keeps its blob: xAI demonstrably accepts its own // blob without the null channel. This is independent of the output-only status removal below. const dropNullContentChannel = opts?.dropNullContentChannel === true && "content" in rec && !Array.isArray(rec.content); // `status` is output-only. Measured OpenAI reasoning items never contain it, and Grok accepts // its own encrypted_content with status removed. Keeping a foreign status beside a retained // blob makes OpenAI reject the field before blob validation, starving the provenance recovery // of the opaque-blob error it needs. Content blanking remains the separate pre-existing rule. const stripOutputStatus = hasOutputStatus; const blankContent = !dropNullContentChannel && !opts?.preserveRawReasoningContent && (hasRawContent || hasOcxEnvelope); if (!blankContent && !stripOutputStatus && !stripEncryptedContent && !dropNullContentChannel) { return item; } changed = true; const next: Record = { ...rec }; if (dropNullContentChannel) delete next.content; if (stripOutputStatus) delete next.status; if (stripEncryptedContent) delete next.encrypted_content; // Routed models can produce raw `reasoning_text` output items. Codex echoes those in later // native GPT requests, but ChatGPT's Responses backend accepts reasoning input only with empty // `content`; keep summaries/ids and drop the raw content so native passthrough does not 400. // DeepSeek's Responses API instead ACCEPTS plaintext reasoning replay (its compatibility // guide merges reasoning items into the adjacent assistant message), so providers flagged // `preserveResponsesReasoningContent` keep it — deleting valid replay content there breaks // continuations after tool calls (issue #875 family). if (blankContent) next.content = []; return next; }); return changed ? { ...raw, input } : body; } function stripUnsupportedReasoningSummaryDelivery(body: unknown, modelId: string): unknown { if (catalogModelSupportsReasoningSummaries(modelId) !== false) return body; if (!isPlainObject(body) || !isPlainObject(body.stream_options)) return body; if (!("reasoning_summary_delivery" in body.stream_options)) return body; const streamOptions = { ...body.stream_options }; delete streamOptions.reasoning_summary_delivery; const next = { ...body }; if (Object.keys(streamOptions).length > 0) next.stream_options = streamOptions; else delete next.stream_options; return next; } function stripInvalidItemIds(body: unknown): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; const validPrefixes: Record = { message: "msg_", agent_message: "amsg_", reasoning: "rs_", function_call: "fc_", custom_tool_call: "ctc_", tool_search_call: "tsc_", web_search_call: "ws_", }; let changed = false; const input = body.input.map(item => { if (!isPlainObject(item) || typeof item.type !== "string") return item; const validPrefix = validPrefixes[item.type]; if (!validPrefix) return item; if (typeof item.id === "string" && item.id.startsWith(validPrefix)) return item; if (!("id" in item)) return item; changed = true; const next = { ...item }; delete next.id; return next; }); return changed ? { ...body, input } : body; } /** * Codex-private tool fields that only the ChatGPT backend understands. * * A third-party Responses gateway validates its schema and rejects the whole request before * inference — xAI answers `Argument not supported: external_web_access` — so these are removed at * the noncanonical boundary while the tool and every public option stay. * * Keep this a table. Each private bit Codex attaches has so far arrived as its own bespoke strip * with its own traversal, and the traversals disagreed about which containers they covered; a new * one should be a row here instead. `toolTypes` omitted means the field is private on any tool. */ const CANONICAL_ONLY_TOOL_FIELDS: readonly { field: string; toolTypes?: ReadonlySet; capabilityGated?: boolean }[] = [ // ChatGPT's browsing policy bit. The public hosted tool is enabled by its presence alone. // OWNERSHIP: official OpenAI API-key traffic and unclassified gateways ACCEPT this field, so // it is only stripped when the provider capability denies it (supportsOpenAiWebSearchToolFields // === false), matching stripOpenAiOnlyWebSearchFields; see // tests/responses/responses-routed-web-search-fields.test.ts. { field: "external_web_access", toolTypes: new Set(["web_search", "web_search_preview"]), capabilityGated: true }, // Deferred-discovery marker. `activateDeferredTool` clears it only for tools a `tool_search_output` // already loaded, so a still-deferred declaration — including one promoted out of a namespace // group — otherwise reaches the wire carrying it. { field: "defer_loading" }, ]; function stripCanonicalOnlyToolFields(body: unknown, includeCapabilityGated: boolean): unknown { if (!isPlainObject(body)) return body; const rewriteTools = (tools: unknown[]): unknown[] => { let changed = false; const rewritten = tools.map(tool => { if (!isPlainObject(tool)) return tool; let next = tool; for (const { field, toolTypes, capabilityGated } of CANONICAL_ONLY_TOOL_FIELDS) { if (capabilityGated && !includeCapabilityGated) continue; if (!Object.hasOwn(next, field)) continue; if (toolTypes && (typeof next.type !== "string" || !toolTypes.has(next.type))) continue; const { [field]: _private, ...rest } = next; next = rest; } if (next === tool) return tool; changed = true; return next; }); return changed ? rewritten : tools; }; let rewrittenBody = body; if (Array.isArray(body.tools)) { const tools = rewriteTools(body.tools); if (tools !== body.tools) rewrittenBody = { ...rewrittenBody, tools }; } if (!Array.isArray(body.input)) return rewrittenBody; let input: unknown[] | undefined; for (let index = 0; index < body.input.length; index += 1) { const item = body.input[index]; if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) continue; const tools = rewriteTools(item.tools); if (tools === item.tools) continue; input ??= [...body.input]; input[index] = { ...item, tools }; } return input ? { ...rewrittenBody, input } : rewrittenBody; } /** * Codex keeps this ChatGPT-internal item metadata when its configured provider name is `openai`. * Loopback OpenCodex injection intentionally retains that provider identity for history continuity, * even when the proxy ultimately routes the request to a public Responses destination. Those * destinations reject the private field as an unknown `input[*]` parameter, so remove it at the * noncanonical boundary without mutating the caller-owned raw body. */ function stripInternalChatMessageMetadataPassthrough(body: unknown): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; let changed = false; const input = body.input.map(item => { if (!isPlainObject(item) || !Object.hasOwn(item, "internal_chat_message_metadata_passthrough")) { return item; } changed = true; const next = { ...item }; delete next.internal_chat_message_metadata_passthrough; return next; }); return changed ? { ...body, input } : body; } /** * When `store` is false, the upstream API does not persist response items. Any item ID * forwarded in `input` is then interpreted as a reference to a stored item that does not * exist, producing a 404. Strip all item IDs in this case — `call_id` pairing is unaffected. * Matches codex-rs behavior (core/src/client.rs:918-925). */ function stripItemIdsWhenUnstored(body: unknown): unknown { if (!isPlainObject(body) || body.store !== false) return body; if (!Array.isArray(body.input)) return body; let changed = false; const input = body.input.map(item => { if (!isPlainObject(item) || !("id" in item)) return item; changed = true; const next = { ...item }; delete next.id; return next; }); return changed ? { ...body, input } : body; } /** * Normalize replayed compaction items for the destination backend. * * A compaction item carries an `encrypted_content` blob the client replays verbatim on every later * turn, and only the backend that minted it can decode it. Proxy-minted `ocx1:` envelopes are * transparent base64 rather than encryption, so no upstream can read them and they always become * plain user messages. Native blobs have multiple possible minters, so a destination's ability to * decode its own blobs does not make a blob from a previous serving identity portable. On a known * identity mismatch the blob degrades to the same note the bridged parser uses, even when the * destination normally accepts native blobs. Without a known mismatch, the destination capability * keeps the existing behavior. * * A bare `context_compaction` marker carries no blob and is forwarded untouched. */ function scrubOcxCompactionItems( body: unknown, destinationDecodesNativeBlob: boolean, threadServingIdentityChanged: boolean, ): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; let changed = false; const input = body.input.map(item => { if (!isPlainObject(item) || !isCompactionItemType(item.type)) return item; const encrypted = typeof item.encrypted_content === "string" ? item.encrypted_content : undefined; if (encrypted === undefined) return item; if ( decodeCompactionSummary(encrypted) === null && destinationDecodesNativeBlob && !threadServingIdentityChanged ) return item; changed = true; return { type: "message", role: "user", content: [{ type: "input_text", text: compactionItemToText(encrypted) }], }; }); return changed ? { ...body, input } : body; } /** * Strip unsupported `reasoning` sub-parameters for native slugs that reject them (e.g. Spark). * codex-rs injects `reasoning.context` and `reasoning.summary` based on catalog flags; Spark's * backend rejects both. The catalog fix prevents `use_responses_lite` from being set, but this * is a defense-in-depth guard so stale on-disk catalogs don't break until the user runs `ocx sync`. */ function stripUnsupportedReasoningParams(body: unknown): unknown { if (!isPlainObject(body)) return body; const model = typeof body.model === "string" ? body.model : ""; if (!model.includes("codex-spark")) return body; if (!isPlainObject(body.reasoning)) return body; const reasoning = body.reasoning as Record; // Spark supports reasoning.effort but rejects context, summary, and generate_summary. const { context: _ctx, summary: _sum, generate_summary: _gs, ...rest } = reasoning; if (_ctx === undefined && _sum === undefined && _gs === undefined) return body; return { ...body, reasoning: Object.keys(rest).length > 0 ? rest : undefined }; } /** * GPT-5.6 retired the legacy 24-hour retention field, and the ChatGPT backend 400s the whole * request when that field is present (issue #2092). * * The retired field is NOT translated to the replacement: 5.6 carries a different TTL contract, * and implicit caching still applies when the caller sent no replacement options. Inventing a * value here would silently change a caching decision the caller never made. * * Deliberately narrow on both axes, because a wider strip is a behavior change rather than a fix: * only the gpt-5.6 family (an older model may still honor the field), and only on the canonical * ChatGPT backend, which is the deployment that rejects it. Matching is exact-or-dashed-prefix so * a future `gpt-5.60` is not swept up by a bare `startsWith`. */ function stripDeprecatedPromptCacheRetention(body: unknown, modelId: unknown): unknown { if (!isPlainObject(body)) return body; if (typeof modelId !== "string") return body; if (modelId !== "gpt-5.6" && !modelId.startsWith("gpt-5.6-")) return body; if (!Object.hasOwn(body, "prompt_cache_retention")) return body; const { prompt_cache_retention: _retention, ...rest } = body; return rest; } /** * Public Responses clients can send `prompt_cache_options`, but the canonical ChatGPT Codex * backend rejects the top-level field before inference (issue #2765). Custom forward gateways and * API-key Responses providers own different wire contracts, so the caller applies this only after * the canonical destination predicate succeeds. */ function stripCanonicalForwardPromptCacheOptions(body: unknown): unknown { if (!isPlainObject(body) || !Object.hasOwn(body, "prompt_cache_options")) return body; const { prompt_cache_options: _options, ...rest } = body; return rest; } /** * A false model capability prevents Codex from emitting summary fields after the catalog refresh. * Strip them here as well so an already-running client with a stale catalog cannot keep sending an * upstream-rejected `reasoning_summary_delivery` value (issue #323). */ function stripDisabledReasoningSummaries( body: unknown, provider: OcxProviderConfig, modelId: string, ): unknown { if (modelRecordValue(provider.modelSupportsReasoningSummaries, modelId) !== false || !isPlainObject(body)) { return body; } let changed = false; let streamOptions = body.stream_options; if (isPlainObject(streamOptions) && Object.hasOwn(streamOptions, "reasoning_summary_delivery")) { const { reasoning_summary_delivery: _delivery, ...rest } = streamOptions; streamOptions = rest; changed = true; } let reasoning = body.reasoning; if (isPlainObject(reasoning)) { const { summary: _summary, generate_summary: _generateSummary, ...rest } = reasoning; if (_summary !== undefined || _generateSummary !== undefined) { reasoning = rest; changed = true; } } if (!changed) return body; return { ...body, ...(isPlainObject(streamOptions) && Object.keys(streamOptions).length > 0 ? { stream_options: streamOptions } : { stream_options: undefined }), ...(isPlainObject(reasoning) && Object.keys(reasoning).length > 0 ? { reasoning } : { reasoning: undefined }), }; } /** * Hide a no-op Responses verbosity control from the wire as well as the catalog. This runs at * final serialization so a stale catalog or direct caller cannot bypass the capability. Other * `text` settings (notably structured-output `format`) remain untouched. */ function stripDisabledVerbosity( body: unknown, provider: OcxProviderConfig, modelId: string, ): unknown { if (modelRecordValue(provider.modelSupportsVerbosity, modelId) !== false || !isPlainObject(body)) { return body; } if (!isPlainObject(body.text) || !Object.hasOwn(body.text, "verbosity")) return body; const { verbosity: _verbosity, ...rest } = body.text; return { ...body, ...(Object.keys(rest).length > 0 ? { text: rest } : { text: undefined }), }; } /** * Normalize only the delivery enum Codex already emitted. Do not inject a field into callers that * did not request summaries, and leave every unconfigured provider/model byte-for-byte unchanged. */ function normalizeConfiguredReasoningSummaryDelivery( body: unknown, provider: OcxProviderConfig, modelId: string, ): unknown { const delivery = modelRecordValue(provider.modelReasoningSummaryDelivery, modelId); if (delivery === undefined || !isPlainObject(body) || !isPlainObject(body.stream_options)) return body; if (!Object.hasOwn(body.stream_options, "reasoning_summary_delivery")) return body; if (body.stream_options.reasoning_summary_delivery === delivery) return body; return { ...body, stream_options: { ...body.stream_options, reasoning_summary_delivery: delivery, }, }; } /** * Comprehensive Spark compatibility layer. codex-rs emits five tool types (function, * namespace, tool_search, web_search, custom) plus extensions (defer_loading, * parallel_tool_calls, tool_search_call/output items). Spark's serving path only * supports flat function tools and hosted web_search. This function: * - Flattens MCP-style namespace tools → promotes inner functions to top level. The reserved * `functions` group is kept as a group (#3217): Codex 0.147+ sends every ordinary client tool * inside it on Responses Lite, the backend accepts the group as-is, and flattening it changes * what the backend answers with — a `custom_tool_call` carrying `namespace: "exec"`, which * codex-rs concatenates into the unroutable `execexec`. Traced on a live proxy: with the * group intact the same backend returns the bare `exec` call and the turn completes. * - Drops unsupported tool types (tool_search, custom) * - Strips defer_loading from function tools * - Strips namespace from input items * - Drops tool_search_call/tool_search_output input items * - Sets parallel_tool_calls to false */ function stripSparkCompatibility(body: unknown): unknown { if (!isPlainObject(body)) return body; const model = typeof body.model === "string" ? body.model : ""; if (!model.includes("codex-spark")) return body; let changed = false; const SPARK_SAFE_TOOL_TYPES = new Set(["function", "web_search", "web_search_preview"]); // Inside the reserved group Codex sends freeform `custom` tools (code-mode `exec`) and the // backend accepts them there; the top-level "drop custom" rule stays for flattened groups. const SPARK_SAFE_FUNCTIONS_GROUP_CHILD_TYPES = new Set(["function", "custom"]); const filterSparkFunctionsGroup = (group: Record): Record | undefined => { if (!Array.isArray(group.tools)) return undefined; let groupChanged = false; const children: unknown[] = []; for (const child of group.tools) { if (!isPlainObject(child) || typeof child.type !== "string" || !SPARK_SAFE_FUNCTIONS_GROUP_CHILD_TYPES.has(child.type)) { groupChanged = true; continue; } if (child.type === "function" && "defer_loading" in child) { const { defer_loading: _, ...rest } = child; groupChanged = true; children.push(rest); continue; } children.push(child); } if (children.length === 0) return undefined; return groupChanged ? { ...group, tools: children } : group; }; let tools = body.tools; if (Array.isArray(tools)) { const flattened: unknown[] = []; for (const t of tools) { if (isPlainObject(t) && t.type === "namespace" && t.name === SPARK_RESERVED_FUNCTIONS_NAMESPACE) { const kept = filterSparkFunctionsGroup(t); if (kept !== t) changed = true; if (kept) flattened.push(kept); } else if (isPlainObject(t) && t.type === "namespace") { changed = true; if (Array.isArray(t.tools)) { for (const inner of t.tools) flattened.push(inner); } } else if (isPlainObject(t) && typeof t.type === "string" && !SPARK_SAFE_TOOL_TYPES.has(t.type)) { changed = true; } else { flattened.push(t); } } // Strip defer_loading from promoted/remaining function tools. tools = flattened.map(t => { if (isPlainObject(t) && t.type === "function" && "defer_loading" in t) { const { defer_loading: _, ...rest } = t; changed = true; return rest; } return t; }); } // Clean input items: strip namespace, drop tool_search_call/tool_search_output. const SPARK_UNSUPPORTED_INPUT_TYPES = new Set([ "tool_search_call", "tool_search_output", "custom_tool_call", "custom_tool_call_output", ]); let input = body.input; if (Array.isArray(input)) { const cleaned: unknown[] = []; for (const item of input) { if (isPlainObject(item) && typeof item.type === "string" && SPARK_UNSUPPORTED_INPUT_TYPES.has(item.type)) { changed = true; continue; } // Process additional_tools items: filter their inner tools array the same way. if (isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { const innerTools = item.tools as unknown[]; const filteredInner: unknown[] = []; for (const t of innerTools) { if (isPlainObject(t) && t.type === "namespace" && t.name === SPARK_RESERVED_FUNCTIONS_NAMESPACE) { const kept = filterSparkFunctionsGroup(t); if (kept !== t) changed = true; if (kept) filteredInner.push(kept); } else if (isPlainObject(t) && t.type === "namespace") { changed = true; if (Array.isArray(t.tools)) { for (const fn of t.tools) filteredInner.push(fn); } } else if (isPlainObject(t) && typeof t.type === "string" && !SPARK_SAFE_TOOL_TYPES.has(t.type)) { changed = true; // drop custom, tool_search, etc. } else { filteredInner.push(t); } } // Strip defer_loading from remaining function tools. const cleanedInner = filteredInner.map(t => { if (isPlainObject(t) && t.type === "function" && "defer_loading" in t) { const { defer_loading: _, ...rest } = t; changed = true; return rest; } return t; }); cleaned.push({ ...item, tools: cleanedInner }); continue; } if (isPlainObject(item) && "namespace" in item) { const { namespace: _, ...rest } = item; changed = true; cleaned.push(rest); } else { cleaned.push(item); } } if (changed) input = cleaned; } // Force parallel_tool_calls off for Spark. const extraOverrides: Record = {}; if (body.parallel_tool_calls === true) { extraOverrides.parallel_tool_calls = false; changed = true; } return changed ? { ...body, ...(tools !== body.tools ? { tools } : {}), ...(input !== body.input ? { input } : {}), ...extraOverrides } : body; } function isPlainObject(v: unknown): v is Record { return !!v && typeof v === "object" && !Array.isArray(v); } /** Codex's reserved client-tool group on Responses Lite; carries no wire prefix. */ const SPARK_RESERVED_FUNCTIONS_NAMESPACE = "functions"; function isLiteSparkRequestBody(body: unknown): boolean { if (!isPlainObject(body)) return false; const model = typeof body.model === "string" ? body.model : ""; if (!model.includes("codex-spark")) return false; const input = body.input; if (!Array.isArray(input)) return false; for (const item of input) { if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) continue; for (const tool of item.tools) { if (isPlainObject(tool) && tool.type === "namespace" && tool.name === SPARK_RESERVED_FUNCTIONS_NAMESPACE) { return true; } } } return false; } /** * Apply the routed provider's real effort ladder to an existing Responses reasoning field. * Native forward requests keep the server-owned native clamp; unknown third-party ladders stay * byte-equivalent instead of acquiring a policy from this adapter. */ function mapRoutedResponsesReasoningEffort( body: unknown, provider: OcxProviderConfig, modelId: string, ): unknown { if (provider.authMode === "forward") return body; if (configuredReasoningEfforts(provider, modelId) === undefined) return body; if (!isPlainObject(body) || !isPlainObject(body.reasoning)) return body; const declaredEfforts = modelRecordValue(provider.modelReasoningEfforts, modelId) ?? provider.reasoningEfforts; // An explicitly empty ladder means no effort control, not no reasoning output. // Omit only effort so the upstream default applies; unknown/non-rankable ladders stay untouched. if (declaredEfforts?.length === 0 && Object.hasOwn(body.reasoning, "effort")) { const { effort: _effort, ...reasoning } = body.reasoning; return { ...body, reasoning: Object.keys(reasoning).length > 0 ? reasoning : undefined }; } const requested = body.reasoning.effort; if (typeof requested !== "string") return body; const mapped = mapReasoningEffort(provider, modelId, requested); if (!mapped || mapped === requested) return body; return { ...body, reasoning: { ...body.reasoning, effort: mapped } }; } function normalizeFunctionToolSchema(tool: unknown, xaiTarget: boolean): unknown | undefined { if (!isPlainObject(tool) || tool.type !== "function") return tool; if (xaiTarget) { const parameters = normalizeXaiToolParameters(isPlainObject(tool.parameters) ? tool.parameters : {}); return parameters === undefined ? undefined : { ...tool, parameters }; } if (isPlainObject(tool.parameters) && tool.parameters.type === "object") return tool; return { ...tool, parameters: { ...(isPlainObject(tool.parameters) ? tool.parameters : {}), type: "object" }, }; } /** * Re-point `tool_choice` after an incompatible function was dropped from the catalog. Names here * are already wire names, because namespace lowering rewrote the declarations and the selector * together before this runs. A selector left naming an omitted tool reaches Grok as a dangling * reference it rejects, and silently relaxing it to `auto` is worse: the turn would quietly * proceed without the tool the caller required. So an `allowed_tools` list drops the omitted * entries while any remain, and a selection with nothing left to point at fails locally with the * same 400 the caller gets for a tool catalog this proxy cannot lower. */ function reconcileToolChoiceForOmittedTools( body: Record, omittedFunctionNames: ReadonlySet, ): Record { if (omittedFunctionNames.size === 0) return body; const toolChoice = body.tool_choice; if (!isPlainObject(toolChoice)) return body; const refuse = (name: string): never => { throw new XaiToolSchemaCompatibilityError( `tool_choice requires function "${name}", but its parameter schema cannot be represented for this destination; ` + "relax tool_choice or simplify the tool's parameter schema", ); }; if (toolChoice.type === "function" && typeof toolChoice.name === "string") { return omittedFunctionNames.has(toolChoice.name) ? refuse(toolChoice.name) : body; } if (toolChoice.type === "allowed_tools" && Array.isArray(toolChoice.tools)) { const omitted = toolChoice.tools.filter(tool => isPlainObject(tool) && tool.type === "function" && typeof tool.name === "string" && omittedFunctionNames.has(tool.name)); if (omitted.length === 0) return body; const kept = toolChoice.tools.filter(tool => !omitted.includes(tool)); if (kept.length === 0) { const first = omitted[0]; return refuse(isPlainObject(first) && typeof first.name === "string" ? first.name : "unknown"); } return { ...body, tool_choice: { ...toolChoice, tools: kept } }; } return body; } function normalizeToolSchemas(body: unknown, xaiTarget: boolean): unknown { if (!isPlainObject(body)) return body; const omittedFunctionNames = new Set(); const normalizeTools = (tools: unknown[]): unknown[] => { let changed = false; const normalized: unknown[] = []; for (const tool of tools) { const fixed = normalizeFunctionToolSchema(tool, xaiTarget); if (fixed === undefined) { changed = true; if (isPlainObject(tool) && typeof tool.name === "string") omittedFunctionNames.add(tool.name); continue; } if (fixed !== tool) changed = true; normalized.push(fixed); } return changed ? normalized : tools; }; let normalizedBody = body; if (Array.isArray(body.tools)) { const tools = normalizeTools(body.tools); if (tools !== body.tools) normalizedBody = { ...normalizedBody, tools }; } if (Array.isArray(normalizedBody.input)) { let inputChanged = false; const input = normalizedBody.input.map((item) => { if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item; const tools = normalizeTools(item.tools); if (tools === item.tools) return item; inputChanged = true; return { ...item, tools }; }); if (inputChanged) normalizedBody = { ...normalizedBody, input }; } if (omittedFunctionNames.size > 0) { // A dropped tool is a capability the caller declared and will not get, and the only other // trace of it is a turn that never makes the call. Name them so the cause is recoverable. debugProviderDiagnostic("openai-responses", "tool-schema-omitted", { omitted: [...omittedFunctionNames], }); } return reconcileToolChoiceForOmittedTools(normalizedBody, omittedFunctionNames); } function activateDeferredTool(tool: Record): Record { const { defer_loading: _, ...activeTool } = tool; if (tool.type !== "namespace" || !Array.isArray(tool.tools)) return activeTool; return { ...activeTool, tools: tool.tools.map(inner => isPlainObject(inner) ? activateDeferredTool(inner) : inner), }; } function mergeLoadedTools(declaredTools: unknown[], loadedTools: unknown[]): unknown[] { const merged = [...declaredTools]; let changed = false; for (const candidate of loadedTools) { if (!isPlainObject(candidate) || typeof candidate.name !== "string") continue; const loaded = activateDeferredTool(candidate); if (loaded.type === "namespace" && Array.isArray(loaded.tools)) { const namespaceIndex = merged.findIndex(tool => isPlainObject(tool) && tool.type === "namespace" && tool.name === loaded.name ); if (namespaceIndex < 0) { merged.push(loaded); changed = true; continue; } const namespace = merged[namespaceIndex]; if (!isPlainObject(namespace)) continue; const namespaceTools = Array.isArray(namespace.tools) ? namespace.tools : []; const nextNamespaceTools = [...namespaceTools]; let namespaceChanged = "defer_loading" in namespace; for (const tool of loaded.tools) { if (!isPlainObject(tool) || typeof tool.name !== "string") continue; const declaredIndex = nextNamespaceTools.findIndex(declared => isPlainObject(declared) && declared.name === tool.name ); if (declaredIndex < 0) { nextNamespaceTools.push(tool); namespaceChanged = true; continue; } const declared = nextNamespaceTools[declaredIndex]; if (isPlainObject(declared) && "defer_loading" in declared) { nextNamespaceTools[declaredIndex] = activateDeferredTool(declared); namespaceChanged = true; } } if (!namespaceChanged) continue; const { defer_loading: _, ...activeNamespace } = namespace; merged[namespaceIndex] = { ...activeNamespace, tools: nextNamespaceTools }; changed = true; continue; } const declaredIndex = merged.findIndex(tool => isPlainObject(tool) && tool.type !== "namespace" && tool.name === loaded.name ); if (declaredIndex < 0) { merged.push(loaded); changed = true; } else { const declared = merged[declaredIndex]; if (isPlainObject(declared) && "defer_loading" in declared) { merged[declaredIndex] = activateDeferredTool(declared); changed = true; } } } return changed ? merged : declaredTools; } /** * Client-executed tool search only changes Codex's parsed tool context. Routed passthrough keeps * serializing the raw request, so activate those returned definitions for upstreams that do not * implement the native deferred-loading handshake themselves. */ function promoteClientLoadedTools(body: unknown): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; const loadedTools = body.input.flatMap(item => isPlainObject(item) && item.type === "tool_search_output" && Array.isArray(item.tools) ? item.tools : [] ); if (loadedTools.length === 0) return body; if (Array.isArray(body.tools)) { const tools = mergeLoadedTools(body.tools, loadedTools); return tools === body.tools ? body : { ...body, tools }; } const additionalToolsIndex = body.input.findIndex(item => isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools) ); if (additionalToolsIndex < 0) return { ...body, tools: mergeLoadedTools([], loadedTools) }; const additionalTools = body.input[additionalToolsIndex]; if (!isPlainObject(additionalTools) || !Array.isArray(additionalTools.tools)) return body; const tools = mergeLoadedTools(additionalTools.tools, loadedTools); if (tools === additionalTools.tools) return body; const input = [...body.input]; input[additionalToolsIndex] = { ...additionalTools, tools }; return { ...body, input }; } const MAX_RESPONSES_CALL_ID_LENGTH = 64; const REPAIRED_CALL_ID_PREFIX = "call_ocx_"; const REPAIRED_CALL_ID_DIGEST_LENGTH = MAX_RESPONSES_CALL_ID_LENGTH - REPAIRED_CALL_ID_PREFIX.length; /** * The ChatGPT Responses backend rejects input `call_id` values longer than 64 characters. Codex * sidechat/fork replay can namespace call ids from routed providers past that limit. Forward mode * already sends explicit replay input without `previous_response_id`, so it is safe to replace each * oversized id and every matching call/output occurrence with one deterministic request-local alias. * Raw API-key continuations are intentionally excluded because an output-only continuation may * reference a call stored upstream under the original id. Proxy-expanded API-key replays are * explicit and stateless here, so they are safe to repair too. */ function repairOversizedReplayCallIds(body: unknown): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; const occupied = new Set(); for (const item of body.input) { if (!isPlainObject(item) || typeof item.call_id !== "string") continue; if (item.call_id.length <= MAX_RESPONSES_CALL_ID_LENGTH) occupied.add(item.call_id); } const aliases = new Map(); let changed = false; const input = body.input.map(item => { if (!isPlainObject(item) || typeof item.call_id !== "string") return item; const original = item.call_id; if (original.length <= MAX_RESPONSES_CALL_ID_LENGTH) return item; let alias = aliases.get(original); if (!alias) { let salt = 0; do { const hashInput = salt === 0 ? original : `${original}\0${salt}`; const digest = createHash("sha256").update(hashInput).digest("hex"); alias = `${REPAIRED_CALL_ID_PREFIX}${digest.slice(0, REPAIRED_CALL_ID_DIGEST_LENGTH)}`; salt += 1; } while (occupied.has(alias)); aliases.set(original, alias); occupied.add(alias); } changed = true; return { ...item, call_id: alias }; }); return changed ? { ...body, input } : body; } /** Flatten a Responses tool-output `output` value (string or content-part array) to plain text. */ function toolOutputText(output: unknown): string { if (typeof output === "string") return output; if (!Array.isArray(output)) return JSON.stringify(output ?? ""); return output.map(part => { if (!isPlainObject(part)) return ""; if (typeof part.text === "string") return part.text; if (part.type === "refusal" && typeof part.refusal === "string") return `[refusal] ${part.refusal}`; return ""; }).filter(Boolean).join("\n"); } /** True when an output can be losslessly represented as user-message content. */ function isRepairableToolOutput(output: unknown): output is string | Record[] { if (typeof output === "string") return true; if (!Array.isArray(output)) return false; return output.every(part => { if (!isPlainObject(part)) return false; if (typeof part.type !== "string") return false; if (["output_text", "text", "input_text"].includes(part.type)) { return typeof part.text === "string"; } if (part.type === "refusal") return typeof part.refusal === "string"; if (part.type === "encrypted_content") return typeof part.encrypted_content === "string"; if (part.type !== "input_image") return false; const imageUrl = part.image_url; const fileId = part.file_id; const imageUrlIsString = typeof imageUrl === "string"; const fileIdIsString = typeof fileId === "string"; const hasUsableSource = (imageUrlIsString && imageUrl.length > 0) || (fileIdIsString && fileId.length > 0); const validSource = hasUsableSource && (part.image_url === undefined || imageUrlIsString) && (part.file_id === undefined || fileIdIsString); const validDetail = part.detail === undefined || (typeof part.detail === "string" && ["auto", "low", "high", "original"].includes(part.detail)); return validSource && validDetail; }); } /** Convert orphaned tool output to user-message content without discarding valid images. */ function orphanedToolOutputContent(output: unknown, callId = ""): Record[] { const marker = `[tool output for ${callId || "unknown call"}]`; if (typeof output !== "string" && !Array.isArray(output)) { return [{ type: "input_text", text: marker }]; } if (!Array.isArray(output)) { return [{ type: "input_text", text: `${marker}\n${toolOutputText(output)}` }]; } const content: Record[] = [{ type: "input_text", text: marker }]; for (const part of output) { if (!isPlainObject(part)) continue; if (part.type === "input_image") { content.push(part); } else if (part.type === "encrypted_content" && typeof part.encrypted_content === "string") { content.push({ type: "input_text", text: "[encrypted content omitted]" }); } else if (typeof part.text === "string") { content.push({ type: "input_text", text: part.text }); } else if (part.type === "refusal" && typeof part.refusal === "string") { content.push({ type: "input_text", text: `[refusal] ${part.refusal}` }); } } return content; } /** True when a Responses tool output item is present but carries no usable content. */ function isToolOutputEmpty(output: unknown): boolean { if (typeof output === "string") return output.trim() === ""; if (Array.isArray(output)) { // Mirror the Chat wire rule through the shared contract: only a pure // text/refusal part array whose joined content trims empty is annotated. // input_image, encrypted_content, input_file and any other non-text part is // real output and must never be replaced. return isWhitespaceOnlyTextPartArray(output); } // A missing or null `output` is not a present-but-empty result: it is an // incomplete payload. Leave it untouched so the upstream contract fails // closed, and the orphan repair can surface it honestly instead of claiming // the tool ran with no output. return false; } /** * Rewrite present-but-empty tool outputs to an explicit annotation. Synthetic * missing-result placeholders are non-empty and pass through untouched. No-op unless * the provider opts in (`annotateEmptyToolOutputs`). */ function annotateEmptyResponsesToolOutputs(body: unknown, enabled: boolean): unknown { if (!enabled || !isPlainObject(body) || !Array.isArray(body.input)) return body; let changed = false; const input = body.input.map(item => { if (!isPlainObject(item) || (item.type !== "function_call_output" && item.type !== "custom_tool_call_output")) return item; if (!isToolOutputEmpty(item.output)) return item; changed = true; return { ...item, output: EMPTY_TOOL_OUTPUT_ANNOTATION }; }); return changed ? { ...body, input } : body; } /** * Preserve the text of structurally invalid tool-output items before they reach a strict * Responses parser. Stateful destinations may legitimately receive an output whose matching * call lives behind `previous_response_id`, so ordinary orphan repair cannot run universally. * A missing or empty `call_id`, however, cannot identify stored state on any destination. */ function repairUnidentifiedToolOutputItems(body: unknown): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; let changed = false; const input = body.input.map(item => { if (!isPlainObject(item) || (item.type !== "function_call_output" && item.type !== "custom_tool_call_output") || (typeof item.call_id === "string" && item.call_id.length > 0)) { return item; } if (!isRepairableToolOutput(item.output)) return item; changed = true; return { type: "message", role: "user", content: orphanedToolOutputContent(item.output), }; }); return changed ? { ...body, input } : body; } /** * Repair a forward-mode input array whose continuation context was lost. When the replay * expansion misses (proxy restart, unrecorded prior turn), previous_response_id is stripped * (the ChatGPT backend rejects it), so the delta may carry items that reference now-absent * prior items and 400 upstream: * - `function_call`/`local_shell_call`/`custom_tool_call` without their paired output item * ("No tool output found for tool call "). A stateless upstream cannot resolve * the pair from its own storage, so a placeholder output is synthesized to keep the * turn continuable without pretending the result was real. Synthetic outputs are * emitted after the complete parallel call batch, in call order alongside any real * outputs, so the adjacency normalizer can still recognize the batch as one * reasoning-bearing assistant turn (#1477). Gated on * `synthesizeMissingCallOutputs` (stateless AND non-forward wires); forward replay keeps * fail-closed behavior. * - `function_call_output`/`custom_tool_call_output` without their paired call item * ("No tool call found for function call output with call_id ..."). Converted to user * messages so the result text survives. `function_call_output` also pairs with * `local_shell_call` (codex-rs emits shell outputs as function_call_output). * - `reasoning` items ("Item 'rs_*' ... was provided without its required following item"). * Dropped, but only when `dropReasoning` (unexpanded miss): on a replay hit the prior * reasoning chain is intact and must be preserved. * Runs on every forward request; with intact pairs it returns the original reference. */ /** * Repair a replayed `web_search_call` action that is missing either key. * * `webSearchAction()` in the bridge now emits both keys, but that only helps items * created after the fix. A conversation that already recorded * `{type:"search", query:"..."}` or `{type:"search", queries:[...]}` replays that stored * item on every subsequent turn. DeepSeek's native Responses parser requires `queries` * (#930) and Console Go's validator requires `query` (#3071), so upgrading alone leaves * those threads permanently 400ing in one direction or the other. The repair runs both * ways. * * Input items carry a loose schema, so a stored `queries` is not necessarily an array of * strings. A partly- or wholly-malformed array is left alone rather than used as a source * for the singular field: writing `query: 123` would satisfy the presence check and still * fail the validator this repair exists to satisfy, and deriving `query` from * `["a", 42]` would satisfy Console Go while leaving DeepSeek to reject the same replay. * An empty `queries: []` canonicalizes to the shape the bridge emits for an empty search, * keeping an existing `query` when the item has one. * * Runs on every Responses request, on both `input` items and the `action` nested inside * them. Returns the original reference when nothing needs repair, so the common path * allocates nothing. */ function backfillWebSearchQueries(body: unknown): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; let changed = false; const input = body.input.map(item => { if (!isPlainObject(item) || item.type !== "web_search_call") return item; const action = item.action; if (!isPlainObject(action) || action.type !== "search") return item; // Repair whichever side is missing so both strict parsers pass: // DeepSeek native Responses requires `queries`; Console Go requires `query`. const rep: Record = { ...action }; let itemChanged = false; const hasQuery = typeof action.query === "string"; const queries = Array.isArray(action.queries) ? action.queries : undefined; if (queries !== undefined && queries.length === 0) { // An empty array satisfies neither validator. Canonicalize to the empty-search // shape the bridge emits, keeping an existing query rather than discarding it. const query = hasQuery ? action.query as string : ""; rep.query = query; rep.queries = [query]; itemChanged = true; } else if (!hasQuery && queries !== undefined) { // A plural array is only a usable source for the singular field when EVERY member // is a string: deriving `query` from a partly-malformed array would satisfy Console // Go while leaving DeepSeek to reject the same replay. Wholly malformed arrays are // left untouched — coercing or dropping members would invent semantics the stored // item never had. if (queries.every(entry => typeof entry === "string")) { rep.query = queries[0]; // multi-query item recorded before the fix itemChanged = true; } } else if (hasQuery && queries === undefined) { rep.queries = [action.query]; // single-query item recorded before the fix itemChanged = true; } if (itemChanged) changed = true; return itemChanged ? { ...item, action: rep } : item; }); return changed ? { ...body, input } : body; } function repairOrphanedInputItems(body: unknown, dropReasoning: boolean, synthesizeMissingCallOutputs = false): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; const input = body.input; const functionCallIds = new Set(); const customCallIds = new Set(); const functionOutputIds = new Set(); const customOutputIds = new Set(); for (const item of input) { if (!isPlainObject(item) || typeof item.call_id !== "string") continue; if (item.type === "function_call" || item.type === "local_shell_call") functionCallIds.add(item.call_id); else if (item.type === "custom_tool_call") customCallIds.add(item.call_id); else if (item.type === "function_call_output") functionOutputIds.add(item.call_id); else if (item.type === "custom_tool_call_output") customOutputIds.add(item.call_id); } let changed = false; const repaired: unknown[] = []; const syntheticKeys = new Set(); const pendingSyntheticOutputs: unknown[] = []; const flushPendingSyntheticOutputs = (): void => { if (pendingSyntheticOutputs.length === 0) return; repaired.push(...pendingSyntheticOutputs); pendingSyntheticOutputs.length = 0; }; for (const item of input) { if (!isPlainObject(item)) { flushPendingSyntheticOutputs(); repaired.push(item); continue; } if (dropReasoning && item.type === "reasoning") { changed = true; continue; } const isFnOutput = item.type === "function_call_output"; const isCustomOutput = item.type === "custom_tool_call_output"; if (isFnOutput || isCustomOutput) { flushPendingSyntheticOutputs(); const callId = typeof item.call_id === "string" ? item.call_id : ""; const paired = isFnOutput ? functionCallIds.has(callId) : customCallIds.has(callId); const usableOutput = isRepairableToolOutput(item.output); // A known orphan call is still useful as a labeled user message even when its output is // incomplete. With no call id and no output, preserve the invalid item so validation fails // closed rather than pretending any tool result exists. const knownNullOutput = callId.length > 0 && item.output == null; if (!paired && (knownNullOutput || usableOutput)) { changed = true; repaired.push({ type: "message", role: "user", content: orphanedToolOutputContent(item.output, callId), }); continue; } } const isFnCall = item.type === "function_call" || item.type === "local_shell_call"; const isCustomCall = item.type === "custom_tool_call"; if (isFnCall || isCustomCall) { repaired.push(item); if (synthesizeMissingCallOutputs) { const callId = typeof item.call_id === "string" ? item.call_id : ""; const hasOutput = isFnCall ? functionOutputIds.has(callId) : customOutputIds.has(callId); if (!hasOutput && callId) { changed = true; const name = typeof item.name === "string" && item.name.length > 0 ? item.name : callId; const text = `[ocx] no tool result was recorded for "${name}"; execution status unknown — do not treat this as success, failure, or user-provided input.`; syntheticKeys.add(`${isFnCall ? "function" : "custom"}:${callId}`); pendingSyntheticOutputs.push(isFnCall ? { type: "function_call_output", call_id: callId, output: text } : { type: "custom_tool_call_output", call_id: callId, output: text }); } } continue; } flushPendingSyntheticOutputs(); repaired.push(item); } flushPendingSyntheticOutputs(); const callKeyOf = (item: unknown): string | null => { if (!isPlainObject(item) || typeof item.call_id !== "string") return null; if (item.type === "function_call" || item.type === "local_shell_call") return `function:${item.call_id}`; if (item.type === "custom_tool_call") return `custom:${item.call_id}`; return null; }; const outputKeyOf = (item: unknown): string | null => { if (!isPlainObject(item) || typeof item.call_id !== "string") return null; if (item.type === "function_call_output") return `function:${item.call_id}`; if (item.type === "custom_tool_call_output") return `custom:${item.call_id}`; return null; }; const reorderBatchOutputs = (items: unknown[]): unknown[] => { const ordered: unknown[] = []; const claimedOutputIndexes = new Set(); const outputIndexesByKey = new Map(); for (let outputIndex = 0; outputIndex < items.length; outputIndex += 1) { const outputKey = outputKeyOf(items[outputIndex]); if (outputKey === null) continue; const bucket = outputIndexesByKey.get(outputKey); if (bucket) bucket.indexes.push(outputIndex); else outputIndexesByKey.set(outputKey, { indexes: [outputIndex], offset: 0 }); } let index = 0; while (index < items.length) { if (claimedOutputIndexes.has(index)) { index += 1; continue; } const key = callKeyOf(items[index]); if (key === null) { ordered.push(items[index]); index += 1; continue; } const batch: unknown[] = []; const batchKeys: string[] = []; let cursor = index; while (cursor < items.length) { const nextKey = callKeyOf(items[cursor]); if (nextKey === null) break; batch.push(items[cursor]); batchKeys.push(nextKey); cursor += 1; } const hasSynthetic = batchKeys.some(batchKey => syntheticKeys.has(batchKey)); if (!hasSynthetic) { ordered.push(...batch); index = cursor; continue; } const batchOutputs: unknown[] = []; for (const batchKey of batchKeys) { const bucket = outputIndexesByKey.get(batchKey); if (!bucket) continue; while (bucket.offset < bucket.indexes.length && bucket.indexes[bucket.offset]! < cursor) { bucket.offset += 1; } while (bucket.offset < bucket.indexes.length) { const outputIndex = bucket.indexes[bucket.offset]!; bucket.offset += 1; if (claimedOutputIndexes.has(outputIndex)) continue; claimedOutputIndexes.add(outputIndex); batchOutputs.push(items[outputIndex]); break; } } ordered.push(...batch, ...batchOutputs); index = cursor; } return ordered; }; return changed ? { ...body, input: reorderBatchOutputs(repaired) } : body; } /** * Make unambiguous Responses tool batches contiguous for upstream parsers that require it. * * [Decision Log] * - 목적과 의도: Keep Codex hook-injected developer context without splitting a parallel tool-call turn away from its reasoning or making a strict upstream reject matching results. * - 기존 구현 및 제약 조건: The orphan repair verifies only pair presence, while the original pair-by-pair reorder turned `reasoning, call A, call B, output A, output B` into two assistant turns and made DeepSeek reject call B for missing reasoning (#1477). * - 검토한 주요 대안: Disable parallel calls (DeepSeek always enables them); duplicate reasoning per call; reorder each pair; or normalize the complete unambiguous call batch. * - 선택한 방식: Treat calls emitted before the first matched result as one batch, emit all calls followed by their matched outputs, and preserve intervening non-tool items immediately after the batch. * - 다른 대안 대신 이 방식을 선택한 이유: Batch normalization matches the Responses parallel-call shape without fabricating reasoning, while the provider gate and unique-pair requirement keep the blast radius narrow. * - 장점, 단점 및 영향: DeepSeek keeps one reasoning-bearing assistant turn for parallel calls and still accepts hook-interleaved single calls; tolerant providers stay byte/order equivalent, and duplicate, missing, or backwards call/result pairs are not guessed. */ function normalizeResponsesToolResultAdjacency(body: unknown): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; const input = body.input; const calls = new Map(); const outputs = new Map(); const appendIndex = (map: Map, key: string, index: number): void => { const existing = map.get(key); if (existing) existing.push(index); else map.set(key, [index]); }; for (let index = 0; index < input.length; index += 1) { const item = input[index]; if (!isPlainObject(item) || typeof item.call_id !== "string" || item.call_id.length === 0) continue; if (item.type === "function_call" || item.type === "local_shell_call") { appendIndex(calls, `function:${item.call_id}`, index); } else if (item.type === "custom_tool_call") { appendIndex(calls, `custom:${item.call_id}`, index); } else if (item.type === "function_call_output") { appendIndex(outputs, `function:${item.call_id}`, index); } else if (item.type === "custom_tool_call_output") { appendIndex(outputs, `custom:${item.call_id}`, index); } } const pairs: Array<{ callIndex: number; outputIndex: number }> = []; for (const [key, callIndices] of calls) { const outputIndices = outputs.get(key); if (!outputIndices) return body; if (callIndices.length !== 1 || outputIndices.length !== 1) return body; const callIndex = callIndices[0]!; const outputIndex = outputIndices[0]!; if (outputIndex <= callIndex) return body; pairs.push({ callIndex, outputIndex }); } // Reject any collected output that lacks exactly one matching call. A lone or // duplicated output is ambiguous, and normalizing on top of it could sever a // result from the reasoning-bearing call turn it belongs to. for (const [key, outputIndices] of outputs) { const callIndices = calls.get(key); if (!callIndices || callIndices.length !== 1 || outputIndices.length !== 1) return body; } pairs.sort((left, right) => left.callIndex - right.callIndex); const movedIndices = new Set(); const batchAt = new Map(); for (let cursor = 0; cursor < pairs.length;) { const group = [pairs[cursor]!]; let firstOutputIndex = pairs[cursor]!.outputIndex; let next = cursor + 1; while (next < pairs.length && pairs[next]!.callIndex < firstOutputIndex) { group.push(pairs[next]!); firstOutputIndex = Math.min(firstOutputIndex, pairs[next]!.outputIndex); next += 1; } // Within one reasoning turn the outputs must appear in the same order as their // calls. If they are reversed, normalizing would fabricate a new output order; // leave the ambiguous history untouched instead. for (let groupIndex = 1; groupIndex < group.length; groupIndex += 1) { if (group[groupIndex]!.outputIndex < group[groupIndex - 1]!.outputIndex) return body; } const batch = [ ...group.map(pair => input[pair.callIndex]), ...group.map(pair => input[pair.outputIndex]), ]; const anchor = group[0]!.callIndex; const alreadyContiguous = batch.every((item, offset) => input[anchor + offset] === item); if (!alreadyContiguous) { batchAt.set(anchor, batch); for (const pair of group) { movedIndices.add(pair.callIndex); movedIndices.add(pair.outputIndex); } } cursor = next; } if (batchAt.size === 0) return body; const normalized: unknown[] = []; for (let index = 0; index < input.length; index += 1) { const batch = batchAt.get(index); if (batch) normalized.push(...batch); if (!movedIndices.has(index)) normalized.push(input[index]); } return { ...body, input: normalized }; } /** * Remove `previous_response_id` before forwarding. Two triggers: * - the proxy expanded the request into a full input replay (the id is now redundant), or * - the target is the ChatGPT backend (`authMode: "forward"`), whose Codex REST endpoint * categorically rejects the parameter with `{"detail":"Unsupported parameter: * previous_response_id"}` (strict allowlist; it also rejects `metadata` and * `max_output_tokens`). Codex only sends the id on WS turns, and ocx converts those to * internal HTTP requests, so forwarding it upstream is a guaranteed 400 — stripping is * strictly better even when the local replay state missed. API-key mode keeps the field on * unexpanded requests: the platform `/v1/responses` supports real server-side storage. */ function stripPreviousResponseId(body: unknown, strip: boolean): unknown { if (!strip || !isPlainObject(body) || !Object.prototype.hasOwnProperty.call(body, "previous_response_id")) return body; const { previous_response_id: _previousResponseId, ...rest } = body; return rest; } /** Apply the settled tier only to a fresh outbound object; `_rawBody` remains caller-owned. */ function applyTierDecisionToResponsesBody(body: unknown, decision: TierDecision | undefined): unknown { if (!decision || decision.kind === "forward-caller" || !isPlainObject(body)) return body; const next: Record = { ...body }; if (decision.kind === "set") next.service_tier = decision.value; else delete next.service_tier; return next; } /** * Drop request parameters a stateless Responses upstream cannot implement, and pin * `store` false. * * `previous_response_id` is listed here as well as in `stripPreviousResponseId` * because that helper's strip is conditional on replay expansion, and it keeps the * field for API-key providers on the premise that the platform offers real * server-side storage. DeepSeek documents the opposite: "the API is stateless: * responses and conversations are not stored on the server", so the field can never * be honoured regardless of expansion state. * * `prompt` is a reference to a server-stored prompt template — the most stateful * field in the accepted schema. * * `service_tier` is deliberately NOT dropped: the final TierDecision is applied to a * detached outbound body before this sanitizer chain, and silently deleting a configured knob is * worse than forwarding a parameter the upstream ignores. * * MUST run before the composed sanitize chain below: `stripItemIdsWhenUnstored` keys * off `store === false`, and a stateless upstream cannot resolve a stored item id. * Returns a copy, so `parsed._rawBody` keeps the client's original `store` value and * the local replay cache still records the turn. */ function stripStatefulResponsesParams(body: unknown): unknown { if (!isPlainObject(body)) return body; const drop = ["previous_response_id", "conversation", "background", "metadata", "prompt"] as const; const present = drop.some(key => Object.prototype.hasOwnProperty.call(body, key)); if (!present && body.store === false) return body; const next: Record = { ...body }; for (const key of drop) delete next[key]; next.store = false; return next; } /** * Remove top-level parameters the ChatGPT backend (`authMode: "forward"`) rejects * with `{"detail":"Unsupported parameter: …"}` (strict allowlist). Codex CLI never * sends these — it controls output length via `reasoning.effort` — but third-party * Responses API clients (GJC, SDK wrappers) include `max_output_tokens` per the * public spec. `metadata` is likewise absent from the allowlist. No-op when the * body carries neither field, keeping the common Codex path allocation-free. */ function stripUnsupportedForwardParams(body: unknown): unknown { if (!isPlainObject(body)) return body; const hasMot = Object.prototype.hasOwnProperty.call(body, "max_output_tokens"); const hasMeta = Object.prototype.hasOwnProperty.call(body, "metadata"); if (!hasMot && !hasMeta) return body; const { max_output_tokens: _mot, metadata: _meta, ...rest } = body; return rest; } /** Return the lossless text represented by one system message, or null when it is multimodal. */ function canonicalForwardSystemText(item: Record): string | null { const content = item.content; if (content === undefined) return ""; if (typeof content === "string") return content; if (!Array.isArray(content)) return null; let text = ""; for (const block of content) { if (!isPlainObject(block)) return null; if (block.type !== "input_text" && block.type !== "text") return null; if (typeof block.text !== "string") return null; text += block.text; } return text; } /** Only message items may carry privileged system instructions. */ function isCanonicalForwardSystemMessage(item: unknown): item is Record { return isPlainObject(item) && (item.type === undefined || item.type === "message") && item.role === "system"; } /** * The public Responses API accepts input system messages and `truncation`, but the canonical * ChatGPT Codex forward endpoint rejects both. Fold only fully textual system messages into the * existing top-level instructions and remove the unsupported flag at this destination boundary. * * The fold is atomic: if any system message contains a non-text block, keep every message in * place so the proxy never silently drops multimodal content. The backend may still reject that * unsupported shape, but it will not receive a partially rewritten prompt. */ function normalizeCanonicalForwardPromptEnvelope(body: unknown): unknown { if (!isPlainObject(body)) return body; const stripTruncation = Object.hasOwn(body, "truncation"); const input = Array.isArray(body.input) ? body.input : undefined; if (!input) { if (!stripTruncation) return body; const { truncation: _truncation, ...rest } = body; return rest; } const foldedText: string[] = []; let sawSystemMessage = false; let canFoldAllSystemMessages = true; for (const item of input) { if (!isCanonicalForwardSystemMessage(item)) continue; sawSystemMessage = true; const text = canonicalForwardSystemText(item); if (text === null) { canFoldAllSystemMessages = false; break; } foldedText.push(text); } if (!stripTruncation && (!sawSystemMessage || !canFoldAllSystemMessages)) return body; const next: Record = { ...body }; if (stripTruncation) delete next.truncation; if (sawSystemMessage && canFoldAllSystemMessages) { next.input = input.filter(item => !isCanonicalForwardSystemMessage(item)); const folded = foldedText.join("\n\n"); if (folded !== "") { const existing = typeof body.instructions === "string" ? body.instructions : ""; next.instructions = existing !== "" ? `${existing}\n\n${folded}` : folded; } } return next; } const POSIT_CACHE_MARKER_MAX_DEPTH = 64; const POSIT_CACHE_MARKER_MAX_NODES = 100_000; type PromptCacheMarkerRewrite = { value: unknown; changed: boolean; complete: boolean; }; /** * Remove Posit/Anthropic-style prompt-cache markers without trusting request nesting. The walk * aborts atomically when its depth or node budget is exceeded, so a hostile extension object can * neither overflow the stack nor receive a partially rewritten subtree. */ function stripPromptCacheBreakpoints( value: unknown, state: { nodes: number }, depth = 0, ): PromptCacheMarkerRewrite { state.nodes += 1; if (depth > POSIT_CACHE_MARKER_MAX_DEPTH || state.nodes > POSIT_CACHE_MARKER_MAX_NODES) { return { value, changed: false, complete: false }; } if (Array.isArray(value)) { let changed = false; const next: unknown[] = []; for (const entry of value) { const rewritten = stripPromptCacheBreakpoints(entry, state, depth + 1); if (!rewritten.complete) return { value, changed: false, complete: false }; changed ||= rewritten.changed; next.push(rewritten.value); } return { value: changed ? next : value, changed, complete: true }; } if (!isPlainObject(value)) return { value, changed: false, complete: true }; let changed = Object.hasOwn(value, "prompt_cache_breakpoint"); const next: Record = {}; for (const [key, entry] of Object.entries(value)) { if (key === "prompt_cache_breakpoint") continue; const rewritten = stripPromptCacheBreakpoints(entry, state, depth + 1); if (!rewritten.complete) return { value, changed: false, complete: false }; changed ||= rewritten.changed; next[key] = rewritten.value; } return { value: changed ? next : value, changed, complete: true }; } /** * Posit Assistant can replay client-only cache markers and stored-item references on a * `store: false` continuation. The canonical ChatGPT Codex backend rejects both. Remove the * markers recursively and drop only `item_reference` rows that cannot name persisted state; * ordinary item ids are handled later by stripItemIdsWhenUnstored and tool call_id pairs remain. */ function normalizeCanonicalForwardContinuationEnvelope(body: unknown): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; let input: unknown[] = body.input; let changed = false; if (body.store === false) { const withoutReferences = input.filter(item => !isPlainObject(item) || item.type !== "item_reference"); if (withoutReferences.length !== input.length) { input = withoutReferences; changed = true; } } const markerRewrite = stripPromptCacheBreakpoints(input, { nodes: 0 }); if (markerRewrite.complete && markerRewrite.changed) { input = markerRewrite.value as unknown[]; changed = true; } return changed ? { ...body, input } : body; } const IMAGE_GEN_NAMESPACE = "image_gen"; const HOSTED_IMAGE_GENERATION_TOOL = "image_generation"; const IMAGE_GEN_DOTTED_PREFIX = `${IMAGE_GEN_NAMESPACE}.`; const IMAGE_GEN_WIRE_PREFIX = `${IMAGE_GEN_NAMESPACE}__`; /** Remove a supported client prefix before constructing the canonical image-gen wire alias. */ function imageGenLocalName(name: string): string { if (name.startsWith(IMAGE_GEN_DOTTED_PREFIX)) return name.slice(IMAGE_GEN_DOTTED_PREFIX.length); if (name.startsWith(IMAGE_GEN_WIRE_PREFIX)) return name.slice(IMAGE_GEN_WIRE_PREFIX.length); return name; } /** Build the flat public-Responses name used only on the upstream wire. */ function imageGenWireName(name: string): string { return namespacedToolName(IMAGE_GEN_NAMESPACE, imageGenLocalName(name)); } /** Match client image-gen declarations across namespace, legacy dotted, and canonical wire forms. */ function isImageGenClientName(name: string): boolean { return name === IMAGE_GEN_NAMESPACE || name.startsWith(IMAGE_GEN_DOTTED_PREFIX) || name.startsWith(IMAGE_GEN_WIRE_PREFIX); } /** Identify declarations that should activate image-gen request normalization. */ function declaresImageGenClientTool(tool: unknown): boolean { if (!isPlainObject(tool) || typeof tool.name !== "string") return false; if (tool.type === "namespace") return tool.name === IMAGE_GEN_NAMESPACE; return isImageGenClientName(tool.name); } /** Rewrite client image-gen selectors to the hosted tool without widening caller restrictions. */ function preferHostedImageGenToolChoice(toolChoice: unknown): unknown { if (!isPlainObject(toolChoice)) return toolChoice; if ((toolChoice.type === "function" || toolChoice.type === "custom") && typeof toolChoice.name === "string") { return isImageGenClientName(toolChoice.name) ? { type: HOSTED_IMAGE_GENERATION_TOOL } : toolChoice; } if (toolChoice.type !== "allowed_tools" || !Array.isArray(toolChoice.tools)) return toolChoice; const hasHostedImageTool = toolChoice.tools.some(tool => isPlainObject(tool) && tool.type === HOSTED_IMAGE_GENERATION_TOOL); let changed = false; let addedHostedImageTool = false; const tools: unknown[] = []; for (const tool of toolChoice.tools) { const isClientImageTool = isPlainObject(tool) && (tool.type === "function" || tool.type === "custom") && typeof tool.name === "string" && isImageGenClientName(tool.name); if (!isClientImageTool) { tools.push(tool); continue; } changed = true; if (!hasHostedImageTool && !addedHostedImageTool) { tools.push({ type: HOSTED_IMAGE_GENERATION_TOOL }); addedHostedImageTool = true; } } return changed ? { ...toolChoice, tools } : toolChoice; } /** * Some Responses-compatible gateways reserve the hosted image namespace even when the request * does not explicitly declare `image_generation`. For an explicitly configured model, remove only * colliding client declarations so the gateway's hosted tool can take precedence. */ function preferConfiguredHostedTools( body: unknown, provider: OcxProviderConfig, modelId: string, selectedModelId?: string, ): unknown { // A virtual model's advertised id takes precedence over its resolved wire-model id. // Read own properties only: a routed model id of `constructor`/`toString` would // otherwise resolve to an inherited Object.prototype function and throw on the // membership test below, failing the request before it is dispatched. const preferenceMap = provider.modelPreferHostedTools; const ownPreference = (key: string | undefined): string[] | undefined => { if (!key || !preferenceMap || !Object.prototype.hasOwnProperty.call(preferenceMap, key)) return undefined; const entry = preferenceMap[key]; return Array.isArray(entry) ? entry : undefined; }; const preferredTools = ownPreference(selectedModelId) ?? ownPreference(modelId); if (!preferredTools?.includes(HOSTED_IMAGE_GENERATION_TOOL) || !isPlainObject(body)) return body; const stripGroup = (tools: unknown[]): unknown[] => { const filtered = tools.filter(tool => !declaresImageGenClientTool(tool)); return filtered.length === tools.length ? tools : filtered; }; let changed = false; let tools = body.tools; let strippedTopLevelImageGenTool = false; if (Array.isArray(body.tools)) { tools = stripGroup(body.tools); strippedTopLevelImageGenTool = tools !== body.tools; changed ||= strippedTopLevelImageGenTool; } let input = body.input; const strippedAdditionalToolsIndices = new Set(); if (Array.isArray(body.input)) { let nestedChanged = false; const mappedInput = body.input.map((item, index) => { if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item; const nestedTools = stripGroup(item.tools); if (nestedTools === item.tools) return item; strippedAdditionalToolsIndices.add(index); nestedChanged = true; return { ...item, tools: nestedTools }; }); if (nestedChanged) { input = mappedInput; changed = true; } } const hasToolChoice = Object.hasOwn(body, "tool_choice"); const toolChoice = hasToolChoice ? preferHostedImageGenToolChoice(body.tool_choice) : body.tool_choice; const toolChoiceChanged = hasToolChoice && toolChoice !== body.tool_choice; const hasHostedImageGenTool = (toolGroup: unknown): boolean => Array.isArray(toolGroup) && toolGroup.some(tool => isPlainObject(tool) && tool.type === HOSTED_IMAGE_GENERATION_TOOL); const hasHostedImageGenDeclaration = hasHostedImageGenTool(tools) || (Array.isArray(input) && input.some(item => isPlainObject(item) && item.type === "additional_tools" && hasHostedImageGenTool(item.tools))); if ((strippedTopLevelImageGenTool || strippedAdditionalToolsIndices.size > 0) && !hasHostedImageGenDeclaration) { if (strippedTopLevelImageGenTool && Array.isArray(tools)) { tools = [...tools, { type: HOSTED_IMAGE_GENERATION_TOOL }]; } else if (strippedAdditionalToolsIndices.size > 0 && Array.isArray(input)) { // Restore into the FIRST stripped container only. Tool declarations are // request-scoped, not container-scoped — the containers are separate carriers for // one tool set, so a single hosted declaration covers the request. An earlier // revision restored into every stripped container and put `image_generation` on // the wire twice; review caught it. const firstStripped = Math.min(...strippedAdditionalToolsIndices); input = input.map((item, index) => index === firstStripped && isPlainObject(item) && Array.isArray(item.tools) ? { ...item, tools: [...item.tools, { type: HOSTED_IMAGE_GENERATION_TOOL }] } : item); } } changed ||= toolChoiceChanged; if (!changed) return body; const next: Record = { ...body, ...(Array.isArray(body.tools) ? { tools } : {}), ...(Array.isArray(body.input) ? { input } : {}), }; if (toolChoiceChanged) next.tool_choice = toolChoice; return next; } /** * Lower one complete Codex image-gen namespace to public Responses function tools. * * The public API reserves the `image_gen` namespace and restricts function names to a flat safe * alphabet. `image_gen__` is therefore an upstream-only alias; client-facing responses are * restored to explicit `{ namespace: "image_gen", name: "" }` calls by the server. Only a * non-empty namespace containing named function tools is safe to lower. Malformed, empty, and * future namespace shapes stay untouched instead of silently losing client capabilities. */ function flattenImageGenNamespace(tool: unknown): Record[] | undefined { if ( !isPlainObject(tool) || tool.type !== "namespace" || tool.name !== IMAGE_GEN_NAMESPACE || !Array.isArray(tool.tools) || tool.tools.length === 0 ) return undefined; for (const innerTool of tool.tools) { if ( !isPlainObject(innerTool) || innerTool.type !== "function" || typeof innerTool.name !== "string" || innerTool.name.length === 0 ) return undefined; } return tool.tools.map(innerTool => { const functionTool = innerTool as Record & { name: string }; return { ...functionTool, name: imageGenWireName(functionTool.name), }; }); } /** Convert a legacy dotted function declaration while preserving all other function metadata. */ function normalizeFlatImageGenFunction(tool: unknown): unknown { if ( !isPlainObject(tool) || tool.type !== "function" || typeof tool.name !== "string" || !tool.name.startsWith(IMAGE_GEN_DOTTED_PREFIX) ) return tool; return { ...tool, name: imageGenWireName(tool.name) }; } /** Return the image-gen function name used for stable cross-container deduplication. */ function imageGenFunctionName(tool: unknown): string | undefined { if (!isPlainObject(tool) || tool.type !== "function" || typeof tool.name !== "string") { return undefined; } return isImageGenClientName(tool.name) ? tool.name : undefined; } /** True only when a declaration can yield a callable upstream-safe image-gen function alias. */ function declaresUsableImageGenAlias(tool: unknown): boolean { if (flattenImageGenNamespace(tool)) return true; if (!isPlainObject(tool) || tool.type !== "function" || typeof tool.name !== "string") { return false; } if (tool.name.startsWith(IMAGE_GEN_DOTTED_PREFIX)) { return tool.name.length > IMAGE_GEN_DOTTED_PREFIX.length; } return tool.name.startsWith(IMAGE_GEN_WIRE_PREFIX) && tool.name.length > IMAGE_GEN_WIRE_PREFIX.length; } /** Collect client tool-choice names and the exact upstream aliases declared for them. */ function imageGenToolChoiceAliases(toolGroups: unknown[][]): Map { const aliases = new Map(); for (const group of toolGroups) { for (const tool of group) { const flattened = flattenImageGenNamespace(tool); if (flattened) { for (const candidate of flattened) { const wireName = candidate.name as string; aliases.set(`${IMAGE_GEN_DOTTED_PREFIX}${imageGenLocalName(wireName)}`, wireName); aliases.set(wireName, wireName); } continue; } if (!isPlainObject(tool) || tool.type !== "function" || typeof tool.name !== "string") { continue; } if ( tool.name.startsWith(IMAGE_GEN_DOTTED_PREFIX) && tool.name.length > IMAGE_GEN_DOTTED_PREFIX.length ) { aliases.set(tool.name, imageGenWireName(tool.name)); } else if ( tool.name.startsWith(IMAGE_GEN_WIRE_PREFIX) && tool.name.length > IMAGE_GEN_WIRE_PREFIX.length ) { aliases.set(tool.name, tool.name); } } } return aliases; } /** Rewrite function selectors only when their corresponding declaration receives a wire alias. */ function normalizeImageGenToolChoice( toolChoice: unknown, aliases: ReadonlyMap, ): unknown { if (!isPlainObject(toolChoice)) return toolChoice; if (toolChoice.type === "function" && typeof toolChoice.name === "string") { const alias = aliases.get(toolChoice.name); return alias && alias !== toolChoice.name ? { ...toolChoice, name: alias } : toolChoice; } if (toolChoice.type !== "allowed_tools" || !Array.isArray(toolChoice.tools)) return toolChoice; let changed = false; const tools = toolChoice.tools.map(tool => { if (!isPlainObject(tool) || tool.type !== "function" || typeof tool.name !== "string") { return tool; } const alias = aliases.get(tool.name); if (!alias || alias === tool.name) return tool; changed = true; return { ...tool, name: alias }; }); return changed ? { ...toolChoice, tools } : toolChoice; } /** Identify replayed image-gen calls that require upstream wire encoding. */ function declaresImageGenFunctionCall(item: unknown): boolean { if (!isPlainObject(item) || item.type !== "function_call" || typeof item.name !== "string") { return false; } return item.namespace === IMAGE_GEN_NAMESPACE || isImageGenClientName(item.name); } /** Encode native or legacy replay calls to the same flat name used by tool declarations. */ function normalizeImageGenFunctionCall(item: unknown): unknown { if (!declaresImageGenFunctionCall(item) || !isPlainObject(item) || typeof item.name !== "string") { return item; } if (item.namespace === IMAGE_GEN_NAMESPACE) { const { namespace: _namespace, ...rest } = item; return { ...rest, name: imageGenWireName(item.name) }; } if (item.name.startsWith(IMAGE_GEN_DOTTED_PREFIX)) { return { ...item, name: imageGenWireName(item.name) }; } return item; } /** * Normalize Codex's private image-gen tool declaration for API-key Responses providers. * * A complete `image_gen` namespace is flattened to safe `image_gen__` aliases even when it is * the only image tool in the request. Replayed client calls are encoded to the same alias, including * legacy dotted calls from older compatibility attempts. When a usable alias replaces a client * image-gen declaration, the duplicate hosted `image_generation` entry is removed. Duplicate aliases * are resolved in stable container order: top-level tools first, then Responses Lite * `additional_tools` entries. * * This function is called only on the API-key path. ChatGPT forward mode understands the private * namespace and must keep it. Copy-on-write preserves the original request reference when no * namespace is flattened, hosted tool removed, or duplicate function discarded. */ function normalizeImageGenClientTools(body: unknown): unknown { if (!isPlainObject(body)) return body; const toolGroups = collectResponsesToolGroups(body); const hasImageGenClientTool = toolGroups.some(group => group.some(declaresImageGenClientTool)) || (Array.isArray(body.input) && body.input.some(declaresImageGenFunctionCall)); if (!hasImageGenClientTool) return body; const hasUsableImageGenAlias = toolGroups.some(group => group.some(declaresUsableImageGenAlias)); const toolChoiceAliases = imageGenToolChoiceAliases(toolGroups); const seenFunctionNames = new Set(); const normalizeGroup = (tools: unknown[]): unknown[] => { const normalized: unknown[] = []; let groupChanged = false; for (const tool of tools) { if ( hasUsableImageGenAlias && isPlainObject(tool) && tool.type === HOSTED_IMAGE_GENERATION_TOOL ) { groupChanged = true; continue; } const flattened = flattenImageGenNamespace(tool); const candidates = flattened ?? [tool]; if (flattened) groupChanged = true; for (const candidate of candidates) { const normalizedCandidate = normalizeFlatImageGenFunction(candidate); if (normalizedCandidate !== candidate) groupChanged = true; const functionName = imageGenFunctionName(normalizedCandidate); if (functionName && seenFunctionNames.has(functionName)) { groupChanged = true; continue; } if (functionName) seenFunctionNames.add(functionName); normalized.push(normalizedCandidate); } } return groupChanged ? normalized : tools; }; let changed = false; let tools = body.tools; if (Array.isArray(body.tools)) { tools = normalizeGroup(body.tools); changed ||= tools !== body.tools; } let input = body.input; if (Array.isArray(body.input)) { let nestedChanged = false; const mappedInput = body.input.map(item => { if (isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { const nestedTools = normalizeGroup(item.tools); if (nestedTools === item.tools) return item; nestedChanged = true; return { ...item, tools: nestedTools }; } const normalizedCall = normalizeImageGenFunctionCall(item); if (normalizedCall !== item) nestedChanged = true; return normalizedCall; }); if (nestedChanged) { input = mappedInput; changed = true; } } const toolChoice = normalizeImageGenToolChoice(body.tool_choice, toolChoiceAliases); changed ||= toolChoice !== body.tool_choice; if (!changed) return body; return { ...body, ...(Array.isArray(body.tools) ? { tools } : {}), ...(Array.isArray(body.input) ? { input } : {}), ...(Object.prototype.hasOwnProperty.call(body, "tool_choice") ? { tool_choice: toolChoice } : {}), }; } /** * Remove hosted tool entries the target native slug rejects, so the OAuth-passthrough body never * carries a tool the upstream model 400s on. No-op (returns the original reference) when nothing * matches, keeping the common path allocation-free. */ function stripUnsupportedHostedTools(body: unknown, provider: Pick): unknown { if (!isPlainObject(body)) return body; const model = typeof body.model === "string" ? body.model : ""; const filterTools = (tools: unknown[]): unknown[] => { const filtered = tools.filter(t => { const type = isPlainObject(t) && typeof t.type === "string" ? t.type : undefined; return !type || !isHostedToolUnsupportedForModel(model, type, provider.baseUrl); }); return filtered.length === tools.length ? tools : filtered; }; let next: Record = body; let changed = false; if (Array.isArray(body.tools)) { const tools = filterTools(body.tools); if (tools !== body.tools) { next = { ...next, tools }; changed = true; } } if (Array.isArray(body.input)) { let inputChanged = false; const input = body.input.map(item => { if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item; const tools = filterTools(item.tools); if (tools === item.tools) return item; inputChanged = true; return { ...item, tools }; }); if (inputChanged) { next = { ...next, input }; changed = true; } } const toolChoice = next.tool_choice; if (isPlainObject(toolChoice) && toolChoice.type === "allowed_tools" && Array.isArray(toolChoice.tools)) { const tools = filterTools(toolChoice.tools); if (tools !== toolChoice.tools) { next = { ...next, tool_choice: tools.length > 0 ? { ...toolChoice, tools } : "none" }; changed = true; } } else if ( isPlainObject(toolChoice) && typeof toolChoice.type === "string" && isHostedToolUnsupportedForModel(model, toolChoice.type, provider.baseUrl) ) { next = { ...next, tool_choice: "none" }; changed = true; } else if (changed && toolChoice === "required") { const hasDeclaredTools = (Array.isArray(next.tools) && next.tools.length > 0) || (Array.isArray(next.input) && next.input.some(item => isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools) && item.tools.length > 0)); if (!hasDeclaredTools) { next = { ...next, tool_choice: "none" }; } } return changed ? next : body; } /** * OpenAI hosted web_search config fields that a capability-classified Responses * upstream may reject wholesale. xAI's /v1/responses 400s the entire request on * `external_web_access` and `search_context_size` ("Argument not supported"), * which killed every routed Grok turn whose client (Codex) attaches its * default web_search tool config (probe 2026-08-21: both fields 400 * individually; `user_location` and `filters` are accepted and kept). * The caller decides whether to apply this compatibility transform from explicit * provider capability metadata; an unclassified upstream keeps the fields. */ const OPENAI_ONLY_WEB_SEARCH_FIELDS = ["external_web_access", "search_context_size"] as const; function stripOpenAiOnlyWebSearchFieldsFromTools(tools: unknown[]): { tools: unknown[]; changed: boolean; } { let changed = false; const stripped = tools.map(tool => { if (!isPlainObject(tool) || (tool.type !== "web_search" && tool.type !== "web_search_preview")) { return tool; } if (!OPENAI_ONLY_WEB_SEARCH_FIELDS.some(field => Object.hasOwn(tool, field))) return tool; const { external_web_access: _access, search_context_size: _size, ...rest } = tool; changed = true; return rest; }); return { tools: changed ? stripped : tools, changed }; } export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown { if (!isPlainObject(body)) return body; let next: Record = body; let changed = false; if (Array.isArray(body.tools)) { const stripped = stripOpenAiOnlyWebSearchFieldsFromTools(body.tools); if (stripped.changed) { next = { ...next, tools: stripped.tools }; changed = true; } } if (Array.isArray(body.input)) { let inputChanged = false; const input = body.input.map(item => { if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) { return item; } const stripped = stripOpenAiOnlyWebSearchFieldsFromTools(item.tools); if (!stripped.changed) return item; inputChanged = true; return { ...item, tools: stripped.tools }; }); if (inputChanged) { next = { ...next, input }; changed = true; } } return changed ? next : body; } /** * Muse Spark ids whose Responses gateway refuses provider-specific fields on a plain * `web_search` tool. Membership, not equality: 1.3 shipped 2026-09-02 as the * same-shaped successor to 1.2 on the same Zen wire, and an equality check would * have let a Codex-emitted `web_search` body reach the * gateway and come back 400 for every request the moment 1.3 was selected. */ const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set([ "muse-spark-1.3-contributor", "muse-spark-1.2-contributor", ]); const MUSE_SPARK_WEB_SEARCH_STRICT_RESPONSE_URLS = new Set([ "https://opencode.ai/zen/v1/responses", "https://opencode.ai/zen/go/v1/responses", ]); const MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS = [ "search_content_types", "indexed_web_access", ] as const; /** * OpenCode Zen / Go Muse Spark Responses gateway refuses a short list of Codex * `web_search` fields. `web_search_preview` keeps its accepted shape, and Luna * remains untouched. Match the exact effective request URL; malformed, credentialed, * or parameterized destinations keep their original body instead of assuming this * gateway contract. Keep the rejected names together so a newly identified field is * a one-line compatibility update rather than another bespoke rewrite. */ function stripMuseSparkUnsupportedWebSearchFields( body: unknown, modelId: unknown, responseUrl: string, ): unknown { if (!isPlainObject(body)) return body; if (typeof modelId !== "string") return body; if (!MUSE_SPARK_WEB_SEARCH_STRICT_MODELS.has(modelId.trim().toLowerCase())) return body; let destination: string; try { const url = new URL(responseUrl); if (url.username || url.password || url.search || url.hash) return body; destination = `${url.origin.toLowerCase()}${url.pathname.replace(/\/+$/, "")}`; } catch { return body; } if (!MUSE_SPARK_WEB_SEARCH_STRICT_RESPONSE_URLS.has(destination)) return body; const rewriteTools = (tools: unknown[]): { tools: unknown[]; changed: boolean } => { let changed = false; const rewritten = tools.map(tool => { if (!isPlainObject(tool) || tool.type !== "web_search") return tool; if (!MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS.some(field => Object.hasOwn(tool, field))) { return tool; } const rest = { ...tool }; for (const field of MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS) delete rest[field]; changed = true; return rest; }); return { tools: changed ? rewritten : tools, changed }; }; let next: Record = body; let changed = false; if (Array.isArray(body.tools)) { const rewritten = rewriteTools(body.tools); if (rewritten.changed) { next = { ...next, tools: rewritten.tools }; changed = true; } } if (Array.isArray(next.input)) { let inputChanged = false; const input = next.input.map(item => { if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item; const rewritten = rewriteTools(item.tools); if (!rewritten.changed) return item; inputChanged = true; return { ...item, tools: rewritten.tools }; }); if (inputChanged) { next = { ...next, input }; changed = true; } } return changed ? next : body; } /** Replace every `input_image` part under a routed-compaction body with a short marker. */ function stripInputImagesDeep(value: unknown): unknown { if (Array.isArray(value)) return value.map(stripInputImagesDeep); if (!isPlainObject(value)) return value; if (value.type === "input_image") { return { type: "input_text", text: "[image omitted for compaction]" }; } const out: Record = {}; for (const [key, entry] of Object.entries(value)) out[key] = stripInputImagesDeep(entry); return out; } /** * Rewrite a compaction turn for an upstream that does not speak Codex's private * `compaction_trigger` item: drop the trigger and the whole tool surface, and ask * for the handoff summary in plain terms instead (#422). * * The adapter builds from `parsed._rawBody`, so the summarizer prompt that * handleResponses() pushed onto `parsed.context` never reaches the wire — it has to * be applied here. Images go too: a summary needs no pixels, and a text-only * gateway would reject them. */ function buildRoutedCompactionBody(body: unknown): unknown { if (!isPlainObject(body)) return body; // `text` goes with the tool fields: the summary must be prose, not schema-constrained JSON. const { tools: _tools, tool_choice: _toolChoice, parallel_tool_calls: _parallel, text: _text, ...rest } = body; const input = Array.isArray(body.input) ? body.input : []; const kept = input.filter(item => !isPlainObject(item) // `additional_tools` is how Codex Desktop's responses-lite shape carries tools; // leaving it in would break the no-tools invariant even with `tools` removed. || (item.type !== "compaction_trigger" && item.type !== "additional_tools")); return { ...rest, input: [ ...(stripInputImagesDeep(kept) as unknown[]), { type: "message", role: "user", content: [{ type: "input_text", text: COMPACT_PROMPT }] }, ], }; } /** Read the Responses `usage` block, if the gateway sent one. */ function usageFromResponsesPayload(payload: unknown): OcxUsage | undefined { if (!isPlainObject(payload) || !isPlainObject(payload.usage)) return undefined; const usage = payload.usage; const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0; const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0; // openai/codex#41980: the raw usage object is wire data a rebuilt response.completed must keep — // unknown keys (subscription metadata, future counters) ride along even when the token counts // themselves are zero or absent (metadata-only usage). const knownKeys = new Set(["input_tokens", "output_tokens", "total_tokens", "input_tokens_details", "output_tokens_details"]); const hasExtras = Object.keys(usage).some(key => !knownKeys.has(key)) || (isPlainObject(usage.input_tokens_details) && Object.keys(usage.input_tokens_details).some(key => key !== "cached_tokens" && key !== "cache_write_tokens")) || (isPlainObject(usage.output_tokens_details) && Object.keys(usage.output_tokens_details).some(key => key !== "reasoning_tokens")); if (inputTokens === 0 && outputTokens === 0 && !hasExtras) return undefined; const inputDetails = isPlainObject(usage.input_tokens_details) ? usage.input_tokens_details : undefined; const outputDetails = isPlainObject(usage.output_tokens_details) ? usage.output_tokens_details : undefined; return { inputTokens, outputTokens, ...(typeof usage.total_tokens === "number" ? { totalTokens: usage.total_tokens } : {}), ...(typeof inputDetails?.cached_tokens === "number" ? { cachedInputTokens: inputDetails.cached_tokens } : {}), ...(typeof inputDetails?.cache_write_tokens === "number" ? { cacheCreationInputTokens: inputDetails.cache_write_tokens } : {}), ...(typeof outputDetails?.reasoning_tokens === "number" ? { reasoningOutputTokens: outputDetails.reasoning_tokens } : {}), ...(hasExtras ? { rawUsage: { ...usage } } : {}), }; } function responsesPayloadText(response: unknown): string { if (!isPlainObject(response) || !Array.isArray(response.output)) return ""; return response.output .filter(item => isPlainObject(item) && item.type === "message") .flatMap(item => (Array.isArray((item as Record).content) ? (item as { content: unknown[] }).content : [])) .filter(part => isPlainObject(part) && part.type === "output_text") .map(part => String((part as { text?: unknown }).text ?? "")) .join(""); } function responsesErrorMessage(payload: unknown): string { if (!isPlainObject(payload)) return "upstream compaction failed"; const err = payload.error; if (typeof err === "string") return err; if (isPlainObject(err) && typeof err.message === "string") return err.message; const incomplete = payload.incomplete_details; if (isPlainObject(incomplete) && typeof incomplete.reason === "string") return incomplete.reason; return "upstream compaction failed"; } export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): ProviderAdapter & { passthrough: true } { return { name: "openai-responses", passthrough: true as const, buildRequest(parsed: OcxParsedRequest, incoming: IncomingMeta) { const translatorBudget = incoming.translatorBudget; const headers: Record = { "Content-Type": "application/json" }; let url: string; if (provider.authMode === "forward") { const mayForwardCallerCredentials = isCanonicalOpenAiForwardProvider(provider); // OAuth passthrough: ChatGPT backend path is `${baseUrl}/responses` (no /v1). const baseUrl = mayForwardCallerCredentials ? CODEX_FORWARD_BASE_URL : provider.baseUrl.replace(/\/+$/, ""); url = `${baseUrl}/responses`; if (provider.headers) Object.assign(headers, provider.headers); // static headers first… const runtimeProvider = provider as { _codexAccountOverride?: { accessToken: string; chatgptAccountId: string }; _codexAccountRequired?: boolean; }; if ( mayForwardCallerCredentials && runtimeProvider._codexAccountRequired && !runtimeProvider._codexAccountOverride ) { throw new Error("Codex pool account auth is required but unavailable"); } if (mayForwardCallerCredentials) { for (const h of FORWARD_HEADERS) { const v = incoming?.headers.get(h); if (v) { if (h === CODEX_RESPONSES_LITE_HEADER) { for (const name of Object.keys(headers)) { if (name.toLowerCase() === h) delete headers[name]; } } headers[h] = v; // …so genuine forwarded fields win. } } } const override = runtimeProvider._codexAccountOverride; if (override && mayForwardCallerCredentials) { headers["authorization"] = `Bearer ${override.accessToken}`; headers["chatgpt-account-id"] = override.chatgptAccountId; } } else { if (provider.responsesPath === undefined) { url = openaiResponsesUrl(provider.baseUrl); } else { const base = provider.baseUrl.replace(/\/$/, ""); url = `${base}${provider.responsesPath}`; } if (provider.apiKey) headers["Authorization"] = `Bearer ${provider.apiKey}`; if (provider.headers) Object.assign(headers, provider.headers); } const forward = provider.authMode === "forward"; let convertedRoutedCustomToolNames: Set | undefined; let routedCustomToolRepairNames: Set | undefined; let convertedRoutedToolSearchNames: Set | undefined; let convertedRoutedNamespaceToolAliases: Map | undefined; const canonicalSpark = isCanonicalOpenAiForwardProvider(provider) && parsed.modelId.includes("codex-spark"); const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true; let outBody = stripPreviousResponseId( parsed._rawBody, forward || parsed._previousResponseInputExpanded === true, ); if (!forward && isOpenCodeGo(provider.baseUrl)) outBody = normalizeOpenCodeGoAgentMessages(outBody); outBody = mapRoutedResponsesReasoningEffort(outBody, provider, parsed.modelId); // stripPreviousResponseId() intentionally returns its input on a no-op. Detach before the // tier write so a force-fast/default decision can never mutate parsed._rawBody. outBody = applyTierDecisionToResponsesBody(outBody, parsed.options?.tierDecision); const stateless = provider.statelessResponses === true; if (stateless) outBody = stripStatefulResponsesParams(outBody); // A replay miss can leave a function_call_output whose paired function_call sat // in the prefix that was never expanded. A stateless upstream cannot resolve the // pair from its own storage either, so it needs the same repair the forward // backend gets — dropping previous_response_id is not much use if the body that // reaches the wire is unparseable. if (provider.annotateEmptyToolOutputs === true) { outBody = annotateEmptyResponsesToolOutputs(outBody, true); } if (forward || stateless) { outBody = repairOrphanedInputItems(outBody, unexpandedMiss, stateless && !forward); } if (provider.requiresAdjacentResponsesToolResults === true) { outBody = normalizeResponsesToolResultAdjacency(outBody); } if (forward) { outBody = stripUnsupportedForwardParams(outBody); // Only the canonical ChatGPT backend rejects the retired field; a self-hosted or // third-party forward gateway may still accept it, so this must not be widened. if (isCanonicalOpenAiForwardProvider(provider)) { outBody = stripDeprecatedPromptCacheRetention(outBody, parsed.modelId); outBody = stripCanonicalForwardPromptCacheOptions(outBody); outBody = normalizeCanonicalForwardPromptEnvelope(outBody); outBody = normalizeCanonicalForwardContinuationEnvelope(outBody); } } else { outBody = preferConfiguredHostedTools( outBody, provider, parsed.modelId, parsed._openAiVirtualSelectedModelId, ); outBody = normalizeImageGenClientTools(outBody); } if (forward || parsed._previousResponseInputExpanded === true) { outBody = repairOversizedReplayCallIds(outBody); } outBody = stripUnsupportedReasoningSummaryDelivery(outBody, parsed.modelId); // Repair stored history from before the bridge emitted both keys, in either // direction: a conversation that already recorded a web_search_call replays it // every turn, and a strict parser rejects the whole request over the missing key — // `queries` for DeepSeek (#930), `query` for Console Go (#3071). outBody = backfillWebSearchQueries(outBody); if (!isCanonicalOpenAiForwardProvider(provider)) { outBody = stripInternalChatMessageMetadataPassthrough(outBody); outBody = promoteClientLoadedTools(outBody); } if ((!isCanonicalOpenAiForwardProvider(provider) || canonicalSpark) && !isLiteSparkRequestBody(outBody)) { const rewritten = rewriteRoutedCustomToolsForUpstream( outBody, provider.supportsResponsesCustomTools, ); outBody = rewritten.body; convertedRoutedCustomToolNames = rewritten.names; routedCustomToolRepairNames = rewritten.repairNames; } if ((!isCanonicalOpenAiForwardProvider(provider) || canonicalSpark) && !isLiteSparkRequestBody(outBody)) { // Run after custom-tool lowering so the search compatibility layer can choose a // collision-free public function name against the final routed function catalog. const rewritten = rewriteRoutedToolSearchForUpstream(outBody); outBody = rewritten.body; convertedRoutedToolSearchNames = rewritten.names; } if ((!isCanonicalOpenAiForwardProvider(provider) || canonicalSpark) && !isLiteSparkRequestBody(outBody)) { // Codex 0.147 emits private namespace tool groups, while public/third-party Responses // gateways accept only flat tool variants. Run after custom/tool-search lowering so // namespace children already carry their final public kind before they are promoted. const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody, convertedRoutedCustomToolNames); outBody = rewritten.body; convertedRoutedNamespaceToolAliases = rewritten.aliases; } if (!isCanonicalOpenAiForwardProvider(provider)) { // Preserve xAI's cached-only fail-closed semantics and image-search mapping before the // generic capability fallback removes the private OpenAI fields. outBody = normalizeXaiResponsesWebSearch(outBody, provider); outBody = injectXaiResponsesXSearch(outBody, provider, parsed._replayPrefixLen); // xAI and explicitly classified compatible gateways reject these OpenAI web_search // extensions. Keep them for OpenAI API-key traffic and unclassified gateways. if (provider.supportsOpenAiWebSearchToolFields === false) { outBody = stripOpenAiOnlyWebSearchFields(outBody); } outBody = stripMuseSparkUnsupportedWebSearchFields(outBody, parsed.modelId, url); // Last, so promoted namespace children are also cleared of Codex-private fields. outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false); } // Same predicate as the routedCompaction gate in handleResponses(): an authMode check would // let a noncanonical custom forward provider skip this rewrite while the server still routes // it as a summarizer turn (#422). The compaction body build removes the tool surface and must // therefore be the last routed transform that may depend on those declarations. Structural // sanitizers below can still run after it. outBody = normalizeResponsesCodeMode(outBody, parsed, provider); if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) { outBody = buildRoutedCompactionBody(outBody); } // Run after routed compaction so nested input_image parts are replaced before a malformed // tool output is flattened to text and can no longer be inspected structurally. outBody = repairUnidentifiedToolOutputItems(outBody); const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true; const sanitizedBody = normalizeToolSchemas( stripSparkCompatibility( stripUnsupportedReasoningParams( stripItemIdsWhenUnstored( stripInvalidItemIds( stripUnsupportedHostedTools( sanitizeReasoningInputContent( scrubOcxCompactionItems( outBody, destinationDecodesNativeCompactionBlob(provider), threadServingIdentityChanged, ), { preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true, dropNullContentChannel: !isOpenAiOperatedResponsesDestination(provider), stripEncryptedContent: threadServingIdentityChanged, }, ), provider, ), ), ), ), ), isXaiSchemaTarget(provider), ); const finalBody = stripDisabledVerbosity( stripDisabledReasoningSummaries( normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId), provider, parsed.modelId, ), provider, parsed.modelId, ); if (isCanonicalOpenAiForwardProvider(provider)) { const routingHeaders = new Headers(headers); applyCodexRoutingHint(routingHeaders, finalBody); // Static headers may use mixed casing. Remove every stale spelling // without normalizing unrelated headers returned by this adapter. for (const name of Object.keys(headers)) { if (name.toLowerCase() === CODEX_ROUTING_HINT_HEADER) delete headers[name]; } const hint = routingHeaders.get(CODEX_ROUTING_HINT_HEADER); if (hint !== null) headers[CODEX_ROUTING_HINT_HEADER] = hint; } const actualServiceTier = isPlainObject(finalBody) && typeof finalBody.service_tier === "string" ? finalBody.service_tier : null; const tierLog = createAdapterTierMetadata( parsed.options?.tierObservation, parsed.options?.tierDecision, actualServiceTier === null ? null : "service-tier", actualServiceTier, ); const body = JSON.stringify(finalBody); const releaseBodyObservation = translatorBudget.observeExternallyCapped( "passthrough_serialization", new TextEncoder().encode(body).byteLength, ); return { url, method: "POST", headers, body, releaseBodyObservation, ...(convertedRoutedCustomToolNames ? { convertedRoutedCustomToolNames } : {}), ...(routedCustomToolRepairNames ? { routedCustomToolRepairNames } : {}), ...(convertedRoutedToolSearchNames ? { convertedRoutedToolSearchNames } : {}), ...(convertedRoutedNamespaceToolAliases ? { convertedRoutedNamespaceToolAliases } : {}), ...(tierLog ? { tierLog } : {}), }; }, // The passthrough normally relays the upstream stream verbatim and never parses. // The exception is a routed compaction turn: the server drives this adapter like // an ordinary one so the bridge can build the single compaction item (#422). async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator { if (!response.body) { yield { type: "error", message: "passthrough adapter received no response body" }; return; } const budgetEncoder = new TextEncoder(); let deltas = ""; let doneText = ""; let snapshot = ""; let usage: OcxUsage | undefined; let compactionEncryptedContent: string | undefined; let completedSeen = false; for await (const event of decodeServerSentEvents(response.body, { translatorBudget: budget })) { let payload: unknown; try { payload = JSON.parse(event.data); } catch { continue; } if (!isPlainObject(payload)) continue; switch (payload.type) { case "response.output_text.delta": if (typeof payload.delta === "string") { const next = deltas + payload.delta; const previousBytes = budgetEncoder.encode(deltas).byteLength; const reservation = budget.reserveTransient(budgetEncoder.encode(next).byteLength, { kind: "retained_collectors" }); deltas = next; reservation.commitRetained(); budget.releaseRetained(previousBytes, { kind: "retained_collectors" }); } break; case "response.output_text.done": if (typeof payload.text === "string") { const next = doneText + payload.text; const previousBytes = budgetEncoder.encode(doneText).byteLength; const reservation = budget.reserveTransient(budgetEncoder.encode(next).byteLength, { kind: "retained_collectors" }); doneText = next; reservation.commitRetained(); budget.releaseRetained(previousBytes, { kind: "retained_collectors" }); } break; case "response.failed": case "error": yield { type: "error", message: responsesErrorMessage(payload.response ?? payload) }; return; case "response.incomplete": yield { type: "incomplete", reason: responsesErrorMessage(payload.response ?? payload) }; return; case "response.completed": { completedSeen = true; const responsePayload = isPlainObject(payload.response) ? payload.response : undefined; const output = Array.isArray(responsePayload?.output) ? responsePayload.output : []; const compaction = output.find(item => isPlainObject(item) && item.type === "compaction"); if (isPlainObject(compaction) && typeof compaction.encrypted_content === "string") { const nextEncryptedContent = compaction.encrypted_content; const previousBytes = budgetEncoder.encode(compactionEncryptedContent ?? "").byteLength; const reservation = budget.reserveTransient(budgetEncoder.encode(nextEncryptedContent).byteLength, { kind: "retained_collectors" }); compactionEncryptedContent = nextEncryptedContent; reservation.commitRetained(); budget.releaseRetained(previousBytes, { kind: "retained_collectors" }); } const next = responsesPayloadText(payload.response); const previousBytes = budgetEncoder.encode(snapshot).byteLength; const reservation = budget.reserveTransient(budgetEncoder.encode(next).byteLength, { kind: "retained_collectors" }); snapshot = next; reservation.commitRetained(); budget.releaseRetained(previousBytes, { kind: "retained_collectors" }); } { const nextUsage = usageFromResponsesPayload(payload.response); // The attached raw usage object can be event-sized (unknown keys carry arbitrary // values); it stays reachable until the terminal yields, so charge it like the // adjacent retained collectors or it would defeat the per-request memory cap. const previousRawBytes = usage?.rawUsage === undefined ? 0 : budgetEncoder.encode(JSON.stringify(usage.rawUsage)).byteLength; const nextRawBytes = nextUsage?.rawUsage === undefined ? 0 : budgetEncoder.encode(JSON.stringify(nextUsage.rawUsage)).byteLength; if (nextRawBytes > 0) { const reservation = budget.reserveTransient(nextRawBytes, { kind: "retained_collectors" }); usage = nextUsage; reservation.commitRetained(); } else { usage = nextUsage; } if (previousRawBytes > 0) { budget.releaseRetained(previousRawBytes, { kind: "retained_collectors" }); } } break; } // Buffered text is still upstream progress, but gateway keepalives are not. // Yield after accounting, directly to the consumer: no progress queue or content leak. if ( !completedSeen && (payload.type === "response.output_text.delta" || payload.type === "response.reasoning_summary_text.delta" || payload.type === "response.reasoning_text.delta") && typeof payload.delta === "string" && payload.delta.length > 0 ) { yield { type: "heartbeat" }; } } // Gateways differ in which of these they emit; prefer the authoritative // completed snapshot so text is never double-counted. const text = snapshot || doneText || deltas; if (text) yield { type: "text_delta", text }; budget.releaseRetained( budgetEncoder.encode(deltas).byteLength + budgetEncoder.encode(doneText).byteLength + budgetEncoder.encode(snapshot).byteLength + (usage?.rawUsage === undefined ? 0 : budgetEncoder.encode(JSON.stringify(usage.rawUsage)).byteLength), { kind: "retained_collectors" }, ); yield { type: "done", ...(usage ? { usage } : {}), ...(compactionEncryptedContent ? { compactionEncryptedContent } : {}), }; }, async parseResponse(response: Response, budget: TranslatorBudget): Promise { let payload: unknown; try { payload = await response.json(); } catch { return [{ type: "error", message: "malformed upstream compaction response" }]; } budget.chargeRetained(new TextEncoder().encode(JSON.stringify(payload)).byteLength, { kind: "retained_collectors" }); if (!isPlainObject(payload)) { return [{ type: "error", message: "malformed upstream compaction response" }]; } if (payload.error || payload.status === "failed") { return [{ type: "error", message: responsesErrorMessage(payload) }]; } if (payload.status === "incomplete") { return [{ type: "incomplete", reason: responsesErrorMessage(payload) }]; } const usage = usageFromResponsesPayload(payload); const output = Array.isArray(payload.output) ? payload.output : []; const compaction = output.find(item => isPlainObject(item) && item.type === "compaction"); const compactionEncryptedContent = isPlainObject(compaction) && typeof compaction.encrypted_content === "string" ? compaction.encrypted_content : undefined; const text = responsesPayloadText(payload); if (!text && !compactionEncryptedContent) { // A completed turn with neither text nor a native compaction blob cannot become a // replacement-history item. A ciphertext-only native completion is valid, though. return [{ type: "error", message: "upstream compaction returned no summary text" }]; } return [...(text ? [{ type: "text_delta" as const, text }] : []), { type: "done", ...(usage ? { usage } : {}), ...(compactionEncryptedContent ? { compactionEncryptedContent } : {}), }]; }, }; }