@robhowley/pi-openrouter 0.9.0 → 0.9.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 +39 -4
- package/extensions/openrouter/__tests__/cache.test.ts +769 -0
- package/extensions/openrouter/__tests__/client.test.ts +333 -15
- package/extensions/openrouter/__tests__/commands.test.ts +816 -0
- package/extensions/openrouter/__tests__/fixtures.ts +140 -1
- package/extensions/openrouter/__tests__/format.test.ts +19 -0
- package/extensions/openrouter/__tests__/hooks.test.ts +276 -0
- package/extensions/openrouter/__tests__/index.test.ts +112 -363
- package/extensions/openrouter/__tests__/local-usage.test.ts +777 -0
- package/extensions/openrouter/__tests__/normalizers.test.ts +288 -0
- package/extensions/openrouter/__tests__/overlay.test.ts +225 -0
- package/extensions/openrouter/__tests__/session-state.test.ts +233 -0
- package/extensions/openrouter/__tests__/session.test.ts +44 -43
- package/extensions/openrouter/account-client.ts +11 -61
- package/extensions/openrouter/cache.ts +203 -91
- package/extensions/openrouter/client.ts +49 -3
- package/extensions/openrouter/commands.ts +555 -0
- package/extensions/openrouter/format.ts +7 -4
- package/extensions/openrouter/hooks.ts +229 -0
- package/extensions/openrouter/index.ts +13 -990
- package/extensions/openrouter/local-usage.ts +145 -22
- package/extensions/openrouter/models/__tests__/cache.test.ts +63 -2
- package/extensions/openrouter/models/__tests__/mapper.test.ts +29 -0
- package/extensions/openrouter/models/__tests__/override-commands.test.ts +668 -0
- package/extensions/openrouter/models/__tests__/sync.test.ts +156 -4
- package/extensions/openrouter/models/cache.ts +27 -2
- package/extensions/openrouter/models/mapper.ts +35 -69
- package/extensions/openrouter/models/override-commands.ts +434 -0
- package/extensions/openrouter/models/skip-hints.ts +19 -0
- package/extensions/openrouter/models/sync.ts +22 -10
- package/extensions/openrouter/models/types.ts +2 -1
- package/extensions/openrouter/normalizers.ts +128 -0
- package/extensions/openrouter/overlay.ts +19 -8
- package/extensions/openrouter/session-state.ts +110 -0
- package/extensions/openrouter/session.ts +16 -0
- package/extensions/openrouter/types.ts +28 -9
- package/package.json +1 -1
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model override DSL parsing, validation, and command handlers.
|
|
3
|
+
* Extracted from index.ts to keep command routing logic separate from DSL implementation.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ThinkingLevelMap, UserModelOverride } from './types.js';
|
|
7
|
+
import type { ModelOverridesFile } from './types.js';
|
|
8
|
+
import {
|
|
9
|
+
getModelOverride,
|
|
10
|
+
getOverrideModelIds,
|
|
11
|
+
hasOverrides,
|
|
12
|
+
loadModelOverrides,
|
|
13
|
+
removeModelOverride,
|
|
14
|
+
saveModelOverrides,
|
|
15
|
+
setModelOverride,
|
|
16
|
+
} from './overrides.js';
|
|
17
|
+
// Utility to extract error messages
|
|
18
|
+
function getErrorMessage(error: unknown): string {
|
|
19
|
+
return error instanceof Error ? error.message : String(error);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// =============================================================================
|
|
23
|
+
// Types
|
|
24
|
+
// =============================================================================
|
|
25
|
+
|
|
26
|
+
export interface HandlerResult {
|
|
27
|
+
success: boolean;
|
|
28
|
+
message: string;
|
|
29
|
+
modelId?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ScopedField {
|
|
33
|
+
targetField: string;
|
|
34
|
+
targetType: 'string' | 'number' | 'boolean';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// =============================================================================
|
|
38
|
+
// Scoped Field Mapping
|
|
39
|
+
// =============================================================================
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Scoped field name mapping: converts user-facing 'thinking.X' to internal 'thinkingLevelMap.X'
|
|
43
|
+
* Also supports exact PiModelConfig field names for future extensibility.
|
|
44
|
+
*/
|
|
45
|
+
export const SCOPED_FIELD_MAP: Record<string, ScopedField> = {
|
|
46
|
+
// thinking.* shorthand - maps to thinkingLevelMap
|
|
47
|
+
'thinking.off': { targetField: 'thinkingLevelMap.off', targetType: 'string' },
|
|
48
|
+
'thinking.minimal': { targetField: 'thinkingLevelMap.minimal', targetType: 'string' },
|
|
49
|
+
'thinking.low': { targetField: 'thinkingLevelMap.low', targetType: 'string' },
|
|
50
|
+
'thinking.medium': { targetField: 'thinkingLevelMap.medium', targetType: 'string' },
|
|
51
|
+
'thinking.high': { targetField: 'thinkingLevelMap.high', targetType: 'string' },
|
|
52
|
+
'thinking.xhigh': { targetField: 'thinkingLevelMap.xhigh', targetType: 'string' },
|
|
53
|
+
|
|
54
|
+
// exact field names (passthrough)
|
|
55
|
+
'thinkingLevelMap.off': { targetField: 'thinkingLevelMap.off', targetType: 'string' },
|
|
56
|
+
'thinkingLevelMap.minimal': { targetField: 'thinkingLevelMap.minimal', targetType: 'string' },
|
|
57
|
+
'thinkingLevelMap.low': { targetField: 'thinkingLevelMap.low', targetType: 'string' },
|
|
58
|
+
'thinkingLevelMap.medium': { targetField: 'thinkingLevelMap.medium', targetType: 'string' },
|
|
59
|
+
'thinkingLevelMap.high': { targetField: 'thinkingLevelMap.high', targetType: 'string' },
|
|
60
|
+
'thinkingLevelMap.xhigh': { targetField: 'thinkingLevelMap.xhigh', targetType: 'string' },
|
|
61
|
+
|
|
62
|
+
// top-level fields (future extensibility)
|
|
63
|
+
contextWindow: { targetField: 'contextWindow', targetType: 'number' },
|
|
64
|
+
maxTokens: { targetField: 'maxTokens', targetType: 'number' },
|
|
65
|
+
reasoning: { targetField: 'reasoning', targetType: 'boolean' },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// =============================================================================
|
|
69
|
+
// Validation
|
|
70
|
+
// =============================================================================
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Conservative allowlist of thinking level values accepted via CLI DSL.
|
|
74
|
+
* These match documented OpenRouter and Pi thinking values.
|
|
75
|
+
*
|
|
76
|
+
* The JSON file escape hatch (`~/.pi/openrouter/model-overrides.json`) can be
|
|
77
|
+
* edited manually for advanced or experimental values outside this set.
|
|
78
|
+
*/
|
|
79
|
+
const ALLOWED_THINKING_VALUES = new Set([
|
|
80
|
+
'off',
|
|
81
|
+
'minimal',
|
|
82
|
+
'low',
|
|
83
|
+
'medium',
|
|
84
|
+
'high',
|
|
85
|
+
'max',
|
|
86
|
+
'xhigh', // Alias for some models
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Validate a thinking level value from the CLI DSL.
|
|
91
|
+
* Rejects empty, whitespace-only, or control-character values.
|
|
92
|
+
* Allows null (explicit "hide this level in UI" signal) and documented thinking values.
|
|
93
|
+
*/
|
|
94
|
+
export function validateThinkingValue(value: string | null): {
|
|
95
|
+
valid: boolean;
|
|
96
|
+
error?: string;
|
|
97
|
+
} {
|
|
98
|
+
if (value === null) {
|
|
99
|
+
return { valid: true }; // null is allowed (means "hide this level")
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Reject empty or whitespace-only
|
|
103
|
+
if (value.trim() === '') {
|
|
104
|
+
return {
|
|
105
|
+
valid: false,
|
|
106
|
+
error: 'Thinking value cannot be empty or whitespace-only',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Reject control characters (0x00-0x1F except tab/newline, and 0x7F)
|
|
111
|
+
// eslint-disable-next-line no-control-regex
|
|
112
|
+
if (/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/.test(value)) {
|
|
113
|
+
return {
|
|
114
|
+
valid: false,
|
|
115
|
+
error: 'Thinking value cannot contain control characters',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check against conservative allowlist
|
|
120
|
+
if (!ALLOWED_THINKING_VALUES.has(value)) {
|
|
121
|
+
return {
|
|
122
|
+
valid: false,
|
|
123
|
+
error: `Thinking value "${value}" is not in the allowed set: ${Array.from(ALLOWED_THINKING_VALUES).join(', ')}\nFor advanced values, edit ~/.pi/openrouter/model-overrides.json directly`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { valid: true };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// =============================================================================
|
|
131
|
+
// DSL Parsing
|
|
132
|
+
// =============================================================================
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Result of parsing a scoped assignment.
|
|
136
|
+
*/
|
|
137
|
+
export type ParseResult =
|
|
138
|
+
| { ok: true; fullPath: string; value: unknown }
|
|
139
|
+
| { ok: false; code: 'invalid-assignment' }
|
|
140
|
+
| { ok: false; code: 'invalid-thinking-value'; field: string; message: string };
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Parse a scoped assignment like "thinking.high=high" or "contextWindow=128000".
|
|
144
|
+
*/
|
|
145
|
+
export function parseScopedAssignment(assignment: string): ParseResult {
|
|
146
|
+
const eqIdx = assignment.indexOf('=');
|
|
147
|
+
if (eqIdx === -1) return { ok: false, code: 'invalid-assignment' };
|
|
148
|
+
|
|
149
|
+
const scopedName = assignment.slice(0, eqIdx).trim();
|
|
150
|
+
const rawValue = assignment.slice(eqIdx + 1).trim();
|
|
151
|
+
|
|
152
|
+
const mapped = SCOPED_FIELD_MAP[scopedName];
|
|
153
|
+
if (!mapped) return { ok: false, code: 'invalid-assignment' };
|
|
154
|
+
|
|
155
|
+
// Parse value by type
|
|
156
|
+
let parsedValue: unknown;
|
|
157
|
+
switch (mapped.targetType) {
|
|
158
|
+
case 'string': {
|
|
159
|
+
// "null" -> null, otherwise string
|
|
160
|
+
const stringValue = rawValue === 'null' ? null : rawValue;
|
|
161
|
+
|
|
162
|
+
// Validate thinking values if this is a thinkingLevelMap field
|
|
163
|
+
if (mapped.targetField.startsWith('thinkingLevelMap.')) {
|
|
164
|
+
const validation = validateThinkingValue(stringValue);
|
|
165
|
+
if (!validation.valid) {
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
code: 'invalid-thinking-value',
|
|
169
|
+
field: scopedName,
|
|
170
|
+
message: validation.error!,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
parsedValue = stringValue;
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
case 'number': {
|
|
179
|
+
const num = parseInt(rawValue, 10);
|
|
180
|
+
if (isNaN(num)) return { ok: false, code: 'invalid-assignment' };
|
|
181
|
+
parsedValue = num;
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
case 'boolean':
|
|
185
|
+
if (rawValue !== 'true' && rawValue !== 'false')
|
|
186
|
+
return { ok: false, code: 'invalid-assignment' };
|
|
187
|
+
parsedValue = rawValue === 'true';
|
|
188
|
+
break;
|
|
189
|
+
default:
|
|
190
|
+
return { ok: false, code: 'invalid-assignment' };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { ok: true, fullPath: mapped.targetField, value: parsedValue };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Apply a nested value to an object using dot notation path.
|
|
198
|
+
*/
|
|
199
|
+
export function applyNestedValue(obj: Record<string, unknown>, path: string, value: unknown): void {
|
|
200
|
+
const parts = path.split('.');
|
|
201
|
+
let current: unknown = obj;
|
|
202
|
+
|
|
203
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
204
|
+
const key = parts[i]!;
|
|
205
|
+
const currentRecord = current as Record<string, unknown>;
|
|
206
|
+
if (
|
|
207
|
+
!(key in currentRecord) ||
|
|
208
|
+
typeof currentRecord[key] !== 'object' ||
|
|
209
|
+
currentRecord[key] === null
|
|
210
|
+
) {
|
|
211
|
+
currentRecord[key] = {};
|
|
212
|
+
}
|
|
213
|
+
current = currentRecord[key];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const finalKey = parts[parts.length - 1]!;
|
|
217
|
+
(current as Record<string, unknown>)[finalKey] = value;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// =============================================================================
|
|
221
|
+
// Command Handlers
|
|
222
|
+
// =============================================================================
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Handle /openrouter model-override-set command.
|
|
226
|
+
* Format: model-override-set <model-id> <field=value>...
|
|
227
|
+
* Examples:
|
|
228
|
+
* /openrouter model-override-set deepseek/deepseek-v4-pro thinking.high=high thinking.xhigh=max
|
|
229
|
+
* /openrouter model-override-set deepseek/deepseek-v4-pro contextWindow=128000
|
|
230
|
+
*/
|
|
231
|
+
export async function handleModelOverrideSet(
|
|
232
|
+
args: string,
|
|
233
|
+
userOverrides: ModelOverridesFile,
|
|
234
|
+
): Promise<HandlerResult> {
|
|
235
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
236
|
+
|
|
237
|
+
if (parts.length < 1) {
|
|
238
|
+
return {
|
|
239
|
+
success: false,
|
|
240
|
+
message:
|
|
241
|
+
'Usage: /openrouter model-override-set <model-id> <field=value>...\nExample: /openrouter model-override-set deepseek/deepseek-v4-pro thinking.high=high thinking.xhigh=max',
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const modelId = parts[0];
|
|
246
|
+
|
|
247
|
+
if (!modelId) {
|
|
248
|
+
return {
|
|
249
|
+
success: false,
|
|
250
|
+
message:
|
|
251
|
+
'Usage: /openrouter model-override-set <model-id> <field=value>...\nExample: /openrouter model-override-set deepseek/deepseek-v4-pro thinking.high=high thinking.xhigh=max',
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Validate model ID format (should be provider/model)
|
|
256
|
+
if (!modelId.includes('/')) {
|
|
257
|
+
return {
|
|
258
|
+
success: false,
|
|
259
|
+
message: `Invalid model ID format: "${modelId}"\nExpected format: provider/model (e.g., "deepseek/deepseek-v4-pro")`,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Build override incrementally from assignments
|
|
264
|
+
const override: UserModelOverride = {};
|
|
265
|
+
const assignments = parts.slice(1).filter((p) => !p.startsWith('--'));
|
|
266
|
+
|
|
267
|
+
for (const assignment of assignments) {
|
|
268
|
+
const parsed = parseScopedAssignment(assignment);
|
|
269
|
+
if (!parsed.ok) {
|
|
270
|
+
if (parsed.code === 'invalid-thinking-value') {
|
|
271
|
+
return {
|
|
272
|
+
success: false,
|
|
273
|
+
message: `Invalid thinking value for "${parsed.field}": ${parsed.message}`,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
success: false,
|
|
278
|
+
message: `Invalid assignment: "${assignment}"\nExpected format: field=value (e.g., thinking.high=high or contextWindow=128000)\nSee available fields with /openrouter model-override-list --fields`,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
applyNestedValue(override as Record<string, unknown>, parsed.fullPath, parsed.value);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// If no assignments provided, error out
|
|
285
|
+
if (Object.keys(override).length === 0) {
|
|
286
|
+
return {
|
|
287
|
+
success: false,
|
|
288
|
+
message:
|
|
289
|
+
'No field assignments provided.\nUsage: /openrouter model-override-set <model-id> field=value [field=value]...\nExample: /openrouter model-override-set deepseek/deepseek-v4-pro thinking.high=high thinking.xhigh=max',
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Update overrides file
|
|
294
|
+
const updatedOverrides = setModelOverride(userOverrides, modelId, override);
|
|
295
|
+
try {
|
|
296
|
+
await saveModelOverrides(updatedOverrides);
|
|
297
|
+
} catch (error) {
|
|
298
|
+
return {
|
|
299
|
+
success: false,
|
|
300
|
+
message: `Failed to save overrides for ${modelId}: ${getErrorMessage(error)}`,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const savedOverride = updatedOverrides.overrides[modelId] as UserModelOverride;
|
|
305
|
+
|
|
306
|
+
// Format success message
|
|
307
|
+
const lines: string[] = [`Saved overrides for ${modelId}:`];
|
|
308
|
+
for (const [key, val] of Object.entries(savedOverride)) {
|
|
309
|
+
if (key === 'thinkingLevelMap' && val) {
|
|
310
|
+
lines.push(' thinkingLevelMap:');
|
|
311
|
+
for (const [level, mapped] of Object.entries(val as ThinkingLevelMap)) {
|
|
312
|
+
lines.push(` ${level}: ${mapped === null ? 'null' : mapped}`);
|
|
313
|
+
}
|
|
314
|
+
} else {
|
|
315
|
+
lines.push(` ${key}: ${val}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
success: true,
|
|
321
|
+
message: lines.join('\n'),
|
|
322
|
+
modelId,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Handle /openrouter model-override-clear command.
|
|
328
|
+
*/
|
|
329
|
+
export async function handleModelOverrideClear(
|
|
330
|
+
args: string,
|
|
331
|
+
userOverrides: ModelOverridesFile,
|
|
332
|
+
): Promise<HandlerResult> {
|
|
333
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
334
|
+
const modelId = parts[0];
|
|
335
|
+
|
|
336
|
+
if (!modelId) {
|
|
337
|
+
return {
|
|
338
|
+
success: false,
|
|
339
|
+
message: 'Usage: /openrouter model-override-clear <model-id>',
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (!modelId.includes('/')) {
|
|
344
|
+
return {
|
|
345
|
+
success: false,
|
|
346
|
+
message: `Invalid model ID format: "${modelId}"\nExpected format: provider/model`,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const existing = getModelOverride(userOverrides, modelId);
|
|
351
|
+
if (!existing) {
|
|
352
|
+
return {
|
|
353
|
+
success: false,
|
|
354
|
+
message: `No overrides found for ${modelId}`,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const updatedOverrides = removeModelOverride(userOverrides, modelId);
|
|
359
|
+
try {
|
|
360
|
+
await saveModelOverrides(updatedOverrides);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
return {
|
|
363
|
+
success: false,
|
|
364
|
+
message: `Failed to clear overrides for ${modelId}: ${getErrorMessage(error)}`,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
success: true,
|
|
370
|
+
message: `Cleared all overrides for ${modelId}`,
|
|
371
|
+
modelId,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Handle /openrouter model-override-list command.
|
|
377
|
+
*/
|
|
378
|
+
export async function handleModelOverrideList(args: string): Promise<string> {
|
|
379
|
+
const userOverrides = await loadModelOverrides();
|
|
380
|
+
const modelId = args.trim();
|
|
381
|
+
|
|
382
|
+
// List available fields if --fields flag
|
|
383
|
+
if (modelId === '--fields') {
|
|
384
|
+
const fields = Object.keys(SCOPED_FIELD_MAP)
|
|
385
|
+
.map(
|
|
386
|
+
(k) => ` ${k}: ${SCOPED_FIELD_MAP[k]!.targetField} (${SCOPED_FIELD_MAP[k]!.targetType})`,
|
|
387
|
+
)
|
|
388
|
+
.join('\n');
|
|
389
|
+
return `Available override fields:\n${fields}`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (modelId) {
|
|
393
|
+
// Show specific model
|
|
394
|
+
const override = getModelOverride(userOverrides, modelId);
|
|
395
|
+
if (!override) {
|
|
396
|
+
return `No overrides configured for ${modelId}`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const lines: string[] = [`Overrides for ${modelId}:`];
|
|
400
|
+
for (const [key, val] of Object.entries(override)) {
|
|
401
|
+
if (key === 'thinkingLevelMap' && val) {
|
|
402
|
+
lines.push(' thinkingLevelMap:');
|
|
403
|
+
for (const [level, mapped] of Object.entries(val as ThinkingLevelMap)) {
|
|
404
|
+
lines.push(` ${level}: ${mapped === null ? 'null' : mapped}`);
|
|
405
|
+
}
|
|
406
|
+
} else {
|
|
407
|
+
lines.push(` ${key}: ${val}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return lines.join('\n');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (!hasOverrides(userOverrides)) {
|
|
414
|
+
return 'No model overrides configured.\nUse /openrouter model-override-set to add overrides.';
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// List all overrides
|
|
418
|
+
const modelIds = getOverrideModelIds(userOverrides);
|
|
419
|
+
const lines: string[] = [`${modelIds.length} model(s) with overrides:`];
|
|
420
|
+
for (const id of modelIds) {
|
|
421
|
+
const override = getModelOverride(userOverrides, id);
|
|
422
|
+
if (override?.thinkingLevelMap && Object.keys(override.thinkingLevelMap).length > 0) {
|
|
423
|
+
const tlm = Object.entries(override.thinkingLevelMap)
|
|
424
|
+
.filter(([, v]) => v !== null)
|
|
425
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
426
|
+
.join(',');
|
|
427
|
+
lines.push(` ${id}${tlm ? ` [${tlm}]` : ''}`);
|
|
428
|
+
} else {
|
|
429
|
+
lines.push(` ${id}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
lines.push('\nUse /openrouter model-override-list <model-id> for details');
|
|
433
|
+
return lines.join('\n');
|
|
434
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const SKIP_REASON_HINTS: Record<string, string> = {
|
|
2
|
+
'missing context window':
|
|
3
|
+
"Add a local contextWindow override with '/openrouter model-override-set <model-id> contextWindow=<tokens>' if the model's limit is known.",
|
|
4
|
+
'missing max tokens':
|
|
5
|
+
"Add a local maxTokens override with '/openrouter model-override-set <model-id> maxTokens=<tokens>' if the model's completion limit is known.",
|
|
6
|
+
'missing prompt pricing':
|
|
7
|
+
'OpenRouter did not provide complete pricing metadata, so Pi cannot map model cost safely.',
|
|
8
|
+
'missing completion pricing':
|
|
9
|
+
'OpenRouter did not provide complete pricing metadata, so Pi cannot map model cost safely.',
|
|
10
|
+
'non-text output modalities':
|
|
11
|
+
'This sync only registers models that advertise text/chat output, so non-text-only models are skipped.',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Return an optional human-readable hint for a stable machine-readable skip reason.
|
|
16
|
+
*/
|
|
17
|
+
export function getSkipReasonHint(reason: string): string | undefined {
|
|
18
|
+
return SKIP_REASON_HINTS[reason];
|
|
19
|
+
}
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { fetchUserModels } from '../client.js';
|
|
7
|
-
import { mapOpenRouterModels
|
|
7
|
+
import { mapOpenRouterModels } from './mapper.js';
|
|
8
|
+
import { sdkModelToOpenRouterModel } from '../normalizers.js';
|
|
8
9
|
import { loadCache, saveCache } from './cache.js';
|
|
9
10
|
import type { ExtensionContext } from '@mariozechner/pi-coding-agent';
|
|
10
11
|
import type {
|
|
@@ -63,15 +64,13 @@ export function getSyncState(): SyncResult | null {
|
|
|
63
64
|
/**
|
|
64
65
|
* Register mapped models with Pi's OpenRouter provider.
|
|
65
66
|
*
|
|
66
|
-
* Uses modelRegistry.registerProvider() to
|
|
67
|
-
*
|
|
67
|
+
* Uses modelRegistry.registerProvider() to replace the provider's model list with the synced
|
|
68
|
+
* user-scoped catalog plus the built-in router aliases that do not appear in /models/user.
|
|
68
69
|
*/
|
|
69
70
|
export async function registerModelsWithProvider(
|
|
70
71
|
ctx: ExtensionContext,
|
|
71
72
|
configs: PiModelConfig[],
|
|
72
73
|
): Promise<void> {
|
|
73
|
-
// Register models with Pi's OpenRouter provider
|
|
74
|
-
// This replaces all existing models for the provider with our synced ones
|
|
75
74
|
ctx.modelRegistry.registerProvider('openrouter', {
|
|
76
75
|
baseUrl: 'https://openrouter.ai/api/v1',
|
|
77
76
|
apiKey: 'OPENROUTER_API_KEY',
|
|
@@ -96,6 +95,18 @@ const BUILTIN_ROUTER_MODELS: PiModelConfig[] = ROUTER_DEFINITIONS.map((r) => ({
|
|
|
96
95
|
maxTokens: r.maxTokens,
|
|
97
96
|
}));
|
|
98
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Add built-in router aliases exactly once to a synced user catalog.
|
|
100
|
+
*
|
|
101
|
+
* The OpenRouter provider registration replaces the built-in list, so router aliases are the
|
|
102
|
+
* only built-ins we intentionally preserve in this cleanup pass.
|
|
103
|
+
*/
|
|
104
|
+
export function includeBuiltinRouterModels(configs: PiModelConfig[]): PiModelConfig[] {
|
|
105
|
+
const seen = new Set(configs.map((config) => config.id));
|
|
106
|
+
const routersToAdd = BUILTIN_ROUTER_MODELS.filter((router) => !seen.has(router.id));
|
|
107
|
+
return [...configs, ...routersToAdd];
|
|
108
|
+
}
|
|
109
|
+
|
|
99
110
|
/**
|
|
100
111
|
* Convert router definitions to OpenRouterModel format for cache storage.
|
|
101
112
|
*/
|
|
@@ -134,8 +145,8 @@ export async function syncModels(_ctx: ExtensionContext): Promise<SyncResult> {
|
|
|
134
145
|
const response = await fetchUserModels();
|
|
135
146
|
const { configs, skipped, skippedDetails } = await mapOpenRouterModels(response.data);
|
|
136
147
|
|
|
137
|
-
// Add built-in router aliases that don't appear in /models/user endpoint
|
|
138
|
-
const configsWithRouters =
|
|
148
|
+
// Add built-in router aliases that don't appear in /models/user endpoint.
|
|
149
|
+
const configsWithRouters = includeBuiltinRouterModels(configs);
|
|
139
150
|
|
|
140
151
|
// Register with Pi's OpenRouter provider
|
|
141
152
|
await registerModelsWithProvider(_ctx, configsWithRouters);
|
|
@@ -173,15 +184,16 @@ export async function syncModels(_ctx: ExtensionContext): Promise<SyncResult> {
|
|
|
173
184
|
if (cache) {
|
|
174
185
|
// Attempt 2: Use cached models
|
|
175
186
|
const { configs, skipped } = await mapOpenRouterModels(cache.models);
|
|
187
|
+
const configsWithRouters = includeBuiltinRouterModels(configs);
|
|
176
188
|
|
|
177
|
-
await registerModelsWithProvider(_ctx,
|
|
189
|
+
await registerModelsWithProvider(_ctx, configsWithRouters);
|
|
178
190
|
|
|
179
191
|
// Use cached skip details if available
|
|
180
192
|
const cachedSkipDetails = cache.skippedDetails || [];
|
|
181
193
|
|
|
182
194
|
const result: SyncResult = {
|
|
183
195
|
success: false,
|
|
184
|
-
registeredCount:
|
|
196
|
+
registeredCount: configsWithRouters.length,
|
|
185
197
|
skippedCount: skipped,
|
|
186
198
|
source: 'cache',
|
|
187
199
|
cacheUpdated: false,
|
|
@@ -245,7 +257,7 @@ export async function areModelsAvailable(): Promise<boolean> {
|
|
|
245
257
|
|
|
246
258
|
// Check cache file on disk
|
|
247
259
|
const cache = await loadCache();
|
|
248
|
-
return cache
|
|
260
|
+
return !!cache && cache.models.length > 0;
|
|
249
261
|
}
|
|
250
262
|
|
|
251
263
|
/**
|
|
@@ -9,7 +9,7 @@ export interface OpenRouterModel {
|
|
|
9
9
|
output_modalities?: string[];
|
|
10
10
|
};
|
|
11
11
|
context_length: number;
|
|
12
|
-
pricing
|
|
12
|
+
pricing?: {
|
|
13
13
|
prompt: string; // per-token price as string
|
|
14
14
|
completion: string;
|
|
15
15
|
input_cache_read?: string;
|
|
@@ -94,6 +94,7 @@ export interface ModelsCache {
|
|
|
94
94
|
export interface SkipReason {
|
|
95
95
|
id: string;
|
|
96
96
|
reason: string;
|
|
97
|
+
hint?: string;
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
/**
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { Model as SDKModel } from '@openrouter/sdk/models/index.js';
|
|
2
|
+
import type { GetCurrentKeyData, ListData } from '@openrouter/sdk/models/operations/index.js';
|
|
3
|
+
import type { BYOKStatus, ResetCadence } from './account-types.js';
|
|
4
|
+
import type { OpenRouterModel } from './models/types.js';
|
|
5
|
+
|
|
6
|
+
export interface NormalizedKeyMetadata {
|
|
7
|
+
name: string;
|
|
8
|
+
label: string;
|
|
9
|
+
used: number;
|
|
10
|
+
resetCadence: ResetCadence;
|
|
11
|
+
byok: BYOKStatus;
|
|
12
|
+
hash: string;
|
|
13
|
+
disabled: boolean;
|
|
14
|
+
limit?: number;
|
|
15
|
+
remaining?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Convert SDK Model to our canonical OpenRouterModel shape.
|
|
20
|
+
* This isolates SDK camelCase/null handling at the package boundary.
|
|
21
|
+
*/
|
|
22
|
+
export function sdkModelToOpenRouterModel(model: SDKModel): OpenRouterModel {
|
|
23
|
+
const topProvider = model.topProvider
|
|
24
|
+
? {
|
|
25
|
+
context_length: model.topProvider.contextLength ?? 0,
|
|
26
|
+
max_completion_tokens: model.topProvider.maxCompletionTokens ?? 0,
|
|
27
|
+
}
|
|
28
|
+
: undefined;
|
|
29
|
+
|
|
30
|
+
const perRequestLimits = model.perRequestLimits
|
|
31
|
+
? {
|
|
32
|
+
completion_tokens: model.perRequestLimits.completionTokens ?? 0,
|
|
33
|
+
}
|
|
34
|
+
: undefined;
|
|
35
|
+
|
|
36
|
+
const architecture = model.architecture
|
|
37
|
+
? {
|
|
38
|
+
input_modalities: model.architecture.inputModalities ?? [],
|
|
39
|
+
output_modalities: model.architecture.outputModalities ?? [],
|
|
40
|
+
}
|
|
41
|
+
: undefined;
|
|
42
|
+
|
|
43
|
+
const pricing = model.pricing
|
|
44
|
+
? {
|
|
45
|
+
prompt: String(model.pricing.prompt ?? 0),
|
|
46
|
+
completion: String(model.pricing.completion ?? 0),
|
|
47
|
+
input_cache_read: String(model.pricing.inputCacheRead ?? 0),
|
|
48
|
+
input_cache_write: String(model.pricing.inputCacheWrite ?? 0),
|
|
49
|
+
}
|
|
50
|
+
: undefined;
|
|
51
|
+
|
|
52
|
+
const result: OpenRouterModel = {
|
|
53
|
+
id: model.id,
|
|
54
|
+
name: model.name,
|
|
55
|
+
context_length: model.contextLength ?? 0,
|
|
56
|
+
supported_parameters: model.supportedParameters,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
if (architecture) {
|
|
60
|
+
result.architecture = architecture;
|
|
61
|
+
}
|
|
62
|
+
if (pricing) {
|
|
63
|
+
result.pricing = pricing;
|
|
64
|
+
}
|
|
65
|
+
if (topProvider) {
|
|
66
|
+
result.top_provider = topProvider;
|
|
67
|
+
}
|
|
68
|
+
if (perRequestLimits) {
|
|
69
|
+
result.per_request_limits = perRequestLimits;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Normalize mixed SDK/canonical model inputs into the package's canonical shape.
|
|
77
|
+
*/
|
|
78
|
+
export function normalizeOpenRouterModel(model: OpenRouterModel | SDKModel): OpenRouterModel {
|
|
79
|
+
return 'contextLength' in model ? sdkModelToOpenRouterModel(model as SDKModel) : model;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Normalize SDK key metadata into the package's canonical internal shape.
|
|
84
|
+
* Converts SDK null/variant fields once so account code can stay domain-focused.
|
|
85
|
+
*/
|
|
86
|
+
export function normalizeSdkKeyMetadata(raw: GetCurrentKeyData | ListData): NormalizedKeyMetadata {
|
|
87
|
+
const used = raw.usage ?? raw.usageMonthly ?? 0;
|
|
88
|
+
const limit = raw.limit ?? undefined;
|
|
89
|
+
const remaining = raw.limitRemaining ?? undefined;
|
|
90
|
+
|
|
91
|
+
let byok: BYOKStatus = '?';
|
|
92
|
+
if (raw.includeByokInLimit === true) {
|
|
93
|
+
byok = 'incl';
|
|
94
|
+
} else if (raw.includeByokInLimit === false) {
|
|
95
|
+
byok = 'excl';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let resetCadence: ResetCadence = 'partial';
|
|
99
|
+
if (raw.limitReset) {
|
|
100
|
+
const reset = raw.limitReset.toLowerCase();
|
|
101
|
+
if (reset === 'monthly') {
|
|
102
|
+
resetCadence = 'monthly';
|
|
103
|
+
} else if (reset === 'daily') {
|
|
104
|
+
resetCadence = 'daily';
|
|
105
|
+
} else if (reset === 'never') {
|
|
106
|
+
resetCadence = 'never';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const normalized: NormalizedKeyMetadata = {
|
|
111
|
+
name: 'name' in raw ? (raw as ListData).name : raw.label,
|
|
112
|
+
label: raw.label,
|
|
113
|
+
used,
|
|
114
|
+
resetCadence,
|
|
115
|
+
byok,
|
|
116
|
+
hash: 'hash' in raw ? (raw as ListData).hash : 'unknown',
|
|
117
|
+
disabled: 'disabled' in raw ? (raw as ListData).disabled : false,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
if (limit !== undefined) {
|
|
121
|
+
normalized.limit = limit;
|
|
122
|
+
}
|
|
123
|
+
if (remaining !== undefined) {
|
|
124
|
+
normalized.remaining = remaining;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return normalized;
|
|
128
|
+
}
|