@bitkyc08/opencodex 2.55.0 → 2.57.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 (256) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  3. package/gui/dist/assets/{index-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +4 -3
  6. package/src/adapters/base.ts +21 -0
  7. package/src/adapters/codebuddy/adapter.ts +2 -1
  8. package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
  9. package/src/adapters/command-code.ts +1 -1
  10. package/src/adapters/cursor/envelope-echo.ts +8 -2
  11. package/src/adapters/cursor/transport-retry.ts +46 -1
  12. package/src/adapters/cursor.ts +4 -0
  13. package/src/adapters/google.ts +7 -7
  14. package/src/adapters/kiro/adapter.ts +42 -1
  15. package/src/adapters/kiro/payload.ts +17 -3
  16. package/src/adapters/kiro/reasoning.ts +70 -7
  17. package/src/adapters/kiro/stream.ts +8 -2
  18. package/src/adapters/kiro/wire.ts +2 -1
  19. package/src/adapters/kiro-events.ts +21 -13
  20. package/src/adapters/kiro-retry.ts +23 -4
  21. package/src/adapters/openai-chat/errors.ts +116 -0
  22. package/src/adapters/openai-chat/messages.ts +346 -0
  23. package/src/adapters/openai-chat/passthrough.ts +146 -0
  24. package/src/adapters/openai-chat/response-events.ts +117 -0
  25. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  26. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  27. package/src/adapters/openai-chat/tool-schema.ts +495 -0
  28. package/src/adapters/openai-chat/wire.ts +50 -0
  29. package/src/adapters/openai-chat.ts +40 -1452
  30. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  31. package/src/adapters/openai-responses/image-gen.ts +406 -0
  32. package/src/adapters/openai-responses/internal.ts +3 -0
  33. package/src/adapters/openai-responses/passthrough.ts +642 -0
  34. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  35. package/src/adapters/openai-responses/reasoning.ts +220 -0
  36. package/src/adapters/openai-responses/request-strips.ts +185 -0
  37. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  38. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  39. package/src/adapters/openai-responses/web-search.ts +156 -0
  40. package/src/adapters/openai-responses.ts +4 -2625
  41. package/src/bridge/errors.ts +58 -0
  42. package/src/bridge/internal.ts +174 -0
  43. package/src/bridge/response-json.ts +630 -0
  44. package/src/bridge/sse.ts +1462 -0
  45. package/src/bridge.ts +5 -2204
  46. package/src/chat/inbound.ts +12 -1
  47. package/src/claude/desktop-profile.ts +66 -9
  48. package/src/claude/outbound.ts +18 -0
  49. package/src/cli/account-main.ts +1 -1
  50. package/src/cli/capabilities.ts +2 -2
  51. package/src/cli/combo.ts +10 -1
  52. package/src/cli/index.ts +48 -5
  53. package/src/cli/registry.ts +2 -1
  54. package/src/cli/system-command.ts +4 -4
  55. package/src/clients/config-export.ts +7 -3
  56. package/src/codex/account-label.ts +14 -3
  57. package/src/codex/account-lifecycle.ts +3 -0
  58. package/src/codex/account-store.ts +184 -35
  59. package/src/codex/account-usability.ts +21 -0
  60. package/src/codex/auth-api/account-list.ts +507 -0
  61. package/src/codex/auth-api/http.ts +32 -0
  62. package/src/codex/auth-api/login-flow.ts +566 -0
  63. package/src/codex/auth-api/login-state.ts +64 -0
  64. package/src/codex/auth-api/main-account-probe.ts +331 -0
  65. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  66. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  67. package/src/codex/auth-api/reset-credit-service.ts +431 -0
  68. package/src/codex/auth-api/routes.ts +425 -0
  69. package/src/codex/auth-api/runtime-config.ts +48 -0
  70. package/src/codex/auth-api.ts +27 -3118
  71. package/src/codex/auth-context.ts +252 -35
  72. package/src/codex/catalog/aggregation.ts +80 -1
  73. package/src/codex/catalog/auto-review.ts +507 -0
  74. package/src/codex/catalog/build-entries.ts +981 -0
  75. package/src/codex/catalog/combo-member.ts +375 -0
  76. package/src/codex/catalog/derive-entry.ts +229 -0
  77. package/src/codex/catalog/effort.ts +0 -1
  78. package/src/codex/catalog/gated-native-warn.ts +63 -0
  79. package/src/codex/catalog/gather-capture.ts +533 -0
  80. package/src/codex/catalog/model-hints.ts +691 -0
  81. package/src/codex/catalog/model-visibility.ts +305 -0
  82. package/src/codex/catalog/provider-fetch.ts +52 -2942
  83. package/src/codex/catalog/provider-models.ts +685 -0
  84. package/src/codex/catalog/remote.ts +30 -0
  85. package/src/codex/catalog/restore.ts +132 -0
  86. package/src/codex/catalog/retained-sync.ts +714 -0
  87. package/src/codex/catalog/routed-gather.ts +895 -0
  88. package/src/codex/catalog/subagent-roster.ts +176 -0
  89. package/src/codex/catalog/sync.ts +52 -2698
  90. package/src/codex/cli-install-provenance.ts +7 -1
  91. package/src/codex/convergence.ts +7 -2
  92. package/src/codex/desktop-app/types.ts +11 -2
  93. package/src/codex/desktop-app/windows.ts +5 -5
  94. package/src/codex/inject/config-toml.ts +563 -0
  95. package/src/codex/inject/remove.ts +192 -0
  96. package/src/codex/inject/restore.ts +567 -0
  97. package/src/codex/inject/routing-classify.ts +109 -0
  98. package/src/codex/inject/routing-target.ts +125 -0
  99. package/src/codex/inject.ts +89 -1444
  100. package/src/codex/lineage.ts +458 -0
  101. package/src/codex/model-entitlements.ts +152 -15
  102. package/src/codex/pool-refresh-backoff.ts +161 -0
  103. package/src/codex/quota-rejection.ts +104 -15
  104. package/src/codex/routing/active-account.ts +194 -0
  105. package/src/codex/routing/cache-affinity.ts +70 -0
  106. package/src/codex/routing/cooldown-math.ts +285 -0
  107. package/src/codex/routing/health-store.ts +402 -0
  108. package/src/codex/routing/probe-lease.ts +358 -0
  109. package/src/codex/routing/selection.ts +780 -0
  110. package/src/codex/routing/thread-affinity.ts +586 -0
  111. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  112. package/src/codex/routing.ts +370 -2271
  113. package/src/codex/shim-fingerprint.ts +223 -0
  114. package/src/codex/shim-inspect.ts +175 -0
  115. package/src/codex/shim-probe.ts +367 -0
  116. package/src/codex/shim-restore-lock.ts +169 -0
  117. package/src/codex/shim-state-file.ts +151 -0
  118. package/src/codex/shim-templates.ts +265 -0
  119. package/src/codex/shim.ts +48 -1268
  120. package/src/codex/warmup.ts +1 -1
  121. package/src/combos/failover.ts +85 -0
  122. package/src/combos/request.ts +17 -10
  123. package/src/combos/types.ts +23 -2
  124. package/src/config/diagnostics.ts +705 -0
  125. package/src/config/feature-flags.ts +55 -0
  126. package/src/config/live-reconcile.ts +403 -0
  127. package/src/config/load-degrade.ts +880 -0
  128. package/src/config/mutation-lock.ts +244 -0
  129. package/src/config/openai-tier-backup.ts +268 -0
  130. package/src/config/pending-teardown.ts +31 -0
  131. package/src/config/persist-unlocked.ts +92 -0
  132. package/src/config/proxy-env.ts +188 -0
  133. package/src/config/salvage.ts +244 -0
  134. package/src/config/schema/config-schema.ts +640 -0
  135. package/src/config/schema/leaf-validators.ts +855 -0
  136. package/src/config/warn-memo.ts +28 -0
  137. package/src/config.ts +234 -4481
  138. package/src/generated/compatibility-version.json +649 -121
  139. package/src/images/loop.ts +1 -1
  140. package/src/lib/errors.ts +17 -0
  141. package/src/lib/request-execution-budget.ts +198 -23
  142. package/src/lib/spend-reservation-ledger.ts +958 -0
  143. package/src/lib/state-store-registrations.ts +6 -2
  144. package/src/lib/test-home-guard.ts +85 -1
  145. package/src/lib/upstream-retry.ts +132 -21
  146. package/src/lib/windows-elevation.ts +76 -14
  147. package/src/lib/workflow-budget.ts +553 -30
  148. package/src/oauth/index.ts +2 -2
  149. package/src/oauth/key-providers.ts +2 -2
  150. package/src/providers/kiro-models.ts +4 -3
  151. package/src/providers/label.ts +19 -1
  152. package/src/providers/model-discovery.ts +16 -0
  153. package/src/providers/quota/account-cache.ts +441 -0
  154. package/src/providers/quota/antigravity.ts +295 -0
  155. package/src/providers/quota/report-cache.ts +320 -0
  156. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  157. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  158. package/src/providers/quota.ts +324 -3079
  159. package/src/providers/registry/entries-core.ts +1228 -0
  160. package/src/providers/registry/entries-extended.ts +1213 -0
  161. package/src/providers/registry/model-seeds.ts +912 -0
  162. package/src/providers/registry/types.ts +352 -0
  163. package/src/providers/registry.ts +24 -3536
  164. package/src/responses/continuation-ownership.ts +29 -0
  165. package/src/responses/reasoning-envelope.ts +6 -3
  166. package/src/responses/state/replay-fingerprint.ts +80 -0
  167. package/src/responses/state/snapshot-codec.ts +104 -0
  168. package/src/responses/state/spill-failure.ts +118 -0
  169. package/src/responses/state/spill-queue.ts +665 -0
  170. package/src/responses/state/temp-recovery.ts +257 -0
  171. package/src/responses/state.ts +82 -1143
  172. package/src/routing/identity-domains.ts +456 -0
  173. package/src/routing/probe-lease.ts +613 -0
  174. package/src/server/chat-completions.ts +3 -1
  175. package/src/server/chat-native.ts +37 -9
  176. package/src/server/index/bounded-request.ts +88 -0
  177. package/src/server/index/live-sideband.ts +601 -0
  178. package/src/server/index/serve-options.ts +1766 -0
  179. package/src/server/index/startup-warnings.ts +213 -0
  180. package/src/server/index/websocket-handler.ts +339 -0
  181. package/src/server/index.ts +45 -2552
  182. package/src/server/inspection-tee.ts +107 -0
  183. package/src/server/live.ts +46 -1
  184. package/src/server/management/combo-routes.ts +10 -1
  185. package/src/server/management/route-registry.ts +26 -23
  186. package/src/server/management/shared.ts +8 -5
  187. package/src/server/management/workflow-budget-routes.ts +133 -0
  188. package/src/server/management-api.ts +12 -0
  189. package/src/server/relay-eager.ts +2 -0
  190. package/src/server/relay.ts +14 -19
  191. package/src/server/request-log-conversation.ts +9 -7
  192. package/src/server/request-log.ts +372 -4
  193. package/src/server/response-log-body.ts +153 -0
  194. package/src/server/responses/account-change-state.ts +307 -0
  195. package/src/server/responses/adapter-continuation.ts +540 -0
  196. package/src/server/responses/adapter-delivery.ts +208 -0
  197. package/src/server/responses/adapter-dispatch.ts +1042 -0
  198. package/src/server/responses/codex-ws-wire.ts +5 -0
  199. package/src/server/responses/collaboration.ts +74 -4
  200. package/src/server/responses/combo-session-recall.ts +68 -8
  201. package/src/server/responses/compact.ts +113 -17
  202. package/src/server/responses/completion-policy.ts +33 -0
  203. package/src/server/responses/core-auth.ts +529 -0
  204. package/src/server/responses/core-codex-account.ts +907 -0
  205. package/src/server/responses/core-combo-failure.ts +210 -0
  206. package/src/server/responses/core-combo.ts +787 -0
  207. package/src/server/responses/core-errors.ts +170 -0
  208. package/src/server/responses/core-lifetime.ts +95 -0
  209. package/src/server/responses/core-normalize.ts +350 -0
  210. package/src/server/responses/core-opaque-recovery.ts +380 -0
  211. package/src/server/responses/core-options.ts +159 -0
  212. package/src/server/responses/core-replay.ts +298 -0
  213. package/src/server/responses/core.ts +192 -8893
  214. package/src/server/responses/encrypted-payload.ts +0 -1
  215. package/src/server/responses/input-admission.ts +126 -6
  216. package/src/server/responses/passthrough-delivery.ts +869 -0
  217. package/src/server/responses/passthrough-dispatch.ts +1494 -0
  218. package/src/server/responses/passthrough-error.ts +38 -2
  219. package/src/server/responses/passthrough-execution.ts +54 -0
  220. package/src/server/responses/request-prepare.ts +1080 -0
  221. package/src/server/responses/request-send-budget.ts +259 -0
  222. package/src/server/responses/request-sidecar-auth.ts +149 -0
  223. package/src/server/responses/request-spend.ts +147 -0
  224. package/src/server/responses/request-transport.ts +803 -0
  225. package/src/server/responses/response-effects.ts +157 -0
  226. package/src/server/responses/run-turn-execution.ts +476 -0
  227. package/src/server/responses/sidecar-execution.ts +463 -0
  228. package/src/server/responses/terminal-guard.ts +65 -4
  229. package/src/server/responses-image-gen-repair.ts +1 -1
  230. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  231. package/src/server/workflow-refusal.ts +84 -0
  232. package/src/service/windows-ops.ts +210 -16
  233. package/src/service/windows-scheduler.ts +28 -21
  234. package/src/service.ts +1 -1
  235. package/src/types/config.ts +34 -1
  236. package/src/types/request.ts +8 -5
  237. package/src/types/tools.ts +24 -0
  238. package/src/types.ts +2 -0
  239. package/src/update/index.ts +10 -0
  240. package/src/update/stop-contract.d.mts +1 -0
  241. package/src/update/stop-contract.mjs +19 -0
  242. package/src/update/stop-decision.d.mts +1 -1
  243. package/src/update/stop-decision.mjs +12 -3
  244. package/src/usage/log.ts +147 -1
  245. package/src/usage/summary.ts +171 -21
  246. package/src/vision/anthropic-describe.ts +1 -1
  247. package/src/vision/describe.ts +5 -5
  248. package/src/web-search/anthropic-executor.ts +1 -1
  249. package/src/web-search/exa-executor.ts +1 -1
  250. package/src/web-search/executor.ts +1 -1
  251. package/src/web-search/gemini-executor.ts +1 -1
  252. package/src/web-search/loop.ts +1 -1
  253. package/src/web-search/ollama-executor.ts +1 -1
  254. package/src/web-search/parse.ts +67 -14
  255. package/src/web-search/passthrough-bridge.ts +64 -31
  256. package/src/web-search/xai-executor.ts +1 -1
@@ -0,0 +1,787 @@
1
+ import { isDeclaredReasoningEffort } from "../../reasoning-effort";
2
+ import { recordAttemptRequestedEffort } from "../request-log";
3
+ import {
4
+ CODEX_TEXT_GUARDED_BUDGET_POLICY,
5
+ deriveRequestExecutionBudget,
6
+ isRequestExecutionBudget,
7
+ } from "../../lib/request-execution-budget";
8
+ import type {
9
+ RequestExecutionBudgetPolicy,
10
+ RequestExecutionBudget,
11
+ } from "../../lib/request-execution-budget";
12
+ import type { OcxConfig } from "../../types";
13
+ import type { RequestLogContext } from "../request-log";
14
+ import type { HandleResponsesOptions, ResponsesDispatchers, ConsumedComboFailure } from "./core-options";
15
+ import type { TranslatorBudget } from "../../lib/translator-budget";
16
+ import {
17
+ getCombo,
18
+ comboRequestHasImageInput,
19
+ pickComboTargetWithWait,
20
+ targetKey,
21
+ concreteComboRequestBody,
22
+ comboDefaultEffort,
23
+ isComboTargetInCooldown,
24
+ noteComboSuccess,
25
+ comboFailureDecision,
26
+ advanceComboAfterFailure,
27
+ comboFailureCooldownScope,
28
+ } from "../../combos";
29
+ import { formatErrorResponse } from "../../bridge";
30
+ import {
31
+ expandPreviousResponseInput,
32
+ previousResponseScopeMismatch,
33
+ previousResponseReplayFailure,
34
+ previousResponseProviderState,
35
+ } from "../../responses/state";
36
+ import { hasUnreadableEncryptedAgentTask } from "./encrypted-payload";
37
+ import { routeConcreteModel, comboRouteDecisionTrace } from "../../router";
38
+ import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
39
+ import type { AgentTaskRecoveryFailureReason } from "./agent-task-recovery";
40
+ import {
41
+ agentTaskRecoveryConfig,
42
+ discardEncryptedAgentTaskRecovery,
43
+ recoverEncryptedAgentTaskWithResult,
44
+ } from "./agent-task-recovery";
45
+ import { isThreadSpawnRequest, supportedLadderFor } from "../effort-policy";
46
+ import {
47
+ clientCancelledResponse,
48
+ comboUnavailable,
49
+ targetIncompatibleResponse,
50
+ unreadableEncryptedAgentTaskResponse,
51
+ } from "./core-errors";
52
+ import {
53
+ buildComboChildHeaders,
54
+ createChildPassthroughCallbackGate,
55
+ consumeComboFailure,
56
+ } from "./core-combo-failure";
57
+ import {
58
+ linkRequestSessionLane,
59
+ reasoningReplayConversationIdFromResponsesRequest,
60
+ sessionIdHeaderFromRequest,
61
+ sessionLaneIdFromRequest,
62
+ } from "../request-log-conversation";
63
+ import type { CodexAuthContext } from "../../codex/auth-context";
64
+ import type { ResponsesTerminalStatus } from "../../bridge";
65
+ import { beginRequestAttempt, sealRequestAttemptIdentity, finishRequestAttempt } from "../request-log";
66
+ import { rememberComboForLane } from "./combo-session-recall";
67
+ import { runTurnAdapterSseResponses } from "./core-lifetime";
68
+ import {
69
+ isNativePassthroughSseResponse,
70
+ isEagerRelaySseResponse,
71
+ markNativePassthroughSseResponse,
72
+ markEagerRelaySseResponse,
73
+ } from "../relay";
74
+ import { preflightComboStreamResponse } from "./combo-stream-preflight";
75
+ import { streamingContextOverflowResponse, jsonContextOverflowResponse } from "./context-overflow";
76
+ import { mandatoryResponsesReasoningReplayUnavailable } from "./core-replay";
77
+
78
+ /**
79
+ * Sends one combo target may run on its own before the ladder moves on. A target is a whole
80
+ * request as far as its own provider is concerned, so this is the guarded profile's base
81
+ * allowance rather than a separate number to keep in sync.
82
+ */
83
+ export const COMBO_TARGET_BASE_SENDS = CODEX_TEXT_GUARDED_BUDGET_POLICY.baseSendAllowance;
84
+
85
+
86
+ /**
87
+ * A combo's execution policy is DECLARED by the combo, not inherited from the single-target
88
+ * profile.
89
+ *
90
+ * `maxTargetTransitions: 1` and `maxAlternateTargetSends: 1` describe an account move, and
91
+ * applying them to a combo would refuse the second hop of a three-target combo -- which is why
92
+ * combo was left off `reserveDispatch` when the per-request split landed. The transitions a
93
+ * combo may make are exactly the targets it declares minus the one it starts on. What stays
94
+ * capped is the TOTAL: the first target's full ladder, one send for every further declared
95
+ * target, and the one shared final-recovery reserve. A one-target combo reduces to the guarded
96
+ * profile exactly, and a three-target combo whose every target fails hard reaches upstream six
97
+ * times instead of the twelve #4546 measured.
98
+ */
99
+ export function comboExecutionBudgetPolicy(declaredTargets: number): RequestExecutionBudgetPolicy {
100
+ const targets = Math.max(1, Math.trunc(declaredTargets));
101
+ const hops = targets - 1;
102
+ const reserve = CODEX_TEXT_GUARDED_BUDGET_POLICY.finalRecoveryAllowance;
103
+ const total = COMBO_TARGET_BASE_SENDS + hops + reserve;
104
+ return {
105
+ maxTotalModelSends: total,
106
+ baseSendAllowance: total - reserve,
107
+ finalRecoveryAllowance: reserve,
108
+ maxAlternateTargetSends: Math.max(1, hops),
109
+ maxTargetTransitions: Math.max(1, hops),
110
+ };
111
+ }
112
+
113
+
114
+ /**
115
+ * A budget scope that keeps its own recovery ledgers but spends the SAME request-wide counter.
116
+ *
117
+ * The sharing has to happen inside the factory. Redefining `used` as an accessor onto the parent
118
+ * only shared what callers read from the outside: `remainingBaseSends`, the total check and the
119
+ * reserve test all consult the factory's own private counter, which an overridden property
120
+ * cannot reach. Each derived scope therefore admitted dispatches as though the request had spent
121
+ * nothing, and the per-target holdback below -- expressed against `maxTotalModelSends` -- had
122
+ * nothing to hold back from.
123
+ *
124
+ * `deriveRequestExecutionBudget` binds the scope to the parent's real ledger, including pending
125
+ * externally-counted bookings and the durable-spend observer, all of which must travel together.
126
+ * A pending booking is a send already counted in the total and waiting for its reporter, and the
127
+ * observer books by watching that same counter move (#4707) -- so a scope that spent the counter
128
+ * without carrying the observer would move it without booking, and this combo's child sends
129
+ * would go missing from the spend ledger. The reserve, alternate-target and transition ledgers
130
+ * stay per-scope on purpose: a combo target's account failover is its own recovery decision,
131
+ * while the request total still bounds every target together.
132
+ */
133
+ export function deriveSendBudgetScope(
134
+ parent: RequestExecutionBudget,
135
+ policy: RequestExecutionBudgetPolicy,
136
+ ): RequestExecutionBudget {
137
+ return deriveRequestExecutionBudget(parent, policy);
138
+ }
139
+
140
+
141
+ /**
142
+ * The ladder one combo target may run, expressed as an allowance on the request-wide counter.
143
+ *
144
+ * `used + COMBO_TARGET_BASE_SENDS` gives this target its own ladder from wherever the request
145
+ * already stands, and the clamp holds back one send for each target still declared after it: a
146
+ * first target that 5xx-streaks must not eat the send the last declared target is entitled to.
147
+ * That guarantee is the difference between a per-target policy and a shared pool the first
148
+ * target drains.
149
+ */
150
+ export function comboTargetSendBudget(
151
+ comboScope: RequestExecutionBudget,
152
+ targetsDeclaredAfterThisOne: number,
153
+ ): RequestExecutionBudget {
154
+ const policy = comboScope.policy;
155
+ const heldForLaterTargets = Math.max(0, targetsDeclaredAfterThisOne);
156
+ const ceiling = Math.max(1, policy.maxTotalModelSends - heldForLaterTargets);
157
+ return deriveSendBudgetScope(comboScope, {
158
+ maxTotalModelSends: policy.maxTotalModelSends,
159
+ baseSendAllowance: Math.min(ceiling, comboScope.used + COMBO_TARGET_BASE_SENDS),
160
+ finalRecoveryAllowance: policy.finalRecoveryAllowance,
161
+ // Within one target the account-move shape is unchanged: three same-account sends plus one
162
+ // alternate is the recovery live traffic depends on, and a combo does not widen it.
163
+ maxAlternateTargetSends: CODEX_TEXT_GUARDED_BUDGET_POLICY.maxAlternateTargetSends,
164
+ maxTargetTransitions: CODEX_TEXT_GUARDED_BUDGET_POLICY.maxTargetTransitions,
165
+ });
166
+ }
167
+
168
+
169
+ export async function executeComboResponses(
170
+ req: Request,
171
+ rawBody: unknown,
172
+ comboId: string,
173
+ config: OcxConfig,
174
+ logCtx: RequestLogContext,
175
+ options: HandleResponsesOptions & { translatorBudget: TranslatorBudget },
176
+ requestDispatchers: ResponsesDispatchers,
177
+ ): Promise<Response> {
178
+ const requestedModel = typeof (rawBody as { model?: unknown } | null)?.model === "string"
179
+ ? (rawBody as { model: string }).model
180
+ : `combo/${comboId}`;
181
+ Object.assign(logCtx, {
182
+ requestedModel,
183
+ model: requestedModel,
184
+ provider: "combo",
185
+ comboId,
186
+ });
187
+ const combo = getCombo(config, comboId);
188
+ if (!combo) {
189
+ return formatErrorResponse(404, "invalid_request_error", `Unknown combo: ${comboId}`);
190
+ }
191
+ // The ladder's own scope, derived from what this combo DECLARES. It shares the request-wide
192
+ // counter with the holder that arrived on options -- a combo child already inherited that
193
+ // counter, but nothing read it as a limit across targets -- while its transition and
194
+ // alternate-target ledgers come from the target list rather than from the single-target
195
+ // account-move profile (#4546).
196
+ const comboSendScope = isRequestExecutionBudget(options.sendBudget)
197
+ ? deriveSendBudgetScope(options.sendBudget, comboExecutionBudgetPolicy(combo.targets.length))
198
+ : undefined;
199
+ // Expand previous_response_id before image policy and child dispatch so a
200
+ // continuation that only references prior images still fails closed when
201
+ // imageInput is disabled (and so targets see the full replayed input).
202
+ const inboundClientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined;
203
+ const body = expandPreviousResponseInput(rawBody, inboundClientThreadId);
204
+ const scopeMismatch = previousResponseScopeMismatch(body);
205
+ if (scopeMismatch) {
206
+ console.warn("[opencodex] dropped a previous_response_id with a mismatched client task scope; continuing fresh");
207
+ }
208
+ if (previousResponseReplayFailure(body)) {
209
+ return formatErrorResponse(
210
+ 400,
211
+ "previous_response_not_found",
212
+ "Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.",
213
+ );
214
+ }
215
+ // Missing state returns the original body without a failure marker. Reject
216
+ // that unresolved continuation for image-disabled combos so a target cannot
217
+ // resolve prior images out of band. A successful expansion yields a new
218
+ // object (still carrying previous_response_id) and must not be treated as
219
+ // unresolved — text-only stored continuations remain allowed.
220
+ const requestedPreviousId = typeof (rawBody as { previous_response_id?: unknown } | null)?.previous_response_id === "string"
221
+ ? (rawBody as { previous_response_id: string }).previous_response_id.trim()
222
+ : "";
223
+ const unresolvedPrevious = requestedPreviousId.length > 0 && body === rawBody;
224
+ if (combo.imageInput === "disabled" && unresolvedPrevious) {
225
+ return formatErrorResponse(
226
+ 400,
227
+ "previous_response_not_found",
228
+ "Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.",
229
+ );
230
+ }
231
+ if (combo.imageInput === "disabled" && comboRequestHasImageInput(body)) {
232
+ return formatErrorResponse(400, "invalid_request_error", `Combo "${comboId}" does not accept image input`);
233
+ }
234
+ const comboReplaySnapshot = {
235
+ sourceBody: body,
236
+ previousResponseInputExpanded: body !== rawBody
237
+ && typeof (body as { previous_response_id?: unknown }).previous_response_id === "string",
238
+ providerContinuation: !scopeMismatch && body !== rawBody && requestedPreviousId
239
+ ? previousResponseProviderState(requestedPreviousId)
240
+ : undefined,
241
+ recoveredPlaintext: false,
242
+ };
243
+ const reasoningReplayConversationId = reasoningReplayConversationIdFromResponsesRequest({
244
+ clientThreadId: inboundClientThreadId,
245
+ threadIdHeader: req.headers.get("thread-id"),
246
+ sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
247
+ });
248
+ const reasoningReplayEligible = (target: (typeof combo.targets)[number]): boolean => {
249
+ try {
250
+ const route = routeConcreteModel(config, `${target.provider}/${target.model}`);
251
+ const unavailable = mandatoryResponsesReasoningReplayUnavailable({
252
+ body,
253
+ clientThreadId: reasoningReplayConversationId,
254
+ providerName: route.providerName,
255
+ provider: route.provider,
256
+ adapterName: route.provider.adapter,
257
+ modelId: route.modelId,
258
+ });
259
+ return !unavailable;
260
+ } catch {
261
+ // Routing failures are not evidence of replay incompatibility. Keep the target eligible so
262
+ // the existing selection and dispatch path preserves its original routing failure surface.
263
+ return true;
264
+ }
265
+ };
266
+ const adoptFailedChildLog = (childLog: RequestLogContext): void => {
267
+ // Attempts remain the complete physical history; the logical row mirrors the most recent
268
+ // failed target so an exhausted combo still has useful top-level reasoning diagnostics.
269
+ Object.assign(logCtx, childLog, {
270
+ requestedModel,
271
+ model: requestedModel,
272
+ provider: "combo",
273
+ comboId,
274
+ routeDecision: logCtx.routeDecision,
275
+ attempts: logCtx.attempts,
276
+ activeAttempt: undefined,
277
+ activeAttemptStartedAt: undefined,
278
+ });
279
+ };
280
+
281
+ const unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
282
+ (body as { input?: unknown } | undefined)?.input,
283
+ );
284
+ const canDecryptUnreadableAgentTask = (target: (typeof combo.targets)[number]): boolean => {
285
+ const provider = config.providers[target.provider];
286
+ if (!provider || provider.disabled === true) return false;
287
+ try {
288
+ const route = routeConcreteModel(config, `${target.provider}/${target.model}`);
289
+ return isCanonicalOpenAiForwardProvider(route.provider);
290
+ } catch {
291
+ return false;
292
+ }
293
+ };
294
+ let comboPayloadReadable = false;
295
+ const payloadEligible = (target: (typeof combo.targets)[number]): boolean =>
296
+ comboPayloadReadable || !unreadableEncryptedAgentTask || canDecryptUnreadableAgentTask(target);
297
+ const targetEligible = (target: (typeof combo.targets)[number]): boolean =>
298
+ payloadEligible(target) && reasoningReplayEligible(target);
299
+ const onlyReplayIncompatibleTargetsRemain = (excluded: Iterable<string> = []): boolean => {
300
+ const excludedKeys = new Set(excluded);
301
+ const remaining = combo.targets.filter(target => {
302
+ const provider = config.providers[target.provider];
303
+ return provider?.disabled !== true
304
+ && !excludedKeys.has(targetKey(target))
305
+ && payloadEligible(target);
306
+ });
307
+ return remaining.length > 0 && remaining.every(target => !reasoningReplayEligible(target));
308
+ };
309
+ let encryptedTaskRecoveryAttempted = false;
310
+ let recoveryFailureReason: AgentTaskRecoveryFailureReason | undefined;
311
+ let storedPool401ReplayDispatched = false;
312
+ const recoverUnreadableEncryptedTask = async (): Promise<boolean> => {
313
+ if (encryptedTaskRecoveryAttempted) return false;
314
+ encryptedTaskRecoveryAttempted = true;
315
+ const recovery = agentTaskRecoveryConfig(config);
316
+ if (
317
+ (options.inboundWire ?? "responses") !== "responses"
318
+ || !isThreadSpawnRequest(req.headers)
319
+ || !recovery
320
+ || options.comboAttempt
321
+ ) {
322
+ discardEncryptedAgentTaskRecovery(
323
+ req,
324
+ (body as { input?: unknown } | undefined)?.input,
325
+ config,
326
+ { parentThreadId: inboundClientThreadId },
327
+ );
328
+ return false;
329
+ }
330
+ let recovered = false;
331
+ try {
332
+ const result = await recoverEncryptedAgentTaskWithResult(
333
+ req,
334
+ (body as { input?: unknown } | undefined)?.input,
335
+ recovery,
336
+ config,
337
+ { parentThreadId: inboundClientThreadId, abortSignal: options.abortSignal },
338
+ );
339
+ recovered = result.recovered;
340
+ recoveryFailureReason = result.recovered ? undefined : result.reason;
341
+ } catch {
342
+ recovered = false;
343
+ recoveryFailureReason = undefined;
344
+ }
345
+ // Recovery has the same in-place input mutation contract as the direct routed path.
346
+ if (
347
+ !recovered
348
+ || hasUnreadableEncryptedAgentTask((body as { input?: unknown } | undefined)?.input)
349
+ ) {
350
+ discardEncryptedAgentTaskRecovery(
351
+ req,
352
+ (body as { input?: unknown } | undefined)?.input,
353
+ config,
354
+ { parentThreadId: inboundClientThreadId },
355
+ );
356
+ return false;
357
+ }
358
+ comboPayloadReadable = true;
359
+ comboReplaySnapshot.recoveredPlaintext = true;
360
+ return true;
361
+ };
362
+ const initialNow = Date.now();
363
+ const pickWithWait = (pickOptions: {
364
+ exclude?: Iterable<string>;
365
+ eligible?: (target: NonNullable<typeof combo>["targets"][number]) => boolean;
366
+ now?: number;
367
+ }) => pickComboTargetWithWait(config, comboId, {
368
+ ...pickOptions,
369
+ waitForCooldownMs: combo.waitForCooldownMs,
370
+ abortSignal: options.abortSignal,
371
+ });
372
+ let pick = await pickWithWait({
373
+ eligible: targetEligible,
374
+ now: initialNow,
375
+ });
376
+
377
+ if (unreadableEncryptedAgentTask && !pick) {
378
+ pick = await pickWithWait({ now: initialNow });
379
+ if (!pick) {
380
+ discardEncryptedAgentTaskRecovery(
381
+ req,
382
+ (body as { input?: unknown } | undefined)?.input,
383
+ config,
384
+ { parentThreadId: inboundClientThreadId },
385
+ );
386
+ return options.abortSignal?.aborted
387
+ ? clientCancelledResponse()
388
+ : comboUnavailable(comboId);
389
+ }
390
+ if (!(await recoverUnreadableEncryptedTask())) {
391
+ return options.abortSignal?.aborted
392
+ ? clientCancelledResponse()
393
+ : unreadableEncryptedAgentTaskResponse(recoveryFailureReason);
394
+ }
395
+ }
396
+
397
+ if (!pick) {
398
+ if (onlyReplayIncompatibleTargetsRemain()) return targetIncompatibleResponse();
399
+ return options.abortSignal?.aborted
400
+ ? clientCancelledResponse()
401
+ : comboUnavailable(comboId);
402
+ }
403
+ // One immutable combo selection trace, before any child dispatch; child
404
+ // adoption below must never replace it with a concrete child route trace.
405
+ logCtx.routeDecision = comboRouteDecisionTrace(config, comboId, pick, requestedModel);
406
+
407
+ const originalReasoning = body && typeof body === "object" && !Array.isArray(body)
408
+ ? (body as { reasoning?: unknown }).reasoning
409
+ : undefined;
410
+ const originalRequestedEffortValue = originalReasoning && typeof originalReasoning === "object" && !Array.isArray(originalReasoning)
411
+ ? (originalReasoning as { effort?: unknown }).effort
412
+ : undefined;
413
+ const originalRequestedEffort = typeof originalRequestedEffortValue === "string"
414
+ && isDeclaredReasoningEffort(originalRequestedEffortValue)
415
+ ? originalRequestedEffortValue
416
+ : undefined;
417
+ const restoreOriginalRequestedEffort = (childLog: RequestLogContext): void => {
418
+ if (originalRequestedEffort === undefined) return;
419
+ const normalizedRequestedEffort = childLog.requestedEffort;
420
+ const transitionIndex = normalizedRequestedEffort?.indexOf("->") ?? -1;
421
+ childLog.requestedEffort = transitionIndex >= 0
422
+ ? `${originalRequestedEffort}${normalizedRequestedEffort!.slice(transitionIndex)}`
423
+ : originalRequestedEffort;
424
+ recordAttemptRequestedEffort(childLog);
425
+ };
426
+
427
+ let lastFailure: Response | null = null;
428
+ // Dispatched targets, not attempted picks: it indexes the declared target list so the clamp
429
+ // below can tell how many targets are still entitled to a send.
430
+ let comboTargetsDispatched = 0;
431
+ // The child log behind `lastFailure`. The natural end of the ladder adopts it inside the
432
+ // no-more-targets branch; a budget refusal ends the ladder one iteration later, where that
433
+ // iteration's own `childLog` is already out of scope.
434
+ let lastFailedChildLog: RequestLogContext | undefined;
435
+ // The exhausted-combo mapping below runs outside the loop, where `failure.upstreamCode`
436
+ // is gone, so carry the loop's own classification decision instead of re-deriving a
437
+ // weaker one from the status alone (#4149).
438
+ let lastFailureClassifiesOverflow = false;
439
+ while (pick) {
440
+ if (options.abortSignal?.aborted) return clientCancelledResponse();
441
+ const firstComboTarget = comboTargetsDispatched === 0;
442
+ // The first target seeds the ledger's target identity and charges nothing; every later one
443
+ // is a real transition, refused once the declared hops, the alternate-target ledger or the
444
+ // request total are spent. `countedExternally` is required: the child charges its own
445
+ // physical sends, and charging here as well would halve the cap without saying so.
446
+ const hopDecision = comboSendScope?.reserveDispatch({
447
+ sendClass: firstComboTarget ? "initial" : "combo-failover",
448
+ targetKey: `${pick.target.provider}/${pick.target.model}`,
449
+ countedExternally: true,
450
+ });
451
+ if (hopDecision && hopDecision.allowed) hopDecision.permit.use();
452
+ else if (hopDecision && !firstComboTarget) {
453
+ // Out of budget is not this target's failure. The established exhaustion contract is to
454
+ // return the last real upstream answer with its status, headers and any quota body
455
+ // intact rather than to mint a synthetic error, and a later target only exists because
456
+ // an earlier one already recorded one.
457
+ if (lastFailedChildLog) adoptFailedChildLog(lastFailedChildLog);
458
+ break;
459
+ }
460
+ const targetSendBudget = comboSendScope
461
+ ? comboTargetSendBudget(comboSendScope, combo.targets.length - 1 - comboTargetsDispatched)
462
+ : options.sendBudget;
463
+ comboTargetsDispatched += 1;
464
+ const childLog: RequestLogContext = {
465
+ model: pick.target.model,
466
+ provider: pick.target.provider,
467
+ ...(logCtx.conversationId ? { conversationId: logCtx.conversationId } : {}),
468
+ ...(logCtx.surface ? { surface: logCtx.surface } : {}),
469
+ };
470
+ const targetRoute = routeConcreteModel(config, `${pick.target.provider}/${pick.target.model}`);
471
+ const childBody = concreteComboRequestBody(
472
+ body,
473
+ pick.target,
474
+ comboDefaultEffort(config, comboId),
475
+ supportedLadderFor({ provider: targetRoute.provider, modelId: targetRoute.modelId }),
476
+ combo.reasoningEffortMode,
477
+ combo.defaultEffortMode,
478
+ );
479
+ const childHeaders = buildComboChildHeaders(req.headers);
480
+ const childRequest = new Request(req.url, {
481
+ method: req.method,
482
+ headers: childHeaders,
483
+ body: JSON.stringify(childBody),
484
+ });
485
+ linkRequestSessionLane(req, childRequest);
486
+ let resolvedAuth: CodexAuthContext | undefined;
487
+ let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined;
488
+ const started = Date.now();
489
+ const attempt = beginRequestAttempt(
490
+ (logCtx.attempts?.length ?? 0) + 1,
491
+ pick.target.provider,
492
+ pick.target.model,
493
+ config.providers[pick.target.provider]!.adapter,
494
+ );
495
+ childLog.activeAttempt = attempt;
496
+ if (originalRequestedEffort !== undefined) {
497
+ childLog.requestedEffort = originalRequestedEffort;
498
+ recordAttemptRequestedEffort(childLog);
499
+ }
500
+ childLog.activeAttemptStartedAt = started;
501
+ childLog.attempts = logCtx.attempts ??= [];
502
+ childLog.attempts.push(attempt);
503
+ let attemptRetained = false;
504
+ const retainCancelledAttempt = (): void => {
505
+ if (attemptRetained) return;
506
+ sealRequestAttemptIdentity(
507
+ attempt,
508
+ childLog.provider,
509
+ childLog.providerAdapter ?? attempt.adapter,
510
+ childLog.accountLogLabel,
511
+ );
512
+ finishRequestAttempt(attempt, 499, Date.now() - started, childLog.usage);
513
+ attemptRetained = true;
514
+ };
515
+ const completedTarget = { provider: pick.target.provider, model: pick.target.model };
516
+ const writerGeneration = pick.writerGeneration;
517
+ let consumedChildFailure: ConsumedComboFailure | undefined;
518
+ const callbackGate = createChildPassthroughCallbackGate({
519
+ ...options,
520
+ onResponseComplete: model => {
521
+ // The live config can change while the child is streaming. Never retain credentials.
522
+ const currentCombo = getCombo(config, comboId);
523
+ const provider = config.providers[completedTarget.provider];
524
+ if (Object.hasOwn(config.providers, completedTarget.provider)
525
+ && provider && provider.disabled !== true
526
+ && currentCombo?.targets.some(target => targetKey(target) === targetKey(completedTarget))) {
527
+ rememberComboForLane(sessionLaneIdFromRequest(req.headers), comboId, completedTarget, model, writerGeneration);
528
+ }
529
+ options.onResponseComplete?.(model);
530
+ },
531
+ onNativePassthroughTerminal: status => {
532
+ // A committed stream can acquire terminal metadata after preflight copied
533
+ // the child log. Publish it before the outer logger finalizes, but only
534
+ // through the gate: discarded attempts must never affect the parent.
535
+ // Undefined child fields must preserve metadata already inspected by WS.
536
+ if (childLog.terminalHttpStatus !== undefined) logCtx.terminalHttpStatus = childLog.terminalHttpStatus;
537
+ if (childLog.terminalIncompleteReason !== undefined) logCtx.terminalIncompleteReason = childLog.terminalIncompleteReason;
538
+ if (childLog.terminalErrorCode !== undefined) logCtx.terminalErrorCode = childLog.terminalErrorCode;
539
+ if (childLog.upstreamError !== undefined) logCtx.upstreamError = childLog.upstreamError;
540
+ options.onNativePassthroughTerminal?.(status);
541
+ },
542
+ });
543
+ let response: Response;
544
+ try {
545
+ const currentTargetProvider = pick.target.provider;
546
+ const deferCodexResetDerivedCooldown = combo.strategy === "failover"
547
+ && combo.targets.slice(pick.targetIndex + 1).some(target =>
548
+ target.provider === currentTargetProvider
549
+ && targetEligible(target)
550
+ && !isComboTargetInCooldown(comboId, target),
551
+ );
552
+ response = await requestDispatchers.handleResponses(childRequest, config, childLog, {
553
+ ...options,
554
+ // After the spread: the child must run on THIS target's ladder, not on the holder the
555
+ // parent arrived with.
556
+ sendBudget: targetSendBudget,
557
+ comboAttempt: true,
558
+ comboReplaySnapshot,
559
+ deferCodexResetDerivedCooldown,
560
+ // Attempt-relative TTFT is recorded HERE (not via childLog.firstOutputMs — a later
561
+ // Object.assign(logCtx, childLog) would overwrite the request-relative value).
562
+ onFirstOutput: () => {
563
+ if (attempt.firstOutputMs === undefined) {
564
+ attempt.firstOutputMs = Math.max(0, Date.now() - started);
565
+ }
566
+ options.onFirstOutput?.();
567
+ },
568
+ onCodexAuthContextResolved: value => { resolvedAuth = value; },
569
+ setTerminalOutcomeRecorder: value => { terminalRecorder = value; },
570
+ onConsumedComboFailure: value => { consumedChildFailure = value; },
571
+ onStoredPool401ReplayDispatched: () => { storedPool401ReplayDispatched = true; },
572
+ onNativePassthroughTerminal: callbackGate.onTerminal,
573
+ onNativePassthroughCancel: callbackGate.onCancel,
574
+ onResponseComplete: callbackGate.onResponseComplete,
575
+ });
576
+ restoreOriginalRequestedEffort(childLog);
577
+ } catch (error) {
578
+ callbackGate.discard();
579
+ if (options.abortSignal?.aborted) {
580
+ retainCancelledAttempt();
581
+ return clientCancelledResponse();
582
+ }
583
+ finishRequestAttempt(attempt, 502, Date.now() - started, childLog.usage);
584
+ throw error;
585
+ }
586
+
587
+ if (options.abortSignal?.aborted) {
588
+ callbackGate.discard();
589
+ retainCancelledAttempt();
590
+ return clientCancelledResponse();
591
+ }
592
+
593
+ if (response.ok && !runTurnAdapterSseResponses.has(response)) {
594
+ const nativePassthrough = isNativePassthroughSseResponse(response);
595
+ const eagerRelay = isEagerRelaySseResponse(response);
596
+ let preflight;
597
+ try {
598
+ preflight = await preflightComboStreamResponse(response, childLog);
599
+ } catch (error) {
600
+ callbackGate.discard();
601
+ if (options.abortSignal?.aborted) {
602
+ retainCancelledAttempt();
603
+ return clientCancelledResponse();
604
+ }
605
+ finishRequestAttempt(attempt, 502, Date.now() - started, childLog.usage);
606
+ throw error;
607
+ }
608
+ if (preflight.kind === "failed") {
609
+ callbackGate.discard();
610
+ terminalRecorder?.("failed", preflight.response.status);
611
+ response = preflight.response;
612
+ } else {
613
+ response = preflight.response;
614
+ if (nativePassthrough) markNativePassthroughSseResponse(response);
615
+ if (eagerRelay) markEagerRelaySseResponse(response);
616
+ }
617
+ }
618
+
619
+ if (response.ok) {
620
+ sealRequestAttemptIdentity(
621
+ attempt,
622
+ childLog.provider,
623
+ childLog.providerAdapter ?? attempt.adapter,
624
+ childLog.accountLogLabel,
625
+ );
626
+ attemptRetained = true;
627
+ noteComboSuccess(comboId, combo, pick.target, pick.writerGeneration);
628
+ Object.assign(logCtx, childLog, {
629
+ requestedModel,
630
+ model: requestedModel,
631
+ provider: "combo",
632
+ comboId,
633
+ routeDecision: logCtx.routeDecision,
634
+ attempts: logCtx.attempts,
635
+ activeAttempt: attempt,
636
+ activeAttemptStartedAt: started,
637
+ resolvedModel: childLog.resolvedModel ?? childLog.model,
638
+ });
639
+ options.onCodexAuthContextResolved?.(resolvedAuth);
640
+ options.setTerminalOutcomeRecorder?.(terminalRecorder);
641
+ callbackGate.commit();
642
+ return response;
643
+ }
644
+
645
+ callbackGate.discard();
646
+ if (response.status === 499) {
647
+ retainCancelledAttempt();
648
+ return clientCancelledResponse();
649
+ }
650
+ let failure: ConsumedComboFailure;
651
+ try {
652
+ failure = consumedChildFailure
653
+ ?? await consumeComboFailure(response, options.abortSignal);
654
+ } catch (error) {
655
+ if (options.abortSignal?.aborted) {
656
+ retainCancelledAttempt();
657
+ return clientCancelledResponse();
658
+ }
659
+ finishRequestAttempt(attempt, 502, Date.now() - started, childLog.usage);
660
+ throw error;
661
+ }
662
+ if (options.abortSignal?.aborted) {
663
+ retainCancelledAttempt();
664
+ return clientCancelledResponse();
665
+ }
666
+ sealRequestAttemptIdentity(
667
+ attempt,
668
+ childLog.provider,
669
+ childLog.providerAdapter ?? attempt.adapter,
670
+ childLog.accountLogLabel,
671
+ );
672
+ finishRequestAttempt(
673
+ attempt,
674
+ failure.response.status,
675
+ Date.now() - started,
676
+ failure.usage,
677
+ );
678
+ attemptRetained = true;
679
+ lastFailure = failure.response;
680
+ lastFailedChildLog = childLog;
681
+ const failureDecision = comboFailureDecision(failure.response.status, failure.classificationText, {
682
+ code: failure.upstreamCode,
683
+ });
684
+ const wantsStream = (rawBody as { stream?: unknown } | null)?.stream === true;
685
+ // Local byte admission has its own diagnostic; do not relabel it as an upstream refusal.
686
+ const classifyOverflow = failure.response.status === 413
687
+ && (wantsStream || (failure.upstreamCode !== "outbound_body_too_large"
688
+ && failure.upstreamCode !== "translation_buffer_limit"));
689
+ lastFailureClassifiesOverflow = classifyOverflow;
690
+ if (storedPool401ReplayDispatched) {
691
+ if (failureDecision === "hop" && unreadableEncryptedAgentTask && !comboPayloadReadable) {
692
+ const recoveredTarget = await pickWithWait({
693
+ exclude: pick.attempted,
694
+ eligible: target => {
695
+ try {
696
+ const route = routeConcreteModel(config, `${target.provider}/${target.model}`);
697
+ return route.codexAccountMode === undefined
698
+ && !isCanonicalOpenAiForwardProvider(route.provider);
699
+ } catch {
700
+ return false;
701
+ }
702
+ },
703
+ });
704
+ if (options.abortSignal?.aborted) return clientCancelledResponse();
705
+ if (recoveredTarget && await recoverUnreadableEncryptedTask()) {
706
+ pick = recoveredTarget;
707
+ continue;
708
+ }
709
+ if (options.abortSignal?.aborted) return clientCancelledResponse();
710
+ }
711
+ // Keep the spent Pool budget sticky even after a recovered routed child:
712
+ // no later failure may reopen ordinary combo/native account hopping.
713
+ adoptFailedChildLog(childLog);
714
+ if (classifyOverflow && failureDecision === "stop") {
715
+ return wantsStream
716
+ ? streamingContextOverflowResponse(requestedModel, options.translatorBudget)
717
+ : jsonContextOverflowResponse();
718
+ }
719
+ return lastFailure;
720
+ }
721
+ if (failureDecision === "stop") {
722
+ adoptFailedChildLog(childLog);
723
+ if (classifyOverflow) {
724
+ return wantsStream
725
+ ? streamingContextOverflowResponse(requestedModel, options.translatorBudget)
726
+ : jsonContextOverflowResponse();
727
+ }
728
+ return lastFailure;
729
+ }
730
+ console.warn(
731
+ `[combo] ${comboId}: ${targetKey(pick.target)} failed with ${failure.response.status} after ${Date.now() - started}ms`,
732
+ );
733
+ const failureNow = Date.now();
734
+ const attemptedTargets = pick.attempted;
735
+ const nextPick = advanceComboAfterFailure(config, pick, {
736
+ retryAfter: failure.retryAfter,
737
+ resetAt: failure.resetAt,
738
+ cooldownMs: combo.cooldownMs,
739
+ now: failureNow,
740
+ cooldownScope: comboFailureCooldownScope(failure.response.status, failure.classificationText, {
741
+ code: failure.upstreamCode,
742
+ }),
743
+ eligible: targetEligible,
744
+ status: failure.response.status,
745
+ code: failure.upstreamCode,
746
+ message: failure.classificationText,
747
+ });
748
+ if (nextPick) {
749
+ pick = nextPick;
750
+ } else {
751
+ pick = await pickWithWait({
752
+ exclude: pick.attempted,
753
+ eligible: targetEligible,
754
+ now: failureNow,
755
+ });
756
+ }
757
+ if (!pick) {
758
+ if (options.abortSignal?.aborted) return clientCancelledResponse();
759
+ if (onlyReplayIncompatibleTargetsRemain(attemptedTargets)) {
760
+ adoptFailedChildLog(childLog);
761
+ return targetIncompatibleResponse();
762
+ }
763
+ if (unreadableEncryptedAgentTask && !comboPayloadReadable) {
764
+ const recoveredTarget = await pickWithWait({
765
+ exclude: attemptedTargets,
766
+ now: failureNow,
767
+ });
768
+ if (recoveredTarget && await recoverUnreadableEncryptedTask()) {
769
+ pick = recoveredTarget;
770
+ continue;
771
+ }
772
+ }
773
+ // Waiting or recovery may have observed cancellation after the check above.
774
+ if (options.abortSignal?.aborted) return clientCancelledResponse();
775
+ adoptFailedChildLog(childLog);
776
+ }
777
+ }
778
+ if (
779
+ lastFailure?.status === 413
780
+ && lastFailureClassifiesOverflow
781
+ ) {
782
+ return (rawBody as { stream?: unknown } | null)?.stream === true
783
+ ? streamingContextOverflowResponse(requestedModel, options.translatorBudget)
784
+ : jsonContextOverflowResponse();
785
+ }
786
+ return lastFailure!;
787
+ }