@maxiaochao/pi-toolkit 0.11.8 → 0.11.10
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 +12 -0
- package/README.md +15 -28
- package/package.json +2 -4
- package/packages/openai-account-pool/CHANGELOG.md +0 -11
- 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,230 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,18 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,339 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
accountLabel,
|
|
3
|
-
commitAccount,
|
|
4
|
-
disablePool,
|
|
5
|
-
loadPoolSnapshot,
|
|
6
|
-
maskAccountId,
|
|
7
|
-
removeAccount,
|
|
8
|
-
type PoolAccount,
|
|
9
|
-
type PoolSnapshot,
|
|
10
|
-
} from "./auth.ts";
|
|
11
|
-
import { availabilityFor, setPreferredAccountId } from "./availability.ts";
|
|
12
|
-
import { withEnrollmentLease } from "./lease.ts";
|
|
13
|
-
import { emptyPoolState, readPoolState, withPoolStateTransaction, type AuthEntry, type PoolStateFile } from "./store.ts";
|
|
14
|
-
import { describeSoonestReady, describeUsage, refreshPoolUsage, storedUsage, usageAvailable } from "./usage.ts";
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* `/codex_pool`: ask, log in once, save, ask again. Everything here is UI-agnostic so the TUI,
|
|
18
|
-
* RPC and tests drive the same loop. Each successful login is saved immediately, so ending the
|
|
19
|
-
* loop, pressing escape or closing pi never loses an account that was already added.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
export type EnrollmentChoice = "continue" | "remove" | "end";
|
|
23
|
-
|
|
24
|
-
export interface EnrollmentView {
|
|
25
|
-
/** Accounts already in the pool before this iteration. */
|
|
26
|
-
savedAccounts: number;
|
|
27
|
-
/** Those accounts by label, so the user can see which ones are already in. */
|
|
28
|
-
savedLabels: string[];
|
|
29
|
-
/** An existing plain login that the first add will keep as a pool account. */
|
|
30
|
-
nativeLoginPending: boolean;
|
|
31
|
-
addedThisRun: number;
|
|
32
|
-
/** Non-secret sentence about what the previous iteration did. */
|
|
33
|
-
lastOutcome?: string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** The prompt the user reads before every add. Shared by the TUI, RPC and media. */
|
|
37
|
-
export function confirmText(view: EnrollmentView): string {
|
|
38
|
-
const lines: string[] = [];
|
|
39
|
-
if (view.lastOutcome) lines.push(view.lastOutcome);
|
|
40
|
-
if (view.savedLabels.length === 0) lines.push("账号池里还没有账号。");
|
|
41
|
-
else lines.push(`已保存 ${view.savedAccounts} 个账号:${view.savedLabels.join("、")}。`);
|
|
42
|
-
if (view.nativeLoginPending) lines.push("原有 openai-codex 登录会在第一次添加时保留并入池。");
|
|
43
|
-
if (view.savedAccounts === 1) lines.push("再添加一个账号才有备用额度。");
|
|
44
|
-
if (view.savedAccounts > 0) lines.push("重复登录已有的账号只会更新它的凭据,不会多出一个。");
|
|
45
|
-
return lines.join("\n");
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface EnrollmentUi {
|
|
49
|
-
/** Shows the state and asks what to do next. */
|
|
50
|
-
confirm(view: EnrollmentView): Promise<EnrollmentChoice>;
|
|
51
|
-
/** Runs exactly one native OAuth login. Rejects when the user cancels. */
|
|
52
|
-
login(): Promise<AuthEntry>;
|
|
53
|
-
/** Picks the account to delete, or undefined when the user backs out. */
|
|
54
|
-
pickRemoval(accounts: readonly PoolAccount[]): Promise<PoolAccount | undefined>;
|
|
55
|
-
notify(message: string, kind?: "info" | "warning" | "error"): void;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export interface EnrollmentResult {
|
|
59
|
-
added: number;
|
|
60
|
-
updated: number;
|
|
61
|
-
removed: number;
|
|
62
|
-
/** Accounts in the pool when the loop ended. */
|
|
63
|
-
accounts: number;
|
|
64
|
-
cancelled: boolean;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Login failures can quote a raw token response, so only a short, scrubbed reason is shown. */
|
|
68
|
-
export function redactMessage(error: unknown): string {
|
|
69
|
-
const raw = error instanceof Error ? error.message : String(error);
|
|
70
|
-
const scrubbed = raw
|
|
71
|
-
.replace(/https?:\/\/\S+/g, "<url>")
|
|
72
|
-
.replace(/[A-Za-z0-9._~+/=-]{24,}/g, "<redacted>")
|
|
73
|
-
.replace(/\s+/g, " ")
|
|
74
|
-
.trim();
|
|
75
|
-
return scrubbed.length > 160 ? `${scrubbed.slice(0, 160)}…` : scrubbed || "未知错误";
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function isCancellation(error: unknown): boolean {
|
|
79
|
-
if (error instanceof Error && error.name === "AbortError") return true;
|
|
80
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
81
|
-
return /Login cancelled|cancelled|canceled|aborted/i.test(message);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function describeCommit(result: Awaited<ReturnType<typeof commitAccount>>): string {
|
|
85
|
-
const label = accountLabel(result.account);
|
|
86
|
-
const parts = [result.outcome === "added" ? `已添加账号 ${label}` : `${label} 是已在池里的账号,凭据已更新,没有新增`];
|
|
87
|
-
if (result.migratedExistingLogin) parts.push("原有登录已保留并入池");
|
|
88
|
-
if (result.readoptedAccounts > 0) parts.push(`恢复了 ${result.readoptedAccounts} 个此前保存的账号`);
|
|
89
|
-
parts.push(`当前共 ${result.accountCount} 个账号`);
|
|
90
|
-
return `${parts.join(",")}。`;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export async function runEnrollment(input: {
|
|
94
|
-
authPath: string;
|
|
95
|
-
statePath: string;
|
|
96
|
-
ui: EnrollmentUi;
|
|
97
|
-
signal?: AbortSignal;
|
|
98
|
-
}): Promise<EnrollmentResult> {
|
|
99
|
-
// One wizard at a time per agent directory: two callback servers must never race.
|
|
100
|
-
return withEnrollmentLease({ statePath: input.statePath, purpose: "codex_pool", signal: input.signal }, async () => {
|
|
101
|
-
const result: EnrollmentResult = { added: 0, updated: 0, removed: 0, accounts: 0, cancelled: false };
|
|
102
|
-
let lastOutcome: string | undefined;
|
|
103
|
-
|
|
104
|
-
while (true) {
|
|
105
|
-
const snapshot = await loadPoolSnapshot(input.authPath);
|
|
106
|
-
const sorted = [...snapshot.accounts].sort((left, right) => left.ordinal - right.ordinal);
|
|
107
|
-
result.accounts = sorted.length;
|
|
108
|
-
const choice = await input.ui.confirm({
|
|
109
|
-
savedAccounts: sorted.length,
|
|
110
|
-
savedLabels: sorted.map((account) => accountLabel(account)),
|
|
111
|
-
nativeLoginPending: !snapshot.enabled && snapshot.nativeLoginPresent,
|
|
112
|
-
addedThisRun: result.added + result.updated,
|
|
113
|
-
lastOutcome,
|
|
114
|
-
});
|
|
115
|
-
if (choice === "end") break;
|
|
116
|
-
|
|
117
|
-
if (choice === "remove") {
|
|
118
|
-
lastOutcome = await removeThroughWizard({ ...input, accounts: sorted, result });
|
|
119
|
-
if (lastOutcome) input.ui.notify(lastOutcome);
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
let credential: AuthEntry;
|
|
124
|
-
try {
|
|
125
|
-
credential = await input.ui.login();
|
|
126
|
-
} catch (error) {
|
|
127
|
-
if (isCancellation(error)) {
|
|
128
|
-
result.cancelled = true;
|
|
129
|
-
break;
|
|
130
|
-
}
|
|
131
|
-
// A failed login never retries by itself; the next loop asks again.
|
|
132
|
-
lastOutcome = `上一次登录失败(${redactMessage(error)}),已保存的账号不受影响。`;
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
try {
|
|
137
|
-
const committed = await commitAccount({ authPath: input.authPath, credential, signal: input.signal });
|
|
138
|
-
if (committed.outcome === "added") result.added += 1;
|
|
139
|
-
else result.updated += 1;
|
|
140
|
-
result.accounts = committed.accountCount;
|
|
141
|
-
lastOutcome = describeCommit(committed);
|
|
142
|
-
input.ui.notify(lastOutcome);
|
|
143
|
-
} catch (error) {
|
|
144
|
-
lastOutcome = `上一次保存失败(${redactMessage(error)}),已保存的账号不受影响。`;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const finalSnapshot = await loadPoolSnapshot(input.authPath);
|
|
149
|
-
result.accounts = finalSnapshot.accounts.length;
|
|
150
|
-
return result;
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Deleting from inside the wizard, so adding and dropping an account need one command. */
|
|
155
|
-
async function removeThroughWizard(input: {
|
|
156
|
-
authPath: string;
|
|
157
|
-
statePath: string;
|
|
158
|
-
ui: EnrollmentUi;
|
|
159
|
-
accounts: readonly PoolAccount[];
|
|
160
|
-
result: EnrollmentResult;
|
|
161
|
-
}): Promise<string | undefined> {
|
|
162
|
-
if (input.accounts.length === 0) return "账号池里没有可删除的账号。";
|
|
163
|
-
let chosen: PoolAccount | undefined;
|
|
164
|
-
try {
|
|
165
|
-
chosen = await input.ui.pickRemoval(input.accounts);
|
|
166
|
-
} catch (error) {
|
|
167
|
-
if (isCancellation(error)) return undefined;
|
|
168
|
-
throw error;
|
|
169
|
-
}
|
|
170
|
-
if (!chosen) return undefined;
|
|
171
|
-
try {
|
|
172
|
-
const removal = await removePoolAccount({ authPath: input.authPath, statePath: input.statePath, accountId: chosen.id });
|
|
173
|
-
input.result.removed += 1;
|
|
174
|
-
input.result.accounts = removal.remaining;
|
|
175
|
-
return removal.poolDisabled
|
|
176
|
-
? `已删除最后一个账号 ${accountLabel(chosen)},账号池已关闭。`
|
|
177
|
-
: `已删除账号 ${accountLabel(chosen)},还剩 ${removal.remaining} 个。`;
|
|
178
|
-
} catch (error) {
|
|
179
|
-
return `删除失败(${redactMessage(error)}),账号池没有变化。`;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
export type AccountState = "ready" | "cooling" | "reauth" | "probing";
|
|
184
|
-
|
|
185
|
-
export interface AccountStatusLine {
|
|
186
|
-
label: string;
|
|
187
|
-
state: AccountState;
|
|
188
|
-
detail: string;
|
|
189
|
-
isCurrent: boolean;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
export interface PoolStatus {
|
|
193
|
-
enabled: boolean;
|
|
194
|
-
nativeLoginPresent: boolean;
|
|
195
|
-
accounts: AccountStatusLine[];
|
|
196
|
-
/** Short non-secret summary line for the user. */
|
|
197
|
-
summary: string;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Pins the account every process starts from, or returns to automatic order with `undefined`.
|
|
202
|
-
* Failover still applies: a pinned account that runs out of quota hands the request on.
|
|
203
|
-
*/
|
|
204
|
-
export async function setDefaultAccount(input: { authPath: string; statePath: string; accountId: string | undefined }): Promise<PoolAccount | undefined> {
|
|
205
|
-
if (input.accountId === undefined) {
|
|
206
|
-
await setPreferredAccountId({ statePath: input.statePath, accountId: undefined });
|
|
207
|
-
return undefined;
|
|
208
|
-
}
|
|
209
|
-
const snapshot = await loadPoolSnapshot(input.authPath);
|
|
210
|
-
const account = snapshot.accounts.find((candidate) => candidate.id === input.accountId);
|
|
211
|
-
if (!account) throw new AccountQueryError("这个账号不在账号池里,无法设为默认。");
|
|
212
|
-
await setPreferredAccountId({ statePath: input.statePath, accountId: account.id });
|
|
213
|
-
return account;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
export async function poolStatus(input: {
|
|
217
|
-
authPath: string;
|
|
218
|
-
statePath: string;
|
|
219
|
-
now?: number;
|
|
220
|
-
modelId?: string;
|
|
221
|
-
refresh?: (credential: AuthEntry, signal?: AbortSignal) => Promise<AuthEntry>;
|
|
222
|
-
fetch?: typeof globalThis.fetch;
|
|
223
|
-
signal?: AbortSignal;
|
|
224
|
-
}): Promise<PoolStatus> {
|
|
225
|
-
const now = input.now ?? Date.now();
|
|
226
|
-
const snapshot = await loadPoolSnapshot(input.authPath);
|
|
227
|
-
if (!snapshot.enabled) {
|
|
228
|
-
return {
|
|
229
|
-
enabled: false,
|
|
230
|
-
nativeLoginPresent: snapshot.nativeLoginPresent,
|
|
231
|
-
accounts: [],
|
|
232
|
-
summary: snapshot.nativeLoginPresent
|
|
233
|
-
? "账号池未启用,当前使用原生单账号登录。运行 /codex_pool 添加账号即可启用。"
|
|
234
|
-
: "账号池未启用,且没有 openai-codex 登录。运行 /codex_pool 添加账号。",
|
|
235
|
-
};
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
const refreshed = input.refresh
|
|
239
|
-
? await refreshPoolUsage({
|
|
240
|
-
authPath: input.authPath,
|
|
241
|
-
statePath: input.statePath,
|
|
242
|
-
accounts: snapshot.accounts,
|
|
243
|
-
refresh: input.refresh,
|
|
244
|
-
now: () => now,
|
|
245
|
-
fetch: input.fetch,
|
|
246
|
-
signal: input.signal,
|
|
247
|
-
})
|
|
248
|
-
: [];
|
|
249
|
-
const refreshById = new Map(refreshed.map((result) => [result.account.id, result]));
|
|
250
|
-
const state = await readPoolState(input.statePath);
|
|
251
|
-
const availability = availabilityFor({
|
|
252
|
-
accounts: snapshot.accounts,
|
|
253
|
-
state,
|
|
254
|
-
now,
|
|
255
|
-
modelId: input.modelId,
|
|
256
|
-
});
|
|
257
|
-
const accounts = availability.map((entry, index): AccountStatusLine => {
|
|
258
|
-
const account = snapshot.accounts[index];
|
|
259
|
-
const label = accountLabel(account);
|
|
260
|
-
const isCurrent = account.id === state.currentAccountId;
|
|
261
|
-
const live = refreshById.get(account.id);
|
|
262
|
-
const usage = live?.usage ?? storedUsage(state.accounts[account.id]);
|
|
263
|
-
const usageText = live?.error ? "用量未知" : describeUsage(usage, now) ?? "用量未知";
|
|
264
|
-
if (entry.authInvalid) return { label, state: "reauth", detail: "需要重新授权,运行 /codex_pool 重新登录这个账号", isCurrent };
|
|
265
|
-
if (entry.unavailable?.reason === "probe_in_progress") return { label, state: "probing", detail: "另一个进程正在重试", isCurrent };
|
|
266
|
-
if (entry.unavailable || (usage && !usageAvailable(usage))) {
|
|
267
|
-
const reset = entry.unavailable?.serverResetAtMs;
|
|
268
|
-
return {
|
|
269
|
-
label,
|
|
270
|
-
state: "cooling",
|
|
271
|
-
detail: `${usageText};${reset ? `额度冷却中,约 ${Math.max(1, Math.round((reset - now) / 60_000))} 分钟后恢复` : "额度暂不可用"}`,
|
|
272
|
-
isCurrent,
|
|
273
|
-
};
|
|
274
|
-
}
|
|
275
|
-
return { label, state: "ready", detail: `${usageText};${entry.needsProbe ? "冷却已过,下次请求会先试这个账号" : "可用"}`, isCurrent };
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
const ready = accounts.filter((entry) => entry.state === "ready").length;
|
|
279
|
-
let summary =
|
|
280
|
-
accounts.length === 1
|
|
281
|
-
? "账号池已启用,但只有 1 个账号,暂无备用账号。运行 /codex_pool 继续添加。"
|
|
282
|
-
: `账号池已启用:${accounts.length} 个账号,其中 ${ready} 个现在可用。`;
|
|
283
|
-
if (ready === 0) {
|
|
284
|
-
const recovery = describeSoonestReady({ accounts: snapshot.accounts, state, now, modelId: input.modelId });
|
|
285
|
-
if (recovery) summary += ` ${recovery}。`;
|
|
286
|
-
}
|
|
287
|
-
return { enabled: true, nativeLoginPresent: false, accounts, summary };
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
export class AccountQueryError extends Error {}
|
|
291
|
-
|
|
292
|
-
/** Accepts the ordinal, the sign-in address, a full account id or the masked suffix from status. */
|
|
293
|
-
export function resolveAccount(snapshot: PoolSnapshot, query: string): PoolAccount {
|
|
294
|
-
const trimmed = query.trim().replace(/^[#…\s]+/, "");
|
|
295
|
-
if (!trimmed) throw new AccountQueryError("请提供要删除的账号编号、邮箱或标识,例如 /codex_pool remove 2。");
|
|
296
|
-
const byOrdinal = /^\d+$/.test(trimmed) ? snapshot.accounts.find((account) => account.ordinal === Number(trimmed)) : undefined;
|
|
297
|
-
if (byOrdinal) return byOrdinal;
|
|
298
|
-
const needle = trimmed.toLowerCase();
|
|
299
|
-
const byEmail = snapshot.accounts.filter((account) => account.email?.toLowerCase().includes(needle));
|
|
300
|
-
if (byEmail.length === 1) return byEmail[0];
|
|
301
|
-
if (byEmail.length > 1) throw new AccountQueryError(`“${trimmed}”匹配到 ${byEmail.length} 个账号,请用编号,例如 /codex_pool remove 2。`);
|
|
302
|
-
const matches = snapshot.accounts.filter((account) => account.id === trimmed || account.id.endsWith(trimmed));
|
|
303
|
-
if (matches.length === 1) return matches[0];
|
|
304
|
-
if (matches.length > 1) throw new AccountQueryError(`“${trimmed}”匹配到 ${matches.length} 个账号,请用编号,例如 /codex_pool remove 2。`);
|
|
305
|
-
throw new AccountQueryError(`没有找到账号“${trimmed}”。用 /codex_pool status 查看编号。`);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/** Forgets everything the pool recorded about accounts it no longer owns. */
|
|
309
|
-
async function forgetState(statePath: string, accountIds: readonly string[] | "all"): Promise<void> {
|
|
310
|
-
await withPoolStateTransaction(statePath, (state) => {
|
|
311
|
-
if (accountIds === "all") return { value: { ...emptyPoolState(), enrollment: state.enrollment }, result: undefined };
|
|
312
|
-
const accounts = { ...state.accounts };
|
|
313
|
-
for (const accountId of accountIds) delete accounts[accountId];
|
|
314
|
-
const next: PoolStateFile = { ...state, accounts };
|
|
315
|
-
// References to an account that no longer exists would silently lie; drop them instead.
|
|
316
|
-
if (next.preferredAccountId && accountIds.includes(next.preferredAccountId)) delete next.preferredAccountId;
|
|
317
|
-
if (next.currentAccountId && accountIds.includes(next.currentAccountId)) delete next.currentAccountId;
|
|
318
|
-
return { value: next, result: undefined };
|
|
319
|
-
});
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
export async function endPool(input: { authPath: string; statePath: string; keepAccountId?: string }): Promise<{ kept: PoolAccount; dormantAccounts: number }> {
|
|
323
|
-
const snapshot = await loadPoolSnapshot(input.authPath);
|
|
324
|
-
if (!snapshot.enabled) throw new AccountQueryError("账号池未启用,无需关闭。");
|
|
325
|
-
const keep =
|
|
326
|
-
snapshot.accounts.find((account) => account.id === input.keepAccountId) ??
|
|
327
|
-
[...snapshot.accounts].sort((left, right) => left.ordinal - right.ordinal)[0];
|
|
328
|
-
const result = await disablePool({ authPath: input.authPath, accountId: keep.id });
|
|
329
|
-
await forgetState(input.statePath, "all");
|
|
330
|
-
return { kept: result.restored, dormantAccounts: result.dormantAccounts };
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
export async function removePoolAccount(input: { authPath: string; statePath: string; accountId: string }): Promise<{ removed: PoolAccount; remaining: number; poolDisabled: boolean }> {
|
|
334
|
-
const result = await removeAccount({ authPath: input.authPath, accountId: input.accountId });
|
|
335
|
-
await forgetState(input.statePath, result.poolDisabled ? "all" : [input.accountId]);
|
|
336
|
-
return result;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
export { maskAccountId };
|