@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,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { setTimeout as delay } from 'node:timers/promises';
2
3
  import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
3
4
  import {
@@ -14,8 +15,8 @@ import type {
14
15
  ExtensionAPI,
15
16
  ExtensionContext,
16
17
  } from '@earendil-works/pi-coding-agent';
18
+ import { runClassifier } from './classifier';
17
19
  import {
18
- clampThinkingLevel,
19
20
  collectProfileThinkingLevels,
20
21
  MAX_THINKING_LEVEL,
21
22
  parseCanonicalModelRef,
@@ -25,6 +26,18 @@ import {
25
26
  resolveMaxTokens,
26
27
  } from './config';
27
28
  import { DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS } from './constants';
29
+ import {
30
+ extractTextFromContent,
31
+ getBoundedRecentContext,
32
+ hasImageAttachment,
33
+ } from './context';
34
+ import { createJevCandidate, runJev } from './jev';
35
+ import {
36
+ availableRoutePairs,
37
+ decisionForPair,
38
+ primaryRoutePairs,
39
+ selectBaselineRoute,
40
+ } from './routing';
28
41
  import type {
29
42
  RouterConfig,
30
43
  RouterPinByProfile,
@@ -68,16 +81,6 @@ export const waitForRegistry = async (
68
81
  return undefined;
69
82
  };
70
83
 
71
- import {
72
- buildRoutingDecision,
73
- decideRouting,
74
- extractTextFromContent,
75
- hasImageAttachment,
76
- phaseForTier,
77
- resolveAvailableTier,
78
- runClassifier,
79
- } from './routing';
80
-
81
84
  export const createErrorMessage = (
82
85
  model: Model<Api>,
83
86
  message: string,
@@ -130,19 +133,28 @@ const truncateContext = (context: Context, limit: number): Context => {
130
133
 
131
134
  // Drop only complete turns. Splitting an assistant/tool-result pair corrupts transcripts.
132
135
  // This text estimate cannot guarantee a fit for images/tools or one oversized active turn.
136
+ const systemMessages = messages.filter(
137
+ (message) => message.role === 'system',
138
+ );
133
139
  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++];
140
+ let nextRemovableIndex = 0;
141
+ for (let i = 0; i < messages.length && remaining > limit; i += 1) {
142
+ if (messages[i]?.role !== 'user') continue;
143
+ while (nextRemovableIndex < i) {
144
+ const candidate = messages[nextRemovableIndex];
145
+ if (candidate?.role !== 'system') {
146
+ remaining -= messageTokens[nextRemovableIndex] ?? 0;
147
+ }
148
+ nextRemovableIndex += 1;
149
+ }
138
150
  }
139
151
  return {
140
152
  ...context,
141
153
  messages: [
154
+ ...systemMessages,
142
155
  ...messages
143
- .slice(0, startIndex)
144
- .filter((message) => message.role === 'system'),
145
- ...messages.slice(startIndex),
156
+ .slice(nextRemovableIndex)
157
+ .filter((message) => message.role !== 'system'),
146
158
  ],
147
159
  };
148
160
  };
@@ -201,24 +213,29 @@ export const registerRouterProvider = (
201
213
  const profileList = profileNames(state.currentConfig);
202
214
 
203
215
  // Map profiles to their capacities
204
- const modelDefinitions = profileList.map((name) => {
216
+ const modelDefinitions = profileList.flatMap((name) => {
205
217
  const profile = state.currentConfig.profiles[name];
218
+ if (!profile) return [];
206
219
 
207
220
  // Report the MAX context window and max output tokens across all tiers.
208
221
  // The honesty check + truncateContext handles the case where the
209
222
  // actually routed model is smaller.
210
223
  let maxContextWindow = 0;
211
- let maxMaxTokens = 0;
224
+ let maxOutputTokens = 0;
212
225
  for (const tier of ROUTER_TIERS) {
213
226
  if (!profile[tier]) continue;
214
- const cw = resolveContextWindow(
227
+ const contextWindow = resolveContextWindow(
228
+ tier,
229
+ profile,
230
+ state.currentModelRegistry,
231
+ );
232
+ const maxTokens = resolveMaxTokens(
215
233
  tier,
216
234
  profile,
217
235
  state.currentModelRegistry,
218
236
  );
219
- const mot = resolveMaxTokens(tier, profile, state.currentModelRegistry);
220
- if (cw > maxContextWindow) maxContextWindow = cw;
221
- if (mot > maxMaxTokens) maxMaxTokens = mot;
237
+ if (contextWindow > maxContextWindow) maxContextWindow = contextWindow;
238
+ if (maxTokens > maxOutputTokens) maxOutputTokens = maxTokens;
222
239
  }
223
240
 
224
241
  const hasReasoning = supportsReasoning(profile, state.currentModelRegistry);
@@ -233,21 +250,55 @@ export const registerRouterProvider = (
233
250
  if (Object.keys(map).length > 0) thinkingLevelMap = map;
234
251
  }
235
252
 
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
- };
253
+ return [
254
+ {
255
+ id: name,
256
+ name: `Router ${name}`,
257
+ reasoning: hasReasoning,
258
+ ...(thinkingLevelMap ? { thinkingLevelMap } : {}),
259
+ input: ['text', 'image'] satisfies ('text' | 'image')[],
260
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
261
+ contextWindow: maxContextWindow || DEFAULT_CONTEXT_WINDOW,
262
+ maxTokens: maxOutputTokens || DEFAULT_MAX_TOKENS,
263
+ },
264
+ ];
246
265
  });
247
266
 
248
267
  const modelsKey = JSON.stringify(modelDefinitions);
249
268
  if (state.lastRegisteredModels === modelsKey) return;
250
269
 
270
+ // Runtime only: no task identities or branch metadata enter persisted decisions.
271
+ type ContinuationRecord = {
272
+ turn: string;
273
+ policy: string;
274
+ branch: string[];
275
+ decision: RoutingDecision;
276
+ toolCalls: Set<string>;
277
+ config: RouterConfig;
278
+ };
279
+ // Streams can complete out of order. Keep a small turn-keyed history rather
280
+ // than letting the latest stream replace another stream's continuation.
281
+ const continuations = new Map<string, ContinuationRecord>();
282
+ const advisedTurns = new Map<string, true>();
283
+ const rememberContinuation = (record: ContinuationRecord) => {
284
+ continuations.delete(record.turn);
285
+ continuations.set(record.turn, record);
286
+ while (continuations.size > 16) {
287
+ const oldest = continuations.keys().next().value;
288
+ if (oldest === undefined) break;
289
+ continuations.delete(oldest);
290
+ }
291
+ };
292
+ const rememberAdvisedTurn = (turn: string) => {
293
+ advisedTurns.delete(turn);
294
+ advisedTurns.set(turn, true);
295
+ while (advisedTurns.size > 16) {
296
+ const oldest = advisedTurns.keys().next().value;
297
+ if (oldest === undefined) break;
298
+ advisedTurns.delete(oldest);
299
+ }
300
+ };
301
+
251
302
  pi.registerProvider('router', {
252
303
  baseUrl: 'router://local',
253
304
  apiKey: 'pi-model-router',
@@ -260,8 +311,10 @@ export const registerRouterProvider = (
260
311
  ): AssistantMessageEventStream {
261
312
  const stream = createAssistantMessageEventStream();
262
313
 
263
- (async () => {
314
+ void (async () => {
264
315
  let partialMessage: AssistantMessage | undefined;
316
+ let activeTurn: string | undefined;
317
+ let generationSucceeded = false;
265
318
  try {
266
319
  // Wait for the router to be fully initialized (session_start sets currentModelRegistry).
267
320
  // This handles the race where subagents (e.g. from pi-dynamic-workflows) invoke
@@ -281,6 +334,7 @@ export const registerRouterProvider = (
281
334
  throw new Error(`Unknown router profile: ${model.id}`);
282
335
  }
283
336
 
337
+ options?.signal?.throwIfAborted();
284
338
  state.selectedProfile = model.id;
285
339
  state.routerEnabled = true;
286
340
 
@@ -289,131 +343,270 @@ export const registerRouterProvider = (
289
343
  state.currentConfig.maxSessionBudget !== undefined &&
290
344
  state.accumulatedCost >= state.currentConfig.maxSessionBudget;
291
345
 
292
- let decision: RoutingDecision = decideRouting(
293
- context,
346
+ const imageAttached = hasImageAttachment(context);
347
+ const findModel = (provider: string, id: string) =>
348
+ registry.find(provider, id);
349
+ const thinkingOverrides = state.thinkingByProfile[model.id];
350
+ const available = () =>
351
+ availableRoutePairs(
352
+ profile,
353
+ findModel,
354
+ imageAttached,
355
+ thinkingOverrides,
356
+ );
357
+ let pairs = available();
358
+ const lastUserIndex = context.messages.findLastIndex(
359
+ (entry) => entry.role === 'user',
360
+ );
361
+ const user = context.messages[lastUserIndex];
362
+ const turn =
363
+ user?.role === 'user' && user.timestamp > 0
364
+ ? createHash('sha256')
365
+ .update(
366
+ JSON.stringify(
367
+ context.messages.slice(0, lastUserIndex + 1),
368
+ ),
369
+ )
370
+ .digest('hex')
371
+ : undefined;
372
+ activeTurn = turn;
373
+ const branch =
374
+ state.lastExtensionContext?.sessionManager
375
+ .getBranch()
376
+ .filter(
377
+ (entry) =>
378
+ entry.type !== 'custom' ||
379
+ entry.customType !== 'router-state',
380
+ )
381
+ .map((entry) => entry.id) ?? [];
382
+ // Pi resolves authentication per request. This validates provider/model
383
+ // identity, not the backend account behind that provider.
384
+ const policy = JSON.stringify([
294
385
  model.id,
295
386
  profile,
296
- state.lastDecision,
297
387
  pinnedTier,
298
- state.thinkingByProfile[model.id],
299
- state.currentConfig.phaseBias,
300
- state.currentConfig.rules,
388
+ thinkingOverrides,
389
+ state.currentConfig.classifierModel,
301
390
  isBudgetExceeded,
391
+ ]);
392
+ const toolContinuation =
393
+ context.messages.at(-1)?.role === 'toolResult';
394
+ const continuationRecord =
395
+ toolContinuation && turn ? continuations.get(turn) : undefined;
396
+ const continuationDecision = continuationRecord?.decision;
397
+ const latestAssistantIndex = context.messages.findLastIndex(
398
+ (entry) => entry.role === 'assistant',
302
399
  );
303
-
304
- // Classifier Override — skip when budget is already exceeded since the
305
- // result would be downgraded anyway, saving an unnecessary LLM call.
306
- if (
307
- state.currentConfig.classifierModel &&
308
- !pinnedTier &&
309
- !decision.isRuleMatched &&
310
- !isBudgetExceeded
311
- ) {
312
- const classifierResult = await runClassifier(
313
- state.currentConfig.classifierModel.model,
314
- registry,
315
- context,
316
- state.lastDecision?.profile === model.id
317
- ? state.lastDecision.phase
318
- : undefined,
319
- state.currentConfig.classifierModel.thinking,
320
- options?.signal,
400
+ const latestResults = context.messages
401
+ .slice(latestAssistantIndex + 1)
402
+ .filter((entry) => entry.role === 'toolResult');
403
+ const latestAssistant = context.messages[latestAssistantIndex];
404
+ const reusable =
405
+ toolContinuation &&
406
+ continuationRecord &&
407
+ continuationDecision &&
408
+ turn &&
409
+ continuationRecord.turn === turn &&
410
+ continuationRecord.policy === policy &&
411
+ continuationRecord.config === state.currentConfig &&
412
+ continuationDecision?.profile === model.id &&
413
+ branch.length > 0 &&
414
+ branch.every((id) => typeof id === 'string' && id.length > 0) &&
415
+ continuationRecord.branch.length > 0 &&
416
+ continuationRecord.branch.every((id, i) => branch[i] === id) &&
417
+ latestResults.length > 0 &&
418
+ latestAssistant?.role === 'assistant' &&
419
+ latestAssistant.provider === continuationDecision.targetProvider &&
420
+ latestAssistant.model === continuationDecision.targetModelId &&
421
+ latestResults.every((entry) =>
422
+ latestAssistant.content.some(
423
+ (part) =>
424
+ part.type === 'toolCall' && part.id === entry.toolCallId,
425
+ ),
426
+ ) &&
427
+ latestResults.every((entry) =>
428
+ continuationRecord.toolCalls.has(entry.toolCallId),
429
+ ) &&
430
+ pairs.some(
431
+ (pair) =>
432
+ pair.tier === continuationDecision.tier &&
433
+ pair.model === continuationDecision.targetLabel &&
434
+ pair.thinking === continuationDecision.thinking,
321
435
  );
322
- options?.signal?.throwIfAborted();
323
- if (classifierResult) {
324
- const tier = resolveAvailableTier(profile, classifierResult.tier);
325
- decision = buildRoutingDecision(
326
- model.id,
327
- profile,
328
- tier,
329
- phaseForTier(tier),
330
- `Classifier: ${classifierResult.reasoning}`,
331
- state.thinkingByProfile[model.id],
332
- true,
333
- );
334
- }
335
- }
336
-
337
- const lastMessage = context.messages[context.messages.length - 1];
338
- const previousDecision = state.lastDecision;
339
- const isGoogleThinkingToolContinuation =
340
- lastMessage?.role === 'toolResult' &&
341
- previousDecision?.profile === model.id &&
342
- previousDecision.targetProvider === 'google' &&
343
- previousDecision.thinking !== 'off' &&
344
- decision.targetProvider === 'google' &&
345
- decision.thinking !== 'off' &&
346
- previousDecision.targetLabel !== decision.targetLabel;
347
-
348
- if (isGoogleThinkingToolContinuation && previousDecision) {
436
+ let decision: RoutingDecision;
437
+ if (reusable && continuationDecision) {
349
438
  decision = {
350
- ...decision,
351
- tier: previousDecision.tier,
352
- phase: previousDecision.phase,
353
- targetProvider: previousDecision.targetProvider,
354
- targetModelId: previousDecision.targetModelId,
355
- targetLabel: previousDecision.targetLabel,
356
- thinking: previousDecision.thinking,
357
- reasoning:
358
- `Preserved ${previousDecision.targetLabel} for a Google tool-result continuation ` +
359
- `to avoid thought-signature replay errors. (Original: ${decision.reasoning})`,
439
+ ...continuationDecision,
440
+ reasonCode: 'continuation',
441
+ // Advisor diagnostics describe the original routing attempt only.
442
+ isClassifier: undefined,
443
+ routingLatencyMs: undefined,
444
+ errorClass: undefined,
445
+ timestamp: Date.now(),
360
446
  };
447
+ } else {
448
+ if (toolContinuation && turn) continuations.delete(turn);
449
+ const baseline = selectBaselineRoute(
450
+ model.id,
451
+ profile,
452
+ pairs,
453
+ pinnedTier,
454
+ isBudgetExceeded,
455
+ );
456
+ decision = decisionForPair(
457
+ model.id,
458
+ baseline.pair,
459
+ baseline.reasonCode,
460
+ );
461
+ decision.isBudgetForced = baseline.isBudgetForced;
361
462
  }
362
463
 
363
- const imageAttached = hasImageAttachment(context);
364
- const checkModelSupportsImage = (modelRef: string) => {
365
- try {
366
- const { provider, modelId } = parseCanonicalModelRef(modelRef);
367
- const m = registry.find(provider, modelId);
368
- return m?.input?.includes('image') ?? false;
369
- } catch {
370
- return false;
371
- }
372
- };
373
-
374
- if (imageAttached) {
375
- const tierModels = [
376
- decision.targetLabel,
377
- ...(profile[decision.tier]?.fallbacks ?? []),
378
- ];
379
- if (!tierModels.some(checkModelSupportsImage)) {
380
- const tiersToTry: RouterTier[] =
381
- decision.tier === 'low'
382
- ? ['medium', 'high']
383
- : decision.tier === 'medium'
384
- ? ['high']
385
- : [];
386
-
387
- let foundTier: RouterTier | undefined;
388
- for (const t of tiersToTry) {
389
- const tierConfig = profile[t];
390
- if (!tierConfig) continue;
391
- const tModels = [
392
- tierConfig.model,
393
- ...(tierConfig.fallbacks ?? []),
394
- ];
395
- if (tModels.some(checkModelSupportsImage)) {
396
- foundTier = t;
397
- break;
464
+ // Tool results never invoke advisors, even when their prior route cannot be reused.
465
+ if (
466
+ !toolContinuation &&
467
+ !pinnedTier &&
468
+ !isBudgetExceeded &&
469
+ user &&
470
+ turn &&
471
+ !advisedTurns.has(turn) &&
472
+ (state.currentConfig.classifierModel ||
473
+ (state.currentConfig.jev?.enabled && profile.jev?.enabled))
474
+ ) {
475
+ rememberAdvisedTurn(turn);
476
+ const started = performance.now();
477
+ const jev = state.currentConfig.jev;
478
+ const useJev =
479
+ jev?.enabled &&
480
+ profile.jev?.enabled &&
481
+ jev.apiKey.trim().length > 0;
482
+ const routingDeadline = started + (useJev ? 1500 : 10_000);
483
+ const candidates = primaryRoutePairs(profile, pairs).map(
484
+ createJevCandidate,
485
+ );
486
+ // A single primary bypasses advice, not a baseline's eligible fallback.
487
+ if (candidates.length > 1) {
488
+ if (useJev && jev) {
489
+ const advice = await runJev(
490
+ {
491
+ ...jev,
492
+ timeoutMs: Math.min(750, jev.timeoutMs),
493
+ },
494
+ {
495
+ taskSummary: getBoundedRecentContext(
496
+ context,
497
+ jev.maxStateChars,
498
+ ),
499
+ candidates,
500
+ profile: profile.jev,
501
+ routingDeadline,
502
+ signal: options?.signal,
503
+ },
504
+ ).catch(() => undefined);
505
+ options?.signal?.throwIfAborted();
506
+ // Re-read registry capabilities after the network boundary.
507
+ pairs = available();
508
+ const candidate = candidates.find(
509
+ (entry) => entry.id === advice?.candidateId,
510
+ );
511
+ if (
512
+ candidate &&
513
+ performance.now() < routingDeadline &&
514
+ pairs.some(
515
+ (pair) =>
516
+ pair.model === candidate.model &&
517
+ pair.tier === candidate.tier &&
518
+ pair.thinking === candidate.thinking,
519
+ )
520
+ ) {
521
+ decision = decisionForPair(model.id, candidate, 'jev');
522
+ } else {
523
+ const baseline = selectBaselineRoute(
524
+ model.id,
525
+ profile,
526
+ pairs,
527
+ );
528
+ decision = decisionForPair(
529
+ model.id,
530
+ baseline.pair,
531
+ baseline.reasonCode,
532
+ );
533
+ decision.errorClass = 'advisor-unavailable';
398
534
  }
399
- }
400
-
401
- if (foundTier) {
402
- decision = buildRoutingDecision(
535
+ } else if (state.currentConfig.classifierModel) {
536
+ const classifier = state.currentConfig.classifierModel;
537
+ const result = await runClassifier(
538
+ classifier.model,
539
+ registry,
540
+ context,
541
+ undefined,
542
+ classifier.thinking,
543
+ options?.signal,
544
+ routingDeadline,
545
+ ).catch(() => undefined);
546
+ options?.signal?.throwIfAborted();
547
+ pairs = available();
548
+ const baseline = selectBaselineRoute(model.id, profile, pairs);
549
+ decision = decisionForPair(
403
550
  model.id,
404
- profile,
405
- foundTier,
406
- phaseForTier(foundTier),
407
- `Forced ${foundTier} tier because the originally routed ${decision.tier} tier does not support image attachments.`,
408
- state.thinkingByProfile[model.id],
409
- false,
551
+ baseline.pair,
552
+ baseline.reasonCode,
410
553
  );
554
+ if (result && performance.now() < routingDeadline) {
555
+ const pair = pairs.find(
556
+ (entry) => entry.tier === result.tier,
557
+ );
558
+ if (pair) {
559
+ decision = {
560
+ ...decisionForPair(model.id, pair, 'classifier'),
561
+ isClassifier: true,
562
+ };
563
+ } else decision.errorClass = 'advisor-unavailable';
564
+ } else decision.errorClass = 'advisor-unavailable';
411
565
  }
566
+ decision.routingLatencyMs = Math.max(
567
+ 0,
568
+ performance.now() - started,
569
+ );
570
+ if (performance.now() >= routingDeadline)
571
+ decision.errorClass = 'deadline';
412
572
  }
413
573
  }
414
574
 
575
+ // Google thought signatures cannot be replayed against a different thinking model.
576
+ const priorAssistant = context.messages[latestAssistantIndex];
577
+ if (
578
+ toolContinuation &&
579
+ priorAssistant?.role === 'assistant' &&
580
+ priorAssistant.provider === 'google' &&
581
+ priorAssistant.content.some(
582
+ (entry) =>
583
+ entry.type === 'thinking' ||
584
+ (entry.type === 'toolCall' && entry.thoughtSignature),
585
+ ) &&
586
+ (decision.targetProvider !== priorAssistant.provider ||
587
+ decision.targetModelId !== priorAssistant.model)
588
+ ) {
589
+ const priorPair =
590
+ pairs.find(
591
+ (pair) =>
592
+ pair.model ===
593
+ `${priorAssistant.provider}/${priorAssistant.model}` &&
594
+ pair.tier === continuationDecision?.tier &&
595
+ pair.thinking === continuationDecision?.thinking,
596
+ ) ??
597
+ pairs.find(
598
+ (pair) =>
599
+ pair.model ===
600
+ `${priorAssistant.provider}/${priorAssistant.model}`,
601
+ );
602
+ if (!priorPair)
603
+ throw new Error(
604
+ 'No compatible route for Google tool continuation.',
605
+ );
606
+ decision = decisionForPair(model.id, priorPair, 'continuation');
607
+ }
608
+
415
609
  state.lastDecision = decision;
416
- actions.recordDebugDecision(decision);
417
610
 
418
611
  // Sync pi's thinking level display with the router's effective thinking.
419
612
  // Wrapped in try/catch: in subagent contexts the extension runtime
@@ -430,26 +623,16 @@ export const registerRouterProvider = (
430
623
  // Stale extension context — skip non-critical UI updates.
431
624
  }
432
625
 
433
- let modelsToTry = [
434
- ...new Set([
435
- decision.targetLabel,
436
- ...(profile[decision.tier]?.fallbacks ?? []),
437
- ]),
438
- ];
439
- if (imageAttached) {
440
- modelsToTry = modelsToTry.filter(checkModelSupportsImage);
441
- if (modelsToTry.length === 0) {
442
- throw new Error(
443
- 'No configured model supports image attachments.',
444
- );
445
- }
446
- }
626
+ // Explicit fallback refs authorize provider changes; never discover other accounts.
627
+ const modelsToTry = [
628
+ decision.targetLabel,
629
+ ...(profile[decision.tier]?.fallbacks ?? []),
630
+ ].filter((ref, i, refs) => refs.indexOf(ref) === i);
447
631
  let lastError: unknown;
448
632
  let success = false;
449
633
 
450
- for (let i = 0; i < modelsToTry.length; i++) {
634
+ for (const [i, modelRef] of modelsToTry.entries()) {
451
635
  options?.signal?.throwIfAborted();
452
- const modelRef = modelsToTry[i];
453
636
  const { provider: targetProvider, modelId: targetModelId } =
454
637
  parseCanonicalModelRef(modelRef);
455
638
 
@@ -478,26 +661,33 @@ export const registerRouterProvider = (
478
661
  effectiveContext = truncateContext(context, targetLimit);
479
662
  }
480
663
 
481
- const thinkingOverride = actions.getThinkingOverride(
482
- model.id,
483
- decision.tier,
664
+ const pair = available().find(
665
+ (candidate) =>
666
+ candidate.tier === decision.tier &&
667
+ candidate.model === modelRef,
484
668
  );
485
- let requestedReasoning = thinkingOverride ?? decision.thinking;
486
-
487
- if (requestedReasoning !== 'off' && targetModel.reasoning) {
488
- const tierConfig = profile[decision.tier];
489
- if (tierConfig?.resolvedThinkingLevels) {
490
- requestedReasoning = clampThinkingLevel(
491
- requestedReasoning as ThinkingLevel,
492
- tierConfig.resolvedThinkingLevels,
493
- ) as typeof requestedReasoning;
494
- }
669
+ if (!pair)
670
+ throw new Error(
671
+ 'Routed model capabilities or thinking are unsupported.',
672
+ );
673
+ if (
674
+ toolContinuation &&
675
+ priorAssistant?.role === 'assistant' &&
676
+ priorAssistant.provider === 'google' &&
677
+ priorAssistant.content.some(
678
+ (entry) =>
679
+ entry.type === 'thinking' ||
680
+ (entry.type === 'toolCall' && entry.thoughtSignature),
681
+ ) &&
682
+ (targetProvider !== priorAssistant.provider ||
683
+ targetModelId !== priorAssistant.model)
684
+ ) {
685
+ throw new Error(
686
+ 'No compatible route for Google tool continuation.',
687
+ );
495
688
  }
496
-
497
- const delegatedReasoning =
498
- targetModel.reasoning && requestedReasoning !== 'off'
499
- ? (requestedReasoning as SimpleStreamOptions['reasoning'])
500
- : undefined;
689
+ const delegatedReasoning: SimpleStreamOptions['reasoning'] =
690
+ pair.thinking !== 'off' ? pair.thinking : undefined;
501
691
 
502
692
  try {
503
693
  if (state.lastExtensionContext) {
@@ -522,7 +712,7 @@ export const registerRouterProvider = (
522
712
  ...delegationOptions
523
713
  } = options ?? {};
524
714
 
525
- const delegatedOptions = {
715
+ const delegatedOptions: SimpleStreamOptions = {
526
716
  ...delegationOptions,
527
717
  ...(delegatedReasoning
528
718
  ? { reasoning: delegatedReasoning }
@@ -541,7 +731,13 @@ export const registerRouterProvider = (
541
731
  decision.targetModelId = targetModelId;
542
732
  decision.targetLabel = modelRef;
543
733
  decision.thinking = delegatedReasoning ?? 'off';
544
- decision.isFallback = i > 0;
734
+ decision.isFallback =
735
+ i > 0 || modelRef !== profile[decision.tier]?.model;
736
+ if (
737
+ decision.isFallback &&
738
+ decision.reasonCode !== 'continuation'
739
+ )
740
+ decision.reasonCode = 'fallback';
545
741
  };
546
742
  for await (const event of delegatedStream) {
547
743
  if (
@@ -572,10 +768,25 @@ export const registerRouterProvider = (
572
768
  }
573
769
  if (event.type === 'done' || event.type === 'error') {
574
770
  terminalReceived = true;
771
+ generationSucceeded = event.type === 'done';
575
772
  recordTarget();
576
773
  const cost = (
577
774
  event.type === 'done' ? event.message : event.error
578
775
  ).usage.cost.total;
776
+ if (event.type === 'done' && turn) {
777
+ rememberContinuation({
778
+ turn,
779
+ policy,
780
+ branch,
781
+ decision,
782
+ config: state.currentConfig,
783
+ toolCalls: new Set(
784
+ event.message.content.flatMap((entry) =>
785
+ entry.type === 'toolCall' ? [entry.id] : [],
786
+ ),
787
+ ),
788
+ });
789
+ }
579
790
  if (Number.isFinite(cost) && cost > 0)
580
791
  state.accumulatedCost += cost;
581
792
  }
@@ -611,6 +822,7 @@ export const registerRouterProvider = (
611
822
  );
612
823
  }
613
824
 
825
+ actions.recordDebugDecision(decision);
614
826
  stream.end();
615
827
  } catch (error) {
616
828
  const reason = options?.signal?.aborted ? 'aborted' : 'error';
@@ -626,6 +838,8 @@ export const registerRouterProvider = (
626
838
  });
627
839
  stream.end();
628
840
  } finally {
841
+ if (!generationSucceeded && activeTurn)
842
+ advisedTurns.delete(activeTurn);
629
843
  try {
630
844
  actions.persistState();
631
845
  } catch {