@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
@@ -35,6 +35,7 @@ import {
35
35
  codexPoolAffinityKey,
36
36
  previewCodexPoolLineage,
37
37
  applyCodexAuthContextToProvider,
38
+ hasCallerCodexBearer,
38
39
  } from "../../codex/auth-context";
39
40
  import {
40
41
  copyPreviousResponseReplayProvenance,
@@ -84,7 +85,11 @@ import {
84
85
  canPassThroughEncryptedV2AgentTask,
85
86
  applyFinalRouteRequestNormalization,
86
87
  } from "./core-normalize";
87
- import { resolveCodexModelEntitlements } from "../../codex/model-entitlements";
88
+ import {
89
+ cachedDeniedCodexAccountIdsForModel,
90
+ resolveCodexModelEntitlements,
91
+ } from "../../codex/model-entitlements";
92
+ import { MAIN_CODEX_ACCOUNT_ID } from "../../codex/main-account";
88
93
  import {
89
94
  previewCodexAccountForRequest,
90
95
  codexQuotaScopeForModel,
@@ -101,7 +106,7 @@ import {
101
106
  isCodexReserveHelperUnsupported,
102
107
  CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE,
103
108
  } from "../../codex/loopback-target";
104
- import { checkInputAdmission } from "./input-admission";
109
+ import { checkComboTargetInputAdmission, checkInputAdmission } from "./input-admission";
105
110
  import { nativeContextLimits } from "../../codex/catalog";
106
111
  import { streamingContextOverflowResponse } from "./context-overflow";
107
112
  import {
@@ -115,6 +120,8 @@ import { codexAuthContextLogLabel } from "../../codex/account-label";
115
120
  import {
116
121
  conversationStateBindingFromAuth,
117
122
  applyAccountChangeConversationStateScrub,
123
+ accountChangeFileReferenceRefusal,
124
+ conversationCarriesUploadedFiles,
118
125
  } from "./account-change-state";
119
126
 
120
127
  /** Parses, selects, and admits one request without changing the dispatch policy. */
@@ -365,6 +372,14 @@ export async function prepareResponsesRequest(
365
372
  }
366
373
  logCtx.requestedModel = parsed.modelId;
367
374
  logCtx.requestedEffort = parsed.options.reasoning;
375
+ // What this request may spend beyond its input, for the durable spend reservation (#4707).
376
+ // Read from the caller rather than from the adapter's serialized body, because the
377
+ // reservation has to exist before the body does. A caller that omits it leaves the
378
+ // provider/model default in charge and reserves only the input estimate; settlement then
379
+ // books the real figure, so the gap is a looser bound up front, never a wrong one after.
380
+ if (typeof parsed.options.maxOutputTokens === "number" && parsed.options.maxOutputTokens > 0) {
381
+ logCtx.spendOutputCeilingTokens = Math.trunc(parsed.options.maxOutputTokens);
382
+ }
368
383
  logCtx.callerServiceTier = sanitizeLogMetadataString(parsed.options.serviceTier);
369
384
  logCtx.requestedServiceTier = parsed.options.serviceTier;
370
385
  logCtx.requestedSpeedLabel = requestLogSpeedLabel(parsed.options.serviceTier);
@@ -442,12 +457,41 @@ export async function prepareResponsesRequest(
442
457
  && (route.codexAccountId === undefined || initialSubagentFallbackChain !== null)
443
458
  ? codexAccountSelectionForTurn(options.turnAdmissionLease)?.()
444
459
  : undefined;
460
+ // The credential headers final authentication will be given, resolved once and reused by
461
+ // everything below that has to predict what final auth decides.
462
+ const previewAuthHeaders = codexRouteCredentialDomainHeaders(
463
+ req,
464
+ route,
465
+ options,
466
+ credentialDomainWasRewritten,
467
+ );
468
+ // Does the CALLER own the credential this request will authenticate with? Validated exactly
469
+ // the way final auth validates it: the route ownership predicate AND the caller-bearer check
470
+ // `resolveCodexAuthContext` re-applies to these same headers.
471
+ const previewRequestScopedMainCredential = codexRouteCredentialOwnership(
472
+ previewAuthHeaders,
473
+ config,
474
+ route,
475
+ options,
476
+ ).requestScopedMainCredential && hasCallerCodexBearer(previewAuthHeaders);
445
477
  const nativeMainRecoveryBlocked = isNativeMainTrafficBlocked();
446
- const nativeMainReadsForbidden = nativeMainRecoveryBlocked
478
+ // The same three inputs final auth ORs together (src/codex/auth-context.ts). Request-owned
479
+ // ownership is first there and has to be first here: computing the preview fence from
480
+ // recovery and drain state alone let a `thread_spawn` carrying a forwardable caller bearer
481
+ // read the physical main token it is forbidden to touch, and score main differently than the
482
+ // resolution this preview exists to predict.
483
+ const nativeMainReadsForbidden = previewRequestScopedMainCredential
484
+ || nativeMainRecoveryBlocked
447
485
  || previewSelectionAdmission?.mainProfileDraining === true;
486
+ // Deliberately NOT fenced on ownership: final auth derives `nativeMainSelectionOnly` from the
487
+ // drain alone, and adding a term here would diverge from it in the other direction.
448
488
  const previewSelectionOptions = {
449
489
  nativeMainSelectionOnly: !nativeMainRecoveryBlocked
450
490
  && previewSelectionAdmission?.mainProfileDraining === true,
491
+ // Preview must reach the same answer as the final resolution, including the uploaded-file
492
+ // retention (#4778): a preview that reported a quota move the request will not make would
493
+ // hand subagent fallback a different account than the one that actually serves.
494
+ retainAccountForUploadedFiles: conversationCarriesUploadedFiles(parsed._rawBody),
451
495
  };
452
496
  let selectedForwardHeaders = req.headers;
453
497
  let subagentFallbackAccountId = config.activeCodexAccountId ?? null;
@@ -466,22 +510,11 @@ export async function prepareResponsesRequest(
466
510
  // deliberately create no affinity at all -- previewing a family binding for one of those would
467
511
  // hand model fallback an account this request can never authenticate as. Read-only: the record
468
512
  // 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
513
  const poolLineage = previewCodexPoolLineage(previewAuthHeaders, options.codexAuthPolicy ?? config, {
476
514
  accountId: route.codexAccountId,
477
515
  modelId: route.modelId,
478
516
  admission: options.admission,
479
- requestScopedMainCredential: codexRouteCredentialOwnership(
480
- previewAuthHeaders,
481
- config,
482
- route,
483
- options,
484
- ).requestScopedMainCredential,
517
+ requestScopedMainCredential: previewRequestScopedMainCredential,
485
518
  });
486
519
 
487
520
  try {
@@ -519,7 +552,20 @@ export async function prepareResponsesRequest(
519
552
  config,
520
553
  previewNow,
521
554
  codexQuotaScopeForModel(modelId),
522
- { ...previewSelectionOptions, modelEligibleAccountIds },
555
+ {
556
+ ...previewSelectionOptions,
557
+ modelEligibleAccountIds,
558
+ // Per CANDIDATE model, like the scope and the eligible set above: the preference is
559
+ // model-specific, so hoisting it out of the closure would score every fallback
560
+ // candidate against the requested model's evidence and diverge from final auth (#4768).
561
+ // Under the same native-main read fence final auth applies: the reader validates each
562
+ // cached roster against the account's current credential, and for main that is a
563
+ // synchronous read of the stored token. A preview that read it would both cross the
564
+ // fence and score main differently than the resolution it is supposed to predict.
565
+ deniedModelAccountIds: cachedDeniedCodexAccountIdsForModel(modelId, previewNow, {
566
+ excludeAccountIds: nativeMainReadsForbidden ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined,
567
+ }),
568
+ },
523
569
  modelId,
524
570
  poolLineage,
525
571
  );
@@ -650,9 +696,31 @@ export async function prepareResponsesRequest(
650
696
  const fallback = (() => {
651
697
  try {
652
698
  const recoveryNativeMainBlocked = isNativeMainTrafficBlocked();
699
+ // Recompute ownership here rather than reusing the pre-decryption value: a
700
+ // subagent fallback above may have re-routed, and `requestScopedMainCredential`
701
+ // is a function of the route as well as the headers.
702
+ const recoveryAuthHeaders = codexRouteCredentialDomainHeaders(
703
+ req,
704
+ route,
705
+ options,
706
+ credentialDomainWasRewritten,
707
+ );
708
+ const recoveryRequestScopedMainCredential = codexRouteCredentialOwnership(
709
+ recoveryAuthHeaders,
710
+ config,
711
+ route,
712
+ options,
713
+ ).requestScopedMainCredential && hasCallerCodexBearer(recoveryAuthHeaders);
653
714
  const recoverySelectionOptions = {
654
715
  nativeMainSelectionOnly: !recoveryNativeMainBlocked
655
716
  && recoverySelectionAdmission?.mainProfileDraining === true,
717
+ // #4778, same reason as `previewSelectionOptions` above: this preview decides
718
+ // which account subagent fallback scores against, and final auth passes the
719
+ // retention. Recovery is exactly where the two could diverge -- it re-previews
720
+ // against the DECRYPTED body, which is the first point at which a file reference
721
+ // that was ciphertext-only becomes readable, so reconstructing the options
722
+ // without the bit lets preview report a quota move the request will not make.
723
+ retainAccountForUploadedFiles: conversationCarriesUploadedFiles(parsed._rawBody),
656
724
  };
657
725
  const recoveryNow = Date.now();
658
726
  // Carry the entitlement filter through recovery too (#2509/#2623). The scope was
@@ -665,7 +733,21 @@ export async function prepareResponsesRequest(
665
733
  config,
666
734
  previewNow,
667
735
  codexQuotaScopeForModel(modelId),
668
- { ...recoverySelectionOptions, modelEligibleAccountIds },
736
+ {
737
+ ...recoverySelectionOptions,
738
+ modelEligibleAccountIds,
739
+ // Same read fence as the first preview, evaluated against recovery's own view
740
+ // of the drain AND of credential ownership, rather than the one captured before
741
+ // decryption. Omitting ownership here would reopen the fence the first preview
742
+ // closes, on the one path that re-previews after the route may have moved.
743
+ deniedModelAccountIds: cachedDeniedCodexAccountIdsForModel(modelId, previewNow, {
744
+ excludeAccountIds: recoveryRequestScopedMainCredential
745
+ || recoveryNativeMainBlocked
746
+ || recoverySelectionAdmission?.mainProfileDraining === true
747
+ ? new Set([MAIN_CODEX_ACCOUNT_ID])
748
+ : undefined,
749
+ }),
750
+ },
669
751
  modelId,
670
752
  poolLineage,
671
753
  );
@@ -860,7 +942,12 @@ export async function prepareResponsesRequest(
860
942
  // refusing the turn that shrinks the context would deadlock the client against the very
861
943
  // limit this gate reports — it would be told to compact and then denied the compaction.
862
944
  if (parsed._compactionRequest !== true) {
863
- const inputAdmission = checkInputAdmission(parsed, route.provider, route.providerName, parsed.modelId, nativeContextLimits(config));
945
+ // A combo child is the one caller that can afford a strict gate: skipping a target it
946
+ // cannot fit is safe before any upstream bytes are sent, and the ladder continues. A
947
+ // direct request has nowhere to go, so it keeps the loose pathological-input gate.
948
+ const inputAdmission = options.comboAttempt
949
+ ? checkComboTargetInputAdmission(parsed, route.provider, route.providerName, parsed.modelId, nativeContextLimits(config))
950
+ : checkInputAdmission(parsed, route.provider, route.providerName, parsed.modelId, nativeContextLimits(config));
864
951
  if (!inputAdmission.admitted) {
865
952
  // #1524: this is a LOCAL preflight refusal, not an upstream verdict. A policy or combo
866
953
  // fallback must be able to skip this candidate and try one whose context window fits,
@@ -876,9 +963,13 @@ export async function prepareResponsesRequest(
876
963
  return formatErrorResponse(
877
964
  413,
878
965
  "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.`,
966
+ inputAdmission.requiredOutputHeadroom !== undefined
967
+ ? `Estimated input (~${inputAdmission.estimatedTokens} tokens) plus ${inputAdmission.requiredOutputHeadroom} `
968
+ + `tokens of requested output headroom cannot fit the context window of ${parsed.modelId} `
969
+ + `(${inputAdmission.ceiling} tokens).`
970
+ : `Estimated input (~${inputAdmission.estimatedTokens} tokens) is far past the context window `
971
+ + `of ${parsed.modelId} (${inputAdmission.ceiling} tokens). Start a new session or choose a `
972
+ + `model with a larger context window.`,
882
973
  );
883
974
  }
884
975
  }
@@ -897,7 +988,18 @@ export async function prepareResponsesRequest(
897
988
  let substituteMainCredential = false;
898
989
  let callerAuthHeaders: Headers;
899
990
  {
900
- const finalAuth = await resolveResponsesCodexAuth(req, config, route, options, credentialDomainWasRewritten);
991
+ // #4778: uploaded files are scoped to the account that issued them, so a conversation
992
+ // carrying live references must retain its binding across a voluntary quota move. Answered
993
+ // from the body alone, by the same predicate the refusal guard uses, so the two can never
994
+ // disagree about which conversations are in scope.
995
+ const finalAuth = await resolveResponsesCodexAuth(
996
+ req,
997
+ config,
998
+ route,
999
+ options,
1000
+ credentialDomainWasRewritten,
1001
+ conversationCarriesUploadedFiles(parsed._rawBody),
1002
+ );
901
1003
  if (!finalAuth.ok) return finalAuth.response;
902
1004
  admissionState.authCtx = finalAuth.authCtx;
903
1005
  selectedForwardHeaders = withClaudeNativeSession(finalAuth.headers, route.provider, options.claudeNativeSessionId);
@@ -921,6 +1023,14 @@ export async function prepareResponsesRequest(
921
1023
  {
922
1024
  const binding = conversationStateBindingFromAuth(admissionState.authCtx, poolAffinityKey);
923
1025
  if (binding) {
1026
+ // Before the scrub, because a file reference is refused rather than removed and the
1027
+ // refusal has to happen while there is still no dispatch to undo.
1028
+ const refusal = accountChangeFileReferenceRefusal({
1029
+ body: parsed._rawBody,
1030
+ bindingKey: binding.bindingKey,
1031
+ servingAccountId: binding.accountId,
1032
+ });
1033
+ if (refusal) return refusal;
924
1034
  applyAccountChangeConversationStateScrub({
925
1035
  body: parsed._rawBody,
926
1036
  parsed,
@@ -5,7 +5,13 @@ import { workflowRefusalResponse } from "../workflow-refusal";
5
5
  import type { AttemptRecoveryKind } from "../../usage/log";
6
6
  import { noteAttemptSend } from "../request-log";
7
7
  import { TRANSIENT_RETRY_MAX_ATTEMPTS } from "../../lib/upstream-retry";
8
- import type { SingleUseDispatchPermit, SendClass } from "../../lib/request-execution-budget";
8
+ import type {
9
+ DispatchDecision,
10
+ DispatchIntent,
11
+ RequestExecutionBudget,
12
+ SendClass,
13
+ SingleUseDispatchPermit,
14
+ } from "../../lib/request-execution-budget";
9
15
 
10
16
  /** Owns the shared request send counter and recovery permits. */
11
17
  export function createResponsesSendBudget(
@@ -52,7 +58,7 @@ export function createResponsesSendBudget(
52
58
  /**
53
59
  * Records an adapter's OWN inner retries against this attempt.
54
60
  *
55
- * Ordinal 1 is the send each call site already recorded through `noteAttemptSend`, so only
61
+ * Ordinal 1 is the send each call site already recorded through `noteRoutedAttemptSend`, so only
56
62
  * the extra physical sends are added here and an adapter that does not retry internally
57
63
  * leaves its log byte-for-byte as it was. Kiro reaches roughly eighteen sends per call and
58
64
  * Cursor re-sends a whole turn, and both reported one; a count that cannot be observed
@@ -75,6 +81,32 @@ export function createResponsesSendBudget(
75
81
  * was recovering from.
76
82
  */
77
83
  let pendingHopPermit: SingleUseDispatchPermit | undefined;
84
+ /**
85
+ * The budget an adapter's OWN dispatch ladder reserves against.
86
+ *
87
+ * Kiro and Cursor reserve once per physical send, and that is right: their ladders are the
88
+ * layer that actually sends, and counting one adapter call as one send hid up to eighteen
89
+ * upstream requests. But a credential hop has already booked the replay it is about to make,
90
+ * and a reservation IS the charge, so an adapter that reserves again turns one physical send
91
+ * into two charges -- and once the base allowance is spent, into a refusal that answers with
92
+ * a synthetic error in place of the 429 the hop was recovering from (#4709).
93
+ *
94
+ * The hop hands its reservation down through `pendingHopPermit`, the same seam the
95
+ * passthrough ladder already uses, and this view spends it on the adapter's FIRST
96
+ * reservation. Every later send in that ladder is a new physical send and is charged
97
+ * normally. A permit the adapter takes but never sends under is released through the same
98
+ * call it would have used for a reservation of its own, so an abandoned replay is refunded
99
+ * rather than left charged.
100
+ */
101
+ const adapterDispatchBudget: RequestExecutionBudget | undefined = adapterSendBudget === undefined
102
+ ? undefined
103
+ : adapterDispatchBudgetView(adapterSendBudget, {
104
+ claimHopPermit: () => {
105
+ const permit = pendingHopPermit;
106
+ pendingHopPermit = undefined;
107
+ return permit;
108
+ },
109
+ });
78
110
  /**
79
111
  * How many sends a recovery leg may make, and the permit that authorises the last one.
80
112
  *
@@ -147,6 +179,7 @@ export function createResponsesSendBudget(
147
179
  noteTransientSends,
148
180
  remainingTransientSendBudget,
149
181
  adapterSendBudget,
182
+ adapterDispatchBudget,
150
183
  noteAdapterPhysicalSend,
151
184
  sendBudgetExhausted,
152
185
  get pendingHopPermit(): SingleUseDispatchPermit | undefined {
@@ -162,3 +195,65 @@ export function createResponsesSendBudget(
162
195
  }
163
196
 
164
197
  export type ResponsesSendBudget = Exclude<ReturnType<typeof createResponsesSendBudget>, Response>;
198
+
199
+ /**
200
+ * A LIVE delegating view of one request's execution budget, with a credential hop's
201
+ * reservation spendable through it.
202
+ *
203
+ * Every member forwards rather than copying. A spread of the budget would freeze `used`,
204
+ * `reserveSpent` and the target counters at construction time, handing the adapter a budget
205
+ * that can never read as exhausted -- the same class of defect as the fresh per-layer
206
+ * allowances #4546 removed.
207
+ */
208
+ function adapterDispatchBudgetView(
209
+ budget: RequestExecutionBudget,
210
+ hop: { claimHopPermit: () => SingleUseDispatchPermit | undefined },
211
+ ): RequestExecutionBudget {
212
+ return {
213
+ get used(): number { return budget.used; },
214
+ set used(next: number) { budget.used = next; },
215
+ logicalRequestId: budget.logicalRequestId,
216
+ policyVersion: budget.policyVersion,
217
+ policy: budget.policy,
218
+ get reserveSpent(): boolean { return budget.reserveSpent; },
219
+ get alternateTargetSends(): number { return budget.alternateTargetSends; },
220
+ get targetTransitions(): number { return budget.targetTransitions; },
221
+ get lastTargetKey(): string | undefined { return budget.lastTargetKey; },
222
+ remainingBaseSends: (cap: number): number => budget.remainingBaseSends(cap),
223
+ reserveDispatch(intent: DispatchIntent): DispatchDecision {
224
+ // A dispatch whose upstream state is unknown is refused on its own merits. A hop that
225
+ // already paid does not make an unsafe replay safe, so that check stays with the budget.
226
+ if (intent.replaySafe !== false) {
227
+ const hopPermit = hop.claimHopPermit();
228
+ // Confirmed here rather than in `use()`: the adapter reserves immediately before it
229
+ // opens the transport, which is the same boundary the hop's own confirmation uses.
230
+ // A permit some other leg already settled returns false, and this falls through to a
231
+ // real reservation rather than handing the adapter a dead permit -- an adapter whose
232
+ // `use()` fails treats the request as exhausted and stops sending entirely.
233
+ if (hopPermit !== undefined && hopPermit.assumeCharge()) {
234
+ let spent = false;
235
+ return {
236
+ allowed: true,
237
+ permit: {
238
+ sendClass: hopPermit.sendClass,
239
+ use: (): boolean => {
240
+ if (spent) return false;
241
+ spent = true;
242
+ return true;
243
+ },
244
+ assumeCharge: (): boolean => {
245
+ if (spent) return false;
246
+ spent = true;
247
+ return true;
248
+ },
249
+ // The hop's charge is already settled and belongs to the leg that asked for it,
250
+ // so there is nothing here to refund.
251
+ release: (): void => {},
252
+ },
253
+ };
254
+ }
255
+ }
256
+ return budget.reserveDispatch(intent);
257
+ },
258
+ };
259
+ }
@@ -0,0 +1,147 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { RequestSendObserver } from "../../lib/request-execution-budget";
3
+ import { sharedSpendLedger, type SpendReservationLedger } from "../../lib/spend-reservation-ledger";
4
+ import type { RequestLogContext } from "../request-log";
5
+
6
+ /** The terminal usage a request reported, in the only two fields the ledger books. */
7
+ export interface TerminalSpendUsage {
8
+ inputTokens?: number;
9
+ outputTokens?: number;
10
+ }
11
+
12
+ /** Settles one request's durable spend entries once its terminal usage is known. */
13
+ export interface RequestSpendSettlement {
14
+ settle(usage: TerminalSpendUsage | undefined): void;
15
+ }
16
+
17
+ export interface RequestSpendTracker extends RequestSendObserver, RequestSpendSettlement {
18
+ /** Dispatches this request lost to a ledger ceiling. Zero on every ordinary request. */
19
+ readonly refusals: number;
20
+ }
21
+
22
+ /**
23
+ * One request's entries in the durable spend ledger (#4707).
24
+ *
25
+ * The ledger has had the whole reserve/dispatch/settle vocabulary since #4546 and no production
26
+ * caller: `spend-ledger.jsonl` was never created by ordinary traffic, and the ceilings the
27
+ * feature advertised stayed process-local and count-only, resetting on restart. This is the
28
+ * caller.
29
+ *
30
+ * It books one entry per physical send by observing the request's own send counter rather than
31
+ * by being called from each dispatch site. That counter moves exactly once per physical send,
32
+ * so one entry per increment is one entry per send -- and a dispatch path added later cannot
33
+ * forget to book, which is how the previous wiring attempt ended up with no caller at all.
34
+ *
35
+ * Settlement follows what the request actually learned. The terminal usage belongs to the LAST
36
+ * send that left, so that one settles with the real figure. Every earlier send failed without
37
+ * reporting usage of its own and may still have been billed, so it becomes unresolved spend
38
+ * rather than free. A request that ends with no usage at all -- a cancel, a lost stream --
39
+ * leaves all of them unresolved, which is the conservative answer this ledger exists to give.
40
+ */
41
+ export function createRequestSpendTracker(
42
+ logCtx: Pick<
43
+ RequestLogContext,
44
+ "provider" | "accountLogLabel" | "usageLogInputTokens" | "spendOutputCeilingTokens"
45
+ >,
46
+ rootId: string | undefined,
47
+ injected?: SpendReservationLedger,
48
+ ): RequestSpendTracker {
49
+ // Resolved on the first CHARGE, not when the request is built. The shared ledger opens a
50
+ // journal under the OpenCodex home, and a request that never dispatches -- refused at
51
+ // admission, answered locally, cancelled before its first send -- has no business creating
52
+ // one. It also means the home in effect at dispatch is the one that gets written.
53
+ let ledgerRef: SpendReservationLedger | undefined = injected;
54
+ const ledger = (): SpendReservationLedger => (ledgerRef ??= sharedSpendLedger());
55
+ // Every send this request still owes the ledger an answer for, oldest first.
56
+ const live: string[] = [];
57
+ let refusals = 0;
58
+ let resolved = false;
59
+ /**
60
+ * Confirm the sends this request has already moved past.
61
+ *
62
+ * A booking is only marked dispatched once a LATER send exists, because that later send
63
+ * proves the earlier one left. The newest booking stays open until it is settled, so a
64
+ * reservation the budget hands back -- a rotation that found no alternate, a rebuild
65
+ * abandoned before the wire -- can still be released for free while this process is alive.
66
+ * A crash resolves every surviving reservation as unresolved spend regardless of this mark,
67
+ * because a journal that lost its tail cannot prove a send never left.
68
+ */
69
+ const confirmOlderSends = (): void => {
70
+ for (let index = 0; index < live.length - 1; index += 1) ledger().markDispatched(live[index] as string);
71
+ };
72
+ return {
73
+ charge(): boolean {
74
+ const sendId = randomUUID();
75
+ const decision = ledger().reserve({
76
+ sendId,
77
+ scopes: {
78
+ ...(rootId !== undefined ? { rootId } : {}),
79
+ // Already the privacy-safe label the request log uses, and the ledger aliases it
80
+ // again on the way to disk. A raw credential never reaches either.
81
+ ...(logCtx.accountLogLabel !== undefined ? { identityId: logCtx.accountLogLabel } : {}),
82
+ ...(logCtx.provider !== undefined ? { poolId: logCtx.provider } : {}),
83
+ },
84
+ inputTokens: logCtx.usageLogInputTokens ?? 0,
85
+ outputCeilingTokens: logCtx.spendOutputCeilingTokens ?? 0,
86
+ });
87
+ if (!decision.reserved) {
88
+ refusals += 1;
89
+ // Only an operator's configured ceiling refuses a dispatch. Every other denial --
90
+ // capacity, durability, a journal this process could not prove complete -- means the
91
+ // ledger cannot ACCOUNT for this send, which is not a reason to refuse one. An
92
+ // unconfigured install keeps the count caps it already had and is not newly refused,
93
+ // and a degraded ledger must not become an outage.
94
+ return decision.denial.reason !== "spend-limit-exceeded";
95
+ }
96
+ live.push(sendId);
97
+ confirmOlderSends();
98
+ return true;
99
+ },
100
+ refund(): void {
101
+ const sendId = live.pop();
102
+ if (sendId === undefined) return;
103
+ // Undispatched, so this returns the tokens. If the send was already confirmed by a later
104
+ // one, `abandon` refuses and unresolved is the only honest outcome left.
105
+ if (!ledger().abandon(sendId)) ledger().markLost(sendId);
106
+ },
107
+ settle(usage: TerminalSpendUsage | undefined): void {
108
+ if (resolved) return;
109
+ resolved = true;
110
+ const terminal = live.pop();
111
+ if (terminal !== undefined) {
112
+ const reported = typeof usage?.inputTokens === "number" || typeof usage?.outputTokens === "number";
113
+ if (reported) {
114
+ ledger().settle(terminal, {
115
+ inputTokens: usage?.inputTokens ?? 0,
116
+ outputTokens: usage?.outputTokens ?? 0,
117
+ });
118
+ } else {
119
+ // The response never reported usage. It may still have been billed.
120
+ ledger().markLost(terminal);
121
+ }
122
+ }
123
+ for (const sendId of live.splice(0)) ledger().markLost(sendId);
124
+ },
125
+ get refusals(): number { return refusals; },
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Give a request a spend tracker and hand back the observer its budget reports through.
131
+ *
132
+ * The tracker is parked on the log context because `addFinalRequestLog` is the one seam every
133
+ * request passes exactly once, whatever transport served it and however it ended, and it is
134
+ * where the terminal usage is already known.
135
+ */
136
+ export function attachRequestSpendTracker(
137
+ req: Pick<Request, "headers">,
138
+ logCtx: RequestLogContext,
139
+ ledger?: SpendReservationLedger,
140
+ ): RequestSendObserver {
141
+ const rootId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined;
142
+ const tracker = ledger === undefined
143
+ ? createRequestSpendTracker(logCtx, rootId)
144
+ : createRequestSpendTracker(logCtx, rootId, ledger);
145
+ logCtx.spendTracker = tracker;
146
+ return tracker;
147
+ }
@@ -8,7 +8,7 @@ import {
8
8
  credentialGeneration,
9
9
  } from "../../oauth/store";
10
10
  import type { ProviderAdapter, AdapterRequest } from "../../adapters/base";
11
- import type { OcxParsedRequest, OcxProviderConfig } from "../../types";
11
+ import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../../types";
12
12
  import type { AnthropicAccountSelectionReason } from "../../oauth/anthropic-routing";
13
13
  import {
14
14
  isAnthropicAccountPoolEnabled,
@@ -33,7 +33,7 @@ import {
33
33
  preferredInitialAccount,
34
34
  noteGenericPoolSelection,
35
35
  } from "../../oauth/generic-account-failover";
36
- import { stampOAuthAccountLabel } from "../../providers/label";
36
+ import { stampOAuthAccountLabel, usesApiKeyAccount } from "../../providers/label";
37
37
  import { resolveProviderTransport } from "../../providers/xai-transport";
38
38
  import { resolveCopilotApiBaseUrl } from "../../oauth/github-copilot";
39
39
  import {
@@ -59,7 +59,11 @@ import {
59
59
  sealRequestAttemptIdentity,
60
60
  recordAttemptCredentialSource,
61
61
  recordAdapterTierMetadata,
62
+ noteProviderAttemptSend,
63
+ recordKeyAttemptFailure,
64
+ recordKeyAttemptUsage,
62
65
  } from "../request-log";
66
+ import type { AttemptRecoveryKind } from "../../usage/log";
63
67
  import { resolvePassiveRouteSubjectId } from "../passive-route-linker";
64
68
 
65
69
  /** Owns live credential selection and adapter bindings for one request. */
@@ -241,6 +245,30 @@ export async function prepareResponsesTransport(
241
245
  replayOAuthCredentialSnapshot = { accountId: snapshot.accountId, generation: snapshot.generation };
242
246
  return true;
243
247
  };
248
+ // Key sends may be rebuilt while queued. Keep metadata pending until the guarded
249
+ // physical dispatch binds it to the selection that actually reaches the upstream.
250
+ let pendingKeySend: { estimate: number | undefined; recovery?: AttemptRecoveryKind } | undefined;
251
+ const noteRoutedAttemptSend = (estimate: number | undefined, recovery?: AttemptRecoveryKind): void => {
252
+ if (usesApiKeyAccount(route.provider)) pendingKeySend = { estimate, recovery };
253
+ else noteProviderAttemptSend(logCtx, route.providerName, route.provider, estimate, recovery);
254
+ };
255
+ const commitKeyAttemptSend = (): void => {
256
+ if (!usesApiKeyAccount(route.provider)) return;
257
+ noteProviderAttemptSend(logCtx, route.providerName, route.provider,
258
+ pendingKeySend?.estimate ?? logCtx.usageLogInputTokens, pendingKeySend?.recovery);
259
+ pendingKeySend = undefined;
260
+ };
261
+ const bindKeyUsageFromBridge = (usage: OcxUsage | undefined): void => {
262
+ logCtx.usageFromBridge = true;
263
+ if (usesApiKeyAccount(route.provider)) {
264
+ logCtx.usage = logCtx.activeAttempt?.usage;
265
+ return;
266
+ }
267
+ if (usage) {
268
+ logCtx.usage = usage;
269
+ if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
270
+ }
271
+ };
244
272
  const selectionIsCurrent = (binding: DispatchBinding | undefined): boolean => {
245
273
  if (route.provider.authMode === "forward") return true;
246
274
  if (!binding) return false;
@@ -260,6 +288,27 @@ export async function prepareResponsesTransport(
260
288
  : undefined
261
289
  : { kind: "api-key", provider: { ...route.provider } };
262
290
  if (binding) adapterBindings.set(resolved, binding);
291
+ // Observe terminals before search/image loops or continuation guards hide earlier rounds.
292
+ // Each adapter parser is called once per physical response; bridge totals are client-only.
293
+ const observedResponses = new WeakSet<object>();
294
+ const observeUsage = (event: AdapterEvent, response: object): void => {
295
+ if (usesApiKeyAccount(provider) && "usage" in event && event.usage && !observedResponses.has(response)) {
296
+ observedResponses.add(response);
297
+ recordKeyAttemptUsage(logCtx, event.usage);
298
+ }
299
+ };
300
+ const parseStream = resolved.parseStream.bind(resolved);
301
+ resolved.parseStream = async function* (...args) {
302
+ for await (const event of parseStream(...args)) { observeUsage(event, args[0]); yield event; }
303
+ };
304
+ if (resolved.parseResponse) {
305
+ const parseResponse = resolved.parseResponse.bind(resolved);
306
+ resolved.parseResponse = async (...args) => {
307
+ const events = await parseResponse(...args);
308
+ events.forEach(event => observeUsage(event, args[0]));
309
+ return events;
310
+ };
311
+ }
263
312
  const build = resolved.buildRequest.bind(resolved);
264
313
  resolved.buildRequest = async (requestParsed, incoming) => {
265
314
  const request = await build(requestParsed, incoming);
@@ -268,7 +317,11 @@ export async function prepareResponsesTransport(
268
317
  return request;
269
318
  };
270
319
  if (resolved.runTurn) {
271
- rawRunTurns.set(resolved, resolved.runTurn.bind(resolved));
320
+ const runTurn = resolved.runTurn.bind(resolved);
321
+ rawRunTurns.set(resolved, (requestParsed, incoming, emit) => {
322
+ const response = {};
323
+ return runTurn(requestParsed, incoming, event => { observeUsage(event, response); emit(event); });
324
+ });
272
325
  resolved.runTurn = (requestParsed, incoming, emit) => runSelectedTurn(resolved, requestParsed, incoming, emit);
273
326
  }
274
327
  return resolved;
@@ -319,6 +372,7 @@ export async function prepareResponsesTransport(
319
372
  refused = true;
320
373
  throw new Error("Account selection changed before the first turn dispatch");
321
374
  }
375
+ commitKeyAttemptSend();
322
376
  sent = true;
323
377
  },
324
378
  });
@@ -351,7 +405,9 @@ export async function prepareResponsesTransport(
351
405
  && sentHeaders?.get("authorization") === `Bearer ${snapshot.accessToken}`
352
406
  && !sentHeaders?.has("x-api-key");
353
407
  // Reselection can choose a provider override instead of the supplied executor.
408
+ commitKeyAttemptSend();
354
409
  const response = await fetchImpl(destination, { ...dispatchInit, redirect: "manual" });
410
+ if (!response.ok) await recordKeyAttemptFailure(logCtx, response, dispatchInit.signal ?? options.abortSignal);
355
411
  // Observe each physical response before retries replace it. The binding belongs to
356
412
  // this dispatch, so a manual switch cannot file A's headers against B. Header
357
413
  // overrides and credential replacement make ownership unprovable: skip those writes.
@@ -736,6 +792,9 @@ export async function prepareResponsesTransport(
736
792
  resolveSelectionAdapter,
737
793
  refreshRunTurnAdapter,
738
794
  oauthDispatch,
795
+ noteRoutedAttemptSend,
796
+ commitKeyAttemptSend,
797
+ bindKeyUsageFromBridge,
739
798
  anthropicSessionKey,
740
799
  isPassthrough,
741
800
  };