@bitkyc08/opencodex 2.55.0 → 2.56.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 (167) hide show
  1. package/gui/dist/assets/{index-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -1
  4. package/src/adapters/base.ts +21 -0
  5. package/src/adapters/cursor/transport-retry.ts +46 -1
  6. package/src/adapters/cursor.ts +4 -0
  7. package/src/adapters/kiro/adapter.ts +42 -1
  8. package/src/adapters/kiro-retry.ts +23 -4
  9. package/src/adapters/openai-chat/errors.ts +116 -0
  10. package/src/adapters/openai-chat/messages.ts +346 -0
  11. package/src/adapters/openai-chat/passthrough.ts +146 -0
  12. package/src/adapters/openai-chat/response-events.ts +117 -0
  13. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  14. package/src/adapters/openai-chat/tool-schema.ts +477 -0
  15. package/src/adapters/openai-chat/wire.ts +50 -0
  16. package/src/adapters/openai-chat.ts +33 -1445
  17. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  18. package/src/adapters/openai-responses/image-gen.ts +406 -0
  19. package/src/adapters/openai-responses/internal.ts +3 -0
  20. package/src/adapters/openai-responses/passthrough.ts +611 -0
  21. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  22. package/src/adapters/openai-responses/reasoning.ts +220 -0
  23. package/src/adapters/openai-responses/request-strips.ts +185 -0
  24. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  25. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  26. package/src/adapters/openai-responses/web-search.ts +156 -0
  27. package/src/adapters/openai-responses.ts +4 -2625
  28. package/src/bridge/errors.ts +34 -0
  29. package/src/bridge/internal.ts +174 -0
  30. package/src/bridge/response-json.ts +624 -0
  31. package/src/bridge/sse.ts +1444 -0
  32. package/src/bridge.ts +5 -2204
  33. package/src/chat/inbound.ts +12 -1
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-store.ts +71 -9
  36. package/src/codex/auth-api/account-list.ts +507 -0
  37. package/src/codex/auth-api/http.ts +32 -0
  38. package/src/codex/auth-api/login-flow.ts +554 -0
  39. package/src/codex/auth-api/login-state.ts +64 -0
  40. package/src/codex/auth-api/main-account-probe.ts +331 -0
  41. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  42. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  43. package/src/codex/auth-api/reset-credit-service.ts +422 -0
  44. package/src/codex/auth-api/routes.ts +425 -0
  45. package/src/codex/auth-api/runtime-config.ts +48 -0
  46. package/src/codex/auth-api.ts +27 -3118
  47. package/src/codex/auth-context.ts +95 -28
  48. package/src/codex/catalog/auto-review.ts +507 -0
  49. package/src/codex/catalog/build-entries.ts +981 -0
  50. package/src/codex/catalog/combo-member.ts +375 -0
  51. package/src/codex/catalog/derive-entry.ts +229 -0
  52. package/src/codex/catalog/effort.ts +0 -1
  53. package/src/codex/catalog/gated-native-warn.ts +63 -0
  54. package/src/codex/catalog/gather-capture.ts +533 -0
  55. package/src/codex/catalog/model-hints.ts +691 -0
  56. package/src/codex/catalog/model-visibility.ts +304 -0
  57. package/src/codex/catalog/provider-fetch.ts +52 -2942
  58. package/src/codex/catalog/provider-models.ts +685 -0
  59. package/src/codex/catalog/restore.ts +132 -0
  60. package/src/codex/catalog/retained-sync.ts +706 -0
  61. package/src/codex/catalog/routed-gather.ts +858 -0
  62. package/src/codex/catalog/subagent-roster.ts +176 -0
  63. package/src/codex/catalog/sync.ts +52 -2698
  64. package/src/codex/inject/config-toml.ts +563 -0
  65. package/src/codex/inject/remove.ts +192 -0
  66. package/src/codex/inject/restore.ts +540 -0
  67. package/src/codex/inject/routing-classify.ts +109 -0
  68. package/src/codex/inject/routing-target.ts +125 -0
  69. package/src/codex/inject.ts +81 -1436
  70. package/src/codex/lineage.ts +458 -0
  71. package/src/codex/pool-refresh-backoff.ts +152 -0
  72. package/src/codex/routing/active-account.ts +194 -0
  73. package/src/codex/routing/cooldown-math.ts +275 -0
  74. package/src/codex/routing/health-store.ts +402 -0
  75. package/src/codex/routing/probe-lease.ts +358 -0
  76. package/src/codex/routing/selection.ts +703 -0
  77. package/src/codex/routing/thread-affinity.ts +538 -0
  78. package/src/codex/routing.ts +353 -2234
  79. package/src/codex/shim-fingerprint.ts +223 -0
  80. package/src/codex/shim-inspect.ts +175 -0
  81. package/src/codex/shim-probe.ts +367 -0
  82. package/src/codex/shim-restore-lock.ts +169 -0
  83. package/src/codex/shim-state-file.ts +151 -0
  84. package/src/codex/shim-templates.ts +265 -0
  85. package/src/codex/shim.ts +48 -1268
  86. package/src/config/diagnostics.ts +705 -0
  87. package/src/config/feature-flags.ts +55 -0
  88. package/src/config/live-reconcile.ts +403 -0
  89. package/src/config/load-degrade.ts +880 -0
  90. package/src/config/mutation-lock.ts +244 -0
  91. package/src/config/openai-tier-backup.ts +268 -0
  92. package/src/config/persist-unlocked.ts +92 -0
  93. package/src/config/proxy-env.ts +188 -0
  94. package/src/config/salvage.ts +244 -0
  95. package/src/config/schema/config-schema.ts +640 -0
  96. package/src/config/schema/leaf-validators.ts +855 -0
  97. package/src/config/warn-memo.ts +28 -0
  98. package/src/config.ts +234 -4481
  99. package/src/generated/compatibility-version.json +539 -39
  100. package/src/lib/request-execution-budget.ts +69 -20
  101. package/src/lib/spend-reservation-ledger.ts +940 -0
  102. package/src/lib/upstream-retry.ts +55 -11
  103. package/src/lib/workflow-budget.ts +553 -30
  104. package/src/providers/quota/account-cache.ts +441 -0
  105. package/src/providers/quota/antigravity.ts +295 -0
  106. package/src/providers/quota/report-cache.ts +320 -0
  107. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  108. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  109. package/src/providers/quota.ts +324 -3079
  110. package/src/providers/registry/entries-core.ts +1221 -0
  111. package/src/providers/registry/entries-extended.ts +1204 -0
  112. package/src/providers/registry/model-seeds.ts +908 -0
  113. package/src/providers/registry/types.ts +352 -0
  114. package/src/providers/registry.ts +24 -3536
  115. package/src/responses/continuation-ownership.ts +29 -0
  116. package/src/responses/state/replay-fingerprint.ts +80 -0
  117. package/src/responses/state/snapshot-codec.ts +104 -0
  118. package/src/responses/state/spill-failure.ts +118 -0
  119. package/src/responses/state/spill-queue.ts +665 -0
  120. package/src/responses/state/temp-recovery.ts +257 -0
  121. package/src/responses/state.ts +82 -1143
  122. package/src/routing/identity-domains.ts +449 -0
  123. package/src/routing/probe-lease.ts +511 -0
  124. package/src/server/index/bounded-request.ts +88 -0
  125. package/src/server/index/live-sideband.ts +565 -0
  126. package/src/server/index/serve-options.ts +1766 -0
  127. package/src/server/index/startup-warnings.ts +213 -0
  128. package/src/server/index/websocket-handler.ts +335 -0
  129. package/src/server/index.ts +40 -2547
  130. package/src/server/management/route-registry.ts +26 -23
  131. package/src/server/management/shared.ts +8 -5
  132. package/src/server/management/workflow-budget-routes.ts +133 -0
  133. package/src/server/management-api.ts +12 -0
  134. package/src/server/request-log-conversation.ts +9 -7
  135. package/src/server/request-log.ts +245 -1
  136. package/src/server/responses/account-change-state.ts +233 -0
  137. package/src/server/responses/adapter-continuation.ts +514 -0
  138. package/src/server/responses/adapter-delivery.ts +214 -0
  139. package/src/server/responses/adapter-dispatch.ts +971 -0
  140. package/src/server/responses/compact.ts +59 -4
  141. package/src/server/responses/completion-policy.ts +33 -0
  142. package/src/server/responses/core-auth.ts +527 -0
  143. package/src/server/responses/core-codex-account.ts +859 -0
  144. package/src/server/responses/core-combo-failure.ts +210 -0
  145. package/src/server/responses/core-combo.ts +707 -0
  146. package/src/server/responses/core-errors.ts +152 -0
  147. package/src/server/responses/core-lifetime.ts +95 -0
  148. package/src/server/responses/core-normalize.ts +350 -0
  149. package/src/server/responses/core-opaque-recovery.ts +380 -0
  150. package/src/server/responses/core-options.ts +159 -0
  151. package/src/server/responses/core-replay.ts +225 -0
  152. package/src/server/responses/core.ts +192 -8893
  153. package/src/server/responses/passthrough-delivery.ts +856 -0
  154. package/src/server/responses/passthrough-dispatch.ts +1476 -0
  155. package/src/server/responses/passthrough-execution.ts +54 -0
  156. package/src/server/responses/request-prepare.ts +970 -0
  157. package/src/server/responses/request-send-budget.ts +164 -0
  158. package/src/server/responses/request-sidecar-auth.ts +149 -0
  159. package/src/server/responses/request-transport.ts +744 -0
  160. package/src/server/responses/response-effects.ts +157 -0
  161. package/src/server/responses/run-turn-execution.ts +448 -0
  162. package/src/server/responses/sidecar-execution.ts +469 -0
  163. package/src/server/responses-image-gen-repair.ts +1 -1
  164. package/src/server/workflow-refusal.ts +84 -0
  165. package/src/types/config.ts +30 -0
  166. package/src/usage/log.ts +146 -0
  167. package/src/usage/summary.ts +171 -21
@@ -0,0 +1,703 @@
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
+ export function getEligiblePoolAccounts(
127
+ config: OcxConfig,
128
+ excludeId?: string,
129
+ now = Date.now(),
130
+ quotaScope?: CodexQuotaScope,
131
+ selectionOptions?: CodexAccountUsabilityOptions,
132
+ skipFailoverReadyCandidates = false,
133
+ ): readonly string[] {
134
+ const excludedPlans = excludedCodexPoolPlanKeys(config);
135
+ const ids = (config.codexAccounts ?? [])
136
+ .filter(account => isSelectableCodexPoolAccount(account)
137
+ && account.id !== excludeId
138
+ && !isCodexAccountPaused(config, account.id)
139
+ && !isCodexAccountPlanExcluded(config, account.id, excludedPlans)
140
+ && !isAccountNeedsReauth(account.id)
141
+ && (!skipFailoverReadyCandidates || !shouldFailover(config, account.id, now)))
142
+ .filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null)
143
+ .filter(account => !isCodexAccountSoftAvoided(account.id, now))
144
+ .filter(account => !isCodexQuotaAvoided(account.id, quotaScope, now))
145
+ .filter(account => !isCodexPoolRefreshCooling(account.id, now))
146
+ .filter(account => isCodexAccountUsable(config, account.id, selectionOptions))
147
+ .map(account => account.id);
148
+ // The main Codex account is not stored in config.codexAccounts; include it as a
149
+ // first-class rotation candidate when its read-only token is usable (Option A).
150
+ if (
151
+ excludeId !== MAIN_CODEX_ACCOUNT_ID
152
+ && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
153
+ && (!isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID) || hasMainAccountRefreshGrant())
154
+ && getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null
155
+ && !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now)
156
+ // The main login is not in `config.codexAccounts`, so it never passes through the
157
+ // filters above and this is the only place an avoidance window can exclude it. Without
158
+ // this the window a refusal announced applies to the pool but not to the account that
159
+ // earned it: the cooldown caps at fifteen minutes, the window runs up to six hours, and
160
+ // in between the main account returns as a first-class candidate.
161
+ && !isCodexQuotaAvoided(MAIN_CODEX_ACCOUNT_ID, quotaScope, now)
162
+ && !isCodexPoolRefreshCooling(MAIN_CODEX_ACCOUNT_ID, now)
163
+ && (!skipFailoverReadyCandidates || !shouldFailover(config, MAIN_CODEX_ACCOUNT_ID, now))
164
+ && isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions)
165
+ ) {
166
+ ids.unshift(MAIN_CODEX_ACCOUNT_ID);
167
+ }
168
+ // Single choke point for selection order: every strategy, failover, and preview
169
+ // reaches the pool through here, so tiering applies once rather than per picker.
170
+ // Eligibility above is unchanged — this only narrows an already-eligible list.
171
+ return selectPriorityTier(
172
+ ids,
173
+ codexAccountPriorityLookup(config),
174
+ id => hasCodexQuotaHeadroom(config, id, selectionOptions, now),
175
+ pinnedCodexAccountId(config),
176
+ );
177
+ }
178
+
179
+ function listEligibleCodexAccountIds(
180
+ config: OcxConfig,
181
+ now: number,
182
+ quotaScope?: CodexQuotaScope,
183
+ selectionOptions?: CodexAccountUsabilityOptions,
184
+ ): readonly string[] {
185
+ return getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions);
186
+ }
187
+
188
+ /** Shared reset timestamps are not evidence for independent model-quota groups. */
189
+ export function accountPoolStrategyForScope(config: OcxConfig, quotaScope?: CodexQuotaScope) {
190
+ const strategy = normalizeCodexAccountPoolStrategy(config.accountPoolStrategy);
191
+ return strategy === "reset-first" && isIndependentCodexQuotaScope(quotaScope) ? "quota" : strategy;
192
+ }
193
+
194
+ function stickyLimitForConfig(config: OcxConfig): number {
195
+ return normalizeAccountPoolStickyLimit(config.accountPoolStickyLimit);
196
+ }
197
+
198
+ /**
199
+ * Whether an account still has quota to give under the auto-switch threshold.
200
+ *
201
+ * Fill-first and the priority tier filter share this predicate, and share both of
202
+ * its escape hatches. A disabled threshold means only health, pause, and reauth
203
+ * may drain an account; unknown usage is a guess, so it must neither force
204
+ * fill-first off the active account nor drain a tier that was simply never
205
+ * primed. A genuinely exhausted account 429s into cooldown and leaves
206
+ * eligibility on its own.
207
+ */
208
+ export function hasCodexQuotaHeadroom(
209
+ config: OcxConfig,
210
+ accountId: string,
211
+ selectionOptions?: CodexAccountUsabilityOptions,
212
+ now: number = Date.now(),
213
+ ): boolean {
214
+ const threshold = config.autoSwitchThreshold ?? 80;
215
+ if (threshold <= 0) return true;
216
+ const usage = computeCodexUsageScore(
217
+ getAccountQuota(accountId),
218
+ getPoolAccountPlanForSelection(config, accountId, selectionOptions),
219
+ now,
220
+ );
221
+ if (isUnknownUsage(usage)) return true;
222
+ return usage < threshold;
223
+ }
224
+
225
+ /**
226
+ * Is a live binding held for its prompt cache?
227
+ *
228
+ * Unset means yes. Cache affinity shipped as an opt-in flag (#4292) and then #4546 measured
229
+ * what the default costs: a pool whose accounts all sit in the 80-99% band hands a bound
230
+ * conversation from account to account, and because provider prompt caches are account-isolated
231
+ * every hop re-sends the entire prefix. An install that has never heard of this flag is exactly
232
+ * the install that gets hurt by it, so the protection cannot be something you have to find.
233
+ *
234
+ * `false` restores capacity-first routing byte-for-byte. It is a real choice -- a pinned thread
235
+ * on a busy account pays latency -- and it stays available; it is just no longer the default.
236
+ */
237
+ export function isCacheAffinityEnabled(config: OcxConfig): boolean {
238
+ return config.pool?.cacheAffinity !== false;
239
+ }
240
+
241
+ /** Earliest future shared short/weekly reset; missing evidence and ties use usage order. */
242
+ export function pickResetFirstCodexAccount(
243
+ config: OcxConfig,
244
+ ids: readonly string[],
245
+ now: number,
246
+ selectionOptions?: CodexAccountUsabilityOptions,
247
+ ): string | null {
248
+ const available = ids.filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now));
249
+ if (available.length === 0) return pickLowestUsageAmong(config, ids, selectionOptions, now);
250
+ let earliest = Number.POSITIVE_INFINITY;
251
+ let candidates: string[] = [];
252
+ for (const id of available) {
253
+ const quota = getAccountQuota(id);
254
+ const resets = [quota?.shortResetAt, quota?.weeklyResetAt]
255
+ .filter((reset): reset is number => typeof reset === "number" && Number.isFinite(reset))
256
+ .map(resetAtToMs)
257
+ .filter(reset => reset > now);
258
+ const next = Math.min(...resets);
259
+ if (next < earliest) {
260
+ earliest = next;
261
+ candidates = [id];
262
+ } else if (next === earliest) candidates.push(id);
263
+ }
264
+ return pickLowestUsageAmong(config, candidates, selectionOptions, now);
265
+ }
266
+
267
+ /**
268
+ * Fill-first: keep selectable active under threshold; otherwise advance to the next
269
+ * eligible id in stable sorted order after the current active (wrapping).
270
+ */
271
+ function pickFillFirstCodexAccount(
272
+ config: OcxConfig,
273
+ now: number,
274
+ quotaScope?: CodexQuotaScope,
275
+ selectionOptions?: CodexAccountUsabilityOptions,
276
+ ): string | null {
277
+ const eligible = listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions);
278
+ if (eligible.length === 0) return null;
279
+
280
+ const active = getEffectiveActiveCodexAccountId(config);
281
+ if (active && eligible.includes(active) && hasCodexQuotaHeadroom(config, active, selectionOptions, now)) {
282
+ return active;
283
+ }
284
+
285
+ return pickNextFillFirstCodexAccount(config, active ?? null, eligible, now, selectionOptions);
286
+ }
287
+
288
+ /** Next eligible account in stable order after `afterId` (wrapping). */
289
+ function pickNextFillFirstCodexAccount(
290
+ config: OcxConfig,
291
+ afterId: string | null,
292
+ eligible: readonly string[] = listEligibleCodexAccountIds(config, Date.now()),
293
+ now = Date.now(),
294
+ selectionOptions?: CodexAccountUsabilityOptions,
295
+ ): string | null {
296
+ if (eligible.length === 0) return null;
297
+ const ordered = [...eligible].sort((a, b) => a.localeCompare(b));
298
+ if (!afterId) {
299
+ // Prefer an under-threshold account when starting with no active cursor.
300
+ for (const id of ordered) {
301
+ if (hasCodexQuotaHeadroom(config, id, selectionOptions, now)) return id;
302
+ }
303
+ return ordered[0] ?? null;
304
+ }
305
+
306
+ const allConfigured = [
307
+ ...(isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions) || afterId === MAIN_CODEX_ACCOUNT_ID
308
+ ? [MAIN_CODEX_ACCOUNT_ID]
309
+ : []),
310
+ ...(config.codexAccounts ?? []).filter(account => !account.isMain).map(account => account.id),
311
+ ];
312
+ const stableAll = [...new Set(allConfigured)].sort((a, b) => a.localeCompare(b));
313
+ const startIdx = stableAll.indexOf(afterId);
314
+ if (startIdx < 0) {
315
+ for (const id of ordered) {
316
+ if (hasCodexQuotaHeadroom(config, id, selectionOptions, now)) return id;
317
+ }
318
+ return ordered[0] ?? null;
319
+ }
320
+
321
+ // Skip successors that are also at/above threshold (known drained usage).
322
+ let fallback: string | null = null;
323
+ for (let step = 1; step <= stableAll.length; step++) {
324
+ const candidate = stableAll[(startIdx + step) % stableAll.length]!;
325
+ if (!eligible.includes(candidate)) continue;
326
+ if (!fallback) fallback = candidate;
327
+ if (hasCodexQuotaHeadroom(config, candidate, selectionOptions, now)) return candidate;
328
+ }
329
+ return fallback ?? ordered[0] ?? null;
330
+ }
331
+
332
+ /**
333
+ * Unbound new-session pick for round-robin / fill-first. Returns null to fall through
334
+ * to the legacy quota path (or when the strategy is quota).
335
+ *
336
+ * When `commit` is true (resolve path), advances RR state. `commitSharedActive`
337
+ * and `commitAffinity` independently control the two cross-request side effects:
338
+ * model-scoped entitlement selection can bind a new task without replacing an
339
+ * existing task binding or global active choice. Preview remains a dry-run peek.
340
+ *
341
+ * Automatic strategy picks never sync-write config; only manual selection persists active.
342
+ *
343
+ * Known limitation (follow-up): when a subagent preview peeks an RR account and the request
344
+ * then falls back to a non-Codex provider, the ring is not reserved/committed. Prefer seeding
345
+ * the peeked account if that path becomes load-bearing.
346
+ */
347
+ export function pickUnboundStrategyAccount(
348
+ config: OcxConfig,
349
+ threadId: string | null,
350
+ now: number,
351
+ commit: boolean,
352
+ quotaScope?: CodexQuotaScope,
353
+ selectionOptions?: CodexAccountUsabilityOptions,
354
+ commitSharedActive = commit,
355
+ commitAffinity = commit,
356
+ ): string | null {
357
+ const strategy = accountPoolStrategyForScope(config, quotaScope);
358
+ if (strategy === "quota") return null;
359
+ const poolKey = codexPoolKeyForScope(quotaScope);
360
+
361
+ let picked: string | null = null;
362
+ if (strategy === "round-robin") {
363
+ const eligible = listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions);
364
+ const limit = stickyLimitForConfig(config);
365
+ if (!commit) {
366
+ return peekRoundRobinAccount(poolKey, eligible, limit);
367
+ }
368
+ picked = pickRoundRobinAccount(poolKey, eligible, limit);
369
+ if (!picked) return null;
370
+ if (commitSharedActive) {
371
+ if (!isIndependentCodexQuotaScope(quotaScope)
372
+ && !manualPreferenceBlocks(codexPoolKeyForScope(quotaScope), picked)) {
373
+ rememberActiveCodexAccount(config, picked);
374
+ }
375
+ }
376
+ if (commitAffinity && threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
377
+ notePoolRotationSuccess(poolKey, picked, limit);
378
+ return picked;
379
+ }
380
+
381
+ if (strategy === "fill-first" || strategy === "reset-first") {
382
+ picked = strategy === "reset-first"
383
+ ? pickResetFirstCodexAccount(config, listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions), now, selectionOptions)
384
+ : pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions);
385
+ if (!picked) return null;
386
+ if (commitSharedActive) {
387
+ if (!isIndependentCodexQuotaScope(quotaScope)
388
+ && !manualPreferenceBlocks(codexPoolKeyForScope(quotaScope), picked)) {
389
+ rememberActiveCodexAccount(config, picked);
390
+ }
391
+ }
392
+ if (commitAffinity && threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
393
+ return picked;
394
+ }
395
+
396
+ return null;
397
+ }
398
+
399
+ export function getPoolAccountPlan(config: OcxConfig, accountId: string): string | undefined {
400
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) return getMainAccountPlan();
401
+ return (config.codexAccounts ?? [])
402
+ .find(account => isSelectableCodexPoolAccount(account) && account.id === accountId)?.plan;
403
+ }
404
+
405
+ /** Selection-only main routing must not lazily read the fenced native credential for its plan. */
406
+ export function getPoolAccountPlanForSelection(
407
+ config: OcxConfig,
408
+ accountId: string,
409
+ selectionOptions?: CodexAccountUsabilityOptions,
410
+ ): string | undefined {
411
+ if (accountId === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) {
412
+ return undefined;
413
+ }
414
+ return getPoolAccountPlan(config, accountId);
415
+ }
416
+
417
+ /** Shared routing state must ignore a request-scoped entitlement roster. */
418
+ export function sharedStateSelectionOptions(
419
+ selectionOptions?: CodexAccountUsabilityOptions,
420
+ ): Pick<
421
+ CodexAccountUsabilityOptions,
422
+ "nativeMainSelectionOnly" | "isMainAccountTokenLive"
423
+ > | undefined {
424
+ if (!selectionOptions) return undefined;
425
+ return {
426
+ ...(selectionOptions.nativeMainSelectionOnly !== undefined
427
+ ? { nativeMainSelectionOnly: selectionOptions.nativeMainSelectionOnly }
428
+ : {}),
429
+ ...(selectionOptions.isMainAccountTokenLive
430
+ ? { isMainAccountTokenLive: selectionOptions.isMainAccountTokenLive }
431
+ : {}),
432
+ };
433
+ }
434
+
435
+ export function pickLowerUsageAccount(
436
+ config: OcxConfig,
437
+ active: string,
438
+ activeUsage: number,
439
+ now: number,
440
+ quotaScope?: CodexQuotaScope,
441
+ selectionOptions?: CodexAccountUsabilityOptions,
442
+ skipFailoverReadyCandidates = false,
443
+ ): string {
444
+ let best = active;
445
+ let bestUsage = activeUsage;
446
+ for (const id of getEligiblePoolAccounts(
447
+ config,
448
+ active,
449
+ now,
450
+ quotaScope,
451
+ selectionOptions,
452
+ skipFailoverReadyCandidates,
453
+ )) {
454
+ const usage = computeCodexUsageScore(
455
+ getAccountQuota(id),
456
+ getPoolAccountPlanForSelection(config, id, selectionOptions),
457
+ now,
458
+ );
459
+ if (usage < bestUsage) {
460
+ best = id;
461
+ bestUsage = usage;
462
+ }
463
+ }
464
+ return best;
465
+ }
466
+
467
+ /** Coolest account in an already-selected candidate list; first index wins ties. */
468
+ export function pickLowestUsageAmong(
469
+ config: OcxConfig,
470
+ ids: readonly string[],
471
+ selectionOptions?: CodexAccountUsabilityOptions,
472
+ now: number = Date.now(),
473
+ ): string | null {
474
+ let best: string | null = null;
475
+ let bestUsage = Number.POSITIVE_INFINITY;
476
+ for (const id of ids) {
477
+ const usage = computeCodexUsageScore(
478
+ getAccountQuota(id),
479
+ getPoolAccountPlanForSelection(config, id, selectionOptions),
480
+ now,
481
+ );
482
+ if (usage < bestUsage) {
483
+ best = id;
484
+ bestUsage = usage;
485
+ }
486
+ }
487
+ return best;
488
+ }
489
+
490
+ export function pickLowestUsageCodexAccount(
491
+ config: OcxConfig,
492
+ excludeId?: string,
493
+ now = Date.now(),
494
+ quotaScope?: CodexQuotaScope,
495
+ selectionOptions?: CodexAccountUsabilityOptions,
496
+ ): string | null {
497
+ return pickLowestUsageAmong(
498
+ config,
499
+ getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions),
500
+ selectionOptions,
501
+ now,
502
+ );
503
+ }
504
+
505
+ /**
506
+ * Strategy-aware alternate after a cooled/excluded account (same-request 429 retry
507
+ * and active promotion). Quota keeps lowest-usage; fill-first advances stable order;
508
+ * round-robin takes the next ring pick (caller should have noted the failure).
509
+ */
510
+ export function pickAlternateCodexAccount(
511
+ config: OcxConfig,
512
+ excludeId: string,
513
+ now = Date.now(),
514
+ quotaScope?: CodexQuotaScope,
515
+ selectionOptions?: CodexAccountUsabilityOptions,
516
+ ): string | null {
517
+ const strategy = accountPoolStrategyForScope(config, quotaScope);
518
+ // The exclusion is passed into eligibility rather than post-filtered off its
519
+ // result: when the excluded account is the only healthy member of the top
520
+ // tier, the tier walk must be free to descend instead of selecting that tier
521
+ // and then handing back an empty list.
522
+ if (strategy === "round-robin") {
523
+ const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions);
524
+ return pickRoundRobinAccount(codexPoolKeyForScope(quotaScope), eligible, stickyLimitForConfig(config));
525
+ }
526
+ if (strategy === "fill-first") {
527
+ const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions);
528
+ return pickNextFillFirstCodexAccount(config, excludeId, eligible, now, selectionOptions);
529
+ }
530
+ if (strategy === "reset-first") {
531
+ return pickResetFirstCodexAccount(config, getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions), now, selectionOptions);
532
+ }
533
+ return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope, selectionOptions);
534
+ }
535
+
536
+ /**
537
+ * The account {@link pickAlternateCodexAccount} WOULD return, without returning it.
538
+ *
539
+ * Only the round-robin branch has a side effect -- `pickRoundRobinAccount` commits the pick and
540
+ * advances the ring -- so every other strategy delegates rather than growing a second copy of
541
+ * the selection rule that could drift from it.
542
+ *
543
+ * This exists because preview and resolve have to agree on the FIRST transient detour, not just
544
+ * on later ones. Preview feeds subagent model-availability scoring, so a preview that reported
545
+ * the bound account while resolve was about to serve from a cool sibling could retire a model
546
+ * over usage the request would never have touched.
547
+ */
548
+ export function peekAlternateCodexAccount(
549
+ config: OcxConfig,
550
+ excludeId: string,
551
+ now: number,
552
+ quotaScope?: CodexQuotaScope,
553
+ selectionOptions?: CodexAccountUsabilityOptions,
554
+ ): string | null {
555
+ if (accountPoolStrategyForScope(config, quotaScope) === "round-robin") {
556
+ const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions);
557
+ return peekRoundRobinAccount(codexPoolKeyForScope(quotaScope), eligible, stickyLimitForConfig(config));
558
+ }
559
+ return pickAlternateCodexAccount(config, excludeId, now, quotaScope, selectionOptions);
560
+ }
561
+
562
+ export function isUnknownUsage(usage: number): boolean {
563
+ return usage >= CODEX_UNKNOWN_USAGE_SCORE;
564
+ }
565
+
566
+ /**
567
+ * Move an unbound request back up when a higher tier regains headroom — the
568
+ * weekly-reset case. Returns null when nothing should change.
569
+ *
570
+ * Downward moves are deliberately left to {@link applyQuotaAutoSwitch}: this only
571
+ * fires when the tier filter has already excluded `active`, and only toward a
572
+ * tier that strictly outranks it. Threads bound by affinity never reach here.
573
+ */
574
+ export function pickPriorityPreemption(
575
+ config: OcxConfig,
576
+ active: string,
577
+ now: number,
578
+ quotaScope?: CodexQuotaScope,
579
+ selectionOptions?: CodexAccountUsabilityOptions,
580
+ ): string | null {
581
+ const eligible = getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions);
582
+ if (eligible.length === 0 || eligible.includes(active)) return null;
583
+ const pinned = pinnedCodexAccountId(config);
584
+ // A live pin already lowered the tier ceiling; never preempt past an explicit
585
+ // operator choice. Same liveness test the tier filter applies, so preview and
586
+ // resolve agree even before the pin is garbage-collected.
587
+ if (
588
+ pinned !== undefined
589
+ && eligible.includes(pinned)
590
+ && hasCodexQuotaHeadroom(config, pinned, selectionOptions, now)
591
+ ) return null;
592
+ const priorityOf = codexAccountPriorityLookup(config);
593
+ if (priorityOf(eligible[0]!) <= priorityOf(active)) return null;
594
+ // Members without headroom are in the tier only because a sibling has some;
595
+ // picking one would hand the request straight back to a drained account.
596
+ return pickLowestUsageAmong(
597
+ config,
598
+ eligible.filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now)),
599
+ selectionOptions,
600
+ now,
601
+ );
602
+ }
603
+
604
+ export function applyQuotaAutoSwitch(
605
+ config: OcxConfig,
606
+ active: string,
607
+ now: number,
608
+ quotaScope?: CodexQuotaScope,
609
+ selectionOptions?: CodexAccountUsabilityOptions,
610
+ commitSharedSelection = true,
611
+ ): string {
612
+ const threshold = config.autoSwitchThreshold ?? 80;
613
+ if (threshold <= 0) return active;
614
+ const quota = getAccountQuota(active);
615
+ const activeUsage = computeCodexUsageScore(
616
+ quota,
617
+ getPoolAccountPlanForSelection(config, active, selectionOptions),
618
+ now,
619
+ );
620
+ // Unknown usage is not evidence that a user's explicit selection crossed the
621
+ // threshold. Wait for quota priming instead of rotating among guesses.
622
+ if (isUnknownUsage(activeUsage)) return active;
623
+ if (activeUsage < threshold) return active;
624
+ const best = pickLowerUsageAccount(config, active, activeUsage, now, quotaScope, selectionOptions);
625
+ if (best !== active) {
626
+ if (commitSharedSelection && !isIndependentCodexQuotaScope(quotaScope)) {
627
+ setActiveCodexAccount(config, best);
628
+ }
629
+ return best;
630
+ }
631
+
632
+ return active;
633
+ }
634
+
635
+ export function shouldFailover(config: OcxConfig, accountId: string, now: number): boolean {
636
+ const threshold = config.upstreamFailoverThreshold ?? 3;
637
+ if (threshold <= 0) return false;
638
+ dropSpentCredentialFailure(accountId);
639
+ const health = getAccountHealth(accountId);
640
+ if (health?.lastFailureAt && now - health.lastFailureAt > CODEX_FAILURE_WINDOW_MS) return false;
641
+ return !!health && health.consecutiveFailures >= threshold;
642
+ }
643
+
644
+ export function isHealthySharedCodexSelection(
645
+ config: OcxConfig,
646
+ accountId: string,
647
+ now: number,
648
+ quotaScope: CodexQuotaScope | undefined,
649
+ selectionOptions: CodexAccountUsabilityOptions | undefined,
650
+ ): boolean {
651
+ return isCodexAccountSelectable(config, accountId, now, quotaScope, selectionOptions)
652
+ && hasCodexQuotaHeadroom(config, accountId, selectionOptions, now)
653
+ && !shouldFailover(config, accountId, now);
654
+ }
655
+
656
+ export function strategySelectionOptionsForModelDetour(
657
+ config: OcxConfig,
658
+ now: number,
659
+ quotaScope: CodexQuotaScope | undefined,
660
+ selectionOptions: CodexAccountUsabilityOptions | undefined,
661
+ ): CodexAccountUsabilityOptions | undefined {
662
+ if (selectionOptions?.modelEligibleAccountIds === undefined) return selectionOptions;
663
+ const sharedSelectionOptions = sharedStateSelectionOptions(selectionOptions) ?? {};
664
+ return {
665
+ ...selectionOptions,
666
+ modelEligibleAccountIds: new Set(
667
+ [...selectionOptions.modelEligibleAccountIds].filter(accountId =>
668
+ isHealthySharedCodexSelection(
669
+ config,
670
+ accountId,
671
+ now,
672
+ quotaScope,
673
+ sharedSelectionOptions,
674
+ )
675
+ ),
676
+ ),
677
+ };
678
+ }
679
+
680
+ export function applyFailureFailover(
681
+ config: OcxConfig,
682
+ active: string,
683
+ now: number,
684
+ quotaScope?: CodexQuotaScope,
685
+ selectionOptions?: CodexAccountUsabilityOptions,
686
+ commitSharedSelection = true,
687
+ ): string {
688
+ if (!shouldFailover(config, active, now)) return active;
689
+ const best = pickAlternateCodexAccount(config, active, now, quotaScope, selectionOptions);
690
+ if (best) {
691
+ // The scope still routes away from the failing account — that is this request's
692
+ // own decision — but an independent one must not persist a new shared active
693
+ // account. recordCodexUpstreamOutcome only suppresses the promotion it makes at
694
+ // the moment of the failure; the streak outlives the soft avoid, so a later
695
+ // scoped resolve reaches here with the streak still tripped and would otherwise
696
+ // move the shared cursor after all.
697
+ if (commitSharedSelection && !isIndependentCodexQuotaScope(quotaScope)) {
698
+ promoteActiveCodexAccount(config, best);
699
+ }
700
+ return best;
701
+ }
702
+ return active;
703
+ }