routstrd 0.3.2 → 0.3.3
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 +787 -135
- package/dist/index.js +61 -275
- 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 +2 -2
- package/src/tui/usage/data.ts +12 -181
- package/src/tui/usage/render.ts +57 -107
- package/src/tui/usage/types.ts +4 -45
- package/src/utils/config.ts +8 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { createWriteStream, mkdirSync, type WriteStream } from "fs";
|
|
2
|
+
import { writeFile } from "fs/promises";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import type { SdkLogger } from "@routstr/sdk";
|
|
5
|
+
|
|
6
|
+
export interface RequestResponseLogRequestInput {
|
|
7
|
+
method: string;
|
|
8
|
+
url: string;
|
|
9
|
+
path: string;
|
|
10
|
+
baseUrl: string;
|
|
11
|
+
headers: Record<string, string>;
|
|
12
|
+
body?: unknown;
|
|
13
|
+
rawBody?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RequestResponseLogSink {
|
|
17
|
+
logRequest?(input: RequestResponseLogRequestInput): string | undefined | Promise<string | undefined>;
|
|
18
|
+
logResponseStart?(id: string | undefined, response: Response): void | Promise<void>;
|
|
19
|
+
logResponseChunk?(id: string | undefined, sequence: number, text: string): void | Promise<void>;
|
|
20
|
+
logResponseEnd?(id: string | undefined): void | Promise<void>;
|
|
21
|
+
logResponseError?(id: string | undefined, error: unknown): void | Promise<void>;
|
|
22
|
+
logResponseBody?(id: string | undefined, response: Response): void | Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ActiveResponseLog {
|
|
26
|
+
stream: WriteStream;
|
|
27
|
+
pending: Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FileRequestResponseLogSinkOptions {
|
|
31
|
+
dir: string;
|
|
32
|
+
logger?: SdkLogger;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SENSITIVE_HEADER_NAMES = new Set([
|
|
36
|
+
"authorization",
|
|
37
|
+
"x-cashu",
|
|
38
|
+
"cookie",
|
|
39
|
+
"set-cookie",
|
|
40
|
+
"proxy-authorization",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const SENSITIVE_BODY_FIELD_NAMES = new Set([
|
|
44
|
+
"authorization",
|
|
45
|
+
"api_key",
|
|
46
|
+
"apikey",
|
|
47
|
+
"apiKey",
|
|
48
|
+
"access_token",
|
|
49
|
+
"accessToken",
|
|
50
|
+
"bearer",
|
|
51
|
+
"cashu",
|
|
52
|
+
"cookie",
|
|
53
|
+
"key",
|
|
54
|
+
"password",
|
|
55
|
+
"secret",
|
|
56
|
+
"token",
|
|
57
|
+
"x-cashu",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const REDACTED = "[REDACTED]";
|
|
61
|
+
|
|
62
|
+
const sanitizeForFilename = (value: string): string =>
|
|
63
|
+
value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
64
|
+
|
|
65
|
+
const makeId = (): string => {
|
|
66
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
67
|
+
const random = crypto.randomUUID().slice(0, 8);
|
|
68
|
+
return `${timestamp}-${random}`;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const headersToObject = (headers: Headers): Record<string, string> => {
|
|
72
|
+
const out: Record<string, string> = {};
|
|
73
|
+
headers.forEach((value, key) => {
|
|
74
|
+
out[key] = value;
|
|
75
|
+
});
|
|
76
|
+
return out;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const redactHeaders = (headers: Record<string, string>): Record<string, string> =>
|
|
80
|
+
Object.fromEntries(
|
|
81
|
+
Object.entries(headers).map(([key, value]) => [
|
|
82
|
+
key,
|
|
83
|
+
SENSITIVE_HEADER_NAMES.has(key.toLowerCase()) ? REDACTED : value,
|
|
84
|
+
]),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const redactBody = (value: unknown): unknown => {
|
|
88
|
+
if (!value || typeof value !== "object") return value;
|
|
89
|
+
if (Array.isArray(value)) return value.map(redactBody);
|
|
90
|
+
|
|
91
|
+
return Object.fromEntries(
|
|
92
|
+
Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
|
|
93
|
+
key,
|
|
94
|
+
SENSITIVE_BODY_FIELD_NAMES.has(key) || SENSITIVE_BODY_FIELD_NAMES.has(key.toLowerCase())
|
|
95
|
+
? REDACTED
|
|
96
|
+
: redactBody(entry),
|
|
97
|
+
]),
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const redactRawBody = (rawBody: string | undefined): string | undefined => {
|
|
102
|
+
if (!rawBody) return undefined;
|
|
103
|
+
try {
|
|
104
|
+
return JSON.stringify(redactBody(JSON.parse(rawBody)));
|
|
105
|
+
} catch {
|
|
106
|
+
return rawBody;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export class FileRequestResponseLogSink implements RequestResponseLogSink {
|
|
111
|
+
private requestsDir: string;
|
|
112
|
+
private responsesDir: string;
|
|
113
|
+
private activeResponses = new Map<string, ActiveResponseLog>();
|
|
114
|
+
|
|
115
|
+
constructor(private options: FileRequestResponseLogSinkOptions) {
|
|
116
|
+
this.requestsDir = join(options.dir, "requests");
|
|
117
|
+
this.responsesDir = join(options.dir, "responses");
|
|
118
|
+
mkdirSync(this.requestsDir, { recursive: true });
|
|
119
|
+
mkdirSync(this.responsesDir, { recursive: true });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async logRequest(input: RequestResponseLogRequestInput): Promise<string | undefined> {
|
|
123
|
+
try {
|
|
124
|
+
const id = makeId();
|
|
125
|
+
const filePath = join(this.requestsDir, `${sanitizeForFilename(id)}.json`);
|
|
126
|
+
await writeFile(
|
|
127
|
+
filePath,
|
|
128
|
+
JSON.stringify(
|
|
129
|
+
{
|
|
130
|
+
id,
|
|
131
|
+
timestamp: new Date().toISOString(),
|
|
132
|
+
method: input.method,
|
|
133
|
+
url: input.url,
|
|
134
|
+
path: input.path,
|
|
135
|
+
baseUrl: input.baseUrl,
|
|
136
|
+
headers: redactHeaders(input.headers),
|
|
137
|
+
body: redactBody(input.body),
|
|
138
|
+
rawBody: redactRawBody(input.rawBody),
|
|
139
|
+
},
|
|
140
|
+
null,
|
|
141
|
+
2,
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
return id;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
this.options.logger?.error?.("[request-response-log] failed to log request:", error);
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async logResponseStart(id: string | undefined, response: Response): Promise<void> {
|
|
152
|
+
if (!id) return;
|
|
153
|
+
await this.append(id, {
|
|
154
|
+
type: "response_start",
|
|
155
|
+
status: response.status,
|
|
156
|
+
statusText: response.statusText,
|
|
157
|
+
headers: redactHeaders(headersToObject(response.headers)),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
logResponseChunk(id: string | undefined, sequence: number, text: string): void {
|
|
162
|
+
if (!id) return;
|
|
163
|
+
void this.append(id, {
|
|
164
|
+
type: "chunk",
|
|
165
|
+
sequence,
|
|
166
|
+
text,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async logResponseEnd(id: string | undefined): Promise<void> {
|
|
171
|
+
if (!id) return;
|
|
172
|
+
await this.append(id, { type: "end" });
|
|
173
|
+
await this.close(id);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async logResponseError(id: string | undefined, error: unknown): Promise<void> {
|
|
177
|
+
if (!id) return;
|
|
178
|
+
await this.append(id, {
|
|
179
|
+
type: "error",
|
|
180
|
+
error: error instanceof Error ? { message: error.message, stack: error.stack } : String(error),
|
|
181
|
+
});
|
|
182
|
+
await this.close(id);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async logResponseBody(id: string | undefined, response: Response): Promise<void> {
|
|
186
|
+
if (!id) return;
|
|
187
|
+
try {
|
|
188
|
+
if (!response.body) {
|
|
189
|
+
await this.logResponseEnd(id);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const reader = response.body.getReader();
|
|
194
|
+
const decoder = new TextDecoder("utf-8");
|
|
195
|
+
let sequence = 0;
|
|
196
|
+
|
|
197
|
+
while (true) {
|
|
198
|
+
const { done, value } = await reader.read();
|
|
199
|
+
if (done) break;
|
|
200
|
+
if (value && value.byteLength > 0) {
|
|
201
|
+
await this.append(id, {
|
|
202
|
+
type: "chunk",
|
|
203
|
+
sequence: sequence++,
|
|
204
|
+
text: decoder.decode(value, { stream: true }),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const tail = decoder.decode();
|
|
210
|
+
if (tail) {
|
|
211
|
+
await this.append(id, {
|
|
212
|
+
type: "chunk",
|
|
213
|
+
sequence: sequence++,
|
|
214
|
+
text: tail,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await this.logResponseEnd(id);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
await this.logResponseError(id, error);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private getOrCreate(id: string): ActiveResponseLog {
|
|
225
|
+
const existing = this.activeResponses.get(id);
|
|
226
|
+
if (existing) return existing;
|
|
227
|
+
|
|
228
|
+
const filePath = join(this.responsesDir, `${sanitizeForFilename(id)}.jsonl`);
|
|
229
|
+
const stream = createWriteStream(filePath, { flags: "a" });
|
|
230
|
+
const active: ActiveResponseLog = {
|
|
231
|
+
stream,
|
|
232
|
+
pending: Promise.resolve(),
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
stream.on("error", (error) => {
|
|
236
|
+
this.options.logger?.error?.("[request-response-log] response log stream error:", error);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
this.activeResponses.set(id, active);
|
|
240
|
+
return active;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private async append(id: string, event: Record<string, unknown>): Promise<void> {
|
|
244
|
+
try {
|
|
245
|
+
const active = this.getOrCreate(id);
|
|
246
|
+
const line = JSON.stringify({ requestLogId: id, timestamp: new Date().toISOString(), ...event }) + "\n";
|
|
247
|
+
active.pending = active.pending.then(
|
|
248
|
+
() =>
|
|
249
|
+
new Promise<void>((resolve, reject) => {
|
|
250
|
+
active.stream.write(line, (error) => {
|
|
251
|
+
if (error) reject(error);
|
|
252
|
+
else resolve();
|
|
253
|
+
});
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
await active.pending;
|
|
257
|
+
} catch (error) {
|
|
258
|
+
this.options.logger?.error?.("[request-response-log] failed to append response event:", error);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private async close(id: string): Promise<void> {
|
|
263
|
+
const active = this.activeResponses.get(id);
|
|
264
|
+
if (!active) return;
|
|
265
|
+
this.activeResponses.delete(id);
|
|
266
|
+
await active.pending;
|
|
267
|
+
await new Promise<void>((resolve) => active.stream.end(resolve));
|
|
268
|
+
}
|
|
269
|
+
}
|
package/src/tui/usage/app.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getVisibleTabs } from "./constants.ts";
|
|
2
2
|
import type { Tab } from "./types.ts";
|
|
3
|
-
import { fetchBalance, fetchClients, fetchStatus,
|
|
3
|
+
import { fetchBalance, fetchClients, fetchStatus, fetchUsageSummary, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type StatusInfo } from "./data.ts";
|
|
4
4
|
import {
|
|
5
5
|
applyScrollToContent,
|
|
6
6
|
exitSearchMode,
|
|
@@ -89,7 +89,7 @@ export async function runUsageTui(): Promise<void> {
|
|
|
89
89
|
const height = getHeight();
|
|
90
90
|
|
|
91
91
|
if (forceFetch || shouldUpdate) {
|
|
92
|
-
stats = await
|
|
92
|
+
stats = await fetchUsageSummary();
|
|
93
93
|
balance = await fetchBalance();
|
|
94
94
|
status = await fetchStatus();
|
|
95
95
|
clients = await fetchClients();
|
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
|
|
|
@@ -76,53 +76,31 @@ export async function fetchBalance(): Promise<BalanceInfo | null> {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
export async function
|
|
79
|
+
export async function fetchUsageSummary(): Promise<UsageStats | null> {
|
|
80
80
|
try {
|
|
81
81
|
const running = await isDaemonRunning();
|
|
82
82
|
if (!running) return null;
|
|
83
83
|
|
|
84
|
-
const
|
|
84
|
+
const tz = new Date().getTimezoneOffset();
|
|
85
|
+
const result = await callDaemon(`/usage/summary?tz=${tz}`);
|
|
85
86
|
if (result.error) return null;
|
|
86
87
|
|
|
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
|
-
);
|
|
88
|
+
const summary = result.output as UsageSummary | undefined;
|
|
89
|
+
if (!summary || typeof summary.totals !== "object") return null;
|
|
102
90
|
|
|
103
91
|
return {
|
|
104
|
-
entries:
|
|
105
|
-
totalEntries:
|
|
106
|
-
totalSatsCost: totals.satsCost,
|
|
107
|
-
recentSatsCost: totals.satsCost,
|
|
108
|
-
limit,
|
|
92
|
+
entries: summary.recent,
|
|
93
|
+
totalEntries: summary.totals.requests,
|
|
94
|
+
totalSatsCost: summary.totals.satsCost,
|
|
95
|
+
recentSatsCost: summary.totals.satsCost,
|
|
96
|
+
limit: 50,
|
|
97
|
+
summary,
|
|
109
98
|
};
|
|
110
99
|
} catch {
|
|
111
100
|
return null;
|
|
112
101
|
}
|
|
113
102
|
}
|
|
114
103
|
|
|
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
104
|
export function formatTime(timestamp: number): string {
|
|
127
105
|
const d = new Date(timestamp);
|
|
128
106
|
const p = (n: number) => String(n).padStart(2, "0");
|
|
@@ -135,100 +113,6 @@ export function formatNumber(n: number): string {
|
|
|
135
113
|
return n.toString();
|
|
136
114
|
}
|
|
137
115
|
|
|
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
116
|
// ─── Client / Npub helpers ────────────────────────────────────────────
|
|
233
117
|
|
|
234
118
|
export interface ClientInfo {
|
|
@@ -262,56 +146,3 @@ export async function fetchClients(): Promise<ClientInfo[]> {
|
|
|
262
146
|
export function hasAnyNpubs(clients: ClientInfo[]): boolean {
|
|
263
147
|
return clients.some((c) => !!c.ownerNpub);
|
|
264
148
|
}
|
|
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
|
-
}
|