@alexeiled/pi-model-router 0.5.2 → 0.6.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/CHANGELOG.md +18 -0
- package/README.md +150 -24
- package/extensions/classifier.ts +96 -70
- package/extensions/commands.ts +42 -23
- package/extensions/config.ts +193 -102
- package/extensions/context.ts +61 -28
- package/extensions/index.ts +29 -10
- package/extensions/jev.ts +223 -0
- package/extensions/provider.ts +372 -143
- package/extensions/routing.ts +236 -296
- package/extensions/state.ts +55 -10
- package/extensions/types.ts +92 -12
- package/extensions/ui.ts +59 -8
- package/model-router.example.json +17 -10
- package/package.json +1 -1
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 {
|
|
@@ -16,7 +17,6 @@ import type {
|
|
|
16
17
|
} from '@earendil-works/pi-coding-agent';
|
|
17
18
|
import { runClassifier } from './classifier';
|
|
18
19
|
import {
|
|
19
|
-
clampThinkingLevel,
|
|
20
20
|
collectProfileThinkingLevels,
|
|
21
21
|
MAX_THINKING_LEVEL,
|
|
22
22
|
parseCanonicalModelRef,
|
|
@@ -26,14 +26,20 @@ import {
|
|
|
26
26
|
resolveMaxTokens,
|
|
27
27
|
} from './config';
|
|
28
28
|
import { DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS } from './constants';
|
|
29
|
-
import { extractTextFromContent, hasImageAttachment } from './context';
|
|
30
29
|
import {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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,
|
|
35
40
|
} from './routing';
|
|
36
41
|
import type {
|
|
42
|
+
AdvisorOutcome,
|
|
37
43
|
RouterConfig,
|
|
38
44
|
RouterPinByProfile,
|
|
39
45
|
RouterThinkingByProfile,
|
|
@@ -262,6 +268,38 @@ export const registerRouterProvider = (
|
|
|
262
268
|
const modelsKey = JSON.stringify(modelDefinitions);
|
|
263
269
|
if (state.lastRegisteredModels === modelsKey) return;
|
|
264
270
|
|
|
271
|
+
// Runtime only: no task identities or branch metadata enter persisted decisions.
|
|
272
|
+
type ContinuationRecord = {
|
|
273
|
+
turn: string;
|
|
274
|
+
policy: string;
|
|
275
|
+
branch: string[];
|
|
276
|
+
decision: RoutingDecision;
|
|
277
|
+
toolCalls: Set<string>;
|
|
278
|
+
config: RouterConfig;
|
|
279
|
+
};
|
|
280
|
+
// Streams can complete out of order. Keep a small turn-keyed history rather
|
|
281
|
+
// than letting the latest stream replace another stream's continuation.
|
|
282
|
+
const continuations = new Map<string, ContinuationRecord>();
|
|
283
|
+
const advisedTurns = new Map<string, AdvisorOutcome>();
|
|
284
|
+
const rememberContinuation = (record: ContinuationRecord) => {
|
|
285
|
+
continuations.delete(record.turn);
|
|
286
|
+
continuations.set(record.turn, record);
|
|
287
|
+
while (continuations.size > 16) {
|
|
288
|
+
const oldest = continuations.keys().next().value;
|
|
289
|
+
if (oldest === undefined) break;
|
|
290
|
+
continuations.delete(oldest);
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
const rememberAdvisedTurn = (turn: string, outcome: AdvisorOutcome) => {
|
|
294
|
+
advisedTurns.delete(turn);
|
|
295
|
+
advisedTurns.set(turn, outcome);
|
|
296
|
+
while (advisedTurns.size > 16) {
|
|
297
|
+
const oldest = advisedTurns.keys().next().value;
|
|
298
|
+
if (oldest === undefined) break;
|
|
299
|
+
advisedTurns.delete(oldest);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
265
303
|
pi.registerProvider('router', {
|
|
266
304
|
baseUrl: 'router://local',
|
|
267
305
|
apiKey: 'pi-model-router',
|
|
@@ -276,6 +314,8 @@ export const registerRouterProvider = (
|
|
|
276
314
|
|
|
277
315
|
void (async () => {
|
|
278
316
|
let partialMessage: AssistantMessage | undefined;
|
|
317
|
+
let activeTurn: string | undefined;
|
|
318
|
+
let generationSucceeded = false;
|
|
279
319
|
try {
|
|
280
320
|
// Wait for the router to be fully initialized (session_start sets currentModelRegistry).
|
|
281
321
|
// This handles the race where subagents (e.g. from pi-dynamic-workflows) invoke
|
|
@@ -295,6 +335,7 @@ export const registerRouterProvider = (
|
|
|
295
335
|
throw new Error(`Unknown router profile: ${model.id}`);
|
|
296
336
|
}
|
|
297
337
|
|
|
338
|
+
options?.signal?.throwIfAborted();
|
|
298
339
|
state.selectedProfile = model.id;
|
|
299
340
|
state.routerEnabled = true;
|
|
300
341
|
|
|
@@ -303,131 +344,297 @@ export const registerRouterProvider = (
|
|
|
303
344
|
state.currentConfig.maxSessionBudget !== undefined &&
|
|
304
345
|
state.accumulatedCost >= state.currentConfig.maxSessionBudget;
|
|
305
346
|
|
|
306
|
-
|
|
307
|
-
|
|
347
|
+
const imageAttached = hasImageAttachment(context);
|
|
348
|
+
const findModel = (provider: string, id: string) =>
|
|
349
|
+
registry.find(provider, id);
|
|
350
|
+
const thinkingOverrides = state.thinkingByProfile[model.id];
|
|
351
|
+
const available = () =>
|
|
352
|
+
availableRoutePairs(
|
|
353
|
+
profile,
|
|
354
|
+
findModel,
|
|
355
|
+
imageAttached,
|
|
356
|
+
thinkingOverrides,
|
|
357
|
+
);
|
|
358
|
+
let pairs = available();
|
|
359
|
+
const lastUserIndex = context.messages.findLastIndex(
|
|
360
|
+
(entry) => entry.role === 'user',
|
|
361
|
+
);
|
|
362
|
+
const user = context.messages[lastUserIndex];
|
|
363
|
+
const turn =
|
|
364
|
+
user?.role === 'user' && user.timestamp > 0
|
|
365
|
+
? createHash('sha256')
|
|
366
|
+
.update(
|
|
367
|
+
JSON.stringify(
|
|
368
|
+
context.messages.slice(0, lastUserIndex + 1),
|
|
369
|
+
),
|
|
370
|
+
)
|
|
371
|
+
.digest('hex')
|
|
372
|
+
: undefined;
|
|
373
|
+
activeTurn = turn;
|
|
374
|
+
const branch =
|
|
375
|
+
state.lastExtensionContext?.sessionManager
|
|
376
|
+
.getBranch()
|
|
377
|
+
.filter(
|
|
378
|
+
(entry) =>
|
|
379
|
+
entry.type !== 'custom' ||
|
|
380
|
+
entry.customType !== 'router-state',
|
|
381
|
+
)
|
|
382
|
+
.map((entry) => entry.id) ?? [];
|
|
383
|
+
// Pi resolves authentication per request. This validates provider/model
|
|
384
|
+
// identity, not the backend account behind that provider.
|
|
385
|
+
const policy = JSON.stringify([
|
|
308
386
|
model.id,
|
|
309
387
|
profile,
|
|
310
|
-
state.lastDecision,
|
|
311
388
|
pinnedTier,
|
|
312
|
-
|
|
313
|
-
state.currentConfig.
|
|
314
|
-
state.currentConfig.rules,
|
|
389
|
+
thinkingOverrides,
|
|
390
|
+
state.currentConfig.classifierModel,
|
|
315
391
|
isBudgetExceeded,
|
|
392
|
+
]);
|
|
393
|
+
const toolContinuation =
|
|
394
|
+
context.messages.at(-1)?.role === 'toolResult';
|
|
395
|
+
const jev = state.currentConfig.jev;
|
|
396
|
+
const useJev = Boolean(
|
|
397
|
+
jev?.enabled &&
|
|
398
|
+
profile.jev?.enabled &&
|
|
399
|
+
jev.apiKey.trim().length > 0,
|
|
400
|
+
);
|
|
401
|
+
const advisorConfigured =
|
|
402
|
+
useJev || Boolean(state.currentConfig.classifierModel);
|
|
403
|
+
const continuationRecord =
|
|
404
|
+
toolContinuation && turn ? continuations.get(turn) : undefined;
|
|
405
|
+
const continuationDecision = continuationRecord?.decision;
|
|
406
|
+
const latestAssistantIndex = context.messages.findLastIndex(
|
|
407
|
+
(entry) => entry.role === 'assistant',
|
|
316
408
|
);
|
|
409
|
+
const latestResults = context.messages
|
|
410
|
+
.slice(latestAssistantIndex + 1)
|
|
411
|
+
.filter((entry) => entry.role === 'toolResult');
|
|
412
|
+
const latestAssistant = context.messages[latestAssistantIndex];
|
|
413
|
+
const reusable =
|
|
414
|
+
toolContinuation &&
|
|
415
|
+
continuationRecord &&
|
|
416
|
+
continuationDecision &&
|
|
417
|
+
turn &&
|
|
418
|
+
continuationRecord.turn === turn &&
|
|
419
|
+
continuationRecord.policy === policy &&
|
|
420
|
+
continuationRecord.config === state.currentConfig &&
|
|
421
|
+
continuationDecision?.profile === model.id &&
|
|
422
|
+
branch.length > 0 &&
|
|
423
|
+
branch.every((id) => typeof id === 'string' && id.length > 0) &&
|
|
424
|
+
continuationRecord.branch.length > 0 &&
|
|
425
|
+
continuationRecord.branch.every((id, i) => branch[i] === id) &&
|
|
426
|
+
latestResults.length > 0 &&
|
|
427
|
+
latestAssistant?.role === 'assistant' &&
|
|
428
|
+
latestAssistant.provider === continuationDecision.targetProvider &&
|
|
429
|
+
latestAssistant.model === continuationDecision.targetModelId &&
|
|
430
|
+
latestResults.every((entry) =>
|
|
431
|
+
latestAssistant.content.some(
|
|
432
|
+
(part) =>
|
|
433
|
+
part.type === 'toolCall' && part.id === entry.toolCallId,
|
|
434
|
+
),
|
|
435
|
+
) &&
|
|
436
|
+
latestResults.every((entry) =>
|
|
437
|
+
continuationRecord.toolCalls.has(entry.toolCallId),
|
|
438
|
+
) &&
|
|
439
|
+
pairs.some(
|
|
440
|
+
(pair) =>
|
|
441
|
+
pair.tier === continuationDecision.tier &&
|
|
442
|
+
pair.model === continuationDecision.targetLabel &&
|
|
443
|
+
pair.thinking === continuationDecision.thinking,
|
|
444
|
+
);
|
|
445
|
+
let decision: RoutingDecision;
|
|
446
|
+
if (reusable && continuationDecision) {
|
|
447
|
+
decision = {
|
|
448
|
+
...continuationDecision,
|
|
449
|
+
reasonCode: 'continuation',
|
|
450
|
+
// Advisor diagnostics describe the original routing attempt only.
|
|
451
|
+
isClassifier: undefined,
|
|
452
|
+
routingLatencyMs: undefined,
|
|
453
|
+
errorClass: undefined,
|
|
454
|
+
timestamp: Date.now(),
|
|
455
|
+
};
|
|
456
|
+
} else {
|
|
457
|
+
if (toolContinuation && turn) continuations.delete(turn);
|
|
458
|
+
const baseline = selectBaselineRoute(
|
|
459
|
+
model.id,
|
|
460
|
+
profile,
|
|
461
|
+
pairs,
|
|
462
|
+
pinnedTier,
|
|
463
|
+
isBudgetExceeded,
|
|
464
|
+
);
|
|
465
|
+
decision = decisionForPair(
|
|
466
|
+
model.id,
|
|
467
|
+
baseline.pair,
|
|
468
|
+
baseline.reasonCode,
|
|
469
|
+
);
|
|
470
|
+
decision.isBudgetForced = baseline.isBudgetForced;
|
|
471
|
+
decision.advisor = advisorConfigured ? 'bypassed' : 'none';
|
|
472
|
+
if (!toolContinuation && turn) {
|
|
473
|
+
const previousAdvisor = advisedTurns.get(turn);
|
|
474
|
+
if (previousAdvisor) decision.advisor = previousAdvisor;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
317
477
|
|
|
318
|
-
//
|
|
319
|
-
// result would be downgraded anyway, saving an unnecessary LLM call.
|
|
478
|
+
// Tool results never invoke advisors, even when their prior route cannot be reused.
|
|
320
479
|
if (
|
|
321
|
-
|
|
480
|
+
!toolContinuation &&
|
|
322
481
|
!pinnedTier &&
|
|
323
|
-
!
|
|
324
|
-
|
|
482
|
+
!isBudgetExceeded &&
|
|
483
|
+
user &&
|
|
484
|
+
turn &&
|
|
485
|
+
!advisedTurns.has(turn) &&
|
|
486
|
+
advisorConfigured
|
|
325
487
|
) {
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
state.lastDecision?.profile === model.id
|
|
331
|
-
? state.lastDecision.phase
|
|
332
|
-
: undefined,
|
|
333
|
-
state.currentConfig.classifierModel.thinking,
|
|
334
|
-
options?.signal,
|
|
488
|
+
const started = performance.now();
|
|
489
|
+
const routingDeadline = started + (useJev ? 1500 : 10_000);
|
|
490
|
+
const candidates = primaryRoutePairs(profile, pairs).map(
|
|
491
|
+
createJevCandidate,
|
|
335
492
|
);
|
|
336
|
-
|
|
337
|
-
if (
|
|
338
|
-
|
|
339
|
-
|
|
493
|
+
// A single primary bypasses advice, not a baseline's eligible fallback.
|
|
494
|
+
if (candidates.length <= 1) {
|
|
495
|
+
decision.advisor = 'bypassed';
|
|
496
|
+
rememberAdvisedTurn(turn, 'bypassed');
|
|
497
|
+
} else if (useJev && jev) {
|
|
498
|
+
decision.advisor = 'jev';
|
|
499
|
+
rememberAdvisedTurn(turn, 'jev');
|
|
500
|
+
const advice = await runJev(
|
|
501
|
+
{
|
|
502
|
+
...jev,
|
|
503
|
+
timeoutMs: Math.min(750, jev.timeoutMs),
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
taskSummary: getBoundedRecentContext(
|
|
507
|
+
context,
|
|
508
|
+
jev.maxStateChars,
|
|
509
|
+
),
|
|
510
|
+
candidates,
|
|
511
|
+
profile: profile.jev,
|
|
512
|
+
routingDeadline,
|
|
513
|
+
signal: options?.signal,
|
|
514
|
+
},
|
|
515
|
+
).catch(() => undefined);
|
|
516
|
+
options?.signal?.throwIfAborted();
|
|
517
|
+
// Re-read registry capabilities after the network boundary.
|
|
518
|
+
pairs = available();
|
|
519
|
+
const candidate = candidates.find(
|
|
520
|
+
(entry) => entry.id === advice?.candidateId,
|
|
521
|
+
);
|
|
522
|
+
if (
|
|
523
|
+
candidate &&
|
|
524
|
+
performance.now() < routingDeadline &&
|
|
525
|
+
pairs.some(
|
|
526
|
+
(pair) =>
|
|
527
|
+
pair.model === candidate.model &&
|
|
528
|
+
pair.tier === candidate.tier &&
|
|
529
|
+
pair.thinking === candidate.thinking,
|
|
530
|
+
)
|
|
531
|
+
) {
|
|
532
|
+
decision = {
|
|
533
|
+
...decisionForPair(model.id, candidate, 'jev'),
|
|
534
|
+
advisor: 'jev',
|
|
535
|
+
};
|
|
536
|
+
} else {
|
|
537
|
+
const baseline = selectBaselineRoute(model.id, profile, pairs);
|
|
538
|
+
decision = decisionForPair(
|
|
539
|
+
model.id,
|
|
540
|
+
baseline.pair,
|
|
541
|
+
baseline.reasonCode,
|
|
542
|
+
);
|
|
543
|
+
decision.advisor = 'jev-fallback';
|
|
544
|
+
rememberAdvisedTurn(turn, 'jev-fallback');
|
|
545
|
+
decision.errorClass = 'advisor-unavailable';
|
|
546
|
+
}
|
|
547
|
+
decision.routingLatencyMs = Math.max(
|
|
548
|
+
0,
|
|
549
|
+
performance.now() - started,
|
|
550
|
+
);
|
|
551
|
+
if (performance.now() >= routingDeadline)
|
|
552
|
+
decision.errorClass = 'deadline';
|
|
553
|
+
} else if (state.currentConfig.classifierModel) {
|
|
554
|
+
const classifier = state.currentConfig.classifierModel;
|
|
555
|
+
const result = await runClassifier(
|
|
556
|
+
classifier.model,
|
|
557
|
+
registry,
|
|
558
|
+
context,
|
|
559
|
+
undefined,
|
|
560
|
+
classifier.thinking,
|
|
561
|
+
options?.signal,
|
|
562
|
+
routingDeadline,
|
|
563
|
+
).catch(() => undefined);
|
|
564
|
+
options?.signal?.throwIfAborted();
|
|
565
|
+
pairs = available();
|
|
566
|
+
const baseline = selectBaselineRoute(model.id, profile, pairs);
|
|
567
|
+
decision = decisionForPair(
|
|
340
568
|
model.id,
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
569
|
+
baseline.pair,
|
|
570
|
+
baseline.reasonCode,
|
|
571
|
+
);
|
|
572
|
+
if (result && performance.now() < routingDeadline) {
|
|
573
|
+
const pair = pairs.find((entry) => entry.tier === result.tier);
|
|
574
|
+
if (pair) {
|
|
575
|
+
decision = {
|
|
576
|
+
...decisionForPair(model.id, pair, 'classifier'),
|
|
577
|
+
isClassifier: true,
|
|
578
|
+
advisor: 'classifier',
|
|
579
|
+
};
|
|
580
|
+
rememberAdvisedTurn(turn, 'classifier');
|
|
581
|
+
} else {
|
|
582
|
+
decision.advisor = 'classifier-fallback';
|
|
583
|
+
rememberAdvisedTurn(turn, 'classifier-fallback');
|
|
584
|
+
decision.errorClass = 'advisor-unavailable';
|
|
585
|
+
}
|
|
586
|
+
} else {
|
|
587
|
+
decision.advisor = 'classifier-fallback';
|
|
588
|
+
rememberAdvisedTurn(turn, 'classifier-fallback');
|
|
589
|
+
decision.errorClass = 'advisor-unavailable';
|
|
590
|
+
}
|
|
591
|
+
decision.routingLatencyMs = Math.max(
|
|
592
|
+
0,
|
|
593
|
+
performance.now() - started,
|
|
347
594
|
);
|
|
595
|
+
if (performance.now() >= routingDeadline)
|
|
596
|
+
decision.errorClass = 'deadline';
|
|
348
597
|
}
|
|
349
598
|
}
|
|
350
599
|
|
|
351
|
-
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
600
|
+
// Google thought signatures cannot be replayed against a different thinking model.
|
|
601
|
+
const priorAssistant = context.messages[latestAssistantIndex];
|
|
602
|
+
if (
|
|
603
|
+
toolContinuation &&
|
|
604
|
+
priorAssistant?.role === 'assistant' &&
|
|
605
|
+
priorAssistant.provider === 'google' &&
|
|
606
|
+
priorAssistant.content.some(
|
|
607
|
+
(entry) =>
|
|
608
|
+
entry.type === 'thinking' ||
|
|
609
|
+
(entry.type === 'toolCall' && entry.thoughtSignature),
|
|
610
|
+
) &&
|
|
611
|
+
(decision.targetProvider !== priorAssistant.provider ||
|
|
612
|
+
decision.targetModelId !== priorAssistant.model)
|
|
613
|
+
) {
|
|
614
|
+
const priorPair =
|
|
615
|
+
pairs.find(
|
|
616
|
+
(pair) =>
|
|
617
|
+
pair.model ===
|
|
618
|
+
`${priorAssistant.provider}/${priorAssistant.model}` &&
|
|
619
|
+
pair.tier === continuationDecision?.tier &&
|
|
620
|
+
pair.thinking === continuationDecision?.thinking,
|
|
621
|
+
) ??
|
|
622
|
+
pairs.find(
|
|
623
|
+
(pair) =>
|
|
624
|
+
pair.model ===
|
|
625
|
+
`${priorAssistant.provider}/${priorAssistant.model}`,
|
|
626
|
+
);
|
|
627
|
+
if (!priorPair)
|
|
628
|
+
throw new Error(
|
|
629
|
+
'No compatible route for Google tool continuation.',
|
|
630
|
+
);
|
|
363
631
|
decision = {
|
|
364
|
-
...
|
|
365
|
-
|
|
366
|
-
phase: previousDecision.phase,
|
|
367
|
-
targetProvider: previousDecision.targetProvider,
|
|
368
|
-
targetModelId: previousDecision.targetModelId,
|
|
369
|
-
targetLabel: previousDecision.targetLabel,
|
|
370
|
-
thinking: previousDecision.thinking,
|
|
371
|
-
reasoning:
|
|
372
|
-
`Preserved ${previousDecision.targetLabel} for a Google tool-result continuation ` +
|
|
373
|
-
`to avoid thought-signature replay errors. (Original: ${decision.reasoning})`,
|
|
632
|
+
...decisionForPair(model.id, priorPair, 'continuation'),
|
|
633
|
+
advisor: decision.advisor,
|
|
374
634
|
};
|
|
375
635
|
}
|
|
376
636
|
|
|
377
|
-
const imageAttached = hasImageAttachment(context);
|
|
378
|
-
const checkModelSupportsImage = (modelRef: string) => {
|
|
379
|
-
try {
|
|
380
|
-
const { provider, modelId } = parseCanonicalModelRef(modelRef);
|
|
381
|
-
const m = registry.find(provider, modelId);
|
|
382
|
-
return m?.input?.includes('image') ?? false;
|
|
383
|
-
} catch {
|
|
384
|
-
return false;
|
|
385
|
-
}
|
|
386
|
-
};
|
|
387
|
-
|
|
388
|
-
if (imageAttached) {
|
|
389
|
-
const tierModels = [
|
|
390
|
-
decision.targetLabel,
|
|
391
|
-
...(profile[decision.tier]?.fallbacks ?? []),
|
|
392
|
-
];
|
|
393
|
-
if (!tierModels.some(checkModelSupportsImage)) {
|
|
394
|
-
const tiersToTry: RouterTier[] =
|
|
395
|
-
decision.tier === 'low'
|
|
396
|
-
? ['medium', 'high']
|
|
397
|
-
: decision.tier === 'medium'
|
|
398
|
-
? ['high']
|
|
399
|
-
: [];
|
|
400
|
-
|
|
401
|
-
let foundTier: RouterTier | undefined;
|
|
402
|
-
for (const t of tiersToTry) {
|
|
403
|
-
const tierConfig = profile[t];
|
|
404
|
-
if (!tierConfig) continue;
|
|
405
|
-
const tModels = [
|
|
406
|
-
tierConfig.model,
|
|
407
|
-
...(tierConfig.fallbacks ?? []),
|
|
408
|
-
];
|
|
409
|
-
if (tModels.some(checkModelSupportsImage)) {
|
|
410
|
-
foundTier = t;
|
|
411
|
-
break;
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
if (foundTier) {
|
|
416
|
-
decision = buildRoutingDecision(
|
|
417
|
-
model.id,
|
|
418
|
-
profile,
|
|
419
|
-
foundTier,
|
|
420
|
-
phaseForTier(foundTier),
|
|
421
|
-
`Forced ${foundTier} tier because the originally routed ${decision.tier} tier does not support image attachments.`,
|
|
422
|
-
state.thinkingByProfile[model.id],
|
|
423
|
-
false,
|
|
424
|
-
);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
637
|
state.lastDecision = decision;
|
|
430
|
-
actions.recordDebugDecision(decision);
|
|
431
638
|
|
|
432
639
|
// Sync pi's thinking level display with the router's effective thinking.
|
|
433
640
|
// Wrapped in try/catch: in subagent contexts the extension runtime
|
|
@@ -444,20 +651,11 @@ export const registerRouterProvider = (
|
|
|
444
651
|
// Stale extension context — skip non-critical UI updates.
|
|
445
652
|
}
|
|
446
653
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
];
|
|
453
|
-
if (imageAttached) {
|
|
454
|
-
modelsToTry = modelsToTry.filter(checkModelSupportsImage);
|
|
455
|
-
if (modelsToTry.length === 0) {
|
|
456
|
-
throw new Error(
|
|
457
|
-
'No configured model supports image attachments.',
|
|
458
|
-
);
|
|
459
|
-
}
|
|
460
|
-
}
|
|
654
|
+
// Explicit fallback refs authorize provider changes; never discover other accounts.
|
|
655
|
+
const modelsToTry = [
|
|
656
|
+
decision.targetLabel,
|
|
657
|
+
...(profile[decision.tier]?.fallbacks ?? []),
|
|
658
|
+
].filter((ref, i, refs) => refs.indexOf(ref) === i);
|
|
461
659
|
let lastError: unknown;
|
|
462
660
|
let success = false;
|
|
463
661
|
|
|
@@ -491,26 +689,33 @@ export const registerRouterProvider = (
|
|
|
491
689
|
effectiveContext = truncateContext(context, targetLimit);
|
|
492
690
|
}
|
|
493
691
|
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
692
|
+
const pair = available().find(
|
|
693
|
+
(candidate) =>
|
|
694
|
+
candidate.tier === decision.tier &&
|
|
695
|
+
candidate.model === modelRef,
|
|
497
696
|
);
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
697
|
+
if (!pair)
|
|
698
|
+
throw new Error(
|
|
699
|
+
'Routed model capabilities or thinking are unsupported.',
|
|
700
|
+
);
|
|
701
|
+
if (
|
|
702
|
+
toolContinuation &&
|
|
703
|
+
priorAssistant?.role === 'assistant' &&
|
|
704
|
+
priorAssistant.provider === 'google' &&
|
|
705
|
+
priorAssistant.content.some(
|
|
706
|
+
(entry) =>
|
|
707
|
+
entry.type === 'thinking' ||
|
|
708
|
+
(entry.type === 'toolCall' && entry.thoughtSignature),
|
|
709
|
+
) &&
|
|
710
|
+
(targetProvider !== priorAssistant.provider ||
|
|
711
|
+
targetModelId !== priorAssistant.model)
|
|
712
|
+
) {
|
|
713
|
+
throw new Error(
|
|
714
|
+
'No compatible route for Google tool continuation.',
|
|
715
|
+
);
|
|
508
716
|
}
|
|
509
|
-
|
|
510
717
|
const delegatedReasoning: SimpleStreamOptions['reasoning'] =
|
|
511
|
-
|
|
512
|
-
? requestedReasoning
|
|
513
|
-
: undefined;
|
|
718
|
+
pair.thinking !== 'off' ? pair.thinking : undefined;
|
|
514
719
|
|
|
515
720
|
try {
|
|
516
721
|
if (state.lastExtensionContext) {
|
|
@@ -554,7 +759,13 @@ export const registerRouterProvider = (
|
|
|
554
759
|
decision.targetModelId = targetModelId;
|
|
555
760
|
decision.targetLabel = modelRef;
|
|
556
761
|
decision.thinking = delegatedReasoning ?? 'off';
|
|
557
|
-
decision.isFallback =
|
|
762
|
+
decision.isFallback =
|
|
763
|
+
i > 0 || modelRef !== profile[decision.tier]?.model;
|
|
764
|
+
if (
|
|
765
|
+
decision.isFallback &&
|
|
766
|
+
decision.reasonCode !== 'continuation'
|
|
767
|
+
)
|
|
768
|
+
decision.reasonCode = 'fallback';
|
|
558
769
|
};
|
|
559
770
|
for await (const event of delegatedStream) {
|
|
560
771
|
if (
|
|
@@ -585,10 +796,25 @@ export const registerRouterProvider = (
|
|
|
585
796
|
}
|
|
586
797
|
if (event.type === 'done' || event.type === 'error') {
|
|
587
798
|
terminalReceived = true;
|
|
799
|
+
generationSucceeded = event.type === 'done';
|
|
588
800
|
recordTarget();
|
|
589
801
|
const cost = (
|
|
590
802
|
event.type === 'done' ? event.message : event.error
|
|
591
803
|
).usage.cost.total;
|
|
804
|
+
if (event.type === 'done' && turn) {
|
|
805
|
+
rememberContinuation({
|
|
806
|
+
turn,
|
|
807
|
+
policy,
|
|
808
|
+
branch,
|
|
809
|
+
decision,
|
|
810
|
+
config: state.currentConfig,
|
|
811
|
+
toolCalls: new Set(
|
|
812
|
+
event.message.content.flatMap((entry) =>
|
|
813
|
+
entry.type === 'toolCall' ? [entry.id] : [],
|
|
814
|
+
),
|
|
815
|
+
),
|
|
816
|
+
});
|
|
817
|
+
}
|
|
592
818
|
if (Number.isFinite(cost) && cost > 0)
|
|
593
819
|
state.accumulatedCost += cost;
|
|
594
820
|
}
|
|
@@ -624,6 +850,7 @@ export const registerRouterProvider = (
|
|
|
624
850
|
);
|
|
625
851
|
}
|
|
626
852
|
|
|
853
|
+
actions.recordDebugDecision(decision);
|
|
627
854
|
stream.end();
|
|
628
855
|
} catch (error) {
|
|
629
856
|
const reason = options?.signal?.aborted ? 'aborted' : 'error';
|
|
@@ -639,6 +866,8 @@ export const registerRouterProvider = (
|
|
|
639
866
|
});
|
|
640
867
|
stream.end();
|
|
641
868
|
} finally {
|
|
869
|
+
if (!generationSucceeded && activeTurn)
|
|
870
|
+
advisedTurns.delete(activeTurn);
|
|
642
871
|
try {
|
|
643
872
|
actions.persistState();
|
|
644
873
|
} catch {
|