@robhowley/pi-openrouter 0.7.0 → 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,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
  // =============================================================================
@@ -95,10 +133,14 @@ export default function (pi: ExtensionAPI) {
95
133
  if (!message) return;
96
134
 
97
135
  // Check if this is an OpenRouter request based on the message content/model
136
+ // Include url/endpoint from turnEvent so isOpenRouterRequest can check them
98
137
  const isOpenRouter = isOpenRouterRequest(
99
- { type: 'before_provider_request', payload: message } as unknown as Parameters<
100
- typeof isOpenRouterRequest
101
- >[0],
138
+ {
139
+ type: 'before_provider_request',
140
+ payload: message,
141
+ url: turnEvent['url'],
142
+ endpoint: turnEvent['endpoint'],
143
+ } as unknown as Parameters<typeof isOpenRouterRequest>[0],
102
144
  ctx,
103
145
  );
104
146
  if (!isOpenRouter) return;
@@ -130,18 +172,20 @@ export default function (pi: ExtensionAPI) {
130
172
  // Calculate total cost from usage.cost.total
131
173
  const totalCost = usage.cost?.total;
132
174
 
133
- const localEvent = {
175
+ const localEvent: LocalUsageEvent = {
134
176
  id: crypto.randomUUID(),
135
- generationId: message['responseId'],
177
+ generationId: String(message['responseId'] ?? ''),
136
178
  sessionId: getCurrentSessionId(ctx),
137
179
  completedAt: new Date().toISOString(),
138
- model: modelToLog,
139
- promptTokens: usage.input,
140
- completionTokens: usage.output,
141
- cacheReadTokens: usage.cacheRead,
142
- cacheWriteTokens: usage.cacheWrite,
143
- cost: totalCost,
144
- } as LocalUsageEvent;
180
+ model: modelToLog ?? 'unknown',
181
+ requests: 1,
182
+ promptTokens: usage.input ?? 0,
183
+ completionTokens: usage.output ?? 0,
184
+ reasoningTokens: 0,
185
+ cacheReadTokens: usage.cacheRead ?? 0,
186
+ cacheWriteTokens: usage.cacheWrite ?? 0,
187
+ cost: totalCost ?? 0,
188
+ };
145
189
 
146
190
  // Write to local JSONL - fail open (don't throw)
147
191
  writeLocalUsage(localEvent).catch(() => {});
@@ -181,18 +225,29 @@ export default function (pi: ExtensionAPI) {
181
225
  },
182
226
  });
183
227
 
184
- // 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]
185
231
  pi.registerCommand('openrouter', {
186
- description: 'OpenRouter commands: usage, account, session',
232
+ description: 'OpenRouter commands: usage, account, session, models-sync, models-status',
187
233
  getArgumentCompletions: (prefix: string) => {
188
- const subcommands = ['usage', 'account', 'session'];
234
+ const subcommands = ['usage', 'account', 'session', 'models-sync', 'models-status'];
189
235
  const items = subcommands
190
236
  .filter((s) => s.startsWith(prefix))
191
237
  .map((s) => ({ value: s, label: s }));
192
238
  return items.length > 0 ? items : null;
193
239
  },
194
240
  handler: async (args, ctx) => {
195
- 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
+ );
196
251
 
197
252
  switch (subcommand) {
198
253
  case 'usage': {
@@ -208,8 +263,73 @@ export default function (pi: ExtensionAPI) {
208
263
  ctx.ui.notify(`OpenRouter session_id\n${getCurrentSessionId(ctx)}`, 'info');
209
264
  break;
210
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
+ }
211
331
  default: {
212
- const available = ['usage', 'account', 'session'];
332
+ const available = ['usage', 'account', 'session', 'models-sync', 'models-status'];
213
333
  const message =
214
334
  available.length > 0
215
335
  ? `Available subcommands: ${available.join(', ')}${available.length > 1 ? '' : ''}`
@@ -352,7 +472,7 @@ async function showUsageOverlay(ctx: ExtensionContext, _subcommand?: string) {
352
472
  const cachedSummary = usageCache.get('usage');
353
473
  const lastFetchTimestamp = usageCache.getTimestamp('usage');
354
474
  const cachedMinutesAgo = lastFetchTimestamp
355
- ? Math.round((Date.now() - lastFetchTimestamp) / 60000)
475
+ ? Math.round((Date.now() - lastFetchTimestamp) / MS_PER_MINUTE)
356
476
  : null;
357
477
 
358
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
+ });