@bitkyc08/opencodex 2.89.0 → 2.91.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 (242) hide show
  1. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  2. package/gui/dist/assets/App-nZ34cR5G.js +58 -0
  3. package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-BuLy7hL1.js} +1 -1
  4. package/gui/dist/assets/{index-uchD-ngf.js → index-DGXS51sp.js} +15 -15
  5. package/gui/dist/assets/quota-summary-DO1Sx5TM.js +1 -0
  6. package/gui/dist/index.html +1 -1
  7. package/package.json +2 -2
  8. package/src/adapters/anthropic.ts +2 -5
  9. package/src/adapters/base.ts +4 -0
  10. package/src/adapters/claude-cli/adapter.ts +13 -7
  11. package/src/adapters/claude-cli/usage-admission.ts +28 -2
  12. package/src/adapters/coding-agent/turn.ts +14 -4
  13. package/src/adapters/cursor/protobuf-request.ts +3 -0
  14. package/src/adapters/google-progress-history.ts +19 -1
  15. package/src/adapters/google.ts +28 -9
  16. package/src/adapters/image.ts +5 -3
  17. package/src/adapters/input-media-guard.ts +5 -2
  18. package/src/adapters/kiro-refusal.ts +41 -1
  19. package/src/adapters/ollama-native.ts +1 -0
  20. package/src/adapters/openai-chat/messages.ts +34 -4
  21. package/src/adapters/openai-chat/passthrough.ts +37 -4
  22. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  23. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  24. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  25. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  26. package/src/adapters/openai-chat.ts +7 -6
  27. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  28. package/src/adapters/openai-responses/passthrough.ts +7 -2
  29. package/src/adapters/openai-responses/request-strips.ts +32 -0
  30. package/src/adapters/openai-responses/transport.ts +19 -1
  31. package/src/chat/inbound.ts +20 -2
  32. package/src/chat/outbound.ts +36 -4
  33. package/src/chat/reasoning-details.ts +137 -0
  34. package/src/claude/desktop-3p-library.ts +43 -3
  35. package/src/claude/desktop-3p.ts +20 -10
  36. package/src/claude/desktop-picker-profile.ts +18 -9
  37. package/src/claude/desktop-remote-store-io.ts +2 -2
  38. package/src/cli/capabilities-agents-routing.ts +50 -0
  39. package/src/cli/capabilities-base.ts +15 -2
  40. package/src/cli/capabilities-observe-system.ts +4 -4
  41. package/src/cli/codex-shim-autorestore.ts +5 -0
  42. package/src/cli/dispatch.ts +30 -0
  43. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  44. package/src/cli/fallback.ts +127 -0
  45. package/src/cli/help.ts +1 -0
  46. package/src/cli/index.ts +1 -2
  47. package/src/cli/link-apply-restart.ts +72 -0
  48. package/src/cli/link-child.ts +6 -2
  49. package/src/cli/link-cleanup.ts +95 -0
  50. package/src/cli/link-enrollment.ts +4 -0
  51. package/src/cli/link.ts +84 -9
  52. package/src/cli/provider.ts +44 -2
  53. package/src/cli/registry.ts +13 -1
  54. package/src/cli/restart-failure.ts +4 -0
  55. package/src/cli/runtime-api.ts +1 -1
  56. package/src/cli/status-codex-settings.ts +56 -0
  57. package/src/cli/status-fallback.ts +29 -0
  58. package/src/cli/status-install.ts +52 -0
  59. package/src/cli/status.ts +20 -1
  60. package/src/cli/system-restart-client.ts +32 -1
  61. package/src/cli/update-restart-child.ts +4 -3
  62. package/src/cli/update-restart-home.ts +20 -8
  63. package/src/client/connect.ts +82 -6
  64. package/src/client/home-unreachable.ts +16 -0
  65. package/src/client/hub-relay.ts +8 -2
  66. package/src/client/link-home.ts +119 -6
  67. package/src/client/link-parity-reporter.ts +107 -0
  68. package/src/client/link-relay-transport.ts +8 -1
  69. package/src/client/link-relay.ts +27 -3
  70. package/src/client/machine-listener.ts +83 -1
  71. package/src/client/runtime-restart.ts +127 -0
  72. package/src/client/runtime.ts +137 -25
  73. package/src/codex/account-lifecycle.ts +19 -6
  74. package/src/codex/account-store.ts +27 -8
  75. package/src/codex/account-usability.ts +4 -1
  76. package/src/codex/auth-api/account-list.ts +10 -5
  77. package/src/codex/auth-api/account-pause-group.ts +2 -22
  78. package/src/codex/auth-api/login-collision.ts +59 -0
  79. package/src/codex/auth-api/login-flow.ts +12 -7
  80. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  81. package/src/codex/auth-collision.ts +31 -4
  82. package/src/codex/auth-context.ts +58 -12
  83. package/src/codex/catalog/parsing.ts +4 -0
  84. package/src/codex/cli-update-plan.ts +18 -3
  85. package/src/codex/collision-identity.ts +78 -0
  86. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  87. package/src/codex/main-account-hard-lock.ts +14 -0
  88. package/src/codex/quota-history.ts +7 -0
  89. package/src/codex/quota.ts +10 -0
  90. package/src/codex/routing/cooldown-math.ts +5 -0
  91. package/src/codex/routing/failure-window.ts +61 -9
  92. package/src/codex/routing.ts +4 -3
  93. package/src/codex/shim-diagnostics.ts +2 -1
  94. package/src/codex/shim-fingerprint.ts +11 -2
  95. package/src/codex/shim-templates.ts +16 -5
  96. package/src/codex/shim-windows-refresh.ts +224 -0
  97. package/src/codex/shim.ts +45 -21
  98. package/src/codex/subagent-model-fallback.ts +24 -1
  99. package/src/combos/failover.ts +8 -1
  100. package/src/combos/index.ts +1 -0
  101. package/src/combos/resolve.ts +12 -0
  102. package/src/config/diagnostics.ts +5 -0
  103. package/src/config/load-degrade.ts +2 -0
  104. package/src/config/provider-validation.ts +7 -2
  105. package/src/config/schema/config-schema.ts +3 -0
  106. package/src/config/schema/fallback-policy.ts +40 -0
  107. package/src/generated/compatibility-version.json +360 -192
  108. package/src/generated/model-metadata.ts +1 -1
  109. package/src/images/loop.ts +2 -2
  110. package/src/lib/authorization-scheme.ts +95 -0
  111. package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
  112. package/src/lib/provider-admission-wait.ts +14 -0
  113. package/src/lib/provider-client-headers.ts +1 -0
  114. package/src/lib/redact.ts +2 -2
  115. package/src/lib/service-secrets.ts +37 -2
  116. package/src/lib/system-restart-contract.ts +24 -0
  117. package/src/lib/test-home-guard.ts +78 -11
  118. package/src/lib/translator-budget.ts +1 -1
  119. package/src/lib/upstream-retry.ts +15 -0
  120. package/src/link/child-parity.ts +124 -0
  121. package/src/link/codex-settings.ts +37 -0
  122. package/src/link/compatibility.ts +119 -0
  123. package/src/link/management-routes.ts +2 -0
  124. package/src/link/peer-report.ts +100 -0
  125. package/src/link/remote-platform.ts +78 -0
  126. package/src/link/ssh-argv.ts +19 -0
  127. package/src/link/status-projection.ts +24 -2
  128. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  129. package/src/oauth/account-import/parser.ts +15 -2
  130. package/src/oauth/account-import/types.ts +7 -0
  131. package/src/oauth/chatgpt-shared.ts +10 -1
  132. package/src/oauth/dsh.ts +45 -2
  133. package/src/oauth/index.ts +1 -1
  134. package/src/oauth/kimi.ts +2 -0
  135. package/src/oauth/kiro.ts +44 -1
  136. package/src/oauth/login-flow-state.ts +11 -2
  137. package/src/oauth/nous.ts +4 -1
  138. package/src/oauth/store.ts +2 -1
  139. package/src/protocols/codecs/chat.ts +3 -3
  140. package/src/protocols/encoders/adapter-events.ts +26 -2
  141. package/src/protocols/encoders/chat.ts +55 -3
  142. package/src/providers/claude-cli-usage.ts +20 -5
  143. package/src/providers/initial-model-selection.ts +2 -2
  144. package/src/providers/key-failover.ts +10 -2
  145. package/src/providers/key-store.ts +8 -3
  146. package/src/providers/mistral-transport.ts +240 -0
  147. package/src/providers/openai-sidecar.ts +5 -0
  148. package/src/providers/quota/anthropic-account-quota.ts +18 -6
  149. package/src/providers/quota/antigravity.ts +8 -10
  150. package/src/providers/quota/failure-codes.ts +18 -0
  151. package/src/providers/quota/vendor-probes-oauth.ts +12 -3
  152. package/src/providers/quota-routing-cache.ts +1 -1
  153. package/src/providers/quota.ts +5 -1
  154. package/src/providers/registry/entries-extended.ts +6 -1
  155. package/src/providers/routed-config.ts +1 -1
  156. package/src/providers/xai-transport.ts +2 -1
  157. package/src/quota-pool/assemble.ts +326 -0
  158. package/src/quota-pool/contract.ts +58 -0
  159. package/src/quota-pool/identity.ts +203 -0
  160. package/src/remote/features.ts +74 -0
  161. package/src/remote/protocol.ts +2 -0
  162. package/src/remote/served-by.ts +57 -0
  163. package/src/responses/audio.ts +46 -0
  164. package/src/responses/input-media.ts +37 -15
  165. package/src/responses/parser-content.ts +22 -27
  166. package/src/responses/parser.ts +7 -0
  167. package/src/responses/reasoning-envelope.ts +12 -0
  168. package/src/responses/reasoning-retention.ts +218 -11
  169. package/src/responses/schema.ts +3 -4
  170. package/src/responses/web-search-replay.ts +11 -2
  171. package/src/routing/fallback-policy.ts +290 -0
  172. package/src/routing/fallback-state.ts +179 -0
  173. package/src/server/audio-client.ts +2 -1
  174. package/src/server/audio-dictation.ts +2 -1
  175. package/src/server/audio-upstream.ts +2 -1
  176. package/src/server/auth-cors.ts +6 -5
  177. package/src/server/background-lifecycle.ts +4 -0
  178. package/src/server/chat-completions.ts +8 -1
  179. package/src/server/chat-native.ts +8 -7
  180. package/src/server/gui-session.ts +2 -1
  181. package/src/server/index/link-listener.ts +11 -1
  182. package/src/server/index/link-management.ts +47 -0
  183. package/src/server/index/link-relay-sessions.ts +11 -2
  184. package/src/server/index/optional-listeners.ts +7 -2
  185. package/src/server/index/serve-options.ts +27 -4
  186. package/src/server/index/websocket-handler.ts +10 -0
  187. package/src/server/lifecycle.ts +17 -1
  188. package/src/server/live.ts +5 -0
  189. package/src/server/management/context.ts +1 -1
  190. package/src/server/management/fallback-routes.ts +75 -0
  191. package/src/server/management/link-enrollment.ts +21 -3
  192. package/src/server/management/link-route-state.ts +2 -0
  193. package/src/server/management/link-routes.ts +12 -1
  194. package/src/server/management/oauth-account-routes.ts +2 -1
  195. package/src/server/management/quota-pool-routes.ts +24 -0
  196. package/src/server/management/route-registry.ts +3 -0
  197. package/src/server/management-api.ts +16 -0
  198. package/src/server/management-auth.ts +2 -1
  199. package/src/server/messages-native.ts +11 -0
  200. package/src/server/request-log.ts +16 -7
  201. package/src/server/responses/adapter-continuation.ts +2 -2
  202. package/src/server/responses/adapter-dispatch.ts +2 -2
  203. package/src/server/responses/agent-task-recovery.ts +1 -1
  204. package/src/server/responses/codex-ws-exchange.ts +3 -0
  205. package/src/server/responses/codex-ws-metadata.ts +44 -0
  206. package/src/server/responses/codex-ws-session.ts +21 -1
  207. package/src/server/responses/combo-stream-preflight.ts +7 -2
  208. package/src/server/responses/compact.ts +4 -2
  209. package/src/server/responses/core-auth.ts +31 -1
  210. package/src/server/responses/core-codex-account.ts +21 -14
  211. package/src/server/responses/core-combo.ts +75 -1
  212. package/src/server/responses/core-opaque-recovery.ts +20 -11
  213. package/src/server/responses/deepseek-image-files.ts +121 -0
  214. package/src/server/responses/empty-completion-guard.ts +8 -0
  215. package/src/server/responses/encrypted-payload.ts +3 -3
  216. package/src/server/responses/fetch-helpers.ts +30 -10
  217. package/src/server/responses/input-admission.ts +6 -2
  218. package/src/server/responses/main-hard-lock-routing.ts +85 -8
  219. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  220. package/src/server/responses/passthrough-completion.ts +41 -0
  221. package/src/server/responses/passthrough-delivery.ts +2 -0
  222. package/src/server/responses/passthrough-dispatch.ts +13 -13
  223. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  224. package/src/server/responses/policy-fallback.ts +44 -1
  225. package/src/server/responses/reasoning-archive-error.ts +21 -0
  226. package/src/server/responses/request-prepare.ts +19 -1
  227. package/src/server/responses/request-transport.ts +61 -3
  228. package/src/server/restart-replacement.ts +50 -2
  229. package/src/server/ws-bridge.ts +5 -0
  230. package/src/service/cli.ts +25 -2
  231. package/src/service/diagnostics.ts +20 -6
  232. package/src/service/orchestration.ts +17 -0
  233. package/src/service/status-json.ts +116 -0
  234. package/src/types/config.ts +7 -0
  235. package/src/types/fallback.ts +38 -0
  236. package/src/types/provider.ts +2 -0
  237. package/src/types/request.ts +16 -1
  238. package/src/types.ts +1 -0
  239. package/src/web-search/loop.ts +2 -2
  240. package/gui/dist/assets/App-BnRSujOq.js +0 -58
  241. package/gui/dist/assets/App-dnkJ85te.css +0 -1
  242. package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
@@ -370,6 +370,9 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
370
370
  { method: "GET", path: "/api/provider-context-caps", module: "server/management/provider-routes", mutates: false },
371
371
  { method: "GET", path: "/api/provider-presets", module: "server/management/provider-routes", mutates: false },
372
372
  { method: "GET", path: "/api/provider-quotas", module: "server/management/provider-routes", mutates: false },
373
+ { method: "GET", path: "/api/quota-pool", module: "server/management/quota-pool-routes", mutates: false, exempt: { reason: "deferred-verb", why: "The ocx.quota-pool/1 read is a machine contract GOAT consumes over management and the link relay; the CLI shows the same caches through ocx provider quota and ocx account.", owner: "ocx-1 quota pool", ownerDoc: "structure/quota-pool.md" } },
374
+ { method: "GET", path: "/api/fallback", module: "server/management/fallback-routes", mutates: false },
375
+ { method: "PUT", path: "/api/fallback", module: "server/management/fallback-routes", mutates: true },
373
376
  { method: "GET", path: "/api/provider-request-pacing", module: "server/management/provider-key-pacing-routes", mutates: false },
374
377
  { method: "GET", path: "/api/providers", module: "server/management/provider-routes", mutates: false },
375
378
  { method: "PATCH", path: "/api/providers", module: "server/management/provider-routes", mutates: true },
@@ -161,6 +161,20 @@ async function handleLowQuotaRoutesOnDemand(ctx: ManagementContext): Promise<Res
161
161
  return handleLowQuotaRoutes(ctx);
162
162
  }
163
163
 
164
+ /** Lazy for the same reason: the quota-pool assembler pulls every quota cache reader. */
165
+ async function handleQuotaPoolRoutesOnDemand(ctx: ManagementContext): Promise<Response | null> {
166
+ if (!pathInManagementNamespace(ctx.url.pathname, "/api/quota-pool", false)) return null;
167
+ const { handleQuotaPoolRoutes } = await import("./management/quota-pool-routes");
168
+ return handleQuotaPoolRoutes(ctx);
169
+ }
170
+
171
+ /** Lazy: the OCX-11 fallback state view and its write live off the dashboard's common path. */
172
+ async function handleFallbackRoutesOnDemand(ctx: ManagementContext): Promise<Response | null> {
173
+ if (!pathInManagementNamespace(ctx.url.pathname, "/api/fallback", false)) return null;
174
+ const { handleFallbackRoutes } = await import("./management/fallback-routes");
175
+ return handleFallbackRoutes(ctx);
176
+ }
177
+
164
178
  /**
165
179
  * Lazy like the Lab and routing-profile handlers, and for the same recorded reason: this file is
166
180
  * mounted for every dashboard request, so a static import would put the workflow-budget ledger
@@ -351,6 +365,8 @@ export async function handleManagementAPI(
351
365
  ?? (await handleRequestHistoryRoutes(ctx))
352
366
  ?? (await handleQuotaResetRoutesOnDemand(ctx))
353
367
  ?? (await handleLowQuotaRoutesOnDemand(ctx))
368
+ ?? (await handleQuotaPoolRoutesOnDemand(ctx))
369
+ ?? (await handleFallbackRoutesOnDemand(ctx))
354
370
  ?? (await handleWorkflowBudgetRoutesOnDemand(ctx))
355
371
  ?? (await handleCodexAgentRoleRoutesOnDemand(ctx))
356
372
  ?? (await handleProtocolRoutesOnDemand(ctx))
@@ -73,6 +73,7 @@ import {
73
73
  } from "./gui-session";
74
74
  import { hasLocalDesktopSnapshotCapability } from "./local-desktop-snapshot-auth";
75
75
  import { hasLocalAccountSwitchCapability } from "./local-account-switch-auth";
76
+ import { authorizationCredential } from "../lib/authorization-scheme";
76
77
  export type { GuiSessionBootstrap, GuiSessionRequestContext } from "./gui-session";
77
78
 
78
79
  const LOCAL_READ_REPLAY_LIMIT = 256;
@@ -545,7 +546,7 @@ function hasGuiPairCapability(
545
546
 
546
547
  function requestManagementCredential(req: Request): string | null {
547
548
  return req.headers.get("x-opencodex-api-key")?.trim()
548
- || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim()
549
+ || authorizationCredential(req.headers)
549
550
  || null;
550
551
  }
551
552
 
@@ -849,6 +849,17 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
849
849
  }
850
850
  releaseRetainedRequest();
851
851
 
852
+ // The last pool account refused on spent quota and rotation found no replacement: answer the
853
+ // usage limit with its reset (#6991), as the bridge lane does, not the upstream "try again later".
854
+ const poolUsageLimit = response.status === 429 && oauthBinding
855
+ ? anthropicRoutingFor(nativeInstance!).anthropicPoolUsageLimitAfterRefusal(Date.now(), oauthBinding.routeDecision, route.modelId)
856
+ : null;
857
+ if (poolUsageLimit) {
858
+ discard(response);
859
+ cleanupAbort();
860
+ logCtx.errorCode = "usage_limit_reached";
861
+ return retainUpstreamMessagesRequestId(localOAuthFailure(poolUsageLimit)!, response.headers);
862
+ }
852
863
  if (!response.ok) {
853
864
  let bodyText = "";
854
865
  try {
@@ -1720,8 +1720,10 @@ export function noteProviderAttemptSend(
1720
1720
  if (attempt && attempt.sendCount > 0 && attemptAccountChanged(previous, next, attempt.provider, logCtx.provider)) {
1721
1721
  // An input estimate is not evidence that a failed send used that many tokens.
1722
1722
  delete attempt.inputTokenEstimate;
1723
+ // An account-scoped recovery kind names the status the sealed account answered.
1723
1724
  finishRequestAttempt(attempt, attempt.status >= 100 ? attempt.status
1724
- : recovery === "key-401" ? 401 : recovery?.includes("429") ? 429 : 502,
1725
+ : recovery === "key-401" || recovery === "oauth-401" ? 401 : recovery === "oauth-account-403" ? 403
1726
+ : recovery === "oauth-account-402" ? 402 : recovery?.includes("429") ? 429 : 502,
1725
1727
  Date.now() - (logCtx.activeAttemptStartedAt ?? Date.now()), attempt.usage);
1726
1728
  // This attempt is being sealed because a NAMED recovery rejected it, so the recovery kind
1727
1729
  // is direct evidence here rather than an inference from history. Without this the sealed
@@ -1850,16 +1852,20 @@ export function finishRequestAttempt(
1850
1852
  export function aggregateAttemptUsage(
1851
1853
  attempts: readonly PersistedUsageAttempt[],
1852
1854
  ): FinalizedUsageResult {
1853
- const status: UsageStatus = attempts.length > 0
1854
- && attempts.every(attempt => attempt.usageStatus === "unsupported")
1855
+ // A usage-less attempt that never reached upstream spent nothing (summary.ts `spentNothing`),
1856
+ // so it cannot leave the row's usage unknown.
1857
+ const measured = attempts.filter(attempt => attempt.usage
1858
+ || (attempt.sendCount !== 0 && attempt.locallyAnswered !== true));
1859
+ const status: UsageStatus = measured.length > 0
1860
+ && measured.every(attempt => attempt.usageStatus === "unsupported")
1855
1861
  ? "unsupported"
1856
- : attempts.some(attempt => (
1862
+ : measured.some(attempt => (
1857
1863
  attempt.usageStatus === "unreported" || attempt.usageStatus === "unsupported"
1858
1864
  ))
1859
1865
  ? "unreported"
1860
- : attempts.some(attempt => attempt.usageStatus === "estimated")
1866
+ : measured.some(attempt => attempt.usageStatus === "estimated")
1861
1867
  ? "estimated"
1862
- : attempts.length > 0
1868
+ : measured.length > 0
1863
1869
  ? "reported"
1864
1870
  : "unreported";
1865
1871
 
@@ -1876,7 +1882,10 @@ export function aggregateAttemptUsage(
1876
1882
  return present.length > 0 ? present.reduce((sum, value) => sum + value, 0) : undefined;
1877
1883
  };
1878
1884
  const cachedInputTokens = sumOptional("cachedInputTokens");
1879
- const cacheReadInputTokens = sumOptional("cacheReadInputTokens");
1885
+ // Readers take `cacheReadInputTokens ?? cachedInputTokens` (usage/cost.ts), so once any attempt
1886
+ // reports the read field, an attempt that only reports `cachedInputTokens` must count in it too.
1887
+ const cacheReadInputTokens = sumOptional("cacheReadInputTokens") === undefined ? undefined
1888
+ : usages.reduce((sum, usage) => sum + (usage.cacheReadInputTokens ?? usage.cachedInputTokens ?? 0), 0);
1880
1889
  const cacheCreationInputTokens = sumOptional("cacheCreationInputTokens");
1881
1890
  const cacheCreation1hInputTokens = sumOptional("cacheCreation1hInputTokens");
1882
1891
  const reasoningOutputTokens = sumOptional("reasoningOutputTokens");
@@ -28,7 +28,7 @@ import {
28
28
  fetchWithTransientRetry,
29
29
  fetchWithResetRetry,
30
30
  applyUpstreamRecoveryInit,
31
- isNonReplayableResponse,
31
+ isNonReplayableResponse, isSameTargetRetryHold,
32
32
  prepareSameTarget429Wait,
33
33
  SendBudgetExhaustedError,
34
34
  } from "../../lib/upstream-retry";
@@ -349,7 +349,7 @@ export function createAdapterContinuations(
349
349
  && !kiroReplaySetupFailed
350
350
  // A synthesized replay refusal is not a rate limit; replaying the continuation on
351
351
  // it would re-send a turn whose first send may already have been processed.
352
- && !isNonReplayableResponse(response)
352
+ && !isNonReplayableResponse(response) && !isSameTargetRetryHold(response)
353
353
  && rateLimitPolicy !== null
354
354
  && adapterExchange.rateLimitRetries < rateLimitPolicy.attempts
355
355
  // The main recovery loop and the passthrough ladder both consult the shared remainder
@@ -9,7 +9,7 @@ import { rotateAnthropicAccountOnResponseForInstance } from "../../oauth/anthrop
9
9
  import { authorizeResendForRecovery } from "../../lib/request-resend-gate";
10
10
  import { ambiguousResendAllowanceFor, selfContainedResponsesBody } from "./reset-replay";
11
11
  import { transientSendCapFor } from "./request-send-budget";
12
- import { isNonReplayableResponse } from "../../lib/upstream-retry";
12
+ import { isNonReplayableResponse, isSameTargetRetryHold } from "../../lib/upstream-retry";
13
13
  import { sanitizeNonReplayableUpstreamError } from "./non-replayable-error";
14
14
  import { isLocalUpstream } from "../../lib/local-upstream";
15
15
  import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options";
@@ -1239,7 +1239,7 @@ export async function prepareAdapterExchange(
1239
1239
  // replay is lossless. Runs before key failover so "primary-first" setups keep the same
1240
1240
  // key on rate-limit blips; only after the attempts are exhausted does failover run.
1241
1241
  while (
1242
- upstreamResponse.status === 429
1242
+ upstreamResponse.status === 429 && !isSameTargetRetryHold(upstreamResponse)
1243
1243
  && rateLimitPolicy !== null
1244
1244
  && rateLimitRetries < rateLimitPolicy.attempts
1245
1245
  && !sendBudgetExhausted()
@@ -104,7 +104,7 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
104
104
  const type = input[itemIndex] && typeof input[itemIndex] === "object"
105
105
  ? (input[itemIndex] as { type?: unknown }).type
106
106
  : undefined;
107
- if (type !== "compaction_trigger" && type !== "additional_tools") break;
107
+ if (type !== "compaction_trigger" && type !== "additional_tools" && type !== "configuration_update") break;
108
108
  itemIndex -= 1;
109
109
  }
110
110
 
@@ -458,6 +458,9 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
458
458
  return;
459
459
  }
460
460
  if (terminal || settledPreOpen || signal?.aborted) return;
461
+ // New sockets have just validated their upgrade; retained sockets carry the
462
+ // same connection signal into a fresh exchange, never its predecessor's frames.
463
+ metadata?.seed(session.upgradeHeaders());
461
464
  sent = true;
462
465
  try {
463
466
  if (nativeControl) {
@@ -6,11 +6,48 @@ export const CODEX_WS_METADATA_MAX_HEADERS = 128;
6
6
  export const CODEX_WS_METADATA_MAX_VALUE_BYTES = 4096;
7
7
 
8
8
  export type CodexWsQuotaObserver = (headers: Headers) => void;
9
+ const REASONING_INCLUDED_HEADER = "x-reasoning-included";
10
+ const HTTP_HEADER_TOKEN = /^[!#$%&'*+.^_`|~0-9a-z-]+$/i;
9
11
 
10
12
  function record(value: unknown): value is Record<string, unknown> {
11
13
  return value !== null && typeof value === "object" && !Array.isArray(value);
12
14
  }
13
15
 
16
+ /** Bun emits this payload before validating the upgrade; the session must wait for open. */
17
+ export function readCodexWsUpgradeHeaders(payload: unknown): Headers {
18
+ try {
19
+ if (!record(payload) || payload.statusCode !== 101 || !Array.isArray(payload.rawHeaders)
20
+ || payload.rawHeaders.length % 2 !== 0 || payload.rawHeaders.length > CODEX_WS_METADATA_MAX_HEADERS * 2) return new Headers();
21
+ const headers = new Headers();
22
+ let bytes = 0;
23
+ let connectionNominated = false;
24
+ for (let i = 0; i < payload.rawHeaders.length; i += 2) {
25
+ const name = payload.rawHeaders[i];
26
+ const value = payload.rawHeaders[i + 1];
27
+ if (typeof name !== "string" || typeof value !== "string"
28
+ || name.length + value.length > CODEX_WS_METADATA_MAX_BYTES - bytes || !HTTP_HEADER_TOKEN.test(name)
29
+ || /[\r\n\0]/.test(value)) return new Headers();
30
+ bytes += Buffer.byteLength(name) + Buffer.byteLength(value);
31
+ if (bytes > CODEX_WS_METADATA_MAX_BYTES) return new Headers();
32
+ const lower = name.toLowerCase();
33
+ if (lower === "connection" && value.split(",").some(token => token.trim().toLowerCase() === REASONING_INCLUDED_HEADER)) {
34
+ connectionNominated = true;
35
+ }
36
+ // Other allowlisted response headers (quota, request id, turn state, model)
37
+ // describe a response, not a reusable connection. Never retain them here.
38
+ if (lower === REASONING_INCLUDED_HEADER) {
39
+ if (Buffer.byteLength(value) > CODEX_WS_METADATA_MAX_VALUE_BYTES) return new Headers();
40
+ headers.append(lower, value);
41
+ if (Buffer.byteLength(headers.get(lower)!) > CODEX_WS_METADATA_MAX_VALUE_BYTES) return new Headers();
42
+ }
43
+ }
44
+ return connectionNominated ? new Headers() : headers;
45
+ } catch {
46
+ // Missing/unsupported or malformed optional metadata cannot authorize a retry.
47
+ return new Headers();
48
+ }
49
+ }
50
+
14
51
  function finiteNonnegative(value: unknown): value is number {
15
52
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
16
53
  }
@@ -91,6 +128,13 @@ export class CodexWsMetadata {
91
128
 
92
129
  snapshot(): Headers { return new Headers(this.headers); }
93
130
 
131
+ /** Seed only the connection-level inclusion signal; per-response frames still win. */
132
+ seed(headers: Headers): void {
133
+ if (this.ended || this.committed || this.headers.has(REASONING_INCLUDED_HEADER)) return;
134
+ const value = headers.get(REASONING_INCLUDED_HEADER);
135
+ if (value !== null) setMetadataHeader(this.headers, REASONING_INCLUDED_HEADER, value);
136
+ }
137
+
94
138
  private publishQuota(headers: Headers): void {
95
139
  // Observation is auxiliary bookkeeping; a consumer exception cannot turn
96
140
  // a valid provider frame into a retryable transport failure.
@@ -1,3 +1,5 @@
1
+ import { readCodexWsUpgradeHeaders } from "./codex-ws-metadata";
2
+
1
3
  export const MAX_CODEX_WS_SESSION_EXCHANGES = 32;
2
4
 
3
5
  /** Owns one physical socket; request listeners belong to the exchange, not this object. */
@@ -8,10 +10,13 @@ export class CodexWsSession {
8
10
  busy = false;
9
11
  private owner?: (reason: Error) => void;
10
12
  private readonly completedIds = new Set<string>();
13
+ private pendingUpgradeHeaders?: Headers;
14
+ private connectionHeaders?: Headers;
11
15
 
12
16
  constructor(url: string, headers: Record<string, string>, readonly retainable = false,
13
17
  private readonly changed: () => void = () => {}, proxy?: string) {
14
18
  this.socket = new WebSocket(url, { headers, ...(proxy ? { proxy } : {}) } as unknown as string[]);
19
+ this.socket.addEventListener("handshake", this.onHandshake);
15
20
  this.socket.addEventListener("open", this.onOpen);
16
21
  this.socket.addEventListener("message", this.onIdleMessage);
17
22
  this.socket.addEventListener("close", this.onClose);
@@ -20,6 +25,7 @@ export class CodexWsSession {
20
25
 
21
26
  get reused(): boolean { return this.completedIds.size > 0; }
22
27
  hasCompleted(id: string): boolean { return this.completedIds.has(id); }
28
+ upgradeHeaders(): Headers { return new Headers(this.opened && !this.closed ? this.connectionHeaders : undefined); }
23
29
 
24
30
  reserve(): boolean {
25
31
  if (this.closed || this.busy || (this.opened && this.socket.readyState !== undefined && this.socket.readyState !== 1)) return false;
@@ -71,7 +77,18 @@ export class CodexWsSession {
71
77
  }
72
78
  }
73
79
 
74
- private onOpen = (): void => { this.opened = true; };
80
+ private onHandshake = (event: Event): void => {
81
+ if (this.opened || this.closed) return;
82
+ this.pendingUpgradeHeaders = readCodexWsUpgradeHeaders((event as MessageEvent<unknown>).data);
83
+ };
84
+ private onOpen = (): void => {
85
+ if (this.closed) return;
86
+ // A 101 payload alone is not proof that Bun accepted the handshake.
87
+ this.connectionHeaders = this.pendingUpgradeHeaders;
88
+ this.pendingUpgradeHeaders = undefined;
89
+ this.socket.removeEventListener("handshake", this.onHandshake);
90
+ this.opened = true;
91
+ };
75
92
  private onIdleMessage = (): void => {
76
93
  if (!this.busy) this.dispose(new Error("codex websocket received unsolicited idle data"));
77
94
  };
@@ -85,6 +102,9 @@ export class CodexWsSession {
85
102
  // The active exchange's close listener retains pre-send fallback semantics.
86
103
  };
87
104
  private detach(): void {
105
+ this.pendingUpgradeHeaders = undefined;
106
+ this.connectionHeaders = undefined;
107
+ this.socket.removeEventListener("handshake", this.onHandshake);
88
108
  this.socket.removeEventListener("open", this.onOpen);
89
109
  this.socket.removeEventListener("message", this.onIdleMessage);
90
110
  this.socket.removeEventListener("close", this.onClose);
@@ -224,8 +224,13 @@ function failedTerminalResponse(
224
224
  : topLevelError && typeof topLevelError === "object" && !Array.isArray(topLevelError)
225
225
  ? topLevelError as Record<string, unknown>
226
226
  : {
227
- type: "upstream_error",
228
- code: "upstream_server_error",
227
+ // A caller-validated flat error can carry its identity beside type:error.
228
+ // Retain it when that caller established a 400, rather than discarding the
229
+ // rejection code before the single-shot opaque-state recovery sees it.
230
+ type: terminalPayload.type === "error" && logCtx.terminalHttpStatus === 400
231
+ ? "invalid_request_error" : "upstream_error",
232
+ code: terminalPayload.type === "error" && logCtx.terminalHttpStatus === 400
233
+ ? (typeof terminalPayload.code === "string" ? terminalPayload.code : null) : "upstream_server_error",
229
234
  message: typeof terminalPayload.message === "string"
230
235
  ? terminalPayload.message
231
236
  : logCtx.upstreamError ?? "Provider stream failed before producing output",
@@ -88,6 +88,7 @@ import {
88
88
  recordCodexUpstreamOutcome,
89
89
  type CodexUpstreamOutcome,
90
90
  } from "../../codex/routing";
91
+ import { codexFailureWindowOutcomeMeta } from "../../codex/routing/failure-window";
91
92
  import {
92
93
  applyAccountChangeConversationStateScrub,
93
94
  conversationStateBindingFromAuth,
@@ -134,7 +135,7 @@ import type { DataPlaneAdmission } from "../auth-cors";
134
135
  import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
135
136
  import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers";
136
137
  import { buildResponsesTransport, copyResponsesClientMetadata } from "../../adapters/openai-responses/transport";
137
- import { scrubOcxCompactionItems, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough } from "../../adapters/openai-responses/request-strips";
138
+ import { scrubOcxCompactionItems, stripCanonicalOnlyInputItems, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough } from "../../adapters/openai-responses/request-strips";
138
139
  import { dropResponsesReasoningInputItems } from "../../adapters/openai-responses/reasoning";
139
140
  import { requiresPlaintextReasoningReplay } from "../../adapters/openai-responses/passthrough";
140
141
  import { destinationDecodesNativeCompactionBlob, isOpenAiOperatedResponsesDestination } from "../../providers/openai-tiers-destination";
@@ -1062,6 +1063,7 @@ export async function handleResponsesCompact(
1062
1063
  if ((outcome === 401 || outcome === 403) && upstream && wasPermanentAccountRefusal(upstream)) return;
1063
1064
  recordCodexUpstreamOutcome(config, ctx.accountId, outcome, {
1064
1065
  ...meta,
1066
+ ...codexFailureWindowOutcomeMeta(ctx),
1065
1067
  threadId: ctx.kind === "pool" || ctx.kind === "main-pool" ? ctx.affinityKey : undefined,
1066
1068
  fixedAccount: ctx.fixedAccount,
1067
1069
  modelId: selectedModelId,
@@ -1153,7 +1155,7 @@ export async function handleResponsesCompact(
1153
1155
  replayBody = stripInternalChatMessageMetadataPassthrough(replayBody) as typeof replayBody;
1154
1156
  }
1155
1157
  return isOpenAiOperatedResponsesDestination(sendProvider)
1156
- ? replayBody : stripCanonicalOnlyTopLevelFields(replayBody);
1158
+ ? replayBody : stripCanonicalOnlyInputItems(stripCanonicalOnlyTopLevelFields(replayBody));
1157
1159
  };
1158
1160
  const doFetch = (upstreamRecovery?: UpstreamSendRecovery) => fetchWithHeaderTimeout(
1159
1161
  compactUrl,
@@ -2,6 +2,7 @@ import type { OcxProviderConfig, OcxConfig } from "../../types";
2
2
  import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
3
3
  import type { CodexAuthContext } from "../../codex/auth-context";
4
4
  import type { RouteResult } from "../../router";
5
+ import { routeConcreteModel } from "../../router";
5
6
  import type { HandleResponsesOptions } from "./core-options";
6
7
  import {
7
8
  hasForwardableCodexBearer,
@@ -24,6 +25,7 @@ import {
24
25
  CodexAuthContextError,
25
26
  applyCodexAuthContextToProvider,
26
27
  stripCodexRuntimeProviderFields,
28
+ mainAccountHardLockCallerProof,
27
29
  } from "../../codex/auth-context";
28
30
  import { codexAccountSelectionForTurn, tryClaimNativeMainProfileForTurn } from "../lifecycle";
29
31
  import { isNativeMainTrafficBlocked } from "../../codex/native-profile-startup";
@@ -44,6 +46,7 @@ import { forceRefreshMainAccountToken } from "../../codex/main-account";
44
46
  import { rebindPoolCreditPolicy } from "../../codex/pool-credit-policy";
45
47
  import { safeCallerSessionId } from "../caller-session-identity";
46
48
  import { copyResponsesClientMetadata } from "../../adapters/openai-responses/transport";
49
+ import { AgentAssertionUnsupportedError, agentAssertionUnsupportedResponse, authorizationCredential, carryAgentAssertionForCallerBearer, hasAgentAssertionAuthorization } from "../../lib/authorization-scheme";
47
50
 
48
51
  /** Normalize caller aliases only on native egress; preserve ingress and fallback identity. */
49
52
  export function withClaudeNativeSession(headers: Headers, provider: OcxProviderConfig, sessionId?: string): Headers {
@@ -203,6 +206,9 @@ export async function resolveResponsesCodexAuth(
203
206
  retainAccountForUploadedFiles = false,
204
207
  ): Promise<ResponsesAuthResolution> {
205
208
  let authCtx: CodexAuthContext | undefined;
209
+ // Ingress already refuses assertion_only; a later internal hop must not reach a pool or
210
+ // routed credential with it either, because that would be a silent credential swap.
211
+ if (hasAgentAssertionAuthorization(req.headers)) return { ok: false, response: agentAssertionUnsupportedResponse() };
206
212
  try {
207
213
  let authInputHeaders = codexRouteCredentialDomainHeaders(
208
214
  req,
@@ -330,7 +336,7 @@ export async function resolveResponsesCodexAuth(
330
336
  } else {
331
337
  // Even adapters that ignore caller auth must not retain a proxy secret for
332
338
  // a later internal hop or a future transport change.
333
- const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
339
+ const bearer = authorizationCredential(headers);
334
340
  if (bearer && isProxyAdmissionSecret(bearer, config)) {
335
341
  headers.delete("authorization");
336
342
  headers.delete("chatgpt-account-id");
@@ -341,6 +347,11 @@ export async function resolveResponsesCodexAuth(
341
347
  if (options.compactionRecoveryKind === "compaction-v1") {
342
348
  copyResponsesClientMetadata(headers, authInputHeaders, route.provider);
343
349
  }
350
+ // dual_header: the assertion is first-party only and travels only beside the caller's own
351
+ // unchanged Bearer, compared with the ORIGINAL request (injected credentials do not count).
352
+ if (isCanonicalOpenAiForwardProvider(route.provider) && authCtx?.kind === "main" && !substituteMainCredential) {
353
+ carryAgentAssertionForCallerBearer(headers, req.headers);
354
+ }
344
355
  return {
345
356
  ok: true,
346
357
  authCtx,
@@ -362,6 +373,7 @@ export async function resolveResponsesCodexAuth(
362
373
  if (err instanceof ForwardAdmissionCredentialError) {
363
374
  return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) };
364
375
  }
376
+ if (err instanceof AgentAssertionUnsupportedError) return { ok: false, response: agentAssertionUnsupportedResponse() };
365
377
  const response = mapCodexAuthContextErrorToResponse(err, {
366
378
  accountSelector: route.codexAccountNamespace,
367
379
  now: Date.now(),
@@ -590,3 +602,21 @@ export async function refreshNativeMainForwardAuth(args: {
590
602
  }) ?? nativeMainRefreshFailureResponse(error) };
591
603
  }
592
604
  }
605
+
606
+ /** Inspect the original credential domain, before fallback changes the route or drops headers. */
607
+ export function mainHardLockFallbackCallerCheck(
608
+ req: Request, config: OcxConfig, options: HandleResponsesOptions,
609
+ ): (sourceModel: string) => Promise<(() => boolean) | null> {
610
+ return async sourceModel => {
611
+ let sourceRoute: ReturnType<typeof routeConcreteModel>;
612
+ try { sourceRoute = routeConcreteModel(config, sourceModel); }
613
+ // This preview cannot replace the normal routing phase's existing error response.
614
+ catch { return null; }
615
+ const headers = codexRouteCredentialDomainHeaders(req, sourceRoute, options, false, config);
616
+ const { substituteMainCredential } = codexRouteCredentialOwnership(headers, config, sourceRoute, options);
617
+ return mainAccountHardLockCallerProof(headers, options.codexAuthPolicy ?? config, {
618
+ substituteMainCredentialForDirect: substituteMainCredential,
619
+ signal: options.abortSignal ?? req.signal,
620
+ });
621
+ };
622
+ }
@@ -11,6 +11,7 @@ import { claimMainQuotaDispatchForWs, isMainQuotaDispatchLive, renewMainQuotaDis
11
11
  import type { CodexWsQuotaObserver } from "./codex-ws-metadata";
12
12
  import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
13
13
  import { isCodexAccountGenerationLive } from "../../codex/account-store";
14
+ import { codexFailureWindowOutcomeMeta } from "../../codex/routing/failure-window";
14
15
  import { applyAccountQuotaFromUpstreamHeaders as applyCapturedCodexQuota } from "../../codex/quota";
15
16
  import type { RouteResult } from "../../router";
16
17
  import {
@@ -93,20 +94,19 @@ export function sidecarOutcomeRecorder(
93
94
  config: OcxConfig,
94
95
  authCtx: CodexAuthContext,
95
96
  ): ((outcome: CodexUpstreamOutcome) => void) | undefined {
96
- return authCtx.kind === "pool" || authCtx.kind === "main-pool"
97
- ? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
98
- threadId: authCtx.affinityKey,
99
- fixedAccount: authCtx.fixedAccount,
100
- probeLeaseId: authCtx.probeLeaseId,
101
- probeQuotaScope: authCtx.probeQuotaScope,
102
- writerGeneration: authCtx.writerGeneration,
103
- // A vision or web-search sidecar can return 401/403, and that is evidence about the exact
104
- // stored credential it used. Without the generation it becomes an account-wide quarantine
105
- // that a replacement inherits (#2892 gap 4). `main-pool` has no stored-record generation, so
106
- // it keeps the unfenced account-wide semantics.
107
- ...(authCtx.kind === "pool" ? { credentialGeneration: authCtx.generation } : {}),
108
- })
109
- : undefined;
97
+ if (authCtx.kind !== "pool" && authCtx.kind !== "main-pool") return undefined;
98
+ const windowMeta = codexFailureWindowOutcomeMeta(authCtx);
99
+ return outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
100
+ ...windowMeta,
101
+ threadId: authCtx.affinityKey,
102
+ fixedAccount: authCtx.fixedAccount,
103
+ probeLeaseId: authCtx.probeLeaseId,
104
+ probeQuotaScope: authCtx.probeQuotaScope,
105
+ writerGeneration: authCtx.writerGeneration,
106
+ // Quarantine retains its stored-credential fence. main-pool has no stored-record
107
+ // generation; its separate dispatch proof above fences only failure-window samples.
108
+ ...(authCtx.kind === "pool" ? { credentialGeneration: authCtx.generation } : {}),
109
+ });
110
110
  }
111
111
 
112
112
 
@@ -148,6 +148,7 @@ export function recordCodexPreOutputRefusalOutcome(
148
148
  if (!usesCodexForwardPoolAuth(authCtx, provider) || wasPermanentAccountRefusal(response)) return;
149
149
  noteCodexWsPreOutputRefusalOutcome(response);
150
150
  recordCodexUpstreamOutcome(config, authCtx.accountId, response.status, {
151
+ ...codexFailureWindowOutcomeMeta(authCtx),
151
152
  threadId: authCtx.affinityKey,
152
153
  fixedAccount: authCtx.fixedAccount,
153
154
  modelId,
@@ -676,6 +677,7 @@ export async function retryCodexPoolOnAlternateAccount(
676
677
  if (codexWsPreOutputRefusalOutcomeRecorded(firstResponse)) return;
677
678
  noteCodexWsPreOutputRefusalOutcome(firstResponse);
678
679
  recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
680
+ ...codexFailureWindowOutcomeMeta(firstAuthCtx),
679
681
  threadId: firstAuthCtx.affinityKey,
680
682
  fixedAccount: firstAuthCtx.fixedAccount,
681
683
  modelId: route.modelId,
@@ -693,6 +695,7 @@ export async function retryCodexPoolOnAlternateAccount(
693
695
  if (outcomeStatus === firstResponse.status || (outcomeStatus !== 429 && outcomeStatus !== 402)) return;
694
696
  recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
695
697
  ...codexQuotaOutcomeMeta(firstResponse),
698
+ ...codexFailureWindowOutcomeMeta(firstAuthCtx),
696
699
  threadId: firstAuthCtx.affinityKey,
697
700
  modelId: route.modelId,
698
701
  probeLeaseId: codexProbeLeaseId(firstAuthCtx),
@@ -862,6 +865,7 @@ export async function retryCodexPoolOnAlternateAccount(
862
865
  }
863
866
  recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
864
867
  ...quotaMeta,
868
+ ...codexFailureWindowOutcomeMeta(firstAuthCtx),
865
869
  threadId: firstAuthCtx.affinityKey,
866
870
  modelId: route.modelId,
867
871
  probeLeaseId: codexProbeLeaseId(firstAuthCtx),
@@ -1149,6 +1153,7 @@ export function codexForwardTerminalOutcomeRecorder(
1149
1153
  logCtx?: RequestLogContext,
1150
1154
  ): ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined {
1151
1155
  if (!usesCodexForwardPoolAuth(authCtx, provider)) return undefined;
1156
+ const windowMeta = codexFailureWindowOutcomeMeta(authCtx);
1152
1157
  return (status, httpStatusOverride) => {
1153
1158
  const quotaStatus = [httpStatusOverride, logCtx?.terminalHttpStatus]
1154
1159
  .find(value => value === 429 || value === 402);
@@ -1157,6 +1162,7 @@ export function codexForwardTerminalOutcomeRecorder(
1157
1162
  // request. Don't penalize account health; record success to clear any
1158
1163
  // prior soft-avoid so a healthy account isn't stuck avoided.
1159
1164
  recordCodexUpstreamOutcome(config, authCtx.accountId, 200, {
1165
+ ...windowMeta,
1160
1166
  threadId: authCtx.affinityKey,
1161
1167
  fixedAccount: authCtx.fixedAccount,
1162
1168
  modelId,
@@ -1180,6 +1186,7 @@ export function codexForwardTerminalOutcomeRecorder(
1180
1186
  ? 200
1181
1187
  : (quotaStatus ?? httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502);
1182
1188
  recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
1189
+ ...windowMeta,
1183
1190
  threadId: authCtx.affinityKey,
1184
1191
  fixedAccount: authCtx.fixedAccount,
1185
1192
  modelId,