@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,259 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { acquireFileLock, type FileLockOptions } from "./lock.ts";
5
+
6
+ /** The model provider the pool serves. Requests keep using `openai-codex/<model>`. */
7
+ export const MAIN_PROVIDER_ID = "openai-codex";
8
+ /** Prefix of the per-account storage keys. These are credential-store keys, not providers. */
9
+ export const POOL_ENTRY_PREFIX = "pi-openai-account-pool/";
10
+ export const STATE_DIRECTORY_NAME = "openai-account-pool";
11
+ export const STATE_SCHEMA_VERSION = 1;
12
+
13
+ export type AuthEntry = Record<string, unknown>;
14
+ export type AuthEntries = Record<string, AuthEntry>;
15
+
16
+ export interface TransactionResult<TValue, TResult> {
17
+ /** Returned only to write; omit it to leave the file untouched. */
18
+ value?: TValue;
19
+ result: TResult;
20
+ }
21
+
22
+ export function authPathFor(agentDir: string): string {
23
+ return join(agentDir, "auth.json");
24
+ }
25
+
26
+ export function poolStatePathFor(agentDir: string): string {
27
+ return join(agentDir, STATE_DIRECTORY_NAME, "state.json");
28
+ }
29
+
30
+ /** Stable storage key for one account, derived from the provider's own account id. */
31
+ export function poolEntryKey(accountId: string): string {
32
+ return `${POOL_ENTRY_PREFIX}${createHash("sha256").update(accountId).digest("hex").slice(0, 16)}`;
33
+ }
34
+
35
+ export function isPoolEntryKey(key: string): boolean {
36
+ return key.startsWith(POOL_ENTRY_PREFIX);
37
+ }
38
+
39
+ function parseAuthEntries(text: string, path: string): AuthEntries {
40
+ const content = text.replace(/^\uFEFF/, "");
41
+ if (content.trim() === "") return {};
42
+ const parsed = JSON.parse(content) as unknown;
43
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
44
+ throw new Error(`Invalid ${path}: expected a JSON object.`);
45
+ }
46
+ for (const [key, value] of Object.entries(parsed)) {
47
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
48
+ throw new Error(`Invalid ${path}: credential "${key}" is not an object.`);
49
+ }
50
+ }
51
+ return parsed as AuthEntries;
52
+ }
53
+
54
+ export async function readAuthEntries(authPath: string): Promise<AuthEntries> {
55
+ let text: string;
56
+ try { text = await readFile(authPath, "utf8"); }
57
+ catch (error) {
58
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
59
+ throw error;
60
+ }
61
+ return parseAuthEntries(text, authPath);
62
+ }
63
+
64
+ /** Replacing a symlink or a non-file would write outside the agent directory or corrupt state. */
65
+ async function assertReplaceableFile(path: string): Promise<void> {
66
+ let identity;
67
+ try { identity = await lstat(path); }
68
+ catch (error) {
69
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
70
+ throw error;
71
+ }
72
+ if (!identity.isFile()) throw new Error(`Refusing to replace ${path}: it is not a regular file.`);
73
+ }
74
+
75
+ async function writeTextAtomic(path: string, text: string): Promise<void> {
76
+ const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
77
+ const handle = await open(temporary, "wx", 0o600);
78
+ try {
79
+ await handle.writeFile(text, "utf8");
80
+ await handle.sync();
81
+ } finally {
82
+ await handle.close();
83
+ }
84
+ try { await rename(temporary, path); }
85
+ catch (error) {
86
+ await unlink(temporary).catch(() => {});
87
+ throw error;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Read-modify-write of `auth.json` under the lock pi uses. Returning `value` replaces the whole
93
+ * file in one atomic rename, which is what lets one transaction add an account and update the
94
+ * main entry together. Unrelated provider entries must be carried over by the caller.
95
+ */
96
+ export async function withAuthTransaction<T>(
97
+ authPath: string,
98
+ run: (entries: AuthEntries) => Promise<TransactionResult<AuthEntries, T>> | TransactionResult<AuthEntries, T>,
99
+ options: FileLockOptions = {},
100
+ ): Promise<T> {
101
+ await mkdir(dirname(authPath), { recursive: true, mode: 0o700 });
102
+ const lock = await acquireFileLock(authPath, options);
103
+ try {
104
+ await assertReplaceableFile(authPath);
105
+ const { value, result } = await run(await readAuthEntries(authPath));
106
+ if (value !== undefined) {
107
+ await lock.verifyHeld();
108
+ // Match pi's own formatting so concurrent writers produce comparable files.
109
+ await writeTextAtomic(authPath, JSON.stringify(value, null, 2));
110
+ }
111
+ return result;
112
+ } finally {
113
+ await lock.release();
114
+ }
115
+ }
116
+
117
+ /** Why an account is unavailable right now. Non-secret; shared across pi processes. */
118
+ export interface AccountCooldown {
119
+ reason: string;
120
+ /** Pool generation this evidence belongs to; older evidence never outranks a newer login. */
121
+ generation: number;
122
+ /** Server-stated reset time in epoch ms, or null when the server did not state one. */
123
+ serverResetAtMs: number | null;
124
+ /** Local backoff before the next probe. Not a claimed quota reset time. */
125
+ probeAfterMs: number | null;
126
+ recordedAtMs: number;
127
+ }
128
+
129
+ /** One server-reported quota window. Non-secret: a percentage is not a credential. */
130
+ export interface UsageWindow {
131
+ usedPercent: number;
132
+ /** Window length the server reported, which is what tells the five-hour one from the weekly one. */
133
+ windowSeconds?: number;
134
+ /** Server-stated reset time in epoch ms. Absent when the server stated none. */
135
+ resetAtMs?: number;
136
+ }
137
+
138
+ /** Quota as of the last successful wham/usage lookup for this account. */
139
+ export interface AccountUsage {
140
+ /** Codex's primary window, the rolling five-hour one. */
141
+ primary?: UsageWindow;
142
+ /** Codex's secondary window, the weekly one. */
143
+ secondary?: UsageWindow;
144
+ /** Explicit backend decision, when returned by wham/usage. */
145
+ allowed?: boolean;
146
+ limitReached?: boolean;
147
+ plan?: string;
148
+ observedAtMs: number;
149
+ }
150
+
151
+ export interface AccountStateRecord {
152
+ /** Keyed by cooldown scope: "account" or `model:<id>`. */
153
+ cooldowns: Record<string, AccountCooldown>;
154
+ /** Last live usage observation, so a used-up account can still state its reset. */
155
+ usage?: AccountUsage;
156
+ /** Generation whose credential was proven invalid; re-authorizing bumps past it. */
157
+ authInvalidGeneration?: number;
158
+ /** When that proof was recorded, so an older request's success cannot clear it. */
159
+ authInvalidAtMs?: number;
160
+ /** Bounded lease so only one process probes a recovered account. */
161
+ probeLease?: { owner: string; untilMs: number };
162
+ }
163
+
164
+ /** Who is currently changing pool membership. Keeps enrollment, off and removal exclusive. */
165
+ export interface EnrollmentLeaseRecord {
166
+ owner: string;
167
+ processId: number;
168
+ purpose: string;
169
+ startedAtMs: number;
170
+ expiresAtMs: number;
171
+ }
172
+
173
+ export interface PoolStateFile {
174
+ schemaVersion: number;
175
+ policy: "sticky";
176
+ accounts: Record<string, AccountStateRecord>;
177
+ /** Account the user picked by hand. Outranks the per-process sticky choice while usable. */
178
+ preferredAccountId?: string;
179
+ /** Account that most recently completed a model request successfully. */
180
+ currentAccountId?: string;
181
+ enrollment?: EnrollmentLeaseRecord;
182
+ }
183
+
184
+ export function emptyPoolState(): PoolStateFile {
185
+ return { schemaVersion: STATE_SCHEMA_VERSION, policy: "sticky", accounts: {} };
186
+ }
187
+
188
+ function parsePoolState(text: string, path: string): PoolStateFile {
189
+ const content = text.replace(/^\uFEFF/, "");
190
+ if (content.trim() === "") return emptyPoolState();
191
+ const parsed = JSON.parse(content) as Partial<PoolStateFile> | null;
192
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
193
+ throw new Error(`Invalid ${path}: expected a JSON object.`);
194
+ }
195
+ if (parsed.schemaVersion !== STATE_SCHEMA_VERSION) {
196
+ throw new Error(`Unsupported ${path}: schemaVersion ${String(parsed.schemaVersion)} is not ${STATE_SCHEMA_VERSION}.`);
197
+ }
198
+ if (typeof parsed.accounts !== "object" || parsed.accounts === null || Array.isArray(parsed.accounts)) {
199
+ throw new Error(`Invalid ${path}: "accounts" must be an object.`);
200
+ }
201
+ const state: PoolStateFile = { schemaVersion: STATE_SCHEMA_VERSION, policy: "sticky", accounts: parsed.accounts as Record<string, AccountStateRecord> };
202
+ if (parsed.preferredAccountId !== undefined) {
203
+ if (typeof parsed.preferredAccountId !== "string" || parsed.preferredAccountId === "") {
204
+ throw new Error(`Invalid ${path}: "preferredAccountId" must be a non-empty string.`);
205
+ }
206
+ state.preferredAccountId = parsed.preferredAccountId;
207
+ }
208
+ if (parsed.currentAccountId !== undefined) {
209
+ if (typeof parsed.currentAccountId !== "string" || parsed.currentAccountId === "") {
210
+ throw new Error(`Invalid ${path}: "currentAccountId" must be a non-empty string.`);
211
+ }
212
+ state.currentAccountId = parsed.currentAccountId;
213
+ }
214
+ const enrollment = parsed.enrollment;
215
+ if (enrollment) {
216
+ if (
217
+ typeof enrollment.owner !== "string" ||
218
+ typeof enrollment.processId !== "number" ||
219
+ typeof enrollment.purpose !== "string" ||
220
+ typeof enrollment.expiresAtMs !== "number" ||
221
+ typeof enrollment.startedAtMs !== "number"
222
+ ) {
223
+ throw new Error(`Invalid ${path}: "enrollment" is malformed.`);
224
+ }
225
+ state.enrollment = enrollment;
226
+ }
227
+ return state;
228
+ }
229
+
230
+ export async function readPoolState(statePath: string): Promise<PoolStateFile> {
231
+ let text: string;
232
+ try { text = await readFile(statePath, "utf8"); }
233
+ catch (error) {
234
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return emptyPoolState();
235
+ throw error;
236
+ }
237
+ return parsePoolState(text, statePath);
238
+ }
239
+
240
+ /** Short transaction for the non-secret cooldown/probe state. Never holds across a model stream. */
241
+ export async function withPoolStateTransaction<T>(
242
+ statePath: string,
243
+ run: (state: PoolStateFile) => Promise<TransactionResult<PoolStateFile, T>> | TransactionResult<PoolStateFile, T>,
244
+ options: FileLockOptions = {},
245
+ ): Promise<T> {
246
+ await mkdir(dirname(statePath), { recursive: true, mode: 0o700 });
247
+ const lock = await acquireFileLock(statePath, options);
248
+ try {
249
+ await assertReplaceableFile(statePath);
250
+ const { value, result } = await run(await readPoolState(statePath));
251
+ if (value !== undefined) {
252
+ await lock.verifyHeld();
253
+ await writeTextAtomic(statePath, `${JSON.stringify(value, null, 2)}\n`);
254
+ }
255
+ return result;
256
+ } finally {
257
+ await lock.release();
258
+ }
259
+ }
@@ -0,0 +1,291 @@
1
+ import { accountLabel, ensureFreshAccountCredential, type PoolAccount } from "./auth.ts";
2
+ import { recordUsage } from "./availability.ts";
3
+ import type { AccountStateRecord, AccountUsage, PoolStateFile, UsageWindow } from "./store.ts";
4
+
5
+ /** ChatGPT's read-only Codex quota endpoint. Private and not guaranteed stable. */
6
+ export const CODEX_USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
7
+
8
+ const MAX_RESET_HORIZON_MS = 30 * 24 * 60 * 60 * 1_000;
9
+ const ONE_DAY_MS = 24 * 60 * 60 * 1_000;
10
+ const MAX_USAGE_BODY_BYTES = 256 * 1024;
11
+ const DEFAULT_USAGE_TIMEOUT_MS = 10_000;
12
+ const EXHAUSTED_PERCENT = 100;
13
+ const MILLISECOND_EPOCH_FLOOR = 1e10;
14
+
15
+ function finite(value: unknown): number | undefined {
16
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
17
+ }
18
+
19
+ function resetAtMs(raw: number | undefined, now: number): number | undefined {
20
+ if (raw === undefined || raw <= 0) return undefined;
21
+ const candidate = raw > MILLISECOND_EPOCH_FLOOR ? raw : raw * 1_000;
22
+ return candidate > now - ONE_DAY_MS && candidate < now + MAX_RESET_HORIZON_MS ? candidate : undefined;
23
+ }
24
+
25
+ function normalizeWindow(value: unknown, now: number): UsageWindow | undefined {
26
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
27
+ const raw = value as Record<string, unknown>;
28
+ const used = finite(raw.used_percent) ?? finite(raw.usedPercent);
29
+ if (used === undefined) return undefined;
30
+ const seconds = finite(raw.limit_window_seconds) ?? finite(raw.window_seconds) ?? finite(raw.windowSeconds);
31
+ const minutes = finite(raw.window_minutes) ?? finite(raw.windowMinutes);
32
+ const reset = resetAtMs(finite(raw.reset_at) ?? finite(raw.resetAt), now);
33
+ return {
34
+ usedPercent: Math.max(0, Math.min(100, used)),
35
+ ...(seconds !== undefined && seconds > 0
36
+ ? { windowSeconds: Math.round(seconds) }
37
+ : minutes !== undefined && minutes > 0
38
+ ? { windowSeconds: Math.round(minutes * 60) }
39
+ : {}),
40
+ ...(reset !== undefined ? { resetAtMs: reset } : {}),
41
+ };
42
+ }
43
+
44
+ /** Normalizes one wham/usage response into the non-secret state we persist. */
45
+ export function normalizeUsageResponse(value: unknown, now: number): AccountUsage {
46
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Codex usage response is not an object.");
47
+ const raw = value as Record<string, unknown>;
48
+ const rateLimit = typeof raw.rate_limit === "object" && raw.rate_limit !== null ? (raw.rate_limit as Record<string, unknown>) : undefined;
49
+ if (!rateLimit) throw new Error("Codex usage response has no rate_limit object.");
50
+ const primary = normalizeWindow(rateLimit.primary_window, now);
51
+ const secondary = normalizeWindow(rateLimit.secondary_window, now);
52
+ const allowed = typeof rateLimit.allowed === "boolean" ? rateLimit.allowed : undefined;
53
+ const limitReached = typeof rateLimit.limit_reached === "boolean" ? rateLimit.limit_reached : undefined;
54
+ const plan = typeof raw.plan_type === "string" && /^[a-z0-9_-]{1,32}$/i.test(raw.plan_type) ? raw.plan_type : undefined;
55
+ if (!primary && !secondary && allowed === undefined && limitReached === undefined) {
56
+ throw new Error("Codex usage response contains no usable quota data.");
57
+ }
58
+ return {
59
+ ...(primary ? { primary } : {}),
60
+ ...(secondary ? { secondary } : {}),
61
+ ...(allowed !== undefined ? { allowed } : {}),
62
+ ...(limitReached !== undefined ? { limitReached } : {}),
63
+ ...(plan ? { plan } : {}),
64
+ observedAtMs: now,
65
+ };
66
+ }
67
+
68
+ async function readJsonBounded(response: Response): Promise<unknown> {
69
+ if (!response.body) return JSON.parse("");
70
+ const reader = response.body.getReader();
71
+ const chunks: Uint8Array[] = [];
72
+ let size = 0;
73
+ try {
74
+ while (true) {
75
+ const { done, value } = await reader.read();
76
+ if (done) break;
77
+ size += value.byteLength;
78
+ if (size > MAX_USAGE_BODY_BYTES) throw new Error("Codex usage response is too large.");
79
+ chunks.push(value);
80
+ }
81
+ } finally {
82
+ await reader.cancel().catch(() => {});
83
+ }
84
+ return JSON.parse(new TextDecoder().decode(Buffer.concat(chunks)));
85
+ }
86
+
87
+ /** Fetches live quota for one account. Tokens are used only in request headers and never returned. */
88
+ export async function fetchAccountUsage(input: {
89
+ credential: Record<string, unknown>;
90
+ accountId: string;
91
+ now?: () => number;
92
+ fetch?: typeof globalThis.fetch;
93
+ signal?: AbortSignal;
94
+ timeoutMs?: number;
95
+ }): Promise<AccountUsage> {
96
+ const access = typeof input.credential.access === "string" ? input.credential.access : undefined;
97
+ if (!access) throw new Error("Codex account has no access token.");
98
+ const controller = new AbortController();
99
+ const abort = () => controller.abort(input.signal?.reason);
100
+ if (input.signal?.aborted) abort();
101
+ else input.signal?.addEventListener("abort", abort, { once: true });
102
+ const timer = setTimeout(() => controller.abort(new Error("usage_timeout")), input.timeoutMs ?? DEFAULT_USAGE_TIMEOUT_MS);
103
+ try {
104
+ const response = await (input.fetch ?? globalThis.fetch)(CODEX_USAGE_ENDPOINT, {
105
+ method: "GET",
106
+ headers: {
107
+ Authorization: `Bearer ${access}`,
108
+ "ChatGPT-Account-Id": input.accountId,
109
+ Accept: "application/json",
110
+ },
111
+ signal: controller.signal,
112
+ });
113
+ if (!response.ok) throw new Error(`Codex usage request failed (${response.status}).`);
114
+ const now = (input.now ?? (() => Date.now()))();
115
+ return normalizeUsageResponse(await readJsonBounded(response), now);
116
+ } finally {
117
+ clearTimeout(timer);
118
+ input.signal?.removeEventListener("abort", abort);
119
+ }
120
+ }
121
+
122
+ function sanitizeWindow(value: unknown): UsageWindow | undefined {
123
+ if (typeof value !== "object" || value === null) return undefined;
124
+ const raw = value as Partial<UsageWindow>;
125
+ if (typeof raw.usedPercent !== "number" || !Number.isFinite(raw.usedPercent)) return undefined;
126
+ const seconds = finite(raw.windowSeconds);
127
+ const reset = finite(raw.resetAtMs);
128
+ return {
129
+ usedPercent: Math.max(0, Math.min(100, raw.usedPercent)),
130
+ ...(seconds !== undefined ? { windowSeconds: seconds } : {}),
131
+ ...(reset !== undefined ? { resetAtMs: reset } : {}),
132
+ };
133
+ }
134
+
135
+ export function storedUsage(record: AccountStateRecord | undefined): AccountUsage | undefined {
136
+ const raw = record?.usage;
137
+ if (typeof raw !== "object" || raw === null) return undefined;
138
+ const observedAtMs = finite(raw.observedAtMs);
139
+ if (observedAtMs === undefined) return undefined;
140
+ const primary = sanitizeWindow(raw.primary);
141
+ const secondary = sanitizeWindow(raw.secondary);
142
+ const allowed = typeof raw.allowed === "boolean" ? raw.allowed : undefined;
143
+ const limitReached = typeof raw.limitReached === "boolean" ? raw.limitReached : undefined;
144
+ const plan = typeof raw.plan === "string" ? raw.plan : undefined;
145
+ if (!primary && !secondary && allowed === undefined && limitReached === undefined) return undefined;
146
+ return {
147
+ ...(primary ? { primary } : {}),
148
+ ...(secondary ? { secondary } : {}),
149
+ ...(allowed !== undefined ? { allowed } : {}),
150
+ ...(limitReached !== undefined ? { limitReached } : {}),
151
+ ...(plan ? { plan } : {}),
152
+ observedAtMs,
153
+ };
154
+ }
155
+
156
+ export function usageAvailable(usage: AccountUsage): boolean {
157
+ if (usage.allowed === false || usage.limitReached === true) return false;
158
+ if (usage.allowed === true && usage.limitReached === false) return true;
159
+ return ![usage.primary, usage.secondary].some((window) => window && window.usedPercent >= EXHAUSTED_PERCENT);
160
+ }
161
+
162
+ /**
163
+ * Refreshes accounts sequentially. Used by `/codex_pool status`, and only when every local account
164
+ * is unavailable before a model request. A failed account is reported but never blocks the rest.
165
+ */
166
+ export async function refreshPoolUsage(input: {
167
+ authPath: string;
168
+ statePath: string;
169
+ accounts: readonly PoolAccount[];
170
+ refresh: (credential: Record<string, unknown>, signal?: AbortSignal) => Promise<Record<string, unknown>>;
171
+ now?: () => number;
172
+ fetch?: typeof globalThis.fetch;
173
+ signal?: AbortSignal;
174
+ timeoutMs?: number;
175
+ }): Promise<UsageRefreshResult[]> {
176
+ const results: UsageRefreshResult[] = [];
177
+ for (const account of [...input.accounts].sort((left, right) => left.ordinal - right.ordinal)) {
178
+ try {
179
+ const observedAtMs = (input.now ?? (() => Date.now()))();
180
+ const fresh = await ensureFreshAccountCredential({
181
+ authPath: input.authPath,
182
+ account,
183
+ refresh: (credential) => input.refresh(credential, input.signal),
184
+ signal: input.signal,
185
+ now: input.now,
186
+ });
187
+ const usage = await fetchAccountUsage({
188
+ credential: fresh.credential,
189
+ accountId: account.id,
190
+ now: input.now,
191
+ fetch: input.fetch,
192
+ signal: input.signal,
193
+ timeoutMs: input.timeoutMs,
194
+ });
195
+ // The request start is the latest instant this response can safely disprove. A quota
196
+ // failure recorded while the response was in flight must survive it.
197
+ usage.observedAtMs = observedAtMs;
198
+ await recordUsage({ statePath: input.statePath, account, usage, clearQuotaCooldowns: usageAvailable(usage) });
199
+ results.push({ account, usage });
200
+ } catch {
201
+ if (input.signal?.aborted) throw input.signal.reason ?? new Error("Request was aborted");
202
+ results.push({ account, error: "usage_unavailable" });
203
+ }
204
+ }
205
+ return results;
206
+ }
207
+
208
+ export interface UsageRefreshResult {
209
+ account: PoolAccount;
210
+ usage?: AccountUsage;
211
+ error?: string;
212
+ }
213
+
214
+ export interface ReadyEstimate {
215
+ accountId: string;
216
+ readyAtMs: number;
217
+ }
218
+
219
+ export function accountReadyAtMs(input: { record: AccountStateRecord | undefined; account: PoolAccount; now: number; modelId?: string }): number | undefined {
220
+ const { record, account, now } = input;
221
+ const future = (candidate: number | null | undefined): number | undefined =>
222
+ typeof candidate === "number" && candidate > now ? candidate : undefined;
223
+ const scopes = new Set(["account", ...(input.modelId ? [`model:${input.modelId}`] : [])]);
224
+ const cooldowns = Object.entries(record?.cooldowns ?? {})
225
+ .filter(([scope, cooldown]) => scopes.has(scope) && cooldown.generation === account.generation)
226
+ .map(([, cooldown]) => cooldown);
227
+ const stated = cooldowns.map((cooldown) => future(cooldown.serverResetAtMs));
228
+ if (cooldowns.length > 0 && stated.some((candidate) => candidate === undefined)) return undefined;
229
+ const usage = storedUsage(record);
230
+ const windows = [usage?.primary, usage?.secondary].filter((window): window is UsageWindow => !!window);
231
+ const exhaustedWindows = windows.filter((window) => window.usedPercent >= EXHAUSTED_PERCENT);
232
+ const explicitlyBlocked = usage?.allowed === false || usage?.limitReached === true;
233
+ const blockingWindows = exhaustedWindows.length > 0 ? exhaustedWindows : explicitlyBlocked ? windows : [];
234
+ const exhausted = blockingWindows.map((window) => future(window.resetAtMs));
235
+ if (explicitlyBlocked && blockingWindows.length === 0) return undefined;
236
+ if (blockingWindows.length > 0 && exhausted.some((candidate) => candidate === undefined)) return undefined;
237
+ // Every active limit must have reset before the account is usable. The later reset wins both
238
+ // across model/account cooldowns and across the primary/secondary quota windows.
239
+ const candidates = [...stated, ...exhausted].filter((candidate): candidate is number => candidate !== undefined);
240
+ return candidates.length > 0 ? Math.max(...candidates) : undefined;
241
+ }
242
+
243
+ export function soonestReady(input: { accounts: readonly PoolAccount[]; state: PoolStateFile; now: number; modelId?: string }): ReadyEstimate | undefined {
244
+ let best: ReadyEstimate | undefined;
245
+ for (const account of input.accounts) {
246
+ const record = input.state.accounts[account.id];
247
+ if (record?.authInvalidGeneration === account.generation) continue;
248
+ const readyAtMs = accountReadyAtMs({ record, account, now: input.now, modelId: input.modelId });
249
+ if (readyAtMs === undefined) continue;
250
+ if (!best || readyAtMs < best.readyAtMs) best = { accountId: account.id, readyAtMs };
251
+ }
252
+ return best;
253
+ }
254
+
255
+ function clockText(atMs: number): string {
256
+ const at = new Date(atMs);
257
+ return `${String(at.getHours()).padStart(2, "0")}:${String(at.getMinutes()).padStart(2, "0")}`;
258
+ }
259
+
260
+ export function formatRecovery(atMs: number, now: number): string {
261
+ const minutes = Math.max(1, Math.round((atMs - now) / 60_000));
262
+ if (minutes < 60) return `约 ${minutes} 分钟后(${clockText(atMs)})`;
263
+ const hours = Math.floor(minutes / 60);
264
+ const rest = minutes % 60;
265
+ if (hours < 24) return `约 ${hours} 小时${rest > 0 ? ` ${rest} 分钟` : ""}后(${clockText(atMs)})`;
266
+ return `约 ${Math.floor(hours / 24)} 天 ${hours % 24} 小时后(${clockText(atMs)})`;
267
+ }
268
+
269
+ function windowName(window: UsageWindow, fallback: string): string {
270
+ if (window.windowSeconds === undefined) return fallback;
271
+ const hours = window.windowSeconds / 3_600;
272
+ if (hours >= 24 && hours % 24 === 0) return `${hours / 24} 天`;
273
+ return Number.isInteger(hours) ? `${hours} 小时` : fallback;
274
+ }
275
+
276
+ export function describeUsage(usage: AccountUsage | undefined, now = Date.now()): string | undefined {
277
+ if (!usage) return undefined;
278
+ const parts: string[] = [];
279
+ const describe = (window: UsageWindow, fallback: string) =>
280
+ `${windowName(window, fallback)}已用 ${Math.round(window.usedPercent)}%${window.resetAtMs ? `,${formatRecovery(window.resetAtMs, now)}重置` : ""}`;
281
+ if (usage.primary) parts.push(describe(usage.primary, "5 小时"));
282
+ if (usage.secondary) parts.push(describe(usage.secondary, "7 天"));
283
+ return parts.length > 0 ? parts.join(";") : undefined;
284
+ }
285
+
286
+ export function describeSoonestReady(input: { accounts: readonly PoolAccount[]; state: PoolStateFile; now: number; modelId?: string }): string | undefined {
287
+ const estimate = soonestReady(input);
288
+ if (!estimate) return undefined;
289
+ const account = input.accounts.find((candidate) => candidate.id === estimate.accountId);
290
+ return `最早可用:${formatRecovery(estimate.readyAtMs, input.now)},${account ? accountLabel(account) : "某个账号"}`;
291
+ }
@@ -0,0 +1,10 @@
1
+ import assert from "node:assert/strict";
2
+
3
+ import { codexPoolArgumentCompletions } from "../src/command-completions.ts";
4
+
5
+ assert.deepEqual(codexPoolArgumentCompletions("sta"), [{ value: "status", label: "status" }]);
6
+ assert.deepEqual(codexPoolArgumentCompletions("status"), [], "an exact subcommand must let Enter submit immediately");
7
+ assert.deepEqual(codexPoolArgumentCompletions("use"), [], "every exact subcommand must hide its identical completion");
8
+ assert.deepEqual(codexPoolArgumentCompletions("use 2"), [], "account arguments are not subcommand completions");
9
+
10
+ console.log("Command completion tests passed");