routstrd 0.4.9 → 0.4.11

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.11",
4
4
  "module": "src/index.ts",
5
5
  "type": "module",
6
6
  "private": false,
@@ -23,6 +23,7 @@
23
23
  "monitor": "bun src/index.ts monitor",
24
24
  "lint": "tsc --noEmit",
25
25
  "test": "bun test",
26
+ "smoke": "scripts/smoke/chat-completions.sh",
26
27
  "build": "bun build src/index.ts --target=bun --outfile=dist/index.js --external better-sqlite3 && bun build src/daemon/index.ts --target=bun --outfile=dist/daemon/index.js --external better-sqlite3",
27
28
  "build:binary": "bun build --compile --no-compile-autoload-dotenv --no-compile-autoload-bunfig src/index.ts --outfile=dist/routstrd",
28
29
  "prepublishOnly": "bun run build"
@@ -38,7 +39,7 @@
38
39
  "@cashu/cashu-ts": "^4.3.0",
39
40
  "@cashu/coco-core": "^1.0.1",
40
41
  "@cashu/coco-sqlite-bun": "^1.0.1",
41
- "@routstr/sdk": "^0.4.4",
42
+ "@routstr/sdk": "^0.4.6",
42
43
  "@scure/bip39": "^2.2.0",
43
44
  "applesauce-core": "^5.1.0",
44
45
  "applesauce-relay": "^5.1.0",
@@ -21,6 +21,7 @@ import {
21
21
  import { receiveCashuToken } from "../wallet";
22
22
  import { getClientsFromStore } from "../../utils/clients";
23
23
  import { getUsageSummary } from "./usage-summary";
24
+ import { applyDefaultOutputTokenLimit } from "./request-body";
24
25
 
25
26
  // Hop-by-hop headers describe the *upstream* connection, not this one, and must
26
27
  // never be copied onto our response. In particular, copying the upstream's
@@ -1747,17 +1748,10 @@ export function createDaemonRequestHandler(deps: {
1747
1748
  // limit. Without this, the SDK prices at the provider's worst-case
1748
1749
  // max_completion_cost, which varies widely across providers (2.3× for
1749
1750
  // kimi-k3) and balloons during provider failover. Chat/completions use
1750
- // max_tokens; the OpenAI Responses API uses max_output_tokens.
1751
- if (deps.maxTokens > 0) {
1752
- const isResponsesPath = url.pathname.includes("/responses");
1753
- if (isResponsesPath) {
1754
- if (typeof bodyObj.max_output_tokens !== "number") {
1755
- bodyObj.max_output_tokens = deps.maxTokens;
1756
- }
1757
- } else if (typeof bodyObj.max_tokens !== "number") {
1758
- bodyObj.max_tokens = deps.maxTokens;
1759
- }
1760
- }
1751
+ // max_tokens; the OpenAI Responses API uses max_output_tokens. Endpoints
1752
+ // that do not define those fields (e.g. /v1/systemone) are forwarded
1753
+ // verbatim — injecting one makes a strict upstream answer 400.
1754
+ applyDefaultOutputTokenLimit(url.pathname, bodyObj, deps.maxTokens);
1761
1755
 
1762
1756
  const forcedProvider: string | undefined =
1763
1757
  url.searchParams.get("provider") ||
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Request-body shaping for the proxied path.
3
+ *
4
+ * The ingress forwards the caller's JSON body to the upstream provider. It is
5
+ * allowed to add exactly one thing — a default output-token limit — and only
6
+ * where that field is part of the endpoint's documented vocabulary. Anything
7
+ * else must be forwarded verbatim: several upstreams (notably TypeSafe's
8
+ * `POST /v1/systemone`) validate strictly and reject unknown fields, so an
9
+ * injected chat-completions field turns a valid request into an opaque
10
+ * `400 Invalid request.`
11
+ */
12
+
13
+ /**
14
+ * OpenAI-compatible endpoints whose bodies define `max_tokens`,
15
+ * `max_output_tokens`, and `stream`.
16
+ *
17
+ * Matched by path suffix so `/v1/chat/completions`, `/chat/completions`, and
18
+ * custom path-prefixed proxies all qualify. Kept in sync with
19
+ * `isOpenAiJsonBodyPath` in `@routstr/sdk` (exported there since 0.4.6); once
20
+ * routstrd depends on that version this local copy can be dropped.
21
+ */
22
+ export const OPENAI_JSON_BODY_PATH_SUFFIXES = [
23
+ "/chat/completions",
24
+ "/completions",
25
+ "/responses",
26
+ ] as const;
27
+
28
+ /**
29
+ * True when `pathname` addresses an endpoint whose body carries the OpenAI
30
+ * request vocabulary. Ignores a query string and a trailing slash.
31
+ */
32
+ export function isOpenAiJsonBodyPath(pathname: string): boolean {
33
+ const path = (pathname.split("?")[0] ?? "").replace(/\/+$/, "");
34
+ return OPENAI_JSON_BODY_PATH_SUFFIXES.some((suffix) => path.endsWith(suffix));
35
+ }
36
+
37
+ /**
38
+ * Cap the completion budget when the client does not set an output token
39
+ * limit. Without this, the SDK prices at the provider's worst-case
40
+ * `max_completion_cost`, which varies widely across providers (2.3× for
41
+ * kimi-k3) and balloons during provider failover. Chat/completions use
42
+ * `max_tokens`; the OpenAI Responses API uses `max_output_tokens`.
43
+ *
44
+ * Non-OpenAI endpoints are left untouched — `/v1/systemone` returns typed
45
+ * judgments rather than generated text, so it has no completion budget, and it
46
+ * rejects the field outright. Mutates `body` in place. Returns true when a
47
+ * field was added.
48
+ */
49
+ export function applyDefaultOutputTokenLimit(
50
+ pathname: string,
51
+ body: Record<string, unknown>,
52
+ maxTokens: number,
53
+ ): boolean {
54
+ if (!(maxTokens > 0) || !isOpenAiJsonBodyPath(pathname)) {
55
+ return false;
56
+ }
57
+
58
+ if (pathname.split("?")[0]!.replace(/\/+$/, "").endsWith("/responses")) {
59
+ if (typeof body.max_output_tokens === "number") return false;
60
+ body.max_output_tokens = maxTokens;
61
+ return true;
62
+ }
63
+
64
+ if (typeof body.max_tokens === "number") return false;
65
+ body.max_tokens = maxTokens;
66
+ return true;
67
+ }
@@ -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
 
@@ -58,6 +58,9 @@ import {
58
58
  walletDir as defaultWalletDir,
59
59
  walletPidPath as defaultWalletPidPath,
60
60
  } from "./paths";
61
+ import { DEFAULT_MINT_URL, seedTrustedMints } from "./trusted-mints";
62
+
63
+ export { DEFAULT_MINT_URL, DEFAULT_TRUSTED_MINT_URLS } from "./trusted-mints";
61
64
 
62
65
  const NPC_DEFAULT_BASE_URL = "https://npubx.cash";
63
66
 
@@ -117,7 +120,6 @@ interface CocodConfig {
117
120
  }
118
121
 
119
122
  const STARTUP_LOG_PREFIX = "[routstrd:start]";
120
- export const DEFAULT_MINT_URL = "https://mint.cubabitcoin.org";
121
123
 
122
124
  function startupProgress(message: string): void {
123
125
  logger.info(message);
@@ -1293,10 +1295,23 @@ export async function createCocoClient(
1293
1295
  configuredDefault || trustedMints[0]?.mintUrl || DEFAULT_MINT_URL,
1294
1296
  );
1295
1297
 
1296
- if (!trustedMints.some((mint) => mint.mintUrl === defaultMintUrl)) {
1297
- startupProgress(`Adding default mint: ${defaultMintUrl}`);
1298
- await coco.mint.addMint(defaultMintUrl, { trusted: true });
1299
- }
1298
+ // Seeds the mints we ship as trusted. The default mint is strict (see
1299
+ // seedTrustedMints); extra seeds only warn, so an unreachable mint that is
1300
+ // not the default cannot stop the daemon from starting.
1301
+ await seedTrustedMints(
1302
+ {
1303
+ trustedMints: trustedMints.map((mint) => mint.mintUrl),
1304
+ addMint: (mintUrl) => coco!.mint.addMint(mintUrl, { trusted: true }),
1305
+ },
1306
+ defaultMintUrl,
1307
+ {
1308
+ onProgress: startupProgress,
1309
+ onError: (message, error) =>
1310
+ logger.warn(message, {
1311
+ error: error instanceof Error ? error.message : String(error),
1312
+ }),
1313
+ },
1314
+ );
1300
1315
 
1301
1316
  // Persist only after the mint was successfully fetched and trusted. A failed
1302
1317
  // network request must not leave config pointing at an unusable default.
@@ -0,0 +1,87 @@
1
+ import { normalizeMintUrl } from "@cashu/coco-core";
2
+
3
+ /**
4
+ * Mint used as the default for wallets that have no configured default. It is
5
+ * always trusted, and a wallet is never allowed to point its default at a mint
6
+ * it could not fetch.
7
+ */
8
+ export const DEFAULT_MINT_URL = "https://mint.cubabitcoin.org";
9
+
10
+ /**
11
+ * Mints routstrd trusts out of the box. Every entry is added as a trusted mint
12
+ * on startup so users can send/receive without an explicit
13
+ * `wallet mints add`. `DEFAULT_MINT_URL` is listed first because it seeds the
14
+ * default mint of a fresh wallet; extra entries never change an existing
15
+ * default.
16
+ */
17
+ export const DEFAULT_TRUSTED_MINT_URLS: readonly string[] = [
18
+ DEFAULT_MINT_URL,
19
+ "https://mint.minibits.cash/Bitcoin",
20
+ ];
21
+
22
+ export interface TrustedMintSeeder {
23
+ /** Mint URLs the wallet currently trusts. */
24
+ trustedMints: readonly string[];
25
+ /** Trust a mint, fetching its info and keysets from the mint itself. */
26
+ addMint: (mintUrl: string) => Promise<unknown>;
27
+ }
28
+
29
+ export interface SeedTrustedMintsOptions {
30
+ /** Mints to ensure are trusted, in order. Defaults to the shipped seeds. */
31
+ seeds?: readonly string[];
32
+ /** Called before each mint fetch with a user-facing progress message. */
33
+ onProgress?: (message: string) => void;
34
+ /** Called when a non-default seed could not be added. */
35
+ onError?: (message: string, error: unknown) => void;
36
+ }
37
+
38
+ // Stored and configured mint URLs come from SQLite and JSON, so a malformed
39
+ // value must never crash startup. Normalization only strips the default port
40
+ // and a trailing slash, so falling back to the raw string keeps comparisons
41
+ // meaningful.
42
+ function safeNormalizeMintUrl(mintUrl: string): string {
43
+ try {
44
+ return normalizeMintUrl(mintUrl);
45
+ } catch {
46
+ return mintUrl;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Ensure the mint seeds routstrd ships are trusted, without ever moving a
52
+ * wallet's default away from `defaultMintUrl`.
53
+ *
54
+ * The default mint is seeded strictly: if it cannot be fetched the error is
55
+ * rethrown, because persisting an unusable default is worse than failing
56
+ * startup. Every other seed is best-effort — sending the mint fetch failure to
57
+ * `onError` — so a single unreachable mint cannot keep the daemon down.
58
+ */
59
+ export async function seedTrustedMints(
60
+ wallet: TrustedMintSeeder,
61
+ defaultMintUrl: string,
62
+ options: SeedTrustedMintsOptions = {},
63
+ ): Promise<void> {
64
+ const seeds = options.seeds ?? DEFAULT_TRUSTED_MINT_URLS;
65
+ const target = safeNormalizeMintUrl(defaultMintUrl);
66
+ const trusted = new Set(wallet.trustedMints.map(safeNormalizeMintUrl));
67
+ const attempted = new Set<string>();
68
+
69
+ for (const seed of [defaultMintUrl, ...seeds]) {
70
+ const mintUrl = safeNormalizeMintUrl(seed);
71
+ if (attempted.has(mintUrl)) continue;
72
+ attempted.add(mintUrl);
73
+ if (trusted.has(mintUrl)) continue;
74
+
75
+ const isDefault = mintUrl === target;
76
+ try {
77
+ options.onProgress?.(
78
+ `Adding ${isDefault ? "default" : "trusted"} mint: ${mintUrl}`,
79
+ );
80
+ await wallet.addMint(mintUrl);
81
+ trusted.add(mintUrl);
82
+ } catch (error) {
83
+ if (isDefault) throw error;
84
+ options.onError?.(`Could not add trusted mint ${mintUrl}`, error);
85
+ }
86
+ }
87
+ }
@@ -5,17 +5,144 @@ import type { RoutstrdConfig } from "../utils/config";
5
5
  import type { IntegrationConfig, RoutstrModel } from "./registry";
6
6
  import { callDaemon, getDaemonBaseUrl } from "../utils/daemon-client";
7
7
 
8
- type PiModelEntry = {
8
+ export type PiThinkingLevel =
9
+ | "off"
10
+ | "minimal"
11
+ | "low"
12
+ | "medium"
13
+ | "high"
14
+ | "xhigh"
15
+ | "max";
16
+
17
+ export type ThinkingLevelMap = Partial<Record<PiThinkingLevel, string | null>>;
18
+
19
+ export type PiModelEntry = {
9
20
  id: string;
10
21
  contextWindow?: number;
11
22
  name?: string;
12
23
  input?: string[];
13
- // Thinking/reasoning config is user-curated and preserved across refreshes.
14
24
  reasoning?: boolean;
15
- thinkingLevelMap?: Record<string, string | null>;
25
+ thinkingLevelMap?: ThinkingLevelMap;
16
26
  compat?: Record<string, unknown>;
17
27
  };
18
28
 
29
+ /** pi thinking levels, in pi's documented order. */
30
+ export const PI_THINKING_LEVELS: readonly PiThinkingLevel[] = [
31
+ "off",
32
+ "minimal",
33
+ "low",
34
+ "medium",
35
+ "high",
36
+ "xhigh",
37
+ "max",
38
+ ];
39
+
40
+ /**
41
+ * pi level -> the value sent to the provider. routstr-core publishes its
42
+ * allowlist in this same vocabulary (`none`/`minimal`/`low`/`medium`/`high`/
43
+ * `xhigh`/`max`), so the mapping is identity apart from `off`.
44
+ */
45
+ const THINKING_LEVEL_VALUES: Record<PiThinkingLevel, string> = {
46
+ off: "none",
47
+ minimal: "minimal",
48
+ low: "low",
49
+ medium: "medium",
50
+ high: "high",
51
+ xhigh: "xhigh",
52
+ max: "max",
53
+ };
54
+
55
+ /**
56
+ * Build pi's thinking fields from the daemon's per-model `reasoning` object.
57
+ *
58
+ * Every level is written explicitly: pi treats an omitted level as "use the
59
+ * provider's default mapping" for standard levels (through `high`) and as
60
+ * "unsupported" for `xhigh`/`max`, so a partial map would silently advertise
61
+ * levels the model rejects. `null` hides the level in pi's UI.
62
+ *
63
+ * Returns null when the daemon publishes no effort allowlist (models whose
64
+ * upstream only reports `mandatory`, or that report no reasoning at all).
65
+ * Those are not guessable, so the caller keeps whatever the user curated.
66
+ */
67
+ export function deriveThinkingFields(
68
+ model: RoutstrModel,
69
+ ): { reasoning: true; thinkingLevelMap: ThinkingLevelMap } | null {
70
+ const reasoning = model.reasoning;
71
+ if (!reasoning) return null;
72
+
73
+ const supported = (reasoning.supported_efforts ?? [])
74
+ .filter((effort): effort is string => typeof effort === "string")
75
+ .map((effort) => effort.trim().toLowerCase())
76
+ .filter(Boolean);
77
+ if (supported.length === 0) return null;
78
+
79
+ const allowed = new Set(supported);
80
+ // routstr-core strips `none` from mandatory models, so never offer `off` there.
81
+ if (reasoning.mandatory === true) allowed.delete("none");
82
+
83
+ const thinkingLevelMap: ThinkingLevelMap = {};
84
+ for (const level of PI_THINKING_LEVELS) {
85
+ const value = THINKING_LEVEL_VALUES[level];
86
+ thinkingLevelMap[level] = allowed.has(value) ? value : null;
87
+ }
88
+
89
+ return { reasoning: true, thinkingLevelMap };
90
+ }
91
+
92
+ const isDeepSeekModel = (id: string): boolean => id.startsWith("deepseek");
93
+
94
+ /** Project one daemon model onto a pi config entry. */
95
+ export function buildPiModelEntry(
96
+ model: RoutstrModel,
97
+ previous?: PiModelEntry,
98
+ ): PiModelEntry {
99
+ const entry: PiModelEntry = { id: model.id };
100
+
101
+ if (model.context_length !== undefined && model.context_length > 0) {
102
+ entry.contextWindow = model.context_length;
103
+ }
104
+
105
+ if (model.name) {
106
+ entry.name = model.name;
107
+ }
108
+
109
+ // Map the daemon's input modalities to Pi's ["text", "image"] vocabulary.
110
+ const mods = model.architecture?.input_modalities ?? [];
111
+ const input: string[] = [];
112
+ if (mods.includes("text")) input.push("text");
113
+ if (mods.includes("image")) input.push("image");
114
+ entry.input = input;
115
+
116
+ const derived = deriveThinkingFields(model);
117
+ if (derived) {
118
+ entry.reasoning = derived.reasoning;
119
+ entry.thinkingLevelMap = derived.thinkingLevelMap;
120
+ } else {
121
+ // No allowlist to derive from: keep the user's hand-curated fields rather
122
+ // than guessing which levels the model accepts.
123
+ if (previous?.reasoning !== undefined) entry.reasoning = previous.reasoning;
124
+ if (previous?.thinkingLevelMap !== undefined) {
125
+ entry.thinkingLevelMap = previous.thinkingLevelMap;
126
+ }
127
+ }
128
+
129
+ // `compat` is never published by the daemon; it stays user-curated — except
130
+ // for deepseek* models, where the role spelling below is authoritative.
131
+ if (isDeepSeekModel(model.id)) {
132
+ // DeepSeek-backed models reject the `developer` role (OpenAI's newer
133
+ // spelling of `system`) on strict upstreams with a hard 400. Pi sends
134
+ // `developer` for reasoning models on unrecognized providers because its
135
+ // provider heuristics only see the local daemon URL and can't know
136
+ // DeepSeek sits behind it — force the universally-accepted `system`
137
+ // spelling for every deepseek* model, keeping any other user-set keys.
138
+ entry.compat = { ...(previous?.compat ?? {}), supportsDeveloperRole: false };
139
+ } else if (previous?.compat !== undefined) {
140
+ entry.compat = previous.compat;
141
+ }
142
+
143
+ return entry;
144
+ }
145
+
19
146
  type PiProviderConfig = {
20
147
  baseUrl?: string;
21
148
  api?: string;
@@ -68,39 +195,17 @@ export async function installPiIntegration(
68
195
  }
69
196
 
70
197
  // Rebuild every model entry from scratch from the daemon, so the generated
71
- // models.json is always a faithful projection of the daemon's state. The only
72
- // exception is thinking/reasoning config (reasoning, thinkingLevelMap, compat),
73
- // which the daemon does not provide and the user curates by hand — preserve it.
198
+ // models.json is always a faithful projection of the daemon's state.
199
+ // Thinking fields are derived from the model's published reasoning allowlist;
200
+ // when the daemon has none, the user's hand-curated values are preserved.
201
+ // `compat` stays user-curated, except for the deepseek* pin applied below.
74
202
  const existingModels = new Map<string, PiModelEntry>(
75
203
  (piConfig.providers["routstr"]?.models ?? []).map((m) => [m.id, m]),
76
204
  );
77
205
 
78
- const providerModels: PiModelEntry[] = models.map((model) => {
79
- const previous = existingModels.get(model.id);
80
- const entry: PiModelEntry = { id: model.id };
81
-
82
- if (model.context_length !== undefined && model.context_length > 0) {
83
- entry.contextWindow = model.context_length;
84
- }
85
-
86
- if (model.name) {
87
- entry.name = model.name;
88
- }
89
-
90
- // Map the daemon's input modalities to Pi's ["text", "image"] vocabulary.
91
- const mods = model.architecture?.input_modalities ?? [];
92
- const input: string[] = [];
93
- if (mods.includes("text")) input.push("text");
94
- if (mods.includes("image")) input.push("image");
95
- entry.input = input;
96
-
97
- // Preserve user-curated thinking fields from the previous entry.
98
- if (previous?.reasoning !== undefined) entry.reasoning = previous.reasoning;
99
- if (previous?.thinkingLevelMap !== undefined) entry.thinkingLevelMap = previous.thinkingLevelMap;
100
- if (previous?.compat !== undefined) entry.compat = previous.compat;
101
-
102
- return entry;
103
- });
206
+ const providerModels: PiModelEntry[] = models.map((model) =>
207
+ buildPiModelEntry(model, existingModels.get(model.id)),
208
+ );
104
209
 
105
210
  // Rebuild provider from scratch too; only write routstrd-managed fields.
106
211
  piConfig.providers["routstr"] = {
@@ -14,6 +14,19 @@ export interface IntegrationConfig {
14
14
  configPath: string;
15
15
  }
16
16
 
17
+ /**
18
+ * Per-model reasoning metadata as published by routstr-core (OpenRouter shape).
19
+ * Models with no reasoning support omit the whole object, and models whose
20
+ * upstream publishes no effort allowlist carry only `mandatory`.
21
+ */
22
+ export type RoutstrReasoning = {
23
+ mandatory?: boolean | null;
24
+ default_enabled?: boolean | null;
25
+ supported_efforts?: string[] | null;
26
+ default_effort?: string | null;
27
+ supports_max_tokens?: boolean | null;
28
+ };
29
+
17
30
  export type RoutstrModel = {
18
31
  id: string;
19
32
  name?: string;
@@ -27,6 +40,7 @@ export type RoutstrModel = {
27
40
  context_length?: number;
28
41
  max_completion_tokens?: number;
29
42
  };
43
+ reasoning?: RoutstrReasoning | null;
30
44
  };
31
45
 
32
46
  export type IntegrationFn = (
@@ -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;