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 +4 -1
- package/dist/index.js +101 -23
- package/dist/providers/openai/usage.d.ts +4 -2
- package/dist/providers/types.d.ts +5 -0
- package/dist/scheduler/score-core.d.ts +25 -6
- package/dist/scheduler/select.d.ts +7 -4
- package/dist/status.d.ts +2 -0
- package/dist/types.d.ts +19 -0
- package/dist/v1.js +101 -23
- package/dist/v2-entry.js +101 -23
- package/package.json +1 -1
- package/tui/auth-load-balancer-scoring.ts +50 -11
- package/tui/auth-load-balancer-tui.logic.ts +49 -1
- package/tui/auth-load-balancer-tui.view.tsx +7 -1
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
|
-
|
|
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
|
|
13369
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
154
|
-
* `weeklyUtil` field is computed locally — `src/status.ts` uses the raw
|
|
155
|
-
* keep tie-breaking deterministic for cooling-down accounts, the TUI uses
|
|
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,
|
|
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,
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* the
|
|
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. */
|