@bitkyc08/opencodex 2.57.0 → 2.59.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 (241) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-C5IebErG.js +136 -0
  3. package/gui/dist/assets/{index-C5-RdDmD.css → index-OESInAjC.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +2 -2
  8. package/src/adapters/base.ts +11 -1
  9. package/src/adapters/codebuddy/scaffold-guard.ts +5 -4
  10. package/src/adapters/command-code.ts +13 -4
  11. package/src/adapters/cursor/catalog.ts +11 -0
  12. package/src/adapters/cursor/cursor-errors.ts +15 -0
  13. package/src/adapters/cursor/discovery.ts +65 -1
  14. package/src/adapters/cursor/effort-map.ts +16 -2
  15. package/src/adapters/cursor/envelope-echo.ts +55 -2
  16. package/src/adapters/cursor/live-transport.ts +5 -1
  17. package/src/adapters/cursor/message-mapper.ts +3 -2
  18. package/src/adapters/cursor/protobuf-events.ts +110 -11
  19. package/src/adapters/cursor/protobuf-request.ts +27 -6
  20. package/src/adapters/cursor/request-builder.ts +14 -3
  21. package/src/adapters/cursor/text-toolcall.ts +230 -0
  22. package/src/adapters/cursor/thread-continuity.ts +141 -0
  23. package/src/adapters/cursor/tool-guidance.ts +5 -4
  24. package/src/adapters/cursor/types.ts +5 -0
  25. package/src/adapters/cursor.ts +97 -6
  26. package/src/adapters/devin/cloud-direct/chat.ts +11 -2
  27. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  28. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
  29. package/src/adapters/devin.ts +75 -13
  30. package/src/adapters/google-antigravity-wire.ts +29 -2
  31. package/src/adapters/google-http.ts +45 -13
  32. package/src/adapters/google.ts +23 -4
  33. package/src/adapters/mimo-free.ts +32 -17
  34. package/src/adapters/ollama-native.ts +42 -8
  35. package/src/adapters/openai-chat/response-events.ts +61 -0
  36. package/src/adapters/openai-chat.ts +5 -10
  37. package/src/adapters/openai-responses/passthrough.ts +40 -5
  38. package/src/adapters/openai-responses/request-strips.ts +43 -0
  39. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  40. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  41. package/src/adapters/physical-send.ts +50 -0
  42. package/src/adapters/responses-tool-schema.ts +76 -46
  43. package/src/adapters/run-turn-queue.ts +17 -4
  44. package/src/bridge/response-json.ts +2 -2
  45. package/src/bridge/sse.ts +166 -25
  46. package/src/claude/context-windows.ts +22 -0
  47. package/src/claude/outbound.ts +46 -5
  48. package/src/cli/account-api.ts +4 -3
  49. package/src/cli/account-extended.ts +22 -2
  50. package/src/cli/account-orca-import.ts +63 -0
  51. package/src/cli/account.ts +32 -4
  52. package/src/cli/capabilities.ts +40 -0
  53. package/src/cli/claude.ts +29 -1
  54. package/src/cli/codex-cli-update.ts +97 -2
  55. package/src/cli/config-command.ts +35 -18
  56. package/src/cli/dispatch.ts +71 -4
  57. package/src/cli/doctor.ts +197 -2
  58. package/src/cli/help.ts +4 -1
  59. package/src/cli/index.ts +132 -22
  60. package/src/cli/models-runtime.ts +33 -4
  61. package/src/cli/registry.ts +11 -1
  62. package/src/cli/runtime-api.ts +44 -0
  63. package/src/cli/start-args.ts +94 -0
  64. package/src/cli/system-command.ts +72 -1
  65. package/src/cli/uninstall-client-state.ts +12 -0
  66. package/src/client/machine-api.ts +4 -3
  67. package/src/client/machine-listener.ts +14 -1
  68. package/src/clients/config-export/constants.ts +2 -3
  69. package/src/clients/config-export.ts +5 -5
  70. package/src/codex/account-store.ts +81 -5
  71. package/src/codex/auth-api/pool-quota-probe.ts +14 -3
  72. package/src/codex/auth-api/routes.ts +17 -2
  73. package/src/codex/auth-context.ts +58 -20
  74. package/src/codex/catalog/build-entries.ts +25 -4
  75. package/src/codex/catalog/derive-entry.ts +8 -1
  76. package/src/codex/catalog/effort.ts +10 -6
  77. package/src/codex/catalog/gather-capture.ts +1 -0
  78. package/src/codex/catalog/model-hints.ts +37 -5
  79. package/src/codex/catalog/parsing.ts +83 -5
  80. package/src/codex/catalog/reserve-warn.ts +96 -0
  81. package/src/codex/catalog/retained-sync.ts +19 -0
  82. package/src/codex/catalog/routed-gather.ts +42 -3
  83. package/src/codex/cli-installation-identity.ts +210 -0
  84. package/src/codex/cli-installation-targets.ts +158 -0
  85. package/src/codex/convergence.ts +5 -0
  86. package/src/codex/desktop-switches.ts +145 -0
  87. package/src/codex/history-job.ts +5 -1
  88. package/src/codex/history-provider.ts +37 -5
  89. package/src/codex/history-state-open.ts +105 -0
  90. package/src/codex/history-worker.ts +14 -1
  91. package/src/codex/inject/config-toml.ts +44 -2
  92. package/src/codex/inject/remove.ts +145 -7
  93. package/src/codex/inject/restore.ts +204 -32
  94. package/src/codex/inject.ts +6 -9
  95. package/src/codex/lineage.ts +83 -32
  96. package/src/codex/loopback-target.ts +40 -0
  97. package/src/codex/main-account-hard-lock.ts +2 -1
  98. package/src/codex/main-account.ts +10 -3
  99. package/src/codex/main-device-reauth.ts +17 -9
  100. package/src/codex/model-entitlements.ts +60 -1
  101. package/src/codex/native-profile-startup.ts +64 -20
  102. package/src/codex/observed-model-denials.ts +137 -0
  103. package/src/codex/orca-auth-source.ts +94 -0
  104. package/src/codex/orca-import.ts +219 -0
  105. package/src/codex/prompt-text-probe.ts +282 -12
  106. package/src/codex/quota-401-recovery.ts +12 -0
  107. package/src/codex/quota-types.ts +65 -0
  108. package/src/codex/quota.ts +24 -19
  109. package/src/codex/routing/cooldown-math.ts +8 -47
  110. package/src/codex/routing/pin-drain.ts +57 -0
  111. package/src/codex/routing.ts +13 -15
  112. package/src/codex/subagent-model-fallback.ts +94 -0
  113. package/src/codex/windows-installation-files.ts +224 -0
  114. package/src/combos/failover.ts +122 -5
  115. package/src/config/atomic-write.ts +83 -8
  116. package/src/config/diagnostics.ts +21 -0
  117. package/src/config/load-degrade.ts +15 -0
  118. package/src/config/pending-teardown.ts +8 -0
  119. package/src/config/process-state.ts +36 -3
  120. package/src/config/provider-relative-send-path.ts +16 -0
  121. package/src/config/proxy-env.ts +23 -5
  122. package/src/config/schema/config-schema.ts +23 -0
  123. package/src/config/schema/leaf-validators.ts +65 -17
  124. package/src/generated/compatibility-version.json +337 -201
  125. package/src/generated/model-metadata.ts +1 -1
  126. package/src/lib/bounded-body.ts +4 -2
  127. package/src/lib/bounded-subprocess.ts +62 -10
  128. package/src/lib/destination-policy.ts +48 -6
  129. package/src/lib/errors.ts +3 -15
  130. package/src/lib/local-destinations.ts +32 -5
  131. package/src/lib/provider-outbound.ts +3 -3
  132. package/src/lib/proxy-env.ts +70 -3
  133. package/src/lib/request-execution-budget.ts +11 -3
  134. package/src/lib/response-body-inactivity.ts +193 -0
  135. package/src/lib/retry-delay.ts +69 -0
  136. package/src/lib/socks5-fetch.ts +631 -0
  137. package/src/lib/spend-reservation-ledger.ts +115 -9
  138. package/src/lib/windows-secret-acl.ts +151 -15
  139. package/src/lib/windows-user-principal.ts +5 -1
  140. package/src/lib/workflow-budget.ts +145 -8
  141. package/src/oauth/account-quota-rank.ts +72 -15
  142. package/src/oauth/generic-account-failover.ts +40 -27
  143. package/src/oauth/orcarouter.ts +15 -2
  144. package/src/oauth/store.ts +8 -0
  145. package/src/providers/codex-capacity.ts +9 -0
  146. package/src/providers/derive.ts +6 -0
  147. package/src/providers/devin-provider-merge-migration.ts +33 -12
  148. package/src/providers/free-directory.ts +20 -2
  149. package/src/providers/key-failover.ts +261 -7
  150. package/src/providers/model-discovery.ts +19 -7
  151. package/src/providers/model-rename-migration.ts +1 -0
  152. package/src/providers/openai-sidecar.ts +4 -0
  153. package/src/providers/opencode-go-transport.ts +14 -5
  154. package/src/providers/quota/report-cache.ts +3 -0
  155. package/src/providers/registry/entries-core.ts +11 -0
  156. package/src/providers/registry/entries-extended.ts +146 -28
  157. package/src/providers/registry/model-seeds.ts +136 -29
  158. package/src/providers/registry/types.ts +9 -0
  159. package/src/responses/apply-patch-envelope.ts +44 -11
  160. package/src/responses/bridge-search-replay-cache.ts +152 -0
  161. package/src/responses/code-mode-helper-compat.ts +26 -16
  162. package/src/responses/custom-tool-compat.ts +1 -1
  163. package/src/responses/hosted-tool-policy.ts +85 -2
  164. package/src/responses/schema.ts +9 -2
  165. package/src/responses/spill-store.ts +17 -0
  166. package/src/responses/state/body-policy.ts +25 -0
  167. package/src/responses/state/spill-queue.ts +8 -6
  168. package/src/responses/state.ts +3 -22
  169. package/src/router.ts +4 -0
  170. package/src/server/auth-cors.ts +27 -0
  171. package/src/server/chat-completions.ts +9 -4
  172. package/src/server/chat-native-sse.ts +26 -9
  173. package/src/server/chat-native.ts +10 -4
  174. package/src/server/claude-messages.ts +24 -2
  175. package/src/server/gui-static.ts +36 -2
  176. package/src/server/inbound-body-admission.ts +187 -0
  177. package/src/server/index/websocket-handler.ts +48 -1
  178. package/src/server/index.ts +15 -19
  179. package/src/server/management/api-access.ts +3 -4
  180. package/src/server/management/config-routes.ts +57 -10
  181. package/src/server/management/provider-capability-config.ts +35 -7
  182. package/src/server/management/provider-routes.ts +70 -18
  183. package/src/server/models-capabilities.ts +24 -3
  184. package/src/server/proxy-liveness.ts +97 -2
  185. package/src/server/relay.ts +17 -24
  186. package/src/server/request-log.ts +25 -1
  187. package/src/server/responses/adapter-continuation.ts +71 -27
  188. package/src/server/responses/adapter-delivery.ts +39 -8
  189. package/src/server/responses/adapter-dispatch.ts +52 -24
  190. package/src/server/responses/codex-ws-exchange.ts +65 -4
  191. package/src/server/responses/combo-stream-preflight.ts +68 -5
  192. package/src/server/responses/compact.ts +60 -11
  193. package/src/server/responses/core-codex-account.ts +83 -22
  194. package/src/server/responses/core-combo.ts +26 -0
  195. package/src/server/responses/core-normalize.ts +12 -5
  196. package/src/server/responses/core-options.ts +3 -0
  197. package/src/server/responses/fetch-helpers.ts +72 -3
  198. package/src/server/responses/native-injection-protocol.ts +42 -0
  199. package/src/server/responses/native-injection-replay.ts +105 -0
  200. package/src/server/responses/native-injection.ts +242 -0
  201. package/src/server/responses/native-response-control.ts +56 -0
  202. package/src/server/responses/native-response-json.ts +14 -0
  203. package/src/server/responses/native-response-output.ts +37 -0
  204. package/src/server/responses/native-steering-log.ts +44 -0
  205. package/src/server/responses/native-steering-policy.ts +49 -0
  206. package/src/server/responses/native-steering-replay.ts +126 -0
  207. package/src/server/responses/native-steering-settings.ts +76 -0
  208. package/src/server/responses/native-steering.ts +400 -0
  209. package/src/server/responses/native-tool-results.ts +130 -0
  210. package/src/server/responses/passthrough-delivery.ts +21 -1
  211. package/src/server/responses/passthrough-dispatch.ts +146 -49
  212. package/src/server/responses/passthrough-execution.ts +11 -1
  213. package/src/server/responses/request-prepare.ts +70 -0
  214. package/src/server/responses/request-send-budget.ts +84 -7
  215. package/src/server/responses/request-sidecar-auth.ts +16 -8
  216. package/src/server/responses/request-spend.ts +38 -9
  217. package/src/server/responses/request-transport.ts +13 -10
  218. package/src/server/responses/run-turn-execution.ts +20 -5
  219. package/src/server/responses/sidecar-execution.ts +2 -0
  220. package/src/server/responses/ws-upstream.ts +23 -2
  221. package/src/server/responses-custom-tool-repair.ts +2 -2
  222. package/src/server/sse-frame-buffer.ts +12 -10
  223. package/src/server/sse-payload-rewrite.ts +36 -9
  224. package/src/server/stop-teardown.ts +8 -1
  225. package/src/server/system-env-shell.ts +5 -1
  226. package/src/server/system-env.ts +7 -1
  227. package/src/server/workflow-refusal.ts +56 -2
  228. package/src/server/ws-bridge.ts +16 -1
  229. package/src/service/cli.ts +29 -7
  230. package/src/service/guards.ts +10 -0
  231. package/src/service/health.ts +43 -0
  232. package/src/service/state.ts +7 -2
  233. package/src/types/accounts.ts +4 -0
  234. package/src/types/config.ts +104 -3
  235. package/src/types/provider.ts +32 -0
  236. package/src/types/request.ts +7 -1
  237. package/src/types/wire.ts +9 -1
  238. package/src/usage/expected-prices.ts +28 -0
  239. package/src/usage/log.ts +87 -4
  240. package/src/web-search/passthrough-bridge.ts +39 -5
  241. package/gui/dist/assets/index-Cz7CLdif.js +0 -128
@@ -1,3 +1,7 @@
1
+ import { createSteeringSettingsNormalizer } from "./native-steering-policy";
2
+ import { nativeResponseControlEligible } from "./native-response-control";
3
+ import { NativeInjectionReplay } from "./native-injection-replay";
4
+ import { NativeSteeringReplay } from "./native-steering-replay";
1
5
  import type {
2
6
  ResponsesRequestContext,
3
7
  ResponsesAdmissionState,
@@ -7,10 +11,11 @@ import type { PreparedResponsesRequest } from "./request-prepare";
7
11
  import type { ResponsesTransport } from "./request-transport";
8
12
  import type { ResponsesEffects } from "./response-effects";
9
13
  import type { ResponsesSendBudget } from "./request-send-budget";
14
+ import { transientSendCapFor } from "./request-send-budget";
10
15
  import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
11
16
  import { codexSafetyBufferingFilterOptions, terminalStatusFromParsed } from "../relay";
12
17
  import { imageGenToolCallAliases } from "../responses-image-gen-repair";
13
- import { rememberResponseState } from "../../responses/state";
18
+ import { rememberResponseState, isBodyNonPersistable } from "../../responses/state";
14
19
  import {
15
20
  currentTurnWireToolCatalogBody,
16
21
  hasExplicitWireToolCatalog,
@@ -64,6 +69,7 @@ import {
64
69
  sealRequestAttemptIdentity,
65
70
  recordAttemptCredentialSource,
66
71
  } from "../request-log";
72
+ import { noteAttemptRecoveryWithheld } from "../request-log";
67
73
  import {
68
74
  upstreamHostHealthKey,
69
75
  normalizeUpstreamHostCircuitThreshold,
@@ -87,11 +93,15 @@ import {
87
93
  usesCodexForwardPoolAuth,
88
94
  codexWsQuotaObserver,
89
95
  isFixedCodexAccount,
90
- shouldRetryCodexPoolAccountModel400,
96
+ codexPoolAccountModel400Denial,
91
97
  shouldRetryCodexPoolAccountQuota,
92
98
  shouldRetryCodexPoolAccountTransient,
93
99
  retryCodexPoolOnAlternateAccount,
94
100
  } from "./core-codex-account";
101
+ import {
102
+ clearCodexModelDenialEvidence,
103
+ recordCodexModelDenialEvidence,
104
+ } from "../../codex/model-entitlements";
95
105
  import { readCodexWsStage } from "./codex-ws-wire";
96
106
  import { linkAbortSignal } from "./core-lifetime";
97
107
  import type { CodexAuthContext } from "../../codex/auth-context";
@@ -101,9 +111,8 @@ import {
101
111
  SendBudgetExhaustedError,
102
112
  fetchWithTransientRetry,
103
113
  applyUpstreamRecoveryInit,
104
- TRANSIENT_RETRY_MAX_ATTEMPTS,
105
114
  isNonReplayableResponse,
106
- prepareSameTarget429Wait,
115
+ prepareSameTarget429Wait,
107
116
  sleepWithAbort,
108
117
  } from "../../lib/upstream-retry";
109
118
  import { mapCodexAuthContextErrorToResponse } from "./codex-auth-error";
@@ -111,7 +120,11 @@ import { classifyTransportFailureKind, transportErrorCode } from "../../lib/upst
111
120
  import { recordCodexUpstreamOutcome } from "../../codex/routing";
112
121
  import { describeUpstreamConnectFailure } from "./upstream-error";
113
122
  import type { OpaqueBlobRecoveryGuard } from "./core-opaque-recovery";
114
- import { rateLimitRetryPolicyFor, rateLimitRetryDelayMs } from "../../providers/key-failover";
123
+ import {
124
+ rateLimitRetryPolicyFor,
125
+ rateLimitRetryDelayMs,
126
+ transientRetryPolicyFor,
127
+ } from "../../providers/key-failover";
115
128
  import type { AttemptRecoveryKind } from "../../usage/log";
116
129
  import { resolveWireProtocolOverride } from "../adapter-resolve";
117
130
  import { refreshPoolForwardAuth, refreshNativeMainForwardAuth, withClaudeNativeSession } from "./core-auth";
@@ -194,6 +207,7 @@ export async function preparePassthroughExchange(
194
207
  | "reserveCredentialHop"
195
208
  | "pendingHopPermit"
196
209
  | "workflowRootId"
210
+ | "sendsUsed"
197
211
  >,
198
212
  ) {
199
213
  const { config, logCtx, options, req } = requestContext;
@@ -251,6 +265,19 @@ export async function preparePassthroughExchange(
251
265
  ? (response: { id?: unknown; output?: unknown; status?: unknown }) =>
252
266
  rememberResponseState(parsed._rawBody, response, undefined, responseStateOptions(true))
253
267
  : undefined;
268
+ if (options.nativeControl && nativeResponseControlEligible(route.provider, options.nativeControl)
269
+ && options.inboundTransport === "websocket" && !options.comboAttempt) {
270
+ const body = parsed._rawBody as Record<string, unknown>;
271
+ if (options.nativeControl.kind === "steering") {
272
+ options.nativeControl.normalizeContinuation = createSteeringSettingsNormalizer(parsed, route, config, req.headers);
273
+ }
274
+ const Replay = options.nativeControl.kind === "injection" ? NativeInjectionReplay : NativeSteeringReplay;
275
+ options.nativeControl.replayFactory = () => new Replay(body.input, (input, response) => {
276
+ if (passthroughRecordEligible && !isBodyNonPersistable(body)) {
277
+ rememberResponseState({ ...body, input }, response, undefined, responseStateOptions(true));
278
+ }
279
+ });
280
+ }
254
281
  if (parsed.previousResponseId && !parsed._previousResponseInputExpanded) {
255
282
  console.warn(
256
283
  `[responses] previous_response_id ${parsed.previousResponseId} not found in local replay state `
@@ -648,6 +675,33 @@ export async function preparePassthroughExchange(
648
675
  linkAbortSignal(upstream, options.abortSignal);
649
676
  const connectMs = config.connectTimeoutMs ?? 200_000;
650
677
  let upstreamResponse: Response;
678
+ /**
679
+ * This leg's transient-5xx ladder cap, from the provider's own `transientRetryOn5xx`.
680
+ *
681
+ * The lane used to pass `TRANSIENT_RETRY_MAX_ATTEMPTS` at every call site, so an operator who
682
+ * configured the option on a key-auth `openai-responses` provider changed nothing in either
683
+ * direction, while the same provider on `openai-chat` was tuned normally. That asymmetry is
684
+ * #4893. The gate in `transientRetryPolicyFor` returns null for OAuth and forward providers,
685
+ * so the ChatGPT pool keeps exactly the ladder it has always had.
686
+ *
687
+ * Read per call rather than captured once, for two reasons. `route.provider` is reassigned
688
+ * inside the recovery loop by credential rotation and transport resolution, so a hoisted
689
+ * policy could outlive the provider row it came from. And the configured value is a total for
690
+ * the whole request, so it has to be measured against what the request has already sent at
691
+ * the moment each leg asks.
692
+ *
693
+ * Still bounded by the request: every site feeds this to `remainingTransientSendBudget` or
694
+ * `recoverySendAllowance`, which intersect it with the request-wide base allowance. So a
695
+ * provider can narrow this request's sends exactly and cannot widen the bound that exists to
696
+ * stop per-request amplification (#4546).
697
+ */
698
+ const transientSendPolicy = () => transientRetryPolicyFor(route.provider);
699
+ const transientSendAttempts = (): number => transientSendCapFor(
700
+ transientSendPolicy()?.attempts,
701
+ sendBudgetState.sendsUsed,
702
+ );
703
+ const configuredTransientSendBudgetExhausted = (): boolean =>
704
+ transientSendPolicy() !== null && transientSendAttempts() === 0;
651
705
  /**
652
706
  * Refuse a built body that exceeds the operator's configured ceiling, before it is sent.
653
707
  *
@@ -772,6 +826,9 @@ export async function preparePassthroughExchange(
772
826
  body: request.body,
773
827
  }, recovery), upstream.signal, connectMs, parsed.stream,
774
828
  providerFetch(route.provider, options.codexWsRuntimeIdentity, {
829
+ nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt
830
+ && responseEffects.plaintextV2AgentMessageToolNames.size === 0
831
+ ? options.nativeControl : undefined,
775
832
  dispatchOverride: oauthDispatch(request),
776
833
  providerName: route.providerName,
777
834
  modelId: route.modelId,
@@ -784,7 +841,7 @@ export async function preparePassthroughExchange(
784
841
  // retry wrapper replaces — proves the host was reached (#914 review).
785
842
  .then(adoptObservedResponse);
786
843
  },
787
- { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(TRANSIENT_RETRY_MAX_ATTEMPTS), onSendsConsumed: noteTransientSends },
844
+ { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(transientSendAttempts()), onSendsConsumed: noteTransientSends },
788
845
  );
789
846
  } catch (err) {
790
847
  return transportFailureResponse(err);
@@ -844,14 +901,15 @@ export async function preparePassthroughExchange(
844
901
  recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, retryAdapter.name);
845
902
  const rebuiltBodyRefusal = refuseOversizedOutboundBody(request);
846
903
  if (rebuiltBodyRefusal) return { failed: rebuiltBodyRefusal };
847
- // The base allowance is spent first; once it is gone this leg may still draw the one
848
- // shared final-recovery reserve, which is what keeps a validated sanitized rebuild
849
- // after a 5xx streak alive at four total sends instead of dying at three. Reserved
850
- // outside the try so the finally can hand it back if the leg never reached its send.
904
+ // The base allowance is spent first. An unconfigured provider may then draw the one shared
905
+ // final-recovery reserve, which keeps a validated sanitized rebuild after a 5xx streak alive
906
+ // at four total sends instead of dying at three. An explicit provider total cannot widen.
907
+ // Reserve outside the try so the finally can hand it back if the leg never reaches its send.
851
908
  const allowance = recoverySendAllowance(
852
- TRANSIENT_RETRY_MAX_ATTEMPTS,
909
+ transientSendAttempts(),
853
910
  recoveryClassFor(recovery),
854
911
  `${route.providerName}|${route.modelId}|${recovery}`,
912
+ { allowFinalRecoveryReserve: transientSendPolicy() === null },
855
913
  );
856
914
  try {
857
915
  return await fetchWithTransientRetry(
@@ -868,6 +926,9 @@ export async function preparePassthroughExchange(
868
926
  body: request.body,
869
927
  }, innerRecovery), upstream.signal, connectMs, parsed.stream,
870
928
  providerFetch(route.provider, options.codexWsRuntimeIdentity, {
929
+ nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt
930
+ && responseEffects.plaintextV2AgentMessageToolNames.size === 0
931
+ ? options.nativeControl : undefined,
871
932
  dispatchOverride: oauthDispatch(request),
872
933
  providerName: route.providerName,
873
934
  modelId: route.modelId,
@@ -974,6 +1035,9 @@ export async function preparePassthroughExchange(
974
1035
  // here on is a genuine transport attempt.
975
1036
  storedPoolReplayDispatchNotifier(
976
1037
  providerFetch(route.provider, options.codexWsRuntimeIdentity, {
1038
+ nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt
1039
+ && responseEffects.plaintextV2AgentMessageToolNames.size === 0
1040
+ ? options.nativeControl : undefined,
977
1041
  dispatchOverride: oauthDispatch(request),
978
1042
  providerName: route.providerName,
979
1043
  modelId: route.modelId,
@@ -1007,7 +1071,7 @@ export async function preparePassthroughExchange(
1007
1071
  // Refused here, before the 401 body is cancelled: once it is gone the request can only
1008
1072
  // answer with a synthetic 502, which would report a proxy budget decision as an upstream
1009
1073
  // fault and throw away the credential evidence the client needs.
1010
- && !sendBudgetExhausted()
1074
+ && !sendBudgetExhausted(transientSendAttempts())
1011
1075
  ) {
1012
1076
  oauth401ReplayAttempted = true;
1013
1077
  try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
@@ -1095,6 +1159,9 @@ export async function preparePassthroughExchange(
1095
1159
  body: request.body,
1096
1160
  }, recovery), upstream.signal, connectMs, parsed.stream,
1097
1161
  providerFetch(route.provider, options.codexWsRuntimeIdentity, {
1162
+ nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt
1163
+ && responseEffects.plaintextV2AgentMessageToolNames.size === 0
1164
+ ? options.nativeControl : undefined,
1098
1165
  dispatchOverride: oauthDispatch(request),
1099
1166
  providerName: route.providerName,
1100
1167
  modelId: route.modelId,
@@ -1105,7 +1172,7 @@ export async function preparePassthroughExchange(
1105
1172
  route.provider.authMode === "forward")
1106
1173
  .then(adoptObservedResponse);
1107
1174
  },
1108
- { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(TRANSIENT_RETRY_MAX_ATTEMPTS), onSendsConsumed: noteTransientSends },
1175
+ { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(transientSendAttempts()), onSendsConsumed: noteTransientSends },
1109
1176
  );
1110
1177
  } catch (err) {
1111
1178
  return transportFailureResponse(err);
@@ -1116,13 +1183,13 @@ export async function preparePassthroughExchange(
1116
1183
 
1117
1184
  // Native Responses returns before the generic adapter's OAuth rotation loop. Keep
1118
1185
  // the same quorum, cooldown and request budget here, before any client bytes flow.
1119
- if (
1120
- upstreamResponse.status === 429
1186
+ if (
1187
+ upstreamResponse.status === 429
1121
1188
  // Not a provider rate limit when this proxy synthesized it for a refused reset
1122
1189
  // replay; rotating accounts on it would re-send an inference that may already
1123
1190
  // have run and would cool down an account that refused nothing.
1124
1191
  && !isNonReplayableResponse(upstreamResponse)
1125
- && transportState.genericFailoverAccountId
1192
+ && transportState.genericFailoverAccountId
1126
1193
  && transportState.genericFailovers < GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST
1127
1194
  && isGenericOAuthFailoverEnabled(config, route.providerName)
1128
1195
  ) {
@@ -1138,6 +1205,8 @@ export async function preparePassthroughExchange(
1138
1205
  const nextAccountId = rotateGenericOAuthAccountOn429(
1139
1206
  config, route.providerName, transportState.genericFailoverAccountId,
1140
1207
  upstreamResponse.headers.get("retry-after"),
1208
+ Date.now(),
1209
+ route.modelId,
1141
1210
  );
1142
1211
  let snapshot: OAuthAccessSnapshot | undefined;
1143
1212
  if (nextAccountId) {
@@ -1165,6 +1234,11 @@ export async function preparePassthroughExchange(
1165
1234
  }
1166
1235
  // No credential moved, so the reservation costs nothing.
1167
1236
  hop.permit?.release();
1237
+ } else {
1238
+ // Rotation was available -- the roster cap above admitted it -- and the shared request
1239
+ // budget refused. Recorded so a one-send log is not read as "nothing was eligible",
1240
+ // which is the ambiguity this attribution exists to remove (#5044).
1241
+ noteAttemptRecoveryWithheld(logCtx.activeAttempt, "rotation-send-budget");
1168
1242
  }
1169
1243
  }
1170
1244
 
@@ -1174,15 +1248,15 @@ export async function preparePassthroughExchange(
1174
1248
  // immediately with no same-key replay. Pre-stream only — nothing has been relayed yet, so
1175
1249
  // the replay is lossless (same invariant as the recovery loop). Forward/OAuth providers
1176
1250
  // keep their pool logic below (rateLimitRetryPolicyFor returns null for them).
1177
- while (
1178
- upstreamResponse.status === 429
1251
+ while (
1252
+ upstreamResponse.status === 429
1179
1253
  && !isNonReplayableResponse(upstreamResponse)
1180
- && rateLimitPolicy !== null
1254
+ && rateLimitPolicy !== null
1181
1255
  && rateLimitRetries < rateLimitPolicy.attempts
1182
1256
  // Checked here rather than inside the helper: prepareSameTarget429Wait releases the 429
1183
1257
  // body, so a refusal discovered after the wait can no longer return the real rate-limit
1184
1258
  // answer and would surface a synthetic 502 instead.
1185
- && !sendBudgetExhausted()
1259
+ && !sendBudgetExhausted(transientSendAttempts())
1186
1260
  ) {
1187
1261
  rateLimitRetries += 1;
1188
1262
  // Release unread body + deliberate wait via the shared same-target helper.
@@ -1217,6 +1291,9 @@ export async function preparePassthroughExchange(
1217
1291
  body: request.body,
1218
1292
  }, recovery), upstream.signal, connectMs, parsed.stream,
1219
1293
  providerFetch(route.provider, options.codexWsRuntimeIdentity, {
1294
+ nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt
1295
+ && responseEffects.plaintextV2AgentMessageToolNames.size === 0
1296
+ ? options.nativeControl : undefined,
1220
1297
  dispatchOverride: oauthDispatch(request),
1221
1298
  providerName: route.providerName,
1222
1299
  modelId: route.modelId,
@@ -1227,7 +1304,7 @@ export async function preparePassthroughExchange(
1227
1304
  route.provider.authMode === "forward")
1228
1305
  .then(adoptObservedResponse);
1229
1306
  },
1230
- { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(TRANSIENT_RETRY_MAX_ATTEMPTS), onSendsConsumed: noteTransientSends },
1307
+ { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(transientSendAttempts()), onSendsConsumed: noteTransientSends },
1231
1308
  );
1232
1309
  } catch (err) {
1233
1310
  return transportFailureResponse(err);
@@ -1259,11 +1336,25 @@ export async function preparePassthroughExchange(
1259
1336
 
1260
1337
  if (usesCodexForwardPoolAuth(admissionState.authCtx, route.provider)) {
1261
1338
  let poolRetryOutcome: number | undefined;
1262
- if (await shouldRetryCodexPoolAccountModel400(
1339
+ // A success is the freshest evidence there is about this pair, and it outranks any earlier
1340
+ // refusal: whatever the entitlement was when upstream declined, it is not that now. Both
1341
+ // ids are cleared because the wire model can differ from the routed one.
1342
+ if (upstreamResponse.ok) {
1343
+ clearCodexModelDenialEvidence(admissionState.authCtx.accountId, route.modelId);
1344
+ clearCodexModelDenialEvidence(admissionState.authCtx.accountId, parsed.modelId);
1345
+ }
1346
+ const model400Denial = await codexPoolAccountModel400Denial(
1263
1347
  upstreamResponse,
1264
1348
  route.modelId,
1265
1349
  options.abortSignal,
1266
- )) {
1350
+ parsed.modelId,
1351
+ );
1352
+ if (model400Denial !== undefined) {
1353
+ // Spend this refusal on more than one retry. It is the account's own authenticated
1354
+ // answer about this model, and the roster cache that selection otherwise reads expires
1355
+ // five minutes after a catalog sync fills it -- so without remembering this, the next
1356
+ // request selects the same account on quota alone and takes the same 400 (#4906).
1357
+ recordCodexModelDenialEvidence(admissionState.authCtx.accountId, model400Denial);
1267
1358
  poolRetryOutcome = 400;
1268
1359
  } else if (!admissionState.authCtx.fixedAccount && await shouldRetryCodexPoolAccountQuota(
1269
1360
  upstreamResponse,
@@ -1333,18 +1424,20 @@ export async function preparePassthroughExchange(
1333
1424
  // eviction, or an older transcript). Inspect only a bounded clone of a 4xx whose exact outbound
1334
1425
  // Responses body still carries opaque state, then rebuild once through the ordinary adapter
1335
1426
  // sanitation path. A second rejection falls through unchanged because the guard stays armed.
1336
- const opaqueBlobRecovery = await attemptOpaqueBlobRecovery({
1337
- response: upstreamResponse,
1338
- outboundBody: request.body,
1339
- adapterName: transportState.adapter.name,
1340
- parsed,
1341
- guard: opaqueBlobRecoveryGuard,
1342
- signal: upstream.signal,
1343
- }, rebuildAndRefetch);
1344
- if (opaqueBlobRecovery.kind === "failed") return opaqueBlobRecovery.response;
1345
- if (opaqueBlobRecovery.kind === "recovered") {
1346
- upstreamResponse = opaqueBlobRecovery.response;
1347
- continue passthroughRecovery;
1427
+ if (!configuredTransientSendBudgetExhausted()) {
1428
+ const opaqueBlobRecovery = await attemptOpaqueBlobRecovery({
1429
+ response: upstreamResponse,
1430
+ outboundBody: request.body,
1431
+ adapterName: transportState.adapter.name,
1432
+ parsed,
1433
+ guard: opaqueBlobRecoveryGuard,
1434
+ signal: upstream.signal,
1435
+ }, rebuildAndRefetch);
1436
+ if (opaqueBlobRecovery.kind === "failed") return opaqueBlobRecovery.response;
1437
+ if (opaqueBlobRecovery.kind === "recovered") {
1438
+ upstreamResponse = opaqueBlobRecovery.response;
1439
+ continue passthroughRecovery;
1440
+ }
1348
1441
  }
1349
1442
 
1350
1443
  const recoveryContentType = upstreamResponse.headers.get("content-type")?.toLowerCase() ?? "";
@@ -1352,6 +1445,7 @@ export async function preparePassthroughExchange(
1352
1445
  && !!upstreamResponse.body
1353
1446
  && (recoveryContentType.includes("text/event-stream") || (!recoveryContentType && parsed.stream))
1354
1447
  && !opaqueBlobRecoveryGuard.attempted
1448
+ && !configuredTransientSendBudgetExhausted()
1355
1449
  && outboundResponsesBodyCarriesEncryptedFunctionOutput(request.body);
1356
1450
  if (streamedFunctionOutputCandidate) {
1357
1451
  const preflightLog: RequestLogContext = { model: logCtx.model, provider: logCtx.provider };
@@ -1368,19 +1462,21 @@ export async function preparePassthroughExchange(
1368
1462
  if (options.abortSignal?.aborted) return transportFailureResponse(options.abortSignal.reason);
1369
1463
  upstreamResponse = preflight.response;
1370
1464
  if (preflight.kind === "failed") {
1371
- const streamedOpaqueRecovery = await attemptOpaqueBlobRecovery({
1372
- response: upstreamResponse,
1373
- outboundBody: request.body,
1374
- adapterName: transportState.adapter.name,
1375
- parsed,
1376
- guard: opaqueBlobRecoveryGuard,
1377
- signal: upstream.signal,
1378
- }, rebuildAndRefetch);
1379
- if (streamedOpaqueRecovery.kind === "failed") return streamedOpaqueRecovery.response;
1380
- if (streamedOpaqueRecovery.kind === "recovered") {
1381
- resetStreamedOpaqueBlobLogContext(logCtx);
1382
- upstreamResponse = streamedOpaqueRecovery.response;
1383
- continue passthroughRecovery;
1465
+ if (!configuredTransientSendBudgetExhausted()) {
1466
+ const streamedOpaqueRecovery = await attemptOpaqueBlobRecovery({
1467
+ response: upstreamResponse,
1468
+ outboundBody: request.body,
1469
+ adapterName: transportState.adapter.name,
1470
+ parsed,
1471
+ guard: opaqueBlobRecoveryGuard,
1472
+ signal: upstream.signal,
1473
+ }, rebuildAndRefetch);
1474
+ if (streamedOpaqueRecovery.kind === "failed") return streamedOpaqueRecovery.response;
1475
+ if (streamedOpaqueRecovery.kind === "recovered") {
1476
+ resetStreamedOpaqueBlobLogContext(logCtx);
1477
+ upstreamResponse = streamedOpaqueRecovery.response;
1478
+ continue passthroughRecovery;
1479
+ }
1384
1480
  }
1385
1481
  logCtx.upstreamError = preflightLog.upstreamError;
1386
1482
  logCtx.terminalHttpStatus = preflightLog.terminalHttpStatus;
@@ -1399,6 +1495,7 @@ export async function preparePassthroughExchange(
1399
1495
  upstream.signal,
1400
1496
  );
1401
1497
  if (uploadRejectionBody !== undefined
1498
+ && !configuredTransientSendBudgetExhausted()
1402
1499
  && isTransientConsoleGoUploadRejection({
1403
1500
  status: upstreamResponse.status,
1404
1501
  errorBody: uploadRejectionBody,
@@ -1437,7 +1534,7 @@ export async function preparePassthroughExchange(
1437
1534
  requested: parsed.options.reasoning,
1438
1535
  rejectionText,
1439
1536
  });
1440
- if (downgrade) {
1537
+ if (downgrade && !configuredTransientSendBudgetExhausted()) {
1441
1538
  reasoningEffortDowngradeGuard.attempted = true;
1442
1539
  parsed.options.reasoning = downgrade.effort;
1443
1540
  try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
@@ -10,6 +10,8 @@ import type { ResponsesEffects } from "./response-effects";
10
10
  import type { ResponsesSendBudget } from "./request-send-budget";
11
11
  import { preparePassthroughExchange } from "./passthrough-dispatch";
12
12
  import { deliverPassthroughResponse } from "./passthrough-delivery";
13
+ import { guardDirectPassthroughBodyInactivity } from "../../lib/response-body-inactivity";
14
+ import { resolveStallTimeoutSec } from "../../stall-timeout";
13
15
  import { releaseUpstreamHostAdmission } from "../../codex/upstream-host-health";
14
16
  import { releaseCodexAuthContextProbeLease } from "../../codex/auth-context";
15
17
 
@@ -36,7 +38,7 @@ export async function executePassthroughResponse(
36
38
  sendBudgetState,
37
39
  );
38
40
  if (nativeExchange instanceof Response) return nativeExchange;
39
- return await deliverPassthroughResponse(
41
+ const response = await deliverPassthroughResponse(
40
42
  requestContext,
41
43
  admissionState,
42
44
  requestState,
@@ -45,6 +47,14 @@ export async function executePassthroughResponse(
45
47
  responseEffects,
46
48
  nativeExchange,
47
49
  );
50
+ // Delivery has already classified SSE (including a missing upstream content type)
51
+ // and consumed bounded JSON/errors. Guard only its remaining direct body, outside
52
+ // the relay/lifetime wrappers so their prefetch cannot arm our inactivity clock.
53
+ return guardDirectPassthroughBodyInactivity(
54
+ response,
55
+ nativeExchange.upstream.signal,
56
+ resolveStallTimeoutSec(requestContext.config.stallTimeoutSec) * 1000,
57
+ );
48
58
  } finally {
49
59
  if (nativeHostState.lease) {
50
60
  releaseUpstreamHostAdmission(nativeHostState.lease);
@@ -36,6 +36,7 @@ import {
36
36
  previewCodexPoolLineage,
37
37
  applyCodexAuthContextToProvider,
38
38
  hasCallerCodexBearer,
39
+ requestOwnedMainPinState,
39
40
  } from "../../codex/auth-context";
40
41
  import {
41
42
  copyPreviousResponseReplayProvenance,
@@ -104,7 +105,9 @@ import { hasUnmappedRoutedCustomToolOutput } from "../../responses/custom-tool-c
104
105
  import { PROVIDER_OWNED_CONTINUATION_WIRES, resolvedAdapterWire } from "../../responses/continuation-ownership";
105
106
  import {
106
107
  isCodexReserveHelperUnsupported,
108
+ isCodexReserveOptInMissing,
107
109
  CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE,
110
+ CODEX_RESERVE_OPT_IN_REQUIRED_MESSAGE,
108
111
  } from "../../codex/loopback-target";
109
112
  import { checkComboTargetInputAdmission, checkInputAdmission } from "./input-admission";
110
113
  import { nativeContextLimits } from "../../codex/catalog";
@@ -230,6 +233,10 @@ export async function prepareResponsesRequest(
230
233
  (body as { input?: unknown } | undefined)?.input,
231
234
  );
232
235
  const inboundClientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined;
236
+ // The request's OWN thread, which `x-codex-parent-thread-id` is not: parallel children of one
237
+ // parent all present the same parent id. `codexConversationIdentity` already reads this header
238
+ // for the same reason, and a surface that must tell siblings apart needs it too (#5033).
239
+ const inboundOwnThreadId = req.headers.get("thread-id")?.trim() || undefined;
233
240
  const cursorClientThreadId = codexPoolAffinityKey(req.headers);
234
241
  const originalBody = body;
235
242
  if (options.comboReplaySnapshot) {
@@ -302,6 +309,7 @@ export async function prepareResponsesRequest(
302
309
  ? options.comboReplaySnapshot.providerContinuation
303
310
  : previousResponseProviderState(parsed.previousResponseId);
304
311
  if (providerContinuationCandidate) parsed._providerContinuationCandidate = providerContinuationCandidate;
312
+ if (inboundOwnThreadId) parsed._codexOwnThreadId = inboundOwnThreadId;
305
313
  if (inboundClientThreadId) {
306
314
  parsed._clientThreadId = inboundClientThreadId;
307
315
  } else if (
@@ -483,11 +491,37 @@ export async function prepareResponsesRequest(
483
491
  const nativeMainReadsForbidden = previewRequestScopedMainCredential
484
492
  || nativeMainRecoveryBlocked
485
493
  || previewSelectionAdmission?.mainProfileDraining === true;
494
+ // The liveness answer final authentication gives its own selection options, computed from the
495
+ // same shared predicate so the two cannot drift apart again (#4850). `fixedAccountId` is
496
+ // mirrored through `route.codexAccountId` because that is literally what core-auth.ts passes
497
+ // as `accountId`. A reserve-authorized request is the one input where the two can differ, and
498
+ // it differs harmlessly: reserve plus a caller bearer is served as main either way, which is
499
+ // the answer this produces.
500
+ const previewRequestOwnedMainPin = requestOwnedMainPinState(
501
+ previewAuthHeaders,
502
+ config,
503
+ options.codexAuthPolicy ?? config,
504
+ previewRequestScopedMainCredential,
505
+ route.codexAccountId,
506
+ ).preserve;
486
507
  // Deliberately NOT fenced on ownership: final auth derives `nativeMainSelectionOnly` from the
487
508
  // drain alone, and adding a term here would diverge from it in the other direction.
488
509
  const previewSelectionOptions = {
489
510
  nativeMainSelectionOnly: !nativeMainRecoveryBlocked
490
511
  && previewSelectionAdmission?.mainProfileDraining === true,
512
+ // Pool eligibility was the last part of preview still outside the fence (#4850). Without
513
+ // this seam `codexAccountUnusableReason` takes its default branch into
514
+ // `isMainAccountCredentialUsable()`, which opens the physical `auth.json` -- twice per
515
+ // spawn, because subagent fallback re-enters the preview through the callback below.
516
+ //
517
+ // Scoped to ownership, and carrying final auth's value rather than a constant, because
518
+ // preview exists to predict final auth. Under an effective main pin the request really is
519
+ // served by its own main credential, so main must stay eligible; without the pin final auth
520
+ // scores main `main_credential_unavailable` and drops it, so preview has to drop it too. A
521
+ // hardcoded `true` would be wrong in the second case and `false` in the first.
522
+ isMainAccountTokenLive: previewRequestScopedMainCredential
523
+ ? () => previewRequestOwnedMainPin
524
+ : undefined,
491
525
  // Preview must reach the same answer as the final resolution, including the uploaded-file
492
526
  // retention (#4778): a preview that reported a quota move the request will not make would
493
527
  // hand subagent fallback a different account than the one that actually serves.
@@ -672,6 +706,7 @@ export async function prepareResponsesRequest(
672
706
  "_providerContinuationOwner",
673
707
  "_cursorConversationId",
674
708
  "_clientThreadId",
709
+ "_codexOwnThreadId",
675
710
  "_promptCacheKeyIsSharedCohort",
676
711
  "_cursorClientThreadId",
677
712
  "_reasoningReplayScope",
@@ -711,9 +746,23 @@ export async function prepareResponsesRequest(
711
746
  route,
712
747
  options,
713
748
  ).requestScopedMainCredential && hasCallerCodexBearer(recoveryAuthHeaders);
749
+ // Recovery's own answer to the same question, against the route it may have moved
750
+ // to. Reconstructing the options without it is what left pool eligibility outside
751
+ // the fence on the first preview (#4850); recovery re-previews, so it would leave
752
+ // the same two reads on the one path that runs after decryption.
753
+ const recoveryRequestOwnedMainPin = requestOwnedMainPinState(
754
+ recoveryAuthHeaders,
755
+ config,
756
+ options.codexAuthPolicy ?? config,
757
+ recoveryRequestScopedMainCredential,
758
+ route.codexAccountId,
759
+ ).preserve;
714
760
  const recoverySelectionOptions = {
715
761
  nativeMainSelectionOnly: !recoveryNativeMainBlocked
716
762
  && recoverySelectionAdmission?.mainProfileDraining === true,
763
+ isMainAccountTokenLive: recoveryRequestScopedMainCredential
764
+ ? () => recoveryRequestOwnedMainPin
765
+ : undefined,
717
766
  // #4778, same reason as `previewSelectionOptions` above: this preview decides
718
767
  // which account subagent fallback scores against, and final auth passes the
719
768
  // retention. Recovery is exactly where the two could diverge -- it re-previews
@@ -935,6 +984,27 @@ export async function prepareResponsesRequest(
935
984
  options.admission, options.visionDescribeTerminal === true)) {
936
985
  return formatErrorResponse(400, "invalid_request_error", CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE);
937
986
  }
987
+ // #4940: the opt-in is off, so every Reserve affordance in this process is inert -- no catalog
988
+ // row, no main-credential substitution, no authorization handshake, and no `luna-reserve` header
989
+ // on the send. Forwarding `gpt-reserve` as an ordinary native model therefore buys nothing but a
990
+ // 429 "The usage limit has been reached", which names neither the real cause nor the setting the
991
+ // operator would have to change. Refuse here instead, on the same terms and in the same place as
992
+ // the helper refusal above: after alias/combo resolution, before auth, host-circuit budget or any
993
+ // upstream byte.
994
+ //
995
+ // Two narrowings beyond the predicate, both about not answering a question this refusal cannot
996
+ // answer correctly. A terminal vision/search helper is excluded because enabling the opt-in would
997
+ // not make it work -- it would produce the helper refusal above instead, so telling that caller to
998
+ // enable the flag is advice that does not hold. Non-native inbound wires are excluded because a
999
+ // `gpt-reserve` selector reaching us over Chat or Anthropic Messages is an operator-authored
1000
+ // route (a `claudeCode.modelMap` entry, say), not a Codex client that was forced onto Reserve by
1001
+ // its own usage snapshot, and that route keeps whatever behavior it has today.
1002
+ if (inboundWire === "responses"
1003
+ && options.visionDescribeTerminal !== true
1004
+ && isCanonicalOpenAiForwardProvider(route.provider)
1005
+ && isCodexReserveOptInMissing(options.codexAuthPolicy ?? config, route.modelId, options.admission)) {
1006
+ return formatErrorResponse(400, "invalid_request_error", CODEX_RESERVE_OPT_IN_REQUIRED_MESSAGE);
1007
+ }
938
1008
  // Refuse an input that cannot plausibly fit the model context window before spending auth,
939
1009
  // circuit budget, or upstream bandwidth on a turn the provider will reject anyway (#1412).
940
1010
  //