@north-light/crouter 0.3.160 → 0.3.162
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +55 -395
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +204 -419
- package/dist/clients/attach/__tests__/markdown-headings.test.js +8 -0
- package/dist/clients/attach/render/glyph-codepoints.d.ts +3 -0
- package/dist/clients/attach/render/glyph-codepoints.js +42 -0
- package/dist/clients/attach/render/markdown-headings.js +15 -4
- package/dist/clients/attach/viewer.js +558 -558
- 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__/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__/review-model-floor.test.js +2 -2
- package/dist/core/auth-file.d.ts +3 -2
- package/dist/core/auth-file.js +5 -3
- package/dist/core/canvas/labels.d.ts +5 -3
- package/dist/core/canvas/labels.js +7 -4
- package/dist/core/canvas/types.d.ts +6 -1
- package/dist/core/config.d.ts +3 -1
- package/dist/core/config.js +88 -2
- package/dist/core/model-routes.d.ts +37 -0
- package/dist/core/model-routes.js +140 -0
- package/dist/core/runtime/broker.d.ts +0 -4
- package/dist/core/runtime/broker.js +116 -372
- package/dist/core/runtime/launch.d.ts +9 -11
- package/dist/core/runtime/launch.js +15 -21
- 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/naming.js +21 -14
- package/dist/core/runtime/promote.js +1 -0
- package/dist/core/subscription-state.d.ts +12 -36
- package/dist/core/subscription-state.js +19 -203
- 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/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 +8 -8
- package/runtime.lock.json +161 -161
|
@@ -3,17 +3,6 @@ import type { NodeMeta, LaunchSpec, Mode, Lifecycle } from '../canvas/index.js';
|
|
|
3
3
|
import type { ModelProvider, ModelStrength, ScopeConfig } from '../../types.js';
|
|
4
4
|
export { OPENAI_PROVIDER_ENV, configuredLadderProviders, inferPortableModelSelection } from './model-selection.js';
|
|
5
5
|
export type { PortableModelSelection } from './model-selection.js';
|
|
6
|
-
export interface EquivalentProviderModel {
|
|
7
|
-
fromProvider: ModelProvider;
|
|
8
|
-
toProvider: ModelProvider;
|
|
9
|
-
strength: ModelStrength;
|
|
10
|
-
model: string;
|
|
11
|
-
}
|
|
12
|
-
/** Map a concrete ladder model back to its provider×strength cell, then return
|
|
13
|
-
* the other configured provider's model at the same strength. `failedProviders`
|
|
14
|
-
* is per-turn state: once a provider has produced a retryable outage error, do
|
|
15
|
-
* not bounce back to it or keep reattempting the same failed provider. */
|
|
16
|
-
export declare function equivalentOtherProviderModel(currentModel: string, failedProviders?: ReadonlySet<ModelProvider>): EquivalentProviderModel | null;
|
|
17
6
|
/** True when an explicit model token is QUALIFIED — a `<provider>/<model-id>`
|
|
18
7
|
* or `<provider>/<tier>` raw token — the structural marker of a concrete exact
|
|
19
8
|
* pick. Deliberately provider-agnostic: a real registry provider namespace
|
|
@@ -31,6 +20,10 @@ export declare function isProviderPinnedModelToken(model: string): boolean;
|
|
|
31
20
|
* ladder the running build ships, so they never freeze a stale concrete
|
|
32
21
|
* model id to disk. Mirrors `normalizeModel`'s branch structure exactly. */
|
|
33
22
|
export declare function isPortableModelToken(model: string): boolean;
|
|
23
|
+
export declare function logicalModelIntent(model: string, ladders?: ScopeConfig['modelLadders']): {
|
|
24
|
+
family: string;
|
|
25
|
+
strength: ModelStrength;
|
|
26
|
+
} | undefined;
|
|
34
27
|
export declare function normalizeModel(model: string, ladders?: ScopeConfig['modelLadders']): string;
|
|
35
28
|
/** The interleaved model-ladder cycle Alt+M / Alt+Shift+M walks: for each strength tier
|
|
36
29
|
* from strongest to weakest, each offered provider before descending — anthropic/ultra,
|
|
@@ -95,6 +88,11 @@ export declare function buildLaunchSpec(kind: string, mode: Mode, opts: {
|
|
|
95
88
|
* promote/config, where `opts.model`'s shape IS the raw token and
|
|
96
89
|
* legitimately re-decides the pin. */
|
|
97
90
|
modelProviderPinned?: boolean;
|
|
91
|
+
/** Preserved intent from an existing launch recipe when rebuilding it. */
|
|
92
|
+
modelIntent?: {
|
|
93
|
+
family: string;
|
|
94
|
+
strength: ModelStrength;
|
|
95
|
+
};
|
|
98
96
|
/** Exact-consult launch: pass the captured `provider/id:thinking` spec
|
|
99
97
|
* verbatim as `launch.model`, skipping `floorReviewModel`/`normalizeModel`
|
|
100
98
|
* entirely (avoids any tier-collision reinterpretation). `opts.model` MUST
|
|
@@ -43,26 +43,7 @@ export { OPENAI_PROVIDER_ENV, configuredLadderProviders, inferPortableModelSelec
|
|
|
43
43
|
function ladderFor(provider, ladders = modelLadders()) {
|
|
44
44
|
return ladders[provider];
|
|
45
45
|
}
|
|
46
|
-
// Automatic turn-time fallback ordering + strength ranking stay local to the
|
|
47
|
-
// launch recipe (not needed by the pure selection primitives).
|
|
48
|
-
const FALLBACK_PROVIDER_KEYS = ['anthropic', 'openai'];
|
|
49
46
|
const MODEL_STRENGTH_RANK = { light: 0, medium: 1, strong: 2, ultra: 3 };
|
|
50
|
-
/** Map a concrete ladder model back to its provider×strength cell, then return
|
|
51
|
-
* the other configured provider's model at the same strength. `failedProviders`
|
|
52
|
-
* is per-turn state: once a provider has produced a retryable outage error, do
|
|
53
|
-
* not bounce back to it or keep reattempting the same failed provider. */
|
|
54
|
-
export function equivalentOtherProviderModel(currentModel, failedProviders = new Set()) {
|
|
55
|
-
const ladders = modelLadders();
|
|
56
|
-
const cell = findLadderCell(normalizeModel(currentModel), ladders, FALLBACK_PROVIDER_KEYS);
|
|
57
|
-
if (cell === null)
|
|
58
|
-
return null;
|
|
59
|
-
if (failedProviders.has(cell.provider))
|
|
60
|
-
return null;
|
|
61
|
-
const toProvider = cell.provider === 'anthropic' ? 'openai' : 'anthropic';
|
|
62
|
-
if (failedProviders.has(toProvider))
|
|
63
|
-
return null;
|
|
64
|
-
return { fromProvider: cell.provider, toProvider, strength: cell.strength, model: ladders[toProvider][cell.strength] };
|
|
65
|
-
}
|
|
66
47
|
/** Back-map a concrete model spec to the provider×strength ladder cell it came
|
|
67
48
|
* from. Two cells may legitimately name the SAME model id at different thinking
|
|
68
49
|
* levels (the openai ladder's `ultra` and `strong` are both gpt-5.6-sol, at
|
|
@@ -147,7 +128,8 @@ function floorReviewModel(kind, requestedModel, kindModel, ladders = modelLadder
|
|
|
147
128
|
* (`ultra`) or bare family alias (`opus`/`sonnet`/`haiku`) has no `/` and
|
|
148
129
|
* stays unpinned. */
|
|
149
130
|
export function isProviderPinnedModelToken(model) {
|
|
150
|
-
|
|
131
|
+
// Logical family/strength refs express a preference, not provider exclusivity.
|
|
132
|
+
return model.includes('/') && !isPortableModelToken(model);
|
|
151
133
|
}
|
|
152
134
|
/** True when `model` is a PORTABLE ladder token — a bare strength/alias
|
|
153
135
|
* (`ultra`, `normal`, `opus`, …) or a `anthropic|openai / strength` pair —
|
|
@@ -165,6 +147,16 @@ export function isPortableModelToken(model) {
|
|
|
165
147
|
}
|
|
166
148
|
return STRENGTH_ALIASES[model] !== undefined || model === 'opus' || model === 'sonnet' || model === 'haiku';
|
|
167
149
|
}
|
|
150
|
+
export function logicalModelIntent(model, ladders = modelLadders()) {
|
|
151
|
+
const strength = modelTokenStrength(model);
|
|
152
|
+
if (strength === null)
|
|
153
|
+
return undefined;
|
|
154
|
+
if (model.includes('/'))
|
|
155
|
+
return { family: model.slice(0, model.indexOf('/')), strength };
|
|
156
|
+
if (model === 'opus' || model === 'sonnet' || model === 'haiku')
|
|
157
|
+
return { family: 'anthropic', strength };
|
|
158
|
+
return { family: defaultProvider(ladders), strength };
|
|
159
|
+
}
|
|
168
160
|
export function normalizeModel(model, ladders = modelLadders()) {
|
|
169
161
|
// 1. Qualified provider/rest.
|
|
170
162
|
if (model.includes('/')) {
|
|
@@ -265,10 +257,12 @@ export function buildLaunchSpec(kind, mode, opts) {
|
|
|
265
257
|
const ladders = merged.modelLadders;
|
|
266
258
|
const kindModel = mode === 'orchestrator' ? kindConfig?.orchestratorModel ?? kindConfig?.model : kindConfig?.model;
|
|
267
259
|
const chosenModel = opts.modelExact === true ? opts.model : floorReviewModel(kind, opts.model, kindModel, ladders);
|
|
260
|
+
const intent = opts.modelExact === true ? undefined : (opts.modelIntent ?? (chosenModel !== undefined ? logicalModelIntent(chosenModel, ladders) : undefined));
|
|
268
261
|
const launch = {
|
|
269
262
|
model: opts.modelExact === true
|
|
270
263
|
? chosenModel
|
|
271
264
|
: (chosenModel !== undefined ? normalizeModel(chosenModel, ladders) : undefined),
|
|
265
|
+
modelIntent: intent,
|
|
272
266
|
modelProviderPinned: opts.modelProviderPinned !== undefined
|
|
273
267
|
? opts.modelProviderPinned
|
|
274
268
|
: opts.model !== undefined && isProviderPinnedModelToken(opts.model),
|
|
@@ -276,7 +270,7 @@ export function buildLaunchSpec(kind, mode, opts) {
|
|
|
276
270
|
tools: kindConfig?.tools ?? [],
|
|
277
271
|
extensions: mergeCanvasExtensions([...CANVAS_EXTENSIONS, ...(kindConfig?.extensions ?? [])]),
|
|
278
272
|
systemPrompt: LAUNCH_SYSTEM_PROMPT,
|
|
279
|
-
env: { ...(opts.extraEnv ?? {}) },
|
|
273
|
+
env: { ...(opts.extraEnv ?? {}), ...(intent ? { CRTR_MODEL_INTENT: `${intent.family}/${intent.strength}` } : {}) },
|
|
280
274
|
};
|
|
281
275
|
return { launch, lifecycle: opts.lifecycle };
|
|
282
276
|
}
|
|
@@ -4,20 +4,6 @@ import type { AssistantMessageDiagnostic } from "@earendil-works/pi-ai";
|
|
|
4
4
|
export declare const MANAGED_PROVIDER_COOLING_DIAGNOSTIC = "crtr:managed-provider-cooling";
|
|
5
5
|
/** Stable error code so a caller can recognize the typed error without message parsing. */
|
|
6
6
|
export declare const MANAGED_PROVIDER_COOLING_CODE = "managed_provider_cooling";
|
|
7
|
-
/**
|
|
8
|
-
* Broker-owned, live policy exposed to the jiti-loaded provider-rotation
|
|
9
|
-
* extension through its UI context. A callback (rather than a boot-time value)
|
|
10
|
-
* is required because an attached viewer can pin or unpin the running broker
|
|
11
|
-
* with `set_model`; the very next provider attempt must observe that decision.
|
|
12
|
-
*/
|
|
13
|
-
export declare const MANAGED_PROVIDER_ROTATION_POLICY_UI: unique symbol;
|
|
14
|
-
export type ManagedProviderRotationPolicy = {
|
|
15
|
-
allowCrossProviderFallback: boolean;
|
|
16
|
-
reason?: "provider-pinned" | "model-exact";
|
|
17
|
-
};
|
|
18
|
-
export type ManagedProviderRotationPolicyControl = () => ManagedProviderRotationPolicy;
|
|
19
|
-
/** Non-broker/test hosts retain the historical permissive fallback policy. */
|
|
20
|
-
export declare function readManagedProviderRotationPolicy(ui: unknown): ManagedProviderRotationPolicy;
|
|
21
7
|
/** Every managed provider pool tried this turn is cooling; `rateLimitedUntil` is
|
|
22
8
|
* the earliest future deadline (epoch ms) across those pools. */
|
|
23
9
|
export declare class ManagedProviderCoolingError extends Error {
|
|
@@ -16,25 +16,6 @@
|
|
|
16
16
|
export const MANAGED_PROVIDER_COOLING_DIAGNOSTIC = "crtr:managed-provider-cooling";
|
|
17
17
|
/** Stable error code so a caller can recognize the typed error without message parsing. */
|
|
18
18
|
export const MANAGED_PROVIDER_COOLING_CODE = "managed_provider_cooling";
|
|
19
|
-
/**
|
|
20
|
-
* Broker-owned, live policy exposed to the jiti-loaded provider-rotation
|
|
21
|
-
* extension through its UI context. A callback (rather than a boot-time value)
|
|
22
|
-
* is required because an attached viewer can pin or unpin the running broker
|
|
23
|
-
* with `set_model`; the very next provider attempt must observe that decision.
|
|
24
|
-
*/
|
|
25
|
-
export const MANAGED_PROVIDER_ROTATION_POLICY_UI = Symbol.for("@crouton-kit/crtr:managed-provider-rotation-policy");
|
|
26
|
-
/** Non-broker/test hosts retain the historical permissive fallback policy. */
|
|
27
|
-
export function readManagedProviderRotationPolicy(ui) {
|
|
28
|
-
const control = ui?.[MANAGED_PROVIDER_ROTATION_POLICY_UI];
|
|
29
|
-
if (typeof control !== "function")
|
|
30
|
-
return { allowCrossProviderFallback: true };
|
|
31
|
-
try {
|
|
32
|
-
return control();
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
return { allowCrossProviderFallback: true };
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
19
|
/** Every managed provider pool tried this turn is cooling; `rateLimitedUntil` is
|
|
39
20
|
* the earliest future deadline (epoch ms) across those pools. */
|
|
40
21
|
export class ManagedProviderCoolingError extends Error {
|
|
@@ -65,6 +65,12 @@ export function persistDormantModel(nodeId, spec, pinned) {
|
|
|
65
65
|
const meta = getNode(nodeId);
|
|
66
66
|
if (meta === null)
|
|
67
67
|
return;
|
|
68
|
-
const launch = meta.launch
|
|
68
|
+
const launch = meta.launch === undefined
|
|
69
|
+
? undefined
|
|
70
|
+
: (() => {
|
|
71
|
+
const { modelIntent: _modelIntent, env, ...rest } = meta.launch;
|
|
72
|
+
const { CRTR_MODEL_INTENT: _intentEnv, ...remainingEnv } = env;
|
|
73
|
+
return { ...rest, model: spec, modelProviderPinned: pinned, env: remainingEnv };
|
|
74
|
+
})();
|
|
69
75
|
updateNode(nodeId, { model_override: spec, ...(launch !== undefined ? { launch } : {}) });
|
|
70
76
|
}
|
|
@@ -35,10 +35,12 @@ const PROMPT_CAP = 2000;
|
|
|
35
35
|
* provider's own cold start; naming is async and off the spawn path, so a slow
|
|
36
36
|
* call delays only the label, never the node. */
|
|
37
37
|
const NAME_TIMEOUT_MS = 45_000;
|
|
38
|
-
/** Nerd Font glyphs offered to the namer as
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
38
|
+
/** Nerd Font glyphs offered to the namer as a FALLBACK set for when a task has
|
|
39
|
+
* no concrete subject — never a closed set, and deliberately introduced to the
|
|
40
|
+
* model as the generic tier so it reaches for a real noun first. Measured
|
|
41
|
+
* against 20 real prompts, an unqualified example list anchors the model onto
|
|
42
|
+
* it almost totally; the two-step rule in the system prompt is what pulls picks
|
|
43
|
+
* back out to the actual subject. */
|
|
42
44
|
const ICON_EXAMPLES = [
|
|
43
45
|
'fixing/diagnosis: U+F188 bug, U+F0F1 stethoscope, U+F06D fire, U+F071 warning',
|
|
44
46
|
'building/changing code: U+F0AD wrench, U+F067 plus, U+F121 code, U+F085 cogs, U+F021 refresh',
|
|
@@ -58,16 +60,21 @@ const NAME_SYSTEM_PROMPT = 'You name coding-agent work sessions. The name is a l
|
|
|
58
60
|
'hyphens (e.g. `refactor-auth-token-flow`, `add-csv-export-endpoint`). No punctuation, ' +
|
|
59
61
|
'quotes, or prose.\n' +
|
|
60
62
|
'- `icon`: ONE Nerd Font glyph, written as its codepoint in `U+XXXX` form (e.g. ' +
|
|
61
|
-
'`U+F188`).
|
|
62
|
-
'
|
|
63
|
-
'
|
|
64
|
-
'
|
|
65
|
-
'
|
|
66
|
-
'
|
|
67
|
-
|
|
68
|
-
'
|
|
69
|
-
'
|
|
70
|
-
'
|
|
63
|
+
'`U+F188`). Emit a codepoint, not a name like `nf-fa-bug`, and not an emoji.\n\n' +
|
|
64
|
+
'Choosing the icon — work through it in this order:\n' +
|
|
65
|
+
'1. Name the concrete SUBJECT of the task: the thing being acted on. A font, a ' +
|
|
66
|
+
'trash can, an account, a browser tab, a clock, an invoice, a map, a robot, a specific ' +
|
|
67
|
+
"technology's logo. If a glyph depicts that thing, use it. This is almost always the " +
|
|
68
|
+
'better icon, because it is what distinguishes this session from every other one.\n' +
|
|
69
|
+
'2. Only when the task has no depictable subject, fall back to the ACTIVITY (fixing, ' +
|
|
70
|
+
'documenting, testing) using the generic set below.\n\n' +
|
|
71
|
+
'ANY Nerd Font codepoint is allowed and the set is enormous — the whole Font Awesome ' +
|
|
72
|
+
'range, Devicons and Seti technology logos, Octicons, Codicons, Weather, and the ' +
|
|
73
|
+
'thousands of Material Design nouns above U+F0001. Reach into it. The examples below ' +
|
|
74
|
+
'are the generic tier, NOT a menu: if your pick came from them and the task had a real ' +
|
|
75
|
+
'subject, you picked wrong. Never reuse a generic gear, wrench, or bug for a task that ' +
|
|
76
|
+
'is actually about something.\n\n' +
|
|
77
|
+
'Generic fallback set, by kind of work:\n' +
|
|
71
78
|
ICON_EXAMPLES;
|
|
72
79
|
/** Put the raw task text FIRST in a delimited block, then the instruction, so the
|
|
73
80
|
* model reads the content before being told what to do and never mistakes the
|
|
@@ -59,6 +59,7 @@ function reshapeNode(nodeId, opts = {}, beforePersist) {
|
|
|
59
59
|
// just re-supplies the current value for resolution, not a new user
|
|
60
60
|
// choice — so carry the node's existing pin verbatim instead.
|
|
61
61
|
modelProviderPinned: opts.model === undefined ? node.launch?.modelProviderPinned : undefined,
|
|
62
|
+
modelIntent: opts.model === undefined ? node.launch?.modelIntent : undefined,
|
|
62
63
|
// Resolve kind/ladder config against the NODE's own cwd + profile, not
|
|
63
64
|
// this process's ambient scope.
|
|
64
65
|
cwd: node.cwd,
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import type { ThinkingLevel } from "@earendil-works/pi-ai";
|
|
2
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
1
|
export declare const ANTHROPIC_PROVIDER_ID = "anthropic";
|
|
4
2
|
export declare const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
|
|
5
3
|
export declare const INVALID_REFRESH_TOKEN_BACKOFF_MS: number;
|
|
6
4
|
export type ManagedProviderId = typeof ANTHROPIC_PROVIDER_ID | typeof OPENAI_CODEX_PROVIDER_ID;
|
|
7
|
-
export type LadderProviderId = "anthropic" | "openai";
|
|
8
5
|
export type ModelStrength = "ultra" | "strong" | "medium" | "light";
|
|
6
|
+
export type RotationConfig = {
|
|
7
|
+
defaultFallbackStrength?: ModelStrength;
|
|
8
|
+
revertWhenAvailable?: boolean;
|
|
9
|
+
};
|
|
10
|
+
type RotationConfigUpdate = RotationConfig;
|
|
9
11
|
export type SubscriptionCredential = {
|
|
10
12
|
label: string;
|
|
11
13
|
refresh: string;
|
|
@@ -17,27 +19,6 @@ export type SubscriptionCredential = {
|
|
|
17
19
|
authFailure?: "invalid_grant";
|
|
18
20
|
accountId?: string;
|
|
19
21
|
};
|
|
20
|
-
export type ModelRef = {
|
|
21
|
-
providerId: ManagedProviderId;
|
|
22
|
-
modelId: string;
|
|
23
|
-
};
|
|
24
|
-
export type RotationConfig = {
|
|
25
|
-
defaultFallbackStrength?: ModelStrength;
|
|
26
|
-
revertWhenAvailable?: boolean;
|
|
27
|
-
preferredModel?: ModelRef;
|
|
28
|
-
};
|
|
29
|
-
type RotationConfigUpdate = {
|
|
30
|
-
defaultFallbackStrength?: ModelStrength;
|
|
31
|
-
revertWhenAvailable?: boolean;
|
|
32
|
-
preferredModel?: ModelRef | null;
|
|
33
|
-
};
|
|
34
|
-
export type FallbackTarget = {
|
|
35
|
-
providerId: ManagedProviderId;
|
|
36
|
-
modelId: string;
|
|
37
|
-
label: string;
|
|
38
|
-
strength: ModelStrength;
|
|
39
|
-
thinkingLevel?: ThinkingLevel;
|
|
40
|
-
};
|
|
41
22
|
/** A concrete provider/model considered for either broker launch or turn-time rotation. */
|
|
42
23
|
export type ProviderCandidate = {
|
|
43
24
|
providerId: string;
|
|
@@ -49,6 +30,12 @@ export type ProviderCandidate = {
|
|
|
49
30
|
coolingUntil?: number;
|
|
50
31
|
/** False when this candidate may only be selected by an explicit request, never as fallback. */
|
|
51
32
|
automaticFallback?: boolean;
|
|
33
|
+
/** Route provenance carried through selection diagnostics. */
|
|
34
|
+
routeId?: string;
|
|
35
|
+
credentialSource?: string;
|
|
36
|
+
family?: string;
|
|
37
|
+
strength?: ModelStrength;
|
|
38
|
+
rank?: number;
|
|
52
39
|
};
|
|
53
40
|
export type ProviderCandidateRejection = "unregistered" | "non-managed-fallback" | "unauthenticated" | "cooling";
|
|
54
41
|
/**
|
|
@@ -72,12 +59,6 @@ export declare function resolveProviderCandidates(candidates: readonly ProviderC
|
|
|
72
59
|
}>;
|
|
73
60
|
};
|
|
74
61
|
export declare function isManagedProvider(providerId: string | undefined): providerId is ManagedProviderId;
|
|
75
|
-
/** The `modelLadders` CONFIG key for a runtime provider id -- `openai-codex`
|
|
76
|
-
* (the runtime/auth id) maps to the `openai` ladder key used in
|
|
77
|
-
* ~/.crouter/config.json. Callers building a repair command that a user can
|
|
78
|
-
* paste into `crtr sys config set modelLadders.<key>...` must use THIS key,
|
|
79
|
-
* not the runtime provider id. */
|
|
80
|
-
export declare function getLadderProviderId(providerId: ManagedProviderId): LadderProviderId;
|
|
81
62
|
export declare function getProviderLabel(providerId: ManagedProviderId): string;
|
|
82
63
|
export declare function mutateSubscriptionPool(providerId: ManagedProviderId, fn: (pool: SubscriptionCredential[], assertLockHeld: () => void) => SubscriptionCredential[] | undefined | Promise<SubscriptionCredential[] | undefined>): Promise<SubscriptionCredential[]>;
|
|
83
64
|
export declare function refreshSubscriptionCredential(providerId: ManagedProviderId, label: string, exchange: (refreshToken: string) => Promise<{
|
|
@@ -118,6 +99,7 @@ export declare function commitManagedLogin(providerId: ManagedProviderId, label:
|
|
|
118
99
|
lastRateLimitedAt: number;
|
|
119
100
|
autoLabel?: boolean;
|
|
120
101
|
}): Promise<SubscriptionCredential[]>;
|
|
102
|
+
export declare function removeAllManagedAccounts(providerId: ManagedProviderId): Promise<number>;
|
|
121
103
|
export declare function removeManagedProvider(providerId: ManagedProviderId, label?: string): Promise<SubscriptionCredential[]>;
|
|
122
104
|
export declare function removeSubscription(providerId: ManagedProviderId, ref: string): Promise<SubscriptionCredential[]>;
|
|
123
105
|
export declare function promoteSubscription(providerId: ManagedProviderId, ref: string): Promise<SubscriptionCredential[]>;
|
|
@@ -128,11 +110,5 @@ export declare function markSubscriptionSuccess(providerId: ManagedProviderId, r
|
|
|
128
110
|
export declare function parseRetryAfterHeader(value: string | undefined, now?: number): number | undefined;
|
|
129
111
|
export declare function readRotationConfig(): RotationConfig;
|
|
130
112
|
export declare function writeRotationConfig(next: RotationConfigUpdate): RotationConfig;
|
|
131
|
-
export declare function clearPreferredModel(): RotationConfig;
|
|
132
|
-
export declare function rememberPreferredModel(model: ModelRef): RotationConfig;
|
|
133
|
-
export declare function resolveFallbackTarget(currentProviderId: ManagedProviderId, currentModelId?: string, config?: RotationConfig, currentThinkingLevel?: string): FallbackTarget | undefined;
|
|
134
|
-
export declare function shouldRestorePreferredModel(config?: RotationConfig, now?: number): boolean;
|
|
135
|
-
export declare function restorePreferredModelIfPossible(piApi: ExtensionAPI, ctx: Pick<ExtensionContext, "model" | "modelRegistry">): Promise<boolean>;
|
|
136
|
-
export declare function switchToFallbackIfPossible(piApi: ExtensionAPI, ctx: Pick<ExtensionContext, "model" | "modelRegistry">, preferredModel?: ModelRef | undefined): Promise<FallbackTarget | undefined>;
|
|
137
113
|
export declare function formatStatusLine(providerId: ManagedProviderId, now?: number): string;
|
|
138
114
|
export {};
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { defaultModelLaddersConfig } from "../types.js";
|
|
4
3
|
import { homedir } from "node:os";
|
|
5
4
|
import { dirname, join } from "node:path";
|
|
6
5
|
import { LockedJsonFile } from "./locked-file.js";
|
|
@@ -61,19 +60,14 @@ function agentDir() {
|
|
|
61
60
|
function rotationConfigFile() {
|
|
62
61
|
return join(agentDir(), "provider-rotation.json");
|
|
63
62
|
}
|
|
64
|
-
function crouterConfigFile() {
|
|
65
|
-
return join(homedir(), ".crouter", "config.json");
|
|
66
|
-
}
|
|
67
63
|
const DEFAULT_FALLBACK_STRENGTH = "strong";
|
|
68
64
|
const PROVIDERS = {
|
|
69
65
|
[ANTHROPIC_PROVIDER_ID]: {
|
|
70
66
|
label: "Claude",
|
|
71
|
-
ladderProviderId: "anthropic",
|
|
72
67
|
poolFileName: "anthropic-subscriptions.json",
|
|
73
68
|
},
|
|
74
69
|
[OPENAI_CODEX_PROVIDER_ID]: {
|
|
75
70
|
label: "OpenAI Codex",
|
|
76
|
-
ladderProviderId: "openai",
|
|
77
71
|
poolFileName: "openai-codex-subscriptions.json",
|
|
78
72
|
},
|
|
79
73
|
};
|
|
@@ -184,9 +178,6 @@ function traceDroppedRows(providerId, stored, normalized) {
|
|
|
184
178
|
return;
|
|
185
179
|
process.stderr.write(`[subscription-pool] ${getProviderLabel(providerId)}: dropping ${stored.length - normalized.length} stored row(s) with no label or no token pair\n`);
|
|
186
180
|
}
|
|
187
|
-
function getCurrentModelRef(model) {
|
|
188
|
-
return isManagedProvider(model?.provider) ? { providerId: model.provider, modelId: model.id } : undefined;
|
|
189
|
-
}
|
|
190
181
|
// A refreshed/modified token set is only storable when it carries BOTH halves and a usable
|
|
191
182
|
// expiry. Persisting a partial one is silently destructive: `JSON.stringify` drops the missing
|
|
192
183
|
// key, and `normalizeCredential` (which requires refresh AND access together) then discards the
|
|
@@ -247,14 +238,6 @@ function logCredentialSetChange(providerId, base, next) {
|
|
|
247
238
|
export function isManagedProvider(providerId) {
|
|
248
239
|
return providerId === ANTHROPIC_PROVIDER_ID || providerId === OPENAI_CODEX_PROVIDER_ID;
|
|
249
240
|
}
|
|
250
|
-
/** The `modelLadders` CONFIG key for a runtime provider id -- `openai-codex`
|
|
251
|
-
* (the runtime/auth id) maps to the `openai` ladder key used in
|
|
252
|
-
* ~/.crouter/config.json. Callers building a repair command that a user can
|
|
253
|
-
* paste into `crtr sys config set modelLadders.<key>...` must use THIS key,
|
|
254
|
-
* not the runtime provider id. */
|
|
255
|
-
export function getLadderProviderId(providerId) {
|
|
256
|
-
return PROVIDERS[providerId].ladderProviderId;
|
|
257
|
-
}
|
|
258
241
|
export function getProviderLabel(providerId) {
|
|
259
242
|
return PROVIDERS[providerId].label;
|
|
260
243
|
}
|
|
@@ -603,14 +586,26 @@ export async function commitManagedLogin(providerId, label, credential) {
|
|
|
603
586
|
return merged;
|
|
604
587
|
});
|
|
605
588
|
}
|
|
589
|
+
// Remove every account for one managed provider through a single locked transform. The
|
|
590
|
+
// returned count belongs to that locked snapshot, so callers do not race a concurrent login.
|
|
591
|
+
export async function removeAllManagedAccounts(providerId) {
|
|
592
|
+
let removed = 0;
|
|
593
|
+
await mutateSubscriptionPool(providerId, (pool) => {
|
|
594
|
+
removed = pool.length;
|
|
595
|
+
return [];
|
|
596
|
+
});
|
|
597
|
+
return removed;
|
|
598
|
+
}
|
|
606
599
|
// Coordinated removal for the human UI. Removes a single account when `label` is given; the ENTIRE
|
|
607
|
-
// provider (every pool account) when `label` is omitted — always through
|
|
600
|
+
// provider (every pool account) when `label` is omitted — always through a pool transform. UI
|
|
608
601
|
// must NEVER reach for a raw auth.json delete on a managed provider: that strips a file pi no
|
|
609
602
|
// longer reads for managed providers and orphans the whole pool.
|
|
610
603
|
export async function removeManagedProvider(providerId, label) {
|
|
604
|
+
if (label === undefined) {
|
|
605
|
+
await removeAllManagedAccounts(providerId);
|
|
606
|
+
return [];
|
|
607
|
+
}
|
|
611
608
|
return mutateSubscriptionPool(providerId, (pool) => {
|
|
612
|
-
if (label === undefined)
|
|
613
|
-
return [];
|
|
614
609
|
const index = resolveCredentialIndex(pool, label);
|
|
615
610
|
if (index === -1)
|
|
616
611
|
throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${label}`);
|
|
@@ -701,9 +696,6 @@ export async function markSubscriptionSuccess(providerId, ref, attemptAt = Date.
|
|
|
701
696
|
return next;
|
|
702
697
|
});
|
|
703
698
|
}
|
|
704
|
-
function findAvailableSubscription(providerId, now = Date.now()) {
|
|
705
|
-
return readSubscriptionPool(providerId).find((entry) => !entry.rateLimitedUntil || entry.rateLimitedUntil <= now);
|
|
706
|
-
}
|
|
707
699
|
export function parseRetryAfterHeader(value, now = Date.now()) {
|
|
708
700
|
if (!value)
|
|
709
701
|
return undefined;
|
|
@@ -716,102 +708,11 @@ export function parseRetryAfterHeader(value, now = Date.now()) {
|
|
|
716
708
|
const parsed = Date.parse(trimmed);
|
|
717
709
|
return Number.isFinite(parsed) ? Math.max(0, parsed - now) : undefined;
|
|
718
710
|
}
|
|
719
|
-
function thinkingLevelOfRef(ref) {
|
|
720
|
-
const match = ref.trim().match(/:(thinking|off|minimal|low|medium|high|xhigh|max)$/i);
|
|
721
|
-
if (!match)
|
|
722
|
-
return undefined;
|
|
723
|
-
const level = match[1].toLowerCase();
|
|
724
|
-
return level === "off" || level === "thinking" ? undefined : level;
|
|
725
|
-
}
|
|
726
|
-
function normalizeModelRef(ref) {
|
|
727
|
-
return ref
|
|
728
|
-
.trim()
|
|
729
|
-
.replace(/^[^/]+\//, "")
|
|
730
|
-
.replace(/:(?:thinking|off|minimal|low|medium|high|xhigh|max)$/i, "")
|
|
731
|
-
.toLowerCase();
|
|
732
|
-
}
|
|
733
|
-
function splitModelRef(ref) {
|
|
734
|
-
const trimmed = ref.trim();
|
|
735
|
-
const slash = trimmed.indexOf("/");
|
|
736
|
-
const providerId = slash === -1 ? OPENAI_CODEX_PROVIDER_ID : trimmed.slice(0, slash).trim();
|
|
737
|
-
if (!isManagedProvider(providerId)) {
|
|
738
|
-
throw new Error(`Unsupported fallback provider: ${providerId}`);
|
|
739
|
-
}
|
|
740
|
-
const rawModelId = slash === -1 ? trimmed : trimmed.slice(slash + 1).trim();
|
|
741
|
-
const thinkingMatch = rawModelId.match(/^(.*?)(?::(thinking|off|minimal|low|medium|high|xhigh|max))$/i);
|
|
742
|
-
return {
|
|
743
|
-
providerId,
|
|
744
|
-
modelId: thinkingMatch ? thinkingMatch[1] : rawModelId,
|
|
745
|
-
label: `${providerId}/${thinkingMatch ? thinkingMatch[1] : rawModelId}${thinkingMatch?.[2] ? `:${thinkingMatch[2].toLowerCase()}` : ""}`,
|
|
746
|
-
strength: DEFAULT_FALLBACK_STRENGTH,
|
|
747
|
-
thinkingLevel: thinkingMatch?.[2] && thinkingMatch[2].toLowerCase() !== "off" && thinkingMatch[2].toLowerCase() !== "thinking"
|
|
748
|
-
? thinkingMatch[2].toLowerCase()
|
|
749
|
-
: undefined,
|
|
750
|
-
};
|
|
751
|
-
}
|
|
752
|
-
// Layers ~/.crouter/config.json's `modelLadders` (if any) OVER crouter's
|
|
753
|
-
// builtin ladder (`defaultModelLaddersConfig()`, src/types.ts — imported
|
|
754
|
-
// directly: this package's canonical home is the crouter tree, so the
|
|
755
|
-
// relative import always resolves, to dist/types.js at runtime), per
|
|
756
|
-
// provider/strength — mirroring crouter's own
|
|
757
|
-
// `mergeModelLadders` (src/core/config.ts), which is what a node's launcher
|
|
758
|
-
// actually resolves models from. Without this fallback, a config-less user
|
|
759
|
-
// (the common case: fresh install, no ~/.crouter/config.json) saw an EMPTY
|
|
760
|
-
// ladder here while crtr itself launched them on the builtin defaults —
|
|
761
|
-
// rotation could then never resolve a strength tier or a cross-provider
|
|
762
|
-
// fallback for a model it was already running. Always returns a fully
|
|
763
|
-
// populated ladder for both providers; a config.json override only replaces
|
|
764
|
-
// the specific strengths it sets.
|
|
765
|
-
function readModelLadders() {
|
|
766
|
-
const config = readJsonFile(crouterConfigFile());
|
|
767
|
-
const overrides = config?.modelLadders;
|
|
768
|
-
const builtin = defaultModelLaddersConfig();
|
|
769
|
-
const result = {};
|
|
770
|
-
for (const provider of ["anthropic", "openai"]) {
|
|
771
|
-
const normalized = { ...builtin[provider] };
|
|
772
|
-
const override = overrides?.[provider];
|
|
773
|
-
if (override) {
|
|
774
|
-
for (const strength of ["ultra", "strong", "medium", "light"]) {
|
|
775
|
-
const value = typeof override[strength] === "string" ? override[strength]?.trim() : "";
|
|
776
|
-
if (value)
|
|
777
|
-
normalized[strength] = value;
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
result[provider] = normalized;
|
|
781
|
-
}
|
|
782
|
-
return result;
|
|
783
|
-
}
|
|
784
|
-
function findStrengthForModelId(providerId, modelId, defaultStrength, ladders = readModelLadders(), currentThinkingLevel) {
|
|
785
|
-
const source = ladders?.[PROVIDERS[providerId].ladderProviderId];
|
|
786
|
-
const bareId = modelId ? normalizeModelRef(modelId) : undefined;
|
|
787
|
-
if (!source || !bareId)
|
|
788
|
-
return undefined;
|
|
789
|
-
const matches = [];
|
|
790
|
-
for (const strength of ["ultra", "strong", "medium", "light"]) {
|
|
791
|
-
const ref = source[strength];
|
|
792
|
-
if (ref && normalizeModelRef(ref) === bareId)
|
|
793
|
-
matches.push(strength);
|
|
794
|
-
}
|
|
795
|
-
if (matches.length === 0)
|
|
796
|
-
return undefined;
|
|
797
|
-
// Rungs that share a bare model id (e.g. gpt-5.6-sol:max vs gpt-5.6-sol:high) are
|
|
798
|
-
// disambiguated by the live reasoning level so an ultra run doesn't collapse to strong.
|
|
799
|
-
if (matches.length > 1 && currentThinkingLevel) {
|
|
800
|
-
const exact = matches.find((strength) => thinkingLevelOfRef(source[strength]) === currentThinkingLevel);
|
|
801
|
-
if (exact)
|
|
802
|
-
return exact;
|
|
803
|
-
}
|
|
804
|
-
return matches.includes(defaultStrength) ? defaultStrength : matches[0];
|
|
805
|
-
}
|
|
806
711
|
export function readRotationConfig() {
|
|
807
712
|
const raw = readJsonFile(rotationConfigFile()) ?? {};
|
|
808
|
-
const preferredModel = raw.preferredModel && isManagedProvider(raw.preferredModel.providerId) && typeof raw.preferredModel.modelId === "string"
|
|
809
|
-
? { providerId: raw.preferredModel.providerId, modelId: raw.preferredModel.modelId.trim() }
|
|
810
|
-
: undefined;
|
|
811
713
|
return {
|
|
812
714
|
defaultFallbackStrength: normalizeStrength(raw.defaultFallbackStrength) ?? DEFAULT_FALLBACK_STRENGTH,
|
|
813
715
|
revertWhenAvailable: raw.revertWhenAvailable ?? true,
|
|
814
|
-
preferredModel,
|
|
815
716
|
};
|
|
816
717
|
}
|
|
817
718
|
export function writeRotationConfig(next) {
|
|
@@ -819,100 +720,15 @@ export function writeRotationConfig(next) {
|
|
|
819
720
|
const merged = {
|
|
820
721
|
defaultFallbackStrength: normalizeStrength(next.defaultFallbackStrength) ?? current.defaultFallbackStrength ?? DEFAULT_FALLBACK_STRENGTH,
|
|
821
722
|
revertWhenAvailable: next.revertWhenAvailable ?? current.revertWhenAvailable ?? true,
|
|
822
|
-
preferredModel: Object.prototype.hasOwnProperty.call(next, "preferredModel")
|
|
823
|
-
? next.preferredModel || undefined
|
|
824
|
-
: current.preferredModel,
|
|
825
723
|
};
|
|
826
724
|
writeJsonFile(rotationConfigFile(), merged);
|
|
827
725
|
return merged;
|
|
828
726
|
}
|
|
829
|
-
export function clearPreferredModel() {
|
|
830
|
-
return writeRotationConfig({ preferredModel: null });
|
|
831
|
-
}
|
|
832
|
-
export function rememberPreferredModel(model) {
|
|
833
|
-
return writeRotationConfig({ preferredModel: model });
|
|
834
|
-
}
|
|
835
|
-
// `readModelLadders()` always fills both providers from the builtin defaults, so this
|
|
836
|
-
// only returns undefined (never throws) in the defensive case where a rung is still
|
|
837
|
-
// genuinely missing; callers must treat that as a graceful "no fallback available" case
|
|
838
|
-
// rather than a config error to surface mid-turn.
|
|
839
|
-
export function resolveFallbackTarget(currentProviderId, currentModelId, config = readRotationConfig(), currentThinkingLevel) {
|
|
840
|
-
const ladders = readModelLadders();
|
|
841
|
-
const defaultStrength = config.defaultFallbackStrength ?? DEFAULT_FALLBACK_STRENGTH;
|
|
842
|
-
const sourceStrength = findStrengthForModelId(currentProviderId, currentModelId, defaultStrength, ladders, currentThinkingLevel);
|
|
843
|
-
const strength = sourceStrength ?? defaultStrength;
|
|
844
|
-
const targetProvider = currentProviderId === ANTHROPIC_PROVIDER_ID ? OPENAI_CODEX_PROVIDER_ID : ANTHROPIC_PROVIDER_ID;
|
|
845
|
-
const targetLadder = ladders?.[PROVIDERS[targetProvider].ladderProviderId];
|
|
846
|
-
const candidates = strength === defaultStrength ? [strength] : [strength, defaultStrength];
|
|
847
|
-
const fallbackRef = candidates.map((candidate) => targetLadder?.[candidate]).find((ref) => typeof ref === "string" && ref.trim().length > 0);
|
|
848
|
-
if (!fallbackRef) {
|
|
849
|
-
return undefined;
|
|
850
|
-
}
|
|
851
|
-
const parsed = splitModelRef(fallbackRef);
|
|
852
|
-
const resolvedStrength = candidates.find((candidate) => targetLadder?.[candidate] === fallbackRef) ?? strength;
|
|
853
|
-
return { ...parsed, strength: resolvedStrength };
|
|
854
|
-
}
|
|
855
|
-
export function shouldRestorePreferredModel(config = readRotationConfig(), now = Date.now()) {
|
|
856
|
-
if (!config.revertWhenAvailable || !config.preferredModel)
|
|
857
|
-
return false;
|
|
858
|
-
return Boolean(findAvailableSubscription(config.preferredModel.providerId, now));
|
|
859
|
-
}
|
|
860
|
-
export async function restorePreferredModelIfPossible(piApi, ctx) {
|
|
861
|
-
const config = readRotationConfig();
|
|
862
|
-
if (!shouldRestorePreferredModel(config, Date.now()))
|
|
863
|
-
return false;
|
|
864
|
-
const preferred = config.preferredModel;
|
|
865
|
-
if (!preferred)
|
|
866
|
-
return false;
|
|
867
|
-
const current = getCurrentModelRef(ctx.model);
|
|
868
|
-
if (current?.providerId === preferred.providerId && current.modelId === preferred.modelId) {
|
|
869
|
-
clearPreferredModel();
|
|
870
|
-
return true;
|
|
871
|
-
}
|
|
872
|
-
const model = ctx.modelRegistry.find(preferred.providerId, preferred.modelId);
|
|
873
|
-
if (!model)
|
|
874
|
-
return false;
|
|
875
|
-
const switched = await piApi.setModel(model);
|
|
876
|
-
if (switched)
|
|
877
|
-
clearPreferredModel();
|
|
878
|
-
return switched;
|
|
879
|
-
}
|
|
880
|
-
export async function switchToFallbackIfPossible(piApi, ctx, preferredModel = getCurrentModelRef(ctx.model)) {
|
|
881
|
-
if (!preferredModel)
|
|
882
|
-
return undefined;
|
|
883
|
-
rememberPreferredModel(preferredModel);
|
|
884
|
-
const currentThinkingLevel = piApi.getThinkingLevel?.();
|
|
885
|
-
const target = resolveFallbackTarget(preferredModel.providerId, preferredModel.modelId, readRotationConfig(), currentThinkingLevel);
|
|
886
|
-
if (!target)
|
|
887
|
-
return undefined;
|
|
888
|
-
const current = getCurrentModelRef(ctx.model);
|
|
889
|
-
const model = ctx.modelRegistry.find(target.providerId, target.modelId);
|
|
890
|
-
if (!model)
|
|
891
|
-
return undefined;
|
|
892
|
-
if (current?.providerId !== target.providerId || current.modelId !== target.modelId) {
|
|
893
|
-
const switched = await piApi.setModel(model);
|
|
894
|
-
if (!switched) {
|
|
895
|
-
// `setModel` returns false when its target lacks configured auth and leaves
|
|
896
|
-
// the live model unchanged. A concurrent transition can still independently
|
|
897
|
-
// make THIS exact target current while this call is in flight, so re-read
|
|
898
|
-
// the live model and accept only when it now equals the target on both provider and
|
|
899
|
-
// model id; any other current model is a genuine failed switch.
|
|
900
|
-
const afterSwitch = getCurrentModelRef(ctx.model);
|
|
901
|
-
if (afterSwitch?.providerId !== target.providerId || afterSwitch.modelId !== target.modelId)
|
|
902
|
-
return undefined;
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
|
-
if (target.thinkingLevel)
|
|
906
|
-
piApi.setThinkingLevel(target.thinkingLevel);
|
|
907
|
-
return target;
|
|
908
|
-
}
|
|
909
727
|
export function formatStatusLine(providerId, now = Date.now()) {
|
|
910
728
|
const pool = readSubscriptionPool(providerId);
|
|
911
|
-
const ready = pool.filter((entry) => !entry.rateLimitedUntil || entry.rateLimitedUntil <= now);
|
|
729
|
+
const ready = pool.filter((entry) => entry.authFailure === undefined && (!entry.rateLimitedUntil || entry.rateLimitedUntil <= now));
|
|
912
730
|
const label = getProviderLabel(providerId);
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
}
|
|
917
|
-
return `${label} ${ready[0]?.label ?? providerId} (${ready.length}/${pool.length} ready)`;
|
|
731
|
+
return ready.length === 0
|
|
732
|
+
? `${label} pool exhausted`
|
|
733
|
+
: `${label} ${ready[0]?.label ?? providerId} (${ready.length}/${pool.length} ready)`;
|
|
918
734
|
}
|