feat: add verified profile import and export
This commit is contained in:
@@ -298,6 +298,7 @@ gateway 运行时只负责 API 与静态文件服务,不再把复杂 UI 硬写
|
||||
- `usage` 中的 input / output / total / reasoning tokens
|
||||
- 分别管理文本 profiles 与图片 profiles
|
||||
- 各自新建 / 编辑 / 探测 / 切换 / 删除 profile env
|
||||
- 各自导出 / 导入可移植 profile JSON;导出前必须再次验证该 profile 当前实际使用的 API key
|
||||
- 文本切换 provider `base_url` 不会改写图片分流,图片切换只影响 `/images/*` 与 `/v1/images/*`
|
||||
- 各自切换 `passthrough` / `manual_bearer` / `fixed_bearer` / `auth_json` 认证模式
|
||||
- 改 `reasoning_equals`
|
||||
@@ -319,9 +320,25 @@ gateway 运行时只负责 API 与静态文件服务,不再把复杂 UI 硬写
|
||||
- 请求历史只记录元数据、请求体字节数和 token usage,不保存请求正文或响应正文;默认展示最近 200 条
|
||||
- gateway 日志持久化到 `~/.codex-retry-gateway/logs/gateway.log`
|
||||
- 请求记录持久化到 `~/.codex-retry-gateway/logs/requests.jsonl` 与 `~/.codex-retry-gateway/logs/requests.sqlite`,重启后会用于 UI 请求页以及 overview 的累计 token / reasoning 统计
|
||||
- `manual_bearer` 的手动 token/password 只写入系统 secret 文件,API/UI 不读回明文;profile env 只保存 secret 文件路径
|
||||
- `manual_bearer` 的手动 token/password 只写入系统 secret 文件,普通 profile API/UI 不读回明文;只有再次验证当前 key 的显式导出接口会把它放进下载文件,profile env 仍只保存 secret 文件路径
|
||||
- 其他 profile env 不保存明文 `sk-...`;固定密钥请使用 env/file 引用,或用 `auth_json` 指向 `auth.json` 字段名
|
||||
|
||||
### Profile 导入与导出
|
||||
|
||||
文本与图片 profile 都支持从 Profiles 页面导出和导入:
|
||||
|
||||
- 文本接口:`POST /__codex_retry_gateway/api/profiles/export`、`POST /__codex_retry_gateway/api/profiles/import`
|
||||
- 图片接口:`POST /__codex_retry_gateway/api/image-profiles/export`、`POST /__codex_retry_gateway/api/image-profiles/import`
|
||||
- 导出前必须再次提交该 profile 当前实际使用的 API key;gateway 会从现有 `manual_bearer` secret 文件、`fixed_bearer` env/file 或 `auth_json` 解析当前 key,并做常量时间比对
|
||||
- `passthrough` 没有 profile-owned key,无法完成再次验证,因此拒绝导出
|
||||
- 验证成功后才返回 `codex-retry-gateway-profile` v1 JSON;文件包含明文 API key,文本 profile 若配置了管理 Access key 也会一并保留
|
||||
- 导出响应使用 `Cache-Control: no-store`,服务端不生成或保留导出副本,日志只记录 profile 名称和认证来源,不记录 key
|
||||
- 导入只接受该格式的 JSON,并把包内 key 写入目标用户的默认 secret 文件,目录权限为 `0700`、文件权限为 `0600`;profile env 仍只保存 secret 文件路径
|
||||
- 为避免当前控制链路被意外改写,导入不会自动切换 profile,也不允许直接覆盖当前活跃 profile;同名非活跃 profile 需要显式确认覆盖
|
||||
- 导入后的认证统一落为可移植的 `manual_bearer`;先用 profile 探针验证,再手动热切换
|
||||
|
||||
导出文件本身就是明文 secret 载体,不应提交到 Git、上传到普通日志或长期放在共享下载目录。
|
||||
|
||||
## 如何调整拦截条件
|
||||
|
||||
编辑:
|
||||
@@ -398,6 +415,7 @@ macOS / Linux: ~/.codex-retry-gateway
|
||||
- `node scripts/test-gateway-e2e.mjs`
|
||||
- 已通过
|
||||
- 验证 strict capture 下 `/responses` 生命周期不注入空事件,长流 delta 按序完整回放并以 `response.completed` 结束
|
||||
- 验证文本 / 图片 profile 导出必须重新校验 key,错误 key 不泄露 secret;导入后 key 只进入 `0600` secret 文件,不进入 profile env 或普通 API 响应
|
||||
- `test-install-restore.ps1`
|
||||
- 已通过
|
||||
- 验证安装、透传、UI 页面、热更新配置、实时日志、516 统计、恢复闭环
|
||||
|
||||
+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);
|
||||
|
||||
@@ -4,7 +4,7 @@ import http from "node:http";
|
||||
import net from "node:net";
|
||||
import { once } from "node:events";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -801,6 +801,134 @@ async function run() {
|
||||
"文本 profile 保存不应写入图片配置",
|
||||
);
|
||||
|
||||
const exportableTextProfileResponse = await fetch(
|
||||
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/profiles`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...adminHeaders, "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: "export-source",
|
||||
listen_host: "127.0.0.1",
|
||||
listen_port: gatewayPort,
|
||||
upstream_base_url: `http://127.0.0.1:${upstreamPort}`,
|
||||
auth_mode: "manual_bearer",
|
||||
manual_secret: "text-export-secret",
|
||||
management_access_key: "profile-admin-secret",
|
||||
request_history_limit: 25,
|
||||
model_remap: "gpt-test=gpt-upstream",
|
||||
reasoning_match_mode: "manual",
|
||||
reasoning_equals: [516],
|
||||
retryable_status_codes: [429, 503],
|
||||
retryable_error_messages: ["capacity test"],
|
||||
upstream_fetch_retry_attempts: 3,
|
||||
upstream_fetch_retry_backoff_ms: 50,
|
||||
endpoints: ["/responses", "/v1/responses"],
|
||||
}),
|
||||
},
|
||||
);
|
||||
assert(exportableTextProfileResponse.status === 200, `可导出文本 profile 保存失败: ${exportableTextProfileResponse.status}`);
|
||||
const exportableTextProfilePayload = await exportableTextProfileResponse.json();
|
||||
assert(!JSON.stringify(exportableTextProfilePayload).includes("text-export-secret"), "普通 profile API 不应返回导出 key");
|
||||
|
||||
const passthroughExportResponse = await fetch(
|
||||
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/profiles/export`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...adminHeaders, "content-type": "application/json" },
|
||||
body: JSON.stringify({ profile: "legacy-text", verification_key: "normal-upstream-key" }),
|
||||
},
|
||||
);
|
||||
assert(passthroughExportResponse.status === 400, "passthrough profile 不应允许导出不可验证的 key");
|
||||
const passthroughExportPayload = await passthroughExportResponse.json();
|
||||
assert(
|
||||
passthroughExportPayload?.error?.code === "profile_export_key_unavailable",
|
||||
"passthrough profile 未返回 key unavailable 标识",
|
||||
);
|
||||
|
||||
const wrongTextExportResponse = await fetch(
|
||||
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/profiles/export`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...adminHeaders, "content-type": "application/json" },
|
||||
body: JSON.stringify({ profile: "export-source", verification_key: "wrong-export-secret" }),
|
||||
},
|
||||
);
|
||||
assert(wrongTextExportResponse.status === 403, `错误文本 key 应拒绝导出: ${wrongTextExportResponse.status}`);
|
||||
const wrongTextExportPayload = await wrongTextExportResponse.json();
|
||||
assert(wrongTextExportPayload?.error?.code === "profile_export_key_invalid", "错误文本 key 未返回验证失败标识");
|
||||
assert(!JSON.stringify(wrongTextExportPayload).includes("text-export-secret"), "错误 key 响应泄露了文本 profile key");
|
||||
|
||||
const textExportResponse = await fetch(
|
||||
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/profiles/export`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...adminHeaders, "content-type": "application/json" },
|
||||
body: JSON.stringify({ profile: "export-source", verification_key: "text-export-secret" }),
|
||||
},
|
||||
);
|
||||
assert(textExportResponse.status === 200, `文本 profile 导出失败: ${textExportResponse.status}`);
|
||||
assert((textExportResponse.headers.get("cache-control") || "").includes("no-store"), "文本导出响应未禁用缓存");
|
||||
assert(
|
||||
(textExportResponse.headers.get("content-disposition") || "").includes("export-source.profile.json"),
|
||||
"文本导出响应缺少下载文件名",
|
||||
);
|
||||
const textExportBundle = await textExportResponse.json();
|
||||
assert(textExportBundle?.format === "codex-retry-gateway-profile", "文本导出格式标识错误");
|
||||
assert(textExportBundle?.version === 1 && textExportBundle?.kind === "text", "文本导出版本或 kind 错误");
|
||||
assert(textExportBundle?.profile?.key === "text-export-secret", "文本导出文件未包含 API key");
|
||||
assert(textExportBundle?.profile?.management_access_key === "profile-admin-secret", "文本导出文件未保留管理 access key");
|
||||
assert(textExportBundle?.profile?.auth_mode === "manual_bearer", "文本导出文件未转换为可移植认证模式");
|
||||
assert(textExportBundle?.profile?.auth_file === undefined, "文本导出文件不应包含源机器 secret 路径");
|
||||
|
||||
const textImportResponse = await fetch(
|
||||
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/profiles/import`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...adminHeaders, "content-type": "application/json" },
|
||||
body: JSON.stringify({ bundle: textExportBundle, name: "imported-text" }),
|
||||
},
|
||||
);
|
||||
assert(textImportResponse.status === 200, `文本 profile 导入失败: ${textImportResponse.status}`);
|
||||
const textImportPayload = await textImportResponse.json();
|
||||
const importedTextProfile = (textImportPayload.profiles || []).find((profile) => profile?.name === "imported-text");
|
||||
assert(importedTextProfile?.form?.auth_mode === "manual_bearer", "导入文本 profile 未使用 manual_bearer");
|
||||
assert(importedTextProfile?.form?.manual_secret_configured === true, "导入文本 profile 未配置本机 secret");
|
||||
assert(!JSON.stringify(textImportPayload).includes("text-export-secret"), "文本导入响应泄露了 API key");
|
||||
const importedTextEnv = await readFile(path.join(profilesDir, "imported-text.env"), "utf8");
|
||||
assert(!importedTextEnv.includes("text-export-secret"), "导入文本 profile env 不应包含明文 API key");
|
||||
assert(importedTextEnv.includes("CODEX_RETRY_GATEWAY_MANAGEMENT_ACCESS_KEY=profile-admin-secret"), "导入文本 profile 未恢复管理 access key");
|
||||
const importedTextSecretPath = path.join(tempRoot, ".codex-retry-gateway", "secrets", "imported-text.token");
|
||||
assert(
|
||||
(await readFile(importedTextSecretPath, "utf8")).trim()
|
||||
=== "text-export-secret",
|
||||
"导入文本 profile 未把 API key 写入本机 secret 文件",
|
||||
);
|
||||
assert(((await stat(importedTextSecretPath)).mode & 0o777) === 0o600, "导入文本 profile secret 权限不是 0600");
|
||||
|
||||
const duplicateTextImportResponse = await fetch(
|
||||
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/profiles/import`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...adminHeaders, "content-type": "application/json" },
|
||||
body: JSON.stringify({ bundle: textExportBundle, name: "imported-text" }),
|
||||
},
|
||||
);
|
||||
assert(duplicateTextImportResponse.status === 409, "同名文本 profile 未确认覆盖时不应导入");
|
||||
const duplicateTextImportPayload = await duplicateTextImportResponse.json();
|
||||
assert(duplicateTextImportPayload?.error?.code === "profile_import_exists", "同名文本 profile 未返回冲突标识");
|
||||
|
||||
const activeTextImportResponse = await fetch(
|
||||
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/profiles/import`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...adminHeaders, "content-type": "application/json" },
|
||||
body: JSON.stringify({ bundle: textExportBundle, name: "legacy-text", overwrite: true }),
|
||||
},
|
||||
);
|
||||
assert(activeTextImportResponse.status === 409, "不应允许导入覆盖当前文本 profile");
|
||||
const activeTextImportPayload = await activeTextImportResponse.json();
|
||||
assert(activeTextImportPayload?.error?.code === "profile_import_active_conflict", "当前文本 profile 冲突标识错误");
|
||||
|
||||
const imageProfileResponse = await fetch(
|
||||
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/image-profiles`,
|
||||
{
|
||||
@@ -822,6 +950,61 @@ async function run() {
|
||||
assert(imagePrimary?.summary?.auth_source === "system secret file configured", "独立图片 profile 未返回认证来源");
|
||||
assert(!JSON.stringify(imageProfilePayload).includes("test-image-profile-secret"), "图片 profile API 不应返回图片明文 secret");
|
||||
|
||||
const wrongImageExportResponse = await fetch(
|
||||
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/image-profiles/export`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...adminHeaders, "content-type": "application/json" },
|
||||
body: JSON.stringify({ profile: "image-primary", verification_key: "wrong-image-secret" }),
|
||||
},
|
||||
);
|
||||
assert(wrongImageExportResponse.status === 403, "错误图片 key 应拒绝导出");
|
||||
const wrongImageExportPayload = await wrongImageExportResponse.json();
|
||||
assert(wrongImageExportPayload?.error?.code === "profile_export_key_invalid", "错误图片 key 未返回验证失败标识");
|
||||
assert(!JSON.stringify(wrongImageExportPayload).includes("test-image-profile-secret"), "错误图片 key 响应泄露了真实 key");
|
||||
|
||||
const imageExportResponse = await fetch(
|
||||
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/image-profiles/export`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...adminHeaders, "content-type": "application/json" },
|
||||
body: JSON.stringify({ profile: "image-primary", verification_key: "test-image-profile-secret" }),
|
||||
},
|
||||
);
|
||||
assert(imageExportResponse.status === 200, `图片 profile 导出失败: ${imageExportResponse.status}`);
|
||||
assert((imageExportResponse.headers.get("cache-control") || "").includes("no-store"), "图片导出响应未禁用缓存");
|
||||
const imageExportBundle = await imageExportResponse.json();
|
||||
assert(imageExportBundle?.kind === "image", "图片导出 kind 错误");
|
||||
assert(imageExportBundle?.profile?.key === "test-image-profile-secret", "图片导出文件未包含 API key");
|
||||
assert(imageExportBundle?.profile?.auth_mode === "manual_bearer", "图片导出文件未转换为可移植认证模式");
|
||||
|
||||
const imageImportResponse = await fetch(
|
||||
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/image-profiles/import`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...adminHeaders, "content-type": "application/json" },
|
||||
body: JSON.stringify({ bundle: imageExportBundle, name: "imported-image" }),
|
||||
},
|
||||
);
|
||||
assert(imageImportResponse.status === 200, `图片 profile 导入失败: ${imageImportResponse.status}`);
|
||||
const imageImportPayload = await imageImportResponse.json();
|
||||
const importedImageProfile = (imageImportPayload.image_profiles || []).find((profile) => profile?.name === "imported-image");
|
||||
assert(importedImageProfile?.form?.auth_mode === "manual_bearer", "导入图片 profile 未使用 manual_bearer");
|
||||
assert(importedImageProfile?.form?.manual_secret_configured === true, "导入图片 profile 未配置本机 secret");
|
||||
assert(!JSON.stringify(imageImportPayload).includes("test-image-profile-secret"), "图片导入响应泄露了 API key");
|
||||
const importedImageEnv = await readFile(path.join(imageProfilesDir, "imported-image.env"), "utf8");
|
||||
assert(!importedImageEnv.includes("test-image-profile-secret"), "导入图片 profile env 不应包含明文 API key");
|
||||
const importedImageSecretPath = path.join(tempRoot, ".codex-retry-gateway", "secrets", "imported-image.images.token");
|
||||
assert(
|
||||
(await readFile(importedImageSecretPath, "utf8")).trim()
|
||||
=== "test-image-profile-secret",
|
||||
"导入图片 profile 未把 API key 写入本机 secret 文件",
|
||||
);
|
||||
assert(((await stat(importedImageSecretPath)).mode & 0o777) === 0o600, "导入图片 profile secret 权限不是 0600");
|
||||
const profileTransferLog = await readFile(logPath, "utf8");
|
||||
assert(!profileTransferLog.includes("text-export-secret"), "gateway 日志泄露了文本 profile 导出 key");
|
||||
assert(!profileTransferLog.includes("test-image-profile-secret"), "gateway 日志泄露了图片 profile 导出 key");
|
||||
|
||||
const switchImageProfileResponse = await fetch(
|
||||
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/image-profiles/switch`,
|
||||
{
|
||||
|
||||
+441
-1
@@ -1,4 +1,4 @@
|
||||
import { FormEvent, startTransition, useEffect, useState } from "react";
|
||||
import { ChangeEvent, FormEvent, startTransition, useEffect, useState } from "react";
|
||||
|
||||
type PageKey = "overview" | "requests" | "profiles" | "rules" | "logs";
|
||||
type Tone = "" | "success" | "error";
|
||||
@@ -243,6 +243,12 @@ type ProfilesPayload = {
|
||||
listen?: string;
|
||||
upstream_base_url?: string;
|
||||
} | null;
|
||||
imported_profile?: {
|
||||
name?: string;
|
||||
file_path?: string;
|
||||
overwritten?: boolean;
|
||||
} | null;
|
||||
message?: string;
|
||||
profiles?: Profile[];
|
||||
};
|
||||
|
||||
@@ -254,9 +260,29 @@ type ImageProfilesPayload = {
|
||||
hot_swapped?: boolean;
|
||||
image_base_url?: string;
|
||||
} | null;
|
||||
imported_image_profile?: {
|
||||
name?: string;
|
||||
file_path?: string;
|
||||
overwritten?: boolean;
|
||||
} | null;
|
||||
message?: string;
|
||||
image_profiles?: ImageProfile[];
|
||||
};
|
||||
|
||||
type ProfileBundle = {
|
||||
format: "codex-retry-gateway-profile";
|
||||
version: number;
|
||||
kind: "text" | "image";
|
||||
exported_at?: string;
|
||||
source_auth_mode?: string;
|
||||
source_auth_source?: string;
|
||||
profile?: {
|
||||
name?: string;
|
||||
key?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type LogEntry = {
|
||||
seq: number;
|
||||
at?: string;
|
||||
@@ -332,9 +358,13 @@ const api = {
|
||||
profiles: "/__codex_retry_gateway/api/profiles",
|
||||
profileProbe: "/__codex_retry_gateway/api/profiles/probe",
|
||||
profileSwitch: "/__codex_retry_gateway/api/profiles/switch",
|
||||
profileExport: "/__codex_retry_gateway/api/profiles/export",
|
||||
profileImport: "/__codex_retry_gateway/api/profiles/import",
|
||||
imageProfiles: "/__codex_retry_gateway/api/image-profiles",
|
||||
imageProfileProbe: "/__codex_retry_gateway/api/image-profiles/probe",
|
||||
imageProfileSwitch: "/__codex_retry_gateway/api/image-profiles/switch",
|
||||
imageProfileExport: "/__codex_retry_gateway/api/image-profiles/export",
|
||||
imageProfileImport: "/__codex_retry_gateway/api/image-profiles/import",
|
||||
restore: "/__codex_retry_gateway/api/restore",
|
||||
};
|
||||
|
||||
@@ -689,6 +719,38 @@ async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
function downloadJsonFile(payload: unknown, filename: string) {
|
||||
const blob = new Blob([`${JSON.stringify(payload, null, 2)}\n`], {
|
||||
type: "application/json;charset=utf-8",
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = filename;
|
||||
anchor.rel = "noopener";
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
function validateProfileBundleFile(payload: unknown, expectedKind: "text" | "image"): ProfileBundle {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
throw new Error("文件内容不是有效的 profile bundle");
|
||||
}
|
||||
const bundle = payload as ProfileBundle;
|
||||
if (bundle.format !== "codex-retry-gateway-profile" || bundle.version !== 1) {
|
||||
throw new Error("不支持的 profile 导出文件格式或版本");
|
||||
}
|
||||
if (bundle.kind !== expectedKind) {
|
||||
throw new Error(expectedKind === "text" ? "请选择文本 profile 导出文件" : "请选择图片 profile 导出文件");
|
||||
}
|
||||
if (!bundle.profile?.name || !bundle.profile?.key) {
|
||||
throw new Error("导出文件缺少 profile 名称或 API key");
|
||||
}
|
||||
return bundle;
|
||||
}
|
||||
|
||||
function profileFormFromStatus(status: StatusPayload | null): ProfileFormState {
|
||||
const config = status?.config || {};
|
||||
return {
|
||||
@@ -847,8 +909,22 @@ export default function App() {
|
||||
const [threadRuleMessage, setThreadRuleMessage] = useState<MessageState>({ text: "", tone: "" });
|
||||
const [profileMessage, setProfileMessage] = useState<MessageState>({ text: "", tone: "" });
|
||||
const [profileProbeResult, setProfileProbeResult] = useState<ProfileProbePayload | null>(null);
|
||||
const [profileTransferMessage, setProfileTransferMessage] = useState<MessageState>({ text: "", tone: "" });
|
||||
const [profileExportTarget, setProfileExportTarget] = useState<Profile | null>(null);
|
||||
const [profileExportKey, setProfileExportKey] = useState("");
|
||||
const [exportingProfile, setExportingProfile] = useState("");
|
||||
const [profileImportBundle, setProfileImportBundle] = useState<ProfileBundle | null>(null);
|
||||
const [profileImportName, setProfileImportName] = useState("");
|
||||
const [profileImportOverwrite, setProfileImportOverwrite] = useState(false);
|
||||
const [imageProfileMessage, setImageProfileMessage] = useState<MessageState>({ text: "", tone: "" });
|
||||
const [imageProfileProbeResult, setImageProfileProbeResult] = useState<ProfileProbePayload | null>(null);
|
||||
const [imageProfileTransferMessage, setImageProfileTransferMessage] = useState<MessageState>({ text: "", tone: "" });
|
||||
const [imageProfileExportTarget, setImageProfileExportTarget] = useState<ImageProfile | null>(null);
|
||||
const [imageProfileExportKey, setImageProfileExportKey] = useState("");
|
||||
const [exportingImageProfile, setExportingImageProfile] = useState("");
|
||||
const [imageProfileImportBundle, setImageProfileImportBundle] = useState<ProfileBundle | null>(null);
|
||||
const [imageProfileImportName, setImageProfileImportName] = useState("");
|
||||
const [imageProfileImportOverwrite, setImageProfileImportOverwrite] = useState(false);
|
||||
const [probingProfile, setProbingProfile] = useState("");
|
||||
const [deletingProfile, setDeletingProfile] = useState("");
|
||||
const [switchingTo, setSwitchingTo] = useState("");
|
||||
@@ -1297,6 +1373,103 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
function prepareProfileExport(profile: Profile) {
|
||||
setProfileExportTarget(profile);
|
||||
setProfileExportKey("");
|
||||
setProfileTransferMessage({
|
||||
text: `准备导出文本 profile ${profile.name};请再次输入它当前实际使用的上游 API key。`,
|
||||
tone: "",
|
||||
});
|
||||
}
|
||||
|
||||
async function exportProfileBundle(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!profileExportTarget) {
|
||||
setProfileTransferMessage({ text: "请先从文本 profile 列表选择要导出的项目。", tone: "error" });
|
||||
return;
|
||||
}
|
||||
setExportingProfile(profileExportTarget.name);
|
||||
setProfileTransferMessage({ text: `正在验证并导出 ${profileExportTarget.name}...`, tone: "" });
|
||||
try {
|
||||
const bundle = await fetchJson<ProfileBundle>(api.profileExport, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
profile: profileExportTarget.name,
|
||||
verification_key: profileExportKey,
|
||||
}),
|
||||
});
|
||||
downloadJsonFile(bundle, `codex-retry-gateway-${profileExportTarget.name}.profile.json`);
|
||||
setProfileTransferMessage({
|
||||
text: `文本 profile ${profileExportTarget.name} 已导出。文件包含明文 API key,请按 secret 文件保管。`,
|
||||
tone: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
setProfileTransferMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" });
|
||||
} finally {
|
||||
setProfileExportKey("");
|
||||
setExportingProfile("");
|
||||
}
|
||||
}
|
||||
|
||||
async function selectProfileImportFile(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (file.size > 512 * 1024) {
|
||||
throw new Error("profile 导出文件不能超过 512 KiB");
|
||||
}
|
||||
const parsed = JSON.parse(await file.text());
|
||||
const bundle = validateProfileBundleFile(parsed, "text");
|
||||
setProfileImportBundle(bundle);
|
||||
setProfileImportName(bundle.profile?.name || "");
|
||||
setProfileImportOverwrite(false);
|
||||
setProfileTransferMessage({
|
||||
text: `已载入文本 profile ${bundle.profile?.name || "-"};导入不会自动切换当前 gateway。`,
|
||||
tone: "",
|
||||
});
|
||||
} catch (error) {
|
||||
setProfileImportBundle(null);
|
||||
setProfileImportName("");
|
||||
setProfileTransferMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" });
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function importProfileBundle(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!profileImportBundle) {
|
||||
setProfileTransferMessage({ text: "请先选择文本 profile 导出文件。", tone: "error" });
|
||||
return;
|
||||
}
|
||||
setProfileTransferMessage({ text: `正在导入文本 profile ${profileImportName || "-"}...`, tone: "" });
|
||||
try {
|
||||
const payload = await fetchJson<ProfilesPayload>(api.profileImport, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
bundle: profileImportBundle,
|
||||
name: profileImportName,
|
||||
overwrite: profileImportOverwrite,
|
||||
}),
|
||||
});
|
||||
setProfiles(payload.profiles || []);
|
||||
setProfilesMeta(`目录:${payload.profiles_dir || "-"};当前运行:${payload.active_profile || "-"}。`);
|
||||
setProfileImportBundle(null);
|
||||
setProfileImportName("");
|
||||
setProfileImportOverwrite(false);
|
||||
setProfileTransferMessage({
|
||||
text: payload.message || "文本 profile 已导入;请先探针验证,再手动切换。",
|
||||
tone: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
setProfileTransferMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
async function saveImageProfile(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setImageProfileMessage({ text: "正在保存图片 profile...", tone: "" });
|
||||
@@ -1409,6 +1582,103 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
function prepareImageProfileExport(profile: ImageProfile) {
|
||||
setImageProfileExportTarget(profile);
|
||||
setImageProfileExportKey("");
|
||||
setImageProfileTransferMessage({
|
||||
text: `准备导出图片 profile ${profile.name};请再次输入它当前实际使用的图片 API key。`,
|
||||
tone: "",
|
||||
});
|
||||
}
|
||||
|
||||
async function exportImageProfileBundle(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!imageProfileExportTarget) {
|
||||
setImageProfileTransferMessage({ text: "请先从图片 profile 列表选择要导出的项目。", tone: "error" });
|
||||
return;
|
||||
}
|
||||
setExportingImageProfile(imageProfileExportTarget.name);
|
||||
setImageProfileTransferMessage({ text: `正在验证并导出 ${imageProfileExportTarget.name}...`, tone: "" });
|
||||
try {
|
||||
const bundle = await fetchJson<ProfileBundle>(api.imageProfileExport, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
profile: imageProfileExportTarget.name,
|
||||
verification_key: imageProfileExportKey,
|
||||
}),
|
||||
});
|
||||
downloadJsonFile(bundle, `codex-retry-gateway-${imageProfileExportTarget.name}.image-profile.json`);
|
||||
setImageProfileTransferMessage({
|
||||
text: `图片 profile ${imageProfileExportTarget.name} 已导出。文件包含明文 API key,请按 secret 文件保管。`,
|
||||
tone: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
setImageProfileTransferMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" });
|
||||
} finally {
|
||||
setImageProfileExportKey("");
|
||||
setExportingImageProfile("");
|
||||
}
|
||||
}
|
||||
|
||||
async function selectImageProfileImportFile(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (file.size > 512 * 1024) {
|
||||
throw new Error("图片 profile 导出文件不能超过 512 KiB");
|
||||
}
|
||||
const parsed = JSON.parse(await file.text());
|
||||
const bundle = validateProfileBundleFile(parsed, "image");
|
||||
setImageProfileImportBundle(bundle);
|
||||
setImageProfileImportName(bundle.profile?.name || "");
|
||||
setImageProfileImportOverwrite(false);
|
||||
setImageProfileTransferMessage({
|
||||
text: `已载入图片 profile ${bundle.profile?.name || "-"};导入不会自动切换当前 gateway。`,
|
||||
tone: "",
|
||||
});
|
||||
} catch (error) {
|
||||
setImageProfileImportBundle(null);
|
||||
setImageProfileImportName("");
|
||||
setImageProfileTransferMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" });
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function importImageProfileBundle(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!imageProfileImportBundle) {
|
||||
setImageProfileTransferMessage({ text: "请先选择图片 profile 导出文件。", tone: "error" });
|
||||
return;
|
||||
}
|
||||
setImageProfileTransferMessage({ text: `正在导入图片 profile ${imageProfileImportName || "-"}...`, tone: "" });
|
||||
try {
|
||||
const payload = await fetchJson<ImageProfilesPayload>(api.imageProfileImport, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
bundle: imageProfileImportBundle,
|
||||
name: imageProfileImportName,
|
||||
overwrite: imageProfileImportOverwrite,
|
||||
}),
|
||||
});
|
||||
setImageProfiles(payload.image_profiles || []);
|
||||
setImageProfilesMeta(`目录:${payload.image_profiles_dir || "-"};当前运行:${payload.active_image_profile || "未配置"}。`);
|
||||
setImageProfileImportBundle(null);
|
||||
setImageProfileImportName("");
|
||||
setImageProfileImportOverwrite(false);
|
||||
setImageProfileTransferMessage({
|
||||
text: payload.message || "图片 profile 已导入;请先探针验证,再手动切换。",
|
||||
tone: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
setImageProfileTransferMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
function waitForProfile(profileName: string) {
|
||||
const deadline = Date.now() + 12000;
|
||||
const tick = async () => {
|
||||
@@ -1901,6 +2171,9 @@ export default function App() {
|
||||
<button className="ghost" type="button" onClick={() => editProfile(profile)}>
|
||||
编辑
|
||||
</button>
|
||||
<button className="ghost" type="button" onClick={() => prepareProfileExport(profile)}>
|
||||
导出
|
||||
</button>
|
||||
<button className="ghost" type="button" disabled={probingProfile === profile.name} onClick={() => probeProfile(profile)}>
|
||||
{probingProfile === profile.name ? "探测中" : "探针"}
|
||||
</button>
|
||||
@@ -1936,6 +2209,88 @@ export default function App() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="文本 Profile 导入 / 导出"
|
||||
hint="导出前必须再次验证当前上游 API key;导入只保存为本机 manual_bearer secret,不会自动切换当前实例。"
|
||||
>
|
||||
<div className="grid transfer-grid">
|
||||
<article className="profile-card transfer-pane">
|
||||
<div className="profile-head">
|
||||
<div>
|
||||
<h3>导出文本 Profile</h3>
|
||||
<div className="hint">下载文件包含明文 API key,以及该 profile 自身配置的管理 Access key。</div>
|
||||
</div>
|
||||
{profileExportTarget ? <span className="badge">{profileExportTarget.name}</span> : null}
|
||||
</div>
|
||||
<form onSubmit={exportProfileBundle}>
|
||||
<Field label="待导出 Profile">
|
||||
<input value={profileExportTarget?.name || ""} placeholder="先点击列表中的“导出”" readOnly />
|
||||
</Field>
|
||||
<Field label="再次验证当前 API Key" hint="必须与该 profile 当前 secret/env/auth.json 实际解析出的 key 一致。">
|
||||
<input
|
||||
type="password"
|
||||
value={profileExportKey}
|
||||
autoComplete="off"
|
||||
placeholder="重新输入当前上游 API key"
|
||||
onChange={(event) => setProfileExportKey(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="toolbar">
|
||||
<button className="primary" type="submit" disabled={!profileExportTarget || Boolean(exportingProfile)}>
|
||||
{exportingProfile ? "验证中" : "验证并导出"}
|
||||
</button>
|
||||
<button
|
||||
className="ghost"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setProfileExportTarget(null);
|
||||
setProfileExportKey("");
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article className="profile-card transfer-pane">
|
||||
<div className="profile-head">
|
||||
<div>
|
||||
<h3>导入文本 Profile</h3>
|
||||
<div className="hint">API key 会写入本机 `0600` secret 文件,profile env 只保存文件路径。</div>
|
||||
</div>
|
||||
{profileImportBundle ? <span className="badge success">文件已载入</span> : null}
|
||||
</div>
|
||||
<form onSubmit={importProfileBundle}>
|
||||
<Field label="Profile JSON 文件">
|
||||
<input type="file" accept=".json,application/json" onChange={selectProfileImportFile} />
|
||||
</Field>
|
||||
<Field label="导入名称" hint="可以改名导入;不能直接覆盖当前运行的 profile。">
|
||||
<input
|
||||
value={profileImportName}
|
||||
placeholder="导出文件中的 profile 名称"
|
||||
onChange={(event) => setProfileImportName(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<label className="inline-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={profileImportOverwrite}
|
||||
onChange={(event) => setProfileImportOverwrite(event.target.checked)}
|
||||
/>
|
||||
<span>允许覆盖同名的非活跃文本 profile</span>
|
||||
</label>
|
||||
<div className="toolbar">
|
||||
<button className="primary" type="submit" disabled={!profileImportBundle}>
|
||||
导入并保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
<Message message={profileTransferMessage} />
|
||||
</Card>
|
||||
|
||||
<Card title={profileForm.name ? `编辑文本 ${profileForm.name}` : "编辑文本 Profile"} hint="手动 token/password 只能写入系统 secret 文件,保存后不会从后端读回或展示。">
|
||||
<form onSubmit={saveProfile}>
|
||||
<Field label="Profile 名称">
|
||||
@@ -2115,6 +2470,9 @@ export default function App() {
|
||||
<button className="ghost" type="button" onClick={() => editImageProfile(profile)}>
|
||||
编辑
|
||||
</button>
|
||||
<button className="ghost" type="button" onClick={() => prepareImageProfileExport(profile)}>
|
||||
导出
|
||||
</button>
|
||||
<button className="ghost" type="button" disabled={probingImageProfile === profile.name} onClick={() => probeImageProfile(profile)}>
|
||||
{probingImageProfile === profile.name ? "探测中" : "探针"}
|
||||
</button>
|
||||
@@ -2136,6 +2494,88 @@ export default function App() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="图片 Profile 导入 / 导出"
|
||||
hint="导出前必须再次验证当前图片 API key;导入后只保存,不自动改变 /images/* 当前上游。"
|
||||
>
|
||||
<div className="grid transfer-grid">
|
||||
<article className="profile-card transfer-pane">
|
||||
<div className="profile-head">
|
||||
<div>
|
||||
<h3>导出图片 Profile</h3>
|
||||
<div className="hint">下载文件包含明文图片 API key,请按 secret 文件保管。</div>
|
||||
</div>
|
||||
{imageProfileExportTarget ? <span className="badge">{imageProfileExportTarget.name}</span> : null}
|
||||
</div>
|
||||
<form onSubmit={exportImageProfileBundle}>
|
||||
<Field label="待导出图片 Profile">
|
||||
<input value={imageProfileExportTarget?.name || ""} placeholder="先点击列表中的“导出”" readOnly />
|
||||
</Field>
|
||||
<Field label="再次验证当前图片 API Key">
|
||||
<input
|
||||
type="password"
|
||||
value={imageProfileExportKey}
|
||||
autoComplete="off"
|
||||
placeholder="重新输入当前图片 API key"
|
||||
onChange={(event) => setImageProfileExportKey(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="toolbar">
|
||||
<button className="primary" type="submit" disabled={!imageProfileExportTarget || Boolean(exportingImageProfile)}>
|
||||
{exportingImageProfile ? "验证中" : "验证并导出"}
|
||||
</button>
|
||||
<button
|
||||
className="ghost"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setImageProfileExportTarget(null);
|
||||
setImageProfileExportKey("");
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article className="profile-card transfer-pane">
|
||||
<div className="profile-head">
|
||||
<div>
|
||||
<h3>导入图片 Profile</h3>
|
||||
<div className="hint">图片 key 会写入本机 `0600` secret 文件,导入后请先探针验证。</div>
|
||||
</div>
|
||||
{imageProfileImportBundle ? <span className="badge success">文件已载入</span> : null}
|
||||
</div>
|
||||
<form onSubmit={importImageProfileBundle}>
|
||||
<Field label="图片 Profile JSON 文件">
|
||||
<input type="file" accept=".json,application/json" onChange={selectImageProfileImportFile} />
|
||||
</Field>
|
||||
<Field label="导入名称" hint="可以改名导入;不能直接覆盖当前运行的图片 profile。">
|
||||
<input
|
||||
value={imageProfileImportName}
|
||||
placeholder="导出文件中的图片 profile 名称"
|
||||
onChange={(event) => setImageProfileImportName(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<label className="inline-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={imageProfileImportOverwrite}
|
||||
onChange={(event) => setImageProfileImportOverwrite(event.target.checked)}
|
||||
/>
|
||||
<span>允许覆盖同名的非活跃图片 profile</span>
|
||||
</label>
|
||||
<div className="toolbar">
|
||||
<button className="primary" type="submit" disabled={!imageProfileImportBundle}>
|
||||
导入并保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
<Message message={imageProfileTransferMessage} />
|
||||
</Card>
|
||||
|
||||
<Card title={imageProfileForm.name ? `编辑图片 ${imageProfileForm.name}` : "编辑图片 Profile"} hint="图片 API key 只写入系统 secret 文件,保存后不会从后端读回或展示。">
|
||||
<form onSubmit={saveImageProfile}>
|
||||
<Field label="图片 Profile 名称">
|
||||
|
||||
@@ -804,6 +804,15 @@ tr:last-child td {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.transfer-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.transfer-pane {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.compact-profile-card {
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1042,6 +1051,7 @@ form {
|
||||
.token-strip,
|
||||
.mini-stats,
|
||||
.field-row,
|
||||
.transfer-grid,
|
||||
.nav {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user