@alexeiled/pi-model-router 0.5.1 → 0.6.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 +21 -0
- package/README.md +145 -24
- package/extensions/classifier.ts +119 -0
- package/extensions/commands.ts +76 -40
- package/extensions/config.ts +240 -146
- package/extensions/context.ts +91 -0
- package/extensions/index.ts +120 -132
- package/extensions/jev.ts +223 -0
- package/extensions/provider.ts +392 -178
- package/extensions/routing.ts +233 -436
- package/extensions/state.ts +74 -25
- package/extensions/types.ts +148 -52
- package/extensions/ui.ts +32 -34
- package/model-router.example.json +17 -10
- package/package.json +4 -4
package/extensions/provider.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
3
|
import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
|
|
3
4
|
import {
|
|
@@ -14,8 +15,8 @@ import type {
|
|
|
14
15
|
ExtensionAPI,
|
|
15
16
|
ExtensionContext,
|
|
16
17
|
} from '@earendil-works/pi-coding-agent';
|
|
18
|
+
import { runClassifier } from './classifier';
|
|
17
19
|
import {
|
|
18
|
-
clampThinkingLevel,
|
|
19
20
|
collectProfileThinkingLevels,
|
|
20
21
|
MAX_THINKING_LEVEL,
|
|
21
22
|
parseCanonicalModelRef,
|
|
@@ -25,6 +26,18 @@ import {
|
|
|
25
26
|
resolveMaxTokens,
|
|
26
27
|
} from './config';
|
|
27
28
|
import { DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS } from './constants';
|
|
29
|
+
import {
|
|
30
|
+
extractTextFromContent,
|
|
31
|
+
getBoundedRecentContext,
|
|
32
|
+
hasImageAttachment,
|
|
33
|
+
} from './context';
|
|
34
|
+
import { createJevCandidate, runJev } from './jev';
|
|
35
|
+
import {
|
|
36
|
+
availableRoutePairs,
|
|
37
|
+
decisionForPair,
|
|
38
|
+
primaryRoutePairs,
|
|
39
|
+
selectBaselineRoute,
|
|
40
|
+
} from './routing';
|
|
28
41
|
import type {
|
|
29
42
|
RouterConfig,
|
|
30
43
|
RouterPinByProfile,
|
|
@@ -68,16 +81,6 @@ export const waitForRegistry = async (
|
|
|
68
81
|
return undefined;
|
|
69
82
|
};
|
|
70
83
|
|
|
71
|
-
import {
|
|
72
|
-
buildRoutingDecision,
|
|
73
|
-
decideRouting,
|
|
74
|
-
extractTextFromContent,
|
|
75
|
-
hasImageAttachment,
|
|
76
|
-
phaseForTier,
|
|
77
|
-
resolveAvailableTier,
|
|
78
|
-
runClassifier,
|
|
79
|
-
} from './routing';
|
|
80
|
-
|
|
81
84
|
export const createErrorMessage = (
|
|
82
85
|
model: Model<Api>,
|
|
83
86
|
message: string,
|
|
@@ -130,19 +133,28 @@ const truncateContext = (context: Context, limit: number): Context => {
|
|
|
130
133
|
|
|
131
134
|
// Drop only complete turns. Splitting an assistant/tool-result pair corrupts transcripts.
|
|
132
135
|
// This text estimate cannot guarantee a fit for images/tools or one oversized active turn.
|
|
136
|
+
const systemMessages = messages.filter(
|
|
137
|
+
(message) => message.role === 'system',
|
|
138
|
+
);
|
|
133
139
|
let remaining = totalTokens;
|
|
134
|
-
let
|
|
135
|
-
for (let i =
|
|
136
|
-
if (messages[i]
|
|
137
|
-
while (
|
|
140
|
+
let nextRemovableIndex = 0;
|
|
141
|
+
for (let i = 0; i < messages.length && remaining > limit; i += 1) {
|
|
142
|
+
if (messages[i]?.role !== 'user') continue;
|
|
143
|
+
while (nextRemovableIndex < i) {
|
|
144
|
+
const candidate = messages[nextRemovableIndex];
|
|
145
|
+
if (candidate?.role !== 'system') {
|
|
146
|
+
remaining -= messageTokens[nextRemovableIndex] ?? 0;
|
|
147
|
+
}
|
|
148
|
+
nextRemovableIndex += 1;
|
|
149
|
+
}
|
|
138
150
|
}
|
|
139
151
|
return {
|
|
140
152
|
...context,
|
|
141
153
|
messages: [
|
|
154
|
+
...systemMessages,
|
|
142
155
|
...messages
|
|
143
|
-
.slice(
|
|
144
|
-
.filter((message) => message.role
|
|
145
|
-
...messages.slice(startIndex),
|
|
156
|
+
.slice(nextRemovableIndex)
|
|
157
|
+
.filter((message) => message.role !== 'system'),
|
|
146
158
|
],
|
|
147
159
|
};
|
|
148
160
|
};
|
|
@@ -201,24 +213,29 @@ export const registerRouterProvider = (
|
|
|
201
213
|
const profileList = profileNames(state.currentConfig);
|
|
202
214
|
|
|
203
215
|
// Map profiles to their capacities
|
|
204
|
-
const modelDefinitions = profileList.
|
|
216
|
+
const modelDefinitions = profileList.flatMap((name) => {
|
|
205
217
|
const profile = state.currentConfig.profiles[name];
|
|
218
|
+
if (!profile) return [];
|
|
206
219
|
|
|
207
220
|
// Report the MAX context window and max output tokens across all tiers.
|
|
208
221
|
// The honesty check + truncateContext handles the case where the
|
|
209
222
|
// actually routed model is smaller.
|
|
210
223
|
let maxContextWindow = 0;
|
|
211
|
-
let
|
|
224
|
+
let maxOutputTokens = 0;
|
|
212
225
|
for (const tier of ROUTER_TIERS) {
|
|
213
226
|
if (!profile[tier]) continue;
|
|
214
|
-
const
|
|
227
|
+
const contextWindow = resolveContextWindow(
|
|
228
|
+
tier,
|
|
229
|
+
profile,
|
|
230
|
+
state.currentModelRegistry,
|
|
231
|
+
);
|
|
232
|
+
const maxTokens = resolveMaxTokens(
|
|
215
233
|
tier,
|
|
216
234
|
profile,
|
|
217
235
|
state.currentModelRegistry,
|
|
218
236
|
);
|
|
219
|
-
|
|
220
|
-
if (
|
|
221
|
-
if (mot > maxMaxTokens) maxMaxTokens = mot;
|
|
237
|
+
if (contextWindow > maxContextWindow) maxContextWindow = contextWindow;
|
|
238
|
+
if (maxTokens > maxOutputTokens) maxOutputTokens = maxTokens;
|
|
222
239
|
}
|
|
223
240
|
|
|
224
241
|
const hasReasoning = supportsReasoning(profile, state.currentModelRegistry);
|
|
@@ -233,21 +250,55 @@ export const registerRouterProvider = (
|
|
|
233
250
|
if (Object.keys(map).length > 0) thinkingLevelMap = map;
|
|
234
251
|
}
|
|
235
252
|
|
|
236
|
-
return
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
253
|
+
return [
|
|
254
|
+
{
|
|
255
|
+
id: name,
|
|
256
|
+
name: `Router ${name}`,
|
|
257
|
+
reasoning: hasReasoning,
|
|
258
|
+
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
|
|
259
|
+
input: ['text', 'image'] satisfies ('text' | 'image')[],
|
|
260
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
261
|
+
contextWindow: maxContextWindow || DEFAULT_CONTEXT_WINDOW,
|
|
262
|
+
maxTokens: maxOutputTokens || DEFAULT_MAX_TOKENS,
|
|
263
|
+
},
|
|
264
|
+
];
|
|
246
265
|
});
|
|
247
266
|
|
|
248
267
|
const modelsKey = JSON.stringify(modelDefinitions);
|
|
249
268
|
if (state.lastRegisteredModels === modelsKey) return;
|
|
250
269
|
|
|
270
|
+
// Runtime only: no task identities or branch metadata enter persisted decisions.
|
|
271
|
+
type ContinuationRecord = {
|
|
272
|
+
turn: string;
|
|
273
|
+
policy: string;
|
|
274
|
+
branch: string[];
|
|
275
|
+
decision: RoutingDecision;
|
|
276
|
+
toolCalls: Set<string>;
|
|
277
|
+
config: RouterConfig;
|
|
278
|
+
};
|
|
279
|
+
// Streams can complete out of order. Keep a small turn-keyed history rather
|
|
280
|
+
// than letting the latest stream replace another stream's continuation.
|
|
281
|
+
const continuations = new Map<string, ContinuationRecord>();
|
|
282
|
+
const advisedTurns = new Map<string, true>();
|
|
283
|
+
const rememberContinuation = (record: ContinuationRecord) => {
|
|
284
|
+
continuations.delete(record.turn);
|
|
285
|
+
continuations.set(record.turn, record);
|
|
286
|
+
while (continuations.size > 16) {
|
|
287
|
+
const oldest = continuations.keys().next().value;
|
|
288
|
+
if (oldest === undefined) break;
|
|
289
|
+
continuations.delete(oldest);
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
const rememberAdvisedTurn = (turn: string) => {
|
|
293
|
+
advisedTurns.delete(turn);
|
|
294
|
+
advisedTurns.set(turn, true);
|
|
295
|
+
while (advisedTurns.size > 16) {
|
|
296
|
+
const oldest = advisedTurns.keys().next().value;
|
|
297
|
+
if (oldest === undefined) break;
|
|
298
|
+
advisedTurns.delete(oldest);
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
251
302
|
pi.registerProvider('router', {
|
|
252
303
|
baseUrl: 'router://local',
|
|
253
304
|
apiKey: 'pi-model-router',
|
|
@@ -260,8 +311,10 @@ export const registerRouterProvider = (
|
|
|
260
311
|
): AssistantMessageEventStream {
|
|
261
312
|
const stream = createAssistantMessageEventStream();
|
|
262
313
|
|
|
263
|
-
(async () => {
|
|
314
|
+
void (async () => {
|
|
264
315
|
let partialMessage: AssistantMessage | undefined;
|
|
316
|
+
let activeTurn: string | undefined;
|
|
317
|
+
let generationSucceeded = false;
|
|
265
318
|
try {
|
|
266
319
|
// Wait for the router to be fully initialized (session_start sets currentModelRegistry).
|
|
267
320
|
// This handles the race where subagents (e.g. from pi-dynamic-workflows) invoke
|
|
@@ -281,6 +334,7 @@ export const registerRouterProvider = (
|
|
|
281
334
|
throw new Error(`Unknown router profile: ${model.id}`);
|
|
282
335
|
}
|
|
283
336
|
|
|
337
|
+
options?.signal?.throwIfAborted();
|
|
284
338
|
state.selectedProfile = model.id;
|
|
285
339
|
state.routerEnabled = true;
|
|
286
340
|
|
|
@@ -289,131 +343,270 @@ export const registerRouterProvider = (
|
|
|
289
343
|
state.currentConfig.maxSessionBudget !== undefined &&
|
|
290
344
|
state.accumulatedCost >= state.currentConfig.maxSessionBudget;
|
|
291
345
|
|
|
292
|
-
|
|
293
|
-
|
|
346
|
+
const imageAttached = hasImageAttachment(context);
|
|
347
|
+
const findModel = (provider: string, id: string) =>
|
|
348
|
+
registry.find(provider, id);
|
|
349
|
+
const thinkingOverrides = state.thinkingByProfile[model.id];
|
|
350
|
+
const available = () =>
|
|
351
|
+
availableRoutePairs(
|
|
352
|
+
profile,
|
|
353
|
+
findModel,
|
|
354
|
+
imageAttached,
|
|
355
|
+
thinkingOverrides,
|
|
356
|
+
);
|
|
357
|
+
let pairs = available();
|
|
358
|
+
const lastUserIndex = context.messages.findLastIndex(
|
|
359
|
+
(entry) => entry.role === 'user',
|
|
360
|
+
);
|
|
361
|
+
const user = context.messages[lastUserIndex];
|
|
362
|
+
const turn =
|
|
363
|
+
user?.role === 'user' && user.timestamp > 0
|
|
364
|
+
? createHash('sha256')
|
|
365
|
+
.update(
|
|
366
|
+
JSON.stringify(
|
|
367
|
+
context.messages.slice(0, lastUserIndex + 1),
|
|
368
|
+
),
|
|
369
|
+
)
|
|
370
|
+
.digest('hex')
|
|
371
|
+
: undefined;
|
|
372
|
+
activeTurn = turn;
|
|
373
|
+
const branch =
|
|
374
|
+
state.lastExtensionContext?.sessionManager
|
|
375
|
+
.getBranch()
|
|
376
|
+
.filter(
|
|
377
|
+
(entry) =>
|
|
378
|
+
entry.type !== 'custom' ||
|
|
379
|
+
entry.customType !== 'router-state',
|
|
380
|
+
)
|
|
381
|
+
.map((entry) => entry.id) ?? [];
|
|
382
|
+
// Pi resolves authentication per request. This validates provider/model
|
|
383
|
+
// identity, not the backend account behind that provider.
|
|
384
|
+
const policy = JSON.stringify([
|
|
294
385
|
model.id,
|
|
295
386
|
profile,
|
|
296
|
-
state.lastDecision,
|
|
297
387
|
pinnedTier,
|
|
298
|
-
|
|
299
|
-
state.currentConfig.
|
|
300
|
-
state.currentConfig.rules,
|
|
388
|
+
thinkingOverrides,
|
|
389
|
+
state.currentConfig.classifierModel,
|
|
301
390
|
isBudgetExceeded,
|
|
391
|
+
]);
|
|
392
|
+
const toolContinuation =
|
|
393
|
+
context.messages.at(-1)?.role === 'toolResult';
|
|
394
|
+
const continuationRecord =
|
|
395
|
+
toolContinuation && turn ? continuations.get(turn) : undefined;
|
|
396
|
+
const continuationDecision = continuationRecord?.decision;
|
|
397
|
+
const latestAssistantIndex = context.messages.findLastIndex(
|
|
398
|
+
(entry) => entry.role === 'assistant',
|
|
302
399
|
);
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
400
|
+
const latestResults = context.messages
|
|
401
|
+
.slice(latestAssistantIndex + 1)
|
|
402
|
+
.filter((entry) => entry.role === 'toolResult');
|
|
403
|
+
const latestAssistant = context.messages[latestAssistantIndex];
|
|
404
|
+
const reusable =
|
|
405
|
+
toolContinuation &&
|
|
406
|
+
continuationRecord &&
|
|
407
|
+
continuationDecision &&
|
|
408
|
+
turn &&
|
|
409
|
+
continuationRecord.turn === turn &&
|
|
410
|
+
continuationRecord.policy === policy &&
|
|
411
|
+
continuationRecord.config === state.currentConfig &&
|
|
412
|
+
continuationDecision?.profile === model.id &&
|
|
413
|
+
branch.length > 0 &&
|
|
414
|
+
branch.every((id) => typeof id === 'string' && id.length > 0) &&
|
|
415
|
+
continuationRecord.branch.length > 0 &&
|
|
416
|
+
continuationRecord.branch.every((id, i) => branch[i] === id) &&
|
|
417
|
+
latestResults.length > 0 &&
|
|
418
|
+
latestAssistant?.role === 'assistant' &&
|
|
419
|
+
latestAssistant.provider === continuationDecision.targetProvider &&
|
|
420
|
+
latestAssistant.model === continuationDecision.targetModelId &&
|
|
421
|
+
latestResults.every((entry) =>
|
|
422
|
+
latestAssistant.content.some(
|
|
423
|
+
(part) =>
|
|
424
|
+
part.type === 'toolCall' && part.id === entry.toolCallId,
|
|
425
|
+
),
|
|
426
|
+
) &&
|
|
427
|
+
latestResults.every((entry) =>
|
|
428
|
+
continuationRecord.toolCalls.has(entry.toolCallId),
|
|
429
|
+
) &&
|
|
430
|
+
pairs.some(
|
|
431
|
+
(pair) =>
|
|
432
|
+
pair.tier === continuationDecision.tier &&
|
|
433
|
+
pair.model === continuationDecision.targetLabel &&
|
|
434
|
+
pair.thinking === continuationDecision.thinking,
|
|
321
435
|
);
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const tier = resolveAvailableTier(profile, classifierResult.tier);
|
|
325
|
-
decision = buildRoutingDecision(
|
|
326
|
-
model.id,
|
|
327
|
-
profile,
|
|
328
|
-
tier,
|
|
329
|
-
phaseForTier(tier),
|
|
330
|
-
`Classifier: ${classifierResult.reasoning}`,
|
|
331
|
-
state.thinkingByProfile[model.id],
|
|
332
|
-
true,
|
|
333
|
-
);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
const lastMessage = context.messages[context.messages.length - 1];
|
|
338
|
-
const previousDecision = state.lastDecision;
|
|
339
|
-
const isGoogleThinkingToolContinuation =
|
|
340
|
-
lastMessage?.role === 'toolResult' &&
|
|
341
|
-
previousDecision?.profile === model.id &&
|
|
342
|
-
previousDecision.targetProvider === 'google' &&
|
|
343
|
-
previousDecision.thinking !== 'off' &&
|
|
344
|
-
decision.targetProvider === 'google' &&
|
|
345
|
-
decision.thinking !== 'off' &&
|
|
346
|
-
previousDecision.targetLabel !== decision.targetLabel;
|
|
347
|
-
|
|
348
|
-
if (isGoogleThinkingToolContinuation && previousDecision) {
|
|
436
|
+
let decision: RoutingDecision;
|
|
437
|
+
if (reusable && continuationDecision) {
|
|
349
438
|
decision = {
|
|
350
|
-
...
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
reasoning:
|
|
358
|
-
`Preserved ${previousDecision.targetLabel} for a Google tool-result continuation ` +
|
|
359
|
-
`to avoid thought-signature replay errors. (Original: ${decision.reasoning})`,
|
|
439
|
+
...continuationDecision,
|
|
440
|
+
reasonCode: 'continuation',
|
|
441
|
+
// Advisor diagnostics describe the original routing attempt only.
|
|
442
|
+
isClassifier: undefined,
|
|
443
|
+
routingLatencyMs: undefined,
|
|
444
|
+
errorClass: undefined,
|
|
445
|
+
timestamp: Date.now(),
|
|
360
446
|
};
|
|
447
|
+
} else {
|
|
448
|
+
if (toolContinuation && turn) continuations.delete(turn);
|
|
449
|
+
const baseline = selectBaselineRoute(
|
|
450
|
+
model.id,
|
|
451
|
+
profile,
|
|
452
|
+
pairs,
|
|
453
|
+
pinnedTier,
|
|
454
|
+
isBudgetExceeded,
|
|
455
|
+
);
|
|
456
|
+
decision = decisionForPair(
|
|
457
|
+
model.id,
|
|
458
|
+
baseline.pair,
|
|
459
|
+
baseline.reasonCode,
|
|
460
|
+
);
|
|
461
|
+
decision.isBudgetForced = baseline.isBudgetForced;
|
|
361
462
|
}
|
|
362
463
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
464
|
+
// Tool results never invoke advisors, even when their prior route cannot be reused.
|
|
465
|
+
if (
|
|
466
|
+
!toolContinuation &&
|
|
467
|
+
!pinnedTier &&
|
|
468
|
+
!isBudgetExceeded &&
|
|
469
|
+
user &&
|
|
470
|
+
turn &&
|
|
471
|
+
!advisedTurns.has(turn) &&
|
|
472
|
+
(state.currentConfig.classifierModel ||
|
|
473
|
+
(state.currentConfig.jev?.enabled && profile.jev?.enabled))
|
|
474
|
+
) {
|
|
475
|
+
rememberAdvisedTurn(turn);
|
|
476
|
+
const started = performance.now();
|
|
477
|
+
const jev = state.currentConfig.jev;
|
|
478
|
+
const useJev =
|
|
479
|
+
jev?.enabled &&
|
|
480
|
+
profile.jev?.enabled &&
|
|
481
|
+
jev.apiKey.trim().length > 0;
|
|
482
|
+
const routingDeadline = started + (useJev ? 1500 : 10_000);
|
|
483
|
+
const candidates = primaryRoutePairs(profile, pairs).map(
|
|
484
|
+
createJevCandidate,
|
|
485
|
+
);
|
|
486
|
+
// A single primary bypasses advice, not a baseline's eligible fallback.
|
|
487
|
+
if (candidates.length > 1) {
|
|
488
|
+
if (useJev && jev) {
|
|
489
|
+
const advice = await runJev(
|
|
490
|
+
{
|
|
491
|
+
...jev,
|
|
492
|
+
timeoutMs: Math.min(750, jev.timeoutMs),
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
taskSummary: getBoundedRecentContext(
|
|
496
|
+
context,
|
|
497
|
+
jev.maxStateChars,
|
|
498
|
+
),
|
|
499
|
+
candidates,
|
|
500
|
+
profile: profile.jev,
|
|
501
|
+
routingDeadline,
|
|
502
|
+
signal: options?.signal,
|
|
503
|
+
},
|
|
504
|
+
).catch(() => undefined);
|
|
505
|
+
options?.signal?.throwIfAborted();
|
|
506
|
+
// Re-read registry capabilities after the network boundary.
|
|
507
|
+
pairs = available();
|
|
508
|
+
const candidate = candidates.find(
|
|
509
|
+
(entry) => entry.id === advice?.candidateId,
|
|
510
|
+
);
|
|
511
|
+
if (
|
|
512
|
+
candidate &&
|
|
513
|
+
performance.now() < routingDeadline &&
|
|
514
|
+
pairs.some(
|
|
515
|
+
(pair) =>
|
|
516
|
+
pair.model === candidate.model &&
|
|
517
|
+
pair.tier === candidate.tier &&
|
|
518
|
+
pair.thinking === candidate.thinking,
|
|
519
|
+
)
|
|
520
|
+
) {
|
|
521
|
+
decision = decisionForPair(model.id, candidate, 'jev');
|
|
522
|
+
} else {
|
|
523
|
+
const baseline = selectBaselineRoute(
|
|
524
|
+
model.id,
|
|
525
|
+
profile,
|
|
526
|
+
pairs,
|
|
527
|
+
);
|
|
528
|
+
decision = decisionForPair(
|
|
529
|
+
model.id,
|
|
530
|
+
baseline.pair,
|
|
531
|
+
baseline.reasonCode,
|
|
532
|
+
);
|
|
533
|
+
decision.errorClass = 'advisor-unavailable';
|
|
398
534
|
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
535
|
+
} else if (state.currentConfig.classifierModel) {
|
|
536
|
+
const classifier = state.currentConfig.classifierModel;
|
|
537
|
+
const result = await runClassifier(
|
|
538
|
+
classifier.model,
|
|
539
|
+
registry,
|
|
540
|
+
context,
|
|
541
|
+
undefined,
|
|
542
|
+
classifier.thinking,
|
|
543
|
+
options?.signal,
|
|
544
|
+
routingDeadline,
|
|
545
|
+
).catch(() => undefined);
|
|
546
|
+
options?.signal?.throwIfAborted();
|
|
547
|
+
pairs = available();
|
|
548
|
+
const baseline = selectBaselineRoute(model.id, profile, pairs);
|
|
549
|
+
decision = decisionForPair(
|
|
403
550
|
model.id,
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
phaseForTier(foundTier),
|
|
407
|
-
`Forced ${foundTier} tier because the originally routed ${decision.tier} tier does not support image attachments.`,
|
|
408
|
-
state.thinkingByProfile[model.id],
|
|
409
|
-
false,
|
|
551
|
+
baseline.pair,
|
|
552
|
+
baseline.reasonCode,
|
|
410
553
|
);
|
|
554
|
+
if (result && performance.now() < routingDeadline) {
|
|
555
|
+
const pair = pairs.find(
|
|
556
|
+
(entry) => entry.tier === result.tier,
|
|
557
|
+
);
|
|
558
|
+
if (pair) {
|
|
559
|
+
decision = {
|
|
560
|
+
...decisionForPair(model.id, pair, 'classifier'),
|
|
561
|
+
isClassifier: true,
|
|
562
|
+
};
|
|
563
|
+
} else decision.errorClass = 'advisor-unavailable';
|
|
564
|
+
} else decision.errorClass = 'advisor-unavailable';
|
|
411
565
|
}
|
|
566
|
+
decision.routingLatencyMs = Math.max(
|
|
567
|
+
0,
|
|
568
|
+
performance.now() - started,
|
|
569
|
+
);
|
|
570
|
+
if (performance.now() >= routingDeadline)
|
|
571
|
+
decision.errorClass = 'deadline';
|
|
412
572
|
}
|
|
413
573
|
}
|
|
414
574
|
|
|
575
|
+
// Google thought signatures cannot be replayed against a different thinking model.
|
|
576
|
+
const priorAssistant = context.messages[latestAssistantIndex];
|
|
577
|
+
if (
|
|
578
|
+
toolContinuation &&
|
|
579
|
+
priorAssistant?.role === 'assistant' &&
|
|
580
|
+
priorAssistant.provider === 'google' &&
|
|
581
|
+
priorAssistant.content.some(
|
|
582
|
+
(entry) =>
|
|
583
|
+
entry.type === 'thinking' ||
|
|
584
|
+
(entry.type === 'toolCall' && entry.thoughtSignature),
|
|
585
|
+
) &&
|
|
586
|
+
(decision.targetProvider !== priorAssistant.provider ||
|
|
587
|
+
decision.targetModelId !== priorAssistant.model)
|
|
588
|
+
) {
|
|
589
|
+
const priorPair =
|
|
590
|
+
pairs.find(
|
|
591
|
+
(pair) =>
|
|
592
|
+
pair.model ===
|
|
593
|
+
`${priorAssistant.provider}/${priorAssistant.model}` &&
|
|
594
|
+
pair.tier === continuationDecision?.tier &&
|
|
595
|
+
pair.thinking === continuationDecision?.thinking,
|
|
596
|
+
) ??
|
|
597
|
+
pairs.find(
|
|
598
|
+
(pair) =>
|
|
599
|
+
pair.model ===
|
|
600
|
+
`${priorAssistant.provider}/${priorAssistant.model}`,
|
|
601
|
+
);
|
|
602
|
+
if (!priorPair)
|
|
603
|
+
throw new Error(
|
|
604
|
+
'No compatible route for Google tool continuation.',
|
|
605
|
+
);
|
|
606
|
+
decision = decisionForPair(model.id, priorPair, 'continuation');
|
|
607
|
+
}
|
|
608
|
+
|
|
415
609
|
state.lastDecision = decision;
|
|
416
|
-
actions.recordDebugDecision(decision);
|
|
417
610
|
|
|
418
611
|
// Sync pi's thinking level display with the router's effective thinking.
|
|
419
612
|
// Wrapped in try/catch: in subagent contexts the extension runtime
|
|
@@ -430,26 +623,16 @@ export const registerRouterProvider = (
|
|
|
430
623
|
// Stale extension context — skip non-critical UI updates.
|
|
431
624
|
}
|
|
432
625
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
];
|
|
439
|
-
if (imageAttached) {
|
|
440
|
-
modelsToTry = modelsToTry.filter(checkModelSupportsImage);
|
|
441
|
-
if (modelsToTry.length === 0) {
|
|
442
|
-
throw new Error(
|
|
443
|
-
'No configured model supports image attachments.',
|
|
444
|
-
);
|
|
445
|
-
}
|
|
446
|
-
}
|
|
626
|
+
// Explicit fallback refs authorize provider changes; never discover other accounts.
|
|
627
|
+
const modelsToTry = [
|
|
628
|
+
decision.targetLabel,
|
|
629
|
+
...(profile[decision.tier]?.fallbacks ?? []),
|
|
630
|
+
].filter((ref, i, refs) => refs.indexOf(ref) === i);
|
|
447
631
|
let lastError: unknown;
|
|
448
632
|
let success = false;
|
|
449
633
|
|
|
450
|
-
for (
|
|
634
|
+
for (const [i, modelRef] of modelsToTry.entries()) {
|
|
451
635
|
options?.signal?.throwIfAborted();
|
|
452
|
-
const modelRef = modelsToTry[i];
|
|
453
636
|
const { provider: targetProvider, modelId: targetModelId } =
|
|
454
637
|
parseCanonicalModelRef(modelRef);
|
|
455
638
|
|
|
@@ -478,26 +661,33 @@ export const registerRouterProvider = (
|
|
|
478
661
|
effectiveContext = truncateContext(context, targetLimit);
|
|
479
662
|
}
|
|
480
663
|
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
664
|
+
const pair = available().find(
|
|
665
|
+
(candidate) =>
|
|
666
|
+
candidate.tier === decision.tier &&
|
|
667
|
+
candidate.model === modelRef,
|
|
484
668
|
);
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
669
|
+
if (!pair)
|
|
670
|
+
throw new Error(
|
|
671
|
+
'Routed model capabilities or thinking are unsupported.',
|
|
672
|
+
);
|
|
673
|
+
if (
|
|
674
|
+
toolContinuation &&
|
|
675
|
+
priorAssistant?.role === 'assistant' &&
|
|
676
|
+
priorAssistant.provider === 'google' &&
|
|
677
|
+
priorAssistant.content.some(
|
|
678
|
+
(entry) =>
|
|
679
|
+
entry.type === 'thinking' ||
|
|
680
|
+
(entry.type === 'toolCall' && entry.thoughtSignature),
|
|
681
|
+
) &&
|
|
682
|
+
(targetProvider !== priorAssistant.provider ||
|
|
683
|
+
targetModelId !== priorAssistant.model)
|
|
684
|
+
) {
|
|
685
|
+
throw new Error(
|
|
686
|
+
'No compatible route for Google tool continuation.',
|
|
687
|
+
);
|
|
495
688
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
targetModel.reasoning && requestedReasoning !== 'off'
|
|
499
|
-
? (requestedReasoning as SimpleStreamOptions['reasoning'])
|
|
500
|
-
: undefined;
|
|
689
|
+
const delegatedReasoning: SimpleStreamOptions['reasoning'] =
|
|
690
|
+
pair.thinking !== 'off' ? pair.thinking : undefined;
|
|
501
691
|
|
|
502
692
|
try {
|
|
503
693
|
if (state.lastExtensionContext) {
|
|
@@ -522,7 +712,7 @@ export const registerRouterProvider = (
|
|
|
522
712
|
...delegationOptions
|
|
523
713
|
} = options ?? {};
|
|
524
714
|
|
|
525
|
-
const delegatedOptions = {
|
|
715
|
+
const delegatedOptions: SimpleStreamOptions = {
|
|
526
716
|
...delegationOptions,
|
|
527
717
|
...(delegatedReasoning
|
|
528
718
|
? { reasoning: delegatedReasoning }
|
|
@@ -541,7 +731,13 @@ export const registerRouterProvider = (
|
|
|
541
731
|
decision.targetModelId = targetModelId;
|
|
542
732
|
decision.targetLabel = modelRef;
|
|
543
733
|
decision.thinking = delegatedReasoning ?? 'off';
|
|
544
|
-
decision.isFallback =
|
|
734
|
+
decision.isFallback =
|
|
735
|
+
i > 0 || modelRef !== profile[decision.tier]?.model;
|
|
736
|
+
if (
|
|
737
|
+
decision.isFallback &&
|
|
738
|
+
decision.reasonCode !== 'continuation'
|
|
739
|
+
)
|
|
740
|
+
decision.reasonCode = 'fallback';
|
|
545
741
|
};
|
|
546
742
|
for await (const event of delegatedStream) {
|
|
547
743
|
if (
|
|
@@ -572,10 +768,25 @@ export const registerRouterProvider = (
|
|
|
572
768
|
}
|
|
573
769
|
if (event.type === 'done' || event.type === 'error') {
|
|
574
770
|
terminalReceived = true;
|
|
771
|
+
generationSucceeded = event.type === 'done';
|
|
575
772
|
recordTarget();
|
|
576
773
|
const cost = (
|
|
577
774
|
event.type === 'done' ? event.message : event.error
|
|
578
775
|
).usage.cost.total;
|
|
776
|
+
if (event.type === 'done' && turn) {
|
|
777
|
+
rememberContinuation({
|
|
778
|
+
turn,
|
|
779
|
+
policy,
|
|
780
|
+
branch,
|
|
781
|
+
decision,
|
|
782
|
+
config: state.currentConfig,
|
|
783
|
+
toolCalls: new Set(
|
|
784
|
+
event.message.content.flatMap((entry) =>
|
|
785
|
+
entry.type === 'toolCall' ? [entry.id] : [],
|
|
786
|
+
),
|
|
787
|
+
),
|
|
788
|
+
});
|
|
789
|
+
}
|
|
579
790
|
if (Number.isFinite(cost) && cost > 0)
|
|
580
791
|
state.accumulatedCost += cost;
|
|
581
792
|
}
|
|
@@ -611,6 +822,7 @@ export const registerRouterProvider = (
|
|
|
611
822
|
);
|
|
612
823
|
}
|
|
613
824
|
|
|
825
|
+
actions.recordDebugDecision(decision);
|
|
614
826
|
stream.end();
|
|
615
827
|
} catch (error) {
|
|
616
828
|
const reason = options?.signal?.aborted ? 'aborted' : 'error';
|
|
@@ -626,6 +838,8 @@ export const registerRouterProvider = (
|
|
|
626
838
|
});
|
|
627
839
|
stream.end();
|
|
628
840
|
} finally {
|
|
841
|
+
if (!generationSucceeded && activeTurn)
|
|
842
|
+
advisedTurns.delete(activeTurn);
|
|
629
843
|
try {
|
|
630
844
|
actions.persistState();
|
|
631
845
|
} catch {
|