@robhowley/pi-openrouter 0.14.1 → 0.14.2
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 +4 -3
- package/extensions/openrouter/__tests__/normalizers.test.ts +70 -0
- package/extensions/openrouter/models/__tests__/cache.test.ts +14 -3
- package/extensions/openrouter/models/__tests__/mapper-overrides.test.ts +164 -10
- package/extensions/openrouter/models/__tests__/override-commands.test.ts +14 -2
- package/extensions/openrouter/models/mapper.ts +43 -7
- package/extensions/openrouter/models/override-commands.ts +2 -0
- package/extensions/openrouter/models/types.ts +5 -0
- package/extensions/openrouter/normalizers.ts +10 -0
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -200,10 +200,10 @@ Some OpenRouter models don't have complete metadata in Pi's built-in registry or
|
|
|
200
200
|
|
|
201
201
|
```bash
|
|
202
202
|
# Override thinking levels for DeepSeek V4 Pro
|
|
203
|
-
/openrouter model-override-set deepseek/deepseek-v4-pro thinking.high=high thinking.
|
|
203
|
+
/openrouter model-override-set deepseek/deepseek-v4-pro thinking.high=high thinking.max=max
|
|
204
204
|
|
|
205
205
|
# Same thing with exact field names
|
|
206
|
-
/openrouter model-override-set deepseek/deepseek-v4-pro thinkingLevelMap.high=high thinkingLevelMap.
|
|
206
|
+
/openrouter model-override-set deepseek/deepseek-v4-pro thinkingLevelMap.high=high thinkingLevelMap.max=max
|
|
207
207
|
|
|
208
208
|
# Override context window or max tokens
|
|
209
209
|
/openrouter model-override-set custom/model contextWindow=128000 maxTokens=8192
|
|
@@ -211,7 +211,8 @@ Some OpenRouter models don't have complete metadata in Pi's built-in registry or
|
|
|
211
211
|
|
|
212
212
|
**Scoped field names:**
|
|
213
213
|
|
|
214
|
-
- `thinking.off`, `thinking.minimal`, `thinking.low`, `thinking.medium`, `thinking.high`, `thinking.xhigh` → map to `thinkingLevelMap.*`
|
|
214
|
+
- `thinking.off`, `thinking.minimal`, `thinking.low`, `thinking.medium`, `thinking.high`, `thinking.xhigh`, `thinking.max` → map to `thinkingLevelMap.*`
|
|
215
|
+
- `thinkingLevelMap.off`, `thinkingLevelMap.minimal`, `thinkingLevelMap.low`, `thinkingLevelMap.medium`, `thinkingLevelMap.high`, `thinkingLevelMap.xhigh`, `thinkingLevelMap.max` → map to `thinkingLevelMap.*`
|
|
215
216
|
- `contextWindow` → `contextWindow` (number)
|
|
216
217
|
- `maxTokens` → `maxTokens` (number)
|
|
217
218
|
- `reasoning` → `reasoning` (boolean)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import type { Model as SDKModel } from '@openrouter/sdk/models/index.js';
|
|
3
|
+
import { modelFromJSON } from '@openrouter/sdk/models/model.js';
|
|
3
4
|
import type {
|
|
4
5
|
CreateKeysData,
|
|
5
6
|
GetCurrentKeyData,
|
|
@@ -33,6 +34,7 @@ function createSdkModel(overrides: Partial<SDKModel> = {}): SDKModel {
|
|
|
33
34
|
completion: '0.0000015',
|
|
34
35
|
},
|
|
35
36
|
supportedParameters: [],
|
|
37
|
+
supportedVoices: null,
|
|
36
38
|
topProvider: {
|
|
37
39
|
isModerated: false,
|
|
38
40
|
},
|
|
@@ -146,6 +148,37 @@ function createUpdateKeysData(overrides: Partial<UpdateKeysData> = {}): UpdateKe
|
|
|
146
148
|
};
|
|
147
149
|
}
|
|
148
150
|
|
|
151
|
+
function createSdkModelJson(supportedEfforts?: ReadonlyArray<string | null> | null): string {
|
|
152
|
+
return JSON.stringify({
|
|
153
|
+
architecture: {
|
|
154
|
+
input_modalities: ['text'],
|
|
155
|
+
modality: 'text',
|
|
156
|
+
output_modalities: ['text'],
|
|
157
|
+
},
|
|
158
|
+
canonical_slug: 'test/model',
|
|
159
|
+
context_length: 128000,
|
|
160
|
+
created: 0,
|
|
161
|
+
default_parameters: null,
|
|
162
|
+
id: 'test/model',
|
|
163
|
+
links: { details: 'https://openrouter.ai/test/model' },
|
|
164
|
+
name: 'Test Model',
|
|
165
|
+
per_request_limits: null,
|
|
166
|
+
pricing: {
|
|
167
|
+
prompt: '0.0000005',
|
|
168
|
+
completion: '0.0000015',
|
|
169
|
+
},
|
|
170
|
+
reasoning: {
|
|
171
|
+
mandatory: false,
|
|
172
|
+
...(supportedEfforts === undefined ? {} : { supported_efforts: supportedEfforts }),
|
|
173
|
+
},
|
|
174
|
+
supported_parameters: ['reasoning'],
|
|
175
|
+
supported_voices: null,
|
|
176
|
+
top_provider: {
|
|
177
|
+
is_moderated: false,
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
149
182
|
describe('sdkModelToOpenRouterModel', () => {
|
|
150
183
|
it('normalizes SDK camelCase fields into canonical snake_case model shape', () => {
|
|
151
184
|
const normalized = sdkModelToOpenRouterModel(
|
|
@@ -197,6 +230,43 @@ describe('sdkModelToOpenRouterModel', () => {
|
|
|
197
230
|
});
|
|
198
231
|
});
|
|
199
232
|
|
|
233
|
+
it('parses snake_case reasoning metadata through the SDK before normalizing it', () => {
|
|
234
|
+
const parsed = modelFromJSON(createSdkModelJson(['low', null, 'future-effort']));
|
|
235
|
+
|
|
236
|
+
expect(parsed.ok).toBe(true);
|
|
237
|
+
if (!parsed.ok) return;
|
|
238
|
+
|
|
239
|
+
const normalized = sdkModelToOpenRouterModel(parsed.value);
|
|
240
|
+
expect(normalized.reasoning).toEqual({
|
|
241
|
+
mandatory: false,
|
|
242
|
+
supported_efforts: ['low', null, 'future-effort'],
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it.each([
|
|
247
|
+
['omitted', undefined],
|
|
248
|
+
['null', null],
|
|
249
|
+
['empty', []],
|
|
250
|
+
['nullable element', [null]],
|
|
251
|
+
['unknown string', ['future-effort']],
|
|
252
|
+
['mixed values', ['low', null, 'future-effort']],
|
|
253
|
+
] as const)('preserves %s supported_efforts during SDK normalization', (_name, efforts) => {
|
|
254
|
+
const parsed = modelFromJSON(createSdkModelJson(efforts));
|
|
255
|
+
|
|
256
|
+
expect(parsed.ok).toBe(true);
|
|
257
|
+
if (!parsed.ok) return;
|
|
258
|
+
|
|
259
|
+
const normalized = sdkModelToOpenRouterModel(parsed.value);
|
|
260
|
+
if (efforts === undefined) {
|
|
261
|
+
expect(normalized.reasoning).toEqual({ mandatory: false });
|
|
262
|
+
} else {
|
|
263
|
+
expect(normalized.reasoning).toEqual({
|
|
264
|
+
mandatory: false,
|
|
265
|
+
supported_efforts: efforts,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
200
270
|
it('omits optional provider/request blocks when the SDK omits them', () => {
|
|
201
271
|
const normalized = sdkModelToOpenRouterModel(
|
|
202
272
|
createSdkModel({
|
|
@@ -45,15 +45,26 @@ describe('loadCache', () => {
|
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
it('should return parsed cache when file exists and is valid', async () => {
|
|
48
|
-
const mockCache = createMockCache({
|
|
48
|
+
const mockCache = createMockCache({
|
|
49
|
+
catalogMode: 'free-only',
|
|
50
|
+
timestamp: 1234567890,
|
|
51
|
+
models: [
|
|
52
|
+
{
|
|
53
|
+
...createMockCache().models[0]!,
|
|
54
|
+
reasoning: {
|
|
55
|
+
mandatory: false,
|
|
56
|
+
supported_efforts: ['low', null, 'future-effort'],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
});
|
|
49
61
|
await saveCache(mockCache);
|
|
50
62
|
|
|
51
63
|
const result = await loadCache();
|
|
52
64
|
expect(result).not.toBeNull();
|
|
53
65
|
expect(result!.catalogMode).toBe('free-only');
|
|
54
66
|
expect(result!.timestamp).toBe(1234567890);
|
|
55
|
-
expect(result!.models).
|
|
56
|
-
expect(result!.models[0]!.id).toBe('test/model');
|
|
67
|
+
expect(result!.models).toEqual(mockCache.models);
|
|
57
68
|
});
|
|
58
69
|
|
|
59
70
|
it('should return null when cache file contains invalid JSON', async () => {
|
|
@@ -12,14 +12,15 @@ vi.mock('../overrides.js', () => ({
|
|
|
12
12
|
overrides.overrides[modelId],
|
|
13
13
|
}));
|
|
14
14
|
|
|
15
|
-
vi.mock('@earendil-works/pi-ai', () => ({
|
|
16
|
-
|
|
15
|
+
vi.mock('@earendil-works/pi-ai/providers/all', () => ({
|
|
16
|
+
getBuiltinModels: vi.fn(() => [
|
|
17
17
|
{
|
|
18
18
|
id: 'test/model',
|
|
19
19
|
thinkingLevelMap: {
|
|
20
20
|
minimal: 'builtin-minimal',
|
|
21
21
|
high: 'builtin-high',
|
|
22
22
|
xhigh: 'builtin-xhigh',
|
|
23
|
+
max: 'builtin-max',
|
|
23
24
|
},
|
|
24
25
|
},
|
|
25
26
|
]),
|
|
@@ -43,6 +44,7 @@ describe('mapOpenRouterModels overrides', () => {
|
|
|
43
44
|
thinkingLevelMap: {
|
|
44
45
|
high: 'override-high',
|
|
45
46
|
xhigh: null,
|
|
47
|
+
max: null,
|
|
46
48
|
},
|
|
47
49
|
},
|
|
48
50
|
},
|
|
@@ -52,6 +54,10 @@ describe('mapOpenRouterModels overrides', () => {
|
|
|
52
54
|
createValidModel({
|
|
53
55
|
id: 'test/model',
|
|
54
56
|
supported_parameters: ['reasoning'],
|
|
57
|
+
reasoning: {
|
|
58
|
+
mandatory: false,
|
|
59
|
+
supported_efforts: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'],
|
|
60
|
+
},
|
|
55
61
|
}),
|
|
56
62
|
]);
|
|
57
63
|
|
|
@@ -61,21 +67,163 @@ describe('mapOpenRouterModels overrides', () => {
|
|
|
61
67
|
contextWindow: 64000,
|
|
62
68
|
maxTokens: 8192,
|
|
63
69
|
reasoning: false,
|
|
70
|
+
});
|
|
71
|
+
expect(result.configs[0]?.thinkingLevelMap).toEqual({
|
|
72
|
+
minimal: 'builtin-minimal',
|
|
73
|
+
high: 'override-high',
|
|
74
|
+
xhigh: null,
|
|
75
|
+
max: null,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('derives the exact GLM 5.3 effort map from API metadata', async () => {
|
|
80
|
+
const result = await mapOpenRouterModels([
|
|
81
|
+
createValidModel({
|
|
82
|
+
id: 'z-ai/glm-5.3',
|
|
83
|
+
reasoning: { mandatory: true, supported_efforts: ['low', 'high', 'max'] },
|
|
84
|
+
}),
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
expect(result.configs[0]?.thinkingLevelMap).toEqual({
|
|
88
|
+
off: null,
|
|
89
|
+
minimal: null,
|
|
90
|
+
low: 'low',
|
|
91
|
+
medium: null,
|
|
92
|
+
high: 'high',
|
|
93
|
+
xhigh: null,
|
|
94
|
+
max: 'max',
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('derives an API map when an unrelated user override is present', async () => {
|
|
99
|
+
loadModelOverrides.mockResolvedValue({
|
|
100
|
+
version: 1,
|
|
101
|
+
overrides: {
|
|
102
|
+
'api/model': { contextWindow: 64000 },
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const result = await mapOpenRouterModels([
|
|
107
|
+
createValidModel({
|
|
108
|
+
id: 'api/model',
|
|
109
|
+
reasoning: {
|
|
110
|
+
mandatory: false,
|
|
111
|
+
supported_efforts: ['low', null, 'future-effort', 'max', 'none'],
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
expect(result.configs[0]).toMatchObject({
|
|
117
|
+
reasoning: true,
|
|
118
|
+
contextWindow: 64000,
|
|
64
119
|
thinkingLevelMap: {
|
|
65
|
-
|
|
66
|
-
|
|
120
|
+
off: 'none',
|
|
121
|
+
minimal: null,
|
|
122
|
+
low: 'low',
|
|
123
|
+
medium: null,
|
|
124
|
+
high: null,
|
|
67
125
|
xhigh: null,
|
|
126
|
+
max: 'max',
|
|
68
127
|
},
|
|
69
128
|
});
|
|
70
129
|
});
|
|
71
130
|
|
|
72
|
-
it(
|
|
131
|
+
it.each([
|
|
132
|
+
[true, null],
|
|
133
|
+
[false, 'none'],
|
|
134
|
+
] as const)('maps mandatory=%s to off=%s for API maps', async (mandatory, off) => {
|
|
135
|
+
const result = await mapOpenRouterModels([
|
|
136
|
+
createValidModel({
|
|
137
|
+
id: `api/mandatory-${mandatory}`,
|
|
138
|
+
reasoning: { mandatory, supported_efforts: ['high'] },
|
|
139
|
+
}),
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
expect(result.configs[0]?.thinkingLevelMap).toEqual({
|
|
143
|
+
off,
|
|
144
|
+
minimal: null,
|
|
145
|
+
low: null,
|
|
146
|
+
medium: null,
|
|
147
|
+
high: 'high',
|
|
148
|
+
xhigh: null,
|
|
149
|
+
max: null,
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it.each([
|
|
154
|
+
[true, null],
|
|
155
|
+
[false, 'none'],
|
|
156
|
+
] as const)('maps explicit null supported_efforts with mandatory=%s', async (mandatory, off) => {
|
|
157
|
+
const result = await mapOpenRouterModels([
|
|
158
|
+
createValidModel({
|
|
159
|
+
id: `api/unrestricted-${mandatory}`,
|
|
160
|
+
reasoning: { mandatory, supported_efforts: null },
|
|
161
|
+
}),
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
expect(result.configs[0]?.thinkingLevelMap).toEqual({
|
|
165
|
+
off,
|
|
166
|
+
minimal: 'minimal',
|
|
167
|
+
low: 'low',
|
|
168
|
+
medium: 'medium',
|
|
169
|
+
high: 'high',
|
|
170
|
+
xhigh: 'xhigh',
|
|
171
|
+
max: 'max',
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('does not derive an API map from absent or unusable effort metadata', async () => {
|
|
176
|
+
const cases: Array<Array<string | null> | undefined> = [
|
|
177
|
+
undefined,
|
|
178
|
+
[],
|
|
179
|
+
[null],
|
|
180
|
+
['none'],
|
|
181
|
+
['future-effort'],
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
for (const [index, supported_efforts] of cases.entries()) {
|
|
185
|
+
const reasoning =
|
|
186
|
+
supported_efforts === undefined
|
|
187
|
+
? { mandatory: false }
|
|
188
|
+
: { mandatory: false, supported_efforts };
|
|
189
|
+
const result = await mapOpenRouterModels([
|
|
190
|
+
createValidModel({ id: `api/unusable-${index}`, reasoning }),
|
|
191
|
+
]);
|
|
192
|
+
|
|
193
|
+
expect(result.configs[0]?.thinkingLevelMap).toBeUndefined();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('hides off for mandatory reasoning metadata with no usable effort', async () => {
|
|
198
|
+
const result = await mapOpenRouterModels([
|
|
199
|
+
createValidModel({
|
|
200
|
+
id: 'api/mandatory-unknown-effort',
|
|
201
|
+
reasoning: { mandatory: true, supported_efforts: [null, 'none', 'future-effort'] },
|
|
202
|
+
}),
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
expect(result.configs[0]?.thinkingLevelMap).toEqual({ off: null });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('does not derive an API map from supported_parameters alone', async () => {
|
|
209
|
+
const result = await mapOpenRouterModels([
|
|
210
|
+
createValidModel({
|
|
211
|
+
id: 'api/parameter-only',
|
|
212
|
+
supported_parameters: ['reasoning'],
|
|
213
|
+
}),
|
|
214
|
+
]);
|
|
215
|
+
|
|
216
|
+
expect(result.configs[0]?.reasoning).toBe(true);
|
|
217
|
+
expect(result.configs[0]?.thinkingLevelMap).toBeUndefined();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('merges sparse user thinkingLevelMap over the API map', async () => {
|
|
73
221
|
loadModelOverrides.mockResolvedValue({
|
|
74
222
|
version: 1,
|
|
75
223
|
overrides: {
|
|
76
224
|
'new/model': {
|
|
77
225
|
thinkingLevelMap: {
|
|
78
|
-
high: 'high',
|
|
226
|
+
high: 'override-high',
|
|
79
227
|
xhigh: 'max',
|
|
80
228
|
},
|
|
81
229
|
},
|
|
@@ -86,6 +234,7 @@ describe('mapOpenRouterModels overrides', () => {
|
|
|
86
234
|
createValidModel({
|
|
87
235
|
id: 'new/model',
|
|
88
236
|
supported_parameters: ['reasoning'],
|
|
237
|
+
reasoning: { mandatory: true, supported_efforts: ['low', 'high', 'max'] },
|
|
89
238
|
}),
|
|
90
239
|
]);
|
|
91
240
|
|
|
@@ -93,10 +242,15 @@ describe('mapOpenRouterModels overrides', () => {
|
|
|
93
242
|
expect(result.configs[0]).toMatchObject({
|
|
94
243
|
id: 'new/model',
|
|
95
244
|
reasoning: true,
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
245
|
+
});
|
|
246
|
+
expect(result.configs[0]?.thinkingLevelMap).toEqual({
|
|
247
|
+
off: null,
|
|
248
|
+
minimal: null,
|
|
249
|
+
low: 'low',
|
|
250
|
+
medium: null,
|
|
251
|
+
high: 'override-high',
|
|
252
|
+
xhigh: 'max',
|
|
253
|
+
max: 'max',
|
|
100
254
|
});
|
|
101
255
|
});
|
|
102
256
|
});
|
|
@@ -111,6 +111,11 @@ describe('parseScopedAssignment', () => {
|
|
|
111
111
|
fullPath: 'thinkingLevelMap.xhigh',
|
|
112
112
|
value: 'max',
|
|
113
113
|
});
|
|
114
|
+
expect(parseScopedAssignment('thinkingLevelMap.max=max')).toEqual({
|
|
115
|
+
ok: true,
|
|
116
|
+
fullPath: 'thinkingLevelMap.max',
|
|
117
|
+
value: 'max',
|
|
118
|
+
});
|
|
114
119
|
});
|
|
115
120
|
|
|
116
121
|
it('parses null string values for thinking levels', () => {
|
|
@@ -247,6 +252,7 @@ describe('parseScopedAssignment', () => {
|
|
|
247
252
|
['thinking.high=high', 'high'],
|
|
248
253
|
['thinking.xhigh=max', 'max'],
|
|
249
254
|
['thinking.xhigh=xhigh', 'xhigh'],
|
|
255
|
+
['thinking.max=max', 'max'],
|
|
250
256
|
];
|
|
251
257
|
|
|
252
258
|
for (const [input, expectedValue] of validPairs) {
|
|
@@ -402,7 +408,7 @@ describe('handleModelOverrideSet', () => {
|
|
|
402
408
|
const userOverrides = emptyOverrides();
|
|
403
409
|
|
|
404
410
|
const result = await handleModelOverrideSet(
|
|
405
|
-
'test/model thinking.high=high thinking.xhigh=max thinking.off=null contextWindow=64000 maxTokens=8192 reasoning=true',
|
|
411
|
+
'test/model thinking.high=high thinking.xhigh=max thinking.max=max thinking.off=null contextWindow=64000 maxTokens=8192 reasoning=true',
|
|
406
412
|
userOverrides,
|
|
407
413
|
);
|
|
408
414
|
|
|
@@ -415,6 +421,7 @@ describe('handleModelOverrideSet', () => {
|
|
|
415
421
|
thinkingLevelMap: {
|
|
416
422
|
high: 'high',
|
|
417
423
|
xhigh: 'max',
|
|
424
|
+
max: 'max',
|
|
418
425
|
off: null,
|
|
419
426
|
},
|
|
420
427
|
contextWindow: 64000,
|
|
@@ -553,6 +560,8 @@ describe('handleModelOverrideList', () => {
|
|
|
553
560
|
|
|
554
561
|
expect(result).toContain('Available override fields');
|
|
555
562
|
expect(result).toContain('thinking.high');
|
|
563
|
+
expect(result).toContain('thinking.max');
|
|
564
|
+
expect(result).toContain('thinkingLevelMap.max');
|
|
556
565
|
expect(result).toContain('contextWindow');
|
|
557
566
|
});
|
|
558
567
|
|
|
@@ -578,7 +587,7 @@ describe('handleModelOverrideList', () => {
|
|
|
578
587
|
version: 1,
|
|
579
588
|
overrides: {
|
|
580
589
|
'test/model': {
|
|
581
|
-
thinkingLevelMap: { high: 'high', xhigh: 'max', off: null },
|
|
590
|
+
thinkingLevelMap: { high: 'high', xhigh: 'max', max: 'max', off: null },
|
|
582
591
|
contextWindow: 64000,
|
|
583
592
|
},
|
|
584
593
|
},
|
|
@@ -590,6 +599,7 @@ describe('handleModelOverrideList', () => {
|
|
|
590
599
|
expect(result).toContain('thinkingLevelMap:');
|
|
591
600
|
expect(result).toContain('high: high');
|
|
592
601
|
expect(result).toContain('xhigh: max');
|
|
602
|
+
expect(result).toContain('max: max');
|
|
593
603
|
expect(result).toContain('off: null');
|
|
594
604
|
expect(result).toContain('contextWindow: 64000');
|
|
595
605
|
});
|
|
@@ -625,6 +635,7 @@ describe('SCOPED_FIELD_MAP', () => {
|
|
|
625
635
|
'thinking.medium',
|
|
626
636
|
'thinking.high',
|
|
627
637
|
'thinking.xhigh',
|
|
638
|
+
'thinking.max',
|
|
628
639
|
];
|
|
629
640
|
|
|
630
641
|
for (const shorthand of expectedShorthands) {
|
|
@@ -642,6 +653,7 @@ describe('SCOPED_FIELD_MAP', () => {
|
|
|
642
653
|
'thinkingLevelMap.medium',
|
|
643
654
|
'thinkingLevelMap.high',
|
|
644
655
|
'thinkingLevelMap.xhigh',
|
|
656
|
+
'thinkingLevelMap.max',
|
|
645
657
|
];
|
|
646
658
|
|
|
647
659
|
for (const exact of expectedExact) {
|
|
@@ -23,11 +23,9 @@ async function loadBuiltInOpenRouterModels(): Promise<Map<string, PiModelConfig>
|
|
|
23
23
|
|
|
24
24
|
try {
|
|
25
25
|
// Import from pi-ai to get built-in model registry
|
|
26
|
-
const {
|
|
27
|
-
getModels: (provider: string) => unknown[];
|
|
28
|
-
};
|
|
26
|
+
const { getBuiltinModels } = await import('@earendil-works/pi-ai/providers/all');
|
|
29
27
|
|
|
30
|
-
const openrouterModels =
|
|
28
|
+
const openrouterModels = getBuiltinModels('openrouter');
|
|
31
29
|
if (Array.isArray(openrouterModels)) {
|
|
32
30
|
for (const model of openrouterModels) {
|
|
33
31
|
// Extract thinkingLevelMap from built-in model if present
|
|
@@ -57,6 +55,40 @@ async function getBuiltInThinkingLevelMap(
|
|
|
57
55
|
|
|
58
56
|
const COST_PER_MILLION = 1_000_000;
|
|
59
57
|
const DEFAULT_MAX_TOKENS = 4096;
|
|
58
|
+
const API_THINKING_LEVELS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const;
|
|
59
|
+
type ApiThinkingLevel = (typeof API_THINKING_LEVELS)[number];
|
|
60
|
+
|
|
61
|
+
function buildApiThinkingLevelMap(
|
|
62
|
+
reasoning: OpenRouterModel['reasoning'],
|
|
63
|
+
): PiModelConfig['thinkingLevelMap'] {
|
|
64
|
+
if (reasoning === undefined) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const supportedEfforts = new Set(
|
|
69
|
+
reasoning?.supported_efforts === null
|
|
70
|
+
? API_THINKING_LEVELS
|
|
71
|
+
: (reasoning?.supported_efforts?.filter(
|
|
72
|
+
(effort): effort is ApiThinkingLevel =>
|
|
73
|
+
typeof effort === 'string' &&
|
|
74
|
+
(API_THINKING_LEVELS as readonly string[]).includes(effort),
|
|
75
|
+
) ?? []),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
if (supportedEfforts.size === 0) {
|
|
79
|
+
return reasoning.mandatory ? { off: null } : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
off: reasoning.mandatory ? null : 'none',
|
|
84
|
+
minimal: supportedEfforts.has('minimal') ? 'minimal' : null,
|
|
85
|
+
low: supportedEfforts.has('low') ? 'low' : null,
|
|
86
|
+
medium: supportedEfforts.has('medium') ? 'medium' : null,
|
|
87
|
+
high: supportedEfforts.has('high') ? 'high' : null,
|
|
88
|
+
xhigh: supportedEfforts.has('xhigh') ? 'xhigh' : null,
|
|
89
|
+
max: supportedEfforts.has('max') ? 'max' : null,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
60
92
|
|
|
61
93
|
/**
|
|
62
94
|
* Validation result for a model check.
|
|
@@ -123,7 +155,9 @@ async function buildPiConfig(
|
|
|
123
155
|
): Promise<PiModelConfig> {
|
|
124
156
|
const supportedParams = model.supported_parameters ?? [];
|
|
125
157
|
const hasReasoning =
|
|
126
|
-
|
|
158
|
+
model.reasoning !== undefined
|
|
159
|
+
? true
|
|
160
|
+
: supportedParams.includes('reasoning') || supportedParams.includes('include_reasoning');
|
|
127
161
|
const inputModalities = model.architecture?.input_modalities;
|
|
128
162
|
const supportsImages = inputModalities?.includes('image') ?? false;
|
|
129
163
|
|
|
@@ -134,11 +168,13 @@ async function buildPiConfig(
|
|
|
134
168
|
|
|
135
169
|
// Fetch user override for this model
|
|
136
170
|
const userOverride = userOverrides ? getModelOverride(userOverrides, model.id) : undefined;
|
|
171
|
+
const apiThinkingLevelMap = buildApiThinkingLevelMap(model.reasoning);
|
|
137
172
|
|
|
173
|
+
const baseThinkingLevelMap = builtInThinkingLevelMap ?? apiThinkingLevelMap;
|
|
138
174
|
const thinkingLevelMap =
|
|
139
|
-
|
|
175
|
+
baseThinkingLevelMap !== undefined || userOverride?.thinkingLevelMap !== undefined
|
|
140
176
|
? {
|
|
141
|
-
...
|
|
177
|
+
...baseThinkingLevelMap,
|
|
142
178
|
...userOverride?.thinkingLevelMap,
|
|
143
179
|
}
|
|
144
180
|
: undefined;
|
|
@@ -50,6 +50,7 @@ export const SCOPED_FIELD_MAP: Record<string, ScopedField> = {
|
|
|
50
50
|
'thinking.medium': { targetField: 'thinkingLevelMap.medium', targetType: 'string' },
|
|
51
51
|
'thinking.high': { targetField: 'thinkingLevelMap.high', targetType: 'string' },
|
|
52
52
|
'thinking.xhigh': { targetField: 'thinkingLevelMap.xhigh', targetType: 'string' },
|
|
53
|
+
'thinking.max': { targetField: 'thinkingLevelMap.max', targetType: 'string' },
|
|
53
54
|
|
|
54
55
|
// exact field names (passthrough)
|
|
55
56
|
'thinkingLevelMap.off': { targetField: 'thinkingLevelMap.off', targetType: 'string' },
|
|
@@ -58,6 +59,7 @@ export const SCOPED_FIELD_MAP: Record<string, ScopedField> = {
|
|
|
58
59
|
'thinkingLevelMap.medium': { targetField: 'thinkingLevelMap.medium', targetType: 'string' },
|
|
59
60
|
'thinkingLevelMap.high': { targetField: 'thinkingLevelMap.high', targetType: 'string' },
|
|
60
61
|
'thinkingLevelMap.xhigh': { targetField: 'thinkingLevelMap.xhigh', targetType: 'string' },
|
|
62
|
+
'thinkingLevelMap.max': { targetField: 'thinkingLevelMap.max', targetType: 'string' },
|
|
61
63
|
|
|
62
64
|
// top-level fields (future extensibility)
|
|
63
65
|
contextWindow: { targetField: 'contextWindow', targetType: 'number' },
|
|
@@ -16,6 +16,10 @@ export interface OpenRouterModel {
|
|
|
16
16
|
input_cache_write?: string;
|
|
17
17
|
};
|
|
18
18
|
supported_parameters?: string[];
|
|
19
|
+
reasoning?: {
|
|
20
|
+
mandatory: boolean;
|
|
21
|
+
supported_efforts?: Array<string | null> | null;
|
|
22
|
+
};
|
|
19
23
|
top_provider?: {
|
|
20
24
|
context_length?: number;
|
|
21
25
|
max_completion_tokens?: number;
|
|
@@ -44,6 +48,7 @@ export interface ThinkingLevelMap {
|
|
|
44
48
|
medium?: string | null;
|
|
45
49
|
high?: string | null;
|
|
46
50
|
xhigh?: string | null;
|
|
51
|
+
max?: string | null;
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
/**
|
|
@@ -62,6 +62,16 @@ export function sdkModelToOpenRouterModel(model: SDKModel): OpenRouterModel {
|
|
|
62
62
|
supported_parameters: model.supportedParameters,
|
|
63
63
|
};
|
|
64
64
|
|
|
65
|
+
if (model.reasoning !== undefined) {
|
|
66
|
+
const reasoning: NonNullable<OpenRouterModel['reasoning']> = {
|
|
67
|
+
mandatory: model.reasoning.mandatory,
|
|
68
|
+
};
|
|
69
|
+
if (model.reasoning.supportedEfforts !== undefined) {
|
|
70
|
+
reasoning.supported_efforts = model.reasoning.supportedEfforts;
|
|
71
|
+
}
|
|
72
|
+
result.reasoning = reasoning;
|
|
73
|
+
}
|
|
74
|
+
|
|
65
75
|
if (architecture) {
|
|
66
76
|
result.architecture = architecture;
|
|
67
77
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robhowley/pi-openrouter",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Live OpenRouter spend/account TUI overlays, user-scoped or free-only model sync, api key management, and session tagging for Pi.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,15 +38,15 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@mariozechner/pi-tui": "*",
|
|
41
|
-
"@openrouter/sdk": "^0.
|
|
41
|
+
"@openrouter/sdk": "^0.13.13"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@earendil-works/pi-ai": "
|
|
45
|
-
"@earendil-works/pi-coding-agent": "
|
|
44
|
+
"@earendil-works/pi-ai": ">=0.84.4",
|
|
45
|
+
"@earendil-works/pi-coding-agent": ">=0.84.4"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
-
"@earendil-works/pi-ai": "
|
|
49
|
-
"@earendil-works/pi-coding-agent": "
|
|
48
|
+
"@earendil-works/pi-ai": "0.84.4",
|
|
49
|
+
"@earendil-works/pi-coding-agent": "0.84.4",
|
|
50
50
|
"@mariozechner/pi-coding-agent": "*",
|
|
51
51
|
"@types/node": "^22.15.17"
|
|
52
52
|
}
|