@iislee/opencodex 2.11.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 (476) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +225 -0
  3. package/assets/architecture.png +0 -0
  4. package/assets/banner.png +0 -0
  5. package/assets/claude-code-models.gif +0 -0
  6. package/assets/codex-app-picker.png +0 -0
  7. package/bin/ocx.mjs +451 -0
  8. package/bin/package-main.mjs +9 -0
  9. package/gui/dist/assets/index-DTpMHS4F.js +67 -0
  10. package/gui/dist/assets/index-ZNVDE3C7.css +1 -0
  11. package/gui/dist/favicon.png +0 -0
  12. package/gui/dist/icons.svg +24 -0
  13. package/gui/dist/index.html +25 -0
  14. package/gui/dist/logo.png +0 -0
  15. package/gui/dist/provider-icons/alibaba-color.svg +1 -0
  16. package/gui/dist/provider-icons/antigravity-color.svg +1 -0
  17. package/gui/dist/provider-icons/antigravity.svg +1 -0
  18. package/gui/dist/provider-icons/claude-color.svg +1 -0
  19. package/gui/dist/provider-icons/claude.svg +1 -0
  20. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -0
  21. package/gui/dist/provider-icons/copilot-color.svg +1 -0
  22. package/gui/dist/provider-icons/copilot.svg +1 -0
  23. package/gui/dist/provider-icons/cursor-color.svg +2 -0
  24. package/gui/dist/provider-icons/cursor.svg +2 -0
  25. package/gui/dist/provider-icons/deepseek-color.svg +1 -0
  26. package/gui/dist/provider-icons/discord.svg +1 -0
  27. package/gui/dist/provider-icons/firepass-color.svg +1 -0
  28. package/gui/dist/provider-icons/fireworks-color.svg +1 -0
  29. package/gui/dist/provider-icons/gemini-color.svg +1 -0
  30. package/gui/dist/provider-icons/gemini.svg +1 -0
  31. package/gui/dist/provider-icons/github-copilot-color.svg +1 -0
  32. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -0
  33. package/gui/dist/provider-icons/grok-color.svg +1 -0
  34. package/gui/dist/provider-icons/grok.svg +1 -0
  35. package/gui/dist/provider-icons/groq-color.svg +1 -0
  36. package/gui/dist/provider-icons/huggingface-color.svg +1 -0
  37. package/gui/dist/provider-icons/kimi-color.svg +1 -0
  38. package/gui/dist/provider-icons/kiro-color.svg +15 -0
  39. package/gui/dist/provider-icons/kiro.svg +14 -0
  40. package/gui/dist/provider-icons/lm-studio-color.svg +1 -0
  41. package/gui/dist/provider-icons/mistral-color.svg +1 -0
  42. package/gui/dist/provider-icons/moonshot-color.svg +1 -0
  43. package/gui/dist/provider-icons/nvidia-color.svg +1 -0
  44. package/gui/dist/provider-icons/ollama-color.svg +1 -0
  45. package/gui/dist/provider-icons/openai.svg +1 -0
  46. package/gui/dist/provider-icons/opencode.svg +1 -0
  47. package/gui/dist/provider-icons/openrouter-color.svg +1 -0
  48. package/gui/dist/provider-icons/pi.svg +21 -0
  49. package/gui/dist/provider-icons/qianfan-color.svg +1 -0
  50. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -0
  51. package/gui/dist/provider-icons/telegram.svg +1 -0
  52. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -0
  53. package/gui/dist/provider-icons/vllm-color.svg +1 -0
  54. package/gui/dist/provider-icons/xiaomi-color.svg +1 -0
  55. package/package.json +102 -0
  56. package/src/AGENTS.md +28 -0
  57. package/src/adapters/anthropic-image-guard.ts +251 -0
  58. package/src/adapters/anthropic-image-normalize.ts +518 -0
  59. package/src/adapters/anthropic.ts +1003 -0
  60. package/src/adapters/azure.ts +36 -0
  61. package/src/adapters/base.ts +72 -0
  62. package/src/adapters/client-fingerprint.ts +59 -0
  63. package/src/adapters/cursor/arg-codec.ts +38 -0
  64. package/src/adapters/cursor/arg-normalize.ts +104 -0
  65. package/src/adapters/cursor/cursor-errors.ts +165 -0
  66. package/src/adapters/cursor/discovery.ts +276 -0
  67. package/src/adapters/cursor/effort-map.ts +127 -0
  68. package/src/adapters/cursor/exec-policy.ts +88 -0
  69. package/src/adapters/cursor/framing.ts +211 -0
  70. package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
  71. package/src/adapters/cursor/kv-store.ts +52 -0
  72. package/src/adapters/cursor/live-models.ts +153 -0
  73. package/src/adapters/cursor/live-smoke-gate.ts +41 -0
  74. package/src/adapters/cursor/live-transport.ts +1214 -0
  75. package/src/adapters/cursor/mcp-config.ts +42 -0
  76. package/src/adapters/cursor/mcp-manager.ts +333 -0
  77. package/src/adapters/cursor/message-mapper.ts +49 -0
  78. package/src/adapters/cursor/native-exec-common.ts +55 -0
  79. package/src/adapters/cursor/native-exec-desktop.ts +184 -0
  80. package/src/adapters/cursor/native-exec-fs.ts +329 -0
  81. package/src/adapters/cursor/native-exec-mcp.ts +153 -0
  82. package/src/adapters/cursor/native-exec-network.ts +43 -0
  83. package/src/adapters/cursor/native-exec-shell.ts +548 -0
  84. package/src/adapters/cursor/native-exec-tools.ts +118 -0
  85. package/src/adapters/cursor/native-exec.ts +576 -0
  86. package/src/adapters/cursor/protobuf-events.ts +563 -0
  87. package/src/adapters/cursor/protobuf-request.ts +714 -0
  88. package/src/adapters/cursor/request-builder.ts +255 -0
  89. package/src/adapters/cursor/thread-continuity.ts +67 -0
  90. package/src/adapters/cursor/tool-definitions.ts +505 -0
  91. package/src/adapters/cursor/transport-retry.ts +132 -0
  92. package/src/adapters/cursor/transport.ts +57 -0
  93. package/src/adapters/cursor/types.ts +52 -0
  94. package/src/adapters/cursor.ts +196 -0
  95. package/src/adapters/google-antigravity-replay.ts +303 -0
  96. package/src/adapters/google-antigravity-wire.ts +108 -0
  97. package/src/adapters/google-errors.ts +85 -0
  98. package/src/adapters/google-http.ts +100 -0
  99. package/src/adapters/google-tool-schema.ts +173 -0
  100. package/src/adapters/google-truncation.ts +13 -0
  101. package/src/adapters/google-wire-compiler.ts +232 -0
  102. package/src/adapters/google.ts +758 -0
  103. package/src/adapters/identity.ts +44 -0
  104. package/src/adapters/image.ts +23 -0
  105. package/src/adapters/kiro-constants.ts +16 -0
  106. package/src/adapters/kiro-errors.ts +197 -0
  107. package/src/adapters/kiro-events.ts +179 -0
  108. package/src/adapters/kiro-images.ts +129 -0
  109. package/src/adapters/kiro-retry.ts +312 -0
  110. package/src/adapters/kiro-thinking.ts +96 -0
  111. package/src/adapters/kiro-tool-fallback.ts +36 -0
  112. package/src/adapters/kiro-tools.ts +215 -0
  113. package/src/adapters/kiro-truncation.ts +33 -0
  114. package/src/adapters/kiro-wire.ts +129 -0
  115. package/src/adapters/kiro.ts +1898 -0
  116. package/src/adapters/mimo-free.ts +263 -0
  117. package/src/adapters/openai-chat.ts +1005 -0
  118. package/src/adapters/openai-responses.ts +1137 -0
  119. package/src/adapters/run-turn-queue.ts +114 -0
  120. package/src/adapters/tool-catalog-nudge.ts +71 -0
  121. package/src/adapters/upstream-http-error.ts +48 -0
  122. package/src/bridge.ts +1619 -0
  123. package/src/chat/inbound.ts +295 -0
  124. package/src/chat/outbound.ts +765 -0
  125. package/src/claude/agents-inject.ts +243 -0
  126. package/src/claude/alias.ts +149 -0
  127. package/src/claude/auth-detect.ts +229 -0
  128. package/src/claude/auth-mode-migration.ts +32 -0
  129. package/src/claude/auth-mode.ts +62 -0
  130. package/src/claude/context-windows.ts +189 -0
  131. package/src/claude/desktop-3p-guard.ts +35 -0
  132. package/src/claude/desktop-3p-paths.ts +84 -0
  133. package/src/claude/desktop-3p.ts +381 -0
  134. package/src/claude/desktop-health.ts +26 -0
  135. package/src/claude/desktop-profile.ts +263 -0
  136. package/src/claude/gateway-cache.ts +70 -0
  137. package/src/claude/inbound-debug.ts +163 -0
  138. package/src/claude/inbound.ts +509 -0
  139. package/src/claude/model-info.ts +151 -0
  140. package/src/claude/outbound.ts +872 -0
  141. package/src/cli/access.ts +108 -0
  142. package/src/cli/account-api.ts +268 -0
  143. package/src/cli/account-auth.ts +223 -0
  144. package/src/cli/account-extended.ts +350 -0
  145. package/src/cli/account.ts +275 -0
  146. package/src/cli/agent-driven.ts +70 -0
  147. package/src/cli/agent.ts +184 -0
  148. package/src/cli/catalog-prewarm.ts +27 -0
  149. package/src/cli/claude-desktop.ts +188 -0
  150. package/src/cli/claude.ts +286 -0
  151. package/src/cli/codex-shim-autorestore.ts +45 -0
  152. package/src/cli/combo.ts +119 -0
  153. package/src/cli/config-command.ts +145 -0
  154. package/src/cli/debug.ts +228 -0
  155. package/src/cli/doctor.ts +930 -0
  156. package/src/cli/export-command.ts +187 -0
  157. package/src/cli/help.ts +354 -0
  158. package/src/cli/index.ts +1113 -0
  159. package/src/cli/init.ts +224 -0
  160. package/src/cli/integrations.ts +142 -0
  161. package/src/cli/interactive-confirm.ts +133 -0
  162. package/src/cli/internal-dispatch.ts +20 -0
  163. package/src/cli/models-runtime.ts +212 -0
  164. package/src/cli/models.ts +336 -0
  165. package/src/cli/observe.ts +117 -0
  166. package/src/cli/opencode.ts +586 -0
  167. package/src/cli/pi.ts +188 -0
  168. package/src/cli/provider-runtime.ts +162 -0
  169. package/src/cli/provider.ts +463 -0
  170. package/src/cli/runtime-api.ts +325 -0
  171. package/src/cli/star-prompt.ts +155 -0
  172. package/src/cli/status-oauth.ts +78 -0
  173. package/src/cli/status.ts +321 -0
  174. package/src/cli/sync-cloud.ts +283 -0
  175. package/src/cli/system-command.ts +112 -0
  176. package/src/cli/tray-proxy.ts +52 -0
  177. package/src/cli/v2.ts +173 -0
  178. package/src/cli.ts +10 -0
  179. package/src/clients/config-export.ts +377 -0
  180. package/src/clients/effective-status.ts +385 -0
  181. package/src/clients/probes/agy.ts +55 -0
  182. package/src/clients/probes/cc-switch.ts +110 -0
  183. package/src/clients/probes/claude.ts +90 -0
  184. package/src/clients/probes/codex.ts +125 -0
  185. package/src/clients/probes/grok.ts +29 -0
  186. package/src/clients/probes/opencode.ts +109 -0
  187. package/src/clients/probes/paseo.ts +55 -0
  188. package/src/clients/probes/pi.ts +55 -0
  189. package/src/cloud/onedrive-auth.ts +666 -0
  190. package/src/cloud/onedrive-graph.ts +108 -0
  191. package/src/cloud/settings.ts +75 -0
  192. package/src/cloud/sync.ts +212 -0
  193. package/src/cloud/types.ts +56 -0
  194. package/src/cloud/vault.ts +89 -0
  195. package/src/codex/account-id.ts +34 -0
  196. package/src/codex/account-label.ts +34 -0
  197. package/src/codex/account-lifecycle.ts +55 -0
  198. package/src/codex/account-namespace-match.ts +63 -0
  199. package/src/codex/account-namespaces.ts +149 -0
  200. package/src/codex/account-pause.ts +20 -0
  201. package/src/codex/account-runtime-state.ts +31 -0
  202. package/src/codex/account-store.ts +517 -0
  203. package/src/codex/account-usability.ts +20 -0
  204. package/src/codex/app-server-processes.ts +756 -0
  205. package/src/codex/auth-api.ts +1540 -0
  206. package/src/codex/auth-collision.ts +107 -0
  207. package/src/codex/auth-context.ts +352 -0
  208. package/src/codex/autostart-health.ts +149 -0
  209. package/src/codex/catalog/aggregation.ts +378 -0
  210. package/src/codex/catalog/bundled.ts +251 -0
  211. package/src/codex/catalog/effort.ts +355 -0
  212. package/src/codex/catalog/metadata.ts +180 -0
  213. package/src/codex/catalog/parsing.ts +456 -0
  214. package/src/codex/catalog/provider-fetch.ts +902 -0
  215. package/src/codex/catalog/sync.ts +620 -0
  216. package/src/codex/catalog.ts +12 -0
  217. package/src/codex/data/upstream-models.json +830 -0
  218. package/src/codex/exec-invocation.ts +22 -0
  219. package/src/codex/features.ts +969 -0
  220. package/src/codex/history-migration-guardian.ts +102 -0
  221. package/src/codex/history-provider.ts +776 -0
  222. package/src/codex/home.ts +206 -0
  223. package/src/codex/inject.ts +799 -0
  224. package/src/codex/injected-marker.ts +72 -0
  225. package/src/codex/journal.ts +163 -0
  226. package/src/codex/main-account-cache.ts +32 -0
  227. package/src/codex/main-account.ts +40 -0
  228. package/src/codex/model-cache.ts +227 -0
  229. package/src/codex/paths.ts +65 -0
  230. package/src/codex/plugins-doctor.ts +242 -0
  231. package/src/codex/pool-rotation.ts +225 -0
  232. package/src/codex/project-config-warnings.ts +411 -0
  233. package/src/codex/quota.ts +411 -0
  234. package/src/codex/refresh.ts +53 -0
  235. package/src/codex/routing.ts +1477 -0
  236. package/src/codex/runtime.ts +538 -0
  237. package/src/codex/shim.ts +1189 -0
  238. package/src/codex/subagent-defaults.ts +550 -0
  239. package/src/codex/subagent-model-fallback.ts +469 -0
  240. package/src/codex/sync.ts +130 -0
  241. package/src/codex/warmup.ts +192 -0
  242. package/src/codex/websocket-registry.ts +100 -0
  243. package/src/combos/failover.ts +140 -0
  244. package/src/combos/index.ts +41 -0
  245. package/src/combos/request.ts +62 -0
  246. package/src/combos/resolve.ts +232 -0
  247. package/src/combos/types.ts +326 -0
  248. package/src/config.ts +2356 -0
  249. package/src/generated/jawcode-model-metadata.ts +104 -0
  250. package/src/github/star-state.ts +203 -0
  251. package/src/grok/inject.ts +545 -0
  252. package/src/grok/status.ts +121 -0
  253. package/src/grok/sync.ts +103 -0
  254. package/src/grok/usage-hook/report.mjs +348 -0
  255. package/src/grok/usage-hook.ts +278 -0
  256. package/src/images/artifacts.ts +516 -0
  257. package/src/images/fulfill-video.ts +163 -0
  258. package/src/images/fulfill.ts +149 -0
  259. package/src/images/index.ts +4 -0
  260. package/src/images/loop.ts +829 -0
  261. package/src/images/plan.ts +133 -0
  262. package/src/images/synthetic-tool.ts +133 -0
  263. package/src/images/types.ts +41 -0
  264. package/src/images/xai-client.ts +141 -0
  265. package/src/images/xai-video-client.ts +163 -0
  266. package/src/index.ts +22 -0
  267. package/src/lib/abort.ts +146 -0
  268. package/src/lib/admin-secrets.ts +25 -0
  269. package/src/lib/admission.ts +83 -0
  270. package/src/lib/app-owned-memory-stores.ts +173 -0
  271. package/src/lib/app-owned-memory.ts +265 -0
  272. package/src/lib/bounded-body.ts +202 -0
  273. package/src/lib/bun-binary-validator.d.mts +3 -0
  274. package/src/lib/bun-binary-validator.mjs +18 -0
  275. package/src/lib/bun-runtime.ts +71 -0
  276. package/src/lib/bun-stream-caps.ts +126 -0
  277. package/src/lib/config-ownership.ts +360 -0
  278. package/src/lib/crash-guard.ts +344 -0
  279. package/src/lib/debug-log-buffer.ts +83 -0
  280. package/src/lib/debug-settings.ts +108 -0
  281. package/src/lib/debug.ts +31 -0
  282. package/src/lib/destination-policy.ts +316 -0
  283. package/src/lib/errors.ts +364 -0
  284. package/src/lib/eventstream-decoder.ts +253 -0
  285. package/src/lib/gcp-adc.ts +341 -0
  286. package/src/lib/injection-debug-log.ts +58 -0
  287. package/src/lib/open-url.ts +25 -0
  288. package/src/lib/pinned-http.ts +151 -0
  289. package/src/lib/privacy.ts +20 -0
  290. package/src/lib/process-control.ts +165 -0
  291. package/src/lib/provider-outbound.ts +170 -0
  292. package/src/lib/provider-url.ts +14 -0
  293. package/src/lib/proxy-env.ts +18 -0
  294. package/src/lib/redact.ts +105 -0
  295. package/src/lib/retry-after.ts +55 -0
  296. package/src/lib/service-secrets.ts +25 -0
  297. package/src/lib/shadow-call.ts +30 -0
  298. package/src/lib/sidecar-tracker.ts +52 -0
  299. package/src/lib/sse-decoder.ts +323 -0
  300. package/src/lib/state-store-registrations.ts +109 -0
  301. package/src/lib/state-store-sweeper.ts +184 -0
  302. package/src/lib/test-home-guard.ts +90 -0
  303. package/src/lib/token-estimate.ts +69 -0
  304. package/src/lib/translator-budget.ts +356 -0
  305. package/src/lib/upstream-retry.ts +239 -0
  306. package/src/lib/win-exec.ts +115 -0
  307. package/src/lib/win-paths.ts +68 -0
  308. package/src/lib/windows-elevation.ts +705 -0
  309. package/src/lib/windows-secret-acl.ts +514 -0
  310. package/src/lib/winsw.ts +375 -0
  311. package/src/oauth/anthropic-routing.ts +594 -0
  312. package/src/oauth/anthropic.ts +177 -0
  313. package/src/oauth/callback-server.ts +294 -0
  314. package/src/oauth/chatgpt.ts +150 -0
  315. package/src/oauth/cursor.ts +211 -0
  316. package/src/oauth/github-copilot.ts +428 -0
  317. package/src/oauth/google-antigravity.ts +230 -0
  318. package/src/oauth/health.ts +399 -0
  319. package/src/oauth/index.ts +1174 -0
  320. package/src/oauth/key-providers.ts +108 -0
  321. package/src/oauth/kimi.ts +213 -0
  322. package/src/oauth/kiro-credentials.ts +726 -0
  323. package/src/oauth/kiro.ts +577 -0
  324. package/src/oauth/local-token-detect.ts +121 -0
  325. package/src/oauth/log.ts +48 -0
  326. package/src/oauth/login-cli.ts +163 -0
  327. package/src/oauth/pkce.ts +15 -0
  328. package/src/oauth/store.ts +630 -0
  329. package/src/oauth/token-guardian.ts +303 -0
  330. package/src/oauth/types.ts +62 -0
  331. package/src/oauth/xai.ts +241 -0
  332. package/src/pi/extensions.ts +72 -0
  333. package/src/pi/home.ts +42 -0
  334. package/src/pi/index.ts +40 -0
  335. package/src/pi/models.ts +278 -0
  336. package/src/pi/packages.ts +219 -0
  337. package/src/pi/settings.ts +365 -0
  338. package/src/pi/status.ts +68 -0
  339. package/src/pi/sync.ts +75 -0
  340. package/src/providers/alibaba-region-backup.ts +75 -0
  341. package/src/providers/alibaba-region-migration.ts +156 -0
  342. package/src/providers/alibaba-region-startup.ts +36 -0
  343. package/src/providers/antigravity-models.ts +205 -0
  344. package/src/providers/api-keys.ts +140 -0
  345. package/src/providers/base-url-choices.ts +64 -0
  346. package/src/providers/context-cap.ts +65 -0
  347. package/src/providers/derive.ts +339 -0
  348. package/src/providers/free-directory.ts +184 -0
  349. package/src/providers/github-copilot-transport.ts +56 -0
  350. package/src/providers/key-failover.ts +203 -0
  351. package/src/providers/kiro-models.ts +67 -0
  352. package/src/providers/label.ts +19 -0
  353. package/src/providers/model-discovery.ts +356 -0
  354. package/src/providers/openai-sidecar.ts +175 -0
  355. package/src/providers/openai-tier-startup.ts +27 -0
  356. package/src/providers/openai-tiers.ts +301 -0
  357. package/src/providers/openai-virtual-models.ts +82 -0
  358. package/src/providers/openrouter-routing.ts +102 -0
  359. package/src/providers/provider-id-rewrite.ts +150 -0
  360. package/src/providers/quota.ts +1260 -0
  361. package/src/providers/registry.ts +1600 -0
  362. package/src/providers/slug-codec.ts +67 -0
  363. package/src/providers/xai-transport.ts +141 -0
  364. package/src/reasoning-effort.ts +135 -0
  365. package/src/responses/compaction.ts +117 -0
  366. package/src/responses/parser.ts +656 -0
  367. package/src/responses/reasoning-envelope.ts +52 -0
  368. package/src/responses/schema.ts +159 -0
  369. package/src/responses/spill-store.ts +394 -0
  370. package/src/responses/state.ts +895 -0
  371. package/src/responses/tool-groups.ts +19 -0
  372. package/src/router.ts +425 -0
  373. package/src/server/adapter-resolve.ts +80 -0
  374. package/src/server/auth-cors.ts +530 -0
  375. package/src/server/chat-completions.ts +368 -0
  376. package/src/server/claude-messages.ts +914 -0
  377. package/src/server/effort-policy.ts +172 -0
  378. package/src/server/gui-static.ts +123 -0
  379. package/src/server/image-retry.ts +42 -0
  380. package/src/server/images.ts +476 -0
  381. package/src/server/index.ts +1126 -0
  382. package/src/server/lifecycle.ts +227 -0
  383. package/src/server/live.ts +598 -0
  384. package/src/server/management/agent-settings-routes.ts +1169 -0
  385. package/src/server/management/api-access.ts +141 -0
  386. package/src/server/management/api-key-usage.ts +167 -0
  387. package/src/server/management/body.ts +35 -0
  388. package/src/server/management/clients-routes.ts +63 -0
  389. package/src/server/management/cloud-sync-routes.ts +266 -0
  390. package/src/server/management/combo-routes.ts +220 -0
  391. package/src/server/management/config-routes.ts +422 -0
  392. package/src/server/management/context.ts +31 -0
  393. package/src/server/management/logs-usage-routes.ts +707 -0
  394. package/src/server/management/model-routes.ts +525 -0
  395. package/src/server/management/oauth-account-routes.ts +563 -0
  396. package/src/server/management/provider-routes.ts +556 -0
  397. package/src/server/management/shared.ts +277 -0
  398. package/src/server/management/sidebar-routes.ts +90 -0
  399. package/src/server/management/system-restart.ts +179 -0
  400. package/src/server/management/system-routes.ts +117 -0
  401. package/src/server/management/usage-summary-cache.ts +86 -0
  402. package/src/server/management-api.ts +215 -0
  403. package/src/server/management-auth.ts +267 -0
  404. package/src/server/memory-watchdog.ts +156 -0
  405. package/src/server/port-reclaim.ts +307 -0
  406. package/src/server/ports.ts +116 -0
  407. package/src/server/proxy-liveness.ts +201 -0
  408. package/src/server/relay-eager.ts +313 -0
  409. package/src/server/relay.ts +1049 -0
  410. package/src/server/request-decompress.ts +132 -0
  411. package/src/server/request-log-conversation.ts +168 -0
  412. package/src/server/request-log.ts +1046 -0
  413. package/src/server/responses/collaboration.ts +354 -0
  414. package/src/server/responses/compact.ts +384 -0
  415. package/src/server/responses/core.ts +2758 -0
  416. package/src/server/responses/encrypted-payload.ts +308 -0
  417. package/src/server/responses/fetch-helpers.ts +157 -0
  418. package/src/server/responses/passthrough-error.ts +78 -0
  419. package/src/server/responses/terminal-guard.ts +230 -0
  420. package/src/server/responses/upstream-error.ts +48 -0
  421. package/src/server/responses-image-gen-repair.ts +132 -0
  422. package/src/server/responses-item-id-repair.ts +224 -0
  423. package/src/server/responses.ts +9 -0
  424. package/src/server/search.ts +136 -0
  425. package/src/server/sse-payload-rewrite.ts +175 -0
  426. package/src/server/startup-action-control.ts +308 -0
  427. package/src/server/startup-health-cache.ts +113 -0
  428. package/src/server/system-env.ts +413 -0
  429. package/src/server/windows-tcp-drop.ts +184 -0
  430. package/src/server/windows-tray-control.ts +41 -0
  431. package/src/server/ws-bridge.ts +471 -0
  432. package/src/service.ts +2554 -0
  433. package/src/stall-timeout.ts +20 -0
  434. package/src/storage/cleanup-job.ts +57 -0
  435. package/src/storage/cleanup.ts +3085 -0
  436. package/src/storage/policy-job.ts +457 -0
  437. package/src/storage/policy-scheduler.ts +40 -0
  438. package/src/storage/policy-worker.ts +59 -0
  439. package/src/storage/policy.ts +527 -0
  440. package/src/storage/restore-job.ts +299 -0
  441. package/src/storage/restore-worker.ts +58 -0
  442. package/src/storage/scanner.ts +238 -0
  443. package/src/storage/storage-mutation-coordinator.ts +139 -0
  444. package/src/storage/worker-lifecycle.ts +215 -0
  445. package/src/tray/assets/opencodex-tray-offline.ico +0 -0
  446. package/src/tray/assets/opencodex-tray-online.ico +0 -0
  447. package/src/tray/assets/opencodex-tray-warning.ico +0 -0
  448. package/src/tray/assets/opencodex-tray.png +0 -0
  449. package/src/tray/windows-tray.ps1 +290 -0
  450. package/src/tray/windows.ts +730 -0
  451. package/src/types.ts +1237 -0
  452. package/src/update/badge.ts +72 -0
  453. package/src/update/index.ts +407 -0
  454. package/src/update/job.ts +1520 -0
  455. package/src/update/notify.ts +257 -0
  456. package/src/update/npm-invocation.d.mts +23 -0
  457. package/src/update/npm-invocation.mjs +94 -0
  458. package/src/update/tray-update-plan.d.mts +18 -0
  459. package/src/update/tray-update-plan.mjs +38 -0
  460. package/src/usage/cost.ts +0 -0
  461. package/src/usage/debug.ts +97 -0
  462. package/src/usage/expected-prices.ts +164 -0
  463. package/src/usage/log.ts +658 -0
  464. package/src/usage/summary.ts +585 -0
  465. package/src/usage/totals.ts +14 -0
  466. package/src/vision/anthropic-describe.ts +185 -0
  467. package/src/vision/describe.ts +125 -0
  468. package/src/vision/index.ts +467 -0
  469. package/src/web-search/anthropic-executor.ts +189 -0
  470. package/src/web-search/executor.ts +105 -0
  471. package/src/web-search/format-result.ts +89 -0
  472. package/src/web-search/index.ts +196 -0
  473. package/src/web-search/loop.ts +664 -0
  474. package/src/web-search/parse.ts +220 -0
  475. package/src/web-search/progress-stream.ts +342 -0
  476. package/src/web-search/synthetic-tool.ts +47 -0
@@ -0,0 +1,1477 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { saveConfigPreservingClaudeCode } from "../config";
3
+ import { isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store";
4
+ import { codexAccountLogLabel } from "./account-label";
5
+ import { isCodexAccountPaused } from "./account-pause";
6
+ import { isCodexAccountUsable } from "./account-usability";
7
+ import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
8
+ import {
9
+ POOL_KEY_CODEX,
10
+ normalizeAccountPoolStickyLimit,
11
+ normalizeAccountPoolStrategy,
12
+ notePoolRotationFailure,
13
+ notePoolRotationSuccess,
14
+ peekRoundRobinAccount,
15
+ pickRoundRobinAccount,
16
+ seedPoolRotationAccount,
17
+ } from "./pool-rotation";
18
+ import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota";
19
+ import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "./main-account";
20
+ import { isSelectableCodexPoolAccount } from "./account-id";
21
+ import type { OcxConfig } from "../types";
22
+ import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
23
+ import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
24
+ import { retainedUtf8Bytes } from "../lib/admission";
25
+
26
+ type ThreadAffinityEntry = {
27
+ accountId: string;
28
+ generation: number;
29
+ createdAt: number;
30
+ lastUsedAt: number;
31
+ // Last time the bound account's quota threshold was re-evaluated for this
32
+ // thread (interval-gated to avoid per-request flapping). See REEVAL_INTERVAL_MS.
33
+ lastReevalAt: number;
34
+ };
35
+
36
+ export type CodexThreadResolution =
37
+ | { status: "selected"; accountId: string }
38
+ | { status: "none" }
39
+ | { status: "expired"; accountId: string };
40
+
41
+ /**
42
+ * Process-local cursor for automatic RR/fill-first (and quota-429 when not
43
+ * sync-writing) picks. Keeps unrelated `saveConfig` from persisting transient
44
+ * rotation as the operator's `activeCodexAccountId`. Manual selection clears it
45
+ * so disk/`config.activeCodexAccountId` remains authoritative.
46
+ */
47
+ let runtimeActiveCodexAccountId: string | undefined;
48
+
49
+ type CodexUpstreamHealth = {
50
+ consecutiveFailures: number;
51
+ /** Consecutive healthy terminals observed while recovering from escalation level 2+. */
52
+ consecutiveSuccesses?: number;
53
+ lastFailureStatus?: number;
54
+ lastFailureAt?: number;
55
+ /** Hard cooldown (quota 429). Survives a later 2xx; blocks auth + selection. */
56
+ cooldownUntil?: number;
57
+ /** When the current cooldown was recorded; origin of the probe interval clock. */
58
+ cooldownSince?: number;
59
+ /**
60
+ * What produced the cooldown. An explicit Retry-After is a literal retry
61
+ * directive and is never probed; a quota resetAt only announces a window
62
+ * refresh, so it may be probed early (#433).
63
+ */
64
+ cooldownSource?: CodexCooldownSource;
65
+ /**
66
+ * Bumped on every cooldown write. A probe lease records the generation it was
67
+ * issued for so a lease cannot clear a cooldown that a later 429 replaced.
68
+ */
69
+ cooldownGeneration?: number;
70
+ /**
71
+ * Identity of the in-flight probe. A cooled-down account sends no traffic, so
72
+ * no organic 2xx can prove recovery; only the outcome carrying this id may
73
+ * clear the cooldown.
74
+ */
75
+ probeLeaseId?: string;
76
+ /** Cooldown generation at the moment the lease was granted. */
77
+ probeLeaseGeneration?: number;
78
+ /** Last probe grant or conclusion; paces the probe interval. */
79
+ lastProbeAt?: number;
80
+ /**
81
+ * Soft avoid after connect_error / timeout / transient 5xx. Cleared on 2xx.
82
+ * Blocks pool selection + thread affinity reuse so a sticky session can leave a
83
+ * flaky account without throwing CodexAccountCooldownError (hard-only).
84
+ */
85
+ softAvoidUntil?: number;
86
+ };
87
+
88
+ const CODEX_DEFAULT_QUOTA_COOLDOWN_MS = 60_000;
89
+ const CODEX_MAX_QUOTA_COOLDOWN_MS = 24 * 60 * 60_000;
90
+ /**
91
+ * A weekly/monthly quota `resetAt` announces when the window refreshes; it is not
92
+ * a "come back after this" directive like Retry-After. Plan quota routinely frees
93
+ * up long before the advertised reset, so cap reset-derived cooldowns far below
94
+ * the Retry-After ceiling (#433).
95
+ */
96
+ const CODEX_MAX_RESET_DERIVED_COOLDOWN_MS = 15 * 60_000;
97
+ /** Minimum gap between probe leases for one cooled-down account. */
98
+ export const CODEX_QUOTA_PROBE_INTERVAL_MS = 5 * 60_000;
99
+ export const CODEX_FAILURE_WINDOW_MS = 5 * 60_000;
100
+ /** How long a transient failure keeps the account out of pool selection. */
101
+ export const CODEX_TRANSIENT_SOFT_AVOID_MS = 30_000;
102
+ const CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS = [
103
+ CODEX_TRANSIENT_SOFT_AVOID_MS,
104
+ 2 * 60_000,
105
+ 10 * 60_000,
106
+ 30 * 60_000,
107
+ ] as const;
108
+ export const CODEX_THREAD_AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000;
109
+ export const CODEX_THREAD_AFFINITY_MAX_ENTRIES = 2048;
110
+ const MAX_AFFINITY_COMPONENT_BYTES = 512;
111
+ // Min interval between quota threshold re-evaluations for a single bound thread.
112
+ // Well under the 5h/weekly quota windows, but enough to stop per-request flapping.
113
+ export const CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS = 60_000;
114
+
115
+ const upstreamHealth = new Map<string, CodexUpstreamHealth>();
116
+ /**
117
+ * Reset-derived 429s can describe a quota owned by one native model family,
118
+ * rather than the whole ChatGPT account. Keep those advisory cooldowns apart
119
+ * from account-wide Retry-After/default throttles and transient health.
120
+ */
121
+ const quotaScopedHealth = new Map<string, Map<CodexQuotaScope, CodexUpstreamHealth>>();
122
+ let lastReconciledGeneration = 0;
123
+ let liveHealthAccountIds = new Set<string>();
124
+
125
+ export type CodexUpstreamOutcome = number | "connect_error" | "timeout";
126
+ export type CodexUpstreamOutcomeClass = "success" | "credential" | "quota" | "transient" | "caller" | "unknown";
127
+ export type CodexCooldownSource = "retry-after" | "reset-derived" | "default";
128
+ /**
129
+ * Native Codex quota groups known to be independent upstream. Keep the mapping
130
+ * deliberately conservative: unlisted models share the normal native group.
131
+ * Add a new explicit group here only when its independent upstream quota is
132
+ * confirmed, so shared limits never receive cross-model bypasses.
133
+ */
134
+ export type CodexQuotaScope = "shared" | "spark";
135
+
136
+ /**
137
+ * Requests without a resolved native model retain the historic one-account-per-
138
+ * thread behavior. Requests with a known quota scope get an independent
139
+ * affinity so a Spark failover cannot displace the same thread's Terra/Luna
140
+ * account (and vice versa).
141
+ */
142
+ type ThreadAffinityScope = CodexQuotaScope | "legacy";
143
+ const LEGACY_THREAD_AFFINITY_SCOPE = "legacy" as const;
144
+ const threadAccountMap = new Map<string, Map<ThreadAffinityScope, ThreadAffinityEntry>>();
145
+
146
+ const NATIVE_MODEL_QUOTA_SCOPES: Readonly<Record<string, CodexQuotaScope>> = {
147
+ "gpt-5.3-codex-spark": "spark",
148
+ };
149
+
150
+ // A thread can have one legacy binding plus one binding for each known scope.
151
+ // This upper-bound guard avoids an exact map scan until it can be over capacity.
152
+ const MAX_THREAD_AFFINITY_SCOPES = new Set([
153
+ LEGACY_THREAD_AFFINITY_SCOPE,
154
+ "shared",
155
+ ...Object.values(NATIVE_MODEL_QUOTA_SCOPES),
156
+ ]).size;
157
+
158
+ export function codexQuotaScopeForModel(modelId: string | undefined): CodexQuotaScope | undefined {
159
+ if (!modelId?.trim()) return undefined;
160
+ return NATIVE_MODEL_QUOTA_SCOPES[modelId.trim().toLowerCase()] ?? "shared";
161
+ }
162
+
163
+ /** Independent quota groups must not mutate the shared active-account cursor. */
164
+ function isIndependentCodexQuotaScope(quotaScope?: CodexQuotaScope): boolean {
165
+ return quotaScope !== undefined && quotaScope !== "shared";
166
+ }
167
+
168
+ function codexPoolKeyForScope(quotaScope?: CodexQuotaScope): string {
169
+ return isIndependentCodexQuotaScope(quotaScope) ? `${POOL_KEY_CODEX}:${quotaScope}` : POOL_KEY_CODEX;
170
+ }
171
+
172
+ export type CodexUpstreamOutcomeMeta = {
173
+ retryAfter?: string | null;
174
+ resetAt?: unknown | unknown[];
175
+ now?: number;
176
+ /** Native model selected for this request; used only for confirmed scoped quotas. */
177
+ modelId?: string;
178
+ /** When set, clears affinity for this thread immediately on transient failure. */
179
+ threadId?: string | null;
180
+ /**
181
+ * Probe lease held by this request, when it was admitted through an active
182
+ * quota cooldown. Only the outcome carrying the current lease may clear the
183
+ * cooldown (#433).
184
+ */
185
+ probeLeaseId?: string;
186
+ /** Scope of `probeLeaseId` when it was granted against a model-scoped cooldown. */
187
+ probeQuotaScope?: CodexQuotaScope;
188
+ /**
189
+ * Already-chosen alternate for same-request 429 retry. When set, promotion
190
+ * reuses this account instead of calling {@link pickAlternateCodexAccount}
191
+ * again (which would advance a round-robin ring twice).
192
+ */
193
+ promoteAccountId?: string;
194
+ /** Generation captured when this routed account was selected. */
195
+ writerGeneration?: number;
196
+ };
197
+
198
+ function hasConfiguredPoolAccount(config: OcxConfig, accountId: string): boolean {
199
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) return isCodexAccountUsable(config, accountId);
200
+ return (config.codexAccounts ?? [])
201
+ .some(account => isSelectableCodexPoolAccount(account) && account.id === accountId);
202
+ }
203
+
204
+ export function listLiveCodexAccountIds(config: OcxConfig): ReadonlySet<string> {
205
+ const ids = new Set((config.codexAccounts ?? []).map(account => account.id));
206
+ const openai = config.providers.openai;
207
+ if (openai && openai.disabled !== true && isCanonicalOpenAiForwardProvider(openai)) {
208
+ ids.add(MAIN_CODEX_ACCOUNT_ID);
209
+ }
210
+ return ids;
211
+ }
212
+
213
+ export function clearThreadAccountMap(): void {
214
+ threadAccountMap.clear();
215
+ }
216
+
217
+ export function clearThreadAccountMapForAccount(accountId: string): void {
218
+ for (const [threadId, affinities] of threadAccountMap) {
219
+ for (const [scope, entry] of affinities) {
220
+ if (entry.accountId === accountId) affinities.delete(scope);
221
+ }
222
+ if (affinities.size === 0) threadAccountMap.delete(threadId);
223
+ }
224
+ }
225
+
226
+ export function clearCodexUpstreamHealth(): void {
227
+ upstreamHealth.clear();
228
+ quotaScopedHealth.clear();
229
+ runtimeActiveCodexAccountId = undefined;
230
+ }
231
+
232
+ export function clearCodexUpstreamHealthForAccount(accountId: string): void {
233
+ upstreamHealth.delete(accountId);
234
+ quotaScopedHealth.delete(accountId);
235
+ }
236
+
237
+ export function reconcileCodexRoutingHealth(context: GenerationContext): number {
238
+ if (context.generation <= lastReconciledGeneration) return 0;
239
+ let removed = 0;
240
+ for (const accountId of upstreamHealth.keys()) {
241
+ if (context.codexAccountIds.has(accountId)) continue;
242
+ upstreamHealth.delete(accountId);
243
+ removed += 1;
244
+ }
245
+ for (const accountId of quotaScopedHealth.keys()) {
246
+ if (context.codexAccountIds.has(accountId)) continue;
247
+ quotaScopedHealth.delete(accountId);
248
+ removed += 1;
249
+ }
250
+ liveHealthAccountIds = new Set(context.codexAccountIds);
251
+ lastReconciledGeneration = context.generation;
252
+ return removed;
253
+ }
254
+
255
+ export function getCodexUpstreamHealth(
256
+ accountId: string,
257
+ ): CodexUpstreamHealth | null {
258
+ return upstreamHealth.get(accountId) ?? null;
259
+ }
260
+
261
+ function scopedHealthFor(accountId: string, scope: CodexQuotaScope): CodexUpstreamHealth | undefined {
262
+ return quotaScopedHealth.get(accountId)?.get(scope);
263
+ }
264
+
265
+ function setScopedHealth(accountId: string, scope: CodexQuotaScope, health: CodexUpstreamHealth): void {
266
+ let scopes = quotaScopedHealth.get(accountId);
267
+ if (!scopes) {
268
+ scopes = new Map();
269
+ quotaScopedHealth.set(accountId, scopes);
270
+ }
271
+ scopes.set(scope, health);
272
+ }
273
+
274
+ function deleteScopedHealth(accountId: string, scope: CodexQuotaScope): void {
275
+ const scopes = quotaScopedHealth.get(accountId);
276
+ if (!scopes) return;
277
+ scopes.delete(scope);
278
+ if (scopes.size === 0) quotaScopedHealth.delete(accountId);
279
+ }
280
+
281
+ export function computeCodexUsageScore(quota: {
282
+ weeklyPercent?: number;
283
+ monthlyPercent?: number;
284
+ } | null, plan?: string | null): number {
285
+ if (!quota) return CODEX_UNKNOWN_USAGE_SCORE;
286
+ const normalizedPlan = plan?.trim().toLowerCase();
287
+ if (normalizedPlan === "go" || normalizedPlan === "free") {
288
+ return typeof quota.monthlyPercent === "number" && Number.isFinite(quota.monthlyPercent)
289
+ ? quota.monthlyPercent
290
+ : CODEX_UNKNOWN_USAGE_SCORE;
291
+ }
292
+ const values = [quota.weeklyPercent, quota.monthlyPercent]
293
+ .filter((value): value is number => typeof value === "number" && Number.isFinite(value));
294
+ return values.length > 0 ? Math.max(...values) : CODEX_UNKNOWN_USAGE_SCORE;
295
+ }
296
+
297
+ export function classifyCodexUpstreamOutcome(outcome: CodexUpstreamOutcome): CodexUpstreamOutcomeClass {
298
+ if (outcome === "connect_error" || outcome === "timeout") return "transient";
299
+ if (!Number.isFinite(outcome)) return "unknown";
300
+ if (outcome >= 200 && outcome < 300) return "success";
301
+ if (outcome === 401 || outcome === 403) return "credential";
302
+ // 402 Payment Required is treated as quota exhaustion for pool cooldown/failover
303
+ // (same-request alternate retry records this outcome for the depleted account).
304
+ if (outcome === 429 || outcome === 402) return "quota";
305
+ if (outcome >= 400 && outcome < 500) return "caller";
306
+ if (outcome >= 500 && outcome < 600) return "transient";
307
+ return "unknown";
308
+ }
309
+
310
+ function clampCooldownMs(ms: number): number {
311
+ return Math.min(Math.max(ms, 1), CODEX_MAX_QUOTA_COOLDOWN_MS);
312
+ }
313
+
314
+ export function parseRetryAfterMs(value: string | null | undefined, now = Date.now()): number | undefined {
315
+ const text = value?.trim();
316
+ if (!text) return undefined;
317
+ if (/^\d+(?:\.\d+)?$/.test(text)) {
318
+ const seconds = Number(text);
319
+ if (Number.isFinite(seconds) && seconds > 0) return clampCooldownMs(Math.ceil(seconds * 1000));
320
+ }
321
+ const timestamp = Date.parse(text);
322
+ if (!Number.isFinite(timestamp)) return undefined;
323
+ const delay = timestamp - now;
324
+ return delay > 0 ? clampCooldownMs(delay) : undefined;
325
+ }
326
+
327
+ function resetTimestampMs(value: unknown): number | undefined {
328
+ const numeric = typeof value === "number"
329
+ ? value
330
+ : typeof value === "string" && value.trim() !== ""
331
+ ? Number(value)
332
+ : undefined;
333
+ if (typeof numeric !== "number" || !Number.isFinite(numeric) || numeric <= 0) return undefined;
334
+ return numeric < 1_000_000_000_000 ? numeric * 1000 : numeric;
335
+ }
336
+
337
+ export function parseResetCooldownMs(resetAt: unknown | unknown[] | undefined, now = Date.now()): number | undefined {
338
+ const values = Array.isArray(resetAt) ? resetAt : [resetAt];
339
+ let best: number | undefined;
340
+ for (const value of values) {
341
+ const timestamp = resetTimestampMs(value);
342
+ if (timestamp === undefined) continue;
343
+ const delay = timestamp - now;
344
+ if (delay <= 0) continue;
345
+ // A far-future reset must not pin the account for the full Retry-After
346
+ // ceiling: quota usually frees up well before the advertised window (#433).
347
+ const clamped = Math.min(clampCooldownMs(delay), CODEX_MAX_RESET_DERIVED_COOLDOWN_MS);
348
+ if (best === undefined || clamped < best) best = clamped;
349
+ }
350
+ return best;
351
+ }
352
+
353
+ export function computeQuotaCooldown(meta: CodexUpstreamOutcomeMeta = {}): {
354
+ until: number;
355
+ source: CodexCooldownSource;
356
+ } {
357
+ const now = meta.now ?? Date.now();
358
+ const retryAfterMs = parseRetryAfterMs(meta.retryAfter, now);
359
+ if (retryAfterMs !== undefined) return { until: now + retryAfterMs, source: "retry-after" };
360
+ const resetCooldownMs = parseResetCooldownMs(meta.resetAt, now);
361
+ if (resetCooldownMs !== undefined) return { until: now + resetCooldownMs, source: "reset-derived" };
362
+ return { until: now + CODEX_DEFAULT_QUOTA_COOLDOWN_MS, source: "default" };
363
+ }
364
+
365
+ export function computeQuotaCooldownUntil(meta: CodexUpstreamOutcomeMeta = {}): number {
366
+ return computeQuotaCooldown(meta).until;
367
+ }
368
+
369
+ /**
370
+ * Grant at most one probe lease per interval for a cooled-down account.
371
+ *
372
+ * A cooled-down account is short-circuited locally, so it never sends traffic and
373
+ * no organic 2xx can prove that upstream quota recovered — the cooldown can only
374
+ * end by expiry or a proxy restart (#433). Releasing a single probe breaks that
375
+ * deadlock. Explicit Retry-After cooldowns are excluded: those are literal retry
376
+ * directives, not window announcements.
377
+ *
378
+ * Returns the lease id, or null when no probe may go out right now.
379
+ */
380
+ export function tryAcquireCodexQuotaProbeLease(accountId: string, now = Date.now()): string | null {
381
+ if (!canAcquireCodexQuotaProbeLease(accountId, now)) return null;
382
+ const health = upstreamHealth.get(accountId)!;
383
+ const probeLeaseId = randomUUID();
384
+ upstreamHealth.set(accountId, {
385
+ ...health,
386
+ probeLeaseId,
387
+ probeLeaseGeneration: health.cooldownGeneration ?? 0,
388
+ lastProbeAt: now,
389
+ });
390
+ return probeLeaseId;
391
+ }
392
+
393
+ /** Side-effect-free check mirroring {@link tryAcquireCodexQuotaProbeLease} eligibility. */
394
+ export function canAcquireCodexQuotaProbeLease(accountId: string, now = Date.now()): boolean {
395
+ return canAcquireQuotaProbeLease(upstreamHealth.get(accountId), now);
396
+ }
397
+
398
+ function canAcquireQuotaProbeLease(health: CodexUpstreamHealth | undefined, now: number): boolean {
399
+ if (!health) return false;
400
+ const cooldownUntil = health.cooldownUntil;
401
+ if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return false;
402
+ if (health.cooldownSource === "retry-after") return false;
403
+ if (health.probeLeaseId !== undefined) return false;
404
+ const origin = health.lastProbeAt ?? health.cooldownSince ?? cooldownUntil;
405
+ return now - origin >= CODEX_QUOTA_PROBE_INTERVAL_MS;
406
+ }
407
+
408
+ /** Acquire the recovery probe for one confirmed model-specific quota group. */
409
+ export function tryAcquireCodexQuotaScopeProbeLease(
410
+ accountId: string,
411
+ scope: CodexQuotaScope,
412
+ now = Date.now(),
413
+ ): string | null {
414
+ const health = scopedHealthFor(accountId, scope);
415
+ if (!canAcquireQuotaProbeLease(health, now)) return null;
416
+ const probeLeaseId = randomUUID();
417
+ setScopedHealth(accountId, scope, {
418
+ ...health!,
419
+ probeLeaseId,
420
+ probeLeaseGeneration: health!.cooldownGeneration ?? 0,
421
+ lastProbeAt: now,
422
+ });
423
+ return probeLeaseId;
424
+ }
425
+
426
+ /**
427
+ * Hand a probe lease back without recording an upstream outcome. Used by paths
428
+ * that take a lease and then fail before any request reaches upstream.
429
+ */
430
+ export function releaseCodexQuotaProbeLease(accountId: string, leaseId: string, now = Date.now()): void {
431
+ const health = upstreamHealth.get(accountId);
432
+ if (!health || health.probeLeaseId !== leaseId) return;
433
+ upstreamHealth.set(accountId, withProbeLeaseReleased(health, now));
434
+ }
435
+
436
+ /** Release a model-specific quota probe when the request never reaches upstream. */
437
+ export function releaseCodexQuotaScopeProbeLease(
438
+ accountId: string,
439
+ scope: CodexQuotaScope,
440
+ leaseId: string,
441
+ now = Date.now(),
442
+ ): void {
443
+ const health = scopedHealthFor(accountId, scope);
444
+ if (!health || health.probeLeaseId !== leaseId) return;
445
+ setScopedHealth(accountId, scope, withProbeLeaseReleased(health, now));
446
+ }
447
+
448
+ /**
449
+ * True when this outcome belongs to the account's in-flight probe. The
450
+ * undefined-id guard matters: without it an outcome carrying no lease would match
451
+ * an account holding no lease and be mistaken for the probe owner.
452
+ */
453
+ function ownsProbeLease(health: CodexUpstreamHealth | undefined, meta: CodexUpstreamOutcomeMeta): boolean {
454
+ return meta.probeLeaseId !== undefined && meta.probeLeaseId === health?.probeLeaseId;
455
+ }
456
+
457
+ /**
458
+ * True when the owning probe may still clear the cooldown. A later 429 bumps the
459
+ * generation, so a probe that started under an older cooldown must not erase the
460
+ * newer restriction (which may carry an explicit Retry-After).
461
+ */
462
+ function probeMayClearCooldown(health: CodexUpstreamHealth | undefined, meta: CodexUpstreamOutcomeMeta): boolean {
463
+ return ownsProbeLease(health, meta)
464
+ && (health!.probeLeaseGeneration ?? 0) === (health!.cooldownGeneration ?? 0);
465
+ }
466
+
467
+ /** Strip the in-flight lease while preserving every hard-cooldown field. */
468
+ function withProbeLeaseReleased(health: CodexUpstreamHealth, now: number): CodexUpstreamHealth {
469
+ const { probeLeaseId: _id, probeLeaseGeneration: _gen, ...rest } = health;
470
+ return { ...rest, lastProbeAt: now };
471
+ }
472
+
473
+ /**
474
+ * Hard-cooldown bookkeeping that ordinary success/transient transitions rebuild
475
+ * their health object from. Dropping these would let one late unrelated response
476
+ * erase a Retry-After source, a cooldown generation, or someone else's live probe.
477
+ */
478
+ function preservedCooldownFields(health: CodexUpstreamHealth | undefined): Partial<CodexUpstreamHealth> {
479
+ if (!health) return {};
480
+ const { consecutiveFailures: _f, consecutiveSuccesses: _s, lastFailureStatus: _st, lastFailureAt: _at, softAvoidUntil: _sa, ...cooldownFields } = health;
481
+ return cooldownFields;
482
+ }
483
+
484
+ /** Manual selection resets transient routing evidence without bypassing a real 429 cooldown. */
485
+ export function resetCodexRoutingForManualSelection(accountId: string): void {
486
+ clearThreadAccountMap();
487
+ // Manual selection is the operator source of truth — drop any automatic runtime cursor.
488
+ runtimeActiveCodexAccountId = undefined;
489
+ // Seed the RR ring so the next unbound new session honors the manually selected account
490
+ // under round-robin (affinity-cleared threads / null threadId). Fill-first already follows
491
+ // config.activeCodexAccountId, which the caller persists before invoking this.
492
+ seedPoolRotationAccount(POOL_KEY_CODEX, accountId);
493
+ for (const scope of new Set(Object.values(NATIVE_MODEL_QUOTA_SCOPES))) {
494
+ if (isIndependentCodexQuotaScope(scope)) {
495
+ seedPoolRotationAccount(codexPoolKeyForScope(scope), accountId);
496
+ }
497
+ }
498
+ const current = upstreamHealth.get(accountId);
499
+ if (!current) return;
500
+ const preserved = preservedCooldownFields(current);
501
+ if (Object.keys(preserved).length === 0) upstreamHealth.delete(accountId);
502
+ else upstreamHealth.set(accountId, { consecutiveFailures: 0, ...preserved });
503
+ }
504
+
505
+ export function getCodexAccountCooldownUntil(accountId: string, now = Date.now()): number | null {
506
+ const cooldownUntil = upstreamHealth.get(accountId)?.cooldownUntil;
507
+ return typeof cooldownUntil === "number" && Number.isFinite(cooldownUntil) && cooldownUntil > now ? cooldownUntil : null;
508
+ }
509
+
510
+ /** Read-only cooldown snapshot for shared OAuth health projection (no write side effects). */
511
+ export function getCodexAccountHealthSnapshot(accountId: string, now = Date.now()): {
512
+ cooldownUntil?: number;
513
+ cooldownSource?: CodexCooldownSource;
514
+ } | null {
515
+ const cooldownUntil = getCodexAccountCooldownUntil(accountId, now);
516
+ if (cooldownUntil === null) return null;
517
+ const source = upstreamHealth.get(accountId)?.cooldownSource;
518
+ return {
519
+ cooldownUntil,
520
+ ...(source ? { cooldownSource: source } : {}),
521
+ };
522
+ }
523
+
524
+ /**
525
+ * Read the cooldown relevant to a routed native model. Account-wide cooldowns
526
+ * (Retry-After/default) always win; reset-derived scoped state applies only to
527
+ * its confirmed quota group.
528
+ */
529
+ export function getCodexQuotaHealthSnapshot(
530
+ accountId: string,
531
+ quotaScope: CodexQuotaScope | undefined,
532
+ now = Date.now(),
533
+ ): {
534
+ cooldownUntil?: number;
535
+ cooldownSource?: CodexCooldownSource;
536
+ quotaScope?: CodexQuotaScope;
537
+ } | null {
538
+ const account = getCodexAccountHealthSnapshot(accountId, now);
539
+ if (account) return account;
540
+ if (!quotaScope) return null;
541
+ const scoped = scopedHealthFor(accountId, quotaScope);
542
+ const cooldownUntil = scoped?.cooldownUntil;
543
+ if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return null;
544
+ return {
545
+ cooldownUntil,
546
+ ...(scoped?.cooldownSource ? { cooldownSource: scoped.cooldownSource } : {}),
547
+ quotaScope,
548
+ };
549
+ }
550
+
551
+ export function isCodexAccountInCooldown(accountId: string, now = Date.now()): boolean {
552
+ return getCodexAccountCooldownUntil(accountId, now) !== null;
553
+ }
554
+
555
+ /**
556
+ * Manually lift a hard quota cooldown without touching failure history.
557
+ *
558
+ * Injected Codex routing makes this proxy the ONLY model path for Codex Desktop, so a
559
+ * cooldown that outlives the real upstream limit reads to the user as "the whole app is
560
+ * broken" with no escape but editing config.toml. This is that escape hatch.
561
+ *
562
+ * Deliberately narrow:
563
+ * - Failure counters and softAvoid survive. Clearing a cooldown says "the quota window
564
+ * moved", not "this account is healthy"; failover must keep its knowledge.
565
+ * - Dropping `probeLeaseId` is what stops a stale in-flight probe from later "proving"
566
+ * recovery against a NEWER cooldown: {@link ownsProbeLease} needs the id to match.
567
+ * `cooldownGeneration` is preserved and bumped as redundancy only — a fresh 429 already
568
+ * bumps it in {@link recordCodexUpstreamOutcome}, so the bump here is not load-bearing
569
+ * today and is kept so the invariant survives a future change that retains the lease.
570
+ *
571
+ * Returns false when the account carried no live cooldown (already expired or never set).
572
+ */
573
+ export function clearCodexAccountCooldown(accountId: string, now = Date.now()): boolean {
574
+ const clear = (health: CodexUpstreamHealth): CodexUpstreamHealth | null => {
575
+ const cooldownUntil = health.cooldownUntil;
576
+ if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return null;
577
+ const {
578
+ cooldownUntil: _until,
579
+ cooldownSince: _since,
580
+ cooldownSource: _source,
581
+ probeLeaseId: _leaseId,
582
+ probeLeaseGeneration: _leaseGeneration,
583
+ ...rest
584
+ } = health;
585
+ return {
586
+ ...rest,
587
+ cooldownGeneration: (health.cooldownGeneration ?? 0) + 1,
588
+ lastProbeAt: now,
589
+ };
590
+ };
591
+
592
+ let cleared = false;
593
+ const accountHealth = upstreamHealth.get(accountId);
594
+ if (accountHealth) {
595
+ const next = clear(accountHealth);
596
+ if (next) {
597
+ upstreamHealth.set(accountId, next);
598
+ cleared = true;
599
+ }
600
+ }
601
+ for (const [scope, health] of quotaScopedHealth.get(accountId) ?? []) {
602
+ const next = clear(health);
603
+ if (next) {
604
+ setScopedHealth(accountId, scope, next);
605
+ cleared = true;
606
+ }
607
+ }
608
+ return cleared;
609
+ }
610
+
611
+ export function getCodexAccountSoftAvoidUntil(accountId: string, now = Date.now()): number | null {
612
+ const softAvoidUntil = upstreamHealth.get(accountId)?.softAvoidUntil;
613
+ return typeof softAvoidUntil === "number" && Number.isFinite(softAvoidUntil) && softAvoidUntil > now
614
+ ? softAvoidUntil
615
+ : null;
616
+ }
617
+
618
+ export function isCodexAccountSoftAvoided(accountId: string, now = Date.now()): boolean {
619
+ return getCodexAccountSoftAvoidUntil(accountId, now) !== null;
620
+ }
621
+
622
+ function isCodexAccountSelectable(
623
+ config: OcxConfig,
624
+ accountId: string,
625
+ now: number,
626
+ quotaScope?: CodexQuotaScope,
627
+ ): boolean {
628
+ return !isCodexAccountPaused(config, accountId)
629
+ && getCodexQuotaHealthSnapshot(accountId, quotaScope, now) === null
630
+ && !isCodexAccountSoftAvoided(accountId, now)
631
+ && isCodexAccountUsable(config, accountId);
632
+ }
633
+
634
+ function threadAffinityScope(quotaScope?: CodexQuotaScope): ThreadAffinityScope {
635
+ return quotaScope ?? LEGACY_THREAD_AFFINITY_SCOPE;
636
+ }
637
+
638
+ function admissibleAffinityComponent(value: string): boolean {
639
+ return retainedUtf8Bytes(value) <= MAX_AFFINITY_COMPONENT_BYTES;
640
+ }
641
+
642
+ function getThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): ThreadAffinityEntry | undefined {
643
+ if (!admissibleAffinityComponent(threadId)) return undefined;
644
+ return threadAccountMap.get(threadId)?.get(threadAffinityScope(quotaScope));
645
+ }
646
+
647
+ function deleteThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): void {
648
+ if (!admissibleAffinityComponent(threadId)) return;
649
+ const affinities = threadAccountMap.get(threadId);
650
+ if (!affinities) return;
651
+ affinities.delete(threadAffinityScope(quotaScope));
652
+ if (affinities.size === 0) threadAccountMap.delete(threadId);
653
+ }
654
+
655
+ /** Remove only the matching failed account's affinities for one thread. */
656
+ function deleteThreadAffinitiesForAccount(threadId: string, accountId: string): void {
657
+ if (!admissibleAffinityComponent(threadId) || !admissibleAffinityComponent(accountId)) return;
658
+ const affinities = threadAccountMap.get(threadId);
659
+ if (!affinities) return;
660
+ for (const [scope, entry] of affinities) {
661
+ if (entry.accountId === accountId) affinities.delete(scope);
662
+ }
663
+ if (affinities.size === 0) threadAccountMap.delete(threadId);
664
+ }
665
+
666
+ function threadAffinityEntryCount(): number {
667
+ let count = 0;
668
+ for (const affinities of threadAccountMap.values()) count += affinities.size;
669
+ return count;
670
+ }
671
+
672
+ function isThreadAffinityExpired(entry: ThreadAffinityEntry, now: number): boolean {
673
+ return now - entry.lastUsedAt > CODEX_THREAD_AFFINITY_IDLE_TTL_MS;
674
+ }
675
+
676
+ function isThreadAffinityGenerationLive(entry: ThreadAffinityEntry): boolean {
677
+ if (entry.accountId === MAIN_CODEX_ACCOUNT_ID) return entry.generation === 0;
678
+ return isCodexAccountGenerationLive(entry.accountId, entry.generation);
679
+ }
680
+
681
+ function pruneExpiredThreadAffinities(now: number): void {
682
+ for (const [threadId, affinities] of threadAccountMap) {
683
+ for (const [scope, entry] of affinities) {
684
+ if (isThreadAffinityExpired(entry, now)) affinities.delete(scope);
685
+ }
686
+ if (affinities.size === 0) threadAccountMap.delete(threadId);
687
+ }
688
+ }
689
+
690
+ function pruneLruThreadAffinities(): void {
691
+ if (threadAccountMap.size * MAX_THREAD_AFFINITY_SCOPES <= CODEX_THREAD_AFFINITY_MAX_ENTRIES) return;
692
+ while (threadAffinityEntryCount() > CODEX_THREAD_AFFINITY_MAX_ENTRIES) {
693
+ let oldestThreadId: string | null = null;
694
+ let oldestScope: ThreadAffinityScope | null = null;
695
+ let oldestLastUsedAt = Number.POSITIVE_INFINITY;
696
+ for (const [threadId, affinities] of threadAccountMap) {
697
+ for (const [scope, entry] of affinities) {
698
+ if (entry.lastUsedAt < oldestLastUsedAt) {
699
+ oldestThreadId = threadId;
700
+ oldestScope = scope;
701
+ oldestLastUsedAt = entry.lastUsedAt;
702
+ }
703
+ }
704
+ }
705
+ if (!oldestThreadId || !oldestScope) return;
706
+ deleteThreadAffinity(oldestThreadId, oldestScope === LEGACY_THREAD_AFFINITY_SCOPE ? undefined : oldestScope);
707
+ }
708
+ }
709
+
710
+ function bindThreadAffinity(
711
+ threadId: string,
712
+ accountId: string,
713
+ now: number,
714
+ quotaScope?: CodexQuotaScope,
715
+ ): void {
716
+ if (!admissibleAffinityComponent(threadId) || !admissibleAffinityComponent(accountId)) return;
717
+ const record = accountId === MAIN_CODEX_ACCOUNT_ID ? undefined : readCodexAccountRecord(accountId);
718
+ if (accountId !== MAIN_CODEX_ACCOUNT_ID && (!record?.credential || record.deletedAt != null)) return;
719
+ pruneExpiredThreadAffinities(now);
720
+ const scope = threadAffinityScope(quotaScope);
721
+ const affinities = threadAccountMap.get(threadId) ?? new Map<ThreadAffinityScope, ThreadAffinityEntry>();
722
+ const previous = affinities.get(scope);
723
+ affinities.set(scope, {
724
+ accountId,
725
+ generation: accountId === MAIN_CODEX_ACCOUNT_ID ? 0 : record!.generation,
726
+ createdAt: previous?.createdAt ?? now,
727
+ lastUsedAt: now,
728
+ lastReevalAt: now,
729
+ });
730
+ threadAccountMap.set(threadId, affinities);
731
+ pruneLruThreadAffinities();
732
+ }
733
+
734
+ function getEligiblePoolAccounts(
735
+ config: OcxConfig,
736
+ excludeId?: string,
737
+ now = Date.now(),
738
+ quotaScope?: CodexQuotaScope,
739
+ ): string[] {
740
+ const ids = (config.codexAccounts ?? [])
741
+ .filter(account => isSelectableCodexPoolAccount(account)
742
+ && account.id !== excludeId
743
+ && !isCodexAccountPaused(config, account.id)
744
+ && !isAccountNeedsReauth(account.id))
745
+ .filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null)
746
+ .filter(account => !isCodexAccountSoftAvoided(account.id, now))
747
+ .filter(account => isCodexAccountUsable(config, account.id))
748
+ .map(account => account.id);
749
+ // The main Codex account is not stored in config.codexAccounts; include it as a
750
+ // first-class rotation candidate when its read-only token is usable (Option A).
751
+ if (
752
+ excludeId !== MAIN_CODEX_ACCOUNT_ID
753
+ && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
754
+ && !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)
755
+ && getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null
756
+ && !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now)
757
+ && isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID)
758
+ ) {
759
+ ids.unshift(MAIN_CODEX_ACCOUNT_ID);
760
+ }
761
+ return ids;
762
+ }
763
+
764
+ function listEligibleCodexAccountIds(
765
+ config: OcxConfig,
766
+ now: number,
767
+ quotaScope?: CodexQuotaScope,
768
+ ): string[] {
769
+ return getEligiblePoolAccounts(config, undefined, now, quotaScope);
770
+ }
771
+
772
+ function stickyLimitForConfig(config: OcxConfig): number {
773
+ return normalizeAccountPoolStickyLimit(config.accountPoolStickyLimit);
774
+ }
775
+
776
+ function isActiveUnderFillFirstThreshold(config: OcxConfig, accountId: string): boolean {
777
+ const threshold = config.autoSwitchThreshold ?? 80;
778
+ if (threshold <= 0) return true;
779
+ const usage = computeCodexUsageScore(getAccountQuota(accountId), getPoolAccountPlan(config, accountId));
780
+ // Unknown usage must not force fill-first to abandon the active account.
781
+ if (isUnknownUsage(usage)) return true;
782
+ return usage < threshold;
783
+ }
784
+
785
+ /**
786
+ * Fill-first: keep selectable active under threshold; otherwise advance to the next
787
+ * eligible id in stable sorted order after the current active (wrapping).
788
+ */
789
+ function pickFillFirstCodexAccount(
790
+ config: OcxConfig,
791
+ now: number,
792
+ quotaScope?: CodexQuotaScope,
793
+ ): string | null {
794
+ const eligible = listEligibleCodexAccountIds(config, now, quotaScope);
795
+ if (eligible.length === 0) return null;
796
+
797
+ const active = getEffectiveActiveCodexAccountId(config);
798
+ if (active && eligible.includes(active) && isActiveUnderFillFirstThreshold(config, active)) {
799
+ return active;
800
+ }
801
+
802
+ return pickNextFillFirstCodexAccount(config, active ?? null, eligible, now);
803
+ }
804
+
805
+ /** Next eligible account in stable order after `afterId` (wrapping). */
806
+ function pickNextFillFirstCodexAccount(
807
+ config: OcxConfig,
808
+ afterId: string | null,
809
+ eligible = listEligibleCodexAccountIds(config, Date.now()),
810
+ _now = Date.now(),
811
+ ): string | null {
812
+ if (eligible.length === 0) return null;
813
+ const ordered = [...eligible].sort((a, b) => a.localeCompare(b));
814
+ if (!afterId) {
815
+ // Prefer an under-threshold account when starting with no active cursor.
816
+ for (const id of ordered) {
817
+ if (isActiveUnderFillFirstThreshold(config, id)) return id;
818
+ }
819
+ return ordered[0] ?? null;
820
+ }
821
+
822
+ const allConfigured = [
823
+ ...(isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID) || afterId === MAIN_CODEX_ACCOUNT_ID
824
+ ? [MAIN_CODEX_ACCOUNT_ID]
825
+ : []),
826
+ ...(config.codexAccounts ?? []).filter(account => !account.isMain).map(account => account.id),
827
+ ];
828
+ const stableAll = [...new Set(allConfigured)].sort((a, b) => a.localeCompare(b));
829
+ const startIdx = stableAll.indexOf(afterId);
830
+ if (startIdx < 0) {
831
+ for (const id of ordered) {
832
+ if (isActiveUnderFillFirstThreshold(config, id)) return id;
833
+ }
834
+ return ordered[0] ?? null;
835
+ }
836
+
837
+ // Skip successors that are also at/above threshold (known drained usage).
838
+ let fallback: string | null = null;
839
+ for (let step = 1; step <= stableAll.length; step++) {
840
+ const candidate = stableAll[(startIdx + step) % stableAll.length]!;
841
+ if (!eligible.includes(candidate)) continue;
842
+ if (!fallback) fallback = candidate;
843
+ if (isActiveUnderFillFirstThreshold(config, candidate)) return candidate;
844
+ }
845
+ return fallback ?? ordered[0] ?? null;
846
+ }
847
+
848
+ /**
849
+ * Unbound new-session pick for round-robin / fill-first. Returns null to fall through
850
+ * to the legacy quota path (or when the strategy is quota).
851
+ *
852
+ * When `commit` is true (resolve path), remembers active in-memory, binds thread affinity, and
853
+ * notes RR success. When `commit` is false (preview), returns the same RR/fill-first
854
+ * account resolve would pick via a dry-run peek — without mutating ring weights,
855
+ * activeKey, sticky counters, config, or affinity.
856
+ *
857
+ * Automatic strategy picks never sync-write config; only manual selection persists active.
858
+ *
859
+ * Known limitation (follow-up): when a subagent preview peeks an RR account and the request
860
+ * then falls back to a non-Codex provider, the ring is not reserved/committed. Prefer seeding
861
+ * the peeked account if that path becomes load-bearing.
862
+ */
863
+ function pickUnboundStrategyAccount(
864
+ config: OcxConfig,
865
+ threadId: string | null,
866
+ now: number,
867
+ commit: boolean,
868
+ quotaScope?: CodexQuotaScope,
869
+ ): string | null {
870
+ const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
871
+ if (strategy === "quota") return null;
872
+ const poolKey = codexPoolKeyForScope(quotaScope);
873
+
874
+ let picked: string | null = null;
875
+ if (strategy === "round-robin") {
876
+ const eligible = listEligibleCodexAccountIds(config, now, quotaScope);
877
+ const limit = stickyLimitForConfig(config);
878
+ if (!commit) {
879
+ return peekRoundRobinAccount(poolKey, eligible, limit);
880
+ }
881
+ picked = pickRoundRobinAccount(poolKey, eligible, limit);
882
+ if (!picked) return null;
883
+ if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked);
884
+ if (threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
885
+ notePoolRotationSuccess(poolKey, picked, limit);
886
+ return picked;
887
+ }
888
+
889
+ if (strategy === "fill-first") {
890
+ picked = pickFillFirstCodexAccount(config, now, quotaScope);
891
+ if (!picked) return null;
892
+ if (commit) {
893
+ if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked);
894
+ if (threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
895
+ }
896
+ return picked;
897
+ }
898
+
899
+ return null;
900
+ }
901
+
902
+ export function getPoolAccountPlan(config: OcxConfig, accountId: string): string | undefined {
903
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) return getMainAccountPlan();
904
+ return (config.codexAccounts ?? [])
905
+ .find(account => isSelectableCodexPoolAccount(account) && account.id === accountId)?.plan;
906
+ }
907
+
908
+ function pickLowerUsageAccount(
909
+ config: OcxConfig,
910
+ active: string,
911
+ activeUsage: number,
912
+ now: number,
913
+ quotaScope?: CodexQuotaScope,
914
+ ): string {
915
+ let best = active;
916
+ let bestUsage = activeUsage;
917
+ for (const id of getEligiblePoolAccounts(config, active, now, quotaScope)) {
918
+ const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id));
919
+ if (usage < bestUsage) {
920
+ best = id;
921
+ bestUsage = usage;
922
+ }
923
+ }
924
+ return best;
925
+ }
926
+
927
+ export function pickLowestUsageCodexAccount(
928
+ config: OcxConfig,
929
+ excludeId?: string,
930
+ now = Date.now(),
931
+ quotaScope?: CodexQuotaScope,
932
+ ): string | null {
933
+ let best: string | null = null;
934
+ let bestUsage = Number.POSITIVE_INFINITY;
935
+ for (const id of getEligiblePoolAccounts(config, excludeId, now, quotaScope)) {
936
+ const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id));
937
+ if (usage < bestUsage) {
938
+ best = id;
939
+ bestUsage = usage;
940
+ }
941
+ }
942
+ return best;
943
+ }
944
+
945
+ /**
946
+ * Strategy-aware alternate after a cooled/excluded account (same-request 429 retry
947
+ * and active promotion). Quota keeps lowest-usage; fill-first advances stable order;
948
+ * round-robin takes the next ring pick (caller should have noted the failure).
949
+ */
950
+ export function pickAlternateCodexAccount(
951
+ config: OcxConfig,
952
+ excludeId: string,
953
+ now = Date.now(),
954
+ quotaScope?: CodexQuotaScope,
955
+ ): string | null {
956
+ const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
957
+ if (strategy === "round-robin") {
958
+ const eligible = listEligibleCodexAccountIds(config, now, quotaScope).filter(id => id !== excludeId);
959
+ return pickRoundRobinAccount(codexPoolKeyForScope(quotaScope), eligible, stickyLimitForConfig(config));
960
+ }
961
+ if (strategy === "fill-first") {
962
+ const eligible = listEligibleCodexAccountIds(config, now, quotaScope).filter(id => id !== excludeId);
963
+ return pickNextFillFirstCodexAccount(config, excludeId, eligible, now);
964
+ }
965
+ return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope);
966
+ }
967
+
968
+ /** Effective active: automatic runtime cursor, else operator/persisted selection. */
969
+ export function getEffectiveActiveCodexAccountId(config: OcxConfig): string | undefined {
970
+ return runtimeActiveCodexAccountId ?? config.activeCodexAccountId;
971
+ }
972
+
973
+ /**
974
+ * Automatic strategy / failover cursor only — never mutates `config.activeCodexAccountId`
975
+ * so an unrelated `saveConfig` cannot persist transient rotation as operator selection.
976
+ */
977
+ function rememberActiveCodexAccount(_config: OcxConfig, accountId: string): void {
978
+ runtimeActiveCodexAccountId = accountId;
979
+ }
980
+
981
+ /** Persist operator (or quota-strategy) active selection to config + disk. */
982
+ function setActiveCodexAccount(config: OcxConfig, accountId: string): void {
983
+ runtimeActiveCodexAccountId = undefined;
984
+ if (config.activeCodexAccountId === accountId) return;
985
+ config.activeCodexAccountId = accountId;
986
+ saveConfigPreservingClaudeCode(config);
987
+ }
988
+
989
+ /** Quota strategy persists; RR/fill-first keep a process-local cursor only. */
990
+ function promoteActiveCodexAccount(config: OcxConfig, accountId: string): void {
991
+ if (normalizeAccountPoolStrategy(config.accountPoolStrategy) === "quota") {
992
+ setActiveCodexAccount(config, accountId);
993
+ return;
994
+ }
995
+ rememberActiveCodexAccount(config, accountId);
996
+ }
997
+
998
+ /**
999
+ * Reconcile the effective active account after an administrative exclusion such as pause.
1000
+ * The operator's persisted selection is cleared when it names the excluded account; quota
1001
+ * keeps its historical persisted promotion, while rotating strategies retain the replacement
1002
+ * only in the process-local cursor.
1003
+ */
1004
+ export function reconcileCodexActiveAfterExclusion(
1005
+ config: OcxConfig,
1006
+ excludedAccountId: string,
1007
+ now = Date.now(),
1008
+ ): string | null {
1009
+ const wasEffective = (getEffectiveActiveCodexAccountId(config) ?? MAIN_CODEX_ACCOUNT_ID) === excludedAccountId;
1010
+ if (config.activeCodexAccountId === excludedAccountId) {
1011
+ config.activeCodexAccountId = undefined;
1012
+ }
1013
+ if (!wasEffective) return getEffectiveActiveCodexAccountId(config) ?? null;
1014
+
1015
+ runtimeActiveCodexAccountId = undefined;
1016
+ const fallback = pickAlternateCodexAccount(config, excludedAccountId, now);
1017
+ if (fallback) promoteActiveCodexAccount(config, fallback);
1018
+ return fallback;
1019
+ }
1020
+
1021
+ function isUnknownUsage(usage: number): boolean {
1022
+ return usage >= CODEX_UNKNOWN_USAGE_SCORE;
1023
+ }
1024
+
1025
+ function applyQuotaAutoSwitch(
1026
+ config: OcxConfig,
1027
+ active: string,
1028
+ now: number,
1029
+ quotaScope?: CodexQuotaScope,
1030
+ ): string {
1031
+ const threshold = config.autoSwitchThreshold ?? 80;
1032
+ if (threshold <= 0) return active;
1033
+ const quota = getAccountQuota(active);
1034
+ const activeUsage = computeCodexUsageScore(quota, getPoolAccountPlan(config, active));
1035
+ // Unknown usage is not evidence that a user's explicit selection crossed the
1036
+ // threshold. Wait for quota priming instead of rotating among guesses.
1037
+ if (isUnknownUsage(activeUsage)) return active;
1038
+ if (activeUsage < threshold) return active;
1039
+ const best = pickLowerUsageAccount(config, active, activeUsage, now, quotaScope);
1040
+ if (best !== active) {
1041
+ if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, best);
1042
+ return best;
1043
+ }
1044
+
1045
+ return active;
1046
+ }
1047
+
1048
+ function shouldFailover(config: OcxConfig, accountId: string, now: number): boolean {
1049
+ const threshold = config.upstreamFailoverThreshold ?? 3;
1050
+ if (threshold <= 0) return false;
1051
+ const health = upstreamHealth.get(accountId);
1052
+ if (health?.lastFailureAt && now - health.lastFailureAt > CODEX_FAILURE_WINDOW_MS) return false;
1053
+ return !!health && health.consecutiveFailures >= threshold;
1054
+ }
1055
+
1056
+ function applyFailureFailover(config: OcxConfig, active: string, now: number): string {
1057
+ if (!shouldFailover(config, active, now)) return active;
1058
+ const best = pickAlternateCodexAccount(config, active, now);
1059
+ if (best) {
1060
+ promoteActiveCodexAccount(config, best);
1061
+ return best;
1062
+ }
1063
+ return active;
1064
+ }
1065
+
1066
+ export function resolveCodexAccountForThread(
1067
+ threadId: string | null,
1068
+ config: OcxConfig,
1069
+ now = Date.now(),
1070
+ quotaScope?: CodexQuotaScope,
1071
+ ): string | null {
1072
+ const resolution = resolveCodexAccountForThreadDetailed(threadId, config, now, quotaScope);
1073
+ return resolution.status === "selected" ? resolution.accountId : null;
1074
+ }
1075
+
1076
+ /**
1077
+ * Side-effect-free preview of the Codex pool account native routing would prefer.
1078
+ * Used for subagent fallback quota decisions before final auth.
1079
+ *
1080
+ * Does not mutate activeCodexAccountId, thread affinity, config on disk, or probe leases.
1081
+ * Mirrors {@link resolveCodexAccountForThreadDetailed} account choice, including returning a
1082
+ * configured cooled account so callers can evaluate probe/quota availability.
1083
+ */
1084
+ export function previewCodexAccountForRequest(
1085
+ threadId: string | null,
1086
+ config: OcxConfig,
1087
+ now = Date.now(),
1088
+ quotaScope?: CodexQuotaScope,
1089
+ ): string | null {
1090
+ const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined;
1091
+ if (threadId && entry) {
1092
+ if (
1093
+ !isThreadAffinityExpired(entry, now)
1094
+ && isThreadAffinityGenerationLive(entry)
1095
+ && isCodexAccountSelectable(config, entry.accountId, now, quotaScope)
1096
+ && !shouldFailover(config, entry.accountId, now)
1097
+ ) {
1098
+ // Quota strategy only: non-quota strategies keep affinity for ongoing threads
1099
+ // (new-session-only rotation — docs / affinity policy A).
1100
+ const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
1101
+ if (strategy === "quota") {
1102
+ const threshold = config.autoSwitchThreshold ?? 80;
1103
+ if (threshold > 0) {
1104
+ const usage = computeCodexUsageScore(
1105
+ getAccountQuota(entry.accountId),
1106
+ getPoolAccountPlan(config, entry.accountId),
1107
+ );
1108
+ if (!isUnknownUsage(usage) && usage >= threshold) {
1109
+ const best = pickLowerUsageAccount(config, entry.accountId, usage, now, quotaScope);
1110
+ if (best !== entry.accountId) return best;
1111
+ }
1112
+ }
1113
+ }
1114
+ return entry.accountId;
1115
+ }
1116
+ // Stale/unusable affinity is ignored for preview (no map mutation).
1117
+ }
1118
+
1119
+ const strategyPick = pickUnboundStrategyAccount(config, threadId, now, false, quotaScope);
1120
+ if (strategyPick) return strategyPick;
1121
+
1122
+ let active = getEffectiveActiveCodexAccountId(config) ?? null;
1123
+ if (!active) {
1124
+ return pickLowestUsageCodexAccount(config, undefined, now, quotaScope);
1125
+ }
1126
+ if (!isCodexAccountSelectable(config, active, now, quotaScope)) {
1127
+ const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope);
1128
+ if (fallback) active = fallback;
1129
+ else if (hasConfiguredPoolAccount(config, active) && !isCodexAccountPaused(config, active)) return active;
1130
+ else return null;
1131
+ }
1132
+
1133
+ const threshold = config.autoSwitchThreshold ?? 80;
1134
+ if (threshold > 0) {
1135
+ const usage = computeCodexUsageScore(getAccountQuota(active), getPoolAccountPlan(config, active));
1136
+ if (!isUnknownUsage(usage) && usage >= threshold) {
1137
+ active = pickLowerUsageAccount(config, active, usage, now, quotaScope);
1138
+ }
1139
+ }
1140
+ if (shouldFailover(config, active, now)) {
1141
+ const best = pickLowestUsageCodexAccount(config, active, now, quotaScope);
1142
+ if (best) active = best;
1143
+ }
1144
+ if (!isCodexAccountUsable(config, active)) {
1145
+ return hasConfiguredPoolAccount(config, active) ? active : null;
1146
+ }
1147
+ if (isCodexAccountPaused(config, active)) return null;
1148
+ if (getCodexQuotaHealthSnapshot(active, quotaScope, now)) {
1149
+ return hasConfiguredPoolAccount(config, active) ? active : null;
1150
+ }
1151
+ return active;
1152
+ }
1153
+
1154
+ export function resolveCodexAccountForThreadDetailed(
1155
+ threadId: string | null,
1156
+ config: OcxConfig,
1157
+ now = Date.now(),
1158
+ quotaScope?: CodexQuotaScope,
1159
+ ): CodexThreadResolution {
1160
+ const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined;
1161
+ if (threadId && entry) {
1162
+ if (isThreadAffinityExpired(entry, now)) {
1163
+ deleteThreadAffinity(threadId, quotaScope);
1164
+ return { status: "expired", accountId: entry.accountId };
1165
+ }
1166
+ if (
1167
+ isThreadAffinityGenerationLive(entry)
1168
+ && isCodexAccountSelectable(config, entry.accountId, now, quotaScope)
1169
+ // Affined threads must leave a failing account once the streak trips failover
1170
+ // (soft-avoid covers the first-hit case; this catches post-avoid residual streaks).
1171
+ && !shouldFailover(config, entry.accountId, now)
1172
+ ) {
1173
+ entry.lastUsedAt = now;
1174
+ // Periodic quota re-eval: a long-lived bound thread must still switch when
1175
+ // it crosses autoSwitchThreshold and a strictly-cooler account exists.
1176
+ // Without this the reuse branch returns before applyQuotaAutoSwitch and the
1177
+ // thread stays pinned for the full idle TTL (the WSL "never switches" report).
1178
+ // Over-threshold pins re-eval immediately so a depleted primary does not keep
1179
+ // serving for up to 60s after a secondary with quota is available (#584).
1180
+ // Non-quota strategies (RR / fill-first) keep affinity for ongoing threads —
1181
+ // rotation is new-session-only (affinity policy A).
1182
+ const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
1183
+ if (strategy === "quota") {
1184
+ const threshold = config.autoSwitchThreshold ?? 80;
1185
+ const usage = threshold > 0
1186
+ ? computeCodexUsageScore(
1187
+ getAccountQuota(entry.accountId),
1188
+ getPoolAccountPlan(config, entry.accountId),
1189
+ )
1190
+ : 0;
1191
+ const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold;
1192
+ if (overThreshold || now - entry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS) {
1193
+ entry.lastReevalAt = now;
1194
+ if (overThreshold) {
1195
+ const best = pickLowerUsageAccount(config, entry.accountId, usage, now, quotaScope);
1196
+ if (best !== entry.accountId) {
1197
+ if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, best);
1198
+ bindThreadAffinity(threadId, best, now, quotaScope); // rebinds + resets clocks
1199
+ return { status: "selected", accountId: best };
1200
+ }
1201
+ }
1202
+ }
1203
+ }
1204
+ return { status: "selected", accountId: entry.accountId };
1205
+ }
1206
+ deleteThreadAffinity(threadId, quotaScope);
1207
+ }
1208
+
1209
+ const strategyPick = pickUnboundStrategyAccount(config, threadId, now, true, quotaScope);
1210
+ if (strategyPick) return { status: "selected", accountId: strategyPick };
1211
+
1212
+ let active = getEffectiveActiveCodexAccountId(config);
1213
+ if (!active) {
1214
+ const selected = pickLowestUsageCodexAccount(config, undefined, now, quotaScope);
1215
+ if (!selected) return { status: "none" };
1216
+ if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, selected);
1217
+ active = selected;
1218
+ }
1219
+ if (!isCodexAccountSelectable(config, active, now, quotaScope)) {
1220
+ const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope);
1221
+ if (fallback) {
1222
+ if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, fallback);
1223
+ active = fallback;
1224
+ } else if (hasConfiguredPoolAccount(config, active) && !isCodexAccountPaused(config, active)) {
1225
+ return { status: "selected", accountId: active };
1226
+ } else {
1227
+ return { status: "none" };
1228
+ }
1229
+ }
1230
+ active = applyQuotaAutoSwitch(config, active, now, quotaScope);
1231
+ active = applyFailureFailover(config, active, now);
1232
+ if (!isCodexAccountUsable(config, active)) {
1233
+ return hasConfiguredPoolAccount(config, active) ? { status: "selected", accountId: active } : { status: "none" };
1234
+ }
1235
+ if (isCodexAccountPaused(config, active)) return { status: "none" };
1236
+ if (getCodexQuotaHealthSnapshot(active, quotaScope, now)) {
1237
+ return hasConfiguredPoolAccount(config, active) ? { status: "selected", accountId: active } : { status: "none" };
1238
+ }
1239
+ if (threadId) bindThreadAffinity(threadId, active, now, quotaScope);
1240
+ return { status: "selected", accountId: active };
1241
+ }
1242
+
1243
+ export function recordCodexUpstreamOutcome(
1244
+ config: OcxConfig,
1245
+ accountId: string | null,
1246
+ outcome: CodexUpstreamOutcome,
1247
+ meta: CodexUpstreamOutcomeMeta = {},
1248
+ ): void {
1249
+ if (!accountId) return;
1250
+ const writerGeneration = meta.writerGeneration ?? captureConfigGeneration();
1251
+ if (writerGeneration < lastReconciledGeneration && !liveHealthAccountIds.has(accountId)) return;
1252
+ const now = meta.now ?? Date.now();
1253
+ const outcomeClass = classifyCodexUpstreamOutcome(outcome);
1254
+ const quotaScope = codexQuotaScopeForModel(meta.modelId);
1255
+ if (outcomeClass === "success") {
1256
+ const scopedProbe = meta.probeQuotaScope
1257
+ ? scopedHealthFor(accountId, meta.probeQuotaScope)
1258
+ : undefined;
1259
+ if (scopedProbe && meta.probeQuotaScope) {
1260
+ if (scopedProbe.cooldownUntil && probeMayClearCooldown(scopedProbe, meta)) {
1261
+ deleteScopedHealth(accountId, meta.probeQuotaScope);
1262
+ } else if (ownsProbeLease(scopedProbe, meta)) {
1263
+ setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now));
1264
+ }
1265
+ }
1266
+ const current = upstreamHealth.get(accountId);
1267
+ const cooldownUntil = getCodexAccountCooldownUntil(accountId, now);
1268
+ // A leased probe that is still on its own cooldown generation proves the
1269
+ // account recovered: clear the hard cooldown outright (#433).
1270
+ if (cooldownUntil && probeMayClearCooldown(current, meta)) {
1271
+ upstreamHealth.delete(accountId);
1272
+ return;
1273
+ }
1274
+ // Owning probe on a stale generation: the lease is done, but a newer 429
1275
+ // replaced the cooldown in the meantime, so only give the lease back.
1276
+ // Non-owners keep every hard-cooldown field, including someone else's live lease.
1277
+ const base = ownsProbeLease(current, meta) ? withProbeLeaseReleased(current!, now) : current;
1278
+ const preserved = preservedCooldownFields(base);
1279
+ const failoverEnabled = (config.upstreamFailoverThreshold ?? 3) > 0;
1280
+ if (failoverEnabled && current && current.consecutiveFailures >= 2) {
1281
+ const consecutiveSuccesses = (current.consecutiveSuccesses ?? 0) + 1;
1282
+ if (consecutiveSuccesses < 2) {
1283
+ upstreamHealth.set(accountId, {
1284
+ ...base!,
1285
+ ...preserved,
1286
+ consecutiveSuccesses,
1287
+ });
1288
+ return;
1289
+ }
1290
+ }
1291
+ // Level 1 clears immediately; escalated accounts need two consecutive healthy terminals.
1292
+ // Hard quota cooldown intentionally survives either recovery path.
1293
+ if (cooldownUntil) upstreamHealth.set(accountId, { consecutiveFailures: 0, ...preserved });
1294
+ else upstreamHealth.delete(accountId);
1295
+ return;
1296
+ }
1297
+ if (outcomeClass === "caller") {
1298
+ // A 4xx does not change account health, but it does conclude an in-flight
1299
+ // probe — otherwise the lease would never be handed back.
1300
+ const current = upstreamHealth.get(accountId);
1301
+ const scopedProbe = meta.probeQuotaScope
1302
+ ? scopedHealthFor(accountId, meta.probeQuotaScope)
1303
+ : undefined;
1304
+ if (scopedProbe && meta.probeQuotaScope && ownsProbeLease(scopedProbe, meta)) {
1305
+ setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now));
1306
+ }
1307
+ if (ownsProbeLease(current, meta)) {
1308
+ upstreamHealth.set(accountId, withProbeLeaseReleased(current!, now));
1309
+ }
1310
+ return;
1311
+ }
1312
+
1313
+ const lastFailureStatus = typeof outcome === "number" ? outcome : 0;
1314
+ if (outcomeClass === "credential") {
1315
+ // 401/403 quarantines the account for reauth. That supersedes quota state
1316
+ // entirely: a cooldown (and any probe lease) on an unusable account is moot.
1317
+ upstreamHealth.set(accountId, {
1318
+ consecutiveFailures: 1,
1319
+ lastFailureStatus,
1320
+ lastFailureAt: now,
1321
+ });
1322
+ quotaScopedHealth.delete(accountId);
1323
+ markAccountNeedsReauth(accountId, writerGeneration);
1324
+ clearThreadAccountMapForAccount(accountId);
1325
+ return;
1326
+ }
1327
+
1328
+ if (outcomeClass === "quota") {
1329
+ const { until, source } = computeQuotaCooldown(meta);
1330
+ // A reset timestamp is an advisory quota-window announcement. When the
1331
+ // selected native model belongs to a confirmed independent group, preserve
1332
+ // it there so a different group (Spark versus the shared native quota) can
1333
+ // still reach upstream. Explicit Retry-After/default 429s remain account-wide.
1334
+ if (source === "reset-derived" && quotaScope) {
1335
+ const prior = scopedHealthFor(accountId, quotaScope);
1336
+ const cooldownGeneration = (prior?.cooldownGeneration ?? 0) + 1;
1337
+ const ownsLease = meta.probeQuotaScope === quotaScope && ownsProbeLease(prior, meta);
1338
+ setScopedHealth(accountId, quotaScope, {
1339
+ consecutiveFailures: 0,
1340
+ lastFailureStatus,
1341
+ lastFailureAt: now,
1342
+ cooldownUntil: until,
1343
+ cooldownSince: now,
1344
+ cooldownSource: source,
1345
+ cooldownGeneration,
1346
+ ...(ownsLease
1347
+ ? { lastProbeAt: now }
1348
+ : {
1349
+ ...(prior?.probeLeaseId !== undefined ? { probeLeaseId: prior.probeLeaseId } : {}),
1350
+ ...(prior?.probeLeaseGeneration !== undefined ? { probeLeaseGeneration: prior.probeLeaseGeneration } : {}),
1351
+ ...(prior?.lastProbeAt !== undefined ? { lastProbeAt: prior.lastProbeAt } : {}),
1352
+ }),
1353
+ });
1354
+ // The shared native scope is the existing account-wide native behavior:
1355
+ // threads must leave it and new requests should prefer an eligible account.
1356
+ // Spark remains isolated so a same-account Terra/Luna combo fallback can run.
1357
+ if (quotaScope === "shared") {
1358
+ clearThreadAccountMapForAccount(accountId);
1359
+ notePoolRotationFailure(POOL_KEY_CODEX, accountId);
1360
+ if (getEffectiveActiveCodexAccountId(config) === accountId) {
1361
+ // Same-request 429 retry already picked via excludeAccountId — reuse it so
1362
+ // round-robin does not advance the ring a second time.
1363
+ const reused = meta.promoteAccountId && meta.promoteAccountId !== accountId
1364
+ ? meta.promoteAccountId
1365
+ : null;
1366
+ const fallback = reused ?? pickAlternateCodexAccount(config, accountId, now, quotaScope);
1367
+ if (fallback) promoteActiveCodexAccount(config, fallback);
1368
+ }
1369
+ }
1370
+ return;
1371
+ }
1372
+
1373
+ // A scoped probe that received an account-wide throttle is no longer live.
1374
+ const scopedProbe = meta.probeQuotaScope
1375
+ ? scopedHealthFor(accountId, meta.probeQuotaScope)
1376
+ : undefined;
1377
+ if (scopedProbe && meta.probeQuotaScope && ownsProbeLease(scopedProbe, meta)) {
1378
+ setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now));
1379
+ }
1380
+ const prior = upstreamHealth.get(accountId);
1381
+ // Every cooldown write bumps the generation so a probe issued against the
1382
+ // previous cooldown can no longer clear this one (#433).
1383
+ const cooldownGeneration = (prior?.cooldownGeneration ?? 0) + 1;
1384
+ // A failed probe concludes its lease; an unrelated 429 leaves the live probe alone.
1385
+ const ownsLease = ownsProbeLease(prior, meta);
1386
+ upstreamHealth.set(accountId, {
1387
+ consecutiveFailures: 0,
1388
+ lastFailureStatus,
1389
+ lastFailureAt: now,
1390
+ cooldownUntil: until,
1391
+ cooldownSince: now,
1392
+ cooldownSource: source,
1393
+ cooldownGeneration,
1394
+ ...(ownsLease
1395
+ ? { lastProbeAt: now }
1396
+ : {
1397
+ ...(prior?.probeLeaseId !== undefined ? { probeLeaseId: prior.probeLeaseId } : {}),
1398
+ ...(prior?.probeLeaseGeneration !== undefined ? { probeLeaseGeneration: prior.probeLeaseGeneration } : {}),
1399
+ ...(prior?.lastProbeAt !== undefined ? { lastProbeAt: prior.lastProbeAt } : {}),
1400
+ }),
1401
+ });
1402
+ clearThreadAccountMapForAccount(accountId);
1403
+ notePoolRotationFailure(POOL_KEY_CODEX, accountId);
1404
+ const effectiveActive = getEffectiveActiveCodexAccountId(config);
1405
+ if (effectiveActive === accountId) {
1406
+ // Same-request 429 retry already picked via excludeAccountId — reuse it so
1407
+ // round-robin does not advance the ring a second time.
1408
+ const reused = meta.promoteAccountId && meta.promoteAccountId !== accountId
1409
+ ? meta.promoteAccountId
1410
+ : null;
1411
+ const fallback = reused ?? pickAlternateCodexAccount(config, accountId, now);
1412
+ if (fallback) promoteActiveCodexAccount(config, fallback);
1413
+ }
1414
+ return;
1415
+ }
1416
+
1417
+ // transient (connect_error / timeout / 5xx)
1418
+ const current = upstreamHealth.get(accountId);
1419
+ const scopedProbe = meta.probeQuotaScope
1420
+ ? scopedHealthFor(accountId, meta.probeQuotaScope)
1421
+ : undefined;
1422
+ if (scopedProbe && meta.probeQuotaScope && ownsProbeLease(scopedProbe, meta)) {
1423
+ setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now));
1424
+ }
1425
+ // A transient failure concludes an owning probe; an unrelated 5xx must not
1426
+ // consume someone else's live lease or drop hard-cooldown bookkeeping (#433).
1427
+ const transientBase = ownsProbeLease(current, meta) ? withProbeLeaseReleased(current!, now) : current;
1428
+ const stale = current?.lastFailureAt ? now - current.lastFailureAt > CODEX_FAILURE_WINDOW_MS : false;
1429
+ const hardCooldownUntil = getCodexAccountCooldownUntil(accountId, now) ?? undefined;
1430
+ // Soft avoid + affinity clears are part of failover. When threshold is 0, leave
1431
+ // sticky sessions alone (same as shouldFailover / applyFailureFailover no-ops).
1432
+ const failoverThreshold = config.upstreamFailoverThreshold ?? 3;
1433
+ const consecutiveFailures = stale ? 1 : (current?.consecutiveFailures ?? 0) + 1;
1434
+ const failoverReady = failoverThreshold > 0 && consecutiveFailures >= failoverThreshold;
1435
+ const escalationMs = CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS[
1436
+ Math.min(Math.max(consecutiveFailures - failoverThreshold, 0), CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS.length - 1)
1437
+ ]!;
1438
+ const softAvoidUntil = failoverReady
1439
+ ? Math.max(
1440
+ getCodexAccountSoftAvoidUntil(accountId, now) ?? 0,
1441
+ now + escalationMs,
1442
+ )
1443
+ : undefined;
1444
+ upstreamHealth.set(accountId, {
1445
+ ...preservedCooldownFields(transientBase),
1446
+ consecutiveFailures,
1447
+ lastFailureStatus,
1448
+ lastFailureAt: now,
1449
+ ...(hardCooldownUntil ? { cooldownUntil: hardCooldownUntil } : {}),
1450
+ ...(softAvoidUntil !== undefined ? { softAvoidUntil } : {}),
1451
+ });
1452
+ // Drop this thread's pin immediately so the next continue can rebind without
1453
+ // waiting for the soft-avoid selectable check. Guard: only delete when the
1454
+ // thread is still pinned to the FAILING account — a late failure from account A
1455
+ // must not delete a newer healthy binding to account B (race: T→A, A fails,
1456
+ // T→B, late A failure must not delete B's mapping).
1457
+ if (failoverReady && meta.threadId) {
1458
+ deleteThreadAffinitiesForAccount(meta.threadId, accountId);
1459
+ }
1460
+ // Once the account is past the failover streak, clear every thread still pinned
1461
+ // to it — matching 429 affinity behavior so "continue" cannot stay on a bad peer.
1462
+ if (shouldFailover(config, accountId, now)) {
1463
+ clearThreadAccountMapForAccount(accountId);
1464
+ }
1465
+ if (getEffectiveActiveCodexAccountId(config) === accountId) applyFailureFailover(config, accountId, now);
1466
+ }
1467
+
1468
+ export function formatCodexProviderForLog(providerName: string, accountId: string | null, config: OcxConfig): string {
1469
+ if (!accountId) return providerName;
1470
+ // The main Codex login participates in rotation as "main-pool" (MAIN_CODEX_ACCOUNT_ID) but is the
1471
+ // same physical account as the "main" passthrough (null accountId). Log both under the base provider
1472
+ // name so usage/tokens aggregate into a single row instead of splitting into `chatgpt` + `chatgpt-main`.
1473
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) return providerName;
1474
+ const account = (config.codexAccounts ?? [])
1475
+ .find(candidate => isSelectableCodexPoolAccount(candidate) && candidate.id === accountId);
1476
+ return account ? `${providerName}-${codexAccountLogLabel(account)}` : providerName;
1477
+ }