@alexeiled/pi-model-router 0.5.0
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 +16 -0
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/extensions/commands.ts +677 -0
- package/extensions/config.ts +642 -0
- package/extensions/constants.ts +49 -0
- package/extensions/index.ts +573 -0
- package/extensions/provider.ts +650 -0
- package/extensions/routing.ts +483 -0
- package/extensions/state.ts +101 -0
- package/extensions/types.ts +109 -0
- package/extensions/ui.ts +131 -0
- package/model-router.example.json +72 -0
- package/package.json +77 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
|
|
2
|
+
import {
|
|
3
|
+
type Api,
|
|
4
|
+
type AssistantMessage,
|
|
5
|
+
type AssistantMessageEventStream,
|
|
6
|
+
type Context,
|
|
7
|
+
createAssistantMessageEventStream,
|
|
8
|
+
type Model,
|
|
9
|
+
normalizeContext,
|
|
10
|
+
type SimpleStreamOptions,
|
|
11
|
+
type TranscriptContext,
|
|
12
|
+
} from '@earendil-works/pi-ai';
|
|
13
|
+
import { streamSimple } from '@earendil-works/pi-ai/compat';
|
|
14
|
+
import type {
|
|
15
|
+
ExtensionAPI,
|
|
16
|
+
ExtensionContext,
|
|
17
|
+
} from '@earendil-works/pi-coding-agent';
|
|
18
|
+
import {
|
|
19
|
+
clampThinkingLevel,
|
|
20
|
+
collectProfileThinkingLevels,
|
|
21
|
+
MAX_THINKING_LEVEL,
|
|
22
|
+
parseCanonicalModelRef,
|
|
23
|
+
profileNames,
|
|
24
|
+
ROUTER_TIERS,
|
|
25
|
+
resolveContextWindow,
|
|
26
|
+
resolveMaxTokens,
|
|
27
|
+
} from './config';
|
|
28
|
+
import {
|
|
29
|
+
DEFAULT_CONTEXT_WINDOW,
|
|
30
|
+
DEFAULT_MAX_TOKENS,
|
|
31
|
+
hasUsableRequestAuth,
|
|
32
|
+
type RegistryWithProviderAuth,
|
|
33
|
+
resolveDelegatedModel,
|
|
34
|
+
} from './constants';
|
|
35
|
+
import type {
|
|
36
|
+
RouterConfig,
|
|
37
|
+
RouterPinByProfile,
|
|
38
|
+
RouterThinkingByProfile,
|
|
39
|
+
RouterTier,
|
|
40
|
+
RoutingDecision,
|
|
41
|
+
} from './types';
|
|
42
|
+
|
|
43
|
+
const REGISTRY_WAIT_TIMEOUT_MS = 5000;
|
|
44
|
+
const REGISTRY_WAIT_INITIAL_DELAY_MS = 50;
|
|
45
|
+
const REGISTRY_WAIT_MAX_DELAY_MS = 500;
|
|
46
|
+
|
|
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
|
+
/**
|
|
59
|
+
* Wait for the model registry to become available with exponential backoff.
|
|
60
|
+
* This handles the race condition where subagents (e.g. from pi-dynamic-workflows)
|
|
61
|
+
* invoke the router provider before session_start has fired in their context.
|
|
62
|
+
*/
|
|
63
|
+
export const waitForRegistry = async (
|
|
64
|
+
state: {
|
|
65
|
+
readonly currentModelRegistry:
|
|
66
|
+
| ExtensionContext['modelRegistry']
|
|
67
|
+
| undefined;
|
|
68
|
+
},
|
|
69
|
+
timeoutMs: number = REGISTRY_WAIT_TIMEOUT_MS,
|
|
70
|
+
): Promise<ExtensionContext['modelRegistry'] | undefined> => {
|
|
71
|
+
if (state.currentModelRegistry) return state.currentModelRegistry;
|
|
72
|
+
|
|
73
|
+
const start = Date.now();
|
|
74
|
+
let delay = REGISTRY_WAIT_INITIAL_DELAY_MS;
|
|
75
|
+
while (Date.now() - start < timeoutMs) {
|
|
76
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
77
|
+
if (state.currentModelRegistry) return state.currentModelRegistry;
|
|
78
|
+
delay = Math.min(delay * 2, REGISTRY_WAIT_MAX_DELAY_MS);
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
import {
|
|
84
|
+
buildRoutingDecision,
|
|
85
|
+
decideRouting,
|
|
86
|
+
extractTextFromContent,
|
|
87
|
+
hasImageAttachment,
|
|
88
|
+
phaseForTier,
|
|
89
|
+
runClassifier,
|
|
90
|
+
} from './routing';
|
|
91
|
+
|
|
92
|
+
export const createErrorMessage = (
|
|
93
|
+
model: Model<Api>,
|
|
94
|
+
message: string,
|
|
95
|
+
): AssistantMessage => {
|
|
96
|
+
return {
|
|
97
|
+
role: 'assistant',
|
|
98
|
+
content: [],
|
|
99
|
+
api: model.api,
|
|
100
|
+
provider: model.provider,
|
|
101
|
+
model: model.id,
|
|
102
|
+
usage: {
|
|
103
|
+
input: 0,
|
|
104
|
+
output: 0,
|
|
105
|
+
cacheRead: 0,
|
|
106
|
+
cacheWrite: 0,
|
|
107
|
+
totalTokens: 0,
|
|
108
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
109
|
+
},
|
|
110
|
+
stopReason: 'error',
|
|
111
|
+
errorMessage: message,
|
|
112
|
+
timestamp: Date.now(),
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Heuristic token estimator (conservative: 3 characters per token)
|
|
118
|
+
*/
|
|
119
|
+
const estimateTokens = (text: string): number => Math.ceil(text.length / 3);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 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.
|
|
124
|
+
*/
|
|
125
|
+
const truncateContext = (context: Context, limit: number): Context => {
|
|
126
|
+
const messages = [...context.messages];
|
|
127
|
+
if (messages.length <= 1) return context;
|
|
128
|
+
|
|
129
|
+
const systemTokens = context.systemPrompt
|
|
130
|
+
? estimateTokens(context.systemPrompt)
|
|
131
|
+
: 0;
|
|
132
|
+
|
|
133
|
+
// Pre-calculate token sizes
|
|
134
|
+
const messageTokens = messages.map((m) =>
|
|
135
|
+
estimateTokens(extractTextFromContent(m.content)),
|
|
136
|
+
);
|
|
137
|
+
const totalTokens =
|
|
138
|
+
systemTokens + messageTokens.reduce((sum, t) => sum + t, 0);
|
|
139
|
+
|
|
140
|
+
if (totalTokens <= limit) return context;
|
|
141
|
+
|
|
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++;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const finalMessages = [...messages.slice(startIndex), latestMessage];
|
|
159
|
+
return { ...context, messages: finalMessages };
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const supportsReasoning = (
|
|
163
|
+
profile: RouterConfig['profiles'][string],
|
|
164
|
+
modelRegistry: ExtensionContext['modelRegistry'] | undefined,
|
|
165
|
+
): boolean => {
|
|
166
|
+
if (!modelRegistry) return false;
|
|
167
|
+
|
|
168
|
+
for (const tier of ROUTER_TIERS) {
|
|
169
|
+
const tierConfig = profile[tier];
|
|
170
|
+
if (!tierConfig) continue;
|
|
171
|
+
try {
|
|
172
|
+
const { provider, modelId } = parseCanonicalModelRef(tierConfig.model);
|
|
173
|
+
if (modelRegistry.find(provider, modelId)?.reasoning) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
} catch (_error) {
|
|
177
|
+
// ignore invalid model refs here; config normalization handles warnings
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return false;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export const registerRouterProvider = (
|
|
185
|
+
pi: ExtensionAPI,
|
|
186
|
+
state: {
|
|
187
|
+
lastRegisteredModels: string;
|
|
188
|
+
readonly currentConfig: RouterConfig;
|
|
189
|
+
readonly currentModelRegistry:
|
|
190
|
+
| ExtensionContext['modelRegistry']
|
|
191
|
+
| undefined;
|
|
192
|
+
readonly lastExtensionContext: ExtensionContext | undefined;
|
|
193
|
+
selectedProfile: string | undefined;
|
|
194
|
+
routerEnabled: boolean;
|
|
195
|
+
lastDecision: RoutingDecision | undefined;
|
|
196
|
+
readonly thinkingByProfile: RouterThinkingByProfile;
|
|
197
|
+
readonly pinnedTierByProfile: RouterPinByProfile;
|
|
198
|
+
accumulatedCost: number;
|
|
199
|
+
/** Override for the registry wait timeout (for testing). */
|
|
200
|
+
readonly registryTimeoutMs?: number;
|
|
201
|
+
},
|
|
202
|
+
actions: {
|
|
203
|
+
persistState: () => void;
|
|
204
|
+
recordDebugDecision: (decision: RoutingDecision) => void;
|
|
205
|
+
getThinkingOverride: (
|
|
206
|
+
profileName: string,
|
|
207
|
+
tier: RouterTier,
|
|
208
|
+
) => ThinkingLevel | undefined;
|
|
209
|
+
updateStatus: (ctx: ExtensionContext) => void;
|
|
210
|
+
syncPiThinkingLevel: (level: ThinkingLevel) => void;
|
|
211
|
+
},
|
|
212
|
+
) => {
|
|
213
|
+
const profileList = profileNames(state.currentConfig);
|
|
214
|
+
|
|
215
|
+
// Map profiles to their capacities
|
|
216
|
+
const modelDefinitions = profileList.map((name) => {
|
|
217
|
+
const profile = state.currentConfig.profiles[name];
|
|
218
|
+
|
|
219
|
+
// Report the MAX context window and max output tokens across all tiers.
|
|
220
|
+
// The honesty check + truncateContext handles the case where the
|
|
221
|
+
// actually routed model is smaller.
|
|
222
|
+
let maxContextWindow = DEFAULT_CONTEXT_WINDOW;
|
|
223
|
+
let maxMaxTokens = DEFAULT_MAX_TOKENS;
|
|
224
|
+
for (const tier of ROUTER_TIERS) {
|
|
225
|
+
if (!profile[tier]) continue;
|
|
226
|
+
const cw = resolveContextWindow(
|
|
227
|
+
tier,
|
|
228
|
+
profile,
|
|
229
|
+
state.currentModelRegistry,
|
|
230
|
+
);
|
|
231
|
+
const mot = resolveMaxTokens(tier, profile, state.currentModelRegistry);
|
|
232
|
+
if (cw > maxContextWindow) maxContextWindow = cw;
|
|
233
|
+
if (mot > maxMaxTokens) maxMaxTokens = mot;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const hasReasoning = supportsReasoning(profile, state.currentModelRegistry);
|
|
237
|
+
const profileLevels = collectProfileThinkingLevels(profile);
|
|
238
|
+
// Build thinkingLevelMap from the union of all tier models' declared levels.
|
|
239
|
+
// Only needed if xhigh or max are in the set (pi supports all others by default).
|
|
240
|
+
let thinkingLevelMap: Record<string, string> | undefined;
|
|
241
|
+
if (hasReasoning) {
|
|
242
|
+
const map: Record<string, string> = {};
|
|
243
|
+
if (profileLevels.has('xhigh')) map.xhigh = 'xhigh';
|
|
244
|
+
if (profileLevels.has(MAX_THINKING_LEVEL)) map.max = MAX_THINKING_LEVEL;
|
|
245
|
+
if (Object.keys(map).length > 0) thinkingLevelMap = map;
|
|
246
|
+
}
|
|
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
|
+
};
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const modelsKey = modelDefinitions
|
|
261
|
+
.map((m) => `${m.id}:${m.contextWindow}:${m.maxTokens}:${m.reasoning}`)
|
|
262
|
+
.join(',');
|
|
263
|
+
if (state.lastRegisteredModels === modelsKey) return;
|
|
264
|
+
|
|
265
|
+
pi.registerProvider('router', {
|
|
266
|
+
baseUrl: 'router://local',
|
|
267
|
+
apiKey: 'pi-model-router',
|
|
268
|
+
api: 'router-local-api',
|
|
269
|
+
models: modelDefinitions,
|
|
270
|
+
streamSimple(
|
|
271
|
+
model: Model<Api>,
|
|
272
|
+
context: Context,
|
|
273
|
+
options?: SimpleStreamOptions,
|
|
274
|
+
): AssistantMessageEventStream {
|
|
275
|
+
const stream = createAssistantMessageEventStream();
|
|
276
|
+
|
|
277
|
+
(async () => {
|
|
278
|
+
try {
|
|
279
|
+
// Wait for the router to be fully initialized (session_start sets currentModelRegistry).
|
|
280
|
+
// This handles the race where subagents (e.g. from pi-dynamic-workflows) invoke
|
|
281
|
+
// the router provider before session_start has fired in their context.
|
|
282
|
+
const registry = await waitForRegistry(
|
|
283
|
+
state,
|
|
284
|
+
state.registryTimeoutMs,
|
|
285
|
+
);
|
|
286
|
+
if (!registry) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
'Router provider initialization timed out. session_start may not have fired.',
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
const profile = state.currentConfig.profiles[model.id];
|
|
292
|
+
if (!profile) {
|
|
293
|
+
throw new Error(`Unknown router profile: ${model.id}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
state.selectedProfile = model.id;
|
|
297
|
+
state.routerEnabled = true;
|
|
298
|
+
|
|
299
|
+
const pinnedTier = state.pinnedTierByProfile[model.id];
|
|
300
|
+
const isBudgetExceeded =
|
|
301
|
+
state.currentConfig.maxSessionBudget !== undefined &&
|
|
302
|
+
state.accumulatedCost >= state.currentConfig.maxSessionBudget;
|
|
303
|
+
|
|
304
|
+
let decision: RoutingDecision = decideRouting(
|
|
305
|
+
context,
|
|
306
|
+
model.id,
|
|
307
|
+
profile,
|
|
308
|
+
state.lastDecision,
|
|
309
|
+
pinnedTier,
|
|
310
|
+
state.thinkingByProfile[model.id],
|
|
311
|
+
state.currentConfig.phaseBias,
|
|
312
|
+
state.currentConfig.rules,
|
|
313
|
+
isBudgetExceeded,
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
// Classifier Override — skip when budget is already exceeded since the
|
|
317
|
+
// result would be downgraded anyway, saving an unnecessary LLM call.
|
|
318
|
+
if (
|
|
319
|
+
state.currentConfig.classifierModel &&
|
|
320
|
+
!pinnedTier &&
|
|
321
|
+
!decision.isRuleMatched &&
|
|
322
|
+
!isBudgetExceeded
|
|
323
|
+
) {
|
|
324
|
+
const classifierResult = await runClassifier(
|
|
325
|
+
state.currentConfig.classifierModel.model,
|
|
326
|
+
registry,
|
|
327
|
+
context,
|
|
328
|
+
state.lastDecision?.phase,
|
|
329
|
+
state.currentConfig.classifierModel.thinking,
|
|
330
|
+
);
|
|
331
|
+
if (classifierResult) {
|
|
332
|
+
decision = buildRoutingDecision(
|
|
333
|
+
model.id,
|
|
334
|
+
profile,
|
|
335
|
+
classifierResult.tier,
|
|
336
|
+
phaseForTier(classifierResult.tier),
|
|
337
|
+
`Classifier: ${classifierResult.reasoning}`,
|
|
338
|
+
state.thinkingByProfile[model.id],
|
|
339
|
+
true,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const lastMessage = context.messages[context.messages.length - 1];
|
|
345
|
+
const previousDecision = state.lastDecision;
|
|
346
|
+
const isGoogleThinkingToolContinuation =
|
|
347
|
+
lastMessage?.role === 'toolResult' &&
|
|
348
|
+
previousDecision?.profile === model.id &&
|
|
349
|
+
previousDecision.targetProvider === 'google' &&
|
|
350
|
+
previousDecision.thinking !== 'off' &&
|
|
351
|
+
decision.targetProvider === 'google' &&
|
|
352
|
+
decision.thinking !== 'off' &&
|
|
353
|
+
previousDecision.targetLabel !== decision.targetLabel;
|
|
354
|
+
|
|
355
|
+
if (isGoogleThinkingToolContinuation && previousDecision) {
|
|
356
|
+
decision = {
|
|
357
|
+
...decision,
|
|
358
|
+
tier: previousDecision.tier,
|
|
359
|
+
phase: previousDecision.phase,
|
|
360
|
+
targetProvider: previousDecision.targetProvider,
|
|
361
|
+
targetModelId: previousDecision.targetModelId,
|
|
362
|
+
targetLabel: previousDecision.targetLabel,
|
|
363
|
+
thinking: previousDecision.thinking,
|
|
364
|
+
reasoning:
|
|
365
|
+
`Preserved ${previousDecision.targetLabel} for a Google tool-result continuation ` +
|
|
366
|
+
`to avoid thought-signature replay errors. (Original: ${decision.reasoning})`,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const imageAttached = hasImageAttachment(context);
|
|
371
|
+
const checkModelSupportsImage = (modelRef: string) => {
|
|
372
|
+
try {
|
|
373
|
+
const { provider, modelId } = parseCanonicalModelRef(modelRef);
|
|
374
|
+
const m = registry.find(provider, modelId);
|
|
375
|
+
return m?.input?.includes('image') ?? false;
|
|
376
|
+
} catch {
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
if (imageAttached) {
|
|
382
|
+
const tierModels = [
|
|
383
|
+
decision.targetLabel,
|
|
384
|
+
...(profile[decision.tier]?.fallbacks ?? []),
|
|
385
|
+
];
|
|
386
|
+
if (!tierModels.some(checkModelSupportsImage)) {
|
|
387
|
+
const tiersToTry: RouterTier[] =
|
|
388
|
+
decision.tier === 'low'
|
|
389
|
+
? ['medium', 'high']
|
|
390
|
+
: decision.tier === 'medium'
|
|
391
|
+
? ['high']
|
|
392
|
+
: [];
|
|
393
|
+
|
|
394
|
+
let foundTier: RouterTier | undefined;
|
|
395
|
+
for (const t of tiersToTry) {
|
|
396
|
+
const tierConfig = profile[t];
|
|
397
|
+
if (!tierConfig) continue;
|
|
398
|
+
const tModels = [
|
|
399
|
+
tierConfig.model,
|
|
400
|
+
...(tierConfig.fallbacks ?? []),
|
|
401
|
+
];
|
|
402
|
+
if (tModels.some(checkModelSupportsImage)) {
|
|
403
|
+
foundTier = t;
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (foundTier) {
|
|
409
|
+
decision = buildRoutingDecision(
|
|
410
|
+
model.id,
|
|
411
|
+
profile,
|
|
412
|
+
foundTier,
|
|
413
|
+
phaseForTier(foundTier),
|
|
414
|
+
`Forced ${foundTier} tier because the originally routed ${decision.tier} tier does not support image attachments.`,
|
|
415
|
+
state.thinkingByProfile[model.id],
|
|
416
|
+
false,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
state.lastDecision = decision;
|
|
423
|
+
actions.recordDebugDecision(decision);
|
|
424
|
+
|
|
425
|
+
// Sync pi's thinking level display with the router's effective thinking.
|
|
426
|
+
// Wrapped in try/catch: in subagent contexts the extension runtime
|
|
427
|
+
// may be invalidated (stale) after session teardown.
|
|
428
|
+
const effectiveThinking =
|
|
429
|
+
actions.getThinkingOverride(model.id, decision.tier) ??
|
|
430
|
+
decision.thinking;
|
|
431
|
+
try {
|
|
432
|
+
actions.syncPiThinkingLevel(effectiveThinking);
|
|
433
|
+
if (state.lastExtensionContext) {
|
|
434
|
+
actions.updateStatus(state.lastExtensionContext);
|
|
435
|
+
}
|
|
436
|
+
} catch {
|
|
437
|
+
// Stale extension context — skip non-critical UI updates.
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
let modelsToTry = [
|
|
441
|
+
...new Set([
|
|
442
|
+
decision.targetLabel,
|
|
443
|
+
...(profile[decision.tier]?.fallbacks ?? []),
|
|
444
|
+
]),
|
|
445
|
+
];
|
|
446
|
+
if (imageAttached) {
|
|
447
|
+
modelsToTry = modelsToTry.filter(checkModelSupportsImage);
|
|
448
|
+
if (modelsToTry.length === 0) {
|
|
449
|
+
modelsToTry = [decision.targetLabel];
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
let lastError: unknown;
|
|
453
|
+
let success = false;
|
|
454
|
+
|
|
455
|
+
for (let i = 0; i < modelsToTry.length; i++) {
|
|
456
|
+
const modelRef = modelsToTry[i];
|
|
457
|
+
const { provider: targetProvider, modelId: targetModelId } =
|
|
458
|
+
parseCanonicalModelRef(modelRef);
|
|
459
|
+
|
|
460
|
+
if (targetProvider === 'router') continue;
|
|
461
|
+
|
|
462
|
+
const targetModel = registry.find(targetProvider, targetModelId);
|
|
463
|
+
if (!targetModel) {
|
|
464
|
+
lastError = new Error(
|
|
465
|
+
`Routed model not found: ${targetProvider}/${targetModelId}`,
|
|
466
|
+
);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
|
|
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
|
+
|
|
486
|
+
try {
|
|
487
|
+
// HONESTY CHECK & AUTO-TRUNCATION
|
|
488
|
+
// If the picked model has a smaller context than what we reported, truncate now.
|
|
489
|
+
let effectiveContext = context;
|
|
490
|
+
const targetLimit = resolveContextWindow(
|
|
491
|
+
decision.tier,
|
|
492
|
+
profile,
|
|
493
|
+
registry,
|
|
494
|
+
);
|
|
495
|
+
if (
|
|
496
|
+
model.contextWindow !== undefined &&
|
|
497
|
+
targetLimit < model.contextWindow
|
|
498
|
+
) {
|
|
499
|
+
effectiveContext = truncateContext(context, targetLimit);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const thinkingOverride = actions.getThinkingOverride(
|
|
503
|
+
model.id,
|
|
504
|
+
decision.tier,
|
|
505
|
+
);
|
|
506
|
+
let requestedReasoning = thinkingOverride ?? decision.thinking;
|
|
507
|
+
|
|
508
|
+
if (requestedReasoning !== 'off' && targetModel.reasoning) {
|
|
509
|
+
const tierConfig = profile[decision.tier];
|
|
510
|
+
if (tierConfig?.resolvedThinkingLevels) {
|
|
511
|
+
requestedReasoning = clampThinkingLevel(
|
|
512
|
+
requestedReasoning as ThinkingLevel,
|
|
513
|
+
tierConfig.resolvedThinkingLevels,
|
|
514
|
+
) as typeof requestedReasoning;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const delegatedReasoning =
|
|
519
|
+
targetModel.reasoning && requestedReasoning !== 'off'
|
|
520
|
+
? (requestedReasoning as SimpleStreamOptions['reasoning'])
|
|
521
|
+
: undefined;
|
|
522
|
+
|
|
523
|
+
try {
|
|
524
|
+
if (state.lastExtensionContext) {
|
|
525
|
+
if (delegatedReasoning) {
|
|
526
|
+
state.lastExtensionContext.ui.setHiddenThinkingLabel?.(
|
|
527
|
+
`Thinking (${targetProvider}/${targetModelId})...`,
|
|
528
|
+
);
|
|
529
|
+
} else {
|
|
530
|
+
state.lastExtensionContext.ui.setHiddenThinkingLabel?.();
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
} catch {
|
|
534
|
+
// Stale extension context — skip non-critical UI updates.
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Strip pi's reasoning from options — the router controls thinking
|
|
538
|
+
const { reasoning: _piReasoning, ...delegationOptions } =
|
|
539
|
+
options ?? {};
|
|
540
|
+
|
|
541
|
+
const delegatedOptions = {
|
|
542
|
+
...delegationOptions,
|
|
543
|
+
apiKey,
|
|
544
|
+
headers,
|
|
545
|
+
...(delegatedReasoning
|
|
546
|
+
? { reasoning: delegatedReasoning }
|
|
547
|
+
: {}),
|
|
548
|
+
};
|
|
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;
|
|
567
|
+
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) {
|
|
573
|
+
const errorMessage =
|
|
574
|
+
'error' in event &&
|
|
575
|
+
event.error &&
|
|
576
|
+
typeof event.error === 'object' &&
|
|
577
|
+
'errorMessage' in event.error &&
|
|
578
|
+
typeof event.error.errorMessage === 'string'
|
|
579
|
+
? event.error.errorMessage
|
|
580
|
+
: undefined;
|
|
581
|
+
throw new Error(
|
|
582
|
+
errorMessage || 'Model failed before sending content.',
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
const isContent =
|
|
586
|
+
event.type === 'text_delta' ||
|
|
587
|
+
event.type === 'thinking_delta' ||
|
|
588
|
+
event.type === 'toolcall_delta' ||
|
|
589
|
+
event.type === 'toolcall_end';
|
|
590
|
+
if (isContent) contentReceived = true;
|
|
591
|
+
stream.push(event);
|
|
592
|
+
}
|
|
593
|
+
success = true;
|
|
594
|
+
if (i > 0) decision.isFallback = true;
|
|
595
|
+
break;
|
|
596
|
+
} catch (err) {
|
|
597
|
+
lastError = err;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (!success) {
|
|
602
|
+
throw lastError instanceof Error
|
|
603
|
+
? lastError
|
|
604
|
+
: new Error(
|
|
605
|
+
typeof lastError === 'string'
|
|
606
|
+
? lastError
|
|
607
|
+
: 'Failed to delegate to any model in the chain.',
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
stream.end();
|
|
612
|
+
} 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,
|
|
631
|
+
error instanceof Error ? error.message : String(error),
|
|
632
|
+
),
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
stream.end();
|
|
636
|
+
} finally {
|
|
637
|
+
try {
|
|
638
|
+
actions.persistState();
|
|
639
|
+
} catch {
|
|
640
|
+
// Ignore: extension context may be stale after session teardown.
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
})();
|
|
644
|
+
|
|
645
|
+
return stream;
|
|
646
|
+
},
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
state.lastRegisteredModels = modelsKey;
|
|
650
|
+
};
|