@robhowley/pi-openrouter 0.9.1 → 0.10.0
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 +8 -2
- package/extensions/openrouter/__tests__/hooks.test.ts +262 -9
- package/extensions/openrouter/__tests__/status-bar.test.ts +262 -0
- package/extensions/openrouter/hooks.ts +70 -11
- package/extensions/openrouter/local-usage.ts +15 -10
- package/extensions/openrouter/status-bar.ts +101 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -48,6 +48,8 @@ The sync uses OpenRouter’s authenticated user model catalog, so Pi can see the
|
|
|
48
48
|
|
|
49
49
|
`/openrouter models-status`
|
|
50
50
|
|
|
51
|
+
Model count and cache health live here. The Pi footer/status bar does not persistently show `OpenRouter {N} models` anymore.
|
|
52
|
+
|
|
51
53
|
Example status output:
|
|
52
54
|
|
|
53
55
|
```text
|
|
@@ -61,7 +63,7 @@ To see why models were skipped:
|
|
|
61
63
|
|
|
62
64
|
Skipped output may include a grouped suggestion when Pi can offer a safe next step, such as adding a local `contextWindow` override for incomplete metadata.
|
|
63
65
|
|
|
64
|
-
Skipped models do not make the sync fail; models are skipped when required metadata cannot be safely mapped into Pi’s provider model config. The last successful catalog is cached so Pi can keep using it if a later refresh fails, and the cache persists across sessions. If a session starts with a cached catalog that has not been registered yet, status will show:
|
|
66
|
+
Skipped models do not make the sync fail; models are skipped when required metadata cannot be safely mapped into Pi’s provider model config. The last successful catalog is cached so Pi can keep using it if a later refresh fails, and the cache persists across sessions. If a session starts with a cached catalog that has not been registered yet, `/openrouter models-status` will show:
|
|
65
67
|
|
|
66
68
|
```text
|
|
67
69
|
OpenRouter models cached
|
|
@@ -122,7 +124,11 @@ pi:[uuid]
|
|
|
122
124
|
|
|
123
125
|
## Local usage tracking
|
|
124
126
|
|
|
125
|
-
The extension logs completed OpenRouter turns to local JSONL files in `~/.pi/openrouter/usage/` to provide near-real-time usage data for "Today's spend" in the usage overlay. This supplements the OpenRouter Activity API, which typically has a delay.
|
|
127
|
+
The extension logs completed OpenRouter turns to local JSONL files in `~/.pi/openrouter/usage/` to provide near-real-time usage data for the footer/status bar and for "Today's spend" in the usage overlay. This supplements the OpenRouter Activity API, which typically has a delay.
|
|
128
|
+
|
|
129
|
+
When positive local spend exists in the last 30 UTC days, the footer/status bar shows local-only Today spend and a 30-day average multiplier, for example `OR $2.14 today · 1.3x 30d avg`.
|
|
130
|
+
|
|
131
|
+
Model count and cache health remain available through `/openrouter models-status`.
|
|
126
132
|
|
|
127
133
|
**Retention:** Local usage files are automatically cleaned up after 90 days.
|
|
128
134
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
|
|
3
3
|
const mocks = vi.hoisted(() => ({
|
|
4
4
|
stopBackgroundRefresh: vi.fn(),
|
|
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
|
|
|
10
10
|
mapOpenRouterModels: vi.fn(),
|
|
11
11
|
includeBuiltinRouterModels: vi.fn(),
|
|
12
12
|
isSyncEnabled: vi.fn(),
|
|
13
|
+
loadOpenRouterStatusBar: vi.fn(),
|
|
13
14
|
}));
|
|
14
15
|
|
|
15
16
|
vi.mock('../cache.js', () => ({
|
|
@@ -40,6 +41,10 @@ vi.mock('../models/sync.js', () => ({
|
|
|
40
41
|
isSyncEnabled: mocks.isSyncEnabled,
|
|
41
42
|
}));
|
|
42
43
|
|
|
44
|
+
vi.mock('../status-bar.js', () => ({
|
|
45
|
+
loadOpenRouterStatusBar: mocks.loadOpenRouterStatusBar,
|
|
46
|
+
}));
|
|
47
|
+
|
|
43
48
|
function createMockPi() {
|
|
44
49
|
const handlers = new Map<string, any>();
|
|
45
50
|
return {
|
|
@@ -69,6 +74,14 @@ function createMockContext(overrides: { hasUI?: boolean } = {}) {
|
|
|
69
74
|
} as any;
|
|
70
75
|
}
|
|
71
76
|
|
|
77
|
+
function createDeferredPromise<T>() {
|
|
78
|
+
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
79
|
+
const promise = new Promise<T>((res) => {
|
|
80
|
+
resolve = res;
|
|
81
|
+
});
|
|
82
|
+
return { promise, resolve };
|
|
83
|
+
}
|
|
84
|
+
|
|
72
85
|
async function loadHooksModule() {
|
|
73
86
|
return import('../hooks.js');
|
|
74
87
|
}
|
|
@@ -77,6 +90,7 @@ describe('openrouter hooks', () => {
|
|
|
77
90
|
beforeEach(() => {
|
|
78
91
|
vi.resetModules();
|
|
79
92
|
vi.resetAllMocks();
|
|
93
|
+
vi.useRealTimers();
|
|
80
94
|
|
|
81
95
|
mocks.isOpenRouterRequest.mockReturnValue(true);
|
|
82
96
|
mocks.writeLocalUsage.mockResolvedValue(undefined);
|
|
@@ -86,9 +100,14 @@ describe('openrouter hooks', () => {
|
|
|
86
100
|
mocks.mapOpenRouterModels.mockResolvedValue({ configs: [{ id: 'model-a' }] });
|
|
87
101
|
mocks.includeBuiltinRouterModels.mockReturnValue([{ id: 'model-a' }, { id: 'router' }]);
|
|
88
102
|
mocks.isSyncEnabled.mockReturnValue(true);
|
|
103
|
+
mocks.loadOpenRouterStatusBar.mockResolvedValue({ kind: 'empty' });
|
|
89
104
|
});
|
|
90
105
|
|
|
91
|
-
|
|
106
|
+
afterEach(() => {
|
|
107
|
+
vi.useRealTimers();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('loads cached startup models and preserves startup cache info', async () => {
|
|
92
111
|
const { pi } = createMockPi();
|
|
93
112
|
const { loadStartupCacheState } = await loadHooksModule();
|
|
94
113
|
|
|
@@ -148,41 +167,89 @@ describe('openrouter hooks', () => {
|
|
|
148
167
|
expect(pi.on.mock.calls.filter(([event]) => event === 'session_shutdown')).toHaveLength(2);
|
|
149
168
|
});
|
|
150
169
|
|
|
151
|
-
it('
|
|
170
|
+
it('sets startup usage status from the local burn-rate helper and keeps cache notifications', async () => {
|
|
152
171
|
const { handlers, pi } = createMockPi();
|
|
153
172
|
const ctx = createMockContext();
|
|
154
173
|
const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
|
|
155
174
|
|
|
175
|
+
mocks.loadOpenRouterStatusBar.mockResolvedValue({
|
|
176
|
+
kind: 'ready',
|
|
177
|
+
text: 'OR $2.14 today · 1.3x 30d avg',
|
|
178
|
+
});
|
|
179
|
+
|
|
156
180
|
initializeSessionState();
|
|
157
181
|
installOpenRouterHooks(pi as any, {
|
|
158
182
|
info: { count: 5, age: '3 minutes' },
|
|
159
183
|
});
|
|
160
184
|
|
|
161
|
-
handlers.get('session_start')({ reason: 'startup' }, ctx);
|
|
185
|
+
await handlers.get('session_start')({ reason: 'startup' }, ctx);
|
|
162
186
|
|
|
163
|
-
expect(
|
|
187
|
+
expect(mocks.loadOpenRouterStatusBar).toHaveBeenCalledTimes(1);
|
|
188
|
+
await vi.waitFor(() => {
|
|
189
|
+
expect(ctx.ui.setStatus).toHaveBeenCalledWith(
|
|
190
|
+
'openrouter',
|
|
191
|
+
'dim:OR $2.14 today · 1.3x 30d avg',
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
expect(String(ctx.ui.setStatus.mock.calls[0]?.[1] ?? '')).not.toContain('models');
|
|
164
195
|
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
165
196
|
'OpenRouter: 5 models loaded from cache (3 minutes old). Run /openrouter models-sync to refresh.',
|
|
166
197
|
'info',
|
|
167
198
|
);
|
|
168
199
|
});
|
|
169
200
|
|
|
170
|
-
it('
|
|
201
|
+
it('clears stale startup status asynchronously when cached models exist but local spend is empty', async () => {
|
|
171
202
|
const { handlers, pi } = createMockPi();
|
|
172
203
|
const ctx = createMockContext();
|
|
173
204
|
const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
|
|
205
|
+
const statusLoad = createDeferredPromise<{ kind: 'ready'; text: string } | { kind: 'empty' }>();
|
|
206
|
+
|
|
207
|
+
mocks.loadOpenRouterStatusBar.mockReturnValue(statusLoad.promise);
|
|
208
|
+
|
|
209
|
+
initializeSessionState();
|
|
210
|
+
installOpenRouterHooks(pi as any, {
|
|
211
|
+
info: { count: 5, age: '3 minutes' },
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await handlers.get('session_start')({ reason: 'startup' }, ctx);
|
|
215
|
+
|
|
216
|
+
expect(mocks.loadOpenRouterStatusBar).toHaveBeenCalledTimes(1);
|
|
217
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
218
|
+
'OpenRouter: 5 models loaded from cache (3 minutes old). Run /openrouter models-sync to refresh.',
|
|
219
|
+
'info',
|
|
220
|
+
);
|
|
221
|
+
expect(ctx.ui.setStatus).not.toHaveBeenCalled();
|
|
222
|
+
|
|
223
|
+
statusLoad.resolve({ kind: 'empty' });
|
|
224
|
+
|
|
225
|
+
await vi.waitFor(() => {
|
|
226
|
+
expect(ctx.ui.setStatus).toHaveBeenCalledWith('openrouter', undefined);
|
|
227
|
+
});
|
|
228
|
+
expect(ctx.ui.theme.fg).not.toHaveBeenCalled();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('preserves the existing startup status when the usage helper fails open', async () => {
|
|
232
|
+
const { handlers, pi } = createMockPi();
|
|
233
|
+
const ctx = createMockContext();
|
|
234
|
+
const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
|
|
235
|
+
|
|
236
|
+
mocks.loadOpenRouterStatusBar.mockResolvedValue({ kind: 'failed' });
|
|
174
237
|
|
|
175
238
|
initializeSessionState();
|
|
176
239
|
installOpenRouterHooks(pi as any, {
|
|
177
240
|
warning: 'OpenRouter: cached models found but failed to register: mapper failed',
|
|
178
241
|
});
|
|
179
242
|
|
|
180
|
-
handlers.get('session_start')({ reason: 'startup' }, ctx);
|
|
243
|
+
await handlers.get('session_start')({ reason: 'startup' }, ctx);
|
|
181
244
|
|
|
245
|
+
expect(mocks.loadOpenRouterStatusBar).toHaveBeenCalledTimes(1);
|
|
182
246
|
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
183
247
|
'OpenRouter: cached models found but failed to register: mapper failed',
|
|
184
248
|
'warning',
|
|
185
249
|
);
|
|
250
|
+
await Promise.resolve();
|
|
251
|
+
expect(ctx.ui.setStatus).not.toHaveBeenCalled();
|
|
252
|
+
expect(ctx.ui.theme.fg).not.toHaveBeenCalled();
|
|
186
253
|
});
|
|
187
254
|
|
|
188
255
|
it('keeps before_provider_request session tagging behavior unchanged through the installed hook', async () => {
|
|
@@ -214,13 +281,20 @@ describe('openrouter hooks', () => {
|
|
|
214
281
|
});
|
|
215
282
|
});
|
|
216
283
|
|
|
217
|
-
it('
|
|
284
|
+
it('refreshes the usage status after a successful OpenRouter turn_end local write without blocking the hook', async () => {
|
|
218
285
|
const { handlers, pi } = createMockPi();
|
|
219
286
|
const ctx = createMockContext();
|
|
220
287
|
const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
|
|
221
288
|
const randomUuidSpy = vi
|
|
222
289
|
.spyOn(globalThis.crypto, 'randomUUID')
|
|
223
290
|
.mockReturnValue('11111111-1111-4111-8111-111111111111');
|
|
291
|
+
const writeLocalUsageDeferred = createDeferredPromise<void>();
|
|
292
|
+
|
|
293
|
+
mocks.writeLocalUsage.mockReturnValue(writeLocalUsageDeferred.promise);
|
|
294
|
+
mocks.loadOpenRouterStatusBar.mockResolvedValue({
|
|
295
|
+
kind: 'ready',
|
|
296
|
+
text: 'OR $1.25 today · 30.0x 30d avg',
|
|
297
|
+
});
|
|
224
298
|
|
|
225
299
|
initializeSessionState();
|
|
226
300
|
installOpenRouterHooks(pi as any, {});
|
|
@@ -258,19 +332,198 @@ describe('openrouter hooks', () => {
|
|
|
258
332
|
cost: 1.25,
|
|
259
333
|
}),
|
|
260
334
|
);
|
|
335
|
+
expect(mocks.loadOpenRouterStatusBar).not.toHaveBeenCalled();
|
|
336
|
+
expect(ctx.ui.setStatus).not.toHaveBeenCalled();
|
|
337
|
+
|
|
338
|
+
writeLocalUsageDeferred.resolve();
|
|
339
|
+
|
|
340
|
+
await vi.waitFor(() => {
|
|
341
|
+
expect(mocks.loadOpenRouterStatusBar).toHaveBeenCalledTimes(1);
|
|
342
|
+
});
|
|
343
|
+
const writeCallOrder = mocks.writeLocalUsage.mock.invocationCallOrder[0] ?? 0;
|
|
344
|
+
const refreshCallOrder = mocks.loadOpenRouterStatusBar.mock.invocationCallOrder[0] ?? 0;
|
|
345
|
+
expect(writeCallOrder).toBeLessThan(refreshCallOrder);
|
|
346
|
+
await vi.waitFor(() => {
|
|
347
|
+
expect(ctx.ui.setStatus).toHaveBeenCalledWith(
|
|
348
|
+
'openrouter',
|
|
349
|
+
'dim:OR $1.25 today · 30.0x 30d avg',
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
expect(String(ctx.ui.setStatus.mock.calls[0]?.[1] ?? '')).not.toContain('models');
|
|
261
353
|
|
|
262
354
|
randomUuidSpy.mockRestore();
|
|
263
355
|
});
|
|
264
356
|
|
|
265
|
-
it('
|
|
357
|
+
it('does not write local usage or update status for non-OpenRouter turns', async () => {
|
|
266
358
|
const { handlers, pi } = createMockPi();
|
|
359
|
+
const ctx = createMockContext();
|
|
267
360
|
const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
|
|
268
361
|
|
|
362
|
+
mocks.isOpenRouterRequest.mockReturnValue(false);
|
|
363
|
+
mocks.loadOpenRouterStatusBar.mockResolvedValue({
|
|
364
|
+
kind: 'ready',
|
|
365
|
+
text: 'OR $9.99 today · 9.9x 30d avg',
|
|
366
|
+
});
|
|
367
|
+
|
|
269
368
|
initializeSessionState();
|
|
270
369
|
installOpenRouterHooks(pi as any, {});
|
|
271
370
|
|
|
371
|
+
await handlers.get('turn_end')(
|
|
372
|
+
{
|
|
373
|
+
url: 'https://api.anthropic.com/v1/messages',
|
|
374
|
+
message: {
|
|
375
|
+
model: 'claude-sonnet-4',
|
|
376
|
+
responseId: 'resp-2',
|
|
377
|
+
usage: {
|
|
378
|
+
input: 4,
|
|
379
|
+
output: 2,
|
|
380
|
+
cost: { total: 0.42 },
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
ctx,
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
expect(mocks.writeLocalUsage).not.toHaveBeenCalled();
|
|
388
|
+
expect(mocks.loadOpenRouterStatusBar).not.toHaveBeenCalled();
|
|
389
|
+
expect(ctx.ui.setStatus).not.toHaveBeenCalled();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('clears stale status after a turn when the usage helper reports empty local spend', async () => {
|
|
393
|
+
const { handlers, pi } = createMockPi();
|
|
394
|
+
const ctx = createMockContext();
|
|
395
|
+
const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
|
|
396
|
+
|
|
397
|
+
mocks.loadOpenRouterStatusBar.mockResolvedValue({ kind: 'empty' });
|
|
398
|
+
|
|
399
|
+
initializeSessionState();
|
|
400
|
+
installOpenRouterHooks(pi as any, {});
|
|
401
|
+
|
|
402
|
+
await handlers.get('turn_end')(
|
|
403
|
+
{
|
|
404
|
+
url: 'https://openrouter.ai/api/v1/chat/completions',
|
|
405
|
+
message: {
|
|
406
|
+
model: 'openrouter/anthropic/claude-sonnet-4',
|
|
407
|
+
responseId: 'resp-3',
|
|
408
|
+
usage: {
|
|
409
|
+
input: 1,
|
|
410
|
+
output: 1,
|
|
411
|
+
cost: { total: 0.01 },
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
ctx,
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
await vi.waitFor(() => {
|
|
419
|
+
expect(ctx.ui.setStatus).toHaveBeenCalledWith('openrouter', undefined);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('preserves the existing status after a turn when the usage helper fails open', async () => {
|
|
424
|
+
const { handlers, pi } = createMockPi();
|
|
425
|
+
const ctx = createMockContext();
|
|
426
|
+
const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
|
|
427
|
+
|
|
428
|
+
mocks.loadOpenRouterStatusBar.mockResolvedValue({ kind: 'failed' });
|
|
429
|
+
|
|
430
|
+
initializeSessionState();
|
|
431
|
+
installOpenRouterHooks(pi as any, {});
|
|
432
|
+
|
|
433
|
+
await handlers.get('turn_end')(
|
|
434
|
+
{
|
|
435
|
+
url: 'https://openrouter.ai/api/v1/chat/completions',
|
|
436
|
+
message: {
|
|
437
|
+
model: 'openrouter/anthropic/claude-sonnet-4',
|
|
438
|
+
responseId: 'resp-4',
|
|
439
|
+
usage: {
|
|
440
|
+
input: 1,
|
|
441
|
+
output: 1,
|
|
442
|
+
cost: { total: 0.01 },
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
ctx,
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
await Promise.resolve();
|
|
450
|
+
expect(ctx.ui.setStatus).not.toHaveBeenCalled();
|
|
451
|
+
expect(ctx.ui.theme.fg).not.toHaveBeenCalled();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('refreshes the status at UTC midnight and reschedules while the session stays active', async () => {
|
|
455
|
+
vi.useFakeTimers();
|
|
456
|
+
vi.setSystemTime(new Date('2026-05-22T23:59:50.000Z'));
|
|
457
|
+
|
|
458
|
+
const { handlers, pi } = createMockPi();
|
|
459
|
+
const ctx = createMockContext();
|
|
460
|
+
const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
|
|
461
|
+
|
|
462
|
+
mocks.loadOpenRouterStatusBar
|
|
463
|
+
.mockResolvedValueOnce({ kind: 'ready', text: 'OR $4.50 today · 2.0x 30d avg' })
|
|
464
|
+
.mockResolvedValueOnce({ kind: 'ready', text: 'OR $0.25 today · 0.2x 30d avg' })
|
|
465
|
+
.mockResolvedValueOnce({ kind: 'ready', text: 'OR $0.30 today · 0.2x 30d avg' });
|
|
466
|
+
|
|
467
|
+
initializeSessionState();
|
|
468
|
+
installOpenRouterHooks(pi as any, {});
|
|
469
|
+
|
|
470
|
+
await handlers.get('session_start')({ reason: 'startup' }, ctx);
|
|
471
|
+
|
|
472
|
+
await vi.waitFor(() => {
|
|
473
|
+
expect(ctx.ui.setStatus).toHaveBeenCalledWith(
|
|
474
|
+
'openrouter',
|
|
475
|
+
'dim:OR $4.50 today · 2.0x 30d avg',
|
|
476
|
+
);
|
|
477
|
+
});
|
|
478
|
+
expect(mocks.loadOpenRouterStatusBar).toHaveBeenCalledTimes(1);
|
|
479
|
+
|
|
480
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
481
|
+
|
|
482
|
+
await vi.waitFor(() => {
|
|
483
|
+
expect(mocks.loadOpenRouterStatusBar).toHaveBeenCalledTimes(2);
|
|
484
|
+
});
|
|
485
|
+
expect(ctx.ui.setStatus).toHaveBeenLastCalledWith(
|
|
486
|
+
'openrouter',
|
|
487
|
+
'dim:OR $0.25 today · 0.2x 30d avg',
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000);
|
|
491
|
+
|
|
492
|
+
await vi.waitFor(() => {
|
|
493
|
+
expect(mocks.loadOpenRouterStatusBar).toHaveBeenCalledTimes(3);
|
|
494
|
+
});
|
|
495
|
+
expect(ctx.ui.setStatus).toHaveBeenLastCalledWith(
|
|
496
|
+
'openrouter',
|
|
497
|
+
'dim:OR $0.30 today · 0.2x 30d avg',
|
|
498
|
+
);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('clears the UTC-midnight rollover timer on session shutdown', async () => {
|
|
502
|
+
vi.useFakeTimers();
|
|
503
|
+
vi.setSystemTime(new Date('2026-05-22T23:59:50.000Z'));
|
|
504
|
+
|
|
505
|
+
const { handlers, pi } = createMockPi();
|
|
506
|
+
const ctx = createMockContext();
|
|
507
|
+
const { initializeSessionState, installOpenRouterHooks } = await loadHooksModule();
|
|
508
|
+
|
|
509
|
+
mocks.loadOpenRouterStatusBar.mockResolvedValue({
|
|
510
|
+
kind: 'ready',
|
|
511
|
+
text: 'OR $1.00 today · 1.0x 30d avg',
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
initializeSessionState();
|
|
515
|
+
installOpenRouterHooks(pi as any, {});
|
|
516
|
+
|
|
517
|
+
await handlers.get('session_start')({ reason: 'startup' }, ctx);
|
|
518
|
+
await vi.waitFor(() => {
|
|
519
|
+
expect(mocks.loadOpenRouterStatusBar).toHaveBeenCalledTimes(1);
|
|
520
|
+
});
|
|
521
|
+
|
|
272
522
|
handlers.get('session_shutdown')();
|
|
273
523
|
|
|
524
|
+
await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000);
|
|
525
|
+
|
|
526
|
+
expect(mocks.loadOpenRouterStatusBar).toHaveBeenCalledTimes(1);
|
|
274
527
|
expect(mocks.stopBackgroundRefresh).toHaveBeenCalledTimes(1);
|
|
275
528
|
});
|
|
276
529
|
});
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { setLocalUsageDir, dedupeLocalUsageEvents } from '../local-usage.js';
|
|
6
|
+
import {
|
|
7
|
+
calculateOpenRouterStatusStats,
|
|
8
|
+
formatOpenRouterStatusBar,
|
|
9
|
+
loadOpenRouterStatusBar,
|
|
10
|
+
loadOpenRouterStatusStats,
|
|
11
|
+
type OpenRouterStatusStats,
|
|
12
|
+
} from '../status-bar.js';
|
|
13
|
+
import type { LocalUsageEvent } from '../types.js';
|
|
14
|
+
|
|
15
|
+
let testDir: string;
|
|
16
|
+
|
|
17
|
+
function createLocalUsageEvent(
|
|
18
|
+
id: string,
|
|
19
|
+
completedAt: string,
|
|
20
|
+
cost: number,
|
|
21
|
+
overrides: Partial<LocalUsageEvent> = {},
|
|
22
|
+
): LocalUsageEvent {
|
|
23
|
+
return {
|
|
24
|
+
id,
|
|
25
|
+
generationId: `${id}-generation`,
|
|
26
|
+
sessionId: 'session-test',
|
|
27
|
+
completedAt,
|
|
28
|
+
requests: 1,
|
|
29
|
+
model: 'openrouter/anthropic/claude-sonnet-4',
|
|
30
|
+
provider: 'anthropic',
|
|
31
|
+
promptTokens: 10,
|
|
32
|
+
completionTokens: 5,
|
|
33
|
+
cost,
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function writeDailyFile(
|
|
39
|
+
dateUtc: string,
|
|
40
|
+
rows: Array<LocalUsageEvent | string>,
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
const content = rows
|
|
43
|
+
.map((row) => (typeof row === 'string' ? row : JSON.stringify(row)))
|
|
44
|
+
.join('\n');
|
|
45
|
+
await fs.writeFile(path.join(testDir, `${dateUtc}.jsonl`), `${content}\n`, 'utf8');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
beforeEach(async () => {
|
|
49
|
+
testDir = path.join(
|
|
50
|
+
os.tmpdir(),
|
|
51
|
+
`pi-openrouter-status-bar-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
52
|
+
);
|
|
53
|
+
await fs.mkdir(testDir, { recursive: true });
|
|
54
|
+
setLocalUsageDir(testDir);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(async () => {
|
|
58
|
+
setLocalUsageDir(null);
|
|
59
|
+
vi.restoreAllMocks();
|
|
60
|
+
vi.doUnmock('../local-usage.js');
|
|
61
|
+
vi.doUnmock('../status-bar.js');
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
await fs.rm(testDir, { recursive: true, force: true });
|
|
65
|
+
} catch {
|
|
66
|
+
// Ignore cleanup errors.
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('calculateOpenRouterStatusStats', () => {
|
|
71
|
+
it('returns null for no local events', () => {
|
|
72
|
+
expect(calculateOpenRouterStatusStats([], '2026-05-22')).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns null when the full 30-day window totals zero local spend', () => {
|
|
76
|
+
expect(
|
|
77
|
+
calculateOpenRouterStatusStats(
|
|
78
|
+
[
|
|
79
|
+
createLocalUsageEvent('today-zero', '2026-05-22T09:15:00.000Z', 0),
|
|
80
|
+
createLocalUsageEvent('older-zero', '2026-05-12T09:15:00.000Z', 0),
|
|
81
|
+
],
|
|
82
|
+
'2026-05-22',
|
|
83
|
+
),
|
|
84
|
+
).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('includes only UTC-today spend and divides by exactly 30 calendar days', () => {
|
|
88
|
+
const stats = calculateOpenRouterStatusStats(
|
|
89
|
+
[
|
|
90
|
+
createLocalUsageEvent('today', '2026-05-22T09:15:00.000Z', 3),
|
|
91
|
+
createLocalUsageEvent('yesterday', '2026-05-21T09:15:00.000Z', 9),
|
|
92
|
+
],
|
|
93
|
+
'2026-05-22',
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(stats).toEqual({
|
|
97
|
+
todayLocalSpend: 3,
|
|
98
|
+
averageLocalDailySpendLast30Days: 0.4,
|
|
99
|
+
burnRateMultiplier: 7.5,
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('uses only the today-29 through today window', () => {
|
|
104
|
+
const stats = calculateOpenRouterStatusStats(
|
|
105
|
+
[
|
|
106
|
+
createLocalUsageEvent('old', '2026-04-22T12:00:00.000Z', 100),
|
|
107
|
+
createLocalUsageEvent('boundary', '2026-04-23T12:00:00.000Z', 6),
|
|
108
|
+
createLocalUsageEvent('recent', '2026-05-21T12:00:00.000Z', 3),
|
|
109
|
+
createLocalUsageEvent('today', '2026-05-22T12:00:00.000Z', 1.5),
|
|
110
|
+
],
|
|
111
|
+
'2026-05-22',
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(stats).toEqual({
|
|
115
|
+
todayLocalSpend: 1.5,
|
|
116
|
+
averageLocalDailySpendLast30Days: 0.35,
|
|
117
|
+
burnRateMultiplier: 1.5 / 0.35,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('deduplicates event ids exactly once across the full 30-day window', () => {
|
|
122
|
+
const events = [
|
|
123
|
+
createLocalUsageEvent('duplicate-id', '2026-05-20T12:00:00.000Z', 1),
|
|
124
|
+
createLocalUsageEvent('duplicate-id', '2026-05-22T12:00:00.000Z', 99),
|
|
125
|
+
createLocalUsageEvent('unique-id', '2026-05-22T13:00:00.000Z', 2),
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
expect(dedupeLocalUsageEvents(events)).toHaveLength(2);
|
|
129
|
+
expect(calculateOpenRouterStatusStats(events, '2026-05-22')).toEqual({
|
|
130
|
+
todayLocalSpend: 2,
|
|
131
|
+
averageLocalDailySpendLast30Days: 0.1,
|
|
132
|
+
burnRateMultiplier: 20,
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('formatOpenRouterStatusBar', () => {
|
|
138
|
+
it('formats the representative status text exactly', () => {
|
|
139
|
+
const stats: OpenRouterStatusStats = {
|
|
140
|
+
todayLocalSpend: 2.14,
|
|
141
|
+
averageLocalDailySpendLast30Days: 1.64,
|
|
142
|
+
burnRateMultiplier: 1.3,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
expect(formatOpenRouterStatusBar(stats)).toBe('OR $2.14 today · 1.3x 30d avg');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('formats $0.00 today · 0.0x 30d avg when prior 30-day spend exists but today spend is zero', () => {
|
|
149
|
+
const stats: OpenRouterStatusStats = {
|
|
150
|
+
todayLocalSpend: 0,
|
|
151
|
+
averageLocalDailySpendLast30Days: 0.5,
|
|
152
|
+
burnRateMultiplier: 0,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
expect(formatOpenRouterStatusBar(stats)).toBe('OR $0.00 today · 0.0x 30d avg');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('omits the multiplier when given a stats object with no denominator', () => {
|
|
159
|
+
expect(
|
|
160
|
+
formatOpenRouterStatusBar({
|
|
161
|
+
todayLocalSpend: 2.14,
|
|
162
|
+
averageLocalDailySpendLast30Days: 0,
|
|
163
|
+
burnRateMultiplier: null,
|
|
164
|
+
}),
|
|
165
|
+
).toBe('OR $2.14 today');
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('loadOpenRouterStatusStats and loadOpenRouterStatusBar', () => {
|
|
170
|
+
it('returns an empty result for empty local usage data', async () => {
|
|
171
|
+
const now = new Date('2026-05-22T12:00:00.000Z');
|
|
172
|
+
|
|
173
|
+
await expect(loadOpenRouterStatusStats(now)).resolves.toBeNull();
|
|
174
|
+
await expect(loadOpenRouterStatusBar(now)).resolves.toEqual({ kind: 'empty' });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('returns an empty result when the 30-day window has only zero-cost local rows', async () => {
|
|
178
|
+
await writeDailyFile('2026-05-12', [
|
|
179
|
+
createLocalUsageEvent('older-zero', '2026-05-12T12:00:00.000Z', 0),
|
|
180
|
+
]);
|
|
181
|
+
await writeDailyFile('2026-05-22', [
|
|
182
|
+
createLocalUsageEvent('today-zero', '2026-05-22T12:00:00.000Z', 0),
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
const now = new Date('2026-05-22T12:00:00.000Z');
|
|
186
|
+
|
|
187
|
+
await expect(loadOpenRouterStatusStats(now)).resolves.toBeNull();
|
|
188
|
+
await expect(loadOpenRouterStatusBar(now)).resolves.toEqual({ kind: 'empty' });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('requests local usage only from today-29 through today', async () => {
|
|
192
|
+
vi.resetModules();
|
|
193
|
+
const actualLocalUsage =
|
|
194
|
+
await vi.importActual<typeof import('../local-usage.js')>('../local-usage.js');
|
|
195
|
+
const readLocalUsage = vi
|
|
196
|
+
.fn()
|
|
197
|
+
.mockResolvedValue([
|
|
198
|
+
createLocalUsageEvent('boundary', '2026-04-23T12:00:00.000Z', 1),
|
|
199
|
+
createLocalUsageEvent('today', '2026-05-22T12:00:00.000Z', 2),
|
|
200
|
+
]);
|
|
201
|
+
|
|
202
|
+
vi.doMock('../local-usage.js', () => ({
|
|
203
|
+
...actualLocalUsage,
|
|
204
|
+
readLocalUsage,
|
|
205
|
+
}));
|
|
206
|
+
|
|
207
|
+
const { loadOpenRouterStatusStats: loadMockedStats } = await import('../status-bar.js');
|
|
208
|
+
|
|
209
|
+
await expect(loadMockedStats(new Date('2026-05-22T12:00:00.000Z'))).resolves.toEqual({
|
|
210
|
+
todayLocalSpend: 2,
|
|
211
|
+
averageLocalDailySpendLast30Days: 0.1,
|
|
212
|
+
burnRateMultiplier: 20,
|
|
213
|
+
});
|
|
214
|
+
expect(readLocalUsage).toHaveBeenCalledTimes(1);
|
|
215
|
+
expect(readLocalUsage).toHaveBeenCalledWith({
|
|
216
|
+
fromDateUtc: '2026-04-23',
|
|
217
|
+
toDateUtc: '2026-05-22',
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('tolerates missing files and malformed rows while returning a ready status', async () => {
|
|
222
|
+
await writeDailyFile('2026-05-10', [
|
|
223
|
+
createLocalUsageEvent('older', '2026-05-10T12:00:00.000Z', 4),
|
|
224
|
+
'{not json}',
|
|
225
|
+
]);
|
|
226
|
+
await writeDailyFile('2026-05-22', [
|
|
227
|
+
createLocalUsageEvent('today', '2026-05-22T12:00:00.000Z', 2),
|
|
228
|
+
'',
|
|
229
|
+
]);
|
|
230
|
+
|
|
231
|
+
const now = new Date('2026-05-22T12:00:00.000Z');
|
|
232
|
+
|
|
233
|
+
await expect(loadOpenRouterStatusStats(now)).resolves.toEqual({
|
|
234
|
+
todayLocalSpend: 2,
|
|
235
|
+
averageLocalDailySpendLast30Days: 0.2,
|
|
236
|
+
burnRateMultiplier: 10,
|
|
237
|
+
});
|
|
238
|
+
await expect(loadOpenRouterStatusBar(now)).resolves.toEqual({
|
|
239
|
+
kind: 'ready',
|
|
240
|
+
text: 'OR $2.00 today · 10.0x 30d avg',
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('returns a failed result when the local usage read path throws unexpectedly', async () => {
|
|
245
|
+
vi.resetModules();
|
|
246
|
+
|
|
247
|
+
const readLocalUsage = vi.fn().mockRejectedValue(new Error('disk exploded'));
|
|
248
|
+
vi.doMock('../local-usage.js', async () => {
|
|
249
|
+
const actual = await vi.importActual<typeof import('../local-usage.js')>('../local-usage.js');
|
|
250
|
+
return {
|
|
251
|
+
...actual,
|
|
252
|
+
readLocalUsage,
|
|
253
|
+
};
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const { loadOpenRouterStatusBar: loadMockedBar } = await import('../status-bar.js');
|
|
257
|
+
|
|
258
|
+
await expect(loadMockedBar(new Date('2026-05-22T12:00:00.000Z'))).resolves.toEqual({
|
|
259
|
+
kind: 'failed',
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -10,9 +10,13 @@ import { writeLocalUsage, type LocalUsageEvent } from './local-usage.js';
|
|
|
10
10
|
import { loadCache, getCacheAgeMs, formatDuration } from './models/cache.js';
|
|
11
11
|
import { mapOpenRouterModels } from './models/mapper.js';
|
|
12
12
|
import { includeBuiltinRouterModels, isSyncEnabled } from './models/sync.js';
|
|
13
|
+
import { loadOpenRouterStatusBar } from './status-bar.js';
|
|
13
14
|
|
|
14
15
|
let sessionState: SessionState | null = null;
|
|
15
16
|
let sessionTrackingInstalled = false;
|
|
17
|
+
let openRouterStatusRolloverTimer: ReturnType<typeof setTimeout> | null = null;
|
|
18
|
+
|
|
19
|
+
type StatusContext = Pick<ExtensionContext, 'hasUI' | 'ui'>;
|
|
16
20
|
|
|
17
21
|
export interface StartupCacheState {
|
|
18
22
|
info?: {
|
|
@@ -129,15 +133,12 @@ function installSessionTaggingHook(pi: Pick<ExtensionAPI, 'on'>): void {
|
|
|
129
133
|
}
|
|
130
134
|
|
|
131
135
|
function installLocalUsageHook(pi: Pick<ExtensionAPI, 'on'>): void {
|
|
132
|
-
pi.on('turn_end',
|
|
133
|
-
|
|
136
|
+
pi.on('turn_end', (event, ctx) => {
|
|
137
|
+
captureLocalUsage(event as unknown, ctx);
|
|
134
138
|
});
|
|
135
139
|
}
|
|
136
140
|
|
|
137
|
-
|
|
138
|
-
event: unknown,
|
|
139
|
-
ctx: { sessionManager: { getSessionId(): string } },
|
|
140
|
-
): Promise<void> {
|
|
141
|
+
function captureLocalUsage(event: unknown, ctx: ExtensionContext): void {
|
|
141
142
|
try {
|
|
142
143
|
const turnEvent = event as Record<string, unknown>;
|
|
143
144
|
|
|
@@ -184,17 +185,77 @@ async function captureLocalUsage(
|
|
|
184
185
|
cost: usage.cost?.total ?? 0,
|
|
185
186
|
};
|
|
186
187
|
|
|
187
|
-
writeLocalUsage(localEvent)
|
|
188
|
+
void writeLocalUsage(localEvent)
|
|
189
|
+
.then(() => refreshOpenRouterUsageStatus(ctx))
|
|
190
|
+
.catch(() => {});
|
|
188
191
|
} catch {
|
|
189
192
|
// Fail open - silently ignore errors
|
|
190
193
|
}
|
|
191
194
|
}
|
|
192
195
|
|
|
196
|
+
async function refreshOpenRouterUsageStatus(ctx: StatusContext): Promise<void> {
|
|
197
|
+
if (!ctx.hasUI) return;
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const statusResult = await loadOpenRouterStatusBar();
|
|
201
|
+
|
|
202
|
+
switch (statusResult.kind) {
|
|
203
|
+
case 'ready':
|
|
204
|
+
ctx.ui.setStatus('openrouter', ctx.ui.theme.fg('dim', statusResult.text));
|
|
205
|
+
return;
|
|
206
|
+
case 'empty':
|
|
207
|
+
ctx.ui.setStatus('openrouter', undefined);
|
|
208
|
+
return;
|
|
209
|
+
case 'failed':
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// Fail open - preserve the existing status on unexpected refresh errors.
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function clearOpenRouterStatusRolloverTimer(): void {
|
|
218
|
+
if (openRouterStatusRolloverTimer !== null) {
|
|
219
|
+
clearTimeout(openRouterStatusRolloverTimer);
|
|
220
|
+
openRouterStatusRolloverTimer = null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function getMillisecondsUntilNextUtcMidnight(now: Date = new Date()): number {
|
|
225
|
+
const nextUtcMidnight = Date.UTC(
|
|
226
|
+
now.getUTCFullYear(),
|
|
227
|
+
now.getUTCMonth(),
|
|
228
|
+
now.getUTCDate() + 1,
|
|
229
|
+
0,
|
|
230
|
+
0,
|
|
231
|
+
0,
|
|
232
|
+
0,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
return Math.max(0, nextUtcMidnight - now.getTime());
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function scheduleOpenRouterStatusRollover(ctx: StatusContext): void {
|
|
239
|
+
clearOpenRouterStatusRolloverTimer();
|
|
240
|
+
|
|
241
|
+
if (!ctx.hasUI) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
openRouterStatusRolloverTimer = setTimeout(() => {
|
|
246
|
+
openRouterStatusRolloverTimer = null;
|
|
247
|
+
void refreshOpenRouterUsageStatus(ctx).catch(() => {});
|
|
248
|
+
scheduleOpenRouterStatusRollover(ctx);
|
|
249
|
+
}, getMillisecondsUntilNextUtcMidnight());
|
|
250
|
+
openRouterStatusRolloverTimer.unref?.();
|
|
251
|
+
}
|
|
252
|
+
|
|
193
253
|
function installLifecycleHooks(
|
|
194
254
|
pi: Pick<ExtensionAPI, 'on'>,
|
|
195
255
|
startupState: StartupCacheState,
|
|
196
256
|
): void {
|
|
197
257
|
pi.on('session_shutdown', () => {
|
|
258
|
+
clearOpenRouterStatusRolloverTimer();
|
|
198
259
|
stopBackgroundRefresh();
|
|
199
260
|
sessionState?.reset();
|
|
200
261
|
});
|
|
@@ -213,10 +274,8 @@ function handleSessionStart(
|
|
|
213
274
|
|
|
214
275
|
if (!ctx.hasUI) return;
|
|
215
276
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
ctx.ui.setStatus('openrouter', ctx.ui.theme.fg('dim', statusText));
|
|
219
|
-
}
|
|
277
|
+
void refreshOpenRouterUsageStatus(ctx).catch(() => {});
|
|
278
|
+
scheduleOpenRouterStatusRollover(ctx);
|
|
220
279
|
|
|
221
280
|
if (event.reason === 'startup' && startupState.info) {
|
|
222
281
|
const notice = `OpenRouter: ${startupState.info.count} models loaded from cache (${startupState.info.age} old). Run /openrouter models-sync to refresh.`;
|
|
@@ -165,15 +165,9 @@ export async function readLocalUsage(options: ReadLocalUsageOptions): Promise<Lo
|
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
/**
|
|
168
|
-
*
|
|
169
|
-
* Deduplicates by id (first occurrence wins).
|
|
168
|
+
* Deduplicate local usage events by id (first occurrence wins).
|
|
170
169
|
*/
|
|
171
|
-
export function
|
|
172
|
-
if (events.length === 0) {
|
|
173
|
-
return createZeroAggregate();
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// Deduplicate by id
|
|
170
|
+
export function dedupeLocalUsageEvents(events: LocalUsageEvent[]): LocalUsageEvent[] {
|
|
177
171
|
const seen = new Set<string>();
|
|
178
172
|
const unique: LocalUsageEvent[] = [];
|
|
179
173
|
|
|
@@ -183,8 +177,19 @@ export function aggregateLocal(events: LocalUsageEvent[]): UsageAggregate {
|
|
|
183
177
|
unique.push(event);
|
|
184
178
|
}
|
|
185
179
|
|
|
186
|
-
|
|
187
|
-
|
|
180
|
+
return unique;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Aggregate local usage events into UsageAggregate.
|
|
185
|
+
* Deduplicates by id (first occurrence wins).
|
|
186
|
+
*/
|
|
187
|
+
export function aggregateLocal(events: LocalUsageEvent[]): UsageAggregate {
|
|
188
|
+
if (events.length === 0) {
|
|
189
|
+
return createZeroAggregate();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const result = dedupeLocalUsageEvents(events).reduce((acc, event) => {
|
|
188
193
|
acc.requests += event.requests ?? 1;
|
|
189
194
|
acc.promptTokens += event.promptTokens || 0;
|
|
190
195
|
acc.completionTokens += event.completionTokens || 0;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addUtcDays,
|
|
3
|
+
dedupeLocalUsageEvents,
|
|
4
|
+
getCurrentUtcDate,
|
|
5
|
+
getUtcDateFromTimestamp,
|
|
6
|
+
readLocalUsage,
|
|
7
|
+
} from './local-usage.js';
|
|
8
|
+
import type { LocalUsageEvent } from './types.js';
|
|
9
|
+
|
|
10
|
+
const STATUS_WINDOW_DAYS = 30;
|
|
11
|
+
const STATUS_WINDOW_LABEL = '30d avg';
|
|
12
|
+
const STATUS_PREFIX = 'OR';
|
|
13
|
+
const STATUS_SEPARATOR = ' · ';
|
|
14
|
+
|
|
15
|
+
export interface OpenRouterStatusStats {
|
|
16
|
+
todayLocalSpend: number;
|
|
17
|
+
averageLocalDailySpendLast30Days: number;
|
|
18
|
+
burnRateMultiplier: number | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type OpenRouterStatusBarLoadResult =
|
|
22
|
+
| { kind: 'ready'; text: string }
|
|
23
|
+
| { kind: 'empty' }
|
|
24
|
+
| { kind: 'failed' };
|
|
25
|
+
|
|
26
|
+
function getUtcDateForNow(now?: Date): string {
|
|
27
|
+
return now ? now.toISOString().slice(0, 10) : getCurrentUtcDate();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function calculateOpenRouterStatusStats(
|
|
31
|
+
events: LocalUsageEvent[],
|
|
32
|
+
nowUtcDate: string = getCurrentUtcDate(),
|
|
33
|
+
): OpenRouterStatusStats | null {
|
|
34
|
+
const windowStartUtc = addUtcDays(nowUtcDate, -(STATUS_WINDOW_DAYS - 1));
|
|
35
|
+
const uniqueEvents = dedupeLocalUsageEvents(events);
|
|
36
|
+
|
|
37
|
+
let todayLocalSpend = 0;
|
|
38
|
+
let totalLocalSpendInWindow = 0;
|
|
39
|
+
|
|
40
|
+
for (const event of uniqueEvents) {
|
|
41
|
+
const completedDateUtc = getUtcDateFromTimestamp(event.completedAt);
|
|
42
|
+
if (completedDateUtc < windowStartUtc || completedDateUtc > nowUtcDate) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const cost = event.cost ?? 0;
|
|
47
|
+
totalLocalSpendInWindow += cost;
|
|
48
|
+
|
|
49
|
+
if (completedDateUtc === nowUtcDate) {
|
|
50
|
+
todayLocalSpend += cost;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (totalLocalSpendInWindow <= 0) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const averageLocalDailySpendLast30Days = totalLocalSpendInWindow / STATUS_WINDOW_DAYS;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
todayLocalSpend,
|
|
62
|
+
averageLocalDailySpendLast30Days,
|
|
63
|
+
burnRateMultiplier:
|
|
64
|
+
averageLocalDailySpendLast30Days === 0
|
|
65
|
+
? null
|
|
66
|
+
: todayLocalSpend / averageLocalDailySpendLast30Days,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function formatOpenRouterStatusBar(stats: OpenRouterStatusStats): string {
|
|
71
|
+
const today = `${STATUS_PREFIX} $${stats.todayLocalSpend.toFixed(2)} today`;
|
|
72
|
+
if (stats.burnRateMultiplier === null) {
|
|
73
|
+
return today;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return `${today}${STATUS_SEPARATOR}${stats.burnRateMultiplier.toFixed(1)}x ${STATUS_WINDOW_LABEL}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function loadOpenRouterStatusStats(now?: Date): Promise<OpenRouterStatusStats | null> {
|
|
80
|
+
const todayUtc = getUtcDateForNow(now);
|
|
81
|
+
const fromDateUtc = addUtcDays(todayUtc, -(STATUS_WINDOW_DAYS - 1));
|
|
82
|
+
const events = await readLocalUsage({ fromDateUtc, toDateUtc: todayUtc });
|
|
83
|
+
|
|
84
|
+
return calculateOpenRouterStatusStats(events, todayUtc);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function loadOpenRouterStatusBar(now?: Date): Promise<OpenRouterStatusBarLoadResult> {
|
|
88
|
+
try {
|
|
89
|
+
const stats = await loadOpenRouterStatusStats(now);
|
|
90
|
+
if (!stats) {
|
|
91
|
+
return { kind: 'empty' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
kind: 'ready',
|
|
96
|
+
text: formatOpenRouterStatusBar(stats),
|
|
97
|
+
};
|
|
98
|
+
} catch {
|
|
99
|
+
return { kind: 'failed' };
|
|
100
|
+
}
|
|
101
|
+
}
|
package/package.json
CHANGED