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
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
|
329
|
-
* `weeklyUtil` field is computed locally — `src/status.ts` uses the raw
|
|
330
|
-
* keep tie-breaking deterministic for cooling-down accounts, the TUI uses
|
|
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,
|
|
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 {
|
|
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
|
|
343
|
-
|
|
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: [
|
|
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,
|