routstrd 0.4.9 → 0.4.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "routstrd",
3
- "version": "0.4.9",
3
+ "version": "0.4.10",
4
4
  "module": "src/index.ts",
5
5
  "type": "module",
6
6
  "private": false,
@@ -38,7 +38,7 @@
38
38
  "@cashu/cashu-ts": "^4.3.0",
39
39
  "@cashu/coco-core": "^1.0.1",
40
40
  "@cashu/coco-sqlite-bun": "^1.0.1",
41
- "@routstr/sdk": "^0.4.4",
41
+ "@routstr/sdk": "^0.4.5",
42
42
  "@scure/bip39": "^2.2.0",
43
43
  "applesauce-core": "^5.1.0",
44
44
  "applesauce-relay": "^5.1.0",
@@ -87,22 +87,25 @@ export function createModelService(
87
87
  const providers = await modelManager.bootstrapProviders(false);
88
88
  logger.log(`Bootstrapped ${providers.length} providers`);
89
89
 
90
- // Sync discovered providers into the store so `providers list` reflects
91
- // the same set that the model manager knows about.
90
+ // Mirror discovery into the store so `providers list` reports the same
91
+ // set that the model manager polls and routes to. The list is
92
+ // *replaced*, not merged: an add-only merge keeps URLs discovery no
93
+ // longer reports (e.g. after a bootstrap regression or a provider
94
+ // unpublishing) visible to the CLI and to per-model views while
95
+ // routing silently ignores them.
92
96
  const {
93
97
  baseUrlsList,
94
98
  setBaseUrlsList,
95
99
  setDisabledProviders,
96
100
  } = store.getState();
97
- const existing = new Set(baseUrlsList);
98
- const merged = [
99
- ...baseUrlsList,
100
- ...providers.filter((url) => !existing.has(url)),
101
- ];
102
- if (merged.length !== baseUrlsList.length) {
103
- setBaseUrlsList(merged);
101
+ const known = new Set(baseUrlsList);
102
+ const inSync =
103
+ known.size === providers.length &&
104
+ providers.every((url) => known.has(url));
105
+ if (!inSync) {
106
+ setBaseUrlsList(providers);
104
107
  logger.log(
105
- `Synced ${merged.length - baseUrlsList.length} new provider(s) into store`,
108
+ `Synced ${providers.length} discovered provider(s) into store (was ${baseUrlsList.length})`,
106
109
  );
107
110
  }
108
111
 
@@ -1,6 +1,6 @@
1
1
  import { getVisibleTabs } from "./constants.ts";
2
2
  import type { Tab } from "./types.ts";
3
- import { fetchBalance, fetchClients, fetchStatus, fetchUsageSummary, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type StatusInfo } from "./data.ts";
3
+ import { buildClientNaming, emptyClientNaming, fetchBalance, fetchClients, fetchNpubs, fetchStatus, fetchUsageSummary, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type ClientNaming, type NpubEntry, type StatusInfo } from "./data.ts";
4
4
  import {
5
5
  applyScrollToContent,
6
6
  exitSearchMode,
@@ -48,6 +48,8 @@ export async function runUsageTui(): Promise<void> {
48
48
  let balance: BalanceInfo | null = null;
49
49
  let status: StatusInfo | null = null;
50
50
  let clients: ClientInfo[] = [];
51
+ let npubs: NpubEntry[] = [];
52
+ let naming: ClientNaming = emptyClientNaming();
51
53
  let visibleTabs: Tab[] = getVisibleTabs(false);
52
54
  let refreshInterval: ReturnType<typeof setInterval> | null = null;
53
55
  let autoRefresh = true;
@@ -144,6 +146,18 @@ export async function runUsageTui(): Promise<void> {
144
146
  currentTab = "clients";
145
147
  vimState.scrollPos = 0;
146
148
  }
149
+
150
+ // Names/roles live on the auth proxy, not the usage summary. Only
151
+ // fetch them when the Npubs tab is actually reachable, and keep the
152
+ // last good list if the endpoint is temporarily unreachable.
153
+ if (npubsVisible) {
154
+ const newNpubs = await fetchNpubs();
155
+ if (newNpubs.length > 0) npubs = newNpubs;
156
+ }
157
+
158
+ // Owner info (for "Name (client-id)" labels in the Recent tab) comes
159
+ // from /clients + /npubs; both are empty in local mode.
160
+ naming = buildClientNaming(clients, npubs);
147
161
  }
148
162
  render();
149
163
  } finally {
@@ -170,7 +184,7 @@ export async function runUsageTui(): Promise<void> {
170
184
  return;
171
185
  }
172
186
 
173
- const content = renderTabContent(currentTab, stats, balance, status, width, clients);
187
+ const content = renderTabContent(currentTab, stats, balance, status, width, naming);
174
188
  const footer = `${COLORS.dim}Press [Q] to quit, [R] to refresh, [A] to toggle auto-refresh${autoRefresh ? " (on)" : " (off)"} scroll:${vimState.scrollPos}${COLORS.reset}${vimState.mode === "normal" ? ` ${COLORS.yellow}vim: hjkl/arrows, / search, g top, gg bottom${COLORS.reset}` : ""}`;
175
189
  const chrome = renderHeader(currentTab, width, visibleTabs, updateInfo ?? undefined) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
176
190
  const chromeLines = chrome.split("\n").length - 1;
@@ -1,5 +1,6 @@
1
1
  import type { UsageTrackingEntry } from "../../daemon/types.ts";
2
2
  import {
3
+ callAuth,
3
4
  callDaemon,
4
5
  getDaemonBaseUrl,
5
6
  isDaemonRunning,
@@ -145,3 +146,112 @@ export async function fetchClients(): Promise<ClientInfo[]> {
145
146
  export function hasAnyNpubs(clients: ClientInfo[]): boolean {
146
147
  return clients.some((c) => !!c.ownerNpub);
147
148
  }
149
+
150
+ /** A configured npub as returned by the auth proxy (`/npubs`). */
151
+ export interface NpubEntry {
152
+ npub: string;
153
+ name: string | null;
154
+ role: string;
155
+ }
156
+
157
+ /**
158
+ * Fetch the configured npubs (with their display names and roles) from the
159
+ * auth proxy. Returns an empty list when the endpoint is unavailable — e.g. a
160
+ * local daemon that doesn't route `/npubs` — so the TUI degrades gracefully.
161
+ */
162
+ export async function fetchNpubs(): Promise<NpubEntry[]> {
163
+ try {
164
+ const result = await callAuth("/npubs");
165
+ if (result.error) return [];
166
+
167
+ // Handle both wrapped { output: { npubs } } and direct { npubs } responses.
168
+ const direct = (result as { npubs?: NpubEntry[] }).npubs;
169
+ const wrapped = (result.output as { npubs?: NpubEntry[] } | undefined)?.npubs;
170
+ return direct ?? wrapped ?? [];
171
+ } catch {
172
+ return [];
173
+ }
174
+ }
175
+
176
+ /** Number of trailing npub characters the auth proxy folds into a client id. */
177
+ export const NPUB_SUFFIX_LENGTH = 7;
178
+
179
+ /**
180
+ * Lookup tables for turning a raw usage `client` id (e.g.
181
+ * `claude-code-a1b2c3d`) back into a human label (e.g. `Alice (claude-code)`).
182
+ *
183
+ * All maps are empty in local mode, so {@link resolveClientLabel} falls back to
184
+ * the raw id with no behaviour change there.
185
+ */
186
+ export interface ClientNaming {
187
+ /** Configured npubs (names + roles) from the auth proxy. */
188
+ npubs: NpubEntry[];
189
+ /** bare clientId -> owner npub, from `GET /clients`. */
190
+ ownersByClientId: Map<string, string>;
191
+ /** npub -> display name (null when unset). */
192
+ npubNames: Map<string, string | null>;
193
+ /** trailing npub suffix -> npub. */
194
+ npubsBySuffix: Map<string, string>;
195
+ }
196
+
197
+ export function buildClientNaming(clients: ClientInfo[], npubs: NpubEntry[]): ClientNaming {
198
+ const ownersByClientId = new Map<string, string>();
199
+ for (const c of clients) {
200
+ if (c.ownerNpub) ownersByClientId.set(c.clientId, c.ownerNpub);
201
+ }
202
+
203
+ const npubNames = new Map<string, string | null>();
204
+ const npubsBySuffix = new Map<string, string>();
205
+ const remember = (npub: string) => npubsBySuffix.set(npub.slice(-NPUB_SUFFIX_LENGTH), npub);
206
+
207
+ for (const n of npubs) {
208
+ npubNames.set(n.npub, n.name ?? null);
209
+ remember(n.npub);
210
+ }
211
+ // `GET /clients` also carries owner npubs, which covers entries whose owner
212
+ // isn't present in the (possibly filtered) `/npubs` response.
213
+ for (const owner of ownersByClientId.values()) remember(owner);
214
+
215
+ return { npubs, ownersByClientId, npubNames, npubsBySuffix };
216
+ }
217
+
218
+ /** Configured display name for an npub, or null when unset/blank. */
219
+ function npubDisplayName(naming: ClientNaming, npub: string): string | null {
220
+ const name = naming.npubNames.get(npub)?.trim();
221
+ return name && name.length > 0 ? name : null;
222
+ }
223
+
224
+ /** `Alice (claude-code)` when the owner has a name, else the bare client id. */
225
+ function formatClientLabel(naming: ClientNaming, bareId: string, ownerNpub: string): string {
226
+ const name = npubDisplayName(naming, ownerNpub);
227
+ return name ? `${name} (${bareId})` : bareId;
228
+ }
229
+
230
+ /**
231
+ * Render a usage entry's `client` value as `Name (client-id)`, where `Name` is
232
+ * the owner npub's display name and the `-<npub tail>` suffix is stripped.
233
+ * Falls back to the raw id when no owner/name can be resolved (local mode).
234
+ */
235
+ export function resolveClientLabel(clientId: string | undefined, naming: ClientNaming): string {
236
+ const raw = clientId && clientId.length > 0 ? clientId : "unknown";
237
+
238
+ // Exact match: the raw id is a known bare clientId or its suffixed form.
239
+ for (const [bareId, ownerNpub] of naming.ownersByClientId) {
240
+ const suffixed = `${bareId}-${ownerNpub.slice(-NPUB_SUFFIX_LENGTH)}`;
241
+ if (raw !== bareId && raw !== suffixed) continue;
242
+ return formatClientLabel(naming, bareId, ownerNpub);
243
+ }
244
+
245
+ // Fallback: strip a trailing `-<npub tail>` that matches a known npub.
246
+ for (const [suffix, npub] of naming.npubsBySuffix) {
247
+ const marker = `-${suffix}`;
248
+ if (raw.length <= marker.length || !raw.endsWith(marker)) continue;
249
+ return formatClientLabel(naming, raw.slice(0, -marker.length), npub);
250
+ }
251
+
252
+ return raw;
253
+ }
254
+
255
+ export function emptyClientNaming(): ClientNaming {
256
+ return buildClientNaming([], []);
257
+ }
@@ -3,7 +3,8 @@ import type { Tab } from "./types.ts";
3
3
  import {
4
4
  formatNumber,
5
5
  formatTime,
6
- type ClientInfo,
6
+ resolveClientLabel,
7
+ type ClientNaming,
7
8
  } from "./data.ts";
8
9
  import { vimState } from "./state.ts";
9
10
  import { stripAnsi } from "./terminal.ts";
@@ -503,20 +504,23 @@ export function renderClients(stats: UsageStats, width: number): string {
503
504
  return output;
504
505
  }
505
506
 
506
- export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: number): string {
507
+ export function renderNpubs(stats: UsageStats, naming: ClientNaming, width: number): string {
507
508
  const npubStats = stats.summary.npubs;
508
509
  if (npubStats.length === 0) return renderBox(["No npub data available"], width, "Npub Breakdown");
509
510
 
511
+ // Index configured npubs by their npub so usage rows can show names/roles.
512
+ const configured = new Map(naming.npubs.map((entry) => [entry.npub, entry]));
513
+
510
514
  const totalCost = stats.totalSatsCost;
511
515
  const maxCost = npubStats[0]!.satsCost;
512
516
  const lines: string[] = [];
513
517
 
514
- const col1 = 24; // Npub (truncated)
518
+ const col1 = 24; // Name (or truncated npub)
515
519
  const col2 = 12; // Requests
516
520
  const col3 = 24; // Cost
517
521
  const col4 = 12; // Tokens
518
522
 
519
- const hNpub = "Npub".padEnd(col1);
523
+ const hNpub = "Name".padEnd(col1);
520
524
  const hReqs = "Requests".padEnd(col2);
521
525
  const hCost = "Cost".padEnd(col3);
522
526
  const hTok = "Tokens".padEnd(col4);
@@ -527,9 +531,11 @@ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: num
527
531
  for (const npub of npubStats) {
528
532
  const pct = totalCost > 0 ? ((npub.satsCost / totalCost) * 100).toFixed(1) : "0.0";
529
533
  const avgCostFormatted = formatCost(npub.requests > 0 ? npub.satsCost / npub.requests : 0);
530
- const shortNpub = truncateNpub(npub.npub);
534
+ const entry = configured.get(npub.npub);
535
+ const name = entry?.name?.trim();
536
+ const label = name || truncateNpub(npub.npub);
531
537
 
532
- const dNpub = shortNpub.padEnd(col1);
538
+ const dNpub = label.slice(0, col1 - 1).padEnd(col1);
533
539
  const dReqs = formatReqs(npub.requests).padEnd(col2);
534
540
  const dCost = `${formatCost(npub.satsCost)} sats (${pct}%)`.padEnd(col3);
535
541
  const dTok = formatNumber(npub.totalTokens).padEnd(col4);
@@ -541,8 +547,9 @@ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: num
541
547
  `${COLORS.green}${dCost}${COLORS.reset}` +
542
548
  `${COLORS.dim}${dTok}${dAvg}${COLORS.reset}`
543
549
  );
544
- // Full npub on its own line for copy-ability
545
- lines.push(` ${COLORS.dim}${npub.npub}${COLORS.reset}`);
550
+ // Full npub (and role when known) on its own line for copy-ability.
551
+ const roleSuffix = entry?.role ? ` [${entry.role}]` : "";
552
+ lines.push(` ${COLORS.dim}${npub.npub}${roleSuffix}${COLORS.reset}`);
546
553
  lines.push(` ${renderBarChart("", npub.satsCost, maxCost, width - 6, COLORS.magenta, Number(pct), "npub-detail")}`);
547
554
  lines.push("");
548
555
  }
@@ -555,7 +562,9 @@ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: num
555
562
  // Use pre-aggregated topModels from summary
556
563
  for (const topNpub of stats.summary.npubs.slice(0, 5)) {
557
564
  if (topNpub.topModels.length === 0) continue;
558
- npubModelLines.push(`${COLORS.bold}${truncateNpub(topNpub.npub)}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`);
565
+ const name = configured.get(topNpub.npub)?.name?.trim();
566
+ const label = name || truncateNpub(topNpub.npub);
567
+ npubModelLines.push(`${COLORS.bold}${label}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`);
559
568
  for (const m of topNpub.topModels) {
560
569
  npubModelLines.push(` ${(MODEL_COLORS[m.modelId] || MODEL_COLORS.default)}${m.modelId.padEnd(18)}${COLORS.reset} ${formatNumber(m.totalTokens).padEnd(8)} tokens ${formatCost(m.satsCost)} sats`);
561
570
  }
@@ -576,22 +585,38 @@ function truncateNpub(npub: string): string {
576
585
  return npub.slice(0, 10) + "…" + npub.slice(-6);
577
586
  }
578
587
 
579
- export function renderRecent(stats: UsageStats, width: number): string {
588
+ export function renderRecent(stats: UsageStats, width: number, naming: ClientNaming): string {
580
589
  const recentEntries = stats.entries.slice(0, 50);
581
590
  if (recentEntries.length === 0) return renderBox(["No recent entries"], width, "Recent Requests");
582
591
 
583
- const clientCol = 14;
592
+ const timeCol = 10;
593
+ const modelCol = 18;
584
594
  const tokensCol = 18;
585
595
  const costCol = 18;
586
- const providerCol = Math.max(16, width - 4 - 10 - 18 - tokensCol - costCol - clientCol - 5);
596
+
597
+ // Remote mode: `Alice (claude-code)` — owner display name + the client id
598
+ // with the `-<npub tail>` suffix stripped. Local mode (no owner/name data)
599
+ // keeps the previous behaviour: the raw id in a fixed 14-col cell.
600
+ const hasOwnerInfo = naming.npubs.length > 0 || naming.ownersByClientId.size > 0;
601
+ const clientLabels = recentEntries.map((entry) =>
602
+ hasOwnerInfo ? resolveClientLabel(entry.client, naming) : entry.client || "unknown"
603
+ );
604
+ const maxLabelLen = clientLabels.reduce((max, label) => Math.max(max, label.length), 6);
605
+ const clientCol = hasOwnerInfo ? Math.min(32, Math.max(14, maxLabelLen)) : 14;
606
+
607
+ // Budget: time + model + tokens + cost + provider + client + 5 separators.
608
+ const innerWidth = Math.max(0, width - 4);
609
+ const providerCol = Math.max(12, innerWidth - timeCol - modelCol - tokensCol - costCol - 5 - clientCol);
610
+
587
611
  const msatsToSats = (msats?: number) => typeof msats === "number" ? msats / 1000 : 0;
588
612
  const lines: string[] = [];
589
- lines.push(`${COLORS.bold}${"TIME".padEnd(10)} ${"MODEL".padEnd(18)} ${"I/CR/CW/O".padEnd(tokensCol)} ${"I/O/T in sats".padEnd(costCol)} ${"BASE:PROVIDER".padEnd(providerCol)} ${"CLIENT".slice(0, clientCol)}${COLORS.reset}`);
613
+ lines.push(`${COLORS.bold}${["TIME".padEnd(timeCol), "MODEL".padEnd(modelCol), "I/CR/CW/O".padEnd(tokensCol), "I/O/T in sats".padEnd(costCol), "BASE:PROVIDER".padEnd(providerCol), "CLIENT".padEnd(clientCol)].join(" ")}${COLORS.reset}`);
590
614
  lines.push(COLORS.dim + "─".repeat(width - 4) + COLORS.reset);
591
615
 
592
- for (const entry of recentEntries) {
616
+ for (let i = 0; i < recentEntries.length; i++) {
617
+ const entry = recentEntries[i]!;
593
618
  const time = formatTime(entry.timestamp).slice(0, 8);
594
- const model = entry.modelId.slice(0, 18).padEnd(18);
619
+ const model = entry.modelId.slice(0, modelCol).padEnd(modelCol);
595
620
  const tokens = [
596
621
  entry.promptTokens,
597
622
  entry.cacheReadInputTokens || 0,
@@ -606,16 +631,16 @@ export function renderRecent(stats: UsageStats, width: number): string {
606
631
  ].join("/");
607
632
  const baseUrl = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", "");
608
633
  const provider = `${baseUrl}:${entry.provider || "unknown"}`.slice(0, providerCol).padEnd(providerCol);
609
- const clientName = (entry.client || "unknown").slice(0, clientCol - 1);
634
+ const clientLabel = clientLabels[i]!.slice(0, clientCol).padEnd(clientCol);
610
635
  const clientColor = CLIENT_COLORS[entry.client || "unknown"] || CLIENT_COLORS.default || COLORS.white;
611
636
  const modelColor = MODEL_COLORS[entry.modelId] || MODEL_COLORS.default;
612
- lines.push(`${COLORS.dim}${time}${COLORS.reset} ${modelColor}${model}${COLORS.reset} ${tokens.padEnd(tokensCol)} ${COLORS.green}${cost.padEnd(costCol)}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset} ${clientColor}${clientName}${COLORS.reset}`);
637
+ lines.push(`${COLORS.dim}${time}${COLORS.reset} ${modelColor}${model}${COLORS.reset} ${tokens.padEnd(tokensCol)} ${COLORS.green}${cost.padEnd(costCol)}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset} ${clientColor}${clientLabel}${COLORS.reset}`);
613
638
  }
614
639
 
615
640
  return renderBox(lines, width, `Recent Requests (${stats.entries.length} shown)`);
616
641
  }
617
642
 
618
- export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number, clients: ClientInfo[] = []): string {
643
+ export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number, naming: ClientNaming): string {
619
644
  switch (activeTab) {
620
645
  case "overview": return renderOverview(stats, balance, status, width);
621
646
  case "today": return renderToday(stats, width);
@@ -623,8 +648,8 @@ export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: B
623
648
  case "providers": return renderProviders(stats, width);
624
649
  case "tokens": return renderTokens(stats, width);
625
650
  case "clients": return renderClients(stats, width);
626
- case "npubs": return renderNpubs(stats, clients, width);
627
- case "recent": return renderRecent(stats, width);
651
+ case "npubs": return renderNpubs(stats, naming, width);
652
+ case "recent": return renderRecent(stats, width, naming);
628
653
  default: return "Unknown tab";
629
654
  }
630
655
  }