@robhowley/pi-openrouter 0.11.1 → 0.12.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 +27 -1
- package/extensions/openrouter/__tests__/commands.test.ts +142 -44
- package/extensions/openrouter/__tests__/fixtures.ts +1 -0
- package/extensions/openrouter/__tests__/hooks.test.ts +82 -3
- package/extensions/openrouter/commands.ts +95 -17
- package/extensions/openrouter/hooks.ts +24 -4
- package/extensions/openrouter/models/__tests__/cache.test.ts +20 -1
- package/extensions/openrouter/models/__tests__/sync.test.ts +398 -222
- package/extensions/openrouter/models/cache.ts +27 -5
- package/extensions/openrouter/models/sync.ts +230 -77
- package/extensions/openrouter/models/types.ts +36 -3
- package/package.json +2 -2
|
@@ -4,10 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
6
6
|
import type { ExtensionContext, ModelRegistry } from '@mariozechner/pi-coding-agent';
|
|
7
|
-
import type { PiModelConfig, SyncResult } from '../types.js';
|
|
7
|
+
import type { CatalogMode, PiModelConfig, SkipReason, SyncResult } from '../types.js';
|
|
8
8
|
import { ROUTER_ALIASES } from '../types.js';
|
|
9
9
|
import { createPiModelConfig, createValidModel } from '../../__tests__/fixtures.js';
|
|
10
|
-
|
|
11
10
|
// Import modules
|
|
12
11
|
import {
|
|
13
12
|
syncModels,
|
|
@@ -16,6 +15,12 @@ import {
|
|
|
16
15
|
getStatusText,
|
|
17
16
|
areModelsAvailable,
|
|
18
17
|
includeBuiltinRouterModels,
|
|
18
|
+
isExplicitFreeModelId,
|
|
19
|
+
filterModelsForCatalogMode,
|
|
20
|
+
getActiveCatalogState,
|
|
21
|
+
getBuiltinRoutersForCatalogMode,
|
|
22
|
+
setActiveCatalogState,
|
|
23
|
+
getSkipReasonsAsync,
|
|
19
24
|
} from '../sync.js';
|
|
20
25
|
import { fetchUserModels, AuthError } from '../../client.js';
|
|
21
26
|
import { loadCache, saveCache } from '../cache.js';
|
|
@@ -122,115 +127,298 @@ function createMockSessionManager(): ExtensionContext['sessionManager'] {
|
|
|
122
127
|
} as any;
|
|
123
128
|
}
|
|
124
129
|
|
|
130
|
+
function createSdkModel(overrides: Record<string, unknown> = {}) {
|
|
131
|
+
return {
|
|
132
|
+
id: 'test/model',
|
|
133
|
+
name: 'Test Model',
|
|
134
|
+
architecture: {
|
|
135
|
+
inputModalities: ['text'],
|
|
136
|
+
outputModalities: ['text'],
|
|
137
|
+
},
|
|
138
|
+
contextLength: 128000,
|
|
139
|
+
pricing: {
|
|
140
|
+
prompt: 0.000001,
|
|
141
|
+
completion: 0.000002,
|
|
142
|
+
inputCacheRead: 0,
|
|
143
|
+
inputCacheWrite: 0,
|
|
144
|
+
},
|
|
145
|
+
supportedParameters: [],
|
|
146
|
+
topProvider: {
|
|
147
|
+
contextLength: 128000,
|
|
148
|
+
maxCompletionTokens: 4096,
|
|
149
|
+
},
|
|
150
|
+
...overrides,
|
|
151
|
+
} as any;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function createSkipReasons(count: number): SkipReason[] {
|
|
155
|
+
return Array.from({ length: count }, (_, index) => ({
|
|
156
|
+
id: `bad/model-${index + 1}`,
|
|
157
|
+
reason: 'missing context window',
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function createSyncResult(overrides: Partial<SyncResult> = {}): SyncResult {
|
|
162
|
+
return {
|
|
163
|
+
success: true,
|
|
164
|
+
outcome: 'synced',
|
|
165
|
+
requestedMode: 'full',
|
|
166
|
+
catalogMode: 'full',
|
|
167
|
+
registeredCount: 1,
|
|
168
|
+
skippedCount: 0,
|
|
169
|
+
skippedDetails: [],
|
|
170
|
+
source: 'api',
|
|
171
|
+
cacheUpdated: true,
|
|
172
|
+
cacheAgeMs: 0,
|
|
173
|
+
error: null,
|
|
174
|
+
...overrides,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
describe('catalog mode helpers', () => {
|
|
179
|
+
it('detects explicit free model IDs', () => {
|
|
180
|
+
expect(isExplicitFreeModelId('provider/model:free')).toBe(true);
|
|
181
|
+
expect(isExplicitFreeModelId('provider/model')).toBe(false);
|
|
182
|
+
expect(isExplicitFreeModelId('openrouter/free')).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('filters raw models before mapping for free-only mode', () => {
|
|
186
|
+
const models = [
|
|
187
|
+
{ id: 'provider/paid-model' },
|
|
188
|
+
{ id: 'provider/free-model:free' },
|
|
189
|
+
{ id: 'openrouter/free' },
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
expect(filterModelsForCatalogMode(models, 'full').map((model) => model.id)).toEqual([
|
|
193
|
+
'provider/paid-model',
|
|
194
|
+
'provider/free-model:free',
|
|
195
|
+
'openrouter/free',
|
|
196
|
+
]);
|
|
197
|
+
expect(filterModelsForCatalogMode(models, 'free-only').map((model) => model.id)).toEqual([
|
|
198
|
+
'provider/free-model:free',
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('returns mode-specific built-in routers', () => {
|
|
203
|
+
expect(getBuiltinRoutersForCatalogMode('full').map((model) => model.id)).toEqual(
|
|
204
|
+
ROUTER_ALIASES,
|
|
205
|
+
);
|
|
206
|
+
expect(getBuiltinRoutersForCatalogMode('free-only').map((model) => model.id)).toEqual([
|
|
207
|
+
'openrouter/free',
|
|
208
|
+
]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('includes built-in routers exactly once for full mode by default', () => {
|
|
212
|
+
const configs = includeBuiltinRouterModels([
|
|
213
|
+
createPiModelConfig({ id: 'user/model-a' }),
|
|
214
|
+
createPiModelConfig({ id: ROUTER_ALIASES[0]! }),
|
|
215
|
+
]);
|
|
216
|
+
const registeredIds = configs.map((model) => model.id);
|
|
217
|
+
|
|
218
|
+
expect(registeredIds).toEqual(['user/model-a', ...ROUTER_ALIASES]);
|
|
219
|
+
for (const routerId of ROUTER_ALIASES) {
|
|
220
|
+
expect(registeredIds.filter((id) => id === routerId)).toHaveLength(1);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('injects only openrouter/free in free-only mode', () => {
|
|
225
|
+
const configs = includeBuiltinRouterModels(
|
|
226
|
+
[createPiModelConfig({ id: 'user/model:free' })],
|
|
227
|
+
'free-only',
|
|
228
|
+
);
|
|
229
|
+
expect(configs.map((model) => model.id)).toEqual(['user/model:free', 'openrouter/free']);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
125
233
|
describe('syncModels', () => {
|
|
126
234
|
const mockRegisterProvider = vi.fn();
|
|
127
235
|
const mockCtx = createMockExtensionContext({ registerProvider: mockRegisterProvider });
|
|
128
236
|
|
|
129
237
|
beforeEach(() => {
|
|
130
238
|
vi.resetAllMocks();
|
|
131
|
-
|
|
132
|
-
|
|
239
|
+
setSyncState(null);
|
|
240
|
+
setActiveCatalogState(null);
|
|
133
241
|
delete process.env['OPENROUTER_API_KEY'];
|
|
134
242
|
});
|
|
135
243
|
|
|
136
|
-
it('
|
|
137
|
-
// Ensure API key is not set
|
|
138
|
-
delete process.env['OPENROUTER_API_KEY'];
|
|
139
|
-
delete process.env['OPENROUTER_MANAGEMENT_KEY'];
|
|
140
|
-
// Mock fetchUserModels to throw AuthError
|
|
244
|
+
it('returns unavailable when API key is missing and no cache exists', async () => {
|
|
141
245
|
vi.mocked(fetchUserModels).mockRejectedValueOnce(
|
|
142
246
|
new AuthError(
|
|
143
247
|
'OpenRouter API key not configured. Set OPENROUTER_API_KEY or OPENROUTER_MANAGEMENT_KEY.',
|
|
144
248
|
),
|
|
145
249
|
);
|
|
146
|
-
// Mock loadCache to return null (no cache available)
|
|
147
250
|
vi.mocked(loadCache).mockResolvedValueOnce(null);
|
|
148
251
|
|
|
149
252
|
const result = await syncModels(mockCtx);
|
|
150
253
|
|
|
151
|
-
expect(result
|
|
152
|
-
|
|
153
|
-
|
|
254
|
+
expect(result).toMatchObject({
|
|
255
|
+
success: false,
|
|
256
|
+
outcome: 'unavailable',
|
|
257
|
+
source: 'none',
|
|
258
|
+
requestedMode: 'full',
|
|
259
|
+
catalogMode: null,
|
|
260
|
+
registeredCount: 0,
|
|
261
|
+
});
|
|
154
262
|
expect(result.error).toContain('OpenRouter API key not configured');
|
|
263
|
+
expect(getActiveCatalogState()).toBeNull();
|
|
155
264
|
});
|
|
156
265
|
|
|
157
|
-
it('
|
|
158
|
-
// Mock successful API response with minimal model data
|
|
159
|
-
const mockModel = {
|
|
160
|
-
id: 'anthropic/claude-3-opus',
|
|
161
|
-
name: 'Claude 3 Opus',
|
|
162
|
-
architecture: {
|
|
163
|
-
inputModalities: ['text', 'image'],
|
|
164
|
-
outputModalities: ['text'],
|
|
165
|
-
},
|
|
166
|
-
contextLength: 200000,
|
|
167
|
-
pricing: {
|
|
168
|
-
prompt: 0.000015,
|
|
169
|
-
completion: 0.000075,
|
|
170
|
-
inputCacheRead: 0.0000015,
|
|
171
|
-
inputCacheWrite: 0.0000075,
|
|
172
|
-
},
|
|
173
|
-
supportedParameters: ['reasoning'],
|
|
174
|
-
topProvider: {
|
|
175
|
-
contextLength: 200000,
|
|
176
|
-
maxCompletionTokens: 4096,
|
|
177
|
-
},
|
|
178
|
-
};
|
|
179
|
-
|
|
266
|
+
it('syncs the full API catalog, persists full-mode cache metadata, and seeds active state', async () => {
|
|
180
267
|
vi.mocked(fetchUserModels).mockResolvedValueOnce({
|
|
181
|
-
data: [
|
|
268
|
+
data: [
|
|
269
|
+
createSdkModel({
|
|
270
|
+
id: 'user/model-a',
|
|
271
|
+
name: 'User Model A',
|
|
272
|
+
}),
|
|
273
|
+
],
|
|
182
274
|
} as any);
|
|
183
275
|
|
|
184
|
-
vi.mocked(loadCache).mockResolvedValueOnce(null);
|
|
185
|
-
|
|
186
276
|
const result = await syncModels(mockCtx);
|
|
277
|
+
const providerConfig = mockRegisterProvider.mock.calls[0]![1] as { models: PiModelConfig[] };
|
|
278
|
+
const registeredIds = providerConfig.models.map((model) => model.id);
|
|
187
279
|
|
|
188
|
-
expect(result
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
280
|
+
expect(result).toMatchObject({
|
|
281
|
+
success: true,
|
|
282
|
+
outcome: 'synced',
|
|
283
|
+
requestedMode: 'full',
|
|
284
|
+
catalogMode: 'full',
|
|
285
|
+
source: 'api',
|
|
286
|
+
cacheUpdated: true,
|
|
287
|
+
registeredCount: 1 + ROUTER_ALIASES.length,
|
|
288
|
+
});
|
|
289
|
+
expect(registeredIds).toEqual(['user/model-a', ...ROUTER_ALIASES]);
|
|
290
|
+
expect(vi.mocked(saveCache)).toHaveBeenCalledWith(
|
|
291
|
+
expect.objectContaining({
|
|
292
|
+
catalogMode: 'full',
|
|
293
|
+
}),
|
|
294
|
+
);
|
|
295
|
+
const savedCache = vi.mocked(saveCache).mock.calls[0]![0];
|
|
296
|
+
expect(savedCache.models.map((model) => model.id)).toEqual(['user/model-a', ...ROUTER_ALIASES]);
|
|
297
|
+
expect(getActiveCatalogState()).toEqual({
|
|
298
|
+
mode: 'full',
|
|
299
|
+
registeredModelIds: ['user/model-a', ...ROUTER_ALIASES],
|
|
300
|
+
registeredCount: 1 + ROUTER_ALIASES.length,
|
|
301
|
+
skippedCount: 0,
|
|
302
|
+
skippedDetails: [],
|
|
303
|
+
source: 'api',
|
|
304
|
+
cacheAgeMs: 0,
|
|
305
|
+
});
|
|
192
306
|
});
|
|
193
307
|
|
|
194
|
-
it('
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
308
|
+
it('syncs free-only mode, excludes paid variants, and does not inflate skipped counts from filtered paid models', async () => {
|
|
309
|
+
vi.mocked(fetchUserModels).mockResolvedValueOnce({
|
|
310
|
+
data: [
|
|
311
|
+
createSdkModel({ id: 'provider/model', name: 'Paid Variant' }),
|
|
312
|
+
createSdkModel({ id: 'provider/model:free', name: 'Free Variant' }),
|
|
313
|
+
createSdkModel({
|
|
314
|
+
id: 'provider/invalid-paid',
|
|
315
|
+
name: 'Invalid Paid Variant',
|
|
316
|
+
contextLength: 0,
|
|
317
|
+
topProvider: { contextLength: 0, maxCompletionTokens: 4096 },
|
|
318
|
+
}),
|
|
319
|
+
createSdkModel({
|
|
320
|
+
id: 'provider/invalid-free:free',
|
|
321
|
+
name: 'Invalid Free Variant',
|
|
322
|
+
contextLength: 0,
|
|
323
|
+
topProvider: { contextLength: 0, maxCompletionTokens: 4096 },
|
|
324
|
+
}),
|
|
325
|
+
],
|
|
326
|
+
} as any);
|
|
327
|
+
|
|
328
|
+
const result = await syncModels(mockCtx, 'free-only');
|
|
329
|
+
const providerConfig = mockRegisterProvider.mock.calls[0]![1] as { models: PiModelConfig[] };
|
|
330
|
+
const registeredIds = providerConfig.models.map((model) => model.id);
|
|
331
|
+
const savedCache = vi.mocked(saveCache).mock.calls[0]![0];
|
|
332
|
+
|
|
333
|
+
expect(result).toMatchObject({
|
|
334
|
+
success: true,
|
|
335
|
+
outcome: 'synced',
|
|
336
|
+
requestedMode: 'free-only',
|
|
337
|
+
catalogMode: 'free-only',
|
|
338
|
+
source: 'api',
|
|
339
|
+
registeredCount: 2,
|
|
340
|
+
skippedCount: 1,
|
|
341
|
+
});
|
|
342
|
+
expect(result.skippedDetails).toEqual([
|
|
343
|
+
{
|
|
344
|
+
id: 'provider/invalid-free:free',
|
|
345
|
+
reason: 'missing context window',
|
|
346
|
+
hint: expect.stringContaining('contextWindow'),
|
|
211
347
|
},
|
|
348
|
+
]);
|
|
349
|
+
expect(registeredIds).toEqual(['provider/model:free', 'openrouter/free']);
|
|
350
|
+
expect(registeredIds).not.toContain('provider/model');
|
|
351
|
+
expect(registeredIds).not.toContain('provider/invalid-paid');
|
|
352
|
+
expect(registeredIds).not.toContain('openrouter/auto');
|
|
353
|
+
expect(registeredIds).not.toContain('openrouter/owl-alpha');
|
|
354
|
+
expect(savedCache.catalogMode).toBe('free-only');
|
|
355
|
+
expect(savedCache.models.map((model) => model.id)).toEqual([
|
|
356
|
+
'provider/model:free',
|
|
357
|
+
'provider/invalid-free:free',
|
|
358
|
+
'openrouter/free',
|
|
359
|
+
]);
|
|
360
|
+
expect(getActiveCatalogState()).toEqual({
|
|
361
|
+
mode: 'free-only',
|
|
362
|
+
registeredModelIds: ['provider/model:free', 'openrouter/free'],
|
|
363
|
+
registeredCount: 2,
|
|
364
|
+
skippedCount: 1,
|
|
365
|
+
skippedDetails: [
|
|
366
|
+
{
|
|
367
|
+
id: 'provider/invalid-free:free',
|
|
368
|
+
reason: 'missing context window',
|
|
369
|
+
hint: expect.stringContaining('contextWindow'),
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
source: 'api',
|
|
373
|
+
cacheAgeMs: 0,
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('treats an empty free-only API catalog as a no-op before router injection', async () => {
|
|
378
|
+
const previousState = {
|
|
379
|
+
mode: 'full' as CatalogMode,
|
|
380
|
+
registeredCount: 4,
|
|
381
|
+
skippedCount: 2,
|
|
382
|
+
skippedDetails: createSkipReasons(2),
|
|
383
|
+
source: 'api' as const,
|
|
384
|
+
cacheAgeMs: 0,
|
|
212
385
|
};
|
|
386
|
+
setActiveCatalogState(previousState);
|
|
213
387
|
|
|
214
388
|
vi.mocked(fetchUserModels).mockResolvedValueOnce({
|
|
215
|
-
data: [
|
|
389
|
+
data: [createSdkModel({ id: 'provider/paid-a' }), createSdkModel({ id: 'provider/paid-b' })],
|
|
216
390
|
} as any);
|
|
217
391
|
|
|
218
|
-
const result = await syncModels(mockCtx);
|
|
219
|
-
const providerConfig = mockRegisterProvider.mock.calls[0]![1] as { models: PiModelConfig[] };
|
|
220
|
-
const registeredIds = providerConfig.models.map((model) => model.id);
|
|
392
|
+
const result = await syncModels(mockCtx, 'free-only');
|
|
221
393
|
|
|
222
|
-
expect(result
|
|
223
|
-
|
|
224
|
-
|
|
394
|
+
expect(result).toMatchObject({
|
|
395
|
+
success: false,
|
|
396
|
+
outcome: 'no-change',
|
|
397
|
+
requestedMode: 'free-only',
|
|
398
|
+
catalogMode: 'full',
|
|
399
|
+
registeredCount: 0,
|
|
400
|
+
skippedCount: 0,
|
|
401
|
+
cacheUpdated: false,
|
|
402
|
+
source: 'api',
|
|
403
|
+
error: null,
|
|
404
|
+
});
|
|
405
|
+
expect(mockRegisterProvider).not.toHaveBeenCalled();
|
|
406
|
+
expect(saveCache).not.toHaveBeenCalled();
|
|
407
|
+
expect(getActiveCatalogState()).toEqual(previousState);
|
|
225
408
|
});
|
|
226
409
|
|
|
227
|
-
it('
|
|
410
|
+
it('re-registers cached models using the cached catalog mode on API failure', async () => {
|
|
228
411
|
vi.mocked(fetchUserModels).mockRejectedValueOnce(new Error('api down'));
|
|
229
412
|
vi.mocked(loadCache).mockResolvedValueOnce({
|
|
230
|
-
|
|
413
|
+
catalogMode: 'free-only',
|
|
414
|
+
models: [
|
|
415
|
+
createValidModel({ id: 'cached/model:free', name: 'Cached Free Model' }),
|
|
416
|
+
createValidModel({ id: 'cached/paid-model', name: 'Cached Paid Model' }),
|
|
417
|
+
createValidModel({ id: 'openrouter/free', name: 'Free Router' }),
|
|
418
|
+
],
|
|
231
419
|
skippedDetails: [
|
|
232
420
|
{
|
|
233
|
-
id: 'bad
|
|
421
|
+
id: 'cached/bad:free',
|
|
234
422
|
reason: 'missing context window',
|
|
235
423
|
hint: "Add a local contextWindow override with '/openrouter model-override-set <model-id> contextWindow=<tokens>' if the model's limit is known.",
|
|
236
424
|
},
|
|
@@ -238,162 +426,149 @@ describe('syncModels', () => {
|
|
|
238
426
|
timestamp: Date.now() - 60000,
|
|
239
427
|
});
|
|
240
428
|
|
|
241
|
-
const result = await syncModels(mockCtx);
|
|
429
|
+
const result = await syncModels(mockCtx, 'full');
|
|
242
430
|
const providerConfig = mockRegisterProvider.mock.calls[0]![1] as { models: PiModelConfig[] };
|
|
243
431
|
const registeredIds = providerConfig.models.map((model) => model.id);
|
|
244
432
|
|
|
245
|
-
expect(result
|
|
246
|
-
|
|
247
|
-
|
|
433
|
+
expect(result).toMatchObject({
|
|
434
|
+
success: false,
|
|
435
|
+
outcome: 'cache-fallback',
|
|
436
|
+
requestedMode: 'full',
|
|
437
|
+
catalogMode: 'free-only',
|
|
438
|
+
source: 'cache',
|
|
439
|
+
registeredCount: 2,
|
|
440
|
+
skippedCount: 1,
|
|
441
|
+
});
|
|
442
|
+
expect(registeredIds).toEqual(['cached/model:free', 'openrouter/free']);
|
|
443
|
+
expect(registeredIds).not.toContain('cached/paid-model');
|
|
444
|
+
expect(registeredIds).not.toContain('openrouter/auto');
|
|
445
|
+
expect(registeredIds).not.toContain('openrouter/owl-alpha');
|
|
248
446
|
expect(result.skippedDetails).toEqual([
|
|
249
447
|
{
|
|
250
|
-
id: 'bad
|
|
448
|
+
id: 'cached/bad:free',
|
|
251
449
|
reason: 'missing context window',
|
|
252
|
-
hint:
|
|
450
|
+
hint: expect.stringContaining('contextWindow'),
|
|
253
451
|
},
|
|
254
452
|
]);
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
id: 'user/model-a',
|
|
262
|
-
name: 'User Model A',
|
|
263
|
-
architecture: {
|
|
264
|
-
inputModalities: ['text'],
|
|
265
|
-
outputModalities: ['text'],
|
|
266
|
-
},
|
|
267
|
-
contextLength: 128000,
|
|
268
|
-
pricing: {
|
|
269
|
-
prompt: 0.000001,
|
|
270
|
-
completion: 0.000002,
|
|
271
|
-
},
|
|
272
|
-
supportedParameters: [],
|
|
273
|
-
topProvider: {
|
|
274
|
-
contextLength: 128000,
|
|
275
|
-
maxCompletionTokens: 4096,
|
|
276
|
-
},
|
|
277
|
-
},
|
|
453
|
+
expect(getActiveCatalogState()).toEqual({
|
|
454
|
+
mode: 'free-only',
|
|
455
|
+
registeredModelIds: ['cached/model:free', 'openrouter/free'],
|
|
456
|
+
registeredCount: 2,
|
|
457
|
+
skippedCount: 1,
|
|
458
|
+
skippedDetails: [
|
|
278
459
|
{
|
|
279
|
-
id: 'bad
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
inputModalities: ['text'],
|
|
283
|
-
outputModalities: ['text'],
|
|
284
|
-
},
|
|
285
|
-
contextLength: 0,
|
|
286
|
-
pricing: {
|
|
287
|
-
prompt: 0.000001,
|
|
288
|
-
completion: 0.000002,
|
|
289
|
-
},
|
|
290
|
-
supportedParameters: [],
|
|
291
|
-
topProvider: {
|
|
292
|
-
contextLength: 0,
|
|
293
|
-
maxCompletionTokens: 4096,
|
|
294
|
-
},
|
|
460
|
+
id: 'cached/bad:free',
|
|
461
|
+
reason: 'missing context window',
|
|
462
|
+
hint: expect.stringContaining('contextWindow'),
|
|
295
463
|
},
|
|
296
464
|
],
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
expect(result.skippedDetails).toEqual([
|
|
302
|
-
{
|
|
303
|
-
id: 'bad/model',
|
|
304
|
-
reason: 'missing context window',
|
|
305
|
-
hint: expect.stringContaining(
|
|
306
|
-
'/openrouter model-override-set <model-id> contextWindow=<tokens>',
|
|
307
|
-
),
|
|
308
|
-
},
|
|
309
|
-
]);
|
|
310
|
-
expect(vi.mocked(saveCache)).toHaveBeenCalledWith(
|
|
311
|
-
expect.objectContaining({
|
|
312
|
-
skippedDetails: [
|
|
313
|
-
{
|
|
314
|
-
id: 'bad/model',
|
|
315
|
-
reason: 'missing context window',
|
|
316
|
-
hint: expect.stringContaining(
|
|
317
|
-
'/openrouter model-override-set <model-id> contextWindow=<tokens>',
|
|
318
|
-
),
|
|
319
|
-
},
|
|
320
|
-
],
|
|
321
|
-
}),
|
|
322
|
-
);
|
|
465
|
+
source: 'cache',
|
|
466
|
+
cacheAgeMs: expect.any(Number),
|
|
467
|
+
});
|
|
323
468
|
});
|
|
324
469
|
|
|
325
|
-
it('
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
470
|
+
it('preserves the active catalog when refresh fails and no cache is available', async () => {
|
|
471
|
+
setActiveCatalogState({
|
|
472
|
+
mode: 'full',
|
|
473
|
+
registeredCount: 4,
|
|
474
|
+
skippedCount: 0,
|
|
475
|
+
skippedDetails: [],
|
|
476
|
+
source: 'api',
|
|
477
|
+
cacheAgeMs: 0,
|
|
478
|
+
});
|
|
479
|
+
vi.mocked(fetchUserModels).mockRejectedValueOnce(new Error('network down'));
|
|
480
|
+
vi.mocked(loadCache).mockResolvedValueOnce(null);
|
|
331
481
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
482
|
+
const result = await syncModels(mockCtx, 'free-only');
|
|
483
|
+
|
|
484
|
+
expect(result).toMatchObject({
|
|
485
|
+
success: false,
|
|
486
|
+
outcome: 'unavailable',
|
|
487
|
+
requestedMode: 'free-only',
|
|
488
|
+
catalogMode: 'full',
|
|
489
|
+
source: 'none',
|
|
490
|
+
registeredCount: 0,
|
|
491
|
+
});
|
|
492
|
+
expect(getActiveCatalogState()).toEqual({
|
|
493
|
+
mode: 'full',
|
|
494
|
+
registeredCount: 4,
|
|
495
|
+
skippedCount: 0,
|
|
496
|
+
skippedDetails: [],
|
|
497
|
+
source: 'api',
|
|
498
|
+
cacheAgeMs: 0,
|
|
499
|
+
});
|
|
500
|
+
expect(await areModelsAvailable()).toBe(true);
|
|
336
501
|
});
|
|
337
502
|
});
|
|
338
503
|
|
|
339
|
-
describe('
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
504
|
+
describe('sync state management', () => {
|
|
505
|
+
beforeEach(() => {
|
|
506
|
+
setSyncState(null);
|
|
507
|
+
setActiveCatalogState(null);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('stores and retrieves sync state', () => {
|
|
511
|
+
const mockResult = createSyncResult({
|
|
343
512
|
registeredCount: 10,
|
|
344
513
|
skippedCount: 2,
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
cacheAgeMs: null,
|
|
348
|
-
error: null,
|
|
349
|
-
};
|
|
514
|
+
skippedDetails: createSkipReasons(2),
|
|
515
|
+
});
|
|
350
516
|
|
|
351
517
|
setSyncState(mockResult);
|
|
352
|
-
|
|
353
|
-
const retrieved = getSyncState();
|
|
354
|
-
expect(retrieved).toEqual(mockResult);
|
|
518
|
+
expect(getSyncState()).toEqual(mockResult);
|
|
355
519
|
});
|
|
356
520
|
|
|
357
|
-
it('
|
|
358
|
-
(
|
|
359
|
-
|
|
521
|
+
it('stores and retrieves active catalog state separately from sync state', () => {
|
|
522
|
+
setSyncState(createSyncResult({ outcome: 'unavailable', success: false, source: 'none' }));
|
|
523
|
+
setActiveCatalogState({
|
|
524
|
+
mode: 'free-only',
|
|
525
|
+
registeredCount: 3,
|
|
526
|
+
skippedCount: 1,
|
|
527
|
+
skippedDetails: createSkipReasons(1),
|
|
528
|
+
source: 'cache',
|
|
529
|
+
cacheAgeMs: 60000,
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
expect(getSyncState()?.outcome).toBe('unavailable');
|
|
533
|
+
expect(getActiveCatalogState()).toEqual({
|
|
534
|
+
mode: 'free-only',
|
|
535
|
+
registeredCount: 3,
|
|
536
|
+
skippedCount: 1,
|
|
537
|
+
skippedDetails: createSkipReasons(1),
|
|
538
|
+
source: 'cache',
|
|
539
|
+
cacheAgeMs: 60000,
|
|
540
|
+
});
|
|
360
541
|
});
|
|
361
542
|
});
|
|
362
543
|
|
|
363
544
|
describe('getStatusText', () => {
|
|
364
545
|
beforeEach(() => {
|
|
365
|
-
|
|
546
|
+
setSyncState(null);
|
|
547
|
+
setActiveCatalogState(null);
|
|
366
548
|
});
|
|
367
549
|
|
|
368
|
-
it('
|
|
550
|
+
it('returns not synced when no sync or active state exists', () => {
|
|
369
551
|
expect(getStatusText()).toBe('OpenRouter models: not synced');
|
|
370
552
|
});
|
|
371
553
|
|
|
372
|
-
it('
|
|
373
|
-
setSyncState(
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
expect(text).toContain('healthy');
|
|
384
|
-
expect(text).toContain('312 registered');
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
it('should return cached for cache fallback', () => {
|
|
388
|
-
setSyncState({
|
|
389
|
-
success: false,
|
|
554
|
+
it('prefers the active catalog state over the last sync result', () => {
|
|
555
|
+
setSyncState(
|
|
556
|
+
createSyncResult({
|
|
557
|
+
success: false,
|
|
558
|
+
outcome: 'unavailable',
|
|
559
|
+
source: 'none',
|
|
560
|
+
registeredCount: 0,
|
|
561
|
+
}),
|
|
562
|
+
);
|
|
563
|
+
setActiveCatalogState({
|
|
564
|
+
mode: 'full',
|
|
390
565
|
registeredCount: 287,
|
|
391
566
|
skippedCount: 21,
|
|
567
|
+
skippedDetails: createSkipReasons(21),
|
|
392
568
|
source: 'cache',
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
} as SyncResult);
|
|
569
|
+
cacheAgeMs: 7200000,
|
|
570
|
+
});
|
|
571
|
+
|
|
397
572
|
const text = getStatusText();
|
|
398
573
|
expect(text).toContain('cached');
|
|
399
574
|
expect(text).toContain('287 registered');
|
|
@@ -402,13 +577,17 @@ describe('getStatusText', () => {
|
|
|
402
577
|
it('should return broken for complete failure', () => {
|
|
403
578
|
setSyncState({
|
|
404
579
|
success: false,
|
|
580
|
+
outcome: 'unavailable',
|
|
581
|
+
requestedMode: 'full',
|
|
582
|
+
catalogMode: null,
|
|
405
583
|
registeredCount: 0,
|
|
406
584
|
skippedCount: 0,
|
|
585
|
+
skippedDetails: [],
|
|
407
586
|
source: 'none',
|
|
408
587
|
cacheUpdated: false,
|
|
409
588
|
cacheAgeMs: null,
|
|
410
589
|
error: 'missing or invalid OpenRouter auth',
|
|
411
|
-
}
|
|
590
|
+
});
|
|
412
591
|
const text = getStatusText();
|
|
413
592
|
expect(text).toContain('broken');
|
|
414
593
|
expect(text).toContain('0 registered');
|
|
@@ -417,49 +596,46 @@ describe('getStatusText', () => {
|
|
|
417
596
|
|
|
418
597
|
describe('areModelsAvailable', () => {
|
|
419
598
|
beforeEach(() => {
|
|
420
|
-
|
|
599
|
+
setSyncState(null);
|
|
600
|
+
setActiveCatalogState(null);
|
|
421
601
|
});
|
|
422
602
|
|
|
423
|
-
it('
|
|
603
|
+
it('returns false when no active state or cache exists', async () => {
|
|
604
|
+
vi.mocked(loadCache).mockResolvedValueOnce(null);
|
|
424
605
|
expect(await areModelsAvailable()).toBe(false);
|
|
425
606
|
});
|
|
426
607
|
|
|
427
|
-
it('
|
|
428
|
-
|
|
429
|
-
|
|
608
|
+
it('returns true when an active catalog is registered', async () => {
|
|
609
|
+
setActiveCatalogState({
|
|
610
|
+
mode: 'full',
|
|
430
611
|
registeredCount: 10,
|
|
431
612
|
skippedCount: 0,
|
|
613
|
+
skippedDetails: [],
|
|
432
614
|
source: 'api',
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
error: null,
|
|
436
|
-
} as SyncResult);
|
|
615
|
+
cacheAgeMs: 0,
|
|
616
|
+
});
|
|
437
617
|
expect(await areModelsAvailable()).toBe(true);
|
|
438
618
|
});
|
|
619
|
+
});
|
|
439
620
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
skippedCount: 0,
|
|
445
|
-
source: 'cache',
|
|
446
|
-
cacheUpdated: false,
|
|
447
|
-
cacheAgeMs: 7200000,
|
|
448
|
-
error: 'API error',
|
|
449
|
-
} as SyncResult);
|
|
450
|
-
expect(await areModelsAvailable()).toBe(true);
|
|
621
|
+
describe('getSkipReasonsAsync', () => {
|
|
622
|
+
beforeEach(() => {
|
|
623
|
+
setSyncState(null);
|
|
624
|
+
setActiveCatalogState(null);
|
|
451
625
|
});
|
|
452
626
|
|
|
453
|
-
it('
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
}
|
|
463
|
-
|
|
627
|
+
it('returns the full skip list when maxResults is omitted and slices only when requested', async () => {
|
|
628
|
+
const skippedDetails = createSkipReasons(12);
|
|
629
|
+
setActiveCatalogState({
|
|
630
|
+
mode: 'full',
|
|
631
|
+
registeredCount: 25,
|
|
632
|
+
skippedCount: skippedDetails.length,
|
|
633
|
+
skippedDetails,
|
|
634
|
+
source: 'api',
|
|
635
|
+
cacheAgeMs: 0,
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
expect(await getSkipReasonsAsync()).toHaveLength(12);
|
|
639
|
+
expect(await getSkipReasonsAsync(10)).toHaveLength(10);
|
|
464
640
|
});
|
|
465
641
|
});
|