@yansigit/opencodex 2.31.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 (826) hide show
  1. package/AGENTS_INSTALL.md +109 -0
  2. package/LICENSE +21 -0
  3. package/README.md +303 -0
  4. package/assets/architecture.png +0 -0
  5. package/assets/banner.png +0 -0
  6. package/assets/claude-code-models.gif +0 -0
  7. package/assets/codex-app-picker.png +0 -0
  8. package/bin/ocx.mjs +587 -0
  9. package/bin/package-main.mjs +9 -0
  10. package/gui/dist/assets/index-BNJ7r4Gd.js +102 -0
  11. package/gui/dist/assets/index-CGoDO3uO.css +1 -0
  12. package/gui/dist/favicon.png +0 -0
  13. package/gui/dist/icons.svg +24 -0
  14. package/gui/dist/index.html +25 -0
  15. package/gui/dist/logo.png +0 -0
  16. package/gui/dist/provider-icons/alibaba-color.svg +1 -0
  17. package/gui/dist/provider-icons/antigravity-color.svg +1 -0
  18. package/gui/dist/provider-icons/claude-color.svg +1 -0
  19. package/gui/dist/provider-icons/cline-color.svg +16 -0
  20. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -0
  21. package/gui/dist/provider-icons/commandcode-color.svg +1 -0
  22. package/gui/dist/provider-icons/copilot-color.svg +1 -0
  23. package/gui/dist/provider-icons/cursor-color.svg +2 -0
  24. package/gui/dist/provider-icons/deepseek-color.svg +1 -0
  25. package/gui/dist/provider-icons/discord.svg +1 -0
  26. package/gui/dist/provider-icons/firepass-color.svg +1 -0
  27. package/gui/dist/provider-icons/fireworks-color.svg +1 -0
  28. package/gui/dist/provider-icons/gemini-color.svg +1 -0
  29. package/gui/dist/provider-icons/github-copilot-color.svg +1 -0
  30. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -0
  31. package/gui/dist/provider-icons/grok.svg +1 -0
  32. package/gui/dist/provider-icons/groq-color.svg +1 -0
  33. package/gui/dist/provider-icons/huggingface-color.svg +1 -0
  34. package/gui/dist/provider-icons/kimi-color.svg +1 -0
  35. package/gui/dist/provider-icons/kiro-color.svg +15 -0
  36. package/gui/dist/provider-icons/lm-studio-color.svg +1 -0
  37. package/gui/dist/provider-icons/mistral-color.svg +1 -0
  38. package/gui/dist/provider-icons/moonshot-color.svg +1 -0
  39. package/gui/dist/provider-icons/nvidia-color.svg +1 -0
  40. package/gui/dist/provider-icons/ollama-color.svg +1 -0
  41. package/gui/dist/provider-icons/openai.svg +1 -0
  42. package/gui/dist/provider-icons/opencode.svg +2 -0
  43. package/gui/dist/provider-icons/openrouter-color.svg +1 -0
  44. package/gui/dist/provider-icons/pi.svg +21 -0
  45. package/gui/dist/provider-icons/qianfan-color.svg +1 -0
  46. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -0
  47. package/gui/dist/provider-icons/telegram.svg +1 -0
  48. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -0
  49. package/gui/dist/provider-icons/vllm-color.svg +1 -0
  50. package/gui/dist/provider-icons/xiaomi-color.svg +1 -0
  51. package/package.json +108 -0
  52. package/src/AGENTS.md +28 -0
  53. package/src/adapters/anthropic-image-guard.ts +251 -0
  54. package/src/adapters/anthropic-image-normalize.ts +518 -0
  55. package/src/adapters/anthropic-output-schema.ts +137 -0
  56. package/src/adapters/anthropic.ts +1327 -0
  57. package/src/adapters/azure.ts +36 -0
  58. package/src/adapters/base.ts +121 -0
  59. package/src/adapters/client-fingerprint.ts +65 -0
  60. package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +69 -0
  61. package/src/adapters/command-code.ts +601 -0
  62. package/src/adapters/cursor/arg-codec.ts +38 -0
  63. package/src/adapters/cursor/arg-normalize.ts +104 -0
  64. package/src/adapters/cursor/checkpoint-store.ts +303 -0
  65. package/src/adapters/cursor/cursor-errors.ts +288 -0
  66. package/src/adapters/cursor/discovery.ts +333 -0
  67. package/src/adapters/cursor/effort-map.ts +151 -0
  68. package/src/adapters/cursor/exec-policy.ts +88 -0
  69. package/src/adapters/cursor/framing.ts +250 -0
  70. package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
  71. package/src/adapters/cursor/h2-pool.ts +123 -0
  72. package/src/adapters/cursor/http1-bidi.ts +361 -0
  73. package/src/adapters/cursor/images.ts +704 -0
  74. package/src/adapters/cursor/kv-store.ts +52 -0
  75. package/src/adapters/cursor/live-models.ts +269 -0
  76. package/src/adapters/cursor/live-smoke-gate.ts +41 -0
  77. package/src/adapters/cursor/live-transport.ts +1653 -0
  78. package/src/adapters/cursor/mcp-config.ts +42 -0
  79. package/src/adapters/cursor/mcp-manager.ts +333 -0
  80. package/src/adapters/cursor/message-mapper.ts +49 -0
  81. package/src/adapters/cursor/native-exec-common.ts +76 -0
  82. package/src/adapters/cursor/native-exec-desktop.ts +184 -0
  83. package/src/adapters/cursor/native-exec-fs.ts +332 -0
  84. package/src/adapters/cursor/native-exec-mcp.ts +153 -0
  85. package/src/adapters/cursor/native-exec-network.ts +43 -0
  86. package/src/adapters/cursor/native-exec-shell.ts +547 -0
  87. package/src/adapters/cursor/native-exec-tools.ts +118 -0
  88. package/src/adapters/cursor/native-exec.ts +663 -0
  89. package/src/adapters/cursor/protobuf-events.ts +1381 -0
  90. package/src/adapters/cursor/protobuf-request.ts +1032 -0
  91. package/src/adapters/cursor/request-builder.ts +461 -0
  92. package/src/adapters/cursor/thread-continuity.ts +67 -0
  93. package/src/adapters/cursor/tool-definitions.ts +735 -0
  94. package/src/adapters/cursor/tool-result-normalize.ts +92 -0
  95. package/src/adapters/cursor/transport-retry.ts +132 -0
  96. package/src/adapters/cursor/transport.ts +79 -0
  97. package/src/adapters/cursor/types.ts +79 -0
  98. package/src/adapters/cursor.ts +322 -0
  99. package/src/adapters/google-antigravity-hosts.ts +48 -0
  100. package/src/adapters/google-antigravity-replay.ts +827 -0
  101. package/src/adapters/google-antigravity-tools.ts +105 -0
  102. package/src/adapters/google-antigravity-wire.ts +141 -0
  103. package/src/adapters/google-errors.ts +92 -0
  104. package/src/adapters/google-http.ts +460 -0
  105. package/src/adapters/google-tool-schema.ts +238 -0
  106. package/src/adapters/google-truncation.ts +24 -0
  107. package/src/adapters/google-wire-compiler.ts +232 -0
  108. package/src/adapters/google.ts +1377 -0
  109. package/src/adapters/identity.ts +77 -0
  110. package/src/adapters/image.ts +23 -0
  111. package/src/adapters/kiro-constants.ts +16 -0
  112. package/src/adapters/kiro-errors.ts +208 -0
  113. package/src/adapters/kiro-events.ts +197 -0
  114. package/src/adapters/kiro-images.ts +129 -0
  115. package/src/adapters/kiro-retry.ts +312 -0
  116. package/src/adapters/kiro-thinking.ts +112 -0
  117. package/src/adapters/kiro-tool-fallback.ts +36 -0
  118. package/src/adapters/kiro-tools.ts +224 -0
  119. package/src/adapters/kiro-truncation.ts +33 -0
  120. package/src/adapters/kiro-wire.ts +129 -0
  121. package/src/adapters/kiro.ts +1936 -0
  122. package/src/adapters/mimo-free.ts +280 -0
  123. package/src/adapters/openai-chat-url.ts +11 -0
  124. package/src/adapters/openai-chat.ts +1980 -0
  125. package/src/adapters/openai-responses-url.ts +16 -0
  126. package/src/adapters/openai-responses.ts +1911 -0
  127. package/src/adapters/registry.ts +175 -0
  128. package/src/adapters/responses-tool-schema.ts +67 -0
  129. package/src/adapters/run-turn-queue.ts +114 -0
  130. package/src/adapters/tool-call-id.ts +119 -0
  131. package/src/adapters/tool-catalog-nudge.ts +154 -0
  132. package/src/adapters/upstream-http-error.ts +48 -0
  133. package/src/adapters/xai-web-search.ts +185 -0
  134. package/src/bridge.ts +1986 -0
  135. package/src/chat/inbound.ts +319 -0
  136. package/src/chat/outbound.ts +821 -0
  137. package/src/claude/agents-inject.ts +266 -0
  138. package/src/claude/alias.ts +149 -0
  139. package/src/claude/auth-detect.ts +229 -0
  140. package/src/claude/auth-mode-migration.ts +32 -0
  141. package/src/claude/auth-mode.ts +62 -0
  142. package/src/claude/context-windows.ts +205 -0
  143. package/src/claude/desktop-3p-guard.ts +35 -0
  144. package/src/claude/desktop-3p-paths.ts +84 -0
  145. package/src/claude/desktop-3p.ts +615 -0
  146. package/src/claude/desktop-health.ts +26 -0
  147. package/src/claude/desktop-profile.ts +263 -0
  148. package/src/claude/gateway-cache.ts +107 -0
  149. package/src/claude/inbound-debug.ts +163 -0
  150. package/src/claude/inbound.ts +578 -0
  151. package/src/claude/model-info.ts +174 -0
  152. package/src/claude/outbound.ts +926 -0
  153. package/src/cli/access.ts +108 -0
  154. package/src/cli/account-api.ts +302 -0
  155. package/src/cli/account-auth.ts +250 -0
  156. package/src/cli/account-catalog-refresh.ts +14 -0
  157. package/src/cli/account-extended.ts +737 -0
  158. package/src/cli/account-main.ts +317 -0
  159. package/src/cli/account.ts +299 -0
  160. package/src/cli/agent-driven.ts +70 -0
  161. package/src/cli/agent.ts +290 -0
  162. package/src/cli/catalog-prewarm.ts +27 -0
  163. package/src/cli/claude-agent-startup-sync.ts +73 -0
  164. package/src/cli/claude-desktop.ts +213 -0
  165. package/src/cli/claude.ts +355 -0
  166. package/src/cli/codex-log-guard-doctor.ts +103 -0
  167. package/src/cli/codex-shim-autorestore.ts +47 -0
  168. package/src/cli/codex-shim-readiness.ts +76 -0
  169. package/src/cli/combo.ts +127 -0
  170. package/src/cli/config-command.ts +209 -0
  171. package/src/cli/debug.ts +228 -0
  172. package/src/cli/dispatch.ts +585 -0
  173. package/src/cli/doctor.ts +1202 -0
  174. package/src/cli/ensure-desired-integrations.ts +152 -0
  175. package/src/cli/export-command.ts +213 -0
  176. package/src/cli/help.ts +101 -0
  177. package/src/cli/index.ts +973 -0
  178. package/src/cli/init.ts +211 -0
  179. package/src/cli/integrations.ts +260 -0
  180. package/src/cli/interactive-confirm.ts +133 -0
  181. package/src/cli/lab.ts +607 -0
  182. package/src/cli/launcher-context.ts +77 -0
  183. package/src/cli/minimax.ts +497 -0
  184. package/src/cli/models-runtime.ts +245 -0
  185. package/src/cli/models.ts +422 -0
  186. package/src/cli/observe.ts +206 -0
  187. package/src/cli/opencode.ts +588 -0
  188. package/src/cli/provider-replit.ts +232 -0
  189. package/src/cli/provider-runtime.ts +179 -0
  190. package/src/cli/provider.ts +492 -0
  191. package/src/cli/ready.ts +301 -0
  192. package/src/cli/registry.ts +422 -0
  193. package/src/cli/replit-gateway-key-input.ts +138 -0
  194. package/src/cli/root.ts +86 -0
  195. package/src/cli/route-policy.ts +92 -0
  196. package/src/cli/runtime-api.ts +328 -0
  197. package/src/cli/star-prompt.ts +211 -0
  198. package/src/cli/status-oauth.ts +78 -0
  199. package/src/cli/status.ts +328 -0
  200. package/src/cli/system-command.ts +112 -0
  201. package/src/cli/system-restart-client.ts +146 -0
  202. package/src/cli/tray-proxy.ts +199 -0
  203. package/src/cli/v2.ts +268 -0
  204. package/src/cli.ts +10 -0
  205. package/src/clients/config-export.ts +1704 -0
  206. package/src/codex/account-id.ts +34 -0
  207. package/src/codex/account-label.ts +47 -0
  208. package/src/codex/account-lifecycle.ts +172 -0
  209. package/src/codex/account-namespace-match.ts +63 -0
  210. package/src/codex/account-namespaces.ts +195 -0
  211. package/src/codex/account-pause.ts +20 -0
  212. package/src/codex/account-priority.ts +83 -0
  213. package/src/codex/account-runtime-state.ts +31 -0
  214. package/src/codex/account-store.ts +544 -0
  215. package/src/codex/account-usability.ts +43 -0
  216. package/src/codex/admission.ts +256 -0
  217. package/src/codex/affinity-debug.ts +162 -0
  218. package/src/codex/agent-roles-sync.ts +225 -0
  219. package/src/codex/agent-roles.ts +238 -0
  220. package/src/codex/app-server-processes.ts +1143 -0
  221. package/src/codex/app-server-restart-service.ts +232 -0
  222. package/src/codex/auth-api.ts +2147 -0
  223. package/src/codex/auth-collision.ts +109 -0
  224. package/src/codex/auth-context.ts +665 -0
  225. package/src/codex/autostart-health.ts +156 -0
  226. package/src/codex/catalog/account-models.ts +67 -0
  227. package/src/codex/catalog/aggregation.ts +436 -0
  228. package/src/codex/catalog/bundled.ts +549 -0
  229. package/src/codex/catalog/effort.ts +446 -0
  230. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  231. package/src/codex/catalog/kinds.ts +2 -0
  232. package/src/codex/catalog/metadata.ts +664 -0
  233. package/src/codex/catalog/native-models.ts +72 -0
  234. package/src/codex/catalog/parsing.ts +650 -0
  235. package/src/codex/catalog/provider-fetch.ts +2064 -0
  236. package/src/codex/catalog/sync.ts +1883 -0
  237. package/src/codex/catalog-admission.ts +199 -0
  238. package/src/codex/catalog-refresh-status.ts +105 -0
  239. package/src/codex/catalog-write-serialization.ts +242 -0
  240. package/src/codex/catalog.ts +14 -0
  241. package/src/codex/codex-write-lock.ts +384 -0
  242. package/src/codex/convergence-types.ts +614 -0
  243. package/src/codex/convergence.ts +651 -0
  244. package/src/codex/coordinator-doctor.ts +332 -0
  245. package/src/codex/custom-model-catalog-migration.ts +176 -0
  246. package/src/codex/data/upstream-models.json +830 -0
  247. package/src/codex/desired-state.ts +230 -0
  248. package/src/codex/exec-invocation.ts +22 -0
  249. package/src/codex/features.ts +1566 -0
  250. package/src/codex/generation.ts +202 -0
  251. package/src/codex/history-job.ts +407 -0
  252. package/src/codex/history-lock.ts +242 -0
  253. package/src/codex/history-migration-guardian.ts +108 -0
  254. package/src/codex/history-provider.ts +979 -0
  255. package/src/codex/history-transition.ts +105 -0
  256. package/src/codex/history-worker.ts +220 -0
  257. package/src/codex/home.ts +206 -0
  258. package/src/codex/inject-coordination.ts +290 -0
  259. package/src/codex/inject.ts +1733 -0
  260. package/src/codex/injected-marker.ts +106 -0
  261. package/src/codex/integration-record.ts +266 -0
  262. package/src/codex/internal/catalog-writer.ts +203 -0
  263. package/src/codex/internal/history-writer.ts +80 -0
  264. package/src/codex/journal.ts +225 -0
  265. package/src/codex/log-guard/inspect.ts +506 -0
  266. package/src/codex/log-guard/lock.ts +150 -0
  267. package/src/codex/log-guard/maintenance.ts +403 -0
  268. package/src/codex/log-guard/path-safety.ts +88 -0
  269. package/src/codex/log-guard/policy.ts +44 -0
  270. package/src/codex/log-guard/processes.ts +205 -0
  271. package/src/codex/log-guard/protection.ts +489 -0
  272. package/src/codex/log-guard/sqlite-errors.ts +9 -0
  273. package/src/codex/main-account-cache.ts +56 -0
  274. package/src/codex/main-account.ts +68 -0
  275. package/src/codex/management-convergence.ts +167 -0
  276. package/src/codex/model-cache.ts +273 -0
  277. package/src/codex/model-entitlements.ts +353 -0
  278. package/src/codex/native-main-admission.ts +47 -0
  279. package/src/codex/native-main-auth-temp.ts +187 -0
  280. package/src/codex/native-main-claim.ts +178 -0
  281. package/src/codex/native-main-lock-file.ts +162 -0
  282. package/src/codex/native-main-owner.ts +329 -0
  283. package/src/codex/native-profile-api.ts +247 -0
  284. package/src/codex/native-profile-manager.ts +1531 -0
  285. package/src/codex/native-profile-processes.ts +121 -0
  286. package/src/codex/native-profile-recovery.ts +99 -0
  287. package/src/codex/native-profile-stage-store.ts +387 -0
  288. package/src/codex/native-profile-startup.ts +492 -0
  289. package/src/codex/native-profile-store.ts +855 -0
  290. package/src/codex/native-profile-types.ts +120 -0
  291. package/src/codex/native-residue.ts +682 -0
  292. package/src/codex/paths.ts +144 -0
  293. package/src/codex/plan-from-token.ts +140 -0
  294. package/src/codex/plan.ts +40 -0
  295. package/src/codex/plugins-doctor.ts +242 -0
  296. package/src/codex/pool-rotation.ts +295 -0
  297. package/src/codex/project-config-warnings.ts +425 -0
  298. package/src/codex/prompt-journal.ts +352 -0
  299. package/src/codex/prompt-layers.ts +967 -0
  300. package/src/codex/prompt-lock.ts +143 -0
  301. package/src/codex/quota-rejection.ts +298 -0
  302. package/src/codex/quota.ts +573 -0
  303. package/src/codex/refresh.ts +62 -0
  304. package/src/codex/reset-credit-recovery.ts +1044 -0
  305. package/src/codex/routing.ts +1888 -0
  306. package/src/codex/runtime.ts +659 -0
  307. package/src/codex/shim.ts +2170 -0
  308. package/src/codex/subagent-defaults.ts +550 -0
  309. package/src/codex/subagent-model-fallback.ts +784 -0
  310. package/src/codex/sync.ts +319 -0
  311. package/src/codex/transition-state.ts +612 -0
  312. package/src/codex/upstream-host-health.ts +368 -0
  313. package/src/codex/user-identity.ts +557 -0
  314. package/src/codex/warmup.ts +298 -0
  315. package/src/codex/websocket-registry.ts +100 -0
  316. package/src/codex/write-coordination.ts +114 -0
  317. package/src/combos/failover.ts +160 -0
  318. package/src/combos/index.ts +45 -0
  319. package/src/combos/request.ts +94 -0
  320. package/src/combos/resolve.ts +232 -0
  321. package/src/combos/types.ts +398 -0
  322. package/src/config/provider-name.ts +24 -0
  323. package/src/config.ts +4041 -0
  324. package/src/fork/register.ts +3 -0
  325. package/src/generated/compatibility-version.json +3116 -0
  326. package/src/generated/model-metadata.ts +106 -0
  327. package/src/github/star-state.ts +203 -0
  328. package/src/grok/inject.ts +530 -0
  329. package/src/grok/inspect.ts +45 -0
  330. package/src/grok/status.ts +121 -0
  331. package/src/grok/sync.ts +66 -0
  332. package/src/images/artifacts.ts +516 -0
  333. package/src/images/fulfill-video.ts +163 -0
  334. package/src/images/fulfill.ts +149 -0
  335. package/src/images/index.ts +4 -0
  336. package/src/images/loop.ts +955 -0
  337. package/src/images/plan.ts +143 -0
  338. package/src/images/synthetic-tool.ts +133 -0
  339. package/src/images/types.ts +41 -0
  340. package/src/images/xai-client.ts +141 -0
  341. package/src/images/xai-video-client.ts +163 -0
  342. package/src/index.ts +22 -0
  343. package/src/integrations/config-io.ts +269 -0
  344. package/src/integrations/journal.ts +315 -0
  345. package/src/integrations/merge.ts +135 -0
  346. package/src/integrations/mutation-flight.ts +71 -0
  347. package/src/integrations/native/ownership-preflight.ts +202 -0
  348. package/src/integrations/omp-yaml-source.ts +358 -0
  349. package/src/integrations/owned-refresh.ts +74 -0
  350. package/src/integrations/ownership.ts +111 -0
  351. package/src/integrations/registry.ts +159 -0
  352. package/src/integrations/serialize.ts +314 -0
  353. package/src/integrations/state.ts +361 -0
  354. package/src/integrations/store.ts +103 -0
  355. package/src/integrations/writer-lock.ts +98 -0
  356. package/src/integrations/writer.ts +691 -0
  357. package/src/lab/artifacts/sanitize.ts +586 -0
  358. package/src/lab/artifacts/secure-fs.ts +475 -0
  359. package/src/lab/artifacts/store.ts +310 -0
  360. package/src/lab/automation/budgets.ts +78 -0
  361. package/src/lab/automation/config-persistence.ts +256 -0
  362. package/src/lab/automation/constants.ts +39 -0
  363. package/src/lab/automation/cooldown.ts +103 -0
  364. package/src/lab/automation/dispatch.ts +211 -0
  365. package/src/lab/automation/index.ts +13 -0
  366. package/src/lab/automation/orchestrator.ts +499 -0
  367. package/src/lab/automation/persistence.ts +512 -0
  368. package/src/lab/automation/planner.ts +371 -0
  369. package/src/lab/automation/policy.ts +136 -0
  370. package/src/lab/automation/queue.ts +191 -0
  371. package/src/lab/automation/recovery.ts +24 -0
  372. package/src/lab/automation/route-context.ts +21 -0
  373. package/src/lab/automation/run-key.ts +44 -0
  374. package/src/lab/automation/runs-query.ts +34 -0
  375. package/src/lab/automation/types.ts +160 -0
  376. package/src/lab/conformance/assertion.ts +325 -0
  377. package/src/lab/conformance/digest.ts +22 -0
  378. package/src/lab/conformance/executor.ts +741 -0
  379. package/src/lab/conformance/fixture-provider.ts +27 -0
  380. package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
  381. package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
  382. package/src/lab/conformance/harness-budget.ts +47 -0
  383. package/src/lab/conformance/index.ts +5 -0
  384. package/src/lab/conformance/jcs.ts +64 -0
  385. package/src/lab/conformance/json-pointer.ts +39 -0
  386. package/src/lab/conformance/manifest.ts +180 -0
  387. package/src/lab/conformance/mcp-stub.ts +179 -0
  388. package/src/lab/conformance/negative-controls.ts +164 -0
  389. package/src/lab/conformance/observation.ts +355 -0
  390. package/src/lab/conformance/runner.ts +68 -0
  391. package/src/lab/conformance/sse-normalize.ts +59 -0
  392. package/src/lab/conformance/suite-manifest.ts +78 -0
  393. package/src/lab/conformance/types.ts +214 -0
  394. package/src/lab/constants.ts +126 -0
  395. package/src/lab/digest.ts +64 -0
  396. package/src/lab/events/errors.ts +9 -0
  397. package/src/lab/events/limits.ts +117 -0
  398. package/src/lab/events/types.ts +229 -0
  399. package/src/lab/events/validate.ts +781 -0
  400. package/src/lab/fabric/constants.ts +40 -0
  401. package/src/lab/fabric/executor.ts +492 -0
  402. package/src/lab/fabric/index.ts +80 -0
  403. package/src/lab/fabric/manifest.ts +222 -0
  404. package/src/lab/fabric/observe.ts +489 -0
  405. package/src/lab/fabric/patch.ts +79 -0
  406. package/src/lab/fabric/producer-child.ts +139 -0
  407. package/src/lab/fabric/producer-isolate.ts +276 -0
  408. package/src/lab/fabric/producer-protocol.ts +61 -0
  409. package/src/lab/fabric/scratch.ts +439 -0
  410. package/src/lab/fabric/subject.ts +106 -0
  411. package/src/lab/fabric/types.ts +134 -0
  412. package/src/lab/fabric/verifier.ts +98 -0
  413. package/src/lab/index.ts +54 -0
  414. package/src/lab/ledger/artifact-refs.ts +127 -0
  415. package/src/lab/ledger/invalidation.ts +136 -0
  416. package/src/lab/ledger/purge.ts +310 -0
  417. package/src/lab/ledger/store.ts +532 -0
  418. package/src/lab/live/credential-lease.ts +53 -0
  419. package/src/lab/live/destination.ts +155 -0
  420. package/src/lab/live/executor.ts +336 -0
  421. package/src/lab/live/inert-tools.ts +56 -0
  422. package/src/lab/live/manifest.ts +85 -0
  423. package/src/lab/live/mcp-loopback.ts +57 -0
  424. package/src/lab/live/runner.ts +19 -0
  425. package/src/lab/live/sandbox.ts +61 -0
  426. package/src/lab/live/suite-manifest.ts +41 -0
  427. package/src/lab/live/transport.ts +118 -0
  428. package/src/lab/live/types.ts +197 -0
  429. package/src/lab/observe/from-conformance.ts +301 -0
  430. package/src/lab/observe/from-live.ts +117 -0
  431. package/src/lab/paths.ts +153 -0
  432. package/src/lab/projection/rebuild.ts +495 -0
  433. package/src/lab/projection/schema.ts +135 -0
  434. package/src/lab/projection/verdicts.ts +474 -0
  435. package/src/lab/projection/verification.ts +412 -0
  436. package/src/lab/public/bundle.ts +217 -0
  437. package/src/lab/public/community-authority.ts +175 -0
  438. package/src/lab/public/community-files.ts +29 -0
  439. package/src/lab/public/community.ts +479 -0
  440. package/src/lab/public/file-safety.ts +155 -0
  441. package/src/lab/public/ids.ts +26 -0
  442. package/src/lab/public/index.ts +16 -0
  443. package/src/lab/public/mutation-lock.ts +424 -0
  444. package/src/lab/public/operator.ts +353 -0
  445. package/src/lab/public/origin-purge.ts +79 -0
  446. package/src/lab/public/origin.ts +203 -0
  447. package/src/lab/public/privacy.ts +143 -0
  448. package/src/lab/public/private-file.ts +261 -0
  449. package/src/lab/public/project.ts +124 -0
  450. package/src/lab/public/purge-test-fault.ts +21 -0
  451. package/src/lab/public/purge.ts +223 -0
  452. package/src/lab/public/registry.ts +44 -0
  453. package/src/lab/public/revocation.ts +252 -0
  454. package/src/lab/public/signature.ts +243 -0
  455. package/src/lab/public/storage.ts +105 -0
  456. package/src/lab/public/strict-json.ts +206 -0
  457. package/src/lab/public/time.ts +26 -0
  458. package/src/lab/public/types.ts +172 -0
  459. package/src/lab/public/validate.ts +391 -0
  460. package/src/lab/query/catalog.ts +101 -0
  461. package/src/lab/query/connection.ts +107 -0
  462. package/src/lab/query/constants.ts +4 -0
  463. package/src/lab/query/cursor.ts +132 -0
  464. package/src/lab/query/dto-map.ts +277 -0
  465. package/src/lab/query/errors.ts +22 -0
  466. package/src/lab/query/freshness.ts +53 -0
  467. package/src/lab/query/index.ts +45 -0
  468. package/src/lab/query/latest-observation.ts +59 -0
  469. package/src/lab/query/passive-production.ts +159 -0
  470. package/src/lab/query/queries.ts +444 -0
  471. package/src/lab/query/types.ts +266 -0
  472. package/src/lab/subject/behavior-fingerprint.ts +77 -0
  473. package/src/lab/subject/installation-salt.ts +112 -0
  474. package/src/lab/subject/protocol-subject.ts +80 -0
  475. package/src/lab/subject/route-subject.ts +74 -0
  476. package/src/lib/abort.ts +146 -0
  477. package/src/lib/admin-secrets.ts +25 -0
  478. package/src/lib/admission.ts +83 -0
  479. package/src/lib/app-owned-memory-stores.ts +195 -0
  480. package/src/lib/app-owned-memory.ts +265 -0
  481. package/src/lib/bounded-body.ts +346 -0
  482. package/src/lib/bun-binary-validator.d.mts +3 -0
  483. package/src/lib/bun-binary-validator.mjs +18 -0
  484. package/src/lib/bun-runtime.ts +184 -0
  485. package/src/lib/bun-stream-caps.ts +130 -0
  486. package/src/lib/codex-restart-contract.ts +120 -0
  487. package/src/lib/config-ownership.ts +364 -0
  488. package/src/lib/crash-guard.ts +344 -0
  489. package/src/lib/debug-log-buffer.ts +83 -0
  490. package/src/lib/debug-settings.ts +108 -0
  491. package/src/lib/debug.ts +31 -0
  492. package/src/lib/destination-policy.ts +380 -0
  493. package/src/lib/errors.ts +406 -0
  494. package/src/lib/eventstream-decoder.ts +253 -0
  495. package/src/lib/fabric-task-execution-authority.ts +7 -0
  496. package/src/lib/fabric-task-host.ts +29 -0
  497. package/src/lib/gcp-adc.ts +341 -0
  498. package/src/lib/injection-debug-log.ts +58 -0
  499. package/src/lib/lab-activation.ts +223 -0
  500. package/src/lib/lab-live-execution-authority.ts +13 -0
  501. package/src/lib/lab-live-host.ts +30 -0
  502. package/src/lib/lab-live-pinned-sender.ts +56 -0
  503. package/src/lib/lab-live-route-production.ts +130 -0
  504. package/src/lib/lab-passive-linker-registration.ts +26 -0
  505. package/src/lib/local-management-attestation.ts +51 -0
  506. package/src/lib/local-management-capability.ts +100 -0
  507. package/src/lib/local-provider-reload-contract.ts +100 -0
  508. package/src/lib/open-url.ts +25 -0
  509. package/src/lib/optional-shutdown-hooks.ts +57 -0
  510. package/src/lib/pinned-http.ts +270 -0
  511. package/src/lib/privacy.ts +20 -0
  512. package/src/lib/process-control.ts +168 -0
  513. package/src/lib/provider-outbound.ts +210 -0
  514. package/src/lib/provider-url.ts +14 -0
  515. package/src/lib/proxy-env.ts +18 -0
  516. package/src/lib/redact.ts +521 -0
  517. package/src/lib/retry-after.ts +55 -0
  518. package/src/lib/self-launch-argv.ts +15 -0
  519. package/src/lib/server-resource-ownership.ts +71 -0
  520. package/src/lib/service-secrets.ts +25 -0
  521. package/src/lib/shadow-call.ts +61 -0
  522. package/src/lib/sidecar-tracker.ts +52 -0
  523. package/src/lib/sse-decoder.ts +364 -0
  524. package/src/lib/state-store-registrations.ts +119 -0
  525. package/src/lib/state-store-sweeper.ts +184 -0
  526. package/src/lib/system-restart-contract.ts +73 -0
  527. package/src/lib/test-home-guard.ts +90 -0
  528. package/src/lib/token-estimate.ts +86 -0
  529. package/src/lib/tool-argument-integers.ts +202 -0
  530. package/src/lib/translator-budget.ts +400 -0
  531. package/src/lib/upstream-http-version.ts +57 -0
  532. package/src/lib/upstream-reachability.ts +95 -0
  533. package/src/lib/upstream-retry.ts +392 -0
  534. package/src/lib/win-exec.ts +115 -0
  535. package/src/lib/win-paths.ts +68 -0
  536. package/src/lib/windows-atomic-replace.ts +156 -0
  537. package/src/lib/windows-elevation.ts +773 -0
  538. package/src/lib/windows-secret-acl.ts +854 -0
  539. package/src/lib/windows-service-wrappers.ts +72 -0
  540. package/src/lib/windows-text.ts +106 -0
  541. package/src/lib/windows-user-principal.ts +341 -0
  542. package/src/lib/winsw.ts +403 -0
  543. package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
  544. package/src/oauth/account-import/index.ts +15 -0
  545. package/src/oauth/account-import/parser.ts +83 -0
  546. package/src/oauth/account-import/registry.ts +18 -0
  547. package/src/oauth/account-import/service.ts +75 -0
  548. package/src/oauth/account-import/types.ts +91 -0
  549. package/src/oauth/anthropic-routing.ts +594 -0
  550. package/src/oauth/anthropic.ts +188 -0
  551. package/src/oauth/antigravity-routing.ts +151 -0
  552. package/src/oauth/callback-server.ts +300 -0
  553. package/src/oauth/chatgpt.ts +161 -0
  554. package/src/oauth/command-code.ts +239 -0
  555. package/src/oauth/cursor.ts +252 -0
  556. package/src/oauth/github-copilot.ts +428 -0
  557. package/src/oauth/google-antigravity.ts +262 -0
  558. package/src/oauth/health.ts +407 -0
  559. package/src/oauth/index.ts +1504 -0
  560. package/src/oauth/key-providers.ts +124 -0
  561. package/src/oauth/kimi.ts +227 -0
  562. package/src/oauth/kiro-credentials.ts +726 -0
  563. package/src/oauth/kiro.ts +621 -0
  564. package/src/oauth/local-token-detect.ts +130 -0
  565. package/src/oauth/log.ts +50 -0
  566. package/src/oauth/login-cli.ts +223 -0
  567. package/src/oauth/nous.ts +798 -0
  568. package/src/oauth/pkce.ts +15 -0
  569. package/src/oauth/store.ts +728 -0
  570. package/src/oauth/token-guardian.ts +309 -0
  571. package/src/oauth/types.ts +62 -0
  572. package/src/oauth/xai.ts +241 -0
  573. package/src/providers/alibaba-region-backup.ts +75 -0
  574. package/src/providers/alibaba-region-migration.ts +156 -0
  575. package/src/providers/alibaba-region-startup.ts +36 -0
  576. package/src/providers/antigravity-models.ts +695 -0
  577. package/src/providers/antigravity-quota.ts +216 -0
  578. package/src/providers/api-keys.ts +140 -0
  579. package/src/providers/base-url-choices.ts +74 -0
  580. package/src/providers/codex-capacity.ts +292 -0
  581. package/src/providers/command-code-efforts.ts +144 -0
  582. package/src/providers/context-cap.ts +82 -0
  583. package/src/providers/cursor-pool.ts +72 -0
  584. package/src/providers/derive.ts +586 -0
  585. package/src/providers/fastwire.ts +501 -0
  586. package/src/providers/free-directory.ts +187 -0
  587. package/src/providers/github-copilot-transport.ts +56 -0
  588. package/src/providers/google-vertex-location.ts +14 -0
  589. package/src/providers/key-failover.ts +271 -0
  590. package/src/providers/kiro-models.ts +67 -0
  591. package/src/providers/label.ts +19 -0
  592. package/src/providers/model-discovery-limits.ts +16 -0
  593. package/src/providers/model-discovery.ts +449 -0
  594. package/src/providers/model-rename-migration.ts +255 -0
  595. package/src/providers/model-rename-startup.ts +28 -0
  596. package/src/providers/openai-sidecar.ts +243 -0
  597. package/src/providers/openai-tier-startup.ts +56 -0
  598. package/src/providers/openai-tiers.ts +423 -0
  599. package/src/providers/openai-virtual-models.ts +83 -0
  600. package/src/providers/opencode-zen-rate-limit.ts +102 -0
  601. package/src/providers/openrouter-routing.ts +102 -0
  602. package/src/providers/provider-id-rewrite.ts +185 -0
  603. package/src/providers/quota.ts +2345 -0
  604. package/src/providers/registry.ts +2918 -0
  605. package/src/providers/replit/constants.ts +27 -0
  606. package/src/providers/replit/derive.ts +85 -0
  607. package/src/providers/replit/headers.ts +28 -0
  608. package/src/providers/replit/origin.ts +55 -0
  609. package/src/providers/replit/pair-install-response.ts +72 -0
  610. package/src/providers/replit/probe.ts +199 -0
  611. package/src/providers/replit/setup.ts +350 -0
  612. package/src/providers/request-pacing.ts +310 -0
  613. package/src/providers/service-tier.ts +277 -0
  614. package/src/providers/slug-codec.ts +103 -0
  615. package/src/providers/static-model-discovery.ts +86 -0
  616. package/src/providers/xai-responses-opt-in.ts +15 -0
  617. package/src/providers/xai-transport.ts +148 -0
  618. package/src/reasoning-effort.ts +183 -0
  619. package/src/responses/compaction.ts +142 -0
  620. package/src/responses/custom-tool-compat.ts +266 -0
  621. package/src/responses/hosted-tool-policy.ts +9 -0
  622. package/src/responses/namespace-tool-compat.ts +355 -0
  623. package/src/responses/parser.ts +838 -0
  624. package/src/responses/provider-continuation.ts +98 -0
  625. package/src/responses/provider-opaque-metadata.ts +73 -0
  626. package/src/responses/reasoning-envelope.ts +60 -0
  627. package/src/responses/reasoning-replay-cache.ts +426 -0
  628. package/src/responses/schema.ts +165 -0
  629. package/src/responses/spill-store.ts +459 -0
  630. package/src/responses/state.ts +1433 -0
  631. package/src/responses/thought-signature-replay.ts +347 -0
  632. package/src/responses/tool-groups.ts +19 -0
  633. package/src/responses/tool-search-compat.ts +301 -0
  634. package/src/responses/truncated-stop-reason.ts +60 -0
  635. package/src/router.ts +761 -0
  636. package/src/routing/analytics.ts +378 -0
  637. package/src/routing/capability.ts +244 -0
  638. package/src/routing/compatibility/assemble.ts +73 -0
  639. package/src/routing/compatibility/behavior.ts +278 -0
  640. package/src/routing/compatibility/catalog.ts +99 -0
  641. package/src/routing/compatibility/endpoint.ts +52 -0
  642. package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
  643. package/src/routing/compatibility/policy.ts +181 -0
  644. package/src/routing/compatibility/provider-slot.ts +56 -0
  645. package/src/routing/compatibility/reader.ts +110 -0
  646. package/src/routing/compatibility/subject.ts +191 -0
  647. package/src/routing/compatibility/types.ts +64 -0
  648. package/src/routing/compatibility/version.ts +104 -0
  649. package/src/routing/cost.ts +77 -0
  650. package/src/routing/evaluator.ts +495 -0
  651. package/src/routing/health.ts +412 -0
  652. package/src/routing/history/cursor.ts +43 -0
  653. package/src/routing/history/indexer.ts +605 -0
  654. package/src/routing/history/schema.ts +72 -0
  655. package/src/routing/profile-namespace.ts +15 -0
  656. package/src/routing/profile.ts +547 -0
  657. package/src/routing/quota.ts +145 -0
  658. package/src/routing/request-evidence.ts +45 -0
  659. package/src/routing/trace.ts +776 -0
  660. package/src/server/adapter-resolve.ts +53 -0
  661. package/src/server/auth-cors.ts +751 -0
  662. package/src/server/background-lifecycle.ts +182 -0
  663. package/src/server/chat-completions.ts +442 -0
  664. package/src/server/chat-native-sse.ts +331 -0
  665. package/src/server/chat-native.ts +426 -0
  666. package/src/server/claude-messages.ts +1030 -0
  667. package/src/server/direct-local-http.ts +347 -0
  668. package/src/server/effort-policy.ts +190 -0
  669. package/src/server/github-copilot-responses-repair.ts +338 -0
  670. package/src/server/gui-static.ts +152 -0
  671. package/src/server/image-retry.ts +42 -0
  672. package/src/server/images.ts +568 -0
  673. package/src/server/index.ts +1813 -0
  674. package/src/server/lifecycle.ts +498 -0
  675. package/src/server/live.ts +717 -0
  676. package/src/server/local-management-read-client.ts +90 -0
  677. package/src/server/local-provider-reload-client.ts +137 -0
  678. package/src/server/management/agent-settings-routes.ts +1433 -0
  679. package/src/server/management/api-access.ts +141 -0
  680. package/src/server/management/api-key-usage.ts +193 -0
  681. package/src/server/management/body.ts +41 -0
  682. package/src/server/management/combo-routes.ts +263 -0
  683. package/src/server/management/config-routes.ts +835 -0
  684. package/src/server/management/context.ts +113 -0
  685. package/src/server/management/integration-routes.ts +498 -0
  686. package/src/server/management/lab-automation-routes.ts +206 -0
  687. package/src/server/management/lab-routes.ts +563 -0
  688. package/src/server/management/logs-usage-routes.ts +586 -0
  689. package/src/server/management/model-routes.ts +560 -0
  690. package/src/server/management/model-rows.ts +163 -0
  691. package/src/server/management/native-integration-routes.ts +769 -0
  692. package/src/server/management/oauth-account-routes.ts +637 -0
  693. package/src/server/management/provider-capability-config.ts +48 -0
  694. package/src/server/management/provider-routes.ts +1033 -0
  695. package/src/server/management/replit-provider-routes.ts +86 -0
  696. package/src/server/management/request-history-routes.ts +191 -0
  697. package/src/server/management/routing-analytics-routes.ts +74 -0
  698. package/src/server/management/routing-profile-routes.ts +385 -0
  699. package/src/server/management/shared.ts +286 -0
  700. package/src/server/management/sidebar-routes.ts +106 -0
  701. package/src/server/management/storage-log-guard-routes.ts +186 -0
  702. package/src/server/management/sync-response.ts +69 -0
  703. package/src/server/management/system-restart.ts +435 -0
  704. package/src/server/management/system-routes.ts +194 -0
  705. package/src/server/management/usage-summary-cache.ts +94 -0
  706. package/src/server/management/vision-sidecar-options.ts +167 -0
  707. package/src/server/management/web-search-sidecar-options.ts +120 -0
  708. package/src/server/management-api.ts +314 -0
  709. package/src/server/management-auth.ts +482 -0
  710. package/src/server/memory-watchdog.ts +156 -0
  711. package/src/server/passive-route-linker.ts +66 -0
  712. package/src/server/port-reclaim.ts +307 -0
  713. package/src/server/ports.ts +156 -0
  714. package/src/server/proxy-liveness.ts +328 -0
  715. package/src/server/readiness.ts +99 -0
  716. package/src/server/relay-eager.ts +353 -0
  717. package/src/server/relay.ts +1209 -0
  718. package/src/server/request-decompress.ts +239 -0
  719. package/src/server/request-log-conversation.ts +168 -0
  720. package/src/server/request-log.ts +1259 -0
  721. package/src/server/responses/agent-task-recovery-cache.ts +143 -0
  722. package/src/server/responses/agent-task-recovery.ts +465 -0
  723. package/src/server/responses/collaboration.ts +551 -0
  724. package/src/server/responses/compact.ts +771 -0
  725. package/src/server/responses/core.ts +5389 -0
  726. package/src/server/responses/empty-completion-guard.ts +276 -0
  727. package/src/server/responses/encrypted-payload.ts +331 -0
  728. package/src/server/responses/fetch-helpers.ts +232 -0
  729. package/src/server/responses/input-admission.ts +185 -0
  730. package/src/server/responses/pacing-overload.ts +13 -0
  731. package/src/server/responses/passthrough-error.ts +78 -0
  732. package/src/server/responses/policy-fallback.ts +178 -0
  733. package/src/server/responses/responses-field-backfill.ts +251 -0
  734. package/src/server/responses/terminal-guard.ts +251 -0
  735. package/src/server/responses/upstream-error.ts +53 -0
  736. package/src/server/responses/ws-upstream.ts +308 -0
  737. package/src/server/responses-custom-tool-repair.ts +282 -0
  738. package/src/server/responses-image-gen-repair.ts +132 -0
  739. package/src/server/responses-item-id-repair.ts +272 -0
  740. package/src/server/responses-json-events.ts +90 -0
  741. package/src/server/responses-model-rewrite.ts +29 -0
  742. package/src/server/responses-reasoning-summary-rewrite.ts +178 -0
  743. package/src/server/responses-snapshot-repair.ts +621 -0
  744. package/src/server/responses-terminal-repair.ts +342 -0
  745. package/src/server/responses-tool-search-repair.ts +267 -0
  746. package/src/server/responses-undeclared-tool-guard.ts +153 -0
  747. package/src/server/responses.ts +25 -0
  748. package/src/server/search.ts +201 -0
  749. package/src/server/sse-frame-buffer.ts +292 -0
  750. package/src/server/sse-payload-rewrite.ts +263 -0
  751. package/src/server/startup-action-control.ts +315 -0
  752. package/src/server/startup-health-cache.ts +131 -0
  753. package/src/server/system-env.ts +484 -0
  754. package/src/server/windows-tcp-drop.ts +184 -0
  755. package/src/server/windows-tray-control.ts +41 -0
  756. package/src/server/ws-bridge.ts +472 -0
  757. package/src/service-manager-probe.ts +892 -0
  758. package/src/service.ts +3575 -0
  759. package/src/sidecar/auth.ts +92 -0
  760. package/src/sidecar/candidates.ts +83 -0
  761. package/src/stall-timeout.ts +20 -0
  762. package/src/storage/cleanup-job.ts +57 -0
  763. package/src/storage/cleanup.ts +3085 -0
  764. package/src/storage/policy-job.ts +457 -0
  765. package/src/storage/policy-scheduler.ts +40 -0
  766. package/src/storage/policy-worker.ts +59 -0
  767. package/src/storage/policy.ts +527 -0
  768. package/src/storage/restore-job.ts +299 -0
  769. package/src/storage/restore-worker.ts +58 -0
  770. package/src/storage/scanner.ts +238 -0
  771. package/src/storage/storage-mutation-coordinator.ts +139 -0
  772. package/src/storage/worker-lifecycle.ts +215 -0
  773. package/src/tray/assets/opencodex-tray-offline.ico +0 -0
  774. package/src/tray/assets/opencodex-tray-online.ico +0 -0
  775. package/src/tray/assets/opencodex-tray-warning.ico +0 -0
  776. package/src/tray/assets/opencodex-tray.png +0 -0
  777. package/src/tray/windows-tray.ps1 +364 -0
  778. package/src/tray/windows.ts +757 -0
  779. package/src/types/accounts.ts +37 -0
  780. package/src/types/config.ts +876 -0
  781. package/src/types/provider.ts +545 -0
  782. package/src/types/request.ts +384 -0
  783. package/src/types/tools.ts +131 -0
  784. package/src/types/wire.ts +80 -0
  785. package/src/types.ts +106 -0
  786. package/src/update/badge.ts +72 -0
  787. package/src/update/index.ts +415 -0
  788. package/src/update/job.ts +1887 -0
  789. package/src/update/notify.ts +263 -0
  790. package/src/update/npm-cache-preflight.d.mts +47 -0
  791. package/src/update/npm-cache-preflight.mjs +201 -0
  792. package/src/update/npm-invocation.d.mts +23 -0
  793. package/src/update/npm-invocation.mjs +94 -0
  794. package/src/update/transactional-install.d.mts +22 -0
  795. package/src/update/transactional-install.mjs +259 -0
  796. package/src/update/tray-update-plan.d.mts +18 -0
  797. package/src/update/tray-update-plan.mjs +38 -0
  798. package/src/usage/cost.ts +625 -0
  799. package/src/usage/debug.ts +97 -0
  800. package/src/usage/expected-prices.ts +416 -0
  801. package/src/usage/log.ts +1223 -0
  802. package/src/usage/summary.ts +753 -0
  803. package/src/usage/totals.ts +14 -0
  804. package/src/usage/user-cost-overlay-reconciler.ts +313 -0
  805. package/src/usage/user-cost-overlays.ts +314 -0
  806. package/src/vision/anthropic-describe.ts +189 -0
  807. package/src/vision/backends.ts +97 -0
  808. package/src/vision/describe.ts +131 -0
  809. package/src/vision/eligibility.ts +250 -0
  810. package/src/vision/index.ts +681 -0
  811. package/src/vision/reasoning.ts +55 -0
  812. package/src/vision/routed-describe.ts +175 -0
  813. package/src/vision/timeout-bounds.ts +9 -0
  814. package/src/web-search/anthropic-executor.ts +195 -0
  815. package/src/web-search/backends.ts +108 -0
  816. package/src/web-search/exa-executor.ts +88 -0
  817. package/src/web-search/executor.ts +113 -0
  818. package/src/web-search/format-result.ts +89 -0
  819. package/src/web-search/gemini-executor.ts +141 -0
  820. package/src/web-search/index.ts +331 -0
  821. package/src/web-search/loop.ts +896 -0
  822. package/src/web-search/parse.ts +315 -0
  823. package/src/web-search/progress-stream.ts +342 -0
  824. package/src/web-search/sources.ts +60 -0
  825. package/src/web-search/synthetic-tool.ts +47 -0
  826. package/src/web-search/xai-executor.ts +219 -0
@@ -0,0 +1,1888 @@
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 { clearCodexAccountPin, codexAccountPriorityLookup, pinnedCodexAccountId } from "./account-priority";
7
+ import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "./account-usability";
8
+ import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
9
+ import {
10
+ POOL_KEY_CODEX,
11
+ normalizeAccountPoolStickyLimit,
12
+ normalizeAccountPoolStrategy,
13
+ notePoolRotationFailure,
14
+ notePoolRotationSuccess,
15
+ peekRoundRobinAccount,
16
+ pickRoundRobinAccount,
17
+ seedPoolRotationAccount,
18
+ selectPriorityTier,
19
+ } from "./pool-rotation";
20
+ import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota";
21
+ import { isThirtyDayOnlyCodexPlan } from "./plan";
22
+ import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "./main-account";
23
+ import { isSelectableCodexPoolAccount } from "./account-id";
24
+ import type { OcxConfig } from "../types";
25
+ import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
26
+ import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
27
+ import { retainedUtf8Bytes } from "../lib/admission";
28
+ import { recordUpstreamHostFailure } from "./upstream-host-health";
29
+
30
+ type ThreadAffinityEntry = {
31
+ accountId: string;
32
+ generation: number;
33
+ createdAt: number;
34
+ lastUsedAt: number;
35
+ // Last time the bound account's quota threshold was re-evaluated for this
36
+ // thread (interval-gated to avoid per-request flapping). See REEVAL_INTERVAL_MS.
37
+ lastReevalAt: number;
38
+ };
39
+
40
+ export type CodexThreadResolution =
41
+ | { status: "selected"; accountId: string }
42
+ | { status: "none" }
43
+ | { status: "expired"; accountId: string };
44
+
45
+ /**
46
+ * Process-local cursor for automatic RR/fill-first (and quota-429 when not
47
+ * sync-writing) picks. Keeps unrelated `saveConfig` from persisting transient
48
+ * rotation as the operator's `activeCodexAccountId`. Manual selection clears it
49
+ * so disk/`config.activeCodexAccountId` remains authoritative.
50
+ */
51
+ let runtimeActiveCodexAccountId: string | undefined;
52
+
53
+ type CodexUpstreamHealth = {
54
+ consecutiveFailures: number;
55
+ /** Consecutive healthy terminals observed while recovering from escalation level 2+. */
56
+ consecutiveSuccesses?: number;
57
+ lastFailureStatus?: number;
58
+ lastFailureAt?: number;
59
+ /** Hard cooldown (quota 429). Survives a later 2xx; blocks auth + selection. */
60
+ cooldownUntil?: number;
61
+ /** When the current cooldown was recorded; origin of the probe interval clock. */
62
+ cooldownSince?: number;
63
+ /**
64
+ * What produced the cooldown. An explicit Retry-After is a literal retry
65
+ * directive and is never probed; a quota resetAt only announces a window
66
+ * refresh, so it may be probed early (#433).
67
+ */
68
+ cooldownSource?: CodexCooldownSource;
69
+ /**
70
+ * Bumped on every cooldown write. A probe lease records the generation it was
71
+ * issued for so a lease cannot clear a cooldown that a later 429 replaced.
72
+ */
73
+ cooldownGeneration?: number;
74
+ /**
75
+ * Identity of the in-flight probe. A cooled-down account sends no traffic, so
76
+ * no organic 2xx can prove recovery; only the outcome carrying this id may
77
+ * clear the cooldown.
78
+ */
79
+ probeLeaseId?: string;
80
+ /** Cooldown generation at the moment the lease was granted. */
81
+ probeLeaseGeneration?: number;
82
+ /** Last probe grant or conclusion; paces the probe interval. */
83
+ lastProbeAt?: number;
84
+ /**
85
+ * Soft avoid after connect_error / timeout / transient 5xx. Cleared on 2xx.
86
+ * Blocks pool selection + thread affinity reuse so a sticky session can leave a
87
+ * flaky account without throwing CodexAccountCooldownError (hard-only).
88
+ */
89
+ softAvoidUntil?: number;
90
+ };
91
+
92
+ const CODEX_DEFAULT_QUOTA_COOLDOWN_MS = 60_000;
93
+ const CODEX_MAX_QUOTA_COOLDOWN_MS = 24 * 60 * 60_000;
94
+ /**
95
+ * A weekly/monthly quota `resetAt` announces when the window refreshes; it is not
96
+ * a "come back after this" directive like Retry-After. Plan quota routinely frees
97
+ * up long before the advertised reset, so cap reset-derived cooldowns far below
98
+ * the Retry-After ceiling (#433).
99
+ */
100
+ const CODEX_MAX_RESET_DERIVED_COOLDOWN_MS = 15 * 60_000;
101
+ /** Minimum gap between probe leases for one cooled-down account. */
102
+ export const CODEX_QUOTA_PROBE_INTERVAL_MS = 5 * 60_000;
103
+ export const CODEX_FAILURE_WINDOW_MS = 5 * 60_000;
104
+ /** How long a transient failure keeps the account out of pool selection. */
105
+ export const CODEX_TRANSIENT_SOFT_AVOID_MS = 30_000;
106
+ const CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS = [
107
+ CODEX_TRANSIENT_SOFT_AVOID_MS,
108
+ 2 * 60_000,
109
+ 10 * 60_000,
110
+ 30 * 60_000,
111
+ ] as const;
112
+ export const CODEX_THREAD_AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000;
113
+ export const CODEX_THREAD_AFFINITY_MAX_ENTRIES = 2048;
114
+ const MAX_AFFINITY_COMPONENT_BYTES = 512;
115
+ // Min interval between quota threshold re-evaluations for a single bound thread.
116
+ // Well under the 5h/weekly quota windows, but enough to stop per-request flapping.
117
+ export const CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS = 60_000;
118
+
119
+ const upstreamHealth = new Map<string, CodexUpstreamHealth>();
120
+ /**
121
+ * Reset-derived 429s can describe a quota owned by one native model family,
122
+ * rather than the whole ChatGPT account. Keep those advisory cooldowns apart
123
+ * from account-wide Retry-After/default throttles and transient health.
124
+ */
125
+ const quotaScopedHealth = new Map<string, Map<CodexQuotaScope, CodexUpstreamHealth>>();
126
+ let lastReconciledGeneration = 0;
127
+ let liveHealthAccountIds = new Set<string>();
128
+
129
+ export type CodexUpstreamOutcome = number | "connect_error" | "timeout" | "connect_neutral";
130
+ export type CodexUpstreamOutcomeClass = "success" | "credential"
131
+ | "workspace" | "quota" | "transient" | "caller" | "neutral" | "unknown";
132
+ export type CodexCooldownSource = "retry-after" | "reset-derived" | "default";
133
+ /**
134
+ * Native Codex quota groups known to be independent upstream. Keep the mapping
135
+ * deliberately conservative: unlisted models share the normal native group.
136
+ * Add a new explicit group here only when its independent upstream quota is
137
+ * confirmed, so shared limits never receive cross-model bypasses.
138
+ */
139
+ export type CodexQuotaScope = "shared" | "spark";
140
+
141
+ export type CodexQuotaRecoveryProbeClaim = {
142
+ accountId: string;
143
+ scope?: CodexQuotaScope;
144
+ leaseId: string;
145
+ cooldownGeneration: number;
146
+ credentialGeneration: number;
147
+ /** Claim-time `replacedAt`; unchanged after a probe-owned refresh, stamped on external replacement. */
148
+ credentialReplacedAt?: number;
149
+ };
150
+
151
+ export type CodexQuotaRecoveryProbeProof = {
152
+ credentialGeneration?: number;
153
+ };
154
+
155
+ /**
156
+ * Requests without a resolved native model retain the historic one-account-per-
157
+ * thread behavior. Requests with a known quota scope get an independent
158
+ * affinity so a Spark failover cannot displace the same thread's Terra/Luna
159
+ * account (and vice versa).
160
+ */
161
+ type ThreadAffinityScope = CodexQuotaScope | "legacy";
162
+ const LEGACY_THREAD_AFFINITY_SCOPE = "legacy" as const;
163
+ const threadAccountMap = new Map<string, Map<ThreadAffinityScope, ThreadAffinityEntry>>();
164
+
165
+ const NATIVE_MODEL_QUOTA_SCOPES: Readonly<Record<string, CodexQuotaScope>> = {
166
+ "gpt-5.3-codex-spark": "spark",
167
+ };
168
+
169
+ // A thread can have one legacy binding plus one binding for each known scope.
170
+ // This upper-bound guard avoids an exact map scan until it can be over capacity.
171
+ const MAX_THREAD_AFFINITY_SCOPES = new Set([
172
+ LEGACY_THREAD_AFFINITY_SCOPE,
173
+ "shared",
174
+ ...Object.values(NATIVE_MODEL_QUOTA_SCOPES),
175
+ ]).size;
176
+
177
+ export function codexQuotaScopeForModel(modelId: string | undefined): CodexQuotaScope | undefined {
178
+ if (!modelId?.trim()) return undefined;
179
+ return NATIVE_MODEL_QUOTA_SCOPES[modelId.trim().toLowerCase()] ?? "shared";
180
+ }
181
+
182
+ /** Independent quota groups must not mutate the shared active-account cursor. */
183
+ function isIndependentCodexQuotaScope(quotaScope?: CodexQuotaScope): boolean {
184
+ return quotaScope !== undefined && quotaScope !== "shared";
185
+ }
186
+
187
+ function codexPoolKeyForScope(quotaScope?: CodexQuotaScope): string {
188
+ return isIndependentCodexQuotaScope(quotaScope) ? `${POOL_KEY_CODEX}:${quotaScope}` : POOL_KEY_CODEX;
189
+ }
190
+
191
+ export type CodexUpstreamOutcomeMeta = {
192
+ retryAfter?: string | null;
193
+ resetAt?: unknown | unknown[];
194
+ now?: number;
195
+ /** (provider, host) ledger key for account-neutral reachability failures (#914). */
196
+ hostKey?: string;
197
+ /**
198
+ * Upstream denial evidence for a 403. A workspace/entitlement denial means the CREDENTIAL
199
+ * is fine and the account simply cannot reach this workspace, so it must not be quarantined
200
+ * for reauthentication (#1789). Absent evidence keeps the historical credential handling.
201
+ */
202
+ denial?: "workspace" | "entitlement";
203
+ /** Stable transport code recorded alongside a neutral host failure. */
204
+ lastFailureCode?: string;
205
+ /** Native model selected for this request; used only for confirmed scoped quotas. */
206
+ modelId?: string;
207
+ /** When set, clears affinity for this thread immediately on transient failure. */
208
+ threadId?: string | null;
209
+ /**
210
+ * Suppress Pool rotation and quota/transient affinity mutations for an account-qualified
211
+ * request. Credential failures still sweep stale affinities because reauthentication is
212
+ * account-wide.
213
+ */
214
+ fixedAccount?: boolean;
215
+ /**
216
+ * Probe lease held by this request, when it was admitted through an active
217
+ * quota cooldown. Only the outcome carrying the current lease may clear the
218
+ * cooldown (#433).
219
+ */
220
+ probeLeaseId?: string;
221
+ /** Scope of `probeLeaseId` when it was granted against a model-scoped cooldown. */
222
+ probeQuotaScope?: CodexQuotaScope;
223
+ /**
224
+ * Already-chosen alternate for same-request 429 retry. When set, promotion
225
+ * reuses this account instead of calling {@link pickAlternateCodexAccount}
226
+ * again (which would advance a round-robin ring twice).
227
+ */
228
+ promoteAccountId?: string;
229
+ /** Generation captured when this routed account was selected. */
230
+ writerGeneration?: number;
231
+ };
232
+
233
+ function hasConfiguredPoolAccount(
234
+ config: OcxConfig,
235
+ accountId: string,
236
+ selectionOptions?: CodexAccountUsabilityOptions,
237
+ ): boolean {
238
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) {
239
+ return isCodexAccountUsable(config, accountId, selectionOptions);
240
+ }
241
+ return (config.codexAccounts ?? [])
242
+ .some(account => isSelectableCodexPoolAccount(account) && account.id === accountId);
243
+ }
244
+
245
+ export function listLiveCodexAccountIds(config: OcxConfig): ReadonlySet<string> {
246
+ const ids = new Set((config.codexAccounts ?? []).map(account => account.id));
247
+ const openai = config.providers.openai;
248
+ if (openai && openai.disabled !== true && isCanonicalOpenAiForwardProvider(openai)) {
249
+ ids.add(MAIN_CODEX_ACCOUNT_ID);
250
+ }
251
+ return ids;
252
+ }
253
+
254
+ export function clearThreadAccountMap(): void {
255
+ threadAccountMap.clear();
256
+ }
257
+
258
+ export function clearThreadAccountMapForAccount(accountId: string): void {
259
+ for (const [threadId, affinities] of threadAccountMap) {
260
+ for (const [scope, entry] of affinities) {
261
+ if (entry.accountId === accountId) affinities.delete(scope);
262
+ }
263
+ if (affinities.size === 0) threadAccountMap.delete(threadId);
264
+ }
265
+ }
266
+
267
+ export function clearCodexUpstreamHealth(): void {
268
+ upstreamHealth.clear();
269
+ quotaScopedHealth.clear();
270
+ runtimeActiveCodexAccountId = undefined;
271
+ }
272
+
273
+ export function clearCodexUpstreamHealthForAccount(accountId: string): void {
274
+ upstreamHealth.delete(accountId);
275
+ quotaScopedHealth.delete(accountId);
276
+ }
277
+
278
+ export function reconcileCodexRoutingHealth(context: GenerationContext): number {
279
+ if (context.generation <= lastReconciledGeneration) return 0;
280
+ let removed = 0;
281
+ for (const accountId of upstreamHealth.keys()) {
282
+ if (context.codexAccountIds.has(accountId)) continue;
283
+ upstreamHealth.delete(accountId);
284
+ removed += 1;
285
+ }
286
+ for (const accountId of quotaScopedHealth.keys()) {
287
+ if (context.codexAccountIds.has(accountId)) continue;
288
+ quotaScopedHealth.delete(accountId);
289
+ removed += 1;
290
+ }
291
+ liveHealthAccountIds = new Set(context.codexAccountIds);
292
+ lastReconciledGeneration = context.generation;
293
+ return removed;
294
+ }
295
+
296
+ export function getCodexUpstreamHealth(
297
+ accountId: string,
298
+ ): CodexUpstreamHealth | null {
299
+ return upstreamHealth.get(accountId) ?? null;
300
+ }
301
+
302
+ function scopedHealthFor(accountId: string, scope: CodexQuotaScope): CodexUpstreamHealth | undefined {
303
+ return quotaScopedHealth.get(accountId)?.get(scope);
304
+ }
305
+
306
+ function setScopedHealth(accountId: string, scope: CodexQuotaScope, health: CodexUpstreamHealth): void {
307
+ let scopes = quotaScopedHealth.get(accountId);
308
+ if (!scopes) {
309
+ scopes = new Map();
310
+ quotaScopedHealth.set(accountId, scopes);
311
+ }
312
+ scopes.set(scope, health);
313
+ }
314
+
315
+ function deleteScopedHealth(accountId: string, scope: CodexQuotaScope): void {
316
+ const scopes = quotaScopedHealth.get(accountId);
317
+ if (!scopes) return;
318
+ scopes.delete(scope);
319
+ if (scopes.size === 0) quotaScopedHealth.delete(accountId);
320
+ }
321
+
322
+ export function computeCodexUsageScore(quota: {
323
+ weeklyPercent?: number;
324
+ monthlyPercent?: number;
325
+ shortPercent?: number;
326
+ } | null, plan?: unknown): number {
327
+ if (!quota) return CODEX_UNKNOWN_USAGE_SCORE;
328
+ const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
329
+ const longWindows = isThirtyDayOnlyCodexPlan(plan)
330
+ ? [quota.monthlyPercent]
331
+ : [quota.weeklyPercent, quota.monthlyPercent];
332
+ const knownLong = longWindows.filter(finite);
333
+ // The short burst window only REFINES a known long-window position; it cannot stand in for
334
+ // one. A snapshot carrying just `shortPercent: 0` would otherwise score a flat 0 and make an
335
+ // account whose weekly/monthly usage is entirely unverified look like the emptiest in the
336
+ // pool, so `pickLowestUsageAmong` would send every request to it. Unknown has to stay
337
+ // unknown until a governing window is actually observed.
338
+ if (knownLong.length === 0) return CODEX_UNKNOWN_USAGE_SCORE;
339
+ const values = finite(quota.shortPercent) ? [...knownLong, quota.shortPercent] : knownLong;
340
+ return Math.max(...values);
341
+ }
342
+
343
+ export function classifyCodexUpstreamOutcome(
344
+ outcome: CodexUpstreamOutcome,
345
+ denial?: "workspace" | "entitlement",
346
+ ): CodexUpstreamOutcomeClass {
347
+ if (outcome === "connect_neutral") return "neutral";
348
+ if (outcome === "connect_error" || outcome === "timeout") return "transient";
349
+ if (!Number.isFinite(outcome)) return "unknown";
350
+ if (outcome >= 200 && outcome < 300) return "success";
351
+ // Explicit 3xx policy (#914): a redirect response is relayed as-is and is
352
+ // never account or host health evidence — it proves the host is reachable
353
+ // and says nothing about the credential. Relayed as the neutral class so a
354
+ // stray 3xx cannot increment an account's transient streak.
355
+ if (outcome >= 300 && outcome < 400) return "neutral";
356
+ // 401 is always a credential problem. A 403 is only a credential problem when nothing
357
+ // tells us otherwise: a workspace/entitlement denial (#1789) means the credential is valid
358
+ // and the account simply lacks access here, so quarantining it for reauth is wrong advice.
359
+ // Absent denial evidence the historical mapping stands, so the change fails safe.
360
+ if (outcome === 403 && denial !== undefined) return "workspace";
361
+ if (outcome === 401 || outcome === 403) return "credential";
362
+ // 402 Payment Required is treated as quota exhaustion for pool cooldown/failover
363
+ // (same-request alternate retry records this outcome for the depleted account).
364
+ if (outcome === 429 || outcome === 402) return "quota";
365
+ if (outcome >= 400 && outcome < 500) return "caller";
366
+ if (outcome >= 500 && outcome < 600) return "transient";
367
+ return "unknown";
368
+ }
369
+
370
+ function clampCooldownMs(ms: number): number {
371
+ return Math.min(Math.max(ms, 1), CODEX_MAX_QUOTA_COOLDOWN_MS);
372
+ }
373
+
374
+ export function parseRetryAfterMs(value: string | null | undefined, now = Date.now()): number | undefined {
375
+ const text = value?.trim();
376
+ if (!text) return undefined;
377
+ if (/^\d+(?:\.\d+)?$/.test(text)) {
378
+ const seconds = Number(text);
379
+ if (Number.isFinite(seconds) && seconds > 0) return clampCooldownMs(Math.ceil(seconds * 1000));
380
+ }
381
+ const timestamp = Date.parse(text);
382
+ if (!Number.isFinite(timestamp)) return undefined;
383
+ const delay = timestamp - now;
384
+ return delay > 0 ? clampCooldownMs(delay) : undefined;
385
+ }
386
+
387
+ function resetTimestampMs(value: unknown): number | undefined {
388
+ const numeric = typeof value === "number"
389
+ ? value
390
+ : typeof value === "string" && value.trim() !== ""
391
+ ? Number(value)
392
+ : undefined;
393
+ if (typeof numeric !== "number" || !Number.isFinite(numeric) || numeric <= 0) return undefined;
394
+ return numeric < 1_000_000_000_000 ? numeric * 1000 : numeric;
395
+ }
396
+
397
+ export function parseResetCooldownMs(resetAt: unknown | unknown[] | undefined, now = Date.now()): number | undefined {
398
+ const values = Array.isArray(resetAt) ? resetAt : [resetAt];
399
+ let best: number | undefined;
400
+ for (const value of values) {
401
+ const timestamp = resetTimestampMs(value);
402
+ if (timestamp === undefined) continue;
403
+ const delay = timestamp - now;
404
+ if (delay <= 0) continue;
405
+ // A far-future reset must not pin the account for the full Retry-After
406
+ // ceiling: quota usually frees up well before the advertised window (#433).
407
+ const clamped = Math.min(clampCooldownMs(delay), CODEX_MAX_RESET_DERIVED_COOLDOWN_MS);
408
+ if (best === undefined || clamped < best) best = clamped;
409
+ }
410
+ return best;
411
+ }
412
+
413
+ export function computeQuotaCooldown(meta: CodexUpstreamOutcomeMeta = {}): {
414
+ until: number;
415
+ source: CodexCooldownSource;
416
+ } {
417
+ const now = meta.now ?? Date.now();
418
+ const retryAfterMs = parseRetryAfterMs(meta.retryAfter, now);
419
+ if (retryAfterMs !== undefined) return { until: now + retryAfterMs, source: "retry-after" };
420
+ const resetCooldownMs = parseResetCooldownMs(meta.resetAt, now);
421
+ if (resetCooldownMs !== undefined) return { until: now + resetCooldownMs, source: "reset-derived" };
422
+ return { until: now + CODEX_DEFAULT_QUOTA_COOLDOWN_MS, source: "default" };
423
+ }
424
+
425
+ export function computeQuotaCooldownUntil(meta: CodexUpstreamOutcomeMeta = {}): number {
426
+ return computeQuotaCooldown(meta).until;
427
+ }
428
+
429
+ /**
430
+ * Grant at most one probe lease per interval for a cooled-down account.
431
+ *
432
+ * A cooled-down account is short-circuited locally, so it never sends traffic and
433
+ * no organic 2xx can prove that upstream quota recovered — the cooldown can only
434
+ * end by expiry or a proxy restart (#433). Releasing a single probe breaks that
435
+ * deadlock. Explicit Retry-After cooldowns are excluded: those are literal retry
436
+ * directives, not window announcements.
437
+ *
438
+ * Returns the lease id, or null when no probe may go out right now.
439
+ */
440
+ export function tryAcquireCodexQuotaProbeLease(accountId: string, now = Date.now()): string | null {
441
+ if (!canAcquireCodexQuotaProbeLease(accountId, now)) return null;
442
+ const health = upstreamHealth.get(accountId)!;
443
+ const probeLeaseId = randomUUID();
444
+ upstreamHealth.set(accountId, {
445
+ ...health,
446
+ probeLeaseId,
447
+ probeLeaseGeneration: health.cooldownGeneration ?? 0,
448
+ lastProbeAt: now,
449
+ });
450
+ return probeLeaseId;
451
+ }
452
+
453
+ /** Side-effect-free check mirroring {@link tryAcquireCodexQuotaProbeLease} eligibility. */
454
+ export function canAcquireCodexQuotaProbeLease(accountId: string, now = Date.now()): boolean {
455
+ return canAcquireQuotaProbeLease(upstreamHealth.get(accountId), now);
456
+ }
457
+
458
+ function canAcquireQuotaProbeLease(health: CodexUpstreamHealth | undefined, now: number): boolean {
459
+ if (!health) return false;
460
+ const cooldownUntil = health.cooldownUntil;
461
+ if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return false;
462
+ if (health.cooldownSource === "retry-after") return false;
463
+ if (health.probeLeaseId !== undefined) return false;
464
+ const origin = health.lastProbeAt ?? health.cooldownSince ?? cooldownUntil;
465
+ return now - origin >= CODEX_QUOTA_PROBE_INTERVAL_MS;
466
+ }
467
+
468
+ /**
469
+ * Claim due reset-derived cooldown probes without consulting account selection.
470
+ * Pool credentials only: the main account has no quota-refresh single-flight.
471
+ */
472
+ export function claimDueCodexQuotaRecoveryProbes(
473
+ config: OcxConfig,
474
+ limit: number,
475
+ now = Date.now(),
476
+ ): CodexQuotaRecoveryProbeClaim[] {
477
+ const boundedLimit = Math.max(0, Math.floor(limit));
478
+ if (boundedLimit === 0) return [];
479
+ const candidates: Array<{
480
+ accountId: string;
481
+ scope?: CodexQuotaScope;
482
+ health: CodexUpstreamHealth;
483
+ credentialGeneration: number;
484
+ credentialReplacedAt?: number;
485
+ order: number;
486
+ }> = [];
487
+ for (const [order, account] of (config.codexAccounts ?? []).entries()) {
488
+ if (!isSelectableCodexPoolAccount(account)
489
+ || isCodexAccountPaused(config, account.id)
490
+ || isAccountNeedsReauth(account.id)) continue;
491
+ const record = readCodexAccountRecord(account.id);
492
+ if (!record?.credential || record.deletedAt != null) continue;
493
+ const due = [
494
+ { scope: undefined, health: upstreamHealth.get(account.id) },
495
+ ...[...(quotaScopedHealth.get(account.id) ?? [])].map(([scope, health]) => ({ scope, health })),
496
+ ].filter((entry): entry is { scope?: CodexQuotaScope; health: CodexUpstreamHealth } =>
497
+ // `spark` is deliberately never claimed. `GET /backend-api/wham/usage` takes no scope
498
+ // parameter and returns generic weekly/monthly windows, so its result can never prove a
499
+ // spark recovery — a claim here would spend an upstream call to settle `false` every
500
+ // time, and (with one claim per account per pass) delay the shared scope that CAN recover.
501
+ entry.scope !== "spark"
502
+ && entry.health?.cooldownSource === "reset-derived"
503
+ && canAcquireQuotaProbeLease(entry.health, now))
504
+ .sort((a, b) =>
505
+ (a.health.lastProbeAt ?? a.health.cooldownSince ?? 0)
506
+ - (b.health.lastProbeAt ?? b.health.cooldownSince ?? 0));
507
+ const candidate = due[0];
508
+ if (candidate) candidates.push({
509
+ accountId: account.id,
510
+ ...(candidate.scope ? { scope: candidate.scope } : {}),
511
+ health: candidate.health,
512
+ credentialGeneration: record.generation,
513
+ ...(record.replacedAt !== undefined ? { credentialReplacedAt: record.replacedAt } : {}),
514
+ order,
515
+ });
516
+ }
517
+ candidates.sort((a, b) => {
518
+ const age = (a.health.lastProbeAt ?? a.health.cooldownSince ?? 0)
519
+ - (b.health.lastProbeAt ?? b.health.cooldownSince ?? 0);
520
+ return age || a.order - b.order;
521
+ });
522
+ return candidates.slice(0, boundedLimit).map(candidate => {
523
+ const leaseId = randomUUID();
524
+ const next = {
525
+ ...candidate.health,
526
+ probeLeaseId: leaseId,
527
+ probeLeaseGeneration: candidate.health.cooldownGeneration ?? 0,
528
+ lastProbeAt: now,
529
+ };
530
+ if (candidate.scope) setScopedHealth(candidate.accountId, candidate.scope, next);
531
+ else upstreamHealth.set(candidate.accountId, next);
532
+ return {
533
+ accountId: candidate.accountId,
534
+ ...(candidate.scope ? { scope: candidate.scope } : {}),
535
+ leaseId,
536
+ cooldownGeneration: candidate.health.cooldownGeneration ?? 0,
537
+ credentialGeneration: candidate.credentialGeneration,
538
+ ...(candidate.credentialReplacedAt !== undefined
539
+ ? { credentialReplacedAt: candidate.credentialReplacedAt }
540
+ : {}),
541
+ };
542
+ });
543
+ }
544
+
545
+ /** Settle one background recovery claim without mutating account-wide outcome state. */
546
+ export function settleCodexQuotaRecoveryProbe(
547
+ claim: CodexQuotaRecoveryProbeClaim,
548
+ recovered: boolean,
549
+ proof: CodexQuotaRecoveryProbeProof,
550
+ now = Date.now(),
551
+ ): boolean {
552
+ const health = claim.scope
553
+ ? scopedHealthFor(claim.accountId, claim.scope)
554
+ : upstreamHealth.get(claim.accountId);
555
+ if (!health || health.probeLeaseId !== claim.leaseId) return false;
556
+ const currentRecord = readCodexAccountRecord(claim.accountId);
557
+ const proofGeneration = proof.credentialGeneration;
558
+ // A probe-owned token refresh (getValidCodexToken) advances the credential generation by
559
+ // exactly one while preserving `replacedAt`; an external credential replacement bumps the
560
+ // generation too but stamps a fresh `replacedAt`. Accept the +1 transition only when the
561
+ // claim-time lineage is intact AND the generation the fresh quota was proven under is live.
562
+ const generationFenced = proofGeneration !== undefined
563
+ && (proofGeneration === claim.credentialGeneration
564
+ ? isCodexAccountGenerationLive(claim.accountId, proofGeneration)
565
+ : proofGeneration === claim.credentialGeneration + 1
566
+ && currentRecord?.replacedAt === claim.credentialReplacedAt
567
+ && isCodexAccountGenerationLive(claim.accountId, proofGeneration));
568
+ const fenced = (health.cooldownGeneration ?? 0) === claim.cooldownGeneration
569
+ && (health.probeLeaseGeneration ?? 0) === claim.cooldownGeneration
570
+ && generationFenced;
571
+ if (!recovered || !fenced) {
572
+ const released = withProbeLeaseReleased(health, now);
573
+ if (claim.scope) setScopedHealth(claim.accountId, claim.scope, released);
574
+ else upstreamHealth.set(claim.accountId, released);
575
+ return false;
576
+ }
577
+ if (claim.scope) {
578
+ deleteScopedHealth(claim.accountId, claim.scope);
579
+ } else {
580
+ const {
581
+ cooldownUntil: _until,
582
+ cooldownSince: _since,
583
+ cooldownSource: _source,
584
+ probeLeaseId: _leaseId,
585
+ probeLeaseGeneration: _leaseGeneration,
586
+ ...rest
587
+ } = health;
588
+ upstreamHealth.set(claim.accountId, {
589
+ ...rest,
590
+ cooldownGeneration: claim.cooldownGeneration + 1,
591
+ lastProbeAt: now,
592
+ });
593
+ }
594
+ return true;
595
+ }
596
+
597
+ /** Acquire the recovery probe for one confirmed model-specific quota group. */
598
+ export function tryAcquireCodexQuotaScopeProbeLease(
599
+ accountId: string,
600
+ scope: CodexQuotaScope,
601
+ now = Date.now(),
602
+ ): string | null {
603
+ const health = scopedHealthFor(accountId, scope);
604
+ if (!canAcquireQuotaProbeLease(health, now)) return null;
605
+ const probeLeaseId = randomUUID();
606
+ setScopedHealth(accountId, scope, {
607
+ ...health!,
608
+ probeLeaseId,
609
+ probeLeaseGeneration: health!.cooldownGeneration ?? 0,
610
+ lastProbeAt: now,
611
+ });
612
+ return probeLeaseId;
613
+ }
614
+
615
+ /**
616
+ * Hand a probe lease back without recording an upstream outcome. Used by paths
617
+ * that take a lease and then fail before any request reaches upstream.
618
+ */
619
+ export function releaseCodexQuotaProbeLease(accountId: string, leaseId: string, now = Date.now()): void {
620
+ const health = upstreamHealth.get(accountId);
621
+ if (!health || health.probeLeaseId !== leaseId) return;
622
+ upstreamHealth.set(accountId, withProbeLeaseReleased(health, now));
623
+ }
624
+
625
+ /** Release a model-specific quota probe when the request never reaches upstream. */
626
+ export function releaseCodexQuotaScopeProbeLease(
627
+ accountId: string,
628
+ scope: CodexQuotaScope,
629
+ leaseId: string,
630
+ now = Date.now(),
631
+ ): void {
632
+ const health = scopedHealthFor(accountId, scope);
633
+ if (!health || health.probeLeaseId !== leaseId) return;
634
+ setScopedHealth(accountId, scope, withProbeLeaseReleased(health, now));
635
+ }
636
+
637
+ /**
638
+ * True when this outcome belongs to the account's in-flight probe. The
639
+ * undefined-id guard matters: without it an outcome carrying no lease would match
640
+ * an account holding no lease and be mistaken for the probe owner.
641
+ */
642
+ function ownsProbeLease(health: CodexUpstreamHealth | undefined, meta: CodexUpstreamOutcomeMeta): boolean {
643
+ return meta.probeLeaseId !== undefined && meta.probeLeaseId === health?.probeLeaseId;
644
+ }
645
+
646
+ /**
647
+ * True when the owning probe may still clear the cooldown. A later 429 bumps the
648
+ * generation, so a probe that started under an older cooldown must not erase the
649
+ * newer restriction (which may carry an explicit Retry-After).
650
+ */
651
+ function probeMayClearCooldown(health: CodexUpstreamHealth | undefined, meta: CodexUpstreamOutcomeMeta): boolean {
652
+ return ownsProbeLease(health, meta)
653
+ && (health!.probeLeaseGeneration ?? 0) === (health!.cooldownGeneration ?? 0);
654
+ }
655
+
656
+ /** Strip the in-flight lease while preserving every hard-cooldown field. */
657
+ function withProbeLeaseReleased(health: CodexUpstreamHealth, now: number): CodexUpstreamHealth {
658
+ const { probeLeaseId: _id, probeLeaseGeneration: _gen, ...rest } = health;
659
+ return { ...rest, lastProbeAt: now };
660
+ }
661
+
662
+ /**
663
+ * Hard-cooldown bookkeeping that ordinary success/transient transitions rebuild
664
+ * their health object from. Dropping these would let one late unrelated response
665
+ * erase a Retry-After source, a cooldown generation, or someone else's live probe.
666
+ */
667
+ function preservedCooldownFields(health: CodexUpstreamHealth | undefined): Partial<CodexUpstreamHealth> {
668
+ if (!health) return {};
669
+ const { consecutiveFailures: _f, consecutiveSuccesses: _s, lastFailureStatus: _st, lastFailureAt: _at, softAvoidUntil: _sa, ...cooldownFields } = health;
670
+ return cooldownFields;
671
+ }
672
+
673
+ /** Manual selection resets transient routing evidence without bypassing a real 429 cooldown. */
674
+ export function resetCodexRoutingForManualSelection(accountId: string): void {
675
+ clearThreadAccountMap();
676
+ // Manual selection is the operator source of truth — drop any automatic runtime cursor.
677
+ runtimeActiveCodexAccountId = undefined;
678
+ // Seed the RR ring so the next unbound new session honors the manually selected account
679
+ // under round-robin (affinity-cleared threads / null threadId). Fill-first already follows
680
+ // config.activeCodexAccountId, which the caller persists before invoking this.
681
+ seedPoolRotationAccount(POOL_KEY_CODEX, accountId);
682
+ for (const scope of new Set(Object.values(NATIVE_MODEL_QUOTA_SCOPES))) {
683
+ if (isIndependentCodexQuotaScope(scope)) {
684
+ seedPoolRotationAccount(codexPoolKeyForScope(scope), accountId);
685
+ }
686
+ }
687
+ const current = upstreamHealth.get(accountId);
688
+ if (!current) return;
689
+ const preserved = preservedCooldownFields(current);
690
+ if (Object.keys(preserved).length === 0) upstreamHealth.delete(accountId);
691
+ else upstreamHealth.set(accountId, { consecutiveFailures: 0, ...preserved });
692
+ }
693
+
694
+ export function getCodexAccountCooldownUntil(accountId: string, now = Date.now()): number | null {
695
+ const cooldownUntil = upstreamHealth.get(accountId)?.cooldownUntil;
696
+ return typeof cooldownUntil === "number" && Number.isFinite(cooldownUntil) && cooldownUntil > now ? cooldownUntil : null;
697
+ }
698
+
699
+ /** Read-only cooldown snapshot for shared OAuth health projection (no write side effects). */
700
+ export function getCodexAccountHealthSnapshot(accountId: string, now = Date.now()): {
701
+ cooldownUntil?: number;
702
+ cooldownSource?: CodexCooldownSource;
703
+ } | null {
704
+ const cooldownUntil = getCodexAccountCooldownUntil(accountId, now);
705
+ if (cooldownUntil === null) return null;
706
+ const source = upstreamHealth.get(accountId)?.cooldownSource;
707
+ return {
708
+ cooldownUntil,
709
+ ...(source ? { cooldownSource: source } : {}),
710
+ };
711
+ }
712
+
713
+ /**
714
+ * Read the cooldown relevant to a routed native model. Account-wide cooldowns
715
+ * (Retry-After/default) always win; reset-derived scoped state applies only to
716
+ * its confirmed quota group.
717
+ */
718
+ export function getCodexQuotaHealthSnapshot(
719
+ accountId: string,
720
+ quotaScope: CodexQuotaScope | undefined,
721
+ now = Date.now(),
722
+ ): {
723
+ cooldownUntil?: number;
724
+ cooldownSource?: CodexCooldownSource;
725
+ quotaScope?: CodexQuotaScope;
726
+ } | null {
727
+ const account = getCodexAccountHealthSnapshot(accountId, now);
728
+ if (account) return account;
729
+ if (!quotaScope) return null;
730
+ const scoped = scopedHealthFor(accountId, quotaScope);
731
+ const cooldownUntil = scoped?.cooldownUntil;
732
+ if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return null;
733
+ return {
734
+ cooldownUntil,
735
+ ...(scoped?.cooldownSource ? { cooldownSource: scoped.cooldownSource } : {}),
736
+ quotaScope,
737
+ };
738
+ }
739
+
740
+ export function isCodexAccountInCooldown(accountId: string, now = Date.now()): boolean {
741
+ return getCodexAccountCooldownUntil(accountId, now) !== null;
742
+ }
743
+
744
+ /**
745
+ * Manually lift a hard quota cooldown without touching failure history.
746
+ *
747
+ * Injected Codex routing makes this proxy the ONLY model path for Codex Desktop, so a
748
+ * cooldown that outlives the real upstream limit reads to the user as "the whole app is
749
+ * broken" with no escape but editing config.toml. This is that escape hatch.
750
+ *
751
+ * Deliberately narrow:
752
+ * - Failure counters and softAvoid survive. Clearing a cooldown says "the quota window
753
+ * moved", not "this account is healthy"; failover must keep its knowledge.
754
+ * - Dropping `probeLeaseId` is what stops a stale in-flight probe from later "proving"
755
+ * recovery against a NEWER cooldown: {@link ownsProbeLease} needs the id to match.
756
+ * `cooldownGeneration` is preserved and bumped as redundancy only — a fresh 429 already
757
+ * bumps it in {@link recordCodexUpstreamOutcome}, so the bump here is not load-bearing
758
+ * today and is kept so the invariant survives a future change that retains the lease.
759
+ *
760
+ * Returns false when the account carried no live cooldown (already expired or never set).
761
+ */
762
+ export function clearCodexAccountCooldown(accountId: string, now = Date.now()): boolean {
763
+ const clear = (health: CodexUpstreamHealth): CodexUpstreamHealth | null => {
764
+ const cooldownUntil = health.cooldownUntil;
765
+ if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return null;
766
+ const {
767
+ cooldownUntil: _until,
768
+ cooldownSince: _since,
769
+ cooldownSource: _source,
770
+ probeLeaseId: _leaseId,
771
+ probeLeaseGeneration: _leaseGeneration,
772
+ ...rest
773
+ } = health;
774
+ return {
775
+ ...rest,
776
+ cooldownGeneration: (health.cooldownGeneration ?? 0) + 1,
777
+ lastProbeAt: now,
778
+ };
779
+ };
780
+
781
+ let cleared = false;
782
+ const accountHealth = upstreamHealth.get(accountId);
783
+ if (accountHealth) {
784
+ const next = clear(accountHealth);
785
+ if (next) {
786
+ upstreamHealth.set(accountId, next);
787
+ cleared = true;
788
+ }
789
+ }
790
+ for (const [scope, health] of quotaScopedHealth.get(accountId) ?? []) {
791
+ const next = clear(health);
792
+ if (next) {
793
+ setScopedHealth(accountId, scope, next);
794
+ cleared = true;
795
+ }
796
+ }
797
+ return cleared;
798
+ }
799
+
800
+ export function getCodexAccountSoftAvoidUntil(accountId: string, now = Date.now()): number | null {
801
+ const softAvoidUntil = upstreamHealth.get(accountId)?.softAvoidUntil;
802
+ return typeof softAvoidUntil === "number" && Number.isFinite(softAvoidUntil) && softAvoidUntil > now
803
+ ? softAvoidUntil
804
+ : null;
805
+ }
806
+
807
+ export function isCodexAccountSoftAvoided(accountId: string, now = Date.now()): boolean {
808
+ return getCodexAccountSoftAvoidUntil(accountId, now) !== null;
809
+ }
810
+
811
+ function isCodexAccountSelectable(
812
+ config: OcxConfig,
813
+ accountId: string,
814
+ now: number,
815
+ quotaScope?: CodexQuotaScope,
816
+ selectionOptions?: CodexAccountUsabilityOptions,
817
+ ): boolean {
818
+ return !isCodexAccountPaused(config, accountId)
819
+ && getCodexQuotaHealthSnapshot(accountId, quotaScope, now) === null
820
+ && !isCodexAccountSoftAvoided(accountId, now)
821
+ && isCodexAccountUsable(config, accountId, selectionOptions);
822
+ }
823
+
824
+ function threadAffinityScope(quotaScope?: CodexQuotaScope): ThreadAffinityScope {
825
+ return quotaScope ?? LEGACY_THREAD_AFFINITY_SCOPE;
826
+ }
827
+
828
+ function admissibleAffinityComponent(value: string): boolean {
829
+ return retainedUtf8Bytes(value) <= MAX_AFFINITY_COMPONENT_BYTES;
830
+ }
831
+
832
+ function getThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): ThreadAffinityEntry | undefined {
833
+ if (!admissibleAffinityComponent(threadId)) return undefined;
834
+ return threadAccountMap.get(threadId)?.get(threadAffinityScope(quotaScope));
835
+ }
836
+
837
+ function deleteThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): void {
838
+ if (!admissibleAffinityComponent(threadId)) return;
839
+ const affinities = threadAccountMap.get(threadId);
840
+ if (!affinities) return;
841
+ affinities.delete(threadAffinityScope(quotaScope));
842
+ if (affinities.size === 0) threadAccountMap.delete(threadId);
843
+ }
844
+
845
+ /** Remove only the matching failed account's affinities for one thread. */
846
+ function deleteThreadAffinitiesForAccount(threadId: string, accountId: string): void {
847
+ if (!admissibleAffinityComponent(threadId) || !admissibleAffinityComponent(accountId)) return;
848
+ const affinities = threadAccountMap.get(threadId);
849
+ if (!affinities) return;
850
+ for (const [scope, entry] of affinities) {
851
+ if (entry.accountId === accountId) affinities.delete(scope);
852
+ }
853
+ if (affinities.size === 0) threadAccountMap.delete(threadId);
854
+ }
855
+
856
+ function threadAffinityEntryCount(): number {
857
+ let count = 0;
858
+ for (const affinities of threadAccountMap.values()) count += affinities.size;
859
+ return count;
860
+ }
861
+
862
+ function isThreadAffinityExpired(entry: ThreadAffinityEntry, now: number): boolean {
863
+ return now - entry.lastUsedAt > CODEX_THREAD_AFFINITY_IDLE_TTL_MS;
864
+ }
865
+
866
+ function isThreadAffinityGenerationLive(entry: ThreadAffinityEntry): boolean {
867
+ if (entry.accountId === MAIN_CODEX_ACCOUNT_ID) return entry.generation === 0;
868
+ return isCodexAccountGenerationLive(entry.accountId, entry.generation);
869
+ }
870
+
871
+ function pruneExpiredThreadAffinities(now: number): void {
872
+ for (const [threadId, affinities] of threadAccountMap) {
873
+ for (const [scope, entry] of affinities) {
874
+ if (isThreadAffinityExpired(entry, now)) affinities.delete(scope);
875
+ }
876
+ if (affinities.size === 0) threadAccountMap.delete(threadId);
877
+ }
878
+ }
879
+
880
+ function pruneLruThreadAffinities(): void {
881
+ if (threadAccountMap.size * MAX_THREAD_AFFINITY_SCOPES <= CODEX_THREAD_AFFINITY_MAX_ENTRIES) return;
882
+ while (threadAffinityEntryCount() > CODEX_THREAD_AFFINITY_MAX_ENTRIES) {
883
+ let oldestThreadId: string | null = null;
884
+ let oldestScope: ThreadAffinityScope | null = null;
885
+ let oldestLastUsedAt = Number.POSITIVE_INFINITY;
886
+ for (const [threadId, affinities] of threadAccountMap) {
887
+ for (const [scope, entry] of affinities) {
888
+ if (entry.lastUsedAt < oldestLastUsedAt) {
889
+ oldestThreadId = threadId;
890
+ oldestScope = scope;
891
+ oldestLastUsedAt = entry.lastUsedAt;
892
+ }
893
+ }
894
+ }
895
+ if (!oldestThreadId || !oldestScope) return;
896
+ deleteThreadAffinity(oldestThreadId, oldestScope === LEGACY_THREAD_AFFINITY_SCOPE ? undefined : oldestScope);
897
+ }
898
+ }
899
+
900
+ function bindThreadAffinity(
901
+ threadId: string,
902
+ accountId: string,
903
+ now: number,
904
+ quotaScope?: CodexQuotaScope,
905
+ ): void {
906
+ if (!admissibleAffinityComponent(threadId) || !admissibleAffinityComponent(accountId)) return;
907
+ const record = accountId === MAIN_CODEX_ACCOUNT_ID ? undefined : readCodexAccountRecord(accountId);
908
+ if (accountId !== MAIN_CODEX_ACCOUNT_ID && (!record?.credential || record.deletedAt != null)) return;
909
+ pruneExpiredThreadAffinities(now);
910
+ const scope = threadAffinityScope(quotaScope);
911
+ const affinities = threadAccountMap.get(threadId) ?? new Map<ThreadAffinityScope, ThreadAffinityEntry>();
912
+ const previous = affinities.get(scope);
913
+ affinities.set(scope, {
914
+ accountId,
915
+ generation: accountId === MAIN_CODEX_ACCOUNT_ID ? 0 : record!.generation,
916
+ createdAt: previous?.createdAt ?? now,
917
+ lastUsedAt: now,
918
+ lastReevalAt: now,
919
+ });
920
+ threadAccountMap.set(threadId, affinities);
921
+ pruneLruThreadAffinities();
922
+ }
923
+
924
+ function getEligiblePoolAccounts(
925
+ config: OcxConfig,
926
+ excludeId?: string,
927
+ now = Date.now(),
928
+ quotaScope?: CodexQuotaScope,
929
+ selectionOptions?: CodexAccountUsabilityOptions,
930
+ ): readonly string[] {
931
+ const ids = (config.codexAccounts ?? [])
932
+ .filter(account => isSelectableCodexPoolAccount(account)
933
+ && account.id !== excludeId
934
+ && !isCodexAccountPaused(config, account.id)
935
+ && !isAccountNeedsReauth(account.id))
936
+ .filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null)
937
+ .filter(account => !isCodexAccountSoftAvoided(account.id, now))
938
+ .filter(account => isCodexAccountUsable(config, account.id, selectionOptions))
939
+ .map(account => account.id);
940
+ // The main Codex account is not stored in config.codexAccounts; include it as a
941
+ // first-class rotation candidate when its read-only token is usable (Option A).
942
+ if (
943
+ excludeId !== MAIN_CODEX_ACCOUNT_ID
944
+ && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
945
+ && !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)
946
+ && getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null
947
+ && !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now)
948
+ && isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions)
949
+ ) {
950
+ ids.unshift(MAIN_CODEX_ACCOUNT_ID);
951
+ }
952
+ // Single choke point for selection order: every strategy, failover, and preview
953
+ // reaches the pool through here, so tiering applies once rather than per picker.
954
+ // Eligibility above is unchanged — this only narrows an already-eligible list.
955
+ return selectPriorityTier(
956
+ ids,
957
+ codexAccountPriorityLookup(config),
958
+ id => hasCodexQuotaHeadroom(config, id),
959
+ pinnedCodexAccountId(config),
960
+ );
961
+ }
962
+
963
+ function listEligibleCodexAccountIds(
964
+ config: OcxConfig,
965
+ now: number,
966
+ quotaScope?: CodexQuotaScope,
967
+ selectionOptions?: CodexAccountUsabilityOptions,
968
+ ): readonly string[] {
969
+ return getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions);
970
+ }
971
+
972
+ function stickyLimitForConfig(config: OcxConfig): number {
973
+ return normalizeAccountPoolStickyLimit(config.accountPoolStickyLimit);
974
+ }
975
+
976
+ /**
977
+ * Whether an account still has quota to give under the auto-switch threshold.
978
+ *
979
+ * Fill-first and the priority tier filter share this predicate, and share both of
980
+ * its escape hatches. A disabled threshold means only health, pause, and reauth
981
+ * may drain an account; unknown usage is a guess, so it must neither force
982
+ * fill-first off the active account nor drain a tier that was simply never
983
+ * primed. A genuinely exhausted account 429s into cooldown and leaves
984
+ * eligibility on its own.
985
+ */
986
+ function hasCodexQuotaHeadroom(config: OcxConfig, accountId: string): boolean {
987
+ const threshold = config.autoSwitchThreshold ?? 80;
988
+ if (threshold <= 0) return true;
989
+ const usage = computeCodexUsageScore(getAccountQuota(accountId), getPoolAccountPlan(config, accountId));
990
+ if (isUnknownUsage(usage)) return true;
991
+ return usage < threshold;
992
+ }
993
+
994
+ /**
995
+ * Fill-first: keep selectable active under threshold; otherwise advance to the next
996
+ * eligible id in stable sorted order after the current active (wrapping).
997
+ */
998
+ function pickFillFirstCodexAccount(
999
+ config: OcxConfig,
1000
+ now: number,
1001
+ quotaScope?: CodexQuotaScope,
1002
+ selectionOptions?: CodexAccountUsabilityOptions,
1003
+ ): string | null {
1004
+ const eligible = listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions);
1005
+ if (eligible.length === 0) return null;
1006
+
1007
+ const active = getEffectiveActiveCodexAccountId(config);
1008
+ if (active && eligible.includes(active) && hasCodexQuotaHeadroom(config, active)) {
1009
+ return active;
1010
+ }
1011
+
1012
+ return pickNextFillFirstCodexAccount(config, active ?? null, eligible, now, selectionOptions);
1013
+ }
1014
+
1015
+ /** Next eligible account in stable order after `afterId` (wrapping). */
1016
+ function pickNextFillFirstCodexAccount(
1017
+ config: OcxConfig,
1018
+ afterId: string | null,
1019
+ eligible: readonly string[] = listEligibleCodexAccountIds(config, Date.now()),
1020
+ _now = Date.now(),
1021
+ selectionOptions?: CodexAccountUsabilityOptions,
1022
+ ): string | null {
1023
+ if (eligible.length === 0) return null;
1024
+ const ordered = [...eligible].sort((a, b) => a.localeCompare(b));
1025
+ if (!afterId) {
1026
+ // Prefer an under-threshold account when starting with no active cursor.
1027
+ for (const id of ordered) {
1028
+ if (hasCodexQuotaHeadroom(config, id)) return id;
1029
+ }
1030
+ return ordered[0] ?? null;
1031
+ }
1032
+
1033
+ const allConfigured = [
1034
+ ...(isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions) || afterId === MAIN_CODEX_ACCOUNT_ID
1035
+ ? [MAIN_CODEX_ACCOUNT_ID]
1036
+ : []),
1037
+ ...(config.codexAccounts ?? []).filter(account => !account.isMain).map(account => account.id),
1038
+ ];
1039
+ const stableAll = [...new Set(allConfigured)].sort((a, b) => a.localeCompare(b));
1040
+ const startIdx = stableAll.indexOf(afterId);
1041
+ if (startIdx < 0) {
1042
+ for (const id of ordered) {
1043
+ if (hasCodexQuotaHeadroom(config, id)) return id;
1044
+ }
1045
+ return ordered[0] ?? null;
1046
+ }
1047
+
1048
+ // Skip successors that are also at/above threshold (known drained usage).
1049
+ let fallback: string | null = null;
1050
+ for (let step = 1; step <= stableAll.length; step++) {
1051
+ const candidate = stableAll[(startIdx + step) % stableAll.length]!;
1052
+ if (!eligible.includes(candidate)) continue;
1053
+ if (!fallback) fallback = candidate;
1054
+ if (hasCodexQuotaHeadroom(config, candidate)) return candidate;
1055
+ }
1056
+ return fallback ?? ordered[0] ?? null;
1057
+ }
1058
+
1059
+ /**
1060
+ * Unbound new-session pick for round-robin / fill-first. Returns null to fall through
1061
+ * to the legacy quota path (or when the strategy is quota).
1062
+ *
1063
+ * When `commit` is true (resolve path), remembers active in-memory, binds thread affinity, and
1064
+ * notes RR success. When `commit` is false (preview), returns the same RR/fill-first
1065
+ * account resolve would pick via a dry-run peek — without mutating ring weights,
1066
+ * activeKey, sticky counters, config, or affinity.
1067
+ *
1068
+ * Automatic strategy picks never sync-write config; only manual selection persists active.
1069
+ *
1070
+ * Known limitation (follow-up): when a subagent preview peeks an RR account and the request
1071
+ * then falls back to a non-Codex provider, the ring is not reserved/committed. Prefer seeding
1072
+ * the peeked account if that path becomes load-bearing.
1073
+ */
1074
+ function pickUnboundStrategyAccount(
1075
+ config: OcxConfig,
1076
+ threadId: string | null,
1077
+ now: number,
1078
+ commit: boolean,
1079
+ quotaScope?: CodexQuotaScope,
1080
+ selectionOptions?: CodexAccountUsabilityOptions,
1081
+ ): string | null {
1082
+ const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
1083
+ if (strategy === "quota") return null;
1084
+ const poolKey = codexPoolKeyForScope(quotaScope);
1085
+
1086
+ let picked: string | null = null;
1087
+ if (strategy === "round-robin") {
1088
+ const eligible = listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions);
1089
+ const limit = stickyLimitForConfig(config);
1090
+ if (!commit) {
1091
+ return peekRoundRobinAccount(poolKey, eligible, limit);
1092
+ }
1093
+ picked = pickRoundRobinAccount(poolKey, eligible, limit);
1094
+ if (!picked) return null;
1095
+ if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked);
1096
+ if (threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
1097
+ notePoolRotationSuccess(poolKey, picked, limit);
1098
+ return picked;
1099
+ }
1100
+
1101
+ if (strategy === "fill-first") {
1102
+ picked = pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions);
1103
+ if (!picked) return null;
1104
+ if (commit) {
1105
+ if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked);
1106
+ if (threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
1107
+ }
1108
+ return picked;
1109
+ }
1110
+
1111
+ return null;
1112
+ }
1113
+
1114
+ export function getPoolAccountPlan(config: OcxConfig, accountId: string): string | undefined {
1115
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) return getMainAccountPlan();
1116
+ return (config.codexAccounts ?? [])
1117
+ .find(account => isSelectableCodexPoolAccount(account) && account.id === accountId)?.plan;
1118
+ }
1119
+
1120
+ function pickLowerUsageAccount(
1121
+ config: OcxConfig,
1122
+ active: string,
1123
+ activeUsage: number,
1124
+ now: number,
1125
+ quotaScope?: CodexQuotaScope,
1126
+ selectionOptions?: CodexAccountUsabilityOptions,
1127
+ ): string {
1128
+ let best = active;
1129
+ let bestUsage = activeUsage;
1130
+ for (const id of getEligiblePoolAccounts(config, active, now, quotaScope, selectionOptions)) {
1131
+ const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id));
1132
+ if (usage < bestUsage) {
1133
+ best = id;
1134
+ bestUsage = usage;
1135
+ }
1136
+ }
1137
+ return best;
1138
+ }
1139
+
1140
+ /** Coolest account in an already-selected candidate list; first index wins ties. */
1141
+ function pickLowestUsageAmong(config: OcxConfig, ids: readonly string[]): string | null {
1142
+ let best: string | null = null;
1143
+ let bestUsage = Number.POSITIVE_INFINITY;
1144
+ for (const id of ids) {
1145
+ const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id));
1146
+ if (usage < bestUsage) {
1147
+ best = id;
1148
+ bestUsage = usage;
1149
+ }
1150
+ }
1151
+ return best;
1152
+ }
1153
+
1154
+ export function pickLowestUsageCodexAccount(
1155
+ config: OcxConfig,
1156
+ excludeId?: string,
1157
+ now = Date.now(),
1158
+ quotaScope?: CodexQuotaScope,
1159
+ selectionOptions?: CodexAccountUsabilityOptions,
1160
+ ): string | null {
1161
+ return pickLowestUsageAmong(config, getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions));
1162
+ }
1163
+
1164
+ /**
1165
+ * Strategy-aware alternate after a cooled/excluded account (same-request 429 retry
1166
+ * and active promotion). Quota keeps lowest-usage; fill-first advances stable order;
1167
+ * round-robin takes the next ring pick (caller should have noted the failure).
1168
+ */
1169
+ export function pickAlternateCodexAccount(
1170
+ config: OcxConfig,
1171
+ excludeId: string,
1172
+ now = Date.now(),
1173
+ quotaScope?: CodexQuotaScope,
1174
+ selectionOptions?: CodexAccountUsabilityOptions,
1175
+ ): string | null {
1176
+ const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
1177
+ // The exclusion is passed into eligibility rather than post-filtered off its
1178
+ // result: when the excluded account is the only healthy member of the top
1179
+ // tier, the tier walk must be free to descend instead of selecting that tier
1180
+ // and then handing back an empty list.
1181
+ if (strategy === "round-robin") {
1182
+ const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions);
1183
+ return pickRoundRobinAccount(codexPoolKeyForScope(quotaScope), eligible, stickyLimitForConfig(config));
1184
+ }
1185
+ if (strategy === "fill-first") {
1186
+ const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions);
1187
+ return pickNextFillFirstCodexAccount(config, excludeId, eligible, now, selectionOptions);
1188
+ }
1189
+ return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope, selectionOptions);
1190
+ }
1191
+
1192
+ /** Effective active: automatic runtime cursor, else operator/persisted selection. */
1193
+ export function getEffectiveActiveCodexAccountId(config: OcxConfig): string | undefined {
1194
+ return runtimeActiveCodexAccountId ?? config.activeCodexAccountId;
1195
+ }
1196
+
1197
+ /**
1198
+ * Whether the account routing is currently on is there because an operator asked
1199
+ * for it, rather than because a strategy landed on it. Surfaces read this instead
1200
+ * of comparing the stored pin themselves, which would report a pin that a later
1201
+ * automatic pick has already moved past.
1202
+ */
1203
+ export function isEffectiveCodexAccountPinned(config: OcxConfig): boolean {
1204
+ const pinned = pinnedCodexAccountId(config);
1205
+ return pinned !== undefined && pinned === getEffectiveActiveCodexAccountId(config);
1206
+ }
1207
+
1208
+ /**
1209
+ * Automatic strategy / failover cursor only — never mutates `config.activeCodexAccountId`
1210
+ * so an unrelated `saveConfig` cannot persist transient rotation as operator selection.
1211
+ */
1212
+ function rememberActiveCodexAccount(_config: OcxConfig, accountId: string): void {
1213
+ runtimeActiveCodexAccountId = accountId;
1214
+ }
1215
+
1216
+ /**
1217
+ * End the manual pin when routing moves to a different account. Returns whether
1218
+ * the pin changed so the caller can fold it into a write it was already making.
1219
+ */
1220
+ function releaseCodexAccountPinFor(config: OcxConfig, accountId: string): boolean {
1221
+ const pinned = pinnedCodexAccountId(config);
1222
+ if (pinned === undefined || pinned === accountId) return false;
1223
+ clearCodexAccountPin(config);
1224
+ return true;
1225
+ }
1226
+
1227
+ /** Persist operator (or quota-strategy) active selection to config + disk. */
1228
+ function setActiveCodexAccount(config: OcxConfig, accountId: string): void {
1229
+ runtimeActiveCodexAccountId = undefined;
1230
+ const releasedPin = releaseCodexAccountPinFor(config, accountId);
1231
+ if (config.activeCodexAccountId === accountId && !releasedPin) return;
1232
+ config.activeCodexAccountId = accountId;
1233
+ saveConfigPreservingClaudeCode(config);
1234
+ }
1235
+
1236
+ /** Quota strategy persists; RR/fill-first keep a process-local cursor only. */
1237
+ function promoteActiveCodexAccount(config: OcxConfig, accountId: string): void {
1238
+ if (normalizeAccountPoolStrategy(config.accountPoolStrategy) === "quota") {
1239
+ setActiveCodexAccount(config, accountId);
1240
+ return;
1241
+ }
1242
+ // Runtime-only, like the cursor itself: a caller that persists (pause, delete)
1243
+ // saves this release with its own write; a transient failover does not, so the
1244
+ // pin survives a restart that also clears the failure history behind it.
1245
+ releaseCodexAccountPinFor(config, accountId);
1246
+ rememberActiveCodexAccount(config, accountId);
1247
+ }
1248
+
1249
+ /**
1250
+ * Reconcile the effective active account after an administrative exclusion such as pause.
1251
+ * The operator's persisted selection is cleared when it names the excluded account; quota
1252
+ * keeps its historical persisted promotion, while rotating strategies retain the replacement
1253
+ * only in the process-local cursor.
1254
+ */
1255
+ export function reconcileCodexActiveAfterExclusion(
1256
+ config: OcxConfig,
1257
+ excludedAccountId: string,
1258
+ now = Date.now(),
1259
+ ): string | null {
1260
+ const wasEffective = (getEffectiveActiveCodexAccountId(config) ?? MAIN_CODEX_ACCOUNT_ID) === excludedAccountId;
1261
+ if (config.activeCodexAccountId === excludedAccountId) {
1262
+ config.activeCodexAccountId = undefined;
1263
+ }
1264
+ // Excluding an account revokes any manual pin on it even when it was not the
1265
+ // effective active — otherwise a paused account keeps acting as a tier ceiling,
1266
+ // suppressing every higher-ordered account while being unusable itself.
1267
+ clearCodexAccountPin(config, excludedAccountId);
1268
+ if (!wasEffective) return getEffectiveActiveCodexAccountId(config) ?? null;
1269
+
1270
+ runtimeActiveCodexAccountId = undefined;
1271
+ const fallback = pickAlternateCodexAccount(config, excludedAccountId, now);
1272
+ if (fallback) promoteActiveCodexAccount(config, fallback);
1273
+ return fallback;
1274
+ }
1275
+
1276
+ function isUnknownUsage(usage: number): boolean {
1277
+ return usage >= CODEX_UNKNOWN_USAGE_SCORE;
1278
+ }
1279
+
1280
+ /**
1281
+ * Move an unbound request back up when a higher tier regains headroom — the
1282
+ * weekly-reset case. Returns null when nothing should change.
1283
+ *
1284
+ * Downward moves are deliberately left to {@link applyQuotaAutoSwitch}: this only
1285
+ * fires when the tier filter has already excluded `active`, and only toward a
1286
+ * tier that strictly outranks it. Threads bound by affinity never reach here.
1287
+ */
1288
+ function pickPriorityPreemption(
1289
+ config: OcxConfig,
1290
+ active: string,
1291
+ now: number,
1292
+ quotaScope?: CodexQuotaScope,
1293
+ selectionOptions?: CodexAccountUsabilityOptions,
1294
+ ): string | null {
1295
+ const eligible = getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions);
1296
+ if (eligible.length === 0 || eligible.includes(active)) return null;
1297
+ const pinned = pinnedCodexAccountId(config);
1298
+ // A live pin already lowered the tier ceiling; never preempt past an explicit
1299
+ // operator choice. Same liveness test the tier filter applies, so preview and
1300
+ // resolve agree even before the pin is garbage-collected.
1301
+ if (pinned !== undefined && eligible.includes(pinned) && hasCodexQuotaHeadroom(config, pinned)) return null;
1302
+ const priorityOf = codexAccountPriorityLookup(config);
1303
+ if (priorityOf(eligible[0]!) <= priorityOf(active)) return null;
1304
+ // Members without headroom are in the tier only because a sibling has some;
1305
+ // picking one would hand the request straight back to a drained account.
1306
+ return pickLowestUsageAmong(config, eligible.filter(id => hasCodexQuotaHeadroom(config, id)));
1307
+ }
1308
+
1309
+ /**
1310
+ * Release a pin whose account is durably drained. "Use this account now" ends
1311
+ * when the account crosses the auto-switch threshold or stops being selectable
1312
+ * at all — never on a transient cooldown or soft-avoid, which it recovers from
1313
+ * on its own. Clearing the pin also removes the condition, so this writes at
1314
+ * most once per pin.
1315
+ */
1316
+ function releaseDrainedCodexAccountPin(config: OcxConfig): void {
1317
+ const pinned = pinnedCodexAccountId(config);
1318
+ if (pinned === undefined) return;
1319
+ const drained = !isCodexAccountUsable(config, pinned)
1320
+ || isAccountNeedsReauth(pinned)
1321
+ || isCodexAccountPaused(config, pinned)
1322
+ || !hasCodexQuotaHeadroom(config, pinned);
1323
+ if (!drained) return;
1324
+ clearCodexAccountPin(config);
1325
+ saveConfigPreservingClaudeCode(config);
1326
+ }
1327
+
1328
+ function applyQuotaAutoSwitch(
1329
+ config: OcxConfig,
1330
+ active: string,
1331
+ now: number,
1332
+ quotaScope?: CodexQuotaScope,
1333
+ selectionOptions?: CodexAccountUsabilityOptions,
1334
+ ): string {
1335
+ const threshold = config.autoSwitchThreshold ?? 80;
1336
+ if (threshold <= 0) return active;
1337
+ const quota = getAccountQuota(active);
1338
+ const activeUsage = computeCodexUsageScore(quota, getPoolAccountPlan(config, active));
1339
+ // Unknown usage is not evidence that a user's explicit selection crossed the
1340
+ // threshold. Wait for quota priming instead of rotating among guesses.
1341
+ if (isUnknownUsage(activeUsage)) return active;
1342
+ if (activeUsage < threshold) return active;
1343
+ const best = pickLowerUsageAccount(config, active, activeUsage, now, quotaScope, selectionOptions);
1344
+ if (best !== active) {
1345
+ if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, best);
1346
+ return best;
1347
+ }
1348
+
1349
+ return active;
1350
+ }
1351
+
1352
+ function shouldFailover(config: OcxConfig, accountId: string, now: number): boolean {
1353
+ const threshold = config.upstreamFailoverThreshold ?? 3;
1354
+ if (threshold <= 0) return false;
1355
+ const health = upstreamHealth.get(accountId);
1356
+ if (health?.lastFailureAt && now - health.lastFailureAt > CODEX_FAILURE_WINDOW_MS) return false;
1357
+ return !!health && health.consecutiveFailures >= threshold;
1358
+ }
1359
+
1360
+ function applyFailureFailover(
1361
+ config: OcxConfig,
1362
+ active: string,
1363
+ now: number,
1364
+ quotaScope?: CodexQuotaScope,
1365
+ selectionOptions?: CodexAccountUsabilityOptions,
1366
+ ): string {
1367
+ if (!shouldFailover(config, active, now)) return active;
1368
+ const best = pickAlternateCodexAccount(config, active, now, quotaScope, selectionOptions);
1369
+ if (best) {
1370
+ // The scope still routes away from the failing account — that is this request's
1371
+ // own decision — but an independent one must not persist a new shared active
1372
+ // account. recordCodexUpstreamOutcome only suppresses the promotion it makes at
1373
+ // the moment of the failure; the streak outlives the soft avoid, so a later
1374
+ // scoped resolve reaches here with the streak still tripped and would otherwise
1375
+ // move the shared cursor after all.
1376
+ if (!isIndependentCodexQuotaScope(quotaScope)) promoteActiveCodexAccount(config, best);
1377
+ return best;
1378
+ }
1379
+ return active;
1380
+ }
1381
+
1382
+ export function resolveCodexAccountForThread(
1383
+ threadId: string | null,
1384
+ config: OcxConfig,
1385
+ now = Date.now(),
1386
+ quotaScope?: CodexQuotaScope,
1387
+ ): string | null {
1388
+ const resolution = resolveCodexAccountForThreadDetailed(threadId, config, now, quotaScope);
1389
+ return resolution.status === "selected" ? resolution.accountId : null;
1390
+ }
1391
+
1392
+ /**
1393
+ * Side-effect-free preview of the Codex pool account native routing would prefer.
1394
+ * Used for subagent fallback quota decisions before final auth.
1395
+ *
1396
+ * Does not mutate activeCodexAccountId, thread affinity, config on disk, or probe leases.
1397
+ * Mirrors {@link resolveCodexAccountForThreadDetailed} account choice, including returning a
1398
+ * configured cooled account so callers can evaluate probe/quota availability.
1399
+ */
1400
+ export function previewCodexAccountForRequest(
1401
+ threadId: string | null,
1402
+ config: OcxConfig,
1403
+ now = Date.now(),
1404
+ quotaScope?: CodexQuotaScope,
1405
+ selectionOptions?: CodexAccountUsabilityOptions,
1406
+ ): string | null {
1407
+ const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined;
1408
+ if (threadId && entry) {
1409
+ if (
1410
+ !isThreadAffinityExpired(entry, now)
1411
+ && isThreadAffinityGenerationLive(entry)
1412
+ && isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions)
1413
+ && !shouldFailover(config, entry.accountId, now)
1414
+ ) {
1415
+ // Quota strategy only: non-quota strategies keep affinity for ongoing threads
1416
+ // (new-session-only rotation — docs / affinity policy A).
1417
+ const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
1418
+ if (strategy === "quota") {
1419
+ const threshold = config.autoSwitchThreshold ?? 80;
1420
+ if (threshold > 0) {
1421
+ const usage = computeCodexUsageScore(
1422
+ getAccountQuota(entry.accountId),
1423
+ getPoolAccountPlan(config, entry.accountId),
1424
+ );
1425
+ if (!isUnknownUsage(usage) && usage >= threshold) {
1426
+ const best = pickLowerUsageAccount(
1427
+ config,
1428
+ entry.accountId,
1429
+ usage,
1430
+ now,
1431
+ quotaScope,
1432
+ selectionOptions,
1433
+ );
1434
+ if (best !== entry.accountId) return best;
1435
+ }
1436
+ }
1437
+ }
1438
+ return entry.accountId;
1439
+ }
1440
+ // Stale/unusable affinity is ignored for preview (no map mutation).
1441
+ }
1442
+
1443
+ const strategyPick = pickUnboundStrategyAccount(
1444
+ config,
1445
+ threadId,
1446
+ now,
1447
+ false,
1448
+ quotaScope,
1449
+ selectionOptions,
1450
+ );
1451
+ if (strategyPick) return strategyPick;
1452
+
1453
+ let active = getEffectiveActiveCodexAccountId(config) ?? null;
1454
+ if (!active) {
1455
+ return pickLowestUsageCodexAccount(config, undefined, now, quotaScope, selectionOptions);
1456
+ }
1457
+ if (!isCodexAccountSelectable(config, active, now, quotaScope, selectionOptions)) {
1458
+ const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions);
1459
+ if (fallback) active = fallback;
1460
+ else if (
1461
+ hasConfiguredPoolAccount(config, active, selectionOptions)
1462
+ && !isCodexAccountPaused(config, active)
1463
+ ) return active;
1464
+ else return null;
1465
+ }
1466
+ active = pickPriorityPreemption(config, active, now, quotaScope, selectionOptions) ?? active;
1467
+
1468
+ const threshold = config.autoSwitchThreshold ?? 80;
1469
+ if (threshold > 0) {
1470
+ const usage = computeCodexUsageScore(getAccountQuota(active), getPoolAccountPlan(config, active));
1471
+ if (!isUnknownUsage(usage) && usage >= threshold) {
1472
+ active = pickLowerUsageAccount(config, active, usage, now, quotaScope, selectionOptions);
1473
+ }
1474
+ }
1475
+ if (shouldFailover(config, active, now)) {
1476
+ const best = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions);
1477
+ if (best) active = best;
1478
+ }
1479
+ if (!isCodexAccountUsable(config, active, selectionOptions)) {
1480
+ return hasConfiguredPoolAccount(config, active, selectionOptions) ? active : null;
1481
+ }
1482
+ if (isCodexAccountPaused(config, active)) return null;
1483
+ if (getCodexQuotaHealthSnapshot(active, quotaScope, now)) {
1484
+ return hasConfiguredPoolAccount(config, active, selectionOptions) ? active : null;
1485
+ }
1486
+ return active;
1487
+ }
1488
+
1489
+ export function resolveCodexAccountForThreadDetailed(
1490
+ threadId: string | null,
1491
+ config: OcxConfig,
1492
+ now = Date.now(),
1493
+ quotaScope?: CodexQuotaScope,
1494
+ selectionOptions?: CodexAccountUsabilityOptions,
1495
+ ): CodexThreadResolution {
1496
+ // Retiring a spent manual pin is independent of affinity: an existing thread
1497
+ // keeps its account below, but the operator's tier ceiling must not silently
1498
+ // revive after quota resets. Independent model scopes must never persist a
1499
+ // change to shared routing state.
1500
+ if (!isIndependentCodexQuotaScope(quotaScope)) releaseDrainedCodexAccountPin(config);
1501
+
1502
+ const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined;
1503
+ if (threadId && entry) {
1504
+ if (isThreadAffinityExpired(entry, now)) {
1505
+ deleteThreadAffinity(threadId, quotaScope);
1506
+ return { status: "expired", accountId: entry.accountId };
1507
+ }
1508
+ if (
1509
+ isThreadAffinityGenerationLive(entry)
1510
+ && isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions)
1511
+ // Affined threads must leave a failing account once the streak trips failover
1512
+ // (soft-avoid covers the first-hit case; this catches post-avoid residual streaks).
1513
+ && !shouldFailover(config, entry.accountId, now)
1514
+ ) {
1515
+ entry.lastUsedAt = now;
1516
+ // Periodic quota re-eval: a long-lived bound thread must still switch when
1517
+ // it crosses autoSwitchThreshold and a strictly-cooler account exists.
1518
+ // Without this the reuse branch returns before applyQuotaAutoSwitch and the
1519
+ // thread stays pinned for the full idle TTL (the WSL "never switches" report).
1520
+ // Over-threshold pins re-eval immediately so a depleted primary does not keep
1521
+ // serving for up to 60s after a secondary with quota is available (#584).
1522
+ // Non-quota strategies (RR / fill-first) keep affinity for ongoing threads —
1523
+ // rotation is new-session-only (affinity policy A).
1524
+ const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
1525
+ if (strategy === "quota") {
1526
+ const threshold = config.autoSwitchThreshold ?? 80;
1527
+ const usage = threshold > 0
1528
+ ? computeCodexUsageScore(
1529
+ getAccountQuota(entry.accountId),
1530
+ getPoolAccountPlan(config, entry.accountId),
1531
+ )
1532
+ : 0;
1533
+ const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold;
1534
+ if (overThreshold || now - entry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS) {
1535
+ entry.lastReevalAt = now;
1536
+ if (overThreshold) {
1537
+ const best = pickLowerUsageAccount(config, entry.accountId, usage, now, quotaScope, selectionOptions);
1538
+ if (best !== entry.accountId) {
1539
+ if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, best);
1540
+ bindThreadAffinity(threadId, best, now, quotaScope); // rebinds + resets clocks
1541
+ return { status: "selected", accountId: best };
1542
+ }
1543
+ }
1544
+ }
1545
+ }
1546
+ return { status: "selected", accountId: entry.accountId };
1547
+ }
1548
+ deleteThreadAffinity(threadId, quotaScope);
1549
+ }
1550
+
1551
+ const strategyPick = pickUnboundStrategyAccount(config, threadId, now, true, quotaScope, selectionOptions);
1552
+ if (strategyPick) return { status: "selected", accountId: strategyPick };
1553
+
1554
+ let active = getEffectiveActiveCodexAccountId(config);
1555
+ if (!active) {
1556
+ const selected = pickLowestUsageCodexAccount(config, undefined, now, quotaScope, selectionOptions);
1557
+ if (!selected) return { status: "none" };
1558
+ if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, selected);
1559
+ active = selected;
1560
+ }
1561
+ if (!isCodexAccountSelectable(config, active, now, quotaScope, selectionOptions)) {
1562
+ const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions);
1563
+ if (fallback) {
1564
+ if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, fallback);
1565
+ active = fallback;
1566
+ } else if (
1567
+ hasConfiguredPoolAccount(config, active, selectionOptions)
1568
+ && !isCodexAccountPaused(config, active)
1569
+ ) {
1570
+ return { status: "selected", accountId: active };
1571
+ } else {
1572
+ return { status: "none" };
1573
+ }
1574
+ }
1575
+ // Before applyQuotaAutoSwitch: its sync disk write would otherwise persist a
1576
+ // move inside the drained tier that preemption immediately overrides.
1577
+ const preempted = pickPriorityPreemption(config, active, now, quotaScope, selectionOptions);
1578
+ if (preempted) {
1579
+ // Runtime-only, like every other automatic pick: config.activeCodexAccountId
1580
+ // stays the operator's selection and getEffectiveActiveCodexAccountId is what
1581
+ // surfaces this to the API and dashboard. An independent quota group must not
1582
+ // move the shared cursor at all — its ordering decision is its own.
1583
+ if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, preempted);
1584
+ active = preempted;
1585
+ }
1586
+ active = applyQuotaAutoSwitch(config, active, now, quotaScope, selectionOptions);
1587
+ active = applyFailureFailover(config, active, now, quotaScope, selectionOptions);
1588
+ if (!isCodexAccountUsable(config, active, selectionOptions)) {
1589
+ return hasConfiguredPoolAccount(config, active, selectionOptions)
1590
+ ? { status: "selected", accountId: active }
1591
+ : { status: "none" };
1592
+ }
1593
+ if (isCodexAccountPaused(config, active)) return { status: "none" };
1594
+ if (getCodexQuotaHealthSnapshot(active, quotaScope, now)) {
1595
+ return hasConfiguredPoolAccount(config, active, selectionOptions)
1596
+ ? { status: "selected", accountId: active }
1597
+ : { status: "none" };
1598
+ }
1599
+ if (threadId) bindThreadAffinity(threadId, active, now, quotaScope);
1600
+ return { status: "selected", accountId: active };
1601
+ }
1602
+
1603
+ export function recordCodexUpstreamOutcome(
1604
+ config: OcxConfig,
1605
+ accountId: string | null,
1606
+ outcome: CodexUpstreamOutcome,
1607
+ meta: CodexUpstreamOutcomeMeta = {},
1608
+ ): void {
1609
+ // Host-level evidence is account-independent (#914): a pre-connection
1610
+ // reachability failure is recorded in the (provider, host) ledger even when
1611
+ // there is no account to attribute, or the account's writer generation is
1612
+ // stale — the early returns below must not gate it.
1613
+ if (outcome === "connect_neutral" && meta.hostKey) {
1614
+ recordUpstreamHostFailure(meta.hostKey, { code: meta.lastFailureCode, now: meta.now ?? Date.now() });
1615
+ }
1616
+ if (!accountId) return;
1617
+ const writerGeneration = meta.writerGeneration ?? captureConfigGeneration();
1618
+ if (writerGeneration < lastReconciledGeneration && !liveHealthAccountIds.has(accountId)) return;
1619
+ const now = meta.now ?? Date.now();
1620
+ const outcomeClass = classifyCodexUpstreamOutcome(outcome, meta.denial);
1621
+ const quotaScope = codexQuotaScopeForModel(meta.modelId);
1622
+ if (outcomeClass === "success") {
1623
+ const scopedProbe = meta.probeQuotaScope
1624
+ ? scopedHealthFor(accountId, meta.probeQuotaScope)
1625
+ : undefined;
1626
+ if (scopedProbe && meta.probeQuotaScope) {
1627
+ if (scopedProbe.cooldownUntil && probeMayClearCooldown(scopedProbe, meta)) {
1628
+ deleteScopedHealth(accountId, meta.probeQuotaScope);
1629
+ } else if (ownsProbeLease(scopedProbe, meta)) {
1630
+ setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now));
1631
+ }
1632
+ }
1633
+ const current = upstreamHealth.get(accountId);
1634
+ const cooldownUntil = getCodexAccountCooldownUntil(accountId, now);
1635
+ // A leased probe that is still on its own cooldown generation proves the
1636
+ // account recovered: clear the hard cooldown outright (#433).
1637
+ if (cooldownUntil && probeMayClearCooldown(current, meta)) {
1638
+ upstreamHealth.delete(accountId);
1639
+ return;
1640
+ }
1641
+ // Owning probe on a stale generation: the lease is done, but a newer 429
1642
+ // replaced the cooldown in the meantime, so only give the lease back.
1643
+ // Non-owners keep every hard-cooldown field, including someone else's live lease.
1644
+ const base = ownsProbeLease(current, meta) ? withProbeLeaseReleased(current!, now) : current;
1645
+ const preserved = preservedCooldownFields(base);
1646
+ const failoverEnabled = (config.upstreamFailoverThreshold ?? 3) > 0;
1647
+ if (failoverEnabled && current && current.consecutiveFailures >= 2) {
1648
+ const consecutiveSuccesses = (current.consecutiveSuccesses ?? 0) + 1;
1649
+ if (consecutiveSuccesses < 2) {
1650
+ upstreamHealth.set(accountId, {
1651
+ ...base!,
1652
+ ...preserved,
1653
+ consecutiveSuccesses,
1654
+ });
1655
+ return;
1656
+ }
1657
+ }
1658
+ // Level 1 clears immediately; escalated accounts need two consecutive healthy terminals.
1659
+ // Hard quota cooldown intentionally survives either recovery path.
1660
+ if (cooldownUntil) upstreamHealth.set(accountId, { consecutiveFailures: 0, ...preserved });
1661
+ else upstreamHealth.delete(accountId);
1662
+ return;
1663
+ }
1664
+ if (outcomeClass === "caller") {
1665
+ // A 4xx does not change account health, but it does conclude an in-flight
1666
+ // probe — otherwise the lease would never be handed back.
1667
+ const current = upstreamHealth.get(accountId);
1668
+ const scopedProbe = meta.probeQuotaScope
1669
+ ? scopedHealthFor(accountId, meta.probeQuotaScope)
1670
+ : undefined;
1671
+ if (scopedProbe && meta.probeQuotaScope && ownsProbeLease(scopedProbe, meta)) {
1672
+ setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now));
1673
+ }
1674
+ if (ownsProbeLease(current, meta)) {
1675
+ upstreamHealth.set(accountId, withProbeLeaseReleased(current!, now));
1676
+ }
1677
+ return;
1678
+ }
1679
+
1680
+ if (outcomeClass === "neutral") {
1681
+ // A proven pre-connection reachability failure (DNS / TCP refusal) or a
1682
+ // relayed 3xx is host-level, not account evidence: rotation cannot repair
1683
+ // it and must not happen (#914). Conclude any owned probe lease, record the
1684
+ // failure under the (provider, host) ledger when one is named, and leave
1685
+ // account health, thread affinity, and the active account untouched.
1686
+ const current = upstreamHealth.get(accountId);
1687
+ const scopedProbe = meta.probeQuotaScope
1688
+ ? scopedHealthFor(accountId, meta.probeQuotaScope)
1689
+ : undefined;
1690
+ if (scopedProbe && meta.probeQuotaScope && ownsProbeLease(scopedProbe, meta)) {
1691
+ setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now));
1692
+ }
1693
+ if (ownsProbeLease(current, meta)) {
1694
+ upstreamHealth.set(accountId, withProbeLeaseReleased(current!, now));
1695
+ }
1696
+ return;
1697
+ }
1698
+
1699
+ const lastFailureStatus = typeof outcome === "number" ? outcome : 0;
1700
+ if (outcomeClass === "workspace") {
1701
+ // The credential is valid; this account just cannot reach this workspace (#1789).
1702
+ // Record the failure so routing stops preferring it, but do not mark it for
1703
+ // reauthentication and do not sweep its thread affinities: telling the user to
1704
+ // re-login is wrong advice that cannot fix a workspace grant.
1705
+ upstreamHealth.set(accountId, {
1706
+ consecutiveFailures: (upstreamHealth.get(accountId)?.consecutiveFailures ?? 0) + 1,
1707
+ lastFailureStatus,
1708
+ lastFailureAt: now,
1709
+ });
1710
+ return;
1711
+ }
1712
+ if (outcomeClass === "credential") {
1713
+ // 401/403 quarantines the account for reauth. That supersedes quota state
1714
+ // entirely: a cooldown (and any probe lease) on an unusable account is moot.
1715
+ upstreamHealth.set(accountId, {
1716
+ consecutiveFailures: 1,
1717
+ lastFailureStatus,
1718
+ lastFailureAt: now,
1719
+ });
1720
+ quotaScopedHealth.delete(accountId);
1721
+ markAccountNeedsReauth(accountId, writerGeneration);
1722
+ clearThreadAccountMapForAccount(accountId);
1723
+ return;
1724
+ }
1725
+
1726
+ if (outcomeClass === "quota") {
1727
+ const { until, source } = computeQuotaCooldown(meta);
1728
+ // A reset timestamp is an advisory quota-window announcement. When the
1729
+ // selected native model belongs to a confirmed independent group, preserve
1730
+ // it there so a different group (Spark versus the shared native quota) can
1731
+ // still reach upstream. Explicit Retry-After/default 429s remain account-wide.
1732
+ if (source === "reset-derived" && quotaScope) {
1733
+ const prior = scopedHealthFor(accountId, quotaScope);
1734
+ const cooldownGeneration = (prior?.cooldownGeneration ?? 0) + 1;
1735
+ const ownsLease = meta.probeQuotaScope === quotaScope && ownsProbeLease(prior, meta);
1736
+ setScopedHealth(accountId, quotaScope, {
1737
+ consecutiveFailures: 0,
1738
+ lastFailureStatus,
1739
+ lastFailureAt: now,
1740
+ cooldownUntil: until,
1741
+ cooldownSince: now,
1742
+ cooldownSource: source,
1743
+ cooldownGeneration,
1744
+ ...(ownsLease
1745
+ ? { lastProbeAt: now }
1746
+ : {
1747
+ ...(prior?.probeLeaseId !== undefined ? { probeLeaseId: prior.probeLeaseId } : {}),
1748
+ ...(prior?.probeLeaseGeneration !== undefined ? { probeLeaseGeneration: prior.probeLeaseGeneration } : {}),
1749
+ ...(prior?.lastProbeAt !== undefined ? { lastProbeAt: prior.lastProbeAt } : {}),
1750
+ }),
1751
+ });
1752
+ // The shared native scope is the existing account-wide native behavior:
1753
+ // threads must leave it and new requests should prefer an eligible account.
1754
+ // Spark remains isolated so a same-account Terra/Luna combo fallback can run.
1755
+ if (quotaScope === "shared" && !meta.fixedAccount) {
1756
+ clearThreadAccountMapForAccount(accountId);
1757
+ notePoolRotationFailure(POOL_KEY_CODEX, accountId);
1758
+ if (getEffectiveActiveCodexAccountId(config) === accountId) {
1759
+ // Same-request 429 retry already picked via excludeAccountId — reuse it so
1760
+ // round-robin does not advance the ring a second time.
1761
+ const reused = meta.promoteAccountId && meta.promoteAccountId !== accountId
1762
+ ? meta.promoteAccountId
1763
+ : null;
1764
+ const fallback = reused ?? pickAlternateCodexAccount(config, accountId, now, quotaScope);
1765
+ if (fallback) promoteActiveCodexAccount(config, fallback);
1766
+ }
1767
+ }
1768
+ return;
1769
+ }
1770
+
1771
+ // A scoped probe that received an account-wide throttle is no longer live.
1772
+ const scopedProbe = meta.probeQuotaScope
1773
+ ? scopedHealthFor(accountId, meta.probeQuotaScope)
1774
+ : undefined;
1775
+ if (scopedProbe && meta.probeQuotaScope && ownsProbeLease(scopedProbe, meta)) {
1776
+ setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now));
1777
+ }
1778
+ const prior = upstreamHealth.get(accountId);
1779
+ // Every cooldown write bumps the generation so a probe issued against the
1780
+ // previous cooldown can no longer clear this one (#433).
1781
+ const cooldownGeneration = (prior?.cooldownGeneration ?? 0) + 1;
1782
+ // A failed probe concludes its lease; an unrelated 429 leaves the live probe alone.
1783
+ const ownsLease = ownsProbeLease(prior, meta);
1784
+ upstreamHealth.set(accountId, {
1785
+ consecutiveFailures: 0,
1786
+ lastFailureStatus,
1787
+ lastFailureAt: now,
1788
+ cooldownUntil: until,
1789
+ cooldownSince: now,
1790
+ cooldownSource: source,
1791
+ cooldownGeneration,
1792
+ ...(ownsLease
1793
+ ? { lastProbeAt: now }
1794
+ : {
1795
+ ...(prior?.probeLeaseId !== undefined ? { probeLeaseId: prior.probeLeaseId } : {}),
1796
+ ...(prior?.probeLeaseGeneration !== undefined ? { probeLeaseGeneration: prior.probeLeaseGeneration } : {}),
1797
+ ...(prior?.lastProbeAt !== undefined ? { lastProbeAt: prior.lastProbeAt } : {}),
1798
+ }),
1799
+ });
1800
+ if (!meta.fixedAccount) {
1801
+ clearThreadAccountMapForAccount(accountId);
1802
+ // An independent native quota request may discover an account-wide throttle,
1803
+ // but it still must not advance the shared RR ring or active cursor. The next
1804
+ // shared request observes the cooldown and chooses its own fallback.
1805
+ if (!isIndependentCodexQuotaScope(quotaScope)) {
1806
+ notePoolRotationFailure(POOL_KEY_CODEX, accountId);
1807
+ const effectiveActive = getEffectiveActiveCodexAccountId(config);
1808
+ if (effectiveActive === accountId) {
1809
+ // Same-request 429 retry already picked via excludeAccountId — reuse it so
1810
+ // round-robin does not advance the ring a second time.
1811
+ const reused = meta.promoteAccountId && meta.promoteAccountId !== accountId
1812
+ ? meta.promoteAccountId
1813
+ : null;
1814
+ const fallback = reused ?? pickAlternateCodexAccount(config, accountId, now, quotaScope);
1815
+ if (fallback) promoteActiveCodexAccount(config, fallback);
1816
+ }
1817
+ }
1818
+ }
1819
+ return;
1820
+ }
1821
+
1822
+ // transient (connect_error / timeout / 5xx)
1823
+ const current = upstreamHealth.get(accountId);
1824
+ const scopedProbe = meta.probeQuotaScope
1825
+ ? scopedHealthFor(accountId, meta.probeQuotaScope)
1826
+ : undefined;
1827
+ if (scopedProbe && meta.probeQuotaScope && ownsProbeLease(scopedProbe, meta)) {
1828
+ setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now));
1829
+ }
1830
+ // A transient failure concludes an owning probe; an unrelated 5xx must not
1831
+ // consume someone else's live lease or drop hard-cooldown bookkeeping (#433).
1832
+ const transientBase = ownsProbeLease(current, meta) ? withProbeLeaseReleased(current!, now) : current;
1833
+ const stale = current?.lastFailureAt ? now - current.lastFailureAt > CODEX_FAILURE_WINDOW_MS : false;
1834
+ const hardCooldownUntil = getCodexAccountCooldownUntil(accountId, now) ?? undefined;
1835
+ // Soft avoid + affinity clears are part of failover. When threshold is 0, leave
1836
+ // sticky sessions alone (same as shouldFailover / applyFailureFailover no-ops).
1837
+ const failoverThreshold = config.upstreamFailoverThreshold ?? 3;
1838
+ const consecutiveFailures = stale ? 1 : (current?.consecutiveFailures ?? 0) + 1;
1839
+ const failoverReady = failoverThreshold > 0 && consecutiveFailures >= failoverThreshold;
1840
+ const escalationMs = CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS[
1841
+ Math.min(Math.max(consecutiveFailures - failoverThreshold, 0), CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS.length - 1)
1842
+ ]!;
1843
+ const softAvoidUntil = failoverReady
1844
+ ? Math.max(
1845
+ getCodexAccountSoftAvoidUntil(accountId, now) ?? 0,
1846
+ now + escalationMs,
1847
+ )
1848
+ : undefined;
1849
+ upstreamHealth.set(accountId, {
1850
+ ...preservedCooldownFields(transientBase),
1851
+ consecutiveFailures,
1852
+ lastFailureStatus,
1853
+ lastFailureAt: now,
1854
+ ...(hardCooldownUntil ? { cooldownUntil: hardCooldownUntil } : {}),
1855
+ ...(softAvoidUntil !== undefined ? { softAvoidUntil } : {}),
1856
+ });
1857
+ // Drop this thread's pin immediately so the next continue can rebind without
1858
+ // waiting for the soft-avoid selectable check. Guard: only delete when the
1859
+ // thread is still pinned to the FAILING account — a late failure from account A
1860
+ // must not delete a newer healthy binding to account B (race: T→A, A fails,
1861
+ // T→B, late A failure must not delete B's mapping).
1862
+ if (!meta.fixedAccount && failoverReady && meta.threadId) {
1863
+ deleteThreadAffinitiesForAccount(meta.threadId, accountId);
1864
+ }
1865
+ // Once the account is past the failover streak, clear every thread still pinned
1866
+ // to it — matching 429 affinity behavior so "continue" cannot stay on a bad peer.
1867
+ if (!meta.fixedAccount && shouldFailover(config, accountId, now)) {
1868
+ clearThreadAccountMapForAccount(accountId);
1869
+ }
1870
+ if (
1871
+ !meta.fixedAccount
1872
+ && !isIndependentCodexQuotaScope(quotaScope)
1873
+ && getEffectiveActiveCodexAccountId(config) === accountId
1874
+ ) {
1875
+ applyFailureFailover(config, accountId, now, quotaScope);
1876
+ }
1877
+ }
1878
+
1879
+ export function formatCodexProviderForLog(providerName: string, accountId: string | null, config: OcxConfig): string {
1880
+ if (!accountId) return providerName;
1881
+ // The main Codex login participates in rotation as "main-pool" (MAIN_CODEX_ACCOUNT_ID) but is the
1882
+ // same physical account as the "main" passthrough (null accountId). Log both under the base provider
1883
+ // name so usage/tokens aggregate into a single row instead of splitting into `chatgpt` + `chatgpt-main`.
1884
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) return providerName;
1885
+ const account = (config.codexAccounts ?? [])
1886
+ .find(candidate => isSelectableCodexPoolAccount(candidate) && candidate.id === accountId);
1887
+ return account ? `${providerName}-${codexAccountLogLabel(account)}` : providerName;
1888
+ }