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.
@@ -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
- * Best-effort: seed/refresh usage for accounts whose WEEKLY snapshot is missing or
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
- * `poolSnapshot` lets the request hot path reuse the pool it JUST read for
24
- * account selection instead of paying a second serialized file read +
25
- * JSON.parse per request. The snapshot is only consulted for the staleness
26
- * gates below; the actual usage write still goes through `mutatePool` (which
27
- * re-reads under the lock), so a slightly stale snapshot is harmless. Callers
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 two consumers that accept arbitrary JSON
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`), and the incoming `system`
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`). NOTE:
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.2.2",
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.17.13"
44
+ "@opencode-ai/plugin": ">=1.18.26"
45
45
  },
46
46
  "devDependencies": {
47
- "@opencode-ai/plugin": "^1.17.13",
48
- "@opentui/core": "0.4.2",
49
- "@opentui/solid": "0.4.2",
50
- "@types/bun": "^1.3.14",
51
- "@types/node": "^26.1.0",
52
- "eslint-plugin-devup": "^2.0.19",
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.72",
55
- "solid-js": "1.9.14",
56
- "typescript": "^6.0.3"
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<string, { accountId?: string }>
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
- // and FLOOR the day figure (36h is "1d", not "2d") so the TUI bar's day/minute
311
- // rounding can never disagree with the CLI/tool dashboard's. NOTE: this is
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` (e.g. "3h25m"), this renders just
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 < 60) return `${mins}m`
445
+ if (mins <= 120) return `${mins}m`
318
446
  const hrs = Math.floor(mins / 60)
319
- if (hrs < 24) return `${hrs}h`
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) return 're-login'
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),