routstrd 0.3.1 → 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 +2364 -2769
- package/dist/index.js +102 -284
- package/package.json +2 -2
- package/say_hi.sh +32 -0
- package/src/cli.ts +15 -2
- package/src/daemon/http/usage-summary.test.ts +259 -0
- package/src/daemon/http/usage-summary.ts +361 -0
- package/src/daemon/models.ts +55 -0
- package/src/daemon/request-response-log-sink.ts +269 -0
- package/src/daemon/types.ts +6 -0
- package/src/tui/usage/app.ts +2 -2
- package/src/tui/usage/data.ts +15 -180
- package/src/tui/usage/render.ts +77 -112
- package/src/tui/usage/types.ts +4 -45
- package/src/utils/clients.ts +3 -4
- package/src/utils/config.ts +11 -0
- package/new-task.md +0 -60
package/src/daemon/models.ts
CHANGED
|
@@ -120,9 +120,64 @@ export function createModelService(modelManager: ModelManager, store: SdkStore)
|
|
|
120
120
|
};
|
|
121
121
|
};
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Force-refresh everything: re-fetch Nostr provider discovery events,
|
|
125
|
+
* routstr21 model list, Nostr review events, and models from all enabled
|
|
126
|
+
* providers. Syncs the discovered provider list into the store.
|
|
127
|
+
*/
|
|
128
|
+
const refreshProvidersAndModels = async (): Promise<void> => {
|
|
129
|
+
// Reset the bootstrap promise so we don't reuse cached results
|
|
130
|
+
providerBootstrapPromise = null;
|
|
131
|
+
|
|
132
|
+
console.log("Force-refreshing providers from Nostr...");
|
|
133
|
+
|
|
134
|
+
// Force-refresh provider discovery from Nostr (kind 38421)
|
|
135
|
+
const providers = await modelManager.bootstrapProviders(false, true);
|
|
136
|
+
console.log(`Discovered ${providers.length} providers from Nostr`);
|
|
137
|
+
|
|
138
|
+
// Force-refresh routstr21 models from Nostr (kind 38423)
|
|
139
|
+
const routstr21ModelIds = await modelManager.fetchRoutstr21Models(true);
|
|
140
|
+
console.log(`Fetched ${routstr21ModelIds.length} routstr21 model IDs from Nostr`);
|
|
141
|
+
|
|
142
|
+
// Force-refresh models from all providers
|
|
143
|
+
const models = await modelManager.fetchModels(providers, true);
|
|
144
|
+
console.log(`Fetched ${models.length} models from ${providers.length} providers`);
|
|
145
|
+
|
|
146
|
+
// Sync review events from Nostr (kind 38425) and apply disabled status
|
|
147
|
+
const reviewedDisabled = await modelManager.syncReviewedProvidersFromNostr(
|
|
148
|
+
providers,
|
|
149
|
+
undefined,
|
|
150
|
+
true,
|
|
151
|
+
);
|
|
152
|
+
if (reviewedDisabled.length > 0) {
|
|
153
|
+
console.log(
|
|
154
|
+
`Review sync disabled ${reviewedDisabled.length} provider(s): ${reviewedDisabled.join(", ")}`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Sync discovered providers into the store
|
|
159
|
+
const { baseUrlsList, setBaseUrlsList, disabledProviders, setDisabledProviders } =
|
|
160
|
+
store.getState() as any;
|
|
161
|
+
|
|
162
|
+
// Replace baseUrlsList with the fresh provider list
|
|
163
|
+
setBaseUrlsList(providers);
|
|
164
|
+
|
|
165
|
+
// Merge review-disabled providers into the store's disabled list
|
|
166
|
+
const existingDisabled = new Set(disabledProviders || []);
|
|
167
|
+
for (const url of reviewedDisabled) {
|
|
168
|
+
existingDisabled.add(url);
|
|
169
|
+
}
|
|
170
|
+
setDisabledProviders([...existingDisabled]);
|
|
171
|
+
|
|
172
|
+
console.log(
|
|
173
|
+
`Provider refresh complete: ${providers.length} total, ${existingDisabled.size} disabled`,
|
|
174
|
+
);
|
|
175
|
+
};
|
|
176
|
+
|
|
123
177
|
return {
|
|
124
178
|
ensureProvidersBootstrapped,
|
|
125
179
|
getRoutstr21Models,
|
|
126
180
|
getModelProviders,
|
|
181
|
+
refreshProvidersAndModels,
|
|
127
182
|
};
|
|
128
183
|
}
|
|
@@ -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/daemon/types.ts
CHANGED
|
@@ -11,6 +11,12 @@ export type UsageData = {
|
|
|
11
11
|
totalTokens: number;
|
|
12
12
|
cost: number;
|
|
13
13
|
satsCost: number;
|
|
14
|
+
provider?: string;
|
|
15
|
+
inputMsats?: number;
|
|
16
|
+
outputMsats?: number;
|
|
17
|
+
totalMsats?: number;
|
|
18
|
+
cacheReadInputTokens?: number;
|
|
19
|
+
cacheCreationInputTokens?: number;
|
|
14
20
|
};
|
|
15
21
|
|
|
16
22
|
export type UsageTrackingEntry = UsageData & {
|
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,35 @@ 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
|
-
return new Date(timestamp).toISOString().split("T")[0] ?? "";
|
|
122
|
-
}
|
|
123
|
-
|
|
124
104
|
export function formatTime(timestamp: number): string {
|
|
125
|
-
|
|
105
|
+
const d = new Date(timestamp);
|
|
106
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
107
|
+
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
126
108
|
}
|
|
127
109
|
|
|
128
110
|
export function formatNumber(n: number): string {
|
|
@@ -131,100 +113,6 @@ export function formatNumber(n: number): string {
|
|
|
131
113
|
return n.toString();
|
|
132
114
|
}
|
|
133
115
|
|
|
134
|
-
export function getDayStats(entries: UsageTrackingEntry[]): Map<string, DayStats> {
|
|
135
|
-
const days = new Map<string, DayStats>();
|
|
136
|
-
for (const entry of entries) {
|
|
137
|
-
const date = formatDate(entry.timestamp);
|
|
138
|
-
const existing = days.get(date) || { date, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
139
|
-
days.set(date, {
|
|
140
|
-
...existing,
|
|
141
|
-
requests: existing.requests + 1,
|
|
142
|
-
satsCost: existing.satsCost + entry.satsCost,
|
|
143
|
-
promptTokens: existing.promptTokens + entry.promptTokens,
|
|
144
|
-
completionTokens: existing.completionTokens + entry.completionTokens,
|
|
145
|
-
totalTokens: existing.totalTokens + entry.totalTokens,
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
return days;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
export function getHourlyToday(entries: UsageTrackingEntry[]): Map<number, DayStats> {
|
|
152
|
-
const todayStart = getTodayStart();
|
|
153
|
-
const hours = new Map<number, DayStats>();
|
|
154
|
-
for (const entry of entries) {
|
|
155
|
-
if (entry.timestamp < todayStart) continue;
|
|
156
|
-
const hour = new Date(entry.timestamp).getHours();
|
|
157
|
-
const existing = hours.get(hour) || {
|
|
158
|
-
date: formatDate(entry.timestamp), requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
|
|
159
|
-
};
|
|
160
|
-
hours.set(hour, {
|
|
161
|
-
...existing,
|
|
162
|
-
requests: existing.requests + 1,
|
|
163
|
-
satsCost: existing.satsCost + entry.satsCost,
|
|
164
|
-
promptTokens: existing.promptTokens + entry.promptTokens,
|
|
165
|
-
completionTokens: existing.completionTokens + entry.completionTokens,
|
|
166
|
-
totalTokens: existing.totalTokens + entry.totalTokens,
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
return hours;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
export function getModelStats(entries: UsageTrackingEntry[]): ModelStats[] {
|
|
173
|
-
const models = new Map<string, ModelStats>();
|
|
174
|
-
for (const entry of entries) {
|
|
175
|
-
const existing = models.get(entry.modelId) || {
|
|
176
|
-
modelId: entry.modelId, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
|
|
177
|
-
};
|
|
178
|
-
models.set(entry.modelId, {
|
|
179
|
-
...existing,
|
|
180
|
-
requests: existing.requests + 1,
|
|
181
|
-
satsCost: existing.satsCost + entry.satsCost,
|
|
182
|
-
promptTokens: existing.promptTokens + entry.promptTokens,
|
|
183
|
-
completionTokens: existing.completionTokens + entry.completionTokens,
|
|
184
|
-
totalTokens: existing.totalTokens + entry.totalTokens,
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
return Array.from(models.values()).sort((a, b) => b.satsCost - a.satsCost);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
export function getProviderStats(entries: UsageTrackingEntry[]): ProviderStats[] {
|
|
191
|
-
const providers = new Map<string, ProviderStats>();
|
|
192
|
-
for (const entry of entries) {
|
|
193
|
-
const url = entry.baseUrl || "unknown";
|
|
194
|
-
const existing = providers.get(url) || {
|
|
195
|
-
baseUrl: url, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
|
|
196
|
-
};
|
|
197
|
-
providers.set(url, {
|
|
198
|
-
...existing,
|
|
199
|
-
requests: existing.requests + 1,
|
|
200
|
-
satsCost: existing.satsCost + entry.satsCost,
|
|
201
|
-
promptTokens: existing.promptTokens + entry.promptTokens,
|
|
202
|
-
completionTokens: existing.completionTokens + entry.completionTokens,
|
|
203
|
-
totalTokens: existing.totalTokens + entry.totalTokens,
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
return Array.from(providers.values()).sort((a, b) => b.satsCost - a.satsCost);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
export function getClientStats(entries: UsageTrackingEntry[]): ClientStats[] {
|
|
210
|
-
const clients = new Map<string, ClientStats>();
|
|
211
|
-
for (const entry of entries) {
|
|
212
|
-
const client = entry.client || "unknown";
|
|
213
|
-
const existing = clients.get(client) || {
|
|
214
|
-
client, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
|
|
215
|
-
};
|
|
216
|
-
clients.set(client, {
|
|
217
|
-
...existing,
|
|
218
|
-
requests: existing.requests + 1,
|
|
219
|
-
satsCost: existing.satsCost + entry.satsCost,
|
|
220
|
-
promptTokens: existing.promptTokens + entry.promptTokens,
|
|
221
|
-
completionTokens: existing.completionTokens + entry.completionTokens,
|
|
222
|
-
totalTokens: existing.totalTokens + entry.totalTokens,
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
return Array.from(clients.values()).sort((a, b) => b.satsCost - a.satsCost);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
116
|
// ─── Client / Npub helpers ────────────────────────────────────────────
|
|
229
117
|
|
|
230
118
|
export interface ClientInfo {
|
|
@@ -258,56 +146,3 @@ export async function fetchClients(): Promise<ClientInfo[]> {
|
|
|
258
146
|
export function hasAnyNpubs(clients: ClientInfo[]): boolean {
|
|
259
147
|
return clients.some((c) => !!c.ownerNpub);
|
|
260
148
|
}
|
|
261
|
-
|
|
262
|
-
export function getNpubStats(entries: UsageTrackingEntry[], clients: ClientInfo[]): NpubStats[] {
|
|
263
|
-
// Build client-id → ownerNpub lookup
|
|
264
|
-
const clientNpubMap = new Map<string, string>();
|
|
265
|
-
for (const c of clients) {
|
|
266
|
-
if (c.ownerNpub) {
|
|
267
|
-
clientNpubMap.set(c.clientId, c.ownerNpub);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// Aggregate usage per npub
|
|
272
|
-
const npubs = new Map<string, NpubStats>();
|
|
273
|
-
for (const entry of entries) {
|
|
274
|
-
const npub = clientNpubMap.get(entry.client || "");
|
|
275
|
-
if (!npub) continue; // skip entries whose client has no ownerNpub
|
|
276
|
-
|
|
277
|
-
const existing = npubs.get(npub) || {
|
|
278
|
-
npub,
|
|
279
|
-
requests: 0,
|
|
280
|
-
satsCost: 0,
|
|
281
|
-
promptTokens: 0,
|
|
282
|
-
completionTokens: 0,
|
|
283
|
-
totalTokens: 0,
|
|
284
|
-
};
|
|
285
|
-
npubs.set(npub, {
|
|
286
|
-
...existing,
|
|
287
|
-
requests: existing.requests + 1,
|
|
288
|
-
satsCost: existing.satsCost + entry.satsCost,
|
|
289
|
-
promptTokens: existing.promptTokens + entry.promptTokens,
|
|
290
|
-
completionTokens: existing.completionTokens + entry.completionTokens,
|
|
291
|
-
totalTokens: existing.totalTokens + entry.totalTokens,
|
|
292
|
-
});
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
return Array.from(npubs.values()).sort((a, b) => b.satsCost - a.satsCost);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// ─── Totals ───────────────────────────────────────────────────────────
|
|
299
|
-
|
|
300
|
-
export function getTotals(entries: UsageTrackingEntry[] | undefined) {
|
|
301
|
-
if (!entries || !Array.isArray(entries)) {
|
|
302
|
-
return { promptTokens: 0, completionTokens: 0, totalTokens: 0, satsCost: 0 };
|
|
303
|
-
}
|
|
304
|
-
return entries.reduce(
|
|
305
|
-
(acc, entry) => ({
|
|
306
|
-
promptTokens: acc.promptTokens + entry.promptTokens,
|
|
307
|
-
completionTokens: acc.completionTokens + entry.completionTokens,
|
|
308
|
-
totalTokens: acc.totalTokens + entry.totalTokens,
|
|
309
|
-
satsCost: acc.satsCost + entry.satsCost,
|
|
310
|
-
}),
|
|
311
|
-
{ promptTokens: 0, completionTokens: 0, totalTokens: 0, satsCost: 0 }
|
|
312
|
-
);
|
|
313
|
-
}
|