@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.
- package/README.md +43 -3
- package/extensions/openrouter/__tests__/client.test.ts +169 -0
- package/extensions/openrouter/__tests__/fixtures.ts +134 -0
- package/extensions/openrouter/__tests__/format.test.ts +25 -59
- package/extensions/openrouter/__tests__/session.test.ts +309 -69
- package/extensions/openrouter/client.ts +70 -12
- package/extensions/openrouter/index.ts +138 -18
- package/extensions/openrouter/models/__tests__/cache.test.ts +140 -0
- package/extensions/openrouter/models/__tests__/mapper.test.ts +221 -0
- package/extensions/openrouter/models/__tests__/sync.test.ts +313 -0
- package/extensions/openrouter/models/cache.ts +105 -0
- package/extensions/openrouter/models/mapper.ts +182 -0
- package/extensions/openrouter/models/sync.ts +312 -0
- package/extensions/openrouter/models/types.ts +148 -0
- package/extensions/openrouter/session.ts +20 -5
- package/package.json +2 -2
|
@@ -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
|
-
{
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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) /
|
|
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
|
+
});
|