@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.
- package/README.md +87 -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 +163 -0
- 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 -589
- package/extensions/openrouter/local-usage.ts +145 -22
- package/extensions/openrouter/models/__tests__/cache.test.ts +63 -2
- package/extensions/openrouter/models/__tests__/mapper-overrides.test.ts +102 -0
- 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__/overrides.test.ts +237 -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 +60 -77
- package/extensions/openrouter/models/override-commands.ts +434 -0
- package/extensions/openrouter/models/overrides.ts +174 -0
- package/extensions/openrouter/models/skip-hints.ts +19 -0
- package/extensions/openrouter/models/sync.ts +22 -10
- package/extensions/openrouter/models/types.ts +31 -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
|
@@ -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
|
-
*
|
|
62
|
-
* Handles SDK's camelCase naming convention.
|
|
62
|
+
* Validation result for a model check.
|
|
63
63
|
*/
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
73
|
+
* Build a failed validation result with a stable machine reason and optional hint.
|
|
109
74
|
*/
|
|
110
|
-
function
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
87
|
+
return invalidModel('missing id', 'unknown');
|
|
131
88
|
}
|
|
132
89
|
|
|
133
90
|
// Check: missing required pricing fields
|
|
134
|
-
|
|
135
|
-
|
|
91
|
+
const pricing = model.pricing;
|
|
92
|
+
if (!pricing?.prompt) {
|
|
93
|
+
return invalidModel('missing prompt pricing', model.id);
|
|
136
94
|
}
|
|
137
|
-
if (!
|
|
138
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
+
}
|