#!/usr/bin/env node import http from "node:http"; import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import fs from "node:fs"; import path from "node:path"; import { TextDecoder } from "node:util"; import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const ADMIN_BASE_PATH = "/__codex_retry_gateway"; const UI_PATH = `${ADMIN_BASE_PATH}/ui`; const STATUS_API_PATH = `${ADMIN_BASE_PATH}/api/status`; const CONFIG_API_PATH = `${ADMIN_BASE_PATH}/api/config`; const LOGS_API_PATH = `${ADMIN_BASE_PATH}/api/logs`; const RESTORE_API_PATH = `${ADMIN_BASE_PATH}/api/restore`; const DEFAULT_CONFIG = { listen_host: "127.0.0.1", listen_port: 4610, upstream_base_url: "", request_body_limit_bytes: 10 * 1024 * 1024, endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], reasoning_equals: [516], non_stream_status_code: 502, stream_action: "strict_502", log_match: true, health_path: "/__codex_retry_gateway/health", }; const REASONING_POINTERS = [ "/usage/output_tokens_details/reasoning_tokens", "/usage/completion_tokens_details/reasoning_tokens", "/response/usage/output_tokens_details/reasoning_tokens", "/response/usage/completion_tokens_details/reasoning_tokens", ]; function parseArgs(argv) { const args = { config: null, log: null }; for (let i = 2; i < argv.length; i += 1) { const current = argv[i]; if (current === "--config") { args.config = argv[i + 1]; i += 1; } else if (current === "--log") { args.log = argv[i + 1]; i += 1; } else if (current === "--help" || current === "-h") { printHelp(); process.exit(0); } } return args; } function printHelp() { process.stdout.write( [ "用法:", " node gateway.mjs --config [--log ]", "", "说明:", " 独立 Codex 本地重试网关。", " 非流式命中 reasoning_tokens=516 时返回 502。", " 流式命中时默认缓存并返回 502,避免半截流返回。", "", ].join("\n"), ); } function normalizePath(inputPath) { const [withoutQuery] = `${inputPath || "/"}`.split("?"); const trimmed = withoutQuery.length > 1 ? withoutQuery.replace(/\/+$/, "") : withoutQuery; return trimmed || "/"; } function flattenValues(value) { if (Array.isArray(value)) { return value.flatMap((item) => flattenValues(item)); } return [value]; } function isJsonContentType(contentType) { return `${contentType || ""}`.toLowerCase().includes("application/json"); } function isSseContentType(contentType) { return `${contentType || ""}`.toLowerCase().includes("text/event-stream"); } function jsonPointerGet(value, pointer) { if (!pointer.startsWith("/")) { return undefined; } return pointer .slice(1) .split("/") .map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~")) .reduce((current, segment) => { if (current === null || current === undefined) { return undefined; } return current[segment]; }, value); } function extractReasoningTokens(payload) { for (const pointer of REASONING_POINTERS) { const raw = jsonPointerGet(payload, pointer); if (Number.isInteger(raw)) { return raw; } } return null; } function normalizeIntegerList(values, fallback = []) { const source = values === undefined || values === null ? fallback : values; const normalized = flattenValues(source) .flatMap((value) => { if (typeof value === "string") { return value.split(/[\s,]+/).filter(Boolean); } return [value]; }) .map((value) => Number.parseInt(`${value}`, 10)) .filter((value) => Number.isInteger(value)); return [...new Set(normalized)]; } function normalizeStringList(values, fallback = []) { const source = values === undefined || values === null ? fallback : values; const normalized = flattenValues(source) .flatMap((value) => `${value ?? ""}`.split(/[\s,]+/)) .map((value) => value.trim()) .filter(Boolean); return [...new Set(normalized)]; } function buildBlockedBody(pathname, reasoning, statusCode) { return JSON.stringify({ error: { message: `codex retry gateway blocked suspicious reasoning response on ${pathname}`, type: "codex_retry_gateway", code: "reasoning_guard_triggered", reasoning_tokens: reasoning, status_code: statusCode, }, }); } function buildGatewayErrorBody(message) { return JSON.stringify({ error: { message, type: "codex_retry_gateway_error", code: "gateway_error", }, }); } function createMonitor() { return { started_at: new Date().toISOString(), next_log_seq: 1, log_entries: [], total_proxy_request_count: 0, inspected_response_count: 0, matched_response_count: 0, observed_reasoning_counts: {}, }; } function createMonitorRecorder(monitor) { return (message) => { const entry = { seq: monitor.next_log_seq, at: new Date().toISOString(), message, }; monitor.next_log_seq += 1; monitor.log_entries.push(entry); return entry; }; } function createLogger(logPath, recordEntry) { if (!logPath) { return (message) => { const entry = recordEntry ? recordEntry(message) : { at: new Date().toISOString(), message }; process.stdout.write(`${entry.at} ${entry.message}\n`); }; } const stream = fs.createWriteStream(logPath, { flags: "a" }); return (message) => { const entry = recordEntry ? recordEntry(message) : { at: new Date().toISOString(), message }; const line = `${entry.at} ${entry.message}\n`; stream.write(line); process.stdout.write(line); }; } function incrementReasoningCount(counter, reasoning) { if (!Number.isInteger(reasoning)) { return; } const key = `${reasoning}`; counter[key] = (counter[key] || 0) + 1; } function recordInspectedResponse(monitor, reasoning, matched) { monitor.inspected_response_count += 1; incrementReasoningCount(monitor.observed_reasoning_counts, reasoning); if (matched) { monitor.matched_response_count += 1; } } function buildMetricsSnapshot(monitor) { const reasoning516Count = monitor.observed_reasoning_counts["516"] || 0; const inspectedResponseCount = monitor.inspected_response_count; return { started_at: monitor.started_at, total_proxy_request_count: monitor.total_proxy_request_count, inspected_response_count: inspectedResponseCount, matched_response_count: monitor.matched_response_count, reasoning_516_count: reasoning516Count, reasoning_516_ratio: inspectedResponseCount === 0 ? 0 : reasoning516Count / inspectedResponseCount, observed_reasoning_counts: { ...monitor.observed_reasoning_counts }, }; } function buildLogsSnapshot(monitor, sinceSeq = null) { const entries = Number.isInteger(sinceSeq) ? monitor.log_entries.filter((entry) => entry.seq > sinceSeq) : monitor.log_entries; return { total_entries: monitor.log_entries.length, latest_seq: monitor.next_log_seq - 1, entries, }; } async function loadConfig(configPath) { const content = await readFile(configPath, "utf8"); const loaded = JSON.parse(content); const config = { ...DEFAULT_CONFIG, ...loaded }; config.endpoints = normalizeStringList(config.endpoints, DEFAULT_CONFIG.endpoints).map(normalizePath); config.reasoning_equals = normalizeIntegerList( config.reasoning_equals, DEFAULT_CONFIG.reasoning_equals, ); if (!config.upstream_base_url) { throw new Error("配置缺少 upstream_base_url"); } return config; } function buildRuntimePaths(configPath, logPath) { const configDirectory = path.dirname(configPath); const stateRoot = path.dirname(configDirectory); return { stateRoot, statePath: path.join(stateRoot, "state.json"), pidPath: path.join(stateRoot, "gateway.pid"), configPath, logPath, }; } async function readOptionalJson(jsonPath) { try { const content = await readFile(jsonPath, "utf8"); return JSON.parse(content); } catch { return null; } } async function writeConfig(configPath, config) { await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); } function extractProviderBaseUrl(content, providerName) { if (!content || !providerName) { return null; } const sectionPattern = new RegExp( String.raw`^\[model_providers\.${providerName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\]\s*$[\s\S]*?(?=^\[|\Z)`, "m", ); const sectionMatch = content.match(sectionPattern); if (!sectionMatch) { return null; } const baseUrlMatch = sectionMatch[0].match(/^\s*base_url\s*=\s*"([^"]+)"\s*$/m); return baseUrlMatch ? baseUrlMatch[1] : null; } async function readRuntimeState(runtime) { const state = await readOptionalJson(runtime.paths.statePath); if (!state) { return null; } let codexCurrentBaseUrl = null; if (state.codex_config_path && state.provider_name) { try { const codexConfig = await readFile(state.codex_config_path, "utf8"); codexCurrentBaseUrl = extractProviderBaseUrl(codexConfig, state.provider_name); } catch { codexCurrentBaseUrl = null; } } return { ...state, codex_current_base_url: codexCurrentBaseUrl, }; } async function restoreRuntimeState(runtime, state) { const backupPath = state?.latest_backup_path; const codexConfigPath = state?.codex_config_path; if (!backupPath || !fs.existsSync(backupPath)) { throw new Error(`未找到可恢复备份: ${backupPath || "unknown"}`); } if (!codexConfigPath) { throw new Error("安装状态里缺少 codex_config_path"); } await copyFile(backupPath, codexConfigPath); await Promise.all([ rm(runtime.paths.statePath, { force: true }), rm(runtime.paths.pidPath, { force: true }), ]); } function jsonResponse(res, statusCode, payload, headers = {}) { res.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", ...headers, }); res.end(JSON.stringify(payload)); } function htmlResponse(res, html) { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(html); } function buildEditableConfig(currentConfig, payload) { const nextReasoning = normalizeIntegerList(payload.reasoning_equals, currentConfig.reasoning_equals); const nextEndpoints = normalizeStringList(payload.endpoints, currentConfig.endpoints).map(normalizePath); const nextStatusCode = payload.non_stream_status_code === undefined ? currentConfig.non_stream_status_code : Number.parseInt(`${payload.non_stream_status_code}`, 10); if (nextReasoning.length === 0) { throw new Error("reasoning_equals 不能为空"); } if (nextEndpoints.length === 0) { throw new Error("endpoints 不能为空"); } if (!Number.isInteger(nextStatusCode) || nextStatusCode < 100 || nextStatusCode > 599) { throw new Error("non_stream_status_code 必须是 100-599 的整数"); } return { ...currentConfig, reasoning_equals: nextReasoning, endpoints: nextEndpoints, non_stream_status_code: nextStatusCode, log_match: payload.log_match === undefined ? currentConfig.log_match : Boolean(payload.log_match), }; } function buildManagementHtml() { const uiConfig = { statusPath: STATUS_API_PATH, configPath: CONFIG_API_PATH, logsPath: LOGS_API_PATH, restorePath: RESTORE_API_PATH, }; return ` Codex Retry Gateway
本地管理页

Codex Retry Gateway

这个页面直接挂在正在运行的 gateway 上。你可以在这里查看当前接管状态、修改 516 拦截条件,并一键恢复 Codex 原设置。

运行状态

-
-
-
-
-
-
-
0
0
0
0.00%
0

如果“当前 Codex Base URL”已经是本机监听地址,就说明当前 Codex 已经被这个 gateway 接管。统计口径按本次 gateway 启动以来累计。

拦截规则

多个值用英文逗号或空格分隔。
每行一个路径。默认建议同时保留 root 与 /v1 两套路径。

点击“恢复”后,gateway 会停掉,所以这个页面会失联。这是预期行为,不是报错。

实时日志

正在读取日志...

正在读取日志...
`; } async function handleManagementRequest(runtime, req, res, requestUrl) { const pathname = normalizePath(requestUrl.pathname); if (pathname === UI_PATH) { htmlResponse(res, buildManagementHtml()); return true; } if (pathname === STATUS_API_PATH && req.method === "GET") { const state = await readRuntimeState(runtime); jsonResponse(res, 200, { ok: true, listen: `${runtime.config.listen_host}:${runtime.config.listen_port}`, config: runtime.config, state, paths: { config_path: runtime.configPath, state_path: runtime.paths.statePath, state_root: runtime.paths.stateRoot, log_path: runtime.logPath, }, metrics: buildMetricsSnapshot(runtime.monitor), }); return true; } if (pathname === LOGS_API_PATH && req.method === "GET") { const sinceSeqRaw = requestUrl.searchParams.get("since_seq"); const sinceSeq = sinceSeqRaw === null ? null : Number.parseInt(sinceSeqRaw, 10); jsonResponse(res, 200, { ok: true, ...buildLogsSnapshot(runtime.monitor, Number.isInteger(sinceSeq) ? sinceSeq : null), }); return true; } if (pathname === CONFIG_API_PATH && req.method === "POST") { const body = await readRequestBody(req, runtime.config.request_body_limit_bytes); const payload = parseJsonSafely(body); if (!payload) { jsonResponse(res, 400, { error: { message: "配置保存请求必须是有效 JSON", code: "invalid_json", }, }); return true; } const nextConfig = buildEditableConfig(runtime.config, payload); await writeConfig(runtime.configPath, nextConfig); runtime.config = nextConfig; runtime.logger( `[config] updated reasoning_equals=${nextConfig.reasoning_equals.join(",")} endpoints=${nextConfig.endpoints.join(",")}`, ); const state = await readRuntimeState(runtime); jsonResponse(res, 200, { ok: true, message: "配置已保存并立即生效", config: runtime.config, state, paths: { config_path: runtime.configPath, state_path: runtime.paths.statePath, state_root: runtime.paths.stateRoot, log_path: runtime.logPath, }, metrics: buildMetricsSnapshot(runtime.monitor), }); return true; } if (pathname === RESTORE_API_PATH && req.method === "POST") { const state = await readRuntimeState(runtime); if (!state) { jsonResponse(res, 409, { error: { message: "当前未检测到安装状态,无法恢复 Codex 原设置", code: "state_not_found", }, }); return true; } await restoreRuntimeState(runtime, state); runtime.logger(`[restore] restored via UI state_root=${runtime.paths.stateRoot}`); jsonResponse(res, 202, { ok: true, message: "原设置已恢复,gateway 即将关闭", }); res.on("finish", () => { const exitTimer = setTimeout(() => { if (runtime.server) { runtime.server.close(() => { process.exit(0); }); } else { process.exit(0); } const hardExitTimer = setTimeout(() => { process.exit(0); }, 600); hardExitTimer.unref(); }, 120); exitTimer.unref(); }); return true; } return false; } function buildUpstreamUrl(baseUrl, requestUrl) { const upstream = new URL(baseUrl); const normalizedBasePath = upstream.pathname.endsWith("/") ? upstream.pathname.slice(0, -1) : upstream.pathname; const incomingPath = requestUrl.pathname; let finalPath = incomingPath; if (normalizedBasePath && normalizedBasePath !== "/") { if (incomingPath.startsWith(`${normalizedBasePath}/`) || incomingPath === normalizedBasePath) { finalPath = incomingPath; } else if (normalizedBasePath.endsWith("/v1") && incomingPath.startsWith("/v1/")) { finalPath = `${normalizedBasePath}${incomingPath.slice(3)}`; } else { finalPath = `${normalizedBasePath}${incomingPath}`; } } upstream.pathname = finalPath; upstream.search = requestUrl.search; return upstream.toString(); } function cloneHeadersForUpstream(headers) { const outgoing = new Headers(); for (const [key, value] of Object.entries(headers)) { if (value === undefined) { continue; } const lowerKey = key.toLowerCase(); if ( lowerKey === "host" || lowerKey === "content-length" || lowerKey === "connection" || lowerKey === "transfer-encoding" ) { continue; } if (Array.isArray(value)) { for (const item of value) { outgoing.append(key, item); } } else { outgoing.set(key, value); } } return outgoing; } function copyHeadersToClient(sourceHeaders, target) { for (const [key, value] of sourceHeaders.entries()) { const lowerKey = key.toLowerCase(); if ( lowerKey === "content-length" || lowerKey === "transfer-encoding" || lowerKey === "content-encoding" || lowerKey === "connection" ) { continue; } target.setHeader(key, value); } } async function readRequestBody(req, limitBytes) { const chunks = []; let total = 0; for await (const chunk of req) { total += chunk.length; if (total > limitBytes) { throw new Error(`请求体超过限制: ${limitBytes} bytes`); } chunks.push(chunk); } return Buffer.concat(chunks); } function parseJsonSafely(buffer) { try { return JSON.parse(buffer.toString("utf8")); } catch { return null; } } function matchPath(config, pathname) { return config.endpoints.includes(normalizePath(pathname)); } function reasoningMatched(config, reasoning) { return reasoning !== null && config.reasoning_equals.includes(reasoning); } function isExpectedStreamTermination(error) { if (!error) { return false; } if (error.name === "AbortError") { return true; } return error instanceof TypeError && error.message === "terminated"; } function isRetryableUpstreamFetchError(error) { if (!error) { return false; } return error instanceof TypeError && error.message === "fetch failed"; } async function fetchUpstreamWithRetry(upstreamUrl, init, logger) { const maxAttempts = 2; let lastError = null; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { return await fetch(upstreamUrl, init); } catch (error) { lastError = error; if (!isRetryableUpstreamFetchError(error) || attempt === maxAttempts) { break; } logger?.(`[retry] upstream fetch failed attempt=${attempt} url=${upstreamUrl}`); } } throw lastError; } function inspectSseChunk(state, chunk) { const decoded = state.decoder.decode(chunk, { stream: true }); state.buffer += decoded; const blocks = state.buffer.split(/\r?\n\r?\n/); state.buffer = blocks.pop() ?? ""; for (const block of blocks) { const lines = block .split(/\r?\n/) .map((line) => line.trimEnd()) .filter(Boolean); const dataLines = lines .filter((line) => line.startsWith("data:")) .map((line) => line.replace(/^data:\s?/, "")); if (dataLines.length === 0) { continue; } const payloadText = dataLines.join("\n"); if (payloadText === "[DONE]") { continue; } try { const parsed = JSON.parse(payloadText); const reasoning = extractReasoningTokens(parsed); if (reasoning !== null) { return reasoning; } } catch { // ignore malformed SSE payloads } } return null; } async function handleNonStreaming({ config, logger, monitor, pathname, upstreamResponse, res, }) { const bodyBuffer = Buffer.from(await upstreamResponse.arrayBuffer()); const parsed = isJsonContentType(upstreamResponse.headers.get("content-type")) ? parseJsonSafely(bodyBuffer) : null; const reasoning = parsed ? extractReasoningTokens(parsed) : null; const matched = reasoningMatched(config, reasoning); recordInspectedResponse(monitor, reasoning, matched); if (matched) { if (config.log_match) { logger( `[match] non-stream path=${pathname} reasoning_tokens=${reasoning} action=status_${config.non_stream_status_code}`, ); } const blockedBody = buildBlockedBody(pathname, reasoning, config.non_stream_status_code); res.writeHead(config.non_stream_status_code, { "content-type": "application/json; charset=utf-8", "x-codex-retry-gateway-reason": "reasoning-guard-triggered", }); res.end(blockedBody); return; } copyHeadersToClient(upstreamResponse.headers, res); res.writeHead(upstreamResponse.status); res.end(bodyBuffer); } async function handleStreaming({ config, logger, monitor, pathname, upstreamResponse, res, abortController, }) { const strict502Mode = config.stream_action !== "disconnect"; const reader = upstreamResponse.body.getReader(); const sseState = { decoder: new TextDecoder("utf8"), buffer: "", }; let wroteAnyChunk = false; let observedReasoning = null; const bufferedChunks = []; if (!strict502Mode) { copyHeadersToClient(upstreamResponse.headers, res); res.writeHead(upstreamResponse.status); } while (true) { let readResult; try { readResult = await reader.read(); } catch (error) { if (isExpectedStreamTermination(error)) { recordInspectedResponse(monitor, observedReasoning, false); if (strict502Mode) { logger?.(`[stream] upstream terminated before completion path=${pathname} action=status_502`); res.writeHead(502, { "content-type": "application/json; charset=utf-8" }); res.end(buildGatewayErrorBody("upstream stream terminated before completion")); } else { res.end(); } return; } throw error; } const { done, value } = readResult; if (done) { recordInspectedResponse(monitor, observedReasoning, false); if (strict502Mode) { copyHeadersToClient(upstreamResponse.headers, res); res.writeHead(upstreamResponse.status); res.end(Buffer.concat(bufferedChunks)); } else { res.end(); } return; } const chunkBuffer = Buffer.from(value); const reasoning = inspectSseChunk(sseState, value); if (Number.isInteger(reasoning)) { observedReasoning = reasoning; } if (reasoningMatched(config, reasoning)) { recordInspectedResponse(monitor, reasoning, true); if (config.log_match) { logger( `[match] stream path=${pathname} reasoning_tokens=${reasoning} action=${config.stream_action}`, ); } if (strict502Mode || !wroteAnyChunk) { abortController.abort(); reader.cancel().catch(() => {}); const blockedBody = buildBlockedBody(pathname, reasoning, config.non_stream_status_code); res.writeHead(config.non_stream_status_code, { "content-type": "application/json; charset=utf-8", "x-codex-retry-gateway-reason": "reasoning-guard-triggered", }); res.end(blockedBody); } else { abortController.abort(); reader.cancel().catch(() => {}); res.socket?.destroy(); } return; } if (strict502Mode) { bufferedChunks.push(chunkBuffer); } else { wroteAnyChunk = true; res.write(chunkBuffer); } } } async function proxyRequest(runtime, req, res) { const { logger } = runtime; const config = runtime.config; const incomingUrl = new URL(req.url, `http://${req.headers.host || "127.0.0.1"}`); const pathname = normalizePath(incomingUrl.pathname); if (pathname === config.health_path) { res.writeHead(200, { "content-type": "application/json; charset=utf-8" }); res.end( JSON.stringify({ ok: true, listen: `${config.listen_host}:${config.listen_port}`, upstream_base_url: config.upstream_base_url, ui_path: UI_PATH, }), ); return; } if (await handleManagementRequest(runtime, req, res, incomingUrl)) { return; } runtime.monitor.total_proxy_request_count += 1; const requestBody = await readRequestBody(req, config.request_body_limit_bytes); const requestJson = isJsonContentType(req.headers["content-type"]) ? parseJsonSafely(requestBody) : null; const requestIsStream = Boolean(requestJson?.stream); const upstreamUrl = buildUpstreamUrl(config.upstream_base_url, incomingUrl); const abortController = new AbortController(); const upstreamResponse = await fetchUpstreamWithRetry(upstreamUrl, { method: req.method, headers: cloneHeadersForUpstream(req.headers), body: requestBody.length > 0 ? requestBody : undefined, signal: abortController.signal, }, logger); const shouldInspect = matchPath(config, pathname); const responseIsStream = requestIsStream || isSseContentType(upstreamResponse.headers.get("content-type")); if (!shouldInspect) { copyHeadersToClient(upstreamResponse.headers, res); res.writeHead(upstreamResponse.status); const body = Buffer.from(await upstreamResponse.arrayBuffer()); res.end(body); return; } if (responseIsStream) { await handleStreaming({ config, logger, monitor: runtime.monitor, pathname, upstreamResponse, res, abortController, }); return; } await handleNonStreaming({ config, logger, monitor: runtime.monitor, pathname, upstreamResponse, res, }); } async function main() { const args = parseArgs(process.argv); const configPath = args.config || path.join(__dirname, "config.json"); const config = await loadConfig(configPath); const monitor = createMonitor(); if (args.log) { await mkdir(path.dirname(args.log), { recursive: true }); } const logger = createLogger(args.log, createMonitorRecorder(monitor)); const runtime = { config, configPath, logPath: args.log || null, logger, monitor, paths: buildRuntimePaths(configPath, args.log || null), server: null, }; const server = http.createServer(async (req, res) => { try { await proxyRequest(runtime, req, res); } catch (error) { logger(`[error] ${error?.stack || error}`); if (!res.headersSent) { res.writeHead(502, { "content-type": "application/json; charset=utf-8" }); res.end( JSON.stringify({ error: { message: `${error?.message || error}`, type: "codex_retry_gateway_error", code: "gateway_error", }, }), ); } else { res.socket?.destroy(); } } }); runtime.server = server; server.listen(config.listen_port, config.listen_host, () => { logger( `[start] codex retry gateway listening on http://${config.listen_host}:${config.listen_port} -> ${config.upstream_base_url}`, ); }); } main().catch((error) => { process.stderr.write(`${error?.stack || error}\n`); process.exit(1); });