@alexeiled/pi-model-router 0.5.2 → 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.
@@ -7,6 +7,7 @@ import { DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS } from './constants';
7
7
  import type {
8
8
  ClassifierConfig,
9
9
  ConfigLoadResult,
10
+ JevConfig,
10
11
  ModelDefinition,
11
12
  ParsedConfigFile,
12
13
  RawRouterConfig,
@@ -14,10 +15,11 @@ import type {
14
15
  RouterConfig,
15
16
  RouterProfile,
16
17
  RouterTier,
17
- RoutingRule,
18
18
  } from './types';
19
19
 
20
- export const ROUTER_TIERS = ['high', 'medium', 'low'] as const;
20
+ import { ROUTER_TIERS } from './types';
21
+
22
+ export { ROUTER_TIERS } from './types';
21
23
 
22
24
  // Pi accepts this model capability at runtime, but older peer type releases omit it.
23
25
  export const MAX_THINKING_LEVEL: ThinkingLevel = 'max';
@@ -31,7 +33,7 @@ export const THINKING_LEVELS: readonly ThinkingLevel[] = [
31
33
  'xhigh',
32
34
  MAX_THINKING_LEVEL,
33
35
  ];
34
- export const ROUTER_PIN_VALUES = ['auto', 'high', 'medium', 'low'] as const;
36
+ export const ROUTER_PIN_VALUES = ['auto', ...ROUTER_TIERS] as const;
35
37
  export type RouterPinValue = (typeof ROUTER_PIN_VALUES)[number];
36
38
  export const isRouterPinValue = (value: unknown): value is RouterPinValue =>
37
39
  ROUTER_PIN_VALUES.some((candidate) => candidate === value);
@@ -51,7 +53,7 @@ export const isThinkingLevel = (value: unknown): value is ThinkingLevel =>
51
53
  typeof value === 'string' && THINKING_LEVELS.some((level) => level === value);
52
54
 
53
55
  export const isRouterTier = (value: unknown): value is RouterTier =>
54
- value === 'high' || value === 'medium' || value === 'low';
56
+ ROUTER_TIERS.some((tier) => tier === value);
55
57
 
56
58
  export const parseConfigFile = (path: string): ParsedConfigFile => {
57
59
  if (!existsSync(path)) {
@@ -67,12 +69,11 @@ export const parseConfigFile = (path: string): ParsedConfigFile => {
67
69
  };
68
70
  }
69
71
  return { config: parsed, warnings: [] };
70
- } catch (error) {
72
+ } catch {
73
+ // JSON parse errors can include source snippets containing credentials.
71
74
  return {
72
75
  config: {},
73
- warnings: [
74
- `Failed to parse router config at ${path}: ${error instanceof Error ? error.message : String(error)}`,
75
- ],
76
+ warnings: [`Failed to parse router config at ${path}.`],
76
77
  };
77
78
  }
78
79
  };
@@ -121,9 +122,12 @@ export const mergeConfig = (
121
122
  ? mergedProfiles[name]
122
123
  : {};
123
124
  mergedProfiles[name] = {
125
+ baselineTier: mergeRawValue(existing.baselineTier, profile.baselineTier),
124
126
  high: mergeRawValue(existing.high, profile.high),
125
127
  medium: mergeRawValue(existing.medium, profile.medium),
126
128
  low: mergeRawValue(existing.low, profile.low),
129
+ micro: mergeRawValue(existing.micro, profile.micro),
130
+ jev: mergeRawValue(existing.jev, profile.jev),
127
131
  };
128
132
  }
129
133
 
@@ -132,6 +136,7 @@ export const mergeConfig = (
132
136
  const mergedModels = { ...baseModels, ...overrideModels };
133
137
 
134
138
  return {
139
+ jev: mergeRawValue(base.jev, override.jev),
135
140
  debug: override.debug ?? base.debug,
136
141
  classifierModel: override.classifierModel ?? base.classifierModel,
137
142
  phaseBias: override.phaseBias ?? base.phaseBias,
@@ -147,16 +152,12 @@ export const parseCanonicalModelRef = (
147
152
  ): { provider: string; modelId: string } => {
148
153
  const slashIndex = value.indexOf('/');
149
154
  if (slashIndex === -1) {
150
- throw new Error(
151
- `Invalid model reference "${value}". Expected "provider/model".`,
152
- );
155
+ throw new Error('Invalid model reference. Expected "provider/model".');
153
156
  }
154
157
  const provider = value.slice(0, slashIndex).trim();
155
158
  const modelId = value.slice(slashIndex + 1).trim();
156
159
  if (!provider || !modelId) {
157
- throw new Error(
158
- `Invalid model reference "${value}". Expected "provider/model".`,
159
- );
160
+ throw new Error('Invalid model reference. Expected "provider/model".');
160
161
  }
161
162
  return { provider, modelId };
162
163
  };
@@ -180,7 +181,7 @@ export const normalizeModelsMap = (
180
181
  continue;
181
182
  }
182
183
 
183
- const model = typeof entry.model === 'string' ? entry.model.trim() : '';
184
+ let model = typeof entry.model === 'string' ? entry.model.trim() : '';
184
185
  if (!model) {
185
186
  warnings.push(
186
187
  `Model definition "${alias}" is missing the "model" field. Skipped.`,
@@ -189,10 +190,11 @@ export const normalizeModelsMap = (
189
190
  }
190
191
 
191
192
  try {
192
- parseCanonicalModelRef(model);
193
- } catch (error) {
193
+ const { provider, modelId } = parseCanonicalModelRef(model);
194
+ model = `${provider}/${modelId}`;
195
+ } catch {
194
196
  warnings.push(
195
- `Model definition "${alias}": ${error instanceof Error ? error.message : String(error)}`,
197
+ `Model definition "${alias}" has an invalid model reference. Skipped.`,
196
198
  );
197
199
  continue;
198
200
  }
@@ -265,23 +267,31 @@ export const normalizeTierConfig = (
265
267
  const aliasDefinition = resolved.definition;
266
268
  let parsedModel: string;
267
269
  try {
268
- parseCanonicalModelRef(resolved.canonicalRef);
269
- parsedModel = resolved.canonicalRef;
270
- } catch (error) {
270
+ const { provider, modelId } = parseCanonicalModelRef(resolved.canonicalRef);
271
+ parsedModel = `${provider}/${modelId}`;
272
+ } catch {
271
273
  warnings.push(
272
- `Profile "${profileName}" ${tier} tier: ${error instanceof Error ? error.message : String(error)} Tier disabled.`,
274
+ `Profile "${profileName}" ${tier} tier has an invalid model reference. Tier disabled.`,
273
275
  );
274
276
  return undefined;
275
277
  }
276
278
 
277
- const thinking = isThinkingLevel(value.thinking) ? value.thinking : 'medium';
279
+ const tierReasoning =
280
+ typeof value.reasoning === 'boolean' ? value.reasoning : undefined;
281
+ const effectiveReasoning = tierReasoning ?? aliasDefinition?.reasoning;
282
+ const defaultThinking =
283
+ tier === 'micro' || effectiveReasoning === false ? 'off' : 'medium';
284
+ const thinking = isThinkingLevel(value.thinking)
285
+ ? value.thinking
286
+ : defaultThinking;
278
287
  if (value.thinking !== undefined && !isThinkingLevel(value.thinking)) {
279
288
  warnings.push(
280
- `Profile "${profileName}" ${tier} tier has invalid thinking level. Defaulting to medium.`,
289
+ `Profile "${profileName}" ${tier} tier has invalid thinking level. Defaulting to ${defaultThinking}.`,
281
290
  );
282
291
  }
283
292
 
284
293
  let fallbacks: string[] | undefined;
294
+ const resolvedFallbacks: ModelDefinition[] = [];
285
295
  if (Array.isArray(value.fallbacks)) {
286
296
  fallbacks = [];
287
297
  for (const f of value.fallbacks) {
@@ -289,11 +299,15 @@ export const normalizeTierConfig = (
289
299
  // Resolve aliases in fallbacks too
290
300
  const resolvedFallback = resolveModelRef(f, models);
291
301
  try {
292
- parseCanonicalModelRef(resolvedFallback.canonicalRef);
293
- fallbacks.push(resolvedFallback.canonicalRef);
294
- } catch (error) {
302
+ const { provider, modelId } = parseCanonicalModelRef(
303
+ resolvedFallback.canonicalRef,
304
+ );
305
+ const model = `${provider}/${modelId}`;
306
+ fallbacks.push(model);
307
+ resolvedFallbacks.push({ ...resolvedFallback.definition, model });
308
+ } catch {
295
309
  warnings.push(
296
- `Invalid fallback model "${f}" in profile "${profileName}" ${tier} tier: ${error instanceof Error ? error.message : String(error)}`,
310
+ `Invalid fallback model in profile "${profileName}" ${tier} tier. Ignored.`,
297
311
  );
298
312
  }
299
313
  }
@@ -318,11 +332,6 @@ export const normalizeTierConfig = (
318
332
  const resolvedMaxTokens =
319
333
  tierMaxTokens ?? aliasDefinition?.maxTokens ?? DEFAULT_MAX_TOKENS;
320
334
 
321
- // Resolve reasoning: tier config > alias > undefined (assumed true)
322
- const tierReasoning =
323
- typeof value.reasoning === 'boolean' ? value.reasoning : undefined;
324
- const effectiveReasoning = tierReasoning ?? aliasDefinition?.reasoning;
325
-
326
335
  // Resolve thinkingLevels: tier config > alias > default
327
336
  // Validate tier-level thinkingLevels array
328
337
  let tierThinkingLevels: ThinkingLevel[] | undefined;
@@ -353,11 +362,13 @@ export const normalizeTierConfig = (
353
362
 
354
363
  return {
355
364
  model: parsedModel,
365
+ thinkingExplicit: isThinkingLevel(value.thinking),
356
366
  thinking,
357
367
  fallbacks,
368
+ resolvedFallbacks,
358
369
  contextWindow: tierContextWindow,
359
370
  maxTokens: tierMaxTokens,
360
- reasoning: tierReasoning,
371
+ reasoning: effectiveReasoning,
361
372
  thinkingLevels: tierThinkingLevels,
362
373
  resolvedContextWindow,
363
374
  resolvedMaxTokens,
@@ -365,6 +376,108 @@ export const normalizeTierConfig = (
365
376
  };
366
377
  };
367
378
 
379
+ export const DEFAULT_JEV_CONFIG = {
380
+ endpoint: 'https://api.typesafe.ai/v1/systemone',
381
+ model: 'jev-1.13.0',
382
+ timeoutMs: 750,
383
+ confidenceThreshold: 0.65,
384
+ maxStateChars: 12000,
385
+ mode: 'advisory',
386
+ } as const;
387
+
388
+ export const isJevEndpoint = (value: unknown): value is string => {
389
+ if (typeof value !== 'string') return false;
390
+ try {
391
+ const url = new URL(value);
392
+ return (
393
+ url.protocol === 'https:' &&
394
+ !url.username &&
395
+ !url.password &&
396
+ !url.search &&
397
+ !url.hash
398
+ );
399
+ } catch {
400
+ return false;
401
+ }
402
+ };
403
+
404
+ export const normalizeJevConfig = (
405
+ raw: unknown,
406
+ warnings: string[],
407
+ ): JevConfig | undefined => {
408
+ if (raw === undefined) return undefined;
409
+ const invalid = (): undefined => {
410
+ warnings.push('Ignored invalid Jev configuration.');
411
+ return undefined;
412
+ };
413
+ if (!isObjectRecord(raw)) return invalid();
414
+ const value: Record<string, unknown> = { ...DEFAULT_JEV_CONFIG, ...raw };
415
+ if (
416
+ (value.enabled !== undefined && typeof value.enabled !== 'boolean') ||
417
+ !isJevEndpoint(value.endpoint) ||
418
+ typeof value.model !== 'string' ||
419
+ !/^[a-zA-Z0-9._-]{1,128}$/.test(value.model) ||
420
+ typeof value.timeoutMs !== 'number' ||
421
+ !Number.isFinite(value.timeoutMs) ||
422
+ value.timeoutMs <= 0 ||
423
+ value.timeoutMs > 1500 ||
424
+ typeof value.confidenceThreshold !== 'number' ||
425
+ !Number.isFinite(value.confidenceThreshold) ||
426
+ value.confidenceThreshold < 0 ||
427
+ value.confidenceThreshold > 1 ||
428
+ typeof value.maxStateChars !== 'number' ||
429
+ !Number.isInteger(value.maxStateChars) ||
430
+ value.maxStateChars < 1 ||
431
+ value.maxStateChars > 12000 ||
432
+ value.mode !== 'advisory' ||
433
+ (value.apiKey !== undefined &&
434
+ (typeof value.apiKey !== 'string' || /[\r\n]/.test(value.apiKey)))
435
+ )
436
+ return invalid();
437
+ if (value.timeoutMs > 750)
438
+ warnings.push(
439
+ 'Jev timeoutMs clamped to the effective 750 ms provider cap.',
440
+ );
441
+ const apiKey = typeof value.apiKey === 'string' ? value.apiKey.trim() : '';
442
+ if (value.enabled === true && !apiKey) {
443
+ warnings.push('Jev disabled: missing user-config API key.');
444
+ }
445
+ return {
446
+ enabled: value.enabled === true && apiKey.length > 0,
447
+ apiKey,
448
+ endpoint: value.endpoint,
449
+ model: value.model,
450
+ timeoutMs: Math.min(value.timeoutMs, 750),
451
+ confidenceThreshold: value.confidenceThreshold,
452
+ maxStateChars: value.maxStateChars,
453
+ mode: 'advisory',
454
+ };
455
+ };
456
+
457
+ // Remove every project Jev setting before merging with user-owned credentials.
458
+ export const stripProjectJevConfig = (
459
+ raw: RawRouterConfig,
460
+ warnings: string[],
461
+ ): RawRouterConfig => {
462
+ const { jev: ignored, ...project } = raw;
463
+ let found = ignored !== undefined;
464
+ if (isObjectRecord(project.profiles)) {
465
+ project.profiles = Object.fromEntries(
466
+ Object.entries(project.profiles).map(([name, profile]) => {
467
+ if (!isObjectRecord(profile)) return [name, profile];
468
+ const { jev, ...tiers } = profile;
469
+ if (jev !== undefined) found = true;
470
+ return [name, tiers];
471
+ }),
472
+ );
473
+ }
474
+ if (found)
475
+ warnings.push(
476
+ 'Ignored project Jev settings: configure Jev only in user config.',
477
+ );
478
+ return project;
479
+ };
480
+
368
481
  export const normalizeConfig = (raw: RawRouterConfig): ConfigLoadResult => {
369
482
  const warnings: string[] = [];
370
483
 
@@ -401,53 +514,57 @@ export const normalizeConfig = (raw: RawRouterConfig): ConfigLoadResult => {
401
514
  hasModels ? normalizedModels : undefined,
402
515
  );
403
516
 
404
- if (!high && !medium && !low) {
517
+ const micro = normalizeTierConfig(
518
+ profileRecord.micro,
519
+ name,
520
+ 'micro',
521
+ warnings,
522
+ hasModels ? normalizedModels : undefined,
523
+ );
524
+
525
+ if (!high && !medium && !low && !micro) {
405
526
  warnings.push(`Profile "${name}" has no valid tiers. Skipped.`);
406
527
  continue;
407
528
  }
408
529
 
409
- normalizedProfiles[name] = { high, medium, low };
530
+ let baselineTier: RouterTier | undefined;
531
+ if (profileRecord.baselineTier !== undefined) {
532
+ const candidate = profileRecord.baselineTier;
533
+ const normalizedTier = isRouterTier(candidate)
534
+ ? { high, medium, low, micro }[candidate]
535
+ : undefined;
536
+ if (isRouterTier(candidate) && normalizedTier) {
537
+ baselineTier = candidate;
538
+ } else {
539
+ warnings.push(
540
+ `Profile "${name}" baselineTier must name a configured tier. Ignored.`,
541
+ );
542
+ }
543
+ }
544
+
545
+ const jev = isObjectRecord(profileRecord.jev)
546
+ ? { enabled: profileRecord.jev.enabled === true }
547
+ : undefined;
548
+ normalizedProfiles[name] = {
549
+ ...(baselineTier ? { baselineTier } : {}),
550
+ high,
551
+ medium,
552
+ low,
553
+ micro,
554
+ jev,
555
+ };
410
556
  }
411
557
 
412
- const phaseBias =
413
- typeof raw.phaseBias === 'number'
414
- ? Math.max(0, Math.min(1, raw.phaseBias))
415
- : 0.5;
558
+ if (raw.phaseBias !== undefined)
559
+ warnings.push('Deprecated router config field "phaseBias" ignored.');
560
+ if (raw.rules !== undefined)
561
+ warnings.push('Deprecated router config field "rules" ignored.');
416
562
 
417
563
  const maxSessionBudget =
418
564
  typeof raw.maxSessionBudget === 'number' && raw.maxSessionBudget > 0
419
565
  ? raw.maxSessionBudget
420
566
  : undefined;
421
567
 
422
- const rules: RoutingRule[] = [];
423
- if (Array.isArray(raw.rules)) {
424
- for (const rule of raw.rules) {
425
- if (isObjectRecord(rule)) {
426
- const matches = rule.matches;
427
- const tier = rule.tier;
428
- if (
429
- ((typeof matches === 'string' && matches.trim().length > 0) ||
430
- (Array.isArray(matches) &&
431
- matches.length > 0 &&
432
- matches.every(
433
- (m) => typeof m === 'string' && m.trim().length > 0,
434
- ))) &&
435
- isRouterTier(tier)
436
- ) {
437
- rules.push({
438
- matches,
439
- tier,
440
- reason: typeof rule.reason === 'string' ? rule.reason : undefined,
441
- });
442
- } else {
443
- warnings.push(
444
- `Ignored invalid routing rule: ${JSON.stringify(rule)}`,
445
- );
446
- }
447
- }
448
- }
449
- }
450
-
451
568
  // Resolve classifierModel — accepts string or { model, thinking } object
452
569
  let classifierModel: ClassifierConfig | undefined;
453
570
  const rawClassifier = raw.classifierModel;
@@ -459,10 +576,8 @@ export const normalizeConfig = (raw: RawRouterConfig): ConfigLoadResult => {
459
576
  try {
460
577
  parseCanonicalModelRef(resolved.canonicalRef);
461
578
  classifierModel = { model: resolved.canonicalRef };
462
- } catch (error) {
463
- warnings.push(
464
- `Invalid classifierModel: ${error instanceof Error ? error.message : String(error)}`,
465
- );
579
+ } catch {
580
+ warnings.push('Invalid classifierModel model reference. Ignored.');
466
581
  }
467
582
  } else if (isObjectRecord(rawClassifier)) {
468
583
  const modelRef =
@@ -479,14 +594,12 @@ export const normalizeConfig = (raw: RawRouterConfig): ConfigLoadResult => {
479
594
  : undefined;
480
595
  if (rawClassifier.thinking !== undefined && !thinking) {
481
596
  warnings.push(
482
- `classifierModel has invalid thinking level "${String(rawClassifier.thinking)}". Ignored.`,
597
+ 'classifierModel has an invalid thinking level. Ignored.',
483
598
  );
484
599
  }
485
600
  classifierModel = { model: resolved.canonicalRef, thinking };
486
- } catch (error) {
487
- warnings.push(
488
- `Invalid classifierModel: ${error instanceof Error ? error.message : String(error)}`,
489
- );
601
+ } catch {
602
+ warnings.push('Invalid classifierModel model reference. Ignored.');
490
603
  }
491
604
  } else {
492
605
  warnings.push(
@@ -497,11 +610,10 @@ export const normalizeConfig = (raw: RawRouterConfig): ConfigLoadResult => {
497
610
 
498
611
  return {
499
612
  config: {
613
+ jev: normalizeJevConfig(raw.jev, warnings),
500
614
  debug: typeof raw.debug === 'boolean' ? raw.debug : false,
501
615
  classifierModel,
502
- phaseBias,
503
616
  maxSessionBudget,
504
- rules: rules.length > 0 ? rules : undefined,
505
617
  profiles: normalizedProfiles,
506
618
  models: hasModels ? normalizedModels : undefined,
507
619
  },
@@ -517,7 +629,7 @@ export const loadRouterConfig = (cwd: string): ConfigLoadResult => {
517
629
  const baseConfig: RawRouterConfig = { profiles: {} };
518
630
  const merged = mergeConfig(
519
631
  mergeConfig(baseConfig, globalResult.config),
520
- projectResult.config,
632
+ stripProjectJevConfig(projectResult.config, projectResult.warnings),
521
633
  );
522
634
  const normalized = normalizeConfig(merged);
523
635
  return {
@@ -638,24 +750,3 @@ export const getUnsupportedTiers = (
638
750
  }
639
751
  return unsupported;
640
752
  };
641
-
642
- /**
643
- * Clamps a requested thinking level to the highest supported level
644
- * in the provided array of supported levels.
645
- */
646
- export const clampThinkingLevel = (
647
- requested: ThinkingLevel,
648
- supported: ThinkingLevel[] | undefined,
649
- ): ThinkingLevel => {
650
- if (requested === 'off' || !supported || supported.length === 0) {
651
- return 'off';
652
- }
653
-
654
- const reqIdx = THINKING_LEVELS.indexOf(requested);
655
- for (let i = reqIdx; i >= 0; i--) {
656
- const level = THINKING_LEVELS[i];
657
- if (level && supported.includes(level)) return level;
658
- }
659
-
660
- return 'off';
661
- };
@@ -17,35 +17,71 @@ export const extractTextFromContent = (
17
17
  .join('\n');
18
18
  };
19
19
 
20
- export const getLastUserText = (context: Context): string => {
21
- for (let i = context.messages.length - 1; i >= 0; i -= 1) {
22
- const message = context.messages[i];
23
- if (message?.role === 'user') {
24
- return extractTextFromContent(message.content).trim();
20
+ /** Text blocks only: never include system/config, thinking, tool arguments or binary data. */
21
+ export const getBoundedRecentContext = (
22
+ context: Context,
23
+ maxChars: number,
24
+ ): string => {
25
+ if (!Number.isFinite(maxChars) || maxChars < 1) return '';
26
+ const budget = Math.floor(maxChars);
27
+ const latestUser = context.messages.findLastIndex(
28
+ (message) => message.role === 'user',
29
+ );
30
+ const selected = new Map<number, string>();
31
+ const render = (message: Message, limit: number): string => {
32
+ const label = `${message.role === 'toolResult' ? 'tool' : message.role}:\n`;
33
+ // For tiny budgets prioritize request text over a partial role label.
34
+ const prefix = limit > label.length ? label : '';
35
+ let text = '';
36
+ const remaining = limit - prefix.length;
37
+ if (typeof message.content === 'string') {
38
+ text = message.content.slice(0, remaining);
39
+ } else {
40
+ for (const part of message.content) {
41
+ if (part.type !== 'text' || !part.text) continue;
42
+ text +=
43
+ `${text ? '\n' : ''}${part.text.slice(0, remaining - text.length)}`.slice(
44
+ 0,
45
+ remaining - text.length,
46
+ );
47
+ if (text.length >= remaining) break;
48
+ }
25
49
  }
50
+ return text ? prefix + text : '';
51
+ };
52
+ const request = context.messages[latestUser];
53
+ const latest = request ? render(request, budget) : '';
54
+ if (latest) selected.set(latestUser, latest);
55
+ let remaining = budget - latest.length;
56
+ const recent = context.messages
57
+ .map((message, index) => ({ message, index }))
58
+ .filter(
59
+ ({ message, index }) =>
60
+ index !== latestUser &&
61
+ (message.role === 'user' ||
62
+ message.role === 'assistant' ||
63
+ message.role === 'toolResult'),
64
+ )
65
+ .slice(-5)
66
+ .reverse();
67
+ for (const [position, { message, index }] of recent.entries()) {
68
+ const separator = selected.size ? 2 : 0;
69
+ // Share the remainder so one oversized tool result cannot erase all history.
70
+ const allowance = Math.floor(
71
+ (remaining - separator) / (recent.length - position),
72
+ );
73
+ if (allowance <= 0) break;
74
+ const text = render(message, allowance);
75
+ if (!text) continue;
76
+ selected.set(index, text);
77
+ remaining -= text.length + separator;
26
78
  }
27
- return '';
79
+ return [...selected.entries()]
80
+ .sort(([left], [right]) => left - right)
81
+ .map(([, text]) => text)
82
+ .join('\n\n');
28
83
  };
29
84
 
30
- export const getRecentConversationText = (
31
- context: Context,
32
- limit = 6,
33
- ): string =>
34
- context.messages
35
- .slice(-limit)
36
- .map((message) =>
37
- message ? extractTextFromContent(message.content).trim() : '',
38
- )
39
- .filter(Boolean)
40
- .join('\n')
41
- .toLowerCase();
42
-
43
- export const countToolResults = (context: Context): number =>
44
- context.messages.filter((message) => message?.role === 'toolResult').length;
45
-
46
- export const countWords = (text: string): number =>
47
- text.split(/\s+/).filter(Boolean).length;
48
-
49
85
  export const hasImageAttachment = (context: Context): boolean =>
50
86
  context.messages.some(
51
87
  (message) =>
@@ -53,6 +89,3 @@ export const hasImageAttachment = (context: Context): boolean =>
53
89
  Array.isArray(message.content) &&
54
90
  message.content.some((part) => part.type === 'image'),
55
91
  );
56
-
57
- export const containsAny = (text: string, keywords: string[]): boolean =>
58
- keywords.some((keyword) => text.includes(keyword));
@@ -14,11 +14,13 @@ import {
14
14
  } from './config';
15
15
  import { MAX_DEBUG_HISTORY } from './constants';
16
16
  import { registerRouterProvider } from './provider';
17
+ import { preservesRouteCoverage } from './routing';
17
18
  import {
18
19
  buildPersistedState,
19
20
  isRouterPersistedState,
20
21
  loadLastRouterProfile,
21
22
  saveLastRouterProfile,
23
+ snapshotDecision,
22
24
  } from './state';
23
25
  import type {
24
26
  RouterConfig,
@@ -151,7 +153,9 @@ const routerExtension = (pi: ExtensionAPI) => {
151
153
  };
152
154
 
153
155
  const recordDebugDecision = (decision: RoutingDecision) => {
154
- debugHistory = [...debugHistory, decision].slice(-MAX_DEBUG_HISTORY);
156
+ debugHistory = [...debugHistory, snapshotDecision(decision)].slice(
157
+ -MAX_DEBUG_HISTORY,
158
+ );
155
159
  };
156
160
 
157
161
  const getThinkingOverride = (profileName: string, tier: RouterTier) => {
@@ -208,7 +212,7 @@ const routerExtension = (pi: ExtensionAPI) => {
208
212
  lastNonRouterModel,
209
213
  accumulatedCost,
210
214
  widgetEnabled,
211
- currentConfig,
215
+ maxSessionBudget: currentConfig.maxSessionBudget,
212
216
  }),
213
217
  reloadConfig: (
214
218
  ctx?: ExtensionContext,
@@ -369,13 +373,15 @@ const routerExtension = (pi: ExtensionAPI) => {
369
373
  debugEnabled = savedState.debugEnabled ?? debugEnabled;
370
374
  widgetEnabled = savedState.widgetEnabled ?? widgetEnabled;
371
375
  debugHistory = savedState.debugHistory
372
- ? structuredClone(savedState.debugHistory).slice(-MAX_DEBUG_HISTORY)
376
+ ? savedState.debugHistory
377
+ .map(snapshotDecision)
378
+ .slice(-MAX_DEBUG_HISTORY)
373
379
  : [];
374
380
  if (!hasExplicitStartupModel) {
375
381
  lastNonRouterModel =
376
382
  savedState.lastNonRouterModel ?? lastNonRouterModel;
377
383
  lastDecision = savedState.lastDecision
378
- ? structuredClone(savedState.lastDecision)
384
+ ? snapshotDecision(savedState.lastDecision)
379
385
  : undefined;
380
386
  }
381
387
  accumulatedCost = savedState.accumulatedCost ?? 0;
@@ -516,14 +522,27 @@ const routerExtension = (pi: ExtensionAPI) => {
516
522
 
517
523
  // User changed pi's thinking level (e.g. via shift+tab).
518
524
  // Apply as an all-tier thinking override for the active router profile.
519
- let overrides = thinkingByProfile[selectedProfile];
520
- if (!overrides) {
521
- overrides = {};
522
- thinkingByProfile[selectedProfile] = overrides;
523
- }
525
+ const overrides = { ...thinkingByProfile[selectedProfile] };
524
526
  for (const t of ROUTER_TIERS) {
525
527
  overrides[t] = event.level;
526
528
  }
529
+ const activeProfile = currentConfig.profiles[selectedProfile];
530
+ if (!activeProfile) return;
531
+ if (
532
+ preservesRouteCoverage(
533
+ activeProfile,
534
+ (provider, id) => ctx.modelRegistry.find(provider, id),
535
+ overrides,
536
+ ) === false
537
+ ) {
538
+ actions.syncPiThinkingLevel(event.previousLevel);
539
+ ctx.ui.notify(
540
+ `Router thinking unchanged: '${event.level}' leaves no eligible route.`,
541
+ 'warning',
542
+ );
543
+ return;
544
+ }
545
+ thinkingByProfile[selectedProfile] = overrides;
527
546
  persistState();
528
547
  actions.updateStatus(ctx);
529
548
  if (event.level !== 'off') {
@@ -533,7 +552,7 @@ const routerExtension = (pi: ExtensionAPI) => {
533
552
  if (unsupported.length > 0) {
534
553
  ctx.ui.notify(
535
554
  `Router thinking (all) set to ${event.level}. ` +
536
- `${unsupported.join(', ')} tier${unsupported.length > 1 ? 's' : ''} may not support '${event.level}'.`,
555
+ `${unsupported.join(', ')} tier${unsupported.length > 1 ? 's' : ''} may not support '${event.level}' and will be skipped when unsupported.`,
537
556
  'warning',
538
557
  );
539
558
  }