@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/extensions/routing.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import
|
|
3
|
-
import { streamSimple } from '@earendil-works/pi-ai/compat';
|
|
4
|
-
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
5
|
-
import { isRouterTier, parseCanonicalModelRef } from './config';
|
|
1
|
+
import type { Context } from '@earendil-works/pi-ai';
|
|
2
|
+
import { parseCanonicalModelRef } from './config';
|
|
6
3
|
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
containsAny,
|
|
5
|
+
countToolResults,
|
|
6
|
+
countWords,
|
|
7
|
+
getLastUserText,
|
|
8
|
+
getRecentConversationText,
|
|
9
|
+
} from './context';
|
|
11
10
|
import type {
|
|
12
11
|
RouterPhase,
|
|
13
12
|
RouterProfile,
|
|
@@ -17,67 +16,6 @@ import type {
|
|
|
17
16
|
RoutingRule,
|
|
18
17
|
} from './types';
|
|
19
18
|
|
|
20
|
-
export const extractTextFromContent = (
|
|
21
|
-
content: string | Message['content'],
|
|
22
|
-
): string => {
|
|
23
|
-
if (typeof content === 'string') {
|
|
24
|
-
return content;
|
|
25
|
-
}
|
|
26
|
-
return content
|
|
27
|
-
.map((part) => {
|
|
28
|
-
if (part.type === 'text') return part.text;
|
|
29
|
-
if (part.type === 'thinking') return part.thinking;
|
|
30
|
-
if (part.type === 'toolCall')
|
|
31
|
-
return `${part.name} ${JSON.stringify(part.arguments)}`;
|
|
32
|
-
return '';
|
|
33
|
-
})
|
|
34
|
-
.filter(Boolean)
|
|
35
|
-
.join('\n');
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
export const getLastUserText = (context: Context): string => {
|
|
39
|
-
for (let i = context.messages.length - 1; i >= 0; i--) {
|
|
40
|
-
const message = context.messages[i];
|
|
41
|
-
if (message.role === 'user') {
|
|
42
|
-
return extractTextFromContent(message.content).trim();
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
return '';
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
export const getRecentConversationText = (
|
|
49
|
-
context: Context,
|
|
50
|
-
limit = 6,
|
|
51
|
-
): string => {
|
|
52
|
-
return context.messages
|
|
53
|
-
.slice(-limit)
|
|
54
|
-
.map((message) => extractTextFromContent(message.content).trim())
|
|
55
|
-
.filter(Boolean)
|
|
56
|
-
.join('\n')
|
|
57
|
-
.toLowerCase();
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
export const countToolResults = (context: Context): number => {
|
|
61
|
-
return context.messages.filter((message) => message.role === 'toolResult')
|
|
62
|
-
.length;
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
export const countWords = (text: string): number => {
|
|
66
|
-
return text.split(/\s+/).filter(Boolean).length;
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
export const hasImageAttachment = (context: Context): boolean => {
|
|
70
|
-
return context.messages.some(
|
|
71
|
-
(message) =>
|
|
72
|
-
Array.isArray(message.content) &&
|
|
73
|
-
message.content.some((part) => part.type === 'image'),
|
|
74
|
-
);
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
export const containsAny = (text: string, keywords: string[]): boolean => {
|
|
78
|
-
return keywords.some((keyword) => text.includes(keyword));
|
|
79
|
-
};
|
|
80
|
-
|
|
81
19
|
export const phaseForTier = (tier: RouterTier): RouterPhase => {
|
|
82
20
|
if (tier === 'high') return 'planning';
|
|
83
21
|
if (tier === 'medium') return 'implementation';
|
|
@@ -93,11 +31,13 @@ export const resolveAvailableTier = (
|
|
|
93
31
|
const order: RouterTier[] = ['low', 'medium', 'high'];
|
|
94
32
|
const startIdx = order.indexOf(preferred);
|
|
95
33
|
for (let i = startIdx + 1; i < order.length; i++) {
|
|
96
|
-
|
|
34
|
+
const tier = order[i];
|
|
35
|
+
if (tier && profile[tier]) return tier;
|
|
97
36
|
}
|
|
98
37
|
// Fall "down" as last resort
|
|
99
38
|
for (let i = startIdx - 1; i >= 0; i--) {
|
|
100
|
-
|
|
39
|
+
const tier = order[i];
|
|
40
|
+
if (tier && profile[tier]) return tier;
|
|
101
41
|
}
|
|
102
42
|
return preferred; // unreachable if profile has ≥1 tier
|
|
103
43
|
};
|
|
@@ -148,6 +88,7 @@ export const decideRouting = (
|
|
|
148
88
|
rules?: RoutingRule[],
|
|
149
89
|
isBudgetExceeded = false,
|
|
150
90
|
): RoutingDecision => {
|
|
91
|
+
if (previousDecision?.profile !== profileName) previousDecision = undefined;
|
|
151
92
|
const prompt = getLastUserText(context).toLowerCase();
|
|
152
93
|
const recentConversation = getRecentConversationText(context);
|
|
153
94
|
const toolResultCount = countToolResults(context);
|
|
@@ -372,7 +313,10 @@ export const decideRouting = (
|
|
|
372
313
|
}
|
|
373
314
|
|
|
374
315
|
// Resolve to nearest available tier if the selected tier is disabled
|
|
375
|
-
const resolvedTier =
|
|
316
|
+
const resolvedTier =
|
|
317
|
+
isBudgetForced && !profile.medium && profile.low
|
|
318
|
+
? 'low'
|
|
319
|
+
: resolveAvailableTier(profile, tier);
|
|
376
320
|
if (resolvedTier !== tier) {
|
|
377
321
|
reasoning = `Resolved from ${tier} to ${resolvedTier} tier (${tier} tier is not configured). Original: ${reasoning}`;
|
|
378
322
|
phase = phaseForTier(resolvedTier);
|
|
@@ -392,92 +336,3 @@ export const decideRouting = (
|
|
|
392
336
|
decision.isBudgetForced = isBudgetForced;
|
|
393
337
|
return decision;
|
|
394
338
|
};
|
|
395
|
-
|
|
396
|
-
export const runClassifier = async (
|
|
397
|
-
classifierModelRef: string,
|
|
398
|
-
modelRegistry: ExtensionContext['modelRegistry'],
|
|
399
|
-
context: Context,
|
|
400
|
-
currentPhase?: RouterPhase,
|
|
401
|
-
thinking?: ThinkingLevel,
|
|
402
|
-
): Promise<{ tier: RouterTier; reasoning: string } | undefined> => {
|
|
403
|
-
try {
|
|
404
|
-
const { provider, modelId } = parseCanonicalModelRef(classifierModelRef);
|
|
405
|
-
const model = modelRegistry.find(provider, modelId);
|
|
406
|
-
if (!model) return undefined;
|
|
407
|
-
|
|
408
|
-
const auth = await modelRegistry.getApiKeyAndHeaders(model);
|
|
409
|
-
if (!auth.ok || !hasUsableRequestAuth(auth)) return undefined;
|
|
410
|
-
const apiKey = auth.apiKey;
|
|
411
|
-
const headers = auth.headers;
|
|
412
|
-
const requestModel = await resolveDelegatedModel(
|
|
413
|
-
modelRegistry as unknown as RegistryWithProviderAuth,
|
|
414
|
-
model,
|
|
415
|
-
);
|
|
416
|
-
|
|
417
|
-
const promptText = getLastUserText(context);
|
|
418
|
-
const historyText = getRecentConversationText(context, 4);
|
|
419
|
-
|
|
420
|
-
const classifierPrompt = `You are a model router classifier. Your job is to categorize the user's latest request into one of three tiers: "high", "medium", or "low".
|
|
421
|
-
|
|
422
|
-
Tiers:
|
|
423
|
-
- high: Architecture, design, planning, tradeoff analysis, broad debugging, large refactors, codebase research.
|
|
424
|
-
- medium: Implementation of a known plan, multi-file edits, normal coding work, focused debugging, tests/fixes.
|
|
425
|
-
- low: Summaries, changelogs, formatting, quick explanations, small bounded transforms, simple read-only lookup.
|
|
426
|
-
|
|
427
|
-
${currentPhase ? `Current conversation phase: ${currentPhase}\n` : ''}
|
|
428
|
-
Recent history:
|
|
429
|
-
${historyText}
|
|
430
|
-
|
|
431
|
-
Latest user message:
|
|
432
|
-
${promptText}
|
|
433
|
-
|
|
434
|
-
Return your decision in exactly two lines:
|
|
435
|
-
Tier: [high|medium|low]
|
|
436
|
-
Reasoning: [one short sentence]
|
|
437
|
-
|
|
438
|
-
${currentPhase === 'planning' ? 'Consider that the conversation is currently in a planning phase. Bias toward "high" unless the request is clearly a simple implementation or summary.' : ''}
|
|
439
|
-
${currentPhase === 'implementation' ? 'Consider that the conversation is currently in an implementation phase. Bias toward "medium" unless the request is clearly planning or a simple summary.' : ''}`;
|
|
440
|
-
|
|
441
|
-
const classifierContext: Context = {
|
|
442
|
-
messages: [
|
|
443
|
-
{ role: 'user', content: classifierPrompt, timestamp: Date.now() },
|
|
444
|
-
],
|
|
445
|
-
};
|
|
446
|
-
|
|
447
|
-
const reasoningOption =
|
|
448
|
-
model.reasoning && thinking && thinking !== 'off' ? thinking : undefined;
|
|
449
|
-
|
|
450
|
-
const stream = streamSimple(requestModel, classifierContext, {
|
|
451
|
-
apiKey,
|
|
452
|
-
headers,
|
|
453
|
-
...(reasoningOption ? { reasoning: reasoningOption } : {}),
|
|
454
|
-
});
|
|
455
|
-
let fullText = '';
|
|
456
|
-
for await (const event of stream) {
|
|
457
|
-
if (event.type === 'text_delta' && typeof event.delta === 'string') {
|
|
458
|
-
fullText += event.delta;
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
const lines = fullText.trim().split('\n');
|
|
463
|
-
const tierLine = lines.find((l) => l.toLowerCase().startsWith('tier:'));
|
|
464
|
-
const reasoningLine = lines.find((l) =>
|
|
465
|
-
l.toLowerCase().startsWith('reasoning:'),
|
|
466
|
-
);
|
|
467
|
-
|
|
468
|
-
if (tierLine) {
|
|
469
|
-
const tierValue = tierLine.split(':')[1].trim().toLowerCase();
|
|
470
|
-
if (isRouterTier(tierValue)) {
|
|
471
|
-
return {
|
|
472
|
-
tier: tierValue,
|
|
473
|
-
reasoning: reasoningLine
|
|
474
|
-
? reasoningLine.split(':')[1].trim()
|
|
475
|
-
: 'Classifier decision.',
|
|
476
|
-
};
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
} catch {
|
|
480
|
-
// Ignore classifier errors and fall back to heuristics
|
|
481
|
-
}
|
|
482
|
-
return undefined;
|
|
483
|
-
};
|
package/extensions/state.ts
CHANGED
|
@@ -1,23 +1,61 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
4
|
+
import {
|
|
5
|
+
isObjectRecord,
|
|
6
|
+
isRouterTier,
|
|
7
|
+
isThinkingLevel,
|
|
8
|
+
parseCanonicalModelRef,
|
|
9
|
+
} from './config';
|
|
4
10
|
import type {
|
|
11
|
+
PersistedStateInput,
|
|
5
12
|
RouterLastProfileState,
|
|
6
13
|
RouterPersistedState,
|
|
7
14
|
RouterPinByProfile,
|
|
8
|
-
RouterThinkingByProfile,
|
|
9
15
|
RoutingDecision,
|
|
10
16
|
} from './types';
|
|
11
17
|
|
|
12
18
|
const LAST_PROFILE_STATE_FILE = 'model-router-state.json';
|
|
13
19
|
|
|
14
|
-
const
|
|
15
|
-
|
|
20
|
+
const isPhase = (value: unknown) =>
|
|
21
|
+
value === 'planning' || value === 'implementation' || value === 'lightweight';
|
|
22
|
+
const isFiniteNumber = (value: unknown): value is number =>
|
|
23
|
+
typeof value === 'number' && Number.isFinite(value);
|
|
24
|
+
const isModelRef = (value: unknown) => {
|
|
25
|
+
if (typeof value !== 'string') return false;
|
|
26
|
+
try {
|
|
27
|
+
parseCanonicalModelRef(value);
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const isDecision = (value: unknown): value is RoutingDecision =>
|
|
34
|
+
isObjectRecord(value) &&
|
|
35
|
+
isRouterTier(value.tier) &&
|
|
36
|
+
isPhase(value.phase) &&
|
|
37
|
+
isThinkingLevel(value.thinking) &&
|
|
38
|
+
isFiniteNumber(value.timestamp) &&
|
|
39
|
+
[
|
|
40
|
+
'profile',
|
|
41
|
+
'targetProvider',
|
|
42
|
+
'targetModelId',
|
|
43
|
+
'targetLabel',
|
|
44
|
+
'reasoning',
|
|
45
|
+
].every((key) => typeof value[key] === 'string') &&
|
|
46
|
+
['isClassifier', 'isFallback', 'isBudgetForced', 'isRuleMatched'].every(
|
|
47
|
+
(key) => value[key] === undefined || typeof value[key] === 'boolean',
|
|
48
|
+
);
|
|
49
|
+
const isMap = (value: unknown, validate: (entry: unknown) => boolean) =>
|
|
50
|
+
isObjectRecord(value) &&
|
|
51
|
+
Object.entries(value).every(
|
|
52
|
+
([key, entry]) => key !== '__proto__' && validate(entry),
|
|
53
|
+
);
|
|
16
54
|
|
|
17
55
|
export const isRouterLastProfileState = (
|
|
18
56
|
value: unknown,
|
|
19
57
|
): value is RouterLastProfileState =>
|
|
20
|
-
|
|
58
|
+
isObjectRecord(value) &&
|
|
21
59
|
typeof value.selectedProfile === 'string' &&
|
|
22
60
|
value.selectedProfile.length > 0 &&
|
|
23
61
|
typeof value.timestamp === 'number';
|
|
@@ -58,36 +96,61 @@ export const saveLastRouterProfile = (
|
|
|
58
96
|
export const isRouterPersistedState = (
|
|
59
97
|
value: unknown,
|
|
60
98
|
): value is RouterPersistedState => {
|
|
61
|
-
if (
|
|
62
|
-
return false;
|
|
63
|
-
}
|
|
64
|
-
if (!isRecord(value)) {
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
99
|
+
if (!isObjectRecord(value)) return false;
|
|
67
100
|
return (
|
|
68
101
|
typeof value.enabled === 'boolean' &&
|
|
69
102
|
typeof value.selectedProfile === 'string' &&
|
|
70
|
-
|
|
103
|
+
isFiniteNumber(value.timestamp) &&
|
|
104
|
+
(value.pinTier === undefined || isRouterTier(value.pinTier)) &&
|
|
105
|
+
(value.pinByProfile === undefined ||
|
|
106
|
+
isMap(value.pinByProfile, isRouterTier)) &&
|
|
107
|
+
(value.thinkingByProfile === undefined ||
|
|
108
|
+
isMap(
|
|
109
|
+
value.thinkingByProfile,
|
|
110
|
+
(tiers) =>
|
|
111
|
+
isObjectRecord(tiers) &&
|
|
112
|
+
Object.entries(tiers).every(
|
|
113
|
+
([tier, level]) => isRouterTier(tier) && isThinkingLevel(level),
|
|
114
|
+
),
|
|
115
|
+
)) &&
|
|
116
|
+
(value.lastDecision === undefined || isDecision(value.lastDecision)) &&
|
|
117
|
+
(value.debugHistory === undefined ||
|
|
118
|
+
(Array.isArray(value.debugHistory) &&
|
|
119
|
+
value.debugHistory.every(isDecision))) &&
|
|
120
|
+
(value.lastPhase === undefined || isPhase(value.lastPhase)) &&
|
|
121
|
+
(value.lastNonRouterModel === undefined ||
|
|
122
|
+
isModelRef(value.lastNonRouterModel)) &&
|
|
123
|
+
(value.accumulatedCost === undefined ||
|
|
124
|
+
(isFiniteNumber(value.accumulatedCost) && value.accumulatedCost >= 0)) &&
|
|
125
|
+
['debugEnabled', 'widgetEnabled'].every(
|
|
126
|
+
(key) => value[key] === undefined || typeof value[key] === 'boolean',
|
|
127
|
+
)
|
|
71
128
|
);
|
|
72
129
|
};
|
|
73
130
|
|
|
74
|
-
export const buildPersistedState = (
|
|
75
|
-
routerEnabled
|
|
76
|
-
selectedProfile
|
|
77
|
-
pinnedTierByProfile
|
|
78
|
-
thinkingByProfile
|
|
79
|
-
debugEnabled
|
|
80
|
-
widgetEnabled
|
|
81
|
-
debugHistory
|
|
82
|
-
lastDecision
|
|
83
|
-
lastNonRouterModel
|
|
84
|
-
accumulatedCost
|
|
85
|
-
): RouterPersistedState => {
|
|
86
|
-
|
|
131
|
+
export const buildPersistedState = ({
|
|
132
|
+
routerEnabled,
|
|
133
|
+
selectedProfile,
|
|
134
|
+
pinnedTierByProfile,
|
|
135
|
+
thinkingByProfile,
|
|
136
|
+
debugEnabled,
|
|
137
|
+
widgetEnabled,
|
|
138
|
+
debugHistory,
|
|
139
|
+
lastDecision,
|
|
140
|
+
lastNonRouterModel,
|
|
141
|
+
accumulatedCost,
|
|
142
|
+
}: PersistedStateInput): RouterPersistedState => {
|
|
143
|
+
const pinByProfile: RouterPinByProfile = {};
|
|
144
|
+
for (const [profile, tier] of Object.entries(pinnedTierByProfile)) {
|
|
145
|
+
if (tier) pinByProfile[profile] = tier;
|
|
146
|
+
}
|
|
147
|
+
return structuredClone({
|
|
87
148
|
enabled: routerEnabled,
|
|
88
149
|
selectedProfile: selectedProfile ?? '',
|
|
89
|
-
|
|
90
|
-
|
|
150
|
+
...(selectedProfile && pinnedTierByProfile[selectedProfile]
|
|
151
|
+
? { pinTier: pinnedTierByProfile[selectedProfile] }
|
|
152
|
+
: {}),
|
|
153
|
+
pinByProfile,
|
|
91
154
|
thinkingByProfile: { ...thinkingByProfile },
|
|
92
155
|
debugEnabled,
|
|
93
156
|
widgetEnabled,
|
|
@@ -97,5 +160,5 @@ export const buildPersistedState = (
|
|
|
97
160
|
lastNonRouterModel,
|
|
98
161
|
accumulatedCost,
|
|
99
162
|
timestamp: Date.now(),
|
|
100
|
-
};
|
|
163
|
+
});
|
|
101
164
|
};
|
package/extensions/types.ts
CHANGED
|
@@ -10,49 +10,60 @@ export type RouterThinkingByProfile = Record<string, RouterThinkingByTier>;
|
|
|
10
10
|
export interface RoutingRule {
|
|
11
11
|
matches: string | string[];
|
|
12
12
|
tier: RouterTier;
|
|
13
|
-
reason?: string;
|
|
13
|
+
reason?: string | undefined;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export interface ModelDefinition {
|
|
17
17
|
model: string;
|
|
18
|
-
contextWindow?: number;
|
|
19
|
-
maxTokens?: number;
|
|
20
|
-
reasoning?: boolean;
|
|
21
|
-
thinkingLevels?: ThinkingLevel[];
|
|
18
|
+
contextWindow?: number | undefined;
|
|
19
|
+
maxTokens?: number | undefined;
|
|
20
|
+
reasoning?: boolean | undefined;
|
|
21
|
+
thinkingLevels?: ThinkingLevel[] | undefined;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export interface ClassifierConfig {
|
|
25
25
|
model: string;
|
|
26
|
-
thinking?: ThinkingLevel;
|
|
26
|
+
thinking?: ThinkingLevel | undefined;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export interface RoutedTierConfig {
|
|
30
30
|
model: string;
|
|
31
|
-
thinking?: ThinkingLevel;
|
|
32
|
-
fallbacks?: string[];
|
|
33
|
-
contextWindow?: number;
|
|
34
|
-
maxTokens?: number;
|
|
35
|
-
reasoning?: boolean;
|
|
36
|
-
thinkingLevels?: ThinkingLevel[];
|
|
37
|
-
resolvedContextWindow?: number;
|
|
38
|
-
resolvedMaxTokens?: number;
|
|
39
|
-
resolvedThinkingLevels?: ThinkingLevel[];
|
|
31
|
+
thinking?: ThinkingLevel | undefined;
|
|
32
|
+
fallbacks?: string[] | undefined;
|
|
33
|
+
contextWindow?: number | undefined;
|
|
34
|
+
maxTokens?: number | undefined;
|
|
35
|
+
reasoning?: boolean | undefined;
|
|
36
|
+
thinkingLevels?: ThinkingLevel[] | undefined;
|
|
37
|
+
resolvedContextWindow?: number | undefined;
|
|
38
|
+
resolvedMaxTokens?: number | undefined;
|
|
39
|
+
resolvedThinkingLevels?: ThinkingLevel[] | undefined;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export interface RouterProfile {
|
|
43
|
-
high?: RoutedTierConfig;
|
|
44
|
-
medium?: RoutedTierConfig;
|
|
45
|
-
low?: RoutedTierConfig;
|
|
43
|
+
high?: RoutedTierConfig | undefined;
|
|
44
|
+
medium?: RoutedTierConfig | undefined;
|
|
45
|
+
low?: RoutedTierConfig | undefined;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
export interface RouterConfig {
|
|
49
|
-
debug?: boolean;
|
|
50
|
-
classifierModel?: ClassifierConfig;
|
|
51
|
-
phaseBias?: number;
|
|
52
|
-
maxSessionBudget?: number;
|
|
53
|
-
rules?: RoutingRule[];
|
|
49
|
+
debug?: boolean | undefined;
|
|
50
|
+
classifierModel?: ClassifierConfig | undefined;
|
|
51
|
+
phaseBias?: number | undefined;
|
|
52
|
+
maxSessionBudget?: number | undefined;
|
|
53
|
+
rules?: RoutingRule[] | undefined;
|
|
54
54
|
profiles: Record<string, RouterProfile>;
|
|
55
|
-
models?: Record<string, ModelDefinition
|
|
55
|
+
models?: Record<string, ModelDefinition> | undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface RouterStatusState {
|
|
59
|
+
routerEnabled: boolean;
|
|
60
|
+
selectedProfile: string | undefined;
|
|
61
|
+
pinnedTierByProfile: RouterPinByProfile;
|
|
62
|
+
lastDecision: RoutingDecision | undefined;
|
|
63
|
+
lastNonRouterModel: string | undefined;
|
|
64
|
+
accumulatedCost: number;
|
|
65
|
+
widgetEnabled: boolean;
|
|
66
|
+
currentConfig: RouterConfig;
|
|
56
67
|
}
|
|
57
68
|
|
|
58
69
|
export interface RoutingDecision {
|
|
@@ -65,10 +76,10 @@ export interface RoutingDecision {
|
|
|
65
76
|
reasoning: string;
|
|
66
77
|
thinking: ThinkingLevel;
|
|
67
78
|
timestamp: number;
|
|
68
|
-
isClassifier?: boolean;
|
|
69
|
-
isFallback?: boolean;
|
|
70
|
-
isBudgetForced?: boolean;
|
|
71
|
-
isRuleMatched?: boolean;
|
|
79
|
+
isClassifier?: boolean | undefined;
|
|
80
|
+
isFallback?: boolean | undefined;
|
|
81
|
+
isBudgetForced?: boolean | undefined;
|
|
82
|
+
isRuleMatched?: boolean | undefined;
|
|
72
83
|
}
|
|
73
84
|
|
|
74
85
|
export interface RouterLastProfileState {
|
|
@@ -76,34 +87,51 @@ export interface RouterLastProfileState {
|
|
|
76
87
|
timestamp: number;
|
|
77
88
|
}
|
|
78
89
|
|
|
90
|
+
export interface PersistedStateInput {
|
|
91
|
+
routerEnabled: boolean;
|
|
92
|
+
selectedProfile: string | undefined;
|
|
93
|
+
pinnedTierByProfile: RouterPinByProfile;
|
|
94
|
+
thinkingByProfile: RouterThinkingByProfile;
|
|
95
|
+
debugEnabled: boolean;
|
|
96
|
+
widgetEnabled: boolean;
|
|
97
|
+
debugHistory: RoutingDecision[];
|
|
98
|
+
lastDecision: RoutingDecision | undefined;
|
|
99
|
+
lastNonRouterModel: string | undefined;
|
|
100
|
+
accumulatedCost: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
79
103
|
export interface RouterPersistedState {
|
|
80
104
|
enabled: boolean;
|
|
81
105
|
selectedProfile: string;
|
|
82
|
-
pinTier?: RouterTier;
|
|
83
|
-
pinByProfile?: RouterPinByProfile;
|
|
84
|
-
thinkingByProfile?: RouterThinkingByProfile;
|
|
85
|
-
debugEnabled?: boolean;
|
|
86
|
-
widgetEnabled?: boolean;
|
|
87
|
-
debugHistory?: RoutingDecision[];
|
|
88
|
-
lastPhase?: RouterPhase;
|
|
89
|
-
lastDecision?: RoutingDecision;
|
|
90
|
-
lastNonRouterModel?: string;
|
|
91
|
-
accumulatedCost?: number;
|
|
106
|
+
pinTier?: RouterTier | undefined;
|
|
107
|
+
pinByProfile?: RouterPinByProfile | undefined;
|
|
108
|
+
thinkingByProfile?: RouterThinkingByProfile | undefined;
|
|
109
|
+
debugEnabled?: boolean | undefined;
|
|
110
|
+
widgetEnabled?: boolean | undefined;
|
|
111
|
+
debugHistory?: RoutingDecision[] | undefined;
|
|
112
|
+
lastPhase?: RouterPhase | undefined;
|
|
113
|
+
lastDecision?: RoutingDecision | undefined;
|
|
114
|
+
lastNonRouterModel?: string | undefined;
|
|
115
|
+
accumulatedCost?: number | undefined;
|
|
92
116
|
timestamp: number;
|
|
93
117
|
}
|
|
94
118
|
|
|
119
|
+
export interface RawRouterConfig {
|
|
120
|
+
debug?: unknown;
|
|
121
|
+
classifierModel?: unknown;
|
|
122
|
+
phaseBias?: unknown;
|
|
123
|
+
maxSessionBudget?: unknown;
|
|
124
|
+
rules?: unknown;
|
|
125
|
+
profiles?: unknown;
|
|
126
|
+
models?: unknown;
|
|
127
|
+
}
|
|
128
|
+
|
|
95
129
|
export interface ConfigLoadResult {
|
|
96
130
|
config: RouterConfig;
|
|
97
131
|
warnings: string[];
|
|
98
132
|
}
|
|
99
133
|
|
|
100
134
|
export interface ParsedConfigFile {
|
|
101
|
-
config:
|
|
135
|
+
config: RawRouterConfig;
|
|
102
136
|
warnings: string[];
|
|
103
137
|
}
|
|
104
|
-
|
|
105
|
-
export interface CustomSessionEntry {
|
|
106
|
-
type: string;
|
|
107
|
-
customType?: string;
|
|
108
|
-
data?: unknown;
|
|
109
|
-
}
|
package/extensions/ui.ts
CHANGED
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import type {
|
|
3
|
-
RouterConfig,
|
|
4
3
|
RouterPinByProfile,
|
|
4
|
+
RouterStatusState,
|
|
5
5
|
RouterThinkingByProfile,
|
|
6
6
|
RoutingDecision,
|
|
7
7
|
} from './types';
|
|
8
8
|
|
|
9
|
-
const getEffectiveThinking = (
|
|
10
|
-
thinkingByProfile: RouterThinkingByProfile,
|
|
11
|
-
profileName: string,
|
|
12
|
-
decision: RoutingDecision,
|
|
13
|
-
) => thinkingByProfile[profileName]?.[decision.tier] ?? decision.thinking;
|
|
14
|
-
|
|
15
9
|
const getDecisionFlags = (decision: RoutingDecision): string[] => {
|
|
16
10
|
const flags: string[] = [];
|
|
17
11
|
if (decision.isFallback) flags.push('fallback');
|
|
@@ -53,16 +47,18 @@ export const formatModelRef = (ref: string | undefined): string => {
|
|
|
53
47
|
|
|
54
48
|
export const updateStatus = (
|
|
55
49
|
ctx: ExtensionContext,
|
|
56
|
-
|
|
57
|
-
selectedProfile: string | undefined,
|
|
58
|
-
pinnedTierByProfile: RouterPinByProfile,
|
|
59
|
-
thinkingByProfile: RouterThinkingByProfile,
|
|
60
|
-
lastDecision: RoutingDecision | undefined,
|
|
61
|
-
lastNonRouterModel: string | undefined,
|
|
62
|
-
accumulatedCost: number,
|
|
63
|
-
widgetEnabled: boolean,
|
|
64
|
-
currentConfig: RouterConfig,
|
|
50
|
+
state: RouterStatusState,
|
|
65
51
|
) => {
|
|
52
|
+
const {
|
|
53
|
+
routerEnabled,
|
|
54
|
+
selectedProfile,
|
|
55
|
+
pinnedTierByProfile,
|
|
56
|
+
lastDecision,
|
|
57
|
+
lastNonRouterModel,
|
|
58
|
+
accumulatedCost,
|
|
59
|
+
widgetEnabled,
|
|
60
|
+
currentConfig,
|
|
61
|
+
} = state;
|
|
66
62
|
const activeRouterProfile = routerEnabled ? selectedProfile : undefined;
|
|
67
63
|
const statusProfile = selectedProfile ?? 'none';
|
|
68
64
|
const activePin = selectedProfile
|
|
@@ -77,12 +73,7 @@ export const updateStatus = (
|
|
|
77
73
|
|
|
78
74
|
let statusText: string;
|
|
79
75
|
if (lastDecision && matchesProfile && matchesPin) {
|
|
80
|
-
|
|
81
|
-
thinkingByProfile,
|
|
82
|
-
activeRouterProfile,
|
|
83
|
-
lastDecision,
|
|
84
|
-
);
|
|
85
|
-
statusText = `router:${activeRouterProfile}${pinLabel} -> ${lastDecision.tier} -> ${lastDecision.targetProvider}/${lastDecision.targetModelId} (${effectiveThinking})`;
|
|
76
|
+
statusText = `router:${activeRouterProfile}${pinLabel} -> ${lastDecision.tier} -> ${lastDecision.targetProvider}/${lastDecision.targetModelId} (${lastDecision.thinking})`;
|
|
86
77
|
} else {
|
|
87
78
|
statusText = `router:${activeRouterProfile}${pinLabel} -> waiting`;
|
|
88
79
|
}
|
|
@@ -106,16 +97,11 @@ export const updateStatus = (
|
|
|
106
97
|
: ''),
|
|
107
98
|
];
|
|
108
99
|
if (lastDecision && lastDecision.profile === statusProfile) {
|
|
109
|
-
const effectiveThinking = getEffectiveThinking(
|
|
110
|
-
thinkingByProfile,
|
|
111
|
-
statusProfile,
|
|
112
|
-
lastDecision,
|
|
113
|
-
);
|
|
114
100
|
const flags = getDecisionFlags(lastDecision);
|
|
115
101
|
const flagsStr = flags.length > 0 ? ` [${flags.join(',')}]` : '';
|
|
116
102
|
|
|
117
103
|
widgetLines.push(
|
|
118
|
-
`Route: ${lastDecision.tier}${flagsStr} -> ${lastDecision.targetProvider}/${lastDecision.targetModelId} (${
|
|
104
|
+
`Route: ${lastDecision.tier}${flagsStr} -> ${lastDecision.targetProvider}/${lastDecision.targetModelId} (${lastDecision.thinking})`,
|
|
119
105
|
`Phase: ${lastDecision.phase}`,
|
|
120
106
|
);
|
|
121
107
|
} else if (!routerEnabled && lastNonRouterModel) {
|
package/package.json
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alexeiled/pi-model-router",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"extensions",
|
|
7
|
-
"!extensions
|
|
7
|
+
"!extensions/**/*.test.ts",
|
|
8
|
+
"!extensions/test",
|
|
8
9
|
"LICENSE",
|
|
9
10
|
"model-router.example.json",
|
|
10
11
|
"README.md",
|
|
11
12
|
"CHANGELOG.md"
|
|
12
13
|
],
|
|
13
|
-
"description": "
|
|
14
|
+
"description": "Independently maintained Pi model router with tiered routing, soft budget controls, classifier fallback, and custom-provider support.",
|
|
14
15
|
"keywords": [
|
|
15
16
|
"pi-package",
|
|
16
17
|
"pi",
|
|
@@ -23,7 +24,7 @@
|
|
|
23
24
|
"engines": {
|
|
24
25
|
"node": ">=22.19.0"
|
|
25
26
|
},
|
|
26
|
-
"packageManager": "npm@
|
|
27
|
+
"packageManager": "npm@12.0.2",
|
|
27
28
|
"author": "Alexei Ledenev",
|
|
28
29
|
"contributors": [
|
|
29
30
|
"Ye Liu"
|
|
@@ -51,7 +52,7 @@
|
|
|
51
52
|
"tsc": "tsc --noEmit",
|
|
52
53
|
"build": "tsc",
|
|
53
54
|
"prepublishOnly": "npm run check && npm test",
|
|
54
|
-
"test": "vitest run",
|
|
55
|
+
"test": "vitest run --pool=threads",
|
|
55
56
|
"check": "biome check --error-on-warnings . && npm run tsc",
|
|
56
57
|
"lint": "biome lint --error-on-warnings .",
|
|
57
58
|
"lint:fix": "biome lint --write --error-on-warnings .",
|