feat: add verified profile import and export
This commit is contained in:
+407
@@ -25,14 +25,21 @@ const THREAD_RULE_ITEM_API_PREFIX = `${THREAD_RULES_API_PATH}/`;
|
||||
const PROFILES_API_PATH = `${ADMIN_BASE_PATH}/api/profiles`;
|
||||
const PROFILE_PROBE_API_PATH = `${ADMIN_BASE_PATH}/api/profiles/probe`;
|
||||
const PROFILE_SWITCH_API_PATH = `${ADMIN_BASE_PATH}/api/profiles/switch`;
|
||||
const PROFILE_EXPORT_API_PATH = `${ADMIN_BASE_PATH}/api/profiles/export`;
|
||||
const PROFILE_IMPORT_API_PATH = `${ADMIN_BASE_PATH}/api/profiles/import`;
|
||||
const PROFILE_ITEM_API_PREFIX = `${ADMIN_BASE_PATH}/api/profiles/`;
|
||||
const IMAGE_PROFILES_API_PATH = `${ADMIN_BASE_PATH}/api/image-profiles`;
|
||||
const IMAGE_PROFILE_PROBE_API_PATH = `${ADMIN_BASE_PATH}/api/image-profiles/probe`;
|
||||
const IMAGE_PROFILE_SWITCH_API_PATH = `${ADMIN_BASE_PATH}/api/image-profiles/switch`;
|
||||
const IMAGE_PROFILE_EXPORT_API_PATH = `${ADMIN_BASE_PATH}/api/image-profiles/export`;
|
||||
const IMAGE_PROFILE_IMPORT_API_PATH = `${ADMIN_BASE_PATH}/api/image-profiles/import`;
|
||||
const IMAGE_PROFILE_ITEM_API_PREFIX = `${ADMIN_BASE_PATH}/api/image-profiles/`;
|
||||
const RESTORE_API_PATH = `${ADMIN_BASE_PATH}/api/restore`;
|
||||
const STATUS_REASONING_COUNT_LIMIT = 24;
|
||||
const MANAGEMENT_ACCESS_COOKIE = "codex_retry_gateway_access";
|
||||
const PROFILE_BUNDLE_FORMAT = "codex-retry-gateway-profile";
|
||||
const PROFILE_BUNDLE_VERSION = 1;
|
||||
const PROFILE_TRANSFER_BODY_LIMIT_BYTES = 512 * 1024;
|
||||
const RESPONSES_REASONING_RETRY_PATHS = new Set(["/responses", "/v1/responses"]);
|
||||
const REASONING_RETRY_ABORT_CLIENT = "reasoning_retry_client_disconnected";
|
||||
const REASONING_RETRY_ABORT_WINNER = "reasoning_retry_winner_selected";
|
||||
@@ -265,6 +272,28 @@ function timingSafeEquals(left, right) {
|
||||
return mismatch === 0;
|
||||
}
|
||||
|
||||
class ProfileTransferError extends Error {
|
||||
constructor(message, code, statusCode = 400) {
|
||||
super(message);
|
||||
this.name = "ProfileTransferError";
|
||||
this.code = code;
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeExportableKey(value) {
|
||||
return `${value || ""}`.trim().replace(/^Bearer\s+/i, "").trim();
|
||||
}
|
||||
|
||||
function profileTransferErrorPayload(error, fallbackCode = "profile_transfer_failed") {
|
||||
return {
|
||||
error: {
|
||||
message: `${error?.message || error}`,
|
||||
code: `${error?.code || fallbackCode}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseCookieHeader(cookieHeader) {
|
||||
const cookies = {};
|
||||
for (const part of `${cookieHeader || ""}`.split(";")) {
|
||||
@@ -2019,6 +2048,240 @@ async function writeImageProfile(runtime, payload) {
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyProfileExportKey(authConfig, providedKey, scopeLabel) {
|
||||
const verificationKey = normalizeExportableKey(providedKey);
|
||||
if (!verificationKey) {
|
||||
throw new ProfileTransferError(
|
||||
`导出${scopeLabel} profile 前必须再次输入当前 API key`,
|
||||
"profile_export_key_required",
|
||||
);
|
||||
}
|
||||
|
||||
let resolvedAuth;
|
||||
try {
|
||||
resolvedAuth = await resolveUpstreamAuth(authConfig, `${scopeLabel}_profile_export`);
|
||||
} catch {
|
||||
throw new ProfileTransferError(
|
||||
`${scopeLabel} profile 当前没有可解析、可验证的 API key`,
|
||||
"profile_export_key_unavailable",
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedKey = normalizeExportableKey(resolvedAuth.authorization);
|
||||
if (!resolvedKey) {
|
||||
throw new ProfileTransferError(
|
||||
`${scopeLabel} profile 使用 passthrough 或没有 profile-owned API key,无法安全导出`,
|
||||
"profile_export_key_unavailable",
|
||||
);
|
||||
}
|
||||
if (!timingSafeEquals(verificationKey, resolvedKey)) {
|
||||
throw new ProfileTransferError(
|
||||
"API key 验证失败,未导出 profile",
|
||||
"profile_export_key_invalid",
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
key: resolvedKey,
|
||||
source_auth_mode: resolvedAuth.mode,
|
||||
source_auth_source: resolvedAuth.source,
|
||||
};
|
||||
}
|
||||
|
||||
function imageProfileAuthConfig(config) {
|
||||
return {
|
||||
upstream_auth_mode: config.image_auth_mode,
|
||||
upstream_auth_env: config.image_auth_env,
|
||||
upstream_auth_file: config.image_auth_file,
|
||||
upstream_auth_json_path: config.image_auth_json_path,
|
||||
upstream_auth_json_key: config.image_auth_json_key,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildTextProfileExportBundle(runtime, payload) {
|
||||
const profileName = `${payload?.profile || ""}`.trim();
|
||||
if (!profileName) {
|
||||
throw new ProfileTransferError("缺少要导出的 profile", "profile_required");
|
||||
}
|
||||
|
||||
const { env, config } = await loadProfileConfigForProbe(runtime, profileName);
|
||||
const credential = await verifyProfileExportKey(config, payload?.verification_key, "文本");
|
||||
return {
|
||||
format: PROFILE_BUNDLE_FORMAT,
|
||||
version: PROFILE_BUNDLE_VERSION,
|
||||
kind: "text",
|
||||
exported_at: new Date().toISOString(),
|
||||
source_auth_mode: credential.source_auth_mode,
|
||||
source_auth_source: credential.source_auth_source,
|
||||
profile: {
|
||||
name: profileName,
|
||||
listen_host: config.listen_host,
|
||||
listen_port: config.listen_port,
|
||||
upstream_base_url: config.upstream_base_url,
|
||||
auth_mode: "manual_bearer",
|
||||
key: credential.key,
|
||||
management_access_key: normalizeManagementAccessKey(
|
||||
env.CODEX_RETRY_GATEWAY_MANAGEMENT_ACCESS_KEY,
|
||||
),
|
||||
request_history_limit: config.request_history_limit,
|
||||
model_remap: config.model_remap || "",
|
||||
reasoning_match_mode: config.reasoning_match_mode,
|
||||
reasoning_equals: config.reasoning_equals,
|
||||
retryable_status_codes: config.retryable_status_codes,
|
||||
retryable_error_messages: config.retryable_error_messages,
|
||||
upstream_fetch_retry_attempts: config.upstream_fetch_retry_attempts,
|
||||
upstream_fetch_retry_backoff_ms: config.upstream_fetch_retry_backoff_ms,
|
||||
endpoints: config.endpoints,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function buildImageProfileExportBundle(runtime, payload) {
|
||||
const profileName = `${payload?.profile || ""}`.trim();
|
||||
if (!profileName) {
|
||||
throw new ProfileTransferError("缺少要导出的图片 profile", "profile_required");
|
||||
}
|
||||
|
||||
const { config } = await loadImageProfileConfigForProbe(runtime, profileName);
|
||||
if (!config.image_base_url) {
|
||||
throw new ProfileTransferError(
|
||||
`图片 profile ${profileName} 未配置 Base URL`,
|
||||
"profile_export_disabled",
|
||||
);
|
||||
}
|
||||
const credential = await verifyProfileExportKey(
|
||||
imageProfileAuthConfig(config),
|
||||
payload?.verification_key,
|
||||
"图片",
|
||||
);
|
||||
return {
|
||||
format: PROFILE_BUNDLE_FORMAT,
|
||||
version: PROFILE_BUNDLE_VERSION,
|
||||
kind: "image",
|
||||
exported_at: new Date().toISOString(),
|
||||
source_auth_mode: credential.source_auth_mode,
|
||||
source_auth_source: credential.source_auth_source,
|
||||
profile: {
|
||||
name: profileName,
|
||||
base_url: config.image_base_url,
|
||||
auth_mode: "manual_bearer",
|
||||
key: credential.key,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseProfileImportBundle(payload, expectedKind) {
|
||||
const bundle = payload?.bundle;
|
||||
if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) {
|
||||
throw new ProfileTransferError("导入请求缺少 profile bundle", "profile_bundle_required");
|
||||
}
|
||||
if (bundle.format !== PROFILE_BUNDLE_FORMAT || bundle.version !== PROFILE_BUNDLE_VERSION) {
|
||||
throw new ProfileTransferError(
|
||||
"不支持的 profile 导出文件格式或版本",
|
||||
"profile_bundle_unsupported",
|
||||
);
|
||||
}
|
||||
if (bundle.kind !== expectedKind) {
|
||||
throw new ProfileTransferError(
|
||||
expectedKind === "text" ? "该文件不是文本 profile" : "该文件不是图片 profile",
|
||||
"profile_bundle_kind_mismatch",
|
||||
);
|
||||
}
|
||||
if (!bundle.profile || typeof bundle.profile !== "object" || Array.isArray(bundle.profile)) {
|
||||
throw new ProfileTransferError("profile bundle 缺少 profile 配置", "profile_bundle_invalid");
|
||||
}
|
||||
const key = normalizeExportableKey(bundle.profile.key);
|
||||
if (!key) {
|
||||
throw new ProfileTransferError("profile bundle 中缺少 API key", "profile_bundle_key_required");
|
||||
}
|
||||
const name = `${payload?.name || bundle.profile.name || ""}`.trim();
|
||||
validateProfileName(name);
|
||||
return {
|
||||
bundle,
|
||||
profile: bundle.profile,
|
||||
key,
|
||||
name,
|
||||
overwrite: payload?.overwrite === true,
|
||||
};
|
||||
}
|
||||
|
||||
async function importTextProfileBundle(runtime, payload) {
|
||||
const imported = parseProfileImportBundle(payload, "text");
|
||||
const activeProfile = runtime.config.profile_name || "default";
|
||||
const profilePath = path.join(runtime.paths.profilesDir, `${imported.name}.env`);
|
||||
if (imported.name === activeProfile) {
|
||||
throw new ProfileTransferError(
|
||||
"不能直接覆盖当前运行的 profile;请改名导入,探针验证后再切换",
|
||||
"profile_import_active_conflict",
|
||||
409,
|
||||
);
|
||||
}
|
||||
const existed = fs.existsSync(profilePath);
|
||||
if (existed && !imported.overwrite) {
|
||||
throw new ProfileTransferError(
|
||||
`profile 已存在: ${imported.name};确认后可选择覆盖导入`,
|
||||
"profile_import_exists",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await writeProfile(runtime, {
|
||||
name: imported.name,
|
||||
listen_host: imported.profile.listen_host,
|
||||
listen_port: imported.profile.listen_port,
|
||||
upstream_base_url: imported.profile.upstream_base_url,
|
||||
auth_mode: "manual_bearer",
|
||||
manual_secret: imported.key,
|
||||
management_access_key: imported.profile.management_access_key,
|
||||
request_history_limit: imported.profile.request_history_limit,
|
||||
model_remap: imported.profile.model_remap,
|
||||
reasoning_match_mode: imported.profile.reasoning_match_mode,
|
||||
reasoning_equals: imported.profile.reasoning_equals,
|
||||
retryable_status_codes: imported.profile.retryable_status_codes,
|
||||
retryable_error_messages: imported.profile.retryable_error_messages,
|
||||
upstream_fetch_retry_attempts: imported.profile.upstream_fetch_retry_attempts,
|
||||
upstream_fetch_retry_backoff_ms: imported.profile.upstream_fetch_retry_backoff_ms,
|
||||
endpoints: imported.profile.endpoints,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
overwritten: existed && imported.overwrite,
|
||||
};
|
||||
}
|
||||
|
||||
async function importImageProfileBundle(runtime, payload) {
|
||||
const imported = parseProfileImportBundle(payload, "image");
|
||||
const activeProfile = `${runtime.config.image_profile_name || ""}`.trim();
|
||||
const profilePath = path.join(runtime.paths.imageProfilesDir, `${imported.name}.env`);
|
||||
if (imported.name === activeProfile) {
|
||||
throw new ProfileTransferError(
|
||||
"不能直接覆盖当前运行的图片 profile;请改名导入,探针验证后再切换",
|
||||
"profile_import_active_conflict",
|
||||
409,
|
||||
);
|
||||
}
|
||||
const existed = fs.existsSync(profilePath);
|
||||
if (existed && !imported.overwrite) {
|
||||
throw new ProfileTransferError(
|
||||
`图片 profile 已存在: ${imported.name};确认后可选择覆盖导入`,
|
||||
"profile_import_exists",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await writeImageProfile(runtime, {
|
||||
name: imported.name,
|
||||
base_url: imported.profile.base_url,
|
||||
auth_mode: "manual_bearer",
|
||||
manual_secret: imported.key,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
overwritten: existed && imported.overwrite,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMetricsSnapshot(monitor) {
|
||||
const reasoning516Count = monitor.observed_reasoning_counts["516"] || 0;
|
||||
const inspectedResponseCount = monitor.inspected_response_count;
|
||||
@@ -3686,6 +3949,78 @@ async function handleManagementRequest(runtime, req, res, requestUrl) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === PROFILE_EXPORT_API_PATH && req.method === "POST") {
|
||||
const body = await readRequestBody(
|
||||
req,
|
||||
Math.min(runtime.config.request_body_limit_bytes, PROFILE_TRANSFER_BODY_LIMIT_BYTES),
|
||||
);
|
||||
const payload = parseJsonSafely(body);
|
||||
if (!payload) {
|
||||
jsonResponse(req, res, 400, {
|
||||
error: {
|
||||
message: "profile 导出请求必须是有效 JSON",
|
||||
code: "invalid_json",
|
||||
},
|
||||
}, { "cache-control": "no-store" });
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const bundle = await buildTextProfileExportBundle(runtime, payload);
|
||||
runtime.logger(
|
||||
`[profile] exported name=${bundle.profile.name} source_auth=${bundle.source_auth_mode}/${bundle.source_auth_source}`,
|
||||
);
|
||||
jsonResponse(req, res, 200, bundle, {
|
||||
"cache-control": "no-store, max-age=0",
|
||||
pragma: "no-cache",
|
||||
"content-disposition": `attachment; filename="codex-retry-gateway-${bundle.profile.name}.profile.json"`,
|
||||
"x-content-type-options": "nosniff",
|
||||
});
|
||||
} catch (error) {
|
||||
const statusCode = Number.isInteger(error?.statusCode) ? error.statusCode : 400;
|
||||
jsonResponse(req, res, statusCode, profileTransferErrorPayload(error, "profile_export_failed"), {
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === PROFILE_IMPORT_API_PATH && req.method === "POST") {
|
||||
const body = await readRequestBody(
|
||||
req,
|
||||
Math.min(runtime.config.request_body_limit_bytes, PROFILE_TRANSFER_BODY_LIMIT_BYTES),
|
||||
);
|
||||
const payload = parseJsonSafely(body);
|
||||
if (!payload) {
|
||||
jsonResponse(req, res, 400, {
|
||||
error: {
|
||||
message: "profile 导入请求必须是有效 JSON",
|
||||
code: "invalid_json",
|
||||
},
|
||||
}, { "cache-control": "no-store" });
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await importTextProfileBundle(runtime, payload);
|
||||
runtime.logger(`[profile] imported name=${result.name} overwritten=${result.overwritten}`);
|
||||
jsonResponse(req, res, 200, {
|
||||
ok: true,
|
||||
message: "profile 已导入;API key 已写入本机受限 secret 文件,尚未切换当前实例",
|
||||
imported_profile: result,
|
||||
profiles_dir: runtime.paths.profilesDir,
|
||||
active_profile: runtime.config.profile_name || "default",
|
||||
profiles: await listProfiles(runtime),
|
||||
}, { "cache-control": "no-store" });
|
||||
} catch (error) {
|
||||
const statusCode = Number.isInteger(error?.statusCode) ? error.statusCode : 400;
|
||||
jsonResponse(req, res, statusCode, profileTransferErrorPayload(error, "profile_import_failed"), {
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === PROFILES_API_PATH && req.method === "POST") {
|
||||
const body = await readRequestBody(req, runtime.config.request_body_limit_bytes);
|
||||
const payload = parseJsonSafely(body);
|
||||
@@ -3800,6 +4135,78 @@ async function handleManagementRequest(runtime, req, res, requestUrl) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === IMAGE_PROFILE_EXPORT_API_PATH && req.method === "POST") {
|
||||
const body = await readRequestBody(
|
||||
req,
|
||||
Math.min(runtime.config.request_body_limit_bytes, PROFILE_TRANSFER_BODY_LIMIT_BYTES),
|
||||
);
|
||||
const payload = parseJsonSafely(body);
|
||||
if (!payload) {
|
||||
jsonResponse(req, res, 400, {
|
||||
error: {
|
||||
message: "图片 profile 导出请求必须是有效 JSON",
|
||||
code: "invalid_json",
|
||||
},
|
||||
}, { "cache-control": "no-store" });
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const bundle = await buildImageProfileExportBundle(runtime, payload);
|
||||
runtime.logger(
|
||||
`[image-profile] exported name=${bundle.profile.name} source_auth=${bundle.source_auth_mode}/${bundle.source_auth_source}`,
|
||||
);
|
||||
jsonResponse(req, res, 200, bundle, {
|
||||
"cache-control": "no-store, max-age=0",
|
||||
pragma: "no-cache",
|
||||
"content-disposition": `attachment; filename="codex-retry-gateway-${bundle.profile.name}.image-profile.json"`,
|
||||
"x-content-type-options": "nosniff",
|
||||
});
|
||||
} catch (error) {
|
||||
const statusCode = Number.isInteger(error?.statusCode) ? error.statusCode : 400;
|
||||
jsonResponse(req, res, statusCode, profileTransferErrorPayload(error, "profile_export_failed"), {
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === IMAGE_PROFILE_IMPORT_API_PATH && req.method === "POST") {
|
||||
const body = await readRequestBody(
|
||||
req,
|
||||
Math.min(runtime.config.request_body_limit_bytes, PROFILE_TRANSFER_BODY_LIMIT_BYTES),
|
||||
);
|
||||
const payload = parseJsonSafely(body);
|
||||
if (!payload) {
|
||||
jsonResponse(req, res, 400, {
|
||||
error: {
|
||||
message: "图片 profile 导入请求必须是有效 JSON",
|
||||
code: "invalid_json",
|
||||
},
|
||||
}, { "cache-control": "no-store" });
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await importImageProfileBundle(runtime, payload);
|
||||
runtime.logger(`[image-profile] imported name=${result.name} overwritten=${result.overwritten}`);
|
||||
jsonResponse(req, res, 200, {
|
||||
ok: true,
|
||||
message: "图片 profile 已导入;API key 已写入本机受限 secret 文件,尚未切换当前实例",
|
||||
imported_image_profile: result,
|
||||
image_profiles_dir: runtime.paths.imageProfilesDir,
|
||||
active_image_profile: runtime.config.image_profile_name || "",
|
||||
image_profiles: await listImageProfiles(runtime),
|
||||
}, { "cache-control": "no-store" });
|
||||
} catch (error) {
|
||||
const statusCode = Number.isInteger(error?.statusCode) ? error.statusCode : 400;
|
||||
jsonResponse(req, res, statusCode, profileTransferErrorPayload(error, "profile_import_failed"), {
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === IMAGE_PROFILES_API_PATH && req.method === "POST") {
|
||||
const body = await readRequestBody(req, runtime.config.request_body_limit_bytes);
|
||||
const payload = parseJsonSafely(body);
|
||||
|
||||
Reference in New Issue
Block a user