@alexeiled/pi-model-router 0.5.1 → 0.6.0
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 +21 -0
- package/README.md +145 -24
- package/extensions/classifier.ts +119 -0
- package/extensions/commands.ts +76 -40
- package/extensions/config.ts +240 -146
- package/extensions/context.ts +91 -0
- package/extensions/index.ts +120 -132
- package/extensions/jev.ts +223 -0
- package/extensions/provider.ts +392 -178
- package/extensions/routing.ts +233 -436
- package/extensions/state.ts +74 -25
- package/extensions/types.ts +148 -52
- package/extensions/ui.ts +32 -34
- package/model-router.example.json +17 -10
- package/package.json +4 -4
package/extensions/routing.ts
CHANGED
|
@@ -1,76 +1,28 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import {
|
|
2
|
+
type Api,
|
|
3
|
+
getSupportedThinkingLevels,
|
|
4
|
+
type Model,
|
|
5
|
+
} from '@earendil-works/pi-ai';
|
|
6
|
+
import { parseCanonicalModelRef } from './config';
|
|
5
7
|
import type {
|
|
8
|
+
ModelDefinition,
|
|
9
|
+
RoutePair,
|
|
6
10
|
RouterPhase,
|
|
7
11
|
RouterProfile,
|
|
8
12
|
RouterThinkingByTier,
|
|
9
13
|
RouterTier,
|
|
10
14
|
RoutingDecision,
|
|
11
|
-
|
|
15
|
+
RoutingReasonCode,
|
|
12
16
|
} from './types';
|
|
17
|
+
import { ROUTER_TIERS } from './types';
|
|
13
18
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
};
|
|
19
|
+
/** The configured default preference order when no baseline tier is supplied. */
|
|
20
|
+
export const BASELINE_TIER_ORDER: readonly RouterTier[] = [
|
|
21
|
+
'medium',
|
|
22
|
+
'high',
|
|
23
|
+
'low',
|
|
24
|
+
'micro',
|
|
25
|
+
] as const;
|
|
74
26
|
|
|
75
27
|
export const phaseForTier = (tier: RouterTier): RouterPhase => {
|
|
76
28
|
if (tier === 'high') return 'planning';
|
|
@@ -78,404 +30,249 @@ export const phaseForTier = (tier: RouterTier): RouterPhase => {
|
|
|
78
30
|
return 'lightweight';
|
|
79
31
|
};
|
|
80
32
|
|
|
81
|
-
export const
|
|
82
|
-
profile: RouterProfile,
|
|
83
|
-
preferred: RouterTier,
|
|
84
|
-
): RouterTier => {
|
|
85
|
-
if (profile[preferred]) return preferred;
|
|
86
|
-
// Fall "up": low → medium → high
|
|
87
|
-
const order: RouterTier[] = ['low', 'medium', 'high'];
|
|
88
|
-
const startIdx = order.indexOf(preferred);
|
|
89
|
-
for (let i = startIdx + 1; i < order.length; i++) {
|
|
90
|
-
if (profile[order[i]]) return order[i];
|
|
91
|
-
}
|
|
92
|
-
// Fall "down" as last resort
|
|
93
|
-
for (let i = startIdx - 1; i >= 0; i--) {
|
|
94
|
-
if (profile[order[i]]) return order[i];
|
|
95
|
-
}
|
|
96
|
-
return preferred; // unreachable if profile has ≥1 tier
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
export const buildRoutingDecision = (
|
|
100
|
-
profileName: string,
|
|
33
|
+
export const resolveRoutePair = (
|
|
101
34
|
profile: RouterProfile,
|
|
102
35
|
tier: RouterTier,
|
|
103
|
-
phase: RouterPhase,
|
|
104
|
-
reasoning: string,
|
|
105
36
|
thinkingOverrides?: RouterThinkingByTier,
|
|
106
|
-
|
|
107
|
-
): RoutingDecision => {
|
|
37
|
+
): RoutePair => {
|
|
108
38
|
const routed = profile[tier];
|
|
109
|
-
if (!routed)
|
|
110
|
-
throw new Error(
|
|
111
|
-
`Profile "${profileName}" has no configuration for the ${tier} tier.`,
|
|
112
|
-
);
|
|
113
|
-
}
|
|
39
|
+
if (!routed)
|
|
40
|
+
throw new Error('No eligible route: selected tier is not configured.');
|
|
114
41
|
const { provider, modelId } = parseCanonicalModelRef(routed.model);
|
|
115
|
-
const baseThinking =
|
|
116
|
-
routed.thinking ??
|
|
117
|
-
(tier === 'high' ? 'high' : tier === 'low' ? 'low' : 'medium');
|
|
118
|
-
const effectiveThinking = thinkingOverrides?.[tier] ?? baseThinking;
|
|
119
|
-
|
|
120
42
|
return {
|
|
121
|
-
profile: profileName,
|
|
122
43
|
tier,
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
thinking: effectiveThinking,
|
|
129
|
-
timestamp: Date.now(),
|
|
130
|
-
isClassifier,
|
|
44
|
+
model: `${provider}/${modelId}`,
|
|
45
|
+
thinking:
|
|
46
|
+
thinkingOverrides?.[tier] ??
|
|
47
|
+
routed.thinking ??
|
|
48
|
+
(tier === 'micro' ? 'off' : tier),
|
|
131
49
|
};
|
|
132
50
|
};
|
|
133
51
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Revalidate the actual target against the live registry. A configured effort
|
|
54
|
+
* declaration is restrictive: it can reject a route, but never grants a
|
|
55
|
+
* capability the live model does not expose.
|
|
56
|
+
*/
|
|
57
|
+
export const validateRoutePair = (
|
|
58
|
+
pair: RoutePair,
|
|
59
|
+
findModel: (provider: string, modelId: string) => Model<Api> | undefined,
|
|
60
|
+
imageAttached: boolean,
|
|
61
|
+
declaredLevels?: ModelDefinition['thinkingLevels'],
|
|
62
|
+
): boolean => {
|
|
63
|
+
try {
|
|
64
|
+
const { provider, modelId } = parseCanonicalModelRef(pair.model);
|
|
65
|
+
if (provider === 'router') return false;
|
|
66
|
+
const model = findModel(provider, modelId);
|
|
67
|
+
return Boolean(
|
|
68
|
+
model?.input.includes(imageAttached ? 'image' : 'text') &&
|
|
69
|
+
getSupportedThinkingLevels(model).includes(pair.thinking) &&
|
|
70
|
+
(!declaredLevels ||
|
|
71
|
+
pair.thinking === 'off' ||
|
|
72
|
+
declaredLevels.includes(pair.thinking)),
|
|
73
|
+
);
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Return all generation attempts that the active profile can serve. The first
|
|
81
|
+
* route for a tier is its primary; subsequent routes are explicit generation
|
|
82
|
+
* fallbacks and remain in their configured order.
|
|
83
|
+
*
|
|
84
|
+
* Tier and fallback references are normalized before this boundary. Parse them
|
|
85
|
+
* directly so a canonical-looking model reference cannot resolve as an alias a
|
|
86
|
+
* second time.
|
|
87
|
+
*/
|
|
88
|
+
export const availableRoutePairs = (
|
|
137
89
|
profile: RouterProfile,
|
|
138
|
-
|
|
139
|
-
|
|
90
|
+
findModel: (provider: string, modelId: string) => Model<Api> | undefined,
|
|
91
|
+
imageAttached: boolean,
|
|
140
92
|
thinkingOverrides?: RouterThinkingByTier,
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
93
|
+
): RoutePair[] =>
|
|
94
|
+
ROUTER_TIERS.flatMap((tier) => {
|
|
95
|
+
const config = profile[tier];
|
|
96
|
+
if (!config) return [];
|
|
97
|
+
|
|
98
|
+
let primary: RoutePair;
|
|
99
|
+
try {
|
|
100
|
+
primary = resolveRoutePair(profile, tier, thinkingOverrides);
|
|
101
|
+
} catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
151
104
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
'change',
|
|
219
|
-
'apply',
|
|
220
|
-
'continue',
|
|
221
|
-
'resume',
|
|
222
|
-
'make the changes',
|
|
223
|
-
'go ahead',
|
|
224
|
-
];
|
|
225
|
-
const lookupKeywords = [
|
|
226
|
-
'where is',
|
|
227
|
-
'which file',
|
|
228
|
-
'show me',
|
|
229
|
-
'list',
|
|
230
|
-
'what files',
|
|
231
|
-
'find',
|
|
232
|
-
'grep',
|
|
233
|
-
];
|
|
105
|
+
return [primary.model, ...(config.fallbacks ?? [])]
|
|
106
|
+
.flatMap((ref, index) => {
|
|
107
|
+
try {
|
|
108
|
+
const { provider, modelId } = parseCanonicalModelRef(ref);
|
|
109
|
+
const model = findModel(provider, modelId);
|
|
110
|
+
const ownConfig =
|
|
111
|
+
index === 0 ? config : config.resolvedFallbacks?.[index - 1];
|
|
112
|
+
// A non-reasoning model defaults to off, but explicit unsupported
|
|
113
|
+
// effort remains ineligible.
|
|
114
|
+
const thinking =
|
|
115
|
+
thinkingOverrides?.[tier] ??
|
|
116
|
+
((config.thinkingExplicit ?? config.thinking !== undefined)
|
|
117
|
+
? primary.thinking
|
|
118
|
+
: ownConfig?.reasoning === false || !model?.reasoning
|
|
119
|
+
? 'off'
|
|
120
|
+
: primary.thinking);
|
|
121
|
+
const pair = { tier, model: `${provider}/${modelId}`, thinking };
|
|
122
|
+
return validateRoutePair(
|
|
123
|
+
pair,
|
|
124
|
+
findModel,
|
|
125
|
+
imageAttached,
|
|
126
|
+
ownConfig?.reasoning === false
|
|
127
|
+
? []
|
|
128
|
+
: index === 0
|
|
129
|
+
? (config.thinkingLevels ?? config.resolvedThinkingLevels)
|
|
130
|
+
: ownConfig?.thinkingLevels,
|
|
131
|
+
)
|
|
132
|
+
? [pair]
|
|
133
|
+
: [];
|
|
134
|
+
} catch {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
.filter(
|
|
139
|
+
(pair, index, pairs) =>
|
|
140
|
+
pairs.findIndex((other) => other.model === pair.model) === index,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Keep at least one configured route usable for each input kind when a
|
|
146
|
+
* thinking override is accepted. This prevents an override from silently
|
|
147
|
+
* removing all generation options while still allowing partial profiles and
|
|
148
|
+
* explicit pins to fail at their normal capability check.
|
|
149
|
+
*/
|
|
150
|
+
export const preservesRouteCoverage = (
|
|
151
|
+
profile: RouterProfile,
|
|
152
|
+
findModel: (provider: string, modelId: string) => Model<Api> | undefined,
|
|
153
|
+
thinkingOverrides: RouterThinkingByTier,
|
|
154
|
+
): boolean => {
|
|
155
|
+
for (const imageAttached of [false, true]) {
|
|
156
|
+
const configured = availableRoutePairs(profile, findModel, imageAttached);
|
|
157
|
+
const overridden = availableRoutePairs(
|
|
158
|
+
profile,
|
|
159
|
+
findModel,
|
|
160
|
+
imageAttached,
|
|
161
|
+
thinkingOverrides,
|
|
162
|
+
);
|
|
163
|
+
if (configured.length > 0 && overridden.length === 0) return false;
|
|
164
|
+
}
|
|
165
|
+
return [false, true].some(
|
|
166
|
+
(imageAttached) =>
|
|
167
|
+
availableRoutePairs(profile, findModel, imageAttached, thinkingOverrides)
|
|
168
|
+
.length > 0,
|
|
169
|
+
);
|
|
170
|
+
};
|
|
234
171
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
172
|
+
export interface BaselineSelection {
|
|
173
|
+
pair: RoutePair;
|
|
174
|
+
reasonCode: 'baseline' | 'pinned' | 'budget';
|
|
175
|
+
isBudgetForced: boolean;
|
|
176
|
+
}
|
|
239
177
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
} else {
|
|
245
|
-
// Check custom rules first
|
|
246
|
-
if (rules) {
|
|
247
|
-
let highestTier: RouterTier | undefined;
|
|
248
|
-
let winningRule: RoutingRule | undefined;
|
|
249
|
-
const tierRank: Record<RouterTier, number> = {
|
|
250
|
-
low: 1,
|
|
251
|
-
medium: 2,
|
|
252
|
-
high: 3,
|
|
253
|
-
};
|
|
178
|
+
const routeForTier = (
|
|
179
|
+
pairs: readonly RoutePair[],
|
|
180
|
+
tier: RouterTier,
|
|
181
|
+
): RoutePair | undefined => pairs.find((pair) => pair.tier === tier);
|
|
254
182
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
if (containsAny(prompt, lowercaseMatches)) {
|
|
261
|
-
if (!highestTier || tierRank[rule.tier] > tierRank[highestTier]) {
|
|
262
|
-
highestTier = rule.tier;
|
|
263
|
-
winningRule = rule;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
}
|
|
183
|
+
const preferredOrder = (profile: RouterProfile): readonly RouterTier[] => {
|
|
184
|
+
const baseline = profile.baselineTier;
|
|
185
|
+
if (!baseline || !profile[baseline]) return BASELINE_TIER_ORDER;
|
|
186
|
+
return [baseline, ...BASELINE_TIER_ORDER.filter((tier) => tier !== baseline)];
|
|
187
|
+
};
|
|
267
188
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
279
|
-
}
|
|
189
|
+
const chooseFromOrder = (
|
|
190
|
+
pairs: readonly RoutePair[],
|
|
191
|
+
order: readonly RouterTier[],
|
|
192
|
+
): RoutePair | undefined => {
|
|
193
|
+
for (const tier of order) {
|
|
194
|
+
const pair = routeForTier(pairs, tier);
|
|
195
|
+
if (pair) return pair;
|
|
196
|
+
}
|
|
197
|
+
return undefined;
|
|
198
|
+
};
|
|
280
199
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Select the local route without inspecting prompt text. Eligibility has
|
|
202
|
+
* already been filtered by the registry/input/effort boundary.
|
|
203
|
+
*/
|
|
204
|
+
export const selectBaselineRoute = (
|
|
205
|
+
profileName: string,
|
|
206
|
+
profile: RouterProfile,
|
|
207
|
+
pairs: readonly RoutePair[],
|
|
208
|
+
pinnedTier?: RouterTier,
|
|
209
|
+
isBudgetExceeded = false,
|
|
210
|
+
): BaselineSelection => {
|
|
211
|
+
if (pinnedTier) {
|
|
212
|
+
const pair = routeForTier(pairs, pinnedTier);
|
|
213
|
+
if (!pair) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`Pinned tier "${pinnedTier}" for profile "${profileName}" has no eligible model for the current input and thinking level.`,
|
|
294
216
|
);
|
|
295
|
-
|
|
296
|
-
if (containsAny(prompt, explicitHighHints)) {
|
|
297
|
-
phase = 'planning';
|
|
298
|
-
tier = 'high';
|
|
299
|
-
reasoning =
|
|
300
|
-
'Detected an explicit request for deeper or higher-quality reasoning.';
|
|
301
|
-
} else if (containsAny(prompt, explicitLowHints)) {
|
|
302
|
-
phase = 'lightweight';
|
|
303
|
-
tier = 'low';
|
|
304
|
-
reasoning =
|
|
305
|
-
'Detected an explicit request for a faster or lighter response.';
|
|
306
|
-
} else if (containsAny(prompt, summaryKeywords)) {
|
|
307
|
-
phase = 'lightweight';
|
|
308
|
-
tier = 'low';
|
|
309
|
-
reasoning = 'Detected summary or lightweight transformation keywords.';
|
|
310
|
-
} else if (
|
|
311
|
-
containsAny(prompt, planningKeywords) ||
|
|
312
|
-
prompt.startsWith('why ') ||
|
|
313
|
-
wordCount >= highThreshold ||
|
|
314
|
-
multiLinePrompt
|
|
315
|
-
) {
|
|
316
|
-
phase = 'planning';
|
|
317
|
-
tier = 'high';
|
|
318
|
-
reasoning =
|
|
319
|
-
previousDecision?.phase === 'planning'
|
|
320
|
-
? 'Continued planning phase based on complexity or keywords.'
|
|
321
|
-
: 'Detected planning, broad analysis, or a high-complexity request.';
|
|
322
|
-
} else if (containsAny(prompt, implementationKeywords)) {
|
|
323
|
-
phase = 'implementation';
|
|
324
|
-
tier = 'medium';
|
|
325
|
-
reasoning =
|
|
326
|
-
'Detected implementation-oriented work with bounded execution scope.';
|
|
327
|
-
} else if (
|
|
328
|
-
containsAny(prompt, lookupKeywords) &&
|
|
329
|
-
wordCount <= 24 &&
|
|
330
|
-
toolResultCount === 0
|
|
331
|
-
) {
|
|
332
|
-
phase = 'lightweight';
|
|
333
|
-
tier = 'low';
|
|
334
|
-
reasoning = 'Detected a short read-only lookup request.';
|
|
335
|
-
} else if (
|
|
336
|
-
previousDecision?.phase === 'planning' &&
|
|
337
|
-
toolResultCount === 0 &&
|
|
338
|
-
wordCount > lowThreshold
|
|
339
|
-
) {
|
|
340
|
-
phase = 'planning';
|
|
341
|
-
tier = 'high';
|
|
342
|
-
reasoning =
|
|
343
|
-
'Kept the planning-phase bias because the conversation still looks exploratory.';
|
|
344
|
-
} else if (
|
|
345
|
-
toolResultCount > 0 ||
|
|
346
|
-
previousDecision?.phase === 'implementation' ||
|
|
347
|
-
recentConversation.includes('plan:')
|
|
348
|
-
) {
|
|
349
|
-
phase = 'implementation';
|
|
350
|
-
tier = 'medium';
|
|
351
|
-
reasoning =
|
|
352
|
-
'Detected active implementation work from prior tools or recent plan execution context.';
|
|
353
|
-
} else if (wordCount <= lowThreshold) {
|
|
354
|
-
phase = 'lightweight';
|
|
355
|
-
tier = 'low';
|
|
356
|
-
reasoning = 'Detected a short bounded request.';
|
|
357
|
-
}
|
|
358
217
|
}
|
|
218
|
+
return { pair, reasonCode: 'pinned', isBudgetForced: false };
|
|
359
219
|
}
|
|
360
220
|
|
|
361
|
-
|
|
362
|
-
if (isBudgetExceeded
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
221
|
+
const order = preferredOrder(profile);
|
|
222
|
+
if (isBudgetExceeded) {
|
|
223
|
+
const budgetOrder = order.filter(
|
|
224
|
+
(tier) => tier === 'medium' || tier === 'low' || tier === 'micro',
|
|
225
|
+
);
|
|
226
|
+
const budgetPair = chooseFromOrder(pairs, budgetOrder);
|
|
227
|
+
if (budgetPair) {
|
|
228
|
+
return { pair: budgetPair, reasonCode: 'budget', isBudgetForced: true };
|
|
229
|
+
}
|
|
230
|
+
const configuredBaseline = chooseFromOrder(pairs, order);
|
|
231
|
+
if (configuredBaseline) {
|
|
232
|
+
return {
|
|
233
|
+
pair: configuredBaseline,
|
|
234
|
+
reasonCode: 'budget',
|
|
235
|
+
isBudgetForced: false,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
const baseline = chooseFromOrder(pairs, order);
|
|
240
|
+
if (baseline)
|
|
241
|
+
return { pair: baseline, reasonCode: 'baseline', isBudgetForced: false };
|
|
378
242
|
}
|
|
379
243
|
|
|
380
|
-
|
|
381
|
-
profileName
|
|
382
|
-
profile,
|
|
383
|
-
tier,
|
|
384
|
-
phase,
|
|
385
|
-
reasoning,
|
|
386
|
-
thinkingOverrides,
|
|
387
|
-
false,
|
|
244
|
+
throw new Error(
|
|
245
|
+
`No eligible route for profile "${profileName}": no configured model is available for the current input and thinking level.`,
|
|
388
246
|
);
|
|
389
|
-
decision.isRuleMatched = isRuleMatched;
|
|
390
|
-
decision.isBudgetForced = isBudgetForced;
|
|
391
|
-
return decision;
|
|
392
247
|
};
|
|
393
248
|
|
|
394
|
-
export const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
)
|
|
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:'),
|
|
249
|
+
export const primaryRoutePairs = (
|
|
250
|
+
profile: RouterProfile,
|
|
251
|
+
pairs: readonly RoutePair[],
|
|
252
|
+
): RoutePair[] =>
|
|
253
|
+
BASELINE_TIER_ORDER.flatMap((tier) => {
|
|
254
|
+
// Keep the effective effort already validated against the live registry.
|
|
255
|
+
const primary = pairs.find(
|
|
256
|
+
(pair) => pair.tier === tier && pair.model === profile[tier]?.model,
|
|
464
257
|
);
|
|
258
|
+
return primary ? [primary] : [];
|
|
259
|
+
});
|
|
465
260
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
261
|
+
export const decisionForPair = (
|
|
262
|
+
profile: string,
|
|
263
|
+
pair: RoutePair,
|
|
264
|
+
reasonCode: RoutingReasonCode,
|
|
265
|
+
): RoutingDecision => {
|
|
266
|
+
const { provider, modelId } = parseCanonicalModelRef(pair.model);
|
|
267
|
+
return {
|
|
268
|
+
profile,
|
|
269
|
+
tier: pair.tier,
|
|
270
|
+
phase: phaseForTier(pair.tier),
|
|
271
|
+
targetProvider: provider,
|
|
272
|
+
targetModelId: modelId,
|
|
273
|
+
targetLabel: pair.model,
|
|
274
|
+
thinking: pair.thinking,
|
|
275
|
+
reasonCode,
|
|
276
|
+
timestamp: Date.now(),
|
|
277
|
+
};
|
|
481
278
|
};
|