opencode-auth-load-balancer 0.2.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +289 -0
  3. package/dist/accounts.d.ts +20 -0
  4. package/dist/cli/status.d.ts +2 -0
  5. package/dist/fetch.d.ts +34 -0
  6. package/dist/index.d.ts +10 -0
  7. package/dist/index.js +14829 -0
  8. package/dist/notify.d.ts +47 -0
  9. package/dist/pool/lock.d.ts +39 -0
  10. package/dist/pool/paths.d.ts +16 -0
  11. package/dist/pool/store.d.ts +51 -0
  12. package/dist/prime.d.ts +32 -0
  13. package/dist/providers/anthropic/adapter.d.ts +8 -0
  14. package/dist/providers/anthropic/cch.d.ts +20 -0
  15. package/dist/providers/anthropic/constants.d.ts +64 -0
  16. package/dist/providers/anthropic/fallback.d.ts +52 -0
  17. package/dist/providers/anthropic/oauth.d.ts +8 -0
  18. package/dist/providers/anthropic/transform.d.ts +23 -0
  19. package/dist/providers/anthropic/usage.d.ts +14 -0
  20. package/dist/providers/headers.d.ts +7 -0
  21. package/dist/providers/http-timeouts.d.ts +32 -0
  22. package/dist/providers/oauth-callback.d.ts +100 -0
  23. package/dist/providers/openai/adapter.d.ts +9 -0
  24. package/dist/providers/openai/constants.d.ts +18 -0
  25. package/dist/providers/openai/jwt.d.ts +5 -0
  26. package/dist/providers/openai/oauth.d.ts +12 -0
  27. package/dist/providers/openai/transform.d.ts +18 -0
  28. package/dist/providers/openai/usage.d.ts +16 -0
  29. package/dist/providers/pkce.d.ts +10 -0
  30. package/dist/providers/types.d.ts +122 -0
  31. package/dist/providers/usage-headers.d.ts +55 -0
  32. package/dist/providers/usage-http.d.ts +8 -0
  33. package/dist/refresh.d.ts +13 -0
  34. package/dist/scheduler/config.d.ts +46 -0
  35. package/dist/scheduler/score-core.d.ts +167 -0
  36. package/dist/scheduler/select.d.ts +41 -0
  37. package/dist/session.d.ts +15 -0
  38. package/dist/status.d.ts +55 -0
  39. package/dist/types.d.ts +101 -0
  40. package/dist/usage-merge.d.ts +23 -0
  41. package/dist/usage-refresh.d.ts +30 -0
  42. package/dist/util.d.ts +118 -0
  43. package/package.json +75 -0
  44. package/tui/auth-load-balancer-scoring.ts +344 -0
  45. package/tui/auth-load-balancer-tui.logic.ts +362 -0
  46. package/tui/auth-load-balancer-tui.ts +39 -0
  47. package/tui/auth-load-balancer-tui.view.tsx +327 -0
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Pure, non-JSX pool-file logic for the load balancer's TUI surfaces: resolving
3
+ * the pool-file path, reading/normalizing the on-disk pool file, the atomic
4
+ * rename/delete mutations reachable from the sidebar menu, and the
5
+ * display-formatting helpers (`pct`/`until`/`winPct`/`tierResets`/`stateOf`) both
6
+ * `app_bottom` and `sidebar_content` render with. Split out of
7
+ * `auth-load-balancer-tui.view.tsx` (which imports these unchanged) so this
8
+ * logic — which needs no SolidJS/@opentui/JSX at all — is directly
9
+ * unit-testable exactly like its server-side analogue, `src/pool/store.ts`,
10
+ * already is.
11
+ */
12
+ import { readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
13
+ import { homedir } from 'node:os'
14
+ import { join } from 'node:path'
15
+
16
+ import {
17
+ displayUtil,
18
+ isExhausted,
19
+ loadScoreConfig,
20
+ type ScoreAccount,
21
+ type ScoreWindow,
22
+ } from './auth-load-balancer-scoring'
23
+
24
+ // Scoring knobs read from env exactly like the server — single-sourced in the shared
25
+ // ./auth-load-balancer-scoring module (a byte copy of src/scheduler/score-core.ts).
26
+ // Used by `stateOf` below (`isExhausted(sa, cfg, now)`) and re-exported so
27
+ // `auth-load-balancer-tui.view.tsx` (which already imports several other
28
+ // symbols from this module) shares the same computed value instead of
29
+ // calling `loadScoreConfig()` again for an identical result.
30
+ export const cfg = loadScoreConfig()
31
+
32
+ export function poolFile(): string {
33
+ const override = process.env.OPENCODE_AUTH_LB_DIR?.trim()
34
+ if (override) return join(override, 'auth-load-balancer.json')
35
+ const xdg = process.env.XDG_DATA_HOME?.trim()
36
+ if (xdg) return join(xdg, 'opencode', 'auth-load-balancer.json')
37
+ return join(
38
+ homedir(),
39
+ '.local',
40
+ 'share',
41
+ 'opencode',
42
+ 'auth-load-balancer.json',
43
+ )
44
+ }
45
+
46
+ // Captured ONCE at module load, like `cfg` above (and the server's cachedPool
47
+ // in src/pool/paths.ts) — the env-derived path never changes at runtime.
48
+ export const POOL_FILE = poolFile()
49
+
50
+ export interface UsageWindow {
51
+ utilization?: number
52
+ resetAt?: number
53
+ }
54
+ export interface PoolAccount {
55
+ id: string
56
+ providerID: string
57
+ label: string
58
+ usage?: { weekly?: UsageWindow | null; hourly?: UsageWindow | null } | null
59
+ cooldownUntil?: number
60
+ /** tier name → epoch ms until that model tier's cap resets (tier requests steer around the account until then). */
61
+ modelCooldownsUntil?: Record<string, number> | null
62
+ /** LEGACY pre-tier-map field (folded into `modelCooldownsUntil.opus` for display until the server migrates the file). */
63
+ opusCooldownUntil?: number
64
+ disabledReason?: string | null
65
+ }
66
+ /** Loosely-typed view of the on-disk pool JSON (one shape for reads AND read-modify-writes). */
67
+ export interface PoolShape {
68
+ accounts?: PoolAccount[]
69
+ lastSelected?: Record<string, string>
70
+ sessions?: Record<string, { accountId?: string }>
71
+ }
72
+
73
+ /**
74
+ * True for a plain JSON object (non-null `object`, not an array). Local copy
75
+ * of `isPlainObject` (src/util.ts) by design — the TUI runtime cannot import
76
+ * `src/` — under the name that file's NOTE cross-references. No `value is
77
+ * Record<…>` narrowing: the `.filter(isPlainRecordValue)` site below must
78
+ * keep its `PoolAccount[]` element type, which a type-guard predicate to a
79
+ * different type would widen away.
80
+ */
81
+ export function isPlainRecordValue(value: unknown): boolean {
82
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
83
+ }
84
+
85
+ /**
86
+ * Normalize parsed pool JSON defensively. `JSON.parse('null')` SUCCEEDS (so
87
+ * readPool's catch never fires) and a hand-edited non-array `accounts` passes
88
+ * straight through — either then throws inside the BottomBar/SidebarPanel
89
+ * memos (`p.accounts ?? []` on null / `.find` on an object), breaking BOTH
90
+ * slots until the file is repaired by hand. The server guards these cases in
91
+ * `readRaw` (src/pool/store.ts); mirror that trust boundary here.
92
+ */
93
+ export function toPoolShape(parsed: unknown): PoolShape {
94
+ if (!isPlainRecordValue(parsed)) return {}
95
+ const pool = parsed as PoolShape
96
+ // Row-level mirror of the server's `normalizeAccounts` trust boundary
97
+ // (store.ts): a hand-edited `accounts: [null, …]` (or a primitive element)
98
+ // otherwise throws inside the BottomBar/SidebarPanel memos (`x.id` on null)
99
+ // every poll until the file is repaired by hand — the server only heals it
100
+ // on its next write. Only the IDENTITY fields are irreparable:
101
+ // `normalizeAccounts` (src/pool/store.ts) drops rows with a non-string `id`
102
+ // OR `providerID` (they cannot be guessed), so drop exactly those here too.
103
+ // A non-string `label` is REPARABLE — `normalizeAccounts`' label heal sets
104
+ // it to `''` and KEEPS the row — so healing
105
+ // (not dropping) below keeps the bar/sidebar consistent with the server
106
+ // dashboards AND keeps `mutatePoolFile` (which re-serializes this shape)
107
+ // from permanently deleting an account the server would have healed. The
108
+ // inline arrow (not a type-guard predicate) keeps the `PoolAccount[]`
109
+ // element type — see the isPlainRecordValue note.
110
+ pool.accounts = Array.isArray(pool.accounts)
111
+ ? pool.accounts.filter(
112
+ (row) =>
113
+ isPlainRecordValue(row) &&
114
+ typeof row.id === 'string' &&
115
+ typeof row.providerID === 'string',
116
+ )
117
+ : undefined
118
+ if (pool.accounts) {
119
+ for (const row of pool.accounts) {
120
+ // Mirror `normalizeAccounts`' label heal — an empty label renders
121
+ // blank but never throws.
122
+ if (typeof (row as { label?: unknown }).label !== 'string') row.label = ''
123
+ }
124
+ }
125
+ // `lastSelected` / `sessions` must be plain records (the server heals these
126
+ // via `isPlainObject`). A hand-edited primitive (`"lastSelected": "oops"`)
127
+ // or array otherwise survives: `Object.entries` on a string iterates
128
+ // per-character garbage every poll, and `mutatePoolFile` writes the corrupt
129
+ // shape back. Reject to `undefined` so the `?? {}` fallbacks apply and the
130
+ // next TUI write heals the file.
131
+ if (!isAbsentOrPlainRecord(pool.lastSelected)) pool.lastSelected = undefined
132
+ if (!isAbsentOrPlainRecord(pool.sessions)) pool.sessions = undefined
133
+ return pool
134
+ }
135
+
136
+ /**
137
+ * True when the value is absent (`undefined`) or a plain JSON object. NOT the
138
+ * same as `isPlainObject` (src/util.ts) — that returns false for `undefined`;
139
+ * here absent fields are fine because the `?? {}` fallbacks downstream heal them.
140
+ */
141
+ export function isAbsentOrPlainRecord(value: unknown): boolean {
142
+ return value === undefined || isPlainRecordValue(value)
143
+ }
144
+
145
+ /** Read the pool file at `path` (defaults to `POOL_FILE`); malformed/missing → `{}`. */
146
+ export function readPool(path: string = POOL_FILE): PoolShape {
147
+ try {
148
+ return toPoolShape(JSON.parse(readFileSync(path, 'utf8')))
149
+ } catch {
150
+ return {}
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Injectable sync fs surface — mirrors `src/pool/store.ts`'s async `FsOps` so
156
+ * `mutatePoolFile`'s atomic-write + cleanup-on-failure branches are
157
+ * unit-testable without racing the real filesystem. `BottomBar`/`SidebarPanel`
158
+ * never pass this — they get `realFsOps` via the default parameter.
159
+ */
160
+ export interface FsOps {
161
+ readFileSync: (path: string, encoding: 'utf8') => string
162
+ writeFileSync: (path: string, data: string, options: { mode: number }) => void
163
+ renameSync: (oldPath: string, newPath: string) => void
164
+ unlinkSync: (path: string) => void
165
+ }
166
+ const realFsOps: FsOps = { readFileSync, writeFileSync, renameSync, unlinkSync }
167
+
168
+ // Mirrors src/pool/store.ts's `RENAME_RETRIES`/`RENAME_RETRY_MS`: a concurrent
169
+ // process (the server's own `mutatePool`) can briefly hold the target open
170
+ // (Windows EPERM) during exactly the race window documented in the README's
171
+ // "TUI pool writes" limitation. The server retries that race 5x; this synchronous
172
+ // counterpart (everything here is `*Sync`) now does the same via `Bun.sleepSync`
173
+ // instead of silently dropping the user's Rename/Delete click on the first transient failure.
174
+ const RENAME_RETRIES = 5
175
+ const RENAME_RETRY_MS = 20
176
+
177
+ /** Read-modify-write the pool file atomically (temp + rename — never a direct overwrite). */
178
+ export function mutatePoolFile(
179
+ fn: (pool: PoolShape) => void,
180
+ path: string = POOL_FILE,
181
+ ops: FsOps = realFsOps,
182
+ ): void {
183
+ let tmp: string | undefined
184
+ try {
185
+ // Same guard as readPool: `fn` must never run on `null` / a non-object
186
+ // (JSON.parse('null') succeeds), and healing here also repairs the file.
187
+ const pool = toPoolShape(JSON.parse(ops.readFileSync(path, 'utf8')))
188
+ fn(pool)
189
+ const payload = JSON.stringify(pool, null, 2)
190
+ tmp = `${path}.${process.pid}.${Date.now()}.tmp`
191
+ ops.writeFileSync(tmp, payload, { mode: 0o600 })
192
+ let lastError: unknown
193
+ let renamed = false
194
+ for (let attempt = 0; attempt < RENAME_RETRIES; attempt++) {
195
+ try {
196
+ ops.renameSync(tmp, path)
197
+ renamed = true
198
+ break
199
+ } catch (error) {
200
+ lastError = error
201
+ // Separate ATTEMPTS only; after the final failure there is nothing
202
+ // left to wait for.
203
+ if (attempt < RENAME_RETRIES - 1) Bun.sleepSync(RENAME_RETRY_MS)
204
+ }
205
+ }
206
+ if (!renamed) throw lastError
207
+ tmp = undefined
208
+ } catch {
209
+ // Atomic temp+rename failed (e.g. the server briefly holds the file on
210
+ // Windows). NEVER fall back to a direct overwrite — that would shred a
211
+ // concurrent server write of usage/cooldown/session/tokenGen state
212
+ // (see src/pool/store.ts writeJsonAtomic, which was deliberately changed
213
+ // away from that fallback in iteration #0046). Mirror its cleanup so a
214
+ // sustained failure doesn't leave a stray .tmp behind; the user can retry.
215
+ if (tmp) {
216
+ try {
217
+ ops.unlinkSync(tmp)
218
+ } catch {
219
+ /* ignore — best-effort cleanup */
220
+ }
221
+ }
222
+ }
223
+ }
224
+
225
+ export function renameInPool(
226
+ id: string,
227
+ label: string,
228
+ path: string = POOL_FILE,
229
+ ): void {
230
+ mutatePoolFile((pool) => {
231
+ const acct = (pool.accounts ?? []).find((a) => a.id === id)
232
+ if (acct) acct.label = label
233
+ }, path)
234
+ }
235
+
236
+ /** Remove an account from the pool and drop any in-use / session references to it. */
237
+ export function deleteFromPool(id: string, path: string = POOL_FILE): void {
238
+ mutatePoolFile((pool) => {
239
+ pool.accounts = (pool.accounts ?? []).filter((a) => a.id !== id)
240
+ const lastSelected = pool.lastSelected ?? {}
241
+ for (const key of Object.keys(lastSelected)) {
242
+ if (lastSelected[key] === id) delete lastSelected[key]
243
+ }
244
+ const sessions = pool.sessions ?? {}
245
+ for (const key of Object.keys(sessions)) {
246
+ if (sessions[key]?.accountId === id) delete sessions[key]
247
+ }
248
+ }, path)
249
+ }
250
+
251
+ /**
252
+ * Normalize a raw (loosely-typed) pool account into the strict shape the shared
253
+ * scorer reads. The pool file is user-editable and read RAW here (the server
254
+ * normalizes at its own read boundary — `normalizeWindow`/`normalizeAccounts`
255
+ * in src/pool/store.ts), so a hand-edited string `resetAt`/`cooldownUntil`
256
+ * would otherwise survive `?? 0` into the scorer and poison its numeric
257
+ * comparisons (`isWindowExpired`, `stateOf`'s `> now`) until the server heals
258
+ * the file — guard with Number.isFinite, not just nullish or typeof:
259
+ * `JSON.parse('1e999') === Infinity` and `typeof Infinity === 'number'`, so a
260
+ * hand-edited `1e999` cooldownUntil would render "(cooldown)" forever and a
261
+ * `1e999` resetAt would render the literal "Infinityd" every poll.
262
+ */
263
+ export function isFiniteNumber(v: unknown): v is number {
264
+ return Number.isFinite(v)
265
+ }
266
+ export function toScoreWindow(
267
+ w: UsageWindow | null | undefined,
268
+ ): ScoreWindow | null {
269
+ return w && isFiniteNumber(w.utilization)
270
+ ? {
271
+ utilization: w.utilization,
272
+ resetAt: isFiniteNumber(w.resetAt) && w.resetAt >= 0 ? w.resetAt : 0,
273
+ }
274
+ : null
275
+ }
276
+ export function toScore(a: PoolAccount): ScoreAccount {
277
+ return {
278
+ usage: {
279
+ hourly: toScoreWindow(a.usage?.hourly),
280
+ weekly: toScoreWindow(a.usage?.weekly),
281
+ },
282
+ cooldownUntil: isFiniteNumber(a.cooldownUntil) ? a.cooldownUntil : 0,
283
+ disabledReason: a.disabledReason ?? null,
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Byte-deterministic ASCII string comparator (never `localeCompare` — keeps
289
+ * tier/provider ordering byte-identical regardless of host locale). Ships its
290
+ * own copy independent of `src/status.ts`'s identical helper — this file
291
+ * already documents that the TUI runtime cannot import `src/`.
292
+ */
293
+ export function compareAscii(a: string, b: string): number {
294
+ return a < b ? -1 : a > b ? 1 : 0
295
+ }
296
+
297
+ export function pct(u: number | null | undefined): string {
298
+ return typeof u === 'number' ? `${Math.round(u * 100)}%` : '-'
299
+ }
300
+ export function until(resetAt: number | undefined, now: number): string {
301
+ // Finiteness guard, not just truthiness/typeof: this reads the RAW
302
+ // pool-file value (see the callers' "deliberately stays on the RAW
303
+ // resetAt" note), so a hand-edited string (`"2026-01-01"`) would make
304
+ // `resetAt - now` NaN and render the literal "NaNd" — and a `1e999`
305
+ // (`Infinity`, which typeof calls a number) the literal "Infinityd" —
306
+ // every poll until the server heals the file.
307
+ if (!isFiniteNumber(resetAt) || resetAt <= now) return '-'
308
+ // Mirror the server's `relTime` (src/status.ts) semantics: floor minutes at 1
309
+ // (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
312
+ // 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 —
315
+ // not byte-identical output.
316
+ const mins = Math.max(1, Math.round((resetAt - now) / 60_000))
317
+ if (mins < 60) return `${mins}m`
318
+ const hrs = Math.floor(mins / 60)
319
+ if (hrs < 24) return `${hrs}h`
320
+ return `${Math.floor(hrs / 24)}d`
321
+ }
322
+ /** A window's utilization for the bar: "-" when absent, "0%" once it has reset (stale value dropped). */
323
+ export function winPct(w: UsageWindow | null | undefined, now: number): string {
324
+ return pct(displayUtil(toScoreWindow(w), now))
325
+ }
326
+ /**
327
+ * The account's active model-tier cooldowns as display pairs, read from the
328
+ * RAW pool file: entries must be finiteness-guarded like every other raw read
329
+ * here (a hand-edited `1e999` → Infinity would render "opus" forever), and
330
+ * the LEGACY `opusCooldownUntil` field is folded in (max-merged) until the
331
+ * server migrates the file. Sorted by tier name (raw `< / >`, no locale) so
332
+ * the bar can never disagree with the CLI/tool dashboard's ordering.
333
+ */
334
+ export function tierResets(a: PoolAccount, now: number): [string, number][] {
335
+ const raw = a.modelCooldownsUntil
336
+ const merged: Record<string, number> = {}
337
+ if (isPlainRecordValue(raw)) {
338
+ for (const [tier, resetAt] of Object.entries(
339
+ raw as Record<string, unknown>,
340
+ )) {
341
+ if (isFiniteNumber(resetAt) && resetAt > now) merged[tier] = resetAt
342
+ }
343
+ }
344
+ if (isFiniteNumber(a.opusCooldownUntil) && a.opusCooldownUntil > now) {
345
+ merged.opus = Math.max(merged.opus ?? 0, a.opusCooldownUntil)
346
+ }
347
+ return Object.entries(merged).sort(([x], [y]) => compareAscii(x, y))
348
+ }
349
+ export function stateOf(
350
+ sa: ScoreAccount,
351
+ tiers: [string, number][],
352
+ now: number,
353
+ ): string {
354
+ if (sa.disabledReason) return 're-login'
355
+ if (sa.cooldownUntil > now) return `cooldown ${until(sa.cooldownUntil, now)}`
356
+ if (isExhausted(sa, cfg, now)) return 'full'
357
+ // A model-tier limit keeps the account usable (other models + downgrade),
358
+ // so it annotates rather than sidelines — mirrors src/status.ts.
359
+ if (tiers.length > 0)
360
+ return tiers.map(([tier, at]) => `${tier} ${until(at, now)}`).join(' · ')
361
+ return ''
362
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * opencode TUI plugin ENTRY for the load balancer's persistent bottom status bar.
3
+ *
4
+ * Why this is a `.ts` entry that lazily imports a `.tsx` view:
5
+ * - opencode discovers file plugins with the glob `{plugin,plugins}/*.{ts,js}`
6
+ * (packages/opencode/src/config/plugin.ts) — `.tsx` is NOT scanned, so a
7
+ * hand-authored `.tsx` is silently skipped and never loads.
8
+ * - opencode's TUI runtime registers the @opentui/solid JSX transform whose Bun
9
+ * onLoad filter is `/\.(js|ts)x$/` — i.e. it only transforms `.tsx`/`.jsx`.
10
+ * A `.ts` file therefore cannot itself contain JSX, but it CAN import a `.tsx`
11
+ * module, which the runtime transform compiles correctly.
12
+ * - The `.tsx` is imported LAZILY inside `tui()` (which only ever runs in the TUI
13
+ * runtime where the transform is registered), so the server plugin worker never
14
+ * evaluates the SolidJS/@opentui module graph.
15
+ *
16
+ * Install: copy all THREE files (this entry, `auth-load-balancer-tui.view.tsx`,
17
+ * and `auth-load-balancer-scoring.ts`) into a directory the server does NOT
18
+ * scan — anything other than `plugin/`/`plugins/`, e.g.
19
+ * `~/.config/opencode/tui-plugins/` — then register THIS entry in
20
+ * `~/.config/opencode/tui.json`'s `plugin` array (`file:///…/auth-load-balancer-tui.ts`)
21
+ * and restart. Do NOT drop these files in a `plugins/` dir: the server globs
22
+ * `{plugin,plugins}/*.{ts,js}` and would load the entry as a server plugin,
23
+ * reject it (`must default export … server()`), and log an error on every
24
+ * launch — see the README's "Why this shape (it matters)" section.
25
+ */
26
+ import type { TuiPlugin, TuiPluginModule } from '@opencode-ai/plugin/tui'
27
+
28
+ const tui: TuiPlugin = async (api) => {
29
+ // Lazy so the server worker (no Solid transform) never imports the .tsx graph.
30
+ const { registerSlots } = await import('./auth-load-balancer-tui.view.tsx')
31
+ registerSlots(api)
32
+ }
33
+
34
+ const plugin: TuiPluginModule & { id: string } = {
35
+ id: 'auth-load-balancer-tui',
36
+ tui,
37
+ }
38
+
39
+ export default plugin