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,259 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { createMemoryUsageTrackingDriver } from "@routstr/sdk/storage";
|
|
3
|
+
import type { UsageTrackingEntry } from "@routstr/sdk/storage";
|
|
4
|
+
import type { ClientEntry } from "../../utils/clients";
|
|
5
|
+
import { getUsageSummary, __resetUsageSummaryCacheForTest } from "./usage-summary";
|
|
6
|
+
|
|
7
|
+
// ─── Test fixtures ────────────────────────────────────────────────────────────
|
|
8
|
+
//
|
|
9
|
+
// All timestamps fall within 30 days of 2026-06-02 (today as of this writing).
|
|
10
|
+
// tz = 300 (UTC-5 / EST). After shifting by -300min, UTC dates become:
|
|
11
|
+
// d1: 2026-05-20T10:00Z → shifted 2026-05-20T05:00Z → local day "2026-05-20", hour 05
|
|
12
|
+
// d2: 2026-05-21T03:00Z → shifted 2026-05-20T22:00Z → local day "2026-05-20", hour 22
|
|
13
|
+
// d3: 2026-05-21T06:00Z → shifted 2026-05-21T01:00Z → local day "2026-05-21", hour 01
|
|
14
|
+
|
|
15
|
+
const D1 = new Date("2026-05-20T10:00:00Z").getTime(); // local 2026-05-20, hour 05
|
|
16
|
+
const D2 = new Date("2026-05-21T03:00:00Z").getTime(); // local 2026-05-20, hour 22
|
|
17
|
+
const D3 = new Date("2026-05-21T06:00:00Z").getTime(); // local 2026-05-21, hour 01
|
|
18
|
+
|
|
19
|
+
const BASE_ENTRY: Omit<UsageTrackingEntry, "id" | "timestamp" | "modelId" | "client" | "totalTokens"> = {
|
|
20
|
+
baseUrl: "https://api.openai.com/",
|
|
21
|
+
requestId: "req-1",
|
|
22
|
+
cost: 0.01,
|
|
23
|
+
satsCost: 10,
|
|
24
|
+
promptTokens: 100,
|
|
25
|
+
completionTokens: 50,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function makeEntry(
|
|
29
|
+
id: string,
|
|
30
|
+
overrides: Partial<UsageTrackingEntry> & Pick<UsageTrackingEntry, "id" | "timestamp" | "modelId" | "totalTokens">,
|
|
31
|
+
): UsageTrackingEntry {
|
|
32
|
+
return {
|
|
33
|
+
...BASE_ENTRY,
|
|
34
|
+
...overrides,
|
|
35
|
+
id,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Seed entries:
|
|
40
|
+
// - 2 entries for model-a, client-1 (owned by npub1)
|
|
41
|
+
// - 1 entry for model-b, client-2 (no npub)
|
|
42
|
+
// - 1 entry for model-a, no client (null client)
|
|
43
|
+
// - totalTokens spread across tiny (< 1000) and small (1000-10000) buckets
|
|
44
|
+
|
|
45
|
+
const ENTRIES: UsageTrackingEntry[] = [
|
|
46
|
+
// model-a, client-1, day 2026-05-20 (D1), totalTokens=500 (tiny)
|
|
47
|
+
makeEntry("e1", {
|
|
48
|
+
id: "e1",
|
|
49
|
+
timestamp: D1,
|
|
50
|
+
modelId: "model-a",
|
|
51
|
+
client: "client-1",
|
|
52
|
+
totalTokens: 500,
|
|
53
|
+
promptTokens: 350,
|
|
54
|
+
completionTokens: 150,
|
|
55
|
+
satsCost: 10,
|
|
56
|
+
cost: 0.01,
|
|
57
|
+
}),
|
|
58
|
+
// model-a, client-1, day 2026-05-20 (D2), totalTokens=2000 (small)
|
|
59
|
+
makeEntry("e2", {
|
|
60
|
+
id: "e2",
|
|
61
|
+
timestamp: D2,
|
|
62
|
+
modelId: "model-a",
|
|
63
|
+
client: "client-1",
|
|
64
|
+
totalTokens: 2000,
|
|
65
|
+
promptTokens: 1400,
|
|
66
|
+
completionTokens: 600,
|
|
67
|
+
satsCost: 20,
|
|
68
|
+
cost: 0.02,
|
|
69
|
+
}),
|
|
70
|
+
// model-b, client-2, day 2026-05-21 (D3), totalTokens=800 (tiny)
|
|
71
|
+
makeEntry("e3", {
|
|
72
|
+
id: "e3",
|
|
73
|
+
timestamp: D3,
|
|
74
|
+
modelId: "model-b",
|
|
75
|
+
client: "client-2",
|
|
76
|
+
totalTokens: 800,
|
|
77
|
+
promptTokens: 600,
|
|
78
|
+
completionTokens: 200,
|
|
79
|
+
satsCost: 8,
|
|
80
|
+
cost: 0.008,
|
|
81
|
+
}),
|
|
82
|
+
// model-a, no client, day 2026-05-21 (D3), totalTokens=5000 (small)
|
|
83
|
+
makeEntry("e4", {
|
|
84
|
+
id: "e4",
|
|
85
|
+
timestamp: D3 + 1000,
|
|
86
|
+
modelId: "model-a",
|
|
87
|
+
client: undefined,
|
|
88
|
+
totalTokens: 5000,
|
|
89
|
+
promptTokens: 3500,
|
|
90
|
+
completionTokens: 1500,
|
|
91
|
+
satsCost: 50,
|
|
92
|
+
cost: 0.05,
|
|
93
|
+
}),
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
const CLIENTS: ClientEntry[] = [
|
|
97
|
+
{
|
|
98
|
+
clientId: "client-1",
|
|
99
|
+
name: "Client One",
|
|
100
|
+
apiKey: "sk-client1",
|
|
101
|
+
createdAt: D1 - 86400000,
|
|
102
|
+
ownerNpub: "npub1abc",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
clientId: "client-2",
|
|
106
|
+
name: "Client Two",
|
|
107
|
+
apiKey: "sk-client2",
|
|
108
|
+
createdAt: D1 - 86400000,
|
|
109
|
+
// no ownerNpub
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
const TZ = 300; // UTC-5 / EST
|
|
114
|
+
|
|
115
|
+
describe("getUsageSummary", () => {
|
|
116
|
+
let driver: ReturnType<typeof createMemoryUsageTrackingDriver>;
|
|
117
|
+
|
|
118
|
+
beforeEach(async () => {
|
|
119
|
+
// Reset the module-level memo cache explicitly so each test starts cold.
|
|
120
|
+
// Without this, tests 2-10 would share the cached object from test 1
|
|
121
|
+
// (all use the same fixtures → identical cache key → same TTL window).
|
|
122
|
+
__resetUsageSummaryCacheForTest();
|
|
123
|
+
driver = createMemoryUsageTrackingDriver(ENTRIES);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("returns correct totals", async () => {
|
|
127
|
+
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
|
128
|
+
|
|
129
|
+
expect(summary.totals.requests).toBe(4);
|
|
130
|
+
expect(summary.totals.totalTokens).toBe(500 + 2000 + 800 + 5000);
|
|
131
|
+
expect(summary.totals.promptTokens).toBe(350 + 1400 + 600 + 3500);
|
|
132
|
+
expect(summary.totals.completionTokens).toBe(150 + 600 + 200 + 1500);
|
|
133
|
+
expect(summary.totals.satsCost).toBe(10 + 20 + 8 + 50);
|
|
134
|
+
expect(summary.totals.cost).toBeCloseTo(0.01 + 0.02 + 0.008 + 0.05, 5);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("returns models sorted desc by satsCost", async () => {
|
|
138
|
+
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
|
139
|
+
const { models } = summary;
|
|
140
|
+
|
|
141
|
+
// model-a: 3 entries (e1, e2, e4) total satsCost = 10+20+50 = 80
|
|
142
|
+
// model-b: 1 entry (e3) satsCost = 8
|
|
143
|
+
expect(models).toHaveLength(2);
|
|
144
|
+
expect(models[0]!.modelId).toBe("model-a");
|
|
145
|
+
expect(models[0]!.satsCost).toBe(80);
|
|
146
|
+
expect(models[0]!.requests).toBe(3);
|
|
147
|
+
expect(models[1]!.modelId).toBe("model-b");
|
|
148
|
+
expect(models[1]!.satsCost).toBe(8);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("returns days most-recent-first with correct tz-bucketing", async () => {
|
|
152
|
+
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
|
153
|
+
const { days } = summary;
|
|
154
|
+
|
|
155
|
+
// D1 (10:00Z) → local 2026-05-20, D2 (03:00Z) → local 2026-05-20, D3 (06:00Z) → local 2026-05-21
|
|
156
|
+
// So: day 2026-05-20 has e1+e2, day 2026-05-21 has e3+e4
|
|
157
|
+
expect(days.length).toBeGreaterThanOrEqual(2);
|
|
158
|
+
|
|
159
|
+
// Most-recent-first: 2026-05-21 first
|
|
160
|
+
expect(days[0]!.date).toBe("2026-05-21");
|
|
161
|
+
expect(days[0]!.requests).toBe(2); // e3, e4
|
|
162
|
+
expect(days[0]!.satsCost).toBe(8 + 50);
|
|
163
|
+
|
|
164
|
+
expect(days[1]!.date).toBe("2026-05-20");
|
|
165
|
+
expect(days[1]!.requests).toBe(2); // e1, e2
|
|
166
|
+
expect(days[1]!.satsCost).toBe(10 + 20);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("returns empty hoursToday (test entries are not today)", async () => {
|
|
170
|
+
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
|
171
|
+
// Test entries are in May 2026, not today (June 2026)
|
|
172
|
+
expect(summary.hoursToday).toHaveLength(0);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("returns correct sizeBuckets", async () => {
|
|
176
|
+
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
|
177
|
+
const { sizeBuckets } = summary;
|
|
178
|
+
|
|
179
|
+
// tiny [0, 1000): e1 (500) + e3 (800) = 2 entries
|
|
180
|
+
expect(sizeBuckets.tiny.count).toBe(2);
|
|
181
|
+
expect(sizeBuckets.tiny.cost).toBe(10 + 8);
|
|
182
|
+
|
|
183
|
+
// small [1000, 10000): e2 (2000) + e4 (5000) = 2 entries
|
|
184
|
+
expect(sizeBuckets.small.count).toBe(2);
|
|
185
|
+
expect(sizeBuckets.small.cost).toBe(20 + 50);
|
|
186
|
+
|
|
187
|
+
// medium, large, huge should be 0
|
|
188
|
+
expect(sizeBuckets.medium.count).toBe(0);
|
|
189
|
+
expect(sizeBuckets.large.count).toBe(0);
|
|
190
|
+
expect(sizeBuckets.huge.count).toBe(0);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("returns per-client topModels for top non-null clients", async () => {
|
|
194
|
+
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
|
195
|
+
const { clients } = summary;
|
|
196
|
+
|
|
197
|
+
// client-1: e1 + e2 (model-a), satsCost = 30
|
|
198
|
+
// client-2: e3 (model-b), satsCost = 8
|
|
199
|
+
// unknown (null client): e4 (model-a), satsCost = 50
|
|
200
|
+
// Sorted desc by satsCost: unknown (50), client-1 (30), client-2 (8)
|
|
201
|
+
|
|
202
|
+
const c1 = clients.find((c) => c.client === "client-1");
|
|
203
|
+
expect(c1).toBeDefined();
|
|
204
|
+
expect(c1!.requests).toBe(2);
|
|
205
|
+
expect(c1!.satsCost).toBe(30);
|
|
206
|
+
// client-1 is in top 3 non-null clients, should have topModels
|
|
207
|
+
expect(c1!.topModels.length).toBeGreaterThan(0);
|
|
208
|
+
expect(c1!.topModels[0]!.modelId).toBe("model-a");
|
|
209
|
+
|
|
210
|
+
const c2 = clients.find((c) => c.client === "client-2");
|
|
211
|
+
expect(c2).toBeDefined();
|
|
212
|
+
expect(c2!.requests).toBe(1);
|
|
213
|
+
expect(c2!.satsCost).toBe(8);
|
|
214
|
+
|
|
215
|
+
// unknown client (null group) should have empty topModels
|
|
216
|
+
const unknown = clients.find((c) => c.client === "unknown");
|
|
217
|
+
expect(unknown).toBeDefined();
|
|
218
|
+
expect(unknown!.topModels).toHaveLength(0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("folds client rows into npubs correctly", async () => {
|
|
222
|
+
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
|
223
|
+
const { npubs } = summary;
|
|
224
|
+
|
|
225
|
+
// Only client-1 has ownerNpub = "npub1abc"
|
|
226
|
+
// npub1abc: e1 + e2 = requests 2, satsCost 30
|
|
227
|
+
expect(npubs).toHaveLength(1);
|
|
228
|
+
expect(npubs[0]!.npub).toBe("npub1abc");
|
|
229
|
+
expect(npubs[0]!.requests).toBe(2);
|
|
230
|
+
expect(npubs[0]!.satsCost).toBe(30);
|
|
231
|
+
// Top models for npub1abc (clients: ["client-1"]): only model-a
|
|
232
|
+
expect(npubs[0]!.topModels.length).toBeGreaterThan(0);
|
|
233
|
+
expect(npubs[0]!.topModels[0]!.modelId).toBe("model-a");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("returns recent entries (up to 50)", async () => {
|
|
237
|
+
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
|
238
|
+
expect(summary.recent).toHaveLength(4); // only 4 entries in driver
|
|
239
|
+
// Should be sorted desc by timestamp
|
|
240
|
+
expect(summary.recent[0]!.timestamp).toBeGreaterThanOrEqual(summary.recent[1]!.timestamp);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("returns providers correctly", async () => {
|
|
244
|
+
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
|
245
|
+
const { providers } = summary;
|
|
246
|
+
|
|
247
|
+
// All entries use the same baseUrl
|
|
248
|
+
expect(providers).toHaveLength(1);
|
|
249
|
+
expect(providers[0]!.baseUrl).toBe("https://api.openai.com/");
|
|
250
|
+
expect(providers[0]!.requests).toBe(4);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("caches results for same key within TTL", async () => {
|
|
254
|
+
const summary1 = await getUsageSummary(driver, CLIENTS, TZ);
|
|
255
|
+
const summary2 = await getUsageSummary(driver, CLIENTS, TZ);
|
|
256
|
+
// Same object reference means it was served from cache
|
|
257
|
+
expect(summary1).toBe(summary2);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
UsageAggregateRow,
|
|
3
|
+
UsageTrackingDriver,
|
|
4
|
+
UsageTrackingEntry,
|
|
5
|
+
} from "@routstr/sdk/storage";
|
|
6
|
+
import type { ClientEntry } from "../../utils/clients";
|
|
7
|
+
|
|
8
|
+
// ─── Public shape ────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface StatRow {
|
|
11
|
+
requests: number;
|
|
12
|
+
promptTokens: number;
|
|
13
|
+
completionTokens: number;
|
|
14
|
+
totalTokens: number;
|
|
15
|
+
cost: number;
|
|
16
|
+
satsCost: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ModelSummary extends StatRow {
|
|
20
|
+
modelId: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ProviderSummary extends StatRow {
|
|
24
|
+
baseUrl: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TopModel {
|
|
28
|
+
modelId: string;
|
|
29
|
+
requests: number;
|
|
30
|
+
satsCost: number;
|
|
31
|
+
totalTokens: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ClientSummary extends StatRow {
|
|
35
|
+
client: string;
|
|
36
|
+
topModels: TopModel[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface NpubSummary extends StatRow {
|
|
40
|
+
npub: string;
|
|
41
|
+
topModels: TopModel[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface DaySummary extends StatRow {
|
|
45
|
+
date: string; // "YYYY-MM-DD"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface HourSummary extends StatRow {
|
|
49
|
+
hour: number; // 0..23
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SizeBucket {
|
|
53
|
+
count: number;
|
|
54
|
+
cost: number; // summed satsCost
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface UsageSummary {
|
|
58
|
+
generatedAt: number;
|
|
59
|
+
totals: StatRow;
|
|
60
|
+
models: ModelSummary[];
|
|
61
|
+
providers: ProviderSummary[];
|
|
62
|
+
clients: ClientSummary[];
|
|
63
|
+
npubs: NpubSummary[];
|
|
64
|
+
days: DaySummary[];
|
|
65
|
+
hoursToday: HourSummary[];
|
|
66
|
+
sizeBuckets: {
|
|
67
|
+
tiny: SizeBucket;
|
|
68
|
+
small: SizeBucket;
|
|
69
|
+
medium: SizeBucket;
|
|
70
|
+
large: SizeBucket;
|
|
71
|
+
huge: SizeBucket;
|
|
72
|
+
};
|
|
73
|
+
recent: UsageTrackingEntry[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
function rowToStat(r: UsageAggregateRow): StatRow {
|
|
79
|
+
return {
|
|
80
|
+
requests: r.requests,
|
|
81
|
+
promptTokens: r.promptTokens,
|
|
82
|
+
completionTokens: r.completionTokens,
|
|
83
|
+
totalTokens: r.totalTokens,
|
|
84
|
+
cost: r.cost,
|
|
85
|
+
satsCost: r.satsCost,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function rowToTopModel(r: UsageAggregateRow): TopModel {
|
|
90
|
+
return {
|
|
91
|
+
modelId: r.group ?? "unknown",
|
|
92
|
+
requests: r.requests,
|
|
93
|
+
satsCost: r.satsCost,
|
|
94
|
+
totalTokens: r.totalTokens,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function emptyBucket(): SizeBucket {
|
|
99
|
+
return { count: 0, cost: 0 };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** [minInclusive, maxExclusive) token bounds for each size bucket. */
|
|
103
|
+
const SIZE_BUCKET_BOUNDS = {
|
|
104
|
+
tiny: [0, 1000],
|
|
105
|
+
small: [1000, 10000],
|
|
106
|
+
medium: [10000, 50000],
|
|
107
|
+
large: [50000, 100000],
|
|
108
|
+
huge: [100000, Infinity],
|
|
109
|
+
} as const;
|
|
110
|
+
|
|
111
|
+
function computeSizeBuckets(
|
|
112
|
+
entries: UsageTrackingEntry[],
|
|
113
|
+
): UsageSummary["sizeBuckets"] {
|
|
114
|
+
const buckets = {
|
|
115
|
+
tiny: emptyBucket(),
|
|
116
|
+
small: emptyBucket(),
|
|
117
|
+
medium: emptyBucket(),
|
|
118
|
+
large: emptyBucket(),
|
|
119
|
+
huge: emptyBucket(),
|
|
120
|
+
};
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
for (const [name, [min, max]] of Object.entries(SIZE_BUCKET_BOUNDS)) {
|
|
123
|
+
if (entry.totalTokens >= min && entry.totalTokens < max) {
|
|
124
|
+
const bucket = buckets[name as keyof typeof buckets];
|
|
125
|
+
bucket.count++;
|
|
126
|
+
bucket.cost += entry.satsCost;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return buckets;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Returns the UTC ms for the start of the local day containing `now`. */
|
|
135
|
+
function startOfLocalDayUtc(now: number, tzOffsetMinutes: number): number {
|
|
136
|
+
return (
|
|
137
|
+
Math.floor((now - tzOffsetMinutes * 60000) / 86400000) * 86400000 +
|
|
138
|
+
tzOffsetMinutes * 60000
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── Module-level memo cache ─────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
interface CacheEntry {
|
|
145
|
+
key: string;
|
|
146
|
+
summary: UsageSummary;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let _cache: CacheEntry | null = null;
|
|
150
|
+
const CACHE_TTL_MS = 60_000;
|
|
151
|
+
|
|
152
|
+
/** Clears the module-level memo cache. Intended for use in unit tests only. */
|
|
153
|
+
export function __resetUsageSummaryCacheForTest(): void {
|
|
154
|
+
_cache = null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ─── Main builder ─────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
export async function getUsageSummary(
|
|
160
|
+
driver: UsageTrackingDriver,
|
|
161
|
+
clients: ClientEntry[],
|
|
162
|
+
tzOffsetMinutes: number,
|
|
163
|
+
/** If set, only include usage for these client IDs (e.g. from `?npub=` filtering). */
|
|
164
|
+
clientFilter?: string[],
|
|
165
|
+
): Promise<UsageSummary> {
|
|
166
|
+
// Cache key: total row count + per-client identity + filter + tz.
|
|
167
|
+
const count = await driver.count(clientFilter ? { clients: clientFilter } : {});
|
|
168
|
+
const clientIdentity = clients.map((c) => `${c.clientId}:${c.ownerNpub ?? ""}`).join(",");
|
|
169
|
+
const filterKey = clientFilter ? `:f:${clientFilter.sort().join(",")}` : "";
|
|
170
|
+
const cacheKey = `${count}:${clientIdentity}:${tzOffsetMinutes}${filterKey}`;
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
|
|
173
|
+
if (
|
|
174
|
+
_cache !== null &&
|
|
175
|
+
_cache.key === cacheKey &&
|
|
176
|
+
now - _cache.summary.generatedAt <= CACHE_TTL_MS
|
|
177
|
+
) {
|
|
178
|
+
return _cache.summary;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Base filter applied to every aggregate/list/count call
|
|
182
|
+
const baseFilter = clientFilter ? { clients: clientFilter } as const : {};
|
|
183
|
+
|
|
184
|
+
// Short-circuit: if the filter yields no rows, return zeroed summary
|
|
185
|
+
if (count === 0) {
|
|
186
|
+
const zeroStat: StatRow = {
|
|
187
|
+
requests: 0, promptTokens: 0, completionTokens: 0,
|
|
188
|
+
totalTokens: 0, cost: 0, satsCost: 0,
|
|
189
|
+
};
|
|
190
|
+
const zeroSummary: UsageSummary = {
|
|
191
|
+
generatedAt: now,
|
|
192
|
+
totals: zeroStat,
|
|
193
|
+
models: [],
|
|
194
|
+
providers: [],
|
|
195
|
+
clients: [],
|
|
196
|
+
npubs: [],
|
|
197
|
+
days: [],
|
|
198
|
+
hoursToday: [],
|
|
199
|
+
sizeBuckets: {
|
|
200
|
+
tiny: emptyBucket(), small: emptyBucket(), medium: emptyBucket(),
|
|
201
|
+
large: emptyBucket(), huge: emptyBucket(),
|
|
202
|
+
},
|
|
203
|
+
recent: [],
|
|
204
|
+
};
|
|
205
|
+
_cache = { key: cacheKey, summary: zeroSummary };
|
|
206
|
+
return zeroSummary;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Totals ─────────────────────────────────────────────────────────────────
|
|
210
|
+
const [totalsRow] = await driver.aggregate({ ...baseFilter });
|
|
211
|
+
const totals: StatRow = totalsRow ? rowToStat(totalsRow) : {
|
|
212
|
+
requests: 0, promptTokens: 0, completionTokens: 0,
|
|
213
|
+
totalTokens: 0, cost: 0, satsCost: 0,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// ── Models ─────────────────────────────────────────────────────────────────
|
|
217
|
+
const modelRows = await driver.aggregate({ ...baseFilter, groupBy: "modelId" });
|
|
218
|
+
const models: ModelSummary[] = modelRows.map((r) => ({
|
|
219
|
+
modelId: r.group ?? "unknown",
|
|
220
|
+
...rowToStat(r),
|
|
221
|
+
}));
|
|
222
|
+
|
|
223
|
+
// ── Providers ──────────────────────────────────────────────────────────────
|
|
224
|
+
const providerRows = await driver.aggregate({ ...baseFilter, groupBy: "baseUrl" });
|
|
225
|
+
const providers: ProviderSummary[] = providerRows.map((r) => ({
|
|
226
|
+
baseUrl: r.group ?? "unknown",
|
|
227
|
+
...rowToStat(r),
|
|
228
|
+
}));
|
|
229
|
+
|
|
230
|
+
// ── Clients ────────────────────────────────────────────────────────────────
|
|
231
|
+
const clientRows = await driver.aggregate({ ...baseFilter, groupBy: "client" });
|
|
232
|
+
const clientSummaries: ClientSummary[] = clientRows.map((r) => ({
|
|
233
|
+
client: r.group ?? "unknown",
|
|
234
|
+
...rowToStat(r),
|
|
235
|
+
topModels: [],
|
|
236
|
+
}));
|
|
237
|
+
|
|
238
|
+
// Fill topModels for the top 3 non-null client rows
|
|
239
|
+
const topClientRows = clientRows
|
|
240
|
+
.filter((r) => r.group !== null)
|
|
241
|
+
.slice(0, 3);
|
|
242
|
+
for (let i = 0; i < topClientRows.length; i++) {
|
|
243
|
+
const clientId = topClientRows[i]!.group!;
|
|
244
|
+
const topModelRows = await driver.aggregate({
|
|
245
|
+
...baseFilter,
|
|
246
|
+
groupBy: "modelId",
|
|
247
|
+
client: clientId,
|
|
248
|
+
});
|
|
249
|
+
// Find matching ClientSummary and set topModels
|
|
250
|
+
const summary = clientSummaries.find((c) => c.client === clientId);
|
|
251
|
+
if (summary) {
|
|
252
|
+
summary.topModels = topModelRows.slice(0, 5).map(rowToTopModel);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Npubs ──────────────────────────────────────────────────────────────────
|
|
257
|
+
// Build clientId → ownerNpub lookup (only clients with ownerNpub)
|
|
258
|
+
const clientToNpub = new Map<string, string>();
|
|
259
|
+
for (const c of clients) {
|
|
260
|
+
if (c.ownerNpub) {
|
|
261
|
+
clientToNpub.set(c.clientId, c.ownerNpub);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Fold client rows into per-npub sums
|
|
266
|
+
const npubStats = new Map<string, StatRow>();
|
|
267
|
+
const npubClientIds = new Map<string, string[]>();
|
|
268
|
+
for (const r of clientRows) {
|
|
269
|
+
if (r.group === null) continue;
|
|
270
|
+
const npub = clientToNpub.get(r.group);
|
|
271
|
+
if (!npub) continue;
|
|
272
|
+
const existing = npubStats.get(npub);
|
|
273
|
+
if (existing) {
|
|
274
|
+
existing.requests += r.requests;
|
|
275
|
+
existing.promptTokens += r.promptTokens;
|
|
276
|
+
existing.completionTokens += r.completionTokens;
|
|
277
|
+
existing.totalTokens += r.totalTokens;
|
|
278
|
+
existing.cost += r.cost;
|
|
279
|
+
existing.satsCost += r.satsCost;
|
|
280
|
+
} else {
|
|
281
|
+
npubStats.set(npub, { ...rowToStat(r) });
|
|
282
|
+
}
|
|
283
|
+
const ids = npubClientIds.get(npub) ?? [];
|
|
284
|
+
ids.push(r.group);
|
|
285
|
+
npubClientIds.set(npub, ids);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Sort npubs desc by satsCost
|
|
289
|
+
const sortedNpubs = [...npubStats.entries()].sort(
|
|
290
|
+
(a, b) => b[1].satsCost - a[1].satsCost,
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
const npubs: NpubSummary[] = sortedNpubs.map(([npub, stat]) => ({
|
|
294
|
+
npub,
|
|
295
|
+
...stat,
|
|
296
|
+
topModels: [],
|
|
297
|
+
}));
|
|
298
|
+
|
|
299
|
+
// Fill topModels for top 5 npubs
|
|
300
|
+
for (let i = 0; i < Math.min(5, npubs.length); i++) {
|
|
301
|
+
const npubSummary = npubs[i]!;
|
|
302
|
+
const ids = npubClientIds.get(npubSummary.npub) ?? [];
|
|
303
|
+
if (ids.length > 0) {
|
|
304
|
+
const topModelRows = await driver.aggregate({
|
|
305
|
+
groupBy: "modelId",
|
|
306
|
+
clients: ids,
|
|
307
|
+
});
|
|
308
|
+
npubSummary.topModels = topModelRows.slice(0, 5).map(rowToTopModel);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── Days (last 30, most-recent-first) ─────────────────────────────────────
|
|
313
|
+
const dayRows = await driver.aggregate({
|
|
314
|
+
...baseFilter,
|
|
315
|
+
groupBy: "day",
|
|
316
|
+
tzOffsetMinutes,
|
|
317
|
+
after: now - 30 * 86400000,
|
|
318
|
+
});
|
|
319
|
+
const days: DaySummary[] = dayRows
|
|
320
|
+
.map((r) => ({ date: r.group!, ...rowToStat(r) }))
|
|
321
|
+
.reverse(); // aggregate returns ascending; we want most-recent-first
|
|
322
|
+
|
|
323
|
+
// ── Hours today ────────────────────────────────────────────────────────────
|
|
324
|
+
const todayStartUtc = startOfLocalDayUtc(now, tzOffsetMinutes);
|
|
325
|
+
const hourRows = await driver.aggregate({
|
|
326
|
+
...baseFilter,
|
|
327
|
+
groupBy: "hour",
|
|
328
|
+
tzOffsetMinutes,
|
|
329
|
+
after: todayStartUtc - 1,
|
|
330
|
+
});
|
|
331
|
+
const hoursToday: HourSummary[] = hourRows.map((r) => ({
|
|
332
|
+
hour: Number(r.group),
|
|
333
|
+
...rowToStat(r),
|
|
334
|
+
}));
|
|
335
|
+
|
|
336
|
+
// ── Size buckets ───────────────────────────────────────────────────────────
|
|
337
|
+
// The SDK's aggregate() no longer supports token-range filters
|
|
338
|
+
// (minTotalTokens/maxTotalTokens were removed in SDK pr-8), so bucket in
|
|
339
|
+
// JS from a single list() call instead of five aggregate() queries.
|
|
340
|
+
const allEntries = await driver.list(baseFilter);
|
|
341
|
+
const sizeBuckets = computeSizeBuckets(allEntries);
|
|
342
|
+
|
|
343
|
+
// ── Recent entries ─────────────────────────────────────────────────────────
|
|
344
|
+
const recent = await driver.list({ ...baseFilter, limit: 50 });
|
|
345
|
+
|
|
346
|
+
const summary: UsageSummary = {
|
|
347
|
+
generatedAt: now,
|
|
348
|
+
totals,
|
|
349
|
+
models,
|
|
350
|
+
providers,
|
|
351
|
+
clients: clientSummaries,
|
|
352
|
+
npubs,
|
|
353
|
+
days,
|
|
354
|
+
hoursToday,
|
|
355
|
+
sizeBuckets,
|
|
356
|
+
recent,
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
_cache = { key: cacheKey, summary };
|
|
360
|
+
return summary;
|
|
361
|
+
}
|