opencode-auth-load-balancer 0.5.0 → 0.6.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.
@@ -12,7 +12,8 @@
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
14
  * Click any account row for a menu to Rename (prompt), Disable/Enable
15
- * (toggle), Re-login (provider OAuth), or Delete (confirm) it.
15
+ * (toggle), Re-login (provider OAuth), pair a Claude row's other login
16
+ * (setup-token for inference / OAuth for usage), or Delete (confirm) it.
16
17
  *
17
18
  * The ranking is computed by the SAME code the server scheduler uses — imported from a
18
19
  * byte-identical copy of src/scheduler/score-core.ts installed alongside this file
@@ -30,25 +31,40 @@ import {
30
31
  utilOf,
31
32
  } from './auth-load-balancer-scoring'
32
33
  import {
34
+ awaitSyncResult,
33
35
  cfg,
36
+ cleanLabel,
34
37
  clearReloginTargetInPool,
35
38
  compareAscii,
39
+ credentialTag,
36
40
  deleteFromPool,
41
+ discardSyncIntent,
42
+ hasLostLogin,
43
+ isGistLinkShape,
44
+ lostLoginLines,
37
45
  MANUAL_DISABLED_REASON,
38
46
  pct,
39
47
  pickAuthMethodIndex,
40
48
  type PoolShape,
41
49
  readPool,
50
+ readShareLink,
51
+ readSyncView,
42
52
  renameInPool,
53
+ rowWarnings,
43
54
  sessionAccountId,
44
55
  sessionFallback,
45
56
  setDisabledInPool,
46
57
  setReloginTargetInPool,
47
58
  stateOf,
59
+ type SyncAction,
60
+ syncLines,
61
+ syncMenu,
62
+ type SyncMenuId,
48
63
  tierResets,
49
64
  toScore,
50
65
  until,
51
66
  winPct,
67
+ writeSyncIntent,
52
68
  } from './auth-load-balancer-tui.logic'
53
69
 
54
70
  // NOTE: ships its OWN copy of `src/status.ts`'s `PROVIDER_NAMES` by design —
@@ -113,6 +129,14 @@ function usePool() {
113
129
  return sample
114
130
  }
115
131
 
132
+ /** One poll sample of the sync state: role, last sync, latest outcome (never the key). */
133
+ function useSync() {
134
+ const [view, setView] = createSignal(readSyncView())
135
+ const timer = setInterval(() => setView(readSyncView()), POLL_MS)
136
+ onCleanup(() => clearInterval(timer))
137
+ return view
138
+ }
139
+
116
140
  /** Shared theme-color accessor used identically by `BottomBar` and `SidebarPanel`. */
117
141
  function themeColor(api: TuiPluginApi) {
118
142
  return () => api.theme.current
@@ -131,6 +155,8 @@ interface Chip extends WindowDisplay {
131
155
  label: string
132
156
  /** Raw model ids (requested → served) when this session runs on a fallback model, else undefined. */
133
157
  fallback?: { from: string; to: string }
158
+ /** `<label> <warning>` for each login warning on this provider's rows (`rowWarnings`). */
159
+ warnings: string[]
134
160
  }
135
161
 
136
162
  /** Always-visible bottom bar (app_bottom): the in-use account per provider. */
@@ -159,7 +185,7 @@ function BottomBar(props: { api: TuiPluginApi }) {
159
185
  if (!a) continue
160
186
  out.push({
161
187
  name: providerLabel(providerID),
162
- label: a.label,
188
+ label: cleanLabel(a.label),
163
189
  weeklyPct: winPct(a.usage?.weekly, now),
164
190
  weeklyReset: until(a.usage?.weekly?.resetAt, now),
165
191
  hourlyPct: winPct(a.usage?.hourly, now),
@@ -167,6 +193,9 @@ function BottomBar(props: { api: TuiPluginApi }) {
167
193
  // Only THIS session's fallback (keyed by sid); the `lastSelected` home-screen
168
194
  // fallback path has no session, so no downgrade to surface there.
169
195
  fallback: sessionFallback(p, providerID, sid),
196
+ warnings: accounts
197
+ .filter((x) => x.providerID === providerID)
198
+ .flatMap((x) => rowWarnings(x, now).map((w) => `${x.label} ${w}`)),
170
199
  })
171
200
  }
172
201
  return out
@@ -186,6 +215,11 @@ function BottomBar(props: { api: TuiPluginApi }) {
186
215
  </span>
187
216
  ) : null}
188
217
  {` wk ${a.weeklyPct} (${a.weeklyReset}) · 5h ${a.hourlyPct} (${a.hourlyReset})`}
218
+ {a.warnings.length > 0 ? (
219
+ <span style={{ fg: color().warning }}>
220
+ {` ! ${a.warnings.join(' · ')}`}
221
+ </span>
222
+ ) : null}
189
223
  </text>
190
224
  )}
191
225
  </For>
@@ -202,8 +236,15 @@ interface Row extends WindowDisplay {
202
236
  rank: number | null
203
237
  state: string
204
238
  manuallyDisabled: boolean
205
- /** A static API-key row: re-login asks for a new key, not an OAuth sign-in. */
239
+ /** A static-credential row (API key, setup-token): re-login asks for a new one, not an OAuth sign-in. */
206
240
  apiKey: boolean
241
+ /** `credentialTag`: `token` / `token+oauth` on a setup-token row, else ''. */
242
+ login: string
243
+ /** A login of this row was refused for good: its menu offers to re-login that one. */
244
+ oauthLost: boolean
245
+ tokenLost: boolean
246
+ /** Why each lost login stopped working (`lostLoginLines`). */
247
+ lostLines: string[]
207
248
  }
208
249
  interface Group {
209
250
  provider: string
@@ -216,6 +257,7 @@ function SidebarPanel(props: {
216
257
  sessionId: string | undefined
217
258
  }) {
218
259
  const sample = usePool()
260
+ const sync = useSync()
219
261
  const color = themeColor(props.api)
220
262
  const groups = createMemo<Group[]>(() => {
221
263
  const { pool: p, now } = sample()
@@ -249,7 +291,7 @@ function SidebarPanel(props: {
249
291
  if (available) rank += 1
250
292
  return {
251
293
  id: a.id,
252
- label: a.label,
294
+ label: cleanLabel(a.label),
253
295
  providerID,
254
296
  current: sessionAcct === a.id,
255
297
  score: available ? score : null,
@@ -266,9 +308,15 @@ function SidebarPanel(props: {
266
308
  // Raw reads are finiteness-guarded inside tierResets: a hand-edited
267
309
  // `1e999` entry (Infinity via JSON.parse) would otherwise render its
268
310
  // tier annotation forever until the server heals the file.
269
- state: stateOf(sa, tierResets(a, now), now),
311
+ state: [stateOf(sa, tierResets(a, now), now), ...rowWarnings(a, now)]
312
+ .filter(Boolean)
313
+ .join(' · '),
270
314
  manuallyDisabled: a.disabledReason === MANUAL_DISABLED_REASON,
271
315
  apiKey: a.refresh === '',
316
+ login: credentialTag(a),
317
+ oauthLost: hasLostLogin(a, 'oauth'),
318
+ tokenLost: hasLostLogin(a, 'token'),
319
+ lostLines: lostLoginLines(a, now),
272
320
  }
273
321
  })
274
322
  return { provider: providerLabel(providerID), rows }
@@ -307,17 +355,22 @@ function SidebarPanel(props: {
307
355
  )
308
356
  }
309
357
 
358
+ /**
359
+ * Run one of the provider's pooled logins onto the clicked row:
360
+ * `staticLogin` picks the setup-token / API-key login, else the OAuth one.
361
+ * `action` names the step in the dialog and toasts ("Re-login", …).
362
+ */
310
363
  async function openRelogin(
311
- id: string,
312
- label: string,
313
- providerID: string,
314
- apiKey: boolean,
364
+ row: Row,
365
+ staticLogin: boolean,
366
+ action: string,
315
367
  ): Promise<void> {
368
+ const { id, label, providerID } = row
316
369
  try {
317
370
  const methods = (await props.api.client.provider.auth()).data?.[
318
371
  providerID
319
372
  ]
320
- const method = pickAuthMethodIndex(methods, apiKey)
373
+ const method = pickAuthMethodIndex(methods, staticLogin)
321
374
  if (method === null) {
322
375
  props.api.ui.toast({
323
376
  variant: 'error',
@@ -355,7 +408,7 @@ function SidebarPanel(props: {
355
408
  if (completed) {
356
409
  props.api.ui.toast({
357
410
  variant: 'success',
358
- message: `Re-login complete for ${label}.`,
411
+ message: `${action} complete for ${label}.`,
359
412
  })
360
413
  // The existing 3s usePool poll observes the consumed intent and
361
414
  // cleared disabledReason; no manual state refresh is needed.
@@ -363,7 +416,7 @@ function SidebarPanel(props: {
363
416
  clearReloginTargetInPool()
364
417
  props.api.ui.toast({
365
418
  variant: 'error',
366
- message: `Re-login failed for ${label}.`,
419
+ message: `${action} failed for ${label}.`,
367
420
  })
368
421
  }
369
422
  dialog().clear()
@@ -372,7 +425,7 @@ function SidebarPanel(props: {
372
425
  dialog().clear()
373
426
  props.api.ui.toast({
374
427
  variant: 'error',
375
- message: `Re-login failed for ${label}.`,
428
+ message: `${action} failed for ${label}.`,
376
429
  })
377
430
  }
378
431
  }
@@ -382,7 +435,7 @@ function SidebarPanel(props: {
382
435
  // to approve it for as long as the callback waits.
383
436
  dialog().replace(() =>
384
437
  props.api.ui.DialogAlert({
385
- title: `Re-login "${label}"`,
438
+ title: `${action} "${label}"`,
386
439
  message: `${auth.instructions}\n\n${auth.url}`,
387
440
  onConfirm: () => dialog().clear(),
388
441
  }),
@@ -393,7 +446,7 @@ function SidebarPanel(props: {
393
446
 
394
447
  dialog().replace(() =>
395
448
  props.api.ui.DialogPrompt({
396
- title: `Re-login "${label}"`,
449
+ title: `${action} "${label}"`,
397
450
  description: () => (
398
451
  <box>
399
452
  <text>{auth.instructions}</text>
@@ -421,46 +474,15 @@ function SidebarPanel(props: {
421
474
  dialog().clear()
422
475
  props.api.ui.toast({
423
476
  variant: 'error',
424
- message: `Re-login failed for ${label}.`,
477
+ message: `${action} failed for ${label}.`,
425
478
  })
426
479
  }
427
480
  }
428
481
 
429
- // Click an account -> a small menu so Rename, Disable/Enable, Re-login, and Delete are
430
- // all reachable. Deliberately NOT api.ui.DialogSelect: that always renders an
431
- // auto-focused filter <input>, and a focused opentui input swallows the FIRST
432
- // Esc (to blur itself) — so the menu needed TWO Esc presses to close. A plain
433
- // clickable list has no input, so the dialog stack's own Esc binding closes it
434
- // in ONE press. The menu is opened by a mouse click on the row, so mouse-driven
435
- // options stay consistent (there is no keyboard path that opens it).
436
- function openMenu(
437
- id: string,
438
- label: string,
439
- providerID: string,
440
- manuallyDisabled: boolean,
441
- apiKey: boolean,
482
+ function showMenu(
483
+ title: string,
484
+ items: readonly { title: string; run: () => void }[],
442
485
  ): void {
443
- const items: { title: string; run: () => void }[] = [
444
- { title: 'Rename', run: () => openRename(id, label) },
445
- {
446
- // Reversible (just a scheduler skip), so no confirm step — flip the
447
- // pool flag and close, unlike Delete which drops the tokens.
448
- title: manuallyDisabled
449
- ? 'Enable — include in selection'
450
- : 'Disable — exclude from selection',
451
- run: () => {
452
- setDisabledInPool(id, !manuallyDisabled)
453
- dialog().clear()
454
- },
455
- },
456
- {
457
- title: 'Re-login — re-authorize with the provider',
458
- run: () => {
459
- void openRelogin(id, label, providerID, apiKey)
460
- },
461
- },
462
- { title: 'Delete — remove from pool', run: () => openDelete(id, label) },
463
- ]
464
486
  dialog().replace(() => {
465
487
  const c = color()
466
488
  const [hovered, setHovered] = createSignal(-1)
@@ -474,7 +496,7 @@ function SidebarPanel(props: {
474
496
  >
475
497
  <box flexDirection="row" justifyContent="space-between">
476
498
  <text fg={c.text}>
477
- <b>{label}</b>
499
+ <b>{title}</b>
478
500
  </text>
479
501
  <text fg={c.textMuted} onMouseUp={() => dialog().clear()}>
480
502
  esc
@@ -501,68 +523,238 @@ function SidebarPanel(props: {
501
523
  })
502
524
  }
503
525
 
526
+ // Click an account -> a small menu so Rename, Disable/Enable, Re-login, pairing, and
527
+ // Delete are all reachable. Deliberately NOT api.ui.DialogSelect: that always renders an
528
+ // auto-focused filter <input>, and a focused opentui input swallows the FIRST
529
+ // Esc (to blur itself) — so the menu needed TWO Esc presses to close. A plain
530
+ // clickable list has no input, so the dialog stack's own Esc binding closes it
531
+ // in ONE press. The menu is opened by a mouse click on the row, so mouse-driven
532
+ // options stay consistent (there is no keyboard path that opens it).
533
+ function openMenu(row: Row): void {
534
+ const { id, label, manuallyDisabled } = row
535
+ // A Claude row holds two logins — a setup-token serving inference, an
536
+ // OAuth login measuring usage (and serving inference without a token) —
537
+ // so it gets one item per login instead of an ambiguous "Re-login".
538
+ const hasOAuth = !row.apiKey
539
+ const hasToken = row.login !== ''
540
+ const logins =
541
+ row.providerID === 'anthropic'
542
+ ? [
543
+ {
544
+ action:
545
+ hasOAuth || row.oauthLost
546
+ ? 'Re-login OAuth'
547
+ : 'Add OAuth login',
548
+ role: hasToken ? 'usage' : 'inference + usage',
549
+ staticLogin: false,
550
+ },
551
+ {
552
+ action: hasToken
553
+ ? 'Replace setup-token'
554
+ : row.tokenLost
555
+ ? 'Re-login setup-token'
556
+ : 'Add setup-token',
557
+ role: 'inference',
558
+ staticLogin: true,
559
+ },
560
+ ]
561
+ : [
562
+ {
563
+ action: 'Re-login',
564
+ role: 're-authorize with the provider',
565
+ staticLogin: row.apiKey,
566
+ },
567
+ ]
568
+ const items: { title: string; run: () => void }[] = [
569
+ { title: 'Rename', run: () => openRename(id, label) },
570
+ {
571
+ // Reversible (just a scheduler skip), so no confirm step — flip the
572
+ // pool flag and close, unlike Delete which drops the tokens.
573
+ title: manuallyDisabled
574
+ ? 'Enable — include in selection'
575
+ : 'Disable — exclude from selection',
576
+ run: () => {
577
+ setDisabledInPool(id, !manuallyDisabled)
578
+ dialog().clear()
579
+ },
580
+ },
581
+ ...logins.map(({ action, role, staticLogin }) => ({
582
+ title: `${action} — ${role}`,
583
+ run: () => {
584
+ void openRelogin(row, staticLogin, action)
585
+ },
586
+ })),
587
+ { title: 'Delete — remove from pool', run: () => openDelete(id, label) },
588
+ ]
589
+ showMenu(label, items)
590
+ }
591
+
592
+ /**
593
+ * Ask the server for a sync action and report its outcome. The toast carries
594
+ * the server's own message, which never holds the link; only `showLink`,
595
+ * which the user chose, opens the link in a dialog.
596
+ */
597
+ async function runSync(
598
+ action: SyncAction,
599
+ link?: string,
600
+ showLink = false,
601
+ ): Promise<void> {
602
+ dialog().clear()
603
+ const at = writeSyncIntent(action, link)
604
+ if (action === 'forget') return
605
+ const status = await awaitSyncResult(at)
606
+ if (!status) discardSyncIntent(at)
607
+ props.api.ui.toast({
608
+ variant: status?.ok ? 'success' : 'error',
609
+ message: status?.message ?? 'Sync is taking longer than expected.',
610
+ })
611
+ const shared = status?.ok && showLink ? readShareLink() : undefined
612
+ if (shared) openLink(shared)
613
+ }
614
+
615
+ function openLink(link: string): void {
616
+ dialog().replace(() =>
617
+ props.api.ui.DialogAlert({
618
+ title: 'Share link',
619
+ message: `${link}\n\nOpen opencode on another machine, choose Sync here, and paste this. Anyone holding it can read these credentials: if it leaks, delete the gist and upload again for a new link.`,
620
+ onConfirm: () => dialog().clear(),
621
+ }),
622
+ )
623
+ }
624
+
625
+ function openSubscribe(): void {
626
+ dialog().replace(() =>
627
+ props.api.ui.DialogPrompt({
628
+ title: 'Follow a gist link',
629
+ placeholder: 'https://gist.github.com/<user>/<id>#<key>',
630
+ onConfirm: (text: string) => {
631
+ if (isGistLinkShape(text)) void runSync('subscribe', text)
632
+ else {
633
+ dialog().clear()
634
+ props.api.ui.toast({
635
+ variant: 'error',
636
+ message: 'That is not a gist link with a key.',
637
+ })
638
+ }
639
+ },
640
+ onCancel: () => dialog().clear(),
641
+ }),
642
+ )
643
+ }
644
+
645
+ function openForget(): void {
646
+ dialog().replace(() =>
647
+ props.api.ui.DialogConfirm({
648
+ title: 'Stop syncing',
649
+ message:
650
+ 'Stop syncing with the gist? Accounts already imported stay in the pool; the gist itself is not deleted.',
651
+ onConfirm: () => void runSync('forget'),
652
+ onCancel: () => dialog().clear(),
653
+ }),
654
+ )
655
+ }
656
+
657
+ const syncRuns: Record<SyncMenuId, () => void> = {
658
+ 'show-link': () => {
659
+ const link = readShareLink()
660
+ if (link) openLink(link)
661
+ },
662
+ upload: () => void runSync('upload'),
663
+ 'upload-new': () => void runSync('upload-new', undefined, true),
664
+ sync: () => void runSync('sync'),
665
+ subscribe: openSubscribe,
666
+ forget: openForget,
667
+ }
668
+
669
+ function openSyncMenu(): void {
670
+ showMenu(
671
+ 'Gist sync',
672
+ syncMenu(sync()).map((item) => ({
673
+ title: item.title,
674
+ run: syncRuns[item.id],
675
+ })),
676
+ )
677
+ }
678
+
504
679
  return (
505
- <Show when={groups().length > 0}>
506
- <box>
680
+ <box>
681
+ <Show when={groups().length > 0}>
682
+ <box>
683
+ <text fg={color().text}>
684
+ <b>Auth accounts</b>
685
+ <span style={{ fg: color().textMuted }}>
686
+ {' '}
687
+ (click: rename / disable / re-login / delete)
688
+ </span>
689
+ </text>
690
+ <For each={groups()}>
691
+ {(g) => (
692
+ <box>
693
+ <text fg={color().textMuted}>{g.provider}</text>
694
+ <For each={g.rows}>
695
+ {(r) => (
696
+ <box onMouseUp={() => openMenu(r)}>
697
+ <text
698
+ fg={
699
+ r.manuallyDisabled
700
+ ? color().textMuted
701
+ : r.current
702
+ ? color().primary
703
+ : color().text
704
+ }
705
+ wrapMode="word"
706
+ >
707
+ {(r.current ? '▶ ' : ' ') +
708
+ (r.rank ? `${r.rank}. ` : ' ') +
709
+ r.label}
710
+ <Show when={r.login}>
711
+ <span style={{ fg: color().textMuted }}>
712
+ {` [${r.login}]`}
713
+ </span>
714
+ </Show>
715
+ <Show when={r.score !== null}>
716
+ <span style={{ fg: color().secondary }}>
717
+ {` ${(r.score ?? 0).toFixed(2)}`}
718
+ </span>
719
+ </Show>
720
+ <Show when={r.state}>
721
+ <span
722
+ style={{ fg: color().warning }}
723
+ >{` (${r.state})`}</span>
724
+ </Show>
725
+ </text>
726
+ <text fg={color().textMuted}>
727
+ {` wk ${r.weeklyPct} (${r.weeklyReset}) · 5h ${r.hourlyPct} (${r.hourlyReset})`}
728
+ </text>
729
+ <For each={r.lostLines}>
730
+ {(line) => (
731
+ <text fg={color().textMuted} wrapMode="word">
732
+ {` ${line}`}
733
+ </text>
734
+ )}
735
+ </For>
736
+ </box>
737
+ )}
738
+ </For>
739
+ </box>
740
+ )}
741
+ </For>
742
+ </box>
743
+ </Show>
744
+ <box onMouseUp={openSyncMenu}>
507
745
  <text fg={color().text}>
508
- <b>Auth accounts</b>
509
- <span style={{ fg: color().textMuted }}>
510
- {' '}
511
- (click: rename / disable / re-login / delete)
512
- </span>
746
+ <b>Gist sync</b>
747
+ <span style={{ fg: color().textMuted }}> (click)</span>
513
748
  </text>
514
- <For each={groups()}>
515
- {(g) => (
516
- <box>
517
- <text fg={color().textMuted}>{g.provider}</text>
518
- <For each={g.rows}>
519
- {(r) => (
520
- <box
521
- onMouseUp={() =>
522
- openMenu(
523
- r.id,
524
- r.label,
525
- r.providerID,
526
- r.manuallyDisabled,
527
- r.apiKey,
528
- )
529
- }
530
- >
531
- <text
532
- fg={
533
- r.manuallyDisabled
534
- ? color().textMuted
535
- : r.current
536
- ? color().primary
537
- : color().text
538
- }
539
- wrapMode="word"
540
- >
541
- {(r.current ? '▶ ' : ' ') +
542
- (r.rank ? `${r.rank}. ` : ' ') +
543
- r.label}
544
- <Show when={r.score !== null}>
545
- <span style={{ fg: color().secondary }}>
546
- {` ${(r.score ?? 0).toFixed(2)}`}
547
- </span>
548
- </Show>
549
- <Show when={r.state}>
550
- <span
551
- style={{ fg: color().warning }}
552
- >{` (${r.state})`}</span>
553
- </Show>
554
- </text>
555
- <text fg={color().textMuted}>
556
- {` wk ${r.weeklyPct} (${r.weeklyReset}) · 5h ${r.hourlyPct} (${r.hourlyReset})`}
557
- </text>
558
- </box>
559
- )}
560
- </For>
561
- </box>
749
+ <For each={syncLines(sync(), sample().now)}>
750
+ {(line) => (
751
+ <text fg={color().textMuted} wrapMode="word">
752
+ {` ${line}`}
753
+ </text>
562
754
  )}
563
755
  </For>
564
756
  </box>
565
- </Show>
757
+ </box>
566
758
  )
567
759
  }
568
760