feat: publish codex retry gateway
This commit is contained in:
@@ -0,0 +1,620 @@
|
||||
#!/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 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]),
|
||||
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 || "disconnect",
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Get-GatewayRoot {
|
||||
return Split-Path -Parent $PSScriptRoot
|
||||
}
|
||||
|
||||
function Get-GatewayBaseUrl {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ListenHost,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$ListenPort
|
||||
)
|
||||
|
||||
return "http://{0}:{1}" -f $ListenHost, $ListenPort
|
||||
}
|
||||
|
||||
function Get-GatewayStatePaths {
|
||||
param(
|
||||
[string]$StateRoot = (Join-Path $HOME ".codex-retry-gateway")
|
||||
)
|
||||
|
||||
return [pscustomobject]@{
|
||||
StateRoot = $StateRoot
|
||||
ConfigDir = Join-Path $StateRoot "config"
|
||||
LogDir = Join-Path $StateRoot "logs"
|
||||
BackupDir = Join-Path $StateRoot "backups"
|
||||
ConfigPath = Join-Path $StateRoot "config\config.json"
|
||||
LogPath = Join-Path $StateRoot "logs\gateway.log"
|
||||
StatePath = Join-Path $StateRoot "state.json"
|
||||
PidPath = Join-Path $StateRoot "gateway.pid"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-GatewayBaseUrlFromConfig {
|
||||
param(
|
||||
$GatewayConfig
|
||||
)
|
||||
|
||||
if ($null -eq $GatewayConfig) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace([string]$GatewayConfig.listen_host) -or $null -eq $GatewayConfig.listen_port) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return Get-GatewayBaseUrl `
|
||||
-ListenHost ([string]$GatewayConfig.listen_host) `
|
||||
-ListenPort ([int]$GatewayConfig.listen_port)
|
||||
}
|
||||
|
||||
function Ensure-Directory {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
New-Item -ItemType Directory -Path $Path -Force | Out-Null
|
||||
}
|
||||
|
||||
function Write-Utf8NoBomFile {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Content
|
||||
)
|
||||
|
||||
$parent = Split-Path -Parent $Path
|
||||
if ($parent) {
|
||||
Ensure-Directory -Path $parent
|
||||
}
|
||||
|
||||
$encoding = [System.Text.UTF8Encoding]::new($false)
|
||||
[System.IO.File]::WriteAllText($Path, $Content, $encoding)
|
||||
}
|
||||
|
||||
function Read-JsonFile {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$raw = Get-Content -LiteralPath $Path -Raw
|
||||
if ([string]::IsNullOrWhiteSpace($raw)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return $raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Write-JsonFile {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path,
|
||||
[Parameter(Mandatory = $true)]
|
||||
$Value
|
||||
)
|
||||
|
||||
$json = $Value | ConvertTo-Json -Depth 20
|
||||
Write-Utf8NoBomFile -Path $Path -Content ($json + "`n")
|
||||
}
|
||||
|
||||
function Get-CodexProviderContext {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CodexConfigPath
|
||||
)
|
||||
|
||||
$content = Get-Content -LiteralPath $CodexConfigPath -Raw
|
||||
$providerMatch = [regex]::Match($content, '(?m)^\s*model_provider\s*=\s*"([^"]+)"\s*$')
|
||||
if (-not $providerMatch.Success) {
|
||||
throw "model_provider was not found in $CodexConfigPath"
|
||||
}
|
||||
|
||||
$providerName = $providerMatch.Groups[1].Value
|
||||
$sectionPattern = "(?ms)^\[model_providers\." + [regex]::Escape($providerName) + "\]\s*$.*?(?=^\[|\z)"
|
||||
$sectionMatch = [regex]::Match($content, $sectionPattern)
|
||||
if (-not $sectionMatch.Success) {
|
||||
throw "[model_providers.$providerName] was not found in $CodexConfigPath"
|
||||
}
|
||||
|
||||
$sectionText = $sectionMatch.Value
|
||||
$baseUrlMatch = [regex]::Match($sectionText, '(?m)^\s*base_url\s*=\s*"([^"]+)"\s*$')
|
||||
if (-not $baseUrlMatch.Success) {
|
||||
throw "base_url was not found in [model_providers.$providerName]"
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
Content = $content
|
||||
ProviderName = $providerName
|
||||
SectionText = $sectionText
|
||||
SectionIndex = $sectionMatch.Index
|
||||
SectionLength = $sectionMatch.Length
|
||||
CurrentBaseUrl = $baseUrlMatch.Groups[1].Value
|
||||
BaseUrlLineText = $baseUrlMatch.Value
|
||||
}
|
||||
}
|
||||
|
||||
function Set-CodexProviderBaseUrl {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CodexConfigPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ProviderName,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$NewBaseUrl
|
||||
)
|
||||
|
||||
$context = Get-CodexProviderContext -CodexConfigPath $CodexConfigPath
|
||||
if ($context.ProviderName -ne $ProviderName) {
|
||||
throw "model_provider changed unexpectedly: expected $ProviderName, actual $($context.ProviderName)"
|
||||
}
|
||||
|
||||
$updatedSection = [regex]::Replace(
|
||||
$context.SectionText,
|
||||
'(?m)^(\s*base_url\s*=\s*")([^"]*)("\s*)$',
|
||||
{
|
||||
param($match)
|
||||
return $match.Groups[1].Value + $NewBaseUrl + $match.Groups[3].Value
|
||||
},
|
||||
1
|
||||
)
|
||||
|
||||
$updatedContent =
|
||||
$context.Content.Substring(0, $context.SectionIndex) +
|
||||
$updatedSection +
|
||||
$context.Content.Substring($context.SectionIndex + $context.SectionLength)
|
||||
|
||||
Write-Utf8NoBomFile -Path $CodexConfigPath -Content $updatedContent
|
||||
}
|
||||
|
||||
function Test-ProcessAlive {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$ProcessId
|
||||
)
|
||||
|
||||
try {
|
||||
$null = Get-Process -Id $ProcessId -ErrorAction Stop
|
||||
return $true
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-GatewayHealth {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ListenHost,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$ListenPort,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$HealthPath,
|
||||
[int]$TimeoutSeconds = 10
|
||||
)
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
$healthUrl = "http://{0}:{1}{2}" -f $ListenHost, $ListenPort, $HealthPath
|
||||
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri $healthUrl -UseBasicParsing -TimeoutSec 2
|
||||
if ($response.StatusCode -eq 200) {
|
||||
return $response
|
||||
}
|
||||
} catch {
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
}
|
||||
|
||||
throw "Gateway health check timed out: $healthUrl"
|
||||
}
|
||||
|
||||
function Normalize-IntArray {
|
||||
param(
|
||||
$Values,
|
||||
[int[]]$Default = @(516)
|
||||
)
|
||||
|
||||
if ($null -eq $Values) {
|
||||
return ,@($Default)
|
||||
}
|
||||
|
||||
$queue = New-Object System.Collections.Generic.List[object]
|
||||
foreach ($item in @($Values)) {
|
||||
$queue.Add($item)
|
||||
}
|
||||
|
||||
$normalized = @()
|
||||
foreach ($value in $queue) {
|
||||
if ($null -eq $value) {
|
||||
continue
|
||||
}
|
||||
if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) {
|
||||
foreach ($nestedValue in @($value)) {
|
||||
if ($null -eq $nestedValue) {
|
||||
continue
|
||||
}
|
||||
$normalized += [int]$nestedValue
|
||||
}
|
||||
continue
|
||||
}
|
||||
$normalized += [int]$value
|
||||
}
|
||||
|
||||
if ($normalized.Count -eq 0) {
|
||||
return ,@($Default)
|
||||
}
|
||||
|
||||
return ,@($normalized)
|
||||
}
|
||||
|
||||
function Normalize-StringArray {
|
||||
param(
|
||||
$Values,
|
||||
[string[]]$Default
|
||||
)
|
||||
|
||||
if ($null -eq $Values) {
|
||||
return ,@($Default)
|
||||
}
|
||||
|
||||
$normalized = @()
|
||||
foreach ($value in @($Values)) {
|
||||
if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) {
|
||||
foreach ($nestedValue in @($value)) {
|
||||
if ([string]::IsNullOrWhiteSpace([string]$nestedValue)) {
|
||||
continue
|
||||
}
|
||||
foreach ($part in ([string]$nestedValue).Split(@(" ", "`t", "`r", "`n"), [System.StringSplitOptions]::RemoveEmptyEntries)) {
|
||||
$normalized += $part
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace([string]$value)) {
|
||||
continue
|
||||
}
|
||||
foreach ($part in ([string]$value).Split(@(" ", "`t", "`r", "`n"), [System.StringSplitOptions]::RemoveEmptyEntries)) {
|
||||
$normalized += $part
|
||||
}
|
||||
}
|
||||
|
||||
if ($normalized.Count -eq 0) {
|
||||
return ,@($Default)
|
||||
}
|
||||
|
||||
return ,@($normalized)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
DEFAULT_CODEX_CONFIG_PATH,
|
||||
DEFAULT_LISTEN_HOST,
|
||||
DEFAULT_LISTEN_PORT,
|
||||
DEFAULT_STATE_ROOT,
|
||||
installForCurrentProvider,
|
||||
parseOptions,
|
||||
} from "./admin-lib.mjs";
|
||||
|
||||
async function main() {
|
||||
const options = parseOptions(process.argv);
|
||||
const result = await installForCurrentProvider({
|
||||
codexConfigPath: options.codexConfigPath || DEFAULT_CODEX_CONFIG_PATH,
|
||||
stateRoot: options.stateRoot || DEFAULT_STATE_ROOT,
|
||||
listenHost: options.listenHost || DEFAULT_LISTEN_HOST,
|
||||
listenPort: options.listenPort ? Number.parseInt(`${options.listenPort}`, 10) : DEFAULT_LISTEN_PORT,
|
||||
});
|
||||
|
||||
process.stdout.write("Installed Codex Retry Gateway\n");
|
||||
process.stdout.write(`provider=${result.provider}\n`);
|
||||
process.stdout.write(`upstream=${result.upstream}\n`);
|
||||
process.stdout.write(`gateway=${result.gateway}\n`);
|
||||
process.stdout.write(`config=${result.configPath}\n`);
|
||||
process.stdout.write(`backup=${result.backupPath}\n`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error?.stack || error}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
param(
|
||||
[string]$CodexConfigPath = "$HOME\.codex\config.toml",
|
||||
[string]$StateRoot = "$HOME\.codex-retry-gateway",
|
||||
[string]$ListenHost = "127.0.0.1",
|
||||
[int]$ListenPort = 4610
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
. (Join-Path $PSScriptRoot "common.ps1")
|
||||
|
||||
$paths = Get-GatewayStatePaths -StateRoot $StateRoot
|
||||
Ensure-Directory -Path $paths.StateRoot
|
||||
Ensure-Directory -Path $paths.ConfigDir
|
||||
Ensure-Directory -Path $paths.LogDir
|
||||
Ensure-Directory -Path $paths.BackupDir
|
||||
|
||||
if (-not (Test-Path -LiteralPath $CodexConfigPath)) {
|
||||
throw "Codex config file was not found: $CodexConfigPath"
|
||||
}
|
||||
|
||||
$providerContext = Get-CodexProviderContext -CodexConfigPath $CodexConfigPath
|
||||
$localGatewayBaseUrl = "http://{0}:{1}" -f $ListenHost, $ListenPort
|
||||
$existingState = Read-JsonFile -Path $paths.StatePath
|
||||
|
||||
$originalBaseUrl = $providerContext.CurrentBaseUrl
|
||||
if ($providerContext.CurrentBaseUrl -eq $localGatewayBaseUrl) {
|
||||
if ($null -eq $existingState -or [string]::IsNullOrWhiteSpace([string]$existingState.original_base_url)) {
|
||||
throw "Provider already points to the local gateway, but original_base_url is missing from state."
|
||||
}
|
||||
$originalBaseUrl = [string]$existingState.original_base_url
|
||||
}
|
||||
|
||||
if ($originalBaseUrl -eq $localGatewayBaseUrl) {
|
||||
throw "A real upstream_base_url could not be determined."
|
||||
}
|
||||
|
||||
$backupPath = Join-Path $paths.BackupDir ("config-" + (Get-Date -Format "yyyyMMdd-HHmmss") + ".toml")
|
||||
Copy-Item -LiteralPath $CodexConfigPath -Destination $backupPath -Force
|
||||
|
||||
$existingGatewayConfig = Read-JsonFile -Path $paths.ConfigPath
|
||||
$defaultEndpoints = @("/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions")
|
||||
$mergedEndpoints = @()
|
||||
foreach ($endpoint in @(
|
||||
$(if ($existingGatewayConfig) { Normalize-StringArray -Values $existingGatewayConfig.endpoints -Default @() } else { @() }) +
|
||||
$defaultEndpoints
|
||||
)) {
|
||||
if ([string]::IsNullOrWhiteSpace([string]$endpoint)) {
|
||||
continue
|
||||
}
|
||||
if ($mergedEndpoints -notcontains [string]$endpoint) {
|
||||
$mergedEndpoints += [string]$endpoint
|
||||
}
|
||||
}
|
||||
|
||||
$gatewayConfig = [ordered]@{
|
||||
listen_host = $ListenHost
|
||||
listen_port = $ListenPort
|
||||
upstream_base_url = $originalBaseUrl
|
||||
request_body_limit_bytes = if ($existingGatewayConfig -and $null -ne $existingGatewayConfig.request_body_limit_bytes) { [int]$existingGatewayConfig.request_body_limit_bytes } else { 10485760 }
|
||||
endpoints = @($mergedEndpoints)
|
||||
reasoning_equals = Normalize-IntArray -Values $(if ($existingGatewayConfig) { $existingGatewayConfig.reasoning_equals } else { $null }) -Default @(516)
|
||||
non_stream_status_code = if ($existingGatewayConfig -and $null -ne $existingGatewayConfig.non_stream_status_code) { [int]$existingGatewayConfig.non_stream_status_code } else { 502 }
|
||||
stream_action = if ($existingGatewayConfig -and -not [string]::IsNullOrWhiteSpace([string]$existingGatewayConfig.stream_action)) { [string]$existingGatewayConfig.stream_action } else { "disconnect" }
|
||||
log_match = if ($existingGatewayConfig -and $null -ne $existingGatewayConfig.log_match) { [bool]$existingGatewayConfig.log_match } else { $true }
|
||||
health_path = if ($existingGatewayConfig -and -not [string]::IsNullOrWhiteSpace([string]$existingGatewayConfig.health_path)) { [string]$existingGatewayConfig.health_path } else { "/__codex_retry_gateway/health" }
|
||||
}
|
||||
|
||||
$previousConfigContent = Get-Content -LiteralPath $CodexConfigPath -Raw
|
||||
|
||||
try {
|
||||
Write-JsonFile -Path $paths.ConfigPath -Value $gatewayConfig
|
||||
Set-CodexProviderBaseUrl `
|
||||
-CodexConfigPath $CodexConfigPath `
|
||||
-ProviderName $providerContext.ProviderName `
|
||||
-NewBaseUrl $localGatewayBaseUrl
|
||||
|
||||
& (Join-Path $PSScriptRoot "start-gateway.ps1") `
|
||||
-StateRoot $StateRoot `
|
||||
-ConfigPath $paths.ConfigPath `
|
||||
-LogPath $paths.LogPath `
|
||||
-RestartIfRunning
|
||||
|
||||
$state = [ordered]@{
|
||||
installed_at = (Get-Date).ToString("o")
|
||||
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
|
||||
}
|
||||
Write-JsonFile -Path $paths.StatePath -Value $state
|
||||
|
||||
Write-Output "Installed Codex Retry Gateway"
|
||||
Write-Output "provider=$($providerContext.ProviderName)"
|
||||
Write-Output "upstream=$originalBaseUrl"
|
||||
Write-Output "gateway=$localGatewayBaseUrl"
|
||||
Write-Output "config=$($paths.ConfigPath)"
|
||||
Write-Output "backup=$backupPath"
|
||||
} catch {
|
||||
Write-Utf8NoBomFile -Path $CodexConfigPath -Content $previousConfigContent
|
||||
& (Join-Path $PSScriptRoot "stop-gateway.ps1") -StateRoot $StateRoot -Quiet
|
||||
throw
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
NODE_BIN="node"
|
||||
ARGS=("$@")
|
||||
if command -v node.exe >/dev/null 2>&1; then
|
||||
NODE_BIN="node.exe"
|
||||
if command -v wslpath >/dev/null 2>&1; then
|
||||
SCRIPT_DIR="$(wslpath -w "$SCRIPT_DIR")"
|
||||
else
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -W)"
|
||||
fi
|
||||
NORMALIZED_ARGS=()
|
||||
EXPECT_PATH_VALUE=0
|
||||
for ARG in "${ARGS[@]}"; do
|
||||
if [[ "$EXPECT_PATH_VALUE" == 1 ]]; then
|
||||
if command -v wslpath >/dev/null 2>&1; then
|
||||
ARG="$(wslpath -w "$ARG")"
|
||||
fi
|
||||
EXPECT_PATH_VALUE=0
|
||||
fi
|
||||
case "$ARG" in
|
||||
--codex-config-path|--state-root)
|
||||
EXPECT_PATH_VALUE=1
|
||||
;;
|
||||
esac
|
||||
NORMALIZED_ARGS+=("$ARG")
|
||||
done
|
||||
ARGS=("${NORMALIZED_ARGS[@]}")
|
||||
fi
|
||||
"$NODE_BIN" "$SCRIPT_DIR/install-for-current-provider.mjs" "${ARGS[@]}"
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
DEFAULT_CODEX_CONFIG_PATH,
|
||||
DEFAULT_LISTEN_HOST,
|
||||
DEFAULT_LISTEN_PORT,
|
||||
DEFAULT_STATE_ROOT,
|
||||
launchUi,
|
||||
parseOptions,
|
||||
} from "./admin-lib.mjs";
|
||||
|
||||
async function main() {
|
||||
const options = parseOptions(process.argv, { booleanFlags: ["no-open"] });
|
||||
const result = await launchUi({
|
||||
codexConfigPath: options.codexConfigPath || DEFAULT_CODEX_CONFIG_PATH,
|
||||
stateRoot: options.stateRoot || DEFAULT_STATE_ROOT,
|
||||
listenHost: options.listenHost || DEFAULT_LISTEN_HOST,
|
||||
listenPort: options.listenPort ? Number.parseInt(`${options.listenPort}`, 10) : DEFAULT_LISTEN_PORT,
|
||||
noOpen: Boolean(options.noOpen),
|
||||
});
|
||||
|
||||
process.stdout.write("Codex Retry Gateway UI is ready\n");
|
||||
process.stdout.write(`mode=${result.mode}\n`);
|
||||
process.stdout.write(`ui=${result.uiUrl}\n`);
|
||||
process.stdout.write(`gateway=${result.gatewayBaseUrl}\n`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error?.stack || error}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
param(
|
||||
[string]$CodexConfigPath = "$HOME\.codex\config.toml",
|
||||
[string]$StateRoot = "$HOME\.codex-retry-gateway",
|
||||
[string]$ListenHost = "127.0.0.1",
|
||||
[int]$ListenPort = 4610,
|
||||
[switch]$NoOpen
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
. (Join-Path $PSScriptRoot "common.ps1")
|
||||
|
||||
$paths = Get-GatewayStatePaths -StateRoot $StateRoot
|
||||
Ensure-Directory -Path $paths.StateRoot
|
||||
Ensure-Directory -Path $paths.ConfigDir
|
||||
Ensure-Directory -Path $paths.LogDir
|
||||
Ensure-Directory -Path $paths.BackupDir
|
||||
|
||||
if (-not (Test-Path -LiteralPath $CodexConfigPath)) {
|
||||
throw "Codex config file was not found: $CodexConfigPath"
|
||||
}
|
||||
|
||||
$providerContext = Get-CodexProviderContext -CodexConfigPath $CodexConfigPath
|
||||
$currentBaseUrl = [string]$providerContext.CurrentBaseUrl
|
||||
$requestedGatewayBaseUrl = Get-GatewayBaseUrl -ListenHost $ListenHost -ListenPort $ListenPort
|
||||
$existingState = Read-JsonFile -Path $paths.StatePath
|
||||
$existingGatewayConfig = Read-JsonFile -Path $paths.ConfigPath
|
||||
$stateGatewayBaseUrl = if ($existingState -and -not [string]::IsNullOrWhiteSpace([string]$existingState.gateway_base_url)) { [string]$existingState.gateway_base_url } else { $null }
|
||||
$configGatewayBaseUrl = Get-GatewayBaseUrlFromConfig -GatewayConfig $existingGatewayConfig
|
||||
$managedGatewayBaseUrls = @($requestedGatewayBaseUrl)
|
||||
foreach ($candidate in @($stateGatewayBaseUrl, $configGatewayBaseUrl)) {
|
||||
if ([string]::IsNullOrWhiteSpace([string]$candidate)) {
|
||||
continue
|
||||
}
|
||||
if ($managedGatewayBaseUrls -notcontains [string]$candidate) {
|
||||
$managedGatewayBaseUrls += [string]$candidate
|
||||
}
|
||||
}
|
||||
|
||||
$originalBaseUrl = if ($existingState -and -not [string]::IsNullOrWhiteSpace([string]$existingState.original_base_url)) {
|
||||
[string]$existingState.original_base_url
|
||||
} elseif ($existingGatewayConfig -and -not [string]::IsNullOrWhiteSpace([string]$existingGatewayConfig.upstream_base_url)) {
|
||||
[string]$existingGatewayConfig.upstream_base_url
|
||||
} else {
|
||||
$null
|
||||
}
|
||||
|
||||
$canReuseExistingInstall =
|
||||
($null -ne $existingGatewayConfig) -and
|
||||
(-not [string]::IsNullOrWhiteSpace([string]$originalBaseUrl)) -and
|
||||
($managedGatewayBaseUrls -contains $currentBaseUrl)
|
||||
|
||||
$mode = "install"
|
||||
|
||||
if (-not $canReuseExistingInstall) {
|
||||
& (Join-Path $PSScriptRoot "install-for-current-provider.ps1") `
|
||||
-CodexConfigPath $CodexConfigPath `
|
||||
-StateRoot $StateRoot `
|
||||
-ListenHost $ListenHost `
|
||||
-ListenPort $ListenPort
|
||||
} else {
|
||||
$mode = "reuse"
|
||||
$previousCodexConfigContent = Get-Content -LiteralPath $CodexConfigPath -Raw
|
||||
$previousGatewayConfigContent = if (Test-Path -LiteralPath $paths.ConfigPath) { Get-Content -LiteralPath $paths.ConfigPath -Raw } else { $null }
|
||||
$previousStateContent = if (Test-Path -LiteralPath $paths.StatePath) { Get-Content -LiteralPath $paths.StatePath -Raw } else { $null }
|
||||
|
||||
try {
|
||||
$existingGatewayConfig.listen_host = $ListenHost
|
||||
$existingGatewayConfig.listen_port = $ListenPort
|
||||
if ([string]::IsNullOrWhiteSpace([string]$existingGatewayConfig.health_path)) {
|
||||
$existingGatewayConfig.health_path = "/__codex_retry_gateway/health"
|
||||
}
|
||||
Write-JsonFile -Path $paths.ConfigPath -Value $existingGatewayConfig
|
||||
|
||||
if ($currentBaseUrl -ne $requestedGatewayBaseUrl) {
|
||||
Set-CodexProviderBaseUrl `
|
||||
-CodexConfigPath $CodexConfigPath `
|
||||
-ProviderName $providerContext.ProviderName `
|
||||
-NewBaseUrl $requestedGatewayBaseUrl
|
||||
}
|
||||
|
||||
& (Join-Path $PSScriptRoot "start-gateway.ps1") `
|
||||
-StateRoot $StateRoot `
|
||||
-ConfigPath $paths.ConfigPath `
|
||||
-LogPath $paths.LogPath `
|
||||
-RestartIfRunning
|
||||
|
||||
$statePayload = [ordered]@{
|
||||
installed_at = if ($existingState -and $existingState.installed_at) { [string]$existingState.installed_at } else { (Get-Date).ToString("o") }
|
||||
last_started_at = (Get-Date).ToString("o")
|
||||
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 = if ($existingState -and $existingState.latest_backup_path) { [string]$existingState.latest_backup_path } else { "" }
|
||||
state_root = $paths.StateRoot
|
||||
}
|
||||
Write-JsonFile -Path $paths.StatePath -Value $statePayload
|
||||
} catch {
|
||||
Write-Utf8NoBomFile -Path $CodexConfigPath -Content $previousCodexConfigContent
|
||||
if ($null -ne $previousGatewayConfigContent) {
|
||||
Write-Utf8NoBomFile -Path $paths.ConfigPath -Content $previousGatewayConfigContent
|
||||
}
|
||||
if ($null -ne $previousStateContent) {
|
||||
Write-Utf8NoBomFile -Path $paths.StatePath -Content $previousStateContent
|
||||
}
|
||||
& (Join-Path $PSScriptRoot "stop-gateway.ps1") -StateRoot $StateRoot -Quiet
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
$effectiveGatewayConfig = Read-JsonFile -Path $paths.ConfigPath
|
||||
$effectiveGatewayBaseUrl = Get-GatewayBaseUrlFromConfig -GatewayConfig $effectiveGatewayConfig
|
||||
if ([string]::IsNullOrWhiteSpace([string]$effectiveGatewayBaseUrl)) {
|
||||
$effectiveGatewayBaseUrl = $requestedGatewayBaseUrl
|
||||
}
|
||||
|
||||
$uiUrl = $effectiveGatewayBaseUrl + "/__codex_retry_gateway/ui"
|
||||
if (-not $NoOpen) {
|
||||
Start-Process $uiUrl | Out-Null
|
||||
}
|
||||
|
||||
Write-Output "Codex Retry Gateway UI is ready"
|
||||
Write-Output "mode=$mode"
|
||||
Write-Output "ui=$uiUrl"
|
||||
Write-Output "gateway=$effectiveGatewayBaseUrl"
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
NODE_BIN="node"
|
||||
ARGS=("$@")
|
||||
if command -v node.exe >/dev/null 2>&1; then
|
||||
NODE_BIN="node.exe"
|
||||
if command -v wslpath >/dev/null 2>&1; then
|
||||
SCRIPT_DIR="$(wslpath -w "$SCRIPT_DIR")"
|
||||
else
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -W)"
|
||||
fi
|
||||
NORMALIZED_ARGS=()
|
||||
EXPECT_PATH_VALUE=0
|
||||
for ARG in "${ARGS[@]}"; do
|
||||
if [[ "$EXPECT_PATH_VALUE" == 1 ]]; then
|
||||
if command -v wslpath >/dev/null 2>&1; then
|
||||
ARG="$(wslpath -w "$ARG")"
|
||||
fi
|
||||
EXPECT_PATH_VALUE=0
|
||||
fi
|
||||
case "$ARG" in
|
||||
--codex-config-path|--state-root)
|
||||
EXPECT_PATH_VALUE=1
|
||||
;;
|
||||
esac
|
||||
NORMALIZED_ARGS+=("$ARG")
|
||||
done
|
||||
ARGS=("${NORMALIZED_ARGS[@]}")
|
||||
fi
|
||||
"$NODE_BIN" "$SCRIPT_DIR/launch-ui.mjs" "${ARGS[@]}"
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
DEFAULT_CODEX_CONFIG_PATH,
|
||||
DEFAULT_STATE_ROOT,
|
||||
parseOptions,
|
||||
restoreCodexConfig,
|
||||
} from "./admin-lib.mjs";
|
||||
|
||||
async function main() {
|
||||
const options = parseOptions(process.argv);
|
||||
const result = await restoreCodexConfig({
|
||||
stateRoot: options.stateRoot || DEFAULT_STATE_ROOT,
|
||||
codexConfigPath: options.codexConfigPath || DEFAULT_CODEX_CONFIG_PATH,
|
||||
});
|
||||
|
||||
process.stdout.write("Restored Codex config\n");
|
||||
process.stdout.write(`config=${result.configPath}\n`);
|
||||
process.stdout.write(`restored_from=${result.restoredFrom}\n`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error?.stack || error}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
param(
|
||||
[string]$StateRoot = "$HOME\.codex-retry-gateway",
|
||||
[string]$CodexConfigPath = "$HOME\.codex\config.toml"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
. (Join-Path $PSScriptRoot "common.ps1")
|
||||
|
||||
$paths = Get-GatewayStatePaths -StateRoot $StateRoot
|
||||
$state = Read-JsonFile -Path $paths.StatePath
|
||||
if ($null -eq $state) {
|
||||
throw "Install state file was not found: $($paths.StatePath)"
|
||||
}
|
||||
|
||||
$backupPath = [string]$state.latest_backup_path
|
||||
if ([string]::IsNullOrWhiteSpace($backupPath) -or -not (Test-Path -LiteralPath $backupPath)) {
|
||||
throw "A restorable backup file was not found: $backupPath"
|
||||
}
|
||||
|
||||
& (Join-Path $PSScriptRoot "stop-gateway.ps1") -StateRoot $StateRoot -Quiet
|
||||
Copy-Item -LiteralPath $backupPath -Destination $CodexConfigPath -Force
|
||||
Remove-Item -LiteralPath $paths.StatePath -Force -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Output "Restored Codex config"
|
||||
Write-Output "config=$CodexConfigPath"
|
||||
Write-Output "restored_from=$backupPath"
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
NODE_BIN="node"
|
||||
ARGS=("$@")
|
||||
if command -v node.exe >/dev/null 2>&1; then
|
||||
NODE_BIN="node.exe"
|
||||
if command -v wslpath >/dev/null 2>&1; then
|
||||
SCRIPT_DIR="$(wslpath -w "$SCRIPT_DIR")"
|
||||
else
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -W)"
|
||||
fi
|
||||
NORMALIZED_ARGS=()
|
||||
EXPECT_PATH_VALUE=0
|
||||
for ARG in "${ARGS[@]}"; do
|
||||
if [[ "$EXPECT_PATH_VALUE" == 1 ]]; then
|
||||
if command -v wslpath >/dev/null 2>&1; then
|
||||
ARG="$(wslpath -w "$ARG")"
|
||||
fi
|
||||
EXPECT_PATH_VALUE=0
|
||||
fi
|
||||
case "$ARG" in
|
||||
--codex-config-path|--state-root)
|
||||
EXPECT_PATH_VALUE=1
|
||||
;;
|
||||
esac
|
||||
NORMALIZED_ARGS+=("$ARG")
|
||||
done
|
||||
ARGS=("${NORMALIZED_ARGS[@]}")
|
||||
fi
|
||||
"$NODE_BIN" "$SCRIPT_DIR/restore-codex-config.mjs" "${ARGS[@]}"
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
DEFAULT_STATE_ROOT,
|
||||
getGatewayStatePaths,
|
||||
parseOptions,
|
||||
startGateway,
|
||||
} from "./admin-lib.mjs";
|
||||
|
||||
async function main() {
|
||||
const options = parseOptions(process.argv, { booleanFlags: ["restart-if-running"] });
|
||||
const stateRoot = options.stateRoot || DEFAULT_STATE_ROOT;
|
||||
const paths = getGatewayStatePaths(stateRoot);
|
||||
|
||||
const message = await startGateway({
|
||||
stateRoot,
|
||||
configPath: options.configPath || paths.configPath,
|
||||
logPath: options.logPath || paths.logPath,
|
||||
restartIfRunning: Boolean(options.restartIfRunning),
|
||||
});
|
||||
|
||||
process.stdout.write(`${message}\n`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error?.stack || error}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
param(
|
||||
[string]$StateRoot = "$HOME\.codex-retry-gateway",
|
||||
[string]$ConfigPath,
|
||||
[string]$LogPath,
|
||||
[switch]$RestartIfRunning
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
. (Join-Path $PSScriptRoot "common.ps1")
|
||||
|
||||
$paths = Get-GatewayStatePaths -StateRoot $StateRoot
|
||||
if (-not $ConfigPath) {
|
||||
$ConfigPath = $paths.ConfigPath
|
||||
}
|
||||
if (-not $LogPath) {
|
||||
$LogPath = $paths.LogPath
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ConfigPath)) {
|
||||
throw "Gateway config file was not found: $ConfigPath"
|
||||
}
|
||||
|
||||
Ensure-Directory -Path (Split-Path -Parent $LogPath)
|
||||
|
||||
if (Test-Path -LiteralPath $paths.PidPath) {
|
||||
$existingPidRaw = (Get-Content -LiteralPath $paths.PidPath -Raw).Trim()
|
||||
if ($existingPidRaw) {
|
||||
$existingPid = [int]$existingPidRaw
|
||||
if (Test-ProcessAlive -ProcessId $existingPid) {
|
||||
if ($RestartIfRunning) {
|
||||
& (Join-Path $PSScriptRoot "stop-gateway.ps1") -StateRoot $StateRoot -Quiet
|
||||
} else {
|
||||
Write-Output "Gateway is already running. PID=$existingPid"
|
||||
exit 0
|
||||
}
|
||||
} else {
|
||||
Remove-Item -LiteralPath $paths.PidPath -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$gatewayConfig = Read-JsonFile -Path $ConfigPath
|
||||
if ($null -eq $gatewayConfig) {
|
||||
throw "Gateway config file could not be read: $ConfigPath"
|
||||
}
|
||||
|
||||
$gatewayRoot = Get-GatewayRoot
|
||||
$gatewayEntry = Join-Path $gatewayRoot "gateway.mjs"
|
||||
if (-not (Test-Path -LiteralPath $gatewayEntry)) {
|
||||
throw "Gateway entry file was not found: $gatewayEntry"
|
||||
}
|
||||
|
||||
$nodeCommand = (Get-Command node -ErrorAction Stop).Source
|
||||
$argumentLine = @(
|
||||
('"{0}"' -f $gatewayEntry),
|
||||
"--config",
|
||||
('"{0}"' -f $ConfigPath),
|
||||
"--log",
|
||||
('"{0}"' -f $LogPath)
|
||||
) -join " "
|
||||
|
||||
$process = Start-Process `
|
||||
-FilePath $nodeCommand `
|
||||
-ArgumentList $argumentLine `
|
||||
-WorkingDirectory $gatewayRoot `
|
||||
-WindowStyle Hidden `
|
||||
-PassThru
|
||||
|
||||
Set-Content -LiteralPath $paths.PidPath -Value $process.Id -NoNewline
|
||||
|
||||
Start-Sleep -Milliseconds 300
|
||||
if ($process.HasExited) {
|
||||
$logTail = if (Test-Path -LiteralPath $LogPath) { Get-Content -LiteralPath $LogPath -Tail 20 | Out-String } else { "" }
|
||||
throw "Gateway exited right after startup. PID=$($process.Id)`n$logTail"
|
||||
}
|
||||
|
||||
$null = Wait-GatewayHealth `
|
||||
-ListenHost ([string]$gatewayConfig.listen_host) `
|
||||
-ListenPort ([int]$gatewayConfig.listen_port) `
|
||||
-HealthPath ([string]$gatewayConfig.health_path)
|
||||
|
||||
Write-Output ("Gateway started. PID={0}. Listen=http://{1}:{2}" -f $process.Id, $gatewayConfig.listen_host, $gatewayConfig.listen_port)
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
NODE_BIN="node"
|
||||
ARGS=("$@")
|
||||
if command -v node.exe >/dev/null 2>&1; then
|
||||
NODE_BIN="node.exe"
|
||||
if command -v wslpath >/dev/null 2>&1; then
|
||||
SCRIPT_DIR="$(wslpath -w "$SCRIPT_DIR")"
|
||||
else
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -W)"
|
||||
fi
|
||||
NORMALIZED_ARGS=()
|
||||
EXPECT_PATH_VALUE=0
|
||||
for ARG in "${ARGS[@]}"; do
|
||||
if [[ "$EXPECT_PATH_VALUE" == 1 ]]; then
|
||||
if command -v wslpath >/dev/null 2>&1; then
|
||||
ARG="$(wslpath -w "$ARG")"
|
||||
fi
|
||||
EXPECT_PATH_VALUE=0
|
||||
fi
|
||||
case "$ARG" in
|
||||
--state-root|--config-path|--log-path)
|
||||
EXPECT_PATH_VALUE=1
|
||||
;;
|
||||
esac
|
||||
NORMALIZED_ARGS+=("$ARG")
|
||||
done
|
||||
ARGS=("${NORMALIZED_ARGS[@]}")
|
||||
fi
|
||||
"$NODE_BIN" "$SCRIPT_DIR/start-gateway.mjs" "${ARGS[@]}"
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
DEFAULT_STATE_ROOT,
|
||||
parseOptions,
|
||||
stopGateway,
|
||||
} from "./admin-lib.mjs";
|
||||
|
||||
async function main() {
|
||||
const options = parseOptions(process.argv, { booleanFlags: ["quiet"] });
|
||||
const message = await stopGateway({
|
||||
stateRoot: options.stateRoot || DEFAULT_STATE_ROOT,
|
||||
quiet: Boolean(options.quiet),
|
||||
});
|
||||
|
||||
if (message) {
|
||||
process.stdout.write(`${message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error?.stack || error}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
param(
|
||||
[string]$StateRoot = "$HOME\.codex-retry-gateway",
|
||||
[switch]$Quiet
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
. (Join-Path $PSScriptRoot "common.ps1")
|
||||
|
||||
$paths = Get-GatewayStatePaths -StateRoot $StateRoot
|
||||
if (-not (Test-Path -LiteralPath $paths.PidPath)) {
|
||||
if (-not $Quiet) {
|
||||
Write-Output "No running gateway PID file was found."
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
$pidRaw = (Get-Content -LiteralPath $paths.PidPath -Raw).Trim()
|
||||
if (-not $pidRaw) {
|
||||
Remove-Item -LiteralPath $paths.PidPath -Force
|
||||
if (-not $Quiet) {
|
||||
Write-Output "Gateway PID file was empty and has been removed."
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
$gatewayPid = [int]$pidRaw
|
||||
if (Test-ProcessAlive -ProcessId $gatewayPid) {
|
||||
Stop-Process -Id $gatewayPid -Force
|
||||
}
|
||||
|
||||
Remove-Item -LiteralPath $paths.PidPath -Force -ErrorAction SilentlyContinue
|
||||
if (-not $Quiet) {
|
||||
Write-Output "Gateway stopped. PID=$gatewayPid"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
NODE_BIN="node"
|
||||
ARGS=("$@")
|
||||
if command -v node.exe >/dev/null 2>&1; then
|
||||
NODE_BIN="node.exe"
|
||||
if command -v wslpath >/dev/null 2>&1; then
|
||||
SCRIPT_DIR="$(wslpath -w "$SCRIPT_DIR")"
|
||||
else
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -W)"
|
||||
fi
|
||||
NORMALIZED_ARGS=()
|
||||
EXPECT_PATH_VALUE=0
|
||||
for ARG in "${ARGS[@]}"; do
|
||||
if [[ "$EXPECT_PATH_VALUE" == 1 ]]; then
|
||||
if command -v wslpath >/dev/null 2>&1; then
|
||||
ARG="$(wslpath -w "$ARG")"
|
||||
fi
|
||||
EXPECT_PATH_VALUE=0
|
||||
fi
|
||||
case "$ARG" in
|
||||
--state-root)
|
||||
EXPECT_PATH_VALUE=1
|
||||
;;
|
||||
esac
|
||||
NORMALIZED_ARGS+=("$ARG")
|
||||
done
|
||||
ARGS=("${NORMALIZED_ARGS[@]}")
|
||||
fi
|
||||
"$NODE_BIN" "$SCRIPT_DIR/stop-gateway.mjs" "${ARGS[@]}"
|
||||
@@ -0,0 +1,316 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import { once } from "node:events";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const gatewayRoot = path.resolve(import.meta.dirname, "..");
|
||||
const gatewayEntry = path.join(gatewayRoot, "gateway.mjs");
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function getFreePort() {
|
||||
const server = net.createServer();
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
const port = address && typeof address === "object" ? address.port : null;
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
if (!port) {
|
||||
throw new Error("无法分配空闲端口");
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function createJsonResponse(res, statusCode, body, extraHeaders = {}) {
|
||||
res.writeHead(statusCode, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
...extraHeaders,
|
||||
});
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function createSseResponse(res, chunks) {
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-cache",
|
||||
connection: "keep-alive",
|
||||
"x-upstream-test": "sse",
|
||||
});
|
||||
|
||||
let index = 0;
|
||||
const timer = setInterval(() => {
|
||||
if (index >= chunks.length) {
|
||||
clearInterval(timer);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
res.write(chunks[index]);
|
||||
index += 1;
|
||||
}, 20);
|
||||
|
||||
res.on("close", () => {
|
||||
clearInterval(timer);
|
||||
});
|
||||
}
|
||||
|
||||
function startFakeUpstream(port) {
|
||||
const server = http.createServer((req, res) => {
|
||||
const responsePaths = new Set(["/responses", "/v1/responses"]);
|
||||
const chatCompletionPaths = new Set(["/chat/completions", "/v1/chat/completions"]);
|
||||
|
||||
if (req.method === "GET" && req.url === "/v1/models") {
|
||||
createJsonResponse(
|
||||
res,
|
||||
200,
|
||||
{
|
||||
object: "list",
|
||||
data: [{ id: "fake-model" }],
|
||||
},
|
||||
{ "x-upstream-test": "models-ok" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && responsePaths.has(req.url)) {
|
||||
let body = "";
|
||||
req.setEncoding("utf8");
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
const parsed = JSON.parse(body || "{}");
|
||||
const reasoning = parsed.test_reasoning_tokens ?? 128;
|
||||
createJsonResponse(
|
||||
res,
|
||||
200,
|
||||
{
|
||||
id: "resp_test",
|
||||
usage: {
|
||||
output_tokens_details: {
|
||||
reasoning_tokens: reasoning,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ "x-upstream-test": `responses-${reasoning}` },
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && chatCompletionPaths.has(req.url)) {
|
||||
let body = "";
|
||||
req.setEncoding("utf8");
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
const parsed = JSON.parse(body || "{}");
|
||||
const reasoning = parsed.test_reasoning_tokens ?? 128;
|
||||
if (reasoning === 516) {
|
||||
createSseResponse(res, [
|
||||
'data: {"id":"chunk-1","choices":[{"delta":{"content":"hello"}}]}\n\n',
|
||||
'data: {"usage":{"completion_tokens_details":{"reasoning_tokens":516}}}\n\n',
|
||||
"data: [DONE]\n\n",
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
createSseResponse(res, [
|
||||
'data: {"id":"chunk-1","choices":[{"delta":{"content":"hello"}}]}\n\n',
|
||||
'data: {"usage":{"completion_tokens_details":{"reasoning_tokens":128}}}\n\n',
|
||||
"data: [DONE]\n\n",
|
||||
]);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
createJsonResponse(res, 404, { error: "not found" });
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForHealth(url, timeoutMs = 5000) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore startup race
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`等待网关健康检查超时: ${url}`);
|
||||
}
|
||||
|
||||
function startGateway(configPath, logPath) {
|
||||
const child = spawn(process.execPath, [gatewayEntry, "--config", configPath, "--log", logPath], {
|
||||
cwd: gatewayRoot,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
return {
|
||||
child,
|
||||
getOutput() {
|
||||
return { stdout, stderr };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readSseUntilClose(url, requestBody) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder("utf8");
|
||||
let text = "";
|
||||
let closedByError = false;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
text += decoder.decode(value, { stream: true });
|
||||
} catch (error) {
|
||||
closedByError = true;
|
||||
text += `\n[[reader-error:${error?.name || "unknown"}]]`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
text += decoder.decode();
|
||||
return {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
text,
|
||||
closedByError,
|
||||
};
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "codex-retry-gateway-"));
|
||||
const upstreamPort = await getFreePort();
|
||||
const gatewayPort = await getFreePort();
|
||||
const configPath = path.join(tempRoot, "config.json");
|
||||
const logPath = path.join(tempRoot, "gateway.log");
|
||||
|
||||
const config = {
|
||||
listen_host: "127.0.0.1",
|
||||
listen_port: gatewayPort,
|
||||
upstream_base_url: `http://127.0.0.1:${upstreamPort}`,
|
||||
request_body_limit_bytes: 10 * 1024 * 1024,
|
||||
endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
|
||||
reasoning_equals: [516],
|
||||
non_stream_status_code: 502,
|
||||
stream_action: "disconnect",
|
||||
log_match: true,
|
||||
health_path: "/__codex_retry_gateway/health",
|
||||
};
|
||||
|
||||
await writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
|
||||
|
||||
const upstream = await startFakeUpstream(upstreamPort);
|
||||
const gateway = startGateway(configPath, logPath);
|
||||
|
||||
try {
|
||||
await waitForHealth(`http://127.0.0.1:${gatewayPort}${config.health_path}`);
|
||||
|
||||
const modelsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/v1/models`);
|
||||
assert(modelsResponse.status === 200, `/v1/models 透传状态异常: ${modelsResponse.status}`);
|
||||
assert(
|
||||
modelsResponse.headers.get("x-upstream-test") === "models-ok",
|
||||
"/v1/models 未保留上游头",
|
||||
);
|
||||
|
||||
for (const responsePath of ["/responses", "/v1/responses"]) {
|
||||
const blockedResponse = await fetch(`http://127.0.0.1:${gatewayPort}${responsePath}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ test_reasoning_tokens: 516 }),
|
||||
});
|
||||
const blockedBody = await blockedResponse.json();
|
||||
assert(blockedResponse.status === 502, `${responsePath} 516 未返回 502: ${blockedResponse.status}`);
|
||||
assert(
|
||||
blockedBody?.error?.code === "reasoning_guard_triggered",
|
||||
`${responsePath} 516 返回体不正确`,
|
||||
);
|
||||
|
||||
const okResponse = await fetch(`http://127.0.0.1:${gatewayPort}${responsePath}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ test_reasoning_tokens: 128 }),
|
||||
});
|
||||
const okBody = await okResponse.json();
|
||||
assert(okResponse.status === 200, `${responsePath} 128 透传状态异常: ${okResponse.status}`);
|
||||
assert(okResponse.headers.get("x-upstream-test") === "responses-128", `${responsePath} 128 未保留头`);
|
||||
assert(
|
||||
okBody?.usage?.output_tokens_details?.reasoning_tokens === 128,
|
||||
`${responsePath} 128 返回体异常`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const streamPath of ["/chat/completions", "/v1/chat/completions"]) {
|
||||
const blockedStream = await readSseUntilClose(
|
||||
`http://127.0.0.1:${gatewayPort}${streamPath}`,
|
||||
{ stream: true, test_reasoning_tokens: 516 },
|
||||
);
|
||||
assert(blockedStream.status === 200, `${streamPath} 516 首状态异常: ${blockedStream.status}`);
|
||||
assert(blockedStream.text.includes('"content":"hello"'), `${streamPath} 流式 516 未先透传正常 chunk`);
|
||||
assert(!blockedStream.text.includes("[DONE]"), `${streamPath} 流式 516 不应完整结束`);
|
||||
assert(
|
||||
blockedStream.closedByError || blockedStream.text.includes("[[reader-error:"),
|
||||
`${streamPath} 流式 516 未表现为中途断开`,
|
||||
);
|
||||
|
||||
const okStream = await readSseUntilClose(
|
||||
`http://127.0.0.1:${gatewayPort}${streamPath}`,
|
||||
{ stream: true, test_reasoning_tokens: 128 },
|
||||
);
|
||||
assert(okStream.status === 200, `${streamPath} 128 首状态异常: ${okStream.status}`);
|
||||
assert(okStream.text.includes("[DONE]"), `${streamPath} 流式 128 未完整结束`);
|
||||
assert(!okStream.closedByError, `${streamPath} 流式 128 不应异常断开`);
|
||||
}
|
||||
|
||||
process.stdout.write("PASS codex-retry-gateway e2e\n");
|
||||
} finally {
|
||||
gateway.child.kill();
|
||||
upstream.close();
|
||||
await once(upstream, "close");
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error?.stack || error}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$nodeScript = Join-Path $scriptDir "test-gateway-e2e.mjs"
|
||||
|
||||
node $nodeScript
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import { once } from "node:events";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const scriptsRoot = import.meta.dirname;
|
||||
const installScript = path.join(scriptsRoot, "install-for-current-provider.ps1");
|
||||
const restoreScript = path.join(scriptsRoot, "restore-codex-config.ps1");
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function getFreePort() {
|
||||
const server = net.createServer();
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
const port = address && typeof address === "object" ? address.port : null;
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
if (!port) {
|
||||
throw new Error("Failed to allocate a free port");
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function startFakeUpstream(port) {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method === "GET" && req.url === "/v1/models") {
|
||||
res.writeHead(200, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"x-upstream-test": "install-flow-ok",
|
||||
});
|
||||
res.end(JSON.stringify({ object: "list", data: [{ id: "install-test-model" }] }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && (req.url === "/responses" || req.url === "/v1/responses")) {
|
||||
let body = "";
|
||||
req.setEncoding("utf8");
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
const parsed = JSON.parse(body || "{}");
|
||||
const reasoning = parsed.test_reasoning_tokens ?? 128;
|
||||
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: "install-test-response",
|
||||
usage: {
|
||||
output_tokens_details: {
|
||||
reasoning_tokens: reasoning,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
async function runPowerShellScript(scriptPath, args) {
|
||||
const child = spawn(
|
||||
"powershell",
|
||||
["-ExecutionPolicy", "Bypass", "-File", scriptPath, ...args],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
const [exitCode] = await once(child, "exit");
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`PowerShell script failed: ${scriptPath}\nstdout:\n${stdout}\nstderr:\n${stderr}`);
|
||||
}
|
||||
|
||||
return { stdout, stderr };
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "codex-retry-gateway-install-"));
|
||||
const codexDir = path.join(tempRoot, ".codex");
|
||||
const stateRoot = path.join(tempRoot, ".codex-retry-gateway");
|
||||
const codexConfigPath = path.join(codexDir, "config.toml");
|
||||
const upstreamPort = await getFreePort();
|
||||
const gatewayPort = await getFreePort();
|
||||
|
||||
await mkdir(codexDir, { recursive: true });
|
||||
await writeFile(
|
||||
codexConfigPath,
|
||||
[
|
||||
'model_provider = "custom"',
|
||||
"",
|
||||
"[model_providers.custom]",
|
||||
'name = "Install Test"',
|
||||
`base_url = "http://127.0.0.1:${upstreamPort}"`,
|
||||
'wire_api = "responses"',
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const upstream = await startFakeUpstream(upstreamPort);
|
||||
|
||||
try {
|
||||
await runPowerShellScript(installScript, [
|
||||
"-CodexConfigPath",
|
||||
codexConfigPath,
|
||||
"-StateRoot",
|
||||
stateRoot,
|
||||
"-ListenPort",
|
||||
String(gatewayPort),
|
||||
]);
|
||||
|
||||
const updatedConfig = await readFile(codexConfigPath, "utf8");
|
||||
assert(
|
||||
updatedConfig.includes(`base_url = "http://127.0.0.1:${gatewayPort}"`),
|
||||
"Install script did not redirect base_url to local gateway",
|
||||
);
|
||||
|
||||
const gatewayConfig = JSON.parse(
|
||||
await readFile(path.join(stateRoot, "config", "config.json"), "utf8"),
|
||||
);
|
||||
assert(
|
||||
gatewayConfig.upstream_base_url === `http://127.0.0.1:${upstreamPort}`,
|
||||
"Gateway config did not preserve original upstream_base_url",
|
||||
);
|
||||
assert(Array.isArray(gatewayConfig.endpoints), "Gateway config endpoints must be an array");
|
||||
assert(
|
||||
gatewayConfig.endpoints.includes("/responses") &&
|
||||
gatewayConfig.endpoints.includes("/chat/completions") &&
|
||||
gatewayConfig.endpoints.includes("/v1/responses") &&
|
||||
gatewayConfig.endpoints.includes("/v1/chat/completions"),
|
||||
"Gateway config endpoints did not include both root and /v1 variants",
|
||||
);
|
||||
|
||||
const proxiedModels = await fetch(`http://127.0.0.1:${gatewayPort}/v1/models`);
|
||||
assert(proxiedModels.status === 200, `/v1/models through installed gateway failed: ${proxiedModels.status}`);
|
||||
assert(
|
||||
proxiedModels.headers.get("x-upstream-test") === "install-flow-ok",
|
||||
"Installed gateway did not preserve upstream header",
|
||||
);
|
||||
|
||||
const uiResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/ui`);
|
||||
const uiHtml = await uiResponse.text();
|
||||
assert(uiResponse.status === 200, `Management UI failed to load: ${uiResponse.status}`);
|
||||
assert(uiHtml.includes("Codex Retry Gateway"), "Management UI HTML did not include expected title");
|
||||
assert(uiHtml.includes("516 命中次数"), "Management UI HTML did not include 516 match stats");
|
||||
assert(uiHtml.includes("516 占比"), "Management UI HTML did not include 516 ratio stats");
|
||||
assert(uiHtml.includes("实时日志"), "Management UI HTML did not include live log panel");
|
||||
|
||||
const statusResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`);
|
||||
const statusPayload = await statusResponse.json();
|
||||
assert(statusResponse.status === 200, `Status API failed: ${statusResponse.status}`);
|
||||
assert(statusPayload.config?.upstream_base_url === `http://127.0.0.1:${upstreamPort}`, "Status API did not expose config");
|
||||
assert(statusPayload.state?.original_base_url === `http://127.0.0.1:${upstreamPort}`, "Status API did not expose install state");
|
||||
assert(statusPayload.metrics?.inspected_response_count === 0, "Status API did not expose initial inspected count");
|
||||
assert(statusPayload.metrics?.reasoning_516_count === 0, "Status API did not expose initial 516 count");
|
||||
|
||||
const normalResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ test_reasoning_tokens: 128 }),
|
||||
});
|
||||
assert(normalResponse.status === 200, `Expected a passthrough response before 516 test: ${normalResponse.status}`);
|
||||
|
||||
const blocked516Response = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ test_reasoning_tokens: 516 }),
|
||||
});
|
||||
assert(blocked516Response.status === 502, `Default 516 block did not trigger: ${blocked516Response.status}`);
|
||||
|
||||
const metricsStatusResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`);
|
||||
const metricsStatusPayload = await metricsStatusResponse.json();
|
||||
assert(metricsStatusResponse.status === 200, `Status API failed after traffic: ${metricsStatusResponse.status}`);
|
||||
assert(metricsStatusPayload.metrics?.inspected_response_count === 2, "Status API inspected count was not updated");
|
||||
assert(metricsStatusPayload.metrics?.matched_response_count === 1, "Status API matched count was not updated");
|
||||
assert(metricsStatusPayload.metrics?.reasoning_516_count === 1, "Status API 516 count was not updated");
|
||||
assert(metricsStatusPayload.metrics?.reasoning_516_ratio === 0.5, "Status API 516 ratio was not updated");
|
||||
|
||||
const logsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/logs`);
|
||||
const logsPayload = await logsResponse.json();
|
||||
assert(logsResponse.status === 200, `Logs API failed: ${logsResponse.status}`);
|
||||
assert(Array.isArray(logsPayload.entries), "Logs API did not return entries array");
|
||||
assert(
|
||||
logsPayload.entries.some((entry) => `${entry.message || ""}`.includes("[start]")),
|
||||
"Logs API did not include gateway start log",
|
||||
);
|
||||
assert(
|
||||
logsPayload.entries.some((entry) => `${entry.message || ""}`.includes("reasoning_tokens=516")),
|
||||
"Logs API did not include 516 match log",
|
||||
);
|
||||
|
||||
const saveConfigResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/config`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
reasoning_equals: [1024],
|
||||
endpoints: ["/responses", "/v1/responses"],
|
||||
non_stream_status_code: 503,
|
||||
log_match: false,
|
||||
}),
|
||||
});
|
||||
const saveConfigPayload = await saveConfigResponse.json();
|
||||
assert(saveConfigResponse.status === 200, `Save config API failed: ${saveConfigResponse.status}`);
|
||||
assert(saveConfigPayload.config?.non_stream_status_code === 503, "Save config API did not return updated config");
|
||||
|
||||
const updatedGatewayConfig = JSON.parse(
|
||||
await readFile(path.join(stateRoot, "config", "config.json"), "utf8"),
|
||||
);
|
||||
assert(
|
||||
JSON.stringify(updatedGatewayConfig.reasoning_equals) === JSON.stringify([1024]),
|
||||
"Saved config file did not persist reasoning_equals",
|
||||
);
|
||||
|
||||
const incrementalLogsResponse = await fetch(
|
||||
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/logs?since_seq=${logsPayload.latest_seq}`,
|
||||
);
|
||||
const incrementalLogsPayload = await incrementalLogsResponse.json();
|
||||
assert(incrementalLogsResponse.status === 200, `Incremental logs API failed: ${incrementalLogsResponse.status}`);
|
||||
assert(
|
||||
incrementalLogsPayload.entries.some((entry) => `${entry.message || ""}`.includes("[config] updated")),
|
||||
"Incremental logs API did not include config update log",
|
||||
);
|
||||
|
||||
const blockedAfterSave = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ test_reasoning_tokens: 1024 }),
|
||||
});
|
||||
assert(blockedAfterSave.status === 503, `Hot reloaded config did not take effect: ${blockedAfterSave.status}`);
|
||||
|
||||
const restoreViaUiResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/restore`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const restoreViaUiPayload = await restoreViaUiResponse.json();
|
||||
assert(restoreViaUiResponse.status === 202, `Restore API failed: ${restoreViaUiResponse.status}`);
|
||||
assert(restoreViaUiPayload.ok === true, "Restore API did not acknowledge the restore request");
|
||||
|
||||
const restoreStartedAt = Date.now();
|
||||
while (Date.now() - restoreStartedAt < 10000) {
|
||||
const restoredCandidate = await readFile(codexConfigPath, "utf8");
|
||||
if (restoredCandidate.includes(`base_url = "http://127.0.0.1:${upstreamPort}"`)) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
|
||||
const restoredConfig = await readFile(codexConfigPath, "utf8");
|
||||
assert(
|
||||
restoredConfig.includes(`base_url = "http://127.0.0.1:${upstreamPort}"`),
|
||||
"Restore script did not recover original base_url",
|
||||
);
|
||||
|
||||
process.stdout.write("PASS install-restore flow\n");
|
||||
} finally {
|
||||
upstream.close();
|
||||
await once(upstream, "close");
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error?.stack || error}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$nodeScript = Join-Path $scriptDir "test-install-restore.mjs"
|
||||
|
||||
node $nodeScript
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import { once } from "node:events";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const scriptsRoot = import.meta.dirname;
|
||||
const launchScript = path.join(scriptsRoot, "launch-ui.sh");
|
||||
const restoreScript = path.join(scriptsRoot, "restore-codex-config.sh");
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function toUnixPathForBash(inputPath) {
|
||||
if (process.platform !== "win32") {
|
||||
return inputPath;
|
||||
}
|
||||
return `/mnt/${inputPath.slice(0, 1).toLowerCase()}${inputPath.slice(2).replace(/\\/g, "/")}`;
|
||||
}
|
||||
|
||||
async function getFreePort() {
|
||||
const server = net.createServer();
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
const port = address && typeof address === "object" ? address.port : null;
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
if (!port) {
|
||||
throw new Error("Failed to allocate a free port");
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function startFakeUpstream(port) {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method === "GET" && req.url === "/v1/models") {
|
||||
res.writeHead(200, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"x-upstream-test": "unix-launch-ok",
|
||||
});
|
||||
res.end(JSON.stringify({ object: "list", data: [{ id: "unix-launch-model" }] }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && (req.url === "/responses" || req.url === "/v1/responses")) {
|
||||
let body = "";
|
||||
req.setEncoding("utf8");
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
const parsed = JSON.parse(body || "{}");
|
||||
const reasoning = parsed.test_reasoning_tokens ?? 128;
|
||||
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: "unix-launch-response",
|
||||
usage: {
|
||||
output_tokens_details: {
|
||||
reasoning_tokens: reasoning,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
async function runBashScript(scriptPath, args) {
|
||||
const bashScriptPath =
|
||||
process.platform === "win32"
|
||||
? path.relative(process.cwd(), scriptPath).split(path.sep).join("/")
|
||||
: scriptPath;
|
||||
|
||||
const bashArgs = [bashScriptPath, ...args];
|
||||
|
||||
const child = spawn("bash", bashArgs, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
const [exitCode] = await once(child, "exit");
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Bash script failed: ${scriptPath}\nstdout:\n${stdout}\nstderr:\n${stderr}`);
|
||||
}
|
||||
|
||||
return { stdout, stderr };
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "codex-retry-gateway-unix-"));
|
||||
const codexDir = path.join(tempRoot, ".codex");
|
||||
const stateRoot = path.join(tempRoot, ".codex-retry-gateway");
|
||||
const codexConfigPath = path.join(codexDir, "config.toml");
|
||||
const upstreamPort = await getFreePort();
|
||||
const gatewayPort = await getFreePort();
|
||||
const gatewayBaseUrl = `http://127.0.0.1:${gatewayPort}`;
|
||||
const upstreamBaseUrl = `http://127.0.0.1:${upstreamPort}`;
|
||||
|
||||
await mkdir(codexDir, { recursive: true });
|
||||
await writeFile(
|
||||
codexConfigPath,
|
||||
[
|
||||
'model_provider = "custom"',
|
||||
"",
|
||||
"[model_providers.custom]",
|
||||
'name = "Unix Launch Test"',
|
||||
`base_url = "${upstreamBaseUrl}"`,
|
||||
'wire_api = "responses"',
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const upstream = await startFakeUpstream(upstreamPort);
|
||||
|
||||
try {
|
||||
await runBashScript(launchScript, [
|
||||
"--codex-config-path",
|
||||
toUnixPathForBash(codexConfigPath),
|
||||
"--state-root",
|
||||
toUnixPathForBash(stateRoot),
|
||||
"--listen-port",
|
||||
String(gatewayPort),
|
||||
"--no-open",
|
||||
]);
|
||||
|
||||
const installedConfig = await readFile(codexConfigPath, "utf8");
|
||||
assert(
|
||||
installedConfig.includes(`base_url = "${gatewayBaseUrl}"`),
|
||||
"Unix launch did not redirect the current provider to the local gateway",
|
||||
);
|
||||
|
||||
const uiResponse = await fetch(`${gatewayBaseUrl}/__codex_retry_gateway/ui`);
|
||||
assert(uiResponse.status === 200, `Unix UI page was not reachable: ${uiResponse.status}`);
|
||||
|
||||
const proxiedModels = await fetch(`${gatewayBaseUrl}/v1/models`);
|
||||
assert(proxiedModels.status === 200, `/v1/models through unix launch flow failed: ${proxiedModels.status}`);
|
||||
assert(
|
||||
proxiedModels.headers.get("x-upstream-test") === "unix-launch-ok",
|
||||
"Unix launch gateway did not preserve upstream headers",
|
||||
);
|
||||
|
||||
await runBashScript(restoreScript, [
|
||||
"--codex-config-path",
|
||||
toUnixPathForBash(codexConfigPath),
|
||||
"--state-root",
|
||||
toUnixPathForBash(stateRoot),
|
||||
]);
|
||||
|
||||
const restoredConfig = await readFile(codexConfigPath, "utf8");
|
||||
assert(
|
||||
restoredConfig.includes(`base_url = "${upstreamBaseUrl}"`),
|
||||
"Unix restore did not recover original base_url",
|
||||
);
|
||||
|
||||
process.stdout.write("PASS unix launch-ui flow\n");
|
||||
} finally {
|
||||
upstream.close();
|
||||
await once(upstream, "close");
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error?.stack || error}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$nodeScript = Join-Path $scriptDir "test-launch-ui-unix.mjs"
|
||||
|
||||
node $nodeScript
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import { once } from "node:events";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const scriptsRoot = import.meta.dirname;
|
||||
const launchScript = path.join(scriptsRoot, "launch-ui.ps1");
|
||||
const restoreScript = path.join(scriptsRoot, "restore-codex-config.ps1");
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function getFreePort() {
|
||||
const server = net.createServer();
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
const port = address && typeof address === "object" ? address.port : null;
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
if (!port) {
|
||||
throw new Error("Failed to allocate a free port");
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function startFakeUpstream(port) {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method === "GET" && req.url === "/v1/models") {
|
||||
res.writeHead(200, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"x-upstream-test": "launch-ui-ok",
|
||||
});
|
||||
res.end(JSON.stringify({ object: "list", data: [{ id: "launch-ui-test-model" }] }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && (req.url === "/responses" || req.url === "/v1/responses")) {
|
||||
let body = "";
|
||||
req.setEncoding("utf8");
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
const parsed = JSON.parse(body || "{}");
|
||||
const reasoning = parsed.test_reasoning_tokens ?? 128;
|
||||
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: "launch-ui-response",
|
||||
usage: {
|
||||
output_tokens_details: {
|
||||
reasoning_tokens: reasoning,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
async function runPowerShellScript(scriptPath, args) {
|
||||
const child = spawn(
|
||||
"powershell",
|
||||
["-ExecutionPolicy", "Bypass", "-File", scriptPath, ...args],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
const [exitCode] = await once(child, "exit");
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`PowerShell script failed: ${scriptPath}\nstdout:\n${stdout}\nstderr:\n${stderr}`);
|
||||
}
|
||||
|
||||
return { stdout, stderr };
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "codex-retry-gateway-launch-"));
|
||||
const codexDir = path.join(tempRoot, ".codex");
|
||||
const stateRoot = path.join(tempRoot, ".codex-retry-gateway");
|
||||
const codexConfigPath = path.join(codexDir, "config.toml");
|
||||
const upstreamPort = await getFreePort();
|
||||
const gatewayPort = await getFreePort();
|
||||
const gatewayBaseUrl = `http://127.0.0.1:${gatewayPort}`;
|
||||
const upstreamBaseUrl = `http://127.0.0.1:${upstreamPort}`;
|
||||
|
||||
await mkdir(codexDir, { recursive: true });
|
||||
await writeFile(
|
||||
codexConfigPath,
|
||||
[
|
||||
'model_provider = "custom"',
|
||||
"",
|
||||
"[model_providers.custom]",
|
||||
'name = "Launch UI Test"',
|
||||
`base_url = "${upstreamBaseUrl}"`,
|
||||
'wire_api = "responses"',
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const upstream = await startFakeUpstream(upstreamPort);
|
||||
|
||||
try {
|
||||
await runPowerShellScript(launchScript, [
|
||||
"-CodexConfigPath",
|
||||
codexConfigPath,
|
||||
"-StateRoot",
|
||||
stateRoot,
|
||||
"-ListenPort",
|
||||
String(gatewayPort),
|
||||
"-NoOpen",
|
||||
]);
|
||||
|
||||
const installedConfig = await readFile(codexConfigPath, "utf8");
|
||||
assert(
|
||||
installedConfig.includes(`base_url = "${gatewayBaseUrl}"`),
|
||||
"First launch did not redirect the current provider to the local gateway",
|
||||
);
|
||||
|
||||
const uiResponse = await fetch(`${gatewayBaseUrl}/__codex_retry_gateway/ui`);
|
||||
assert(uiResponse.status === 200, `UI page was not reachable after first launch: ${uiResponse.status}`);
|
||||
|
||||
const statusResponse = await fetch(`${gatewayBaseUrl}/__codex_retry_gateway/api/status`);
|
||||
const statusPayload = await statusResponse.json();
|
||||
assert(statusResponse.status === 200, `Status API failed after first launch: ${statusResponse.status}`);
|
||||
assert(
|
||||
statusPayload.state?.original_base_url === upstreamBaseUrl,
|
||||
"First launch did not persist the original upstream base URL",
|
||||
);
|
||||
|
||||
const firstStateRaw = await readFile(path.join(stateRoot, "state.json"), "utf8");
|
||||
const firstState = JSON.parse(firstStateRaw);
|
||||
|
||||
await runPowerShellScript(launchScript, [
|
||||
"-CodexConfigPath",
|
||||
codexConfigPath,
|
||||
"-StateRoot",
|
||||
stateRoot,
|
||||
"-ListenPort",
|
||||
String(gatewayPort),
|
||||
"-NoOpen",
|
||||
]);
|
||||
|
||||
const secondStateRaw = await readFile(path.join(stateRoot, "state.json"), "utf8");
|
||||
const secondState = JSON.parse(secondStateRaw);
|
||||
assert(
|
||||
secondState.original_base_url === firstState.original_base_url,
|
||||
"Second launch overwrote original_base_url unexpectedly",
|
||||
);
|
||||
assert(
|
||||
secondState.gateway_base_url === gatewayBaseUrl,
|
||||
"Second launch did not preserve gateway_base_url",
|
||||
);
|
||||
|
||||
const proxiedModels = await fetch(`${gatewayBaseUrl}/v1/models`);
|
||||
assert(proxiedModels.status === 200, `/v1/models through launch UI flow failed: ${proxiedModels.status}`);
|
||||
assert(
|
||||
proxiedModels.headers.get("x-upstream-test") === "launch-ui-ok",
|
||||
"Gateway did not preserve upstream headers after second launch",
|
||||
);
|
||||
|
||||
const blockedResponse = await fetch(`${gatewayBaseUrl}/responses`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ test_reasoning_tokens: 516 }),
|
||||
});
|
||||
assert(blockedResponse.status === 502, `Default 516 interception was not active: ${blockedResponse.status}`);
|
||||
|
||||
process.stdout.write("PASS launch-ui flow\n");
|
||||
} finally {
|
||||
try {
|
||||
await runPowerShellScript(restoreScript, [
|
||||
"-CodexConfigPath",
|
||||
codexConfigPath,
|
||||
"-StateRoot",
|
||||
stateRoot,
|
||||
]);
|
||||
} catch {
|
||||
// 测试清理阶段允许忽略恢复失败,避免覆盖主失败原因。
|
||||
}
|
||||
upstream.close();
|
||||
await once(upstream, "close");
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error?.stack || error}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$nodeScript = Join-Path $scriptDir "test-launch-ui.mjs"
|
||||
|
||||
node $nodeScript
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
Reference in New Issue
Block a user