routstrd 0.2.16 → 0.2.18

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.
@@ -37553,6 +37553,7 @@ var createBunSqliteUsageTrackingDriver2 = (options = {}) => {
37553
37553
 
37554
37554
  // src/daemon/wallet/index.ts
37555
37555
  init_cashu_ts_es();
37556
+ init_dist3();
37556
37557
 
37557
37558
  // src/daemon/wallet/cocod-client.ts
37558
37559
  import { createHash } from "crypto";
@@ -37908,6 +37909,9 @@ async function createWalletAdapter(options = {}) {
37908
37909
  await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
37909
37910
  continue;
37910
37911
  }
37912
+ if (errorMessage.includes("Not enough proofs")) {
37913
+ throw new InsufficientBalanceError(amount, 0);
37914
+ }
37911
37915
  logger3.error("Error in walletAdapter sendToken:", error);
37912
37916
  throw error;
37913
37917
  }
@@ -37940,7 +37944,7 @@ async function createWalletAdapter(options = {}) {
37940
37944
  }
37941
37945
 
37942
37946
  // src/daemon/models.ts
37943
- function createModelService(modelManager) {
37947
+ function createModelService(modelManager, store) {
37944
37948
  let providerBootstrapPromise = null;
37945
37949
  const ensureProvidersBootstrapped = () => {
37946
37950
  if (!providerBootstrapPromise) {
@@ -37949,6 +37953,16 @@ function createModelService(modelManager) {
37949
37953
  const providers = await modelManager.bootstrapProviders(false);
37950
37954
  logger3.log(`Bootstrapped ${providers.length} providers`);
37951
37955
  await modelManager.fetchModels(providers);
37956
+ const { baseUrlsList, setBaseUrlsList } = store.getState();
37957
+ const existing = new Set(baseUrlsList);
37958
+ const merged = [
37959
+ ...baseUrlsList,
37960
+ ...providers.filter((url2) => !existing.has(url2))
37961
+ ];
37962
+ if (merged.length !== baseUrlsList.length) {
37963
+ setBaseUrlsList(merged);
37964
+ logger3.log(`Synced ${merged.length - baseUrlsList.length} new provider(s) into store`);
37965
+ }
37952
37966
  logger3.log("Provider bootstrap complete.");
37953
37967
  })().catch((error) => {
37954
37968
  logger3.error("Provider bootstrap failed:", error);
@@ -42656,6 +42670,18 @@ async function buildWalletDetails(deps) {
42656
42670
  activeMint: deps.walletAdapter.getActiveMintUrl()
42657
42671
  };
42658
42672
  }
42673
+ function makeSdkLogger(prefix) {
42674
+ const tag = prefix ? `[${prefix}]` : undefined;
42675
+ const fmt = (...args) => tag ? [tag, ...args] : args;
42676
+ return {
42677
+ log: (...args) => logger3.log(...fmt(...args)),
42678
+ warn: (...args) => logger3.log(...fmt(...args)),
42679
+ error: (...args) => logger3.error(...fmt(...args)),
42680
+ debug: (...args) => logger3.debug(...fmt(...args)),
42681
+ child: (p) => makeSdkLogger(prefix ? `${prefix}:${p}` : p)
42682
+ };
42683
+ }
42684
+ var sdkLogger = makeSdkLogger();
42659
42685
  function createDaemonRequestHandler(deps) {
42660
42686
  return async function handler(req, res) {
42661
42687
  const host = req.headers.host || "localhost";
@@ -43228,7 +43254,9 @@ function createDaemonRequestHandler(deps) {
43228
43254
  }
43229
43255
  try {
43230
43256
  await deps.ensureProvidersBootstrapped();
43231
- logger3.log("Routing request with path: ", url2.pathname);
43257
+ const reqId = randomBytes6(4).toString("hex");
43258
+ const reqLogger = sdkLogger.child(`req:${reqId}`);
43259
+ logger3.log(`[req:${reqId}] Routing request with path: `, url2.pathname);
43232
43260
  const response = await routeRequests({
43233
43261
  modelId,
43234
43262
  requestBody,
@@ -43244,7 +43272,8 @@ function createDaemonRequestHandler(deps) {
43244
43272
  mode: deps.mode,
43245
43273
  usageTrackingDriver: deps.usageTrackingDriver,
43246
43274
  sdkStore: deps.store,
43247
- providerManager: deps.providerManager
43275
+ providerManager: deps.providerManager,
43276
+ logger: reqLogger
43248
43277
  });
43249
43278
  res.statusCode = response.status;
43250
43279
  response.headers.forEach((value, key) => {
@@ -43327,6 +43356,18 @@ async function refreshModelsAndIntegrations(getRoutstr21Models, config, label =
43327
43356
 
43328
43357
  // src/daemon/index.ts
43329
43358
  init_dist3();
43359
+ function makeSdkLogger2(prefix) {
43360
+ const tag = prefix ? `[${prefix}]` : undefined;
43361
+ const fmt = (...args) => tag ? [tag, ...args] : args;
43362
+ return {
43363
+ log: (...args) => logger3.log(...fmt(...args)),
43364
+ warn: (...args) => logger3.log(...fmt(...args)),
43365
+ error: (...args) => logger3.error(...fmt(...args)),
43366
+ debug: (...args) => logger3.debug(...fmt(...args)),
43367
+ child: (p) => makeSdkLogger2(prefix ? `${prefix}:${p}` : p)
43368
+ };
43369
+ }
43370
+ var daemonSdkLogger = makeSdkLogger2();
43330
43371
  async function main() {
43331
43372
  const args = parseArgs(process.argv);
43332
43373
  const config = await loadDaemonConfig();
@@ -43335,7 +43376,7 @@ async function main() {
43335
43376
  await ensureDirs();
43336
43377
  const updatedConfig = { ...config, port, provider };
43337
43378
  saveDaemonConfig(updatedConfig);
43338
- const sqliteDriver = await createBunSqliteDriver2(DB_PATH);
43379
+ const sqliteDriver = await createBunSqliteDriver2(DB_PATH, { logger: daemonSdkLogger });
43339
43380
  const { store, hydrate } = createSdkStore({ driver: sqliteDriver });
43340
43381
  await hydrate;
43341
43382
  const { Database } = await import("bun:sqlite");
@@ -43345,11 +43386,11 @@ async function main() {
43345
43386
  legacyStorageDriver: sqliteDriver
43346
43387
  });
43347
43388
  const discoveryAdapter = createDiscoveryAdapterFromStore(store);
43348
- const providerRegistry = createProviderRegistryFromStore(store);
43389
+ const providerRegistry = createProviderRegistryFromStore(store, daemonSdkLogger);
43349
43390
  const storageAdapter = createStorageAdapterFromStore(store);
43350
- const modelManager = new ModelManager(discoveryAdapter);
43351
- const providerManager = new ProviderManager(providerRegistry, store);
43352
- const { ensureProvidersBootstrapped, getRoutstr21Models, getModelProviders } = createModelService(modelManager);
43391
+ const modelManager = new ModelManager(discoveryAdapter, { logger: daemonSdkLogger });
43392
+ const providerManager = new ProviderManager(providerRegistry, store, daemonSdkLogger);
43393
+ const { ensureProvidersBootstrapped, getRoutstr21Models, getModelProviders } = createModelService(modelManager, store);
43353
43394
  const walletClient = createCocodClient({ cocodPath: config.cocodPath });
43354
43395
  const walletAdapter = await createWalletAdapter({
43355
43396
  cocodPath: config.cocodPath,
package/dist/index.js CHANGED
@@ -2302,14 +2302,6 @@ var init_process_lock = () => {
2302
2302
  };
2303
2303
 
2304
2304
  // src/start-daemon.ts
2305
- import { existsSync as existsSync2 } from "fs";
2306
- function getTodayLogFile() {
2307
- const now = new Date;
2308
- const year = now.getFullYear();
2309
- const month = String(now.getMonth() + 1).padStart(2, "0");
2310
- const day = String(now.getDate()).padStart(2, "0");
2311
- return `${LOGS_DIR2}/${year}-${month}-${day}.log`;
2312
- }
2313
2305
  async function isDaemonHealthy(port) {
2314
2306
  const controller = new AbortController;
2315
2307
  const timeoutId = setTimeout(() => controller.abort(), 2000);
@@ -2339,12 +2331,8 @@ async function startDaemonUnlocked(options) {
2339
2331
  if (options.provider) {
2340
2332
  args.push("--provider", options.provider);
2341
2333
  }
2342
- if (!existsSync2(LOGS_DIR2)) {
2343
- await Bun.$`mkdir -p ${LOGS_DIR2}`;
2344
- }
2345
2334
  const daemonScript = new URL("./daemon/index.js", import.meta.url).pathname;
2346
- const todayLogFile = getTodayLogFile();
2347
- const shellCmd = `bun run "${daemonScript}" ${args.map((a) => `'${a}'`).join(" ")} >> "${todayLogFile}" 2>&1`;
2335
+ const shellCmd = `bun run "${daemonScript}" ${args.map((a) => `'${a}'`).join(" ")}`;
2348
2336
  const proc = Bun.spawn(["sh", "-c", shellCmd], {
2349
2337
  stdout: "inherit",
2350
2338
  stderr: "inherit",
@@ -9380,10 +9368,10 @@ var init_nip98 = __esm(() => {
9380
9368
  });
9381
9369
 
9382
9370
  // src/utils/daemon-client.ts
9383
- import { existsSync as existsSync3 } from "fs";
9371
+ import { existsSync as existsSync2 } from "fs";
9384
9372
  async function loadConfig() {
9385
9373
  try {
9386
- if (existsSync3(CONFIG_FILE)) {
9374
+ if (existsSync2(CONFIG_FILE)) {
9387
9375
  const content = await Bun.file(CONFIG_FILE).text();
9388
9376
  return { ...DEFAULT_CONFIG, ...JSON.parse(content) };
9389
9377
  }
@@ -13681,17 +13669,23 @@ var require_browser = __commonJS((exports) => {
13681
13669
  });
13682
13670
 
13683
13671
  // src/tui/usage/constants.ts
13684
- var TABS, COLORS, MODEL_COLORS, CLIENT_COLORS;
13672
+ function getVisibleTabs(hasNpubs) {
13673
+ const tabs = ALL_TABS.filter((t) => t.id !== "npubs" || hasNpubs);
13674
+ return tabs.map((t, i2) => ({ ...t, key: String(i2 + 1) }));
13675
+ }
13676
+ var ALL_TABS, TABS, COLORS, MODEL_COLORS, CLIENT_COLORS;
13685
13677
  var init_constants = __esm(() => {
13686
- TABS = [
13678
+ ALL_TABS = [
13687
13679
  { id: "overview", name: "Overview", key: "1" },
13688
13680
  { id: "today", name: "Today", key: "2" },
13689
13681
  { id: "models", name: "Models", key: "3" },
13690
13682
  { id: "providers", name: "Providers", key: "4" },
13691
13683
  { id: "tokens", name: "Tokens", key: "5" },
13692
13684
  { id: "clients", name: "Clients", key: "6" },
13693
- { id: "recent", name: "Recent", key: "7" }
13685
+ { id: "npubs", name: "Npubs", key: "7" },
13686
+ { id: "recent", name: "Recent", key: "8" }
13694
13687
  ];
13688
+ TABS = getVisibleTabs(false);
13695
13689
  COLORS = {
13696
13690
  reset: "\x1B[0m",
13697
13691
  bold: "\x1B[1m",
@@ -13915,6 +13909,58 @@ function getClientStats(entries) {
13915
13909
  }
13916
13910
  return Array.from(clients.values()).sort((a, b) => b.satsCost - a.satsCost);
13917
13911
  }
13912
+ async function fetchClients() {
13913
+ try {
13914
+ const running = await isDaemonRunning();
13915
+ if (!running)
13916
+ return [];
13917
+ const result = await callDaemon("/clients");
13918
+ if (result.error)
13919
+ return [];
13920
+ const output = result.output;
13921
+ return (output?.clients || []).map((c) => ({
13922
+ clientId: c.id,
13923
+ name: c.name,
13924
+ ownerNpub: c.ownerNpub
13925
+ }));
13926
+ } catch {
13927
+ return [];
13928
+ }
13929
+ }
13930
+ function hasAnyNpubs(clients) {
13931
+ return clients.some((c) => !!c.ownerNpub);
13932
+ }
13933
+ function getNpubStats(entries, clients) {
13934
+ const clientNpubMap = new Map;
13935
+ for (const c of clients) {
13936
+ if (c.ownerNpub) {
13937
+ clientNpubMap.set(c.clientId, c.ownerNpub);
13938
+ }
13939
+ }
13940
+ const npubs = new Map;
13941
+ for (const entry of entries) {
13942
+ const npub = clientNpubMap.get(entry.client || "");
13943
+ if (!npub)
13944
+ continue;
13945
+ const existing = npubs.get(npub) || {
13946
+ npub,
13947
+ requests: 0,
13948
+ satsCost: 0,
13949
+ promptTokens: 0,
13950
+ completionTokens: 0,
13951
+ totalTokens: 0
13952
+ };
13953
+ npubs.set(npub, {
13954
+ ...existing,
13955
+ requests: existing.requests + 1,
13956
+ satsCost: existing.satsCost + entry.satsCost,
13957
+ promptTokens: existing.promptTokens + entry.promptTokens,
13958
+ completionTokens: existing.completionTokens + entry.completionTokens,
13959
+ totalTokens: existing.totalTokens + entry.totalTokens
13960
+ });
13961
+ }
13962
+ return Array.from(npubs.values()).sort((a, b) => b.satsCost - a.satsCost);
13963
+ }
13918
13964
  function getTotals(entries) {
13919
13965
  if (!entries || !Array.isArray(entries)) {
13920
13966
  return { promptTokens: 0, completionTokens: 0, totalTokens: 0, satsCost: 0 };
@@ -14061,10 +14107,11 @@ function formatReqs(value) {
14061
14107
  return (value / 1000).toFixed(1) + "k";
14062
14108
  return value.toString();
14063
14109
  }
14064
- function renderHeader(activeTab, width) {
14110
+ function renderHeader(activeTab, width, visibleTabs) {
14065
14111
  const title = `${COLORS.bold}${COLORS.cyan}ROUTSTRD USAGE MONITOR${COLORS.reset}`;
14066
14112
  const vimIndicator = `${COLORS.yellow}[vim]${COLORS.reset}`;
14067
- const help = `${COLORS.dim}[Q] Quit [\u2191\u2193] Scroll [\u2190\u2192] Tabs [1-7] Tabs [R] Refresh${COLORS.reset}`;
14113
+ const maxKey = visibleTabs.length;
14114
+ const help = `${COLORS.dim}[Q] Quit [\u2191\u2193] Scroll [\u2190\u2192] Tabs [1-${maxKey}] Tabs [R] Refresh${COLORS.reset}`;
14068
14115
  const fill = width - title.length - help.length - vimIndicator.length - 6;
14069
14116
  return `${title}${vimIndicator}${" ".repeat(Math.max(1, fill))}${help}
14070
14117
  `;
@@ -14080,8 +14127,8 @@ function renderSearchBar() {
14080
14127
  ${searchLine}${placeholder}
14081
14128
  `;
14082
14129
  }
14083
- function renderTabs(activeTab) {
14084
- const tabStr = TABS.map((tab) => tab.id === activeTab ? `${COLORS.bgBlue} ${tab.key}:${tab.name} ${COLORS.reset}` : `${COLORS.dim}[${tab.key}]${COLORS.reset} ${tab.name}`).join(" ");
14130
+ function renderTabs(activeTab, visibleTabs) {
14131
+ const tabStr = visibleTabs.map((tab) => tab.id === activeTab ? `${COLORS.bgBlue} ${tab.key}:${tab.name} ${COLORS.reset}` : `${COLORS.dim}[${tab.key}]${COLORS.reset} ${tab.name}`).join(" ");
14085
14132
  return `${" ".repeat(2)}${tabStr}
14086
14133
  `;
14087
14134
  }
@@ -14452,25 +14499,106 @@ function renderClients(stats, width) {
14452
14499
  }
14453
14500
  return output;
14454
14501
  }
14502
+ function renderNpubs(stats, clients, width) {
14503
+ const npubStats = getNpubStats(stats.entries, clients);
14504
+ if (npubStats.length === 0)
14505
+ return renderBox(["No npub data available"], width, "Npub Breakdown");
14506
+ const totalCost = stats.totalSatsCost;
14507
+ const maxCost = npubStats[0].satsCost;
14508
+ const lines = [];
14509
+ const col1 = 24;
14510
+ const col2 = 12;
14511
+ const col3 = 24;
14512
+ const col4 = 12;
14513
+ const hNpub = "Npub".padEnd(col1);
14514
+ const hReqs = "Requests".padEnd(col2);
14515
+ const hCost = "Cost".padEnd(col3);
14516
+ const hTok = "Tokens".padEnd(col4);
14517
+ lines.push(`${COLORS.bold}${hNpub}${hReqs}${hCost}${hTok}Avg Cost${COLORS.reset}`);
14518
+ lines.push(COLORS.dim + "\u2500".repeat(Math.max(0, width - 4)) + COLORS.reset);
14519
+ startBarSection("npub-detail", col1);
14520
+ for (const npub of npubStats) {
14521
+ const pct = totalCost > 0 ? (npub.satsCost / totalCost * 100).toFixed(1) : "0.0";
14522
+ const avgCostFormatted = formatCost(npub.requests > 0 ? npub.satsCost / npub.requests : 0);
14523
+ const shortNpub = truncateNpub(npub.npub);
14524
+ const dNpub = shortNpub.padEnd(col1);
14525
+ const dReqs = formatReqs(npub.requests).padEnd(col2);
14526
+ const dCost = `${formatCost(npub.satsCost)} sats (${pct}%)`.padEnd(col3);
14527
+ const dTok = formatNumber(npub.totalTokens).padEnd(col4);
14528
+ const dAvg = `${avgCostFormatted} sats/req`;
14529
+ lines.push(`${COLORS.magenta}${COLORS.bold}${dNpub}${COLORS.reset}` + `${dReqs}` + `${COLORS.green}${dCost}${COLORS.reset}` + `${COLORS.dim}${dTok}${dAvg}${COLORS.reset}`);
14530
+ lines.push(` ${COLORS.dim}${npub.npub}${COLORS.reset}`);
14531
+ lines.push(` ${renderBarChart("", npub.satsCost, maxCost, width - 6, COLORS.magenta, Number(pct), "npub-detail")}`);
14532
+ lines.push("");
14533
+ }
14534
+ endBarSection("npub-detail");
14535
+ let output = renderBox(lines, width, "Npub Breakdown");
14536
+ const npubModelMap = new Map;
14537
+ const clientNpubMap = new Map;
14538
+ for (const c of clients) {
14539
+ if (c.ownerNpub)
14540
+ clientNpubMap.set(c.clientId, c.ownerNpub);
14541
+ }
14542
+ for (const entry of stats.entries) {
14543
+ const npub = clientNpubMap.get(entry.client || "");
14544
+ if (!npub)
14545
+ continue;
14546
+ const model = entry.modelId;
14547
+ if (!npubModelMap.has(npub))
14548
+ npubModelMap.set(npub, new Map);
14549
+ const modelMap = npubModelMap.get(npub);
14550
+ const existing = modelMap.get(model) || { requests: 0, satsCost: 0, tokens: 0 };
14551
+ modelMap.set(model, {
14552
+ requests: existing.requests + 1,
14553
+ satsCost: existing.satsCost + entry.satsCost,
14554
+ tokens: existing.tokens + entry.totalTokens
14555
+ });
14556
+ }
14557
+ const npubModelLines = [];
14558
+ for (const topNpub of npubStats.slice(0, 5)) {
14559
+ const modelMap = npubModelMap.get(topNpub.npub);
14560
+ if (!modelMap)
14561
+ continue;
14562
+ const models = Array.from(modelMap.entries()).sort((a, b) => b[1].satsCost - a[1].satsCost).slice(0, 5);
14563
+ npubModelLines.push(`${COLORS.bold}${truncateNpub(topNpub.npub)}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`);
14564
+ for (const [model, data] of models) {
14565
+ npubModelLines.push(` ${MODEL_COLORS[model] || MODEL_COLORS.default}${model.padEnd(18)}${COLORS.reset} ${formatNumber(data.tokens).padEnd(8)} tokens ${formatCost(data.satsCost)} sats`);
14566
+ }
14567
+ npubModelLines.push("");
14568
+ }
14569
+ if (npubModelLines.length > 0) {
14570
+ output += `
14571
+ ` + renderBox(npubModelLines, width, "Top Models per Npub");
14572
+ }
14573
+ return output;
14574
+ }
14575
+ function truncateNpub(npub) {
14576
+ if (npub.length <= 24)
14577
+ return npub;
14578
+ return npub.slice(0, 10) + "\u2026" + npub.slice(-6);
14579
+ }
14455
14580
  function renderRecent(stats, width) {
14456
14581
  const recentEntries = stats.entries.slice(0, 50);
14457
14582
  if (recentEntries.length === 0)
14458
14583
  return renderBox(["No recent entries"], width, "Recent Requests");
14584
+ const clientCol = 14;
14459
14585
  const lines = [];
14460
- lines.push(`${COLORS.bold}${"TIME".padEnd(10)} ${"MODEL".padEnd(18)} ${"TOKENS".padEnd(10)} ${"COST".padEnd(12)} ${"PROVIDER".slice(0, Math.max(0, width - 60))}${COLORS.reset}`);
14586
+ lines.push(`${COLORS.bold}${"TIME".padEnd(10)} ${"MODEL".padEnd(18)} ${"TOKENS".padEnd(10)} ${"COST".padEnd(12)} ${"PROVIDER".padEnd(16)} ${"CLIENT".slice(0, clientCol)}${COLORS.reset}`);
14461
14587
  lines.push(COLORS.dim + "\u2500".repeat(width - 4) + COLORS.reset);
14462
14588
  for (const entry of recentEntries) {
14463
14589
  const time = formatTime(entry.timestamp).slice(0, 8);
14464
14590
  const model = entry.modelId.slice(0, 18).padEnd(18);
14465
14591
  const tokens = `${formatNumber(entry.totalTokens).padEnd(6)} (${formatNumber(entry.promptTokens)}+${formatNumber(entry.completionTokens)})`;
14466
14592
  const cost = `${formatCost(entry.satsCost).padEnd(8)} sats`;
14467
- const provider = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", "").slice(0, Math.max(0, width - 60));
14468
- const color = MODEL_COLORS[entry.modelId] || MODEL_COLORS.default;
14469
- lines.push(`${COLORS.dim}${time}${COLORS.reset} ${color}${model}${COLORS.reset} ${tokens.padEnd(10)} ${COLORS.green}${cost}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset}`);
14593
+ const provider = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", "").slice(0, 16).padEnd(16);
14594
+ const clientName = (entry.client || "unknown").slice(0, clientCol - 1);
14595
+ const clientColor = CLIENT_COLORS[entry.client || "unknown"] || CLIENT_COLORS.default || COLORS.white;
14596
+ const modelColor = MODEL_COLORS[entry.modelId] || MODEL_COLORS.default;
14597
+ lines.push(`${COLORS.dim}${time}${COLORS.reset} ${modelColor}${model}${COLORS.reset} ${tokens.padEnd(10)} ${COLORS.green}${cost}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset} ${clientColor}${clientName}${COLORS.reset}`);
14470
14598
  }
14471
14599
  return renderBox(lines, width, `Recent Requests (${stats.entries.length} shown)`);
14472
14600
  }
14473
- function renderTabContent(activeTab, stats, balance, status, width) {
14601
+ function renderTabContent(activeTab, stats, balance, status, width, clients = []) {
14474
14602
  switch (activeTab) {
14475
14603
  case "overview":
14476
14604
  return renderOverview(stats, balance, status, width);
@@ -14484,6 +14612,8 @@ function renderTabContent(activeTab, stats, balance, status, width) {
14484
14612
  return renderTokens(stats, width);
14485
14613
  case "clients":
14486
14614
  return renderClients(stats, width);
14615
+ case "npubs":
14616
+ return renderNpubs(stats, clients, width);
14487
14617
  case "recent":
14488
14618
  return renderRecent(stats, width);
14489
14619
  default:
@@ -14513,6 +14643,8 @@ async function runUsageTui() {
14513
14643
  let stats = null;
14514
14644
  let balance = null;
14515
14645
  let status = null;
14646
+ let clients = [];
14647
+ let visibleTabs = getVisibleTabs(false);
14516
14648
  let refreshInterval = null;
14517
14649
  let shouldUpdate = true;
14518
14650
  let autoRefresh = true;
@@ -14552,6 +14684,13 @@ async function runUsageTui() {
14552
14684
  stats = await fetchUsage(1e4);
14553
14685
  balance = await fetchBalance();
14554
14686
  status = await fetchStatus();
14687
+ clients = await fetchClients();
14688
+ const npubsVisible = hasAnyNpubs(clients);
14689
+ visibleTabs = getVisibleTabs(npubsVisible);
14690
+ if (currentTab === "npubs" && !npubsVisible) {
14691
+ currentTab = "clients";
14692
+ vimState.scrollPos = 0;
14693
+ }
14555
14694
  shouldUpdate = false;
14556
14695
  }
14557
14696
  if (!stats) {
@@ -14561,9 +14700,9 @@ async function runUsageTui() {
14561
14700
  Press Q to quit.`);
14562
14701
  return;
14563
14702
  }
14564
- const content = renderTabContent(currentTab, stats, balance, status, width);
14703
+ const content = renderTabContent(currentTab, stats, balance, status, width, clients);
14565
14704
  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}` : ""}`;
14566
- const chrome = renderHeader(currentTab, width) + renderTabs(currentTab) + renderSeparator(width) + renderSearchBar();
14705
+ const chrome = renderHeader(currentTab, width, visibleTabs) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
14567
14706
  const chromeLines = chrome.split(`
14568
14707
  `).length - 1;
14569
14708
  const footerSeparator = renderSeparator(width);
@@ -14637,15 +14776,15 @@ Press Q to quit.`);
14637
14776
  return;
14638
14777
  }
14639
14778
  if (key === "l" || key === "\x1B[C" || key === "\x1BOC") {
14640
- const currentIdx = TABS.findIndex((t) => t.id === currentTab);
14641
- currentTab = TABS[(currentIdx + 1) % TABS.length].id;
14779
+ const currentIdx = visibleTabs.findIndex((t) => t.id === currentTab);
14780
+ currentTab = visibleTabs[(currentIdx + 1) % visibleTabs.length].id;
14642
14781
  vimState.scrollPos = 0;
14643
14782
  render2(false);
14644
14783
  return;
14645
14784
  }
14646
14785
  if (key === "h" || key === "\x1B[D" || key === "\x1BOD") {
14647
- const currentIdx = TABS.findIndex((t) => t.id === currentTab);
14648
- currentTab = TABS[(currentIdx - 1 + TABS.length) % TABS.length].id;
14786
+ const currentIdx = visibleTabs.findIndex((t) => t.id === currentTab);
14787
+ currentTab = visibleTabs[(currentIdx - 1 + visibleTabs.length) % visibleTabs.length].id;
14649
14788
  vimState.scrollPos = 0;
14650
14789
  render2(false);
14651
14790
  return;
@@ -14724,7 +14863,7 @@ Press Q to quit.`);
14724
14863
  render2(false);
14725
14864
  return;
14726
14865
  }
14727
- const tab = TABS.find((t) => t.key === key);
14866
+ const tab = visibleTabs.find((t) => t.key === key);
14728
14867
  if (tab) {
14729
14868
  currentTab = tab.id;
14730
14869
  vimState.scrollPos = 0;
@@ -14747,7 +14886,6 @@ var init_app = __esm(() => {
14747
14886
  init_state();
14748
14887
  init_constants();
14749
14888
  init_render();
14750
- init_daemon_client();
14751
14889
  });
14752
14890
 
14753
14891
  // src/tui/usage/index.ts
@@ -14789,7 +14927,7 @@ import { join as join3 } from "path";
14789
14927
  // src/integrations/opencode.ts
14790
14928
  init_logger();
14791
14929
  init_daemon_client();
14792
- import { existsSync as existsSync4, mkdirSync } from "fs";
14930
+ import { existsSync as existsSync3, mkdirSync } from "fs";
14793
14931
  import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
14794
14932
  import { dirname as dirname2 } from "path";
14795
14933
  var OPENCODE_SMALL_MODEL = "routstr/minimax-m2.5";
@@ -14801,7 +14939,7 @@ Installing routstr models in opencode.json...`);
14801
14939
  const baseUrl = getDaemonBaseUrl(config);
14802
14940
  let opencodeConfig;
14803
14941
  try {
14804
- if (existsSync4(configPath)) {
14942
+ if (existsSync3(configPath)) {
14805
14943
  const content = await readFile2(configPath, "utf-8");
14806
14944
  opencodeConfig = JSON.parse(content);
14807
14945
  } else {
@@ -14846,7 +14984,7 @@ Installing routstr models in opencode.json...`);
14846
14984
  // src/integrations/pi.ts
14847
14985
  init_logger();
14848
14986
  init_daemon_client();
14849
- import { existsSync as existsSync5, mkdirSync as mkdirSync2 } from "fs";
14987
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2 } from "fs";
14850
14988
  import { readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
14851
14989
  import { dirname as dirname3 } from "path";
14852
14990
  async function installPiIntegration(config, apiKey, integrationConfig) {
@@ -14857,7 +14995,7 @@ Installing routstr models in pi models.json...`);
14857
14995
  const baseUrl = `${getDaemonBaseUrl(config)}/v1`;
14858
14996
  let piConfig = {};
14859
14997
  try {
14860
- if (existsSync5(configPath)) {
14998
+ if (existsSync4(configPath)) {
14861
14999
  const content = await readFile3(configPath, "utf-8");
14862
15000
  piConfig = JSON.parse(content);
14863
15001
  }
@@ -14894,7 +15032,7 @@ Installing routstr models in pi models.json...`);
14894
15032
  // src/integrations/openclaw.ts
14895
15033
  init_logger();
14896
15034
  init_daemon_client();
14897
- import { existsSync as existsSync6, mkdirSync as mkdirSync3 } from "fs";
15035
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3 } from "fs";
14898
15036
  import { readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
14899
15037
  import { dirname as dirname4 } from "path";
14900
15038
  var OPENCLAW_PROVIDER_ID = "routstr";
@@ -14908,7 +15046,7 @@ Installing routstr models in openclaw.json...`);
14908
15046
  const baseUrl = getDaemonBaseUrl(config);
14909
15047
  let openclawConfig = {};
14910
15048
  try {
14911
- if (existsSync6(configPath)) {
15049
+ if (existsSync5(configPath)) {
14912
15050
  const content = await readFile4(configPath, "utf-8");
14913
15051
  openclawConfig = JSON.parse(content);
14914
15052
  }
@@ -14970,7 +15108,7 @@ Installing routstr models in openclaw.json...`);
14970
15108
  // src/integrations/claudecode.ts
14971
15109
  init_logger();
14972
15110
  init_daemon_client();
14973
- import { existsSync as existsSync7, mkdirSync as mkdirSync4 } from "fs";
15111
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4 } from "fs";
14974
15112
  import { readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
14975
15113
  import { dirname as dirname5 } from "path";
14976
15114
  async function installClaudeCodeIntegration(config, apiKey, integrationConfig) {
@@ -14981,7 +15119,7 @@ Installing routstr configuration in ${configPath}...`);
14981
15119
  const baseUrl = getDaemonBaseUrl(config);
14982
15120
  let settings = {};
14983
15121
  try {
14984
- if (existsSync7(configPath)) {
15122
+ if (existsSync6(configPath)) {
14985
15123
  const content = await readFile5(configPath, "utf-8");
14986
15124
  settings = JSON.parse(content);
14987
15125
  }
@@ -15028,7 +15166,7 @@ Installing routstr configuration in ${configPath}...`);
15028
15166
  // src/integrations/hermes.ts
15029
15167
  init_logger();
15030
15168
  init_daemon_client();
15031
- import { existsSync as existsSync8, mkdirSync as mkdirSync5 } from "fs";
15169
+ import { existsSync as existsSync7, mkdirSync as mkdirSync5 } from "fs";
15032
15170
  import { readFile as readFile6, writeFile as writeFile6 } from "fs/promises";
15033
15171
  import { dirname as dirname6 } from "path";
15034
15172
  async function installHermesIntegration(config, apiKey, integrationConfig) {
@@ -15057,7 +15195,7 @@ Installing routstr configuration in ${configPath}...`);
15057
15195
  }
15058
15196
  let content = "";
15059
15197
  try {
15060
- if (existsSync8(configPath)) {
15198
+ if (existsSync7(configPath)) {
15061
15199
  content = await readFile6(configPath, "utf-8");
15062
15200
  }
15063
15201
  } catch (error) {
@@ -15309,7 +15447,7 @@ async function addClientAction(options) {
15309
15447
  // src/cli.ts
15310
15448
  init_config();
15311
15449
  init_logger();
15312
- import { existsSync as existsSync10, mkdirSync as mkdirSync6 } from "fs";
15450
+ import { existsSync as existsSync9, mkdirSync as mkdirSync6 } from "fs";
15313
15451
  import { execSync } from "child_process";
15314
15452
 
15315
15453
  // src/integrations/index.ts
@@ -15463,7 +15601,7 @@ init_nip98();
15463
15601
  init_esm();
15464
15602
 
15465
15603
  // src/daemon/wallet/cocod-client.ts
15466
- import { existsSync as existsSync9 } from "fs";
15604
+ import { existsSync as existsSync8 } from "fs";
15467
15605
  init_logger();
15468
15606
  init_process_lock();
15469
15607
  var DEFAULT_CONFIG_DIR = process.env.COCOD_DIR || `${process.env.HOME || process.env.USERPROFILE || ""}/.cocod`;
@@ -15475,7 +15613,7 @@ function resolveCocodExecutable(cocodPath) {
15475
15613
  async function isCocodInstalled(cocodPath) {
15476
15614
  const executable = resolveCocodExecutable(cocodPath);
15477
15615
  if (executable.includes("/")) {
15478
- return existsSync9(executable);
15616
+ return existsSync8(executable);
15479
15617
  }
15480
15618
  try {
15481
15619
  const proc = Bun.spawn({
@@ -15491,7 +15629,7 @@ async function isCocodInstalled(cocodPath) {
15491
15629
  // package.json
15492
15630
  var package_default = {
15493
15631
  name: "routstrd",
15494
- version: "0.2.16",
15632
+ version: "0.2.18",
15495
15633
  module: "src/index.ts",
15496
15634
  type: "module",
15497
15635
  private: false,
@@ -15515,7 +15653,7 @@ var package_default = {
15515
15653
  },
15516
15654
  dependencies: {
15517
15655
  "@cashu/cashu-ts": "^3.1.1",
15518
- "@routstr/sdk": "^0.3.2",
15656
+ "@routstr/sdk": "^0.3.4",
15519
15657
  "applesauce-core": "^5.1.0",
15520
15658
  "applesauce-relay": "^5.1.0",
15521
15659
  commander: "^14.0.2",
@@ -15570,11 +15708,11 @@ async function requireLocalDaemon() {
15570
15708
  }
15571
15709
  async function initDaemon() {
15572
15710
  logger.log("Initializing routstrd...");
15573
- if (!existsSync10(CONFIG_DIR)) {
15711
+ if (!existsSync9(CONFIG_DIR)) {
15574
15712
  mkdirSync6(CONFIG_DIR, { recursive: true });
15575
15713
  logger.log(`Created config directory: ${CONFIG_DIR}`);
15576
15714
  }
15577
- if (!existsSync10(CONFIG_FILE)) {
15715
+ if (!existsSync9(CONFIG_FILE)) {
15578
15716
  const config2 = {
15579
15717
  ...DEFAULT_CONFIG,
15580
15718
  cocodPath: null
@@ -15698,7 +15836,7 @@ program.command("remote <url>").description("Configure a remote daemon URL").act
15698
15836
  console.error(`Invalid URL: ${url}`);
15699
15837
  process.exit(1);
15700
15838
  }
15701
- if (!existsSync10(CONFIG_DIR)) {
15839
+ if (!existsSync9(CONFIG_DIR)) {
15702
15840
  mkdirSync6(CONFIG_DIR, { recursive: true });
15703
15841
  }
15704
15842
  const config = await loadConfig();
@@ -16293,7 +16431,7 @@ serviceCmd.command("install").description("Install and start routstrd using PM2
16293
16431
  const path = import.meta.require("path");
16294
16432
  daemonPath = path.join(path.dirname(import.meta.url).replace("file://", ""), "daemon", "index.js");
16295
16433
  }
16296
- if (!existsSync10(daemonPath)) {
16434
+ if (!existsSync9(daemonPath)) {
16297
16435
  console.error(`Could not find daemon at ${daemonPath}. Did you run 'bun run build'?`);
16298
16436
  process.exit(1);
16299
16437
  }
@@ -16431,14 +16569,14 @@ program.command("logs").description("View daemon logs").option("-f, --follow", "
16431
16569
  const yesterday = new Date;
16432
16570
  yesterday.setDate(yesterday.getDate() - 1);
16433
16571
  const yesterdayFile = getLogFileForDate2(yesterday);
16434
- if (!existsSync10(todayFile) && !existsSync10(yesterdayFile)) {
16572
+ if (!existsSync9(todayFile) && !existsSync9(yesterdayFile)) {
16435
16573
  console.log("No log files found. Daemon may not have started yet.");
16436
16574
  console.log(`Logs directory: ${LOGS_DIR2}`);
16437
16575
  process.exit(1);
16438
16576
  }
16439
16577
  const lines = parseInt(options.lines, 10);
16440
16578
  const logFiles = [yesterdayFile, todayFile].filter((file, index, files) => {
16441
- return existsSync10(file) && files.indexOf(file) === index;
16579
+ return existsSync9(file) && files.indexOf(file) === index;
16442
16580
  });
16443
16581
  if (options.follow) {
16444
16582
  const proc2 = Bun.spawn(["tail", "-n", String(lines), "-f", todayFile], {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "routstrd",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
4
4
  "module": "src/index.ts",
5
5
  "type": "module",
6
6
  "private": false,
@@ -24,7 +24,7 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "@cashu/cashu-ts": "^3.1.1",
27
- "@routstr/sdk": "^0.3.2",
27
+ "@routstr/sdk": "^0.3.4",
28
28
  "applesauce-core": "^5.1.0",
29
29
  "applesauce-relay": "^5.1.0",
30
30
  "commander": "^14.0.2",
@@ -1,4 +1,4 @@
1
- import { ModelManager } from "@routstr/sdk";
1
+ import { ModelManager, type SdkStore } from "@routstr/sdk";
2
2
  import type { ExposedModel } from "./types";
3
3
  import { logger } from "../utils/logger";
4
4
 
@@ -16,7 +16,7 @@ export type ModelWithProviders = ExposedModel & {
16
16
  providers: ModelProviderInfo[];
17
17
  };
18
18
 
19
- export function createModelService(modelManager: ModelManager) {
19
+ export function createModelService(modelManager: ModelManager, store: SdkStore) {
20
20
  let providerBootstrapPromise: Promise<void> | null = null;
21
21
 
22
22
  const ensureProvidersBootstrapped = (): Promise<void> => {
@@ -26,6 +26,22 @@ export function createModelService(modelManager: ModelManager) {
26
26
  const providers = await modelManager.bootstrapProviders(false);
27
27
  logger.log(`Bootstrapped ${providers.length} providers`);
28
28
  await modelManager.fetchModels(providers);
29
+
30
+ // Sync discovered providers into the store so `providers list` reflects
31
+ // the same set that the model manager knows about.
32
+ const { baseUrlsList, setBaseUrlsList } = store.getState();
33
+ const existing = new Set(baseUrlsList);
34
+ const merged = [
35
+ ...baseUrlsList,
36
+ ...providers.filter((url) => !existing.has(url)),
37
+ ];
38
+ if (merged.length !== baseUrlsList.length) {
39
+ setBaseUrlsList(merged);
40
+ logger.log(
41
+ `Synced ${merged.length - baseUrlsList.length} new provider(s) into store`,
42
+ );
43
+ }
44
+
29
45
  logger.log("Provider bootstrap complete.");
30
46
  })().catch((error) => {
31
47
  logger.error("Provider bootstrap failed:", error);
@@ -1,18 +1,9 @@
1
1
  import { logger } from "./utils/logger";
2
- import { existsSync } from "fs";
3
2
  import { CONFIG_DIR, LOGS_DIR } from "./utils/config";
4
3
  import { withCrossProcessLock } from "./utils/process-lock";
5
4
 
6
5
  const DAEMON_STARTUP_LOCK_PATH = `${CONFIG_DIR}/routstrd-startup.lock`;
7
6
 
8
- function getTodayLogFile(): string {
9
- const now = new Date();
10
- const year = now.getFullYear();
11
- const month = String(now.getMonth() + 1).padStart(2, "0");
12
- const day = String(now.getDate()).padStart(2, "0");
13
- return `${LOGS_DIR}/${year}-${month}-${day}.log`;
14
- }
15
-
16
7
  async function isDaemonHealthy(port: string): Promise<boolean> {
17
8
  const controller = new AbortController();
18
9
  const timeoutId = setTimeout(() => controller.abort(), 2000);
@@ -48,14 +39,8 @@ async function startDaemonUnlocked(
48
39
  args.push("--provider", options.provider);
49
40
  }
50
41
 
51
- // Ensure logs directory exists (logger handles date-based files)
52
- if (!existsSync(LOGS_DIR)) {
53
- await Bun.$`mkdir -p ${LOGS_DIR}`;
54
- }
55
-
56
42
  const daemonScript = new URL("./daemon/index.js", import.meta.url).pathname;
57
- const todayLogFile = getTodayLogFile();
58
- const shellCmd = `bun run "${daemonScript}" ${args.map((a) => `'${a}'`).join(" ")} >> "${todayLogFile}" 2>&1`;
43
+ const shellCmd = `bun run "${daemonScript}" ${args.map((a) => `'${a}'`).join(" ")}`;
59
44
 
60
45
  const proc = Bun.spawn(["sh", "-c", shellCmd], {
61
46
  stdout: "inherit",
@@ -1,5 +1,6 @@
1
- import { TABS } from "./constants.ts";
2
- import { fetchBalance, fetchStatus, fetchUsage, type BalanceInfo, type StatusInfo } from "./data.ts";
1
+ import { getVisibleTabs } from "./constants.ts";
2
+ import type { Tab } from "./types.ts";
3
+ import { fetchBalance, fetchClients, fetchStatus, fetchUsage, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type StatusInfo } from "./data.ts";
3
4
  import {
4
5
  applyScrollToContent,
5
6
  exitSearchMode,
@@ -28,7 +29,6 @@ import {
28
29
  import { COLORS } from "./constants.ts";
29
30
  import { renderHeader, renderSearchBar, renderSeparator, renderTabContent, renderTabs } from "./render.ts";
30
31
  import type { TabId, UsageStats } from "./types.ts";
31
- import { isDaemonRunning } from "../../utils/daemon-client.ts";
32
32
 
33
33
  export async function runUsageTui(): Promise<void> {
34
34
  const running = await isDaemonRunning();
@@ -46,6 +46,8 @@ export async function runUsageTui(): Promise<void> {
46
46
  let stats: UsageStats | null = null;
47
47
  let balance: BalanceInfo | null = null;
48
48
  let status: StatusInfo | null = null;
49
+ let clients: ClientInfo[] = [];
50
+ let visibleTabs: Tab[] = getVisibleTabs(false);
49
51
  let refreshInterval: ReturnType<typeof setInterval> | null = null;
50
52
  let shouldUpdate = true;
51
53
  let autoRefresh = true;
@@ -90,6 +92,14 @@ export async function runUsageTui(): Promise<void> {
90
92
  stats = await fetchUsage(10000);
91
93
  balance = await fetchBalance();
92
94
  status = await fetchStatus();
95
+ clients = await fetchClients();
96
+ const npubsVisible = hasAnyNpubs(clients);
97
+ visibleTabs = getVisibleTabs(npubsVisible);
98
+ // If current tab is npubs but it's no longer visible, fall back to clients
99
+ if (currentTab === "npubs" && !npubsVisible) {
100
+ currentTab = "clients";
101
+ vimState.scrollPos = 0;
102
+ }
93
103
  shouldUpdate = false;
94
104
  }
95
105
 
@@ -104,9 +114,9 @@ export async function runUsageTui(): Promise<void> {
104
114
  return;
105
115
  }
106
116
 
107
- const content = renderTabContent(currentTab, stats, balance, status, width);
117
+ const content = renderTabContent(currentTab, stats, balance, status, width, clients);
108
118
  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}` : ""}`;
109
- const chrome = renderHeader(currentTab, width) + renderTabs(currentTab) + renderSeparator(width) + renderSearchBar();
119
+ const chrome = renderHeader(currentTab, width, visibleTabs) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
110
120
  const chromeLines = chrome.split("\n").length - 1;
111
121
  const footerSeparator = renderSeparator(width);
112
122
  const footerLines = footerSeparator.split("\n").length - 1;
@@ -176,15 +186,15 @@ export async function runUsageTui(): Promise<void> {
176
186
  return;
177
187
  }
178
188
  if (key === "l" || key === "\x1b[C" || key === "\x1bOC") {
179
- const currentIdx = TABS.findIndex((t) => t.id === currentTab);
180
- currentTab = TABS[(currentIdx + 1) % TABS.length]!.id;
189
+ const currentIdx = visibleTabs.findIndex((t) => t.id === currentTab);
190
+ currentTab = visibleTabs[(currentIdx + 1) % visibleTabs.length]!.id;
181
191
  vimState.scrollPos = 0;
182
192
  void render(false);
183
193
  return;
184
194
  }
185
195
  if (key === "h" || key === "\x1b[D" || key === "\x1bOD") {
186
- const currentIdx = TABS.findIndex((t) => t.id === currentTab);
187
- currentTab = TABS[(currentIdx - 1 + TABS.length) % TABS.length]!.id;
196
+ const currentIdx = visibleTabs.findIndex((t) => t.id === currentTab);
197
+ currentTab = visibleTabs[(currentIdx - 1 + visibleTabs.length) % visibleTabs.length]!.id;
188
198
  vimState.scrollPos = 0;
189
199
  void render(false);
190
200
  return;
@@ -226,7 +236,7 @@ export async function runUsageTui(): Promise<void> {
226
236
  }
227
237
  if (key === "\x1b") { scrollToTop(); void render(false); return; }
228
238
 
229
- const tab = TABS.find((t) => t.key === key);
239
+ const tab = visibleTabs.find((t) => t.key === key);
230
240
  if (tab) {
231
241
  currentTab = tab.id;
232
242
  vimState.scrollPos = 0;
@@ -1,15 +1,28 @@
1
1
  import type { Tab } from "./types.ts";
2
2
 
3
- export const TABS: Tab[] = [
3
+ export const ALL_TABS: Tab[] = [
4
4
  { id: "overview", name: "Overview", key: "1" },
5
5
  { id: "today", name: "Today", key: "2" },
6
6
  { id: "models", name: "Models", key: "3" },
7
7
  { id: "providers", name: "Providers", key: "4" },
8
8
  { id: "tokens", name: "Tokens", key: "5" },
9
9
  { id: "clients", name: "Clients", key: "6" },
10
- { id: "recent", name: "Recent", key: "7" },
10
+ { id: "npubs", name: "Npubs", key: "7" },
11
+ { id: "recent", name: "Recent", key: "8" },
11
12
  ];
12
13
 
14
+ /**
15
+ * Returns the visible tab list, hiding the Npubs tab when no clients
16
+ * have an ownerNpub. Keys are re-assigned sequentially (1..N).
17
+ */
18
+ export function getVisibleTabs(hasNpubs: boolean): Tab[] {
19
+ const tabs = ALL_TABS.filter((t) => t.id !== "npubs" || hasNpubs);
20
+ return tabs.map((t, i) => ({ ...t, key: String(i + 1) }));
21
+ }
22
+
23
+ /** Default tab list (no npub data yet). */
24
+ export const TABS: Tab[] = getVisibleTabs(false);
25
+
13
26
  export const COLORS = {
14
27
  reset: "\x1b[0m",
15
28
  bold: "\x1b[1m",
@@ -1,6 +1,8 @@
1
1
  import type { UsageTrackingEntry } from "../../daemon/types.ts";
2
2
  import { callDaemon, isDaemonRunning } from "../../utils/daemon-client.ts";
3
- import type { ClientStats, DayStats, ModelStats, ProviderStats, UsageStats } from "./types.ts";
3
+ import type { ClientStats, DayStats, ModelStats, NpubStats, ProviderStats, UsageStats } from "./types.ts";
4
+
5
+ export { isDaemonRunning };
4
6
 
5
7
  export interface BalanceKey {
6
8
  id: string;
@@ -223,6 +225,78 @@ export function getClientStats(entries: UsageTrackingEntry[]): ClientStats[] {
223
225
  return Array.from(clients.values()).sort((a, b) => b.satsCost - a.satsCost);
224
226
  }
225
227
 
228
+ // ─── Client / Npub helpers ────────────────────────────────────────────
229
+
230
+ export interface ClientInfo {
231
+ clientId: string;
232
+ name: string;
233
+ ownerNpub?: string;
234
+ }
235
+
236
+ export async function fetchClients(): Promise<ClientInfo[]> {
237
+ try {
238
+ const running = await isDaemonRunning();
239
+ if (!running) return [];
240
+
241
+ const result = await callDaemon("/clients");
242
+ if (result.error) return [];
243
+
244
+ const output = result.output as {
245
+ clients?: Array<{ id: string; name: string; ownerNpub?: string }>;
246
+ };
247
+
248
+ return (output?.clients || []).map((c) => ({
249
+ clientId: c.id,
250
+ name: c.name,
251
+ ownerNpub: c.ownerNpub,
252
+ }));
253
+ } catch {
254
+ return [];
255
+ }
256
+ }
257
+
258
+ export function hasAnyNpubs(clients: ClientInfo[]): boolean {
259
+ return clients.some((c) => !!c.ownerNpub);
260
+ }
261
+
262
+ export function getNpubStats(entries: UsageTrackingEntry[], clients: ClientInfo[]): NpubStats[] {
263
+ // Build client-id → ownerNpub lookup
264
+ const clientNpubMap = new Map<string, string>();
265
+ for (const c of clients) {
266
+ if (c.ownerNpub) {
267
+ clientNpubMap.set(c.clientId, c.ownerNpub);
268
+ }
269
+ }
270
+
271
+ // Aggregate usage per npub
272
+ const npubs = new Map<string, NpubStats>();
273
+ for (const entry of entries) {
274
+ const npub = clientNpubMap.get(entry.client || "");
275
+ if (!npub) continue; // skip entries whose client has no ownerNpub
276
+
277
+ const existing = npubs.get(npub) || {
278
+ npub,
279
+ requests: 0,
280
+ satsCost: 0,
281
+ promptTokens: 0,
282
+ completionTokens: 0,
283
+ totalTokens: 0,
284
+ };
285
+ npubs.set(npub, {
286
+ ...existing,
287
+ requests: existing.requests + 1,
288
+ satsCost: existing.satsCost + entry.satsCost,
289
+ promptTokens: existing.promptTokens + entry.promptTokens,
290
+ completionTokens: existing.completionTokens + entry.completionTokens,
291
+ totalTokens: existing.totalTokens + entry.totalTokens,
292
+ });
293
+ }
294
+
295
+ return Array.from(npubs.values()).sort((a, b) => b.satsCost - a.satsCost);
296
+ }
297
+
298
+ // ─── Totals ───────────────────────────────────────────────────────────
299
+
226
300
  export function getTotals(entries: UsageTrackingEntry[] | undefined) {
227
301
  if (!entries || !Array.isArray(entries)) {
228
302
  return { promptTokens: 0, completionTokens: 0, totalTokens: 0, satsCost: 0 };
@@ -1,4 +1,5 @@
1
- import { CLIENT_COLORS, COLORS, MODEL_COLORS, TABS } from "./constants.ts";
1
+ import { CLIENT_COLORS, COLORS, MODEL_COLORS } from "./constants.ts";
2
+ import type { Tab } from "./types.ts";
2
3
  import {
3
4
  formatDate,
4
5
  formatNumber,
@@ -7,9 +8,11 @@ import {
7
8
  getDayStats,
8
9
  getHourlyToday,
9
10
  getModelStats,
11
+ getNpubStats,
10
12
  getProviderStats,
11
13
  getTodayStart,
12
14
  getTotals,
15
+ type ClientInfo,
13
16
  } from "./data.ts";
14
17
  import { vimState } from "./state.ts";
15
18
  import { stripAnsi } from "./terminal.ts";
@@ -30,10 +33,11 @@ function formatReqs(value: number): string {
30
33
  return value.toString();
31
34
  }
32
35
 
33
- export function renderHeader(activeTab: TabId, width: number): string {
36
+ export function renderHeader(activeTab: TabId, width: number, visibleTabs: Tab[]): string {
34
37
  const title = `${COLORS.bold}${COLORS.cyan}ROUTSTRD USAGE MONITOR${COLORS.reset}`;
35
38
  const vimIndicator = `${COLORS.yellow}[vim]${COLORS.reset}`;
36
- const help = `${COLORS.dim}[Q] Quit [↑↓] Scroll [←→] Tabs [1-7] Tabs [R] Refresh${COLORS.reset}`;
39
+ const maxKey = visibleTabs.length;
40
+ const help = `${COLORS.dim}[Q] Quit [↑↓] Scroll [←→] Tabs [1-${maxKey}] Tabs [R] Refresh${COLORS.reset}`;
37
41
  const fill = width - title.length - help.length - vimIndicator.length - 6;
38
42
  return `${title}${vimIndicator}${" ".repeat(Math.max(1, fill))}${help}\n`;
39
43
  }
@@ -49,8 +53,8 @@ export function renderSearchBar(): string {
49
53
  return `\n${searchLine}${placeholder}\n`;
50
54
  }
51
55
 
52
- export function renderTabs(activeTab: TabId): string {
53
- const tabStr = TABS.map((tab) => tab.id === activeTab
56
+ export function renderTabs(activeTab: TabId, visibleTabs: Tab[]): string {
57
+ const tabStr = visibleTabs.map((tab) => tab.id === activeTab
54
58
  ? `${COLORS.bgBlue} ${tab.key}:${tab.name} ${COLORS.reset}`
55
59
  : `${COLORS.dim}[${tab.key}]${COLORS.reset} ${tab.name}`).join(" ");
56
60
  return `${" ".repeat(2)}${tabStr}\n`;
@@ -507,12 +511,106 @@ export function renderClients(stats: UsageStats, width: number): string {
507
511
  return output;
508
512
  }
509
513
 
514
+ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: number): string {
515
+ const npubStats = getNpubStats(stats.entries, clients);
516
+ if (npubStats.length === 0) return renderBox(["No npub data available"], width, "Npub Breakdown");
517
+
518
+ const totalCost = stats.totalSatsCost;
519
+ const maxCost = npubStats[0]!.satsCost;
520
+ const lines: string[] = [];
521
+
522
+ const col1 = 24; // Npub (truncated)
523
+ const col2 = 12; // Requests
524
+ const col3 = 24; // Cost
525
+ const col4 = 12; // Tokens
526
+
527
+ const hNpub = "Npub".padEnd(col1);
528
+ const hReqs = "Requests".padEnd(col2);
529
+ const hCost = "Cost".padEnd(col3);
530
+ const hTok = "Tokens".padEnd(col4);
531
+ lines.push(`${COLORS.bold}${hNpub}${hReqs}${hCost}${hTok}Avg Cost${COLORS.reset}`);
532
+ lines.push(COLORS.dim + "─".repeat(Math.max(0, width - 4)) + COLORS.reset);
533
+
534
+ startBarSection("npub-detail", col1);
535
+ for (const npub of npubStats) {
536
+ const pct = totalCost > 0 ? ((npub.satsCost / totalCost) * 100).toFixed(1) : "0.0";
537
+ const avgCostFormatted = formatCost(npub.requests > 0 ? npub.satsCost / npub.requests : 0);
538
+ const shortNpub = truncateNpub(npub.npub);
539
+
540
+ const dNpub = shortNpub.padEnd(col1);
541
+ const dReqs = formatReqs(npub.requests).padEnd(col2);
542
+ const dCost = `${formatCost(npub.satsCost)} sats (${pct}%)`.padEnd(col3);
543
+ const dTok = formatNumber(npub.totalTokens).padEnd(col4);
544
+ const dAvg = `${avgCostFormatted} sats/req`;
545
+
546
+ lines.push(
547
+ `${COLORS.magenta}${COLORS.bold}${dNpub}${COLORS.reset}` +
548
+ `${dReqs}` +
549
+ `${COLORS.green}${dCost}${COLORS.reset}` +
550
+ `${COLORS.dim}${dTok}${dAvg}${COLORS.reset}`
551
+ );
552
+ // Full npub on its own line for copy-ability
553
+ lines.push(` ${COLORS.dim}${npub.npub}${COLORS.reset}`);
554
+ lines.push(` ${renderBarChart("", npub.satsCost, maxCost, width - 6, COLORS.magenta, Number(pct), "npub-detail")}`);
555
+ lines.push("");
556
+ }
557
+ endBarSection("npub-detail");
558
+
559
+ let output = renderBox(lines, width, "Npub Breakdown");
560
+
561
+ // Top models per npub (same pattern as clients tab)
562
+ const npubModelMap = new Map<string, Map<string, { requests: number; satsCost: number; tokens: number }>>();
563
+ const clientNpubMap = new Map<string, string>();
564
+ for (const c of clients) {
565
+ if (c.ownerNpub) clientNpubMap.set(c.clientId, c.ownerNpub);
566
+ }
567
+
568
+ for (const entry of stats.entries) {
569
+ const npub = clientNpubMap.get(entry.client || "");
570
+ if (!npub) continue;
571
+ const model = entry.modelId;
572
+ if (!npubModelMap.has(npub)) npubModelMap.set(npub, new Map());
573
+ const modelMap = npubModelMap.get(npub)!;
574
+ const existing = modelMap.get(model) || { requests: 0, satsCost: 0, tokens: 0 };
575
+ modelMap.set(model, {
576
+ requests: existing.requests + 1,
577
+ satsCost: existing.satsCost + entry.satsCost,
578
+ tokens: existing.tokens + entry.totalTokens,
579
+ });
580
+ }
581
+
582
+ const npubModelLines: string[] = [];
583
+ for (const topNpub of npubStats.slice(0, 5)) {
584
+ const modelMap = npubModelMap.get(topNpub.npub);
585
+ if (!modelMap) continue;
586
+ const models = Array.from(modelMap.entries()).sort((a, b) => b[1].satsCost - a[1].satsCost).slice(0, 5);
587
+ npubModelLines.push(`${COLORS.bold}${truncateNpub(topNpub.npub)}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`);
588
+ for (const [model, data] of models) {
589
+ npubModelLines.push(` ${(MODEL_COLORS[model] || MODEL_COLORS.default)}${model.padEnd(18)}${COLORS.reset} ${formatNumber(data.tokens).padEnd(8)} tokens ${formatCost(data.satsCost)} sats`);
590
+ }
591
+ npubModelLines.push("");
592
+ }
593
+
594
+ if (npubModelLines.length > 0) {
595
+ output += "\n" + renderBox(npubModelLines, width, "Top Models per Npub");
596
+ }
597
+
598
+ return output;
599
+ }
600
+
601
+ /** Truncate an npub for display: first 10 chars + … + last 6 chars. */
602
+ function truncateNpub(npub: string): string {
603
+ if (npub.length <= 24) return npub;
604
+ return npub.slice(0, 10) + "…" + npub.slice(-6);
605
+ }
606
+
510
607
  export function renderRecent(stats: UsageStats, width: number): string {
511
608
  const recentEntries = stats.entries.slice(0, 50);
512
609
  if (recentEntries.length === 0) return renderBox(["No recent entries"], width, "Recent Requests");
513
610
 
611
+ const clientCol = 14;
514
612
  const lines: string[] = [];
515
- lines.push(`${COLORS.bold}${"TIME".padEnd(10)} ${"MODEL".padEnd(18)} ${"TOKENS".padEnd(10)} ${"COST".padEnd(12)} ${"PROVIDER".slice(0, Math.max(0, width - 60))}${COLORS.reset}`);
613
+ lines.push(`${COLORS.bold}${"TIME".padEnd(10)} ${"MODEL".padEnd(18)} ${"TOKENS".padEnd(10)} ${"COST".padEnd(12)} ${"PROVIDER".padEnd(16)} ${"CLIENT".slice(0, clientCol)}${COLORS.reset}`);
516
614
  lines.push(COLORS.dim + "─".repeat(width - 4) + COLORS.reset);
517
615
 
518
616
  for (const entry of recentEntries) {
@@ -520,15 +618,17 @@ export function renderRecent(stats: UsageStats, width: number): string {
520
618
  const model = entry.modelId.slice(0, 18).padEnd(18);
521
619
  const tokens = `${formatNumber(entry.totalTokens).padEnd(6)} (${formatNumber(entry.promptTokens)}+${formatNumber(entry.completionTokens)})`;
522
620
  const cost = `${formatCost(entry.satsCost).padEnd(8)} sats`;
523
- const provider = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", "").slice(0, Math.max(0, width - 60));
524
- const color = MODEL_COLORS[entry.modelId] || MODEL_COLORS.default;
525
- lines.push(`${COLORS.dim}${time}${COLORS.reset} ${color}${model}${COLORS.reset} ${tokens.padEnd(10)} ${COLORS.green}${cost}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset}`);
621
+ const provider = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", "").slice(0, 16).padEnd(16);
622
+ const clientName = (entry.client || "unknown").slice(0, clientCol - 1);
623
+ const clientColor = CLIENT_COLORS[entry.client || "unknown"] || CLIENT_COLORS.default || COLORS.white;
624
+ const modelColor = MODEL_COLORS[entry.modelId] || MODEL_COLORS.default;
625
+ lines.push(`${COLORS.dim}${time}${COLORS.reset} ${modelColor}${model}${COLORS.reset} ${tokens.padEnd(10)} ${COLORS.green}${cost}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset} ${clientColor}${clientName}${COLORS.reset}`);
526
626
  }
527
627
 
528
628
  return renderBox(lines, width, `Recent Requests (${stats.entries.length} shown)`);
529
629
  }
530
630
 
531
- export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number): string {
631
+ export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number, clients: ClientInfo[] = []): string {
532
632
  switch (activeTab) {
533
633
  case "overview": return renderOverview(stats, balance, status, width);
534
634
  case "today": return renderToday(stats, width);
@@ -536,6 +636,7 @@ export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: B
536
636
  case "providers": return renderProviders(stats, width);
537
637
  case "tokens": return renderTokens(stats, width);
538
638
  case "clients": return renderClients(stats, width);
639
+ case "npubs": return renderNpubs(stats, clients, width);
539
640
  case "recent": return renderRecent(stats, width);
540
641
  default: return "Unknown tab";
541
642
  }
@@ -44,7 +44,7 @@ export interface ClientStats {
44
44
  totalTokens: number;
45
45
  }
46
46
 
47
- export type TabId = "overview" | "today" | "models" | "providers" | "tokens" | "clients" | "recent";
47
+ export type TabId = "overview" | "today" | "models" | "providers" | "tokens" | "clients" | "npubs" | "recent";
48
48
 
49
49
  export interface Tab {
50
50
  id: TabId;
@@ -52,6 +52,15 @@ export interface Tab {
52
52
  key: string;
53
53
  }
54
54
 
55
+ export interface NpubStats {
56
+ npub: string;
57
+ requests: number;
58
+ satsCost: number;
59
+ promptTokens: number;
60
+ completionTokens: number;
61
+ totalTokens: number;
62
+ }
63
+
55
64
  export interface VimState {
56
65
  scrollPos: number;
57
66
  searchQuery: string;