@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/extensions/index.ts
CHANGED
|
@@ -21,7 +21,6 @@ import {
|
|
|
21
21
|
saveLastRouterProfile,
|
|
22
22
|
} from './state';
|
|
23
23
|
import type {
|
|
24
|
-
CustomSessionEntry,
|
|
25
24
|
RouterConfig,
|
|
26
25
|
RouterPinByProfile,
|
|
27
26
|
RouterThinkingByProfile,
|
|
@@ -58,6 +57,74 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
58
57
|
let isInternalThinkingChange = false;
|
|
59
58
|
let ignoreStartupThinkingEvent = false;
|
|
60
59
|
|
|
60
|
+
const runtimeState = {
|
|
61
|
+
get lastRegisteredModels() {
|
|
62
|
+
return lastRegisteredModels;
|
|
63
|
+
},
|
|
64
|
+
set lastRegisteredModels(value: string) {
|
|
65
|
+
lastRegisteredModels = value;
|
|
66
|
+
},
|
|
67
|
+
get currentConfig() {
|
|
68
|
+
return currentConfig;
|
|
69
|
+
},
|
|
70
|
+
get currentModelRegistry() {
|
|
71
|
+
return currentModelRegistry;
|
|
72
|
+
},
|
|
73
|
+
get lastExtensionContext() {
|
|
74
|
+
return lastExtensionContext;
|
|
75
|
+
},
|
|
76
|
+
get selectedProfile() {
|
|
77
|
+
return selectedProfile;
|
|
78
|
+
},
|
|
79
|
+
set selectedProfile(value: string | undefined) {
|
|
80
|
+
selectedProfile = value;
|
|
81
|
+
},
|
|
82
|
+
get routerEnabled() {
|
|
83
|
+
return routerEnabled;
|
|
84
|
+
},
|
|
85
|
+
set routerEnabled(value: boolean) {
|
|
86
|
+
routerEnabled = value;
|
|
87
|
+
},
|
|
88
|
+
get lastDecision() {
|
|
89
|
+
return lastDecision;
|
|
90
|
+
},
|
|
91
|
+
set lastDecision(value: RoutingDecision | undefined) {
|
|
92
|
+
lastDecision = value;
|
|
93
|
+
},
|
|
94
|
+
thinkingByProfile,
|
|
95
|
+
pinnedTierByProfile,
|
|
96
|
+
get accumulatedCost() {
|
|
97
|
+
return accumulatedCost;
|
|
98
|
+
},
|
|
99
|
+
set accumulatedCost(value: number) {
|
|
100
|
+
accumulatedCost = value;
|
|
101
|
+
},
|
|
102
|
+
get debugEnabled() {
|
|
103
|
+
return debugEnabled;
|
|
104
|
+
},
|
|
105
|
+
set debugEnabled(value: boolean) {
|
|
106
|
+
debugEnabled = value;
|
|
107
|
+
},
|
|
108
|
+
get widgetEnabled() {
|
|
109
|
+
return widgetEnabled;
|
|
110
|
+
},
|
|
111
|
+
set widgetEnabled(value: boolean) {
|
|
112
|
+
widgetEnabled = value;
|
|
113
|
+
},
|
|
114
|
+
get debugHistory() {
|
|
115
|
+
return debugHistory;
|
|
116
|
+
},
|
|
117
|
+
get lastNonRouterModel() {
|
|
118
|
+
return lastNonRouterModel;
|
|
119
|
+
},
|
|
120
|
+
set lastNonRouterModel(value: string | undefined) {
|
|
121
|
+
lastNonRouterModel = value;
|
|
122
|
+
},
|
|
123
|
+
get lastConfigWarnings() {
|
|
124
|
+
return lastConfigWarnings;
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
61
128
|
const setModelInternally = async (
|
|
62
129
|
model: NonNullable<ExtensionContext['model']>,
|
|
63
130
|
) => {
|
|
@@ -92,7 +159,7 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
92
159
|
};
|
|
93
160
|
|
|
94
161
|
const persistState = () => {
|
|
95
|
-
const state = buildPersistedState(
|
|
162
|
+
const state = buildPersistedState({
|
|
96
163
|
routerEnabled,
|
|
97
164
|
selectedProfile,
|
|
98
165
|
pinnedTierByProfile,
|
|
@@ -103,7 +170,7 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
103
170
|
lastDecision,
|
|
104
171
|
lastNonRouterModel,
|
|
105
172
|
accumulatedCost,
|
|
106
|
-
);
|
|
173
|
+
});
|
|
107
174
|
const snapshot = JSON.stringify({
|
|
108
175
|
...state,
|
|
109
176
|
timestamp: 0,
|
|
@@ -133,18 +200,16 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
133
200
|
persistState,
|
|
134
201
|
syncPiThinkingLevel: setThinkingLevelInternally,
|
|
135
202
|
updateStatus: (ctx: ExtensionContext) =>
|
|
136
|
-
updateStatus(
|
|
137
|
-
ctx,
|
|
203
|
+
updateStatus(ctx, {
|
|
138
204
|
routerEnabled,
|
|
139
205
|
selectedProfile,
|
|
140
206
|
pinnedTierByProfile,
|
|
141
|
-
thinkingByProfile,
|
|
142
207
|
lastDecision,
|
|
143
208
|
lastNonRouterModel,
|
|
144
209
|
accumulatedCost,
|
|
145
210
|
widgetEnabled,
|
|
146
211
|
currentConfig,
|
|
147
|
-
),
|
|
212
|
+
}),
|
|
148
213
|
reloadConfig: (
|
|
149
214
|
ctx?: ExtensionContext,
|
|
150
215
|
options?: { preserveDebug?: boolean },
|
|
@@ -221,59 +286,13 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
221
286
|
return true;
|
|
222
287
|
},
|
|
223
288
|
registerRouterProvider: () => {
|
|
224
|
-
registerRouterProvider(
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
lastRegisteredModels = v;
|
|
232
|
-
},
|
|
233
|
-
get currentConfig() {
|
|
234
|
-
return currentConfig;
|
|
235
|
-
},
|
|
236
|
-
get currentModelRegistry() {
|
|
237
|
-
return currentModelRegistry;
|
|
238
|
-
},
|
|
239
|
-
get lastExtensionContext() {
|
|
240
|
-
return lastExtensionContext;
|
|
241
|
-
},
|
|
242
|
-
get selectedProfile() {
|
|
243
|
-
return selectedProfile;
|
|
244
|
-
},
|
|
245
|
-
set selectedProfile(v) {
|
|
246
|
-
selectedProfile = v;
|
|
247
|
-
},
|
|
248
|
-
get routerEnabled() {
|
|
249
|
-
return routerEnabled;
|
|
250
|
-
},
|
|
251
|
-
set routerEnabled(v) {
|
|
252
|
-
routerEnabled = v;
|
|
253
|
-
},
|
|
254
|
-
get lastDecision() {
|
|
255
|
-
return lastDecision;
|
|
256
|
-
},
|
|
257
|
-
set lastDecision(v) {
|
|
258
|
-
lastDecision = v;
|
|
259
|
-
},
|
|
260
|
-
thinkingByProfile,
|
|
261
|
-
pinnedTierByProfile,
|
|
262
|
-
get accumulatedCost() {
|
|
263
|
-
return accumulatedCost;
|
|
264
|
-
},
|
|
265
|
-
set accumulatedCost(v) {
|
|
266
|
-
accumulatedCost = v;
|
|
267
|
-
},
|
|
268
|
-
},
|
|
269
|
-
{
|
|
270
|
-
persistState,
|
|
271
|
-
recordDebugDecision,
|
|
272
|
-
getThinkingOverride,
|
|
273
|
-
updateStatus: actions.updateStatus,
|
|
274
|
-
syncPiThinkingLevel: setThinkingLevelInternally,
|
|
275
|
-
},
|
|
276
|
-
);
|
|
289
|
+
registerRouterProvider(pi, runtimeState, {
|
|
290
|
+
persistState,
|
|
291
|
+
recordDebugDecision,
|
|
292
|
+
getThinkingOverride,
|
|
293
|
+
updateStatus: actions.updateStatus,
|
|
294
|
+
syncPiThinkingLevel: setThinkingLevelInternally,
|
|
295
|
+
});
|
|
277
296
|
},
|
|
278
297
|
};
|
|
279
298
|
|
|
@@ -318,16 +337,16 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
318
337
|
|
|
319
338
|
await actions.ensureValidActiveRouterProfile(ctx);
|
|
320
339
|
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
.
|
|
324
|
-
|
|
325
|
-
|
|
340
|
+
const savedState = ctx.sessionManager
|
|
341
|
+
.getBranch()
|
|
342
|
+
.map((entry) =>
|
|
343
|
+
entry.type === 'custom' && entry.customType === 'router-state'
|
|
344
|
+
? entry.data
|
|
345
|
+
: undefined,
|
|
326
346
|
)
|
|
327
|
-
.
|
|
328
|
-
.findLast((data) => isRouterPersistedState(data));
|
|
347
|
+
.findLast(isRouterPersistedState);
|
|
329
348
|
|
|
330
|
-
if (
|
|
349
|
+
if (savedState) {
|
|
331
350
|
if (!hasExplicitStartupModel) {
|
|
332
351
|
selectedProfile = resolveProfileName(
|
|
333
352
|
currentConfig,
|
|
@@ -405,59 +424,7 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
405
424
|
actions.updateStatus(ctx);
|
|
406
425
|
};
|
|
407
426
|
|
|
408
|
-
registerCommands(
|
|
409
|
-
pi,
|
|
410
|
-
{
|
|
411
|
-
get currentConfig() {
|
|
412
|
-
return currentConfig;
|
|
413
|
-
},
|
|
414
|
-
get routerEnabled() {
|
|
415
|
-
return routerEnabled;
|
|
416
|
-
},
|
|
417
|
-
set routerEnabled(v) {
|
|
418
|
-
routerEnabled = v;
|
|
419
|
-
},
|
|
420
|
-
get selectedProfile() {
|
|
421
|
-
return selectedProfile;
|
|
422
|
-
},
|
|
423
|
-
set selectedProfile(v) {
|
|
424
|
-
selectedProfile = v;
|
|
425
|
-
},
|
|
426
|
-
pinnedTierByProfile,
|
|
427
|
-
thinkingByProfile,
|
|
428
|
-
get lastDecision() {
|
|
429
|
-
return lastDecision;
|
|
430
|
-
},
|
|
431
|
-
get lastNonRouterModel() {
|
|
432
|
-
return lastNonRouterModel;
|
|
433
|
-
},
|
|
434
|
-
set lastNonRouterModel(v) {
|
|
435
|
-
lastNonRouterModel = v;
|
|
436
|
-
},
|
|
437
|
-
get accumulatedCost() {
|
|
438
|
-
return accumulatedCost;
|
|
439
|
-
},
|
|
440
|
-
get debugEnabled() {
|
|
441
|
-
return debugEnabled;
|
|
442
|
-
},
|
|
443
|
-
set debugEnabled(v) {
|
|
444
|
-
debugEnabled = v;
|
|
445
|
-
},
|
|
446
|
-
get widgetEnabled() {
|
|
447
|
-
return widgetEnabled;
|
|
448
|
-
},
|
|
449
|
-
set widgetEnabled(v) {
|
|
450
|
-
widgetEnabled = v;
|
|
451
|
-
},
|
|
452
|
-
get debugHistory() {
|
|
453
|
-
return debugHistory;
|
|
454
|
-
},
|
|
455
|
-
get lastConfigWarnings() {
|
|
456
|
-
return lastConfigWarnings;
|
|
457
|
-
},
|
|
458
|
-
},
|
|
459
|
-
actions,
|
|
460
|
-
);
|
|
427
|
+
registerCommands(pi, runtimeState, actions);
|
|
461
428
|
|
|
462
429
|
pi.on('session_start', async (event, ctx) => {
|
|
463
430
|
isInitialized = true;
|
|
@@ -549,18 +516,20 @@ const routerExtension = (pi: ExtensionAPI) => {
|
|
|
549
516
|
|
|
550
517
|
// User changed pi's thinking level (e.g. via shift+tab).
|
|
551
518
|
// Apply as an all-tier thinking override for the active router profile.
|
|
552
|
-
thinkingByProfile[selectedProfile]
|
|
553
|
-
|
|
519
|
+
let overrides = thinkingByProfile[selectedProfile];
|
|
520
|
+
if (!overrides) {
|
|
521
|
+
overrides = {};
|
|
522
|
+
thinkingByProfile[selectedProfile] = overrides;
|
|
523
|
+
}
|
|
554
524
|
for (const t of ROUTER_TIERS) {
|
|
555
525
|
overrides[t] = event.level;
|
|
556
526
|
}
|
|
557
527
|
persistState();
|
|
558
528
|
actions.updateStatus(ctx);
|
|
559
529
|
if (event.level !== 'off') {
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
);
|
|
530
|
+
const activeProfile = currentConfig.profiles[selectedProfile];
|
|
531
|
+
if (!activeProfile) return;
|
|
532
|
+
const unsupported = getUnsupportedTiers(activeProfile, event.level);
|
|
564
533
|
if (unsupported.length > 0) {
|
|
565
534
|
ctx.ui.notify(
|
|
566
535
|
`Router thinking (all) set to ${event.level}. ` +
|
package/extensions/provider.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
ExtensionAPI,
|
|
15
15
|
ExtensionContext,
|
|
16
16
|
} from '@earendil-works/pi-coding-agent';
|
|
17
|
+
import { runClassifier } from './classifier';
|
|
17
18
|
import {
|
|
18
19
|
clampThinkingLevel,
|
|
19
20
|
collectProfileThinkingLevels,
|
|
@@ -25,6 +26,13 @@ import {
|
|
|
25
26
|
resolveMaxTokens,
|
|
26
27
|
} from './config';
|
|
27
28
|
import { DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS } from './constants';
|
|
29
|
+
import { extractTextFromContent, hasImageAttachment } from './context';
|
|
30
|
+
import {
|
|
31
|
+
buildRoutingDecision,
|
|
32
|
+
decideRouting,
|
|
33
|
+
phaseForTier,
|
|
34
|
+
resolveAvailableTier,
|
|
35
|
+
} from './routing';
|
|
28
36
|
import type {
|
|
29
37
|
RouterConfig,
|
|
30
38
|
RouterPinByProfile,
|
|
@@ -68,16 +76,6 @@ export const waitForRegistry = async (
|
|
|
68
76
|
return undefined;
|
|
69
77
|
};
|
|
70
78
|
|
|
71
|
-
import {
|
|
72
|
-
buildRoutingDecision,
|
|
73
|
-
decideRouting,
|
|
74
|
-
extractTextFromContent,
|
|
75
|
-
hasImageAttachment,
|
|
76
|
-
phaseForTier,
|
|
77
|
-
resolveAvailableTier,
|
|
78
|
-
runClassifier,
|
|
79
|
-
} from './routing';
|
|
80
|
-
|
|
81
79
|
export const createErrorMessage = (
|
|
82
80
|
model: Model<Api>,
|
|
83
81
|
message: string,
|
|
@@ -130,19 +128,28 @@ const truncateContext = (context: Context, limit: number): Context => {
|
|
|
130
128
|
|
|
131
129
|
// Drop only complete turns. Splitting an assistant/tool-result pair corrupts transcripts.
|
|
132
130
|
// This text estimate cannot guarantee a fit for images/tools or one oversized active turn.
|
|
131
|
+
const systemMessages = messages.filter(
|
|
132
|
+
(message) => message.role === 'system',
|
|
133
|
+
);
|
|
133
134
|
let remaining = totalTokens;
|
|
134
|
-
let
|
|
135
|
-
for (let i =
|
|
136
|
-
if (messages[i]
|
|
137
|
-
while (
|
|
135
|
+
let nextRemovableIndex = 0;
|
|
136
|
+
for (let i = 0; i < messages.length && remaining > limit; i += 1) {
|
|
137
|
+
if (messages[i]?.role !== 'user') continue;
|
|
138
|
+
while (nextRemovableIndex < i) {
|
|
139
|
+
const candidate = messages[nextRemovableIndex];
|
|
140
|
+
if (candidate?.role !== 'system') {
|
|
141
|
+
remaining -= messageTokens[nextRemovableIndex] ?? 0;
|
|
142
|
+
}
|
|
143
|
+
nextRemovableIndex += 1;
|
|
144
|
+
}
|
|
138
145
|
}
|
|
139
146
|
return {
|
|
140
147
|
...context,
|
|
141
148
|
messages: [
|
|
149
|
+
...systemMessages,
|
|
142
150
|
...messages
|
|
143
|
-
.slice(
|
|
144
|
-
.filter((message) => message.role
|
|
145
|
-
...messages.slice(startIndex),
|
|
151
|
+
.slice(nextRemovableIndex)
|
|
152
|
+
.filter((message) => message.role !== 'system'),
|
|
146
153
|
],
|
|
147
154
|
};
|
|
148
155
|
};
|
|
@@ -201,24 +208,29 @@ export const registerRouterProvider = (
|
|
|
201
208
|
const profileList = profileNames(state.currentConfig);
|
|
202
209
|
|
|
203
210
|
// Map profiles to their capacities
|
|
204
|
-
const modelDefinitions = profileList.
|
|
211
|
+
const modelDefinitions = profileList.flatMap((name) => {
|
|
205
212
|
const profile = state.currentConfig.profiles[name];
|
|
213
|
+
if (!profile) return [];
|
|
206
214
|
|
|
207
215
|
// Report the MAX context window and max output tokens across all tiers.
|
|
208
216
|
// The honesty check + truncateContext handles the case where the
|
|
209
217
|
// actually routed model is smaller.
|
|
210
218
|
let maxContextWindow = 0;
|
|
211
|
-
let
|
|
219
|
+
let maxOutputTokens = 0;
|
|
212
220
|
for (const tier of ROUTER_TIERS) {
|
|
213
221
|
if (!profile[tier]) continue;
|
|
214
|
-
const
|
|
222
|
+
const contextWindow = resolveContextWindow(
|
|
215
223
|
tier,
|
|
216
224
|
profile,
|
|
217
225
|
state.currentModelRegistry,
|
|
218
226
|
);
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
227
|
+
const maxTokens = resolveMaxTokens(
|
|
228
|
+
tier,
|
|
229
|
+
profile,
|
|
230
|
+
state.currentModelRegistry,
|
|
231
|
+
);
|
|
232
|
+
if (contextWindow > maxContextWindow) maxContextWindow = contextWindow;
|
|
233
|
+
if (maxTokens > maxOutputTokens) maxOutputTokens = maxTokens;
|
|
222
234
|
}
|
|
223
235
|
|
|
224
236
|
const hasReasoning = supportsReasoning(profile, state.currentModelRegistry);
|
|
@@ -233,16 +245,18 @@ export const registerRouterProvider = (
|
|
|
233
245
|
if (Object.keys(map).length > 0) thinkingLevelMap = map;
|
|
234
246
|
}
|
|
235
247
|
|
|
236
|
-
return
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
248
|
+
return [
|
|
249
|
+
{
|
|
250
|
+
id: name,
|
|
251
|
+
name: `Router ${name}`,
|
|
252
|
+
reasoning: hasReasoning,
|
|
253
|
+
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
|
|
254
|
+
input: ['text', 'image'] satisfies ('text' | 'image')[],
|
|
255
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
256
|
+
contextWindow: maxContextWindow || DEFAULT_CONTEXT_WINDOW,
|
|
257
|
+
maxTokens: maxOutputTokens || DEFAULT_MAX_TOKENS,
|
|
258
|
+
},
|
|
259
|
+
];
|
|
246
260
|
});
|
|
247
261
|
|
|
248
262
|
const modelsKey = JSON.stringify(modelDefinitions);
|
|
@@ -260,7 +274,7 @@ export const registerRouterProvider = (
|
|
|
260
274
|
): AssistantMessageEventStream {
|
|
261
275
|
const stream = createAssistantMessageEventStream();
|
|
262
276
|
|
|
263
|
-
(async () => {
|
|
277
|
+
void (async () => {
|
|
264
278
|
let partialMessage: AssistantMessage | undefined;
|
|
265
279
|
try {
|
|
266
280
|
// Wait for the router to be fully initialized (session_start sets currentModelRegistry).
|
|
@@ -447,9 +461,8 @@ export const registerRouterProvider = (
|
|
|
447
461
|
let lastError: unknown;
|
|
448
462
|
let success = false;
|
|
449
463
|
|
|
450
|
-
for (
|
|
464
|
+
for (const [i, modelRef] of modelsToTry.entries()) {
|
|
451
465
|
options?.signal?.throwIfAborted();
|
|
452
|
-
const modelRef = modelsToTry[i];
|
|
453
466
|
const { provider: targetProvider, modelId: targetModelId } =
|
|
454
467
|
parseCanonicalModelRef(modelRef);
|
|
455
468
|
|
|
@@ -488,15 +501,15 @@ export const registerRouterProvider = (
|
|
|
488
501
|
const tierConfig = profile[decision.tier];
|
|
489
502
|
if (tierConfig?.resolvedThinkingLevels) {
|
|
490
503
|
requestedReasoning = clampThinkingLevel(
|
|
491
|
-
requestedReasoning
|
|
504
|
+
requestedReasoning,
|
|
492
505
|
tierConfig.resolvedThinkingLevels,
|
|
493
|
-
)
|
|
506
|
+
);
|
|
494
507
|
}
|
|
495
508
|
}
|
|
496
509
|
|
|
497
|
-
const delegatedReasoning =
|
|
510
|
+
const delegatedReasoning: SimpleStreamOptions['reasoning'] =
|
|
498
511
|
targetModel.reasoning && requestedReasoning !== 'off'
|
|
499
|
-
?
|
|
512
|
+
? requestedReasoning
|
|
500
513
|
: undefined;
|
|
501
514
|
|
|
502
515
|
try {
|
|
@@ -522,7 +535,7 @@ export const registerRouterProvider = (
|
|
|
522
535
|
...delegationOptions
|
|
523
536
|
} = options ?? {};
|
|
524
537
|
|
|
525
|
-
const delegatedOptions = {
|
|
538
|
+
const delegatedOptions: SimpleStreamOptions = {
|
|
526
539
|
...delegationOptions,
|
|
527
540
|
...(delegatedReasoning
|
|
528
541
|
? { reasoning: delegatedReasoning }
|
package/extensions/routing.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
|
|
1
|
+
import type { Context } from '@earendil-works/pi-ai';
|
|
2
|
+
import { parseCanonicalModelRef } from './config';
|
|
3
|
+
import {
|
|
4
|
+
containsAny,
|
|
5
|
+
countToolResults,
|
|
6
|
+
countWords,
|
|
7
|
+
getLastUserText,
|
|
8
|
+
getRecentConversationText,
|
|
9
|
+
} from './context';
|
|
5
10
|
import type {
|
|
6
11
|
RouterPhase,
|
|
7
12
|
RouterProfile,
|
|
@@ -11,67 +16,6 @@ import type {
|
|
|
11
16
|
RoutingRule,
|
|
12
17
|
} from './types';
|
|
13
18
|
|
|
14
|
-
export const extractTextFromContent = (
|
|
15
|
-
content: string | Message['content'],
|
|
16
|
-
): string => {
|
|
17
|
-
if (typeof content === 'string') {
|
|
18
|
-
return content;
|
|
19
|
-
}
|
|
20
|
-
return content
|
|
21
|
-
.map((part) => {
|
|
22
|
-
if (part.type === 'text') return part.text;
|
|
23
|
-
if (part.type === 'thinking') return part.thinking;
|
|
24
|
-
if (part.type === 'toolCall')
|
|
25
|
-
return `${part.name} ${JSON.stringify(part.arguments)}`;
|
|
26
|
-
return '';
|
|
27
|
-
})
|
|
28
|
-
.filter(Boolean)
|
|
29
|
-
.join('\n');
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
export const getLastUserText = (context: Context): string => {
|
|
33
|
-
for (let i = context.messages.length - 1; i >= 0; i--) {
|
|
34
|
-
const message = context.messages[i];
|
|
35
|
-
if (message.role === 'user') {
|
|
36
|
-
return extractTextFromContent(message.content).trim();
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return '';
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
export const getRecentConversationText = (
|
|
43
|
-
context: Context,
|
|
44
|
-
limit = 6,
|
|
45
|
-
): string => {
|
|
46
|
-
return context.messages
|
|
47
|
-
.slice(-limit)
|
|
48
|
-
.map((message) => extractTextFromContent(message.content).trim())
|
|
49
|
-
.filter(Boolean)
|
|
50
|
-
.join('\n')
|
|
51
|
-
.toLowerCase();
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
export const countToolResults = (context: Context): number => {
|
|
55
|
-
return context.messages.filter((message) => message.role === 'toolResult')
|
|
56
|
-
.length;
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
export const countWords = (text: string): number => {
|
|
60
|
-
return text.split(/\s+/).filter(Boolean).length;
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
export const hasImageAttachment = (context: Context): boolean => {
|
|
64
|
-
return context.messages.some(
|
|
65
|
-
(message) =>
|
|
66
|
-
Array.isArray(message.content) &&
|
|
67
|
-
message.content.some((part) => part.type === 'image'),
|
|
68
|
-
);
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
export const containsAny = (text: string, keywords: string[]): boolean => {
|
|
72
|
-
return keywords.some((keyword) => text.includes(keyword));
|
|
73
|
-
};
|
|
74
|
-
|
|
75
19
|
export const phaseForTier = (tier: RouterTier): RouterPhase => {
|
|
76
20
|
if (tier === 'high') return 'planning';
|
|
77
21
|
if (tier === 'medium') return 'implementation';
|
|
@@ -87,11 +31,13 @@ export const resolveAvailableTier = (
|
|
|
87
31
|
const order: RouterTier[] = ['low', 'medium', 'high'];
|
|
88
32
|
const startIdx = order.indexOf(preferred);
|
|
89
33
|
for (let i = startIdx + 1; i < order.length; i++) {
|
|
90
|
-
|
|
34
|
+
const tier = order[i];
|
|
35
|
+
if (tier && profile[tier]) return tier;
|
|
91
36
|
}
|
|
92
37
|
// Fall "down" as last resort
|
|
93
38
|
for (let i = startIdx - 1; i >= 0; i--) {
|
|
94
|
-
|
|
39
|
+
const tier = order[i];
|
|
40
|
+
if (tier && profile[tier]) return tier;
|
|
95
41
|
}
|
|
96
42
|
return preferred; // unreachable if profile has ≥1 tier
|
|
97
43
|
};
|
|
@@ -390,92 +336,3 @@ export const decideRouting = (
|
|
|
390
336
|
decision.isBudgetForced = isBudgetForced;
|
|
391
337
|
return decision;
|
|
392
338
|
};
|
|
393
|
-
|
|
394
|
-
export const runClassifier = async (
|
|
395
|
-
classifierModelRef: string,
|
|
396
|
-
modelRegistry: ExtensionContext['modelRegistry'],
|
|
397
|
-
context: Context,
|
|
398
|
-
currentPhase?: RouterPhase,
|
|
399
|
-
thinking?: ThinkingLevel,
|
|
400
|
-
signal?: AbortSignal,
|
|
401
|
-
): Promise<{ tier: RouterTier; reasoning: string } | undefined> => {
|
|
402
|
-
try {
|
|
403
|
-
const { provider, modelId } = parseCanonicalModelRef(classifierModelRef);
|
|
404
|
-
const model = modelRegistry.find(provider, modelId);
|
|
405
|
-
if (!model || provider === 'router') return undefined;
|
|
406
|
-
signal?.throwIfAborted();
|
|
407
|
-
|
|
408
|
-
const promptText = getLastUserText(context);
|
|
409
|
-
const historyText = getRecentConversationText(context, 4);
|
|
410
|
-
|
|
411
|
-
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".
|
|
412
|
-
|
|
413
|
-
Tiers:
|
|
414
|
-
- high: Architecture, design, planning, tradeoff analysis, broad debugging, large refactors, codebase research.
|
|
415
|
-
- medium: Implementation of a known plan, multi-file edits, normal coding work, focused debugging, tests/fixes.
|
|
416
|
-
- low: Summaries, changelogs, formatting, quick explanations, small bounded transforms, simple read-only lookup.
|
|
417
|
-
|
|
418
|
-
${currentPhase ? `Current conversation phase: ${currentPhase}\n` : ''}
|
|
419
|
-
Recent history:
|
|
420
|
-
${historyText}
|
|
421
|
-
|
|
422
|
-
Latest user message:
|
|
423
|
-
${promptText}
|
|
424
|
-
|
|
425
|
-
Return your decision in exactly two lines:
|
|
426
|
-
Tier: [high|medium|low]
|
|
427
|
-
Reasoning: [one short sentence]
|
|
428
|
-
|
|
429
|
-
${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.' : ''}
|
|
430
|
-
${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.' : ''}`;
|
|
431
|
-
|
|
432
|
-
const classifierContext: Context = {
|
|
433
|
-
messages: [
|
|
434
|
-
{ role: 'user', content: classifierPrompt, timestamp: Date.now() },
|
|
435
|
-
],
|
|
436
|
-
};
|
|
437
|
-
|
|
438
|
-
const reasoningOption =
|
|
439
|
-
model.reasoning && thinking && thinking !== 'off' ? thinking : undefined;
|
|
440
|
-
|
|
441
|
-
const timeout = AbortSignal.timeout(10_000);
|
|
442
|
-
const stream = modelRegistry.streamSimple(model, classifierContext, {
|
|
443
|
-
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
444
|
-
maxTokens: 256,
|
|
445
|
-
...(reasoningOption ? { reasoning: reasoningOption } : {}),
|
|
446
|
-
});
|
|
447
|
-
let fullText = '';
|
|
448
|
-
let completed = false;
|
|
449
|
-
for await (const event of stream) {
|
|
450
|
-
if (event.type === 'error') return undefined;
|
|
451
|
-
if (event.type === 'text_delta') fullText += event.delta;
|
|
452
|
-
if (event.type === 'done') {
|
|
453
|
-
completed = true;
|
|
454
|
-
fullText = extractTextFromContent(event.message.content);
|
|
455
|
-
break;
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
if (!completed) return undefined;
|
|
459
|
-
|
|
460
|
-
const lines = fullText.trim().split('\n');
|
|
461
|
-
const tierLine = lines.find((l) => l.toLowerCase().startsWith('tier:'));
|
|
462
|
-
const reasoningLine = lines.find((l) =>
|
|
463
|
-
l.toLowerCase().startsWith('reasoning:'),
|
|
464
|
-
);
|
|
465
|
-
|
|
466
|
-
if (tierLine) {
|
|
467
|
-
const tierValue = tierLine.split(':')[1].trim().toLowerCase();
|
|
468
|
-
if (isRouterTier(tierValue)) {
|
|
469
|
-
return {
|
|
470
|
-
tier: tierValue,
|
|
471
|
-
reasoning: reasoningLine
|
|
472
|
-
? reasoningLine.slice(reasoningLine.indexOf(':') + 1).trim()
|
|
473
|
-
: 'Classifier decision.',
|
|
474
|
-
};
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
} catch {
|
|
478
|
-
// Ignore classifier errors and fall back to heuristics
|
|
479
|
-
}
|
|
480
|
-
return undefined;
|
|
481
|
-
};
|