#!/usr/bin/env node import http from "node:http"; import { spawn } from "node:child_process"; import { chmod, copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; import fs from "node:fs"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; 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 UI_STATIC_ROOT = path.join(__dirname, "public", "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 REQUESTS_API_PATH = `${ADMIN_BASE_PATH}/api/requests`; const PROFILES_API_PATH = `${ADMIN_BASE_PATH}/api/profiles`; const PROFILE_PROBE_API_PATH = `${ADMIN_BASE_PATH}/api/profiles/probe`; const PROFILE_SWITCH_API_PATH = `${ADMIN_BASE_PATH}/api/profiles/switch`; const PROFILE_ITEM_API_PREFIX = `${ADMIN_BASE_PATH}/api/profiles/`; const RESTORE_API_PATH = `${ADMIN_BASE_PATH}/api/restore`; const DEFAULT_CONFIG = { profile_name: "default", listen_host: "127.0.0.1", listen_port: 4610, upstream_base_url: "", upstream_auth_mode: "passthrough", upstream_auth_env: "CODEX_RETRY_GATEWAY_UPSTREAM_API_KEY", upstream_auth_file: "", upstream_auth_json_path: "", upstream_auth_json_key: "OPENAI_API_KEY", request_body_limit_bytes: 10 * 1024 * 1024, request_history_limit: 0, model_remap: "", endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], reasoning_equals: [516], retryable_status_codes: [429, 503], retryable_error_messages: [ "Selected model is at capacity. Please try a different model.", ], upstream_fetch_retry_attempts: 5, upstream_fetch_retry_backoff_ms: 350, 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 firstInteger(...values) { for (const value of values) { if (Number.isInteger(value)) { return value; } } return null; } function normalizeUsageSnapshot(payload) { const usage = payload?.usage || payload?.response?.usage || null; if (!usage || typeof usage !== "object") { return null; } const inputTokens = firstInteger(usage.input_tokens, usage.prompt_tokens); const outputTokens = firstInteger(usage.output_tokens, usage.completion_tokens); const totalTokens = firstInteger( usage.total_tokens, inputTokens !== null && outputTokens !== null ? inputTokens + outputTokens : null, ); const reasoningTokens = firstInteger( usage.output_tokens_details?.reasoning_tokens, usage.completion_tokens_details?.reasoning_tokens, payload?.response?.usage?.output_tokens_details?.reasoning_tokens, payload?.response?.usage?.completion_tokens_details?.reasoning_tokens, ); const cachedTokens = firstInteger( usage.input_tokens_details?.cached_tokens, usage.prompt_tokens_details?.cached_tokens, payload?.response?.usage?.input_tokens_details?.cached_tokens, payload?.response?.usage?.prompt_tokens_details?.cached_tokens, ); if ( inputTokens === null && outputTokens === null && totalTokens === null && reasoningTokens === null && cachedTokens === null ) { return null; } return { input_tokens: inputTokens, output_tokens: outputTokens, total_tokens: totalTokens, reasoning_tokens: reasoningTokens, cached_tokens: cachedTokens, }; } function mergeUsageSnapshots(current, next) { if (!next) { return current || null; } return { input_tokens: next.input_tokens ?? current?.input_tokens ?? null, output_tokens: next.output_tokens ?? current?.output_tokens ?? null, total_tokens: next.total_tokens ?? current?.total_tokens ?? null, reasoning_tokens: next.reasoning_tokens ?? current?.reasoning_tokens ?? null, cached_tokens: next.cached_tokens ?? current?.cached_tokens ?? 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 normalizePhraseList(values, fallback = []) { const source = values === undefined || values === null ? fallback : values; const normalized = flattenValues(source) .flatMap((value) => { if (typeof value === "string") { return value.split(/\r?\n/); } return [value]; }) .map((value) => `${value ?? ""}`.trim()) .filter(Boolean); return [...new Set(normalized)]; } function normalizePositiveInteger(value, fallback) { const parsed = Number.parseInt(`${value ?? ""}`, 10); if (Number.isInteger(parsed) && parsed > 0) { return parsed; } return fallback; } function normalizeNonNegativeInteger(value, fallback) { const parsed = Number.parseInt(`${value ?? ""}`, 10); if (Number.isInteger(parsed) && parsed >= 0) { return parsed; } return fallback; } function sleep(ms) { if (!Number.isFinite(ms) || ms <= 0) { return Promise.resolve(); } return new Promise((resolve) => setTimeout(resolve, ms)); } function parseModelRemapMap(value) { const map = {}; for (const rawEntry of `${value || ""}`.split(/\r?\n|[;,]/)) { const entry = rawEntry.trim(); if (!entry) { continue; } const separatorIndex = entry.indexOf("="); if (separatorIndex <= 0) { continue; } const from = entry.slice(0, separatorIndex).trim(); const to = entry.slice(separatorIndex + 1).trim(); if (!from || !to) { continue; } map[from] = to; } return map; } function canHotSwapProfile(currentConfig, nextConfig) { return ( `${currentConfig?.listen_host || ""}` === `${nextConfig?.listen_host || ""}` && Number.parseInt(`${currentConfig?.listen_port || ""}`, 10) === Number.parseInt(`${nextConfig?.listen_port || ""}`, 10) ); } function parseEnvText(content) { const values = {}; for (const rawLine of `${content || ""}`.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith("#")) { continue; } const separatorIndex = line.indexOf("="); if (separatorIndex <= 0) { continue; } const key = line.slice(0, separatorIndex).trim(); let value = line.slice(separatorIndex + 1).trim(); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } values[key] = value; } return values; } function isSecretLikeKey(key) { return /TOKEN|SECRET|PASSWORD|API_KEY|AUTH|BEARER|KEY/i.test(`${key || ""}`); } function redactProfileValue(key, value) { if (!value) { return ""; } if (isSecretLikeKey(key) || /^sk-[A-Za-z0-9_-]+/.test(value)) { return "[configured]"; } return value; } function getProfileNameFromFile(fileName) { if (!fileName.endsWith(".env")) { return null; } const profileName = fileName.slice(0, -4); return /^[A-Za-z0-9_.-]+$/.test(profileName) ? profileName : null; } function validateProfileName(profileName) { if (!/^[A-Za-z0-9_.-]+$/.test(profileName)) { throw new Error("profile 名称只能包含字母、数字、下划线、点和短横线"); } } 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 buildRetryableUpstreamErrorBody(pathname, upstreamStatusCode, upstreamMessage, statusCode) { return JSON.stringify({ error: { message: `codex retry gateway converted retryable upstream error on ${pathname}`, type: "codex_retry_gateway", code: "upstream_error_retry_triggered", upstream_status_code: upstreamStatusCode, upstream_error_message: upstreamMessage, 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(), persistent_since: null, next_log_seq: 1, next_request_seq: 1, log_entries: [], request_entries: [], total_proxy_request_count: 0, inspected_response_count: 0, matched_response_count: 0, token_totals: { input_tokens: 0, output_tokens: 0, total_tokens: 0, reasoning_tokens: 0, cached_tokens: 0, }, observed_reasoning_counts: {}, }; } function buildLogEntry(seq, at, message) { return { seq, at, message, }; } function parseLogLine(line, seq) { const match = `${line || ""}`.match(/^(\S+)\s([\s\S]*)$/); if (!match) { return buildLogEntry(seq, null, line); } return buildLogEntry(seq, match[1], match[2]); } function parseJsonLine(line) { if (!line.trim()) { return null; } try { return JSON.parse(line); } catch { return null; } } async function readJsonlFile(filePath) { const text = await readOptionalText(filePath); if (!text) { return []; } return text .split(/\r?\n/) .map(parseJsonLine) .filter(Boolean); } async function appendJsonl(filePath, value) { await mkdir(path.dirname(filePath), { recursive: true }); await fs.promises.appendFile(filePath, `${JSON.stringify(value)}\n`, "utf8"); } function openRequestsDatabase(dbPath) { fs.mkdirSync(path.dirname(dbPath), { recursive: true }); const db = new DatabaseSync(dbPath); db.exec(` PRAGMA journal_mode = WAL; CREATE TABLE IF NOT EXISTS requests ( seq INTEGER PRIMARY KEY, started_at TEXT, finished_at TEXT, duration_ms INTEGER, profile_name TEXT, method TEXT, path TEXT, model TEXT, requested_model TEXT, forwarded_model TEXT, request_stream INTEGER, response_stream INTEGER, inspected INTEGER, matched INTEGER, status_code INTEGER, upstream_status_code INTEGER, reasoning_tokens INTEGER, input_tokens INTEGER, output_tokens INTEGER, total_tokens INTEGER, cached_tokens INTEGER, error TEXT, upstream_origin TEXT, upstream_path TEXT, upstream_auth_mode TEXT, upstream_auth_source TEXT, payload_json TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_requests_started_at ON requests(started_at DESC); CREATE INDEX IF NOT EXISTS idx_requests_profile_name ON requests(profile_name); CREATE INDEX IF NOT EXISTS idx_requests_status_code ON requests(status_code); CREATE INDEX IF NOT EXISTS idx_requests_matched ON requests(matched); CREATE INDEX IF NOT EXISTS idx_requests_response_stream ON requests(response_stream); `); return db; } function boolToInt(value) { return value ? 1 : 0; } function usageField(entry, key) { const value = entry?.usage?.[key]; return Number.isInteger(value) ? value : null; } function buildPersistedRequestPayload(entry) { return Object.fromEntries( Object.entries(entry || {}).filter(([key]) => !key.startsWith("_")), ); } function requestRowFromEntry(entry) { const payload = buildPersistedRequestPayload(entry); return { seq: entry.seq, started_at: entry.started_at || null, finished_at: entry.finished_at || null, duration_ms: Number.isInteger(entry.duration_ms) ? entry.duration_ms : null, profile_name: entry.profile_name || null, method: entry.method || null, path: entry.path || null, model: entry.model || null, requested_model: entry.requested_model || null, forwarded_model: entry.forwarded_model || null, request_stream: boolToInt(Boolean(entry.request_stream)), response_stream: boolToInt(Boolean(entry.response_stream)), inspected: boolToInt(Boolean(entry.inspected)), matched: boolToInt(Boolean(entry.matched)), status_code: Number.isInteger(entry.status_code) ? entry.status_code : null, upstream_status_code: Number.isInteger(entry.upstream_status_code) ? entry.upstream_status_code : null, reasoning_tokens: Number.isInteger(entry.reasoning_tokens) ? entry.reasoning_tokens : usageField(entry, "reasoning_tokens"), input_tokens: usageField(entry, "input_tokens"), output_tokens: usageField(entry, "output_tokens"), total_tokens: usageField(entry, "total_tokens"), cached_tokens: usageField(entry, "cached_tokens"), error: entry.error || null, upstream_origin: entry.upstream?.origin || null, upstream_path: entry.upstream?.path || null, upstream_auth_mode: entry.upstream?.auth_mode || null, upstream_auth_source: entry.upstream?.auth_source || null, payload_json: JSON.stringify(payload), }; } function insertRequestRow(db, row) { db.prepare(` INSERT OR REPLACE INTO requests ( seq, started_at, finished_at, duration_ms, profile_name, method, path, model, requested_model, forwarded_model, request_stream, response_stream, inspected, matched, status_code, upstream_status_code, reasoning_tokens, input_tokens, output_tokens, total_tokens, cached_tokens, error, upstream_origin, upstream_path, upstream_auth_mode, upstream_auth_source, payload_json ) VALUES ( @seq, @started_at, @finished_at, @duration_ms, @profile_name, @method, @path, @model, @requested_model, @forwarded_model, @request_stream, @response_stream, @inspected, @matched, @status_code, @upstream_status_code, @reasoning_tokens, @input_tokens, @output_tokens, @total_tokens, @cached_tokens, @error, @upstream_origin, @upstream_path, @upstream_auth_mode, @upstream_auth_source, @payload_json ) `).run(row); } async function importRequestsJsonlToDb(db, filePath) { const rows = await readJsonlFile(filePath); if (rows.length === 0) { return 0; } const countRow = db.prepare("SELECT COUNT(*) AS count FROM requests").get(); if ((countRow?.count || 0) >= rows.length) { return 0; } db.exec("BEGIN"); try { for (const entry of rows) { if (!Number.isInteger(entry?.seq)) { continue; } insertRequestRow(db, requestRowFromEntry(entry)); } db.exec("COMMIT"); } catch (error) { try { db.exec("ROLLBACK"); } catch { // Ignore rollback failures so the original insert error is preserved. } throw error; } return rows.length; } function parseRequestRowPayload(row) { if (!row?.payload_json) { return null; } try { return JSON.parse(row.payload_json); } catch { return null; } } function buildRequestQueryFilters({ query, filter }) { const clauses = []; const params = {}; const trimmedQuery = `${query || ""}`.trim().toLowerCase(); if (trimmedQuery) { clauses.push(` ( lower(coalesce(profile_name, '')) LIKE @query OR lower(coalesce(method, '')) LIKE @query OR lower(coalesce(path, '')) LIKE @query OR lower(coalesce(model, '')) LIKE @query OR lower(coalesce(requested_model, '')) LIKE @query OR lower(coalesce(forwarded_model, '')) LIKE @query OR lower(coalesce(error, '')) LIKE @query OR lower(coalesce(upstream_origin, '')) LIKE @query OR lower(coalesce(upstream_path, '')) LIKE @query OR CAST(coalesce(status_code, '') AS TEXT) LIKE @query OR CAST(coalesce(upstream_status_code, '') AS TEXT) LIKE @query OR CAST(coalesce(input_tokens, '') AS TEXT) LIKE @query OR CAST(coalesce(output_tokens, '') AS TEXT) LIKE @query OR CAST(coalesce(total_tokens, '') AS TEXT) LIKE @query OR CAST(coalesce(cached_tokens, '') AS TEXT) LIKE @query OR CAST(coalesce(reasoning_tokens, '') AS TEXT) LIKE @query ) `); params.query = `%${trimmedQuery}%`; } if (filter === "matched") { clauses.push("matched = 1"); } else if (filter === "stream") { clauses.push("response_stream = 1"); } else if (filter === "error") { clauses.push("(error IS NOT NULL AND error != '')"); } return { whereSql: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "", params, }; } async function hydrateMonitorFromDisk(monitor, paths, requestHistoryLimit, requestsDb = null) { monitor.request_entries = []; if (requestsDb) { const totalsRow = requestsDb.prepare(` SELECT COUNT(*) AS total_proxy_request_count, COALESCE(SUM(CASE WHEN inspected = 1 THEN 1 ELSE 0 END), 0) AS inspected_response_count, COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) AS matched_response_count, COALESCE(SUM(COALESCE(input_tokens, 0)), 0) AS input_tokens, COALESCE(SUM(COALESCE(output_tokens, 0)), 0) AS output_tokens, COALESCE(SUM(COALESCE(total_tokens, 0)), 0) AS total_tokens, COALESCE(SUM(COALESCE(reasoning_tokens, 0)), 0) AS reasoning_tokens, COALESCE(SUM(COALESCE(cached_tokens, 0)), 0) AS cached_tokens, MIN(NULLIF(started_at, '')) AS persistent_since, MAX(seq) AS max_seq FROM requests `).get(); monitor.total_proxy_request_count = totalsRow?.total_proxy_request_count || 0; monitor.inspected_response_count = totalsRow?.inspected_response_count || 0; monitor.matched_response_count = totalsRow?.matched_response_count || 0; monitor.token_totals = { input_tokens: totalsRow?.input_tokens || 0, output_tokens: totalsRow?.output_tokens || 0, total_tokens: totalsRow?.total_tokens || 0, reasoning_tokens: totalsRow?.reasoning_tokens || 0, cached_tokens: totalsRow?.cached_tokens || 0, }; monitor.persistent_since = totalsRow?.persistent_since || null; monitor.next_request_seq = Number.isInteger(totalsRow?.max_seq) ? totalsRow.max_seq + 1 : 1; monitor.observed_reasoning_counts = {}; const reasoningRows = requestsDb.prepare(` SELECT reasoning_tokens, COUNT(*) AS count FROM requests WHERE reasoning_tokens IS NOT NULL GROUP BY reasoning_tokens `).all(); for (const row of reasoningRows) { if (!Number.isInteger(row?.reasoning_tokens)) { continue; } monitor.observed_reasoning_counts[`${row.reasoning_tokens}`] = row.count || 0; } return; } const requestEntries = await readJsonlFile(paths.requestsPath); monitor.next_request_seq = requestEntries.reduce((maxSeq, entry) => { return Math.max(maxSeq, Number.isInteger(entry.seq) ? entry.seq + 1 : maxSeq); }, 1); for (const entry of requestEntries) { monitor.total_proxy_request_count += 1; if (entry.inspected) { recordInspectedResponse(monitor, entry.reasoning_tokens ?? entry.usage?.reasoning_tokens ?? null, Boolean(entry.matched)); } addTokenTotals(monitor, entry.usage); } const firstStartedAt = requestEntries .map((entry) => `${entry?.started_at || ""}`.trim()) .filter(Boolean) .sort()[0]; monitor.persistent_since = firstStartedAt || null; } 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 addTokenTotals(monitor, usage) { if (!usage) { return; } for (const key of ["input_tokens", "output_tokens", "total_tokens", "reasoning_tokens", "cached_tokens"]) { const value = usage[key]; if (Number.isInteger(value)) { monitor.token_totals[key] += value; } } } function upsertRequestEntry(runtime, entry, { persistJsonl = false, includeUsage = false } = {}) { if (includeUsage) { addTokenTotals(runtime.monitor, entry.usage); } if (runtime.requestsDb) { insertRequestRow(runtime.requestsDb, requestRowFromEntry(entry)); } if (persistJsonl) { appendJsonl(runtime.paths.requestsPath, buildPersistedRequestPayload(entry)).catch((error) => { runtime.logger?.(`[requests] failed to persist request seq=${entry.seq}: ${error?.message || error}`); }); } } function recordRequestEntry(runtime, entry, limit = DEFAULT_CONFIG.request_history_limit) { upsertRequestEntry(runtime, entry, { persistJsonl: true, includeUsage: true, }); } function markAndPersistFirstResponse(runtime, entry, at = new Date()) { if (!markRequestFirstResponse(entry, at)) { return false; } upsertRequestEntry(runtime, entry); return true; } function summarizeProfileAuthSource(env) { const mode = env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE || "passthrough"; if (mode === "auth_json") { return env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_PATH ? "auth.json path configured" : "~/.codex/auth.json"; } if (mode === "manual_bearer") { const secretPath = env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE || ""; if (!secretPath) { return "system secret file missing"; } return fs.existsSync(secretPath) ? "system secret file configured" : "system secret file missing"; } if (mode === "fixed_bearer") { if (env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE) { return "token file configured"; } if (env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV) { return `env:${env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV}`; } } return "passthrough"; } function buildProfileFormModel(env) { const manualSecretFile = env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE || ""; return { listen_host: env.CODEX_RETRY_GATEWAY_LISTEN_HOST || "", listen_port: env.CODEX_RETRY_GATEWAY_LISTEN_PORT || "", upstream_base_url: env.CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL || "", auth_mode: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE || "passthrough", auth_env: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV || "", auth_file: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE === "manual_bearer" ? "" : env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE || "", manual_secret_file: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE === "manual_bearer" ? manualSecretFile : "", manual_secret_configured: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE === "manual_bearer" && Boolean(manualSecretFile) && fs.existsSync(manualSecretFile), auth_json_path: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_PATH || "", auth_json_key: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_KEY || "", request_history_limit: env.CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT || `${DEFAULT_CONFIG.request_history_limit}`, model_remap: env.CODEX_RETRY_GATEWAY_MODEL_REMAP || "", reasoning_equals: env.CODEX_RETRY_GATEWAY_REASONING_EQUALS || "", retryable_status_codes: env.CODEX_RETRY_GATEWAY_RETRYABLE_STATUS_CODES || "", retryable_error_messages: normalizePhraseList( env.CODEX_RETRY_GATEWAY_RETRYABLE_ERROR_MESSAGES || DEFAULT_CONFIG.retryable_error_messages, DEFAULT_CONFIG.retryable_error_messages, ), upstream_fetch_retry_attempts: env.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS || `${DEFAULT_CONFIG.upstream_fetch_retry_attempts}`, upstream_fetch_retry_backoff_ms: env.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_BACKOFF_MS || `${DEFAULT_CONFIG.upstream_fetch_retry_backoff_ms}`, endpoints: normalizeStringList(env.CODEX_RETRY_GATEWAY_ENDPOINTS || DEFAULT_CONFIG.endpoints, DEFAULT_CONFIG.endpoints), }; } function buildConfigFromProfileEnv(profileName, env) { const config = { ...DEFAULT_CONFIG, profile_name: profileName, listen_host: env.CODEX_RETRY_GATEWAY_LISTEN_HOST || DEFAULT_CONFIG.listen_host, listen_port: env.CODEX_RETRY_GATEWAY_LISTEN_PORT ? Number.parseInt(`${env.CODEX_RETRY_GATEWAY_LISTEN_PORT}`, 10) : DEFAULT_CONFIG.listen_port, upstream_base_url: env.CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL || "", upstream_auth_mode: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE || DEFAULT_CONFIG.upstream_auth_mode, upstream_auth_env: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV || DEFAULT_CONFIG.upstream_auth_env, upstream_auth_file: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE || "", upstream_auth_json_path: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_PATH || "", upstream_auth_json_key: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_KEY || DEFAULT_CONFIG.upstream_auth_json_key, request_body_limit_bytes: env.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES ? Number.parseInt(`${env.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES}`, 10) : DEFAULT_CONFIG.request_body_limit_bytes, request_history_limit: env.CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT ? Number.parseInt(`${env.CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT}`, 10) : DEFAULT_CONFIG.request_history_limit, model_remap: env.CODEX_RETRY_GATEWAY_MODEL_REMAP || "", endpoints: normalizeStringList(env.CODEX_RETRY_GATEWAY_ENDPOINTS || DEFAULT_CONFIG.endpoints, DEFAULT_CONFIG.endpoints).map(normalizePath), reasoning_equals: normalizeIntegerList(env.CODEX_RETRY_GATEWAY_REASONING_EQUALS || DEFAULT_CONFIG.reasoning_equals, DEFAULT_CONFIG.reasoning_equals), retryable_status_codes: normalizeIntegerList( env.CODEX_RETRY_GATEWAY_RETRYABLE_STATUS_CODES || DEFAULT_CONFIG.retryable_status_codes, DEFAULT_CONFIG.retryable_status_codes, ), retryable_error_messages: normalizePhraseList( env.CODEX_RETRY_GATEWAY_RETRYABLE_ERROR_MESSAGES || DEFAULT_CONFIG.retryable_error_messages, DEFAULT_CONFIG.retryable_error_messages, ), upstream_fetch_retry_attempts: env.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS ? normalizePositiveInteger( env.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS, DEFAULT_CONFIG.upstream_fetch_retry_attempts, ) : DEFAULT_CONFIG.upstream_fetch_retry_attempts, upstream_fetch_retry_backoff_ms: env.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_BACKOFF_MS ? normalizeNonNegativeInteger( env.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_BACKOFF_MS, DEFAULT_CONFIG.upstream_fetch_retry_backoff_ms, ) : DEFAULT_CONFIG.upstream_fetch_retry_backoff_ms, non_stream_status_code: env.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE ? Number.parseInt(`${env.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE}`, 10) : DEFAULT_CONFIG.non_stream_status_code, stream_action: env.CODEX_RETRY_GATEWAY_STREAM_ACTION || DEFAULT_CONFIG.stream_action, log_match: env.CODEX_RETRY_GATEWAY_LOG_MATCH === undefined ? DEFAULT_CONFIG.log_match : ["1", "true", "yes", "on"].includes(`${env.CODEX_RETRY_GATEWAY_LOG_MATCH}`.trim().toLowerCase()), health_path: env.CODEX_RETRY_GATEWAY_HEALTH_PATH || DEFAULT_CONFIG.health_path, }; config.model_remap_map = parseModelRemapMap(config.model_remap); return config; } function assertNoInlineProfileSecret(payload) { const suspectFields = [ "auth_env", "auth_file", "auth_json_path", "auth_json_key", "upstream_base_url", ]; for (const field of suspectFields) { const value = `${payload?.[field] || ""}`.trim(); if (/^sk-[A-Za-z0-9_-]+/.test(value) || /Bearer\s+sk-[A-Za-z0-9_-]+/i.test(value)) { throw new Error("profile 不保存明文 sk 密钥;请改用 env/file/auth.json 引用"); } } } function defaultManualSecretPath(profileName) { const homeDir = process.env.HOME || ""; return path.join(homeDir, ".codex-retry-gateway", "secrets", `${profileName}.token`); } async function writeManualSecret(profileName, secretValue) { const text = `${secretValue || ""}`.trim(); if (!text) { return null; } const secretPath = defaultManualSecretPath(profileName); await mkdir(path.dirname(secretPath), { recursive: true, mode: 0o700 }); await writeFile(secretPath, `${text}\n`, { encoding: "utf8", mode: 0o600 }); await chmod(path.dirname(secretPath), 0o700).catch(() => {}); await chmod(secretPath, 0o600).catch(() => {}); return secretPath; } function serializeEnvValue(value) { const text = `${value ?? ""}`; if (/^[A-Za-z0-9_./:@?&=,+-]*$/.test(text)) { return text; } return JSON.stringify(text); } async function buildProfileEnvText(payload) { assertNoInlineProfileSecret(payload); const name = `${payload?.name || ""}`.trim(); validateProfileName(name); const listenPort = Number.parseInt(`${payload.listen_port || DEFAULT_CONFIG.listen_port}`, 10); if (!Number.isInteger(listenPort) || listenPort < 1 || listenPort > 65535) { throw new Error("监听端口必须是 1-65535 的整数"); } const upstreamBaseUrl = `${payload.upstream_base_url || ""}`.trim(); if (!upstreamBaseUrl) { throw new Error("上游 Base URL 不能为空"); } try { new URL(upstreamBaseUrl); } catch { throw new Error("上游 Base URL 必须是合法 URL"); } const authMode = normalizeAuthMode(payload.auth_mode); const reasoningEquals = normalizeIntegerList(payload.reasoning_equals, DEFAULT_CONFIG.reasoning_equals); if (reasoningEquals.length === 0) { throw new Error("reasoning_equals 不能为空"); } const endpoints = normalizeStringList(payload.endpoints, DEFAULT_CONFIG.endpoints).map(normalizePath); if (endpoints.length === 0) { throw new Error("endpoints 不能为空"); } const requestHistoryLimit = Number.parseInt( `${payload.request_history_limit || DEFAULT_CONFIG.request_history_limit}`, 10, ); if (!Number.isInteger(requestHistoryLimit) || requestHistoryLimit < 0) { throw new Error("History Limit 必须是 0 或正整数;0 表示不裁剪"); } const retryableStatusCodes = normalizeIntegerList( payload.retryable_status_codes, DEFAULT_CONFIG.retryable_status_codes, ); const retryableErrorMessages = normalizePhraseList( payload.retryable_error_messages, DEFAULT_CONFIG.retryable_error_messages, ); const upstreamFetchRetryAttempts = normalizePositiveInteger( payload.upstream_fetch_retry_attempts, DEFAULT_CONFIG.upstream_fetch_retry_attempts, ); const upstreamFetchRetryBackoffMs = normalizeNonNegativeInteger( payload.upstream_fetch_retry_backoff_ms, DEFAULT_CONFIG.upstream_fetch_retry_backoff_ms, ); if (retryableStatusCodes.length === 0) { throw new Error("retryable_status_codes 不能为空"); } if (retryableErrorMessages.length === 0) { throw new Error("retryable_error_messages 不能为空"); } if (upstreamFetchRetryAttempts < 1) { throw new Error("upstream_fetch_retry_attempts 必须是正整数"); } if (upstreamFetchRetryBackoffMs < 0) { throw new Error("upstream_fetch_retry_backoff_ms 不能为负数"); } const envPairs = [ ["CODEX_RETRY_GATEWAY_LISTEN_HOST", `${payload.listen_host || DEFAULT_CONFIG.listen_host}`.trim()], ["CODEX_RETRY_GATEWAY_LISTEN_PORT", `${listenPort}`], ["CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL", upstreamBaseUrl], ["CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE", authMode], ["CODEX_RETRY_GATEWAY_REASONING_EQUALS", reasoningEquals.join(",")], ["CODEX_RETRY_GATEWAY_RETRYABLE_STATUS_CODES", retryableStatusCodes.join(",")], ["CODEX_RETRY_GATEWAY_RETRYABLE_ERROR_MESSAGES", retryableErrorMessages.join("\n")], ["CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS", `${upstreamFetchRetryAttempts}`], ["CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_BACKOFF_MS", `${upstreamFetchRetryBackoffMs}`], ["CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT", `${requestHistoryLimit}`], ["CODEX_RETRY_GATEWAY_ENDPOINTS", endpoints.join(",")], ]; const modelRemap = `${payload.model_remap || ""}`.trim(); if (modelRemap) { envPairs.push(["CODEX_RETRY_GATEWAY_MODEL_REMAP", modelRemap]); } if (authMode === "manual_bearer") { const secretPath = (await writeManualSecret(name, payload.manual_secret)) || `${payload.manual_secret_file || ""}`.trim() || defaultManualSecretPath(name); if (!fs.existsSync(secretPath)) { throw new Error("manual_bearer 需要手动填入一次 token/password 后才能保存"); } envPairs.push(["CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE", secretPath]); } else if (authMode === "fixed_bearer") { envPairs.push([ "CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV", `${payload.auth_env || DEFAULT_CONFIG.upstream_auth_env}`.trim(), ]); if (`${payload.auth_file || ""}`.trim()) { envPairs.push(["CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE", `${payload.auth_file}`.trim()]); } } else if (authMode === "auth_json") { if (`${payload.auth_json_path || ""}`.trim()) { envPairs.push(["CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_PATH", `${payload.auth_json_path}`.trim()]); } envPairs.push([ "CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_KEY", `${payload.auth_json_key || DEFAULT_CONFIG.upstream_auth_json_key}`.trim(), ]); } const lines = [ "# Managed by codex-retry-gateway UI.", "# Do not put raw sk-* secrets here; use env/file/auth.json references.", ...envPairs.map(([key, value]) => `${key}=${serializeEnvValue(value)}`), "", ]; return { name, content: lines.join("\n"), }; } async function writeProfile(runtime, payload) { const { name, content } = await buildProfileEnvText(payload); await mkdir(runtime.paths.profilesDir, { recursive: true }); const profilePath = path.join(runtime.paths.profilesDir, `${name}.env`); await writeFile(profilePath, content, { encoding: "utf8", mode: 0o600 }); return { name, file_path: profilePath, }; } function buildMetricsSnapshot(monitor) { const reasoning516Count = monitor.observed_reasoning_counts["516"] || 0; const inspectedResponseCount = monitor.inspected_response_count; return { started_at: monitor.started_at, persistent_since: monitor.persistent_since, 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, token_totals: { ...monitor.token_totals }, 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 buildPersistentLogsSnapshot(runtime, sinceSeq = null, limit = 500) { const safeLimit = Number.isInteger(limit) && limit > 0 ? Math.min(limit, 1000) : 500; const text = runtime.logPath ? await readOptionalText(runtime.logPath) : null; if (!text) { return buildLogsSnapshot(runtime.monitor, sinceSeq); } const allEntries = text .split(/\r?\n/) .filter(Boolean) .map((line, index) => parseLogLine(line, index + 1)); const entries = Number.isInteger(sinceSeq) ? allEntries.filter((entry) => entry.seq > sinceSeq) : allEntries.slice(-safeLimit); return { total_entries: allEntries.length, latest_seq: allEntries.length, entries, }; } function buildRequestsSnapshot(monitor, limit = 50) { const safeLimit = Number.isInteger(limit) && limit > 0 ? Math.min(limit, 500) : 50; const entries = monitor.request_entries.slice(-safeLimit).reverse(); return { total_entries: monitor.request_entries.length, latest_seq: monitor.next_request_seq - 1, entries, }; } async function buildPersistentRequestsSnapshot(runtime, { limit = 50, offset = 0, query = "", filter = "all" } = {}) { const safeLimit = Number.isInteger(limit) && limit > 0 ? Math.min(limit, 500) : 50; const safeOffset = Number.isInteger(offset) && offset > 0 ? offset : 0; if (!runtime.requestsDb) { const entries = await readJsonlFile(runtime.paths.requestsPath); return { total_entries: entries.length, latest_seq: entries.reduce((maxSeq, entry) => { return Math.max(maxSeq, Number.isInteger(entry.seq) ? entry.seq : maxSeq); }, 0), entries: entries.slice(-safeLimit).reverse(), }; } const { whereSql, params } = buildRequestQueryFilters({ query, filter }); const totalRow = runtime.requestsDb.prepare(`SELECT COUNT(*) AS count FROM requests ${whereSql}`).get(params); const latestRow = runtime.requestsDb.prepare("SELECT MAX(seq) AS latest_seq FROM requests").get(); const rows = runtime.requestsDb.prepare(` SELECT payload_json FROM requests ${whereSql} ORDER BY seq DESC LIMIT @limit OFFSET @offset `).all({ ...params, limit: safeLimit, offset: safeOffset, }); return { total_entries: totalRow?.count || 0, latest_seq: latestRow?.latest_seq || 0, entries: rows.map(parseRequestRowPayload).filter(Boolean), }; } function buildRequestEntry({ seq, startedAt, startedMs, req, pathname, requestJson, profileName }) { return { seq, lifecycle_state: "sent", started_at: startedAt.toISOString(), first_response_at: null, first_response_delay_ms: null, last_activity_at: null, finished_at: null, duration_ms: null, profile_name: profileName || "default", method: req.method, path: pathname, request_body_bytes: null, response_bytes_received: 0, model: requestJson?.model || null, requested_model: requestJson?.model || null, forwarded_model: requestJson?.model || null, request_stream: Boolean(requestJson?.stream), response_stream: false, stream_chunk_count: 0, usage_last_updated_at: null, upstream_attempt_count: 0, inspected: false, matched: false, status_code: null, upstream_status_code: null, upstream: null, reasoning_tokens: null, usage: null, error: null, _started_ms: startedMs, }; } function markRequestFirstResponse(entry, at = new Date()) { if (entry.first_response_at) { return false; } const firstAt = at instanceof Date ? at : new Date(at); const firstMs = firstAt.getTime(); entry.first_response_at = firstAt.toISOString(); entry.first_response_delay_ms = Number.isFinite(entry._started_ms) ? Math.max(0, firstMs - entry._started_ms) : null; entry.last_activity_at = firstAt.toISOString(); entry.lifecycle_state = "receive_first"; return true; } const STREAM_PROGRESS_PERSIST_INTERVAL_MS = 1000; function updateStreamingProgress(entry, { chunkBytes = 0, usage = null, reasoning = null, at = new Date() } = {}) { const observedAt = at instanceof Date ? at : new Date(at); const observedAtIso = observedAt.toISOString(); entry.last_activity_at = observedAtIso; entry.lifecycle_state = "streaming"; if (chunkBytes > 0) { entry.stream_chunk_count = (entry.stream_chunk_count || 0) + 1; entry.response_bytes_received = (entry.response_bytes_received || 0) + chunkBytes; } if (usage) { entry.usage = mergeUsageSnapshots(entry.usage, usage); entry.usage_last_updated_at = observedAtIso; } if (Number.isInteger(reasoning)) { entry.reasoning_tokens = reasoning; } } function persistStreamingProgress(runtime, entry, { force = false, usageUpdated = false } = {}, at = new Date()) { const observedAt = at instanceof Date ? at : new Date(at); const nowMs = observedAt.getTime(); const lastPersistedMs = Number.isFinite(entry._last_stream_persisted_ms) ? entry._last_stream_persisted_ms : 0; if (!force && !usageUpdated && nowMs - lastPersistedMs < STREAM_PROGRESS_PERSIST_INTERVAL_MS) { return false; } entry._last_stream_persisted_ms = nowMs; upsertRequestEntry(runtime, entry); return true; } function finalizeRequestEntry(entry, result = {}) { const finishedAt = new Date(); const startedMs = entry._started_ms; delete entry._started_ms; delete entry._last_stream_persisted_ms; return { ...entry, ...result, lifecycle_state: "finish", finished_at: finishedAt.toISOString(), duration_ms: Number.isFinite(startedMs) ? Math.max(0, Date.now() - startedMs) : null, }; } async function loadConfig(configPath) { const content = await readFile(configPath, "utf8"); const loaded = JSON.parse(content); const config = { ...DEFAULT_CONFIG, ...loaded }; config.model_remap_map = parseModelRemapMap(config.model_remap); config.endpoints = normalizeStringList(config.endpoints, DEFAULT_CONFIG.endpoints).map(normalizePath); config.reasoning_equals = normalizeIntegerList( config.reasoning_equals, DEFAULT_CONFIG.reasoning_equals, ); config.retryable_status_codes = normalizeIntegerList( config.retryable_status_codes, DEFAULT_CONFIG.retryable_status_codes, ); config.retryable_error_messages = normalizePhraseList( config.retryable_error_messages, DEFAULT_CONFIG.retryable_error_messages, ); config.upstream_fetch_retry_attempts = normalizePositiveInteger( config.upstream_fetch_retry_attempts, DEFAULT_CONFIG.upstream_fetch_retry_attempts, ); config.upstream_fetch_retry_backoff_ms = normalizeNonNegativeInteger( config.upstream_fetch_retry_backoff_ms, DEFAULT_CONFIG.upstream_fetch_retry_backoff_ms, ); 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); const homeDir = process.env.HOME || ""; return { stateRoot, statePath: path.join(stateRoot, "state.json"), pidPath: path.join(stateRoot, "gateway.pid"), profilesDir: path.join(homeDir, ".config", "codex-retry-gateway", "profiles"), configPath, logPath, requestsPath: path.join(stateRoot, "logs", "requests.jsonl"), requestsDbPath: path.join(stateRoot, "logs", "requests.sqlite"), }; } async function readOptionalJson(jsonPath) { try { const content = await readFile(jsonPath, "utf8"); return JSON.parse(content); } catch { return null; } } async function readOptionalText(textPath) { try { return await readFile(textPath, "utf8"); } catch { return null; } } function normalizeAuthMode(value) { const mode = `${value || "passthrough"}`.trim().toLowerCase(); if (["passthrough", "fixed_bearer", "manual_bearer", "auth_json"].includes(mode)) { return mode; } return "passthrough"; } function sanitizeConfigForStatus(config) { const { upstream_auth_file, upstream_auth_json_path, upstream_auth_env, upstream_auth_json_key, model_remap_map, ...rest } = config; return { ...rest, upstream_auth_env: upstream_auth_env || null, upstream_auth_file: upstream_auth_file ? "[configured]" : "", upstream_auth_json_path: upstream_auth_json_path ? "[configured]" : "", upstream_auth_json_key: upstream_auth_json_key || null, }; } function remapRequestModel(config, requestJson) { if (!requestJson || typeof requestJson !== "object") { return { requestJson, remapped: false, forwardedModel: null }; } const requestedModel = `${requestJson.model || ""}`.trim(); if (!requestedModel) { return { requestJson, remapped: false, forwardedModel: null }; } const forwardedModel = config.model_remap_map?.[requestedModel]; if (!forwardedModel || forwardedModel === requestedModel) { return { requestJson, remapped: false, forwardedModel: requestedModel }; } return { requestJson: { ...requestJson, model: forwardedModel, }, remapped: true, forwardedModel, }; } async function resolveUpstreamAuth(config) { const mode = normalizeAuthMode(config.upstream_auth_mode); if (mode === "passthrough") { return { mode, authorization: null, source: "passthrough" }; } let token = ""; let source = "missing"; if (mode === "fixed_bearer") { const envName = config.upstream_auth_env || DEFAULT_CONFIG.upstream_auth_env; token = envName ? `${process.env[envName] || ""}`.trim() : ""; source = token ? "env" : "missing"; if (!token && config.upstream_auth_file) { token = `${(await readOptionalText(config.upstream_auth_file)) || ""}`.trim(); source = token ? "file" : "file_empty"; } } else if (mode === "manual_bearer") { if (config.upstream_auth_file) { token = `${(await readOptionalText(config.upstream_auth_file)) || ""}`.trim(); source = token ? "manual_file" : "manual_file_empty"; } } else if (mode === "auth_json") { const authPath = config.upstream_auth_json_path || path.join(process.env.HOME || "", ".codex", "auth.json"); const key = config.upstream_auth_json_key || DEFAULT_CONFIG.upstream_auth_json_key; const authJson = await readOptionalJson(authPath); token = `${authJson?.[key] || ""}`.trim(); source = token ? "auth_json" : "auth_json_missing"; } if (!token) { throw new Error(`upstream_auth_mode=${mode} requires a configured token source`); } const authorization = token.toLowerCase().startsWith("bearer ") ? token : `Bearer ${token}`; return { mode, authorization, source }; } async function writeConfig(configPath, config) { await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); } async function updateRuntimeState(runtime, updates) { const current = await readOptionalJson(runtime.paths.statePath); if (!current) { return; } await writeFile( runtime.paths.statePath, `${JSON.stringify({ ...current, ...updates }, null, 2)}\n`, "utf8", ); } async function listProfiles(runtime) { let files = []; try { files = await readdir(runtime.paths.profilesDir, { withFileTypes: true }); } catch { files = []; } const activeProfile = runtime.config.profile_name || "default"; const profiles = []; for (const file of files) { if (!file.isFile()) { continue; } const name = getProfileNameFromFile(file.name); if (!name) { continue; } const filePath = path.join(runtime.paths.profilesDir, file.name); const env = parseEnvText((await readOptionalText(filePath)) || ""); const form = buildProfileFormModel(env); profiles.push({ name, active: name === activeProfile, file_path: filePath, summary: { listen_host: form.listen_host, listen_port: form.listen_port, upstream_base_url: form.upstream_base_url, auth_mode: form.auth_mode, auth_env: redactProfileValue( "CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV", form.auth_env, ), auth_file: form.auth_file ? "[configured]" : "", auth_json_path: form.auth_json_path ? "[configured]" : "", auth_json_key: redactProfileValue( "CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_KEY", form.auth_json_key, ), request_history_limit: form.request_history_limit, model_remap: form.model_remap || "", auth_source: summarizeProfileAuthSource(env), reasoning_equals: form.reasoning_equals, }, form, }); } profiles.sort((left, right) => left.name.localeCompare(right.name)); return profiles; } function runDetached(command, args) { const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true, }); child.unref(); return child.pid; } async function triggerProfileSwitch(runtime, profileName) { if (!/^[A-Za-z0-9_.-]+$/.test(profileName)) { throw new Error("profile 名称只能包含字母、数字、下划线、点和短横线"); } const profilePath = path.join(runtime.paths.profilesDir, `${profileName}.env`); if (!fs.existsSync(profilePath)) { throw new Error(`profile 不存在: ${profileName}`); } const currentUnit = `codex-retry-gateway@${runtime.config.profile_name || "default"}.service`; const targetUnit = `codex-retry-gateway@${profileName}.service`; const switchScript = [ "set -e", `systemctl --user disable --now ${currentUnit}`, `systemctl --user enable --now ${targetUnit}`, ].join("\n"); const unitName = `codex-retry-gateway-switch-${Date.now()}`; const pid = runDetached("systemd-run", [ "--user", "--collect", `--unit=${unitName}`, "--on-active=1s", "/usr/bin/env", "bash", "-lc", switchScript, ]); return { profile: profileName, current_unit: currentUnit, target_unit: targetUnit, switch_unit: `${unitName}.service`, pid, }; } async function applyProfileConfig(runtime, profileName) { const { profilePath, config } = await loadProfileConfigForProbe(runtime, profileName); if (!canHotSwapProfile(runtime.config, config)) { throw new Error("该 profile 的监听地址或端口与当前实例不同,暂不支持无重启热切换"); } runtime.config = { ...config, model_remap_map: parseModelRemapMap(config.model_remap), }; await writeConfig(runtime.configPath, runtime.config); await updateRuntimeState(runtime, { profile_name: runtime.config.profile_name || "default", profile_env_path: profilePath, gateway_base_url: `http://${runtime.config.listen_host}:${runtime.config.listen_port}`, last_started_at: new Date().toISOString(), }); runtime.logger( `[profile] hot-swapped profile=${runtime.config.profile_name || "default"} auth=${normalizeAuthMode(runtime.config.upstream_auth_mode)} upstream=${runtime.config.upstream_base_url}`, ); return { profile: runtime.config.profile_name || "default", profile_env_path: profilePath, hot_swapped: true, listen: `${runtime.config.listen_host}:${runtime.config.listen_port}`, upstream_base_url: runtime.config.upstream_base_url, }; } async function loadProfileConfigForProbe(runtime, profileName) { validateProfileName(profileName); const profilePath = path.join(runtime.paths.profilesDir, `${profileName}.env`); if (!fs.existsSync(profilePath)) { throw new Error(`profile 不存在: ${profileName}`); } const env = parseEnvText((await readOptionalText(profilePath)) || ""); const config = buildConfigFromProfileEnv(profileName, env); if (!config.upstream_base_url) { throw new Error(`profile ${profileName} 缺少 upstream_base_url`); } return { profilePath, env, config }; } async function deleteProfile(runtime, profileName) { validateProfileName(profileName); const activeProfile = runtime.config.profile_name || "default"; if (profileName === activeProfile) { throw new Error("不能删除当前正在运行的 profile;请先切换到其他 profile"); } const profilePath = path.join(runtime.paths.profilesDir, `${profileName}.env`); if (!fs.existsSync(profilePath)) { throw new Error(`profile 不存在: ${profileName}`); } await rm(profilePath, { force: true }); return { name: profileName, file_path: profilePath, }; } async function readProbeBodySummary(response, maxChars = 400) { const contentType = response.headers.get("content-type") || ""; const text = await response.text(); let summary = text.slice(0, maxChars); if (contentType.includes("application/json")) { try { const payload = JSON.parse(text); if (Array.isArray(payload?.data)) { summary = JSON.stringify(payload.data.slice(0, 8).map((item) => item.id ?? item.display_name ?? item), null, 2); } else if (payload?.error) { summary = JSON.stringify(payload.error); } else { summary = text.slice(0, maxChars); } } catch { summary = text.slice(0, maxChars); } } return { content_type: contentType, body_preview: summary, }; } async function probeProfile(runtime, payload) { const profileName = `${payload?.profile || ""}`.trim(); if (!profileName) { throw new Error("缺少 profile"); } const { config } = await loadProfileConfigForProbe(runtime, profileName); const upstreamAuth = await resolveUpstreamAuth(config); const result = { profile: profileName, upstream_base_url: config.upstream_base_url, auth_mode: upstreamAuth.mode, auth_source: upstreamAuth.source, authorization_configured: Boolean(upstreamAuth.authorization), model_remap: config.model_remap || "", probes: [], }; const modelsUrl = buildUpstreamUrl(config.upstream_base_url, new URL("http://local/v1/models")); const { response: modelsResponse } = await fetchUpstreamWithRetry(modelsUrl, { method: "GET", headers: cloneHeadersForUpstream({}, upstreamAuth), }, config, runtime.logger, { method: "GET", pathname: "/v1/models" }); result.probes.push({ kind: "models", target: "/v1/models", status: modelsResponse.status, ...await readProbeBodySummary(modelsResponse, 600), }); const requestedModel = `${payload?.model || ""}`.trim(); if (requestedModel) { const { requestJson, forwardedModel } = remapRequestModel(config, { model: requestedModel, input: `${payload?.input || "ping"}`, max_output_tokens: 1, stream: false, }); const responsesUrl = buildUpstreamUrl(config.upstream_base_url, new URL("http://local/v1/responses")); const { response: responseProbe } = await fetchUpstreamWithRetry(responsesUrl, { method: "POST", headers: cloneHeadersForUpstream({ "content-type": "application/json" }, upstreamAuth), body: JSON.stringify(requestJson), }, config, runtime.logger, { method: "POST", pathname: "/v1/responses" }); result.probes.push({ kind: "responses", target: "/v1/responses", requested_model: requestedModel, forwarded_model: forwardedModel || requestedModel, status: responseProbe.status, ...await readProbeBodySummary(responseProbe, 600), }); } return result; } 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)); } const STATIC_CONTENT_TYPES = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", ".woff": "font/woff", ".woff2": "font/woff2", }; function contentTypeForFile(filePath) { return STATIC_CONTENT_TYPES[path.extname(filePath).toLowerCase()] || "application/octet-stream"; } function safeJoinStatic(root, requestPath) { const decoded = decodeURIComponent(requestPath); const relative = decoded.replace(/^\/+/, ""); const fullPath = path.resolve(root, relative); const rootPath = path.resolve(root); if (fullPath !== rootPath && !fullPath.startsWith(`${rootPath}${path.sep}`)) { return null; } return fullPath; } async function serveStaticFile(res, filePath) { try { const body = await readFile(filePath); res.writeHead(200, { "content-type": contentTypeForFile(filePath), "cache-control": filePath.includes(`${path.sep}assets${path.sep}`) ? "public, max-age=31536000, immutable" : "no-cache", }); res.end(body); return true; } catch { return false; } } async function serveManagementUi(res, requestPathname) { const uiPrefix = `${UI_PATH}/`; if (requestPathname === UI_PATH || requestPathname === `${UI_PATH}/`) { return serveStaticFile(res, path.join(UI_STATIC_ROOT, "index.html")); } if (!requestPathname.startsWith(uiPrefix)) { return false; } const staticPath = safeJoinStatic(UI_STATIC_ROOT, requestPathname.slice(uiPrefix.length)); if (!staticPath) { jsonResponse(res, 403, { error: { message: "invalid static path", code: "invalid_static_path", }, }); return true; } if (await serveStaticFile(res, staticPath)) { return true; } return serveStaticFile(res, path.join(UI_STATIC_ROOT, "index.html")); } function buildEditableConfig(currentConfig, payload) { const nextReasoning = normalizeIntegerList(payload.reasoning_equals, currentConfig.reasoning_equals); const nextRetryableStatusCodes = normalizeIntegerList( payload.retryable_status_codes, currentConfig.retryable_status_codes, ); const nextRetryableErrorMessages = normalizePhraseList( payload.retryable_error_messages, currentConfig.retryable_error_messages, ); const nextEndpoints = normalizeStringList(payload.endpoints, currentConfig.endpoints).map(normalizePath); const nextUpstreamFetchRetryAttempts = payload.upstream_fetch_retry_attempts === undefined ? normalizePositiveInteger( currentConfig.upstream_fetch_retry_attempts, DEFAULT_CONFIG.upstream_fetch_retry_attempts, ) : normalizePositiveInteger( payload.upstream_fetch_retry_attempts, DEFAULT_CONFIG.upstream_fetch_retry_attempts, ); const nextUpstreamFetchRetryBackoffMs = payload.upstream_fetch_retry_backoff_ms === undefined ? normalizeNonNegativeInteger( currentConfig.upstream_fetch_retry_backoff_ms, DEFAULT_CONFIG.upstream_fetch_retry_backoff_ms, ) : normalizeNonNegativeInteger( payload.upstream_fetch_retry_backoff_ms, DEFAULT_CONFIG.upstream_fetch_retry_backoff_ms, ); 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 (nextRetryableStatusCodes.length === 0) { throw new Error("retryable_status_codes 不能为空"); } if (nextRetryableErrorMessages.length === 0) { throw new Error("retryable_error_messages 不能为空"); } if (nextEndpoints.length === 0) { throw new Error("endpoints 不能为空"); } if (nextUpstreamFetchRetryAttempts < 1) { throw new Error("upstream_fetch_retry_attempts 必须是正整数"); } if (nextUpstreamFetchRetryBackoffMs < 0) { throw new Error("upstream_fetch_retry_backoff_ms 不能为负数"); } if (!Number.isInteger(nextStatusCode) || nextStatusCode < 100 || nextStatusCode > 599) { throw new Error("non_stream_status_code 必须是 100-599 的整数"); } return { ...currentConfig, reasoning_equals: nextReasoning, retryable_status_codes: nextRetryableStatusCodes, retryable_error_messages: nextRetryableErrorMessages, endpoints: nextEndpoints, upstream_fetch_retry_attempts: nextUpstreamFetchRetryAttempts, upstream_fetch_retry_backoff_ms: nextUpstreamFetchRetryBackoffMs, non_stream_status_code: nextStatusCode, log_match: payload.log_match === undefined ? currentConfig.log_match : Boolean(payload.log_match), }; } async function handleManagementRequest(runtime, req, res, requestUrl) { const pathname = normalizePath(requestUrl.pathname); if (pathname === UI_PATH || pathname.startsWith(`${UI_PATH}/`)) { if (!(await serveManagementUi(res, requestUrl.pathname))) { jsonResponse(res, 503, { error: { message: "UI assets were not built. Run: npm run build:ui", code: "ui_not_built", }, }); } 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: sanitizeConfigForStatus(runtime.config), state, paths: { config_path: runtime.configPath, state_path: runtime.paths.statePath, state_root: runtime.paths.stateRoot, log_path: runtime.logPath, requests_path: runtime.paths.requestsPath, profiles_dir: runtime.paths.profilesDir, }, metrics: buildMetricsSnapshot(runtime.monitor), }); return true; } if (pathname === LOGS_API_PATH && req.method === "GET") { const sinceSeqRaw = requestUrl.searchParams.get("since_seq"); const limitRaw = requestUrl.searchParams.get("limit"); const sinceSeq = sinceSeqRaw === null ? null : Number.parseInt(sinceSeqRaw, 10); const limit = limitRaw === null ? 500 : Number.parseInt(limitRaw, 10); jsonResponse(res, 200, { ok: true, ...await buildPersistentLogsSnapshot( runtime, Number.isInteger(sinceSeq) ? sinceSeq : null, Number.isInteger(limit) ? limit : 500, ), }); return true; } if (pathname === REQUESTS_API_PATH && req.method === "GET") { const limitRaw = requestUrl.searchParams.get("limit"); const offsetRaw = requestUrl.searchParams.get("offset"); const query = requestUrl.searchParams.get("query") || ""; const filter = requestUrl.searchParams.get("filter") || "all"; const limit = limitRaw === null ? 50 : Number.parseInt(limitRaw, 10); const offset = offsetRaw === null ? 0 : Number.parseInt(offsetRaw, 10); jsonResponse(res, 200, { ok: true, ...await buildPersistentRequestsSnapshot(runtime, { limit: Number.isInteger(limit) ? limit : 50, offset: Number.isInteger(offset) ? offset : 0, query, filter, }), }); return true; } if (pathname === PROFILES_API_PATH && req.method === "GET") { jsonResponse(res, 200, { ok: true, profiles_dir: runtime.paths.profilesDir, active_profile: runtime.config.profile_name || "default", profiles: await listProfiles(runtime), }); return true; } if (pathname === PROFILES_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: "profile 保存请求必须是有效 JSON", code: "invalid_json", }, }); return true; } const result = await writeProfile(runtime, payload); let applied = null; if (result.name === (runtime.config.profile_name || "default")) { applied = await applyProfileConfig(runtime, result.name); } runtime.logger(`[profile] saved name=${result.name} path=${result.file_path}`); jsonResponse(res, 200, { ok: true, message: applied ? "profile 已保存并已热应用" : "profile 已保存", saved_profile: result, applied_profile: applied, profiles_dir: runtime.paths.profilesDir, active_profile: runtime.config.profile_name || "default", profiles: await listProfiles(runtime), }); return true; } if (pathname === PROFILE_PROBE_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: "profile probe 请求必须是有效 JSON", code: "invalid_json", }, }); return true; } const result = await probeProfile(runtime, payload); runtime.logger( `[profile-probe] profile=${result.profile} auth=${result.auth_mode}/${result.auth_source} upstream=${result.upstream_base_url}`, ); jsonResponse(res, 200, { ok: true, ...result, }); return true; } if (pathname === PROFILE_SWITCH_API_PATH && req.method === "POST") { const body = await readRequestBody(req, runtime.config.request_body_limit_bytes); const payload = parseJsonSafely(body); const profileName = `${payload?.profile || ""}`.trim(); if (!profileName) { jsonResponse(res, 400, { error: { message: "缺少 profile", code: "profile_required", }, }); return true; } const result = await applyProfileConfig(runtime, profileName); jsonResponse(res, 200, { ok: true, message: "profile 已热切换,无需重启 gateway", ...result, }); return true; } if (pathname.startsWith(PROFILE_ITEM_API_PREFIX) && req.method === "DELETE") { const rawName = pathname.slice(PROFILE_ITEM_API_PREFIX.length); if (!rawName || rawName.includes("/")) { jsonResponse(res, 400, { error: { message: "无效的 profile 名称", code: "invalid_profile_name", }, }); return true; } const profileName = decodeURIComponent(rawName); const result = await deleteProfile(runtime, profileName); runtime.logger(`[profile] deleted name=${result.name} path=${result.file_path}`); jsonResponse(res, 200, { ok: true, message: "profile 已删除", deleted_profile: result, profiles_dir: runtime.paths.profilesDir, active_profile: runtime.config.profile_name || "default", profiles: await listProfiles(runtime), }); 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(",")} retryable_status_codes=${nextConfig.retryable_status_codes.join(",")} endpoints=${nextConfig.endpoints.join(",")}`, ); const state = await readRuntimeState(runtime); jsonResponse(res, 200, { ok: true, message: "配置已保存并立即生效", config: sanitizeConfigForStatus(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 buildUpstreamSnapshot({ upstreamUrl, upstreamAuth = null, upstreamResponse = null }) { const parsedUrl = new URL(upstreamUrl); const snapshot = { origin: parsedUrl.origin, path: parsedUrl.pathname, auth_mode: upstreamAuth?.mode || "unknown", auth_source: upstreamAuth?.source || "unknown", authorization_configured: Boolean(upstreamAuth?.authorization), }; if (upstreamResponse) { snapshot.status = upstreamResponse.status; snapshot.content_type = upstreamResponse.headers.get("content-type") || ""; } return snapshot; } function cloneHeadersForUpstream(headers, upstreamAuth = null) { 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); } } if (upstreamAuth?.authorization) { outgoing.set("authorization", upstreamAuth.authorization); } 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 rebuildBufferedResponse(response, bodyBuffer) { return new Response(bodyBuffer, { status: response.status, statusText: response.statusText, headers: new Headers(response.headers), }); } function matchPath(config, pathname) { return config.endpoints.includes(normalizePath(pathname)); } function reasoningMatched(config, reasoning) { return reasoning !== null && config.reasoning_equals.includes(reasoning); } function collectRetryableMessageCandidates(value, state = { seen: new Set(), results: [] }, depth = 0) { if (value === null || value === undefined || depth > 5 || state.results.length >= 64) { return state.results; } if (typeof value === "string") { const trimmed = value.trim(); if (trimmed) { state.results.push(trimmed); } return state.results; } if (typeof value !== "object") { return state.results; } if (state.seen.has(value)) { return state.results; } state.seen.add(value); if (Array.isArray(value)) { for (const item of value) { collectRetryableMessageCandidates(item, state, depth + 1); } return state.results; } const preferredKeys = ["message", "error", "detail", "details", "description", "title"]; for (const key of preferredKeys) { if (Object.hasOwn(value, key)) { collectRetryableMessageCandidates(value[key], state, depth + 1); } } for (const [key, nested] of Object.entries(value)) { if (preferredKeys.includes(key)) { continue; } collectRetryableMessageCandidates(nested, state, depth + 1); } return state.results; } function truncateRetryableMessage(value, maxLength = 280) { const text = `${value || ""}`.trim(); if (!text) { return ""; } return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text; } function findRetryableUpstreamErrorMatch(config, upstreamStatusCode, parsedBody, bodyText) { if (!isUpstreamErrorStatus(upstreamStatusCode)) { return null; } const retryableMessages = normalizePhraseList( config.retryable_error_messages, DEFAULT_CONFIG.retryable_error_messages, ); if (retryableMessages.length === 0) { return null; } const retryableStatusCodes = normalizeIntegerList( config.retryable_status_codes, DEFAULT_CONFIG.retryable_status_codes, ); if (retryableStatusCodes.length > 0 && !retryableStatusCodes.includes(upstreamStatusCode)) { return null; } const normalizedPatterns = retryableMessages.map((message) => ({ original: message, normalized: message.toLowerCase(), })); const candidateMessages = []; if (parsedBody) { candidateMessages.push(...collectRetryableMessageCandidates(parsedBody)); } const trimmedBodyText = `${bodyText || ""}`.trim(); if (trimmedBodyText) { candidateMessages.push(trimmedBodyText); } for (const candidate of candidateMessages) { const normalizedCandidate = candidate.toLowerCase(); for (const pattern of normalizedPatterns) { if (normalizedCandidate.includes(pattern.normalized)) { return { matched_pattern: pattern.original, matched_message: truncateRetryableMessage(candidate), }; } } } return null; } 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"; } function isUpstreamErrorStatus(statusCode) { return Number.isInteger(statusCode) && statusCode >= 400; } function computeRetryBackoffMs(config, attempt) { const baseDelay = normalizeNonNegativeInteger( config?.upstream_fetch_retry_backoff_ms, DEFAULT_CONFIG.upstream_fetch_retry_backoff_ms, ); if (baseDelay <= 0 || attempt <= 1) { return baseDelay; } const multiplier = Math.min(attempt - 1, 4); return baseDelay * multiplier; } async function fetchUpstreamWithRetry(upstreamUrl, init, config, logger, requestContext = {}) { const maxAttempts = normalizePositiveInteger( config?.upstream_fetch_retry_attempts, DEFAULT_CONFIG.upstream_fetch_retry_attempts, ); const method = `${requestContext.method || init?.method || "GET"}`.toUpperCase(); const pathname = requestContext.pathname || ""; let lastError = null; let lastResponse = null; let retryableUpstreamError = null; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { const response = await fetch(upstreamUrl, init); lastResponse = response; if (method === "GET") { return { response, attempt_count: attempt, retryable_upstream_error: null, }; } if (!isUpstreamErrorStatus(response.status)) { return { response, attempt_count: attempt, retryable_upstream_error: null, }; } const contentType = response.headers.get("content-type"); if (!isJsonContentType(contentType)) { return { response, attempt_count: attempt, retryable_upstream_error: null, }; } const bodyBuffer = Buffer.from(await response.arrayBuffer()); const parsed = parseJsonSafely(bodyBuffer); const bodyText = bodyBuffer.toString("utf8"); retryableUpstreamError = findRetryableUpstreamErrorMatch( config, response.status, parsed, bodyText, ); if (!retryableUpstreamError) { return { response: rebuildBufferedResponse(response, bodyBuffer), attempt_count: attempt, retryable_upstream_error: null, }; } if (attempt === maxAttempts) { return { response: rebuildBufferedResponse(response, bodyBuffer), attempt_count: attempt, retryable_upstream_error: { ...retryableUpstreamError, upstream_status_code: response.status, }, }; } const backoffMs = computeRetryBackoffMs(config, attempt); logger?.( `[retry] upstream retryable error attempt=${attempt} next_attempt=${attempt + 1} status=${response.status} path=${pathname || "-"} reason=${JSON.stringify(retryableUpstreamError.matched_pattern)} backoff_ms=${backoffMs}`, ); if (backoffMs > 0) { await sleep(backoffMs); } } catch (error) { lastError = error; if (error && typeof error === "object") { error.gatewayAttemptCount = attempt; } if (!isRetryableUpstreamFetchError(error) || attempt === maxAttempts) { break; } const backoffMs = computeRetryBackoffMs(config, attempt); logger?.( `[retry] upstream fetch failed attempt=${attempt} next_attempt=${attempt + 1} method=${method} path=${pathname || "-"} url=${upstreamUrl} backoff_ms=${backoffMs}`, ); if (backoffMs > 0) { await sleep(backoffMs); } } } if (lastError) { throw lastError; } return { response: lastResponse, attempt_count: maxAttempts, retryable_upstream_error: retryableUpstreamError, }; } function inspectSseChunk(state, chunk) { const decoded = state.decoder.decode(chunk, { stream: true }); state.buffer += decoded; const result = { reasoning: null, usage: null, }; 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) { result.reasoning = reasoning; } result.usage = mergeUsageSnapshots(result.usage, normalizeUsageSnapshot(parsed)); } catch { // ignore malformed SSE payloads } } return result; } async function handleNonStreaming({ runtime, config, logger, monitor, pathname, upstreamResponse, res, requestEntry, terminalRetryableUpstreamError = null, }) { markAndPersistFirstResponse(runtime, requestEntry); const bodyBuffer = Buffer.from(await upstreamResponse.arrayBuffer()); const bodyText = bodyBuffer.toString("utf8"); const parsed = isJsonContentType(upstreamResponse.headers.get("content-type")) ? parseJsonSafely(bodyBuffer) : null; const reasoning = parsed ? extractReasoningTokens(parsed) : null; const usage = parsed ? normalizeUsageSnapshot(parsed) : null; const matched = reasoningMatched(config, reasoning); const retryableUpstreamError = terminalRetryableUpstreamError || findRetryableUpstreamErrorMatch( config, upstreamResponse.status, parsed, bodyText, ); recordInspectedResponse(monitor, reasoning, matched || Boolean(retryableUpstreamError)); 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 { inspected: true, matched, status_code: config.non_stream_status_code, upstream_status_code: upstreamResponse.status, reasoning_tokens: reasoning, usage, }; } if (retryableUpstreamError) { if (config.log_match) { logger( `[match] non-stream path=${pathname} upstream_status=${upstreamResponse.status} retryable_error=${JSON.stringify(retryableUpstreamError.matched_pattern)} action=status_${config.non_stream_status_code}`, ); } const blockedBody = buildRetryableUpstreamErrorBody( pathname, upstreamResponse.status, retryableUpstreamError.matched_message || retryableUpstreamError.matched_pattern, config.non_stream_status_code, ); res.writeHead(config.non_stream_status_code, { "content-type": "application/json; charset=utf-8", "x-codex-retry-gateway-reason": "upstream-error-retry-triggered", }); res.end(blockedBody); return { inspected: true, matched: true, status_code: config.non_stream_status_code, upstream_status_code: upstreamResponse.status, reasoning_tokens: reasoning, usage, error: `retryable upstream error: ${retryableUpstreamError.matched_pattern}`, match_reason: "retryable_upstream_error", }; } copyHeadersToClient(upstreamResponse.headers, res); res.writeHead(upstreamResponse.status); res.end(bodyBuffer); return { inspected: true, matched, status_code: upstreamResponse.status, upstream_status_code: upstreamResponse.status, reasoning_tokens: reasoning, usage, }; } async function handleStreaming({ runtime, config, logger, monitor, pathname, upstreamResponse, res, abortController, requestEntry, }) { const strict502Mode = config.stream_action !== "disconnect"; const reader = upstreamResponse.body.getReader(); const sseState = { decoder: new TextDecoder("utf8"), buffer: "", }; let wroteAnyChunk = false; let observedReasoning = null; let observedUsage = 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); persistStreamingProgress(runtime, requestEntry, { force: true }, new Date()); 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")); return { inspected: true, matched: false, status_code: 502, upstream_status_code: upstreamResponse.status, reasoning_tokens: observedReasoning, usage: observedUsage, error: "upstream stream terminated before completion", response_bytes_received: requestEntry.response_bytes_received, stream_chunk_count: requestEntry.stream_chunk_count, }; } else { res.end(); return { inspected: true, matched: false, status_code: upstreamResponse.status, upstream_status_code: upstreamResponse.status, reasoning_tokens: observedReasoning, usage: observedUsage, error: "upstream stream terminated before completion", response_bytes_received: requestEntry.response_bytes_received, stream_chunk_count: requestEntry.stream_chunk_count, }; } } throw error; } const { done, value } = readResult; if (done) { recordInspectedResponse(monitor, observedReasoning, false); persistStreamingProgress(runtime, requestEntry, { force: true }, new Date()); if (strict502Mode) { copyHeadersToClient(upstreamResponse.headers, res); res.writeHead(upstreamResponse.status); res.end(Buffer.concat(bufferedChunks)); } else { res.end(); } return { inspected: true, matched: false, status_code: upstreamResponse.status, upstream_status_code: upstreamResponse.status, reasoning_tokens: observedReasoning, usage: observedUsage, response_bytes_received: requestEntry.response_bytes_received, stream_chunk_count: requestEntry.stream_chunk_count, }; } const chunkBuffer = Buffer.from(value); const now = new Date(); markAndPersistFirstResponse(runtime, requestEntry, now); const inspection = inspectSseChunk(sseState, value); const reasoning = inspection.reasoning; const usageUpdated = Boolean(inspection.usage); observedUsage = mergeUsageSnapshots(observedUsage, inspection.usage); if (Number.isInteger(reasoning)) { observedReasoning = reasoning; } updateStreamingProgress(requestEntry, { chunkBytes: chunkBuffer.length, usage: inspection.usage, reasoning, at: now, }); persistStreamingProgress( runtime, requestEntry, { usageUpdated, force: requestEntry.stream_chunk_count === 1 }, now, ); 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 { inspected: true, matched: true, status_code: config.non_stream_status_code, upstream_status_code: upstreamResponse.status, reasoning_tokens: reasoning, usage: observedUsage, response_bytes_received: requestEntry.response_bytes_received, stream_chunk_count: requestEntry.stream_chunk_count, }; } 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 requestStartedAt = new Date(); const requestStartedMs = Date.now(); const incomingUrl = new URL(req.url, `http://${req.headers.host || "127.0.0.1"}`); const pathname = normalizePath(incomingUrl.pathname); if (pathname === "/favicon.ico") { res.writeHead(204, { "cache-control": "public, max-age=86400" }); res.end(); return; } 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 requestSeq = runtime.monitor.next_request_seq; runtime.monitor.next_request_seq += 1; const requestEntry = buildRequestEntry({ seq: requestSeq, startedAt: requestStartedAt, startedMs: requestStartedMs, req, pathname, requestJson: null, profileName: config.profile_name || "default", }); upsertRequestEntry(runtime, requestEntry); try { const rawRequestBody = await readRequestBody(req, config.request_body_limit_bytes); const parsedRequestJson = isJsonContentType(req.headers["content-type"]) ? parseJsonSafely(rawRequestBody) : null; const { requestJson, remapped, forwardedModel } = remapRequestModel(config, parsedRequestJson); const requestBody = remapped ? Buffer.from(JSON.stringify(requestJson)) : rawRequestBody; const requestIsStream = Boolean(requestJson?.stream); requestEntry.request_body_bytes = rawRequestBody.length; requestEntry.model = requestJson?.model || null; requestEntry.requested_model = parsedRequestJson?.model || null; requestEntry.forwarded_model = forwardedModel || parsedRequestJson?.model || null; requestEntry.model = requestEntry.forwarded_model; requestEntry.request_stream = requestIsStream; upsertRequestEntry(runtime, requestEntry); const upstreamUrl = buildUpstreamUrl(config.upstream_base_url, incomingUrl); const abortController = new AbortController(); const upstreamAuth = await resolveUpstreamAuth(config); requestEntry.upstream = buildUpstreamSnapshot({ upstreamUrl, upstreamAuth }); upsertRequestEntry(runtime, requestEntry); if (remapped && requestEntry.requested_model && requestEntry.forwarded_model) { logger?.( `[model-remap] profile=${config.profile_name || "default"} requested=${requestEntry.requested_model} forwarded=${requestEntry.forwarded_model}`, ); } const { response: upstreamResponse, attempt_count: upstreamAttemptCount, retryable_upstream_error: terminalRetryableUpstreamError, } = await fetchUpstreamWithRetry(upstreamUrl, { method: req.method, headers: cloneHeadersForUpstream(req.headers, upstreamAuth), body: requestBody.length > 0 ? requestBody : undefined, signal: abortController.signal, }, config, logger, { method: req.method, pathname }); const shouldInspect = matchPath(config, pathname); const responseContentType = upstreamResponse.headers.get("content-type"); const responseIsStream = isSseContentType(responseContentType) || ( requestIsStream && !isJsonContentType(responseContentType) && !isUpstreamErrorStatus(upstreamResponse.status) ); requestEntry.response_stream = responseIsStream; requestEntry.inspected = shouldInspect; requestEntry.upstream_status_code = upstreamResponse.status; requestEntry.upstream_attempt_count = upstreamAttemptCount; requestEntry.upstream = buildUpstreamSnapshot({ upstreamUrl, upstreamAuth, upstreamResponse }); upsertRequestEntry(runtime, requestEntry); if (isUpstreamErrorStatus(upstreamResponse.status)) { logger?.( `[upstream] status=${upstreamResponse.status} profile=${config.profile_name || "default"} path=${requestEntry.upstream.path} auth=${requestEntry.upstream.auth_mode}/${requestEntry.upstream.auth_source} content_type=${requestEntry.upstream.content_type || "-"}`, ); } if (!shouldInspect) { markRequestFirstResponse(requestEntry); upsertRequestEntry(runtime, requestEntry); copyHeadersToClient(upstreamResponse.headers, res); res.writeHead(upstreamResponse.status); const body = Buffer.from(await upstreamResponse.arrayBuffer()); res.end(body); recordRequestEntry( runtime, finalizeRequestEntry(requestEntry, { status_code: upstreamResponse.status, upstream_status_code: upstreamResponse.status, inspected: false, }), config.request_history_limit, ); return; } if (responseIsStream) { const result = await handleStreaming({ runtime, config, logger, monitor: runtime.monitor, pathname, upstreamResponse, res, abortController, requestEntry, }); recordRequestEntry( runtime, finalizeRequestEntry(requestEntry, { response_stream: true, ...result, }), config.request_history_limit, ); return; } const result = await handleNonStreaming({ runtime, config, logger, monitor: runtime.monitor, pathname, upstreamResponse, res, requestEntry, terminalRetryableUpstreamError, }); recordRequestEntry( runtime, finalizeRequestEntry(requestEntry, { response_stream: false, ...result, }), config.request_history_limit, ); return; } catch (error) { if (Number.isInteger(error?.gatewayAttemptCount)) { requestEntry.upstream_attempt_count = error.gatewayAttemptCount; } recordRequestEntry( runtime, finalizeRequestEntry(requestEntry, { status_code: res.headersSent ? null : 502, error: `${error?.message || error}`, }), config.request_history_limit, ); throw error; } } 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 requestsDb = openRequestsDatabase(buildRuntimePaths(configPath, args.log || null).requestsDbPath); const runtime = { config, configPath, logPath: args.log || null, logger, monitor, paths: buildRuntimePaths(configPath, args.log || null), requestsDb, server: null, }; const importedCount = await importRequestsJsonlToDb(requestsDb, runtime.paths.requestsPath); await hydrateMonitorFromDisk(monitor, runtime.paths, config.request_history_limit, requestsDb); logger(`[start] hydrated persistent request metrics from db path=${runtime.paths.requestsDbPath}`); logger(`[start] requests db ready path=${runtime.paths.requestsDbPath} imported_jsonl_rows=${importedCount}`); 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; let shuttingDown = false; const shutdown = (signal) => { if (shuttingDown) { return; } shuttingDown = true; logger(`[stop] received ${signal}, closing gateway`); server.close(() => { process.exit(0); }); const hardExitTimer = setTimeout(() => { logger("[stop] forced exit after graceful shutdown timeout"); process.exit(signal === "SIGTERM" || signal === "SIGINT" ? 0 : 1); }, 5000); hardExitTimer.unref(); }; process.on("SIGTERM", () => shutdown("SIGTERM")); process.on("SIGINT", () => shutdown("SIGINT")); server.listen(config.listen_port, config.listen_host, () => { updateRuntimeState(runtime, { last_started_at: new Date().toISOString(), profile_name: config.profile_name || "default", gateway_base_url: `http://${config.listen_host}:${config.listen_port}`, }).catch((error) => logger(`[state] failed to update runtime state: ${error?.message || error}`)); logger( `[start] codex retry gateway profile=${config.profile_name || "default"} auth=${normalizeAuthMode(config.upstream_auth_mode)} 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); });