@crouton-kit/crouter 0.3.78 → 0.3.80

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 (80) hide show
  1. package/dist/build-root.d.ts +12 -4
  2. package/dist/build-root.js +25 -6
  3. package/dist/builtin-memory/crouter-development/plugins.md +82 -5
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1228 -12
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +3 -3
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -787
  7. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +8 -3
  8. package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
  9. package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
  10. package/dist/clients/attach/__tests__/mermaid-render.test.js +9 -1
  11. package/dist/clients/attach/attach-cmd.d.ts +9 -1
  12. package/dist/clients/attach/attach-cmd.js +847 -801
  13. package/dist/clients/attach/auth-pickers.d.ts +0 -12
  14. package/dist/clients/attach/auth-pickers.js +64 -15
  15. package/dist/clients/attach/chat-view.d.ts +4 -0
  16. package/dist/clients/attach/chat-view.js +10 -0
  17. package/dist/clients/attach/graph-overlay.d.ts +12 -2
  18. package/dist/clients/attach/graph-overlay.js +83 -33
  19. package/dist/clients/attach/input-controller.d.ts +19 -0
  20. package/dist/clients/attach/input-controller.js +55 -11
  21. package/dist/clients/attach/titled-editor.d.ts +21 -0
  22. package/dist/clients/attach/titled-editor.js +101 -0
  23. package/dist/commands/human/queue.js +3 -4
  24. package/dist/commands/pkg/plugin-inspect.js +22 -1
  25. package/dist/commands/pkg/plugin-manage.js +31 -9
  26. package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
  27. package/dist/commands/sys/doctor.js +42 -4
  28. package/dist/commands/sys/setup-core.d.ts +37 -0
  29. package/dist/commands/sys/setup-core.js +138 -1
  30. package/dist/commands/sys/setup.d.ts +88 -0
  31. package/dist/commands/sys/setup.js +915 -171
  32. package/dist/commands/view-pick.d.ts +4 -0
  33. package/dist/commands/view-pick.js +17 -7
  34. package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
  35. package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
  36. package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
  37. package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
  38. package/dist/core/__tests__/command-plugins.test.js +444 -0
  39. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  40. package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
  41. package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
  42. package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
  43. package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
  44. package/dist/core/canvas/browse/app.d.ts +6 -0
  45. package/dist/core/canvas/browse/app.js +71 -41
  46. package/dist/core/command-plugins/adapter.d.ts +15 -0
  47. package/dist/core/command-plugins/adapter.js +145 -0
  48. package/dist/core/command-plugins/compose.d.ts +5 -0
  49. package/dist/core/command-plugins/compose.js +56 -0
  50. package/dist/core/command-plugins/discovery.d.ts +104 -0
  51. package/dist/core/command-plugins/discovery.js +565 -0
  52. package/dist/core/config.d.ts +2 -5
  53. package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
  54. package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
  55. package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
  56. package/dist/core/keybindings/catalog.d.ts +3 -3
  57. package/dist/core/keybindings/catalog.js +2 -1
  58. package/dist/core/profiles/select.d.ts +6 -0
  59. package/dist/core/profiles/select.js +90 -59
  60. package/dist/core/provider-management.d.ts +12 -0
  61. package/dist/core/provider-management.js +24 -0
  62. package/dist/core/runtime/banner.d.ts +13 -0
  63. package/dist/core/runtime/banner.js +51 -0
  64. package/dist/core/runtime/broker.js +7 -6
  65. package/dist/core/runtime/pi-vendored.d.ts +8 -0
  66. package/dist/core/runtime/pi-vendored.js +14 -0
  67. package/dist/core/runtime/recap.d.ts +1 -1
  68. package/dist/core/runtime/recap.js +50 -25
  69. package/dist/core/runtime/session-list-cache.d.ts +10 -0
  70. package/dist/core/runtime/session-list-cache.js +94 -26
  71. package/dist/core/runtime/spawn.js +5 -17
  72. package/dist/core/runtime/tmux.js +2 -1
  73. package/dist/core/scope.js +27 -4
  74. package/dist/core/subscription-state.d.ts +90 -0
  75. package/dist/core/subscription-state.js +762 -0
  76. package/dist/daemon/crtrd.js +253 -12
  77. package/dist/pi-extensions/canvas-recap.js +43 -17
  78. package/dist/types.d.ts +6 -13
  79. package/dist/types.js +0 -3
  80. package/package.json +7 -3
@@ -0,0 +1,762 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { defaultModelLaddersConfig } from "../types.js";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { FileAuthStorageBackend } from "@earendil-works/pi-coding-agent";
6
+ export const ANTHROPIC_PROVIDER_ID = "anthropic";
7
+ export const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
8
+ const AGENT_DIR = join(homedir(), ".pi", "agent");
9
+ const ROTATION_CONFIG_FILE = join(AGENT_DIR, "provider-rotation.json");
10
+ const CROUTER_CONFIG_FILE = join(homedir(), ".crouter", "config.json");
11
+ const DEFAULT_FALLBACK_STRENGTH = "strong";
12
+ const PROVIDERS = {
13
+ [ANTHROPIC_PROVIDER_ID]: {
14
+ label: "Claude",
15
+ ladderProviderId: "anthropic",
16
+ poolFile: join(AGENT_DIR, "anthropic-subscriptions.json"),
17
+ },
18
+ [OPENAI_CODEX_PROVIDER_ID]: {
19
+ label: "OpenAI Codex",
20
+ ladderProviderId: "openai",
21
+ poolFile: join(AGENT_DIR, "openai-codex-subscriptions.json"),
22
+ },
23
+ };
24
+ function readJsonFile(path) {
25
+ if (!existsSync(path))
26
+ return undefined;
27
+ try {
28
+ const raw = readFileSync(path, "utf8").trim();
29
+ if (!raw)
30
+ return undefined;
31
+ return JSON.parse(raw);
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ }
37
+ function writeJsonFile(path, value, mode) {
38
+ mkdirSync(dirname(path), { recursive: true });
39
+ const tmp = `${path}.${process.pid}.tmp`;
40
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, mode === undefined ? "utf8" : { encoding: "utf8", mode });
41
+ if (mode !== undefined)
42
+ chmodSync(tmp, mode);
43
+ renameSync(tmp, path);
44
+ }
45
+ function normalizeStrength(value) {
46
+ return value === "ultra" || value === "strong" || value === "medium" || value === "light" ? value : undefined;
47
+ }
48
+ function normalizeTiming(value) {
49
+ const numeric = Number(value);
50
+ return Number.isFinite(numeric) ? numeric : 0;
51
+ }
52
+ function normalizeCredential(value) {
53
+ const label = typeof value.label === "string" ? value.label.trim() : "";
54
+ if (!label)
55
+ return undefined;
56
+ const refresh = typeof value.refresh === "string" ? value.refresh.trim() : "";
57
+ const access = typeof value.access === "string" ? value.access.trim() : "";
58
+ const accountId = typeof value.accountId === "string" ? value.accountId.trim() : "";
59
+ const expires = Number(value.expires);
60
+ const rateLimitedUntil = Number(value.rateLimitedUntil);
61
+ return {
62
+ label,
63
+ ...(refresh && access ? { refresh, access, expires: Number.isFinite(expires) ? expires : 0 } : {}),
64
+ ...(accountId ? { accountId } : {}),
65
+ rateLimitedUntil: Number.isFinite(rateLimitedUntil) ? rateLimitedUntil : 0,
66
+ lastAttemptAt: normalizeTiming(value.lastAttemptAt),
67
+ lastRateLimitedAt: normalizeTiming(value.lastRateLimitedAt),
68
+ };
69
+ }
70
+ function normalizeCredentials(values) {
71
+ if (!Array.isArray(values))
72
+ return [];
73
+ return values.map((entry) => normalizeCredential(entry)).filter((entry) => Boolean(entry));
74
+ }
75
+ function getCurrentModelRef(model) {
76
+ return isManagedProvider(model?.provider) ? { providerId: model.provider, modelId: model.id } : undefined;
77
+ }
78
+ function readAuthCredential(providerId) {
79
+ return new FileAuthStorageBackend().withLock((current) => {
80
+ if (!current)
81
+ return { result: undefined };
82
+ const data = JSON.parse(current);
83
+ const record = data[providerId];
84
+ if (!record)
85
+ return { result: undefined };
86
+ const accountId = typeof record.accountId === "string" && record.accountId.trim() ? record.accountId.trim() : undefined;
87
+ return { result: { type: record.type, accountId } };
88
+ });
89
+ }
90
+ // Reconciles the METADATA-ONLY pool entry for the default label (`label === providerId`)
91
+ // against auth.json, which is the source of truth for the default slot's identity. auth.json
92
+ // is read through FileAuthStorageBackend's real lock so a held auth.json lock or parse failure
93
+ // is surfaced instead of silently pretending the default credential is absent. Gated
94
+ // specifically on an OAUTH auth.json record (`type === "oauth"`) rather than the presence of
95
+ // any credential, because an api_key credential has no refresh token -- a metadata-only
96
+ // "default" slot seeded for one would be a slot `refreshDefaultSlotCredential` can never
97
+ // actually refresh; with no oauth record the pool is left exactly as-is (never seeded, never
98
+ // stripped). When an oauth record exists this ALWAYS re-derives the default entry's identity:
99
+ // it seeds a missing entry and rewrites an existing entry's `accountId` to match auth.json's
100
+ // (dropping it when auth.json carries none), preserving that entry's cooldown/timing fields.
101
+ // A NEW array reference is returned only when something actually changed -- an in-sync pool
102
+ // returns the SAME reference, which is what lets `readSubscriptionPool`'s heal-trigger and the
103
+ // mutate primitive's change detection stay sound. No token is ever copied into the pool: the
104
+ // value lives exclusively in auth.json. `accountId` IS identity metadata, so the default slot
105
+ // participates in duplicate detection and `/provider-sub list` rendering like an explicit account.
106
+ function ensureDefaultSubscriptionMetadata(providerId, pool) {
107
+ const authCred = readAuthCredential(providerId);
108
+ if (authCred?.type !== "oauth")
109
+ return pool;
110
+ const desired = authCred.accountId;
111
+ const idx = pool.findIndex((entry) => entry.label === providerId);
112
+ if (idx === -1) {
113
+ const seeded = normalizeCredential({
114
+ label: providerId,
115
+ ...(desired ? { accountId: desired } : {}),
116
+ rateLimitedUntil: 0,
117
+ lastAttemptAt: 0,
118
+ lastRateLimitedAt: 0,
119
+ });
120
+ return seeded ? [...pool, seeded] : pool;
121
+ }
122
+ const existing = pool[idx];
123
+ if ((existing.accountId ?? undefined) === (desired ?? undefined))
124
+ return pool;
125
+ const next = [...pool];
126
+ if (desired) {
127
+ next[idx] = { ...existing, accountId: desired };
128
+ }
129
+ else {
130
+ const { accountId: _drop, ...rest } = existing;
131
+ next[idx] = rest;
132
+ }
133
+ return next;
134
+ }
135
+ // The default label's value lives exclusively in auth.json, so its pool entry must never
136
+ // carry `refresh`/`access`/`expires`; this strips those fields on every read and write,
137
+ // self-healing any pool file that has them physically present on disk. `changed` tells
138
+ // the caller whether anything was actually stripped, so `readSubscriptionPool` can
139
+ // persist the metadata-only result immediately rather than leaving a stale copy on disk
140
+ // until some unrelated write.
141
+ function scrubDefaultValue(providerId, pool) {
142
+ let changed = false;
143
+ const next = pool.map((entry) => {
144
+ if (entry.label !== providerId || (entry.refresh === undefined && entry.access === undefined && entry.expires === undefined))
145
+ return entry;
146
+ changed = true;
147
+ const { refresh: _refresh, access: _access, expires: _expires, ...meta } = entry;
148
+ return meta;
149
+ });
150
+ return { pool: next, changed };
151
+ }
152
+ export function isManagedProvider(providerId) {
153
+ return providerId === ANTHROPIC_PROVIDER_ID || providerId === OPENAI_CODEX_PROVIDER_ID;
154
+ }
155
+ /** The `modelLadders` CONFIG key for a runtime provider id -- `openai-codex`
156
+ * (the runtime/auth id) maps to the `openai` ladder key used in
157
+ * ~/.crouter/config.json. Callers building a repair command that a user can
158
+ * paste into `crtr sys config set modelLadders.<key>...` must use THIS key,
159
+ * not the runtime provider id. */
160
+ export function getLadderProviderId(providerId) {
161
+ return PROVIDERS[providerId].ladderProviderId;
162
+ }
163
+ export function getProviderLabel(providerId) {
164
+ return PROVIDERS[providerId].label;
165
+ }
166
+ // The ONE locked read/modify/write primitive for a provider pool file (design §1). Acquires
167
+ // a cross-process lock ON THE POOL FILE via the same proper-lockfile-backed backend pi uses
168
+ // for auth.json, re-reads + normalizes the pool UNDER the lock, applies the caller's pure
169
+ // transform, and persists atomically (tmp+rename, mode 0600) before releasing. Every write to
170
+ // a pool file goes through here -- that is what finally makes the causal cooldown guards sound
171
+ // (they compare against fresh-under-lock state, not a stale snapshot).
172
+ //
173
+ // `fn` returning `undefined` means "no change, skip the write" (used by the timestamp guards).
174
+ // `withLock` seeds a missing file with `{}` (an OBJECT, not an array) -- normalizeCredentials
175
+ // coerces any non-array to [] so that first-lock seed is harmless; do not "fix" it into a parse
176
+ // error. We do our OWN atomic write inside the callback and return `next: undefined` so the
177
+ // backend's non-atomic writeFileSync path never runs and lock-free readers always observe a
178
+ // whole snapshot.
179
+ //
180
+ // Lock ordering (contract): pool lock -> auth lock is legal (seeding reads auth.json under its
181
+ // own FileAuthStorageBackend lock while holding the pool lock, via ensureDefaultSubscriptionMetadata
182
+ // -> readAuthCredential). NEVER acquire a pool lock inside an auth-lock callback. Throws (rather
183
+ // than proceeding unlocked) if lock acquisition is exhausted -- callers surface that as a visible
184
+ // error, never a silent lost write.
185
+ export function mutateSubscriptionPool(providerId, fn) {
186
+ const poolFile = PROVIDERS[providerId].poolFile;
187
+ const backend = new FileAuthStorageBackend(poolFile);
188
+ return backend.withLock((current) => {
189
+ let parsed;
190
+ try {
191
+ parsed = current ? JSON.parse(current) : undefined;
192
+ }
193
+ catch {
194
+ parsed = undefined;
195
+ }
196
+ const { pool: scrubbed, changed: wasScrubbed } = scrubDefaultValue(providerId, normalizeCredentials(parsed));
197
+ const base = ensureDefaultSubscriptionMetadata(providerId, scrubbed);
198
+ const transformed = fn(base);
199
+ if (transformed === undefined) {
200
+ assertDefaultAccountAvailable(base, providerId);
201
+ // A causal timestamp guard may reject the caller's update while auth-driven identity
202
+ // reconciliation or token scrubbing still needs to heal the stored metadata.
203
+ if (wasScrubbed || base !== scrubbed) {
204
+ const healed = scrubDefaultValue(providerId, normalizeCredentials(base)).pool;
205
+ writeJsonFile(poolFile, healed, 0o600);
206
+ return { result: healed };
207
+ }
208
+ return { result: base };
209
+ }
210
+ const normalized = scrubDefaultValue(providerId, normalizeCredentials(transformed)).pool;
211
+ // Reconciliation may have projected a new default identity from auth.json. Validate the
212
+ // final candidate while the pool lock is still held, after the transform so a removal or
213
+ // default-account switch can repair an externally-created collision instead of bricking it.
214
+ assertDefaultAccountAvailable(normalized, providerId);
215
+ writeJsonFile(poolFile, normalized, 0o600);
216
+ return { result: normalized };
217
+ });
218
+ }
219
+ export function readSubscriptionPool(providerId) {
220
+ const poolFile = PROVIDERS[providerId].poolFile;
221
+ const raw = normalizeCredentials(readJsonFile(poolFile));
222
+ const { pool: scrubbed, changed: wasScrubbed } = scrubDefaultValue(providerId, raw);
223
+ const withDefaultSlot = ensureDefaultSubscriptionMetadata(providerId, scrubbed);
224
+ // Reads are lock-free (a whole-file tmp+rename write means a reader always sees a complete
225
+ // snapshot). Only when the on-disk pool actually needs self-healing -- a stray default-slot
226
+ // value to scrub, or default metadata to reconcile with auth.json -- do we take the lock and
227
+ // re-derive the healed pool from fresh state through the write primitive.
228
+ // A source-of-truth auth change can reconcile the default slot into an explicit account.
229
+ // Force that case through the locked mutation path too: it either validates a fresh snapshot
230
+ // and persists a unique identity, or throws without returning/writing a duplicate pool.
231
+ if (wasScrubbed || withDefaultSlot !== scrubbed || findDefaultAccountCollision(withDefaultSlot, providerId)) {
232
+ return mutateSubscriptionPool(providerId, (pool) => pool);
233
+ }
234
+ return withDefaultSlot;
235
+ }
236
+ // Seed/replace the entire pool under the lock. Production mutation goes through the specific
237
+ // mutators below (each a thin transform over `mutateSubscriptionPool`); this whole-array replace
238
+ // exists for test seeding and is still locked, never an unlocked write surface.
239
+ export function writeSubscriptionPool(providerId, next) {
240
+ return mutateSubscriptionPool(providerId, () => next);
241
+ }
242
+ function resolveCredentialIndex(pool, ref) {
243
+ const needle = ref.trim().toLowerCase();
244
+ const numeric = Number.parseInt(needle, 10);
245
+ if (Number.isFinite(numeric) && String(numeric) === needle && numeric >= 1 && numeric <= pool.length) {
246
+ return numeric - 1;
247
+ }
248
+ return pool.findIndex((entry) => entry.label.toLowerCase() === needle);
249
+ }
250
+ // Merge an incoming upsert onto an existing same-label entry (design §1). Credential VALUE
251
+ // fields always come from the incoming entry (that is the point of the upsert); timing/cooldown
252
+ // state is reconciled causally so a refresh/reauth upsert built from a snapshot taken before a
253
+ // concurrent 429 can never erase that newer cooldown. The `>=` on `lastRateLimitedAt` is
254
+ // deliberate: reauth clears a cooldown after a successful re-login while preserving
255
+ // `lastRateLimitedAt`, so its equal-timestamp write wins and clears -- but a 429 that landed
256
+ // AFTER the reauth snapshot has a strictly greater `lastRateLimitedAt` and survives.
257
+ function mergeUpsert(existing, incoming) {
258
+ const cooldownFromIncoming = incoming.lastRateLimitedAt >= existing.lastRateLimitedAt;
259
+ const accountId = incoming.accountId ?? existing.accountId;
260
+ return {
261
+ ...incoming,
262
+ ...(accountId ? { accountId } : {}),
263
+ lastAttemptAt: Math.max(existing.lastAttemptAt, incoming.lastAttemptAt),
264
+ lastRateLimitedAt: cooldownFromIncoming ? incoming.lastRateLimitedAt : existing.lastRateLimitedAt,
265
+ rateLimitedUntil: cooldownFromIncoming ? incoming.rateLimitedUntil : existing.rateLimitedUntil,
266
+ };
267
+ }
268
+ export function upsertSubscription(providerId, next) {
269
+ return mutateSubscriptionPool(providerId, (pool) => {
270
+ const index = pool.findIndex((entry) => entry.label.toLowerCase() === next.label.toLowerCase());
271
+ if (index === -1)
272
+ return [...pool, next];
273
+ const merged = [...pool];
274
+ merged[index] = mergeUpsert(pool[index], next);
275
+ return merged;
276
+ });
277
+ }
278
+ // Thrown from inside the locked add transform when a label or Codex account already exists. A
279
+ // typed error so the /provider-sub handler surfaces a precise, user-facing message while the
280
+ // uniqueness decision itself stays atomic under the pool lock.
281
+ export class DuplicateSubscriptionError extends Error {
282
+ constructor(message) {
283
+ super(message);
284
+ this.name = "DuplicateSubscriptionError";
285
+ }
286
+ }
287
+ // The ONE account-uniqueness gate, shared by every identity write (explicit add, explicit
288
+ // reauth, default (re)login). Called INSIDE a `mutateSubscriptionPool` transform so it sees
289
+ // fresh-under-lock state. `exceptLabel` is the slot being updated -- excluded from the scan so
290
+ // a reauth/relogin of a slot into the SAME account it already holds is not a self-collision.
291
+ // A missing `accountId` makes no dedupe claim (Anthropic has no account claim, and legacy
292
+ // entries predate identity capture), so it is a no-op. Throws the typed error the handler
293
+ // surfaces to the user.
294
+ function findAccountCollision(pool, accountId, exceptLabel) {
295
+ if (!accountId)
296
+ return undefined;
297
+ return pool.find((entry) => entry.label.toLowerCase() !== exceptLabel.toLowerCase() && entry.accountId === accountId);
298
+ }
299
+ function assertAccountAvailable(pool, providerId, accountId, exceptLabel) {
300
+ const collision = findAccountCollision(pool, accountId, exceptLabel);
301
+ if (collision) {
302
+ throw new DuplicateSubscriptionError(`This ${getProviderLabel(providerId)} account is already added as “${collision.label}” — it shares the same quota, so a second slot adds no redundancy.`);
303
+ }
304
+ }
305
+ function findDefaultAccountCollision(pool, providerId) {
306
+ const defaultSlot = pool.find((entry) => entry.label === providerId);
307
+ return findAccountCollision(pool, defaultSlot?.accountId, providerId);
308
+ }
309
+ function assertDefaultAccountAvailable(pool, providerId) {
310
+ const defaultSlot = pool.find((entry) => entry.label === providerId);
311
+ assertAccountAvailable(pool, providerId, defaultSlot?.accountId, providerId);
312
+ }
313
+ // Atomically append a NEW explicit subscription (non-default label), enforcing label AND Codex
314
+ // account uniqueness INSIDE the pool lock (design finding 2). The long OAuth flow stays outside
315
+ // the lock; only this final validate-then-append is serialized, so two brokers that both finish
316
+ // OAuth for the same account cannot each slip a second label onto one quota. Account uniqueness
317
+ // also catches a collision with the default slot, whose identity is seeded from auth.json.
318
+ export function addSubscription(providerId, entry) {
319
+ return mutateSubscriptionPool(providerId, (pool) => {
320
+ // Normalize the candidate label BEFORE the locked uniqueness check: `normalizeCredentials`
321
+ // trims on persist, so a raw " work " that passed an untrimmed check would land as a
322
+ // duplicate "work". Reject an empty/whitespace label here too -- the storage primitive
323
+ // stays defensive regardless of what the handler validated.
324
+ const label = entry.label.trim();
325
+ if (!label)
326
+ throw new Error(`${getProviderLabel(providerId)} subscription label cannot be empty`);
327
+ if (pool.some((existing) => existing.label.toLowerCase() === label.toLowerCase())) {
328
+ throw new DuplicateSubscriptionError(`${getProviderLabel(providerId)} subscription already exists: ${label}`);
329
+ }
330
+ assertAccountAvailable(pool, providerId, entry.accountId, label);
331
+ return [...pool, { ...entry, label }];
332
+ });
333
+ }
334
+ // Identity-checked upsert for an EXPLICIT slot's reauth (label !== providerId). Same merge
335
+ // semantics as `upsertSubscription` (append-or-`mergeUpsert`, preserving cooldown causality),
336
+ // but gated by `assertAccountAvailable` first: reauthenticating a slot into an account already
337
+ // held by ANOTHER slot (default or explicit) throws instead of creating a duplicate quota entry.
338
+ // `next.label` is excluded from the uniqueness scan so a reauth into the slot's OWN account passes.
339
+ export function upsertSubscriptionWithUniqueAccount(providerId, next) {
340
+ return mutateSubscriptionPool(providerId, (pool) => {
341
+ assertAccountAvailable(pool, providerId, next.accountId, next.label);
342
+ const index = pool.findIndex((entry) => entry.label.toLowerCase() === next.label.toLowerCase());
343
+ if (index === -1)
344
+ return [...pool, next];
345
+ const merged = [...pool];
346
+ merged[index] = mergeUpsert(pool[index], next);
347
+ return merged;
348
+ });
349
+ }
350
+ // Persist the default slot's freshly-logged-in credential into auth.json under the auth lock.
351
+ // Called ONLY from `commitDefaultIdentity`, while the pool lock is already held, so the effective
352
+ // order is pool-lock -> auth-lock (the legal direction). Writes the full oauth record (tokens +
353
+ // optional accountId); a blank/whitespace accountId is dropped.
354
+ function persistDefaultAuthRecordSync(providerId, login) {
355
+ const accountId = login.accountId?.trim() || undefined;
356
+ new FileAuthStorageBackend().withLock((current) => {
357
+ // Corrupt credential storage is never equivalent to an empty file: parsing must fail before
358
+ // either store changes rather than silently deleting every other provider's credential.
359
+ const data = current ? JSON.parse(current) : {};
360
+ data[providerId] = { type: "oauth", refresh: login.refresh, access: login.access, expires: login.expires, ...(accountId ? { accountId } : {}) };
361
+ return { result: undefined, next: `${JSON.stringify(data, null, 2)}\n` };
362
+ });
363
+ }
364
+ // Atomically commit a (re)login of the DEFAULT slot (design finding 2). Under the pool lock it:
365
+ // (1) validates account-uniqueness EXCLUDING the default slot -- throwing BEFORE any auth write,
366
+ // so a rejection never partially switches auth.json; (2) persists the credential to auth.json
367
+ // under the nested auth lock (pool -> auth order); (3) reflects the new identity and causally
368
+ // reconciles the cooldown on the metadata-only default pool entry (NO tokens copied -- the entry
369
+ // stays metadata-only; scrubDefaultValue enforces this on write regardless). Seeds the default entry
370
+ // if it is absent. The account uniqueness catches a collision with any explicit slot.
371
+ export function commitDefaultIdentity(providerId, login) {
372
+ const accountId = login.accountId?.trim() || undefined;
373
+ return mutateSubscriptionPool(providerId, (pool) => {
374
+ assertAccountAvailable(pool, providerId, accountId, providerId);
375
+ persistDefaultAuthRecordSync(providerId, login);
376
+ const index = pool.findIndex((entry) => entry.label === providerId);
377
+ const now = Date.now();
378
+ const existing = index === -1 ? undefined : pool[index];
379
+ // Cooldowns belong to an account identity. A login that actually switches accounts starts
380
+ // the new quota clean. For the same account, mirror mergeUpsert's causal rule: only a login
381
+ // whose snapshot includes the latest 429 may clear it; a strictly newer concurrent 429 wins.
382
+ const sameAccount = (existing?.accountId ?? undefined) === (accountId ?? undefined);
383
+ const loginCanClearCooldown = !existing || !sameAccount || login.lastRateLimitedAt >= existing.lastRateLimitedAt;
384
+ const base = existing === undefined
385
+ ? { label: providerId, rateLimitedUntil: 0, lastAttemptAt: now, lastRateLimitedAt: 0 }
386
+ : {
387
+ ...existing,
388
+ rateLimitedUntil: loginCanClearCooldown ? 0 : existing.rateLimitedUntil,
389
+ lastAttemptAt: Math.max(existing.lastAttemptAt, now),
390
+ };
391
+ const entry = accountId
392
+ ? { ...base, accountId }
393
+ : (() => {
394
+ const { accountId: _drop, ...rest } = base;
395
+ return rest;
396
+ })();
397
+ const next = [...pool];
398
+ if (index === -1)
399
+ next.push(entry);
400
+ else
401
+ next[index] = entry;
402
+ return next;
403
+ });
404
+ }
405
+ export function renameSubscription(providerId, ref, nextLabel) {
406
+ return mutateSubscriptionPool(providerId, (pool) => {
407
+ const index = resolveCredentialIndex(pool, ref);
408
+ if (index === -1)
409
+ throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
410
+ if (pool[index].label === providerId)
411
+ throw new Error(`The Default account label cannot be renamed`);
412
+ const label = nextLabel.trim();
413
+ if (!label)
414
+ throw new Error(`${getProviderLabel(providerId)} subscription label cannot be empty`);
415
+ if (pool.some((entry, entryIndex) => entryIndex !== index && entry.label.toLowerCase() === label.toLowerCase())) {
416
+ throw new DuplicateSubscriptionError(`${getProviderLabel(providerId)} subscription already exists: ${label}`);
417
+ }
418
+ const next = [...pool];
419
+ next[index] = { ...next[index], label };
420
+ return next;
421
+ });
422
+ }
423
+ // The default pool slot is only metadata, but its matching auth.json OAuth record is
424
+ // live state. Remove both while holding the pool lock (then the auth lock), matching
425
+ // commitDefaultIdentity's lock order so a deleted default cannot be immediately seeded back.
426
+ export function removeManagedAccount(providerId, ref) {
427
+ return mutateSubscriptionPool(providerId, (pool) => {
428
+ const index = resolveCredentialIndex(pool, ref);
429
+ if (index === -1)
430
+ throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
431
+ if (pool[index].label === providerId) {
432
+ new FileAuthStorageBackend().withLock((current) => {
433
+ // Corrupt credential storage is never equivalent to an empty file: parsing must fail before
434
+ // either store changes rather than silently deleting every other provider's credential.
435
+ const data = current ? JSON.parse(current) : {};
436
+ delete data[providerId];
437
+ return { result: undefined, next: `${JSON.stringify(data, null, 2)}\n` };
438
+ });
439
+ }
440
+ const next = [...pool];
441
+ next.splice(index, 1);
442
+ return next;
443
+ });
444
+ }
445
+ // Coordinated persistence for a freshly-acquired managed OAuth credential — the ONE place human
446
+ // UI (the `crtr sys setup` Providers panel and `crtr surface attach`'s /login) turns a login into
447
+ // stored state, so the coordinated-account invariants hold from every entrypoint. The default slot
448
+ // (label === providerId) commits through commitDefaultIdentity (auth.json + pool metadata under one
449
+ // lock, account-uniqueness enforced BEFORE any auth write); any explicit label add/upserts into the
450
+ // pool with the same account-uniqueness gate. UI must NEVER persist a managed login via a raw
451
+ // AuthStorage.login/set — that path skips uniqueness and leaves the pool identity unseeded.
452
+ // `defaultLastRateLimitedAt` is the default slot's rate-limit timestamp observed BEFORE OAuth
453
+ // began: commitDefaultIdentity's causal cooldown rule needs the pre-login snapshot so a strictly
454
+ // newer concurrent 429 survives a same-account re-login (see DefaultSlotLogin).
455
+ export function commitManagedLogin(providerId, label, credential) {
456
+ const accountId = credential.accountId?.trim() || undefined;
457
+ if (label === providerId) {
458
+ return commitDefaultIdentity(providerId, {
459
+ refresh: credential.refresh,
460
+ access: credential.access,
461
+ expires: credential.expires,
462
+ ...(accountId ? { accountId } : {}),
463
+ lastRateLimitedAt: credential.defaultLastRateLimitedAt,
464
+ });
465
+ }
466
+ const existing = readSubscriptionPool(providerId).find((entry) => entry.label.toLowerCase() === label.toLowerCase());
467
+ const next = {
468
+ label,
469
+ refresh: credential.refresh,
470
+ access: credential.access,
471
+ expires: credential.expires,
472
+ ...(accountId ? { accountId } : {}),
473
+ rateLimitedUntil: 0,
474
+ lastAttemptAt: Date.now(),
475
+ lastRateLimitedAt: existing?.lastRateLimitedAt ?? 0,
476
+ };
477
+ return existing ? upsertSubscriptionWithUniqueAccount(providerId, next) : addSubscription(providerId, next);
478
+ }
479
+ // Coordinated removal for the human UI. Removes a single account when `label` is given; the ENTIRE
480
+ // provider (every pool account plus its auth.json default record) when `label` is omitted — always
481
+ // through removeManagedAccount so pool and auth.json are torn down together. UI must NEVER reach for
482
+ // a raw AuthStorage.logout() on a managed provider: that strips only auth.json and orphans the pool,
483
+ // recreating the split-ownership bug this coordination exists to eliminate.
484
+ export function removeManagedProvider(providerId, label) {
485
+ const refs = label !== undefined ? [label] : readSubscriptionPool(providerId).map((account) => account.label);
486
+ let pool = readSubscriptionPool(providerId);
487
+ for (const ref of refs)
488
+ pool = removeManagedAccount(providerId, ref);
489
+ return pool;
490
+ }
491
+ export function removeSubscription(providerId, ref) {
492
+ return mutateSubscriptionPool(providerId, (pool) => {
493
+ const index = resolveCredentialIndex(pool, ref);
494
+ if (index === -1)
495
+ throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
496
+ const next = [...pool];
497
+ next.splice(index, 1);
498
+ return next;
499
+ });
500
+ }
501
+ export function promoteSubscription(providerId, ref) {
502
+ return mutateSubscriptionPool(providerId, (pool) => {
503
+ const index = resolveCredentialIndex(pool, ref);
504
+ if (index === -1)
505
+ throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
506
+ if (index === 0)
507
+ return pool;
508
+ const next = [...pool];
509
+ next.unshift(next.splice(index, 1)[0]);
510
+ return next;
511
+ });
512
+ }
513
+ export function markSubscriptionAttempt(providerId, ref, attemptAt = Date.now()) {
514
+ return mutateSubscriptionPool(providerId, (pool) => {
515
+ const index = resolveCredentialIndex(pool, ref);
516
+ if (index === -1)
517
+ throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
518
+ const next = [...pool];
519
+ next[index] = { ...pool[index], lastAttemptAt: Math.max(pool[index].lastAttemptAt, attemptAt) };
520
+ return next;
521
+ });
522
+ }
523
+ export function markSubscriptionRateLimited(providerId, ref, retryAfterMs, attemptAt = Date.now(), rateLimitedAt = Date.now()) {
524
+ return mutateSubscriptionPool(providerId, (pool) => {
525
+ const index = resolveCredentialIndex(pool, ref);
526
+ if (index === -1)
527
+ throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
528
+ // A later rate-limit is authoritative until it expires: an out-of-order (stale) 429 whose
529
+ // basis predates the slot's newest cooldown must not reopen or shorten it.
530
+ if (rateLimitedAt < pool[index].lastRateLimitedAt)
531
+ return undefined;
532
+ const next = [...pool];
533
+ next[index] = {
534
+ ...pool[index],
535
+ lastAttemptAt: Math.max(pool[index].lastAttemptAt, attemptAt),
536
+ lastRateLimitedAt: Math.max(pool[index].lastRateLimitedAt, rateLimitedAt),
537
+ rateLimitedUntil: rateLimitedAt + Math.max(0, retryAfterMs),
538
+ };
539
+ return next;
540
+ });
541
+ }
542
+ export function markSubscriptionSuccess(providerId, ref, attemptAt = Date.now()) {
543
+ return mutateSubscriptionPool(providerId, (pool) => {
544
+ const index = resolveCredentialIndex(pool, ref);
545
+ if (index === -1)
546
+ throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
547
+ // A success whose attempt started before the slot's newest 429 must not clear that cooldown
548
+ // (the memory rule): treat the later rate-limit as authoritative until it expires.
549
+ if (attemptAt < pool[index].lastRateLimitedAt)
550
+ return undefined;
551
+ const next = [...pool];
552
+ next[index] = { ...pool[index], lastAttemptAt: Math.max(pool[index].lastAttemptAt, attemptAt), rateLimitedUntil: 0 };
553
+ return next;
554
+ });
555
+ }
556
+ export function findAvailableSubscription(providerId, now = Date.now()) {
557
+ return readSubscriptionPool(providerId).find((entry) => !entry.rateLimitedUntil || entry.rateLimitedUntil <= now);
558
+ }
559
+ export function parseRetryAfterHeader(value, now = Date.now()) {
560
+ if (!value)
561
+ return undefined;
562
+ const trimmed = value.trim();
563
+ if (!trimmed)
564
+ return undefined;
565
+ const seconds = Number(trimmed);
566
+ if (Number.isFinite(seconds))
567
+ return Math.max(0, Math.round(seconds * 1000));
568
+ const parsed = Date.parse(trimmed);
569
+ return Number.isFinite(parsed) ? Math.max(0, parsed - now) : undefined;
570
+ }
571
+ function thinkingLevelOfRef(ref) {
572
+ const match = ref.trim().match(/:(thinking|off|minimal|low|medium|high|xhigh|max)$/i);
573
+ if (!match)
574
+ return undefined;
575
+ const level = match[1].toLowerCase();
576
+ return level === "off" || level === "thinking" ? undefined : level;
577
+ }
578
+ function normalizeModelRef(ref) {
579
+ return ref
580
+ .trim()
581
+ .replace(/^[^/]+\//, "")
582
+ .replace(/:(?:thinking|off|minimal|low|medium|high|xhigh|max)$/i, "")
583
+ .toLowerCase();
584
+ }
585
+ function splitModelRef(ref) {
586
+ const trimmed = ref.trim();
587
+ const slash = trimmed.indexOf("/");
588
+ const providerId = slash === -1 ? OPENAI_CODEX_PROVIDER_ID : trimmed.slice(0, slash).trim();
589
+ if (!isManagedProvider(providerId)) {
590
+ throw new Error(`Unsupported fallback provider: ${providerId}`);
591
+ }
592
+ const rawModelId = slash === -1 ? trimmed : trimmed.slice(slash + 1).trim();
593
+ const thinkingMatch = rawModelId.match(/^(.*?)(?::(thinking|off|minimal|low|medium|high|xhigh|max))$/i);
594
+ return {
595
+ providerId,
596
+ modelId: thinkingMatch ? thinkingMatch[1] : rawModelId,
597
+ label: `${providerId}/${thinkingMatch ? thinkingMatch[1] : rawModelId}${thinkingMatch?.[2] ? `:${thinkingMatch[2].toLowerCase()}` : ""}`,
598
+ strength: DEFAULT_FALLBACK_STRENGTH,
599
+ thinkingLevel: thinkingMatch?.[2] && thinkingMatch[2].toLowerCase() !== "off" && thinkingMatch[2].toLowerCase() !== "thinking"
600
+ ? thinkingMatch[2].toLowerCase()
601
+ : undefined,
602
+ };
603
+ }
604
+ // Layers ~/.crouter/config.json's `modelLadders` (if any) OVER crouter's
605
+ // builtin ladder (`defaultModelLaddersConfig()`, src/types.ts — imported
606
+ // directly: this package's canonical home is the crouter tree, so the
607
+ // relative import always resolves, to dist/types.js at runtime), per
608
+ // provider/strength — mirroring crouter's own
609
+ // `mergeModelLadders` (src/core/config.ts), which is what a node's launcher
610
+ // actually resolves models from. Without this fallback, a config-less user
611
+ // (the common case: fresh install, no ~/.crouter/config.json) saw an EMPTY
612
+ // ladder here while crtr itself launched them on the builtin defaults —
613
+ // rotation could then never resolve a strength tier or a cross-provider
614
+ // fallback for a model it was already running. Always returns a fully
615
+ // populated ladder for both providers; a config.json override only replaces
616
+ // the specific strengths it sets.
617
+ function readModelLadders() {
618
+ const config = readJsonFile(CROUTER_CONFIG_FILE);
619
+ const overrides = config?.modelLadders;
620
+ const builtin = defaultModelLaddersConfig();
621
+ const result = {};
622
+ for (const provider of ["anthropic", "openai"]) {
623
+ const normalized = { ...builtin[provider] };
624
+ const override = overrides?.[provider];
625
+ if (override) {
626
+ for (const strength of ["ultra", "strong", "medium", "light"]) {
627
+ const value = typeof override[strength] === "string" ? override[strength]?.trim() : "";
628
+ if (value)
629
+ normalized[strength] = value;
630
+ }
631
+ }
632
+ result[provider] = normalized;
633
+ }
634
+ return result;
635
+ }
636
+ function findStrengthForModelId(providerId, modelId, defaultStrength, ladders = readModelLadders(), currentThinkingLevel) {
637
+ const source = ladders?.[PROVIDERS[providerId].ladderProviderId];
638
+ const bareId = modelId ? normalizeModelRef(modelId) : undefined;
639
+ if (!source || !bareId)
640
+ return undefined;
641
+ const matches = [];
642
+ for (const strength of ["ultra", "strong", "medium", "light"]) {
643
+ const ref = source[strength];
644
+ if (ref && normalizeModelRef(ref) === bareId)
645
+ matches.push(strength);
646
+ }
647
+ if (matches.length === 0)
648
+ return undefined;
649
+ // Rungs that share a bare model id (e.g. gpt-5.6-sol:max vs gpt-5.6-sol:high) are
650
+ // disambiguated by the live reasoning level so an ultra run doesn't collapse to strong.
651
+ if (matches.length > 1 && currentThinkingLevel) {
652
+ const exact = matches.find((strength) => thinkingLevelOfRef(source[strength]) === currentThinkingLevel);
653
+ if (exact)
654
+ return exact;
655
+ }
656
+ return matches.includes(defaultStrength) ? defaultStrength : matches[0];
657
+ }
658
+ export function readRotationConfig() {
659
+ const raw = readJsonFile(ROTATION_CONFIG_FILE) ?? {};
660
+ const preferredModel = raw.preferredModel && isManagedProvider(raw.preferredModel.providerId) && typeof raw.preferredModel.modelId === "string"
661
+ ? { providerId: raw.preferredModel.providerId, modelId: raw.preferredModel.modelId.trim() }
662
+ : undefined;
663
+ return {
664
+ defaultFallbackStrength: normalizeStrength(raw.defaultFallbackStrength) ?? DEFAULT_FALLBACK_STRENGTH,
665
+ revertWhenAvailable: raw.revertWhenAvailable ?? true,
666
+ preferredModel,
667
+ };
668
+ }
669
+ export function writeRotationConfig(next) {
670
+ const current = readRotationConfig();
671
+ const merged = {
672
+ defaultFallbackStrength: normalizeStrength(next.defaultFallbackStrength) ?? current.defaultFallbackStrength ?? DEFAULT_FALLBACK_STRENGTH,
673
+ revertWhenAvailable: next.revertWhenAvailable ?? current.revertWhenAvailable ?? true,
674
+ preferredModel: Object.prototype.hasOwnProperty.call(next, "preferredModel")
675
+ ? next.preferredModel || undefined
676
+ : current.preferredModel,
677
+ };
678
+ writeJsonFile(ROTATION_CONFIG_FILE, merged);
679
+ return merged;
680
+ }
681
+ export function clearPreferredModel() {
682
+ return writeRotationConfig({ preferredModel: null });
683
+ }
684
+ export function rememberPreferredModel(model) {
685
+ return writeRotationConfig({ preferredModel: model });
686
+ }
687
+ // `readModelLadders()` always fills both providers from the builtin defaults, so this
688
+ // only returns undefined (never throws) in the defensive case where a rung is still
689
+ // genuinely missing; callers must treat that as a graceful "no fallback available" case
690
+ // rather than a config error to surface mid-turn.
691
+ export function resolveFallbackTarget(currentProviderId, currentModelId, config = readRotationConfig(), currentThinkingLevel) {
692
+ const ladders = readModelLadders();
693
+ const defaultStrength = config.defaultFallbackStrength ?? DEFAULT_FALLBACK_STRENGTH;
694
+ const sourceStrength = findStrengthForModelId(currentProviderId, currentModelId, defaultStrength, ladders, currentThinkingLevel);
695
+ const strength = sourceStrength ?? defaultStrength;
696
+ const targetProvider = currentProviderId === ANTHROPIC_PROVIDER_ID ? OPENAI_CODEX_PROVIDER_ID : ANTHROPIC_PROVIDER_ID;
697
+ const targetLadder = ladders?.[PROVIDERS[targetProvider].ladderProviderId];
698
+ const candidates = strength === defaultStrength ? [strength] : [strength, defaultStrength];
699
+ const fallbackRef = candidates.map((candidate) => targetLadder?.[candidate]).find((ref) => typeof ref === "string" && ref.trim().length > 0);
700
+ if (!fallbackRef) {
701
+ return undefined;
702
+ }
703
+ const parsed = splitModelRef(fallbackRef);
704
+ const resolvedStrength = candidates.find((candidate) => targetLadder?.[candidate] === fallbackRef) ?? strength;
705
+ return { ...parsed, strength: resolvedStrength };
706
+ }
707
+ export function shouldRestorePreferredModel(config = readRotationConfig(), now = Date.now()) {
708
+ if (!config.revertWhenAvailable || !config.preferredModel)
709
+ return false;
710
+ return Boolean(findAvailableSubscription(config.preferredModel.providerId, now));
711
+ }
712
+ export async function restorePreferredModelIfPossible(piApi, ctx) {
713
+ const config = readRotationConfig();
714
+ if (!shouldRestorePreferredModel(config, Date.now()))
715
+ return false;
716
+ const preferred = config.preferredModel;
717
+ if (!preferred)
718
+ return false;
719
+ const current = getCurrentModelRef(ctx.model);
720
+ if (current?.providerId === preferred.providerId && current.modelId === preferred.modelId) {
721
+ clearPreferredModel();
722
+ return true;
723
+ }
724
+ const model = ctx.modelRegistry.find(preferred.providerId, preferred.modelId);
725
+ if (!model)
726
+ return false;
727
+ const switched = await piApi.setModel(model);
728
+ if (switched)
729
+ clearPreferredModel();
730
+ return switched;
731
+ }
732
+ export async function switchToFallbackIfPossible(piApi, ctx, preferredModel = getCurrentModelRef(ctx.model)) {
733
+ if (!preferredModel)
734
+ return undefined;
735
+ rememberPreferredModel(preferredModel);
736
+ const currentThinkingLevel = piApi.getThinkingLevel?.();
737
+ const target = resolveFallbackTarget(preferredModel.providerId, preferredModel.modelId, readRotationConfig(), currentThinkingLevel);
738
+ if (!target)
739
+ return undefined;
740
+ const current = getCurrentModelRef(ctx.model);
741
+ const model = ctx.modelRegistry.find(target.providerId, target.modelId);
742
+ if (!model)
743
+ return undefined;
744
+ if (current?.providerId !== target.providerId || current.modelId !== target.modelId) {
745
+ const switched = await piApi.setModel(model);
746
+ if (!switched)
747
+ return undefined;
748
+ }
749
+ if (target.thinkingLevel)
750
+ piApi.setThinkingLevel(target.thinkingLevel);
751
+ return target;
752
+ }
753
+ export function formatStatusLine(providerId, now = Date.now()) {
754
+ const pool = readSubscriptionPool(providerId);
755
+ const ready = pool.filter((entry) => !entry.rateLimitedUntil || entry.rateLimitedUntil <= now);
756
+ const label = getProviderLabel(providerId);
757
+ if (ready.length === 0) {
758
+ const fallback = resolveFallbackTarget(providerId, undefined, readRotationConfig());
759
+ return fallback ? `${label} pool exhausted -> ${fallback.label}` : `${label} pool exhausted (no fallback configured)`;
760
+ }
761
+ return `${label} ${ready[0]?.label ?? providerId} (${ready.length}/${pool.length} ready)`;
762
+ }