@bitkyc08/opencodex 2.81.0 → 2.83.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 (272) hide show
  1. package/bin/ocx.mjs +166 -73
  2. package/gui/dist/assets/App-Ba8uG696.js +54 -0
  3. package/gui/dist/assets/{Tray-EwAOsP0S.js → Tray-IImbNPRy.js} +1 -1
  4. package/gui/dist/assets/{index-Brk6F3fn.css → index-BAbcj51J.css} +1 -1
  5. package/gui/dist/assets/{index-RsoaAxXY.js → index-BHRZdJuv.js} +13 -13
  6. package/gui/dist/assets/{quota-summary-D2lN8tDm.js → quota-summary-DiJO6sYY.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +1 -1
  9. package/src/adapters/anthropic.ts +1 -1
  10. package/src/adapters/cursor/catalog.ts +2 -2
  11. package/src/adapters/cursor/effort-map.ts +1 -1
  12. package/src/adapters/devin/live-models.ts +2 -2
  13. package/src/adapters/google-truncation.ts +22 -0
  14. package/src/adapters/google.ts +7 -3
  15. package/src/adapters/ollama-native.ts +18 -14
  16. package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
  17. package/src/adapters/openai-responses/passthrough.ts +11 -4
  18. package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
  19. package/src/adapters/openai-responses/reasoning.ts +11 -1
  20. package/src/adapters/openai-responses/tool-output-recovery.ts +20 -0
  21. package/src/bridge/errors.ts +7 -1
  22. package/src/claude/agents-inject.ts +14 -3
  23. package/src/claude/context-windows.ts +22 -20
  24. package/src/claude/desktop-3p.ts +88 -12
  25. package/src/claude/desktop-picker.ts +1 -1
  26. package/src/claude/desktop-profile.ts +2 -1
  27. package/src/claude/gateway-cache.ts +18 -1
  28. package/src/claude/inbound.ts +77 -3
  29. package/src/claude/intercept/cli-catalog.ts +3 -0
  30. package/src/claude/intercept/picker-ca-persistence.ts +7 -6
  31. package/src/claude/intercept/picker-models.ts +14 -6
  32. package/src/claude/intercept/settings.ts +3 -0
  33. package/src/claude/long-context.ts +66 -0
  34. package/src/claude/model-info.ts +19 -16
  35. package/src/claude/outbound.ts +94 -9
  36. package/src/claude/subagent-model.ts +40 -11
  37. package/src/cli/account-auth.ts +20 -12
  38. package/src/cli/agent.ts +9 -5
  39. package/src/cli/aside-profile-recovery.ts +33 -0
  40. package/src/cli/capabilities-base.ts +5 -5
  41. package/src/cli/capabilities-integrations.ts +2 -2
  42. package/src/cli/cli-path-diagnostics.ts +132 -0
  43. package/src/cli/desktop-runtime-guidance.ts +65 -0
  44. package/src/cli/dispatch.ts +5 -0
  45. package/src/cli/doctor.ts +16 -3
  46. package/src/cli/index.ts +14 -9
  47. package/src/cli/integration-aside-sync.ts +2 -1
  48. package/src/cli/integrations.ts +16 -3
  49. package/src/cli/resolve.ts +26 -3
  50. package/src/cli/root.ts +4 -2
  51. package/src/cli/status.ts +64 -12
  52. package/src/cli/update-restart-child.ts +4 -2
  53. package/src/cli/update-restart-home.ts +28 -10
  54. package/src/cli/update-restart-service-record.ts +116 -0
  55. package/src/cli/update-restart-supervision.ts +96 -0
  56. package/src/cli/update-restart.ts +59 -13
  57. package/src/cli/v2.ts +27 -20
  58. package/src/cli/version-skew-notice.ts +77 -0
  59. package/src/clients/config-export/model-metadata.ts +16 -0
  60. package/src/clients/config-export/zcode-store.ts +46 -11
  61. package/src/clients/config-export/zcode.ts +3 -5
  62. package/src/clients/config-export.ts +1 -1
  63. package/src/clients/lazycodex.ts +1 -1
  64. package/src/clients/omo-role-models.ts +32 -7
  65. package/src/codex/auth-api/account-selection.ts +107 -0
  66. package/src/codex/auth-api/pool-mode-gate.ts +4 -5
  67. package/src/codex/auth-api/routes.ts +10 -49
  68. package/src/codex/auth-context.ts +28 -4
  69. package/src/codex/autostart-health.ts +12 -3
  70. package/src/codex/catalog/build-entries.ts +18 -0
  71. package/src/codex/catalog/bundled.ts +155 -26
  72. package/src/codex/catalog/combo-member.ts +3 -2
  73. package/src/codex/catalog/effort.ts +26 -5
  74. package/src/codex/catalog/gather-capture.ts +5 -0
  75. package/src/codex/catalog/model-hints.ts +15 -8
  76. package/src/codex/catalog/model-visibility.ts +3 -1
  77. package/src/codex/catalog/native-models.ts +1 -1
  78. package/src/codex/catalog/operator-tiers.ts +38 -0
  79. package/src/codex/catalog/provider-models.ts +68 -35
  80. package/src/codex/catalog/retained-sync.ts +7 -5
  81. package/src/codex/catalog/routed-gather.ts +15 -1
  82. package/src/codex/catalog-auto-refresh-sources.ts +3 -4
  83. package/src/codex/catalog-auto-refresh.ts +19 -0
  84. package/src/codex/catalog.ts +1 -1
  85. package/src/codex/codex-home-owner.ts +3 -0
  86. package/src/codex/config-write-lock.ts +333 -0
  87. package/src/codex/convergence.ts +2 -1
  88. package/src/codex/desktop-app/darwin.ts +1 -1
  89. package/src/codex/desktop-app/handoff.ts +1 -1
  90. package/src/codex/desktop-app/linux.ts +1 -1
  91. package/src/codex/desktop-app/lock.ts +1 -1
  92. package/src/codex/desktop-app/types.ts +1 -1
  93. package/src/codex/desktop-app/windows.ts +1 -1
  94. package/src/codex/desktop-app-restart.ts +1 -1
  95. package/src/codex/features.ts +257 -200
  96. package/src/codex/inject/config-write-section.ts +34 -0
  97. package/src/codex/inject/multi-agent-v2.ts +14 -6
  98. package/src/codex/inject/remove.ts +36 -11
  99. package/src/codex/inject/restore.ts +85 -20
  100. package/src/codex/inject-coordination.ts +15 -6
  101. package/src/codex/inject.ts +62 -20
  102. package/src/codex/journal.ts +62 -17
  103. package/src/codex/main-account-cache.ts +43 -0
  104. package/src/codex/main-account.ts +6 -7
  105. package/src/codex/paths.ts +2 -0
  106. package/src/codex/prepared-home.ts +18 -0
  107. package/src/codex/prompt-journal.ts +25 -12
  108. package/src/codex/prompt-layers.ts +49 -26
  109. package/src/codex/prompt-lock-claim.ts +134 -0
  110. package/src/codex/prompt-lock-evidence.ts +26 -0
  111. package/src/codex/prompt-lock-io.ts +16 -0
  112. package/src/codex/prompt-lock-owner.ts +130 -0
  113. package/src/codex/prompt-lock.ts +84 -53
  114. package/src/codex/routing/failure-window.ts +146 -0
  115. package/src/codex/routing/selection.ts +4 -1
  116. package/src/codex/routing.ts +13 -17
  117. package/src/codex/runtime.ts +106 -18
  118. package/src/codex/shim.ts +45 -19
  119. package/src/combos/jev-service-exchange.ts +281 -0
  120. package/src/combos/jev.ts +34 -220
  121. package/src/config/atomic-write.ts +1 -1
  122. package/src/config/codex-ws-reuse-setting.ts +10 -0
  123. package/src/config/live-reconcile.ts +17 -0
  124. package/src/config/load-degrade.ts +8 -0
  125. package/src/config/rebase-provenance.ts +11 -0
  126. package/src/config/schema/config-schema.ts +4 -0
  127. package/src/generated/compatibility-version.json +375 -227
  128. package/src/generated/model-metadata.ts +2 -2
  129. package/src/grok/inject.ts +276 -206
  130. package/src/grok/reset-coupon-ledger.ts +223 -57
  131. package/src/grok/reset-coupons.ts +13 -6
  132. package/src/grok/sync.ts +2 -1
  133. package/src/images/loop.ts +29 -11
  134. package/src/integrations/aside-profiles.ts +10 -3
  135. package/src/integrations/catalog-refresh.ts +18 -2
  136. package/src/integrations/cursor-detect.ts +1 -1
  137. package/src/integrations/writer.ts +9 -3
  138. package/src/lib/bun-path-runtime.d.mts +32 -0
  139. package/src/lib/bun-path-runtime.mjs +95 -0
  140. package/src/lib/bun-runtime-preflight.ts +140 -0
  141. package/src/lib/desktop-cli-handoff.d.mts +20 -0
  142. package/src/lib/desktop-cli-handoff.mjs +92 -0
  143. package/src/lib/desktop-cli-record.d.mts +29 -0
  144. package/src/lib/desktop-cli-record.mjs +102 -0
  145. package/src/lib/errors.ts +25 -0
  146. package/src/lib/redact.ts +2 -2
  147. package/src/lib/request-execution-budget.ts +8 -0
  148. package/src/lib/synced-state-location.ts +46 -6
  149. package/src/lib/test-home-guard.ts +87 -1
  150. package/src/lib/translator-budget.ts +17 -4
  151. package/src/lib/windows-atomic-replace.ts +38 -16
  152. package/src/lib/windows-elevation.ts +6 -0
  153. package/src/lib/windows-secret-acl.ts +92 -25
  154. package/src/lib/winsw.ts +2 -3
  155. package/src/oauth/anthropic-account-refusal.ts +39 -1
  156. package/src/oauth/anthropic-routing.ts +1 -1
  157. package/src/oauth/anthropic-send-ownership.ts +3 -3
  158. package/src/oauth/chatgpt.ts +113 -41
  159. package/src/oauth/index.ts +7 -3
  160. package/src/oauth/meta-muse-device.ts +1 -1
  161. package/src/oauth/store.ts +1 -1
  162. package/src/oauth/types.ts +1 -1
  163. package/src/providers/anthropic-fast.ts +1 -1
  164. package/src/providers/anthropic-reset-grant-ledger.ts +1 -1
  165. package/src/providers/azure-model-metadata.ts +115 -0
  166. package/src/providers/derive.ts +21 -0
  167. package/src/providers/devin-cli-authmode-migration.ts +1 -1
  168. package/src/providers/devin-provider-merge-migration.ts +1 -1
  169. package/src/providers/github-copilot-auto.ts +169 -0
  170. package/src/providers/model-presets.ts +1 -1
  171. package/src/providers/muse-subscription-usage.ts +1 -1
  172. package/src/providers/quota/vendor-probes-key.ts +127 -17
  173. package/src/providers/quota.ts +1 -1
  174. package/src/providers/registry/entries-core.ts +6 -6
  175. package/src/providers/registry/entries-extended.ts +4 -0
  176. package/src/providers/registry/model-seeds.ts +3 -3
  177. package/src/providers/xai-fast-model.ts +1 -1
  178. package/src/responses/reasoning-envelope.ts +29 -1
  179. package/src/responses/reasoning-replay-cache.ts +17 -1
  180. package/src/responses/spill-store.ts +15 -2
  181. package/src/responses/state/metrics.ts +8 -0
  182. package/src/responses/state/spill-failure.ts +12 -0
  183. package/src/responses/state/spill-queue.ts +12 -5
  184. package/src/responses/state.ts +18 -10
  185. package/src/responses/task-input.ts +17 -0
  186. package/src/server/background-lifecycle.ts +27 -0
  187. package/src/server/chat-completions.ts +1 -0
  188. package/src/server/chat-native.ts +28 -12
  189. package/src/server/claude-messages.ts +43 -6
  190. package/src/server/inbound-body-admission.ts +25 -7
  191. package/src/server/index/claude-intercept-lifecycle.ts +3 -1
  192. package/src/server/index/link-listener.ts +30 -17
  193. package/src/server/index/serve-options.ts +18 -4
  194. package/src/server/index/spend-ledger-lifecycle.ts +2 -1
  195. package/src/server/index/startup-warnings.ts +19 -0
  196. package/src/server/index.ts +2 -1
  197. package/src/server/lifecycle.ts +31 -4
  198. package/src/server/management/agent-settings-routes.ts +93 -32
  199. package/src/server/management/codex-agent-role-routes.ts +4 -2
  200. package/src/server/management/codex-prompt-routes.ts +1 -0
  201. package/src/server/management/codex-role-auto-assign.ts +13 -2
  202. package/src/server/management/config-routes.ts +59 -8
  203. package/src/server/management/context.ts +2 -2
  204. package/src/server/management/grok-coupon-routes.ts +277 -52
  205. package/src/server/management/link-routes.ts +42 -20
  206. package/src/server/management/route-registry.ts +2 -2
  207. package/src/server/management/shared.ts +3 -3
  208. package/src/server/messages-native-oauth.ts +9 -8
  209. package/src/server/messages-native.ts +49 -13
  210. package/src/server/messages-response-headers.ts +14 -0
  211. package/src/server/relay-eager.ts +2 -0
  212. package/src/server/relay.ts +18 -7
  213. package/src/server/request-log-conversation.ts +48 -5
  214. package/src/server/request-log-failure-attribution.ts +3 -0
  215. package/src/server/request-log-terminal-status.ts +186 -0
  216. package/src/server/request-log.ts +15 -89
  217. package/src/server/responses/adapter-continuation.ts +11 -3
  218. package/src/server/responses/adapter-dispatch.ts +99 -19
  219. package/src/server/responses/codex-ws-exchange.ts +17 -2
  220. package/src/server/responses/codex-ws-pool.ts +100 -13
  221. package/src/server/responses/codex-ws-wire.ts +9 -0
  222. package/src/server/responses/combo-requested-effort.ts +3 -1
  223. package/src/server/responses/compact.ts +3 -2
  224. package/src/server/responses/core-codex-account.ts +22 -1
  225. package/src/server/responses/core-combo-native.ts +20 -3
  226. package/src/server/responses/core-combo.ts +22 -14
  227. package/src/server/responses/core-lifetime.ts +3 -36
  228. package/src/server/responses/core-opaque-recovery.ts +5 -5
  229. package/src/server/responses/core-options.ts +5 -0
  230. package/src/server/responses/core-replay.ts +73 -0
  231. package/src/server/responses/core.ts +9 -9
  232. package/src/server/responses/empty-completion-guard.ts +18 -2
  233. package/src/server/responses/fetch-helpers.ts +26 -3
  234. package/src/server/responses/jev-model-invoke.ts +1 -1
  235. package/src/server/responses/passthrough-delivery.ts +24 -2
  236. package/src/server/responses/passthrough-dispatch.ts +134 -62
  237. package/src/server/responses/passthrough-execution.ts +3 -3
  238. package/src/server/responses/request-prepare.ts +4 -0
  239. package/src/server/responses/request-send-budget.ts +112 -49
  240. package/src/server/responses/request-transport.ts +125 -5
  241. package/src/server/responses/response-effects.ts +1 -0
  242. package/src/server/responses/run-turn-execution.ts +15 -5
  243. package/src/server/responses/sidecar-execution.ts +30 -16
  244. package/src/server/responses/sidecar-send-budget.ts +58 -0
  245. package/src/server/responses/spend-storage-error.ts +33 -0
  246. package/src/server/responses/ws-upstream.ts +19 -6
  247. package/src/server/startup-health-cache.ts +2 -1
  248. package/src/server/workflow-refusal.ts +3 -0
  249. package/src/service/cli.ts +20 -4
  250. package/src/service/desktop-command-guard.ts +32 -0
  251. package/src/service/desktop-startup.ts +66 -40
  252. package/src/service/desktop-supervision.d.mts +26 -0
  253. package/src/service/desktop-supervision.mjs +140 -0
  254. package/src/service/managing-cli.ts +1 -0
  255. package/src/service/orchestration.ts +46 -20
  256. package/src/service/repair.ts +25 -9
  257. package/src/service/state.ts +4 -3
  258. package/src/service/systemd.ts +17 -8
  259. package/src/service/windows-ops.ts +61 -22
  260. package/src/storage/policy-job.ts +5 -1
  261. package/src/storage/policy-worker.ts +3 -1
  262. package/src/storage/policy.ts +9 -0
  263. package/src/types/config.ts +21 -0
  264. package/src/types/request.ts +10 -0
  265. package/src/types/wire.ts +1 -0
  266. package/src/update/index.ts +57 -9
  267. package/src/update/restart-ownership.ts +10 -3
  268. package/src/update/runtime-ownership.d.mts +6 -0
  269. package/src/update/runtime-ownership.mjs +23 -7
  270. package/src/usage/expected-prices.ts +1 -1
  271. package/src/web-search/loop.ts +5 -2
  272. package/gui/dist/assets/App-7yhoys8a.js +0 -54
@@ -1,3 +1,4 @@
1
+ import { spendLedgerStorageErrorResponse } from "./spend-storage-error";
1
2
  import { classifyAnthropic429 } from "../../oauth/anthropic-rate-limit-policy";
2
3
  import { rotateAnthropicAccountOnResponseForInstance } from "../../oauth/anthropic-account-refusal";
3
4
  import { authorizeResendForRecovery } from "../../lib/request-resend-gate";
@@ -134,12 +135,15 @@ export async function prepareAdapterExchange(
134
135
  | "transportToken"
135
136
  | "oauthDispatch"
136
137
  | "imageTierBias"
138
+ | "oauth401ReplayAttempted"
137
139
  | "isOAuth401ReplayProvider"
138
140
  | "sentOAuthSnapshot"
139
141
  | "refreshResolvedOAuthSelection"
140
142
  | "replayOAuthCredentialSnapshot"
141
143
  | "invalidateSameTargetRequest"
142
144
  | "resolveSelectionAdapter"
145
+ | "resolveCopilotSelection"
146
+ | "copilotRefusalResponse"
143
147
  | "anthropicInstance"
144
148
  | "currentAnthropicRouteDecision"
145
149
  | "anthropicRouteDecision"
@@ -156,6 +160,7 @@ export async function prepareAdapterExchange(
156
160
  responseEffects: Pick<ResponsesEffects, "cancelResponseCompletion" | "notifyResponseComplete" | "refreshRequestToolAliases">,
157
161
  sendBudgetState: Pick<
158
162
  ResponsesSendBudget,
163
+ | "adapterSendBudget"
159
164
  | "adapterDispatchBudget"
160
165
  | "noteAdapterPhysicalSend"
161
166
  | "noteAdapterRecoveryWithheld"
@@ -170,6 +175,9 @@ export async function prepareAdapterExchange(
170
175
  | "workflowRootId"
171
176
  | "claimAmbiguousResend"
172
177
  | "sendsUsed"
178
+ | "targetSendsUsed"
179
+ | "initialSendAllowance"
180
+ | "noteInitialDispatch"
173
181
  >,
174
182
  ) {
175
183
  const { options, config, logCtx, req } = requestContext;
@@ -179,6 +187,8 @@ export async function prepareAdapterExchange(
179
187
  refreshResolvedOAuthSelection,
180
188
  invalidateSameTargetRequest,
181
189
  resolveSelectionAdapter,
190
+ resolveCopilotSelection,
191
+ copilotRefusalResponse,
182
192
  anthropicInstance,
183
193
  currentAnthropicRouteDecision,
184
194
  anthropicSessionKey,
@@ -196,6 +206,7 @@ export async function prepareAdapterExchange(
196
206
  } = requestState;
197
207
  const { cancelResponseCompletion, notifyResponseComplete, refreshRequestToolAliases } = responseEffects;
198
208
  const {
209
+ adapterSendBudget,
199
210
  adapterDispatchBudget,
200
211
  noteAdapterPhysicalSend,
201
212
  noteAdapterRecoveryWithheld,
@@ -219,6 +230,17 @@ export async function prepareAdapterExchange(
219
230
 
220
231
  const upstream = new AbortController();
221
232
  const cleanupUpstreamAbort = linkAbortSignal(upstream, options.abortSignal);
233
+ /** Contain replacement negotiation before adapter admission and release its upstream abort link. */
234
+ const resolveRotatedCopilotSelection = async (): Promise<Response | undefined> => {
235
+ try { await resolveCopilotSelection(parsed); }
236
+ catch (error) {
237
+ cleanupUpstreamAbort();
238
+ upstream.abort();
239
+ if ((options.abortSignal ?? req.signal).aborted) return clientCancelledResponse();
240
+ return copilotRefusalResponse(error)
241
+ ?? formatErrorResponse(502, "upstream_error", "GitHub Copilot Auto negotiation failed.");
242
+ }
243
+ };
222
244
  const connectMs = config.connectTimeoutMs ?? 200_000;
223
245
  // Bridge stall budget (seconds of silence before upstream_stall_timeout); the retry backoff
224
246
  // heartbeat interval is derived from it so the watchdog is always fed during deliberate waits.
@@ -335,10 +357,15 @@ export async function prepareAdapterExchange(
335
357
  * is invisible to it and a missed bump would replay a request built with a stale key.
336
358
  */
337
359
 
360
+ const initialRecovery = route.providerName === "github-copilot"
361
+ ? sendBudgetState.pendingHopPermit?.sendClass === "auth-recovery" ? "oauth-account-429"
362
+ : transportState.oauth401ReplayAttempted ? "oauth-401" : undefined
363
+ : undefined;
338
364
  let upstreamResponse: Response;
365
+ let handoffPermit: ResponsesSendBudget["pendingHopPermit"];
339
366
  try {
340
367
  if (transportState.activeAdapter.fetchResponse) {
341
- transportState.noteRoutedAttemptSend(inputTokenEstimate);
368
+ transportState.noteRoutedAttemptSend(inputTokenEstimate, initialRecovery);
342
369
  const producer = adapterDispatchBudget?.beginSpendProducer?.();
343
370
  try {
344
371
  upstreamResponse = await withProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal, pacingSlot =>
@@ -371,31 +398,55 @@ export async function prepareAdapterExchange(
371
398
  // reset-only semantics so combo failover hops on the first 5xx.
372
399
  const transientPolicy = transientRetryPolicyFor(route.provider);
373
400
  const resetPolicy = resetReplayPolicyFor(route.provider);
374
- const compactPrepaid = options.compactionRecoveryAttempted ? sendBudgetState.pendingHopPermit : undefined;
401
+ // Rebook a native Copilot hop against the negotiated destination before dispatch.
402
+ // Raw externally counted permits settle through the retry helper's send reporter.
403
+ if (route.providerName === "github-copilot" && adapterSendBudget && sendBudgetState.pendingHopPermit
404
+ && sendBudgetState.pendingHopPermit.sendClass === "auth-recovery") {
405
+ const original = sendBudgetState.pendingHopPermit;
406
+ sendBudgetState.pendingHopPermit = undefined;
407
+ original.release();
408
+ const decision = adapterSendBudget.reserveDispatch({
409
+ sendClass: original.sendClass,
410
+ targetKey: `${route.providerName}|${builtInitialRequest.url}|${route.modelId}`,
411
+ countedExternally: true,
412
+ });
413
+ if (!decision.allowed) throw new SendBudgetExhaustedError(safeHostLabel(builtInitialRequest.url));
414
+ handoffPermit = decision.permit;
415
+ }
416
+ const compactPrepaid = handoffPermit ?? (options.compactionRecoveryAttempted ? sendBudgetState.pendingHopPermit : undefined);
375
417
  if (compactPrepaid) sendBudgetState.pendingHopPermit = undefined;
376
418
  let compactPrepaidUsed = false;
419
+ // A combo-owned or compaction-prepaid send is settled by the physical-dispatch receipt
420
+ // when spend enforcement is off. Enforced spend and direct requests keep the shared
421
+ // physical-send reporter, which already owns the permit lifecycle there.
422
+ // Stable per request: a Combo or compaction permit comes from `reserveDispatch`, which starts
423
+ // (and freezes) the spend policy first, so `spendEnforced` cannot change before dispatch.
424
+ const receiptMode = !sendBudgetState.adapterSendBudget?.spendEnforced
425
+ && Boolean(options.comboInitialSend || compactPrepaid);
377
426
  // Combo and emergency compaction admission already book this target's first send.
378
427
  // Its configured initial ceiling is target-local; the shared remainder below still
379
428
  // accounts for earlier targets without deducting their sends from this target twice.
380
429
  const initialSendCap = transientPolicy || resetPolicy
430
+ || (options.comboAttempt && route.provider.adapter === "google")
381
431
  ? transientSendCapFor(transientPolicy?.attempts,
382
432
  (options.comboAttempt || compactPrepaid) ? 0 : sendBudgetState.sendsUsed)
383
433
  : 1;
384
434
  const fetchWithRetryPolicy = (route.provider.adapter === "google" || transientPolicy)
385
435
  ? fetchWithTransientRetry
386
436
  : fetchWithResetRetry;
387
- upstreamResponse = await fetchWithRetryPolicy(
437
+ try { upstreamResponse = await fetchWithRetryPolicy(
388
438
  recovery => {
389
439
  // Unconfigured generic sends may omit the counting reporter. Capture policy
390
440
  // independently so a later recovery still belongs to this request's start.
391
441
  sendBudgetState.adapterSendBudget?.startRequest?.({
392
442
  poolId: logCtx.spendPoolId ?? route.providerName, identityId: logCtx.accountLogLabel,
393
443
  });
394
- if (compactPrepaid && !compactPrepaidUsed) {
444
+ // Receipt mode books the physical send at executor admission instead of here.
445
+ if (!receiptMode && compactPrepaid && !compactPrepaidUsed) {
395
446
  if (!compactPrepaid.use()) throw new SendBudgetExhaustedError(safeHostLabel(builtInitialRequest.url));
396
447
  compactPrepaidUsed = true;
397
448
  }
398
- transportState.noteRoutedAttemptSend(inputTokenEstimate, recovery);
449
+ transportState.noteRoutedAttemptSend(inputTokenEstimate, recovery ?? initialRecovery);
399
450
  return fetchWithHeaderTimeout(builtInitialRequest.url, applyUpstreamRecoveryInit({
400
451
  method: builtInitialRequest.method,
401
452
  headers: builtInitialRequest.headers,
@@ -405,6 +456,14 @@ export async function prepareAdapterExchange(
405
456
  dispatchOverride: oauthDispatch(builtInitialRequest),
406
457
  providerName: route.providerName,
407
458
  modelId: route.modelId,
459
+ onPhysicalDispatch: () => {
460
+ // Preserve the unconfigured direct-adapter accounting contract; Combo admission
461
+ // and explicit retry/compaction budgets require a receipt for their booked send.
462
+ if (receiptMode) {
463
+ sendBudgetState.noteInitialDispatch(compactPrepaid && !compactPrepaidUsed ? compactPrepaid : undefined);
464
+ compactPrepaidUsed = true;
465
+ }
466
+ },
408
467
  beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
409
468
  ? createCodexAuthDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
410
469
  }));
@@ -413,27 +472,34 @@ export async function prepareAdapterExchange(
413
472
  abortSignal: upstream.signal,
414
473
  label: safeHostLabel(builtInitialRequest.url),
415
474
  claimAmbiguousResend: claimPreHeaderResend,
416
- ...(sendBudgetState.adapterSendBudget?.spendEnforced || options.comboDispatchPermit || transientPolicy || resetPolicy || compactPrepaid
475
+ ...(sendBudgetState.adapterSendBudget?.spendEnforced || options.comboDispatchPermit || transientPolicy || resetPolicy || compactPrepaid || options.comboAttempt
417
476
  ? {
418
477
  // A pending compaction permit already booked this leg's first physical send.
419
- ...(transientPolicy || resetPolicy || compactPrepaid
478
+ ...(transientPolicy || resetPolicy || compactPrepaid || options.comboAttempt
420
479
  ? {
421
480
  attempts: Math.min(initialSendCap,
422
- remainingTransientSendBudget(initialSendCap) + (compactPrepaid ? 1 : 0)),
481
+ sendBudgetState.initialSendAllowance(initialSendCap) + (compactPrepaid ? 1 : 0)),
423
482
  }
424
483
  : {}),
425
- // Keep the exact combo or compaction receipt on the helper callback. The reporter
426
- // also settles the request and workflow counters for reset-only retries.
427
- onSendsConsumed: transientSendReporter(compactPrepaid ?? options.comboDispatchPermit),
484
+ // Reporter path: direct requests and enforced spend. Receipt-mode sends are
485
+ // settled by `onPhysicalDispatch`, so a reporter here would count them twice.
486
+ ...(receiptMode
487
+ ? {}
488
+ : { onSendsConsumed: transientSendReporter(compactPrepaid ?? options.comboDispatchPermit) }),
428
489
  }
429
490
  : {}),
430
491
  },
431
- );
492
+ ); } finally { compactPrepaid?.release(); }
432
493
  }
433
494
  } catch (err) {
434
495
  cleanupUpstreamAbort();
435
496
  upstream.abort();
436
497
  if (options.abortSignal?.aborted) return clientCancelledResponse();
498
+ const storageRefusal = spendLedgerStorageErrorResponse(err, logCtx);
499
+ if (storageRefusal) {
500
+ releaseCodexAuthContextProbeLease(admissionState.authCtx);
501
+ return storageRefusal;
502
+ }
437
503
  const refusal = err instanceof UpstreamRetryEvidenceError ? err.cause : err;
438
504
  const codexRefusal = mapCodexAuthContextErrorToResponse(unwrapUpstreamRetryEvidenceError(err), {
439
505
  now: Date.now(), accountSelector: route.codexAccountNamespace,
@@ -464,6 +530,7 @@ export async function prepareAdapterExchange(
464
530
  const msg = describeUpstreamConnectFailure(err, connectMs);
465
531
  return formatErrorResponse(502, "upstream_error", msg);
466
532
  } finally {
533
+ handoffPermit?.release();
467
534
  builtInitialRequest.releaseBodyObservation?.();
468
535
  }
469
536
 
@@ -487,7 +554,7 @@ export async function prepareAdapterExchange(
487
554
  // Console Go answers a transient 400 "Invalid upload request." for bodies it accepts
488
555
  // moments later; at most one byte-identical replay is allowed per request.
489
556
  const consoleGoUploadRetryGuard: { attempted: boolean } = { attempted: false };
490
- let oauth401ReplayAttempted = false;
557
+ let oauth401ReplayAttempted = transportState.oauth401ReplayAttempted;
491
558
  let antigravityAuthRotationAttempted = false;
492
559
  // At most one reasoning-effort downgrade per request. This sits outside the recovery loop
493
560
  // below for the same reason the two guards above do: a guard declared inside it is reset by
@@ -609,8 +676,7 @@ export async function prepareAdapterExchange(
609
676
  const helperCountsSends = sendBudgetState.adapterSendBudget?.spendEnforced === true || refetchTransientPolicy !== null || resetReplayPolicyFor(route.provider) !== null;
610
677
  const prepaid = sendBudgetState.pendingHopPermit;
611
678
  const configuredTotal = refetchTransientPolicy?.attempts;
612
- const refetchCap = transientSendCapFor(configuredTotal,
613
- sendBudgetState.sendsUsed - (prepaid ? 1 : 0));
679
+ const refetchCap = transientSendCapFor(configuredTotal, sendBudgetState.targetSendsUsed);
614
680
  // An exact total includes a booked hop, even when it consumed the last base slot.
615
681
  // Keep that funded send while forbidding the final reserve from widening the total.
616
682
  const prepaidLastSlot = helperCountsSends && configuredTotal !== undefined && prepaid
@@ -671,6 +737,13 @@ export async function prepareAdapterExchange(
671
737
  retryRequest.releaseBodyObservation?.();
672
738
  }
673
739
  } catch (err) {
740
+ const storageRefusal = !options.abortSignal?.aborted && spendLedgerStorageErrorResponse(err, logCtx);
741
+ if (storageRefusal) {
742
+ cleanupUpstreamAbort();
743
+ upstream.abort();
744
+ releaseCodexAuthContextProbeLease(admissionState.authCtx);
745
+ return { failed: storageRefusal };
746
+ }
674
747
  const codexRefusal = !options.abortSignal?.aborted && mapCodexAuthContextErrorToResponse(unwrapUpstreamRetryEvidenceError(err), {
675
748
  now: Date.now(), accountSelector: route.codexAccountNamespace,
676
749
  });
@@ -772,7 +845,7 @@ export async function prepareAdapterExchange(
772
845
  && !oauth401ReplayAttempted
773
846
  && !sendBudgetExhausted()
774
847
  ) {
775
- oauth401ReplayAttempted = true;
848
+ oauth401ReplayAttempted = transportState.oauth401ReplayAttempted = true;
776
849
  let refreshed: OAuthAccessSnapshot;
777
850
  try {
778
851
  refreshed = await refreshResolvedOAuthSelection(transportState.sentOAuthSnapshot);
@@ -843,7 +916,7 @@ export async function prepareAdapterExchange(
843
916
  if (route.providerName === "kiro") {
844
917
  parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) };
845
918
  }
846
- const refreshedProvider = resolveProviderTransport(
919
+ let refreshedProvider = resolveProviderTransport(
847
920
  route.providerName,
848
921
  {
849
922
  ...route.provider,
@@ -856,6 +929,9 @@ export async function prepareAdapterExchange(
856
929
  : undefined,
857
930
  );
858
931
  route.provider = refreshedProvider;
932
+ const copilotRefusal = await resolveRotatedCopilotSelection();
933
+ if (copilotRefusal) return copilotRefusal;
934
+ refreshedProvider = route.provider;
859
935
  invalidateSameTargetRequest();
860
936
  transportState.activeAdapter = resolveSelectionAdapter(
861
937
  resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider, inboundWire, route.staticPolicy),
@@ -908,6 +984,8 @@ export async function prepareAdapterExchange(
908
984
  // until runtime cleanup (one per rotated key).
909
985
  try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
910
986
  route.provider = rotated;
987
+ const selectionFailure = await resolveRotatedCopilotSelection();
988
+ if (selectionFailure) return selectionFailure;
911
989
  invalidateSameTargetRequest();
912
990
  transportState.activeAdapter = resolveSelectionAdapter(
913
991
  resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, route.staticPolicy),
@@ -1054,6 +1132,8 @@ export async function prepareAdapterExchange(
1054
1132
  // until runtime cleanup (one per rotated key under a rate-limit storm).
1055
1133
  try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
1056
1134
  route.provider = rotated;
1135
+ const selectionFailure = await resolveRotatedCopilotSelection();
1136
+ if (selectionFailure) return selectionFailure;
1057
1137
  invalidateSameTargetRequest();
1058
1138
  transportState.activeAdapter = resolveSelectionAdapter(
1059
1139
  resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, route.staticPolicy),
@@ -1076,7 +1156,7 @@ export async function prepareAdapterExchange(
1076
1156
  // Anthropic OAuth: recover a rate limit or proven account entitlement refusal
1077
1157
  // before output, within the shared request and account rotation limits.
1078
1158
  while (
1079
- (upstreamResponse.status === 429 || upstreamResponse.status === 403)
1159
+ (upstreamResponse.status === 429 || upstreamResponse.status === 403 || upstreamResponse.status === 401)
1080
1160
  && anthropicInstance
1081
1161
  && transportState.anthropicPoolAccountId
1082
1162
  ) {
@@ -1101,7 +1181,7 @@ export async function prepareAdapterExchange(
1101
1181
  );
1102
1182
  sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel);
1103
1183
  recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name);
1104
- const result = await rebuildAndRefetch("anthropic-oauth-429");
1184
+ const result = await rebuildAndRefetch(upstreamResponse.status === 401 ? "oauth-401" : "anthropic-oauth-429");
1105
1185
  if ("failed" in result) return result.failed;
1106
1186
  upstreamResponse = result;
1107
1187
  if (isNonReplayableResponse(upstreamResponse)) continue recovery;
@@ -9,7 +9,7 @@ import { CODEX_RESPONSES_HTTP_URL, type PreparedCodexWsRequest } from "./codex-w
9
9
  import { CodexWsCorrelation } from "./codex-ws-correlation";
10
10
  import type { CodexWsSession } from "./codex-ws-session";
11
11
  import { UPGRADE_DEADLINE_MS, CODEX_WS_LIVENESS_PING_INTERVAL_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES,
12
- MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage,
12
+ MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, markCodexWsPreludeProjection, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage,
13
13
  codexWsCreateFrameExceedsLimit, codexWsFailureDetail, codexWsPreResponseFailure, markCodexWsStage, codexWsOcxVersion,
14
14
  markCodexWsSocketDeath, type CodexWsFailureStage, type CodexWsStageRecord } from "./codex-ws-wire";
15
15
 
@@ -23,6 +23,7 @@ interface ExchangeOptions {
23
23
  sseFallback: typeof globalThis.fetch;
24
24
  onQuota?: CodexWsQuotaObserver;
25
25
  beforeDispatch?: (headers: Headers) => void;
26
+ onPhysicalDispatch?: () => void;
26
27
  /** Bun version string the caller gated on; stamped onto the stage record. */
27
28
  bunVersion?: string;
28
29
  }
@@ -44,7 +45,8 @@ function rejectionHeaders(source: Record<string, unknown>, prelude: Headers): He
44
45
  }
45
46
  }
46
47
  // Reuse the metadata owner's count/value/family budgets and window freshness
47
- // rules, without publishing quota twice. The unmarked HTTP response owns it.
48
+ // rules, without publishing quota twice. Pool bookkeeping consumes the HTTP
49
+ // projection; plain-main publication belongs only to the WS observer.
48
50
  const projected = new CodexWsMetadata();
49
51
  try {
50
52
  for (const values of [Object.fromEntries(prelude), source]) {
@@ -240,6 +242,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
240
242
  // are left out: their channel may already have sent continuation frames on this socket, so
241
243
  // the create frame alone no longer describes the turn.
242
244
  if (socketDied && !nativeControl) markCodexWsSocketDeath(failureResponse, stage);
245
+ markCodexWsPreludeProjection(failureResponse);
243
246
  resolve(failureResponse);
244
247
  return;
245
248
  }
@@ -411,6 +414,17 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
411
414
  failStream(error);
412
415
  return;
413
416
  }
417
+ try { options.onPhysicalDispatch?.(); } catch (error) {
418
+ // Admission refused before the frame left. This is a pre-open rejection,
419
+ // not a transport failure and not an authorization to replay over SSE.
420
+ settledPreOpen = true;
421
+ sent = false;
422
+ terminal = true;
423
+ cleanup();
424
+ session.dispose();
425
+ reject(error);
426
+ return;
427
+ }
414
428
  try {
415
429
  ws.send(frameText);
416
430
  sentAt = Date.now();
@@ -501,6 +515,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
501
515
  cleanup();
502
516
  try { controller.close(); } catch { /* unused stream already closed */ }
503
517
  session.dispose();
518
+ markCodexWsPreludeProjection(rejection);
504
519
  resolve(rejection);
505
520
  return;
506
521
  }
@@ -1,4 +1,5 @@
1
1
  import { createHmac, randomBytes } from "node:crypto";
2
+ import { codexWsReuseAcrossTurnsEnabled } from "../../config/codex-ws-reuse-setting";
2
3
  import { registerOptionalShutdownHook } from "../../lib/optional-shutdown-hooks";
3
4
  import { CODEX_RESPONSES_HTTP_URL, CODEX_WS_FRAME_HEADERS } from "./codex-ws-request";
4
5
  import { CODEX_WS_ID_MAX_BYTES } from "./codex-ws-correlation";
@@ -7,11 +8,15 @@ import { CodexWsSession } from "./codex-ws-session";
7
8
  export const CODEX_WS_POOL_MAX_SESSIONS = 32;
8
9
  export const CODEX_WS_POOL_IDLE_MS = 30_000;
9
10
  export const CODEX_WS_POOL_MAX_AGE_MS = 5 * 60_000;
11
+ export const CODEX_WS_POOL_WAIT_MS = 750;
12
+ /** Retained sockets per account+thread while cross-turn reuse is on. */
13
+ export const CODEX_WS_POOL_SCOPE_LIMIT = 2;
14
+ const CODEX_WS_POOL_WAIT_DEPTH = 2;
10
15
  const MUTABLE_HEADERS = new Set<string>(CODEX_WS_FRAME_HEADERS);
11
16
  let processKey: Buffer | undefined;
12
17
  let poolSequence = 0;
13
18
 
14
- export interface CodexWsReuseIdentity { key: string; scope: string }
19
+ export interface CodexWsReuseIdentity { key: string; scope: string; routeTag: string }
15
20
  function value(value: unknown): value is string {
16
21
  return typeof value === "string" && value.trim().length > 0
17
22
  && !/[\u0000-\u001f\u007f]/.test(value) && Buffer.byteLength(value) <= CODEX_WS_ID_MAX_BYTES;
@@ -49,19 +54,23 @@ export function codexWsReuseIdentity(url: string, headers: Record<string, string
49
54
  const immutable = Object.entries(headers).filter(([name]) => !MUTABLE_HEADERS.has(name)).sort(([a], [b]) => a.localeCompare(b));
50
55
  if (immutable.length > 128 || immutable.some(([, field]) => !value(field))
51
56
  || immutable.reduce((bytes, [name, field]) => bytes + Buffer.byteLength(name) + Buffer.byteLength(field), 0) > 32 * 1024) return null;
52
- const scope = digest([url, account, thread, turn]);
57
+ const reuseAcrossTurns = codexWsReuseAcrossTurnsEnabled();
58
+ const scope = digest(reuseAcrossTurns ? [url, account, thread] : [url, account, thread, turn]);
53
59
  const lite = metadata.ws_request_header_x_openai_internal_codex_responses_lite;
54
60
  if (lite !== undefined && lite !== "true" && lite !== "false") return null;
55
61
  // `dialUrl` is the socket destination after plugin rewrites; absent means the canonical one.
56
- return { scope, key: digest([scope, authorization, body.model, body.service_tier ?? null, lite ?? null, immutable, proxy ?? null, dialUrl ?? null]) };
62
+ const routeTag = digest(["route", authorization, proxy ?? null, dialUrl ?? null]);
63
+ return { scope, routeTag, key: digest([scope, authorization, body.model, body.service_tier ?? null, lite ?? null, immutable, proxy ?? null, dialUrl ?? null]) };
57
64
  }
58
65
 
59
66
  interface Entry { identity: CodexWsReuseIdentity; session: CodexWsSession; createdAt: number; idleAt: number; retired: boolean }
60
- interface PoolOptions { now?: () => number; maxSessions?: number; idleMs?: number; maxAgeMs?: number }
67
+ interface Waiter { key: string; resolve: (session: CodexWsSession | null) => void; timer: ReturnType<typeof setTimeout>; onAbort: () => void; signal?: AbortSignal }
68
+ interface PoolOptions { now?: () => number; maxSessions?: number; idleMs?: number; maxAgeMs?: number; waitMs?: number }
61
69
 
62
70
  /** Bounded retained sockets only. Busy/capacity misses keep the existing one-shot path. */
63
71
  export class CodexWsPool {
64
72
  private readonly entries = new Map<string, Entry>();
73
+ private readonly waiters: Waiter[] = [];
65
74
  private timer?: ReturnType<typeof setTimeout>;
66
75
  private detachShutdown?: () => void;
67
76
  private readonly hookKey = `codex-upstream-ws-pool-${++poolSequence}`;
@@ -69,29 +78,74 @@ export class CodexWsPool {
69
78
  private readonly maxSessions: number;
70
79
  private readonly idleMs: number;
71
80
  private readonly maxAgeMs: number;
81
+ private readonly waitMs: number;
72
82
  constructor(options: PoolOptions = {}) {
73
83
  this.now = options.now ?? Date.now;
74
84
  this.maxSessions = options.maxSessions ?? CODEX_WS_POOL_MAX_SESSIONS;
75
85
  this.idleMs = options.idleMs ?? CODEX_WS_POOL_IDLE_MS;
76
86
  this.maxAgeMs = options.maxAgeMs ?? CODEX_WS_POOL_MAX_AGE_MS;
87
+ this.waitMs = options.waitMs ?? CODEX_WS_POOL_WAIT_MS;
77
88
  }
78
89
 
79
90
  acquire(identity: CodexWsReuseIdentity, url: string, headers: Record<string, string>, proxy?: string): CodexWsSession | null {
80
- this.sweep();
81
- for (const entry of this.entries.values()) {
82
- if (entry.identity.scope !== identity.scope || entry.identity.key === identity.key) continue;
83
- entry.retired = true;
84
- if (!entry.session.busy) this.remove(entry);
91
+ return this.claim(identity, url, headers, proxy).session;
92
+ }
93
+
94
+ /**
95
+ * Before any frame is sent, wait for the matching busy socket instead of dialing a throwaway.
96
+ * The queue is FIFO and at most two deep. An abort or the deadline returns null.
97
+ */
98
+ acquireWaiting(
99
+ identity: CodexWsReuseIdentity,
100
+ url: string,
101
+ headers: Record<string, string>,
102
+ proxy: string | undefined,
103
+ signal?: AbortSignal,
104
+ ): Promise<CodexWsSession | null> {
105
+ const claimed = this.claim(identity, url, headers, proxy);
106
+ if (claimed.session || !claimed.busy) return Promise.resolve(claimed.session);
107
+ if (signal?.aborted) return Promise.resolve(null);
108
+ if (this.waiters.filter(waiter => waiter.key === identity.key).length >= CODEX_WS_POOL_WAIT_DEPTH) {
109
+ return Promise.resolve(null);
85
110
  }
111
+ return new Promise(resolve => {
112
+ const waiter: Waiter = {
113
+ key: identity.key,
114
+ resolve,
115
+ signal,
116
+ timer: setTimeout(() => this.finishWaiter(waiter, null), this.waitMs),
117
+ onAbort: () => this.finishWaiter(waiter, null),
118
+ };
119
+ waiter.timer.unref?.();
120
+ signal?.addEventListener("abort", waiter.onAbort, { once: true });
121
+ this.waiters.push(waiter);
122
+ });
123
+ }
124
+
125
+ private claim(
126
+ identity: CodexWsReuseIdentity,
127
+ url: string,
128
+ headers: Record<string, string>,
129
+ proxy?: string,
130
+ ): { session: CodexWsSession | null; busy: boolean } {
131
+ this.sweep();
132
+ this.retireSiblings(identity);
86
133
  const existing = this.entries.get(identity.key);
87
134
  if (existing) {
88
- if (existing.retired || existing.session.busy) return null;
89
- if (existing.session.reserve()) { this.arm(); return existing.session; }
135
+ if (existing.retired) return { session: null, busy: false };
136
+ if (existing.session.busy) return { session: null, busy: true };
137
+ if (existing.session.reserve()) { this.arm(); return { session: existing.session, busy: false }; }
90
138
  this.remove(existing);
139
+ } else if (codexWsReuseAcrossTurnsEnabled() && this.scopeCount(identity.scope) >= CODEX_WS_POOL_SCOPE_LIMIT) {
140
+ const oldest = [...this.entries.values()]
141
+ .filter(entry => entry.identity.scope === identity.scope && !entry.session.busy)
142
+ .sort((left, right) => left.idleAt - right.idleAt)[0];
143
+ if (!oldest) return { session: null, busy: false };
144
+ this.remove(oldest);
91
145
  }
92
146
  if (this.entries.size >= this.maxSessions) {
93
147
  const oldest = [...this.entries.values()].filter(entry => !entry.session.busy).sort((a, b) => a.idleAt - b.idleAt)[0];
94
- if (!oldest) return null;
148
+ if (!oldest) return { session: null, busy: false };
95
149
  this.remove(oldest);
96
150
  }
97
151
  const createdAt = this.now();
@@ -100,13 +154,45 @@ export class CodexWsPool {
100
154
  session.reserve();
101
155
  this.entries.set(identity.key, entry);
102
156
  this.detachShutdown ??= registerOptionalShutdownHook(this.hookKey, () => this.dispose());
103
- return session;
157
+ return { session, busy: false };
158
+ }
159
+
160
+ private retireSiblings(identity: CodexWsReuseIdentity): void {
161
+ const reuseAcrossTurns = codexWsReuseAcrossTurnsEnabled();
162
+ for (const entry of this.entries.values()) {
163
+ if (entry.identity.scope !== identity.scope || entry.identity.key === identity.key) continue;
164
+ const routeChanged = entry.identity.routeTag !== identity.routeTag;
165
+ if (reuseAcrossTurns && !routeChanged) continue;
166
+ entry.retired = true;
167
+ if (!entry.session.busy) this.remove(entry);
168
+ }
169
+ }
170
+
171
+ private scopeCount(scope: string): number {
172
+ let count = 0;
173
+ for (const entry of this.entries.values()) if (entry.identity.scope === scope && !entry.retired) count += 1;
174
+ return count;
175
+ }
176
+
177
+ private finishWaiter(waiter: Waiter, session: CodexWsSession | null): void {
178
+ const index = this.waiters.indexOf(waiter);
179
+ if (index < 0) return;
180
+ this.waiters.splice(index, 1);
181
+ clearTimeout(waiter.timer);
182
+ waiter.signal?.removeEventListener("abort", waiter.onAbort);
183
+ waiter.resolve(session);
104
184
  }
105
185
 
106
186
  private changed(entry: Entry): void {
107
187
  if (this.entries.get(entry.identity.key) !== entry) return;
108
188
  if (entry.session.closed) this.entries.delete(entry.identity.key);
109
189
  else if (!entry.session.busy) {
190
+ const waiter = this.waiters.find(candidate => candidate.key === entry.identity.key);
191
+ if (waiter && !entry.retired && entry.session.reserve()) {
192
+ this.finishWaiter(waiter, entry.session);
193
+ this.arm();
194
+ return;
195
+ }
110
196
  entry.idleAt = this.now();
111
197
  if (entry.retired || entry.idleAt - entry.createdAt >= this.maxAgeMs) this.remove(entry);
112
198
  }
@@ -152,6 +238,7 @@ export class CodexWsPool {
152
238
  this.detachShutdown = undefined;
153
239
  const entries = [...this.entries.values()];
154
240
  this.entries.clear();
241
+ for (const waiter of [...this.waiters]) this.finishWaiter(waiter, null);
155
242
  for (const entry of entries) entry.session.dispose(new DOMException("codex websocket pool shutdown", "AbortError"));
156
243
  }
157
244
 
@@ -49,6 +49,7 @@ const WS_CLOSE_MESSAGE_TOO_BIG = 1009;
49
49
 
50
50
  const codexWsUpstreamResponses = new WeakSet<Response>();
51
51
  const quotaObservedResponses = new WeakSet<Response>();
52
+ const codexWsPreludeProjections = new WeakSet<Response>();
52
53
 
53
54
  /** Quota arrived directly at its captured account; do not replay old HTTP prelude headers. */
54
55
  export function isCodexWsQuotaObservedResponse(response: Response): boolean {
@@ -60,6 +61,14 @@ export function isCodexWsUpstreamResponse(response: Response): boolean {
60
61
  return codexWsUpstreamResponses.has(response);
61
62
  }
62
63
 
64
+ /** Pre-response projection; its headers retain the exchange's prelude snapshot. */
65
+ export function markCodexWsPreludeProjection(response: Response): void {
66
+ codexWsPreludeProjections.add(response);
67
+ }
68
+
69
+ export function isCodexWsPreludeProjection(response: Response): boolean {
70
+ return codexWsPreludeProjections.has(response);
71
+ }
63
72
 
64
73
  export function markCodexWsResponse(response: Response, observed: boolean): void {
65
74
  codexWsUpstreamResponses.add(response);
@@ -10,5 +10,7 @@ export function comboRequestedEffortLabel(
10
10
  const forcedTransition = forcedEffort != null && forcedEffort !== originalRequestedEffort
11
11
  ? `->${forcedEffort}`
12
12
  : "";
13
- return `${originalRequestedEffort}${forcedTransition}${laterTransitions}`;
13
+ const segments = `${originalRequestedEffort}${forcedTransition}${laterTransitions}`.split("->");
14
+ // A pin that kept the effort it was given is not a transition; drop the repeated segment.
15
+ return segments.filter((segment, index) => index === 0 || segment !== segments[index - 1]).join("->");
14
16
  }
@@ -1,3 +1,4 @@
1
+ import { spendLedgerStorageErrorResponse } from "./spend-storage-error";
1
2
  import { createPhysicalSendReporter } from "../../lib/request-execution-budget";
2
3
  import { createInferenceSendBudget } from "../inference/context";
3
4
  import { unboundPoolSpendRefusalResponse } from "../workflow-refusal";
@@ -1099,11 +1100,11 @@ export async function handleResponsesCompact(
1099
1100
  let outcomeCtx = authCtx;
1100
1101
  const localDispatchRefusal = (error: unknown): Response | undefined => {
1101
1102
  const cause = unwrapUpstreamRetryEvidenceError(error);
1102
- const response = cause instanceof SendBudgetExhaustedError
1103
+ const response = spendLedgerStorageErrorResponse(error, logCtx) ?? (cause instanceof SendBudgetExhaustedError
1103
1104
  ? unboundPoolSpendRefusalResponse(logCtx) ?? formatErrorResponse(429, "request_send_budget_exhausted", cause.message)
1104
1105
  : mapCodexAuthContextErrorToResponse(cause, {
1105
1106
  now: Date.now(), accountSelector: route.codexAccountNamespace,
1106
- });
1107
+ }));
1107
1108
  if (response) {
1108
1109
  releaseUpstreamHostAdmission(compactHostAdmissionLease);
1109
1110
  compactHostAdmissionLease = null;
@@ -6,6 +6,7 @@ import {
6
6
  computeQuotaCooldown,
7
7
  formatCodexProviderForLog,
8
8
  } from "../../codex/routing";
9
+ import { claimMainQuotaDispatchForWs, isMainQuotaDispatchLive, renewMainQuotaDispatchForAttempt, type MainQuotaDispatch } from "../../codex/main-account-cache";
9
10
  import type { CodexWsQuotaObserver } from "./codex-ws-metadata";
10
11
  import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
11
12
  import { isCodexAccountGenerationLive } from "../../codex/account-store";
@@ -121,8 +122,28 @@ export function usesCodexForwardPoolAuth(
121
122
  }
122
123
 
123
124
 
125
+ /** Live proof that this plain-main response used the observed main credential. */
126
+ export function liveMainQuotaDispatch(
127
+ authCtx: CodexAuthContext, provider: OcxProviderConfig,
128
+ ): MainQuotaDispatch | undefined {
129
+ if (authCtx.kind !== "main" || !authCtx.mainQuotaDispatch) return undefined;
130
+ if (!isCanonicalOpenAiForwardProvider(provider)
131
+ || provider.authMode !== "forward" || provider.adapter !== "openai-responses") return undefined;
132
+ return isMainQuotaDispatchLive(authCtx.mainQuotaDispatch) ? authCtx.mainQuotaDispatch : undefined;
133
+ }
134
+
124
135
  export function codexWsQuotaObserver(authCtx: CodexAuthContext, provider: OcxProviderConfig, modelId?: string): CodexWsQuotaObserver | undefined {
125
- if (!isCanonicalOpenAiForwardProvider(provider) || !usesCodexForwardPoolAuth(authCtx, provider)) return undefined;
136
+ if (!isCanonicalOpenAiForwardProvider(provider)) return undefined;
137
+ if (!usesCodexForwardPoolAuth(authCtx, provider)) {
138
+ const captured = liveMainQuotaDispatch(authCtx, provider);
139
+ if (!captured || authCtx.kind !== "main") return undefined;
140
+ const dispatch = authCtx.mainQuotaDispatch = renewMainQuotaDispatchForAttempt(captured);
141
+ return headers => {
142
+ claimMainQuotaDispatchForWs(dispatch);
143
+ if (!isMainQuotaDispatchLive(dispatch)) return;
144
+ applyCapturedCodexQuota(MAIN_CODEX_ACCOUNT_ID, headers, dispatch.configGeneration, dispatch.writer, { modelId });
145
+ };
146
+ }
126
147
  const { accountId, writerGeneration } = authCtx;
127
148
  const credentialGeneration = authCtx.kind === "pool" ? authCtx.generation : undefined;
128
149
  const mainWriter = authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined;