feat: separate image profiles from text routing
This commit is contained in:
@@ -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。
|
在 Linux 上需要常驻运行时,推荐用 `scripts/run-profile.mjs` 作为 systemd 的 ExecStart。它会读取 profile env,生成运行时 `config.json`,并把当前 Codex provider 的 `base_url` 指向本机 gateway。
|
||||||
|
|
||||||
profile env 默认放在:
|
文本 profile env 默认放在:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
~/.config/codex-retry-gateway/profiles/<profile>.env
|
~/.config/codex-retry-gateway/profiles/<profile>.env
|
||||||
```
|
```
|
||||||
|
|
||||||
|
图片 profile env 独立放在:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.config/codex-retry-gateway/image-profiles/<profile>.env
|
||||||
|
```
|
||||||
|
|
||||||
|
当前文本 profile 与当前图片 profile 分别由 state 中的 `profile_name`、`image_profile_name` 记录;切换其中任意一方不会改写另一方。首次升级时,当前文本 profile 中已有的 `CODEX_RETRY_GATEWAY_IMAGE_*` 字段会复制到同名图片 profile,旧文本文件会保留作为兼容回退,但后续 UI/TUI 保存文本 profile 不再写入图片字段。
|
||||||
|
|
||||||
常用字段:
|
常用字段:
|
||||||
|
|
||||||
- `CODEX_RETRY_GATEWAY_LISTEN_HOST`
|
- `CODEX_RETRY_GATEWAY_LISTEN_HOST`
|
||||||
@@ -170,12 +178,31 @@ profile env 默认放在:
|
|||||||
- `fixed_bearer`:从环境变量或文件读取 token,并覆盖上游 `Authorization: Bearer ...`
|
- `fixed_bearer`:从环境变量或文件读取 token,并覆盖上游 `Authorization: Bearer ...`
|
||||||
- `auth_json`:从 Codex `auth.json` 的指定 key 读取 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
|
```bash
|
||||||
node ./scripts/run-profile.mjs default
|
node ./scripts/run-profile.mjs default
|
||||||
```
|
```
|
||||||
|
|
||||||
|
指定图片 profile:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node ./scripts/run-profile.mjs default --image-profile images
|
||||||
|
```
|
||||||
|
|
||||||
如果已经由 state 记录了当前活跃 profile,也可以直接不传 profile 参数:
|
如果已经由 state 记录了当前活跃 profile,也可以直接不传 profile 参数:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -269,10 +296,10 @@ gateway 运行时只负责 API 与静态文件服务,不再把复杂 UI 硬写
|
|||||||
- 请求时间戳、首字耗时、总耗时、请求体大小、路径、模型、状态码
|
- 请求时间戳、首字耗时、总耗时、请求体大小、路径、模型、状态码
|
||||||
- 相同重发请求会带相同的 `request_id`
|
- 相同重发请求会带相同的 `request_id`
|
||||||
- `usage` 中的 input / output / total / reasoning tokens
|
- `usage` 中的 input / output / total / reasoning tokens
|
||||||
- 管理 profiles
|
- 分别管理文本 profiles 与图片 profiles
|
||||||
- 新建 / 编辑 profile env
|
- 各自新建 / 编辑 / 探测 / 切换 / 删除 profile env
|
||||||
- 切换 provider `base_url`
|
- 文本切换 provider `base_url` 不会改写图片分流,图片切换只影响 `/images/*` 与 `/v1/images/*`
|
||||||
- 切换 `passthrough` / `manual_bearer` / `fixed_bearer` / `auth_json` 认证模式
|
- 各自切换 `passthrough` / `manual_bearer` / `fixed_bearer` / `auth_json` 认证模式
|
||||||
- 改 `reasoning_equals`
|
- 改 `reasoning_equals`
|
||||||
- 改 capacity error 的匹配状态码和错误文案
|
- 改 capacity error 的匹配状态码和错误文案
|
||||||
- 改 `endpoints`
|
- 改 `endpoints`
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"profile_name": "default",
|
"profile_name": "default",
|
||||||
|
"image_profile_name": "",
|
||||||
"listen_host": "127.0.0.1",
|
"listen_host": "127.0.0.1",
|
||||||
"listen_port": 4610,
|
"listen_port": 4610,
|
||||||
"upstream_base_url": "https://api.openai.com",
|
"upstream_base_url": "https://api.openai.com",
|
||||||
@@ -8,6 +9,12 @@
|
|||||||
"upstream_auth_file": "",
|
"upstream_auth_file": "",
|
||||||
"upstream_auth_json_path": "",
|
"upstream_auth_json_path": "",
|
||||||
"upstream_auth_json_key": "OPENAI_API_KEY",
|
"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_body_limit_bytes": 1073741824,
|
||||||
"request_history_limit": 200,
|
"request_history_limit": 200,
|
||||||
"endpoints": ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
|
"endpoints": ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
|
||||||
|
|||||||
+1145
-74
File diff suppressed because it is too large
Load Diff
+186
-2
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import fs from "node:fs";
|
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 os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
@@ -32,8 +32,17 @@ import {
|
|||||||
} from "./admin-lib.mjs";
|
} from "./admin-lib.mjs";
|
||||||
|
|
||||||
const DEFAULT_PROFILES_DIR = path.join(os.homedir(), ".config", "codex-retry-gateway", "profiles");
|
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 DEFAULT_REQUEST_BODY_LIMIT_BYTES = 1024 * 1024 * 1024;
|
||||||
const LEGACY_DEFAULT_REQUEST_BODY_LIMIT_BYTES = 10 * 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) {
|
function parseEnvFile(content) {
|
||||||
const parsed = {};
|
const parsed = {};
|
||||||
@@ -67,6 +76,10 @@ function getProfileName(options) {
|
|||||||
return `${options.profile || positional || process.env.CODEX_RETRY_GATEWAY_PROFILE || ""}`.trim();
|
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) {
|
function resolveDefaultRequestedProfileName(existingState) {
|
||||||
const stateProfileName = `${existingState?.profile_name || ""}`.trim();
|
const stateProfileName = `${existingState?.profile_name || ""}`.trim();
|
||||||
if (stateProfileName) {
|
if (stateProfileName) {
|
||||||
@@ -93,6 +106,31 @@ function resolvePreferredProfileName({ requestedProfileName, existingState, pref
|
|||||||
return stateProfileName;
|
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) {
|
function boolFromEnv(value, fallback = false) {
|
||||||
if (value === undefined || value === null || value === "") {
|
if (value === undefined || value === null || value === "") {
|
||||||
return fallback;
|
return fallback;
|
||||||
@@ -165,6 +203,58 @@ function buildProfileAuthConfig(profileEnv) {
|
|||||||
return authConfig;
|
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) {
|
async function loadProfileEnv(profileName, profilesDir) {
|
||||||
const profilePath = path.join(profilesDir, `${profileName}.env`);
|
const profilePath = path.join(profilesDir, `${profileName}.env`);
|
||||||
if (!fs.existsSync(profilePath)) {
|
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 =
|
const upstreamBaseUrl =
|
||||||
profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL ||
|
profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL ||
|
||||||
existingGatewayConfig?.upstream_base_url ||
|
existingGatewayConfig?.upstream_base_url ||
|
||||||
@@ -190,6 +341,7 @@ function buildProfileConfig({ profileName, profileEnv, existingGatewayConfig, pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
const profileAuthConfig = buildProfileAuthConfig(profileEnv);
|
const profileAuthConfig = buildProfileAuthConfig(profileEnv);
|
||||||
|
const imageProfileAuthConfig = buildImageProfileAuthConfig(imageProfileEnv || {});
|
||||||
const reasoningMatchMode = normalizeReasoningMatchMode(
|
const reasoningMatchMode = normalizeReasoningMatchMode(
|
||||||
profileEnv.CODEX_RETRY_GATEWAY_REASONING_MATCH_MODE ||
|
profileEnv.CODEX_RETRY_GATEWAY_REASONING_MATCH_MODE ||
|
||||||
existingGatewayConfig?.reasoning_match_mode ||
|
existingGatewayConfig?.reasoning_match_mode ||
|
||||||
@@ -204,6 +356,9 @@ function buildProfileConfig({ profileName, profileEnv, existingGatewayConfig, pr
|
|||||||
: DEFAULT_LISTEN_PORT,
|
: DEFAULT_LISTEN_PORT,
|
||||||
upstream_base_url: upstreamBaseUrl,
|
upstream_base_url: upstreamBaseUrl,
|
||||||
...profileAuthConfig,
|
...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
|
request_body_limit_bytes: profileEnv.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES
|
||||||
? normalizeRequestBodyLimitBytes(profileEnv.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES)
|
? normalizeRequestBodyLimitBytes(profileEnv.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES)
|
||||||
: normalizeRequestBodyLimitBytes(existingGatewayConfig?.request_body_limit_bytes),
|
: normalizeRequestBodyLimitBytes(existingGatewayConfig?.request_body_limit_bytes),
|
||||||
@@ -288,6 +443,7 @@ async function ensureCodexPointsToGateway({ paths, codexConfigPath, providerCont
|
|||||||
async function main() {
|
async function main() {
|
||||||
const options = parseOptions(process.argv, { booleanFlags: ["no-codex-config-update", "prefer-state-profile"] });
|
const options = parseOptions(process.argv, { booleanFlags: ["no-codex-config-update", "prefer-state-profile"] });
|
||||||
const profilesDir = options.profilesDir || DEFAULT_PROFILES_DIR;
|
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 stateRoot = options.stateRoot || process.env.CODEX_RETRY_GATEWAY_STATE_ROOT || DEFAULT_STATE_ROOT;
|
||||||
const codexConfigPath =
|
const codexConfigPath =
|
||||||
options.codexConfigPath ||
|
options.codexConfigPath ||
|
||||||
@@ -315,7 +471,31 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { profilePath, env: profileEnv } = await loadProfileEnv(profileName, profilesDir);
|
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)) {
|
for (const [key, value] of Object.entries(profileEnv)) {
|
||||||
|
if (key.startsWith("CODEX_RETRY_GATEWAY_IMAGE_")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
process.env[key] = value;
|
process.env[key] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,6 +510,8 @@ async function main() {
|
|||||||
const gatewayConfig = buildProfileConfig({
|
const gatewayConfig = buildProfileConfig({
|
||||||
profileName,
|
profileName,
|
||||||
profileEnv,
|
profileEnv,
|
||||||
|
imageProfileName,
|
||||||
|
imageProfileEnv,
|
||||||
existingGatewayConfig,
|
existingGatewayConfig,
|
||||||
providerContext,
|
providerContext,
|
||||||
localGatewayBaseUrl,
|
localGatewayBaseUrl,
|
||||||
@@ -355,6 +537,8 @@ async function main() {
|
|||||||
last_started_at: new Date().toISOString(),
|
last_started_at: new Date().toISOString(),
|
||||||
profile_name: profileName,
|
profile_name: profileName,
|
||||||
profile_env_path: profilePath,
|
profile_env_path: profilePath,
|
||||||
|
image_profile_name: imageProfileName || "",
|
||||||
|
image_profile_env_path: imageProfilePath,
|
||||||
codex_config_path: codexConfigPath,
|
codex_config_path: codexConfigPath,
|
||||||
provider_name: providerContext.providerName,
|
provider_name: providerContext.providerName,
|
||||||
original_base_url: installState.originalBaseUrl,
|
original_base_url: installState.originalBaseUrl,
|
||||||
|
|||||||
+513
-11
@@ -4,7 +4,7 @@ import http from "node:http";
|
|||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import { once } from "node:events";
|
import { once } from "node:events";
|
||||||
import { spawn } from "node:child_process";
|
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 os from "node:os";
|
||||||
import path from "node:path";
|
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 failBeforeResponseCounts = new Map();
|
||||||
const capacityBeforeSuccessCounts = new Map();
|
const capacityBeforeSuccessCounts = new Map();
|
||||||
const reasoningBeforeSuccessCounts = new Map();
|
const reasoningBeforeSuccessCounts = new Map();
|
||||||
@@ -303,14 +303,70 @@ function startFakeUpstream(port) {
|
|||||||
? parsed.test_stream_delta_chunks
|
? parsed.test_stream_delta_chunks
|
||||||
: 1;
|
: 1;
|
||||||
const deltaTextBase = parsed.test_stream_delta_text || "hello";
|
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 streamChunks = Array.from({ length: deltaChunkCount }, (_, index) => {
|
||||||
const deltaText = deltaChunkCount === 1 ? deltaTextBase : `${deltaTextBase}-${index + 1}`;
|
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`;
|
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(
|
if (parsed.test_stream_include_lifecycle) {
|
||||||
`data: {"response":{"usage":{"output_tokens_details":{"reasoning_tokens":${reasoning}}}}}\n\n`,
|
streamChunks.unshift(...lifecycleChunks);
|
||||||
"data: [DONE]\n\n",
|
}
|
||||||
);
|
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, {
|
createSseResponse(res, streamChunks, parsed.test_reasoning_response_delay_ms ?? parsed.test_stream_chunk_delay_ms ?? 20, {
|
||||||
headers: reasoningAttempt
|
headers: reasoningAttempt
|
||||||
? { "x-upstream-reasoning-attempt": `${reasoningAttempt}` }
|
? { "x-upstream-reasoning-attempt": `${reasoningAttempt}` }
|
||||||
@@ -353,6 +409,30 @@ function startFakeUpstream(port) {
|
|||||||
return;
|
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)) {
|
if (req.method === "POST" && chatCompletionPaths.has(req.url)) {
|
||||||
let body = "";
|
let body = "";
|
||||||
req.setEncoding("utf8");
|
req.setEncoding("utf8");
|
||||||
@@ -415,9 +495,10 @@ async function waitForHealth(url, timeoutMs = 5000) {
|
|||||||
throw new Error(`等待网关健康检查超时: ${url}`);
|
throw new Error(`等待网关健康检查超时: ${url}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startGateway(configPath, logPath) {
|
function startGateway(configPath, logPath, environment = {}) {
|
||||||
const child = spawn(process.execPath, [gatewayEntry, "--config", configPath, "--log", logPath], {
|
const child = spawn(process.execPath, [gatewayEntry, "--config", configPath, "--log", logPath], {
|
||||||
cwd: gatewayRoot,
|
cwd: gatewayRoot,
|
||||||
|
env: { ...process.env, ...environment },
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -490,14 +571,21 @@ async function run() {
|
|||||||
|
|
||||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "codex-retry-gateway-"));
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "codex-retry-gateway-"));
|
||||||
const upstreamPort = await getFreePort();
|
const upstreamPort = await getFreePort();
|
||||||
|
const imageUpstreamPort = await getFreePort();
|
||||||
const gatewayPort = await getFreePort();
|
const gatewayPort = await getFreePort();
|
||||||
const configPath = path.join(tempRoot, "config.json");
|
const configPath = path.join(tempRoot, "config.json");
|
||||||
const logPath = path.join(tempRoot, "gateway.log");
|
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 = {
|
const config = {
|
||||||
|
profile_name: "legacy-text",
|
||||||
listen_host: "127.0.0.1",
|
listen_host: "127.0.0.1",
|
||||||
listen_port: gatewayPort,
|
listen_port: gatewayPort,
|
||||||
upstream_base_url: `http://127.0.0.1:${upstreamPort}`,
|
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,
|
request_body_limit_bytes: 1024 * 1024 * 1024,
|
||||||
endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
|
endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
|
||||||
reasoning_match_mode: "formula_518n_minus_2",
|
reasoning_match_mode: "formula_518n_minus_2",
|
||||||
@@ -516,10 +604,26 @@ async function run() {
|
|||||||
health_path: "/__codex_retry_gateway/health",
|
health_path: "/__codex_retry_gateway/health",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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");
|
await writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
|
||||||
|
|
||||||
const upstream = await startFakeUpstream(upstreamPort);
|
const upstream = await startFakeUpstream(upstreamPort, { label: "default" });
|
||||||
let gateway = startGateway(configPath, logPath);
|
const imageUpstream = await startFakeUpstream(imageUpstreamPort, { label: "images" });
|
||||||
|
const gatewayEnvironment = { HOME: tempRoot };
|
||||||
|
let gateway = startGateway(configPath, logPath, gatewayEnvironment);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
@@ -558,6 +662,111 @@ async function run() {
|
|||||||
"status API 未暴露 management_access_key_configured",
|
"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`);
|
const modelsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/v1/models`);
|
||||||
assert(modelsResponse.status === 200, `/v1/models 透传状态异常: ${modelsResponse.status}`);
|
assert(modelsResponse.status === 200, `/v1/models 透传状态异常: ${modelsResponse.status}`);
|
||||||
assert(
|
assert(
|
||||||
@@ -565,6 +774,91 @@ async function run() {
|
|||||||
"/v1/models 未保留上游头",
|
"/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"]) {
|
for (const responsePath of ["/responses", "/v1/responses"]) {
|
||||||
const blockedResponse = await fetch(`http://127.0.0.1:${gatewayPort}${responsePath}`, {
|
const blockedResponse = await fetch(`http://127.0.0.1:${gatewayPort}${responsePath}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -604,6 +898,154 @@ async function run() {
|
|||||||
"/responses 2070 返回体不正确",
|
"/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", {
|
const missingThreadRetryKey = reasoningRetryKeyForRequest("/responses", {
|
||||||
stream: false,
|
stream: false,
|
||||||
test_reasoning_retry_key: "missing-thread-fallback",
|
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(
|
const terminatedStream = await readSseUntilClose(
|
||||||
`http://127.0.0.1:${gatewayPort}/responses`,
|
`http://127.0.0.1:${gatewayPort}/responses`,
|
||||||
{ stream: true, test_force_terminate: true },
|
{ stream: true, test_force_terminate: true },
|
||||||
@@ -1158,7 +1658,7 @@ async function run() {
|
|||||||
|
|
||||||
gateway.child.kill();
|
gateway.child.kill();
|
||||||
await once(gateway.child, "exit");
|
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}`);
|
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 });
|
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");
|
await writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
|
||||||
gateway.child.kill();
|
gateway.child.kill();
|
||||||
await once(gateway.child, "exit");
|
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}`);
|
await waitForHealth(`http://127.0.0.1:${gatewayPort}${config.health_path}`);
|
||||||
|
|
||||||
const escapedNewlineCapacityResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
const escapedNewlineCapacityResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
||||||
@@ -1204,7 +1704,9 @@ async function run() {
|
|||||||
} finally {
|
} finally {
|
||||||
gateway.child.kill();
|
gateway.child.kill();
|
||||||
upstream.close();
|
upstream.close();
|
||||||
|
imageUpstream.close();
|
||||||
await once(upstream, "close");
|
await once(upstream, "close");
|
||||||
|
await once(imageUpstream, "close");
|
||||||
await rm(tempRoot, { recursive: true, force: true });
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+697
-76
File diff suppressed because it is too large
Load Diff
@@ -678,6 +678,17 @@ a {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.thread-rule-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-rule-actions button {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.reasoning-retry-block {
|
.reasoning-retry-block {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
border-color: rgba(22, 107, 92, 0.16);
|
border-color: rgba(22, 107, 92, 0.16);
|
||||||
@@ -718,6 +729,31 @@ a {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rules-layout {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-rule-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-rule-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid rgba(30, 33, 29, 0.08);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 250, 240, 0.74);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-rule-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 960px;
|
min-width: 960px;
|
||||||
|
|||||||
Reference in New Issue
Block a user