@robhowley/pi-openrouter 0.7.1 → 0.8.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,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,47 @@ 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
+
20
31
  // Store the current session state for use in command handlers
21
32
  let currentSessionState: OpenRouterSessionState | null = null;
22
33
  let sessionTrackingInstalled = false;
23
34
 
35
+ // =============================================================================
36
+ // Utility Functions
37
+ // =============================================================================
38
+
39
+ /**
40
+ * Format skipped models details for --skipped flag output.
41
+ */
42
+ function formatSkippedDetails(
43
+ skipCount: number,
44
+ groupedReasons: Record<string, number>,
45
+ skipReasons: Array<{ id: string; reason: string }>,
46
+ ): string {
47
+ if (skipCount === 0) {
48
+ return '\n\nNo skipped models';
49
+ }
50
+
51
+ let details = `\n\nOpenRouter skipped models: ${skipCount}\n`;
52
+ for (const [reason, count] of Object.entries(groupedReasons)) {
53
+ details += `\n${count} ${reason}\n`;
54
+ const modelsWithReason = skipReasons.filter((r) => r.reason === reason).map((r) => r.id);
55
+ for (const id of modelsWithReason) {
56
+ details += `- ${id}\n`;
57
+ }
58
+ }
59
+ return details;
60
+ }
61
+
24
62
  // =============================================================================
25
63
  // Session State Management
26
64
  // =============================================================================
@@ -187,18 +225,29 @@ export default function (pi: ExtensionAPI) {
187
225
  },
188
226
  });
189
227
 
190
- // Single entry point with subcommands: /openrouter [usage|account|session]
228
+ // ============== MODELS COMMANDS (subcommands of /openrouter) ==============
229
+
230
+ // Single entry point with subcommands: /openrouter [usage|account|session|models-sync|models-status]
191
231
  pi.registerCommand('openrouter', {
192
- description: 'OpenRouter commands: usage, account, session',
232
+ description: 'OpenRouter commands: usage, account, session, models-sync, models-status',
193
233
  getArgumentCompletions: (prefix: string) => {
194
- const subcommands = ['usage', 'account', 'session'];
234
+ const subcommands = ['usage', 'account', 'session', 'models-sync', 'models-status'];
195
235
  const items = subcommands
196
236
  .filter((s) => s.startsWith(prefix))
197
237
  .map((s) => ({ value: s, label: s }));
198
238
  return items.length > 0 ? items : null;
199
239
  },
200
240
  handler: async (args, ctx) => {
201
- const subcommand = args.trim().split(/\s+/)[0] || '';
241
+ // Parse subcommand and flags
242
+ const parts = args.trim().split(/\s+/);
243
+ const subcommand = parts[0] || '';
244
+ const flags = parts.slice(1).reduce(
245
+ (acc, flag) => {
246
+ acc[flag] = true;
247
+ return acc;
248
+ },
249
+ {} as Record<string, boolean>,
250
+ );
202
251
 
203
252
  switch (subcommand) {
204
253
  case 'usage': {
@@ -214,8 +263,73 @@ export default function (pi: ExtensionAPI) {
214
263
  ctx.ui.notify(`OpenRouter session_id\n${getCurrentSessionId(ctx)}`, 'info');
215
264
  break;
216
265
  }
266
+ case 'models-sync': {
267
+ if (!isSyncEnabled()) {
268
+ ctx.ui.notify(
269
+ 'OpenRouter model sync is disabled. Set openrouterModelSync: true in ~/.pi/agent/settings.json to enable.',
270
+ 'error',
271
+ );
272
+ return;
273
+ }
274
+ const result = await syncModels(ctx);
275
+
276
+ // Display brief result using same color scheme as overlays
277
+ if (!result.success) {
278
+ let message = '';
279
+ if (result.source === 'cache') {
280
+ message = `OpenRouter models sync failed\n${result.registeredCount} registered from cache\nCache age: ${formatDuration(result.cacheAgeMs)}\nError: ${result.error}`;
281
+ } else {
282
+ message = `OpenRouter models unavailable\n0 registered\nError: ${result.error}`;
283
+ }
284
+ ctx.ui.notify(message, result.source === 'cache' ? 'warning' : 'error');
285
+ } else {
286
+ const message = `OpenRouter models synced\n${result.registeredCount} registered${result.skippedCount > 0 ? ` · ${result.skippedCount} skipped` : ''} · cache updated`;
287
+ ctx.ui.notify(message, 'info');
288
+ }
289
+ break;
290
+ }
291
+ case 'models-status': {
292
+ const state = getSyncState();
293
+ const skipReasons = await getSkipReasonsAsync();
294
+ const groupedReasons = groupSkipReasons(skipReasons);
295
+
296
+ // Get real-time cache age from disk
297
+ const cache = await loadCache();
298
+ const cacheAgeMs = cache ? getCacheAgeMs(cache) : null;
299
+
300
+ if (!state && !cache) {
301
+ ctx.ui.notify('OpenRouter models: not synced', 'error');
302
+ } else if (!state && cache) {
303
+ // Cache exists but no in-memory state (new Pi session)
304
+ const cachedCount = cache.models.length;
305
+ const message = `OpenRouter models cached\n${cachedCount} models in cache · age: ${formatDuration(cacheAgeMs)}\nRun '/openrouter models-sync' to register models`;
306
+ ctx.ui.notify(message, 'info');
307
+ } else if (state?.success) {
308
+ const skipCount = skipReasons.length;
309
+ let message = `OpenRouter models healthy\n${state.registeredCount} registered${skipCount > 0 ? ` · ${skipCount} skipped` : ''} · cache age: ${formatDuration(cacheAgeMs)}`;
310
+
311
+ if (flags['--skipped']) {
312
+ message += formatSkippedDetails(skipCount, groupedReasons, skipReasons);
313
+ }
314
+ ctx.ui.notify(message, 'info');
315
+ } else if (state?.source === 'cache') {
316
+ const skipCount = skipReasons.length;
317
+ let message = `OpenRouter models cached\n${state.registeredCount} registered${skipCount > 0 ? ` · ${skipCount} skipped` : ''}\nCache age: ${formatDuration(cacheAgeMs)}\nError: ${state.error}`;
318
+
319
+ if (flags['--skipped']) {
320
+ message += formatSkippedDetails(skipCount, groupedReasons, skipReasons);
321
+ }
322
+ ctx.ui.notify(message, 'warning');
323
+ } else {
324
+ ctx.ui.notify(
325
+ `OpenRouter models broken\n0 registered\nError: ${state?.error}`,
326
+ 'error',
327
+ );
328
+ }
329
+ break;
330
+ }
217
331
  default: {
218
- const available = ['usage', 'account', 'session'];
332
+ const available = ['usage', 'account', 'session', 'models-sync', 'models-status'];
219
333
  const message =
220
334
  available.length > 0
221
335
  ? `Available subcommands: ${available.join(', ')}${available.length > 1 ? '' : ''}`
@@ -358,7 +472,7 @@ async function showUsageOverlay(ctx: ExtensionContext, _subcommand?: string) {
358
472
  const cachedSummary = usageCache.get('usage');
359
473
  const lastFetchTimestamp = usageCache.getTimestamp('usage');
360
474
  const cachedMinutesAgo = lastFetchTimestamp
361
- ? Math.round((Date.now() - lastFetchTimestamp) / 60000)
475
+ ? Math.round((Date.now() - lastFetchTimestamp) / MS_PER_MINUTE)
362
476
  : null;
363
477
 
364
478
  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
+ });
@@ -0,0 +1,221 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { mapOpenRouterModel, mapOpenRouterModels } from '../mapper.js';
3
+ import { createValidModel } from '../../__tests__/fixtures.js';
4
+ import type { OpenRouterModel, MapResult } from '../types.js';
5
+
6
+ describe('mapOpenRouterModel', () => {
7
+ it('should map a valid model correctly', () => {
8
+ const model = createValidModel();
9
+ const result = mapOpenRouterModel(model);
10
+
11
+ expect(result).not.toBeNull();
12
+ expect(result!.id).toBe('test/model');
13
+ expect(result!.name).toBe('Test Model');
14
+ expect(result!.contextWindow).toBe(128000);
15
+ expect(result!.maxTokens).toBe(4096); // default
16
+ expect(result!.cost.input).toBe(0.5); // 0.0000005 * 1M
17
+ expect(result!.cost.output).toBe(1.5); // 0.0000015 * 1M
18
+ });
19
+
20
+ it('should use name fallback to id when name missing', () => {
21
+ const model = createValidModel();
22
+ // @ts-expect-error: intentionally setting name to undefined for test
23
+ model.name = undefined;
24
+ const result = mapOpenRouterModel(model);
25
+
26
+ expect(result!.name).toBe('test/model');
27
+ });
28
+
29
+ describe('skip conditions', () => {
30
+ const skipCases: Array<{
31
+ name: string;
32
+ overrides: Partial<OpenRouterModel> | ((m: OpenRouterModel) => void);
33
+ }> = [
34
+ { name: 'empty id', overrides: { id: '' } },
35
+ { name: 'undefined id (deleted property)', overrides: (m) => delete (m as any).id },
36
+ { name: 'missing pricing.prompt', overrides: { pricing: { completion: '0.000001' } as any } },
37
+ { name: 'missing pricing.completion', overrides: { pricing: { prompt: '0.000001' } as any } },
38
+ {
39
+ name: 'missing context_length and top_provider',
40
+ overrides: { context_length: 0, top_provider: { context_length: 0 } },
41
+ },
42
+ {
43
+ name: 'non-text output modalities',
44
+ overrides: { architecture: { output_modalities: ['image', 'audio'] } as any },
45
+ },
46
+ ];
47
+
48
+ skipCases.forEach(({ name, overrides }) => {
49
+ it(`should skip model with ${name}`, () => {
50
+ const model = createValidModel();
51
+ if (typeof overrides === 'function') {
52
+ overrides(model);
53
+ } else {
54
+ Object.assign(model, overrides);
55
+ }
56
+ expect(mapOpenRouterModel(model)).toBeNull();
57
+ });
58
+ });
59
+ });
60
+
61
+ describe('reasoning detection', () => {
62
+ it("should detect reasoning from 'reasoning' parameter", () => {
63
+ const model = createValidModel({
64
+ supported_parameters: ['temperature', 'reasoning', 'max_tokens'],
65
+ });
66
+ expect(mapOpenRouterModel(model)!.reasoning).toBe(true);
67
+ });
68
+
69
+ it("should detect reasoning from 'include_reasoning' parameter", () => {
70
+ const model = createValidModel({
71
+ supported_parameters: ['include_reasoning'],
72
+ });
73
+ expect(mapOpenRouterModel(model)!.reasoning).toBe(true);
74
+ });
75
+
76
+ it('should not detect reasoning when neither parameter present', () => {
77
+ const model = createValidModel({
78
+ supported_parameters: ['temperature', 'max_tokens'],
79
+ });
80
+ expect(mapOpenRouterModel(model)!.reasoning).toBe(false);
81
+ });
82
+
83
+ it('should handle missing supported_parameters', () => {
84
+ const model = createValidModel({ supported_parameters: [] as any });
85
+ expect(mapOpenRouterModel(model)!.reasoning).toBe(false);
86
+ });
87
+ });
88
+
89
+ describe('input modality detection', () => {
90
+ it('should detect image support from input_modalities', () => {
91
+ const model = createValidModel({
92
+ architecture: { input_modalities: ['text', 'image'] } as any,
93
+ });
94
+ expect(mapOpenRouterModel(model)!.input).toEqual(['text', 'image']);
95
+ });
96
+
97
+ it('should default to text-only without image in modalities', () => {
98
+ const model = createValidModel({
99
+ architecture: { input_modalities: ['text'] } as any,
100
+ });
101
+ expect(mapOpenRouterModel(model)!.input).toEqual(['text']);
102
+ });
103
+
104
+ it('should default to text-only when architecture missing', () => {
105
+ const model = createValidModel({ architecture: null as any });
106
+ expect(mapOpenRouterModel(model)!.input).toEqual(['text']);
107
+ });
108
+ });
109
+
110
+ describe('cost calculation', () => {
111
+ it('should convert per-token to per-1M-tokens', () => {
112
+ const model = createValidModel({
113
+ pricing: { prompt: '0.000002', completion: '0.000006' },
114
+ });
115
+ const result = mapOpenRouterModel(model);
116
+ expect(result!.cost.input).toBe(2.0); // 0.000002 * 1,000,000
117
+ expect(result!.cost.output).toBe(6.0); // 0.000006 * 1,000,000
118
+ });
119
+
120
+ it('should handle cache pricing when present', () => {
121
+ const model = createValidModel({
122
+ pricing: {
123
+ prompt: '0.000001',
124
+ completion: '0.000003',
125
+ input_cache_read: '0.0000005',
126
+ input_cache_write: '0.000001',
127
+ },
128
+ });
129
+ const result = mapOpenRouterModel(model);
130
+ expect(result!.cost.cacheRead).toBe(0.5);
131
+ expect(result!.cost.cacheWrite).toBe(1.0);
132
+ });
133
+
134
+ it('should default cache pricing to 0 when missing', () => {
135
+ const model = createValidModel({
136
+ pricing: { prompt: '0.000001', completion: '0.000003' },
137
+ });
138
+ const result = mapOpenRouterModel(model);
139
+ expect(result!.cost.cacheRead).toBe(0);
140
+ expect(result!.cost.cacheWrite).toBe(0);
141
+ });
142
+ });
143
+
144
+ describe('contextWindow fallback', () => {
145
+ it('should prefer top_provider.context_length', () => {
146
+ const model = createValidModel({
147
+ context_length: 8000,
148
+ top_provider: { context_length: 128000 },
149
+ });
150
+ expect(mapOpenRouterModel(model)!.contextWindow).toBe(128000);
151
+ });
152
+
153
+ it('should fall back to context_length', () => {
154
+ const model = createValidModel({
155
+ context_length: 32000,
156
+ top_provider: null as any,
157
+ });
158
+ expect(mapOpenRouterModel(model)!.contextWindow).toBe(32000);
159
+ });
160
+ });
161
+
162
+ describe('maxTokens fallback', () => {
163
+ it('should prefer top_provider.max_completion_tokens', () => {
164
+ const model = createValidModel({
165
+ top_provider: { max_completion_tokens: 8192 },
166
+ per_request_limits: { completion_tokens: 4096 },
167
+ });
168
+ expect(mapOpenRouterModel(model)!.maxTokens).toBe(8192);
169
+ });
170
+
171
+ it('should fall back to per_request_limits.completion_tokens', () => {
172
+ const model = createValidModel({
173
+ per_request_limits: { completion_tokens: 8192 },
174
+ });
175
+ expect(mapOpenRouterModel(model)!.maxTokens).toBe(8192);
176
+ });
177
+
178
+ it('should use default when neither present', () => {
179
+ const model = createValidModel();
180
+ expect(mapOpenRouterModel(model)!.maxTokens).toBe(4096);
181
+ });
182
+ });
183
+ });
184
+
185
+ describe('mapOpenRouterModels', () => {
186
+ it('should map multiple models and track skips', () => {
187
+ const models: OpenRouterModel[] = [
188
+ createValidModel({ id: 'model/valid-1', name: 'Valid 1' }),
189
+ createValidModel({ id: '', name: 'Invalid (no id)' }), // will skip
190
+ createValidModel({ id: 'model/valid-2', name: 'Valid 2' }),
191
+ ];
192
+
193
+ const result: MapResult = mapOpenRouterModels(models);
194
+
195
+ expect(result.configs).toHaveLength(2);
196
+ expect(result.skipped).toBe(1);
197
+ expect(result.configs[0]!.id).toBe('model/valid-1');
198
+ expect(result.configs[1]!.id).toBe('model/valid-2');
199
+ });
200
+
201
+ it('should handle empty array', () => {
202
+ const result = mapOpenRouterModels([] as OpenRouterModel[]);
203
+ expect(result.configs).toHaveLength(0);
204
+ expect(result.skipped).toBe(0);
205
+ });
206
+
207
+ it('should skip all invalid models', () => {
208
+ const models: OpenRouterModel[] = [
209
+ createValidModel({ id: '' }),
210
+ createValidModel({ pricing: { prompt: '0.000001' } as any }),
211
+ createValidModel({
212
+ context_length: 0,
213
+ top_provider: { context_length: 0 },
214
+ }),
215
+ ];
216
+
217
+ const result = mapOpenRouterModels(models);
218
+ expect(result.configs).toHaveLength(0);
219
+ expect(result.skipped).toBe(3);
220
+ });
221
+ });