@robhowley/pi-openrouter 0.9.0 → 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.
Files changed (39) hide show
  1. package/README.md +46 -5
  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 +529 -0
  8. package/extensions/openrouter/__tests__/index.test.ts +112 -363
  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/__tests__/status-bar.test.ts +262 -0
  15. package/extensions/openrouter/account-client.ts +11 -61
  16. package/extensions/openrouter/cache.ts +203 -91
  17. package/extensions/openrouter/client.ts +49 -3
  18. package/extensions/openrouter/commands.ts +555 -0
  19. package/extensions/openrouter/format.ts +7 -4
  20. package/extensions/openrouter/hooks.ts +288 -0
  21. package/extensions/openrouter/index.ts +13 -990
  22. package/extensions/openrouter/local-usage.ts +158 -30
  23. package/extensions/openrouter/models/__tests__/cache.test.ts +63 -2
  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__/sync.test.ts +156 -4
  27. package/extensions/openrouter/models/cache.ts +27 -2
  28. package/extensions/openrouter/models/mapper.ts +35 -69
  29. package/extensions/openrouter/models/override-commands.ts +434 -0
  30. package/extensions/openrouter/models/skip-hints.ts +19 -0
  31. package/extensions/openrouter/models/sync.ts +22 -10
  32. package/extensions/openrouter/models/types.ts +2 -1
  33. package/extensions/openrouter/normalizers.ts +128 -0
  34. package/extensions/openrouter/overlay.ts +19 -8
  35. package/extensions/openrouter/session-state.ts +110 -0
  36. package/extensions/openrouter/session.ts +16 -0
  37. package/extensions/openrouter/status-bar.ts +101 -0
  38. package/extensions/openrouter/types.ts +28 -9
  39. package/package.json +1 -1
@@ -1,5 +1,15 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { fetchUserModels, isConfigured, getApiKey, ApiError, AuthError } from '../client.js';
2
+ import {
3
+ fetchUserModels,
4
+ isConfigured,
5
+ getApiKey,
6
+ getModelSyncApiKey,
7
+ getUsageApiKey,
8
+ isConfiguredForModelSync,
9
+ isConfiguredForUsage,
10
+ ApiError,
11
+ AuthError,
12
+ } from '../client.js';
3
13
  import { restoreEnv } from './fixtures.js';
4
14
  import type { Mock } from 'vitest';
5
15
 
@@ -8,14 +18,31 @@ vi.mock('@openrouter/sdk/sdk/sdk.js', () => ({
8
18
  OpenRouter: vi.fn(),
9
19
  }));
10
20
 
21
+ /**
22
+ * Helper to set or clear OPENROUTER_API_KEY and OPENROUTER_MANAGEMENT_KEY.
23
+ * Pass undefined to delete the env var.
24
+ */
25
+ function setKeys(apiKey?: string, mgmtKey?: string) {
26
+ if (apiKey === undefined) {
27
+ delete process.env['OPENROUTER_API_KEY'];
28
+ } else {
29
+ process.env['OPENROUTER_API_KEY'] = apiKey;
30
+ }
31
+ if (mgmtKey === undefined) {
32
+ delete process.env['OPENROUTER_MANAGEMENT_KEY'];
33
+ } else {
34
+ process.env['OPENROUTER_MANAGEMENT_KEY'] = mgmtKey;
35
+ }
36
+ }
37
+
11
38
  /**
12
39
  * Factory function to create a minimal mock SDK client.
13
40
  * Reduces boilerplate from ~25 lines to 1 line per test.
14
41
  */
15
- function createMockSDKClient(overrides: { listForUser?: Mock } = {}) {
42
+ function createMockSDKClient(overrides: { listForUser?: Mock; getCredits?: Mock } = {}) {
16
43
  return {
17
44
  models: { listForUser: overrides.listForUser ?? vi.fn() },
18
- credits: {},
45
+ credits: { getCredits: overrides.getCredits ?? vi.fn() },
19
46
  analytics: {},
20
47
  chat: {},
21
48
  embeddings: {},
@@ -46,14 +73,14 @@ describe('fetchUserModels', () => {
46
73
  });
47
74
 
48
75
  it('should throw AuthError when API key not set', async () => {
49
- delete process.env['OPENROUTER_API_KEY'];
76
+ setKeys(undefined, undefined);
50
77
 
51
- await expect(fetchUserModels()).rejects.toThrow('OPENROUTER_API_KEY not set');
78
+ await expect(fetchUserModels()).rejects.toThrow('OpenRouter API key not configured');
52
79
  await expect(fetchUserModels()).rejects.toBeInstanceOf(AuthError);
53
80
  });
54
81
 
55
82
  it('should fetch models with SDK', async () => {
56
- process.env['OPENROUTER_API_KEY'] = 'test-key';
83
+ setKeys('test-key', undefined);
57
84
 
58
85
  const mockResponse = {
59
86
  data: [
@@ -81,7 +108,7 @@ describe('fetchUserModels', () => {
81
108
  });
82
109
 
83
110
  it('should throw ApiError on 401 unauthorized', async () => {
84
- process.env['OPENROUTER_API_KEY'] = 'invalid-key';
111
+ setKeys('invalid-key', undefined);
85
112
 
86
113
  const MockOpenRouter = vi.mocked((await import('@openrouter/sdk/sdk/sdk.js')).OpenRouter);
87
114
  const mockClient = createMockSDKClient({
@@ -96,7 +123,7 @@ describe('fetchUserModels', () => {
96
123
  });
97
124
 
98
125
  it('should throw ApiError on rate limit (429)', async () => {
99
- process.env['OPENROUTER_API_KEY'] = 'test-key';
126
+ setKeys('test-key', undefined);
100
127
 
101
128
  const MockOpenRouter = vi.mocked((await import('@openrouter/sdk/sdk/sdk.js')).OpenRouter);
102
129
  const mockClient = createMockSDKClient({
@@ -111,7 +138,7 @@ describe('fetchUserModels', () => {
111
138
  });
112
139
 
113
140
  it('should throw ApiError on server error', async () => {
114
- process.env['OPENROUTER_API_KEY'] = 'test-key';
141
+ setKeys('test-key', undefined);
115
142
 
116
143
  const MockOpenRouter = vi.mocked((await import('@openrouter/sdk/sdk/sdk.js')).OpenRouter);
117
144
  const mockClient = createMockSDKClient({
@@ -132,17 +159,17 @@ describe('isConfigured', () => {
132
159
  });
133
160
 
134
161
  it('should return true when API key is set', () => {
135
- process.env['OPENROUTER_API_KEY'] = 'test-key';
162
+ setKeys('test-key', undefined);
136
163
  expect(isConfigured()).toBe(true);
137
164
  });
138
165
 
139
166
  it('should return false when API key is not set', () => {
140
- delete process.env['OPENROUTER_API_KEY'];
167
+ setKeys(undefined, undefined);
141
168
  expect(isConfigured()).toBe(false);
142
169
  });
143
170
 
144
171
  it('should return false when API key is empty string', () => {
145
- process.env['OPENROUTER_API_KEY'] = '';
172
+ setKeys('', undefined);
146
173
  expect(isConfigured()).toBe(false);
147
174
  });
148
175
  });
@@ -153,17 +180,308 @@ describe('getApiKey', () => {
153
180
  });
154
181
 
155
182
  it('should return the API key when set', () => {
156
- process.env['OPENROUTER_API_KEY'] = 'test-key';
183
+ setKeys('test-key', undefined);
157
184
  expect(getApiKey()).toBe('test-key');
158
185
  });
159
186
 
160
187
  it('should return undefined when API key is not set', () => {
161
- delete process.env['OPENROUTER_API_KEY'];
188
+ setKeys(undefined, undefined);
162
189
  expect(getApiKey()).toBeUndefined();
163
190
  });
164
191
 
165
192
  it('should return empty string when API key is empty', () => {
166
- process.env['OPENROUTER_API_KEY'] = '';
193
+ setKeys('', undefined);
167
194
  expect(getApiKey()).toBe('');
168
195
  });
169
196
  });
197
+
198
+ // Phase 1 regression tests
199
+ describe('getModelSyncApiKey', () => {
200
+ beforeEach(() => {
201
+ restoreEnv();
202
+ });
203
+
204
+ it.each([
205
+ {
206
+ apiKey: 'api-key',
207
+ mgmtKey: 'mgmt-key',
208
+ expected: 'api-key',
209
+ desc: 'should return OPENROUTER_API_KEY when both keys are present',
210
+ },
211
+ {
212
+ apiKey: undefined,
213
+ mgmtKey: 'mgmt-key',
214
+ expected: 'mgmt-key',
215
+ desc: 'should return OPENROUTER_MANAGEMENT_KEY when only management key is present',
216
+ },
217
+ {
218
+ apiKey: undefined,
219
+ mgmtKey: undefined,
220
+ expected: undefined,
221
+ desc: 'should return undefined when no keys are present',
222
+ },
223
+ {
224
+ apiKey: '',
225
+ mgmtKey: 'mgmt-key',
226
+ expected: 'mgmt-key',
227
+ desc: 'should treat empty strings as absent',
228
+ },
229
+ {
230
+ apiKey: ' ',
231
+ mgmtKey: 'mgmt-key',
232
+ expected: 'mgmt-key',
233
+ desc: 'should treat whitespace-only strings as absent',
234
+ },
235
+ ])('$desc', ({ apiKey, mgmtKey, expected }) => {
236
+ setKeys(apiKey, mgmtKey);
237
+ expect(getModelSyncApiKey()).toBe(expected);
238
+ });
239
+ });
240
+
241
+ describe('getUsageApiKey', () => {
242
+ beforeEach(() => {
243
+ restoreEnv();
244
+ });
245
+
246
+ it.each([
247
+ {
248
+ apiKey: 'api-key',
249
+ mgmtKey: 'mgmt-key',
250
+ expected: 'mgmt-key',
251
+ desc: 'should return OPENROUTER_MANAGEMENT_KEY when both keys are present',
252
+ },
253
+ {
254
+ apiKey: 'api-key',
255
+ mgmtKey: undefined,
256
+ expected: 'api-key',
257
+ desc: 'should return OPENROUTER_API_KEY when only API key is present',
258
+ },
259
+ {
260
+ apiKey: undefined,
261
+ mgmtKey: undefined,
262
+ expected: undefined,
263
+ desc: 'should return undefined when no keys are present',
264
+ },
265
+ {
266
+ apiKey: 'api-key',
267
+ mgmtKey: '',
268
+ expected: 'api-key',
269
+ desc: 'should treat empty strings as absent',
270
+ },
271
+ {
272
+ apiKey: ' ',
273
+ mgmtKey: 'mgmt-key',
274
+ expected: 'mgmt-key',
275
+ desc: 'should treat whitespace-only strings as absent',
276
+ },
277
+ ])('$desc', ({ apiKey, mgmtKey, expected }) => {
278
+ setKeys(apiKey, mgmtKey);
279
+ expect(getUsageApiKey()).toBe(expected);
280
+ });
281
+ });
282
+
283
+ describe('isConfiguredForModelSync', () => {
284
+ beforeEach(() => {
285
+ restoreEnv();
286
+ });
287
+
288
+ it.each([
289
+ {
290
+ apiKey: 'api-key',
291
+ mgmtKey: undefined,
292
+ expected: true,
293
+ desc: 'should return true when API key is set',
294
+ },
295
+ {
296
+ apiKey: undefined,
297
+ mgmtKey: 'mgmt-key',
298
+ expected: true,
299
+ desc: 'should return true when management key is set',
300
+ },
301
+ {
302
+ apiKey: undefined,
303
+ mgmtKey: undefined,
304
+ expected: false,
305
+ desc: 'should return false when no keys are set',
306
+ },
307
+ ])('$desc', ({ apiKey, mgmtKey, expected }) => {
308
+ setKeys(apiKey, mgmtKey);
309
+ expect(isConfiguredForModelSync()).toBe(expected);
310
+ });
311
+ });
312
+
313
+ describe('isConfiguredForUsage', () => {
314
+ beforeEach(() => {
315
+ restoreEnv();
316
+ });
317
+
318
+ it.each([
319
+ {
320
+ apiKey: undefined,
321
+ mgmtKey: 'mgmt-key',
322
+ expected: true,
323
+ desc: 'should return true when management key is set',
324
+ },
325
+ {
326
+ apiKey: 'api-key',
327
+ mgmtKey: undefined,
328
+ expected: true,
329
+ desc: 'should return true when API key is set',
330
+ },
331
+ {
332
+ apiKey: undefined,
333
+ mgmtKey: undefined,
334
+ expected: false,
335
+ desc: 'should return false when no keys are set',
336
+ },
337
+ ])('$desc', ({ apiKey, mgmtKey, expected }) => {
338
+ setKeys(apiKey, mgmtKey);
339
+ expect(isConfiguredForUsage()).toBe(expected);
340
+ });
341
+ });
342
+
343
+ describe('fetchUserModels with management key fallback', () => {
344
+ beforeEach(() => {
345
+ vi.resetAllMocks();
346
+ restoreEnv();
347
+ });
348
+
349
+ it('should accept OPENROUTER_MANAGEMENT_KEY when only management key is present', async () => {
350
+ setKeys(undefined, 'mgmt-key');
351
+
352
+ const mockResponse = {
353
+ data: [
354
+ {
355
+ id: 'openai/gpt-4',
356
+ name: 'GPT-4',
357
+ context_length: 8192,
358
+ pricing: { prompt: '0.00003', completion: '0.00006' },
359
+ },
360
+ ],
361
+ };
362
+
363
+ const MockOpenRouter = vi.mocked((await import('@openrouter/sdk/sdk/sdk.js')).OpenRouter);
364
+ const mockClient = createMockSDKClient({
365
+ listForUser: vi.fn().mockResolvedValue(mockResponse),
366
+ });
367
+ MockOpenRouter.mockImplementation(() => mockClient as any);
368
+
369
+ const result = await fetchUserModels();
370
+ expect(result!.data).toHaveLength(1);
371
+ expect(mockClient.models.listForUser).toHaveBeenCalled();
372
+ // Verify management key was used
373
+ expect(MockOpenRouter).toHaveBeenCalledWith(expect.objectContaining({ apiKey: 'mgmt-key' }));
374
+ });
375
+
376
+ it('should prefer OPENROUTER_API_KEY when both keys are present', async () => {
377
+ setKeys('api-key', 'mgmt-key');
378
+
379
+ const mockResponse = {
380
+ data: [
381
+ {
382
+ id: 'openai/gpt-4',
383
+ name: 'GPT-4',
384
+ context_length: 8192,
385
+ pricing: { prompt: '0.00003', completion: '0.00006' },
386
+ },
387
+ ],
388
+ };
389
+
390
+ const MockOpenRouter = vi.mocked((await import('@openrouter/sdk/sdk/sdk.js')).OpenRouter);
391
+ const mockClient = createMockSDKClient({
392
+ listForUser: vi.fn().mockResolvedValue(mockResponse),
393
+ });
394
+ MockOpenRouter.mockImplementation(() => mockClient as any);
395
+
396
+ await fetchUserModels();
397
+ // Verify that the OpenRouter client was constructed with the API key, not management key
398
+ expect(MockOpenRouter).toHaveBeenCalledWith(expect.objectContaining({ apiKey: 'api-key' }));
399
+ });
400
+ });
401
+
402
+ describe('getCredits with usage API key selection', () => {
403
+ beforeEach(() => {
404
+ vi.resetAllMocks();
405
+ restoreEnv();
406
+ vi.resetModules(); // Clear module cache including client instance
407
+ });
408
+
409
+ it('should use OPENROUTER_MANAGEMENT_KEY when both keys are present', async () => {
410
+ setKeys('api-key', 'mgmt-key');
411
+
412
+ const mockResponse = {
413
+ data: {
414
+ total_granted: 10.0,
415
+ total_used: 2.5,
416
+ total_available: 7.5,
417
+ },
418
+ };
419
+
420
+ const MockOpenRouter = vi.mocked((await import('@openrouter/sdk/sdk/sdk.js')).OpenRouter);
421
+ const mockClient = createMockSDKClient({
422
+ getCredits: vi.fn().mockResolvedValue(mockResponse),
423
+ });
424
+ MockOpenRouter.mockImplementation(() => mockClient as any);
425
+
426
+ const { getCredits } = await import('../client.js');
427
+ await getCredits();
428
+
429
+ // Verify management key was used (not API key)
430
+ expect(MockOpenRouter).toHaveBeenCalledWith(expect.objectContaining({ apiKey: 'mgmt-key' }));
431
+ });
432
+
433
+ it('should fall back to OPENROUTER_API_KEY when only API key is present', async () => {
434
+ setKeys('api-key', undefined);
435
+
436
+ const mockResponse = {
437
+ data: {
438
+ total_granted: 10.0,
439
+ total_used: 2.5,
440
+ total_available: 7.5,
441
+ },
442
+ };
443
+
444
+ const MockOpenRouter = vi.mocked((await import('@openrouter/sdk/sdk/sdk.js')).OpenRouter);
445
+ const mockClient = createMockSDKClient({
446
+ getCredits: vi.fn().mockResolvedValue(mockResponse),
447
+ });
448
+ MockOpenRouter.mockImplementation(() => mockClient as any);
449
+
450
+ const { getCredits } = await import('../client.js');
451
+ await getCredits();
452
+
453
+ expect(MockOpenRouter).toHaveBeenCalledWith(expect.objectContaining({ apiKey: 'api-key' }));
454
+ });
455
+
456
+ it('should fall back to API key when management key is whitespace', async () => {
457
+ setKeys('api-key', ' ');
458
+
459
+ const mockResponse = {
460
+ data: {
461
+ total_granted: 10.0,
462
+ total_used: 2.5,
463
+ total_available: 7.5,
464
+ },
465
+ };
466
+
467
+ const MockOpenRouter = vi.mocked((await import('@openrouter/sdk/sdk/sdk.js')).OpenRouter);
468
+ const mockClient = createMockSDKClient({
469
+ getCredits: vi.fn().mockResolvedValue(mockResponse),
470
+ });
471
+ MockOpenRouter.mockImplementation(() => mockClient as any);
472
+
473
+ const { getCredits } = await import('../client.js');
474
+ await getCredits();
475
+
476
+ // Should use API key since management key is whitespace-only
477
+ expect(MockOpenRouter).toHaveBeenCalledWith(expect.objectContaining({ apiKey: 'api-key' }));
478
+ });
479
+
480
+ it('should return null when no keys are configured', async () => {
481
+ setKeys(undefined, undefined);
482
+
483
+ const { getCredits } = await import('../client.js');
484
+ const result = await getCredits();
485
+ expect(result).toBeNull();
486
+ });
487
+ });