358 lines
12 KiB
JavaScript
358 lines
12 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,
|
|
normalizeStringArray,
|
|
parseOptions,
|
|
readJsonFile,
|
|
setCodexProviderBaseUrl,
|
|
waitGatewayHealth,
|
|
writeJsonFile,
|
|
writeUtf8File,
|
|
} from "./admin-lib.mjs";
|
|
|
|
const DEFAULT_PROFILES_DIR = path.join(os.homedir(), ".config", "codex-retry-gateway", "profiles");
|
|
|
|
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 || "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 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
|
|
? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES}`, 10)
|
|
: Number.parseInt(`${existingGatewayConfig?.request_body_limit_bytes || 10485760}`, 10),
|
|
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],
|
|
),
|
|
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 requestedProfileName = getProfileName(options);
|
|
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 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);
|
|
});
|