@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,128 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { readPoolState, withPoolStateTransaction, type EnrollmentLeaseRecord, type PoolStateFile } from "./store.ts";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Membership changes hold a lease instead of the credential lock, because adding an account
|
|
6
|
-
* waits for a browser. The lease keeps two pi processes from running wizards or an off at the
|
|
7
|
-
* same time, and it expires so a crashed command recovers without manual cleanup.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export const DEFAULT_ENROLLMENT_TTL_MS = 120_000;
|
|
11
|
-
const RENEW_SAFETY_MS = 5_000;
|
|
12
|
-
|
|
13
|
-
export class EnrollmentInProgressError extends Error {
|
|
14
|
-
constructor(record: EnrollmentLeaseRecord) {
|
|
15
|
-
super(`Another pi process is changing the openai-codex account pool (${record.purpose}, pid ${record.processId}). Finish or close it first.`);
|
|
16
|
-
this.name = "EnrollmentInProgressError";
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export class EnrollmentLeaseLostError extends Error {
|
|
21
|
-
constructor() {
|
|
22
|
-
super("This pool operation lost its lease, so it must not change accounts. Run the command again.");
|
|
23
|
-
this.name = "EnrollmentLeaseLostError";
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface EnrollmentLease {
|
|
28
|
-
record: EnrollmentLeaseRecord;
|
|
29
|
-
/** Extend while a long step such as a browser authorization is still running. */
|
|
30
|
-
renew(): Promise<void>;
|
|
31
|
-
release(): Promise<void>;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function isProcessAlive(processId: number): boolean {
|
|
35
|
-
if (!Number.isInteger(processId) || processId <= 0) return false;
|
|
36
|
-
try {
|
|
37
|
-
process.kill(processId, 0);
|
|
38
|
-
return true;
|
|
39
|
-
} catch (error) {
|
|
40
|
-
// EPERM means the process exists but belongs to someone else.
|
|
41
|
-
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function activeEnrollment(state: PoolStateFile, now: number): EnrollmentLeaseRecord | undefined {
|
|
46
|
-
const record = state.enrollment;
|
|
47
|
-
if (!record) return undefined;
|
|
48
|
-
if (record.expiresAtMs <= now) return undefined;
|
|
49
|
-
if (record.processId !== process.pid && !isProcessAlive(record.processId)) return undefined;
|
|
50
|
-
return record;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export async function assertNoActiveEnrollment(input: { statePath: string; now?: number }): Promise<void> {
|
|
54
|
-
const record = activeEnrollment(await readPoolState(input.statePath), input.now ?? Date.now());
|
|
55
|
-
if (record) throw new EnrollmentInProgressError(record);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export async function acquireEnrollmentLease(input: {
|
|
59
|
-
statePath: string;
|
|
60
|
-
purpose: string;
|
|
61
|
-
ttlMs?: number;
|
|
62
|
-
now?: () => number;
|
|
63
|
-
signal?: AbortSignal;
|
|
64
|
-
}): Promise<EnrollmentLease> {
|
|
65
|
-
const now = input.now ?? (() => Date.now());
|
|
66
|
-
const ttlMs = input.ttlMs ?? DEFAULT_ENROLLMENT_TTL_MS;
|
|
67
|
-
const owner = randomUUID();
|
|
68
|
-
|
|
69
|
-
const record = await withPoolStateTransaction(input.statePath, (state) => {
|
|
70
|
-
const held = activeEnrollment(state, now());
|
|
71
|
-
if (held) throw new EnrollmentInProgressError(held);
|
|
72
|
-
const mine: EnrollmentLeaseRecord = {
|
|
73
|
-
owner,
|
|
74
|
-
processId: process.pid,
|
|
75
|
-
purpose: input.purpose,
|
|
76
|
-
startedAtMs: now(),
|
|
77
|
-
expiresAtMs: now() + ttlMs,
|
|
78
|
-
};
|
|
79
|
-
return { value: { ...state, enrollment: mine }, result: mine };
|
|
80
|
-
}, { signal: input.signal });
|
|
81
|
-
|
|
82
|
-
let current = record;
|
|
83
|
-
let released = false;
|
|
84
|
-
|
|
85
|
-
return {
|
|
86
|
-
get record() {
|
|
87
|
-
return current;
|
|
88
|
-
},
|
|
89
|
-
async renew() {
|
|
90
|
-
if (released) throw new EnrollmentLeaseLostError();
|
|
91
|
-
current = await withPoolStateTransaction(input.statePath, (state) => {
|
|
92
|
-
if (state.enrollment?.owner !== owner) throw new EnrollmentLeaseLostError();
|
|
93
|
-
const renewed: EnrollmentLeaseRecord = { ...state.enrollment, expiresAtMs: now() + ttlMs };
|
|
94
|
-
return { value: { ...state, enrollment: renewed }, result: renewed };
|
|
95
|
-
});
|
|
96
|
-
},
|
|
97
|
-
async release() {
|
|
98
|
-
if (released) return;
|
|
99
|
-
released = true;
|
|
100
|
-
await withPoolStateTransaction(input.statePath, (state) => {
|
|
101
|
-
// Only the holder clears it; a reclaimed lease now belongs to another process.
|
|
102
|
-
if (state.enrollment?.owner !== owner) return { result: undefined };
|
|
103
|
-
const next = { ...state };
|
|
104
|
-
delete next.enrollment;
|
|
105
|
-
return { value: next, result: undefined };
|
|
106
|
-
});
|
|
107
|
-
},
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Run one membership change under a lease, renewing it while the body is still working. */
|
|
112
|
-
export async function withEnrollmentLease<T>(
|
|
113
|
-
input: { statePath: string; purpose: string; ttlMs?: number; signal?: AbortSignal },
|
|
114
|
-
run: (lease: EnrollmentLease) => Promise<T>,
|
|
115
|
-
): Promise<T> {
|
|
116
|
-
const lease = await acquireEnrollmentLease(input);
|
|
117
|
-
const ttlMs = input.ttlMs ?? DEFAULT_ENROLLMENT_TTL_MS;
|
|
118
|
-
const timer = setInterval(() => {
|
|
119
|
-
void lease.renew().catch(() => { /* The next write fails visibly through assertHeld/renew. */ });
|
|
120
|
-
}, Math.max(ttlMs - RENEW_SAFETY_MS, RENEW_SAFETY_MS));
|
|
121
|
-
timer.unref?.();
|
|
122
|
-
try {
|
|
123
|
-
return await run(lease);
|
|
124
|
-
} finally {
|
|
125
|
-
clearInterval(timer);
|
|
126
|
-
await lease.release();
|
|
127
|
-
}
|
|
128
|
-
}
|
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
import { rmdirSync } from "node:fs";
|
|
2
|
-
import { mkdir, rmdir, stat, utimes } from "node:fs/promises";
|
|
3
|
-
import { resolve } from "node:path";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Directory lock using the on-disk protocol of `proper-lockfile`, the library pi's own auth
|
|
7
|
-
* backend locks `auth.json` with: `<file>.lock` is a directory, its mtime is the holder's
|
|
8
|
-
* heartbeat, and a heartbeat older than the stale window may be reclaimed. Sharing the
|
|
9
|
-
* protocol is what makes pool writes mutually exclusive with pi's own credential writes.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
/** Reclaim window for a holder that stopped heart-beating. Matches pi's auth-lock wait window. */
|
|
13
|
-
const DEFAULT_STALE_MS = 30_000;
|
|
14
|
-
const DEFAULT_WAIT_MS = 30_000;
|
|
15
|
-
/** Well inside proper-lockfile's 10s default stale window, so pi never reclaims a lock we hold. */
|
|
16
|
-
const HEARTBEAT_MS = 2_000;
|
|
17
|
-
const MAX_BACKOFF_MS = 500;
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Stamps the heartbeat with a unique sub-millisecond time, so a directory another holder
|
|
21
|
-
* recreated is recognised even when it reuses our inode and lands in the same millisecond.
|
|
22
|
-
* Filesystems that only keep whole milliseconds degrade to proper-lockfile's own mtime check.
|
|
23
|
-
*/
|
|
24
|
-
async function stampHeartbeat(directory: string): Promise<bigint> {
|
|
25
|
-
const seconds = (Date.now() + Math.random()) / 1000;
|
|
26
|
-
await utimes(directory, seconds, seconds);
|
|
27
|
-
return (await stat(directory, { bigint: true })).mtimeNs;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export class LockTimeoutError extends Error {
|
|
31
|
-
constructor(path: string) {
|
|
32
|
-
super(`Timed out waiting for ${path}. Another pi process is writing credentials.`);
|
|
33
|
-
this.name = "LockTimeoutError";
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export class LockCompromisedError extends Error {
|
|
38
|
-
constructor(path: string, options?: { cause?: unknown }) {
|
|
39
|
-
super(`Lost the lock on ${path} before the write completed.`, options);
|
|
40
|
-
this.name = "LockCompromisedError";
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface HeldLock {
|
|
45
|
-
/** Throws when the lock was reclaimed or released, so a caller never writes unprotected. */
|
|
46
|
-
assertHeld(): void;
|
|
47
|
-
/** Re-reads the directory, so ownership is checked at write time rather than at the last heartbeat. */
|
|
48
|
-
verifyHeld(): Promise<void>;
|
|
49
|
-
release(): Promise<void>;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export interface FileLockOptions {
|
|
53
|
-
staleMs?: number;
|
|
54
|
-
waitMs?: number;
|
|
55
|
-
signal?: AbortSignal;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const heldDirectories = new Set<string>();
|
|
59
|
-
let exitHookInstalled = false;
|
|
60
|
-
|
|
61
|
-
function installExitHook(): void {
|
|
62
|
-
if (exitHookInstalled) return;
|
|
63
|
-
exitHookInstalled = true;
|
|
64
|
-
process.on("exit", () => {
|
|
65
|
-
for (const directory of heldDirectories) {
|
|
66
|
-
try { rmdirSync(directory); } catch { /* Nothing to clean up when the directory is already gone. */ }
|
|
67
|
-
}
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function lockDirectoryFor(targetPath: string): string {
|
|
72
|
-
return `${resolve(targetPath)}.lock`;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const delay = (milliseconds: number, signal?: AbortSignal) => new Promise<void>((resolveDelay, reject) => {
|
|
76
|
-
const onAbort = () => { clearTimeout(timer); reject(signal?.reason ?? new Error("Lock wait aborted.")); };
|
|
77
|
-
const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolveDelay(); }, milliseconds);
|
|
78
|
-
if (signal?.aborted) onAbort();
|
|
79
|
-
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
/** True when the caller should retry `mkdir`, either because the directory is gone or it was stale. */
|
|
83
|
-
async function reclaimIfStale(directory: string, staleMs: number): Promise<boolean> {
|
|
84
|
-
let identity;
|
|
85
|
-
try { identity = await stat(directory); }
|
|
86
|
-
catch (error) {
|
|
87
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return true;
|
|
88
|
-
throw error;
|
|
89
|
-
}
|
|
90
|
-
if (identity.mtimeMs >= Date.now() - staleMs) return false;
|
|
91
|
-
try { await rmdir(directory); }
|
|
92
|
-
catch (error) {
|
|
93
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
94
|
-
}
|
|
95
|
-
return true;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function holdLock(directory: string, stamp: bigint): HeldLock {
|
|
99
|
-
installExitHook();
|
|
100
|
-
heldDirectories.add(directory);
|
|
101
|
-
let ownStamp = stamp;
|
|
102
|
-
let compromised: Error | undefined;
|
|
103
|
-
let released = false;
|
|
104
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
105
|
-
|
|
106
|
-
const scheduleHeartbeat = () => {
|
|
107
|
-
timer = setTimeout(() => { void heartbeat(); }, HEARTBEAT_MS);
|
|
108
|
-
timer.unref?.();
|
|
109
|
-
};
|
|
110
|
-
/** Any other holder's mkdir or heartbeat replaces our stamp. */
|
|
111
|
-
const assertOwnStamp = async (): Promise<void> => {
|
|
112
|
-
if ((await stat(directory, { bigint: true })).mtimeNs !== ownStamp) throw new LockCompromisedError(directory);
|
|
113
|
-
};
|
|
114
|
-
const heartbeat = async () => {
|
|
115
|
-
if (released) return;
|
|
116
|
-
try {
|
|
117
|
-
await assertOwnStamp();
|
|
118
|
-
ownStamp = await stampHeartbeat(directory);
|
|
119
|
-
} catch (error) {
|
|
120
|
-
compromised = error instanceof LockCompromisedError ? error : new LockCompromisedError(directory, { cause: error });
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
if (!released) scheduleHeartbeat();
|
|
124
|
-
};
|
|
125
|
-
scheduleHeartbeat();
|
|
126
|
-
|
|
127
|
-
return {
|
|
128
|
-
assertHeld() {
|
|
129
|
-
if (compromised) throw compromised;
|
|
130
|
-
if (released) throw new LockCompromisedError(directory);
|
|
131
|
-
},
|
|
132
|
-
async verifyHeld() {
|
|
133
|
-
this.assertHeld();
|
|
134
|
-
try { await assertOwnStamp(); }
|
|
135
|
-
catch (error) {
|
|
136
|
-
compromised = error instanceof LockCompromisedError ? error : new LockCompromisedError(directory, { cause: error });
|
|
137
|
-
throw compromised;
|
|
138
|
-
}
|
|
139
|
-
},
|
|
140
|
-
async release() {
|
|
141
|
-
if (released) return;
|
|
142
|
-
released = true;
|
|
143
|
-
if (timer) clearTimeout(timer);
|
|
144
|
-
heldDirectories.delete(directory);
|
|
145
|
-
// A reclaimed directory belongs to another holder; removing it would break their lock.
|
|
146
|
-
if (compromised) return;
|
|
147
|
-
try { await rmdir(directory); }
|
|
148
|
-
catch (error) {
|
|
149
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
150
|
-
}
|
|
151
|
-
},
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export async function acquireFileLock(targetPath: string, options: FileLockOptions = {}): Promise<HeldLock> {
|
|
156
|
-
const directory = lockDirectoryFor(targetPath);
|
|
157
|
-
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
158
|
-
const deadline = Date.now() + (options.waitMs ?? DEFAULT_WAIT_MS);
|
|
159
|
-
for (let attempt = 0; ; attempt++) {
|
|
160
|
-
options.signal?.throwIfAborted();
|
|
161
|
-
try {
|
|
162
|
-
await mkdir(directory, { mode: 0o700 });
|
|
163
|
-
return holdLock(directory, await stampHeartbeat(directory));
|
|
164
|
-
} catch (error) {
|
|
165
|
-
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
166
|
-
}
|
|
167
|
-
if (await reclaimIfStale(directory, staleMs)) continue;
|
|
168
|
-
if (Date.now() >= deadline) throw new LockTimeoutError(directory);
|
|
169
|
-
await delay(Math.round(Math.min(10 * 2 ** attempt, MAX_BACKOFF_MS) * (1 + Math.random())), options.signal);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
import type { AuthEvent, AuthInfoLink, AuthPrompt, OAuthDeviceCodeInfo, ProviderAuthInteraction } from "@earendil-works/pi-ai";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* One OAuth login inside pi's single editor slot.
|
|
5
|
-
*
|
|
6
|
-
* pi hands extensions one slot below the chat: `ui.select` puts the editor back when it closes,
|
|
7
|
-
* so a selector opened while the login dialog is mounted takes the dialog down with it, and every
|
|
8
|
-
* later auth step renders into a component nobody can see. The Codex flow hits exactly that, as it
|
|
9
|
-
* asks for the login method first and shows the sign-in URL and the manual-code input afterwards.
|
|
10
|
-
*
|
|
11
|
-
* The slot therefore mounts the dialog lazily and steps out of it around every selector, and keeps
|
|
12
|
-
* auth events in the order the provider emitted them.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
/** The part of pi's login dialog this module drives. */
|
|
16
|
-
export interface LoginDialog {
|
|
17
|
-
/** Aborts when the user cancels inside the dialog. */
|
|
18
|
-
readonly signal: AbortSignal;
|
|
19
|
-
showAuth(url: string, instructions?: string): void;
|
|
20
|
-
showDeviceCode(info: OAuthDeviceCodeInfo): void;
|
|
21
|
-
showInfo(message: string, links?: readonly AuthInfoLink[]): void;
|
|
22
|
-
showProgress(message: string): void;
|
|
23
|
-
showWaiting(message: string): void;
|
|
24
|
-
showManualInput(message: string): Promise<string>;
|
|
25
|
-
showPrompt(message: string, placeholder?: string): Promise<string>;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface LoginSlotUi {
|
|
29
|
-
/**
|
|
30
|
-
* Mounts a login dialog in the slot and reports it together with the callback that takes it
|
|
31
|
-
* down again. Resolves once the slot has been handed back.
|
|
32
|
-
*/
|
|
33
|
-
mount(register: (dialog: LoginDialog, close: () => void) => void): Promise<unknown>;
|
|
34
|
-
/** Shows a selector in the same slot. Resolves to undefined when the user cancels. */
|
|
35
|
-
select(message: string, options: readonly string[], signal?: AbortSignal): Promise<string | undefined>;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const CANCELLED = "Login cancelled";
|
|
39
|
-
|
|
40
|
-
/** A prompt can lose its race, e.g. a manual code while the callback server wins. */
|
|
41
|
-
function withCancellation<T>(answer: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
|
42
|
-
if (!signal) return answer;
|
|
43
|
-
if (signal.aborted) return Promise.reject(new Error(CANCELLED));
|
|
44
|
-
let onAbort: (() => void) | undefined;
|
|
45
|
-
const aborted = new Promise<never>((_resolve, reject) => {
|
|
46
|
-
onAbort = () => reject(new Error(CANCELLED));
|
|
47
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
48
|
-
});
|
|
49
|
-
return Promise.race([answer, aborted]).finally(() => {
|
|
50
|
-
if (onAbort) signal.removeEventListener("abort", onAbort);
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export class LoginSlot {
|
|
55
|
-
private readonly aborter = new AbortController();
|
|
56
|
-
private dialog: LoginDialog | undefined;
|
|
57
|
-
private opening: Promise<LoginDialog> | undefined;
|
|
58
|
-
private hide: (() => void) | undefined;
|
|
59
|
-
private dismissed: Promise<unknown> = Promise.resolve();
|
|
60
|
-
/** Serializes everything that needs the slot to itself. Never rejects. */
|
|
61
|
-
private queue: Promise<void> = Promise.resolve();
|
|
62
|
-
private readonly ui: LoginSlotUi;
|
|
63
|
-
|
|
64
|
-
constructor(ui: LoginSlotUi) {
|
|
65
|
-
this.ui = ui;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** What `oauth.login()` is called with. */
|
|
69
|
-
get interaction(): ProviderAuthInteraction {
|
|
70
|
-
return {
|
|
71
|
-
signal: this.aborter.signal,
|
|
72
|
-
prompt: (request) => this.prompt(request),
|
|
73
|
-
notify: (event) => this.notify(event),
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Gives the slot back once the login is over. */
|
|
78
|
-
async close(): Promise<void> {
|
|
79
|
-
await this.queue;
|
|
80
|
-
await this.unmount();
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
private enqueue<T>(task: () => Promise<T>): Promise<T> {
|
|
84
|
-
const running = this.queue.then(task);
|
|
85
|
-
this.queue = running.then(
|
|
86
|
-
() => undefined,
|
|
87
|
-
() => undefined,
|
|
88
|
-
);
|
|
89
|
-
return running;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
private open(): Promise<LoginDialog> {
|
|
93
|
-
if (this.opening) return this.opening;
|
|
94
|
-
let ready: (dialog: LoginDialog) => void = () => {};
|
|
95
|
-
let failed: (error: unknown) => void = () => {};
|
|
96
|
-
const shown = new Promise<LoginDialog>((resolve, reject) => {
|
|
97
|
-
ready = resolve;
|
|
98
|
-
failed = reject;
|
|
99
|
-
});
|
|
100
|
-
this.dismissed = this.ui
|
|
101
|
-
.mount((dialog, close) => {
|
|
102
|
-
// Cancelling inside the dialog cancels the login, the same key `/login` honours.
|
|
103
|
-
dialog.signal.addEventListener("abort", () => this.aborter.abort(), { once: true });
|
|
104
|
-
this.dialog = dialog;
|
|
105
|
-
this.hide = close;
|
|
106
|
-
ready(dialog);
|
|
107
|
-
})
|
|
108
|
-
.catch((error: unknown) => {
|
|
109
|
-
failed(error);
|
|
110
|
-
});
|
|
111
|
-
this.opening = shown;
|
|
112
|
-
return shown;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
private async unmount(): Promise<void> {
|
|
116
|
-
const hide = this.hide;
|
|
117
|
-
const dismissed = this.dismissed;
|
|
118
|
-
this.dialog = undefined;
|
|
119
|
-
this.opening = undefined;
|
|
120
|
-
this.hide = undefined;
|
|
121
|
-
this.dismissed = Promise.resolve();
|
|
122
|
-
hide?.();
|
|
123
|
-
await dismissed;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
private async prompt(request: AuthPrompt): Promise<string> {
|
|
127
|
-
if (request.type === "select") return withCancellation(this.select(request), request.signal);
|
|
128
|
-
// Wait for the events that arrived before this prompt, so the dialog reads in order.
|
|
129
|
-
await this.queue;
|
|
130
|
-
const dialog = await this.open();
|
|
131
|
-
const answer =
|
|
132
|
-
request.type === "manual_code" ? dialog.showManualInput(request.message) : dialog.showPrompt(request.message, request.placeholder);
|
|
133
|
-
return withCancellation(answer, request.signal);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
private select(request: AuthPrompt & { type: "select" }): Promise<string> {
|
|
137
|
-
return this.enqueue(async () => {
|
|
138
|
-
await this.unmount();
|
|
139
|
-
const labels = request.options.map((option) => option.label);
|
|
140
|
-
const chosen = await this.ui.select(request.message, labels, request.signal);
|
|
141
|
-
const index = chosen === undefined ? -1 : labels.indexOf(chosen);
|
|
142
|
-
if (index < 0) throw new Error(CANCELLED);
|
|
143
|
-
return request.options[index].id;
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
private notify(event: AuthEvent): void {
|
|
148
|
-
void this.enqueue(async () => {
|
|
149
|
-
const dialog = await this.open();
|
|
150
|
-
if (event.type === "auth_url") dialog.showAuth(event.url, event.instructions);
|
|
151
|
-
else if (event.type === "device_code") {
|
|
152
|
-
dialog.showDeviceCode(event);
|
|
153
|
-
dialog.showWaiting("等待浏览器完成授权…");
|
|
154
|
-
} else if (event.type === "info") dialog.showInfo(event.message, event.links);
|
|
155
|
-
else dialog.showProgress(event.message);
|
|
156
|
-
}).catch(() => undefined);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure decisions: what a failure proves, which account to use next, and whether this request may
|
|
3
|
-
* still switch accounts. Nothing here reads files or the network, so every rule is testable.
|
|
4
|
-
*
|
|
5
|
-
* Classification reads structured evidence only. pi's Codex adapter formats a plain HTTP 429 with
|
|
6
|
-
* the same "You have hit your ChatGPT usage limit" text as a real subscription limit, so matching
|
|
7
|
-
* the user-facing message would switch accounts on ordinary rate limiting.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export type FailureKind =
|
|
11
|
-
/** The subscription's own limit is reached: the only automatic quota switch. */
|
|
12
|
-
| "quota"
|
|
13
|
-
/** The stored authorization is proven invalid; the account needs re-authorizing. */
|
|
14
|
-
| "auth_invalid"
|
|
15
|
-
/** Authentication was rejected once; the same account may be repaired first. */
|
|
16
|
-
| "auth_retry"
|
|
17
|
-
/** Rate limiting, network trouble or a server-side blip: retry the same account. */
|
|
18
|
-
| "transient"
|
|
19
|
-
| "cancelled"
|
|
20
|
-
/** A definite error that another account would not fix. */
|
|
21
|
-
| "fatal"
|
|
22
|
-
| "unknown";
|
|
23
|
-
|
|
24
|
-
export interface ProviderErrorBody {
|
|
25
|
-
code?: unknown;
|
|
26
|
-
type?: unknown;
|
|
27
|
-
message?: unknown;
|
|
28
|
-
plan_type?: unknown;
|
|
29
|
-
resets_at?: unknown;
|
|
30
|
-
resets_in_seconds?: unknown;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface FailureEvidence {
|
|
34
|
-
status?: number;
|
|
35
|
-
body?: ProviderErrorBody;
|
|
36
|
-
cancelled?: boolean;
|
|
37
|
-
/** Set when the failure came from the OAuth token exchange rather than the model request. */
|
|
38
|
-
oauthRefresh?: { invalidGrant: boolean };
|
|
39
|
-
/** Transport-level failure such as DNS, reset connection or timeout. */
|
|
40
|
-
networkFailure?: boolean;
|
|
41
|
-
/** Model the request was for; quota without plan-level evidence is scoped to it. */
|
|
42
|
-
modelId?: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface Classification {
|
|
46
|
-
kind: FailureKind;
|
|
47
|
-
/** Short, non-secret reason for status output and state records. */
|
|
48
|
-
reason: string;
|
|
49
|
-
/** Cooldown scope: the whole account, or `model:<id>` when the evidence is not plan-level. */
|
|
50
|
-
scope: string;
|
|
51
|
-
/** Server-stated reset time in epoch ms, or null when the server stated none. */
|
|
52
|
-
serverResetAtMs: number | null;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export const ACCOUNT_SCOPE = "account";
|
|
56
|
-
const MAX_RESET_HORIZON_MS = 30 * 24 * 60 * 60 * 1_000;
|
|
57
|
-
|
|
58
|
-
function errorCode(body: ProviderErrorBody | undefined): string {
|
|
59
|
-
const code = typeof body?.code === "string" ? body.code : "";
|
|
60
|
-
if (code) return code.toLowerCase();
|
|
61
|
-
return typeof body?.type === "string" ? body.type.toLowerCase() : "";
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Only a field that is finite and lands in a plausible window counts as a reset time. */
|
|
65
|
-
export function serverResetAtMs(body: ProviderErrorBody | undefined, now: number): number | null {
|
|
66
|
-
const inWindow = (value: number) => value > now - 24 * 60 * 60 * 1_000 && value < now + MAX_RESET_HORIZON_MS;
|
|
67
|
-
if (typeof body?.resets_at === "number" && Number.isFinite(body.resets_at)) {
|
|
68
|
-
// The Codex error reports epoch seconds.
|
|
69
|
-
const candidate = body.resets_at * 1_000;
|
|
70
|
-
if (inWindow(candidate)) return candidate;
|
|
71
|
-
}
|
|
72
|
-
if (typeof body?.resets_in_seconds === "number" && Number.isFinite(body.resets_in_seconds) && body.resets_in_seconds >= 0) {
|
|
73
|
-
const candidate = now + body.resets_in_seconds * 1_000;
|
|
74
|
-
if (inWindow(candidate)) return candidate;
|
|
75
|
-
}
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function classifyFailure(evidence: FailureEvidence, now: number): Classification {
|
|
80
|
-
const scopeForModel = evidence.modelId ? `model:${evidence.modelId}` : ACCOUNT_SCOPE;
|
|
81
|
-
const plain = (kind: FailureKind, reason: string): Classification => ({ kind, reason, scope: scopeForModel, serverResetAtMs: null });
|
|
82
|
-
|
|
83
|
-
if (evidence.cancelled) return plain("cancelled", "cancelled");
|
|
84
|
-
|
|
85
|
-
if (evidence.oauthRefresh) {
|
|
86
|
-
// A network problem during the token exchange says nothing about the authorization.
|
|
87
|
-
return evidence.oauthRefresh.invalidGrant ? plain("auth_invalid", "invalid_grant") : plain("transient", "oauth_refresh_unavailable");
|
|
88
|
-
}
|
|
89
|
-
if (evidence.networkFailure) return plain("transient", "network_failure");
|
|
90
|
-
|
|
91
|
-
const code = errorCode(evidence.body);
|
|
92
|
-
const status = evidence.status;
|
|
93
|
-
|
|
94
|
-
if (code === "usage_limit_reached") {
|
|
95
|
-
// A plan-level limit is shared by every model; without that evidence stay model-scoped.
|
|
96
|
-
const planLevel = typeof evidence.body?.plan_type === "string" && evidence.body.plan_type.length > 0;
|
|
97
|
-
return {
|
|
98
|
-
kind: "quota",
|
|
99
|
-
reason: "usage_limit_reached",
|
|
100
|
-
scope: planLevel ? ACCOUNT_SCOPE : scopeForModel,
|
|
101
|
-
serverResetAtMs: serverResetAtMs(evidence.body, now),
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
if (code === "invalid_grant" || code === "invalid_token" || code === "token_revoked") return plain("auth_invalid", code);
|
|
105
|
-
if (code === "rate_limit_exceeded") return plain("transient", "rate_limit_exceeded");
|
|
106
|
-
// usage_not_included means the plan does not cover this model at all; another account is a guess.
|
|
107
|
-
if (code === "usage_not_included") return plain("fatal", "usage_not_included");
|
|
108
|
-
if (code === "context_length_exceeded") return plain("fatal", "context_length_exceeded");
|
|
109
|
-
|
|
110
|
-
if (status === 401) return plain("auth_retry", "unauthorized");
|
|
111
|
-
if (status === 429) return plain("transient", "http_429");
|
|
112
|
-
if (status !== undefined && status >= 500) return plain("transient", `http_${status}`);
|
|
113
|
-
if (status === 403) return plain("fatal", "http_403");
|
|
114
|
-
if (status !== undefined && status >= 400) return plain("fatal", `http_${status}`);
|
|
115
|
-
|
|
116
|
-
return plain("unknown", "unknown_error");
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export interface AccountAvailability {
|
|
120
|
-
id: string;
|
|
121
|
-
ordinal: number;
|
|
122
|
-
/** Why the account cannot be used now; undefined means usable. */
|
|
123
|
-
unavailable?: { reason: string; untilMs: number | null; serverResetAtMs: number | null };
|
|
124
|
-
/** The account is usable but should be probed under a lease, after a cooldown ended. */
|
|
125
|
-
needsProbe: boolean;
|
|
126
|
-
authInvalid: boolean;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export interface SelectionInput {
|
|
130
|
-
accounts: AccountAvailability[];
|
|
131
|
-
/** Sticky preference of this process: keep using the account that currently works. */
|
|
132
|
-
preferredId?: string;
|
|
133
|
-
/** Accounts this logical request already tried, so no account is tried twice. */
|
|
134
|
-
attemptedIds: readonly string[];
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Sticky selection: stay on the preferred account while it works, otherwise take the next usable
|
|
139
|
-
* account in the order they were added. Recovered accounts become candidates again.
|
|
140
|
-
*/
|
|
141
|
-
export function selectAccount(input: SelectionInput): AccountAvailability | undefined {
|
|
142
|
-
const attempted = new Set(input.attemptedIds);
|
|
143
|
-
const usable = input.accounts.filter((account) => !attempted.has(account.id) && !account.unavailable && !account.authInvalid);
|
|
144
|
-
const preferred = usable.find((account) => account.id === input.preferredId);
|
|
145
|
-
if (preferred) return preferred;
|
|
146
|
-
return [...usable].sort((left, right) => left.ordinal - right.ordinal)[0];
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export type FailoverAction =
|
|
150
|
-
/** Try the next account for this same request. */
|
|
151
|
-
| "failover"
|
|
152
|
-
/** Re-read and refresh the current account once, then retry it. */
|
|
153
|
-
| "repair"
|
|
154
|
-
/** Finish the request with the provider's error. */
|
|
155
|
-
| "stop";
|
|
156
|
-
|
|
157
|
-
export interface FailoverInput {
|
|
158
|
-
classification: Classification;
|
|
159
|
-
/** Text, thinking or a tool call already reached the caller, so nothing may be replayed. */
|
|
160
|
-
emittedOutput: boolean;
|
|
161
|
-
/** Another untried, usable account exists for this request. */
|
|
162
|
-
hasRemainingCandidate: boolean;
|
|
163
|
-
/** The current account already used its one authentication repair. */
|
|
164
|
-
repairUsed: boolean;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export function decideFailover(input: FailoverInput): { action: FailoverAction; reason: string } {
|
|
168
|
-
const { kind } = input.classification;
|
|
169
|
-
if (kind === "cancelled") return { action: "stop", reason: "cancelled" };
|
|
170
|
-
if (kind === "transient" || kind === "fatal" || kind === "unknown") return { action: "stop", reason: kind };
|
|
171
|
-
if (kind === "auth_retry" && !input.repairUsed) return { action: "repair", reason: "authentication_repair" };
|
|
172
|
-
// Everything below is quota or a proven authorization problem.
|
|
173
|
-
if (input.emittedOutput) return { action: "stop", reason: "output_already_delivered" };
|
|
174
|
-
if (!input.hasRemainingCandidate) return { action: "stop", reason: "pool_exhausted" };
|
|
175
|
-
return { action: "failover", reason: input.classification.reason };
|
|
176
|
-
}
|