#!/usr/bin/env node import { spawn } from "node:child_process"; import fs from "node:fs"; import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; export const DEFAULT_STATE_ROOT = path.join(os.homedir(), ".codex-retry-gateway"); export const DEFAULT_CODEX_CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml"); export const DEFAULT_LISTEN_HOST = "127.0.0.1"; export const DEFAULT_LISTEN_PORT = 4610; export const DEFAULT_HEALTH_PATH = "/__codex_retry_gateway/health"; function escapeRegExp(value) { return `${value}`.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } export function parseOptions(argv, { booleanFlags = [] } = {}) { const options = { _: [] }; const booleanSet = new Set(booleanFlags); for (let index = 2; index < argv.length; index += 1) { const current = argv[index]; if (!current.startsWith("--")) { options._.push(current); continue; } const flagName = current.slice(2); const optionKey = flagName.replace(/-([a-z])/g, (_, char) => char.toUpperCase()); if (booleanSet.has(flagName)) { options[optionKey] = true; continue; } const nextValue = argv[index + 1]; if (nextValue === undefined) { throw new Error(`Missing value for --${flagName}`); } options[optionKey] = nextValue; index += 1; } return options; } export function getGatewayRoot() { return path.resolve(import.meta.dirname, ".."); } export function getGatewayStatePaths(stateRoot = DEFAULT_STATE_ROOT) { return { stateRoot, configDir: path.join(stateRoot, "config"), logDir: path.join(stateRoot, "logs"), backupDir: path.join(stateRoot, "backups"), configPath: path.join(stateRoot, "config", "config.json"), logPath: path.join(stateRoot, "logs", "gateway.log"), statePath: path.join(stateRoot, "state.json"), pidPath: path.join(stateRoot, "gateway.pid"), }; } export function getGatewayBaseUrl(listenHost, listenPort) { return `http://${listenHost}:${listenPort}`; } export function getGatewayBaseUrlFromConfig(gatewayConfig) { if (!gatewayConfig) { return null; } if (!gatewayConfig.listen_host || gatewayConfig.listen_port === undefined || gatewayConfig.listen_port === null) { return null; } return getGatewayBaseUrl(`${gatewayConfig.listen_host}`, Number.parseInt(`${gatewayConfig.listen_port}`, 10)); } export async function ensureDirectory(targetPath) { await mkdir(targetPath, { recursive: true }); } export async function writeUtf8File(targetPath, content) { const parent = path.dirname(targetPath); if (parent && parent !== ".") { await ensureDirectory(parent); } await writeFile(targetPath, content, "utf8"); } export async function readJsonFile(filePath) { if (!fs.existsSync(filePath)) { return null; } const raw = await readFile(filePath, "utf8"); if (!raw.trim()) { return null; } return JSON.parse(raw); } export async function writeJsonFile(filePath, value) { await writeUtf8File(filePath, `${JSON.stringify(value, null, 2)}\n`); } export async function getCodexProviderContext(codexConfigPath) { const content = await readFile(codexConfigPath, "utf8"); const providerMatch = content.match(/^\s*model_provider\s*=\s*"([^"]+)"\s*$/m); if (!providerMatch) { throw new Error(`model_provider was not found in ${codexConfigPath}`); } const providerName = providerMatch[1]; const sectionHeaderRegex = new RegExp(`^\\[model_providers\\.${escapeRegExp(providerName)}\\]\\s*$`, "m"); const sectionHeaderMatch = sectionHeaderRegex.exec(content); if (!sectionHeaderMatch) { throw new Error(`[model_providers.${providerName}] was not found in ${codexConfigPath}`); } const sectionIndex = sectionHeaderMatch.index; const headerEndIndex = sectionIndex + sectionHeaderMatch[0].length; const remainder = content.slice(headerEndIndex); const nextSectionMatch = /^\[.*$/m.exec(remainder); const sectionEndIndex = nextSectionMatch ? headerEndIndex + nextSectionMatch.index : content.length; const sectionText = content.slice(sectionIndex, sectionEndIndex); const baseUrlMatch = sectionText.match(/^\s*base_url\s*=\s*"([^"]+)"\s*$/m); if (!baseUrlMatch) { throw new Error(`base_url was not found in [model_providers.${providerName}]`); } return { content, providerName, sectionText, sectionIndex, sectionLength: sectionText.length, currentBaseUrl: baseUrlMatch[1], baseUrlLineText: baseUrlMatch[0], }; } export async function setCodexProviderBaseUrl({ codexConfigPath, providerName, newBaseUrl }) { const context = await getCodexProviderContext(codexConfigPath); if (context.providerName !== providerName) { throw new Error(`model_provider changed unexpectedly: expected ${providerName}, actual ${context.providerName}`); } let replaced = false; const updatedSection = context.sectionText.replace( /^(\s*base_url\s*=\s*")([^"]*)("\s*)$/m, (_, prefix, __existing, suffix) => { replaced = true; return `${prefix}${newBaseUrl}${suffix}`; }, ); if (!replaced) { throw new Error(`base_url was not found in [model_providers.${providerName}]`); } const updatedContent = context.content.slice(0, context.sectionIndex) + updatedSection + context.content.slice(context.sectionIndex + context.sectionLength); await writeUtf8File(codexConfigPath, updatedContent); } export function normalizeIntArray(values, fallback = [516]) { const source = values === undefined || values === null ? fallback : values; const queue = Array.isArray(source) ? source.flat(Infinity) : [source]; const normalized = queue .map((value) => (typeof value === "string" ? value.split(/[\s,]+/).filter(Boolean) : [value])) .flat() .map((value) => Number.parseInt(`${value}`, 10)) .filter((value) => Number.isInteger(value)); return normalized.length > 0 ? [...new Set(normalized)] : [...fallback]; } export function normalizeStringArray(values, fallback = []) { const source = values === undefined || values === null ? fallback : values; const queue = Array.isArray(source) ? source.flat(Infinity) : [source]; const normalized = queue .flatMap((value) => `${value ?? ""}`.split(/[\s,]+/)) .map((value) => value.trim()) .filter(Boolean); return normalized.length > 0 ? [...new Set(normalized)] : [...fallback]; } export function normalizePhraseArray(values, fallback = []) { const source = values === undefined || values === null ? fallback : values; const queue = Array.isArray(source) ? source.flat(Infinity) : [source]; const normalized = queue .flatMap((value) => (typeof value === "string" ? value.split(/\r?\n/) : [value])) .map((value) => `${value ?? ""}`.trim()) .filter(Boolean); return normalized.length > 0 ? [...new Set(normalized)] : [...fallback]; } export function isProcessAlive(processId) { try { process.kill(processId, 0); return true; } catch { return false; } } export async function waitGatewayHealth({ listenHost, listenPort, healthPath, timeoutSeconds = 10, }) { const deadline = Date.now() + timeoutSeconds * 1000; const healthUrl = `${getGatewayBaseUrl(listenHost, listenPort)}${healthPath}`; while (Date.now() < deadline) { try { const response = await fetch(healthUrl, { signal: AbortSignal.timeout(2000) }); if (response.status === 200) { return response; } } catch { // ignore and retry } await new Promise((resolve) => setTimeout(resolve, 200)); } throw new Error(`Gateway health check timed out: ${healthUrl}`); } async function readTail(filePath, lineCount = 20) { if (!fs.existsSync(filePath)) { return ""; } const raw = await readFile(filePath, "utf8"); return raw.split(/\r?\n/).slice(-lineCount).join("\n").trim(); } function openUrl(url) { let command; let args; if (process.platform === "win32") { command = "cmd"; args = ["/c", "start", "", url]; } else if (process.platform === "darwin") { command = "open"; args = [url]; } else { command = "xdg-open"; args = [url]; } const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true, }); child.unref(); } export async function stopGateway({ stateRoot = DEFAULT_STATE_ROOT, quiet = false }) { const paths = getGatewayStatePaths(stateRoot); if (!fs.existsSync(paths.pidPath)) { return quiet ? null : "No running gateway PID file was found."; } const pidRaw = (await readFile(paths.pidPath, "utf8")).trim(); if (!pidRaw) { await rm(paths.pidPath, { force: true }); return quiet ? null : "Gateway PID file was empty and has been removed."; } const gatewayPid = Number.parseInt(pidRaw, 10); if (Number.isInteger(gatewayPid) && isProcessAlive(gatewayPid)) { try { process.kill(gatewayPid); } catch { // ignore first failure } const deadline = Date.now() + 3000; while (Date.now() < deadline && isProcessAlive(gatewayPid)) { await new Promise((resolve) => setTimeout(resolve, 100)); } if (isProcessAlive(gatewayPid)) { try { process.kill(gatewayPid, "SIGKILL"); } catch { // ignore hard kill failure } } } await rm(paths.pidPath, { force: true }); return quiet ? null : `Gateway stopped. PID=${gatewayPid}`; } export async function startGateway({ stateRoot = DEFAULT_STATE_ROOT, configPath, logPath, restartIfRunning = false, }) { const paths = getGatewayStatePaths(stateRoot); const effectiveConfigPath = configPath || paths.configPath; const effectiveLogPath = logPath || paths.logPath; if (!fs.existsSync(effectiveConfigPath)) { throw new Error(`Gateway config file was not found: ${effectiveConfigPath}`); } await ensureDirectory(path.dirname(effectiveLogPath)); if (fs.existsSync(paths.pidPath)) { const existingPidRaw = (await readFile(paths.pidPath, "utf8")).trim(); if (existingPidRaw) { const existingPid = Number.parseInt(existingPidRaw, 10); if (Number.isInteger(existingPid) && isProcessAlive(existingPid)) { if (restartIfRunning) { await stopGateway({ stateRoot, quiet: true }); } else { return `Gateway is already running. PID=${existingPid}`; } } else { await rm(paths.pidPath, { force: true }); } } } const gatewayConfig = await readJsonFile(effectiveConfigPath); if (!gatewayConfig) { throw new Error(`Gateway config file could not be read: ${effectiveConfigPath}`); } const gatewayRoot = getGatewayRoot(); const gatewayEntry = path.join(gatewayRoot, "gateway.mjs"); if (!fs.existsSync(gatewayEntry)) { throw new Error(`Gateway entry file was not found: ${gatewayEntry}`); } const child = spawn(process.execPath, [gatewayEntry, "--config", effectiveConfigPath, "--log", effectiveLogPath], { cwd: gatewayRoot, detached: true, stdio: "ignore", windowsHide: true, }); child.unref(); await writeUtf8File(paths.pidPath, `${child.pid}`); await new Promise((resolve) => setTimeout(resolve, 300)); if (!isProcessAlive(child.pid)) { const logTail = await readTail(effectiveLogPath, 20); throw new Error(`Gateway exited right after startup. PID=${child.pid}\n${logTail}`); } await waitGatewayHealth({ listenHost: `${gatewayConfig.listen_host}`, listenPort: Number.parseInt(`${gatewayConfig.listen_port}`, 10), healthPath: `${gatewayConfig.health_path || DEFAULT_HEALTH_PATH}`, }); return `Gateway started. PID=${child.pid}. Listen=${getGatewayBaseUrl(gatewayConfig.listen_host, gatewayConfig.listen_port)}`; } export async function installForCurrentProvider({ codexConfigPath = DEFAULT_CODEX_CONFIG_PATH, stateRoot = DEFAULT_STATE_ROOT, listenHost = DEFAULT_LISTEN_HOST, listenPort = DEFAULT_LISTEN_PORT, }) { const paths = getGatewayStatePaths(stateRoot); await ensureDirectory(paths.stateRoot); await ensureDirectory(paths.configDir); await ensureDirectory(paths.logDir); await ensureDirectory(paths.backupDir); if (!fs.existsSync(codexConfigPath)) { throw new Error(`Codex config file was not found: ${codexConfigPath}`); } const providerContext = await getCodexProviderContext(codexConfigPath); const localGatewayBaseUrl = getGatewayBaseUrl(listenHost, listenPort); const existingState = await readJsonFile(paths.statePath); let originalBaseUrl = providerContext.currentBaseUrl; if (providerContext.currentBaseUrl === localGatewayBaseUrl) { if (!existingState?.original_base_url) { throw new Error("Provider already points to the local gateway, but original_base_url is missing from state."); } originalBaseUrl = `${existingState.original_base_url}`; } if (originalBaseUrl === localGatewayBaseUrl) { throw new Error("A real upstream_base_url could not be determined."); } const backupPath = path.join(paths.backupDir, `config-${new Date().toISOString().replace(/[:.]/g, "").replace("T", "-").slice(0, 15)}.toml`); await copyFile(codexConfigPath, backupPath); const existingGatewayConfig = await readJsonFile(paths.configPath); const defaultEndpoints = ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"]; const mergedEndpoints = []; for (const endpoint of [ ...normalizeStringArray(existingGatewayConfig?.endpoints, []), ...defaultEndpoints, ]) { if (!mergedEndpoints.includes(endpoint)) { mergedEndpoints.push(endpoint); } } const gatewayConfig = { listen_host: listenHost, listen_port: listenPort, upstream_base_url: originalBaseUrl, request_body_limit_bytes: existingGatewayConfig?.request_body_limit_bytes === undefined || existingGatewayConfig?.request_body_limit_bytes === null ? 10485760 : Number.parseInt(`${existingGatewayConfig.request_body_limit_bytes}`, 10), endpoints: mergedEndpoints, reasoning_equals: normalizeIntArray(existingGatewayConfig?.reasoning_equals, [516]), retryable_status_codes: normalizeIntArray(existingGatewayConfig?.retryable_status_codes, [429, 503]), retryable_error_messages: normalizePhraseArray(existingGatewayConfig?.retryable_error_messages, [ "Selected model is at capacity. Please try a different model.", ]), non_stream_status_code: existingGatewayConfig?.non_stream_status_code === undefined || existingGatewayConfig?.non_stream_status_code === null ? 502 : Number.parseInt(`${existingGatewayConfig.non_stream_status_code}`, 10), stream_action: existingGatewayConfig?.stream_action || "strict_502", log_match: existingGatewayConfig?.log_match === undefined ? true : Boolean(existingGatewayConfig.log_match), health_path: existingGatewayConfig?.health_path || DEFAULT_HEALTH_PATH, }; const previousConfigContent = await readFile(codexConfigPath, "utf8"); try { await writeJsonFile(paths.configPath, gatewayConfig); await setCodexProviderBaseUrl({ codexConfigPath, providerName: providerContext.providerName, newBaseUrl: localGatewayBaseUrl, }); await startGateway({ stateRoot, configPath: paths.configPath, logPath: paths.logPath, restartIfRunning: true, }); const state = { installed_at: new Date().toISOString(), codex_config_path: codexConfigPath, provider_name: providerContext.providerName, original_base_url: originalBaseUrl, gateway_base_url: localGatewayBaseUrl, gateway_config_path: paths.configPath, gateway_log_path: paths.logPath, gateway_pid_path: paths.pidPath, latest_backup_path: backupPath, state_root: paths.stateRoot, }; await writeJsonFile(paths.statePath, state); return { provider: providerContext.providerName, upstream: originalBaseUrl, gateway: localGatewayBaseUrl, configPath: paths.configPath, backupPath, }; } catch (error) { await writeUtf8File(codexConfigPath, previousConfigContent); await stopGateway({ stateRoot, quiet: true }); throw error; } } export async function restoreCodexConfig({ stateRoot = DEFAULT_STATE_ROOT, codexConfigPath = DEFAULT_CODEX_CONFIG_PATH, }) { const paths = getGatewayStatePaths(stateRoot); const state = await readJsonFile(paths.statePath); if (!state) { throw new Error(`Install state file was not found: ${paths.statePath}`); } const backupPath = `${state.latest_backup_path || ""}`; if (!backupPath || !fs.existsSync(backupPath)) { throw new Error(`A restorable backup file was not found: ${backupPath}`); } await stopGateway({ stateRoot, quiet: true }); await copyFile(backupPath, codexConfigPath); await rm(paths.statePath, { force: true }); return { configPath: codexConfigPath, restoredFrom: backupPath, }; } export async function launchUi({ codexConfigPath = DEFAULT_CODEX_CONFIG_PATH, stateRoot = DEFAULT_STATE_ROOT, listenHost = DEFAULT_LISTEN_HOST, listenPort = DEFAULT_LISTEN_PORT, noOpen = false, }) { const paths = getGatewayStatePaths(stateRoot); await ensureDirectory(paths.stateRoot); await ensureDirectory(paths.configDir); await ensureDirectory(paths.logDir); await ensureDirectory(paths.backupDir); if (!fs.existsSync(codexConfigPath)) { throw new Error(`Codex config file was not found: ${codexConfigPath}`); } const providerContext = await getCodexProviderContext(codexConfigPath); const currentBaseUrl = `${providerContext.currentBaseUrl}`; const requestedGatewayBaseUrl = getGatewayBaseUrl(listenHost, listenPort); const existingState = await readJsonFile(paths.statePath); const existingGatewayConfig = await readJsonFile(paths.configPath); const stateGatewayBaseUrl = existingState?.gateway_base_url ? `${existingState.gateway_base_url}` : null; const configGatewayBaseUrl = getGatewayBaseUrlFromConfig(existingGatewayConfig); const managedGatewayBaseUrls = [requestedGatewayBaseUrl]; for (const candidate of [stateGatewayBaseUrl, configGatewayBaseUrl]) { if (candidate && !managedGatewayBaseUrls.includes(candidate)) { managedGatewayBaseUrls.push(candidate); } } const originalBaseUrl = existingState?.original_base_url ? `${existingState.original_base_url}` : existingGatewayConfig?.upstream_base_url ? `${existingGatewayConfig.upstream_base_url}` : null; const canReuseExistingInstall = existingGatewayConfig && originalBaseUrl && managedGatewayBaseUrls.includes(currentBaseUrl); let mode = "install"; if (!canReuseExistingInstall) { await installForCurrentProvider({ codexConfigPath, stateRoot, listenHost, listenPort, }); } else { mode = "reuse"; const previousCodexConfigContent = await readFile(codexConfigPath, "utf8"); const previousGatewayConfigContent = fs.existsSync(paths.configPath) ? await readFile(paths.configPath, "utf8") : null; const previousStateContent = fs.existsSync(paths.statePath) ? await readFile(paths.statePath, "utf8") : null; try { existingGatewayConfig.listen_host = listenHost; existingGatewayConfig.listen_port = listenPort; if (!existingGatewayConfig.health_path) { existingGatewayConfig.health_path = DEFAULT_HEALTH_PATH; } await writeJsonFile(paths.configPath, existingGatewayConfig); if (currentBaseUrl !== requestedGatewayBaseUrl) { await setCodexProviderBaseUrl({ codexConfigPath, providerName: providerContext.providerName, newBaseUrl: requestedGatewayBaseUrl, }); } await startGateway({ stateRoot, configPath: paths.configPath, logPath: paths.logPath, restartIfRunning: true, }); const statePayload = { installed_at: existingState?.installed_at ? `${existingState.installed_at}` : new Date().toISOString(), last_started_at: new Date().toISOString(), codex_config_path: codexConfigPath, provider_name: providerContext.providerName, original_base_url: originalBaseUrl, gateway_base_url: requestedGatewayBaseUrl, gateway_config_path: paths.configPath, gateway_log_path: paths.logPath, gateway_pid_path: paths.pidPath, latest_backup_path: existingState?.latest_backup_path ? `${existingState.latest_backup_path}` : "", state_root: paths.stateRoot, }; await writeJsonFile(paths.statePath, statePayload); } catch (error) { await writeUtf8File(codexConfigPath, previousCodexConfigContent); if (previousGatewayConfigContent !== null) { await writeUtf8File(paths.configPath, previousGatewayConfigContent); } if (previousStateContent !== null) { await writeUtf8File(paths.statePath, previousStateContent); } await stopGateway({ stateRoot, quiet: true }); throw error; } } const effectiveGatewayConfig = await readJsonFile(paths.configPath); const effectiveGatewayBaseUrl = getGatewayBaseUrlFromConfig(effectiveGatewayConfig) || requestedGatewayBaseUrl; const uiUrl = `${effectiveGatewayBaseUrl}/__codex_retry_gateway/ui`; if (!noOpen) { openUrl(uiUrl); } return { mode, uiUrl, gatewayBaseUrl: effectiveGatewayBaseUrl, }; }