@robhowley/pi-openrouter 0.11.1 → 0.12.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.
@@ -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
- (setSyncState as (result: SyncResult | null) => void)(null);
132
- // Explicitly delete API key
239
+ setSyncState(null);
240
+ setActiveCatalogState(null);
133
241
  delete process.env['OPENROUTER_API_KEY'];
134
242
  });
135
243
 
136
- it('should return failure when API key is missing and no cache', async () => {
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.success).toBe(false);
152
- expect(result.registeredCount).toBe(0);
153
- expect(result.source).toBe('none');
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('should sync models from API and register with provider', async () => {
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: [mockModel],
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.success).toBe(true);
189
- expect(result.source).toBe('api');
190
- expect(result.registeredCount).toBeGreaterThan(0);
191
- expect(mockRegisterProvider).toHaveBeenCalled();
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('should register the API user catalog plus router aliases exactly once', async () => {
195
- const mockModel = {
196
- id: 'user/model-a',
197
- name: 'User Model A',
198
- architecture: {
199
- inputModalities: ['text'],
200
- outputModalities: ['text'],
201
- },
202
- contextLength: 128000,
203
- pricing: {
204
- prompt: 0.000001,
205
- completion: 0.000002,
206
- },
207
- supportedParameters: [],
208
- topProvider: {
209
- contextLength: 128000,
210
- maxCompletionTokens: 4096,
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: [mockModel],
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.registeredCount).toBe(1 + ROUTER_ALIASES.length);
223
- expect(registeredIds).toEqual(['user/model-a', ...ROUTER_ALIASES]);
224
- expect(registeredIds).not.toContain('anthropic/claude-3-opus');
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('should register cached user models plus router aliases on API failure', async () => {
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
- models: [createValidModel({ id: 'cached/model-a', name: 'Cached Model A' })],
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/model',
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.source).toBe('cache');
246
- expect(result.registeredCount).toBe(1 + ROUTER_ALIASES.length);
247
- expect(registeredIds).toEqual(['cached/model-a', ...ROUTER_ALIASES]);
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/model',
448
+ id: 'cached/bad:free',
251
449
  reason: 'missing context window',
252
- hint: "Add a local contextWindow override with '/openrouter model-override-set <model-id> contextWindow=<tokens>' if the model's limit is known.",
450
+ hint: expect.stringContaining('contextWindow'),
253
451
  },
254
452
  ]);
255
- });
256
-
257
- it('should persist skipped reason hints in the saved cache', async () => {
258
- vi.mocked(fetchUserModels).mockResolvedValueOnce({
259
- data: [
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/model',
280
- name: 'Bad Model',
281
- architecture: {
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
- } as any);
298
-
299
- const result = await syncModels(mockCtx);
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('should include router aliases at most once', () => {
326
- const configs = includeBuiltinRouterModels([
327
- createPiModelConfig({ id: 'user/model-a' }),
328
- createPiModelConfig({ id: ROUTER_ALIASES[0]! }),
329
- ]);
330
- const registeredIds = configs.map((model) => model.id);
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
- expect(registeredIds).toEqual(['user/model-a', ...ROUTER_ALIASES]);
333
- for (const routerId of ROUTER_ALIASES) {
334
- expect(registeredIds.filter((id) => id === routerId)).toHaveLength(1);
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('syncState management', () => {
340
- it('should store and retrieve sync state', () => {
341
- const mockResult: SyncResult = {
342
- success: true,
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
- source: 'api',
346
- cacheUpdated: true,
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('should return null when no state set', () => {
358
- (setSyncState as (result: SyncResult | null) => void)(null);
359
- expect(getSyncState()).toBeNull();
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
- (setSyncState as (result: SyncResult | null) => void)(null);
546
+ setSyncState(null);
547
+ setActiveCatalogState(null);
366
548
  });
367
549
 
368
- it('should return not synced when no state', () => {
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('should return healthy for successful sync', () => {
373
- setSyncState({
374
- success: true,
375
- registeredCount: 312,
376
- skippedCount: 18,
377
- source: 'api',
378
- cacheUpdated: true,
379
- cacheAgeMs: null,
380
- error: null,
381
- } as SyncResult);
382
- const text = getStatusText();
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
- cacheUpdated: false,
394
- cacheAgeMs: 7200000, // 2 hours
395
- error: '401 unauthorized',
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
- } as SyncResult);
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
- (setSyncState as (result: SyncResult | null) => void)(null);
599
+ setSyncState(null);
600
+ setActiveCatalogState(null);
421
601
  });
422
602
 
423
- it('should return false when no state', async () => {
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('should return true when models are synced', async () => {
428
- setSyncState({
429
- success: true,
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
- cacheUpdated: true,
434
- cacheAgeMs: null,
435
- error: null,
436
- } as SyncResult);
615
+ cacheAgeMs: 0,
616
+ });
437
617
  expect(await areModelsAvailable()).toBe(true);
438
618
  });
619
+ });
439
620
 
440
- it('should return true when using cache (models still available)', async () => {
441
- setSyncState({
442
- success: false,
443
- registeredCount: 5,
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('should return false when no models registered', async () => {
454
- setSyncState({
455
- success: false,
456
- registeredCount: 0,
457
- skippedCount: 0,
458
- source: 'none',
459
- cacheUpdated: false,
460
- cacheAgeMs: null,
461
- error: 'Complete failure',
462
- } as SyncResult);
463
- expect(await areModelsAvailable()).toBe(false);
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
  });