@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.
@@ -1,76 +1,28 @@
1
- import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
2
- import type { Context, Message } from '@earendil-works/pi-ai';
3
- import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
4
- import { isRouterTier, parseCanonicalModelRef } from './config';
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
- RoutingRule,
15
+ RoutingReasonCode,
12
16
  } from './types';
17
+ import { ROUTER_TIERS } 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
- };
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 resolveAvailableTier = (
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
- isClassifier?: boolean,
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
- phase,
124
- targetProvider: provider,
125
- targetModelId: modelId,
126
- targetLabel: routed.model,
127
- reasoning,
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
- export const decideRouting = (
135
- context: Context,
136
- profileName: string,
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
- previousDecision: RoutingDecision | undefined,
139
- pinnedTier?: RouterTier,
90
+ findModel: (provider: string, modelId: string) => Model<Api> | undefined,
91
+ imageAttached: boolean,
140
92
  thinkingOverrides?: RouterThinkingByTier,
141
- phaseBias = 0.5,
142
- rules?: RoutingRule[],
143
- isBudgetExceeded = false,
144
- ): RoutingDecision => {
145
- if (previousDecision?.profile !== profileName) previousDecision = undefined;
146
- const prompt = getLastUserText(context).toLowerCase();
147
- const recentConversation = getRecentConversationText(context);
148
- const toolResultCount = countToolResults(context);
149
- const wordCount = countWords(prompt);
150
- const multiLinePrompt = prompt.split('\n').length >= 4;
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
- const explicitHighHints = [
153
- 'best',
154
- 'deep',
155
- 'deeply',
156
- 'carefully',
157
- 'thoroughly',
158
- 'robust',
159
- 'comprehensive',
160
- 'step by step',
161
- 'think hard',
162
- 'highest quality',
163
- 'ultrathink',
164
- ];
165
- const explicitLowHints = [
166
- 'fast',
167
- 'cheap',
168
- 'quick',
169
- 'quickly',
170
- 'brief',
171
- 'briefly',
172
- 'one sentence',
173
- 'one line',
174
- 'tiny',
175
- 'small',
176
- ];
177
- const planningKeywords = [
178
- 'plan',
179
- 'planning',
180
- 'architecture',
181
- 'architect',
182
- 'design',
183
- 'tradeoff',
184
- 'trade-off',
185
- 'research',
186
- 'investigate',
187
- 'root cause',
188
- 'analyze',
189
- 'analysis',
190
- 'migration',
191
- 'strategy',
192
- 'compare',
193
- 'options',
194
- 'approach',
195
- ];
196
- const summaryKeywords = [
197
- 'summarize',
198
- 'summary',
199
- 'changelog',
200
- 'rewrite',
201
- 'reformat',
202
- 'format',
203
- 'rename',
204
- 'explain briefly',
205
- 'recap',
206
- 'tl;dr',
207
- ];
208
- const implementationKeywords = [
209
- 'implement',
210
- 'code',
211
- 'fix',
212
- 'update',
213
- 'edit',
214
- 'write',
215
- 'refactor',
216
- 'add tests',
217
- 'patch',
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
- let phase: RouterPhase = previousDecision?.phase ?? 'implementation';
236
- let tier: RouterTier = 'medium';
237
- let reasoning = 'Defaulted to medium tier for general coding work.';
238
- let isRuleMatched = false;
172
+ export interface BaselineSelection {
173
+ pair: RoutePair;
174
+ reasonCode: 'baseline' | 'pinned' | 'budget';
175
+ isBudgetForced: boolean;
176
+ }
239
177
 
240
- if (pinnedTier) {
241
- phase = phaseForTier(pinnedTier);
242
- tier = pinnedTier;
243
- reasoning = `Pinned to ${pinnedTier} tier via /router-pin.`;
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
- for (const rule of rules) {
256
- const matches = Array.isArray(rule.matches)
257
- ? rule.matches
258
- : [rule.matches];
259
- const lowercaseMatches = matches.map((m) => m.toLowerCase());
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
- if (winningRule && highestTier) {
269
- tier = highestTier;
270
- phase = phaseForTier(tier);
271
- const matches = Array.isArray(winningRule.matches)
272
- ? winningRule.matches
273
- : [winningRule.matches];
274
- reasoning =
275
- winningRule.reason ??
276
- `Matched custom routing rule for: ${matches.join(', ')}`;
277
- isRuleMatched = true;
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
- if (!isRuleMatched) {
282
- // Sticky phase adjustments
283
- const highThreshold = Math.max(
284
- 40,
285
- 120 - (previousDecision?.phase === 'planning' ? phaseBias * 80 : 0),
286
- );
287
- const lowThreshold = Math.max(
288
- 4,
289
- 12 -
290
- (previousDecision?.phase === 'implementation' ||
291
- previousDecision?.phase === 'planning'
292
- ? phaseBias * 8
293
- : 0),
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
- let isBudgetForced = false;
362
- if (isBudgetExceeded && tier === 'high') {
363
- tier = 'medium';
364
- phase = 'implementation';
365
- reasoning = `Budget exceeded. Downgraded from high to medium tier. (Original: ${reasoning})`;
366
- isBudgetForced = true;
367
- }
368
-
369
- // Resolve to nearest available tier if the selected tier is disabled
370
- const resolvedTier =
371
- isBudgetForced && !profile.medium && profile.low
372
- ? 'low'
373
- : resolveAvailableTier(profile, tier);
374
- if (resolvedTier !== tier) {
375
- reasoning = `Resolved from ${tier} to ${resolvedTier} tier (${tier} tier is not configured). Original: ${reasoning}`;
376
- phase = phaseForTier(resolvedTier);
377
- tier = resolvedTier;
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
- const decision = buildRoutingDecision(
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 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:'),
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
- 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;
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
  };