@bitkyc08/opencodex 2.55.0 → 2.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/gui/dist/assets/{index-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -1
  4. package/src/adapters/base.ts +21 -0
  5. package/src/adapters/cursor/transport-retry.ts +46 -1
  6. package/src/adapters/cursor.ts +4 -0
  7. package/src/adapters/kiro/adapter.ts +42 -1
  8. package/src/adapters/kiro-retry.ts +23 -4
  9. package/src/adapters/openai-chat/errors.ts +116 -0
  10. package/src/adapters/openai-chat/messages.ts +346 -0
  11. package/src/adapters/openai-chat/passthrough.ts +146 -0
  12. package/src/adapters/openai-chat/response-events.ts +117 -0
  13. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  14. package/src/adapters/openai-chat/tool-schema.ts +477 -0
  15. package/src/adapters/openai-chat/wire.ts +50 -0
  16. package/src/adapters/openai-chat.ts +33 -1445
  17. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  18. package/src/adapters/openai-responses/image-gen.ts +406 -0
  19. package/src/adapters/openai-responses/internal.ts +3 -0
  20. package/src/adapters/openai-responses/passthrough.ts +611 -0
  21. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  22. package/src/adapters/openai-responses/reasoning.ts +220 -0
  23. package/src/adapters/openai-responses/request-strips.ts +185 -0
  24. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  25. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  26. package/src/adapters/openai-responses/web-search.ts +156 -0
  27. package/src/adapters/openai-responses.ts +4 -2625
  28. package/src/bridge/errors.ts +34 -0
  29. package/src/bridge/internal.ts +174 -0
  30. package/src/bridge/response-json.ts +624 -0
  31. package/src/bridge/sse.ts +1444 -0
  32. package/src/bridge.ts +5 -2204
  33. package/src/chat/inbound.ts +12 -1
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-store.ts +71 -9
  36. package/src/codex/auth-api/account-list.ts +507 -0
  37. package/src/codex/auth-api/http.ts +32 -0
  38. package/src/codex/auth-api/login-flow.ts +554 -0
  39. package/src/codex/auth-api/login-state.ts +64 -0
  40. package/src/codex/auth-api/main-account-probe.ts +331 -0
  41. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  42. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  43. package/src/codex/auth-api/reset-credit-service.ts +422 -0
  44. package/src/codex/auth-api/routes.ts +425 -0
  45. package/src/codex/auth-api/runtime-config.ts +48 -0
  46. package/src/codex/auth-api.ts +27 -3118
  47. package/src/codex/auth-context.ts +95 -28
  48. package/src/codex/catalog/auto-review.ts +507 -0
  49. package/src/codex/catalog/build-entries.ts +981 -0
  50. package/src/codex/catalog/combo-member.ts +375 -0
  51. package/src/codex/catalog/derive-entry.ts +229 -0
  52. package/src/codex/catalog/effort.ts +0 -1
  53. package/src/codex/catalog/gated-native-warn.ts +63 -0
  54. package/src/codex/catalog/gather-capture.ts +533 -0
  55. package/src/codex/catalog/model-hints.ts +691 -0
  56. package/src/codex/catalog/model-visibility.ts +304 -0
  57. package/src/codex/catalog/provider-fetch.ts +52 -2942
  58. package/src/codex/catalog/provider-models.ts +685 -0
  59. package/src/codex/catalog/restore.ts +132 -0
  60. package/src/codex/catalog/retained-sync.ts +706 -0
  61. package/src/codex/catalog/routed-gather.ts +858 -0
  62. package/src/codex/catalog/subagent-roster.ts +176 -0
  63. package/src/codex/catalog/sync.ts +52 -2698
  64. package/src/codex/inject/config-toml.ts +563 -0
  65. package/src/codex/inject/remove.ts +192 -0
  66. package/src/codex/inject/restore.ts +540 -0
  67. package/src/codex/inject/routing-classify.ts +109 -0
  68. package/src/codex/inject/routing-target.ts +125 -0
  69. package/src/codex/inject.ts +81 -1436
  70. package/src/codex/lineage.ts +458 -0
  71. package/src/codex/pool-refresh-backoff.ts +152 -0
  72. package/src/codex/routing/active-account.ts +194 -0
  73. package/src/codex/routing/cooldown-math.ts +275 -0
  74. package/src/codex/routing/health-store.ts +402 -0
  75. package/src/codex/routing/probe-lease.ts +358 -0
  76. package/src/codex/routing/selection.ts +703 -0
  77. package/src/codex/routing/thread-affinity.ts +538 -0
  78. package/src/codex/routing.ts +353 -2234
  79. package/src/codex/shim-fingerprint.ts +223 -0
  80. package/src/codex/shim-inspect.ts +175 -0
  81. package/src/codex/shim-probe.ts +367 -0
  82. package/src/codex/shim-restore-lock.ts +169 -0
  83. package/src/codex/shim-state-file.ts +151 -0
  84. package/src/codex/shim-templates.ts +265 -0
  85. package/src/codex/shim.ts +48 -1268
  86. package/src/config/diagnostics.ts +705 -0
  87. package/src/config/feature-flags.ts +55 -0
  88. package/src/config/live-reconcile.ts +403 -0
  89. package/src/config/load-degrade.ts +880 -0
  90. package/src/config/mutation-lock.ts +244 -0
  91. package/src/config/openai-tier-backup.ts +268 -0
  92. package/src/config/persist-unlocked.ts +92 -0
  93. package/src/config/proxy-env.ts +188 -0
  94. package/src/config/salvage.ts +244 -0
  95. package/src/config/schema/config-schema.ts +640 -0
  96. package/src/config/schema/leaf-validators.ts +855 -0
  97. package/src/config/warn-memo.ts +28 -0
  98. package/src/config.ts +234 -4481
  99. package/src/generated/compatibility-version.json +539 -39
  100. package/src/lib/request-execution-budget.ts +69 -20
  101. package/src/lib/spend-reservation-ledger.ts +940 -0
  102. package/src/lib/upstream-retry.ts +55 -11
  103. package/src/lib/workflow-budget.ts +553 -30
  104. package/src/providers/quota/account-cache.ts +441 -0
  105. package/src/providers/quota/antigravity.ts +295 -0
  106. package/src/providers/quota/report-cache.ts +320 -0
  107. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  108. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  109. package/src/providers/quota.ts +324 -3079
  110. package/src/providers/registry/entries-core.ts +1221 -0
  111. package/src/providers/registry/entries-extended.ts +1204 -0
  112. package/src/providers/registry/model-seeds.ts +908 -0
  113. package/src/providers/registry/types.ts +352 -0
  114. package/src/providers/registry.ts +24 -3536
  115. package/src/responses/continuation-ownership.ts +29 -0
  116. package/src/responses/state/replay-fingerprint.ts +80 -0
  117. package/src/responses/state/snapshot-codec.ts +104 -0
  118. package/src/responses/state/spill-failure.ts +118 -0
  119. package/src/responses/state/spill-queue.ts +665 -0
  120. package/src/responses/state/temp-recovery.ts +257 -0
  121. package/src/responses/state.ts +82 -1143
  122. package/src/routing/identity-domains.ts +449 -0
  123. package/src/routing/probe-lease.ts +511 -0
  124. package/src/server/index/bounded-request.ts +88 -0
  125. package/src/server/index/live-sideband.ts +565 -0
  126. package/src/server/index/serve-options.ts +1766 -0
  127. package/src/server/index/startup-warnings.ts +213 -0
  128. package/src/server/index/websocket-handler.ts +335 -0
  129. package/src/server/index.ts +40 -2547
  130. package/src/server/management/route-registry.ts +26 -23
  131. package/src/server/management/shared.ts +8 -5
  132. package/src/server/management/workflow-budget-routes.ts +133 -0
  133. package/src/server/management-api.ts +12 -0
  134. package/src/server/request-log-conversation.ts +9 -7
  135. package/src/server/request-log.ts +245 -1
  136. package/src/server/responses/account-change-state.ts +233 -0
  137. package/src/server/responses/adapter-continuation.ts +514 -0
  138. package/src/server/responses/adapter-delivery.ts +214 -0
  139. package/src/server/responses/adapter-dispatch.ts +971 -0
  140. package/src/server/responses/compact.ts +59 -4
  141. package/src/server/responses/completion-policy.ts +33 -0
  142. package/src/server/responses/core-auth.ts +527 -0
  143. package/src/server/responses/core-codex-account.ts +859 -0
  144. package/src/server/responses/core-combo-failure.ts +210 -0
  145. package/src/server/responses/core-combo.ts +707 -0
  146. package/src/server/responses/core-errors.ts +152 -0
  147. package/src/server/responses/core-lifetime.ts +95 -0
  148. package/src/server/responses/core-normalize.ts +350 -0
  149. package/src/server/responses/core-opaque-recovery.ts +380 -0
  150. package/src/server/responses/core-options.ts +159 -0
  151. package/src/server/responses/core-replay.ts +225 -0
  152. package/src/server/responses/core.ts +192 -8893
  153. package/src/server/responses/passthrough-delivery.ts +856 -0
  154. package/src/server/responses/passthrough-dispatch.ts +1476 -0
  155. package/src/server/responses/passthrough-execution.ts +54 -0
  156. package/src/server/responses/request-prepare.ts +970 -0
  157. package/src/server/responses/request-send-budget.ts +164 -0
  158. package/src/server/responses/request-sidecar-auth.ts +149 -0
  159. package/src/server/responses/request-transport.ts +744 -0
  160. package/src/server/responses/response-effects.ts +157 -0
  161. package/src/server/responses/run-turn-execution.ts +448 -0
  162. package/src/server/responses/sidecar-execution.ts +469 -0
  163. package/src/server/responses-image-gen-repair.ts +1 -1
  164. package/src/server/workflow-refusal.ts +84 -0
  165. package/src/types/config.ts +30 -0
  166. package/src/usage/log.ts +146 -0
  167. package/src/usage/summary.ts +171 -21
@@ -0,0 +1,970 @@
1
+ import type { ResponsesRequestContext, ResponsesAdmissionState, ResponsesDispatchers } from "./core-options";
2
+ import {
3
+ agentTaskRecoveryConfig,
4
+ restoreCachedEncryptedAgentTasks,
5
+ recoverEncryptedAgentTaskWithResult,
6
+ } from "./agent-task-recovery";
7
+ import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "../request-decompress";
8
+ import {
9
+ clientCancelledResponse,
10
+ decodeRequestErrorResponse,
11
+ comboUnavailable,
12
+ unreadableEncryptedAgentTaskResponse,
13
+ } from "./core-errors";
14
+ import { parseSyntheticRowId } from "../fast-row";
15
+ import { resolveComboId, comboIdFromRawBody, NoAvailableComboTargetsError } from "../../combos";
16
+ import { recallComboForLane } from "./combo-session-recall";
17
+ import {
18
+ sessionLaneIdFromRequest,
19
+ conversationIdFromResponsesRequest,
20
+ sessionIdHeaderFromRequest,
21
+ reasoningReplayConversationIdFromResponsesRequest,
22
+ } from "../request-log-conversation";
23
+ import {
24
+ isShadowSourceModel,
25
+ shadowSourceModelPrefix,
26
+ shouldInterceptShadowCall,
27
+ } from "../../lib/shadow-call";
28
+ import { sanitizeLogMetadataString } from "../../lib/redact";
29
+ import {
30
+ hasUnreadableEncryptedAgentTask,
31
+ sanitizeEncryptedContentInPlace,
32
+ stripAgentMessageCiphertextInPlace,
33
+ } from "./encrypted-payload";
34
+ import {
35
+ codexPoolAffinityKey,
36
+ previewCodexPoolLineage,
37
+ applyCodexAuthContextToProvider,
38
+ } from "../../codex/auth-context";
39
+ import {
40
+ copyPreviousResponseReplayProvenance,
41
+ expandPreviousResponseInput,
42
+ previousResponseScopeMismatch,
43
+ previousResponseReplayFailure,
44
+ markBodyNonPersistable,
45
+ previousResponseProviderState,
46
+ } from "../../responses/state";
47
+ import { formatErrorResponse } from "../../bridge";
48
+ import type { OcxParsedRequest } from "../../types";
49
+ import { buildToolBridgeMaps } from "./collaboration";
50
+ import { parseRequest } from "../../responses/parser";
51
+ import { anthropicSessionKeyFromParts } from "../../oauth/anthropic-routing";
52
+ import { isTranslatorBudgetExceededError } from "../../lib/translator-budget";
53
+ import { bindTurnTerminationScope, rememberDeliveredFinalAnswer } from "../../responses/turn-termination";
54
+ import { requestLogSpeedLabel, readConfiguredCodexServiceTier } from "../request-log";
55
+ import type { RouteResult } from "../../router";
56
+ import {
57
+ routeConcreteModel,
58
+ routeCompactionModel,
59
+ routeModel,
60
+ NoEligiblePolicyCandidateError,
61
+ } from "../../router";
62
+ import { evidenceFromBody } from "../../routing/request-evidence";
63
+ import { OPENAI_CODEX_PROVIDER_ID, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
64
+ import { isThreadSpawnRequest } from "../effort-policy";
65
+ import {
66
+ resolveSubagentFallbackChain,
67
+ maybePrimeSubagentQuota,
68
+ applySubagentModelFallback,
69
+ } from "../../codex/subagent-model-fallback";
70
+ import { codexAccountSelectionForTurn } from "../lifecycle";
71
+ import { isNativeMainTrafficBlocked } from "../../codex/native-profile-startup";
72
+ import type {
73
+ SubagentPoolAccountPreview,
74
+ SubagentModelEligibleAccountIds,
75
+ } from "../../codex/subagent-model-fallback";
76
+ import {
77
+ codexRouteCredentialDomainHeaders,
78
+ codexRouteCredentialOwnership,
79
+ resolveResponsesCodexAuth,
80
+ withClaudeNativeSession,
81
+ } from "./core-auth";
82
+ import {
83
+ resolveSubagentFallbackModelEligibility,
84
+ canPassThroughEncryptedV2AgentTask,
85
+ applyFinalRouteRequestNormalization,
86
+ } from "./core-normalize";
87
+ import { resolveCodexModelEntitlements } from "../../codex/model-entitlements";
88
+ import {
89
+ previewCodexAccountForRequest,
90
+ codexQuotaScopeForModel,
91
+ formatCodexProviderForLog,
92
+ } from "../../codex/routing";
93
+ import { isInjectionDebugEnabled } from "../../lib/debug-settings";
94
+ import { injectionDebugLog } from "../../lib/injection-debug-log";
95
+ import { slugsEquivalent } from "../../providers/slug-codec";
96
+ import type { AgentTaskRecoveryFailureReason } from "./agent-task-recovery";
97
+ import { resolveWireProtocolOverride } from "../adapter-resolve";
98
+ import { hasUnmappedRoutedCustomToolOutput } from "../../responses/custom-tool-compat";
99
+ import { PROVIDER_OWNED_CONTINUATION_WIRES, resolvedAdapterWire } from "../../responses/continuation-ownership";
100
+ import {
101
+ isCodexReserveHelperUnsupported,
102
+ CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE,
103
+ } from "../../codex/loopback-target";
104
+ import { checkInputAdmission } from "./input-admission";
105
+ import { nativeContextLimits } from "../../codex/catalog";
106
+ import { streamingContextOverflowResponse } from "./context-overflow";
107
+ import {
108
+ preAuthUpstreamHostCircuitKey,
109
+ upstreamHostCircuitOpenResponse,
110
+ applyCodexAccountGatedWireNormalization,
111
+ codexLogAccountId,
112
+ } from "./core-codex-account";
113
+ import { acquireUpstreamHostAdmission } from "../../codex/upstream-host-health";
114
+ import { codexAuthContextLogLabel } from "../../codex/account-label";
115
+ import {
116
+ conversationStateBindingFromAuth,
117
+ applyAccountChangeConversationStateScrub,
118
+ } from "./account-change-state";
119
+
120
+ /** Parses, selects, and admits one request without changing the dispatch policy. */
121
+ export async function prepareResponsesRequest(
122
+ requestContext: Pick<ResponsesRequestContext, "options" | "config" | "req" | "logCtx">,
123
+ admissionState: ResponsesAdmissionState,
124
+ requestDispatchers: ResponsesDispatchers,
125
+ ) {
126
+ const { options, config, req, logCtx } = requestContext;
127
+
128
+ // The Chat and Anthropic surfaces replay through here with a Responses-shaped body,
129
+ // so an omitted value means a genuine Responses inbound.
130
+ const inboundWire = options.inboundWire ?? "responses";
131
+ const translatorBudget = options.translatorBudget;
132
+ const agentTaskRecovery = agentTaskRecoveryConfig(config);
133
+ let body: unknown;
134
+ try {
135
+ body = await readJsonRequestBody(req, translatorBudget, resolveInboundBodyLimitBytes(config.maxInboundBodyBytes));
136
+ } catch (err) {
137
+ if (options.abortSignal?.aborted || req.signal.aborted) {
138
+ return clientCancelledResponse();
139
+ }
140
+ return decodeRequestErrorResponse(err, "responses");
141
+ }
142
+ // An effort row naming a table-less combo (`combo/x--high`) must reach the combo dispatcher
143
+ // as its base id, so the selector is normalized here, before comboIdFromRawBody reads model.
144
+ const comboRows = !options.comboAttempt && body && typeof body === "object" && !Array.isArray(body)
145
+ && typeof (body as { model?: unknown }).model === "string"
146
+ // One parse for both grammars, from the selector as the client sent it. Parsing them
147
+ // separately made the outcome depend on which ran first.
148
+ ? parseSyntheticRowId((body as { model: string }).model, config)
149
+ : { fastRow: null, effortRow: null };
150
+ const comboEffortRow = comboRows.effortRow;
151
+ if (comboRows.fastRow) {
152
+ // Same reason as the effort row above: the combo dispatcher reads `model` next, so the
153
+ // selector has to be normalized before it, or a combo child is built from a synthetic id.
154
+ const raw = body as Record<string, unknown>;
155
+ raw.model = comboRows.fastRow.baseId;
156
+ // A caller INTENT, not a decision. decideTier still rules on eligibility downstream, so
157
+ // fastMode:false and an ineligible route both still suppress it.
158
+ raw.service_tier = "priority";
159
+ }
160
+ if (comboEffortRow) {
161
+ const raw = body as Record<string, unknown>;
162
+ raw.model = comboEffortRow.baseId;
163
+ const rawReasoning = raw.reasoning;
164
+ raw.reasoning = {
165
+ ...(rawReasoning && typeof rawReasoning === "object" && !Array.isArray(rawReasoning)
166
+ ? rawReasoning as Record<string, unknown>
167
+ : {}),
168
+ effort: comboEffortRow.effort,
169
+ };
170
+ }
171
+ // Compaction may send the last client-visible bare model after a combo switch.
172
+ // Configured selectors take precedence; otherwise recall before combo dispatch (#3891).
173
+ if (!options.comboAttempt && body && typeof body === "object" && !Array.isArray(body)) {
174
+ const rawModel = (body as { model?: unknown }).model;
175
+ const rawInput = (body as { input?: unknown }).input;
176
+ const isCompactionTrigger = Array.isArray(rawInput)
177
+ && rawInput.some((item: unknown) =>
178
+ typeof item === "object" && item !== null && (item as { type?: string }).type === "compaction_trigger");
179
+ if (typeof rawModel === "string" && !rawModel.includes("/") && isCompactionTrigger
180
+ && !comboRows.fastRow && !comboEffortRow
181
+ && !resolveComboId(config, rawModel)) {
182
+ const recalledComboId = recallComboForLane(config, sessionLaneIdFromRequest(req.headers), rawModel);
183
+ if (recalledComboId) {
184
+ (body as Record<string, unknown>).model = `combo/${recalledComboId}`;
185
+ }
186
+ }
187
+ }
188
+ // A shadow-call replacement that names a COMBO is routing policy, not the identity of any
189
+ // one pick. The late intercept site below resolves it through routeModel/tryPickComboModel,
190
+ // which collapses the table to a single target while still tagging `routeKind: "combo"`, so
191
+ // the combo gate on the next line never fires, handleComboResponses never runs, and 429/5xx
192
+ // hops — which only exist inside that loop — are unreachable (#4129). Rewrite the selector
193
+ // here instead, before comboIdFromRawBody reads `model`, and identify the combo by CONFIG
194
+ // LOOKUP so the check can never observe a one-candidate collapse.
195
+ if (!options.comboAttempt && body && typeof body === "object" && !Array.isArray(body)) {
196
+ const shadowIntercept = config.shadowCallIntercept;
197
+ const rawShadowModel = (body as { model?: unknown }).model;
198
+ if (shadowIntercept?.enabled && shadowIntercept.model && typeof rawShadowModel === "string"
199
+ && isShadowSourceModel(rawShadowModel, shadowIntercept.sourceModels)) {
200
+ const shadowComboId = resolveComboId(config, shadowIntercept.model);
201
+ if (shadowComboId && Object.hasOwn(config.combos ?? {}, shadowComboId)) {
202
+ (body as Record<string, unknown>).model = shadowIntercept.model;
203
+ // Same rule as the late intercept site: record the operator-configured prefix that
204
+ // matched, never the caller's raw model string. Matching is by prefix, so the raw
205
+ // value is caller-controlled and reaches usage.jsonl and /api/logs.
206
+ logCtx.shadowCallRewrittenFrom = sanitizeLogMetadataString(
207
+ shadowSourceModelPrefix(rawShadowModel, shadowIntercept.sourceModels),
208
+ );
209
+ }
210
+ }
211
+ }
212
+ const comboId = !options.comboAttempt ? comboIdFromRawBody(body, config) : null;
213
+ if (comboId && Object.hasOwn(config.combos ?? {}, comboId)) {
214
+ options.onRequestBodyRead?.();
215
+ return requestDispatchers.handleComboResponses(req, body, comboId, config, logCtx, {
216
+ ...options,
217
+ // The original request body was accepted above. Combo children are synthetic
218
+ // replays and must not repeat the caller-owned timeout transition.
219
+ onRequestBodyRead: undefined,
220
+ });
221
+ }
222
+ let unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
223
+ (body as { input?: unknown } | undefined)?.input,
224
+ );
225
+ const inboundClientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined;
226
+ const cursorClientThreadId = codexPoolAffinityKey(req.headers);
227
+ const originalBody = body;
228
+ if (options.comboReplaySnapshot) {
229
+ copyPreviousResponseReplayProvenance(options.comboReplaySnapshot.sourceBody, body);
230
+ } else {
231
+ body = expandPreviousResponseInput(body, inboundClientThreadId);
232
+ if (previousResponseScopeMismatch(body)) {
233
+ console.warn("[opencodex] dropped a previous_response_id with a mismatched client task scope; continuing fresh");
234
+ }
235
+ if (previousResponseReplayFailure(body)) {
236
+ return formatErrorResponse(
237
+ 400,
238
+ "previous_response_not_found",
239
+ "Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.",
240
+ );
241
+ }
242
+ }
243
+ const previousResponseInputExpanded = options.comboReplaySnapshot?.previousResponseInputExpanded
244
+ ?? (body !== originalBody
245
+ && typeof (body as { previous_response_id?: unknown }).previous_response_id === "string");
246
+
247
+ // Spawn-message compatibility (both directions): agent_message task payloads ride in
248
+ // encrypted_content slots as plaintext. Rewrite them to input_text on the RAW body BEFORE
249
+ // parsing so every consumer sees the payload: parseRequest (routed/translated providers read
250
+ // the parsed messages) and the native passthrough (_rawBody is this same object, serialized
251
+ // verbatim). Genuine backend ciphertext is left byte-identical (looksLikeBackendCiphertext).
252
+ {
253
+ const rewritten = sanitizeEncryptedContentInPlace(
254
+ (body as { input?: unknown } | undefined)?.input,
255
+ );
256
+ if (rewritten > 0)
257
+ console.warn(
258
+ `[opencodex] rewrote ${rewritten} plaintext encrypted_content part(s) to input_text (spawn-message compatibility)`,
259
+ );
260
+ }
261
+
262
+ let parsed: OcxParsedRequest;
263
+ let toolBridgeMaps: ReturnType<typeof buildToolBridgeMaps>;
264
+ try {
265
+ parsed = parseRequest(body);
266
+ parsed._promptCacheKeyIsSharedCohort = options.promptCacheKeyIsSharedCohort;
267
+ // Captured before any parser mutates it, so both grammars see the client's id.
268
+ const { fastRow, effortRow } = parseSyntheticRowId(parsed.modelId, config);
269
+ if (fastRow) {
270
+ parsed.modelId = fastRow.baseId;
271
+ parsed.options.serviceTier = "priority";
272
+ const raw = parsed._rawBody as Record<string, unknown>;
273
+ raw.model = fastRow.baseId;
274
+ raw.service_tier = "priority";
275
+ }
276
+ if (effortRow) {
277
+ parsed.modelId = effortRow.baseId;
278
+ parsed.options.reasoning = effortRow.effort;
279
+ const raw = parsed._rawBody as Record<string, unknown>;
280
+ const rawReasoning = raw.reasoning;
281
+ raw.model = effortRow.baseId;
282
+ raw.reasoning = {
283
+ ...(rawReasoning && typeof rawReasoning === "object" && !Array.isArray(rawReasoning)
284
+ ? rawReasoning as Record<string, unknown>
285
+ : {}),
286
+ effort: effortRow.effort,
287
+ };
288
+ }
289
+ if (options.comboReplaySnapshot?.recoveredPlaintext) {
290
+ markBodyNonPersistable(parsed._rawBody);
291
+ }
292
+ toolBridgeMaps = buildToolBridgeMaps(parsed, translatorBudget);
293
+ if (previousResponseInputExpanded) parsed._previousResponseInputExpanded = true;
294
+ const providerContinuationCandidate = options.comboReplaySnapshot
295
+ ? options.comboReplaySnapshot.providerContinuation
296
+ : previousResponseProviderState(parsed.previousResponseId);
297
+ if (providerContinuationCandidate) parsed._providerContinuationCandidate = providerContinuationCandidate;
298
+ if (inboundClientThreadId) {
299
+ parsed._clientThreadId = inboundClientThreadId;
300
+ } else if (
301
+ options.inboundWire === "anthropic"
302
+ && options.promptCacheKeyIsSharedCohort !== true
303
+ && typeof parsed.options.promptCacheKey === "string"
304
+ && parsed.options.promptCacheKey.trim().length > 0
305
+ ) {
306
+ // Claude Code has no Codex parent-thread header, but its metadata.user_id is
307
+ // translated into a stable per-session prompt_cache_key. Use it as the replay
308
+ // thread identity so Gemini thought signatures are remembered by call_id for
309
+ // Anthropic Messages clients too (#1735/#1926). Keep `_clientThreadId` unset so
310
+ // existing provider session-id derivation (first-user-text fallback) is unchanged.
311
+ // Normalize through anthropicSessionKeyFromParts so overlong keys are hashed and
312
+ // trimming matches the affinity/session-key path exactly (no raw >128-char ids).
313
+ const normalizedCacheKey = anthropicSessionKeyFromParts({
314
+ promptCacheKey: parsed.options.promptCacheKey,
315
+ // The enclosing branch already proves this is not the shared cohort.
316
+ promptCacheKeyIsSharedCohort: false,
317
+ });
318
+ if (normalizedCacheKey) {
319
+ parsed._reasoningReplayScope = { clientThreadId: normalizedCacheKey };
320
+ }
321
+ }
322
+ if (cursorClientThreadId) parsed._cursorClientThreadId = cursorClientThreadId;
323
+ } catch (err) {
324
+ if (isTranslatorBudgetExceededError(err)) {
325
+ return formatErrorResponse(413, "request_too_large", "request translation buffer exceeded the safe limit", {
326
+ code: "translation_buffer_limit",
327
+ });
328
+ }
329
+ return formatErrorResponse(400, "invalid_request_error", err instanceof Error ? err.message : String(err));
330
+ }
331
+ options.onRequestBodyRead?.();
332
+ const responseStateOptions = (force = false): { force?: boolean; clientThreadId?: string } => ({
333
+ ...(force ? { force: true } : {}),
334
+ ...(parsed._clientThreadId ? { clientThreadId: parsed._clientThreadId } : {}),
335
+ });
336
+ const resolvedConversationId = conversationIdFromResponsesRequest({
337
+ clientThreadId: parsed._clientThreadId,
338
+ sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
339
+ threadIdHeader: req.headers.get("thread-id"),
340
+ cursorConversationId: parsed._cursorConversationId,
341
+ });
342
+ bindTurnTerminationScope(parsed, resolvedConversationId);
343
+ const rememberKiroDeliveredFinalAnswer = (adapterName: string, response: unknown): void => {
344
+ if (adapterName === "kiro") rememberDeliveredFinalAnswer(parsed, response);
345
+ };
346
+ // _clientThreadId remains the routing/continuation identity supplied by Codex. Replay state uses
347
+ // a dedicated raw conversation namespace so mixed headers that carry the same identity still
348
+ // match, and a shared/synthetic session_id cannot coalesce distinct thread/Cursor conversations.
349
+ // Keep an Anthropic prompt_cache_key scope already bound above (#1735/#1926).
350
+ if (!parsed._reasoningReplayScope) {
351
+ const reasoningReplayConversationId = reasoningReplayConversationIdFromResponsesRequest({
352
+ clientThreadId: parsed._clientThreadId,
353
+ threadIdHeader: req.headers.get("thread-id"),
354
+ cursorConversationId: parsed._cursorConversationId,
355
+ sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
356
+ });
357
+ if (reasoningReplayConversationId) {
358
+ parsed._reasoningReplayScope = { clientThreadId: reasoningReplayConversationId };
359
+ }
360
+ }
361
+ // Prefer a pre-populated id (routed Claude) over Responses headers that may be
362
+ // absent or synthetically injected (session_id from prompt_cache_key).
363
+ if (!logCtx.conversationId) {
364
+ logCtx.conversationId = resolvedConversationId;
365
+ }
366
+ logCtx.requestedModel = parsed.modelId;
367
+ logCtx.requestedEffort = parsed.options.reasoning;
368
+ logCtx.callerServiceTier = sanitizeLogMetadataString(parsed.options.serviceTier);
369
+ logCtx.requestedServiceTier = parsed.options.serviceTier;
370
+ logCtx.requestedSpeedLabel = requestLogSpeedLabel(parsed.options.serviceTier);
371
+ logCtx.configuredServiceTier = readConfiguredCodexServiceTier();
372
+ logCtx.configuredSpeedLabel = requestLogSpeedLabel(logCtx.configuredServiceTier);
373
+
374
+ let route: RouteResult;
375
+ let credentialDomainWasRewritten = false;
376
+ try {
377
+ // A `compaction_trigger` turn may name a bare native model the operator has
378
+ // no canonical OpenAI route for (#2901). Only the initial compaction route
379
+ // may fall back to the configured default provider; combo attempts and the
380
+ // later fallback/recovery re-routes keep the ordinary reservation.
381
+ const resolveRoute = (modelId: string) => options.comboAttempt
382
+ ? routeConcreteModel(config, modelId)
383
+ : parsed._compactionRequest === true
384
+ ? routeCompactionModel(config, modelId, evidenceFromBody(parsed._rawBody))
385
+ : routeModel(config, modelId, evidenceFromBody(parsed._rawBody));
386
+ const _sci = config.shadowCallIntercept;
387
+ let shadowRoute: RouteResult | undefined;
388
+ if (_sci?.enabled && _sci.model && isShadowSourceModel(parsed.modelId, _sci.sourceModels)) {
389
+ const sourcePrefix = shadowSourceModelPrefix(parsed.modelId, _sci.sourceModels)!;
390
+ let sourceIdentity = { providerName: OPENAI_CODEX_PROVIDER_ID, modelId: sourcePrefix };
391
+ try {
392
+ const resolvedSource = routeConcreteModel(config, parsed.modelId);
393
+ sourceIdentity = { providerName: resolvedSource.providerName, modelId: sourcePrefix };
394
+ } catch { /* Native Codex helper calls remain OpenAI-owned without an enabled OpenAI route. */ }
395
+ const targetRoute = resolveRoute(_sci.model);
396
+ if (shouldInterceptShadowCall(parsed.modelId, _sci.sourceModels, sourceIdentity, targetRoute)) {
397
+ credentialDomainWasRewritten = true;
398
+ const _sciOriginal = parsed.modelId;
399
+ parsed.modelId = _sci.model;
400
+ if (parsed._rawBody && typeof parsed._rawBody === "object") {
401
+ (parsed._rawBody as { model?: string }).model = _sci.model;
402
+ }
403
+ // Record the operator-configured prefix that matched, NOT the caller's raw model string.
404
+ // Matching is by prefix, so a caller can append arbitrary text and still intercept; that
405
+ // raw value would then land in usage.jsonl and /api/logs behind a pattern-based redactor
406
+ // that does not recognize every credential family. The prefix is a value the operator
407
+ // configured, so no caller-controlled string is persisted.
408
+ logCtx.shadowCallRewrittenFrom = sanitizeLogMetadataString(
409
+ shadowSourceModelPrefix(_sciOriginal, _sci.sourceModels),
410
+ );
411
+ // Helpers must not resume/append into the parent thread's Cursor conversation.
412
+ parsed._cursorIsolateConversation = true;
413
+ shadowRoute = targetRoute;
414
+ }
415
+ }
416
+ if (parsed._compactionRequest === true) parsed._cursorIsolateConversation = true;
417
+ route = shadowRoute ?? resolveRoute(parsed.modelId);
418
+ logCtx.routeDecision = route.routeDecision;
419
+ } catch (err) {
420
+ if (err instanceof NoAvailableComboTargetsError) {
421
+ return comboUnavailable(err.comboId);
422
+ }
423
+ if (err instanceof NoEligiblePolicyCandidateError) {
424
+ // Persist the evaluation trace (per-candidate exclusions + the
425
+ // no-eligible reason) so failed policy requests stay auditable.
426
+ logCtx.routeDecision = err.trace;
427
+ }
428
+ return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err));
429
+ }
430
+
431
+ const hasUnexpandedPreviousResponse = !!parsed.previousResponseId
432
+ && parsed._previousResponseInputExpanded !== true;
433
+ // Exact account selectors are isolated from Pool-wide quota work. A canonical replay miss must
434
+ // also fail closed without polling quota upstream. Cached fallback state can still select a
435
+ // provider with native continuation support below.
436
+ const threadSpawn = isThreadSpawnRequest(req.headers);
437
+ const initialSubagentFallbackChain = threadSpawn && !options.comboAttempt
438
+ ? resolveSubagentFallbackChain(parsed, config)
439
+ : null;
440
+ const previewSelectionAdmission = threadSpawn
441
+ && !options.comboAttempt
442
+ && (route.codexAccountId === undefined || initialSubagentFallbackChain !== null)
443
+ ? codexAccountSelectionForTurn(options.turnAdmissionLease)?.()
444
+ : undefined;
445
+ const nativeMainRecoveryBlocked = isNativeMainTrafficBlocked();
446
+ const nativeMainReadsForbidden = nativeMainRecoveryBlocked
447
+ || previewSelectionAdmission?.mainProfileDraining === true;
448
+ const previewSelectionOptions = {
449
+ nativeMainSelectionOnly: !nativeMainRecoveryBlocked
450
+ && previewSelectionAdmission?.mainProfileDraining === true,
451
+ };
452
+ let selectedForwardHeaders = req.headers;
453
+ let subagentFallbackAccountId = config.activeCodexAccountId ?? null;
454
+ let subagentFallbackAccountPreview: SubagentPoolAccountPreview | undefined;
455
+ let subagentFallbackModelEligibleAccountIdsForModel: SubagentModelEligibleAccountIds | undefined;
456
+ let subagentQuotaFailureModel = parsed.modelId;
457
+ const parentThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() ?? null;
458
+ const poolAffinityKey = codexPoolAffinityKey(req.headers) ?? null;
459
+ // Preview has to see the same lineage resolve does. Without it, a child's first turn is
460
+ // previewed as a cold pick and resolved onto the family account, and the subagent fallback
461
+ // then decides model eligibility against an account the request will never use.
462
+ //
463
+ // "The same" means both halves of the question the final resolution asks. The Authorization
464
+ // it will be given, because the lineage scope is an HMAC of exactly that header; and its own
465
+ // Pool-state predicate, because a fixed account selector and a request-owned credential
466
+ // deliberately create no affinity at all -- previewing a family binding for one of those would
467
+ // hand model fallback an account this request can never authenticate as. Read-only: the record
468
+ // is written by the resolution that binds, never by a preview that may own no Pool state.
469
+ const previewAuthHeaders = codexRouteCredentialDomainHeaders(
470
+ req,
471
+ route,
472
+ options,
473
+ credentialDomainWasRewritten,
474
+ );
475
+ const poolLineage = previewCodexPoolLineage(previewAuthHeaders, options.codexAuthPolicy ?? config, {
476
+ accountId: route.codexAccountId,
477
+ modelId: route.modelId,
478
+ admission: options.admission,
479
+ requestScopedMainCredential: codexRouteCredentialOwnership(
480
+ previewAuthHeaders,
481
+ config,
482
+ route,
483
+ options,
484
+ ).requestScopedMainCredential,
485
+ });
486
+
487
+ try {
488
+ if (
489
+ threadSpawn
490
+ && route.codexAccountId === undefined
491
+ && !(hasUnexpandedPreviousResponse && isCanonicalOpenAiForwardProvider(route.provider))
492
+ ) {
493
+ await maybePrimeSubagentQuota(config, Date.now(), { nativeMainReadsForbidden });
494
+ }
495
+
496
+ // Subagent fallback must settle the final model/provider BEFORE route-dependent
497
+ // normalization (virtual models, effort caps, service tier, wire protocol).
498
+ // Preview the preferred Codex account without acquiring a probe lease or refreshing
499
+ // tokens — auth is resolved only after the final route is selected.
500
+ if (
501
+ threadSpawn
502
+ && !options.comboAttempt
503
+ && (route.codexAccountId === undefined || initialSubagentFallbackChain !== null)
504
+ ) {
505
+ // The final resolveCodexAuthContext binds under codexQuotaScopeForModel(route.modelId),
506
+ // so the preview must read the same scope slot — an undefined scope would map to the
507
+ // "legacy" affinity bucket and never find a binding made under "shared" or a native
508
+ // model scope, making the preview diverge from the account that actually authenticates.
509
+ const fallbackChain = initialSubagentFallbackChain;
510
+ subagentFallbackModelEligibleAccountIdsForModel = await resolveSubagentFallbackModelEligibility({
511
+ config,
512
+ fallbackChain,
513
+ nativeMainReadsForbidden,
514
+ resolver: options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements,
515
+ });
516
+ const fallbackNow = Date.now();
517
+ subagentFallbackAccountPreview = (modelId, previewNow, modelEligibleAccountIds) => previewCodexAccountForRequest(
518
+ poolAffinityKey,
519
+ config,
520
+ previewNow,
521
+ codexQuotaScopeForModel(modelId),
522
+ { ...previewSelectionOptions, modelEligibleAccountIds },
523
+ modelId,
524
+ poolLineage,
525
+ );
526
+ const previewAccountId = route.codexAccountId ?? subagentFallbackAccountPreview(
527
+ route.modelId,
528
+ fallbackNow,
529
+ subagentFallbackModelEligibleAccountIdsForModel?.(route.modelId),
530
+ );
531
+ subagentFallbackAccountId = previewAccountId ?? config.activeCodexAccountId ?? null;
532
+ const fallback = applySubagentModelFallback(
533
+ parsed,
534
+ req.headers,
535
+ config,
536
+ previewAccountId,
537
+ fallbackNow,
538
+ unreadableEncryptedAgentTask,
539
+ previewSelectionOptions,
540
+ subagentFallbackAccountPreview,
541
+ subagentFallbackModelEligibleAccountIdsForModel,
542
+ fallbackChain,
543
+ candidateRoute => canPassThroughEncryptedV2AgentTask(candidateRoute, inboundWire),
544
+ );
545
+ if (fallback) {
546
+ (logCtx as unknown as Record<string, unknown>).subagentModelFallbackFrom = fallback.from;
547
+ (logCtx as unknown as Record<string, unknown>).subagentModelFallbackTo = fallback.to;
548
+ if (isInjectionDebugEnabled()) {
549
+ injectionDebugLog(`[opencodex] subagent model fallback ${fallback.from} -> ${fallback.to}`);
550
+ }
551
+ }
552
+ subagentQuotaFailureModel = fallback?.to ?? parsed.modelId;
553
+
554
+ if (fallback?.to && !slugsEquivalent(fallback.to, route.modelId)) {
555
+ try {
556
+ route = routeModel(config, fallback.to, evidenceFromBody(parsed._rawBody));
557
+ credentialDomainWasRewritten = true;
558
+ logCtx.routeDecision = route.routeDecision;
559
+ } catch (err) {
560
+ if (err instanceof NoAvailableComboTargetsError) {
561
+ return comboUnavailable(err.comboId);
562
+ }
563
+ if (err instanceof NoEligiblePolicyCandidateError) {
564
+ logCtx.routeDecision = err.trace;
565
+ }
566
+ return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err));
567
+ }
568
+ }
569
+ }
570
+ } finally {
571
+ previewSelectionAdmission?.release();
572
+ }
573
+
574
+ let recoveryFailureReason: AgentTaskRecoveryFailureReason | undefined;
575
+ // Native fallback and explicitly trusted direct Responses routes can consume ciphertext,
576
+ // so recover only after final route selection.
577
+ //
578
+ // Deliberately NOT gated on `threadSpawn` (#4089). Switching a live thread from a native
579
+ // ChatGPT model to a routed provider replays a backend-minted encrypted agent message on every
580
+ // later turn, and a model switch is not a spawn, so the spawn requirement failed the thread
581
+ // closed permanently without ever attempting recovery. The trust boundary is
582
+ // `recoveryAdmission()` in ./agent-task-recovery -- Codex originator, live native ChatGPT
583
+ // bearer, matching chatgpt-account-id, no inbound API key, no proxy-admission secret -- which
584
+ // admits only the owner of the session that would be spent. `threadSpawn` narrowed which of
585
+ // that owner's own requests could use their own session; it kept nobody else out. The combo
586
+ // gate above keeps its spawn requirement: that path has its own native-target filtering and
587
+ // per-attempt failover, and the reported defect is on this path.
588
+ if (
589
+ inboundWire === "responses"
590
+ && agentTaskRecovery
591
+ && !isCanonicalOpenAiForwardProvider(route.provider)
592
+ && !options.comboAttempt
593
+ && !canPassThroughEncryptedV2AgentTask(route, inboundWire)
594
+ ) {
595
+ let recovered = restoreCachedEncryptedAgentTasks(
596
+ req, (body as { input?: unknown } | undefined)?.input, config, { parentThreadId },
597
+ ) > 0;
598
+ unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
599
+ (body as { input?: unknown } | undefined)?.input,
600
+ );
601
+ if (unreadableEncryptedAgentTask) try {
602
+ const result = await recoverEncryptedAgentTaskWithResult(
603
+ req,
604
+ (body as { input?: unknown } | undefined)?.input,
605
+ agentTaskRecovery,
606
+ config,
607
+ { parentThreadId, abortSignal: options.abortSignal },
608
+ );
609
+ recovered = result.recovered;
610
+ recoveryFailureReason = result.recovered ? undefined : result.reason;
611
+ } catch {
612
+ recovered = false;
613
+ recoveryFailureReason = undefined;
614
+ }
615
+ if (recovered) {
616
+ unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
617
+ (body as { input?: unknown } | undefined)?.input,
618
+ );
619
+ if (!unreadableEncryptedAgentTask) {
620
+ try {
621
+ const reparsed = parseRequest(body);
622
+ const kept: Array<keyof OcxParsedRequest> = [
623
+ "_previousResponseInputExpanded",
624
+ "_providerContinuation",
625
+ "_providerContinuationCandidate",
626
+ "_providerContinuationOwner",
627
+ "_cursorConversationId",
628
+ "_clientThreadId",
629
+ "_promptCacheKeyIsSharedCohort",
630
+ "_cursorClientThreadId",
631
+ "_reasoningReplayScope",
632
+ "_cursorIsolateConversation",
633
+ ];
634
+ for (const key of kept) {
635
+ if (parsed[key] !== undefined) {
636
+ (reparsed as unknown as Record<string, unknown>)[key] = parsed[key];
637
+ }
638
+ }
639
+ bindTurnTerminationScope(reparsed, resolvedConversationId);
640
+ parsed = reparsed;
641
+ // The recovery mutated `body.input` in place, so `_rawBody` now carries decrypted task
642
+ // text. Bar it from the continuation cache before any recording path can reach it —
643
+ // that cache is persisted to disk, which would defeat the recovery cache's TTL.
644
+ markBodyNonPersistable(parsed._rawBody);
645
+
646
+ // The ciphertext-only pass intentionally excludes routed candidates. Once recovery
647
+ // makes the assignment readable, run selection again with the full configured chain
648
+ // and keep the route in sync with any newly selected fallback.
649
+ const recoverySelectionAdmission = codexAccountSelectionForTurn(options.turnAdmissionLease)?.();
650
+ const fallback = (() => {
651
+ try {
652
+ const recoveryNativeMainBlocked = isNativeMainTrafficBlocked();
653
+ const recoverySelectionOptions = {
654
+ nativeMainSelectionOnly: !recoveryNativeMainBlocked
655
+ && recoverySelectionAdmission?.mainProfileDraining === true,
656
+ };
657
+ const recoveryNow = Date.now();
658
+ // Carry the entitlement filter through recovery too (#2509/#2623). The scope was
659
+ // already re-previewed per candidate here; the ELIGIBLE-ACCOUNT set was not, so a
660
+ // recovered assignment could select an account that is not entitled to the model
661
+ // and then fail closed at final auth — the same class of stale-selection bug as
662
+ // the quota scope, one layer over.
663
+ subagentFallbackAccountPreview = (modelId, previewNow, modelEligibleAccountIds) => previewCodexAccountForRequest(
664
+ poolAffinityKey,
665
+ config,
666
+ previewNow,
667
+ codexQuotaScopeForModel(modelId),
668
+ { ...recoverySelectionOptions, modelEligibleAccountIds },
669
+ modelId,
670
+ poolLineage,
671
+ );
672
+ const recoveryPreviewAccountId = subagentFallbackAccountPreview(
673
+ parsed.modelId,
674
+ recoveryNow,
675
+ subagentFallbackModelEligibleAccountIdsForModel?.(parsed.modelId),
676
+ );
677
+ return applySubagentModelFallback(
678
+ parsed,
679
+ req.headers,
680
+ config,
681
+ recoveryPreviewAccountId,
682
+ recoveryNow,
683
+ false,
684
+ recoverySelectionOptions,
685
+ subagentFallbackAccountPreview,
686
+ subagentFallbackModelEligibleAccountIdsForModel,
687
+ );
688
+ } finally {
689
+ recoverySelectionAdmission?.release();
690
+ }
691
+ })();
692
+ if (fallback) {
693
+ (logCtx as unknown as Record<string, unknown>).subagentModelFallbackFrom = fallback.from;
694
+ (logCtx as unknown as Record<string, unknown>).subagentModelFallbackTo = fallback.to;
695
+ if (isInjectionDebugEnabled()) {
696
+ injectionDebugLog(`[opencodex] subagent model fallback ${fallback.from} -> ${fallback.to}`);
697
+ }
698
+ }
699
+ subagentQuotaFailureModel = fallback?.to ?? parsed.modelId;
700
+
701
+ if (fallback?.to && !slugsEquivalent(fallback.to, route.modelId)) {
702
+ try {
703
+ route = routeModel(config, fallback.to, evidenceFromBody(parsed._rawBody));
704
+ credentialDomainWasRewritten = true;
705
+ logCtx.routeDecision = route.routeDecision;
706
+ } catch (err) {
707
+ if (err instanceof NoAvailableComboTargetsError) {
708
+ return comboUnavailable(err.comboId);
709
+ }
710
+ if (err instanceof NoEligiblePolicyCandidateError) {
711
+ logCtx.routeDecision = err.trace;
712
+ }
713
+ return formatErrorResponse(
714
+ 404,
715
+ "invalid_request_error",
716
+ err instanceof Error ? err.message : String(err),
717
+ );
718
+ }
719
+ }
720
+ } catch {
721
+ unreadableEncryptedAgentTask = true;
722
+ }
723
+ }
724
+ }
725
+ }
726
+
727
+ if (options.abortSignal?.aborted) return clientCancelledResponse();
728
+
729
+ // Encrypted child tasks may reach the canonical native backend or an explicitly trusted
730
+ // direct Responses route. This runs against the FINAL route so native-only fallback can
731
+ // rescue an incompatible primary without weakening combo behavior.
732
+ const finalRouteCanPassThroughEncryptedTask = !options.comboAttempt
733
+ && canPassThroughEncryptedV2AgentTask(route, inboundWire);
734
+ if (
735
+ (route.combo !== undefined || !isCanonicalOpenAiForwardProvider(route.provider))
736
+ && !finalRouteCanPassThroughEncryptedTask
737
+ && unreadableEncryptedAgentTask
738
+ ) {
739
+ return unreadableEncryptedAgentTaskResponse(recoveryFailureReason);
740
+ }
741
+
742
+ // The guard above asks whether the CURRENT worker task is readable, and it only inspects the
743
+ // tail item. An `agent_message` that mixes readable text with backend ciphertext answers
744
+ // "readable" to that question at every position, so it passed -- and then
745
+ // `normalizeRoutedAgentMessages` refused to lower it, because lowering requires every part to
746
+ // be representable. The raw Responses passthrough serialized the private item as it stood, so
747
+ // backend ciphertext and an item type only the Codex backend declares reached a third-party
748
+ // provider, which answered `422 unknown item type "agent_message"` (#4454).
749
+ //
750
+ // The opaque-blob path already knows the repair: replace the undecryptable part with an
751
+ // omission marker, which leaves the item lowerable. It applied that repair only AFTER an
752
+ // upstream rejection. For a destination that cannot accept the private item under any
753
+ // circumstances, that round trip was never going to succeed and sent the ciphertext to find
754
+ // out, so do the repair here instead. Recovery above has already had its chance to turn the
755
+ // same bytes into real plaintext; only what it could not rescue reaches this.
756
+ if (inboundWire === "responses" && !finalRouteCanPassThroughEncryptedTask) {
757
+ // Only the raw Responses passthrough puts input items on the wire verbatim, so that is the
758
+ // only wire this has to repair: translated wires rebuild the body from parsed messages, where
759
+ // `inputContentParts` drops an encrypted part instead of forwarding it. The exemption is the
760
+ // canonical Codex backend alone, because it is the one destination that minted these bytes and
761
+ // can read them. `authMode: "forward"` is NOT that test -- a noncanonical forward gateway is
762
+ // somebody else's server that happens to be configured for passthrough, and it receives the
763
+ // ciphertext like any other third party.
764
+ //
765
+ // Combo children run this too. Each child carries its own `structuredClone` of the body
766
+ // (`concreteComboRequestBody`) and its own concrete route, so a sibling's repair is invisible
767
+ // here and a target that resolves to a routed Responses wire would otherwise send the
768
+ // ciphertext that the parent's own dispatch no longer does.
769
+ const wireProvider = resolveWireProtocolOverride(
770
+ route.providerName,
771
+ route.modelId,
772
+ route.provider,
773
+ inboundWire,
774
+ );
775
+ if (wireProvider.adapter === "openai-responses" && !isCanonicalOpenAiForwardProvider(wireProvider)) {
776
+ const repaired = stripAgentMessageCiphertextInPlace((body as { input?: unknown } | undefined)?.input);
777
+ if (repaired > 0) {
778
+ console.warn(
779
+ `[opencodex] replaced ciphertext in ${repaired} replayed agent message(s) with an omission marker; the selected provider cannot read native ChatGPT ciphertext`,
780
+ );
781
+ }
782
+ }
783
+ }
784
+
785
+ // The canonical ChatGPT backend rejects previous_response_id, so a local replay miss leaves no
786
+ // safe way to recover the omitted history. Fail before auth, adapter construction, or upstream
787
+ // I/O instead of stripping the id and silently forwarding a context-free delta (#702).
788
+ // Codex recognizes previous_response_not_found on WebSocket errors and reconnects with its
789
+ // full input. A generic invalid_request_error instead terminates the task after cache expiry.
790
+ if (
791
+ hasUnexpandedPreviousResponse
792
+ && isCanonicalOpenAiForwardProvider(route.provider)
793
+ ) {
794
+ return formatErrorResponse(
795
+ 400,
796
+ "previous_response_not_found",
797
+ "OpenAI forward continuation state is unavailable or expired; resend the full conversation without previous_response_id.",
798
+ );
799
+ }
800
+
801
+ if (hasUnexpandedPreviousResponse) {
802
+ const continuationProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
803
+ // Can the DESTINATION see the history this process failed to restore? Only the native
804
+ // Responses passthrough can: it forwards previous_response_id to a backend that stored the
805
+ // chain. Every translated wire rebuilds the conversation from this request's input alone —
806
+ // including the three that look stateful, for the reasons recorded in
807
+ // responses/continuation-ownership.ts — so a replay miss there is not a degraded turn. It is
808
+ // the entire conversation deleted, with one user line left in its place and nothing in the
809
+ // response saying so. Refuse before auth or upstream I/O and let the client resend.
810
+ const continuationWire = resolvedAdapterWire(continuationProvider.adapter);
811
+ const upstreamOwnsOmittedHistory = continuationWire === "openai-responses"
812
+ // Stateless destinations cannot resolve the omitted prefix. Stateful destinations may,
813
+ // but a lowered custom result still needs its call to recover the original wire type.
814
+ // Native function/custom continuations without lowering keep their upstream-owned state.
815
+ ? !(continuationProvider.statelessResponses === true
816
+ || hasUnmappedRoutedCustomToolOutput(parsed._rawBody, continuationProvider.supportsResponsesCustomTools))
817
+ // An unknown adapter is left to the resolution error it already raises below.
818
+ : continuationWire === undefined || PROVIDER_OWNED_CONTINUATION_WIRES.has(continuationWire);
819
+ if (!upstreamOwnsOmittedHistory) {
820
+ return formatErrorResponse(
821
+ 400,
822
+ "previous_response_not_found",
823
+ "Routed continuation requires unavailable local history; resend the full conversation without previous_response_id.",
824
+ );
825
+ }
826
+ }
827
+
828
+ // Captured before normalization: whether the CLIENT asked for SSE. The
829
+ // transport-neutral upstream-streaming policy below may force a bounded JSON
830
+ // upstream for reliability (#875); the answer must then be reframed to SSE
831
+ // for streaming clients.
832
+ const clientRequestedStream = parsed.stream;
833
+ await applyFinalRouteRequestNormalization({
834
+ parsed,
835
+ route,
836
+ config,
837
+ req,
838
+ logCtx,
839
+ inboundWire,
840
+ inboundTransport: options.inboundTransport,
841
+ claudeGoAffinity: options.claudeGoAffinity,
842
+ });
843
+ // Attribute local auth/cooldown failures to the public selector too; exact auth may fail before
844
+ // the normal post-resolution provider label is assigned.
845
+ if (route.codexAccountNamespace) {
846
+ logCtx.provider = `${route.providerName}-${route.codexAccountNamespace}`;
847
+ }
848
+
849
+ if (options.abortSignal?.aborted) return clientCancelledResponse();
850
+ // Resolve aliases/combo children before refusing helpers; do not spend main auth or host budget.
851
+ if (isCanonicalOpenAiForwardProvider(route.provider)
852
+ && isCodexReserveHelperUnsupported(options.codexAuthPolicy ?? config, route.modelId,
853
+ options.admission, options.visionDescribeTerminal === true)) {
854
+ return formatErrorResponse(400, "invalid_request_error", CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE);
855
+ }
856
+ // Refuse an input that cannot plausibly fit the model context window before spending auth,
857
+ // circuit budget, or upstream bandwidth on a turn the provider will reject anyway (#1412).
858
+ //
859
+ // Compaction turns are exempt: Codex sends compaction_trigger BECAUSE context is full, so
860
+ // refusing the turn that shrinks the context would deadlock the client against the very
861
+ // limit this gate reports — it would be told to compact and then denied the compaction.
862
+ if (parsed._compactionRequest !== true) {
863
+ const inputAdmission = checkInputAdmission(parsed, route.provider, route.providerName, parsed.modelId, nativeContextLimits(config));
864
+ if (!inputAdmission.admitted) {
865
+ // #1524: this is a LOCAL preflight refusal, not an upstream verdict. A policy or combo
866
+ // fallback must be able to skip this candidate and try one whose context window fits,
867
+ // instead of treating the first incompatible candidate as the end of the chain. The
868
+ // distinct code is what lets the fallback layer tell the two apart -- an upstream
869
+ // `context_length_exceeded` still stops, because retrying it elsewhere is guesswork.
870
+ if (clientRequestedStream && !options.comboAttempt) {
871
+ return streamingContextOverflowResponse(
872
+ parsed._responseModelId ?? parsed.modelId,
873
+ translatorBudget,
874
+ );
875
+ }
876
+ return formatErrorResponse(
877
+ 413,
878
+ "input_admission_refused",
879
+ `Estimated input (~${inputAdmission.estimatedTokens} tokens) is far past the context window `
880
+ + `of ${parsed.modelId} (${inputAdmission.ceiling} tokens). Start a new session or choose a `
881
+ + `model with a larger context window.`,
882
+ );
883
+ }
884
+ }
885
+ const preAuthHostKey = preAuthUpstreamHostCircuitKey(route, config);
886
+ if (preAuthHostKey) {
887
+ const admission = acquireUpstreamHostAdmission(
888
+ preAuthHostKey,
889
+ config.upstreamHostCircuitThreshold,
890
+ );
891
+ if (admission.kind === "blocked") {
892
+ return upstreamHostCircuitOpenResponse(admission.retryAfterSeconds);
893
+ }
894
+ admissionState.pendingHostAdmissionLease = admission.lease;
895
+ }
896
+
897
+ let substituteMainCredential = false;
898
+ let callerAuthHeaders: Headers;
899
+ {
900
+ const finalAuth = await resolveResponsesCodexAuth(req, config, route, options, credentialDomainWasRewritten);
901
+ if (!finalAuth.ok) return finalAuth.response;
902
+ admissionState.authCtx = finalAuth.authCtx;
903
+ selectedForwardHeaders = withClaudeNativeSession(finalAuth.headers, route.provider, options.claudeNativeSessionId);
904
+ callerAuthHeaders = withClaudeNativeSession(finalAuth.callerAuthHeaders, route.provider, options.claudeNativeSessionId);
905
+ substituteMainCredential = finalAuth.substituteMainCredential;
906
+ }
907
+
908
+ route.provider = applyCodexAuthContextToProvider(route.provider, admissionState.authCtx, route.codexAccountMode);
909
+ applyCodexAccountGatedWireNormalization(parsed, route, logCtx);
910
+ logCtx.provider = route.codexAccountNamespace
911
+ ? `${route.providerName}-${route.codexAccountNamespace}`
912
+ : formatCodexProviderForLog(route.providerName, codexLogAccountId(admissionState.authCtx), config);
913
+ logCtx.accountLogLabel = codexAuthContextLogLabel(admissionState.authCtx, config);
914
+ // A move is the expensive event: it discards the prefix warmed on the previous account. Record
915
+ // it as an event with its cause, so the operator reads it off one line instead of inferring it
916
+ // from account labels across many (#4546).
917
+ if (admissionState.authCtx.kind === "pool" && admissionState.authCtx.affinityDecision) {
918
+ logCtx.affinity = admissionState.authCtx.affinityDecision.move;
919
+ logCtx.affinityReason = admissionState.authCtx.affinityDecision.reason;
920
+ }
921
+ {
922
+ const binding = conversationStateBindingFromAuth(admissionState.authCtx, poolAffinityKey);
923
+ if (binding) {
924
+ applyAccountChangeConversationStateScrub({
925
+ body: parsed._rawBody,
926
+ parsed,
927
+ bindingKey: binding.bindingKey,
928
+ servingAccountId: binding.accountId,
929
+ logCtx,
930
+ });
931
+ }
932
+ }
933
+ // Seed an account-derived scope before final adapter binding. Cursor never treats it as
934
+ // authoritative: bindRouteReasoningReplayScope replaces it with the exact route owner or a
935
+ // per-request fail-closed sentinel after the final provider and credential are known.
936
+ const identityScope = codexLogAccountId(admissionState.authCtx);
937
+ if (identityScope) parsed._cursorIdentityScope = identityScope;
938
+ subagentFallbackAccountId = admissionState.authCtx.kind === "pool" || admissionState.authCtx.kind === "main-pool"
939
+ ? admissionState.authCtx.accountId
940
+ : config.activeCodexAccountId ?? null;
941
+
942
+ return {
943
+ inboundWire,
944
+ translatorBudget,
945
+ parsed,
946
+ toolBridgeMaps,
947
+ responseStateOptions,
948
+ rememberKiroDeliveredFinalAnswer,
949
+ route,
950
+ get selectedForwardHeaders(): typeof selectedForwardHeaders {
951
+ return selectedForwardHeaders;
952
+ },
953
+ set selectedForwardHeaders(value: typeof selectedForwardHeaders) {
954
+ selectedForwardHeaders = value;
955
+ },
956
+ get subagentFallbackAccountId(): typeof subagentFallbackAccountId {
957
+ return subagentFallbackAccountId;
958
+ },
959
+ set subagentFallbackAccountId(value: typeof subagentFallbackAccountId) {
960
+ subagentFallbackAccountId = value;
961
+ },
962
+ subagentQuotaFailureModel,
963
+ poolAffinityKey,
964
+ clientRequestedStream,
965
+ substituteMainCredential,
966
+ callerAuthHeaders,
967
+ };
968
+ }
969
+
970
+ export type PreparedResponsesRequest = Exclude<Awaited<ReturnType<typeof prepareResponsesRequest>>, Response>;