@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.
- package/dist/api/client.d.ts +3 -1
- package/dist/api/client.js +6 -0
- package/dist/api/dto/modelauth.d.ts +32 -0
- package/dist/api/dto/modelauth.js +2 -1
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/builtin-memory/00-runtime-base.md +1 -0
- package/dist/builtin-memory/04-orchestration-kernel.md +1 -0
- package/dist/builtin-memory/init.md +38 -0
- package/dist/builtin-memory/wedged-child-on-runaway-bash.md +2 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +88 -396
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +215 -419
- package/dist/clients/attach/__tests__/crtr-output.test.js +14 -9
- package/dist/clients/attach/__tests__/edit-diff.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/edit-diff.test.js +34 -0
- package/dist/clients/attach/__tests__/editor-frame-title.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/editor-frame-title.test.js +19 -0
- package/dist/clients/attach/render/chat-view.js +8 -3
- package/dist/clients/attach/render/crtr-output.js +31 -2
- package/dist/clients/attach/render/edit-diff.js +26 -2
- package/dist/clients/attach/render/tool-calls.d.ts +7 -2
- package/dist/clients/attach/render/tool-calls.js +25 -4
- package/dist/clients/attach/session/connection.d.ts +1 -1
- package/dist/clients/attach/session/editor-frame.d.ts +5 -1
- package/dist/clients/attach/session/editor-frame.js +21 -15
- package/dist/clients/attach/session/identity.d.ts +1 -1
- package/dist/clients/attach/viewer.js +441 -439
- package/dist/commands/__tests__/api-canvas-source.test.d.ts +1 -0
- package/dist/commands/__tests__/api-canvas-source.test.js +37 -0
- package/dist/commands/__tests__/human.test.js +2 -0
- package/dist/commands/__tests__/search-contents.test.d.ts +1 -0
- package/dist/commands/__tests__/search-contents.test.js +52 -0
- package/dist/commands/api-client.js +1 -0
- package/dist/commands/human/prompts.js +2 -2
- package/dist/commands/human/shared.d.ts +1 -0
- package/dist/commands/human/shared.js +7 -4
- package/dist/commands/memory/lint.d.ts +6 -0
- package/dist/commands/memory/lint.js +177 -35
- package/dist/commands/memory/read.js +22 -1
- package/dist/commands/memory/write.js +18 -4
- package/dist/commands/memory.js +1 -1
- package/dist/commands/search/contents.js +2 -2
- package/dist/commands/sys/__tests__/sync-deps.test.js +12 -21
- package/dist/commands/sys/__tests__/sync-import.test.js +30 -29
- package/dist/commands/sys/setup-wizard.d.ts +8 -1
- package/dist/commands/sys/setup-wizard.js +209 -58
- package/dist/commands/sys/sync-deps.js +14 -18
- package/dist/commands/sys/sync-project-guidance.js +16 -19
- package/dist/core/__tests__/broker-launch-candidates.test.js +13 -3
- package/dist/core/__tests__/broker-sdk-wiring.test.js +53 -25
- package/dist/core/__tests__/full/broker-provider-retry.test.js +6 -122
- package/dist/core/__tests__/migration.test.js +8 -3
- package/dist/core/__tests__/model-pin-durability.test.js +7 -5
- package/dist/core/__tests__/model-routes.test.d.ts +1 -0
- package/dist/core/__tests__/model-routes.test.js +41 -0
- package/dist/core/__tests__/on-read-crouter-home-fence.test.js +7 -10
- package/dist/core/__tests__/on-read-dedup-resume.test.js +13 -25
- package/dist/core/__tests__/on-read-identity.test.js +8 -15
- package/dist/core/__tests__/review-model-floor.test.js +2 -2
- package/dist/core/__tests__/revive.test.js +2 -2
- package/dist/core/__tests__/tmux-surface.test.js +10 -4
- package/dist/core/__tests__/worktree.test.js +3 -3
- package/dist/core/auth-file.d.ts +3 -2
- package/dist/core/auth-file.js +5 -3
- package/dist/core/canvas/extensions.d.ts +1 -1
- package/dist/core/canvas/extensions.js +18 -11
- package/dist/core/canvas/labels.d.ts +4 -5
- package/dist/core/canvas/labels.js +9 -9
- package/dist/core/canvas/types.d.ts +6 -1
- package/dist/core/command-manifests/schema.js +18 -5
- package/dist/core/command.js +17 -7
- package/dist/core/config.d.ts +3 -1
- package/dist/core/config.js +88 -2
- package/dist/core/configured-clis/invoker.js +2 -0
- package/dist/core/help.d.ts +3 -1
- package/dist/core/help.js +5 -2
- package/dist/core/keybindings/__tests__/resolve.test.js +5 -2
- package/dist/core/keybindings/catalog.d.ts +3 -2
- package/dist/core/keybindings/catalog.js +34 -25
- package/dist/core/keybindings/index.d.ts +1 -1
- package/dist/core/keybindings/resolve.js +8 -0
- package/dist/core/keybindings/types.d.ts +6 -1
- package/dist/core/memory/doc-link-grammar.d.ts +20 -0
- package/dist/core/memory/doc-link-grammar.js +110 -0
- package/dist/core/memory-resolver.d.ts +5 -0
- package/dist/core/memory-resolver.js +12 -1
- package/dist/core/model-routes.d.ts +37 -0
- package/dist/core/model-routes.js +140 -0
- package/dist/core/runtime/bearings.d.ts +8 -8
- package/dist/core/runtime/bearings.js +20 -16
- package/dist/core/runtime/broker.d.ts +0 -4
- package/dist/core/runtime/broker.js +119 -385
- package/dist/core/runtime/canvas-extensions.d.ts +1 -0
- package/dist/core/runtime/canvas-extensions.js +2 -0
- package/dist/core/runtime/launch.d.ts +10 -12
- package/dist/core/runtime/launch.js +16 -22
- package/dist/core/runtime/managed-provider-cooling.d.ts +0 -14
- package/dist/core/runtime/managed-provider-cooling.js +0 -19
- package/dist/core/runtime/model-swap.js +7 -1
- package/dist/core/runtime/promote.js +1 -0
- package/dist/core/runtime/tmux.js +136 -100
- package/dist/core/scope.js +4 -5
- package/dist/core/subscription-state.d.ts +12 -36
- package/dist/core/subscription-state.js +19 -203
- package/dist/core/substrate/ceiling.d.ts +3 -6
- package/dist/core/substrate/ceiling.js +13 -15
- package/dist/core/substrate/index.d.ts +2 -3
- package/dist/core/substrate/index.js +2 -2
- package/dist/core/substrate/on-read.d.ts +4 -13
- package/dist/core/substrate/on-read.js +142 -262
- package/dist/core/substrate/render.js +4 -4
- package/dist/core/substrate/schema.d.ts +3 -2
- package/dist/core/substrate/schema.js +5 -5
- package/dist/daemon/api/__tests__/full/api-server.test.js +9 -6
- package/dist/daemon/api/__tests__/full/b10-attach-modelauth.test.js +123 -1
- package/dist/daemon/api/handlers/modelauth.js +51 -2
- package/dist/daemon/api/handlers/nodes.js +2 -0
- package/dist/pi-extensions/__tests__/canvas-tool-guide.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-tool-guide.test.js +96 -0
- package/dist/pi-extensions/canvas-doc-substrate.js +7 -8
- package/dist/pi-extensions/canvas-tool-guide.d.ts +14 -0
- package/dist/pi-extensions/canvas-tool-guide.js +74 -0
- package/dist/types.d.ts +17 -0
- package/dist/web-client/assets/{index-B76ZKfT_.js → index-NIuSCOHM.js} +1 -1
- package/dist/web-client/index.html +1 -1
- package/dist/web-client/sw.js +1 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- 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
|
-
|
|
46
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
519
|
+
thinkingLevel: string | undefined;
|
|
628
520
|
};
|
|
629
521
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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<
|
|
528
|
+
localCoolingDeadlines: Map<string, number>,
|
|
648
529
|
): void {
|
|
649
530
|
const now = Date.now();
|
|
650
|
-
for (const [
|
|
651
|
-
if (
|
|
652
|
-
const seenLabels = seenReadyLabels.get(
|
|
653
|
-
const hasFreshCredential = readSubscriptionPool(providerId).some((credential) => {
|
|
654
|
-
if (credential.
|
|
655
|
-
const cooldownAttempt = cooldownAttempts.get(
|
|
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(
|
|
660
|
-
|
|
540
|
+
candidates.push(candidate);
|
|
541
|
+
queuedRouteIds.add(routeId);
|
|
661
542
|
}
|
|
662
543
|
}
|
|
663
544
|
}
|
|
664
545
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
const
|
|
679
|
-
const
|
|
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<
|
|
682
|
-
const seenReadyLabels = new Map<
|
|
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
|
|
600
|
+
let terminalModel = model;
|
|
685
601
|
|
|
686
602
|
for (let candidate = candidates.shift(); candidate !== undefined; candidate = candidates.shift()) {
|
|
687
|
-
const { model: candidateModel
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
if (
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
if (
|
|
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
|
-
|
|
709
|
-
const
|
|
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(
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
-
|
|
669
|
+
enqueueFreshRouteCandidates(candidates, queuedRouteIds, knownRoutes, seenReadyLabels, cooldownAttempts, localCoolingDeadlines);
|
|
670
|
+
if (advanceResult !== undefined) logRouteQueueAdvance(candidate, candidates[0], advanceResult);
|
|
874
671
|
}
|
|
875
672
|
|
|
876
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
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:
|
|
926
|
-
localCoolingDeadlines
|
|
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
|
|
931
|
-
for (const
|
|
932
|
-
const localUntil = localCoolingDeadlines.get(
|
|
933
|
-
if (localUntil > now) {
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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
|
|