@kdejaeger/pi-model-router 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +468 -0
- package/docs/ARCHITECTURE.md +75 -0
- package/extensions/commands.ts +452 -0
- package/extensions/config.ts +405 -0
- package/extensions/index.ts +412 -0
- package/extensions/provider.ts +442 -0
- package/extensions/routing.ts +290 -0
- package/extensions/state.ts +39 -0
- package/extensions/types.ts +74 -0
- package/extensions/ui.ts +54 -0
- package/model-router.example.json +48 -0
- package/package.json +54 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Api,
|
|
3
|
+
type AssistantMessage,
|
|
4
|
+
type AssistantMessageEventStream,
|
|
5
|
+
type Context,
|
|
6
|
+
createAssistantMessageEventStream,
|
|
7
|
+
type Model,
|
|
8
|
+
type SimpleStreamOptions,
|
|
9
|
+
streamSimple
|
|
10
|
+
} from '@earendil-works/pi-ai';
|
|
11
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
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';
|
|
15
|
+
import { formatDecision } from './ui';
|
|
16
|
+
|
|
17
|
+
const createErrorMessage = (model: Model<Api>, message: string): AssistantMessage => {
|
|
18
|
+
return {
|
|
19
|
+
role: 'assistant',
|
|
20
|
+
content: [],
|
|
21
|
+
api: model.api,
|
|
22
|
+
provider: model.provider,
|
|
23
|
+
model: model.id,
|
|
24
|
+
usage: {
|
|
25
|
+
input: 0,
|
|
26
|
+
output: 0,
|
|
27
|
+
cacheRead: 0,
|
|
28
|
+
cacheWrite: 0,
|
|
29
|
+
totalTokens: 0,
|
|
30
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
31
|
+
},
|
|
32
|
+
stopReason: 'error',
|
|
33
|
+
errorMessage: message,
|
|
34
|
+
timestamp: Date.now(),
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Token estimator: 1 token ~= 4 characters. Conservative and matches pi's compaction path. */
|
|
39
|
+
const estimateTokens = (text: string): number => Math.ceil(text.length / 4);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 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.
|
|
44
|
+
*/
|
|
45
|
+
const truncateContext = (context: Context, limit: number): Context => {
|
|
46
|
+
const messages = [...context.messages];
|
|
47
|
+
if (messages.length <= 1) return context;
|
|
48
|
+
|
|
49
|
+
const systemTokens = context.systemPrompt ? estimateTokens(context.systemPrompt) : 0;
|
|
50
|
+
const totalTokens = systemTokens + messages.reduce((sum, m) => sum + estimateTokens(extractTextFromContent(m.content)), 0);
|
|
51
|
+
if (totalTokens <= limit) return context;
|
|
52
|
+
|
|
53
|
+
const latestMessage = messages.pop()!;
|
|
54
|
+
const latestTokens = estimateTokens(extractTextFromContent(latestMessage.content));
|
|
55
|
+
|
|
56
|
+
let runningTotal = systemTokens + latestTokens + messages.reduce((sum, m) => sum + estimateTokens(extractTextFromContent(m.content)), 0);
|
|
57
|
+
while (messages.length > 0 && runningTotal > limit) {
|
|
58
|
+
const shifted = messages.shift()!;
|
|
59
|
+
runningTotal -= estimateTokens(extractTextFromContent(shifted.content));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { ...context, messages: [...messages, latestMessage] };
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const hasRecentImage = (context: Context): boolean => {
|
|
66
|
+
// Only check the last 6 messages (typically covers the last 3 full turns (3 user messages and 3 assistant responses)
|
|
67
|
+
// to detect images relevant to the current turn. This covers: direct user uploads, tool results from screenshot reads,
|
|
68
|
+
// and assistant messages with images - without firing on stale images from many turns ago.
|
|
69
|
+
const recentMessages = context.messages.slice(-6);
|
|
70
|
+
return recentMessages.some((msg) => Array.isArray(msg.content) && msg.content.some((part) => part.type === 'image'));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const registerRouterProvider = (
|
|
74
|
+
pi: ExtensionAPI,
|
|
75
|
+
state: {
|
|
76
|
+
lastLoadedModelKeys: string;
|
|
77
|
+
readonly currentConfig: RouterConfig;
|
|
78
|
+
readonly currentModelRegistry: ExtensionContext['modelRegistry'] | undefined;
|
|
79
|
+
readonly lastExtensionContext: ExtensionContext | undefined;
|
|
80
|
+
selectedProfile: string | undefined;
|
|
81
|
+
routerEnabled: boolean;
|
|
82
|
+
lastDecision: RoutingDecision | undefined;
|
|
83
|
+
readonly pinnedTierByProfile: RouterPinByProfile;
|
|
84
|
+
debugEnabled: boolean;
|
|
85
|
+
},
|
|
86
|
+
actions: {
|
|
87
|
+
persistState: () => void;
|
|
88
|
+
recordDebugDecision: (decision: RoutingDecision) => void;
|
|
89
|
+
updateStatus: (ctx: ExtensionContext) => void;
|
|
90
|
+
},
|
|
91
|
+
) => {
|
|
92
|
+
const currentConfig = state.currentConfig;
|
|
93
|
+
|
|
94
|
+
// Map profiles to their capacities
|
|
95
|
+
const models = profileNames(currentConfig).map((name) => {
|
|
96
|
+
const profile = currentConfig.profiles[name];
|
|
97
|
+
let maxContextWindow = 0;
|
|
98
|
+
let maxOutputTokens = 0;
|
|
99
|
+
|
|
100
|
+
if (state.currentModelRegistry) {
|
|
101
|
+
for (const tier of ROUTER_TIERS) {
|
|
102
|
+
const tierConfig = profile[tier];
|
|
103
|
+
if (!tierConfig) continue;
|
|
104
|
+
const modelsInTier = [tierConfig.model, ...(tierConfig.fallbacks ?? [])];
|
|
105
|
+
for (const modelRef of modelsInTier) {
|
|
106
|
+
const model = resolveModelFromRef(modelRef, state.currentModelRegistry);
|
|
107
|
+
if (model) {
|
|
108
|
+
const currentContextWindow = model.contextWindow ?? 0;
|
|
109
|
+
if (currentContextWindow > maxContextWindow) {
|
|
110
|
+
maxContextWindow = currentContextWindow;
|
|
111
|
+
maxOutputTokens = model.maxTokens ?? 120_000;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (maxContextWindow === 0) maxContextWindow = 1_000_000;
|
|
119
|
+
if (maxOutputTokens === 0) maxOutputTokens = 120_000;
|
|
120
|
+
|
|
121
|
+
const profileSupportsReasoning = state.currentModelRegistry
|
|
122
|
+
? ROUTER_TIERS.some((tier) => {
|
|
123
|
+
const tierConfig = profile[tier];
|
|
124
|
+
if (!tierConfig) return false;
|
|
125
|
+
return (
|
|
126
|
+
resolveModelFromRef(tierConfig.model, state.currentModelRegistry)?.reasoning ||
|
|
127
|
+
tierConfig.fallbacks?.some(
|
|
128
|
+
(fb) => resolveModelFromRef(fb, state.currentModelRegistry)?.reasoning,
|
|
129
|
+
)
|
|
130
|
+
);
|
|
131
|
+
})
|
|
132
|
+
: false;
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
id: name,
|
|
136
|
+
name: `⇋ ${name}`,
|
|
137
|
+
reasoning: profileSupportsReasoning,
|
|
138
|
+
input: ['text', 'image'] as ('text' | 'image')[],
|
|
139
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
140
|
+
contextWindow: maxContextWindow,
|
|
141
|
+
maxTokens: maxOutputTokens,
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (state.currentModelRegistry) {
|
|
146
|
+
const invalidOverrides = Object.keys(
|
|
147
|
+
state.currentConfig.contextThresholdPercentOverrides ?? {},
|
|
148
|
+
).filter((modelRef) => !resolveModelFromRef(modelRef, state.currentModelRegistry));
|
|
149
|
+
|
|
150
|
+
if (invalidOverrides.length > 0) {
|
|
151
|
+
state.lastExtensionContext?.ui.notify(
|
|
152
|
+
`Router configuration contains contextThresholdPercentOverrides for models that do not exist: ${invalidOverrides.map((modelRef) => JSON.stringify(modelRef)).join(', ')}`,
|
|
153
|
+
'warning',
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const loadedModelKeys = models.map((m) => `${m.id}:${m.contextWindow}:${m.maxTokens}:${m.reasoning}`).join(',');
|
|
159
|
+
if (state.lastLoadedModelKeys === loadedModelKeys) return; // models did not change, no need to re-register
|
|
160
|
+
|
|
161
|
+
pi.registerProvider(
|
|
162
|
+
'router', { // config (baseUrl, apiKey, ...)
|
|
163
|
+
baseUrl: 'router://local',
|
|
164
|
+
apiKey: 'pi-model-router',
|
|
165
|
+
api: 'router-local-api',
|
|
166
|
+
models,
|
|
167
|
+
streamSimple: (
|
|
168
|
+
model: Model<Api>,
|
|
169
|
+
context: Context,
|
|
170
|
+
options?: SimpleStreamOptions,
|
|
171
|
+
): AssistantMessageEventStream => {
|
|
172
|
+
const stream = createAssistantMessageEventStream();
|
|
173
|
+
const ctx = state.lastExtensionContext;
|
|
174
|
+
const modelRegistry = state.currentModelRegistry;
|
|
175
|
+
|
|
176
|
+
(async () => {
|
|
177
|
+
try {
|
|
178
|
+
if (!modelRegistry) {
|
|
179
|
+
throw new Error('Router provider not initialized yet. Wait for session_start and retry.');
|
|
180
|
+
}
|
|
181
|
+
const profile = currentConfig.profiles[model.id];
|
|
182
|
+
if (!profile) {
|
|
183
|
+
throw new Error(`Unknown router profile: ${model.id}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
state.selectedProfile = model.id;
|
|
187
|
+
state.routerEnabled = true;
|
|
188
|
+
|
|
189
|
+
const pinnedTier = state.pinnedTierByProfile[model.id];
|
|
190
|
+
const lastDecision = state.lastDecision;
|
|
191
|
+
|
|
192
|
+
const lastMessage = context.messages[context.messages.length - 1];
|
|
193
|
+
const lastMsgWasTool = lastMessage?.role === 'toolResult';
|
|
194
|
+
|
|
195
|
+
let decision: RoutingDecision;
|
|
196
|
+
|
|
197
|
+
let bShouldRunClassifier = false;
|
|
198
|
+
|
|
199
|
+
const isGoogleContinuation =
|
|
200
|
+
lastMsgWasTool &&
|
|
201
|
+
lastDecision?.profile === model.id &&
|
|
202
|
+
lastDecision?.targetProvider === 'google' &&
|
|
203
|
+
lastDecision?.thinking !== 'off';
|
|
204
|
+
if (isGoogleContinuation) { // Google thinking lock — preserve exact model on tool-result continuations
|
|
205
|
+
const toolResultsCount = countToolResultsSinceLastUserPrompt(context);
|
|
206
|
+
decision = {
|
|
207
|
+
...lastDecision!,
|
|
208
|
+
timestamp: Date.now(),
|
|
209
|
+
reasoning: `Preserved ${lastDecision!.targetLabel} for Google tool-result continuation.`,
|
|
210
|
+
lastClassifierRunToolCount: toolResultsCount,
|
|
211
|
+
};
|
|
212
|
+
} else {
|
|
213
|
+
let resolvedTier: RouterTier;
|
|
214
|
+
let resolvedReasoning: string;
|
|
215
|
+
let lastClassifierRunToolCount = lastDecision?.lastClassifierRunToolCount;
|
|
216
|
+
|
|
217
|
+
if (currentConfig.classifierModels?.length && !pinnedTier) {
|
|
218
|
+
const toolResultsCount = countToolResultsSinceLastUserPrompt(context);
|
|
219
|
+
|
|
220
|
+
bShouldRunClassifier = shouldRunClassifier(currentConfig, context, lastDecision, lastMsgWasTool, toolResultsCount, state.debugEnabled, ctx);
|
|
221
|
+
const classifierResult = bShouldRunClassifier
|
|
222
|
+
? await runClassifier(currentConfig, modelRegistry, context, lastDecision, ctx, state.debugEnabled)
|
|
223
|
+
: null;
|
|
224
|
+
|
|
225
|
+
if (classifierResult) {
|
|
226
|
+
resolvedTier = classifierResult.tier;
|
|
227
|
+
resolvedReasoning = `Classifier: ${classifierResult.reasoning}`;
|
|
228
|
+
lastClassifierRunToolCount = toolResultsCount;
|
|
229
|
+
} else if (lastDecision) {
|
|
230
|
+
// Classifier skipped or failed — reuse previous decision
|
|
231
|
+
resolvedTier = lastDecision.tier;
|
|
232
|
+
resolvedReasoning = lastDecision.reasoning;
|
|
233
|
+
} else {
|
|
234
|
+
resolvedTier = 'medium';
|
|
235
|
+
resolvedReasoning = 'No classifier result yet, defaulting to medium.';
|
|
236
|
+
}
|
|
237
|
+
} else if (pinnedTier) {
|
|
238
|
+
resolvedTier = pinnedTier;
|
|
239
|
+
resolvedReasoning = `Pinned to ${pinnedTier} tier.`;
|
|
240
|
+
} else {
|
|
241
|
+
resolvedTier = 'medium';
|
|
242
|
+
resolvedReasoning = 'No classifier configured, defaulting to medium.';
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
decision = buildRoutingDecision(model.id, profile, resolvedTier, resolvedReasoning, lastClassifierRunToolCount);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let tokensUsed = 0;
|
|
249
|
+
try {
|
|
250
|
+
const contextUsage = await ctx?.getContextUsage();
|
|
251
|
+
tokensUsed = contextUsage?.tokens ?? 0;
|
|
252
|
+
} catch {
|
|
253
|
+
ctx?.ui.notify('Could not read current context size from pi.', 'warning');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const detectedImageInRecentContext = hasRecentImage(context);
|
|
257
|
+
const tiersToTry = ROUTER_TIERS.slice(0, ROUTER_TIERS.indexOf(decision.tier) + 1).reverse();
|
|
258
|
+
const triedModels = new Set<string>();
|
|
259
|
+
const failureReasons: string[] = [];
|
|
260
|
+
let lastError: unknown;
|
|
261
|
+
let success = false;
|
|
262
|
+
|
|
263
|
+
// Pass 1: models that satisfy both image support and context thresholds.
|
|
264
|
+
// Pass 2: last resort — models that satisfy image support but need context truncation.
|
|
265
|
+
attemptLoop: for (const pass of [1, 2]) {
|
|
266
|
+
for (const tier of tiersToTry) {
|
|
267
|
+
const tierConfig = profile[tier];
|
|
268
|
+
if (!tierConfig) continue;
|
|
269
|
+
const modelsInTier = [tierConfig.model, ...(tierConfig.fallbacks ?? [])];
|
|
270
|
+
|
|
271
|
+
if (pass === 2) {
|
|
272
|
+
// Sort models by context window descending to minimize truncation.
|
|
273
|
+
modelsInTier.sort((a, b) => {
|
|
274
|
+
const limitA = resolveModelFromRef(a, modelRegistry)?.contextWindow || 0;
|
|
275
|
+
const limitB = resolveModelFromRef(b, modelRegistry)?.contextWindow || 0;
|
|
276
|
+
return limitB - limitA;
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
for (const modelRef of modelsInTier) {
|
|
281
|
+
if (triedModels.has(modelRef)) continue;
|
|
282
|
+
if (detectedImageInRecentContext && !resolveModelFromRef(modelRef, modelRegistry)?.input?.includes('image')) {
|
|
283
|
+
failureReasons.push(`${modelRef} does not support images`);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const targetModel = resolveModelFromRef(modelRef, modelRegistry);
|
|
288
|
+
if (!targetModel) {
|
|
289
|
+
failureReasons.push(`${modelRef} not found in registry`);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (targetModel.contextWindow === undefined || targetModel.contextWindow === 0) {
|
|
294
|
+
ctx?.ui.notify(`Router warning: model ${modelRef} has no known context size`, 'warning');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const thresholdPercent =
|
|
298
|
+
currentConfig.contextThresholdPercentOverrides?.[modelRef] ??
|
|
299
|
+
currentConfig.defaultContextThresholdPercent ?? 90;
|
|
300
|
+
const targetContextWindow = targetModel.contextWindow || 200_000;
|
|
301
|
+
const targetContextLimit = Math.floor((thresholdPercent / 100) * targetContextWindow);
|
|
302
|
+
const fitsContext = tokensUsed <= targetContextLimit;
|
|
303
|
+
|
|
304
|
+
if (pass === 1 && !fitsContext) {
|
|
305
|
+
failureReasons.push(`${modelRef} context exceeded (used ${tokensUsed} > ${targetContextLimit})`);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
triedModels.add(modelRef);
|
|
309
|
+
const { provider: targetProvider, modelId: targetModelId } = parseCanonicalModelRef(modelRef);
|
|
310
|
+
|
|
311
|
+
if (tier !== decision.tier || modelRef !== decision.targetLabel) {
|
|
312
|
+
const triggerReasons = [
|
|
313
|
+
...(detectedImageInRecentContext ? ['images'] : []),
|
|
314
|
+
...(!fitsContext ? ['context limit exceeded'] : []),
|
|
315
|
+
].join(' and ');
|
|
316
|
+
|
|
317
|
+
if (tier !== decision.tier) {
|
|
318
|
+
decision = buildRoutingDecision(
|
|
319
|
+
model.id, profile, tier,
|
|
320
|
+
`Forced ${tier} tier because ${decision.tier} tier lacks models${triggerReasons ? ` for ${triggerReasons}` : ''}.`,
|
|
321
|
+
decision.lastClassifierRunToolCount,
|
|
322
|
+
);
|
|
323
|
+
} else {
|
|
324
|
+
decision.reasoning += triggerReasons ? ` (Using ${modelRef} for ${triggerReasons})` : '';
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
decision.targetProvider = targetProvider;
|
|
328
|
+
decision.targetModelId = targetModelId;
|
|
329
|
+
decision.targetLabel = modelRef;
|
|
330
|
+
decision.isFallback = !tierConfig || modelRef !== tierConfig.model;
|
|
331
|
+
decision.isContextTriggered = !fitsContext;
|
|
332
|
+
|
|
333
|
+
if (ctx) {
|
|
334
|
+
if (state.debugEnabled && bShouldRunClassifier) {
|
|
335
|
+
ctx.ui.notify(`Decision ${formatDecision(decision)}`, 'info');
|
|
336
|
+
}
|
|
337
|
+
actions.updateStatus(ctx);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const auth = await modelRegistry.getApiKeyAndHeaders(targetModel);
|
|
341
|
+
if (!auth.ok) {
|
|
342
|
+
const reason = `Auth failed for model: ${modelRef}: ${auth.error}`;
|
|
343
|
+
lastError = new Error(reason);
|
|
344
|
+
failureReasons.push(`${modelRef} auth failed: ${reason}`);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
// Note: auth.apiKey may be undefined for env-var-based providers
|
|
348
|
+
// (e.g., OPENROUTER_API_KEY). The compat streamSimple resolves
|
|
349
|
+
// env vars independently, so we pass auth.apiKey as-is to let the
|
|
350
|
+
// compat layer handle the fallback.
|
|
351
|
+
|
|
352
|
+
let effectiveContext = context;
|
|
353
|
+
if (!fitsContext) {
|
|
354
|
+
effectiveContext = truncateContext(context, targetContextLimit);
|
|
355
|
+
ctx?.ui.notify(`Memory too large for ${modelRef} — trimmed ${context.messages.length - effectiveContext.messages.length} messages. Run /compact to reduce context size.`, 'warning');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Attention: stripping reasoning from pi's incoming options so it doesn't leak into ...baseOptions. The router controls this via delegatedReasoning below.
|
|
359
|
+
const { onPayload, headers: originalHeaders, reasoning: _incomingReasoning, ...baseOptions } = options ?? {};
|
|
360
|
+
|
|
361
|
+
const effectiveHeaders: Record<string, string> = {
|
|
362
|
+
...(originalHeaders as Record<string, string> | undefined),
|
|
363
|
+
...(auth.headers ?? {}),
|
|
364
|
+
...(targetProvider === 'openrouter' ? OPENROUTER_ATTR_HEADERS : {}),
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const delegatedReasoning = targetModel.reasoning && decision.thinking !== 'off' ? decision.thinking : undefined;
|
|
368
|
+
pi.setThinkingLevel(delegatedReasoning ?? 'off');
|
|
369
|
+
|
|
370
|
+
const effectiveOptions: SimpleStreamOptions = {
|
|
371
|
+
...baseOptions,
|
|
372
|
+
apiKey: auth.apiKey,
|
|
373
|
+
headers: effectiveHeaders,
|
|
374
|
+
...(delegatedReasoning ? { reasoning: delegatedReasoning } : {}),
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
if (targetProvider === 'openrouter') {
|
|
378
|
+
effectiveOptions.onPayload = createOpenRouterOnPayload(ctx?.sessionManager, onPayload) ?? onPayload;
|
|
379
|
+
} else if (onPayload) {
|
|
380
|
+
effectiveOptions.onPayload = onPayload;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const MAX_ATTEMPTS_PER_MODEL = 2;
|
|
384
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS_PER_MODEL; attempt++) {
|
|
385
|
+
try {
|
|
386
|
+
const delegatedStream = streamSimple(targetModel, effectiveContext, effectiveOptions);
|
|
387
|
+
let contentReceived = false;
|
|
388
|
+
for await (const event of delegatedStream) {
|
|
389
|
+
if (event.type === 'error' && !contentReceived) {
|
|
390
|
+
throw new Error(event.error.errorMessage || 'Model failed before sending content.');
|
|
391
|
+
}
|
|
392
|
+
if (
|
|
393
|
+
event.type === 'text_delta' ||
|
|
394
|
+
event.type === 'thinking_delta' ||
|
|
395
|
+
event.type === 'toolcall_delta' ||
|
|
396
|
+
event.type === 'toolcall_end'
|
|
397
|
+
) {
|
|
398
|
+
contentReceived = true;
|
|
399
|
+
}
|
|
400
|
+
stream.push(event);
|
|
401
|
+
}
|
|
402
|
+
success = true;
|
|
403
|
+
state.lastDecision = decision;
|
|
404
|
+
break attemptLoop;
|
|
405
|
+
} catch (err) {
|
|
406
|
+
lastError = err;
|
|
407
|
+
const remaining = MAX_ATTEMPTS_PER_MODEL - attempt;
|
|
408
|
+
const retryMsg = remaining > 0 ? ` — ${remaining} ${remaining === 1 ? 'retry' : 'retries'} left` : '';
|
|
409
|
+
ctx?.ui.notify(`Failed to reach model ${modelRef} (attempt ${attempt}/${MAX_ATTEMPTS_PER_MODEL}): ${err}${retryMsg}`, 'warning');
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
actions.recordDebugDecision(decision);
|
|
417
|
+
|
|
418
|
+
if (!success) {
|
|
419
|
+
const errorMsg = `Failed to delegate to any model in the chain.${failureReasons.length > 0 ? ' Reasons: ' + failureReasons.filter(Boolean).join('; ') + '.' : ''}`;
|
|
420
|
+
const combined = lastError ? new Error(`${(lastError as Error).message} — ${errorMsg}`) : new Error(errorMsg);
|
|
421
|
+
throw combined;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
stream.end();
|
|
425
|
+
} catch (error) {
|
|
426
|
+
stream.push({
|
|
427
|
+
type: 'error',
|
|
428
|
+
reason: 'error',
|
|
429
|
+
error: createErrorMessage(model, error instanceof Error ? error.message : String(error)),
|
|
430
|
+
});
|
|
431
|
+
stream.end();
|
|
432
|
+
} finally {
|
|
433
|
+
actions.persistState();
|
|
434
|
+
}
|
|
435
|
+
})();
|
|
436
|
+
|
|
437
|
+
return stream;
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
state.lastLoadedModelKeys = loadedModelKeys;
|
|
442
|
+
};
|