feat: add profile ui and request telemetry
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
#!/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);
|
||||
});
|
||||
@@ -287,7 +287,14 @@ async function run() {
|
||||
const gateway = startGateway(configPath, logPath);
|
||||
|
||||
try {
|
||||
await waitForHealth(`http://127.0.0.1:${gatewayPort}${config.health_path}`);
|
||||
try {
|
||||
await waitForHealth(`http://127.0.0.1:${gatewayPort}${config.health_path}`);
|
||||
} catch (error) {
|
||||
const output = gateway.getOutput();
|
||||
throw new Error(
|
||||
`${error?.message || error}\nstdout:\n${output.stdout || "(empty)"}\nstderr:\n${output.stderr || "(empty)"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const modelsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/v1/models`);
|
||||
assert(modelsResponse.status === 200, `/v1/models 透传状态异常: ${modelsResponse.status}`);
|
||||
@@ -323,15 +330,25 @@ async function run() {
|
||||
);
|
||||
}
|
||||
|
||||
const recoveredPayload = JSON.stringify({ test_fail_before_response_once: true });
|
||||
const recoveredResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ test_fail_before_response_once: true }),
|
||||
body: recoveredPayload,
|
||||
});
|
||||
const recoveredBody = await recoveredResponse.json();
|
||||
assert(recoveredResponse.status === 200, `首次 fetch failed 后未自动恢复: ${recoveredResponse.status}`);
|
||||
assert(recoveredBody?.retry_attempt === 2, "首次 fetch failed 后未命中第二次上游请求");
|
||||
|
||||
const requestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`);
|
||||
const requestsPayload = await requestsResponse.json();
|
||||
const recoveredEntry = requestsPayload?.entries?.find((entry) => entry.path === "/responses" && entry.status_code === 200);
|
||||
assert(requestsResponse.status === 200, `请求历史 API 状态异常: ${requestsResponse.status}`);
|
||||
assert(
|
||||
recoveredEntry?.request_body_bytes === Buffer.byteLength(recoveredPayload),
|
||||
`请求体大小记录异常: ${recoveredEntry?.request_body_bytes}`,
|
||||
);
|
||||
|
||||
for (const streamPath of [
|
||||
"/responses",
|
||||
"/v1/responses",
|
||||
|
||||
Reference in New Issue
Block a user