@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
@@ -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';
@@ -1,7 +1,9 @@
1
1
  import type { OpenRouterModel, PiModelConfig, SkipReason, MapResult } from './types.js';
2
2
  import { ROUTER_ALIASES } from './types.js';
3
+ import { getSkipReasonHint } from './skip-hints.js';
3
4
  import type { Model as SDKModel } from '@openrouter/sdk/models/index.js';
4
5
  import { loadModelOverrides, getModelOverride } from './overrides.js';
6
+ import { normalizeOpenRouterModel } from '../normalizers.js';
5
7
 
6
8
  // Cache for built-in OpenRouter models from pi-ai
7
9
  // Populated lazily on first access
@@ -57,68 +59,24 @@ const COST_PER_MILLION = 1_000_000;
57
59
  const DEFAULT_MAX_TOKENS = 4096;
58
60
 
59
61
  /**
60
- * Convert SDK Model to our OpenRouterModel type for compatibility.
61
- * Handles SDK's camelCase naming convention.
62
+ * Validation result for a model check.
62
63
  */
63
- export function sdkModelToOpenRouterModel(model: SDKModel): OpenRouterModel {
64
- const topProvider = model.topProvider
65
- ? {
66
- context_length: model.topProvider.contextLength ?? 0,
67
- max_completion_tokens: model.topProvider.maxCompletionTokens ?? 0,
68
- }
69
- : undefined;
70
-
71
- const perRequestLimits = model.perRequestLimits
72
- ? {
73
- completion_tokens: model.perRequestLimits.completionTokens ?? 0,
74
- }
75
- : undefined;
76
-
77
- // Build the object conditionally to avoid undefined property issues
78
- const result: OpenRouterModel = {
79
- id: model.id,
80
- name: model.name,
81
- architecture: {
82
- input_modalities: model.architecture.inputModalities ?? [],
83
- output_modalities: model.architecture.outputModalities ?? [],
84
- },
85
- context_length: model.contextLength ?? 0,
86
- pricing: {
87
- prompt: String(model.pricing.prompt ?? 0),
88
- completion: String(model.pricing.completion ?? 0),
89
- input_cache_read: String(model.pricing.inputCacheRead ?? 0),
90
- input_cache_write: String(model.pricing.inputCacheWrite ?? 0),
91
- },
92
- supported_parameters: model.supportedParameters,
93
- };
64
+ type PricedOpenRouterModel = OpenRouterModel & {
65
+ pricing: NonNullable<OpenRouterModel['pricing']>;
66
+ };
94
67
 
95
- // Conditionally add optional properties to avoid explicit undefined
96
- if (topProvider) {
97
- result.top_provider = topProvider;
98
- }
99
- if (perRequestLimits) {
100
- result.per_request_limits = perRequestLimits;
101
- }
102
-
103
- return result;
104
- }
68
+ type ValidationResult =
69
+ | { valid: true; model: PricedOpenRouterModel; contextWindow: number }
70
+ | { valid: false; reason: string; modelId: string; hint?: string };
105
71
 
106
72
  /**
107
- * Normalize input model to OpenRouterModel format.
73
+ * Build a failed validation result with a stable machine reason and optional hint.
108
74
  */
109
- function normalizeModel(model: OpenRouterModel | SDKModel): OpenRouterModel {
110
- return 'contextLength' in model
111
- ? sdkModelToOpenRouterModel(model as SDKModel)
112
- : (model as OpenRouterModel);
75
+ function invalidModel(reason: string, modelId: string): ValidationResult {
76
+ const hint = getSkipReasonHint(reason);
77
+ return hint ? { valid: false, reason, modelId, hint } : { valid: false, reason, modelId };
113
78
  }
114
79
 
115
- /**
116
- * Validation result for a model check.
117
- */
118
- type ValidationResult =
119
- | { valid: true; model: OpenRouterModel; contextWindow: number }
120
- | { valid: false; reason: string; modelId: string };
121
-
122
80
  /**
123
81
  * Validate a model and return either a valid result with extracted context window
124
82
  * or a failure reason.
@@ -126,30 +84,31 @@ type ValidationResult =
126
84
  function validateModel(model: OpenRouterModel): ValidationResult {
127
85
  // Check: missing required id
128
86
  if (!model.id) {
129
- return { valid: false, reason: 'missing id', modelId: 'unknown' };
87
+ return invalidModel('missing id', 'unknown');
130
88
  }
131
89
 
132
90
  // Check: missing required pricing fields
133
- if (!model.pricing?.prompt) {
134
- return { valid: false, reason: 'missing prompt pricing', modelId: model.id };
91
+ const pricing = model.pricing;
92
+ if (!pricing?.prompt) {
93
+ return invalidModel('missing prompt pricing', model.id);
135
94
  }
136
- if (!model.pricing?.completion) {
137
- return { valid: false, reason: 'missing completion pricing', modelId: model.id };
95
+ if (!pricing.completion) {
96
+ return invalidModel('missing completion pricing', model.id);
138
97
  }
139
98
 
140
99
  // Check: missing context window (both primary and fallback)
141
100
  const contextWindow = model.top_provider?.context_length ?? model.context_length;
142
101
  if (!contextWindow) {
143
- return { valid: false, reason: 'missing context window', modelId: model.id };
102
+ return invalidModel('missing context window', model.id);
144
103
  }
145
104
 
146
105
  // Check: explicitly non-text output (if specified)
147
106
  const outputModalities = model.architecture?.output_modalities;
148
107
  if (outputModalities && !outputModalities.includes('text')) {
149
- return { valid: false, reason: 'non-text output modalities', modelId: model.id };
108
+ return invalidModel('non-text output modalities', model.id);
150
109
  }
151
110
 
152
- return { valid: true, model, contextWindow };
111
+ return { valid: true, model: { ...model, pricing }, contextWindow };
153
112
  }
154
113
 
155
114
  /**
@@ -158,7 +117,7 @@ function validateModel(model: OpenRouterModel): ValidationResult {
158
117
  * Priority: user overrides > built-in registry > API data
159
118
  */
160
119
  async function buildPiConfig(
161
- model: OpenRouterModel,
120
+ model: PricedOpenRouterModel,
162
121
  contextWindow: number,
163
122
  userOverrides?: Awaited<ReturnType<typeof loadModelOverrides>>,
164
123
  ): Promise<PiModelConfig> {
@@ -227,7 +186,7 @@ export async function mapOpenRouterModels(
227
186
  const skippedDetails: SkipReason[] = [];
228
187
 
229
188
  for (const rawModel of models) {
230
- const model = normalizeModel(rawModel);
189
+ const model = normalizeOpenRouterModel(rawModel);
231
190
 
232
191
  // Skip router aliases - they're added manually after mapping
233
192
  if (ROUTER_ALIASES.includes(model.id)) {
@@ -238,11 +197,18 @@ export async function mapOpenRouterModels(
238
197
 
239
198
  if (!validation.valid) {
240
199
  skipped++;
241
- skippedDetails.push({ id: validation.modelId, reason: validation.reason });
200
+ const skippedDetail: SkipReason = {
201
+ id: validation.modelId,
202
+ reason: validation.reason,
203
+ };
204
+ if (validation.hint) {
205
+ skippedDetail.hint = validation.hint;
206
+ }
207
+ skippedDetails.push(skippedDetail);
242
208
  continue;
243
209
  }
244
210
 
245
- configs.push(await buildPiConfig(model, validation.contextWindow, userOverrides));
211
+ configs.push(await buildPiConfig(validation.model, validation.contextWindow, userOverrides));
246
212
  }
247
213
 
248
214
  return { configs, skipped, skippedDetails };
@@ -259,7 +225,7 @@ export async function mapOpenRouterModel(
259
225
  await loadBuiltInOpenRouterModels();
260
226
  const userOverrides = await loadModelOverrides();
261
227
 
262
- const normalized = normalizeModel(model);
228
+ const normalized = normalizeOpenRouterModel(model);
263
229
 
264
230
  // Router aliases are handled separately, skip them here
265
231
  if (ROUTER_ALIASES.includes(normalized.id)) {
@@ -272,5 +238,5 @@ export async function mapOpenRouterModel(
272
238
  return null;
273
239
  }
274
240
 
275
- return buildPiConfig(normalized, validation.contextWindow, userOverrides);
241
+ return buildPiConfig(validation.model, validation.contextWindow, userOverrides);
276
242
  }