@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,418 @@
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
+ }
@@ -0,0 +1,230 @@
1
+ import type { PoolAccount } from "./auth.ts";
2
+ import { ACCOUNT_SCOPE, type AccountAvailability, type Classification } from "./policy.ts";
3
+ import {
4
+ readPoolState,
5
+ withPoolStateTransaction,
6
+ type AccountCooldown,
7
+ type AccountStateRecord,
8
+ type AccountUsage,
9
+ type PoolStateFile,
10
+ } from "./store.ts";
11
+
12
+ /**
13
+ * Shared availability state. Every pi process using the same agent directory reads the same
14
+ * cooldowns, so a limit one process hit is skipped by the next request anywhere.
15
+ *
16
+ * Two ordering rules keep concurrent requests honest: evidence recorded for an older account
17
+ * generation is ignored after a re-authorization, and a success only clears evidence that was
18
+ * recorded before that request started.
19
+ */
20
+
21
+ /** Local backoff when the server stated no reset time. Not a claimed quota reset. */
22
+ export const DEFAULT_PROBE_BACKOFF_MS = 60_000;
23
+ export const DEFAULT_PROBE_LEASE_MS = 30_000;
24
+
25
+ function emptyRecord(): AccountStateRecord {
26
+ return { cooldowns: {} };
27
+ }
28
+
29
+ function cooldownEndMs(cooldown: AccountCooldown): number {
30
+ return Math.max(cooldown.serverResetAtMs ?? 0, cooldown.probeAfterMs ?? 0);
31
+ }
32
+
33
+ /** Cooldowns that still describe this account: same generation, and in scope for this request. */
34
+ function relevantCooldowns(record: AccountStateRecord | undefined, account: PoolAccount, modelId?: string): AccountCooldown[] {
35
+ const scopes = modelId ? [ACCOUNT_SCOPE, `model:${modelId}`] : [ACCOUNT_SCOPE];
36
+ return scopes
37
+ .map((scope) => record?.cooldowns?.[scope])
38
+ .filter((cooldown): cooldown is AccountCooldown => !!cooldown && cooldown.generation === account.generation);
39
+ }
40
+
41
+ export function availabilityFor(input: {
42
+ accounts: readonly PoolAccount[];
43
+ state: PoolStateFile;
44
+ now: number;
45
+ modelId?: string;
46
+ /** This process's probe owner id, so its own lease does not block it. */
47
+ probeOwner?: string;
48
+ }): AccountAvailability[] {
49
+ return input.accounts.map((account) => {
50
+ const record = input.state.accounts[account.id];
51
+ const authInvalid = record?.authInvalidGeneration === account.generation;
52
+ const cooldowns = relevantCooldowns(record, account, input.modelId);
53
+ const active = cooldowns.filter((cooldown) => cooldownEndMs(cooldown) > input.now);
54
+ if (active.length > 0) {
55
+ const blocking = active.reduce((latest, cooldown) => (cooldownEndMs(cooldown) > cooldownEndMs(latest) ? cooldown : latest));
56
+ return {
57
+ id: account.id,
58
+ ordinal: account.ordinal,
59
+ authInvalid,
60
+ needsProbe: false,
61
+ unavailable: { reason: blocking.reason, untilMs: cooldownEndMs(blocking), serverResetAtMs: blocking.serverResetAtMs },
62
+ };
63
+ }
64
+ const needsProbe = cooldowns.length > 0;
65
+ const lease = record?.probeLease;
66
+ if (lease && lease.untilMs > input.now && lease.owner !== input.probeOwner) {
67
+ // Another process is already testing whether this account recovered.
68
+ return {
69
+ id: account.id,
70
+ ordinal: account.ordinal,
71
+ authInvalid,
72
+ needsProbe: true,
73
+ unavailable: { reason: "probe_in_progress", untilMs: lease.untilMs, serverResetAtMs: null },
74
+ };
75
+ }
76
+ return { id: account.id, ordinal: account.ordinal, authInvalid, needsProbe };
77
+ });
78
+ }
79
+
80
+ export async function readAvailability(input: {
81
+ statePath: string;
82
+ accounts: readonly PoolAccount[];
83
+ now: number;
84
+ modelId?: string;
85
+ probeOwner?: string;
86
+ }): Promise<AccountAvailability[]> {
87
+ return availabilityFor({ ...input, state: await readPoolState(input.statePath) });
88
+ }
89
+
90
+ /** Availability and the manual preference from one read, so a request sees one consistent state. */
91
+ export async function readSelectionState(input: {
92
+ statePath: string;
93
+ accounts: readonly PoolAccount[];
94
+ now: number;
95
+ modelId?: string;
96
+ probeOwner?: string;
97
+ }): Promise<{ availability: AccountAvailability[]; preferredAccountId?: string }> {
98
+ const state = await readPoolState(input.statePath);
99
+ return { availability: availabilityFor({ ...input, state }), preferredAccountId: state.preferredAccountId };
100
+ }
101
+
102
+ export async function readPreferredAccountId(statePath: string): Promise<string | undefined> {
103
+ return (await readPoolState(statePath)).preferredAccountId;
104
+ }
105
+
106
+ /** Pins the account every process starts from, or clears the pin when given `undefined`. */
107
+ export async function setPreferredAccountId(input: { statePath: string; accountId: string | undefined }): Promise<void> {
108
+ await withPoolStateTransaction(input.statePath, (state) => {
109
+ if (state.preferredAccountId === input.accountId) return { result: undefined };
110
+ const next = { ...state };
111
+ if (input.accountId === undefined) delete next.preferredAccountId;
112
+ else next.preferredAccountId = input.accountId;
113
+ return { value: next, result: undefined };
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Stores the quota a response reported. The newest observation wins, so a slow write cannot
119
+ * replace a fresher reading with a stale one. No generation guard: quota belongs to the ChatGPT
120
+ * account itself and survives re-authorization, unlike the credential.
121
+ */
122
+ export async function recordUsage(input: {
123
+ statePath: string;
124
+ account: PoolAccount;
125
+ usage: AccountUsage;
126
+ /** An available live observation disproves only quota evidence no newer than itself. */
127
+ clearQuotaCooldowns?: boolean;
128
+ }): Promise<boolean> {
129
+ return withPoolStateTransaction(input.statePath, (state) => {
130
+ const existing = state.accounts[input.account.id];
131
+ if ((existing?.usage?.observedAtMs ?? 0) >= input.usage.observedAtMs) return { result: false };
132
+ const record: AccountStateRecord = { ...(existing ?? emptyRecord()), usage: input.usage };
133
+ if (input.clearQuotaCooldowns) {
134
+ record.cooldowns = Object.fromEntries(
135
+ Object.entries(record.cooldowns ?? {}).filter(
136
+ ([, cooldown]) =>
137
+ cooldown.generation !== input.account.generation || cooldown.recordedAtMs > input.usage.observedAtMs,
138
+ ),
139
+ );
140
+ }
141
+ return { value: { ...state, accounts: { ...state.accounts, [input.account.id]: record } }, result: true };
142
+ });
143
+ }
144
+
145
+ export async function recordFailure(input: {
146
+ statePath: string;
147
+ account: PoolAccount;
148
+ classification: Classification;
149
+ now: number;
150
+ probeBackoffMs?: number;
151
+ }): Promise<void> {
152
+ const { classification, account, now } = input;
153
+ if (classification.kind !== "quota" && classification.kind !== "auth_invalid") return;
154
+ await withPoolStateTransaction(input.statePath, (state) => {
155
+ const record: AccountStateRecord = { ...(state.accounts[account.id] ?? emptyRecord()) };
156
+ record.cooldowns = { ...record.cooldowns };
157
+ if (classification.kind === "auth_invalid") {
158
+ record.authInvalidGeneration = account.generation;
159
+ record.authInvalidAtMs = now;
160
+ } else {
161
+ const existing = record.cooldowns[classification.scope];
162
+ // Evidence from an older generation must not outrank a newer login.
163
+ if (existing && existing.generation > account.generation) return { result: undefined };
164
+ record.cooldowns[classification.scope] = {
165
+ reason: classification.reason,
166
+ generation: account.generation,
167
+ serverResetAtMs: classification.serverResetAtMs,
168
+ // Without a server reset time, only a short local backoff before probing again.
169
+ probeAfterMs: classification.serverResetAtMs === null ? now + (input.probeBackoffMs ?? DEFAULT_PROBE_BACKOFF_MS) : null,
170
+ recordedAtMs: now,
171
+ };
172
+ }
173
+ delete record.probeLease;
174
+ return { value: { ...state, accounts: { ...state.accounts, [account.id]: record } }, result: undefined };
175
+ });
176
+ }
177
+
178
+ /** Clear only the evidence that predates this request, so a slow success cannot undo a new limit. */
179
+ export async function recordSuccess(input: {
180
+ statePath: string;
181
+ account: PoolAccount;
182
+ requestStartedAtMs: number;
183
+ clearEvidence?: boolean;
184
+ }): Promise<void> {
185
+ await withPoolStateTransaction(input.statePath, (state) => {
186
+ const record = state.accounts[input.account.id];
187
+ if (!record || input.clearEvidence === false) {
188
+ return { value: { ...state, currentAccountId: input.account.id }, result: undefined };
189
+ }
190
+ const cooldowns: Record<string, AccountCooldown> = {};
191
+ for (const [scope, cooldown] of Object.entries(record.cooldowns ?? {})) {
192
+ const stale = cooldown.generation !== input.account.generation;
193
+ if (!stale && cooldown.recordedAtMs > input.requestStartedAtMs) cooldowns[scope] = cooldown;
194
+ }
195
+ // Usage is an observation, not evidence of failure, so success does not invalidate it.
196
+ const next: AccountStateRecord = { cooldowns, ...(record.usage ? { usage: record.usage } : {}) };
197
+ if (record.authInvalidGeneration === input.account.generation && (record.authInvalidAtMs ?? 0) > input.requestStartedAtMs) {
198
+ next.authInvalidGeneration = record.authInvalidGeneration;
199
+ next.authInvalidAtMs = record.authInvalidAtMs;
200
+ }
201
+ return { value: { ...state, currentAccountId: input.account.id, accounts: { ...state.accounts, [input.account.id]: next } }, result: undefined };
202
+ });
203
+ }
204
+
205
+ /** Bounded lease so a recovered account is probed by one process, not by all of them. */
206
+ export async function claimProbeLease(input: {
207
+ statePath: string;
208
+ accountId: string;
209
+ owner: string;
210
+ now: number;
211
+ ttlMs?: number;
212
+ }): Promise<boolean> {
213
+ return withPoolStateTransaction(input.statePath, (state) => {
214
+ const record: AccountStateRecord = { ...(state.accounts[input.accountId] ?? emptyRecord()) };
215
+ const lease = record.probeLease;
216
+ if (lease && lease.untilMs > input.now && lease.owner !== input.owner) return { result: false };
217
+ record.probeLease = { owner: input.owner, untilMs: input.now + (input.ttlMs ?? DEFAULT_PROBE_LEASE_MS) };
218
+ return { value: { ...state, accounts: { ...state.accounts, [input.accountId]: record } }, result: true };
219
+ });
220
+ }
221
+
222
+ export async function releaseProbeLease(input: { statePath: string; accountId: string; owner: string }): Promise<void> {
223
+ await withPoolStateTransaction(input.statePath, (state) => {
224
+ const record = state.accounts[input.accountId];
225
+ if (record?.probeLease?.owner !== input.owner) return { result: undefined };
226
+ const next = { ...record };
227
+ delete next.probeLease;
228
+ return { value: { ...state, accounts: { ...state.accounts, [input.accountId]: next } }, result: undefined };
229
+ });
230
+ }
@@ -0,0 +1,18 @@
1
+ export const CODEX_POOL_SUBCOMMANDS = ["status", "use", "remove", "off"] as const;
2
+
3
+ export interface CommandCompletion {
4
+ value: string;
5
+ label: string;
6
+ }
7
+
8
+ /**
9
+ * Pi consumes Enter to accept an argument completion. Once a subcommand is exact,
10
+ * hide its identical candidate so Enter submits the slash command immediately.
11
+ */
12
+ export function codexPoolArgumentCompletions(prefix: string): CommandCompletion[] {
13
+ const normalized = prefix.trim();
14
+ if (normalized.includes(" ") || CODEX_POOL_SUBCOMMANDS.some((name) => name === normalized)) return [];
15
+ return CODEX_POOL_SUBCOMMANDS
16
+ .filter((name) => name.startsWith(normalized))
17
+ .map((name) => ({ value: name, label: name }));
18
+ }