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.
@@ -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
+ }