@alexeiled/pi-model-router 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,20 +1,20 @@
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,
17
16
  } from '@earendil-works/pi-coding-agent';
17
+ import { runClassifier } from './classifier';
18
18
  import {
19
19
  clampThinkingLevel,
20
20
  collectProfileThinkingLevels,
@@ -25,13 +25,14 @@ import {
25
25
  resolveContextWindow,
26
26
  resolveMaxTokens,
27
27
  } from './config';
28
+ import { DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS } from './constants';
29
+ import { extractTextFromContent, hasImageAttachment } from './context';
28
30
  import {
29
- DEFAULT_CONTEXT_WINDOW,
30
- DEFAULT_MAX_TOKENS,
31
- hasUsableRequestAuth,
32
- type RegistryWithProviderAuth,
33
- resolveDelegatedModel,
34
- } from './constants';
31
+ buildRoutingDecision,
32
+ decideRouting,
33
+ phaseForTier,
34
+ resolveAvailableTier,
35
+ } from './routing';
35
36
  import type {
36
37
  RouterConfig,
37
38
  RouterPinByProfile,
@@ -44,17 +45,6 @@ const REGISTRY_WAIT_TIMEOUT_MS = 5000;
44
45
  const REGISTRY_WAIT_INITIAL_DELAY_MS = 50;
45
46
  const REGISTRY_WAIT_MAX_DELAY_MS = 500;
46
47
 
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
48
  /**
59
49
  * Wait for the model registry to become available with exponential backoff.
60
50
  * This handles the race condition where subagents (e.g. from pi-dynamic-workflows)
@@ -67,28 +57,25 @@ export const waitForRegistry = async (
67
57
  | undefined;
68
58
  },
69
59
  timeoutMs: number = REGISTRY_WAIT_TIMEOUT_MS,
60
+ signal?: AbortSignal,
70
61
  ): Promise<ExtensionContext['modelRegistry'] | undefined> => {
62
+ signal?.throwIfAborted();
71
63
  if (state.currentModelRegistry) return state.currentModelRegistry;
72
64
 
73
65
  const start = Date.now();
74
- let delay = REGISTRY_WAIT_INITIAL_DELAY_MS;
66
+ let interval = REGISTRY_WAIT_INITIAL_DELAY_MS;
75
67
  while (Date.now() - start < timeoutMs) {
76
- await new Promise((resolve) => setTimeout(resolve, delay));
68
+ await delay(
69
+ Math.min(interval, timeoutMs - (Date.now() - start)),
70
+ undefined,
71
+ { signal },
72
+ );
77
73
  if (state.currentModelRegistry) return state.currentModelRegistry;
78
- delay = Math.min(delay * 2, REGISTRY_WAIT_MAX_DELAY_MS);
74
+ interval = Math.min(interval * 2, REGISTRY_WAIT_MAX_DELAY_MS);
79
75
  }
80
76
  return undefined;
81
77
  };
82
78
 
83
- import {
84
- buildRoutingDecision,
85
- decideRouting,
86
- extractTextFromContent,
87
- hasImageAttachment,
88
- phaseForTier,
89
- runClassifier,
90
- } from './routing';
91
-
92
79
  export const createErrorMessage = (
93
80
  model: Model<Api>,
94
81
  message: string,
@@ -120,7 +107,7 @@ const estimateTokens = (text: string): number => Math.ceil(text.length / 3);
120
107
 
121
108
  /**
122
109
  * 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.
110
+ * Preserves the system prompt and the complete latest user/tool turn.
124
111
  */
125
112
  const truncateContext = (context: Context, limit: number): Context => {
126
113
  const messages = [...context.messages];
@@ -139,24 +126,32 @@ const truncateContext = (context: Context, limit: number): Context => {
139
126
 
140
127
  if (totalTokens <= limit) return context;
141
128
 
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
-
149
- 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++;
129
+ // Drop only complete turns. Splitting an assistant/tool-result pair corrupts transcripts.
130
+ // This text estimate cannot guarantee a fit for images/tools or one oversized active turn.
131
+ const systemMessages = messages.filter(
132
+ (message) => message.role === 'system',
133
+ );
134
+ let remaining = totalTokens;
135
+ let nextRemovableIndex = 0;
136
+ for (let i = 0; i < messages.length && remaining > limit; i += 1) {
137
+ if (messages[i]?.role !== 'user') continue;
138
+ while (nextRemovableIndex < i) {
139
+ const candidate = messages[nextRemovableIndex];
140
+ if (candidate?.role !== 'system') {
141
+ remaining -= messageTokens[nextRemovableIndex] ?? 0;
142
+ }
143
+ nextRemovableIndex += 1;
144
+ }
156
145
  }
157
-
158
- const finalMessages = [...messages.slice(startIndex), latestMessage];
159
- return { ...context, messages: finalMessages };
146
+ return {
147
+ ...context,
148
+ messages: [
149
+ ...systemMessages,
150
+ ...messages
151
+ .slice(nextRemovableIndex)
152
+ .filter((message) => message.role !== 'system'),
153
+ ],
154
+ };
160
155
  };
161
156
 
162
157
  const supportsReasoning = (
@@ -213,24 +208,29 @@ export const registerRouterProvider = (
213
208
  const profileList = profileNames(state.currentConfig);
214
209
 
215
210
  // Map profiles to their capacities
216
- const modelDefinitions = profileList.map((name) => {
211
+ const modelDefinitions = profileList.flatMap((name) => {
217
212
  const profile = state.currentConfig.profiles[name];
213
+ if (!profile) return [];
218
214
 
219
215
  // Report the MAX context window and max output tokens across all tiers.
220
216
  // The honesty check + truncateContext handles the case where the
221
217
  // actually routed model is smaller.
222
- let maxContextWindow = DEFAULT_CONTEXT_WINDOW;
223
- let maxMaxTokens = DEFAULT_MAX_TOKENS;
218
+ let maxContextWindow = 0;
219
+ let maxOutputTokens = 0;
224
220
  for (const tier of ROUTER_TIERS) {
225
221
  if (!profile[tier]) continue;
226
- const cw = resolveContextWindow(
222
+ const contextWindow = resolveContextWindow(
227
223
  tier,
228
224
  profile,
229
225
  state.currentModelRegistry,
230
226
  );
231
- const mot = resolveMaxTokens(tier, profile, state.currentModelRegistry);
232
- if (cw > maxContextWindow) maxContextWindow = cw;
233
- if (mot > maxMaxTokens) maxMaxTokens = mot;
227
+ const maxTokens = resolveMaxTokens(
228
+ tier,
229
+ profile,
230
+ state.currentModelRegistry,
231
+ );
232
+ if (contextWindow > maxContextWindow) maxContextWindow = contextWindow;
233
+ if (maxTokens > maxOutputTokens) maxOutputTokens = maxTokens;
234
234
  }
235
235
 
236
236
  const hasReasoning = supportsReasoning(profile, state.currentModelRegistry);
@@ -245,21 +245,21 @@ export const registerRouterProvider = (
245
245
  if (Object.keys(map).length > 0) thinkingLevelMap = map;
246
246
  }
247
247
 
248
- return {
249
- id: name,
250
- name: `Router ${name}`,
251
- reasoning: hasReasoning,
252
- ...(thinkingLevelMap ? { thinkingLevelMap } : {}),
253
- input: ['text', 'image'] as ('text' | 'image')[],
254
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
255
- contextWindow: maxContextWindow,
256
- maxTokens: maxMaxTokens,
257
- };
248
+ return [
249
+ {
250
+ id: name,
251
+ name: `Router ${name}`,
252
+ reasoning: hasReasoning,
253
+ ...(thinkingLevelMap ? { thinkingLevelMap } : {}),
254
+ input: ['text', 'image'] satisfies ('text' | 'image')[],
255
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
256
+ contextWindow: maxContextWindow || DEFAULT_CONTEXT_WINDOW,
257
+ maxTokens: maxOutputTokens || DEFAULT_MAX_TOKENS,
258
+ },
259
+ ];
258
260
  });
259
261
 
260
- const modelsKey = modelDefinitions
261
- .map((m) => `${m.id}:${m.contextWindow}:${m.maxTokens}:${m.reasoning}`)
262
- .join(',');
262
+ const modelsKey = JSON.stringify(modelDefinitions);
263
263
  if (state.lastRegisteredModels === modelsKey) return;
264
264
 
265
265
  pi.registerProvider('router', {
@@ -274,7 +274,8 @@ export const registerRouterProvider = (
274
274
  ): AssistantMessageEventStream {
275
275
  const stream = createAssistantMessageEventStream();
276
276
 
277
- (async () => {
277
+ void (async () => {
278
+ let partialMessage: AssistantMessage | undefined;
278
279
  try {
279
280
  // Wait for the router to be fully initialized (session_start sets currentModelRegistry).
280
281
  // This handles the race where subagents (e.g. from pi-dynamic-workflows) invoke
@@ -282,6 +283,7 @@ export const registerRouterProvider = (
282
283
  const registry = await waitForRegistry(
283
284
  state,
284
285
  state.registryTimeoutMs,
286
+ options?.signal,
285
287
  );
286
288
  if (!registry) {
287
289
  throw new Error(
@@ -325,15 +327,20 @@ export const registerRouterProvider = (
325
327
  state.currentConfig.classifierModel.model,
326
328
  registry,
327
329
  context,
328
- state.lastDecision?.phase,
330
+ state.lastDecision?.profile === model.id
331
+ ? state.lastDecision.phase
332
+ : undefined,
329
333
  state.currentConfig.classifierModel.thinking,
334
+ options?.signal,
330
335
  );
336
+ options?.signal?.throwIfAborted();
331
337
  if (classifierResult) {
338
+ const tier = resolveAvailableTier(profile, classifierResult.tier);
332
339
  decision = buildRoutingDecision(
333
340
  model.id,
334
341
  profile,
335
- classifierResult.tier,
336
- phaseForTier(classifierResult.tier),
342
+ tier,
343
+ phaseForTier(tier),
337
344
  `Classifier: ${classifierResult.reasoning}`,
338
345
  state.thinkingByProfile[model.id],
339
346
  true,
@@ -446,14 +453,16 @@ export const registerRouterProvider = (
446
453
  if (imageAttached) {
447
454
  modelsToTry = modelsToTry.filter(checkModelSupportsImage);
448
455
  if (modelsToTry.length === 0) {
449
- modelsToTry = [decision.targetLabel];
456
+ throw new Error(
457
+ 'No configured model supports image attachments.',
458
+ );
450
459
  }
451
460
  }
452
461
  let lastError: unknown;
453
462
  let success = false;
454
463
 
455
- for (let i = 0; i < modelsToTry.length; i++) {
456
- const modelRef = modelsToTry[i];
464
+ for (const [i, modelRef] of modelsToTry.entries()) {
465
+ options?.signal?.throwIfAborted();
457
466
  const { provider: targetProvider, modelId: targetModelId } =
458
467
  parseCanonicalModelRef(modelRef);
459
468
 
@@ -467,31 +476,14 @@ export const registerRouterProvider = (
467
476
  continue;
468
477
  }
469
478
 
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
-
479
+ let contentReceived = false;
486
480
  try {
487
481
  // HONESTY CHECK & AUTO-TRUNCATION
488
482
  // If the picked model has a smaller context than what we reported, truncate now.
489
483
  let effectiveContext = context;
490
- const targetLimit = resolveContextWindow(
491
- decision.tier,
492
- profile,
493
- registry,
494
- );
484
+ const targetLimit =
485
+ targetModel.contextWindow ??
486
+ resolveContextWindow(decision.tier, profile, registry);
495
487
  if (
496
488
  model.contextWindow !== undefined &&
497
489
  targetLimit < model.contextWindow
@@ -509,15 +501,15 @@ export const registerRouterProvider = (
509
501
  const tierConfig = profile[decision.tier];
510
502
  if (tierConfig?.resolvedThinkingLevels) {
511
503
  requestedReasoning = clampThinkingLevel(
512
- requestedReasoning as ThinkingLevel,
504
+ requestedReasoning,
513
505
  tierConfig.resolvedThinkingLevels,
514
- ) as typeof requestedReasoning;
506
+ );
515
507
  }
516
508
  }
517
509
 
518
- const delegatedReasoning =
510
+ const delegatedReasoning: SimpleStreamOptions['reasoning'] =
519
511
  targetModel.reasoning && requestedReasoning !== 'off'
520
- ? (requestedReasoning as SimpleStreamOptions['reasoning'])
512
+ ? requestedReasoning
521
513
  : undefined;
522
514
 
523
515
  try {
@@ -534,42 +526,42 @@ export const registerRouterProvider = (
534
526
  // Stale extension context — skip non-critical UI updates.
535
527
  }
536
528
 
537
- // Strip pi's reasoning from options the router controls thinking
538
- const { reasoning: _piReasoning, ...delegationOptions } =
539
- options ?? {};
529
+ // Router credentials must not override the concrete provider's request auth.
530
+ const {
531
+ reasoning: _piReasoning,
532
+ apiKey: _routerKey,
533
+ headers: _routerHeaders,
534
+ env: _routerEnv,
535
+ ...delegationOptions
536
+ } = options ?? {};
540
537
 
541
- const delegatedOptions = {
538
+ const delegatedOptions: SimpleStreamOptions = {
542
539
  ...delegationOptions,
543
- apiKey,
544
- headers,
545
540
  ...(delegatedReasoning
546
541
  ? { reasoning: delegatedReasoning }
547
542
  : {}),
548
543
  };
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;
544
+ // Pi owns request-time auth, custom/native providers, URLs and transcript normalization.
545
+ const delegatedStream = registry.streamSimple(
546
+ targetModel,
547
+ effectiveContext,
548
+ delegatedOptions,
549
+ );
550
+ let terminalReceived = false;
551
+ const pendingEvents: AssistantMessageEvent[] = [];
552
+ const recordTarget = () => {
553
+ decision.targetProvider = targetProvider;
554
+ decision.targetModelId = targetModelId;
555
+ decision.targetLabel = modelRef;
556
+ decision.thinking = delegatedReasoning ?? 'off';
557
+ decision.isFallback = i > 0;
558
+ };
567
559
  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) {
560
+ if (
561
+ event.type === 'error' &&
562
+ event.reason !== 'aborted' &&
563
+ !contentReceived
564
+ ) {
573
565
  const errorMessage =
574
566
  'error' in event &&
575
567
  event.error &&
@@ -587,13 +579,37 @@ export const registerRouterProvider = (
587
579
  event.type === 'thinking_delta' ||
588
580
  event.type === 'toolcall_delta' ||
589
581
  event.type === 'toolcall_end';
590
- if (isContent) contentReceived = true;
591
- stream.push(event);
582
+ if (isContent) {
583
+ contentReceived = true;
584
+ recordTarget();
585
+ }
586
+ if (event.type === 'done' || event.type === 'error') {
587
+ terminalReceived = true;
588
+ recordTarget();
589
+ const cost = (
590
+ event.type === 'done' ? event.message : event.error
591
+ ).usage.cost.total;
592
+ if (Number.isFinite(cost) && cost > 0)
593
+ state.accumulatedCost += cost;
594
+ }
595
+ if (contentReceived || terminalReceived) {
596
+ for (const pending of pendingEvents.splice(0))
597
+ stream.push(pending);
598
+ stream.push(event);
599
+ if ('partial' in event) partialMessage = event.partial;
600
+ } else {
601
+ pendingEvents.push(event);
602
+ }
603
+ if (terminalReceived) break;
592
604
  }
605
+ if (!terminalReceived)
606
+ throw new Error(
607
+ 'Provider stream ended without a terminal event.',
608
+ );
593
609
  success = true;
594
- if (i > 0) decision.isFallback = true;
595
610
  break;
596
611
  } catch (err) {
612
+ if (contentReceived || options?.signal?.aborted) throw err;
597
613
  lastError = err;
598
614
  }
599
615
  }
@@ -610,28 +626,17 @@ export const registerRouterProvider = (
610
626
 
611
627
  stream.end();
612
628
  } 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,
629
+ const reason = options?.signal?.aborted ? 'aborted' : 'error';
630
+ stream.push({
631
+ type: 'error',
632
+ reason,
633
+ error: {
634
+ ...(partialMessage ?? createErrorMessage(model, '')),
635
+ errorMessage:
631
636
  error instanceof Error ? error.message : String(error),
632
- ),
633
- });
634
- }
637
+ stopReason: reason,
638
+ },
639
+ });
635
640
  stream.end();
636
641
  } finally {
637
642
  try {