@kdejaeger/pi-model-router 0.4.2 → 0.4.5

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/README.md CHANGED
@@ -23,8 +23,8 @@ This extension (forked from [yeliu84/pi-model-router](https://github.com/yeliu84
23
23
 
24
24
  The pi-model-router registers itself as a **custom logical provider** (`router`) via `pi.registerProvider`. Each profile becomes a stable model (e.g., `router/cheap`). The model shown in your footer on the left stays fixed, while the underlying LLM changes per turn based on task complexity.
25
25
 
26
- ![Router footer](docs/footer.png)
27
- ![Router screenshot](docs/screenshot.png)
26
+ ![Router footer](https://raw.githubusercontent.com/kdejaeger/pi-model-router/main/docs/footer.png)
27
+ ![Router screenshot](https://raw.githubusercontent.com/kdejaeger/pi-model-router/main/docs/screenshot.png)
28
28
 
29
29
  For the full decision pipeline, see [How Routing Works](#how-routing-works).
30
30
 
@@ -1,22 +1,8 @@
1
- import type {
2
- ExtensionAPI,
3
- ExtensionContext,
4
- } from '@earendil-works/pi-coding-agent';
1
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
5
2
  import type { AutocompleteItem } from '@earendil-works/pi-tui';
6
- import type {
7
- RouterConfig,
8
- RouterPinByProfile,
9
- RoutingDecision,
10
- } from './types';
11
- import {
12
- profileNames,
13
- ROUTER_PIN_VALUES,
14
- resolveModelFromRef,
15
- isPinValue,
16
- } from './config';
17
- import {
18
- formatPinSummary,
19
- } from './ui';
3
+ import type { RouterConfig, RouterPinByProfile, RoutingDecision } from './types';
4
+ import { profileNames, ROUTER_PIN_VALUES, resolveModelFromRef, isPinValue } from './config';
5
+ import { formatPinSummary } from './ui';
20
6
 
21
7
  export const registerCommands = (
22
8
  pi: ExtensionAPI,
@@ -33,16 +19,9 @@ export const registerCommands = (
33
19
  actions: {
34
20
  persistState: () => void;
35
21
  updateStatus: (ctx: ExtensionContext) => void;
36
- reloadConfig: (
37
- ctx?: ExtensionContext,
38
- options?: { preserveDebug?: boolean },
39
- ) => void;
22
+ reloadConfig: (ctx?: ExtensionContext, options?: { preserveDebug?: boolean }) => void;
40
23
  ensureValidActiveRouterProfile: (ctx: ExtensionContext) => Promise<void>;
41
- switchToRouterProfile: (
42
- profileName: string,
43
- ctx: ExtensionContext,
44
- strict?: boolean,
45
- ) => Promise<boolean>;
24
+ switchToRouterProfile: (profileName: string, ctx: ExtensionContext, strict?: boolean) => Promise<boolean>;
46
25
  },
47
26
  ) => {
48
27
  const SUBCOMMAND_DETAILS = [
@@ -56,12 +35,8 @@ export const registerCommands = (
56
35
  { name: 'help', desc: 'Show usage help for subcommands' },
57
36
  ];
58
37
 
59
- const getSubcommandCompletions = (
60
- prefix: string,
61
- ): AutocompleteItem[] | null => {
62
- const items = SUBCOMMAND_DETAILS.filter((s) =>
63
- s.name.startsWith(prefix),
64
- ).map((s) => ({
38
+ const getSubcommandCompletions = (prefix: string): AutocompleteItem[] | null => {
39
+ const items = SUBCOMMAND_DETAILS.filter((s) => s.name.startsWith(prefix)).map((s) => ({
65
40
  value: s.name,
66
41
  label: s.name,
67
42
  description: s.desc,
@@ -73,9 +48,7 @@ export const registerCommands = (
73
48
  // pin [profile] <tier|clear>
74
49
  if (args.length <= 1) {
75
50
  const token = args[0] ?? '';
76
- const pinItems = ROUTER_PIN_VALUES.filter((value) =>
77
- value.startsWith(token),
78
- ).map((value) => ({ value, label: value }));
51
+ const pinItems = ROUTER_PIN_VALUES.filter((value) => value.startsWith(token)).map((value) => ({ value, label: value }));
79
52
  const profileItems = profileNames(state.currentConfig)
80
53
  .filter((name) => name.startsWith(token))
81
54
  .map((name) => ({ value: name, label: `router/${name}` }));
@@ -86,9 +59,7 @@ export const registerCommands = (
86
59
  const profileToken = args[0];
87
60
  if (!state.currentConfig.profiles[profileToken]) return null;
88
61
  const pinPrefix = args[1] ?? '';
89
- const items = ROUTER_PIN_VALUES.filter((value) =>
90
- value.startsWith(pinPrefix),
91
- ).map((value) => ({
62
+ const items = ROUTER_PIN_VALUES.filter((value) => value.startsWith(pinPrefix)).map((value) => ({
92
63
  value: `${profileToken} ${value}`,
93
64
  label: `${profileToken} ${value}`,
94
65
  }));
@@ -107,9 +78,7 @@ export const registerCommands = (
107
78
  ctx.ui.notify('Usage: /router status (no arguments)', 'error');
108
79
  return;
109
80
  }
110
- const profilePin = state.selectedProfile
111
- ? state.pinnedTierByProfile[state.selectedProfile] ?? 'none'
112
- : 'none';
81
+ const profilePin = state.selectedProfile ? (state.pinnedTierByProfile[state.selectedProfile] ?? 'none') : 'none';
113
82
  const lines = [
114
83
  `Router enabled: ${state.routerEnabled ? 'yes' : 'off'}`,
115
84
  `Selected profile: ${state.selectedProfile ?? 'none'}`,
@@ -193,10 +162,7 @@ export const registerCommands = (
193
162
  }
194
163
 
195
164
  if (!isPinValue(pinValue)) {
196
- ctx.ui.notify(
197
- `Invalid router pin: ${pinValue}. Use high, medium, low, or clear`,
198
- 'error',
199
- );
165
+ ctx.ui.notify(`Invalid router pin: ${pinValue}. Use high, medium, low, or clear`, 'error');
200
166
  return;
201
167
  }
202
168
 
@@ -235,10 +201,7 @@ export const registerCommands = (
235
201
  }
236
202
  const targetModel = resolveModelFromRef(state.lastNonRouterModel, ctx.modelRegistry);
237
203
  if (!targetModel) {
238
- ctx.ui.notify(
239
- `Recorded non-router model is unavailable: ${state.lastNonRouterModel}`,
240
- 'error',
241
- );
204
+ ctx.ui.notify(`Recorded non-router model is unavailable: ${state.lastNonRouterModel}`, 'error');
242
205
  return;
243
206
  }
244
207
  const success = await pi.setModel(targetModel);
@@ -250,10 +213,7 @@ export const registerCommands = (
250
213
  actions.persistState();
251
214
  pi.setThinkingLevel('off');
252
215
  actions.updateStatus(ctx);
253
- ctx.ui.notify(
254
- `Router disabled. Restored ${state.lastNonRouterModel}`,
255
- 'info',
256
- );
216
+ ctx.ui.notify(`Router disabled. Restored ${state.lastNonRouterModel}`, 'info');
257
217
  };
258
218
 
259
219
  const handleDebug = async (args: string[], ctx: ExtensionContext) => {
@@ -269,10 +229,7 @@ export const registerCommands = (
269
229
  }
270
230
  actions.persistState();
271
231
  actions.updateStatus(ctx);
272
- ctx.ui.notify(
273
- `Router debug ${state.debugEnabled ? 'enabled' : 'disabled'}.`,
274
- 'info',
275
- );
232
+ ctx.ui.notify(`Router debug ${state.debugEnabled ? 'enabled' : 'disabled'}.`, 'info');
276
233
  };
277
234
 
278
235
  const handleReload = async (args: string[], ctx: ExtensionContext) => {
@@ -288,10 +245,7 @@ export const registerCommands = (
288
245
  ctx.ui.notify(`Router reload warnings:\n${state.lastConfigWarnings.join('\n')}`, 'warning');
289
246
  }
290
247
 
291
- ctx.ui.notify(
292
- `Router config reloaded. Profiles: ${profileNames(state.currentConfig).join(', ')}`,
293
- 'info',
294
- );
248
+ ctx.ui.notify(`Router config reloaded. Profiles: ${profileNames(state.currentConfig).join(', ')}`, 'info');
295
249
  };
296
250
 
297
251
  pi.registerCommand('router', {
@@ -412,22 +366,13 @@ export const registerCommands = (
412
366
  // Check if subcommand is actually a profile name (backwards compatible-ish with /router-on)
413
367
  if (state.currentConfig.profiles[subcommand]) {
414
368
  if (subArgs.length > 0) {
415
- ctx.ui.notify(
416
- `Usage: /router ${subcommand} (no extra arguments allowed)`,
417
- 'error',
418
- );
369
+ ctx.ui.notify(`Usage: /router ${subcommand} (no extra arguments allowed)`, 'error');
419
370
  return;
420
371
  }
421
372
  await actions.switchToRouterProfile(subcommand, ctx);
422
- ctx.ui.notify(
423
- `Router enabled with profile: ${state.selectedProfile}`,
424
- 'info',
425
- );
373
+ ctx.ui.notify(`Router enabled with profile: ${state.selectedProfile}`, 'info');
426
374
  } else {
427
- ctx.ui.notify(
428
- `Unknown router subcommand: ${subcommand}. Try /router help`,
429
- 'error',
430
- );
375
+ ctx.ui.notify(`Unknown router subcommand: ${subcommand}. Try /router help`, 'error');
431
376
  }
432
377
  } else {
433
378
  await handleStatus(subArgs, ctx);
@@ -1,26 +1,23 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { type Api, type Model } from '@earendil-works/pi-ai';
3
+ import {
4
+ type Api,
5
+ type AssistantMessageEventStream,
6
+ type Context,
7
+ type Model,
8
+ type SimpleStreamOptions,
9
+ } from '@earendil-works/pi-ai';
10
+ import { streamSimple } from '@earendil-works/pi-ai/compat';
4
11
  import { getAgentDir, type ExtensionContext } from '@earendil-works/pi-coding-agent';
5
12
  import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
6
- import type {
7
- RouterConfig,
8
- RouterProfile,
9
- RoutedTierConfig,
10
- ConfigLoadResult,
11
- ParsedConfigFile,
12
- RouterTier,
13
- } from './types';
13
+ import type { RouterConfig, RouterProfile, RoutedTierConfig, ConfigLoadResult, ParsedConfigFile, RouterTier } from './types';
14
14
 
15
15
  export const ROUTER_TIERS = ['high', 'medium', 'low'] as const;
16
16
 
17
- const THINKING_LEVELS: readonly ThinkingLevel[] = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
17
+ const THINKING_LEVELS: readonly ThinkingLevel[] = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
18
18
  export const ROUTER_PIN_VALUES = ['clear', 'high', 'medium', 'low'] as const;
19
19
 
20
- const isObjectRecord = (
21
- value: unknown,
22
- ): value is Record<string, unknown> =>
23
- typeof value === 'object' && value !== null;
20
+ const isObjectRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
24
21
 
25
22
  const isThinkingLevel = (value: unknown): value is ThinkingLevel =>
26
23
  typeof value === 'string' && THINKING_LEVELS.includes(value as ThinkingLevel);
@@ -32,7 +29,12 @@ const isThinkingLevel = (value: unknown): value is ThinkingLevel =>
32
29
  export const isPinValue = (value: string): value is (typeof ROUTER_PIN_VALUES)[number] =>
33
30
  (ROUTER_PIN_VALUES as readonly string[]).includes(value);
34
31
 
35
- const validateNonNegativeInt = (val: unknown, label: string, fallback: number | undefined, warnings: string[]): number | undefined => {
32
+ const validateNonNegativeInt = (
33
+ val: unknown,
34
+ label: string,
35
+ fallback: number | undefined,
36
+ warnings: string[],
37
+ ): number | undefined => {
36
38
  if (val === undefined || val === null) return fallback;
37
39
  if (typeof val !== 'number' || !Number.isInteger(val) || val < 0) {
38
40
  warnings.push(`Invalid ${label} (${JSON.stringify(val)}). Must be a non-negative integer.`);
@@ -58,26 +60,18 @@ const parseConfigFile = (path: string): ParsedConfigFile => {
58
60
  } catch (error) {
59
61
  return {
60
62
  config: {},
61
- warnings: [
62
- `Failed to parse router config at ${path}: ${error instanceof Error ? error.message : String(error)}`,
63
- ],
63
+ warnings: [`Failed to parse router config at ${path}: ${error instanceof Error ? error.message : String(error)}`],
64
64
  };
65
65
  }
66
66
  };
67
67
 
68
- const mergeTier = (
69
- existing?: RoutedTierConfig,
70
- next?: Partial<RoutedTierConfig>,
71
- ): RoutedTierConfig | undefined => {
68
+ const mergeTier = (existing?: RoutedTierConfig, next?: Partial<RoutedTierConfig>): RoutedTierConfig | undefined => {
72
69
  if (!next) return existing;
73
70
  if (!existing) return next?.model ? (next as RoutedTierConfig) : undefined;
74
71
  return { ...existing, ...next };
75
72
  };
76
73
 
77
- const mergeConfig = (
78
- base: RouterConfig,
79
- override: Partial<RouterConfig>,
80
- ): RouterConfig => {
74
+ const mergeConfig = (base: RouterConfig, override: Partial<RouterConfig>): RouterConfig => {
81
75
  const mergedProfiles: Record<string, RouterProfile> = { ...base.profiles };
82
76
  for (const [name, profile] of Object.entries(override.profiles ?? {})) {
83
77
  if (!isObjectRecord(profile)) continue;
@@ -92,37 +86,25 @@ const mergeConfig = (
92
86
  return {
93
87
  debug: override.debug ?? base.debug,
94
88
  classifierModels: override.classifierModels ?? base.classifierModels,
95
- classifierModelThinking:
96
- override.classifierModelThinking ?? base.classifierModelThinking,
97
- classifierRunOnceAfterToolCount:
98
- override.classifierRunOnceAfterToolCount ?? base.classifierRunOnceAfterToolCount,
99
- classifierRunAfterToolFailures:
100
- override.classifierRunAfterToolFailures ?? base.classifierRunAfterToolFailures,
101
- classifierInterval:
102
- override.classifierInterval ?? base.classifierInterval,
103
- defaultContextThresholdPercent:
104
- override.defaultContextThresholdPercent ?? base.defaultContextThresholdPercent,
105
- contextThresholdPercentOverrides:
106
- override.contextThresholdPercentOverrides ?? base.contextThresholdPercentOverrides,
89
+ classifierModelThinking: override.classifierModelThinking ?? base.classifierModelThinking,
90
+ classifierRunOnceAfterToolCount: override.classifierRunOnceAfterToolCount ?? base.classifierRunOnceAfterToolCount,
91
+ classifierRunAfterToolFailures: override.classifierRunAfterToolFailures ?? base.classifierRunAfterToolFailures,
92
+ classifierInterval: override.classifierInterval ?? base.classifierInterval,
93
+ defaultContextThresholdPercent: override.defaultContextThresholdPercent ?? base.defaultContextThresholdPercent,
94
+ contextThresholdPercentOverrides: override.contextThresholdPercentOverrides ?? base.contextThresholdPercentOverrides,
107
95
  profiles: mergedProfiles,
108
96
  };
109
97
  };
110
98
 
111
- export const parseCanonicalModelRef = (
112
- value: string,
113
- ): { provider: string; modelId: string } => {
99
+ export const parseCanonicalModelRef = (value: string): { provider: string; modelId: string } => {
114
100
  const slashIndex = value.indexOf('/');
115
101
  if (slashIndex === -1) {
116
- throw new Error(
117
- `Invalid model reference "${value}". Expected "provider/model".`,
118
- );
102
+ throw new Error(`Invalid model reference "${value}". Expected "provider/model".`);
119
103
  }
120
104
  const provider = value.slice(0, slashIndex).trim();
121
105
  const modelId = value.slice(slashIndex + 1).trim();
122
106
  if (!provider || !modelId) {
123
- throw new Error(
124
- `Invalid model reference "${value}". Expected "provider/model".`,
125
- );
107
+ throw new Error(`Invalid model reference "${value}". Expected "provider/model".`);
126
108
  }
127
109
  return { provider, modelId };
128
110
  };
@@ -155,9 +137,7 @@ const normalizeTierConfig = (
155
137
 
156
138
  const model = typeof value.model === 'string' ? value.model.trim() : '';
157
139
  if (!model) {
158
- warnings.push(
159
- `Profile "${profileName}" ${tier} tier is missing a model. Tier disabled.`,
160
- );
140
+ warnings.push(`Profile "${profileName}" ${tier} tier is missing a model. Tier disabled.`);
161
141
  return undefined;
162
142
  }
163
143
 
@@ -170,13 +150,9 @@ const normalizeTierConfig = (
170
150
  return undefined;
171
151
  }
172
152
 
173
- const thinking = isThinkingLevel(value.thinking)
174
- ? value.thinking
175
- : 'medium';
153
+ const thinking = isThinkingLevel(value.thinking) ? value.thinking : 'medium';
176
154
  if (value.thinking !== undefined && !isThinkingLevel(value.thinking)) {
177
- warnings.push(
178
- `Profile "${profileName}" ${tier} tier has invalid thinking level. Defaulting to medium.`,
179
- );
155
+ warnings.push(`Profile "${profileName}" ${tier} tier has invalid thinking level. Defaulting to medium.`);
180
156
  }
181
157
 
182
158
  let fallbacks: string[] | undefined;
@@ -213,29 +189,12 @@ const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
213
189
  if (trimmedName !== name) {
214
190
  warnings.push(`Profile name "${name}" has leading/trailing whitespace. Using "${trimmedName}".`);
215
191
  }
216
- const high = normalizeTierConfig(
217
- profile?.high,
218
- trimmedName,
219
- 'high',
220
- warnings,
221
- );
222
- const medium = normalizeTierConfig(
223
- profile?.medium,
224
- trimmedName,
225
- 'medium',
226
- warnings,
227
- );
228
- const low = normalizeTierConfig(
229
- profile?.low,
230
- trimmedName,
231
- 'low',
232
- warnings,
233
- );
192
+ const high = normalizeTierConfig(profile?.high, trimmedName, 'high', warnings);
193
+ const medium = normalizeTierConfig(profile?.medium, trimmedName, 'medium', warnings);
194
+ const low = normalizeTierConfig(profile?.low, trimmedName, 'low', warnings);
234
195
 
235
196
  if (!high && !medium && !low) {
236
- warnings.push(
237
- `Profile "${trimmedName}" has no valid tiers. Skipped.`,
238
- );
197
+ warnings.push(`Profile "${trimmedName}" has no valid tiers. Skipped.`);
239
198
  continue;
240
199
  }
241
200
  if (!high) {
@@ -271,9 +230,7 @@ const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
271
230
  `defaultContextThresholdPercent (${raw.defaultContextThresholdPercent}) is not a positive number. Falling back to 90.`,
272
231
  );
273
232
  } else if (raw.defaultContextThresholdPercent > 100) {
274
- warnings.push(
275
- `defaultContextThresholdPercent (${raw.defaultContextThresholdPercent}) exceeds 100. Falling back to 90.`,
276
- );
233
+ warnings.push(`defaultContextThresholdPercent (${raw.defaultContextThresholdPercent}) exceeds 100. Falling back to 90.`);
277
234
  } else {
278
235
  defaultContextThresholdPercent = raw.defaultContextThresholdPercent;
279
236
  }
@@ -287,11 +244,15 @@ const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
287
244
  try {
288
245
  parseCanonicalModelRef(trimmed);
289
246
  } catch (error) {
290
- warnings.push(`Ignored contextThresholdPercentOverride "${key}": invalid model reference — ${error instanceof Error ? error.message : String(error)}`);
247
+ warnings.push(
248
+ `Ignored contextThresholdPercentOverride "${key}": invalid model reference — ${error instanceof Error ? error.message : String(error)}`,
249
+ );
291
250
  return [];
292
251
  }
293
252
  if (typeof val !== 'number' || val <= 0) {
294
- warnings.push(`Ignored contextThresholdPercentOverride "${key}" (${JSON.stringify(val)}): expected a positive number.`);
253
+ warnings.push(
254
+ `Ignored contextThresholdPercentOverride "${key}" (${JSON.stringify(val)}): expected a positive number.`,
255
+ );
295
256
  return [];
296
257
  }
297
258
  return [[trimmed, val] as [string, number]];
@@ -310,9 +271,7 @@ const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
310
271
  parseCanonicalModelRef(trimmedCM);
311
272
  classifierModels.push(trimmedCM);
312
273
  } catch (error) {
313
- warnings.push(
314
- `Invalid classifierModels entry "${rawCM}": ${error instanceof Error ? error.message : String(error)}`,
315
- );
274
+ warnings.push(`Invalid classifierModels entry "${rawCM}": ${error instanceof Error ? error.message : String(error)}`);
316
275
  }
317
276
  } else {
318
277
  warnings.push(`Ignored non-string classifierModels entry: ${JSON.stringify(rawCM)}`);
@@ -328,8 +287,18 @@ const normalizeConfig = (raw: RouterConfig): ConfigLoadResult => {
328
287
  warnings.push(`Invalid classifierModelThinking value "${raw.classifierModelThinking}". Falling back to "off".`);
329
288
  }
330
289
 
331
- const classifierRunOnceAfterToolCount = validateNonNegativeInt(raw.classifierRunOnceAfterToolCount, 'classifierRunOnceAfterToolCount', 3, warnings);
332
- const classifierRunAfterToolFailures = validateNonNegativeInt(raw.classifierRunAfterToolFailures, 'classifierRunAfterToolFailures', 2, warnings);
290
+ const classifierRunOnceAfterToolCount = validateNonNegativeInt(
291
+ raw.classifierRunOnceAfterToolCount,
292
+ 'classifierRunOnceAfterToolCount',
293
+ 3,
294
+ warnings,
295
+ );
296
+ const classifierRunAfterToolFailures = validateNonNegativeInt(
297
+ raw.classifierRunAfterToolFailures,
298
+ 'classifierRunAfterToolFailures',
299
+ 2,
300
+ warnings,
301
+ );
333
302
  const classifierInterval = validateNonNegativeInt(raw.classifierInterval, 'classifierInterval', 10, warnings);
334
303
 
335
304
  return {
@@ -354,18 +323,11 @@ export const loadRouterConfig = (cwd: string): ConfigLoadResult => {
354
323
  const globalResult = parseConfigFile(globalPath);
355
324
  const projectResult = parseConfigFile(projectPath);
356
325
  const baseConfig: RouterConfig = { profiles: {} };
357
- const merged = mergeConfig(
358
- mergeConfig(baseConfig, globalResult.config),
359
- projectResult.config,
360
- );
326
+ const merged = mergeConfig(mergeConfig(baseConfig, globalResult.config), projectResult.config);
361
327
  const normalized = normalizeConfig(merged);
362
328
  return {
363
329
  config: normalized.config,
364
- warnings: [
365
- ...globalResult.warnings,
366
- ...projectResult.warnings,
367
- ...normalized.warnings,
368
- ],
330
+ warnings: [...globalResult.warnings, ...projectResult.warnings, ...normalized.warnings],
369
331
  };
370
332
  };
371
333
 
@@ -386,20 +348,57 @@ export const OPENROUTER_ATTR_HEADERS: Readonly<Record<string, string>> = {
386
348
  /** Create an onPayload handler that injects session_id for OpenRouter session tracking. */
387
349
  export const createOpenRouterOnPayload = (
388
350
  sessionProvider?: { getSessionId(): string; getSessionName(): string | undefined },
389
- origOnPayload?: (p: any, m: any) => any,
390
- ): ((p: any, m: any) => Promise<any>) | undefined => {
351
+ origOnPayload?: (payload: unknown, model: Model<Api>) => unknown | Promise<unknown>,
352
+ ): ((payload: unknown, model: Model<Api>) => Promise<unknown>) | undefined => {
391
353
  const rawId = sessionProvider?.getSessionId();
392
354
  const name = sessionProvider?.getSessionName();
393
355
  const sessionId = name && rawId ? `${name.replace(/\s+/g, '-')}-${rawId.slice(0, 8)}` : rawId;
394
356
  if (!sessionId) return undefined;
395
- return async (p: any, m: any) => {
396
- const payload = origOnPayload ? await origOnPayload(p, m) : p;
397
- return { ...payload, session_id: sessionId };
357
+ return async (p: unknown, m: Model<Api>) => {
358
+ const payload = origOnPayload ? ((await origOnPayload(p, m)) ?? p) : p;
359
+ return typeof payload === 'object' && payload !== null ? { ...payload, session_id: sessionId } : payload;
398
360
  };
399
361
  };
400
362
 
401
- export const resolveProfileName = (
402
- config: RouterConfig,
403
- requested?: string,
404
- ): string | undefined =>
363
+ export const resolveProfileName = (config: RouterConfig, requested?: string): string | undefined =>
405
364
  requested && config.profiles[requested] ? requested : undefined;
365
+
366
+ /**
367
+ * Duck-typing interface for forward-compatible dispatch with Pi ModelRegistry
368
+ * runtimes that register custom provider stream handlers.
369
+ */
370
+ interface RegistryWithProviderDispatch {
371
+ getRegisteredProviderConfig?(providerName: string):
372
+ | {
373
+ api?: Api;
374
+ streamSimple?: (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;
375
+ }
376
+ | undefined;
377
+ getRegisteredNativeProvider?(providerName: string):
378
+ | {
379
+ streamSimple?: (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;
380
+ }
381
+ | undefined;
382
+ }
383
+
384
+ /**
385
+ * Dispatch streaming to a registered custom provider if available,
386
+ * falling back to the global compatibility streamSimple.
387
+ */
388
+ export const dispatchStream = (
389
+ model: Model<Api>,
390
+ context: Context,
391
+ options: SimpleStreamOptions,
392
+ modelRegistry?: ExtensionContext['modelRegistry'],
393
+ ): AssistantMessageEventStream => {
394
+ const registry = modelRegistry as unknown as RegistryWithProviderDispatch | undefined;
395
+ const providerConfig = registry?.getRegisteredProviderConfig?.(model.provider);
396
+ if (providerConfig?.streamSimple && (!providerConfig.api || providerConfig.api === model.api)) {
397
+ return providerConfig.streamSimple(model, context, options);
398
+ }
399
+ const nativeProvider = registry?.getRegisteredNativeProvider?.(model.provider);
400
+ if (nativeProvider?.streamSimple) {
401
+ return nativeProvider.streamSimple(model, context, options);
402
+ }
403
+ return streamSimple(model, context, options);
404
+ };
@@ -1,18 +1,6 @@
1
- import type {
2
- ExtensionAPI,
3
- ExtensionContext,
4
- } from '@earendil-works/pi-coding-agent';
5
- import {
6
- type RouterConfig,
7
- type RoutingDecision,
8
- type RouterPinByProfile,
9
- type CustomSessionEntry,
10
- } from './types';
11
- import {
12
- loadRouterConfig,
13
- profileNames,
14
- resolveProfileName,
15
- } from './config';
1
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
+ import { type RouterConfig, type RoutingDecision, type RouterPinByProfile, type CustomSessionEntry } from './types';
3
+ import { loadRouterConfig, profileNames, resolveProfileName } from './config';
16
4
  import { isRouterPersistedState, buildPersistedState } from './state';
17
5
  import { updateStatus } from './ui';
18
6
  import { registerCommands } from './commands';
@@ -35,12 +23,13 @@ const routerExtension = (pi: ExtensionAPI) => {
35
23
  let isInitialized = false;
36
24
  let isRouterDelegating = false;
37
25
 
38
- const setModelInternally = async (
39
- model: NonNullable<ExtensionContext['model']>,
40
- ) => {
26
+ const setModelInternally = async (model: NonNullable<ExtensionContext['model']>) => {
41
27
  isRouterDelegating = true;
42
28
  try {
43
29
  return await pi.setModel(model);
30
+ } catch {
31
+ // Extension context may be stale after session teardown.
32
+ return false;
44
33
  } finally {
45
34
  isRouterDelegating = false;
46
35
  }
@@ -58,31 +47,24 @@ const routerExtension = (pi: ExtensionAPI) => {
58
47
  const snapshot = JSON.stringify({
59
48
  ...state,
60
49
  timestamp: 0,
61
- lastDecision: state.lastDecision
62
- ? { ...state.lastDecision, timestamp: 0 }
63
- : undefined,
50
+ lastDecision: state.lastDecision ? { ...state.lastDecision, timestamp: 0 } : undefined,
64
51
  });
65
52
  if (snapshot === lastPersistedSnapshot) {
66
53
  return;
67
54
  }
68
- pi.appendEntry('router-state', state);
55
+ try {
56
+ pi.appendEntry('router-state', state);
57
+ } catch {
58
+ // Defensive fallback: session_shutdown or teardown may have invalidated context
59
+ return;
60
+ }
69
61
  lastPersistedSnapshot = snapshot;
70
62
  };
71
63
 
72
64
  const actions = {
73
65
  persistState,
74
- updateStatus: (ctx: ExtensionContext) =>
75
- updateStatus(
76
- ctx,
77
- routerEnabled,
78
- selectedProfile,
79
- pinnedTierByProfile,
80
- lastDecision,
81
- ),
82
- reloadConfig: (
83
- ctx?: ExtensionContext,
84
- options?: { preserveDebug?: boolean },
85
- ) => {
66
+ updateStatus: (ctx: ExtensionContext) => updateStatus(ctx, routerEnabled, selectedProfile, pinnedTierByProfile, lastDecision),
67
+ reloadConfig: (ctx?: ExtensionContext, options?: { preserveDebug?: boolean }) => {
86
68
  const loaded = loadRouterConfig(currentCwd);
87
69
  currentConfig = loaded.config;
88
70
  lastConfigWarnings = loaded.warnings;
@@ -114,11 +96,7 @@ const routerExtension = (pi: ExtensionAPI) => {
114
96
  routerEnabled = false;
115
97
  selectedProfile = undefined;
116
98
  },
117
- switchToRouterProfile: async (
118
- profileName: string,
119
- ctx: ExtensionContext,
120
- strict = true,
121
- ) => {
99
+ switchToRouterProfile: async (profileName: string, ctx: ExtensionContext, strict = true) => {
122
100
  if (!currentConfig.profiles[profileName]) {
123
101
  if (strict) {
124
102
  ctx.ui.notify(`Unknown router profile: ${profileName}`, 'error');
@@ -146,7 +124,11 @@ const routerExtension = (pi: ExtensionAPI) => {
146
124
  selectedProfile = profileName;
147
125
  routerEnabled = true;
148
126
  persistState();
149
- pi.setThinkingLevel('off');
127
+ try {
128
+ pi.setThinkingLevel('off');
129
+ } catch {
130
+ // Stale context
131
+ }
150
132
  actions.updateStatus(ctx);
151
133
  return true;
152
134
  },
@@ -217,39 +199,26 @@ const routerExtension = (pi: ExtensionAPI) => {
217
199
  await new Promise((resolve) => setTimeout(resolve, 50));
218
200
 
219
201
  routerEnabled = ctx.model?.provider === 'router';
220
- selectedProfile = resolveProfileName(
221
- currentConfig,
222
- ctx.model?.provider === 'router' ? ctx.model.id : selectedProfile,
223
- );
202
+ selectedProfile = resolveProfileName(currentConfig, ctx.model?.provider === 'router' ? ctx.model.id : selectedProfile);
224
203
  pinnedTierByProfile = {};
225
204
  lastNonRouterModel =
226
- ctx.model && ctx.model.provider !== 'router'
227
- ? `${ctx.model.provider}/${ctx.model.id}`
228
- : lastNonRouterModel;
205
+ ctx.model && ctx.model.provider !== 'router' ? `${ctx.model.provider}/${ctx.model.id}` : lastNonRouterModel;
229
206
 
230
207
  const entries = ctx.sessionManager.getBranch() as CustomSessionEntry[];
231
208
  const savedState = entries
232
- .filter(
233
- (entry) =>
234
- entry.type === 'custom' && entry.customType === 'router-state',
235
- )
209
+ .filter((entry) => entry.type === 'custom' && entry.customType === 'router-state')
236
210
  .map((entry) => entry.data)
237
211
  .findLast((data) => isRouterPersistedState(data));
238
212
 
239
213
  if (isRouterPersistedState(savedState)) {
240
- selectedProfile = resolveProfileName(
241
- currentConfig,
242
- savedState.selectedProfile,
243
- );
214
+ selectedProfile = resolveProfileName(currentConfig, savedState.selectedProfile);
244
215
  if (!selectedProfile) {
245
216
  routerEnabled = false;
246
217
  } else {
247
218
  routerEnabled = savedState.enabled;
248
219
  }
249
220
  lastDecision = savedState.lastDecision;
250
- pinnedTierByProfile = savedState.pinByProfile
251
- ? { ...savedState.pinByProfile }
252
- : {};
221
+ pinnedTierByProfile = savedState.pinByProfile ? { ...savedState.pinByProfile } : {};
253
222
  if (savedState.pinTier && selectedProfile) {
254
223
  pinnedTierByProfile[selectedProfile] = savedState.pinTier;
255
224
  }
@@ -319,7 +288,7 @@ const routerExtension = (pi: ExtensionAPI) => {
319
288
  set debugEnabled(v) {
320
289
  debugEnabled = v;
321
290
  },
322
-
291
+
323
292
  get lastConfigWarnings() {
324
293
  return lastConfigWarnings;
325
294
  },
@@ -327,6 +296,15 @@ const routerExtension = (pi: ExtensionAPI) => {
327
296
  actions,
328
297
  );
329
298
 
299
+ const ensureInitializedFromContext = (ctx: ExtensionContext) => {
300
+ if (!currentModelRegistry) {
301
+ currentModelRegistry = ctx.modelRegistry;
302
+ lastExtensionContext = ctx;
303
+ currentCwd = ctx.cwd;
304
+ actions.reloadConfig(ctx);
305
+ }
306
+ };
307
+
330
308
  pi.on('session_start', async (_event, ctx) => {
331
309
  await restoreStateFromSession(ctx);
332
310
  isInitialized = true;
@@ -336,14 +314,16 @@ const routerExtension = (pi: ExtensionAPI) => {
336
314
  }
337
315
 
338
316
  if (debugEnabled) {
339
- ctx.ui.notify(
340
- `Router initialized with profiles: ${profileNames(currentConfig).join(', ')}`,
341
- 'info',
342
- );
317
+ ctx.ui.notify(`Router initialized with profiles: ${profileNames(currentConfig).join(', ')}`, 'info');
343
318
  }
344
319
  });
345
320
 
321
+ pi.on('turn_start', async (_event, ctx) => {
322
+ ensureInitializedFromContext(ctx);
323
+ });
324
+
346
325
  pi.on('model_select', async (event, ctx) => {
326
+ ensureInitializedFromContext(ctx);
347
327
  if (!isInitialized || isRouterDelegating) return;
348
328
  if (event.model.provider === 'router') {
349
329
  const profileName = resolveProfileName(currentConfig, event.model.id);
@@ -357,8 +337,7 @@ const routerExtension = (pi: ExtensionAPI) => {
357
337
  const registryModel = ctx.modelRegistry.find('router', profileName);
358
338
  if (
359
339
  registryModel &&
360
- (registryModel.contextWindow !== event.model.contextWindow ||
361
- registryModel.maxTokens !== event.model.maxTokens)
340
+ (registryModel.contextWindow !== event.model.contextWindow || registryModel.maxTokens !== event.model.maxTokens)
362
341
  ) {
363
342
  await setModelInternally(registryModel);
364
343
  }
@@ -375,19 +354,28 @@ const routerExtension = (pi: ExtensionAPI) => {
375
354
  });
376
355
 
377
356
  pi.on('turn_end', async (_event, ctx) => {
357
+ ensureInitializedFromContext(ctx);
378
358
  if (routerEnabled && selectedProfile && ctx.model?.provider !== 'router') {
379
359
  const routerModel = ctx.modelRegistry.find('router', selectedProfile);
380
360
  if (routerModel) {
381
361
  const success = await setModelInternally(routerModel);
382
362
  if (!success) {
383
- ctx.ui.notify('Failed to re-assert router model after turn. Router disabled.', 'warning');
363
+ try {
364
+ ctx.ui.notify('Failed to re-assert router model after turn. Router disabled.', 'warning');
365
+ } catch {
366
+ // Stale context
367
+ }
384
368
  routerEnabled = false;
385
369
  selectedProfile = undefined;
386
370
  }
387
371
  }
388
372
  }
389
373
  persistState();
390
- actions.updateStatus(ctx);
374
+ try {
375
+ actions.updateStatus(ctx);
376
+ } catch {
377
+ // Stale context
378
+ }
391
379
  });
392
380
  };
393
381
 
@@ -5,15 +5,56 @@ import {
5
5
  type Context,
6
6
  createAssistantMessageEventStream,
7
7
  type Model,
8
+ type ProviderHeaders,
8
9
  type SimpleStreamOptions,
9
10
  } from '@earendil-works/pi-ai';
10
- import { streamSimple } from '@earendil-works/pi-ai/compat';
11
11
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
12
12
  import type { RouterConfig, RouterPinByProfile, RouterTier, RoutingDecision } from './types';
13
- import { createOpenRouterOnPayload, OPENROUTER_ATTR_HEADERS, parseCanonicalModelRef, profileNames, resolveModelFromRef, ROUTER_TIERS } from './config';
14
- import { buildRoutingDecision, countToolResultsSinceLastUserPrompt, extractTextFromContent, runClassifier, shouldRunClassifier } from './routing';
13
+ import {
14
+ createOpenRouterOnPayload,
15
+ dispatchStream,
16
+ OPENROUTER_ATTR_HEADERS,
17
+ parseCanonicalModelRef,
18
+ profileNames,
19
+ resolveModelFromRef,
20
+ ROUTER_TIERS,
21
+ } from './config';
22
+ import {
23
+ buildRoutingDecision,
24
+ countToolResultsSinceLastUserPrompt,
25
+ extractTextFromContent,
26
+ runClassifier,
27
+ shouldRunClassifier,
28
+ } from './routing';
15
29
  import { formatDecision } from './ui';
16
30
 
31
+ const REGISTRY_WAIT_TIMEOUT_MS = 5000;
32
+ const REGISTRY_WAIT_INITIAL_DELAY_MS = 50;
33
+ const REGISTRY_WAIT_MAX_DELAY_MS = 500;
34
+
35
+ /**
36
+ * Wait for the model registry to become available with exponential backoff.
37
+ * This handles race conditions where subagents invoke the router provider
38
+ * before session_start has fired in their context.
39
+ */
40
+ export const waitForRegistry = async (
41
+ state: {
42
+ readonly currentModelRegistry: ExtensionContext['modelRegistry'] | undefined;
43
+ },
44
+ timeoutMs: number = REGISTRY_WAIT_TIMEOUT_MS,
45
+ ): Promise<ExtensionContext['modelRegistry'] | undefined> => {
46
+ if (state.currentModelRegistry) return state.currentModelRegistry;
47
+
48
+ const start = Date.now();
49
+ let delay = REGISTRY_WAIT_INITIAL_DELAY_MS;
50
+ while (Date.now() - start < timeoutMs) {
51
+ await new Promise((resolve) => setTimeout(resolve, delay));
52
+ if (state.currentModelRegistry) return state.currentModelRegistry;
53
+ delay = Math.min(delay * 2, REGISTRY_WAIT_MAX_DELAY_MS);
54
+ }
55
+ return undefined;
56
+ };
57
+
17
58
  const createErrorMessage = (model: Model<Api>, message: string): AssistantMessage => {
18
59
  return {
19
60
  role: 'assistant',
@@ -40,7 +81,7 @@ const estimateTokens = (text: string): number => Math.ceil(text.length / 4);
40
81
 
41
82
  /**
42
83
  * Truncate context to fit within a target token limit by removing oldest messages.
43
- * Always preserves the first system message and the latest user message.
84
+ * Preserves the first system message and the latest user message, ensuring no orphaned tool results.
44
85
  */
45
86
  const truncateContext = (context: Context, limit: number): Context => {
46
87
  const messages = [...context.messages];
@@ -53,12 +94,19 @@ const truncateContext = (context: Context, limit: number): Context => {
53
94
  const latestMessage = messages.pop()!;
54
95
  const latestTokens = estimateTokens(extractTextFromContent(latestMessage.content));
55
96
 
56
- let runningTotal = systemTokens + latestTokens + messages.reduce((sum, m) => sum + estimateTokens(extractTextFromContent(m.content)), 0);
97
+ let runningTotal =
98
+ systemTokens + latestTokens + messages.reduce((sum, m) => sum + estimateTokens(extractTextFromContent(m.content)), 0);
57
99
  while (messages.length > 0 && runningTotal > limit) {
58
100
  const shifted = messages.shift()!;
59
101
  runningTotal -= estimateTokens(extractTextFromContent(shifted.content));
60
102
  }
61
103
 
104
+ // Ensure the truncated context starts cleanly at a user prompt, rather than an orphaned toolResult
105
+ while (messages.length > 0 && messages[0].role === 'toolResult') {
106
+ const dropped = messages.shift()!;
107
+ runningTotal -= estimateTokens(extractTextFromContent(dropped.content));
108
+ }
109
+
62
110
  return { ...context, messages: [...messages, latestMessage] };
63
111
  };
64
112
 
@@ -123,9 +171,7 @@ export const registerRouterProvider = (
123
171
  if (!tierConfig) return false;
124
172
  return (
125
173
  resolveModelFromRef(tierConfig.model, state.currentModelRegistry)?.reasoning ||
126
- tierConfig.fallbacks?.some(
127
- (fb) => resolveModelFromRef(fb, state.currentModelRegistry)?.reasoning,
128
- )
174
+ tierConfig.fallbacks?.some((fb) => resolveModelFromRef(fb, state.currentModelRegistry)?.reasoning)
129
175
  );
130
176
  })
131
177
  : false;
@@ -142,9 +188,9 @@ export const registerRouterProvider = (
142
188
  });
143
189
 
144
190
  if (state.currentModelRegistry) {
145
- const invalidOverrides = Object.keys(
146
- state.currentConfig.contextThresholdPercentOverrides ?? {},
147
- ).filter((modelRef) => !resolveModelFromRef(modelRef, state.currentModelRegistry));
191
+ const invalidOverrides = Object.keys(state.currentConfig.contextThresholdPercentOverrides ?? {}).filter(
192
+ (modelRef) => !resolveModelFromRef(modelRef, state.currentModelRegistry),
193
+ );
148
194
 
149
195
  if (invalidOverrides.length > 0) {
150
196
  state.lastExtensionContext?.ui.notify(
@@ -157,25 +203,21 @@ export const registerRouterProvider = (
157
203
  const loadedModelKeys = models.map((m) => `${m.id}:${m.contextWindow}:${m.maxTokens}:${m.reasoning}`).join(',');
158
204
  if (state.lastLoadedModelKeys === loadedModelKeys) return; // models did not change, no need to re-register
159
205
 
160
- pi.registerProvider(
161
- 'router', { // config (baseUrl, apiKey, ...)
206
+ pi.registerProvider('router', {
207
+ // config (baseUrl, apiKey, ...)
162
208
  baseUrl: 'router://local',
163
209
  apiKey: 'pi-model-router',
164
210
  api: 'router-local-api',
165
211
  models,
166
- streamSimple: (
167
- model: Model<Api>,
168
- context: Context,
169
- options?: SimpleStreamOptions,
170
- ): AssistantMessageEventStream => {
212
+ streamSimple: (model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream => {
171
213
  const stream = createAssistantMessageEventStream();
172
214
  const ctx = state.lastExtensionContext;
173
- const modelRegistry = state.currentModelRegistry;
174
215
 
175
216
  (async () => {
176
217
  try {
218
+ const modelRegistry = await waitForRegistry(state);
177
219
  if (!modelRegistry) {
178
- throw new Error('Router provider not initialized yet. Wait for session_start and retry.');
220
+ throw new Error('Router provider initialization timed out. session_start may not have fired.');
179
221
  }
180
222
  const profile = currentConfig.profiles[model.id];
181
223
  if (!profile) {
@@ -200,7 +242,8 @@ export const registerRouterProvider = (
200
242
  lastDecision?.profile === model.id &&
201
243
  lastDecision?.targetProvider === 'google' &&
202
244
  lastDecision?.thinking !== 'off';
203
- if (isGoogleContinuation) { // Google thinking lock — preserve exact model on tool-result continuations
245
+ if (isGoogleContinuation) {
246
+ // Google thinking lock — preserve exact model on tool-result continuations
204
247
  const toolResultsCount = countToolResultsSinceLastUserPrompt(context);
205
248
  decision = {
206
249
  ...lastDecision!,
@@ -216,7 +259,15 @@ export const registerRouterProvider = (
216
259
  if (currentConfig.classifierModels?.length && !pinnedTier) {
217
260
  const toolResultsCount = countToolResultsSinceLastUserPrompt(context);
218
261
 
219
- bShouldRunClassifier = shouldRunClassifier(currentConfig, context, lastDecision, lastMsgWasTool, toolResultsCount, state.debugEnabled, ctx);
262
+ bShouldRunClassifier = shouldRunClassifier(
263
+ currentConfig,
264
+ context,
265
+ lastDecision,
266
+ lastMsgWasTool,
267
+ toolResultsCount,
268
+ state.debugEnabled,
269
+ ctx,
270
+ );
220
271
  const classifierResult = bShouldRunClassifier
221
272
  ? await runClassifier(currentConfig, modelRegistry, context, lastDecision, ctx, state.debugEnabled)
222
273
  : null;
@@ -295,7 +346,8 @@ export const registerRouterProvider = (
295
346
 
296
347
  const thresholdPercent =
297
348
  currentConfig.contextThresholdPercentOverrides?.[modelRef] ??
298
- currentConfig.defaultContextThresholdPercent ?? 90;
349
+ currentConfig.defaultContextThresholdPercent ??
350
+ 90;
299
351
  const targetContextWindow = targetModel.contextWindow || 200_000;
300
352
  const targetContextLimit = Math.floor((thresholdPercent / 100) * targetContextWindow);
301
353
  const fitsContext = tokensUsed <= targetContextLimit;
@@ -315,7 +367,9 @@ export const registerRouterProvider = (
315
367
 
316
368
  if (tier !== decision.tier) {
317
369
  decision = buildRoutingDecision(
318
- model.id, profile, tier,
370
+ model.id,
371
+ profile,
372
+ tier,
319
373
  `Forced ${tier} tier because ${decision.tier} tier lacks models${triggerReasons ? ` for ${triggerReasons}` : ''}.`,
320
374
  decision.lastClassifierRunToolCount,
321
375
  );
@@ -330,10 +384,14 @@ export const registerRouterProvider = (
330
384
  decision.isContextTriggered = !fitsContext;
331
385
 
332
386
  if (ctx) {
333
- if (state.debugEnabled && bShouldRunClassifier) {
334
- ctx.ui.notify(`Decision ${formatDecision(decision)}`, 'info');
387
+ try {
388
+ if (state.debugEnabled && bShouldRunClassifier) {
389
+ ctx.ui.notify(`Decision ${formatDecision(decision)}`, 'info');
390
+ }
391
+ actions.updateStatus(ctx);
392
+ } catch {
393
+ // Stale extension context
335
394
  }
336
- actions.updateStatus(ctx);
337
395
  }
338
396
 
339
397
  const auth = await modelRegistry.getApiKeyAndHeaders(targetModel);
@@ -351,20 +409,28 @@ export const registerRouterProvider = (
351
409
  let effectiveContext = context;
352
410
  if (!fitsContext) {
353
411
  effectiveContext = truncateContext(context, targetContextLimit);
354
- ctx?.ui.notify(`Memory too large for ${modelRef} — trimmed ${context.messages.length - effectiveContext.messages.length} messages. Run /compact to reduce context size.`, 'warning');
412
+ ctx?.ui.notify(
413
+ `Memory too large for ${modelRef} — trimmed ${context.messages.length - effectiveContext.messages.length} messages. Run /compact to reduce context size.`,
414
+ 'warning',
415
+ );
355
416
  }
356
417
 
357
418
  // Attention: stripping reasoning from pi's incoming options so it doesn't leak into ...baseOptions. The router controls this via delegatedReasoning below.
358
419
  const { onPayload, headers: originalHeaders, reasoning: _incomingReasoning, ...baseOptions } = options ?? {};
359
420
 
360
- const effectiveHeaders: Record<string, string> = {
361
- ...(originalHeaders as Record<string, string> | undefined),
362
- ...(auth.headers ?? {}),
421
+ const effectiveHeaders: ProviderHeaders = {
422
+ ...targetModel.headers,
363
423
  ...(targetProvider === 'openrouter' ? OPENROUTER_ATTR_HEADERS : {}),
424
+ ...auth.headers,
425
+ ...originalHeaders,
364
426
  };
365
427
 
366
428
  const delegatedReasoning = targetModel.reasoning && decision.thinking !== 'off' ? decision.thinking : undefined;
367
- pi.setThinkingLevel(delegatedReasoning ?? 'off');
429
+ try {
430
+ pi.setThinkingLevel(delegatedReasoning ?? 'off');
431
+ } catch {
432
+ // Stale extension context after session teardown
433
+ }
368
434
 
369
435
  const effectiveOptions: SimpleStreamOptions = {
370
436
  ...baseOptions,
@@ -379,10 +445,16 @@ export const registerRouterProvider = (
379
445
  effectiveOptions.onPayload = onPayload;
380
446
  }
381
447
 
448
+ // Apply credential-specific baseUrl override if resolved by getApiKeyAndHeaders
449
+ // (e.g. GitHub Copilot business/enterprise tenant endpoints) to avoid 421 Misdirected Request.
450
+ const authBaseUrl = (auth as { baseUrl?: string }).baseUrl;
451
+ const requestModel = authBaseUrl ? { ...targetModel, baseUrl: authBaseUrl } : targetModel;
452
+
453
+ let eventsPushed = 0;
382
454
  const MAX_ATTEMPTS_PER_MODEL = 2;
383
455
  for (let attempt = 1; attempt <= MAX_ATTEMPTS_PER_MODEL; attempt++) {
384
456
  try {
385
- const delegatedStream = streamSimple(targetModel, effectiveContext, effectiveOptions);
457
+ const delegatedStream = dispatchStream(requestModel, effectiveContext, effectiveOptions, modelRegistry);
386
458
  let contentReceived = false;
387
459
  for await (const event of delegatedStream) {
388
460
  if (event.type === 'error' && !contentReceived) {
@@ -397,15 +469,23 @@ export const registerRouterProvider = (
397
469
  contentReceived = true;
398
470
  }
399
471
  stream.push(event);
472
+ eventsPushed++;
400
473
  }
401
474
  success = true;
402
475
  state.lastDecision = decision;
403
476
  break attemptLoop;
404
477
  } catch (err) {
405
478
  lastError = err;
479
+ if (eventsPushed > 0) {
480
+ // Stream was already partially transmitted to the consumer; cannot retry or fall back safely.
481
+ throw err;
482
+ }
406
483
  const remaining = MAX_ATTEMPTS_PER_MODEL - attempt;
407
484
  const retryMsg = remaining > 0 ? ` — ${remaining} ${remaining === 1 ? 'retry' : 'retries'} left` : '';
408
- ctx?.ui.notify(`Failed to reach model ${modelRef} (attempt ${attempt}/${MAX_ATTEMPTS_PER_MODEL}): ${err}${retryMsg}`, 'warning');
485
+ ctx?.ui.notify(
486
+ `Failed to reach model ${modelRef} (attempt ${attempt}/${MAX_ATTEMPTS_PER_MODEL}): ${err}${retryMsg}`,
487
+ 'warning',
488
+ );
409
489
  }
410
490
  }
411
491
  }
@@ -414,20 +494,34 @@ export const registerRouterProvider = (
414
494
 
415
495
  if (!success) {
416
496
  const errorMsg = `Failed to delegate to any model in the chain.${failureReasons.length > 0 ? ' Reasons: ' + failureReasons.filter(Boolean).join('; ') + '.' : ''}`;
417
- const combinedError = lastError ? new Error(`${(lastError as Error).message} ${errorMsg}`) : new Error(errorMsg);
497
+ const lastErrorText = lastError instanceof Error ? lastError.message : String(lastError ?? '');
498
+ const combinedError = lastError ? new Error(`${lastErrorText} — ${errorMsg}`) : new Error(errorMsg);
418
499
  throw combinedError;
419
500
  }
420
501
 
421
502
  stream.end();
422
503
  } catch (error) {
423
- stream.push({
424
- type: 'error',
425
- reason: 'error',
426
- error: createErrorMessage(model, error instanceof Error ? error.message : String(error)),
427
- });
504
+ const isStaleCtx = error instanceof Error && error.message.includes('stale');
505
+ if (isStaleCtx) {
506
+ stream.push({
507
+ type: 'done',
508
+ reason: 'stop',
509
+ message: createErrorMessage(model, ''),
510
+ });
511
+ } else {
512
+ stream.push({
513
+ type: 'error',
514
+ reason: 'error',
515
+ error: createErrorMessage(model, error instanceof Error ? error.message : String(error)),
516
+ });
517
+ }
428
518
  stream.end();
429
519
  } finally {
430
- actions.persistState();
520
+ try {
521
+ actions.persistState();
522
+ } catch {
523
+ // Ignore: extension context may be stale after session teardown.
524
+ }
431
525
  }
432
526
  })();
433
527
 
@@ -1,8 +1,13 @@
1
- import { streamSimple } from '@earendil-works/pi-ai/compat';
2
- import type { Context, Message } from '@earendil-works/pi-ai';
1
+ import type { Context, Message, ProviderHeaders, SimpleStreamOptions } from '@earendil-works/pi-ai';
3
2
  import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
4
3
  import type { RouterConfig, RouterProfile, RouterTier, RoutingDecision } from './types';
5
- import { createOpenRouterOnPayload, OPENROUTER_ATTR_HEADERS, parseCanonicalModelRef, resolveModelFromRef } from './config';
4
+ import {
5
+ createOpenRouterOnPayload,
6
+ dispatchStream,
7
+ OPENROUTER_ATTR_HEADERS,
8
+ parseCanonicalModelRef,
9
+ resolveModelFromRef,
10
+ } from './config';
6
11
 
7
12
  export const extractTextFromContent = (content: string | Message['content']): string => {
8
13
  if (typeof content === 'string') {
@@ -27,7 +32,8 @@ const lastUserMessage = (context: Context): Message | undefined => {
27
32
  return undefined;
28
33
  };
29
34
 
30
- const escapeXML = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
35
+ const escapeXML = (s: string): string =>
36
+ s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
31
37
 
32
38
  const getRecentConversationText = (context: Context, limit: number): string => {
33
39
  const messages = context.messages.slice(-limit);
@@ -239,25 +245,32 @@ export const runClassifier = async (
239
245
 
240
246
  const isOpenRouter = parseCanonicalModelRef(classifierModelRef).provider === 'openrouter';
241
247
 
242
- const classifierOptions: Record<string, unknown> = {
248
+ const effectiveClassifierHeaders: ProviderHeaders = {
249
+ ...model.headers,
250
+ ...(isOpenRouter ? OPENROUTER_ATTR_HEADERS : {}),
251
+ ...auth.headers,
252
+ };
253
+
254
+ const classifierOptions: SimpleStreamOptions = {
243
255
  apiKey: auth.apiKey,
244
- headers: {
245
- ...(auth.headers ?? {}),
246
- ...(isOpenRouter ? OPENROUTER_ATTR_HEADERS : {}),
247
- },
256
+ headers: effectiveClassifierHeaders,
248
257
  ...(thinking && thinking !== 'off' ? { reasoning: thinking } : {}),
249
258
  };
250
259
 
251
-
252
260
  if (isOpenRouter) {
253
261
  const onPayload = createOpenRouterOnPayload(extCtx?.sessionManager);
254
262
  if (onPayload) classifierOptions.onPayload = onPayload;
255
263
  }
256
264
 
265
+ // Apply credential-specific baseUrl override if resolved by getApiKeyAndHeaders
266
+ // (e.g. GitHub Copilot business/enterprise tenant endpoints) to avoid 421 Misdirected Request.
267
+ const authBaseUrl = (auth as { baseUrl?: string }).baseUrl;
268
+ const requestModel = authBaseUrl ? { ...model, baseUrl: authBaseUrl } : model;
269
+
257
270
  const MAX_CLASSIFIER_ATTEMPTS = 3;
258
271
  for (let attempt = 1; attempt <= MAX_CLASSIFIER_ATTEMPTS; attempt++) {
259
272
  try {
260
- const stream = streamSimple(model, classifierContext, classifierOptions);
273
+ const stream = dispatchStream(requestModel, classifierContext, classifierOptions, modelRegistry);
261
274
  let fullText = '';
262
275
  for await (const event of stream) {
263
276
  if (event.type === 'error') throw new Error(event.error?.errorMessage ?? 'Unknown classifier error');
@@ -1,21 +1,11 @@
1
- import type {
2
- RouterPinByProfile,
3
- RoutingDecision,
4
- RouterPersistedState,
5
- } from './types';
1
+ import type { RouterPinByProfile, RoutingDecision, RouterPersistedState } from './types';
6
2
 
7
- export const isRouterPersistedState = (
8
- value: unknown,
9
- ): value is RouterPersistedState => {
3
+ export const isRouterPersistedState = (value: unknown): value is RouterPersistedState => {
10
4
  if (typeof value !== 'object' || value === null) {
11
5
  return false;
12
6
  }
13
7
  const v = value as Record<string, unknown>;
14
- return (
15
- typeof v.enabled === 'boolean' &&
16
- typeof v.selectedProfile === 'string' &&
17
- typeof v.timestamp === 'number'
18
- );
8
+ return typeof v.enabled === 'boolean' && typeof v.selectedProfile === 'string' && typeof v.timestamp === 'number';
19
9
  };
20
10
 
21
11
  export const buildPersistedState = (
package/extensions/ui.ts CHANGED
@@ -1,8 +1,5 @@
1
1
  import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
2
- import type {
3
- RoutingDecision,
4
- RouterPinByProfile,
5
- } from './types';
2
+ import type { RoutingDecision, RouterPinByProfile } from './types';
6
3
 
7
4
  const getDecisionFlags = (decision: RoutingDecision): string[] => {
8
5
  const flags: string[] = [];
@@ -15,9 +12,7 @@ export const formatDecision = (d: RoutingDecision): string => {
15
12
  return `[${new Date(d.timestamp).toLocaleTimeString()}] ${d.tier} -> ${d.targetProvider}/${d.targetModelId} (${d.thinking}) - ${d.reasoning}`;
16
13
  };
17
14
 
18
- export const formatPinSummary = (
19
- pinnedTierByProfile: RouterPinByProfile,
20
- ): string => {
15
+ export const formatPinSummary = (pinnedTierByProfile: RouterPinByProfile): string => {
21
16
  const entries = Object.entries(pinnedTierByProfile)
22
17
  .sort(([a], [b]) => a.localeCompare(b))
23
18
  .map(([profile, tier]) => `${profile}:${tier}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kdejaeger/pi-model-router",
3
- "version": "0.4.2",
3
+ "version": "0.4.5",
4
4
  "type": "module",
5
5
  "description": "Intelligent per-turn model router extension for the pi coding agent",
6
6
  "keywords": [
@@ -42,14 +42,14 @@
42
42
  "prepublishOnly": "npm run tsc"
43
43
  },
44
44
  "peerDependencies": {
45
- "@earendil-works/pi-agent-core": "^0.80.6",
46
- "@earendil-works/pi-ai": "^0.80.6",
47
- "@earendil-works/pi-coding-agent": "^0.80.6",
48
- "@earendil-works/pi-tui": "^0.80.6",
45
+ "@earendil-works/pi-agent-core": "^0.80",
46
+ "@earendil-works/pi-ai": "^0.80",
47
+ "@earendil-works/pi-coding-agent": "^0.80",
48
+ "@earendil-works/pi-tui": "^0.80",
49
49
  "@sinclair/typebox": "*"
50
50
  },
51
51
  "devDependencies": {
52
- "prettier": "^3.8.1",
53
- "typescript": "^6.0.2"
52
+ "prettier": "^3.8",
53
+ "typescript": "^6"
54
54
  }
55
55
  }