Files
codex-retry-gateway/scripts/admin-lib.mjs
T

643 lines
22 KiB
JavaScript

#!/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");
const DEFAULT_REQUEST_BODY_LIMIT_BYTES = 1024 * 1024 * 1024;
const LEGACY_DEFAULT_REQUEST_BODY_LIMIT_BYTES = 10 * 1024 * 1024;
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
? DEFAULT_REQUEST_BODY_LIMIT_BYTES
: (
Number.parseInt(`${existingGatewayConfig.request_body_limit_bytes}`, 10) === LEGACY_DEFAULT_REQUEST_BODY_LIMIT_BYTES
? DEFAULT_REQUEST_BODY_LIMIT_BYTES
: 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.",
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later",
]),
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,
};
}