@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,161 @@
1
+
2
+ /**
3
+ * Per-account cooldown for a stored Codex pool credential whose forced refresh
4
+ * failed without proving the grant is dead.
5
+ *
6
+ * A token-endpoint 5xx, a generation CAS loss, or a network blip is transient
7
+ * (#2887): it must not quarantine the account or drop its binding. Retrying the
8
+ * same doomed refresh on every request, though, is how a single unhealthy
9
+ * account pinned the pool at 503 while healthy siblings sat idle. Consecutive
10
+ * non-terminal failures open a bounded growing cooldown; during that window no
11
+ * new forced refresh starts, and selection prefers a sibling. The first
12
+ * successful refresh clears it.
13
+ */
14
+
15
+ import { fallbackCodexAccountLogLabel } from "./account-label";
16
+
17
+ export const CODEX_POOL_REFRESH_INCOMPLETE_LOG_REASON = "codex_pool_refresh_incomplete";
18
+
19
+ /** Growing delays between forced-refresh attempts for one account. */
20
+ export const CODEX_POOL_REFRESH_FAILURE_BACKOFF_MS = [2_000, 5_000, 15_000, 30_000, 60_000] as const;
21
+
22
+ export class CodexPoolRefreshCooldownError extends Error {
23
+ readonly retryable = true;
24
+ readonly code = "CODEX_REFRESH_COOLING";
25
+
26
+ constructor(message = "Codex credential refresh is cooling down") {
27
+ super(message);
28
+ this.name = "CodexPoolRefreshCooldownError";
29
+ }
30
+ }
31
+
32
+ type RefreshFailureBackoff = {
33
+ consecutiveFailures: number;
34
+ cooldownUntil: number;
35
+ reason: string;
36
+ };
37
+
38
+ const backoffByAccount = new Map<string, RefreshFailureBackoff>();
39
+ /**
40
+ * Bumped whenever an account's failures are cleared because something proved them obsolete — a
41
+ * successful refresh, or a replacement credential written by login/reauth. A refresh flight that
42
+ * started before that moment is reporting on a grant that no longer exists, and its late failure
43
+ * must not re-quarantine the credential that replaced it.
44
+ */
45
+ const fenceByAccount = new Map<string, number>();
46
+ /**
47
+ * Invalidates every account fence without having to know which refresh flights are currently in
48
+ * progress. A bulk routing-state reset can race a first failure for an account that has no map
49
+ * entry yet, so iterating either map cannot close this boundary.
50
+ */
51
+ let globalFence = 0;
52
+ let nowOverride: number | undefined;
53
+
54
+ export function setCodexPoolRefreshFailureNowForTests(now?: number): void {
55
+ nowOverride = now;
56
+ }
57
+
58
+ export function resetCodexPoolRefreshFailureBackoffForTests(): void {
59
+ backoffByAccount.clear();
60
+ fenceByAccount.clear();
61
+ globalFence = 0;
62
+ nowOverride = undefined;
63
+ }
64
+
65
+ /** The value a refresh flight captures before it starts, to be handed back on failure. */
66
+ export function codexPoolRefreshFence(accountId: string): string {
67
+ return `${globalFence}:${fenceByAccount.get(accountId) ?? 0}`;
68
+ }
69
+
70
+ export function clearCodexPoolRefreshFailure(accountId: string): void {
71
+ backoffByAccount.delete(accountId);
72
+ fenceByAccount.set(accountId, (fenceByAccount.get(accountId) ?? 0) + 1);
73
+ }
74
+
75
+ /**
76
+ * Drop every remembered failure. Called when the routing layer discards its per-account state,
77
+ * because a cooldown outliving the binding it was learned alongside would keep an account out of
78
+ * selection for a roster the operator has already replaced.
79
+ */
80
+ export function clearAllCodexPoolRefreshFailures(): void {
81
+ backoffByAccount.clear();
82
+ fenceByAccount.clear();
83
+ globalFence += 1;
84
+ }
85
+
86
+ function currentNow(now?: number): number {
87
+ return now ?? nowOverride ?? Date.now();
88
+ }
89
+
90
+ function delayFor(consecutiveFailures: number): number {
91
+ const index = Math.min(Math.max(consecutiveFailures, 1), CODEX_POOL_REFRESH_FAILURE_BACKOFF_MS.length) - 1;
92
+ return CODEX_POOL_REFRESH_FAILURE_BACKOFF_MS[index]!;
93
+ }
94
+
95
+ /**
96
+ * How many consecutive non-terminal failures must land before a refresh is WITHHELD.
97
+ *
98
+ * Withholding on the first failure was wrong twice over. A single token-endpoint blip is the
99
+ * ordinary case that the very next attempt clears, and -- worse -- a withheld refresh never runs,
100
+ * so an account whose grant is actually revoked can no longer discover that: the terminal 401 it
101
+ * owes the operator turns into a retryable 503 that never resolves. The cooldown exists for the
102
+ * account that keeps failing, not for the one that failed once.
103
+ */
104
+ export const CODEX_POOL_REFRESH_COOLDOWN_AFTER_FAILURES = 3;
105
+
106
+ export function getCodexPoolRefreshCooldownUntil(accountId: string, now = currentNow()): number | null {
107
+ const entry = backoffByAccount.get(accountId);
108
+ if (!entry) return null;
109
+ if (entry.consecutiveFailures < CODEX_POOL_REFRESH_COOLDOWN_AFTER_FAILURES) return null;
110
+ return entry.cooldownUntil > now ? entry.cooldownUntil : null;
111
+ }
112
+
113
+ export function isCodexPoolRefreshCooling(accountId: string, now = currentNow()): boolean {
114
+ return getCodexPoolRefreshCooldownUntil(accountId, now) !== null;
115
+ }
116
+
117
+ /**
118
+ * Record a non-terminal forced-refresh failure. Already-cooling accounts do not
119
+ * grow the window: growth requires another real attempt after the previous one
120
+ * expired. Logs the classified reason once per account per window, with the
121
+ * durable hash label — never a token and never an email.
122
+ */
123
+ export function noteCodexPoolRefreshFailure(
124
+ accountId: string,
125
+ reason: string,
126
+ now = currentNow(),
127
+ fence?: string,
128
+ ): { consecutiveFailures: number; cooldownUntil: number; openedWindow: boolean } {
129
+ const existing = backoffByAccount.get(accountId);
130
+ // A flight that started before the account's failures were cleared is speaking for a grant
131
+ // that has since been replaced or proven healthy. Recording it would put the new credential
132
+ // back in the quarantine its predecessor earned.
133
+ if (fence !== undefined && fence !== codexPoolRefreshFence(accountId)) {
134
+ return {
135
+ consecutiveFailures: existing?.consecutiveFailures ?? 0,
136
+ cooldownUntil: existing?.cooldownUntil ?? 0,
137
+ openedWindow: false,
138
+ };
139
+ }
140
+ // The "do not grow inside an open window" rule applies only once the window is actually
141
+ // WITHHOLDING. Below the threshold no refresh is being withheld, so every failure is a real
142
+ // attempt that really failed and must count -- otherwise a client retrying the 503 once a
143
+ // second can never reach the threshold the cooldown is meant to protect against.
144
+ const withholding = existing !== undefined
145
+ && existing.consecutiveFailures >= CODEX_POOL_REFRESH_COOLDOWN_AFTER_FAILURES;
146
+ if (existing && withholding && existing.cooldownUntil > now) {
147
+ return {
148
+ consecutiveFailures: existing.consecutiveFailures,
149
+ cooldownUntil: existing.cooldownUntil,
150
+ openedWindow: false,
151
+ };
152
+ }
153
+ const consecutiveFailures = (existing?.consecutiveFailures ?? 0) + 1;
154
+ const cooldownUntil = now + delayFor(consecutiveFailures);
155
+ backoffByAccount.set(accountId, { consecutiveFailures, cooldownUntil, reason });
156
+ const label = fallbackCodexAccountLogLabel(accountId);
157
+ console.warn(
158
+ `[codex-auth] Codex pool account ${label} credential refresh failed (${reason})`,
159
+ );
160
+ return { consecutiveFailures, cooldownUntil, openedWindow: true };
161
+ }
@@ -8,8 +8,41 @@ const RESET_ELIGIBLE_CODE_VALUES = [
8
8
  export type CodexResetEligibleExhaustionCode =
9
9
  (typeof RESET_ELIGIBLE_CODE_VALUES)[number];
10
10
 
11
+ /**
12
+ * Upstream codes that name an ORGANIZATION- or PROJECT-scoped exhaustion (#4546).
13
+ *
14
+ * These are a different animal from the reset-eligible codes above, and the difference is the
15
+ * whole point. `usage_limit_exceeded` describes the account that was asked; another pool account
16
+ * carries its own plan allowance, so rotating to it is a real move. Every code here describes a
17
+ * limit the CREDENTIAL does not own -- a balance, a spend cap, or a usage cap held by the
18
+ * organization or project the credential belongs to. Two credentials inside that organization
19
+ * are refused by the same counter, so rotating between them pays a cold prompt prefix for zero
20
+ * new capacity, which is the send amplification this unit exists to stop.
21
+ *
22
+ * openai/codex reached the same classification from the client side: #44492 maps exactly these
23
+ * HTTP 429 codes to a terminal `QuotaExceeded` instead of a retry-limit failure, and #45602
24
+ * extends it to the SSE path while deliberately KEEPING `rate_limit_exceeded` and `slow_down`
25
+ * retryable. The platform documentation states the same rule for the whole class: "It does not
26
+ * mean that quota, billing, or other errors that require user action can be resolved by
27
+ * retrying."
28
+ *
29
+ * Membership here says nothing about reset credits. A reset credit reconciles a ChatGPT plan
30
+ * window; it cannot pay an organization's bill, so these codes are deliberately absent from
31
+ * {@link RESET_ELIGIBLE_CODE_VALUES} and never set `resetCreditEligible`.
32
+ */
33
+ const SCOPED_EXHAUSTION_CODE_VALUES = [
34
+ "credit_balance_exhausted",
35
+ "organization_spend_limit_exceeded",
36
+ "project_spend_limit_exceeded",
37
+ "organization_usage_limit_exceeded",
38
+ ] as const;
39
+
40
+ export type CodexScopedExhaustionCode =
41
+ (typeof SCOPED_EXHAUSTION_CODE_VALUES)[number];
42
+
11
43
  export type CodexPreStreamRejectionKind =
12
44
  | "reset-eligible-exhaustion"
45
+ | "scoped-quota-exhaustion"
13
46
  | "generic-rate-limit"
14
47
  | "unverified-billing-or-quota"
15
48
  | "transient-server-error"
@@ -23,6 +56,12 @@ export interface CodexPreStreamRejection {
23
56
  alternateRetryEligible: boolean;
24
57
  resetCreditEligible: boolean;
25
58
  semanticCode?: CodexResetEligibleExhaustionCode;
59
+ /**
60
+ * The organization- or project-scoped exhaustion code the upstream body named, when it named
61
+ * one. Never accompanied by `semanticCode`: the two sets are disjoint, and only `semanticCode`
62
+ * may authorize a reset credit.
63
+ */
64
+ scopedExhaustionCode?: CodexScopedExhaustionCode;
26
65
  /**
27
66
  * Structured denial evidence for a 403. Present only when the upstream body names a
28
67
  * workspace/entitlement denial, which proves the CREDENTIAL is valid and the account
@@ -97,6 +136,7 @@ function structuredDenialCode(payload: unknown): string | undefined {
97
136
  }
98
137
 
99
138
  const RESET_ELIGIBLE_CODES: ReadonlySet<string> = new Set(RESET_ELIGIBLE_CODE_VALUES);
139
+ const SCOPED_EXHAUSTION_CODES: ReadonlySet<string> = new Set(SCOPED_EXHAUSTION_CODE_VALUES);
100
140
 
101
141
  const TRANSIENT_SERVER_STATUSES = new Set([500, 502, 503, 504, 520, 521, 522]);
102
142
  const JSON_NUMBER_PATTERN = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
@@ -107,6 +147,7 @@ function rejection(
107
147
  options: {
108
148
  alternateRetryEligible?: boolean;
109
149
  semanticCode?: CodexResetEligibleExhaustionCode;
150
+ scopedExhaustionCode?: CodexScopedExhaustionCode;
110
151
  } = {},
111
152
  ): CodexPreStreamRejection {
112
153
  return {
@@ -115,6 +156,7 @@ function rejection(
115
156
  alternateRetryEligible: options.alternateRetryEligible === true,
116
157
  resetCreditEligible: options.semanticCode !== undefined,
117
158
  ...(options.semanticCode ? { semanticCode: options.semanticCode } : {}),
159
+ ...(options.scopedExhaustionCode ? { scopedExhaustionCode: options.scopedExhaustionCode } : {}),
118
160
  };
119
161
  }
120
162
 
@@ -210,9 +252,19 @@ function isUnsafeJsonDocument(text: string): boolean {
210
252
  }
211
253
  }
212
254
 
213
- function exactResetEligibleCode(
255
+ /**
256
+ * Read the one exact, unambiguous code a container declares, and only if it is in `allowed`.
257
+ *
258
+ * Generic over the allowed set so the reset-eligible and organization-scoped classifications
259
+ * share one parser. They must: the strictness here -- a `code`/`type` pair that disagrees is
260
+ * rejected rather than resolved, and no trimming or case folding is applied -- is what keeps a
261
+ * near-miss from being read as an exact upstream code, and a second hand-written copy would
262
+ * drift away from that.
263
+ */
264
+ function exactAllowedCode(
214
265
  container: Record<string, unknown>,
215
- ): CodexResetEligibleExhaustionCode | undefined {
266
+ allowed: ReadonlySet<string>,
267
+ ): string | undefined {
216
268
  const hasCode = hasOwnField(container, "code");
217
269
  const hasType = hasOwnField(container, "type");
218
270
  if (!hasCode && !hasType) return undefined;
@@ -226,49 +278,80 @@ function exactResetEligibleCode(
226
278
 
227
279
  const value = hasCode ? code : type;
228
280
  if (typeof value !== "string") return undefined;
229
- return RESET_ELIGIBLE_CODES.has(value as CodexResetEligibleExhaustionCode)
230
- ? value as CodexResetEligibleExhaustionCode
231
- : undefined;
281
+ return allowed.has(value) ? value : undefined;
232
282
  }
233
283
 
234
- function structuredResetEligibleCode(payload: unknown): CodexResetEligibleExhaustionCode | undefined {
284
+ function structuredAllowedCode(payload: unknown, allowed: ReadonlySet<string>): string | undefined {
235
285
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
236
286
  const root = payload as Record<string, unknown>;
237
287
  const hasRootDiscriminator = hasOwnField(root, "code") || hasOwnField(root, "type");
238
288
 
239
- if (!hasOwnField(root, "error")) return exactResetEligibleCode(root);
289
+ if (!hasOwnField(root, "error")) return exactAllowedCode(root, allowed);
240
290
  if (hasRootDiscriminator) return undefined;
241
291
 
242
292
  const nested = root.error;
243
293
  if (!nested || typeof nested !== "object" || Array.isArray(nested)) return undefined;
244
- return exactResetEligibleCode(nested as Record<string, unknown>);
294
+ return exactAllowedCode(nested as Record<string, unknown>, allowed);
245
295
  }
246
296
 
247
- async function resetEligibleCodeFromResponse(
297
+ /**
298
+ * Parse one bounded body and classify its structured code against both sets at once.
299
+ *
300
+ * One read, because the caller holds a `Response` whose body may only be consumed once per
301
+ * clone and the two questions are asked about the same bytes.
302
+ */
303
+ async function exhaustionCodeFromResponse(
248
304
  response: Response,
249
305
  signal?: AbortSignal,
250
- ): Promise<CodexResetEligibleExhaustionCode | undefined> {
306
+ ): Promise<{
307
+ resetEligible?: CodexResetEligibleExhaustionCode;
308
+ scoped?: CodexScopedExhaustionCode;
309
+ }> {
251
310
  try {
252
311
  const body = await readBoundedResponseBody(response.clone(), { signal, fatalUtf8: true });
253
- if (!body.displaySafe || body.truncated || !body.text.trim()) return undefined;
312
+ if (!body.displaySafe || body.truncated || !body.text.trim()) return {};
254
313
  const payload = JSON.parse(body.text) as unknown;
255
314
  // JSON.parse silently keeps the last duplicate key, making contradictory
256
315
  // payloads order-dependent. Reject any duplicate at any object depth.
257
- if (isUnsafeJsonDocument(body.text)) return undefined;
258
- return structuredResetEligibleCode(payload);
316
+ if (isUnsafeJsonDocument(body.text)) return {};
317
+ const resetEligible = structuredAllowedCode(payload, RESET_ELIGIBLE_CODES);
318
+ if (resetEligible !== undefined) {
319
+ return { resetEligible: resetEligible as CodexResetEligibleExhaustionCode };
320
+ }
321
+ const scoped = structuredAllowedCode(payload, SCOPED_EXHAUSTION_CODES);
322
+ return scoped === undefined ? {} : { scoped: scoped as CodexScopedExhaustionCode };
259
323
  } catch {
260
324
  // Classification must fail closed. A malformed, oversized, consumed, or
261
325
  // cancelled body cannot authorize an irreversible reset-credit operation.
262
- return undefined;
326
+ return {};
263
327
  }
264
328
  }
265
329
 
330
+ /**
331
+ * The organization- or project-scoped exhaustion code this rejection names, if any.
332
+ *
333
+ * Exported for the account-rotation gate, which has to answer "may another pool account serve
334
+ * this?" before it has any reason to build a full classification. Fails closed to `undefined`:
335
+ * an unreadable, truncated, duplicate-keyed or aborted body leaves the caller's existing
336
+ * behaviour untouched, so only positive evidence can ever withhold a rotation.
337
+ */
338
+ export async function codexScopedExhaustionCode(
339
+ response: Response,
340
+ options: { signal?: AbortSignal } = {},
341
+ ): Promise<CodexScopedExhaustionCode | undefined> {
342
+ return (await exhaustionCodeFromResponse(response, options.signal)).scoped;
343
+ }
344
+
266
345
  /**
267
346
  * Classify an upstream Codex rejection before any response event is exposed.
268
347
  *
269
348
  * Only an exact structured exhaustion code on HTTP 429/402 is reset-eligible.
270
349
  * Status alone and message text are intentionally insufficient. The broad
271
350
  * alternate-account retry remains eligible for 429/402 to preserve #584.
351
+ *
352
+ * The one carve-out from that breadth is an organization- or project-scoped exhaustion
353
+ * ({@link SCOPED_EXHAUSTION_CODE_VALUES}), which reports `alternateRetryEligible: false`
354
+ * because every credential inside the refusing limit would be refused by the same counter.
272
355
  */
273
356
  export async function classifyCodexPreStreamRejection(
274
357
  response: Response,
@@ -283,13 +366,19 @@ export async function classifyCodexPreStreamRejection(
283
366
  if (TRANSIENT_SERVER_STATUSES.has(status)) return rejection(status, "transient-server-error");
284
367
  if (status !== 429 && status !== 402) return rejection(status, "other");
285
368
 
286
- const semanticCode = await resetEligibleCodeFromResponse(response, options.signal);
369
+ const { resetEligible: semanticCode, scoped } = await exhaustionCodeFromResponse(
370
+ response,
371
+ options.signal,
372
+ );
287
373
  if (semanticCode) {
288
374
  return rejection(status, "reset-eligible-exhaustion", {
289
375
  alternateRetryEligible: true,
290
376
  semanticCode,
291
377
  });
292
378
  }
379
+ if (scoped) {
380
+ return rejection(status, "scoped-quota-exhaustion", { scopedExhaustionCode: scoped });
381
+ }
293
382
  return rejection(
294
383
  status,
295
384
  status === 429 ? "generic-rate-limit" : "unverified-billing-or-quota",
@@ -0,0 +1,194 @@
1
+ import { saveConfigPreservingClaudeCode } from "../../config";
2
+ import { clearCodexAccountPin, pinnedCodexAccountId } from "../account-priority";
3
+ import {
4
+ POOL_KEY_CODEX,
5
+ normalizeCodexAccountPoolStrategy,
6
+ seedPoolRotationAccount,
7
+ } from "../pool-rotation";
8
+ import type { OcxConfig } from "../../types";
9
+ import { clearThreadAccountMap } from "./thread-affinity";
10
+ import {
11
+ NATIVE_MODEL_QUOTA_SCOPES,
12
+ codexPoolKeyForScope,
13
+ deleteAccountHealth,
14
+ deleteScopedHealth,
15
+ getAccountHealth,
16
+ isIndependentCodexQuotaScope,
17
+ listScopedHealthEntries,
18
+ preservedCooldownFields,
19
+ setAccountHealth,
20
+ setScopedHealth,
21
+ type CodexUpstreamHealth,
22
+ } from "./health-store";
23
+
24
+ /**
25
+ * Process-local cursor for automatic RR/fill-first (and quota-429 when not
26
+ * sync-writing) picks. Keeps unrelated `saveConfig` from persisting transient
27
+ * rotation as the operator's `activeCodexAccountId`. Manual selection clears it
28
+ * so disk/`config.activeCodexAccountId` remains authoritative.
29
+ */
30
+ let runtimeActiveCodexAccountId: string | undefined;
31
+
32
+ /** Manual selection resets transient routing evidence without bypassing a real 429 cooldown. */
33
+ export function resetCodexRoutingForManualSelection(accountId: string): void {
34
+ clearThreadAccountMap();
35
+ // Manual selection is the operator source of truth — drop any automatic runtime cursor.
36
+ runtimeActiveCodexAccountId = undefined;
37
+ // Record the pick as an unspent one-shot on the SHARED scope only. An independent scope
38
+ // gets no entry on purpose: every write site the guard protects is already skipped for
39
+ // independent scopes, so an entry there would be state nothing reads — and state nothing
40
+ // reads is what the next reader mistakes for a rule.
41
+ //
42
+ // Seeding happens ONLY here. A pool-driven promote must never create or move a preference,
43
+ // or the pool would manufacture an operator intent nobody expressed.
44
+ manualPreference.set(POOL_KEY_CODEX, accountId);
45
+ // Seed the RR ring so the next unbound new session honors the manually selected account
46
+ // under round-robin (affinity-cleared threads / null threadId). Fill-first already follows
47
+ // config.activeCodexAccountId, which the caller persists before invoking this.
48
+ seedPoolRotationAccount(POOL_KEY_CODEX, accountId);
49
+ for (const scope of new Set(Object.values(NATIVE_MODEL_QUOTA_SCOPES))) {
50
+ if (isIndependentCodexQuotaScope(scope)) {
51
+ seedPoolRotationAccount(codexPoolKeyForScope(scope), accountId);
52
+ }
53
+ }
54
+ // Quota avoidance is a preference, like the soft avoid dropped above, and an operator naming
55
+ // this account has overruled it. The hard cooldown is the part that survives.
56
+ const overrule = (health: CodexUpstreamHealth) => {
57
+ const { quotaAvoidUntil: _avoid, ...retained } = preservedCooldownFields(health);
58
+ return retained;
59
+ };
60
+ const current = getAccountHealth(accountId);
61
+ if (current) {
62
+ const retained = overrule(current);
63
+ if (Object.keys(retained).length === 0) deleteAccountHealth(accountId);
64
+ else setAccountHealth(accountId, { consecutiveFailures: 0, ...retained });
65
+ }
66
+ // A reset-derived refusal records its avoidance on the SCOPED map and returns before the
67
+ // account-wide entry is written, so naming the account has to reach that map too. Stopping
68
+ // at `upstreamHealth` — and returning early when it holds nothing — overruled nothing in
69
+ // the case that produces the avoidance this function exists to overrule.
70
+ for (const [scope, health] of [...(listScopedHealthEntries(accountId))]) {
71
+ const retained = overrule(health);
72
+ if (Object.keys(retained).length === 0) deleteScopedHealth(accountId, scope);
73
+ else setScopedHealth(accountId, scope, { consecutiveFailures: 0, ...retained });
74
+ }
75
+ }
76
+
77
+ /** Effective active: automatic runtime cursor, else operator/persisted selection. */
78
+ /**
79
+ * Unspent operator selections, keyed by pool scope.
80
+ *
81
+ * Codex has no account-side equivalent of the Anthropic `selectionRevision`, so staleness
82
+ * cannot be detected by comparing values: a pool-driven promote legitimately moves the
83
+ * persisted active account, and reading that as staleness would silently spend the
84
+ * operator's one-shot. Invalidation is keyed to the OPERATOR path instead — another manual
85
+ * selection, the account leaving the pool, or a successful dispatch on it.
86
+ */
87
+ const manualPreference = new Map<string, string>();
88
+
89
+ /**
90
+ * Spend the one-shot for a pool scope once a dispatch on that account actually succeeded.
91
+ * This is the Codex analogue of `commitAnthropicSelectionRouting`, which Codex lacks.
92
+ *
93
+ * Wiring this BEFORE the guard below is not a style choice. Measured: with the guard in
94
+ * place and no consume site, the first manual selection freezes the automatic cursor
95
+ * permanently and 15 of 69 rotation tests fail.
96
+ */
97
+ export function consumeManualPreference(accountId: string, poolKey: string): void {
98
+ if (manualPreference.get(poolKey) === accountId) manualPreference.delete(poolKey);
99
+ }
100
+
101
+ /**
102
+ * Drop an account's preference in every scope. Pause and exclusion do not route through
103
+ * `resetCodexRoutingForManualSelection`, so without this a preference could outlive the
104
+ * account it names and keep suppressing the automatic cursor.
105
+ */
106
+ export function forgetManualPreference(accountId: string): void {
107
+ for (const [poolKey, preferred] of manualPreference) {
108
+ if (preferred === accountId) manualPreference.delete(poolKey);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * True while an unspent operator selection for this scope names a DIFFERENT account than
114
+ * the automatic pick about to be recorded.
115
+ *
116
+ * Callers pass their own scope: an independent quota scope keeps its own entry and must
117
+ * never read the shared one. The failover promote does NOT consult this — see its call
118
+ * site for why.
119
+ */
120
+ export function manualPreferenceBlocks(poolKey: string, accountId: string): boolean {
121
+ const preferred = manualPreference.get(poolKey);
122
+ return preferred !== undefined && preferred !== accountId;
123
+ }
124
+
125
+ export function getEffectiveActiveCodexAccountId(config: OcxConfig): string | undefined {
126
+ return runtimeActiveCodexAccountId ?? config.activeCodexAccountId;
127
+ }
128
+
129
+ /**
130
+ * Whether the account routing is currently on is there because an operator asked
131
+ * for it, rather than because a strategy landed on it. Surfaces read this instead
132
+ * of comparing the stored pin themselves, which would report a pin that a later
133
+ * automatic pick has already moved past.
134
+ */
135
+ export function isEffectiveCodexAccountPinned(config: OcxConfig): boolean {
136
+ const pinned = pinnedCodexAccountId(config);
137
+ return pinned !== undefined && pinned === getEffectiveActiveCodexAccountId(config);
138
+ }
139
+
140
+ /**
141
+ * Automatic strategy / failover cursor only — never mutates `config.activeCodexAccountId`
142
+ * so an unrelated `saveConfig` cannot persist transient rotation as operator selection.
143
+ */
144
+ export function rememberActiveCodexAccount(_config: OcxConfig, accountId: string): void {
145
+ runtimeActiveCodexAccountId = accountId;
146
+ }
147
+
148
+ /**
149
+ * End the manual pin when routing moves to a different account. Returns whether
150
+ * the pin changed so the caller can fold it into a write it was already making.
151
+ */
152
+ function releaseCodexAccountPinFor(config: OcxConfig, accountId: string): boolean {
153
+ const pinned = pinnedCodexAccountId(config);
154
+ if (pinned === undefined || pinned === accountId) return false;
155
+ clearCodexAccountPin(config);
156
+ return true;
157
+ }
158
+
159
+ /** Persist operator (or quota-strategy) active selection to config + disk. */
160
+ export function setActiveCodexAccount(config: OcxConfig, accountId: string): void {
161
+ runtimeActiveCodexAccountId = undefined;
162
+ const releasedPin = releaseCodexAccountPinFor(config, accountId);
163
+ if (config.activeCodexAccountId === accountId && !releasedPin) return;
164
+ config.activeCodexAccountId = accountId;
165
+ saveConfigPreservingClaudeCode(config);
166
+ }
167
+
168
+ /** Quota strategy persists; RR/fill-first keep a process-local cursor only. */
169
+ export function promoteActiveCodexAccount(config: OcxConfig, accountId: string): void {
170
+ if (normalizeCodexAccountPoolStrategy(config.accountPoolStrategy) === "quota") {
171
+ setActiveCodexAccount(config, accountId);
172
+ return;
173
+ }
174
+ // Runtime-only, like the cursor itself: a caller that persists (pause, delete)
175
+ // saves this release with its own write; a transient failover does not, so the
176
+ // pin survives a restart that also clears the failure history behind it.
177
+ releaseCodexAccountPinFor(config, accountId);
178
+ rememberActiveCodexAccount(config, accountId);
179
+ }
180
+
181
+ export function clearAllManualPreferences(): void {
182
+ manualPreference.clear();
183
+ }
184
+
185
+ export function forgetRuntimeActiveCodexAccount(): void {
186
+ runtimeActiveCodexAccountId = undefined;
187
+ }
188
+
189
+ export function forgetRoutingPreferencesOutside(codexAccountIds: ReadonlySet<string>): void {
190
+ for (const [poolKey, preferred] of manualPreference) {
191
+ if (codexAccountIds.has(preferred)) continue;
192
+ manualPreference.delete(poolKey);
193
+ }
194
+ }
@@ -0,0 +1,70 @@
1
+ import type { OcxConfig } from "../../types";
2
+ import type { CodexAccountUsabilityOptions } from "../account-usability";
3
+ import { isCodexAccountUsable } from "../account-usability";
4
+ import { isCacheAffinityEnabled, isUnknownUsage } from "./selection";
5
+
6
+ /**
7
+ * Does a healthy bound account keep its conversation when quota re-evaluation looks at it?
8
+ *
9
+ * Two independent reasons say yes, and they are not the same claim. `pool.cacheAffinity` is an
10
+ * operator preference about COST: provider prompt caches are account-isolated, so handing a bound
11
+ * conversation from account to account re-sends the whole prefix, and #4546 measured 7k-token
12
+ * turns becoming 150k-token ones. Setting it false restores capacity-first routing, and an
13
+ * operator who wants that keeps it.
14
+ *
15
+ * Uploaded-file retention is a claim about CORRECTNESS, so it does not take that instruction
16
+ * (#4778). Uploaded files are scoped to the account that issued them. Moving a conversation that
17
+ * carries live `file_id` references does not cost a cold prefix -- it orphans the reference, and
18
+ * because the reference stays in conversation history EVERY later turn is refused with
19
+ * `409 account_change_file_scope` until the user re-uploads under the serving account or starts
20
+ * over. That is a dead conversation rather than an expensive one, and `pool.cacheAffinity: false`
21
+ * was never asking to accept it: the flag trades cache locality for capacity, not correctness for
22
+ * capacity.
23
+ *
24
+ * This answers the VOLUNTARY move only. Its caller still releases the binding on genuine
25
+ * exhaustion or an unusable account, and every involuntary release that runs earlier in
26
+ * `resolveCodexAccountForThreadDetailed` -- quota refusal, failover streak, pause, cooldown, lost
27
+ * generation, affinity expiry -- never reaches here at all. So retention can never wedge a
28
+ * conversation on an account that cannot serve it, which is exactly why the #4710 refusal remains
29
+ * required: this makes that refusal rarer and does not replace it.
30
+ *
31
+ * It lives beside selection rather than inside `routing.ts` because it is a policy question two
32
+ * call sites ask -- the live path in `reevaluateAffinityQuota` and the side-effect-free
33
+ * `previewReusableAffinityAccount` that subagent fallback reads -- and those two must answer
34
+ * identically or preview hands fallback a different account than the request uses.
35
+ */
36
+ export function retainsBoundAccountForQuota(
37
+ config: OcxConfig,
38
+ selectionOptions?: CodexAccountUsabilityOptions,
39
+ ): boolean {
40
+ return isCacheAffinityEnabled(config)
41
+ || selectionOptions?.retainAccountForUploadedFiles === true;
42
+ }
43
+
44
+ /**
45
+ * May a LIVE binding be moved for quota reasons?
46
+ *
47
+ * Default: no. The bar is genuine exhaustion, because moving a bound conversation discards the
48
+ * prompt cache warmed on its account and a threshold crossing is a hint that the account is
49
+ * getting busy rather than evidence it cannot serve (#4546). Deliberately NOT
50
+ * `hasCodexQuotaHeadroom`, which reads `usage < autoSwitchThreshold` and would reproduce the old
51
+ * rule under a new name.
52
+ *
53
+ * When nothing retains, the historical rule comes back: a crossing of `autoSwitchThreshold` is
54
+ * enough. That is capacity-first routing, and an operator who asks for it keeps it -- it is just
55
+ * not what an install gets by never having heard of the flag.
56
+ */
57
+ export function mayRebindAffinityForQuota(
58
+ config: OcxConfig,
59
+ accountId: string,
60
+ usage: number,
61
+ threshold: number,
62
+ selectionOptions?: CodexAccountUsabilityOptions,
63
+ ): boolean {
64
+ const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold;
65
+ if (!retainsBoundAccountForQuota(config, selectionOptions)) return overThreshold;
66
+ // The usable half is already guaranteed by both callers, which gate on
67
+ // isCodexAccountSelectable; kept explicit so the predicate reads correctly on its own.
68
+ return !isCodexAccountUsable(config, accountId, selectionOptions)
69
+ || (!isUnknownUsage(usage) && usage >= 100);
70
+ }