@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.
- 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 +1 -81
- package/extensions/openrouter/client.ts +70 -12
- package/extensions/openrouter/index.ts +120 -6
- 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/package.json +2 -2
|
@@ -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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
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) /
|
|
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
|
+
});
|