@robhowley/pi-openrouter 0.8.3 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +87 -4
  2. package/extensions/openrouter/__tests__/cache.test.ts +769 -0
  3. package/extensions/openrouter/__tests__/client.test.ts +333 -15
  4. package/extensions/openrouter/__tests__/commands.test.ts +816 -0
  5. package/extensions/openrouter/__tests__/fixtures.ts +140 -1
  6. package/extensions/openrouter/__tests__/format.test.ts +19 -0
  7. package/extensions/openrouter/__tests__/hooks.test.ts +276 -0
  8. package/extensions/openrouter/__tests__/index.test.ts +163 -0
  9. package/extensions/openrouter/__tests__/local-usage.test.ts +777 -0
  10. package/extensions/openrouter/__tests__/normalizers.test.ts +288 -0
  11. package/extensions/openrouter/__tests__/overlay.test.ts +225 -0
  12. package/extensions/openrouter/__tests__/session-state.test.ts +233 -0
  13. package/extensions/openrouter/__tests__/session.test.ts +44 -43
  14. package/extensions/openrouter/account-client.ts +11 -61
  15. package/extensions/openrouter/cache.ts +203 -91
  16. package/extensions/openrouter/client.ts +49 -3
  17. package/extensions/openrouter/commands.ts +555 -0
  18. package/extensions/openrouter/format.ts +7 -4
  19. package/extensions/openrouter/hooks.ts +229 -0
  20. package/extensions/openrouter/index.ts +13 -589
  21. package/extensions/openrouter/local-usage.ts +145 -22
  22. package/extensions/openrouter/models/__tests__/cache.test.ts +63 -2
  23. package/extensions/openrouter/models/__tests__/mapper-overrides.test.ts +102 -0
  24. package/extensions/openrouter/models/__tests__/mapper.test.ts +29 -0
  25. package/extensions/openrouter/models/__tests__/override-commands.test.ts +668 -0
  26. package/extensions/openrouter/models/__tests__/overrides.test.ts +237 -0
  27. package/extensions/openrouter/models/__tests__/sync.test.ts +156 -4
  28. package/extensions/openrouter/models/cache.ts +27 -2
  29. package/extensions/openrouter/models/mapper.ts +60 -77
  30. package/extensions/openrouter/models/override-commands.ts +434 -0
  31. package/extensions/openrouter/models/overrides.ts +174 -0
  32. package/extensions/openrouter/models/skip-hints.ts +19 -0
  33. package/extensions/openrouter/models/sync.ts +22 -10
  34. package/extensions/openrouter/models/types.ts +31 -1
  35. package/extensions/openrouter/normalizers.ts +128 -0
  36. package/extensions/openrouter/overlay.ts +19 -8
  37. package/extensions/openrouter/session-state.ts +110 -0
  38. package/extensions/openrouter/session.ts +16 -0
  39. package/extensions/openrouter/types.ts +28 -9
  40. package/package.json +1 -1
@@ -0,0 +1,237 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import {
3
+ loadModelOverrides,
4
+ ModelOverridesLoadError,
5
+ saveModelOverrides,
6
+ setModelOverride,
7
+ removeModelOverride,
8
+ } from '../overrides.js';
9
+ import type { ModelOverridesFile, UserModelOverride } from '../types.js';
10
+ import { existsSync } from 'node:fs';
11
+ import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+
15
+ // Mock node:fs
16
+ vi.mock('node:fs', () => ({
17
+ existsSync: vi.fn(),
18
+ }));
19
+
20
+ // Mock node:fs/promises
21
+ vi.mock('node:fs/promises', () => ({
22
+ readFile: vi.fn(),
23
+ writeFile: vi.fn(),
24
+ mkdir: vi.fn(),
25
+ rm: vi.fn(),
26
+ }));
27
+
28
+ // Mock node:os
29
+ vi.mock('node:os', () => ({
30
+ homedir: vi.fn(),
31
+ }));
32
+
33
+ const OVERRIDES_FILE = join('/mock/home', '.pi', 'openrouter', 'model-overrides.json');
34
+
35
+ describe('overrides', () => {
36
+ beforeEach(() => {
37
+ vi.mocked(homedir).mockReturnValue('/mock/home');
38
+ vi.mocked(existsSync).mockReturnValue(false);
39
+ vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
40
+ vi.mocked(writeFile).mockResolvedValue(undefined);
41
+ vi.mocked(mkdir).mockResolvedValue(undefined);
42
+ vi.mocked(rm).mockResolvedValue(undefined);
43
+ });
44
+
45
+ afterEach(() => {
46
+ vi.clearAllMocks();
47
+ });
48
+
49
+ describe('loadModelOverrides', () => {
50
+ it('should return empty overrides when file does not exist', async () => {
51
+ vi.mocked(existsSync).mockReturnValue(false);
52
+
53
+ const result = await loadModelOverrides();
54
+
55
+ expect(result).toEqual({ version: 1, overrides: {} });
56
+ });
57
+
58
+ it('should throw when file is invalid JSON', async () => {
59
+ vi.mocked(existsSync).mockReturnValue(true);
60
+ vi.mocked(readFile).mockResolvedValue('invalid json');
61
+
62
+ await expect(loadModelOverrides()).rejects.toThrow(ModelOverridesLoadError);
63
+ await expect(loadModelOverrides()).rejects.toThrow('Invalid JSON in model overrides file');
64
+ });
65
+
66
+ it('should throw when file has wrong structure', async () => {
67
+ vi.mocked(existsSync).mockReturnValue(true);
68
+ vi.mocked(readFile).mockResolvedValue(JSON.stringify({ foo: 'bar' }));
69
+
70
+ await expect(loadModelOverrides()).rejects.toThrow(ModelOverridesLoadError);
71
+ await expect(loadModelOverrides()).rejects.toThrow('Invalid model overrides file structure');
72
+ });
73
+
74
+ it('should throw when existing file cannot be read', async () => {
75
+ vi.mocked(existsSync).mockReturnValue(true);
76
+ vi.mocked(readFile).mockRejectedValue(new Error('permission denied'));
77
+
78
+ await expect(loadModelOverrides()).rejects.toThrow(ModelOverridesLoadError);
79
+ await expect(loadModelOverrides()).rejects.toThrow('Failed to read model overrides file');
80
+ });
81
+
82
+ it('should load valid overrides file', async () => {
83
+ const mockData: ModelOverridesFile = {
84
+ version: 1,
85
+ overrides: {
86
+ 'test/model': {
87
+ thinkingLevelMap: { high: 'high', xhigh: 'max' },
88
+ },
89
+ },
90
+ };
91
+ vi.mocked(existsSync).mockReturnValue(true);
92
+ vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockData));
93
+
94
+ const result = await loadModelOverrides();
95
+
96
+ expect(result).toEqual(mockData);
97
+ });
98
+ });
99
+
100
+ describe('saveModelOverrides', () => {
101
+ it('should create directory if it does not exist', async () => {
102
+ vi.mocked(existsSync).mockReturnValue(false);
103
+
104
+ const overrides: ModelOverridesFile = { version: 1, overrides: {} };
105
+ await saveModelOverrides(overrides);
106
+
107
+ expect(mkdir).toHaveBeenCalledWith(join('/mock/home', '.pi', 'openrouter'), {
108
+ recursive: true,
109
+ });
110
+ });
111
+
112
+ it('should write JSON to file', async () => {
113
+ const overrides: ModelOverridesFile = {
114
+ version: 1,
115
+ overrides: {
116
+ 'test/model': {
117
+ thinkingLevelMap: { high: 'high' },
118
+ },
119
+ },
120
+ };
121
+
122
+ await saveModelOverrides(overrides);
123
+
124
+ expect(writeFile).toHaveBeenCalledWith(
125
+ OVERRIDES_FILE,
126
+ JSON.stringify(overrides, null, 2),
127
+ 'utf-8',
128
+ );
129
+ });
130
+ });
131
+
132
+ describe('setModelOverride', () => {
133
+ it('should add new override', () => {
134
+ const overrides: ModelOverridesFile = { version: 1, overrides: {} };
135
+ const override: UserModelOverride = {
136
+ thinkingLevelMap: { high: 'high' },
137
+ };
138
+
139
+ const result = setModelOverride(overrides, 'test/model', override);
140
+
141
+ expect(result.overrides['test/model']).toEqual({
142
+ thinkingLevelMap: { high: 'high' },
143
+ });
144
+ });
145
+
146
+ it('should merge with existing override', () => {
147
+ const existing: UserModelOverride = {
148
+ thinkingLevelMap: { minimal: null, high: 'low' },
149
+ contextWindow: 128000,
150
+ };
151
+ const overrides: ModelOverridesFile = {
152
+ version: 1,
153
+ overrides: { 'test/model': existing },
154
+ };
155
+
156
+ const newOverride: UserModelOverride = {
157
+ thinkingLevelMap: { high: 'high' },
158
+ maxTokens: 8192,
159
+ };
160
+
161
+ const result = setModelOverride(overrides, 'test/model', newOverride);
162
+
163
+ expect(result.overrides['test/model']).toEqual({
164
+ thinkingLevelMap: { minimal: null, high: 'high' },
165
+ contextWindow: 128000,
166
+ maxTokens: 8192,
167
+ });
168
+ });
169
+
170
+ it('should clean up undefined thinkingLevelMap entries', () => {
171
+ const overrides: ModelOverridesFile = { version: 1, overrides: {} };
172
+ const override: UserModelOverride = {
173
+ thinkingLevelMap: { high: 'high', medium: undefined as unknown as null },
174
+ };
175
+
176
+ const result = setModelOverride(overrides, 'test/model', override);
177
+
178
+ expect(result.overrides['test/model']?.thinkingLevelMap).toEqual({
179
+ high: 'high',
180
+ });
181
+ });
182
+
183
+ it('should remove empty thinkingLevelMap', () => {
184
+ const overrides: ModelOverridesFile = { version: 1, overrides: {} };
185
+ const override: UserModelOverride = {
186
+ thinkingLevelMap: {},
187
+ contextWindow: 128000,
188
+ };
189
+
190
+ const result = setModelOverride(overrides, 'test/model', override);
191
+
192
+ expect(result.overrides['test/model']?.thinkingLevelMap).toBeUndefined();
193
+ expect(result.overrides['test/model']?.contextWindow).toBe(128000);
194
+ });
195
+
196
+ it('should not create thinkingLevelMap for non-thinking overrides', () => {
197
+ const overrides: ModelOverridesFile = { version: 1, overrides: {} };
198
+
199
+ const result = setModelOverride(overrides, 'test/model', {
200
+ contextWindow: 64000,
201
+ });
202
+
203
+ expect(result.overrides['test/model']).toEqual({
204
+ contextWindow: 64000,
205
+ });
206
+ });
207
+ });
208
+
209
+ describe('removeModelOverride', () => {
210
+ it('should remove existing override', () => {
211
+ const overrides: ModelOverridesFile = {
212
+ version: 1,
213
+ overrides: {
214
+ 'test/model': { thinkingLevelMap: { high: 'high' } },
215
+ },
216
+ };
217
+
218
+ const result = removeModelOverride(overrides, 'test/model');
219
+
220
+ expect(result.overrides['test/model']).toBeUndefined();
221
+ expect(Object.keys(result.overrides)).toHaveLength(0);
222
+ });
223
+
224
+ it('should be idempotent for non-existent model', () => {
225
+ const overrides: ModelOverridesFile = {
226
+ version: 1,
227
+ overrides: {
228
+ 'other/model': { thinkingLevelMap: { high: 'high' } },
229
+ },
230
+ };
231
+
232
+ const result = removeModelOverride(overrides, 'unknown/model');
233
+
234
+ expect(result).toEqual(overrides);
235
+ });
236
+ });
237
+ });
@@ -4,7 +4,9 @@
4
4
 
5
5
  import { describe, it, expect, vi, beforeEach } from 'vitest';
6
6
  import type { ExtensionContext, ModelRegistry } from '@mariozechner/pi-coding-agent';
7
- import type { SyncResult } from '../types.js';
7
+ import type { PiModelConfig, SyncResult } from '../types.js';
8
+ import { ROUTER_ALIASES } from '../types.js';
9
+ import { createPiModelConfig, createValidModel } from '../../__tests__/fixtures.js';
8
10
 
9
11
  // Import modules
10
12
  import {
@@ -13,9 +15,10 @@ import {
13
15
  getSyncState,
14
16
  getStatusText,
15
17
  areModelsAvailable,
18
+ includeBuiltinRouterModels,
16
19
  } from '../sync.js';
17
20
  import { fetchUserModels, AuthError } from '../../client.js';
18
- import { loadCache } from '../cache.js';
21
+ import { loadCache, saveCache } from '../cache.js';
19
22
 
20
23
  // Mock the client module to control API behavior
21
24
  vi.mock('../../client.js', () => ({
@@ -133,8 +136,13 @@ describe('syncModels', () => {
133
136
  it('should return failure when API key is missing and no cache', async () => {
134
137
  // Ensure API key is not set
135
138
  delete process.env['OPENROUTER_API_KEY'];
139
+ delete process.env['OPENROUTER_MANAGEMENT_KEY'];
136
140
  // Mock fetchUserModels to throw AuthError
137
- vi.mocked(fetchUserModels).mockRejectedValueOnce(new AuthError('OPENROUTER_API_KEY not set'));
141
+ vi.mocked(fetchUserModels).mockRejectedValueOnce(
142
+ new AuthError(
143
+ 'OpenRouter API key not configured. Set OPENROUTER_API_KEY or OPENROUTER_MANAGEMENT_KEY.',
144
+ ),
145
+ );
138
146
  // Mock loadCache to return null (no cache available)
139
147
  vi.mocked(loadCache).mockResolvedValueOnce(null);
140
148
 
@@ -143,7 +151,7 @@ describe('syncModels', () => {
143
151
  expect(result.success).toBe(false);
144
152
  expect(result.registeredCount).toBe(0);
145
153
  expect(result.source).toBe('none');
146
- expect(result.error).toContain('OPENROUTER_API_KEY not set');
154
+ expect(result.error).toContain('OpenRouter API key not configured');
147
155
  });
148
156
 
149
157
  it('should sync models from API and register with provider', async () => {
@@ -182,6 +190,150 @@ describe('syncModels', () => {
182
190
  expect(result.registeredCount).toBeGreaterThan(0);
183
191
  expect(mockRegisterProvider).toHaveBeenCalled();
184
192
  });
193
+
194
+ it('should register the API user catalog plus router aliases exactly once', async () => {
195
+ const mockModel = {
196
+ id: 'user/model-a',
197
+ name: 'User Model A',
198
+ architecture: {
199
+ inputModalities: ['text'],
200
+ outputModalities: ['text'],
201
+ },
202
+ contextLength: 128000,
203
+ pricing: {
204
+ prompt: 0.000001,
205
+ completion: 0.000002,
206
+ },
207
+ supportedParameters: [],
208
+ topProvider: {
209
+ contextLength: 128000,
210
+ maxCompletionTokens: 4096,
211
+ },
212
+ };
213
+
214
+ vi.mocked(fetchUserModels).mockResolvedValueOnce({
215
+ data: [mockModel],
216
+ } as any);
217
+
218
+ const result = await syncModels(mockCtx);
219
+ const providerConfig = mockRegisterProvider.mock.calls[0]![1] as { models: PiModelConfig[] };
220
+ const registeredIds = providerConfig.models.map((model) => model.id);
221
+
222
+ expect(result.registeredCount).toBe(1 + ROUTER_ALIASES.length);
223
+ expect(registeredIds).toEqual(['user/model-a', ...ROUTER_ALIASES]);
224
+ expect(registeredIds).not.toContain('anthropic/claude-3-opus');
225
+ });
226
+
227
+ it('should register cached user models plus router aliases on API failure', async () => {
228
+ vi.mocked(fetchUserModels).mockRejectedValueOnce(new Error('api down'));
229
+ vi.mocked(loadCache).mockResolvedValueOnce({
230
+ models: [createValidModel({ id: 'cached/model-a', name: 'Cached Model A' })],
231
+ skippedDetails: [
232
+ {
233
+ id: 'bad/model',
234
+ reason: 'missing context window',
235
+ hint: "Add a local contextWindow override with '/openrouter model-override-set <model-id> contextWindow=<tokens>' if the model's limit is known.",
236
+ },
237
+ ],
238
+ timestamp: Date.now() - 60000,
239
+ });
240
+
241
+ const result = await syncModels(mockCtx);
242
+ const providerConfig = mockRegisterProvider.mock.calls[0]![1] as { models: PiModelConfig[] };
243
+ const registeredIds = providerConfig.models.map((model) => model.id);
244
+
245
+ expect(result.source).toBe('cache');
246
+ expect(result.registeredCount).toBe(1 + ROUTER_ALIASES.length);
247
+ expect(registeredIds).toEqual(['cached/model-a', ...ROUTER_ALIASES]);
248
+ expect(result.skippedDetails).toEqual([
249
+ {
250
+ id: 'bad/model',
251
+ reason: 'missing context window',
252
+ hint: "Add a local contextWindow override with '/openrouter model-override-set <model-id> contextWindow=<tokens>' if the model's limit is known.",
253
+ },
254
+ ]);
255
+ });
256
+
257
+ it('should persist skipped reason hints in the saved cache', async () => {
258
+ vi.mocked(fetchUserModels).mockResolvedValueOnce({
259
+ data: [
260
+ {
261
+ id: 'user/model-a',
262
+ name: 'User Model A',
263
+ architecture: {
264
+ inputModalities: ['text'],
265
+ outputModalities: ['text'],
266
+ },
267
+ contextLength: 128000,
268
+ pricing: {
269
+ prompt: 0.000001,
270
+ completion: 0.000002,
271
+ },
272
+ supportedParameters: [],
273
+ topProvider: {
274
+ contextLength: 128000,
275
+ maxCompletionTokens: 4096,
276
+ },
277
+ },
278
+ {
279
+ id: 'bad/model',
280
+ name: 'Bad Model',
281
+ architecture: {
282
+ inputModalities: ['text'],
283
+ outputModalities: ['text'],
284
+ },
285
+ contextLength: 0,
286
+ pricing: {
287
+ prompt: 0.000001,
288
+ completion: 0.000002,
289
+ },
290
+ supportedParameters: [],
291
+ topProvider: {
292
+ contextLength: 0,
293
+ maxCompletionTokens: 4096,
294
+ },
295
+ },
296
+ ],
297
+ } as any);
298
+
299
+ const result = await syncModels(mockCtx);
300
+
301
+ expect(result.skippedDetails).toEqual([
302
+ {
303
+ id: 'bad/model',
304
+ reason: 'missing context window',
305
+ hint: expect.stringContaining(
306
+ '/openrouter model-override-set <model-id> contextWindow=<tokens>',
307
+ ),
308
+ },
309
+ ]);
310
+ expect(vi.mocked(saveCache)).toHaveBeenCalledWith(
311
+ expect.objectContaining({
312
+ skippedDetails: [
313
+ {
314
+ id: 'bad/model',
315
+ reason: 'missing context window',
316
+ hint: expect.stringContaining(
317
+ '/openrouter model-override-set <model-id> contextWindow=<tokens>',
318
+ ),
319
+ },
320
+ ],
321
+ }),
322
+ );
323
+ });
324
+
325
+ it('should include router aliases at most once', () => {
326
+ const configs = includeBuiltinRouterModels([
327
+ createPiModelConfig({ id: 'user/model-a' }),
328
+ createPiModelConfig({ id: ROUTER_ALIASES[0]! }),
329
+ ]);
330
+ const registeredIds = configs.map((model) => model.id);
331
+
332
+ expect(registeredIds).toEqual(['user/model-a', ...ROUTER_ALIASES]);
333
+ for (const routerId of ROUTER_ALIASES) {
334
+ expect(registeredIds.filter((id) => id === routerId)).toHaveLength(1);
335
+ }
336
+ });
185
337
  });
186
338
 
187
339
  describe('syncState management', () => {
@@ -6,6 +6,7 @@ import { MS_PER_MINUTE } from './types.js';
6
6
 
7
7
  const CACHE_FILENAME = 'models-cache.json';
8
8
  const DEFAULT_CACHE_DIR = join(homedir(), '.pi', 'openrouter');
9
+ const MAX_FUTURE_SKEW_MS = 5 * MS_PER_MINUTE;
9
10
 
10
11
  // Allow overriding cache directory for testing
11
12
  let cacheDirOverride: string | null = null;
@@ -40,6 +41,21 @@ async function ensureCacheDir(): Promise<void> {
40
41
  await mkdir(getCacheDir(), { recursive: true });
41
42
  }
42
43
 
44
+ /**
45
+ * Returns true when a cache timestamp is structurally valid and not too far in the future.
46
+ */
47
+ function isValidCacheTimestamp(timestamp: number, now = Date.now()): boolean {
48
+ return Number.isFinite(timestamp) && timestamp <= now + MAX_FUTURE_SKEW_MS;
49
+ }
50
+
51
+ /**
52
+ * Clamp future timestamps when saving so new cache writes never persist negative ages.
53
+ */
54
+ function normalizeTimestampForSave(timestamp: number, now = Date.now()): number {
55
+ if (!isValidCacheTimestamp(timestamp, now)) return now;
56
+ return Math.min(timestamp, now);
57
+ }
58
+
43
59
  /**
44
60
  * Load cached models from disk.
45
61
  * Returns null if cache doesn't exist or is corrupted.
@@ -55,6 +71,10 @@ export async function loadCache(): Promise<ModelsCache | null> {
55
71
  return null;
56
72
  }
57
73
 
74
+ if (!isValidCacheTimestamp(parsed.timestamp)) {
75
+ return null;
76
+ }
77
+
58
78
  return parsed;
59
79
  } catch {
60
80
  // File doesn't exist, permission error, or invalid JSON
@@ -68,14 +88,18 @@ export async function loadCache(): Promise<ModelsCache | null> {
68
88
  export async function saveCache(cache: ModelsCache): Promise<void> {
69
89
  await ensureCacheDir();
70
90
  const cachePath = getCachePath();
71
- await writeFile(cachePath, JSON.stringify(cache, null, 2));
91
+ const normalizedCache: ModelsCache = {
92
+ ...cache,
93
+ timestamp: normalizeTimestampForSave(cache.timestamp),
94
+ };
95
+ await writeFile(cachePath, JSON.stringify(normalizedCache, null, 2));
72
96
  }
73
97
 
74
98
  /**
75
99
  * Get the age of the cache in milliseconds.
76
100
  */
77
101
  export function getCacheAgeMs(cache: ModelsCache): number {
78
- return Date.now() - cache.timestamp;
102
+ return Math.max(0, Date.now() - cache.timestamp);
79
103
  }
80
104
 
81
105
  /**
@@ -84,6 +108,7 @@ export function getCacheAgeMs(cache: ModelsCache): number {
84
108
  */
85
109
  export function formatDuration(ms: number | null): string {
86
110
  if (ms === null) return 'unknown';
111
+ if (ms <= 0) return '<1m';
87
112
 
88
113
  const minutes = Math.floor(ms / MS_PER_MINUTE);
89
114
  if (minutes < 1) return '<1m';