Files
codex-retry-gateway/scripts/run-profile.mjs
T

401 lines
14 KiB
JavaScript

#!/usr/bin/env node
import { spawn } from "node:child_process";
import fs from "node:fs";
import { copyFile, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
DEFAULT_CODEX_CONFIG_PATH,
DEFAULT_HEALTH_PATH,
DEFAULT_LISTEN_HOST,
DEFAULT_LISTEN_PORT,
DEFAULT_STATE_ROOT,
ensureDirectory,
getCodexProviderContext,
getGatewayBaseUrl,
getGatewayStatePaths,
normalizeIntArray,
normalizePhraseArray,
normalizeStringArray,
parseOptions,
readJsonFile,
setCodexProviderBaseUrl,
waitGatewayHealth,
writeJsonFile,
writeUtf8File,
} from "./admin-lib.mjs";
const DEFAULT_PROFILES_DIR = path.join(os.homedir(), ".config", "codex-retry-gateway", "profiles");
const DEFAULT_REQUEST_BODY_LIMIT_BYTES = 1024 * 1024 * 1024;
const LEGACY_DEFAULT_REQUEST_BODY_LIMIT_BYTES = 10 * 1024 * 1024;
function parseEnvFile(content) {
const parsed = {};
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);
}
parsed[key] = value;
}
return parsed;
}
function getProfileName(options) {
const positional = options._?.[0];
return `${options.profile || positional || process.env.CODEX_RETRY_GATEWAY_PROFILE || ""}`.trim();
}
function resolveDefaultRequestedProfileName(existingState) {
const stateProfileName = `${existingState?.profile_name || ""}`.trim();
if (stateProfileName) {
return stateProfileName;
}
return process.env.CODEX_RETRY_GATEWAY_DEFAULT_PROFILE || "default";
}
function resolvePreferredProfileName({ requestedProfileName, existingState, preferStateProfile, profilesDir }) {
if (!preferStateProfile) {
return requestedProfileName;
}
const stateProfileName = `${existingState?.profile_name || ""}`.trim();
if (!stateProfileName) {
return requestedProfileName;
}
const stateProfilePath = path.join(profilesDir, `${stateProfileName}.env`);
if (!fs.existsSync(stateProfilePath)) {
return requestedProfileName;
}
return stateProfileName;
}
function boolFromEnv(value, fallback = false) {
if (value === undefined || value === null || value === "") {
return fallback;
}
return ["1", "true", "yes", "on"].includes(`${value}`.trim().toLowerCase());
}
function normalizeRequestBodyLimitBytes(value) {
const parsed = Number.parseInt(`${value ?? ""}`, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_REQUEST_BODY_LIMIT_BYTES;
}
if (parsed === LEGACY_DEFAULT_REQUEST_BODY_LIMIT_BYTES) {
return DEFAULT_REQUEST_BODY_LIMIT_BYTES;
}
return parsed;
}
function normalizeAuthMode(value) {
const mode = `${value || "passthrough"}`.trim().toLowerCase();
if (["passthrough", "fixed_bearer", "manual_bearer", "auth_json"].includes(mode)) {
return mode;
}
return "passthrough";
}
function inferProfileAuthMode(profileEnv) {
if (profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE) {
return normalizeAuthMode(profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE);
}
if (
profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_PATH ||
profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_KEY
) {
return "auth_json";
}
if (
profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE ||
profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV
) {
return "fixed_bearer";
}
return "passthrough";
}
function buildProfileAuthConfig(profileEnv) {
const authMode = inferProfileAuthMode(profileEnv);
const authConfig = {
upstream_auth_mode: authMode,
upstream_auth_env: "",
upstream_auth_file: "",
upstream_auth_json_path: "",
upstream_auth_json_key: "",
};
if (authMode === "fixed_bearer") {
authConfig.upstream_auth_env =
profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV ||
"CODEX_RETRY_GATEWAY_UPSTREAM_API_KEY";
authConfig.upstream_auth_file = profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE || "";
} else if (authMode === "manual_bearer") {
authConfig.upstream_auth_file = profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE || "";
} else if (authMode === "auth_json") {
authConfig.upstream_auth_json_path = profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_PATH || "";
authConfig.upstream_auth_json_key =
profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_KEY ||
"OPENAI_API_KEY";
}
return authConfig;
}
async function loadProfileEnv(profileName, profilesDir) {
const profilePath = path.join(profilesDir, `${profileName}.env`);
if (!fs.existsSync(profilePath)) {
throw new Error(`Profile env file was not found: ${profilePath}`);
}
const content = await readFile(profilePath, "utf8");
return {
profilePath,
env: parseEnvFile(content),
};
}
function buildProfileConfig({ profileName, profileEnv, existingGatewayConfig, providerContext, localGatewayBaseUrl }) {
const upstreamBaseUrl =
profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL ||
existingGatewayConfig?.upstream_base_url ||
(providerContext.currentBaseUrl === localGatewayBaseUrl
? null
: providerContext.currentBaseUrl);
if (!upstreamBaseUrl) {
throw new Error("A real upstream base_url could not be determined for this profile.");
}
const profileAuthConfig = buildProfileAuthConfig(profileEnv);
return {
profile_name: profileName,
listen_host: profileEnv.CODEX_RETRY_GATEWAY_LISTEN_HOST || DEFAULT_LISTEN_HOST,
listen_port: profileEnv.CODEX_RETRY_GATEWAY_LISTEN_PORT
? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_LISTEN_PORT}`, 10)
: DEFAULT_LISTEN_PORT,
upstream_base_url: upstreamBaseUrl,
...profileAuthConfig,
request_body_limit_bytes: profileEnv.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES
? normalizeRequestBodyLimitBytes(profileEnv.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES)
: normalizeRequestBodyLimitBytes(existingGatewayConfig?.request_body_limit_bytes),
request_history_limit: profileEnv.CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT
? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT}`, 10)
: Number.parseInt(`${existingGatewayConfig?.request_history_limit || 200}`, 10),
model_remap: profileEnv.CODEX_RETRY_GATEWAY_MODEL_REMAP || existingGatewayConfig?.model_remap || "",
endpoints: normalizeStringArray(
profileEnv.CODEX_RETRY_GATEWAY_ENDPOINTS || existingGatewayConfig?.endpoints,
["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
),
reasoning_equals: normalizeIntArray(
profileEnv.CODEX_RETRY_GATEWAY_REASONING_EQUALS || existingGatewayConfig?.reasoning_equals,
[516],
),
retryable_status_codes: normalizeIntArray(
profileEnv.CODEX_RETRY_GATEWAY_RETRYABLE_STATUS_CODES || existingGatewayConfig?.retryable_status_codes,
[429, 503],
),
retryable_error_messages: normalizePhraseArray(
profileEnv.CODEX_RETRY_GATEWAY_RETRYABLE_ERROR_MESSAGES || 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",
],
),
management_access_key:
profileEnv.CODEX_RETRY_GATEWAY_MANAGEMENT_ACCESS_KEY ||
existingGatewayConfig?.management_access_key ||
"",
upstream_fetch_retry_attempts: profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS
? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS}`, 10)
: Number.parseInt(`${existingGatewayConfig?.upstream_fetch_retry_attempts || 5}`, 10),
upstream_fetch_retry_backoff_ms: profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_BACKOFF_MS
? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_BACKOFF_MS}`, 10)
: Number.parseInt(`${existingGatewayConfig?.upstream_fetch_retry_backoff_ms || 350}`, 10),
non_stream_status_code: profileEnv.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE
? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE}`, 10)
: Number.parseInt(`${existingGatewayConfig?.non_stream_status_code || 502}`, 10),
stream_action: profileEnv.CODEX_RETRY_GATEWAY_STREAM_ACTION || existingGatewayConfig?.stream_action || "strict_502",
log_match: profileEnv.CODEX_RETRY_GATEWAY_LOG_MATCH === undefined
? existingGatewayConfig?.log_match !== false
: boolFromEnv(profileEnv.CODEX_RETRY_GATEWAY_LOG_MATCH, true),
health_path: profileEnv.CODEX_RETRY_GATEWAY_HEALTH_PATH || existingGatewayConfig?.health_path || DEFAULT_HEALTH_PATH,
};
}
async function ensureCodexPointsToGateway({ paths, codexConfigPath, providerContext, localGatewayBaseUrl }) {
await ensureDirectory(paths.backupDir);
const existingState = await readJsonFile(paths.statePath);
let originalBaseUrl = providerContext.currentBaseUrl;
if (providerContext.currentBaseUrl === localGatewayBaseUrl) {
originalBaseUrl = existingState?.original_base_url || null;
}
if (!originalBaseUrl || originalBaseUrl === localGatewayBaseUrl) {
throw new Error("A restorable original Codex base_url could not be determined.");
}
const backupPath =
existingState?.latest_backup_path ||
path.join(
paths.backupDir,
`config-${new Date().toISOString().replace(/[:.]/g, "").replace("T", "-").slice(0, 15)}.toml`,
);
if (!existingState?.latest_backup_path) {
await copyFile(codexConfigPath, backupPath);
}
if (providerContext.currentBaseUrl !== localGatewayBaseUrl) {
await setCodexProviderBaseUrl({
codexConfigPath,
providerName: providerContext.providerName,
newBaseUrl: localGatewayBaseUrl,
});
}
return { existingState, originalBaseUrl, backupPath };
}
async function main() {
const options = parseOptions(process.argv, { booleanFlags: ["no-codex-config-update", "prefer-state-profile"] });
const profilesDir = options.profilesDir || DEFAULT_PROFILES_DIR;
const stateRoot = options.stateRoot || process.env.CODEX_RETRY_GATEWAY_STATE_ROOT || DEFAULT_STATE_ROOT;
const codexConfigPath =
options.codexConfigPath ||
process.env.CODEX_RETRY_GATEWAY_CODEX_CONFIG_PATH ||
DEFAULT_CODEX_CONFIG_PATH;
const paths = getGatewayStatePaths(stateRoot);
await ensureDirectory(paths.stateRoot);
await ensureDirectory(paths.configDir);
await ensureDirectory(paths.logDir);
await ensureDirectory(paths.backupDir);
const existingState = await readJsonFile(paths.statePath);
const requestedProfileName = getProfileName(options) || resolveDefaultRequestedProfileName(existingState);
const profileName = resolvePreferredProfileName({
requestedProfileName,
existingState,
preferStateProfile: Boolean(options.preferStateProfile),
profilesDir,
});
if (profileName !== requestedProfileName) {
process.stdout.write(
`[run-profile] prefer-state-profile requested=${requestedProfileName} effective=${profileName}\n`,
);
}
const { profilePath, env: profileEnv } = await loadProfileEnv(profileName, profilesDir);
for (const [key, value] of Object.entries(profileEnv)) {
process.env[key] = value;
}
const providerContext = await getCodexProviderContext(codexConfigPath);
const listenHost = profileEnv.CODEX_RETRY_GATEWAY_LISTEN_HOST || DEFAULT_LISTEN_HOST;
const listenPort = profileEnv.CODEX_RETRY_GATEWAY_LISTEN_PORT
? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_LISTEN_PORT}`, 10)
: DEFAULT_LISTEN_PORT;
const localGatewayBaseUrl = getGatewayBaseUrl(listenHost, listenPort);
const existingGatewayConfig = await readJsonFile(paths.configPath);
const gatewayConfig = buildProfileConfig({
profileName,
profileEnv,
existingGatewayConfig,
providerContext,
localGatewayBaseUrl,
});
const installState = options.noCodexConfigUpdate
? {
existingState,
originalBaseUrl: providerContext.currentBaseUrl,
backupPath: null,
}
: await ensureCodexPointsToGateway({
paths,
codexConfigPath,
providerContext,
localGatewayBaseUrl,
});
await writeJsonFile(paths.configPath, gatewayConfig);
await writeJsonFile(paths.statePath, {
...(installState.existingState || {}),
installed_at: installState.existingState?.installed_at || new Date().toISOString(),
last_started_at: new Date().toISOString(),
profile_name: profileName,
profile_env_path: profilePath,
codex_config_path: codexConfigPath,
provider_name: providerContext.providerName,
original_base_url: installState.originalBaseUrl,
gateway_base_url: localGatewayBaseUrl,
gateway_config_path: paths.configPath,
gateway_log_path: paths.logPath,
gateway_pid_path: paths.pidPath,
latest_backup_path: installState.backupPath || installState.existingState?.latest_backup_path || null,
state_root: paths.stateRoot,
});
await writeUtf8File(paths.pidPath, `${process.pid}\n`);
const gatewayEntry = path.resolve(import.meta.dirname, "..", "gateway.mjs");
const child = spawn(process.execPath, [gatewayEntry, "--config", paths.configPath, "--log", paths.logPath], {
cwd: path.resolve(import.meta.dirname, ".."),
stdio: "inherit",
windowsHide: true,
});
let stoppingBySignal = false;
const forwardSignal = (signal) => {
stoppingBySignal = true;
if (!child.killed) {
child.kill(signal);
}
};
process.on("SIGTERM", () => forwardSignal("SIGTERM"));
process.on("SIGINT", () => forwardSignal("SIGINT"));
child.on("exit", async (code, signal) => {
await rm(paths.pidPath, { force: true }).catch(() => {});
if (stoppingBySignal) {
process.exit(0);
}
process.exit(code ?? (signal ? 0 : 1));
});
try {
await waitGatewayHealth({
listenHost: gatewayConfig.listen_host,
listenPort: gatewayConfig.listen_port,
healthPath: gatewayConfig.health_path,
});
} catch (error) {
if (!child.killed) {
child.kill("SIGTERM");
}
throw error;
}
}
main().catch((error) => {
process.stderr.write(`${error?.stack || error}\n`);
process.exit(1);
});