@alexeiled/pi-model-router 0.5.1 → 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 +10 -0
- package/README.md +4 -2
- package/extensions/classifier.ts +93 -0
- package/extensions/commands.ts +42 -22
- package/extensions/config.ts +49 -46
- package/extensions/context.ts +58 -0
- package/extensions/index.ts +96 -127
- package/extensions/provider.ts +54 -41
- package/extensions/routing.ts +13 -156
- package/extensions/state.ts +21 -15
- package/extensions/types.ts +73 -45
- package/extensions/ui.ts +14 -28
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
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
|
+
|
|
3
13
|
## 0.5.1 — 2026-09-20
|
|
4
14
|
|
|
5
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.
|
package/README.md
CHANGED
|
@@ -63,13 +63,15 @@ npm run check
|
|
|
63
63
|
npm test
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
`npm run check` runs Biome lint, formatting
|
|
67
|
-
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.
|
|
68
69
|
|
|
69
70
|
- `npm run format` formats TypeScript and root JSON files.
|
|
70
71
|
- `npm run lint` checks lint rules; `npm run lint:fix` applies safe lint fixes.
|
|
71
72
|
- `npx biome check --write .` also fixes formatting and import order.
|
|
72
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.
|
|
73
75
|
|
|
74
76
|
[Biome](https://biomejs.dev/) replaces Prettier and supplies linting in one pinned
|
|
75
77
|
direct tooling dependency, without ESLint or formatter plugins. Type checking stays with
|
|
@@ -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,8 +139,8 @@ export const registerCommands = (
|
|
|
136
139
|
];
|
|
137
140
|
}
|
|
138
141
|
|
|
139
|
-
|
|
140
|
-
|
|
142
|
+
const tier = args[0];
|
|
143
|
+
if (isRouterTier(tier)) {
|
|
141
144
|
const levelPrefix = args[1] ?? '';
|
|
142
145
|
return levelValues
|
|
143
146
|
.filter((v) => v.startsWith(levelPrefix))
|
|
@@ -248,7 +251,7 @@ export const registerCommands = (
|
|
|
248
251
|
|
|
249
252
|
const pinValue = args[0];
|
|
250
253
|
|
|
251
|
-
if (!
|
|
254
|
+
if (!isRouterPinValue(pinValue)) {
|
|
252
255
|
ctx.ui.notify(
|
|
253
256
|
`Invalid router pin: ${pinValue}. Use one of: ${ROUTER_PIN_VALUES.join(', ')}`,
|
|
254
257
|
'error',
|
|
@@ -256,7 +259,8 @@ export const registerCommands = (
|
|
|
256
259
|
return;
|
|
257
260
|
}
|
|
258
261
|
|
|
259
|
-
const nextTier
|
|
262
|
+
const nextTier: RouterTier | undefined =
|
|
263
|
+
pinValue === 'auto' ? undefined : pinValue;
|
|
260
264
|
if (nextTier) {
|
|
261
265
|
state.pinnedTierByProfile[currentProfile] = nextTier;
|
|
262
266
|
} else {
|
|
@@ -303,16 +307,20 @@ export const registerCommands = (
|
|
|
303
307
|
let tier: RouterTier | 'all' | undefined;
|
|
304
308
|
let levelValue = '';
|
|
305
309
|
|
|
306
|
-
const tierValues = ['high', 'medium', 'low'];
|
|
307
310
|
const levelValues = ['auto', ...THINKING_LEVELS];
|
|
308
311
|
|
|
309
312
|
if (args.length === 1) {
|
|
310
|
-
|
|
313
|
+
const level = args[0];
|
|
314
|
+
if (!level) return;
|
|
315
|
+
levelValue = level;
|
|
311
316
|
tier = 'all';
|
|
312
317
|
} else if (args.length === 2) {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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;
|
|
316
324
|
} else {
|
|
317
325
|
ctx.ui.notify(
|
|
318
326
|
`Invalid tier: ${args[0]}. Use high, medium, or low.`,
|
|
@@ -322,7 +330,7 @@ export const registerCommands = (
|
|
|
322
330
|
}
|
|
323
331
|
}
|
|
324
332
|
|
|
325
|
-
if (tier !== 'all' && !
|
|
333
|
+
if (tier !== 'all' && !tier) {
|
|
326
334
|
ctx.ui.notify(
|
|
327
335
|
`Invalid tier: ${tier}. Use high, medium, or low.`,
|
|
328
336
|
'error',
|
|
@@ -338,10 +346,17 @@ export const registerCommands = (
|
|
|
338
346
|
}
|
|
339
347
|
|
|
340
348
|
const nextLevel =
|
|
341
|
-
levelValue === 'auto'
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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];
|
|
345
360
|
for (const targetTier of tiers) {
|
|
346
361
|
if (nextLevel) overrides[targetTier] = nextLevel;
|
|
347
362
|
else delete overrides[targetTier];
|
|
@@ -359,10 +374,9 @@ export const registerCommands = (
|
|
|
359
374
|
}
|
|
360
375
|
// Only warn when the level isn't supported by some tiers; skip for 'off' and 'auto'
|
|
361
376
|
if (nextLevel && nextLevel !== 'off') {
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
);
|
|
377
|
+
const activeProfile = state.currentConfig.profiles[currentProfile];
|
|
378
|
+
if (!activeProfile) return;
|
|
379
|
+
const unsupported = getUnsupportedTiers(activeProfile, nextLevel);
|
|
366
380
|
if (unsupported.length > 0) {
|
|
367
381
|
ctx.ui.notify(
|
|
368
382
|
`Router thinking (${tier}) set to ${nextLevel}. ` +
|
|
@@ -416,7 +430,7 @@ export const registerCommands = (
|
|
|
416
430
|
return;
|
|
417
431
|
}
|
|
418
432
|
const tier = args[0]?.toLowerCase();
|
|
419
|
-
if (!
|
|
433
|
+
if (!isRouterTier(tier)) {
|
|
420
434
|
ctx.ui.notify('Usage: /router fix <high|medium|low>', 'error');
|
|
421
435
|
return;
|
|
422
436
|
}
|
|
@@ -424,7 +438,7 @@ export const registerCommands = (
|
|
|
424
438
|
ctx.ui.notify('No recent routing decision to fix.', 'warning');
|
|
425
439
|
return;
|
|
426
440
|
}
|
|
427
|
-
state.pinnedTierByProfile[state.lastDecision.profile] = tier
|
|
441
|
+
state.pinnedTierByProfile[state.lastDecision.profile] = tier;
|
|
428
442
|
actions.persistState();
|
|
429
443
|
actions.updateStatus(ctx);
|
|
430
444
|
ctx.ui.notify(
|
|
@@ -515,10 +529,12 @@ export const registerCommands = (
|
|
|
515
529
|
}
|
|
516
530
|
|
|
517
531
|
if (parts.length === 1 && !hasTrailingSpace) {
|
|
518
|
-
|
|
532
|
+
const subcommand = parts[0];
|
|
533
|
+
return subcommand ? getSubcommandCompletions(subcommand) : null;
|
|
519
534
|
}
|
|
520
535
|
|
|
521
536
|
const subcommand = parts[0];
|
|
537
|
+
if (!subcommand) return null;
|
|
522
538
|
const subArgs = parts.slice(1);
|
|
523
539
|
if (hasTrailingSpace && parts.length === 1) {
|
|
524
540
|
subArgs.push('');
|
|
@@ -597,6 +613,10 @@ export const registerCommands = (
|
|
|
597
613
|
const parts = args?.trim().split(/\s+/) ?? [];
|
|
598
614
|
const subcommand = parts[0];
|
|
599
615
|
const subArgs = parts.slice(1);
|
|
616
|
+
if (!subcommand) {
|
|
617
|
+
await handleStatus(subArgs, ctx);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
600
620
|
|
|
601
621
|
switch (subcommand) {
|
|
602
622
|
case 'profile':
|
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: {},
|
|
@@ -90,40 +94,42 @@ export const resolveModelRef = (
|
|
|
90
94
|
return { canonicalRef: ref };
|
|
91
95
|
};
|
|
92
96
|
|
|
93
|
-
const
|
|
94
|
-
existing
|
|
95
|
-
next
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (!existing) return next as RoutedTierConfig;
|
|
100
|
-
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;
|
|
101
103
|
};
|
|
102
104
|
|
|
103
105
|
export const mergeConfig = (
|
|
104
|
-
base:
|
|
105
|
-
override:
|
|
106
|
-
):
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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])
|
|
113
121
|
? mergedProfiles[name]
|
|
114
|
-
:
|
|
115
|
-
const nextProfile = profile as Partial<RouterProfile>;
|
|
122
|
+
: {};
|
|
116
123
|
mergedProfiles[name] = {
|
|
117
|
-
high:
|
|
118
|
-
medium:
|
|
119
|
-
low:
|
|
124
|
+
high: mergeRawValue(existing.high, profile.high),
|
|
125
|
+
medium: mergeRawValue(existing.medium, profile.medium),
|
|
126
|
+
low: mergeRawValue(existing.low, profile.low),
|
|
120
127
|
};
|
|
121
128
|
}
|
|
122
129
|
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
};
|
|
130
|
+
const baseModels = isObjectRecord(base.models) ? base.models : {};
|
|
131
|
+
const overrideModels = isObjectRecord(override.models) ? override.models : {};
|
|
132
|
+
const mergedModels = { ...baseModels, ...overrideModels };
|
|
127
133
|
|
|
128
134
|
return {
|
|
129
135
|
debug: override.debug ?? base.debug,
|
|
@@ -159,7 +165,7 @@ export const parseCanonicalModelRef = (
|
|
|
159
165
|
* Validate and normalize the models map from config.
|
|
160
166
|
*/
|
|
161
167
|
export const normalizeModelsMap = (
|
|
162
|
-
raw:
|
|
168
|
+
raw: unknown,
|
|
163
169
|
warnings: string[],
|
|
164
170
|
): Record<string, ModelDefinition> => {
|
|
165
171
|
const result: Record<string, ModelDefinition> = {};
|
|
@@ -321,8 +327,8 @@ export const normalizeTierConfig = (
|
|
|
321
327
|
// Validate tier-level thinkingLevels array
|
|
322
328
|
let tierThinkingLevels: ThinkingLevel[] | undefined;
|
|
323
329
|
if (Array.isArray(value.thinkingLevels)) {
|
|
324
|
-
tierThinkingLevels =
|
|
325
|
-
|
|
330
|
+
tierThinkingLevels = value.thinkingLevels.filter((l): l is ThinkingLevel =>
|
|
331
|
+
isThinkingLevel(l),
|
|
326
332
|
);
|
|
327
333
|
if (tierThinkingLevels.length === 0) tierThinkingLevels = undefined;
|
|
328
334
|
}
|
|
@@ -359,14 +365,11 @@ export const normalizeTierConfig = (
|
|
|
359
365
|
};
|
|
360
366
|
};
|
|
361
367
|
|
|
362
|
-
export const normalizeConfig = (raw:
|
|
368
|
+
export const normalizeConfig = (raw: RawRouterConfig): ConfigLoadResult => {
|
|
363
369
|
const warnings: string[] = [];
|
|
364
370
|
|
|
365
371
|
// 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
|
-
);
|
|
372
|
+
const normalizedModels = normalizeModelsMap(raw.models, warnings);
|
|
370
373
|
const hasModels = Object.keys(normalizedModels).length > 0;
|
|
371
374
|
|
|
372
375
|
const normalizedProfiles: Record<string, RouterProfile> = {};
|
|
@@ -375,22 +378,23 @@ export const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
|
|
|
375
378
|
isObjectRecord(raw.profiles) ? raw.profiles : {},
|
|
376
379
|
)) {
|
|
377
380
|
if (name === '__proto__') continue;
|
|
381
|
+
const profileRecord = isObjectRecord(profile) ? profile : {};
|
|
378
382
|
const high = normalizeTierConfig(
|
|
379
|
-
|
|
383
|
+
profileRecord.high,
|
|
380
384
|
name,
|
|
381
385
|
'high',
|
|
382
386
|
warnings,
|
|
383
387
|
hasModels ? normalizedModels : undefined,
|
|
384
388
|
);
|
|
385
389
|
const medium = normalizeTierConfig(
|
|
386
|
-
|
|
390
|
+
profileRecord.medium,
|
|
387
391
|
name,
|
|
388
392
|
'medium',
|
|
389
393
|
warnings,
|
|
390
394
|
hasModels ? normalizedModels : undefined,
|
|
391
395
|
);
|
|
392
396
|
const low = normalizeTierConfig(
|
|
393
|
-
|
|
397
|
+
profileRecord.low,
|
|
394
398
|
name,
|
|
395
399
|
'low',
|
|
396
400
|
warnings,
|
|
@@ -446,7 +450,7 @@ export const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
|
|
|
446
450
|
|
|
447
451
|
// Resolve classifierModel — accepts string or { model, thinking } object
|
|
448
452
|
let classifierModel: ClassifierConfig | undefined;
|
|
449
|
-
const rawClassifier = raw.classifierModel
|
|
453
|
+
const rawClassifier = raw.classifierModel;
|
|
450
454
|
if (typeof rawClassifier === 'string' && rawClassifier.trim()) {
|
|
451
455
|
const resolved = resolveModelRef(
|
|
452
456
|
rawClassifier.trim(),
|
|
@@ -510,7 +514,7 @@ export const loadRouterConfig = (cwd: string): ConfigLoadResult => {
|
|
|
510
514
|
const projectPath = join(cwd, '.pi', 'model-router.json');
|
|
511
515
|
const globalResult = parseConfigFile(globalPath);
|
|
512
516
|
const projectResult = parseConfigFile(projectPath);
|
|
513
|
-
const baseConfig:
|
|
517
|
+
const baseConfig: RawRouterConfig = { profiles: {} };
|
|
514
518
|
const merged = mergeConfig(
|
|
515
519
|
mergeConfig(baseConfig, globalResult.config),
|
|
516
520
|
projectResult.config,
|
|
@@ -649,9 +653,8 @@ export const clampThinkingLevel = (
|
|
|
649
653
|
|
|
650
654
|
const reqIdx = THINKING_LEVELS.indexOf(requested);
|
|
651
655
|
for (let i = reqIdx; i >= 0; i--) {
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
}
|
|
656
|
+
const level = THINKING_LEVELS[i];
|
|
657
|
+
if (level && supported.includes(level)) return level;
|
|
655
658
|
}
|
|
656
659
|
|
|
657
660
|
return 'off';
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Context, Message } from '@earendil-works/pi-ai';
|
|
2
|
+
|
|
3
|
+
export const extractTextFromContent = (
|
|
4
|
+
content: string | Message['content'],
|
|
5
|
+
): string => {
|
|
6
|
+
if (typeof content === 'string') return content;
|
|
7
|
+
return content
|
|
8
|
+
.map((part) => {
|
|
9
|
+
if (part.type === 'text') return part.text;
|
|
10
|
+
if (part.type === 'thinking') return part.thinking;
|
|
11
|
+
if (part.type === 'toolCall') {
|
|
12
|
+
return `${part.name} ${JSON.stringify(part.arguments)}`;
|
|
13
|
+
}
|
|
14
|
+
return '';
|
|
15
|
+
})
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
.join('\n');
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const getLastUserText = (context: Context): string => {
|
|
21
|
+
for (let i = context.messages.length - 1; i >= 0; i -= 1) {
|
|
22
|
+
const message = context.messages[i];
|
|
23
|
+
if (message?.role === 'user') {
|
|
24
|
+
return extractTextFromContent(message.content).trim();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return '';
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const getRecentConversationText = (
|
|
31
|
+
context: Context,
|
|
32
|
+
limit = 6,
|
|
33
|
+
): string =>
|
|
34
|
+
context.messages
|
|
35
|
+
.slice(-limit)
|
|
36
|
+
.map((message) =>
|
|
37
|
+
message ? extractTextFromContent(message.content).trim() : '',
|
|
38
|
+
)
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
.join('\n')
|
|
41
|
+
.toLowerCase();
|
|
42
|
+
|
|
43
|
+
export const countToolResults = (context: Context): number =>
|
|
44
|
+
context.messages.filter((message) => message?.role === 'toolResult').length;
|
|
45
|
+
|
|
46
|
+
export const countWords = (text: string): number =>
|
|
47
|
+
text.split(/\s+/).filter(Boolean).length;
|
|
48
|
+
|
|
49
|
+
export const hasImageAttachment = (context: Context): boolean =>
|
|
50
|
+
context.messages.some(
|
|
51
|
+
(message) =>
|
|
52
|
+
message &&
|
|
53
|
+
Array.isArray(message.content) &&
|
|
54
|
+
message.content.some((part) => part.type === 'image'),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
export const containsAny = (text: string, keywords: string[]): boolean =>
|
|
58
|
+
keywords.some((keyword) => text.includes(keyword));
|