@bitkyc08/opencodex 2.56.0 → 2.58.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 (206) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-D4zuyIxQ.js → index-BbrHOIY0.js} +21 -21
  3. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/adapters/codebuddy/adapter.ts +2 -1
  7. package/src/adapters/codebuddy/scaffold-guard.ts +249 -0
  8. package/src/adapters/command-code.ts +12 -3
  9. package/src/adapters/cursor/cursor-errors.ts +15 -0
  10. package/src/adapters/cursor/discovery.ts +65 -1
  11. package/src/adapters/cursor/envelope-echo.ts +8 -2
  12. package/src/adapters/cursor/live-transport.ts +5 -1
  13. package/src/adapters/cursor/protobuf-events.ts +110 -11
  14. package/src/adapters/cursor/protobuf-request.ts +19 -1
  15. package/src/adapters/cursor/text-toolcall.ts +230 -0
  16. package/src/adapters/cursor/thread-continuity.ts +67 -0
  17. package/src/adapters/cursor/types.ts +5 -0
  18. package/src/adapters/cursor.ts +55 -5
  19. package/src/adapters/google-http.ts +38 -13
  20. package/src/adapters/google.ts +7 -7
  21. package/src/adapters/kiro/payload.ts +17 -3
  22. package/src/adapters/kiro/reasoning.ts +70 -7
  23. package/src/adapters/kiro/stream.ts +8 -2
  24. package/src/adapters/kiro/wire.ts +2 -1
  25. package/src/adapters/kiro-events.ts +21 -13
  26. package/src/adapters/mimo-free.ts +32 -17
  27. package/src/adapters/ollama-native.ts +42 -8
  28. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  29. package/src/adapters/openai-chat/tool-schema.ts +25 -7
  30. package/src/adapters/openai-chat.ts +8 -8
  31. package/src/adapters/openai-responses/passthrough.ts +62 -5
  32. package/src/adapters/openai-responses/request-strips.ts +43 -0
  33. package/src/adapters/physical-send.ts +50 -0
  34. package/src/bridge/errors.ts +26 -2
  35. package/src/bridge/response-json.ts +8 -2
  36. package/src/bridge/sse.ts +20 -2
  37. package/src/claude/desktop-profile.ts +66 -9
  38. package/src/claude/outbound.ts +32 -4
  39. package/src/cli/account-main.ts +1 -1
  40. package/src/cli/capabilities.ts +2 -2
  41. package/src/cli/combo.ts +10 -1
  42. package/src/cli/config-command.ts +35 -18
  43. package/src/cli/dispatch.ts +17 -4
  44. package/src/cli/index.ts +92 -7
  45. package/src/cli/registry.ts +2 -1
  46. package/src/cli/system-command.ts +74 -5
  47. package/src/cli/uninstall-client-state.ts +12 -0
  48. package/src/clients/config-export.ts +7 -3
  49. package/src/codex/account-label.ts +14 -3
  50. package/src/codex/account-store.ts +113 -26
  51. package/src/codex/account-usability.ts +21 -0
  52. package/src/codex/auth-api/login-flow.ts +14 -2
  53. package/src/codex/auth-api/reset-credit-service.ts +11 -2
  54. package/src/codex/auth-context.ts +199 -15
  55. package/src/codex/catalog/aggregation.ts +80 -1
  56. package/src/codex/catalog/model-visibility.ts +1 -0
  57. package/src/codex/catalog/remote.ts +30 -0
  58. package/src/codex/catalog/retained-sync.ts +9 -1
  59. package/src/codex/catalog/routed-gather.ts +38 -1
  60. package/src/codex/cli-install-provenance.ts +7 -1
  61. package/src/codex/convergence.ts +7 -2
  62. package/src/codex/desktop-app/types.ts +11 -2
  63. package/src/codex/desktop-app/windows.ts +5 -5
  64. package/src/codex/desktop-switches.ts +145 -0
  65. package/src/codex/history-job.ts +5 -1
  66. package/src/codex/history-provider.ts +33 -4
  67. package/src/codex/history-worker.ts +14 -1
  68. package/src/codex/inject/remove.ts +145 -7
  69. package/src/codex/inject/restore.ts +231 -32
  70. package/src/codex/inject.ts +12 -16
  71. package/src/codex/loopback-target.ts +9 -0
  72. package/src/codex/model-entitlements.ts +152 -15
  73. package/src/codex/native-profile-startup.ts +64 -20
  74. package/src/codex/pool-refresh-backoff.ts +12 -3
  75. package/src/codex/quota-rejection.ts +104 -15
  76. package/src/codex/routing/cache-affinity.ts +70 -0
  77. package/src/codex/routing/cooldown-math.ts +10 -0
  78. package/src/codex/routing/selection.ts +79 -2
  79. package/src/codex/routing/thread-affinity.ts +50 -2
  80. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  81. package/src/codex/routing.ts +29 -49
  82. package/src/codex/warmup.ts +1 -1
  83. package/src/combos/failover.ts +85 -0
  84. package/src/combos/request.ts +17 -10
  85. package/src/combos/types.ts +23 -2
  86. package/src/config/atomic-write.ts +83 -8
  87. package/src/config/pending-teardown.ts +31 -0
  88. package/src/config/schema/config-schema.ts +2 -0
  89. package/src/config/schema/leaf-validators.ts +1 -0
  90. package/src/generated/compatibility-version.json +272 -180
  91. package/src/images/loop.ts +1 -1
  92. package/src/lib/bounded-subprocess.ts +62 -10
  93. package/src/lib/errors.ts +17 -0
  94. package/src/lib/request-execution-budget.ts +147 -21
  95. package/src/lib/spend-reservation-ledger.ts +18 -0
  96. package/src/lib/state-store-registrations.ts +6 -2
  97. package/src/lib/test-home-guard.ts +85 -1
  98. package/src/lib/upstream-retry.ts +77 -10
  99. package/src/lib/windows-elevation.ts +76 -14
  100. package/src/lib/windows-secret-acl.ts +151 -15
  101. package/src/lib/windows-user-principal.ts +5 -1
  102. package/src/oauth/index.ts +2 -2
  103. package/src/oauth/key-providers.ts +2 -2
  104. package/src/providers/derive.ts +6 -0
  105. package/src/providers/kiro-models.ts +4 -3
  106. package/src/providers/label.ts +19 -1
  107. package/src/providers/model-discovery.ts +35 -7
  108. package/src/providers/registry/entries-core.ts +18 -0
  109. package/src/providers/registry/entries-extended.ts +59 -28
  110. package/src/providers/registry/model-seeds.ts +71 -17
  111. package/src/providers/registry/types.ts +9 -0
  112. package/src/responses/reasoning-envelope.ts +6 -3
  113. package/src/responses/spill-store.ts +17 -0
  114. package/src/responses/state/body-policy.ts +25 -0
  115. package/src/responses/state/spill-queue.ts +8 -6
  116. package/src/responses/state.ts +3 -22
  117. package/src/router.ts +4 -0
  118. package/src/routing/identity-domains.ts +21 -14
  119. package/src/routing/probe-lease.ts +103 -1
  120. package/src/server/auth-cors.ts +1 -0
  121. package/src/server/chat-completions.ts +3 -1
  122. package/src/server/chat-native.ts +37 -9
  123. package/src/server/index/live-sideband.ts +37 -1
  124. package/src/server/index/websocket-handler.ts +54 -3
  125. package/src/server/index.ts +5 -5
  126. package/src/server/inspection-tee.ts +107 -0
  127. package/src/server/live.ts +46 -1
  128. package/src/server/management/combo-routes.ts +10 -1
  129. package/src/server/management/config-routes.ts +27 -5
  130. package/src/server/models-capabilities.ts +24 -3
  131. package/src/server/relay-eager.ts +2 -0
  132. package/src/server/relay.ts +14 -19
  133. package/src/server/request-log.ts +127 -3
  134. package/src/server/response-log-body.ts +153 -0
  135. package/src/server/responses/account-change-state.ts +74 -0
  136. package/src/server/responses/adapter-continuation.ts +33 -7
  137. package/src/server/responses/adapter-delivery.ts +5 -11
  138. package/src/server/responses/adapter-dispatch.ts +84 -13
  139. package/src/server/responses/codex-ws-exchange.ts +65 -4
  140. package/src/server/responses/codex-ws-wire.ts +5 -0
  141. package/src/server/responses/collaboration.ts +74 -4
  142. package/src/server/responses/combo-session-recall.ts +68 -8
  143. package/src/server/responses/combo-stream-preflight.ts +68 -5
  144. package/src/server/responses/compact.ts +54 -13
  145. package/src/server/responses/core-auth.ts +2 -0
  146. package/src/server/responses/core-codex-account.ts +51 -3
  147. package/src/server/responses/core-combo.ts +129 -23
  148. package/src/server/responses/core-errors.ts +18 -0
  149. package/src/server/responses/core-options.ts +3 -0
  150. package/src/server/responses/core-replay.ts +105 -32
  151. package/src/server/responses/core.ts +3 -3
  152. package/src/server/responses/encrypted-payload.ts +0 -1
  153. package/src/server/responses/fetch-helpers.ts +4 -1
  154. package/src/server/responses/input-admission.ts +126 -6
  155. package/src/server/responses/native-injection-protocol.ts +42 -0
  156. package/src/server/responses/native-injection-replay.ts +105 -0
  157. package/src/server/responses/native-injection.ts +242 -0
  158. package/src/server/responses/native-response-control.ts +56 -0
  159. package/src/server/responses/native-response-json.ts +14 -0
  160. package/src/server/responses/native-response-output.ts +37 -0
  161. package/src/server/responses/native-steering-log.ts +44 -0
  162. package/src/server/responses/native-steering-policy.ts +49 -0
  163. package/src/server/responses/native-steering-replay.ts +126 -0
  164. package/src/server/responses/native-steering-settings.ts +76 -0
  165. package/src/server/responses/native-steering.ts +400 -0
  166. package/src/server/responses/native-tool-results.ts +130 -0
  167. package/src/server/responses/passthrough-delivery.ts +30 -6
  168. package/src/server/responses/passthrough-dispatch.ts +61 -11
  169. package/src/server/responses/passthrough-error.ts +38 -2
  170. package/src/server/responses/request-prepare.ts +173 -22
  171. package/src/server/responses/request-send-budget.ts +97 -2
  172. package/src/server/responses/request-spend.ts +147 -0
  173. package/src/server/responses/request-transport.ts +62 -3
  174. package/src/server/responses/run-turn-execution.ts +59 -31
  175. package/src/server/responses/sidecar-execution.ts +7 -13
  176. package/src/server/responses/terminal-guard.ts +65 -4
  177. package/src/server/responses/ws-upstream.ts +21 -1
  178. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  179. package/src/server/stop-teardown.ts +8 -1
  180. package/src/server/ws-bridge.ts +16 -1
  181. package/src/service/cli.ts +13 -1
  182. package/src/service/windows-ops.ts +210 -16
  183. package/src/service/windows-scheduler.ts +28 -21
  184. package/src/service.ts +1 -1
  185. package/src/types/config.ts +8 -1
  186. package/src/types/provider.ts +13 -0
  187. package/src/types/request.ts +8 -5
  188. package/src/types/tools.ts +24 -0
  189. package/src/types.ts +2 -0
  190. package/src/update/index.ts +10 -0
  191. package/src/update/stop-contract.d.mts +1 -0
  192. package/src/update/stop-contract.mjs +19 -0
  193. package/src/update/stop-decision.d.mts +1 -1
  194. package/src/update/stop-decision.mjs +12 -3
  195. package/src/usage/log.ts +1 -1
  196. package/src/vision/anthropic-describe.ts +1 -1
  197. package/src/vision/describe.ts +5 -5
  198. package/src/web-search/anthropic-executor.ts +1 -1
  199. package/src/web-search/exa-executor.ts +1 -1
  200. package/src/web-search/executor.ts +1 -1
  201. package/src/web-search/gemini-executor.ts +1 -1
  202. package/src/web-search/loop.ts +1 -1
  203. package/src/web-search/ollama-executor.ts +1 -1
  204. package/src/web-search/parse.ts +67 -14
  205. package/src/web-search/passthrough-bridge.ts +64 -31
  206. package/src/web-search/xai-executor.ts +1 -1
@@ -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,
@@ -10,7 +14,7 @@ import type { ResponsesSendBudget } from "./request-send-budget";
10
14
  import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
11
15
  import { codexSafetyBufferingFilterOptions, terminalStatusFromParsed } from "../relay";
12
16
  import { imageGenToolCallAliases } from "../responses-image-gen-repair";
13
- import { rememberResponseState } from "../../responses/state";
17
+ import { rememberResponseState, isBodyNonPersistable } from "../../responses/state";
14
18
  import {
15
19
  currentTurnWireToolCatalogBody,
16
20
  hasExplicitWireToolCatalog,
@@ -29,6 +33,7 @@ import {
29
33
  unwrapUpstreamRetryEvidenceError,
30
34
  codexProbeLeaseId,
31
35
  codexProbeQuotaScope,
36
+ codexTransientProbeGrant,
32
37
  createCodexReserveDispatchGuard,
33
38
  } from "../../codex/auth-context";
34
39
  import {
@@ -60,7 +65,6 @@ import { restorePlaintextV2AgentMessageCalls } from "../../responses/plaintext-v
60
65
  import {
61
66
  recordAdapterReasoning,
62
67
  recordAdapterTier,
63
- noteAttemptSend,
64
68
  sealRequestAttemptIdentity,
65
69
  recordAttemptCredentialSource,
66
70
  } from "../request-log";
@@ -80,6 +84,7 @@ import {
80
84
  safeHostLabel,
81
85
  storedPoolReplayDispatchNotifier,
82
86
  } from "./fetch-helpers";
87
+ import { classifyPoolRecoveryDispatch } from "../../routing/probe-lease";
83
88
  import { clientCancelledResponse } from "./core-errors";
84
89
  import {
85
90
  upstreamHostCircuitOpenResponse,
@@ -101,6 +106,7 @@ import {
101
106
  fetchWithTransientRetry,
102
107
  applyUpstreamRecoveryInit,
103
108
  TRANSIENT_RETRY_MAX_ATTEMPTS,
109
+ isNonReplayableResponse,
104
110
  prepareSameTarget429Wait,
105
111
  sleepWithAbort,
106
112
  } from "../../lib/upstream-retry";
@@ -171,6 +177,7 @@ export async function preparePassthroughExchange(
171
177
  | "replayOAuthCredentialSnapshot"
172
178
  | "genericFailovers"
173
179
  | "applyFailoverSnapshot"
180
+ | "noteRoutedAttemptSend"
174
181
  >,
175
182
  responseEffects: Pick<
176
183
  ResponsesEffects,
@@ -248,6 +255,19 @@ export async function preparePassthroughExchange(
248
255
  ? (response: { id?: unknown; output?: unknown; status?: unknown }) =>
249
256
  rememberResponseState(parsed._rawBody, response, undefined, responseStateOptions(true))
250
257
  : undefined;
258
+ if (options.nativeControl && nativeResponseControlEligible(route.provider, options.nativeControl)
259
+ && options.inboundTransport === "websocket" && !options.comboAttempt) {
260
+ const body = parsed._rawBody as Record<string, unknown>;
261
+ if (options.nativeControl.kind === "steering") {
262
+ options.nativeControl.normalizeContinuation = createSteeringSettingsNormalizer(parsed, route, config, req.headers);
263
+ }
264
+ const Replay = options.nativeControl.kind === "injection" ? NativeInjectionReplay : NativeSteeringReplay;
265
+ options.nativeControl.replayFactory = () => new Replay(body.input, (input, response) => {
266
+ if (passthroughRecordEligible && !isBodyNonPersistable(body)) {
267
+ rememberResponseState({ ...body, input }, response, undefined, responseStateOptions(true));
268
+ }
269
+ });
270
+ }
251
271
  if (parsed.previousResponseId && !parsed._previousResponseInputExpanded) {
252
272
  console.warn(
253
273
  `[responses] previous_response_id ${parsed.previousResponseId} not found in local replay state `
@@ -346,10 +366,13 @@ export async function preparePassthroughExchange(
346
366
  const declaredWireToolNames = new Set<string>();
347
367
  const declaredBareWireToolNames = new Set<string>();
348
368
  const declaredNamelessClientCallTypes = new Set<string>();
349
- // `buildToolBridgeMaps` creates a bare alias only when the caller selected exactly one
350
- // namespaced tool through a bare tool_choice. Restore that request-bounded identity before
351
- // authorization checks instead of admitting the bare name into the declared set: for `exec`,
352
- // the latter would also authorize the unrelated code-mode helper names.
369
+ // `buildToolBridgeMaps` adds each eligible bare alias to `declaredToolNames` and `toolNsMap`
370
+ // (one authorized identity claims the bare name). `refreshUndeclaredToolGuard` normally copies
371
+ // those entries into `declaredWireToolNames`, but passthrough restoration runs before the
372
+ // undeclared-tool guard, so restore that request-bounded identity here, before authorization
373
+ // checks. `exec` uses separate handling: its bridge alias is copied into the declared set only
374
+ // when the client itself declared bare `exec`, because otherwise code-mode normalization could
375
+ // authorize the unrelated code-mode helper names.
353
376
  const authorizedBareNamespaceToolAliases: RoutedNamespaceToolAliases = new Map(
354
377
  [...toolBridgeMaps.toolNsMap].flatMap(([alias, identity]) =>
355
378
  alias === identity.name
@@ -736,6 +759,7 @@ export async function preparePassthroughExchange(
736
759
  modelId: route.modelId,
737
760
  probeLeaseId: codexProbeLeaseId(admissionState.authCtx),
738
761
  probeQuotaScope: codexProbeQuotaScope(admissionState.authCtx),
762
+ transientProbe: codexTransientProbeGrant(admissionState.authCtx),
739
763
  writerGeneration: admissionState.authCtx.writerGeneration,
740
764
  });
741
765
  }
@@ -752,13 +776,22 @@ export async function preparePassthroughExchange(
752
776
  // Body is a replayable string; nothing has streamed to the client yet.
753
777
  upstreamResponse = await fetchWithTransientRetry(
754
778
  recovery => {
755
- noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery);
779
+ // The pool-wide recovery window measures recovery traffic against observed demand,
780
+ // and this is where demand is observed: `recovery === undefined` is a new request's
781
+ // first send, everything after it is the same request trying again. Without this the
782
+ // ratio has no denominator and the window collapses to its quiet-pool floor, which
783
+ // would throttle recovery on a busy proxy exactly as hard as on an idle one (#4701).
784
+ if (recovery === undefined) classifyPoolRecoveryDispatch("initial");
785
+ transportState.noteRoutedAttemptSend(passthroughEstimate, recovery);
756
786
  return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
757
787
  method: request.method,
758
788
  headers: request.headers,
759
789
  body: request.body,
760
790
  }, recovery), upstream.signal, connectMs, parsed.stream,
761
791
  providerFetch(route.provider, options.codexWsRuntimeIdentity, {
792
+ nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt
793
+ && responseEffects.plaintextV2AgentMessageToolNames.size === 0
794
+ ? options.nativeControl : undefined,
762
795
  dispatchOverride: oauthDispatch(request),
763
796
  providerName: route.providerName,
764
797
  modelId: route.modelId,
@@ -848,13 +881,16 @@ export async function preparePassthroughExchange(
848
881
  if (allowance.permit && !allowance.permit.use()) {
849
882
  throw new SendBudgetExhaustedError(safeHostLabel(request.url));
850
883
  }
851
- noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, innerRecovery ?? recovery);
884
+ transportState.noteRoutedAttemptSend(passthroughEstimate, innerRecovery ?? recovery);
852
885
  return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
853
886
  method: request.method,
854
887
  headers: request.headers,
855
888
  body: request.body,
856
889
  }, innerRecovery), upstream.signal, connectMs, parsed.stream,
857
890
  providerFetch(route.provider, options.codexWsRuntimeIdentity, {
891
+ nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt
892
+ && responseEffects.plaintextV2AgentMessageToolNames.size === 0
893
+ ? options.nativeControl : undefined,
858
894
  dispatchOverride: oauthDispatch(request),
859
895
  providerName: route.providerName,
860
896
  modelId: route.modelId,
@@ -946,7 +982,7 @@ export async function preparePassthroughExchange(
946
982
  // every other build site; a replay is exactly when a grown payload reappears.
947
983
  const replayBodyRefusal = refuseOversizedOutboundBody(request);
948
984
  if (replayBodyRefusal) return replayBodyRefusal;
949
- noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, "oauth-401");
985
+ transportState.noteRoutedAttemptSend(passthroughEstimate, "oauth-401");
950
986
  upstreamResponse = await fetchWithHeaderTimeout(
951
987
  request.url,
952
988
  { method: request.method, headers: request.headers, body: request.body },
@@ -961,6 +997,9 @@ export async function preparePassthroughExchange(
961
997
  // here on is a genuine transport attempt.
962
998
  storedPoolReplayDispatchNotifier(
963
999
  providerFetch(route.provider, options.codexWsRuntimeIdentity, {
1000
+ nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt
1001
+ && responseEffects.plaintextV2AgentMessageToolNames.size === 0
1002
+ ? options.nativeControl : undefined,
964
1003
  dispatchOverride: oauthDispatch(request),
965
1004
  providerName: route.providerName,
966
1005
  modelId: route.modelId,
@@ -1075,13 +1114,16 @@ export async function preparePassthroughExchange(
1075
1114
  try {
1076
1115
  upstreamResponse = await fetchWithTransientRetry(
1077
1116
  recovery => {
1078
- noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery ?? "oauth-401");
1117
+ transportState.noteRoutedAttemptSend(passthroughEstimate, recovery ?? "oauth-401");
1079
1118
  return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
1080
1119
  method: request.method,
1081
1120
  headers: request.headers,
1082
1121
  body: request.body,
1083
1122
  }, recovery), upstream.signal, connectMs, parsed.stream,
1084
1123
  providerFetch(route.provider, options.codexWsRuntimeIdentity, {
1124
+ nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt
1125
+ && responseEffects.plaintextV2AgentMessageToolNames.size === 0
1126
+ ? options.nativeControl : undefined,
1085
1127
  dispatchOverride: oauthDispatch(request),
1086
1128
  providerName: route.providerName,
1087
1129
  modelId: route.modelId,
@@ -1105,6 +1147,10 @@ export async function preparePassthroughExchange(
1105
1147
  // the same quorum, cooldown and request budget here, before any client bytes flow.
1106
1148
  if (
1107
1149
  upstreamResponse.status === 429
1150
+ // Not a provider rate limit when this proxy synthesized it for a refused reset
1151
+ // replay; rotating accounts on it would re-send an inference that may already
1152
+ // have run and would cool down an account that refused nothing.
1153
+ && !isNonReplayableResponse(upstreamResponse)
1108
1154
  && transportState.genericFailoverAccountId
1109
1155
  && transportState.genericFailovers < GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST
1110
1156
  && isGenericOAuthFailoverEnabled(config, route.providerName)
@@ -1159,6 +1205,7 @@ export async function preparePassthroughExchange(
1159
1205
  // keep their pool logic below (rateLimitRetryPolicyFor returns null for them).
1160
1206
  while (
1161
1207
  upstreamResponse.status === 429
1208
+ && !isNonReplayableResponse(upstreamResponse)
1162
1209
  && rateLimitPolicy !== null
1163
1210
  && rateLimitRetries < rateLimitPolicy.attempts
1164
1211
  // Checked here rather than inside the helper: prepareSameTarget429Wait releases the 429
@@ -1192,13 +1239,16 @@ export async function preparePassthroughExchange(
1192
1239
  recovery => {
1193
1240
  // The first send of every replay is itself a rate-limit retry; inner transient-5xx
1194
1241
  // recoveries keep their own label (recovery is provided for those).
1195
- noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery ?? "rate-limit-429");
1242
+ transportState.noteRoutedAttemptSend(passthroughEstimate, recovery ?? "rate-limit-429");
1196
1243
  return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
1197
1244
  method: request.method,
1198
1245
  headers: request.headers,
1199
1246
  body: request.body,
1200
1247
  }, recovery), upstream.signal, connectMs, parsed.stream,
1201
1248
  providerFetch(route.provider, options.codexWsRuntimeIdentity, {
1249
+ nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt
1250
+ && responseEffects.plaintextV2AgentMessageToolNames.size === 0
1251
+ ? options.nativeControl : undefined,
1202
1252
  dispatchOverride: oauthDispatch(request),
1203
1253
  providerName: route.providerName,
1204
1254
  modelId: route.modelId,
@@ -1,5 +1,6 @@
1
1
  import { formatErrorResponse } from "../../bridge";
2
2
  import { isCyberPolicyCode, isCyberPolicyMessage } from "../../lib/errors";
3
+ import { isReplayRefusalCode, UPSTREAM_RESET_REPLAY_REFUSED_CODE } from "../../lib/upstream-retry";
3
4
  import {
4
5
  resolveClientRetryAfter,
5
6
  validateClientRetryAfterHeader,
@@ -24,6 +25,26 @@ function isCyberPolicyBody(body: string): boolean {
24
25
  return false;
25
26
  }
26
27
 
28
+ /**
29
+ * True for a body this proxy wrote to refuse replaying an ambiguous pre-header reset.
30
+ *
31
+ * It is read off the body rather than a marker because this formatter is handed bytes, not
32
+ * the response they came from, and the refusal reaches it after the original body was read.
33
+ * The code is this proxy's own, so an upstream echoing it is not a case worth widening for.
34
+ */
35
+ function isReplayRefusalBody(body: string): boolean {
36
+ if (!body.includes(UPSTREAM_RESET_REPLAY_REFUSED_CODE)) return false;
37
+ try {
38
+ const parsed = JSON.parse(body) as Record<string, unknown>;
39
+ const error = parsed.error && typeof parsed.error === "object" && !Array.isArray(parsed.error)
40
+ ? parsed.error as Record<string, unknown>
41
+ : undefined;
42
+ return isReplayRefusalCode(error?.code) || isReplayRefusalCode(parsed.code);
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
27
48
  /**
28
49
  * Passthrough adapters historically relayed upstream non-2xx bodies verbatim.
29
50
  * Codex maps an *empty* body to the literal client string "Unknown error"
@@ -38,6 +59,9 @@ function isCyberPolicyBody(body: string): boolean {
38
59
  * - missing/malformed values are replaced when resolveClientRetryAfter yields a value
39
60
  * - malformed/expired values are removed when the resolver returns undefined
40
61
  * (e.g. quota-exhausted 429s must not keep junk headers or get the synthetic "2")
62
+ * - a replay refusal this proxy wrote gets none and keeps none: the whole point of the
63
+ * refusal is that the turn may already be running, and the synthetic default for a
64
+ * retryable 429 is a direct instruction to the client to send it a second time
41
65
  */
42
66
  export function formatPassthroughUpstreamError(
43
67
  status: number,
@@ -46,6 +70,13 @@ export function formatPassthroughUpstreamError(
46
70
  statusText?: string;
47
71
  headers?: Headers;
48
72
  now?: number;
73
+ /**
74
+ * Provenance from the caller that still holds the response: this body is a refusal this
75
+ * proxy synthesized. The body check below is the fallback for a re-wrapped body, and it
76
+ * cannot answer at all when the bounded read returned nothing display-safe -- which is
77
+ * precisely when the empty-body branch would invent the retryable-429 default.
78
+ */
79
+ replayRefusal?: boolean;
49
80
  },
50
81
  ): Response {
51
82
  const trimmed = bodyText.trim();
@@ -53,7 +84,12 @@ export function formatPassthroughUpstreamError(
53
84
  const upstreamRetryAfter = options?.headers?.get("retry-after")?.trim() || undefined;
54
85
  const originalValid = validateClientRetryAfterHeader(upstreamRetryAfter, now);
55
86
  const cyberPolicyFailure = isCyberPolicyBody(trimmed);
56
- const resolved = cyberPolicyFailure
87
+ // Two different reasons to answer with no wait at all, handled the same way: a hard policy
88
+ // block will not become servable, and a refusal we made was never a rate limit.
89
+ const suppressRetryAfter = cyberPolicyFailure
90
+ || options?.replayRefusal === true
91
+ || isReplayRefusalBody(trimmed);
92
+ const resolved = suppressRetryAfter
57
93
  ? undefined
58
94
  : resolveClientRetryAfter({
59
95
  status,
@@ -64,7 +100,7 @@ export function formatPassthroughUpstreamError(
64
100
 
65
101
  if (trimmed) {
66
102
  const needsSet = resolved !== undefined && upstreamRetryAfter !== resolved;
67
- const needsDelete = (cyberPolicyFailure && upstreamRetryAfter !== undefined)
103
+ const needsDelete = (suppressRetryAfter && upstreamRetryAfter !== undefined)
68
104
  || (resolved === undefined
69
105
  && upstreamRetryAfter !== undefined
70
106
  && originalValid === undefined);
@@ -35,6 +35,8 @@ import {
35
35
  codexPoolAffinityKey,
36
36
  previewCodexPoolLineage,
37
37
  applyCodexAuthContextToProvider,
38
+ hasCallerCodexBearer,
39
+ requestOwnedMainPinState,
38
40
  } from "../../codex/auth-context";
39
41
  import {
40
42
  copyPreviousResponseReplayProvenance,
@@ -84,7 +86,11 @@ import {
84
86
  canPassThroughEncryptedV2AgentTask,
85
87
  applyFinalRouteRequestNormalization,
86
88
  } from "./core-normalize";
87
- import { resolveCodexModelEntitlements } from "../../codex/model-entitlements";
89
+ import {
90
+ cachedDeniedCodexAccountIdsForModel,
91
+ resolveCodexModelEntitlements,
92
+ } from "../../codex/model-entitlements";
93
+ import { MAIN_CODEX_ACCOUNT_ID } from "../../codex/main-account";
88
94
  import {
89
95
  previewCodexAccountForRequest,
90
96
  codexQuotaScopeForModel,
@@ -101,7 +107,7 @@ import {
101
107
  isCodexReserveHelperUnsupported,
102
108
  CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE,
103
109
  } from "../../codex/loopback-target";
104
- import { checkInputAdmission } from "./input-admission";
110
+ import { checkComboTargetInputAdmission, checkInputAdmission } from "./input-admission";
105
111
  import { nativeContextLimits } from "../../codex/catalog";
106
112
  import { streamingContextOverflowResponse } from "./context-overflow";
107
113
  import {
@@ -115,6 +121,8 @@ import { codexAuthContextLogLabel } from "../../codex/account-label";
115
121
  import {
116
122
  conversationStateBindingFromAuth,
117
123
  applyAccountChangeConversationStateScrub,
124
+ accountChangeFileReferenceRefusal,
125
+ conversationCarriesUploadedFiles,
118
126
  } from "./account-change-state";
119
127
 
120
128
  /** Parses, selects, and admits one request without changing the dispatch policy. */
@@ -365,6 +373,14 @@ export async function prepareResponsesRequest(
365
373
  }
366
374
  logCtx.requestedModel = parsed.modelId;
367
375
  logCtx.requestedEffort = parsed.options.reasoning;
376
+ // What this request may spend beyond its input, for the durable spend reservation (#4707).
377
+ // Read from the caller rather than from the adapter's serialized body, because the
378
+ // reservation has to exist before the body does. A caller that omits it leaves the
379
+ // provider/model default in charge and reserves only the input estimate; settlement then
380
+ // books the real figure, so the gap is a looser bound up front, never a wrong one after.
381
+ if (typeof parsed.options.maxOutputTokens === "number" && parsed.options.maxOutputTokens > 0) {
382
+ logCtx.spendOutputCeilingTokens = Math.trunc(parsed.options.maxOutputTokens);
383
+ }
368
384
  logCtx.callerServiceTier = sanitizeLogMetadataString(parsed.options.serviceTier);
369
385
  logCtx.requestedServiceTier = parsed.options.serviceTier;
370
386
  logCtx.requestedSpeedLabel = requestLogSpeedLabel(parsed.options.serviceTier);
@@ -442,12 +458,67 @@ export async function prepareResponsesRequest(
442
458
  && (route.codexAccountId === undefined || initialSubagentFallbackChain !== null)
443
459
  ? codexAccountSelectionForTurn(options.turnAdmissionLease)?.()
444
460
  : undefined;
461
+ // The credential headers final authentication will be given, resolved once and reused by
462
+ // everything below that has to predict what final auth decides.
463
+ const previewAuthHeaders = codexRouteCredentialDomainHeaders(
464
+ req,
465
+ route,
466
+ options,
467
+ credentialDomainWasRewritten,
468
+ );
469
+ // Does the CALLER own the credential this request will authenticate with? Validated exactly
470
+ // the way final auth validates it: the route ownership predicate AND the caller-bearer check
471
+ // `resolveCodexAuthContext` re-applies to these same headers.
472
+ const previewRequestScopedMainCredential = codexRouteCredentialOwnership(
473
+ previewAuthHeaders,
474
+ config,
475
+ route,
476
+ options,
477
+ ).requestScopedMainCredential && hasCallerCodexBearer(previewAuthHeaders);
445
478
  const nativeMainRecoveryBlocked = isNativeMainTrafficBlocked();
446
- const nativeMainReadsForbidden = nativeMainRecoveryBlocked
479
+ // The same three inputs final auth ORs together (src/codex/auth-context.ts). Request-owned
480
+ // ownership is first there and has to be first here: computing the preview fence from
481
+ // recovery and drain state alone let a `thread_spawn` carrying a forwardable caller bearer
482
+ // read the physical main token it is forbidden to touch, and score main differently than the
483
+ // resolution this preview exists to predict.
484
+ const nativeMainReadsForbidden = previewRequestScopedMainCredential
485
+ || nativeMainRecoveryBlocked
447
486
  || previewSelectionAdmission?.mainProfileDraining === true;
487
+ // The liveness answer final authentication gives its own selection options, computed from the
488
+ // same shared predicate so the two cannot drift apart again (#4850). `fixedAccountId` is
489
+ // mirrored through `route.codexAccountId` because that is literally what core-auth.ts passes
490
+ // as `accountId`. A reserve-authorized request is the one input where the two can differ, and
491
+ // it differs harmlessly: reserve plus a caller bearer is served as main either way, which is
492
+ // the answer this produces.
493
+ const previewRequestOwnedMainPin = requestOwnedMainPinState(
494
+ previewAuthHeaders,
495
+ config,
496
+ options.codexAuthPolicy ?? config,
497
+ previewRequestScopedMainCredential,
498
+ route.codexAccountId,
499
+ ).preserve;
500
+ // Deliberately NOT fenced on ownership: final auth derives `nativeMainSelectionOnly` from the
501
+ // drain alone, and adding a term here would diverge from it in the other direction.
448
502
  const previewSelectionOptions = {
449
503
  nativeMainSelectionOnly: !nativeMainRecoveryBlocked
450
504
  && previewSelectionAdmission?.mainProfileDraining === true,
505
+ // Pool eligibility was the last part of preview still outside the fence (#4850). Without
506
+ // this seam `codexAccountUnusableReason` takes its default branch into
507
+ // `isMainAccountCredentialUsable()`, which opens the physical `auth.json` -- twice per
508
+ // spawn, because subagent fallback re-enters the preview through the callback below.
509
+ //
510
+ // Scoped to ownership, and carrying final auth's value rather than a constant, because
511
+ // preview exists to predict final auth. Under an effective main pin the request really is
512
+ // served by its own main credential, so main must stay eligible; without the pin final auth
513
+ // scores main `main_credential_unavailable` and drops it, so preview has to drop it too. A
514
+ // hardcoded `true` would be wrong in the second case and `false` in the first.
515
+ isMainAccountTokenLive: previewRequestScopedMainCredential
516
+ ? () => previewRequestOwnedMainPin
517
+ : undefined,
518
+ // Preview must reach the same answer as the final resolution, including the uploaded-file
519
+ // retention (#4778): a preview that reported a quota move the request will not make would
520
+ // hand subagent fallback a different account than the one that actually serves.
521
+ retainAccountForUploadedFiles: conversationCarriesUploadedFiles(parsed._rawBody),
451
522
  };
452
523
  let selectedForwardHeaders = req.headers;
453
524
  let subagentFallbackAccountId = config.activeCodexAccountId ?? null;
@@ -466,22 +537,11 @@ export async function prepareResponsesRequest(
466
537
  // deliberately create no affinity at all -- previewing a family binding for one of those would
467
538
  // hand model fallback an account this request can never authenticate as. Read-only: the record
468
539
  // is written by the resolution that binds, never by a preview that may own no Pool state.
469
- const previewAuthHeaders = codexRouteCredentialDomainHeaders(
470
- req,
471
- route,
472
- options,
473
- credentialDomainWasRewritten,
474
- );
475
540
  const poolLineage = previewCodexPoolLineage(previewAuthHeaders, options.codexAuthPolicy ?? config, {
476
541
  accountId: route.codexAccountId,
477
542
  modelId: route.modelId,
478
543
  admission: options.admission,
479
- requestScopedMainCredential: codexRouteCredentialOwnership(
480
- previewAuthHeaders,
481
- config,
482
- route,
483
- options,
484
- ).requestScopedMainCredential,
544
+ requestScopedMainCredential: previewRequestScopedMainCredential,
485
545
  });
486
546
 
487
547
  try {
@@ -519,7 +579,20 @@ export async function prepareResponsesRequest(
519
579
  config,
520
580
  previewNow,
521
581
  codexQuotaScopeForModel(modelId),
522
- { ...previewSelectionOptions, modelEligibleAccountIds },
582
+ {
583
+ ...previewSelectionOptions,
584
+ modelEligibleAccountIds,
585
+ // Per CANDIDATE model, like the scope and the eligible set above: the preference is
586
+ // model-specific, so hoisting it out of the closure would score every fallback
587
+ // candidate against the requested model's evidence and diverge from final auth (#4768).
588
+ // Under the same native-main read fence final auth applies: the reader validates each
589
+ // cached roster against the account's current credential, and for main that is a
590
+ // synchronous read of the stored token. A preview that read it would both cross the
591
+ // fence and score main differently than the resolution it is supposed to predict.
592
+ deniedModelAccountIds: cachedDeniedCodexAccountIdsForModel(modelId, previewNow, {
593
+ excludeAccountIds: nativeMainReadsForbidden ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined,
594
+ }),
595
+ },
523
596
  modelId,
524
597
  poolLineage,
525
598
  );
@@ -650,9 +723,45 @@ export async function prepareResponsesRequest(
650
723
  const fallback = (() => {
651
724
  try {
652
725
  const recoveryNativeMainBlocked = isNativeMainTrafficBlocked();
726
+ // Recompute ownership here rather than reusing the pre-decryption value: a
727
+ // subagent fallback above may have re-routed, and `requestScopedMainCredential`
728
+ // is a function of the route as well as the headers.
729
+ const recoveryAuthHeaders = codexRouteCredentialDomainHeaders(
730
+ req,
731
+ route,
732
+ options,
733
+ credentialDomainWasRewritten,
734
+ );
735
+ const recoveryRequestScopedMainCredential = codexRouteCredentialOwnership(
736
+ recoveryAuthHeaders,
737
+ config,
738
+ route,
739
+ options,
740
+ ).requestScopedMainCredential && hasCallerCodexBearer(recoveryAuthHeaders);
741
+ // Recovery's own answer to the same question, against the route it may have moved
742
+ // to. Reconstructing the options without it is what left pool eligibility outside
743
+ // the fence on the first preview (#4850); recovery re-previews, so it would leave
744
+ // the same two reads on the one path that runs after decryption.
745
+ const recoveryRequestOwnedMainPin = requestOwnedMainPinState(
746
+ recoveryAuthHeaders,
747
+ config,
748
+ options.codexAuthPolicy ?? config,
749
+ recoveryRequestScopedMainCredential,
750
+ route.codexAccountId,
751
+ ).preserve;
653
752
  const recoverySelectionOptions = {
654
753
  nativeMainSelectionOnly: !recoveryNativeMainBlocked
655
754
  && recoverySelectionAdmission?.mainProfileDraining === true,
755
+ isMainAccountTokenLive: recoveryRequestScopedMainCredential
756
+ ? () => recoveryRequestOwnedMainPin
757
+ : undefined,
758
+ // #4778, same reason as `previewSelectionOptions` above: this preview decides
759
+ // which account subagent fallback scores against, and final auth passes the
760
+ // retention. Recovery is exactly where the two could diverge -- it re-previews
761
+ // against the DECRYPTED body, which is the first point at which a file reference
762
+ // that was ciphertext-only becomes readable, so reconstructing the options
763
+ // without the bit lets preview report a quota move the request will not make.
764
+ retainAccountForUploadedFiles: conversationCarriesUploadedFiles(parsed._rawBody),
656
765
  };
657
766
  const recoveryNow = Date.now();
658
767
  // Carry the entitlement filter through recovery too (#2509/#2623). The scope was
@@ -665,7 +774,21 @@ export async function prepareResponsesRequest(
665
774
  config,
666
775
  previewNow,
667
776
  codexQuotaScopeForModel(modelId),
668
- { ...recoverySelectionOptions, modelEligibleAccountIds },
777
+ {
778
+ ...recoverySelectionOptions,
779
+ modelEligibleAccountIds,
780
+ // Same read fence as the first preview, evaluated against recovery's own view
781
+ // of the drain AND of credential ownership, rather than the one captured before
782
+ // decryption. Omitting ownership here would reopen the fence the first preview
783
+ // closes, on the one path that re-previews after the route may have moved.
784
+ deniedModelAccountIds: cachedDeniedCodexAccountIdsForModel(modelId, previewNow, {
785
+ excludeAccountIds: recoveryRequestScopedMainCredential
786
+ || recoveryNativeMainBlocked
787
+ || recoverySelectionAdmission?.mainProfileDraining === true
788
+ ? new Set([MAIN_CODEX_ACCOUNT_ID])
789
+ : undefined,
790
+ }),
791
+ },
669
792
  modelId,
670
793
  poolLineage,
671
794
  );
@@ -860,7 +983,12 @@ export async function prepareResponsesRequest(
860
983
  // refusing the turn that shrinks the context would deadlock the client against the very
861
984
  // limit this gate reports — it would be told to compact and then denied the compaction.
862
985
  if (parsed._compactionRequest !== true) {
863
- const inputAdmission = checkInputAdmission(parsed, route.provider, route.providerName, parsed.modelId, nativeContextLimits(config));
986
+ // A combo child is the one caller that can afford a strict gate: skipping a target it
987
+ // cannot fit is safe before any upstream bytes are sent, and the ladder continues. A
988
+ // direct request has nowhere to go, so it keeps the loose pathological-input gate.
989
+ const inputAdmission = options.comboAttempt
990
+ ? checkComboTargetInputAdmission(parsed, route.provider, route.providerName, parsed.modelId, nativeContextLimits(config))
991
+ : checkInputAdmission(parsed, route.provider, route.providerName, parsed.modelId, nativeContextLimits(config));
864
992
  if (!inputAdmission.admitted) {
865
993
  // #1524: this is a LOCAL preflight refusal, not an upstream verdict. A policy or combo
866
994
  // fallback must be able to skip this candidate and try one whose context window fits,
@@ -876,9 +1004,13 @@ export async function prepareResponsesRequest(
876
1004
  return formatErrorResponse(
877
1005
  413,
878
1006
  "input_admission_refused",
879
- `Estimated input (~${inputAdmission.estimatedTokens} tokens) is far past the context window `
880
- + `of ${parsed.modelId} (${inputAdmission.ceiling} tokens). Start a new session or choose a `
881
- + `model with a larger context window.`,
1007
+ inputAdmission.requiredOutputHeadroom !== undefined
1008
+ ? `Estimated input (~${inputAdmission.estimatedTokens} tokens) plus ${inputAdmission.requiredOutputHeadroom} `
1009
+ + `tokens of requested output headroom cannot fit the context window of ${parsed.modelId} `
1010
+ + `(${inputAdmission.ceiling} tokens).`
1011
+ : `Estimated input (~${inputAdmission.estimatedTokens} tokens) is far past the context window `
1012
+ + `of ${parsed.modelId} (${inputAdmission.ceiling} tokens). Start a new session or choose a `
1013
+ + `model with a larger context window.`,
882
1014
  );
883
1015
  }
884
1016
  }
@@ -897,7 +1029,18 @@ export async function prepareResponsesRequest(
897
1029
  let substituteMainCredential = false;
898
1030
  let callerAuthHeaders: Headers;
899
1031
  {
900
- const finalAuth = await resolveResponsesCodexAuth(req, config, route, options, credentialDomainWasRewritten);
1032
+ // #4778: uploaded files are scoped to the account that issued them, so a conversation
1033
+ // carrying live references must retain its binding across a voluntary quota move. Answered
1034
+ // from the body alone, by the same predicate the refusal guard uses, so the two can never
1035
+ // disagree about which conversations are in scope.
1036
+ const finalAuth = await resolveResponsesCodexAuth(
1037
+ req,
1038
+ config,
1039
+ route,
1040
+ options,
1041
+ credentialDomainWasRewritten,
1042
+ conversationCarriesUploadedFiles(parsed._rawBody),
1043
+ );
901
1044
  if (!finalAuth.ok) return finalAuth.response;
902
1045
  admissionState.authCtx = finalAuth.authCtx;
903
1046
  selectedForwardHeaders = withClaudeNativeSession(finalAuth.headers, route.provider, options.claudeNativeSessionId);
@@ -921,6 +1064,14 @@ export async function prepareResponsesRequest(
921
1064
  {
922
1065
  const binding = conversationStateBindingFromAuth(admissionState.authCtx, poolAffinityKey);
923
1066
  if (binding) {
1067
+ // Before the scrub, because a file reference is refused rather than removed and the
1068
+ // refusal has to happen while there is still no dispatch to undo.
1069
+ const refusal = accountChangeFileReferenceRefusal({
1070
+ body: parsed._rawBody,
1071
+ bindingKey: binding.bindingKey,
1072
+ servingAccountId: binding.accountId,
1073
+ });
1074
+ if (refusal) return refusal;
924
1075
  applyAccountChangeConversationStateScrub({
925
1076
  body: parsed._rawBody,
926
1077
  parsed,