diff --git a/README.md b/README.md index 2f755b6..5489918 100644 --- a/README.md +++ b/README.md @@ -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:`,不会把不可作为客户端目标的 `0.0.0.0` 写入 provider `base_url`。 +- 从管理页保存当前正在运行的文本 Profile 时,会只同步当前 `model_provider` 的 `base_url`;首次需要改写时保留/创建 `config.toml` 备份,并保留已知的原始上游地址。 - 默认示例上游见 `config.example.json` - 实际运行时配置会写到当前用户目录下的 gateway 状态目录 diff --git a/gateway.mjs b/gateway.mjs index 9ed072c..cfbe377 100644 --- a/gateway.mjs +++ b/gateway.mjs @@ -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,20 +3219,31 @@ async function applyProfileConfig(runtime, profileName) { throw new Error("该 profile 的监听地址或端口与当前实例不同,暂不支持无重启热切换"); } - runtime.config = { - ...config, - model_remap_map: parseModelRemapMap(config.model_remap), - }; + const gatewayBaseUrl = getGatewayBaseUrl(config.listen_host, config.listen_port); + const codexProviderSync = await syncCurrentCodexProviderToGateway(runtime, gatewayBaseUrl); - await writeConfig(runtime.configPath, runtime.config); - 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}`, - last_started_at: new Date().toISOString(), - }); + try { + runtime.config = { + ...config, + model_remap_map: parseModelRemapMap(config.model_remap), + }; + + await writeConfig(runtime.configPath, runtime.config); + await updateRuntimeState(runtime, { + profile_name: runtime.config.profile_name || "default", + profile_env_path: profilePath, + 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}`, diff --git a/scripts/admin-lib.mjs b/scripts/admin-lib.mjs index bb8c92c..8cacf37 100644 --- a/scripts/admin-lib.mjs +++ b/scripts/admin-lib.mjs @@ -73,9 +73,23 @@ export function getGatewayStatePaths(stateRoot = DEFAULT_STATE_ROOT) { }; } -export function getGatewayBaseUrl(listenHost, listenPort) { - return `http://${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) { @@ -408,16 +422,16 @@ export async function installForCurrentProvider({ 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."); - } + 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 (isGatewayBaseUrlForListener(originalBaseUrl, listenHost, listenPort)) { + 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); @@ -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); diff --git a/scripts/run-profile.mjs b/scripts/run-profile.mjs index b9654ab..96aec29 100644 --- a/scripts/run-profile.mjs +++ b/scripts/run-profile.mjs @@ -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, }); diff --git a/scripts/test-gateway-e2e.mjs b/scripts/test-gateway-e2e.mjs index 84f321b..fca4069 100644 --- a/scripts/test-gateway-e2e.mjs +++ b/scripts/test-gateway-e2e.mjs @@ -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"); diff --git a/scripts/test-launch-ui-unix.mjs b/scripts/test-launch-ui-unix.mjs index ef73a5d..9d854bd 100644 --- a/scripts/test-launch-ui-unix.mjs +++ b/scripts/test-launch-ui-unix.mjs @@ -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(