@north-light/crouter 0.3.161 → 0.3.162

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.
Files changed (43) hide show
  1. package/dist/api/client.d.ts +3 -1
  2. package/dist/api/client.js +6 -0
  3. package/dist/api/dto/modelauth.d.ts +32 -0
  4. package/dist/api/dto/modelauth.js +2 -1
  5. package/dist/api/routes.d.ts +1 -0
  6. package/dist/api/routes.js +1 -0
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +55 -395
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +204 -419
  9. package/dist/clients/attach/viewer.js +592 -592
  10. package/dist/core/__tests__/broker-launch-candidates.test.js +13 -3
  11. package/dist/core/__tests__/broker-sdk-wiring.test.js +53 -25
  12. package/dist/core/__tests__/full/broker-provider-retry.test.js +6 -122
  13. package/dist/core/__tests__/model-pin-durability.test.js +7 -5
  14. package/dist/core/__tests__/model-routes.test.d.ts +1 -0
  15. package/dist/core/__tests__/model-routes.test.js +41 -0
  16. package/dist/core/__tests__/review-model-floor.test.js +2 -2
  17. package/dist/core/auth-file.d.ts +3 -2
  18. package/dist/core/auth-file.js +5 -3
  19. package/dist/core/canvas/types.d.ts +6 -1
  20. package/dist/core/config.d.ts +3 -1
  21. package/dist/core/config.js +88 -2
  22. package/dist/core/model-routes.d.ts +37 -0
  23. package/dist/core/model-routes.js +140 -0
  24. package/dist/core/runtime/broker.d.ts +0 -4
  25. package/dist/core/runtime/broker.js +116 -372
  26. package/dist/core/runtime/launch.d.ts +9 -11
  27. package/dist/core/runtime/launch.js +15 -21
  28. package/dist/core/runtime/managed-provider-cooling.d.ts +0 -14
  29. package/dist/core/runtime/managed-provider-cooling.js +0 -19
  30. package/dist/core/runtime/model-swap.js +7 -1
  31. package/dist/core/runtime/promote.js +1 -0
  32. package/dist/core/subscription-state.d.ts +12 -36
  33. package/dist/core/subscription-state.js +19 -203
  34. package/dist/daemon/api/__tests__/full/api-server.test.js +9 -6
  35. package/dist/daemon/api/__tests__/full/b10-attach-modelauth.test.js +123 -1
  36. package/dist/daemon/api/handlers/modelauth.js +51 -2
  37. package/dist/daemon/api/handlers/nodes.js +2 -0
  38. package/dist/types.d.ts +17 -0
  39. package/dist/web-client/assets/{index-B76ZKfT_.js → index-NIuSCOHM.js} +1 -1
  40. package/dist/web-client/index.html +1 -1
  41. package/dist/web-client/sw.js +1 -1
  42. package/package.json +1 -1
  43. package/runtime.lock.json +2 -2
@@ -17,7 +17,6 @@ import {
17
17
  OPENAI_CODEX_PROVIDER_ID,
18
18
  INVALID_REFRESH_TOKEN_BACKOFF_MS,
19
19
  formatStatusLine,
20
- getLadderProviderId,
21
20
  getProviderLabel,
22
21
  isManagedProvider,
23
22
  isInvalidRefreshTokenError,
@@ -32,19 +31,14 @@ import {
32
31
  readSubscriptionPool,
33
32
  refreshSubscriptionCredential,
34
33
  removeManagedAccount,
35
- resolveFallbackTarget,
36
34
  resolveProviderCandidates,
37
- restorePreferredModelIfPossible,
38
- switchToFallbackIfPossible,
39
35
  upsertSubscriptionWithUniqueAccount,
40
- type FallbackTarget,
41
36
  type ManagedProviderId,
42
37
  type SubscriptionCredential,
43
38
  } from "../../../core/subscription-state.js";
44
- import {
45
- ManagedProviderCoolingError,
46
- readManagedProviderRotationPolicy,
47
- } from "../../../core/runtime/managed-provider-cooling.js";
39
+ import { readMergedLaunchConfig } from "../../../core/config.js";
40
+ import { expandModelCandidates, modelRequestFromConfig, probeRouteAvailability } from "../../../core/model-routes.js";
41
+ import { ManagedProviderCoolingError } from "../../../core/runtime/managed-provider-cooling.js";
48
42
 
49
43
  // A response without Retry-After proves only that THIS attempt was throttled; it
50
44
  // does not authorize crouter to invent a five-minute provider-wide outage. Keep
@@ -68,6 +62,13 @@ const SUBSCRIPTION_LOCK_RETRY_BACKOFFS_MS = [25, 75, 150];
68
62
  let runtimeContext: ExtensionContext | undefined;
69
63
  let extensionAPI: ExtensionAPI | undefined;
70
64
 
65
+ // A declared non-managed provider is re-registered with the route driver at
66
+ // session start, after its owning extension has registered its real transport.
67
+ // Keep that composed transport here: calling the registry after our wrapper is
68
+ // installed would recurse back into the route driver.
69
+ type ProviderStream = (model: Model<any>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;
70
+ const delegatedProviderStreams = new Map<string, ProviderStream>();
71
+
71
72
  type AttemptResult = {
72
73
  result: "success" | "rate_limited" | "transient_exhausted" | "fatal";
73
74
  retryAfterMs?: number;
@@ -369,37 +370,39 @@ export function __setStreamForProviderForTest(fn: typeof defaultStreamForProvide
369
370
 
370
371
  /** Test-only seam: exercise `fallbackRejectionHint` for one rejection discriminant
371
372
  * (with an optional resolved-but-rejected candidate) without staging a full turn. */
372
- export function __fallbackRejectionHintForTest(reason: FallbackRejectionReason, candidate?: FallbackTarget): string {
373
- return fallbackRejectionHint({ reason, candidate });
374
- }
375
-
376
373
  // Drives one credential, retrying transient (non-rate-limit) failures in place on the
377
374
  // same credential and reporting a classified result for rotation logging.
378
375
  // A genuine rate limit ends the attempt immediately (the caller cools the credential down
379
376
  // and rotates); a transient blip gets a short in-place retry budget before giving up.
380
377
  async function streamProviderAttempt(
381
- providerId: ManagedProviderId,
378
+ providerId: string,
382
379
  model: Model<any>,
383
380
  context: Context,
384
381
  options: SimpleStreamOptions | undefined,
385
- credential: ResolvedCredential,
382
+ credential: ResolvedCredential | undefined,
386
383
  outer: AssistantMessageEventStream,
387
384
  ): Promise<AttemptResult> {
388
385
  for (let attempt = 1; ; attempt++) {
389
386
  const attemptAt = Date.now();
390
387
  let observedStatus: number | undefined;
391
388
  let requestedRetryAfterMs: number | undefined;
392
- const inner = streamForProvider(model, context, {
389
+ const streamOptions = {
393
390
  ...options,
394
- apiKey: credential.access,
395
- onResponse: async (response, responseModel) => {
391
+ ...(credential ? { apiKey: credential.access } : {}),
392
+ onResponse: async (response: { status: number; headers: Record<string, string> }, responseModel: Model<any>) => {
396
393
  observedStatus = response.status;
397
394
  if (response.status === 429 || response.status === 503) {
398
395
  requestedRetryAfterMs = retryAfterMs(response.headers);
399
396
  }
400
- await options?.onResponse?.(response, responseModel);
397
+ await options?.onResponse?.(response as any, responseModel);
401
398
  },
402
- });
399
+ };
400
+ const provider = runtimeContext?.modelRegistry.getProvider?.(model.provider);
401
+ const inner = credential
402
+ ? streamForProvider(model, context, streamOptions)
403
+ : delegatedProviderStreams.get(providerId)?.(model, context, streamOptions)
404
+ ?? provider?.streamSimple(model, context, streamOptions)
405
+ ?? streamForProvider(model, context, streamOptions);
403
406
 
404
407
  const bufferedEvents: Parameters<AssistantMessageEventStream["push"]>[0][] = [];
405
408
  let sawContent = false;
@@ -493,7 +496,7 @@ async function streamProviderAttempt(
493
496
  return { result: "transient_exhausted", attemptAt, classification, attempts: attempt };
494
497
  }
495
498
  logRotation(
496
- `${getProviderLabel(providerId)} subscription "${credential.label}" hit a transient error (${classification.reason}, status=${classification.status ?? "n/a"}); retrying same credential in ${backoffMs}ms (attempt ${attempt + 1})`,
499
+ `${credential ? `${getProviderLabel(providerId as ManagedProviderId)} subscription "${credential.label}"` : providerId} hit a transient error (${classification.reason}, status=${classification.status ?? "n/a"}); retrying same route in ${backoffMs}ms (attempt ${attempt + 1})`,
497
500
  );
498
501
  await activeSleep(backoffMs);
499
502
  }
@@ -504,446 +507,196 @@ function emitTerminalError(stream: AssistantMessageEventStream, model: Model<any
504
507
  stream.end();
505
508
  }
506
509
 
507
- // Why a candidate fallback was rejected -- each reason gets its OWN accurate hint
508
- // (`fallbackRejectionHint`) so the terminal error never gives advice that can't fix the
509
- // actual blocker. Only `no_credentials` is a genuine "go authenticate" case; the others
510
- // are config/exhaustion states where telling the user to authenticate would be misleading.
511
- type FallbackRejectionReason =
512
- | "no_ladder_entry"
513
- | "already_attempted"
514
- | "no_credentials"
515
- | "model_unregistered"
516
- | "cooling"
517
- | "provider_pinned";
518
-
519
- // On rejection we carry the resolved-but-rejected `candidate` FallbackTarget whenever one
520
- // exists (every reason except `no_ladder_entry`, where no target resolved at all), so the
521
- // hint can name the exact provider/model and build an executable repair command from the
522
- // candidate's config ladder key + strength.
523
- type FallbackResolution =
524
- | { target: FallbackTarget }
525
- | { target?: undefined; reason: FallbackRejectionReason; candidate?: FallbackTarget };
526
-
527
- // Cross-provider fallback requires a target that is usable right now: authenticated
528
- // (its subscription pool has at least one credential, even if currently
529
- // cooling down) AND registered in the model registry -- rather than blindly switching the
530
- // session model to a provider nobody logged into. Returns the rejection reason (config has
531
- // no ladder rung, the target was already tried this turn, the target has zero credentials,
532
- // or its resolved model isn't in the registry) rather than just `undefined`, so callers can
533
- // surface an accurate terminal error instead of always defaulting to "authenticate a
534
- // fallback provider" -- which is actively wrong when the real blocker is a stale ladder.
535
- function resolveViableFallback(
536
- providerId: ManagedProviderId,
537
- modelId: string | undefined,
538
- attempted: Set<ManagedProviderId>,
539
- ): FallbackResolution {
540
- const target = resolveFallbackTarget(providerId, modelId, readRotationConfig(), extensionAPI?.getThinkingLevel?.());
541
- if (!target) return { reason: "no_ladder_entry" };
542
- if (attempted.has(target.providerId)) return { reason: "already_attempted", candidate: target };
543
- // Pool occupancy IS authentication: every stored row carries a token pair, so an empty pool
544
- // means switching the session model onto this provider would make pi fail the NEXT turn with a
545
- // non-retryable "Provider is not configured" that no agent can clear from inside its own turn.
546
- const pool = readSubscriptionPool(target.providerId);
547
- const resolution = resolveProviderCandidates([{
548
- providerId: target.providerId,
549
- modelId: target.modelId,
550
- thinkingLevel: target.thinkingLevel,
551
- registered: Boolean(runtimeContext?.modelRegistry.find(target.providerId, target.modelId)),
552
- authenticated: pool.length > 0,
553
- coolingUntil: pool.length > 0 && pool.every((entry) => (entry.rateLimitedUntil || 0) > Date.now())
554
- ? Math.min(...pool.map((entry) => entry.rateLimitedUntil || 0))
555
- : undefined,
556
- }]);
557
- if (resolution.candidates.length > 0) return { target };
558
- switch (resolution.rejected[0]?.reason) {
559
- case "unauthenticated": return { reason: "no_credentials", candidate: target };
560
- case "unregistered": return { reason: "model_unregistered", candidate: target };
561
- // Do not churn the session model merely to wait on an unavailable target.
562
- // The caller includes this candidate's pool in the typed cooling deadline,
563
- // so the broker can re-drive when either provider becomes ready.
564
- case "cooling": return { reason: "cooling", candidate: target };
565
- default: return { reason: "no_credentials", candidate: target };
566
- }
567
- }
568
-
569
- // Actionable hint appended to a terminal error when no fallback is viable. Each rejection
570
- // reason gets advice that can resolve it; only `no_credentials` advises authentication because
571
- // it is the sole case where a login supplies the missing capability.
572
- function fallbackRejectionHint(resolution: FallbackResolution): string {
573
- if (resolution.target) return "try again later";
574
- const candidate = resolution.candidate;
575
- switch (resolution.reason) {
576
- case "no_credentials": {
577
- const who = candidate ? ` (${getProviderLabel(candidate.providerId)}, e.g. /provider-sub ${candidate.providerId} add)` : " with /provider-sub";
578
- return `authenticate a fallback provider${who}, or try again later`;
579
- }
580
- case "model_unregistered": {
581
- // Repair command must be EXECUTABLE as printed: `--value`, and the CONFIG ladder
582
- // provider key (`openai`, not the runtime `openai-codex`) plus the candidate's own
583
- // strength -- so the user pastes it, swaps in an installed model id, and it works.
584
- const ladderKey = candidate ? getLadderProviderId(candidate.providerId) : "<provider>";
585
- const strength = candidate?.strength ?? "<strength>";
586
- const named = candidate ? candidate.label : "the configured fallback";
587
- return `configured fallback ${named} is not in this build's model registry -- the modelLadders config is out of sync with the installed model catalog. Point it at an installed model with \`crtr sys config set modelLadders.${ladderKey}.${strength} --value <installed-model-id>\`, or upgrade/downgrade to a build whose ladder matches`;
588
- }
589
- case "already_attempted":
590
- return "every managed provider was already tried this turn and is exhausted or cooling down -- try again later";
591
- case "cooling":
592
- return "the configured fallback provider is also cooling down -- retrying when the first provider becomes ready";
593
- case "provider_pinned":
594
- return "the selected provider is explicitly pinned -- retrying it when its first credential becomes ready";
595
- case "no_ladder_entry":
596
- return "no cross-provider fallback is configured for this model -- set one with `crtr sys config set modelLadders.<provider>.<strength> --value <model-id>`, or try again later";
597
- }
598
- }
599
-
600
- // Switches the session model to an already-viability-checked fallback target. On the rare
601
- // race where the switch itself still fails (e.g. `setModel` rejects it), emits a graceful,
602
- // actionable terminal error on `stream` instead of throwing a generic "failed to switch".
603
- async function switchToFallbackModel(
604
- stream: AssistantMessageEventStream,
605
- model: Model<any>,
606
- originalModel: { providerId: ManagedProviderId; modelId: string },
607
- ): Promise<FallbackTarget | undefined> {
608
- const ctx = runtimeContext;
609
- if (!extensionAPI || !ctx) {
610
- emitTerminalError(stream, model, `Provider fallback unavailable: runtime not initialized for ${originalModel.providerId}/${originalModel.modelId}`);
611
- return undefined;
612
- }
613
- const target = await switchToFallbackIfPossible(extensionAPI, ctx, originalModel);
614
- if (!target) {
615
- emitTerminalError(stream, model, `Provider fallback unavailable: failed to switch models for ${getProviderLabel(originalModel.providerId)}`);
616
- return undefined;
617
- }
618
- return target;
619
- }
620
-
621
- // Drives the ordered recovery candidates for one managed turn. Each candidate is a
622
- // provider/model whose ready credentials are tried in order. A provider returns to
623
- // the list only for a newly ready label or a success that followed this turn's
624
- // cooldown, so bookkeeping updates never retry an exhausted pool.
625
- type ManagedProviderCandidate = {
510
+ // Routes, rather than provider ids, own queue bookkeeping: a route may share a
511
+ // provider with another credential source while still having distinct ordering and
512
+ // turn-local cooling state.
513
+ type RouteCandidate = {
514
+ routeId: string;
515
+ providerId: string;
626
516
  model: Model<any>;
627
- providerId: ManagedProviderId;
517
+ thinkingLevel: string | undefined;
628
518
  };
629
519
 
630
- type ManagedProviderTerminalState = {
631
- model: Model<any>;
632
- providerId: ManagedProviderId;
633
- currentPool: SubscriptionCredential[];
634
- fallbackResolution: FallbackResolution;
635
- };
636
-
637
- function credentialKey(providerId: ManagedProviderId, label: string): string {
638
- return `${providerId}/${label}`;
639
- }
640
-
641
- function enqueueFreshProviderCandidates(
642
- candidates: ManagedProviderCandidate[],
643
- queuedProviderIds: Set<ManagedProviderId>,
644
- knownModels: Map<ManagedProviderId, Model<any>>,
645
- seenReadyLabels: Map<ManagedProviderId, Set<string>>,
520
+ function enqueueFreshRouteCandidates(
521
+ candidates: RouteCandidate[],
522
+ queuedRouteIds: Set<string>,
523
+ knownRoutes: Map<string, RouteCandidate>,
524
+ seenReadyLabels: Map<string, Set<string>>,
646
525
  cooldownAttempts: Map<string, number>,
647
- localCoolingDeadlines: Map<ManagedProviderId, number>,
526
+ localCoolingDeadlines: Map<string, number>,
648
527
  ): void {
649
528
  const now = Date.now();
650
- for (const [providerId, model] of knownModels) {
651
- if (queuedProviderIds.has(providerId) || (localCoolingDeadlines.get(providerId) ?? 0) > now) continue;
652
- const seenLabels = seenReadyLabels.get(providerId) ?? new Set<string>();
653
- const hasFreshCredential = readSubscriptionPool(providerId).some((credential) => {
654
- if (credential.rateLimitedUntil && credential.rateLimitedUntil > now) return false;
655
- const cooldownAttempt = cooldownAttempts.get(credentialKey(providerId, credential.label));
529
+ for (const [routeId, candidate] of knownRoutes) {
530
+ if (queuedRouteIds.has(routeId) || (localCoolingDeadlines.get(routeId) ?? 0) > now || !isManagedProvider(candidate.providerId)) continue;
531
+ const seenLabels = seenReadyLabels.get(routeId) ?? new Set<string>();
532
+ const hasFreshCredential = readSubscriptionPool(candidate.providerId).some((credential) => {
533
+ if (credential.authFailure !== undefined || (credential.rateLimitedUntil || 0) > now) return false;
534
+ const cooldownAttempt = cooldownAttempts.get(`${routeId}/${credential.label}`);
656
535
  return !seenLabels.has(credential.label) || (cooldownAttempt !== undefined && credential.lastAttemptAt > cooldownAttempt);
657
536
  });
658
537
  if (hasFreshCredential) {
659
- candidates.push({ model, providerId });
660
- queuedProviderIds.add(providerId);
538
+ candidates.push(candidate);
539
+ queuedRouteIds.add(routeId);
661
540
  }
662
541
  }
663
542
  }
664
543
 
665
- // Drives managed providers through an explicit candidate list. The list is exhausted
666
- // only when no provider has a newly-ready credential or recovered cooldown and no viable fallback remains.
667
- // The broker owns delayed retry through typed cooling errors; this loop never sleeps
668
- // while waiting for a provider pool to recover.
544
+ async function switchToRoute(pi: ExtensionAPI, ctx: ExtensionContext, candidate: RouteCandidate): Promise<boolean> {
545
+ if (ctx.model?.provider !== candidate.providerId || ctx.model.id !== candidate.model.id) {
546
+ if (!await pi.setModel(candidate.model)) return false;
547
+ }
548
+ pi.setThinkingLevel((candidate.thinkingLevel ?? 'off') as any);
549
+ return ctx.model?.provider === candidate.providerId && ctx.model.id === candidate.model.id;
550
+ }
551
+
552
+ // Drive the deterministic route queue from the live rank. Managed routes walk
553
+ // their pool rows; every other declared route is one configured credential.
669
554
  async function runManagedProvider(
670
555
  model: Model<any>,
671
556
  context: Context,
672
557
  options: SimpleStreamOptions | undefined,
673
558
  stream: AssistantMessageEventStream,
674
559
  ): Promise<void> {
675
- const initialProviderId = model.provider as ManagedProviderId;
676
- const candidates: ManagedProviderCandidate[] = [{ model, providerId: initialProviderId }];
677
- const queuedProviderIds = new Set<ManagedProviderId>([initialProviderId]);
678
- const knownModels = new Map<ManagedProviderId, Model<any>>([[initialProviderId, model]]);
679
- const attempted = new Set<ManagedProviderId>();
560
+ const liveThinking = runtimeContext?.thinkingLevel === undefined || runtimeContext?.thinkingLevel === 'off'
561
+ ? undefined
562
+ : runtimeContext.thinkingLevel;
563
+ const request = modelRequestFromConfig(`${model.provider}/${model.id}${liveThinking ? `:${liveThinking}` : ''}`, process.env['CRTR_MODEL_INTENT'], false);
564
+ const expanded = request ? expandModelCandidates(request, process.env['CRTR_NODE_CWD'], process.env['CRTR_PROFILE_ID']) : [];
565
+ const liveRank = expanded.find((candidate) => candidate.providerId === model.provider && candidate.modelId === model.id && candidate.thinkingLevel === liveThinking)?.rank ?? 0;
566
+ const routeCandidates: RouteCandidate[] = expanded.slice(liveRank)
567
+ .map((route) => {
568
+ const registry = runtimeContext?.modelRegistry;
569
+ const routeModel = registry?.find(route.providerId, route.modelId);
570
+ if (!registry || !routeModel) return undefined;
571
+ const availability = probeRouteAvailability(route, registry);
572
+ if (!availability.registered || (!isManagedProvider(route.providerId) && !availability.authenticated)) return undefined;
573
+ return { routeId: route.routeId, providerId: route.providerId, model: routeModel, thinkingLevel: route.thinkingLevel };
574
+ })
575
+ .filter((candidate): candidate is RouteCandidate => candidate !== undefined);
576
+ const isLiveRoute = (candidate: RouteCandidate): boolean =>
577
+ candidate.providerId === model.provider && candidate.model.id === model.id && candidate.thinkingLevel === liveThinking;
578
+ // A cooling or unauthenticated managed route contributes its diagnostic and may
579
+ // later re-enter when a credential becomes ready, but it is never selected now.
580
+ const candidates = routeCandidates.filter((candidate) => isLiveRoute(candidate) || !isManagedProvider(candidate.providerId) || readSubscriptionPool(candidate.providerId).some((entry) => entry.authFailure === undefined && (entry.rateLimitedUntil || 0) <= Date.now()));
581
+ if (!candidates.some(isLiveRoute)) {
582
+ candidates.unshift({ routeId: `live:${model.provider}/${model.id}`, providerId: model.provider, model, thinkingLevel: undefined });
583
+ }
584
+
585
+ const queuedRouteIds = new Set(candidates.map((candidate) => candidate.routeId));
586
+ const knownRoutes = new Map([...routeCandidates, ...candidates].map((candidate) => [candidate.routeId, candidate]));
587
+ const attemptedRoutes = new Set<string>();
680
588
  const authFailures = new Set<string>();
681
- const localCoolingDeadlines = new Map<ManagedProviderId, number>();
682
- const seenReadyLabels = new Map<ManagedProviderId, Set<string>>();
589
+ const localCoolingDeadlines = new Map<string, number>();
590
+ const seenReadyLabels = new Map<string, Set<string>>();
683
591
  const cooldownAttempts = new Map<string, number>();
684
- let terminal: ManagedProviderTerminalState | undefined;
592
+ let terminalModel = model;
685
593
 
686
594
  for (let candidate = candidates.shift(); candidate !== undefined; candidate = candidates.shift()) {
687
- const { model: candidateModel, providerId } = candidate;
688
- queuedProviderIds.delete(providerId);
689
- knownModels.set(providerId, candidateModel);
690
- attempted.add(providerId);
691
-
692
- const liveModel = getCurrentModelRef(runtimeContext?.model);
693
- if (liveModel && (liveModel.providerId !== providerId || liveModel.modelId !== candidateModel.id)) {
694
- const target = await switchToFallbackModel(stream, candidateModel, liveModel);
695
- if (!target) return; // terminal error already emitted
696
- if (target.providerId !== providerId || target.modelId !== candidateModel.id) {
697
- emitTerminalError(stream, candidateModel, `Provider re-entry unavailable: expected ${providerId}/${candidateModel.id} but switched to ${target.label}`);
698
- return;
699
- }
700
- logRotation(`${getProviderLabel(liveModel.providerId)} pool recovered -> ${target.label}`);
701
- setStatus(`${getProviderLabel(liveModel.providerId)} -> ${target.label}`);
595
+ const { routeId, providerId, model: candidateModel } = candidate;
596
+ queuedRouteIds.delete(routeId);
597
+ knownRoutes.set(routeId, candidate);
598
+ attemptedRoutes.add(routeId);
599
+ terminalModel = candidateModel;
600
+
601
+ if (!await switchToRoute(extensionAPI!, runtimeContext!, candidate)) {
602
+ emitTerminalError(stream, candidateModel, `Provider route ${routeId} is unavailable: failed to select ${providerId}/${candidateModel.id}`);
603
+ return;
702
604
  }
703
605
 
704
- const rawPool = readSubscriptionPool(providerId);
705
- for (const entry of rawPool) {
706
- if (entry.authFailure === "invalid_grant") authFailures.add(`${providerId}/${entry.label}`);
606
+ if (!isManagedProvider(providerId)) {
607
+ const result = await streamProviderAttempt(providerId, candidateModel, context, options, undefined, stream);
608
+ if (result.result === 'success') { stream.end(); return; }
609
+ if (result.result === 'fatal') { stream.end(); return; }
610
+ const cooldownMs = result.result === 'rate_limited' ? result.retryAfterMs ?? DEFAULT_RATE_LIMIT_BACKOFF_MS : TRANSIENT_ROTATE_COOLDOWN_MS;
611
+ localCoolingDeadlines.set(routeId, Math.max(localCoolingDeadlines.get(routeId) ?? 0, Date.now() + cooldownMs));
612
+ logRotation(`${routeId} ${result.result === 'rate_limited' ? 'rate-limited' : 'transient failure'}; advancing route queue`);
613
+ continue;
707
614
  }
708
- const available = rawPool.filter((entry) => !entry.rateLimitedUntil || entry.rateLimitedUntil <= Date.now());
709
- const seenLabels = seenReadyLabels.get(providerId) ?? new Set<string>();
615
+
616
+ const rawPool = readSubscriptionPool(providerId);
617
+ for (const entry of rawPool) if (entry.authFailure === 'invalid_grant') authFailures.add(`${providerId}/${entry.label}`);
618
+ const available = rawPool.filter((entry) => entry.authFailure === undefined && (!entry.rateLimitedUntil || entry.rateLimitedUntil <= Date.now()));
619
+ const seenLabels = seenReadyLabels.get(routeId) ?? new Set<string>();
710
620
  for (const credential of available) seenLabels.add(credential.label);
711
- seenReadyLabels.set(providerId, seenLabels);
621
+ seenReadyLabels.set(routeId, seenLabels);
712
622
 
713
623
  for (const credential of available) {
714
624
  let freshCredential: ResolvedCredential;
715
625
  try {
716
626
  freshCredential = await refreshCredentialIfNeeded(providerId, credential);
717
627
  } catch (error) {
718
- const attemptAt = Date.now();
719
- if (isInvalidRefreshTokenError(error)) {
720
- let reauthenticated: ResolvedCredential | undefined;
721
- try {
722
- reauthenticated = await reauthenticateCredential(providerId, credential);
723
- } catch (loginError) {
724
- runtimeContext?.ui.notify(
725
- `${getProviderLabel(providerId)} re-authentication failed: ${loginError instanceof Error ? loginError.message : String(loginError)}`,
726
- "error",
727
- );
728
- }
729
- if (reauthenticated) {
730
- freshCredential = reauthenticated;
731
- } else {
732
- // Keep the unusable credential out of ordinary rotation, but retain
733
- // its auth cause for this turn. A 30-day exclusion is not a transient
734
- // rate limit and must never author an auto rate-limit recovery fault.
735
- await recordSubscriptionMutation(
736
- `recording ${getProviderLabel(providerId)} authentication failure`,
737
- () => markSubscriptionAuthenticationFailed(providerId, credential.label, INVALID_REFRESH_TOKEN_BACKOFF_MS, attemptAt),
738
- );
739
- authFailures.add(`${providerId}/${credential.label}`);
740
- setStatus(formatStatusLine(providerId));
741
- continue;
742
- }
743
- } else {
744
- throw error;
628
+ if (!isInvalidRefreshTokenError(error)) throw error;
629
+ let reauthenticated: ResolvedCredential | undefined;
630
+ try { reauthenticated = await reauthenticateCredential(providerId, credential); } catch (loginError) {
631
+ runtimeContext?.ui.notify(`${getProviderLabel(providerId)} re-authentication failed: ${loginError instanceof Error ? loginError.message : String(loginError)}`, 'error');
632
+ }
633
+ if (!reauthenticated) {
634
+ const attemptAt = Date.now();
635
+ await recordSubscriptionMutation(`recording ${getProviderLabel(providerId)} authentication failure`, () => markSubscriptionAuthenticationFailed(providerId, credential.label, INVALID_REFRESH_TOKEN_BACKOFF_MS, attemptAt));
636
+ authFailures.add(`${providerId}/${credential.label}`);
637
+ continue;
745
638
  }
639
+ freshCredential = reauthenticated;
746
640
  }
747
641
  const result = await streamProviderAttempt(providerId, candidateModel, context, options, freshCredential, stream);
748
-
749
- if (result.result === "success") {
750
- // The common healthy state already has nothing to clear. Avoid two global
751
- // file locks just to advance display-only lastAttemptAt telemetry.
752
- if (freshCredential.rateLimitedUntil || freshCredential.authFailure) {
753
- await recordSubscriptionMutation(
754
- `recording ${getProviderLabel(providerId)} success`,
755
- () => markSubscriptionSuccess(providerId, freshCredential.label, result.attemptAt),
756
- );
757
- }
758
- logRotation(`${getProviderLabel(providerId)} subscription "${freshCredential.label}" succeeded; cooldown cleared`);
759
- setStatus(formatStatusLine(providerId));
642
+ if (result.result === 'success') {
643
+ if (freshCredential.rateLimitedUntil || freshCredential.authFailure) await recordSubscriptionMutation(`recording ${getProviderLabel(providerId)} success`, () => markSubscriptionSuccess(providerId, freshCredential.label, result.attemptAt));
760
644
  stream.end();
761
645
  return;
762
646
  }
763
-
764
- if (result.result === "rate_limited") {
765
- const cooldownMs = result.retryAfterMs ?? DEFAULT_RATE_LIMIT_BACKOFF_MS;
766
- if (result.retryAfterMs !== undefined) {
767
- // A server-declared deadline is authoritative for every broker sharing
768
- // this credential, so persist it in the cross-process pool.
769
- const persisted = await recordSubscriptionMutation(
770
- `recording ${getProviderLabel(providerId)} server cooldown`,
771
- () => markSubscriptionRateLimited(providerId, freshCredential.label, cooldownMs, result.attemptAt),
772
- );
773
- if (persisted) {
774
- // A zero server deadline is immediately eligible, but it still needs
775
- // a later causal success before it becomes a new recovery candidate.
776
- cooldownAttempts.set(credentialKey(providerId, freshCredential.label), result.attemptAt);
777
- } else {
778
- localCoolingDeadlines.set(
779
- providerId,
780
- Math.max(localCoolingDeadlines.get(providerId) ?? 0, Date.now() + cooldownMs),
781
- );
782
- }
783
- } else {
784
- // A headerless 429/usage-limit response proves only that this request
785
- // failed. Persisting an invented deadline globally let one busy node
786
- // gate unrelated nodes that were succeeding concurrently. Keep the
787
- // short probe deadline turn-local instead.
788
- localCoolingDeadlines.set(
789
- providerId,
790
- Math.max(localCoolingDeadlines.get(providerId) ?? 0, Date.now() + cooldownMs),
791
- );
792
- }
793
- const retryAfterPart = result.retryAfterMs !== undefined ? `retry-after=${Math.ceil(result.retryAfterMs / 1000)}s` : "no retry-after header (default backoff)";
794
- logRotation(
795
- `${getProviderLabel(providerId)} subscription "${freshCredential.label}" rate-limited (status=${result.classification?.status ?? "n/a"}, reason=${result.classification?.reason ?? "unclassified"}, ${retryAfterPart}); ${result.retryAfterMs !== undefined ? "shared cooldown" : "turn-local retry backoff"} ${Math.ceil(cooldownMs / 1000)}s, trying next credential`,
796
- );
797
- runtimeContext?.ui.notify?.(
798
- `${getProviderLabel(providerId)} "${freshCredential.label}" rate-limited (${result.retryAfterMs !== undefined ? "cooling down" : "retrying this turn in"} ${Math.ceil(cooldownMs / 1000)}s); rotating to another credential/provider.`,
799
- "warning",
800
- );
801
- continue;
802
- }
803
-
804
- if (result.result === "transient_exhausted") {
805
- // The credential is likely healthy -- this was a provider-side transient blip (e.g.
806
- // "overloaded") that survived the in-place retries. Surface it to the user and rotate:
807
- // cool the credential down briefly so this loop moves on to the next credential or fallback.
808
- const reason = result.classification?.reason ?? "transient error";
809
- const persisted = await recordSubscriptionMutation(
810
- `recording ${getProviderLabel(providerId)} transient cooldown`,
811
- () => markSubscriptionRateLimited(providerId, freshCredential.label, TRANSIENT_ROTATE_COOLDOWN_MS, result.attemptAt),
812
- );
813
- if (persisted) {
814
- cooldownAttempts.set(credentialKey(providerId, freshCredential.label), result.attemptAt);
815
- } else {
816
- localCoolingDeadlines.set(
817
- providerId,
818
- Math.max(localCoolingDeadlines.get(providerId) ?? 0, Date.now() + TRANSIENT_ROTATE_COOLDOWN_MS),
819
- );
820
- }
821
- runtimeContext?.ui.notify?.(
822
- `${getProviderLabel(providerId)} "${freshCredential.label}" hit a transient error after ${result.attempts ?? 1} attempts (${reason}); rotating to another credential/provider.`,
823
- "warning",
824
- );
825
- logRotation(
826
- `${getProviderLabel(providerId)} subscription "${freshCredential.label}" transient error exhausted (reason=${reason}, status=${result.classification?.status ?? "n/a"}); cooling down ${Math.ceil(TRANSIENT_ROTATE_COOLDOWN_MS / 1000)}s and rotating`,
827
- );
828
- setStatus(formatStatusLine(providerId));
829
- continue;
830
- }
831
-
832
- // Fatal (or partial content already streamed): cannot safely switch providers mid-turn.
833
- if (result.classification) {
834
- logRotation(
835
- `${getProviderLabel(providerId)} subscription "${freshCredential.label}" failed fatally (status=${result.classification.status ?? "n/a"}, reason=${result.classification.reason})`,
836
- );
837
- }
838
- stream.end();
839
- return;
840
- }
841
-
842
- // Read a fresh pool after the attempts above so terminal cooling state reflects
843
- // mutations made by this candidate, not its initial lock-free snapshot.
844
- const currentPool = readSubscriptionPool(providerId);
845
- enqueueFreshProviderCandidates(candidates, queuedProviderIds, knownModels, seenReadyLabels, cooldownAttempts, localCoolingDeadlines);
846
- if (candidates.length > 0) continue;
847
-
848
- const originalModel = getCurrentModelRef(candidateModel);
849
- if (!originalModel) throw new Error(`Provider fallback unavailable: missing current model`);
850
-
851
- const rotationPolicy = readManagedProviderRotationPolicy(runtimeContext?.ui);
852
- const fallbackResolution: FallbackResolution = rotationPolicy.allowCrossProviderFallback
853
- ? resolveViableFallback(originalModel.providerId, originalModel.modelId, attempted)
854
- : { reason: "provider_pinned" };
855
- if (fallbackResolution.target) {
856
- const target = await switchToFallbackModel(stream, candidateModel, originalModel);
857
- if (!target) return; // terminal error already emitted
858
- logRotation(`${getProviderLabel(providerId)} pool exhausted -> ${target.label}`);
859
- setStatus(`${getProviderLabel(providerId)} -> ${target.label}`);
860
-
861
- const fallbackModel = runtimeContext?.modelRegistry.find(target.providerId, target.modelId);
862
- if (!fallbackModel) {
863
- emitTerminalError(stream, candidateModel, `Provider fallback unavailable: ${target.label} not in model registry`);
864
- return;
647
+ if (result.result === 'fatal') { stream.end(); return; }
648
+ const cooldownMs = result.result === 'rate_limited' ? result.retryAfterMs ?? DEFAULT_RATE_LIMIT_BACKOFF_MS : TRANSIENT_ROTATE_COOLDOWN_MS;
649
+ if ((result.result === 'rate_limited' && result.retryAfterMs !== undefined) || result.result === 'transient_exhausted') {
650
+ const source = result.result === 'transient_exhausted' ? 'transient cooldown' : 'server cooldown';
651
+ const persisted = await recordSubscriptionMutation(`recording ${getProviderLabel(providerId)} ${source}`, () => markSubscriptionRateLimited(providerId, freshCredential.label, cooldownMs, result.attemptAt));
652
+ if (persisted) cooldownAttempts.set(`${routeId}/${freshCredential.label}`, result.attemptAt);
653
+ else localCoolingDeadlines.set(routeId, Math.max(localCoolingDeadlines.get(routeId) ?? 0, Date.now() + cooldownMs));
654
+ } else {
655
+ localCoolingDeadlines.set(routeId, Math.max(localCoolingDeadlines.get(routeId) ?? 0, Date.now() + cooldownMs));
865
656
  }
866
-
867
- candidates.push({ model: fallbackModel, providerId: target.providerId });
868
- queuedProviderIds.add(target.providerId);
869
- knownModels.set(target.providerId, fallbackModel);
870
- continue;
871
657
  }
872
658
 
873
- terminal = { model: candidateModel, providerId, currentPool, fallbackResolution };
659
+ enqueueFreshRouteCandidates(candidates, queuedRouteIds, knownRoutes, seenReadyLabels, cooldownAttempts, localCoolingDeadlines);
874
660
  }
875
661
 
876
- if (!terminal) throw new Error("Managed provider recovery exhausted without a terminal state");
877
-
878
- const { model: terminalModel, providerId, currentPool, fallbackResolution } = terminal;
879
- const label = getProviderLabel(providerId);
880
- const triedList = [...attempted].join(", ");
881
-
882
- if (currentPool.length === 0) {
883
- emitTerminalError(stream, terminalModel, `${label} not authenticated — run \`crtr sys setup\` (or /provider-sub ${providerId} add) (tried ${triedList})`);
884
- return;
885
- }
886
-
887
- const fallbackHint = fallbackRejectionHint(fallbackResolution);
888
- const coolingProviders = new Set(attempted);
889
- if (!fallbackResolution.target && fallbackResolution.reason === "cooling" && fallbackResolution.candidate) {
890
- coolingProviders.add(fallbackResolution.candidate.providerId);
891
- }
892
- const coolingDeadline = earliestCoolingDeadline(coolingProviders, localCoolingDeadlines);
662
+ const coolingDeadline = earliestCoolingDeadline([...knownRoutes.values()], localCoolingDeadlines);
663
+ const tried = [...attemptedRoutes].join(', ');
893
664
  if (coolingDeadline !== undefined) {
894
- const coolingSecs = Math.ceil((coolingDeadline - Date.now()) / 1000);
895
- emitCoolingError(
896
- stream,
897
- terminalModel,
898
- new ManagedProviderCoolingError(
899
- `All managed providers are rate limited (cooling down ~${coolingSecs}s); ${fallbackHint} (tried ${triedList})`,
900
- coolingDeadline,
901
- ),
902
- );
665
+ emitCoolingError(stream, terminalModel, new ManagedProviderCoolingError(`All provider routes are rate limited (cooling down ~${Math.ceil((coolingDeadline - Date.now()) / 1000)}s; tried ${tried})`, coolingDeadline));
903
666
  return;
904
667
  }
905
-
906
668
  const authFailure = [...authFailures][0];
907
669
  if (authFailure !== undefined) {
908
- emitTerminalError(stream, terminalModel, `Provider authentication failed (invalid_grant) for ${authFailure}; re-authenticate the credential and retry (tried ${triedList})`);
670
+ emitTerminalError(stream, terminalModel, `Provider authentication failed (invalid_grant) for ${authFailure}; re-authenticate the credential and retry (tried ${tried})`);
909
671
  return;
910
672
  }
911
-
912
- emitTerminalError(
913
- stream,
914
- terminalModel,
915
- `All managed provider pools exhausted; ${fallbackHint} (tried ${triedList})`,
916
- );
673
+ emitTerminalError(stream, terminalModel, `All provider routes exhausted (tried ${tried})`);
917
674
  }
918
675
 
919
- // The earliest strictly-future cooldown deadline across every managed pool tried
920
- // this turn, or undefined unless EVERY existing credential in those pools is
921
- // cooling (a single ready credential, or an empty attempted set, means this is not
922
- // an all-cooling condition and must not be typed as such). Read fresh under each
923
- // pool's lock-free snapshot, mirroring the wait-out math above.
676
+ // The earliest strictly-future deadline only when every attempted route is cooling.
924
677
  function earliestCoolingDeadline(
925
- attempted: Set<ManagedProviderId>,
926
- localCoolingDeadlines = new Map<ManagedProviderId, number>(),
678
+ attempted: Iterable<RouteCandidate>,
679
+ localCoolingDeadlines: Map<string, number>,
927
680
  ): number | undefined {
928
681
  const now = Date.now();
929
682
  let earliest: number | undefined;
930
- let sawCredential = false;
931
- for (const attemptedProvider of attempted) {
932
- const localUntil = localCoolingDeadlines.get(attemptedProvider) ?? 0;
933
- if (localUntil > now) {
934
- sawCredential = true;
935
- if (earliest === undefined || localUntil < earliest) earliest = localUntil;
936
- continue;
937
- }
938
- for (const entry of readSubscriptionPool(attemptedProvider)) {
939
- if (entry.authFailure !== undefined) continue;
940
- sawCredential = true;
941
- const until = entry.rateLimitedUntil || 0;
942
- if (until <= now) return undefined; // a ready credential exists — not all cooling
943
- if (earliest === undefined || until < earliest) earliest = until;
944
- }
683
+ let sawRoute = false;
684
+ for (const route of attempted) {
685
+ const localUntil = localCoolingDeadlines.get(route.routeId) ?? 0;
686
+ if (localUntil > now) { sawRoute = true; earliest = earliest === undefined ? localUntil : Math.min(earliest, localUntil); continue; }
687
+ if (!isManagedProvider(route.providerId)) return undefined;
688
+ const pool = readSubscriptionPool(route.providerId).filter((entry) => entry.authFailure === undefined);
689
+ // Routes with no usable credential were never runnable this turn, so they do
690
+ // not defeat the cooling outcome of another route. An auth-failed-only pool
691
+ // is likewise unauthenticated; a valid cooling row still contributes its
692
+ // deadline even when a sibling row carries invalid_grant.
693
+ if (pool.length === 0) continue;
694
+ sawRoute = true;
695
+ if (pool.some((entry) => (entry.rateLimitedUntil || 0) <= now)) return undefined;
696
+ const routeEarliest = Math.min(...pool.map((entry) => entry.rateLimitedUntil || 0));
697
+ earliest = earliest === undefined ? routeEarliest : Math.min(earliest, routeEarliest);
945
698
  }
946
- return sawCredential ? earliest : undefined;
699
+ return sawRoute ? earliest : undefined;
947
700
  }
948
701
 
949
702
  // Emit the typed cooling terminal error, attaching its diagnostic (the earliest
@@ -957,10 +710,6 @@ function emitCoolingError(stream: AssistantMessageEventStream, model: Model<any>
957
710
  }
958
711
 
959
712
  function streamManagedProvider(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
960
- if (!isManagedProvider(model.provider)) {
961
- return streamForProvider(model, context, options);
962
- }
963
-
964
713
  const stream = createAssistantMessageEventStream();
965
714
 
966
715
  runManagedProvider(model, context, options, stream).catch((error) => {
@@ -987,9 +736,51 @@ function parseProvider(value: string | undefined): ManagedProviderId | undefined
987
736
  return isManagedProvider(value) ? value : undefined;
988
737
  }
989
738
 
739
+ function installDeclaredRouteProviders(pi: ExtensionAPI, ctx: ExtensionContext): void {
740
+ const config = readMergedLaunchConfig(ctx.cwd, process.env['CRTR_PROFILE_ID'] || null);
741
+ const providerIds = new Set(Object.values(config.modelRoutes ?? {}).map((route) => route.providerId));
742
+ for (const providerId of providerIds) {
743
+ if (isManagedProvider(providerId) || delegatedProviderStreams.has(providerId)) continue;
744
+ const provider = ctx.modelRegistry.getProvider?.(providerId);
745
+ const model = ctx.modelRegistry.getAll?.().find((entry) => entry.provider === providerId);
746
+ if (!provider || !model) continue;
747
+ delegatedProviderStreams.set(providerId, provider.streamSimple.bind(provider));
748
+ // `api` is required by pi whenever a registration supplies streamSimple.
749
+ // Re-registration merges this wrapper over the existing declaration, so the
750
+ // provider's models, auth, and captured composed transport remain intact.
751
+ pi.registerProvider(providerId, { api: model.api, streamSimple: streamManagedProvider });
752
+ }
753
+ }
754
+
990
755
  function updateRotationStatus(ctx: ExtensionContext): void {
991
756
  const current = getCurrentModelRef(ctx.model);
992
- if (current) setStatus(formatStatusLine(current.providerId));
757
+ if (current && isManagedProvider(current.providerId)) setStatus(formatStatusLine(current.providerId));
758
+ }
759
+
760
+ /** Restore only when the deterministic route rank improves; no shared-home
761
+ * preferred-model state is needed or consulted. */
762
+ async function restoreHigherRankIfAvailable(pi: ExtensionAPI, ctx: ExtensionContext): Promise<boolean> {
763
+ if (!readRotationConfig().revertWhenAvailable) return false;
764
+ const liveThinking = ctx.thinkingLevel === undefined || ctx.thinkingLevel === 'off' ? undefined : ctx.thinkingLevel;
765
+ const request = modelRequestFromConfig(
766
+ `${ctx.model?.provider ?? ''}/${ctx.model?.id ?? ''}${liveThinking ? `:${liveThinking}` : ''}`,
767
+ process.env['CRTR_MODEL_INTENT'],
768
+ false,
769
+ );
770
+ if (!request || !ctx.model) return false;
771
+ const candidates = expandModelCandidates(request, process.env['CRTR_NODE_CWD'], process.env['CRTR_PROFILE_ID']);
772
+ const liveRank = candidates.find((candidate) => candidate.providerId === ctx.model?.provider && candidate.modelId === ctx.model?.id && candidate.thinkingLevel === liveThinking)?.rank;
773
+ if (liveRank === undefined) return false;
774
+ const preferred = candidates.find((candidate) => candidate.rank < liveRank && resolveProviderCandidates([{
775
+ ...candidate,
776
+ ...probeRouteAvailability(candidate, ctx.modelRegistry),
777
+ automaticFallback: true,
778
+ }]).candidates.length > 0);
779
+ if (!preferred) return false;
780
+ const model = ctx.modelRegistry.find(preferred.providerId, preferred.modelId);
781
+ if (!model || !await pi.setModel(model)) return false;
782
+ pi.setThinkingLevel((preferred.thinkingLevel ?? 'off') as any);
783
+ return true;
993
784
  }
994
785
 
995
786
  // Manual-code input is a human-in-the-loop OAuth round-trip (open link, log in on
@@ -1108,22 +899,16 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1108
899
 
1109
900
  pi.on("session_start", async (_event, ctx) => {
1110
901
  runtimeContext = ctx;
1111
- const restored = readManagedProviderRotationPolicy(ctx.ui).allowCrossProviderFallback
1112
- && await restorePreferredModelIfPossible(pi, ctx);
1113
- if (restored && ctx.model) {
1114
- logRotation(`restored preferred ${ctx.model.provider}/${ctx.model.id}`);
1115
- setStatus(formatStatusLine(ctx.model.provider as ManagedProviderId));
1116
- }
902
+ installDeclaredRouteProviders(pi, ctx);
1117
903
  updateRotationStatus(ctx);
1118
904
  });
1119
905
 
1120
906
  pi.on("before_agent_start", async (_event, ctx) => {
1121
907
  runtimeContext = ctx;
1122
- const restored = readManagedProviderRotationPolicy(ctx.ui).allowCrossProviderFallback
1123
- && await restorePreferredModelIfPossible(pi, ctx);
1124
- if (restored && ctx.model) {
1125
- logRotation(`restored preferred ${ctx.model.provider}/${ctx.model.id}`);
1126
- setStatus(formatStatusLine(ctx.model.provider as ManagedProviderId));
908
+ const restored = await restoreHigherRankIfAvailable(pi, ctx);
909
+ if (restored && ctx.model && isManagedProvider(ctx.model.provider)) {
910
+ logRotation(`restored higher-ranked route ${ctx.model.provider}/${ctx.model.id}`);
911
+ setStatus(formatStatusLine(ctx.model.provider));
1127
912
  }
1128
913
  });
1129
914