@kylebrodeur/pi-model-router 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,415 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { getAgentDir } from '@mariozechner/pi-coding-agent';
4
+ import type { ThinkingLevel } from '@mariozechner/pi-agent-core';
5
+ import type {
6
+ RouterConfig,
7
+ RouterProfile,
8
+ RoutedTierConfig,
9
+ ConfigLoadResult,
10
+ ParsedConfigFile,
11
+ RouterTier,
12
+ RoutingRule,
13
+ } from './types';
14
+
15
+ import { readSettingsScope } from './scope-shim';
16
+
17
+ export const ROUTER_TIERS = ['high', 'medium', 'low'] as const;
18
+
19
+ export const FALLBACK_CONFIG: RouterConfig = {
20
+ defaultProfile: 'auto',
21
+ debug: false,
22
+ features: {
23
+ ollamaSync: true,
24
+ rateLimitFallback: true,
25
+ scopeShim: true,
26
+ respectPiScope: false, // Disabled by default for backward compatibility
27
+ perTurnRouting: true,
28
+ intentClassifier: false,
29
+ costBudgeting: true,
30
+ phaseMemory: true,
31
+ contextCompression: true,
32
+ },
33
+ profiles: {
34
+ auto: {
35
+ high: { model: 'openai/gpt-5.4-pro', thinking: 'off' },
36
+ medium: { model: 'google/gemini-flash-latest', thinking: 'off' },
37
+ low: { model: 'openai/gpt-5.4-nano', thinking: 'off' },
38
+ },
39
+ },
40
+ };
41
+
42
+ export const THINKING_LEVELS: readonly ThinkingLevel[] = [
43
+ 'off',
44
+ 'minimal',
45
+ 'low',
46
+ 'medium',
47
+ 'high',
48
+ 'xhigh',
49
+ ];
50
+ export const ROUTER_PIN_VALUES = ['auto', 'high', 'medium', 'low'] as const;
51
+
52
+ export const isObjectRecord = (
53
+ value: unknown,
54
+ ): value is Record<string, unknown> =>
55
+ typeof value === 'object' && value !== null;
56
+
57
+ export const isThinkingLevel = (value: unknown): value is ThinkingLevel =>
58
+ typeof value === 'string' && THINKING_LEVELS.includes(value as ThinkingLevel);
59
+
60
+ export const isRouterTier = (value: unknown): value is RouterTier =>
61
+ value === 'high' || value === 'medium' || value === 'low';
62
+
63
+ export const parseConfigFile = (path: string): ParsedConfigFile => {
64
+ if (!existsSync(path)) {
65
+ return { config: {}, warnings: [] };
66
+ }
67
+
68
+ try {
69
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
70
+ if (!isObjectRecord(parsed)) {
71
+ return {
72
+ config: {},
73
+ warnings: [`Ignored router config at ${path}: expected a JSON object.`],
74
+ };
75
+ }
76
+ return { config: parsed as Partial<RouterConfig>, warnings: [] };
77
+ } catch (error) {
78
+ return {
79
+ config: {},
80
+ warnings: [
81
+ `Failed to parse router config at ${path}: ${error instanceof Error ? error.message : String(error)}`,
82
+ ],
83
+ };
84
+ }
85
+ };
86
+
87
+ export const mergeConfig = (
88
+ base: RouterConfig,
89
+ override: Partial<RouterConfig>,
90
+ ): RouterConfig => {
91
+ const mergedProfiles: Record<string, RouterProfile> = { ...base.profiles };
92
+ for (const [name, profile] of Object.entries(override.profiles ?? {})) {
93
+ const existing = mergedProfiles[name];
94
+ const nextProfile = profile as Partial<RouterProfile>;
95
+ mergedProfiles[name] = {
96
+ high: {
97
+ ...(existing?.high ?? FALLBACK_CONFIG.profiles.auto.high),
98
+ ...(nextProfile.high ?? {}),
99
+ },
100
+ medium: {
101
+ ...(existing?.medium ?? FALLBACK_CONFIG.profiles.auto.medium),
102
+ ...(nextProfile.medium ?? {}),
103
+ },
104
+ low: {
105
+ ...(existing?.low ?? FALLBACK_CONFIG.profiles.auto.low),
106
+ ...(nextProfile.low ?? {}),
107
+ },
108
+ };
109
+ }
110
+ return {
111
+ defaultProfile: override.defaultProfile ?? base.defaultProfile,
112
+ debug: override.debug ?? base.debug,
113
+ classifierModel: override.classifierModel ?? base.classifierModel,
114
+ phaseBias: override.phaseBias ?? base.phaseBias,
115
+ largeContextThreshold:
116
+ override.largeContextThreshold ?? base.largeContextThreshold,
117
+ maxSessionBudget: override.maxSessionBudget ?? base.maxSessionBudget,
118
+ rules: override.rules ?? base.rules,
119
+ profiles: mergedProfiles,
120
+ features:
121
+ base.features || override.features
122
+ ? { ...base.features, ...override.features }
123
+ : undefined,
124
+ ollamaSync:
125
+ base.ollamaSync || override.ollamaSync
126
+ ? { ...base.ollamaSync, ...override.ollamaSync }
127
+ : undefined,
128
+ rateLimitFallback:
129
+ base.rateLimitFallback || override.rateLimitFallback
130
+ ? { ...base.rateLimitFallback, ...override.rateLimitFallback }
131
+ : undefined,
132
+ };
133
+ };
134
+
135
+ export const parseCanonicalModelRef = (
136
+ value: string,
137
+ ): { provider: string; modelId: string } => {
138
+ const slashIndex = value.indexOf('/');
139
+ if (slashIndex === -1) {
140
+ throw new Error(
141
+ `Invalid model reference "${value}". Expected "provider/model".`,
142
+ );
143
+ }
144
+ const provider = value.slice(0, slashIndex).trim();
145
+ const modelId = value.slice(slashIndex + 1).trim();
146
+ if (!provider || !modelId) {
147
+ throw new Error(
148
+ `Invalid model reference "${value}". Expected "provider/model".`,
149
+ );
150
+ }
151
+ return { provider, modelId };
152
+ };
153
+
154
+ export const normalizeTierConfig = (
155
+ value: unknown,
156
+ fallback: RoutedTierConfig,
157
+ profileName: string,
158
+ tier: RouterTier,
159
+ warnings: string[],
160
+ enabledModelsScope?: string[], // New parameter
161
+ ): RoutedTierConfig => {
162
+ if (!isObjectRecord(value)) {
163
+ warnings.push(
164
+ `Profile "${profileName}" has invalid ${tier} tier config. Falling back to ${fallback.model}.`,
165
+ );
166
+ return { ...fallback };
167
+ }
168
+
169
+ const model = typeof value.model === 'string' ? value.model.trim() : '';
170
+ let parsedModel = fallback.model;
171
+ if (!model) {
172
+ warnings.push(
173
+ `Profile "${profileName}" ${tier} tier is missing a model. Falling back to ${fallback.model}.`,
174
+ );
175
+ } else {
176
+ try {
177
+ parseCanonicalModelRef(model);
178
+ parsedModel = model;
179
+ if (enabledModelsScope && !enabledModelsScope.includes(parsedModel)) {
180
+ warnings.push(
181
+ `[Scope Violation] Profile "${profileName}" ${tier} tier uses '${parsedModel}', but it is not in Pi's enabledModels.`,
182
+ );
183
+ }
184
+ } catch (error) {
185
+ warnings.push(error instanceof Error ? error.message : String(error));
186
+ }
187
+ }
188
+
189
+ const thinking = isThinkingLevel(value.thinking)
190
+ ? value.thinking
191
+ : fallback.thinking;
192
+ if (value.thinking !== undefined && !isThinkingLevel(value.thinking)) {
193
+ warnings.push(
194
+ `Profile "${profileName}" ${tier} tier has invalid thinking level. Falling back to ${fallback.thinking ?? 'medium'}.`,
195
+ );
196
+ }
197
+
198
+ let fallbacks: string[] | undefined = undefined;
199
+ if (Array.isArray(value.fallbacks)) {
200
+ fallbacks = [];
201
+ for (const f of value.fallbacks) {
202
+ if (typeof f === 'string') {
203
+ try {
204
+ parseCanonicalModelRef(f);
205
+ if (enabledModelsScope && !enabledModelsScope.includes(f)) {
206
+ warnings.push(
207
+ `[Scope Violation] Profile "${profileName}" ${tier} tier fallback '${f}' is not in Pi's enabledModels.`,
208
+ );
209
+ }
210
+ fallbacks.push(f);
211
+ } catch (error) {
212
+ warnings.push(
213
+ `Invalid fallback model "${f}" in profile "${profileName}" ${tier} tier: ${error instanceof Error ? error.message : String(error)}`,
214
+ );
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ return { model: parsedModel, thinking, fallbacks };
221
+ };
222
+
223
+ export const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
224
+ const warnings: string[] = [];
225
+ const normalizedProfiles: Record<string, RouterProfile> = {};
226
+ const fallbackAuto = FALLBACK_CONFIG.profiles.auto;
227
+
228
+ const features = raw.features as RouterConfig['features'];
229
+ let enabledModelsScope: string[] | undefined = undefined;
230
+
231
+ // If strict scope validation is enabled, read Pi's settings
232
+ if (features?.respectPiScope) {
233
+ const scopeResult = readSettingsScope();
234
+ if (scopeResult.success && scopeResult.enabledModels) {
235
+ // Exclude virtual router models from the strict validation check
236
+ enabledModelsScope = scopeResult.enabledModels.filter(
237
+ (m) => !m.startsWith('router/'),
238
+ );
239
+ }
240
+ }
241
+
242
+ for (const [name, profile] of Object.entries(raw.profiles ?? {})) {
243
+ normalizedProfiles[name] = {
244
+ high: normalizeTierConfig(
245
+ profile?.high,
246
+ fallbackAuto.high,
247
+ name,
248
+ 'high',
249
+ warnings,
250
+ enabledModelsScope,
251
+ ),
252
+ medium: normalizeTierConfig(
253
+ profile?.medium,
254
+ fallbackAuto.medium,
255
+ name,
256
+ 'medium',
257
+ warnings,
258
+ enabledModelsScope,
259
+ ),
260
+ low: normalizeTierConfig(
261
+ profile?.low,
262
+ fallbackAuto.low,
263
+ name,
264
+ 'low',
265
+ warnings,
266
+ enabledModelsScope,
267
+ ),
268
+ };
269
+ }
270
+
271
+ if (Object.keys(normalizedProfiles).length === 0) {
272
+ normalizedProfiles.auto = fallbackAuto;
273
+ warnings.push(
274
+ 'No valid router profiles found. Falling back to the built-in auto profile.',
275
+ );
276
+ }
277
+
278
+ let defaultProfile =
279
+ typeof raw.defaultProfile === 'string' && raw.defaultProfile.trim()
280
+ ? raw.defaultProfile.trim()
281
+ : undefined;
282
+ if (!defaultProfile || !normalizedProfiles[defaultProfile]) {
283
+ const fallbackProfile = normalizedProfiles[
284
+ FALLBACK_CONFIG.defaultProfile ?? 'auto'
285
+ ]
286
+ ? (FALLBACK_CONFIG.defaultProfile ?? 'auto')
287
+ : Object.keys(normalizedProfiles).sort()[0];
288
+ if (defaultProfile && !normalizedProfiles[defaultProfile]) {
289
+ warnings.push(
290
+ `Default router profile "${defaultProfile}" was not found. Falling back to "${fallbackProfile}".`,
291
+ );
292
+ }
293
+ defaultProfile = fallbackProfile;
294
+ }
295
+
296
+ const phaseBias =
297
+ typeof raw.phaseBias === 'number'
298
+ ? Math.max(0, Math.min(1, raw.phaseBias))
299
+ : 0.5;
300
+
301
+ const largeContextThreshold =
302
+ typeof raw.largeContextThreshold === 'number' &&
303
+ raw.largeContextThreshold > 0
304
+ ? raw.largeContextThreshold
305
+ : undefined;
306
+
307
+ const maxSessionBudget =
308
+ typeof raw.maxSessionBudget === 'number' && raw.maxSessionBudget > 0
309
+ ? raw.maxSessionBudget
310
+ : undefined;
311
+
312
+ const rules: RoutingRule[] = [];
313
+ if (Array.isArray(raw.rules)) {
314
+ for (const rule of raw.rules) {
315
+ if (isObjectRecord(rule)) {
316
+ const matches = rule.matches;
317
+ const tier = rule.tier;
318
+ if (
319
+ (typeof matches === 'string' || Array.isArray(matches)) &&
320
+ isRouterTier(tier)
321
+ ) {
322
+ rules.push({
323
+ matches,
324
+ tier,
325
+ reason: typeof rule.reason === 'string' ? rule.reason : undefined,
326
+ });
327
+ } else {
328
+ warnings.push(
329
+ `Ignored invalid routing rule: ${JSON.stringify(rule)}`,
330
+ );
331
+ }
332
+ }
333
+ }
334
+ }
335
+
336
+ let classifierModel =
337
+ typeof raw.classifierModel === 'string'
338
+ ? raw.classifierModel.trim()
339
+ : undefined;
340
+ if (classifierModel) {
341
+ try {
342
+ parseCanonicalModelRef(classifierModel);
343
+ } catch (error) {
344
+ warnings.push(
345
+ `Invalid classifierModel: ${error instanceof Error ? error.message : String(error)}`,
346
+ );
347
+ classifierModel = undefined;
348
+ }
349
+ }
350
+
351
+ return {
352
+ config: {
353
+ defaultProfile,
354
+ debug: typeof raw.debug === 'boolean' ? raw.debug : false,
355
+ classifierModel,
356
+ phaseBias,
357
+ largeContextThreshold,
358
+ maxSessionBudget,
359
+ rules: rules.length > 0 ? rules : undefined,
360
+ profiles: normalizedProfiles,
361
+ // ─── Feature toggles (added by fork) ──────────────────────────────
362
+ features:
363
+ typeof raw.features === 'object' && raw.features !== null
364
+ ? (raw.features as RouterConfig['features'])
365
+ : undefined,
366
+ ollamaSync:
367
+ typeof raw.ollamaSync === 'object' && raw.ollamaSync !== null
368
+ ? (raw.ollamaSync as RouterConfig['ollamaSync'])
369
+ : undefined,
370
+ rateLimitFallback:
371
+ typeof raw.rateLimitFallback === 'object' &&
372
+ raw.rateLimitFallback !== null
373
+ ? (raw.rateLimitFallback as RouterConfig['rateLimitFallback'])
374
+ : undefined,
375
+ },
376
+ warnings,
377
+ };
378
+ };
379
+
380
+ export const loadRouterConfig = (cwd: string): ConfigLoadResult => {
381
+ const globalPath = join(getAgentDir(), 'model-router.json');
382
+ const projectPath = join(cwd, '.pi', 'model-router.json');
383
+ const globalResult = parseConfigFile(globalPath);
384
+ const projectResult = parseConfigFile(projectPath);
385
+ const merged = mergeConfig(
386
+ mergeConfig(FALLBACK_CONFIG, globalResult.config),
387
+ projectResult.config,
388
+ );
389
+ const normalized = normalizeConfig(merged);
390
+ return {
391
+ config: normalized.config,
392
+ warnings: [
393
+ ...globalResult.warnings,
394
+ ...projectResult.warnings,
395
+ ...normalized.warnings,
396
+ ],
397
+ };
398
+ };
399
+
400
+ export const profileNames = (config: RouterConfig): string[] => {
401
+ return Object.keys(config.profiles).sort();
402
+ };
403
+
404
+ export const resolveProfileName = (
405
+ config: RouterConfig,
406
+ requested?: string,
407
+ ): string => {
408
+ if (requested && config.profiles[requested]) {
409
+ return requested;
410
+ }
411
+ if (config.defaultProfile && config.profiles[config.defaultProfile]) {
412
+ return config.defaultProfile;
413
+ }
414
+ return profileNames(config)[0] ?? 'auto';
415
+ };
@@ -0,0 +1 @@
1
+ export const MAX_DEBUG_HISTORY = 12;