opencode-auth-load-balancer 0.9.1 → 0.10.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.
package/README.md CHANGED
@@ -16,6 +16,7 @@ Selection is **not** round-robin. It is weighted primarily by **weekly** usage,
16
16
  - **Self-updating Claude Code version** — Anthropic gates new models on the client version the request claims, so a pinned version stops working the day a model ships (`Claude Code 2.1.87 does not support this model; version 2.1.251 or newer is required`). The plugin discovers the current version from npm instead, caches it for a day, and never regresses below a version known to work. See [Claude Code version](#claude-code-version).
17
17
  - **Session affinity** — a conversation stays pinned to one account so you keep its prompt cache and don't re-send context on every turn.
18
18
  - **Proactive migration** — leaves an account at a configurable soft threshold (~95%) instead of waiting for a hard 100% wall (which can break in-flight subagents).
19
+ - **Codex credits** — a Codex account whose plan usage is exhausted keeps serving on its ChatGPT credits, as the Codex CLI and desktop app do. Credits cost money, so plan quota on any account is spent first, and a session running on credits moves back as soon as an account has plan headroom again. The dashboards show what is left beside the exhausted state.
19
20
  - **Single-use refresh-token safety** — per-account singleflight refresh; rotated tokens are persisted immediately.
20
21
  - **Visibility** — toasts for account switches, model fallback, pending/recovery, and restart restoration; an on-demand `auth_lb_status` tool; and a `bun run status` CLI dashboard.
21
22
  - **Gist sync of credentials and OAuth logins** — machines share their Claude tokens, Kimi API keys and (unless switched off) their Claude / Codex / Kimi OAuth logins, encrypted, through a secret GitHub gist, both ways: every machine that follows the link downloads, and one with a GitHub token also uploads its own. See [Sync credentials and OAuth logins between machines](#sync-credentials-and-oauth-logins-between-machines-github-gist).
@@ -287,6 +288,8 @@ Pending turns
287
288
 
288
289
  `▶` marks the in-use account; `#` is the rank the scheduler would pick next.
289
290
 
291
+ An exhausted Codex account that still has ChatGPT credits stays selectable, ranked after every account with plan headroom, and its state shows the balance: `ready · credits 42.5` here, `full · credits 42.5` in the TUI sidebar. `credits unlimited` and `credits available` (no amount reported) are usable too; `no credits` means the account waits for its plan to reset. Balances come from the `/wham/usage` endpoint and from the `x-codex-credits-*` headers of every Codex response. A turn waiting for quota rechecks at least every five minutes, so credits that appear meanwhile (reported for the first time, or bought) resume it without waiting for the plan reset.
292
+
290
293
  While OpenCode is running, stale usage is checked every five minutes even without inference traffic or a status-tool call. Periodic checks share a machine-wide lock and a next-attempt timestamp in `auth-load-balancer.json.usage-poll.json`, so multiple windows do not multiply periodic requests, including failed attempts. The file contains scheduling metadata only, never credentials.
291
294
 
292
295
  A fresh usage measurement during re-login or a periodic check clears an obsolete, long quota cooldown when both usage windows have headroom. A successful re-login alone is not enough: exhausted or malformed usage, authentication/transient backoffs, and a newer rejection received during a usage check retain their cooldowns. Provider-side resets are picked up on the next successful check; a failed endpoint request never assumes recovery.
@@ -343,7 +346,7 @@ All knobs are environment variables with sane defaults.
343
346
  | `OPENCODE_AUTH_LB_HOURLY_INFLUENCE` | `0.5` | How much 5h headroom modulates the weekly-urgency score (0–1). |
344
347
  | `OPENCODE_AUTH_LB_MIN_RESET_MS` | `300000` | Floor on time-to-reset (caps urgency near a reset). |
345
348
  | `OPENCODE_AUTH_LB_WEEK_WINDOW_MS` | `604800000` | Baseline horizon used when a weekly reset time is unknown. |
346
- | `OPENCODE_AUTH_LB_EXHAUSTED_AT` | `0.999` | Hard exhaustion: at/above this utilization an account is excluded. |
349
+ | `OPENCODE_AUTH_LB_EXHAUSTED_AT` | `0.999` | Hard exhaustion: at/above this utilization an account is excluded, unless Codex credits keep it serving. |
347
350
  | `OPENCODE_AUTH_LB_MIGRATE_AT` | `0.95` | Soft threshold to proactively leave a pinned account (before 100%). |
348
351
  | `OPENCODE_AUTH_LB_WEEKLY_DRAIN_TARGET` | `0.98` | Soft threshold for the WEEKLY window: scoring treats weekly quota as "fully drained" past this utilization, and a pinned session proactively migrates once its weekly util crosses it. The 5h window uses `MIGRATE_AT` (~0.95); the weekly window uses this (~0.98). Must be in (`MIGRATE_AT`, `EXHAUSTED_AT`]. |
349
352
  | `OPENCODE_AUTH_LB_CHEAP_SWITCH_MAX_BYTES` | `65536` | For non-forced (proactive/drain) switches, only switch when the request body ≤ this many bytes, so a grown conversation isn't re-sent onto a fresh (uncached) account — a full per-account prompt-cache write. `0` disables the gate (always switch). A proactive switch bypasses this gate when the pinned account is within ~1% of hard exhaustion (a forced switch is imminent anyway and the context only grows); forced switches always ignore it. |
package/dist/index.js CHANGED
@@ -13054,6 +13054,18 @@ function normalizeWindow(w) {
13054
13054
  win.resetAt = 0;
13055
13055
  return win;
13056
13056
  }
13057
+ function normalizeCredits(c) {
13058
+ if (!isPlainObject2(c))
13059
+ return;
13060
+ const credits = c;
13061
+ if (typeof credits.hasCredits !== "boolean" || typeof credits.unlimited !== "boolean")
13062
+ return;
13063
+ if (!isFiniteNumber(credits.balance))
13064
+ delete credits.balance;
13065
+ if (credits.limitReached !== true)
13066
+ delete credits.limitReached;
13067
+ return credits;
13068
+ }
13057
13069
  function normalizeAccounts(rows) {
13058
13070
  let accounts = null;
13059
13071
  for (let i = 0;i < rows.length; i++) {
@@ -13073,7 +13085,13 @@ function normalizeAccounts(rows) {
13073
13085
  row.usage.capturedAt = 0;
13074
13086
  row.usage.hourly = normalizeWindow(row.usage.hourly);
13075
13087
  row.usage.weekly = normalizeWindow(row.usage.weekly);
13088
+ delete row.usage.credits;
13076
13089
  }
13090
+ const credits = normalizeCredits(row.credits);
13091
+ if (credits)
13092
+ row.credits = credits;
13093
+ else
13094
+ delete row.credits;
13077
13095
  if (!Number.isFinite(row.cooldownUntil))
13078
13096
  row.cooldownUntil = 0;
13079
13097
  if (row.cooldownKind !== undefined && row.cooldownKind !== "quota" && row.cooldownKind !== "auth" && row.cooldownKind !== "transient")
@@ -13326,14 +13344,19 @@ function maxUtil(account, now) {
13326
13344
  function isExhausted(account, cfg, now) {
13327
13345
  return maxUtil(account, now) >= cfg.exhaustedAt;
13328
13346
  }
13347
+ function hasCreditHeadroom(account) {
13348
+ const credits = account.credits;
13349
+ return !!credits && !credits.limitReached && (credits.unlimited || credits.hasCredits);
13350
+ }
13351
+ function hasHeadroom(account, cfg, now) {
13352
+ return !isExhausted(account, cfg, now) || hasCreditHeadroom(account);
13353
+ }
13329
13354
  function isAvailable(account, cfg, now) {
13330
13355
  if (account.disabledReason)
13331
13356
  return false;
13332
13357
  if (account.cooldownUntil > now)
13333
13358
  return false;
13334
- if (isExhausted(account, cfg, now))
13335
- return false;
13336
- return true;
13359
+ return hasHeadroom(account, cfg, now);
13337
13360
  }
13338
13361
  function weeklyUrgency(account, cfg, now) {
13339
13362
  const weekly = account.usage.weekly;
@@ -13364,9 +13387,11 @@ function scoreAccount(account, cfg, now) {
13364
13387
  function compareRanked(x, y) {
13365
13388
  if (x.available !== y.available)
13366
13389
  return x.available ? -1 : 1;
13367
- if (x.available)
13368
- return y.score - x.score;
13369
- return x.weeklyUtil - y.weeklyUtil;
13390
+ if (!x.available)
13391
+ return x.weeklyUtil - y.weeklyUtil;
13392
+ if (x.onCredits !== y.onCredits)
13393
+ return x.onCredits ? 1 : -1;
13394
+ return y.score - x.score;
13370
13395
  }
13371
13396
  var DAY_MS, WEEK_MS2, HOUR_MS, HOURLY_WINDOW_MS, RESET_CUSHION_DAYS = 0.25, SCORE_DEFAULTS;
13372
13397
  var init_score_core = __esm(() => {
@@ -13388,7 +13413,7 @@ var init_score_core = __esm(() => {
13388
13413
  function reconcileQuotaCooldown(account, cfg, now) {
13389
13414
  if (account.cooldownKind === "auth" || account.cooldownKind === "transient")
13390
13415
  return;
13391
- if (account.cooldownUntil > now + MAX_TRANSIENT_COOLDOWN_MS && !isExhausted(account, cfg, now)) {
13416
+ if (account.cooldownUntil > now + MAX_TRANSIENT_COOLDOWN_MS && hasHeadroom(account, cfg, now)) {
13392
13417
  account.cooldownUntil = 0;
13393
13418
  delete account.cooldownKind;
13394
13419
  }
@@ -13459,6 +13484,8 @@ function clearLostLogin(row, login) {
13459
13484
  function mergeLoginUsage(row, usage) {
13460
13485
  if (!usage)
13461
13486
  return;
13487
+ if (usage.credits)
13488
+ row.credits = usage.credits;
13462
13489
  row.usage = {
13463
13490
  hourly: usage.hourly ?? row.usage.hourly,
13464
13491
  weekly: usage.weekly ? preserveWeeklyAnchor(usage.weekly, row.usage.weekly, usage.capturedAt) : row.usage.weekly,
@@ -13584,7 +13611,8 @@ function classifyProviderRecovery(accounts, providerID, cfg, now, pollMs) {
13584
13611
  continue;
13585
13612
  const windows = exhaustedWindows(account, cfg, now);
13586
13613
  const cooldownActive = account.cooldownUntil > now;
13587
- if (!cooldownActive && windows.length === 0)
13614
+ const blocking = hasCreditHeadroom(account) ? [] : windows;
13615
+ if (!cooldownActive && blocking.length === 0)
13588
13616
  return { state: "available" };
13589
13617
  if (cooldownActive && (account.cooldownKind === "auth" || account.cooldownKind === "transient"))
13590
13618
  continue;
@@ -13593,7 +13621,7 @@ function classifyProviderRecovery(accounts, providerID, cfg, now, pollMs) {
13593
13621
  hasQuotaCandidate = true;
13594
13622
  const constraints = [];
13595
13623
  let accountHasUnknownReset = false;
13596
- for (const window of windows) {
13624
+ for (const window of blocking) {
13597
13625
  if (window.resetAt > now)
13598
13626
  constraints.push(window.resetAt);
13599
13627
  else
@@ -13906,22 +13934,27 @@ function weeklyUtil(account) {
13906
13934
  function selectAccount(accounts, providerID, now, cfg = DEFAULT_CONFIG, exclude = NO_EXCLUSIONS, incumbentId = null) {
13907
13935
  let bestAvail = null;
13908
13936
  let bestAvailScore = Number.NEGATIVE_INFINITY;
13937
+ let bestAvailOnCredits = true;
13909
13938
  let bestFallback = null;
13910
13939
  let lowestWeeklyUtil = Number.POSITIVE_INFINITY;
13911
13940
  let incumbentAccount = null;
13912
13941
  let incumbentScore = Number.NEGATIVE_INFINITY;
13942
+ let incumbentOnCredits = false;
13913
13943
  for (const account of accounts) {
13914
13944
  if (account.providerID !== providerID || account.disabledReason || exclude.has(account.id))
13915
13945
  continue;
13916
13946
  if (isAvailable(account, cfg, now)) {
13917
13947
  const score = scoreAccount(account, cfg, now);
13918
- if (score > bestAvailScore) {
13948
+ const onCredits = isExhausted(account, cfg, now);
13949
+ if (onCredits === bestAvailOnCredits ? score > bestAvailScore : bestAvailOnCredits) {
13919
13950
  bestAvailScore = score;
13920
13951
  bestAvail = account;
13952
+ bestAvailOnCredits = onCredits;
13921
13953
  }
13922
13954
  if (account.id === incumbentId) {
13923
13955
  incumbentAccount = account;
13924
13956
  incumbentScore = score;
13957
+ incumbentOnCredits = onCredits;
13925
13958
  }
13926
13959
  } else {
13927
13960
  const util = weeklyUtil(account);
@@ -13932,7 +13965,7 @@ function selectAccount(accounts, providerID, now, cfg = DEFAULT_CONFIG, exclude
13932
13965
  }
13933
13966
  }
13934
13967
  if (bestAvail) {
13935
- if (incumbentAccount && incumbentScore >= bestAvailScore * (1 - INCUMBENT_HYSTERESIS))
13968
+ if (incumbentAccount && incumbentOnCredits === bestAvailOnCredits && incumbentScore >= bestAvailScore * (1 - INCUMBENT_HYSTERESIS))
13936
13969
  return { account: incumbentAccount, degraded: false, sticky: false };
13937
13970
  return { account: bestAvail, degraded: false, sticky: false };
13938
13971
  }
@@ -13971,7 +14004,7 @@ function selectForSession(pool, providerID, sessionKey, now, cfg = DEFAULT_CONFI
13971
14004
  const cheap = (!pinnedImminent || cfg.drainMigrate) && isCheapMoment(typeof requestBytes === "function" ? requestBytes() : requestBytes, cfg);
13972
14005
  if (pinnedOverSoft && (pinnedImminent || cheap) || cfg.drainMigrate && cheap) {
13973
14006
  const alt = selectAccount(pool.accounts, providerID, now, cfg, withExcluded(exclude, pinned.id), null);
13974
- if (alt && !alt.degraded) {
14007
+ if (alt && !alt.degraded && !isExhausted(alt.account, cfg, now)) {
13975
14008
  if (pinnedOverSoft) {
13976
14009
  const altUtil = maxUtil(alt.account, now);
13977
14010
  const proactiveBetter = pinnedImminent ? altUtil < pinnedUtil : pinnedUtil - altUtil >= PROACTIVE_MIGRATE_MIN_DELTA && weeklyUrgency(alt.account, cfg, now) >= weeklyUrgency(pinned, cfg, now);
@@ -15569,10 +15602,31 @@ var init_transform2 = __esm(() => {
15569
15602
  function isWeeklyMinutes(minutes) {
15570
15603
  return minutes >= WEEK_MINUTES * 0.95 && minutes <= WEEK_MINUTES * 1.05;
15571
15604
  }
15605
+ function creditsFrom(hasCredits, unlimited, balance, limitReached) {
15606
+ if (typeof hasCredits !== "boolean" || typeof unlimited !== "boolean")
15607
+ return;
15608
+ const amount = typeof balance === "string" && balance.trim() !== "" ? Number(balance) : Number.NaN;
15609
+ return {
15610
+ hasCredits,
15611
+ unlimited,
15612
+ ...Number.isFinite(amount) ? { balance: amount } : {},
15613
+ ...limitReached === true ? { limitReached } : {}
15614
+ };
15615
+ }
15616
+ function headerBool(raw) {
15617
+ const value = raw?.toLowerCase();
15618
+ if (value === "true" || value === "1")
15619
+ return true;
15620
+ if (value === "false" || value === "0")
15621
+ return false;
15622
+ return;
15623
+ }
15572
15624
  function parseUsageHeaders2(headers) {
15573
15625
  const primaryWindowMinutes = Number(headers.get(PRIMARY_WINDOW_MINUTES_HEADER));
15574
15626
  const spec = isWeeklyMinutes(primaryWindowMinutes) ? WEEKLY_PRIMARY_HEADER_SPEC : HEADER_SPEC2;
15575
- return parseWindowPairHeaders(headers, spec);
15627
+ const windows = parseWindowPairHeaders(headers, spec);
15628
+ const credits = creditsFrom(headerBool(headers.get("x-codex-credits-has-credits")), headerBool(headers.get("x-codex-credits-unlimited")), headers.get("x-codex-credits-balance"));
15629
+ return credits ? { ...windows, credits } : windows;
15576
15630
  }
15577
15631
  function endpointWindow2(w) {
15578
15632
  return endpointWindowFrom(w, (win) => win.used_percent, 100, (win) => secondsToMs(Number(win.reset_at ?? 0)));
@@ -15592,10 +15646,13 @@ async function fetchUsage2(account, now) {
15592
15646
  return null;
15593
15647
  const { primary_window: primary, secondary_window: secondary } = rateLimit;
15594
15648
  const primaryIsWeekly = isWeeklyMinutes(Math.ceil(Number(primary?.limit_window_seconds) / 60));
15649
+ const reported = json?.credits;
15650
+ const credits = isPlainObject2(reported) ? creditsFrom(reported.has_credits, reported.unlimited, reported.balance, reported.overage_limit_reached) : undefined;
15595
15651
  return {
15596
15652
  hourly: endpointWindow2(primaryIsWeekly ? secondary : primary),
15597
15653
  weekly: endpointWindow2(primaryIsWeekly ? primary : secondary),
15598
- capturedAt: now
15654
+ capturedAt: now,
15655
+ ...credits ? { credits } : {}
15599
15656
  };
15600
15657
  }
15601
15658
  var WEEK_MINUTES, PRIMARY_WINDOW_MINUTES_HEADER = "x-codex-primary-window-minutes", HEADER_SPEC2, WEEKLY_PRIMARY_HEADER_SPEC;
@@ -15642,6 +15699,7 @@ var init_adapter3 = __esm(() => {
15642
15699
  },
15643
15700
  parseUsageHeaders: parseUsageHeaders2,
15644
15701
  fetchUsage: fetchUsage2,
15702
+ reportsCredits: true,
15645
15703
  classifyError: classifyHttpStatus
15646
15704
  };
15647
15705
  });
@@ -15675,11 +15733,14 @@ async function refreshUsage(adapters, now, poolSnapshot) {
15675
15733
  aliveIds?.add(account.id);
15676
15734
  if (account.disabledReason && account.disabledReason !== MANUAL_DISABLED_REASON)
15677
15735
  continue;
15678
- if (account.usage.capturedAt !== 0 && now - account.usage.capturedAt < USAGE_REFRESH_TTL_MS)
15736
+ const fresh = account.usage.capturedAt !== 0 && now - account.usage.capturedAt < USAGE_REFRESH_TTL_MS;
15737
+ if (fresh && account.credits)
15679
15738
  continue;
15680
15739
  const adapter = adapterFor(adapters, account.providerID);
15681
15740
  if (!adapter)
15682
15741
  continue;
15742
+ if (fresh && !adapter.reportsCredits)
15743
+ continue;
15683
15744
  if ((lastPoll.get(account.id) ?? 0) > now - USAGE_REFRESH_TTL_MS)
15684
15745
  continue;
15685
15746
  lastPoll.set(account.id, now);
@@ -15711,6 +15772,8 @@ async function refreshUsage(adapters, now, poolSnapshot) {
15711
15772
  await mutatePool((p) => {
15712
15773
  const stored = findAccount(p, account.id);
15713
15774
  if (stored) {
15775
+ if (snapshot.credits)
15776
+ stored.credits = snapshot.credits;
15714
15777
  stored.usage = {
15715
15778
  hourly: snapshot.hourly ?? stored.usage.hourly,
15716
15779
  weekly: snapshot.weekly === null ? stored.usage.weekly : preserveWeeklyAnchor(snapshot.weekly, stored.usage.weekly, now),
@@ -15763,6 +15826,8 @@ async function bestEffort(what, op) {
15763
15826
  function applyUsagePartial(account, partial, now) {
15764
15827
  if (partial.hourly !== undefined)
15765
15828
  account.usage.hourly = partial.hourly;
15829
+ if (partial.credits !== undefined)
15830
+ account.credits = partial.credits;
15766
15831
  if (partial.weekly !== undefined) {
15767
15832
  account.usage.weekly = preserveWeeklyAnchor(partial.weekly, account.usage.weekly, now);
15768
15833
  account.usage.capturedAt = now;
@@ -15831,7 +15896,7 @@ async function recordSuccess(adapter, accountId, partial, sessionKey, now, cfg,
15831
15896
  if (account) {
15832
15897
  if (partial)
15833
15898
  applyUsagePartial(account, partial, now);
15834
- if (partial && account.cooldownUntil > now + ACCOUNT_COOLDOWN_MS && !isExhausted(account, cfg, now)) {
15899
+ if (partial && account.cooldownUntil > now + ACCOUNT_COOLDOWN_MS && hasHeadroom(account, cfg, now)) {
15835
15900
  account.cooldownUntil = 0;
15836
15901
  delete account.cooldownKind;
15837
15902
  }
@@ -16175,7 +16240,18 @@ function byProviderId(a, b) {
16175
16240
  function isExpired(window, now) {
16176
16241
  return !!window && window.resetAt > 0 && window.resetAt <= now;
16177
16242
  }
16178
- function toStatus(account, now, current, available, rank) {
16243
+ function creditsText(credits) {
16244
+ if (!credits)
16245
+ return "";
16246
+ if (credits.limitReached)
16247
+ return "credit limit reached";
16248
+ if (credits.unlimited)
16249
+ return "credits unlimited";
16250
+ if (!credits.hasCredits)
16251
+ return "no credits";
16252
+ return credits.balance === undefined ? "credits available" : `credits ${Math.round(credits.balance * 100) / 100}`;
16253
+ }
16254
+ function toStatus(account, now, current, available, exhausted, rank) {
16179
16255
  const weekly = account.usage.weekly;
16180
16256
  const weeklyExpired = isExpired(weekly, now);
16181
16257
  return {
@@ -16191,6 +16267,7 @@ function toStatus(account, now, current, available, rank) {
16191
16267
  login: credentialTag(account),
16192
16268
  warnings: loginWarnings(account, now),
16193
16269
  lostNotes: lostLoginNotes(account, now),
16270
+ credits: exhausted ? creditsText(account.credits) : "",
16194
16271
  current,
16195
16272
  rank
16196
16273
  };
@@ -16209,14 +16286,17 @@ function buildStatus(pool, now, cfg = DEFAULT_CONFIG) {
16209
16286
  const currentAccountId = pool.lastSelected[providerID] ?? null;
16210
16287
  const ranked = providerAccounts.map((a) => {
16211
16288
  const available = isAvailable(a, cfg, now);
16289
+ const exhausted = isExhausted(a, cfg, now);
16212
16290
  return {
16213
16291
  a,
16214
16292
  available,
16293
+ exhausted,
16294
+ onCredits: available && exhausted,
16215
16295
  score: available ? scoreAccount(a, cfg, now) : Number.NEGATIVE_INFINITY,
16216
16296
  weeklyUtil: a.usage.weekly?.utilization ?? 0
16217
16297
  };
16218
16298
  }).sort(compareRanked);
16219
- const accounts = ranked.map(({ a, available }, i) => toStatus(a, now, a.id === currentAccountId, available, i + 1));
16299
+ const accounts = ranked.map(({ a, available, exhausted }, i) => toStatus(a, now, a.id === currentAccountId, available, exhausted, i + 1));
16220
16300
  return { providerID, accounts };
16221
16301
  });
16222
16302
  }
@@ -16256,12 +16336,10 @@ function stateOf(a, now) {
16256
16336
  if (a.cooldownUntil > now)
16257
16337
  return `cooldown ${relTime(a.cooldownUntil, now)}`;
16258
16338
  if (!a.available)
16259
- return "exhausted";
16339
+ return a.credits ? `exhausted · ${a.credits}` : "exhausted";
16260
16340
  const base = a.current ? "in use" : "ready";
16261
16341
  const tiers = Object.entries(a.modelCooldownsUntil).filter(([, until]) => until > now).sort(([x], [y]) => compareAscii(x, y)).map(([tier, until]) => `${tier} ${relTime(until, now)}`);
16262
- if (tiers.length > 0)
16263
- return `${base} · ${tiers.join(" · ")}`;
16264
- return base;
16342
+ return [base, a.credits, ...tiers].filter(Boolean).join(" · ");
16265
16343
  }
16266
16344
  function providerName(providerID) {
16267
16345
  return PROVIDER_NAMES[providerID] ?? providerID;
@@ -16737,7 +16815,7 @@ class PendingCoordinator {
16737
16815
  await this.notifyPending(ref, recovery);
16738
16816
  try {
16739
16817
  for (;; ) {
16740
- await this.dependencies.sleep(Math.max(0, recovery.nextCheckAt - this.dependencies.now()), controller.signal);
16818
+ await this.dependencies.sleep(Math.max(0, Math.min(recovery.nextCheckAt - this.dependencies.now(), USAGE_REFRESH_TTL_MS)), controller.signal);
16741
16819
  const now = this.dependencies.now();
16742
16820
  await this.dependencies.refreshUsage(now).catch(ignore);
16743
16821
  let next;
@@ -8,11 +8,13 @@ import type { PoolAccount, UsageSnapshot } from '../../types';
8
8
  * NaN, and every comparison against NaN is false. The parse itself (null-short-circuit,
9
9
  * "no capturedAt", "`{}` collapses to null") lives in the shared
10
10
  * `parseWindowPairHeaders` — only the header names, the percent scale, and
11
- * this role detection are Codex-specific.
11
+ * this role detection are Codex-specific. The credits headers
12
+ * (x-codex-credits-*) ride along, even on a response without windows.
12
13
  */
13
14
  export declare function parseUsageHeaders(headers: Headers): Partial<UsageSnapshot> | null;
14
15
  /**
15
- * Poll GET /wham/usage for authoritative 5h + weekly utilization without
16
+ * Poll GET /wham/usage for authoritative 5h + weekly utilization (and the
17
+ * credits spent past them) without
16
18
  * consuming inference quota, assigning each returned window to its role BY
17
19
  * DURATION (see `isWeeklyMinutes`) rather than by position. A 401 is surfaced
18
20
  * for bounded OAuth recovery; other failures return null.
@@ -151,6 +151,11 @@ export interface ProviderAdapter {
151
151
  parseUsageHeaders(headers: Headers): Partial<UsageSnapshot> | null;
152
152
  /** Poll a dedicated usage endpoint for authoritative quota, if one exists. */
153
153
  fetchUsage(account: PoolAccount, now: number): Promise<UsageSnapshot | null>;
154
+ /**
155
+ * The usage endpoint reports credits (Codex): a row whose credits are still
156
+ * unknown is polled even when its windows are fresh.
157
+ */
158
+ reportsCredits?: boolean;
154
159
  /** Classify an HTTP status for rotation decisions. */
155
160
  classifyError(status: number): ErrorClass;
156
161
  /**
@@ -45,6 +45,13 @@ export interface ScoreWindow {
45
45
  utilization: number;
46
46
  resetAt: number;
47
47
  }
48
+ /** Codex credits, which keep an account serving once its plan windows are exhausted. */
49
+ export interface ScoreCredits {
50
+ hasCredits: boolean;
51
+ unlimited: boolean;
52
+ /** The credit spending cap is hit: the balance cannot be spent. */
53
+ limitReached?: boolean;
54
+ }
48
55
  /**
49
56
  * The slice of an account the scoring reads. The server's full `PoolAccount` and the
50
57
  * TUI's parsed-pool account both satisfy this (the TUI normalizes its looser JSON into
@@ -55,6 +62,7 @@ export interface ScoreAccount {
55
62
  hourly: ScoreWindow | null;
56
63
  weekly: ScoreWindow | null;
57
64
  };
65
+ credits?: ScoreCredits;
58
66
  cooldownUntil: number;
59
67
  disabledReason: string | null;
60
68
  }
@@ -97,7 +105,15 @@ export declare function displayUtil(window: ScoreWindow | null, now: number): nu
97
105
  export declare function maxUtil(account: ScoreAccount, now: number): number;
98
106
  /** An account is exhausted when ANY still-live window is at/over the exhaustion threshold. */
99
107
  export declare function isExhausted(account: ScoreAccount, cfg: ScoreConfig, now: number): boolean;
100
- /** Available = not disabled, not in cooldown, and not exhausted. */
108
+ /**
109
+ * Whether credits keep the account serving past exhausted plan windows. Same test as
110
+ * Codex itself (`unlimited || has_credits`, the backend then bills the credits), unless
111
+ * the account's credit spending cap is hit.
112
+ */
113
+ export declare function hasCreditHeadroom(account: ScoreAccount): boolean;
114
+ /** Plan headroom, or credits to spend once the plan is exhausted. */
115
+ export declare function hasHeadroom(account: ScoreAccount, cfg: ScoreConfig, now: number): boolean;
116
+ /** Available = not disabled, not in cooldown, and with plan or credit headroom. */
101
117
  export declare function isAvailable(account: ScoreAccount, cfg: ScoreConfig, now: number): boolean;
102
118
  /**
103
119
  * Weekly "urgency" — how perishable an account's remaining weekly quota is. It favors
@@ -150,18 +166,21 @@ export declare function scoreAccount(account: ScoreAccount, cfg: ScoreConfig, no
150
166
  * Sort comparator shared by `src/status.ts` and the TUI sidebar ranking pipeline, so the
151
167
  * dashboard's *ordering* rule can never silently drift from what the server displays —
152
168
  * the same single-source guarantee `scoreAccount` provides for the *formula*. Both
153
- * pipelines map each account into `{ available, score, weeklyUtil }` (the per-pipeline
154
- * `weeklyUtil` field is computed locally — `src/status.ts` uses the raw stored value to
155
- * keep tie-breaking deterministic for cooling-down accounts, the TUI uses `utilOf` to
156
- * discard a window that already reset) and pass that shape here:
169
+ * pipelines map each account into `{ available, onCredits, score, weeklyUtil }` (the
170
+ * per-pipeline `weeklyUtil` field is computed locally — `src/status.ts` uses the raw
171
+ * stored value to keep tie-breaking deterministic for cooling-down accounts, the TUI uses
172
+ * `utilOf` to discard a window that already reset) and pass that shape here:
157
173
  *
158
174
  * - available accounts come first (they're the only ones the scheduler will pick);
159
- * - among available accounts, higher `score` wins (matches `selectAccount`'s pick);
175
+ * - among available accounts, plan headroom comes before serving on credits alone
176
+ * (`onCredits`: credits cost money), then higher `score` wins (matches
177
+ * `selectAccount`'s pick);
160
178
  * - among unavailable accounts, lower `weeklyUtil` wins — the "least bad cooling-down
161
179
  * account" surfaces first so a forced fall-through stays predictable.
162
180
  */
163
181
  export declare function compareRanked<T extends {
164
182
  available: boolean;
183
+ onCredits: boolean;
165
184
  score: number;
166
185
  weeklyUtil: number;
167
186
  }>(x: T, y: T): number;
@@ -13,10 +13,13 @@ export interface Selection {
13
13
  * 1. Restrict to the provider's non-disabled accounts, minus `exclude` (already
14
14
  * tried this request).
15
15
  * 2. Prefer the subset that is currently available (not exhausted / cooling down).
16
- * 3. Among those, pick the highest urgency score — but KEEP the incumbent
17
- * (`incumbentId`, the provider's current in-use account) when its score is
18
- * within `INCUMBENT_HYSTERESIS` of the best, so near-equal scores don't churn
19
- * the account and waste prompt cache.
16
+ * 3. Among those, prefer plan headroom over serving on credits alone (an
17
+ * exhausted Codex account whose credits keep it usable — credits cost money,
18
+ * plan quota is already paid for), then pick the highest urgency score — but
19
+ * KEEP the incumbent (`incumbentId`, the provider's current in-use account)
20
+ * when it is in that same group and its score is within
21
+ * `INCUMBENT_HYSTERESIS` of the best, so near-equal scores don't churn the
22
+ * account and waste prompt cache.
20
23
  * 4. If none are available, fall back to the least-bad account (lowest weekly
21
24
  * utilization) and flag the selection as `degraded`.
22
25
  *
package/dist/status.d.ts CHANGED
@@ -18,6 +18,8 @@ interface AccountStatus {
18
18
  warnings: string[];
19
19
  /** Why each lost login stopped working, printed under the table. */
20
20
  lostNotes: string[];
21
+ /** What credits leave once the plan is exhausted (`creditsText`); '' otherwise. */
22
+ credits: string;
21
23
  /** The account that most recently served a request for this provider. */
22
24
  current: boolean;
23
25
  /** 1 = next candidate the scheduler would pick. */
package/dist/types.d.ts CHANGED
@@ -9,6 +9,18 @@ export interface UsageWindow {
9
9
  utilization: number;
10
10
  resetAt: number;
11
11
  }
12
+ /**
13
+ * Codex credits: what a ChatGPT account spends once its plan's usage windows
14
+ * are exhausted. `balance` is the amount left, when the backend reports it;
15
+ * `limitReached` (`overage_limit_reached`) means the account's credit
16
+ * spending cap is hit, so the balance cannot be spent.
17
+ */
18
+ export interface UsageCredits {
19
+ hasCredits: boolean;
20
+ unlimited: boolean;
21
+ balance?: number;
22
+ limitReached?: boolean;
23
+ }
12
24
  /** Normalized usage snapshot for one account. */
13
25
  export interface UsageSnapshot {
14
26
  /** Short rolling window (~5h for both Anthropic and Codex). */
@@ -23,6 +35,11 @@ export interface UsageSnapshot {
23
35
  * snapshot fresh, or an out-of-band weekly reset is never picked up.
24
36
  */
25
37
  capturedAt: number;
38
+ /**
39
+ * Codex credits as just measured. Stored on the row (`PoolAccount.credits`),
40
+ * not here: versions before credits rebuild `usage` and would drop them.
41
+ */
42
+ credits?: UsageCredits;
26
43
  }
27
44
  export declare function emptyUsage(): UsageSnapshot;
28
45
  /** Result of an OAuth authorization-code exchange or refresh. */
@@ -142,6 +159,8 @@ export interface PoolAccount {
142
159
  */
143
160
  orgId?: string;
144
161
  usage: UsageSnapshot;
162
+ /** Codex credits, absent until the provider reports them. */
163
+ credits?: UsageCredits;
145
164
  /** epoch ms; the account is skipped until this time. 0 = no cooldown. */
146
165
  cooldownUntil: number;
147
166
  /** Why the active account-wide cooldown was written. Absent on legacy rows. */