@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.
@@ -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
- pi,
226
- {
227
- get lastRegisteredModels() {
228
- return lastRegisteredModels;
229
- },
230
- set lastRegisteredModels(v) {
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 entries = ctx.sessionManager.getBranch() as CustomSessionEntry[];
322
- const savedState = entries
323
- .filter(
324
- (entry) =>
325
- entry.type === 'custom' && entry.customType === 'router-state',
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
- .map((entry) => entry.data)
328
- .findLast((data) => isRouterPersistedState(data));
347
+ .findLast(isRouterPersistedState);
329
348
 
330
- if (isRouterPersistedState(savedState)) {
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
- const overrides = thinkingByProfile[selectedProfile];
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 unsupported = getUnsupportedTiers(
561
- currentConfig.profiles[selectedProfile],
562
- event.level,
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}. ` +
@@ -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 startIndex = 0;
135
- for (let i = 1; i < messages.length && remaining > limit; i++) {
136
- if (messages[i].role !== 'user') continue;
137
- while (startIndex < i) remaining -= messageTokens[startIndex++];
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(0, startIndex)
144
- .filter((message) => message.role === 'system'),
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.map((name) => {
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 maxMaxTokens = 0;
219
+ let maxOutputTokens = 0;
212
220
  for (const tier of ROUTER_TIERS) {
213
221
  if (!profile[tier]) continue;
214
- const cw = resolveContextWindow(
222
+ const contextWindow = resolveContextWindow(
215
223
  tier,
216
224
  profile,
217
225
  state.currentModelRegistry,
218
226
  );
219
- const mot = resolveMaxTokens(tier, profile, state.currentModelRegistry);
220
- if (cw > maxContextWindow) maxContextWindow = cw;
221
- if (mot > maxMaxTokens) maxMaxTokens = mot;
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
- id: name,
238
- name: `Router ${name}`,
239
- reasoning: hasReasoning,
240
- ...(thinkingLevelMap ? { thinkingLevelMap } : {}),
241
- input: ['text', 'image'] as ('text' | 'image')[],
242
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
243
- contextWindow: maxContextWindow || DEFAULT_CONTEXT_WINDOW,
244
- maxTokens: maxMaxTokens || DEFAULT_MAX_TOKENS,
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 (let i = 0; i < modelsToTry.length; i++) {
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 as ThinkingLevel,
504
+ requestedReasoning,
492
505
  tierConfig.resolvedThinkingLevels,
493
- ) as typeof requestedReasoning;
506
+ );
494
507
  }
495
508
  }
496
509
 
497
- const delegatedReasoning =
510
+ const delegatedReasoning: SimpleStreamOptions['reasoning'] =
498
511
  targetModel.reasoning && requestedReasoning !== 'off'
499
- ? (requestedReasoning as SimpleStreamOptions['reasoning'])
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 }
@@ -1,7 +1,12 @@
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 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
- if (profile[order[i]]) return order[i];
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
- if (profile[order[i]]) return order[i];
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
- };