fix: normalize wildcard gateway URLs for Codex

This commit is contained in:
2026-07-20 10:01:19 +08:00
parent 31e673f777
commit 14fae29618
6 changed files with 237 additions and 38 deletions
+3 -1
View File
@@ -10,7 +10,7 @@
- Secret 边界:本地用户配置
- 数据来源:本地 Codex provider 配置、gateway profile state 与上游 OpenAI-compatible API 响应。
- 输出边界:仅返回代理后的 Codex API 响应和本地健康/状态信息。
- 写入边界:只写本地用户态 gateway 状态目录。
- 写入边界:只写本地用户态 gateway 状态目录,以及当前 Codex provider 的 `base_url` 与其可恢复备份
- 本地状态路径:`~/.codex-retry-gateway/`
- 备注:token 与 provider secret 只保留在目标用户配置或 secret 文件中,不进入 repo。
@@ -53,6 +53,8 @@ macOS / Linux:
- 这是一个可独立发布、独立运行的仓库
- 默认监听地址是 `http://127.0.0.1:4610`
- Profile 可以把服务绑定到 `0.0.0.0`;这只影响服务监听。Codex `config.toml`、同机 health 探测和本地 UI 链接仍会使用 `http://127.0.0.1:<listen-port>`,不会把不可作为客户端目标的 `0.0.0.0` 写入 provider `base_url`
- 从管理页保存当前正在运行的文本 Profile 时,会只同步当前 `model_provider``base_url`;首次需要改写时保留/创建 `config.toml` 备份,并保留已知的原始上游地址。
- 默认示例上游见 `config.example.json`
- 实际运行时配置会写到当前用户目录下的 gateway 状态目录
+102 -3
View File
@@ -11,6 +11,13 @@ import zlib from "node:zlib";
import { TextDecoder } from "node:util";
import { fileURLToPath } from "node:url";
import {
getCodexProviderContext,
getGatewayBaseUrl,
getGatewayListenerBaseUrl,
setCodexProviderBaseUrl,
} from "./scripts/admin-lib.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ADMIN_BASE_PATH = "/__codex_retry_gateway";
@@ -2589,6 +2596,7 @@ function buildRuntimePaths(configPath, logPath) {
const homeDir = process.env.HOME || "";
return {
stateRoot,
backupDir: path.join(stateRoot, "backups"),
statePath: path.join(stateRoot, "state.json"),
pidPath: path.join(stateRoot, "gateway.pid"),
profilesDir: path.join(homeDir, ".config", "codex-retry-gateway", "profiles"),
@@ -2912,6 +2920,80 @@ async function updateRuntimeState(runtime, updates) {
);
}
function resolveOriginalCodexBaseUrl(state, currentBaseUrl, listenHost, listenPort) {
const knownGatewayUrls = new Set([
getGatewayBaseUrl(listenHost, listenPort),
getGatewayListenerBaseUrl(listenHost, listenPort),
`${state?.gateway_base_url || ""}`.trim(),
].filter(Boolean));
const stateOriginalBaseUrl = `${state?.original_base_url || ""}`.trim();
if (stateOriginalBaseUrl && !knownGatewayUrls.has(stateOriginalBaseUrl)) {
return stateOriginalBaseUrl;
}
return knownGatewayUrls.has(currentBaseUrl) ? null : currentBaseUrl;
}
async function syncCurrentCodexProviderToGateway(runtime, gatewayBaseUrl) {
const state = await readOptionalJson(runtime.paths.statePath);
const codexConfigPath = `${state?.codex_config_path || ""}`.trim();
if (!codexConfigPath) {
return {
attempted: false,
changed: false,
providerName: "",
originalBaseUrl: null,
backupPath: null,
rollback: async () => {},
};
}
const providerContext = await getCodexProviderContext(codexConfigPath);
const currentBaseUrl = providerContext.currentBaseUrl;
const originalBaseUrl = resolveOriginalCodexBaseUrl(
state,
currentBaseUrl,
runtime.config.listen_host,
runtime.config.listen_port,
);
let backupPath = `${state?.latest_backup_path || ""}`.trim();
if (currentBaseUrl !== gatewayBaseUrl && (!backupPath || !fs.existsSync(backupPath))) {
await mkdir(runtime.paths.backupDir, { recursive: true });
backupPath = path.join(
runtime.paths.backupDir,
`config-${new Date().toISOString().replace(/[:.]/g, "").replace("T", "-").slice(0, 15)}.toml`,
);
await copyFile(codexConfigPath, backupPath);
}
const changed = currentBaseUrl !== gatewayBaseUrl;
if (changed) {
await setCodexProviderBaseUrl({
codexConfigPath,
providerName: providerContext.providerName,
newBaseUrl: gatewayBaseUrl,
});
}
return {
attempted: true,
changed,
providerName: providerContext.providerName,
originalBaseUrl,
backupPath: backupPath || null,
rollback: async () => {
if (!changed) {
return;
}
await setCodexProviderBaseUrl({
codexConfigPath,
providerName: providerContext.providerName,
newBaseUrl: currentBaseUrl,
});
},
};
}
async function listProfiles(runtime) {
let files = [];
try {
@@ -3137,6 +3219,10 @@ async function applyProfileConfig(runtime, profileName) {
throw new Error("该 profile 的监听地址或端口与当前实例不同,暂不支持无重启热切换");
}
const gatewayBaseUrl = getGatewayBaseUrl(config.listen_host, config.listen_port);
const codexProviderSync = await syncCurrentCodexProviderToGateway(runtime, gatewayBaseUrl);
try {
runtime.config = {
...config,
model_remap_map: parseModelRemapMap(config.model_remap),
@@ -3146,11 +3232,18 @@ async function applyProfileConfig(runtime, profileName) {
await updateRuntimeState(runtime, {
profile_name: runtime.config.profile_name || "default",
profile_env_path: profilePath,
gateway_base_url: `http://${runtime.config.listen_host}:${runtime.config.listen_port}`,
gateway_base_url: gatewayBaseUrl,
...(codexProviderSync.providerName ? { provider_name: codexProviderSync.providerName } : {}),
...(codexProviderSync.originalBaseUrl ? { original_base_url: codexProviderSync.originalBaseUrl } : {}),
...(codexProviderSync.backupPath ? { latest_backup_path: codexProviderSync.backupPath } : {}),
last_started_at: new Date().toISOString(),
});
} catch (error) {
await codexProviderSync.rollback().catch(() => {});
throw error;
}
runtime.logger(
`[profile] hot-swapped profile=${runtime.config.profile_name || "default"} auth=${normalizeAuthMode(runtime.config.upstream_auth_mode)} upstream=${runtime.config.upstream_base_url}`,
`[profile] hot-swapped profile=${runtime.config.profile_name || "default"} auth=${normalizeAuthMode(runtime.config.upstream_auth_mode)} upstream=${runtime.config.upstream_base_url} codex_config_sync=${codexProviderSync.changed ? "updated" : codexProviderSync.attempted ? "current" : "unavailable"}`,
);
return {
@@ -3159,6 +3252,12 @@ async function applyProfileConfig(runtime, profileName) {
hot_swapped: true,
listen: `${runtime.config.listen_host}:${runtime.config.listen_port}`,
upstream_base_url: runtime.config.upstream_base_url,
codex_provider_sync: codexProviderSync.attempted
? {
updated: codexProviderSync.changed,
gateway_base_url: gatewayBaseUrl,
}
: null,
};
}
@@ -6639,7 +6738,7 @@ async function main() {
last_started_at: new Date().toISOString(),
profile_name: runtime.config.profile_name || "default",
image_profile_name: runtime.config.image_profile_name || "",
gateway_base_url: `http://${runtime.config.listen_host}:${runtime.config.listen_port}`,
gateway_base_url: getGatewayBaseUrl(runtime.config.listen_host, runtime.config.listen_port),
}).catch((error) => logger(`[state] failed to update runtime state: ${error?.message || error}`));
logger(
`[start] codex retry gateway profile=${runtime.config.profile_name || "default"} image_profile=${runtime.config.image_profile_name || "-"} auth=${normalizeAuthMode(runtime.config.upstream_auth_mode)} reasoning_match_mode=${normalizeReasoningMatchMode(runtime.config.reasoning_match_mode)} listening on http://${runtime.config.listen_host}:${runtime.config.listen_port} -> ${runtime.config.upstream_base_url}`,
+21 -4
View File
@@ -73,10 +73,24 @@ export function getGatewayStatePaths(stateRoot = DEFAULT_STATE_ROOT) {
};
}
export function getGatewayBaseUrl(listenHost, listenPort) {
export function getGatewayListenerBaseUrl(listenHost, listenPort) {
return `http://${listenHost}:${listenPort}`;
}
export function getGatewayBaseUrl(listenHost, listenPort) {
// 0.0.0.0 is a valid bind address, but not a client destination. Keep the
// listener wildcard intact and use loopback for Codex, health probes, and UI
// links that originate on the same machine.
const clientHost = `${listenHost ?? ""}`.trim() === "0.0.0.0" ? "127.0.0.1" : listenHost;
return getGatewayListenerBaseUrl(clientHost, listenPort);
}
export function isGatewayBaseUrlForListener(baseUrl, listenHost, listenPort) {
const candidate = `${baseUrl ?? ""}`.trim();
return candidate === getGatewayBaseUrl(listenHost, listenPort)
|| candidate === getGatewayListenerBaseUrl(listenHost, listenPort);
}
export function getGatewayBaseUrlFromConfig(gatewayConfig) {
if (!gatewayConfig) {
return null;
@@ -408,14 +422,14 @@ export async function installForCurrentProvider({
const existingState = await readJsonFile(paths.statePath);
let originalBaseUrl = providerContext.currentBaseUrl;
if (providerContext.currentBaseUrl === localGatewayBaseUrl) {
if (isGatewayBaseUrlForListener(providerContext.currentBaseUrl, listenHost, listenPort)) {
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) {
if (isGatewayBaseUrlForListener(originalBaseUrl, listenHost, listenPort)) {
throw new Error("A real upstream_base_url could not be determined.");
}
@@ -557,7 +571,10 @@ export async function launchUi({
const existingGatewayConfig = await readJsonFile(paths.configPath);
const stateGatewayBaseUrl = existingState?.gateway_base_url ? `${existingState.gateway_base_url}` : null;
const configGatewayBaseUrl = getGatewayBaseUrlFromConfig(existingGatewayConfig);
const managedGatewayBaseUrls = [requestedGatewayBaseUrl];
const managedGatewayBaseUrls = [
requestedGatewayBaseUrl,
getGatewayListenerBaseUrl(listenHost, listenPort),
];
for (const candidate of [stateGatewayBaseUrl, configGatewayBaseUrl]) {
if (candidate && !managedGatewayBaseUrls.includes(candidate)) {
managedGatewayBaseUrls.push(candidate);
+26 -5
View File
@@ -19,6 +19,7 @@ import {
getCodexProviderContext,
getGatewayBaseUrl,
getGatewayStatePaths,
isGatewayBaseUrlForListener,
normalizeIntArray,
normalizePhraseArray,
normalizeReasoningMatchMode,
@@ -345,12 +346,14 @@ function buildProfileConfig({
imageProfileEnv,
existingGatewayConfig,
providerContext,
listenHost,
listenPort,
localGatewayBaseUrl,
}) {
const upstreamBaseUrl =
profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL ||
existingGatewayConfig?.upstream_base_url ||
(providerContext.currentBaseUrl === localGatewayBaseUrl
(isGatewayBaseUrlForListener(providerContext.currentBaseUrl, listenHost, listenPort)
? null
: providerContext.currentBaseUrl);
@@ -425,15 +428,22 @@ function buildProfileConfig({
};
}
async function ensureCodexPointsToGateway({ paths, codexConfigPath, providerContext, localGatewayBaseUrl }) {
async function ensureCodexPointsToGateway({
paths,
codexConfigPath,
providerContext,
listenHost,
listenPort,
localGatewayBaseUrl,
}) {
await ensureDirectory(paths.backupDir);
const existingState = await readJsonFile(paths.statePath);
let originalBaseUrl = providerContext.currentBaseUrl;
if (providerContext.currentBaseUrl === localGatewayBaseUrl) {
if (isGatewayBaseUrlForListener(providerContext.currentBaseUrl, listenHost, listenPort)) {
originalBaseUrl = existingState?.original_base_url || null;
}
if (!originalBaseUrl || originalBaseUrl === localGatewayBaseUrl) {
if (!originalBaseUrl || isGatewayBaseUrlForListener(originalBaseUrl, listenHost, listenPort)) {
throw new Error("A restorable original Codex base_url could not be determined.");
}
@@ -536,19 +546,30 @@ async function main() {
imageProfileEnv,
existingGatewayConfig,
providerContext,
listenHost,
listenPort,
localGatewayBaseUrl,
});
const installState = options.noCodexConfigUpdate
? {
existingState,
originalBaseUrl: providerContext.currentBaseUrl,
originalBaseUrl: isGatewayBaseUrlForListener(providerContext.currentBaseUrl, listenHost, listenPort)
? (
existingState?.original_base_url
&& !isGatewayBaseUrlForListener(existingState.original_base_url, listenHost, listenPort)
? `${existingState.original_base_url}`
: null
)
: providerContext.currentBaseUrl,
backupPath: null,
}
: await ensureCodexPointsToGateway({
paths,
codexConfigPath,
providerContext,
listenHost,
listenPort,
localGatewayBaseUrl,
});
+58 -4
View File
@@ -638,14 +638,18 @@ async function run() {
const gatewayPort = await getFreePort();
const configPath = path.join(tempRoot, "config.json");
const logPath = path.join(tempRoot, "gateway.log");
const codexConfigPath = path.join(tempRoot, ".codex", "config.toml");
const profilesDir = path.join(tempRoot, ".config", "codex-retry-gateway", "profiles");
const imageProfilesDir = path.join(tempRoot, ".config", "codex-retry-gateway", "image-profiles");
const gatewayBaseUrl = `http://127.0.0.1:${gatewayPort}`;
const legacyWildcardGatewayBaseUrl = `http://0.0.0.0:${gatewayPort}`;
const upstreamBaseUrl = `http://127.0.0.1:${upstreamPort}`;
const config = {
profile_name: "legacy-text",
listen_host: "127.0.0.1",
listen_host: "0.0.0.0",
listen_port: gatewayPort,
upstream_base_url: `http://127.0.0.1:${upstreamPort}`,
upstream_base_url: upstreamBaseUrl,
image_base_url: `http://127.0.0.1:${imageUpstreamPort}`,
image_auth_mode: "fixed_bearer",
image_auth_env: "TEST_CODEX_RETRY_GATEWAY_IMAGE_API_KEY",
@@ -672,12 +676,39 @@ async function run() {
await writeFile(
path.join(profilesDir, "legacy-text.env"),
[
`CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL=http://127.0.0.1:${upstreamPort}`,
"CODEX_RETRY_GATEWAY_LISTEN_HOST=0.0.0.0",
`CODEX_RETRY_GATEWAY_LISTEN_PORT=${gatewayPort}`,
`CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL=${upstreamBaseUrl}`,
"CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE=passthrough",
"",
].join("\n"),
"utf8",
);
await mkdir(path.dirname(codexConfigPath), { recursive: true });
await writeFile(
codexConfigPath,
[
'model_provider = "custom"',
"",
"[model_providers.custom]",
'name = "Gateway E2E Test"',
`base_url = "${legacyWildcardGatewayBaseUrl}"`,
'wire_api = "responses"',
"",
].join("\n"),
"utf8",
);
await writeFile(
path.join(tempRoot, "state.json"),
`${JSON.stringify({
codex_config_path: codexConfigPath,
provider_name: "custom",
original_base_url: upstreamBaseUrl,
gateway_base_url: legacyWildcardGatewayBaseUrl,
profile_name: "legacy-text",
}, null, 2)}\n`,
"utf8",
);
await writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
const upstream = await startFakeUpstream(upstreamPort, { label: "default" });
@@ -1036,7 +1067,7 @@ async function run() {
headers: { ...adminHeaders, "content-type": "application/json" },
body: JSON.stringify({
name: "legacy-text",
listen_host: "127.0.0.1",
listen_host: "0.0.0.0",
listen_port: gatewayPort,
upstream_base_url: `http://127.0.0.1:${upstreamPort}`,
auth_mode: "passthrough",
@@ -1044,6 +1075,29 @@ async function run() {
},
);
assert(saveActiveTextProfileResponse.status === 200, `当前文本 profile 保存失败: ${saveActiveTextProfileResponse.status}`);
const saveActiveTextProfilePayload = await saveActiveTextProfileResponse.json();
assert(
saveActiveTextProfilePayload?.applied_profile?.codex_provider_sync?.updated === true,
"保存活跃 wildcard listener profile 时未同步 Codex provider",
);
assert(
saveActiveTextProfilePayload?.applied_profile?.codex_provider_sync?.gateway_base_url === gatewayBaseUrl,
"保存活跃 wildcard listener profile 时未使用回环 gateway URL",
);
const savedCodexConfig = await readFile(codexConfigPath, "utf8");
assert(
savedCodexConfig.includes(`base_url = "${gatewayBaseUrl}"`),
"保存 wildcard listener profile 后 Codex config 未指向 127.0.0.1",
);
assert(
!savedCodexConfig.includes(`base_url = "${legacyWildcardGatewayBaseUrl}"`),
"保存 wildcard listener profile 后 Codex config 仍指向 0.0.0.0",
);
const savedState = JSON.parse(await readFile(path.join(tempRoot, "state.json"), "utf8"));
assert(savedState.gateway_base_url === gatewayBaseUrl, "runtime state 未规范化 wildcard gateway URL");
assert(savedState.original_base_url === upstreamBaseUrl, "同步 Codex config 时覆盖了原始上游 URL");
assert(savedState.latest_backup_path, "同步 Codex config 时未创建可恢复备份");
assert((await stat(savedState.latest_backup_path)).isFile(), "Codex config 备份文件不存在");
const afterTextSaveStatusResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`, { headers: adminHeaders });
const afterTextSaveStatusPayload = await afterTextSaveStatusResponse.json();
assert(afterTextSaveStatusPayload?.config?.image_profile_name === "image-primary", "保存文本 profile 不应重置图片 profile");
+6
View File
@@ -8,6 +8,8 @@ import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import { getGatewayBaseUrl } from "./admin-lib.mjs";
const scriptsRoot = import.meta.dirname;
const launchScript = path.join(scriptsRoot, "launch-ui.sh");
const restoreScript = path.join(scriptsRoot, "restore-codex-config.sh");
@@ -122,6 +124,10 @@ async function run() {
const gatewayPort = await getFreePort();
const gatewayBaseUrl = `http://127.0.0.1:${gatewayPort}`;
const upstreamBaseUrl = `http://127.0.0.1:${upstreamPort}`;
assert(
getGatewayBaseUrl("0.0.0.0", gatewayPort) === gatewayBaseUrl,
"Wildcard listener must normalize to loopback for local gateway URLs",
);
await mkdir(codexDir, { recursive: true });
await writeFile(