@robhowley/pi-openrouter 0.7.1 → 0.8.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.
@@ -1,6 +1,8 @@
1
1
  import type { ActivityResponse } from '@openrouter/sdk/models/index.js';
2
2
  import type { GetCreditsResponse } from '@openrouter/sdk/models/operations/index.js';
3
3
  import { OpenRouter } from '@openrouter/sdk/sdk/sdk.js';
4
+ import type { ModelsListResponse } from '@openrouter/sdk/models/index.js';
5
+ import { UnauthorizedResponseError } from '@openrouter/sdk/models/errors/index.js';
4
6
 
5
7
  let client: OpenRouter | null = null;
6
8
 
@@ -34,23 +36,76 @@ export async function getActivity(): Promise<ActivityResponse['data'] | null> {
34
36
  }
35
37
  }
36
38
 
37
- interface SDKError {
38
- status?: number;
39
- message?: string;
39
+ /**
40
+ * Fetch the authenticated user's model catalog from OpenRouter.
41
+ * Uses the SDK for consistent error handling and retry behavior.
42
+ */
43
+ export async function fetchUserModels(): Promise<ModelsListResponse> {
44
+ const key = getApiKey();
45
+ if (!key) {
46
+ throw new AuthError('OPENROUTER_API_KEY not set');
47
+ }
48
+
49
+ try {
50
+ const sdkClient = new OpenRouter({ apiKey: key });
51
+ const response = await sdkClient.models.listForUser({ bearer: key }, {});
52
+ return response as ModelsListResponse;
53
+ } catch (err: unknown) {
54
+ throw mapSdkError(err);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Check if the OpenRouter API key is configured.
60
+ */
61
+ export function isConfigured(): boolean {
62
+ return !!getApiKey();
40
63
  }
41
64
 
42
- function isSDKError(err: unknown): err is SDKError {
43
- return err !== null && typeof err === 'object' && 'status' in err;
65
+ /**
66
+ * Get the OpenRouter API key from environment.
67
+ */
68
+ export function getApiKey(): string | undefined {
69
+ return process.env['OPENROUTER_API_KEY'];
44
70
  }
45
71
 
72
+ /**
73
+ * Map SDK errors to our error types with proper status codes.
74
+ */
46
75
  function mapSdkError(err: unknown): Error {
47
- if (isSDKError(err)) {
48
- const status = err.status;
49
- const message = err.message ?? 'Unknown error';
50
- if (status === 401) return new AuthError(message);
51
- return new ApiError(`${status}: ${message}`);
76
+ // Handle UnauthorizedResponseError (401)
77
+ if (err instanceof UnauthorizedResponseError) {
78
+ return new ApiError('Unauthorized: Invalid or expired API key', 401);
52
79
  }
53
- if (err instanceof Error) return err;
80
+
81
+ // Handle other SDK errors with statusCode
82
+ if (err instanceof Error && 'statusCode' in err) {
83
+ const statusCode = (err as { statusCode: number }).statusCode;
84
+ if (statusCode === 401) {
85
+ return new AuthError(err.message || 'Unauthorized');
86
+ }
87
+ return new ApiError(err.message || 'API error', statusCode);
88
+ }
89
+
90
+ // Map error messages to appropriate status codes
91
+ if (err instanceof Error) {
92
+ const message = err.message.toLowerCase();
93
+ if (message.includes('unauthorized')) {
94
+ return new ApiError('Unauthorized: Invalid or expired API key', 401);
95
+ }
96
+ if (message.includes('rate limit') || message.includes('rate limited')) {
97
+ return new ApiError('Rate limited: Too many requests', 429);
98
+ }
99
+ if (
100
+ message.includes('server error') ||
101
+ message.includes('internal') ||
102
+ message.includes('service unavailable')
103
+ ) {
104
+ return new ApiError(err.message || 'Server error', 500);
105
+ }
106
+ return new ApiError(err.message || 'API error', 500);
107
+ }
108
+
54
109
  return new Error(String(err));
55
110
  }
56
111
 
@@ -62,7 +117,10 @@ export class AuthError extends Error {
62
117
  }
63
118
 
64
119
  export class ApiError extends Error {
65
- constructor(message: string) {
120
+ constructor(
121
+ message: string,
122
+ public readonly statusCode?: number,
123
+ ) {
66
124
  super(message);
67
125
  this.name = 'ApiError';
68
126
  }
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from '@mariozechner/pi-coding-agent';
2
2
  import type { UsageSummary } from './types.js';
3
+ import { MS_PER_MINUTE } from './models/types.js';
3
4
  import {
4
5
  usageCache,
5
6
  startBackgroundRefresh,
@@ -17,10 +18,51 @@ import type { KeyInfo } from './account-types.js';
17
18
  import type { RollupStatus } from './account-types.js';
18
19
  import crypto from 'node:crypto';
19
20
 
21
+ // Import models sync
22
+ import {
23
+ syncModels,
24
+ getSyncState,
25
+ isSyncEnabled,
26
+ getSkipReasonsAsync,
27
+ groupSkipReasons,
28
+ } from './models/sync.js';
29
+ import { loadCache, getCacheAgeMs, formatDuration } from './models/cache.js';
30
+ import { mapOpenRouterModels } from './models/mapper.js';
31
+
20
32
  // Store the current session state for use in command handlers
21
33
  let currentSessionState: OpenRouterSessionState | null = null;
22
34
  let sessionTrackingInstalled = false;
23
35
 
36
+ // Store startup cache state for notifications
37
+ let startupCacheInfo: { count: number; age: string } | undefined;
38
+
39
+ // =============================================================================
40
+ // Utility Functions
41
+ // =============================================================================
42
+
43
+ /**
44
+ * Format skipped models details for --skipped flag output.
45
+ */
46
+ function formatSkippedDetails(
47
+ skipCount: number,
48
+ groupedReasons: Record<string, number>,
49
+ skipReasons: Array<{ id: string; reason: string }>,
50
+ ): string {
51
+ if (skipCount === 0) {
52
+ return '\n\nNo skipped models';
53
+ }
54
+
55
+ let details = `\n\nOpenRouter skipped models: ${skipCount}\n`;
56
+ for (const [reason, count] of Object.entries(groupedReasons)) {
57
+ details += `\n${count} ${reason}\n`;
58
+ const modelsWithReason = skipReasons.filter((r) => r.reason === reason).map((r) => r.id);
59
+ for (const id of modelsWithReason) {
60
+ details += `- ${id}\n`;
61
+ }
62
+ }
63
+ return details;
64
+ }
65
+
24
66
  // =============================================================================
25
67
  // Session State Management
26
68
  // =============================================================================
@@ -49,7 +91,36 @@ function getCurrentSessionId(ctx: { sessionManager: { getSessionId(): string } }
49
91
  }
50
92
  }
51
93
 
52
- export default function (pi: ExtensionAPI) {
94
+ export default async function (pi: ExtensionAPI) {
95
+ // Eager cache load on extension startup (before any sessions)
96
+ startupCacheInfo = undefined;
97
+ let startupCacheWarning: string | undefined;
98
+ if (isSyncEnabled()) {
99
+ const cache = await loadCache().catch(() => null);
100
+
101
+ if (cache?.models.length) {
102
+ try {
103
+ const { configs } = mapOpenRouterModels(cache.models);
104
+
105
+ // Register models directly with Pi's OpenRouter provider
106
+ pi.registerProvider('openrouter', {
107
+ baseUrl: 'https://openrouter.ai/api/v1',
108
+ apiKey: 'OPENROUTER_API_KEY',
109
+ api: 'openai-completions',
110
+ models: configs,
111
+ authHeader: true,
112
+ });
113
+
114
+ // Store for session_start notification
115
+ const age = formatDuration(getCacheAgeMs(cache));
116
+ startupCacheInfo = { count: configs.length, age };
117
+ } catch (error) {
118
+ startupCacheInfo = undefined;
119
+ startupCacheWarning = `OpenRouter: cached models found but failed to register: ${error instanceof Error ? error.message : String(error)}`;
120
+ }
121
+ }
122
+ }
123
+
53
124
  // Install before_provider_request hook once
54
125
  if (!sessionTrackingInstalled) {
55
126
  sessionTrackingInstalled = true;
@@ -160,6 +231,27 @@ export default function (pi: ExtensionAPI) {
160
231
  stopBackgroundRefresh();
161
232
  });
162
233
 
234
+ // Notify on first session start after extension load
235
+ pi.on('session_start', (event, ctx) => {
236
+ if (!ctx.hasUI) return;
237
+
238
+ // Show a persistent status indicator
239
+ if (startupCacheInfo) {
240
+ const statusText = `OpenRouter ${startupCacheInfo.count} models`;
241
+ ctx.ui.setStatus('openrouter', ctx.ui.theme.fg('dim', statusText));
242
+ }
243
+
244
+ // Show a one-time notification on startup
245
+ if (event.reason === 'startup' && startupCacheInfo) {
246
+ const notice = `OpenRouter: ${startupCacheInfo.count} models loaded from cache (${startupCacheInfo.age} old). Run /openrouter models-sync to refresh.`;
247
+ ctx.ui.notify(notice, 'info');
248
+ }
249
+
250
+ if (event.reason === 'startup' && startupCacheWarning) {
251
+ ctx.ui.notify(startupCacheWarning, 'warning');
252
+ }
253
+ });
254
+
163
255
  pi.registerCommand('openrouter-usage', {
164
256
  description: 'Show OpenRouter usage: caps, spend, burn rate, and model breakdowns',
165
257
  getArgumentCompletions: () => null,
@@ -187,18 +279,29 @@ export default function (pi: ExtensionAPI) {
187
279
  },
188
280
  });
189
281
 
190
- // Single entry point with subcommands: /openrouter [usage|account|session]
282
+ // ============== MODELS COMMANDS (subcommands of /openrouter) ==============
283
+
284
+ // Single entry point with subcommands: /openrouter [usage|account|session|models-sync|models-status]
191
285
  pi.registerCommand('openrouter', {
192
- description: 'OpenRouter commands: usage, account, session',
286
+ description: 'OpenRouter commands: usage, account, session, models-sync, models-status',
193
287
  getArgumentCompletions: (prefix: string) => {
194
- const subcommands = ['usage', 'account', 'session'];
288
+ const subcommands = ['usage', 'account', 'session', 'models-sync', 'models-status'];
195
289
  const items = subcommands
196
290
  .filter((s) => s.startsWith(prefix))
197
291
  .map((s) => ({ value: s, label: s }));
198
292
  return items.length > 0 ? items : null;
199
293
  },
200
294
  handler: async (args, ctx) => {
201
- const subcommand = args.trim().split(/\s+/)[0] || '';
295
+ // Parse subcommand and flags
296
+ const parts = args.trim().split(/\s+/);
297
+ const subcommand = parts[0] || '';
298
+ const flags = parts.slice(1).reduce(
299
+ (acc, flag) => {
300
+ acc[flag] = true;
301
+ return acc;
302
+ },
303
+ {} as Record<string, boolean>,
304
+ );
202
305
 
203
306
  switch (subcommand) {
204
307
  case 'usage': {
@@ -214,8 +317,73 @@ export default function (pi: ExtensionAPI) {
214
317
  ctx.ui.notify(`OpenRouter session_id\n${getCurrentSessionId(ctx)}`, 'info');
215
318
  break;
216
319
  }
320
+ case 'models-sync': {
321
+ if (!isSyncEnabled()) {
322
+ ctx.ui.notify(
323
+ 'OpenRouter model sync is disabled. Set openrouterModelSync: true in ~/.pi/agent/settings.json to enable.',
324
+ 'error',
325
+ );
326
+ return;
327
+ }
328
+ const result = await syncModels(ctx);
329
+
330
+ // Display brief result using same color scheme as overlays
331
+ if (!result.success) {
332
+ let message = '';
333
+ if (result.source === 'cache') {
334
+ message = `OpenRouter models sync failed\n${result.registeredCount} registered from cache\nCache age: ${formatDuration(result.cacheAgeMs)}\nError: ${result.error}`;
335
+ } else {
336
+ message = `OpenRouter models unavailable\n0 registered\nError: ${result.error}`;
337
+ }
338
+ ctx.ui.notify(message, result.source === 'cache' ? 'warning' : 'error');
339
+ } else {
340
+ const message = `OpenRouter models synced\n${result.registeredCount} registered${result.skippedCount > 0 ? ` · ${result.skippedCount} skipped` : ''} · cache updated`;
341
+ ctx.ui.notify(message, 'info');
342
+ }
343
+ break;
344
+ }
345
+ case 'models-status': {
346
+ const state = getSyncState();
347
+ const skipReasons = await getSkipReasonsAsync();
348
+ const groupedReasons = groupSkipReasons(skipReasons);
349
+
350
+ // Get real-time cache age from disk
351
+ const cache = await loadCache();
352
+ const cacheAgeMs = cache ? getCacheAgeMs(cache) : null;
353
+
354
+ if (!state && !cache) {
355
+ ctx.ui.notify('OpenRouter models: not synced', 'error');
356
+ } else if (!state && cache) {
357
+ // Cache exists but no in-memory state (new Pi session)
358
+ const cachedCount = cache.models.length;
359
+ const message = `OpenRouter models cached\n${cachedCount} models in cache · age: ${formatDuration(cacheAgeMs)}\nRun '/openrouter models-sync' to register models`;
360
+ ctx.ui.notify(message, 'info');
361
+ } else if (state?.success) {
362
+ const skipCount = skipReasons.length;
363
+ let message = `OpenRouter models healthy\n${state.registeredCount} registered${skipCount > 0 ? ` · ${skipCount} skipped` : ''} · cache age: ${formatDuration(cacheAgeMs)}`;
364
+
365
+ if (flags['--skipped']) {
366
+ message += formatSkippedDetails(skipCount, groupedReasons, skipReasons);
367
+ }
368
+ ctx.ui.notify(message, 'info');
369
+ } else if (state?.source === 'cache') {
370
+ const skipCount = skipReasons.length;
371
+ let message = `OpenRouter models cached\n${state.registeredCount} registered${skipCount > 0 ? ` · ${skipCount} skipped` : ''}\nCache age: ${formatDuration(cacheAgeMs)}\nError: ${state.error}`;
372
+
373
+ if (flags['--skipped']) {
374
+ message += formatSkippedDetails(skipCount, groupedReasons, skipReasons);
375
+ }
376
+ ctx.ui.notify(message, 'warning');
377
+ } else {
378
+ ctx.ui.notify(
379
+ `OpenRouter models broken\n0 registered\nError: ${state?.error}`,
380
+ 'error',
381
+ );
382
+ }
383
+ break;
384
+ }
217
385
  default: {
218
- const available = ['usage', 'account', 'session'];
386
+ const available = ['usage', 'account', 'session', 'models-sync', 'models-status'];
219
387
  const message =
220
388
  available.length > 0
221
389
  ? `Available subcommands: ${available.join(', ')}${available.length > 1 ? '' : ''}`
@@ -358,7 +526,7 @@ async function showUsageOverlay(ctx: ExtensionContext, _subcommand?: string) {
358
526
  const cachedSummary = usageCache.get('usage');
359
527
  const lastFetchTimestamp = usageCache.getTimestamp('usage');
360
528
  const cachedMinutesAgo = lastFetchTimestamp
361
- ? Math.round((Date.now() - lastFetchTimestamp) / 60000)
529
+ ? Math.round((Date.now() - lastFetchTimestamp) / MS_PER_MINUTE)
362
530
  : null;
363
531
 
364
532
  if (cachedSummary) {
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdir, rm, writeFile } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { tmpdir } from 'os';
5
+ import { randomUUID } from 'crypto';
6
+ import { loadCache, saveCache, getCacheAgeMs, formatCacheAge, setCacheDir } from '../cache.js';
7
+ import { createMockCache } from '../../__tests__/fixtures.js';
8
+
9
+ // Each test gets its own isolated temp directory
10
+ let testCacheDir: string;
11
+
12
+ async function setupTestCache(): Promise<void> {
13
+ // Create isolated temp directory for this test
14
+ testCacheDir = join(tmpdir(), `pi-openrouter-test-${randomUUID()}`);
15
+ await mkdir(testCacheDir, { recursive: true });
16
+ // Tell the cache module to use our test directory
17
+ setCacheDir(testCacheDir);
18
+ }
19
+
20
+ async function cleanupTestCache(): Promise<void> {
21
+ // Reset cache dir to default (null means use default)
22
+ setCacheDir(null);
23
+ // Clean up temp directory
24
+ try {
25
+ await rm(testCacheDir, { recursive: true, force: true });
26
+ } catch {
27
+ // Ignore cleanup errors
28
+ }
29
+ }
30
+
31
+ describe('loadCache', () => {
32
+ beforeEach(setupTestCache);
33
+ afterEach(cleanupTestCache);
34
+
35
+ it('should return null when cache file does not exist', async () => {
36
+ const result = await loadCache();
37
+ expect(result).toBeNull();
38
+ });
39
+
40
+ it('should return parsed cache when file exists and is valid', async () => {
41
+ const mockCache = createMockCache({ timestamp: 1234567890 });
42
+ await saveCache(mockCache);
43
+
44
+ const result = await loadCache();
45
+ expect(result).not.toBeNull();
46
+ expect(result!.timestamp).toBe(1234567890);
47
+ expect(result!.models).toHaveLength(1);
48
+ expect(result!.models[0]!.id).toBe('test/model');
49
+ });
50
+
51
+ it('should return null when cache file contains invalid JSON', async () => {
52
+ await mkdir(testCacheDir, { recursive: true });
53
+ const cacheFile = join(testCacheDir, 'models-cache.json');
54
+ await writeFile(cacheFile, 'not valid json');
55
+
56
+ const result = await loadCache();
57
+ expect(result).toBeNull();
58
+ });
59
+
60
+ it('should return null when cache structure is invalid', async () => {
61
+ await mkdir(testCacheDir, { recursive: true });
62
+ const cacheFile = join(testCacheDir, 'models-cache.json');
63
+ await writeFile(cacheFile, JSON.stringify({ timestamp: 1234 })); // missing models
64
+
65
+ const result = await loadCache();
66
+ expect(result).toBeNull();
67
+ });
68
+ });
69
+
70
+ describe('saveCache', () => {
71
+ beforeEach(setupTestCache);
72
+ afterEach(cleanupTestCache);
73
+
74
+ it('should create cache file with valid JSON', async () => {
75
+ const mockCache = createMockCache();
76
+
77
+ await saveCache(mockCache);
78
+
79
+ // Verify it can be loaded back
80
+ const loaded = await loadCache();
81
+ expect(loaded).not.toBeNull();
82
+ expect(loaded!.timestamp).toBe(mockCache.timestamp);
83
+ expect(loaded!.models).toEqual(mockCache.models);
84
+ });
85
+
86
+ it('should overwrite existing cache file', async () => {
87
+ const firstCache = createMockCache({ timestamp: 1000 });
88
+ const secondCache = createMockCache({ timestamp: 2000 });
89
+
90
+ await saveCache(firstCache);
91
+ await saveCache(secondCache);
92
+
93
+ const loaded = await loadCache();
94
+ expect(loaded!.timestamp).toBe(2000);
95
+ });
96
+ });
97
+
98
+ describe('getCacheAgeMs', () => {
99
+ it('should calculate age correctly for recent cache', () => {
100
+ const cache = createMockCache({ timestamp: Date.now() - 60000 }); // 1 minute ago
101
+ const age = getCacheAgeMs(cache);
102
+
103
+ // Allow 100ms tolerance for test execution time
104
+ expect(age).toBeGreaterThanOrEqual(60000);
105
+ expect(age).toBeLessThan(61000);
106
+ });
107
+
108
+ it('should return 0 for cache with current timestamp', () => {
109
+ const cache = createMockCache({ timestamp: Date.now() });
110
+ const age = getCacheAgeMs(cache);
111
+ expect(age).toBeGreaterThanOrEqual(0);
112
+ expect(age).toBeLessThan(100);
113
+ });
114
+ });
115
+
116
+ describe('formatCacheAge', () => {
117
+ it('should return null for null cache', () => {
118
+ expect(formatCacheAge(null)).toBeNull();
119
+ });
120
+
121
+ it('should format minutes when less than 1 hour', () => {
122
+ const cache = createMockCache({ timestamp: Date.now() - 4 * 60000 }); // 4 minutes
123
+ expect(formatCacheAge(cache)).toBe('4m');
124
+ });
125
+
126
+ it('should format hours when between 1 hour and 1 day', () => {
127
+ const cache = createMockCache({ timestamp: Date.now() - 2 * 60 * 60000 }); // 2 hours
128
+ expect(formatCacheAge(cache)).toBe('2h');
129
+ });
130
+
131
+ it('should format days when over 1 day', () => {
132
+ const cache = createMockCache({ timestamp: Date.now() - 25 * 60 * 60000 }); // 25 hours
133
+ expect(formatCacheAge(cache)).toBe('1d');
134
+ });
135
+
136
+ it('should handle exact hour boundaries', () => {
137
+ const cache = createMockCache({ timestamp: Date.now() - 60 * 60000 }); // exactly 1 hour
138
+ expect(formatCacheAge(cache)).toBe('1h');
139
+ });
140
+ });