@maxiaochao/pi-toolkit 0.11.9 → 0.11.11
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 +11 -0
- package/README.md +15 -28
- package/package.json +2 -4
- package/packages/openai-account-pool/CHANGELOG.md +0 -15
- package/packages/openai-account-pool/README.md +0 -67
- package/packages/openai-account-pool/TESTING.md +0 -28
- 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 +0 -368
- package/packages/openai-account-pool/package.json +0 -45
- package/packages/openai-account-pool/src/auth.ts +0 -418
- package/packages/openai-account-pool/src/availability.ts +0 -230
- package/packages/openai-account-pool/src/command-completions.ts +0 -18
- package/packages/openai-account-pool/src/enrollment.ts +0 -339
- package/packages/openai-account-pool/src/lease.ts +0 -128
- package/packages/openai-account-pool/src/lock.ts +0 -171
- package/packages/openai-account-pool/src/login-slot.ts +0 -158
- package/packages/openai-account-pool/src/policy.ts +0 -176
- package/packages/openai-account-pool/src/provider.ts +0 -511
- package/packages/openai-account-pool/src/status-widget.ts +0 -62
- package/packages/openai-account-pool/src/store.ts +0 -259
- package/packages/openai-account-pool/src/usage.ts +0 -291
- package/packages/openai-account-pool/tests/command-completions.mjs +0 -10
- package/packages/openai-account-pool/tests/enrollment.mjs +0 -322
- package/packages/openai-account-pool/tests/failover-stream.mjs +0 -554
- package/packages/openai-account-pool/tests/helpers/fixtures.mjs +0 -40
- package/packages/openai-account-pool/tests/helpers/native-credential-store.mjs +0 -58
- package/packages/openai-account-pool/tests/helpers/pi-runtime.mjs +0 -24
- package/packages/openai-account-pool/tests/helpers/refresh-child.mjs +0 -29
- package/packages/openai-account-pool/tests/integration-login-ownership.mjs +0 -299
- package/packages/openai-account-pool/tests/login-slot.mjs +0 -131
- package/packages/openai-account-pool/tests/multiprocess.mjs +0 -73
- package/packages/openai-account-pool/tests/selection.mjs +0 -286
- package/packages/openai-account-pool/tests/store-ownership.mjs +0 -369
- package/packages/openai-account-pool/tests/usage.mjs +0 -428
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@maxiaochao/pi-openai-account-pool",
|
|
3
|
-
"version": "0.1.1",
|
|
4
|
-
"description": "Pool several ChatGPT Plus/Pro logins behind pi's openai-codex provider and switch accounts automatically when one runs out of quota.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"keywords": [
|
|
7
|
-
"pi-package",
|
|
8
|
-
"pi-extension",
|
|
9
|
-
"openai-codex",
|
|
10
|
-
"oauth"
|
|
11
|
-
],
|
|
12
|
-
"files": [
|
|
13
|
-
"extensions",
|
|
14
|
-
"src",
|
|
15
|
-
"tests",
|
|
16
|
-
"docs",
|
|
17
|
-
"README.md",
|
|
18
|
-
"TESTING.md",
|
|
19
|
-
"CHANGELOG.md",
|
|
20
|
-
"package.json"
|
|
21
|
-
],
|
|
22
|
-
"repository": {
|
|
23
|
-
"type": "git",
|
|
24
|
-
"url": "git+https://github.com/chaochaoxiaochao/pi-toolkit.git",
|
|
25
|
-
"directory": "packages/openai-account-pool"
|
|
26
|
-
},
|
|
27
|
-
"pi": {
|
|
28
|
-
"extensions": [
|
|
29
|
-
"./extensions/openai-account-pool.ts"
|
|
30
|
-
]
|
|
31
|
-
},
|
|
32
|
-
"peerDependencies": {
|
|
33
|
-
"@earendil-works/pi-ai": "*",
|
|
34
|
-
"@earendil-works/pi-coding-agent": "*",
|
|
35
|
-
"@earendil-works/pi-tui": "*",
|
|
36
|
-
"typebox": "*"
|
|
37
|
-
},
|
|
38
|
-
"scripts": {
|
|
39
|
-
"test": "node --experimental-strip-types tests/store-ownership.mjs && node --experimental-strip-types tests/selection.mjs && node --experimental-strip-types tests/command-completions.mjs && node --experimental-strip-types tests/login-slot.mjs && node --experimental-strip-types tests/enrollment.mjs && node --experimental-strip-types tests/usage.mjs && node --experimental-strip-types tests/failover-stream.mjs && node --experimental-strip-types tests/multiprocess.mjs && node --experimental-strip-types tests/integration-login-ownership.mjs",
|
|
40
|
-
"load-test": "\"${PI_TEST_BINARY:-pi}\" -ne -ns --no-session -e ./extensions/openai-account-pool.ts"
|
|
41
|
-
},
|
|
42
|
-
"publishConfig": {
|
|
43
|
-
"access": "public"
|
|
44
|
-
}
|
|
45
|
-
}
|
|
@@ -1,418 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
isPoolEntryKey,
|
|
3
|
-
MAIN_PROVIDER_ID,
|
|
4
|
-
poolEntryKey,
|
|
5
|
-
readAuthEntries,
|
|
6
|
-
withAuthTransaction,
|
|
7
|
-
type AuthEntries,
|
|
8
|
-
type AuthEntry,
|
|
9
|
-
} from "./store.ts";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Credential ownership for the pool.
|
|
13
|
-
*
|
|
14
|
-
* `auth.json` stays the single authority. When the pool is enabled the `openai-codex` entry
|
|
15
|
-
* holds an explicitly marked, non-secret reference, and every real OAuth credential lives in
|
|
16
|
-
* its own account entry. One transaction writes both, so adding an account can never drop
|
|
17
|
-
* another one the way a second plain `/login` does.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
/** Placeholders, never a token. The Codex API rejects them because they carry no account claim. */
|
|
21
|
-
export const POOL_REFERENCE_TOKEN = "pi-openai-account-pool:reference-not-a-token";
|
|
22
|
-
/** Request auth the wrapped provider must replace with a selected account before sending. */
|
|
23
|
-
export const POOL_UNRESOLVED_API_KEY = "pi-openai-account-pool:unresolved-account";
|
|
24
|
-
const POOL_REFERENCE_FIELD = "piOpenAIAccountPool";
|
|
25
|
-
/** Non-secret membership data on each account entry, so the reference can be rebuilt from the file. */
|
|
26
|
-
const POOL_MEMBERSHIP_FIELD = "piOpenAIAccountPoolMembership";
|
|
27
|
-
/** Far future so pre-request resolution never tries to refresh the reference itself. */
|
|
28
|
-
const POOL_REFERENCE_EXPIRES_MS = Date.UTC(2100, 0, 1);
|
|
29
|
-
const JWT_ACCOUNT_CLAIM = "https://api.openai.com/auth";
|
|
30
|
-
const JWT_PROFILE_CLAIM = "https://api.openai.com/profile";
|
|
31
|
-
const MAX_EMAIL_LENGTH = 254;
|
|
32
|
-
const DEFAULT_MIN_VALIDITY_MS = 5 * 60_000;
|
|
33
|
-
|
|
34
|
-
/** Non-secret identity claims, so two subscriptions can be told apart by name. */
|
|
35
|
-
export interface AccountProfile {
|
|
36
|
-
/** ChatGPT sign-in address from the token's profile claim. */
|
|
37
|
-
email?: string;
|
|
38
|
-
/** Subscription tier claim, e.g. `plus` or `pro`. */
|
|
39
|
-
plan?: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface PoolAccount extends AccountProfile {
|
|
43
|
-
id: string;
|
|
44
|
-
/** Storage key of this account's real credential. */
|
|
45
|
-
key: string;
|
|
46
|
-
/** Stable display number; never reused after a removal. */
|
|
47
|
-
ordinal: number;
|
|
48
|
-
addedAt: number;
|
|
49
|
-
generation: number;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export interface PoolReference {
|
|
53
|
-
v: 1;
|
|
54
|
-
/** Bumped on every add, update and removal. */
|
|
55
|
-
generation: number;
|
|
56
|
-
accounts: PoolAccount[];
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export class ExistingLoginNotIdentifiableError extends Error {
|
|
60
|
-
constructor() {
|
|
61
|
-
super("The stored openai-codex login has no account id, so it cannot be kept as a pool account. Sign in again to replace it.");
|
|
62
|
-
this.name = "ExistingLoginNotIdentifiableError";
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export class PoolNotEnabledError extends Error {
|
|
67
|
-
constructor() {
|
|
68
|
-
super("The openai-codex account pool is not enabled.");
|
|
69
|
-
this.name = "PoolNotEnabledError";
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export class PoolAccountMissingError extends Error {
|
|
74
|
-
constructor(accountId: string) {
|
|
75
|
-
super(`No stored credential for pool account ${maskAccountId(accountId)}. Re-authorize it.`);
|
|
76
|
-
this.name = "PoolAccountMissingError";
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export function maskAccountId(accountId: string): string {
|
|
81
|
-
return accountId.length <= 4 ? "…" : `…${accountId.slice(-4)}`;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** What the user sees: the sign-in address when the token carries it, else the masked id. */
|
|
85
|
-
export function accountLabel(account: PoolAccount): string {
|
|
86
|
-
return `#${account.ordinal} (${account.email ?? maskAccountId(account.id)})`;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function isOAuthEntry(entry: AuthEntry | undefined): boolean {
|
|
90
|
-
return (
|
|
91
|
-
!!entry &&
|
|
92
|
-
entry.type === "oauth" &&
|
|
93
|
-
typeof entry.access === "string" &&
|
|
94
|
-
typeof entry.refresh === "string" &&
|
|
95
|
-
typeof entry.expires === "number" &&
|
|
96
|
-
Number.isFinite(entry.expires)
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function accessTokenClaims(entry: AuthEntry | undefined): Record<string, unknown> | undefined {
|
|
101
|
-
if (typeof entry?.access !== "string") return undefined;
|
|
102
|
-
const segments = entry.access.split(".");
|
|
103
|
-
if (segments.length !== 3) return undefined;
|
|
104
|
-
try {
|
|
105
|
-
const payload = JSON.parse(Buffer.from(segments[1], "base64url").toString("utf8")) as unknown;
|
|
106
|
-
return typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : undefined;
|
|
107
|
-
} catch {
|
|
108
|
-
return undefined;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Account id from the stored field, falling back to the access token's account claim. */
|
|
113
|
-
export function accountIdOf(entry: AuthEntry | undefined): string | undefined {
|
|
114
|
-
if (!entry) return undefined;
|
|
115
|
-
if (typeof entry.accountId === "string" && entry.accountId.length > 0) return entry.accountId;
|
|
116
|
-
const claim = accessTokenClaims(entry)?.[JWT_ACCOUNT_CLAIM] as { chatgpt_account_id?: unknown } | undefined;
|
|
117
|
-
return typeof claim?.chatgpt_account_id === "string" && claim.chatgpt_account_id.length > 0 ? claim.chatgpt_account_id : undefined;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** Keeps anything unexpected out of the labels the user reads. */
|
|
121
|
-
function sanitizeProfile(profile: { email?: unknown; plan?: unknown }): AccountProfile {
|
|
122
|
-
const email = typeof profile.email === "string" ? profile.email.trim() : "";
|
|
123
|
-
const plan = typeof profile.plan === "string" ? profile.plan.trim() : "";
|
|
124
|
-
return {
|
|
125
|
-
email: email.length <= MAX_EMAIL_LENGTH && /^[^\s@"]+@[^\s@"]+\.[^\s@"]+$/.test(email) ? email : undefined,
|
|
126
|
-
plan: /^[a-z0-9_-]{1,32}$/i.test(plan) ? plan : undefined,
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** Identity claims of a stored credential. None of these is usable as a credential itself. */
|
|
131
|
-
export function accountProfileOf(entry: AuthEntry | undefined): AccountProfile {
|
|
132
|
-
const claims = accessTokenClaims(entry);
|
|
133
|
-
const profile = claims?.[JWT_PROFILE_CLAIM] as { email?: unknown } | undefined;
|
|
134
|
-
const auth = claims?.[JWT_ACCOUNT_CLAIM] as { chatgpt_plan_type?: unknown } | undefined;
|
|
135
|
-
return sanitizeProfile({ email: profile?.email, plan: auth?.chatgpt_plan_type });
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** The token is the fresher source, so a rotated credential updates a stored label. */
|
|
139
|
-
function withAccountProfile(account: PoolAccount, entry: AuthEntry | undefined): PoolAccount {
|
|
140
|
-
const profile = accountProfileOf(entry);
|
|
141
|
-
return { ...account, email: profile.email ?? account.email, plan: profile.plan ?? account.plan };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
export function readPoolReference(entry: AuthEntry | undefined): PoolReference | undefined {
|
|
145
|
-
const raw = entry?.[POOL_REFERENCE_FIELD] as Partial<PoolReference> | undefined;
|
|
146
|
-
if (!raw || raw.v !== 1 || typeof raw.generation !== "number" || !Array.isArray(raw.accounts)) return undefined;
|
|
147
|
-
const accounts: PoolAccount[] = [];
|
|
148
|
-
for (const candidate of raw.accounts as Partial<PoolAccount>[]) {
|
|
149
|
-
if (
|
|
150
|
-
typeof candidate?.id !== "string" ||
|
|
151
|
-
typeof candidate.key !== "string" ||
|
|
152
|
-
typeof candidate.ordinal !== "number" ||
|
|
153
|
-
typeof candidate.addedAt !== "number" ||
|
|
154
|
-
typeof candidate.generation !== "number"
|
|
155
|
-
) {
|
|
156
|
-
return undefined;
|
|
157
|
-
}
|
|
158
|
-
accounts.push({
|
|
159
|
-
id: candidate.id,
|
|
160
|
-
key: candidate.key,
|
|
161
|
-
ordinal: candidate.ordinal,
|
|
162
|
-
addedAt: candidate.addedAt,
|
|
163
|
-
generation: candidate.generation,
|
|
164
|
-
...sanitizeProfile(candidate),
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
return { v: 1, generation: raw.generation, accounts };
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
export function isPoolReferenceEntry(entry: AuthEntry | undefined): boolean {
|
|
171
|
-
return readPoolReference(entry) !== undefined;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
interface AccountMembership {
|
|
175
|
-
ordinal: number;
|
|
176
|
-
addedAt: number;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function readMembership(entry: AuthEntry | undefined): AccountMembership | undefined {
|
|
180
|
-
const raw = entry?.[POOL_MEMBERSHIP_FIELD] as Partial<AccountMembership> | undefined;
|
|
181
|
-
if (!raw || typeof raw.ordinal !== "number" || typeof raw.addedAt !== "number") return undefined;
|
|
182
|
-
return { ordinal: raw.ordinal, addedAt: raw.addedAt };
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function withMembership(credential: AuthEntry, membership: AccountMembership): AuthEntry {
|
|
186
|
-
return { ...accountCredential(credential), [POOL_MEMBERSHIP_FIELD]: membership };
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/** The credential alone, so a restored plain login carries no pool bookkeeping. */
|
|
190
|
-
export function accountCredential(entry: AuthEntry): AuthEntry {
|
|
191
|
-
const credential = { ...entry };
|
|
192
|
-
delete credential[POOL_MEMBERSHIP_FIELD];
|
|
193
|
-
return credential;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Accounts stored under the pool prefix that the main entry no longer references, which is what
|
|
198
|
-
* a plain `/login` or `/logout` leaves behind. They are re-adopted explicitly and reported, never
|
|
199
|
-
* silently resurrected as an extra quota source.
|
|
200
|
-
*/
|
|
201
|
-
function findOrphanedAccounts(entries: AuthEntries, known: PoolAccount[]): PoolAccount[] {
|
|
202
|
-
const knownKeys = new Set(known.map((account) => account.key));
|
|
203
|
-
const orphans: PoolAccount[] = [];
|
|
204
|
-
for (const [key, entry] of Object.entries(entries)) {
|
|
205
|
-
if (!isPoolEntryKey(key) || knownKeys.has(key) || !isOAuthEntry(entry)) continue;
|
|
206
|
-
const id = accountIdOf(entry);
|
|
207
|
-
const membership = readMembership(entry);
|
|
208
|
-
// A key that does not match its own account id is not trustworthy membership data.
|
|
209
|
-
if (!id || !membership || poolEntryKey(id) !== key) continue;
|
|
210
|
-
orphans.push({ id, key, ordinal: membership.ordinal, addedAt: membership.addedAt, generation: 0 });
|
|
211
|
-
}
|
|
212
|
-
return orphans.sort((left, right) => left.ordinal - right.ordinal);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
export function buildPoolReferenceEntry(reference: PoolReference): AuthEntry {
|
|
216
|
-
return {
|
|
217
|
-
type: "oauth",
|
|
218
|
-
access: POOL_REFERENCE_TOKEN,
|
|
219
|
-
refresh: POOL_REFERENCE_TOKEN,
|
|
220
|
-
expires: POOL_REFERENCE_EXPIRES_MS,
|
|
221
|
-
[POOL_REFERENCE_FIELD]: reference,
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
export interface PoolSnapshot {
|
|
226
|
-
enabled: boolean;
|
|
227
|
-
generation: number;
|
|
228
|
-
accounts: PoolAccount[];
|
|
229
|
-
/** True when the main entry holds a real credential, i.e. plain single-account login. */
|
|
230
|
-
nativeLoginPresent: boolean;
|
|
231
|
-
/** True when nothing is stored for openai-codex at all. */
|
|
232
|
-
loggedOut: boolean;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
export function poolSnapshotFromEntries(entries: AuthEntries): PoolSnapshot {
|
|
236
|
-
const main = entries[MAIN_PROVIDER_ID];
|
|
237
|
-
const reference = readPoolReference(main);
|
|
238
|
-
if (reference) {
|
|
239
|
-
const accounts = reference.accounts.map((account) => withAccountProfile(account, entries[account.key]));
|
|
240
|
-
return { enabled: true, generation: reference.generation, accounts, nativeLoginPresent: false, loggedOut: false };
|
|
241
|
-
}
|
|
242
|
-
return {
|
|
243
|
-
enabled: false,
|
|
244
|
-
generation: 0,
|
|
245
|
-
accounts: [],
|
|
246
|
-
nativeLoginPresent: main !== undefined,
|
|
247
|
-
loggedOut: main === undefined,
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
export async function loadPoolSnapshot(authPath: string): Promise<PoolSnapshot> {
|
|
252
|
-
return poolSnapshotFromEntries(await readAuthEntries(authPath));
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
export interface CommitAccountResult {
|
|
256
|
-
outcome: "added" | "updated";
|
|
257
|
-
account: PoolAccount;
|
|
258
|
-
accountCount: number;
|
|
259
|
-
/** True when an existing plain login was kept as a pool account by this commit. */
|
|
260
|
-
migratedExistingLogin: boolean;
|
|
261
|
-
/** Previously stored accounts this commit brought back under the pool reference. */
|
|
262
|
-
readoptedAccounts: number;
|
|
263
|
-
generation: number;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Store one freshly authorized credential and publish the pool reference in the same atomic
|
|
268
|
-
* write. The first successful commit also keeps the existing plain login as an account, which
|
|
269
|
-
* is how the pool turns on without a separate setup step.
|
|
270
|
-
*/
|
|
271
|
-
export async function commitAccount(input: {
|
|
272
|
-
authPath: string;
|
|
273
|
-
credential: AuthEntry;
|
|
274
|
-
now?: number;
|
|
275
|
-
signal?: AbortSignal;
|
|
276
|
-
}): Promise<CommitAccountResult> {
|
|
277
|
-
if (!isOAuthEntry(input.credential)) throw new Error("Refusing to store a credential that is not an OAuth credential.");
|
|
278
|
-
const accountId = accountIdOf(input.credential);
|
|
279
|
-
if (!accountId) throw new Error("The login returned no account id; refusing to store an unidentifiable account.");
|
|
280
|
-
const now = input.now ?? Date.now();
|
|
281
|
-
|
|
282
|
-
return withAuthTransaction(input.authPath, (entries) => {
|
|
283
|
-
const main = entries[MAIN_PROVIDER_ID];
|
|
284
|
-
const reference = readPoolReference(main);
|
|
285
|
-
const generation = (reference?.generation ?? 0) + 1;
|
|
286
|
-
const accounts = reference ? [...reference.accounts] : [];
|
|
287
|
-
const next: AuthEntries = { ...entries };
|
|
288
|
-
let migratedExistingLogin = false;
|
|
289
|
-
|
|
290
|
-
// Without a reference, a real credential in the main entry is the user's plain login.
|
|
291
|
-
if (!reference && isOAuthEntry(main)) {
|
|
292
|
-
const existingId = accountIdOf(main);
|
|
293
|
-
if (!existingId) throw new ExistingLoginNotIdentifiableError();
|
|
294
|
-
migratedExistingLogin = true;
|
|
295
|
-
if (existingId !== accountId) {
|
|
296
|
-
const key = poolEntryKey(existingId);
|
|
297
|
-
const membership = readMembership(entries[key]) ?? { ordinal: 1, addedAt: now };
|
|
298
|
-
const kept: PoolAccount = { id: existingId, key, ordinal: membership.ordinal, addedAt: membership.addedAt, generation };
|
|
299
|
-
accounts.push(withAccountProfile(kept, main));
|
|
300
|
-
next[key] = withMembership(main as AuthEntry, membership);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
const readopted = findOrphanedAccounts(next, accounts).filter((orphan) => orphan.id !== accountId);
|
|
305
|
-
for (const orphan of readopted) accounts.push(withAccountProfile({ ...orphan, generation }, next[orphan.key]));
|
|
306
|
-
accounts.sort((left, right) => left.ordinal - right.ordinal);
|
|
307
|
-
|
|
308
|
-
const existingIndex = accounts.findIndex((candidate) => candidate.id === accountId);
|
|
309
|
-
let account: PoolAccount;
|
|
310
|
-
let outcome: CommitAccountResult["outcome"];
|
|
311
|
-
if (existingIndex >= 0) {
|
|
312
|
-
// A second login with the same account refreshes its credential and label in place.
|
|
313
|
-
account = withAccountProfile({ ...accounts[existingIndex], generation }, input.credential);
|
|
314
|
-
accounts[existingIndex] = account;
|
|
315
|
-
outcome = "updated";
|
|
316
|
-
} else {
|
|
317
|
-
const key = poolEntryKey(accountId);
|
|
318
|
-
// Reuse the display number of an account that was stored before, so status stays stable.
|
|
319
|
-
const membership = readMembership(entries[key]);
|
|
320
|
-
const ordinal = membership?.ordinal ?? accounts.reduce((highest, candidate) => Math.max(highest, candidate.ordinal), 0) + 1;
|
|
321
|
-
account = withAccountProfile({ id: accountId, key, ordinal, addedAt: membership?.addedAt ?? now, generation }, input.credential);
|
|
322
|
-
accounts.push(account);
|
|
323
|
-
outcome = "added";
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
next[account.key] = withMembership({ ...input.credential }, { ordinal: account.ordinal, addedAt: account.addedAt });
|
|
327
|
-
next[MAIN_PROVIDER_ID] = buildPoolReferenceEntry({ v: 1, generation, accounts });
|
|
328
|
-
return {
|
|
329
|
-
value: next,
|
|
330
|
-
result: { outcome, account, accountCount: accounts.length, migratedExistingLogin, readoptedAccounts: readopted.length, generation },
|
|
331
|
-
};
|
|
332
|
-
}, { signal: input.signal });
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/** Restore one account as the plain `openai-codex` login and give up the pool's authority over it. */
|
|
336
|
-
export async function disablePool(input: { authPath: string; accountId: string; signal?: AbortSignal }): Promise<{ restored: PoolAccount; dormantAccounts: number }> {
|
|
337
|
-
return withAuthTransaction(input.authPath, (entries) => {
|
|
338
|
-
const reference = readPoolReference(entries[MAIN_PROVIDER_ID]);
|
|
339
|
-
if (!reference) throw new PoolNotEnabledError();
|
|
340
|
-
const account = reference.accounts.find((candidate) => candidate.id === input.accountId);
|
|
341
|
-
if (!account) throw new PoolAccountMissingError(input.accountId);
|
|
342
|
-
const credential = entries[account.key];
|
|
343
|
-
if (!isOAuthEntry(credential)) throw new PoolAccountMissingError(input.accountId);
|
|
344
|
-
const next: AuthEntries = { ...entries };
|
|
345
|
-
// One refresh authority after off: the restored account exists only in the main entry.
|
|
346
|
-
delete next[account.key];
|
|
347
|
-
next[MAIN_PROVIDER_ID] = accountCredential(credential);
|
|
348
|
-
return { value: next, result: { restored: account, dormantAccounts: reference.accounts.length - 1 } };
|
|
349
|
-
}, { signal: input.signal });
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/** Delete one account's local credential. Removing the last one leaves openai-codex logged out. */
|
|
353
|
-
export async function removeAccount(input: { authPath: string; accountId: string; signal?: AbortSignal }): Promise<{ removed: PoolAccount; remaining: number; poolDisabled: boolean }> {
|
|
354
|
-
return withAuthTransaction(input.authPath, (entries) => {
|
|
355
|
-
const reference = readPoolReference(entries[MAIN_PROVIDER_ID]);
|
|
356
|
-
if (!reference) throw new PoolNotEnabledError();
|
|
357
|
-
const account = reference.accounts.find((candidate) => candidate.id === input.accountId);
|
|
358
|
-
if (!account) throw new PoolAccountMissingError(input.accountId);
|
|
359
|
-
const remaining = reference.accounts.filter((candidate) => candidate.id !== input.accountId);
|
|
360
|
-
const next: AuthEntries = { ...entries };
|
|
361
|
-
delete next[account.key];
|
|
362
|
-
if (remaining.length === 0) delete next[MAIN_PROVIDER_ID];
|
|
363
|
-
else next[MAIN_PROVIDER_ID] = buildPoolReferenceEntry({ v: 1, generation: reference.generation + 1, accounts: remaining });
|
|
364
|
-
return { value: next, result: { removed: account, remaining: remaining.length, poolDisabled: remaining.length === 0 } };
|
|
365
|
-
}, { signal: input.signal });
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
export interface FreshCredential {
|
|
369
|
-
credential: AuthEntry;
|
|
370
|
-
refreshed: boolean;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/**
|
|
374
|
-
* Resolve one account's usable credential, refreshing under the lock with the same
|
|
375
|
-
* double-checked pattern pi uses: another process may have rotated the token already, and a
|
|
376
|
-
* refresh that started must persist even when the request that triggered it is cancelled.
|
|
377
|
-
*/
|
|
378
|
-
export async function ensureFreshAccountCredential(input: {
|
|
379
|
-
authPath: string;
|
|
380
|
-
account: PoolAccount;
|
|
381
|
-
refresh: (credential: AuthEntry) => Promise<AuthEntry>;
|
|
382
|
-
minValidityMs?: number;
|
|
383
|
-
now?: () => number;
|
|
384
|
-
signal?: AbortSignal;
|
|
385
|
-
/** Rotate even when the stored credential still looks valid, after the server rejected it. */
|
|
386
|
-
force?: boolean;
|
|
387
|
-
/** The access token that was rejected; a credential someone else already rotated is taken as is. */
|
|
388
|
-
staleAccess?: string;
|
|
389
|
-
}): Promise<FreshCredential> {
|
|
390
|
-
const minValidityMs = input.minValidityMs ?? DEFAULT_MIN_VALIDITY_MS;
|
|
391
|
-
const now = input.now ?? (() => Date.now());
|
|
392
|
-
const usable = (entry: AuthEntry) => Number(entry.expires) - now() > minValidityMs;
|
|
393
|
-
|
|
394
|
-
if (!input.force) {
|
|
395
|
-
// Optimistic read outside the lock, like pi's own resolution.
|
|
396
|
-
const current = (await readAuthEntries(input.authPath))[input.account.key];
|
|
397
|
-
if (isOAuthEntry(current) && usable(current)) return { credential: accountCredential(current), refreshed: false };
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
return withAuthTransaction(input.authPath, async (entries) => {
|
|
401
|
-
const stored = entries[input.account.key];
|
|
402
|
-
if (!isOAuthEntry(stored)) throw new PoolAccountMissingError(input.account.id);
|
|
403
|
-
// Another request may have rotated the rejected token while this one waited for the lock.
|
|
404
|
-
if (input.force && input.staleAccess !== undefined && stored.access !== input.staleAccess) {
|
|
405
|
-
return { result: { credential: accountCredential(stored), refreshed: false } };
|
|
406
|
-
}
|
|
407
|
-
if (!input.force && usable(stored)) return { result: { credential: accountCredential(stored), refreshed: false } };
|
|
408
|
-
const refreshed = await input.refresh(accountCredential(stored));
|
|
409
|
-
if (!isOAuthEntry(refreshed)) throw new Error("OAuth refresh returned an invalid credential.");
|
|
410
|
-
// Keep the account identity even if the provider omits it when rotating.
|
|
411
|
-
const rotated: AuthEntry = { ...accountCredential(refreshed), accountId: accountIdOf(refreshed) ?? input.account.id };
|
|
412
|
-
const membership = readMembership(stored) ?? { ordinal: input.account.ordinal, addedAt: input.account.addedAt };
|
|
413
|
-
return {
|
|
414
|
-
value: { ...entries, [input.account.key]: withMembership(rotated, membership) },
|
|
415
|
-
result: { credential: rotated, refreshed: true },
|
|
416
|
-
};
|
|
417
|
-
}, { signal: input.signal });
|
|
418
|
-
}
|