@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.
- package/README.md +87 -4
- package/extensions/openrouter/__tests__/cache.test.ts +769 -0
- package/extensions/openrouter/__tests__/client.test.ts +333 -15
- package/extensions/openrouter/__tests__/commands.test.ts +816 -0
- package/extensions/openrouter/__tests__/fixtures.ts +140 -1
- package/extensions/openrouter/__tests__/format.test.ts +19 -0
- package/extensions/openrouter/__tests__/hooks.test.ts +276 -0
- package/extensions/openrouter/__tests__/index.test.ts +163 -0
- package/extensions/openrouter/__tests__/local-usage.test.ts +777 -0
- package/extensions/openrouter/__tests__/normalizers.test.ts +288 -0
- package/extensions/openrouter/__tests__/overlay.test.ts +225 -0
- package/extensions/openrouter/__tests__/session-state.test.ts +233 -0
- package/extensions/openrouter/__tests__/session.test.ts +44 -43
- package/extensions/openrouter/account-client.ts +11 -61
- package/extensions/openrouter/cache.ts +203 -91
- package/extensions/openrouter/client.ts +49 -3
- package/extensions/openrouter/commands.ts +555 -0
- package/extensions/openrouter/format.ts +7 -4
- package/extensions/openrouter/hooks.ts +229 -0
- package/extensions/openrouter/index.ts +13 -589
- package/extensions/openrouter/local-usage.ts +145 -22
- package/extensions/openrouter/models/__tests__/cache.test.ts +63 -2
- package/extensions/openrouter/models/__tests__/mapper-overrides.test.ts +102 -0
- package/extensions/openrouter/models/__tests__/mapper.test.ts +29 -0
- package/extensions/openrouter/models/__tests__/override-commands.test.ts +668 -0
- package/extensions/openrouter/models/__tests__/overrides.test.ts +237 -0
- package/extensions/openrouter/models/__tests__/sync.test.ts +156 -4
- package/extensions/openrouter/models/cache.ts +27 -2
- package/extensions/openrouter/models/mapper.ts +60 -77
- package/extensions/openrouter/models/override-commands.ts +434 -0
- package/extensions/openrouter/models/overrides.ts +174 -0
- package/extensions/openrouter/models/skip-hints.ts +19 -0
- package/extensions/openrouter/models/sync.ts +22 -10
- package/extensions/openrouter/models/types.ts +31 -1
- package/extensions/openrouter/normalizers.ts +128 -0
- package/extensions/openrouter/overlay.ts +19 -8
- package/extensions/openrouter/session-state.ts +110 -0
- package/extensions/openrouter/session.ts +16 -0
- package/extensions/openrouter/types.ts +28 -9
- 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
|
+
});
|