@bitkyc08/opencodex 2.87.0 → 2.89.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 (246) hide show
  1. package/README.md +11 -1
  2. package/bin/ocx.mjs +15 -4
  3. package/gui/dist/assets/App-BnRSujOq.js +58 -0
  4. package/gui/dist/assets/App-dnkJ85te.css +1 -0
  5. package/gui/dist/assets/{Tray-DCd-A1az.js → Tray-7z1COnmz.js} +1 -1
  6. package/gui/dist/assets/index-uchD-ngf.js +94 -0
  7. package/gui/dist/assets/{quota-summary-ChvmKGOk.js → quota-summary-BY_10IEz.js} +1 -1
  8. package/gui/dist/index.html +1 -1
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/cache-retention.ts +117 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -4
  12. package/src/adapters/anthropic-rejection-memo.ts +71 -0
  13. package/src/adapters/anthropic-replay.ts +135 -0
  14. package/src/adapters/anthropic-tool-catalog.ts +11 -0
  15. package/src/adapters/anthropic.ts +86 -48
  16. package/src/adapters/coding-agent/protocol.ts +6 -0
  17. package/src/adapters/google-http.ts +3 -2
  18. package/src/adapters/google-progress-history.ts +53 -0
  19. package/src/adapters/google-vertex-endpoint.ts +67 -0
  20. package/src/adapters/google.ts +52 -30
  21. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  22. package/src/adapters/openai-responses/passthrough.ts +17 -12
  23. package/src/adapters/openai-responses/reasoning.ts +68 -1
  24. package/src/adapters/registry.ts +1 -1
  25. package/src/adapters/upstream-http-error.ts +3 -2
  26. package/src/adapters/zed.ts +7 -4
  27. package/src/bridge/internal.ts +17 -3
  28. package/src/bridge/response-json.ts +4 -4
  29. package/src/bridge/sse.ts +17 -10
  30. package/src/claude/inbound-content-options.ts +66 -0
  31. package/src/claude/inbound.ts +12 -26
  32. package/src/claude/outbound.ts +18 -5
  33. package/src/cli/account-api.ts +13 -1
  34. package/src/cli/account-extended.ts +17 -5
  35. package/src/cli/account-key-quota.ts +4 -1
  36. package/src/cli/account.ts +1 -1
  37. package/src/cli/capabilities-agents-routing.ts +45 -8
  38. package/src/cli/capabilities-base.ts +5 -5
  39. package/src/cli/capabilities-provider-models.ts +5 -4
  40. package/src/cli/combo-result.ts +8 -2
  41. package/src/cli/combo.ts +17 -0
  42. package/src/cli/cross-home-owner.ts +16 -2
  43. package/src/cli/help.ts +1 -1
  44. package/src/cli/loopback-family.ts +31 -0
  45. package/src/cli/message-command.ts +4 -1
  46. package/src/cli/message-remote-args.ts +69 -0
  47. package/src/cli/message-remote-command.ts +77 -0
  48. package/src/cli/provider-runtime.ts +69 -1
  49. package/src/cli/registry.ts +3 -3
  50. package/src/codex/account-pause.ts +6 -1
  51. package/src/codex/account-runtime-state.ts +5 -0
  52. package/src/codex/account-store.ts +30 -3
  53. package/src/codex/auth-api/account-list.ts +3 -0
  54. package/src/codex/auth-api/main-account-probe.ts +3 -2
  55. package/src/codex/auth-context.ts +9 -4
  56. package/src/codex/catalog/aggregation.ts +15 -0
  57. package/src/codex/catalog/auto-review.ts +2 -1
  58. package/src/codex/catalog/build-entries.ts +17 -7
  59. package/src/codex/catalog/derive-entry.ts +3 -1
  60. package/src/codex/catalog/metadata.ts +2 -0
  61. package/src/codex/catalog/model-visibility.ts +0 -2
  62. package/src/codex/catalog/ownership.ts +71 -0
  63. package/src/codex/catalog/parsing.ts +12 -3
  64. package/src/codex/catalog/retained-sync.ts +2 -1
  65. package/src/codex/catalog/routed-gather.ts +3 -1
  66. package/src/codex/catalog/routed-removal.ts +4 -4
  67. package/src/codex/convergence.ts +2 -1
  68. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  69. package/src/codex/low-quota-protection.ts +57 -31
  70. package/src/codex/main-account-cache.ts +77 -3
  71. package/src/codex/main-account.ts +6 -2
  72. package/src/codex/native-residue.ts +2 -3
  73. package/src/codex/quota.ts +26 -2
  74. package/src/codex/websocket-activity.ts +161 -0
  75. package/src/codex/websocket-registry.ts +25 -1
  76. package/src/combos/failover.ts +2 -1
  77. package/src/combos/jev-model-backend.ts +30 -7
  78. package/src/combos/jev-model-request.ts +20 -0
  79. package/src/combos/jev-quota-activation.ts +153 -0
  80. package/src/combos/jev-quota-config.ts +62 -0
  81. package/src/combos/jev-quota-route.ts +26 -0
  82. package/src/combos/jev-quota.ts +88 -0
  83. package/src/combos/jev.ts +25 -12
  84. package/src/combos/types.ts +27 -0
  85. package/src/compatibility/index.ts +3 -0
  86. package/src/compatibility/openai-responses.ts +8 -1
  87. package/src/compatibility/opengateway-responses.ts +61 -0
  88. package/src/config/account-auto-pause-revisions.ts +136 -0
  89. package/src/config/diagnostics.ts +13 -0
  90. package/src/config/live-reconcile.ts +9 -0
  91. package/src/config/opencode-zen-upgrade.ts +9 -8
  92. package/src/config/persist-unlocked.ts +10 -1
  93. package/src/config/removed-opencode-free.ts +135 -0
  94. package/src/config/schema/account-auto-pause.ts +14 -0
  95. package/src/config/schema/config-schema.ts +15 -0
  96. package/src/config/schema/leaf-validators.ts +7 -0
  97. package/src/config.ts +10 -3
  98. package/src/generated/compatibility-version.json +411 -175
  99. package/src/images/loop.ts +101 -27
  100. package/src/lib/account-auto-pause-response.ts +16 -0
  101. package/src/lib/bun-runtime.ts +18 -1
  102. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  103. package/src/lib/bun-transpiler-cache-scope.mjs +68 -0
  104. package/src/lib/decision-quota-sink.ts +76 -0
  105. package/src/lib/request-failure-model.ts +2 -0
  106. package/src/lib/state-store-registrations.ts +25 -0
  107. package/src/link/ssh-argv.ts +3 -0
  108. package/src/messaging/envelope.ts +6 -3
  109. package/src/messaging/remote-auth.ts +83 -0
  110. package/src/messaging/remote-bridge.ts +270 -0
  111. package/src/messaging/remote-contract.ts +108 -0
  112. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  113. package/src/messaging/remote-enrollment.ts +244 -0
  114. package/src/messaging/remote-envelope.ts +40 -0
  115. package/src/messaging/remote-files.ts +117 -0
  116. package/src/messaging/remote-owner.ts +169 -0
  117. package/src/messaging/remote-ports.ts +56 -0
  118. package/src/messaging/remote-process.ts +131 -0
  119. package/src/messaging/remote-replies.ts +98 -0
  120. package/src/messaging/remote-rpc-admission.ts +32 -0
  121. package/src/messaging/remote-send.ts +72 -0
  122. package/src/messaging/remote-store.ts +111 -0
  123. package/src/messaging/remote-tunnels.ts +65 -0
  124. package/src/messaging/rpc.ts +13 -1
  125. package/src/oauth/account-auto-pause-policy.ts +66 -0
  126. package/src/oauth/account-auto-pause.ts +171 -0
  127. package/src/oauth/account-quota-rank.ts +2 -1
  128. package/src/oauth/anthropic-account-refusal.ts +6 -0
  129. package/src/oauth/anthropic-model-family.ts +8 -0
  130. package/src/oauth/anthropic-model-quota.ts +2 -6
  131. package/src/oauth/api-key-pool-preserve.ts +37 -0
  132. package/src/oauth/generic-account-failover.ts +7 -4
  133. package/src/oauth/index.ts +5 -35
  134. package/src/oauth/store.ts +35 -8
  135. package/src/oauth/types.ts +2 -0
  136. package/src/plugins/upstream-hooks.ts +14 -1
  137. package/src/protocols/encoders/adapter-events.ts +5 -5
  138. package/src/protocols/encoders/messages.ts +2 -2
  139. package/src/providers/api-key-pause.ts +32 -0
  140. package/src/providers/api-key-resolve.ts +10 -4
  141. package/src/providers/api-key-selection.ts +6 -1
  142. package/src/providers/api-keys.ts +8 -4
  143. package/src/providers/claude-cli-usage.ts +5 -0
  144. package/src/providers/key-failover.ts +11 -11
  145. package/src/providers/model-rename-fields.ts +1 -0
  146. package/src/providers/opencode-zen-rate-limit.ts +12 -73
  147. package/src/providers/quota/account-cache.ts +15 -1
  148. package/src/providers/quota/anthropic-account-quota.ts +2 -1
  149. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  150. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  151. package/src/providers/quota/antigravity.ts +39 -6
  152. package/src/providers/quota/report-cache.ts +14 -0
  153. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  154. package/src/providers/quota-decision-publication.ts +77 -0
  155. package/src/providers/quota-decision-snapshot.ts +145 -0
  156. package/src/providers/quota-routing-cache.ts +3 -0
  157. package/src/providers/quota-types.ts +6 -1
  158. package/src/providers/quota.ts +3 -2
  159. package/src/providers/registry/entries-extended.ts +8 -52
  160. package/src/providers/registry/model-seeds.ts +1 -1
  161. package/src/providers/registry.ts +1 -2
  162. package/src/providers/stale-vision-classification-migration.ts +1 -1
  163. package/src/responses/parser.ts +18 -10
  164. package/src/responses/reasoning-replay-cache.ts +50 -4
  165. package/src/responses/web-search-replay.ts +138 -0
  166. package/src/routing/analytics.ts +7 -15
  167. package/src/routing/compatibility/behavior.ts +4 -1
  168. package/src/server/auth-cors.ts +7 -0
  169. package/src/server/background-lifecycle.ts +2 -1
  170. package/src/server/chat-native.ts +28 -13
  171. package/src/server/claude-messages.ts +4 -2
  172. package/src/server/index/live-sideband.ts +3 -2
  173. package/src/server/index/optional-listeners.ts +6 -0
  174. package/src/server/index/serve-options.ts +11 -5
  175. package/src/server/index/websocket-handler.ts +18 -4
  176. package/src/server/index.ts +7 -7
  177. package/src/server/lifecycle.ts +13 -1
  178. package/src/server/management/combo-routes.ts +17 -2
  179. package/src/server/management/oauth-account-routes.ts +10 -1
  180. package/src/server/management/provider-completion-test.ts +289 -0
  181. package/src/server/management/provider-routes.ts +9 -1
  182. package/src/server/management/route-registry.ts +2 -0
  183. package/src/server/messages-native.ts +34 -7
  184. package/src/server/messages-shared.ts +4 -1
  185. package/src/server/request-log.ts +7 -2
  186. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  187. package/src/server/responses/adapter-continuation.ts +34 -18
  188. package/src/server/responses/adapter-delivery.ts +53 -6
  189. package/src/server/responses/adapter-dispatch.ts +104 -29
  190. package/src/server/responses/anthropic-thinking-recovery.ts +153 -0
  191. package/src/server/responses/combo-session-recall.ts +25 -0
  192. package/src/server/responses/combo-stream-preflight.ts +6 -3
  193. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  194. package/src/server/responses/compact.ts +8 -5
  195. package/src/server/responses/compaction-recovery.ts +2 -0
  196. package/src/server/responses/context-overflow.ts +141 -2
  197. package/src/server/responses/core-codex-account.ts +9 -3
  198. package/src/server/responses/core-combo.ts +34 -7
  199. package/src/server/responses/core-lifetime.ts +3 -2
  200. package/src/server/responses/core-options.ts +2 -0
  201. package/src/server/responses/core-replay.ts +5 -1
  202. package/src/server/responses/core.ts +12 -9
  203. package/src/server/responses/empty-completion-guard.ts +2 -0
  204. package/src/server/responses/fetch-helpers.ts +35 -4
  205. package/src/server/responses/jev-model-invoke.ts +13 -14
  206. package/src/server/responses/native-steering-log.ts +2 -2
  207. package/src/server/responses/passthrough-delivery.ts +3 -2
  208. package/src/server/responses/passthrough-dispatch.ts +30 -33
  209. package/src/server/responses/passthrough-execution.ts +3 -2
  210. package/src/server/responses/passthrough-observations.ts +38 -0
  211. package/src/server/responses/request-compression.ts +98 -0
  212. package/src/server/responses/request-prepare.ts +1 -0
  213. package/src/server/responses/request-transport.ts +32 -3
  214. package/src/server/responses/response-effects.ts +1 -0
  215. package/src/server/responses/response-tool-ownership.ts +21 -0
  216. package/src/server/responses/run-turn-execution.ts +2 -0
  217. package/src/server/responses/sidecar-execution.ts +59 -10
  218. package/src/server/responses/sidecar-send-budget.ts +2 -2
  219. package/src/server/responses/terminal-guard.ts +2 -0
  220. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  221. package/src/server/startup-action-control.ts +2 -2
  222. package/src/server/startup-health-cache.ts +2 -2
  223. package/src/server/windows-tray-control.ts +2 -2
  224. package/src/server/ws-bridge.ts +2 -0
  225. package/src/service/definition.ts +9 -1
  226. package/src/types/account-auto-pause.ts +15 -0
  227. package/src/types/config.ts +23 -1
  228. package/src/types/opencode-zen.ts +9 -3
  229. package/src/types/provider.ts +9 -5
  230. package/src/types/request.ts +30 -4
  231. package/src/types/wire.ts +4 -2
  232. package/src/types.ts +2 -0
  233. package/src/usage/cost.ts +17 -1
  234. package/src/usage/expected-prices.ts +3 -1
  235. package/src/usage/jev-stats.ts +4 -0
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/summary.ts +16 -3
  238. package/src/usage/telemetry-contract.ts +1 -0
  239. package/src/web-search/anthropic-history.ts +59 -0
  240. package/src/web-search/loop.ts +212 -73
  241. package/src/web-search/progress-stream.ts +14 -1
  242. package/src/web-search/run-turn-loop.ts +9 -3
  243. package/src/web-search/sidecar-search-replay.ts +96 -0
  244. package/gui/dist/assets/App-ZfYbLHkB.js +0 -58
  245. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
  246. package/gui/dist/assets/index-CfYDMj34.js +0 -94
@@ -45,6 +45,7 @@ import {
45
45
  } from "../../combos";
46
46
  import { formatErrorResponse } from "../../bridge";
47
47
  import { SEND_BUDGET_EXHAUSTED_CODE } from "../../lib/errors";
48
+ import { redactSecretString, sanitizeLogMetadataString } from "../../lib/redact";
48
49
  import {
49
50
  expandPreviousResponseInput,
50
51
  previousResponseReplayFailure,
@@ -98,6 +99,7 @@ import type { CodexAuthContext } from "../../codex/auth-context";
98
99
  import type { ResponsesTerminalStatus } from "../../bridge";
99
100
  import { beginRequestAttempt, sealRequestAttemptIdentity, finishRequestAttempt } from "../request-log";
100
101
  import { rememberComboForLane } from "./combo-session-recall";
102
+ import { COMBO_TOOL_LOOP_PIN_REASON, toolLoopPinnedPick } from "./combo-tool-loop-pin";
101
103
  import { runTurnAdapterSseResponses } from "./core-lifetime";
102
104
  import { normalizePersistedJevDecision } from "../../usage/jev-stats";
103
105
  import {
@@ -144,7 +146,8 @@ function cooledPoolAccountLabel(config: OcxConfig, providerName: string, modelId
144
146
  * through `accountLogLabel`, but the per-request account budget can end the pool's own rotation
145
147
  * before the last refused account is recorded. Hand that account to the pool's rotator, which
146
148
  * cools it under the pool's own Retry-After policy, and spare the target only when the pool still
147
- * has an eligible sibling. A lone account or an all-cooled pool returns null and cools the target.
149
+ * has an eligible sibling. The rotator only peeks round-robin here: nothing is sent to that sibling.
150
+ * A lone account or an all-cooled pool returns null and cools the target.
148
151
  */
149
152
  function cooledGenericOAuthAccountLabel(
150
153
  config: OcxConfig, providerName: string, modelId: string, label: string | undefined, retryAfter: string | undefined,
@@ -155,7 +158,7 @@ function cooledGenericOAuthAccountLabel(
155
158
  const matches = getAccountSet(providerName)?.accounts.filter(account =>
156
159
  oauthAccountLogLabel(account.id, base) === label) ?? [];
157
160
  return matches.length === 1
158
- && rotateGenericOAuthAccountOn429(config, providerName, matches[0]!.id, retryAfter, Date.now(), modelId)
161
+ && rotateGenericOAuthAccountOn429(config, providerName, matches[0]!.id, retryAfter, Date.now(), modelId, false)
159
162
  ? label : undefined;
160
163
  }
161
164
 
@@ -588,7 +591,19 @@ export async function executeComboResponses(
588
591
  waitForCooldownMs: combo.waitForCooldownMs,
589
592
  abortSignal: options.abortSignal,
590
593
  });
591
- let pick = await pickWithWait({
594
+ // An in-flight tool loop stays on the target that issued its calls while that target is still
595
+ // selectable (#7298). Override routes never record a lane target, so they never pin either.
596
+ const toolLoopPin = options.compactionRoutingOverride || options.mainHardLockFallbackOverride
597
+ ? undefined
598
+ : toolLoopPinnedPick({
599
+ config,
600
+ comboId,
601
+ lane: sessionLaneIdFromRequest(req.headers),
602
+ body,
603
+ eligible: target => (meteredAllowed || !target.metered) && targetEligible(target),
604
+ now: initialNow,
605
+ });
606
+ let pick = toolLoopPin ?? await pickWithWait({
592
607
  eligible: targetEligible,
593
608
  now: initialNow,
594
609
  });
@@ -624,7 +639,7 @@ export async function executeComboResponses(
624
639
  : comboUnavailable(comboId, undefined, comboExclusionReasons(config, comboId, targetEligible));
625
640
  }
626
641
  let jevDecision: JevDecision | undefined;
627
- if (combo.strategy === "jev") {
642
+ if (combo.strategy === "jev" && !toolLoopPin) {
628
643
  const choices = eligibleJevComboChoices(config, comboId, targetEligible, Date.now());
629
644
  const first = choices[0];
630
645
  if (!first) {
@@ -652,6 +667,7 @@ export async function executeComboResponses(
652
667
  isDestinationAllowed: (providerName, modelId) => routeAllowedByScope(
653
668
  resolveAdmissionModelScope(config, options.admission), { providerName, modelId },
654
669
  ),
670
+ ...(combo.decisionQuotaSignals === true ? { decisionQuotaSignals: true, decisionQuotaTiers: combo.decisionQuotaTiers } : {}),
655
671
  ...(combo.decisionProvider ? { decisionProvider: combo.decisionProvider } : {}),
656
672
  ...(combo.decisionModel
657
673
  ? {
@@ -695,6 +711,7 @@ export async function executeComboResponses(
695
711
  ? { chosenProbability: decision.chosenProbability }
696
712
  : {}),
697
713
  ...(decision.usage ? { usage: decision.usage } : {}),
714
+ ...(decision.quota ? { quota: decision.quota } : {}),
698
715
  });
699
716
  console.debug("[combo] JEV decision", {
700
717
  backend: decision.backend,
@@ -712,8 +729,11 @@ export async function executeComboResponses(
712
729
  // One immutable combo selection trace, before any child dispatch; child
713
730
  // adoption below must never replace it with a concrete child route trace.
714
731
  const decision = comboRouteDecisionTrace(config, comboId, pick, requestedModel);
715
- logCtx.routeDecision = options.memoryModelPhase
716
- ? { ...decision, selected: { ...decision.selected, reason: memoryModelRouteReason(options.memoryModelPhase) } }
732
+ const selectedReason = options.memoryModelPhase
733
+ ? memoryModelRouteReason(options.memoryModelPhase)
734
+ : toolLoopPin ? COMBO_TOOL_LOOP_PIN_REASON : undefined;
735
+ logCtx.routeDecision = selectedReason
736
+ ? { ...decision, selected: { ...decision.selected, reason: selectedReason } }
717
737
  : decision;
718
738
 
719
739
  const originalReasoning = body && typeof body === "object" && !Array.isArray(body)
@@ -1192,8 +1212,15 @@ export async function executeComboResponses(
1192
1212
  }
1193
1213
  return lastFailure;
1194
1214
  }
1215
+ // A bare status made a revoked credential read as a provider outage. Log only the parsed
1216
+ // code and message, never the raw envelope, as one redacted, bounded line.
1217
+ const failureReason = sanitizeLogMetadataString(
1218
+ redactSecretString([failure.upstreamCode, failure.upstreamMessage].filter(Boolean).join(": ")).replace(/\s+/g, " "),
1219
+ 200,
1220
+ );
1195
1221
  console.warn(
1196
- `[combo] ${comboId}: ${targetKey(pick.target)} failed with ${failure.response.status} after ${Date.now() - started}ms`,
1222
+ `[combo] ${comboId}: ${targetKey(pick.target)} failed with ${failure.response.status}`
1223
+ + ` after ${Date.now() - started}ms${failureReason ? `: ${failureReason}` : ""}`,
1197
1224
  );
1198
1225
  const failureNow = Date.now();
1199
1226
  const attemptedTargets = pick.attempted;
@@ -1,4 +1,5 @@
1
1
  import type { TranslatorBudget } from "../../lib/translator-budget";
2
+ import { carryResponseToolOwnership } from "./response-tool-ownership";
2
3
  import { finalizeTranslatorBudgetResponse } from "../../lib/translator-budget";
3
4
  import { isNonReplayableResponse, markResponseNonReplayable } from "../../lib/upstream-retry";
4
5
  import {
@@ -50,7 +51,7 @@ export function finalizeOwnedTranslatorBudget(response: Response, budget: Transl
50
51
  if (isPreinspectedJsonResponse(response)) markPreinspectedJsonResponse(finalizedResponse);
51
52
  if (isNonReplayableResponse(response)) markResponseNonReplayable(finalizedResponse);
52
53
  if (runTurnAdapterSseResponses.has(response)) runTurnAdapterSseResponses.add(finalizedResponse);
53
- return finalizedResponse;
54
+ return carryResponseToolOwnership(response, finalizedResponse);
54
55
  }
55
56
 
56
57
  /** Release a serving-account slot when the client body ends, errors or is cancelled. */
@@ -75,7 +76,7 @@ export function finalizeAccountLease(response: Response, release: () => void, on
75
76
  if (isEagerRelaySseResponse(response)) markEagerRelaySseResponse(wrapped);
76
77
  if (isPreinspectedJsonResponse(response)) markPreinspectedJsonResponse(wrapped);
77
78
  if (runTurnAdapterSseResponses.has(response)) runTurnAdapterSseResponses.add(wrapped);
78
- return wrapped;
79
+ return carryResponseToolOwnership(response, wrapped);
79
80
  }
80
81
 
81
82
 
@@ -177,6 +177,8 @@ export interface HandleResponsesOptions {
177
177
  nativeReasoningMint?: { owner?: NativeReasoningOwner };
178
178
  /** The Claude client's typed `web_search_*` / `web_fetch_*` declarations; kept only for `inboundWire: "anthropic"`. */
179
179
  anthropicServerTools?: readonly Record<string, unknown>[];
180
+ /** The Claude client's replayable server-tool exchanges (`replayedAnthropicServerBlocks`); same scope. */
181
+ anthropicServerReplay?: ReadonlyMap<string, readonly Record<string, unknown>[]>;
180
182
  /** Droid's per-request effort default; each concrete combo or policy target applies it only if its ladder allows it. */
181
183
  droidDefaultEffort?: string;
182
184
  /** PF-07: the Chat source a combo child may send natively; set only by the Chat ingress. */
@@ -1,3 +1,5 @@
1
+ import { bindAnthropicRejectionPolicy } from "../../adapters/anthropic-rejection-memo";
2
+ import { getReasoningReplayOpaqueBlobRejection } from "../../responses/reasoning-replay-cache";
1
3
  import type {
2
4
  OcxProviderContinuationOwner,
3
5
  OcxProviderContinuationState,
@@ -177,8 +179,10 @@ export function bindRouteReasoningReplayScope(args: {
177
179
  parsed._dropForeignReasoningItemIds = true;
178
180
  }
179
181
  }
182
+ const rejectionMemo = getReasoningReplayOpaqueBlobRejection(parsed._reasoningReplayScope);
183
+ bindAnthropicRejectionPolicy(parsed, adapterName === "anthropic" ? rejectionMemo : undefined);
180
184
  if (reasoningReplayOpaqueBlobRejectionMemoized(parsed._reasoningReplayScope)) {
181
- parsed._stripReasoningEncryptedContent = true;
185
+ if (adapterName !== "anthropic" || rejectionMemo?.mode !== "selective") parsed._stripReasoningEncryptedContent = true;
182
186
  parsed._stripNativeCompactionEncryptedContent = true;
183
187
  parsed._dropForeignReasoningItemIds = true;
184
188
  }
@@ -12,6 +12,7 @@ import { createInferenceSendBudget } from "../inference/context";
12
12
  import { finalizeOwnedTranslatorBudget, finalizeAccountLease } from "./core-lifetime";
13
13
  import type { TranslatorBudget } from "../../lib/translator-budget";
14
14
  import { executeComboResponses } from "./core-combo";
15
+ import { registerTurn, unregisterTurn } from "../lifecycle";
15
16
  import { runWithCompactionRecovery } from "./compaction-recovery";
16
17
  /**
17
18
  * Route one `/v1/responses` request through the adapter pipeline: recovery loop, passthrough
@@ -67,15 +68,17 @@ export async function handleComboResponses(
67
68
  logCtx: RequestLogContext,
68
69
  options: HandleResponsesOptions & { translatorBudget: TranslatorBudget },
69
70
  ): Promise<Response> {
70
- return executeComboResponses(
71
- req,
72
- rawBody,
73
- comboId,
74
- config,
75
- logCtx,
76
- options,
77
- requestDispatchers,
78
- );
71
+ // Each child relay binds the turn's lease and unbinds it when its stream ends; the lease is
72
+ // released once nothing is bound. Hold it for the whole ladder so a refused child's relay
73
+ // cannot settle the turn the next target still runs in ("turn already settled"). An accepted
74
+ // child's stream is bound before this returns and keeps the lease until it ends.
75
+ const ladder = new AbortController();
76
+ registerTurn(ladder, options.turnAdmissionLease);
77
+ try {
78
+ return await executeComboResponses(req, rawBody, comboId, config, logCtx, options, requestDispatchers);
79
+ } finally {
80
+ if (options.turnAdmissionLease && "bindAbortController" in options.turnAdmissionLease) unregisterTurn(ladder);
81
+ }
79
82
  }
80
83
  const requestDispatchers: ResponsesDispatchers = { handleResponses, handleComboResponses };
81
84
  export { adapterNeedsForcedContinuation } from "./core-replay";
@@ -172,6 +172,7 @@ export function mergeUsage(
172
172
  const cachedInputTokens = sumOptional("cachedInputTokens");
173
173
  const cacheReadInputTokens = sumOptional("cacheReadInputTokens");
174
174
  const cacheCreationInputTokens = sumOptional("cacheCreationInputTokens");
175
+ const cacheCreation1hInputTokens = sumOptional("cacheCreation1hInputTokens");
175
176
  const reasoningOutputTokens = sumOptional("reasoningOutputTokens");
176
177
  const providerCredits = sumOptional("providerCredits");
177
178
  const contextTotalTokens = second.contextTotalTokens ?? first.contextTotalTokens;
@@ -188,6 +189,7 @@ export function mergeUsage(
188
189
  ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
189
190
  ...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {}),
190
191
  ...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}),
192
+ ...(cacheCreation1hInputTokens !== undefined ? { cacheCreation1hInputTokens } : {}),
191
193
  ...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}),
192
194
  ...(providerCredits !== undefined ? { providerCredits } : {}),
193
195
  ...(first.estimated || second.estimated ? { estimated: true } : {}),
@@ -31,6 +31,7 @@ import {
31
31
  } from "../../lib/provider-egress";
32
32
  import { redactSecretString } from "../../lib/redact";
33
33
  import { outboundCredentials } from "../../lib/outbound-credential-registry";
34
+ import { compressUpstreamRequestBody, isCanonicalCodexResponsesUrl } from "./request-compression";
34
35
 
35
36
  export { withUpstreamHttpVersion };
36
37
 
@@ -48,6 +49,17 @@ const EGRESS_DECIDED = Symbol.for("opencodex.provider-egress.decided");
48
49
  const UPSTREAM_REWRITTEN = Symbol.for("opencodex.plugins.upstream-rewritten");
49
50
  // Survives queued credential rebuilds and executor replacement without importing budget owners.
50
51
  const PHYSICAL_DISPATCH = Symbol.for("opencodex.physical-dispatch");
52
+ // Final destination and headers after plugin rewrites and queued rebuilds, for observers that
53
+ // must attribute a response to the credential that actually reached the wire.
54
+ const PHYSICAL_SEND_OBSERVER = Symbol.for("opencodex.physical-send-observer");
55
+ export type AccountAutoPauseObserver = ((response: Response) => Promise<Response>) & {
56
+ physical?: (sameDestination: boolean, headers: Headers) => void;
57
+ };
58
+ /** Init fragment that reports the final send of the observer's attempt; empty when nothing observes. */
59
+ export function physicalSendObserverInit(observer: AccountAutoPauseObserver | undefined, target: string): Record<symbol, unknown> {
60
+ const physical = observer?.physical;
61
+ return physical ? { [PHYSICAL_SEND_OBSERVER]: (destination: string, headers: Headers) => physical(destination === target, headers) } : {};
62
+ }
51
63
  // One WS attempt and its HTTP fallback are one logical physical-send receipt.
52
64
  const WS_FALLBACK_RECEIPT = Symbol.for("opencodex.ws-fallback-receipt");
53
65
  // Replacement admission waits on the client signal, outside the response-header deadline.
@@ -206,18 +218,27 @@ export function sendWithConnectionPolicy(
206
218
  // Bun can reset large native Codex string uploads before receiving headers while
207
219
  // the identical UTF-8 buffer succeeds. Convert only at the final HTTP send so
208
220
  // WebSocket selection still sees the serialized string and no retry is added.
221
+ // Opt-in compression (#7247) takes precedence at the same boundary and also yields bytes. Every
222
+ // retry and HTTP fallback re-enters here with the caller's original string, so each send derives
223
+ // its own coded bytes; `headers` is already this send's private copy.
209
224
  const target = input instanceof Request ? input.url : String(input);
210
225
  const body = init?.body;
211
- const largeCodexBody = typeof body === "string"
212
- && /^https:\/\/chatgpt\.com\/backend-api\/codex\/responses(?:\/compact)?$/.test(target)
226
+ const compressed = compressUpstreamRequestBody(target, body, headers);
227
+ if (compressed) {
228
+ headers.set("Content-Encoding", compressed.encoding);
229
+ headers.delete("Content-Length");
230
+ }
231
+ const largeCodexBody = !compressed && typeof body === "string"
232
+ && isCanonicalCodexResponsesUrl(target)
213
233
  && Buffer.byteLength(body, "utf8") >= 1024 * 1024;
214
234
  (init as Record<symbol, (() => void) | undefined> | undefined)?.[PHYSICAL_DISPATCH]?.();
235
+ (init as Record<symbol, ((destination: string, headers: Headers) => void) | undefined> | undefined)?.[PHYSICAL_SEND_OBSERVER]?.(target, headers);
215
236
  // Final headers, after plugin rewrites: an upstream can only echo what it received here, so the
216
237
  // request log refuses any diagnostic that repeats one of these values (see request-log-terminal-status).
217
238
  outboundCredentials().remember(headers);
218
239
  return physicalFetch(input, {
219
240
  ...init,
220
- ...(largeCodexBody ? { body: Buffer.from(body, "utf8") } : {}),
241
+ ...(compressed ? { body: compressed.body } : largeCodexBody ? { body: Buffer.from(body, "utf8") } : {}),
221
242
  headers,
222
243
  redirect: "manual",
223
244
  ...(fresh ? { keepalive: false } : {}),
@@ -228,6 +249,7 @@ export function sendWithConnectionPolicy(
228
249
  }
229
250
 
230
251
  export interface ProviderFetchOptions {
252
+ accountAutoPause?: (name: string, provider: OcxProviderConfig, headers: Headers) => AccountAutoPauseObserver | undefined;
231
253
  /**
232
254
  * Keep this send on HTTP even where the WebSocket upstream would normally be selected.
233
255
  *
@@ -344,7 +366,7 @@ export function providerFetch(
344
366
  // ChatGPT Codex backend: streaming turns ride the responses_websockets
345
367
  // transport (measured ~3s faster TTFT than the SSE POST queue); everything
346
368
  // else keeps the provider's HTTP fetch. See ws-upstream.ts for the details.
347
- const unpaced = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit,
369
+ const unobserved = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit,
348
370
  reacquirePacing?: (provider: OcxProviderConfig, modelId?: string) => Promise<void>) => {
349
371
  const upstreamWebsocket = provider.upstreamWebsocket;
350
372
  if (!options.httpOnly && typeof input === "string" && init
@@ -381,6 +403,15 @@ export function providerFetch(
381
403
  }
382
404
  return httpFetch(input, init, reacquirePacing);
383
405
  };
406
+ const unpaced = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit,
407
+ reacquirePacing?: (provider: OcxProviderConfig, modelId?: string) => Promise<void>) => {
408
+ const observe = options.accountAutoPause?.(providerName, provider,
409
+ new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)));
410
+ const observedInit = observe?.physical
411
+ ? { ...init, ...physicalSendObserverInit(observe, input instanceof Request ? input.url : String(input)) } : init;
412
+ const response = await unobserved(input, observedInit, reacquirePacing);
413
+ return observe ? observe(response) : response;
414
+ };
384
415
  let pacingSlotAcquired = options.pacingSlotAcquired === true;
385
416
  const waitForPacing = async (signal?: AbortSignal): Promise<ProviderRequestSlot | undefined> => {
386
417
  if (pacingSlotAcquired) {
@@ -1,6 +1,7 @@
1
1
  import { readBoundedResponseBytes } from "../../lib/bounded-body";
2
2
  import { JevModelInvokeError, type JevModelInvoke, type JevModelInvokeResult } from "../../combos/jev-model-backend";
3
3
  import { JEV_MAX_REQUEST_BYTES, JEV_MAX_RESPONSE_BYTES } from "../../combos/jev";
4
+ import { serializeJevModelRequest } from "../../combos/jev-model-request";
4
5
  import type { OcxConfig } from "../../types";
5
6
  import { codexEffortRank, isCodexReasoningEffort } from "../../reasoning-effort";
6
7
  import { routeConcreteModel } from "../../router";
@@ -92,7 +93,7 @@ function decodeSse(bytes: Uint8Array): JevModelInvokeResult {
92
93
  * bounds billed output (including reasoning) and gives the spend reservation a nonzero ceiling;
93
94
  * a truncated answer is malformed and fails open like any other bad reply.
94
95
  */
95
- export const JEV_MODEL_MAX_OUTPUT_TOKENS = 1024;
96
+ export { JEV_MODEL_MAX_OUTPUT_TOKENS } from "../../combos/jev-model-request";
96
97
 
97
98
  /**
98
99
  * The cheapest reasoning effort the decision model declares, so a reasoning model cannot spend
@@ -121,19 +122,17 @@ export function jevDecisionReasoningEffort(config: OcxConfig, model: string): st
121
122
  * or conversation header. It therefore runs on credentials configured on provider rows only.
122
123
  */
123
124
  export function createJevModelInvoker(context: JevModelInvokerContext): JevModelInvoke {
124
- return async ({ model, instructions, input, signal }) => {
125
+ return async ({ model, instructions, input, signal, withoutQuota }) => {
125
126
  const effort = jevDecisionReasoningEffort(context.config, model);
126
- const body = JSON.stringify({
127
- model,
128
- stream: true,
129
- store: false,
130
- instructions,
131
- input: [{ role: "user", content: [{ type: "input_text", text: input }] }],
132
- tools: [],
133
- max_output_tokens: JEV_MODEL_MAX_OUTPUT_TOKENS,
134
- ...(effort ? { reasoning: { effort } } : {}),
135
- });
136
- if (new TextEncoder().encode(body).byteLength > JEV_MAX_REQUEST_BYTES) {
127
+ let body = serializeJevModelRequest({ model, instructions, input }, effort);
128
+ /** True when the serialized decision request exceeds the request byte limit. */
129
+ const oversized = () => new TextEncoder().encode(body).byteLength > JEV_MAX_REQUEST_BYTES;
130
+ let quotaOmitted = false;
131
+ if (oversized() && withoutQuota) {
132
+ body = serializeJevModelRequest({ model, ...withoutQuota }, effort);
133
+ quotaOmitted = true;
134
+ }
135
+ if (oversized()) {
137
136
  throw new JevModelInvokeError("malformed", "decision request too large");
138
137
  }
139
138
  const lease = tryAdmitTurn(undefined, signal);
@@ -178,7 +177,7 @@ export function createJevModelInvoker(context: JevModelInvokerContext): JevModel
178
177
  return terminalResult(parsed, "");
179
178
  })();
180
179
  usage = result.usage;
181
- return result;
180
+ return quotaOmitted ? { ...result, quotaOmitted: true } : result;
182
181
  } finally {
183
182
  // No final log row is written for this detached context, so settle its spend here.
184
183
  childLog.spendTracker?.settle(childLog.usage ?? (usage
@@ -24,7 +24,7 @@ export function createNativeSteeringLogObserver(logCtx: RequestLogContext, onFir
24
24
  if (usage && (usages.has(event.response.id) || usages.size < MAX_NATIVE_STEERING_RESPONSES)) {
25
25
  // Retain numeric counters only, never arbitrary rawUsage metadata.
26
26
  const counters: OcxUsage = { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens };
27
- for (const key of ["totalTokens", "cachedInputTokens", "cacheReadInputTokens", "cacheCreationInputTokens", "reasoningOutputTokens"] as const) {
27
+ for (const key of ["totalTokens", "cachedInputTokens", "cacheReadInputTokens", "cacheCreationInputTokens", "cacheCreation1hInputTokens", "reasoningOutputTokens"] as const) {
28
28
  if (typeof usage[key] === "number") counters[key] = usage[key];
29
29
  }
30
30
  usages.set(event.response.id, counters);
@@ -33,7 +33,7 @@ export function createNativeSteeringLogObserver(logCtx: RequestLogContext, onFir
33
33
  if (!usages.size) return;
34
34
  const total: OcxUsage = { inputTokens: 0, outputTokens: 0 };
35
35
  for (const usage of usages.values()) {
36
- for (const key of ["inputTokens", "outputTokens", "totalTokens", "cachedInputTokens", "cacheReadInputTokens", "cacheCreationInputTokens", "reasoningOutputTokens"] as const) {
36
+ for (const key of ["inputTokens", "outputTokens", "totalTokens", "cachedInputTokens", "cacheReadInputTokens", "cacheCreationInputTokens", "cacheCreation1hInputTokens", "reasoningOutputTokens"] as const) {
37
37
  const value = usage[key];
38
38
  if (typeof value === "number" && Number.isFinite(value)) total[key] = (total[key] ?? 0) + value;
39
39
  }
@@ -1,3 +1,4 @@
1
+ import { wasPermanentAccountRefusal } from "../../oauth/account-auto-pause";
1
2
  import { isNativeControlResponse } from "./native-response-control";
2
3
  import { createOutboundCredentialMask, createTerminalErrorRedactionBlockRewrite } from "./terminal-error-redaction";
3
4
  import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options";
@@ -527,7 +528,7 @@ export async function deliverPassthroughResponse(
527
528
  } else if (!shouldDeferCodexResetDerivedCooldown(
528
529
  upstreamResponse,
529
530
  options.deferCodexResetDerivedCooldown,
530
- ) && !isReplayRefusalResponse(upstreamResponse)) {
531
+ ) && !isReplayRefusalResponse(upstreamResponse) && !wasPermanentAccountRefusal(upstreamResponse)) {
531
532
  // A refusal this proxy made is not evidence about the account. Recording it would
532
533
  // classify the synthetic 429 as quota exhaustion and write a default cooldown against
533
534
  // a credential the request may never have reached, and that false signal outlives the
@@ -572,7 +573,7 @@ export async function deliverPassthroughResponse(
572
573
  // through sanitizePassthroughHeaders) so a redirect to a dead host can never
573
574
  // masquerade as a pre-connection failure after the credential was seen.
574
575
  // The numeric outcome above already classified it neutral — no streak.
575
- if (isNonReplayableResponse(upstreamResponse) && !isReplayRefusalResponse(upstreamResponse)) {
576
+ if (isNonReplayableResponse(upstreamResponse) && !isReplayRefusalResponse(upstreamResponse) && !wasPermanentAccountRefusal(upstreamResponse)) {
576
577
  return await sanitizeNonReplayableUpstreamError(upstreamResponse, upstream.signal);
577
578
  }
578
579
  if (upstreamResponse.status >= 300 && upstreamResponse.status < 400) {
@@ -1,3 +1,5 @@
1
+ import { recoverAutoPausedAccount } from "./account-auto-pause-recovery";
2
+ import { adoptCodexWsStage, capturePassthroughAffinity } from "./passthrough-observations";
1
3
  import { spendLedgerStorageErrorResponse } from "./spend-storage-error";
2
4
  import { requestPacingOverloadResponse } from "./pacing-overload";
3
5
  import { recordDistributedResponseFailure } from "../../providers/api-key-response-ownership";
@@ -99,7 +101,6 @@ import {
99
101
  upstreamHostCircuitOpenResponse,
100
102
  usesCodexForwardPoolAuth,
101
103
  codexWsQuotaObserver,
102
- isFixedCodexAccount,
103
104
  codexPoolAccountModel400Denial,
104
105
  shouldRetryCodexPoolAccountQuota,
105
106
  shouldRetryCodexPoolAccountTransient,
@@ -111,7 +112,7 @@ import {
111
112
  clearCodexModelDenialEvidence,
112
113
  recordCodexModelDenialEvidence,
113
114
  } from "../../codex/model-entitlements";
114
- import { codexWsSocketDeathStage, isCodexWsUpstreamResponse, readCodexWsStage } from "./codex-ws-wire";
115
+ import { codexWsSocketDeathStage, isCodexWsUpstreamResponse } from "./codex-ws-wire";
115
116
  import { linkAbortSignal } from "./core-lifetime";
116
117
  import type { CodexAuthContext } from "../../codex/auth-context";
117
118
  import { checkOutboundBodySize, describeOutboundBodyRefusal } from "./outbound-body-guard";
@@ -130,6 +131,7 @@ import {
130
131
  } from "../../lib/upstream-retry";
131
132
  import { mapCodexAuthContextErrorToResponse } from "./codex-auth-error";
132
133
  import { classifyTransportFailureKind, transportErrorCode } from "../../lib/upstream-reachability";
134
+ import { captureAccountAutoPauseAttempt, wasAccountAutoPaused, wasPermanentAccountRefusal } from "../../oauth/account-auto-pause";
133
135
  import { recordCodexUpstreamOutcome } from "../../codex/routing";
134
136
  import { describeUpstreamConnectFailure } from "./upstream-error";
135
137
  import type { OpaqueBlobRecoveryGuard } from "./core-opaque-recovery";
@@ -152,7 +154,6 @@ import {
152
154
  rotateGenericOAuthAccountOn429,
153
155
  failoverAccountSnapshot,
154
156
  } from "../../oauth/generic-account-failover";
155
- import { captureCodexAffinityDiagnostic } from "../../codex/affinity-debug";
156
157
  import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-models";
157
158
  import {
158
159
  attemptOpaqueBlobRecovery,
@@ -201,6 +202,7 @@ export async function preparePassthroughExchange(
201
202
  | "genericFailoverAccountId"
202
203
  | "passiveQuotaWriterGeneration"
203
204
  | "oauthDispatch"
205
+ | "recoverAutoPausedSelection"
204
206
  | "resolveSelectionAdapter"
205
207
  | "resolveCopilotSelection"
206
208
  | "copilotRefusalResponse"
@@ -733,22 +735,11 @@ export async function preparePassthroughExchange(
733
735
  }
734
736
  nativeHostState.lease = null;
735
737
  };
736
- /**
737
- * #4191: a Codex WS exchange pins its content-free stage record on the
738
- * Response it resolves (markCodexWsStage). Adopting the record here, at
739
- * the single funnel every physical upstream response passes through,
740
- * binds it to the attempt that actually served it — including the 502/504
741
- * pre-response JSON settles that never reach the SSE relay.
742
- */
743
- const adoptCodexWsStage = (response: Response): void => {
744
- const stage = readCodexWsStage(response);
745
- if (stage && logCtx.activeAttempt) logCtx.activeAttempt.codexWsStage = stage;
746
- };
747
738
  let upstreamResponse: Response, lastDistributedResponse: Response | undefined;
748
739
  const adoptObservedResponse = <T extends Response>(response: T): T => {
749
740
  if (!isNonReplayableResponse(response)) lastDistributedResponse = response.status === 401 || response.status === 429 ? response : undefined;
750
741
  settleObservedHostResponse();
751
- adoptCodexWsStage(response);
742
+ adoptCodexWsStage(response, logCtx);
752
743
  return response;
753
744
  };
754
745
  let passthroughEstimate = typeof request.usageLog?.inputTokens === "number"
@@ -1050,6 +1041,7 @@ export async function preparePassthroughExchange(
1050
1041
  providerName: route.providerName,
1051
1042
  modelId: route.modelId,
1052
1043
  onPhysicalDispatch: receiptMode ? sendBudgetState.noteInitialDispatch : undefined,
1044
+ accountAutoPause: (name, provider, headers) => captureAccountAutoPauseAttempt(name, provider, { config, codex: admissionState.authCtx }, headers),
1053
1045
  onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
1054
1046
  beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
1055
1047
  ? createCodexAuthDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
@@ -1187,6 +1179,7 @@ export async function preparePassthroughExchange(
1187
1179
  dispatchOverride: oauthDispatch(request, parsed, "passthrough"),
1188
1180
  providerName: route.providerName,
1189
1181
  modelId: route.modelId,
1182
+ accountAutoPause: (name, provider, headers) => captureAccountAutoPauseAttempt(name, provider, { config, codex: admissionState.authCtx }, headers),
1190
1183
  onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
1191
1184
  beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
1192
1185
  ? createCodexAuthDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
@@ -1209,8 +1202,18 @@ export async function preparePassthroughExchange(
1209
1202
 
1210
1203
  // Keep recovery kinds in sync with the generic `recovery:` loop below.
1211
1204
  passthroughRecovery: for (;;) {
1205
+ if (wasAccountAutoPaused(upstreamResponse) && route.provider.authMode !== "forward") {
1206
+ const recovered = await recoverAutoPausedAccount({ requestKey: transportState, reserve: reserveCredentialHop,
1207
+ remaining: transientSendCapFor(transientRetryPolicyFor(route.provider)?.attempts, sendBudgetState.targetSendsUsed),
1208
+ refresh: () => transportState.recoverAutoPausedSelection(parsed), refetch: () => rebuildAndRefetch("oauth-account-429"), setPermit: permit => { sendBudgetState.pendingHopPermit = permit; } });
1209
+ if (!recovered) break;
1210
+ if ("failed" in recovered) return recovered.failed;
1211
+ upstreamResponse = recovered; continue passthroughRecovery;
1212
+ }
1213
+
1214
+
1212
1215
  if (
1213
- upstreamResponse.status === 401
1216
+ upstreamResponse.status === 401 && !wasPermanentAccountRefusal(upstreamResponse)
1214
1217
  && (admissionState.authCtx.kind === "main-pool" || admissionState.authCtx.kind === "pool")
1215
1218
  && usesCodexForwardPoolAuth(admissionState.authCtx, route.provider)
1216
1219
  && codex401ReplayKind === null
@@ -1293,6 +1296,7 @@ export async function preparePassthroughExchange(
1293
1296
  dispatchOverride: oauthDispatch(request, parsed, "passthrough"),
1294
1297
  providerName: route.providerName,
1295
1298
  modelId: route.modelId,
1299
+ accountAutoPause: (name, provider, headers) => captureAccountAutoPauseAttempt(name, provider, { config, codex: admissionState.authCtx }, headers),
1296
1300
  onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
1297
1301
  beforeDispatch: headers => {
1298
1302
  replayGuard?.(headers);
@@ -1336,14 +1340,14 @@ export async function preparePassthroughExchange(
1336
1340
  continue passthroughRecovery;
1337
1341
  }
1338
1342
 
1339
- if (codex401ReplayKind !== null && upstreamResponse.status === 401) break;
1343
+ if (codex401ReplayKind !== null && upstreamResponse.status === 401 && !wasPermanentAccountRefusal(upstreamResponse)) break;
1340
1344
 
1341
1345
  // Native Responses providers return before the generic adapter recovery loop below. Keep
1342
1346
  // their OAuth contract identical: one pre-stream 401 forces a credential refresh and one
1343
1347
  // rebuilt replay. xAI's current subscription models use this branch now that their official
1344
1348
  // Grok CLI catalog declares the Responses backend.
1345
1349
  if (
1346
- upstreamResponse.status === 401
1350
+ upstreamResponse.status === 401 && !wasPermanentAccountRefusal(upstreamResponse)
1347
1351
  && isOAuth401ReplayProvider
1348
1352
  && transportState.sentOAuthSnapshot
1349
1353
  && !oauth401ReplayAttempted
@@ -1440,6 +1444,7 @@ export async function preparePassthroughExchange(
1440
1444
  dispatchOverride: oauthDispatch(request, parsed, "passthrough"),
1441
1445
  providerName: route.providerName,
1442
1446
  modelId: route.modelId,
1447
+ accountAutoPause: (name, provider, headers) => captureAccountAutoPauseAttempt(name, provider, { config, codex: admissionState.authCtx }, headers),
1443
1448
  onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
1444
1449
  beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
1445
1450
  ? createCodexAuthDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
@@ -1586,6 +1591,7 @@ export async function preparePassthroughExchange(
1586
1591
  dispatchOverride: oauthDispatch(request, parsed, "passthrough"),
1587
1592
  providerName: route.providerName,
1588
1593
  modelId: route.modelId,
1594
+ accountAutoPause: (name, provider, headers) => captureAccountAutoPauseAttempt(name, provider, { config, codex: admissionState.authCtx }, headers),
1589
1595
  onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
1590
1596
  beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
1591
1597
  ? createCodexAuthDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
@@ -1608,18 +1614,7 @@ export async function preparePassthroughExchange(
1608
1614
  || captureAuthCtx.kind === "pool"
1609
1615
  || captureAuthCtx.kind === "main-pool",
1610
1616
  ): void => {
1611
- if (!isCanonicalOpenAiForwardProvider(route.provider)) return;
1612
- captureCodexAffinityDiagnostic({
1613
- inboundHeaders: req.headers,
1614
- outboundHeaders: captureRequest.headers,
1615
- authKind: captureAuthCtx.kind,
1616
- accountMode: route.codexAccountMode,
1617
- fixedAccount: isFixedCodexAccount(captureAuthCtx),
1618
- credentialSubstituted,
1619
- accountGatedModel: ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId),
1620
- wireModelNormalized: parsed.modelId !== route.modelId,
1621
- status: response.status,
1622
- });
1617
+ capturePassthroughAffinity(response, req.headers, captureRequest.headers, captureAuthCtx, route, parsed.modelId, credentialSubstituted);
1623
1618
  };
1624
1619
  recordDistributedResponseFailure(lastDistributedResponse ?? upstreamResponse, config, route.providerName, options.abortSignal);
1625
1620
  captureAffinityResponse(upstreamResponse);
@@ -1646,7 +1641,9 @@ export async function preparePassthroughExchange(
1646
1641
  options.abortSignal,
1647
1642
  parsed.modelId,
1648
1643
  );
1649
- if (model400Denial !== undefined) {
1644
+ if (wasAccountAutoPaused(upstreamResponse)) {
1645
+ poolRetryOutcome = upstreamResponse.status;
1646
+ } else if (model400Denial !== undefined) {
1650
1647
  // Spend this refusal on more than one retry. It is the account's own authenticated
1651
1648
  // answer about this model, and the roster cache that selection otherwise reads expires
1652
1649
  // five minutes after a catalog sync fills it -- so without remembering this, the next
@@ -1679,7 +1676,7 @@ export async function preparePassthroughExchange(
1679
1676
  // ladder does have one — retrying the account the refreshed roster still grants — and
1680
1677
  // keeps it. An earlier revision also broke here on a non-400 outcome, which no test could
1681
1678
  // justify because this flag already produced the identical result.
1682
- const storedReplaySpent = codex401ReplayKind === "stored";
1679
+ const storedReplaySpent = codex401ReplayKind === "stored" && !wasAccountAutoPaused(upstreamResponse);
1683
1680
  const retry = await retryCodexPoolOnAlternateAccount({
1684
1681
  callerAuthHeaders,
1685
1682
  config,
@@ -1698,7 +1695,7 @@ export async function preparePassthroughExchange(
1698
1695
  httpOnly: canonicalBufferedJson,
1699
1696
  preOutputRefusalHold: preOutputRefusalHold(),
1700
1697
  onResponse: (response, retryAuthCtx, retryRequest) => {
1701
- adoptCodexWsStage(response);
1698
+ adoptCodexWsStage(response, logCtx);
1702
1699
  captureAffinityResponse(
1703
1700
  response,
1704
1701
  retryAuthCtx,
@@ -14,6 +14,7 @@ import { guardDirectPassthroughBodyInactivity } from "../../lib/response-body-in
14
14
  import { resolveStallTimeoutMs } from "../../stall-timeout";
15
15
  import { releaseUpstreamHostAdmission } from "../../codex/upstream-host-health";
16
16
  import { releaseCodexAuthContextProbeLease } from "../../codex/auth-context";
17
+ import { attachResponseToolOwnership } from "./response-tool-ownership";
17
18
 
18
19
  /** Owns the native host lease across dispatch, recovery, and response construction. */
19
20
  export async function executePassthroughResponse(
@@ -50,11 +51,11 @@ export async function executePassthroughResponse(
50
51
  // Delivery has already classified SSE (including a missing upstream content type)
51
52
  // and consumed bounded JSON/errors. Guard only its remaining direct body, outside
52
53
  // the relay/lifetime wrappers so their prefetch cannot arm our inactivity clock.
53
- return guardDirectPassthroughBodyInactivity(
54
+ return attachResponseToolOwnership(guardDirectPassthroughBodyInactivity(
54
55
  response,
55
56
  nativeExchange.upstream.signal,
56
57
  resolveStallTimeoutMs(requestContext.config.stallTimeoutSec, { localUpstream: nativeExchange.localUpstream }),
57
- );
58
+ ), nativeExchange.providerExecutedCallTypes);
58
59
  } finally {
59
60
  if (nativeHostState.lease) {
60
61
  releaseUpstreamHostAdmission(nativeHostState.lease);