opencode-auth-load-balancer 0.2.2 → 0.4.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,16 +64,51 @@ 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
63
83
  // computed exactly once.
64
84
 
85
+ /** One poll sample: the pool, plus the instant it was read. */
86
+ interface PoolSample {
87
+ pool: PoolShape
88
+ now: number
89
+ }
90
+
91
+ /**
92
+ * Capture the pool AND the clock together, beside each other.
93
+ *
94
+ * The timestamp deliberately lives HERE rather than in each consuming memo.
95
+ * A memo that calls `Date.now()` itself is impure — it depends on something
96
+ * Solid cannot track, so its countdowns refresh only by the coincidence that
97
+ * the poll signal happens to change on the same tick — and it measures every
98
+ * `until()` against an instant slightly LATER than the snapshot it is
99
+ * describing. Sampling both at once makes the memos pure functions of one
100
+ * reactive input and keeps each countdown consistent with the pool state it
101
+ * is rendered from.
102
+ */
103
+ function pollSample(): PoolSample {
104
+ return { pool: readPool(), now: Date.now() }
105
+ }
106
+
65
107
  function usePool() {
66
- const [pool, setPool] = createSignal<PoolShape>(readPool())
67
- const timer = setInterval(() => setPool(readPool()), POLL_MS)
108
+ const [sample, setSample] = createSignal<PoolSample>(pollSample())
109
+ const timer = setInterval(() => setSample(pollSample()), POLL_MS)
68
110
  onCleanup(() => clearInterval(timer))
69
- return pool
111
+ return sample
70
112
  }
71
113
 
72
114
  /** Shared theme-color accessor used identically by `BottomBar` and `SidebarPanel`. */
@@ -85,24 +127,32 @@ interface WindowDisplay {
85
127
  interface Chip extends WindowDisplay {
86
128
  name: string
87
129
  label: string
130
+ /** Raw model ids (requested → served) when this session runs on a fallback model, else undefined. */
131
+ fallback?: { from: string; to: string }
88
132
  }
89
133
 
90
134
  /** Always-visible bottom bar (app_bottom): the in-use account per provider. */
91
135
  function BottomBar(props: { api: TuiPluginApi }) {
92
- const pool = usePool()
136
+ const sample = usePool()
93
137
  const color = themeColor(props.api)
94
138
  const chips = createMemo<Chip[]>(() => {
95
- const p = pool()
139
+ const { pool: p, now } = sample()
96
140
  const accounts = p.accounts ?? []
97
- const now = Date.now()
98
141
  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),
142
+ const sid = routeSessionId(props.api)
143
+ // Show the account THIS session is using per provider — NOT the global
144
+ // `lastSelected` (whichever session served last across the whole pool). On
145
+ // the home screen (no session) fall back to `lastSelected` so the bar isn't
146
+ // blank. Providers byte-sorted (`< / >`, no locale) so the bar lists them in
147
+ // the same order as the sidebar, CLI, and status tool.
148
+ const providerIds = [...new Set(accounts.map((x) => x.providerID))].sort(
149
+ compareAscii,
104
150
  )
105
- for (const [providerID, id] of selected) {
151
+ for (const providerID of providerIds) {
152
+ const id =
153
+ sessionAccountId(p, providerID, sid) ??
154
+ (sid ? undefined : p.lastSelected?.[providerID])
155
+ if (!id) continue
106
156
  const a = accounts.find((x) => x.id === id)
107
157
  if (!a) continue
108
158
  out.push({
@@ -112,6 +162,9 @@ function BottomBar(props: { api: TuiPluginApi }) {
112
162
  weeklyReset: until(a.usage?.weekly?.resetAt, now),
113
163
  hourlyPct: winPct(a.usage?.hourly, now),
114
164
  hourlyReset: until(a.usage?.hourly?.resetAt, now),
165
+ // Only THIS session's fallback (keyed by sid); the `lastSelected` home-screen
166
+ // fallback path has no session, so no downgrade to surface there.
167
+ fallback: sessionFallback(p, providerID, sid),
115
168
  })
116
169
  }
117
170
  return out
@@ -125,6 +178,11 @@ function BottomBar(props: { api: TuiPluginApi }) {
125
178
  <span style={{ fg: color().primary }}>
126
179
  {a.name} {a.label}
127
180
  </span>
181
+ {a.fallback ? (
182
+ <span style={{ fg: color().warning }}>
183
+ {` fallback ${a.fallback.from}→${a.fallback.to}`}
184
+ </span>
185
+ ) : null}
128
186
  {` wk ${a.weeklyPct} (${a.weeklyReset}) · 5h ${a.hourlyPct} (${a.hourlyReset})`}
129
187
  </text>
130
188
  )}
@@ -136,10 +194,12 @@ function BottomBar(props: { api: TuiPluginApi }) {
136
194
  interface Row extends WindowDisplay {
137
195
  id: string
138
196
  label: string
197
+ providerID: string
139
198
  current: boolean
140
199
  score: number | null
141
200
  rank: number | null
142
201
  state: string
202
+ manuallyDisabled: boolean
143
203
  }
144
204
  interface Group {
145
205
  provider: string
@@ -147,17 +207,23 @@ interface Group {
147
207
  }
148
208
 
149
209
  /** Sidebar panel: all accounts per provider, scored + sorted, click to rename. */
150
- function SidebarPanel(props: { api: TuiPluginApi }) {
151
- const pool = usePool()
210
+ function SidebarPanel(props: {
211
+ api: TuiPluginApi
212
+ sessionId: string | undefined
213
+ }) {
214
+ const sample = usePool()
152
215
  const color = themeColor(props.api)
153
216
  const groups = createMemo<Group[]>(() => {
154
- const p = pool()
217
+ const { pool: p, now } = sample()
155
218
  const accounts = p.accounts ?? []
156
- const now = Date.now()
157
219
  const providerIds = [...new Set(accounts.map((a) => a.providerID))].sort(
158
220
  compareAscii,
159
221
  )
160
222
  return providerIds.map((providerID) => {
223
+ // The account the VIEWER's session is pinned to for this provider — used
224
+ // for the in-use (▶) marker instead of the global `lastSelected`, so the
225
+ // marker reflects THIS session, not whichever session served last.
226
+ const sessionAcct = sessionAccountId(p, providerID, props.sessionId)
161
227
  const ranked = accounts
162
228
  .filter((a) => a.providerID === providerID)
163
229
  .map((a) => {
@@ -180,7 +246,8 @@ function SidebarPanel(props: { api: TuiPluginApi }) {
180
246
  return {
181
247
  id: a.id,
182
248
  label: a.label,
183
- current: p.lastSelected?.[providerID] === a.id,
249
+ providerID,
250
+ current: sessionAcct === a.id,
184
251
  score: available ? score : null,
185
252
  rank: available ? rank : null,
186
253
  // Reuse the windows `toScore(a)` already normalized instead of
@@ -196,6 +263,7 @@ function SidebarPanel(props: { api: TuiPluginApi }) {
196
263
  // `1e999` entry (Infinity via JSON.parse) would otherwise render its
197
264
  // tier annotation forever until the server heals the file.
198
265
  state: stateOf(sa, tierResets(a, now), now),
266
+ manuallyDisabled: a.disabledReason === MANUAL_DISABLED_REASON,
199
267
  }
200
268
  })
201
269
  return { provider: providerLabel(providerID), rows }
@@ -234,25 +302,187 @@ function SidebarPanel(props: { api: TuiPluginApi }) {
234
302
  )
235
303
  }
236
304
 
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),
305
+ async function openRelogin(
306
+ id: string,
307
+ label: string,
308
+ providerID: string,
309
+ ): Promise<void> {
310
+ try {
311
+ const methods = (await props.api.client.provider.auth()).data?.[
312
+ providerID
313
+ ]
314
+ const method = pickAuthMethodIndex(methods)
315
+ if (method === null) {
316
+ props.api.ui.toast({
317
+ variant: 'error',
318
+ message: `No OAuth login method is registered for ${providerID}.`,
319
+ })
320
+ return
321
+ }
322
+
323
+ const auth = (
324
+ await props.api.client.provider.oauth.authorize({
325
+ providerID,
326
+ method,
327
+ })
328
+ ).data
329
+ if (!auth) {
330
+ props.api.ui.toast({
331
+ variant: 'error',
332
+ message: `Unable to start OAuth login for ${providerID}.`,
333
+ })
334
+ return
335
+ }
336
+
337
+ // Timestamp intentionally left to the callee's default — see its doc.
338
+ setReloginTargetInPool(id, providerID)
339
+
340
+ const completeRelogin = async (code?: string): Promise<void> => {
341
+ try {
342
+ const completed = (
343
+ await props.api.client.provider.oauth.callback({
344
+ providerID,
345
+ method,
346
+ ...(code === undefined ? {} : { code }),
347
+ })
348
+ ).data
349
+ if (completed) {
350
+ props.api.ui.toast({
351
+ variant: 'success',
352
+ message: `Re-login complete for ${label}.`,
353
+ })
354
+ // The existing 3s usePool poll observes the consumed intent and
355
+ // cleared disabledReason; no manual state refresh is needed.
356
+ } else {
357
+ clearReloginTargetInPool()
358
+ props.api.ui.toast({
359
+ variant: 'error',
360
+ message: `Re-login failed for ${label}.`,
361
+ })
362
+ }
363
+ dialog().clear()
364
+ } catch {
365
+ clearReloginTargetInPool()
366
+ dialog().clear()
367
+ props.api.ui.toast({
368
+ variant: 'error',
369
+ message: `Re-login failed for ${label}.`,
370
+ })
371
+ }
372
+ }
373
+
374
+ if (auth.method === 'auto') {
375
+ await completeRelogin()
376
+ return
377
+ }
378
+
379
+ dialog().replace(() =>
380
+ props.api.ui.DialogPrompt({
381
+ title: `Re-login "${label}"`,
382
+ description: () => (
383
+ <box>
384
+ <text>{auth.instructions}</text>
385
+ <text>{auth.url}</text>
386
+ </box>
387
+ ),
388
+ placeholder: 'Paste the authorization code',
389
+ onConfirm: (code: string) => {
390
+ const trimmed = code.trim()
391
+ if (!trimmed) {
392
+ clearReloginTargetInPool()
393
+ dialog().clear()
394
+ return
395
+ }
396
+ void completeRelogin(trimmed)
247
397
  },
248
- {
249
- title: 'Delete — remove from pool',
250
- value: 'delete',
251
- onSelect: () => openDelete(id, label),
398
+ onCancel: () => {
399
+ clearReloginTargetInPool()
400
+ dialog().clear()
252
401
  },
253
- ],
254
- }),
255
- )
402
+ }),
403
+ )
404
+ } catch {
405
+ clearReloginTargetInPool()
406
+ dialog().clear()
407
+ props.api.ui.toast({
408
+ variant: 'error',
409
+ message: `Re-login failed for ${label}.`,
410
+ })
411
+ }
412
+ }
413
+
414
+ // Click an account -> a small menu so Rename, Disable/Enable, Re-login, and Delete are
415
+ // all reachable. Deliberately NOT api.ui.DialogSelect: that always renders an
416
+ // auto-focused filter <input>, and a focused opentui input swallows the FIRST
417
+ // Esc (to blur itself) — so the menu needed TWO Esc presses to close. A plain
418
+ // clickable list has no input, so the dialog stack's own Esc binding closes it
419
+ // in ONE press. The menu is opened by a mouse click on the row, so mouse-driven
420
+ // options stay consistent (there is no keyboard path that opens it).
421
+ function openMenu(
422
+ id: string,
423
+ label: string,
424
+ providerID: string,
425
+ manuallyDisabled: boolean,
426
+ ): void {
427
+ const items: { title: string; run: () => void }[] = [
428
+ { title: 'Rename', run: () => openRename(id, label) },
429
+ {
430
+ // Reversible (just a scheduler skip), so no confirm step — flip the
431
+ // pool flag and close, unlike Delete which drops the tokens.
432
+ title: manuallyDisabled
433
+ ? 'Enable — include in selection'
434
+ : 'Disable — exclude from selection',
435
+ run: () => {
436
+ setDisabledInPool(id, !manuallyDisabled)
437
+ dialog().clear()
438
+ },
439
+ },
440
+ {
441
+ title: 'Re-login — re-authorize with the provider',
442
+ run: () => {
443
+ void openRelogin(id, label, providerID)
444
+ },
445
+ },
446
+ { title: 'Delete — remove from pool', run: () => openDelete(id, label) },
447
+ ]
448
+ dialog().replace(() => {
449
+ const c = color()
450
+ const [hovered, setHovered] = createSignal(-1)
451
+ return (
452
+ <box
453
+ gap={1}
454
+ paddingBottom={1}
455
+ paddingLeft={4}
456
+ paddingRight={4}
457
+ paddingTop={1}
458
+ >
459
+ <box flexDirection="row" justifyContent="space-between">
460
+ <text fg={c.text}>
461
+ <b>{label}</b>
462
+ </text>
463
+ <text fg={c.textMuted} onMouseUp={() => dialog().clear()}>
464
+ esc
465
+ </text>
466
+ </box>
467
+ <box>
468
+ <For each={items}>
469
+ {(item, i) => (
470
+ <box
471
+ onMouseMove={() => setHovered(i())}
472
+ onMouseUp={() => item.run()}
473
+ paddingLeft={1}
474
+ paddingRight={1}
475
+ >
476
+ <text fg={hovered() === i() ? c.primary : c.text}>
477
+ {item.title}
478
+ </text>
479
+ </box>
480
+ )}
481
+ </For>
482
+ </box>
483
+ </box>
484
+ )
485
+ })
256
486
  }
257
487
 
258
488
  return (
@@ -262,7 +492,7 @@ function SidebarPanel(props: { api: TuiPluginApi }) {
262
492
  <b>Auth accounts</b>
263
493
  <span style={{ fg: color().textMuted }}>
264
494
  {' '}
265
- (click: rename / delete)
495
+ (click: rename / disable / re-login / delete)
266
496
  </span>
267
497
  </text>
268
498
  <For each={groups()}>
@@ -271,9 +501,19 @@ function SidebarPanel(props: { api: TuiPluginApi }) {
271
501
  <text fg={color().textMuted}>{g.provider}</text>
272
502
  <For each={g.rows}>
273
503
  {(r) => (
274
- <box onMouseUp={() => openMenu(r.id, r.label)}>
504
+ <box
505
+ onMouseUp={() =>
506
+ openMenu(r.id, r.label, r.providerID, r.manuallyDisabled)
507
+ }
508
+ >
275
509
  <text
276
- fg={r.current ? color().primary : color().text}
510
+ fg={
511
+ r.manuallyDisabled
512
+ ? color().textMuted
513
+ : r.current
514
+ ? color().primary
515
+ : color().text
516
+ }
277
517
  wrapMode="word"
278
518
  >
279
519
  {(r.current ? '▶ ' : ' ') +
@@ -313,9 +553,11 @@ function makeSlots(api: TuiPluginApi): TuiSlotPlugin {
313
553
  app_bottom() {
314
554
  return <BottomBar api={api} />
315
555
  },
316
- // Panel in the session sidebar, next to Context / MCP / LSP.
317
- sidebar_content() {
318
- return <SidebarPanel api={api} />
556
+ // Panel in the session sidebar, next to Context / MCP / LSP. The host
557
+ // hands this slot the current `session_id`, so the in-use marker can scope
558
+ // to the VIEWER's session instead of the global last-selected account.
559
+ sidebar_content(_ctx, props) {
560
+ return <SidebarPanel api={api} sessionId={props.session_id} />
319
561
  },
320
562
  },
321
563
  }