@crouton-kit/crouter 0.3.55 → 0.3.57

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.
@@ -1,6 +1,7 @@
1
1
  import { before, after, test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { spawn } from "node:child_process";
4
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
5
  import { dirname, join } from "node:path";
5
6
  import { tmpdir } from "node:os";
6
7
 
@@ -59,6 +60,29 @@ function resetRotationState() {
59
60
  helpers.clearPreferredModel();
60
61
  }
61
62
 
63
+ function makeAuthFileReadOnly(authPath: string): void {
64
+ chmodSync(authPath, 0o444);
65
+ }
66
+
67
+ function makeAuthFileWritable(authPath: string): void {
68
+ chmodSync(authPath, 0o600);
69
+ }
70
+
71
+ function observeLines(stream: NodeJS.ReadableStream, onLine: (line: string) => void): void {
72
+ let buffer = "";
73
+ stream.setEncoding("utf8");
74
+ stream.on("data", (chunk: string) => {
75
+ buffer += chunk;
76
+ let newlineIndex = buffer.indexOf("\n");
77
+ while (newlineIndex !== -1) {
78
+ const line = buffer.slice(0, newlineIndex);
79
+ buffer = buffer.slice(newlineIndex + 1);
80
+ onLine(line);
81
+ newlineIndex = buffer.indexOf("\n");
82
+ }
83
+ });
84
+ }
85
+
62
86
  before(async () => {
63
87
  home = mkdtempSync(join(tmpdir(), "pi-provider-rotation-"));
64
88
  process.env.HOME = home;
@@ -100,7 +124,12 @@ test("normalizes provider subscription pool files", () => {
100
124
  ]);
101
125
  });
102
126
 
103
- test("empty provider pool reseeds from OAuth auth", () => {
127
+ // The default label's pool entry is metadata-only -- seeding it from auth.json must never
128
+ // copy a token into the pool, only bootstrap the zeroed cooldown bookkeeping that has
129
+ // nowhere else to live. Assert via the literal key set (not just "the value fields are
130
+ // undefined") so a regression that starts writing `refresh: undefined` etc. -- which would
131
+ // still satisfy an `undefined`-only check -- is caught.
132
+ test("fresh-node seeding of the default slot is metadata-only (no value keys present at all)", () => {
104
133
  resetRotationState();
105
134
  const authPath = join(home, ".pi", "agent", "auth.json");
106
135
  mkdirSync(dirname(authPath), { recursive: true });
@@ -117,83 +146,337 @@ test("empty provider pool reseeds from OAuth auth", () => {
117
146
  "utf8",
118
147
  );
119
148
 
120
- assert.deepEqual(helpers.readSubscriptionPool("openai-codex"), [
121
- {
122
- label: "openai-codex",
123
- refresh: "refresh-codex-auth",
124
- access: "access-codex-auth",
125
- expires: 123456,
126
- rateLimitedUntil: 0,
127
- lastAttemptAt: 0,
128
- lastRateLimitedAt: 0,
129
- },
130
- ]);
149
+ const pool = helpers.readSubscriptionPool("openai-codex");
150
+ assert.equal(pool.length, 1);
151
+ assert.deepEqual(Object.keys(pool[0]).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
152
+ assert.deepEqual(pool[0], {
153
+ label: "openai-codex",
154
+ rateLimitedUntil: 0,
155
+ lastAttemptAt: 0,
156
+ lastRateLimitedAt: 0,
157
+ });
131
158
  });
132
159
 
133
- test("default provider subscription syncs when OAuth auth is refreshed elsewhere", () => {
160
+ // There is exactly one place the default slot's value is ever stored -- auth.json -- so
161
+ // there is no second copy in the pool to ever diverge from it. Prove this directly: seed
162
+ // the pool from auth.json holding credential A, then overwrite auth.json with a DIFFERENT
163
+ // credential B (simulating an external refresh/re-login the pool never sees), and read the
164
+ // pool again. It must still be the SAME metadata-only entry, unchanged by B -- because
165
+ // there was never a value in the pool to go stale in the first place.
166
+ test("no divergence is possible: overwriting auth.json elsewhere never changes the metadata-only pool entry", () => {
134
167
  resetRotationState();
135
- helpers.writeSubscriptionPool("anthropic", [
136
- credential("anthropic", {
137
- refresh: "refresh-same",
138
- access: "access-stale",
139
- expires: 1,
140
- rateLimitedUntil: Date.now() + 60_000,
141
- }),
142
- ]);
143
168
  const authPath = join(home, ".pi", "agent", "auth.json");
144
169
  mkdirSync(dirname(authPath), { recursive: true });
145
170
  writeFileSync(
146
171
  authPath,
147
172
  `${JSON.stringify({
148
- anthropic: {
149
- type: "oauth",
150
- refresh: "refresh-same",
151
- access: "access-fresh",
152
- expires: 123456,
153
- },
173
+ anthropic: { type: "oauth", refresh: "refresh-A", access: "access-A", expires: 123456 },
154
174
  })}\n`,
155
175
  "utf8",
156
176
  );
157
177
 
158
- assert.deepEqual(helpers.readSubscriptionPool("anthropic"), [
159
- {
160
- label: "anthropic",
161
- refresh: "refresh-same",
162
- access: "access-fresh",
163
- expires: 123456,
164
- rateLimitedUntil: 0,
165
- lastAttemptAt: 0,
166
- lastRateLimitedAt: 0,
167
- },
168
- ]);
178
+ const first = helpers.readSubscriptionPool("anthropic");
179
+ assert.deepEqual(first, [{ label: "anthropic", rateLimitedUntil: 0, lastAttemptAt: 0, lastRateLimitedAt: 0 }]);
180
+
181
+ writeFileSync(
182
+ authPath,
183
+ `${JSON.stringify({
184
+ anthropic: { type: "oauth", refresh: "refresh-B", access: "access-B", expires: 999999 },
185
+ })}\n`,
186
+ "utf8",
187
+ );
188
+
189
+ const second = helpers.readSubscriptionPool("anthropic");
190
+ assert.deepEqual(second, first);
191
+ assert.deepEqual(Object.keys(second[0]).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
169
192
  });
170
193
 
171
- // Regression: ambient /login auth sync used to append a brand-new pool entry whenever
172
- // the freshly authenticated tokens didn't match an existing entry by refresh/access
173
- // (e.g. a re-login that rotated both tokens), silently duplicating the default account
174
- // instead of updating it. It must update the selected/default slot (index 0) in place;
175
- // adding a genuinely additional account stays an explicit `/provider-sub add` action.
176
- test("/login with non-matching tokens replaces the default provider subscription slot", () => {
194
+ // The default label's pool entry is metadata-only on disk: reading it strips any stored
195
+ // refresh, access, and expires fields and persists the cleaned pool immediately.
196
+ test("scrubbing the default-slot value on read persists the metadata-only pool to disk", () => {
197
+ resetRotationState();
198
+ const poolFile = join(home, ".pi", "agent", "anthropic-subscriptions.json");
199
+ mkdirSync(dirname(poolFile), { recursive: true });
200
+ writeFileSync(
201
+ poolFile,
202
+ `${JSON.stringify([
203
+ { label: "anthropic", refresh: "stale-refresh", access: "stale-access", expires: 123, rateLimitedUntil: 0, lastAttemptAt: 0, lastRateLimitedAt: 0 },
204
+ ])}\n`,
205
+ "utf8",
206
+ );
207
+
208
+ const pool = helpers.readSubscriptionPool("anthropic");
209
+ assert.deepEqual(Object.keys(pool[0]).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
210
+
211
+ // The raw FILE on disk must ALSO be scrubbed -- not just the in-memory return value.
212
+ const raw = JSON.parse(readFileSync(poolFile, "utf8"));
213
+ assert.deepEqual(Object.keys(raw[0]).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
214
+ });
215
+
216
+ // The brick-repro (root cause of the original bug): two concurrent turns both see the
217
+ // SAME expired default-slot credential and both try to refresh it. Without a real lock
218
+ // serializing read -> decide -> refresh -> write, both call the (single-use) refresh token
219
+ // and the loser gets a genuine invalid_grant from the provider -- bricking that turn. Prove
220
+ // the fix: the injected refresh fn is called EXACTLY ONCE across two concurrent callers;
221
+ // the second caller re-reads the winner's fresh value under the lock and skips its own
222
+ // network call entirely; both callers resolve without throwing; auth.json ends up holding
223
+ // exactly the one refreshed value; the pool entry stays metadata-only throughout.
224
+ //
225
+ // This test is proven to fail without the fix, not just asserted to pass with it -- see
226
+ // the fail-then-pass evidence recorded in the implementation report.
227
+ test("brick-repro: concurrent refreshes of the same expired default-slot token serialize through the lock (exactly one network refresh)", async () => {
177
228
  resetRotationState();
178
- helpers.writeSubscriptionPool("anthropic", [credential("anthropic", { refresh: "refresh-first", access: "access-first" })]);
179
229
  const authPath = join(home, ".pi", "agent", "auth.json");
180
230
  mkdirSync(dirname(authPath), { recursive: true });
181
231
  writeFileSync(
182
232
  authPath,
183
233
  `${JSON.stringify({
184
- anthropic: {
185
- type: "oauth",
186
- refresh: "refresh-second",
187
- access: "access-second",
188
- expires: 123456,
189
- },
234
+ anthropic: { type: "oauth", refresh: "shared-refresh", access: "access-stale", expires: 1 },
190
235
  })}\n`,
191
236
  "utf8",
192
237
  );
238
+ const poolEntry = helpers.readSubscriptionPool("anthropic")[0];
239
+ assert.equal(poolEntry.label, "anthropic");
240
+
241
+ let refreshCalls = 0;
242
+ rotationModule.__setRefreshForProviderForTest(async (_providerId, refreshToken) => {
243
+ refreshCalls += 1;
244
+ if (refreshCalls === 1) {
245
+ return { refresh: "shared-refresh-rotated", access: "access-fresh", expires: Date.now() + 60_000 };
246
+ }
247
+ // Any refresh call after the first is simulating "this refresh token was already
248
+ // consumed by the winner" -- a genuine invalid_grant from the provider.
249
+ throw Object.assign(new Error(`HTTP request failed. status=400; body={"error":"invalid_grant"} for token ${refreshToken}`), { status: 400 });
250
+ });
193
251
 
194
- assert.deepEqual(helpers.readSubscriptionPool("anthropic").map((entry) => ({ label: entry.label, refresh: entry.refresh, access: entry.access })), [
195
- { label: "anthropic", refresh: "refresh-second", access: "access-second" },
196
- ]);
252
+ try {
253
+ const [a, b] = await Promise.all([
254
+ rotationModule.__refreshCredentialIfNeededForTest("anthropic", poolEntry),
255
+ rotationModule.__refreshCredentialIfNeededForTest("anthropic", poolEntry),
256
+ ]);
257
+
258
+ assert.equal(refreshCalls, 1);
259
+ assert.equal(a.access, "access-fresh");
260
+ assert.equal(b.access, "access-fresh");
261
+
262
+ const stillMetadataOnly = helpers.readSubscriptionPool("anthropic")[0];
263
+ assert.deepEqual(Object.keys(stillMetadataOnly).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
264
+
265
+ const authRaw = JSON.parse(readFileSync(authPath, "utf8"));
266
+ assert.equal(authRaw.anthropic.access, "access-fresh");
267
+ assert.equal(authRaw.anthropic.refresh, "shared-refresh-rotated");
268
+ } finally {
269
+ rotationModule.__setRefreshForProviderForTest(undefined);
270
+ }
271
+ });
272
+
273
+ // A GENUINE invalid_grant (the refresh token is actually dead, not merely raced) must
274
+ // still reach the real cooldown -> reauth path through the new lock-delegated seam --
275
+ // the fix must not accidentally swallow or misclassify a real failure. Drives it through
276
+ // the actual provider stream (not the raw helper) so `isInvalidRefreshTokenError` +
277
+ // reauthentication + the AuthStorage persist all fire for real.
278
+ test("a genuine invalid_grant through the default-slot lock still reauthenticates and persists to auth.json", async () => {
279
+ resetRotationState();
280
+ writeLadderConfig();
281
+ const authPath = join(home, ".pi", "agent", "auth.json");
282
+ mkdirSync(dirname(authPath), { recursive: true });
283
+ writeFileSync(
284
+ authPath,
285
+ `${JSON.stringify({
286
+ anthropic: { type: "oauth", refresh: "dead-refresh", access: "access-stale", expires: 1 },
287
+ })}\n`,
288
+ "utf8",
289
+ );
290
+ // Seed the metadata-only pool entry for the default label.
291
+ helpers.readSubscriptionPool("anthropic");
292
+
293
+ const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
294
+ ctx.hasUI = true;
295
+ ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
296
+ ctx.ui.notify = () => {};
297
+ const loginCalls: string[] = [];
298
+ // Plain Error, no typed .status: real pi-ai refresh failures carry the HTTP status
299
+ // in the message text, so the refresh path still recognizes an invalid_grant from this
300
+ // shape.
301
+ rotationModule.__setRefreshForProviderForTest(async (providerId) => {
302
+ if (providerId === "anthropic") {
303
+ throw new Error('HTTP request failed. status=400; body={"error":"invalid_grant","error_description":"Refresh token not found or invalid"}');
304
+ }
305
+ throw new Error("unexpected refresh");
306
+ });
307
+ rotationModule.__setLoginForProviderForTest(async (providerId) => {
308
+ loginCalls.push(providerId);
309
+ return { refresh: "refresh-reauthed", access: "access-reauthed", expires: Date.now() + 60_000 };
310
+ });
311
+ rotationModule.__setStreamForProviderForTest((_model, _context, options) => {
312
+ return (async function* () {
313
+ yield { type: "text_delta", text: `hello with ${options?.apiKey}` };
314
+ })() as any;
315
+ });
316
+
317
+ try {
318
+ await providerRotationExtension(pi);
319
+ await events.session_start?.({}, ctx);
320
+ const stream = providers["anthropic"].streamSimple({ provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" }, ctx);
321
+ const emitted = [] as any[];
322
+ for await (const event of stream) emitted.push(event);
323
+
324
+ assert.deepEqual(loginCalls, ["anthropic"]);
325
+ assert.deepEqual(setModelCalls, []);
326
+ assert.ok(emitted.some((e) => e.type === "text_delta"));
327
+ assert.ok(!emitted.some((e) => e.type === "error"));
328
+
329
+ // Reauth persisted to auth.json (the single owner), not the pool.
330
+ const authRaw = JSON.parse(readFileSync(authPath, "utf8"));
331
+ assert.equal(authRaw.anthropic.refresh, "refresh-reauthed");
332
+ assert.equal(authRaw.anthropic.access, "access-reauthed");
333
+
334
+ // Pool stays metadata-only after reauth too, with the cooldown cleared.
335
+ const [entry] = helpers.readSubscriptionPool("anthropic");
336
+ assert.deepEqual(Object.keys(entry).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
337
+ assert.equal(entry.rateLimitedUntil, 0);
338
+ } finally {
339
+ rotationModule.__setRefreshForProviderForTest(undefined);
340
+ rotationModule.__setLoginForProviderForTest(undefined);
341
+ rotationModule.__setStreamForProviderForTest(undefined);
342
+ }
343
+ });
344
+
345
+ // A locked auth.json write failure must not let default-slot reauth look successful or
346
+ // clear the pool's cooldown bookkeeping.
347
+ test("a locked default-slot reauth write failure leaves the pool cooled down and surfaces an error", async () => {
348
+ resetRotationState();
349
+ writeLadderConfig();
350
+ const authPath = join(home, ".pi", "agent", "auth.json");
351
+ mkdirSync(dirname(authPath), { recursive: true });
352
+ writeFileSync(
353
+ authPath,
354
+ `${JSON.stringify({
355
+ anthropic: { type: "oauth", refresh: "dead-refresh", access: "access-stale", expires: 1 },
356
+ })}\n`,
357
+ "utf8",
358
+ );
359
+ // Seed the metadata-only default pool entry for the default label.
360
+ helpers.readSubscriptionPool("anthropic");
361
+
362
+ const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
363
+ ctx.hasUI = true;
364
+ ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
365
+ ctx.ui.notify = () => {};
366
+ rotationModule.__setRefreshForProviderForTest(async (providerId) => {
367
+ if (providerId === "anthropic") {
368
+ throw new Error('HTTP request failed. status=400; body={"error":"invalid_grant","error_description":"Refresh token not found or invalid"}');
369
+ }
370
+ throw new Error("unexpected refresh");
371
+ });
372
+ rotationModule.__setLoginForProviderForTest(async () => ({ refresh: "refresh-reauthed", access: "access-reauthed", expires: Date.now() + 60_000 }));
373
+ rotationModule.__setStreamForProviderForTest((_model, _context, options) => {
374
+ return (async function* () {
375
+ yield { type: "text_delta", text: `hello with ${options?.apiKey}` };
376
+ })() as any;
377
+ });
378
+
379
+ try {
380
+ makeAuthFileReadOnly(authPath);
381
+ await providerRotationExtension(pi);
382
+ await events.session_start?.({}, ctx);
383
+ const stream = providers["anthropic"].streamSimple(
384
+ { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
385
+ ctx,
386
+ );
387
+ const emitted = [] as any[];
388
+ for await (const event of stream) emitted.push(event);
389
+
390
+ assert.deepEqual(setModelCalls, []);
391
+ assert.ok(emitted.some((e) => e.type === "error"));
392
+ assert.ok(!emitted.some((e) => e.type === "text_delta"));
393
+
394
+ const authRaw = JSON.parse(readFileSync(authPath, "utf8"));
395
+ assert.equal(authRaw.anthropic.refresh, "dead-refresh");
396
+
397
+ const [entry] = helpers.readSubscriptionPool("anthropic");
398
+ assert.equal(entry.label, "anthropic");
399
+ assert.ok(entry.rateLimitedUntil > Date.now() + 29 * 24 * 60 * 60 * 1000);
400
+ } finally {
401
+ makeAuthFileWritable(authPath);
402
+ rotationModule.__setRefreshForProviderForTest(undefined);
403
+ rotationModule.__setLoginForProviderForTest(undefined);
404
+ rotationModule.__setStreamForProviderForTest(undefined);
405
+ }
406
+ });
407
+
408
+ // A genuine invalid_grant through the default-slot lock seam cools the credential down
409
+ // and falls back cross-provider when UI is unavailable: expired OAuth in auth.json, a
410
+ // metadata-only default pool entry, an authenticated Codex fallback, and the real
411
+ // streamSimple path all participate.
412
+ test("a genuine invalid_grant through the default-slot lock with no UI reaches cooldown and falls back cross-provider", async () => {
413
+ resetRotationState();
414
+ writeLadderConfig();
415
+ const authPath = join(home, ".pi", "agent", "auth.json");
416
+ mkdirSync(dirname(authPath), { recursive: true });
417
+ writeFileSync(
418
+ authPath,
419
+ `${JSON.stringify({
420
+ anthropic: { type: "oauth", refresh: "dead-refresh", access: "access-stale", expires: 1 },
421
+ })}\n`,
422
+ "utf8",
423
+ );
424
+ // Seed the metadata-only default pool entry for anthropic -- the seam under test.
425
+ helpers.readSubscriptionPool("anthropic");
426
+ // Authenticated Codex fallback already sitting in its own pool.
427
+ helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
428
+
429
+ const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
430
+ ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
431
+ // ctx.hasUI is deliberately left falsy: reauth must be impossible, so the only path
432
+ // forward is cooldown + cross-provider fallback.
433
+ const streamedModels: any[] = [];
434
+ rotationModule.__setRefreshForProviderForTest(async (providerId) => {
435
+ if (providerId === "anthropic") {
436
+ // Plain Error, no typed .status: the refresh path reads the invalid_grant shape from
437
+ // the message text, matching production failures.
438
+ throw new Error('HTTP request failed. status=400; body={"error":"invalid_grant","error_description":"Refresh token not found or invalid"}');
439
+ }
440
+ throw new Error("unexpected refresh");
441
+ });
442
+ rotationModule.__setStreamForProviderForTest((model) => {
443
+ streamedModels.push(model);
444
+ return (async function* () {
445
+ yield { type: "text_delta", text: "hello from codex fallback" };
446
+ })() as any;
447
+ });
448
+
449
+ try {
450
+ await providerRotationExtension(pi);
451
+ await events.session_start?.({}, ctx);
452
+ const stream = providers["anthropic"].streamSimple(
453
+ { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
454
+ ctx,
455
+ );
456
+ const emitted = [] as any[];
457
+ for await (const event of stream) emitted.push(event);
458
+
459
+ // Fell back cross-provider and actually served the turn on Codex.
460
+ assert.deepEqual(setModelCalls.map((m) => m.provider), ["openai-codex"]);
461
+ assert.equal(streamedModels.length, 1);
462
+ assert.equal(streamedModels[0].provider, "openai-codex");
463
+ assert.ok(emitted.some((e) => e.type === "text_delta"));
464
+ assert.ok(!emitted.some((e) => e.type === "error"));
465
+
466
+ // The default slot's metadata entry got the ~30-day invalid-refresh-token cooldown,
467
+ // through the real `withLockAsync` seam -- not a bypass.
468
+ const [entry] = helpers.readSubscriptionPool("anthropic");
469
+ assert.equal(entry.label, "anthropic");
470
+ assert.ok(entry.rateLimitedUntil > Date.now() + 29 * 24 * 60 * 60 * 1000);
471
+ assert.deepEqual(Object.keys(entry).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
472
+
473
+ // No UI -- no reauth happened; auth.json still holds the dead credential unchanged.
474
+ const authRaw = JSON.parse(readFileSync(authPath, "utf8"));
475
+ assert.equal(authRaw.anthropic.refresh, "dead-refresh");
476
+ } finally {
477
+ rotationModule.__setRefreshForProviderForTest(undefined);
478
+ rotationModule.__setStreamForProviderForTest(undefined);
479
+ }
197
480
  });
198
481
 
199
482
  test("/provider-sub add still adds an explicit additional subscription alongside the default slot", () => {
@@ -205,6 +488,238 @@ test("/provider-sub add still adds an explicit additional subscription alongside
205
488
  assert.deepEqual(helpers.readSubscriptionPool("anthropic").map((entry) => entry.label), ["anthropic", "work"]);
206
489
  });
207
490
 
491
+ // /provider-sub <provider> add accepts the default label as the login-backed slot.
492
+ // The pool stays metadata-only for that label, and the registered command handler writes
493
+ // the credential to auth.json.
494
+
495
+ test("/provider-sub add with the default label persists the login credential to auth.json instead of discarding it", async () => {
496
+ resetRotationState();
497
+ const authPath = join(home, ".pi", "agent", "auth.json");
498
+
499
+ const { ctx, commands, pi } = makeRotationCtx();
500
+ ctx.hasUI = true;
501
+ ctx.ui.notify = () => {};
502
+ ctx.ui.input = async (_message: string, placeholder: string) => placeholder; // accept the prompt's own default
503
+ rotationModule.__setLoginForProviderForTest(async () => ({ refresh: "refresh-default-login", access: "access-default-login", expires: Date.now() + 60_000 }));
504
+
505
+ try {
506
+ await providerRotationExtension(pi);
507
+ await commands["provider-sub"].handler("anthropic add", ctx);
508
+
509
+ // The credential must be persisted to auth.json (the single owner), NOT discarded.
510
+ const authRaw = JSON.parse(readFileSync(authPath, "utf8"));
511
+ assert.equal(authRaw.anthropic.refresh, "refresh-default-login");
512
+ assert.equal(authRaw.anthropic.access, "access-default-login");
513
+
514
+ // The pool gets a metadata-only default entry (no value written into it) with the
515
+ // cooldown cleared -- not a broken "authenticated but valueless" slot.
516
+ const [entry] = helpers.readSubscriptionPool("anthropic");
517
+ assert.equal(entry.label, "anthropic");
518
+ assert.deepEqual(Object.keys(entry).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
519
+ assert.equal(entry.rateLimitedUntil, 0);
520
+ } finally {
521
+ rotationModule.__setLoginForProviderForTest(undefined);
522
+ }
523
+ });
524
+
525
+ // A locked auth.json write failure must not let the default-label add path clear the
526
+ // pool cooldown bookkeeping or pretend the add succeeded.
527
+ test("/provider-sub add with the default label keeps the pool cooldown when auth.json write fails", async () => {
528
+ resetRotationState();
529
+ const authPath = join(home, ".pi", "agent", "auth.json");
530
+ mkdirSync(dirname(authPath), { recursive: true });
531
+ writeFileSync(
532
+ authPath,
533
+ `${JSON.stringify({
534
+ anthropic: { type: "oauth", refresh: "refresh-existing", access: "access-existing", expires: Date.now() + 60_000 },
535
+ })}\n`,
536
+ "utf8",
537
+ );
538
+ helpers.readSubscriptionPool("anthropic");
539
+ helpers.markSubscriptionRateLimited("anthropic", "anthropic", 60_000, 100, 200);
540
+ const beforeCooldown = helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil;
541
+
542
+ const { ctx, commands, pi } = makeRotationCtx();
543
+ ctx.hasUI = true;
544
+ ctx.ui.notify = () => {};
545
+ ctx.ui.input = async (_message: string, placeholder: string) => placeholder;
546
+ rotationModule.__setLoginForProviderForTest(async () => ({ refresh: "refresh-default-login", access: "access-default-login", expires: Date.now() + 60_000 }));
547
+
548
+ try {
549
+ makeAuthFileReadOnly(authPath);
550
+ await providerRotationExtension(pi);
551
+ await commands["provider-sub"].handler("anthropic add", ctx);
552
+
553
+ const authRaw = JSON.parse(readFileSync(authPath, "utf8"));
554
+ assert.equal(authRaw.anthropic.refresh, "refresh-existing");
555
+ const [entry] = helpers.readSubscriptionPool("anthropic");
556
+ assert.equal(entry.label, "anthropic");
557
+ assert.equal(entry.rateLimitedUntil, beforeCooldown);
558
+ } finally {
559
+ makeAuthFileWritable(authPath);
560
+ rotationModule.__setLoginForProviderForTest(undefined);
561
+ }
562
+ });
563
+
564
+ // The default metadata slot is seeded whenever auth.json holds an OAuth credential for
565
+ // the provider, even when the pool already contains explicit non-default accounts.
566
+ test("seeds the default metadata slot even when the pool already has an explicit non-default account", () => {
567
+ resetRotationState();
568
+ helpers.writeSubscriptionPool("anthropic", [credential("work", { refresh: "refresh-work", access: "access-work" })]);
569
+
570
+ const authPath = join(home, ".pi", "agent", "auth.json");
571
+ mkdirSync(dirname(authPath), { recursive: true });
572
+ writeFileSync(
573
+ authPath,
574
+ `${JSON.stringify({
575
+ anthropic: { type: "oauth", refresh: "refresh-native-login", access: "access-native-login", expires: Date.now() + 60_000 },
576
+ })}\n`,
577
+ "utf8",
578
+ );
579
+
580
+ const pool = helpers.readSubscriptionPool("anthropic");
581
+ assert.deepEqual(pool.map((entry) => entry.label).sort(), ["anthropic", "work"]);
582
+ const defaultEntry = pool.find((entry) => entry.label === "anthropic");
583
+ assert.deepEqual(Object.keys(defaultEntry!).sort(), ["label", "lastAttemptAt", "lastRateLimitedAt", "rateLimitedUntil"]);
584
+ });
585
+
586
+ // An api_key auth.json record does not seed the default metadata slot.
587
+ test("does not seed a default metadata slot for an api_key auth.json record", () => {
588
+ resetRotationState();
589
+ const authPath = join(home, ".pi", "agent", "auth.json");
590
+ mkdirSync(dirname(authPath), { recursive: true });
591
+ writeFileSync(
592
+ authPath,
593
+ `${JSON.stringify({
594
+ anthropic: { type: "api_key", key: "sk-not-oauth" },
595
+ })}\n`,
596
+ "utf8",
597
+ );
598
+
599
+ assert.deepEqual(helpers.readSubscriptionPool("anthropic"), []);
600
+ });
601
+
602
+ // Hold the real auth.json lock in a separate process and prove the seed read fails
603
+ // visibly instead of silently returning an empty default slot while the file is locked.
604
+ test("locked auth.json seeding fails visibly instead of treating an existing default as absent", async () => {
605
+ resetRotationState();
606
+ const authPath = join(home, ".pi", "agent", "auth.json");
607
+ mkdirSync(dirname(authPath), { recursive: true });
608
+ writeFileSync(
609
+ authPath,
610
+ `${JSON.stringify({
611
+ anthropic: { type: "oauth", refresh: "refresh-locked", access: "access-locked", expires: Date.now() + 60_000 },
612
+ })}
613
+ `,
614
+ "utf8",
615
+ );
616
+
617
+ const subscriptionStateUrl = new URL("../../lib/subscription-state.ts", import.meta.url).href;
618
+ const readProbe = spawn(
619
+ process.execPath,
620
+ [
621
+ "--import",
622
+ "tsx/esm",
623
+ "--input-type=module",
624
+ "-e",
625
+ String.raw`const { readSubscriptionPool } = await import(${JSON.stringify(subscriptionStateUrl)});
626
+ process.stdout.write("ready\n");
627
+ await new Promise((resolve) => {
628
+ process.stdin.setEncoding("utf8");
629
+ process.stdin.once("data", resolve);
630
+ });
631
+ process.stdout.write("about-to-read\n");
632
+ const pool = readSubscriptionPool("anthropic");
633
+ process.stdout.write("result:" + JSON.stringify(pool) + "\n");`,
634
+ ],
635
+ { env: { ...process.env, HOME: home }, stdio: ["pipe", "pipe", "pipe"] },
636
+ );
637
+ let sawReady = false;
638
+ let sawResult = false;
639
+ let readStderr = "";
640
+ readProbe.stderr!.setEncoding("utf8");
641
+ readProbe.stderr!.on("data", (chunk: string) => {
642
+ readStderr += chunk;
643
+ });
644
+ const readReady = new Promise<void>((resolve, reject) => {
645
+ readProbe.once("error", reject);
646
+ readProbe.once("close", (code) => {
647
+ if (!sawReady) reject(new Error(`read-probe exited with ${code} before signalling readiness: ${readStderr.trim()}`));
648
+ });
649
+ observeLines(readProbe.stdout!, (line) => {
650
+ if (line === "ready") {
651
+ sawReady = true;
652
+ resolve();
653
+ return;
654
+ }
655
+ if (line.startsWith("result:")) sawResult = true;
656
+ });
657
+ });
658
+ const readDone = new Promise<number | null>((resolve, reject) => {
659
+ readProbe.once("error", reject);
660
+ readProbe.once("close", resolve);
661
+ });
662
+ await readReady;
663
+
664
+ const lockHolder = spawn(
665
+ process.execPath,
666
+ [
667
+ "--import",
668
+ "tsx/esm",
669
+ "--input-type=module",
670
+ "-e",
671
+ String.raw`import { FileAuthStorageBackend } from "@earendil-works/pi-coding-agent";
672
+ const backend = new FileAuthStorageBackend();
673
+ await backend.withLockAsync(async () => {
674
+ process.stdout.write("locked\n");
675
+ await new Promise((resolve) => {
676
+ process.stdin.setEncoding("utf8");
677
+ process.stdin.once("data", resolve);
678
+ });
679
+ return { result: undefined };
680
+ });`,
681
+ ],
682
+ { env: { ...process.env, HOME: home }, stdio: ["pipe", "pipe", "pipe"] },
683
+ );
684
+ let lockStderr = "";
685
+ lockHolder.stderr!.setEncoding("utf8");
686
+ lockHolder.stderr!.on("data", (chunk: string) => {
687
+ lockStderr += chunk;
688
+ });
689
+ let sawLocked = false;
690
+ const lockReady = new Promise<void>((resolve, reject) => {
691
+ lockHolder.once("error", reject);
692
+ lockHolder.once("close", (code) => {
693
+ if (!sawLocked) reject(new Error(`lock-holder exited with ${code} before signalling readiness: ${lockStderr.trim()}`));
694
+ });
695
+ observeLines(lockHolder.stdout!, (line) => {
696
+ if (line === "locked") {
697
+ sawLocked = true;
698
+ resolve();
699
+ }
700
+ });
701
+ });
702
+ const lockDone = new Promise<void>((resolve, reject) => {
703
+ lockHolder.once("error", reject);
704
+ lockHolder.once("close", (code) => {
705
+ if (code === 0) resolve();
706
+ else reject(new Error(`lock-holder exited with ${code}: ${lockStderr.trim()}`));
707
+ });
708
+ });
709
+ await lockReady;
710
+ readProbe.stdin!.write("go\n");
711
+ readProbe.stdin!.end();
712
+ const readExitCode = await readDone;
713
+ lockHolder.stdin!.write("release\n");
714
+ lockHolder.stdin!.end();
715
+
716
+ assert.equal(sawResult, false);
717
+ assert.notEqual(readExitCode, 0);
718
+ assert.match(readStderr, /ELOCKED|Lock file is already being held/);
719
+ await lockDone;
720
+ assert.deepEqual(helpers.readSubscriptionPool("anthropic"), [{ label: "anthropic", rateLimitedUntil: 0, lastAttemptAt: 0, lastRateLimitedAt: 0 }]);
721
+ });
722
+
208
723
  test("resolves Anthropic to strength-matched OpenAI Codex fallback", () => {
209
724
  resetRotationState();
210
725
  writeLadderConfig();
@@ -230,9 +745,8 @@ test("resolves OpenAI Codex to strength-matched Anthropic fallback", () => {
230
745
  });
231
746
  });
232
747
 
233
- // Regression: ultra (gpt-5.5:xhigh) and strong (gpt-5.5:high) share the bare model id
234
- // `gpt-5.5`. The strength matcher used to collapse both to `strong`, so an ultra run fell
235
- // back to opus instead of the ultra Anthropic model. The live thinking level disambiguates.
748
+ // Ultra and strong share the bare model id gpt-5.5; the live thinking level selects
749
+ // the matching ladder rung.
236
750
  test("disambiguates colliding ladder rungs by the live thinking level", () => {
237
751
  resetRotationState();
238
752
  writeLadderConfig();
@@ -250,7 +764,7 @@ test("disambiguates colliding ladder rungs by the live thinking level", () => {
250
764
  // high -> strong rung -> Anthropic strong (claude-opus-4-8)
251
765
  assert.equal(helpers.resolveFallbackTarget("openai-codex", "gpt-5.5", config, "high").modelId, "claude-opus-4-8");
252
766
 
253
- // No thinking level: preserves the legacy default-strength tie-break (strong -> opus).
767
+ // No thinking level falls back to the default-strength tie-break (strong -> opus).
254
768
  assert.equal(helpers.resolveFallbackTarget("openai-codex", "gpt-5.5", config).modelId, "claude-opus-4-8");
255
769
  });
256
770
 
@@ -445,11 +959,14 @@ function makeRotationCtx() {
445
959
  const setModelCalls: any[] = [];
446
960
  const providers: Record<string, { streamSimple: (...args: any[]) => AsyncIterable<any> }> = {};
447
961
  const events: Record<string, (...args: any[]) => unknown> = {};
962
+ const commands: Record<string, { handler: (args: string, ctx: any) => unknown }> = {};
448
963
  const pi = {
449
964
  registerProvider(name: string, provider: { streamSimple: (...args: any[]) => AsyncIterable<any> }) {
450
965
  providers[name] = provider;
451
966
  },
452
- registerCommand() {},
967
+ registerCommand(name: string, command: { handler: (args: string, ctx: any) => unknown }) {
968
+ commands[name] = command;
969
+ },
453
970
  on(eventName: string, handler: (...args: any[]) => unknown) {
454
971
  events[eventName] = handler;
455
972
  },
@@ -460,12 +977,10 @@ function makeRotationCtx() {
460
977
  },
461
978
  setThinkingLevel() {},
462
979
  } as any;
463
- return { ctx, setModelCalls, providers, events, pi };
980
+ return { ctx, setModelCalls, providers, events, commands, pi };
464
981
  }
465
982
 
466
- // Regression: Codex-exhausted fallback used to switch the model and then FAIL the turn
467
- // with a "retry on fallback" error, stranding autonomous nodes. It must now switch AND
468
- // transparently serve the same turn on the fallback provider.
983
+ // Codex exhaustion switches the model and serves the same turn on the Anthropic fallback.
469
984
  test("falls back transparently and serves the turn on Anthropic when Codex is exhausted", async () => {
470
985
  resetRotationState();
471
986
  writeLadderConfig();
@@ -503,15 +1018,12 @@ test("falls back transparently and serves the turn on Anthropic when Codex is ex
503
1018
  }
504
1019
  });
505
1020
 
506
- // Regression: request timeouts (and other transient network/5xx blips) used to be treated
507
- // exactly like a genuine rate limit -- cooling the credential down for 5 minutes and
508
- // rotating away from a perfectly healthy subscription over one blip (#122). They must
509
- // instead get a short in-place retry on the SAME credential/provider, no cooldown, no
510
- // rotation -- proven here by recovering on the second attempt without ever switching models.
1021
+ // Request timeouts and other transient network/5xx blips retry the same provider in place
1022
+ // without cooling the credential down or rotating away.
511
1023
  test("treats a request timeout as transient: retries the SAME provider in place, no cooldown or rotation", async () => {
512
1024
  resetRotationState();
513
1025
  writeLadderConfig();
514
- helpers.writeSubscriptionPool("anthropic", [credential("anthropic")]);
1026
+ helpers.writeSubscriptionPool("anthropic", [credential("primary")]);
515
1027
  helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
516
1028
 
517
1029
  const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
@@ -568,7 +1080,7 @@ test("treats a request timeout as transient: retries the SAME provider in place,
568
1080
  test("ends the turn without rotating once the transient retry budget is exhausted", async () => {
569
1081
  resetRotationState();
570
1082
  writeLadderConfig();
571
- helpers.writeSubscriptionPool("anthropic", [credential("anthropic")]);
1083
+ helpers.writeSubscriptionPool("anthropic", [credential("primary")]);
572
1084
  helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
573
1085
 
574
1086
  const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
@@ -616,7 +1128,7 @@ test("ends the turn without rotating once the transient retry budget is exhauste
616
1128
  test("treats HTTP 429 as a genuine rate limit: rotates immediately using the Retry-After header, no in-place retry", async () => {
617
1129
  resetRotationState();
618
1130
  writeLadderConfig();
619
- helpers.writeSubscriptionPool("anthropic", [credential("anthropic")]);
1131
+ helpers.writeSubscriptionPool("anthropic", [credential("primary")]);
620
1132
  helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
621
1133
 
622
1134
  const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
@@ -665,7 +1177,7 @@ test("treats HTTP 429 as a genuine rate limit: rotates immediately using the Ret
665
1177
  test("does not cool down credentials or switch providers when the user aborts", async () => {
666
1178
  resetRotationState();
667
1179
  writeLadderConfig();
668
- helpers.writeSubscriptionPool("anthropic", [credential("anthropic")]);
1180
+ helpers.writeSubscriptionPool("anthropic", [credential("primary")]);
669
1181
  helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
670
1182
 
671
1183
  const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
@@ -726,9 +1238,11 @@ test("skips invalid refresh tokens and serves the turn on the fallback provider"
726
1238
  const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
727
1239
  ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
728
1240
  const streamedModels: any[] = [];
1241
+ // Plain Error, no typed .status: the default-slot path still recognizes invalid_grant
1242
+ // from the message text.
729
1243
  rotationModule.__setRefreshForProviderForTest(async (providerId) => {
730
1244
  if (providerId === "anthropic") {
731
- throw Object.assign(new Error('HTTP request failed. status=400; body={"error":"invalid_grant","error_description":"Refresh token not found or invalid"}'), { status: 400 });
1245
+ throw new Error('HTTP request failed. status=400; body={"error":"invalid_grant","error_description":"Refresh token not found or invalid"}');
732
1246
  }
733
1247
  throw new Error("unexpected refresh");
734
1248
  });
@@ -773,9 +1287,11 @@ test("automatically reauthenticates invalid refresh tokens when UI is available"
773
1287
  ctx.ui.notify = () => {};
774
1288
  const loginCalls: string[] = [];
775
1289
  const streamedOptions: any[] = [];
1290
+ // Plain Error, no typed .status: the default-slot path still recognizes invalid_grant
1291
+ // from the message text.
776
1292
  rotationModule.__setRefreshForProviderForTest(async (providerId) => {
777
1293
  if (providerId === "anthropic") {
778
- throw Object.assign(new Error('HTTP request failed. status=400; body={"error":"invalid_grant","error_description":"Refresh token not found or invalid"}'), { status: 400 });
1294
+ throw new Error('HTTP request failed. status=400; body={"error":"invalid_grant","error_description":"Refresh token not found or invalid"}');
779
1295
  }
780
1296
  throw new Error("unexpected refresh");
781
1297
  });
@@ -816,11 +1332,8 @@ test("automatically reauthenticates invalid refresh tokens when UI is available"
816
1332
  }
817
1333
  });
818
1334
 
819
- // Regression (#123): a resolved-but-unauthenticated fallback used to still get switched
820
- // to blindly (the session model would flip to a provider nobody logged into) before
821
- // finally giving up. Neither provider has ever been authenticated here at all, so there
822
- // is nothing viable to switch to -- it must NOT touch the session model, and the terminal
823
- // message must be actionable ("not authenticated") rather than a generic exhaustion notice.
1335
+ // A resolved-but-unauthenticated fallback stays on the current model and returns an
1336
+ // actionable "not authenticated" error when no provider is logged in.
824
1337
  test("emits an actionable 'not authenticated' error without switching models when neither provider has ever logged in", async () => {
825
1338
  resetRotationState();
826
1339
  writeLadderConfig();
@@ -840,16 +1353,8 @@ test("emits an actionable 'not authenticated' error without switching models whe
840
1353
  assert.match(emitted[0].error.errorMessage, /OpenAI Codex not authenticated.*crtr sys setup/);
841
1354
  });
842
1355
 
843
- // Regression: a config-less user (fresh install, no ~/.crouter/config.json at all
844
- // the common case) used to have `readModelLadders()` return nothing, so the moment
845
- // the (empty) Anthropic pool needed a fallback hop, resolution failed with "Unable to
846
- // resolve fallback model for strength strong; configure a openai ladder in
847
- // ~/.crouter/config.json". `readModelLadders()` now always falls back to crouter's
848
- // builtin anthropic/openai ladder (mirroring what crtr itself launches nodes with), so
849
- // the hop resolves to a real target -- but this user genuinely has no working OpenAI
850
- // credentials either, so (#123) it must degrade to an actionable "not authenticated"
851
- // terminal error WITHOUT ever switching the session model to that unauthed target, never
852
- // the raw config error.
1356
+ // The builtin ladder is available even with no ~/.crouter/config.json, so a config-less
1357
+ // user can still resolve cross-provider fallbacks.
853
1358
  test("degrades gracefully via the builtin ladder when a config-less user has no cross-provider credentials", async () => {
854
1359
  resetRotationState();
855
1360
  rmSync(join(home, ".crouter", "config.json"), { force: true });
@@ -889,7 +1394,7 @@ test("degrades gracefully via the builtin ladder when a config-less user has no
889
1394
  // Anthropic pool is empty (reset above), so the credential loop never runs. The builtin
890
1395
  // ladder resolves a cross-provider target (openai-codex/gpt-5.5), but that provider was
891
1396
  // never authenticated either (#123 gates fallback on the target actually being authed),
892
- // so it must NOT switch the session model to it -- straight to an actionable message.
1397
+ // so the session model stays unchanged and the turn returns an actionable message.
893
1398
  const stream = providers["anthropic"].streamSimple(ctx.model, ctx);
894
1399
  const emitted = [] as any[];
895
1400
  for await (const event of stream) emitted.push(event);
@@ -901,14 +1406,7 @@ test("degrades gracefully via the builtin ladder when a config-less user has no
901
1406
  assert.ok(!emitted[0].error.errorMessage.includes("configure a openai ladder"));
902
1407
  });
903
1408
 
904
- // Regression: this is the actual root-cause fix `readModelLadders()` used to read
905
- // ONLY the literal ~/.crouter/config.json with no builtin default, so a config-less
906
- // user (fresh install, only logged into Anthropic) saw an EMPTY ladder even though
907
- // crtr itself launches nodes on the builtin anthropic/openai defaults
908
- // (`defaultModelLaddersConfig()` in src/types.ts). Prove the builtin ladder resolves
909
- // on its own, with no config.json present at all: the current model's own strength tier
910
- // (medium = claude-sonnet-5) must match, and a same-strength cross-provider target
911
- // must resolve without throwing.
1409
+ // readModelLadders() reads the builtin anthropic/openai ladder when config.json is absent.
912
1410
  test("resolves the builtin anthropic ladder (medium = sonnet-5) with no ~/.crouter/config.json present", () => {
913
1411
  resetRotationState();
914
1412
  rmSync(join(home, ".crouter", "config.json"), { force: true });
@@ -924,16 +1422,13 @@ test("resolves the builtin anthropic ladder (medium = sonnet-5) with no ~/.crout
924
1422
  });
925
1423
  });
926
1424
 
927
- // Regression (#123a): a SOLE authenticated provider that is merely cooling down (its one
928
- // credential is rate-limited, but not permanently broken) used to jump straight to a
929
- // fallback/terminal failure the instant its pool went empty, instead of waiting out the
930
- // (short) remaining cooldown. With no OpenAI credentials at all to fall back to, it must
931
- // sleep out the shortest rateLimitedUntil and retry the SAME provider -- and succeed.
1425
+ // A sole provider that is merely cooling down waits out the cooldown when no authenticated
1426
+ // fallback exists.
932
1427
  test("waits out a sole provider's cooldown and retries it when no authenticated fallback exists", async () => {
933
1428
  resetRotationState();
934
1429
  writeLadderConfig();
935
- helpers.writeSubscriptionPool("anthropic", [credential("anthropic")]);
936
- helpers.markSubscriptionRateLimited("anthropic", "anthropic", 30_000);
1430
+ helpers.writeSubscriptionPool("anthropic", [credential("primary")]);
1431
+ helpers.markSubscriptionRateLimited("anthropic", "primary", 30_000);
937
1432
 
938
1433
  const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
939
1434
  ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
@@ -973,15 +1468,13 @@ test("waits out a sole provider's cooldown and retries it when no authenticated
973
1468
  }
974
1469
  });
975
1470
 
976
- // Regression (#123b): a cooldown long enough that it isn't worth an automatic wait (e.g.
977
- // the 30-day invalid-refresh-token backoff also lands in `rateLimitedUntil`) must NOT be
978
- // slept out -- it needs reauth, not patience. It must fall through to an actionable
979
- // terminal error instead, and must never call the sleep seam at all.
1471
+ // Cooldowns longer than the sole-provider wait cap return an actionable error instead of
1472
+ // sleeping.
980
1473
  test("does not wait out a cooldown longer than the sole-provider wait cap; surfaces an actionable error instead", async () => {
981
1474
  resetRotationState();
982
1475
  writeLadderConfig();
983
- helpers.writeSubscriptionPool("anthropic", [credential("anthropic")]);
984
- helpers.markSubscriptionRateLimited("anthropic", "anthropic", 30 * 24 * 60 * 60 * 1000);
1476
+ helpers.writeSubscriptionPool("anthropic", [credential("primary")]);
1477
+ helpers.markSubscriptionRateLimited("anthropic", "primary", 30 * 24 * 60 * 60 * 1000);
985
1478
 
986
1479
  const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
987
1480
  ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
@@ -1010,19 +1503,11 @@ test("does not wait out a cooldown longer than the sole-provider wait cap; surfa
1010
1503
  }
1011
1504
  });
1012
1505
 
1013
- // Regression (review of 10c72d4, Bug 1): the wait-out decision used to be computed from
1014
- // the `rawPool` snapshot taken BEFORE the credential loop ran, not from the pool the loop
1015
- // just mutated via `markSubscriptionRateLimited`. So when the SOLE credential gets rate-
1016
- // limited in THIS SAME call (as opposed to having been pre-marked before the turn even
1017
- // started, like the #123a test above), the stale snapshot still shows `rateLimitedUntil: 0`
1018
- // for every entry, `Math.min(...)` comes out to 0, `waitMs` goes negative, and the code
1019
- // falls straight through to "All managed provider pools exhausted" instead of sleeping out
1020
- // the (short) real cooldown. Proves the fix: a fresh 429 with Retry-After: 1s on the only
1021
- // credential, no authenticated fallback, must sleep and retry -- never emit exhaustion.
1506
+ // The wait-out decision uses the pool state from the current turn, not a stale snapshot.
1022
1507
  test("waits out a sole credential's cooldown observed in THIS turn, not a stale pre-attempt pool snapshot", async () => {
1023
1508
  resetRotationState();
1024
1509
  writeLadderConfig();
1025
- helpers.writeSubscriptionPool("anthropic", [credential("anthropic")]);
1510
+ helpers.writeSubscriptionPool("anthropic", [credential("primary")]);
1026
1511
  // openai-codex pool stays empty -- no authenticated fallback to switch to.
1027
1512
 
1028
1513
  const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
@@ -1057,9 +1542,9 @@ test("waits out a sole credential's cooldown observed in THIS turn, not a stale
1057
1542
  const emitted = [] as any[];
1058
1543
  for await (const event of stream) emitted.push(event);
1059
1544
 
1060
- // Never switches providers -- there is nothing authenticated to switch to.
1545
+ // No authenticated fallback exists, so the provider stays put and the turn waits once.
1061
1546
  assert.deepEqual(setModelCalls, []);
1062
- // The bug: it used to skip straight to "exhausted" without ever sleeping.
1547
+ // The current-turn cooldown path sleeps before retrying.
1063
1548
  assert.equal(sleepCalls.length, 1);
1064
1549
  assert.ok(sleepCalls[0] > 0 && sleepCalls[0] <= 1000);
1065
1550
  assert.equal(anthropicCalls, 2);
@@ -1071,12 +1556,9 @@ test("waits out a sole credential's cooldown observed in THIS turn, not a stale
1071
1556
  }
1072
1557
  });
1073
1558
 
1074
- // Regression (review of 10c72d4, Bug 2): pi-ai's Codex adapter can surface a genuine
1075
- // usage-limit exhaustion (`usage_limit_reached` / "You have hit your ChatGPT usage
1076
- // limit...") on a NON-429 status. The classifier used to only recognize 429/503-with-
1077
- // retry-after or `rate.?limit|too many requests` text, so this landed in the `fatal`
1078
- // bucket -- it neither cooled the credential down nor rotated to the authenticated
1079
- // fallback. Prove it now cools down AND falls back transparently, serving the same turn.
1559
+ // pi-ai's Codex adapter can surface a genuine usage-limit exhaustion on a non-429
1560
+ // status, and the classifier treats it as a rate limit so the credential cools down and
1561
+ // the authenticated fallback serves the same turn.
1080
1562
  test("treats a Codex usage-limit message on a non-429 status as a rate limit: cools down and falls back", async () => {
1081
1563
  resetRotationState();
1082
1564
  writeLadderConfig();