opencode-auth-load-balancer 0.2.2 → 0.4.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 +47 -7
- package/dist/fetch.d.ts +13 -1
- package/dist/index.js +2958 -2072
- package/dist/notify.d.ts +8 -0
- package/dist/pending/coordinator.d.ts +135 -0
- package/dist/pending/lease.d.ts +3 -0
- package/dist/pending/recovery.d.ts +16 -0
- package/dist/pending/store.d.ts +12 -0
- package/dist/pending/types.d.ts +23 -0
- package/dist/pool/lock.d.ts +1 -2
- package/dist/pool/paths.d.ts +11 -0
- package/dist/providers/anthropic/constants.d.ts +55 -10
- package/dist/providers/anthropic/fallback.d.ts +3 -1
- package/dist/providers/anthropic/version.d.ts +29 -0
- package/dist/providers/openai/usage.d.ts +14 -8
- package/dist/providers/registry.d.ts +26 -0
- package/dist/providers/types.d.ts +23 -6
- package/dist/providers/usage-http.d.ts +7 -6
- package/dist/scheduler/select.d.ts +5 -2
- package/dist/session.d.ts +2 -0
- package/dist/status.d.ts +4 -1
- package/dist/types.d.ts +58 -1
- package/dist/usage-refresh.d.ts +35 -16
- package/dist/util.d.ts +4 -3
- package/package.json +11 -11
- package/tui/auth-load-balancer-tui.logic.ts +139 -8
- package/tui/auth-load-balancer-tui.view.tsx +284 -42
package/dist/usage-refresh.d.ts
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import type { ProviderAdapter } from './providers/types';
|
|
2
2
|
import type { PoolFile } from './types';
|
|
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;
|
|
3
16
|
/**
|
|
4
17
|
* Test-only window into the module-private `lastPoll` throttle map. Never
|
|
5
18
|
* imported by production code — only by usage-refresh tests that need to
|
|
@@ -9,22 +22,28 @@ import type { PoolFile } from './types';
|
|
|
9
22
|
*/
|
|
10
23
|
export declare function _lastPollIdsForTests(): Set<string>;
|
|
11
24
|
/**
|
|
12
|
-
*
|
|
13
|
-
* stale (`capturedAt` is stamped only when a weekly window arrives — see
|
|
14
|
-
* `applyUsagePartial` in fetch.ts), via the provider's dedicated usage endpoint.
|
|
15
|
-
* Throttled per account (at most one poll per account per SEED_TTL_MS). Callers
|
|
16
|
-
* invoke it fire-and-forget, so it adds no latency to the request path; failures
|
|
17
|
-
* are swallowed — response headers remain the primary, always-fresh usage signal
|
|
18
|
-
* and this fixes cold-start blindness AND out-of-band server-side resets (e.g. a
|
|
19
|
-
* promotional weekly-quota reset) that response headers alone don't converge.
|
|
20
|
-
*
|
|
21
|
-
* Returns a promise (for tests / explicit awaiting); request-path callers ignore it.
|
|
25
|
+
* Refresh stale usage for ONE provider's accounts (see `refreshUsage`).
|
|
22
26
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* without a pool in hand (e.g. the startup seed in index.ts) omit it.
|
|
27
|
+
* Used by the flows that are inherently scoped to a single provider: the
|
|
28
|
+
* OAuth login callback (only the just-added account matters) and each
|
|
29
|
+
* provider plugin's startup seed, which awaits ITS provider's usage before
|
|
30
|
+
* priming that provider's in-use marker. Ambient callers must NOT use this —
|
|
31
|
+
* see `refreshAllUsageInBackground`.
|
|
29
32
|
*/
|
|
30
33
|
export declare function refreshUsageInBackground(adapter: ProviderAdapter, now: number, poolSnapshot?: PoolFile): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Refresh stale usage across EVERY registered provider (see `refreshUsage`).
|
|
36
|
+
*
|
|
37
|
+
* This is what the ambient callers — the request hot path and the
|
|
38
|
+
* `auth_lb_status` dashboard — use, and the distinction is load-bearing:
|
|
39
|
+
* usage converges to server-side truth from response headers (only for the
|
|
40
|
+
* provider you are actually sending requests to) or from a usage-endpoint
|
|
41
|
+
* poll. Scoping that poll to the requesting provider left an IDLE provider
|
|
42
|
+
* with no refresh cycle whatsoever after its one startup seed, so working all
|
|
43
|
+
* day in Claude froze the Codex numbers in the dashboard/TUI at whatever they
|
|
44
|
+
* were when opencode launched (and vice versa). Refreshing the whole registry
|
|
45
|
+
* instead costs nothing in the steady state — the per-account staleness gate
|
|
46
|
+
* and `lastPoll` throttle inside `refreshUsage` are unchanged, so an idle
|
|
47
|
+
* provider is polled at most once per SEED_TTL_MS and a fresh one not at all.
|
|
48
|
+
*/
|
|
49
|
+
export declare function refreshAllUsageInBackground(now: number, poolSnapshot?: PoolFile): Promise<void>;
|
package/dist/util.d.ts
CHANGED
|
@@ -30,11 +30,12 @@ export declare function clamp01(n: number): number;
|
|
|
30
30
|
export declare function isFiniteNumber(v: unknown): v is number;
|
|
31
31
|
/**
|
|
32
32
|
* True for a plain JSON object (non-null `object`, not an array). Shared
|
|
33
|
-
* trust-boundary predicate for the
|
|
33
|
+
* trust-boundary predicate for the consumers that accept arbitrary JSON
|
|
34
34
|
* shapes: the user-editable pool file's `lastSelected` / `sessions`
|
|
35
|
-
* containers and session rows (`pool/store.ts`),
|
|
35
|
+
* containers and session rows (`pool/store.ts`), the incoming `system`
|
|
36
36
|
* prompt blocks in the Anthropic request transform
|
|
37
|
-
* (`providers/anthropic/transform.ts`)
|
|
37
|
+
* (`providers/anthropic/transform.ts`), and the Codex `/wham/usage`
|
|
38
|
+
* `rate_limit` envelope (`providers/openai/usage.ts`). NOTE:
|
|
38
39
|
* `tui/auth-load-balancer-tui.logic.ts` ships its OWN `isPlainRecordValue`
|
|
39
40
|
* copy by design — the TUI runtime cannot import `src/`.
|
|
40
41
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-auth-load-balancer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Load-balance opencode requests across multiple Anthropic (Claude) and OpenAI (Codex) OAuth accounts, weighted primarily by weekly usage with drain-before-reset scheduling.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,19 +41,19 @@
|
|
|
41
41
|
"prepare": "husky"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@opencode-ai/plugin": ">=1.
|
|
44
|
+
"@opencode-ai/plugin": ">=1.18.26"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@opencode-ai/plugin": "^1.
|
|
48
|
-
"@opentui/core": "0.
|
|
49
|
-
"@opentui/solid": "0.
|
|
50
|
-
"@types/bun": "^1.
|
|
51
|
-
"@types/node": "^26.1
|
|
52
|
-
"eslint-plugin-devup": "^2.0
|
|
47
|
+
"@opencode-ai/plugin": "^1.18.26",
|
|
48
|
+
"@opentui/core": "0.5.10",
|
|
49
|
+
"@opentui/solid": "0.5.10",
|
|
50
|
+
"@types/bun": "^1.4.0",
|
|
51
|
+
"@types/node": "^26.4.1",
|
|
52
|
+
"eslint-plugin-devup": "^2.1.0",
|
|
53
53
|
"husky": "^9.1",
|
|
54
|
-
"oxlint": "1.
|
|
55
|
-
"solid-js": "1.9.
|
|
56
|
-
"typescript": "^
|
|
54
|
+
"oxlint": "1.81",
|
|
55
|
+
"solid-js": "1.9.15",
|
|
56
|
+
"typescript": "^7.0.2"
|
|
57
57
|
},
|
|
58
58
|
"keywords": [
|
|
59
59
|
"opencode",
|
|
@@ -66,8 +66,12 @@ export interface PoolAccount {
|
|
|
66
66
|
/** Loosely-typed view of the on-disk pool JSON (one shape for reads AND read-modify-writes). */
|
|
67
67
|
export interface PoolShape {
|
|
68
68
|
accounts?: PoolAccount[]
|
|
69
|
+
relogin?: { accountId?: string; providerID?: string; expiresAt?: number }
|
|
69
70
|
lastSelected?: Record<string, string>
|
|
70
|
-
sessions?: Record<
|
|
71
|
+
sessions?: Record<
|
|
72
|
+
string,
|
|
73
|
+
{ accountId?: string; fallback?: { from?: string; to?: string } }
|
|
74
|
+
>
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
/**
|
|
@@ -222,6 +226,70 @@ export function mutatePoolFile(
|
|
|
222
226
|
}
|
|
223
227
|
}
|
|
224
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Sentinel `disabledReason` the manual disable toggle writes — the TUI copy of
|
|
231
|
+
* `src/types.ts`'s `MANUAL_DISABLED_REASON` (the TUI runtime cannot import
|
|
232
|
+
* `src/`; see the `isPlainRecordValue`/`isFiniteNumber` NOTEs above for the
|
|
233
|
+
* same trust boundary). Kept BYTE-IDENTICAL to the server constant so a manual
|
|
234
|
+
* disable written here reads back as `disabled` (not `re-login`) everywhere.
|
|
235
|
+
*/
|
|
236
|
+
export const MANUAL_DISABLED_REASON = 'manually disabled'
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* NOTE: byte-identical copy of `src/types.ts`'s `RELOGIN_TTL_MS`, kept in sync
|
|
240
|
+
* by hand because the TUI runtime cannot import `src/`; see the
|
|
241
|
+
* `MANUAL_DISABLED_REASON` NOTE above for the same trust boundary.
|
|
242
|
+
*/
|
|
243
|
+
export const RELOGIN_TTL_MS = 600_000
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* `now` DEFAULTS to the wall clock so the view can fire this without reading
|
|
247
|
+
* the clock itself: the call sits in an async re-login handler, and a bare
|
|
248
|
+
* `Date.now()` written inside a component body is flagged by `react(purity)`
|
|
249
|
+
* even on a non-render path the rule cannot distinguish. Tests still pass an
|
|
250
|
+
* explicit `now` — stamping the TTL from an injectable instant is what keeps
|
|
251
|
+
* the expiry assertion deterministic.
|
|
252
|
+
*/
|
|
253
|
+
export function setReloginTargetInPool(
|
|
254
|
+
accountId: string,
|
|
255
|
+
providerID: string,
|
|
256
|
+
now: number = Date.now(),
|
|
257
|
+
path: string = POOL_FILE,
|
|
258
|
+
): void {
|
|
259
|
+
mutatePoolFile((pool) => {
|
|
260
|
+
pool.relogin = {
|
|
261
|
+
accountId,
|
|
262
|
+
providerID,
|
|
263
|
+
expiresAt: now + RELOGIN_TTL_MS,
|
|
264
|
+
}
|
|
265
|
+
}, path)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function clearReloginTargetInPool(path: string = POOL_FILE): void {
|
|
269
|
+
mutatePoolFile((pool) => {
|
|
270
|
+
delete pool.relogin
|
|
271
|
+
}, path)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Resolve the provider OAuth method by label instead of hardcoding an index:
|
|
276
|
+
* opencode aggregates auth methods from every plugin registered for a provider,
|
|
277
|
+
* so this plugin's position is not stable. Prefer its pooled-login label, then
|
|
278
|
+
* fall back to the provider's first OAuth method when no such label is present.
|
|
279
|
+
*/
|
|
280
|
+
export function pickAuthMethodIndex(
|
|
281
|
+
methods: readonly { type?: string; label?: string }[] | undefined,
|
|
282
|
+
): number | null {
|
|
283
|
+
const pooled = methods?.findIndex(
|
|
284
|
+
(method) =>
|
|
285
|
+
method.type === 'oauth' &&
|
|
286
|
+
method.label?.toLowerCase().includes('load balancer'),
|
|
287
|
+
)
|
|
288
|
+
if (pooled !== undefined && pooled >= 0) return pooled
|
|
289
|
+
const oauth = methods?.findIndex((method) => method.type === 'oauth')
|
|
290
|
+
return oauth !== undefined && oauth >= 0 ? oauth : null
|
|
291
|
+
}
|
|
292
|
+
|
|
225
293
|
export function renameInPool(
|
|
226
294
|
id: string,
|
|
227
295
|
label: string,
|
|
@@ -248,6 +316,23 @@ export function deleteFromPool(id: string, path: string = POOL_FILE): void {
|
|
|
248
316
|
}, path)
|
|
249
317
|
}
|
|
250
318
|
|
|
319
|
+
/**
|
|
320
|
+
* Toggle an account's MANUAL disable flag: `disabled` writes the
|
|
321
|
+
* `MANUAL_DISABLED_REASON` sentinel (the scheduler then skips it, exactly like
|
|
322
|
+
* the server's `isAvailable`), `!disabled` clears it. Reversible, so — unlike
|
|
323
|
+
* Delete — the sidebar calls this straight through with no confirm dialog.
|
|
324
|
+
*/
|
|
325
|
+
export function setDisabledInPool(
|
|
326
|
+
id: string,
|
|
327
|
+
disabled: boolean,
|
|
328
|
+
path: string = POOL_FILE,
|
|
329
|
+
): void {
|
|
330
|
+
mutatePoolFile((pool) => {
|
|
331
|
+
const acct = (pool.accounts ?? []).find((a) => a.id === id)
|
|
332
|
+
if (acct) acct.disabledReason = disabled ? MANUAL_DISABLED_REASON : null
|
|
333
|
+
}, path)
|
|
334
|
+
}
|
|
335
|
+
|
|
251
336
|
/**
|
|
252
337
|
* Normalize a raw (loosely-typed) pool account into the strict shape the shared
|
|
253
338
|
* scorer reads. The pool file is user-editable and read RAW here (the server
|
|
@@ -284,6 +369,48 @@ export function toScore(a: PoolAccount): ScoreAccount {
|
|
|
284
369
|
}
|
|
285
370
|
}
|
|
286
371
|
|
|
372
|
+
/**
|
|
373
|
+
* The account the CURRENT opencode session is pinned to for a provider — i.e.
|
|
374
|
+
* what THIS session is actually using — read from the pool's session map. The
|
|
375
|
+
* key is `<providerID>:s:<sessionID>`, exactly what `deriveSessionKey` +
|
|
376
|
+
* `recordSuccess` (src/fetch.ts) write on each served request. Returns
|
|
377
|
+
* `undefined` when there is no session (home screen) or the session has not yet
|
|
378
|
+
* pinned an account for this provider. The in-use (▶) marker uses this so it
|
|
379
|
+
* reflects the VIEWER's OWN session, not the global `lastSelected` (whichever
|
|
380
|
+
* session served most recently across the whole pool — misleading while several
|
|
381
|
+
* sessions run at once).
|
|
382
|
+
*/
|
|
383
|
+
export function sessionAccountId(
|
|
384
|
+
pool: PoolShape,
|
|
385
|
+
providerID: string,
|
|
386
|
+
sessionId: string | undefined,
|
|
387
|
+
): string | undefined {
|
|
388
|
+
if (!sessionId) return undefined
|
|
389
|
+
return pool.sessions?.[`${providerID}:s:${sessionId}`]?.accountId
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The active model downgrade for THIS session on `providerID`, or `undefined`
|
|
394
|
+
* when the session's latest request ran on the model it asked for (or there is
|
|
395
|
+
* no session). `{ from, to }` are the raw model ids (requested → served) the
|
|
396
|
+
* server recorded in {@link SessionAssignment.fallback}. The bottom bar shows it
|
|
397
|
+
* so a session silently running on a fallback model is visible even after the
|
|
398
|
+
* transient switch toast is gone. Both fields are re-validated as strings (a
|
|
399
|
+
* hand-edited pool could hold anything) — a malformed `fallback` reads as "no
|
|
400
|
+
* downgrade" rather than rendering `undefined`.
|
|
401
|
+
*/
|
|
402
|
+
export function sessionFallback(
|
|
403
|
+
pool: PoolShape,
|
|
404
|
+
providerID: string,
|
|
405
|
+
sessionId: string | undefined,
|
|
406
|
+
): { from: string; to: string } | undefined {
|
|
407
|
+
if (!sessionId) return undefined
|
|
408
|
+
const fb = pool.sessions?.[`${providerID}:s:${sessionId}`]?.fallback
|
|
409
|
+
if (!fb || typeof fb.from !== 'string' || typeof fb.to !== 'string')
|
|
410
|
+
return undefined
|
|
411
|
+
return { from: fb.from, to: fb.to }
|
|
412
|
+
}
|
|
413
|
+
|
|
287
414
|
/**
|
|
288
415
|
* Byte-deterministic ASCII string comparator (never `localeCompare` — keeps
|
|
289
416
|
* tier/provider ordering byte-identical regardless of host locale). Ships its
|
|
@@ -307,16 +434,17 @@ export function until(resetAt: number | undefined, now: number): string {
|
|
|
307
434
|
if (!isFiniteNumber(resetAt) || resetAt <= now) return '-'
|
|
308
435
|
// Mirror the server's `relTime` (src/status.ts) semantics: floor minutes at 1
|
|
309
436
|
// (a sub-30s future reset must not render "0m" — '-' is reserved for elapsed)
|
|
310
|
-
//
|
|
311
|
-
// rounding can never disagree with the
|
|
437
|
+
// keep <=48h as hours (47h is "47h", not "1d") before FLOORing the day
|
|
438
|
+
// figure so the TUI bar's day/minute rounding can never disagree with the
|
|
439
|
+
// CLI/tool dashboard's. NOTE: this is
|
|
312
440
|
// coarser than the CLI within the hour band on purpose (bar-width budget) —
|
|
313
|
-
// the CLI renders `${hrs}h${mins % 60}m`
|
|
314
|
-
// `${hrs}h` (e.g. "3h"). Same day-level bucket, less precision inside it —
|
|
441
|
+
// after the <=120m minute band, the CLI renders `${hrs}h${mins % 60}m`
|
|
442
|
+
// (e.g. "3h25m"), this renders just `${hrs}h` (e.g. "3h"). Same day-level bucket, less precision inside it —
|
|
315
443
|
// not byte-identical output.
|
|
316
444
|
const mins = Math.max(1, Math.round((resetAt - now) / 60_000))
|
|
317
|
-
if (mins
|
|
445
|
+
if (mins <= 120) return `${mins}m`
|
|
318
446
|
const hrs = Math.floor(mins / 60)
|
|
319
|
-
if (
|
|
447
|
+
if (mins <= 48 * 60) return `${hrs}h`
|
|
320
448
|
return `${Math.floor(hrs / 24)}d`
|
|
321
449
|
}
|
|
322
450
|
/** A window's utilization for the bar: "-" when absent, "0%" once it has reset (stale value dropped). */
|
|
@@ -351,7 +479,10 @@ export function stateOf(
|
|
|
351
479
|
tiers: [string, number][],
|
|
352
480
|
now: number,
|
|
353
481
|
): string {
|
|
354
|
-
if (sa.disabledReason)
|
|
482
|
+
if (sa.disabledReason)
|
|
483
|
+
return sa.disabledReason === MANUAL_DISABLED_REASON
|
|
484
|
+
? 'disabled'
|
|
485
|
+
: 're-login'
|
|
355
486
|
if (sa.cooldownUntil > now) return `cooldown ${until(sa.cooldownUntil, now)}`
|
|
356
487
|
if (isExhausted(sa, cfg, now)) return 'full'
|
|
357
488
|
// A model-tier limit keeps the account usable (other models + downgrade),
|