@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.
Files changed (37) hide show
  1. package/README.md +39 -4
  2. package/extensions/openrouter/__tests__/cache.test.ts +769 -0
  3. package/extensions/openrouter/__tests__/client.test.ts +333 -15
  4. package/extensions/openrouter/__tests__/commands.test.ts +816 -0
  5. package/extensions/openrouter/__tests__/fixtures.ts +140 -1
  6. package/extensions/openrouter/__tests__/format.test.ts +19 -0
  7. package/extensions/openrouter/__tests__/hooks.test.ts +276 -0
  8. package/extensions/openrouter/__tests__/index.test.ts +112 -363
  9. package/extensions/openrouter/__tests__/local-usage.test.ts +777 -0
  10. package/extensions/openrouter/__tests__/normalizers.test.ts +288 -0
  11. package/extensions/openrouter/__tests__/overlay.test.ts +225 -0
  12. package/extensions/openrouter/__tests__/session-state.test.ts +233 -0
  13. package/extensions/openrouter/__tests__/session.test.ts +44 -43
  14. package/extensions/openrouter/account-client.ts +11 -61
  15. package/extensions/openrouter/cache.ts +203 -91
  16. package/extensions/openrouter/client.ts +49 -3
  17. package/extensions/openrouter/commands.ts +555 -0
  18. package/extensions/openrouter/format.ts +7 -4
  19. package/extensions/openrouter/hooks.ts +229 -0
  20. package/extensions/openrouter/index.ts +13 -990
  21. package/extensions/openrouter/local-usage.ts +145 -22
  22. package/extensions/openrouter/models/__tests__/cache.test.ts +63 -2
  23. package/extensions/openrouter/models/__tests__/mapper.test.ts +29 -0
  24. package/extensions/openrouter/models/__tests__/override-commands.test.ts +668 -0
  25. package/extensions/openrouter/models/__tests__/sync.test.ts +156 -4
  26. package/extensions/openrouter/models/cache.ts +27 -2
  27. package/extensions/openrouter/models/mapper.ts +35 -69
  28. package/extensions/openrouter/models/override-commands.ts +434 -0
  29. package/extensions/openrouter/models/skip-hints.ts +19 -0
  30. package/extensions/openrouter/models/sync.ts +22 -10
  31. package/extensions/openrouter/models/types.ts +2 -1
  32. package/extensions/openrouter/normalizers.ts +128 -0
  33. package/extensions/openrouter/overlay.ts +19 -8
  34. package/extensions/openrouter/session-state.ts +110 -0
  35. package/extensions/openrouter/session.ts +16 -0
  36. package/extensions/openrouter/types.ts +28 -9
  37. 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, sdkModelToOpenRouterModel } from './mapper.js';
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 add models to the built-in openrouter provider.
67
- * The models array replaces all existing models for the provider.
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 = [...configs, ...BUILTIN_ROUTER_MODELS];
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, configs);
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: configs.length,
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 !== null && cache.models.length > 0;
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
+ }