@maxiaochao/pi-toolkit 0.11.7 → 0.11.9
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/CHANGELOG.md +13 -0
- package/README.md +1 -0
- package/package.json +5 -3
- package/packages/openai-account-pool/CHANGELOG.md +15 -0
- package/packages/openai-account-pool/README.md +67 -0
- package/packages/openai-account-pool/TESTING.md +28 -0
- package/packages/openai-account-pool/docs/demo.gif +0 -0
- package/packages/openai-account-pool/docs/screenshot.png +0 -0
- package/packages/openai-account-pool/extensions/openai-account-pool.ts +368 -0
- package/packages/openai-account-pool/package.json +45 -0
- package/packages/openai-account-pool/src/auth.ts +418 -0
- package/packages/openai-account-pool/src/availability.ts +230 -0
- package/packages/openai-account-pool/src/command-completions.ts +18 -0
- package/packages/openai-account-pool/src/enrollment.ts +339 -0
- package/packages/openai-account-pool/src/lease.ts +128 -0
- package/packages/openai-account-pool/src/lock.ts +171 -0
- package/packages/openai-account-pool/src/login-slot.ts +158 -0
- package/packages/openai-account-pool/src/policy.ts +176 -0
- package/packages/openai-account-pool/src/provider.ts +511 -0
- package/packages/openai-account-pool/src/status-widget.ts +62 -0
- package/packages/openai-account-pool/src/store.ts +259 -0
- package/packages/openai-account-pool/src/usage.ts +291 -0
- package/packages/openai-account-pool/tests/command-completions.mjs +10 -0
- package/packages/openai-account-pool/tests/enrollment.mjs +322 -0
- package/packages/openai-account-pool/tests/failover-stream.mjs +554 -0
- package/packages/openai-account-pool/tests/helpers/fixtures.mjs +40 -0
- package/packages/openai-account-pool/tests/helpers/native-credential-store.mjs +58 -0
- package/packages/openai-account-pool/tests/helpers/pi-runtime.mjs +24 -0
- package/packages/openai-account-pool/tests/helpers/refresh-child.mjs +29 -0
- package/packages/openai-account-pool/tests/integration-login-ownership.mjs +299 -0
- package/packages/openai-account-pool/tests/login-slot.mjs +131 -0
- package/packages/openai-account-pool/tests/multiprocess.mjs +73 -0
- package/packages/openai-account-pool/tests/selection.mjs +286 -0
- package/packages/openai-account-pool/tests/store-ownership.mjs +369 -0
- package/packages/openai-account-pool/tests/usage.mjs +428 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
import { accountIdOf, isPoolReferenceEntry, loadPoolSnapshot } from "../src/auth.ts";
|
|
5
|
+
import { readPreferredAccountId, recordFailure } from "../src/availability.ts";
|
|
6
|
+
import {
|
|
7
|
+
AccountQueryError,
|
|
8
|
+
endPool,
|
|
9
|
+
isCancellation,
|
|
10
|
+
poolStatus,
|
|
11
|
+
redactMessage,
|
|
12
|
+
removePoolAccount,
|
|
13
|
+
resolveAccount,
|
|
14
|
+
runEnrollment,
|
|
15
|
+
setDefaultAccount,
|
|
16
|
+
} from "../src/enrollment.ts";
|
|
17
|
+
import { EnrollmentInProgressError } from "../src/lease.ts";
|
|
18
|
+
import { classifyFailure } from "../src/policy.ts";
|
|
19
|
+
import { poolStatePathFor, readPoolState } from "../src/store.ts";
|
|
20
|
+
import { fakeCredential, makeAgentDir, seedAuthFile } from "./helpers/fixtures.mjs";
|
|
21
|
+
|
|
22
|
+
const ACCOUNT_A = "account-aaaa";
|
|
23
|
+
const ACCOUNT_B = "account-bbbb";
|
|
24
|
+
|
|
25
|
+
/** Replays a fixed sequence of user choices and login results. */
|
|
26
|
+
function scriptedUi(script) {
|
|
27
|
+
const views = [];
|
|
28
|
+
const notes = [];
|
|
29
|
+
const offered = [];
|
|
30
|
+
let step = 0;
|
|
31
|
+
return {
|
|
32
|
+
views,
|
|
33
|
+
notes,
|
|
34
|
+
/** Every account list the delete dropdown was given. */
|
|
35
|
+
offered,
|
|
36
|
+
ui: {
|
|
37
|
+
confirm: async (view) => {
|
|
38
|
+
views.push(view);
|
|
39
|
+
return script[step++]?.choice ?? "end";
|
|
40
|
+
},
|
|
41
|
+
login: async () => {
|
|
42
|
+
const current = script[step - 1];
|
|
43
|
+
if (current.fail) throw current.fail;
|
|
44
|
+
return fakeCredential(current.account, { marker: current.marker ?? "v1", email: current.email, plan: current.plan });
|
|
45
|
+
},
|
|
46
|
+
pickRemoval: async (accounts) => {
|
|
47
|
+
offered.push(accounts.map((account) => account.id));
|
|
48
|
+
const wanted = script[step - 1]?.account;
|
|
49
|
+
return accounts.find((account) => account.id === wanted);
|
|
50
|
+
},
|
|
51
|
+
notify: (message) => notes.push(message),
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const paths = (agent) => ({ authPath: agent.authPath, statePath: poolStatePathFor(agent.directory) });
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Continue as often as the user wants: the pool is not limited to two accounts.
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
{
|
|
62
|
+
const agent = makeAgentDir();
|
|
63
|
+
try {
|
|
64
|
+
const { ui, views, notes } = scriptedUi([
|
|
65
|
+
{ choice: "continue", account: ACCOUNT_A },
|
|
66
|
+
{ choice: "continue", account: ACCOUNT_B },
|
|
67
|
+
{ choice: "continue", account: "account-cccc" },
|
|
68
|
+
{ choice: "continue", account: ACCOUNT_A, marker: "v2" },
|
|
69
|
+
{ choice: "end" },
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
const result = await runEnrollment({ ...paths(agent), ui });
|
|
73
|
+
assert.deepEqual({ added: result.added, updated: result.updated, accounts: result.accounts, cancelled: result.cancelled }, { added: 3, updated: 1, accounts: 3, cancelled: false });
|
|
74
|
+
assert.deepEqual(views.map((view) => view.savedAccounts), [0, 1, 2, 3, 3], "每次确认都显示真实数量");
|
|
75
|
+
assert.equal(views[1].addedThisRun, 1);
|
|
76
|
+
assert.match(views[1].lastOutcome, /已添加账号 #1/);
|
|
77
|
+
assert.match(views[4].lastOutcome, /凭据已更新/, "re-authorizing a known account updates it in place");
|
|
78
|
+
|
|
79
|
+
const snapshot = await loadPoolSnapshot(agent.authPath);
|
|
80
|
+
assert.deepEqual(snapshot.accounts.map((account) => account.ordinal), [1, 2, 3], "ordinals stay stable across updates");
|
|
81
|
+
assert.equal(snapshot.accounts.find((account) => account.id === ACCOUNT_A).ordinal, 1);
|
|
82
|
+
for (const note of notes) assert.ok(!note.includes(ACCOUNT_A), "notes never spell out an account id");
|
|
83
|
+
} finally {
|
|
84
|
+
agent.dispose();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// An existing plain login is kept, not replaced, by the first add.
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
{
|
|
92
|
+
const agent = makeAgentDir();
|
|
93
|
+
try {
|
|
94
|
+
seedAuthFile(agent.authPath, { "openai-codex": fakeCredential(ACCOUNT_A), anthropic: { type: "api_key", key: "$ANTHROPIC_API_KEY" } });
|
|
95
|
+
const { ui, views } = scriptedUi([{ choice: "continue", account: ACCOUNT_B }, { choice: "end" }]);
|
|
96
|
+
|
|
97
|
+
const result = await runEnrollment({ ...paths(agent), ui });
|
|
98
|
+
assert.equal(views[0].nativeLoginPending, true, "the wizard says the existing login will be kept");
|
|
99
|
+
assert.equal(result.accounts, 2);
|
|
100
|
+
assert.match(views[1].lastOutcome, /原有登录已保留并入池/);
|
|
101
|
+
|
|
102
|
+
const entries = JSON.parse(readFileSync(agent.authPath, "utf8"));
|
|
103
|
+
assert.ok(isPoolReferenceEntry(entries["openai-codex"]), "the main entry becomes a non-secret reference");
|
|
104
|
+
assert.equal(accountIdOf(entries["openai-codex"]), undefined);
|
|
105
|
+
assert.deepEqual(entries.anthropic, { type: "api_key", key: "$ANTHROPIC_API_KEY" });
|
|
106
|
+
} finally {
|
|
107
|
+
agent.dispose();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Ending right away changes nothing at all.
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
{
|
|
115
|
+
const agent = makeAgentDir();
|
|
116
|
+
try {
|
|
117
|
+
seedAuthFile(agent.authPath, { "openai-codex": fakeCredential(ACCOUNT_A) });
|
|
118
|
+
const before = readFileSync(agent.authPath, "utf8");
|
|
119
|
+
const { ui } = scriptedUi([{ choice: "end" }]);
|
|
120
|
+
|
|
121
|
+
const result = await runEnrollment({ ...paths(agent), ui });
|
|
122
|
+
assert.deepEqual({ added: result.added, accounts: result.accounts }, { added: 0, accounts: 0 });
|
|
123
|
+
assert.equal(readFileSync(agent.authPath, "utf8"), before, "native single-account login is untouched");
|
|
124
|
+
assert.deepEqual((await readPoolState(poolStatePathFor(agent.directory))).accounts, {});
|
|
125
|
+
} finally {
|
|
126
|
+
agent.dispose();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// A failed login is reported without retrying by itself; a cancel ends the loop.
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
{
|
|
134
|
+
const agent = makeAgentDir();
|
|
135
|
+
try {
|
|
136
|
+
const failure = new Error('OpenAI Codex token exchange failed (400): {"error":"invalid_request","access_token":"sk-abcdefghijklmnopqrstuvwxyz0123456789"}');
|
|
137
|
+
const { ui, views } = scriptedUi([
|
|
138
|
+
{ choice: "continue", fail: failure },
|
|
139
|
+
{ choice: "continue", account: ACCOUNT_A },
|
|
140
|
+
{ choice: "continue", fail: new Error("Login cancelled") },
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
const result = await runEnrollment({ ...paths(agent), ui });
|
|
144
|
+
assert.equal(result.cancelled, true);
|
|
145
|
+
assert.equal(result.accounts, 1, "an account saved before the cancel stays saved");
|
|
146
|
+
assert.match(views[1].lastOutcome, /上一次登录失败/);
|
|
147
|
+
assert.ok(!views[1].lastOutcome.includes("sk-abcdefghijklmnopqrstuvwxyz0123456789"), "token-shaped text never reaches the user");
|
|
148
|
+
} finally {
|
|
149
|
+
agent.dispose();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Redaction keeps the shape of the error without its payload.
|
|
154
|
+
{
|
|
155
|
+
const redacted = redactMessage(new Error('failed: https://auth.openai.com/callback?code=abc123 token "sk-0123456789abcdefghijklmnop"'));
|
|
156
|
+
assert.ok(!redacted.includes("code=abc123"));
|
|
157
|
+
assert.ok(!redacted.includes("sk-0123456789abcdefghijklmnop"));
|
|
158
|
+
assert.match(redacted, /<url>/);
|
|
159
|
+
assert.ok(isCancellation(new Error("Login cancelled")));
|
|
160
|
+
assert.ok(!isCancellation(new Error("network unreachable")));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Two pi processes cannot run the wizard, and two OAuth callbacks, at once.
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
{
|
|
167
|
+
const agent = makeAgentDir();
|
|
168
|
+
try {
|
|
169
|
+
let releaseFirst;
|
|
170
|
+
const firstReachedConfirm = new Promise((resolve) => {
|
|
171
|
+
releaseFirst = resolve;
|
|
172
|
+
});
|
|
173
|
+
let proceed;
|
|
174
|
+
const blocked = new Promise((resolve) => {
|
|
175
|
+
proceed = resolve;
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const first = runEnrollment({
|
|
179
|
+
...paths(agent),
|
|
180
|
+
ui: {
|
|
181
|
+
confirm: async () => {
|
|
182
|
+
releaseFirst();
|
|
183
|
+
await blocked;
|
|
184
|
+
return "end";
|
|
185
|
+
},
|
|
186
|
+
login: async () => {
|
|
187
|
+
throw new Error("not reached");
|
|
188
|
+
},
|
|
189
|
+
pickRemoval: async () => undefined,
|
|
190
|
+
notify: () => {},
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
await firstReachedConfirm;
|
|
194
|
+
|
|
195
|
+
const { ui } = scriptedUi([{ choice: "end" }]);
|
|
196
|
+
await assert.rejects(() => runEnrollment({ ...paths(agent), ui }), EnrollmentInProgressError);
|
|
197
|
+
|
|
198
|
+
proceed();
|
|
199
|
+
await first;
|
|
200
|
+
// The lease is released, so the next wizard runs normally.
|
|
201
|
+
await runEnrollment({ ...paths(agent), ui: scriptedUi([{ choice: "end" }]).ui });
|
|
202
|
+
} finally {
|
|
203
|
+
agent.dispose();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Status, off and remove speak in masked labels and keep auth.json authoritative.
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
{
|
|
211
|
+
const agent = makeAgentDir();
|
|
212
|
+
const { authPath, statePath } = paths(agent);
|
|
213
|
+
try {
|
|
214
|
+
const idle = await poolStatus({ authPath, statePath });
|
|
215
|
+
assert.equal(idle.enabled, false);
|
|
216
|
+
assert.match(idle.summary, /未启用/);
|
|
217
|
+
|
|
218
|
+
const { ui } = scriptedUi([
|
|
219
|
+
{ choice: "continue", account: ACCOUNT_A },
|
|
220
|
+
{ choice: "continue", account: ACCOUNT_B },
|
|
221
|
+
{ choice: "end" },
|
|
222
|
+
]);
|
|
223
|
+
await runEnrollment({ authPath, statePath, ui });
|
|
224
|
+
|
|
225
|
+
const snapshot = await loadPoolSnapshot(authPath);
|
|
226
|
+
const limited = snapshot.accounts.find((account) => account.id === ACCOUNT_A);
|
|
227
|
+
await recordFailure({
|
|
228
|
+
statePath,
|
|
229
|
+
account: limited,
|
|
230
|
+
now: Date.now(),
|
|
231
|
+
classification: classifyFailure({ body: { code: "usage_limit_reached", plan_type: "plus", resets_at: Math.floor(Date.now() / 1_000) + 900 } }, Date.now()),
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const status = await poolStatus({ authPath, statePath });
|
|
235
|
+
assert.equal(status.enabled, true);
|
|
236
|
+
assert.deepEqual(status.accounts.map((account) => account.state), ["cooling", "ready"]);
|
|
237
|
+
assert.match(status.accounts[0].detail, /分钟后恢复/);
|
|
238
|
+
for (const line of status.accounts) assert.ok(!line.label.includes(ACCOUNT_A) && !line.label.includes(ACCOUNT_B), "status shows masked labels only");
|
|
239
|
+
|
|
240
|
+
// Ordinals are what the user types.
|
|
241
|
+
assert.equal(resolveAccount(snapshot, "2").id, ACCOUNT_B);
|
|
242
|
+
assert.throws(() => resolveAccount(snapshot, "9"), AccountQueryError);
|
|
243
|
+
|
|
244
|
+
const removed = await removePoolAccount({ authPath, statePath, accountId: ACCOUNT_B });
|
|
245
|
+
assert.deepEqual({ remaining: removed.remaining, poolDisabled: removed.poolDisabled }, { remaining: 1, poolDisabled: false });
|
|
246
|
+
assert.match((await poolStatus({ authPath, statePath })).summary, /暂无备用账号/);
|
|
247
|
+
|
|
248
|
+
const ended = await endPool({ authPath, statePath });
|
|
249
|
+
assert.equal(ended.kept.id, ACCOUNT_A);
|
|
250
|
+
const entries = JSON.parse(readFileSync(authPath, "utf8"));
|
|
251
|
+
assert.equal(isPoolReferenceEntry(entries["openai-codex"]), false, "the single-account login is authoritative again");
|
|
252
|
+
assert.equal(accountIdOf(entries["openai-codex"]), ACCOUNT_A);
|
|
253
|
+
assert.deepEqual((await readPoolState(statePath)).accounts, {}, "closing the pool forgets its bookkeeping");
|
|
254
|
+
await assert.rejects(() => endPool({ authPath, statePath }), AccountQueryError);
|
|
255
|
+
} finally {
|
|
256
|
+
agent.dispose();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// The sign-in address labels the account, so the user knows what they delete.
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
{
|
|
264
|
+
const agent = makeAgentDir();
|
|
265
|
+
const { authPath, statePath } = paths(agent);
|
|
266
|
+
try {
|
|
267
|
+
const { ui, views, notes, offered } = scriptedUi([
|
|
268
|
+
{ choice: "continue", account: ACCOUNT_A, email: "first@example.com", plan: "plus" },
|
|
269
|
+
{ choice: "continue", account: ACCOUNT_B, email: "second@example.com", plan: "pro" },
|
|
270
|
+
{ choice: "remove", account: ACCOUNT_A },
|
|
271
|
+
{ choice: "end" },
|
|
272
|
+
]);
|
|
273
|
+
|
|
274
|
+
const result = await runEnrollment({ authPath, statePath, ui });
|
|
275
|
+
assert.deepEqual({ added: result.added, removed: result.removed, accounts: result.accounts }, { added: 2, removed: 1, accounts: 1 });
|
|
276
|
+
assert.deepEqual(views[2].savedLabels, ["#1 (first@example.com)", "#2 (second@example.com)"], "the wizard lists accounts by sign-in address");
|
|
277
|
+
assert.deepEqual(offered, [[ACCOUNT_A, ACCOUNT_B]], "the delete dropdown offers every saved account");
|
|
278
|
+
assert.ok(notes.some((note) => note.includes("已删除账号 #1 (first@example.com)")));
|
|
279
|
+
|
|
280
|
+
const snapshot = await loadPoolSnapshot(authPath);
|
|
281
|
+
assert.deepEqual(snapshot.accounts.map((account) => account.id), [ACCOUNT_B]);
|
|
282
|
+
assert.deepEqual({ email: snapshot.accounts[0].email, plan: snapshot.accounts[0].plan }, { email: "second@example.com", plan: "pro" });
|
|
283
|
+
} finally {
|
|
284
|
+
agent.dispose();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// The hand-picked default is shared across processes and dropped with its account.
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
{
|
|
292
|
+
const agent = makeAgentDir();
|
|
293
|
+
const { authPath, statePath } = paths(agent);
|
|
294
|
+
try {
|
|
295
|
+
const { ui } = scriptedUi([
|
|
296
|
+
{ choice: "continue", account: ACCOUNT_A, email: "first@example.com" },
|
|
297
|
+
{ choice: "continue", account: ACCOUNT_B, email: "second@example.com" },
|
|
298
|
+
{ choice: "end" },
|
|
299
|
+
]);
|
|
300
|
+
await runEnrollment({ authPath, statePath, ui });
|
|
301
|
+
|
|
302
|
+
assert.equal(await readPreferredAccountId(statePath), undefined, "no default until the user picks one");
|
|
303
|
+
const chosen = await setDefaultAccount({ authPath, statePath, accountId: ACCOUNT_B });
|
|
304
|
+
assert.equal(chosen.ordinal, 2);
|
|
305
|
+
assert.equal(await readPreferredAccountId(statePath), ACCOUNT_B, "the default lives in the shared state file");
|
|
306
|
+
assert.doesNotMatch((await poolStatus({ authPath, statePath })).accounts[1].label, /★默认/, "manual preference is not a user-facing status badge");
|
|
307
|
+
|
|
308
|
+
await assert.rejects(() => setDefaultAccount({ authPath, statePath, accountId: "account-unknown" }), AccountQueryError);
|
|
309
|
+
assert.equal(await readPreferredAccountId(statePath), ACCOUNT_B, "a rejected pick leaves the default alone");
|
|
310
|
+
|
|
311
|
+
await removePoolAccount({ authPath, statePath, accountId: ACCOUNT_B });
|
|
312
|
+
assert.equal(await readPreferredAccountId(statePath), undefined, "deleting the default account clears the pin");
|
|
313
|
+
|
|
314
|
+
await setDefaultAccount({ authPath, statePath, accountId: ACCOUNT_A });
|
|
315
|
+
await setDefaultAccount({ authPath, statePath, accountId: undefined });
|
|
316
|
+
assert.equal(await readPreferredAccountId(statePath), undefined, "auto clears the pin");
|
|
317
|
+
} finally {
|
|
318
|
+
agent.dispose();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
console.log("Enrollment tests passed");
|