opencode-auth-load-balancer 0.9.0 → 0.9.1

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
@@ -287,6 +287,14 @@ Pending turns
287
287
 
288
288
  `▶` marks the in-use account; `#` is the rank the scheduler would pick next.
289
289
 
290
+ 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
+
292
+ 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.
293
+
294
+ If a usage endpoint rejects an OAuth access token with HTTP 401, the poll uses the same refresh lock and rejected-token recovery as inference, then retries the usage lookup once, even when the advertised access expiry is still in the future. A concurrently refreshed credential is adopted instead of spending its predecessor again. Static API keys are never sent to OAuth refresh, and 403/429 failures do not trigger authentication retries.
295
+
296
+ A 403 caused by a missing `user:profile` scope requires a fresh OAuth login with browser approval. Restarting OpenCode or refreshing the same grant cannot restore that missing permission; an authoritative usage result is still required before clearing the old quota cooldown.
297
+
290
298
  ### Persistent bottom status bar (TUI)
291
299
 
292
300
  A SolidJS TUI plugin renders a persistent bottom status bar (opencode's always-visible `app_bottom` slot) showing both the in-use account(s) per provider (polled from the pool, e.g. `Claude anthropic-1 58% · 5h 12%`) **and** the current session usage — tokens, % of the model context window, and `$` cost — computed the same way opencode's own footer does. It is **four files**:
package/dist/index.js CHANGED
@@ -13385,6 +13385,14 @@ var init_score_core = __esm(() => {
13385
13385
  });
13386
13386
 
13387
13387
  // src/usage-merge.ts
13388
+ function reconcileQuotaCooldown(account, cfg, now) {
13389
+ if (account.cooldownKind === "auth" || account.cooldownKind === "transient")
13390
+ return;
13391
+ if (account.cooldownUntil > now + MAX_TRANSIENT_COOLDOWN_MS && !isExhausted(account, cfg, now)) {
13392
+ account.cooldownUntil = 0;
13393
+ delete account.cooldownKind;
13394
+ }
13395
+ }
13388
13396
  function rollWeeklyAnchorForward(anchor, now) {
13389
13397
  if (!Number.isFinite(anchor) || anchor <= 0)
13390
13398
  return 0;
@@ -13401,8 +13409,10 @@ function preserveWeeklyAnchor(incoming, stored, now) {
13401
13409
  return incoming;
13402
13410
  return { utilization: incoming.utilization, resetAt: anchor };
13403
13411
  }
13412
+ var MAX_TRANSIENT_COOLDOWN_MS;
13404
13413
  var init_usage_merge = __esm(() => {
13405
13414
  init_score_core();
13415
+ MAX_TRANSIENT_COOLDOWN_MS = 5 * 60 * 1000;
13406
13416
  });
13407
13417
 
13408
13418
  // src/accounts.ts
@@ -13454,6 +13464,8 @@ function mergeLoginUsage(row, usage) {
13454
13464
  weekly: usage.weekly ? preserveWeeklyAnchor(usage.weekly, row.usage.weekly, usage.capturedAt) : row.usage.weekly,
13455
13465
  capturedAt: usage.weekly ? usage.capturedAt : row.usage.capturedAt
13456
13466
  };
13467
+ if (usage.weekly)
13468
+ reconcileQuotaCooldown(row, loadScoreConfig(), usage.capturedAt);
13457
13469
  }
13458
13470
  function applyTokens(row, tokens) {
13459
13471
  if (tokens.refresh) {
@@ -13547,6 +13559,7 @@ async function bootstrapFromOpencodeAuth(providerID, getAuth, tokensFromApiKey)
13547
13559
  var init_accounts = __esm(() => {
13548
13560
  init_pairing();
13549
13561
  init_store();
13562
+ init_score_core();
13550
13563
  init_types();
13551
13564
  init_usage_merge();
13552
13565
  });
@@ -14488,24 +14501,46 @@ var init_usage_headers = __esm(() => {
14488
14501
  });
14489
14502
 
14490
14503
  // src/providers/usage-http.ts
14491
- async function fetchJson(url, headers, timeoutMs) {
14492
- let response;
14504
+ async function request(url, headers, timeoutMs) {
14493
14505
  try {
14494
- response = await fetch(url, {
14506
+ return await fetch(url, {
14495
14507
  headers,
14496
14508
  signal: AbortSignal.timeout(timeoutMs)
14497
14509
  });
14498
14510
  } catch {
14499
14511
  return null;
14500
14512
  }
14513
+ }
14514
+ async function readJson(response) {
14515
+ if (!response)
14516
+ return null;
14501
14517
  if (!response.ok) {
14502
14518
  await response.body?.cancel().catch(ignore);
14503
14519
  return null;
14504
14520
  }
14505
14521
  return await response.json().catch(() => null);
14506
14522
  }
14523
+ async function fetchJson(url, headers, timeoutMs) {
14524
+ return readJson(await request(url, headers, timeoutMs));
14525
+ }
14526
+ async function fetchUsageJson(url, headers, timeoutMs) {
14527
+ const response = await request(url, headers, timeoutMs);
14528
+ if (response?.status === 401) {
14529
+ await response.body?.cancel().catch(ignore);
14530
+ throw new UsageAuthenticationError;
14531
+ }
14532
+ return readJson(response);
14533
+ }
14534
+ var UsageAuthenticationError;
14507
14535
  var init_usage_http = __esm(() => {
14508
14536
  init_util2();
14537
+ UsageAuthenticationError = class UsageAuthenticationError extends Error {
14538
+ status = 401;
14539
+ constructor() {
14540
+ super("Usage endpoint rejected the access token (401)");
14541
+ this.name = "UsageAuthenticationError";
14542
+ }
14543
+ };
14509
14544
  });
14510
14545
 
14511
14546
  // src/providers/anthropic/version.ts
@@ -14945,7 +14980,7 @@ async function probeUsage(access, now) {
14945
14980
  async function fetchUsage(account, now) {
14946
14981
  if (!account.refresh)
14947
14982
  return probeUsage(account.access, now);
14948
- const json = await fetchJson(USAGE_URL, {
14983
+ const json = await fetchUsageJson(USAGE_URL, {
14949
14984
  authorization: `Bearer ${account.access}`,
14950
14985
  "anthropic-beta": "oauth-2025-04-20",
14951
14986
  "user-agent": usageUserAgent(),
@@ -15277,7 +15312,7 @@ function parseUsages(json, now) {
15277
15312
  return hourly || weekly ? { hourly, weekly, capturedAt: now } : null;
15278
15313
  }
15279
15314
  function getUsages(baseUrl, key) {
15280
- return fetchJson(`${baseUrl}/usages`, { authorization: `Bearer ${key}`, accept: "application/json" }, USAGE_HTTP_TIMEOUT_MS);
15315
+ return fetchUsageJson(`${baseUrl}/usages`, { authorization: `Bearer ${key}`, accept: "application/json" }, USAGE_HTTP_TIMEOUT_MS);
15281
15316
  }
15282
15317
  function usageFetcher(baseUrl) {
15283
15318
  return async (account, now) => parseUsages(await getUsages(baseUrl, account.access), now);
@@ -15551,7 +15586,7 @@ async function fetchUsage2(account, now) {
15551
15586
  const accountId = resolveAccountId(account);
15552
15587
  if (accountId)
15553
15588
  headers["chatgpt-account-id"] = accountId;
15554
- const json = await fetchJson(USAGE_URL2, headers, USAGE_HTTP_TIMEOUT_MS);
15589
+ const json = await fetchUsageJson(USAGE_URL2, headers, USAGE_HTTP_TIMEOUT_MS);
15555
15590
  const rateLimit = json?.rate_limit;
15556
15591
  if (!isPlainObject2(rateLimit))
15557
15592
  return null;
@@ -15640,7 +15675,7 @@ async function refreshUsage(adapters, now, poolSnapshot) {
15640
15675
  aliveIds?.add(account.id);
15641
15676
  if (account.disabledReason && account.disabledReason !== MANUAL_DISABLED_REASON)
15642
15677
  continue;
15643
- if (account.usage.capturedAt !== 0 && now - account.usage.capturedAt <= USAGE_REFRESH_TTL_MS)
15678
+ if (account.usage.capturedAt !== 0 && now - account.usage.capturedAt < USAGE_REFRESH_TTL_MS)
15644
15679
  continue;
15645
15680
  const adapter = adapterFor(adapters, account.providerID);
15646
15681
  if (!adapter)
@@ -15662,7 +15697,16 @@ async function refreshUsage(adapters, now, poolSnapshot) {
15662
15697
  await Promise.all(stale.map(async ({ adapter, account }) => {
15663
15698
  try {
15664
15699
  await ensureAccessToken(adapter, account, now);
15665
- const snapshot = await adapter.fetchUsage(account, now);
15700
+ const rejectedAccess = account.access;
15701
+ let snapshot;
15702
+ try {
15703
+ snapshot = await adapter.fetchUsage(account, now);
15704
+ } catch (error) {
15705
+ if (!(error instanceof UsageAuthenticationError) || !account.refresh)
15706
+ throw error;
15707
+ await ensureAccessToken(adapter, account, now, rejectedAccess);
15708
+ snapshot = await adapter.fetchUsage(account, now);
15709
+ }
15666
15710
  if (snapshot) {
15667
15711
  await mutatePool((p) => {
15668
15712
  const stored = findAccount(p, account.id);
@@ -15672,10 +15716,8 @@ async function refreshUsage(adapters, now, poolSnapshot) {
15672
15716
  weekly: snapshot.weekly === null ? stored.usage.weekly : preserveWeeklyAnchor(snapshot.weekly, stored.usage.weekly, now),
15673
15717
  capturedAt: snapshot.weekly === null ? stored.usage.capturedAt : now
15674
15718
  };
15675
- if (snapshot.weekly !== null && stored.cooldownUntil > now + MAX_TRANSIENT_COOLDOWN_MS && !isExhausted(stored, scoreCfg, now)) {
15676
- stored.cooldownUntil = 0;
15677
- delete stored.cooldownKind;
15678
- }
15719
+ if (snapshot.weekly !== null && stored.cooldownUntil === account.cooldownUntil && stored.cooldownKind === account.cooldownKind)
15720
+ reconcileQuotaCooldown(stored, scoreCfg, now);
15679
15721
  }
15680
15722
  });
15681
15723
  }
@@ -15688,16 +15730,17 @@ function refreshUsageInBackground(adapter, now, poolSnapshot) {
15688
15730
  function refreshAllUsageInBackground(now, poolSnapshot) {
15689
15731
  return refreshUsage(ADAPTERS, now, poolSnapshot);
15690
15732
  }
15691
- var USAGE_REFRESH_TTL_MS, MAX_TRANSIENT_COOLDOWN_MS, lastPoll;
15733
+ var USAGE_REFRESH_TTL_MS, lastPoll;
15692
15734
  var init_usage_refresh = __esm(() => {
15693
15735
  init_store();
15694
15736
  init_registry();
15737
+ init_usage_http();
15695
15738
  init_refresh();
15696
15739
  init_score_core();
15697
15740
  init_types();
15698
15741
  init_usage_merge();
15742
+ init_usage_merge();
15699
15743
  USAGE_REFRESH_TTL_MS = 5 * 60 * 1000;
15700
- MAX_TRANSIENT_COOLDOWN_MS = 5 * 60 * 1000;
15701
15744
  lastPoll = new Map;
15702
15745
  });
15703
15746
 
@@ -17329,7 +17372,7 @@ function backoffFrom(res, now) {
17329
17372
  function isRateLimited(res) {
17330
17373
  return res.status === 429 || res.status === 403 && (res.headers.get("x-ratelimit-remaining") === "0" || res.headers.has("retry-after"));
17331
17374
  }
17332
- async function request(url, init, now) {
17375
+ async function request2(url, init, now) {
17333
17376
  let res;
17334
17377
  try {
17335
17378
  res = await globalThis.fetch(url, {
@@ -17381,7 +17424,7 @@ async function boundedText(res) {
17381
17424
  }
17382
17425
  return Buffer.concat(chunks).toString("utf8");
17383
17426
  }
17384
- async function readJson(res) {
17427
+ async function readJson2(res) {
17385
17428
  if (!res.ok)
17386
17429
  throw new SyncError("http");
17387
17430
  const text = await boundedText(res);
@@ -17396,7 +17439,7 @@ async function readJson(res) {
17396
17439
  return json;
17397
17440
  }
17398
17441
  async function createGist(token, content, now = Date.now()) {
17399
- const res = await request(API, {
17442
+ const res = await request2(API, {
17400
17443
  method: "POST",
17401
17444
  headers: { ...headers(token), "content-type": "application/json" },
17402
17445
  body: JSON.stringify({
@@ -17405,7 +17448,7 @@ async function createGist(token, content, now = Date.now()) {
17405
17448
  files: { [GIST_FILE]: { content } }
17406
17449
  })
17407
17450
  }, now);
17408
- const json = await readJson(res);
17451
+ const json = await readJson2(res);
17409
17452
  const owner = isPlainObject2(json.owner) ? json.owner.login : undefined;
17410
17453
  if (typeof json.id !== "string" || !GIST_ID_RE.test(json.id))
17411
17454
  throw new SyncError("http");
@@ -17417,7 +17460,7 @@ async function createGist(token, content, now = Date.now()) {
17417
17460
  async function updateGist(token, id, content, now = Date.now()) {
17418
17461
  let res;
17419
17462
  try {
17420
- res = await request(`${API}/${id}`, {
17463
+ res = await request2(`${API}/${id}`, {
17421
17464
  method: "PATCH",
17422
17465
  headers: { ...headers(token), "content-type": "application/json" },
17423
17466
  body: JSON.stringify({ files: { [GIST_FILE]: { content } } })
@@ -17429,10 +17472,10 @@ async function updateGist(token, id, content, now = Date.now()) {
17429
17472
  }
17430
17473
  if (res.status === 401 || res.status === 403)
17431
17474
  throw new SyncError("write-denied");
17432
- await readJson(res);
17475
+ await readJson2(res);
17433
17476
  }
17434
17477
  async function readGist(id, etag, now = Date.now(), token) {
17435
- const ask = (auth) => request(`${API}/${id}`, {
17478
+ const ask = (auth) => request2(`${API}/${id}`, {
17436
17479
  headers: {
17437
17480
  ...headers(auth),
17438
17481
  ...etag ? { "if-none-match": etag } : {}
@@ -17443,7 +17486,7 @@ async function readGist(id, etag, now = Date.now(), token) {
17443
17486
  res = await ask();
17444
17487
  if (res.status === 304)
17445
17488
  return { changed: false };
17446
- const json = await readJson(res);
17489
+ const json = await readJson2(res);
17447
17490
  const file = isPlainObject2(json.files) ? json.files[GIST_FILE] : undefined;
17448
17491
  if (!isPlainObject2(file) || typeof file.content !== "string")
17449
17492
  throw new SyncError("bad-blob");
@@ -18585,6 +18628,71 @@ var init_start = __esm(() => {
18585
18628
  init_state();
18586
18629
  });
18587
18630
 
18631
+ // src/usage-poll.ts
18632
+ import { readFile as readFile8 } from "node:fs/promises";
18633
+ async function nextPollAt(path) {
18634
+ try {
18635
+ const value = JSON.parse(await readFile8(path, "utf8"));
18636
+ return isPlainObject2(value) && isFiniteNumber(value.nextPollAt) ? value.nextPollAt : 0;
18637
+ } catch (error) {
18638
+ if (error instanceof SyntaxError || error instanceof Error && "code" in error && error.code === "ENOENT")
18639
+ return 0;
18640
+ throw error;
18641
+ }
18642
+ }
18643
+ function startUsagePoll(deps = {}) {
18644
+ const now = deps.now ?? Date.now;
18645
+ const refresh = deps.refresh ?? refreshAllUsageInBackground;
18646
+ const timers = deps.timers ?? realTimers;
18647
+ let disposed = false;
18648
+ let inflight;
18649
+ async function poll() {
18650
+ const path = `${poolFilePath()}.usage-poll.json`;
18651
+ try {
18652
+ await withLock(`${path}.lock`, {
18653
+ staleMs: 120000,
18654
+ timeoutMs: 0,
18655
+ retryMs: 25,
18656
+ heartbeatMs: 5000
18657
+ }, async () => {
18658
+ const at = now();
18659
+ if (at < await nextPollAt(path))
18660
+ return;
18661
+ await writeJsonAtomic(path, JSON.stringify({ nextPollAt: at + USAGE_REFRESH_TTL_MS }));
18662
+ await refresh(at);
18663
+ });
18664
+ } catch (error) {
18665
+ if (!(error instanceof LockTimeoutError))
18666
+ throw error;
18667
+ }
18668
+ }
18669
+ function tick() {
18670
+ if (disposed)
18671
+ return Promise.resolve();
18672
+ inflight ??= poll().finally(() => {
18673
+ inflight = undefined;
18674
+ });
18675
+ return inflight;
18676
+ }
18677
+ const timer = timers.set(() => void tick().catch(ignore), USAGE_REFRESH_TTL_MS);
18678
+ return {
18679
+ tick,
18680
+ async dispose() {
18681
+ disposed = true;
18682
+ timers.clear(timer);
18683
+ await inflight;
18684
+ }
18685
+ };
18686
+ }
18687
+ var init_usage_poll = __esm(() => {
18688
+ init_lock();
18689
+ init_paths();
18690
+ init_store();
18691
+ init_loop();
18692
+ init_usage_refresh();
18693
+ init_util2();
18694
+ });
18695
+
18588
18696
  // src/legacy.ts
18589
18697
  function zeroOutCost(provider) {
18590
18698
  for (const model of Object.values(provider.models)) {
@@ -18739,8 +18847,12 @@ function createProviderPlugin(adapter) {
18739
18847
  }
18740
18848
  var PROVIDER_LABELS, AnthropicLoadBalancerPlugin, OpenAILoadBalancerPlugin, KimiCodeLoadBalancerPlugin, KimiCodeGlobalLoadBalancerPlugin, lbResult = (output) => ({ title: "Auth Load Balancer", output }), AuthLoadBalancerStatusPlugin = async (input) => {
18741
18849
  const sync = startSync();
18850
+ const usage = startUsagePoll();
18742
18851
  return {
18743
- dispose: async () => sync?.dispose(),
18852
+ dispose: async () => {
18853
+ await usage.dispose();
18854
+ await sync?.dispose();
18855
+ },
18744
18856
  tool: {
18745
18857
  auth_lb_status: tool({
18746
18858
  description: "Show the auth load-balancer pool: the in-use account per provider, each account's weekly and 5h usage, cooldowns, and the ranked next-candidate accounts. Refreshes stale usage from the provider usage endpoints (throttled) before rendering.",
@@ -18829,6 +18941,7 @@ var init_legacy = __esm(() => {
18829
18941
  init_start();
18830
18942
  init_token_mint();
18831
18943
  init_types();
18944
+ init_usage_poll();
18832
18945
  init_usage_refresh();
18833
18946
  init_util2();
18834
18947
  PROVIDER_LABELS = {
@@ -17,8 +17,8 @@ export declare function sendUsageProbe(access: string): Promise<Response>;
17
17
  export declare function probeSnapshot(headers: Headers, now: number): UsageSnapshot | null;
18
18
  /**
19
19
  * Poll the dedicated usage endpoint for authoritative 5h + 7d utilization without
20
- * consuming inference quota. Returns null on any failure — including a 200 whose
21
- * body is not the usage shape — so the caller keeps the last-known snapshot.
20
+ * consuming inference quota. A 401 is surfaced for bounded OAuth recovery;
21
+ * other failures, including an invalid 200 body, keep the last-known snapshot.
22
22
  *
23
23
  * A row without a refresh token is a `claude setup-token` bearer, scoped to
24
24
  * `user:inference` alone; the endpoint demands `user:profile` and answers it
@@ -6,7 +6,7 @@ import type { PoolAccount, UsageSnapshot } from '../../types';
6
6
  * broken body keeps the last-known snapshot instead of reading as 0% used.
7
7
  */
8
8
  export declare function parseUsages(json: unknown, now: number): UsageSnapshot | null;
9
- /** `GET {baseUrl}/usages` — free, spends no quota. Null on any failure. */
9
+ /** `GET {baseUrl}/usages`: surface 401 for OAuth recovery; other failures return null. */
10
10
  export declare function getUsages(baseUrl: string, key: string): Promise<unknown>;
11
11
  /** The adapter's `fetchUsage` for one deployment. */
12
12
  export declare function usageFetcher(baseUrl: string): (account: PoolAccount, now: number) => Promise<UsageSnapshot | null>;
@@ -14,7 +14,8 @@ export declare function parseUsageHeaders(headers: Headers): Partial<UsageSnapsh
14
14
  /**
15
15
  * Poll GET /wham/usage for authoritative 5h + weekly utilization without
16
16
  * consuming inference quota, assigning each returned window to its role BY
17
- * DURATION (see `isWeeklyMinutes`) rather than by position. Null on failure.
17
+ * DURATION (see `isWeeklyMinutes`) rather than by position. A 401 is surfaced
18
+ * for bounded OAuth recovery; other failures return null.
18
19
  * Sends the Codex CLI's `codex-cli` UA and the `chatgpt-account-id` (from the
19
20
  * stored id or, as a fallback, decoded from the access-token JWT) — required
20
21
  * for team/workspace accounts.
@@ -1,9 +1,6 @@
1
- /**
2
- * Shared HTTP shell for the plugin's read-only JSON GETs — the provider usage
3
- * endpoints (Anthropic `/usage`, OpenAI `/wham/usage`) and the npm dist-tags
4
- * lookup behind `providers/anthropic/version.ts`. GET with a timeout, null on
5
- * ANY failure — network throw/timeout, non-ok status (body cancelled so the
6
- * connection is released), or a 200 whose body is not JSON. Callers keep only
7
- * their own headers and shape guards; the transport contract lives here ONCE.
8
- */
9
1
  export declare function fetchJson<T>(url: string, headers: Record<string, string>, timeoutMs: number): Promise<T | null>;
2
+ export declare class UsageAuthenticationError extends Error {
3
+ readonly status = 401;
4
+ constructor();
5
+ }
6
+ export declare function fetchUsageJson<T>(url: string, headers: Record<string, string>, timeoutMs: number): Promise<T | null>;
@@ -1,4 +1,8 @@
1
- import type { UsageWindow } from './types';
1
+ import { type ScoreConfig } from './scheduler/score-core';
2
+ import type { PoolAccount, UsageWindow } from './types';
3
+ export declare const MAX_TRANSIENT_COOLDOWN_MS: number;
4
+ /** Call only after fresh usage was measured; cached headroom cannot disprove a later 429. */
5
+ export declare function reconcileQuotaCooldown(account: PoolAccount, cfg: ScoreConfig, now: number): void;
2
6
  /**
3
7
  * Roll a FIXED weekly reset anchor forward to its next occurrence strictly after
4
8
  * `now`. Weekly windows are anchored per account (verified live: Anthropic's
@@ -0,0 +1,11 @@
1
+ import { type LoopTimers } from './sync/loop';
2
+ interface UsagePollDeps {
3
+ now?: () => number;
4
+ refresh?: (now: number) => Promise<void>;
5
+ timers?: LoopTimers;
6
+ }
7
+ export declare function startUsagePoll(deps?: UsagePollDeps): {
8
+ tick: () => Promise<void>;
9
+ dispose(): Promise<void>;
10
+ };
11
+ export {};
@@ -1,18 +1,7 @@
1
1
  import type { ProviderAdapter } from './providers/types';
2
2
  import { type PoolFile } from './types';
3
3
  export declare const USAGE_REFRESH_TTL_MS: number;
4
- /**
5
- * Max lifetime of a TRANSIENT (non-quota) cooldown. fetch.ts cools a thrown
6
- * network error or a header-less 429/402 for at most `ACCOUNT_COOLDOWN_MS`
7
- * (5min) and an auth 401/403 for `AUTH_COOLDOWN_MS` (2min); ONLY a quota 429's
8
- * `Retry-After` writes a cooldown beyond this. So a cooldown whose remainder
9
- * still exceeds this bound is necessarily quota-derived — the one class that can
10
- * outlive its own usage window after an out-of-band reset (see the stale-cooldown
11
- * reconciliation below). Kept `=== ACCOUNT_COOLDOWN_MS` by a drift-guard test;
12
- * defined locally because importing it from fetch.ts (which imports THIS module)
13
- * would form a cycle.
14
- */
15
- export declare const MAX_TRANSIENT_COOLDOWN_MS: number;
4
+ export { MAX_TRANSIENT_COOLDOWN_MS } from './usage-merge';
16
5
  /**
17
6
  * Test-only window into the module-private `lastPoll` throttle map. Never
18
7
  * imported by production code — only by usage-refresh tests that need to
package/dist/v1.js CHANGED
@@ -13385,6 +13385,14 @@ var init_score_core = __esm(() => {
13385
13385
  });
13386
13386
 
13387
13387
  // src/usage-merge.ts
13388
+ function reconcileQuotaCooldown(account, cfg, now) {
13389
+ if (account.cooldownKind === "auth" || account.cooldownKind === "transient")
13390
+ return;
13391
+ if (account.cooldownUntil > now + MAX_TRANSIENT_COOLDOWN_MS && !isExhausted(account, cfg, now)) {
13392
+ account.cooldownUntil = 0;
13393
+ delete account.cooldownKind;
13394
+ }
13395
+ }
13388
13396
  function rollWeeklyAnchorForward(anchor, now) {
13389
13397
  if (!Number.isFinite(anchor) || anchor <= 0)
13390
13398
  return 0;
@@ -13401,8 +13409,10 @@ function preserveWeeklyAnchor(incoming, stored, now) {
13401
13409
  return incoming;
13402
13410
  return { utilization: incoming.utilization, resetAt: anchor };
13403
13411
  }
13412
+ var MAX_TRANSIENT_COOLDOWN_MS;
13404
13413
  var init_usage_merge = __esm(() => {
13405
13414
  init_score_core();
13415
+ MAX_TRANSIENT_COOLDOWN_MS = 5 * 60 * 1000;
13406
13416
  });
13407
13417
 
13408
13418
  // src/accounts.ts
@@ -13454,6 +13464,8 @@ function mergeLoginUsage(row, usage) {
13454
13464
  weekly: usage.weekly ? preserveWeeklyAnchor(usage.weekly, row.usage.weekly, usage.capturedAt) : row.usage.weekly,
13455
13465
  capturedAt: usage.weekly ? usage.capturedAt : row.usage.capturedAt
13456
13466
  };
13467
+ if (usage.weekly)
13468
+ reconcileQuotaCooldown(row, loadScoreConfig(), usage.capturedAt);
13457
13469
  }
13458
13470
  function applyTokens(row, tokens) {
13459
13471
  if (tokens.refresh) {
@@ -13547,6 +13559,7 @@ async function bootstrapFromOpencodeAuth(providerID, getAuth, tokensFromApiKey)
13547
13559
  var init_accounts = __esm(() => {
13548
13560
  init_pairing();
13549
13561
  init_store();
13562
+ init_score_core();
13550
13563
  init_types();
13551
13564
  init_usage_merge();
13552
13565
  });
@@ -14488,24 +14501,46 @@ var init_usage_headers = __esm(() => {
14488
14501
  });
14489
14502
 
14490
14503
  // src/providers/usage-http.ts
14491
- async function fetchJson(url, headers, timeoutMs) {
14492
- let response;
14504
+ async function request(url, headers, timeoutMs) {
14493
14505
  try {
14494
- response = await fetch(url, {
14506
+ return await fetch(url, {
14495
14507
  headers,
14496
14508
  signal: AbortSignal.timeout(timeoutMs)
14497
14509
  });
14498
14510
  } catch {
14499
14511
  return null;
14500
14512
  }
14513
+ }
14514
+ async function readJson(response) {
14515
+ if (!response)
14516
+ return null;
14501
14517
  if (!response.ok) {
14502
14518
  await response.body?.cancel().catch(ignore);
14503
14519
  return null;
14504
14520
  }
14505
14521
  return await response.json().catch(() => null);
14506
14522
  }
14523
+ async function fetchJson(url, headers, timeoutMs) {
14524
+ return readJson(await request(url, headers, timeoutMs));
14525
+ }
14526
+ async function fetchUsageJson(url, headers, timeoutMs) {
14527
+ const response = await request(url, headers, timeoutMs);
14528
+ if (response?.status === 401) {
14529
+ await response.body?.cancel().catch(ignore);
14530
+ throw new UsageAuthenticationError;
14531
+ }
14532
+ return readJson(response);
14533
+ }
14534
+ var UsageAuthenticationError;
14507
14535
  var init_usage_http = __esm(() => {
14508
14536
  init_util2();
14537
+ UsageAuthenticationError = class UsageAuthenticationError extends Error {
14538
+ status = 401;
14539
+ constructor() {
14540
+ super("Usage endpoint rejected the access token (401)");
14541
+ this.name = "UsageAuthenticationError";
14542
+ }
14543
+ };
14509
14544
  });
14510
14545
 
14511
14546
  // src/providers/anthropic/version.ts
@@ -14945,7 +14980,7 @@ async function probeUsage(access, now) {
14945
14980
  async function fetchUsage(account, now) {
14946
14981
  if (!account.refresh)
14947
14982
  return probeUsage(account.access, now);
14948
- const json = await fetchJson(USAGE_URL, {
14983
+ const json = await fetchUsageJson(USAGE_URL, {
14949
14984
  authorization: `Bearer ${account.access}`,
14950
14985
  "anthropic-beta": "oauth-2025-04-20",
14951
14986
  "user-agent": usageUserAgent(),
@@ -15277,7 +15312,7 @@ function parseUsages(json, now) {
15277
15312
  return hourly || weekly ? { hourly, weekly, capturedAt: now } : null;
15278
15313
  }
15279
15314
  function getUsages(baseUrl, key) {
15280
- return fetchJson(`${baseUrl}/usages`, { authorization: `Bearer ${key}`, accept: "application/json" }, USAGE_HTTP_TIMEOUT_MS);
15315
+ return fetchUsageJson(`${baseUrl}/usages`, { authorization: `Bearer ${key}`, accept: "application/json" }, USAGE_HTTP_TIMEOUT_MS);
15281
15316
  }
15282
15317
  function usageFetcher(baseUrl) {
15283
15318
  return async (account, now) => parseUsages(await getUsages(baseUrl, account.access), now);
@@ -15551,7 +15586,7 @@ async function fetchUsage2(account, now) {
15551
15586
  const accountId = resolveAccountId(account);
15552
15587
  if (accountId)
15553
15588
  headers["chatgpt-account-id"] = accountId;
15554
- const json = await fetchJson(USAGE_URL2, headers, USAGE_HTTP_TIMEOUT_MS);
15589
+ const json = await fetchUsageJson(USAGE_URL2, headers, USAGE_HTTP_TIMEOUT_MS);
15555
15590
  const rateLimit = json?.rate_limit;
15556
15591
  if (!isPlainObject2(rateLimit))
15557
15592
  return null;
@@ -15640,7 +15675,7 @@ async function refreshUsage(adapters, now, poolSnapshot) {
15640
15675
  aliveIds?.add(account.id);
15641
15676
  if (account.disabledReason && account.disabledReason !== MANUAL_DISABLED_REASON)
15642
15677
  continue;
15643
- if (account.usage.capturedAt !== 0 && now - account.usage.capturedAt <= USAGE_REFRESH_TTL_MS)
15678
+ if (account.usage.capturedAt !== 0 && now - account.usage.capturedAt < USAGE_REFRESH_TTL_MS)
15644
15679
  continue;
15645
15680
  const adapter = adapterFor(adapters, account.providerID);
15646
15681
  if (!adapter)
@@ -15662,7 +15697,16 @@ async function refreshUsage(adapters, now, poolSnapshot) {
15662
15697
  await Promise.all(stale.map(async ({ adapter, account }) => {
15663
15698
  try {
15664
15699
  await ensureAccessToken(adapter, account, now);
15665
- const snapshot = await adapter.fetchUsage(account, now);
15700
+ const rejectedAccess = account.access;
15701
+ let snapshot;
15702
+ try {
15703
+ snapshot = await adapter.fetchUsage(account, now);
15704
+ } catch (error) {
15705
+ if (!(error instanceof UsageAuthenticationError) || !account.refresh)
15706
+ throw error;
15707
+ await ensureAccessToken(adapter, account, now, rejectedAccess);
15708
+ snapshot = await adapter.fetchUsage(account, now);
15709
+ }
15666
15710
  if (snapshot) {
15667
15711
  await mutatePool((p) => {
15668
15712
  const stored = findAccount(p, account.id);
@@ -15672,10 +15716,8 @@ async function refreshUsage(adapters, now, poolSnapshot) {
15672
15716
  weekly: snapshot.weekly === null ? stored.usage.weekly : preserveWeeklyAnchor(snapshot.weekly, stored.usage.weekly, now),
15673
15717
  capturedAt: snapshot.weekly === null ? stored.usage.capturedAt : now
15674
15718
  };
15675
- if (snapshot.weekly !== null && stored.cooldownUntil > now + MAX_TRANSIENT_COOLDOWN_MS && !isExhausted(stored, scoreCfg, now)) {
15676
- stored.cooldownUntil = 0;
15677
- delete stored.cooldownKind;
15678
- }
15719
+ if (snapshot.weekly !== null && stored.cooldownUntil === account.cooldownUntil && stored.cooldownKind === account.cooldownKind)
15720
+ reconcileQuotaCooldown(stored, scoreCfg, now);
15679
15721
  }
15680
15722
  });
15681
15723
  }
@@ -15688,16 +15730,17 @@ function refreshUsageInBackground(adapter, now, poolSnapshot) {
15688
15730
  function refreshAllUsageInBackground(now, poolSnapshot) {
15689
15731
  return refreshUsage(ADAPTERS, now, poolSnapshot);
15690
15732
  }
15691
- var USAGE_REFRESH_TTL_MS, MAX_TRANSIENT_COOLDOWN_MS, lastPoll;
15733
+ var USAGE_REFRESH_TTL_MS, lastPoll;
15692
15734
  var init_usage_refresh = __esm(() => {
15693
15735
  init_store();
15694
15736
  init_registry();
15737
+ init_usage_http();
15695
15738
  init_refresh();
15696
15739
  init_score_core();
15697
15740
  init_types();
15698
15741
  init_usage_merge();
15742
+ init_usage_merge();
15699
15743
  USAGE_REFRESH_TTL_MS = 5 * 60 * 1000;
15700
- MAX_TRANSIENT_COOLDOWN_MS = 5 * 60 * 1000;
15701
15744
  lastPoll = new Map;
15702
15745
  });
15703
15746
 
@@ -17329,7 +17372,7 @@ function backoffFrom(res, now) {
17329
17372
  function isRateLimited(res) {
17330
17373
  return res.status === 429 || res.status === 403 && (res.headers.get("x-ratelimit-remaining") === "0" || res.headers.has("retry-after"));
17331
17374
  }
17332
- async function request(url, init, now) {
17375
+ async function request2(url, init, now) {
17333
17376
  let res;
17334
17377
  try {
17335
17378
  res = await globalThis.fetch(url, {
@@ -17381,7 +17424,7 @@ async function boundedText(res) {
17381
17424
  }
17382
17425
  return Buffer.concat(chunks).toString("utf8");
17383
17426
  }
17384
- async function readJson(res) {
17427
+ async function readJson2(res) {
17385
17428
  if (!res.ok)
17386
17429
  throw new SyncError("http");
17387
17430
  const text = await boundedText(res);
@@ -17396,7 +17439,7 @@ async function readJson(res) {
17396
17439
  return json;
17397
17440
  }
17398
17441
  async function createGist(token, content, now = Date.now()) {
17399
- const res = await request(API, {
17442
+ const res = await request2(API, {
17400
17443
  method: "POST",
17401
17444
  headers: { ...headers(token), "content-type": "application/json" },
17402
17445
  body: JSON.stringify({
@@ -17405,7 +17448,7 @@ async function createGist(token, content, now = Date.now()) {
17405
17448
  files: { [GIST_FILE]: { content } }
17406
17449
  })
17407
17450
  }, now);
17408
- const json = await readJson(res);
17451
+ const json = await readJson2(res);
17409
17452
  const owner = isPlainObject2(json.owner) ? json.owner.login : undefined;
17410
17453
  if (typeof json.id !== "string" || !GIST_ID_RE.test(json.id))
17411
17454
  throw new SyncError("http");
@@ -17417,7 +17460,7 @@ async function createGist(token, content, now = Date.now()) {
17417
17460
  async function updateGist(token, id, content, now = Date.now()) {
17418
17461
  let res;
17419
17462
  try {
17420
- res = await request(`${API}/${id}`, {
17463
+ res = await request2(`${API}/${id}`, {
17421
17464
  method: "PATCH",
17422
17465
  headers: { ...headers(token), "content-type": "application/json" },
17423
17466
  body: JSON.stringify({ files: { [GIST_FILE]: { content } } })
@@ -17429,10 +17472,10 @@ async function updateGist(token, id, content, now = Date.now()) {
17429
17472
  }
17430
17473
  if (res.status === 401 || res.status === 403)
17431
17474
  throw new SyncError("write-denied");
17432
- await readJson(res);
17475
+ await readJson2(res);
17433
17476
  }
17434
17477
  async function readGist(id, etag, now = Date.now(), token) {
17435
- const ask = (auth) => request(`${API}/${id}`, {
17478
+ const ask = (auth) => request2(`${API}/${id}`, {
17436
17479
  headers: {
17437
17480
  ...headers(auth),
17438
17481
  ...etag ? { "if-none-match": etag } : {}
@@ -17443,7 +17486,7 @@ async function readGist(id, etag, now = Date.now(), token) {
17443
17486
  res = await ask();
17444
17487
  if (res.status === 304)
17445
17488
  return { changed: false };
17446
- const json = await readJson(res);
17489
+ const json = await readJson2(res);
17447
17490
  const file = isPlainObject2(json.files) ? json.files[GIST_FILE] : undefined;
17448
17491
  if (!isPlainObject2(file) || typeof file.content !== "string")
17449
17492
  throw new SyncError("bad-blob");
@@ -18585,6 +18628,71 @@ var init_start = __esm(() => {
18585
18628
  init_state();
18586
18629
  });
18587
18630
 
18631
+ // src/usage-poll.ts
18632
+ import { readFile as readFile8 } from "node:fs/promises";
18633
+ async function nextPollAt(path) {
18634
+ try {
18635
+ const value = JSON.parse(await readFile8(path, "utf8"));
18636
+ return isPlainObject2(value) && isFiniteNumber(value.nextPollAt) ? value.nextPollAt : 0;
18637
+ } catch (error) {
18638
+ if (error instanceof SyntaxError || error instanceof Error && "code" in error && error.code === "ENOENT")
18639
+ return 0;
18640
+ throw error;
18641
+ }
18642
+ }
18643
+ function startUsagePoll(deps = {}) {
18644
+ const now = deps.now ?? Date.now;
18645
+ const refresh = deps.refresh ?? refreshAllUsageInBackground;
18646
+ const timers = deps.timers ?? realTimers;
18647
+ let disposed = false;
18648
+ let inflight;
18649
+ async function poll() {
18650
+ const path = `${poolFilePath()}.usage-poll.json`;
18651
+ try {
18652
+ await withLock(`${path}.lock`, {
18653
+ staleMs: 120000,
18654
+ timeoutMs: 0,
18655
+ retryMs: 25,
18656
+ heartbeatMs: 5000
18657
+ }, async () => {
18658
+ const at = now();
18659
+ if (at < await nextPollAt(path))
18660
+ return;
18661
+ await writeJsonAtomic(path, JSON.stringify({ nextPollAt: at + USAGE_REFRESH_TTL_MS }));
18662
+ await refresh(at);
18663
+ });
18664
+ } catch (error) {
18665
+ if (!(error instanceof LockTimeoutError))
18666
+ throw error;
18667
+ }
18668
+ }
18669
+ function tick() {
18670
+ if (disposed)
18671
+ return Promise.resolve();
18672
+ inflight ??= poll().finally(() => {
18673
+ inflight = undefined;
18674
+ });
18675
+ return inflight;
18676
+ }
18677
+ const timer = timers.set(() => void tick().catch(ignore), USAGE_REFRESH_TTL_MS);
18678
+ return {
18679
+ tick,
18680
+ async dispose() {
18681
+ disposed = true;
18682
+ timers.clear(timer);
18683
+ await inflight;
18684
+ }
18685
+ };
18686
+ }
18687
+ var init_usage_poll = __esm(() => {
18688
+ init_lock();
18689
+ init_paths();
18690
+ init_store();
18691
+ init_loop();
18692
+ init_usage_refresh();
18693
+ init_util2();
18694
+ });
18695
+
18588
18696
  // src/legacy.ts
18589
18697
  function zeroOutCost(provider) {
18590
18698
  for (const model of Object.values(provider.models)) {
@@ -18739,8 +18847,12 @@ function createProviderPlugin(adapter) {
18739
18847
  }
18740
18848
  var PROVIDER_LABELS, AnthropicLoadBalancerPlugin, OpenAILoadBalancerPlugin, KimiCodeLoadBalancerPlugin, KimiCodeGlobalLoadBalancerPlugin, lbResult = (output) => ({ title: "Auth Load Balancer", output }), AuthLoadBalancerStatusPlugin = async (input) => {
18741
18849
  const sync = startSync();
18850
+ const usage = startUsagePoll();
18742
18851
  return {
18743
- dispose: async () => sync?.dispose(),
18852
+ dispose: async () => {
18853
+ await usage.dispose();
18854
+ await sync?.dispose();
18855
+ },
18744
18856
  tool: {
18745
18857
  auth_lb_status: tool({
18746
18858
  description: "Show the auth load-balancer pool: the in-use account per provider, each account's weekly and 5h usage, cooldowns, and the ranked next-candidate accounts. Refreshes stale usage from the provider usage endpoints (throttled) before rendering.",
@@ -18829,6 +18941,7 @@ var init_legacy = __esm(() => {
18829
18941
  init_start();
18830
18942
  init_token_mint();
18831
18943
  init_types();
18944
+ init_usage_poll();
18832
18945
  init_usage_refresh();
18833
18946
  init_util2();
18834
18947
  PROVIDER_LABELS = {
package/dist/v2-entry.js CHANGED
@@ -42143,6 +42143,14 @@ var init_score_core = __esm(() => {
42143
42143
  });
42144
42144
 
42145
42145
  // src/usage-merge.ts
42146
+ function reconcileQuotaCooldown(account, cfg, now) {
42147
+ if (account.cooldownKind === "auth" || account.cooldownKind === "transient")
42148
+ return;
42149
+ if (account.cooldownUntil > now + MAX_TRANSIENT_COOLDOWN_MS && !isExhausted(account, cfg, now)) {
42150
+ account.cooldownUntil = 0;
42151
+ delete account.cooldownKind;
42152
+ }
42153
+ }
42146
42154
  function rollWeeklyAnchorForward(anchor, now) {
42147
42155
  if (!Number.isFinite(anchor) || anchor <= 0)
42148
42156
  return 0;
@@ -42159,8 +42167,10 @@ function preserveWeeklyAnchor(incoming, stored, now) {
42159
42167
  return incoming;
42160
42168
  return { utilization: incoming.utilization, resetAt: anchor };
42161
42169
  }
42170
+ var MAX_TRANSIENT_COOLDOWN_MS;
42162
42171
  var init_usage_merge = __esm(() => {
42163
42172
  init_score_core();
42173
+ MAX_TRANSIENT_COOLDOWN_MS = 5 * 60 * 1000;
42164
42174
  });
42165
42175
 
42166
42176
  // src/accounts.ts
@@ -42212,6 +42222,8 @@ function mergeLoginUsage(row, usage) {
42212
42222
  weekly: usage.weekly ? preserveWeeklyAnchor(usage.weekly, row.usage.weekly, usage.capturedAt) : row.usage.weekly,
42213
42223
  capturedAt: usage.weekly ? usage.capturedAt : row.usage.capturedAt
42214
42224
  };
42225
+ if (usage.weekly)
42226
+ reconcileQuotaCooldown(row, loadScoreConfig(), usage.capturedAt);
42215
42227
  }
42216
42228
  function applyTokens(row, tokens) {
42217
42229
  if (tokens.refresh) {
@@ -42305,6 +42317,7 @@ async function bootstrapFromOpencodeAuth(providerID, getAuth, tokensFromApiKey)
42305
42317
  var init_accounts = __esm(() => {
42306
42318
  init_pairing();
42307
42319
  init_store();
42320
+ init_score_core();
42308
42321
  init_types2();
42309
42322
  init_usage_merge();
42310
42323
  });
@@ -43246,24 +43259,46 @@ var init_usage_headers = __esm(() => {
43246
43259
  });
43247
43260
 
43248
43261
  // src/providers/usage-http.ts
43249
- async function fetchJson(url, headers, timeoutMs) {
43250
- let response;
43262
+ async function request(url, headers, timeoutMs) {
43251
43263
  try {
43252
- response = await fetch(url, {
43264
+ return await fetch(url, {
43253
43265
  headers,
43254
43266
  signal: AbortSignal.timeout(timeoutMs)
43255
43267
  });
43256
43268
  } catch {
43257
43269
  return null;
43258
43270
  }
43271
+ }
43272
+ async function readJson(response) {
43273
+ if (!response)
43274
+ return null;
43259
43275
  if (!response.ok) {
43260
43276
  await response.body?.cancel().catch(ignore);
43261
43277
  return null;
43262
43278
  }
43263
43279
  return await response.json().catch(() => null);
43264
43280
  }
43281
+ async function fetchJson(url, headers, timeoutMs) {
43282
+ return readJson(await request(url, headers, timeoutMs));
43283
+ }
43284
+ async function fetchUsageJson(url, headers, timeoutMs) {
43285
+ const response = await request(url, headers, timeoutMs);
43286
+ if (response?.status === 401) {
43287
+ await response.body?.cancel().catch(ignore);
43288
+ throw new UsageAuthenticationError;
43289
+ }
43290
+ return readJson(response);
43291
+ }
43292
+ var UsageAuthenticationError;
43265
43293
  var init_usage_http = __esm(() => {
43266
43294
  init_util4();
43295
+ UsageAuthenticationError = class UsageAuthenticationError extends Error {
43296
+ status = 401;
43297
+ constructor() {
43298
+ super("Usage endpoint rejected the access token (401)");
43299
+ this.name = "UsageAuthenticationError";
43300
+ }
43301
+ };
43267
43302
  });
43268
43303
 
43269
43304
  // src/providers/anthropic/version.ts
@@ -43703,7 +43738,7 @@ async function probeUsage(access, now) {
43703
43738
  async function fetchUsage(account, now) {
43704
43739
  if (!account.refresh)
43705
43740
  return probeUsage(account.access, now);
43706
- const json = await fetchJson(USAGE_URL, {
43741
+ const json = await fetchUsageJson(USAGE_URL, {
43707
43742
  authorization: `Bearer ${account.access}`,
43708
43743
  "anthropic-beta": "oauth-2025-04-20",
43709
43744
  "user-agent": usageUserAgent(),
@@ -44035,7 +44070,7 @@ function parseUsages(json, now) {
44035
44070
  return hourly || weekly ? { hourly, weekly, capturedAt: now } : null;
44036
44071
  }
44037
44072
  function getUsages(baseUrl, key) {
44038
- return fetchJson(`${baseUrl}/usages`, { authorization: `Bearer ${key}`, accept: "application/json" }, USAGE_HTTP_TIMEOUT_MS);
44073
+ return fetchUsageJson(`${baseUrl}/usages`, { authorization: `Bearer ${key}`, accept: "application/json" }, USAGE_HTTP_TIMEOUT_MS);
44039
44074
  }
44040
44075
  function usageFetcher(baseUrl) {
44041
44076
  return async (account, now) => parseUsages(await getUsages(baseUrl, account.access), now);
@@ -44309,7 +44344,7 @@ async function fetchUsage2(account, now) {
44309
44344
  const accountId = resolveAccountId(account);
44310
44345
  if (accountId)
44311
44346
  headers["chatgpt-account-id"] = accountId;
44312
- const json = await fetchJson(USAGE_URL2, headers, USAGE_HTTP_TIMEOUT_MS);
44347
+ const json = await fetchUsageJson(USAGE_URL2, headers, USAGE_HTTP_TIMEOUT_MS);
44313
44348
  const rateLimit = json?.rate_limit;
44314
44349
  if (!isPlainObject4(rateLimit))
44315
44350
  return null;
@@ -44398,7 +44433,7 @@ async function refreshUsage(adapters, now, poolSnapshot) {
44398
44433
  aliveIds?.add(account.id);
44399
44434
  if (account.disabledReason && account.disabledReason !== MANUAL_DISABLED_REASON)
44400
44435
  continue;
44401
- if (account.usage.capturedAt !== 0 && now - account.usage.capturedAt <= USAGE_REFRESH_TTL_MS)
44436
+ if (account.usage.capturedAt !== 0 && now - account.usage.capturedAt < USAGE_REFRESH_TTL_MS)
44402
44437
  continue;
44403
44438
  const adapter = adapterFor(adapters, account.providerID);
44404
44439
  if (!adapter)
@@ -44420,7 +44455,16 @@ async function refreshUsage(adapters, now, poolSnapshot) {
44420
44455
  await Promise.all(stale.map(async ({ adapter, account }) => {
44421
44456
  try {
44422
44457
  await ensureAccessToken(adapter, account, now);
44423
- const snapshot = await adapter.fetchUsage(account, now);
44458
+ const rejectedAccess = account.access;
44459
+ let snapshot;
44460
+ try {
44461
+ snapshot = await adapter.fetchUsage(account, now);
44462
+ } catch (error) {
44463
+ if (!(error instanceof UsageAuthenticationError) || !account.refresh)
44464
+ throw error;
44465
+ await ensureAccessToken(adapter, account, now, rejectedAccess);
44466
+ snapshot = await adapter.fetchUsage(account, now);
44467
+ }
44424
44468
  if (snapshot) {
44425
44469
  await mutatePool((p) => {
44426
44470
  const stored = findAccount(p, account.id);
@@ -44430,10 +44474,8 @@ async function refreshUsage(adapters, now, poolSnapshot) {
44430
44474
  weekly: snapshot.weekly === null ? stored.usage.weekly : preserveWeeklyAnchor(snapshot.weekly, stored.usage.weekly, now),
44431
44475
  capturedAt: snapshot.weekly === null ? stored.usage.capturedAt : now
44432
44476
  };
44433
- if (snapshot.weekly !== null && stored.cooldownUntil > now + MAX_TRANSIENT_COOLDOWN_MS && !isExhausted(stored, scoreCfg, now)) {
44434
- stored.cooldownUntil = 0;
44435
- delete stored.cooldownKind;
44436
- }
44477
+ if (snapshot.weekly !== null && stored.cooldownUntil === account.cooldownUntil && stored.cooldownKind === account.cooldownKind)
44478
+ reconcileQuotaCooldown(stored, scoreCfg, now);
44437
44479
  }
44438
44480
  });
44439
44481
  }
@@ -44446,16 +44488,17 @@ function refreshUsageInBackground(adapter, now, poolSnapshot) {
44446
44488
  function refreshAllUsageInBackground(now, poolSnapshot) {
44447
44489
  return refreshUsage(ADAPTERS, now, poolSnapshot);
44448
44490
  }
44449
- var USAGE_REFRESH_TTL_MS, MAX_TRANSIENT_COOLDOWN_MS, lastPoll;
44491
+ var USAGE_REFRESH_TTL_MS, lastPoll;
44450
44492
  var init_usage_refresh = __esm(() => {
44451
44493
  init_store();
44452
44494
  init_registry();
44495
+ init_usage_http();
44453
44496
  init_refresh();
44454
44497
  init_score_core();
44455
44498
  init_types2();
44456
44499
  init_usage_merge();
44500
+ init_usage_merge();
44457
44501
  USAGE_REFRESH_TTL_MS = 5 * 60 * 1000;
44458
- MAX_TRANSIENT_COOLDOWN_MS = 5 * 60 * 1000;
44459
44502
  lastPoll = new Map;
44460
44503
  });
44461
44504
 
@@ -46087,7 +46130,7 @@ function backoffFrom(res, now) {
46087
46130
  function isRateLimited(res) {
46088
46131
  return res.status === 429 || res.status === 403 && (res.headers.get("x-ratelimit-remaining") === "0" || res.headers.has("retry-after"));
46089
46132
  }
46090
- async function request(url, init, now) {
46133
+ async function request2(url, init, now) {
46091
46134
  let res;
46092
46135
  try {
46093
46136
  res = await globalThis.fetch(url, {
@@ -46139,7 +46182,7 @@ async function boundedText(res) {
46139
46182
  }
46140
46183
  return Buffer.concat(chunks).toString("utf8");
46141
46184
  }
46142
- async function readJson(res) {
46185
+ async function readJson2(res) {
46143
46186
  if (!res.ok)
46144
46187
  throw new SyncError("http");
46145
46188
  const text = await boundedText(res);
@@ -46154,7 +46197,7 @@ async function readJson(res) {
46154
46197
  return json;
46155
46198
  }
46156
46199
  async function createGist(token, content, now = Date.now()) {
46157
- const res = await request(API, {
46200
+ const res = await request2(API, {
46158
46201
  method: "POST",
46159
46202
  headers: { ...headers(token), "content-type": "application/json" },
46160
46203
  body: JSON.stringify({
@@ -46163,7 +46206,7 @@ async function createGist(token, content, now = Date.now()) {
46163
46206
  files: { [GIST_FILE]: { content } }
46164
46207
  })
46165
46208
  }, now);
46166
- const json = await readJson(res);
46209
+ const json = await readJson2(res);
46167
46210
  const owner = isPlainObject4(json.owner) ? json.owner.login : undefined;
46168
46211
  if (typeof json.id !== "string" || !GIST_ID_RE.test(json.id))
46169
46212
  throw new SyncError("http");
@@ -46175,7 +46218,7 @@ async function createGist(token, content, now = Date.now()) {
46175
46218
  async function updateGist(token, id, content, now = Date.now()) {
46176
46219
  let res;
46177
46220
  try {
46178
- res = await request(`${API}/${id}`, {
46221
+ res = await request2(`${API}/${id}`, {
46179
46222
  method: "PATCH",
46180
46223
  headers: { ...headers(token), "content-type": "application/json" },
46181
46224
  body: JSON.stringify({ files: { [GIST_FILE]: { content } } })
@@ -46187,10 +46230,10 @@ async function updateGist(token, id, content, now = Date.now()) {
46187
46230
  }
46188
46231
  if (res.status === 401 || res.status === 403)
46189
46232
  throw new SyncError("write-denied");
46190
- await readJson(res);
46233
+ await readJson2(res);
46191
46234
  }
46192
46235
  async function readGist(id, etag, now = Date.now(), token) {
46193
- const ask = (auth) => request(`${API}/${id}`, {
46236
+ const ask = (auth) => request2(`${API}/${id}`, {
46194
46237
  headers: {
46195
46238
  ...headers(auth),
46196
46239
  ...etag ? { "if-none-match": etag } : {}
@@ -46201,7 +46244,7 @@ async function readGist(id, etag, now = Date.now(), token) {
46201
46244
  res = await ask();
46202
46245
  if (res.status === 304)
46203
46246
  return { changed: false };
46204
- const json = await readJson(res);
46247
+ const json = await readJson2(res);
46205
46248
  const file = isPlainObject4(json.files) ? json.files[GIST_FILE] : undefined;
46206
46249
  if (!isPlainObject4(file) || typeof file.content !== "string")
46207
46250
  throw new SyncError("bad-blob");
@@ -47343,6 +47386,71 @@ var init_start = __esm(() => {
47343
47386
  init_state();
47344
47387
  });
47345
47388
 
47389
+ // src/usage-poll.ts
47390
+ import { readFile as readFile8 } from "node:fs/promises";
47391
+ async function nextPollAt(path) {
47392
+ try {
47393
+ const value = JSON.parse(await readFile8(path, "utf8"));
47394
+ return isPlainObject4(value) && isFiniteNumber(value.nextPollAt) ? value.nextPollAt : 0;
47395
+ } catch (error) {
47396
+ if (error instanceof SyntaxError || error instanceof Error && "code" in error && error.code === "ENOENT")
47397
+ return 0;
47398
+ throw error;
47399
+ }
47400
+ }
47401
+ function startUsagePoll(deps = {}) {
47402
+ const now = deps.now ?? Date.now;
47403
+ const refresh = deps.refresh ?? refreshAllUsageInBackground;
47404
+ const timers = deps.timers ?? realTimers;
47405
+ let disposed = false;
47406
+ let inflight;
47407
+ async function poll() {
47408
+ const path = `${poolFilePath()}.usage-poll.json`;
47409
+ try {
47410
+ await withLock(`${path}.lock`, {
47411
+ staleMs: 120000,
47412
+ timeoutMs: 0,
47413
+ retryMs: 25,
47414
+ heartbeatMs: 5000
47415
+ }, async () => {
47416
+ const at = now();
47417
+ if (at < await nextPollAt(path))
47418
+ return;
47419
+ await writeJsonAtomic(path, JSON.stringify({ nextPollAt: at + USAGE_REFRESH_TTL_MS }));
47420
+ await refresh(at);
47421
+ });
47422
+ } catch (error) {
47423
+ if (!(error instanceof LockTimeoutError))
47424
+ throw error;
47425
+ }
47426
+ }
47427
+ function tick() {
47428
+ if (disposed)
47429
+ return Promise.resolve();
47430
+ inflight ??= poll().finally(() => {
47431
+ inflight = undefined;
47432
+ });
47433
+ return inflight;
47434
+ }
47435
+ const timer = timers.set(() => void tick().catch(ignore), USAGE_REFRESH_TTL_MS);
47436
+ return {
47437
+ tick,
47438
+ async dispose() {
47439
+ disposed = true;
47440
+ timers.clear(timer);
47441
+ await inflight;
47442
+ }
47443
+ };
47444
+ }
47445
+ var init_usage_poll = __esm(() => {
47446
+ init_lock();
47447
+ init_paths();
47448
+ init_store();
47449
+ init_loop();
47450
+ init_usage_refresh();
47451
+ init_util4();
47452
+ });
47453
+
47346
47454
  // src/legacy.ts
47347
47455
  function zeroOutCost(provider) {
47348
47456
  for (const model of Object.values(provider.models)) {
@@ -47497,8 +47605,12 @@ function createProviderPlugin(adapter) {
47497
47605
  }
47498
47606
  var PROVIDER_LABELS, AnthropicLoadBalancerPlugin, OpenAILoadBalancerPlugin, KimiCodeLoadBalancerPlugin, KimiCodeGlobalLoadBalancerPlugin, lbResult = (output) => ({ title: "Auth Load Balancer", output }), AuthLoadBalancerStatusPlugin = async (input) => {
47499
47607
  const sync = startSync();
47608
+ const usage = startUsagePoll();
47500
47609
  return {
47501
- dispose: async () => sync?.dispose(),
47610
+ dispose: async () => {
47611
+ await usage.dispose();
47612
+ await sync?.dispose();
47613
+ },
47502
47614
  tool: {
47503
47615
  auth_lb_status: tool2({
47504
47616
  description: "Show the auth load-balancer pool: the in-use account per provider, each account's weekly and 5h usage, cooldowns, and the ranked next-candidate accounts. Refreshes stale usage from the provider usage endpoints (throttled) before rendering.",
@@ -47587,6 +47699,7 @@ var init_legacy = __esm(() => {
47587
47699
  init_start();
47588
47700
  init_token_mint();
47589
47701
  init_types2();
47702
+ init_usage_poll();
47590
47703
  init_usage_refresh();
47591
47704
  init_util4();
47592
47705
  PROVIDER_LABELS = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-auth-load-balancer",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Load-balance opencode requests across multiple Anthropic (Claude) and OpenAI (Codex) OAuth accounts and Kimi Code subscriptions, weighted primarily by weekly usage with drain-before-reset scheduling.",
5
5
  "type": "module",
6
6
  "license": "MIT",