@alexeiled/pi-model-router 0.5.0 → 0.5.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,6 +1,18 @@
1
1
  # Changelog
2
2
 
3
- ## 0.5.0 (unreleased)
3
+ ## 0.5.1 — 2026-09-20
4
+
5
+ - Delegate generation and classification through Pi's native model registry instead of duplicating auth/dispatch logic. Cover keyless and headers-only auth, native providers and credential URLs with in-memory SDK integration tests.
6
+ - Retry only before output; preserve aborts and partial output, reject unterminated streams, and record the actual fallback model.
7
+ - Use each attempted model's context limit and trim complete turns without discarding system messages or orphaning tool results.
8
+ - Resolve classifier choices against partial profiles, bound classifier requests, and reject malformed/error responses.
9
+ - Deep-copy and validate persisted state; reset snapshot deduplication across branches.
10
+ - Validate rule keywords and malformed profiles; avoid inherited-name lookups and respect non-reasoning tier declarations.
11
+ - Reject invalid debug/widget options, fix thinking completions, and avoid success notifications after failed switches.
12
+ - Replace sleep-based stream tests with real event streams; share typed fixtures and keep test helpers out of npm artifacts.
13
+ - Fail closed on npm registry lookup errors other than a missing version. Document the release and provenance verification procedure.
14
+
15
+ ## 0.5.0 — 2026-09-20
4
16
 
5
17
  This is the first release of the independently maintained `@alexeiled/pi-model-router` fork.
6
18
 
package/README.md CHANGED
@@ -1,6 +1,13 @@
1
1
  # pi-model-router
2
2
 
3
- Smart per-turn model router extension for the [pi-coding-agent](https://github.com/earendil-works/pi/tree/main/packages/coding-agent) that optimizes your AI budget and usage limits without sacrificing quality by dynamically routing each turn to the optimal LLM tier. It automatically selects between high, medium, and low-tier models based on task intent, session budget, context size, and custom rules — complete with automatic fallbacks and phase awareness.
3
+ [![CI](https://github.com/alexei-led/pi-model-router/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/alexei-led/pi-model-router/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/%40alexeiled%2Fpi-model-router?logo=npm)](https://www.npmjs.com/package/@alexeiled/pi-model-router)
5
+ [![npm downloads](https://img.shields.io/npm/dm/%40alexeiled%2Fpi-model-router?logo=npm)](https://www.npmjs.com/package/@alexeiled/pi-model-router)
6
+ [![Latest release](https://img.shields.io/github/v/release/alexei-led/pi-model-router?display_name=tag&sort=semver)](https://github.com/alexei-led/pi-model-router/releases)
7
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
+ [![Node.js >=22.19](https://img.shields.io/badge/node-%3E%3D22.19-339933?logo=node.js&logoColor=white)](package.json)
9
+
10
+ Per-turn model router for [Pi](https://github.com/earendil-works/pi/tree/main/packages/coding-agent). Selects high, medium or low-tier models using task intent, a soft budget policy and custom rules, while keeping the selected `router/<profile>` model stable.
4
11
 
5
12
  > **Independent fork:** This project is an independently maintained fork of [yeliu84/pi-model-router](https://github.com/yeliu84/pi-model-router), originally created by Ye Liu. It is not an official upstream release. The original MIT license and copyright notice are preserved.
6
13
 
@@ -82,6 +89,15 @@ Or load directly for one run:
82
89
  pi -e ./extensions/index.ts
83
90
  ```
84
91
 
92
+ ## Reliability
93
+
94
+ - Both generation and classification use Pi's provider registry, including native/custom providers and credential-specific URLs.
95
+ - Fallbacks run only before content is emitted; cancellation does not retry.
96
+ - Classifier requests use isolated context, a 10-second cancellation deadline and a 256-token output limit. Failures retain local routing.
97
+ - Context trimming preserves system instructions and whole active tool turns. It is a text estimate, not a guarantee that images or a large active turn fit.
98
+
99
+ See [architecture](https://github.com/alexei-led/pi-model-router/blob/main/docs/ARCHITECTURE.md) and [release procedure](https://github.com/alexei-led/pi-model-router/blob/main/docs/RELEASING.md).
100
+
85
101
  ## Configuration
86
102
 
87
103
  Copy the example config to one of:
@@ -112,7 +128,7 @@ The extension stores the last selected profile in `~/.pi/agent/model-router-stat
112
128
  | Field | Description |
113
129
  | ----------------------- | --------------------------------------------------------------------------------- |
114
130
  | `classifierModel` | (Optional) Model used to categorize intent. Supports model aliases. If omitted, fast heuristics are used. |
115
- | `maxSessionBudget` | (Optional) USD budget for the session. Forces `medium` tier once exceeded. |
131
+ | `maxSessionBudget` | (Optional) Soft generation-cost threshold in USD. Downgrades high to medium, or low if medium is absent. Not a spending cap; classifier cost is excluded. |
116
132
  | `phaseBias` | (0.0 - 1.0) Stickiness of the current phase. Higher = more stable. Default `0.5`. |
117
133
  | `rules` | List of custom keyword rules (e.g. `{ "matches": "deploy", "tier": "high" }`). |
118
134
  | `models` | (Optional) Map of model aliases to definitions with `model`, `contextWindow`, `maxTokens`. |
@@ -136,10 +136,6 @@ export const registerCommands = (
136
136
  ];
137
137
  }
138
138
 
139
- if (levelValues.includes(args[0])) {
140
- return null;
141
- }
142
-
143
139
  if ((tierValues as string[]).includes(args[0])) {
144
140
  const tier = args[0];
145
141
  const levelPrefix = args[1] ?? '';
@@ -443,6 +439,10 @@ export const registerCommands = (
443
439
  return;
444
440
  }
445
441
  const cmd = args[0]?.toLowerCase();
442
+ if (cmd && !['on', 'off', 'toggle'].includes(cmd)) {
443
+ ctx.ui.notify('Usage: /router widget <on|off|toggle>', 'error');
444
+ return;
445
+ }
446
446
  if (cmd === 'on') state.widgetEnabled = true;
447
447
  else if (cmd === 'off') state.widgetEnabled = false;
448
448
  else state.widgetEnabled = !state.widgetEnabled;
@@ -460,6 +460,10 @@ export const registerCommands = (
460
460
  return;
461
461
  }
462
462
  const cmd = args[0]?.toLowerCase();
463
+ if (cmd && !['on', 'off', 'toggle', 'clear', 'show'].includes(cmd)) {
464
+ ctx.ui.notify('Usage: /router debug <on|off|toggle|show|clear>', 'error');
465
+ return;
466
+ }
463
467
  if (cmd === 'on') state.debugEnabled = true;
464
468
  else if (cmd === 'off') state.debugEnabled = false;
465
469
  else if (cmd === 'clear') state.debugHistory.length = 0;
@@ -656,11 +660,12 @@ export const registerCommands = (
656
660
  );
657
661
  return;
658
662
  }
659
- await actions.switchToRouterProfile(subcommand, ctx);
660
- ctx.ui.notify(
661
- `Router enabled with profile: ${state.selectedProfile}`,
662
- 'info',
663
- );
663
+ if (await actions.switchToRouterProfile(subcommand, ctx)) {
664
+ ctx.ui.notify(
665
+ `Router enabled with profile: ${state.selectedProfile}`,
666
+ 'info',
667
+ );
668
+ }
664
669
  } else {
665
670
  ctx.ui.notify(
666
671
  `Unknown router subcommand: ${subcommand}. Try /router help`,
@@ -82,7 +82,8 @@ export const resolveModelRef = (
82
82
  ref: string,
83
83
  models: Record<string, ModelDefinition> | undefined,
84
84
  ): { canonicalRef: string; definition?: ModelDefinition } => {
85
- const definition = models?.[ref];
85
+ const definition =
86
+ models && Object.hasOwn(models, ref) ? models[ref] : undefined;
86
87
  if (definition) {
87
88
  return { canonicalRef: definition.model, definition };
88
89
  }
@@ -104,8 +105,13 @@ export const mergeConfig = (
104
105
  override: Partial<RouterConfig>,
105
106
  ): RouterConfig => {
106
107
  const mergedProfiles: Record<string, RouterProfile> = { ...base.profiles };
107
- for (const [name, profile] of Object.entries(override.profiles ?? {})) {
108
- const existing = mergedProfiles[name];
108
+ for (const [name, profile] of Object.entries(
109
+ isObjectRecord(override.profiles) ? override.profiles : {},
110
+ )) {
111
+ if (!isObjectRecord(profile) || name === '__proto__') continue;
112
+ const existing = Object.hasOwn(mergedProfiles, name)
113
+ ? mergedProfiles[name]
114
+ : undefined;
109
115
  const nextProfile = profile as Partial<RouterProfile>;
110
116
  mergedProfiles[name] = {
111
117
  high: mergeTier(existing?.high, nextProfile.high),
@@ -116,7 +122,7 @@ export const mergeConfig = (
116
122
 
117
123
  const mergedModels: Record<string, ModelDefinition> = {
118
124
  ...(base.models ?? {}),
119
- ...(override.models ?? {}),
125
+ ...(isObjectRecord(override.models) ? override.models : {}),
120
126
  };
121
127
 
122
128
  return {
@@ -160,6 +166,7 @@ export const normalizeModelsMap = (
160
166
  if (!raw || !isObjectRecord(raw)) return result;
161
167
 
162
168
  for (const [alias, entry] of Object.entries(raw)) {
169
+ if (alias === '__proto__') continue;
163
170
  if (!isObjectRecord(entry)) {
164
171
  warnings.push(
165
172
  `Ignored invalid model definition "${alias}": expected an object.`,
@@ -331,6 +338,7 @@ export const normalizeTierConfig = (
331
338
  const resolvedThinkingLevels: ThinkingLevel[] = [...baseThinkingLevels];
332
339
  if (
333
340
  !explicitThinkingLevels &&
341
+ effectiveReasoning !== false &&
334
342
  thinking !== 'off' &&
335
343
  !resolvedThinkingLevels.includes(thinking)
336
344
  ) {
@@ -363,7 +371,10 @@ export const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
363
371
 
364
372
  const normalizedProfiles: Record<string, RouterProfile> = {};
365
373
 
366
- for (const [name, profile] of Object.entries(raw.profiles ?? {})) {
374
+ for (const [name, profile] of Object.entries(
375
+ isObjectRecord(raw.profiles) ? raw.profiles : {},
376
+ )) {
377
+ if (name === '__proto__') continue;
367
378
  const high = normalizeTierConfig(
368
379
  profile?.high,
369
380
  name,
@@ -411,7 +422,12 @@ export const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
411
422
  const matches = rule.matches;
412
423
  const tier = rule.tier;
413
424
  if (
414
- (typeof matches === 'string' || Array.isArray(matches)) &&
425
+ ((typeof matches === 'string' && matches.trim().length > 0) ||
426
+ (Array.isArray(matches) &&
427
+ matches.length > 0 &&
428
+ matches.every(
429
+ (m) => typeof m === 'string' && m.trim().length > 0,
430
+ ))) &&
415
431
  isRouterTier(tier)
416
432
  ) {
417
433
  rules.push({
@@ -518,7 +534,7 @@ export const resolveProfileName = (
518
534
  config: RouterConfig,
519
535
  requested?: string,
520
536
  ): string | undefined => {
521
- if (requested && config.profiles[requested]) {
537
+ if (requested && Object.hasOwn(config.profiles, requested)) {
522
538
  return requested;
523
539
  }
524
540
  return undefined;
@@ -1,49 +1,3 @@
1
1
  export const MAX_DEBUG_HISTORY = 12;
2
2
  export const DEFAULT_CONTEXT_WINDOW = 128_000;
3
3
  export const DEFAULT_MAX_TOKENS = 16_384;
4
-
5
- const AUTH_HEADERS = new Set([
6
- 'authorization',
7
- 'x-api-key',
8
- 'cf-aig-authorization',
9
- ]);
10
-
11
- export interface RegistryWithProviderAuth {
12
- getProviderAuth?: (
13
- provider: string,
14
- ) => Promise<{ auth: { baseUrl?: string } } | undefined>;
15
- }
16
-
17
- export const hasUsableRequestAuth = (auth: {
18
- apiKey?: string;
19
- headers?: Record<string, string | null | undefined>;
20
- }): boolean => {
21
- if (typeof auth.apiKey === 'string' && auth.apiKey.trim().length > 0) {
22
- return true;
23
- }
24
-
25
- return Object.entries(auth.headers ?? {}).some(
26
- ([name, value]) =>
27
- AUTH_HEADERS.has(name.toLowerCase()) &&
28
- typeof value === 'string' &&
29
- value.trim().length > 0,
30
- );
31
- };
32
-
33
- export const resolveDelegatedModel = async <
34
- TModel extends { provider: string; baseUrl: string },
35
- >(
36
- registry: RegistryWithProviderAuth,
37
- model: TModel,
38
- ): Promise<TModel> => {
39
- try {
40
- const providerAuth = await registry.getProviderAuth?.(model.provider);
41
- const authBaseUrl = providerAuth?.auth.baseUrl;
42
- if (authBaseUrl && authBaseUrl !== model.baseUrl) {
43
- return { ...model, baseUrl: authBaseUrl };
44
- }
45
- } catch {
46
- // Older Pi versions and unavailable credentials use the model's static URL.
47
- }
48
- return model;
49
- };
@@ -199,7 +199,6 @@ const routerExtension = (pi: ExtensionAPI) => {
199
199
 
200
200
  // Ensure the provider is registered with current capacities for this profile
201
201
  actions.registerRouterProvider();
202
- await new Promise((resolve) => setTimeout(resolve, 50));
203
202
 
204
203
  const routerModel = ctx.modelRegistry.find('router', profileName);
205
204
  if (!routerModel) {
@@ -284,6 +283,7 @@ const routerExtension = (pi: ExtensionAPI) => {
284
283
  ctx: ExtensionContext,
285
284
  startReason: SessionStartEvent['reason'],
286
285
  ) => {
286
+ lastPersistedSnapshot = undefined;
287
287
  ignoreStartupThinkingEvent =
288
288
  startReason === 'startup' ||
289
289
  startReason === 'new' ||
@@ -295,9 +295,6 @@ const routerExtension = (pi: ExtensionAPI) => {
295
295
  const hasExplicitStartupModel =
296
296
  startReason === 'startup' && hasExplicitCliModel();
297
297
 
298
- // Give the registry a moment to synchronize after re-registration
299
- await new Promise((resolve) => setTimeout(resolve, 50));
300
-
301
298
  routerEnabled = ctx.model?.provider === 'router';
302
299
  selectedProfile =
303
300
  ctx.model?.provider === 'router'
@@ -342,7 +339,10 @@ const routerExtension = (pi: ExtensionAPI) => {
342
339
  Object.assign(pinnedTierByProfile, savedState.pinByProfile);
343
340
  }
344
341
  if (savedState.thinkingByProfile) {
345
- Object.assign(thinkingByProfile, savedState.thinkingByProfile);
342
+ Object.assign(
343
+ thinkingByProfile,
344
+ structuredClone(savedState.thinkingByProfile),
345
+ );
346
346
  }
347
347
  if (savedState.pinTier && selectedProfile) {
348
348
  pinnedTierByProfile[selectedProfile] = savedState.pinTier;
@@ -350,12 +350,14 @@ const routerExtension = (pi: ExtensionAPI) => {
350
350
  debugEnabled = savedState.debugEnabled ?? debugEnabled;
351
351
  widgetEnabled = savedState.widgetEnabled ?? widgetEnabled;
352
352
  debugHistory = savedState.debugHistory
353
- ? [...savedState.debugHistory].slice(-MAX_DEBUG_HISTORY)
353
+ ? structuredClone(savedState.debugHistory).slice(-MAX_DEBUG_HISTORY)
354
354
  : [];
355
355
  if (!hasExplicitStartupModel) {
356
356
  lastNonRouterModel =
357
357
  savedState.lastNonRouterModel ?? lastNonRouterModel;
358
- lastDecision = savedState.lastDecision;
358
+ lastDecision = savedState.lastDecision
359
+ ? structuredClone(savedState.lastDecision)
360
+ : undefined;
359
361
  }
360
362
  accumulatedCost = savedState.accumulatedCost ?? 0;
361
363
  } else if (
@@ -1,16 +1,15 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
1
2
  import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
2
3
  import {
3
4
  type Api,
4
5
  type AssistantMessage,
6
+ type AssistantMessageEvent,
5
7
  type AssistantMessageEventStream,
6
8
  type Context,
7
9
  createAssistantMessageEventStream,
8
10
  type Model,
9
- normalizeContext,
10
11
  type SimpleStreamOptions,
11
- type TranscriptContext,
12
12
  } from '@earendil-works/pi-ai';
13
- import { streamSimple } from '@earendil-works/pi-ai/compat';
14
13
  import type {
15
14
  ExtensionAPI,
16
15
  ExtensionContext,
@@ -25,13 +24,7 @@ import {
25
24
  resolveContextWindow,
26
25
  resolveMaxTokens,
27
26
  } from './config';
28
- import {
29
- DEFAULT_CONTEXT_WINDOW,
30
- DEFAULT_MAX_TOKENS,
31
- hasUsableRequestAuth,
32
- type RegistryWithProviderAuth,
33
- resolveDelegatedModel,
34
- } from './constants';
27
+ import { DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS } from './constants';
35
28
  import type {
36
29
  RouterConfig,
37
30
  RouterPinByProfile,
@@ -44,17 +37,6 @@ const REGISTRY_WAIT_TIMEOUT_MS = 5000;
44
37
  const REGISTRY_WAIT_INITIAL_DELAY_MS = 50;
45
38
  const REGISTRY_WAIT_MAX_DELAY_MS = 500;
46
39
 
47
- type ProviderAwareRegistry = ExtensionContext['modelRegistry'] & {
48
- getRegisteredProviderConfig?: (provider: string) => {
49
- api?: Api;
50
- streamSimple?: (
51
- model: Model<Api>,
52
- context: TranscriptContext,
53
- options?: SimpleStreamOptions,
54
- ) => AssistantMessageEventStream;
55
- };
56
- };
57
-
58
40
  /**
59
41
  * Wait for the model registry to become available with exponential backoff.
60
42
  * This handles the race condition where subagents (e.g. from pi-dynamic-workflows)
@@ -67,15 +49,21 @@ export const waitForRegistry = async (
67
49
  | undefined;
68
50
  },
69
51
  timeoutMs: number = REGISTRY_WAIT_TIMEOUT_MS,
52
+ signal?: AbortSignal,
70
53
  ): Promise<ExtensionContext['modelRegistry'] | undefined> => {
54
+ signal?.throwIfAborted();
71
55
  if (state.currentModelRegistry) return state.currentModelRegistry;
72
56
 
73
57
  const start = Date.now();
74
- let delay = REGISTRY_WAIT_INITIAL_DELAY_MS;
58
+ let interval = REGISTRY_WAIT_INITIAL_DELAY_MS;
75
59
  while (Date.now() - start < timeoutMs) {
76
- await new Promise((resolve) => setTimeout(resolve, delay));
60
+ await delay(
61
+ Math.min(interval, timeoutMs - (Date.now() - start)),
62
+ undefined,
63
+ { signal },
64
+ );
77
65
  if (state.currentModelRegistry) return state.currentModelRegistry;
78
- delay = Math.min(delay * 2, REGISTRY_WAIT_MAX_DELAY_MS);
66
+ interval = Math.min(interval * 2, REGISTRY_WAIT_MAX_DELAY_MS);
79
67
  }
80
68
  return undefined;
81
69
  };
@@ -86,6 +74,7 @@ import {
86
74
  extractTextFromContent,
87
75
  hasImageAttachment,
88
76
  phaseForTier,
77
+ resolveAvailableTier,
89
78
  runClassifier,
90
79
  } from './routing';
91
80
 
@@ -120,7 +109,7 @@ const estimateTokens = (text: string): number => Math.ceil(text.length / 3);
120
109
 
121
110
  /**
122
111
  * Truncate context to fit within a target token limit by removing oldest messages.
123
- * Always preserves the first system message and the latest user message.
112
+ * Preserves the system prompt and the complete latest user/tool turn.
124
113
  */
125
114
  const truncateContext = (context: Context, limit: number): Context => {
126
115
  const messages = [...context.messages];
@@ -139,24 +128,23 @@ const truncateContext = (context: Context, limit: number): Context => {
139
128
 
140
129
  if (totalTokens <= limit) return context;
141
130
 
142
- const latestMessage = messages.pop();
143
- if (!latestMessage) return context;
144
- const latestTokens = messageTokens.pop() ?? 0;
145
-
146
- // Keep shifting oldest messages from the start of the list
147
- let activeMessagesTokensSum = messageTokens.reduce((sum, t) => sum + t, 0);
148
-
131
+ // Drop only complete turns. Splitting an assistant/tool-result pair corrupts transcripts.
132
+ // This text estimate cannot guarantee a fit for images/tools or one oversized active turn.
133
+ let remaining = totalTokens;
149
134
  let startIndex = 0;
150
- while (startIndex < messages.length) {
151
- const currentTokens = systemTokens + latestTokens + activeMessagesTokensSum;
152
- if (currentTokens <= limit) break;
153
-
154
- activeMessagesTokensSum -= messageTokens[startIndex];
155
- startIndex++;
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++];
156
138
  }
157
-
158
- const finalMessages = [...messages.slice(startIndex), latestMessage];
159
- return { ...context, messages: finalMessages };
139
+ return {
140
+ ...context,
141
+ messages: [
142
+ ...messages
143
+ .slice(0, startIndex)
144
+ .filter((message) => message.role === 'system'),
145
+ ...messages.slice(startIndex),
146
+ ],
147
+ };
160
148
  };
161
149
 
162
150
  const supportsReasoning = (
@@ -219,8 +207,8 @@ export const registerRouterProvider = (
219
207
  // Report the MAX context window and max output tokens across all tiers.
220
208
  // The honesty check + truncateContext handles the case where the
221
209
  // actually routed model is smaller.
222
- let maxContextWindow = DEFAULT_CONTEXT_WINDOW;
223
- let maxMaxTokens = DEFAULT_MAX_TOKENS;
210
+ let maxContextWindow = 0;
211
+ let maxMaxTokens = 0;
224
212
  for (const tier of ROUTER_TIERS) {
225
213
  if (!profile[tier]) continue;
226
214
  const cw = resolveContextWindow(
@@ -252,14 +240,12 @@ export const registerRouterProvider = (
252
240
  ...(thinkingLevelMap ? { thinkingLevelMap } : {}),
253
241
  input: ['text', 'image'] as ('text' | 'image')[],
254
242
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
255
- contextWindow: maxContextWindow,
256
- maxTokens: maxMaxTokens,
243
+ contextWindow: maxContextWindow || DEFAULT_CONTEXT_WINDOW,
244
+ maxTokens: maxMaxTokens || DEFAULT_MAX_TOKENS,
257
245
  };
258
246
  });
259
247
 
260
- const modelsKey = modelDefinitions
261
- .map((m) => `${m.id}:${m.contextWindow}:${m.maxTokens}:${m.reasoning}`)
262
- .join(',');
248
+ const modelsKey = JSON.stringify(modelDefinitions);
263
249
  if (state.lastRegisteredModels === modelsKey) return;
264
250
 
265
251
  pi.registerProvider('router', {
@@ -275,6 +261,7 @@ export const registerRouterProvider = (
275
261
  const stream = createAssistantMessageEventStream();
276
262
 
277
263
  (async () => {
264
+ let partialMessage: AssistantMessage | undefined;
278
265
  try {
279
266
  // Wait for the router to be fully initialized (session_start sets currentModelRegistry).
280
267
  // This handles the race where subagents (e.g. from pi-dynamic-workflows) invoke
@@ -282,6 +269,7 @@ export const registerRouterProvider = (
282
269
  const registry = await waitForRegistry(
283
270
  state,
284
271
  state.registryTimeoutMs,
272
+ options?.signal,
285
273
  );
286
274
  if (!registry) {
287
275
  throw new Error(
@@ -325,15 +313,20 @@ export const registerRouterProvider = (
325
313
  state.currentConfig.classifierModel.model,
326
314
  registry,
327
315
  context,
328
- state.lastDecision?.phase,
316
+ state.lastDecision?.profile === model.id
317
+ ? state.lastDecision.phase
318
+ : undefined,
329
319
  state.currentConfig.classifierModel.thinking,
320
+ options?.signal,
330
321
  );
322
+ options?.signal?.throwIfAborted();
331
323
  if (classifierResult) {
324
+ const tier = resolveAvailableTier(profile, classifierResult.tier);
332
325
  decision = buildRoutingDecision(
333
326
  model.id,
334
327
  profile,
335
- classifierResult.tier,
336
- phaseForTier(classifierResult.tier),
328
+ tier,
329
+ phaseForTier(tier),
337
330
  `Classifier: ${classifierResult.reasoning}`,
338
331
  state.thinkingByProfile[model.id],
339
332
  true,
@@ -446,13 +439,16 @@ export const registerRouterProvider = (
446
439
  if (imageAttached) {
447
440
  modelsToTry = modelsToTry.filter(checkModelSupportsImage);
448
441
  if (modelsToTry.length === 0) {
449
- modelsToTry = [decision.targetLabel];
442
+ throw new Error(
443
+ 'No configured model supports image attachments.',
444
+ );
450
445
  }
451
446
  }
452
447
  let lastError: unknown;
453
448
  let success = false;
454
449
 
455
450
  for (let i = 0; i < modelsToTry.length; i++) {
451
+ options?.signal?.throwIfAborted();
456
452
  const modelRef = modelsToTry[i];
457
453
  const { provider: targetProvider, modelId: targetModelId } =
458
454
  parseCanonicalModelRef(modelRef);
@@ -467,31 +463,14 @@ export const registerRouterProvider = (
467
463
  continue;
468
464
  }
469
465
 
470
- const auth = await registry.getApiKeyAndHeaders(targetModel);
471
- if (!auth.ok || !hasUsableRequestAuth(auth)) {
472
- lastError = new Error(
473
- auth.ok
474
- ? `No API key or authentication headers for routed model: ${targetProvider}/${targetModelId}`
475
- : `Auth failed for routed model: ${targetProvider}/${targetModelId}: ${auth.error}`,
476
- );
477
- continue;
478
- }
479
- const apiKey = auth.apiKey;
480
- const headers = auth.headers;
481
- const requestModel = await resolveDelegatedModel(
482
- registry as unknown as RegistryWithProviderAuth,
483
- targetModel,
484
- );
485
-
466
+ let contentReceived = false;
486
467
  try {
487
468
  // HONESTY CHECK & AUTO-TRUNCATION
488
469
  // If the picked model has a smaller context than what we reported, truncate now.
489
470
  let effectiveContext = context;
490
- const targetLimit = resolveContextWindow(
491
- decision.tier,
492
- profile,
493
- registry,
494
- );
471
+ const targetLimit =
472
+ targetModel.contextWindow ??
473
+ resolveContextWindow(decision.tier, profile, registry);
495
474
  if (
496
475
  model.contextWindow !== undefined &&
497
476
  targetLimit < model.contextWindow
@@ -534,42 +513,42 @@ export const registerRouterProvider = (
534
513
  // Stale extension context — skip non-critical UI updates.
535
514
  }
536
515
 
537
- // Strip pi's reasoning from options the router controls thinking
538
- const { reasoning: _piReasoning, ...delegationOptions } =
539
- options ?? {};
516
+ // Router credentials must not override the concrete provider's request auth.
517
+ const {
518
+ reasoning: _piReasoning,
519
+ apiKey: _routerKey,
520
+ headers: _routerHeaders,
521
+ env: _routerEnv,
522
+ ...delegationOptions
523
+ } = options ?? {};
540
524
 
541
525
  const delegatedOptions = {
542
526
  ...delegationOptions,
543
- apiKey,
544
- headers,
545
527
  ...(delegatedReasoning
546
528
  ? { reasoning: delegatedReasoning }
547
529
  : {}),
548
530
  };
549
- const registeredProvider = (
550
- registry as ProviderAwareRegistry
551
- ).getRegisteredProviderConfig?.(targetProvider);
552
- const delegatedStream =
553
- registeredProvider?.streamSimple &&
554
- registeredProvider.api === requestModel.api
555
- ? registeredProvider.streamSimple(
556
- requestModel,
557
- normalizeContext(effectiveContext),
558
- delegatedOptions,
559
- )
560
- : streamSimple(
561
- requestModel,
562
- effectiveContext,
563
- delegatedOptions,
564
- );
565
-
566
- let contentReceived = false;
531
+ // Pi owns request-time auth, custom/native providers, URLs and transcript normalization.
532
+ const delegatedStream = registry.streamSimple(
533
+ targetModel,
534
+ effectiveContext,
535
+ delegatedOptions,
536
+ );
537
+ let terminalReceived = false;
538
+ const pendingEvents: AssistantMessageEvent[] = [];
539
+ const recordTarget = () => {
540
+ decision.targetProvider = targetProvider;
541
+ decision.targetModelId = targetModelId;
542
+ decision.targetLabel = modelRef;
543
+ decision.thinking = delegatedReasoning ?? 'off';
544
+ decision.isFallback = i > 0;
545
+ };
567
546
  for await (const event of delegatedStream) {
568
- if (event.type === 'done') {
569
- const cost = event.message.usage?.cost?.total ?? 0;
570
- state.accumulatedCost += cost;
571
- }
572
- if (event.type === 'error' && !contentReceived) {
547
+ if (
548
+ event.type === 'error' &&
549
+ event.reason !== 'aborted' &&
550
+ !contentReceived
551
+ ) {
573
552
  const errorMessage =
574
553
  'error' in event &&
575
554
  event.error &&
@@ -587,13 +566,37 @@ export const registerRouterProvider = (
587
566
  event.type === 'thinking_delta' ||
588
567
  event.type === 'toolcall_delta' ||
589
568
  event.type === 'toolcall_end';
590
- if (isContent) contentReceived = true;
591
- stream.push(event);
569
+ if (isContent) {
570
+ contentReceived = true;
571
+ recordTarget();
572
+ }
573
+ if (event.type === 'done' || event.type === 'error') {
574
+ terminalReceived = true;
575
+ recordTarget();
576
+ const cost = (
577
+ event.type === 'done' ? event.message : event.error
578
+ ).usage.cost.total;
579
+ if (Number.isFinite(cost) && cost > 0)
580
+ state.accumulatedCost += cost;
581
+ }
582
+ if (contentReceived || terminalReceived) {
583
+ for (const pending of pendingEvents.splice(0))
584
+ stream.push(pending);
585
+ stream.push(event);
586
+ if ('partial' in event) partialMessage = event.partial;
587
+ } else {
588
+ pendingEvents.push(event);
589
+ }
590
+ if (terminalReceived) break;
592
591
  }
592
+ if (!terminalReceived)
593
+ throw new Error(
594
+ 'Provider stream ended without a terminal event.',
595
+ );
593
596
  success = true;
594
- if (i > 0) decision.isFallback = true;
595
597
  break;
596
598
  } catch (err) {
599
+ if (contentReceived || options?.signal?.aborted) throw err;
597
600
  lastError = err;
598
601
  }
599
602
  }
@@ -610,28 +613,17 @@ export const registerRouterProvider = (
610
613
 
611
614
  stream.end();
612
615
  } catch (error) {
613
- // When a subagent session is torn down (e.g. by pi-dynamic-workflows),
614
- // the extension runtime is invalidated and any pi/ctx call throws a
615
- // stale-context error. Push a graceful done event so the stream's
616
- // result() promise resolves (required by AssistantMessageEventStream).
617
- const isStaleCtx =
618
- error instanceof Error && error.message.includes('stale');
619
- if (isStaleCtx) {
620
- stream.push({
621
- type: 'done',
622
- reason: 'stop',
623
- message: createErrorMessage(model, ''),
624
- });
625
- } else {
626
- stream.push({
627
- type: 'error',
628
- reason: 'error',
629
- error: createErrorMessage(
630
- model,
616
+ const reason = options?.signal?.aborted ? 'aborted' : 'error';
617
+ stream.push({
618
+ type: 'error',
619
+ reason,
620
+ error: {
621
+ ...(partialMessage ?? createErrorMessage(model, '')),
622
+ errorMessage:
631
623
  error instanceof Error ? error.message : String(error),
632
- ),
633
- });
634
- }
624
+ stopReason: reason,
625
+ },
626
+ });
635
627
  stream.end();
636
628
  } finally {
637
629
  try {
@@ -1,13 +1,7 @@
1
1
  import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
2
2
  import type { Context, Message } from '@earendil-works/pi-ai';
3
- import { streamSimple } from '@earendil-works/pi-ai/compat';
4
3
  import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
5
4
  import { isRouterTier, parseCanonicalModelRef } from './config';
6
- import {
7
- hasUsableRequestAuth,
8
- type RegistryWithProviderAuth,
9
- resolveDelegatedModel,
10
- } from './constants';
11
5
  import type {
12
6
  RouterPhase,
13
7
  RouterProfile,
@@ -148,6 +142,7 @@ export const decideRouting = (
148
142
  rules?: RoutingRule[],
149
143
  isBudgetExceeded = false,
150
144
  ): RoutingDecision => {
145
+ if (previousDecision?.profile !== profileName) previousDecision = undefined;
151
146
  const prompt = getLastUserText(context).toLowerCase();
152
147
  const recentConversation = getRecentConversationText(context);
153
148
  const toolResultCount = countToolResults(context);
@@ -372,7 +367,10 @@ export const decideRouting = (
372
367
  }
373
368
 
374
369
  // Resolve to nearest available tier if the selected tier is disabled
375
- const resolvedTier = resolveAvailableTier(profile, tier);
370
+ const resolvedTier =
371
+ isBudgetForced && !profile.medium && profile.low
372
+ ? 'low'
373
+ : resolveAvailableTier(profile, tier);
376
374
  if (resolvedTier !== tier) {
377
375
  reasoning = `Resolved from ${tier} to ${resolvedTier} tier (${tier} tier is not configured). Original: ${reasoning}`;
378
376
  phase = phaseForTier(resolvedTier);
@@ -399,20 +397,13 @@ export const runClassifier = async (
399
397
  context: Context,
400
398
  currentPhase?: RouterPhase,
401
399
  thinking?: ThinkingLevel,
400
+ signal?: AbortSignal,
402
401
  ): Promise<{ tier: RouterTier; reasoning: string } | undefined> => {
403
402
  try {
404
403
  const { provider, modelId } = parseCanonicalModelRef(classifierModelRef);
405
404
  const model = modelRegistry.find(provider, modelId);
406
- if (!model) return undefined;
407
-
408
- const auth = await modelRegistry.getApiKeyAndHeaders(model);
409
- if (!auth.ok || !hasUsableRequestAuth(auth)) return undefined;
410
- const apiKey = auth.apiKey;
411
- const headers = auth.headers;
412
- const requestModel = await resolveDelegatedModel(
413
- modelRegistry as unknown as RegistryWithProviderAuth,
414
- model,
415
- );
405
+ if (!model || provider === 'router') return undefined;
406
+ signal?.throwIfAborted();
416
407
 
417
408
  const promptText = getLastUserText(context);
418
409
  const historyText = getRecentConversationText(context, 4);
@@ -447,17 +438,24 @@ ${currentPhase === 'implementation' ? 'Consider that the conversation is current
447
438
  const reasoningOption =
448
439
  model.reasoning && thinking && thinking !== 'off' ? thinking : undefined;
449
440
 
450
- const stream = streamSimple(requestModel, classifierContext, {
451
- apiKey,
452
- headers,
441
+ const timeout = AbortSignal.timeout(10_000);
442
+ const stream = modelRegistry.streamSimple(model, classifierContext, {
443
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
444
+ maxTokens: 256,
453
445
  ...(reasoningOption ? { reasoning: reasoningOption } : {}),
454
446
  });
455
447
  let fullText = '';
448
+ let completed = false;
456
449
  for await (const event of stream) {
457
- if (event.type === 'text_delta' && typeof event.delta === 'string') {
458
- fullText += event.delta;
450
+ if (event.type === 'error') return undefined;
451
+ if (event.type === 'text_delta') fullText += event.delta;
452
+ if (event.type === 'done') {
453
+ completed = true;
454
+ fullText = extractTextFromContent(event.message.content);
455
+ break;
459
456
  }
460
457
  }
458
+ if (!completed) return undefined;
461
459
 
462
460
  const lines = fullText.trim().split('\n');
463
461
  const tierLine = lines.find((l) => l.toLowerCase().startsWith('tier:'));
@@ -471,7 +469,7 @@ ${currentPhase === 'implementation' ? 'Consider that the conversation is current
471
469
  return {
472
470
  tier: tierValue,
473
471
  reasoning: reasoningLine
474
- ? reasoningLine.split(':')[1].trim()
472
+ ? reasoningLine.slice(reasoningLine.indexOf(':') + 1).trim()
475
473
  : 'Classifier decision.',
476
474
  };
477
475
  }
@@ -1,6 +1,12 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { getAgentDir } from '@earendil-works/pi-coding-agent';
4
+ import {
5
+ isObjectRecord,
6
+ isRouterTier,
7
+ isThinkingLevel,
8
+ parseCanonicalModelRef,
9
+ } from './config';
4
10
  import type {
5
11
  RouterLastProfileState,
6
12
  RouterPersistedState,
@@ -11,13 +17,45 @@ import type {
11
17
 
12
18
  const LAST_PROFILE_STATE_FILE = 'model-router-state.json';
13
19
 
14
- const isRecord = (value: unknown): value is Record<string, unknown> =>
15
- typeof value === 'object' && value !== null && !Array.isArray(value);
20
+ const isPhase = (value: unknown) =>
21
+ value === 'planning' || value === 'implementation' || value === 'lightweight';
22
+ const isFiniteNumber = (value: unknown): value is number =>
23
+ typeof value === 'number' && Number.isFinite(value);
24
+ const isModelRef = (value: unknown) => {
25
+ if (typeof value !== 'string') return false;
26
+ try {
27
+ parseCanonicalModelRef(value);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ };
33
+ const isDecision = (value: unknown): value is RoutingDecision =>
34
+ isObjectRecord(value) &&
35
+ isRouterTier(value.tier) &&
36
+ isPhase(value.phase) &&
37
+ isThinkingLevel(value.thinking) &&
38
+ isFiniteNumber(value.timestamp) &&
39
+ [
40
+ 'profile',
41
+ 'targetProvider',
42
+ 'targetModelId',
43
+ 'targetLabel',
44
+ 'reasoning',
45
+ ].every((key) => typeof value[key] === 'string') &&
46
+ ['isClassifier', 'isFallback', 'isBudgetForced', 'isRuleMatched'].every(
47
+ (key) => value[key] === undefined || typeof value[key] === 'boolean',
48
+ );
49
+ const isMap = (value: unknown, validate: (entry: unknown) => boolean) =>
50
+ isObjectRecord(value) &&
51
+ Object.entries(value).every(
52
+ ([key, entry]) => key !== '__proto__' && validate(entry),
53
+ );
16
54
 
17
55
  export const isRouterLastProfileState = (
18
56
  value: unknown,
19
57
  ): value is RouterLastProfileState =>
20
- isRecord(value) &&
58
+ isObjectRecord(value) &&
21
59
  typeof value.selectedProfile === 'string' &&
22
60
  value.selectedProfile.length > 0 &&
23
61
  typeof value.timestamp === 'number';
@@ -58,16 +96,35 @@ export const saveLastRouterProfile = (
58
96
  export const isRouterPersistedState = (
59
97
  value: unknown,
60
98
  ): value is RouterPersistedState => {
61
- if (typeof value !== 'object' || value === null) {
62
- return false;
63
- }
64
- if (!isRecord(value)) {
65
- return false;
66
- }
99
+ if (!isObjectRecord(value)) return false;
67
100
  return (
68
101
  typeof value.enabled === 'boolean' &&
69
102
  typeof value.selectedProfile === 'string' &&
70
- typeof value.timestamp === 'number'
103
+ isFiniteNumber(value.timestamp) &&
104
+ (value.pinTier === undefined || isRouterTier(value.pinTier)) &&
105
+ (value.pinByProfile === undefined ||
106
+ isMap(value.pinByProfile, isRouterTier)) &&
107
+ (value.thinkingByProfile === undefined ||
108
+ isMap(
109
+ value.thinkingByProfile,
110
+ (tiers) =>
111
+ isObjectRecord(tiers) &&
112
+ Object.entries(tiers).every(
113
+ ([tier, level]) => isRouterTier(tier) && isThinkingLevel(level),
114
+ ),
115
+ )) &&
116
+ (value.lastDecision === undefined || isDecision(value.lastDecision)) &&
117
+ (value.debugHistory === undefined ||
118
+ (Array.isArray(value.debugHistory) &&
119
+ value.debugHistory.every(isDecision))) &&
120
+ (value.lastPhase === undefined || isPhase(value.lastPhase)) &&
121
+ (value.lastNonRouterModel === undefined ||
122
+ isModelRef(value.lastNonRouterModel)) &&
123
+ (value.accumulatedCost === undefined ||
124
+ (isFiniteNumber(value.accumulatedCost) && value.accumulatedCost >= 0)) &&
125
+ ['debugEnabled', 'widgetEnabled'].every(
126
+ (key) => value[key] === undefined || typeof value[key] === 'boolean',
127
+ )
71
128
  );
72
129
  };
73
130
 
@@ -83,7 +140,7 @@ export const buildPersistedState = (
83
140
  lastNonRouterModel: string | undefined,
84
141
  accumulatedCost: number,
85
142
  ): RouterPersistedState => {
86
- return {
143
+ return structuredClone({
87
144
  enabled: routerEnabled,
88
145
  selectedProfile: selectedProfile ?? '',
89
146
  pinTier: selectedProfile ? pinnedTierByProfile[selectedProfile] : undefined,
@@ -97,5 +154,5 @@ export const buildPersistedState = (
97
154
  lastNonRouterModel,
98
155
  accumulatedCost,
99
156
  timestamp: Date.now(),
100
- };
157
+ });
101
158
  };
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@alexeiled/pi-model-router",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "extensions",
7
- "!extensions/*.test.ts",
7
+ "!extensions/**/*.test.ts",
8
+ "!extensions/test",
8
9
  "LICENSE",
9
10
  "model-router.example.json",
10
11
  "README.md",