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.
- package/bun.lock +2 -2
- package/dist/daemon/index.js +791 -135
- package/dist/index.js +131 -346
- package/package.json +2 -2
- package/src/daemon/http/usage-summary.test.ts +259 -0
- package/src/daemon/http/usage-summary.ts +361 -0
- package/src/daemon/request-response-log-sink.ts +269 -0
- package/src/tui/usage/app.ts +85 -69
- package/src/tui/usage/data.ts +12 -193
- package/src/tui/usage/render.ts +57 -107
- package/src/tui/usage/types.ts +4 -45
- package/src/utils/config.ts +8 -0
package/src/tui/usage/data.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
73
|
+
export async function fetchUsageSummary(): Promise<UsageStats | null> {
|
|
80
74
|
try {
|
|
81
|
-
const
|
|
82
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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:
|
|
105
|
-
totalEntries:
|
|
106
|
-
totalSatsCost: totals.satsCost,
|
|
107
|
-
recentSatsCost: totals.satsCost,
|
|
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
|
-
}
|
package/src/tui/usage/render.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
310
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
393
|
-
const modelStats =
|
|
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 =
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
clientModelLines.push(
|
|
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 =
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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) {
|
package/src/tui/usage/types.ts
CHANGED
|
@@ -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;
|
package/src/utils/config.ts
CHANGED
|
@@ -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. */
|