@maxiaochao/pi-toolkit 0.11.7 → 0.11.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +1 -0
  3. package/package.json +5 -3
  4. package/packages/openai-account-pool/CHANGELOG.md +11 -0
  5. package/packages/openai-account-pool/README.md +67 -0
  6. package/packages/openai-account-pool/TESTING.md +28 -0
  7. package/packages/openai-account-pool/docs/demo.gif +0 -0
  8. package/packages/openai-account-pool/docs/screenshot.png +0 -0
  9. package/packages/openai-account-pool/extensions/openai-account-pool.ts +368 -0
  10. package/packages/openai-account-pool/package.json +45 -0
  11. package/packages/openai-account-pool/src/auth.ts +418 -0
  12. package/packages/openai-account-pool/src/availability.ts +230 -0
  13. package/packages/openai-account-pool/src/command-completions.ts +18 -0
  14. package/packages/openai-account-pool/src/enrollment.ts +339 -0
  15. package/packages/openai-account-pool/src/lease.ts +128 -0
  16. package/packages/openai-account-pool/src/lock.ts +171 -0
  17. package/packages/openai-account-pool/src/login-slot.ts +158 -0
  18. package/packages/openai-account-pool/src/policy.ts +176 -0
  19. package/packages/openai-account-pool/src/provider.ts +511 -0
  20. package/packages/openai-account-pool/src/status-widget.ts +62 -0
  21. package/packages/openai-account-pool/src/store.ts +259 -0
  22. package/packages/openai-account-pool/src/usage.ts +291 -0
  23. package/packages/openai-account-pool/tests/command-completions.mjs +10 -0
  24. package/packages/openai-account-pool/tests/enrollment.mjs +322 -0
  25. package/packages/openai-account-pool/tests/failover-stream.mjs +554 -0
  26. package/packages/openai-account-pool/tests/helpers/fixtures.mjs +40 -0
  27. package/packages/openai-account-pool/tests/helpers/native-credential-store.mjs +58 -0
  28. package/packages/openai-account-pool/tests/helpers/pi-runtime.mjs +24 -0
  29. package/packages/openai-account-pool/tests/helpers/refresh-child.mjs +29 -0
  30. package/packages/openai-account-pool/tests/integration-login-ownership.mjs +299 -0
  31. package/packages/openai-account-pool/tests/login-slot.mjs +131 -0
  32. package/packages/openai-account-pool/tests/multiprocess.mjs +73 -0
  33. package/packages/openai-account-pool/tests/selection.mjs +286 -0
  34. package/packages/openai-account-pool/tests/store-ownership.mjs +369 -0
  35. package/packages/openai-account-pool/tests/usage.mjs +428 -0
@@ -0,0 +1,554 @@
1
+ import assert from "node:assert/strict";
2
+
3
+ import { accountIdOf, commitAccount, loadPoolSnapshot, POOL_UNRESOLVED_API_KEY } from "../src/auth.ts";
4
+ import { recordFailure, setPreferredAccountId } from "../src/availability.ts";
5
+ import { createPoolProvider } from "../src/provider.ts";
6
+ import { poolStatePathFor, readPoolState } from "../src/store.ts";
7
+ import { fakeAccessToken, fakeCredential, makeAgentDir } from "./helpers/fixtures.mjs";
8
+ import { piAiEntry } from "./helpers/pi-runtime.mjs";
9
+
10
+ const { createAssistantMessageEventStream, lazyStream } = await import(piAiEntry());
11
+
12
+ const MODEL = {
13
+ id: "gpt-5-codex",
14
+ name: "GPT-5 Codex",
15
+ api: "openai-codex-responses",
16
+ provider: "openai-codex",
17
+ baseUrl: "https://chatgpt.com/backend-api/codex",
18
+ input: ["text"],
19
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
20
+ };
21
+ const CONTEXT = { messages: [] };
22
+ const ACCOUNT_A = "account-aaaa";
23
+ const ACCOUNT_B = "account-bbbb";
24
+
25
+ function assistantMessage(text, { error } = {}) {
26
+ return {
27
+ role: "assistant",
28
+ content: text ? [{ type: "text", text }] : [],
29
+ api: MODEL.api,
30
+ provider: MODEL.provider,
31
+ model: MODEL.id,
32
+ usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
33
+ stopReason: error ? "error" : "stop",
34
+ ...(error ? { errorMessage: error } : {}),
35
+ timestamp: new Date().toISOString(),
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Stands in for the built-in Codex adapter: it performs one HTTP call through the injected
41
+ * fetch, then reports success or a provider-shaped error event, exactly like the real one.
42
+ */
43
+ function scriptedProvider(steps, log) {
44
+ const run = async (options, stream) => {
45
+ const step = steps.shift() ?? { kind: "ok", text: "unscripted" };
46
+ log.attempts.push({ apiKey: options?.apiKey, step: step.kind });
47
+ const response = await options.fetch("https://chatgpt.com/backend-api/codex/responses", { method: "POST" });
48
+ if (!response.ok) {
49
+ // The real adapter turns any usage-limit-ish failure into the same friendly sentence.
50
+ const friendly = response.status === 429 ? "You have hit your ChatGPT usage limit." : `Request failed (${response.status})`;
51
+ stream.push({ type: "error", reason: "error", error: assistantMessage(undefined, { error: friendly }) });
52
+ return;
53
+ }
54
+ await options.onProviderStreamEvent?.({ type: "response.created" }, MODEL);
55
+ const partial = assistantMessage(step.text ?? "");
56
+ stream.push({ type: "start", partial });
57
+ if (step.kind !== "ok-empty") {
58
+ stream.push({ type: "text_start", contentIndex: 0, partial });
59
+ stream.push({ type: "text_delta", contentIndex: 0, delta: step.text, partial });
60
+ stream.push({ type: "text_end", contentIndex: 0, content: step.text, partial });
61
+ }
62
+ stream.push({ type: "done", reason: "stop", message: partial });
63
+ };
64
+
65
+ const start = (model, context, options) => {
66
+ const stream = createAssistantMessageEventStream();
67
+ run(options, stream).catch((error) => {
68
+ stream.push({ type: "error", reason: "error", error: assistantMessage(undefined, { error: String(error) }) });
69
+ });
70
+ return stream;
71
+ };
72
+
73
+ return {
74
+ id: "openai-codex",
75
+ name: "OpenAI Codex",
76
+ baseUrl: "https://chatgpt.com/backend-api",
77
+ auth: {
78
+ oauth: {
79
+ name: "OpenAI (ChatGPT Plus/Pro)",
80
+ isSubscription: true,
81
+ login: async () => {
82
+ throw new Error("not used in this test");
83
+ },
84
+ refresh: async (credential) => {
85
+ const accountId = accountIdOf(credential);
86
+ log.refreshes.push(accountId);
87
+ return { type: "oauth", access: fakeAccessToken(accountId, "rotated"), refresh: `${credential.refresh}-next`, expires: Date.now() + 3_600_000, accountId };
88
+ },
89
+ toAuth: async (credential) => ({ apiKey: credential.access }),
90
+ },
91
+ },
92
+ getModels: () => [MODEL],
93
+ stream: start,
94
+ streamSimple: start,
95
+ };
96
+ }
97
+
98
+ function jsonResponse(status, payload) {
99
+ return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
100
+ }
101
+
102
+ /** Caller-supplied fetch: proves the pool passes the caller's own instrumentation through. */
103
+ function scriptedFetch(responses, log) {
104
+ return async (input, init) => {
105
+ log.fetches.push(String(input));
106
+ const next = responses.shift();
107
+ return next ? next() : new Response("{}", { status: 200 });
108
+ };
109
+ }
110
+
111
+ async function collect(stream) {
112
+ const events = [];
113
+ for await (const event of stream) events.push(event);
114
+ return events;
115
+ }
116
+
117
+ async function setupPool(accounts = [ACCOUNT_A, ACCOUNT_B]) {
118
+ const agent = makeAgentDir();
119
+ for (const accountId of accounts) {
120
+ await commitAccount({ authPath: agent.authPath, credential: fakeCredential(accountId) });
121
+ }
122
+ return { agent, statePath: poolStatePathFor(agent.directory) };
123
+ }
124
+
125
+ function exhaustedUsageResponse() {
126
+ return jsonResponse(200, {
127
+ rate_limit: {
128
+ allowed: false,
129
+ limit_reached: true,
130
+ primary_window: { used_percent: 100, limit_window_seconds: 18_000, reset_at: Math.floor(Date.now() / 1_000) + 1_800 },
131
+ },
132
+ });
133
+ }
134
+
135
+ function poolRuntime(agent, statePath, notes, usageFetch = async () => exhaustedUsageResponse()) {
136
+ return { lazyStream, authPath: agent.authPath, statePath, probeOwner: "test-owner", notify: (message) => notes.push(message), usageFetch };
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // A proven subscription limit switches accounts, invisibly to the caller.
141
+ // ---------------------------------------------------------------------------
142
+ {
143
+ const { agent, statePath } = await setupPool();
144
+ try {
145
+ const log = { attempts: [], refreshes: [], fetches: [] };
146
+ const notes = [];
147
+ const base = scriptedProvider([{ kind: "quota" }, { kind: "ok", text: "hello" }], log);
148
+ const provider = createPoolProvider(base, poolRuntime(agent, statePath, notes));
149
+ const observed = [];
150
+
151
+ const events = await collect(
152
+ provider.stream(MODEL, CONTEXT, {
153
+ apiKey: POOL_UNRESOLVED_API_KEY,
154
+ fetch: scriptedFetch([() => jsonResponse(429, { error: { code: "usage_limit_reached", plan_type: "plus", resets_at: Math.floor(Date.now() / 1_000) + 1_800 } })], log),
155
+ onProviderStreamEvent: (data) => observed.push(data),
156
+ }),
157
+ );
158
+
159
+ assert.equal(events.filter((event) => event.type === "start").length, 1, "the caller sees exactly one response start");
160
+ assert.equal(events.filter((event) => event.type === "error").length, 0, "the failed attempt is never shown");
161
+ assert.equal(events.at(-1).type, "done");
162
+ assert.equal(events.at(-1).message.content[0].text, "hello");
163
+ assert.equal(log.attempts.length, 2, "both accounts were tried once");
164
+ assert.equal(log.fetches.length, 2, "the caller's own fetch stayed in the request path");
165
+ assert.deepEqual(observed, [{ type: "response.created" }], "the caller's stream observer still fires");
166
+
167
+ const usedTokens = log.attempts.map((attempt) => attempt.apiKey);
168
+ assert.ok(!usedTokens.includes(POOL_UNRESOLVED_API_KEY), "the placeholder never reaches the provider");
169
+ assert.notEqual(usedTokens[0], usedTokens[1], "the retry used a different account");
170
+ assert.equal(notes.length, 1);
171
+ assert.match(notes[0], /额度已用尽/);
172
+ assert.ok(!notes[0].includes(ACCOUNT_A), "account ids are masked in user-visible notes");
173
+
174
+ const state = await readPoolState(statePath);
175
+ assert.equal(state.accounts[ACCOUNT_A].cooldowns.account.reason, "usage_limit_reached");
176
+ assert.equal(state.accounts[ACCOUNT_B], undefined, "a working account records nothing");
177
+
178
+ // Sticky: the next request starts on the account that just worked.
179
+ const second = { attempts: log.attempts, refreshes: log.refreshes, fetches: log.fetches };
180
+ base.__steps = undefined;
181
+ const followUp = createPoolProvider(scriptedProvider([{ kind: "ok", text: "again" }], second), poolRuntime(agent, statePath, notes));
182
+ await collect(followUp.stream(MODEL, CONTEXT, { apiKey: POOL_UNRESOLVED_API_KEY, fetch: scriptedFetch([], second) }));
183
+ assert.equal(second.attempts.at(-1).apiKey, usedTokens[1], "the limited account is skipped while it is cooling down");
184
+ } finally {
185
+ agent.dispose();
186
+ }
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Plain rate limiting looks identical to the user but must not burn the pool.
191
+ // ---------------------------------------------------------------------------
192
+ {
193
+ const { agent, statePath } = await setupPool();
194
+ try {
195
+ const log = { attempts: [], refreshes: [], fetches: [] };
196
+ const notes = [];
197
+ const base = scriptedProvider([{ kind: "rate-limit" }, { kind: "ok", text: "never reached" }], log);
198
+ const provider = createPoolProvider(base, poolRuntime(agent, statePath, notes));
199
+
200
+ const events = await collect(
201
+ provider.stream(MODEL, CONTEXT, {
202
+ apiKey: POOL_UNRESOLVED_API_KEY,
203
+ fetch: scriptedFetch([() => jsonResponse(429, { error: { message: "You have hit your ChatGPT usage limit." } })], log),
204
+ }),
205
+ );
206
+
207
+ assert.equal(log.attempts.length, 1, "a misleading usage-limit message is not evidence of a subscription limit");
208
+ assert.equal(events.at(-1).type, "error");
209
+ assert.match(events.at(-1).error.errorMessage, /usage limit/);
210
+ assert.deepEqual((await readPoolState(statePath)).accounts, {}, "no cooldown is recorded for ordinary rate limiting");
211
+ assert.deepEqual(notes, []);
212
+ } finally {
213
+ agent.dispose();
214
+ }
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // A limit that arrives after output ends this request but still informs the next one.
219
+ // ---------------------------------------------------------------------------
220
+ {
221
+ const { agent, statePath } = await setupPool();
222
+ try {
223
+ const log = { attempts: [], refreshes: [], fetches: [] };
224
+ const base = scriptedProvider([], log);
225
+ // Emit real output first, then fail with a proven limit.
226
+ base.stream = (model, context, options) => {
227
+ const stream = createAssistantMessageEventStream();
228
+ (async () => {
229
+ log.attempts.push({ apiKey: options.apiKey, step: "partial" });
230
+ const partial = assistantMessage("half");
231
+ stream.push({ type: "start", partial });
232
+ stream.push({ type: "text_delta", contentIndex: 0, delta: "half", partial });
233
+ await options.fetch("https://chatgpt.com/backend-api/codex/responses", {});
234
+ stream.push({ type: "error", reason: "error", error: assistantMessage(undefined, { error: "You have hit your ChatGPT usage limit." }) });
235
+ })();
236
+ return stream;
237
+ };
238
+ const provider = createPoolProvider(base, poolRuntime(agent, statePath, []));
239
+
240
+ const events = await collect(
241
+ provider.stream(MODEL, CONTEXT, {
242
+ apiKey: POOL_UNRESOLVED_API_KEY,
243
+ fetch: scriptedFetch([() => jsonResponse(429, { error: { code: "usage_limit_reached", plan_type: "plus" } })], log),
244
+ }),
245
+ );
246
+
247
+ assert.equal(log.attempts.length, 1, "delivered output can never be replayed on another account");
248
+ assert.deepEqual(events.map((event) => event.type), ["start", "text_delta", "error"]);
249
+ const state = await readPoolState(statePath);
250
+ assert.equal(state.accounts[ACCOUNT_A].cooldowns.account.reason, "usage_limit_reached", "the next request will skip this account");
251
+ } finally {
252
+ agent.dispose();
253
+ }
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // A rejected token is repaired once on the same account before any switch.
258
+ // ---------------------------------------------------------------------------
259
+ {
260
+ const { agent, statePath } = await setupPool();
261
+ try {
262
+ const log = { attempts: [], refreshes: [], fetches: [] };
263
+ const base = scriptedProvider([{ kind: "unauthorized" }, { kind: "ok", text: "recovered" }], log);
264
+ const provider = createPoolProvider(base, poolRuntime(agent, statePath, []));
265
+
266
+ const events = await collect(
267
+ provider.stream(MODEL, CONTEXT, {
268
+ apiKey: POOL_UNRESOLVED_API_KEY,
269
+ fetch: scriptedFetch([() => jsonResponse(401, { error: { message: "Unauthorized" } })], log),
270
+ }),
271
+ );
272
+
273
+ assert.deepEqual(log.refreshes, [ACCOUNT_A], "the rejected credential is rotated exactly once");
274
+ assert.equal(log.attempts.length, 2);
275
+ assert.equal(log.attempts[0].apiKey === log.attempts[1].apiKey, false, "the retry uses the rotated token");
276
+ assert.equal(events.at(-1).message.content[0].text, "recovered");
277
+ assert.deepEqual((await readPoolState(statePath)).accounts[ACCOUNT_A]?.cooldowns ?? {}, {}, "a repaired account is not marked unavailable");
278
+ } finally {
279
+ agent.dispose();
280
+ }
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // With every account limited the request stops immediately with a masked status.
285
+ // ---------------------------------------------------------------------------
286
+ {
287
+ const { agent, statePath } = await setupPool();
288
+ try {
289
+ const log = { attempts: [], refreshes: [], fetches: [] };
290
+ const notes = [];
291
+ const base = scriptedProvider([{ kind: "quota" }, { kind: "quota" }, { kind: "ok", text: "must not happen" }], log);
292
+ const provider = createPoolProvider(base, poolRuntime(agent, statePath, notes));
293
+ const limit = () => jsonResponse(429, { error: { code: "usage_limit_reached", plan_type: "plus" } });
294
+
295
+ const events = await collect(
296
+ provider.stream(MODEL, CONTEXT, { apiKey: POOL_UNRESOLVED_API_KEY, fetch: scriptedFetch([limit, limit], log) }),
297
+ );
298
+
299
+ assert.equal(log.attempts.length, 2, "each account is tried at most once per request");
300
+ assert.equal(events.at(-1).type, "error");
301
+ assert.equal(events.at(-1).error.errorMessage, "You have hit your ChatGPT usage limit.", "the provider's own error is forwarded untouched");
302
+ assert.match(notes.at(-1), /账号池暂时都不可用/, "the user is told the whole pool is out");
303
+
304
+ // A later request does not even start: it reports the masked status immediately.
305
+ const next = await collect(provider.stream(MODEL, CONTEXT, { apiKey: POOL_UNRESOLVED_API_KEY, fetch: scriptedFetch([], log) }));
306
+ assert.equal(log.attempts.length, 2, "no request is sent while every account is cooling down");
307
+ const failure = next.at(-1);
308
+ assert.equal(failure.type, "error");
309
+ assert.match(failure.error.errorMessage, /账号池暂时都不可用/);
310
+ assert.match(failure.error.errorMessage, /codex_pool/);
311
+ for (const secret of [ACCOUNT_A, ACCOUNT_B, fakeAccessToken(ACCOUNT_A), POOL_UNRESOLVED_API_KEY]) {
312
+ assert.ok(!failure.error.errorMessage.includes(secret), "the exhausted-pool message stays non-secret");
313
+ assert.ok(!notes.at(-1).includes(secret), "the exhausted-pool note stays non-secret");
314
+ }
315
+ } finally {
316
+ agent.dispose();
317
+ }
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // When all local cooldowns block a request, usage is checked once and a recovered account runs.
322
+ // ---------------------------------------------------------------------------
323
+ {
324
+ const { agent, statePath } = await setupPool();
325
+ try {
326
+ const snapshot = await loadPoolSnapshot(agent.authPath);
327
+ const now = Date.now();
328
+ for (const account of snapshot.accounts) {
329
+ await recordFailure({
330
+ statePath,
331
+ account,
332
+ now,
333
+ classification: { kind: "quota", reason: "usage_limit_reached", scope: "account", serverResetAtMs: now + 30 * 60_000 },
334
+ });
335
+ }
336
+ const usageQueries = [];
337
+ const usageFetch = async (_input, init) => {
338
+ const id = init.headers["ChatGPT-Account-Id"];
339
+ usageQueries.push(id);
340
+ return jsonResponse(200, {
341
+ rate_limit: {
342
+ allowed: id === ACCOUNT_A,
343
+ limit_reached: id !== ACCOUNT_A,
344
+ primary_window: {
345
+ used_percent: id === ACCOUNT_A ? 10 : 100,
346
+ limit_window_seconds: 18_000,
347
+ reset_at: Math.floor((now + 30 * 60_000) / 1_000),
348
+ },
349
+ },
350
+ });
351
+ };
352
+ const log = { attempts: [], refreshes: [], fetches: [] };
353
+ const provider = createPoolProvider(
354
+ scriptedProvider([{ kind: "ok", text: "recovered by usage" }], log),
355
+ poolRuntime(agent, statePath, [], usageFetch),
356
+ );
357
+ const events = await collect(provider.stream(MODEL, CONTEXT, {
358
+ apiKey: POOL_UNRESOLVED_API_KEY,
359
+ fetch: scriptedFetch([], log),
360
+ }));
361
+ assert.deepEqual(usageQueries, [ACCOUNT_A, ACCOUNT_B], "every account is checked sequentially exactly once");
362
+ assert.equal(log.attempts.length, 1);
363
+ assert.equal(log.attempts[0].apiKey, fakeAccessToken(ACCOUNT_A), "the recovered account handles the model request");
364
+ assert.equal(events.at(-1).message.content[0].text, "recovered by usage");
365
+ } finally {
366
+ agent.dispose();
367
+ }
368
+ }
369
+
370
+ // If the one usage pass finds no recovery, it stops and reports the earliest server ready time.
371
+ {
372
+ const { agent, statePath } = await setupPool();
373
+ try {
374
+ const snapshot = await loadPoolSnapshot(agent.authPath);
375
+ const now = Date.now();
376
+ const resets = new Map([[ACCOUNT_A, now + 20 * 60_000], [ACCOUNT_B, now + 10 * 60_000]]);
377
+ for (const account of snapshot.accounts) {
378
+ await recordFailure({
379
+ statePath,
380
+ account,
381
+ now,
382
+ classification: { kind: "quota", reason: "usage_limit_reached", scope: "account", serverResetAtMs: resets.get(account.id) },
383
+ });
384
+ }
385
+ let usageQueries = 0;
386
+ const usageFetch = async (_input, init) => {
387
+ usageQueries += 1;
388
+ const reset = resets.get(init.headers["ChatGPT-Account-Id"]);
389
+ return jsonResponse(200, {
390
+ rate_limit: {
391
+ allowed: false,
392
+ limit_reached: true,
393
+ primary_window: { used_percent: 100, limit_window_seconds: 18_000, reset_at: Math.floor(reset / 1_000) },
394
+ },
395
+ });
396
+ };
397
+ const log = { attempts: [], refreshes: [], fetches: [] };
398
+ const provider = createPoolProvider(scriptedProvider([], log), poolRuntime(agent, statePath, [], usageFetch));
399
+ const events = await collect(provider.stream(MODEL, CONTEXT, { apiKey: POOL_UNRESOLVED_API_KEY, fetch: scriptedFetch([], log) }));
400
+ assert.equal(usageQueries, 2, "automatic usage probing does not loop within one logical request");
401
+ assert.equal(log.attempts.length, 0);
402
+ assert.match(events.at(-1).error.errorMessage, /最早可用:约 10 分钟后/);
403
+ assert.match(events.at(-1).error.errorMessage, /#2/);
404
+ } finally {
405
+ agent.dispose();
406
+ }
407
+ }
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // Requests that are not pool-resolved keep the caller's own credential.
411
+ // ---------------------------------------------------------------------------
412
+ {
413
+ const { agent, statePath } = await setupPool();
414
+ try {
415
+ const log = { attempts: [], refreshes: [], fetches: [] };
416
+ const provider = createPoolProvider(scriptedProvider([{ kind: "ok", text: "direct" }], log), poolRuntime(agent, statePath, []));
417
+
418
+ await collect(provider.stream(MODEL, CONTEXT, { apiKey: "caller-owned-key", fetch: scriptedFetch([], log) }));
419
+ assert.equal(log.attempts[0].apiKey, "caller-owned-key", "an explicit credential is never replaced by a pool token");
420
+
421
+ // A custom endpoint must not receive pool tokens either.
422
+ const custom = { ...MODEL, baseUrl: "https://example.invalid/v1" };
423
+ await collect(provider.stream(custom, CONTEXT, { apiKey: POOL_UNRESOLVED_API_KEY, fetch: scriptedFetch([], log) }));
424
+ assert.equal(log.attempts[1].apiKey, POOL_UNRESOLVED_API_KEY, "the unresolved placeholder fails visibly instead of leaking a token");
425
+ } finally {
426
+ agent.dispose();
427
+ }
428
+ }
429
+
430
+ // ---------------------------------------------------------------------------
431
+ // The stored pool reference resolves to a placeholder, never to a usable token.
432
+ // ---------------------------------------------------------------------------
433
+ {
434
+ const { agent, statePath } = await setupPool();
435
+ try {
436
+ const log = { attempts: [], refreshes: [], fetches: [] };
437
+ const provider = createPoolProvider(scriptedProvider([], log), poolRuntime(agent, statePath, []));
438
+ const entries = JSON.parse(await (await import("node:fs/promises")).readFile(agent.authPath, "utf8"));
439
+
440
+ assert.deepEqual(await provider.auth.oauth.toAuth(entries["openai-codex"]), { apiKey: POOL_UNRESOLVED_API_KEY });
441
+ assert.deepEqual(await provider.auth.oauth.refresh(entries["openai-codex"]), entries["openai-codex"], "the reference is never exchanged for a token");
442
+ assert.deepEqual(log.refreshes, []);
443
+
444
+ const plain = fakeCredential("account-cccc");
445
+ assert.deepEqual(await provider.auth.oauth.toAuth(plain), { apiKey: plain.access }, "a plain login still resolves normally");
446
+ } finally {
447
+ agent.dispose();
448
+ }
449
+ }
450
+
451
+ // ---------------------------------------------------------------------------
452
+ // Transports without an HTTP error, such as WebSocket, report the limit as a stream event.
453
+ // ---------------------------------------------------------------------------
454
+ {
455
+ const { agent, statePath } = await setupPool();
456
+ try {
457
+ const log = { attempts: [], refreshes: [], fetches: [] };
458
+ const base = scriptedProvider([], log);
459
+ const steps = [
460
+ { failed: { error: { code: "usage_limit_reached", plan_type: "pro" } } },
461
+ { failed: { error: { code: "server_error" } } },
462
+ ];
463
+ base.stream = (model, context, options) => {
464
+ const stream = createAssistantMessageEventStream();
465
+ (async () => {
466
+ log.attempts.push({ apiKey: options.apiKey, step: "websocket" });
467
+ const step = steps.shift();
468
+ if (!step) {
469
+ const partial = assistantMessage("ws ok");
470
+ stream.push({ type: "start", partial });
471
+ stream.push({ type: "done", reason: "stop", message: partial });
472
+ return;
473
+ }
474
+ await options.onProviderStreamEvent({ type: "response.failed", response: step.failed }, model);
475
+ stream.push({ type: "error", reason: "error", error: assistantMessage(undefined, { error: "stream failed" }) });
476
+ })();
477
+ return stream;
478
+ };
479
+ const provider = createPoolProvider(base, poolRuntime(agent, statePath, []));
480
+
481
+ await collect(provider.stream(MODEL, CONTEXT, { apiKey: POOL_UNRESOLVED_API_KEY, transport: "websocket" }));
482
+ assert.equal(log.attempts.length, 2, "a limit seen only in the event stream still switches accounts");
483
+ assert.equal((await readPoolState(statePath)).accounts[ACCOUNT_A].cooldowns.account.reason, "usage_limit_reached");
484
+
485
+ // The second account's unstructured failure is not evidence of anything, so it stops there.
486
+ assert.equal((await readPoolState(statePath)).accounts[ACCOUNT_B], undefined);
487
+ } finally {
488
+ agent.dispose();
489
+ }
490
+ }
491
+
492
+ // ---------------------------------------------------------------------------
493
+ // A cancelled request stays cancelled: no retry on another account, no cooldown.
494
+ // ---------------------------------------------------------------------------
495
+ {
496
+ const { agent, statePath } = await setupPool();
497
+ try {
498
+ const log = { attempts: [], refreshes: [], fetches: [] };
499
+ const controller = new AbortController();
500
+ const base = scriptedProvider([], log);
501
+ base.stream = (model, context, options) => {
502
+ const stream = createAssistantMessageEventStream();
503
+ log.attempts.push({ apiKey: options.apiKey, step: "cancelled" });
504
+ controller.abort();
505
+ stream.push({ type: "error", reason: "aborted", error: assistantMessage(undefined, { error: "Request was aborted" }) });
506
+ return stream;
507
+ };
508
+ const provider = createPoolProvider(base, poolRuntime(agent, statePath, []));
509
+
510
+ const events = await collect(provider.stream(MODEL, CONTEXT, { apiKey: POOL_UNRESOLVED_API_KEY, signal: controller.signal }));
511
+ assert.equal(log.attempts.length, 1);
512
+ assert.equal(events.at(-1).error.stopReason, "error");
513
+ assert.equal(events.at(-1).reason, "aborted");
514
+ assert.deepEqual((await readPoolState(statePath)).accounts, {}, "cancelling proves nothing about the account");
515
+ } finally {
516
+ agent.dispose();
517
+ }
518
+ }
519
+
520
+ // ---------------------------------------------------------------------------
521
+ // A hand-picked default decides where a request starts, and still fails over.
522
+ // ---------------------------------------------------------------------------
523
+ {
524
+ const { agent, statePath } = await setupPool();
525
+ try {
526
+ await setPreferredAccountId({ statePath, accountId: ACCOUNT_B });
527
+ const log = { attempts: [], refreshes: [], fetches: [] };
528
+ const notes = [];
529
+ const provider = createPoolProvider(scriptedProvider([{ kind: "ok", text: "from b" }], log), poolRuntime(agent, statePath, notes));
530
+ await collect(provider.stream(MODEL, CONTEXT, { apiKey: POOL_UNRESOLVED_API_KEY, fetch: scriptedFetch([], log) }));
531
+ assert.equal(accountIdOf({ access: log.attempts[0].apiKey }), ACCOUNT_B, "the pinned account is used even though it is not the first one");
532
+
533
+ // Pinning is a starting point, not a cage: a used-up default still hands the request on.
534
+ const failover = { attempts: [], refreshes: [], fetches: [] };
535
+ const second = createPoolProvider(scriptedProvider([{ kind: "quota" }, { kind: "ok", text: "from a" }], failover), poolRuntime(agent, statePath, notes));
536
+ const events = await collect(
537
+ second.stream(MODEL, CONTEXT, {
538
+ apiKey: POOL_UNRESOLVED_API_KEY,
539
+ fetch: scriptedFetch([() => jsonResponse(429, { error: { code: "usage_limit_reached", plan_type: "plus", resets_at: Math.floor(Date.now() / 1_000) + 1_800 } })], failover),
540
+ }),
541
+ );
542
+ assert.equal(events.at(-1).message.content[0].text, "from a");
543
+ assert.deepEqual(
544
+ failover.attempts.map((attempt) => accountIdOf({ access: attempt.apiKey })),
545
+ [ACCOUNT_B, ACCOUNT_A],
546
+ "the pinned account is tried first, then the pool takes over",
547
+ );
548
+ assert.equal((await readPoolState(statePath)).preferredAccountId, ACCOUNT_B, "a failover does not silently repoint the default");
549
+ } finally {
550
+ agent.dispose();
551
+ }
552
+ }
553
+
554
+ console.log("Stream failover tests passed");
@@ -0,0 +1,40 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ const JWT_ACCOUNT_CLAIM = "https://api.openai.com/auth";
6
+ const JWT_PROFILE_CLAIM = "https://api.openai.com/profile";
7
+
8
+ /** Access token shaped like the Codex one: the account id and sign-in address live in JWT claims. */
9
+ export function fakeAccessToken(accountId, marker = "v1", { email, plan } = {}) {
10
+ const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
11
+ const claims = { [JWT_ACCOUNT_CLAIM]: { chatgpt_account_id: accountId }, marker };
12
+ if (plan) claims[JWT_ACCOUNT_CLAIM].chatgpt_plan_type = plan;
13
+ if (email) claims[JWT_PROFILE_CLAIM] = { email, email_verified: true };
14
+ const payload = Buffer.from(JSON.stringify(claims)).toString("base64url");
15
+ return `${header}.${payload}.test-signature`;
16
+ }
17
+
18
+ /** Test-only credential. No real token ever appears in this repository. */
19
+ export function fakeCredential(accountId, { marker = "v1", expiresInMs = 3_600_000, now = Date.now(), email, plan } = {}) {
20
+ return {
21
+ type: "oauth",
22
+ access: fakeAccessToken(accountId, marker, { email, plan }),
23
+ refresh: `test-refresh-${accountId}-${marker}`,
24
+ expires: now + expiresInMs,
25
+ accountId,
26
+ };
27
+ }
28
+
29
+ export function makeAgentDir(prefix = "openai-account-pool-") {
30
+ const directory = mkdtempSync(join(tmpdir(), prefix));
31
+ return {
32
+ directory,
33
+ authPath: join(directory, "auth.json"),
34
+ dispose() { rmSync(directory, { recursive: true, force: true }); },
35
+ };
36
+ }
37
+
38
+ export function seedAuthFile(authPath, entries) {
39
+ writeFileSync(authPath, JSON.stringify(entries, null, 2), { encoding: "utf8", mode: 0o600 });
40
+ }
@@ -0,0 +1,58 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { acquireFileLock } from "../../src/lock.ts";
3
+
4
+ /**
5
+ * Stand-in for pi's own AuthStorage: one credential per provider, a single-entry merge per
6
+ * write, in-place file writes, and the shared auth.json lock. The ownership checks need pi's
7
+ * real write semantics, not a convenient in-memory store.
8
+ */
9
+ export class NativeCredentialStore {
10
+ constructor(authPath) {
11
+ this.authPath = authPath;
12
+ }
13
+
14
+ #read() {
15
+ if (!existsSync(this.authPath)) return {};
16
+ const content = readFileSync(this.authPath, "utf8");
17
+ return content.trim() === "" ? {} : JSON.parse(content);
18
+ }
19
+
20
+ async #withLock(run) {
21
+ const lock = await acquireFileLock(this.authPath);
22
+ try {
23
+ const data = this.#read();
24
+ const { result, next } = await run(data);
25
+ if (next !== undefined) {
26
+ lock.assertHeld();
27
+ writeFileSync(this.authPath, JSON.stringify(next, null, 2), { encoding: "utf8", mode: 0o600 });
28
+ }
29
+ return result;
30
+ } finally {
31
+ await lock.release();
32
+ }
33
+ }
34
+
35
+ async read(providerId) {
36
+ return this.#read()[providerId];
37
+ }
38
+
39
+ async list() {
40
+ return Object.entries(this.#read()).map(([providerId, credential]) => ({ providerId, type: credential.type }));
41
+ }
42
+
43
+ async modify(providerId, fn) {
44
+ return this.#withLock(async (data) => {
45
+ const next = await fn(data[providerId]);
46
+ if (next === undefined) return { result: data[providerId] };
47
+ return { result: next, next: { ...data, [providerId]: next } };
48
+ });
49
+ }
50
+
51
+ async delete(providerId) {
52
+ await this.#withLock(async (data) => {
53
+ const next = { ...data };
54
+ delete next[providerId];
55
+ return { result: undefined, next };
56
+ });
57
+ }
58
+ }
@@ -0,0 +1,24 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, realpathSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { dirname, join } from "node:path";
5
+
6
+ /**
7
+ * Absolute entry of the pi-ai runtime. This repository has no node_modules and the pool only
8
+ * ever runs inside pi, so the integration checks load the same pi-ai the installed pi uses.
9
+ */
10
+ export function piAiEntry() {
11
+ try { return createRequire(import.meta.url).resolve("@earendil-works/pi-ai"); } catch { /* No local install; fall back to the pi on PATH. */ }
12
+ let binary = "";
13
+ try { binary = execFileSync("sh", ["-c", "command -v pi"], { encoding: "utf8" }).trim(); } catch { /* Reported below. */ }
14
+ if (!binary) throw new Error("pi is not on PATH, so @earendil-works/pi-ai cannot be located for the integration checks.");
15
+ let directory = dirname(realpathSync(binary));
16
+ for (let depth = 0; depth < 12; depth++) {
17
+ const candidate = join(directory, "node_modules", "@earendil-works", "pi-ai", "dist", "index.js");
18
+ if (existsSync(candidate)) return candidate;
19
+ const parent = dirname(directory);
20
+ if (parent === directory) break;
21
+ directory = parent;
22
+ }
23
+ throw new Error(`Could not locate @earendil-works/pi-ai next to ${binary}.`);
24
+ }