opencode-auth-load-balancer 0.2.2 → 0.3.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/dist/fetch.d.ts +1 -0
- package/dist/index.js +986 -886
- package/dist/providers/anthropic/constants.d.ts +9 -4
- package/dist/providers/anthropic/fallback.d.ts +3 -1
- package/dist/providers/openai/usage.d.ts +14 -8
- package/dist/providers/registry.d.ts +26 -0
- package/dist/providers/types.d.ts +10 -6
- package/dist/scheduler/select.d.ts +5 -2
- package/dist/status.d.ts +1 -1
- package/dist/types.d.ts +55 -1
- package/dist/usage-refresh.d.ts +34 -16
- package/dist/util.d.ts +4 -3
- package/package.json +8 -8
- package/tui/auth-load-balancer-tui.logic.ts +131 -8
- package/tui/auth-load-balancer-tui.view.tsx +254 -33
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* - sidebar_content — a panel next to Context / MCP / LSP listing ALL accounts
|
|
12
12
|
* per provider, sorted by the scheduler's score (highest = use next), showing
|
|
13
13
|
* the score + use-order, usage + reset countdowns, in-use marker, and state.
|
|
14
|
-
* Click any account row for a menu to Rename (prompt)
|
|
15
|
-
*
|
|
14
|
+
* Click any account row for a menu to Rename (prompt), Disable/Enable
|
|
15
|
+
* (toggle), Re-login (provider OAuth), or Delete (confirm) it.
|
|
16
16
|
*
|
|
17
17
|
* The ranking is computed by the SAME code the server scheduler uses — imported from a
|
|
18
18
|
* byte-identical copy of src/scheduler/score-core.ts installed alongside this file
|
|
@@ -31,12 +31,19 @@ import {
|
|
|
31
31
|
} from './auth-load-balancer-scoring'
|
|
32
32
|
import {
|
|
33
33
|
cfg,
|
|
34
|
+
clearReloginTargetInPool,
|
|
34
35
|
compareAscii,
|
|
35
36
|
deleteFromPool,
|
|
37
|
+
MANUAL_DISABLED_REASON,
|
|
36
38
|
pct,
|
|
39
|
+
pickAuthMethodIndex,
|
|
37
40
|
type PoolShape,
|
|
38
41
|
readPool,
|
|
39
42
|
renameInPool,
|
|
43
|
+
sessionAccountId,
|
|
44
|
+
sessionFallback,
|
|
45
|
+
setDisabledInPool,
|
|
46
|
+
setReloginTargetInPool,
|
|
40
47
|
stateOf,
|
|
41
48
|
tierResets,
|
|
42
49
|
toScore,
|
|
@@ -57,6 +64,19 @@ function providerLabel(id: string): string {
|
|
|
57
64
|
}
|
|
58
65
|
const POLL_MS = 3000
|
|
59
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The current session id from the TUI route, or undefined on the home screen.
|
|
69
|
+
* Unlike the `sidebar_content` slot (handed a `session_id` prop), the always-
|
|
70
|
+
* visible `app_bottom` bar receives no slot props, so it reads the session from
|
|
71
|
+
* the route to scope its in-use account to the VIEWER's own session.
|
|
72
|
+
*/
|
|
73
|
+
function routeSessionId(api: TuiPluginApi): string | undefined {
|
|
74
|
+
const r = api.route.current
|
|
75
|
+
if (r.name !== 'session') return undefined
|
|
76
|
+
const sid = r.params?.sessionID
|
|
77
|
+
return typeof sid === 'string' ? sid : undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
60
80
|
// Scoring knobs read from env exactly like the server — single-sourced in the shared
|
|
61
81
|
// ./auth-load-balancer-scoring module (a byte copy of src/scheduler/score-core.ts),
|
|
62
82
|
// shared with auth-load-balancer-tui.logic.ts (imported above as `cfg`) so it is
|
|
@@ -85,6 +105,8 @@ interface WindowDisplay {
|
|
|
85
105
|
interface Chip extends WindowDisplay {
|
|
86
106
|
name: string
|
|
87
107
|
label: string
|
|
108
|
+
/** Raw model ids (requested → served) when this session runs on a fallback model, else undefined. */
|
|
109
|
+
fallback?: { from: string; to: string }
|
|
88
110
|
}
|
|
89
111
|
|
|
90
112
|
/** Always-visible bottom bar (app_bottom): the in-use account per provider. */
|
|
@@ -96,13 +118,20 @@ function BottomBar(props: { api: TuiPluginApi }) {
|
|
|
96
118
|
const accounts = p.accounts ?? []
|
|
97
119
|
const now = Date.now()
|
|
98
120
|
const out: Chip[] = []
|
|
99
|
-
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
121
|
+
const sid = routeSessionId(props.api)
|
|
122
|
+
// Show the account THIS session is using per provider — NOT the global
|
|
123
|
+
// `lastSelected` (whichever session served last across the whole pool). On
|
|
124
|
+
// the home screen (no session) fall back to `lastSelected` so the bar isn't
|
|
125
|
+
// blank. Providers byte-sorted (`< / >`, no locale) so the bar lists them in
|
|
126
|
+
// the same order as the sidebar, CLI, and status tool.
|
|
127
|
+
const providerIds = [...new Set(accounts.map((x) => x.providerID))].sort(
|
|
128
|
+
compareAscii,
|
|
104
129
|
)
|
|
105
|
-
for (const
|
|
130
|
+
for (const providerID of providerIds) {
|
|
131
|
+
const id =
|
|
132
|
+
sessionAccountId(p, providerID, sid) ??
|
|
133
|
+
(sid ? undefined : p.lastSelected?.[providerID])
|
|
134
|
+
if (!id) continue
|
|
106
135
|
const a = accounts.find((x) => x.id === id)
|
|
107
136
|
if (!a) continue
|
|
108
137
|
out.push({
|
|
@@ -112,6 +141,9 @@ function BottomBar(props: { api: TuiPluginApi }) {
|
|
|
112
141
|
weeklyReset: until(a.usage?.weekly?.resetAt, now),
|
|
113
142
|
hourlyPct: winPct(a.usage?.hourly, now),
|
|
114
143
|
hourlyReset: until(a.usage?.hourly?.resetAt, now),
|
|
144
|
+
// Only THIS session's fallback (keyed by sid); the `lastSelected` home-screen
|
|
145
|
+
// fallback path has no session, so no downgrade to surface there.
|
|
146
|
+
fallback: sessionFallback(p, providerID, sid),
|
|
115
147
|
})
|
|
116
148
|
}
|
|
117
149
|
return out
|
|
@@ -125,6 +157,11 @@ function BottomBar(props: { api: TuiPluginApi }) {
|
|
|
125
157
|
<span style={{ fg: color().primary }}>
|
|
126
158
|
{a.name} {a.label}
|
|
127
159
|
</span>
|
|
160
|
+
{a.fallback ? (
|
|
161
|
+
<span style={{ fg: color().warning }}>
|
|
162
|
+
{` fallback ${a.fallback.from}→${a.fallback.to}`}
|
|
163
|
+
</span>
|
|
164
|
+
) : null}
|
|
128
165
|
{` wk ${a.weeklyPct} (${a.weeklyReset}) · 5h ${a.hourlyPct} (${a.hourlyReset})`}
|
|
129
166
|
</text>
|
|
130
167
|
)}
|
|
@@ -136,10 +173,12 @@ function BottomBar(props: { api: TuiPluginApi }) {
|
|
|
136
173
|
interface Row extends WindowDisplay {
|
|
137
174
|
id: string
|
|
138
175
|
label: string
|
|
176
|
+
providerID: string
|
|
139
177
|
current: boolean
|
|
140
178
|
score: number | null
|
|
141
179
|
rank: number | null
|
|
142
180
|
state: string
|
|
181
|
+
manuallyDisabled: boolean
|
|
143
182
|
}
|
|
144
183
|
interface Group {
|
|
145
184
|
provider: string
|
|
@@ -147,7 +186,10 @@ interface Group {
|
|
|
147
186
|
}
|
|
148
187
|
|
|
149
188
|
/** Sidebar panel: all accounts per provider, scored + sorted, click to rename. */
|
|
150
|
-
function SidebarPanel(props: {
|
|
189
|
+
function SidebarPanel(props: {
|
|
190
|
+
api: TuiPluginApi
|
|
191
|
+
sessionId: string | undefined
|
|
192
|
+
}) {
|
|
151
193
|
const pool = usePool()
|
|
152
194
|
const color = themeColor(props.api)
|
|
153
195
|
const groups = createMemo<Group[]>(() => {
|
|
@@ -158,6 +200,10 @@ function SidebarPanel(props: { api: TuiPluginApi }) {
|
|
|
158
200
|
compareAscii,
|
|
159
201
|
)
|
|
160
202
|
return providerIds.map((providerID) => {
|
|
203
|
+
// The account the VIEWER's session is pinned to for this provider — used
|
|
204
|
+
// for the in-use (▶) marker instead of the global `lastSelected`, so the
|
|
205
|
+
// marker reflects THIS session, not whichever session served last.
|
|
206
|
+
const sessionAcct = sessionAccountId(p, providerID, props.sessionId)
|
|
161
207
|
const ranked = accounts
|
|
162
208
|
.filter((a) => a.providerID === providerID)
|
|
163
209
|
.map((a) => {
|
|
@@ -180,7 +226,8 @@ function SidebarPanel(props: { api: TuiPluginApi }) {
|
|
|
180
226
|
return {
|
|
181
227
|
id: a.id,
|
|
182
228
|
label: a.label,
|
|
183
|
-
|
|
229
|
+
providerID,
|
|
230
|
+
current: sessionAcct === a.id,
|
|
184
231
|
score: available ? score : null,
|
|
185
232
|
rank: available ? rank : null,
|
|
186
233
|
// Reuse the windows `toScore(a)` already normalized instead of
|
|
@@ -196,6 +243,7 @@ function SidebarPanel(props: { api: TuiPluginApi }) {
|
|
|
196
243
|
// `1e999` entry (Infinity via JSON.parse) would otherwise render its
|
|
197
244
|
// tier annotation forever until the server heals the file.
|
|
198
245
|
state: stateOf(sa, tierResets(a, now), now),
|
|
246
|
+
manuallyDisabled: a.disabledReason === MANUAL_DISABLED_REASON,
|
|
199
247
|
}
|
|
200
248
|
})
|
|
201
249
|
return { provider: providerLabel(providerID), rows }
|
|
@@ -234,25 +282,186 @@ function SidebarPanel(props: { api: TuiPluginApi }) {
|
|
|
234
282
|
)
|
|
235
283
|
}
|
|
236
284
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
285
|
+
async function openRelogin(
|
|
286
|
+
id: string,
|
|
287
|
+
label: string,
|
|
288
|
+
providerID: string,
|
|
289
|
+
): Promise<void> {
|
|
290
|
+
try {
|
|
291
|
+
const methods = (await props.api.client.provider.auth()).data?.[
|
|
292
|
+
providerID
|
|
293
|
+
]
|
|
294
|
+
const method = pickAuthMethodIndex(methods)
|
|
295
|
+
if (method === null) {
|
|
296
|
+
props.api.ui.toast({
|
|
297
|
+
variant: 'error',
|
|
298
|
+
message: `No OAuth login method is registered for ${providerID}.`,
|
|
299
|
+
})
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const auth = (
|
|
304
|
+
await props.api.client.provider.oauth.authorize({
|
|
305
|
+
providerID,
|
|
306
|
+
method,
|
|
307
|
+
})
|
|
308
|
+
).data
|
|
309
|
+
if (!auth) {
|
|
310
|
+
props.api.ui.toast({
|
|
311
|
+
variant: 'error',
|
|
312
|
+
message: `Unable to start OAuth login for ${providerID}.`,
|
|
313
|
+
})
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
setReloginTargetInPool(id, providerID, Date.now())
|
|
318
|
+
|
|
319
|
+
const completeRelogin = async (code?: string): Promise<void> => {
|
|
320
|
+
try {
|
|
321
|
+
const completed = (
|
|
322
|
+
await props.api.client.provider.oauth.callback({
|
|
323
|
+
providerID,
|
|
324
|
+
method,
|
|
325
|
+
...(code === undefined ? {} : { code }),
|
|
326
|
+
})
|
|
327
|
+
).data
|
|
328
|
+
if (completed) {
|
|
329
|
+
props.api.ui.toast({
|
|
330
|
+
variant: 'success',
|
|
331
|
+
message: `Re-login complete for ${label}.`,
|
|
332
|
+
})
|
|
333
|
+
// The existing 3s usePool poll observes the consumed intent and
|
|
334
|
+
// cleared disabledReason; no manual state refresh is needed.
|
|
335
|
+
} else {
|
|
336
|
+
clearReloginTargetInPool()
|
|
337
|
+
props.api.ui.toast({
|
|
338
|
+
variant: 'error',
|
|
339
|
+
message: `Re-login failed for ${label}.`,
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
dialog().clear()
|
|
343
|
+
} catch {
|
|
344
|
+
clearReloginTargetInPool()
|
|
345
|
+
dialog().clear()
|
|
346
|
+
props.api.ui.toast({
|
|
347
|
+
variant: 'error',
|
|
348
|
+
message: `Re-login failed for ${label}.`,
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (auth.method === 'auto') {
|
|
354
|
+
await completeRelogin()
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
dialog().replace(() =>
|
|
359
|
+
props.api.ui.DialogPrompt({
|
|
360
|
+
title: `Re-login "${label}"`,
|
|
361
|
+
description: () => (
|
|
362
|
+
<box>
|
|
363
|
+
<text>{auth.instructions}</text>
|
|
364
|
+
<text>{auth.url}</text>
|
|
365
|
+
</box>
|
|
366
|
+
),
|
|
367
|
+
placeholder: 'Paste the authorization code',
|
|
368
|
+
onConfirm: (code: string) => {
|
|
369
|
+
const trimmed = code.trim()
|
|
370
|
+
if (!trimmed) {
|
|
371
|
+
clearReloginTargetInPool()
|
|
372
|
+
dialog().clear()
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
void completeRelogin(trimmed)
|
|
247
376
|
},
|
|
248
|
-
{
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
onSelect: () => openDelete(id, label),
|
|
377
|
+
onCancel: () => {
|
|
378
|
+
clearReloginTargetInPool()
|
|
379
|
+
dialog().clear()
|
|
252
380
|
},
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
381
|
+
}),
|
|
382
|
+
)
|
|
383
|
+
} catch {
|
|
384
|
+
clearReloginTargetInPool()
|
|
385
|
+
dialog().clear()
|
|
386
|
+
props.api.ui.toast({
|
|
387
|
+
variant: 'error',
|
|
388
|
+
message: `Re-login failed for ${label}.`,
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Click an account -> a small menu so Rename, Disable/Enable, Re-login, and Delete are
|
|
394
|
+
// all reachable. Deliberately NOT api.ui.DialogSelect: that always renders an
|
|
395
|
+
// auto-focused filter <input>, and a focused opentui input swallows the FIRST
|
|
396
|
+
// Esc (to blur itself) — so the menu needed TWO Esc presses to close. A plain
|
|
397
|
+
// clickable list has no input, so the dialog stack's own Esc binding closes it
|
|
398
|
+
// in ONE press. The menu is opened by a mouse click on the row, so mouse-driven
|
|
399
|
+
// options stay consistent (there is no keyboard path that opens it).
|
|
400
|
+
function openMenu(
|
|
401
|
+
id: string,
|
|
402
|
+
label: string,
|
|
403
|
+
providerID: string,
|
|
404
|
+
manuallyDisabled: boolean,
|
|
405
|
+
): void {
|
|
406
|
+
const items: { title: string; run: () => void }[] = [
|
|
407
|
+
{ title: 'Rename', run: () => openRename(id, label) },
|
|
408
|
+
{
|
|
409
|
+
// Reversible (just a scheduler skip), so no confirm step — flip the
|
|
410
|
+
// pool flag and close, unlike Delete which drops the tokens.
|
|
411
|
+
title: manuallyDisabled
|
|
412
|
+
? 'Enable — include in selection'
|
|
413
|
+
: 'Disable — exclude from selection',
|
|
414
|
+
run: () => {
|
|
415
|
+
setDisabledInPool(id, !manuallyDisabled)
|
|
416
|
+
dialog().clear()
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
title: 'Re-login — re-authorize with the provider',
|
|
421
|
+
run: () => {
|
|
422
|
+
void openRelogin(id, label, providerID)
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
{ title: 'Delete — remove from pool', run: () => openDelete(id, label) },
|
|
426
|
+
]
|
|
427
|
+
dialog().replace(() => {
|
|
428
|
+
const c = color()
|
|
429
|
+
const [hovered, setHovered] = createSignal(-1)
|
|
430
|
+
return (
|
|
431
|
+
<box
|
|
432
|
+
gap={1}
|
|
433
|
+
paddingBottom={1}
|
|
434
|
+
paddingLeft={4}
|
|
435
|
+
paddingRight={4}
|
|
436
|
+
paddingTop={1}
|
|
437
|
+
>
|
|
438
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
439
|
+
<text fg={c.text}>
|
|
440
|
+
<b>{label}</b>
|
|
441
|
+
</text>
|
|
442
|
+
<text fg={c.textMuted} onMouseUp={() => dialog().clear()}>
|
|
443
|
+
esc
|
|
444
|
+
</text>
|
|
445
|
+
</box>
|
|
446
|
+
<box>
|
|
447
|
+
<For each={items}>
|
|
448
|
+
{(item, i) => (
|
|
449
|
+
<box
|
|
450
|
+
onMouseMove={() => setHovered(i())}
|
|
451
|
+
onMouseUp={() => item.run()}
|
|
452
|
+
paddingLeft={1}
|
|
453
|
+
paddingRight={1}
|
|
454
|
+
>
|
|
455
|
+
<text fg={hovered() === i() ? c.primary : c.text}>
|
|
456
|
+
{item.title}
|
|
457
|
+
</text>
|
|
458
|
+
</box>
|
|
459
|
+
)}
|
|
460
|
+
</For>
|
|
461
|
+
</box>
|
|
462
|
+
</box>
|
|
463
|
+
)
|
|
464
|
+
})
|
|
256
465
|
}
|
|
257
466
|
|
|
258
467
|
return (
|
|
@@ -262,7 +471,7 @@ function SidebarPanel(props: { api: TuiPluginApi }) {
|
|
|
262
471
|
<b>Auth accounts</b>
|
|
263
472
|
<span style={{ fg: color().textMuted }}>
|
|
264
473
|
{' '}
|
|
265
|
-
(click: rename / delete)
|
|
474
|
+
(click: rename / disable / re-login / delete)
|
|
266
475
|
</span>
|
|
267
476
|
</text>
|
|
268
477
|
<For each={groups()}>
|
|
@@ -271,9 +480,19 @@ function SidebarPanel(props: { api: TuiPluginApi }) {
|
|
|
271
480
|
<text fg={color().textMuted}>{g.provider}</text>
|
|
272
481
|
<For each={g.rows}>
|
|
273
482
|
{(r) => (
|
|
274
|
-
<box
|
|
483
|
+
<box
|
|
484
|
+
onMouseUp={() =>
|
|
485
|
+
openMenu(r.id, r.label, r.providerID, r.manuallyDisabled)
|
|
486
|
+
}
|
|
487
|
+
>
|
|
275
488
|
<text
|
|
276
|
-
fg={
|
|
489
|
+
fg={
|
|
490
|
+
r.manuallyDisabled
|
|
491
|
+
? color().textMuted
|
|
492
|
+
: r.current
|
|
493
|
+
? color().primary
|
|
494
|
+
: color().text
|
|
495
|
+
}
|
|
277
496
|
wrapMode="word"
|
|
278
497
|
>
|
|
279
498
|
{(r.current ? '▶ ' : ' ') +
|
|
@@ -313,9 +532,11 @@ function makeSlots(api: TuiPluginApi): TuiSlotPlugin {
|
|
|
313
532
|
app_bottom() {
|
|
314
533
|
return <BottomBar api={api} />
|
|
315
534
|
},
|
|
316
|
-
// Panel in the session sidebar, next to Context / MCP / LSP.
|
|
317
|
-
|
|
318
|
-
|
|
535
|
+
// Panel in the session sidebar, next to Context / MCP / LSP. The host
|
|
536
|
+
// hands this slot the current `session_id`, so the in-use marker can scope
|
|
537
|
+
// to the VIEWER's session instead of the global last-selected account.
|
|
538
|
+
sidebar_content(_ctx, props) {
|
|
539
|
+
return <SidebarPanel api={api} sessionId={props.session_id} />
|
|
319
540
|
},
|
|
320
541
|
},
|
|
321
542
|
}
|