@bitkyc08/opencodex 2.56.0 → 2.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  3. package/gui/dist/assets/{index-D4zuyIxQ.js → index-Cz7CLdif.js} +21 -21
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +3 -3
  6. package/src/adapters/codebuddy/adapter.ts +2 -1
  7. package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
  8. package/src/adapters/command-code.ts +1 -1
  9. package/src/adapters/cursor/envelope-echo.ts +8 -2
  10. package/src/adapters/google.ts +7 -7
  11. package/src/adapters/kiro/payload.ts +17 -3
  12. package/src/adapters/kiro/reasoning.ts +70 -7
  13. package/src/adapters/kiro/stream.ts +8 -2
  14. package/src/adapters/kiro/wire.ts +2 -1
  15. package/src/adapters/kiro-events.ts +21 -13
  16. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  17. package/src/adapters/openai-chat/tool-schema.ts +25 -7
  18. package/src/adapters/openai-chat.ts +8 -8
  19. package/src/adapters/openai-responses/passthrough.ts +32 -1
  20. package/src/bridge/errors.ts +26 -2
  21. package/src/bridge/response-json.ts +7 -1
  22. package/src/bridge/sse.ts +19 -1
  23. package/src/claude/desktop-profile.ts +66 -9
  24. package/src/claude/outbound.ts +18 -0
  25. package/src/cli/account-main.ts +1 -1
  26. package/src/cli/capabilities.ts +2 -2
  27. package/src/cli/combo.ts +10 -1
  28. package/src/cli/index.ts +48 -5
  29. package/src/cli/registry.ts +2 -1
  30. package/src/cli/system-command.ts +4 -4
  31. package/src/clients/config-export.ts +7 -3
  32. package/src/codex/account-label.ts +14 -3
  33. package/src/codex/account-store.ts +113 -26
  34. package/src/codex/account-usability.ts +21 -0
  35. package/src/codex/auth-api/login-flow.ts +14 -2
  36. package/src/codex/auth-api/reset-credit-service.ts +11 -2
  37. package/src/codex/auth-context.ts +157 -7
  38. package/src/codex/catalog/aggregation.ts +80 -1
  39. package/src/codex/catalog/model-visibility.ts +1 -0
  40. package/src/codex/catalog/remote.ts +30 -0
  41. package/src/codex/catalog/retained-sync.ts +9 -1
  42. package/src/codex/catalog/routed-gather.ts +38 -1
  43. package/src/codex/cli-install-provenance.ts +7 -1
  44. package/src/codex/convergence.ts +7 -2
  45. package/src/codex/desktop-app/types.ts +11 -2
  46. package/src/codex/desktop-app/windows.ts +5 -5
  47. package/src/codex/inject/restore.ts +29 -2
  48. package/src/codex/inject.ts +9 -9
  49. package/src/codex/model-entitlements.ts +152 -15
  50. package/src/codex/pool-refresh-backoff.ts +12 -3
  51. package/src/codex/quota-rejection.ts +104 -15
  52. package/src/codex/routing/cache-affinity.ts +70 -0
  53. package/src/codex/routing/cooldown-math.ts +10 -0
  54. package/src/codex/routing/selection.ts +79 -2
  55. package/src/codex/routing/thread-affinity.ts +50 -2
  56. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  57. package/src/codex/routing.ts +29 -49
  58. package/src/codex/warmup.ts +1 -1
  59. package/src/combos/failover.ts +85 -0
  60. package/src/combos/request.ts +17 -10
  61. package/src/combos/types.ts +23 -2
  62. package/src/config/pending-teardown.ts +31 -0
  63. package/src/generated/compatibility-version.json +163 -135
  64. package/src/images/loop.ts +1 -1
  65. package/src/lib/errors.ts +17 -0
  66. package/src/lib/request-execution-budget.ts +147 -21
  67. package/src/lib/spend-reservation-ledger.ts +18 -0
  68. package/src/lib/state-store-registrations.ts +6 -2
  69. package/src/lib/test-home-guard.ts +85 -1
  70. package/src/lib/upstream-retry.ts +77 -10
  71. package/src/lib/windows-elevation.ts +76 -14
  72. package/src/oauth/index.ts +2 -2
  73. package/src/oauth/key-providers.ts +2 -2
  74. package/src/providers/kiro-models.ts +4 -3
  75. package/src/providers/label.ts +19 -1
  76. package/src/providers/model-discovery.ts +16 -0
  77. package/src/providers/registry/entries-core.ts +7 -0
  78. package/src/providers/registry/entries-extended.ts +9 -0
  79. package/src/providers/registry/model-seeds.ts +4 -0
  80. package/src/responses/reasoning-envelope.ts +6 -3
  81. package/src/routing/identity-domains.ts +21 -14
  82. package/src/routing/probe-lease.ts +103 -1
  83. package/src/server/chat-completions.ts +3 -1
  84. package/src/server/chat-native.ts +37 -9
  85. package/src/server/index/live-sideband.ts +37 -1
  86. package/src/server/index/websocket-handler.ts +6 -2
  87. package/src/server/index.ts +5 -5
  88. package/src/server/inspection-tee.ts +107 -0
  89. package/src/server/live.ts +46 -1
  90. package/src/server/management/combo-routes.ts +10 -1
  91. package/src/server/relay-eager.ts +2 -0
  92. package/src/server/relay.ts +14 -19
  93. package/src/server/request-log.ts +127 -3
  94. package/src/server/response-log-body.ts +153 -0
  95. package/src/server/responses/account-change-state.ts +74 -0
  96. package/src/server/responses/adapter-continuation.ts +33 -7
  97. package/src/server/responses/adapter-delivery.ts +5 -11
  98. package/src/server/responses/adapter-dispatch.ts +84 -13
  99. package/src/server/responses/codex-ws-wire.ts +5 -0
  100. package/src/server/responses/collaboration.ts +74 -4
  101. package/src/server/responses/combo-session-recall.ts +68 -8
  102. package/src/server/responses/compact.ts +54 -13
  103. package/src/server/responses/core-auth.ts +2 -0
  104. package/src/server/responses/core-codex-account.ts +51 -3
  105. package/src/server/responses/core-combo.ts +103 -23
  106. package/src/server/responses/core-errors.ts +18 -0
  107. package/src/server/responses/core-replay.ts +105 -32
  108. package/src/server/responses/core.ts +3 -3
  109. package/src/server/responses/encrypted-payload.ts +0 -1
  110. package/src/server/responses/input-admission.ts +126 -6
  111. package/src/server/responses/passthrough-delivery.ts +19 -6
  112. package/src/server/responses/passthrough-dispatch.ts +28 -10
  113. package/src/server/responses/passthrough-error.ts +38 -2
  114. package/src/server/responses/request-prepare.ts +132 -22
  115. package/src/server/responses/request-send-budget.ts +97 -2
  116. package/src/server/responses/request-spend.ts +147 -0
  117. package/src/server/responses/request-transport.ts +62 -3
  118. package/src/server/responses/run-turn-execution.ts +59 -31
  119. package/src/server/responses/sidecar-execution.ts +7 -13
  120. package/src/server/responses/terminal-guard.ts +65 -4
  121. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  122. package/src/service/windows-ops.ts +210 -16
  123. package/src/service/windows-scheduler.ts +28 -21
  124. package/src/service.ts +1 -1
  125. package/src/types/config.ts +4 -1
  126. package/src/types/request.ts +8 -5
  127. package/src/types/tools.ts +24 -0
  128. package/src/types.ts +2 -0
  129. package/src/update/index.ts +10 -0
  130. package/src/update/stop-contract.d.mts +1 -0
  131. package/src/update/stop-contract.mjs +19 -0
  132. package/src/update/stop-decision.d.mts +1 -1
  133. package/src/update/stop-decision.mjs +12 -3
  134. package/src/usage/log.ts +1 -1
  135. package/src/vision/anthropic-describe.ts +1 -1
  136. package/src/vision/describe.ts +5 -5
  137. package/src/web-search/anthropic-executor.ts +1 -1
  138. package/src/web-search/exa-executor.ts +1 -1
  139. package/src/web-search/executor.ts +1 -1
  140. package/src/web-search/gemini-executor.ts +1 -1
  141. package/src/web-search/loop.ts +1 -1
  142. package/src/web-search/ollama-executor.ts +1 -1
  143. package/src/web-search/parse.ts +67 -14
  144. package/src/web-search/passthrough-bridge.ts +64 -31
  145. package/src/web-search/xai-executor.ts +1 -1
@@ -1,3 +1,4 @@
1
+ import { isNonReplayableResponse } from "../../lib/upstream-retry";
1
2
  import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options";
2
3
  import type { PreparedResponsesRequest } from "./request-prepare";
3
4
  import type { ResponsesTransport } from "./request-transport";
@@ -11,7 +12,6 @@ import { trackStreamLifetime } from "../lifecycle";
11
12
  import {
12
13
  recordAdapterReasoning,
13
14
  recordAdapterTier,
14
- noteAttemptSend,
15
15
  sealRequestAttemptIdentity,
16
16
  recordAttemptCredentialSource,
17
17
  } from "../request-log";
@@ -72,7 +72,12 @@ import { consumeComboFailure } from "./core-combo-failure";
72
72
  import { streamingContextOverflowResponse, jsonContextOverflowResponse } from "./context-overflow";
73
73
  import { isFixedCodexAccount } from "./core-codex-account";
74
74
  import { recordSubagentQuotaFailureForThreadSpawn } from "../../codex/subagent-model-fallback";
75
- import { isCyberPolicyCode, CYBER_POLICY_FALLBACK_MESSAGE, CYBER_POLICY_ERROR_CODE } from "../../lib/errors";
75
+ import {
76
+ isCyberPolicyCode,
77
+ CYBER_POLICY_FALLBACK_MESSAGE,
78
+ CYBER_POLICY_ERROR_CODE,
79
+ SEND_BUDGET_EXHAUSTED_CODE,
80
+ } from "../../lib/errors";
76
81
  import { resolveClientRetryAfter } from "../../lib/retry-after";
77
82
  import { cancelBodyOnAbort } from "../../lib/abort";
78
83
 
@@ -115,11 +120,12 @@ export async function prepareAdapterExchange(
115
120
  | "genericFailoverAccountId"
116
121
  | "genericFailovers"
117
122
  | "applyFailoverSnapshot"
123
+ | "noteRoutedAttemptSend"
118
124
  >,
119
125
  responseEffects: Pick<ResponsesEffects, "cancelResponseCompletion" | "notifyResponseComplete" | "refreshRequestToolAliases">,
120
126
  sendBudgetState: Pick<
121
127
  ResponsesSendBudget,
122
- | "adapterSendBudget"
128
+ | "adapterDispatchBudget"
123
129
  | "noteAdapterPhysicalSend"
124
130
  | "remainingTransientSendBudget"
125
131
  | "noteTransientSends"
@@ -127,6 +133,7 @@ export async function prepareAdapterExchange(
127
133
  | "recoveryClassFor"
128
134
  | "sendBudgetExhausted"
129
135
  | "reserveCredentialHop"
136
+ | "pendingHopPermit"
130
137
  >,
131
138
  ) {
132
139
  const { options, config, logCtx, req } = requestContext;
@@ -151,7 +158,7 @@ export async function prepareAdapterExchange(
151
158
  } = requestState;
152
159
  const { cancelResponseCompletion, notifyResponseComplete, refreshRequestToolAliases } = responseEffects;
153
160
  const {
154
- adapterSendBudget,
161
+ adapterDispatchBudget,
155
162
  noteAdapterPhysicalSend,
156
163
  remainingTransientSendBudget,
157
164
  noteTransientSends,
@@ -272,15 +279,16 @@ export async function prepareAdapterExchange(
272
279
  let upstreamResponse: Response;
273
280
  try {
274
281
  if (transportState.activeAdapter.fetchResponse) {
275
- noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate);
282
+ transportState.noteRoutedAttemptSend(inputTokenEstimate);
276
283
  await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal);
277
284
  upstreamResponse = await transportState.activeAdapter.fetchResponse(builtInitialRequest, {
278
285
  abortSignal: upstream.signal,
279
286
  timeoutMs: connectMs,
280
- sendBudget: adapterSendBudget,
287
+ sendBudget: adapterDispatchBudget,
281
288
  onPhysicalSend: send => noteAdapterPhysicalSend(inputTokenEstimate, send),
282
289
  stream: parsed.stream,
283
290
  executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
291
+ pacingSlotAcquired: true,
284
292
  dispatchOverride: oauthDispatch(builtInitialRequest),
285
293
  providerName: route.providerName,
286
294
  modelId: route.modelId,
@@ -300,7 +308,7 @@ export async function prepareAdapterExchange(
300
308
  : fetchWithResetRetry;
301
309
  upstreamResponse = await fetchWithRetryPolicy(
302
310
  recovery => {
303
- noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate, recovery);
311
+ transportState.noteRoutedAttemptSend(inputTokenEstimate, recovery);
304
312
  return fetchWithHeaderTimeout(builtInitialRequest.url, applyUpstreamRecoveryInit({
305
313
  method: builtInitialRequest.method,
306
314
  headers: builtInitialRequest.headers,
@@ -331,6 +339,13 @@ export async function prepareAdapterExchange(
331
339
  cleanupUpstreamAbort();
332
340
  upstream.abort();
333
341
  if (options.abortSignal?.aborted) return clientCancelledResponse();
342
+ // A budget refusal is a decision this process made, not an upstream fault. Reporting it as
343
+ // 502 does more than mislabel it: the Codex client retries 5xx and does not retry a 429, so
344
+ // blaming the provider makes the caller send the whole turn again -- the amplification this
345
+ // budget exists to stop. The passthrough path has answered 429 here since #4546.
346
+ if (err instanceof SendBudgetExhaustedError) {
347
+ return formatErrorResponse(429, SEND_BUDGET_EXHAUSTED_CODE, err.message);
348
+ }
334
349
  const msg = describeUpstreamConnectFailure(err, connectMs);
335
350
  return formatErrorResponse(502, "upstream_error", msg);
336
351
  } finally {
@@ -412,10 +427,10 @@ export async function prepareAdapterExchange(
412
427
  logCtx.providerAdapter = transportState.activeAdapter.name;
413
428
  sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel);
414
429
  recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name);
415
- noteAttemptSend(logCtx.activeAttempt, retryEstimate, recovery);
416
430
  try {
417
431
  try {
418
432
  if (transportState.activeAdapter.fetchResponse) {
433
+ transportState.noteRoutedAttemptSend(retryEstimate, recovery);
419
434
  await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal);
420
435
  // The dispatch boundary is HERE, not before the pacing wait: that wait can reject for
421
436
  // an abort, a saturated queue, an expired slot or a removed provider, and none of
@@ -425,10 +440,11 @@ export async function prepareAdapterExchange(
425
440
  return await transportState.activeAdapter.fetchResponse(retryRequest, {
426
441
  abortSignal: upstream.signal,
427
442
  timeoutMs: connectMs,
428
- sendBudget: adapterSendBudget,
443
+ sendBudget: adapterDispatchBudget,
429
444
  onPhysicalSend: send => noteAdapterPhysicalSend(retryEstimate, send),
430
445
  stream: parsed.stream,
431
446
  executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
447
+ pacingSlotAcquired: true,
432
448
  dispatchOverride: oauthDispatch(retryRequest),
433
449
  providerName: route.providerName,
434
450
  modelId: route.modelId,
@@ -461,6 +477,7 @@ export async function prepareAdapterExchange(
461
477
  if (refetchAllowance?.permit && !refetchAllowance.permit.use()) {
462
478
  throw new SendBudgetExhaustedError(safeHostLabel(retryRequest.url));
463
479
  }
480
+ transportState.noteRoutedAttemptSend(retryEstimate, recoveryKind ?? recovery);
464
481
  // Same boundary on the helper path: the thunk is what reaches the wire, and it
465
482
  // can be refused above before it does. use() past the first attempt is a no-op.
466
483
  onDispatch?.();
@@ -499,12 +516,23 @@ export async function prepareAdapterExchange(
499
516
  if (options.abortSignal?.aborted) {
500
517
  return { failed: clientCancelledResponse() };
501
518
  }
519
+ // Same rule on the recovery leg: the ladder refused to send again, so the answer names
520
+ // this proxy rather than the provider it never reached.
521
+ if (err instanceof SendBudgetExhaustedError) {
522
+ return { failed: formatErrorResponse(429, SEND_BUDGET_EXHAUSTED_CODE, err.message) };
523
+ }
502
524
  const msg = describeUpstreamConnectFailure(err, connectMs);
503
525
  return { failed: formatErrorResponse(502, "upstream_error", msg) };
504
526
  }
505
527
  };
506
528
  // Keep recovery kinds in sync with the native Responses `passthroughRecovery:` loop above.
507
529
  recovery: for (;;) {
530
+ // Preserve the terminal verdict through adapter and combo error formatting.
531
+ // This also covers a reset reached by a 401/429/413 recovery refetch.
532
+ if (isNonReplayableResponse(upstreamResponse)) {
533
+ cleanupUpstreamAbort();
534
+ return upstreamResponse;
535
+ }
508
536
  if (
509
537
  upstreamResponse.status === 401
510
538
  && isOAuth401ReplayProvider
@@ -593,6 +621,11 @@ export async function prepareAdapterExchange(
593
621
  const result = await rebuildAndRefetch("key-401");
594
622
  if ("failed" in result) return result.failed;
595
623
  upstreamResponse = result;
624
+ // A recovery refetch can itself die on an ambiguous pre-header reset, and the refusal
625
+ // that answers it is a 429. Every arm below keys on 429, so letting it fall through
626
+ // hands the marked refusal to the next waiting arm and replays the send it exists to
627
+ // stop. Re-enter the loop guard instead, which returns it unchanged.
628
+ if (isNonReplayableResponse(upstreamResponse)) continue recovery;
596
629
  }
597
630
 
598
631
  // Same-target 429 wait-and-retry (opt-in `retryOn429`, issue #487). Codex never retries
@@ -633,6 +666,9 @@ export async function prepareAdapterExchange(
633
666
  const result = await rebuildAndRefetch("rate-limit-429");
634
667
  if ("failed" in result) return result.failed;
635
668
  upstreamResponse = result;
669
+ // The refusal is a 429 too: without this the while condition is still true and the
670
+ // next configured attempt replays it on the same target.
671
+ if (isNonReplayableResponse(upstreamResponse)) continue recovery;
636
672
  }
637
673
 
638
674
  // Multi-key 429 failover: rotate to the next pool key (cooldown-aware) and retry the
@@ -664,6 +700,9 @@ export async function prepareAdapterExchange(
664
700
  const result = await rebuildAndRefetch("key-429");
665
701
  if ("failed" in result) return result.failed;
666
702
  upstreamResponse = result;
703
+ // Rotating on the refusal would also write a cooldown against a key that rate-limited
704
+ // nothing, which outlives the request.
705
+ if (isNonReplayableResponse(upstreamResponse)) continue recovery;
667
706
  }
668
707
 
669
708
  // Opt-in Anthropic OAuth account pool (#294): cool the failed account and retry
@@ -700,6 +739,7 @@ export async function prepareAdapterExchange(
700
739
  const result = await rebuildAndRefetch("anthropic-oauth-429");
701
740
  if ("failed" in result) return result.failed;
702
741
  upstreamResponse = result;
742
+ if (isNonReplayableResponse(upstreamResponse)) continue recovery;
703
743
  } catch {
704
744
  break;
705
745
  }
@@ -722,9 +762,23 @@ export async function prepareAdapterExchange(
722
762
  // rebuildAndRefetch, so the roster cap alone would let one request walk the roster on
723
763
  // an allowance the rest of the request cannot see. A refusal ends the ladder with the
724
764
  // real 429 already in hand, which is the decided exhaustion contract.
765
+ //
766
+ // Who settles this reservation depends on who dispatches the replay (#4709). An
767
+ // adapter that owns its ladder -- Kiro's reset loop, Cursor's transport loop --
768
+ // reserves once per physical send and would charge the same replay again; the helper
769
+ // path reports it again through `onSendsConsumed`. Both turned one physical send into
770
+ // two charges, and once the allowance was spent, into a synthetic error in place of
771
+ // the 429 this hop was recovering from. The wire protocol is resolved from the
772
+ // provider and model, not from the account, so an account rotation cannot move the
773
+ // replay between these two shapes.
774
+ const adapterOwnsDispatch = transportState.activeAdapter.fetchResponse !== undefined;
725
775
  const hop = reserveCredentialHop(
726
776
  "auth-recovery",
727
777
  `${route.providerName}|${route.modelId}|adapter-recovery-oauth-429`,
778
+ // Only a helper-routed replay reports this send back. A reset-only refetch reports
779
+ // nothing and an adapter ladder settles the booking itself, so promising an external
780
+ // report on either would leave a booking pending until it swallowed a later charge.
781
+ !adapterOwnsDispatch && transientRetryPolicyFor(route.provider) !== null,
728
782
  );
729
783
  if (!hop.allowed) break;
730
784
  const nextAccountId = rotateGenericOAuthAccountOn429(
@@ -755,16 +809,33 @@ export async function prepareAdapterExchange(
755
809
  );
756
810
  sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel);
757
811
  recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name);
758
- // Confirm at the dispatch boundary, not here: a rebuild can fail while shaping the
759
- // request and return `{ failed }` without reaching the wire, and a permit confirmed
760
- // before that would hold the charge for a send that never happened.
761
- const result = await rebuildAndRefetch("oauth-account-429", () => { hop.permit?.use(); });
812
+ // The replay IS this hop's send, so hand the reservation down and let the layer that
813
+ // dispatches settle it: `adapterDispatchBudget` spends it on the adapter's first
814
+ // reservation, and the retry helper's reporter settles the external booking.
815
+ sendBudgetState.pendingHopPermit = hop.permit;
816
+ let result: Response | { failed: Response };
817
+ try {
818
+ // Confirm at the dispatch boundary, not here: a rebuild can fail while shaping the
819
+ // request and return `{ failed }` without reaching the wire, and a permit confirmed
820
+ // before that would hold the charge for a send that never happened. An
821
+ // adapter-owned ladder is the exception -- its own reservation is the confirmation,
822
+ // and settling here first would hand it a dead permit, which it reads as an
823
+ // exhausted request and stops sending on.
824
+ result = await rebuildAndRefetch("oauth-account-429", () => {
825
+ if (!adapterOwnsDispatch) hop.permit?.use();
826
+ });
827
+ } finally {
828
+ sendBudgetState.pendingHopPermit = undefined;
829
+ }
762
830
  if ("failed" in result) {
763
831
  // A no-op if the boundary was reached; a refund if the rebuild died before it.
764
832
  hop.permit?.release();
765
833
  return result.failed;
766
834
  }
767
835
  upstreamResponse = result;
836
+ // The hop's permit is already settled by the dispatch boundary above; continuing
837
+ // only skips the remaining arms, it does not abandon a reservation.
838
+ if (isNonReplayableResponse(upstreamResponse)) continue recovery;
768
839
  } catch {
769
840
  // A throw before the send — snapshot fetch, credential application, adapter
770
841
  // resolution — must hand the reservation back. Without this the ladder charges the
@@ -108,6 +108,11 @@ export type CodexWsStageRecord = Omit<CodexWsFailureStage, "requestBytes"> & {
108
108
  const codexWsStageByResponse = new WeakMap<Response, CodexWsStageRecord>();
109
109
 
110
110
  export function markCodexWsStage(response: Response, record: CodexWsStageRecord): void {
111
+ const current = codexWsStageByResponse.get(response);
112
+ if (current) {
113
+ Object.assign(current, record);
114
+ return;
115
+ }
111
116
  codexWsStageByResponse.set(response, record);
112
117
  }
113
118
 
@@ -30,7 +30,7 @@ import {
30
30
  } from "../../combos";
31
31
  import { isInjectionDebugEnabled } from "../../lib/debug-settings";
32
32
  import { injectionDebugLog } from "../../lib/injection-debug-log";
33
- import { dottedToolName, modelInList, namespacedToolName, toolChoiceToolPredicate } from "../../types";
33
+ import { dottedToolName, modelInList, namespacedToolName, NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES, toolChoiceToolPredicate } from "../../types";
34
34
  import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxProviderContinuationState, OcxUsage } from "../../types";
35
35
  import {
36
36
  forceRefreshOAuthAccessSnapshot,
@@ -83,7 +83,6 @@ import {
83
83
  catalogModelSupportsServiceTier,
84
84
  finishRequestAttempt,
85
85
  inspectResponseLogJson,
86
- noteAttemptSend,
87
86
  readConfiguredCodexServiceTier,
88
87
  requestLogSpeedLabel,
89
88
  sealRequestAttemptIdentity,
@@ -152,6 +151,36 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
152
151
  dottedAliasOwners.set(t.name, null);
153
152
  }
154
153
  }
154
+ // Bare echo alias (`name` with no namespace spelling, #4679): some providers — observed
155
+ // on the muse family via Command Code — echo a namespaced tool by its bare name. The
156
+ // bare spelling is only a safe alias while it names ONE tool and cannot be read as
157
+ // another identity's canonical or dotted spelling.
158
+ // Code-mode helper spellings never gain a bare alias (#4679 review), whatever namespace
159
+ // declares them and whatever put the alias there. The list is owned by `src/types/tools.ts`,
160
+ // beside the names it protects, because the copy that used to live here drifted to a single
161
+ // namespace and had to be widened twice.
162
+ const bareAliasOwners = new Map<string, string | null>();
163
+ for (const t of authorizedTools) {
164
+ // Bare (no-namespace) declarations participate as owners too: a namespaced tool whose
165
+ // bare name equals a bare-declared function must not gain the bare alias, mirroring how
166
+ // the tool_choice bare path refuses ambiguous owners across the whole request catalog.
167
+ const identity = JSON.stringify([t.namespace ?? null, t.name]);
168
+ const owner = bareAliasOwners.get(t.name);
169
+ if (owner === undefined) bareAliasOwners.set(t.name, identity);
170
+ else if (owner !== identity) bareAliasOwners.set(t.name, null);
171
+ }
172
+ for (const t of authorizedTools) {
173
+ const canonical = namespacedToolName(t.namespace, t.name);
174
+ const owner = bareAliasOwners.get(canonical);
175
+ if (owner !== undefined && owner !== JSON.stringify([t.namespace, t.name])) {
176
+ bareAliasOwners.set(canonical, null);
177
+ }
178
+ const dotted = dottedToolName(t.namespace, t.name);
179
+ const dottedOwner = bareAliasOwners.get(dotted);
180
+ if (dottedOwner !== undefined && dottedOwner !== JSON.stringify([t.namespace, t.name])) {
181
+ bareAliasOwners.set(dotted, null);
182
+ }
183
+ }
155
184
  for (const t of authorizedTools) {
156
185
  // Upstream output is untrusted: only restore calls for tools the caller authorized.
157
186
  const wireName = namespacedToolName(t.namespace, t.name);
@@ -175,6 +204,30 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
175
204
  toolNsMap.set(dottedName, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) });
176
205
  if (t.parameters && typeof t.parameters === "object") toolParameterSchemas.set(dottedName, t.parameters);
177
206
  }
207
+ // Bare echo alias (`name` with no namespace spelling, #4679): same tool identity as
208
+ // the flattened wire name, so a provider that drops the namespace prefix still
209
+ // restores against this entry. Ambiguous bare names were resolved to null above;
210
+ // skipping them falls back to the spellings every provider can still echo.
211
+ // Code-mode helper spellings never gain a bare alias, from ANY namespace (#4792 review).
212
+ // Scoping this to `collaboration` was too narrow to be a boundary: a declaration such as
213
+ // `mcp__remote.exec` donated bare `exec` to the declared set, and normalizeDeclaredToolName
214
+ // then rewrote an undeclared `apply_patch`, `exec_command` or `write_stdin` onto it
215
+ // (src/types/tools.ts). No namespace may turn helper normalization on for a catalog that
216
+ // never declared the code-mode shell.
217
+ //
218
+ // The namespaced tool stays usable AS ITSELF: `ns__name` is added unconditionally above
219
+ // and `ns.name` whenever it is unambiguous, so only the namespace-dropping echo fallback
220
+ // is withdrawn, and only for these six spellings.
221
+ if (
222
+ bareAliasOwners.get(t.name) === JSON.stringify([t.namespace, t.name])
223
+ && !NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES.has(t.name)
224
+ ) {
225
+ budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
226
+ declaredToolNames.add(t.name);
227
+ budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([t.name, t.namespace, t.name])).byteLength, { kind: "retained_collectors" });
228
+ toolNsMap.set(t.name, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) });
229
+ if (t.parameters && typeof t.parameters === "object") toolParameterSchemas.set(t.name, t.parameters);
230
+ }
178
231
  }
179
232
  if (t.freeform) {
180
233
  budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
@@ -201,6 +254,20 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
201
254
  // Some routed providers echo a bare tool_choice selector instead of the flattened catalog
202
255
  // name. Accept only selectors the client actually sent and only when the full request catalog
203
256
  // contains one tool with that logical name.
257
+ //
258
+ // A helper spelling selected this way is split rather than refused (#4819). The two things a
259
+ // bare alias does are separable, and passthrough already relies on that: identity RESTORATION
260
+ // runs before authorization there, rewriting the echoed bare name to the namespaced identity
261
+ // the caller declared, and the guard then authorizes `ns__name`. DECLARATION is the part that
262
+ // is unsafe, because a declared-name set carrying bare `exec` is what makes
263
+ // `normalizeDeclaredToolName` rewrite an undeclared `apply_patch`, `exec_command` or
264
+ // `write_stdin` onto the selected tool (src/types/tools.ts).
265
+ //
266
+ // So a helper spelling gets the `toolNsMap` entry and not the `declaredToolNames` entry. The
267
+ // caller nominated exactly one tool by name, `bareNameCounts` proves nothing else answers to
268
+ // it, and restoring it authorizes nothing the request did not already declare. The echo path
269
+ // above withholds both, because a bare echo is a guess rather than a nomination and #4679
270
+ // pinned that shape (`tests/responses/bare-echo-alias.test.ts`).
204
271
  const choice = parsed.options.toolChoice;
205
272
  const bareChoiceNames = new Set(
206
273
  choice && typeof choice === "object"
@@ -213,8 +280,11 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
213
280
  }
214
281
  for (const t of authorizedTools) {
215
282
  if (!t.namespace || !bareChoiceNames.has(t.name) || bareNameCounts.get(t.name) !== 1 || declaredToolNames.has(t.name)) continue;
216
- budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
217
- declaredToolNames.add(t.name);
283
+ // Restore the identity; declare the name only when it is not a helper spelling.
284
+ if (!NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES.has(t.name)) {
285
+ budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
286
+ declaredToolNames.add(t.name);
287
+ }
218
288
  budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([t.name, t.namespace, t.name])).byteLength, { kind: "retained_collectors" });
219
289
  toolNsMap.set(t.name, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) });
220
290
  if (t.parameters && typeof t.parameters === "object") {
@@ -8,14 +8,46 @@ interface ComboRecallEntry {
8
8
  target: Pick<OcxComboTarget, "provider" | "model">;
9
9
  responseModel: string;
10
10
  at: number;
11
+ /** UTF-8 size of `responseModel`, the only client-influenced field of unbounded length. */
12
+ bytes: number;
11
13
  }
12
14
 
13
15
  const RECALL_CAPACITY = 256;
14
16
  const RECALL_TTL_MS = 30 * 60 * 1000;
17
+ /**
18
+ * A model id is provider-reported and arrives on the response, so nothing upstream of here
19
+ * bounds its length. Lane keys are already SHA-256 digests, so the model string is the only
20
+ * field that can grow, and 256 lanes alone do not bound the bytes they hold.
21
+ */
22
+ const RECALL_MODEL_BYTES_MAX = 1024;
23
+ const RECALL_TOTAL_BYTES_MAX = 64 * 1024;
15
24
  const recall = new Map<string, ComboRecallEntry>();
25
+ let recallBytes = 0;
16
26
  let lastReconciledGeneration = 0;
17
27
  let liveOwners: Pick<GenerationContext, "comboIds" | "comboTargets" | "providerNames"> | undefined;
18
28
 
29
+ /** Every removal path goes through here so the byte counter can never drift from the map. */
30
+ function deleteEntry(lane: string): boolean {
31
+ const entry = recall.get(lane);
32
+ if (!entry) return false;
33
+ recall.delete(lane);
34
+ recallBytes -= entry.bytes;
35
+ return true;
36
+ }
37
+
38
+ /**
39
+ * UTF-8 size of a remembered model id, or null when it is too large to retain.
40
+ *
41
+ * The code-unit test runs first and is the part that matters: a UTF-8 encoding is never smaller
42
+ * than the code-unit count, so an oversized string is rejected without encoding it, and the
43
+ * bound cannot be defeated by paying the allocation it exists to prevent.
44
+ */
45
+ function boundedModelBytes(responseModel: string): number | null {
46
+ if (responseModel.length > RECALL_MODEL_BYTES_MAX) return null;
47
+ const bytes = Buffer.byteLength(responseModel, "utf8");
48
+ return bytes > RECALL_MODEL_BYTES_MAX ? null : bytes;
49
+ }
50
+
19
51
  function ownsEntry(context: Pick<GenerationContext, "comboIds" | "comboTargets" | "providerNames">, entry: ComboRecallEntry): boolean {
20
52
  return context.comboIds.has(entry.comboId)
21
53
  && context.providerNames.has(entry.target.provider)
@@ -32,14 +64,29 @@ export function rememberComboForLane(
32
64
  if (!lane || !comboId || !responseModel.trim()) return;
33
65
  // Reject even a same-named recreated owner: its previous in-flight turn is obsolete.
34
66
  if (writerGeneration < Math.max(lastReconciledGeneration, captureConfigGeneration())) return;
35
- const entry = { comboId, target: { provider: target.provider, model: target.model }, responseModel, at: Date.now() };
67
+ // An unretainable model id DECLINES the write; it must not clear the lane. Every other
68
+ // rejection above returns the same way, and clearing here would let a late completion erase
69
+ // a newer selection that this function has no ordering information to compare against.
70
+ const bytes = boundedModelBytes(responseModel);
71
+ if (bytes === null) return;
72
+ const entry = {
73
+ comboId,
74
+ target: { provider: target.provider, model: target.model },
75
+ responseModel,
76
+ at: Date.now(),
77
+ bytes,
78
+ };
36
79
  if (liveOwners && !ownsEntry(liveOwners, entry)) return;
37
- recall.delete(lane);
80
+ deleteEntry(lane);
38
81
  recall.set(lane, entry);
39
- while (recall.size > RECALL_CAPACITY) {
82
+ recallBytes += bytes;
83
+ // Insertion order is recency order, because every write re-inserts its lane at the back.
84
+ // Evicting from the front therefore drops the least recently written lane, never this one:
85
+ // a single entry is capped well below the aggregate budget, so it always fits.
86
+ while (recall.size > RECALL_CAPACITY || recallBytes > RECALL_TOTAL_BYTES_MAX) {
40
87
  const oldest = recall.keys().next().value;
41
- if (oldest === undefined) break;
42
- recall.delete(oldest);
88
+ if (oldest === undefined || oldest === lane) break;
89
+ deleteEntry(oldest);
43
90
  }
44
91
  }
45
92
 
@@ -57,12 +104,25 @@ export function recallComboForLane(
57
104
  || !Object.hasOwn(config.providers, entry.target.provider)
58
105
  || !provider || provider.disabled === true
59
106
  || !combo?.targets.some(target => targetKey(target) === targetKey(entry.target))) {
60
- recall.delete(lane);
107
+ deleteEntry(lane);
61
108
  return undefined;
62
109
  }
63
110
  return entry.responseModel === model ? entry.comboId : undefined;
64
111
  }
65
112
 
113
+ /**
114
+ * Periodic expiry. Without it a lane that is never read again and never touched by a config
115
+ * reconciliation holds its entry for the life of the process: the existing TTL is only
116
+ * evaluated on read or on generation change.
117
+ */
118
+ export function sweepExpiredComboRecall(now: number): number {
119
+ let removed = 0;
120
+ for (const [lane, entry] of recall) {
121
+ if (now - entry.at >= RECALL_TTL_MS && deleteEntry(lane)) removed += 1;
122
+ }
123
+ return removed;
124
+ }
125
+
66
126
  export function reconcileComboRecall(context: GenerationContext): number {
67
127
  if (context.generation <= lastReconciledGeneration) return 0;
68
128
  lastReconciledGeneration = context.generation;
@@ -74,8 +134,7 @@ export function reconcileComboRecall(context: GenerationContext): number {
74
134
  let removed = 0;
75
135
  for (const [lane, entry] of recall) {
76
136
  if (!ownsEntry(context, entry) || Date.now() - entry.at >= RECALL_TTL_MS) {
77
- recall.delete(lane);
78
- removed += 1;
137
+ if (deleteEntry(lane)) removed += 1;
79
138
  }
80
139
  }
81
140
  return removed;
@@ -84,6 +143,7 @@ export function reconcileComboRecall(context: GenerationContext): number {
84
143
  /** Test-only reset, alongside the combo rotation/cooldown resets. */
85
144
  export function clearComboRecallForTests(): void {
86
145
  recall.clear();
146
+ recallBytes = 0;
87
147
  lastReconciledGeneration = 0;
88
148
  liveOwners = undefined;
89
149
  }
@@ -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.