@kdejaeger/pi-model-router 0.3.1

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.
@@ -0,0 +1,290 @@
1
+ import { type Context, type Message, streamSimple } from '@earendil-works/pi-ai';
2
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
3
+ import type { RouterConfig, RouterProfile, RouterTier, RoutingDecision } from './types';
4
+ import { createOpenRouterOnPayload, OPENROUTER_ATTR_HEADERS, parseCanonicalModelRef, resolveModelFromRef } from './config';
5
+
6
+ export const extractTextFromContent = (content: string | Message['content']): string => {
7
+ if (typeof content === 'string') {
8
+ return content;
9
+ }
10
+ return content
11
+ .map((part) => {
12
+ if (part.type === 'text') return part.text;
13
+ if (part.type === 'thinking') return part.thinking;
14
+ if (part.type === 'toolCall') return `${part.name} ${JSON.stringify(part.arguments)}`;
15
+ return '';
16
+ })
17
+ .filter(Boolean)
18
+ .join('\n');
19
+ };
20
+
21
+ const lastUserMessage = (context: Context): Message | undefined => {
22
+ for (let i = context.messages.length - 1; i >= 0; i--) {
23
+ const msg = context.messages[i];
24
+ if (msg.role === 'user') return msg;
25
+ }
26
+ return undefined;
27
+ };
28
+
29
+ const escapeXML = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
30
+
31
+ const getRecentConversationText = (context: Context, limit: number): string => {
32
+ const messages = context.messages.slice(-limit);
33
+
34
+ // Ensure the last user message is included in the context window
35
+ // at its correct chronological position.
36
+ const lastUserMsg = lastUserMessage(context);
37
+ if (lastUserMsg && !messages.includes(lastUserMsg)) {
38
+ messages.unshift(lastUserMsg);
39
+ }
40
+
41
+ const entries = messages
42
+ .map((message) => {
43
+ const content = extractTextFromContent(message.content).trim();
44
+ if (!content) return '';
45
+
46
+ const timeStamp = message.timestamp ? ` timestamp="${new Date(message.timestamp).toISOString()}"` : '';
47
+
48
+ switch (message.role) {
49
+ case 'user':
50
+ return `<USER${timeStamp}>\n${escapeXML(content)}\n</USER>`;
51
+ case 'assistant':
52
+ return `<ASSISTANT${timeStamp}>\n${escapeXML(content)}\n</ASSISTANT>`;
53
+ case 'toolResult':
54
+ return `<TOOL name="${escapeXML(message.toolName ?? 'unknown')}"${message.isError ? ' error="true"' : ''}${timeStamp}>\n${escapeXML(content)}\n</TOOL>`;
55
+ default:
56
+ return `<${(message as { role: string }).role.toUpperCase()}${timeStamp}>\n${escapeXML(content)}\n</${(message as { role: string }).role.toUpperCase()}>`;
57
+ }
58
+ })
59
+ .filter(Boolean)
60
+ .join('\n\n');
61
+
62
+ if (!entries) return '';
63
+ return `<HISTORY>\n${entries}\n</HISTORY>`;
64
+ };
65
+
66
+ /** Count tool results since the last user message (current turn only). */
67
+ export const countToolResultsSinceLastUserPrompt = (context: Context): number => {
68
+ let count = 0;
69
+ for (let i = context.messages.length - 1; i >= 0; i--) {
70
+ const msg = context.messages[i];
71
+ if (msg.role === 'user') break;
72
+ if (msg.role === 'toolResult') count++;
73
+ }
74
+ return count;
75
+ };
76
+
77
+ const countConsecutiveRecentToolFailures = (context: Context): number => {
78
+ let count = 0;
79
+ for (let i = context.messages.length - 1; i >= 0; i--) {
80
+ const msg = context.messages[i];
81
+ if (msg.role === 'user') break;
82
+ if (msg.role !== 'toolResult') continue;
83
+ if (!msg.isError) break;
84
+ count++;
85
+ }
86
+ return count;
87
+ };
88
+
89
+ export const buildRoutingDecision = (
90
+ profileName: string,
91
+ profile: RouterProfile,
92
+ tier: RouterTier,
93
+ reasoning: string,
94
+ lastClassifierRunToolCount?: number,
95
+ ): RoutingDecision => {
96
+ const routedTierConf = profile[tier]!;
97
+
98
+ const { provider, modelId } = parseCanonicalModelRef(routedTierConf.model);
99
+
100
+ return {
101
+ profile: profileName,
102
+ tier,
103
+ targetProvider: provider,
104
+ targetModelId: modelId,
105
+ targetLabel: routedTierConf.model,
106
+ reasoning,
107
+ thinking: routedTierConf.thinking ?? 'medium',
108
+ timestamp: Date.now(),
109
+ lastClassifierRunToolCount,
110
+ };
111
+ };
112
+
113
+ /**
114
+ * Determine if the classifier should be run based on the current context and configuration.
115
+ */
116
+ export const shouldRunClassifier = (
117
+ currentConfig: RouterConfig,
118
+ context: Context,
119
+ lastDecision: RoutingDecision | undefined,
120
+ lastMsgWasTool: boolean,
121
+ toolResultsCount: number,
122
+ debugEnabled?: boolean,
123
+ lastExtensionContext?: ExtensionContext,
124
+ ): boolean => {
125
+ if (!lastMsgWasTool) return true;
126
+
127
+ const confInitN = currentConfig.classifierRunOnceAfterToolCount ?? 3;
128
+ const confFailN = currentConfig.classifierRunAfterToolFailures ?? 2;
129
+ const lastCls = lastDecision?.lastClassifierRunToolCount ?? 0;
130
+
131
+ const triggers: string[] = [];
132
+ if (confInitN > 0 && toolResultsCount >= confInitN && confInitN > lastCls) {
133
+ triggers.push(`init(≥${confInitN})`);
134
+ }
135
+ const failCount = countConsecutiveRecentToolFailures(context);
136
+ if (failCount >= confFailN) triggers.push(`fail(${failCount}≥${confFailN})`);
137
+ const confInterval = currentConfig.classifierInterval ?? 10;
138
+ if (confInterval > 0 && Math.floor(toolResultsCount / confInterval) > Math.floor(lastCls / confInterval)) {
139
+ triggers.push(`interval(%${confInterval})`);
140
+ }
141
+
142
+ if (debugEnabled && lastExtensionContext && triggers.length > 0) {
143
+ lastExtensionContext.ui.notify(`Running router classifier — ${triggers.join(', ')} (cont:${toolResultsCount}) ...`, 'info');
144
+ }
145
+ return triggers.length > 0;
146
+ };
147
+
148
+ /**
149
+ * Build the classifier prompt with conversation history and previous decision context.
150
+ */
151
+ const buildClassifierPrompt = (
152
+ context: Context,
153
+ previousDecision: RoutingDecision | undefined,
154
+ classifierInterval?: number,
155
+ ): string => {
156
+ // Include messages covering the full classifier interval plus padding for coherence.
157
+ // This ensures the classifier has enough history to understand the conversation
158
+ // trajectory without needing the entire session.
159
+ const historyLimit = (classifierInterval ?? 10) + 4;
160
+ const historyText = getRecentConversationText(context, historyLimit);
161
+
162
+ const previousTierLine = previousDecision?.tier
163
+ ? `Previous tier: ${previousDecision.tier} (${previousDecision.reasoning})`
164
+ : '';
165
+
166
+ return `You are a model router classifier. Choose the most appropriate tier for the task.
167
+ Prefer lower tiers when they suffice.
168
+
169
+ Tiers:
170
+
171
+ low — Tasks requiring minimal reasoning, where the action is obvious:
172
+ Good for: summaries, changelogs, renames, reformatting, simple edits,
173
+ lookups ("where is X"), yes/no questions, quick explanations,
174
+ trivial error explanation ("what does this error mean"),
175
+ small repetitive changes across files.
176
+ NOT for: debugging (traces, root cause investigation), any task where you
177
+ need to figure out what to do, novel code generation, complex
178
+ multi-step logic.
179
+
180
+ medium — Standard coding with clear scope:
181
+ Good for: well-defined feature implementation, known bug pattern fixes,
182
+ writing tests within existing patterns, multi-file edits with
183
+ specific instructions, focused debugging.
184
+ NOT for: novel architecture, security analysis, open-ended research,
185
+ problems where the approach is unclear from the start.
186
+
187
+ high — Complex reasoning where the approach is unclear:
188
+ Good for: architecture design from scratch, security threat modeling,
189
+ root-cause analysis of obscure issues, multi-system migration
190
+ planning, novel algorithm design, trade-off analysis with
191
+ no established precedent.
192
+ NOT for: reading files to gather context, implementing a plan that
193
+ already exists, editing files when the user specified exactly
194
+ what to change.
195
+
196
+ ${previousTierLine}
197
+
198
+ Conversation history (The Context):
199
+
200
+ \`\`\`xml
201
+ ${historyText}
202
+ \`\`\`
203
+
204
+ Return your decision in exactly two lines:
205
+ Tier: [high|medium|low]
206
+ Reasoning: [one concise sentence summarizing the request's complexity and why it fits the tier]`;
207
+ };
208
+
209
+ export const runClassifier = async (
210
+ currentConfig: RouterConfig,
211
+ modelRegistry: ExtensionContext['modelRegistry'],
212
+ context: Context,
213
+ previousDecision: RoutingDecision | undefined,
214
+ extCtx?: ExtensionContext,
215
+ debugEnabled = false,
216
+ ): Promise<{ tier: RouterTier; reasoning: string } | undefined> => {
217
+ const classifierModels = currentConfig.classifierModels ?? [];
218
+ if (classifierModels.length === 0) return undefined;
219
+
220
+ const thinking = currentConfig.classifierModelThinking;
221
+ const classifierInterval = currentConfig.classifierInterval ?? 10;
222
+ const classifierPrompt = buildClassifierPrompt(context, previousDecision, classifierInterval);
223
+ const classifierContext: Context = { messages: [{ role: 'user', content: classifierPrompt, timestamp: Date.now() }] };
224
+
225
+ for (const classifierModelRef of classifierModels) {
226
+ const model = resolveModelFromRef(classifierModelRef, modelRegistry);
227
+ if (!model) {
228
+ extCtx?.ui.notify(`[router] Classifier model "${classifierModelRef}" is not available, skipping.`, 'warning');
229
+ continue;
230
+ }
231
+
232
+ const auth = await modelRegistry.getApiKeyAndHeaders(model);
233
+ if (!auth.ok) {
234
+ const reason = `Auth failed for model: ${classifierModelRef}: ${auth.error}`;
235
+ extCtx?.ui.notify(`[router] ${reason}`, 'warning');
236
+ continue;
237
+ }
238
+
239
+ const isOpenRouter = parseCanonicalModelRef(classifierModelRef).provider === 'openrouter';
240
+
241
+ const classifierOptions: Record<string, unknown> = {
242
+ apiKey: auth.apiKey,
243
+ headers: {
244
+ ...(auth.headers ?? {}),
245
+ ...(isOpenRouter ? OPENROUTER_ATTR_HEADERS : {}),
246
+ },
247
+ ...(thinking && thinking !== 'off' ? { reasoning: thinking } : {}),
248
+ };
249
+
250
+
251
+ if (isOpenRouter) {
252
+ const onPayload = createOpenRouterOnPayload(extCtx?.sessionManager);
253
+ if (onPayload) classifierOptions.onPayload = onPayload;
254
+ }
255
+
256
+ const MAX_CLASSIFIER_ATTEMPTS = 3;
257
+ for (let attempt = 1; attempt <= MAX_CLASSIFIER_ATTEMPTS; attempt++) {
258
+ try {
259
+ const stream = streamSimple(model, classifierContext, classifierOptions);
260
+ let fullText = '';
261
+ for await (const event of stream) {
262
+ if (event.type === 'error') throw new Error(event.error?.errorMessage ?? 'Unknown classifier error');
263
+ if (event.type === 'text_delta') fullText += event.delta;
264
+ }
265
+
266
+ const tierMatch = fullText.match(/"?tier"?\s*:\s*"?(high|medium|low)/i);
267
+ const reasoningMatch = fullText.match(/"?reasoning"?\s*:\s*"?([^\n\r]+)/i);
268
+ if (tierMatch) {
269
+ return {
270
+ tier: tierMatch[1].toLowerCase() as RouterTier,
271
+ reasoning: reasoningMatch ? reasoningMatch[1].trim() : 'Classifier decision.',
272
+ };
273
+ }
274
+ if (debugEnabled && extCtx) {
275
+ extCtx.ui.notify('[router] Classifier gave an unparseable response.', 'warning');
276
+ }
277
+ } catch (_err) {
278
+ if (attempt < MAX_CLASSIFIER_ATTEMPTS) {
279
+ const errMsg = (_err as Error)?.message ?? String(_err);
280
+ const detectedStatus = /429|Too Many Requests|rate.?limit/i.test(errMsg) ? 429 : undefined;
281
+ const waitMs = detectedStatus === 429 ? attempt * 2000 : 1000;
282
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
283
+ }
284
+ }
285
+ }
286
+ }
287
+
288
+ extCtx?.ui.notify('[router] Classifier models all failed — falling back to medium tier.', 'warning');
289
+ return undefined;
290
+ };
@@ -0,0 +1,39 @@
1
+ import type {
2
+ RouterPinByProfile,
3
+ RoutingDecision,
4
+ RouterPersistedState,
5
+ } from './types';
6
+
7
+ export const isRouterPersistedState = (
8
+ value: unknown,
9
+ ): value is RouterPersistedState => {
10
+ if (typeof value !== 'object' || value === null) {
11
+ return false;
12
+ }
13
+ const v = value as Record<string, unknown>;
14
+ return (
15
+ typeof v.enabled === 'boolean' &&
16
+ typeof v.selectedProfile === 'string' &&
17
+ typeof v.timestamp === 'number'
18
+ );
19
+ };
20
+
21
+ export const buildPersistedState = (
22
+ routerEnabled: boolean,
23
+ selectedProfile: string | undefined,
24
+ pinnedTierByProfile: RouterPinByProfile,
25
+ debugEnabled: boolean,
26
+ debugHistory: RoutingDecision[],
27
+ lastDecision?: RoutingDecision,
28
+ lastNonRouterModel?: string,
29
+ ): RouterPersistedState => ({
30
+ enabled: routerEnabled,
31
+ selectedProfile: selectedProfile ?? '',
32
+ pinTier: selectedProfile ? pinnedTierByProfile[selectedProfile] : undefined,
33
+ pinByProfile: { ...pinnedTierByProfile },
34
+ debugEnabled,
35
+ debugHistory,
36
+ lastDecision,
37
+ lastNonRouterModel,
38
+ timestamp: Date.now(),
39
+ });
@@ -0,0 +1,74 @@
1
+ import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
2
+
3
+ export type RouterTier = 'high' | 'medium' | 'low';
4
+ export type RouterPinByProfile = Partial<Record<string, RouterTier>>;
5
+
6
+ export interface RoutedTierConfig {
7
+ model: string;
8
+ thinking?: ThinkingLevel;
9
+ fallbacks?: string[];
10
+ }
11
+
12
+ export interface RouterProfile {
13
+ high?: RoutedTierConfig;
14
+ medium?: RoutedTierConfig;
15
+ low?: RoutedTierConfig;
16
+ }
17
+
18
+ export interface RouterConfig {
19
+ debug?: boolean;
20
+ classifierModels?: string[];
21
+ classifierModelThinking?: ThinkingLevel;
22
+ /** Run the classifier once after this many tool continuations. Default: 3. */
23
+ classifierRunOnceAfterToolCount?: number;
24
+ /** Run the classifier after this many consecutive tool failures in a single turn. Default: 2. */
25
+ classifierRunAfterToolFailures?: number;
26
+ /** Run the classifier every Nth tool continuation during long chains. Default: 10. */
27
+ classifierInterval?: number;
28
+ defaultContextThresholdPercent?: number;
29
+ contextThresholdPercentOverrides?: Record<string, number>;
30
+ profiles: Record<string, RouterProfile>;
31
+ }
32
+
33
+ export interface RoutingDecision {
34
+ profile: string;
35
+ tier: RouterTier;
36
+ targetProvider: string;
37
+ targetModelId: string;
38
+ targetLabel: string;
39
+ reasoning: string;
40
+ thinking: ThinkingLevel;
41
+ timestamp: number;
42
+ isFallback?: boolean;
43
+ isContextTriggered?: boolean;
44
+ /** Tool-continuation count when the classifier last ran */
45
+ lastClassifierRunToolCount?: number;
46
+ }
47
+
48
+ export interface RouterPersistedState {
49
+ enabled: boolean;
50
+ selectedProfile: string;
51
+ pinTier?: RouterTier;
52
+ pinByProfile?: RouterPinByProfile;
53
+ debugEnabled?: boolean;
54
+ debugHistory?: RoutingDecision[];
55
+ lastDecision?: RoutingDecision;
56
+ lastNonRouterModel?: string;
57
+ timestamp: number;
58
+ }
59
+
60
+ export interface ConfigLoadResult {
61
+ config: RouterConfig;
62
+ warnings: string[];
63
+ }
64
+
65
+ export interface ParsedConfigFile {
66
+ config: Partial<RouterConfig>;
67
+ warnings: string[];
68
+ }
69
+
70
+ export interface CustomSessionEntry {
71
+ type: string;
72
+ customType?: string;
73
+ data?: unknown;
74
+ }
@@ -0,0 +1,54 @@
1
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
2
+ import type {
3
+ RoutingDecision,
4
+ RouterPinByProfile,
5
+ } from './types';
6
+
7
+ const getDecisionFlags = (decision: RoutingDecision): string[] => {
8
+ const flags: string[] = [];
9
+ if (decision.isFallback) flags.push('fallback');
10
+ if (decision.isContextTriggered) flags.push('context');
11
+ return flags;
12
+ };
13
+
14
+ export const formatDecision = (d: RoutingDecision): string => {
15
+ return `[${new Date(d.timestamp).toLocaleTimeString()}] ${d.tier} -> ${d.targetProvider}/${d.targetModelId} (${d.thinking}) - ${d.reasoning}`;
16
+ };
17
+
18
+ export const formatPinSummary = (
19
+ pinnedTierByProfile: RouterPinByProfile,
20
+ ): string => {
21
+ const entries = Object.entries(pinnedTierByProfile)
22
+ .sort(([a], [b]) => a.localeCompare(b))
23
+ .map(([profile, tier]) => `${profile}:${tier}`);
24
+ return entries.length > 0 ? entries.join(', ') : 'none';
25
+ };
26
+
27
+ export const updateStatus = (
28
+ ctx: ExtensionContext,
29
+ routerEnabled: boolean,
30
+ selectedProfile: string | undefined,
31
+ pinnedTierByProfile: RouterPinByProfile,
32
+ lastDecision: RoutingDecision | undefined,
33
+ ) => {
34
+ const activePin = selectedProfile ? pinnedTierByProfile[selectedProfile] : undefined;
35
+ const pinLabel = activePin ? ` [pin:${activePin}]` : '';
36
+
37
+ let detail: string;
38
+ if (routerEnabled && selectedProfile) {
39
+ const matchesProfile = lastDecision?.profile === selectedProfile;
40
+ const matchesPin = activePin ? lastDecision?.tier === activePin : true;
41
+
42
+ if (matchesProfile && matchesPin && lastDecision) {
43
+ const pinnedStar = activePin && lastDecision.tier === activePin ? ' *' : '';
44
+ const flags = getDecisionFlags(lastDecision);
45
+ const flagsStr = flags.length > 0 ? ` [${flags.join(',')}]` : '';
46
+ detail = `${pinLabel} ${lastDecision.tier}${pinnedStar}${flagsStr} -> ${lastDecision.targetProvider}/${lastDecision.targetModelId}`;
47
+ } else {
48
+ detail = `${pinLabel} waiting`;
49
+ }
50
+ } else {
51
+ detail = ' off';
52
+ }
53
+ ctx.ui.setStatus('router', ctx.ui.theme.fg('accent', '⇋') + ctx.ui.theme.fg('dim', detail));
54
+ };
@@ -0,0 +1,48 @@
1
+ {
2
+ "debug": false,
3
+ "classifierModels": ["google/gemini-flash-lite-latest", "openrouter/deepseek/deepseek-v4-flash"],
4
+ "classifierModelThinking": "off",
5
+ "classifierRunOnceAfterToolCount": 3,
6
+ "classifierRunAfterToolFailures": 2,
7
+ "classifierInterval": 10,
8
+ "defaultContextThresholdPercent": 70,
9
+ "contextThresholdPercentOverrides": {
10
+ "openrouter/deepseek/deepseek-v4-flash": 60,
11
+ "openrouter/deepseek/deepseek-v4-pro": 50,
12
+ "openrouter/google/gemma-4-31b-it": 50,
13
+ "openrouter/google/gemini-3-flash-preview": 80,
14
+ "openrouter/openai/gpt-5.4-nano": 30
15
+ },
16
+ "profiles": {
17
+ "auto": {
18
+ "high": {
19
+ "model": "openai/gpt-5.4-pro",
20
+ "thinking": "high",
21
+ "fallbacks": ["anthropic/claude-3-5-sonnet-20241022"]
22
+ },
23
+ "medium": { "model": "google/gemini-flash-latest", "thinking": "medium" },
24
+ "low": { "model": "openai/gpt-5.4-nano", "thinking": "low" }
25
+ },
26
+ "cheap": {
27
+ "high": { "model": "google/gemini-flash-latest", "thinking": "low" },
28
+ "medium": { "model": "openai/gpt-5.4-nano", "thinking": "off" },
29
+ "low": { "model": "google/gemini-flash-lite-latest", "thinking": "off" }
30
+ },
31
+ "deep": {
32
+ "high": { "model": "openai/o1-preview", "thinking": "xhigh" },
33
+ "medium": { "model": "openai/gpt-5.4-pro", "thinking": "medium" },
34
+ "low": { "model": "google/gemini-flash-latest", "thinking": "low" }
35
+ },
36
+ "anthropic": {
37
+ "high": {
38
+ "model": "anthropic/claude-3-5-sonnet-20241022",
39
+ "thinking": "high"
40
+ },
41
+ "medium": {
42
+ "model": "anthropic/claude-3-5-sonnet-20241022",
43
+ "thinking": "medium"
44
+ },
45
+ "low": { "model": "anthropic/claude-3-haiku-20240307", "thinking": "low" }
46
+ }
47
+ }
48
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@kdejaeger/pi-model-router",
3
+ "version": "0.3.1",
4
+ "type": "module",
5
+ "description": "Intelligent per-turn model router extension for the pi coding agent",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi",
9
+ "model-router",
10
+ "llm",
11
+ "coding-agent",
12
+ "extension"
13
+ ],
14
+ "license": "MIT",
15
+ "author": "Koen De Jaeger",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/kdejaeger/pi-model-router.git"
19
+ },
20
+ "homepage": "https://github.com/kdejaeger/pi-model-router#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/kdejaeger/pi-model-router/issues"
23
+ },
24
+ "files": [
25
+ "extensions/",
26
+ "docs/ARCHITECTURE.md",
27
+ "model-router.example.json",
28
+ "README.md"
29
+ ],
30
+ "exports": {
31
+ ".": "./extensions/index.ts"
32
+ },
33
+ "pi": {
34
+ "extensions": [
35
+ "./extensions/index.ts"
36
+ ]
37
+ },
38
+ "scripts": {
39
+ "tsc": "tsc --noEmit",
40
+ "build": "tsc",
41
+ "prepublishOnly": "npm run tsc"
42
+ },
43
+ "peerDependencies": {
44
+ "@earendil-works/pi-agent-core": "*",
45
+ "@earendil-works/pi-ai": "*",
46
+ "@earendil-works/pi-coding-agent": "*",
47
+ "@earendil-works/pi-tui": "*",
48
+ "@sinclair/typebox": "*"
49
+ },
50
+ "devDependencies": {
51
+ "prettier": "^3.8.1",
52
+ "typescript": "^6.0.2"
53
+ }
54
+ }