@north-light/crouter 0.3.161 → 0.3.163

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 (130) 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-memory/00-runtime-base.md +1 -0
  8. package/dist/builtin-memory/04-orchestration-kernel.md +1 -0
  9. package/dist/builtin-memory/init.md +38 -0
  10. package/dist/builtin-memory/wedged-child-on-runaway-bash.md +2 -2
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +88 -396
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +215 -419
  14. package/dist/clients/attach/__tests__/crtr-output.test.js +14 -9
  15. package/dist/clients/attach/__tests__/edit-diff.test.d.ts +1 -0
  16. package/dist/clients/attach/__tests__/edit-diff.test.js +34 -0
  17. package/dist/clients/attach/__tests__/editor-frame-title.test.d.ts +1 -0
  18. package/dist/clients/attach/__tests__/editor-frame-title.test.js +19 -0
  19. package/dist/clients/attach/render/chat-view.js +8 -3
  20. package/dist/clients/attach/render/crtr-output.js +31 -2
  21. package/dist/clients/attach/render/edit-diff.js +26 -2
  22. package/dist/clients/attach/render/tool-calls.d.ts +7 -2
  23. package/dist/clients/attach/render/tool-calls.js +25 -4
  24. package/dist/clients/attach/session/connection.d.ts +1 -1
  25. package/dist/clients/attach/session/editor-frame.d.ts +5 -1
  26. package/dist/clients/attach/session/editor-frame.js +21 -15
  27. package/dist/clients/attach/session/identity.d.ts +1 -1
  28. package/dist/clients/attach/viewer.js +441 -439
  29. package/dist/commands/__tests__/api-canvas-source.test.d.ts +1 -0
  30. package/dist/commands/__tests__/api-canvas-source.test.js +37 -0
  31. package/dist/commands/__tests__/human.test.js +2 -0
  32. package/dist/commands/__tests__/search-contents.test.d.ts +1 -0
  33. package/dist/commands/__tests__/search-contents.test.js +52 -0
  34. package/dist/commands/api-client.js +1 -0
  35. package/dist/commands/human/prompts.js +2 -2
  36. package/dist/commands/human/shared.d.ts +1 -0
  37. package/dist/commands/human/shared.js +7 -4
  38. package/dist/commands/memory/lint.d.ts +6 -0
  39. package/dist/commands/memory/lint.js +177 -35
  40. package/dist/commands/memory/read.js +22 -1
  41. package/dist/commands/memory/write.js +18 -4
  42. package/dist/commands/memory.js +1 -1
  43. package/dist/commands/search/contents.js +2 -2
  44. package/dist/commands/sys/__tests__/sync-deps.test.js +12 -21
  45. package/dist/commands/sys/__tests__/sync-import.test.js +30 -29
  46. package/dist/commands/sys/setup-wizard.d.ts +8 -1
  47. package/dist/commands/sys/setup-wizard.js +209 -58
  48. package/dist/commands/sys/sync-deps.js +14 -18
  49. package/dist/commands/sys/sync-project-guidance.js +16 -19
  50. package/dist/core/__tests__/broker-launch-candidates.test.js +13 -3
  51. package/dist/core/__tests__/broker-sdk-wiring.test.js +53 -25
  52. package/dist/core/__tests__/full/broker-provider-retry.test.js +6 -122
  53. package/dist/core/__tests__/migration.test.js +8 -3
  54. package/dist/core/__tests__/model-pin-durability.test.js +7 -5
  55. package/dist/core/__tests__/model-routes.test.d.ts +1 -0
  56. package/dist/core/__tests__/model-routes.test.js +41 -0
  57. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +7 -10
  58. package/dist/core/__tests__/on-read-dedup-resume.test.js +13 -25
  59. package/dist/core/__tests__/on-read-identity.test.js +8 -15
  60. package/dist/core/__tests__/review-model-floor.test.js +2 -2
  61. package/dist/core/__tests__/revive.test.js +2 -2
  62. package/dist/core/__tests__/tmux-surface.test.js +10 -4
  63. package/dist/core/__tests__/worktree.test.js +3 -3
  64. package/dist/core/auth-file.d.ts +3 -2
  65. package/dist/core/auth-file.js +5 -3
  66. package/dist/core/canvas/extensions.d.ts +1 -1
  67. package/dist/core/canvas/extensions.js +18 -11
  68. package/dist/core/canvas/labels.d.ts +4 -5
  69. package/dist/core/canvas/labels.js +9 -9
  70. package/dist/core/canvas/types.d.ts +6 -1
  71. package/dist/core/command-manifests/schema.js +18 -5
  72. package/dist/core/command.js +17 -7
  73. package/dist/core/config.d.ts +3 -1
  74. package/dist/core/config.js +88 -2
  75. package/dist/core/configured-clis/invoker.js +2 -0
  76. package/dist/core/help.d.ts +3 -1
  77. package/dist/core/help.js +5 -2
  78. package/dist/core/keybindings/__tests__/resolve.test.js +5 -2
  79. package/dist/core/keybindings/catalog.d.ts +3 -2
  80. package/dist/core/keybindings/catalog.js +34 -25
  81. package/dist/core/keybindings/index.d.ts +1 -1
  82. package/dist/core/keybindings/resolve.js +8 -0
  83. package/dist/core/keybindings/types.d.ts +6 -1
  84. package/dist/core/memory/doc-link-grammar.d.ts +20 -0
  85. package/dist/core/memory/doc-link-grammar.js +110 -0
  86. package/dist/core/memory-resolver.d.ts +5 -0
  87. package/dist/core/memory-resolver.js +12 -1
  88. package/dist/core/model-routes.d.ts +37 -0
  89. package/dist/core/model-routes.js +140 -0
  90. package/dist/core/runtime/bearings.d.ts +8 -8
  91. package/dist/core/runtime/bearings.js +20 -16
  92. package/dist/core/runtime/broker.d.ts +0 -4
  93. package/dist/core/runtime/broker.js +119 -385
  94. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  95. package/dist/core/runtime/canvas-extensions.js +2 -0
  96. package/dist/core/runtime/launch.d.ts +10 -12
  97. package/dist/core/runtime/launch.js +16 -22
  98. package/dist/core/runtime/managed-provider-cooling.d.ts +0 -14
  99. package/dist/core/runtime/managed-provider-cooling.js +0 -19
  100. package/dist/core/runtime/model-swap.js +7 -1
  101. package/dist/core/runtime/promote.js +1 -0
  102. package/dist/core/runtime/tmux.js +136 -100
  103. package/dist/core/scope.js +4 -5
  104. package/dist/core/subscription-state.d.ts +12 -36
  105. package/dist/core/subscription-state.js +19 -203
  106. package/dist/core/substrate/ceiling.d.ts +3 -6
  107. package/dist/core/substrate/ceiling.js +13 -15
  108. package/dist/core/substrate/index.d.ts +2 -3
  109. package/dist/core/substrate/index.js +2 -2
  110. package/dist/core/substrate/on-read.d.ts +4 -13
  111. package/dist/core/substrate/on-read.js +142 -262
  112. package/dist/core/substrate/render.js +4 -4
  113. package/dist/core/substrate/schema.d.ts +3 -2
  114. package/dist/core/substrate/schema.js +5 -5
  115. package/dist/daemon/api/__tests__/full/api-server.test.js +9 -6
  116. package/dist/daemon/api/__tests__/full/b10-attach-modelauth.test.js +123 -1
  117. package/dist/daemon/api/handlers/modelauth.js +51 -2
  118. package/dist/daemon/api/handlers/nodes.js +2 -0
  119. package/dist/pi-extensions/__tests__/canvas-tool-guide.test.d.ts +1 -0
  120. package/dist/pi-extensions/__tests__/canvas-tool-guide.test.js +96 -0
  121. package/dist/pi-extensions/canvas-doc-substrate.js +7 -8
  122. package/dist/pi-extensions/canvas-tool-guide.d.ts +14 -0
  123. package/dist/pi-extensions/canvas-tool-guide.js +74 -0
  124. package/dist/types.d.ts +17 -0
  125. package/dist/web-client/assets/{index-B76ZKfT_.js → index-NIuSCOHM.js} +1 -1
  126. package/dist/web-client/index.html +1 -1
  127. package/dist/web-client/sw.js +1 -1
  128. package/package.json +1 -1
  129. package/runtime.lock.json +2 -2
  130. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/crouter-help.ts +0 -95
@@ -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,207 @@ 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
+ credentialSource: string;
516
+ rank: number;
517
+ providerId: string;
626
518
  model: Model<any>;
627
- providerId: ManagedProviderId;
519
+ thinkingLevel: string | undefined;
628
520
  };
629
521
 
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>>,
522
+ function enqueueFreshRouteCandidates(
523
+ candidates: RouteCandidate[],
524
+ queuedRouteIds: Set<string>,
525
+ knownRoutes: Map<string, RouteCandidate>,
526
+ seenReadyLabels: Map<string, Set<string>>,
646
527
  cooldownAttempts: Map<string, number>,
647
- localCoolingDeadlines: Map<ManagedProviderId, number>,
528
+ localCoolingDeadlines: Map<string, number>,
648
529
  ): void {
649
530
  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));
531
+ for (const [routeId, candidate] of knownRoutes) {
532
+ if (queuedRouteIds.has(routeId) || (localCoolingDeadlines.get(routeId) ?? 0) > now || !isManagedProvider(candidate.providerId)) continue;
533
+ const seenLabels = seenReadyLabels.get(routeId) ?? new Set<string>();
534
+ const hasFreshCredential = readSubscriptionPool(candidate.providerId).some((credential) => {
535
+ if (credential.authFailure !== undefined || (credential.rateLimitedUntil || 0) > now) return false;
536
+ const cooldownAttempt = cooldownAttempts.get(`${routeId}/${credential.label}`);
656
537
  return !seenLabels.has(credential.label) || (cooldownAttempt !== undefined && credential.lastAttemptAt > cooldownAttempt);
657
538
  });
658
539
  if (hasFreshCredential) {
659
- candidates.push({ model, providerId });
660
- queuedProviderIds.add(providerId);
540
+ candidates.push(candidate);
541
+ queuedRouteIds.add(routeId);
661
542
  }
662
543
  }
663
544
  }
664
545
 
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.
546
+ async function switchToRoute(pi: ExtensionAPI, ctx: ExtensionContext, candidate: RouteCandidate): Promise<boolean> {
547
+ if (ctx.model?.provider !== candidate.providerId || ctx.model.id !== candidate.model.id) {
548
+ if (!await pi.setModel(candidate.model)) return false;
549
+ }
550
+ pi.setThinkingLevel((candidate.thinkingLevel ?? 'off') as any);
551
+ return ctx.model?.provider === candidate.providerId && ctx.model.id === candidate.model.id;
552
+ }
553
+
554
+ function logRouteQueueAdvance(from: RouteCandidate, to: RouteCandidate | undefined, result: AttemptResult): void {
555
+ logRotation(
556
+ `from=${from.providerId}/${from.model.id} from_route_id=${from.routeId} from_credential_source=${from.credentialSource} from_rank=${from.rank} to=${to ? `${to.providerId}/${to.model.id}` : "none"} to_route_id=${to?.routeId ?? "none"} to_credential_source=${to?.credentialSource ?? "none"} to_rank=${to?.rank ?? "none"} reason=${result.classification?.reason ?? result.result}; advancing route queue`,
557
+ );
558
+ }
559
+
560
+ // Drive the deterministic route queue from the live rank. Managed routes walk
561
+ // their pool rows; every other declared route is one configured credential.
669
562
  async function runManagedProvider(
670
563
  model: Model<any>,
671
564
  context: Context,
672
565
  options: SimpleStreamOptions | undefined,
673
566
  stream: AssistantMessageEventStream,
674
567
  ): 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>();
568
+ const liveThinking = runtimeContext?.thinkingLevel === undefined || runtimeContext?.thinkingLevel === 'off'
569
+ ? undefined
570
+ : runtimeContext.thinkingLevel;
571
+ const request = modelRequestFromConfig(`${model.provider}/${model.id}${liveThinking ? `:${liveThinking}` : ''}`, process.env['CRTR_MODEL_INTENT'], false);
572
+ const expanded = request ? expandModelCandidates(request, process.env['CRTR_NODE_CWD'], process.env['CRTR_PROFILE_ID']) : [];
573
+ const liveRank = expanded.find((candidate) => candidate.providerId === model.provider && candidate.modelId === model.id && candidate.thinkingLevel === liveThinking)?.rank ?? 0;
574
+ const routeCandidates: RouteCandidate[] = expanded.slice(liveRank)
575
+ .map((route) => {
576
+ const registry = runtimeContext?.modelRegistry;
577
+ const routeModel = registry?.find(route.providerId, route.modelId);
578
+ if (!registry || !routeModel) return undefined;
579
+ const availability = probeRouteAvailability(route, registry);
580
+ if (!availability.registered || (!isManagedProvider(route.providerId) && !availability.authenticated)) return undefined;
581
+ return { routeId: route.routeId, credentialSource: route.credentialSource, rank: route.rank, providerId: route.providerId, model: routeModel, thinkingLevel: route.thinkingLevel };
582
+ })
583
+ .filter((candidate): candidate is RouteCandidate => candidate !== undefined);
584
+ const isLiveRoute = (candidate: RouteCandidate): boolean =>
585
+ candidate.providerId === model.provider && candidate.model.id === model.id && candidate.thinkingLevel === liveThinking;
586
+ // A cooling or unauthenticated managed route contributes its diagnostic and may
587
+ // later re-enter when a credential becomes ready, but it is never selected now.
588
+ const candidates = routeCandidates.filter((candidate) => isLiveRoute(candidate) || !isManagedProvider(candidate.providerId) || readSubscriptionPool(candidate.providerId).some((entry) => entry.authFailure === undefined && (entry.rateLimitedUntil || 0) <= Date.now()));
589
+ if (!candidates.some(isLiveRoute)) {
590
+ candidates.unshift({ routeId: `live:${model.provider}/${model.id}`, credentialSource: 'live', rank: liveRank, providerId: model.provider, model, thinkingLevel: liveThinking });
591
+ }
592
+
593
+ const queuedRouteIds = new Set(candidates.map((candidate) => candidate.routeId));
594
+ const knownRoutes = new Map([...routeCandidates, ...candidates].map((candidate) => [candidate.routeId, candidate]));
595
+ const attemptedRoutes = new Set<string>();
680
596
  const authFailures = new Set<string>();
681
- const localCoolingDeadlines = new Map<ManagedProviderId, number>();
682
- const seenReadyLabels = new Map<ManagedProviderId, Set<string>>();
597
+ const localCoolingDeadlines = new Map<string, number>();
598
+ const seenReadyLabels = new Map<string, Set<string>>();
683
599
  const cooldownAttempts = new Map<string, number>();
684
- let terminal: ManagedProviderTerminalState | undefined;
600
+ let terminalModel = model;
685
601
 
686
602
  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}`);
603
+ const { routeId, providerId, model: candidateModel } = candidate;
604
+ queuedRouteIds.delete(routeId);
605
+ knownRoutes.set(routeId, candidate);
606
+ attemptedRoutes.add(routeId);
607
+ terminalModel = candidateModel;
608
+
609
+ if (!await switchToRoute(extensionAPI!, runtimeContext!, candidate)) {
610
+ emitTerminalError(stream, candidateModel, `Provider route ${routeId} is unavailable: failed to select ${providerId}/${candidateModel.id}`);
611
+ return;
702
612
  }
703
613
 
704
- const rawPool = readSubscriptionPool(providerId);
705
- for (const entry of rawPool) {
706
- if (entry.authFailure === "invalid_grant") authFailures.add(`${providerId}/${entry.label}`);
614
+ if (!isManagedProvider(providerId)) {
615
+ const result = await streamProviderAttempt(providerId, candidateModel, context, options, undefined, stream);
616
+ if (result.result === 'success') { stream.end(); return; }
617
+ if (result.result === 'fatal') { stream.end(); return; }
618
+ const cooldownMs = result.result === 'rate_limited' ? result.retryAfterMs ?? DEFAULT_RATE_LIMIT_BACKOFF_MS : TRANSIENT_ROTATE_COOLDOWN_MS;
619
+ localCoolingDeadlines.set(routeId, Math.max(localCoolingDeadlines.get(routeId) ?? 0, Date.now() + cooldownMs));
620
+ logRouteQueueAdvance(candidate, candidates[0], result);
621
+ continue;
707
622
  }
708
- const available = rawPool.filter((entry) => !entry.rateLimitedUntil || entry.rateLimitedUntil <= Date.now());
709
- const seenLabels = seenReadyLabels.get(providerId) ?? new Set<string>();
623
+
624
+ const rawPool = readSubscriptionPool(providerId);
625
+ for (const entry of rawPool) if (entry.authFailure === 'invalid_grant') authFailures.add(`${providerId}/${entry.label}`);
626
+ const available = rawPool.filter((entry) => entry.authFailure === undefined && (!entry.rateLimitedUntil || entry.rateLimitedUntil <= Date.now()));
627
+ const seenLabels = seenReadyLabels.get(routeId) ?? new Set<string>();
710
628
  for (const credential of available) seenLabels.add(credential.label);
711
- seenReadyLabels.set(providerId, seenLabels);
629
+ seenReadyLabels.set(routeId, seenLabels);
712
630
 
631
+ let advanceResult: AttemptResult | undefined;
713
632
  for (const credential of available) {
714
633
  let freshCredential: ResolvedCredential;
715
634
  try {
716
635
  freshCredential = await refreshCredentialIfNeeded(providerId, credential);
717
636
  } 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;
637
+ if (!isInvalidRefreshTokenError(error)) throw error;
638
+ let reauthenticated: ResolvedCredential | undefined;
639
+ try { reauthenticated = await reauthenticateCredential(providerId, credential); } catch (loginError) {
640
+ runtimeContext?.ui.notify(`${getProviderLabel(providerId)} re-authentication failed: ${loginError instanceof Error ? loginError.message : String(loginError)}`, 'error');
641
+ }
642
+ if (!reauthenticated) {
643
+ const attemptAt = Date.now();
644
+ await recordSubscriptionMutation(`recording ${getProviderLabel(providerId)} authentication failure`, () => markSubscriptionAuthenticationFailed(providerId, credential.label, INVALID_REFRESH_TOKEN_BACKOFF_MS, attemptAt));
645
+ authFailures.add(`${providerId}/${credential.label}`);
646
+ continue;
745
647
  }
648
+ freshCredential = reauthenticated;
746
649
  }
747
650
  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));
651
+ if (result.result === 'success') {
652
+ if (freshCredential.rateLimitedUntil || freshCredential.authFailure) await recordSubscriptionMutation(`recording ${getProviderLabel(providerId)} success`, () => markSubscriptionSuccess(providerId, freshCredential.label, result.attemptAt));
760
653
  stream.end();
761
654
  return;
762
655
  }
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;
656
+ if (result.result === 'fatal') { stream.end(); return; }
657
+ const cooldownMs = result.result === 'rate_limited' ? result.retryAfterMs ?? DEFAULT_RATE_LIMIT_BACKOFF_MS : TRANSIENT_ROTATE_COOLDOWN_MS;
658
+ if ((result.result === 'rate_limited' && result.retryAfterMs !== undefined) || result.result === 'transient_exhausted') {
659
+ const source = result.result === 'transient_exhausted' ? 'transient cooldown' : 'server cooldown';
660
+ const persisted = await recordSubscriptionMutation(`recording ${getProviderLabel(providerId)} ${source}`, () => markSubscriptionRateLimited(providerId, freshCredential.label, cooldownMs, result.attemptAt));
661
+ if (persisted) cooldownAttempts.set(`${routeId}/${freshCredential.label}`, result.attemptAt);
662
+ else localCoolingDeadlines.set(routeId, Math.max(localCoolingDeadlines.get(routeId) ?? 0, Date.now() + cooldownMs));
663
+ } else {
664
+ localCoolingDeadlines.set(routeId, Math.max(localCoolingDeadlines.get(routeId) ?? 0, Date.now() + cooldownMs));
802
665
  }
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;
865
- }
866
-
867
- candidates.push({ model: fallbackModel, providerId: target.providerId });
868
- queuedProviderIds.add(target.providerId);
869
- knownModels.set(target.providerId, fallbackModel);
870
- continue;
666
+ advanceResult = result;
871
667
  }
872
668
 
873
- terminal = { model: candidateModel, providerId, currentPool, fallbackResolution };
669
+ enqueueFreshRouteCandidates(candidates, queuedRouteIds, knownRoutes, seenReadyLabels, cooldownAttempts, localCoolingDeadlines);
670
+ if (advanceResult !== undefined) logRouteQueueAdvance(candidate, candidates[0], advanceResult);
874
671
  }
875
672
 
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);
673
+ const coolingDeadline = earliestCoolingDeadline([...knownRoutes.values()], localCoolingDeadlines);
674
+ const tried = [...attemptedRoutes].join(', ');
893
675
  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
- );
676
+ emitCoolingError(stream, terminalModel, new ManagedProviderCoolingError(`All provider routes are rate limited (cooling down ~${Math.ceil((coolingDeadline - Date.now()) / 1000)}s; tried ${tried})`, coolingDeadline));
903
677
  return;
904
678
  }
905
-
906
679
  const authFailure = [...authFailures][0];
907
680
  if (authFailure !== undefined) {
908
- emitTerminalError(stream, terminalModel, `Provider authentication failed (invalid_grant) for ${authFailure}; re-authenticate the credential and retry (tried ${triedList})`);
681
+ emitTerminalError(stream, terminalModel, `Provider authentication failed (invalid_grant) for ${authFailure}; re-authenticate the credential and retry (tried ${tried})`);
909
682
  return;
910
683
  }
911
-
912
- emitTerminalError(
913
- stream,
914
- terminalModel,
915
- `All managed provider pools exhausted; ${fallbackHint} (tried ${triedList})`,
916
- );
684
+ emitTerminalError(stream, terminalModel, `All provider routes exhausted (tried ${tried})`);
917
685
  }
918
686
 
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.
687
+ // The earliest strictly-future deadline only when every attempted route is cooling.
924
688
  function earliestCoolingDeadline(
925
- attempted: Set<ManagedProviderId>,
926
- localCoolingDeadlines = new Map<ManagedProviderId, number>(),
689
+ attempted: Iterable<RouteCandidate>,
690
+ localCoolingDeadlines: Map<string, number>,
927
691
  ): number | undefined {
928
692
  const now = Date.now();
929
693
  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
- }
694
+ let sawRoute = false;
695
+ for (const route of attempted) {
696
+ const localUntil = localCoolingDeadlines.get(route.routeId) ?? 0;
697
+ if (localUntil > now) { sawRoute = true; earliest = earliest === undefined ? localUntil : Math.min(earliest, localUntil); continue; }
698
+ if (!isManagedProvider(route.providerId)) return undefined;
699
+ const pool = readSubscriptionPool(route.providerId).filter((entry) => entry.authFailure === undefined);
700
+ // Routes with no usable credential were never runnable this turn, so they do
701
+ // not defeat the cooling outcome of another route. An auth-failed-only pool
702
+ // is likewise unauthenticated; a valid cooling row still contributes its
703
+ // deadline even when a sibling row carries invalid_grant.
704
+ if (pool.length === 0) continue;
705
+ sawRoute = true;
706
+ if (pool.some((entry) => (entry.rateLimitedUntil || 0) <= now)) return undefined;
707
+ const routeEarliest = Math.min(...pool.map((entry) => entry.rateLimitedUntil || 0));
708
+ earliest = earliest === undefined ? routeEarliest : Math.min(earliest, routeEarliest);
945
709
  }
946
- return sawCredential ? earliest : undefined;
710
+ return sawRoute ? earliest : undefined;
947
711
  }
948
712
 
949
713
  // Emit the typed cooling terminal error, attaching its diagnostic (the earliest
@@ -957,10 +721,6 @@ function emitCoolingError(stream: AssistantMessageEventStream, model: Model<any>
957
721
  }
958
722
 
959
723
  function streamManagedProvider(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
960
- if (!isManagedProvider(model.provider)) {
961
- return streamForProvider(model, context, options);
962
- }
963
-
964
724
  const stream = createAssistantMessageEventStream();
965
725
 
966
726
  runManagedProvider(model, context, options, stream).catch((error) => {
@@ -987,9 +747,51 @@ function parseProvider(value: string | undefined): ManagedProviderId | undefined
987
747
  return isManagedProvider(value) ? value : undefined;
988
748
  }
989
749
 
750
+ function installDeclaredRouteProviders(pi: ExtensionAPI, ctx: ExtensionContext): void {
751
+ const config = readMergedLaunchConfig(ctx.cwd, process.env['CRTR_PROFILE_ID'] || null);
752
+ const providerIds = new Set(Object.values(config.modelRoutes ?? {}).map((route) => route.providerId));
753
+ for (const providerId of providerIds) {
754
+ if (isManagedProvider(providerId) || delegatedProviderStreams.has(providerId)) continue;
755
+ const provider = ctx.modelRegistry.getProvider?.(providerId);
756
+ const model = ctx.modelRegistry.getAll?.().find((entry) => entry.provider === providerId);
757
+ if (!provider || !model) continue;
758
+ delegatedProviderStreams.set(providerId, provider.streamSimple.bind(provider));
759
+ // `api` is required by pi whenever a registration supplies streamSimple.
760
+ // Re-registration merges this wrapper over the existing declaration, so the
761
+ // provider's models, auth, and captured composed transport remain intact.
762
+ pi.registerProvider(providerId, { api: model.api, streamSimple: streamManagedProvider });
763
+ }
764
+ }
765
+
990
766
  function updateRotationStatus(ctx: ExtensionContext): void {
991
767
  const current = getCurrentModelRef(ctx.model);
992
- if (current) setStatus(formatStatusLine(current.providerId));
768
+ if (current && isManagedProvider(current.providerId)) setStatus(formatStatusLine(current.providerId));
769
+ }
770
+
771
+ /** Restore only when the deterministic route rank improves; no shared-home
772
+ * preferred-model state is needed or consulted. */
773
+ async function restoreHigherRankIfAvailable(pi: ExtensionAPI, ctx: ExtensionContext): Promise<boolean> {
774
+ if (!readRotationConfig().revertWhenAvailable) return false;
775
+ const liveThinking = ctx.thinkingLevel === undefined || ctx.thinkingLevel === 'off' ? undefined : ctx.thinkingLevel;
776
+ const request = modelRequestFromConfig(
777
+ `${ctx.model?.provider ?? ''}/${ctx.model?.id ?? ''}${liveThinking ? `:${liveThinking}` : ''}`,
778
+ process.env['CRTR_MODEL_INTENT'],
779
+ false,
780
+ );
781
+ if (!request || !ctx.model) return false;
782
+ const candidates = expandModelCandidates(request, process.env['CRTR_NODE_CWD'], process.env['CRTR_PROFILE_ID']);
783
+ const liveRank = candidates.find((candidate) => candidate.providerId === ctx.model?.provider && candidate.modelId === ctx.model?.id && candidate.thinkingLevel === liveThinking)?.rank;
784
+ if (liveRank === undefined) return false;
785
+ const preferred = candidates.find((candidate) => candidate.rank < liveRank && resolveProviderCandidates([{
786
+ ...candidate,
787
+ ...probeRouteAvailability(candidate, ctx.modelRegistry),
788
+ automaticFallback: true,
789
+ }]).candidates.length > 0);
790
+ if (!preferred) return false;
791
+ const model = ctx.modelRegistry.find(preferred.providerId, preferred.modelId);
792
+ if (!model || !await pi.setModel(model)) return false;
793
+ pi.setThinkingLevel((preferred.thinkingLevel ?? 'off') as any);
794
+ return true;
993
795
  }
994
796
 
995
797
  // Manual-code input is a human-in-the-loop OAuth round-trip (open link, log in on
@@ -1108,22 +910,16 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1108
910
 
1109
911
  pi.on("session_start", async (_event, ctx) => {
1110
912
  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
- }
913
+ installDeclaredRouteProviders(pi, ctx);
1117
914
  updateRotationStatus(ctx);
1118
915
  });
1119
916
 
1120
917
  pi.on("before_agent_start", async (_event, ctx) => {
1121
918
  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));
919
+ const restored = await restoreHigherRankIfAvailable(pi, ctx);
920
+ if (restored && ctx.model && isManagedProvider(ctx.model.provider)) {
921
+ logRotation(`restored higher-ranked route ${ctx.model.provider}/${ctx.model.id}`);
922
+ setStatus(formatStatusLine(ctx.model.provider));
1127
923
  }
1128
924
  });
1129
925