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.
@@ -48,6 +48,14 @@ export interface ScoreWindow {
48
48
  resetAt: number
49
49
  }
50
50
 
51
+ /** Codex credits, which keep an account serving once its plan windows are exhausted. */
52
+ export interface ScoreCredits {
53
+ hasCredits: boolean
54
+ unlimited: boolean
55
+ /** The credit spending cap is hit: the balance cannot be spent. */
56
+ limitReached?: boolean
57
+ }
58
+
51
59
  /**
52
60
  * The slice of an account the scoring reads. The server's full `PoolAccount` and the
53
61
  * TUI's parsed-pool account both satisfy this (the TUI normalizes its looser JSON into
@@ -58,6 +66,7 @@ export interface ScoreAccount {
58
66
  hourly: ScoreWindow | null
59
67
  weekly: ScoreWindow | null
60
68
  }
69
+ credits?: ScoreCredits
61
70
  cooldownUntil: number
62
71
  disabledReason: string | null
63
72
  }
@@ -200,7 +209,30 @@ export function isExhausted(
200
209
  return maxUtil(account, now) >= cfg.exhaustedAt
201
210
  }
202
211
 
203
- /** Available = not disabled, not in cooldown, and not exhausted. */
212
+ /**
213
+ * Whether credits keep the account serving past exhausted plan windows. Same test as
214
+ * Codex itself (`unlimited || has_credits`, the backend then bills the credits), unless
215
+ * the account's credit spending cap is hit.
216
+ */
217
+ export function hasCreditHeadroom(account: ScoreAccount): boolean {
218
+ const credits = account.credits
219
+ return (
220
+ !!credits &&
221
+ !credits.limitReached &&
222
+ (credits.unlimited || credits.hasCredits)
223
+ )
224
+ }
225
+
226
+ /** Plan headroom, or credits to spend once the plan is exhausted. */
227
+ export function hasHeadroom(
228
+ account: ScoreAccount,
229
+ cfg: ScoreConfig,
230
+ now: number,
231
+ ): boolean {
232
+ return !isExhausted(account, cfg, now) || hasCreditHeadroom(account)
233
+ }
234
+
235
+ /** Available = not disabled, not in cooldown, and with plan or credit headroom. */
204
236
  export function isAvailable(
205
237
  account: ScoreAccount,
206
238
  cfg: ScoreConfig,
@@ -208,8 +240,7 @@ export function isAvailable(
208
240
  ): boolean {
209
241
  if (account.disabledReason) return false
210
242
  if (account.cooldownUntil > now) return false
211
- if (isExhausted(account, cfg, now)) return false
212
- return true
243
+ return hasHeadroom(account, cfg, now)
213
244
  }
214
245
 
215
246
  /**
@@ -325,20 +356,28 @@ export function scoreAccount(
325
356
  * Sort comparator shared by `src/status.ts` and the TUI sidebar ranking pipeline, so the
326
357
  * dashboard's *ordering* rule can never silently drift from what the server displays —
327
358
  * the same single-source guarantee `scoreAccount` provides for the *formula*. Both
328
- * pipelines map each account into `{ available, score, weeklyUtil }` (the per-pipeline
329
- * `weeklyUtil` field is computed locally — `src/status.ts` uses the raw stored value to
330
- * keep tie-breaking deterministic for cooling-down accounts, the TUI uses `utilOf` to
331
- * discard a window that already reset) and pass that shape here:
359
+ * pipelines map each account into `{ available, onCredits, score, weeklyUtil }` (the
360
+ * per-pipeline `weeklyUtil` field is computed locally — `src/status.ts` uses the raw
361
+ * stored value to keep tie-breaking deterministic for cooling-down accounts, the TUI uses
362
+ * `utilOf` to discard a window that already reset) and pass that shape here:
332
363
  *
333
364
  * - available accounts come first (they're the only ones the scheduler will pick);
334
- * - among available accounts, higher `score` wins (matches `selectAccount`'s pick);
365
+ * - among available accounts, plan headroom comes before serving on credits alone
366
+ * (`onCredits`: credits cost money), then higher `score` wins (matches
367
+ * `selectAccount`'s pick);
335
368
  * - among unavailable accounts, lower `weeklyUtil` wins — the "least bad cooling-down
336
369
  * account" surfaces first so a forced fall-through stays predictable.
337
370
  */
338
371
  export function compareRanked<
339
- T extends { available: boolean; score: number; weeklyUtil: number },
372
+ T extends {
373
+ available: boolean
374
+ onCredits: boolean
375
+ score: number
376
+ weeklyUtil: number
377
+ },
340
378
  >(x: T, y: T): number {
341
379
  if (x.available !== y.available) return x.available ? -1 : 1
342
- if (x.available) return y.score - x.score
343
- return x.weeklyUtil - y.weeklyUtil
380
+ if (!x.available) return x.weeklyUtil - y.weeklyUtil
381
+ if (x.onCredits !== y.onCredits) return x.onCredits ? 1 : -1
382
+ return y.score - x.score
344
383
  }
@@ -18,6 +18,7 @@ import {
18
18
  isExhausted,
19
19
  loadScoreConfig,
20
20
  type ScoreAccount,
21
+ type ScoreCredits,
21
22
  type ScoreWindow,
22
23
  } from './auth-load-balancer-scoring'
23
24
 
@@ -51,11 +52,18 @@ export interface UsageWindow {
51
52
  utilization?: number
52
53
  resetAt?: number
53
54
  }
55
+ export interface UsageCredits {
56
+ hasCredits?: boolean
57
+ unlimited?: boolean
58
+ balance?: number
59
+ limitReached?: boolean
60
+ }
54
61
  export interface PoolAccount {
55
62
  id: string
56
63
  providerID: string
57
64
  label: string
58
65
  usage?: { weekly?: UsageWindow | null; hourly?: UsageWindow | null } | null
66
+ credits?: UsageCredits | null
59
67
  cooldownUntil?: number
60
68
  /** tier name → epoch ms until that model tier's cap resets (tier requests steer around the account until then). */
61
69
  modelCooldownsUntil?: Record<string, number> | null
@@ -445,17 +453,56 @@ export function toScoreWindow(
445
453
  }
446
454
  : null
447
455
  }
456
+ /** The raw credits when both flags are booleans (as the server's store requires), else undefined. */
457
+ function creditsOf(
458
+ a: PoolAccount,
459
+ ): (ScoreCredits & { balance?: number }) | undefined {
460
+ const c = a.credits
461
+ if (
462
+ !isPlainRecordValue(c) ||
463
+ typeof c?.hasCredits !== 'boolean' ||
464
+ typeof c.unlimited !== 'boolean'
465
+ )
466
+ return undefined
467
+ return {
468
+ hasCredits: c.hasCredits,
469
+ unlimited: c.unlimited,
470
+ ...(isFiniteNumber(c.balance) ? { balance: c.balance } : {}),
471
+ ...(c.limitReached === true ? { limitReached: true } : {}),
472
+ }
473
+ }
474
+
448
475
  export function toScore(a: PoolAccount): ScoreAccount {
476
+ const credits = creditsOf(a)
449
477
  return {
450
478
  usage: {
451
479
  hourly: toScoreWindow(a.usage?.hourly),
452
480
  weekly: toScoreWindow(a.usage?.weekly),
453
481
  },
482
+ ...(credits ? { credits } : {}),
454
483
  cooldownUntil: isFiniteNumber(a.cooldownUntil) ? a.cooldownUntil : 0,
455
484
  disabledReason: a.disabledReason ?? null,
456
485
  }
457
486
  }
458
487
 
488
+ /**
489
+ * The credits an exhausted account has left: `credits 12.5`, `credits
490
+ * unlimited`, `credits available` (no amount reported), `no credits`,
491
+ * `credit limit reached` (a spending cap stops them); '' when the provider
492
+ * never reported credits. NOTE: copy of `src/status.ts`'s `creditsText` (the
493
+ * TUI runtime cannot import `src/`).
494
+ */
495
+ export function creditsText(a: PoolAccount): string {
496
+ const credits = creditsOf(a)
497
+ if (!credits) return ''
498
+ if (credits.limitReached) return 'credit limit reached'
499
+ if (credits.unlimited) return 'credits unlimited'
500
+ if (!credits.hasCredits) return 'no credits'
501
+ return credits.balance === undefined
502
+ ? 'credits available'
503
+ : `credits ${Math.round(credits.balance * 100) / 100}`
504
+ }
505
+
459
506
  /**
460
507
  * The account the CURRENT opencode session is pinned to for a provider — i.e.
461
508
  * what THIS session is actually using — read from the pool's session map. The
@@ -565,13 +612,14 @@ export function stateOf(
565
612
  sa: ScoreAccount,
566
613
  tiers: [string, number][],
567
614
  now: number,
615
+ credits = '',
568
616
  ): string {
569
617
  if (sa.disabledReason)
570
618
  return sa.disabledReason === MANUAL_DISABLED_REASON
571
619
  ? 'disabled'
572
620
  : 're-login'
573
621
  if (sa.cooldownUntil > now) return `cooldown ${until(sa.cooldownUntil, now)}`
574
- if (isExhausted(sa, cfg, now)) return 'full'
622
+ if (isExhausted(sa, cfg, now)) return credits ? `full · ${credits}` : 'full'
575
623
  // A model-tier limit keeps the account usable (other models + downgrade),
576
624
  // so it annotates rather than sidelines — mirrors src/status.ts.
577
625
  if (tiers.length > 0)
@@ -27,6 +27,7 @@ import {
27
27
  compareRanked,
28
28
  displayUtil,
29
29
  isAvailable,
30
+ isExhausted,
30
31
  scoreAccount,
31
32
  utilOf,
32
33
  } from './auth-load-balancer-scoring'
@@ -37,6 +38,7 @@ import {
37
38
  clearReloginTargetInPool,
38
39
  compareAscii,
39
40
  credentialTag,
41
+ creditsText,
40
42
  deleteFromPool,
41
43
  discardSyncIntent,
42
44
  hasLostLogin,
@@ -279,6 +281,7 @@ function SidebarPanel(props: {
279
281
  a,
280
282
  sa,
281
283
  available,
284
+ onCredits: available && isExhausted(sa, cfg, now),
282
285
  score: available
283
286
  ? scoreAccount(sa, cfg, now)
284
287
  : Number.NEGATIVE_INFINITY,
@@ -308,7 +311,10 @@ function SidebarPanel(props: {
308
311
  // Raw reads are finiteness-guarded inside tierResets: a hand-edited
309
312
  // `1e999` entry (Infinity via JSON.parse) would otherwise render its
310
313
  // tier annotation forever until the server heals the file.
311
- state: [stateOf(sa, tierResets(a, now), now), ...rowWarnings(a, now)]
314
+ state: [
315
+ stateOf(sa, tierResets(a, now), now, creditsText(a)),
316
+ ...rowWarnings(a, now),
317
+ ]
312
318
  .filter(Boolean)
313
319
  .join(' · '),
314
320
  manuallyDisabled: a.disabledReason === MANUAL_DISABLED_REASON,