@kylebrodeur/pi-model-router 0.1.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 +42 -0
- package/CONTRIBUTING.md +310 -0
- package/LEARNINGS.md +181 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +111 -0
- package/README.md +195 -0
- package/TESTING.md +374 -0
- package/docs/ARCHITECTURE.md +54 -0
- package/docs/UPSTREAM_ISSUE_scoped_models.md +94 -0
- package/extensions/commands.ts +1068 -0
- package/extensions/config.ts +415 -0
- package/extensions/constants.ts +1 -0
- package/extensions/index.ts +583 -0
- package/extensions/ollama-sync.ts +254 -0
- package/extensions/provider.ts +558 -0
- package/extensions/rate-limit.ts +317 -0
- package/extensions/routing.ts +418 -0
- package/extensions/scope-shim.ts +213 -0
- package/extensions/state.ts +49 -0
- package/extensions/types.ts +148 -0
- package/extensions/ui.ts +130 -0
- package/model-router.agent-bus.json +15 -0
- package/model-router.essential.json +31 -0
- package/model-router.example.json +70 -0
- package/model-router.ledger.json +15 -0
- package/package.json +64 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { streamSimple, type Context, type Message } from '@mariozechner/pi-ai';
|
|
2
|
+
import type { ExtensionContext } from '@mariozechner/pi-coding-agent';
|
|
3
|
+
import type {
|
|
4
|
+
RouterTier,
|
|
5
|
+
RouterPhase,
|
|
6
|
+
RouterProfile,
|
|
7
|
+
RoutingDecision,
|
|
8
|
+
RoutingRule,
|
|
9
|
+
RouterThinkingByTier,
|
|
10
|
+
} from './types';
|
|
11
|
+
import { parseCanonicalModelRef, isRouterTier } from './config';
|
|
12
|
+
|
|
13
|
+
export const extractTextFromContent = (
|
|
14
|
+
content: string | Message['content'],
|
|
15
|
+
): string => {
|
|
16
|
+
if (typeof content === 'string') {
|
|
17
|
+
return content;
|
|
18
|
+
}
|
|
19
|
+
return content
|
|
20
|
+
.map((part) => {
|
|
21
|
+
if (part.type === 'text') return part.text;
|
|
22
|
+
if (part.type === 'thinking') return part.thinking;
|
|
23
|
+
if (part.type === 'toolCall')
|
|
24
|
+
return `${part.name} ${JSON.stringify(part.arguments)}`;
|
|
25
|
+
return '';
|
|
26
|
+
})
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.join('\n');
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const getLastUserText = (context: Context): string => {
|
|
32
|
+
for (let i = context.messages.length - 1; i >= 0; i--) {
|
|
33
|
+
const message = context.messages[i];
|
|
34
|
+
if (message.role === 'user') {
|
|
35
|
+
return extractTextFromContent(message.content).trim();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return '';
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const getRecentConversationText = (
|
|
42
|
+
context: Context,
|
|
43
|
+
limit = 6,
|
|
44
|
+
): string => {
|
|
45
|
+
return context.messages
|
|
46
|
+
.slice(-limit)
|
|
47
|
+
.map((message) => extractTextFromContent(message.content).trim())
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.join('\n')
|
|
50
|
+
.toLowerCase();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const countToolResults = (context: Context): number => {
|
|
54
|
+
return context.messages.filter((message) => message.role === 'toolResult')
|
|
55
|
+
.length;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const countWords = (text: string): number => {
|
|
59
|
+
return text.split(/\s+/).filter(Boolean).length;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const hasImageAttachment = (context: Context): boolean => {
|
|
63
|
+
return context.messages.some(
|
|
64
|
+
(message) =>
|
|
65
|
+
Array.isArray(message.content) &&
|
|
66
|
+
message.content.some((part) => part.type === 'image'),
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const containsAny = (text: string, keywords: string[]): boolean => {
|
|
71
|
+
return keywords.some((keyword) => text.includes(keyword));
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const phaseForTier = (tier: RouterTier): RouterPhase => {
|
|
75
|
+
if (tier === 'high') return 'planning';
|
|
76
|
+
if (tier === 'medium') return 'implementation';
|
|
77
|
+
return 'lightweight';
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const buildRoutingDecision = (
|
|
81
|
+
profileName: string,
|
|
82
|
+
profile: RouterProfile,
|
|
83
|
+
tier: RouterTier,
|
|
84
|
+
phase: RouterPhase,
|
|
85
|
+
reasoning: string,
|
|
86
|
+
thinkingOverrides?: RouterThinkingByTier,
|
|
87
|
+
isClassifier?: boolean,
|
|
88
|
+
): RoutingDecision => {
|
|
89
|
+
const routed = profile[tier];
|
|
90
|
+
const { provider, modelId } = parseCanonicalModelRef(routed.model);
|
|
91
|
+
const baseThinking =
|
|
92
|
+
routed.thinking ??
|
|
93
|
+
(tier === 'high' ? 'high' : tier === 'low' ? 'low' : 'medium');
|
|
94
|
+
const effectiveThinking = thinkingOverrides?.[tier] ?? baseThinking;
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
profile: profileName,
|
|
98
|
+
tier,
|
|
99
|
+
phase,
|
|
100
|
+
targetProvider: provider,
|
|
101
|
+
targetModelId: modelId,
|
|
102
|
+
targetLabel: routed.model,
|
|
103
|
+
reasoning,
|
|
104
|
+
thinking: effectiveThinking,
|
|
105
|
+
timestamp: Date.now(),
|
|
106
|
+
isClassifier,
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const decideRouting = (
|
|
111
|
+
context: Context,
|
|
112
|
+
profileName: string,
|
|
113
|
+
profile: RouterProfile,
|
|
114
|
+
previousDecision: RoutingDecision | undefined,
|
|
115
|
+
pinnedTier?: RouterTier,
|
|
116
|
+
thinkingOverrides?: RouterThinkingByTier,
|
|
117
|
+
phaseBias = 0.5,
|
|
118
|
+
rules?: RoutingRule[],
|
|
119
|
+
isBudgetExceeded = false,
|
|
120
|
+
): RoutingDecision => {
|
|
121
|
+
const prompt = getLastUserText(context).toLowerCase();
|
|
122
|
+
const recentConversation = getRecentConversationText(context);
|
|
123
|
+
const toolResultCount = countToolResults(context);
|
|
124
|
+
const wordCount = countWords(prompt);
|
|
125
|
+
const multiLinePrompt = prompt.split('\n').length >= 4;
|
|
126
|
+
|
|
127
|
+
const explicitHighHints = [
|
|
128
|
+
'best',
|
|
129
|
+
'deep',
|
|
130
|
+
'deeply',
|
|
131
|
+
'carefully',
|
|
132
|
+
'thoroughly',
|
|
133
|
+
'robust',
|
|
134
|
+
'comprehensive',
|
|
135
|
+
'step by step',
|
|
136
|
+
'think hard',
|
|
137
|
+
'highest quality',
|
|
138
|
+
];
|
|
139
|
+
const explicitLowHints = [
|
|
140
|
+
'fast',
|
|
141
|
+
'cheap',
|
|
142
|
+
'quick',
|
|
143
|
+
'quickly',
|
|
144
|
+
'brief',
|
|
145
|
+
'briefly',
|
|
146
|
+
'one sentence',
|
|
147
|
+
'one line',
|
|
148
|
+
'tiny',
|
|
149
|
+
'small',
|
|
150
|
+
];
|
|
151
|
+
const planningKeywords = [
|
|
152
|
+
'plan',
|
|
153
|
+
'planning',
|
|
154
|
+
'architecture',
|
|
155
|
+
'architect',
|
|
156
|
+
'design',
|
|
157
|
+
'tradeoff',
|
|
158
|
+
'trade-off',
|
|
159
|
+
'research',
|
|
160
|
+
'investigate',
|
|
161
|
+
'root cause',
|
|
162
|
+
'analyze',
|
|
163
|
+
'analysis',
|
|
164
|
+
'migration',
|
|
165
|
+
'strategy',
|
|
166
|
+
'compare',
|
|
167
|
+
'options',
|
|
168
|
+
'approach',
|
|
169
|
+
];
|
|
170
|
+
const summaryKeywords = [
|
|
171
|
+
'summarize',
|
|
172
|
+
'summary',
|
|
173
|
+
'changelog',
|
|
174
|
+
'rewrite',
|
|
175
|
+
'reformat',
|
|
176
|
+
'format',
|
|
177
|
+
'rename',
|
|
178
|
+
'explain briefly',
|
|
179
|
+
'recap',
|
|
180
|
+
'tl;dr',
|
|
181
|
+
];
|
|
182
|
+
const implementationKeywords = [
|
|
183
|
+
'implement',
|
|
184
|
+
'code',
|
|
185
|
+
'fix',
|
|
186
|
+
'update',
|
|
187
|
+
'edit',
|
|
188
|
+
'write',
|
|
189
|
+
'refactor',
|
|
190
|
+
'add tests',
|
|
191
|
+
'patch',
|
|
192
|
+
'change',
|
|
193
|
+
'apply',
|
|
194
|
+
'continue',
|
|
195
|
+
'resume',
|
|
196
|
+
'make the changes',
|
|
197
|
+
'go ahead',
|
|
198
|
+
];
|
|
199
|
+
const lookupKeywords = [
|
|
200
|
+
'where is',
|
|
201
|
+
'which file',
|
|
202
|
+
'show me',
|
|
203
|
+
'list',
|
|
204
|
+
'what files',
|
|
205
|
+
'find',
|
|
206
|
+
'grep',
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
let phase: RouterPhase = previousDecision?.phase ?? 'implementation';
|
|
210
|
+
let tier: RouterTier = 'medium';
|
|
211
|
+
let reasoning = 'Defaulted to medium tier for general coding work.';
|
|
212
|
+
let isRuleMatched = false;
|
|
213
|
+
|
|
214
|
+
if (pinnedTier) {
|
|
215
|
+
phase = phaseForTier(pinnedTier);
|
|
216
|
+
tier = pinnedTier;
|
|
217
|
+
reasoning = `Pinned to ${pinnedTier} tier via /router-pin.`;
|
|
218
|
+
} else {
|
|
219
|
+
// Check custom rules first
|
|
220
|
+
if (rules) {
|
|
221
|
+
for (const rule of rules) {
|
|
222
|
+
const matches = Array.isArray(rule.matches)
|
|
223
|
+
? rule.matches
|
|
224
|
+
: [rule.matches];
|
|
225
|
+
if (containsAny(prompt, matches)) {
|
|
226
|
+
tier = rule.tier;
|
|
227
|
+
phase = phaseForTier(tier);
|
|
228
|
+
reasoning =
|
|
229
|
+
rule.reason ??
|
|
230
|
+
`Matched custom routing rule for: ${matches.join(', ')}`;
|
|
231
|
+
isRuleMatched = true;
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!isRuleMatched) {
|
|
238
|
+
// Sticky phase adjustments
|
|
239
|
+
const highThreshold = Math.max(
|
|
240
|
+
40,
|
|
241
|
+
120 - (previousDecision?.phase === 'planning' ? phaseBias * 80 : 0),
|
|
242
|
+
);
|
|
243
|
+
const lowThreshold = Math.max(
|
|
244
|
+
4,
|
|
245
|
+
12 -
|
|
246
|
+
(previousDecision?.phase === 'implementation' ||
|
|
247
|
+
previousDecision?.phase === 'planning'
|
|
248
|
+
? phaseBias * 8
|
|
249
|
+
: 0),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
if (containsAny(prompt, explicitHighHints)) {
|
|
253
|
+
phase = 'planning';
|
|
254
|
+
tier = 'high';
|
|
255
|
+
reasoning =
|
|
256
|
+
'Detected an explicit request for deeper or higher-quality reasoning.';
|
|
257
|
+
} else if (containsAny(prompt, explicitLowHints)) {
|
|
258
|
+
phase = 'lightweight';
|
|
259
|
+
tier = 'low';
|
|
260
|
+
reasoning =
|
|
261
|
+
'Detected an explicit request for a faster or lighter response.';
|
|
262
|
+
} else if (containsAny(prompt, summaryKeywords)) {
|
|
263
|
+
phase = 'lightweight';
|
|
264
|
+
tier = 'low';
|
|
265
|
+
reasoning = 'Detected summary or lightweight transformation keywords.';
|
|
266
|
+
} else if (
|
|
267
|
+
containsAny(prompt, planningKeywords) ||
|
|
268
|
+
prompt.startsWith('why ') ||
|
|
269
|
+
wordCount >= highThreshold ||
|
|
270
|
+
multiLinePrompt
|
|
271
|
+
) {
|
|
272
|
+
phase = 'planning';
|
|
273
|
+
tier = 'high';
|
|
274
|
+
reasoning =
|
|
275
|
+
previousDecision?.phase === 'planning'
|
|
276
|
+
? 'Continued planning phase based on complexity or keywords.'
|
|
277
|
+
: 'Detected planning, broad analysis, or a high-complexity request.';
|
|
278
|
+
} else if (containsAny(prompt, implementationKeywords)) {
|
|
279
|
+
phase = 'implementation';
|
|
280
|
+
tier = 'medium';
|
|
281
|
+
reasoning =
|
|
282
|
+
'Detected implementation-oriented work with bounded execution scope.';
|
|
283
|
+
} else if (
|
|
284
|
+
containsAny(prompt, lookupKeywords) &&
|
|
285
|
+
wordCount <= 24 &&
|
|
286
|
+
toolResultCount === 0
|
|
287
|
+
) {
|
|
288
|
+
phase = 'lightweight';
|
|
289
|
+
tier = 'low';
|
|
290
|
+
reasoning = 'Detected a short read-only lookup request.';
|
|
291
|
+
} else if (
|
|
292
|
+
previousDecision?.phase === 'planning' &&
|
|
293
|
+
toolResultCount === 0 &&
|
|
294
|
+
wordCount > lowThreshold
|
|
295
|
+
) {
|
|
296
|
+
phase = 'planning';
|
|
297
|
+
tier = 'high';
|
|
298
|
+
reasoning =
|
|
299
|
+
'Kept the planning-phase bias because the conversation still looks exploratory.';
|
|
300
|
+
} else if (
|
|
301
|
+
toolResultCount > 0 ||
|
|
302
|
+
previousDecision?.phase === 'implementation' ||
|
|
303
|
+
recentConversation.includes('plan:')
|
|
304
|
+
) {
|
|
305
|
+
phase = 'implementation';
|
|
306
|
+
tier = 'medium';
|
|
307
|
+
reasoning =
|
|
308
|
+
'Detected active implementation work from prior tools or recent plan execution context.';
|
|
309
|
+
} else if (wordCount <= lowThreshold) {
|
|
310
|
+
phase = 'lightweight';
|
|
311
|
+
tier = 'low';
|
|
312
|
+
reasoning = 'Detected a short bounded request.';
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let isBudgetForced = false;
|
|
318
|
+
if (isBudgetExceeded && tier === 'high') {
|
|
319
|
+
tier = 'medium';
|
|
320
|
+
phase = 'implementation';
|
|
321
|
+
reasoning = `Budget exceeded. Downgraded from high to medium tier. (Original: ${reasoning})`;
|
|
322
|
+
isBudgetForced = true;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const decision = buildRoutingDecision(
|
|
326
|
+
profileName,
|
|
327
|
+
profile,
|
|
328
|
+
tier,
|
|
329
|
+
phase,
|
|
330
|
+
reasoning,
|
|
331
|
+
thinkingOverrides,
|
|
332
|
+
false,
|
|
333
|
+
);
|
|
334
|
+
decision.isRuleMatched = isRuleMatched;
|
|
335
|
+
decision.isBudgetForced = isBudgetForced;
|
|
336
|
+
return decision;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
export const runClassifier = async (
|
|
340
|
+
classifierModelRef: string,
|
|
341
|
+
modelRegistry: ExtensionContext['modelRegistry'],
|
|
342
|
+
context: Context,
|
|
343
|
+
currentPhase?: RouterPhase,
|
|
344
|
+
): Promise<{ tier: RouterTier; reasoning: string } | undefined> => {
|
|
345
|
+
try {
|
|
346
|
+
const { provider, modelId } = parseCanonicalModelRef(classifierModelRef);
|
|
347
|
+
const model = modelRegistry.find(provider, modelId);
|
|
348
|
+
if (!model) return undefined;
|
|
349
|
+
|
|
350
|
+
const auth = await modelRegistry.getApiKeyAndHeaders(model);
|
|
351
|
+
if (!auth.ok || !auth.apiKey) return undefined;
|
|
352
|
+
const apiKey = auth.apiKey;
|
|
353
|
+
const headers = auth.headers;
|
|
354
|
+
|
|
355
|
+
const promptText = getLastUserText(context);
|
|
356
|
+
const historyText = getRecentConversationText(context, 4);
|
|
357
|
+
|
|
358
|
+
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".
|
|
359
|
+
|
|
360
|
+
Tiers:
|
|
361
|
+
- high: Architecture, design, planning, tradeoff analysis, broad debugging, large refactors, codebase research.
|
|
362
|
+
- medium: Implementation of a known plan, multi-file edits, normal coding work, focused debugging, tests/fixes.
|
|
363
|
+
- low: Summaries, changelogs, formatting, quick explanations, small bounded transforms, simple read-only lookup.
|
|
364
|
+
|
|
365
|
+
${currentPhase ? `Current conversation phase: ${currentPhase}\n` : ''}
|
|
366
|
+
Recent history:
|
|
367
|
+
${historyText}
|
|
368
|
+
|
|
369
|
+
Latest user message:
|
|
370
|
+
${promptText}
|
|
371
|
+
|
|
372
|
+
Return your decision in exactly two lines:
|
|
373
|
+
Tier: [high|medium|low]
|
|
374
|
+
Reasoning: [one short sentence]
|
|
375
|
+
|
|
376
|
+
${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.' : ''}
|
|
377
|
+
${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.' : ''}`;
|
|
378
|
+
|
|
379
|
+
const classifierContext: Context = {
|
|
380
|
+
...context,
|
|
381
|
+
messages: [
|
|
382
|
+
{ role: 'user', content: classifierPrompt, timestamp: Date.now() },
|
|
383
|
+
],
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const stream = streamSimple(model, classifierContext, { apiKey, headers });
|
|
387
|
+
let fullText = '';
|
|
388
|
+
for await (const event of stream) {
|
|
389
|
+
if (
|
|
390
|
+
event.type === 'text_delta' &&
|
|
391
|
+
typeof (event as any).delta === 'string'
|
|
392
|
+
) {
|
|
393
|
+
fullText += (event as any).delta;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const lines = fullText.trim().split('\n');
|
|
398
|
+
const tierLine = lines.find((l) => l.toLowerCase().startsWith('tier:'));
|
|
399
|
+
const reasoningLine = lines.find((l) =>
|
|
400
|
+
l.toLowerCase().startsWith('reasoning:'),
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
if (tierLine) {
|
|
404
|
+
const tierValue = tierLine.split(':')[1].trim().toLowerCase();
|
|
405
|
+
if (isRouterTier(tierValue)) {
|
|
406
|
+
return {
|
|
407
|
+
tier: tierValue,
|
|
408
|
+
reasoning: reasoningLine
|
|
409
|
+
? reasoningLine.split(':')[1].trim()
|
|
410
|
+
: 'Classifier decision.',
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
} catch (error) {
|
|
415
|
+
// Ignore classifier errors and fall back to heuristics
|
|
416
|
+
}
|
|
417
|
+
return undefined;
|
|
418
|
+
};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope Shim — work around the lack of ExtensionAPI.setScopedModels().
|
|
3
|
+
*
|
|
4
|
+
* Today: writes `enabledModels` to Pi settings.json directly, then
|
|
5
|
+
* optionally reloads the session so Pi picks up the new scope.
|
|
6
|
+
* Tomorrow: swap `writeSettingsScope()` for `pi.setScopedModels()`
|
|
7
|
+
* when upstream exposes it.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { getAgentDir } from '@mariozechner/pi-coding-agent';
|
|
12
|
+
import type { Model } from '@mariozechner/pi-ai';
|
|
13
|
+
import type {
|
|
14
|
+
ExtensionAPI,
|
|
15
|
+
ExtensionContext,
|
|
16
|
+
} from '@mariozechner/pi-coding-agent';
|
|
17
|
+
import type { RouterProfile, RouterConfig } from './types';
|
|
18
|
+
import { parseCanonicalModelRef } from './config';
|
|
19
|
+
|
|
20
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export interface ScopeModelRef {
|
|
23
|
+
modelRef: string;
|
|
24
|
+
thinkingLevel?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ScopeSettingsResult {
|
|
28
|
+
enabledModels: string[] | undefined;
|
|
29
|
+
success: boolean;
|
|
30
|
+
message: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Derive scope from profile ──────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/** Derive the full scope from ALL router profiles (deduplicated, in config order). */
|
|
36
|
+
export const deriveRouterScope = (config: RouterConfig): ScopeModelRef[] => {
|
|
37
|
+
const seen = new Set<string>();
|
|
38
|
+
const result: ScopeModelRef[] = [];
|
|
39
|
+
|
|
40
|
+
for (const [, profile] of Object.entries(config.profiles)) {
|
|
41
|
+
for (const tier of ['high', 'medium', 'low'] as const) {
|
|
42
|
+
const tierConfig = profile[tier];
|
|
43
|
+
const refs = [tierConfig.model, ...(tierConfig.fallbacks ?? [])];
|
|
44
|
+
for (const modelRef of refs) {
|
|
45
|
+
if (!seen.has(modelRef)) {
|
|
46
|
+
seen.add(modelRef);
|
|
47
|
+
result.push({ modelRef, thinkingLevel: tierConfig.thinking });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return result;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Resolve scope refs into actual Model objects from the registry. */
|
|
57
|
+
export const resolveScopeFromRegistry = (
|
|
58
|
+
scope: ScopeModelRef[],
|
|
59
|
+
ctx: ExtensionContext,
|
|
60
|
+
): { model: Model<any>; thinkingLevel?: string }[] => {
|
|
61
|
+
const resolved: { model: Model<any>; thinkingLevel?: string }[] = [];
|
|
62
|
+
for (const entry of scope) {
|
|
63
|
+
try {
|
|
64
|
+
const { provider, modelId } = parseCanonicalModelRef(entry.modelRef);
|
|
65
|
+
const model = ctx.modelRegistry.find(provider, modelId);
|
|
66
|
+
if (model) {
|
|
67
|
+
resolved.push({ model, thinkingLevel: entry.thinkingLevel });
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// skip invalid refs
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return resolved;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// ─── Settings.json file operations (shim until upstream API) ──────────────────
|
|
77
|
+
|
|
78
|
+
const getSettingsPath = (): string => {
|
|
79
|
+
return join(getAgentDir(), 'settings.json');
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Read Pi settings.json as safely as possible. */
|
|
83
|
+
const readPiSettings = (): Record<string, unknown> => {
|
|
84
|
+
try {
|
|
85
|
+
const raw = readFileSync(getSettingsPath(), 'utf-8');
|
|
86
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
87
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
88
|
+
return parsed as Record<string, unknown>;
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// ignore — may not exist or be invalid JSON
|
|
92
|
+
}
|
|
93
|
+
return {};
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Write Pi settings.json atomically. */
|
|
97
|
+
const writePiSettings = (settings: Record<string, unknown>): void => {
|
|
98
|
+
writeFileSync(getSettingsPath(), JSON.stringify(settings, null, 2));
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Write the current profile's model refs to Pi settings.json `enabledModels`.
|
|
103
|
+
*
|
|
104
|
+
* This is the **shim** — once upstream exposes:
|
|
105
|
+
* `pi.setScopedModels(models: ScopedModelEntry[])`
|
|
106
|
+
* replace this entire function with that call.
|
|
107
|
+
*/
|
|
108
|
+
export const writeSettingsScope = (
|
|
109
|
+
scope: ScopeModelRef[],
|
|
110
|
+
mergeIntoExisting = false,
|
|
111
|
+
): ScopeSettingsResult => {
|
|
112
|
+
const patterns = scope.map((s) => s.modelRef);
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const settings = readPiSettings();
|
|
116
|
+
|
|
117
|
+
if (mergeIntoExisting && Array.isArray(settings.enabledModels)) {
|
|
118
|
+
// prepend router's models, then existing (deduplicated)
|
|
119
|
+
const merged = patterns.filter(
|
|
120
|
+
(p) => !(settings.enabledModels as string[]).includes(p),
|
|
121
|
+
);
|
|
122
|
+
settings.enabledModels = [
|
|
123
|
+
...merged,
|
|
124
|
+
...(settings.enabledModels as string[]),
|
|
125
|
+
];
|
|
126
|
+
} else {
|
|
127
|
+
settings.enabledModels = patterns;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
writePiSettings(settings);
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
enabledModels: settings.enabledModels as string[],
|
|
134
|
+
success: true,
|
|
135
|
+
message: `Updated settings.enabledModels with ${patterns.length} model(s). Run /reload or start a new session to apply.`,
|
|
136
|
+
};
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return {
|
|
139
|
+
enabledModels: undefined,
|
|
140
|
+
success: false,
|
|
141
|
+
message: `Failed to write settings.json: ${error instanceof Error ? error.message : String(error)}`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Clear `enabledModels` from Pi settings.json (restores all models).
|
|
148
|
+
*/
|
|
149
|
+
export const resetSettingsScope = (): ScopeSettingsResult => {
|
|
150
|
+
try {
|
|
151
|
+
const settings = readPiSettings();
|
|
152
|
+
if (settings.enabledModels === undefined) {
|
|
153
|
+
return {
|
|
154
|
+
enabledModels: undefined,
|
|
155
|
+
success: true,
|
|
156
|
+
message: 'No router scope override in settings.json. Nothing to reset.',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
delete settings.enabledModels;
|
|
160
|
+
writePiSettings(settings);
|
|
161
|
+
return {
|
|
162
|
+
enabledModels: undefined,
|
|
163
|
+
success: true,
|
|
164
|
+
message:
|
|
165
|
+
'Cleared settings.enabledModels. Run /reload or start a new session to apply.',
|
|
166
|
+
};
|
|
167
|
+
} catch (error) {
|
|
168
|
+
return {
|
|
169
|
+
enabledModels: undefined,
|
|
170
|
+
success: false,
|
|
171
|
+
message: `Failed to write settings.json: ${error instanceof Error ? error.message : String(error)}`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Read the current `enabledModels` from Pi settings.json.
|
|
178
|
+
*/
|
|
179
|
+
export const readSettingsScope = (): ScopeSettingsResult => {
|
|
180
|
+
const settings = readPiSettings();
|
|
181
|
+
const enabledModels = Array.isArray(settings.enabledModels)
|
|
182
|
+
? (settings.enabledModels as string[])
|
|
183
|
+
: undefined;
|
|
184
|
+
return {
|
|
185
|
+
enabledModels,
|
|
186
|
+
success: true,
|
|
187
|
+
message: enabledModels
|
|
188
|
+
? `Current enabledModels: ${enabledModels.join(', ')}`
|
|
189
|
+
: 'No enabledModels set in settings.json (all models available).',
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// ─── Future upstream migration (commented, ready to swap) ─────────────────────
|
|
194
|
+
|
|
195
|
+
/*
|
|
196
|
+
When upstream exposes `pi.setScopedModels`:
|
|
197
|
+
|
|
198
|
+
export const applyRouterScopeUpstream = (
|
|
199
|
+
pi: ExtensionAPI,
|
|
200
|
+
config: RouterConfig,
|
|
201
|
+
ctx: ExtensionContext,
|
|
202
|
+
): void => {
|
|
203
|
+
const scope = deriveRouterScope(config);
|
|
204
|
+
const resolved = resolveScopeFromRegistry(scope, ctx);
|
|
205
|
+
// @ts-expect-error — not yet in types
|
|
206
|
+
pi.setScopedModels?.(
|
|
207
|
+
resolved.map(({ model, thinkingLevel }) => ({
|
|
208
|
+
model,
|
|
209
|
+
thinkingLevel: thinkingLevel as ThinkingLevel,
|
|
210
|
+
})),
|
|
211
|
+
);
|
|
212
|
+
};
|
|
213
|
+
*/
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RouterPinByProfile,
|
|
3
|
+
RouterThinkingByProfile,
|
|
4
|
+
RoutingDecision,
|
|
5
|
+
RouterPersistedState,
|
|
6
|
+
} from './types';
|
|
7
|
+
|
|
8
|
+
export const isRouterPersistedState = (
|
|
9
|
+
value: unknown,
|
|
10
|
+
): value is RouterPersistedState => {
|
|
11
|
+
if (typeof value !== 'object' || value === null) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
const v = value as any;
|
|
15
|
+
return (
|
|
16
|
+
typeof v.enabled === 'boolean' &&
|
|
17
|
+
typeof v.selectedProfile === 'string' &&
|
|
18
|
+
typeof v.timestamp === 'number'
|
|
19
|
+
);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const buildPersistedState = (
|
|
23
|
+
routerEnabled: boolean,
|
|
24
|
+
selectedProfile: string,
|
|
25
|
+
pinnedTierByProfile: RouterPinByProfile,
|
|
26
|
+
thinkingByProfile: RouterThinkingByProfile,
|
|
27
|
+
debugEnabled: boolean,
|
|
28
|
+
widgetEnabled: boolean,
|
|
29
|
+
debugHistory: RoutingDecision[],
|
|
30
|
+
lastDecision: RoutingDecision | undefined,
|
|
31
|
+
lastNonRouterModel: string | undefined,
|
|
32
|
+
accumulatedCost: number,
|
|
33
|
+
): RouterPersistedState => {
|
|
34
|
+
return {
|
|
35
|
+
enabled: routerEnabled,
|
|
36
|
+
selectedProfile,
|
|
37
|
+
pinTier: pinnedTierByProfile[selectedProfile],
|
|
38
|
+
pinByProfile: { ...pinnedTierByProfile },
|
|
39
|
+
thinkingByProfile: { ...thinkingByProfile },
|
|
40
|
+
debugEnabled,
|
|
41
|
+
widgetEnabled,
|
|
42
|
+
debugHistory,
|
|
43
|
+
lastPhase: lastDecision?.phase,
|
|
44
|
+
lastDecision,
|
|
45
|
+
lastNonRouterModel,
|
|
46
|
+
accumulatedCost,
|
|
47
|
+
timestamp: Date.now(),
|
|
48
|
+
};
|
|
49
|
+
};
|