feat: separate image profiles from text routing

This commit is contained in:
2026-07-10 07:05:22 +08:00
parent 2ff38c71bb
commit 0d244e596c
7 changed files with 2633 additions and 185 deletions
+186 -2
View File
@@ -2,7 +2,7 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import { copyFile, readFile, rm } from "node:fs/promises";
import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -32,8 +32,17 @@ import {
} from "./admin-lib.mjs";
const DEFAULT_PROFILES_DIR = path.join(os.homedir(), ".config", "codex-retry-gateway", "profiles");
const DEFAULT_IMAGE_PROFILES_DIR = path.join(os.homedir(), ".config", "codex-retry-gateway", "image-profiles");
const DEFAULT_REQUEST_BODY_LIMIT_BYTES = 1024 * 1024 * 1024;
const LEGACY_DEFAULT_REQUEST_BODY_LIMIT_BYTES = 10 * 1024 * 1024;
const IMAGE_PROFILE_ENV_KEYS = [
"CODEX_RETRY_GATEWAY_IMAGE_BASE_URL",
"CODEX_RETRY_GATEWAY_IMAGE_AUTH_MODE",
"CODEX_RETRY_GATEWAY_IMAGE_AUTH_ENV",
"CODEX_RETRY_GATEWAY_IMAGE_AUTH_FILE",
"CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_PATH",
"CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_KEY",
];
function parseEnvFile(content) {
const parsed = {};
@@ -67,6 +76,10 @@ function getProfileName(options) {
return `${options.profile || positional || process.env.CODEX_RETRY_GATEWAY_PROFILE || ""}`.trim();
}
function getImageProfileName(options) {
return `${options.imageProfile || process.env.CODEX_RETRY_GATEWAY_IMAGE_PROFILE || ""}`.trim();
}
function resolveDefaultRequestedProfileName(existingState) {
const stateProfileName = `${existingState?.profile_name || ""}`.trim();
if (stateProfileName) {
@@ -93,6 +106,31 @@ function resolvePreferredProfileName({ requestedProfileName, existingState, pref
return stateProfileName;
}
function profileFileExists(profilesDir, profileName) {
return /^[A-Za-z0-9_.-]+$/.test(`${profileName || ""}`)
&& fs.existsSync(path.join(profilesDir, `${profileName}.env`));
}
function resolveImageProfileName({
requestedImageProfileName,
textProfileName,
existingState,
preferStateProfile,
imageProfilesDir,
}) {
if (requestedImageProfileName) {
return profileFileExists(imageProfilesDir, requestedImageProfileName)
? requestedImageProfileName
: "";
}
const candidates = [];
if (preferStateProfile) {
candidates.push(`${existingState?.image_profile_name || ""}`.trim());
}
candidates.push(textProfileName);
return candidates.find((profileName) => profileFileExists(imageProfilesDir, profileName)) || "";
}
function boolFromEnv(value, fallback = false) {
if (value === undefined || value === null || value === "") {
return fallback;
@@ -165,6 +203,58 @@ function buildProfileAuthConfig(profileEnv) {
return authConfig;
}
function inferImageProfileAuthMode(imageProfileEnv) {
if (imageProfileEnv.CODEX_RETRY_GATEWAY_IMAGE_AUTH_MODE) {
return normalizeAuthMode(imageProfileEnv.CODEX_RETRY_GATEWAY_IMAGE_AUTH_MODE);
}
if (
imageProfileEnv.CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_PATH ||
imageProfileEnv.CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_KEY
) {
return "auth_json";
}
if (
imageProfileEnv.CODEX_RETRY_GATEWAY_IMAGE_AUTH_FILE ||
imageProfileEnv.CODEX_RETRY_GATEWAY_IMAGE_AUTH_ENV
) {
return "fixed_bearer";
}
return "fixed_bearer";
}
function buildImageProfileAuthConfig(imageProfileEnv) {
const authMode = inferImageProfileAuthMode(imageProfileEnv);
const authConfig = {
image_auth_mode: authMode,
image_auth_env: "",
image_auth_file: "",
image_auth_json_path: "",
image_auth_json_key: "",
};
if (authMode === "fixed_bearer") {
authConfig.image_auth_env =
imageProfileEnv.CODEX_RETRY_GATEWAY_IMAGE_AUTH_ENV ||
"CODEX_RETRY_GATEWAY_IMAGE_API_KEY";
authConfig.image_auth_file =
imageProfileEnv.CODEX_RETRY_GATEWAY_IMAGE_AUTH_FILE ||
"";
} else if (authMode === "manual_bearer") {
authConfig.image_auth_file =
imageProfileEnv.CODEX_RETRY_GATEWAY_IMAGE_AUTH_FILE ||
"";
} else if (authMode === "auth_json") {
authConfig.image_auth_json_path =
imageProfileEnv.CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_PATH ||
"";
authConfig.image_auth_json_key =
imageProfileEnv.CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_KEY ||
"OPENAI_API_KEY";
}
return authConfig;
}
async function loadProfileEnv(profileName, profilesDir) {
const profilePath = path.join(profilesDir, `${profileName}.env`);
if (!fs.existsSync(profilePath)) {
@@ -177,7 +267,68 @@ async function loadProfileEnv(profileName, profilesDir) {
};
}
function buildProfileConfig({ profileName, profileEnv, existingGatewayConfig, providerContext, localGatewayBaseUrl }) {
function serializeEnvValue(value) {
const text = `${value ?? ""}`;
if (/^[A-Za-z0-9_./:@?&=,+-]*$/.test(text)) {
return text;
}
return JSON.stringify(text);
}
function hasLegacyImageProfileConfig(profileEnv) {
return Boolean(`${profileEnv?.CODEX_RETRY_GATEWAY_IMAGE_BASE_URL || ""}`.trim());
}
async function migrateLegacyImageProfile(profileName, profileEnv, imageProfilesDir) {
if (!hasLegacyImageProfileConfig(profileEnv)) {
return null;
}
const imageProfilePath = path.join(imageProfilesDir, `${profileName}.env`);
if (fs.existsSync(imageProfilePath)) {
return { profilePath: imageProfilePath, migrated: false };
}
const pairs = IMAGE_PROFILE_ENV_KEYS
.filter((key) => profileEnv[key] !== undefined)
.map((key) => [key, profileEnv[key]]);
await mkdir(imageProfilesDir, { recursive: true });
await writeFile(
imageProfilePath,
[
"# Migrated from a legacy text profile by codex-retry-gateway.",
"# Image configuration is now independent from text profiles.",
...pairs.map(([key, value]) => `${key}=${serializeEnvValue(value)}`),
"",
].join("\n"),
{ encoding: "utf8", mode: 0o600 },
);
return { profilePath: imageProfilePath, migrated: true };
}
async function loadImageProfileEnv(profileName, imageProfilesDir) {
if (!profileName) {
return { profilePath: null, env: {} };
}
const profilePath = path.join(imageProfilesDir, `${profileName}.env`);
if (!fs.existsSync(profilePath)) {
throw new Error(`Image profile env file was not found: ${profilePath}`);
}
const content = await readFile(profilePath, "utf8");
return {
profilePath,
env: parseEnvFile(content),
};
}
function buildProfileConfig({
profileName,
profileEnv,
imageProfileName,
imageProfileEnv,
existingGatewayConfig,
providerContext,
localGatewayBaseUrl,
}) {
const upstreamBaseUrl =
profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL ||
existingGatewayConfig?.upstream_base_url ||
@@ -190,6 +341,7 @@ function buildProfileConfig({ profileName, profileEnv, existingGatewayConfig, pr
}
const profileAuthConfig = buildProfileAuthConfig(profileEnv);
const imageProfileAuthConfig = buildImageProfileAuthConfig(imageProfileEnv || {});
const reasoningMatchMode = normalizeReasoningMatchMode(
profileEnv.CODEX_RETRY_GATEWAY_REASONING_MATCH_MODE ||
existingGatewayConfig?.reasoning_match_mode ||
@@ -204,6 +356,9 @@ function buildProfileConfig({ profileName, profileEnv, existingGatewayConfig, pr
: DEFAULT_LISTEN_PORT,
upstream_base_url: upstreamBaseUrl,
...profileAuthConfig,
image_profile_name: imageProfileName || "",
image_base_url: imageProfileEnv?.CODEX_RETRY_GATEWAY_IMAGE_BASE_URL || "",
...imageProfileAuthConfig,
request_body_limit_bytes: profileEnv.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES
? normalizeRequestBodyLimitBytes(profileEnv.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES)
: normalizeRequestBodyLimitBytes(existingGatewayConfig?.request_body_limit_bytes),
@@ -288,6 +443,7 @@ async function ensureCodexPointsToGateway({ paths, codexConfigPath, providerCont
async function main() {
const options = parseOptions(process.argv, { booleanFlags: ["no-codex-config-update", "prefer-state-profile"] });
const profilesDir = options.profilesDir || DEFAULT_PROFILES_DIR;
const imageProfilesDir = options.imageProfilesDir || DEFAULT_IMAGE_PROFILES_DIR;
const stateRoot = options.stateRoot || process.env.CODEX_RETRY_GATEWAY_STATE_ROOT || DEFAULT_STATE_ROOT;
const codexConfigPath =
options.codexConfigPath ||
@@ -315,7 +471,31 @@ async function main() {
}
const { profilePath, env: profileEnv } = await loadProfileEnv(profileName, profilesDir);
const migration = await migrateLegacyImageProfile(profileName, profileEnv, imageProfilesDir);
const requestedImageProfileName = getImageProfileName(options);
const imageProfileName = resolveImageProfileName({
requestedImageProfileName,
textProfileName: profileName,
existingState,
preferStateProfile: Boolean(options.preferStateProfile),
imageProfilesDir,
});
if (requestedImageProfileName && !imageProfileName) {
throw new Error(`Image profile env file was not found: ${path.join(imageProfilesDir, `${requestedImageProfileName}.env`)}`);
}
const { profilePath: imageProfilePath, env: imageProfileEnv } = await loadImageProfileEnv(
imageProfileName,
imageProfilesDir,
);
if (migration?.migrated) {
process.stdout.write(
`[run-profile] migrated legacy image settings text=${profileName} image=${imageProfileName || profileName}\n`,
);
}
for (const [key, value] of Object.entries(profileEnv)) {
if (key.startsWith("CODEX_RETRY_GATEWAY_IMAGE_")) {
continue;
}
process.env[key] = value;
}
@@ -330,6 +510,8 @@ async function main() {
const gatewayConfig = buildProfileConfig({
profileName,
profileEnv,
imageProfileName,
imageProfileEnv,
existingGatewayConfig,
providerContext,
localGatewayBaseUrl,
@@ -355,6 +537,8 @@ async function main() {
last_started_at: new Date().toISOString(),
profile_name: profileName,
profile_env_path: profilePath,
image_profile_name: imageProfileName || "",
image_profile_env_path: imageProfilePath,
codex_config_path: codexConfigPath,
provider_name: providerContext.providerName,
original_base_url: installState.originalBaseUrl,