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,327 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ /**
3
+ * SolidJS view for the load balancer's TUI surfaces (loaded by opencode's TUI
4
+ * runtime via the entry's lazy import). It reads the pool file the server plugin
5
+ * writes (polled) and renders:
6
+ *
7
+ * - app_bottom — an always-visible bottom bar OUTSIDE the prompt, showing the
8
+ * in-use account per provider with weekly % (+ days to reset) and 5h % (+ time
9
+ * to reset). (Rendering inside the prompt's right slot squeezed the prompt and
10
+ * broke its layout when the input was empty.)
11
+ * - sidebar_content — a panel next to Context / MCP / LSP listing ALL accounts
12
+ * per provider, sorted by the scheduler's score (highest = use next), showing
13
+ * the score + use-order, usage + reset countdowns, in-use marker, and state.
14
+ * Click any account row for a menu to Rename (prompt) or Delete (confirm) it,
15
+ * written straight to the pool file.
16
+ *
17
+ * The ranking is computed by the SAME code the server scheduler uses — imported from a
18
+ * byte-identical copy of src/scheduler/score-core.ts installed alongside this file
19
+ * (auth-load-balancer-scoring.ts) — so the displayed order can never drift from what the
20
+ * scheduler actually picks.
21
+ */
22
+ import type { TuiPluginApi, TuiSlotPlugin } from '@opencode-ai/plugin/tui'
23
+ import { createMemo, createSignal, For, onCleanup, Show } from 'solid-js'
24
+
25
+ import {
26
+ compareRanked,
27
+ displayUtil,
28
+ isAvailable,
29
+ scoreAccount,
30
+ utilOf,
31
+ } from './auth-load-balancer-scoring'
32
+ import {
33
+ cfg,
34
+ compareAscii,
35
+ deleteFromPool,
36
+ pct,
37
+ type PoolShape,
38
+ readPool,
39
+ renameInPool,
40
+ stateOf,
41
+ tierResets,
42
+ toScore,
43
+ until,
44
+ winPct,
45
+ } from './auth-load-balancer-tui.logic'
46
+
47
+ // NOTE: ships its OWN copy of `src/status.ts`'s `PROVIDER_NAMES` by design —
48
+ // the TUI runtime cannot import `src/` (see `auth-load-balancer-tui.logic.ts`'s
49
+ // `isPlainRecordValue`/`isFiniteNumber` NOTEs for the same trust boundary).
50
+ const PROVIDER_NAMES: Record<string, string> = {
51
+ anthropic: 'Claude',
52
+ openai: 'Codex',
53
+ }
54
+ /** Shared fallback lookup used by both the bottom bar and sidebar group labels. */
55
+ function providerLabel(id: string): string {
56
+ return PROVIDER_NAMES[id] ?? id
57
+ }
58
+ const POLL_MS = 3000
59
+
60
+ // Scoring knobs read from env exactly like the server — single-sourced in the shared
61
+ // ./auth-load-balancer-scoring module (a byte copy of src/scheduler/score-core.ts),
62
+ // shared with auth-load-balancer-tui.logic.ts (imported above as `cfg`) so it is
63
+ // computed exactly once.
64
+
65
+ function usePool() {
66
+ const [pool, setPool] = createSignal<PoolShape>(readPool())
67
+ const timer = setInterval(() => setPool(readPool()), POLL_MS)
68
+ onCleanup(() => clearInterval(timer))
69
+ return pool
70
+ }
71
+
72
+ /** Shared theme-color accessor used identically by `BottomBar` and `SidebarPanel`. */
73
+ function themeColor(api: TuiPluginApi) {
74
+ return () => api.theme.current
75
+ }
76
+
77
+ /** The 4 usage-window display fields shared verbatim by `Chip` and `Row`. */
78
+ interface WindowDisplay {
79
+ weeklyPct: string
80
+ weeklyReset: string
81
+ hourlyPct: string
82
+ hourlyReset: string
83
+ }
84
+
85
+ interface Chip extends WindowDisplay {
86
+ name: string
87
+ label: string
88
+ }
89
+
90
+ /** Always-visible bottom bar (app_bottom): the in-use account per provider. */
91
+ function BottomBar(props: { api: TuiPluginApi }) {
92
+ const pool = usePool()
93
+ const color = themeColor(props.api)
94
+ const chips = createMemo<Chip[]>(() => {
95
+ const p = pool()
96
+ const accounts = p.accounts ?? []
97
+ const now = Date.now()
98
+ const out: Chip[] = []
99
+ // `lastSelected` key order is whichever provider served first; sort by
100
+ // provider id (byte-deterministic `< / >`, no locale) so the bar lists
101
+ // providers in the same order as the sidebar, CLI, and status tool.
102
+ const selected = Object.entries(p.lastSelected ?? {}).sort(([x], [y]) =>
103
+ compareAscii(x, y),
104
+ )
105
+ for (const [providerID, id] of selected) {
106
+ const a = accounts.find((x) => x.id === id)
107
+ if (!a) continue
108
+ out.push({
109
+ name: providerLabel(providerID),
110
+ label: a.label,
111
+ weeklyPct: winPct(a.usage?.weekly, now),
112
+ weeklyReset: until(a.usage?.weekly?.resetAt, now),
113
+ hourlyPct: winPct(a.usage?.hourly, now),
114
+ hourlyReset: until(a.usage?.hourly?.resetAt, now),
115
+ })
116
+ }
117
+ return out
118
+ })
119
+
120
+ return (
121
+ <box flexDirection="row" flexShrink={0} gap={2} paddingLeft={1}>
122
+ <For each={chips()}>
123
+ {(a) => (
124
+ <text fg={color().textMuted} wrapMode="none">
125
+ <span style={{ fg: color().primary }}>
126
+ {a.name} {a.label}
127
+ </span>
128
+ {` wk ${a.weeklyPct} (${a.weeklyReset}) · 5h ${a.hourlyPct} (${a.hourlyReset})`}
129
+ </text>
130
+ )}
131
+ </For>
132
+ </box>
133
+ )
134
+ }
135
+
136
+ interface Row extends WindowDisplay {
137
+ id: string
138
+ label: string
139
+ current: boolean
140
+ score: number | null
141
+ rank: number | null
142
+ state: string
143
+ }
144
+ interface Group {
145
+ provider: string
146
+ rows: Row[]
147
+ }
148
+
149
+ /** Sidebar panel: all accounts per provider, scored + sorted, click to rename. */
150
+ function SidebarPanel(props: { api: TuiPluginApi }) {
151
+ const pool = usePool()
152
+ const color = themeColor(props.api)
153
+ const groups = createMemo<Group[]>(() => {
154
+ const p = pool()
155
+ const accounts = p.accounts ?? []
156
+ const now = Date.now()
157
+ const providerIds = [...new Set(accounts.map((a) => a.providerID))].sort(
158
+ compareAscii,
159
+ )
160
+ return providerIds.map((providerID) => {
161
+ const ranked = accounts
162
+ .filter((a) => a.providerID === providerID)
163
+ .map((a) => {
164
+ const sa = toScore(a)
165
+ const available = isAvailable(sa, cfg, now)
166
+ return {
167
+ a,
168
+ sa,
169
+ available,
170
+ score: available
171
+ ? scoreAccount(sa, cfg, now)
172
+ : Number.NEGATIVE_INFINITY,
173
+ weeklyUtil: utilOf(sa.usage.weekly, now),
174
+ }
175
+ })
176
+ .sort(compareRanked)
177
+ let rank = 0
178
+ const rows: Row[] = ranked.map(({ a, sa, available, score }) => {
179
+ if (available) rank += 1
180
+ return {
181
+ id: a.id,
182
+ label: a.label,
183
+ current: p.lastSelected?.[providerID] === a.id,
184
+ score: available ? score : null,
185
+ rank: available ? rank : null,
186
+ // Reuse the windows `toScore(a)` already normalized instead of
187
+ // re-running toScoreWindow on the raw window (winPct) a second time.
188
+ // `until()` deliberately stays on the RAW resetAt: a malformed
189
+ // window with a reset but no utilization normalizes to null in `sa`,
190
+ // yet the countdown should still render.
191
+ weeklyPct: pct(displayUtil(sa.usage.weekly, now)),
192
+ weeklyReset: until(a.usage?.weekly?.resetAt, now),
193
+ hourlyPct: pct(displayUtil(sa.usage.hourly, now)),
194
+ hourlyReset: until(a.usage?.hourly?.resetAt, now),
195
+ // Raw reads are finiteness-guarded inside tierResets: a hand-edited
196
+ // `1e999` entry (Infinity via JSON.parse) would otherwise render its
197
+ // tier annotation forever until the server heals the file.
198
+ state: stateOf(sa, tierResets(a, now), now),
199
+ }
200
+ })
201
+ return { provider: providerLabel(providerID), rows }
202
+ })
203
+ })
204
+
205
+ const dialog = () => props.api.ui.dialog
206
+
207
+ function openRename(id: string, label: string): void {
208
+ dialog().replace(() =>
209
+ props.api.ui.DialogPrompt({
210
+ title: `Rename "${label}"`,
211
+ value: label,
212
+ placeholder: 'New label',
213
+ onConfirm: (next: string) => {
214
+ const trimmed = next.trim()
215
+ if (trimmed) renameInPool(id, trimmed)
216
+ dialog().clear()
217
+ },
218
+ onCancel: () => dialog().clear(),
219
+ }),
220
+ )
221
+ }
222
+
223
+ function openDelete(id: string, label: string): void {
224
+ dialog().replace(() =>
225
+ props.api.ui.DialogConfirm({
226
+ title: 'Delete account',
227
+ message: `Remove "${label}" from the load-balancer pool? Its stored tokens are dropped — sign in again to re-add it.`,
228
+ onConfirm: () => {
229
+ deleteFromPool(id)
230
+ dialog().clear()
231
+ },
232
+ onCancel: () => dialog().clear(),
233
+ }),
234
+ )
235
+ }
236
+
237
+ // Click an account -> a small menu so both Rename and Delete are reachable.
238
+ function openMenu(id: string, label: string): void {
239
+ dialog().replace(() =>
240
+ props.api.ui.DialogSelect({
241
+ title: label,
242
+ options: [
243
+ {
244
+ title: 'Rename',
245
+ value: 'rename',
246
+ onSelect: () => openRename(id, label),
247
+ },
248
+ {
249
+ title: 'Delete — remove from pool',
250
+ value: 'delete',
251
+ onSelect: () => openDelete(id, label),
252
+ },
253
+ ],
254
+ }),
255
+ )
256
+ }
257
+
258
+ return (
259
+ <Show when={groups().length > 0}>
260
+ <box>
261
+ <text fg={color().text}>
262
+ <b>Auth accounts</b>
263
+ <span style={{ fg: color().textMuted }}>
264
+ {' '}
265
+ (click: rename / delete)
266
+ </span>
267
+ </text>
268
+ <For each={groups()}>
269
+ {(g) => (
270
+ <box>
271
+ <text fg={color().textMuted}>{g.provider}</text>
272
+ <For each={g.rows}>
273
+ {(r) => (
274
+ <box onMouseUp={() => openMenu(r.id, r.label)}>
275
+ <text
276
+ fg={r.current ? color().primary : color().text}
277
+ wrapMode="word"
278
+ >
279
+ {(r.current ? '▶ ' : ' ') +
280
+ (r.rank ? `${r.rank}. ` : ' ') +
281
+ r.label}
282
+ <Show when={r.score !== null}>
283
+ <span style={{ fg: color().secondary }}>
284
+ {` ${(r.score ?? 0).toFixed(2)}`}
285
+ </span>
286
+ </Show>
287
+ <Show when={r.state}>
288
+ <span
289
+ style={{ fg: color().warning }}
290
+ >{` (${r.state})`}</span>
291
+ </Show>
292
+ </text>
293
+ <text fg={color().textMuted}>
294
+ {` wk ${r.weeklyPct} (${r.weeklyReset}) · 5h ${r.hourlyPct} (${r.hourlyReset})`}
295
+ </text>
296
+ </box>
297
+ )}
298
+ </For>
299
+ </box>
300
+ )}
301
+ </For>
302
+ </box>
303
+ </Show>
304
+ )
305
+ }
306
+
307
+ function makeSlots(api: TuiPluginApi): TuiSlotPlugin {
308
+ return {
309
+ order: 250,
310
+ slots: {
311
+ // Bottom bar OUTSIDE the prompt — rendering inside the prompt's right slot
312
+ // squeezes the prompt and breaks its layout when the input is empty.
313
+ app_bottom() {
314
+ return <BottomBar api={api} />
315
+ },
316
+ // Panel in the session sidebar, next to Context / MCP / LSP.
317
+ sidebar_content() {
318
+ return <SidebarPanel api={api} />
319
+ },
320
+ },
321
+ }
322
+ }
323
+
324
+ /** Called from the `.ts` entry's `tui(api)` (TUI runtime only). */
325
+ export function registerSlots(api: TuiPluginApi): void {
326
+ api.slots.register(makeSlots(api))
327
+ }