@alexeiled/pi-model-router 0.5.1 → 0.6.0

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.
@@ -7,19 +7,22 @@ import { DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS } from './constants';
7
7
  import type {
8
8
  ClassifierConfig,
9
9
  ConfigLoadResult,
10
+ JevConfig,
10
11
  ModelDefinition,
11
12
  ParsedConfigFile,
13
+ RawRouterConfig,
12
14
  RoutedTierConfig,
13
15
  RouterConfig,
14
16
  RouterProfile,
15
17
  RouterTier,
16
- RoutingRule,
17
18
  } from './types';
18
19
 
19
- export const ROUTER_TIERS = ['high', 'medium', 'low'] as const;
20
+ import { ROUTER_TIERS } from './types';
21
+
22
+ export { ROUTER_TIERS } from './types';
20
23
 
21
24
  // Pi accepts this model capability at runtime, but older peer type releases omit it.
22
- export const MAX_THINKING_LEVEL = 'max' as ThinkingLevel;
25
+ export const MAX_THINKING_LEVEL: ThinkingLevel = 'max';
23
26
 
24
27
  export const THINKING_LEVELS: readonly ThinkingLevel[] = [
25
28
  'off',
@@ -30,7 +33,10 @@ export const THINKING_LEVELS: readonly ThinkingLevel[] = [
30
33
  'xhigh',
31
34
  MAX_THINKING_LEVEL,
32
35
  ];
33
- export const ROUTER_PIN_VALUES = ['auto', 'high', 'medium', 'low'] as const;
36
+ export const ROUTER_PIN_VALUES = ['auto', ...ROUTER_TIERS] as const;
37
+ export type RouterPinValue = (typeof ROUTER_PIN_VALUES)[number];
38
+ export const isRouterPinValue = (value: unknown): value is RouterPinValue =>
39
+ ROUTER_PIN_VALUES.some((candidate) => candidate === value);
34
40
 
35
41
  export const DEFAULT_THINKING_LEVELS: readonly ThinkingLevel[] = [
36
42
  'high',
@@ -44,10 +50,10 @@ export const isObjectRecord = (
44
50
  typeof value === 'object' && value !== null && !Array.isArray(value);
45
51
 
46
52
  export const isThinkingLevel = (value: unknown): value is ThinkingLevel =>
47
- typeof value === 'string' && THINKING_LEVELS.includes(value as ThinkingLevel);
53
+ typeof value === 'string' && THINKING_LEVELS.some((level) => level === value);
48
54
 
49
55
  export const isRouterTier = (value: unknown): value is RouterTier =>
50
- value === 'high' || value === 'medium' || value === 'low';
56
+ ROUTER_TIERS.some((tier) => tier === value);
51
57
 
52
58
  export const parseConfigFile = (path: string): ParsedConfigFile => {
53
59
  if (!existsSync(path)) {
@@ -55,20 +61,19 @@ export const parseConfigFile = (path: string): ParsedConfigFile => {
55
61
  }
56
62
 
57
63
  try {
58
- const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
64
+ const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8'));
59
65
  if (!isObjectRecord(parsed)) {
60
66
  return {
61
67
  config: {},
62
68
  warnings: [`Ignored router config at ${path}: expected a JSON object.`],
63
69
  };
64
70
  }
65
- return { config: parsed as Partial<RouterConfig>, warnings: [] };
66
- } catch (error) {
71
+ return { config: parsed, warnings: [] };
72
+ } catch {
73
+ // JSON parse errors can include source snippets containing credentials.
67
74
  return {
68
75
  config: {},
69
- warnings: [
70
- `Failed to parse router config at ${path}: ${error instanceof Error ? error.message : String(error)}`,
71
- ],
76
+ warnings: [`Failed to parse router config at ${path}.`],
72
77
  };
73
78
  }
74
79
  };
@@ -90,42 +95,48 @@ export const resolveModelRef = (
90
95
  return { canonicalRef: ref };
91
96
  };
92
97
 
93
- const mergeTier = (
94
- existing?: RoutedTierConfig,
95
- next?: Partial<RoutedTierConfig>,
96
- ): RoutedTierConfig | undefined => {
97
- if (!existing && !next) return undefined;
98
- if (!next) return existing;
99
- if (!existing) return next as RoutedTierConfig;
100
- return { ...existing, ...next };
98
+ const mergeRawValue = (existing: unknown, next: unknown): unknown => {
99
+ if (next === undefined) return existing;
100
+ if (isObjectRecord(existing) && isObjectRecord(next)) {
101
+ return { ...existing, ...next };
102
+ }
103
+ return next;
101
104
  };
102
105
 
103
106
  export const mergeConfig = (
104
- base: RouterConfig,
105
- override: Partial<RouterConfig>,
106
- ): RouterConfig => {
107
- const mergedProfiles: Record<string, RouterProfile> = { ...base.profiles };
108
- for (const [name, profile] of Object.entries(
109
- isObjectRecord(override.profiles) ? override.profiles : {},
110
- )) {
111
- if (!isObjectRecord(profile) || name === '__proto__') continue;
112
- const existing = Object.hasOwn(mergedProfiles, name)
107
+ base: RawRouterConfig,
108
+ override: RawRouterConfig,
109
+ ): RawRouterConfig => {
110
+ const baseProfiles = isObjectRecord(base.profiles) ? base.profiles : {};
111
+ const overrideProfiles = isObjectRecord(override.profiles)
112
+ ? override.profiles
113
+ : {};
114
+ const mergedProfiles: Record<string, unknown> = { ...baseProfiles };
115
+ for (const [name, profile] of Object.entries(overrideProfiles)) {
116
+ if (name === '__proto__') continue;
117
+ if (!isObjectRecord(profile)) {
118
+ mergedProfiles[name] = profile;
119
+ continue;
120
+ }
121
+ const existing = isObjectRecord(mergedProfiles[name])
113
122
  ? mergedProfiles[name]
114
- : undefined;
115
- const nextProfile = profile as Partial<RouterProfile>;
123
+ : {};
116
124
  mergedProfiles[name] = {
117
- high: mergeTier(existing?.high, nextProfile.high),
118
- medium: mergeTier(existing?.medium, nextProfile.medium),
119
- low: mergeTier(existing?.low, nextProfile.low),
125
+ baselineTier: mergeRawValue(existing.baselineTier, profile.baselineTier),
126
+ high: mergeRawValue(existing.high, profile.high),
127
+ medium: mergeRawValue(existing.medium, profile.medium),
128
+ low: mergeRawValue(existing.low, profile.low),
129
+ micro: mergeRawValue(existing.micro, profile.micro),
130
+ jev: mergeRawValue(existing.jev, profile.jev),
120
131
  };
121
132
  }
122
133
 
123
- const mergedModels: Record<string, ModelDefinition> = {
124
- ...(base.models ?? {}),
125
- ...(isObjectRecord(override.models) ? override.models : {}),
126
- };
134
+ const baseModels = isObjectRecord(base.models) ? base.models : {};
135
+ const overrideModels = isObjectRecord(override.models) ? override.models : {};
136
+ const mergedModels = { ...baseModels, ...overrideModels };
127
137
 
128
138
  return {
139
+ jev: mergeRawValue(base.jev, override.jev),
129
140
  debug: override.debug ?? base.debug,
130
141
  classifierModel: override.classifierModel ?? base.classifierModel,
131
142
  phaseBias: override.phaseBias ?? base.phaseBias,
@@ -141,16 +152,12 @@ export const parseCanonicalModelRef = (
141
152
  ): { provider: string; modelId: string } => {
142
153
  const slashIndex = value.indexOf('/');
143
154
  if (slashIndex === -1) {
144
- throw new Error(
145
- `Invalid model reference "${value}". Expected "provider/model".`,
146
- );
155
+ throw new Error('Invalid model reference. Expected "provider/model".');
147
156
  }
148
157
  const provider = value.slice(0, slashIndex).trim();
149
158
  const modelId = value.slice(slashIndex + 1).trim();
150
159
  if (!provider || !modelId) {
151
- throw new Error(
152
- `Invalid model reference "${value}". Expected "provider/model".`,
153
- );
160
+ throw new Error('Invalid model reference. Expected "provider/model".');
154
161
  }
155
162
  return { provider, modelId };
156
163
  };
@@ -159,7 +166,7 @@ export const parseCanonicalModelRef = (
159
166
  * Validate and normalize the models map from config.
160
167
  */
161
168
  export const normalizeModelsMap = (
162
- raw: Record<string, unknown> | undefined,
169
+ raw: unknown,
163
170
  warnings: string[],
164
171
  ): Record<string, ModelDefinition> => {
165
172
  const result: Record<string, ModelDefinition> = {};
@@ -174,7 +181,7 @@ export const normalizeModelsMap = (
174
181
  continue;
175
182
  }
176
183
 
177
- const model = typeof entry.model === 'string' ? entry.model.trim() : '';
184
+ let model = typeof entry.model === 'string' ? entry.model.trim() : '';
178
185
  if (!model) {
179
186
  warnings.push(
180
187
  `Model definition "${alias}" is missing the "model" field. Skipped.`,
@@ -183,10 +190,11 @@ export const normalizeModelsMap = (
183
190
  }
184
191
 
185
192
  try {
186
- parseCanonicalModelRef(model);
187
- } catch (error) {
193
+ const { provider, modelId } = parseCanonicalModelRef(model);
194
+ model = `${provider}/${modelId}`;
195
+ } catch {
188
196
  warnings.push(
189
- `Model definition "${alias}": ${error instanceof Error ? error.message : String(error)}`,
197
+ `Model definition "${alias}" has an invalid model reference. Skipped.`,
190
198
  );
191
199
  continue;
192
200
  }
@@ -259,23 +267,31 @@ export const normalizeTierConfig = (
259
267
  const aliasDefinition = resolved.definition;
260
268
  let parsedModel: string;
261
269
  try {
262
- parseCanonicalModelRef(resolved.canonicalRef);
263
- parsedModel = resolved.canonicalRef;
264
- } catch (error) {
270
+ const { provider, modelId } = parseCanonicalModelRef(resolved.canonicalRef);
271
+ parsedModel = `${provider}/${modelId}`;
272
+ } catch {
265
273
  warnings.push(
266
- `Profile "${profileName}" ${tier} tier: ${error instanceof Error ? error.message : String(error)} Tier disabled.`,
274
+ `Profile "${profileName}" ${tier} tier has an invalid model reference. Tier disabled.`,
267
275
  );
268
276
  return undefined;
269
277
  }
270
278
 
271
- const thinking = isThinkingLevel(value.thinking) ? value.thinking : 'medium';
279
+ const tierReasoning =
280
+ typeof value.reasoning === 'boolean' ? value.reasoning : undefined;
281
+ const effectiveReasoning = tierReasoning ?? aliasDefinition?.reasoning;
282
+ const defaultThinking =
283
+ tier === 'micro' || effectiveReasoning === false ? 'off' : 'medium';
284
+ const thinking = isThinkingLevel(value.thinking)
285
+ ? value.thinking
286
+ : defaultThinking;
272
287
  if (value.thinking !== undefined && !isThinkingLevel(value.thinking)) {
273
288
  warnings.push(
274
- `Profile "${profileName}" ${tier} tier has invalid thinking level. Defaulting to medium.`,
289
+ `Profile "${profileName}" ${tier} tier has invalid thinking level. Defaulting to ${defaultThinking}.`,
275
290
  );
276
291
  }
277
292
 
278
293
  let fallbacks: string[] | undefined;
294
+ const resolvedFallbacks: ModelDefinition[] = [];
279
295
  if (Array.isArray(value.fallbacks)) {
280
296
  fallbacks = [];
281
297
  for (const f of value.fallbacks) {
@@ -283,11 +299,15 @@ export const normalizeTierConfig = (
283
299
  // Resolve aliases in fallbacks too
284
300
  const resolvedFallback = resolveModelRef(f, models);
285
301
  try {
286
- parseCanonicalModelRef(resolvedFallback.canonicalRef);
287
- fallbacks.push(resolvedFallback.canonicalRef);
288
- } catch (error) {
302
+ const { provider, modelId } = parseCanonicalModelRef(
303
+ resolvedFallback.canonicalRef,
304
+ );
305
+ const model = `${provider}/${modelId}`;
306
+ fallbacks.push(model);
307
+ resolvedFallbacks.push({ ...resolvedFallback.definition, model });
308
+ } catch {
289
309
  warnings.push(
290
- `Invalid fallback model "${f}" in profile "${profileName}" ${tier} tier: ${error instanceof Error ? error.message : String(error)}`,
310
+ `Invalid fallback model in profile "${profileName}" ${tier} tier. Ignored.`,
291
311
  );
292
312
  }
293
313
  }
@@ -312,17 +332,12 @@ export const normalizeTierConfig = (
312
332
  const resolvedMaxTokens =
313
333
  tierMaxTokens ?? aliasDefinition?.maxTokens ?? DEFAULT_MAX_TOKENS;
314
334
 
315
- // Resolve reasoning: tier config > alias > undefined (assumed true)
316
- const tierReasoning =
317
- typeof value.reasoning === 'boolean' ? value.reasoning : undefined;
318
- const effectiveReasoning = tierReasoning ?? aliasDefinition?.reasoning;
319
-
320
335
  // Resolve thinkingLevels: tier config > alias > default
321
336
  // Validate tier-level thinkingLevels array
322
337
  let tierThinkingLevels: ThinkingLevel[] | undefined;
323
338
  if (Array.isArray(value.thinkingLevels)) {
324
- tierThinkingLevels = (value.thinkingLevels as unknown[]).filter(
325
- (l): l is ThinkingLevel => isThinkingLevel(l),
339
+ tierThinkingLevels = value.thinkingLevels.filter((l): l is ThinkingLevel =>
340
+ isThinkingLevel(l),
326
341
  );
327
342
  if (tierThinkingLevels.length === 0) tierThinkingLevels = undefined;
328
343
  }
@@ -347,11 +362,13 @@ export const normalizeTierConfig = (
347
362
 
348
363
  return {
349
364
  model: parsedModel,
365
+ thinkingExplicit: isThinkingLevel(value.thinking),
350
366
  thinking,
351
367
  fallbacks,
368
+ resolvedFallbacks,
352
369
  contextWindow: tierContextWindow,
353
370
  maxTokens: tierMaxTokens,
354
- reasoning: tierReasoning,
371
+ reasoning: effectiveReasoning,
355
372
  thinkingLevels: tierThinkingLevels,
356
373
  resolvedContextWindow,
357
374
  resolvedMaxTokens,
@@ -359,14 +376,113 @@ export const normalizeTierConfig = (
359
376
  };
360
377
  };
361
378
 
362
- export const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
379
+ export const DEFAULT_JEV_CONFIG = {
380
+ endpoint: 'https://api.typesafe.ai/v1/systemone',
381
+ model: 'jev-1.13.0',
382
+ timeoutMs: 750,
383
+ confidenceThreshold: 0.65,
384
+ maxStateChars: 12000,
385
+ mode: 'advisory',
386
+ } as const;
387
+
388
+ export const isJevEndpoint = (value: unknown): value is string => {
389
+ if (typeof value !== 'string') return false;
390
+ try {
391
+ const url = new URL(value);
392
+ return (
393
+ url.protocol === 'https:' &&
394
+ !url.username &&
395
+ !url.password &&
396
+ !url.search &&
397
+ !url.hash
398
+ );
399
+ } catch {
400
+ return false;
401
+ }
402
+ };
403
+
404
+ export const normalizeJevConfig = (
405
+ raw: unknown,
406
+ warnings: string[],
407
+ ): JevConfig | undefined => {
408
+ if (raw === undefined) return undefined;
409
+ const invalid = (): undefined => {
410
+ warnings.push('Ignored invalid Jev configuration.');
411
+ return undefined;
412
+ };
413
+ if (!isObjectRecord(raw)) return invalid();
414
+ const value: Record<string, unknown> = { ...DEFAULT_JEV_CONFIG, ...raw };
415
+ if (
416
+ (value.enabled !== undefined && typeof value.enabled !== 'boolean') ||
417
+ !isJevEndpoint(value.endpoint) ||
418
+ typeof value.model !== 'string' ||
419
+ !/^[a-zA-Z0-9._-]{1,128}$/.test(value.model) ||
420
+ typeof value.timeoutMs !== 'number' ||
421
+ !Number.isFinite(value.timeoutMs) ||
422
+ value.timeoutMs <= 0 ||
423
+ value.timeoutMs > 1500 ||
424
+ typeof value.confidenceThreshold !== 'number' ||
425
+ !Number.isFinite(value.confidenceThreshold) ||
426
+ value.confidenceThreshold < 0 ||
427
+ value.confidenceThreshold > 1 ||
428
+ typeof value.maxStateChars !== 'number' ||
429
+ !Number.isInteger(value.maxStateChars) ||
430
+ value.maxStateChars < 1 ||
431
+ value.maxStateChars > 12000 ||
432
+ value.mode !== 'advisory' ||
433
+ (value.apiKey !== undefined &&
434
+ (typeof value.apiKey !== 'string' || /[\r\n]/.test(value.apiKey)))
435
+ )
436
+ return invalid();
437
+ if (value.timeoutMs > 750)
438
+ warnings.push(
439
+ 'Jev timeoutMs clamped to the effective 750 ms provider cap.',
440
+ );
441
+ const apiKey = typeof value.apiKey === 'string' ? value.apiKey.trim() : '';
442
+ if (value.enabled === true && !apiKey) {
443
+ warnings.push('Jev disabled: missing user-config API key.');
444
+ }
445
+ return {
446
+ enabled: value.enabled === true && apiKey.length > 0,
447
+ apiKey,
448
+ endpoint: value.endpoint,
449
+ model: value.model,
450
+ timeoutMs: Math.min(value.timeoutMs, 750),
451
+ confidenceThreshold: value.confidenceThreshold,
452
+ maxStateChars: value.maxStateChars,
453
+ mode: 'advisory',
454
+ };
455
+ };
456
+
457
+ // Remove every project Jev setting before merging with user-owned credentials.
458
+ export const stripProjectJevConfig = (
459
+ raw: RawRouterConfig,
460
+ warnings: string[],
461
+ ): RawRouterConfig => {
462
+ const { jev: ignored, ...project } = raw;
463
+ let found = ignored !== undefined;
464
+ if (isObjectRecord(project.profiles)) {
465
+ project.profiles = Object.fromEntries(
466
+ Object.entries(project.profiles).map(([name, profile]) => {
467
+ if (!isObjectRecord(profile)) return [name, profile];
468
+ const { jev, ...tiers } = profile;
469
+ if (jev !== undefined) found = true;
470
+ return [name, tiers];
471
+ }),
472
+ );
473
+ }
474
+ if (found)
475
+ warnings.push(
476
+ 'Ignored project Jev settings: configure Jev only in user config.',
477
+ );
478
+ return project;
479
+ };
480
+
481
+ export const normalizeConfig = (raw: RawRouterConfig): ConfigLoadResult => {
363
482
  const warnings: string[] = [];
364
483
 
365
484
  // Normalize models map first so aliases are available during tier normalization
366
- const normalizedModels = normalizeModelsMap(
367
- raw.models as Record<string, unknown> | undefined,
368
- warnings,
369
- );
485
+ const normalizedModels = normalizeModelsMap(raw.models, warnings);
370
486
  const hasModels = Object.keys(normalizedModels).length > 0;
371
487
 
372
488
  const normalizedProfiles: Record<string, RouterProfile> = {};
@@ -375,78 +491,83 @@ export const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
375
491
  isObjectRecord(raw.profiles) ? raw.profiles : {},
376
492
  )) {
377
493
  if (name === '__proto__') continue;
494
+ const profileRecord = isObjectRecord(profile) ? profile : {};
378
495
  const high = normalizeTierConfig(
379
- profile?.high,
496
+ profileRecord.high,
380
497
  name,
381
498
  'high',
382
499
  warnings,
383
500
  hasModels ? normalizedModels : undefined,
384
501
  );
385
502
  const medium = normalizeTierConfig(
386
- profile?.medium,
503
+ profileRecord.medium,
387
504
  name,
388
505
  'medium',
389
506
  warnings,
390
507
  hasModels ? normalizedModels : undefined,
391
508
  );
392
509
  const low = normalizeTierConfig(
393
- profile?.low,
510
+ profileRecord.low,
394
511
  name,
395
512
  'low',
396
513
  warnings,
397
514
  hasModels ? normalizedModels : undefined,
398
515
  );
399
516
 
400
- if (!high && !medium && !low) {
517
+ const micro = normalizeTierConfig(
518
+ profileRecord.micro,
519
+ name,
520
+ 'micro',
521
+ warnings,
522
+ hasModels ? normalizedModels : undefined,
523
+ );
524
+
525
+ if (!high && !medium && !low && !micro) {
401
526
  warnings.push(`Profile "${name}" has no valid tiers. Skipped.`);
402
527
  continue;
403
528
  }
404
529
 
405
- normalizedProfiles[name] = { high, medium, low };
530
+ let baselineTier: RouterTier | undefined;
531
+ if (profileRecord.baselineTier !== undefined) {
532
+ const candidate = profileRecord.baselineTier;
533
+ const normalizedTier = isRouterTier(candidate)
534
+ ? { high, medium, low, micro }[candidate]
535
+ : undefined;
536
+ if (isRouterTier(candidate) && normalizedTier) {
537
+ baselineTier = candidate;
538
+ } else {
539
+ warnings.push(
540
+ `Profile "${name}" baselineTier must name a configured tier. Ignored.`,
541
+ );
542
+ }
543
+ }
544
+
545
+ const jev = isObjectRecord(profileRecord.jev)
546
+ ? { enabled: profileRecord.jev.enabled === true }
547
+ : undefined;
548
+ normalizedProfiles[name] = {
549
+ ...(baselineTier ? { baselineTier } : {}),
550
+ high,
551
+ medium,
552
+ low,
553
+ micro,
554
+ jev,
555
+ };
406
556
  }
407
557
 
408
- const phaseBias =
409
- typeof raw.phaseBias === 'number'
410
- ? Math.max(0, Math.min(1, raw.phaseBias))
411
- : 0.5;
558
+ if (raw.phaseBias !== undefined)
559
+ warnings.push('Deprecated router config field "phaseBias" ignored.');
560
+ if (raw.rules !== undefined)
561
+ warnings.push('Deprecated router config field "rules" ignored.');
412
562
 
413
563
  const maxSessionBudget =
414
564
  typeof raw.maxSessionBudget === 'number' && raw.maxSessionBudget > 0
415
565
  ? raw.maxSessionBudget
416
566
  : undefined;
417
567
 
418
- const rules: RoutingRule[] = [];
419
- if (Array.isArray(raw.rules)) {
420
- for (const rule of raw.rules) {
421
- if (isObjectRecord(rule)) {
422
- const matches = rule.matches;
423
- const tier = rule.tier;
424
- if (
425
- ((typeof matches === 'string' && matches.trim().length > 0) ||
426
- (Array.isArray(matches) &&
427
- matches.length > 0 &&
428
- matches.every(
429
- (m) => typeof m === 'string' && m.trim().length > 0,
430
- ))) &&
431
- isRouterTier(tier)
432
- ) {
433
- rules.push({
434
- matches,
435
- tier,
436
- reason: typeof rule.reason === 'string' ? rule.reason : undefined,
437
- });
438
- } else {
439
- warnings.push(
440
- `Ignored invalid routing rule: ${JSON.stringify(rule)}`,
441
- );
442
- }
443
- }
444
- }
445
- }
446
-
447
568
  // Resolve classifierModel — accepts string or { model, thinking } object
448
569
  let classifierModel: ClassifierConfig | undefined;
449
- const rawClassifier = raw.classifierModel as unknown;
570
+ const rawClassifier = raw.classifierModel;
450
571
  if (typeof rawClassifier === 'string' && rawClassifier.trim()) {
451
572
  const resolved = resolveModelRef(
452
573
  rawClassifier.trim(),
@@ -455,10 +576,8 @@ export const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
455
576
  try {
456
577
  parseCanonicalModelRef(resolved.canonicalRef);
457
578
  classifierModel = { model: resolved.canonicalRef };
458
- } catch (error) {
459
- warnings.push(
460
- `Invalid classifierModel: ${error instanceof Error ? error.message : String(error)}`,
461
- );
579
+ } catch {
580
+ warnings.push('Invalid classifierModel model reference. Ignored.');
462
581
  }
463
582
  } else if (isObjectRecord(rawClassifier)) {
464
583
  const modelRef =
@@ -475,14 +594,12 @@ export const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
475
594
  : undefined;
476
595
  if (rawClassifier.thinking !== undefined && !thinking) {
477
596
  warnings.push(
478
- `classifierModel has invalid thinking level "${String(rawClassifier.thinking)}". Ignored.`,
597
+ 'classifierModel has an invalid thinking level. Ignored.',
479
598
  );
480
599
  }
481
600
  classifierModel = { model: resolved.canonicalRef, thinking };
482
- } catch (error) {
483
- warnings.push(
484
- `Invalid classifierModel: ${error instanceof Error ? error.message : String(error)}`,
485
- );
601
+ } catch {
602
+ warnings.push('Invalid classifierModel model reference. Ignored.');
486
603
  }
487
604
  } else {
488
605
  warnings.push(
@@ -493,11 +610,10 @@ export const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
493
610
 
494
611
  return {
495
612
  config: {
613
+ jev: normalizeJevConfig(raw.jev, warnings),
496
614
  debug: typeof raw.debug === 'boolean' ? raw.debug : false,
497
615
  classifierModel,
498
- phaseBias,
499
616
  maxSessionBudget,
500
- rules: rules.length > 0 ? rules : undefined,
501
617
  profiles: normalizedProfiles,
502
618
  models: hasModels ? normalizedModels : undefined,
503
619
  },
@@ -510,10 +626,10 @@ export const loadRouterConfig = (cwd: string): ConfigLoadResult => {
510
626
  const projectPath = join(cwd, '.pi', 'model-router.json');
511
627
  const globalResult = parseConfigFile(globalPath);
512
628
  const projectResult = parseConfigFile(projectPath);
513
- const baseConfig: RouterConfig = { profiles: {} };
629
+ const baseConfig: RawRouterConfig = { profiles: {} };
514
630
  const merged = mergeConfig(
515
631
  mergeConfig(baseConfig, globalResult.config),
516
- projectResult.config,
632
+ stripProjectJevConfig(projectResult.config, projectResult.warnings),
517
633
  );
518
634
  const normalized = normalizeConfig(merged);
519
635
  return {
@@ -634,25 +750,3 @@ export const getUnsupportedTiers = (
634
750
  }
635
751
  return unsupported;
636
752
  };
637
-
638
- /**
639
- * Clamps a requested thinking level to the highest supported level
640
- * in the provided array of supported levels.
641
- */
642
- export const clampThinkingLevel = (
643
- requested: ThinkingLevel,
644
- supported: ThinkingLevel[] | undefined,
645
- ): ThinkingLevel => {
646
- if (requested === 'off' || !supported || supported.length === 0) {
647
- return 'off';
648
- }
649
-
650
- const reqIdx = THINKING_LEVELS.indexOf(requested);
651
- for (let i = reqIdx; i >= 0; i--) {
652
- if (supported.includes(THINKING_LEVELS[i])) {
653
- return THINKING_LEVELS[i];
654
- }
655
- }
656
-
657
- return 'off';
658
- };