From 0d244e596cf177a7b956d0d7482d21e4a435d24d Mon Sep 17 00:00:00 2001 From: yunyaozhou Date: Fri, 10 Jul 2026 07:05:22 +0800 Subject: [PATCH] feat: separate image profiles from text routing --- README.md | 37 +- config.example.json | 7 + gateway.mjs | 1219 +++++++++++++++++++++++++++++++--- scripts/run-profile.mjs | 188 +++++- scripts/test-gateway-e2e.mjs | 558 +++++++++++++++- ui-src/src/App.tsx | 773 ++++++++++++++++++--- ui-src/src/styles.css | 36 + 7 files changed, 2633 insertions(+), 185 deletions(-) diff --git a/README.md b/README.md index db7d244..d226b1a 100644 --- a/README.md +++ b/README.md @@ -142,12 +142,20 @@ bash ./scripts/install-for-current-provider.sh 在 Linux 上需要常驻运行时,推荐用 `scripts/run-profile.mjs` 作为 systemd 的 ExecStart。它会读取 profile env,生成运行时 `config.json`,并把当前 Codex provider 的 `base_url` 指向本机 gateway。 -profile env 默认放在: +文本 profile env 默认放在: ```text ~/.config/codex-retry-gateway/profiles/.env ``` +图片 profile env 独立放在: + +```text +~/.config/codex-retry-gateway/image-profiles/.env +``` + +当前文本 profile 与当前图片 profile 分别由 state 中的 `profile_name`、`image_profile_name` 记录;切换其中任意一方不会改写另一方。首次升级时,当前文本 profile 中已有的 `CODEX_RETRY_GATEWAY_IMAGE_*` 字段会复制到同名图片 profile,旧文本文件会保留作为兼容回退,但后续 UI/TUI 保存文本 profile 不再写入图片字段。 + 常用字段: - `CODEX_RETRY_GATEWAY_LISTEN_HOST` @@ -170,12 +178,31 @@ profile env 默认放在: - `fixed_bearer`:从环境变量或文件读取 token,并覆盖上游 `Authorization: Bearer ...` - `auth_json`:从 Codex `auth.json` 的指定 key 读取 token,并覆盖上游 `Authorization: Bearer ...` +图片 profile 常用字段: + +- `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` + +- 配置 `CODEX_RETRY_GATEWAY_IMAGE_BASE_URL` 后,`/images/*` 和 `/v1/images/*` 会改用该地址;root 图片路径会在上游规范化为 `/v1/images/*`,其他请求仍使用 `CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL`。 +- 图片认证字段与普通上游同义,使用 `CODEX_RETRY_GATEWAY_IMAGE_AUTH_*`;默认 `fixed_bearer` 从 `CODEX_RETRY_GATEWAY_IMAGE_API_KEY` 读取 key。 +- 不要把 key 直接写进 profile env;优先通过 `IMAGE_AUTH_FILE` 或管理页的 `manual_bearer` 写入用户级受限 secret 文件。 + 示例: ```bash node ./scripts/run-profile.mjs default ``` +指定图片 profile: + +```bash +node ./scripts/run-profile.mjs default --image-profile images +``` + 如果已经由 state 记录了当前活跃 profile,也可以直接不传 profile 参数: ```bash @@ -269,10 +296,10 @@ gateway 运行时只负责 API 与静态文件服务,不再把复杂 UI 硬写 - 请求时间戳、首字耗时、总耗时、请求体大小、路径、模型、状态码 - 相同重发请求会带相同的 `request_id` - `usage` 中的 input / output / total / reasoning tokens -- 管理 profiles - - 新建 / 编辑 profile env - - 切换 provider `base_url` - - 切换 `passthrough` / `manual_bearer` / `fixed_bearer` / `auth_json` 认证模式 +- 分别管理文本 profiles 与图片 profiles + - 各自新建 / 编辑 / 探测 / 切换 / 删除 profile env + - 文本切换 provider `base_url` 不会改写图片分流,图片切换只影响 `/images/*` 与 `/v1/images/*` + - 各自切换 `passthrough` / `manual_bearer` / `fixed_bearer` / `auth_json` 认证模式 - 改 `reasoning_equals` - 改 capacity error 的匹配状态码和错误文案 - 改 `endpoints` diff --git a/config.example.json b/config.example.json index 0604f0e..05c7ab8 100644 --- a/config.example.json +++ b/config.example.json @@ -1,5 +1,6 @@ { "profile_name": "default", + "image_profile_name": "", "listen_host": "127.0.0.1", "listen_port": 4610, "upstream_base_url": "https://api.openai.com", @@ -8,6 +9,12 @@ "upstream_auth_file": "", "upstream_auth_json_path": "", "upstream_auth_json_key": "OPENAI_API_KEY", + "image_base_url": "", + "image_auth_mode": "fixed_bearer", + "image_auth_env": "CODEX_RETRY_GATEWAY_IMAGE_API_KEY", + "image_auth_file": "", + "image_auth_json_path": "", + "image_auth_json_key": "OPENAI_API_KEY", "request_body_limit_bytes": 1073741824, "request_history_limit": 200, "endpoints": ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], diff --git a/gateway.mjs b/gateway.mjs index 664b64d..b04719a 100644 --- a/gateway.mjs +++ b/gateway.mjs @@ -20,10 +20,16 @@ const STATUS_API_PATH = `${ADMIN_BASE_PATH}/api/status`; const CONFIG_API_PATH = `${ADMIN_BASE_PATH}/api/config`; const LOGS_API_PATH = `${ADMIN_BASE_PATH}/api/logs`; const REQUESTS_API_PATH = `${ADMIN_BASE_PATH}/api/requests`; +const THREAD_RULES_API_PATH = `${ADMIN_BASE_PATH}/api/thread-rules`; +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_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_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"; @@ -33,6 +39,7 @@ const REASONING_RETRY_ABORT_WINNER = "reasoning_retry_winner_selected"; const DEFAULT_CONFIG = { profile_name: "default", + image_profile_name: "", listen_host: "127.0.0.1", listen_port: 4610, upstream_base_url: "", @@ -41,6 +48,12 @@ const DEFAULT_CONFIG = { upstream_auth_file: "", upstream_auth_json_path: "", upstream_auth_json_key: "OPENAI_API_KEY", + image_base_url: "", + image_auth_mode: "fixed_bearer", + image_auth_env: "CODEX_RETRY_GATEWAY_IMAGE_API_KEY", + image_auth_file: "", + image_auth_json_path: "", + image_auth_json_key: "OPENAI_API_KEY", request_body_limit_bytes: 1024 * 1024 * 1024, request_history_limit: 0, model_remap: "", @@ -525,6 +538,119 @@ function buildSseBlock(eventName, payloadText) { return Buffer.from(`${lines.join("\n")}\n\n`); } +const RESPONSES_PREVIEW_SUPPRESSED_EVENT_NAMES = new Set([ + "response.created", + "response.in_progress", +]); +const RESPONSES_CAPTURE_PREVIEW_START_AFTER_MS = 1000; +const RESPONSES_MODEL_HEADER_NAMES = new Set(["openai-model", "x-openai-model"]); +const RESPONSES_CAPTURE_PREVIEW_HEARTBEAT_MS = 2000; + +function normalizeResponsesHeaderSubset(...sources) { + const headers = {}; + for (const source of sources) { + if (!source || typeof source !== "object" || Array.isArray(source)) { + continue; + } + for (const [key, value] of Object.entries(source)) { + if (!RESPONSES_MODEL_HEADER_NAMES.has(`${key}`.toLowerCase())) { + continue; + } + headers[key] = cloneJsonLike(value); + } + } + return Object.keys(headers).length > 0 ? headers : null; +} + +function buildResponsesPreviewPayload(type = "response.in_progress") { + return { + type, + response: {}, + }; +} + +function buildResponsesFailedPayload(message, options = {}) { + const payload = { + type: "response.failed", + response: { + status: "failed", + error: { + message: firstNonEmptyString(message, "response failed"), + type: firstNonEmptyString(options.errorType, "server_error"), + }, + }, + }; + const responseId = firstNonEmptyString(options.responseId); + if (responseId) { + payload.response.id = responseId; + } + const code = firstNonEmptyString(options.code); + if (code) { + payload.response.error.code = code; + } + return payload; +} + +function buildResponsesFailedSseDelivery(message, options = {}) { + const payload = buildResponsesFailedPayload(message, options); + const chunk = buildSseBlock("response.failed", JSON.stringify(payload)); + return buildCapturedDelivery( + 200, + new Headers({ + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache", + connection: "keep-alive", + }), + chunk, + { stream_chunks: [chunk] }, + ); +} + +function createResponsesCapturedPreview(res) { + return { + res, + created_at_ms: Date.now(), + started: false, + last_sent_at_ms: 0, + }; +} + +function maybeWriteResponsesCapturedPreview(preview, sourceHeaders = null, nowMs = Date.now()) { + if (!preview?.res || preview.res.writableEnded || preview.res.destroyed) { + return false; + } + if (!preview.started) { + if (nowMs - preview.created_at_ms < RESPONSES_CAPTURE_PREVIEW_START_AFTER_MS) { + return false; + } + preview.started = true; + preview.last_sent_at_ms = nowMs; + if (sourceHeaders) { + copyHeadersToClient(sourceHeaders, preview.res); + } + preview.res.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache", + connection: "keep-alive", + }); + preview.res.write( + buildSseBlock("response.created", JSON.stringify(buildResponsesPreviewPayload("response.created"))), + ); + return true; + } + if (nowMs - preview.last_sent_at_ms < RESPONSES_CAPTURE_PREVIEW_HEARTBEAT_MS) { + return false; + } + preview.last_sent_at_ms = nowMs; + preview.res.write( + buildSseBlock( + "response.in_progress", + JSON.stringify(buildResponsesPreviewPayload("response.in_progress")), + ), + ); + return true; +} + function createEmptySseInspectionResult() { return { reasoning: null, @@ -666,34 +792,60 @@ function normalizeResponsesFailedPayloadForCodex(parsed, eventName = "", fallbac return normalized; } -function normalizeResponsesCompletedPayloadForCodex(parsed, eventName = "", fallbackResponseId = null) { +function normalizeResponsesLifecyclePayloadForCodex(parsed, eventName = "", fallbackResponseId = null) { const eventType = firstNonEmptyString(parsed?.type, eventName); - if (eventType !== "response.done") { + if ( + eventType !== "response.created" && + eventType !== "response.in_progress" && + eventType !== "response.completed" && + eventType !== "response.done" + ) { return null; } - const normalized = cloneJsonLike(parsed) || {}; - normalized.type = "response.completed"; - if (!normalized.response || typeof normalized.response !== "object" || Array.isArray(normalized.response)) { - normalized.response = {}; - } + const normalizedType = eventType === "response.done" ? "response.completed" : eventType; + const normalized = { + type: normalizedType, + response: {}, + }; const responseId = firstNonEmptyString( - normalized.response.id, - normalized.id, + parsed?.response?.id, + parsed?.id, fallbackResponseId, ); - if (responseId && !firstNonEmptyString(normalized.response.id)) { + if (responseId) { normalized.response.id = responseId; } - if (normalized.response.usage === undefined && normalized.usage !== undefined) { - normalized.response.usage = cloneJsonLike(normalized.usage); + const status = firstNonEmptyString(parsed?.response?.status); + if (status) { + normalized.response.status = status; } - if (normalized.response.end_turn === undefined && normalized.end_turn !== undefined) { - normalized.response.end_turn = normalized.end_turn; + const headers = normalizeResponsesHeaderSubset(parsed?.response?.headers, parsed?.headers); + if (headers) { + normalized.response.headers = headers; + } + if (normalizedType === "response.completed") { + const usage = cloneJsonLike(parsed?.response?.usage ?? parsed?.usage); + if (usage !== undefined) { + normalized.response.usage = usage; + } + if (parsed?.response?.end_turn !== undefined) { + normalized.response.end_turn = parsed.response.end_turn; + } else if (parsed?.end_turn !== undefined) { + normalized.response.end_turn = parsed.end_turn; + } } return normalized; } -function processResponsesSseBlockForCodex(state, blockText, fallbackResponseId = null) { +function normalizeResponsesCompletedPayloadForCodex(parsed, eventName = "", fallbackResponseId = null) { + const eventType = firstNonEmptyString(parsed?.type, eventName); + if (eventType !== "response.done" && eventType !== "response.completed") { + return null; + } + return normalizeResponsesLifecyclePayloadForCodex(parsed, eventName, fallbackResponseId); +} + +function processResponsesSseBlockForCodex(state, blockText, fallbackResponseId = null, options = {}) { const lines = `${blockText || ""}` .split(/\r?\n/) .map((line) => line.trimEnd()); @@ -727,7 +879,14 @@ function processResponsesSseBlockForCodex(state, blockText, fallbackResponseId = || extractNonStreamingResponseId(parsed) || firstNonEmptyString(parsed?.response?.id, fallbackResponseId); - let rewritten = normalizeResponsesCompletedPayloadForCodex(parsed, eventName, state.response_id || fallbackResponseId); + let rewritten = normalizeResponsesLifecyclePayloadForCodex( + parsed, + eventName, + state.response_id || fallbackResponseId, + ); + if (!rewritten) { + rewritten = normalizeResponsesCompletedPayloadForCodex(parsed, eventName, state.response_id || fallbackResponseId); + } if (!rewritten) { rewritten = normalizeResponsesFailedPayloadForCodex(parsed, eventName, state.response_id || fallbackResponseId); } @@ -746,30 +905,37 @@ function processResponsesSseBlockForCodex(state, blockText, fallbackResponseId = state.saw_terminal_failure = true; } + if ( + options?.suppressLifecycleEvents && + RESPONSES_PREVIEW_SUPPRESSED_EVENT_NAMES.has(outputEventName) + ) { + return Buffer.alloc(0); + } if (!rewritten) { return Buffer.from(`${lines.join("\n")}\n\n`); } return buildSseBlock(outputEventName, JSON.stringify(outputPayload)); } -function drainResponsesSseForCodex(state, chunk, fallbackResponseId = null) { +function drainResponsesSseForCodex(state, chunk, fallbackResponseId = null, options = {}) { const decoded = state.decoder.decode(chunk, { stream: true }); state.buffer += decoded; const blocks = state.buffer.split(/\r?\n\r?\n/); state.buffer = blocks.pop() ?? ""; return blocks .filter((block) => block.length > 0) - .map((block) => processResponsesSseBlockForCodex(state, block, fallbackResponseId)); + .map((block) => processResponsesSseBlockForCodex(state, block, fallbackResponseId, options)) + .filter((chunkBuffer) => chunkBuffer && chunkBuffer.length > 0); } -function flushResponsesSseForCodex(state, fallbackResponseId = null, fallbackUsage = null) { +function flushResponsesSseForCodex(state, fallbackResponseId = null, fallbackUsage = null, options = {}) { const flushed = state.decoder.decode(); if (flushed) { state.buffer += flushed; } const outputs = []; if (state.buffer.trim()) { - outputs.push(processResponsesSseBlockForCodex(state, state.buffer, fallbackResponseId)); + outputs.push(processResponsesSseBlockForCodex(state, state.buffer, fallbackResponseId, options)); } state.buffer = ""; @@ -790,7 +956,7 @@ function flushResponsesSseForCodex(state, fallbackResponseId = null, fallbackUsa state.saw_response_completed = true; } } - return outputs; + return outputs.filter((chunkBuffer) => chunkBuffer && chunkBuffer.length > 0); } function normalizeIntegerList(values, fallback = []) { @@ -1485,29 +1651,38 @@ function markRequestEntryFirstResponse(runtime, entry, { persistEntry = true, at return true; } -function summarizeProfileAuthSource(env) { - const mode = env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE || "passthrough"; +function summarizeProfileAuthSource(env, options = {}) { + const prefix = options.prefix || "CODEX_RETRY_GATEWAY_UPSTREAM"; + const defaultMode = options.defaultMode || "passthrough"; + const mode = env[`${prefix}_AUTH_MODE`] || defaultMode; if (mode === "auth_json") { - return env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_PATH ? "auth.json path configured" : "~/.codex/auth.json"; + return env[`${prefix}_AUTH_JSON_PATH`] ? "auth.json path configured" : "~/.codex/auth.json"; } if (mode === "manual_bearer") { - const secretPath = env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE || ""; + const secretPath = env[`${prefix}_AUTH_FILE`] || ""; if (!secretPath) { return "system secret file missing"; } return fs.existsSync(secretPath) ? "system secret file configured" : "system secret file missing"; } if (mode === "fixed_bearer") { - if (env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE) { + if (env[`${prefix}_AUTH_FILE`]) { return "token file configured"; } - if (env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV) { - return `env:${env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV}`; + if (env[`${prefix}_AUTH_ENV`]) { + return `env:${env[`${prefix}_AUTH_ENV`]}`; } } return "passthrough"; } +function summarizeImageProfileAuthSource(env) { + return summarizeProfileAuthSource(env, { + prefix: "CODEX_RETRY_GATEWAY_IMAGE", + defaultMode: DEFAULT_CONFIG.image_auth_mode, + }); +} + function buildProfileFormModel(env) { const manualSecretFile = env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE || ""; return { @@ -1547,10 +1722,64 @@ function buildProfileFormModel(env) { }; } -function buildConfigFromProfileEnv(profileName, env) { +function buildImageProfileFormModel(env) { + const manualSecretFile = env.CODEX_RETRY_GATEWAY_IMAGE_AUTH_FILE || ""; + return { + base_url: env.CODEX_RETRY_GATEWAY_IMAGE_BASE_URL || "", + auth_mode: env.CODEX_RETRY_GATEWAY_IMAGE_AUTH_MODE || DEFAULT_CONFIG.image_auth_mode, + auth_env: env.CODEX_RETRY_GATEWAY_IMAGE_AUTH_ENV || DEFAULT_CONFIG.image_auth_env, + auth_file: env.CODEX_RETRY_GATEWAY_IMAGE_AUTH_MODE === "manual_bearer" + ? "" + : manualSecretFile, + manual_secret_file: env.CODEX_RETRY_GATEWAY_IMAGE_AUTH_MODE === "manual_bearer" + ? manualSecretFile + : "", + manual_secret_configured: env.CODEX_RETRY_GATEWAY_IMAGE_AUTH_MODE === "manual_bearer" + && Boolean(manualSecretFile) + && fs.existsSync(manualSecretFile), + auth_json_path: env.CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_PATH || "", + auth_json_key: env.CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_KEY || DEFAULT_CONFIG.image_auth_json_key, + }; +} + +function imageConfigFromConfig(config) { + return { + image_profile_name: `${config?.image_profile_name || ""}`.trim(), + image_base_url: `${config?.image_base_url || ""}`.trim(), + image_auth_mode: config?.image_auth_mode || DEFAULT_CONFIG.image_auth_mode, + image_auth_env: config?.image_auth_env || DEFAULT_CONFIG.image_auth_env, + image_auth_file: config?.image_auth_file || "", + image_auth_json_path: config?.image_auth_json_path || "", + image_auth_json_key: config?.image_auth_json_key || DEFAULT_CONFIG.image_auth_json_key, + }; +} + +function buildImageConfigFromProfileEnv(imageProfileName, env) { + const form = buildImageProfileFormModel(env); + const baseUrl = `${form.base_url || ""}`.trim(); + if (baseUrl) { + try { + new URL(baseUrl); + } catch { + throw new Error("图片上游 Base URL 必须是合法 URL"); + } + } + return { + image_profile_name: imageProfileName || "", + image_base_url: baseUrl, + image_auth_mode: normalizeAuthMode(form.auth_mode), + image_auth_env: form.auth_env || DEFAULT_CONFIG.image_auth_env, + image_auth_file: env.CODEX_RETRY_GATEWAY_IMAGE_AUTH_FILE || "", + image_auth_json_path: form.auth_json_path || "", + image_auth_json_key: form.auth_json_key || DEFAULT_CONFIG.image_auth_json_key, + }; +} + +function buildConfigFromProfileEnv(profileName, env, imageConfig = {}) { const reasoningMatchMode = normalizeReasoningMatchMode(env.CODEX_RETRY_GATEWAY_REASONING_MATCH_MODE); const config = { ...DEFAULT_CONFIG, + ...imageConfigFromConfig(imageConfig), profile_name: profileName, listen_host: env.CODEX_RETRY_GATEWAY_LISTEN_HOST || DEFAULT_CONFIG.listen_host, listen_port: env.CODEX_RETRY_GATEWAY_LISTEN_PORT @@ -1611,15 +1840,14 @@ function buildConfigFromProfileEnv(profileName, env) { return config; } -function assertNoInlineProfileSecret(payload) { - const suspectFields = [ +function assertNoInlineProfileSecret(payload, fields = [ "auth_env", "auth_file", "auth_json_path", "auth_json_key", "upstream_base_url", - ]; - for (const field of suspectFields) { + ]) { + for (const field of fields) { const value = `${payload?.[field] || ""}`.trim(); if (/^sk-[A-Za-z0-9_-]+/.test(value) || /Bearer\s+sk-[A-Za-z0-9_-]+/i.test(value)) { throw new Error("profile 不保存明文 sk 密钥;请改用 env/file/auth.json 引用"); @@ -1632,6 +1860,11 @@ function defaultManualSecretPath(profileName) { return path.join(homeDir, ".codex-retry-gateway", "secrets", `${profileName}.token`); } +function defaultImageManualSecretPath(profileName) { + const homeDir = process.env.HOME || ""; + return path.join(homeDir, ".codex-retry-gateway", "secrets", `${profileName}.images.token`); +} + async function writeManualSecret(profileName, secretValue) { const text = `${secretValue || ""}`.trim(); if (!text) { @@ -1646,6 +1879,20 @@ async function writeManualSecret(profileName, secretValue) { return secretPath; } +async function writeImageManualSecret(profileName, secretValue) { + const text = `${secretValue || ""}`.trim(); + if (!text) { + return null; + } + + const secretPath = defaultImageManualSecretPath(profileName); + await mkdir(path.dirname(secretPath), { recursive: true, mode: 0o700 }); + await writeFile(secretPath, `${text}\n`, { encoding: "utf8", mode: 0o600 }); + await chmod(path.dirname(secretPath), 0o700).catch(() => {}); + await chmod(secretPath, 0o600).catch(() => {}); + return secretPath; +} + function serializeEnvValue(value) { const text = `${value ?? ""}`; if (/^[A-Za-z0-9_./:@?&=,+-]*$/.test(text)) { @@ -1786,6 +2033,71 @@ async function buildProfileEnvText(payload) { }; } +async function buildImageProfileEnvText(payload) { + assertNoInlineProfileSecret(payload, [ + "base_url", + "auth_env", + "auth_file", + "auth_json_path", + "auth_json_key", + ]); + + const name = `${payload?.name || ""}`.trim(); + validateProfileName(name); + + const baseUrl = `${payload?.base_url || ""}`.trim(); + if (baseUrl) { + try { + new URL(baseUrl); + } catch { + throw new Error("图片上游 Base URL 必须是合法 URL"); + } + } + + const authMode = normalizeAuthMode(payload?.auth_mode || DEFAULT_CONFIG.image_auth_mode); + const envPairs = [ + ["CODEX_RETRY_GATEWAY_IMAGE_BASE_URL", baseUrl], + ["CODEX_RETRY_GATEWAY_IMAGE_AUTH_MODE", authMode], + ]; + + if (baseUrl && authMode === "manual_bearer") { + const secretPath = + (await writeImageManualSecret(name, payload?.manual_secret)) || + `${payload?.manual_secret_file || ""}`.trim() || + defaultImageManualSecretPath(name); + if (!fs.existsSync(secretPath)) { + throw new Error("图片 manual_bearer 需要手动填入一次 API key 后才能保存"); + } + envPairs.push(["CODEX_RETRY_GATEWAY_IMAGE_AUTH_FILE", secretPath]); + } else if (baseUrl && authMode === "fixed_bearer") { + envPairs.push([ + "CODEX_RETRY_GATEWAY_IMAGE_AUTH_ENV", + `${payload?.auth_env || DEFAULT_CONFIG.image_auth_env}`.trim(), + ]); + if (`${payload?.auth_file || ""}`.trim()) { + envPairs.push(["CODEX_RETRY_GATEWAY_IMAGE_AUTH_FILE", `${payload.auth_file}`.trim()]); + } + } else if (baseUrl && authMode === "auth_json") { + if (`${payload?.auth_json_path || ""}`.trim()) { + envPairs.push(["CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_PATH", `${payload.auth_json_path}`.trim()]); + } + envPairs.push([ + "CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_KEY", + `${payload?.auth_json_key || DEFAULT_CONFIG.image_auth_json_key}`.trim(), + ]); + } + + return { + name, + content: [ + "# Managed by codex-retry-gateway image profile UI.", + "# Do not put raw sk-* secrets here; use env/file/auth.json references.", + ...envPairs.map(([key, value]) => `${key}=${serializeEnvValue(value)}`), + "", + ].join("\n"), + }; +} + async function writeProfile(runtime, payload) { const { name, content } = await buildProfileEnvText(payload); await mkdir(runtime.paths.profilesDir, { recursive: true }); @@ -1797,6 +2109,17 @@ async function writeProfile(runtime, payload) { }; } +async function writeImageProfile(runtime, payload) { + const { name, content } = await buildImageProfileEnvText(payload); + await mkdir(runtime.paths.imageProfilesDir, { recursive: true }); + const profilePath = path.join(runtime.paths.imageProfilesDir, `${name}.env`); + await writeFile(profilePath, content, { encoding: "utf8", mode: 0o600 }); + return { + name, + file_path: profilePath, + }; +} + function buildMetricsSnapshot(monitor) { const reasoning516Count = monitor.observed_reasoning_counts["516"] || 0; const inspectedResponseCount = monitor.inspected_response_count; @@ -1881,7 +2204,7 @@ async function buildPersistentRequestsSnapshot(runtime, { limit = 50, offset = 0 latest_seq: entries.reduce((maxSeq, entry) => { return Math.max(maxSeq, Number.isInteger(entry.seq) ? entry.seq : maxSeq); }, 0), - entries: entries.slice(-safeLimit).reverse(), + entries: entries.slice(-safeLimit).reverse().map((entry) => decorateRequestEntryWithThreadRule(runtime, entry)), }; } @@ -1903,7 +2226,7 @@ async function buildPersistentRequestsSnapshot(runtime, { limit = 50, offset = 0 return { total_entries: totalRow?.count || 0, latest_seq: latestRow?.latest_seq || 0, - entries: rows.map(parseRequestRowPayload).filter(Boolean), + entries: rows.map(parseRequestRowPayload).filter(Boolean).map((entry) => decorateRequestEntryWithThreadRule(runtime, entry)), }; } @@ -1938,6 +2261,8 @@ function buildRequestEntry({ seq, startedAt, startedMs, req, pathname, requestJs stream_chunk_count: 0, usage_last_updated_at: null, upstream_attempt_count: 0, + reasoning_guard_enabled: true, + reasoning_guard_thread_override: "default", reasoning_retry_enabled: false, reasoning_retry_query_count: 0, reasoning_retry_round_count: 0, @@ -2039,6 +2364,9 @@ async function loadConfig(configPath) { const content = await readFile(configPath, "utf8"); const loaded = JSON.parse(content); const config = { ...DEFAULT_CONFIG, ...loaded }; + config.image_profile_name = /^[A-Za-z0-9_.-]+$/.test(`${config.image_profile_name || ""}`) + ? `${config.image_profile_name}` + : ""; config.model_remap_map = parseModelRemapMap(config.model_remap); config.endpoints = normalizeStringList(config.endpoints, DEFAULT_CONFIG.endpoints).map(normalizePath); config.reasoning_match_mode = normalizeReasoningMatchMode(config.reasoning_match_mode); @@ -2063,6 +2391,14 @@ async function loadConfig(configPath) { config.upstream_fetch_retry_backoff_ms, DEFAULT_CONFIG.upstream_fetch_retry_backoff_ms, ); + config.image_base_url = `${config.image_base_url || ""}`.trim(); + if (config.image_base_url) { + try { + new URL(config.image_base_url); + } catch { + throw new Error("配置中的 image_base_url 必须是合法 URL"); + } + } if (!config.upstream_base_url) { throw new Error("配置缺少 upstream_base_url"); } @@ -2078,8 +2414,10 @@ function buildRuntimePaths(configPath, logPath) { statePath: path.join(stateRoot, "state.json"), pidPath: path.join(stateRoot, "gateway.pid"), profilesDir: path.join(homeDir, ".config", "codex-retry-gateway", "profiles"), + imageProfilesDir: path.join(homeDir, ".config", "codex-retry-gateway", "image-profiles"), configPath, logPath, + threadRulesPath: path.join(stateRoot, "thread-rules.json"), requestsPath: path.join(stateRoot, "logs", "requests.jsonl"), requestsDbPath: path.join(stateRoot, "logs", "requests.sqlite"), }; @@ -2094,6 +2432,188 @@ async function readOptionalJson(jsonPath) { } } +function normalizeThreadRuleEntry(value) { + const threadId = firstNonEmptyString(value?.thread_id, value?.threadId); + if (!threadId || value?.reasoning_intercept_enabled === undefined) { + return null; + } + return { + thread_id: threadId, + reasoning_intercept_enabled: Boolean(value.reasoning_intercept_enabled), + updated_at: firstNonEmptyString(value?.updated_at) || new Date().toISOString(), + }; +} + +function normalizeThreadRulesDocument(payload) { + const rules = Array.isArray(payload) + ? payload + : Array.isArray(payload?.rules) + ? payload.rules + : []; + const entries = []; + for (const rule of rules) { + const normalized = normalizeThreadRuleEntry(rule); + if (normalized) { + entries.push(normalized); + } + } + return entries; +} + +function compareThreadRuleEntries(left, right) { + const leftTime = Date.parse(left?.updated_at || "") || 0; + const rightTime = Date.parse(right?.updated_at || "") || 0; + if (rightTime !== leftTime) { + return rightTime - leftTime; + } + return `${left?.thread_id || ""}`.localeCompare(`${right?.thread_id || ""}`); +} + +function buildThreadRulesMap(entries = []) { + const map = new Map(); + for (const entry of entries) { + map.set(entry.thread_id, entry); + } + return map; +} + +async function loadThreadRules(filePath) { + const payload = await readOptionalJson(filePath); + return buildThreadRulesMap(normalizeThreadRulesDocument(payload)); +} + +function serializeThreadRules(threadRules) { + const rules = Array.from(threadRules?.values?.() || []) + .map((entry) => ({ + thread_id: entry.thread_id, + reasoning_intercept_enabled: Boolean(entry.reasoning_intercept_enabled), + updated_at: entry.updated_at || new Date().toISOString(), + })) + .sort(compareThreadRuleEntries); + return { + version: 1, + rules, + }; +} + +async function writeThreadRules(filePath, threadRules) { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, `${JSON.stringify(serializeThreadRules(threadRules), null, 2)}\n`, "utf8"); +} + +function listThreadRules(runtime) { + return serializeThreadRules(runtime.threadRules).rules; +} + +function getThreadReasoningState(runtime, threadId) { + const normalizedThreadId = firstNonEmptyString(threadId); + if (!normalizedThreadId) { + return { + thread_id: null, + reasoning_intercept_enabled: true, + override: "default", + updated_at: null, + }; + } + const entry = runtime.threadRules?.get(normalizedThreadId) || null; + if (!entry) { + return { + thread_id: normalizedThreadId, + reasoning_intercept_enabled: true, + override: "default", + updated_at: null, + }; + } + return { + thread_id: normalizedThreadId, + reasoning_intercept_enabled: Boolean(entry.reasoning_intercept_enabled), + override: entry.reasoning_intercept_enabled ? "enabled" : "disabled", + updated_at: entry.updated_at || null, + }; +} + +function applyThreadReasoningState(runtime, requestEntry, pathname = requestEntry?.path) { + if (!requestEntry || typeof requestEntry !== "object") { + return requestEntry; + } + const threadState = getThreadReasoningState(runtime, requestEntry.thread_id); + requestEntry.reasoning_guard_enabled = threadState.reasoning_intercept_enabled; + requestEntry.reasoning_guard_thread_override = threadState.override; + const retryPath = isResponsesReasoningRetryPath(pathname); + requestEntry.reasoning_retry_enabled = retryPath && threadState.reasoning_intercept_enabled; + if (!retryPath) { + requestEntry.reasoning_retry_thread_mode = "disabled"; + return requestEntry; + } + if (!threadState.reasoning_intercept_enabled) { + requestEntry.reasoning_retry_thread_mode = "thread_guard_disabled"; + if ( + !requestEntry.reasoning_retry_stop_reason || + requestEntry.reasoning_retry_stop_reason === "missing_thread_id" + ) { + requestEntry.reasoning_retry_stop_reason = "thread_guard_disabled"; + } + return requestEntry; + } + if (requestEntry.thread_id) { + requestEntry.reasoning_retry_thread_mode = "thread_id"; + if (requestEntry.reasoning_retry_stop_reason === "thread_guard_disabled") { + requestEntry.reasoning_retry_stop_reason = null; + } + return requestEntry; + } + requestEntry.reasoning_retry_thread_mode = "missing_thread_id"; + if (!requestEntry.reasoning_retry_stop_reason) { + requestEntry.reasoning_retry_stop_reason = "missing_thread_id"; + } + return requestEntry; +} + +function decorateRequestEntryWithThreadRule(runtime, entry) { + if (!entry || typeof entry !== "object") { + return entry; + } + const threadState = getThreadReasoningState(runtime, entry.thread_id); + return { + ...entry, + reasoning_guard_enabled: threadState.reasoning_intercept_enabled, + reasoning_guard_thread_override: threadState.override, + }; +} + +function normalizeThreadRuleUpdatePayload(payload) { + const threadId = firstNonEmptyString(payload?.thread_id, payload?.threadId); + if (!threadId) { + throw new Error("缺少 thread_id"); + } + if (payload?.reasoning_intercept_enabled === undefined) { + throw new Error("缺少 reasoning_intercept_enabled"); + } + return { + thread_id: threadId, + reasoning_intercept_enabled: Boolean(payload.reasoning_intercept_enabled), + updated_at: new Date().toISOString(), + }; +} + +async function upsertThreadRule(runtime, payload) { + const entry = normalizeThreadRuleUpdatePayload(payload); + runtime.threadRules.set(entry.thread_id, entry); + await writeThreadRules(runtime.paths.threadRulesPath, runtime.threadRules); + return entry; +} + +async function deleteThreadRule(runtime, threadId) { + const normalizedThreadId = firstNonEmptyString(threadId); + if (!normalizedThreadId) { + throw new Error("缺少 thread_id"); + } + const previous = runtime.threadRules.get(normalizedThreadId) || null; + runtime.threadRules.delete(normalizedThreadId); + await writeThreadRules(runtime.paths.threadRulesPath, runtime.threadRules); + return previous; +} + async function readOptionalText(textPath) { try { return await readFile(textPath, "utf8"); @@ -2117,6 +2637,10 @@ function sanitizeConfigForStatus(config) { upstream_auth_json_path, upstream_auth_env, upstream_auth_json_key, + image_auth_file, + image_auth_json_path, + image_auth_env, + image_auth_json_key, model_remap_map, ...rest } = config; @@ -2128,6 +2652,10 @@ function sanitizeConfigForStatus(config) { upstream_auth_file: upstream_auth_file ? "[configured]" : "", upstream_auth_json_path: upstream_auth_json_path ? "[configured]" : "", upstream_auth_json_key: upstream_auth_json_key || null, + image_auth_env: image_auth_env || null, + image_auth_file: image_auth_file ? "[configured]" : "", + image_auth_json_path: image_auth_json_path ? "[configured]" : "", + image_auth_json_key: image_auth_json_key || null, }; } @@ -2153,7 +2681,7 @@ function remapRequestModel(config, requestJson) { }; } -async function resolveUpstreamAuth(config) { +async function resolveUpstreamAuth(config, authScope = "upstream") { const mode = normalizeAuthMode(config.upstream_auth_mode); if (mode === "passthrough") { return { mode, authorization: null, source: "passthrough" }; @@ -2183,7 +2711,7 @@ async function resolveUpstreamAuth(config) { } if (!token) { - throw new Error(`upstream_auth_mode=${mode} requires a configured token source`); + throw new Error(`${authScope}_auth_mode=${mode} requires a configured token source`); } const authorization = token.toLowerCase().startsWith("bearer ") ? token : `Bearer ${token}`; @@ -2259,6 +2787,103 @@ async function listProfiles(runtime) { return profiles; } +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 hasLegacyImageProfileConfig(env) { + return Boolean(`${env?.CODEX_RETRY_GATEWAY_IMAGE_BASE_URL || ""}`.trim()); +} + +function buildLegacyImageProfileEnvText(env) { + const pairs = IMAGE_PROFILE_ENV_KEYS + .filter((key) => env?.[key] !== undefined) + .map((key) => [key, env[key]]); + return [ + "# 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"); +} + +async function migrateLegacyImageProfile(runtime, textProfileName, textEnv = null) { + validateProfileName(textProfileName); + const imageProfilePath = path.join(runtime.paths.imageProfilesDir, `${textProfileName}.env`); + if (fs.existsSync(imageProfilePath)) { + return { name: textProfileName, file_path: imageProfilePath, migrated: false }; + } + + let env = textEnv; + if (!env) { + const textProfilePath = path.join(runtime.paths.profilesDir, `${textProfileName}.env`); + if (!fs.existsSync(textProfilePath)) { + return null; + } + env = parseEnvText((await readOptionalText(textProfilePath)) || ""); + } + if (!hasLegacyImageProfileConfig(env)) { + return null; + } + + await mkdir(runtime.paths.imageProfilesDir, { recursive: true }); + await writeFile(imageProfilePath, buildLegacyImageProfileEnvText(env), { + encoding: "utf8", + mode: 0o600, + }); + return { name: textProfileName, file_path: imageProfilePath, migrated: true }; +} + +async function listImageProfiles(runtime) { + let files = []; + try { + files = await readdir(runtime.paths.imageProfilesDir, { withFileTypes: true }); + } catch { + files = []; + } + + const activeProfile = `${runtime.config.image_profile_name || ""}`.trim(); + const profiles = []; + for (const file of files) { + if (!file.isFile()) { + continue; + } + const name = getProfileNameFromFile(file.name); + if (!name) { + continue; + } + const filePath = path.join(runtime.paths.imageProfilesDir, file.name); + const env = parseEnvText((await readOptionalText(filePath)) || ""); + const form = buildImageProfileFormModel(env); + profiles.push({ + name, + active: name === activeProfile, + file_path: filePath, + summary: { + base_url: form.base_url, + auth_mode: form.auth_mode, + auth_env: redactProfileValue("CODEX_RETRY_GATEWAY_IMAGE_AUTH_ENV", form.auth_env), + auth_file: form.auth_file ? "[configured]" : "", + manual_secret_file: form.manual_secret_file ? "[configured]" : "", + auth_json_path: form.auth_json_path ? "[configured]" : "", + auth_json_key: redactProfileValue( + "CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_KEY", + form.auth_json_key, + ), + auth_source: form.base_url ? summarizeImageProfileAuthSource(env) : "disabled", + }, + form, + }); + } + profiles.sort((left, right) => left.name.localeCompare(right.name)); + return profiles; +} + function runDetached(command, args) { const child = spawn(command, args, { detached: true, @@ -2346,13 +2971,90 @@ async function loadProfileConfigForProbe(runtime, profileName) { throw new Error(`profile 不存在: ${profileName}`); } const env = parseEnvText((await readOptionalText(profilePath)) || ""); - const config = buildConfigFromProfileEnv(profileName, env); + const config = buildConfigFromProfileEnv(profileName, env, imageConfigFromConfig(runtime.config)); if (!config.upstream_base_url) { throw new Error(`profile ${profileName} 缺少 upstream_base_url`); } return { profilePath, env, config }; } +async function loadImageProfileConfigForProbe(runtime, profileName) { + validateProfileName(profileName); + const profilePath = path.join(runtime.paths.imageProfilesDir, `${profileName}.env`); + if (!fs.existsSync(profilePath)) { + throw new Error(`图片 profile 不存在: ${profileName}`); + } + const env = parseEnvText((await readOptionalText(profilePath)) || ""); + const config = buildImageConfigFromProfileEnv(profileName, env); + return { profilePath, env, config }; +} + +async function applyImageProfileConfig(runtime, profileName) { + const { profilePath, config } = await loadImageProfileConfigForProbe(runtime, profileName); + runtime.config = { + ...runtime.config, + ...config, + model_remap_map: parseModelRemapMap(runtime.config.model_remap), + }; + await writeConfig(runtime.configPath, runtime.config); + await updateRuntimeState(runtime, { + image_profile_name: runtime.config.image_profile_name || "", + image_profile_env_path: profilePath, + last_started_at: new Date().toISOString(), + }); + runtime.logger( + `[image-profile] hot-swapped profile=${runtime.config.image_profile_name || "-"} auth=${normalizeAuthMode(runtime.config.image_auth_mode)} upstream=${runtime.config.image_base_url || "disabled"}`, + ); + return { + image_profile: runtime.config.image_profile_name || "", + image_profile_env_path: profilePath, + hot_swapped: true, + image_base_url: runtime.config.image_base_url || "", + }; +} + +async function ensureActiveImageProfile(runtime) { + const textProfileName = `${runtime.config.profile_name || "default"}`.trim(); + const state = await readOptionalJson(runtime.paths.statePath); + const candidates = [ + `${runtime.config.image_profile_name || ""}`.trim(), + `${state?.image_profile_name || ""}`.trim(), + textProfileName, + ].filter((value, index, values) => /^[A-Za-z0-9_.-]+$/.test(value) && values.indexOf(value) === index); + + let selectedName = candidates.find((name) => fs.existsSync(path.join(runtime.paths.imageProfilesDir, `${name}.env`))) || ""; + let migration = null; + if (!selectedName && /^[A-Za-z0-9_.-]+$/.test(textProfileName)) { + migration = await migrateLegacyImageProfile(runtime, textProfileName); + selectedName = migration?.name || ""; + } + if (!selectedName) { + return null; + } + + const { profilePath, config } = await loadImageProfileConfigForProbe(runtime, selectedName); + runtime.config = { + ...runtime.config, + ...config, + model_remap_map: parseModelRemapMap(runtime.config.model_remap), + }; + await writeConfig(runtime.configPath, runtime.config); + await updateRuntimeState(runtime, { + image_profile_name: selectedName, + image_profile_env_path: profilePath, + }); + if (migration?.migrated) { + runtime.logger( + `[image-profile] migrated legacy text profile=${textProfileName} to image profile=${selectedName}`, + ); + } + return { + image_profile: selectedName, + image_profile_env_path: profilePath, + migrated: Boolean(migration?.migrated), + }; +} + async function deleteProfile(runtime, profileName) { validateProfileName(profileName); const activeProfile = runtime.config.profile_name || "default"; @@ -2372,6 +3074,23 @@ async function deleteProfile(runtime, profileName) { }; } +async function deleteImageProfile(runtime, profileName) { + validateProfileName(profileName); + const activeProfile = `${runtime.config.image_profile_name || ""}`.trim(); + if (profileName === activeProfile) { + throw new Error("不能删除当前正在运行的图片 profile;请先切换到其他图片 profile"); + } + const profilePath = path.join(runtime.paths.imageProfilesDir, `${profileName}.env`); + if (!fs.existsSync(profilePath)) { + throw new Error(`图片 profile 不存在: ${profileName}`); + } + await rm(profilePath, { force: true }); + return { + name: profileName, + file_path: profilePath, + }; +} + async function readProbeBodySummary(response, maxChars = 400) { const contentType = response.headers.get("content-type") || ""; const text = await response.text(); @@ -2453,6 +3172,51 @@ async function probeProfile(runtime, payload) { return result; } +async function probeImageProfile(runtime, payload) { + const profileName = `${payload?.profile || ""}`.trim(); + if (!profileName) { + throw new Error("缺少图片 profile"); + } + + const { config } = await loadImageProfileConfigForProbe(runtime, profileName); + if (!config.image_base_url) { + throw new Error(`图片 profile ${profileName} 未配置 Base URL`); + } + const probeConfig = { + ...runtime.config, + ...config, + }; + const upstreamAuth = await resolveUpstreamAuth( + { + 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, + }, + "images", + ); + const modelsUrl = buildUpstreamUrl(config.image_base_url, new URL("http://local/v1/models")); + const { response } = await fetchUpstreamWithRetry(modelsUrl, { + method: "GET", + headers: cloneHeadersForUpstream({}, upstreamAuth), + }, probeConfig, runtime.logger, { method: "GET", pathname: "/v1/models" }); + + return { + image_profile: profileName, + image_base_url: config.image_base_url, + auth_mode: upstreamAuth.mode, + auth_source: upstreamAuth.source, + authorization_configured: Boolean(upstreamAuth.authorization), + probes: [{ + kind: "models", + target: "/v1/models", + status: response.status, + ...await readProbeBodySummary(response, 600), + }], + }; +} + function extractProviderBaseUrl(content, providerName) { if (!content || !providerName) { return null; @@ -2866,7 +3630,9 @@ async function handleManagementRequest(runtime, req, res, requestUrl) { state_root: runtime.paths.stateRoot, log_path: runtime.logPath, requests_path: runtime.paths.requestsPath, + thread_rules_path: runtime.paths.threadRulesPath, profiles_dir: runtime.paths.profilesDir, + image_profiles_dir: runtime.paths.imageProfilesDir, }, metrics: buildMetricsSnapshot(runtime.monitor), }, accessEnabled && accessGranted ? { "set-cookie": buildManagementAccessCookieHeaders(runtime.config, requestManagementAccessKey(req, requestUrl)) } : {}); @@ -2908,6 +3674,89 @@ async function handleManagementRequest(runtime, req, res, requestUrl) { return true; } + if (pathname === THREAD_RULES_API_PATH && req.method === "GET") { + jsonResponse(req, res, 200, { + ok: true, + thread_rules_path: runtime.paths.threadRulesPath, + rules: listThreadRules(runtime), + }); + return true; + } + + if (pathname === THREAD_RULES_API_PATH && req.method === "POST") { + const body = await readRequestBody(req, runtime.config.request_body_limit_bytes); + const payload = parseJsonSafely(body); + if (!payload) { + jsonResponse(req, res, 400, { + error: { + message: "thread rule 保存请求必须是有效 JSON", + code: "invalid_json", + }, + }); + return true; + } + + let savedRule; + try { + savedRule = await upsertThreadRule(runtime, payload); + } catch (error) { + jsonResponse(req, res, 400, { + error: { + message: `${error?.message || error}`, + code: "invalid_thread_rule", + }, + }); + return true; + } + runtime.logger( + `[thread-rule] thread_id=${savedRule.thread_id} reasoning_intercept_enabled=${savedRule.reasoning_intercept_enabled}`, + ); + jsonResponse(req, res, 200, { + ok: true, + message: savedRule.reasoning_intercept_enabled ? "thread 已开启 reasoning 拦截" : "thread 已关闭 reasoning 拦截", + saved_rule: savedRule, + thread_rules_path: runtime.paths.threadRulesPath, + rules: listThreadRules(runtime), + }); + return true; + } + + if (pathname.startsWith(THREAD_RULE_ITEM_API_PREFIX) && req.method === "DELETE") { + const rawThreadId = pathname.slice(THREAD_RULE_ITEM_API_PREFIX.length); + if (!rawThreadId || rawThreadId.includes("/")) { + jsonResponse(req, res, 400, { + error: { + message: "无效的 thread_id", + code: "invalid_thread_id", + }, + }); + return true; + } + + const threadId = decodeURIComponent(rawThreadId); + let removedRule; + try { + removedRule = await deleteThreadRule(runtime, threadId); + } catch (error) { + jsonResponse(req, res, 400, { + error: { + message: `${error?.message || error}`, + code: "invalid_thread_rule", + }, + }); + return true; + } + runtime.logger(`[thread-rule] thread_id=${threadId} restored_to_default`); + jsonResponse(req, res, 200, { + ok: true, + message: "thread 已恢复默认拦截策略", + removed_rule: removedRule, + thread_rules_path: runtime.paths.threadRulesPath, + rules: listThreadRules(runtime), + }); + return true; + } + if (pathname === PROFILES_API_PATH && req.method === "GET") { jsonResponse(req, res, 200, { ok: true, @@ -3022,6 +3871,114 @@ async function handleManagementRequest(runtime, req, res, requestUrl) { return true; } + if (pathname === IMAGE_PROFILES_API_PATH && req.method === "GET") { + jsonResponse(req, res, 200, { + ok: true, + image_profiles_dir: runtime.paths.imageProfilesDir, + active_image_profile: runtime.config.image_profile_name || "", + image_profiles: await listImageProfiles(runtime), + }); + 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); + if (!payload) { + jsonResponse(req, res, 400, { + error: { + message: "图片 profile 保存请求必须是有效 JSON", + code: "invalid_json", + }, + }); + return true; + } + + const result = await writeImageProfile(runtime, payload); + let applied = null; + if (result.name === `${runtime.config.image_profile_name || ""}`.trim()) { + applied = await applyImageProfileConfig(runtime, result.name); + } + runtime.logger(`[image-profile] saved name=${result.name} path=${result.file_path}`); + jsonResponse(req, res, 200, { + ok: true, + message: applied ? "图片 profile 已保存并已热应用" : "图片 profile 已保存", + saved_image_profile: result, + applied_image_profile: applied, + image_profiles_dir: runtime.paths.imageProfilesDir, + active_image_profile: runtime.config.image_profile_name || "", + image_profiles: await listImageProfiles(runtime), + }); + return true; + } + + if (pathname === IMAGE_PROFILE_PROBE_API_PATH && req.method === "POST") { + const body = await readRequestBody(req, runtime.config.request_body_limit_bytes); + const payload = parseJsonSafely(body); + if (!payload) { + jsonResponse(req, res, 400, { + error: { + message: "图片 profile probe 请求必须是有效 JSON", + code: "invalid_json", + }, + }); + return true; + } + const result = await probeImageProfile(runtime, payload); + runtime.logger( + `[image-profile-probe] profile=${result.image_profile} auth=${result.auth_mode}/${result.auth_source} upstream=${result.image_base_url}`, + ); + jsonResponse(req, res, 200, { ok: true, ...result }); + return true; + } + + if (pathname === IMAGE_PROFILE_SWITCH_API_PATH && req.method === "POST") { + const body = await readRequestBody(req, runtime.config.request_body_limit_bytes); + const payload = parseJsonSafely(body); + const profileName = `${payload?.profile || ""}`.trim(); + if (!profileName) { + jsonResponse(req, res, 400, { + error: { + message: "缺少图片 profile", + code: "profile_required", + }, + }); + return true; + } + const result = await applyImageProfileConfig(runtime, profileName); + jsonResponse(req, res, 200, { + ok: true, + message: "图片 profile 已热切换,无需重启 gateway", + ...result, + }); + return true; + } + + if (pathname.startsWith(IMAGE_PROFILE_ITEM_API_PREFIX) && req.method === "DELETE") { + const rawName = pathname.slice(IMAGE_PROFILE_ITEM_API_PREFIX.length); + if (!rawName || rawName.includes("/")) { + jsonResponse(req, res, 400, { + error: { + message: "无效的图片 profile 名称", + code: "invalid_profile_name", + }, + }); + return true; + } + const profileName = decodeURIComponent(rawName); + const result = await deleteImageProfile(runtime, profileName); + runtime.logger(`[image-profile] deleted name=${result.name} path=${result.file_path}`); + jsonResponse(req, res, 200, { + ok: true, + message: "图片 profile 已删除", + deleted_image_profile: result, + image_profiles_dir: runtime.paths.imageProfilesDir, + active_image_profile: runtime.config.image_profile_name || "", + image_profiles: await listImageProfiles(runtime), + }); + return true; + } + if (pathname === CONFIG_API_PATH && req.method === "POST") { const body = await readRequestBody(req, runtime.config.request_body_limit_bytes); const payload = parseJsonSafely(body); @@ -3052,6 +4009,7 @@ async function handleManagementRequest(runtime, req, res, requestUrl) { state_path: runtime.paths.statePath, state_root: runtime.paths.stateRoot, log_path: runtime.logPath, + thread_rules_path: runtime.paths.threadRulesPath, }, metrics: buildMetricsSnapshot(runtime.monitor), }); @@ -3256,6 +4214,20 @@ async function writeCapturedResponse(res, delivery) { res.end(delivery.body); } +async function appendCapturedResponse(res, delivery) { + if (!delivery) { + throw new Error("missing captured response delivery"); + } + if (Array.isArray(delivery.stream_chunks) && delivery.stream_chunks.length > 0) { + await writeBufferedStreamChunks(res, delivery.stream_chunks); + } else if (delivery.body?.length) { + res.write(delivery.body); + } + if (!res.writableEnded) { + res.end(); + } +} + function buildCapturedDelivery(statusCode, headers, body, options = {}) { const delivery = { status_code: statusCode, @@ -3353,7 +4325,7 @@ function isResponsesReasoningRetryPath(pathname) { } function isResponsesReasoningRetryEligible(pathname, requestEntry) { - return isResponsesReasoningRetryPath(pathname) && Boolean(requestEntry?.thread_id); + return isResponsesReasoningRetryPath(pathname) && Boolean(requestEntry?.thread_id) && Boolean(requestEntry?.reasoning_retry_enabled); } function reasoningRetryWaveWidth(round) { @@ -3399,11 +4371,50 @@ function matchPath(config, pathname) { return config.endpoints.includes(normalizePath(pathname)); } +function isImageRequestPath(pathname) { + const normalizedPath = normalizePath(pathname); + return ( + normalizedPath === "/images" || + normalizedPath.startsWith("/images/") || + normalizedPath === "/v1/images" || + normalizedPath.startsWith("/v1/images/") + ); +} + +function normalizeImageRequestUrl(requestUrl) { + const normalized = new URL(requestUrl.toString()); + if (normalized.pathname === "/images" || normalized.pathname.startsWith("/images/")) { + normalized.pathname = `/v1${normalized.pathname}`; + } + return normalized; +} + +function selectUpstreamRoute(config, pathname) { + if (isImageRequestPath(pathname) && `${config.image_base_url || ""}`.trim()) { + return { + kind: "images", + baseUrl: config.image_base_url, + authConfig: { + 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, + }, + }; + } + return { + kind: "default", + baseUrl: config.upstream_base_url, + authConfig: config, + }; +} + function reasoningMatchesFormula518nMinus2(reasoning) { return Number.isInteger(reasoning) && reasoning >= 516 && (reasoning + 2) % 518 === 0; } -function reasoningMatched(config, reasoning) { +function reasoningMatchedByConfig(config, reasoning) { if (!Number.isInteger(reasoning)) { return false; } @@ -3413,6 +4424,13 @@ function reasoningMatched(config, reasoning) { return reasoningMatchesFormula518nMinus2(reasoning); } +function reasoningMatched(runtime, config, requestEntry, reasoning) { + if (!getThreadReasoningState(runtime, requestEntry?.thread_id).reasoning_intercept_enabled) { + return false; + } + return reasoningMatchedByConfig(config, reasoning); +} + function collectRetryableMessageCandidates(value, state = { seen: new Set(), results: [] }, depth = 0) { if (value === null || value === undefined || depth > 5 || state.results.length >= 64) { return state.results; @@ -3769,7 +4787,6 @@ async function handleNonStreaming({ requestEntry.thread_id, parsed ? extractResponseThreadId(parsed) : null, ); - const matched = reasoningMatched(config, reasoning); const retryableUpstreamError = terminalRetryableUpstreamError || findRetryableUpstreamErrorMatch( config, upstreamResponse.status, @@ -3778,6 +4795,8 @@ async function handleNonStreaming({ ); requestEntry.response_id = responseId || requestEntry.response_id || null; requestEntry.thread_id = threadId || requestEntry.thread_id || null; + applyThreadReasoningState(runtime, requestEntry, pathname); + const matched = reasoningMatched(runtime, config, requestEntry, reasoning); recordInspectedResponse(monitor, reasoning, matched || Boolean(retryableUpstreamError)); @@ -3884,6 +4903,7 @@ async function handleStreaming({ requestAbortSignal = null, captureOnly = false, persistEntry = true, + capturedResponsesPreview = null, }) { const strict502Mode = captureOnly || config.stream_action !== "disconnect"; const reader = upstreamResponse.body.getReader(); @@ -4006,6 +5026,7 @@ async function handleStreaming({ codexResponsesSseState, requestEntry.response_id || null, observedUsage, + { suppressLifecycleEvents: Boolean(capturedResponsesPreview?.started) }, ) : []; if (Number.isInteger(finalInspection.reasoning)) { @@ -4018,6 +5039,7 @@ async function handleStreaming({ if (finalInspection.thread_id) { requestEntry.thread_id = finalInspection.thread_id; } + applyThreadReasoningState(runtime, requestEntry, pathname); if (codexResponsesSseState?.response_id && !requestEntry.response_id) { requestEntry.response_id = codexResponsesSseState.response_id; } @@ -4039,7 +5061,7 @@ async function handleStreaming({ stream_chunk_count: requestEntry.stream_chunk_count, }; } - if (reasoningMatched(config, observedReasoning)) { + if (reasoningMatched(runtime, config, requestEntry, observedReasoning)) { recordInspectedResponse(monitor, observedReasoning, true); if (config.log_match) { logger( @@ -4149,13 +5171,6 @@ async function handleStreaming({ const chunkBuffer = Buffer.from(value); const now = new Date(); const inspection = inspectSseChunk(sseState, value, config); - const outputChunks = codexResponsesSseState - ? drainResponsesSseForCodex( - codexResponsesSseState, - value, - requestEntry.response_id || null, - ) - : [chunkBuffer]; const retryableUpstreamError = inspection.retryable_upstream_error; if (retryableUpstreamError) { abortController.abort(); @@ -4190,6 +5205,7 @@ async function handleStreaming({ if (inspection.thread_id) { requestEntry.thread_id = inspection.thread_id; } + applyThreadReasoningState(runtime, requestEntry, pathname); updateStreamingProgress(requestEntry, { chunkBytes: chunkBuffer.length, usage: inspection.usage, @@ -4204,7 +5220,7 @@ async function handleStreaming({ now, ); } - if (reasoningMatched(config, reasoning)) { + if (reasoningMatched(runtime, config, requestEntry, reasoning)) { recordInspectedResponse(monitor, reasoning, true); if (config.log_match) { logger( @@ -4244,6 +5260,18 @@ async function handleStreaming({ }; } + if (captureOnly && codexResponsesSseState && capturedResponsesPreview) { + maybeWriteResponsesCapturedPreview(capturedResponsesPreview, upstreamResponse.headers, now.getTime()); + } + const outputChunks = codexResponsesSseState + ? drainResponsesSseForCodex( + codexResponsesSseState, + value, + requestEntry.response_id || null, + { suppressLifecycleEvents: Boolean(capturedResponsesPreview?.started) }, + ) + : [chunkBuffer]; + if (strict502Mode) { bufferedChunks.push(...outputChunks); } else { @@ -4319,6 +5347,7 @@ async function executeGatewayQuery({ captureOnly = false, persistEntry = true, externalAbortSignals = [], + capturedResponsesPreview = null, }) { const maxUpstreamAttempts = normalizePositiveInteger( config.upstream_fetch_retry_attempts, @@ -4365,7 +5394,11 @@ async function executeGatewayQuery({ requestEntry.response_stream = responseIsStream; requestEntry.inspected = shouldInspect; requestEntry.upstream_status_code = upstreamResponse.status; - requestEntry.upstream = buildUpstreamSnapshot({ upstreamUrl, upstreamAuth, upstreamResponse }); + const upstreamRoute = requestEntry.upstream?.route || "default"; + requestEntry.upstream = { + ...buildUpstreamSnapshot({ upstreamUrl, upstreamAuth, upstreamResponse }), + route: upstreamRoute, + }; if (persistEntry) { upsertRequestEntry(runtime, requestEntry); } @@ -4378,6 +5411,7 @@ async function executeGatewayQuery({ if (!shouldInspect) { markRequestEntryFirstResponse(runtime, requestEntry, { persistEntry }); const body = Buffer.from(await upstreamResponse.arrayBuffer()); + requestEntry.response_bytes_received = body.length; const parsed = isJsonContentType(responseContentType) ? parseJsonSafely(body) : null; @@ -4417,6 +5451,7 @@ async function executeGatewayQuery({ requestAbortSignal: queryAbortLink.controller.signal, captureOnly, persistEntry, + capturedResponsesPreview, }) : await handleNonStreaming({ runtime, @@ -4645,6 +5680,7 @@ async function runResponsesReasoningRetry({ monitor, pathname, req, + res, requestBody, requestIsStream, upstreamUrl, @@ -4655,6 +5691,9 @@ async function runResponsesReasoningRetry({ let round = 1; let totalUpstreamAttempts = 0; const retryExtraState = createReasoningRetryExtraState(); + const capturedResponsesPreview = requestIsStream && res && isResponsesReasoningRetryPath(pathname) + ? createResponsesCapturedPreview(res) + : null; while (true) { if (clientAbortSignal?.aborted) { @@ -4697,6 +5736,7 @@ async function runResponsesReasoningRetry({ upstreamAuth, requestEntry: attemptEntry, externalAbortSignals: [clientAbortSignal, slotController.signal], + capturedResponsesPreview, }); return new Promise((resolve, reject) => { let settled = false; @@ -4782,6 +5822,7 @@ async function runResponsesReasoningRetry({ ...winner.result, response_stream: winner.result.response_stream, total_upstream_attempts: totalUpstreamAttempts, + responses_capture_preview_started: Boolean(capturedResponsesPreview?.started), }; } @@ -4804,8 +5845,18 @@ async function runResponsesReasoningRetry({ requestEntry.reasoning_retry_stop_reason = fatalOutcome.result?.match_reason || "fatal"; return { ...fatalOutcome.result, + delivery: capturedResponsesPreview?.started + ? buildResponsesFailedSseDelivery( + fatalOutcome.result?.error || "reasoning retry fatal stream failure", + { + responseId: requestEntry.response_id || fatalOutcome.result?.response_id || null, + code: "reasoning_retry_failed", + }, + ) + : fatalOutcome.result?.delivery, response_stream: fatalOutcome.result.response_stream, total_upstream_attempts: totalUpstreamAttempts, + responses_capture_preview_started: Boolean(capturedResponsesPreview?.started), }; } @@ -4818,16 +5869,22 @@ async function runResponsesReasoningRetry({ return { inspected: false, matched: false, - status_code: 502, + status_code: capturedResponsesPreview?.started ? 200 : 502, upstream_status_code: null, error: "reasoning retry ended without a successful response", response_stream: requestIsStream, total_upstream_attempts: totalUpstreamAttempts, - delivery: buildCapturedDelivery( - 502, - new Headers({ "content-type": "application/json; charset=utf-8" }), - buildGatewayErrorBody("reasoning retry ended without a successful response"), - ), + delivery: capturedResponsesPreview?.started + ? buildResponsesFailedSseDelivery("reasoning retry ended without a successful response", { + responseId: requestEntry.response_id || null, + code: "reasoning_retry_exhausted", + }) + : buildCapturedDelivery( + 502, + new Headers({ "content-type": "application/json; charset=utf-8" }), + buildGatewayErrorBody("reasoning retry ended without a successful response"), + ), + responses_capture_preview_started: Boolean(capturedResponsesPreview?.started), }; } } @@ -4898,18 +5955,19 @@ async function proxyRequest(runtime, req, res) { requestEntry.reasoning_effort = extractRequestReasoningEffort(requestJson); requestEntry.reasoning_summary = extractRequestReasoningSummary(requestJson); requestEntry.request_stream = requestIsStream; - requestEntry.reasoning_retry_enabled = isResponsesReasoningRetryPath(pathname); - requestEntry.reasoning_retry_thread_mode = requestEntry.reasoning_retry_enabled - ? (requestEntry.thread_id ? "thread_id" : "missing_thread_id") - : "disabled"; - if (requestEntry.reasoning_retry_thread_mode === "missing_thread_id") { - requestEntry.reasoning_retry_stop_reason = "missing_thread_id"; - } + applyThreadReasoningState(runtime, requestEntry, pathname); upsertRequestEntry(runtime, requestEntry); - const upstreamUrl = buildUpstreamUrl(config.upstream_base_url, incomingUrl); - const upstreamAuth = await resolveUpstreamAuth(config); - requestEntry.upstream = buildUpstreamSnapshot({ upstreamUrl, upstreamAuth }); + const upstreamRoute = selectUpstreamRoute(config, pathname); + const upstreamRequestUrl = upstreamRoute.kind === "images" + ? normalizeImageRequestUrl(incomingUrl) + : incomingUrl; + const upstreamUrl = buildUpstreamUrl(upstreamRoute.baseUrl, upstreamRequestUrl); + const upstreamAuth = await resolveUpstreamAuth(upstreamRoute.authConfig, upstreamRoute.kind); + requestEntry.upstream = { + ...buildUpstreamSnapshot({ upstreamUrl, upstreamAuth }), + route: upstreamRoute.kind, + }; upsertRequestEntry(runtime, requestEntry); if (remapped && requestEntry.requested_model && requestEntry.forwarded_model) { logger?.( @@ -4926,6 +5984,7 @@ async function proxyRequest(runtime, req, res) { monitor: runtime.monitor, pathname, req, + res, requestBody, requestIsStream, upstreamUrl, @@ -4949,9 +6008,16 @@ async function proxyRequest(runtime, req, res) { externalAbortSignals: [clientAbortContext.signal], }); - const { delivery, total_upstream_attempts: observedUpstreamAttempts, ...resultFields } = result; + const { + delivery, + total_upstream_attempts: observedUpstreamAttempts, + responses_capture_preview_started: responsesCapturePreviewStarted, + ...resultFields + } = result; if (delivery && !res.headersSent) { await writeCapturedResponse(res, delivery); + } else if (delivery && responsesCapturePreviewStarted && !res.writableEnded) { + await appendCapturedResponse(res, delivery); } if (Number.isInteger(observedUpstreamAttempts)) { requestEntry.upstream_attempt_count = observedUpstreamAttempts; @@ -4995,26 +6061,30 @@ async function main() { const configPath = args.config || path.join(__dirname, "config.json"); const config = await loadConfig(configPath); const monitor = createMonitor(); + const paths = buildRuntimePaths(configPath, args.log || null); if (args.log) { await mkdir(path.dirname(args.log), { recursive: true }); } const logger = createLogger(args.log, createMonitorRecorder(monitor)); - const requestsDb = openRequestsDatabase(buildRuntimePaths(configPath, args.log || null).requestsDbPath); + const requestsDb = openRequestsDatabase(paths.requestsDbPath); const runtime = { config, configPath, logPath: args.log || null, logger, monitor, - paths: buildRuntimePaths(configPath, args.log || null), + paths, + threadRules: await loadThreadRules(paths.threadRulesPath), requestsDb, server: null, }; + await ensureActiveImageProfile(runtime); const importedCount = await importRequestsJsonlToDb(requestsDb, runtime.paths.requestsPath); await hydrateMonitorFromDisk(monitor, runtime.paths, config.request_history_limit, requestsDb); logger(`[start] hydrated persistent request metrics from db path=${runtime.paths.requestsDbPath}`); logger(`[start] requests db ready path=${runtime.paths.requestsDbPath} imported_jsonl_rows=${importedCount}`); + logger(`[start] thread rules ready path=${runtime.paths.threadRulesPath} count=${runtime.threadRules.size}`); const server = http.createServer(async (req, res) => { try { @@ -5059,14 +6129,15 @@ async function main() { process.on("SIGTERM", () => shutdown("SIGTERM")); process.on("SIGINT", () => shutdown("SIGINT")); - server.listen(config.listen_port, config.listen_host, () => { + server.listen(runtime.config.listen_port, runtime.config.listen_host, () => { updateRuntimeState(runtime, { last_started_at: new Date().toISOString(), - profile_name: config.profile_name || "default", - gateway_base_url: `http://${config.listen_host}:${config.listen_port}`, + 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}`, }).catch((error) => logger(`[state] failed to update runtime state: ${error?.message || error}`)); logger( - `[start] codex retry gateway profile=${config.profile_name || "default"} auth=${normalizeAuthMode(config.upstream_auth_mode)} reasoning_match_mode=${normalizeReasoningMatchMode(config.reasoning_match_mode)} listening on http://${config.listen_host}:${config.listen_port} -> ${config.upstream_base_url}`, + `[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/run-profile.mjs b/scripts/run-profile.mjs index 99f6da3..06aef57 100644 --- a/scripts/run-profile.mjs +++ b/scripts/run-profile.mjs @@ -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, diff --git a/scripts/test-gateway-e2e.mjs b/scripts/test-gateway-e2e.mjs index 4f07115..f1f0230 100644 --- a/scripts/test-gateway-e2e.mjs +++ b/scripts/test-gateway-e2e.mjs @@ -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 { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -170,7 +170,7 @@ function beginReasoningRetryTrackedRequest(statsMap, key, res) { }; } -function startFakeUpstream(port) { +function startFakeUpstream(port, options = {}) { const failBeforeResponseCounts = new Map(); const capacityBeforeSuccessCounts = new Map(); const reasoningBeforeSuccessCounts = new Map(); @@ -179,7 +179,7 @@ function startFakeUpstream(port) { const responsePaths = new Set(["/responses", "/v1/responses"]); const chatCompletionPaths = new Set(["/chat/completions", "/v1/chat/completions"]); - if (req.method === "GET" && req.url === "/v1/models") { + if (req.method === "GET" && req.url === "/v1/models") { createJsonResponse( res, 200, @@ -303,14 +303,70 @@ function startFakeUpstream(port) { ? parsed.test_stream_delta_chunks : 1; const deltaTextBase = parsed.test_stream_delta_text || "hello"; + const lifecyclePayloadExtra = parsed.test_stream_lifecycle_marker + ? { + marker: parsed.test_stream_lifecycle_marker, + bulk: "X".repeat(2048), + } + : null; + const lifecycleChunks = parsed.test_stream_include_lifecycle + ? [ + `data: ${JSON.stringify({ + type: "response.created", + response: { + id: "resp_stream", + status: "in_progress", + headers: { "openai-model": parsed.model || "grok-4.5" }, + output: lifecyclePayloadExtra, + }, + })}\n\n`, + `data: ${JSON.stringify({ + type: "response.in_progress", + response: { + id: "resp_stream", + status: "in_progress", + headers: { "openai-model": parsed.model || "grok-4.5" }, + output: lifecyclePayloadExtra, + }, + })}\n\n`, + ] + : []; const streamChunks = Array.from({ length: deltaChunkCount }, (_, index) => { const deltaText = deltaChunkCount === 1 ? deltaTextBase : `${deltaTextBase}-${index + 1}`; return `data: ${JSON.stringify({ type: "response.output_text.delta", delta: deltaText, response_id: "resp_stream", thread_id: parsed.thread_id || "thread_stream", retry_attempt: reasoningAttempt })}\n\n`; }); - streamChunks.push( - `data: {"response":{"usage":{"output_tokens_details":{"reasoning_tokens":${reasoning}}}}}\n\n`, - "data: [DONE]\n\n", - ); + if (parsed.test_stream_include_lifecycle) { + streamChunks.unshift(...lifecycleChunks); + } + if (parsed.test_stream_include_lifecycle) { + streamChunks.push( + `data: ${JSON.stringify({ + type: "response.completed", + response: { + id: "resp_stream", + status: "completed", + headers: { "openai-model": parsed.model || "grok-4.5" }, + output: lifecyclePayloadExtra, + usage: { + input_tokens: 12, + output_tokens: 34, + total_tokens: 46, + output_tokens_details: { + reasoning_tokens: reasoning, + }, + }, + }, + })}\n\n`, + ); + } else { + streamChunks.push( + `data: {"response":{"usage":{"output_tokens_details":{"reasoning_tokens":${reasoning}}}}}\n\n`, + "data: [DONE]\n\n", + ); + } + if (parsed.test_stream_include_lifecycle) { + streamChunks.push("data: [DONE]\n\n"); + } createSseResponse(res, streamChunks, parsed.test_reasoning_response_delay_ms ?? parsed.test_stream_chunk_delay_ms ?? 20, { headers: reasoningAttempt ? { "x-upstream-reasoning-attempt": `${reasoningAttempt}` } @@ -352,6 +408,30 @@ function startFakeUpstream(port) { }); return; } + + if (req.method === "POST" && req.url.startsWith("/v1/images/")) { + let body = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + const contentType = req.headers["content-type"] || ""; + createJsonResponse( + res, + 200, + { + upstream: options.label || "default", + path: req.url, + authorization: req.headers.authorization || "", + content_type: contentType, + request: contentType.includes("application/json") ? JSON.parse(body || "{}") : body, + }, + { "x-upstream-test": `images-${options.label || "default"}` }, + ); + }); + return; + } if (req.method === "POST" && chatCompletionPaths.has(req.url)) { let body = ""; @@ -415,11 +495,12 @@ async function waitForHealth(url, timeoutMs = 5000) { throw new Error(`等待网关健康检查超时: ${url}`); } -function startGateway(configPath, logPath) { - const child = spawn(process.execPath, [gatewayEntry, "--config", configPath, "--log", logPath], { - cwd: gatewayRoot, - stdio: ["ignore", "pipe", "pipe"], - }); +function startGateway(configPath, logPath, environment = {}) { + const child = spawn(process.execPath, [gatewayEntry, "--config", configPath, "--log", logPath], { + cwd: gatewayRoot, + env: { ...process.env, ...environment }, + stdio: ["ignore", "pipe", "pipe"], + }); let stdout = ""; let stderr = ""; @@ -489,15 +570,22 @@ async function run() { ); const tempRoot = await mkdtemp(path.join(os.tmpdir(), "codex-retry-gateway-")); - const upstreamPort = await getFreePort(); - const gatewayPort = await getFreePort(); - const configPath = path.join(tempRoot, "config.json"); - const logPath = path.join(tempRoot, "gateway.log"); + const upstreamPort = await getFreePort(); + const imageUpstreamPort = await getFreePort(); + const gatewayPort = await getFreePort(); + const configPath = path.join(tempRoot, "config.json"); + const logPath = path.join(tempRoot, "gateway.log"); + const profilesDir = path.join(tempRoot, ".config", "codex-retry-gateway", "profiles"); + const imageProfilesDir = path.join(tempRoot, ".config", "codex-retry-gateway", "image-profiles"); const config = { + profile_name: "legacy-text", listen_host: "127.0.0.1", listen_port: gatewayPort, upstream_base_url: `http://127.0.0.1:${upstreamPort}`, + image_base_url: `http://127.0.0.1:${imageUpstreamPort}`, + image_auth_mode: "fixed_bearer", + image_auth_env: "TEST_CODEX_RETRY_GATEWAY_IMAGE_API_KEY", request_body_limit_bytes: 1024 * 1024 * 1024, endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], reasoning_match_mode: "formula_518n_minus_2", @@ -515,11 +603,27 @@ async function run() { log_match: true, health_path: "/__codex_retry_gateway/health", }; - - await writeFile(configPath, JSON.stringify(config, null, 2), "utf8"); - - const upstream = await startFakeUpstream(upstreamPort); - let gateway = startGateway(configPath, logPath); + + process.env.TEST_CODEX_RETRY_GATEWAY_IMAGE_API_KEY = "image-test-key"; + await mkdir(profilesDir, { recursive: true }); + await writeFile( + path.join(profilesDir, "legacy-text.env"), + [ + `CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL=http://127.0.0.1:${upstreamPort}`, + "CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE=passthrough", + `CODEX_RETRY_GATEWAY_IMAGE_BASE_URL=http://127.0.0.1:${imageUpstreamPort}`, + "CODEX_RETRY_GATEWAY_IMAGE_AUTH_MODE=fixed_bearer", + "CODEX_RETRY_GATEWAY_IMAGE_AUTH_ENV=TEST_CODEX_RETRY_GATEWAY_IMAGE_API_KEY", + "", + ].join("\n"), + "utf8", + ); + await writeFile(configPath, JSON.stringify(config, null, 2), "utf8"); + + const upstream = await startFakeUpstream(upstreamPort, { label: "default" }); + const imageUpstream = await startFakeUpstream(imageUpstreamPort, { label: "images" }); + const gatewayEnvironment = { HOME: tempRoot }; + let gateway = startGateway(configPath, logPath, gatewayEnvironment); try { try { @@ -558,12 +662,202 @@ async function run() { "status API 未暴露 management_access_key_configured", ); + const migratedImageProfilesResponse = await fetch( + `http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/image-profiles`, + { headers: adminHeaders }, + ); + assert(migratedImageProfilesResponse.status === 200, `图片 profile 迁移列表读取失败: ${migratedImageProfilesResponse.status}`); + const migratedImageProfilesPayload = await migratedImageProfilesResponse.json(); + const migratedImageProfile = (migratedImageProfilesPayload.image_profiles || []).find( + (profile) => profile?.name === "legacy-text", + ); + assert(migratedImageProfilesPayload.active_image_profile === "legacy-text", "旧图片配置迁移后未成为当前图片 profile"); + assert(migratedImageProfile?.form?.base_url === `http://127.0.0.1:${imageUpstreamPort}`, "旧图片配置未迁移到独立 profile"); + assert( + !(await readFile(path.join(imageProfilesDir, "legacy-text.env"), "utf8")).includes("image-test-key"), + "图片 profile 迁移不应写入 API key 明文", + ); + + const dualUpstreamProfileResponse = 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: "dual-upstream", + listen_host: "127.0.0.1", + listen_port: gatewayPort, + upstream_base_url: `http://127.0.0.1:${upstreamPort}`, + auth_mode: "fixed_bearer", + auth_env: "TEST_CODEX_RETRY_GATEWAY_TEXT_API_KEY", + image_base_url: `http://127.0.0.1:${imageUpstreamPort}`, + image_auth_mode: "manual_bearer", + image_manual_secret: "test-image-profile-secret", + }), + }, + ); + assert(dualUpstreamProfileResponse.status === 200, `双上游 profile 保存失败: ${dualUpstreamProfileResponse.status}`); + const dualUpstreamProfilePayload = await dualUpstreamProfileResponse.json(); + const dualUpstreamProfile = (dualUpstreamProfilePayload.profiles || []).find( + (profile) => profile?.name === "dual-upstream", + ); + assert(dualUpstreamProfile?.form?.upstream_base_url === `http://127.0.0.1:${upstreamPort}`, "文本 profile 保存失败"); + assert(dualUpstreamProfile?.form?.image_base_url === undefined, "文本 profile 不应继续绑定图片配置"); + assert( + !JSON.stringify(dualUpstreamProfilePayload).includes("test-image-profile-secret"), + "文本 profile API 不应返回被忽略的图片明文 secret", + ); + assert( + !(await readFile(path.join(profilesDir, "dual-upstream.env"), "utf8")).includes("CODEX_RETRY_GATEWAY_IMAGE_"), + "文本 profile 保存不应写入图片配置", + ); + + const imageProfileResponse = await fetch( + `http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/image-profiles`, + { + method: "POST", + headers: { ...adminHeaders, "content-type": "application/json" }, + body: JSON.stringify({ + name: "image-primary", + base_url: `http://127.0.0.1:${imageUpstreamPort}`, + auth_mode: "manual_bearer", + manual_secret: "test-image-profile-secret", + }), + }, + ); + assert(imageProfileResponse.status === 200, `独立图片 profile 保存失败: ${imageProfileResponse.status}`); + const imageProfilePayload = await imageProfileResponse.json(); + const imagePrimary = (imageProfilePayload.image_profiles || []).find((profile) => profile?.name === "image-primary"); + assert(imagePrimary?.form?.base_url === `http://127.0.0.1:${imageUpstreamPort}`, "独立图片 profile 未返回图片上游"); + assert(imagePrimary?.summary?.auth_mode === "manual_bearer", "独立图片 profile 未返回认证模式"); + assert(imagePrimary?.summary?.auth_source === "system secret file configured", "独立图片 profile 未返回认证来源"); + assert(!JSON.stringify(imageProfilePayload).includes("test-image-profile-secret"), "图片 profile API 不应返回图片明文 secret"); + + const switchImageProfileResponse = await fetch( + `http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/image-profiles/switch`, + { + method: "POST", + headers: { ...adminHeaders, "content-type": "application/json" }, + body: JSON.stringify({ profile: "image-primary" }), + }, + ); + assert(switchImageProfileResponse.status === 200, `独立图片 profile 热切换失败: ${switchImageProfileResponse.status}`); + const switchedStatusResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`, { headers: adminHeaders }); + const switchedStatusPayload = await switchedStatusResponse.json(); + assert(switchedStatusPayload?.config?.profile_name === "legacy-text", "图片切换不应改变文本 profile"); + assert(switchedStatusPayload?.config?.image_profile_name === "image-primary", "图片切换未更新当前图片 profile"); + + const saveActiveTextProfileResponse = 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: "legacy-text", + listen_host: "127.0.0.1", + listen_port: gatewayPort, + upstream_base_url: `http://127.0.0.1:${upstreamPort}`, + auth_mode: "passthrough", + }), + }, + ); + assert(saveActiveTextProfileResponse.status === 200, `当前文本 profile 保存失败: ${saveActiveTextProfileResponse.status}`); + 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"); + assert(afterTextSaveStatusPayload?.config?.image_base_url === `http://127.0.0.1:${imageUpstreamPort}`, "保存文本 profile 不应改写图片上游"); + const modelsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/v1/models`); assert(modelsResponse.status === 200, `/v1/models 透传状态异常: ${modelsResponse.status}`); - assert( - modelsResponse.headers.get("x-upstream-test") === "models-ok", - "/v1/models 未保留上游头", - ); + assert( + modelsResponse.headers.get("x-upstream-test") === "models-ok", + "/v1/models 未保留上游头", + ); + + const imageResponse = await fetch(`http://127.0.0.1:${gatewayPort}/v1/images/generations`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer normal-upstream-key", + }, + body: JSON.stringify({ model: "gpt-image-1", prompt: "route image test" }), + }); + assert(imageResponse.status === 200, `/v1/images/generations 状态异常: ${imageResponse.status}`); + assert( + imageResponse.headers.get("x-upstream-test") === "images-images", + "/v1/images/generations 未命中独立图片上游", + ); + const imageBody = await imageResponse.json(); + assert(imageBody?.upstream === "images", "/v1/images/generations 未使用图片 base_url"); + assert( + imageBody?.authorization === "Bearer test-image-profile-secret", + "/v1/images/generations 未使用独立图片 API key", + ); + + const rootImageResponse = await fetch(`http://127.0.0.1:${gatewayPort}/images/edits`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer normal-upstream-key", + }, + body: JSON.stringify({ image: "fake-image", prompt: "route root image test" }), + }); + assert(rootImageResponse.status === 200, `/images/edits 状态异常: ${rootImageResponse.status}`); + const rootImageBody = await rootImageResponse.json(); + assert(rootImageBody?.upstream === "images", "/images/edits 未命中独立图片上游"); + assert(rootImageBody?.path === "/v1/images/edits", "/images/edits 未规范化到上游 /v1/images/edits"); + assert( + rootImageBody?.authorization === "Bearer test-image-profile-secret", + "/images/edits 未使用独立图片 API key", + ); + + const multipartBoundary = "----codex-retry-gateway-e2e-boundary"; + const multipartBody = Buffer.from( + [ + `--${multipartBoundary}`, + 'Content-Disposition: form-data; name="image"; filename="test.png"', + "Content-Type: image/png", + "", + "not-a-real-image", + `--${multipartBoundary}`, + 'Content-Disposition: form-data; name="prompt"', + "", + "multipart route test", + `--${multipartBoundary}--`, + "", + ].join("\r\n"), + "utf8", + ); + const multipartImageResponse = await fetch(`http://127.0.0.1:${gatewayPort}/images/edits`, { + method: "POST", + headers: { + "content-type": `multipart/form-data; boundary=${multipartBoundary}`, + "content-length": `${multipartBody.length}`, + authorization: "Bearer normal-upstream-key", + }, + body: multipartBody, + }); + assert(multipartImageResponse.status === 200, `/images/edits multipart 状态异常: ${multipartImageResponse.status}`); + const multipartImageBody = await multipartImageResponse.json(); + assert(multipartImageBody?.upstream === "images", "/images/edits multipart 未命中独立图片上游"); + assert(multipartImageBody?.path === "/v1/images/edits", "/images/edits multipart 未规范化到上游 /v1/images/edits"); + assert( + multipartImageBody?.content_type === `multipart/form-data; boundary=${multipartBoundary}`, + "/images/edits multipart 未保留 content-type boundary", + ); + assert(multipartImageBody?.request === multipartBody.toString("utf8"), "/images/edits multipart 请求体被改写"); + + const imageRequestsResponse = await fetch( + `http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent("/images/edits")}`, + { headers: adminHeaders }, + ); + const imageRequestsPayload = await imageRequestsResponse.json(); + const multipartImageEntry = (imageRequestsPayload.entries || []).find( + (entry) => entry.path === "/images/edits" && entry.request_body_bytes === multipartBody.length, + ); + assert(imageRequestsResponse.status === 200, `图片请求历史 API 状态异常: ${imageRequestsResponse.status}`); + assert(multipartImageEntry?.upstream?.route === "images", "图片请求记录未保留 images 分流标识"); + assert((multipartImageEntry?.response_bytes_received || 0) > 0, "图片请求记录未累计响应字节数"); for (const responsePath of ["/responses", "/v1/responses"]) { const blockedResponse = await fetch(`http://127.0.0.1:${gatewayPort}${responsePath}`, { @@ -604,6 +898,154 @@ async function run() { "/responses 2070 返回体不正确", ); + const toggleThreadId = "thread_rule_toggle"; + const toggleBlockedResponse = await fetch(`http://127.0.0.1:${gatewayPort}/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ thread_id: toggleThreadId, test_reasoning_tokens: 516 }), + }); + const toggleBlockedBody = await toggleBlockedResponse.json(); + assert(toggleBlockedResponse.status === 502, `默认 thread 拦截未命中 516: ${toggleBlockedResponse.status}`); + assert(toggleBlockedBody?.error?.code === "reasoning_guard_triggered", "默认 thread 拦截返回体异常"); + + const disableThreadRuleResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/thread-rules`, { + method: "POST", + headers: { + ...adminHeaders, + "content-type": "application/json", + }, + body: JSON.stringify({ + thread_id: toggleThreadId, + reasoning_intercept_enabled: false, + }), + }); + const disableThreadRulePayload = await disableThreadRuleResponse.json(); + assert(disableThreadRuleResponse.status === 200, `关闭 thread 拦截失败: ${disableThreadRuleResponse.status}`); + assert( + (disableThreadRulePayload?.rules || []).some((rule) => rule.thread_id === toggleThreadId && rule.reasoning_intercept_enabled === false), + "关闭 thread 拦截后规则列表未更新", + ); + + const listedThreadRulesResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/thread-rules`, { + headers: adminHeaders, + }); + const listedThreadRulesPayload = await listedThreadRulesResponse.json(); + assert(listedThreadRulesResponse.status === 200, `thread rules API 读取失败: ${listedThreadRulesResponse.status}`); + assert( + (listedThreadRulesPayload?.rules || []).some((rule) => rule.thread_id === toggleThreadId && rule.reasoning_intercept_enabled === false), + "thread rules API 未返回关闭中的 thread", + ); + + for (const responsePath of ["/responses", "/v1/responses"]) { + const bypassedResponse = await fetch(`http://127.0.0.1:${gatewayPort}${responsePath}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ thread_id: toggleThreadId, test_reasoning_tokens: 516 }), + }); + const bypassedBody = await bypassedResponse.json(); + assert(bypassedResponse.status === 200, `${responsePath} 关闭 thread 拦截后未透传: ${bypassedResponse.status}`); + assert( + bypassedBody?.usage?.output_tokens_details?.reasoning_tokens === 516, + `${responsePath} 关闭 thread 拦截后 reasoning_tokens 异常`, + ); + } + + const disabledRetryKey = reasoningRetryKeyForRequest("/responses", { + stream: false, + thread_id: toggleThreadId, + test_reasoning_retry_key: "thread-disabled-retry", + }); + const disabledRetryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + thread_id: toggleThreadId, + test_reasoning_before_success_times: 1, + test_reasoning_retry_key: "thread-disabled-retry", + }), + }); + const disabledRetryBody = await disabledRetryResponse.json(); + assert(disabledRetryResponse.status === 200, `关闭 thread 拦截后 responses 请求不应失败: ${disabledRetryResponse.status}`); + assert( + disabledRetryBody?.usage?.output_tokens_details?.reasoning_tokens === 516, + "关闭 thread 拦截后 responses 请求不应继续重打到 128", + ); + assert( + disabledRetryResponse.headers.get("x-upstream-reasoning-attempt") === "1", + "关闭 thread 拦截后 responses 请求不应继续发起第二次请求", + ); + const disabledRetryStats = upstream.getReasoningRetryStat(disabledRetryKey); + assert(disabledRetryStats.totalRequests === 1, "关闭 thread 拦截后 responses 请求仍触发了多轮重打"); + const disabledThreadRequestsResponse = await fetch( + `http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent(toggleThreadId)}`, + { headers: adminHeaders }, + ); + const disabledThreadRequestsPayload = await disabledThreadRequestsResponse.json(); + const disabledThreadEntry = (disabledThreadRequestsPayload?.entries || []).find((entry) => { + return entry.thread_id === toggleThreadId && entry.reasoning_tokens === 516 && entry.status_code === 200; + }); + assert(disabledThreadEntry?.reasoning_guard_enabled === false, "关闭 thread 拦截后请求记录未标记 reasoning_guard_enabled=false"); + assert(disabledThreadEntry?.reasoning_guard_thread_override === "disabled", "关闭 thread 拦截后请求记录未标记 override=disabled"); + assert(disabledThreadEntry?.reasoning_retry_thread_mode === "thread_guard_disabled", "关闭 thread 拦截后请求记录未标记 thread_guard_disabled"); + assert(disabledThreadEntry?.reasoning_retry_query_count === 0, "关闭 thread 拦截后请求记录不应累计重打 query"); + + const otherThreadBlockedResponse = await fetch(`http://127.0.0.1:${gatewayPort}/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ thread_id: "thread_rule_other", test_reasoning_tokens: 516 }), + }); + const otherThreadBlockedBody = await otherThreadBlockedResponse.json(); + assert(otherThreadBlockedResponse.status === 502, "关闭单个 thread 拦截后不应影响其他 thread"); + assert(otherThreadBlockedBody?.error?.code === "reasoning_guard_triggered", "其他 thread 的默认拦截返回体异常"); + + const enableThreadRuleResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/thread-rules`, { + method: "POST", + headers: { + ...adminHeaders, + "content-type": "application/json", + }, + body: JSON.stringify({ + thread_id: toggleThreadId, + reasoning_intercept_enabled: true, + }), + }); + const enableThreadRulePayload = await enableThreadRuleResponse.json(); + assert(enableThreadRuleResponse.status === 200, `显式开启 thread 拦截失败: ${enableThreadRuleResponse.status}`); + assert( + (enableThreadRulePayload?.rules || []).some((rule) => rule.thread_id === toggleThreadId && rule.reasoning_intercept_enabled === true), + "显式开启 thread 拦截后规则列表未更新", + ); + const reblockedThreadResponse = await fetch(`http://127.0.0.1:${gatewayPort}/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ thread_id: toggleThreadId, test_reasoning_tokens: 516 }), + }); + const reblockedThreadBody = await reblockedThreadResponse.json(); + assert(reblockedThreadResponse.status === 502, "显式开启 thread 拦截后应重新拦截 516"); + assert(reblockedThreadBody?.error?.code === "reasoning_guard_triggered", "显式开启 thread 拦截后的返回体异常"); + + const restoreThreadRuleResponse = await fetch( + `http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/thread-rules/${encodeURIComponent(toggleThreadId)}`, + { + method: "DELETE", + headers: adminHeaders, + }, + ); + const restoreThreadRulePayload = await restoreThreadRuleResponse.json(); + assert(restoreThreadRuleResponse.status === 200, `恢复默认 thread 拦截失败: ${restoreThreadRuleResponse.status}`); + assert( + !(restoreThreadRulePayload?.rules || []).some((rule) => rule.thread_id === toggleThreadId), + "恢复默认 thread 拦截后规则仍存在", + ); + const restoredThreadResponse = await fetch(`http://127.0.0.1:${gatewayPort}/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ thread_id: toggleThreadId, test_reasoning_tokens: 516 }), + }); + const restoredThreadBody = await restoredThreadResponse.json(); + assert(restoredThreadResponse.status === 502, "恢复默认 thread 拦截后应回到默认拦截"); + assert(restoredThreadBody?.error?.code === "reasoning_guard_triggered", "恢复默认 thread 拦截后的返回体异常"); + const missingThreadRetryKey = reasoningRetryKeyForRequest("/responses", { stream: false, test_reasoning_retry_key: "missing-thread-fallback", @@ -1143,6 +1585,64 @@ async function run() { } } + let previewResolved = false; + const previewFetchPromise = fetch(`http://127.0.0.1:${gatewayPort}/responses`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + stream: true, + thread_id: "thread-preview", + test_reasoning_tokens: 128, + test_stream_include_lifecycle: true, + test_stream_lifecycle_marker: "preview-marker", + test_stream_delta_chunks: 4, + test_stream_chunk_delay_ms: 320, + }), + }).then((response) => { + previewResolved = true; + return response; + }); + await new Promise((resolve) => setTimeout(resolve, 1350)); + assert(previewResolved, "responses thread 重打预览未在首个上游 chunk 后尽早返回头部"); + const previewResponse = await previewFetchPromise; + assert(previewResponse.status === 200, `responses thread 重打预览状态异常: ${previewResponse.status}`); + const previewReader = previewResponse.body.getReader(); + const previewDecoder = new TextDecoder(); + const firstPreviewRead = await previewReader.read(); + const firstPreviewText = previewDecoder.decode(firstPreviewRead.value || new Uint8Array(), { stream: true }); + assert(firstPreviewText.includes('"type":"response.created"'), "responses thread 重打预览未先发 response.created"); + let previewText = firstPreviewText; + while (true) { + const { done, value } = await previewReader.read(); + if (done) { + break; + } + previewText += previewDecoder.decode(value, { stream: true }); + } + previewText += previewDecoder.decode(); + assert(previewText.includes('"delta":"hello-1"'), "responses thread 重打预览未回放最终 delta"); + assert(!previewText.includes("preview-marker"), "responses thread 重打预览不应透传巨大的 lifecycle 原始 payload"); + + const normalizedLifecycleStream = await readSseUntilClose( + `http://127.0.0.1:${gatewayPort}/responses`, + { + stream: true, + test_reasoning_tokens: 128, + test_stream_include_lifecycle: true, + test_stream_lifecycle_marker: "normalization-marker", + test_stream_delta_chunks: 2, + }, + ); + assert(normalizedLifecycleStream.status === 200, `/responses lifecycle 归一化状态异常: ${normalizedLifecycleStream.status}`); + assert( + normalizedLifecycleStream.text.includes('"type":"response.completed"'), + "/responses lifecycle 归一化未保留 response.completed", + ); + assert( + !normalizedLifecycleStream.text.includes("normalization-marker"), + "/responses lifecycle 归一化仍透传了巨大的 lifecycle 原始 payload", + ); + const terminatedStream = await readSseUntilClose( `http://127.0.0.1:${gatewayPort}/responses`, { stream: true, test_force_terminate: true }, @@ -1158,7 +1658,7 @@ async function run() { gateway.child.kill(); await once(gateway.child, "exit"); - gateway = startGateway(configPath, logPath); + gateway = startGateway(configPath, logPath, gatewayEnvironment); await waitForHealth(`http://127.0.0.1:${gatewayPort}${config.health_path}`); const metricsAfterRestartResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`, { headers: adminHeaders }); @@ -1175,7 +1675,7 @@ async function run() { await writeFile(configPath, JSON.stringify(config, null, 2), "utf8"); gateway.child.kill(); await once(gateway.child, "exit"); - gateway = startGateway(configPath, logPath); + gateway = startGateway(configPath, logPath, gatewayEnvironment); await waitForHealth(`http://127.0.0.1:${gatewayPort}${config.health_path}`); const escapedNewlineCapacityResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, { @@ -1204,7 +1704,9 @@ async function run() { } finally { gateway.child.kill(); upstream.close(); - await once(upstream, "close"); + imageUpstream.close(); + await once(upstream, "close"); + await once(imageUpstream, "close"); await rm(tempRoot, { recursive: true, force: true }); } } diff --git a/ui-src/src/App.tsx b/ui-src/src/App.tsx index f04bb39..f447934 100644 --- a/ui-src/src/App.tsx +++ b/ui-src/src/App.tsx @@ -3,9 +3,11 @@ import { FormEvent, startTransition, useEffect, useState } from "react"; type PageKey = "overview" | "requests" | "profiles" | "rules" | "logs"; type Tone = "" | "success" | "error"; type ReasoningMatchMode = "formula_518n_minus_2" | "manual"; +type AuthMode = "passthrough" | "fixed_bearer" | "manual_bearer" | "auth_json"; type GatewayConfig = { profile_name?: string; + image_profile_name?: string; listen_host?: string; listen_port?: number; management_access_key?: string; @@ -14,6 +16,10 @@ type GatewayConfig = { upstream_auth_mode?: string; upstream_auth_env?: string | null; upstream_auth_json_key?: string | null; + image_base_url?: string; + image_auth_mode?: string; + image_auth_env?: string | null; + image_auth_json_key?: string | null; request_history_limit?: number; model_remap?: string; endpoints?: string[]; @@ -59,7 +65,9 @@ type StatusPayload = { paths?: { config_path?: string; profiles_dir?: string; + image_profiles_dir?: string; log_path?: string; + thread_rules_path?: string; }; metrics?: Metrics; }; @@ -98,6 +106,8 @@ type RequestEntry = { stream_chunk_count?: number | null; usage_last_updated_at?: string | null; upstream_attempt_count?: number | null; + reasoning_guard_enabled?: boolean; + reasoning_guard_thread_override?: string | null; reasoning_retry_enabled?: boolean; reasoning_retry_query_count?: number | null; reasoning_retry_round_count?: number | null; @@ -128,6 +138,7 @@ type RequestEntry = { upstream?: { origin?: string; path?: string; + route?: "default" | "images" | string; auth_mode?: string; auth_source?: string; authorization_configured?: boolean; @@ -145,6 +156,21 @@ type RequestsPayload = { entries?: RequestEntry[]; }; +type ThreadRuleEntry = { + thread_id: string; + reasoning_intercept_enabled: boolean; + updated_at?: string | null; +}; + +type ThreadRulesPayload = { + ok: boolean; + thread_rules_path?: string; + rules?: ThreadRuleEntry[]; + saved_rule?: ThreadRuleEntry | null; + removed_rule?: ThreadRuleEntry | null; + message?: string; +}; + type ProfileFormModel = { listen_host?: string; listen_port?: string; @@ -185,6 +211,29 @@ type Profile = { form?: ProfileFormModel; }; +type ImageProfileFormModel = { + base_url?: string; + auth_mode?: string; + auth_env?: string; + auth_file?: string; + manual_secret_file?: string; + manual_secret_configured?: boolean; + auth_json_path?: string; + auth_json_key?: string; +}; + +type ImageProfile = { + name: string; + active: boolean; + file_path?: string; + summary?: { + base_url?: string; + auth_mode?: string; + auth_source?: string; + }; + form?: ImageProfileFormModel; +}; + type ProfilesPayload = { profiles_dir?: string; active_profile?: string; @@ -197,6 +246,17 @@ type ProfilesPayload = { profiles?: Profile[]; }; +type ImageProfilesPayload = { + image_profiles_dir?: string; + active_image_profile?: string; + applied_image_profile?: { + image_profile?: string; + hot_swapped?: boolean; + image_base_url?: string; + } | null; + image_profiles?: ImageProfile[]; +}; + type LogEntry = { seq: number; at?: string; @@ -219,7 +279,7 @@ type ProfileFormState = { listen_host: string; listen_port: string; upstream_base_url: string; - auth_mode: "passthrough" | "fixed_bearer" | "manual_bearer" | "auth_json"; + auth_mode: AuthMode; auth_env: string; auth_file: string; manual_secret: string; @@ -238,6 +298,19 @@ type ProfileFormState = { endpoints: string; }; +type ImageProfileFormState = { + name: string; + base_url: string; + auth_mode: AuthMode; + auth_env: string; + auth_file: string; + manual_secret: string; + manual_secret_file: string; + manual_secret_configured: boolean; + auth_json_path: string; + auth_json_key: string; +}; + type RuleFormState = { reasoning_match_mode: ReasoningMatchMode; reasoning_equals: string; @@ -255,9 +328,13 @@ const api = { config: "/__codex_retry_gateway/api/config", logs: "/__codex_retry_gateway/api/logs", requests: "/__codex_retry_gateway/api/requests", + threadRules: "/__codex_retry_gateway/api/thread-rules", profiles: "/__codex_retry_gateway/api/profiles", profileProbe: "/__codex_retry_gateway/api/profiles/probe", profileSwitch: "/__codex_retry_gateway/api/profiles/switch", + imageProfiles: "/__codex_retry_gateway/api/image-profiles", + imageProfileProbe: "/__codex_retry_gateway/api/image-profiles/probe", + imageProfileSwitch: "/__codex_retry_gateway/api/image-profiles/switch", restore: "/__codex_retry_gateway/api/restore", }; @@ -280,7 +357,9 @@ const zhTimestampFormatter = new Intl.DateTimeFormat("zh-CN", { type ProfileProbePayload = { ok: boolean; profile?: string; + image_profile?: string; upstream_base_url?: string; + image_base_url?: string; auth_mode?: string; auth_source?: string; authorization_configured?: boolean; @@ -309,7 +388,7 @@ const pageCopy: Record = { success: "成功", missing_thread_id: "缺少 thread_id", + thread_guard_disabled: "thread 已关闭拦截", completed_without_retry: "未触发调度", reasoning_guard: "reasoning 命中", retryable_upstream_error: "上游可重试错误", @@ -509,6 +602,9 @@ function formatRetryThreadMode(value?: string | null) { if (!mode || mode === "disabled") { return "未启用"; } + if (mode === "thread_guard_disabled") { + return "thread 已关闭拦截"; + } if (mode === "thread_id") { return "按 thread_id"; } @@ -528,6 +624,28 @@ function sortedReasoningCounts(value?: Record | null) { }); } +function formatThreadGuardOverride(value?: string | null) { + const override = `${value || ""}`.trim(); + if (override === "disabled") { + return "已关闭"; + } + if (override === "enabled") { + return "强制开启"; + } + return "默认开启"; +} + +function threadGuardBadgeTone(value?: string | null) { + const override = `${value || ""}`.trim(); + if (override === "disabled") { + return "warn"; + } + if (override === "enabled") { + return "success"; + } + return ""; +} + function splitList(value: string) { return value .split(/[\s,]+/) @@ -638,6 +756,35 @@ function profileFormFromProfile(profile: Profile): ProfileFormState { }; } +function imageProfileFormFromStatus(status: StatusPayload | null): ImageProfileFormState { + const config = status?.config || {}; + return { + ...defaultImageProfileForm, + name: config.image_profile_name || "", + base_url: config.image_base_url || "", + auth_mode: (config.image_auth_mode as AuthMode) || defaultImageProfileForm.auth_mode, + auth_env: config.image_auth_env || defaultImageProfileForm.auth_env, + auth_json_key: config.image_auth_json_key || defaultImageProfileForm.auth_json_key, + }; +} + +function imageProfileFormFromProfile(profile: ImageProfile): ImageProfileFormState { + const form = profile.form || {}; + return { + ...defaultImageProfileForm, + name: profile.name, + base_url: form.base_url || "", + auth_mode: (form.auth_mode as AuthMode) || defaultImageProfileForm.auth_mode, + auth_env: form.auth_env || defaultImageProfileForm.auth_env, + auth_file: form.auth_file || "", + manual_secret: "", + manual_secret_file: form.manual_secret_file || "", + manual_secret_configured: Boolean(form.manual_secret_configured), + auth_json_path: form.auth_json_path || "", + auth_json_key: form.auth_json_key || defaultImageProfileForm.auth_json_key, + }; +} + function ruleFormFromStatus(status: StatusPayload | null): RuleFormState { const config = status?.config || {}; return { @@ -681,8 +828,12 @@ export default function App() { const [requests, setRequests] = useState([]); const [requestsTotal, setRequestsTotal] = useState(0); const [requestsMeta, setRequestsMeta] = useState("正在读取请求记录..."); + const [threadRules, setThreadRules] = useState([]); + const [threadRulesMeta, setThreadRulesMeta] = useState("正在读取 thread 规则..."); const [profiles, setProfiles] = useState([]); const [profilesMeta, setProfilesMeta] = useState("正在读取 profiles..."); + const [imageProfiles, setImageProfiles] = useState([]); + const [imageProfilesMeta, setImageProfilesMeta] = useState("正在读取图片 profiles..."); const [logs, setLogs] = useState("正在读取日志..."); const [logsMeta, setLogsMeta] = useState("正在读取日志..."); const [latestLogSeq, setLatestLogSeq] = useState(0); @@ -691,14 +842,23 @@ export default function App() { const [requestLimit, setRequestLimit] = useState(REQUEST_PAGE_SIZE); const [ruleForm, setRuleForm] = useState(ruleFormFromStatus(null)); const [profileForm, setProfileForm] = useState(defaultProfileForm); + const [imageProfileForm, setImageProfileForm] = useState(defaultImageProfileForm); const [ruleMessage, setRuleMessage] = useState({ text: "", tone: "" }); + const [threadRuleMessage, setThreadRuleMessage] = useState({ text: "", tone: "" }); const [profileMessage, setProfileMessage] = useState({ text: "", tone: "" }); const [profileProbeResult, setProfileProbeResult] = useState(null); + const [imageProfileMessage, setImageProfileMessage] = useState({ text: "", tone: "" }); + const [imageProfileProbeResult, setImageProfileProbeResult] = useState(null); const [probingProfile, setProbingProfile] = useState(""); const [deletingProfile, setDeletingProfile] = useState(""); const [switchingTo, setSwitchingTo] = useState(""); + const [probingImageProfile, setProbingImageProfile] = useState(""); + const [deletingImageProfile, setDeletingImageProfile] = useState(""); + const [switchingImageTo, setSwitchingImageTo] = useState(""); const [restoreRequested, setRestoreRequested] = useState(false); const [shouldSyncProfileFormToActive, setShouldSyncProfileFormToActive] = useState(true); + const [shouldSyncImageProfileFormToActive, setShouldSyncImageProfileFormToActive] = useState(true); + const [threadRuleBusyThreadId, setThreadRuleBusyThreadId] = useState(""); const metrics = status?.metrics || {}; const tokens = metrics.token_totals || {}; @@ -717,6 +877,7 @@ export default function App() { if (refreshRuleForm) { setRuleForm(ruleFormFromStatus(payload)); setProfileForm(profileFormFromStatus(payload)); + setImageProfileForm(imageProfileFormFromStatus(payload)); } }); } @@ -741,12 +902,27 @@ export default function App() { }); } + async function loadThreadRules() { + const payload = await fetchJson(api.threadRules); + const rules = payload.rules || []; + startTransition(() => { + setThreadRules(rules); + setThreadRulesMeta(`当前覆盖 ${rules.length} 个 thread;文件:${payload.thread_rules_path || "-"}`); + }); + } + async function loadProfiles() { - const payload = await fetchJson(api.profiles); + const [payload, imagePayload] = await Promise.all([ + fetchJson(api.profiles), + fetchJson(api.imageProfiles), + ]); const items = payload.profiles || []; + const imageItems = imagePayload.image_profiles || []; startTransition(() => { setProfiles(items); setProfilesMeta(`目录:${payload.profiles_dir || "-"};当前运行:${payload.active_profile || "-"}。`); + setImageProfiles(imageItems); + setImageProfilesMeta(`目录:${imagePayload.image_profiles_dir || "-"};当前运行:${imagePayload.active_image_profile || "未配置"}。`); }); } @@ -780,6 +956,10 @@ export default function App() { await loadRequests(); return; } + if (targetPage === "rules") { + await loadThreadRules(); + return; + } if (targetPage === "profiles") { await loadProfiles(); return; @@ -790,11 +970,11 @@ export default function App() { } async function refreshLiveData(targetPage: PageKey = page) { - if (restoreRequested || switchingTo) { + if (restoreRequested || switchingTo || switchingImageTo) { return; } const tasks: Array> = [loadStatus(false)]; - if (targetPage !== "overview" && targetPage !== "rules") { + if (targetPage !== "overview") { tasks.push(loadPageData(targetPage, { incrementalLogs: true })); } await Promise.all(tasks); @@ -806,7 +986,8 @@ export default function App() { useEffect(() => { if (page === "profiles") { - setShouldSyncProfileFormToActive(true); + setShouldSyncProfileFormToActive(true); + setShouldSyncImageProfileFormToActive(true); } }, [page]); @@ -834,14 +1015,18 @@ export default function App() { }); }, 10000); return () => window.clearInterval(timer); - }, [restoreRequested, switchingTo]); + }, [restoreRequested, switchingTo, switchingImageTo]); useEffect(() => { - if (page !== "requests" && page !== "logs") { + if (page !== "requests" && page !== "logs" && page !== "rules") { return; } const timer = window.setInterval(() => { loadPageData(page, { incrementalLogs: true }).catch((error) => { + if (page === "rules") { + setThreadRuleMessage({ text: error?.message || String(error), tone: "error" }); + return; + } setRuleMessage({ text: error?.message || String(error), tone: "error" }); }); }, 2500); @@ -849,10 +1034,14 @@ export default function App() { }, [page, requestQuery, requestFilter, latestLogSeq, requestLimit]); useEffect(() => { - if (page === "overview" || page === "rules" || page === "requests") { + if (page === "overview" || page === "requests") { return; } loadPageData(page, { incrementalLogs: false }).catch((error) => { + if (page === "rules") { + setThreadRuleMessage({ text: error?.message || String(error), tone: "error" }); + return; + } setRuleMessage({ text: error?.message || String(error), tone: "error" }); }); }, [page]); @@ -870,6 +1059,19 @@ export default function App() { setShouldSyncProfileFormToActive(false); }, [page, profiles, shouldSyncProfileFormToActive]); + useEffect(() => { + if (page !== "profiles" || !shouldSyncImageProfileFormToActive) { + return; + } + const activeProfile = imageProfiles.find((profile) => profile.active); + if (!activeProfile) { + return; + } + setImageProfileForm(imageProfileFormFromProfile(activeProfile)); + setImageProfileMessage({ text: "", tone: "" }); + setShouldSyncImageProfileFormToActive(false); + }, [page, imageProfiles, shouldSyncImageProfileFormToActive]); + async function saveRules(event: FormEvent) { event.preventDefault(); setRuleMessage({ text: "正在保存配置...", tone: "" }); @@ -901,6 +1103,59 @@ export default function App() { } } + async function updateThreadRule(threadId: string, reasoningInterceptEnabled: boolean) { + if (!threadId.trim()) { + return; + } + setThreadRuleBusyThreadId(threadId); + setThreadRuleMessage({ + text: reasoningInterceptEnabled ? `正在为 ${threadId} 开启拦截...` : `正在为 ${threadId} 关闭拦截...`, + tone: "", + }); + try { + const payload = await fetchJson(api.threadRules, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + thread_id: threadId, + reasoning_intercept_enabled: reasoningInterceptEnabled, + }), + }); + setThreadRules(payload.rules || []); + setThreadRulesMeta(`当前覆盖 ${(payload.rules || []).length} 个 thread;文件:${payload.thread_rules_path || "-"}`); + setThreadRuleMessage({ + text: payload.message || (reasoningInterceptEnabled ? "thread 已开启拦截" : "thread 已关闭拦截"), + tone: "success", + }); + await Promise.all([loadStatus(false), loadRequests(requestLimit), loadThreadRules()]); + } catch (error) { + setThreadRuleMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" }); + } finally { + setThreadRuleBusyThreadId(""); + } + } + + async function restoreThreadRule(threadId: string) { + if (!threadId.trim()) { + return; + } + setThreadRuleBusyThreadId(threadId); + setThreadRuleMessage({ text: `正在恢复 ${threadId} 的默认策略...`, tone: "" }); + try { + const payload = await fetchJson(`${api.threadRules}/${encodeURIComponent(threadId)}`, { + method: "DELETE", + }); + setThreadRules(payload.rules || []); + setThreadRulesMeta(`当前覆盖 ${(payload.rules || []).length} 个 thread;文件:${payload.thread_rules_path || "-"}`); + setThreadRuleMessage({ text: payload.message || "thread 已恢复默认拦截策略", tone: "success" }); + await Promise.all([loadStatus(false), loadRequests(requestLimit), loadThreadRules()]); + } catch (error) { + setThreadRuleMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" }); + } finally { + setThreadRuleBusyThreadId(""); + } + } + useEffect(() => { setRequestLimit(REQUEST_PAGE_SIZE); }, [requestQuery, requestFilter]); @@ -950,7 +1205,11 @@ export default function App() { }); setProfiles(payload.profiles || []); setProfilesMeta(`目录:${payload.profiles_dir || "-"};当前运行:${payload.active_profile || "-"}。`); - setProfileForm((current) => ({ ...current, manual_secret: "", manual_secret_configured: current.auth_mode === "manual_bearer" })); + setProfileForm((current) => ({ + ...current, + manual_secret: "", + manual_secret_configured: current.auth_mode === "manual_bearer", + })); setProfileMessage({ text: payload.applied_profile ? "profile 已保存,并已对当前运行实例后台热应用。" : "profile 已保存。需要运行它时,点击左侧卡片里的“切换”。", tone: "success", @@ -1038,6 +1297,118 @@ export default function App() { } } + async function saveImageProfile(event: FormEvent) { + event.preventDefault(); + setImageProfileMessage({ text: "正在保存图片 profile...", tone: "" }); + try { + const payload = await fetchJson(api.imageProfiles, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: imageProfileForm.name, + base_url: imageProfileForm.base_url, + auth_mode: imageProfileForm.auth_mode, + auth_env: imageProfileForm.auth_env, + auth_file: imageProfileForm.auth_file, + manual_secret: imageProfileForm.manual_secret, + manual_secret_file: imageProfileForm.manual_secret_file, + manual_secret_configured: imageProfileForm.manual_secret_configured, + auth_json_path: imageProfileForm.auth_json_path, + auth_json_key: imageProfileForm.auth_json_key, + }), + }); + setImageProfiles(payload.image_profiles || []); + setImageProfilesMeta(`目录:${payload.image_profiles_dir || "-"};当前运行:${payload.active_image_profile || "未配置"}。`); + setImageProfileForm((current) => ({ + ...current, + manual_secret: "", + manual_secret_configured: current.auth_mode === "manual_bearer", + })); + setImageProfileMessage({ + text: payload.applied_image_profile + ? "图片 profile 已保存,并已对当前运行实例后台热应用。" + : "图片 profile 已保存。需要运行它时,点击左侧卡片里的“切换”。", + tone: "success", + }); + } catch (error) { + setImageProfileMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" }); + } + } + + function editImageProfile(profile: ImageProfile) { + setShouldSyncImageProfileFormToActive(false); + setImageProfileForm(imageProfileFormFromProfile(profile)); + setImageProfileMessage({ text: "编辑后保存图片 profile env;如果保存的是当前运行 profile,后端会直接后台热应用。", tone: "" }); + } + + async function switchImageProfile(profile: ImageProfile) { + if (profile.active) { + return; + } + if (!window.confirm(`切换到图片 profile "${profile.name}" 会立即改写 /images/* 的上游,不重启 gateway。确定继续吗?`)) { + return; + } + setSwitchingImageTo(profile.name); + try { + await fetchJson(api.imageProfileSwitch, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ profile: profile.name }), + }); + waitForImageProfile(profile.name); + } catch (error) { + setSwitchingImageTo(""); + setImageProfileMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" }); + } + } + + async function probeImageProfile(profile: ImageProfile) { + setProbingImageProfile(profile.name); + setImageProfileMessage({ text: `正在探测图片 profile ${profile.name}...`, tone: "" }); + try { + const payload = await fetchJson(api.imageProfileProbe, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ profile: profile.name }), + }); + setImageProfileProbeResult(payload); + setImageProfileMessage({ text: `图片 profile ${profile.name} 探针已完成,不影响当前运行实例。`, tone: "success" }); + } catch (error) { + setImageProfileMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" }); + } finally { + setProbingImageProfile(""); + } + } + + async function removeImageProfile(profile: ImageProfile) { + if (profile.active) { + setImageProfileMessage({ text: "当前运行的图片 profile 不能直接删除;请先切换到其他图片 profile。", tone: "error" }); + return; + } + if (!window.confirm(`删除图片 profile "${profile.name}" 会移除对应 env 文件。确定继续吗?`)) { + return; + } + setDeletingImageProfile(profile.name); + try { + const payload = await fetchJson(`${api.imageProfiles}/${encodeURIComponent(profile.name)}`, { + method: "DELETE", + }); + setImageProfiles(payload.image_profiles || []); + setImageProfilesMeta(`目录:${payload.image_profiles_dir || "-"};当前运行:${payload.active_image_profile || "未配置"}。`); + if (imageProfileForm.name === profile.name) { + setImageProfileForm(defaultImageProfileForm); + } + if (imageProfileProbeResult?.image_profile === profile.name) { + setImageProfileProbeResult(null); + } + setImageProfileMessage({ text: `图片 profile ${profile.name} 已删除。`, tone: "success" }); + } catch (error) { + setImageProfileMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" }); + } finally { + setDeletingImageProfile(""); + } + } + function waitForProfile(profileName: string) { const deadline = Date.now() + 12000; const tick = async () => { @@ -1062,6 +1433,30 @@ export default function App() { window.setTimeout(tick, 300); } + function waitForImageProfile(profileName: string) { + const deadline = Date.now() + 12000; + const tick = async () => { + if (Date.now() > deadline) { + setSwitchingImageTo(""); + setImageProfileMessage({ text: "图片热切换已提交,但暂时没有看到当前实例切到目标 profile。", tone: "error" }); + return; + } + try { + const payload = await fetchJson(api.status); + if (payload.config?.image_profile_name === profileName) { + setSwitchingImageTo(""); + setShouldSyncImageProfileFormToActive(true); + Promise.all([loadStatus(true), loadProfiles()]).catch(() => {}); + return; + } + } catch { + // keep polling + } + window.setTimeout(tick, 500); + }; + window.setTimeout(tick, 300); + } + async function restoreConfig() { if (!window.confirm("恢复后会关闭当前 gateway,并把 Codex 配置切回原上游。确定继续吗?")) { return; @@ -1105,6 +1500,7 @@ export default function App() { + @@ -1159,8 +1555,11 @@ export default function App() {
@@ -1316,6 +1716,9 @@ export default function App() {
{entry.matched ? matched : pass} {entry.error ? error : null} + + guard {formatThreadGuardOverride(entry.reasoning_guard_thread_override)} + {showReasoningRetry ? ( retry {formatRetryStopReason(entry.reasoning_retry_stop_reason)} @@ -1342,7 +1745,7 @@ export default function App() {
- + {`${upstream.origin || "-"}${upstream.path || ""}`} {upstream.auth_mode || "-"} / {upstream.auth_source || "-"} @@ -1370,12 +1773,45 @@ export default function App() { {`response ${entry.response_id || "-"}`} {`request ${entry.request_id || "-"}`} {`thread ${entry.thread_id || "-"}`} + {entry.thread_id ? ( +
+ + + {entry.reasoning_guard_thread_override && entry.reasoning_guard_thread_override !== "default" ? ( + + ) : null} +
+ ) : null}
{showReasoningRetry ? (
{formatRetryThreadMode(entry.reasoning_retry_thread_mode)} + + guard {formatThreadGuardOverride(entry.reasoning_guard_thread_override)} + schedule 1,1,2,2,4,4... / query {numberFormat(entry.reasoning_retry_query_count || 0)} {" / "} @@ -1433,7 +1869,7 @@ export default function App() {
- 新建 Profile + 新建文本 Profile } > @@ -1478,9 +1914,8 @@ export default function App() {
- - - + + - +
setProfileForm({ ...profileForm, name: event.target.value })} /> @@ -1514,13 +1949,13 @@ export default function App() { setProfileForm({ ...profileForm, listen_port: event.target.value })} />
- + setProfileForm({ ...profileForm, upstream_base_url: event.target.value })} />