Spaces:
Sleeping
Sleeping
| import fs from "node:fs/promises"; | |
| import path from "node:path"; | |
| const DEFAULT_REFRESH_ENDPOINT = "https://auth.openai.com/oauth/token"; | |
| const DEFAULT_PROBE_URL = "https://api.openai.com/v1/models"; | |
| const DEFAULT_REFRESH_LEEWAY_SECONDS = 300; | |
| const COOLDOWN_SECONDS = { | |
| auth: 1800, | |
| quota: 900, | |
| rate_limit: 120, | |
| server: 45, | |
| network: 30, | |
| invalid: 300, | |
| }; | |
| function nowMs(nowFn = Date.now) { | |
| return Number(nowFn()); | |
| } | |
| export function decodeJwtPayload(token) { | |
| try { | |
| const parts = String(token || "").split("."); | |
| if (parts.length < 2) return null; | |
| return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")); | |
| } catch { | |
| return null; | |
| } | |
| } | |
| function parseDateMs(value) { | |
| const date = new Date(value); | |
| const stamp = date.getTime(); | |
| return Number.isNaN(stamp) ? null : stamp; | |
| } | |
| function isoFromMs(ms) { | |
| return new Date(ms).toISOString(); | |
| } | |
| function containsAny(text, needles) { | |
| const lower = String(text || "").toLowerCase(); | |
| return needles.some((needle) => lower.includes(needle)); | |
| } | |
| function makeAccountIdForEntry(filePath, index = 0) { | |
| return index > 0 ? `${path.basename(filePath)}#${index + 1}` : path.basename(filePath); | |
| } | |
| function normalizeAccountEntries(raw) { | |
| if (Array.isArray(raw)) { | |
| return raw.filter((entry) => entry && typeof entry === "object"); | |
| } | |
| return []; | |
| } | |
| export function classifyFailure({ status = 0, detail = "" }) { | |
| if (status === 401 || status === 403) { | |
| return { category: "auth", reason: `http-${status}` }; | |
| } | |
| if (status === 429) { | |
| return { category: "rate_limit", reason: "http-429" }; | |
| } | |
| if (status >= 500) { | |
| return { category: "server", reason: `http-${status}` }; | |
| } | |
| if ( | |
| containsAny(detail, [ | |
| "insufficient_quota", | |
| "quota", | |
| "subscription_not_found", | |
| "billing", | |
| "insufficient balance", | |
| "credit", | |
| "额度", | |
| "余额", | |
| "订阅", | |
| ]) | |
| ) { | |
| return { category: "quota", reason: "quota-related" }; | |
| } | |
| if (status === 0) { | |
| return { category: "network", reason: "network" }; | |
| } | |
| return { category: "invalid", reason: status ? `http-${status}` : "unknown" }; | |
| } | |
| function makeAccount(raw, filePath, now, index = 0) { | |
| const isAuthJsonShape = raw && typeof raw === "object" && raw.tokens && typeof raw.tokens === "object"; | |
| const tokenSource = isAuthJsonShape ? raw.tokens : raw; | |
| const normalizedType = raw.type || (isAuthJsonShape ? "codex" : ""); | |
| const payload = decodeJwtPayload(tokenSource.access_token); | |
| const accessTokenExpMs = | |
| typeof payload?.exp === "number" ? Number(payload.exp) * 1000 : null; | |
| const expiredFieldMs = parseDateMs(raw.expired); | |
| const expiresAtMs = accessTokenExpMs || expiredFieldMs; | |
| return { | |
| id: makeAccountIdForEntry(filePath, index), | |
| filePath, | |
| entryIndex: index, | |
| raw, | |
| type: normalizedType, | |
| email: raw.email || "", | |
| accountId: tokenSource.account_id || "", | |
| accessToken: tokenSource.access_token || "", | |
| idToken: tokenSource.id_token || "", | |
| refreshToken: tokenSource.refresh_token || "", | |
| clientId: payload?.client_id || "", | |
| accessTokenExpMs: expiresAtMs, | |
| disabled: Boolean(raw.disabled), | |
| lastRefresh: raw.last_refresh || null, | |
| lastValidation: null, | |
| lastFailureReason: "", | |
| consecutiveFailures: 0, | |
| cooldownUntilMs: 0, | |
| healthy: false, | |
| loadedAtMs: now, | |
| }; | |
| } | |
| export function isAccountStructurallyEligible(account) { | |
| if (!account) return false; | |
| if (account.type !== "codex") return false; | |
| if (account.disabled) return false; | |
| if (!account.accountId || !account.accessToken || !account.idToken) return false; | |
| if (!account.refreshToken) return false; | |
| return true; | |
| } | |
| export function buildStoredCodexAccountEntry(account) { | |
| const nextExpired = account.accessTokenExpMs | |
| ? isoFromMs(account.accessTokenExpMs) | |
| : account.raw.expired; | |
| const nextLastRefresh = account.lastRefresh || new Date().toISOString(); | |
| return { | |
| OPENAI_API_KEY: account.raw?.OPENAI_API_KEY || "", | |
| auth_mode: account.raw?.auth_mode || "chatgpt", | |
| type: account.raw?.type || "codex", | |
| disabled: Boolean(account.raw?.disabled ?? account.disabled), | |
| email: account.raw?.email || account.email || "", | |
| name: account.raw?.name || "", | |
| last_refresh: nextLastRefresh, | |
| expired: nextExpired || null, | |
| tokens: { | |
| ...(account.raw?.tokens || {}), | |
| access_token: account.accessToken, | |
| id_token: account.idToken, | |
| refresh_token: account.refreshToken, | |
| account_id: account.accountId, | |
| }, | |
| }; | |
| } | |
| export class CodexAccountPool { | |
| constructor({ | |
| tokensDir, | |
| refreshEndpoint = DEFAULT_REFRESH_ENDPOINT, | |
| probeUrl = DEFAULT_PROBE_URL, | |
| refreshLeewaySeconds = DEFAULT_REFRESH_LEEWAY_SECONDS, | |
| fetchFn = fetch, | |
| nowFn = Date.now, | |
| logger = () => {}, | |
| loadSnapshot = null, | |
| saveSnapshot = null, | |
| sourcePath = "", | |
| }) { | |
| this.tokensDir = tokensDir; | |
| this.refreshEndpoint = refreshEndpoint; | |
| this.probeUrl = probeUrl; | |
| this.refreshLeewaySeconds = refreshLeewaySeconds; | |
| this.fetchFn = fetchFn; | |
| this.nowFn = nowFn; | |
| this.logger = logger; | |
| this.loadSnapshot = loadSnapshot; | |
| this.saveSnapshot = saveSnapshot; | |
| this.sourcePath = sourcePath || (tokensDir ? path.join(tokensDir, "pool.json") : "pool.json"); | |
| this.accounts = []; | |
| this.activeAccountId = null; | |
| } | |
| listAccounts() { | |
| return [...this.accounts]; | |
| } | |
| getActiveAccount() { | |
| if (!this.activeAccountId) return null; | |
| return this.accounts.find((account) => account.id === this.activeAccountId) || null; | |
| } | |
| async readSnapshot() { | |
| if (typeof this.loadSnapshot === "function") { | |
| const loaded = await this.loadSnapshot(); | |
| if (!loaded) return null; | |
| if (Array.isArray(loaded)) { | |
| return { entries: loaded, sourcePath: this.sourcePath }; | |
| } | |
| return { | |
| entries: Array.isArray(loaded.entries) ? loaded.entries : [], | |
| sourcePath: loaded.sourcePath || this.sourcePath, | |
| }; | |
| } | |
| const filePath = path.join(this.tokensDir, "pool.json"); | |
| try { | |
| const raw = JSON.parse(await fs.readFile(filePath, "utf8")); | |
| return { | |
| entries: normalizeAccountEntries(raw), | |
| sourcePath: filePath, | |
| }; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| async load() { | |
| const now = nowMs(this.nowFn); | |
| const loaded = []; | |
| const snapshot = await this.readSnapshot(); | |
| if (!snapshot) { | |
| this.accounts = []; | |
| this.activeAccountId = null; | |
| return; | |
| } | |
| const entries = normalizeAccountEntries(snapshot.entries); | |
| if (entries.length === 0) { | |
| this.logger("load:skip", { | |
| file: path.basename(snapshot.sourcePath), | |
| reason: "empty-or-invalid-json", | |
| }); | |
| this.accounts = []; | |
| this.activeAccountId = null; | |
| return; | |
| } | |
| for (const [index, entry] of entries.entries()) { | |
| const account = makeAccount(entry, snapshot.sourcePath, now, index); | |
| if (!isAccountStructurallyEligible(account)) { | |
| this.logger("load:skip", { | |
| file: path.basename(snapshot.sourcePath), | |
| index, | |
| reason: "structurally-ineligible", | |
| }); | |
| continue; | |
| } | |
| this.logger("load:account", { | |
| file: path.basename(snapshot.sourcePath), | |
| index, | |
| accountId: account.accountId, | |
| hasRefreshToken: Boolean(account.refreshToken), | |
| }); | |
| loaded.push(account); | |
| } | |
| this.accounts = loaded; | |
| if (!this.activeAccountId && this.accounts.length > 0) { | |
| this.activeAccountId = this.accounts[0].id; | |
| } | |
| if ( | |
| this.activeAccountId && | |
| !this.accounts.find((account) => account.id === this.activeAccountId) | |
| ) { | |
| this.activeAccountId = this.accounts[0]?.id || null; | |
| } | |
| } | |
| needsRefresh(account) { | |
| const now = nowMs(this.nowFn); | |
| if (!account.accessTokenExpMs) return false; | |
| return account.accessTokenExpMs - now <= this.refreshLeewaySeconds * 1000; | |
| } | |
| isCoolingDown(account) { | |
| return account.cooldownUntilMs > nowMs(this.nowFn); | |
| } | |
| pickNextHealthyAccount(excluded = new Set()) { | |
| const ordered = this.accounts; | |
| const activeId = this.activeAccountId; | |
| const startIdx = activeId | |
| ? Math.max(ordered.findIndex((account) => account.id === activeId), 0) | |
| : 0; | |
| const rotated = ordered | |
| .slice(startIdx + 1) | |
| .concat(ordered.slice(0, startIdx + 1)); | |
| return ( | |
| rotated.find((account) => !excluded.has(account.id) && !this.isCoolingDown(account)) || | |
| null | |
| ); | |
| } | |
| markSuccess(account) { | |
| const previousId = this.activeAccountId; | |
| const previous = | |
| previousId ? this.accounts.find((item) => item.id === previousId) : null; | |
| const previousFailure = previous?.lastFailureReason || ""; | |
| account.healthy = true; | |
| account.consecutiveFailures = 0; | |
| account.lastFailureReason = ""; | |
| account.cooldownUntilMs = 0; | |
| account.lastValidation = isoFromMs(nowMs(this.nowFn)); | |
| this.activeAccountId = account.id; | |
| if (previousId && previousId !== account.id) { | |
| this.logger("pool:active-account", { | |
| message: `活跃账号切换:${previousId} → ${account.id} (${account.email || account.accountId || ""}),上一个账号失败原因:${previousFailure || "未知"}`, | |
| id: account.id, | |
| email: account.email, | |
| accountId: account.accountId, | |
| previousId, | |
| previousFailure, | |
| }); | |
| } | |
| } | |
| markFailure(account, category, reason) { | |
| account.healthy = false; | |
| account.consecutiveFailures += 1; | |
| account.lastFailureReason = `${category}:${reason}`; | |
| const cooldownSeconds = COOLDOWN_SECONDS[category] || COOLDOWN_SECONDS.invalid; | |
| account.cooldownUntilMs = nowMs(this.nowFn) + cooldownSeconds * 1000; | |
| } | |
| async persistAccount(account) { | |
| const nextRaw = buildStoredCodexAccountEntry(account); | |
| account.raw = nextRaw; | |
| if (typeof this.saveSnapshot === "function") { | |
| const nextStored = this.accounts.map((item) => buildStoredCodexAccountEntry(item)); | |
| await this.saveSnapshot(nextStored); | |
| return; | |
| } | |
| const stored = JSON.parse(await fs.readFile(account.filePath, "utf8")); | |
| if (Array.isArray(stored)) { | |
| const nextStored = [...stored]; | |
| nextStored[account.entryIndex || 0] = nextRaw; | |
| await fs.writeFile(account.filePath, `${JSON.stringify(nextStored, null, 2)}\n`, "utf8"); | |
| return; | |
| } | |
| await fs.writeFile(account.filePath, `${JSON.stringify(nextRaw, null, 2)}\n`, "utf8"); | |
| } | |
| async refreshAccount(account) { | |
| this.logger("refresh:start", { | |
| id: account.id, | |
| accountId: account.accountId, | |
| }); | |
| const clientId = | |
| account.clientId || decodeJwtPayload(account.accessToken)?.client_id || ""; | |
| if (!clientId) { | |
| this.logger("refresh:fail", { | |
| id: account.id, | |
| accountId: account.accountId, | |
| reason: "missing-client-id", | |
| }); | |
| throw new Error("missing-client-id"); | |
| } | |
| const payload = new URLSearchParams({ | |
| grant_type: "refresh_token", | |
| refresh_token: account.refreshToken, | |
| client_id: clientId, | |
| }); | |
| const response = await this.fetchFn(this.refreshEndpoint, { | |
| method: "POST", | |
| headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| body: payload.toString(), | |
| }); | |
| const text = await response.text(); | |
| let json = null; | |
| try { | |
| json = JSON.parse(text); | |
| } catch { | |
| json = null; | |
| } | |
| if (!response.ok) { | |
| const detail = json?.error_description || json?.error || text || `http-${response.status}`; | |
| const classified = classifyFailure({ status: response.status, detail }); | |
| this.markFailure(account, classified.category, detail); | |
| this.logger("refresh:fail", { | |
| id: account.id, | |
| accountId: account.accountId, | |
| status: response.status, | |
| category: classified.category, | |
| detail, | |
| }); | |
| throw new Error(`refresh-failed:${classified.category}:${detail}`); | |
| } | |
| const accessToken = json?.access_token; | |
| if (!accessToken) { | |
| this.markFailure(account, "invalid", "refresh-no-access-token"); | |
| throw new Error("refresh-no-access-token"); | |
| } | |
| account.accessToken = accessToken; | |
| account.refreshToken = json?.refresh_token || account.refreshToken; | |
| account.idToken = json?.id_token || account.idToken; | |
| account.clientId = json?.client_id || clientId; | |
| account.lastRefresh = new Date().toISOString(); | |
| const jwt = decodeJwtPayload(accessToken); | |
| if (typeof jwt?.exp === "number") { | |
| account.accessTokenExpMs = Number(jwt.exp) * 1000; | |
| } else if (typeof json?.expires_in === "number") { | |
| account.accessTokenExpMs = nowMs(this.nowFn) + Number(json.expires_in) * 1000; | |
| } | |
| await this.persistAccount(account); | |
| this.logger("refresh:ok", { | |
| id: account.id, | |
| accountId: account.accountId, | |
| expiresAt: account.accessTokenExpMs ? isoFromMs(account.accessTokenExpMs) : null, | |
| }); | |
| } | |
| async probeAccount(account) { | |
| this.logger("probe:start", { | |
| id: account.id, | |
| accountId: account.accountId, | |
| }); | |
| let response; | |
| try { | |
| response = await this.fetchFn(this.probeUrl, { | |
| method: "GET", | |
| headers: { authorization: `Bearer ${account.accessToken}` }, | |
| }); | |
| } catch (error) { | |
| const detail = error?.message || String(error); | |
| this.markFailure(account, "network", detail); | |
| this.logger("probe:fail", { | |
| id: account.id, | |
| accountId: account.accountId, | |
| category: "network", | |
| detail, | |
| }); | |
| return { | |
| ok: false, | |
| status: 0, | |
| category: "network", | |
| reason: "network", | |
| detail, | |
| }; | |
| } | |
| if (response.ok) { | |
| this.markSuccess(account); | |
| this.logger("probe:ok", { | |
| id: account.id, | |
| accountId: account.accountId, | |
| status: response.status, | |
| }); | |
| return { ok: true, status: response.status, category: "ok", reason: "probe-ok" }; | |
| } | |
| const detail = await response.text(); | |
| const classified = classifyFailure({ status: response.status, detail }); | |
| this.markFailure(account, classified.category, detail || `http-${response.status}`); | |
| this.logger("probe:fail", { | |
| id: account.id, | |
| accountId: account.accountId, | |
| status: response.status, | |
| category: classified.category, | |
| detail, | |
| }); | |
| return { | |
| ok: false, | |
| status: response.status, | |
| category: classified.category, | |
| reason: classified.reason, | |
| detail, | |
| }; | |
| } | |
| async ensureAccountHealthy(account) { | |
| this.logger("account:check", { | |
| id: account.id, | |
| accountId: account.accountId, | |
| needsRefresh: this.needsRefresh(account), | |
| }); | |
| if (this.needsRefresh(account)) { | |
| await this.refreshAccount(account); | |
| } | |
| return this.probeAccount(account); | |
| } | |
| async getInitialAccount() { | |
| if (this.accounts.length === 0) return null; | |
| const excluded = new Set(); | |
| for (let i = 0; i < this.accounts.length; i += 1) { | |
| const candidate = | |
| i === 0 && this.getActiveAccount() && !excluded.has(this.getActiveAccount().id) | |
| ? this.getActiveAccount() | |
| : this.pickNextHealthyAccount(excluded); | |
| if (!candidate) break; | |
| excluded.add(candidate.id); | |
| if (this.isCoolingDown(candidate)) continue; | |
| try { | |
| const probe = await this.ensureAccountHealthy(candidate); | |
| if (probe.ok) { | |
| this.activeAccountId = candidate.id; | |
| return candidate; | |
| } | |
| } catch { | |
| continue; | |
| } | |
| } | |
| return null; | |
| } | |
| } | |