@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
@@ -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
@@ -1,6 +1,8 @@
1
+ import { isDeclaredReasoningEffort } from "../../reasoning-effort";
2
+ import { recordAttemptRequestedEffort } from "../request-log";
1
3
  import {
2
4
  CODEX_TEXT_GUARDED_BUDGET_POLICY,
3
- createRequestExecutionBudget,
5
+ deriveRequestExecutionBudget,
4
6
  isRequestExecutionBudget,
5
7
  } from "../../lib/request-execution-budget";
6
8
  import type {
@@ -44,6 +46,7 @@ import { isThreadSpawnRequest, supportedLadderFor } from "../effort-policy";
44
46
  import {
45
47
  clientCancelledResponse,
46
48
  comboUnavailable,
49
+ targetIncompatibleResponse,
47
50
  unreadableEncryptedAgentTaskResponse,
48
51
  } from "./core-errors";
49
52
  import {
@@ -51,7 +54,12 @@ import {
51
54
  createChildPassthroughCallbackGate,
52
55
  consumeComboFailure,
53
56
  } from "./core-combo-failure";
54
- import { linkRequestSessionLane, sessionLaneIdFromRequest } from "../request-log-conversation";
57
+ import {
58
+ linkRequestSessionLane,
59
+ reasoningReplayConversationIdFromResponsesRequest,
60
+ sessionIdHeaderFromRequest,
61
+ sessionLaneIdFromRequest,
62
+ } from "../request-log-conversation";
55
63
  import type { CodexAuthContext } from "../../codex/auth-context";
56
64
  import type { ResponsesTerminalStatus } from "../../bridge";
57
65
  import { beginRequestAttempt, sealRequestAttemptIdentity, finishRequestAttempt } from "../request-log";
@@ -65,6 +73,7 @@ import {
65
73
  } from "../relay";
66
74
  import { preflightComboStreamResponse } from "./combo-stream-preflight";
67
75
  import { streamingContextOverflowResponse, jsonContextOverflowResponse } from "./context-overflow";
76
+ import { mandatoryResponsesReasoningReplayUnavailable } from "./core-replay";
68
77
 
69
78
  /**
70
79
  * Sends one combo target may run on its own before the ladder moves on. A target is a whole
@@ -105,25 +114,27 @@ export function comboExecutionBudgetPolicy(declaredTargets: number): RequestExec
105
114
  /**
106
115
  * A budget scope that keeps its own recovery ledgers but spends the SAME request-wide counter.
107
116
  *
108
- * `used` is redefined as an accessor onto the parent because the factory reads it back off this
109
- * object -- `remainingBaseSends` and the total check both do -- so a copied number would let a
110
- * combo target run its ladder against a stale total, which is precisely the per-layer counting
111
- * this work exists to remove. The reserve, alternate-target and transition ledgers stay
112
- * per-scope on purpose: a combo target's account failover is its own recovery decision, while
113
- * the request total still bounds every target together.
117
+ * The sharing has to happen inside the factory. Redefining `used` as an accessor onto the parent
118
+ * only shared what callers read from the outside: `remainingBaseSends`, the total check and the
119
+ * reserve test all consult the factory's own private counter, which an overridden property
120
+ * cannot reach. Each derived scope therefore admitted dispatches as though the request had spent
121
+ * nothing, and the per-target holdback below -- expressed against `maxTotalModelSends` -- had
122
+ * nothing to hold back from.
123
+ *
124
+ * `deriveRequestExecutionBudget` binds the scope to the parent's real ledger, including pending
125
+ * externally-counted bookings and the durable-spend observer, all of which must travel together.
126
+ * A pending booking is a send already counted in the total and waiting for its reporter, and the
127
+ * observer books by watching that same counter move (#4707) -- so a scope that spent the counter
128
+ * without carrying the observer would move it without booking, and this combo's child sends
129
+ * would go missing from the spend ledger. The reserve, alternate-target and transition ledgers
130
+ * stay per-scope on purpose: a combo target's account failover is its own recovery decision,
131
+ * while the request total still bounds every target together.
114
132
  */
115
133
  export function deriveSendBudgetScope(
116
134
  parent: RequestExecutionBudget,
117
135
  policy: RequestExecutionBudgetPolicy,
118
136
  ): RequestExecutionBudget {
119
- const scope = createRequestExecutionBudget(policy, parent.logicalRequestId);
120
- Object.defineProperty(scope, "used", {
121
- get: () => parent.used,
122
- set: (value: number) => { parent.used = value; },
123
- enumerable: true,
124
- configurable: true,
125
- });
126
- return scope;
137
+ return deriveRequestExecutionBudget(parent, policy);
127
138
  }
128
139
 
129
140
 
@@ -229,6 +240,29 @@ export async function executeComboResponses(
229
240
  : undefined,
230
241
  recoveredPlaintext: false,
231
242
  };
243
+ const reasoningReplayConversationId = reasoningReplayConversationIdFromResponsesRequest({
244
+ clientThreadId: inboundClientThreadId,
245
+ threadIdHeader: req.headers.get("thread-id"),
246
+ sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
247
+ });
248
+ const reasoningReplayEligible = (target: (typeof combo.targets)[number]): boolean => {
249
+ try {
250
+ const route = routeConcreteModel(config, `${target.provider}/${target.model}`);
251
+ const unavailable = mandatoryResponsesReasoningReplayUnavailable({
252
+ body,
253
+ clientThreadId: reasoningReplayConversationId,
254
+ providerName: route.providerName,
255
+ provider: route.provider,
256
+ adapterName: route.provider.adapter,
257
+ modelId: route.modelId,
258
+ });
259
+ return !unavailable;
260
+ } catch {
261
+ // Routing failures are not evidence of replay incompatibility. Keep the target eligible so
262
+ // the existing selection and dispatch path preserves its original routing failure surface.
263
+ return true;
264
+ }
265
+ };
232
266
  const adoptFailedChildLog = (childLog: RequestLogContext): void => {
233
267
  // Attempts remain the complete physical history; the logical row mirrors the most recent
234
268
  // failed target so an exhausted combo still has useful top-level reasoning diagnostics.
@@ -260,6 +294,18 @@ export async function executeComboResponses(
260
294
  let comboPayloadReadable = false;
261
295
  const payloadEligible = (target: (typeof combo.targets)[number]): boolean =>
262
296
  comboPayloadReadable || !unreadableEncryptedAgentTask || canDecryptUnreadableAgentTask(target);
297
+ const targetEligible = (target: (typeof combo.targets)[number]): boolean =>
298
+ payloadEligible(target) && reasoningReplayEligible(target);
299
+ const onlyReplayIncompatibleTargetsRemain = (excluded: Iterable<string> = []): boolean => {
300
+ const excludedKeys = new Set(excluded);
301
+ const remaining = combo.targets.filter(target => {
302
+ const provider = config.providers[target.provider];
303
+ return provider?.disabled !== true
304
+ && !excludedKeys.has(targetKey(target))
305
+ && payloadEligible(target);
306
+ });
307
+ return remaining.length > 0 && remaining.every(target => !reasoningReplayEligible(target));
308
+ };
263
309
  let encryptedTaskRecoveryAttempted = false;
264
310
  let recoveryFailureReason: AgentTaskRecoveryFailureReason | undefined;
265
311
  let storedPool401ReplayDispatched = false;
@@ -324,7 +370,7 @@ export async function executeComboResponses(
324
370
  abortSignal: options.abortSignal,
325
371
  });
326
372
  let pick = await pickWithWait({
327
- eligible: payloadEligible,
373
+ eligible: targetEligible,
328
374
  now: initialNow,
329
375
  });
330
376
 
@@ -349,6 +395,7 @@ export async function executeComboResponses(
349
395
  }
350
396
 
351
397
  if (!pick) {
398
+ if (onlyReplayIncompatibleTargetsRemain()) return targetIncompatibleResponse();
352
399
  return options.abortSignal?.aborted
353
400
  ? clientCancelledResponse()
354
401
  : comboUnavailable(comboId);
@@ -357,6 +404,26 @@ export async function executeComboResponses(
357
404
  // adoption below must never replace it with a concrete child route trace.
358
405
  logCtx.routeDecision = comboRouteDecisionTrace(config, comboId, pick, requestedModel);
359
406
 
407
+ const originalReasoning = body && typeof body === "object" && !Array.isArray(body)
408
+ ? (body as { reasoning?: unknown }).reasoning
409
+ : undefined;
410
+ const originalRequestedEffortValue = originalReasoning && typeof originalReasoning === "object" && !Array.isArray(originalReasoning)
411
+ ? (originalReasoning as { effort?: unknown }).effort
412
+ : undefined;
413
+ const originalRequestedEffort = typeof originalRequestedEffortValue === "string"
414
+ && isDeclaredReasoningEffort(originalRequestedEffortValue)
415
+ ? originalRequestedEffortValue
416
+ : undefined;
417
+ const restoreOriginalRequestedEffort = (childLog: RequestLogContext): void => {
418
+ if (originalRequestedEffort === undefined) return;
419
+ const normalizedRequestedEffort = childLog.requestedEffort;
420
+ const transitionIndex = normalizedRequestedEffort?.indexOf("->") ?? -1;
421
+ childLog.requestedEffort = transitionIndex >= 0
422
+ ? `${originalRequestedEffort}${normalizedRequestedEffort!.slice(transitionIndex)}`
423
+ : originalRequestedEffort;
424
+ recordAttemptRequestedEffort(childLog);
425
+ };
426
+
360
427
  let lastFailure: Response | null = null;
361
428
  // Dispatched targets, not attempted picks: it indexes the declared target list so the clamp
362
429
  // below can tell how many targets are still entitled to a send.
@@ -407,6 +474,7 @@ export async function executeComboResponses(
407
474
  comboDefaultEffort(config, comboId),
408
475
  supportedLadderFor({ provider: targetRoute.provider, modelId: targetRoute.modelId }),
409
476
  combo.reasoningEffortMode,
477
+ combo.defaultEffortMode,
410
478
  );
411
479
  const childHeaders = buildComboChildHeaders(req.headers);
412
480
  const childRequest = new Request(req.url, {
@@ -425,6 +493,13 @@ export async function executeComboResponses(
425
493
  config.providers[pick.target.provider]!.adapter,
426
494
  );
427
495
  childLog.activeAttempt = attempt;
496
+ if (originalRequestedEffort !== undefined) {
497
+ childLog.requestedEffort = originalRequestedEffort;
498
+ recordAttemptRequestedEffort(childLog);
499
+ }
500
+ childLog.activeAttemptStartedAt = started;
501
+ childLog.attempts = logCtx.attempts ??= [];
502
+ childLog.attempts.push(attempt);
428
503
  let attemptRetained = false;
429
504
  const retainCancelledAttempt = (): void => {
430
505
  if (attemptRetained) return;
@@ -435,7 +510,6 @@ export async function executeComboResponses(
435
510
  childLog.accountLogLabel,
436
511
  );
437
512
  finishRequestAttempt(attempt, 499, Date.now() - started, childLog.usage);
438
- (logCtx.attempts ??= []).push(attempt);
439
513
  attemptRetained = true;
440
514
  };
441
515
  const completedTarget = { provider: pick.target.provider, model: pick.target.model };
@@ -472,7 +546,7 @@ export async function executeComboResponses(
472
546
  const deferCodexResetDerivedCooldown = combo.strategy === "failover"
473
547
  && combo.targets.slice(pick.targetIndex + 1).some(target =>
474
548
  target.provider === currentTargetProvider
475
- && payloadEligible(target)
549
+ && targetEligible(target)
476
550
  && !isComboTargetInCooldown(comboId, target),
477
551
  );
478
552
  response = await requestDispatchers.handleResponses(childRequest, config, childLog, {
@@ -499,12 +573,14 @@ export async function executeComboResponses(
499
573
  onNativePassthroughCancel: callbackGate.onCancel,
500
574
  onResponseComplete: callbackGate.onResponseComplete,
501
575
  });
576
+ restoreOriginalRequestedEffort(childLog);
502
577
  } catch (error) {
503
578
  callbackGate.discard();
504
579
  if (options.abortSignal?.aborted) {
505
580
  retainCancelledAttempt();
506
581
  return clientCancelledResponse();
507
582
  }
583
+ finishRequestAttempt(attempt, 502, Date.now() - started, childLog.usage);
508
584
  throw error;
509
585
  }
510
586
 
@@ -526,6 +602,7 @@ export async function executeComboResponses(
526
602
  retainCancelledAttempt();
527
603
  return clientCancelledResponse();
528
604
  }
605
+ finishRequestAttempt(attempt, 502, Date.now() - started, childLog.usage);
529
606
  throw error;
530
607
  }
531
608
  if (preflight.kind === "failed") {
@@ -546,7 +623,6 @@ export async function executeComboResponses(
546
623
  childLog.providerAdapter ?? attempt.adapter,
547
624
  childLog.accountLogLabel,
548
625
  );
549
- (logCtx.attempts ??= []).push(attempt);
550
626
  attemptRetained = true;
551
627
  noteComboSuccess(comboId, combo, pick.target, pick.writerGeneration);
552
628
  Object.assign(logCtx, childLog, {
@@ -580,6 +656,7 @@ export async function executeComboResponses(
580
656
  retainCancelledAttempt();
581
657
  return clientCancelledResponse();
582
658
  }
659
+ finishRequestAttempt(attempt, 502, Date.now() - started, childLog.usage);
583
660
  throw error;
584
661
  }
585
662
  if (options.abortSignal?.aborted) {
@@ -598,7 +675,6 @@ export async function executeComboResponses(
598
675
  Date.now() - started,
599
676
  failure.usage,
600
677
  );
601
- (logCtx.attempts ??= []).push(attempt);
602
678
  attemptRetained = true;
603
679
  lastFailure = failure.response;
604
680
  lastFailedChildLog = childLog;
@@ -664,7 +740,7 @@ export async function executeComboResponses(
664
740
  cooldownScope: comboFailureCooldownScope(failure.response.status, failure.classificationText, {
665
741
  code: failure.upstreamCode,
666
742
  }),
667
- eligible: payloadEligible,
743
+ eligible: targetEligible,
668
744
  status: failure.response.status,
669
745
  code: failure.upstreamCode,
670
746
  message: failure.classificationText,
@@ -674,12 +750,16 @@ export async function executeComboResponses(
674
750
  } else {
675
751
  pick = await pickWithWait({
676
752
  exclude: pick.attempted,
677
- eligible: payloadEligible,
753
+ eligible: targetEligible,
678
754
  now: failureNow,
679
755
  });
680
756
  }
681
757
  if (!pick) {
682
758
  if (options.abortSignal?.aborted) return clientCancelledResponse();
759
+ if (onlyReplayIncompatibleTargetsRemain(attemptedTargets)) {
760
+ adoptFailedChildLog(childLog);
761
+ return targetIncompatibleResponse();
762
+ }
683
763
  if (unreadableEncryptedAgentTask && !comboPayloadReadable) {
684
764
  const recoveredTarget = await pickWithWait({
685
765
  exclude: attemptedTargets,
@@ -150,3 +150,21 @@ export function unreadableEncryptedAgentTaskResponse(reason?: AgentTaskRecoveryF
150
150
  { status: 400, headers: { "Content-Type": "application/json" } },
151
151
  );
152
152
  }
153
+
154
+
155
+ export const TARGET_INCOMPATIBLE_MESSAGE =
156
+ "No remaining combo target can continue this tool-bearing history because the reasoning required for replay is unavailable after the serving route changed. Start a new conversation or configure a combo target that can consume the available reasoning.";
157
+
158
+
159
+ export function targetIncompatibleResponse(): Response {
160
+ return new Response(
161
+ JSON.stringify({
162
+ error: {
163
+ message: TARGET_INCOMPATIBLE_MESSAGE,
164
+ type: "invalid_request_error",
165
+ code: "target_incompatible",
166
+ },
167
+ }),
168
+ { status: 400, headers: { "Content-Type": "application/json" } },
169
+ );
170
+ }
@@ -27,6 +27,7 @@ import type { OAuthAccessSnapshot } from "../../oauth";
27
27
  import type { CodexAuthContext } from "../../codex/auth-context";
28
28
  import { thoughtSignatureReplaySalt } from "../../responses/thought-signature-replay";
29
29
  import { randomUUID } from "node:crypto";
30
+ import { requiresPlaintextReasoningReplay } from "../../adapters/openai-responses/passthrough";
30
31
 
31
32
  /**
32
33
  * Adapters whose continuation state must survive Codex's store:false requests.
@@ -107,6 +108,53 @@ export function bindRouteReasoningReplayScope(args: {
107
108
  forwardHeaders?: Headers;
108
109
  }): void {
109
110
  const { parsed, providerName, provider, adapterName } = args;
111
+ const replayIdentity = routeReasoningReplayIdentity({
112
+ ...args,
113
+ modelId: parsed.modelId,
114
+ });
115
+ const continuationDestinationIdentity = providerContinuationDestinationIdentity(parsed, provider);
116
+ const continuationOwner = providerContinuationOwnerFromReplayIdentity(
117
+ replayIdentity && continuationDestinationIdentity
118
+ ? { ...replayIdentity, providerDestinationIdentity: continuationDestinationIdentity }
119
+ : undefined,
120
+ );
121
+ if (adapterName === "cursor") {
122
+ // The final route owner is authoritative for Cursor and supersedes the account-derived
123
+ // seed assigned before route binding. A Cursor conversation must be scoped to the exact
124
+ // provider/destination/adapter/model/credential that serves it.
125
+ if (continuationOwner) parsed._cursorIdentityScope = providerContinuationRouteScope(continuationOwner);
126
+ else if (!parsed._cursorIdentityScope?.startsWith("cursor-unowned:")) {
127
+ // Prevent the adapter's token-only fallback from recreating a provider-private id after the
128
+ // route owner failed closed. The sentinel is per parsed request and contains no credential.
129
+ parsed._cursorIdentityScope = `cursor-unowned:${randomUUID()}`;
130
+ }
131
+ }
132
+ bindReasoningReplayScope(
133
+ parsed._reasoningReplayScope,
134
+ replayIdentity,
135
+ );
136
+ // Keep this sticky for the whole outbound request: a later auth/key rebind may compare equal
137
+ // after the first mismatch, but it cannot make history minted by the prior route decodable.
138
+ if (reasoningReplayServingIdentityChanged(parsed._reasoningReplayScope)) {
139
+ parsed._stripReasoningEncryptedContent = true;
140
+ }
141
+ if (reasoningReplayOpaqueBlobRejectionMemoized(parsed._reasoningReplayScope)) {
142
+ parsed._stripReasoningEncryptedContent = true;
143
+ }
144
+ bindProviderContinuationForRoute(parsed, continuationOwner);
145
+ }
146
+
147
+
148
+ function routeReasoningReplayIdentity(args: {
149
+ providerName: string;
150
+ provider: OcxProviderConfig;
151
+ adapterName: string;
152
+ modelId: string;
153
+ oauthCredentialSnapshot?: Pick<OAuthAccessSnapshot, "accountId" | "generation">;
154
+ codexAuthContext?: CodexAuthContext;
155
+ forwardHeaders?: Headers;
156
+ }): OcxReasoningReplayIdentity | undefined {
157
+ const { providerName, provider, adapterName, modelId } = args;
110
158
  let credentialIdentity: string | undefined;
111
159
  let credentialDurableIdentity: string | undefined;
112
160
  const durableSalt = thoughtSignatureReplaySalt();
@@ -165,47 +213,72 @@ export function bindRouteReasoningReplayScope(args: {
165
213
  );
166
214
  }
167
215
  const providerDestinationIdentity = reasoningReplayDestinationIdentity(provider.baseUrl);
168
- const replayIdentity: OcxReasoningReplayIdentity | undefined = credentialIdentity && providerDestinationIdentity
216
+ return credentialIdentity && providerDestinationIdentity
169
217
  ? {
170
218
  providerName,
171
219
  providerDestinationIdentity,
172
220
  providerDestinationDurableIdentity: durableReplayDestinationIdentity(provider.baseUrl),
173
221
  adapterName,
174
- modelId: parsed.modelId,
222
+ modelId,
175
223
  credentialIdentity,
176
224
  ...(credentialDurableIdentity ? { credentialDurableIdentity } : {}),
177
225
  }
178
226
  : undefined;
179
- const continuationDestinationIdentity = providerContinuationDestinationIdentity(parsed, provider);
180
- const continuationOwner = providerContinuationOwnerFromReplayIdentity(
181
- replayIdentity && continuationDestinationIdentity
182
- ? { ...replayIdentity, providerDestinationIdentity: continuationDestinationIdentity }
183
- : undefined,
184
- );
185
- if (adapterName === "cursor") {
186
- // The final route owner is authoritative for Cursor and supersedes the account-derived
187
- // seed assigned before route binding. A Cursor conversation must be scoped to the exact
188
- // provider/destination/adapter/model/credential that serves it.
189
- if (continuationOwner) parsed._cursorIdentityScope = providerContinuationRouteScope(continuationOwner);
190
- else if (!parsed._cursorIdentityScope?.startsWith("cursor-unowned:")) {
191
- // Prevent the adapter's token-only fallback from recreating a provider-private id after the
192
- // route owner failed closed. The sentinel is per parsed request and contains no credential.
193
- parsed._cursorIdentityScope = `cursor-unowned:${randomUUID()}`;
194
- }
195
- }
196
- bindReasoningReplayScope(
197
- parsed._reasoningReplayScope,
198
- replayIdentity,
199
- );
200
- // Keep this sticky for the whole outbound request: a later auth/key rebind may compare equal
201
- // after the first mismatch, but it cannot make history minted by the prior route decodable.
202
- if (reasoningReplayServingIdentityChanged(parsed._reasoningReplayScope)) {
203
- parsed._stripReasoningEncryptedContent = true;
204
- }
205
- if (reasoningReplayOpaqueBlobRejectionMemoized(parsed._reasoningReplayScope)) {
206
- parsed._stripReasoningEncryptedContent = true;
207
- }
208
- bindProviderContinuationForRoute(parsed, continuationOwner);
227
+ }
228
+
229
+
230
+ /**
231
+ * Whether this exact route cannot satisfy its documented plaintext reasoning replay contract.
232
+ *
233
+ * A generic plaintext-preserving gateway is not made ineligible. Unknown serving provenance also
234
+ * stays eligible; only a proven route mismatch may suppress a combo candidate.
235
+ */
236
+ export function mandatoryResponsesReasoningReplayUnavailable(args: {
237
+ body: unknown;
238
+ clientThreadId: string | undefined;
239
+ providerName: string;
240
+ provider: OcxProviderConfig;
241
+ adapterName: string;
242
+ modelId: string;
243
+ }): boolean {
244
+ const { body, clientThreadId, provider, adapterName } = args;
245
+ if (
246
+ adapterName !== "openai-responses"
247
+ || !requiresPlaintextReasoningReplay(provider)
248
+ || !clientThreadId
249
+ || !requestCarriesTools(body)
250
+ || !hasOpaqueOnlyReasoningItem(body)
251
+ ) return false;
252
+
253
+ const current = routeReasoningReplayIdentity(args);
254
+ return reasoningReplayServingIdentityChanged({ clientThreadId, current });
255
+ }
256
+
257
+
258
+ function requestCarriesTools(body: unknown): boolean {
259
+ if (!body || typeof body !== "object" || Array.isArray(body)) return false;
260
+ return Array.isArray((body as { tools?: unknown }).tools)
261
+ && (body as { tools: unknown[] }).tools.length > 0;
262
+ }
263
+
264
+
265
+ function hasOpaqueOnlyReasoningItem(body: unknown): boolean {
266
+ if (!body || typeof body !== "object" || Array.isArray(body)) return false;
267
+ const input = (body as { input?: unknown }).input;
268
+ if (!Array.isArray(input)) return false;
269
+ return input.some(item => {
270
+ if (!item || typeof item !== "object" || Array.isArray(item)) return false;
271
+ const reasoning = item as Record<string, unknown>;
272
+ if (reasoning.type !== "reasoning" || typeof reasoning.encrypted_content !== "string") return false;
273
+ const content = reasoning.content;
274
+ const hasPlaintext = Array.isArray(content) && content.some(part =>
275
+ !!part && typeof part === "object" && !Array.isArray(part)
276
+ && (part as Record<string, unknown>).type === "reasoning_text"
277
+ && typeof (part as Record<string, unknown>).text === "string"
278
+ && ((part as Record<string, unknown>).text as string).length > 0
279
+ );
280
+ return !hasPlaintext;
281
+ });
209
282
  }
210
283
 
211
284
 
@@ -10,6 +10,7 @@ import { createTranslatorBudget } from "../../lib/translator-budget";
10
10
  import { captureExplicitOpenAiCallerAuth } from "../../providers/openai-sidecar";
11
11
  import { captureCallerDirectAuth } from "../../providers/caller-authorization";
12
12
  import { createRequestExecutionBudget } from "../../lib/request-execution-budget";
13
+ import { attachRequestSpendTracker } from "./request-spend";
13
14
  import { finalizeOwnedTranslatorBudget } from "./core-lifetime";
14
15
  import type { TranslatorBudget } from "../../lib/translator-budget";
15
16
  import { executeComboResponses } from "./core-combo";
@@ -56,9 +57,8 @@ export async function handleResponses(
56
57
  visionDescribeTerminal: options.visionDescribeTerminal === true
57
58
  || req.headers.get("x-opencodex-vision-describe") === "1",
58
59
  translatorBudget,
59
- // Created once at genuine ingress; a combo child arrives with the parent's holder already
60
- // in options and must not start a fresh allowance.
61
- sendBudget: options.sendBudget ?? createRequestExecutionBudget(),
60
+ // Once at ingress, spend observer included: a combo child inherits the parent's holder.
61
+ sendBudget: options.sendBudget ?? createRequestExecutionBudget(undefined, undefined, attachRequestSpendTracker(req, logCtx)),
62
62
  });
63
63
  return ownsBudget ? finalizeOwnedTranslatorBudget(response, translatorBudget) : response;
64
64
  } catch (error) {
@@ -78,7 +78,6 @@ import {
78
78
  catalogModelSupportsServiceTier,
79
79
  finishRequestAttempt,
80
80
  inspectResponseLogJson,
81
- noteAttemptSend,
82
81
  readConfiguredCodexServiceTier,
83
82
  requestLogSpeedLabel,
84
83
  sealRequestAttemptIdentity,