@alexeiled/pi-model-router 0.5.0 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,28 @@
1
1
  # Changelog
2
2
 
3
- ## 0.5.0 (unreleased)
3
+ ## 0.5.2 — 2026-09-20
4
+
5
+ - Fix context trimming so preserved system instructions count toward the actual token estimate.
6
+ - Display the thinking level used by the completed route, not a pending profile override.
7
+ - Separate classifier, context extraction and pure routing modules; share one runtime state adapter across provider and commands.
8
+ - Validate raw configuration at the boundary and enable strict TypeScript indexing, optional-property and unused-code checks.
9
+ - Add focused Biome async-safety, import-order, cycle and Node import rules.
10
+ - Use Vitest worker threads for the small suite; remove arbitrary test sleeps and obvious test comments while preserving the full assertion set.
11
+ - Align package metadata, architecture documentation and release instructions.
12
+
13
+ ## 0.5.1 — 2026-09-20
14
+
15
+ - Delegate generation and classification through Pi's native model registry instead of duplicating auth/dispatch logic. Cover keyless and headers-only auth, native providers and credential URLs with in-memory SDK integration tests.
16
+ - Retry only before output; preserve aborts and partial output, reject unterminated streams, and record the actual fallback model.
17
+ - Use each attempted model's context limit and trim complete turns without discarding system messages or orphaning tool results.
18
+ - Resolve classifier choices against partial profiles, bound classifier requests, and reject malformed/error responses.
19
+ - Deep-copy and validate persisted state; reset snapshot deduplication across branches.
20
+ - Validate rule keywords and malformed profiles; avoid inherited-name lookups and respect non-reasoning tier declarations.
21
+ - Reject invalid debug/widget options, fix thinking completions, and avoid success notifications after failed switches.
22
+ - Replace sleep-based stream tests with real event streams; share typed fixtures and keep test helpers out of npm artifacts.
23
+ - Fail closed on npm registry lookup errors other than a missing version. Document the release and provenance verification procedure.
24
+
25
+ ## 0.5.0 — 2026-09-20
4
26
 
5
27
  This is the first release of the independently maintained `@alexeiled/pi-model-router` fork.
6
28
 
package/README.md CHANGED
@@ -1,6 +1,13 @@
1
1
  # pi-model-router
2
2
 
3
- Smart per-turn model router extension for the [pi-coding-agent](https://github.com/earendil-works/pi/tree/main/packages/coding-agent) that optimizes your AI budget and usage limits without sacrificing quality by dynamically routing each turn to the optimal LLM tier. It automatically selects between high, medium, and low-tier models based on task intent, session budget, context size, and custom rules — complete with automatic fallbacks and phase awareness.
3
+ [![CI](https://github.com/alexei-led/pi-model-router/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/alexei-led/pi-model-router/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/%40alexeiled%2Fpi-model-router?logo=npm)](https://www.npmjs.com/package/@alexeiled/pi-model-router)
5
+ [![npm downloads](https://img.shields.io/npm/dm/%40alexeiled%2Fpi-model-router?logo=npm)](https://www.npmjs.com/package/@alexeiled/pi-model-router)
6
+ [![Latest release](https://img.shields.io/github/v/release/alexei-led/pi-model-router?display_name=tag&sort=semver)](https://github.com/alexei-led/pi-model-router/releases)
7
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
+ [![Node.js >=22.19](https://img.shields.io/badge/node-%3E%3D22.19-339933?logo=node.js&logoColor=white)](package.json)
9
+
10
+ Per-turn model router for [Pi](https://github.com/earendil-works/pi/tree/main/packages/coding-agent). Selects high, medium or low-tier models using task intent, a soft budget policy and custom rules, while keeping the selected `router/<profile>` model stable.
4
11
 
5
12
  > **Independent fork:** This project is an independently maintained fork of [yeliu84/pi-model-router](https://github.com/yeliu84/pi-model-router), originally created by Ye Liu. It is not an official upstream release. The original MIT license and copyright notice are preserved.
6
13
 
@@ -56,13 +63,15 @@ npm run check
56
63
  npm test
57
64
  ```
58
65
 
59
- `npm run check` runs Biome lint, formatting and import-order checks, then the
60
- TypeScript compiler. Warnings fail the check. CI and releases use the same gate.
66
+ `npm run check` runs Biome lint, formatting, import-order and async-safety checks, then
67
+ TypeScript 7 with strict indexing, optional-property and unused-code checks. Warnings fail
68
+ the check. CI and releases use the same gate.
61
69
 
62
70
  - `npm run format` formats TypeScript and root JSON files.
63
71
  - `npm run lint` checks lint rules; `npm run lint:fix` applies safe lint fixes.
64
72
  - `npx biome check --write .` also fixes formatting and import order.
65
73
  - `npm run tsc` runs only the type checker.
74
+ - `npm test` uses Vitest worker threads; this keeps the small suite fast without weakening assertions.
66
75
 
67
76
  [Biome](https://biomejs.dev/) replaces Prettier and supplies linting in one pinned
68
77
  direct tooling dependency, without ESLint or formatter plugins. Type checking stays with
@@ -82,6 +91,15 @@ Or load directly for one run:
82
91
  pi -e ./extensions/index.ts
83
92
  ```
84
93
 
94
+ ## Reliability
95
+
96
+ - Both generation and classification use Pi's provider registry, including native/custom providers and credential-specific URLs.
97
+ - Fallbacks run only before content is emitted; cancellation does not retry.
98
+ - Classifier requests use isolated context, a 10-second cancellation deadline and a 256-token output limit. Failures retain local routing.
99
+ - Context trimming preserves system instructions and whole active tool turns. It is a text estimate, not a guarantee that images or a large active turn fit.
100
+
101
+ See [architecture](https://github.com/alexei-led/pi-model-router/blob/main/docs/ARCHITECTURE.md) and [release procedure](https://github.com/alexei-led/pi-model-router/blob/main/docs/RELEASING.md).
102
+
85
103
  ## Configuration
86
104
 
87
105
  Copy the example config to one of:
@@ -112,7 +130,7 @@ The extension stores the last selected profile in `~/.pi/agent/model-router-stat
112
130
  | Field | Description |
113
131
  | ----------------------- | --------------------------------------------------------------------------------- |
114
132
  | `classifierModel` | (Optional) Model used to categorize intent. Supports model aliases. If omitted, fast heuristics are used. |
115
- | `maxSessionBudget` | (Optional) USD budget for the session. Forces `medium` tier once exceeded. |
133
+ | `maxSessionBudget` | (Optional) Soft generation-cost threshold in USD. Downgrades high to medium, or low if medium is absent. Not a spending cap; classifier cost is excluded. |
116
134
  | `phaseBias` | (0.0 - 1.0) Stickiness of the current phase. Higher = more stable. Default `0.5`. |
117
135
  | `rules` | List of custom keyword rules (e.g. `{ "matches": "deploy", "tier": "high" }`). |
118
136
  | `models` | (Optional) Map of model aliases to definitions with `model`, `contextWindow`, `maxTokens`. |
@@ -0,0 +1,93 @@
1
+ import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
2
+ import type {
3
+ ThinkingLevel as AiThinkingLevel,
4
+ Context,
5
+ } from '@earendil-works/pi-ai';
6
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
7
+ import { isRouterTier, parseCanonicalModelRef } from './config';
8
+ import { extractTextFromContent, getRecentConversationText } from './context';
9
+ import type { RouterPhase, RouterTier } from './types';
10
+
11
+ const CLASSIFIER_TIMEOUT_MS = 10_000;
12
+ const CLASSIFIER_MAX_TOKENS = 256;
13
+
14
+ export const runClassifier = async (
15
+ classifierModelRef: string,
16
+ modelRegistry: ExtensionContext['modelRegistry'],
17
+ context: Context,
18
+ currentPhase?: RouterPhase,
19
+ thinking?: ThinkingLevel,
20
+ signal?: AbortSignal,
21
+ ): Promise<{ tier: RouterTier; reasoning: string } | undefined> => {
22
+ if (signal?.aborted) return undefined;
23
+ const { provider, modelId } = parseCanonicalModelRef(classifierModelRef);
24
+ if (provider === 'router') return undefined;
25
+ const model = modelRegistry.find(provider, modelId);
26
+ if (!model) return undefined;
27
+
28
+ const latestMessage = context.messages.at(-1);
29
+ const classifierContext: Context = {
30
+ messages: [
31
+ {
32
+ role: 'user',
33
+ content: [
34
+ {
35
+ type: 'text',
36
+ text: [
37
+ 'Classify the coding task into exactly one tier: high, medium, or low.',
38
+ 'Return exactly two lines:',
39
+ 'Tier: <high|medium|low>',
40
+ 'Reasoning: <short reason>',
41
+ `Current phase: ${currentPhase ?? 'unknown'}`,
42
+ `Recent conversation:\n${getRecentConversationText(context)}`,
43
+ `Latest request:\n${latestMessage ? extractTextFromContent(latestMessage.content) : ''}`,
44
+ ].join('\n'),
45
+ },
46
+ ],
47
+ timestamp: Date.now(),
48
+ },
49
+ ],
50
+ };
51
+ const timeout = AbortSignal.timeout(CLASSIFIER_TIMEOUT_MS);
52
+ const classifierSignal = signal
53
+ ? AbortSignal.any([signal, timeout])
54
+ : timeout;
55
+ const reasoning: AiThinkingLevel | undefined =
56
+ thinking && thinking !== 'off' ? thinking : undefined;
57
+ const stream = modelRegistry.streamSimple(model, classifierContext, {
58
+ signal: classifierSignal,
59
+ maxTokens: CLASSIFIER_MAX_TOKENS,
60
+ ...(reasoning ? { reasoning } : {}),
61
+ });
62
+
63
+ let fullText = '';
64
+ let completed = false;
65
+ for await (const event of stream) {
66
+ if (event.type === 'error') return undefined;
67
+ if (event.type === 'text_delta') fullText += event.delta;
68
+ if (event.type === 'done') {
69
+ completed = true;
70
+ fullText = extractTextFromContent(event.message.content);
71
+ break;
72
+ }
73
+ }
74
+ if (!completed) return undefined;
75
+
76
+ const tierLine = fullText
77
+ .split('\n')
78
+ .find((line) => line.toLowerCase().startsWith('tier:'));
79
+ const reasoningLine = fullText
80
+ .split('\n')
81
+ .find((line) => line.toLowerCase().startsWith('reasoning:'));
82
+ if (!tierLine || !reasoningLine) return undefined;
83
+
84
+ const tierValue = tierLine
85
+ .slice(tierLine.indexOf(':') + 1)
86
+ .trim()
87
+ .toLowerCase();
88
+ if (!isRouterTier(tierValue)) return undefined;
89
+ return {
90
+ tier: tierValue,
91
+ reasoning: reasoningLine.slice(reasoningLine.indexOf(':') + 1).trim(),
92
+ };
93
+ };
@@ -6,6 +6,9 @@ import type {
6
6
  import type { AutocompleteItem } from '@earendil-works/pi-tui';
7
7
  import {
8
8
  getUnsupportedTiers,
9
+ isRouterPinValue,
10
+ isRouterTier,
11
+ isThinkingLevel,
9
12
  parseCanonicalModelRef,
10
13
  profileNames,
11
14
  ROUTER_PIN_VALUES,
@@ -110,7 +113,7 @@ export const registerCommands = (
110
113
  args: string[],
111
114
  ): AutocompleteItem[] | null => {
112
115
  // thinking [tier] <level|auto>
113
- const tierValues = [...ROUTER_TIERS];
116
+ const tierValues: RouterTier[] = [...ROUTER_TIERS];
114
117
  const levelValues = ['auto', ...THINKING_LEVELS];
115
118
 
116
119
  if (args.length <= 1) {
@@ -136,12 +139,8 @@ export const registerCommands = (
136
139
  ];
137
140
  }
138
141
 
139
- if (levelValues.includes(args[0])) {
140
- return null;
141
- }
142
-
143
- if ((tierValues as string[]).includes(args[0])) {
144
- const tier = args[0];
142
+ const tier = args[0];
143
+ if (isRouterTier(tier)) {
145
144
  const levelPrefix = args[1] ?? '';
146
145
  return levelValues
147
146
  .filter((v) => v.startsWith(levelPrefix))
@@ -252,7 +251,7 @@ export const registerCommands = (
252
251
 
253
252
  const pinValue = args[0];
254
253
 
255
- if (!ROUTER_PIN_VALUES.some((value) => value === pinValue)) {
254
+ if (!isRouterPinValue(pinValue)) {
256
255
  ctx.ui.notify(
257
256
  `Invalid router pin: ${pinValue}. Use one of: ${ROUTER_PIN_VALUES.join(', ')}`,
258
257
  'error',
@@ -260,7 +259,8 @@ export const registerCommands = (
260
259
  return;
261
260
  }
262
261
 
263
- const nextTier = pinValue === 'auto' ? undefined : (pinValue as RouterTier);
262
+ const nextTier: RouterTier | undefined =
263
+ pinValue === 'auto' ? undefined : pinValue;
264
264
  if (nextTier) {
265
265
  state.pinnedTierByProfile[currentProfile] = nextTier;
266
266
  } else {
@@ -307,16 +307,20 @@ export const registerCommands = (
307
307
  let tier: RouterTier | 'all' | undefined;
308
308
  let levelValue = '';
309
309
 
310
- const tierValues = ['high', 'medium', 'low'];
311
310
  const levelValues = ['auto', ...THINKING_LEVELS];
312
311
 
313
312
  if (args.length === 1) {
314
- levelValue = args[0];
313
+ const level = args[0];
314
+ if (!level) return;
315
+ levelValue = level;
315
316
  tier = 'all';
316
317
  } else if (args.length === 2) {
317
- if (tierValues.includes(args[0]) || args[0] === 'all') {
318
- tier = args[0] as RouterTier | 'all';
319
- levelValue = args[1];
318
+ const requestedTier = args[0];
319
+ const requestedLevel = args[1];
320
+ if (!requestedTier || !requestedLevel) return;
321
+ if (isRouterTier(requestedTier) || requestedTier === 'all') {
322
+ tier = requestedTier === 'all' ? 'all' : requestedTier;
323
+ levelValue = requestedLevel;
320
324
  } else {
321
325
  ctx.ui.notify(
322
326
  `Invalid tier: ${args[0]}. Use high, medium, or low.`,
@@ -326,7 +330,7 @@ export const registerCommands = (
326
330
  }
327
331
  }
328
332
 
329
- if (tier !== 'all' && !tierValues.includes(tier as string)) {
333
+ if (tier !== 'all' && !tier) {
330
334
  ctx.ui.notify(
331
335
  `Invalid tier: ${tier}. Use high, medium, or low.`,
332
336
  'error',
@@ -342,10 +346,17 @@ export const registerCommands = (
342
346
  }
343
347
 
344
348
  const nextLevel =
345
- levelValue === 'auto' ? undefined : (levelValue as ThinkingLevel);
346
- state.thinkingByProfile[currentProfile] ??= {};
347
- const overrides = state.thinkingByProfile[currentProfile];
348
- const tiers = tier === 'all' ? ROUTER_TIERS : [tier as RouterTier];
349
+ levelValue === 'auto'
350
+ ? undefined
351
+ : isThinkingLevel(levelValue)
352
+ ? levelValue
353
+ : undefined;
354
+ let overrides = state.thinkingByProfile[currentProfile];
355
+ if (!overrides) {
356
+ overrides = {};
357
+ state.thinkingByProfile[currentProfile] = overrides;
358
+ }
359
+ const tiers = tier === 'all' ? ROUTER_TIERS : [tier];
349
360
  for (const targetTier of tiers) {
350
361
  if (nextLevel) overrides[targetTier] = nextLevel;
351
362
  else delete overrides[targetTier];
@@ -363,10 +374,9 @@ export const registerCommands = (
363
374
  }
364
375
  // Only warn when the level isn't supported by some tiers; skip for 'off' and 'auto'
365
376
  if (nextLevel && nextLevel !== 'off') {
366
- const unsupported = getUnsupportedTiers(
367
- state.currentConfig.profiles[currentProfile],
368
- nextLevel,
369
- );
377
+ const activeProfile = state.currentConfig.profiles[currentProfile];
378
+ if (!activeProfile) return;
379
+ const unsupported = getUnsupportedTiers(activeProfile, nextLevel);
370
380
  if (unsupported.length > 0) {
371
381
  ctx.ui.notify(
372
382
  `Router thinking (${tier}) set to ${nextLevel}. ` +
@@ -420,7 +430,7 @@ export const registerCommands = (
420
430
  return;
421
431
  }
422
432
  const tier = args[0]?.toLowerCase();
423
- if (!ROUTER_TIERS.includes(tier as RouterTier)) {
433
+ if (!isRouterTier(tier)) {
424
434
  ctx.ui.notify('Usage: /router fix <high|medium|low>', 'error');
425
435
  return;
426
436
  }
@@ -428,7 +438,7 @@ export const registerCommands = (
428
438
  ctx.ui.notify('No recent routing decision to fix.', 'warning');
429
439
  return;
430
440
  }
431
- state.pinnedTierByProfile[state.lastDecision.profile] = tier as RouterTier;
441
+ state.pinnedTierByProfile[state.lastDecision.profile] = tier;
432
442
  actions.persistState();
433
443
  actions.updateStatus(ctx);
434
444
  ctx.ui.notify(
@@ -443,6 +453,10 @@ export const registerCommands = (
443
453
  return;
444
454
  }
445
455
  const cmd = args[0]?.toLowerCase();
456
+ if (cmd && !['on', 'off', 'toggle'].includes(cmd)) {
457
+ ctx.ui.notify('Usage: /router widget <on|off|toggle>', 'error');
458
+ return;
459
+ }
446
460
  if (cmd === 'on') state.widgetEnabled = true;
447
461
  else if (cmd === 'off') state.widgetEnabled = false;
448
462
  else state.widgetEnabled = !state.widgetEnabled;
@@ -460,6 +474,10 @@ export const registerCommands = (
460
474
  return;
461
475
  }
462
476
  const cmd = args[0]?.toLowerCase();
477
+ if (cmd && !['on', 'off', 'toggle', 'clear', 'show'].includes(cmd)) {
478
+ ctx.ui.notify('Usage: /router debug <on|off|toggle|show|clear>', 'error');
479
+ return;
480
+ }
463
481
  if (cmd === 'on') state.debugEnabled = true;
464
482
  else if (cmd === 'off') state.debugEnabled = false;
465
483
  else if (cmd === 'clear') state.debugHistory.length = 0;
@@ -511,10 +529,12 @@ export const registerCommands = (
511
529
  }
512
530
 
513
531
  if (parts.length === 1 && !hasTrailingSpace) {
514
- return getSubcommandCompletions(parts[0]);
532
+ const subcommand = parts[0];
533
+ return subcommand ? getSubcommandCompletions(subcommand) : null;
515
534
  }
516
535
 
517
536
  const subcommand = parts[0];
537
+ if (!subcommand) return null;
518
538
  const subArgs = parts.slice(1);
519
539
  if (hasTrailingSpace && parts.length === 1) {
520
540
  subArgs.push('');
@@ -593,6 +613,10 @@ export const registerCommands = (
593
613
  const parts = args?.trim().split(/\s+/) ?? [];
594
614
  const subcommand = parts[0];
595
615
  const subArgs = parts.slice(1);
616
+ if (!subcommand) {
617
+ await handleStatus(subArgs, ctx);
618
+ return;
619
+ }
596
620
 
597
621
  switch (subcommand) {
598
622
  case 'profile':
@@ -656,11 +680,12 @@ export const registerCommands = (
656
680
  );
657
681
  return;
658
682
  }
659
- await actions.switchToRouterProfile(subcommand, ctx);
660
- ctx.ui.notify(
661
- `Router enabled with profile: ${state.selectedProfile}`,
662
- 'info',
663
- );
683
+ if (await actions.switchToRouterProfile(subcommand, ctx)) {
684
+ ctx.ui.notify(
685
+ `Router enabled with profile: ${state.selectedProfile}`,
686
+ 'info',
687
+ );
688
+ }
664
689
  } else {
665
690
  ctx.ui.notify(
666
691
  `Unknown router subcommand: ${subcommand}. Try /router help`,
@@ -9,6 +9,7 @@ import type {
9
9
  ConfigLoadResult,
10
10
  ModelDefinition,
11
11
  ParsedConfigFile,
12
+ RawRouterConfig,
12
13
  RoutedTierConfig,
13
14
  RouterConfig,
14
15
  RouterProfile,
@@ -19,7 +20,7 @@ import type {
19
20
  export const ROUTER_TIERS = ['high', 'medium', 'low'] as const;
20
21
 
21
22
  // Pi accepts this model capability at runtime, but older peer type releases omit it.
22
- export const MAX_THINKING_LEVEL = 'max' as ThinkingLevel;
23
+ export const MAX_THINKING_LEVEL: ThinkingLevel = 'max';
23
24
 
24
25
  export const THINKING_LEVELS: readonly ThinkingLevel[] = [
25
26
  'off',
@@ -31,6 +32,9 @@ export const THINKING_LEVELS: readonly ThinkingLevel[] = [
31
32
  MAX_THINKING_LEVEL,
32
33
  ];
33
34
  export const ROUTER_PIN_VALUES = ['auto', 'high', 'medium', 'low'] as const;
35
+ export type RouterPinValue = (typeof ROUTER_PIN_VALUES)[number];
36
+ export const isRouterPinValue = (value: unknown): value is RouterPinValue =>
37
+ ROUTER_PIN_VALUES.some((candidate) => candidate === value);
34
38
 
35
39
  export const DEFAULT_THINKING_LEVELS: readonly ThinkingLevel[] = [
36
40
  'high',
@@ -44,7 +48,7 @@ export const isObjectRecord = (
44
48
  typeof value === 'object' && value !== null && !Array.isArray(value);
45
49
 
46
50
  export const isThinkingLevel = (value: unknown): value is ThinkingLevel =>
47
- typeof value === 'string' && THINKING_LEVELS.includes(value as ThinkingLevel);
51
+ typeof value === 'string' && THINKING_LEVELS.some((level) => level === value);
48
52
 
49
53
  export const isRouterTier = (value: unknown): value is RouterTier =>
50
54
  value === 'high' || value === 'medium' || value === 'low';
@@ -55,14 +59,14 @@ export const parseConfigFile = (path: string): ParsedConfigFile => {
55
59
  }
56
60
 
57
61
  try {
58
- const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
62
+ const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8'));
59
63
  if (!isObjectRecord(parsed)) {
60
64
  return {
61
65
  config: {},
62
66
  warnings: [`Ignored router config at ${path}: expected a JSON object.`],
63
67
  };
64
68
  }
65
- return { config: parsed as Partial<RouterConfig>, warnings: [] };
69
+ return { config: parsed, warnings: [] };
66
70
  } catch (error) {
67
71
  return {
68
72
  config: {},
@@ -82,42 +86,50 @@ export const resolveModelRef = (
82
86
  ref: string,
83
87
  models: Record<string, ModelDefinition> | undefined,
84
88
  ): { canonicalRef: string; definition?: ModelDefinition } => {
85
- const definition = models?.[ref];
89
+ const definition =
90
+ models && Object.hasOwn(models, ref) ? models[ref] : undefined;
86
91
  if (definition) {
87
92
  return { canonicalRef: definition.model, definition };
88
93
  }
89
94
  return { canonicalRef: ref };
90
95
  };
91
96
 
92
- const mergeTier = (
93
- existing?: RoutedTierConfig,
94
- next?: Partial<RoutedTierConfig>,
95
- ): RoutedTierConfig | undefined => {
96
- if (!existing && !next) return undefined;
97
- if (!next) return existing;
98
- if (!existing) return next as RoutedTierConfig;
99
- return { ...existing, ...next };
97
+ const mergeRawValue = (existing: unknown, next: unknown): unknown => {
98
+ if (next === undefined) return existing;
99
+ if (isObjectRecord(existing) && isObjectRecord(next)) {
100
+ return { ...existing, ...next };
101
+ }
102
+ return next;
100
103
  };
101
104
 
102
105
  export const mergeConfig = (
103
- base: RouterConfig,
104
- override: Partial<RouterConfig>,
105
- ): RouterConfig => {
106
- const mergedProfiles: Record<string, RouterProfile> = { ...base.profiles };
107
- for (const [name, profile] of Object.entries(override.profiles ?? {})) {
108
- const existing = mergedProfiles[name];
109
- const nextProfile = profile as Partial<RouterProfile>;
106
+ base: RawRouterConfig,
107
+ override: RawRouterConfig,
108
+ ): RawRouterConfig => {
109
+ const baseProfiles = isObjectRecord(base.profiles) ? base.profiles : {};
110
+ const overrideProfiles = isObjectRecord(override.profiles)
111
+ ? override.profiles
112
+ : {};
113
+ const mergedProfiles: Record<string, unknown> = { ...baseProfiles };
114
+ for (const [name, profile] of Object.entries(overrideProfiles)) {
115
+ if (name === '__proto__') continue;
116
+ if (!isObjectRecord(profile)) {
117
+ mergedProfiles[name] = profile;
118
+ continue;
119
+ }
120
+ const existing = isObjectRecord(mergedProfiles[name])
121
+ ? mergedProfiles[name]
122
+ : {};
110
123
  mergedProfiles[name] = {
111
- high: mergeTier(existing?.high, nextProfile.high),
112
- medium: mergeTier(existing?.medium, nextProfile.medium),
113
- low: mergeTier(existing?.low, nextProfile.low),
124
+ high: mergeRawValue(existing.high, profile.high),
125
+ medium: mergeRawValue(existing.medium, profile.medium),
126
+ low: mergeRawValue(existing.low, profile.low),
114
127
  };
115
128
  }
116
129
 
117
- const mergedModels: Record<string, ModelDefinition> = {
118
- ...(base.models ?? {}),
119
- ...(override.models ?? {}),
120
- };
130
+ const baseModels = isObjectRecord(base.models) ? base.models : {};
131
+ const overrideModels = isObjectRecord(override.models) ? override.models : {};
132
+ const mergedModels = { ...baseModels, ...overrideModels };
121
133
 
122
134
  return {
123
135
  debug: override.debug ?? base.debug,
@@ -153,13 +165,14 @@ export const parseCanonicalModelRef = (
153
165
  * Validate and normalize the models map from config.
154
166
  */
155
167
  export const normalizeModelsMap = (
156
- raw: Record<string, unknown> | undefined,
168
+ raw: unknown,
157
169
  warnings: string[],
158
170
  ): Record<string, ModelDefinition> => {
159
171
  const result: Record<string, ModelDefinition> = {};
160
172
  if (!raw || !isObjectRecord(raw)) return result;
161
173
 
162
174
  for (const [alias, entry] of Object.entries(raw)) {
175
+ if (alias === '__proto__') continue;
163
176
  if (!isObjectRecord(entry)) {
164
177
  warnings.push(
165
178
  `Ignored invalid model definition "${alias}": expected an object.`,
@@ -314,8 +327,8 @@ export const normalizeTierConfig = (
314
327
  // Validate tier-level thinkingLevels array
315
328
  let tierThinkingLevels: ThinkingLevel[] | undefined;
316
329
  if (Array.isArray(value.thinkingLevels)) {
317
- tierThinkingLevels = (value.thinkingLevels as unknown[]).filter(
318
- (l): l is ThinkingLevel => isThinkingLevel(l),
330
+ tierThinkingLevels = value.thinkingLevels.filter((l): l is ThinkingLevel =>
331
+ isThinkingLevel(l),
319
332
  );
320
333
  if (tierThinkingLevels.length === 0) tierThinkingLevels = undefined;
321
334
  }
@@ -331,6 +344,7 @@ export const normalizeTierConfig = (
331
344
  const resolvedThinkingLevels: ThinkingLevel[] = [...baseThinkingLevels];
332
345
  if (
333
346
  !explicitThinkingLevels &&
347
+ effectiveReasoning !== false &&
334
348
  thinking !== 'off' &&
335
349
  !resolvedThinkingLevels.includes(thinking)
336
350
  ) {
@@ -351,35 +365,36 @@ export const normalizeTierConfig = (
351
365
  };
352
366
  };
353
367
 
354
- export const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
368
+ export const normalizeConfig = (raw: RawRouterConfig): ConfigLoadResult => {
355
369
  const warnings: string[] = [];
356
370
 
357
371
  // Normalize models map first so aliases are available during tier normalization
358
- const normalizedModels = normalizeModelsMap(
359
- raw.models as Record<string, unknown> | undefined,
360
- warnings,
361
- );
372
+ const normalizedModels = normalizeModelsMap(raw.models, warnings);
362
373
  const hasModels = Object.keys(normalizedModels).length > 0;
363
374
 
364
375
  const normalizedProfiles: Record<string, RouterProfile> = {};
365
376
 
366
- for (const [name, profile] of Object.entries(raw.profiles ?? {})) {
377
+ for (const [name, profile] of Object.entries(
378
+ isObjectRecord(raw.profiles) ? raw.profiles : {},
379
+ )) {
380
+ if (name === '__proto__') continue;
381
+ const profileRecord = isObjectRecord(profile) ? profile : {};
367
382
  const high = normalizeTierConfig(
368
- profile?.high,
383
+ profileRecord.high,
369
384
  name,
370
385
  'high',
371
386
  warnings,
372
387
  hasModels ? normalizedModels : undefined,
373
388
  );
374
389
  const medium = normalizeTierConfig(
375
- profile?.medium,
390
+ profileRecord.medium,
376
391
  name,
377
392
  'medium',
378
393
  warnings,
379
394
  hasModels ? normalizedModels : undefined,
380
395
  );
381
396
  const low = normalizeTierConfig(
382
- profile?.low,
397
+ profileRecord.low,
383
398
  name,
384
399
  'low',
385
400
  warnings,
@@ -411,7 +426,12 @@ export const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
411
426
  const matches = rule.matches;
412
427
  const tier = rule.tier;
413
428
  if (
414
- (typeof matches === 'string' || Array.isArray(matches)) &&
429
+ ((typeof matches === 'string' && matches.trim().length > 0) ||
430
+ (Array.isArray(matches) &&
431
+ matches.length > 0 &&
432
+ matches.every(
433
+ (m) => typeof m === 'string' && m.trim().length > 0,
434
+ ))) &&
415
435
  isRouterTier(tier)
416
436
  ) {
417
437
  rules.push({
@@ -430,7 +450,7 @@ export const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
430
450
 
431
451
  // Resolve classifierModel — accepts string or { model, thinking } object
432
452
  let classifierModel: ClassifierConfig | undefined;
433
- const rawClassifier = raw.classifierModel as unknown;
453
+ const rawClassifier = raw.classifierModel;
434
454
  if (typeof rawClassifier === 'string' && rawClassifier.trim()) {
435
455
  const resolved = resolveModelRef(
436
456
  rawClassifier.trim(),
@@ -494,7 +514,7 @@ export const loadRouterConfig = (cwd: string): ConfigLoadResult => {
494
514
  const projectPath = join(cwd, '.pi', 'model-router.json');
495
515
  const globalResult = parseConfigFile(globalPath);
496
516
  const projectResult = parseConfigFile(projectPath);
497
- const baseConfig: RouterConfig = { profiles: {} };
517
+ const baseConfig: RawRouterConfig = { profiles: {} };
498
518
  const merged = mergeConfig(
499
519
  mergeConfig(baseConfig, globalResult.config),
500
520
  projectResult.config,
@@ -518,7 +538,7 @@ export const resolveProfileName = (
518
538
  config: RouterConfig,
519
539
  requested?: string,
520
540
  ): string | undefined => {
521
- if (requested && config.profiles[requested]) {
541
+ if (requested && Object.hasOwn(config.profiles, requested)) {
522
542
  return requested;
523
543
  }
524
544
  return undefined;
@@ -633,9 +653,8 @@ export const clampThinkingLevel = (
633
653
 
634
654
  const reqIdx = THINKING_LEVELS.indexOf(requested);
635
655
  for (let i = reqIdx; i >= 0; i--) {
636
- if (supported.includes(THINKING_LEVELS[i])) {
637
- return THINKING_LEVELS[i];
638
- }
656
+ const level = THINKING_LEVELS[i];
657
+ if (level && supported.includes(level)) return level;
639
658
  }
640
659
 
641
660
  return 'off';