@alexeiled/pi-model-router 0.5.2 → 0.6.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.
- package/CHANGELOG.md +18 -0
- package/README.md +150 -24
- package/extensions/classifier.ts +96 -70
- package/extensions/commands.ts +42 -23
- package/extensions/config.ts +193 -102
- package/extensions/context.ts +61 -28
- package/extensions/index.ts +29 -10
- package/extensions/jev.ts +223 -0
- package/extensions/provider.ts +372 -143
- package/extensions/routing.ts +236 -296
- package/extensions/state.ts +55 -10
- package/extensions/types.ts +92 -12
- package/extensions/ui.ts +59 -8
- package/model-router.example.json +17 -10
- package/package.json +1 -1
package/extensions/routing.ts
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
|
-
import type { Context } from '@earendil-works/pi-ai';
|
|
2
|
-
import { parseCanonicalModelRef } from './config';
|
|
3
1
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
} from './context';
|
|
2
|
+
type Api,
|
|
3
|
+
getSupportedThinkingLevels,
|
|
4
|
+
type Model,
|
|
5
|
+
} from '@earendil-works/pi-ai';
|
|
6
|
+
import { parseCanonicalModelRef } from './config';
|
|
10
7
|
import type {
|
|
8
|
+
ModelDefinition,
|
|
9
|
+
RoutePair,
|
|
11
10
|
RouterPhase,
|
|
12
11
|
RouterProfile,
|
|
13
12
|
RouterThinkingByTier,
|
|
14
13
|
RouterTier,
|
|
15
14
|
RoutingDecision,
|
|
16
|
-
|
|
15
|
+
RoutingReasonCode,
|
|
17
16
|
} from './types';
|
|
17
|
+
import { ROUTER_TIERS } from './types';
|
|
18
|
+
|
|
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;
|
|
18
26
|
|
|
19
27
|
export const phaseForTier = (tier: RouterTier): RouterPhase => {
|
|
20
28
|
if (tier === 'high') return 'planning';
|
|
@@ -22,317 +30,249 @@ export const phaseForTier = (tier: RouterTier): RouterPhase => {
|
|
|
22
30
|
return 'lightweight';
|
|
23
31
|
};
|
|
24
32
|
|
|
25
|
-
export const
|
|
26
|
-
profile: RouterProfile,
|
|
27
|
-
preferred: RouterTier,
|
|
28
|
-
): RouterTier => {
|
|
29
|
-
if (profile[preferred]) return preferred;
|
|
30
|
-
// Fall "up": low → medium → high
|
|
31
|
-
const order: RouterTier[] = ['low', 'medium', 'high'];
|
|
32
|
-
const startIdx = order.indexOf(preferred);
|
|
33
|
-
for (let i = startIdx + 1; i < order.length; i++) {
|
|
34
|
-
const tier = order[i];
|
|
35
|
-
if (tier && profile[tier]) return tier;
|
|
36
|
-
}
|
|
37
|
-
// Fall "down" as last resort
|
|
38
|
-
for (let i = startIdx - 1; i >= 0; i--) {
|
|
39
|
-
const tier = order[i];
|
|
40
|
-
if (tier && profile[tier]) return tier;
|
|
41
|
-
}
|
|
42
|
-
return preferred; // unreachable if profile has ≥1 tier
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
export const buildRoutingDecision = (
|
|
46
|
-
profileName: string,
|
|
33
|
+
export const resolveRoutePair = (
|
|
47
34
|
profile: RouterProfile,
|
|
48
35
|
tier: RouterTier,
|
|
49
|
-
phase: RouterPhase,
|
|
50
|
-
reasoning: string,
|
|
51
36
|
thinkingOverrides?: RouterThinkingByTier,
|
|
52
|
-
|
|
53
|
-
): RoutingDecision => {
|
|
37
|
+
): RoutePair => {
|
|
54
38
|
const routed = profile[tier];
|
|
55
|
-
if (!routed)
|
|
56
|
-
throw new Error(
|
|
57
|
-
`Profile "${profileName}" has no configuration for the ${tier} tier.`,
|
|
58
|
-
);
|
|
59
|
-
}
|
|
39
|
+
if (!routed)
|
|
40
|
+
throw new Error('No eligible route: selected tier is not configured.');
|
|
60
41
|
const { provider, modelId } = parseCanonicalModelRef(routed.model);
|
|
61
|
-
const baseThinking =
|
|
62
|
-
routed.thinking ??
|
|
63
|
-
(tier === 'high' ? 'high' : tier === 'low' ? 'low' : 'medium');
|
|
64
|
-
const effectiveThinking = thinkingOverrides?.[tier] ?? baseThinking;
|
|
65
|
-
|
|
66
42
|
return {
|
|
67
|
-
profile: profileName,
|
|
68
43
|
tier,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
thinking: effectiveThinking,
|
|
75
|
-
timestamp: Date.now(),
|
|
76
|
-
isClassifier,
|
|
44
|
+
model: `${provider}/${modelId}`,
|
|
45
|
+
thinking:
|
|
46
|
+
thinkingOverrides?.[tier] ??
|
|
47
|
+
routed.thinking ??
|
|
48
|
+
(tier === 'micro' ? 'off' : tier),
|
|
77
49
|
};
|
|
78
50
|
};
|
|
79
51
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 = (
|
|
83
89
|
profile: RouterProfile,
|
|
84
|
-
|
|
85
|
-
|
|
90
|
+
findModel: (provider: string, modelId: string) => Model<Api> | undefined,
|
|
91
|
+
imageAttached: boolean,
|
|
86
92
|
thinkingOverrides?: RouterThinkingByTier,
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
)
|
|
91
|
-
if (previousDecision?.profile !== profileName) previousDecision = undefined;
|
|
92
|
-
const prompt = getLastUserText(context).toLowerCase();
|
|
93
|
-
const recentConversation = getRecentConversationText(context);
|
|
94
|
-
const toolResultCount = countToolResults(context);
|
|
95
|
-
const wordCount = countWords(prompt);
|
|
96
|
-
const multiLinePrompt = prompt.split('\n').length >= 4;
|
|
93
|
+
): RoutePair[] =>
|
|
94
|
+
ROUTER_TIERS.flatMap((tier) => {
|
|
95
|
+
const config = profile[tier];
|
|
96
|
+
if (!config) return [];
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
'robust',
|
|
105
|
-
'comprehensive',
|
|
106
|
-
'step by step',
|
|
107
|
-
'think hard',
|
|
108
|
-
'highest quality',
|
|
109
|
-
'ultrathink',
|
|
110
|
-
];
|
|
111
|
-
const explicitLowHints = [
|
|
112
|
-
'fast',
|
|
113
|
-
'cheap',
|
|
114
|
-
'quick',
|
|
115
|
-
'quickly',
|
|
116
|
-
'brief',
|
|
117
|
-
'briefly',
|
|
118
|
-
'one sentence',
|
|
119
|
-
'one line',
|
|
120
|
-
'tiny',
|
|
121
|
-
'small',
|
|
122
|
-
];
|
|
123
|
-
const planningKeywords = [
|
|
124
|
-
'plan',
|
|
125
|
-
'planning',
|
|
126
|
-
'architecture',
|
|
127
|
-
'architect',
|
|
128
|
-
'design',
|
|
129
|
-
'tradeoff',
|
|
130
|
-
'trade-off',
|
|
131
|
-
'research',
|
|
132
|
-
'investigate',
|
|
133
|
-
'root cause',
|
|
134
|
-
'analyze',
|
|
135
|
-
'analysis',
|
|
136
|
-
'migration',
|
|
137
|
-
'strategy',
|
|
138
|
-
'compare',
|
|
139
|
-
'options',
|
|
140
|
-
'approach',
|
|
141
|
-
];
|
|
142
|
-
const summaryKeywords = [
|
|
143
|
-
'summarize',
|
|
144
|
-
'summary',
|
|
145
|
-
'changelog',
|
|
146
|
-
'rewrite',
|
|
147
|
-
'reformat',
|
|
148
|
-
'format',
|
|
149
|
-
'rename',
|
|
150
|
-
'explain briefly',
|
|
151
|
-
'recap',
|
|
152
|
-
'tl;dr',
|
|
153
|
-
];
|
|
154
|
-
const implementationKeywords = [
|
|
155
|
-
'implement',
|
|
156
|
-
'code',
|
|
157
|
-
'fix',
|
|
158
|
-
'update',
|
|
159
|
-
'edit',
|
|
160
|
-
'write',
|
|
161
|
-
'refactor',
|
|
162
|
-
'add tests',
|
|
163
|
-
'patch',
|
|
164
|
-
'change',
|
|
165
|
-
'apply',
|
|
166
|
-
'continue',
|
|
167
|
-
'resume',
|
|
168
|
-
'make the changes',
|
|
169
|
-
'go ahead',
|
|
170
|
-
];
|
|
171
|
-
const lookupKeywords = [
|
|
172
|
-
'where is',
|
|
173
|
-
'which file',
|
|
174
|
-
'show me',
|
|
175
|
-
'list',
|
|
176
|
-
'what files',
|
|
177
|
-
'find',
|
|
178
|
-
'grep',
|
|
179
|
-
];
|
|
98
|
+
let primary: RoutePair;
|
|
99
|
+
try {
|
|
100
|
+
primary = resolveRoutePair(profile, tier, thinkingOverrides);
|
|
101
|
+
} catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
180
104
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
+
});
|
|
185
143
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
+
};
|
|
200
171
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if (containsAny(prompt, lowercaseMatches)) {
|
|
207
|
-
if (!highestTier || tierRank[rule.tier] > tierRank[highestTier]) {
|
|
208
|
-
highestTier = rule.tier;
|
|
209
|
-
winningRule = rule;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
}
|
|
172
|
+
export interface BaselineSelection {
|
|
173
|
+
pair: RoutePair;
|
|
174
|
+
reasonCode: 'baseline' | 'pinned' | 'budget';
|
|
175
|
+
isBudgetForced: boolean;
|
|
176
|
+
}
|
|
213
177
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
? winningRule.matches
|
|
219
|
-
: [winningRule.matches];
|
|
220
|
-
reasoning =
|
|
221
|
-
winningRule.reason ??
|
|
222
|
-
`Matched custom routing rule for: ${matches.join(', ')}`;
|
|
223
|
-
isRuleMatched = true;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
178
|
+
const routeForTier = (
|
|
179
|
+
pairs: readonly RoutePair[],
|
|
180
|
+
tier: RouterTier,
|
|
181
|
+
): RoutePair | undefined => pairs.find((pair) => pair.tier === tier);
|
|
226
182
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
);
|
|
233
|
-
const lowThreshold = Math.max(
|
|
234
|
-
4,
|
|
235
|
-
12 -
|
|
236
|
-
(previousDecision?.phase === 'implementation' ||
|
|
237
|
-
previousDecision?.phase === 'planning'
|
|
238
|
-
? phaseBias * 8
|
|
239
|
-
: 0),
|
|
240
|
-
);
|
|
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
|
+
};
|
|
241
188
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
tier = 'low';
|
|
250
|
-
reasoning =
|
|
251
|
-
'Detected an explicit request for a faster or lighter response.';
|
|
252
|
-
} else if (containsAny(prompt, summaryKeywords)) {
|
|
253
|
-
phase = 'lightweight';
|
|
254
|
-
tier = 'low';
|
|
255
|
-
reasoning = 'Detected summary or lightweight transformation keywords.';
|
|
256
|
-
} else if (
|
|
257
|
-
containsAny(prompt, planningKeywords) ||
|
|
258
|
-
prompt.startsWith('why ') ||
|
|
259
|
-
wordCount >= highThreshold ||
|
|
260
|
-
multiLinePrompt
|
|
261
|
-
) {
|
|
262
|
-
phase = 'planning';
|
|
263
|
-
tier = 'high';
|
|
264
|
-
reasoning =
|
|
265
|
-
previousDecision?.phase === 'planning'
|
|
266
|
-
? 'Continued planning phase based on complexity or keywords.'
|
|
267
|
-
: 'Detected planning, broad analysis, or a high-complexity request.';
|
|
268
|
-
} else if (containsAny(prompt, implementationKeywords)) {
|
|
269
|
-
phase = 'implementation';
|
|
270
|
-
tier = 'medium';
|
|
271
|
-
reasoning =
|
|
272
|
-
'Detected implementation-oriented work with bounded execution scope.';
|
|
273
|
-
} else if (
|
|
274
|
-
containsAny(prompt, lookupKeywords) &&
|
|
275
|
-
wordCount <= 24 &&
|
|
276
|
-
toolResultCount === 0
|
|
277
|
-
) {
|
|
278
|
-
phase = 'lightweight';
|
|
279
|
-
tier = 'low';
|
|
280
|
-
reasoning = 'Detected a short read-only lookup request.';
|
|
281
|
-
} else if (
|
|
282
|
-
previousDecision?.phase === 'planning' &&
|
|
283
|
-
toolResultCount === 0 &&
|
|
284
|
-
wordCount > lowThreshold
|
|
285
|
-
) {
|
|
286
|
-
phase = 'planning';
|
|
287
|
-
tier = 'high';
|
|
288
|
-
reasoning =
|
|
289
|
-
'Kept the planning-phase bias because the conversation still looks exploratory.';
|
|
290
|
-
} else if (
|
|
291
|
-
toolResultCount > 0 ||
|
|
292
|
-
previousDecision?.phase === 'implementation' ||
|
|
293
|
-
recentConversation.includes('plan:')
|
|
294
|
-
) {
|
|
295
|
-
phase = 'implementation';
|
|
296
|
-
tier = 'medium';
|
|
297
|
-
reasoning =
|
|
298
|
-
'Detected active implementation work from prior tools or recent plan execution context.';
|
|
299
|
-
} else if (wordCount <= lowThreshold) {
|
|
300
|
-
phase = 'lightweight';
|
|
301
|
-
tier = 'low';
|
|
302
|
-
reasoning = 'Detected a short bounded request.';
|
|
303
|
-
}
|
|
304
|
-
}
|
|
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;
|
|
305
196
|
}
|
|
197
|
+
return undefined;
|
|
198
|
+
};
|
|
306
199
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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.`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
return { pair, reasonCode: 'pinned', isBudgetForced: false };
|
|
313
219
|
}
|
|
314
220
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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 };
|
|
324
242
|
}
|
|
325
243
|
|
|
326
|
-
|
|
327
|
-
profileName
|
|
328
|
-
profile,
|
|
329
|
-
tier,
|
|
330
|
-
phase,
|
|
331
|
-
reasoning,
|
|
332
|
-
thinkingOverrides,
|
|
333
|
-
false,
|
|
244
|
+
throw new Error(
|
|
245
|
+
`No eligible route for profile "${profileName}": no configured model is available for the current input and thinking level.`,
|
|
334
246
|
);
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
247
|
+
};
|
|
248
|
+
|
|
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,
|
|
257
|
+
);
|
|
258
|
+
return primary ? [primary] : [];
|
|
259
|
+
});
|
|
260
|
+
|
|
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
|
+
};
|
|
338
278
|
};
|
package/extensions/state.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
RouterPinByProfile,
|
|
15
15
|
RoutingDecision,
|
|
16
16
|
} from './types';
|
|
17
|
+
import { isAdvisorOutcome, isRoutingReasonCode } from './types';
|
|
17
18
|
|
|
18
19
|
const LAST_PROFILE_STATE_FILE = 'model-router-state.json';
|
|
19
20
|
|
|
@@ -30,20 +31,34 @@ const isModelRef = (value: unknown) => {
|
|
|
30
31
|
return false;
|
|
31
32
|
}
|
|
32
33
|
};
|
|
34
|
+
|
|
35
|
+
// Historical snapshots remain readable, but obsolete prompt-derived sources
|
|
36
|
+
// are sanitized to `legacy` and never become live routing behavior again.
|
|
37
|
+
const OBSOLETE_REASON_CODES = new Set([
|
|
38
|
+
'custom-rule',
|
|
39
|
+
'micro-mechanical',
|
|
40
|
+
'heuristic',
|
|
41
|
+
'safety-floor',
|
|
42
|
+
'budget-floor-conflict',
|
|
43
|
+
]);
|
|
44
|
+
const isPersistedReasonCode = (value: unknown): boolean =>
|
|
45
|
+
isRoutingReasonCode(value) ||
|
|
46
|
+
(typeof value === 'string' && OBSOLETE_REASON_CODES.has(value));
|
|
47
|
+
|
|
33
48
|
const isDecision = (value: unknown): value is RoutingDecision =>
|
|
34
49
|
isObjectRecord(value) &&
|
|
35
50
|
isRouterTier(value.tier) &&
|
|
36
51
|
isPhase(value.phase) &&
|
|
37
52
|
isThinkingLevel(value.thinking) &&
|
|
38
53
|
isFiniteNumber(value.timestamp) &&
|
|
39
|
-
[
|
|
40
|
-
'
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
'
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
['isClassifier', 'isFallback', 'isBudgetForced'
|
|
54
|
+
['profile', 'targetProvider', 'targetModelId', 'targetLabel'].every(
|
|
55
|
+
(key) => typeof value[key] === 'string',
|
|
56
|
+
) &&
|
|
57
|
+
(value.reasonCode === undefined
|
|
58
|
+
? typeof value.reasoning === 'string'
|
|
59
|
+
: isPersistedReasonCode(value.reasonCode)) &&
|
|
60
|
+
(value.advisor === undefined || isAdvisorOutcome(value.advisor)) &&
|
|
61
|
+
['isClassifier', 'isFallback', 'isBudgetForced'].every(
|
|
47
62
|
(key) => value[key] === undefined || typeof value[key] === 'boolean',
|
|
48
63
|
);
|
|
49
64
|
const isMap = (value: unknown, validate: (entry: unknown) => boolean) =>
|
|
@@ -128,6 +143,36 @@ export const isRouterPersistedState = (
|
|
|
128
143
|
);
|
|
129
144
|
};
|
|
130
145
|
|
|
146
|
+
// Copy only the decision contract, never incidental runtime properties.
|
|
147
|
+
export const snapshotDecision = (
|
|
148
|
+
decision: RoutingDecision,
|
|
149
|
+
): RoutingDecision => ({
|
|
150
|
+
profile: decision.profile,
|
|
151
|
+
tier: decision.tier,
|
|
152
|
+
phase: decision.phase,
|
|
153
|
+
targetProvider: decision.targetProvider,
|
|
154
|
+
targetModelId: decision.targetModelId,
|
|
155
|
+
targetLabel: decision.targetLabel,
|
|
156
|
+
reasonCode: isRoutingReasonCode(decision.reasonCode)
|
|
157
|
+
? decision.reasonCode
|
|
158
|
+
: 'legacy',
|
|
159
|
+
routingLatencyMs:
|
|
160
|
+
isFiniteNumber(decision.routingLatencyMs) && decision.routingLatencyMs >= 0
|
|
161
|
+
? decision.routingLatencyMs
|
|
162
|
+
: undefined,
|
|
163
|
+
errorClass:
|
|
164
|
+
decision.errorClass === 'advisor-unavailable' ||
|
|
165
|
+
decision.errorClass === 'deadline'
|
|
166
|
+
? decision.errorClass
|
|
167
|
+
: undefined,
|
|
168
|
+
advisor: isAdvisorOutcome(decision.advisor) ? decision.advisor : undefined,
|
|
169
|
+
thinking: decision.thinking,
|
|
170
|
+
timestamp: decision.timestamp,
|
|
171
|
+
isClassifier: decision.isClassifier,
|
|
172
|
+
isFallback: decision.isFallback,
|
|
173
|
+
isBudgetForced: decision.isBudgetForced,
|
|
174
|
+
});
|
|
175
|
+
|
|
131
176
|
export const buildPersistedState = ({
|
|
132
177
|
routerEnabled,
|
|
133
178
|
selectedProfile,
|
|
@@ -154,9 +199,9 @@ export const buildPersistedState = ({
|
|
|
154
199
|
thinkingByProfile: { ...thinkingByProfile },
|
|
155
200
|
debugEnabled,
|
|
156
201
|
widgetEnabled,
|
|
157
|
-
debugHistory,
|
|
202
|
+
debugHistory: debugHistory.map(snapshotDecision),
|
|
158
203
|
lastPhase: lastDecision?.phase,
|
|
159
|
-
lastDecision,
|
|
204
|
+
lastDecision: lastDecision ? snapshotDecision(lastDecision) : undefined,
|
|
160
205
|
lastNonRouterModel,
|
|
161
206
|
accumulatedCost,
|
|
162
207
|
timestamp: Date.now(),
|