@alexeiled/pi-model-router 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.1 — 2026-09-21
4
+
5
+ - Add compact human-readable route provenance to the Pi footer, widget and `/router status`: `🧭 Jev ✓` means Jev selected the route, `🧭 Jev ↪ base` means Jev ran but the local baseline was used, and no marker means Jev was not involved.
6
+ - Track closed route-guidance outcomes across Jev, classifier, bypass and baseline paths, including reusable tool continuations and explicit generation fallbacks without persisting remote text or credentials.
7
+ - Keep the normal footer uncluttered for turns without external route guidance; retain latency only as a short widget/status diagnostic.
8
+ - Add regression coverage for UI rendering, persistence compatibility, advisor fallback, continuation inheritance and privacy boundaries.
9
+
3
10
  ## 0.6.0 — 2026-09-21
4
11
 
5
12
  - Add the optional `micro` tier with `off` thinking by default; all four tiers are configured model/effort choices, not security permissions. Existing three-tier and partial profiles remain supported.
package/README.md CHANGED
@@ -244,6 +244,13 @@ request text, raw response or remote explanations. Older saved explanations are
244
244
  discarded as non-rendered `legacy` metadata; Pi's own conversation transcript is
245
245
  separate from router state.
246
246
 
247
+ **Footer and widget:** The footer stays in its normal route-only form when Jev is
248
+ not involved. When Jev selects the route it adds `· 🧭 Jev ✓`; when Jev is used
249
+ but its advice is rejected, it adds `· 🧭 Jev ↪ base`. `base` means the local
250
+ deterministic baseline; the selected tier and model remain visible in the route
251
+ text. `/router widget on` and `/router status` show the same marker and a short
252
+ latency value. No marker means no external route guidance was used.
253
+
247
254
  For chezmoi, use a **private template**, for example
248
255
  `private_model-router.json.tmpl` under your agent-directory source path. Render
249
256
  only the `apiKey` value using a reference such as
@@ -268,11 +275,11 @@ keeps Jev disabled.
268
275
  | `/router thinking <tier> <level>` | Override thinking level for a specific tier (e.g. `/router thinking low off`). |
269
276
  | `/router disable` | Disable the router and switch back to the last non-router model. |
270
277
  | `/router widget <on\|off>` | Toggle the persistent state widget (supports `toggle`). |
271
- | `/router debug <on\|off>` | Toggle turn-by-turn routing notifications (supports `toggle`, `clear`, `show`). |
278
+ | `/router debug <on\|off>` | Toggle router debug state; use `show` or `clear` for local decision history. |
272
279
  | `/router reload` | Hot-reload the configuration JSON. |
273
280
  | `/router help` | Show usage help for all subcommands. |
274
281
 
275
282
  ## Documentation
276
283
 
277
284
  - [Architecture Guide](docs/ARCHITECTURE.md): Deep dive into the routing logic and modular design.
278
- - [Sample Configuration](model-router.example.json): Diverse profile examples (`cheap`, `deep`, `balanced`).
285
+ - [Sample Configuration](model-router.example.json): Profile examples (`auto`, `cheap`, `deep`, `anthropic`).
@@ -24,6 +24,7 @@ import type {
24
24
  RoutingDecision,
25
25
  } from './types';
26
26
  import {
27
+ formatAdvisorDetail,
27
28
  formatDecision,
28
29
  formatDecisionSource,
29
30
  formatModelRef,
@@ -183,6 +184,7 @@ export const registerCommands = (
183
184
  `Debug history: ${state.debugHistory.length} decisions`,
184
185
  ];
185
186
  if (state.lastDecision) {
187
+ const advisorDetail = formatAdvisorDetail(state.lastDecision);
186
188
  lines.push(
187
189
  `Last routed tier: ${state.lastDecision.tier}`,
188
190
  `Last phase: ${state.lastDecision.phase}`,
@@ -190,6 +192,7 @@ export const registerCommands = (
190
192
  ...(formatDecisionSource(state.lastDecision)
191
193
  ? [`Reason: ${formatDecisionSource(state.lastDecision)}`]
192
194
  : []),
195
+ ...(advisorDetail ? [advisorDetail] : []),
193
196
  );
194
197
  }
195
198
  if (state.lastConfigWarnings && state.lastConfigWarnings.length > 0) {
@@ -39,6 +39,7 @@ import {
39
39
  selectBaselineRoute,
40
40
  } from './routing';
41
41
  import type {
42
+ AdvisorOutcome,
42
43
  RouterConfig,
43
44
  RouterPinByProfile,
44
45
  RouterThinkingByProfile,
@@ -279,7 +280,7 @@ export const registerRouterProvider = (
279
280
  // Streams can complete out of order. Keep a small turn-keyed history rather
280
281
  // than letting the latest stream replace another stream's continuation.
281
282
  const continuations = new Map<string, ContinuationRecord>();
282
- const advisedTurns = new Map<string, true>();
283
+ const advisedTurns = new Map<string, AdvisorOutcome>();
283
284
  const rememberContinuation = (record: ContinuationRecord) => {
284
285
  continuations.delete(record.turn);
285
286
  continuations.set(record.turn, record);
@@ -289,9 +290,9 @@ export const registerRouterProvider = (
289
290
  continuations.delete(oldest);
290
291
  }
291
292
  };
292
- const rememberAdvisedTurn = (turn: string) => {
293
+ const rememberAdvisedTurn = (turn: string, outcome: AdvisorOutcome) => {
293
294
  advisedTurns.delete(turn);
294
- advisedTurns.set(turn, true);
295
+ advisedTurns.set(turn, outcome);
295
296
  while (advisedTurns.size > 16) {
296
297
  const oldest = advisedTurns.keys().next().value;
297
298
  if (oldest === undefined) break;
@@ -391,6 +392,14 @@ export const registerRouterProvider = (
391
392
  ]);
392
393
  const toolContinuation =
393
394
  context.messages.at(-1)?.role === 'toolResult';
395
+ const jev = state.currentConfig.jev;
396
+ const useJev = Boolean(
397
+ jev?.enabled &&
398
+ profile.jev?.enabled &&
399
+ jev.apiKey.trim().length > 0,
400
+ );
401
+ const advisorConfigured =
402
+ useJev || Boolean(state.currentConfig.classifierModel);
394
403
  const continuationRecord =
395
404
  toolContinuation && turn ? continuations.get(turn) : undefined;
396
405
  const continuationDecision = continuationRecord?.decision;
@@ -459,6 +468,11 @@ export const registerRouterProvider = (
459
468
  baseline.reasonCode,
460
469
  );
461
470
  decision.isBudgetForced = baseline.isBudgetForced;
471
+ decision.advisor = advisorConfigured ? 'bypassed' : 'none';
472
+ if (!toolContinuation && turn) {
473
+ const previousAdvisor = advisedTurns.get(turn);
474
+ if (previousAdvisor) decision.advisor = previousAdvisor;
475
+ }
462
476
  }
463
477
 
464
478
  // Tool results never invoke advisors, even when their prior route cannot be reused.
@@ -469,99 +483,110 @@ export const registerRouterProvider = (
469
483
  user &&
470
484
  turn &&
471
485
  !advisedTurns.has(turn) &&
472
- (state.currentConfig.classifierModel ||
473
- (state.currentConfig.jev?.enabled && profile.jev?.enabled))
486
+ advisorConfigured
474
487
  ) {
475
- rememberAdvisedTurn(turn);
476
488
  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
489
  const routingDeadline = started + (useJev ? 1500 : 10_000);
483
490
  const candidates = primaryRoutePairs(profile, pairs).map(
484
491
  createJevCandidate,
485
492
  );
486
493
  // 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';
534
- }
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,
494
+ if (candidates.length <= 1) {
495
+ decision.advisor = 'bypassed';
496
+ rememberAdvisedTurn(turn, 'bypassed');
497
+ } else if (useJev && jev) {
498
+ decision.advisor = 'jev';
499
+ rememberAdvisedTurn(turn, 'jev');
500
+ const advice = await runJev(
501
+ {
502
+ ...jev,
503
+ timeoutMs: Math.min(750, jev.timeoutMs),
504
+ },
505
+ {
506
+ taskSummary: getBoundedRecentContext(
507
+ context,
508
+ jev.maxStateChars,
509
+ ),
510
+ candidates,
511
+ profile: profile.jev,
544
512
  routingDeadline,
545
- ).catch(() => undefined);
546
- options?.signal?.throwIfAborted();
547
- pairs = available();
513
+ signal: options?.signal,
514
+ },
515
+ ).catch(() => undefined);
516
+ options?.signal?.throwIfAborted();
517
+ // Re-read registry capabilities after the network boundary.
518
+ pairs = available();
519
+ const candidate = candidates.find(
520
+ (entry) => entry.id === advice?.candidateId,
521
+ );
522
+ if (
523
+ candidate &&
524
+ performance.now() < routingDeadline &&
525
+ pairs.some(
526
+ (pair) =>
527
+ pair.model === candidate.model &&
528
+ pair.tier === candidate.tier &&
529
+ pair.thinking === candidate.thinking,
530
+ )
531
+ ) {
532
+ decision = {
533
+ ...decisionForPair(model.id, candidate, 'jev'),
534
+ advisor: 'jev',
535
+ };
536
+ } else {
548
537
  const baseline = selectBaselineRoute(model.id, profile, pairs);
549
538
  decision = decisionForPair(
550
539
  model.id,
551
540
  baseline.pair,
552
541
  baseline.reasonCode,
553
542
  );
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';
543
+ decision.advisor = 'jev-fallback';
544
+ rememberAdvisedTurn(turn, 'jev-fallback');
545
+ decision.errorClass = 'advisor-unavailable';
546
+ }
547
+ decision.routingLatencyMs = Math.max(
548
+ 0,
549
+ performance.now() - started,
550
+ );
551
+ if (performance.now() >= routingDeadline)
552
+ decision.errorClass = 'deadline';
553
+ } else if (state.currentConfig.classifierModel) {
554
+ const classifier = state.currentConfig.classifierModel;
555
+ const result = await runClassifier(
556
+ classifier.model,
557
+ registry,
558
+ context,
559
+ undefined,
560
+ classifier.thinking,
561
+ options?.signal,
562
+ routingDeadline,
563
+ ).catch(() => undefined);
564
+ options?.signal?.throwIfAborted();
565
+ pairs = available();
566
+ const baseline = selectBaselineRoute(model.id, profile, pairs);
567
+ decision = decisionForPair(
568
+ model.id,
569
+ baseline.pair,
570
+ baseline.reasonCode,
571
+ );
572
+ if (result && performance.now() < routingDeadline) {
573
+ const pair = pairs.find((entry) => entry.tier === result.tier);
574
+ if (pair) {
575
+ decision = {
576
+ ...decisionForPair(model.id, pair, 'classifier'),
577
+ isClassifier: true,
578
+ advisor: 'classifier',
579
+ };
580
+ rememberAdvisedTurn(turn, 'classifier');
581
+ } else {
582
+ decision.advisor = 'classifier-fallback';
583
+ rememberAdvisedTurn(turn, 'classifier-fallback');
584
+ decision.errorClass = 'advisor-unavailable';
585
+ }
586
+ } else {
587
+ decision.advisor = 'classifier-fallback';
588
+ rememberAdvisedTurn(turn, 'classifier-fallback');
589
+ decision.errorClass = 'advisor-unavailable';
565
590
  }
566
591
  decision.routingLatencyMs = Math.max(
567
592
  0,
@@ -603,7 +628,10 @@ export const registerRouterProvider = (
603
628
  throw new Error(
604
629
  'No compatible route for Google tool continuation.',
605
630
  );
606
- decision = decisionForPair(model.id, priorPair, 'continuation');
631
+ decision = {
632
+ ...decisionForPair(model.id, priorPair, 'continuation'),
633
+ advisor: decision.advisor,
634
+ };
607
635
  }
608
636
 
609
637
  state.lastDecision = decision;
@@ -14,7 +14,7 @@ import type {
14
14
  RouterPinByProfile,
15
15
  RoutingDecision,
16
16
  } from './types';
17
- import { isRoutingReasonCode } from './types';
17
+ import { isAdvisorOutcome, isRoutingReasonCode } from './types';
18
18
 
19
19
  const LAST_PROFILE_STATE_FILE = 'model-router-state.json';
20
20
 
@@ -57,6 +57,7 @@ const isDecision = (value: unknown): value is RoutingDecision =>
57
57
  (value.reasonCode === undefined
58
58
  ? typeof value.reasoning === 'string'
59
59
  : isPersistedReasonCode(value.reasonCode)) &&
60
+ (value.advisor === undefined || isAdvisorOutcome(value.advisor)) &&
60
61
  ['isClassifier', 'isFallback', 'isBudgetForced'].every(
61
62
  (key) => value[key] === undefined || typeof value[key] === 'boolean',
62
63
  );
@@ -164,6 +165,7 @@ export const snapshotDecision = (
164
165
  decision.errorClass === 'deadline'
165
166
  ? decision.errorClass
166
167
  : undefined,
168
+ advisor: isAdvisorOutcome(decision.advisor) ? decision.advisor : undefined,
167
169
  thinking: decision.thinking,
168
170
  timestamp: decision.timestamp,
169
171
  isClassifier: decision.isClassifier,
@@ -131,6 +131,17 @@ export const isRoutingReasonCode = (
131
131
  ): value is RoutingReasonCode =>
132
132
  ROUTING_REASON_CODES.some((code) => code === value);
133
133
  export type RoutingErrorClass = 'advisor-unavailable' | 'deadline';
134
+ export const ADVISOR_OUTCOMES = [
135
+ 'none',
136
+ 'bypassed',
137
+ 'jev',
138
+ 'jev-fallback',
139
+ 'classifier',
140
+ 'classifier-fallback',
141
+ ] as const;
142
+ export type AdvisorOutcome = (typeof ADVISOR_OUTCOMES)[number];
143
+ export const isAdvisorOutcome = (value: unknown): value is AdvisorOutcome =>
144
+ ADVISOR_OUTCOMES.some((outcome) => outcome === value);
134
145
 
135
146
  export interface RoutingDecision {
136
147
  profile: string;
@@ -142,6 +153,7 @@ export interface RoutingDecision {
142
153
  reasonCode: RoutingReasonCode;
143
154
  routingLatencyMs?: number | undefined;
144
155
  errorClass?: RoutingErrorClass | undefined;
156
+ advisor?: AdvisorOutcome | undefined;
145
157
  thinking: ThinkingLevel;
146
158
  timestamp: number;
147
159
  isClassifier?: boolean | undefined;
package/extensions/ui.ts CHANGED
@@ -5,7 +5,7 @@ import type {
5
5
  RouterThinkingByProfile,
6
6
  RoutingDecision,
7
7
  } from './types';
8
- import { isRoutingReasonCode } from './types';
8
+ import { isAdvisorOutcome, isRoutingReasonCode } from './types';
9
9
 
10
10
  const getDecisionFlags = (decision: RoutingDecision): string[] => {
11
11
  const flags: string[] = [];
@@ -19,9 +19,53 @@ export const formatDecisionSource = (decision: RoutingDecision): string =>
19
19
  ? decision.reasonCode
20
20
  : '';
21
21
 
22
+ export const formatAdvisorLabel = (
23
+ decision: RoutingDecision,
24
+ ): string | undefined => {
25
+ if (!isAdvisorOutcome(decision.advisor)) return undefined;
26
+ switch (decision.advisor) {
27
+ case 'none':
28
+ case 'bypassed':
29
+ return undefined;
30
+ case 'jev':
31
+ return '🧭 Jev ✓';
32
+ case 'jev-fallback':
33
+ return '🧭 Jev ↪ base';
34
+ case 'classifier':
35
+ return '🧠 Classifier ✓';
36
+ case 'classifier-fallback':
37
+ return '🧠 Classifier ↪ base';
38
+ default:
39
+ return undefined;
40
+ }
41
+ };
42
+
43
+ export const formatAdvisorDetail = (
44
+ decision: RoutingDecision,
45
+ ): string | undefined => {
46
+ const label = formatAdvisorLabel(decision);
47
+ if (!label) return undefined;
48
+ const latency = Number.isFinite(decision.routingLatencyMs)
49
+ ? ` · ${Math.round(decision.routingLatencyMs ?? 0)}ms`
50
+ : '';
51
+ return `${label}${latency}`;
52
+ };
53
+
54
+ export const formatAdvisorFooter = (decision: RoutingDecision): string => {
55
+ if (
56
+ !decision.advisor ||
57
+ decision.advisor === 'none' ||
58
+ decision.advisor === 'bypassed'
59
+ )
60
+ return '';
61
+ const label = formatAdvisorLabel(decision);
62
+ return label ? ` · ${label}` : '';
63
+ };
64
+
22
65
  export const formatDecision = (decision: RoutingDecision): string => {
23
66
  const source = formatDecisionSource(decision);
24
- return `${decision.profile}: ${decision.tier} -> ${decision.targetProvider}/${decision.targetModelId} [${decision.thinking}]${source ? ` (${source})` : ''}`;
67
+ const advisor = formatAdvisorLabel(decision);
68
+ return `${decision.profile}: ${decision.tier} -> ${decision.targetProvider}/${decision.targetModelId} [${decision.thinking}]${source ? ` (${source})` : ''}${advisor ? ` [${advisor}]` : ''}`;
25
69
  };
26
70
 
27
71
  export const formatPinSummary = (
@@ -79,7 +123,7 @@ export const updateStatus = (
79
123
 
80
124
  let statusText: string;
81
125
  if (lastDecision && matchesProfile && matchesPin) {
82
- statusText = `router:${activeRouterProfile}${pinLabel} -> ${lastDecision.tier} -> ${lastDecision.targetProvider}/${lastDecision.targetModelId} (${lastDecision.thinking})`;
126
+ statusText = `router:${activeRouterProfile}${pinLabel} -> ${lastDecision.tier} -> ${lastDecision.targetProvider}/${lastDecision.targetModelId} (${lastDecision.thinking})${formatAdvisorFooter(lastDecision)}`;
83
127
  } else {
84
128
  statusText = `router:${activeRouterProfile}${pinLabel} -> waiting`;
85
129
  }
@@ -103,18 +147,13 @@ export const updateStatus = (
103
147
  if (lastDecision && lastDecision.profile === statusProfile) {
104
148
  const flags = getDecisionFlags(lastDecision);
105
149
  const flagsStr = flags.length > 0 ? ` [${flags.join(',')}]` : '';
150
+ const advisorDetail = formatAdvisorDetail(lastDecision);
106
151
 
107
152
  widgetLines.push(
108
153
  `Route: ${lastDecision.tier}${flagsStr} -> ${lastDecision.targetProvider}/${lastDecision.targetModelId} (${lastDecision.thinking})`,
109
154
  `Phase: ${lastDecision.phase}`,
110
155
  `Source: ${formatDecisionSource(lastDecision) || 'unknown'}`,
111
- ...(Number.isFinite(lastDecision.routingLatencyMs)
112
- ? [`Routing: ${Math.round(lastDecision.routingLatencyMs ?? 0)}ms`]
113
- : []),
114
- ...(lastDecision.errorClass === 'deadline' ||
115
- lastDecision.errorClass === 'advisor-unavailable'
116
- ? [`Routing error: ${lastDecision.errorClass}`]
117
- : []),
156
+ ...(advisorDetail ? [advisorDetail] : []),
118
157
  );
119
158
  } else if (!routerEnabled && lastNonRouterModel) {
120
159
  widgetLines.push(`Fallback: ${lastNonRouterModel}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexeiled/pi-model-router",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "extensions",