@bitkyc08/opencodex 2.55.0-preview.20260914 → 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-DH2PUHqr.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,707 @@
1
+ import {
2
+ CODEX_TEXT_GUARDED_BUDGET_POLICY,
3
+ createRequestExecutionBudget,
4
+ isRequestExecutionBudget,
5
+ } from "../../lib/request-execution-budget";
6
+ import type {
7
+ RequestExecutionBudgetPolicy,
8
+ RequestExecutionBudget,
9
+ } from "../../lib/request-execution-budget";
10
+ import type { OcxConfig } from "../../types";
11
+ import type { RequestLogContext } from "../request-log";
12
+ import type { HandleResponsesOptions, ResponsesDispatchers, ConsumedComboFailure } from "./core-options";
13
+ import type { TranslatorBudget } from "../../lib/translator-budget";
14
+ import {
15
+ getCombo,
16
+ comboRequestHasImageInput,
17
+ pickComboTargetWithWait,
18
+ targetKey,
19
+ concreteComboRequestBody,
20
+ comboDefaultEffort,
21
+ isComboTargetInCooldown,
22
+ noteComboSuccess,
23
+ comboFailureDecision,
24
+ advanceComboAfterFailure,
25
+ comboFailureCooldownScope,
26
+ } from "../../combos";
27
+ import { formatErrorResponse } from "../../bridge";
28
+ import {
29
+ expandPreviousResponseInput,
30
+ previousResponseScopeMismatch,
31
+ previousResponseReplayFailure,
32
+ previousResponseProviderState,
33
+ } from "../../responses/state";
34
+ import { hasUnreadableEncryptedAgentTask } from "./encrypted-payload";
35
+ import { routeConcreteModel, comboRouteDecisionTrace } from "../../router";
36
+ import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
37
+ import type { AgentTaskRecoveryFailureReason } from "./agent-task-recovery";
38
+ import {
39
+ agentTaskRecoveryConfig,
40
+ discardEncryptedAgentTaskRecovery,
41
+ recoverEncryptedAgentTaskWithResult,
42
+ } from "./agent-task-recovery";
43
+ import { isThreadSpawnRequest, supportedLadderFor } from "../effort-policy";
44
+ import {
45
+ clientCancelledResponse,
46
+ comboUnavailable,
47
+ unreadableEncryptedAgentTaskResponse,
48
+ } from "./core-errors";
49
+ import {
50
+ buildComboChildHeaders,
51
+ createChildPassthroughCallbackGate,
52
+ consumeComboFailure,
53
+ } from "./core-combo-failure";
54
+ import { linkRequestSessionLane, sessionLaneIdFromRequest } from "../request-log-conversation";
55
+ import type { CodexAuthContext } from "../../codex/auth-context";
56
+ import type { ResponsesTerminalStatus } from "../../bridge";
57
+ import { beginRequestAttempt, sealRequestAttemptIdentity, finishRequestAttempt } from "../request-log";
58
+ import { rememberComboForLane } from "./combo-session-recall";
59
+ import { runTurnAdapterSseResponses } from "./core-lifetime";
60
+ import {
61
+ isNativePassthroughSseResponse,
62
+ isEagerRelaySseResponse,
63
+ markNativePassthroughSseResponse,
64
+ markEagerRelaySseResponse,
65
+ } from "../relay";
66
+ import { preflightComboStreamResponse } from "./combo-stream-preflight";
67
+ import { streamingContextOverflowResponse, jsonContextOverflowResponse } from "./context-overflow";
68
+
69
+ /**
70
+ * Sends one combo target may run on its own before the ladder moves on. A target is a whole
71
+ * request as far as its own provider is concerned, so this is the guarded profile's base
72
+ * allowance rather than a separate number to keep in sync.
73
+ */
74
+ export const COMBO_TARGET_BASE_SENDS = CODEX_TEXT_GUARDED_BUDGET_POLICY.baseSendAllowance;
75
+
76
+
77
+ /**
78
+ * A combo's execution policy is DECLARED by the combo, not inherited from the single-target
79
+ * profile.
80
+ *
81
+ * `maxTargetTransitions: 1` and `maxAlternateTargetSends: 1` describe an account move, and
82
+ * applying them to a combo would refuse the second hop of a three-target combo -- which is why
83
+ * combo was left off `reserveDispatch` when the per-request split landed. The transitions a
84
+ * combo may make are exactly the targets it declares minus the one it starts on. What stays
85
+ * capped is the TOTAL: the first target's full ladder, one send for every further declared
86
+ * target, and the one shared final-recovery reserve. A one-target combo reduces to the guarded
87
+ * profile exactly, and a three-target combo whose every target fails hard reaches upstream six
88
+ * times instead of the twelve #4546 measured.
89
+ */
90
+ export function comboExecutionBudgetPolicy(declaredTargets: number): RequestExecutionBudgetPolicy {
91
+ const targets = Math.max(1, Math.trunc(declaredTargets));
92
+ const hops = targets - 1;
93
+ const reserve = CODEX_TEXT_GUARDED_BUDGET_POLICY.finalRecoveryAllowance;
94
+ const total = COMBO_TARGET_BASE_SENDS + hops + reserve;
95
+ return {
96
+ maxTotalModelSends: total,
97
+ baseSendAllowance: total - reserve,
98
+ finalRecoveryAllowance: reserve,
99
+ maxAlternateTargetSends: Math.max(1, hops),
100
+ maxTargetTransitions: Math.max(1, hops),
101
+ };
102
+ }
103
+
104
+
105
+ /**
106
+ * A budget scope that keeps its own recovery ledgers but spends the SAME request-wide counter.
107
+ *
108
+ * `used` is redefined as an accessor onto the parent because the factory reads it back off this
109
+ * object -- `remainingBaseSends` and the total check both do -- so a copied number would let a
110
+ * combo target run its ladder against a stale total, which is precisely the per-layer counting
111
+ * this work exists to remove. The reserve, alternate-target and transition ledgers stay
112
+ * per-scope on purpose: a combo target's account failover is its own recovery decision, while
113
+ * the request total still bounds every target together.
114
+ */
115
+ export function deriveSendBudgetScope(
116
+ parent: RequestExecutionBudget,
117
+ policy: RequestExecutionBudgetPolicy,
118
+ ): RequestExecutionBudget {
119
+ const scope = createRequestExecutionBudget(policy, parent.logicalRequestId);
120
+ Object.defineProperty(scope, "used", {
121
+ get: () => parent.used,
122
+ set: (value: number) => { parent.used = value; },
123
+ enumerable: true,
124
+ configurable: true,
125
+ });
126
+ return scope;
127
+ }
128
+
129
+
130
+ /**
131
+ * The ladder one combo target may run, expressed as an allowance on the request-wide counter.
132
+ *
133
+ * `used + COMBO_TARGET_BASE_SENDS` gives this target its own ladder from wherever the request
134
+ * already stands, and the clamp holds back one send for each target still declared after it: a
135
+ * first target that 5xx-streaks must not eat the send the last declared target is entitled to.
136
+ * That guarantee is the difference between a per-target policy and a shared pool the first
137
+ * target drains.
138
+ */
139
+ export function comboTargetSendBudget(
140
+ comboScope: RequestExecutionBudget,
141
+ targetsDeclaredAfterThisOne: number,
142
+ ): RequestExecutionBudget {
143
+ const policy = comboScope.policy;
144
+ const heldForLaterTargets = Math.max(0, targetsDeclaredAfterThisOne);
145
+ const ceiling = Math.max(1, policy.maxTotalModelSends - heldForLaterTargets);
146
+ return deriveSendBudgetScope(comboScope, {
147
+ maxTotalModelSends: policy.maxTotalModelSends,
148
+ baseSendAllowance: Math.min(ceiling, comboScope.used + COMBO_TARGET_BASE_SENDS),
149
+ finalRecoveryAllowance: policy.finalRecoveryAllowance,
150
+ // Within one target the account-move shape is unchanged: three same-account sends plus one
151
+ // alternate is the recovery live traffic depends on, and a combo does not widen it.
152
+ maxAlternateTargetSends: CODEX_TEXT_GUARDED_BUDGET_POLICY.maxAlternateTargetSends,
153
+ maxTargetTransitions: CODEX_TEXT_GUARDED_BUDGET_POLICY.maxTargetTransitions,
154
+ });
155
+ }
156
+
157
+
158
+ export async function executeComboResponses(
159
+ req: Request,
160
+ rawBody: unknown,
161
+ comboId: string,
162
+ config: OcxConfig,
163
+ logCtx: RequestLogContext,
164
+ options: HandleResponsesOptions & { translatorBudget: TranslatorBudget },
165
+ requestDispatchers: ResponsesDispatchers,
166
+ ): Promise<Response> {
167
+ const requestedModel = typeof (rawBody as { model?: unknown } | null)?.model === "string"
168
+ ? (rawBody as { model: string }).model
169
+ : `combo/${comboId}`;
170
+ Object.assign(logCtx, {
171
+ requestedModel,
172
+ model: requestedModel,
173
+ provider: "combo",
174
+ comboId,
175
+ });
176
+ const combo = getCombo(config, comboId);
177
+ if (!combo) {
178
+ return formatErrorResponse(404, "invalid_request_error", `Unknown combo: ${comboId}`);
179
+ }
180
+ // The ladder's own scope, derived from what this combo DECLARES. It shares the request-wide
181
+ // counter with the holder that arrived on options -- a combo child already inherited that
182
+ // counter, but nothing read it as a limit across targets -- while its transition and
183
+ // alternate-target ledgers come from the target list rather than from the single-target
184
+ // account-move profile (#4546).
185
+ const comboSendScope = isRequestExecutionBudget(options.sendBudget)
186
+ ? deriveSendBudgetScope(options.sendBudget, comboExecutionBudgetPolicy(combo.targets.length))
187
+ : undefined;
188
+ // Expand previous_response_id before image policy and child dispatch so a
189
+ // continuation that only references prior images still fails closed when
190
+ // imageInput is disabled (and so targets see the full replayed input).
191
+ const inboundClientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined;
192
+ const body = expandPreviousResponseInput(rawBody, inboundClientThreadId);
193
+ const scopeMismatch = previousResponseScopeMismatch(body);
194
+ if (scopeMismatch) {
195
+ console.warn("[opencodex] dropped a previous_response_id with a mismatched client task scope; continuing fresh");
196
+ }
197
+ if (previousResponseReplayFailure(body)) {
198
+ return formatErrorResponse(
199
+ 400,
200
+ "previous_response_not_found",
201
+ "Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.",
202
+ );
203
+ }
204
+ // Missing state returns the original body without a failure marker. Reject
205
+ // that unresolved continuation for image-disabled combos so a target cannot
206
+ // resolve prior images out of band. A successful expansion yields a new
207
+ // object (still carrying previous_response_id) and must not be treated as
208
+ // unresolved — text-only stored continuations remain allowed.
209
+ const requestedPreviousId = typeof (rawBody as { previous_response_id?: unknown } | null)?.previous_response_id === "string"
210
+ ? (rawBody as { previous_response_id: string }).previous_response_id.trim()
211
+ : "";
212
+ const unresolvedPrevious = requestedPreviousId.length > 0 && body === rawBody;
213
+ if (combo.imageInput === "disabled" && unresolvedPrevious) {
214
+ return formatErrorResponse(
215
+ 400,
216
+ "previous_response_not_found",
217
+ "Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.",
218
+ );
219
+ }
220
+ if (combo.imageInput === "disabled" && comboRequestHasImageInput(body)) {
221
+ return formatErrorResponse(400, "invalid_request_error", `Combo "${comboId}" does not accept image input`);
222
+ }
223
+ const comboReplaySnapshot = {
224
+ sourceBody: body,
225
+ previousResponseInputExpanded: body !== rawBody
226
+ && typeof (body as { previous_response_id?: unknown }).previous_response_id === "string",
227
+ providerContinuation: !scopeMismatch && body !== rawBody && requestedPreviousId
228
+ ? previousResponseProviderState(requestedPreviousId)
229
+ : undefined,
230
+ recoveredPlaintext: false,
231
+ };
232
+ const adoptFailedChildLog = (childLog: RequestLogContext): void => {
233
+ // Attempts remain the complete physical history; the logical row mirrors the most recent
234
+ // failed target so an exhausted combo still has useful top-level reasoning diagnostics.
235
+ Object.assign(logCtx, childLog, {
236
+ requestedModel,
237
+ model: requestedModel,
238
+ provider: "combo",
239
+ comboId,
240
+ routeDecision: logCtx.routeDecision,
241
+ attempts: logCtx.attempts,
242
+ activeAttempt: undefined,
243
+ activeAttemptStartedAt: undefined,
244
+ });
245
+ };
246
+
247
+ const unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
248
+ (body as { input?: unknown } | undefined)?.input,
249
+ );
250
+ const canDecryptUnreadableAgentTask = (target: (typeof combo.targets)[number]): boolean => {
251
+ const provider = config.providers[target.provider];
252
+ if (!provider || provider.disabled === true) return false;
253
+ try {
254
+ const route = routeConcreteModel(config, `${target.provider}/${target.model}`);
255
+ return isCanonicalOpenAiForwardProvider(route.provider);
256
+ } catch {
257
+ return false;
258
+ }
259
+ };
260
+ let comboPayloadReadable = false;
261
+ const payloadEligible = (target: (typeof combo.targets)[number]): boolean =>
262
+ comboPayloadReadable || !unreadableEncryptedAgentTask || canDecryptUnreadableAgentTask(target);
263
+ let encryptedTaskRecoveryAttempted = false;
264
+ let recoveryFailureReason: AgentTaskRecoveryFailureReason | undefined;
265
+ let storedPool401ReplayDispatched = false;
266
+ const recoverUnreadableEncryptedTask = async (): Promise<boolean> => {
267
+ if (encryptedTaskRecoveryAttempted) return false;
268
+ encryptedTaskRecoveryAttempted = true;
269
+ const recovery = agentTaskRecoveryConfig(config);
270
+ if (
271
+ (options.inboundWire ?? "responses") !== "responses"
272
+ || !isThreadSpawnRequest(req.headers)
273
+ || !recovery
274
+ || options.comboAttempt
275
+ ) {
276
+ discardEncryptedAgentTaskRecovery(
277
+ req,
278
+ (body as { input?: unknown } | undefined)?.input,
279
+ config,
280
+ { parentThreadId: inboundClientThreadId },
281
+ );
282
+ return false;
283
+ }
284
+ let recovered = false;
285
+ try {
286
+ const result = await recoverEncryptedAgentTaskWithResult(
287
+ req,
288
+ (body as { input?: unknown } | undefined)?.input,
289
+ recovery,
290
+ config,
291
+ { parentThreadId: inboundClientThreadId, abortSignal: options.abortSignal },
292
+ );
293
+ recovered = result.recovered;
294
+ recoveryFailureReason = result.recovered ? undefined : result.reason;
295
+ } catch {
296
+ recovered = false;
297
+ recoveryFailureReason = undefined;
298
+ }
299
+ // Recovery has the same in-place input mutation contract as the direct routed path.
300
+ if (
301
+ !recovered
302
+ || hasUnreadableEncryptedAgentTask((body as { input?: unknown } | undefined)?.input)
303
+ ) {
304
+ discardEncryptedAgentTaskRecovery(
305
+ req,
306
+ (body as { input?: unknown } | undefined)?.input,
307
+ config,
308
+ { parentThreadId: inboundClientThreadId },
309
+ );
310
+ return false;
311
+ }
312
+ comboPayloadReadable = true;
313
+ comboReplaySnapshot.recoveredPlaintext = true;
314
+ return true;
315
+ };
316
+ const initialNow = Date.now();
317
+ const pickWithWait = (pickOptions: {
318
+ exclude?: Iterable<string>;
319
+ eligible?: (target: NonNullable<typeof combo>["targets"][number]) => boolean;
320
+ now?: number;
321
+ }) => pickComboTargetWithWait(config, comboId, {
322
+ ...pickOptions,
323
+ waitForCooldownMs: combo.waitForCooldownMs,
324
+ abortSignal: options.abortSignal,
325
+ });
326
+ let pick = await pickWithWait({
327
+ eligible: payloadEligible,
328
+ now: initialNow,
329
+ });
330
+
331
+ if (unreadableEncryptedAgentTask && !pick) {
332
+ pick = await pickWithWait({ now: initialNow });
333
+ if (!pick) {
334
+ discardEncryptedAgentTaskRecovery(
335
+ req,
336
+ (body as { input?: unknown } | undefined)?.input,
337
+ config,
338
+ { parentThreadId: inboundClientThreadId },
339
+ );
340
+ return options.abortSignal?.aborted
341
+ ? clientCancelledResponse()
342
+ : comboUnavailable(comboId);
343
+ }
344
+ if (!(await recoverUnreadableEncryptedTask())) {
345
+ return options.abortSignal?.aborted
346
+ ? clientCancelledResponse()
347
+ : unreadableEncryptedAgentTaskResponse(recoveryFailureReason);
348
+ }
349
+ }
350
+
351
+ if (!pick) {
352
+ return options.abortSignal?.aborted
353
+ ? clientCancelledResponse()
354
+ : comboUnavailable(comboId);
355
+ }
356
+ // One immutable combo selection trace, before any child dispatch; child
357
+ // adoption below must never replace it with a concrete child route trace.
358
+ logCtx.routeDecision = comboRouteDecisionTrace(config, comboId, pick, requestedModel);
359
+
360
+ let lastFailure: Response | null = null;
361
+ // Dispatched targets, not attempted picks: it indexes the declared target list so the clamp
362
+ // below can tell how many targets are still entitled to a send.
363
+ let comboTargetsDispatched = 0;
364
+ // The child log behind `lastFailure`. The natural end of the ladder adopts it inside the
365
+ // no-more-targets branch; a budget refusal ends the ladder one iteration later, where that
366
+ // iteration's own `childLog` is already out of scope.
367
+ let lastFailedChildLog: RequestLogContext | undefined;
368
+ // The exhausted-combo mapping below runs outside the loop, where `failure.upstreamCode`
369
+ // is gone, so carry the loop's own classification decision instead of re-deriving a
370
+ // weaker one from the status alone (#4149).
371
+ let lastFailureClassifiesOverflow = false;
372
+ while (pick) {
373
+ if (options.abortSignal?.aborted) return clientCancelledResponse();
374
+ const firstComboTarget = comboTargetsDispatched === 0;
375
+ // The first target seeds the ledger's target identity and charges nothing; every later one
376
+ // is a real transition, refused once the declared hops, the alternate-target ledger or the
377
+ // request total are spent. `countedExternally` is required: the child charges its own
378
+ // physical sends, and charging here as well would halve the cap without saying so.
379
+ const hopDecision = comboSendScope?.reserveDispatch({
380
+ sendClass: firstComboTarget ? "initial" : "combo-failover",
381
+ targetKey: `${pick.target.provider}/${pick.target.model}`,
382
+ countedExternally: true,
383
+ });
384
+ if (hopDecision && hopDecision.allowed) hopDecision.permit.use();
385
+ else if (hopDecision && !firstComboTarget) {
386
+ // Out of budget is not this target's failure. The established exhaustion contract is to
387
+ // return the last real upstream answer with its status, headers and any quota body
388
+ // intact rather than to mint a synthetic error, and a later target only exists because
389
+ // an earlier one already recorded one.
390
+ if (lastFailedChildLog) adoptFailedChildLog(lastFailedChildLog);
391
+ break;
392
+ }
393
+ const targetSendBudget = comboSendScope
394
+ ? comboTargetSendBudget(comboSendScope, combo.targets.length - 1 - comboTargetsDispatched)
395
+ : options.sendBudget;
396
+ comboTargetsDispatched += 1;
397
+ const childLog: RequestLogContext = {
398
+ model: pick.target.model,
399
+ provider: pick.target.provider,
400
+ ...(logCtx.conversationId ? { conversationId: logCtx.conversationId } : {}),
401
+ ...(logCtx.surface ? { surface: logCtx.surface } : {}),
402
+ };
403
+ const targetRoute = routeConcreteModel(config, `${pick.target.provider}/${pick.target.model}`);
404
+ const childBody = concreteComboRequestBody(
405
+ body,
406
+ pick.target,
407
+ comboDefaultEffort(config, comboId),
408
+ supportedLadderFor({ provider: targetRoute.provider, modelId: targetRoute.modelId }),
409
+ combo.reasoningEffortMode,
410
+ );
411
+ const childHeaders = buildComboChildHeaders(req.headers);
412
+ const childRequest = new Request(req.url, {
413
+ method: req.method,
414
+ headers: childHeaders,
415
+ body: JSON.stringify(childBody),
416
+ });
417
+ linkRequestSessionLane(req, childRequest);
418
+ let resolvedAuth: CodexAuthContext | undefined;
419
+ let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined;
420
+ const started = Date.now();
421
+ const attempt = beginRequestAttempt(
422
+ (logCtx.attempts?.length ?? 0) + 1,
423
+ pick.target.provider,
424
+ pick.target.model,
425
+ config.providers[pick.target.provider]!.adapter,
426
+ );
427
+ childLog.activeAttempt = attempt;
428
+ let attemptRetained = false;
429
+ const retainCancelledAttempt = (): void => {
430
+ if (attemptRetained) return;
431
+ sealRequestAttemptIdentity(
432
+ attempt,
433
+ childLog.provider,
434
+ childLog.providerAdapter ?? attempt.adapter,
435
+ childLog.accountLogLabel,
436
+ );
437
+ finishRequestAttempt(attempt, 499, Date.now() - started, childLog.usage);
438
+ (logCtx.attempts ??= []).push(attempt);
439
+ attemptRetained = true;
440
+ };
441
+ const completedTarget = { provider: pick.target.provider, model: pick.target.model };
442
+ const writerGeneration = pick.writerGeneration;
443
+ let consumedChildFailure: ConsumedComboFailure | undefined;
444
+ const callbackGate = createChildPassthroughCallbackGate({
445
+ ...options,
446
+ onResponseComplete: model => {
447
+ // The live config can change while the child is streaming. Never retain credentials.
448
+ const currentCombo = getCombo(config, comboId);
449
+ const provider = config.providers[completedTarget.provider];
450
+ if (Object.hasOwn(config.providers, completedTarget.provider)
451
+ && provider && provider.disabled !== true
452
+ && currentCombo?.targets.some(target => targetKey(target) === targetKey(completedTarget))) {
453
+ rememberComboForLane(sessionLaneIdFromRequest(req.headers), comboId, completedTarget, model, writerGeneration);
454
+ }
455
+ options.onResponseComplete?.(model);
456
+ },
457
+ onNativePassthroughTerminal: status => {
458
+ // A committed stream can acquire terminal metadata after preflight copied
459
+ // the child log. Publish it before the outer logger finalizes, but only
460
+ // through the gate: discarded attempts must never affect the parent.
461
+ // Undefined child fields must preserve metadata already inspected by WS.
462
+ if (childLog.terminalHttpStatus !== undefined) logCtx.terminalHttpStatus = childLog.terminalHttpStatus;
463
+ if (childLog.terminalIncompleteReason !== undefined) logCtx.terminalIncompleteReason = childLog.terminalIncompleteReason;
464
+ if (childLog.terminalErrorCode !== undefined) logCtx.terminalErrorCode = childLog.terminalErrorCode;
465
+ if (childLog.upstreamError !== undefined) logCtx.upstreamError = childLog.upstreamError;
466
+ options.onNativePassthroughTerminal?.(status);
467
+ },
468
+ });
469
+ let response: Response;
470
+ try {
471
+ const currentTargetProvider = pick.target.provider;
472
+ const deferCodexResetDerivedCooldown = combo.strategy === "failover"
473
+ && combo.targets.slice(pick.targetIndex + 1).some(target =>
474
+ target.provider === currentTargetProvider
475
+ && payloadEligible(target)
476
+ && !isComboTargetInCooldown(comboId, target),
477
+ );
478
+ response = await requestDispatchers.handleResponses(childRequest, config, childLog, {
479
+ ...options,
480
+ // After the spread: the child must run on THIS target's ladder, not on the holder the
481
+ // parent arrived with.
482
+ sendBudget: targetSendBudget,
483
+ comboAttempt: true,
484
+ comboReplaySnapshot,
485
+ deferCodexResetDerivedCooldown,
486
+ // Attempt-relative TTFT is recorded HERE (not via childLog.firstOutputMs — a later
487
+ // Object.assign(logCtx, childLog) would overwrite the request-relative value).
488
+ onFirstOutput: () => {
489
+ if (attempt.firstOutputMs === undefined) {
490
+ attempt.firstOutputMs = Math.max(0, Date.now() - started);
491
+ }
492
+ options.onFirstOutput?.();
493
+ },
494
+ onCodexAuthContextResolved: value => { resolvedAuth = value; },
495
+ setTerminalOutcomeRecorder: value => { terminalRecorder = value; },
496
+ onConsumedComboFailure: value => { consumedChildFailure = value; },
497
+ onStoredPool401ReplayDispatched: () => { storedPool401ReplayDispatched = true; },
498
+ onNativePassthroughTerminal: callbackGate.onTerminal,
499
+ onNativePassthroughCancel: callbackGate.onCancel,
500
+ onResponseComplete: callbackGate.onResponseComplete,
501
+ });
502
+ } catch (error) {
503
+ callbackGate.discard();
504
+ if (options.abortSignal?.aborted) {
505
+ retainCancelledAttempt();
506
+ return clientCancelledResponse();
507
+ }
508
+ throw error;
509
+ }
510
+
511
+ if (options.abortSignal?.aborted) {
512
+ callbackGate.discard();
513
+ retainCancelledAttempt();
514
+ return clientCancelledResponse();
515
+ }
516
+
517
+ if (response.ok && !runTurnAdapterSseResponses.has(response)) {
518
+ const nativePassthrough = isNativePassthroughSseResponse(response);
519
+ const eagerRelay = isEagerRelaySseResponse(response);
520
+ let preflight;
521
+ try {
522
+ preflight = await preflightComboStreamResponse(response, childLog);
523
+ } catch (error) {
524
+ callbackGate.discard();
525
+ if (options.abortSignal?.aborted) {
526
+ retainCancelledAttempt();
527
+ return clientCancelledResponse();
528
+ }
529
+ throw error;
530
+ }
531
+ if (preflight.kind === "failed") {
532
+ callbackGate.discard();
533
+ terminalRecorder?.("failed", preflight.response.status);
534
+ response = preflight.response;
535
+ } else {
536
+ response = preflight.response;
537
+ if (nativePassthrough) markNativePassthroughSseResponse(response);
538
+ if (eagerRelay) markEagerRelaySseResponse(response);
539
+ }
540
+ }
541
+
542
+ if (response.ok) {
543
+ sealRequestAttemptIdentity(
544
+ attempt,
545
+ childLog.provider,
546
+ childLog.providerAdapter ?? attempt.adapter,
547
+ childLog.accountLogLabel,
548
+ );
549
+ (logCtx.attempts ??= []).push(attempt);
550
+ attemptRetained = true;
551
+ noteComboSuccess(comboId, combo, pick.target, pick.writerGeneration);
552
+ Object.assign(logCtx, childLog, {
553
+ requestedModel,
554
+ model: requestedModel,
555
+ provider: "combo",
556
+ comboId,
557
+ routeDecision: logCtx.routeDecision,
558
+ attempts: logCtx.attempts,
559
+ activeAttempt: attempt,
560
+ activeAttemptStartedAt: started,
561
+ resolvedModel: childLog.resolvedModel ?? childLog.model,
562
+ });
563
+ options.onCodexAuthContextResolved?.(resolvedAuth);
564
+ options.setTerminalOutcomeRecorder?.(terminalRecorder);
565
+ callbackGate.commit();
566
+ return response;
567
+ }
568
+
569
+ callbackGate.discard();
570
+ if (response.status === 499) {
571
+ retainCancelledAttempt();
572
+ return clientCancelledResponse();
573
+ }
574
+ let failure: ConsumedComboFailure;
575
+ try {
576
+ failure = consumedChildFailure
577
+ ?? await consumeComboFailure(response, options.abortSignal);
578
+ } catch (error) {
579
+ if (options.abortSignal?.aborted) {
580
+ retainCancelledAttempt();
581
+ return clientCancelledResponse();
582
+ }
583
+ throw error;
584
+ }
585
+ if (options.abortSignal?.aborted) {
586
+ retainCancelledAttempt();
587
+ return clientCancelledResponse();
588
+ }
589
+ sealRequestAttemptIdentity(
590
+ attempt,
591
+ childLog.provider,
592
+ childLog.providerAdapter ?? attempt.adapter,
593
+ childLog.accountLogLabel,
594
+ );
595
+ finishRequestAttempt(
596
+ attempt,
597
+ failure.response.status,
598
+ Date.now() - started,
599
+ failure.usage,
600
+ );
601
+ (logCtx.attempts ??= []).push(attempt);
602
+ attemptRetained = true;
603
+ lastFailure = failure.response;
604
+ lastFailedChildLog = childLog;
605
+ const failureDecision = comboFailureDecision(failure.response.status, failure.classificationText, {
606
+ code: failure.upstreamCode,
607
+ });
608
+ const wantsStream = (rawBody as { stream?: unknown } | null)?.stream === true;
609
+ // Local byte admission has its own diagnostic; do not relabel it as an upstream refusal.
610
+ const classifyOverflow = failure.response.status === 413
611
+ && (wantsStream || (failure.upstreamCode !== "outbound_body_too_large"
612
+ && failure.upstreamCode !== "translation_buffer_limit"));
613
+ lastFailureClassifiesOverflow = classifyOverflow;
614
+ if (storedPool401ReplayDispatched) {
615
+ if (failureDecision === "hop" && unreadableEncryptedAgentTask && !comboPayloadReadable) {
616
+ const recoveredTarget = await pickWithWait({
617
+ exclude: pick.attempted,
618
+ eligible: target => {
619
+ try {
620
+ const route = routeConcreteModel(config, `${target.provider}/${target.model}`);
621
+ return route.codexAccountMode === undefined
622
+ && !isCanonicalOpenAiForwardProvider(route.provider);
623
+ } catch {
624
+ return false;
625
+ }
626
+ },
627
+ });
628
+ if (options.abortSignal?.aborted) return clientCancelledResponse();
629
+ if (recoveredTarget && await recoverUnreadableEncryptedTask()) {
630
+ pick = recoveredTarget;
631
+ continue;
632
+ }
633
+ if (options.abortSignal?.aborted) return clientCancelledResponse();
634
+ }
635
+ // Keep the spent Pool budget sticky even after a recovered routed child:
636
+ // no later failure may reopen ordinary combo/native account hopping.
637
+ adoptFailedChildLog(childLog);
638
+ if (classifyOverflow && failureDecision === "stop") {
639
+ return wantsStream
640
+ ? streamingContextOverflowResponse(requestedModel, options.translatorBudget)
641
+ : jsonContextOverflowResponse();
642
+ }
643
+ return lastFailure;
644
+ }
645
+ if (failureDecision === "stop") {
646
+ adoptFailedChildLog(childLog);
647
+ if (classifyOverflow) {
648
+ return wantsStream
649
+ ? streamingContextOverflowResponse(requestedModel, options.translatorBudget)
650
+ : jsonContextOverflowResponse();
651
+ }
652
+ return lastFailure;
653
+ }
654
+ console.warn(
655
+ `[combo] ${comboId}: ${targetKey(pick.target)} failed with ${failure.response.status} after ${Date.now() - started}ms`,
656
+ );
657
+ const failureNow = Date.now();
658
+ const attemptedTargets = pick.attempted;
659
+ const nextPick = advanceComboAfterFailure(config, pick, {
660
+ retryAfter: failure.retryAfter,
661
+ resetAt: failure.resetAt,
662
+ cooldownMs: combo.cooldownMs,
663
+ now: failureNow,
664
+ cooldownScope: comboFailureCooldownScope(failure.response.status, failure.classificationText, {
665
+ code: failure.upstreamCode,
666
+ }),
667
+ eligible: payloadEligible,
668
+ status: failure.response.status,
669
+ code: failure.upstreamCode,
670
+ message: failure.classificationText,
671
+ });
672
+ if (nextPick) {
673
+ pick = nextPick;
674
+ } else {
675
+ pick = await pickWithWait({
676
+ exclude: pick.attempted,
677
+ eligible: payloadEligible,
678
+ now: failureNow,
679
+ });
680
+ }
681
+ if (!pick) {
682
+ if (options.abortSignal?.aborted) return clientCancelledResponse();
683
+ if (unreadableEncryptedAgentTask && !comboPayloadReadable) {
684
+ const recoveredTarget = await pickWithWait({
685
+ exclude: attemptedTargets,
686
+ now: failureNow,
687
+ });
688
+ if (recoveredTarget && await recoverUnreadableEncryptedTask()) {
689
+ pick = recoveredTarget;
690
+ continue;
691
+ }
692
+ }
693
+ // Waiting or recovery may have observed cancellation after the check above.
694
+ if (options.abortSignal?.aborted) return clientCancelledResponse();
695
+ adoptFailedChildLog(childLog);
696
+ }
697
+ }
698
+ if (
699
+ lastFailure?.status === 413
700
+ && lastFailureClassifiesOverflow
701
+ ) {
702
+ return (rawBody as { stream?: unknown } | null)?.stream === true
703
+ ? streamingContextOverflowResponse(requestedModel, options.translatorBudget)
704
+ : jsonContextOverflowResponse();
705
+ }
706
+ return lastFailure!;
707
+ }