@alexeiled/pi-model-router 0.5.2 → 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 +11 -0
- package/README.md +141 -22
- package/extensions/classifier.ts +96 -70
- package/extensions/commands.ts +39 -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 +346 -145
- package/extensions/routing.ts +236 -296
- package/extensions/state.ts +53 -10
- package/extensions/types.ts +80 -12
- package/extensions/ui.ts +19 -7
- 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,12 +26,17 @@ 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 {
|
|
37
42
|
RouterConfig,
|
|
@@ -262,6 +267,38 @@ export const registerRouterProvider = (
|
|
|
262
267
|
const modelsKey = JSON.stringify(modelDefinitions);
|
|
263
268
|
if (state.lastRegisteredModels === modelsKey) return;
|
|
264
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
|
+
|
|
265
302
|
pi.registerProvider('router', {
|
|
266
303
|
baseUrl: 'router://local',
|
|
267
304
|
apiKey: 'pi-model-router',
|
|
@@ -276,6 +313,8 @@ export const registerRouterProvider = (
|
|
|
276
313
|
|
|
277
314
|
void (async () => {
|
|
278
315
|
let partialMessage: AssistantMessage | undefined;
|
|
316
|
+
let activeTurn: string | undefined;
|
|
317
|
+
let generationSucceeded = false;
|
|
279
318
|
try {
|
|
280
319
|
// Wait for the router to be fully initialized (session_start sets currentModelRegistry).
|
|
281
320
|
// This handles the race where subagents (e.g. from pi-dynamic-workflows) invoke
|
|
@@ -295,6 +334,7 @@ export const registerRouterProvider = (
|
|
|
295
334
|
throw new Error(`Unknown router profile: ${model.id}`);
|
|
296
335
|
}
|
|
297
336
|
|
|
337
|
+
options?.signal?.throwIfAborted();
|
|
298
338
|
state.selectedProfile = model.id;
|
|
299
339
|
state.routerEnabled = true;
|
|
300
340
|
|
|
@@ -303,131 +343,270 @@ export const registerRouterProvider = (
|
|
|
303
343
|
state.currentConfig.maxSessionBudget !== undefined &&
|
|
304
344
|
state.accumulatedCost >= state.currentConfig.maxSessionBudget;
|
|
305
345
|
|
|
306
|
-
|
|
307
|
-
|
|
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([
|
|
308
385
|
model.id,
|
|
309
386
|
profile,
|
|
310
|
-
state.lastDecision,
|
|
311
387
|
pinnedTier,
|
|
312
|
-
|
|
313
|
-
state.currentConfig.
|
|
314
|
-
state.currentConfig.rules,
|
|
388
|
+
thinkingOverrides,
|
|
389
|
+
state.currentConfig.classifierModel,
|
|
315
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',
|
|
316
399
|
);
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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,
|
|
335
435
|
);
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const tier = resolveAvailableTier(profile, classifierResult.tier);
|
|
339
|
-
decision = buildRoutingDecision(
|
|
340
|
-
model.id,
|
|
341
|
-
profile,
|
|
342
|
-
tier,
|
|
343
|
-
phaseForTier(tier),
|
|
344
|
-
`Classifier: ${classifierResult.reasoning}`,
|
|
345
|
-
state.thinkingByProfile[model.id],
|
|
346
|
-
true,
|
|
347
|
-
);
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
const lastMessage = context.messages[context.messages.length - 1];
|
|
352
|
-
const previousDecision = state.lastDecision;
|
|
353
|
-
const isGoogleThinkingToolContinuation =
|
|
354
|
-
lastMessage?.role === 'toolResult' &&
|
|
355
|
-
previousDecision?.profile === model.id &&
|
|
356
|
-
previousDecision.targetProvider === 'google' &&
|
|
357
|
-
previousDecision.thinking !== 'off' &&
|
|
358
|
-
decision.targetProvider === 'google' &&
|
|
359
|
-
decision.thinking !== 'off' &&
|
|
360
|
-
previousDecision.targetLabel !== decision.targetLabel;
|
|
361
|
-
|
|
362
|
-
if (isGoogleThinkingToolContinuation && previousDecision) {
|
|
436
|
+
let decision: RoutingDecision;
|
|
437
|
+
if (reusable && continuationDecision) {
|
|
363
438
|
decision = {
|
|
364
|
-
...
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
reasoning:
|
|
372
|
-
`Preserved ${previousDecision.targetLabel} for a Google tool-result continuation ` +
|
|
373
|
-
`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(),
|
|
374
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;
|
|
375
462
|
}
|
|
376
463
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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';
|
|
412
534
|
}
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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(
|
|
417
550
|
model.id,
|
|
418
|
-
|
|
419
|
-
|
|
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,
|
|
551
|
+
baseline.pair,
|
|
552
|
+
baseline.reasonCode,
|
|
424
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';
|
|
425
565
|
}
|
|
566
|
+
decision.routingLatencyMs = Math.max(
|
|
567
|
+
0,
|
|
568
|
+
performance.now() - started,
|
|
569
|
+
);
|
|
570
|
+
if (performance.now() >= routingDeadline)
|
|
571
|
+
decision.errorClass = 'deadline';
|
|
426
572
|
}
|
|
427
573
|
}
|
|
428
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
|
+
|
|
429
609
|
state.lastDecision = decision;
|
|
430
|
-
actions.recordDebugDecision(decision);
|
|
431
610
|
|
|
432
611
|
// Sync pi's thinking level display with the router's effective thinking.
|
|
433
612
|
// Wrapped in try/catch: in subagent contexts the extension runtime
|
|
@@ -444,20 +623,11 @@ export const registerRouterProvider = (
|
|
|
444
623
|
// Stale extension context — skip non-critical UI updates.
|
|
445
624
|
}
|
|
446
625
|
|
|
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
|
-
}
|
|
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);
|
|
461
631
|
let lastError: unknown;
|
|
462
632
|
let success = false;
|
|
463
633
|
|
|
@@ -491,26 +661,33 @@ export const registerRouterProvider = (
|
|
|
491
661
|
effectiveContext = truncateContext(context, targetLimit);
|
|
492
662
|
}
|
|
493
663
|
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
664
|
+
const pair = available().find(
|
|
665
|
+
(candidate) =>
|
|
666
|
+
candidate.tier === decision.tier &&
|
|
667
|
+
candidate.model === modelRef,
|
|
497
668
|
);
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
+
);
|
|
508
688
|
}
|
|
509
|
-
|
|
510
689
|
const delegatedReasoning: SimpleStreamOptions['reasoning'] =
|
|
511
|
-
|
|
512
|
-
? requestedReasoning
|
|
513
|
-
: undefined;
|
|
690
|
+
pair.thinking !== 'off' ? pair.thinking : undefined;
|
|
514
691
|
|
|
515
692
|
try {
|
|
516
693
|
if (state.lastExtensionContext) {
|
|
@@ -554,7 +731,13 @@ export const registerRouterProvider = (
|
|
|
554
731
|
decision.targetModelId = targetModelId;
|
|
555
732
|
decision.targetLabel = modelRef;
|
|
556
733
|
decision.thinking = delegatedReasoning ?? 'off';
|
|
557
|
-
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';
|
|
558
741
|
};
|
|
559
742
|
for await (const event of delegatedStream) {
|
|
560
743
|
if (
|
|
@@ -585,10 +768,25 @@ export const registerRouterProvider = (
|
|
|
585
768
|
}
|
|
586
769
|
if (event.type === 'done' || event.type === 'error') {
|
|
587
770
|
terminalReceived = true;
|
|
771
|
+
generationSucceeded = event.type === 'done';
|
|
588
772
|
recordTarget();
|
|
589
773
|
const cost = (
|
|
590
774
|
event.type === 'done' ? event.message : event.error
|
|
591
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
|
+
}
|
|
592
790
|
if (Number.isFinite(cost) && cost > 0)
|
|
593
791
|
state.accumulatedCost += cost;
|
|
594
792
|
}
|
|
@@ -624,6 +822,7 @@ export const registerRouterProvider = (
|
|
|
624
822
|
);
|
|
625
823
|
}
|
|
626
824
|
|
|
825
|
+
actions.recordDebugDecision(decision);
|
|
627
826
|
stream.end();
|
|
628
827
|
} catch (error) {
|
|
629
828
|
const reason = options?.signal?.aborted ? 'aborted' : 'error';
|
|
@@ -639,6 +838,8 @@ export const registerRouterProvider = (
|
|
|
639
838
|
});
|
|
640
839
|
stream.end();
|
|
641
840
|
} finally {
|
|
841
|
+
if (!generationSucceeded && activeTurn)
|
|
842
|
+
advisedTurns.delete(activeTurn);
|
|
642
843
|
try {
|
|
643
844
|
actions.persistState();
|
|
644
845
|
} catch {
|