@robhowley/pi-openrouter 0.8.3 → 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 (40) hide show
  1. package/README.md +87 -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 +163 -0
  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 -589
  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-overrides.test.ts +102 -0
  24. package/extensions/openrouter/models/__tests__/mapper.test.ts +29 -0
  25. package/extensions/openrouter/models/__tests__/override-commands.test.ts +668 -0
  26. package/extensions/openrouter/models/__tests__/overrides.test.ts +237 -0
  27. package/extensions/openrouter/models/__tests__/sync.test.ts +156 -4
  28. package/extensions/openrouter/models/cache.ts +27 -2
  29. package/extensions/openrouter/models/mapper.ts +60 -77
  30. package/extensions/openrouter/models/override-commands.ts +434 -0
  31. package/extensions/openrouter/models/overrides.ts +174 -0
  32. package/extensions/openrouter/models/skip-hints.ts +19 -0
  33. package/extensions/openrouter/models/sync.ts +22 -10
  34. package/extensions/openrouter/models/types.ts +31 -1
  35. package/extensions/openrouter/normalizers.ts +128 -0
  36. package/extensions/openrouter/overlay.ts +19 -8
  37. package/extensions/openrouter/session-state.ts +110 -0
  38. package/extensions/openrouter/session.ts +16 -0
  39. package/extensions/openrouter/types.ts +28 -9
  40. package/package.json +1 -1
@@ -1,6 +1,9 @@
1
1
  import type { OpenRouterModel, PiModelConfig, SkipReason, MapResult } from './types.js';
2
2
  import { ROUTER_ALIASES } from './types.js';
3
+ import { getSkipReasonHint } from './skip-hints.js';
3
4
  import type { Model as SDKModel } from '@openrouter/sdk/models/index.js';
5
+ import { loadModelOverrides, getModelOverride } from './overrides.js';
6
+ import { normalizeOpenRouterModel } from '../normalizers.js';
4
7
 
5
8
  // Cache for built-in OpenRouter models from pi-ai
6
9
  // Populated lazily on first access
@@ -20,7 +23,6 @@ async function loadBuiltInOpenRouterModels(): Promise<Map<string, PiModelConfig>
20
23
 
21
24
  try {
22
25
  // Import from pi-ai to get built-in model registry
23
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
26
  const { getModels } = (await import('@earendil-works/pi-ai')) as {
25
27
  getModels: (provider: string) => unknown[];
26
28
  };
@@ -29,7 +31,6 @@ async function loadBuiltInOpenRouterModels(): Promise<Map<string, PiModelConfig>
29
31
  if (Array.isArray(openrouterModels)) {
30
32
  for (const model of openrouterModels) {
31
33
  // Extract thinkingLevelMap from built-in model if present
32
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
33
34
  const modelWithThinking = model as { id: string; thinkingLevelMap?: unknown };
34
35
  if (modelWithThinking.id) {
35
36
  models.set(modelWithThinking.id, model as PiModelConfig);
@@ -58,68 +59,24 @@ const COST_PER_MILLION = 1_000_000;
58
59
  const DEFAULT_MAX_TOKENS = 4096;
59
60
 
60
61
  /**
61
- * Convert SDK Model to our OpenRouterModel type for compatibility.
62
- * Handles SDK's camelCase naming convention.
62
+ * Validation result for a model check.
63
63
  */
64
- export function sdkModelToOpenRouterModel(model: SDKModel): OpenRouterModel {
65
- const topProvider = model.topProvider
66
- ? {
67
- context_length: model.topProvider.contextLength ?? 0,
68
- max_completion_tokens: model.topProvider.maxCompletionTokens ?? 0,
69
- }
70
- : undefined;
71
-
72
- const perRequestLimits = model.perRequestLimits
73
- ? {
74
- completion_tokens: model.perRequestLimits.completionTokens ?? 0,
75
- }
76
- : undefined;
77
-
78
- // Build the object conditionally to avoid undefined property issues
79
- const result: OpenRouterModel = {
80
- id: model.id,
81
- name: model.name,
82
- architecture: {
83
- input_modalities: model.architecture.inputModalities ?? [],
84
- output_modalities: model.architecture.outputModalities ?? [],
85
- },
86
- context_length: model.contextLength ?? 0,
87
- pricing: {
88
- prompt: String(model.pricing.prompt ?? 0),
89
- completion: String(model.pricing.completion ?? 0),
90
- input_cache_read: String(model.pricing.inputCacheRead ?? 0),
91
- input_cache_write: String(model.pricing.inputCacheWrite ?? 0),
92
- },
93
- supported_parameters: model.supportedParameters,
94
- };
95
-
96
- // Conditionally add optional properties to avoid explicit undefined
97
- if (topProvider) {
98
- result.top_provider = topProvider;
99
- }
100
- if (perRequestLimits) {
101
- result.per_request_limits = perRequestLimits;
102
- }
64
+ type PricedOpenRouterModel = OpenRouterModel & {
65
+ pricing: NonNullable<OpenRouterModel['pricing']>;
66
+ };
103
67
 
104
- return result;
105
- }
68
+ type ValidationResult =
69
+ | { valid: true; model: PricedOpenRouterModel; contextWindow: number }
70
+ | { valid: false; reason: string; modelId: string; hint?: string };
106
71
 
107
72
  /**
108
- * Normalize input model to OpenRouterModel format.
73
+ * Build a failed validation result with a stable machine reason and optional hint.
109
74
  */
110
- function normalizeModel(model: OpenRouterModel | SDKModel): OpenRouterModel {
111
- return 'contextLength' in model
112
- ? sdkModelToOpenRouterModel(model as SDKModel)
113
- : (model as OpenRouterModel);
75
+ function invalidModel(reason: string, modelId: string): ValidationResult {
76
+ const hint = getSkipReasonHint(reason);
77
+ return hint ? { valid: false, reason, modelId, hint } : { valid: false, reason, modelId };
114
78
  }
115
79
 
116
- /**
117
- * Validation result for a model check.
118
- */
119
- type ValidationResult =
120
- | { valid: true; model: OpenRouterModel; contextWindow: number }
121
- | { valid: false; reason: string; modelId: string };
122
-
123
80
  /**
124
81
  * Validate a model and return either a valid result with extracted context window
125
82
  * or a failure reason.
@@ -127,39 +84,42 @@ type ValidationResult =
127
84
  function validateModel(model: OpenRouterModel): ValidationResult {
128
85
  // Check: missing required id
129
86
  if (!model.id) {
130
- return { valid: false, reason: 'missing id', modelId: 'unknown' };
87
+ return invalidModel('missing id', 'unknown');
131
88
  }
132
89
 
133
90
  // Check: missing required pricing fields
134
- if (!model.pricing?.prompt) {
135
- return { valid: false, reason: 'missing prompt pricing', modelId: model.id };
91
+ const pricing = model.pricing;
92
+ if (!pricing?.prompt) {
93
+ return invalidModel('missing prompt pricing', model.id);
136
94
  }
137
- if (!model.pricing?.completion) {
138
- return { valid: false, reason: 'missing completion pricing', modelId: model.id };
95
+ if (!pricing.completion) {
96
+ return invalidModel('missing completion pricing', model.id);
139
97
  }
140
98
 
141
99
  // Check: missing context window (both primary and fallback)
142
100
  const contextWindow = model.top_provider?.context_length ?? model.context_length;
143
101
  if (!contextWindow) {
144
- return { valid: false, reason: 'missing context window', modelId: model.id };
102
+ return invalidModel('missing context window', model.id);
145
103
  }
146
104
 
147
105
  // Check: explicitly non-text output (if specified)
148
106
  const outputModalities = model.architecture?.output_modalities;
149
107
  if (outputModalities && !outputModalities.includes('text')) {
150
- return { valid: false, reason: 'non-text output modalities', modelId: model.id };
108
+ return invalidModel('non-text output modalities', model.id);
151
109
  }
152
110
 
153
- return { valid: true, model, contextWindow };
111
+ return { valid: true, model: { ...model, pricing }, contextWindow };
154
112
  }
155
113
 
156
114
  /**
157
115
  * Build PiModelConfig from a validated OpenRouterModel.
158
- * Merges thinkingLevelMap from Pi's built-in registry if available.
116
+ * Merges thinkingLevelMap from Pi's built-in registry and user overrides.
117
+ * Priority: user overrides > built-in registry > API data
159
118
  */
160
119
  async function buildPiConfig(
161
- model: OpenRouterModel,
120
+ model: PricedOpenRouterModel,
162
121
  contextWindow: number,
122
+ userOverrides?: Awaited<ReturnType<typeof loadModelOverrides>>,
163
123
  ): Promise<PiModelConfig> {
164
124
  const supportedParams = model.supported_parameters ?? [];
165
125
  const hasReasoning =
@@ -168,12 +128,25 @@ async function buildPiConfig(
168
128
  const supportsImages = inputModalities?.includes('image') ?? false;
169
129
 
170
130
  // Fetch thinkingLevelMap from built-in registry if this is a reasoning model
171
- const thinkingLevelMap = hasReasoning ? await getBuiltInThinkingLevelMap(model.id) : undefined;
131
+ const builtInThinkingLevelMap = hasReasoning
132
+ ? await getBuiltInThinkingLevelMap(model.id)
133
+ : undefined;
134
+
135
+ // Fetch user override for this model
136
+ const userOverride = userOverrides ? getModelOverride(userOverrides, model.id) : undefined;
137
+
138
+ const thinkingLevelMap =
139
+ builtInThinkingLevelMap !== undefined || userOverride?.thinkingLevelMap !== undefined
140
+ ? {
141
+ ...builtInThinkingLevelMap,
142
+ ...userOverride?.thinkingLevelMap,
143
+ }
144
+ : undefined;
172
145
 
173
146
  const config: PiModelConfig = {
174
147
  id: model.id,
175
148
  name: model.name ?? model.id,
176
- reasoning: hasReasoning,
149
+ reasoning: userOverride?.reasoning ?? hasReasoning,
177
150
  input: supportsImages ? ['text', 'image'] : ['text'],
178
151
  cost: {
179
152
  input: Number(model.pricing.prompt) * COST_PER_MILLION,
@@ -181,8 +154,9 @@ async function buildPiConfig(
181
154
  cacheRead: Number(model.pricing.input_cache_read ?? 0) * COST_PER_MILLION,
182
155
  cacheWrite: Number(model.pricing.input_cache_write ?? 0) * COST_PER_MILLION,
183
156
  },
184
- contextWindow,
157
+ contextWindow: userOverride?.contextWindow ?? contextWindow,
185
158
  maxTokens:
159
+ userOverride?.maxTokens ??
186
160
  model.top_provider?.max_completion_tokens ??
187
161
  model.per_request_limits?.completion_tokens ??
188
162
  DEFAULT_MAX_TOKENS,
@@ -198,20 +172,21 @@ async function buildPiConfig(
198
172
 
199
173
  /**
200
174
  * Maps multiple OpenRouter models, tracking skips.
201
- * Async to allow fetching thinkingLevelMap from built-in registry.
175
+ * Async to allow fetching thinkingLevelMap from built-in registry and user overrides.
202
176
  */
203
177
  export async function mapOpenRouterModels(
204
178
  models: OpenRouterModel[] | SDKModel[],
205
179
  ): Promise<MapResult> {
206
- // Pre-load built-in models for efficient lookup during mapping
180
+ // Pre-load built-in models and user overrides for efficient lookup during mapping
207
181
  await loadBuiltInOpenRouterModels();
182
+ const userOverrides = await loadModelOverrides();
208
183
 
209
184
  const configs: PiModelConfig[] = [];
210
185
  let skipped = 0;
211
186
  const skippedDetails: SkipReason[] = [];
212
187
 
213
188
  for (const rawModel of models) {
214
- const model = normalizeModel(rawModel);
189
+ const model = normalizeOpenRouterModel(rawModel);
215
190
 
216
191
  // Skip router aliases - they're added manually after mapping
217
192
  if (ROUTER_ALIASES.includes(model.id)) {
@@ -222,11 +197,18 @@ export async function mapOpenRouterModels(
222
197
 
223
198
  if (!validation.valid) {
224
199
  skipped++;
225
- skippedDetails.push({ id: validation.modelId, reason: validation.reason });
200
+ const skippedDetail: SkipReason = {
201
+ id: validation.modelId,
202
+ reason: validation.reason,
203
+ };
204
+ if (validation.hint) {
205
+ skippedDetail.hint = validation.hint;
206
+ }
207
+ skippedDetails.push(skippedDetail);
226
208
  continue;
227
209
  }
228
210
 
229
- configs.push(await buildPiConfig(model, validation.contextWindow));
211
+ configs.push(await buildPiConfig(validation.model, validation.contextWindow, userOverrides));
230
212
  }
231
213
 
232
214
  return { configs, skipped, skippedDetails };
@@ -241,8 +223,9 @@ export async function mapOpenRouterModel(
241
223
  model: OpenRouterModel | SDKModel,
242
224
  ): Promise<PiModelConfig | null> {
243
225
  await loadBuiltInOpenRouterModels();
226
+ const userOverrides = await loadModelOverrides();
244
227
 
245
- const normalized = normalizeModel(model);
228
+ const normalized = normalizeOpenRouterModel(model);
246
229
 
247
230
  // Router aliases are handled separately, skip them here
248
231
  if (ROUTER_ALIASES.includes(normalized.id)) {
@@ -255,5 +238,5 @@ export async function mapOpenRouterModel(
255
238
  return null;
256
239
  }
257
240
 
258
- return buildPiConfig(normalized, validation.contextWindow);
241
+ return buildPiConfig(validation.model, validation.contextWindow, userOverrides);
259
242
  }
@@ -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
+ }