@robhowley/pi-openrouter 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ });
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Tests for the sync engine.
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
6
+ import type { ExtensionContext, ModelRegistry } from '@mariozechner/pi-coding-agent';
7
+ import type { SyncResult } from '../types.js';
8
+
9
+ // Import modules
10
+ import {
11
+ syncModels,
12
+ setSyncState,
13
+ getSyncState,
14
+ getStatusText,
15
+ areModelsAvailable,
16
+ } from '../sync.js';
17
+ import { fetchUserModels, AuthError } from '../../client.js';
18
+ import { loadCache } from '../cache.js';
19
+
20
+ // Mock the client module to control API behavior
21
+ vi.mock('../../client.js', () => ({
22
+ fetchUserModels: vi.fn(),
23
+ isConfigured: vi.fn(),
24
+ getApiKey: vi.fn(),
25
+ AuthError: class AuthError extends Error {
26
+ constructor(message: string) {
27
+ super(message);
28
+ this.name = 'AuthError';
29
+ }
30
+ },
31
+ }));
32
+
33
+ // Mock the cache module
34
+ vi.mock('../cache.js', () => ({
35
+ loadCache: vi.fn(),
36
+ saveCache: vi.fn(),
37
+ setCacheDir: vi.fn(),
38
+ }));
39
+
40
+ /**
41
+ * Factory for creating minimal mock ExtensionContext.
42
+ * Only implements methods/properties actually used by sync tests.
43
+ */
44
+ function createMockExtensionContext(
45
+ overrides: {
46
+ registerProvider?: typeof vi.fn;
47
+ } = {},
48
+ ): ExtensionContext {
49
+ const mockFn = vi.fn;
50
+
51
+ return {
52
+ modelRegistry: {
53
+ registerProvider: overrides.registerProvider ?? mockFn(),
54
+ } as unknown as ModelRegistry,
55
+ ui: createMockUI(),
56
+ hasUI: true,
57
+ cwd: '/tmp',
58
+ sessionManager: createMockSessionManager(),
59
+ model: undefined,
60
+ isIdle: () => true,
61
+ signal: undefined,
62
+ abort: mockFn(),
63
+ hasPendingMessages: () => false,
64
+ shutdown: mockFn(),
65
+ getContextUsage: mockFn(),
66
+ compact: mockFn(),
67
+ getSystemPrompt: mockFn(),
68
+ } satisfies ExtensionContext;
69
+ }
70
+
71
+ function createMockUI(): ExtensionContext['ui'] {
72
+ const mockFn = vi.fn;
73
+ return {
74
+ select: mockFn(),
75
+ confirm: mockFn(),
76
+ input: mockFn(),
77
+ notify: mockFn(),
78
+ onTerminalInput: mockFn(),
79
+ setStatus: mockFn(),
80
+ setWorkingMessage: mockFn(),
81
+ setWorkingIndicator: mockFn(),
82
+ setHiddenThinkingLabel: mockFn(),
83
+ setWidget: mockFn(),
84
+ setFooter: mockFn(),
85
+ setHeader: mockFn(),
86
+ setTitle: mockFn(),
87
+ custom: mockFn(),
88
+ pasteToEditor: mockFn(),
89
+ setEditorText: mockFn(),
90
+ getEditorText: mockFn(),
91
+ editor: mockFn(),
92
+ setEditorComponent: mockFn(),
93
+ theme: {} as any,
94
+ getAllThemes: mockFn(),
95
+ getTheme: mockFn(),
96
+ setTheme: mockFn(),
97
+ getToolsExpanded: mockFn(),
98
+ setToolsExpanded: mockFn(),
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Creates a mock session manager for testing.
104
+ * Uses `as any` since we only need the mock to satisfy ExtensionContext type,
105
+ * and sync tests don't actually use the session manager.
106
+ */
107
+ function createMockSessionManager(): ExtensionContext['sessionManager'] {
108
+ const mockFn = vi.fn;
109
+ return {
110
+ getCurrentSessionId: mockFn(),
111
+ getCurrentSessionPath: mockFn(),
112
+ getEntry: mockFn(),
113
+ getEntryHistory: mockFn(),
114
+ getEntryById: mockFn(),
115
+ getBranchEntries: mockFn(),
116
+ getBranchSummary: mockFn(),
117
+ getRecentEntries: mockFn(),
118
+ getEntryCount: mockFn(),
119
+ } as any;
120
+ }
121
+
122
+ describe('syncModels', () => {
123
+ const mockRegisterProvider = vi.fn();
124
+ const mockCtx = createMockExtensionContext({ registerProvider: mockRegisterProvider });
125
+
126
+ beforeEach(() => {
127
+ vi.resetAllMocks();
128
+ (setSyncState as (result: SyncResult | null) => void)(null);
129
+ // Explicitly delete API key
130
+ delete process.env['OPENROUTER_API_KEY'];
131
+ });
132
+
133
+ it('should return failure when API key is missing and no cache', async () => {
134
+ // Ensure API key is not set
135
+ delete process.env['OPENROUTER_API_KEY'];
136
+ // Mock fetchUserModels to throw AuthError
137
+ vi.mocked(fetchUserModels).mockRejectedValueOnce(new AuthError('OPENROUTER_API_KEY not set'));
138
+ // Mock loadCache to return null (no cache available)
139
+ vi.mocked(loadCache).mockResolvedValueOnce(null);
140
+
141
+ const result = await syncModels(mockCtx);
142
+
143
+ expect(result.success).toBe(false);
144
+ expect(result.registeredCount).toBe(0);
145
+ expect(result.source).toBe('none');
146
+ expect(result.error).toContain('OPENROUTER_API_KEY not set');
147
+ });
148
+
149
+ it('should sync models from API and register with provider', async () => {
150
+ // Mock successful API response with minimal model data
151
+ const mockModel = {
152
+ id: 'anthropic/claude-3-opus',
153
+ name: 'Claude 3 Opus',
154
+ architecture: {
155
+ inputModalities: ['text', 'image'],
156
+ outputModalities: ['text'],
157
+ },
158
+ contextLength: 200000,
159
+ pricing: {
160
+ prompt: 0.000015,
161
+ completion: 0.000075,
162
+ inputCacheRead: 0.0000015,
163
+ inputCacheWrite: 0.0000075,
164
+ },
165
+ supportedParameters: ['reasoning'],
166
+ topProvider: {
167
+ contextLength: 200000,
168
+ maxCompletionTokens: 4096,
169
+ },
170
+ };
171
+
172
+ vi.mocked(fetchUserModels).mockResolvedValueOnce({
173
+ data: [mockModel],
174
+ } as any);
175
+
176
+ vi.mocked(loadCache).mockResolvedValueOnce(null);
177
+
178
+ const result = await syncModels(mockCtx);
179
+
180
+ expect(result.success).toBe(true);
181
+ expect(result.source).toBe('api');
182
+ expect(result.registeredCount).toBeGreaterThan(0);
183
+ expect(mockRegisterProvider).toHaveBeenCalled();
184
+ });
185
+ });
186
+
187
+ describe('syncState management', () => {
188
+ it('should store and retrieve sync state', () => {
189
+ const mockResult: SyncResult = {
190
+ success: true,
191
+ registeredCount: 10,
192
+ skippedCount: 2,
193
+ source: 'api',
194
+ cacheUpdated: true,
195
+ cacheAgeMs: null,
196
+ error: null,
197
+ };
198
+
199
+ setSyncState(mockResult);
200
+
201
+ const retrieved = getSyncState();
202
+ expect(retrieved).toEqual(mockResult);
203
+ });
204
+
205
+ it('should return null when no state set', () => {
206
+ (setSyncState as (result: SyncResult | null) => void)(null);
207
+ expect(getSyncState()).toBeNull();
208
+ });
209
+ });
210
+
211
+ describe('getStatusText', () => {
212
+ beforeEach(() => {
213
+ (setSyncState as (result: SyncResult | null) => void)(null);
214
+ });
215
+
216
+ it('should return not synced when no state', () => {
217
+ expect(getStatusText()).toBe('OpenRouter models: not synced');
218
+ });
219
+
220
+ it('should return healthy for successful sync', () => {
221
+ setSyncState({
222
+ success: true,
223
+ registeredCount: 312,
224
+ skippedCount: 18,
225
+ source: 'api',
226
+ cacheUpdated: true,
227
+ cacheAgeMs: null,
228
+ error: null,
229
+ } as SyncResult);
230
+ const text = getStatusText();
231
+ expect(text).toContain('healthy');
232
+ expect(text).toContain('312 registered');
233
+ });
234
+
235
+ it('should return cached for cache fallback', () => {
236
+ setSyncState({
237
+ success: false,
238
+ registeredCount: 287,
239
+ skippedCount: 21,
240
+ source: 'cache',
241
+ cacheUpdated: false,
242
+ cacheAgeMs: 7200000, // 2 hours
243
+ error: '401 unauthorized',
244
+ } as SyncResult);
245
+ const text = getStatusText();
246
+ expect(text).toContain('cached');
247
+ expect(text).toContain('287 registered');
248
+ });
249
+
250
+ it('should return broken for complete failure', () => {
251
+ setSyncState({
252
+ success: false,
253
+ registeredCount: 0,
254
+ skippedCount: 0,
255
+ source: 'none',
256
+ cacheUpdated: false,
257
+ cacheAgeMs: null,
258
+ error: 'missing or invalid OpenRouter auth',
259
+ } as SyncResult);
260
+ const text = getStatusText();
261
+ expect(text).toContain('broken');
262
+ expect(text).toContain('0 registered');
263
+ });
264
+ });
265
+
266
+ describe('areModelsAvailable', () => {
267
+ beforeEach(() => {
268
+ (setSyncState as (result: SyncResult | null) => void)(null);
269
+ });
270
+
271
+ it('should return false when no state', async () => {
272
+ expect(await areModelsAvailable()).toBe(false);
273
+ });
274
+
275
+ it('should return true when models are synced', async () => {
276
+ setSyncState({
277
+ success: true,
278
+ registeredCount: 10,
279
+ skippedCount: 0,
280
+ source: 'api',
281
+ cacheUpdated: true,
282
+ cacheAgeMs: null,
283
+ error: null,
284
+ } as SyncResult);
285
+ expect(await areModelsAvailable()).toBe(true);
286
+ });
287
+
288
+ it('should return true when using cache (models still available)', async () => {
289
+ setSyncState({
290
+ success: false,
291
+ registeredCount: 5,
292
+ skippedCount: 0,
293
+ source: 'cache',
294
+ cacheUpdated: false,
295
+ cacheAgeMs: 7200000,
296
+ error: 'API error',
297
+ } as SyncResult);
298
+ expect(await areModelsAvailable()).toBe(true);
299
+ });
300
+
301
+ it('should return false when no models registered', async () => {
302
+ setSyncState({
303
+ success: false,
304
+ registeredCount: 0,
305
+ skippedCount: 0,
306
+ source: 'none',
307
+ cacheUpdated: false,
308
+ cacheAgeMs: null,
309
+ error: 'Complete failure',
310
+ } as SyncResult);
311
+ expect(await areModelsAvailable()).toBe(false);
312
+ });
313
+ });
@@ -0,0 +1,105 @@
1
+ import { readFile, writeFile, mkdir } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+ import type { ModelsCache } from './types.js';
5
+ import { MS_PER_MINUTE } from './types.js';
6
+
7
+ const CACHE_FILENAME = 'models-cache.json';
8
+ const DEFAULT_CACHE_DIR = join(homedir(), '.pi', 'openrouter');
9
+
10
+ // Allow overriding cache directory for testing
11
+ let cacheDirOverride: string | null = null;
12
+
13
+ /**
14
+ * Get the cache directory.
15
+ * Uses override if set (for testing), otherwise uses default.
16
+ */
17
+ function getCacheDir(): string {
18
+ return cacheDirOverride ?? DEFAULT_CACHE_DIR;
19
+ }
20
+
21
+ /**
22
+ * Set a custom cache directory (for testing).
23
+ * Pass null to reset to default.
24
+ */
25
+ export function setCacheDir(dir: string | null): void {
26
+ cacheDirOverride = dir;
27
+ }
28
+
29
+ /**
30
+ * Get the full path to the cache file.
31
+ */
32
+ function getCachePath(): string {
33
+ return join(getCacheDir(), CACHE_FILENAME);
34
+ }
35
+
36
+ /**
37
+ * Ensure the cache directory exists.
38
+ */
39
+ async function ensureCacheDir(): Promise<void> {
40
+ await mkdir(getCacheDir(), { recursive: true });
41
+ }
42
+
43
+ /**
44
+ * Load cached models from disk.
45
+ * Returns null if cache doesn't exist or is corrupted.
46
+ */
47
+ export async function loadCache(): Promise<ModelsCache | null> {
48
+ try {
49
+ const cachePath = getCachePath();
50
+ const data = await readFile(cachePath, 'utf-8');
51
+ const parsed = JSON.parse(data) as ModelsCache;
52
+
53
+ // Validate structure
54
+ if (!parsed.models || !Array.isArray(parsed.models) || typeof parsed.timestamp !== 'number') {
55
+ return null;
56
+ }
57
+
58
+ return parsed;
59
+ } catch {
60
+ // File doesn't exist, permission error, or invalid JSON
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Save models to cache on disk.
67
+ */
68
+ export async function saveCache(cache: ModelsCache): Promise<void> {
69
+ await ensureCacheDir();
70
+ const cachePath = getCachePath();
71
+ await writeFile(cachePath, JSON.stringify(cache, null, 2));
72
+ }
73
+
74
+ /**
75
+ * Get the age of the cache in milliseconds.
76
+ */
77
+ export function getCacheAgeMs(cache: ModelsCache): number {
78
+ return Date.now() - cache.timestamp;
79
+ }
80
+
81
+ /**
82
+ * Format milliseconds duration for display.
83
+ * Examples: "<1m", "4m", "2h", "1d"
84
+ */
85
+ export function formatDuration(ms: number | null): string {
86
+ if (ms === null) return 'unknown';
87
+
88
+ const minutes = Math.floor(ms / MS_PER_MINUTE);
89
+ if (minutes < 1) return '<1m';
90
+ if (minutes < 60) return `${minutes}m`;
91
+
92
+ const hours = Math.floor(minutes / 60);
93
+ if (hours < 24) return `${hours}h`;
94
+
95
+ return `${Math.floor(hours / 24)}d`;
96
+ }
97
+
98
+ /**
99
+ * Format cache age for display.
100
+ * Examples: "4m", "2h", "1d" (returns null for null cache)
101
+ */
102
+ export function formatCacheAge(cache: ModelsCache | null): string | null {
103
+ if (!cache) return null;
104
+ return formatDuration(getCacheAgeMs(cache));
105
+ }