@robhowley/pi-openrouter 0.8.3 → 0.9.1

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.
Files changed (40) hide show
  1. package/README.md +87 -4
  2. package/extensions/openrouter/__tests__/cache.test.ts +769 -0
  3. package/extensions/openrouter/__tests__/client.test.ts +333 -15
  4. package/extensions/openrouter/__tests__/commands.test.ts +816 -0
  5. package/extensions/openrouter/__tests__/fixtures.ts +140 -1
  6. package/extensions/openrouter/__tests__/format.test.ts +19 -0
  7. package/extensions/openrouter/__tests__/hooks.test.ts +276 -0
  8. package/extensions/openrouter/__tests__/index.test.ts +163 -0
  9. package/extensions/openrouter/__tests__/local-usage.test.ts +777 -0
  10. package/extensions/openrouter/__tests__/normalizers.test.ts +288 -0
  11. package/extensions/openrouter/__tests__/overlay.test.ts +225 -0
  12. package/extensions/openrouter/__tests__/session-state.test.ts +233 -0
  13. package/extensions/openrouter/__tests__/session.test.ts +44 -43
  14. package/extensions/openrouter/account-client.ts +11 -61
  15. package/extensions/openrouter/cache.ts +203 -91
  16. package/extensions/openrouter/client.ts +49 -3
  17. package/extensions/openrouter/commands.ts +555 -0
  18. package/extensions/openrouter/format.ts +7 -4
  19. package/extensions/openrouter/hooks.ts +229 -0
  20. package/extensions/openrouter/index.ts +13 -589
  21. package/extensions/openrouter/local-usage.ts +145 -22
  22. package/extensions/openrouter/models/__tests__/cache.test.ts +63 -2
  23. package/extensions/openrouter/models/__tests__/mapper-overrides.test.ts +102 -0
  24. package/extensions/openrouter/models/__tests__/mapper.test.ts +29 -0
  25. package/extensions/openrouter/models/__tests__/override-commands.test.ts +668 -0
  26. package/extensions/openrouter/models/__tests__/overrides.test.ts +237 -0
  27. package/extensions/openrouter/models/__tests__/sync.test.ts +156 -4
  28. package/extensions/openrouter/models/cache.ts +27 -2
  29. package/extensions/openrouter/models/mapper.ts +60 -77
  30. package/extensions/openrouter/models/override-commands.ts +434 -0
  31. package/extensions/openrouter/models/overrides.ts +174 -0
  32. package/extensions/openrouter/models/skip-hints.ts +19 -0
  33. package/extensions/openrouter/models/sync.ts +22 -10
  34. package/extensions/openrouter/models/types.ts +31 -1
  35. package/extensions/openrouter/normalizers.ts +128 -0
  36. package/extensions/openrouter/overlay.ts +19 -8
  37. package/extensions/openrouter/session-state.ts +110 -0
  38. package/extensions/openrouter/session.ts +16 -0
  39. package/extensions/openrouter/types.ts +28 -9
  40. package/package.json +1 -1
@@ -0,0 +1,777 @@
1
+ /**
2
+ * Tests for local usage JSONL storage and aggregation.
3
+ * Phase 6: Local JSONL hardening
4
+ */
5
+
6
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
7
+ import * as fs from 'node:fs/promises';
8
+ import * as path from 'node:path';
9
+ import * as os from 'node:os';
10
+ import {
11
+ writeLocalUsage,
12
+ readLocalUsage,
13
+ aggregateLocal,
14
+ getCurrentUtcDate,
15
+ getUtcDateFromTimestamp,
16
+ addUtcDays,
17
+ cleanupOldUsageFiles,
18
+ setLocalUsageDir,
19
+ } from '../local-usage.js';
20
+ import type { LocalUsageEvent } from '../types.js';
21
+ import { restoreEnv, setEnv, clearEnv } from './fixtures.js';
22
+
23
+ // Test directory setup
24
+ let testDir: string;
25
+
26
+ beforeEach(async () => {
27
+ // Create a unique test directory for each test
28
+ testDir = path.join(
29
+ os.tmpdir(),
30
+ `pi-openrouter-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
31
+ );
32
+ await fs.mkdir(testDir, { recursive: true });
33
+
34
+ // Tell local-usage to use our test directory
35
+ setLocalUsageDir(testDir);
36
+
37
+ // Restore environment
38
+ restoreEnv();
39
+
40
+ // Clear any debug flag
41
+ clearEnv('PI_OPENROUTER_DEBUG_USAGE');
42
+ });
43
+
44
+ afterEach(async () => {
45
+ // Reset local usage directory
46
+ setLocalUsageDir(null);
47
+
48
+ // Clean up test directory
49
+ try {
50
+ await fs.rm(testDir, { recursive: true, force: true });
51
+ } catch {
52
+ // Ignore cleanup errors
53
+ }
54
+
55
+ // Restore environment
56
+ restoreEnv();
57
+ });
58
+
59
+ // =============================================================================
60
+ // Date Utilities
61
+ // =============================================================================
62
+
63
+ describe('date utilities', () => {
64
+ it('getCurrentUtcDate returns YYYY-MM-DD format', () => {
65
+ const result = getCurrentUtcDate();
66
+ expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
67
+ });
68
+
69
+ it('getUtcDateFromTimestamp extracts date from ISO string', () => {
70
+ const timestamp = '2026-05-22T14:30:00.000Z';
71
+ const result = getUtcDateFromTimestamp(timestamp);
72
+ expect(result).toBe('2026-05-22');
73
+ });
74
+
75
+ it('addUtcDays adds positive days correctly', () => {
76
+ const result = addUtcDays('2026-05-22', 3);
77
+ expect(result).toBe('2026-05-25');
78
+ });
79
+
80
+ it('addUtcDays subtracts negative days correctly', () => {
81
+ const result = addUtcDays('2026-05-22', -5);
82
+ expect(result).toBe('2026-05-17');
83
+ });
84
+
85
+ it('addUtcDays handles month boundaries', () => {
86
+ const result = addUtcDays('2026-05-30', 5);
87
+ expect(result).toBe('2026-06-04');
88
+ });
89
+
90
+ it('addUtcDays throws on invalid date', () => {
91
+ expect(() => addUtcDays('invalid', 1)).toThrow('Invalid date string');
92
+ });
93
+ });
94
+
95
+ // =============================================================================
96
+ // Write Functionality
97
+ // =============================================================================
98
+
99
+ describe('writeLocalUsage', () => {
100
+ it('writes valid event to daily JSONL file', async () => {
101
+ const event: LocalUsageEvent = {
102
+ id: 'test-123',
103
+ generationId: 'gen-456',
104
+ sessionId: 'session-789',
105
+ completedAt: '2026-05-22T14:30:00.000Z',
106
+ requests: 1,
107
+ model: 'gpt-4',
108
+ provider: 'openai',
109
+ promptTokens: 100,
110
+ completionTokens: 50,
111
+ cost: 0.0025,
112
+ };
113
+
114
+ await writeLocalUsage(event);
115
+
116
+ // Verify file was created with correct content
117
+ const filePath = path.join(testDir, '2026-05-22.jsonl');
118
+ const content = await fs.readFile(filePath, 'utf8');
119
+
120
+ expect(content).toBe(JSON.stringify(event) + '\n');
121
+ });
122
+
123
+ it('appends multiple events to same daily file', async () => {
124
+ const event1: LocalUsageEvent = {
125
+ id: 'test-1',
126
+ generationId: 'gen-1',
127
+ sessionId: 'session-1',
128
+ completedAt: '2026-05-22T10:00:00.000Z',
129
+ cost: 0.001,
130
+ };
131
+
132
+ const event2: LocalUsageEvent = {
133
+ id: 'test-2',
134
+ generationId: 'gen-2',
135
+ sessionId: 'session-2',
136
+ completedAt: '2026-05-22T15:00:00.000Z',
137
+ cost: 0.002,
138
+ };
139
+
140
+ await writeLocalUsage(event1);
141
+ await writeLocalUsage(event2);
142
+
143
+ const filePath = path.join(testDir, '2026-05-22.jsonl');
144
+ const content = await fs.readFile(filePath, 'utf8');
145
+ const lines = content.trim().split('\n');
146
+
147
+ expect(lines).toHaveLength(2);
148
+ expect(JSON.parse(lines[0]!)).toEqual(event1);
149
+ expect(JSON.parse(lines[1]!)).toEqual(event2);
150
+ });
151
+
152
+ it('writes to different files for different UTC dates', async () => {
153
+ const event1: LocalUsageEvent = {
154
+ id: 'test-1',
155
+ generationId: 'gen-1',
156
+ sessionId: 'session-1',
157
+ completedAt: '2026-05-22T23:59:59.999Z',
158
+ cost: 0.001,
159
+ };
160
+
161
+ const event2: LocalUsageEvent = {
162
+ id: 'test-2',
163
+ generationId: 'gen-2',
164
+ sessionId: 'session-2',
165
+ completedAt: '2026-05-23T00:00:00.000Z',
166
+ cost: 0.002,
167
+ };
168
+
169
+ await writeLocalUsage(event1);
170
+ await writeLocalUsage(event2);
171
+
172
+ const file1 = await fs.readFile(path.join(testDir, '2026-05-22.jsonl'), 'utf8');
173
+ const file2 = await fs.readFile(path.join(testDir, '2026-05-23.jsonl'), 'utf8');
174
+
175
+ expect(JSON.parse(file1.trim())).toEqual(event1);
176
+ expect(JSON.parse(file2.trim())).toEqual(event2);
177
+ });
178
+
179
+ it('fails open on write error without throwing', async () => {
180
+ // Set to an invalid directory that can't be created
181
+ setLocalUsageDir('/invalid/readonly/path');
182
+
183
+ const event: LocalUsageEvent = {
184
+ id: 'test-123',
185
+ generationId: 'gen-456',
186
+ sessionId: 'session-789',
187
+ completedAt: '2026-05-22T14:30:00.000Z',
188
+ cost: 0.001,
189
+ };
190
+
191
+ // Should not throw
192
+ await expect(writeLocalUsage(event)).resolves.toBeUndefined();
193
+
194
+ // Reset for cleanup
195
+ setLocalUsageDir(testDir);
196
+ });
197
+
198
+ it('logs write errors only when debug flag is set', async () => {
199
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
200
+
201
+ // Set to an invalid directory
202
+ setLocalUsageDir('/invalid/readonly/path');
203
+
204
+ const event: LocalUsageEvent = {
205
+ id: 'test-123',
206
+ generationId: 'gen-456',
207
+ sessionId: 'session-789',
208
+ completedAt: '2026-05-22T14:30:00.000Z',
209
+ cost: 0.001,
210
+ };
211
+
212
+ // Without debug flag - should be quiet
213
+ clearEnv('PI_OPENROUTER_DEBUG_USAGE');
214
+ await writeLocalUsage(event);
215
+ expect(consoleErrorSpy).not.toHaveBeenCalled();
216
+
217
+ // With debug flag - should log
218
+ setEnv('PI_OPENROUTER_DEBUG_USAGE', '1');
219
+ await writeLocalUsage(event);
220
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
221
+ expect.stringContaining('[local-usage]'),
222
+ expect.any(Error),
223
+ );
224
+
225
+ consoleErrorSpy.mockRestore();
226
+
227
+ // Reset for cleanup
228
+ setLocalUsageDir(testDir);
229
+ });
230
+ });
231
+
232
+ // =============================================================================
233
+ // Read Functionality
234
+ // =============================================================================
235
+
236
+ describe('readLocalUsage', () => {
237
+ it('reads events from single day file', async () => {
238
+ const events: LocalUsageEvent[] = [
239
+ {
240
+ id: 'test-1',
241
+ generationId: 'gen-1',
242
+ sessionId: 'session-1',
243
+ completedAt: '2026-05-22T10:00:00.000Z',
244
+ cost: 0.001,
245
+ },
246
+ {
247
+ id: 'test-2',
248
+ generationId: 'gen-2',
249
+ sessionId: 'session-2',
250
+ completedAt: '2026-05-22T15:00:00.000Z',
251
+ cost: 0.002,
252
+ },
253
+ ];
254
+
255
+ const filePath = path.join(testDir, '2026-05-22.jsonl');
256
+ const content = events.map((e) => JSON.stringify(e)).join('\n') + '\n';
257
+ await fs.writeFile(filePath, content, 'utf8');
258
+
259
+ const result = await readLocalUsage({
260
+ fromDateUtc: '2026-05-22',
261
+ toDateUtc: '2026-05-22',
262
+ });
263
+
264
+ expect(result).toHaveLength(2);
265
+ expect(result).toEqual(events);
266
+ });
267
+
268
+ it('reads events across multiple days', async () => {
269
+ const event1: LocalUsageEvent = {
270
+ id: 'test-1',
271
+ generationId: 'gen-1',
272
+ sessionId: 'session-1',
273
+ completedAt: '2026-05-20T10:00:00.000Z',
274
+ cost: 0.001,
275
+ };
276
+
277
+ const event2: LocalUsageEvent = {
278
+ id: 'test-2',
279
+ generationId: 'gen-2',
280
+ sessionId: 'session-2',
281
+ completedAt: '2026-05-21T15:00:00.000Z',
282
+ cost: 0.002,
283
+ };
284
+
285
+ const event3: LocalUsageEvent = {
286
+ id: 'test-3',
287
+ generationId: 'gen-3',
288
+ sessionId: 'session-3',
289
+ completedAt: '2026-05-22T20:00:00.000Z',
290
+ cost: 0.003,
291
+ };
292
+
293
+ await fs.writeFile(
294
+ path.join(testDir, '2026-05-20.jsonl'),
295
+ JSON.stringify(event1) + '\n',
296
+ 'utf8',
297
+ );
298
+ await fs.writeFile(
299
+ path.join(testDir, '2026-05-21.jsonl'),
300
+ JSON.stringify(event2) + '\n',
301
+ 'utf8',
302
+ );
303
+ await fs.writeFile(
304
+ path.join(testDir, '2026-05-22.jsonl'),
305
+ JSON.stringify(event3) + '\n',
306
+ 'utf8',
307
+ );
308
+
309
+ const result = await readLocalUsage({
310
+ fromDateUtc: '2026-05-20',
311
+ toDateUtc: '2026-05-22',
312
+ });
313
+
314
+ expect(result).toHaveLength(3);
315
+ expect(result).toEqual([event1, event2, event3]);
316
+ });
317
+
318
+ it('tolerates missing files in date range', async () => {
319
+ const event1: LocalUsageEvent = {
320
+ id: 'test-1',
321
+ generationId: 'gen-1',
322
+ sessionId: 'session-1',
323
+ completedAt: '2026-05-20T10:00:00.000Z',
324
+ cost: 0.001,
325
+ };
326
+
327
+ // Only write day 20, skip day 21, will read 20-22
328
+ await fs.writeFile(
329
+ path.join(testDir, '2026-05-20.jsonl'),
330
+ JSON.stringify(event1) + '\n',
331
+ 'utf8',
332
+ );
333
+
334
+ const result = await readLocalUsage({
335
+ fromDateUtc: '2026-05-20',
336
+ toDateUtc: '2026-05-22',
337
+ });
338
+
339
+ // Should get only the event from day 20
340
+ expect(result).toHaveLength(1);
341
+ expect(result[0]).toEqual(event1);
342
+ });
343
+
344
+ it('tolerates blank lines in files', async () => {
345
+ const event: LocalUsageEvent = {
346
+ id: 'test-1',
347
+ generationId: 'gen-1',
348
+ sessionId: 'session-1',
349
+ completedAt: '2026-05-22T10:00:00.000Z',
350
+ cost: 0.001,
351
+ };
352
+
353
+ const content = `
354
+ ${JSON.stringify(event)}
355
+
356
+
357
+ `;
358
+ await fs.writeFile(path.join(testDir, '2026-05-22.jsonl'), content, 'utf8');
359
+
360
+ const result = await readLocalUsage({
361
+ fromDateUtc: '2026-05-22',
362
+ toDateUtc: '2026-05-22',
363
+ });
364
+
365
+ expect(result).toHaveLength(1);
366
+ expect(result[0]).toEqual(event);
367
+ });
368
+
369
+ it('tolerates malformed JSON lines and continues', async () => {
370
+ const validEvent: LocalUsageEvent = {
371
+ id: 'test-valid',
372
+ generationId: 'gen-1',
373
+ sessionId: 'session-1',
374
+ completedAt: '2026-05-22T10:00:00.000Z',
375
+ cost: 0.001,
376
+ };
377
+
378
+ const content = `${JSON.stringify(validEvent)}
379
+ {invalid json}
380
+ ${JSON.stringify(validEvent)}
381
+ `;
382
+ await fs.writeFile(path.join(testDir, '2026-05-22.jsonl'), content, 'utf8');
383
+
384
+ const result = await readLocalUsage({
385
+ fromDateUtc: '2026-05-22',
386
+ toDateUtc: '2026-05-22',
387
+ });
388
+
389
+ // Should get both valid events, skip malformed line
390
+ expect(result).toHaveLength(2);
391
+ expect(result[0]).toEqual(validEvent);
392
+ expect(result[1]).toEqual(validEvent);
393
+ });
394
+
395
+ it('logs malformed lines only when debug flag is set', async () => {
396
+ const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
397
+
398
+ const content = '{invalid json}\n';
399
+ await fs.writeFile(path.join(testDir, '2026-05-22.jsonl'), content, 'utf8');
400
+
401
+ // Without debug flag - should be quiet
402
+ clearEnv('PI_OPENROUTER_DEBUG_USAGE');
403
+ await readLocalUsage({
404
+ fromDateUtc: '2026-05-22',
405
+ toDateUtc: '2026-05-22',
406
+ });
407
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
408
+
409
+ // With debug flag - should log
410
+ setEnv('PI_OPENROUTER_DEBUG_USAGE', '1');
411
+ await readLocalUsage({
412
+ fromDateUtc: '2026-05-22',
413
+ toDateUtc: '2026-05-22',
414
+ });
415
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
416
+ expect.stringContaining('[local-usage]'),
417
+ expect.anything(),
418
+ );
419
+
420
+ consoleWarnSpy.mockRestore();
421
+ });
422
+
423
+ it('continues on read error for individual files', async () => {
424
+ const event1: LocalUsageEvent = {
425
+ id: 'test-1',
426
+ generationId: 'gen-1',
427
+ sessionId: 'session-1',
428
+ completedAt: '2026-05-20T10:00:00.000Z',
429
+ cost: 0.001,
430
+ };
431
+
432
+ await fs.writeFile(
433
+ path.join(testDir, '2026-05-20.jsonl'),
434
+ JSON.stringify(event1) + '\n',
435
+ 'utf8',
436
+ );
437
+ // Create an unreadable file for day 21 by creating it without read permissions
438
+ const day21Path = path.join(testDir, '2026-05-21.jsonl');
439
+ await fs.writeFile(day21Path, 'test\n', 'utf8');
440
+ try {
441
+ await fs.chmod(day21Path, 0o000);
442
+ } catch {
443
+ // If chmod fails (e.g., on some platforms), skip this test scenario
444
+ // Just verify we can read day 20
445
+ }
446
+
447
+ const result = await readLocalUsage({
448
+ fromDateUtc: '2026-05-20',
449
+ toDateUtc: '2026-05-21',
450
+ });
451
+
452
+ // Should get at least event from day 20
453
+ expect(result.length).toBeGreaterThanOrEqual(1);
454
+ expect(result[0]).toEqual(event1);
455
+
456
+ // Restore permissions for cleanup
457
+ try {
458
+ await fs.chmod(day21Path, 0o644);
459
+ } catch {
460
+ // Ignore
461
+ }
462
+ });
463
+ });
464
+
465
+ // =============================================================================
466
+ // Aggregation
467
+ // =============================================================================
468
+
469
+ describe('aggregateLocal', () => {
470
+ it('returns zero aggregate for empty array', () => {
471
+ const result = aggregateLocal([]);
472
+ expect(result).toEqual({
473
+ requests: 0,
474
+ promptTokens: 0,
475
+ completionTokens: 0,
476
+ reasoningTokens: 0,
477
+ cacheReadTokens: 0,
478
+ cacheWriteTokens: 0,
479
+ cost: 0,
480
+ });
481
+ });
482
+
483
+ it('aggregates single event', () => {
484
+ const events: LocalUsageEvent[] = [
485
+ {
486
+ id: 'test-1',
487
+ generationId: 'gen-1',
488
+ sessionId: 'session-1',
489
+ completedAt: '2026-05-22T10:00:00.000Z',
490
+ requests: 1,
491
+ promptTokens: 100,
492
+ completionTokens: 50,
493
+ reasoningTokens: 25,
494
+ cacheReadTokens: 10,
495
+ cacheWriteTokens: 5,
496
+ cost: 0.0025,
497
+ },
498
+ ];
499
+
500
+ const result = aggregateLocal(events);
501
+ expect(result).toEqual({
502
+ requests: 1,
503
+ promptTokens: 100,
504
+ completionTokens: 50,
505
+ reasoningTokens: 25,
506
+ cacheReadTokens: 10,
507
+ cacheWriteTokens: 5,
508
+ cost: 0.0025,
509
+ });
510
+ });
511
+
512
+ it('aggregates multiple events', () => {
513
+ const events: LocalUsageEvent[] = [
514
+ {
515
+ id: 'test-1',
516
+ generationId: 'gen-1',
517
+ sessionId: 'session-1',
518
+ completedAt: '2026-05-22T10:00:00.000Z',
519
+ requests: 1,
520
+ promptTokens: 100,
521
+ completionTokens: 50,
522
+ cost: 0.0025,
523
+ },
524
+ {
525
+ id: 'test-2',
526
+ generationId: 'gen-2',
527
+ sessionId: 'session-2',
528
+ completedAt: '2026-05-22T15:00:00.000Z',
529
+ requests: 1,
530
+ promptTokens: 200,
531
+ completionTokens: 100,
532
+ cost: 0.005,
533
+ },
534
+ ];
535
+
536
+ const result = aggregateLocal(events);
537
+ expect(result).toEqual({
538
+ requests: 2,
539
+ promptTokens: 300,
540
+ completionTokens: 150,
541
+ reasoningTokens: 0,
542
+ cacheReadTokens: 0,
543
+ cacheWriteTokens: 0,
544
+ cost: 0.0075,
545
+ });
546
+ });
547
+
548
+ it('deduplicates by id, keeping first occurrence', () => {
549
+ const events: LocalUsageEvent[] = [
550
+ {
551
+ id: 'duplicate-id',
552
+ generationId: 'gen-1',
553
+ sessionId: 'session-1',
554
+ completedAt: '2026-05-22T10:00:00.000Z',
555
+ requests: 1,
556
+ promptTokens: 100,
557
+ cost: 0.001,
558
+ },
559
+ {
560
+ id: 'duplicate-id', // Same ID
561
+ generationId: 'gen-2',
562
+ sessionId: 'session-2',
563
+ completedAt: '2026-05-22T11:00:00.000Z',
564
+ requests: 1,
565
+ promptTokens: 999, // Different values
566
+ cost: 0.999,
567
+ },
568
+ {
569
+ id: 'unique-id',
570
+ generationId: 'gen-3',
571
+ sessionId: 'session-3',
572
+ completedAt: '2026-05-22T12:00:00.000Z',
573
+ requests: 1,
574
+ promptTokens: 50,
575
+ cost: 0.002,
576
+ },
577
+ ];
578
+
579
+ const result = aggregateLocal(events);
580
+
581
+ // Should only count first occurrence of 'duplicate-id' + 'unique-id'
582
+ expect(result).toEqual({
583
+ requests: 2,
584
+ promptTokens: 150, // 100 (first duplicate) + 50 (unique)
585
+ completionTokens: 0,
586
+ reasoningTokens: 0,
587
+ cacheReadTokens: 0,
588
+ cacheWriteTokens: 0,
589
+ cost: 0.003, // 0.001 + 0.002
590
+ });
591
+ });
592
+
593
+ it('handles missing optional fields as zero', () => {
594
+ const events: LocalUsageEvent[] = [
595
+ {
596
+ id: 'test-1',
597
+ generationId: 'gen-1',
598
+ sessionId: 'session-1',
599
+ completedAt: '2026-05-22T10:00:00.000Z',
600
+ // No requests, tokens, or cost fields
601
+ },
602
+ ];
603
+
604
+ const result = aggregateLocal(events);
605
+ expect(result.requests).toBe(1); // requests defaults to 1
606
+ expect(result.promptTokens).toBe(0);
607
+ expect(result.completionTokens).toBe(0);
608
+ expect(result.cost).toBe(0);
609
+ });
610
+ });
611
+
612
+ // =============================================================================
613
+ // Retention Cleanup
614
+ // =============================================================================
615
+
616
+ describe('cleanupOldUsageFiles', () => {
617
+ it('deletes files older than retention window', async () => {
618
+ // Create files spanning different ages
619
+ const today = getCurrentUtcDate();
620
+ const day30 = addUtcDays(today, -30);
621
+ const day60 = addUtcDays(today, -60);
622
+ const day90 = addUtcDays(today, -90);
623
+ const day120 = addUtcDays(today, -120);
624
+
625
+ await fs.writeFile(path.join(testDir, `${today}.jsonl`), 'test\n', 'utf8');
626
+ await fs.writeFile(path.join(testDir, `${day30}.jsonl`), 'test\n', 'utf8');
627
+ await fs.writeFile(path.join(testDir, `${day60}.jsonl`), 'test\n', 'utf8');
628
+ await fs.writeFile(path.join(testDir, `${day90}.jsonl`), 'test\n', 'utf8');
629
+ await fs.writeFile(path.join(testDir, `${day120}.jsonl`), 'test\n', 'utf8');
630
+
631
+ // Cleanup with 90-day retention
632
+ await cleanupOldUsageFiles({ retentionDays: 90 });
633
+
634
+ // Check which files remain
635
+ const files = await fs.readdir(testDir);
636
+ const jsonlFiles = files.filter((f) => f.endsWith('.jsonl'));
637
+
638
+ // Should keep today, day30, day60, day90; delete day120
639
+ expect(jsonlFiles).toContain(`${today}.jsonl`);
640
+ expect(jsonlFiles).toContain(`${day30}.jsonl`);
641
+ expect(jsonlFiles).toContain(`${day60}.jsonl`);
642
+ expect(jsonlFiles).toContain(`${day90}.jsonl`); // Exactly on boundary - kept
643
+ expect(jsonlFiles).not.toContain(`${day120}.jsonl`); // Too old - deleted
644
+ });
645
+
646
+ it('uses default 90-day retention when not specified', async () => {
647
+ const today = getCurrentUtcDate();
648
+ const day91 = addUtcDays(today, -91);
649
+
650
+ await fs.writeFile(path.join(testDir, `${today}.jsonl`), 'test\n', 'utf8');
651
+ await fs.writeFile(path.join(testDir, `${day91}.jsonl`), 'test\n', 'utf8');
652
+
653
+ await cleanupOldUsageFiles();
654
+
655
+ const files = await fs.readdir(testDir);
656
+ const jsonlFiles = files.filter((f) => f.endsWith('.jsonl'));
657
+
658
+ expect(jsonlFiles).toContain(`${today}.jsonl`);
659
+ expect(jsonlFiles).not.toContain(`${day91}.jsonl`);
660
+ });
661
+
662
+ it('skips non-JSONL files', async () => {
663
+ await fs.writeFile(path.join(testDir, 'other.txt'), 'test\n', 'utf8');
664
+ await fs.writeFile(path.join(testDir, 'README.md'), 'test\n', 'utf8');
665
+
666
+ await cleanupOldUsageFiles({ retentionDays: 1 });
667
+
668
+ const files = await fs.readdir(testDir);
669
+ expect(files).toContain('other.txt');
670
+ expect(files).toContain('README.md');
671
+ });
672
+
673
+ it('skips malformed filename dates', async () => {
674
+ const today = getCurrentUtcDate();
675
+ await fs.writeFile(path.join(testDir, `${today}.jsonl`), 'test\n', 'utf8');
676
+ await fs.writeFile(path.join(testDir, 'invalid-date.jsonl'), 'test\n', 'utf8');
677
+ await fs.writeFile(path.join(testDir, '2026-13-99.jsonl'), 'test\n', 'utf8'); // Invalid date
678
+
679
+ await cleanupOldUsageFiles({ retentionDays: 1 });
680
+
681
+ const files = await fs.readdir(testDir);
682
+ expect(files).toContain(`${today}.jsonl`);
683
+ // Malformed files should be skipped, not deleted
684
+ expect(files).toContain('invalid-date.jsonl');
685
+ expect(files).toContain('2026-13-99.jsonl');
686
+ });
687
+
688
+ it('fails open on directory read error', async () => {
689
+ // Set to a non-existent directory
690
+ setLocalUsageDir('/nonexistent/directory');
691
+
692
+ // Should not throw
693
+ await expect(cleanupOldUsageFiles()).resolves.toBeUndefined();
694
+
695
+ // Reset for cleanup
696
+ setLocalUsageDir(testDir);
697
+ });
698
+
699
+ it('fails open on individual file delete error', async () => {
700
+ const old = addUtcDays(getCurrentUtcDate(), -120);
701
+ const oldPath = path.join(testDir, `${old}.jsonl`);
702
+ await fs.writeFile(oldPath, 'test\n', 'utf8');
703
+
704
+ // Make the file undeletable by removing write permissions from directory
705
+ try {
706
+ await fs.chmod(testDir, 0o555);
707
+
708
+ // Should not throw even if delete fails
709
+ await expect(cleanupOldUsageFiles({ retentionDays: 90 })).resolves.toBeUndefined();
710
+
711
+ // Restore permissions
712
+ await fs.chmod(testDir, 0o755);
713
+ } catch {
714
+ // If chmod fails, just verify cleanup doesn't throw on any error
715
+ await expect(cleanupOldUsageFiles({ retentionDays: 90 })).resolves.toBeUndefined();
716
+
717
+ // Try to restore
718
+ try {
719
+ await fs.chmod(testDir, 0o755);
720
+ } catch {
721
+ // Ignore
722
+ }
723
+ }
724
+ });
725
+
726
+ it('logs cleanup errors only when debug flag is set', async () => {
727
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
728
+
729
+ // Set to a non-existent directory
730
+ setLocalUsageDir('/nonexistent/directory');
731
+
732
+ // Without debug flag - should be quiet
733
+ clearEnv('PI_OPENROUTER_DEBUG_USAGE');
734
+ await cleanupOldUsageFiles();
735
+ expect(consoleErrorSpy).not.toHaveBeenCalled();
736
+
737
+ // With debug flag - should log
738
+ setEnv('PI_OPENROUTER_DEBUG_USAGE', '1');
739
+ await cleanupOldUsageFiles();
740
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
741
+ expect.stringContaining('[local-usage]'),
742
+ expect.any(Error),
743
+ );
744
+
745
+ consoleErrorSpy.mockRestore();
746
+
747
+ // Reset for cleanup
748
+ setLocalUsageDir(testDir);
749
+ });
750
+
751
+ it('logs deleted file count when debug flag is set', async () => {
752
+ const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
753
+
754
+ const old1 = addUtcDays(getCurrentUtcDate(), -120);
755
+ const old2 = addUtcDays(getCurrentUtcDate(), -121);
756
+ await fs.writeFile(path.join(testDir, `${old1}.jsonl`), 'test\n', 'utf8');
757
+ await fs.writeFile(path.join(testDir, `${old2}.jsonl`), 'test\n', 'utf8');
758
+
759
+ // Without debug flag - should be quiet
760
+ clearEnv('PI_OPENROUTER_DEBUG_USAGE');
761
+ await cleanupOldUsageFiles({ retentionDays: 90 });
762
+ expect(consoleLogSpy).not.toHaveBeenCalled();
763
+
764
+ // Recreate files
765
+ await fs.writeFile(path.join(testDir, `${old1}.jsonl`), 'test\n', 'utf8');
766
+ await fs.writeFile(path.join(testDir, `${old2}.jsonl`), 'test\n', 'utf8');
767
+
768
+ // With debug flag - should log count
769
+ setEnv('PI_OPENROUTER_DEBUG_USAGE', '1');
770
+ await cleanupOldUsageFiles({ retentionDays: 90 });
771
+ expect(consoleLogSpy).toHaveBeenCalledWith(
772
+ expect.stringMatching(/\[local-usage\].*2 old.*file/),
773
+ );
774
+
775
+ consoleLogSpy.mockRestore();
776
+ });
777
+ });