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.
@@ -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) or Delete (confirm) it,
15
- * written straight to the pool file.
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
- // `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),
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 [providerID, id] of selected) {
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: { api: TuiPluginApi }) {
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
- current: p.lastSelected?.[providerID] === a.id,
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
- // 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),
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
- title: 'Delete — remove from pool',
250
- value: 'delete',
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 onMouseUp={() => openMenu(r.id, r.label)}>
483
+ <box
484
+ onMouseUp={() =>
485
+ openMenu(r.id, r.label, r.providerID, r.manuallyDisabled)
486
+ }
487
+ >
275
488
  <text
276
- fg={r.current ? color().primary : color().text}
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
- sidebar_content() {
318
- return <SidebarPanel api={api} />
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
  }