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.
@@ -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
+ }
@@ -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 & {
@@ -1,6 +1,6 @@
1
1
  import { getVisibleTabs } from "./constants.ts";
2
2
  import type { Tab } from "./types.ts";
3
- import { fetchBalance, fetchClients, fetchStatus, fetchUsage, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type StatusInfo } from "./data.ts";
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 fetchUsage(10000);
92
+ stats = await fetchUsageSummary();
93
93
  balance = await fetchBalance();
94
94
  status = await fetchStatus();
95
95
  clients = await fetchClients();
@@ -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
 
@@ -76,53 +76,35 @@ export async function fetchBalance(): Promise<BalanceInfo | null> {
76
76
  }
77
77
  }
78
78
 
79
- export async function fetchUsage(limit = 10000): Promise<UsageStats | null> {
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 result = await callDaemon(`/usage?limit=${limit}`);
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
- // 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
- );
88
+ const summary = result.output as UsageSummary | undefined;
89
+ if (!summary || typeof summary.totals !== "object") return null;
102
90
 
103
91
  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,
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
- return new Date(timestamp).toISOString().split("T")[1]?.slice(0, 8) ?? "";
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
- }