Spaces:
Sleeping
Sleeping
| import { spawn } from "node:child_process"; | |
| import http from "node:http"; | |
| import { Readable } from "node:stream"; | |
| import path from "node:path"; | |
| import { CodexAccountPool, classifyFailure } from "../proxy/codex-account-pool.mjs"; | |
| import { sanitizeForLogs } from "../shared/secret-sanitizer.mjs"; | |
| const DEFAULT_HOST = "127.0.0.1"; | |
| const DEFAULT_PORT = 8787; | |
| const DEFAULT_TOKENS_DIR = path.resolve(process.cwd(), "acc_pool"); | |
| const DEFAULT_UPSTREAM_BASE = "https://chatgpt.com/backend-api/codex"; | |
| const DEFAULT_REFRESH_ENDPOINT = "https://auth.openai.com/oauth/token"; | |
| const DEFAULT_CLIENT_VERSION = "0.117.0"; | |
| const DEFAULT_PROBE_URL = `${DEFAULT_UPSTREAM_BASE}/models?client_version=${DEFAULT_CLIENT_VERSION}`; | |
| const DEFAULT_LOCAL_API_KEY = "local-acc-pool-proxy-key"; | |
| const DEFAULT_MAX_SWITCH_ATTEMPTS = 3; | |
| const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; | |
| const SUPPORTED_PATHS = new Set([ | |
| "/models", | |
| "/responses", | |
| "/v1/models", | |
| "/v1/responses", | |
| "/v1/chat/completions", | |
| ]); | |
| function parseArgs(argv) { | |
| const args = {}; | |
| for (const part of argv) { | |
| if (!part.startsWith("--")) continue; | |
| const raw = part.slice(2); | |
| const idx = raw.indexOf("="); | |
| if (idx < 0) { | |
| args[raw] = "true"; | |
| continue; | |
| } | |
| args[raw.slice(0, idx)] = raw.slice(idx + 1); | |
| } | |
| return args; | |
| } | |
| function printUsage() { | |
| console.log(`Usage: | |
| node src/scripts/codex-local-proxy.mjs | |
| Options: | |
| --host=127.0.0.1 | |
| --port=8787 | |
| --tokens-dir=acc_pool | |
| --upstream-base=https://chatgpt.com/backend-api/codex | |
| --refresh-endpoint=https://auth.openai.com/oauth/token | |
| --probe-url=https://chatgpt.com/backend-api/codex/models?client_version=0.117.0 | |
| --local-api-key=local-acc-pool-proxy-key | |
| --max-switch-attempts=3 | |
| --request-timeout-ms=60000 | |
| --proxy-url=http://127.0.0.1:8118 | |
| --help | |
| `); | |
| } | |
| async function maybeRespawnWithProxy(argv) { | |
| const args = parseArgs(argv); | |
| const proxyUrl = args["proxy-url"] || ""; | |
| if (!proxyUrl || process.env.CODEX_PROXY_BOOTSTRAPPED === "1") { | |
| return false; | |
| } | |
| const childEnv = { | |
| ...process.env, | |
| CODEX_PROXY_BOOTSTRAPPED: "1", | |
| NODE_USE_ENV_PROXY: "1", | |
| HTTPS_PROXY: proxyUrl, | |
| HTTP_PROXY: proxyUrl, | |
| ALL_PROXY: proxyUrl, | |
| }; | |
| const child = spawn(process.execPath, [process.argv[1], ...argv], { | |
| stdio: "inherit", | |
| env: childEnv, | |
| }); | |
| await new Promise((resolve, reject) => { | |
| child.on("exit", (code) => { | |
| process.exitCode = code ?? 1; | |
| resolve(); | |
| }); | |
| child.on("error", reject); | |
| }); | |
| return true; | |
| } | |
| function safeJson(value) { | |
| return JSON.stringify(value, null, 2); | |
| } | |
| function getRequestPath(url = "/") { | |
| try { | |
| const parsed = new URL(url, "http://localhost"); | |
| return parsed.pathname; | |
| } catch { | |
| return "/"; | |
| } | |
| } | |
| function resolveUpstreamUrl(reqUrl, requestPath, options) { | |
| const upstreamBase = options.upstreamBase.replace(/\/+$/, ""); | |
| const parsed = new URL(reqUrl || "/", "http://localhost"); | |
| if (upstreamBase.includes("chatgpt.com/backend-api/codex")) { | |
| if (requestPath === "/models" || requestPath === "/v1/models") { | |
| return `${upstreamBase}/models?client_version=${encodeURIComponent( | |
| options.clientVersion, | |
| )}`; | |
| } | |
| if (requestPath === "/responses" || requestPath === "/v1/responses") { | |
| return `${upstreamBase}/responses`; | |
| } | |
| } | |
| return `${upstreamBase}${parsed.pathname}${parsed.search}`; | |
| } | |
| function copyHeadersForUpstream(reqHeaders, token) { | |
| const headers = {}; | |
| for (const [key, value] of Object.entries(reqHeaders || {})) { | |
| if (value == null) continue; | |
| const lower = key.toLowerCase(); | |
| if (["host", "authorization", "content-length", "connection"].includes(lower)) { | |
| continue; | |
| } | |
| headers[key] = value; | |
| } | |
| headers.authorization = `Bearer ${token}`; | |
| return headers; | |
| } | |
| function copyHeadersToClient(res, upstreamHeaders) { | |
| for (const [key, value] of upstreamHeaders.entries()) { | |
| if (key.toLowerCase() === "transfer-encoding") continue; | |
| res.setHeader(key, value); | |
| } | |
| } | |
| function readRequestBody(req) { | |
| return new Promise((resolve, reject) => { | |
| const chunks = []; | |
| req.on("data", (chunk) => chunks.push(chunk)); | |
| req.on("error", reject); | |
| req.on("end", () => resolve(Buffer.concat(chunks))); | |
| }); | |
| } | |
| function requireLocalAuth(req, expectedKey) { | |
| const auth = String(req.headers.authorization || ""); | |
| const expected = `Bearer ${expectedKey}`; | |
| return auth === expected; | |
| } | |
| function classifyRetryableFailure(status, detail) { | |
| const result = classifyFailure({ status, detail }); | |
| const retryable = ["auth", "rate_limit", "quota", "server", "network"].includes( | |
| result.category, | |
| ); | |
| return { ...result, retryable }; | |
| } | |
| export function accountSummary(account) { | |
| if (!account) return null; | |
| return { | |
| id: account.id, | |
| email: account.email, | |
| accountId: account.accountId, | |
| healthy: account.healthy, | |
| cooldownUntil: account.cooldownUntilMs ? new Date(account.cooldownUntilMs).toISOString() : null, | |
| lastFailureReason: account.lastFailureReason, | |
| lastValidation: account.lastValidation, | |
| }; | |
| } | |
| function createConsoleStartupLogger() { | |
| return (event, payload = {}) => { | |
| const time = new Date().toISOString(); | |
| const sanitized = sanitizeForLogs(payload); | |
| const details = Object.entries(sanitized) | |
| .filter(([, value]) => value !== undefined && value !== null && value !== "") | |
| .map(([key, value]) => `${key}=${JSON.stringify(value)}`) | |
| .join(" "); | |
| console.log(`[startup] ${time} ${event}${details ? ` ${details}` : ""}`); | |
| }; | |
| } | |
| function unauthorized(res) { | |
| res.statusCode = 401; | |
| res.setHeader("content-type", "application/json"); | |
| res.end(safeJson({ error: { message: "Unauthorized local proxy key." } })); | |
| } | |
| export async function createProxyService(options) { | |
| const startupLogger = typeof options.logger === "function" ? options.logger : createConsoleStartupLogger(); | |
| const fetchFn = options.fetchFn || (await createFetchWithProxy(options.proxyUrl)); | |
| const pool = new CodexAccountPool({ | |
| tokensDir: options.tokensDir, | |
| refreshEndpoint: options.refreshEndpoint, | |
| probeUrl: options.probeUrl, | |
| fetchFn, | |
| logger: startupLogger, | |
| loadSnapshot: options.loadSnapshot, | |
| saveSnapshot: options.saveSnapshot, | |
| sourcePath: options.sourcePath, | |
| }); | |
| startupLogger("pool:load:start", { tokensDir: options.tokensDir }); | |
| await pool.load(); | |
| startupLogger("pool:load:done", { count: pool.listAccounts().length }); | |
| startupLogger("pool:initial-account:start"); | |
| const active = await pool.getInitialAccount(); | |
| if (!active) { | |
| const snapshot = pool.listAccounts().map((account) => ({ | |
| id: account.id, | |
| email: account.email, | |
| accountId: account.accountId, | |
| cooldownUntil: account.cooldownUntilMs | |
| ? new Date(account.cooldownUntilMs).toISOString() | |
| : null, | |
| lastFailureReason: account.lastFailureReason || "(none)", | |
| hasRefreshToken: Boolean(account.refreshToken), | |
| })); | |
| throw new Error( | |
| `No usable account after initial probe. Accounts: ${JSON.stringify(snapshot)}`, | |
| ); | |
| } | |
| startupLogger("pool:initial-account:done", { | |
| id: active.id, | |
| accountId: active.accountId, | |
| }); | |
| async function reload() { | |
| await pool.load(); | |
| const nextActive = await pool.getInitialAccount(); | |
| if (!nextActive) { | |
| throw new Error("No usable account after reload."); | |
| } | |
| return nextActive; | |
| } | |
| function getAdminStatus() { | |
| return { | |
| active: accountSummary(pool.getActiveAccount()), | |
| accounts: pool.listAccounts().map(accountSummary), | |
| }; | |
| } | |
| async function handleRequest( | |
| req, | |
| res, | |
| { requestUrl = req.url, exposeHealthDetails = true, exposeStatus = true } = {}, | |
| ) { | |
| const requestPath = getRequestPath(requestUrl); | |
| if (requestPath === "/healthz") { | |
| res.statusCode = 200; | |
| res.setHeader("content-type", "application/json"); | |
| res.end( | |
| safeJson( | |
| exposeHealthDetails | |
| ? { ok: true, active: accountSummary(pool.getActiveAccount()) } | |
| : { ok: true }, | |
| ), | |
| ); | |
| return; | |
| } | |
| if (requestPath === "/proxy/status" && exposeStatus) { | |
| res.statusCode = 200; | |
| res.setHeader("content-type", "application/json"); | |
| res.end(safeJson(getAdminStatus())); | |
| return; | |
| } | |
| if (!requireLocalAuth(req, options.localApiKey)) { | |
| unauthorized(res); | |
| return; | |
| } | |
| if (!SUPPORTED_PATHS.has(requestPath)) { | |
| res.statusCode = 404; | |
| res.setHeader("content-type", "application/json"); | |
| res.end( | |
| safeJson({ | |
| error: { | |
| message: `Unsupported path: ${requestPath}. Supported: ${[ | |
| ...SUPPORTED_PATHS, | |
| ].join(", ")}`, | |
| }, | |
| }), | |
| ); | |
| return; | |
| } | |
| const requestBody = | |
| req.method === "GET" || req.method === "HEAD" ? null : await readRequestBody(req); | |
| const excluded = new Set(); | |
| const maxAttempts = Math.max(1, Number(options.maxSwitchAttempts) + 1); | |
| let lastFailure = null; | |
| let succeeded = false; | |
| for (let attempt = 0; attempt < maxAttempts; attempt += 1) { | |
| const current = | |
| attempt === 0 ? pool.getActiveAccount() || (await pool.getInitialAccount()) : pool.pickNextHealthyAccount(excluded); | |
| if (!current) { | |
| break; | |
| } | |
| excluded.add(current.id); | |
| if (pool.isCoolingDown(current)) { | |
| continue; | |
| } | |
| try { | |
| if (pool.needsRefresh(current)) { | |
| await pool.refreshAccount(current); | |
| } | |
| if ( | |
| options.upstreamBase.includes("chatgpt.com/backend-api/codex") && | |
| requestPath === "/v1/chat/completions" | |
| ) { | |
| res.statusCode = 501; | |
| res.setHeader("content-type", "application/json"); | |
| res.end( | |
| safeJson({ | |
| error: { | |
| message: | |
| "ChatGPT/Codex upstream mode currently supports /v1/models and /v1/responses only.", | |
| }, | |
| }), | |
| ); | |
| return; | |
| } | |
| const upstreamUrl = resolveUpstreamUrl(requestUrl, requestPath, options); | |
| const upstreamHeaders = copyHeadersForUpstream(req.headers, current.accessToken); | |
| const controller = new AbortController(); | |
| const timeout = setTimeout(() => controller.abort(), options.requestTimeoutMs); | |
| let upstream; | |
| try { | |
| upstream = await fetchFn(upstreamUrl, { | |
| method: req.method, | |
| headers: upstreamHeaders, | |
| body: requestBody, | |
| signal: controller.signal, | |
| }); | |
| } finally { | |
| clearTimeout(timeout); | |
| } | |
| if (!upstream.ok) { | |
| const detail = await upstream.text(); | |
| const classified = classifyRetryableFailure(upstream.status, detail); | |
| pool.markFailure(current, classified.category, detail || classified.reason); | |
| lastFailure = { | |
| status: upstream.status, | |
| category: classified.category, | |
| reason: detail || classified.reason, | |
| }; | |
| if (classified.retryable) { | |
| continue; | |
| } | |
| res.statusCode = upstream.status; | |
| res.setHeader("content-type", "application/json"); | |
| res.end( | |
| safeJson({ | |
| error: { | |
| message: detail || "Upstream request failed.", | |
| category: classified.category, | |
| }, | |
| }), | |
| ); | |
| return; | |
| } | |
| pool.markSuccess(current); | |
| succeeded = true; | |
| res.statusCode = upstream.status; | |
| copyHeadersToClient(res, upstream.headers); | |
| if (!upstream.body) { | |
| res.end(); | |
| return; | |
| } | |
| Readable.fromWeb(upstream.body).pipe(res); | |
| return; | |
| } catch (error) { | |
| const detail = error?.message || String(error); | |
| const classified = classifyRetryableFailure(0, detail); | |
| pool.markFailure(current, classified.category, detail); | |
| lastFailure = { | |
| status: 0, | |
| category: classified.category, | |
| reason: detail, | |
| }; | |
| continue; | |
| } | |
| } | |
| if (!succeeded) { | |
| res.statusCode = 503; | |
| res.setHeader("content-type", "application/json"); | |
| res.end( | |
| safeJson({ | |
| error: { | |
| message: "No healthy account available.", | |
| lastFailure, | |
| }, | |
| }), | |
| ); | |
| } | |
| } | |
| return { | |
| pool, | |
| handleRequest, | |
| reload, | |
| getAdminStatus, | |
| }; | |
| } | |
| export async function createProxyServer(options) { | |
| const service = await createProxyService(options); | |
| const server = http.createServer((req, res) => | |
| service.handleRequest(req, res, { | |
| exposeHealthDetails: true, | |
| exposeStatus: true, | |
| }), | |
| ); | |
| return { server, pool: service.pool, service }; | |
| } | |
| async function createFetchWithProxy(proxyUrl) { | |
| if (!proxyUrl) { | |
| return fetch; | |
| } | |
| process.env.NODE_USE_ENV_PROXY = "1"; | |
| process.env.HTTPS_PROXY = proxyUrl; | |
| process.env.HTTP_PROXY = proxyUrl; | |
| process.env.ALL_PROXY = proxyUrl; | |
| return fetch; | |
| } | |
| async function verifyStartupAccounts(pool) { | |
| const reports = []; | |
| for (const account of pool.listAccounts()) { | |
| if (pool.isCoolingDown(account)) { | |
| reports.push({ | |
| id: account.id, | |
| ok: false, | |
| stage: "cooldown", | |
| reason: account.lastFailureReason || "cooldown", | |
| }); | |
| continue; | |
| } | |
| try { | |
| const result = await pool.ensureAccountHealthy(account); | |
| reports.push({ | |
| id: account.id, | |
| ok: Boolean(result?.ok), | |
| stage: "probe", | |
| reason: result?.ok | |
| ? "ok" | |
| : `${result?.category || "unknown"}:${result?.detail || result?.reason || "failed"}`, | |
| }); | |
| } catch (error) { | |
| reports.push({ | |
| id: account.id, | |
| ok: false, | |
| stage: "exception", | |
| reason: error?.message || String(error), | |
| }); | |
| } | |
| } | |
| return reports; | |
| } | |
| async function main() { | |
| if (await maybeRespawnWithProxy(process.argv.slice(2))) { | |
| return; | |
| } | |
| const args = parseArgs(process.argv.slice(2)); | |
| if (args.help === "true") { | |
| printUsage(); | |
| return; | |
| } | |
| const envProxy = | |
| process.env.CODEX_PROXY_UPSTREAM_PROXY || | |
| process.env.HTTPS_PROXY || | |
| process.env.HTTP_PROXY || | |
| ""; | |
| const options = { | |
| host: args.host || process.env.CODEX_PROXY_HOST || DEFAULT_HOST, | |
| port: Number(args.port || process.env.CODEX_PROXY_PORT || DEFAULT_PORT), | |
| tokensDir: path.resolve(args["tokens-dir"] || process.env.CODEX_TOKENS_DIR || DEFAULT_TOKENS_DIR), | |
| upstreamBase: args["upstream-base"] || process.env.CODEX_PROXY_UPSTREAM_BASE || DEFAULT_UPSTREAM_BASE, | |
| refreshEndpoint: | |
| args["refresh-endpoint"] || process.env.CODEX_PROXY_REFRESH_ENDPOINT || DEFAULT_REFRESH_ENDPOINT, | |
| probeUrl: args["probe-url"] || process.env.CODEX_PROXY_PROBE_URL || DEFAULT_PROBE_URL, | |
| localApiKey: args["local-api-key"] || process.env.CODEX_PROXY_API_KEY || DEFAULT_LOCAL_API_KEY, | |
| maxSwitchAttempts: Number( | |
| args["max-switch-attempts"] || process.env.CODEX_PROXY_MAX_SWITCH_ATTEMPTS || DEFAULT_MAX_SWITCH_ATTEMPTS, | |
| ), | |
| requestTimeoutMs: Number( | |
| args["request-timeout-ms"] || process.env.CODEX_PROXY_REQUEST_TIMEOUT_MS || DEFAULT_REQUEST_TIMEOUT_MS, | |
| ), | |
| proxyUrl: args["proxy-url"] || envProxy || "", | |
| clientVersion: | |
| args["client-version"] || process.env.CODEX_PROXY_CLIENT_VERSION || DEFAULT_CLIENT_VERSION, | |
| }; | |
| console.log("[startup] preparing local proxy"); | |
| console.log(`[startup] host=${options.host} port=${options.port}`); | |
| console.log(`[startup] tokensDir=${options.tokensDir}`); | |
| console.log(`[startup] upstreamBase=${options.upstreamBase}`); | |
| console.log(`[startup] upstreamProxy=${options.proxyUrl || "(none)"}`); | |
| let server; | |
| let pool; | |
| try { | |
| ({ server, pool } = await createProxyServer(options)); | |
| } catch (error) { | |
| const fetchFn = await createFetchWithProxy(options.proxyUrl); | |
| const bootstrapPool = new CodexAccountPool({ | |
| tokensDir: options.tokensDir, | |
| refreshEndpoint: options.refreshEndpoint, | |
| probeUrl: options.probeUrl, | |
| fetchFn, | |
| }); | |
| await bootstrapPool.load(); | |
| const reports = await verifyStartupAccounts(bootstrapPool); | |
| console.error(error?.message || error); | |
| console.error("Startup diagnostics:"); | |
| for (const report of reports) { | |
| console.error(`- ${report.id} [${report.stage}] ${report.reason}`); | |
| } | |
| process.exitCode = 1; | |
| return; | |
| } | |
| server.listen(options.port, options.host, () => { | |
| const active = pool.getActiveAccount(); | |
| console.log(`Codex 本地代理已启动:http://${options.host}:${options.port}`); | |
| console.log(`初始活跃账号:${active?.email || active?.id || "(none)"}`); | |
| console.log(`上游代理:${options.proxyUrl || "(none)"}`); | |
| }); | |
| } | |
| const directRun = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname); | |
| if (directRun) { | |
| main().catch((error) => { | |
| console.error(error?.message || error); | |
| process.exitCode = 1; | |
| }); | |
| } | |