@robhowley/pi-openrouter 0.9.1 → 0.11.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.
@@ -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
- it('loads cached startup models and preserves startup status text', async () => {
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('keeps session_start status and startup notifications unchanged', async () => {
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(ctx.ui.setStatus).toHaveBeenCalledWith('openrouter', 'dim:OpenRouter 5 models');
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('keeps startup warning notifications unchanged', async () => {
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('keeps turn_end local usage logging unchanged', async () => {
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('stops background refresh on session shutdown', async () => {
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
  });
@@ -1,6 +1,11 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import type { Model as SDKModel } from '@openrouter/sdk/models/index.js';
3
- import type { GetCurrentKeyData, ListData } from '@openrouter/sdk/models/operations/index.js';
3
+ import type {
4
+ CreateKeysData,
5
+ GetCurrentKeyData,
6
+ ListData,
7
+ UpdateKeysData,
8
+ } from '@openrouter/sdk/models/operations/index.js';
4
9
  import {
5
10
  normalizeOpenRouterModel,
6
11
  normalizeSdkKeyMetadata,
@@ -89,6 +94,58 @@ function createListKeyData(overrides: Partial<ListData> = {}): ListData {
89
94
  };
90
95
  }
91
96
 
97
+ function createCreateKeysData(overrides: Partial<CreateKeysData> = {}): CreateKeysData {
98
+ return {
99
+ byokUsage: 0,
100
+ byokUsageDaily: 0,
101
+ byokUsageMonthly: 0,
102
+ byokUsageWeekly: 0,
103
+ createdAt: '2026-05-22T00:00:00.000Z',
104
+ creatorUserId: null,
105
+ disabled: false,
106
+ hash: 'hash-create',
107
+ includeByokInLimit: true,
108
+ label: 'sk-or-v1-create',
109
+ limit: 100,
110
+ limitRemaining: 40,
111
+ limitReset: 'weekly',
112
+ name: 'Created Key',
113
+ updatedAt: null,
114
+ usage: 60,
115
+ usageDaily: 0,
116
+ usageMonthly: 0,
117
+ usageWeekly: 0,
118
+ workspaceId: 'ws-create',
119
+ ...overrides,
120
+ };
121
+ }
122
+
123
+ function createUpdateKeysData(overrides: Partial<UpdateKeysData> = {}): UpdateKeysData {
124
+ return {
125
+ byokUsage: 0,
126
+ byokUsageDaily: 0,
127
+ byokUsageMonthly: 0,
128
+ byokUsageWeekly: 0,
129
+ createdAt: '2026-05-22T00:00:00.000Z',
130
+ creatorUserId: null,
131
+ disabled: false,
132
+ hash: 'hash-update',
133
+ includeByokInLimit: false,
134
+ label: 'sk-or-v1-update',
135
+ limit: 100,
136
+ limitRemaining: 40,
137
+ limitReset: null,
138
+ name: 'Updated Key',
139
+ updatedAt: null,
140
+ usage: 60,
141
+ usageDaily: 0,
142
+ usageMonthly: 0,
143
+ usageWeekly: 0,
144
+ workspaceId: 'ws-update',
145
+ ...overrides,
146
+ };
147
+ }
148
+
92
149
  describe('sdkModelToOpenRouterModel', () => {
93
150
  it('normalizes SDK camelCase fields into canonical snake_case model shape', () => {
94
151
  const normalized = sdkModelToOpenRouterModel(
@@ -237,9 +294,9 @@ describe('normalizeSdkKeyMetadata', () => {
237
294
  remaining: 0,
238
295
  byok: 'excl',
239
296
  resetCadence: 'daily',
240
- hash: 'unknown',
241
297
  disabled: false,
242
298
  });
299
+ expect(normalized).not.toHaveProperty('hash');
243
300
  expect('limit' in normalized).toBe(true);
244
301
  expect('remaining' in normalized).toBe(true);
245
302
  });
@@ -258,9 +315,9 @@ describe('normalizeSdkKeyMetadata', () => {
258
315
  name: 'sk-or-v1-current',
259
316
  byok: '?',
260
317
  resetCadence: 'partial',
261
- hash: 'unknown',
262
318
  disabled: false,
263
319
  });
320
+ expect(normalized).not.toHaveProperty('hash');
264
321
  expect(normalized).not.toHaveProperty('limit');
265
322
  expect(normalized).not.toHaveProperty('remaining');
266
323
  });
@@ -285,4 +342,30 @@ describe('normalizeSdkKeyMetadata', () => {
285
342
  remaining: 40,
286
343
  });
287
344
  });
345
+
346
+ it('normalizes create responses with weekly resets', () => {
347
+ const normalized = normalizeSdkKeyMetadata(createCreateKeysData());
348
+
349
+ expect(normalized).toMatchObject({
350
+ name: 'Created Key',
351
+ label: 'sk-or-v1-create',
352
+ byok: 'incl',
353
+ resetCadence: 'weekly',
354
+ hash: 'hash-create',
355
+ disabled: false,
356
+ });
357
+ });
358
+
359
+ it('treats null limitReset in update responses as never', () => {
360
+ const normalized = normalizeSdkKeyMetadata(createUpdateKeysData());
361
+
362
+ expect(normalized).toMatchObject({
363
+ name: 'Updated Key',
364
+ label: 'sk-or-v1-update',
365
+ byok: 'excl',
366
+ resetCadence: 'never',
367
+ hash: 'hash-update',
368
+ disabled: false,
369
+ });
370
+ });
288
371
  });