@robhowley/pi-openrouter 0.9.0 → 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 +39 -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 +112 -363
- 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 -990
- package/extensions/openrouter/local-usage.ts +145 -22
- package/extensions/openrouter/models/__tests__/cache.test.ts +63 -2
- 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__/sync.test.ts +156 -4
- package/extensions/openrouter/models/cache.ts +27 -2
- package/extensions/openrouter/models/mapper.ts +35 -69
- package/extensions/openrouter/models/override-commands.ts +434 -0
- package/extensions/openrouter/models/skip-hints.ts +19 -0
- package/extensions/openrouter/models/sync.ts +22 -10
- package/extensions/openrouter/models/types.ts +2 -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,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
|
+
});
|