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.
- package/LICENSE +21 -0
- package/README.md +289 -0
- package/dist/accounts.d.ts +20 -0
- package/dist/cli/status.d.ts +2 -0
- package/dist/fetch.d.ts +34 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +14829 -0
- package/dist/notify.d.ts +47 -0
- package/dist/pool/lock.d.ts +39 -0
- package/dist/pool/paths.d.ts +16 -0
- package/dist/pool/store.d.ts +51 -0
- package/dist/prime.d.ts +32 -0
- package/dist/providers/anthropic/adapter.d.ts +8 -0
- package/dist/providers/anthropic/cch.d.ts +20 -0
- package/dist/providers/anthropic/constants.d.ts +64 -0
- package/dist/providers/anthropic/fallback.d.ts +52 -0
- package/dist/providers/anthropic/oauth.d.ts +8 -0
- package/dist/providers/anthropic/transform.d.ts +23 -0
- package/dist/providers/anthropic/usage.d.ts +14 -0
- package/dist/providers/headers.d.ts +7 -0
- package/dist/providers/http-timeouts.d.ts +32 -0
- package/dist/providers/oauth-callback.d.ts +100 -0
- package/dist/providers/openai/adapter.d.ts +9 -0
- package/dist/providers/openai/constants.d.ts +18 -0
- package/dist/providers/openai/jwt.d.ts +5 -0
- package/dist/providers/openai/oauth.d.ts +12 -0
- package/dist/providers/openai/transform.d.ts +18 -0
- package/dist/providers/openai/usage.d.ts +16 -0
- package/dist/providers/pkce.d.ts +10 -0
- package/dist/providers/types.d.ts +122 -0
- package/dist/providers/usage-headers.d.ts +55 -0
- package/dist/providers/usage-http.d.ts +8 -0
- package/dist/refresh.d.ts +13 -0
- package/dist/scheduler/config.d.ts +46 -0
- package/dist/scheduler/score-core.d.ts +167 -0
- package/dist/scheduler/select.d.ts +41 -0
- package/dist/session.d.ts +15 -0
- package/dist/status.d.ts +55 -0
- package/dist/types.d.ts +101 -0
- package/dist/usage-merge.d.ts +23 -0
- package/dist/usage-refresh.d.ts +30 -0
- package/dist/util.d.ts +118 -0
- package/package.json +75 -0
- package/tui/auth-load-balancer-scoring.ts +344 -0
- package/tui/auth-load-balancer-tui.logic.ts +362 -0
- package/tui/auth-load-balancer-tui.ts +39 -0
- 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
|
+
}
|