@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.
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +612 -130
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +118 -31
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +60 -60
- package/dist/web-client/assets/index-CKvdUlkn.css +2 -0
- package/dist/web-client/assets/index-FWBlgUV2.js +77 -0
- package/dist/web-client/index.html +2 -2
- package/package.json +1 -1
- package/dist/web-client/assets/index-DNmTmdXt.js +0 -77
- package/dist/web-client/assets/index-DuyYI5Sg.css +0 -2
package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { before, after, test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
//
|
|
234
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
507
|
-
//
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
|
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
|
|
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
|
-
//
|
|
820
|
-
//
|
|
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
|
-
//
|
|
844
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
928
|
-
//
|
|
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("
|
|
936
|
-
helpers.markSubscriptionRateLimited("anthropic", "
|
|
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
|
-
//
|
|
977
|
-
//
|
|
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("
|
|
984
|
-
helpers.markSubscriptionRateLimited("anthropic", "
|
|
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
|
-
//
|
|
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("
|
|
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
|
-
//
|
|
1545
|
+
// No authenticated fallback exists, so the provider stays put and the turn waits once.
|
|
1061
1546
|
assert.deepEqual(setModelCalls, []);
|
|
1062
|
-
// The
|
|
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
|
-
//
|
|
1075
|
-
//
|
|
1076
|
-
//
|
|
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();
|