@bitkyc08/opencodex 2.76.0 → 2.77.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 (189) hide show
  1. package/gui/dist/assets/App-COdEeqSj.css +1 -0
  2. package/gui/dist/assets/App-DEpCJEP_.js +54 -0
  3. package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-gloIYNaW.js} +1 -1
  4. package/gui/dist/assets/index-Cd7ittMv.css +1 -0
  5. package/gui/dist/assets/index-DwJKFJm5.js +94 -0
  6. package/gui/dist/assets/quota-summary-DGcEJU3h.js +1 -0
  7. package/gui/dist/index.html +4 -3
  8. package/package.json +1 -1
  9. package/src/adapters/antigravity-refusal.ts +28 -0
  10. package/src/adapters/cursor/protobuf-request.ts +6 -0
  11. package/src/adapters/cursor/request-builder.ts +3 -0
  12. package/src/adapters/cursor/structured-output.ts +13 -0
  13. package/src/adapters/cursor/types.ts +2 -0
  14. package/src/adapters/ollama-native.ts +29 -2
  15. package/src/adapters/openai-responses/passthrough.ts +133 -103
  16. package/src/adapters/openai-responses/request-strips.ts +10 -4
  17. package/src/adapters/zed.ts +26 -4
  18. package/src/bridge/response-json.ts +14 -14
  19. package/src/bridge/sse.ts +15 -5
  20. package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
  21. package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
  22. package/src/claude/agents-inject.ts +4 -43
  23. package/src/claude/gateway-cache.ts +16 -13
  24. package/src/claude/inbound-model-options.ts +9 -2
  25. package/src/claude/intercept/cli-catalog.ts +27 -16
  26. package/src/claude/intercept/picker-bootstrap.ts +37 -16
  27. package/src/claude/intercept/picker-budget.ts +41 -0
  28. package/src/claude/outbound.ts +2 -0
  29. package/src/claude/subagent-force-status.ts +53 -0
  30. package/src/claude/subagent-model.ts +93 -0
  31. package/src/cli/account-api.ts +3 -0
  32. package/src/cli/account.ts +3 -1
  33. package/src/cli/agent.ts +10 -0
  34. package/src/cli/capabilities.ts +9 -0
  35. package/src/cli/chatgpt-command.ts +10 -7
  36. package/src/cli/claude.ts +22 -6
  37. package/src/cli/codex-shim-readiness.ts +89 -0
  38. package/src/cli/connect.ts +19 -0
  39. package/src/cli/dispatch.ts +3 -3
  40. package/src/cli/export-command.ts +20 -9
  41. package/src/cli/gui.ts +8 -3
  42. package/src/cli/help-catalog.ts +84 -0
  43. package/src/cli/help-models-context.ts +17 -0
  44. package/src/cli/help-navigation.ts +50 -0
  45. package/src/cli/help-recovery.ts +66 -0
  46. package/src/cli/help.ts +52 -8
  47. package/src/cli/models-runtime.ts +2 -1
  48. package/src/cli/opencode.ts +54 -10
  49. package/src/cli/provider.ts +3 -29
  50. package/src/cli/registry.ts +29 -5
  51. package/src/cli/root.ts +30 -9
  52. package/src/cli/runtime-api.ts +1 -1
  53. package/src/cli/tray-proxy.ts +3 -2
  54. package/src/clients/config-export/contracts.ts +100 -0
  55. package/src/clients/config-export/droid.ts +54 -2
  56. package/src/clients/config-export/kilo.ts +12 -2
  57. package/src/clients/config-export/model-metadata.ts +10 -4
  58. package/src/clients/config-export/reasoning-metadata.ts +47 -0
  59. package/src/clients/config-export.ts +46 -66
  60. package/src/clients/omo-role-models.ts +17 -3
  61. package/src/codex/account-credit-use.ts +4 -2
  62. package/src/codex/account-runtime-state.ts +21 -0
  63. package/src/codex/account-store.ts +11 -47
  64. package/src/codex/account-usability.ts +11 -8
  65. package/src/codex/auth-api/account-list.ts +73 -63
  66. package/src/codex/auth-api/main-account-probe.ts +25 -12
  67. package/src/codex/auth-api/pool-mode-gate.ts +6 -9
  68. package/src/codex/auth-api/pool-quota-probe.ts +2 -4
  69. package/src/codex/auth-api/reset-credit-service.ts +3 -2
  70. package/src/codex/auth-context.ts +30 -11
  71. package/src/codex/catalog/aggregation.ts +3 -0
  72. package/src/codex/catalog/model-hints.ts +2 -1
  73. package/src/codex/catalog/model-visibility.ts +6 -3
  74. package/src/codex/catalog/parsing.ts +2 -0
  75. package/src/codex/catalog/provider-models.ts +13 -4
  76. package/src/codex/catalog/retained-sync.ts +2 -1
  77. package/src/codex/chatgpt-refresh-failure.ts +56 -0
  78. package/src/codex/convergence.ts +2 -1
  79. package/src/codex/main-account.ts +144 -11
  80. package/src/codex/quota-refresh-outcome.ts +17 -2
  81. package/src/codex/quota-types.ts +33 -1
  82. package/src/codex/quota.ts +56 -15
  83. package/src/codex/routing/cooldown-math.ts +10 -4
  84. package/src/codex/routing/selection.ts +11 -4
  85. package/src/codex/routing.ts +9 -9
  86. package/src/codex/shim-path-resolution.ts +133 -0
  87. package/src/codex/shim.ts +39 -39
  88. package/src/codex/subagent-model-fallback.ts +2 -1
  89. package/src/combos/failover.ts +82 -1
  90. package/src/combos/jev.ts +13 -3
  91. package/src/config/diagnostics.ts +4 -0
  92. package/src/config/load-degrade.ts +4 -0
  93. package/src/config/provider-validation.ts +3 -0
  94. package/src/config/schema/compaction-triggers.ts +8 -0
  95. package/src/config/schema/config-schema.ts +11 -0
  96. package/src/config/schema/leaf-validators.ts +5 -1
  97. package/src/config/subagent-models.ts +12 -0
  98. package/src/generated/compatibility-version.json +245 -153
  99. package/src/grok/session-identity.ts +33 -0
  100. package/src/integrations/catalog-refresh.ts +1 -1
  101. package/src/integrations/mutation-plan.ts +4 -2
  102. package/src/integrations/state.ts +68 -6
  103. package/src/integrations/writer.ts +18 -4
  104. package/src/lib/gui-pair-capability.ts +17 -0
  105. package/src/lib/provider-client-headers.ts +62 -0
  106. package/src/oauth/anthropic-continuity.ts +65 -0
  107. package/src/oauth/anthropic-identity.ts +75 -0
  108. package/src/oauth/anthropic-routing.ts +3 -3
  109. package/src/oauth/anthropic.ts +7 -1
  110. package/src/oauth/generic-account-failover.ts +21 -0
  111. package/src/oauth/health.ts +32 -5
  112. package/src/oauth/index.ts +36 -27
  113. package/src/oauth/local-token-detect.ts +7 -0
  114. package/src/oauth/refresh-policy.ts +2 -0
  115. package/src/oauth/store.ts +55 -6
  116. package/src/oauth/types.ts +9 -0
  117. package/src/protocols/encoders/messages.ts +4 -3
  118. package/src/providers/antigravity-effort-families.ts +75 -0
  119. package/src/providers/antigravity-models.ts +54 -16
  120. package/src/providers/antigravity-wire-snapshot.ts +66 -0
  121. package/src/providers/derive.ts +2 -0
  122. package/src/providers/model-rename-fields.ts +3 -0
  123. package/src/providers/new-model-policy.ts +48 -10
  124. package/src/providers/registry/entries-extended.ts +3 -0
  125. package/src/providers/registry/model-ids.ts +2 -0
  126. package/src/providers/registry/model-seeds.ts +5 -0
  127. package/src/providers/registry/types.ts +4 -0
  128. package/src/responses/compaction.ts +19 -0
  129. package/src/router.ts +6 -0
  130. package/src/server/auth-cors.ts +9 -0
  131. package/src/server/chat-completions.ts +10 -0
  132. package/src/server/claude-messages.ts +18 -11
  133. package/src/server/droid-reasoning-default.ts +50 -0
  134. package/src/server/github-copilot-responses-repair.ts +3 -6
  135. package/src/server/grok-responses-control-frame.ts +2 -2
  136. package/src/server/grok-responses-snapshot-repair.ts +4 -5
  137. package/src/server/gui-session.ts +33 -11
  138. package/src/server/index/claude-intercept-lifecycle.ts +4 -0
  139. package/src/server/index/serve-options.ts +2 -1
  140. package/src/server/management/agent-settings-routes.ts +6 -129
  141. package/src/server/management/config-routes.ts +1 -1
  142. package/src/server/management/integration-routes.ts +64 -6
  143. package/src/server/management/link-routes.ts +20 -13
  144. package/src/server/management/model-row-export-metadata.ts +150 -0
  145. package/src/server/management/model-rows.ts +148 -9
  146. package/src/server/management/route-registry.ts +4 -3
  147. package/src/server/management/shared.ts +19 -5
  148. package/src/server/management/subagent-model-routes.ts +214 -0
  149. package/src/server/relay.ts +14 -44
  150. package/src/server/request-log-generation-window.ts +38 -0
  151. package/src/server/request-log.ts +13 -0
  152. package/src/server/responses/adapter-dispatch.ts +100 -1
  153. package/src/server/responses/codex-auth-error.ts +10 -2
  154. package/src/server/responses/combo-stream-preflight.ts +29 -3
  155. package/src/server/responses/compaction-routing.ts +10 -1
  156. package/src/server/responses/core-auth.ts +57 -26
  157. package/src/server/responses/core-codex-account.ts +6 -3
  158. package/src/server/responses/core-combo-failure.ts +5 -1
  159. package/src/server/responses/core-combo-native.ts +7 -1
  160. package/src/server/responses/core-combo.ts +18 -1
  161. package/src/server/responses/core-normalize.ts +7 -0
  162. package/src/server/responses/core-options.ts +5 -0
  163. package/src/server/responses/fetch-helpers.ts +9 -0
  164. package/src/server/responses/passthrough-delivery.ts +1 -1
  165. package/src/server/responses/policy-fallback.ts +1 -1
  166. package/src/server/responses/request-prepare.ts +5 -0
  167. package/src/server/responses/request-spend.ts +30 -30
  168. package/src/server/responses-custom-tool-repair.ts +2 -2
  169. package/src/server/responses-hosted-image-display.ts +70 -9
  170. package/src/server/responses-terminal-repair.ts +15 -2
  171. package/src/server/sse-frame-buffer.ts +38 -36
  172. package/src/server/sse-payload-rewrite.ts +78 -49
  173. package/src/server/ws-bridge.ts +1 -11
  174. package/src/service/windows-taskxml.ts +1 -2
  175. package/src/service-manager-probe.ts +36 -2
  176. package/src/types/config.ts +4 -0
  177. package/src/types/provider.ts +18 -0
  178. package/src/update/npm-invocation.mjs +35 -3
  179. package/src/update/proxy-liveness-probe.mjs +38 -8
  180. package/src/usage/expected-prices.ts +9 -0
  181. package/src/usage/log.ts +7 -0
  182. package/src/usage/summary.ts +82 -1
  183. package/src/web-search/loop.ts +11 -8
  184. package/src/web-search/run-turn-loop.ts +10 -7
  185. package/gui/dist/assets/App-CddorHdA.js +0 -51
  186. package/gui/dist/assets/App-DMudtISW.css +0 -1
  187. package/gui/dist/assets/index-Btze1I3T.css +0 -1
  188. package/gui/dist/assets/index-CvnY7E6i.js +0 -86
  189. package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
@@ -1,11 +1,15 @@
1
+ import { classifyChatgptRefreshFailure, noteChatgptRefreshFailure } from "./chatgpt-refresh-failure";
2
+ import { readBoundedResponseBody } from "../lib/bounded-body";
1
3
  import { createHash } from "node:crypto";
2
4
  import { readFileSync, statSync } from "node:fs";
3
5
  import { join } from "node:path";
4
6
  import { readCodexTokens } from "./auth-collision";
5
7
  import {
8
+ CHATGPT_CLIENT_ID,
9
+ CHATGPT_TOKEN_URL,
10
+ credsFromToken,
6
11
  decodeJwtPayload,
7
12
  extractAccountId,
8
- refreshChatGPTToken,
9
13
  } from "../oauth/chatgpt";
10
14
  import type { OAuthCredentials } from "../oauth/types";
11
15
  import { extractChatgptPlanType } from "./plan";
@@ -17,7 +21,12 @@ import {
17
21
  import { atomicWriteFile, resolveWriteTarget } from "../config/atomic-write";
18
22
  import { resolveCodexHomeDir } from "./home";
19
23
  import { assertNotRealCodexHomeUnderTest } from "../lib/test-home-guard";
20
- import { clearAccountNeedsReauth } from "./account-runtime-state";
24
+ import {
25
+ clearAccountNeedsReauth,
26
+ isMainRefreshGrantRejected,
27
+ markMainRefreshGrantRejected,
28
+ clearMainRefreshGrantRejection,
29
+ } from "./account-runtime-state";
21
30
  import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch";
22
31
  import { withNativeMainExclusiveClaim } from "./native-main-claim";
23
32
  import { resolveNativeProfileContext } from "./native-profile-store";
@@ -78,6 +87,68 @@ export class MainAccountTokenRefreshError extends Error {
78
87
  }
79
88
  }
80
89
 
90
+ /**
91
+ * The native-main refresh, posted here rather than through `refreshChatGPTToken` so the token
92
+ * endpoint's structured `error` code survives to {@link classifyChatgptRefreshFailure}.
93
+ *
94
+ * `refreshChatGPTToken` collapses the refusal into one display string, and classifying a grant
95
+ * from that string is what let `refresh_token_reused` pass as transient -- the grant never
96
+ * retired, every request re-earning its own 401 -- while a 5xx whose description merely read
97
+ * "session expired" passed as proof and would have retired a live grant.
98
+ *
99
+ * Same endpoint, client id and grant type as every other ChatGPT refresh, and the success path
100
+ * is `credsFromToken` itself, so only the failure branch differs: it classifies by code, exactly
101
+ * as the stored-pool refresh does.
102
+ */
103
+ async function refreshNativeMainGrant(
104
+ refreshToken: string,
105
+ options: { signal: AbortSignal },
106
+ ): Promise<OAuthCredentials> {
107
+ const resp = await fetch(CHATGPT_TOKEN_URL, {
108
+ method: "POST",
109
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
110
+ body: new URLSearchParams({
111
+ grant_type: "refresh_token",
112
+ client_id: CHATGPT_CLIENT_ID,
113
+ refresh_token: refreshToken,
114
+ }).toString(),
115
+ signal: options.signal,
116
+ });
117
+ if (!resp.ok) {
118
+ const body = await readBoundedResponseBody(resp, { signal: options.signal, maxBytes: 16_384, fatalUtf8: true })
119
+ .catch(() => undefined);
120
+ const failure = classifyChatgptRefreshFailure(resp.status, body?.displaySafe ? body.text : "");
121
+ noteChatgptRefreshFailure("native main", resp.status, failure);
122
+ throw new MainAccountTokenRefreshError(failure.reason === "unknown" ? "transient" : "reauth");
123
+ }
124
+ let credentials: OAuthCredentials;
125
+ try {
126
+ credentials = credsFromToken((await resp.json()) as Record<string, unknown>);
127
+ } catch {
128
+ if (options.signal.aborted) throw options.signal.reason;
129
+ noteNativeMainRefresh("transient", resp.status);
130
+ // Parsing errors may include provider body text; retain only the fixed verdict.
131
+ throw new MainAccountTokenRefreshError("transient");
132
+ }
133
+ noteNativeMainRefresh("ok", resp.status);
134
+ return credentials;
135
+ }
136
+
137
+ /**
138
+ * One line per native-main refresh verdict, so a dead grant is diagnosable from service.log.
139
+ *
140
+ * A revoked session is invisible from the outside: the grant is refused, nothing is written, and
141
+ * the request fails with a status that looks like every other 401. Carries the token endpoint's
142
+ * HTTP status and its structured `error` code and nothing else -- never a token, never a body.
143
+ */
144
+ function noteNativeMainRefresh(outcome: string, status?: number, code?: string): void {
145
+ console.warn(
146
+ `[codex] native main refresh: ${outcome}`
147
+ + (status === undefined ? "" : ` status=${status}`)
148
+ + ` code=${code && /^[A-Za-z0-9._-]{1,64}$/.test(code) ? code : "none"}`,
149
+ );
150
+ }
151
+
81
152
  function nonEmptyString(value: unknown): string | undefined {
82
153
  return typeof value === "string" && value.trim() ? value : undefined;
83
154
  }
@@ -147,13 +218,45 @@ function mainAccessTokenFresh(accessToken: string | undefined, now: number, skew
147
218
  }
148
219
 
149
220
  /** A refresh grant makes native main routeable even when the current access token is expired. */
150
- export function isMainAccountCredentialUsable(now = Date.now()): boolean {
221
+ function mainRefreshGrantKey(current: MainAuthJsonCredential): string | undefined {
222
+ return current.refreshToken
223
+ ? `${current.path}:${refreshGrantFingerprintForToken(current.refreshToken)}` : undefined;
224
+ }
225
+
226
+ function credentialRefreshGrantRejected(current: MainAuthJsonCredential | null): boolean {
227
+ const key = current && mainRefreshGrantKey(current);
228
+ return !!key && isMainRefreshGrantRejected(key);
229
+ }
230
+
231
+ export interface MainAccountCredentialStatus {
232
+ refreshGrantRejected: boolean;
233
+ hasRefreshGrant: boolean;
234
+ usable: boolean;
235
+ }
236
+
237
+ /** One owned physical observation; no credential material escapes this view. */
238
+ export function getMainAccountCredentialStatus(now = Date.now()): MainAccountCredentialStatus {
151
239
  const current = readMainAuthJsonCredential();
152
- return !!current?.refreshToken || mainAccessTokenFresh(current?.accessToken, now, 0);
240
+ const refreshGrantRejected = credentialRefreshGrantRejected(current);
241
+ const hasRefreshGrant = !!current?.refreshToken && !refreshGrantRejected;
242
+ return {
243
+ refreshGrantRejected,
244
+ hasRefreshGrant,
245
+ usable: !refreshGrantRejected && (hasRefreshGrant || mainAccessTokenFresh(current?.accessToken, now, 0)),
246
+ };
247
+ }
248
+
249
+ /** Current owned profile only; callers must hold the native read/selection permission. */
250
+ export function isMainAccountRefreshGrantRejected(): boolean {
251
+ return getMainAccountCredentialStatus().refreshGrantRejected;
252
+ }
253
+
254
+ export function isMainAccountCredentialUsable(now = Date.now()): boolean {
255
+ return getMainAccountCredentialStatus(now).usable;
153
256
  }
154
257
 
155
258
  export function hasMainAccountRefreshGrant(): boolean {
156
- return !!readMainAuthJsonCredential()?.refreshToken;
259
+ return getMainAccountCredentialStatus().hasRefreshGrant;
157
260
  }
158
261
 
159
262
  function assertMainAuthJsonSnapshotUnchanged(expected: MainAuthJsonCredential): void {
@@ -342,14 +445,26 @@ async function resolveMainAccountToken(
342
445
  dependencies: NativeMainRefreshDependencies = {},
343
446
  rejectedAccessToken?: string,
344
447
  ): Promise<{ accessToken: string; chatgptAccountId: string } | null> {
448
+ if (dependencies.signal?.aborted) throw dependencies.signal.reason;
345
449
  const initial = readMainAuthJsonCredential();
346
- if (!initial) return null;
450
+ // Every skip below ends a forced refresh WITHOUT asking the token endpoint anything, which from
451
+ // the outside is indistinguishable from a refusal: same failed request, no write, no verdict.
452
+ // A forced refresh is the only caller that passes `rejectedAccessToken`, so only it reports.
453
+ if (!initial) {
454
+ if (rejectedAccessToken !== undefined) noteNativeMainRefresh("skipped-no-credential");
455
+ return null;
456
+ }
457
+ if (credentialRefreshGrantRejected(initial)) throw new MainAccountTokenRefreshError("reauth");
347
458
  const now = Date.now();
348
459
  if (initial.accessToken !== rejectedAccessToken
349
460
  && mainAccessTokenFresh(initial.accessToken, now, MAIN_TOKEN_REFRESH_SKEW_MS)) {
461
+ // The stored credential is no longer the one upstream rejected: somebody else replaced it
462
+ // while this request was in flight, so there is nothing here to refresh or to retire.
463
+ if (rejectedAccessToken !== undefined) noteNativeMainRefresh("skipped-credential-replaced");
350
464
  return { accessToken: initial.accessToken!, chatgptAccountId: initial.chatgptAccountId };
351
465
  }
352
466
  if (!initial.refreshToken) {
467
+ if (rejectedAccessToken !== undefined) noteNativeMainRefresh("skipped-no-refresh-grant");
353
468
  return initial.accessToken !== rejectedAccessToken
354
469
  && mainAccessTokenFresh(initial.accessToken, now, 0)
355
470
  ? { accessToken: initial.accessToken!, chatgptAccountId: initial.chatgptAccountId }
@@ -380,6 +495,7 @@ async function resolveMainAccountToken(
380
495
  () => withCodexRefreshFileLock(lockKey, signal, async () => {
381
496
  const locked = readMainAuthJsonCredential();
382
497
  if (!locked) throw new MainAuthJsonChangedDuringRefreshError();
498
+ if (credentialRefreshGrantRejected(locked)) throw new MainAccountTokenRefreshError("reauth");
383
499
  if (!locked.refreshToken
384
500
  || refreshGrantFingerprintForToken(locked.refreshToken) !== lockKey) {
385
501
  if (locked.accessToken !== rejectedAccessToken
@@ -392,16 +508,31 @@ async function resolveMainAccountToken(
392
508
  && mainAccessTokenFresh(locked.accessToken, Date.now(), MAIN_TOKEN_REFRESH_SKEW_MS)) {
393
509
  return { accessToken: locked.accessToken!, chatgptAccountId: locked.chatgptAccountId };
394
510
  }
395
- const refresh = dependencies.refreshToken
396
- ?? ((refreshToken: string, options: { signal: AbortSignal }) => refreshChatGPTToken(refreshToken, options));
511
+ const refresh = dependencies.refreshToken ?? refreshNativeMainGrant;
397
512
  let refreshed: OAuthCredentials;
398
513
  try {
399
514
  refreshed = await refresh(locked.refreshToken, { signal });
400
515
  } catch (cause) {
516
+ // A refusal already classified at the endpoint keeps that verdict. An injected refresh
517
+ // threw an ordinary Error and has only its message, so prose still decides there --
518
+ // and only there, where there is no structured code to read.
401
519
  const message = cause instanceof Error ? cause.message.toLowerCase() : "";
402
- const reason = /invalid_grant|invalidated|revoked|expired/.test(message)
403
- ? "reauth" as const
404
- : "transient" as const;
520
+ const reason = cause instanceof MainAccountTokenRefreshError
521
+ ? cause.reason
522
+ : dependencies.refreshToken && /invalid_grant|invalidated|revoked|expired/.test(message)
523
+ ? "reauth" as const
524
+ : "transient" as const;
525
+ // The shared point every request path reaches a terminal native-main verdict through:
526
+ // the forced refresh after an upstream 401, the pre-send refresh of an expired bearer,
527
+ // and the compact twin all land here. Recording the quarantine at the callers instead
528
+ // left the native main slot alone unrecorded -- `refreshNativeMainForwardAuth` has no
529
+ // `quarantine` flag for its caller to act on the way the stored-pool twin does -- so a
530
+ // revoked session stayed `needsReauth: false` and every request re-earned its own 401.
531
+ if (signal.aborted) throw signal.reason;
532
+ if (reason === "reauth") {
533
+ assertMainAuthJsonSnapshotUnchanged(locked);
534
+ markMainRefreshGrantRejected(mainRefreshGrantKey(locked)!);
535
+ }
405
536
  throw new MainAccountTokenRefreshError(reason, { cause });
406
537
  }
407
538
  // The refresh may resolve after the caller went away (an implementation that does
@@ -411,6 +542,8 @@ async function resolveMainAccountToken(
411
542
  // before its own commit above.
412
543
  if (dependencies.signal?.aborted) throw dependencies.signal.reason;
413
544
  const result = persistRefreshedMainAuthJson(locked, refreshed);
545
+ // A permitted in-flight success speaks only for its own profile and grant.
546
+ clearMainRefreshGrantRejection(mainRefreshGrantKey(locked)!);
414
547
  if (dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
415
548
  return result;
416
549
  }),
@@ -1,8 +1,22 @@
1
1
  /** Diagnostic only: never use this outcome as quota, entitlement, or admission evidence. */
2
2
  export type CodexQuotaRefreshOutcome =
3
- | { status: "http_error"; httpStatus: number }
3
+ /** `code` is set only for a provider code that requires a new sign-in (fixed vocabulary). */
4
+ | { status: "http_error"; httpStatus: number; code?: CodexTerminalAuthCode }
4
5
  | { status: "ok" | "not_reported" | "timeout" | "network_error" | "invalid_response" | "internal_error" };
5
6
 
7
+ /** Provider auth codes that only a new sign-in can recover. */
8
+ export const CODEX_TERMINAL_AUTH_CODES = [
9
+ "invalid_workspace_selected",
10
+ "invalid_refresh_token",
11
+ // ChatGPT revokes every session of an account whose plan changes (for example Pro to Free),
12
+ // while the access token's `exp` still lies in the future.
13
+ "token_invalidated",
14
+ ] as const;
15
+ export type CodexTerminalAuthCode = typeof CODEX_TERMINAL_AUTH_CODES[number];
16
+ export function isCodexTerminalAuthCode(code: unknown): code is CodexTerminalAuthCode {
17
+ return (CODEX_TERMINAL_AUTH_CODES as readonly unknown[]).includes(code);
18
+ }
19
+
6
20
  /** The management response is untrusted at the CLI boundary; copy only the fixed vocabulary. */
7
21
  export function projectCodexQuotaRefreshOutcome(value: unknown): CodexQuotaRefreshOutcome | undefined {
8
22
  if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
@@ -10,7 +24,8 @@ export function projectCodexQuotaRefreshOutcome(value: unknown): CodexQuotaRefre
10
24
  if (row.status === "http_error") {
11
25
  return typeof row.httpStatus === "number" && Number.isInteger(row.httpStatus)
12
26
  && row.httpStatus >= 100 && row.httpStatus <= 599
13
- ? { status: "http_error", httpStatus: row.httpStatus }
27
+ ? { status: "http_error", httpStatus: row.httpStatus,
28
+ ...(isCodexTerminalAuthCode(row.code) ? { code: row.code } : {}) }
14
29
  : undefined;
15
30
  }
16
31
  switch (row.status) {
@@ -22,6 +22,31 @@ export const TERMINAL_SHORT_WINDOW_FRESHNESS_MS = 5 * 60_000;
22
22
  */
23
23
  export const CODEX_EXHAUSTED_USAGE_PERCENT = 100;
24
24
 
25
+ /** Credits have a separate clock: ordinary usage headers do not re-observe the balance. */
26
+ export const CODEX_CREDITS_FRESHNESS_MS = 5 * 60_000;
27
+
28
+ export interface CodexSpendableCredits {
29
+ hasCredits?: boolean;
30
+ unlimited?: boolean;
31
+ balance?: number;
32
+ overageLimitReached?: boolean;
33
+ allowed?: boolean;
34
+ observedAt: number;
35
+ }
36
+
37
+ export function hasSpendableCodexCredits(
38
+ quota: Pick<StoredAccountQuota, "credits"> | null,
39
+ now = Date.now(),
40
+ ): boolean {
41
+ const credits = quota?.credits;
42
+ if (!credits || !Number.isFinite(credits.observedAt)) return false;
43
+ const age = now - credits.observedAt;
44
+ if (age < 0 || age > CODEX_CREDITS_FRESHNESS_MS
45
+ || credits.allowed === false || credits.overageLimitReached === true || credits.hasCredits === false) return false;
46
+ return credits.unlimited === true || (credits.hasCredits === true
47
+ && typeof credits.balance === "number" && Number.isFinite(credits.balance) && credits.balance > 0);
48
+ }
49
+
25
50
  /**
26
51
  * Above this a value is already milliseconds; at or below it, it is Unix seconds.
27
52
  *
@@ -80,6 +105,8 @@ export type StoredAccountQuota = {
80
105
  shortWindowSeconds?: number;
81
106
  customWindows?: Array<{ label: string; percent: number; resetAt?: number }>;
82
107
  resetCredits?: number;
108
+ /** Spendable usage credits; resetCredits are separate, manually redeemed reset tickets. */
109
+ credits?: CodexSpendableCredits | null;
83
110
  /** Monthly usage came from an explicitly monthly PRIMARY, not supplementary tertiary, window. */
84
111
  monthlyIsPrimaryWindow?: boolean;
85
112
  updatedAt: number;
@@ -114,7 +141,12 @@ export type WhamUsageResponse = {
114
141
  secondary_window?: WhamUsageWindow | null;
115
142
  tertiary_window?: WhamUsageWindow | null;
116
143
  };
117
- credits?: unknown;
144
+ credits?: {
145
+ has_credits?: unknown;
146
+ unlimited?: unknown;
147
+ balance?: unknown;
148
+ overage_limit_reached?: unknown;
149
+ } | null;
118
150
  rate_limit_reset_credits?: { available_count: number } | null;
119
151
  additional_rate_limits?: WhamAdditionalRateLimit[] | null;
120
152
  };
@@ -1,3 +1,4 @@
1
+ import { parseCodexCredits } from "./credits";
1
2
  import { forgetMainAccountUsage, observeMainAccountUsage, type FreshWindow } from "./main-account-external-usage";
2
3
  import { closeSync, constants as fsConstants, existsSync, fstatSync, openSync, readSync, unlinkSync } from "node:fs";
3
4
  import { join } from "node:path";
@@ -11,7 +12,7 @@ import { getObservedMainQuotaIdentityKey, isMainQuotaWriterLive, type MainQuotaW
11
12
 
12
13
  import { CodexQuotaHistory, QUOTA_HISTORY_LIMITS, type QuotaHistoryWindow } from "./quota-history";
13
14
  import { isPoolQuotaWriterLive, poolQuotaHistoryIdentity } from "./account-store";
14
- import { CODEX_EXHAUSTED_USAGE_PERCENT, MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT, resetAtToMs } from "./quota-types";
15
+ import { CODEX_EXHAUSTED_USAGE_PERCENT, MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT, hasSpendableCodexCredits, resetAtToMs } from "./quota-types";
15
16
  import type { PoolQuotaWriter, StoredAccountQuota, WhamUsageResponse, WhamUsageWindow } from "./quota-types";
16
17
 
17
18
  export type { StoredAccountQuota, WhamUsageResponse } from "./quota-types";
@@ -83,10 +84,12 @@ function mayCommitAccountQuota(accountId: string, writerGeneration: number): boo
83
84
  // actually exhausted account is still eligible for threshold rotation.
84
85
  export const CODEX_UNKNOWN_USAGE_SCORE = 101;
85
86
  export function isCodexQuotaExhausted(
86
- quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "shortPercent"> | null,
87
+ quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "shortPercent" | "credits"> | null,
87
88
  plan?: unknown,
89
+ allowCredits = false,
88
90
  ): boolean {
89
91
  if (!quota) return false;
92
+ if (allowCredits && hasSpendableCodexCredits(quota)) return false;
90
93
  // The burst window counts on EVERY plan. It is upstream-enforced independently, so an
91
94
  // account at 100% there is blocked regardless of which longer window governs its plan;
92
95
  // omitting it would route traffic straight into a 429 (#1791).
@@ -118,10 +121,11 @@ export function codexQuotaWindowForPlan(plan?: unknown): "monthly" | "weekly" {
118
121
  }
119
122
 
120
123
  export function isCompleteCodexQuotaRecoverySnapshot(
121
- quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "monthlyIsPrimaryWindow" | "shortPercent"> | null,
124
+ quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "monthlyIsPrimaryWindow" | "shortPercent" | "credits"> | null,
122
125
  plan?: unknown,
126
+ allowCredits = false,
123
127
  ): boolean {
124
- if (!quota || isCodexQuotaExhausted(quota, plan)) return false;
128
+ if (!quota || isCodexQuotaExhausted(quota, plan, allowCredits)) return false;
125
129
  // Recovery still fails closed on MISSING EVIDENCE — a credits-only or windowless payload
126
130
  // carries no usage reading at all and must never clear a cooldown. What it does not do is
127
131
  // fail closed on an unfamiliar plan NAME, which only ever meant "cooled forever".
@@ -277,6 +281,10 @@ function snapshotHasCustom(quota: Omit<StoredAccountQuota, "updatedAt">): boolea
277
281
  function snapshotHasUsage(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
278
282
  return snapshotHasWeekly(quota) || snapshotHasMonthly(quota) || snapshotHasShort(quota) || snapshotHasCustom(quota);
279
283
  }
284
+
285
+ function snapshotHasCredits(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
286
+ return quota.resetCredits !== undefined || quota.credits !== undefined;
287
+ }
280
288
  /**
281
289
  * Publish parsed display quota and separately validated policy evidence after writer checks.
282
290
  * A null policy observation retains only the matching main identity's previous evidence;
@@ -296,7 +304,7 @@ export function setAccountQuotaFromParsed(
296
304
  ): void {
297
305
  quota = withoutRetiredCodexQuota(quota);
298
306
  policyQuota = withoutRetiredCodexQuota(policyQuota);
299
- if (!quota || (!snapshotHasUsage(quota) && quota.resetCredits === undefined)) return;
307
+ if (!quota || (!snapshotHasUsage(quota) && !snapshotHasCredits(quota))) return;
300
308
  if (!mayCommitAccountQuota(accountId, writerGeneration)) return;
301
309
  const isMain = accountId === MAIN_CODEX_ACCOUNT_ID;
302
310
  if (isMain && mainWriter && !isMainQuotaWriterLive(mainWriter)) return;
@@ -343,7 +351,7 @@ export function setAccountQuotaFromParsed(
343
351
  }
344
352
  schedulePersistAccountQuotas();
345
353
  // Credits carry the previous usage tuple; they must not refresh its observation clock.
346
- if (!(quota.resetCredits !== undefined && !snapshotHasUsage(quota))) {
354
+ if (!(snapshotHasCredits(quota) && !snapshotHasUsage(quota))) {
347
355
  if (!isMain && policyQuota && livePoolEvidence) observeCodexLowQuota(accountId, policyQuota);
348
356
  notifyCodexQuotaSnapshot(accountId, next);
349
357
  }
@@ -363,7 +371,11 @@ function mergeAccountQuota(
363
371
  quota = withoutRetiredCodexQuota(quota) ?? {};
364
372
  existing = withoutRetiredCodexQuota(existing ?? null) ?? undefined;
365
373
  const next: StoredAccountQuota = { updatedAt };
366
- const creditsOnly = quota.resetCredits !== undefined && !snapshotHasUsage(quota);
374
+ const creditsOnly = snapshotHasCredits(quota) && !snapshotHasUsage(quota);
375
+ // An explicit empty/invalid credits summary retracts the old balance. Partial headers
376
+ // retain the original observation clock instead of extending its spending authority.
377
+ if (quota.credits !== undefined) next.credits = quota.credits;
378
+ else if (existing?.credits !== undefined) next.credits = existing.credits;
367
379
 
368
380
  if (creditsOnly) {
369
381
  if (existing?.weeklyPercent !== undefined) next.weeklyPercent = existing.weeklyPercent;
@@ -373,7 +385,8 @@ function mergeAccountQuota(
373
385
  if (existing?.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true;
374
386
  assignCarriedShort(next, existing, updatedAt, policyEvidence);
375
387
  if (existing?.customWindows !== undefined) next.customWindows = existing.customWindows;
376
- next.resetCredits = quota.resetCredits;
388
+ if (quota.resetCredits !== undefined) next.resetCredits = quota.resetCredits;
389
+ else if (existing?.resetCredits !== undefined) next.resetCredits = existing.resetCredits;
377
390
  return stampCodexQuotaUsageObservation(next, quota, existing);
378
391
  }
379
392
 
@@ -525,7 +538,7 @@ export function withoutRetiredCodexQuota<T extends Omit<StoredAccountQuota, "upd
525
538
  const next = { ...quota };
526
539
  if (kept.length > 0) next.customWindows = kept;
527
540
  else delete next.customWindows;
528
- return snapshotHasUsage(next) || next.resetCredits !== undefined ? next as T : null;
541
+ return snapshotHasUsage(next) || snapshotHasCredits(next) ? next as T : null;
529
542
  }
530
543
 
531
544
  export function parseUpstreamQuotaHeaders(headers: Headers, options?: { modelId?: string }): Omit<StoredAccountQuota, "updatedAt"> | null {
@@ -644,6 +657,7 @@ export function updateAccountQuota(
644
657
  ...(existing?.monthlyResetAt !== undefined ? { monthlyResetAt: existing.monthlyResetAt } : {}),
645
658
  ...(existing?.customWindows !== undefined ? { customWindows: existing.customWindows } : {}),
646
659
  ...(existing?.resetCredits !== undefined ? { resetCredits: existing.resetCredits } : {}),
660
+ ...(existing?.credits !== undefined ? { credits: existing.credits } : {}),
647
661
  updatedAt: Date.now(),
648
662
  };
649
663
  assignCarriedShort(quota, existing, quota.updatedAt);
@@ -702,6 +716,20 @@ function readMainPolicyQuota(value: unknown): MainPolicyQuota | null {
702
716
  if (typeof number === "number" && Number.isFinite(number) && number >= 0) quota[field] = number;
703
717
  }
704
718
  if (quota.monthlyPercent !== undefined && raw.monthlyIsPrimaryWindow === true) quota.monthlyIsPrimaryWindow = true;
719
+ // Retain the independent credit timestamp on restart; it still expires at its original deadline.
720
+ if (raw.credits === null) quota.credits = null;
721
+ else if (raw.credits && typeof raw.credits === "object" && !Array.isArray(raw.credits)) {
722
+ const stored = raw.credits as Record<string, unknown>;
723
+ if (typeof stored.observedAt === "number" && Number.isFinite(stored.observedAt) && stored.observedAt >= 0) {
724
+ quota.credits = { observedAt: stored.observedAt };
725
+ for (const key of ["hasCredits", "unlimited", "overageLimitReached", "allowed"] as const) {
726
+ if (typeof stored[key] === "boolean") quota.credits[key] = stored[key];
727
+ }
728
+ if (typeof stored.balance === "number" && Number.isFinite(stored.balance) && stored.balance >= 0) {
729
+ quota.credits.balance = stored.balance;
730
+ }
731
+ }
732
+ }
705
733
  return { identityKey: entry.identityKey, quota };
706
734
  }
707
735
 
@@ -845,7 +873,7 @@ function filterMainPolicyMonthlyQuota(
845
873
  delete filtered.monthlyResetAt;
846
874
  delete filtered.monthlyIsPrimaryWindow;
847
875
  // Null retains the matching prior observation; an empty object would merge away evidence.
848
- return hasKnownQuotaValue(filtered) || filtered.resetCredits !== undefined ? filtered : null;
876
+ return hasKnownQuotaValue(filtered) || snapshotHasCredits(filtered) ? filtered : null;
849
877
  }
850
878
 
851
879
  /**
@@ -886,11 +914,24 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
886
914
  ? data.rate_limit_reset_credits.available_count
887
915
  : undefined;
888
916
 
889
- if (!data.rate_limit) {
890
- return resetCredits !== undefined ? { resetCredits } : null;
891
- }
892
-
893
917
  const quota: Omit<StoredAccountQuota, "updatedAt"> = {};
918
+ if (resetCredits !== undefined) quota.resetCredits = resetCredits;
919
+ if (data.credits !== undefined) {
920
+ quota.credits = null;
921
+ const parsed = parseCodexCredits(data.credits);
922
+ if (parsed) {
923
+ const balance = parsed.balance === undefined ? undefined : Number(parsed.balance);
924
+ quota.credits = {
925
+ observedAt: Date.now(),
926
+ ...(parsed.hasCredits !== undefined ? { hasCredits: parsed.hasCredits } : {}),
927
+ ...(parsed.unlimited !== undefined ? { unlimited: parsed.unlimited } : {}),
928
+ ...(parsed.overageLimitReached !== undefined ? { overageLimitReached: parsed.overageLimitReached } : {}),
929
+ ...(typeof data.rate_limit?.allowed === "boolean" ? { allowed: data.rate_limit.allowed } : {}),
930
+ ...(balance !== undefined && Number.isFinite(balance) ? { balance } : {}),
931
+ };
932
+ }
933
+ } else if (data.rate_limit?.allowed === false) quota.credits = null;
934
+ if (!data.rate_limit) return snapshotHasCredits(quota) ? quota : null;
894
935
  const thirtyDayOnly = codexQuotaWindowForPlan(data.plan_type) === "monthly";
895
936
  const primaryWindow = data.rate_limit.primary_window;
896
937
  const secondaryWindow = data.rate_limit.secondary_window;
@@ -951,7 +992,7 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
951
992
 
952
993
  if (resetCredits !== undefined) quota.resetCredits = resetCredits;
953
994
 
954
- return hasKnownQuotaValue(quota) || resetCredits !== undefined ? quota : null;
995
+ return hasKnownQuotaValue(quota) || snapshotHasCredits(quota) ? quota : null;
955
996
  }
956
997
 
957
998
 
@@ -3,7 +3,7 @@ import {
3
3
  CODEX_UNKNOWN_USAGE_SCORE,
4
4
  } from "../quota";
5
5
  import { isThirtyDayOnlyCodexPlan } from "../plan";
6
- import { isTerminalShortWindow } from "../quota-types";
6
+ import { hasSpendableCodexCredits, isTerminalShortWindow, type CodexSpendableCredits } from "../quota-types";
7
7
  import type { CodexQuotaScope } from "./health-store";
8
8
  import type { TransientProbeGrant } from "./thread-affinity";
9
9
 
@@ -107,7 +107,8 @@ export function computeCodexUsageScore(quota: {
107
107
  shortPercent?: number;
108
108
  shortResetAt?: number;
109
109
  shortObservedAt?: number;
110
- } | null, plan?: unknown, now: number = Date.now()): number {
110
+ credits?: CodexSpendableCredits | null;
111
+ } | null, plan?: unknown, now: number = Date.now(), allowCredits = false): number {
111
112
  if (!quota) return CODEX_UNKNOWN_USAGE_SCORE;
112
113
  const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
113
114
  const longWindows = isThirtyDayOnlyCodexPlan(plan)
@@ -126,10 +127,15 @@ export function computeCodexUsageScore(quota: {
126
127
  // correct for uncertainty and wrong for a measured refusal: the account stays selected,
127
128
  // `applyQuotaAutoSwitch` never fires, and the pool wedges on an exhausted credential.
128
129
  if (knownLong.length === 0) {
129
- return isTerminalShortWindow(quota, now) ? CODEX_EXHAUSTED_USAGE_PERCENT : CODEX_UNKNOWN_USAGE_SCORE;
130
+ return isTerminalShortWindow(quota, now)
131
+ ? (allowCredits && hasSpendableCodexCredits(quota, now)) ? CODEX_EXHAUSTED_USAGE_PERCENT - 1 : CODEX_EXHAUSTED_USAGE_PERCENT
132
+ : CODEX_UNKNOWN_USAGE_SCORE;
130
133
  }
131
134
  const values = finite(quota.shortPercent) ? [...knownLong, quota.shortPercent] : knownLong;
132
- return Math.max(...values);
135
+ const score = Math.max(...values);
136
+ // Keep included-capacity accounts ahead of a paid overage account, but do not
137
+ // classify the latter as exhausted solely from its subscription percentages.
138
+ return (allowCredits && hasSpendableCodexCredits(quota, now)) ? Math.min(score, CODEX_EXHAUSTED_USAGE_PERCENT - 1) : score;
133
139
  }
134
140
 
135
141
  // `isTerminalShortWindow` moved to ../quota-types, the leaf the dashboard can import. Routing
@@ -1,5 +1,5 @@
1
1
  import { getEffectiveCodexAutoSwitchThreshold } from "../account-auto-switch";
2
- import { isCodexAccountHeldForCredits } from "../account-credit-use";
2
+ import { codexAccountUsesCreditsAfterLimit, isCodexAccountHeldForCredits } from "../account-credit-use";
3
3
  import { isCodexAccountPaused } from "../account-pause";
4
4
  import { codexAccountPriorityLookup, pinnedCodexAccountId } from "../account-priority";
5
5
  import { isSelectableCodexPoolAccount } from "../account-id";
@@ -16,7 +16,7 @@ import {
16
16
  } from "../pool-rotation";
17
17
  import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota, resetAtToMs } from "../quota";
18
18
  import { codexPlanKey } from "../plan";
19
- import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan, hasMainAccountRefreshGrant } from "../main-account";
19
+ import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "../main-account";
20
20
  import type { OcxConfig } from "../../types";
21
21
  import { CODEX_FAILURE_WINDOW_MS, computeCodexUsageScore } from "./cooldown-math";
22
22
  import {
@@ -220,7 +220,6 @@ export function getEligiblePoolAccounts(
220
220
  if (
221
221
  excludeId !== MAIN_CODEX_ACCOUNT_ID
222
222
  && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
223
- && (!isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID) || hasMainAccountRefreshGrant())
224
223
  && getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null
225
224
  && !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now)
226
225
  // The main login is not in `config.codexAccounts`, so it never passes through the
@@ -293,6 +292,7 @@ export function hasCodexQuotaHeadroom(
293
292
  getAccountQuota(accountId),
294
293
  getPoolAccountPlanForSelection(config, accountId, selectionOptions),
295
294
  now,
295
+ codexAccountUsesCreditsAfterLimit(config, accountId),
296
296
  );
297
297
  if (isUnknownUsage(usage)) return true;
298
298
  return usage < threshold;
@@ -344,6 +344,7 @@ export function hasCodexSharedStateQuotaHeadroom(
344
344
  getAccountQuota(accountId),
345
345
  getPoolAccountPlanForSelection(config, accountId, selectionOptions),
346
346
  now,
347
+ codexAccountUsesCreditsAfterLimit(config, accountId),
347
348
  );
348
349
  return isUnknownUsage(usage) || usage < 100;
349
350
  }
@@ -537,7 +538,7 @@ export function sharedStateSelectionOptions(
537
538
  selectionOptions?: CodexAccountUsabilityOptions,
538
539
  ): Pick<
539
540
  CodexAccountUsabilityOptions,
540
- "nativeMainSelectionOnly" | "isMainAccountTokenLive"
541
+ "nativeMainSelectionOnly" | "isMainAccountTokenLive" | "requestOwnedMainCredential"
541
542
  > | undefined {
542
543
  if (!selectionOptions) return undefined;
543
544
  return {
@@ -547,6 +548,9 @@ export function sharedStateSelectionOptions(
547
548
  ...(selectionOptions.isMainAccountTokenLive
548
549
  ? { isMainAccountTokenLive: selectionOptions.isMainAccountTokenLive }
549
550
  : {}),
551
+ ...(selectionOptions.requestOwnedMainCredential !== undefined
552
+ ? { requestOwnedMainCredential: selectionOptions.requestOwnedMainCredential }
553
+ : {}),
550
554
  };
551
555
  }
552
556
 
@@ -585,6 +589,7 @@ export function pickLowerUsageAccount(
585
589
  getAccountQuota(id),
586
590
  getPoolAccountPlanForSelection(config, id, selectionOptions),
587
591
  now,
592
+ codexAccountUsesCreditsAfterLimit(config, id),
588
593
  );
589
594
  if (usage < bestUsage) {
590
595
  best = id;
@@ -608,6 +613,7 @@ export function pickLowestUsageAmong(
608
613
  getAccountQuota(id),
609
614
  getPoolAccountPlanForSelection(config, id, selectionOptions),
610
615
  now,
616
+ codexAccountUsesCreditsAfterLimit(config, id),
611
617
  );
612
618
  if (usage < bestUsage) {
613
619
  best = id;
@@ -785,6 +791,7 @@ export function applyQuotaAutoSwitch(
785
791
  quota,
786
792
  getPoolAccountPlanForSelection(config, active, selectionOptions),
787
793
  now,
794
+ codexAccountUsesCreditsAfterLimit(config, active),
788
795
  );
789
796
  // Unknown usage is not evidence that a user's explicit selection crossed the
790
797
  // threshold. Wait for quota priming instead of rotating among guesses.