routstrd 0.3.2 → 0.3.4

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.
@@ -1,6 +1,6 @@
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, NpubStats, ProviderStats, UsageStats } from "./types.ts";
3
+ import type { UsageStats, UsageSummary } from "./types.ts";
4
4
 
5
5
  export { isDaemonRunning };
6
6
 
@@ -26,9 +26,6 @@ export interface StatusInfo {
26
26
 
27
27
  export async function fetchStatus(): Promise<StatusInfo | null> {
28
28
  try {
29
- const running = await isDaemonRunning();
30
- if (!running) return null;
31
-
32
29
  const result = await callDaemon("/status");
33
30
  if (result.error) return null;
34
31
 
@@ -52,9 +49,6 @@ export async function fetchStatus(): Promise<StatusInfo | null> {
52
49
 
53
50
  export async function fetchBalance(): Promise<BalanceInfo | null> {
54
51
  try {
55
- const running = await isDaemonRunning();
56
- if (!running) return null;
57
-
58
52
  const result = await callDaemon("/keys/balance");
59
53
  if (result.error) return null;
60
54
 
@@ -76,53 +70,28 @@ export async function fetchBalance(): Promise<BalanceInfo | null> {
76
70
  }
77
71
  }
78
72
 
79
- export async function fetchUsage(limit = 10000): Promise<UsageStats | null> {
73
+ export async function fetchUsageSummary(): Promise<UsageStats | null> {
80
74
  try {
81
- const running = await isDaemonRunning();
82
- if (!running) return null;
83
-
84
- const result = await callDaemon(`/usage?limit=${limit}`);
75
+ const tz = new Date().getTimezoneOffset();
76
+ const result = await callDaemon(`/usage/summary?tz=${tz}`);
85
77
  if (result.error) return null;
86
78
 
87
- // The auth proxy filters usage to the authenticated npub's clients and
88
- // returns client IDs without the owner suffix.
89
- const entries = result.output as UsageTrackingEntry[] | undefined;
90
- const visibleEntries = Array.isArray(entries) ? entries : [];
91
-
92
- // Calculate totals from visible entries
93
- const totals = visibleEntries.reduce(
94
- (acc, entry) => ({
95
- promptTokens: acc.promptTokens + entry.promptTokens,
96
- completionTokens: acc.completionTokens + entry.completionTokens,
97
- totalTokens: acc.totalTokens + entry.totalTokens,
98
- satsCost: acc.satsCost + entry.satsCost,
99
- }),
100
- { promptTokens: 0, completionTokens: 0, totalTokens: 0, satsCost: 0 },
101
- );
79
+ const summary = result.output as UsageSummary | undefined;
80
+ if (!summary || typeof summary.totals !== "object") return null;
102
81
 
103
82
  return {
104
- entries: visibleEntries,
105
- totalEntries: visibleEntries.length,
106
- totalSatsCost: totals.satsCost,
107
- recentSatsCost: totals.satsCost, // For now, recent = total since we don't have time window
108
- limit,
83
+ entries: summary.recent,
84
+ totalEntries: summary.totals.requests,
85
+ totalSatsCost: summary.totals.satsCost,
86
+ recentSatsCost: summary.totals.satsCost,
87
+ limit: 50,
88
+ summary,
109
89
  };
110
90
  } catch {
111
91
  return null;
112
92
  }
113
93
  }
114
94
 
115
- export function getTodayStart(): number {
116
- const now = new Date();
117
- return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
118
- }
119
-
120
- export function formatDate(timestamp: number): string {
121
- const d = new Date(timestamp);
122
- const p = (n: number) => String(n).padStart(2, "0");
123
- return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
124
- }
125
-
126
95
  export function formatTime(timestamp: number): string {
127
96
  const d = new Date(timestamp);
128
97
  const p = (n: number) => String(n).padStart(2, "0");
@@ -135,100 +104,6 @@ export function formatNumber(n: number): string {
135
104
  return n.toString();
136
105
  }
137
106
 
138
- export function getDayStats(entries: UsageTrackingEntry[]): Map<string, DayStats> {
139
- const days = new Map<string, DayStats>();
140
- for (const entry of entries) {
141
- const date = formatDate(entry.timestamp);
142
- const existing = days.get(date) || { date, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 };
143
- days.set(date, {
144
- ...existing,
145
- requests: existing.requests + 1,
146
- satsCost: existing.satsCost + entry.satsCost,
147
- promptTokens: existing.promptTokens + entry.promptTokens,
148
- completionTokens: existing.completionTokens + entry.completionTokens,
149
- totalTokens: existing.totalTokens + entry.totalTokens,
150
- });
151
- }
152
- return days;
153
- }
154
-
155
- export function getHourlyToday(entries: UsageTrackingEntry[]): Map<number, DayStats> {
156
- const todayStart = getTodayStart();
157
- const hours = new Map<number, DayStats>();
158
- for (const entry of entries) {
159
- if (entry.timestamp < todayStart) continue;
160
- const hour = new Date(entry.timestamp).getHours();
161
- const existing = hours.get(hour) || {
162
- date: formatDate(entry.timestamp), requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
163
- };
164
- hours.set(hour, {
165
- ...existing,
166
- requests: existing.requests + 1,
167
- satsCost: existing.satsCost + entry.satsCost,
168
- promptTokens: existing.promptTokens + entry.promptTokens,
169
- completionTokens: existing.completionTokens + entry.completionTokens,
170
- totalTokens: existing.totalTokens + entry.totalTokens,
171
- });
172
- }
173
- return hours;
174
- }
175
-
176
- export function getModelStats(entries: UsageTrackingEntry[]): ModelStats[] {
177
- const models = new Map<string, ModelStats>();
178
- for (const entry of entries) {
179
- const existing = models.get(entry.modelId) || {
180
- modelId: entry.modelId, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
181
- };
182
- models.set(entry.modelId, {
183
- ...existing,
184
- requests: existing.requests + 1,
185
- satsCost: existing.satsCost + entry.satsCost,
186
- promptTokens: existing.promptTokens + entry.promptTokens,
187
- completionTokens: existing.completionTokens + entry.completionTokens,
188
- totalTokens: existing.totalTokens + entry.totalTokens,
189
- });
190
- }
191
- return Array.from(models.values()).sort((a, b) => b.satsCost - a.satsCost);
192
- }
193
-
194
- export function getProviderStats(entries: UsageTrackingEntry[]): ProviderStats[] {
195
- const providers = new Map<string, ProviderStats>();
196
- for (const entry of entries) {
197
- const url = entry.baseUrl || "unknown";
198
- const existing = providers.get(url) || {
199
- baseUrl: url, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
200
- };
201
- providers.set(url, {
202
- ...existing,
203
- requests: existing.requests + 1,
204
- satsCost: existing.satsCost + entry.satsCost,
205
- promptTokens: existing.promptTokens + entry.promptTokens,
206
- completionTokens: existing.completionTokens + entry.completionTokens,
207
- totalTokens: existing.totalTokens + entry.totalTokens,
208
- });
209
- }
210
- return Array.from(providers.values()).sort((a, b) => b.satsCost - a.satsCost);
211
- }
212
-
213
- export function getClientStats(entries: UsageTrackingEntry[]): ClientStats[] {
214
- const clients = new Map<string, ClientStats>();
215
- for (const entry of entries) {
216
- const client = entry.client || "unknown";
217
- const existing = clients.get(client) || {
218
- client, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
219
- };
220
- clients.set(client, {
221
- ...existing,
222
- requests: existing.requests + 1,
223
- satsCost: existing.satsCost + entry.satsCost,
224
- promptTokens: existing.promptTokens + entry.promptTokens,
225
- completionTokens: existing.completionTokens + entry.completionTokens,
226
- totalTokens: existing.totalTokens + entry.totalTokens,
227
- });
228
- }
229
- return Array.from(clients.values()).sort((a, b) => b.satsCost - a.satsCost);
230
- }
231
-
232
107
  // ─── Client / Npub helpers ────────────────────────────────────────────
233
108
 
234
109
  export interface ClientInfo {
@@ -239,9 +114,6 @@ export interface ClientInfo {
239
114
 
240
115
  export async function fetchClients(): Promise<ClientInfo[]> {
241
116
  try {
242
- const running = await isDaemonRunning();
243
- if (!running) return [];
244
-
245
117
  const result = await callDaemon("/clients");
246
118
  if (result.error) return [];
247
119
 
@@ -262,56 +134,3 @@ export async function fetchClients(): Promise<ClientInfo[]> {
262
134
  export function hasAnyNpubs(clients: ClientInfo[]): boolean {
263
135
  return clients.some((c) => !!c.ownerNpub);
264
136
  }
265
-
266
- export function getNpubStats(entries: UsageTrackingEntry[], clients: ClientInfo[]): NpubStats[] {
267
- // Build client-id → ownerNpub lookup
268
- const clientNpubMap = new Map<string, string>();
269
- for (const c of clients) {
270
- if (c.ownerNpub) {
271
- clientNpubMap.set(c.clientId, c.ownerNpub);
272
- }
273
- }
274
-
275
- // Aggregate usage per npub
276
- const npubs = new Map<string, NpubStats>();
277
- for (const entry of entries) {
278
- const npub = clientNpubMap.get(entry.client || "");
279
- if (!npub) continue; // skip entries whose client has no ownerNpub
280
-
281
- const existing = npubs.get(npub) || {
282
- npub,
283
- requests: 0,
284
- satsCost: 0,
285
- promptTokens: 0,
286
- completionTokens: 0,
287
- totalTokens: 0,
288
- };
289
- npubs.set(npub, {
290
- ...existing,
291
- requests: existing.requests + 1,
292
- satsCost: existing.satsCost + entry.satsCost,
293
- promptTokens: existing.promptTokens + entry.promptTokens,
294
- completionTokens: existing.completionTokens + entry.completionTokens,
295
- totalTokens: existing.totalTokens + entry.totalTokens,
296
- });
297
- }
298
-
299
- return Array.from(npubs.values()).sort((a, b) => b.satsCost - a.satsCost);
300
- }
301
-
302
- // ─── Totals ───────────────────────────────────────────────────────────
303
-
304
- export function getTotals(entries: UsageTrackingEntry[] | undefined) {
305
- if (!entries || !Array.isArray(entries)) {
306
- return { promptTokens: 0, completionTokens: 0, totalTokens: 0, satsCost: 0 };
307
- }
308
- return entries.reduce(
309
- (acc, entry) => ({
310
- promptTokens: acc.promptTokens + entry.promptTokens,
311
- completionTokens: acc.completionTokens + entry.completionTokens,
312
- totalTokens: acc.totalTokens + entry.totalTokens,
313
- satsCost: acc.satsCost + entry.satsCost,
314
- }),
315
- { promptTokens: 0, completionTokens: 0, totalTokens: 0, satsCost: 0 }
316
- );
317
- }
@@ -1,17 +1,8 @@
1
1
  import { CLIENT_COLORS, COLORS, MODEL_COLORS } from "./constants.ts";
2
2
  import type { Tab } from "./types.ts";
3
3
  import {
4
- formatDate,
5
4
  formatNumber,
6
5
  formatTime,
7
- getClientStats,
8
- getDayStats,
9
- getHourlyToday,
10
- getModelStats,
11
- getNpubStats,
12
- getProviderStats,
13
- getTodayStart,
14
- getTotals,
15
6
  type ClientInfo,
16
7
  } from "./data.ts";
17
8
  import { vimState } from "./state.ts";
@@ -120,8 +111,7 @@ export function renderBarChart(
120
111
 
121
112
 
122
113
  export function renderOverview(stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number): string {
123
- // Use the server-calculated totals (all entries) instead of summing limited entries
124
- const totals = getTotals(stats.entries);
114
+ const totals = stats.summary.totals;
125
115
  const totalRequests = stats.totalEntries; // Use server's total count, not entries.length
126
116
  const totalVisibleCost = stats.totalSatsCost; // <-- Use server's total, not client-side sum of limited entries
127
117
  const avgCost = totalRequests > 0 ? totalVisibleCost / totalRequests : 0;
@@ -240,7 +230,7 @@ export function renderOverview(stats: UsageStats, balance: BalanceInfo | null, s
240
230
  output = renderBox(statusLines, width, "System Status") + "\n" + output;
241
231
  }
242
232
 
243
- const modelStats = getModelStats(stats.entries);
233
+ const modelStats = stats.summary.models;
244
234
  if (modelStats.length > 0) {
245
235
  const maxCost = modelStats[0]!.satsCost;
246
236
  const totalCost = Math.max(totalVisibleCost, 1);
@@ -259,7 +249,7 @@ export function renderOverview(stats: UsageStats, balance: BalanceInfo | null, s
259
249
  output += "\n" + renderBox(modelLines, width, "Top Models by Cost");
260
250
  }
261
251
 
262
- const clientStats = getClientStats(stats.entries);
252
+ const clientStats = stats.summary.clients;
263
253
  if (clientStats.length > 0) {
264
254
  const maxCost = clientStats[0]!.satsCost;
265
255
  const totalCost = Math.max(totalVisibleCost, 1);
@@ -282,19 +272,28 @@ export function renderOverview(stats: UsageStats, balance: BalanceInfo | null, s
282
272
  }
283
273
 
284
274
  export function renderToday(stats: UsageStats, width: number): string {
285
- const hourly = getHourlyToday(stats.entries);
286
- const todayStart = getTodayStart();
287
275
  const currentHour = new Date().getHours();
288
- const todayStats = { date: formatDate(Date.now()), requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 };
289
-
290
- for (const entry of stats.entries) {
291
- if (entry.timestamp >= todayStart) {
292
- todayStats.requests++;
293
- todayStats.satsCost += entry.satsCost;
294
- todayStats.promptTokens += entry.promptTokens;
295
- todayStats.completionTokens += entry.completionTokens;
296
- todayStats.totalTokens += entry.totalTokens;
297
- }
276
+ // Build today's date string from LOCAL date components so it matches the
277
+ // server's tz-bucketed day keys (which use the client's tzOffsetMinutes).
278
+ const _now = new Date();
279
+ const todayDateStr = `${_now.getFullYear()}-${String(_now.getMonth() + 1).padStart(2, "0")}-${String(_now.getDate()).padStart(2, "0")}`;
280
+
281
+ let todayStats: { date: string; requests: number; satsCost: number; promptTokens: number; completionTokens: number; totalTokens: number };
282
+ let recentDays: Array<{ date: string; requests: number; satsCost: number; totalTokens: number; promptTokens: number; completionTokens: number }>;
283
+ let hourlyMap: Map<number, { requests: number; satsCost: number }>;
284
+
285
+ if (stats.summary) {
286
+ // Use server-aggregated data
287
+ const { days, hoursToday } = stats.summary;
288
+ // days[0] is most-recent-first; check if it's today
289
+ const todayDayStat = days[0]?.date === todayDateStr ? days[0] : undefined;
290
+ todayStats = todayDayStat
291
+ ? { date: todayDayStat.date, requests: todayDayStat.requests, satsCost: todayDayStat.satsCost, promptTokens: todayDayStat.promptTokens, completionTokens: todayDayStat.completionTokens, totalTokens: todayDayStat.totalTokens }
292
+ : { date: todayDateStr, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 };
293
+ // Exclude today by date (it has its own box) rather than by position —
294
+ // days[0] is only today when there was activity today.
295
+ recentDays = days.filter((d) => d.date !== todayDateStr).slice(0, 6);
296
+ hourlyMap = new Map(hoursToday.map((h) => [h.hour, { requests: h.requests, satsCost: h.satsCost }]));
298
297
  }
299
298
 
300
299
  const summaryLines = [
@@ -306,18 +305,17 @@ export function renderToday(stats: UsageStats, width: number): string {
306
305
 
307
306
  let output = renderBox(summaryLines, width, "Today");
308
307
 
309
- const days = Array.from(getDayStats(stats.entries).values()).slice(0, 7);
310
- if (days.length > 1) {
311
- const dayLines = days.slice(1).map((d) => `${d.date}: ${formatReqs(d.requests)} req, ${formatCost(d.satsCost)} sats, ${formatNumber(d.totalTokens)} tokens`);
308
+ if (recentDays.length > 0) {
309
+ const dayLines = recentDays.map((d) => `${d.date}: ${formatReqs(d.requests)} req, ${formatCost(d.satsCost)} sats, ${formatNumber(d.totalTokens)} tokens`);
312
310
  output += "\n" + renderBox(dayLines, width, "Recent Days");
313
311
  }
314
312
 
315
313
  const hourLines: string[] = [];
316
- const maxHourCost = Math.max(...Array.from(hourly.values()).map((h) => h.satsCost), 1);
314
+ const maxHourCost = Math.max(...Array.from(hourlyMap.values()).map((h) => h.satsCost), 1);
317
315
  const totalTodayCost = Math.max(todayStats.satsCost, 1);
318
316
  const hourLabels: string[] = [];
319
317
  for (let h = currentHour; h >= 0; h--) {
320
- const hStat = hourly.get(h);
318
+ const hStat = hourlyMap.get(h);
321
319
  const reqs = hStat?.requests || 0;
322
320
  const cost = hStat?.satsCost || 0;
323
321
  hourLabels.push(`${h.toString().padStart(2, "0")}:00 (${formatReqs(reqs)} req, ${formatCost(cost)} sats) `);
@@ -325,7 +323,7 @@ export function renderToday(stats: UsageStats, width: number): string {
325
323
  const maxHourLabel = Math.max(...hourLabels.map((l) => l.length));
326
324
  startBarSection("hourly", maxHourLabel);
327
325
  for (let i = currentHour; i >= 0; i--) {
328
- const hStat = hourly.get(i);
326
+ const hStat = hourlyMap.get(i);
329
327
  const reqs = hStat?.requests || 0;
330
328
  const cost = hStat?.satsCost || 0;
331
329
  hourLines.push(renderBarChart(
@@ -346,7 +344,7 @@ export function renderToday(stats: UsageStats, width: number): string {
346
344
  }
347
345
 
348
346
  export function renderModels(stats: UsageStats, width: number): string {
349
- const modelStats = getModelStats(stats.entries);
347
+ const modelStats = stats.summary.models;
350
348
  if (modelStats.length === 0) return renderBox(["No model data available"], width, "Models");
351
349
 
352
350
  // Use totalSatsCost (all-time) for percentage calculations to match header
@@ -373,7 +371,7 @@ export function renderModels(stats: UsageStats, width: number): string {
373
371
  }
374
372
 
375
373
  export function renderProviders(stats: UsageStats, width: number): string {
376
- const providerStats = getProviderStats(stats.entries);
374
+ const providerStats = stats.summary.providers;
377
375
  if (providerStats.length === 0) return renderBox(["No provider data available"], width, "Providers");
378
376
 
379
377
  const lines: string[] = [];
@@ -389,8 +387,8 @@ export function renderProviders(stats: UsageStats, width: number): string {
389
387
  }
390
388
 
391
389
  export function renderTokens(stats: UsageStats, width: number): string {
392
- const totals = getTotals(stats.entries);
393
- const modelStats = getModelStats(stats.entries);
390
+ const totals = stats.summary.totals;
391
+ const modelStats = stats.summary.models;
394
392
  const summaryLines = [
395
393
  `${COLORS.bold}Total Prompt Tokens:${COLORS.reset} ${formatNumber(totals.promptTokens)}`,
396
394
  `${COLORS.bold}Total Completion Tokens:${COLORS.reset} ${formatNumber(totals.completionTokens)}`,
@@ -410,23 +408,7 @@ export function renderTokens(stats: UsageStats, width: number): string {
410
408
  output += "\n" + renderBox(tokenLines, width, "Tokens by Model");
411
409
  }
412
410
 
413
- const sizeBuckets = {
414
- tiny: { min: 0, max: 1000, count: 0, cost: 0 },
415
- small: { min: 1000, max: 10000, count: 0, cost: 0 },
416
- medium: { min: 10000, max: 50000, count: 0, cost: 0 },
417
- large: { min: 50000, max: 100000, count: 0, cost: 0 },
418
- huge: { min: 100000, max: Infinity, count: 0, cost: 0 },
419
- };
420
-
421
- for (const entry of stats.entries) {
422
- for (const bucket of Object.values(sizeBuckets)) {
423
- if (entry.totalTokens >= bucket.min && entry.totalTokens < bucket.max) {
424
- bucket.count++;
425
- bucket.cost += entry.satsCost;
426
- break;
427
- }
428
- }
429
- }
411
+ const sizeBuckets = stats.summary.sizeBuckets;
430
412
 
431
413
  const sizeLines = Object.entries(sizeBuckets).map(([name, bucket]) => `${name.padEnd(6)}: ${formatReqs(bucket.count).padStart(5)} reqs, ${formatCost(bucket.cost)} sats`);
432
414
  output += "\n" + renderBox(sizeLines, width, "Request Size Distribution");
@@ -434,7 +416,7 @@ export function renderTokens(stats: UsageStats, width: number): string {
434
416
  }
435
417
 
436
418
  export function renderClients(stats: UsageStats, width: number): string {
437
- const clientStats = getClientStats(stats.entries);
419
+ const clientStats = stats.summary.clients;
438
420
  if (clientStats.length === 0) return renderBox(["No client data available (API key auth not used)"], width, "Client Breakdown");
439
421
 
440
422
  // Use totalSatsCost (all-time) for percentage calculations to match header
@@ -478,31 +460,19 @@ export function renderClients(stats: UsageStats, width: number): string {
478
460
  endBarSection("client-detail");
479
461
 
480
462
  let output = renderBox(lines, width, "Client Breakdown");
481
- const clientModelMap = new Map<string, Map<string, { requests: number; satsCost: number; tokens: number }>>();
482
-
483
- for (const entry of stats.entries) {
484
- const client = entry.client || "unknown";
485
- const model = entry.modelId;
486
- if (!clientModelMap.has(client)) clientModelMap.set(client, new Map());
487
- const modelMap = clientModelMap.get(client)!;
488
- const existing = modelMap.get(model) || { requests: 0, satsCost: 0, tokens: 0 };
489
- modelMap.set(model, {
490
- requests: existing.requests + 1,
491
- satsCost: existing.satsCost + entry.satsCost,
492
- tokens: existing.tokens + entry.totalTokens,
493
- });
494
- }
495
-
496
463
  const clientModelLines: string[] = [];
497
- for (const topClient of clientStats.slice(0, 3)) {
498
- const modelMap = clientModelMap.get(topClient.client);
499
- if (!modelMap) continue;
500
- const models = Array.from(modelMap.entries()).sort((a, b) => b[1].satsCost - a[1].satsCost).slice(0, 5);
501
- clientModelLines.push(`${COLORS.bold}${topClient.client}${COLORS.reset} (${formatReqs(topClient.requests)} reqs, ${formatCost(topClient.satsCost)} sats)`);
502
- for (const [model, data] of models) {
503
- clientModelLines.push(` ${(MODEL_COLORS[model] || MODEL_COLORS.default)}${model.padEnd(18)}${COLORS.reset} ${formatNumber(data.tokens).padEnd(8)} tokens ${formatCost(data.satsCost)} sats`);
464
+
465
+ if (stats.summary) {
466
+ // Use pre-aggregated topModels from summary; exclude the "unknown" bucket
467
+ // (null client rows have no meaningful model attribution to display here).
468
+ for (const topClient of stats.summary.clients.filter((c) => c.client !== "unknown").slice(0, 3)) {
469
+ if (topClient.topModels.length === 0) continue;
470
+ clientModelLines.push(`${COLORS.bold}${topClient.client}${COLORS.reset} (${formatReqs(topClient.requests)} reqs, ${formatCost(topClient.satsCost)} sats)`);
471
+ for (const m of topClient.topModels) {
472
+ clientModelLines.push(` ${(MODEL_COLORS[m.modelId] || MODEL_COLORS.default)}${m.modelId.padEnd(18)}${COLORS.reset} ${formatNumber(m.totalTokens).padEnd(8)} tokens ${formatCost(m.satsCost)} sats`);
473
+ }
474
+ clientModelLines.push("");
504
475
  }
505
- clientModelLines.push("");
506
476
  }
507
477
 
508
478
  if (clientModelLines.length > 0) {
@@ -512,7 +482,7 @@ export function renderClients(stats: UsageStats, width: number): string {
512
482
  }
513
483
 
514
484
  export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: number): string {
515
- const npubStats = getNpubStats(stats.entries, clients);
485
+ const npubStats = stats.summary.npubs;
516
486
  if (npubStats.length === 0) return renderBox(["No npub data available"], width, "Npub Breakdown");
517
487
 
518
488
  const totalCost = stats.totalSatsCost;
@@ -557,38 +527,18 @@ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: num
557
527
  endBarSection("npub-detail");
558
528
 
559
529
  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
530
  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`);
531
+
532
+ if (stats.summary) {
533
+ // Use pre-aggregated topModels from summary
534
+ for (const topNpub of stats.summary.npubs.slice(0, 5)) {
535
+ if (topNpub.topModels.length === 0) continue;
536
+ npubModelLines.push(`${COLORS.bold}${truncateNpub(topNpub.npub)}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`);
537
+ for (const m of topNpub.topModels) {
538
+ 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`);
539
+ }
540
+ npubModelLines.push("");
590
541
  }
591
- npubModelLines.push("");
592
542
  }
593
543
 
594
544
  if (npubModelLines.length > 0) {
@@ -1,4 +1,7 @@
1
1
  import type { UsageTrackingEntry } from "../../daemon/types.ts";
2
+ import type { UsageSummary } from "../../daemon/http/usage-summary.ts";
3
+
4
+ export type { UsageSummary };
2
5
 
3
6
  export interface UsageStats {
4
7
  entries: UsageTrackingEntry[];
@@ -6,42 +9,7 @@ export interface UsageStats {
6
9
  totalSatsCost: number;
7
10
  recentSatsCost: number;
8
11
  limit: number;
9
- }
10
-
11
- export interface DayStats {
12
- date: string;
13
- requests: number;
14
- satsCost: number;
15
- promptTokens: number;
16
- completionTokens: number;
17
- totalTokens: number;
18
- }
19
-
20
- export interface ModelStats {
21
- modelId: string;
22
- requests: number;
23
- satsCost: number;
24
- promptTokens: number;
25
- completionTokens: number;
26
- totalTokens: number;
27
- }
28
-
29
- export interface ProviderStats {
30
- baseUrl: string;
31
- requests: number;
32
- satsCost: number;
33
- promptTokens: number;
34
- completionTokens: number;
35
- totalTokens: number;
36
- }
37
-
38
- export interface ClientStats {
39
- client: string;
40
- requests: number;
41
- satsCost: number;
42
- promptTokens: number;
43
- completionTokens: number;
44
- totalTokens: number;
12
+ summary: UsageSummary;
45
13
  }
46
14
 
47
15
  export type TabId = "overview" | "today" | "models" | "providers" | "tokens" | "clients" | "npubs" | "recent";
@@ -52,15 +20,6 @@ export interface Tab {
52
20
  key: string;
53
21
  }
54
22
 
55
- export interface NpubStats {
56
- npub: string;
57
- requests: number;
58
- satsCost: number;
59
- promptTokens: number;
60
- completionTokens: number;
61
- totalTokens: number;
62
- }
63
-
64
23
  export interface VimState {
65
24
  scrollPos: number;
66
25
  searchQuery: string;
@@ -6,6 +6,7 @@ export const PID_FILE = process.env.ROUTSTRD_PID || `${CONFIG_DIR}/routstrd.pid`
6
6
  export const DB_PATH = `${CONFIG_DIR}/routstr.db`;
7
7
  export const CONFIG_FILE = `${CONFIG_DIR}/config.json`;
8
8
  export const LOGS_DIR = `${CONFIG_DIR}/logs`;
9
+ export const REQUEST_RESPONSE_LOGS_DIR = `${CONFIG_DIR}/request-response-logs`;
9
10
 
10
11
  /** NWC auto-refill configuration */
11
12
  export interface NwcAutoRefillConfig {
@@ -34,6 +35,13 @@ export interface RoutstrdConfig {
34
35
  provider: string | null;
35
36
  cocodPath: string | null;
36
37
  mode?: "xcashu" | "apikeys";
38
+ /** Raw upstream request/response logging. Disabled by default because logs can contain sensitive prompts, outputs, and auth/payment headers. */
39
+ requestResponseLogging?: {
40
+ /** Enable raw request/response file logging. */
41
+ enabled?: boolean;
42
+ /** Root log directory. SDK writes requests/*.json and responses/*.jsonl below this directory. Defaults to ~/.routstrd/request-response-logs. */
43
+ dir?: string;
44
+ };
37
45
  daemonUrl?: string;
38
46
  /** URL of the auth proxy (routstrd-auth) for management endpoints (npubs, clients, usage).
39
47
  * Defaults to daemonUrl or localhost:{port} if not set. */