@robhowley/pi-openrouter 0.8.1 → 0.8.3
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/extensions/openrouter/__tests__/fixtures.ts +27 -0
- package/extensions/openrouter/__tests__/format.test.ts +49 -1
- package/extensions/openrouter/format.ts +7 -8
- package/extensions/openrouter/index.ts +1 -1
- package/extensions/openrouter/models/__tests__/mapper.test.ts +61 -42
- package/extensions/openrouter/models/mapper.ts +82 -5
- package/extensions/openrouter/models/sync.ts +2 -2
- package/extensions/openrouter/models/types.ts +15 -0
- package/package.json +6 -3
|
@@ -132,3 +132,30 @@ export function createPiModelConfig(overrides?: Partial<PiModelConfig>): PiModel
|
|
|
132
132
|
...overrides,
|
|
133
133
|
};
|
|
134
134
|
}
|
|
135
|
+
|
|
136
|
+
// =============================================================================
|
|
137
|
+
// LocalUsageEvent Fixtures
|
|
138
|
+
// =============================================================================
|
|
139
|
+
|
|
140
|
+
import type { LocalUsageEvent } from '../types.js';
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Creates LocalUsageEvent objects for testing local usage aggregation.
|
|
144
|
+
* Use daysAgo=0 for today, daysAgo=3 for within 7d window, etc.
|
|
145
|
+
*/
|
|
146
|
+
export function createLocalEvents(
|
|
147
|
+
events: Array<{ daysAgo: number; cost: number; model?: string; provider?: string }>,
|
|
148
|
+
): LocalUsageEvent[] {
|
|
149
|
+
return events.map((e, i) => ({
|
|
150
|
+
id: `local-${i}`,
|
|
151
|
+
sessionId: 'test-session',
|
|
152
|
+
generationId: `gen-${i}`,
|
|
153
|
+
completedAt: `${createTestDate(e.daysAgo)}T12:00:00.000Z`,
|
|
154
|
+
requests: 1,
|
|
155
|
+
model: e.model ?? 'gpt-4',
|
|
156
|
+
provider: e.provider ?? 'openai',
|
|
157
|
+
promptTokens: 100,
|
|
158
|
+
completionTokens: 50,
|
|
159
|
+
cost: e.cost,
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { aggregateUsage } from '../format.js';
|
|
3
3
|
import { renderSpendSparkline } from '../chart.js';
|
|
4
|
-
import { createTestDate, createActivityItem } from './fixtures.js';
|
|
4
|
+
import { createTestDate, createActivityItem, createLocalEvents } from './fixtures.js';
|
|
5
5
|
import type { ActivityItem } from '@openrouter/sdk/models/index.js';
|
|
6
6
|
|
|
7
7
|
describe('aggregateUsage', () => {
|
|
@@ -224,6 +224,54 @@ describe('aggregateUsage', () => {
|
|
|
224
224
|
expect(result.byProvider[0]?.tokens.output).toBe(80); // 50 + 30
|
|
225
225
|
expect(result.byProvider[0]?.requests).toBe(8); // 5 + 3
|
|
226
226
|
});
|
|
227
|
+
|
|
228
|
+
it.each([
|
|
229
|
+
{
|
|
230
|
+
name: 'local today included in 7d model stats',
|
|
231
|
+
api: { daysAgo: 3, cost: 5, model: 'gpt-4' },
|
|
232
|
+
local: { daysAgo: 0, cost: 2.5, model: 'gpt-4' },
|
|
233
|
+
expectSpend7d: 7.5,
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
name: 'local outside 7d window excluded from 7d stats',
|
|
237
|
+
api: { daysAgo: 3, cost: 5, model: 'gpt-4' },
|
|
238
|
+
local: { daysAgo: 10, cost: 100, model: 'gpt-4' },
|
|
239
|
+
expectSpend7d: 5,
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
name: 'different models tracked separately',
|
|
243
|
+
api: { daysAgo: 1, cost: 5, model: 'gpt-4' },
|
|
244
|
+
local: { daysAgo: 0, cost: 3, model: 'claude-3' },
|
|
245
|
+
expectSpend7d: null,
|
|
246
|
+
expectModels: [
|
|
247
|
+
{ name: 'gpt-4', spend7d: 5 },
|
|
248
|
+
{ name: 'claude-3', spend7d: 3 },
|
|
249
|
+
],
|
|
250
|
+
},
|
|
251
|
+
])('$name', ({ api, local, expectSpend7d, expectModels }) => {
|
|
252
|
+
const credits = { totalUsage: 1000, totalCredits: 2000 };
|
|
253
|
+
|
|
254
|
+
const result = aggregateUsage(
|
|
255
|
+
credits,
|
|
256
|
+
[
|
|
257
|
+
createActivityItem({
|
|
258
|
+
date: createTestDate(api.daysAgo),
|
|
259
|
+
usage: api.cost,
|
|
260
|
+
model: api.model,
|
|
261
|
+
}),
|
|
262
|
+
],
|
|
263
|
+
Date.now(),
|
|
264
|
+
createLocalEvents([local]),
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
if (expectModels) {
|
|
268
|
+
for (const { name, spend7d } of expectModels) {
|
|
269
|
+
expect(result.topModels.find((m) => m.name === name)?.spend7d).toBe(spend7d);
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
expect(result.topModels[0]?.spend7d).toBe(expectSpend7d);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
227
275
|
});
|
|
228
276
|
|
|
229
277
|
describe('renderSpendSparkline', () => {
|
|
@@ -25,13 +25,10 @@ export function aggregateUsage(
|
|
|
25
25
|
return d.date >= utcISODate(startOfWeek);
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
-
const todayData = analytics.filter((d) => {
|
|
29
|
-
// API dates are YYYY-MM-DD in UTC; compare by UTC date boundary
|
|
30
|
-
return d.date >= utcISODate(startOfDay);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
28
|
const weekFromAnalytics = sumSpend(weekData);
|
|
34
|
-
const todayFromAnalytics =
|
|
29
|
+
const todayFromAnalytics = analytics
|
|
30
|
+
.filter((d) => d.date >= utcISODate(startOfDay))
|
|
31
|
+
.reduce((sum, d) => sum + d.usage, 0);
|
|
35
32
|
const month = credits.totalUsage;
|
|
36
33
|
|
|
37
34
|
// Add local events to compute combined totals
|
|
@@ -65,8 +62,10 @@ export function aggregateUsage(
|
|
|
65
62
|
const allData = [...analytics, ...localItems];
|
|
66
63
|
|
|
67
64
|
// Build model stats for both 7d and 30d windows
|
|
68
|
-
//
|
|
69
|
-
const
|
|
65
|
+
// Combine weekData with local events in the 7d window
|
|
66
|
+
const weekLocalItems = localItems.filter((e) => e.date >= utcISODate(startOfWeek));
|
|
67
|
+
const weekAllData = [...weekData, ...weekLocalItems];
|
|
68
|
+
const modelStatsMap = buildModelStats(weekAllData, allData);
|
|
70
69
|
const topModels = Array.from(modelStatsMap.values())
|
|
71
70
|
.sort((a, b) => b.spend30d - a.spend30d)
|
|
72
71
|
.slice(0, 10);
|
|
@@ -100,7 +100,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
100
100
|
|
|
101
101
|
if (cache?.models.length) {
|
|
102
102
|
try {
|
|
103
|
-
const { configs } = mapOpenRouterModels(cache.models);
|
|
103
|
+
const { configs } = await mapOpenRouterModels(cache.models);
|
|
104
104
|
|
|
105
105
|
// Register models directly with Pi's OpenRouter provider
|
|
106
106
|
pi.registerProvider('openrouter', {
|
|
@@ -4,9 +4,9 @@ import { createValidModel } from '../../__tests__/fixtures.js';
|
|
|
4
4
|
import type { OpenRouterModel, MapResult } from '../types.js';
|
|
5
5
|
|
|
6
6
|
describe('mapOpenRouterModel', () => {
|
|
7
|
-
it('should map a valid model correctly', () => {
|
|
7
|
+
it('should map a valid model correctly', async () => {
|
|
8
8
|
const model = createValidModel();
|
|
9
|
-
const result = mapOpenRouterModel(model);
|
|
9
|
+
const result = await mapOpenRouterModel(model);
|
|
10
10
|
|
|
11
11
|
expect(result).not.toBeNull();
|
|
12
12
|
expect(result!.id).toBe('test/model');
|
|
@@ -17,11 +17,11 @@ describe('mapOpenRouterModel', () => {
|
|
|
17
17
|
expect(result!.cost.output).toBe(1.5); // 0.0000015 * 1M
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
-
it('should use name fallback to id when name missing', () => {
|
|
20
|
+
it('should use name fallback to id when name missing', async () => {
|
|
21
21
|
const model = createValidModel();
|
|
22
22
|
// @ts-expect-error: intentionally setting name to undefined for test
|
|
23
23
|
model.name = undefined;
|
|
24
|
-
const result = mapOpenRouterModel(model);
|
|
24
|
+
const result = await mapOpenRouterModel(model);
|
|
25
25
|
|
|
26
26
|
expect(result!.name).toBe('test/model');
|
|
27
27
|
});
|
|
@@ -46,78 +46,78 @@ describe('mapOpenRouterModel', () => {
|
|
|
46
46
|
];
|
|
47
47
|
|
|
48
48
|
skipCases.forEach(({ name, overrides }) => {
|
|
49
|
-
it(`should skip model with ${name}`, () => {
|
|
49
|
+
it(`should skip model with ${name}`, async () => {
|
|
50
50
|
const model = createValidModel();
|
|
51
51
|
if (typeof overrides === 'function') {
|
|
52
52
|
overrides(model);
|
|
53
53
|
} else {
|
|
54
54
|
Object.assign(model, overrides);
|
|
55
55
|
}
|
|
56
|
-
expect(mapOpenRouterModel(model)).toBeNull();
|
|
56
|
+
expect(await mapOpenRouterModel(model)).toBeNull();
|
|
57
57
|
});
|
|
58
58
|
});
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
describe('reasoning detection', () => {
|
|
62
|
-
it("should detect reasoning from 'reasoning' parameter", () => {
|
|
62
|
+
it("should detect reasoning from 'reasoning' parameter", async () => {
|
|
63
63
|
const model = createValidModel({
|
|
64
64
|
supported_parameters: ['temperature', 'reasoning', 'max_tokens'],
|
|
65
65
|
});
|
|
66
|
-
expect(mapOpenRouterModel(model)!.reasoning).toBe(true);
|
|
66
|
+
expect((await mapOpenRouterModel(model))!.reasoning).toBe(true);
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
it("should detect reasoning from 'include_reasoning' parameter", () => {
|
|
69
|
+
it("should detect reasoning from 'include_reasoning' parameter", async () => {
|
|
70
70
|
const model = createValidModel({
|
|
71
71
|
supported_parameters: ['include_reasoning'],
|
|
72
72
|
});
|
|
73
|
-
expect(mapOpenRouterModel(model)!.reasoning).toBe(true);
|
|
73
|
+
expect((await mapOpenRouterModel(model))!.reasoning).toBe(true);
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
it('should not detect reasoning when neither parameter present', () => {
|
|
76
|
+
it('should not detect reasoning when neither parameter present', async () => {
|
|
77
77
|
const model = createValidModel({
|
|
78
78
|
supported_parameters: ['temperature', 'max_tokens'],
|
|
79
79
|
});
|
|
80
|
-
expect(mapOpenRouterModel(model)!.reasoning).toBe(false);
|
|
80
|
+
expect((await mapOpenRouterModel(model))!.reasoning).toBe(false);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
it('should handle missing supported_parameters', () => {
|
|
83
|
+
it('should handle missing supported_parameters', async () => {
|
|
84
84
|
const model = createValidModel({ supported_parameters: [] as any });
|
|
85
|
-
expect(mapOpenRouterModel(model)!.reasoning).toBe(false);
|
|
85
|
+
expect((await mapOpenRouterModel(model))!.reasoning).toBe(false);
|
|
86
86
|
});
|
|
87
87
|
});
|
|
88
88
|
|
|
89
89
|
describe('input modality detection', () => {
|
|
90
|
-
it('should detect image support from input_modalities', () => {
|
|
90
|
+
it('should detect image support from input_modalities', async () => {
|
|
91
91
|
const model = createValidModel({
|
|
92
92
|
architecture: { input_modalities: ['text', 'image'] } as any,
|
|
93
93
|
});
|
|
94
|
-
expect(mapOpenRouterModel(model)!.input).toEqual(['text', 'image']);
|
|
94
|
+
expect((await mapOpenRouterModel(model))!.input).toEqual(['text', 'image']);
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
-
it('should default to text-only without image in modalities', () => {
|
|
97
|
+
it('should default to text-only without image in modalities', async () => {
|
|
98
98
|
const model = createValidModel({
|
|
99
99
|
architecture: { input_modalities: ['text'] } as any,
|
|
100
100
|
});
|
|
101
|
-
expect(mapOpenRouterModel(model)!.input).toEqual(['text']);
|
|
101
|
+
expect((await mapOpenRouterModel(model))!.input).toEqual(['text']);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
-
it('should default to text-only when architecture missing', () => {
|
|
104
|
+
it('should default to text-only when architecture missing', async () => {
|
|
105
105
|
const model = createValidModel({ architecture: null as any });
|
|
106
|
-
expect(mapOpenRouterModel(model)!.input).toEqual(['text']);
|
|
106
|
+
expect((await mapOpenRouterModel(model))!.input).toEqual(['text']);
|
|
107
107
|
});
|
|
108
108
|
});
|
|
109
109
|
|
|
110
110
|
describe('cost calculation', () => {
|
|
111
|
-
it('should convert per-token to per-1M-tokens', () => {
|
|
111
|
+
it('should convert per-token to per-1M-tokens', async () => {
|
|
112
112
|
const model = createValidModel({
|
|
113
113
|
pricing: { prompt: '0.000002', completion: '0.000006' },
|
|
114
114
|
});
|
|
115
|
-
const result = mapOpenRouterModel(model);
|
|
115
|
+
const result = await mapOpenRouterModel(model);
|
|
116
116
|
expect(result!.cost.input).toBe(2.0); // 0.000002 * 1,000,000
|
|
117
117
|
expect(result!.cost.output).toBe(6.0); // 0.000006 * 1,000,000
|
|
118
118
|
});
|
|
119
119
|
|
|
120
|
-
it('should handle cache pricing when present', () => {
|
|
120
|
+
it('should handle cache pricing when present', async () => {
|
|
121
121
|
const model = createValidModel({
|
|
122
122
|
pricing: {
|
|
123
123
|
prompt: '0.000001',
|
|
@@ -126,71 +126,90 @@ describe('mapOpenRouterModel', () => {
|
|
|
126
126
|
input_cache_write: '0.000001',
|
|
127
127
|
},
|
|
128
128
|
});
|
|
129
|
-
const result = mapOpenRouterModel(model);
|
|
129
|
+
const result = await mapOpenRouterModel(model);
|
|
130
130
|
expect(result!.cost.cacheRead).toBe(0.5);
|
|
131
131
|
expect(result!.cost.cacheWrite).toBe(1.0);
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
-
it('should default cache pricing to 0 when missing', () => {
|
|
134
|
+
it('should default cache pricing to 0 when missing', async () => {
|
|
135
135
|
const model = createValidModel({
|
|
136
136
|
pricing: { prompt: '0.000001', completion: '0.000003' },
|
|
137
137
|
});
|
|
138
|
-
const result = mapOpenRouterModel(model);
|
|
138
|
+
const result = await mapOpenRouterModel(model);
|
|
139
139
|
expect(result!.cost.cacheRead).toBe(0);
|
|
140
140
|
expect(result!.cost.cacheWrite).toBe(0);
|
|
141
141
|
});
|
|
142
142
|
});
|
|
143
143
|
|
|
144
144
|
describe('contextWindow fallback', () => {
|
|
145
|
-
it('should prefer top_provider.context_length', () => {
|
|
145
|
+
it('should prefer top_provider.context_length', async () => {
|
|
146
146
|
const model = createValidModel({
|
|
147
147
|
context_length: 8000,
|
|
148
148
|
top_provider: { context_length: 128000 },
|
|
149
149
|
});
|
|
150
|
-
expect(mapOpenRouterModel(model)!.contextWindow).toBe(128000);
|
|
150
|
+
expect((await mapOpenRouterModel(model))!.contextWindow).toBe(128000);
|
|
151
151
|
});
|
|
152
152
|
|
|
153
|
-
it('should fall back to context_length', () => {
|
|
153
|
+
it('should fall back to context_length', async () => {
|
|
154
154
|
const model = createValidModel({
|
|
155
155
|
context_length: 32000,
|
|
156
156
|
top_provider: null as any,
|
|
157
157
|
});
|
|
158
|
-
expect(mapOpenRouterModel(model)!.contextWindow).toBe(32000);
|
|
158
|
+
expect((await mapOpenRouterModel(model))!.contextWindow).toBe(32000);
|
|
159
159
|
});
|
|
160
160
|
});
|
|
161
161
|
|
|
162
162
|
describe('maxTokens fallback', () => {
|
|
163
|
-
it('should prefer top_provider.max_completion_tokens', () => {
|
|
163
|
+
it('should prefer top_provider.max_completion_tokens', async () => {
|
|
164
164
|
const model = createValidModel({
|
|
165
165
|
top_provider: { max_completion_tokens: 8192 },
|
|
166
166
|
per_request_limits: { completion_tokens: 4096 },
|
|
167
167
|
});
|
|
168
|
-
expect(mapOpenRouterModel(model)!.maxTokens).toBe(8192);
|
|
168
|
+
expect((await mapOpenRouterModel(model))!.maxTokens).toBe(8192);
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
-
it('should fall back to per_request_limits.completion_tokens', () => {
|
|
171
|
+
it('should fall back to per_request_limits.completion_tokens', async () => {
|
|
172
172
|
const model = createValidModel({
|
|
173
173
|
per_request_limits: { completion_tokens: 8192 },
|
|
174
174
|
});
|
|
175
|
-
expect(mapOpenRouterModel(model)!.maxTokens).toBe(8192);
|
|
175
|
+
expect((await mapOpenRouterModel(model))!.maxTokens).toBe(8192);
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
-
it('should use default when neither present', () => {
|
|
178
|
+
it('should use default when neither present', async () => {
|
|
179
179
|
const model = createValidModel();
|
|
180
|
-
expect(mapOpenRouterModel(model)!.maxTokens).toBe(4096);
|
|
180
|
+
expect((await mapOpenRouterModel(model))!.maxTokens).toBe(4096);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('thinkingLevelMap', () => {
|
|
185
|
+
it('should include thinkingLevelMap when available from built-in registry', async () => {
|
|
186
|
+
// This test documents the expected behavior when the model exists
|
|
187
|
+
// in Pi's built-in registry. The actual lookup happens via dynamic import
|
|
188
|
+
// of @mariozechner/pi-ai or @earendil-works/pi-ai at runtime.
|
|
189
|
+
const model = createValidModel({
|
|
190
|
+
id: 'deepseek/deepseek-v4-pro',
|
|
191
|
+
supported_parameters: ['reasoning'],
|
|
192
|
+
});
|
|
193
|
+
const result = await mapOpenRouterModel(model);
|
|
194
|
+
expect(result).not.toBeNull();
|
|
195
|
+
// If pi-ai is available and has this model, thinkingLevelMap will be set
|
|
196
|
+
// Otherwise it will be undefined (which is valid)
|
|
197
|
+
expect(
|
|
198
|
+
result!.thinkingLevelMap === undefined || typeof result!.thinkingLevelMap === 'object',
|
|
199
|
+
).toBe(true);
|
|
181
200
|
});
|
|
182
201
|
});
|
|
183
202
|
});
|
|
184
203
|
|
|
185
204
|
describe('mapOpenRouterModels', () => {
|
|
186
|
-
it('should map multiple models and track skips', () => {
|
|
205
|
+
it('should map multiple models and track skips', async () => {
|
|
187
206
|
const models: OpenRouterModel[] = [
|
|
188
207
|
createValidModel({ id: 'model/valid-1', name: 'Valid 1' }),
|
|
189
208
|
createValidModel({ id: '', name: 'Invalid (no id)' }), // will skip
|
|
190
209
|
createValidModel({ id: 'model/valid-2', name: 'Valid 2' }),
|
|
191
210
|
];
|
|
192
211
|
|
|
193
|
-
const result: MapResult = mapOpenRouterModels(models);
|
|
212
|
+
const result: MapResult = await mapOpenRouterModels(models);
|
|
194
213
|
|
|
195
214
|
expect(result.configs).toHaveLength(2);
|
|
196
215
|
expect(result.skipped).toBe(1);
|
|
@@ -198,13 +217,13 @@ describe('mapOpenRouterModels', () => {
|
|
|
198
217
|
expect(result.configs[1]!.id).toBe('model/valid-2');
|
|
199
218
|
});
|
|
200
219
|
|
|
201
|
-
it('should handle empty array', () => {
|
|
202
|
-
const result = mapOpenRouterModels([] as OpenRouterModel[]);
|
|
220
|
+
it('should handle empty array', async () => {
|
|
221
|
+
const result = await mapOpenRouterModels([] as OpenRouterModel[]);
|
|
203
222
|
expect(result.configs).toHaveLength(0);
|
|
204
223
|
expect(result.skipped).toBe(0);
|
|
205
224
|
});
|
|
206
225
|
|
|
207
|
-
it('should skip all invalid models', () => {
|
|
226
|
+
it('should skip all invalid models', async () => {
|
|
208
227
|
const models: OpenRouterModel[] = [
|
|
209
228
|
createValidModel({ id: '' }),
|
|
210
229
|
createValidModel({ pricing: { prompt: '0.000001' } as any }),
|
|
@@ -214,7 +233,7 @@ describe('mapOpenRouterModels', () => {
|
|
|
214
233
|
}),
|
|
215
234
|
];
|
|
216
235
|
|
|
217
|
-
const result = mapOpenRouterModels(models);
|
|
236
|
+
const result = await mapOpenRouterModels(models);
|
|
218
237
|
expect(result.configs).toHaveLength(0);
|
|
219
238
|
expect(result.skipped).toBe(3);
|
|
220
239
|
});
|
|
@@ -2,6 +2,58 @@ import type { OpenRouterModel, PiModelConfig, SkipReason, MapResult } from './ty
|
|
|
2
2
|
import { ROUTER_ALIASES } from './types.js';
|
|
3
3
|
import type { Model as SDKModel } from '@openrouter/sdk/models/index.js';
|
|
4
4
|
|
|
5
|
+
// Cache for built-in OpenRouter models from pi-ai
|
|
6
|
+
// Populated lazily on first access
|
|
7
|
+
let builtInOpenRouterModels: Map<string, PiModelConfig> | undefined;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Load built-in OpenRouter models from pi-ai package if available.
|
|
11
|
+
* This allows us to preserve thinkingLevelMap and other metadata from
|
|
12
|
+
* Pi's built-in registry when syncing models from OpenRouter API.
|
|
13
|
+
*/
|
|
14
|
+
async function loadBuiltInOpenRouterModels(): Promise<Map<string, PiModelConfig>> {
|
|
15
|
+
if (builtInOpenRouterModels !== undefined) {
|
|
16
|
+
return builtInOpenRouterModels;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const models = new Map<string, PiModelConfig>();
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
// Import from pi-ai to get built-in model registry
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
|
+
const { getModels } = (await import('@earendil-works/pi-ai')) as {
|
|
25
|
+
getModels: (provider: string) => unknown[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const openrouterModels = getModels('openrouter');
|
|
29
|
+
if (Array.isArray(openrouterModels)) {
|
|
30
|
+
for (const model of openrouterModels) {
|
|
31
|
+
// Extract thinkingLevelMap from built-in model if present
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
33
|
+
const modelWithThinking = model as { id: string; thinkingLevelMap?: unknown };
|
|
34
|
+
if (modelWithThinking.id) {
|
|
35
|
+
models.set(modelWithThinking.id, model as PiModelConfig);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// Ignore - built-in registry not available, will sync without merging
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
builtInOpenRouterModels = models;
|
|
44
|
+
return models;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get thinkingLevelMap from built-in registry for a model, if available.
|
|
49
|
+
*/
|
|
50
|
+
async function getBuiltInThinkingLevelMap(
|
|
51
|
+
modelId: string,
|
|
52
|
+
): Promise<PiModelConfig['thinkingLevelMap'] | undefined> {
|
|
53
|
+
const builtIn = await loadBuiltInOpenRouterModels();
|
|
54
|
+
return builtIn.get(modelId)?.thinkingLevelMap;
|
|
55
|
+
}
|
|
56
|
+
|
|
5
57
|
const COST_PER_MILLION = 1_000_000;
|
|
6
58
|
const DEFAULT_MAX_TOKENS = 4096;
|
|
7
59
|
|
|
@@ -103,15 +155,22 @@ function validateModel(model: OpenRouterModel): ValidationResult {
|
|
|
103
155
|
|
|
104
156
|
/**
|
|
105
157
|
* Build PiModelConfig from a validated OpenRouterModel.
|
|
158
|
+
* Merges thinkingLevelMap from Pi's built-in registry if available.
|
|
106
159
|
*/
|
|
107
|
-
function buildPiConfig(
|
|
160
|
+
async function buildPiConfig(
|
|
161
|
+
model: OpenRouterModel,
|
|
162
|
+
contextWindow: number,
|
|
163
|
+
): Promise<PiModelConfig> {
|
|
108
164
|
const supportedParams = model.supported_parameters ?? [];
|
|
109
165
|
const hasReasoning =
|
|
110
166
|
supportedParams.includes('reasoning') || supportedParams.includes('include_reasoning');
|
|
111
167
|
const inputModalities = model.architecture?.input_modalities;
|
|
112
168
|
const supportsImages = inputModalities?.includes('image') ?? false;
|
|
113
169
|
|
|
114
|
-
|
|
170
|
+
// Fetch thinkingLevelMap from built-in registry if this is a reasoning model
|
|
171
|
+
const thinkingLevelMap = hasReasoning ? await getBuiltInThinkingLevelMap(model.id) : undefined;
|
|
172
|
+
|
|
173
|
+
const config: PiModelConfig = {
|
|
115
174
|
id: model.id,
|
|
116
175
|
name: model.name ?? model.id,
|
|
117
176
|
reasoning: hasReasoning,
|
|
@@ -128,12 +187,25 @@ function buildPiConfig(model: OpenRouterModel, contextWindow: number): PiModelCo
|
|
|
128
187
|
model.per_request_limits?.completion_tokens ??
|
|
129
188
|
DEFAULT_MAX_TOKENS,
|
|
130
189
|
};
|
|
190
|
+
|
|
191
|
+
// Only add thinkingLevelMap if it's defined for exactOptionalPropertyTypes compatibility
|
|
192
|
+
if (thinkingLevelMap !== undefined) {
|
|
193
|
+
config.thinkingLevelMap = thinkingLevelMap;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return config;
|
|
131
197
|
}
|
|
132
198
|
|
|
133
199
|
/**
|
|
134
200
|
* Maps multiple OpenRouter models, tracking skips.
|
|
201
|
+
* Async to allow fetching thinkingLevelMap from built-in registry.
|
|
135
202
|
*/
|
|
136
|
-
export function mapOpenRouterModels(
|
|
203
|
+
export async function mapOpenRouterModels(
|
|
204
|
+
models: OpenRouterModel[] | SDKModel[],
|
|
205
|
+
): Promise<MapResult> {
|
|
206
|
+
// Pre-load built-in models for efficient lookup during mapping
|
|
207
|
+
await loadBuiltInOpenRouterModels();
|
|
208
|
+
|
|
137
209
|
const configs: PiModelConfig[] = [];
|
|
138
210
|
let skipped = 0;
|
|
139
211
|
const skippedDetails: SkipReason[] = [];
|
|
@@ -154,7 +226,7 @@ export function mapOpenRouterModels(models: OpenRouterModel[] | SDKModel[]): Map
|
|
|
154
226
|
continue;
|
|
155
227
|
}
|
|
156
228
|
|
|
157
|
-
configs.push(buildPiConfig(model, validation.contextWindow));
|
|
229
|
+
configs.push(await buildPiConfig(model, validation.contextWindow));
|
|
158
230
|
}
|
|
159
231
|
|
|
160
232
|
return { configs, skipped, skippedDetails };
|
|
@@ -163,8 +235,13 @@ export function mapOpenRouterModels(models: OpenRouterModel[] | SDKModel[]): Map
|
|
|
163
235
|
/**
|
|
164
236
|
* Maps a single OpenRouter model to Pi model config.
|
|
165
237
|
* Returns null if the model should be skipped.
|
|
238
|
+
* Async to allow fetching thinkingLevelMap from built-in registry.
|
|
166
239
|
*/
|
|
167
|
-
export function mapOpenRouterModel(
|
|
240
|
+
export async function mapOpenRouterModel(
|
|
241
|
+
model: OpenRouterModel | SDKModel,
|
|
242
|
+
): Promise<PiModelConfig | null> {
|
|
243
|
+
await loadBuiltInOpenRouterModels();
|
|
244
|
+
|
|
168
245
|
const normalized = normalizeModel(model);
|
|
169
246
|
|
|
170
247
|
// Router aliases are handled separately, skip them here
|
|
@@ -132,7 +132,7 @@ export async function syncModels(_ctx: ExtensionContext): Promise<SyncResult> {
|
|
|
132
132
|
// Attempt 1: Fetch from API
|
|
133
133
|
try {
|
|
134
134
|
const response = await fetchUserModels();
|
|
135
|
-
const { configs, skipped, skippedDetails } = mapOpenRouterModels(response.data);
|
|
135
|
+
const { configs, skipped, skippedDetails } = await mapOpenRouterModels(response.data);
|
|
136
136
|
|
|
137
137
|
// Add built-in router aliases that don't appear in /models/user endpoint
|
|
138
138
|
const configsWithRouters = [...configs, ...BUILTIN_ROUTER_MODELS];
|
|
@@ -172,7 +172,7 @@ export async function syncModels(_ctx: ExtensionContext): Promise<SyncResult> {
|
|
|
172
172
|
|
|
173
173
|
if (cache) {
|
|
174
174
|
// Attempt 2: Use cached models
|
|
175
|
-
const { configs, skipped } = mapOpenRouterModels(cache.models);
|
|
175
|
+
const { configs, skipped } = await mapOpenRouterModels(cache.models);
|
|
176
176
|
|
|
177
177
|
await registerModelsWithProvider(_ctx, configs);
|
|
178
178
|
|
|
@@ -32,6 +32,20 @@ export interface OpenRouterModelsResponse {
|
|
|
32
32
|
data: OpenRouterModel[];
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Pi thinking level map - copied from pi-ai's Model type.
|
|
37
|
+
* Levels mapped to null are hidden in Pi's UI.
|
|
38
|
+
* Levels mapped to strings are sent to the provider API.
|
|
39
|
+
*/
|
|
40
|
+
export interface ThinkingLevelMap {
|
|
41
|
+
off?: string | null;
|
|
42
|
+
minimal?: string | null;
|
|
43
|
+
low?: string | null;
|
|
44
|
+
medium?: string | null;
|
|
45
|
+
high?: string | null;
|
|
46
|
+
xhigh?: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
/**
|
|
36
50
|
* Mapped Pi model configuration for provider registration
|
|
37
51
|
*/
|
|
@@ -48,6 +62,7 @@ export interface PiModelConfig {
|
|
|
48
62
|
};
|
|
49
63
|
contextWindow: number;
|
|
50
64
|
maxTokens: number;
|
|
65
|
+
thinkingLevelMap?: ThinkingLevelMap;
|
|
51
66
|
}
|
|
52
67
|
|
|
53
68
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robhowley/pi-openrouter",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Live OpenRouter spend/account TUI overlays, user-scoped model sync, and session tagging for Pi.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,10 +40,13 @@
|
|
|
40
40
|
"@openrouter/sdk": "^0.12.21"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"@
|
|
44
|
-
"@
|
|
43
|
+
"@earendil-works/pi-ai": "*",
|
|
44
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
+
"@earendil-works/pi-ai": "*",
|
|
48
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
49
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
47
50
|
"@types/node": "^22.15.17"
|
|
48
51
|
}
|
|
49
52
|
}
|