@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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-openrouter
2
2
 
3
- A [Pi](https://pi.dev/) extension for live OpenRouter visibility and environment sync: usage/account TUI overlays, automatic `session_id` tagging, user-scoped model catalog sync, api key management, and local model field overrides.
3
+ A [Pi](https://pi.dev/) extension for live OpenRouter visibility and environment sync: usage/account TUI overlays, automatic session_id tagging, full or free-only model catalog sync, API key management, and local model field overrides.
4
4
 
5
5
  ## Installation
6
6
 
@@ -32,7 +32,9 @@ export OPENROUTER_MANAGEMENT_KEY=sk-or-...
32
32
  /openrouter session # current OpenRouter session_id
33
33
  /openrouter api-key-create # create an API key (management key required)
34
34
  /openrouter models-sync # sync user-scoped OpenRouter models into Pi
35
+ /openrouter models-sync --free # sync only openrouter/free plus explicit :free models
35
36
  /openrouter models-status # show model sync/cache status
37
+ /openrouter models-status --free # show only registered/skipped free models
36
38
  /openrouter models-status --skipped # show skipped model reasons
37
39
  /openrouter model-override-set # set local model field overrides
38
40
  /openrouter model-override-list # list local model field overrides
@@ -47,6 +49,29 @@ export OPENROUTER_MANAGEMENT_KEY=sk-or-...
47
49
 
48
50
  The sync uses OpenRouter’s authenticated user model catalog, so Pi can see the models available to your account instead of only the default provider list. This intentionally replaces Pi's OpenRouter provider model list with your user-scoped catalog plus OpenRouter's built-in router aliases (`openrouter/auto`, `openrouter/free`, and `openrouter/owl-alpha`). It does not merge in every built-in model unless that model is returned by your OpenRouter account catalog.
49
51
 
52
+ ### Free models only
53
+
54
+ To sync only OpenRouter's free route and explicit free model variants:
55
+
56
+ ```shell
57
+ /openrouter models-sync --free
58
+ ```
59
+
60
+ This registers `openrouter/free` plus available `*:free` models.
61
+
62
+ Use `openrouter/free` for OpenRouter's built-in free model router, or select a specific `:free` model when you want direct control.
63
+
64
+ Free models are best-effort and rate-limited by OpenRouter. They are useful for experiments and low-stakes work, not guaranteed coding loops.
65
+
66
+ Example free-only status output:
67
+
68
+ ```text
69
+ OpenRouter models healthy
70
+ 28 registered · 3 skipped · free-only catalog · cache age: 4m
71
+ ```
72
+
73
+ Run `/openrouter models-sync` again to restore the full user-scoped catalog.
74
+
50
75
  `/openrouter models-status`
51
76
 
52
77
  Model count and cache health live here. The Pi footer/status bar does not persistently show `OpenRouter {N} models` anymore.
@@ -55,7 +80,7 @@ Example status output:
55
80
 
56
81
  ```text
57
82
  OpenRouter models healthy
58
- 363 registered · 2 skipped · cache age: 2m
83
+ 363 registered · 12 skipped · full catalog · cache age: 4m
59
84
  ```
60
85
 
61
86
  To see why models were skipped:
@@ -49,6 +49,7 @@ const { mocks, overlayConstructorCalls, MockUsageOverlayComponent } = vi.hoisted
49
49
  sortKeys: vi.fn(),
50
50
  syncModels: vi.fn(),
51
51
  getSyncState: vi.fn(),
52
+ getActiveCatalogState: vi.fn(),
52
53
  isSyncEnabled: vi.fn(),
53
54
  getSkipReasonsAsync: vi.fn(),
54
55
  groupSkipReasons: vi.fn(),
@@ -107,6 +108,7 @@ vi.mock('../account-format.js', () => ({
107
108
  vi.mock('../models/sync.js', () => ({
108
109
  syncModels: mocks.syncModels,
109
110
  getSyncState: mocks.getSyncState,
111
+ getActiveCatalogState: mocks.getActiveCatalogState,
110
112
  isSyncEnabled: mocks.isSyncEnabled,
111
113
  getSkipReasonsAsync: mocks.getSkipReasonsAsync,
112
114
  groupSkipReasons: mocks.groupSkipReasons,
@@ -210,6 +212,20 @@ function createKeyInventory(
210
212
  };
211
213
  }
212
214
 
215
+ function createActiveCatalogState(overrides: Record<string, unknown> = {}) {
216
+ const registeredModelIds = ['openrouter/free', 'provider/model-a:free', 'provider/model-b'];
217
+ return {
218
+ mode: 'full',
219
+ registeredModelIds,
220
+ registeredCount: registeredModelIds.length,
221
+ skippedDetails: [],
222
+ skippedCount: 0,
223
+ source: 'api',
224
+ cacheAgeMs: 0,
225
+ ...overrides,
226
+ };
227
+ }
228
+
213
229
  describe('registerOpenRouterCommands', () => {
214
230
  beforeEach(() => {
215
231
  vi.resetAllMocks();
@@ -237,9 +253,15 @@ describe('registerOpenRouterCommands', () => {
237
253
  mocks.sortKeys.mockImplementation((keys) => keys);
238
254
  mocks.syncModels.mockResolvedValue({ success: true, registeredCount: 3, skippedCount: 0 });
239
255
  mocks.getSyncState.mockReturnValue(null);
256
+ mocks.getActiveCatalogState.mockReturnValue(null);
240
257
  mocks.isSyncEnabled.mockReturnValue(true);
241
258
  mocks.getSkipReasonsAsync.mockResolvedValue([]);
242
- mocks.groupSkipReasons.mockReturnValue({});
259
+ mocks.groupSkipReasons.mockImplementation((reasons: Array<{ reason: string }>) =>
260
+ reasons.reduce<Record<string, number>>((counts, reason) => {
261
+ counts[reason.reason] = (counts[reason.reason] || 0) + 1;
262
+ return counts;
263
+ }, {}),
264
+ );
243
265
  mocks.loadCache.mockResolvedValue(null);
244
266
  mocks.getCacheAgeMs.mockReturnValue(60000);
245
267
  mocks.formatDuration.mockReturnValue('1 minute');
@@ -485,7 +507,7 @@ describe('registerOpenRouterCommands', () => {
485
507
  );
486
508
  });
487
509
 
488
- it('keeps models-sync success notifications unchanged', async () => {
510
+ it('routes full models-sync through explicit full mode and updated success copy', async () => {
489
511
  const { commands, pi } = createMockPi();
490
512
  const ctx = createMockContext();
491
513
 
@@ -498,36 +520,97 @@ describe('registerOpenRouterCommands', () => {
498
520
  registerOpenRouterCommands(pi as any);
499
521
  await commands.get('openrouter').handler('models-sync', ctx);
500
522
 
501
- expect(mocks.syncModels).toHaveBeenCalledWith(ctx);
523
+ expect(mocks.syncModels).toHaveBeenCalledWith(ctx, 'full');
502
524
  expect(ctx.ui.notify).toHaveBeenCalledWith(
503
- 'OpenRouter models synced\n9 registered · 2 skipped · cache updated',
525
+ 'OpenRouter models synced\n9 registered · 2 skipped · cache age: 0m',
504
526
  'info',
505
527
  );
506
528
  });
507
529
 
508
- it('shows grouped skipped-details hints once per reason', async () => {
530
+ it('threads --free through models-sync and shows the free-only sync note', async () => {
509
531
  const { commands, pi } = createMockPi();
510
532
  const ctx = createMockContext();
511
533
 
512
- mocks.getSyncState.mockReturnValue({ success: true, registeredCount: 7 });
513
- mocks.getSkipReasonsAsync.mockResolvedValue([
514
- {
515
- id: 'provider/a',
516
- reason: 'missing context window',
517
- hint: "Add a local contextWindow override with '/openrouter model-override-set <model-id> contextWindow=<tokens>' if the model's limit is known.",
518
- },
519
- { id: 'provider/b', reason: 'missing context window' },
520
- ]);
521
- mocks.groupSkipReasons.mockReturnValue({ 'missing context window': 2 });
534
+ mocks.syncModels.mockResolvedValue({
535
+ success: true,
536
+ registeredCount: 28,
537
+ skippedCount: 3,
538
+ });
539
+
540
+ registerOpenRouterCommands(pi as any);
541
+ await commands.get('openrouter').handler('models-sync --free', ctx);
542
+
543
+ expect(mocks.syncModels).toHaveBeenCalledWith(ctx, 'free-only');
544
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
545
+ "OpenRouter free models synced\n28 registered · 3 skipped · cache age: 0m\n\nSelect openrouter/free for OpenRouter's built-in free router, or choose a specific :free model.",
546
+ 'info',
547
+ );
548
+ });
549
+
550
+ it('shows the free-empty no-op copy exactly', async () => {
551
+ const { commands, pi } = createMockPi();
552
+ const ctx = createMockContext();
553
+
554
+ mocks.syncModels.mockResolvedValue({
555
+ success: false,
556
+ outcome: 'no-change',
557
+ requestedMode: 'free-only',
558
+ catalogMode: 'full',
559
+ registeredCount: 0,
560
+ skippedCount: 0,
561
+ source: 'api',
562
+ cacheUpdated: false,
563
+ cacheAgeMs: 0,
564
+ error: null,
565
+ });
566
+
567
+ registerOpenRouterCommands(pi as any);
568
+ await commands.get('openrouter').handler('models-sync --free', ctx);
569
+
570
+ expect(mocks.syncModels).toHaveBeenCalledWith(ctx, 'free-only');
571
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
572
+ 'No free OpenRouter models found\nNo model catalog changed',
573
+ 'info',
574
+ );
575
+ });
576
+
577
+ it('shows grouped skipped-details hints once per reason for models-status --free in free-only mode', async () => {
578
+ const { commands, pi } = createMockPi();
579
+ const ctx = createMockContext();
580
+
581
+ mocks.getActiveCatalogState.mockReturnValue(
582
+ createActiveCatalogState({
583
+ mode: 'free-only',
584
+ registeredModelIds: [
585
+ 'openrouter/free',
586
+ 'provider/a:free',
587
+ 'provider/b:free',
588
+ 'provider/c:free',
589
+ 'provider/d:free',
590
+ 'provider/e:free',
591
+ 'provider/f:free',
592
+ ],
593
+ registeredCount: 7,
594
+ skippedDetails: [
595
+ {
596
+ id: 'provider/a:free',
597
+ reason: 'missing context window',
598
+ hint: "Add a local contextWindow override with '/openrouter model-override-set <model-id> contextWindow=<tokens>' if the model's limit is known.",
599
+ },
600
+ { id: 'provider/b:free', reason: 'missing context window' },
601
+ ],
602
+ skippedCount: 2,
603
+ }),
604
+ );
522
605
  mocks.loadCache.mockResolvedValue({ models: [], timestamp: Date.now() - 60000 });
523
606
  mocks.getCacheAgeMs.mockReturnValue(60000);
524
- mocks.formatDuration.mockReturnValue('1 minute');
607
+ mocks.formatDuration.mockReturnValue('1m');
525
608
 
526
609
  registerOpenRouterCommands(pi as any);
527
- await commands.get('openrouter').handler('models-status --skipped', ctx);
610
+ await commands.get('openrouter').handler('models-status --free --skipped', ctx);
528
611
 
529
612
  expect(ctx.ui.notify).toHaveBeenCalledWith(
530
- "OpenRouter models healthy\n7 registered · 2 skipped · cache age: 1 minute\n\nOpenRouter skipped models: 2\n\n2 missing context window\n suggestion: Add a local contextWindow override with '/openrouter model-override-set <model-id> contextWindow=<tokens>' if the model's limit is known.\n- provider/a\n- provider/b\n",
613
+ "OpenRouter models healthy\n7 registered · 2 skipped · free-only catalog · cache age: 1m\n\nOpenRouter skipped models: 2\n\n2 missing context window\n suggestion: Add a local contextWindow override with '/openrouter model-override-set <model-id> contextWindow=<tokens>' if the model's limit is known.\n- provider/a:free\n- provider/b:free\n",
531
614
  'info',
532
615
  );
533
616
  });
@@ -656,10 +739,17 @@ describe('registerOpenRouterCommands', () => {
656
739
  });
657
740
 
658
741
  describe('models-sync failure paths', () => {
659
- it('shows cache-backed failure with warning level and cache metadata', async () => {
742
+ it('shows mode-aware cache-backed refresh failure copy', async () => {
660
743
  const { commands, pi } = createMockPi();
661
744
  const ctx = createMockContext();
662
745
 
746
+ mocks.getActiveCatalogState.mockReturnValue(
747
+ createActiveCatalogState({
748
+ mode: 'free-only',
749
+ registeredModelIds: ['openrouter/free', 'provider/a:free'],
750
+ registeredCount: 2,
751
+ }),
752
+ );
663
753
  mocks.syncModels.mockResolvedValue({
664
754
  success: false,
665
755
  source: 'cache',
@@ -667,13 +757,13 @@ describe('registerOpenRouterCommands', () => {
667
757
  cacheAgeMs: 300000,
668
758
  error: 'API timeout',
669
759
  });
670
- mocks.formatDuration.mockReturnValue('5 minutes');
760
+ mocks.formatDuration.mockReturnValue('5m');
671
761
 
672
762
  registerOpenRouterCommands(pi as any);
673
763
  await commands.get('openrouter').handler('models-sync', ctx);
674
764
 
675
765
  expect(ctx.ui.notify).toHaveBeenCalledWith(
676
- 'OpenRouter models sync failed\n5 registered from cache\nCache age: 5 minutes\nError: API timeout',
766
+ 'OpenRouter models refresh failed\nUsing last successful free-only catalog · cache age: 5m',
677
767
  'warning',
678
768
  );
679
769
  });
@@ -730,34 +820,42 @@ describe('registerOpenRouterCommands', () => {
730
820
  );
731
821
  });
732
822
 
733
- it('shows cache-backed failure warning with skip details when --skipped flag present', async () => {
823
+ it('filters active full catalogs for models-status --skipped --free regardless of flag order', async () => {
734
824
  const { commands, pi } = createMockPi();
735
825
  const ctx = createMockContext();
736
826
 
737
- mocks.getSyncState.mockReturnValue({
738
- success: false,
739
- source: 'cache',
740
- registeredCount: 8,
741
- error: 'Auth expired',
742
- });
743
- mocks.getSkipReasonsAsync.mockResolvedValue([
744
- { id: 'test/model', reason: 'missing pricing' },
745
- ]);
746
- mocks.groupSkipReasons.mockReturnValue({ 'missing pricing': 1 });
747
- mocks.loadCache.mockResolvedValue({ models: [], timestamp: Date.now() - 180000 });
748
- mocks.getCacheAgeMs.mockReturnValue(180000);
749
- mocks.formatDuration.mockReturnValue('3 minutes');
827
+ mocks.getActiveCatalogState.mockReturnValue(
828
+ createActiveCatalogState({
829
+ mode: 'full',
830
+ registeredModelIds: [
831
+ 'openrouter/free',
832
+ 'provider/free-a:free',
833
+ 'provider/paid-a',
834
+ 'provider/paid-b',
835
+ ],
836
+ registeredCount: 4,
837
+ skippedDetails: [
838
+ {
839
+ id: 'provider/free-b:free',
840
+ reason: 'missing context window',
841
+ hint: "Add a local contextWindow override with '/openrouter model-override-set <model-id> contextWindow=<tokens>' if the model's limit is known.",
842
+ },
843
+ { id: 'provider/paid-c', reason: 'missing pricing' },
844
+ ],
845
+ skippedCount: 2,
846
+ }),
847
+ );
848
+ mocks.loadCache.mockResolvedValue({ models: [], timestamp: Date.now() - 240000 });
849
+ mocks.getCacheAgeMs.mockReturnValue(240000);
850
+ mocks.formatDuration.mockReturnValue('4m');
750
851
 
751
852
  registerOpenRouterCommands(pi as any);
752
- await commands.get('openrouter').handler('models-status --skipped', ctx);
753
-
754
- const notifyCall = ctx.ui.notify.mock.calls[0];
755
- expect(notifyCall[1]).toBe('warning');
756
- expect(notifyCall[0]).toContain('OpenRouter models cached');
757
- expect(notifyCall[0]).toContain('8 registered · 1 skipped');
758
- expect(notifyCall[0]).toContain('Cache age: 3 minutes');
759
- expect(notifyCall[0]).toContain('Error: Auth expired');
760
- expect(notifyCall[0]).toContain('missing pricing');
853
+ await commands.get('openrouter').handler('models-status --free --skipped', ctx);
854
+
855
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
856
+ "OpenRouter models healthy\n2 registered · 1 skipped · full catalog · cache age: 4m\n\nOpenRouter skipped models: 1\n\n1 missing context window\n suggestion: Add a local contextWindow override with '/openrouter model-override-set <model-id> contextWindow=<tokens>' if the model's limit is known.\n- provider/free-b:free\n",
857
+ 'info',
858
+ );
761
859
  });
762
860
 
763
861
  it('shows broken error when state exists but not from cache and not success', async () => {
@@ -66,6 +66,7 @@ export function createValidModel(overrides?: Partial<OpenRouterModel>): OpenRout
66
66
  */
67
67
  export function createMockCache(overrides: Partial<ModelsCache> = {}): ModelsCache {
68
68
  return {
69
+ catalogMode: 'full',
69
70
  models: [createValidModel()],
70
71
  timestamp: Date.now() - 1000, // 1 second ago
71
72
  ...overrides,
@@ -8,8 +8,10 @@ const mocks = vi.hoisted(() => ({
8
8
  getCacheAgeMs: vi.fn(),
9
9
  formatDuration: vi.fn(),
10
10
  mapOpenRouterModels: vi.fn(),
11
+ filterModelsForCatalogMode: vi.fn(),
11
12
  includeBuiltinRouterModels: vi.fn(),
12
13
  isSyncEnabled: vi.fn(),
14
+ setActiveCatalogState: vi.fn(),
13
15
  loadOpenRouterStatusBar: vi.fn(),
14
16
  }));
15
17
 
@@ -37,8 +39,10 @@ vi.mock('../models/mapper.js', () => ({
37
39
  }));
38
40
 
39
41
  vi.mock('../models/sync.js', () => ({
42
+ filterModelsForCatalogMode: mocks.filterModelsForCatalogMode,
40
43
  includeBuiltinRouterModels: mocks.includeBuiltinRouterModels,
41
44
  isSyncEnabled: mocks.isSyncEnabled,
45
+ setActiveCatalogState: mocks.setActiveCatalogState,
42
46
  }));
43
47
 
44
48
  vi.mock('../status-bar.js', () => ({
@@ -97,7 +101,12 @@ describe('openrouter hooks', () => {
97
101
  mocks.loadCache.mockResolvedValue(null);
98
102
  mocks.getCacheAgeMs.mockReturnValue(60000);
99
103
  mocks.formatDuration.mockReturnValue('1 minute');
100
- mocks.mapOpenRouterModels.mockResolvedValue({ configs: [{ id: 'model-a' }] });
104
+ mocks.mapOpenRouterModels.mockResolvedValue({
105
+ configs: [{ id: 'model-a' }],
106
+ skipped: 0,
107
+ skippedDetails: [],
108
+ });
109
+ mocks.filterModelsForCatalogMode.mockImplementation((models: Array<{ id: string }>) => models);
101
110
  mocks.includeBuiltinRouterModels.mockReturnValue([{ id: 'model-a' }, { id: 'router' }]);
102
111
  mocks.isSyncEnabled.mockReturnValue(true);
103
112
  mocks.loadOpenRouterStatusBar.mockResolvedValue({ kind: 'empty' });
@@ -107,17 +116,24 @@ describe('openrouter hooks', () => {
107
116
  vi.useRealTimers();
108
117
  });
109
118
 
110
- it('loads cached startup models and preserves startup cache info', async () => {
119
+ it('loads cached startup models, respects cached mode, and seeds active catalog state', async () => {
111
120
  const { pi } = createMockPi();
112
121
  const { loadStartupCacheState } = await loadHooksModule();
113
122
 
114
123
  mocks.loadCache.mockResolvedValue({
124
+ catalogMode: 'full',
115
125
  models: [{ id: 'cached/model-a' }],
126
+ skippedDetails: [],
116
127
  timestamp: Date.now() - 60000,
117
128
  });
118
129
 
119
130
  const startupState = await loadStartupCacheState(pi as any);
120
131
 
132
+ expect(mocks.filterModelsForCatalogMode).toHaveBeenCalledWith(
133
+ [{ id: 'cached/model-a' }],
134
+ 'full',
135
+ );
136
+ expect(mocks.includeBuiltinRouterModels).toHaveBeenCalledWith([{ id: 'model-a' }], 'full');
121
137
  expect(pi.registerProvider).toHaveBeenCalledWith('openrouter', {
122
138
  baseUrl: 'https://openrouter.ai/api/v1',
123
139
  apiKey: 'OPENROUTER_API_KEY',
@@ -125,6 +141,62 @@ describe('openrouter hooks', () => {
125
141
  models: [{ id: 'model-a' }, { id: 'router' }],
126
142
  authHeader: true,
127
143
  });
144
+ expect(mocks.setActiveCatalogState).toHaveBeenCalledWith({
145
+ mode: 'full',
146
+ registeredModelIds: ['model-a', 'router'],
147
+ registeredCount: 2,
148
+ skippedCount: 0,
149
+ skippedDetails: [],
150
+ source: 'cache',
151
+ cacheAgeMs: 60000,
152
+ });
153
+ expect(startupState).toEqual({
154
+ info: {
155
+ count: 2,
156
+ age: '1 minute',
157
+ },
158
+ });
159
+ });
160
+
161
+ it('loads cached free-only startup models with free-only router injection only', async () => {
162
+ const { pi } = createMockPi();
163
+ const { loadStartupCacheState } = await loadHooksModule();
164
+
165
+ mocks.loadCache.mockResolvedValue({
166
+ catalogMode: 'free-only',
167
+ models: [{ id: 'cached/model-a:free' }],
168
+ skippedDetails: [{ id: 'bad/model:free', reason: 'missing context window' }],
169
+ timestamp: Date.now() - 60000,
170
+ });
171
+ mocks.mapOpenRouterModels.mockResolvedValue({
172
+ configs: [{ id: 'cached/model-a:free' }],
173
+ skipped: 1,
174
+ skippedDetails: [{ id: 'bad/model:free', reason: 'missing context window' }],
175
+ });
176
+ mocks.includeBuiltinRouterModels.mockReturnValue([
177
+ { id: 'cached/model-a:free' },
178
+ { id: 'openrouter/free' },
179
+ ]);
180
+
181
+ const startupState = await loadStartupCacheState(pi as any);
182
+
183
+ expect(mocks.filterModelsForCatalogMode).toHaveBeenCalledWith(
184
+ [{ id: 'cached/model-a:free' }],
185
+ 'free-only',
186
+ );
187
+ expect(mocks.includeBuiltinRouterModels).toHaveBeenCalledWith(
188
+ [{ id: 'cached/model-a:free' }],
189
+ 'free-only',
190
+ );
191
+ expect(mocks.setActiveCatalogState).toHaveBeenCalledWith({
192
+ mode: 'free-only',
193
+ registeredModelIds: ['cached/model-a:free', 'openrouter/free'],
194
+ registeredCount: 2,
195
+ skippedCount: 1,
196
+ skippedDetails: [{ id: 'bad/model:free', reason: 'missing context window' }],
197
+ source: 'cache',
198
+ cacheAgeMs: 60000,
199
+ });
128
200
  expect(startupState).toEqual({
129
201
  info: {
130
202
  count: 2,
@@ -138,14 +210,21 @@ describe('openrouter hooks', () => {
138
210
  const { loadStartupCacheState } = await loadHooksModule();
139
211
 
140
212
  mocks.loadCache.mockResolvedValue({
141
- models: [{ id: 'cached/model-a' }],
213
+ catalogMode: 'free-only',
214
+ models: [{ id: 'cached/model-a:free' }],
215
+ skippedDetails: [],
142
216
  timestamp: Date.now() - 60000,
143
217
  });
144
218
  mocks.mapOpenRouterModels.mockRejectedValue(new Error('mapper failed'));
145
219
 
146
220
  const startupState = await loadStartupCacheState(pi as any);
147
221
 
222
+ expect(mocks.filterModelsForCatalogMode).toHaveBeenCalledWith(
223
+ [{ id: 'cached/model-a:free' }],
224
+ 'free-only',
225
+ );
148
226
  expect(pi.registerProvider).not.toHaveBeenCalled();
227
+ expect(mocks.setActiveCatalogState).not.toHaveBeenCalled();
149
228
  expect(startupState).toEqual({
150
229
  warning: 'OpenRouter: cached models found but failed to register: mapper failed',
151
230
  });
@@ -22,6 +22,7 @@ import type { CurrentKeyRelation, KeyInfo, RollupStatus } from './account-types.
22
22
  import {
23
23
  syncModels,
24
24
  getSyncState,
25
+ getActiveCatalogState,
25
26
  isSyncEnabled,
26
27
  getSkipReasonsAsync,
27
28
  groupSkipReasons,
@@ -29,7 +30,7 @@ import {
29
30
  import { loadCache, getCacheAgeMs, formatDuration } from './models/cache.js';
30
31
  import { loadModelOverrides } from './models/overrides.js';
31
32
  import { getSkipReasonHint } from './models/skip-hints.js';
32
- import type { ModelOverridesFile } from './models/types.js';
33
+ import type { CatalogMode, ModelOverridesFile, SkipReason, SyncResult } from './models/types.js';
33
34
  import {
34
35
  handleModelOverrideSet,
35
36
  handleModelOverrideClear,
@@ -111,7 +112,7 @@ async function handleOpenRouterCommand(args: string, ctx: ExtensionContext): Pro
111
112
  break;
112
113
  }
113
114
  case 'models-sync': {
114
- await handleModelsSyncCommand(ctx);
115
+ await handleModelsSyncCommand(ctx, flags);
115
116
  break;
116
117
  }
117
118
  case 'models-status': {
@@ -176,7 +177,34 @@ function notifyCurrentSession(ctx: {
176
177
  ctx.ui.notify(`OpenRouter session_id\n${getCurrentSessionId(ctx)}`, 'info');
177
178
  }
178
179
 
179
- async function handleModelsSyncCommand(ctx: ExtensionContext): Promise<void> {
180
+ function getRequestedCatalogMode(flags: Record<string, boolean>): CatalogMode {
181
+ return flags['--free'] ? 'free-only' : 'full';
182
+ }
183
+
184
+ function getCatalogModeLabel(mode: CatalogMode): string {
185
+ return mode === 'free-only' ? 'free-only catalog' : 'full catalog';
186
+ }
187
+
188
+ function resolveCatalogMode(result: SyncResult, fallbackMode: CatalogMode): CatalogMode {
189
+ return result.catalogMode ?? fallbackMode;
190
+ }
191
+
192
+ function isNoCatalogChange(result: SyncResult): boolean {
193
+ return result.outcome === 'no-change';
194
+ }
195
+
196
+ function isFreeCatalogModelId(id: string): boolean {
197
+ return id === 'openrouter/free' || id.endsWith(':free');
198
+ }
199
+
200
+ function filterFreeSkipReasons(skipReasons: SkipReason[]): SkipReason[] {
201
+ return skipReasons.filter((item) => isFreeCatalogModelId(item.id));
202
+ }
203
+
204
+ async function handleModelsSyncCommand(
205
+ ctx: ExtensionContext,
206
+ flags: Record<string, boolean>,
207
+ ): Promise<void> {
180
208
  if (!isSyncEnabled()) {
181
209
  ctx.ui.notify(
182
210
  'OpenRouter model sync is disabled. Set openrouterModelSync: true in ~/.pi/agent/settings.json to enable.',
@@ -185,19 +213,39 @@ async function handleModelsSyncCommand(ctx: ExtensionContext): Promise<void> {
185
213
  return;
186
214
  }
187
215
 
188
- const result = await syncModels(ctx);
216
+ const requestedMode = getRequestedCatalogMode(flags);
217
+ const result = await syncModels(ctx, requestedMode);
218
+
219
+ if (requestedMode === 'free-only' && isNoCatalogChange(result)) {
220
+ ctx.ui.notify('No free OpenRouter models found\nNo model catalog changed', 'info');
221
+ return;
222
+ }
223
+
189
224
  if (!result.success) {
190
- let message = '';
191
225
  if (result.source === 'cache') {
192
- message = `OpenRouter models sync failed\n${result.registeredCount} registered from cache\nCache age: ${formatDuration(result.cacheAgeMs)}\nError: ${result.error}`;
193
- } else {
194
- message = `OpenRouter models unavailable\n0 registered\nError: ${result.error}`;
226
+ const activeCatalogState = getActiveCatalogState();
227
+ const activeCatalogMode = resolveCatalogMode(
228
+ result,
229
+ activeCatalogState?.mode ?? requestedMode,
230
+ );
231
+ ctx.ui.notify(
232
+ `OpenRouter models refresh failed\nUsing last successful ${getCatalogModeLabel(activeCatalogMode)} · cache age: ${formatDuration(result.cacheAgeMs)}`,
233
+ 'warning',
234
+ );
235
+ return;
195
236
  }
196
- ctx.ui.notify(message, result.source === 'cache' ? 'warning' : 'error');
237
+
238
+ ctx.ui.notify(`OpenRouter models unavailable\n0 registered\nError: ${result.error}`, 'error');
197
239
  return;
198
240
  }
199
241
 
200
- const message = `OpenRouter models synced\n${result.registeredCount} registered${result.skippedCount > 0 ? ` · ${result.skippedCount} skipped` : ''} · cache updated`;
242
+ let message = `${requestedMode === 'free-only' ? 'OpenRouter free models synced' : 'OpenRouter models synced'}\n${result.registeredCount} registered${result.skippedCount > 0 ? ` · ${result.skippedCount} skipped` : ''} · cache age: 0m`;
243
+
244
+ if (requestedMode === 'free-only') {
245
+ message +=
246
+ "\n\nSelect openrouter/free for OpenRouter's built-in free router, or choose a specific :free model.";
247
+ }
248
+
201
249
  ctx.ui.notify(message, 'info');
202
250
  }
203
251
 
@@ -206,12 +254,42 @@ async function handleModelsStatusCommand(
206
254
  flags: Record<string, boolean>,
207
255
  ): Promise<void> {
208
256
  const state = getSyncState();
209
- const skipReasons = await getSkipReasonsAsync();
210
- const groupedReasons = groupSkipReasons(skipReasons);
257
+ const activeCatalogState = getActiveCatalogState();
211
258
 
212
259
  const cache = await loadCache();
213
260
  const cacheAgeMs = cache ? getCacheAgeMs(cache) : null;
214
261
 
262
+ if (activeCatalogState) {
263
+ const activeSkipReasons = activeCatalogState.skippedDetails;
264
+ const visibleSkipReasons = flags['--free']
265
+ ? filterFreeSkipReasons(activeSkipReasons)
266
+ : activeSkipReasons;
267
+ const visibleSkipCount = flags['--free']
268
+ ? visibleSkipReasons.length
269
+ : activeCatalogState.skippedCount;
270
+ const visibleRegisteredIds = flags['--free']
271
+ ? (activeCatalogState.registeredModelIds ?? []).filter(isFreeCatalogModelId)
272
+ : (activeCatalogState.registeredModelIds ?? []);
273
+ const visibleRegisteredCount = flags['--free']
274
+ ? visibleRegisteredIds.length
275
+ : activeCatalogState.registeredCount;
276
+ const groupedReasons = flags['--skipped'] ? groupSkipReasons(visibleSkipReasons) : {};
277
+ const visibleCacheAgeMs = cacheAgeMs ?? activeCatalogState.cacheAgeMs;
278
+
279
+ const statusLabel = activeCatalogState.source === 'cache' ? 'cached' : 'healthy';
280
+ let message = `OpenRouter models ${statusLabel}\n${visibleRegisteredCount} registered${visibleSkipCount > 0 ? ` · ${visibleSkipCount} skipped` : ''} · ${getCatalogModeLabel(activeCatalogState.mode)} · cache age: ${formatDuration(visibleCacheAgeMs)}`;
281
+
282
+ if (flags['--skipped']) {
283
+ message += formatSkippedDetails(visibleSkipCount, groupedReasons, visibleSkipReasons);
284
+ }
285
+ ctx.ui.notify(message, 'info');
286
+ return;
287
+ }
288
+
289
+ const skipReasons = await getSkipReasonsAsync();
290
+ const visibleSkipReasons = flags['--free'] ? filterFreeSkipReasons(skipReasons) : skipReasons;
291
+ const groupedReasons = flags['--skipped'] ? groupSkipReasons(visibleSkipReasons) : {};
292
+
215
293
  if (!state && !cache) {
216
294
  ctx.ui.notify('OpenRouter models: not synced', 'error');
217
295
  return;
@@ -225,22 +303,22 @@ async function handleModelsStatusCommand(
225
303
  }
226
304
 
227
305
  if (state?.success) {
228
- const skipCount = skipReasons.length;
306
+ const skipCount = visibleSkipReasons.length;
229
307
  let message = `OpenRouter models healthy\n${state.registeredCount} registered${skipCount > 0 ? ` · ${skipCount} skipped` : ''} · cache age: ${formatDuration(cacheAgeMs)}`;
230
308
 
231
309
  if (flags['--skipped']) {
232
- message += formatSkippedDetails(skipCount, groupedReasons, skipReasons);
310
+ message += formatSkippedDetails(skipCount, groupedReasons, visibleSkipReasons);
233
311
  }
234
312
  ctx.ui.notify(message, 'info');
235
313
  return;
236
314
  }
237
315
 
238
316
  if (state?.source === 'cache') {
239
- const skipCount = skipReasons.length;
317
+ const skipCount = visibleSkipReasons.length;
240
318
  let message = `OpenRouter models cached\n${state.registeredCount} registered${skipCount > 0 ? ` · ${skipCount} skipped` : ''}\nCache age: ${formatDuration(cacheAgeMs)}\nError: ${state.error}`;
241
319
 
242
320
  if (flags['--skipped']) {
243
- message += formatSkippedDetails(skipCount, groupedReasons, skipReasons);
321
+ message += formatSkippedDetails(skipCount, groupedReasons, visibleSkipReasons);
244
322
  }
245
323
  ctx.ui.notify(message, 'warning');
246
324
  return;
@@ -398,7 +476,7 @@ function getErrorMessage(error: unknown): string {
398
476
  function formatSkippedDetails(
399
477
  skipCount: number,
400
478
  groupedReasons: Record<string, number>,
401
- skipReasons: Array<{ id: string; reason: string; hint?: string }>,
479
+ skipReasons: SkipReason[],
402
480
  ): string {
403
481
  if (skipCount === 0) {
404
482
  return '\n\nNo skipped models';