@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,4 +1,6 @@
1
1
  import type { ResponsesTerminalStatus } from "../../bridge";
2
+ import { comboFailureDecision } from "../../combos";
3
+ import { httpStatusFromTerminalError } from "../../lib/errors";
2
4
  import type { RequestLogContext } from "../request-log";
3
5
  import { createSseInspector } from "../relay";
4
6
  import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer";
@@ -30,12 +32,67 @@ const RETRYABLE_ZERO_OUTPUT_INCOMPLETE_REASONS = new Set([
30
32
  "upstream_stall_timeout",
31
33
  ]);
32
34
 
35
+ function bareErrorStatus(payload: unknown): number | undefined {
36
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
37
+ const event = payload as Record<string, unknown>;
38
+ if (event.type !== "error") return undefined;
39
+ const nested = event.error;
40
+ const error = nested && typeof nested === "object" && !Array.isArray(nested)
41
+ ? nested as Record<string, unknown>
42
+ : event;
43
+ const explicitStatus = [
44
+ event.status,
45
+ event.status_code,
46
+ event.http_status,
47
+ error.status,
48
+ error.status_code,
49
+ error.http_status,
50
+ ]
51
+ .map(value => typeof value === "number" && Number.isInteger(value)
52
+ ? value
53
+ : typeof value === "string" && /^\d{3}$/.test(value.trim())
54
+ ? Number(value)
55
+ : undefined)
56
+ .find(value => value !== undefined && value >= 400 && value <= 599);
57
+ const code = typeof error.code === "string"
58
+ ? error.code
59
+ : typeof event.code === "string" ? event.code : null;
60
+ if (explicitStatus === undefined && code === "invalid_request_error") return 400;
61
+ return explicitStatus ?? httpStatusFromTerminalError({
62
+ type: typeof error.type === "string" && error.type !== "error" ? error.type : undefined,
63
+ code,
64
+ message: typeof error.message === "string"
65
+ ? error.message
66
+ : typeof event.message === "string" ? event.message : undefined,
67
+ });
68
+ }
69
+
70
+ function bareErrorIsRetryable(payload: unknown): boolean {
71
+ const status = bareErrorStatus(payload);
72
+ if (status === undefined || !payload || typeof payload !== "object" || Array.isArray(payload)) {
73
+ return false;
74
+ }
75
+ const event = payload as Record<string, unknown>;
76
+ const nested = event.error;
77
+ const error = nested && typeof nested === "object" && !Array.isArray(nested)
78
+ ? nested as Record<string, unknown>
79
+ : event;
80
+ const code = typeof error.code === "string"
81
+ ? error.code
82
+ : typeof event.code === "string" ? event.code : null;
83
+ const message = typeof error.message === "string"
84
+ ? error.message
85
+ : typeof event.message === "string" ? event.message : "";
86
+ return comboFailureDecision(status, message, { code }) === "hop";
87
+ }
88
+
33
89
  function retryableZeroOutputTerminal(payload: unknown): boolean {
34
90
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
35
91
  const event = payload as {
36
92
  type?: unknown;
37
93
  response?: { incomplete_details?: { reason?: unknown } };
38
94
  };
95
+ if (bareErrorIsRetryable(event)) return true;
39
96
  if (event.type === "response.failed") return true;
40
97
  if (event.type !== "response.incomplete") return false;
41
98
  const reason = event.response?.incomplete_details?.reason;
@@ -95,12 +152,17 @@ function failedTerminalResponse(
95
152
  ? nested as Record<string, unknown>
96
153
  : {};
97
154
  const nestedError = terminalResponse.error;
155
+ const topLevelError = terminalPayload.error;
98
156
  const error = nestedError && typeof nestedError === "object" && !Array.isArray(nestedError)
99
157
  ? nestedError as Record<string, unknown>
158
+ : topLevelError && typeof topLevelError === "object" && !Array.isArray(topLevelError)
159
+ ? topLevelError as Record<string, unknown>
100
160
  : {
101
161
  type: "upstream_error",
102
162
  code: "upstream_server_error",
103
- message: logCtx.upstreamError ?? "Provider stream failed before producing output",
163
+ message: typeof terminalPayload.message === "string"
164
+ ? terminalPayload.message
165
+ : logCtx.upstreamError ?? "Provider stream failed before producing output",
104
166
  };
105
167
  const headers = new Headers(response.headers);
106
168
  headers.set("content-type", "application/json");
@@ -117,7 +179,7 @@ function failedTerminalResponse(
117
179
  ...(usage && typeof usage === "object" && !Array.isArray(usage) ? { usage } : {}),
118
180
  },
119
181
  }), {
120
- status: logCtx.terminalHttpStatus ?? 502,
182
+ status: logCtx.terminalHttpStatus ?? bareErrorStatus(terminalPayload) ?? 502,
121
183
  headers,
122
184
  });
123
185
  }
@@ -154,11 +216,12 @@ export async function preflightComboStreamResponse(
154
216
  const inspector = createSseInspector({
155
217
  logCtx,
156
218
  onParsedPayload: payload => {
219
+ if (terminalStatus !== undefined || outputCommitted || retryableTerminalPayload) return;
157
220
  const retryable = retryableTerminal(payload);
158
221
  const matchedBareError = retryable && payload !== null && typeof payload === "object"
159
222
  && !Array.isArray(payload) && (payload as { type?: unknown }).type === "error";
160
- // Only an explicit caller predicate may opt a known bare error into replay.
161
- // Default combo classification still commits unknown/error events.
223
+ // A zero-output bare error is terminal evidence. Explicit client errors stay
224
+ // committed; unknown and retryable upstream failures may advance the combo.
162
225
  if (comboStreamPayloadCommitsOutput(payload) && !matchedBareError) outputCommitted = true;
163
226
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return;
164
227
  if (retryable) retryableTerminalPayload = payload as Record<string, unknown>;
@@ -197,7 +260,7 @@ export async function preflightComboStreamResponse(
197
260
  }
198
261
 
199
262
  // A bare error event is not a protocol terminal (terminalStatus stays undefined),
200
- // so its exact-message retryable match doubles as the terminal evidence.
263
+ // so its retryable classification doubles as the terminal evidence.
201
264
  if ((terminalStatus === "failed" || terminalStatus === "incomplete"
202
265
  || retryableTerminalPayload?.type === "error")
203
266
  && !outputCommitted && retryableTerminalPayload) {
@@ -53,6 +53,7 @@ import {
53
53
  resolveCodexAuthContext,
54
54
  codexPoolAffinityKey,
55
55
  codexProbeLeaseId,
56
+ codexTransientProbeGrant,
56
57
  codexProbeQuotaScope,
57
58
  releaseCodexAuthContextProbeLease,
58
59
  stripCodexRuntimeProviderFields,
@@ -71,7 +72,9 @@ import {
71
72
  import {
72
73
  applyAccountChangeConversationStateScrub,
73
74
  conversationStateBindingFromAuth,
75
+ accountChangeFileReferenceRefusal,
74
76
  rememberServingConversationStateIssuer,
77
+ conversationCarriesUploadedFiles,
75
78
  } from "./account-change-state";
76
79
  import {
77
80
  TokenRefreshError,
@@ -82,6 +85,7 @@ import {
82
85
  fetchWithResetRetry,
83
86
  fetchWithTransientRetry,
84
87
  applyUpstreamRecoveryInit,
88
+ isNonReplayableResponse,
85
89
  SendBudgetExhaustedError,
86
90
  TRANSIENT_RETRY_MAX_ATTEMPTS,
87
91
  type UpstreamSendRecovery,
@@ -140,7 +144,6 @@ import {
140
144
  catalogModelSupportsServiceTier,
141
145
  finishRequestAttempt,
142
146
  inspectResponseLogJson,
143
- noteAttemptSend,
144
147
  readConfiguredCodexServiceTier,
145
148
  requestLogSpeedLabel,
146
149
  sealRequestAttemptIdentity,
@@ -735,6 +738,14 @@ export async function handleResponsesCompact(
735
738
  beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease),
736
739
  signal: req.signal,
737
740
  nativeMainRefreshDependencies: options.nativeMainRefreshDependencies,
741
+ // #4778: the same retention the regular Responses path passes at its final auth. The
742
+ // post-429 guard below only declines to move AFTER this resolution has already bound
743
+ // an account, so without the bit here a quota-driven rebind could have carried the
744
+ // conversation off its issuing account before that guard is ever consulted -- and an
745
+ // uploaded file is readable only by the account that received it. Answered from `raw`,
746
+ // the same object and the same predicate the guard below uses, so the two can never
747
+ // disagree about which conversations are in scope.
748
+ retainAccountForUploadedFiles: conversationCarriesUploadedFiles(raw),
738
749
  });
739
750
  logCtx.accountLogLabel = codexAuthContextLogLabel(authCtx, config);
740
751
  const selected = await materializeCodexUpstreamAuthAsync(req.headers, authCtx, {
@@ -789,6 +800,14 @@ export async function handleResponsesCompact(
789
800
  {
790
801
  const binding = conversationStateBindingFromAuth(authCtx, codexPoolAffinityKey(req.headers));
791
802
  if (binding) {
803
+ // Refused rather than scrubbed: an uploaded file is content the caller attached, not
804
+ // continuation state the turn can do without.
805
+ const refusal = accountChangeFileReferenceRefusal({
806
+ body: raw,
807
+ bindingKey: binding.bindingKey,
808
+ servingAccountId: binding.accountId,
809
+ });
810
+ if (refusal) return refusal;
792
811
  applyAccountChangeConversationStateScrub({
793
812
  body: raw,
794
813
  bindingKey: binding.bindingKey,
@@ -869,6 +888,7 @@ export async function handleResponsesCompact(
869
888
  // replacement (#2887). Also covers the replay's own second 401.
870
889
  ...(ctx.kind === "pool" ? { credentialGeneration: ctx.generation } : {}),
871
890
  probeQuotaScope: codexProbeQuotaScope(ctx),
891
+ transientProbe: codexTransientProbeGrant(ctx),
872
892
  writerGeneration: ctx.kind === "pool" || ctx.kind === "main-pool"
873
893
  ? ctx.writerGeneration
874
894
  : undefined,
@@ -1068,6 +1088,10 @@ export async function handleResponsesCompact(
1068
1088
  // — reporting exhausted retries while another pool account sat idle (#913).
1069
1089
  if (
1070
1090
  (upstream.status === 429 || upstream.status === 402)
1091
+ // A replay refusal this proxy synthesized carries 429 for the client's benefit only.
1092
+ // It is not pool quota evidence, and the alternate account below is another send of a
1093
+ // compact turn that may already have been processed.
1094
+ && !isNonReplayableResponse(upstream)
1071
1095
  && !storedPool401ReplayAttempted
1072
1096
  && usesCodexForwardPoolAuth(authCtx, route.provider)
1073
1097
  && !authCtx.fixedAccount
@@ -1082,15 +1106,22 @@ export async function handleResponsesCompact(
1082
1106
  ].filter(Boolean);
1083
1107
  // Build the alternate COMPLETELY before cancelling the first body: if construction
1084
1108
  // throws, the first rejection is still intact and can be returned to the client.
1085
- const alternate = await resolveAlternateCompactContext({
1086
- req,
1087
- admission,
1088
- config,
1089
- route,
1090
- selectedModelId,
1091
- excludeAccountId: authCtx.accountId,
1092
- turnAdmissionLease,
1093
- });
1109
+ // The same reasoning refuses an uploaded-file move here: no alternate can read a file the
1110
+ // issuing account received, so which one is chosen is irrelevant and asking before the
1111
+ // resolution costs nothing. It also reuses the guarantee the comment above depends on --
1112
+ // the first body is still uncancelled -- so the client gets the original rejection rather
1113
+ // than an inaccessible-file error from account B (#4710).
1114
+ const alternate = conversationCarriesUploadedFiles(raw)
1115
+ ? undefined
1116
+ : await resolveAlternateCompactContext({
1117
+ req,
1118
+ admission,
1119
+ config,
1120
+ route,
1121
+ selectedModelId,
1122
+ excludeAccountId: authCtx.accountId,
1123
+ turnAdmissionLease,
1124
+ });
1094
1125
  // Resolution can await a credential refresh, so the client may have gone away
1095
1126
  // while we were choosing B. Re-check before spending anything: recording A,
1096
1127
  // cancelling its body, and sending B are all observable side effects, and B's
@@ -1193,8 +1224,14 @@ export async function handleResponsesCompact(
1193
1224
  const bufferedErrorText = buffered.ok
1194
1225
  ? ""
1195
1226
  : await buffered.clone().text().catch(() => "");
1196
- const explicitQuotaStatus = buffered.status === 429 || buffered.status === 402;
1197
- const bodyInferredQuota = !buffered.ok
1227
+ // The client-facing 429 of a synthesized replay refusal says nothing about this
1228
+ // account's quota. Pool accounting keeps reading it as the transport failure it is,
1229
+ // which is also what it recorded before the status was corrected for the client.
1230
+ const replayRefused = isNonReplayableResponse(upstream);
1231
+ const explicitQuotaStatus = !replayRefused
1232
+ && (buffered.status === 429 || buffered.status === 402);
1233
+ const bodyInferredQuota = !replayRefused
1234
+ && !buffered.ok
1198
1235
  && !explicitQuotaStatus
1199
1236
  && isRateLimitOrQuotaFailureMessage(bufferedErrorText);
1200
1237
  const quotaFailure = explicitQuotaStatus || bodyInferredQuota;
@@ -1207,7 +1244,11 @@ export async function handleResponsesCompact(
1207
1244
  // A body-confirmed quota failure can arrive behind a generic 5xx. Record it as
1208
1245
  // quota evidence; otherwise preserve the real upstream status so a local buffering
1209
1246
  // failure after a 200 cannot soft-avoid a healthy account or rotate a thread.
1210
- recordCompactPoolOutcome(outcomeCtx, bodyInferredQuota ? 429 : upstream.status, { retryAfter, resetAt });
1247
+ recordCompactPoolOutcome(
1248
+ outcomeCtx,
1249
+ bodyInferredQuota ? 429 : replayRefused ? 502 : upstream.status,
1250
+ { retryAfter, resetAt },
1251
+ );
1211
1252
  // Lift usage and response metadata from the buffered upstream JSON into the
1212
1253
  // request log; the routed branch gets the same through handleResponses. The
1213
1254
  // synthetic buffer errors are not upstream bodies and stay uninspected.
@@ -138,6 +138,7 @@ export async function resolveResponsesCodexAuth(
138
138
  route: RouteResult,
139
139
  options: HandleResponsesOptions,
140
140
  credentialDomainWasRewritten = false,
141
+ retainAccountForUploadedFiles = false,
141
142
  ): Promise<ResponsesAuthResolution> {
142
143
  try {
143
144
  let authInputHeaders = codexRouteCredentialDomainHeaders(
@@ -232,6 +233,7 @@ export async function resolveResponsesCodexAuth(
232
233
  resolveCodexModelEntitlements: options.resolveCodexModelEntitlements,
233
234
  signal: options.abortSignal,
234
235
  nativeMainRefreshDependencies: options.nativeMainRefreshDependencies,
236
+ retainAccountForUploadedFiles,
235
237
  });
236
238
  options.onCodexAuthContextResolved?.(authCtx);
237
239
  } else {
@@ -17,6 +17,7 @@ import {
17
17
  resetUpstreamHostHealth,
18
18
  } from "../../codex/upstream-host-health";
19
19
  import { safeOriginLabel, fetchWithHeaderTimeout, providerFetch } from "./fetch-helpers";
20
+ import { classifyPoolRecoveryDispatch } from "../../routing/probe-lease";
20
21
  import { formatErrorResponse } from "../../bridge";
21
22
  import { readBoundedResponseBody } from "../../lib/bounded-body";
22
23
  import { upstreamErrorMessageFromPayload, isRateLimitOrQuotaFailureMessage } from "../../lib/errors";
@@ -40,6 +41,7 @@ import { MAIN_CODEX_ACCOUNT_ID } from "../../codex/main-account";
40
41
  import { slugsEquivalent } from "../../providers/slug-codec";
41
42
  import {
42
43
  codexProbeLeaseId,
44
+ codexTransientProbeGrant,
43
45
  codexProbeQuotaScope,
44
46
  releaseCodexAuthContextProbeLease,
45
47
  resolveCodexAuthContext,
@@ -60,13 +62,14 @@ import { bindRouteReasoningReplayScope } from "./core-replay";
60
62
  import {
61
63
  conversationStateBindingFromAuth,
62
64
  applyAccountChangeConversationStateScrub,
65
+ conversationCarriesUploadedFiles,
63
66
  } from "./account-change-state";
64
67
  import {
65
68
  recordAdapterReasoning,
66
69
  recordAdapterTier,
67
70
  sealRequestAttemptIdentity,
68
71
  recordAttemptCredentialSource,
69
- noteAttemptSend,
72
+ noteProviderAttemptSend,
70
73
  } from "../request-log";
71
74
  import { codexAuthContextLogLabel } from "../../codex/account-label";
72
75
  import { chargeWorkflowSends } from "../../lib/workflow-budget";
@@ -221,7 +224,17 @@ export async function shouldRetryCodexPoolAccountQuota(
221
224
  // A post-send WebSocket gateway status must not become a second account's send; the
222
225
  // body carries no quota evidence either, but the marker is the contract, not the prose.
223
226
  if (isNonReplayableResponse(response)) return false;
224
- if (response.status === 402 || response.status === 429) return true;
227
+ if (response.status === 402 || response.status === 429) {
228
+ // Status alone used to authorize the move, which is right for a limit the ACCOUNT owns and
229
+ // wrong for one it merely belongs to. An organization- or project-scoped exhaustion refuses
230
+ // every credential inside that organization, so the second account meets the same counter
231
+ // and the only thing the rotation buys is a second cold prompt prefix (#4546). Positive
232
+ // evidence is required to withhold it: the helper fails closed, so an unreadable or
233
+ // ambiguous body keeps the broad #584 behaviour unchanged, and `rate_limit_exceeded`,
234
+ // `slow_down` and plan-level exhaustion still rotate exactly as before.
235
+ const { codexScopedExhaustionCode } = await import("../../codex/quota-rejection");
236
+ return await codexScopedExhaustionCode(response, { signal }) === undefined;
237
+ }
225
238
  if (response.status < 500 || response.status >= 600) return false;
226
239
  try {
227
240
  // Reject malformed UTF-8 instead of matching quota words around replacement characters.
@@ -459,6 +472,7 @@ export async function retryCodexPoolOnAlternateAccount(
459
472
  modelId: route.modelId,
460
473
  probeLeaseId: codexProbeLeaseId(firstAuthCtx),
461
474
  probeQuotaScope: codexProbeQuotaScope(firstAuthCtx),
475
+ transientProbe: codexTransientProbeGrant(firstAuthCtx),
462
476
  writerGeneration: firstAuthCtx.writerGeneration,
463
477
  });
464
478
  };
@@ -489,6 +503,17 @@ export async function retryCodexPoolOnAlternateAccount(
489
503
  recordUnmovedTransientOutcome();
490
504
  return { kind: "no-alternate" };
491
505
  }
506
+ // An uploaded file is readable only by the account it was sent to, so NO alternate can serve
507
+ // this body. Which account would be chosen does not change that, which is why this asks before
508
+ // the resolution rather than after it: refusing here reserves no send, cancels no response, and
509
+ // leaves the caller holding the first account's rejection to return unchanged (#4710). The
510
+ // initial-dispatch sites answer with a 400 instead, because there is no earlier response there
511
+ // to fall back to. A same-account replay -- the gated-model 400 ladder above -- is unaffected,
512
+ // since it never leaves the issuing account.
513
+ if (!retryAuthCtx && conversationCarriesUploadedFiles(parsed._rawBody)) {
514
+ recordUnmovedTransientOutcome();
515
+ return { kind: "no-alternate" };
516
+ }
492
517
  // An account move is the guarded profile's fourth send and draws the single shared
493
518
  // final-recovery reserve. Nothing bounded it per request before: `excludeAccountId` excludes
494
519
  // only the account that just failed, and the caller's recovery loop can return here after the
@@ -557,6 +582,7 @@ export async function retryCodexPoolOnAlternateAccount(
557
582
  modelId: route.modelId,
558
583
  probeLeaseId: codexProbeLeaseId(firstAuthCtx),
559
584
  probeQuotaScope: codexProbeQuotaScope(firstAuthCtx),
585
+ transientProbe: codexTransientProbeGrant(firstAuthCtx),
560
586
  writerGeneration: firstAuthCtx.writerGeneration,
561
587
  });
562
588
  }
@@ -588,6 +614,7 @@ export async function retryCodexPoolOnAlternateAccount(
588
614
  modelId: route.modelId,
589
615
  probeLeaseId: codexProbeLeaseId(firstAuthCtx),
590
616
  probeQuotaScope: codexProbeQuotaScope(firstAuthCtx),
617
+ transientProbe: codexTransientProbeGrant(firstAuthCtx),
591
618
  writerGeneration: firstAuthCtx.writerGeneration,
592
619
  // Retry already advanced the RR ring via excludeAccountId — reuse for promotion.
593
620
  ...(retryAuthCtx.accountId ? { promoteAccountId: retryAuthCtx.accountId } : {}),
@@ -693,16 +720,35 @@ export async function retryCodexPoolOnAlternateAccount(
693
720
  // The same-account gated-model 400 ladder below keeps its own `maxRetrySends` bound and
694
721
  // does not take the reserve again; only the move itself does.
695
722
  if (accountMovePermit) {
723
+ // The pool-wide recovery window is consulted BEFORE the request-local permit is used.
724
+ // `reserveDispatch` charges at reservation time and `release()` is the only way back, so
725
+ // using the permit first and refusing afterwards would spend a send the request never
726
+ // made. An account move is recovery traffic like any other: one request's own budget
727
+ // cannot see that a thousand other requests are moving at the same moment, which is
728
+ // precisely the amplification this window exists to bound (#4701).
729
+ //
730
+ // A refusal here is not a new failure mode: "no alternate was available" is already the
731
+ // outcome when the pool has nowhere to move this request to, and it is handled.
732
+ if (!classifyPoolRecoveryDispatch("retry").admitted) {
733
+ accountMovePermit.release();
734
+ accountMovePermit = undefined;
735
+ // The alternate context was resolved and will not send. Hand back whatever recovery
736
+ // lease it is holding rather than leaving that account unprobeable.
737
+ releaseCodexAuthContextProbeLease(retryAuthCtx);
738
+ recordUnmovedTransientOutcome();
739
+ return { kind: "no-alternate" };
740
+ }
696
741
  const charged = accountMovePermit.use();
697
742
  accountMovePermit = undefined;
698
743
  if (!charged) {
744
+ releaseCodexAuthContextProbeLease(retryAuthCtx);
699
745
  recordUnmovedTransientOutcome();
700
746
  return { kind: "no-alternate" };
701
747
  }
702
748
  // The move is a physical send like any other, so the root workflow is charged too.
703
749
  chargeWorkflowSends(args.options.workflowRootId, 1);
704
750
  }
705
- noteAttemptSend(logCtx.activeAttempt, passthroughEstimate);
751
+ noteProviderAttemptSend(logCtx, route.providerName, route.provider, passthroughEstimate);
706
752
  try {
707
753
  upstreamResponse = await fetchWithHeaderTimeout(
708
754
  request.url,
@@ -827,6 +873,7 @@ export function codexForwardTerminalOutcomeRecorder(
827
873
  modelId,
828
874
  probeLeaseId: codexProbeLeaseId(authCtx),
829
875
  probeQuotaScope: codexProbeQuotaScope(authCtx),
876
+ transientProbe: codexTransientProbeGrant(authCtx),
830
877
  writerGeneration: authCtx.writerGeneration,
831
878
  });
832
879
  return;
@@ -849,6 +896,7 @@ export function codexForwardTerminalOutcomeRecorder(
849
896
  modelId,
850
897
  probeLeaseId: codexProbeLeaseId(authCtx),
851
898
  probeQuotaScope: codexProbeQuotaScope(authCtx),
899
+ transientProbe: codexTransientProbeGrant(authCtx),
852
900
  writerGeneration: authCtx.writerGeneration,
853
901
  // A mid-stream terminal can carry a semantic 401 long after the credential was
854
902
  // replaced. It is never replayed — the client already saw output — but it must