@alexeiled/pi-model-router 0.5.2 → 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 {
@@ -16,7 +17,6 @@ import type {
16
17
  } from '@earendil-works/pi-coding-agent';
17
18
  import { runClassifier } from './classifier';
18
19
  import {
19
- clampThinkingLevel,
20
20
  collectProfileThinkingLevels,
21
21
  MAX_THINKING_LEVEL,
22
22
  parseCanonicalModelRef,
@@ -26,12 +26,17 @@ import {
26
26
  resolveMaxTokens,
27
27
  } from './config';
28
28
  import { DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS } from './constants';
29
- import { extractTextFromContent, hasImageAttachment } from './context';
30
29
  import {
31
- buildRoutingDecision,
32
- decideRouting,
33
- phaseForTier,
34
- resolveAvailableTier,
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,
35
40
  } from './routing';
36
41
  import type {
37
42
  RouterConfig,
@@ -262,6 +267,38 @@ export const registerRouterProvider = (
262
267
  const modelsKey = JSON.stringify(modelDefinitions);
263
268
  if (state.lastRegisteredModels === modelsKey) return;
264
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
+
265
302
  pi.registerProvider('router', {
266
303
  baseUrl: 'router://local',
267
304
  apiKey: 'pi-model-router',
@@ -276,6 +313,8 @@ export const registerRouterProvider = (
276
313
 
277
314
  void (async () => {
278
315
  let partialMessage: AssistantMessage | undefined;
316
+ let activeTurn: string | undefined;
317
+ let generationSucceeded = false;
279
318
  try {
280
319
  // Wait for the router to be fully initialized (session_start sets currentModelRegistry).
281
320
  // This handles the race where subagents (e.g. from pi-dynamic-workflows) invoke
@@ -295,6 +334,7 @@ export const registerRouterProvider = (
295
334
  throw new Error(`Unknown router profile: ${model.id}`);
296
335
  }
297
336
 
337
+ options?.signal?.throwIfAborted();
298
338
  state.selectedProfile = model.id;
299
339
  state.routerEnabled = true;
300
340
 
@@ -303,131 +343,270 @@ export const registerRouterProvider = (
303
343
  state.currentConfig.maxSessionBudget !== undefined &&
304
344
  state.accumulatedCost >= state.currentConfig.maxSessionBudget;
305
345
 
306
- let decision: RoutingDecision = decideRouting(
307
- 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([
308
385
  model.id,
309
386
  profile,
310
- state.lastDecision,
311
387
  pinnedTier,
312
- state.thinkingByProfile[model.id],
313
- state.currentConfig.phaseBias,
314
- state.currentConfig.rules,
388
+ thinkingOverrides,
389
+ state.currentConfig.classifierModel,
315
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',
316
399
  );
317
-
318
- // Classifier Override — skip when budget is already exceeded since the
319
- // result would be downgraded anyway, saving an unnecessary LLM call.
320
- if (
321
- state.currentConfig.classifierModel &&
322
- !pinnedTier &&
323
- !decision.isRuleMatched &&
324
- !isBudgetExceeded
325
- ) {
326
- const classifierResult = await runClassifier(
327
- state.currentConfig.classifierModel.model,
328
- registry,
329
- context,
330
- state.lastDecision?.profile === model.id
331
- ? state.lastDecision.phase
332
- : undefined,
333
- state.currentConfig.classifierModel.thinking,
334
- 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,
335
435
  );
336
- options?.signal?.throwIfAborted();
337
- if (classifierResult) {
338
- const tier = resolveAvailableTier(profile, classifierResult.tier);
339
- decision = buildRoutingDecision(
340
- model.id,
341
- profile,
342
- tier,
343
- phaseForTier(tier),
344
- `Classifier: ${classifierResult.reasoning}`,
345
- state.thinkingByProfile[model.id],
346
- true,
347
- );
348
- }
349
- }
350
-
351
- const lastMessage = context.messages[context.messages.length - 1];
352
- const previousDecision = state.lastDecision;
353
- const isGoogleThinkingToolContinuation =
354
- lastMessage?.role === 'toolResult' &&
355
- previousDecision?.profile === model.id &&
356
- previousDecision.targetProvider === 'google' &&
357
- previousDecision.thinking !== 'off' &&
358
- decision.targetProvider === 'google' &&
359
- decision.thinking !== 'off' &&
360
- previousDecision.targetLabel !== decision.targetLabel;
361
-
362
- if (isGoogleThinkingToolContinuation && previousDecision) {
436
+ let decision: RoutingDecision;
437
+ if (reusable && continuationDecision) {
363
438
  decision = {
364
- ...decision,
365
- tier: previousDecision.tier,
366
- phase: previousDecision.phase,
367
- targetProvider: previousDecision.targetProvider,
368
- targetModelId: previousDecision.targetModelId,
369
- targetLabel: previousDecision.targetLabel,
370
- thinking: previousDecision.thinking,
371
- reasoning:
372
- `Preserved ${previousDecision.targetLabel} for a Google tool-result continuation ` +
373
- `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(),
374
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;
375
462
  }
376
463
 
377
- const imageAttached = hasImageAttachment(context);
378
- const checkModelSupportsImage = (modelRef: string) => {
379
- try {
380
- const { provider, modelId } = parseCanonicalModelRef(modelRef);
381
- const m = registry.find(provider, modelId);
382
- return m?.input?.includes('image') ?? false;
383
- } catch {
384
- return false;
385
- }
386
- };
387
-
388
- if (imageAttached) {
389
- const tierModels = [
390
- decision.targetLabel,
391
- ...(profile[decision.tier]?.fallbacks ?? []),
392
- ];
393
- if (!tierModels.some(checkModelSupportsImage)) {
394
- const tiersToTry: RouterTier[] =
395
- decision.tier === 'low'
396
- ? ['medium', 'high']
397
- : decision.tier === 'medium'
398
- ? ['high']
399
- : [];
400
-
401
- let foundTier: RouterTier | undefined;
402
- for (const t of tiersToTry) {
403
- const tierConfig = profile[t];
404
- if (!tierConfig) continue;
405
- const tModels = [
406
- tierConfig.model,
407
- ...(tierConfig.fallbacks ?? []),
408
- ];
409
- if (tModels.some(checkModelSupportsImage)) {
410
- foundTier = t;
411
- 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';
412
534
  }
413
- }
414
-
415
- if (foundTier) {
416
- 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(
417
550
  model.id,
418
- profile,
419
- foundTier,
420
- phaseForTier(foundTier),
421
- `Forced ${foundTier} tier because the originally routed ${decision.tier} tier does not support image attachments.`,
422
- state.thinkingByProfile[model.id],
423
- false,
551
+ baseline.pair,
552
+ baseline.reasonCode,
424
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';
425
565
  }
566
+ decision.routingLatencyMs = Math.max(
567
+ 0,
568
+ performance.now() - started,
569
+ );
570
+ if (performance.now() >= routingDeadline)
571
+ decision.errorClass = 'deadline';
426
572
  }
427
573
  }
428
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
+
429
609
  state.lastDecision = decision;
430
- actions.recordDebugDecision(decision);
431
610
 
432
611
  // Sync pi's thinking level display with the router's effective thinking.
433
612
  // Wrapped in try/catch: in subagent contexts the extension runtime
@@ -444,20 +623,11 @@ export const registerRouterProvider = (
444
623
  // Stale extension context — skip non-critical UI updates.
445
624
  }
446
625
 
447
- let modelsToTry = [
448
- ...new Set([
449
- decision.targetLabel,
450
- ...(profile[decision.tier]?.fallbacks ?? []),
451
- ]),
452
- ];
453
- if (imageAttached) {
454
- modelsToTry = modelsToTry.filter(checkModelSupportsImage);
455
- if (modelsToTry.length === 0) {
456
- throw new Error(
457
- 'No configured model supports image attachments.',
458
- );
459
- }
460
- }
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);
461
631
  let lastError: unknown;
462
632
  let success = false;
463
633
 
@@ -491,26 +661,33 @@ export const registerRouterProvider = (
491
661
  effectiveContext = truncateContext(context, targetLimit);
492
662
  }
493
663
 
494
- const thinkingOverride = actions.getThinkingOverride(
495
- model.id,
496
- decision.tier,
664
+ const pair = available().find(
665
+ (candidate) =>
666
+ candidate.tier === decision.tier &&
667
+ candidate.model === modelRef,
497
668
  );
498
- let requestedReasoning = thinkingOverride ?? decision.thinking;
499
-
500
- if (requestedReasoning !== 'off' && targetModel.reasoning) {
501
- const tierConfig = profile[decision.tier];
502
- if (tierConfig?.resolvedThinkingLevels) {
503
- requestedReasoning = clampThinkingLevel(
504
- requestedReasoning,
505
- tierConfig.resolvedThinkingLevels,
506
- );
507
- }
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
+ );
508
688
  }
509
-
510
689
  const delegatedReasoning: SimpleStreamOptions['reasoning'] =
511
- targetModel.reasoning && requestedReasoning !== 'off'
512
- ? requestedReasoning
513
- : undefined;
690
+ pair.thinking !== 'off' ? pair.thinking : undefined;
514
691
 
515
692
  try {
516
693
  if (state.lastExtensionContext) {
@@ -554,7 +731,13 @@ export const registerRouterProvider = (
554
731
  decision.targetModelId = targetModelId;
555
732
  decision.targetLabel = modelRef;
556
733
  decision.thinking = delegatedReasoning ?? 'off';
557
- 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';
558
741
  };
559
742
  for await (const event of delegatedStream) {
560
743
  if (
@@ -585,10 +768,25 @@ export const registerRouterProvider = (
585
768
  }
586
769
  if (event.type === 'done' || event.type === 'error') {
587
770
  terminalReceived = true;
771
+ generationSucceeded = event.type === 'done';
588
772
  recordTarget();
589
773
  const cost = (
590
774
  event.type === 'done' ? event.message : event.error
591
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
+ }
592
790
  if (Number.isFinite(cost) && cost > 0)
593
791
  state.accumulatedCost += cost;
594
792
  }
@@ -624,6 +822,7 @@ export const registerRouterProvider = (
624
822
  );
625
823
  }
626
824
 
825
+ actions.recordDebugDecision(decision);
627
826
  stream.end();
628
827
  } catch (error) {
629
828
  const reason = options?.signal?.aborted ? 'aborted' : 'error';
@@ -639,6 +838,8 @@ export const registerRouterProvider = (
639
838
  });
640
839
  stream.end();
641
840
  } finally {
841
+ if (!generationSucceeded && activeTurn)
842
+ advisedTurns.delete(activeTurn);
642
843
  try {
643
844
  actions.persistState();
644
845
  } catch {