@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
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# pi-openrouter
|
|
2
2
|
|
|
3
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.
|
|
4
|
+
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
5
|
|
|
5
6
|
## Installation
|
|
6
7
|
|
|
@@ -32,7 +33,9 @@ export OPENROUTER_MANAGEMENT_KEY=sk-or-...
|
|
|
32
33
|
/openrouter session # current OpenRouter session_id
|
|
33
34
|
/openrouter api-key-create # create an API key (management key required)
|
|
34
35
|
/openrouter models-sync # sync user-scoped OpenRouter models into Pi
|
|
36
|
+
/openrouter models-sync --free # sync only openrouter/free plus explicit :free models
|
|
35
37
|
/openrouter models-status # show model sync/cache status
|
|
38
|
+
/openrouter models-status --free # show only registered/skipped free models
|
|
36
39
|
/openrouter models-status --skipped # show skipped model reasons
|
|
37
40
|
/openrouter model-override-set # set local model field overrides
|
|
38
41
|
/openrouter model-override-list # list local model field overrides
|
|
@@ -47,6 +50,29 @@ export OPENROUTER_MANAGEMENT_KEY=sk-or-...
|
|
|
47
50
|
|
|
48
51
|
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
52
|
|
|
53
|
+
### Free models only
|
|
54
|
+
|
|
55
|
+
To sync only OpenRouter's free route and explicit free model variants:
|
|
56
|
+
|
|
57
|
+
```shell
|
|
58
|
+
/openrouter models-sync --free
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
This registers `openrouter/free` plus available `*:free` models.
|
|
62
|
+
|
|
63
|
+
Use `openrouter/free` for OpenRouter's built-in free model router, or select a specific `:free` model when you want direct control.
|
|
64
|
+
|
|
65
|
+
Free models are best-effort and rate-limited by OpenRouter. They are useful for experiments and low-stakes work, not guaranteed coding loops.
|
|
66
|
+
|
|
67
|
+
Example free-only status output:
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
OpenRouter models healthy
|
|
71
|
+
28 registered · 3 skipped · free-only catalog · cache age: 4m
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Run `/openrouter models-sync` again to restore the full user-scoped catalog.
|
|
75
|
+
|
|
50
76
|
`/openrouter models-status`
|
|
51
77
|
|
|
52
78
|
Model count and cache health live here. The Pi footer/status bar does not persistently show `OpenRouter {N} models` anymore.
|
|
@@ -55,7 +81,7 @@ Example status output:
|
|
|
55
81
|
|
|
56
82
|
```text
|
|
57
83
|
OpenRouter models healthy
|
|
58
|
-
363 registered ·
|
|
84
|
+
363 registered · 12 skipped · full catalog · cache age: 4m
|
|
59
85
|
```
|
|
60
86
|
|
|
61
87
|
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.
|
|
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('
|
|
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
|
|
525
|
+
'OpenRouter models synced\n9 registered · 2 skipped · cache age: 0m',
|
|
504
526
|
'info',
|
|
505
527
|
);
|
|
506
528
|
});
|
|
507
529
|
|
|
508
|
-
it('
|
|
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.
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
mocks.
|
|
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('
|
|
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:
|
|
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
|
|
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('
|
|
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
|
|
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('
|
|
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.
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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({
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
237
|
+
|
|
238
|
+
ctx.ui.notify(`OpenRouter models unavailable\n0 registered\nError: ${result.error}`, 'error');
|
|
197
239
|
return;
|
|
198
240
|
}
|
|
199
241
|
|
|
200
|
-
|
|
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
|
|
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 =
|
|
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,
|
|
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 =
|
|
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,
|
|
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:
|
|
479
|
+
skipReasons: SkipReason[],
|
|
402
480
|
): string {
|
|
403
481
|
if (skipCount === 0) {
|
|
404
482
|
return '\n\nNo skipped models';
|