@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 +23 -1
- package/README.md +22 -4
- package/extensions/classifier.ts +93 -0
- package/extensions/commands.ts +56 -31
- package/extensions/config.ts +65 -46
- package/extensions/constants.ts +0 -46
- package/extensions/context.ts +58 -0
- package/extensions/index.ts +105 -134
- package/extensions/provider.ts +160 -155
- package/extensions/routing.ts +17 -162
- package/extensions/state.ts +90 -27
- package/extensions/types.ts +73 -45
- package/extensions/ui.ts +14 -28
- package/package.json +6 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.5.
|
|
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
|
-
|
|
3
|
+
[](https://github.com/alexei-led/pi-model-router/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@alexeiled/pi-model-router)
|
|
5
|
+
[](https://www.npmjs.com/package/@alexeiled/pi-model-router)
|
|
6
|
+
[](https://github.com/alexei-led/pi-model-router/releases)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](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
|
|
60
|
-
TypeScript
|
|
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)
|
|
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
|
+
};
|
package/extensions/commands.ts
CHANGED
|
@@ -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
|
-
|
|
140
|
-
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
313
|
+
const level = args[0];
|
|
314
|
+
if (!level) return;
|
|
315
|
+
levelValue = level;
|
|
315
316
|
tier = 'all';
|
|
316
317
|
} else if (args.length === 2) {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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' && !
|
|
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'
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
367
|
-
|
|
368
|
-
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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`,
|
package/extensions/config.ts
CHANGED
|
@@ -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'
|
|
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.
|
|
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'))
|
|
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
|
|
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 =
|
|
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
|
|
93
|
-
existing
|
|
94
|
-
next
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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:
|
|
104
|
-
override:
|
|
105
|
-
):
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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:
|
|
112
|
-
medium:
|
|
113
|
-
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
|
|
118
|
-
|
|
119
|
-
|
|
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:
|
|
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 =
|
|
318
|
-
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
}
|
|
656
|
+
const level = THINKING_LEVELS[i];
|
|
657
|
+
if (level && supported.includes(level)) return level;
|
|
639
658
|
}
|
|
640
659
|
|
|
641
660
|
return 'off';
|