@bitkyc08/opencodex 2.55.0 → 2.57.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 (256) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  3. package/gui/dist/assets/{index-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +4 -3
  6. package/src/adapters/base.ts +21 -0
  7. package/src/adapters/codebuddy/adapter.ts +2 -1
  8. package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
  9. package/src/adapters/command-code.ts +1 -1
  10. package/src/adapters/cursor/envelope-echo.ts +8 -2
  11. package/src/adapters/cursor/transport-retry.ts +46 -1
  12. package/src/adapters/cursor.ts +4 -0
  13. package/src/adapters/google.ts +7 -7
  14. package/src/adapters/kiro/adapter.ts +42 -1
  15. package/src/adapters/kiro/payload.ts +17 -3
  16. package/src/adapters/kiro/reasoning.ts +70 -7
  17. package/src/adapters/kiro/stream.ts +8 -2
  18. package/src/adapters/kiro/wire.ts +2 -1
  19. package/src/adapters/kiro-events.ts +21 -13
  20. package/src/adapters/kiro-retry.ts +23 -4
  21. package/src/adapters/openai-chat/errors.ts +116 -0
  22. package/src/adapters/openai-chat/messages.ts +346 -0
  23. package/src/adapters/openai-chat/passthrough.ts +146 -0
  24. package/src/adapters/openai-chat/response-events.ts +117 -0
  25. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  26. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  27. package/src/adapters/openai-chat/tool-schema.ts +495 -0
  28. package/src/adapters/openai-chat/wire.ts +50 -0
  29. package/src/adapters/openai-chat.ts +40 -1452
  30. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  31. package/src/adapters/openai-responses/image-gen.ts +406 -0
  32. package/src/adapters/openai-responses/internal.ts +3 -0
  33. package/src/adapters/openai-responses/passthrough.ts +642 -0
  34. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  35. package/src/adapters/openai-responses/reasoning.ts +220 -0
  36. package/src/adapters/openai-responses/request-strips.ts +185 -0
  37. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  38. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  39. package/src/adapters/openai-responses/web-search.ts +156 -0
  40. package/src/adapters/openai-responses.ts +4 -2625
  41. package/src/bridge/errors.ts +58 -0
  42. package/src/bridge/internal.ts +174 -0
  43. package/src/bridge/response-json.ts +630 -0
  44. package/src/bridge/sse.ts +1462 -0
  45. package/src/bridge.ts +5 -2204
  46. package/src/chat/inbound.ts +12 -1
  47. package/src/claude/desktop-profile.ts +66 -9
  48. package/src/claude/outbound.ts +18 -0
  49. package/src/cli/account-main.ts +1 -1
  50. package/src/cli/capabilities.ts +2 -2
  51. package/src/cli/combo.ts +10 -1
  52. package/src/cli/index.ts +48 -5
  53. package/src/cli/registry.ts +2 -1
  54. package/src/cli/system-command.ts +4 -4
  55. package/src/clients/config-export.ts +7 -3
  56. package/src/codex/account-label.ts +14 -3
  57. package/src/codex/account-lifecycle.ts +3 -0
  58. package/src/codex/account-store.ts +184 -35
  59. package/src/codex/account-usability.ts +21 -0
  60. package/src/codex/auth-api/account-list.ts +507 -0
  61. package/src/codex/auth-api/http.ts +32 -0
  62. package/src/codex/auth-api/login-flow.ts +566 -0
  63. package/src/codex/auth-api/login-state.ts +64 -0
  64. package/src/codex/auth-api/main-account-probe.ts +331 -0
  65. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  66. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  67. package/src/codex/auth-api/reset-credit-service.ts +431 -0
  68. package/src/codex/auth-api/routes.ts +425 -0
  69. package/src/codex/auth-api/runtime-config.ts +48 -0
  70. package/src/codex/auth-api.ts +27 -3118
  71. package/src/codex/auth-context.ts +252 -35
  72. package/src/codex/catalog/aggregation.ts +80 -1
  73. package/src/codex/catalog/auto-review.ts +507 -0
  74. package/src/codex/catalog/build-entries.ts +981 -0
  75. package/src/codex/catalog/combo-member.ts +375 -0
  76. package/src/codex/catalog/derive-entry.ts +229 -0
  77. package/src/codex/catalog/effort.ts +0 -1
  78. package/src/codex/catalog/gated-native-warn.ts +63 -0
  79. package/src/codex/catalog/gather-capture.ts +533 -0
  80. package/src/codex/catalog/model-hints.ts +691 -0
  81. package/src/codex/catalog/model-visibility.ts +305 -0
  82. package/src/codex/catalog/provider-fetch.ts +52 -2942
  83. package/src/codex/catalog/provider-models.ts +685 -0
  84. package/src/codex/catalog/remote.ts +30 -0
  85. package/src/codex/catalog/restore.ts +132 -0
  86. package/src/codex/catalog/retained-sync.ts +714 -0
  87. package/src/codex/catalog/routed-gather.ts +895 -0
  88. package/src/codex/catalog/subagent-roster.ts +176 -0
  89. package/src/codex/catalog/sync.ts +52 -2698
  90. package/src/codex/cli-install-provenance.ts +7 -1
  91. package/src/codex/convergence.ts +7 -2
  92. package/src/codex/desktop-app/types.ts +11 -2
  93. package/src/codex/desktop-app/windows.ts +5 -5
  94. package/src/codex/inject/config-toml.ts +563 -0
  95. package/src/codex/inject/remove.ts +192 -0
  96. package/src/codex/inject/restore.ts +567 -0
  97. package/src/codex/inject/routing-classify.ts +109 -0
  98. package/src/codex/inject/routing-target.ts +125 -0
  99. package/src/codex/inject.ts +89 -1444
  100. package/src/codex/lineage.ts +458 -0
  101. package/src/codex/model-entitlements.ts +152 -15
  102. package/src/codex/pool-refresh-backoff.ts +161 -0
  103. package/src/codex/quota-rejection.ts +104 -15
  104. package/src/codex/routing/active-account.ts +194 -0
  105. package/src/codex/routing/cache-affinity.ts +70 -0
  106. package/src/codex/routing/cooldown-math.ts +285 -0
  107. package/src/codex/routing/health-store.ts +402 -0
  108. package/src/codex/routing/probe-lease.ts +358 -0
  109. package/src/codex/routing/selection.ts +780 -0
  110. package/src/codex/routing/thread-affinity.ts +586 -0
  111. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  112. package/src/codex/routing.ts +370 -2271
  113. package/src/codex/shim-fingerprint.ts +223 -0
  114. package/src/codex/shim-inspect.ts +175 -0
  115. package/src/codex/shim-probe.ts +367 -0
  116. package/src/codex/shim-restore-lock.ts +169 -0
  117. package/src/codex/shim-state-file.ts +151 -0
  118. package/src/codex/shim-templates.ts +265 -0
  119. package/src/codex/shim.ts +48 -1268
  120. package/src/codex/warmup.ts +1 -1
  121. package/src/combos/failover.ts +85 -0
  122. package/src/combos/request.ts +17 -10
  123. package/src/combos/types.ts +23 -2
  124. package/src/config/diagnostics.ts +705 -0
  125. package/src/config/feature-flags.ts +55 -0
  126. package/src/config/live-reconcile.ts +403 -0
  127. package/src/config/load-degrade.ts +880 -0
  128. package/src/config/mutation-lock.ts +244 -0
  129. package/src/config/openai-tier-backup.ts +268 -0
  130. package/src/config/pending-teardown.ts +31 -0
  131. package/src/config/persist-unlocked.ts +92 -0
  132. package/src/config/proxy-env.ts +188 -0
  133. package/src/config/salvage.ts +244 -0
  134. package/src/config/schema/config-schema.ts +640 -0
  135. package/src/config/schema/leaf-validators.ts +855 -0
  136. package/src/config/warn-memo.ts +28 -0
  137. package/src/config.ts +234 -4481
  138. package/src/generated/compatibility-version.json +649 -121
  139. package/src/images/loop.ts +1 -1
  140. package/src/lib/errors.ts +17 -0
  141. package/src/lib/request-execution-budget.ts +198 -23
  142. package/src/lib/spend-reservation-ledger.ts +958 -0
  143. package/src/lib/state-store-registrations.ts +6 -2
  144. package/src/lib/test-home-guard.ts +85 -1
  145. package/src/lib/upstream-retry.ts +132 -21
  146. package/src/lib/windows-elevation.ts +76 -14
  147. package/src/lib/workflow-budget.ts +553 -30
  148. package/src/oauth/index.ts +2 -2
  149. package/src/oauth/key-providers.ts +2 -2
  150. package/src/providers/kiro-models.ts +4 -3
  151. package/src/providers/label.ts +19 -1
  152. package/src/providers/model-discovery.ts +16 -0
  153. package/src/providers/quota/account-cache.ts +441 -0
  154. package/src/providers/quota/antigravity.ts +295 -0
  155. package/src/providers/quota/report-cache.ts +320 -0
  156. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  157. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  158. package/src/providers/quota.ts +324 -3079
  159. package/src/providers/registry/entries-core.ts +1228 -0
  160. package/src/providers/registry/entries-extended.ts +1213 -0
  161. package/src/providers/registry/model-seeds.ts +912 -0
  162. package/src/providers/registry/types.ts +352 -0
  163. package/src/providers/registry.ts +24 -3536
  164. package/src/responses/continuation-ownership.ts +29 -0
  165. package/src/responses/reasoning-envelope.ts +6 -3
  166. package/src/responses/state/replay-fingerprint.ts +80 -0
  167. package/src/responses/state/snapshot-codec.ts +104 -0
  168. package/src/responses/state/spill-failure.ts +118 -0
  169. package/src/responses/state/spill-queue.ts +665 -0
  170. package/src/responses/state/temp-recovery.ts +257 -0
  171. package/src/responses/state.ts +82 -1143
  172. package/src/routing/identity-domains.ts +456 -0
  173. package/src/routing/probe-lease.ts +613 -0
  174. package/src/server/chat-completions.ts +3 -1
  175. package/src/server/chat-native.ts +37 -9
  176. package/src/server/index/bounded-request.ts +88 -0
  177. package/src/server/index/live-sideband.ts +601 -0
  178. package/src/server/index/serve-options.ts +1766 -0
  179. package/src/server/index/startup-warnings.ts +213 -0
  180. package/src/server/index/websocket-handler.ts +339 -0
  181. package/src/server/index.ts +45 -2552
  182. package/src/server/inspection-tee.ts +107 -0
  183. package/src/server/live.ts +46 -1
  184. package/src/server/management/combo-routes.ts +10 -1
  185. package/src/server/management/route-registry.ts +26 -23
  186. package/src/server/management/shared.ts +8 -5
  187. package/src/server/management/workflow-budget-routes.ts +133 -0
  188. package/src/server/management-api.ts +12 -0
  189. package/src/server/relay-eager.ts +2 -0
  190. package/src/server/relay.ts +14 -19
  191. package/src/server/request-log-conversation.ts +9 -7
  192. package/src/server/request-log.ts +372 -4
  193. package/src/server/response-log-body.ts +153 -0
  194. package/src/server/responses/account-change-state.ts +307 -0
  195. package/src/server/responses/adapter-continuation.ts +540 -0
  196. package/src/server/responses/adapter-delivery.ts +208 -0
  197. package/src/server/responses/adapter-dispatch.ts +1042 -0
  198. package/src/server/responses/codex-ws-wire.ts +5 -0
  199. package/src/server/responses/collaboration.ts +74 -4
  200. package/src/server/responses/combo-session-recall.ts +68 -8
  201. package/src/server/responses/compact.ts +113 -17
  202. package/src/server/responses/completion-policy.ts +33 -0
  203. package/src/server/responses/core-auth.ts +529 -0
  204. package/src/server/responses/core-codex-account.ts +907 -0
  205. package/src/server/responses/core-combo-failure.ts +210 -0
  206. package/src/server/responses/core-combo.ts +787 -0
  207. package/src/server/responses/core-errors.ts +170 -0
  208. package/src/server/responses/core-lifetime.ts +95 -0
  209. package/src/server/responses/core-normalize.ts +350 -0
  210. package/src/server/responses/core-opaque-recovery.ts +380 -0
  211. package/src/server/responses/core-options.ts +159 -0
  212. package/src/server/responses/core-replay.ts +298 -0
  213. package/src/server/responses/core.ts +192 -8893
  214. package/src/server/responses/encrypted-payload.ts +0 -1
  215. package/src/server/responses/input-admission.ts +126 -6
  216. package/src/server/responses/passthrough-delivery.ts +869 -0
  217. package/src/server/responses/passthrough-dispatch.ts +1494 -0
  218. package/src/server/responses/passthrough-error.ts +38 -2
  219. package/src/server/responses/passthrough-execution.ts +54 -0
  220. package/src/server/responses/request-prepare.ts +1080 -0
  221. package/src/server/responses/request-send-budget.ts +259 -0
  222. package/src/server/responses/request-sidecar-auth.ts +149 -0
  223. package/src/server/responses/request-spend.ts +147 -0
  224. package/src/server/responses/request-transport.ts +803 -0
  225. package/src/server/responses/response-effects.ts +157 -0
  226. package/src/server/responses/run-turn-execution.ts +476 -0
  227. package/src/server/responses/sidecar-execution.ts +463 -0
  228. package/src/server/responses/terminal-guard.ts +65 -4
  229. package/src/server/responses-image-gen-repair.ts +1 -1
  230. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  231. package/src/server/workflow-refusal.ts +84 -0
  232. package/src/service/windows-ops.ts +210 -16
  233. package/src/service/windows-scheduler.ts +28 -21
  234. package/src/service.ts +1 -1
  235. package/src/types/config.ts +34 -1
  236. package/src/types/request.ts +8 -5
  237. package/src/types/tools.ts +24 -0
  238. package/src/types.ts +2 -0
  239. package/src/update/index.ts +10 -0
  240. package/src/update/stop-contract.d.mts +1 -0
  241. package/src/update/stop-contract.mjs +19 -0
  242. package/src/update/stop-decision.d.mts +1 -1
  243. package/src/update/stop-decision.mjs +12 -3
  244. package/src/usage/log.ts +147 -1
  245. package/src/usage/summary.ts +171 -21
  246. package/src/vision/anthropic-describe.ts +1 -1
  247. package/src/vision/describe.ts +5 -5
  248. package/src/web-search/anthropic-executor.ts +1 -1
  249. package/src/web-search/exa-executor.ts +1 -1
  250. package/src/web-search/executor.ts +1 -1
  251. package/src/web-search/gemini-executor.ts +1 -1
  252. package/src/web-search/loop.ts +1 -1
  253. package/src/web-search/ollama-executor.ts +1 -1
  254. package/src/web-search/parse.ts +67 -14
  255. package/src/web-search/passthrough-bridge.ts +64 -31
  256. package/src/web-search/xai-executor.ts +1 -1
@@ -0,0 +1,780 @@
1
+ import { isCodexAccountPaused } from "../account-pause";
2
+ import { codexAccountPriorityLookup, pinnedCodexAccountId } from "../account-priority";
3
+ import { isSelectableCodexPoolAccount } from "../account-id";
4
+ import { isAccountNeedsReauth } from "../account-runtime-state";
5
+ import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "../account-usability";
6
+ import { isCodexPoolRefreshCooling } from "../pool-refresh-backoff";
7
+ import {
8
+ normalizeAccountPoolStickyLimit,
9
+ normalizeCodexAccountPoolStrategy,
10
+ notePoolRotationSuccess,
11
+ peekRoundRobinAccount,
12
+ pickRoundRobinAccount,
13
+ selectPriorityTier,
14
+ } from "../pool-rotation";
15
+ import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota, resetAtToMs } from "../quota";
16
+ import { codexPlanKey } from "../plan";
17
+ import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan, hasMainAccountRefreshGrant } from "../main-account";
18
+ import type { OcxConfig } from "../../types";
19
+ import { CODEX_FAILURE_WINDOW_MS, computeCodexUsageScore } from "./cooldown-math";
20
+ import {
21
+ codexPoolKeyForScope,
22
+ dropSpentCredentialFailure,
23
+ getAccountHealth,
24
+ getCodexQuotaHealthSnapshot,
25
+ isCodexAccountSoftAvoided,
26
+ isCodexQuotaAvoided,
27
+ isIndependentCodexQuotaScope,
28
+ type CodexQuotaScope,
29
+ } from "./health-store";
30
+ import { bindThreadAffinity, type CodexAffinityReason } from "./thread-affinity";
31
+ import {
32
+ getEffectiveActiveCodexAccountId,
33
+ manualPreferenceBlocks,
34
+ promoteActiveCodexAccount,
35
+ rememberActiveCodexAccount,
36
+ setActiveCodexAccount,
37
+ } from "./active-account";
38
+
39
+ /**
40
+ * Plan keys the operator excluded from automatic rotation. Absent or empty means no policy, so an
41
+ * existing install rotates exactly as before. Compared with `codexPlanKey` because the stored plan
42
+ * is an unrestricted provider string whose casing this repository does not control.
43
+ */
44
+ function excludedCodexPoolPlanKeys(config: OcxConfig): ReadonlySet<string> | undefined {
45
+ const configured = config.codexPool?.excludedPlans;
46
+ if (!configured?.length) return undefined;
47
+ const keys = configured
48
+ .map(plan => codexPlanKey(plan))
49
+ .filter((key): key is string => key !== undefined);
50
+ return keys.length > 0 ? new Set(keys) : undefined;
51
+ }
52
+
53
+ /**
54
+ * Whether the operator's plan policy removes this account from automatic selection.
55
+ *
56
+ * Modelled on pause rather than usability: an excluded account keeps its credential, quota history,
57
+ * and affinity, stays visible on the account surface, and is still reachable by explicit account
58
+ * selection. Only automatic rotation skips it, which is the distinction #4211 asked for.
59
+ *
60
+ * It is checked in the same two places pause is checked, and that is not redundancy. The eligible
61
+ * list is consulted only when routing picks a NEW account; an already-active or already-affined
62
+ * account is served straight from {@link isCodexAccountSelectable}. A lapsed subscription leaves
63
+ * behind exactly that account, so a policy that filtered only the eligible list would miss the case
64
+ * it exists for.
65
+ *
66
+ * `__main__` is exempt. {@link getPoolAccountPlanForSelection} withholds the main plan during a
67
+ * selection-only drain so routing never reads the fenced native credential for it, so a rule that
68
+ * covered main would disagree with itself between drain and ordinary routing.
69
+ */
70
+ export function isCodexAccountPlanExcluded(
71
+ config: OcxConfig,
72
+ accountId: string,
73
+ precomputed?: ReadonlySet<string>,
74
+ ): boolean {
75
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) return false;
76
+ // Callers that test a whole list pass the set once rather than rebuilding it per row.
77
+ const excluded = precomputed ?? excludedCodexPoolPlanKeys(config);
78
+ if (!excluded) return false;
79
+ const plan = codexPlanKey(getPoolAccountPlan(config, accountId));
80
+ return plan !== undefined && excluded.has(plan);
81
+ }
82
+
83
+ export function isCodexAccountSelectable(
84
+ config: OcxConfig,
85
+ accountId: string,
86
+ now: number,
87
+ quotaScope?: CodexQuotaScope,
88
+ selectionOptions?: CodexAccountUsabilityOptions,
89
+ ): boolean {
90
+ return !isCodexAccountPaused(config, accountId)
91
+ && !isCodexAccountPlanExcluded(config, accountId)
92
+ && getCodexQuotaHealthSnapshot(accountId, quotaScope, now) === null
93
+ && !isCodexQuotaAvoided(accountId, quotaScope, now)
94
+ && !isCodexAccountSoftAvoided(accountId, now)
95
+ && !isCodexPoolRefreshCooling(accountId, now)
96
+ && isCodexAccountUsable(config, accountId, selectionOptions);
97
+ }
98
+
99
+ /**
100
+ * Which guard in {@link isCodexAccountSelectable} refused this account, if any.
101
+ *
102
+ * Deliberately the same predicates in the same order as that function, because the point is to
103
+ * REPORT the guard that actually fired rather than to re-derive a plausible-looking cause. An
104
+ * earlier version of the release reason checked only a subset and let a paused, plan-excluded,
105
+ * cooled-down or quota-avoided release fall through to a quota fallback, which named something
106
+ * routing never used -- a diagnostic that is confidently wrong in exactly the cases an operator
107
+ * would consult it for (#4598).
108
+ */
109
+ export function codexAccountBlockReason(
110
+ config: OcxConfig,
111
+ accountId: string,
112
+ now: number,
113
+ quotaScope?: CodexQuotaScope,
114
+ selectionOptions?: CodexAccountUsabilityOptions,
115
+ ): CodexAffinityReason | undefined {
116
+ if (isCodexAccountPaused(config, accountId)) return "paused";
117
+ if (isCodexAccountPlanExcluded(config, accountId)) return "plan_excluded";
118
+ if (getCodexQuotaHealthSnapshot(accountId, quotaScope, now) !== null) return "cooldown";
119
+ if (isCodexQuotaAvoided(accountId, quotaScope, now)) return "quota_avoided";
120
+ if (isCodexAccountSoftAvoided(accountId, now)) return "transient";
121
+ if (isCodexPoolRefreshCooling(accountId, now)) return "transient";
122
+ if (!isCodexAccountUsable(config, accountId, selectionOptions)) return "unusable";
123
+ return undefined;
124
+ }
125
+
126
+ /**
127
+ * Drop accounts a confirmed roster says cannot serve this model, unless that leaves nothing.
128
+ *
129
+ * The restore-on-empty is the whole safety argument, not a defensive afterthought. Roster
130
+ * evidence can be wrong in the direction that matters: a shard that has not caught up reports a
131
+ * denial for a model the account genuinely owns, and #3022 is what happens when absence is
132
+ * allowed to remove a model outright. Because this can only ever return a non-empty subset of a
133
+ * list the caller already computed, no pool that would have found a working account can be left
134
+ * without one — the worst case is the selection that ships today.
135
+ *
136
+ * It is an ordering rule rather than an eligibility one for the same reason. Nothing below
137
+ * reports `model_not_entitled`, nothing refuses before dispatch, and the existing bounded
138
+ * alternate-account retry on an exact unsupported-model 400 stays exactly where it is as the
139
+ * safety net. This only stops the pool from CHOOSING an account that has already told us it
140
+ * cannot serve the model (#4768).
141
+ *
142
+ * An operator's manual pin is never dropped. Roster evidence orders the pool's own discretion;
143
+ * it does not overrule an explicit human choice, and removing the pinned account here would do
144
+ * more than demote it -- `selectPriorityTier` reads the pin to lower the tier ceiling, so a pin
145
+ * filtered out beforehand stops acting as a ceiling at all and silently re-enables tiers the
146
+ * operator had excluded. An operator who pins an account upstream will refuse still gets the
147
+ * alternate-account retry; what they do not get is the pool quietly deciding they were wrong.
148
+ */
149
+ export function withoutModelDeniedAccounts(
150
+ ids: readonly string[],
151
+ denied: ReadonlySet<string> | undefined,
152
+ pinned?: string,
153
+ ): readonly string[] {
154
+ if (denied === undefined || ids.length === 0) return ids;
155
+ const remaining = ids.filter(id => !denied.has(id) || id === pinned);
156
+ return remaining.length > 0 ? remaining : ids;
157
+ }
158
+
159
+ export function getEligiblePoolAccounts(
160
+ config: OcxConfig,
161
+ excludeId?: string,
162
+ now = Date.now(),
163
+ quotaScope?: CodexQuotaScope,
164
+ selectionOptions?: CodexAccountUsabilityOptions,
165
+ skipFailoverReadyCandidates = false,
166
+ ): readonly string[] {
167
+ const excludedPlans = excludedCodexPoolPlanKeys(config);
168
+ const ids = (config.codexAccounts ?? [])
169
+ .filter(account => isSelectableCodexPoolAccount(account)
170
+ && account.id !== excludeId
171
+ && !isCodexAccountPaused(config, account.id)
172
+ && !isCodexAccountPlanExcluded(config, account.id, excludedPlans)
173
+ && !isAccountNeedsReauth(account.id)
174
+ && (!skipFailoverReadyCandidates || !shouldFailover(config, account.id, now)))
175
+ .filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null)
176
+ .filter(account => !isCodexAccountSoftAvoided(account.id, now))
177
+ .filter(account => !isCodexQuotaAvoided(account.id, quotaScope, now))
178
+ .filter(account => !isCodexPoolRefreshCooling(account.id, now))
179
+ .filter(account => isCodexAccountUsable(config, account.id, selectionOptions))
180
+ .map(account => account.id);
181
+ // The main Codex account is not stored in config.codexAccounts; include it as a
182
+ // first-class rotation candidate when its read-only token is usable (Option A).
183
+ if (
184
+ excludeId !== MAIN_CODEX_ACCOUNT_ID
185
+ && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
186
+ && (!isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID) || hasMainAccountRefreshGrant())
187
+ && getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null
188
+ && !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now)
189
+ // The main login is not in `config.codexAccounts`, so it never passes through the
190
+ // filters above and this is the only place an avoidance window can exclude it. Without
191
+ // this the window a refusal announced applies to the pool but not to the account that
192
+ // earned it: the cooldown caps at fifteen minutes, the window runs up to six hours, and
193
+ // in between the main account returns as a first-class candidate.
194
+ && !isCodexQuotaAvoided(MAIN_CODEX_ACCOUNT_ID, quotaScope, now)
195
+ && !isCodexPoolRefreshCooling(MAIN_CODEX_ACCOUNT_ID, now)
196
+ && (!skipFailoverReadyCandidates || !shouldFailover(config, MAIN_CODEX_ACCOUNT_ID, now))
197
+ && isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions)
198
+ ) {
199
+ ids.unshift(MAIN_CODEX_ACCOUNT_ID);
200
+ }
201
+ // Single choke point for selection order: every strategy, failover, and preview
202
+ // reaches the pool through here, so tiering applies once rather than per picker.
203
+ // Eligibility above is unchanged — this only narrows an already-eligible list.
204
+ //
205
+ // Model entitlement is applied BEFORE the priority tier, because a tier is a quota-ordering
206
+ // question and an account that cannot serve the model at all should not be the reason a tier
207
+ // is selected. Both steps narrow an already-eligible list and neither can empty it.
208
+ const pinned = pinnedCodexAccountId(config);
209
+ return selectPriorityTier(
210
+ withoutModelDeniedAccounts(ids, selectionOptions?.deniedModelAccountIds, pinned),
211
+ codexAccountPriorityLookup(config),
212
+ id => hasCodexQuotaHeadroom(config, id, selectionOptions, now),
213
+ pinned,
214
+ );
215
+ }
216
+
217
+ function listEligibleCodexAccountIds(
218
+ config: OcxConfig,
219
+ now: number,
220
+ quotaScope?: CodexQuotaScope,
221
+ selectionOptions?: CodexAccountUsabilityOptions,
222
+ ): readonly string[] {
223
+ return getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions);
224
+ }
225
+
226
+ /** Shared reset timestamps are not evidence for independent model-quota groups. */
227
+ export function accountPoolStrategyForScope(config: OcxConfig, quotaScope?: CodexQuotaScope) {
228
+ const strategy = normalizeCodexAccountPoolStrategy(config.accountPoolStrategy);
229
+ return strategy === "reset-first" && isIndependentCodexQuotaScope(quotaScope) ? "quota" : strategy;
230
+ }
231
+
232
+ function stickyLimitForConfig(config: OcxConfig): number {
233
+ return normalizeAccountPoolStickyLimit(config.accountPoolStickyLimit);
234
+ }
235
+
236
+ /**
237
+ * Whether an account still has quota to give under the auto-switch threshold.
238
+ *
239
+ * Fill-first and the priority tier filter share this predicate, and share both of
240
+ * its escape hatches. A disabled threshold means only health, pause, and reauth
241
+ * may drain an account; unknown usage is a guess, so it must neither force
242
+ * fill-first off the active account nor drain a tier that was simply never
243
+ * primed. A genuinely exhausted account 429s into cooldown and leaves
244
+ * eligibility on its own.
245
+ */
246
+ export function hasCodexQuotaHeadroom(
247
+ config: OcxConfig,
248
+ accountId: string,
249
+ selectionOptions?: CodexAccountUsabilityOptions,
250
+ now: number = Date.now(),
251
+ ): boolean {
252
+ const threshold = config.autoSwitchThreshold ?? 80;
253
+ if (threshold <= 0) return true;
254
+ const usage = computeCodexUsageScore(
255
+ getAccountQuota(accountId),
256
+ getPoolAccountPlanForSelection(config, accountId, selectionOptions),
257
+ now,
258
+ );
259
+ if (isUnknownUsage(usage)) return true;
260
+ return usage < threshold;
261
+ }
262
+
263
+ /**
264
+ * Is a live binding held for its prompt cache?
265
+ *
266
+ * Unset means yes. Cache affinity shipped as an opt-in flag (#4292) and then #4546 measured
267
+ * what the default costs: a pool whose accounts all sit in the 80-99% band hands a bound
268
+ * conversation from account to account, and because provider prompt caches are account-isolated
269
+ * every hop re-sends the entire prefix. An install that has never heard of this flag is exactly
270
+ * the install that gets hurt by it, so the protection cannot be something you have to find.
271
+ *
272
+ * `false` restores capacity-first routing byte-for-byte. It is a real choice -- a pinned thread
273
+ * on a busy account pays latency -- and it stays available; it is just no longer the default.
274
+ */
275
+ export function isCacheAffinityEnabled(config: OcxConfig): boolean {
276
+ return config.pool?.cacheAffinity !== false;
277
+ }
278
+
279
+ /** Earliest future shared short/weekly reset; missing evidence and ties use usage order. */
280
+ export function pickResetFirstCodexAccount(
281
+ config: OcxConfig,
282
+ ids: readonly string[],
283
+ now: number,
284
+ selectionOptions?: CodexAccountUsabilityOptions,
285
+ ): string | null {
286
+ const available = ids.filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now));
287
+ if (available.length === 0) return pickLowestUsageAmong(config, ids, selectionOptions, now);
288
+ let earliest = Number.POSITIVE_INFINITY;
289
+ let candidates: string[] = [];
290
+ for (const id of available) {
291
+ const quota = getAccountQuota(id);
292
+ const resets = [quota?.shortResetAt, quota?.weeklyResetAt]
293
+ .filter((reset): reset is number => typeof reset === "number" && Number.isFinite(reset))
294
+ .map(resetAtToMs)
295
+ .filter(reset => reset > now);
296
+ const next = Math.min(...resets);
297
+ if (next < earliest) {
298
+ earliest = next;
299
+ candidates = [id];
300
+ } else if (next === earliest) candidates.push(id);
301
+ }
302
+ return pickLowestUsageAmong(config, candidates, selectionOptions, now);
303
+ }
304
+
305
+ /**
306
+ * Fill-first: keep selectable active under threshold; otherwise advance to the next
307
+ * eligible id in stable sorted order after the current active (wrapping).
308
+ */
309
+ function pickFillFirstCodexAccount(
310
+ config: OcxConfig,
311
+ now: number,
312
+ quotaScope?: CodexQuotaScope,
313
+ selectionOptions?: CodexAccountUsabilityOptions,
314
+ ): string | null {
315
+ const eligible = listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions);
316
+ if (eligible.length === 0) return null;
317
+
318
+ const active = getEffectiveActiveCodexAccountId(config);
319
+ if (active && eligible.includes(active) && hasCodexQuotaHeadroom(config, active, selectionOptions, now)) {
320
+ return active;
321
+ }
322
+
323
+ return pickNextFillFirstCodexAccount(config, active ?? null, eligible, now, selectionOptions);
324
+ }
325
+
326
+ /** Next eligible account in stable order after `afterId` (wrapping). */
327
+ function pickNextFillFirstCodexAccount(
328
+ config: OcxConfig,
329
+ afterId: string | null,
330
+ eligible: readonly string[] = listEligibleCodexAccountIds(config, Date.now()),
331
+ now = Date.now(),
332
+ selectionOptions?: CodexAccountUsabilityOptions,
333
+ ): string | null {
334
+ if (eligible.length === 0) return null;
335
+ const ordered = [...eligible].sort((a, b) => a.localeCompare(b));
336
+ if (!afterId) {
337
+ // Prefer an under-threshold account when starting with no active cursor.
338
+ for (const id of ordered) {
339
+ if (hasCodexQuotaHeadroom(config, id, selectionOptions, now)) return id;
340
+ }
341
+ return ordered[0] ?? null;
342
+ }
343
+
344
+ const allConfigured = [
345
+ ...(isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions) || afterId === MAIN_CODEX_ACCOUNT_ID
346
+ ? [MAIN_CODEX_ACCOUNT_ID]
347
+ : []),
348
+ ...(config.codexAccounts ?? []).filter(account => !account.isMain).map(account => account.id),
349
+ ];
350
+ const stableAll = [...new Set(allConfigured)].sort((a, b) => a.localeCompare(b));
351
+ const startIdx = stableAll.indexOf(afterId);
352
+ if (startIdx < 0) {
353
+ for (const id of ordered) {
354
+ if (hasCodexQuotaHeadroom(config, id, selectionOptions, now)) return id;
355
+ }
356
+ return ordered[0] ?? null;
357
+ }
358
+
359
+ // Skip successors that are also at/above threshold (known drained usage).
360
+ let fallback: string | null = null;
361
+ for (let step = 1; step <= stableAll.length; step++) {
362
+ const candidate = stableAll[(startIdx + step) % stableAll.length]!;
363
+ if (!eligible.includes(candidate)) continue;
364
+ if (!fallback) fallback = candidate;
365
+ if (hasCodexQuotaHeadroom(config, candidate, selectionOptions, now)) return candidate;
366
+ }
367
+ return fallback ?? ordered[0] ?? null;
368
+ }
369
+
370
+ /**
371
+ * Unbound new-session pick for round-robin / fill-first. Returns null to fall through
372
+ * to the legacy quota path (or when the strategy is quota).
373
+ *
374
+ * When `commit` is true (resolve path), advances RR state. `commitSharedActive`
375
+ * and `commitAffinity` independently control the two cross-request side effects:
376
+ * model-scoped entitlement selection can bind a new task without replacing an
377
+ * existing task binding or global active choice. Preview remains a dry-run peek.
378
+ *
379
+ * Automatic strategy picks never sync-write config; only manual selection persists active.
380
+ *
381
+ * Known limitation (follow-up): when a subagent preview peeks an RR account and the request
382
+ * then falls back to a non-Codex provider, the ring is not reserved/committed. Prefer seeding
383
+ * the peeked account if that path becomes load-bearing.
384
+ */
385
+ export function pickUnboundStrategyAccount(
386
+ config: OcxConfig,
387
+ threadId: string | null,
388
+ now: number,
389
+ commit: boolean,
390
+ quotaScope?: CodexQuotaScope,
391
+ selectionOptions?: CodexAccountUsabilityOptions,
392
+ commitSharedActive = commit,
393
+ commitAffinity = commit,
394
+ ): string | null {
395
+ const strategy = accountPoolStrategyForScope(config, quotaScope);
396
+ if (strategy === "quota") return null;
397
+ const poolKey = codexPoolKeyForScope(quotaScope);
398
+
399
+ let picked: string | null = null;
400
+ if (strategy === "round-robin") {
401
+ const eligible = listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions);
402
+ const limit = stickyLimitForConfig(config);
403
+ if (!commit) {
404
+ return peekRoundRobinAccount(poolKey, eligible, limit);
405
+ }
406
+ picked = pickRoundRobinAccount(poolKey, eligible, limit);
407
+ if (!picked) return null;
408
+ if (commitSharedActive) {
409
+ if (!isIndependentCodexQuotaScope(quotaScope)
410
+ && !manualPreferenceBlocks(codexPoolKeyForScope(quotaScope), picked)) {
411
+ rememberActiveCodexAccount(config, picked);
412
+ }
413
+ }
414
+ if (commitAffinity && threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
415
+ notePoolRotationSuccess(poolKey, picked, limit);
416
+ return picked;
417
+ }
418
+
419
+ if (strategy === "fill-first" || strategy === "reset-first") {
420
+ picked = strategy === "reset-first"
421
+ ? pickResetFirstCodexAccount(config, listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions), now, selectionOptions)
422
+ : pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions);
423
+ if (!picked) return null;
424
+ if (commitSharedActive) {
425
+ if (!isIndependentCodexQuotaScope(quotaScope)
426
+ && !manualPreferenceBlocks(codexPoolKeyForScope(quotaScope), picked)) {
427
+ rememberActiveCodexAccount(config, picked);
428
+ }
429
+ }
430
+ if (commitAffinity && threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
431
+ return picked;
432
+ }
433
+
434
+ return null;
435
+ }
436
+
437
+ export function getPoolAccountPlan(config: OcxConfig, accountId: string): string | undefined {
438
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) return getMainAccountPlan();
439
+ return (config.codexAccounts ?? [])
440
+ .find(account => isSelectableCodexPoolAccount(account) && account.id === accountId)?.plan;
441
+ }
442
+
443
+ /** Selection-only main routing must not lazily read the fenced native credential for its plan. */
444
+ export function getPoolAccountPlanForSelection(
445
+ config: OcxConfig,
446
+ accountId: string,
447
+ selectionOptions?: CodexAccountUsabilityOptions,
448
+ ): string | undefined {
449
+ if (accountId === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) {
450
+ return undefined;
451
+ }
452
+ return getPoolAccountPlan(config, accountId);
453
+ }
454
+
455
+ /** Shared routing state must ignore a request-scoped entitlement roster. */
456
+ export function sharedStateSelectionOptions(
457
+ selectionOptions?: CodexAccountUsabilityOptions,
458
+ ): Pick<
459
+ CodexAccountUsabilityOptions,
460
+ "nativeMainSelectionOnly" | "isMainAccountTokenLive"
461
+ > | undefined {
462
+ if (!selectionOptions) return undefined;
463
+ return {
464
+ ...(selectionOptions.nativeMainSelectionOnly !== undefined
465
+ ? { nativeMainSelectionOnly: selectionOptions.nativeMainSelectionOnly }
466
+ : {}),
467
+ ...(selectionOptions.isMainAccountTokenLive
468
+ ? { isMainAccountTokenLive: selectionOptions.isMainAccountTokenLive }
469
+ : {}),
470
+ };
471
+ }
472
+
473
+ export function pickLowerUsageAccount(
474
+ config: OcxConfig,
475
+ active: string,
476
+ activeUsage: number,
477
+ now: number,
478
+ quotaScope?: CodexQuotaScope,
479
+ selectionOptions?: CodexAccountUsabilityOptions,
480
+ skipFailoverReadyCandidates = false,
481
+ ): string {
482
+ let best = active;
483
+ let bestUsage = activeUsage;
484
+ for (const id of getEligiblePoolAccounts(
485
+ config,
486
+ active,
487
+ now,
488
+ quotaScope,
489
+ selectionOptions,
490
+ skipFailoverReadyCandidates,
491
+ )) {
492
+ const usage = computeCodexUsageScore(
493
+ getAccountQuota(id),
494
+ getPoolAccountPlanForSelection(config, id, selectionOptions),
495
+ now,
496
+ );
497
+ if (usage < bestUsage) {
498
+ best = id;
499
+ bestUsage = usage;
500
+ }
501
+ }
502
+ return best;
503
+ }
504
+
505
+ /** Coolest account in an already-selected candidate list; first index wins ties. */
506
+ export function pickLowestUsageAmong(
507
+ config: OcxConfig,
508
+ ids: readonly string[],
509
+ selectionOptions?: CodexAccountUsabilityOptions,
510
+ now: number = Date.now(),
511
+ ): string | null {
512
+ let best: string | null = null;
513
+ let bestUsage = Number.POSITIVE_INFINITY;
514
+ for (const id of ids) {
515
+ const usage = computeCodexUsageScore(
516
+ getAccountQuota(id),
517
+ getPoolAccountPlanForSelection(config, id, selectionOptions),
518
+ now,
519
+ );
520
+ if (usage < bestUsage) {
521
+ best = id;
522
+ bestUsage = usage;
523
+ }
524
+ }
525
+ return best;
526
+ }
527
+
528
+ export function pickLowestUsageCodexAccount(
529
+ config: OcxConfig,
530
+ excludeId?: string,
531
+ now = Date.now(),
532
+ quotaScope?: CodexQuotaScope,
533
+ selectionOptions?: CodexAccountUsabilityOptions,
534
+ ): string | null {
535
+ return pickLowestUsageAmong(
536
+ config,
537
+ getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions),
538
+ selectionOptions,
539
+ now,
540
+ );
541
+ }
542
+
543
+ /**
544
+ * Strategy-aware alternate after a cooled/excluded account (same-request 429 retry
545
+ * and active promotion). Quota keeps lowest-usage; fill-first advances stable order;
546
+ * round-robin takes the next ring pick (caller should have noted the failure).
547
+ */
548
+ export function pickAlternateCodexAccount(
549
+ config: OcxConfig,
550
+ excludeId: string,
551
+ now = Date.now(),
552
+ quotaScope?: CodexQuotaScope,
553
+ selectionOptions?: CodexAccountUsabilityOptions,
554
+ ): string | null {
555
+ const strategy = accountPoolStrategyForScope(config, quotaScope);
556
+ // The exclusion is passed into eligibility rather than post-filtered off its
557
+ // result: when the excluded account is the only healthy member of the top
558
+ // tier, the tier walk must be free to descend instead of selecting that tier
559
+ // and then handing back an empty list.
560
+ if (strategy === "round-robin") {
561
+ const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions);
562
+ return pickRoundRobinAccount(codexPoolKeyForScope(quotaScope), eligible, stickyLimitForConfig(config));
563
+ }
564
+ if (strategy === "fill-first") {
565
+ const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions);
566
+ return pickNextFillFirstCodexAccount(config, excludeId, eligible, now, selectionOptions);
567
+ }
568
+ if (strategy === "reset-first") {
569
+ return pickResetFirstCodexAccount(config, getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions), now, selectionOptions);
570
+ }
571
+ return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope, selectionOptions);
572
+ }
573
+
574
+ /**
575
+ * The account {@link pickAlternateCodexAccount} WOULD return, without returning it.
576
+ *
577
+ * Only the round-robin branch has a side effect -- `pickRoundRobinAccount` commits the pick and
578
+ * advances the ring -- so every other strategy delegates rather than growing a second copy of
579
+ * the selection rule that could drift from it.
580
+ *
581
+ * This exists because preview and resolve have to agree on the FIRST transient detour, not just
582
+ * on later ones. Preview feeds subagent model-availability scoring, so a preview that reported
583
+ * the bound account while resolve was about to serve from a cool sibling could retire a model
584
+ * over usage the request would never have touched.
585
+ */
586
+ export function peekAlternateCodexAccount(
587
+ config: OcxConfig,
588
+ excludeId: string,
589
+ now: number,
590
+ quotaScope?: CodexQuotaScope,
591
+ selectionOptions?: CodexAccountUsabilityOptions,
592
+ ): string | null {
593
+ if (accountPoolStrategyForScope(config, quotaScope) === "round-robin") {
594
+ const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions);
595
+ return peekRoundRobinAccount(codexPoolKeyForScope(quotaScope), eligible, stickyLimitForConfig(config));
596
+ }
597
+ return pickAlternateCodexAccount(config, excludeId, now, quotaScope, selectionOptions);
598
+ }
599
+
600
+ export function isUnknownUsage(usage: number): boolean {
601
+ return usage >= CODEX_UNKNOWN_USAGE_SCORE;
602
+ }
603
+
604
+ /**
605
+ * Correct a shared cursor that names an account this model's own roster denies (#4768).
606
+ *
607
+ * {@link getEligiblePoolAccounts} is not the only door into selection. An account that is already
608
+ * ACTIVE is served straight from {@link isCodexAccountSelectable} and never passes through the
609
+ * eligible list, so ordering that list alone left the exact case the issue reports: once the Free
610
+ * account becomes the cursor, every Sol/Astra request keeps going to it and keeps taking the
611
+ * upstream unsupported-model 400. {@link pickPriorityPreemption} does not cover it either -- it
612
+ * refuses to move toward a tier that does not strictly outrank the active one, which is the usual
613
+ * shape here.
614
+ *
615
+ * Three properties keep this inside "order the already-eligible set" rather than widening it.
616
+ * It admits nothing: the replacement comes from {@link getEligiblePoolAccounts}, so every
617
+ * eligibility guard has already passed on it. It cannot fail: with no entitled alternative the
618
+ * active account is returned unchanged, so this can never turn a served request into `none`.
619
+ * And it changes nothing without evidence: absent `deniedModelAccountIds`, or an active account
620
+ * nobody denied, it is the identity function.
621
+ *
622
+ * The caller must NOT persist the result. This is one request's correction for one model, in the
623
+ * same spirit as a model detour; the operator's cursor is theirs. A pinned active account is
624
+ * exempt outright, for the reason {@link withoutModelDeniedAccounts} gives.
625
+ */
626
+ export function preferModelEntitledAccount(
627
+ config: OcxConfig,
628
+ active: string,
629
+ now: number,
630
+ quotaScope?: CodexQuotaScope,
631
+ selectionOptions?: CodexAccountUsabilityOptions,
632
+ ): string {
633
+ const denied = selectionOptions?.deniedModelAccountIds;
634
+ if (denied === undefined || !denied.has(active)) return active;
635
+ if (pinnedCodexAccountId(config) === active) return active;
636
+ // The eligible list restores denied members when filtering would empty it, so re-filter here:
637
+ // moving from one denied account to another buys nothing and costs the warm prefix.
638
+ const entitled = getEligiblePoolAccounts(config, active, now, quotaScope, selectionOptions)
639
+ .filter(id => !denied.has(id));
640
+ return pickLowestUsageAmong(config, entitled, selectionOptions, now) ?? active;
641
+ }
642
+
643
+ /**
644
+ * Move an unbound request back up when a higher tier regains headroom — the
645
+ * weekly-reset case. Returns null when nothing should change.
646
+ *
647
+ * Downward moves are deliberately left to {@link applyQuotaAutoSwitch}: this only
648
+ * fires when the tier filter has already excluded `active`, and only toward a
649
+ * tier that strictly outranks it. Threads bound by affinity never reach here.
650
+ */
651
+ export function pickPriorityPreemption(
652
+ config: OcxConfig,
653
+ active: string,
654
+ now: number,
655
+ quotaScope?: CodexQuotaScope,
656
+ selectionOptions?: CodexAccountUsabilityOptions,
657
+ ): string | null {
658
+ const eligible = getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions);
659
+ if (eligible.length === 0 || eligible.includes(active)) return null;
660
+ const pinned = pinnedCodexAccountId(config);
661
+ // A live pin already lowered the tier ceiling; never preempt past an explicit
662
+ // operator choice. Same liveness test the tier filter applies, so preview and
663
+ // resolve agree even before the pin is garbage-collected.
664
+ if (
665
+ pinned !== undefined
666
+ && eligible.includes(pinned)
667
+ && hasCodexQuotaHeadroom(config, pinned, selectionOptions, now)
668
+ ) return null;
669
+ const priorityOf = codexAccountPriorityLookup(config);
670
+ if (priorityOf(eligible[0]!) <= priorityOf(active)) return null;
671
+ // Members without headroom are in the tier only because a sibling has some;
672
+ // picking one would hand the request straight back to a drained account.
673
+ return pickLowestUsageAmong(
674
+ config,
675
+ eligible.filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now)),
676
+ selectionOptions,
677
+ now,
678
+ );
679
+ }
680
+
681
+ export function applyQuotaAutoSwitch(
682
+ config: OcxConfig,
683
+ active: string,
684
+ now: number,
685
+ quotaScope?: CodexQuotaScope,
686
+ selectionOptions?: CodexAccountUsabilityOptions,
687
+ commitSharedSelection = true,
688
+ ): string {
689
+ const threshold = config.autoSwitchThreshold ?? 80;
690
+ if (threshold <= 0) return active;
691
+ const quota = getAccountQuota(active);
692
+ const activeUsage = computeCodexUsageScore(
693
+ quota,
694
+ getPoolAccountPlanForSelection(config, active, selectionOptions),
695
+ now,
696
+ );
697
+ // Unknown usage is not evidence that a user's explicit selection crossed the
698
+ // threshold. Wait for quota priming instead of rotating among guesses.
699
+ if (isUnknownUsage(activeUsage)) return active;
700
+ if (activeUsage < threshold) return active;
701
+ const best = pickLowerUsageAccount(config, active, activeUsage, now, quotaScope, selectionOptions);
702
+ if (best !== active) {
703
+ if (commitSharedSelection && !isIndependentCodexQuotaScope(quotaScope)) {
704
+ setActiveCodexAccount(config, best);
705
+ }
706
+ return best;
707
+ }
708
+
709
+ return active;
710
+ }
711
+
712
+ export function shouldFailover(config: OcxConfig, accountId: string, now: number): boolean {
713
+ const threshold = config.upstreamFailoverThreshold ?? 3;
714
+ if (threshold <= 0) return false;
715
+ dropSpentCredentialFailure(accountId);
716
+ const health = getAccountHealth(accountId);
717
+ if (health?.lastFailureAt && now - health.lastFailureAt > CODEX_FAILURE_WINDOW_MS) return false;
718
+ return !!health && health.consecutiveFailures >= threshold;
719
+ }
720
+
721
+ export function isHealthySharedCodexSelection(
722
+ config: OcxConfig,
723
+ accountId: string,
724
+ now: number,
725
+ quotaScope: CodexQuotaScope | undefined,
726
+ selectionOptions: CodexAccountUsabilityOptions | undefined,
727
+ ): boolean {
728
+ return isCodexAccountSelectable(config, accountId, now, quotaScope, selectionOptions)
729
+ && hasCodexQuotaHeadroom(config, accountId, selectionOptions, now)
730
+ && !shouldFailover(config, accountId, now);
731
+ }
732
+
733
+ export function strategySelectionOptionsForModelDetour(
734
+ config: OcxConfig,
735
+ now: number,
736
+ quotaScope: CodexQuotaScope | undefined,
737
+ selectionOptions: CodexAccountUsabilityOptions | undefined,
738
+ ): CodexAccountUsabilityOptions | undefined {
739
+ if (selectionOptions?.modelEligibleAccountIds === undefined) return selectionOptions;
740
+ const sharedSelectionOptions = sharedStateSelectionOptions(selectionOptions) ?? {};
741
+ return {
742
+ ...selectionOptions,
743
+ modelEligibleAccountIds: new Set(
744
+ [...selectionOptions.modelEligibleAccountIds].filter(accountId =>
745
+ isHealthySharedCodexSelection(
746
+ config,
747
+ accountId,
748
+ now,
749
+ quotaScope,
750
+ sharedSelectionOptions,
751
+ )
752
+ ),
753
+ ),
754
+ };
755
+ }
756
+
757
+ export function applyFailureFailover(
758
+ config: OcxConfig,
759
+ active: string,
760
+ now: number,
761
+ quotaScope?: CodexQuotaScope,
762
+ selectionOptions?: CodexAccountUsabilityOptions,
763
+ commitSharedSelection = true,
764
+ ): string {
765
+ if (!shouldFailover(config, active, now)) return active;
766
+ const best = pickAlternateCodexAccount(config, active, now, quotaScope, selectionOptions);
767
+ if (best) {
768
+ // The scope still routes away from the failing account — that is this request's
769
+ // own decision — but an independent one must not persist a new shared active
770
+ // account. recordCodexUpstreamOutcome only suppresses the promotion it makes at
771
+ // the moment of the failure; the streak outlives the soft avoid, so a later
772
+ // scoped resolve reaches here with the streak still tripped and would otherwise
773
+ // move the shared cursor after all.
774
+ if (commitSharedSelection && !isIndependentCodexQuotaScope(quotaScope)) {
775
+ promoteActiveCodexAccount(config, best);
776
+ }
777
+ return best;
778
+ }
779
+ return active;
780
+ }