@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.
Files changed (50) hide show
  1. package/dist/api/client.d.ts +3 -1
  2. package/dist/api/client.js +6 -0
  3. package/dist/api/dto/modelauth.d.ts +32 -0
  4. package/dist/api/dto/modelauth.js +2 -1
  5. package/dist/api/routes.d.ts +1 -0
  6. package/dist/api/routes.js +1 -0
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +55 -395
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +204 -419
  9. package/dist/clients/attach/__tests__/markdown-headings.test.js +8 -0
  10. package/dist/clients/attach/render/glyph-codepoints.d.ts +3 -0
  11. package/dist/clients/attach/render/glyph-codepoints.js +42 -0
  12. package/dist/clients/attach/render/markdown-headings.js +15 -4
  13. package/dist/clients/attach/viewer.js +558 -558
  14. package/dist/core/__tests__/broker-launch-candidates.test.js +13 -3
  15. package/dist/core/__tests__/broker-sdk-wiring.test.js +53 -25
  16. package/dist/core/__tests__/full/broker-provider-retry.test.js +6 -122
  17. package/dist/core/__tests__/model-pin-durability.test.js +7 -5
  18. package/dist/core/__tests__/model-routes.test.d.ts +1 -0
  19. package/dist/core/__tests__/model-routes.test.js +41 -0
  20. package/dist/core/__tests__/review-model-floor.test.js +2 -2
  21. package/dist/core/auth-file.d.ts +3 -2
  22. package/dist/core/auth-file.js +5 -3
  23. package/dist/core/canvas/labels.d.ts +5 -3
  24. package/dist/core/canvas/labels.js +7 -4
  25. package/dist/core/canvas/types.d.ts +6 -1
  26. package/dist/core/config.d.ts +3 -1
  27. package/dist/core/config.js +88 -2
  28. package/dist/core/model-routes.d.ts +37 -0
  29. package/dist/core/model-routes.js +140 -0
  30. package/dist/core/runtime/broker.d.ts +0 -4
  31. package/dist/core/runtime/broker.js +116 -372
  32. package/dist/core/runtime/launch.d.ts +9 -11
  33. package/dist/core/runtime/launch.js +15 -21
  34. package/dist/core/runtime/managed-provider-cooling.d.ts +0 -14
  35. package/dist/core/runtime/managed-provider-cooling.js +0 -19
  36. package/dist/core/runtime/model-swap.js +7 -1
  37. package/dist/core/runtime/naming.js +21 -14
  38. package/dist/core/runtime/promote.js +1 -0
  39. package/dist/core/subscription-state.d.ts +12 -36
  40. package/dist/core/subscription-state.js +19 -203
  41. package/dist/daemon/api/__tests__/full/api-server.test.js +9 -6
  42. package/dist/daemon/api/__tests__/full/b10-attach-modelauth.test.js +123 -1
  43. package/dist/daemon/api/handlers/modelauth.js +51 -2
  44. package/dist/daemon/api/handlers/nodes.js +2 -0
  45. package/dist/types.d.ts +17 -0
  46. package/dist/web-client/assets/{index-B76ZKfT_.js → index-NIuSCOHM.js} +1 -1
  47. package/dist/web-client/index.html +1 -1
  48. package/dist/web-client/sw.js +1 -1
  49. package/package.json +8 -8
  50. 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
- return model.includes('/');
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 !== undefined ? { ...meta.launch, model: spec, modelProviderPinned: pinned } : undefined;
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 EXAMPLES, never as a closed set —
39
- * the model may pick any Nerd Font character it thinks fits better. Grouped by
40
- * the kind of work they read as, so the model generalizes from the grouping
41
- * instead of pattern-matching a lookup table. */
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`). ANY Nerd Font codepoint is allowed — the whole Font Awesome, Material, ' +
62
- 'Devicons, Octicons and Codicons ranges are available, so reach for the glyph that ' +
63
- 'actually depicts this work rather than settling for one of the examples below. ' +
64
- 'Emit a codepoint, not a name like `nf-fa-bug`, and not an emoji.\n\n' +
65
- 'Pick the glyph whose depicted object a person would associate with this work at a ' +
66
- 'glance. Prefer the concrete subject (a database, a lock, a browser, a specific ' +
67
- "technology's logo) over a generic verb glyph when the task has one; fall back to the " +
68
- 'activity (fixing, building, testing) otherwise. Never reuse a generic gear for ' +
69
- 'everything.\n\n' +
70
- 'Examples of what reads well, by kind of work:\n' +
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 removeManagedAccount. UI
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
- if (ready.length === 0) {
914
- const fallback = resolveFallbackTarget(providerId, undefined, readRotationConfig());
915
- return fallback ? `${label} pool exhausted -> ${fallback.label}` : `${label} pool exhausted (no fallback configured)`;
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
  }