@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,769 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
+ import {
3
+ BACKGROUND_REFRESH_INTERVAL_MS,
4
+ fetchAndAggregate,
5
+ getRefreshState,
6
+ startBackgroundRefresh,
7
+ stopBackgroundRefresh,
8
+ usageCache,
9
+ } from '../cache.js';
10
+ import { getCredits, getActivity } from '../client.js';
11
+ import { readLocalUsage } from '../local-usage.js';
12
+ import type { LocalUsageEvent } from '../types.js';
13
+ import type { ActivityItem } from '@openrouter/sdk/models/index.js';
14
+ import { createActivityItem, createLocalEvents, createUsageSummary } from './fixtures.js';
15
+
16
+ // Mock dependencies
17
+ vi.mock('../client.js');
18
+ vi.mock('../local-usage.js', async () => {
19
+ const actual = await vi.importActual<typeof import('../local-usage.js')>('../local-usage.js');
20
+ return {
21
+ ...actual,
22
+ readLocalUsage: vi.fn(),
23
+ };
24
+ });
25
+
26
+ const mockGetCredits = vi.mocked(getCredits);
27
+ const mockGetActivity = vi.mocked(getActivity);
28
+ const mockReadLocalUsage = vi.mocked(readLocalUsage);
29
+
30
+ const usageAggregateFields = [
31
+ 'requests',
32
+ 'promptTokens',
33
+ 'completionTokens',
34
+ 'reasoningTokens',
35
+ 'cacheReadTokens',
36
+ 'cacheWriteTokens',
37
+ 'cost',
38
+ ] as const;
39
+
40
+ describe('fetchAndAggregate - Phase 3: Local usage merge when Activity API absent/empty', () => {
41
+ beforeEach(() => {
42
+ vi.resetAllMocks();
43
+ vi.useFakeTimers();
44
+ vi.setSystemTime(new Date('2026-05-22T12:00:00Z'));
45
+ });
46
+
47
+ afterEach(() => {
48
+ vi.useRealTimers();
49
+ });
50
+
51
+ it.each([
52
+ {
53
+ label: 'Activity API returns null',
54
+ officialResponse: null as null | ActivityItem[],
55
+ expectedHasActivityData: false,
56
+ model: 'anthropic/claude-sonnet-4',
57
+ provider: 'anthropic',
58
+ cost: 0.05,
59
+ },
60
+ {
61
+ label: 'Activity API returns empty array',
62
+ officialResponse: [] as ActivityItem[],
63
+ expectedHasActivityData: true,
64
+ model: 'openai/gpt-4',
65
+ provider: 'openai',
66
+ cost: 0.1,
67
+ },
68
+ ])(
69
+ 'should include local cost when $label and local events exist',
70
+ async ({ officialResponse, expectedHasActivityData, model, provider, cost }) => {
71
+ // Setup: credits available, Activity API absent/empty, local events exist
72
+ mockGetCredits.mockResolvedValue({
73
+ totalUsage: 5.0,
74
+ totalCredits: 10.0,
75
+ });
76
+ mockGetActivity.mockResolvedValue(officialResponse);
77
+
78
+ const localEvents = createLocalEvents([{ daysAgo: 0, cost, model, provider }]);
79
+ mockReadLocalUsage.mockResolvedValue(localEvents);
80
+
81
+ const summary = await fetchAndAggregate();
82
+
83
+ expect(summary).toBeDefined();
84
+ expect(summary!.hasActivityData).toBe(expectedHasActivityData);
85
+ expect(summary!.local.cost).toBeGreaterThan(0);
86
+ expect(summary!.local.cost).toBe(cost);
87
+ expect(summary!.combined.cost).toBe(cost);
88
+ expect(summary!.today).toBe(cost);
89
+ expect(summary!.topModels[0]).toMatchObject({
90
+ name: model,
91
+ spend7d: cost,
92
+ spend30d: cost,
93
+ requests7d: 1,
94
+ requests30d: 1,
95
+ });
96
+ expect(summary!.byProvider[0]).toMatchObject({
97
+ name: provider,
98
+ spend: cost,
99
+ requests: 1,
100
+ });
101
+ expect(summary!.byDay['2026-05-22']).toBe(cost);
102
+ // Verify bounded read range: today - 29 days through today
103
+ expect(mockReadLocalUsage).toHaveBeenCalledWith({
104
+ fromDateUtc: '2026-04-23', // 29 days before 2026-05-22
105
+ toDateUtc: '2026-05-22',
106
+ });
107
+ },
108
+ );
109
+
110
+ it('should request only local rows after officialThroughDate', async () => {
111
+ // Setup: Activity API has data through 2026-05-20
112
+ mockGetCredits.mockResolvedValue({
113
+ totalUsage: 3.0,
114
+ totalCredits: 10.0,
115
+ });
116
+
117
+ const officialData = [
118
+ createActivityItem({
119
+ date: '2026-05-20',
120
+ model: 'anthropic/claude-sonnet-4',
121
+ providerName: 'anthropic',
122
+ requests: 2,
123
+ promptTokens: 1000,
124
+ completionTokens: 200,
125
+ reasoningTokens: 0,
126
+ usage: 0.03,
127
+ }),
128
+ ];
129
+ mockGetActivity.mockResolvedValue(officialData);
130
+
131
+ // readLocalUsage should only return rows after officialThroughDate because
132
+ // fetchAndAggregate requests a range starting at officialThroughDate + 1 day.
133
+ const localEvents: LocalUsageEvent[] = [
134
+ {
135
+ id: 'local-after-official-date',
136
+ generationId: 'gen-4',
137
+ sessionId: 'session-4',
138
+ completedAt: '2026-05-21T10:00:00Z',
139
+ requests: 1,
140
+ model: 'anthropic/claude-sonnet-4',
141
+ provider: 'anthropic',
142
+ cost: 0.04,
143
+ promptTokens: 800,
144
+ completionTokens: 150,
145
+ reasoningTokens: 0,
146
+ cacheReadTokens: 0,
147
+ cacheWriteTokens: 0,
148
+ },
149
+ ];
150
+ mockReadLocalUsage.mockResolvedValue(localEvents);
151
+
152
+ const summary = await fetchAndAggregate();
153
+
154
+ expect(summary).toBeDefined();
155
+ // Official aggregate from Activity API
156
+ expect(summary!.official.cost).toBe(0.03);
157
+ // Local aggregate should only include rows after officialThroughDate
158
+ expect(summary!.local.cost).toBe(0.04);
159
+ expect(summary!.combined.cost).toBe(0.07);
160
+ expect(summary!.byDay['2026-05-20']).toBe(0.03);
161
+ expect(summary!.byDay['2026-05-21']).toBe(0.04);
162
+ // Verify read range is from day after official through today
163
+ expect(mockReadLocalUsage).toHaveBeenCalledWith({
164
+ fromDateUtc: '2026-05-21', // Day after 2026-05-20
165
+ toDateUtc: '2026-05-22',
166
+ });
167
+ });
168
+
169
+ it.each([
170
+ {
171
+ label: 'official data is much older than the 30-day window',
172
+ officialThroughDate: '2026-02-11',
173
+ expectedFromDateUtc: '2026-04-23',
174
+ },
175
+ {
176
+ label: 'official data is exactly at the boundary',
177
+ officialThroughDate: '2026-04-22',
178
+ expectedFromDateUtc: '2026-04-23',
179
+ },
180
+ {
181
+ label: 'official data is recent',
182
+ officialThroughDate: '2026-05-20',
183
+ expectedFromDateUtc: '2026-05-21',
184
+ },
185
+ ])(
186
+ 'caps local read range only when $label',
187
+ async ({ officialThroughDate, expectedFromDateUtc }) => {
188
+ mockGetCredits.mockResolvedValue({
189
+ totalUsage: 5.0,
190
+ totalCredits: 10.0,
191
+ });
192
+ mockGetActivity.mockResolvedValue([
193
+ createActivityItem({
194
+ date: officialThroughDate,
195
+ model: 'anthropic/claude-sonnet-4',
196
+ providerName: 'anthropic',
197
+ requests: 1,
198
+ usage: 0.1,
199
+ }),
200
+ ]);
201
+ mockReadLocalUsage.mockResolvedValue([]);
202
+
203
+ const summary = await fetchAndAggregate();
204
+
205
+ expect(summary!.officialThroughDate).toBe(officialThroughDate);
206
+ expect(mockReadLocalUsage).toHaveBeenCalledWith({
207
+ fromDateUtc: expectedFromDateUtc,
208
+ toDateUtc: '2026-05-22',
209
+ });
210
+ },
211
+ );
212
+
213
+ it('should read at most 30 UTC days when no official data exists', async () => {
214
+ // Setup: No Activity data, verify bounded read
215
+ mockGetCredits.mockResolvedValue({
216
+ totalUsage: 2.0,
217
+ totalCredits: 10.0,
218
+ });
219
+ mockGetActivity.mockResolvedValue([]);
220
+ mockReadLocalUsage.mockResolvedValue([]);
221
+
222
+ await fetchAndAggregate();
223
+
224
+ // Verify the read range is bounded to 30 days (today - 29 through today = 30 days inclusive)
225
+ expect(mockReadLocalUsage).toHaveBeenCalledWith({
226
+ fromDateUtc: '2026-04-23', // 29 days before 2026-05-22
227
+ toDateUtc: '2026-05-22',
228
+ });
229
+ });
230
+
231
+ it('should keep official-only summary behavior unchanged when no local events exist', async () => {
232
+ mockGetCredits.mockResolvedValue({
233
+ totalUsage: 4.0,
234
+ totalCredits: 10.0,
235
+ });
236
+
237
+ const officialData = [
238
+ createActivityItem({
239
+ date: '2026-05-21',
240
+ model: 'openai/gpt-4',
241
+ providerName: 'openai',
242
+ requests: 3,
243
+ promptTokens: 3000,
244
+ completionTokens: 600,
245
+ reasoningTokens: 0,
246
+ usage: 0.15,
247
+ }),
248
+ ];
249
+ mockGetActivity.mockResolvedValue(officialData);
250
+ mockReadLocalUsage.mockResolvedValue([]);
251
+
252
+ const summary = await fetchAndAggregate();
253
+
254
+ expect(summary).toBeDefined();
255
+ expect(summary).toMatchObject({
256
+ today: 0,
257
+ week: 0.15,
258
+ month: 4.0,
259
+ hasActivityData: true,
260
+ officialThroughDate: '2026-05-21',
261
+ });
262
+ expect(summary!.topModels[0]).toMatchObject({
263
+ name: 'openai/gpt-4',
264
+ spend7d: 0.15,
265
+ spend30d: 0.15,
266
+ requests7d: 3,
267
+ requests30d: 3,
268
+ });
269
+ expect(summary!.byProvider).toEqual([
270
+ {
271
+ name: 'openai',
272
+ spend: 0.15,
273
+ tokens: {
274
+ input: 3000,
275
+ output: 600,
276
+ reasoning: 0,
277
+ total: 3600,
278
+ },
279
+ requests: 3,
280
+ },
281
+ ]);
282
+ expect(summary!.byDay).toEqual({ '2026-05-21': 0.15 });
283
+ expect(summary!.official).toEqual({
284
+ requests: 3,
285
+ promptTokens: 3000,
286
+ completionTokens: 600,
287
+ reasoningTokens: 0,
288
+ cacheReadTokens: 0,
289
+ cacheWriteTokens: 0,
290
+ cost: 0.15,
291
+ });
292
+ expect(summary!.local).toEqual({
293
+ requests: 0,
294
+ promptTokens: 0,
295
+ completionTokens: 0,
296
+ reasoningTokens: 0,
297
+ cacheReadTokens: 0,
298
+ cacheWriteTokens: 0,
299
+ cost: 0,
300
+ });
301
+ expect(summary!.combined).toEqual(summary!.official);
302
+ });
303
+
304
+ it('should fail open when local read throws error', async () => {
305
+ // Setup: Activity API unavailable, local read fails
306
+ mockGetCredits.mockResolvedValue({
307
+ totalUsage: 1.0,
308
+ totalCredits: 10.0,
309
+ });
310
+ mockGetActivity.mockResolvedValue(null);
311
+ mockReadLocalUsage.mockRejectedValue(new Error('Filesystem error'));
312
+
313
+ const summary = await fetchAndAggregate();
314
+
315
+ // Should not throw, should continue with empty local
316
+ expect(summary).toBeDefined();
317
+ expect(summary!.local.cost).toBe(0);
318
+ expect(summary!.combined.cost).toBe(0);
319
+ });
320
+
321
+ it('should keep combined aggregate equal to official plus local for every numeric field', async () => {
322
+ mockGetCredits.mockResolvedValue({
323
+ totalUsage: 9.0,
324
+ totalCredits: 10.0,
325
+ });
326
+ mockGetActivity.mockResolvedValue([
327
+ createActivityItem({
328
+ date: '2026-05-21',
329
+ model: 'openai/gpt-4',
330
+ providerName: 'openai',
331
+ requests: 2,
332
+ promptTokens: 120,
333
+ completionTokens: 30,
334
+ reasoningTokens: 7,
335
+ usage: 0.02,
336
+ }),
337
+ ]);
338
+ mockReadLocalUsage.mockResolvedValue([
339
+ {
340
+ id: 'local-aggregate-invariant',
341
+ generationId: 'gen-aggregate-invariant',
342
+ sessionId: 'session-aggregate-invariant',
343
+ completedAt: '2026-05-22T10:00:00Z',
344
+ requests: 3,
345
+ model: 'anthropic/claude-sonnet-4',
346
+ provider: 'anthropic',
347
+ promptTokens: 200,
348
+ completionTokens: 40,
349
+ reasoningTokens: 9,
350
+ cacheReadTokens: 11,
351
+ cacheWriteTokens: 13,
352
+ cost: 0.05,
353
+ },
354
+ ]);
355
+
356
+ const summary = await fetchAndAggregate();
357
+
358
+ expect(summary).toBeDefined();
359
+ for (const field of usageAggregateFields) {
360
+ expect(summary!.combined[field]).toBe(summary!.official[field] + summary!.local[field]);
361
+ }
362
+ });
363
+ });
364
+
365
+ describe('fetchAndAggregate - Today/local cutover data contract', () => {
366
+ beforeEach(() => {
367
+ vi.resetAllMocks();
368
+ vi.useFakeTimers();
369
+ vi.setSystemTime(new Date('2026-05-22T12:00:00Z'));
370
+ });
371
+
372
+ afterEach(() => {
373
+ vi.useRealTimers();
374
+ });
375
+
376
+ describe('Activity API absent: bounded 30-day local fallback', () => {
377
+ it.each([
378
+ {
379
+ label: 'multi-day local with today event',
380
+ apiResponse: null as null | ActivityItem[],
381
+ localEvents: [
382
+ { daysAgo: 3, cost: 1.0, model: 'gpt-4', provider: 'openai' },
383
+ { daysAgo: 1, cost: 2.0, model: 'claude-3', provider: 'anthropic' },
384
+ { daysAgo: 0, cost: 0.5, model: 'gpt-4', provider: 'openai' },
385
+ ],
386
+ expectedLocalCost: 3.5,
387
+ expectedLocalRequests: 3,
388
+ expectedToday: 0.5,
389
+ expectedCombinedCost: 3.5,
390
+ },
391
+ {
392
+ label: 'empty Activity API with multi-day local',
393
+ apiResponse: [] as ActivityItem[],
394
+ localEvents: [
395
+ { daysAgo: 3, cost: 1.0, model: 'gpt-4', provider: 'openai' },
396
+ { daysAgo: 1, cost: 2.0, model: 'claude-3', provider: 'anthropic' },
397
+ { daysAgo: 0, cost: 0.5, model: 'gpt-4', provider: 'openai' },
398
+ ],
399
+ expectedLocalCost: 3.5,
400
+ expectedLocalRequests: 3,
401
+ expectedToday: 0.5,
402
+ expectedCombinedCost: 3.5,
403
+ },
404
+ {
405
+ label: 'local has only older events, no today',
406
+ apiResponse: [] as ActivityItem[],
407
+ localEvents: [
408
+ { daysAgo: 5, cost: 1.5, model: 'gpt-4', provider: 'openai' },
409
+ { daysAgo: 2, cost: 0.8, model: 'claude-3', provider: 'anthropic' },
410
+ ],
411
+ expectedLocalCost: 2.3,
412
+ expectedLocalRequests: 2,
413
+ expectedToday: 0,
414
+ expectedCombinedCost: 2.3,
415
+ },
416
+ ])(
417
+ 'sets summary.local to multi-day but summary.today to UTC-today only: $label',
418
+ async ({
419
+ apiResponse,
420
+ localEvents,
421
+ expectedLocalCost,
422
+ expectedLocalRequests,
423
+ expectedToday,
424
+ expectedCombinedCost,
425
+ }) => {
426
+ mockGetCredits.mockResolvedValue({
427
+ totalUsage: 5.0,
428
+ totalCredits: 10.0,
429
+ });
430
+ mockGetActivity.mockResolvedValue(apiResponse);
431
+
432
+ const events = createLocalEvents(localEvents);
433
+ mockReadLocalUsage.mockResolvedValue(events);
434
+
435
+ const summary = await fetchAndAggregate();
436
+
437
+ expect(summary).toBeDefined();
438
+ expect(summary!.local.cost).toBe(expectedLocalCost);
439
+ expect(summary!.local.requests).toBe(expectedLocalRequests);
440
+ expect(summary!.today).toBe(expectedToday);
441
+ expect(summary!.combined.cost).toBe(expectedCombinedCost);
442
+ // Verify bounded 30-day read range
443
+ expect(mockReadLocalUsage).toHaveBeenCalledWith({
444
+ fromDateUtc: '2026-04-23', // 29 days before 2026-05-22
445
+ toDateUtc: '2026-05-22',
446
+ });
447
+ },
448
+ );
449
+ });
450
+
451
+ describe('Activity API behind: local spans multiple post-official days', () => {
452
+ it('sets summary.local to multi-day post-official aggregate but summary.today to UTC-today only', async () => {
453
+ // Scenario: Activity API through 2 days ago (2026-05-20).
454
+ // Local JSONL has events from yesterday (2026-05-21) and today (2026-05-22).
455
+ // summary.local should include both post-official days.
456
+ // summary.today should include only UTC-today (2026-05-22).
457
+ mockGetCredits.mockResolvedValue({
458
+ totalUsage: 10.0,
459
+ totalCredits: 20.0,
460
+ });
461
+
462
+ const officialData = [
463
+ createActivityItem({
464
+ date: '2026-05-20',
465
+ model: 'gpt-4',
466
+ providerName: 'openai',
467
+ requests: 5,
468
+ usage: 2.0,
469
+ }),
470
+ ];
471
+ mockGetActivity.mockResolvedValue(officialData);
472
+
473
+ // Create all events in a single call to avoid ID collision from spreading
474
+ const localEvents = createLocalEvents([
475
+ // Yesterday (2026-05-21)
476
+ { daysAgo: 1, cost: 3.0, model: 'claude-3', provider: 'anthropic' },
477
+ // Today (2026-05-22)
478
+ { daysAgo: 0, cost: 1.5, model: 'gpt-4', provider: 'openai' },
479
+ ]);
480
+ mockReadLocalUsage.mockResolvedValue(localEvents);
481
+
482
+ const summary = await fetchAndAggregate();
483
+
484
+ expect(summary).toBeDefined();
485
+ // official aggregate from API
486
+ expect(summary!.official.cost).toBe(2.0);
487
+ // local includes yesterday + today
488
+ expect(summary!.local.cost).toBe(4.5);
489
+ expect(summary!.local.requests).toBe(2);
490
+ // today includes only UTC-today event
491
+ expect(summary!.today).toBe(1.5);
492
+ // combined = official + local
493
+ expect(summary!.combined.cost).toBe(6.5);
494
+ // Verify read range starts day after officialThroughDate
495
+ expect(mockReadLocalUsage).toHaveBeenCalledWith({
496
+ fromDateUtc: '2026-05-21', // day after 2026-05-20
497
+ toDateUtc: '2026-05-22',
498
+ });
499
+ });
500
+
501
+ it('sets summary.today correctly when local has multiple post-official days but no today events', async () => {
502
+ // Edge case: Activity API through 5 days ago. Local has events from 3-4 days ago, but none today.
503
+ mockGetCredits.mockResolvedValue({
504
+ totalUsage: 15.0,
505
+ totalCredits: 20.0,
506
+ });
507
+
508
+ const officialData = [
509
+ createActivityItem({
510
+ date: '2026-05-17', // 5 days ago
511
+ model: 'gpt-4',
512
+ providerName: 'openai',
513
+ requests: 10,
514
+ usage: 5.0,
515
+ }),
516
+ ];
517
+ mockGetActivity.mockResolvedValue(officialData);
518
+
519
+ // Create all events in a single call to avoid ID collision from spreading
520
+ const localEvents = createLocalEvents([
521
+ { daysAgo: 4, cost: 2.0, model: 'claude-3', provider: 'anthropic' },
522
+ { daysAgo: 3, cost: 1.0, model: 'gpt-4', provider: 'openai' },
523
+ ]);
524
+ mockReadLocalUsage.mockResolvedValue(localEvents);
525
+
526
+ const summary = await fetchAndAggregate();
527
+
528
+ expect(summary).toBeDefined();
529
+ // local includes both post-official events
530
+ expect(summary!.local.cost).toBe(3.0);
531
+ // today should be 0 because no events from today
532
+ expect(summary!.today).toBe(0);
533
+ });
534
+ });
535
+
536
+ describe('Activity API includes today: local should be empty or minimal', () => {
537
+ it('sets summary.today from official when officialThroughDate is today and local is empty', async () => {
538
+ // Scenario: Activity API data through today (2026-05-22).
539
+ // Local read range starts tomorrow, so local should be empty.
540
+ mockGetCredits.mockResolvedValue({
541
+ totalUsage: 8.0,
542
+ totalCredits: 20.0,
543
+ });
544
+
545
+ const officialData = [
546
+ createActivityItem({
547
+ date: '2026-05-22', // today
548
+ model: 'gpt-4',
549
+ providerName: 'openai',
550
+ requests: 8,
551
+ usage: 3.0,
552
+ }),
553
+ ];
554
+ mockGetActivity.mockResolvedValue(officialData);
555
+
556
+ // When officialThroughDate is today, local read range is tomorrow through today,
557
+ // which is an empty range.
558
+ mockReadLocalUsage.mockResolvedValue([]);
559
+
560
+ const summary = await fetchAndAggregate();
561
+
562
+ expect(summary).toBeDefined();
563
+ expect(summary!.officialThroughDate).toBe('2026-05-22');
564
+ // official includes today's data
565
+ expect(summary!.official.cost).toBe(3.0);
566
+ // local is empty because read range starts tomorrow
567
+ expect(summary!.local.cost).toBe(0);
568
+ // today comes from official data
569
+ expect(summary!.today).toBe(3.0);
570
+ // combined = official only
571
+ expect(summary!.combined.cost).toBe(3.0);
572
+ // Verify read range: tomorrow through today = empty range
573
+ expect(mockReadLocalUsage).toHaveBeenCalledWith({
574
+ fromDateUtc: '2026-05-23', // day after 2026-05-22 (today)
575
+ toDateUtc: '2026-05-22', // today
576
+ });
577
+ });
578
+
579
+ it('sets summary.today from official when API includes today', async () => {
580
+ // Scenario: Activity API includes today (2026-05-22) among other days.
581
+ // officialThroughDate will be today, so local read range is empty.
582
+ mockGetCredits.mockResolvedValue({
583
+ totalUsage: 12.0,
584
+ totalCredits: 20.0,
585
+ });
586
+
587
+ const officialData = [
588
+ createActivityItem({
589
+ date: '2026-05-21', // yesterday
590
+ model: 'gpt-4',
591
+ providerName: 'openai',
592
+ requests: 5,
593
+ usage: 4.0,
594
+ }),
595
+ createActivityItem({
596
+ date: '2026-05-22', // today
597
+ model: 'claude-3',
598
+ providerName: 'anthropic',
599
+ requests: 3,
600
+ usage: 2.5,
601
+ }),
602
+ ];
603
+ mockGetActivity.mockResolvedValue(officialData);
604
+
605
+ // Local read range starts tomorrow when official includes today
606
+ mockReadLocalUsage.mockResolvedValue([]);
607
+
608
+ const summary = await fetchAndAggregate();
609
+
610
+ expect(summary).toBeDefined();
611
+ expect(summary!.officialThroughDate).toBe('2026-05-22');
612
+ // official includes both days
613
+ expect(summary!.official.cost).toBe(6.5);
614
+ // local is empty
615
+ expect(summary!.local.cost).toBe(0);
616
+ // today comes from official data (2026-05-22 row)
617
+ expect(summary!.today).toBe(2.5);
618
+ });
619
+ });
620
+ });
621
+
622
+ describe('background refresh lifecycle', () => {
623
+ beforeEach(() => {
624
+ vi.resetAllMocks();
625
+ usageCache.clear();
626
+ stopBackgroundRefresh();
627
+ vi.useFakeTimers();
628
+ vi.setSystemTime(new Date('2026-05-22T12:00:00Z'));
629
+ });
630
+
631
+ afterEach(() => {
632
+ stopBackgroundRefresh();
633
+ usageCache.clear();
634
+ vi.useRealTimers();
635
+ });
636
+
637
+ function mockSuccessfulRefresh() {
638
+ mockGetCredits.mockResolvedValue({ totalUsage: 2, totalCredits: 10 });
639
+ mockGetActivity.mockResolvedValue([]);
640
+ mockReadLocalUsage.mockResolvedValue([]);
641
+ }
642
+
643
+ it('caches usage after an initial successful refresh and schedules the normal interval', async () => {
644
+ mockSuccessfulRefresh();
645
+
646
+ startBackgroundRefresh();
647
+ await vi.advanceTimersByTimeAsync(BACKGROUND_REFRESH_INTERVAL_MS);
648
+
649
+ expect(usageCache.get('usage')).toBeDefined();
650
+ expect(getRefreshState()).toMatchObject({
651
+ status: 'healthy',
652
+ consecutiveFailures: 0,
653
+ nextDelayMs: BACKGROUND_REFRESH_INTERVAL_MS,
654
+ });
655
+ expect(mockGetCredits).toHaveBeenCalledTimes(1);
656
+ });
657
+
658
+ it('keeps last-good stale data available after one refresh failure', async () => {
659
+ const staleSummary = createUsageSummary({ today: 1.23 });
660
+ usageCache.set('usage', staleSummary);
661
+ vi.setSystemTime(new Date('2026-05-22T12:01:00Z'));
662
+ mockGetCredits.mockRejectedValue(new Error('network down'));
663
+
664
+ startBackgroundRefresh();
665
+ await vi.advanceTimersByTimeAsync(BACKGROUND_REFRESH_INTERVAL_MS);
666
+
667
+ expect(usageCache.get('usage')).toBeUndefined();
668
+ expect(usageCache.get('usage', { allowStale: true })).toEqual(staleSummary);
669
+ expect(getRefreshState()).toMatchObject({
670
+ status: 'stale',
671
+ consecutiveFailures: 1,
672
+ lastError: 'network down',
673
+ nextDelayMs: BACKGROUND_REFRESH_INTERVAL_MS * 2,
674
+ });
675
+ });
676
+
677
+ it('continues refreshing after repeated failures instead of stopping permanently', async () => {
678
+ mockGetCredits.mockRejectedValue(new Error('still down'));
679
+
680
+ startBackgroundRefresh();
681
+
682
+ for (const expectedFailures of [1, 2, 3, 4, 5]) {
683
+ await vi.advanceTimersByTimeAsync(
684
+ getRefreshState().nextDelayMs ?? BACKGROUND_REFRESH_INTERVAL_MS,
685
+ );
686
+ expect(getRefreshState().consecutiveFailures).toBe(expectedFailures);
687
+ expect(getRefreshState().nextDelayMs).toBeLessThanOrEqual(
688
+ BACKGROUND_REFRESH_INTERVAL_MS * 32,
689
+ );
690
+ }
691
+
692
+ const callsAfterFiveFailures = mockGetCredits.mock.calls.length;
693
+ await vi.advanceTimersByTimeAsync(
694
+ getRefreshState().nextDelayMs ?? BACKGROUND_REFRESH_INTERVAL_MS,
695
+ );
696
+
697
+ expect(mockGetCredits.mock.calls.length).toBeGreaterThan(callsAfterFiveFailures);
698
+ expect(getRefreshState().status).toBe('failed');
699
+ });
700
+
701
+ it('uses a longer capped backoff for rate-limit failures', async () => {
702
+ mockGetCredits.mockRejectedValue(new Error('429 rate limit exceeded'));
703
+
704
+ startBackgroundRefresh();
705
+ await vi.advanceTimersByTimeAsync(BACKGROUND_REFRESH_INTERVAL_MS);
706
+
707
+ expect(getRefreshState()).toMatchObject({
708
+ status: 'failed',
709
+ consecutiveFailures: 1,
710
+ nextDelayMs: BACKGROUND_REFRESH_INTERVAL_MS * 8,
711
+ });
712
+ });
713
+
714
+ it('resets failure state and returns to the normal interval after recovery', async () => {
715
+ mockGetCredits.mockRejectedValueOnce(new Error('temporary outage'));
716
+ mockGetCredits.mockResolvedValue({ totalUsage: 3, totalCredits: 10 });
717
+ mockGetActivity.mockResolvedValue([]);
718
+ mockReadLocalUsage.mockResolvedValue([]);
719
+
720
+ startBackgroundRefresh();
721
+ await vi.advanceTimersByTimeAsync(BACKGROUND_REFRESH_INTERVAL_MS);
722
+ expect(getRefreshState()).toMatchObject({
723
+ status: 'failed',
724
+ consecutiveFailures: 1,
725
+ nextDelayMs: BACKGROUND_REFRESH_INTERVAL_MS * 2,
726
+ });
727
+
728
+ await vi.advanceTimersByTimeAsync(BACKGROUND_REFRESH_INTERVAL_MS * 2);
729
+
730
+ expect(getRefreshState()).toMatchObject({
731
+ status: 'healthy',
732
+ consecutiveFailures: 0,
733
+ lastError: null,
734
+ nextDelayMs: BACKGROUND_REFRESH_INTERVAL_MS,
735
+ });
736
+ expect(usageCache.get('usage')).toBeDefined();
737
+ });
738
+
739
+ it('clears the timer on stop and prevents further refreshes', async () => {
740
+ mockSuccessfulRefresh();
741
+
742
+ startBackgroundRefresh();
743
+ stopBackgroundRefresh();
744
+ await vi.advanceTimersByTimeAsync(BACKGROUND_REFRESH_INTERVAL_MS * 4);
745
+
746
+ expect(mockGetCredits).not.toHaveBeenCalled();
747
+ expect(getRefreshState()).toMatchObject({
748
+ status: 'idle',
749
+ consecutiveFailures: 0,
750
+ nextDelayMs: null,
751
+ });
752
+ });
753
+
754
+ it('notifies via callback when refresh failures occur', async () => {
755
+ const onFailure = vi.fn();
756
+ mockGetCredits.mockRejectedValue(new Error('persistent failure'));
757
+
758
+ startBackgroundRefresh({ onFailure });
759
+ await vi.advanceTimersByTimeAsync(BACKGROUND_REFRESH_INTERVAL_MS);
760
+
761
+ expect(onFailure).toHaveBeenCalledWith(
762
+ expect.objectContaining({
763
+ status: 'failed',
764
+ consecutiveFailures: 1,
765
+ lastError: 'persistent failure',
766
+ }),
767
+ );
768
+ });
769
+ });