From 4605885262324c7bf5aebdbfb66ce23d5e822f9e Mon Sep 17 00:00:00 2001 From: yunyaozhou Date: Mon, 29 Jun 2026 04:40:32 +0800 Subject: [PATCH] feat: add profile ui and request telemetry --- .gitignore | 15 + AGENTS.md | 26 + README.md | 592 +++--- config.example.json | 29 +- gateway.mjs | 3784 +++++++++++++++++++++------------- package-lock.json | 1829 ++++++++++++++++ package.json | 23 + scripts/run-profile.mjs | 357 ++++ scripts/test-gateway-e2e.mjs | 21 +- ui-src/index.html | 14 + ui-src/src/App.tsx | 1291 ++++++++++++ ui-src/src/main.tsx | 11 + ui-src/src/styles.css | 922 +++++++++ ui-src/tsconfig.json | 20 + ui-src/vite.config.ts | 23 + 15 files changed, 7273 insertions(+), 1684 deletions(-) create mode 100644 AGENTS.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/run-profile.mjs create mode 100644 ui-src/index.html create mode 100644 ui-src/src/App.tsx create mode 100644 ui-src/src/main.tsx create mode 100644 ui-src/src/styles.css create mode 100644 ui-src/tsconfig.json create mode 100644 ui-src/vite.config.ts diff --git a/.gitignore b/.gitignore index 71c5423..374ae54 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,18 @@ .DS_Store Thumbs.db *.log +*.db +*.db-* +*.sqlite +*.sqlite-* +*.jsonl +.env +.env.* +!.env.example +config.json +state/ +logs/ +profiles/ +secrets/ +node_modules/ +public/ui/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4c5cf3d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,26 @@ +# AGENTS + +本仓库用于维护 `codex-retry-gateway` 的本地代理、管理 UI 和 profile 切换能力。 + +## 工作规则 + +- 涉及运行中服务的修改时,优先保证当前会话不断线。 +- 在执行会影响当前 gateway 实例的动作前,必须先做本地验证,再部署。 +- 本地验证至少包括: + - 后端改动后运行 `npm run check` + - 前端改动后运行 `npm run check:ui` + - 需要发布 UI 时运行 `npm run build:ui` +- 涉及 profile / upstream / model remap / auth 的改动,优先使用不切换当前实例的 probe 接口验证 +- 只有在确认测试可行后,才允许影响当前运行实例。 +- profile 切换与当前活跃 profile 保存,默认优先使用进程内热切换 / 热应用,不要先走 systemd 重启路径。 +- 若目标是验证某个 profile 是否可用,默认先使用 `POST /__codex_retry_gateway/api/profiles/probe`,不要先切换当前运行实例。 +- 若必须切换 profile 或重启服务,完成后要确认: + - 当前实例已恢复健康 + - 管理 UI 可访问 + - 当前活跃 profile 符合预期 + +## 部署偏好 + +- 优先小步修改、快速验证。 +- 优先避免会导致当前 Codex 会话掉线的操作。 +- 需要验证删除、切换、重启等行为时,优先使用临时 profile、probe 或热切换,而不是直接重启当前活跃实例。 diff --git a/README.md b/README.md index bd7c7e6..76dc9b5 100644 --- a/README.md +++ b/README.md @@ -1,267 +1,325 @@ -# Codex Retry Gateway - -tg群:https://t.me/AI_INPUT_IM - -一个不依赖 `cc-switch` 路由模式的独立本地网关。 - -目标: - -- 保持 Codex 继续使用现有 `auth.json` -- 只把 `config.toml` 的当前 provider `base_url` 改成本地网关 -- 非流式命中 `reasoning_tokens = 516` 时返回 `502` -- 流式命中时默认先缓存并判断;一旦命中 `516`,统一返回 `502` -- 默认同时拦截 root 路径和 `/v1` 路径: - - `/responses` - - `/chat/completions` - - `/v1/responses` - - `/v1/chat/completions` - -限制: - -- 这个网关不负责 `Responses` 和 `Chat Completions` 协议互转 -- 如果你的上游本身不支持 Codex 当前使用的协议,这个网关不会替你补齐转换能力 - -## 默认路径 - -Windows: - -- Codex 配置:`%USERPROFILE%\.codex\config.toml` -- Gateway 状态目录:`%USERPROFILE%\.codex-retry-gateway` - -macOS / Linux: - -- Codex 配置:`~/.codex/config.toml` -- Gateway 状态目录:`~/.codex-retry-gateway` - -## 当前版本说明 - -- 这是一个可独立发布、独立运行的仓库 -- 默认监听地址是 `http://127.0.0.1:4610` -- 默认示例上游见 `config.example.json` -- 实际运行时配置会写到当前用户目录下的 gateway 状态目录 - -## 一键启动并打开管理页 - -在仓库根目录执行: - -Windows: - -```powershell -powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1 -``` - -macOS / Linux: - -```bash -bash ./scripts/launch-ui.sh -``` - -这个脚本是默认入口,执行后会自动完成: - -- 第一次运行时: - - 备份当前用户目录下的 Codex `config.toml` - - 生成当前用户目录下的 gateway `config.json` - - 启动本地 gateway - - 把当前 `model_provider` 对应的 `base_url` 改到本地 gateway -- 之后再次运行时: - - 自动复用现有安装状态 - - 自动重启或拉起 gateway - - 自动再次打开管理页 - -默认会打开: - -```text -http://127.0.0.1:4610/__codex_retry_gateway/ui -``` - -如果你只想启动、不自动开浏览器: - -```powershell -powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1 -NoOpen -``` - -```bash -bash ./scripts/launch-ui.sh --no-open -``` - -常用参数: - -- Windows 参数: - - `-CodexConfigPath` - - `-StateRoot` - - `-ListenHost` - - `-ListenPort` - - `-NoOpen` -- macOS / Linux 参数: - - `--codex-config-path` - - `--state-root` - - `--listen-host` - - `--listen-port` - - `--no-open` - -macOS / Linux 说明: - -- 需要 `bash` -- 需要 `Node.js 18+` -- Unix 入口会调用跨平台 `node` 管理核心,不依赖 PowerShell -- 推荐显式使用 `bash ...sh` -- 这样即使目录是从 Windows 或压缩包复制过来、没有可执行位,也能直接运行 - -## 手工安装入口 - -如果你明确只想做脚本级安装,不想自动打开 UI,也可以直接执行: - -Windows: - -```powershell -powershell -ExecutionPolicy Bypass -File .\scripts\install-for-current-provider.ps1 -``` - -macOS / Linux: - -```bash -bash ./scripts/install-for-current-provider.sh -``` - -## 如何恢复 - -Windows: - -```powershell -powershell -ExecutionPolicy Bypass -File .\scripts\restore-codex-config.ps1 -``` - -macOS / Linux: - -```bash -bash ./scripts/restore-codex-config.sh -``` - -这个脚本会: - -- 停掉本地 gateway -- 用最近一次备份恢复当前用户目录下的 Codex `config.toml` -- 删除当前安装状态文件 - -## 管理页面 - -页面入口: - -```text -http://127.0.0.1:4610/__codex_retry_gateway/ui -``` - -页面里可以直接做这几件事: - -- 看当前监听地址、真实上游、当前 provider、当前 Codex base URL -- 看本次 gateway 启动以来的实时统计 - - 代理请求总数 - - 被检查响应总数 - - `516` 命中次数 - - `516` 占比 -- 改 `reasoning_equals` -- 改 `endpoints` -- 改 `non_stream_status_code` -- 开关 `log_match` -- 动态查看当前 gateway 的实时日志 -- 一键恢复 Codex 原设置 - -说明: - -- 页面保存配置后会立即热生效,不需要重启 gateway -- 页面点“恢复 Codex 原设置并关闭网关”后,当前页面会失联,这是预期行为 -- 日常恢复优先用 UI;`restore-codex-config.ps1` 作为脚本级应急回滚入口保留 -- UI 恢复不会再额外拉起恢复子进程,而是由当前 gateway 直接完成恢复并退出 -- 统计口径默认按“本次 gateway 启动以来”累计 -- `516` 占比 = `reasoning_tokens = 516` 的响应次数 / 被检查响应总数 - -## 如何调整拦截条件 - -编辑: - -```text -Windows: %USERPROFILE%\.codex-retry-gateway\config\config.json -macOS / Linux: ~/.codex-retry-gateway/config/config.json -``` - -常用字段: - -- `reasoning_equals` - - 例如 `[516]` -- `endpoints` - - 默认包含 root 与 `/v1` 两套路径 -- `non_stream_status_code` - - 默认 `502` -- `stream_action` - - 默认 `strict_502` - - `strict_502`:先缓存整个流,命中 `516` 时统一返回 `502` - - `disconnect`:兼容旧行为;若命中发生在已透传 chunk 之后,则直接断开连接 -- `log_match` - - 是否记录命中日志 - -改完后重启: - -```powershell -powershell -ExecutionPolicy Bypass -File .\scripts\start-gateway.ps1 -RestartIfRunning -``` - -```bash -bash ./scripts/start-gateway.sh --restart-if-running -``` - -如果你已经打开管理页,优先直接在页面里改,通常不需要手改 `config.json`。 - -## 其他机器如何应用 - -在其他 Windows 机器上: - -1. 复制整个仓库目录 -2. 确保本机有 `Node.js 18+` -3. 不需要安装 `cc-switch`,也不需要使用 `cc-switch` 路由模式 -4. 在仓库根目录执行 `powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1` -5. 如需回滚,优先在 UI 里点“恢复 Codex 原设置并关闭网关”;脚本级回滚仍可执行 `powershell -ExecutionPolicy Bypass -File .\scripts\restore-codex-config.ps1` - -在其他 macOS / Linux 机器上: - -1. 复制整个仓库目录 -2. 确保本机有 `bash` -3. 确保本机有 `Node.js 18+` -4. 不需要安装 `cc-switch`,也不需要使用 `cc-switch` 路由模式 -5. 在仓库根目录执行 `bash ./scripts/launch-ui.sh` -6. 如需回滚,优先在 UI 里点“恢复 Codex 原设置并关闭网关”;脚本级回滚仍可执行 `bash ./scripts/restore-codex-config.sh` - -运行时状态默认写到当前用户目录: - -```text -Windows: %USERPROFILE%\.codex-retry-gateway -macOS / Linux: ~/.codex-retry-gateway -``` - -## 已验证事项 - -- `test-gateway-e2e.ps1` - - 已通过 - - 验证 `/responses`、`/chat/completions`、`/v1/responses`、`/v1/chat/completions` -- `test-install-restore.ps1` - - 已通过 - - 验证安装、透传、UI 页面、热更新配置、实时日志、516 统计、恢复闭环 -- `test-launch-ui.ps1` - - 已通过 - - 验证首次一键启动自动安装、再次启动自动复用、UI 可访问、默认 516 拦截仍生效 -- `test-launch-ui-unix.ps1` - - 已通过 - - 在当前 Windows 主机的 Bash 环境里验证 Unix `.sh` 入口能完成启动、透传、恢复闭环 -- `bash ./scripts/launch-ui.sh --no-open` - - 已通过 - - 当前机器实测返回 `mode=reuse` - - 后续 `GET /__codex_retry_gateway/health`、`GET /__codex_retry_gateway/ui`、`GET /v1/models` 都返回 `200` -- `codex exec` - - 已通过 - - 在 Bash 默认入口重新拉起 gateway 后,当前机器再次返回 `OK` -- 当前实机验证示例 - - `GET http://127.0.0.1:4610/__codex_retry_gateway/health` 已通过 - - `GET http://127.0.0.1:4610/v1/models` 已通过,并成功透传到配置里的真实上游 - - `GET http://127.0.0.1:4610/__codex_retry_gateway/ui` 已实际打开并确认页面内容 -- `codex exec` 历史现象 - - gateway 关闭时,真实报错地址为 `http://127.0.0.1:4610/responses` - - gateway 恢复后,`codex exec` 已再次成功返回 `OK` +# Codex Retry Gateway + +tg群:https://t.me/AI_INPUT_IM + +一个不依赖 `cc-switch` 路由模式的独立本地网关。 + +目标: + +- 保持 Codex 继续使用现有 `auth.json` +- 只把 `config.toml` 的当前 provider `base_url` 改成本地网关 +- 非流式命中 `reasoning_tokens = 516` 时返回 `502` +- 流式命中时默认先缓存并判断;一旦命中 `516`,统一返回 `502` +- 默认同时拦截 root 路径和 `/v1` 路径: + - `/responses` + - `/chat/completions` + - `/v1/responses` + - `/v1/chat/completions` + +限制: + +- 这个网关不负责 `Responses` 和 `Chat Completions` 协议互转 +- 如果你的上游本身不支持 Codex 当前使用的协议,这个网关不会替你补齐转换能力 + +## 默认路径 + +Windows: + +- Codex 配置:`%USERPROFILE%\.codex\config.toml` +- Gateway 状态目录:`%USERPROFILE%\.codex-retry-gateway` + +macOS / Linux: + +- Codex 配置:`~/.codex/config.toml` +- Gateway 状态目录:`~/.codex-retry-gateway` + +## 当前版本说明 + +- 这是一个可独立发布、独立运行的仓库 +- 默认监听地址是 `http://127.0.0.1:4610` +- 默认示例上游见 `config.example.json` +- 实际运行时配置会写到当前用户目录下的 gateway 状态目录 + +## 一键启动并打开管理页 + +在仓库根目录执行: + +Windows: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1 +``` + +macOS / Linux: + +```bash +bash ./scripts/launch-ui.sh +``` + +这个脚本是默认入口,执行后会自动完成: + +- 第一次运行时: + - 备份当前用户目录下的 Codex `config.toml` + - 生成当前用户目录下的 gateway `config.json` + - 启动本地 gateway + - 把当前 `model_provider` 对应的 `base_url` 改到本地 gateway +- 之后再次运行时: + - 自动复用现有安装状态 + - 自动重启或拉起 gateway + - 自动再次打开管理页 + +默认会打开: + +```text +http://127.0.0.1:4610/__codex_retry_gateway/ui +``` + +如果你只想启动、不自动开浏览器: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1 -NoOpen +``` + +```bash +bash ./scripts/launch-ui.sh --no-open +``` + +常用参数: + +- Windows 参数: + - `-CodexConfigPath` + - `-StateRoot` + - `-ListenHost` + - `-ListenPort` + - `-NoOpen` +- macOS / Linux 参数: + - `--codex-config-path` + - `--state-root` + - `--listen-host` + - `--listen-port` + - `--no-open` + +macOS / Linux 说明: + +- 需要 `bash` +- 需要 `Node.js 18+` +- Unix 入口会调用跨平台 `node` 管理核心,不依赖 PowerShell +- 推荐显式使用 `bash ...sh` +- 这样即使目录是从 Windows 或压缩包复制过来、没有可执行位,也能直接运行 + +## 手工安装入口 + +如果你明确只想做脚本级安装,不想自动打开 UI,也可以直接执行: + +Windows: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\install-for-current-provider.ps1 +``` + +macOS / Linux: + +```bash +bash ./scripts/install-for-current-provider.sh +``` + +## Linux systemd + profile 启动 + +在 Linux 上需要常驻运行时,推荐用 `scripts/run-profile.mjs` 作为 systemd 的 ExecStart。它会读取 profile env,生成运行时 `config.json`,并把当前 Codex provider 的 `base_url` 指向本机 gateway。 + +profile env 默认放在: + +```text +~/.config/codex-retry-gateway/profiles/.env +``` + +常用字段: + +- `CODEX_RETRY_GATEWAY_LISTEN_HOST` +- `CODEX_RETRY_GATEWAY_LISTEN_PORT` +- `CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL` +- `CODEX_RETRY_GATEWAY_REASONING_EQUALS` +- `CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE` +- `CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV` +- `CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE` +- `CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_PATH` +- `CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_KEY` +- `CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT` + +`CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE` 支持: + +- `passthrough`:默认模式,透传 Codex 发来的 `Authorization` +- `manual_bearer`:在 UI 中手动填入一次 token/password,写入用户级受限 secret 文件,并覆盖上游 `Authorization: Bearer ...` +- `fixed_bearer`:从环境变量或文件读取 token,并覆盖上游 `Authorization: Bearer ...` +- `auth_json`:从 Codex `auth.json` 的指定 key 读取 token,并覆盖上游 `Authorization: Bearer ...` + +示例: + +```bash +node ./scripts/run-profile.mjs default +``` + +## 如何恢复 + +Windows: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\restore-codex-config.ps1 +``` + +macOS / Linux: + +```bash +bash ./scripts/restore-codex-config.sh +``` + +这个脚本会: + +- 停掉本地 gateway +- 用最近一次备份恢复当前用户目录下的 Codex `config.toml` +- 删除当前安装状态文件 + +## 管理页面 + +页面入口: + +```text +http://127.0.0.1:4610/__codex_retry_gateway/ui +``` + +UI 现在是独立的 Vite + React + TypeScript 前端: + +- 源码:`ui-src/` +- 构建产物:`public/ui/` +- 本地开发:`npm run dev:ui` +- 生产构建:`npm install && npm run build:ui` + +gateway 运行时只负责 API 与静态文件服务,不再把复杂 UI 硬写在 `gateway.mjs` 字符串里。 + +页面里可以直接做这几件事: + +- 看当前监听地址、真实上游、当前 provider、当前 Codex base URL +- 看本次 gateway 启动以来的实时统计 + - 代理请求总数 + - 被检查响应总数 + - 累计 input / output / total / reasoning tokens + - `516` 命中次数 + - `516` 占比 +- 看最近请求记录 + - 请求时间戳、首字耗时、总耗时、请求体大小、路径、模型、状态码 + - `usage` 中的 input / output / total / reasoning tokens +- 管理 profiles + - 新建 / 编辑 profile env + - 切换 provider `base_url` + - 切换 `passthrough` / `manual_bearer` / `fixed_bearer` / `auth_json` 认证模式 +- 改 `reasoning_equals` +- 改 `endpoints` +- 改 `non_stream_status_code` +- 开关 `log_match` +- 动态查看当前 gateway 的实时日志 +- 一键恢复 Codex 原设置 + +说明: + +- 页面保存配置后会立即热生效,不需要重启 gateway +- 页面点“恢复 Codex 原设置并关闭网关”后,当前页面会失联,这是预期行为 +- 日常恢复优先用 UI;`restore-codex-config.ps1` 作为脚本级应急回滚入口保留 +- UI 恢复不会再额外拉起恢复子进程,而是由当前 gateway 直接完成恢复并退出 +- 统计口径默认按“本次 gateway 启动以来”累计 +- `516` 占比 = `reasoning_tokens = 516` 的响应次数 / 被检查响应总数 +- 请求历史只记录元数据、请求体字节数和 token usage,不保存请求正文或响应正文;默认展示最近 200 条 +- gateway 日志持久化到 `~/.codex-retry-gateway/logs/gateway.log` +- 请求记录持久化到 `~/.codex-retry-gateway/logs/requests.jsonl`,重启后仍可用于 UI 请求页和 token totals +- `manual_bearer` 的手动 token/password 只写入系统 secret 文件,API/UI 不读回明文;profile env 只保存 secret 文件路径 +- 其他 profile env 不保存明文 `sk-...`;固定密钥请使用 env/file 引用,或用 `auth_json` 指向 `auth.json` 字段名 + +## 如何调整拦截条件 + +编辑: + +```text +Windows: %USERPROFILE%\.codex-retry-gateway\config\config.json +macOS / Linux: ~/.codex-retry-gateway/config/config.json +``` + +常用字段: + +- `reasoning_equals` + - 例如 `[516]` +- `endpoints` + - 默认包含 root 与 `/v1` 两套路径 +- `non_stream_status_code` + - 默认 `502` +- `stream_action` + - 默认 `strict_502` + - `strict_502`:先缓存整个流,命中 `516` 时统一返回 `502` + - `disconnect`:兼容旧行为;若命中发生在已透传 chunk 之后,则直接断开连接 +- `log_match` + - 是否记录命中日志 + +改完后重启: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\start-gateway.ps1 -RestartIfRunning +``` + +```bash +bash ./scripts/start-gateway.sh --restart-if-running +``` + +如果你已经打开管理页,优先直接在页面里改,通常不需要手改 `config.json`。 + +## 其他机器如何应用 + +在其他 Windows 机器上: + +1. 复制整个仓库目录 +2. 确保本机有 `Node.js 18+` +3. 不需要安装 `cc-switch`,也不需要使用 `cc-switch` 路由模式 +4. 在仓库根目录执行 `powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1` +5. 如需回滚,优先在 UI 里点“恢复 Codex 原设置并关闭网关”;脚本级回滚仍可执行 `powershell -ExecutionPolicy Bypass -File .\scripts\restore-codex-config.ps1` + +在其他 macOS / Linux 机器上: + +1. 复制整个仓库目录 +2. 确保本机有 `bash` +3. 确保本机有 `Node.js 18+` +4. 不需要安装 `cc-switch`,也不需要使用 `cc-switch` 路由模式 +5. 在仓库根目录执行 `bash ./scripts/launch-ui.sh` +6. 如需回滚,优先在 UI 里点“恢复 Codex 原设置并关闭网关”;脚本级回滚仍可执行 `bash ./scripts/restore-codex-config.sh` + +运行时状态默认写到当前用户目录: + +```text +Windows: %USERPROFILE%\.codex-retry-gateway +macOS / Linux: ~/.codex-retry-gateway +``` + +## 已验证事项 + +- `test-gateway-e2e.ps1` + - 已通过 + - 验证 `/responses`、`/chat/completions`、`/v1/responses`、`/v1/chat/completions` +- `test-install-restore.ps1` + - 已通过 + - 验证安装、透传、UI 页面、热更新配置、实时日志、516 统计、恢复闭环 +- `test-launch-ui.ps1` + - 已通过 + - 验证首次一键启动自动安装、再次启动自动复用、UI 可访问、默认 516 拦截仍生效 +- `test-launch-ui-unix.ps1` + - 已通过 + - 在当前 Windows 主机的 Bash 环境里验证 Unix `.sh` 入口能完成启动、透传、恢复闭环 +- `bash ./scripts/launch-ui.sh --no-open` + - 已通过 + - 当前机器实测返回 `mode=reuse` + - 后续 `GET /__codex_retry_gateway/health`、`GET /__codex_retry_gateway/ui`、`GET /v1/models` 都返回 `200` +- `codex exec` + - 已通过 + - 在 Bash 默认入口重新拉起 gateway 后,当前机器再次返回 `OK` +- 当前实机验证示例 + - `GET http://127.0.0.1:4610/__codex_retry_gateway/health` 已通过 + - `GET http://127.0.0.1:4610/v1/models` 已通过,并成功透传到配置里的真实上游 + - `GET http://127.0.0.1:4610/__codex_retry_gateway/ui` 已实际打开并确认页面内容 +- `codex exec` 历史现象 + - gateway 关闭时,真实报错地址为 `http://127.0.0.1:4610/responses` + - gateway 恢复后,`codex exec` 已再次成功返回 `OK` diff --git a/config.example.json b/config.example.json index ec0e9df..3be5d57 100644 --- a/config.example.json +++ b/config.example.json @@ -1,12 +1,19 @@ -{ - "listen_host": "127.0.0.1", - "listen_port": 4610, - "upstream_base_url": "https://api.openai.com", - "request_body_limit_bytes": 10485760, - "endpoints": ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], - "reasoning_equals": [516], - "non_stream_status_code": 502, +{ + "profile_name": "default", + "listen_host": "127.0.0.1", + "listen_port": 4610, + "upstream_base_url": "https://api.openai.com", + "upstream_auth_mode": "passthrough", + "upstream_auth_env": "CODEX_RETRY_GATEWAY_UPSTREAM_API_KEY", + "upstream_auth_file": "", + "upstream_auth_json_path": "", + "upstream_auth_json_key": "OPENAI_API_KEY", + "request_body_limit_bytes": 10485760, + "request_history_limit": 200, + "endpoints": ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], + "reasoning_equals": [516], + "non_stream_status_code": 502, "stream_action": "strict_502", - "log_match": true, - "health_path": "/__codex_retry_gateway/health" -} + "log_match": true, + "health_path": "/__codex_retry_gateway/health" +} diff --git a/gateway.mjs b/gateway.mjs index b4aa5fa..97db2c1 100644 --- a/gateway.mjs +++ b/gateway.mjs @@ -1,154 +1,316 @@ -#!/usr/bin/env node - -import http from "node:http"; -import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import fs from "node:fs"; -import path from "node:path"; -import { TextDecoder } from "node:util"; -import { fileURLToPath } from "node:url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const ADMIN_BASE_PATH = "/__codex_retry_gateway"; -const UI_PATH = `${ADMIN_BASE_PATH}/ui`; -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 RESTORE_API_PATH = `${ADMIN_BASE_PATH}/api/restore`; - -const DEFAULT_CONFIG = { - listen_host: "127.0.0.1", - listen_port: 4610, - upstream_base_url: "", - request_body_limit_bytes: 10 * 1024 * 1024, - endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], - reasoning_equals: [516], - non_stream_status_code: 502, +#!/usr/bin/env node + +import http from "node:http"; +import { spawn } from "node:child_process"; +import { chmod, copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import fs from "node:fs"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { TextDecoder } from "node:util"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const ADMIN_BASE_PATH = "/__codex_retry_gateway"; +const UI_PATH = `${ADMIN_BASE_PATH}/ui`; +const UI_STATIC_ROOT = path.join(__dirname, "public", "ui"); +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 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 RESTORE_API_PATH = `${ADMIN_BASE_PATH}/api/restore`; + +const DEFAULT_CONFIG = { + profile_name: "default", + listen_host: "127.0.0.1", + listen_port: 4610, + upstream_base_url: "", + upstream_auth_mode: "passthrough", + upstream_auth_env: "CODEX_RETRY_GATEWAY_UPSTREAM_API_KEY", + upstream_auth_file: "", + upstream_auth_json_path: "", + upstream_auth_json_key: "OPENAI_API_KEY", + request_body_limit_bytes: 10 * 1024 * 1024, + request_history_limit: 0, + model_remap: "", + endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], + reasoning_equals: [516], + non_stream_status_code: 502, stream_action: "strict_502", - log_match: true, - health_path: "/__codex_retry_gateway/health", -}; - -const REASONING_POINTERS = [ - "/usage/output_tokens_details/reasoning_tokens", - "/usage/completion_tokens_details/reasoning_tokens", - "/response/usage/output_tokens_details/reasoning_tokens", - "/response/usage/completion_tokens_details/reasoning_tokens", -]; - -function parseArgs(argv) { - const args = { config: null, log: null }; - for (let i = 2; i < argv.length; i += 1) { - const current = argv[i]; - if (current === "--config") { - args.config = argv[i + 1]; - i += 1; - } else if (current === "--log") { - args.log = argv[i + 1]; - i += 1; - } else if (current === "--help" || current === "-h") { - printHelp(); - process.exit(0); - } - } - return args; -} - -function printHelp() { - process.stdout.write( - [ - "用法:", - " node gateway.mjs --config [--log ]", - "", - "说明:", - " 独立 Codex 本地重试网关。", - " 非流式命中 reasoning_tokens=516 时返回 502。", + log_match: true, + health_path: "/__codex_retry_gateway/health", +}; + +const REASONING_POINTERS = [ + "/usage/output_tokens_details/reasoning_tokens", + "/usage/completion_tokens_details/reasoning_tokens", + "/response/usage/output_tokens_details/reasoning_tokens", + "/response/usage/completion_tokens_details/reasoning_tokens", +]; + +function parseArgs(argv) { + const args = { config: null, log: null }; + for (let i = 2; i < argv.length; i += 1) { + const current = argv[i]; + if (current === "--config") { + args.config = argv[i + 1]; + i += 1; + } else if (current === "--log") { + args.log = argv[i + 1]; + i += 1; + } else if (current === "--help" || current === "-h") { + printHelp(); + process.exit(0); + } + } + return args; +} + +function printHelp() { + process.stdout.write( + [ + "用法:", + " node gateway.mjs --config [--log ]", + "", + "说明:", + " 独立 Codex 本地重试网关。", + " 非流式命中 reasoning_tokens=516 时返回 502。", " 流式命中时默认缓存并返回 502,避免半截流返回。", - "", - ].join("\n"), - ); -} - -function normalizePath(inputPath) { - const [withoutQuery] = `${inputPath || "/"}`.split("?"); - const trimmed = withoutQuery.length > 1 ? withoutQuery.replace(/\/+$/, "") : withoutQuery; - return trimmed || "/"; -} - -function flattenValues(value) { - if (Array.isArray(value)) { - return value.flatMap((item) => flattenValues(item)); - } - return [value]; -} - -function isJsonContentType(contentType) { - return `${contentType || ""}`.toLowerCase().includes("application/json"); -} - -function isSseContentType(contentType) { - return `${contentType || ""}`.toLowerCase().includes("text/event-stream"); -} - -function jsonPointerGet(value, pointer) { - if (!pointer.startsWith("/")) { - return undefined; - } - return pointer - .slice(1) - .split("/") - .map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~")) - .reduce((current, segment) => { - if (current === null || current === undefined) { - return undefined; - } - return current[segment]; - }, value); -} - -function extractReasoningTokens(payload) { - for (const pointer of REASONING_POINTERS) { - const raw = jsonPointerGet(payload, pointer); - if (Number.isInteger(raw)) { - return raw; - } - } - return null; -} - -function normalizeIntegerList(values, fallback = []) { - const source = values === undefined || values === null ? fallback : values; - const normalized = flattenValues(source) - .flatMap((value) => { - if (typeof value === "string") { - return value.split(/[\s,]+/).filter(Boolean); - } - return [value]; - }) - .map((value) => Number.parseInt(`${value}`, 10)) - .filter((value) => Number.isInteger(value)); - - return [...new Set(normalized)]; -} - -function normalizeStringList(values, fallback = []) { - const source = values === undefined || values === null ? fallback : values; - const normalized = flattenValues(source) - .flatMap((value) => `${value ?? ""}`.split(/[\s,]+/)) - .map((value) => value.trim()) - .filter(Boolean); - - return [...new Set(normalized)]; -} - + "", + ].join("\n"), + ); +} + +function normalizePath(inputPath) { + const [withoutQuery] = `${inputPath || "/"}`.split("?"); + const trimmed = withoutQuery.length > 1 ? withoutQuery.replace(/\/+$/, "") : withoutQuery; + return trimmed || "/"; +} + +function flattenValues(value) { + if (Array.isArray(value)) { + return value.flatMap((item) => flattenValues(item)); + } + return [value]; +} + +function isJsonContentType(contentType) { + return `${contentType || ""}`.toLowerCase().includes("application/json"); +} + +function isSseContentType(contentType) { + return `${contentType || ""}`.toLowerCase().includes("text/event-stream"); +} + +function jsonPointerGet(value, pointer) { + if (!pointer.startsWith("/")) { + return undefined; + } + return pointer + .slice(1) + .split("/") + .map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~")) + .reduce((current, segment) => { + if (current === null || current === undefined) { + return undefined; + } + return current[segment]; + }, value); +} + +function extractReasoningTokens(payload) { + for (const pointer of REASONING_POINTERS) { + const raw = jsonPointerGet(payload, pointer); + if (Number.isInteger(raw)) { + return raw; + } + } + return null; +} + +function firstInteger(...values) { + for (const value of values) { + if (Number.isInteger(value)) { + return value; + } + } + return null; +} + +function normalizeUsageSnapshot(payload) { + const usage = payload?.usage || payload?.response?.usage || null; + if (!usage || typeof usage !== "object") { + return null; + } + + const inputTokens = firstInteger(usage.input_tokens, usage.prompt_tokens); + const outputTokens = firstInteger(usage.output_tokens, usage.completion_tokens); + const totalTokens = firstInteger( + usage.total_tokens, + inputTokens !== null && outputTokens !== null ? inputTokens + outputTokens : null, + ); + const reasoningTokens = firstInteger( + usage.output_tokens_details?.reasoning_tokens, + usage.completion_tokens_details?.reasoning_tokens, + payload?.response?.usage?.output_tokens_details?.reasoning_tokens, + payload?.response?.usage?.completion_tokens_details?.reasoning_tokens, + ); + const cachedTokens = firstInteger( + usage.input_tokens_details?.cached_tokens, + usage.prompt_tokens_details?.cached_tokens, + payload?.response?.usage?.input_tokens_details?.cached_tokens, + payload?.response?.usage?.prompt_tokens_details?.cached_tokens, + ); + + if ( + inputTokens === null && + outputTokens === null && + totalTokens === null && + reasoningTokens === null && + cachedTokens === null + ) { + return null; + } + + return { + input_tokens: inputTokens, + output_tokens: outputTokens, + total_tokens: totalTokens, + reasoning_tokens: reasoningTokens, + cached_tokens: cachedTokens, + }; +} + +function mergeUsageSnapshots(current, next) { + if (!next) { + return current || null; + } + return { + input_tokens: next.input_tokens ?? current?.input_tokens ?? null, + output_tokens: next.output_tokens ?? current?.output_tokens ?? null, + total_tokens: next.total_tokens ?? current?.total_tokens ?? null, + reasoning_tokens: next.reasoning_tokens ?? current?.reasoning_tokens ?? null, + cached_tokens: next.cached_tokens ?? current?.cached_tokens ?? null, + }; +} + +function normalizeIntegerList(values, fallback = []) { + const source = values === undefined || values === null ? fallback : values; + const normalized = flattenValues(source) + .flatMap((value) => { + if (typeof value === "string") { + return value.split(/[\s,]+/).filter(Boolean); + } + return [value]; + }) + .map((value) => Number.parseInt(`${value}`, 10)) + .filter((value) => Number.isInteger(value)); + + return [...new Set(normalized)]; +} + +function normalizeStringList(values, fallback = []) { + const source = values === undefined || values === null ? fallback : values; + const normalized = flattenValues(source) + .flatMap((value) => `${value ?? ""}`.split(/[\s,]+/)) + .map((value) => value.trim()) + .filter(Boolean); + + return [...new Set(normalized)]; +} + +function parseModelRemapMap(value) { + const map = {}; + for (const rawEntry of `${value || ""}`.split(/\r?\n|[;,]/)) { + const entry = rawEntry.trim(); + if (!entry) { + continue; + } + const separatorIndex = entry.indexOf("="); + if (separatorIndex <= 0) { + continue; + } + const from = entry.slice(0, separatorIndex).trim(); + const to = entry.slice(separatorIndex + 1).trim(); + if (!from || !to) { + continue; + } + map[from] = to; + } + return map; +} + +function canHotSwapProfile(currentConfig, nextConfig) { + return ( + `${currentConfig?.listen_host || ""}` === `${nextConfig?.listen_host || ""}` && + Number.parseInt(`${currentConfig?.listen_port || ""}`, 10) === Number.parseInt(`${nextConfig?.listen_port || ""}`, 10) + ); +} + +function parseEnvText(content) { + const values = {}; + for (const rawLine of `${content || ""}`.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + const separatorIndex = line.indexOf("="); + if (separatorIndex <= 0) { + continue; + } + const key = line.slice(0, separatorIndex).trim(); + let value = line.slice(separatorIndex + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + values[key] = value; + } + return values; +} + +function isSecretLikeKey(key) { + return /TOKEN|SECRET|PASSWORD|API_KEY|AUTH|BEARER|KEY/i.test(`${key || ""}`); +} + +function redactProfileValue(key, value) { + if (!value) { + return ""; + } + if (isSecretLikeKey(key) || /^sk-[A-Za-z0-9_-]+/.test(value)) { + return "[configured]"; + } + return value; +} + +function getProfileNameFromFile(fileName) { + if (!fileName.endsWith(".env")) { + return null; + } + const profileName = fileName.slice(0, -4); + return /^[A-Za-z0-9_.-]+$/.test(profileName) ? profileName : null; +} + +function validateProfileName(profileName) { + if (!/^[A-Za-z0-9_.-]+$/.test(profileName)) { + throw new Error("profile 名称只能包含字母、数字、下划线、点和短横线"); + } +} + function buildBlockedBody(pathname, reasoning, statusCode) { return JSON.stringify({ error: { message: `codex retry gateway blocked suspicious reasoning response on ${pathname}`, - type: "codex_retry_gateway", - code: "reasoning_guard_triggered", - reasoning_tokens: reasoning, - status_code: statusCode, + type: "codex_retry_gateway", + code: "reasoning_guard_triggered", + reasoning_tokens: reasoning, + status_code: statusCode, }, }); } @@ -162,1048 +324,1674 @@ function buildGatewayErrorBody(message) { }, }); } - -function createMonitor() { - return { - started_at: new Date().toISOString(), - next_log_seq: 1, - log_entries: [], - total_proxy_request_count: 0, - inspected_response_count: 0, - matched_response_count: 0, - observed_reasoning_counts: {}, - }; -} - -function createMonitorRecorder(monitor) { - return (message) => { - const entry = { - seq: monitor.next_log_seq, - at: new Date().toISOString(), - message, - }; - monitor.next_log_seq += 1; - monitor.log_entries.push(entry); - return entry; - }; -} - -function createLogger(logPath, recordEntry) { - if (!logPath) { - return (message) => { - const entry = recordEntry ? recordEntry(message) : { at: new Date().toISOString(), message }; - process.stdout.write(`${entry.at} ${entry.message}\n`); - }; - } - - const stream = fs.createWriteStream(logPath, { flags: "a" }); - return (message) => { - const entry = recordEntry ? recordEntry(message) : { at: new Date().toISOString(), message }; - const line = `${entry.at} ${entry.message}\n`; - stream.write(line); - process.stdout.write(line); - }; -} - -function incrementReasoningCount(counter, reasoning) { - if (!Number.isInteger(reasoning)) { - return; - } - const key = `${reasoning}`; - counter[key] = (counter[key] || 0) + 1; -} - -function recordInspectedResponse(monitor, reasoning, matched) { - monitor.inspected_response_count += 1; - incrementReasoningCount(monitor.observed_reasoning_counts, reasoning); - if (matched) { - monitor.matched_response_count += 1; - } -} - -function buildMetricsSnapshot(monitor) { - const reasoning516Count = monitor.observed_reasoning_counts["516"] || 0; - const inspectedResponseCount = monitor.inspected_response_count; - return { - started_at: monitor.started_at, - total_proxy_request_count: monitor.total_proxy_request_count, - inspected_response_count: inspectedResponseCount, - matched_response_count: monitor.matched_response_count, - reasoning_516_count: reasoning516Count, - reasoning_516_ratio: - inspectedResponseCount === 0 ? 0 : reasoning516Count / inspectedResponseCount, - observed_reasoning_counts: { ...monitor.observed_reasoning_counts }, - }; -} - -function buildLogsSnapshot(monitor, sinceSeq = null) { - const entries = Number.isInteger(sinceSeq) - ? monitor.log_entries.filter((entry) => entry.seq > sinceSeq) - : monitor.log_entries; - - return { - total_entries: monitor.log_entries.length, - latest_seq: monitor.next_log_seq - 1, - entries, - }; -} - -async function loadConfig(configPath) { - const content = await readFile(configPath, "utf8"); - const loaded = JSON.parse(content); - const config = { ...DEFAULT_CONFIG, ...loaded }; - config.endpoints = normalizeStringList(config.endpoints, DEFAULT_CONFIG.endpoints).map(normalizePath); - config.reasoning_equals = normalizeIntegerList( - config.reasoning_equals, - DEFAULT_CONFIG.reasoning_equals, - ); - if (!config.upstream_base_url) { - throw new Error("配置缺少 upstream_base_url"); - } - return config; -} - -function buildRuntimePaths(configPath, logPath) { - const configDirectory = path.dirname(configPath); - const stateRoot = path.dirname(configDirectory); - return { - stateRoot, - statePath: path.join(stateRoot, "state.json"), - pidPath: path.join(stateRoot, "gateway.pid"), - configPath, - logPath, - }; -} - -async function readOptionalJson(jsonPath) { - try { - const content = await readFile(jsonPath, "utf8"); - return JSON.parse(content); - } catch { - return null; - } -} - -async function writeConfig(configPath, config) { - await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); -} - -function extractProviderBaseUrl(content, providerName) { - if (!content || !providerName) { - return null; - } - - const sectionPattern = new RegExp( - String.raw`^\[model_providers\.${providerName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\]\s*$[\s\S]*?(?=^\[|\Z)`, - "m", - ); - const sectionMatch = content.match(sectionPattern); - if (!sectionMatch) { - return null; - } - - const baseUrlMatch = sectionMatch[0].match(/^\s*base_url\s*=\s*"([^"]+)"\s*$/m); - return baseUrlMatch ? baseUrlMatch[1] : null; -} - -async function readRuntimeState(runtime) { - const state = await readOptionalJson(runtime.paths.statePath); - if (!state) { - return null; - } - - let codexCurrentBaseUrl = null; - if (state.codex_config_path && state.provider_name) { - try { - const codexConfig = await readFile(state.codex_config_path, "utf8"); - codexCurrentBaseUrl = extractProviderBaseUrl(codexConfig, state.provider_name); - } catch { - codexCurrentBaseUrl = null; - } - } - - return { - ...state, - codex_current_base_url: codexCurrentBaseUrl, - }; -} - -async function restoreRuntimeState(runtime, state) { - const backupPath = state?.latest_backup_path; - const codexConfigPath = state?.codex_config_path; - - if (!backupPath || !fs.existsSync(backupPath)) { - throw new Error(`未找到可恢复备份: ${backupPath || "unknown"}`); - } - if (!codexConfigPath) { - throw new Error("安装状态里缺少 codex_config_path"); - } - - await copyFile(backupPath, codexConfigPath); - await Promise.all([ - rm(runtime.paths.statePath, { force: true }), - rm(runtime.paths.pidPath, { force: true }), - ]); -} - -function jsonResponse(res, statusCode, payload, headers = {}) { - res.writeHead(statusCode, { - "content-type": "application/json; charset=utf-8", - ...headers, - }); - res.end(JSON.stringify(payload)); -} - -function htmlResponse(res, html) { - res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); - res.end(html); -} - -function buildEditableConfig(currentConfig, payload) { - const nextReasoning = normalizeIntegerList(payload.reasoning_equals, currentConfig.reasoning_equals); - const nextEndpoints = normalizeStringList(payload.endpoints, currentConfig.endpoints).map(normalizePath); - const nextStatusCode = - payload.non_stream_status_code === undefined - ? currentConfig.non_stream_status_code - : Number.parseInt(`${payload.non_stream_status_code}`, 10); - - if (nextReasoning.length === 0) { - throw new Error("reasoning_equals 不能为空"); - } - if (nextEndpoints.length === 0) { - throw new Error("endpoints 不能为空"); - } - if (!Number.isInteger(nextStatusCode) || nextStatusCode < 100 || nextStatusCode > 599) { - throw new Error("non_stream_status_code 必须是 100-599 的整数"); - } - - return { - ...currentConfig, - reasoning_equals: nextReasoning, - endpoints: nextEndpoints, - non_stream_status_code: nextStatusCode, - log_match: payload.log_match === undefined ? currentConfig.log_match : Boolean(payload.log_match), - }; -} - -function buildManagementHtml() { - const uiConfig = { - statusPath: STATUS_API_PATH, - configPath: CONFIG_API_PATH, - logsPath: LOGS_API_PATH, - restorePath: RESTORE_API_PATH, - }; - - return ` - - - - - Codex Retry Gateway - - - -
-
-
本地管理页
-

Codex Retry Gateway

-

- 这个页面直接挂在正在运行的 gateway 上。你可以在这里查看当前接管状态、修改 516 拦截条件,并一键恢复 Codex 原设置。 -

-
- -
-
-
-

运行状态

-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
0
-
0
-
0
-
0.00%
-
0
-
-

- 如果“当前 Codex Base URL”已经是本机监听地址,就说明当前 Codex 已经被这个 gateway 接管。统计口径按本次 gateway 启动以来累计。 -

-
-
- -
-
-

拦截规则

-
-
- - -
多个值用英文逗号或空格分隔。
-
- -
- - -
每行一个路径。默认建议同时保留 root 与 /v1 两套路径。
-
- -
- - -
- -
- - -
- -
- - -
-
-
-

- 点击“恢复”后,gateway 会停掉,所以这个页面会失联。这是预期行为,不是报错。 -

-
-
- -
-
-

实时日志

-

正在读取日志...

-
正在读取日志...
-
-
-
-
- - - -`; -} - -async function handleManagementRequest(runtime, req, res, requestUrl) { - const pathname = normalizePath(requestUrl.pathname); - - if (pathname === UI_PATH) { - htmlResponse(res, buildManagementHtml()); - return true; - } - - if (pathname === STATUS_API_PATH && req.method === "GET") { - const state = await readRuntimeState(runtime); - jsonResponse(res, 200, { - ok: true, - listen: `${runtime.config.listen_host}:${runtime.config.listen_port}`, - config: runtime.config, - state, - paths: { - config_path: runtime.configPath, - state_path: runtime.paths.statePath, - state_root: runtime.paths.stateRoot, - log_path: runtime.logPath, - }, - metrics: buildMetricsSnapshot(runtime.monitor), - }); - return true; - } - - if (pathname === LOGS_API_PATH && req.method === "GET") { - const sinceSeqRaw = requestUrl.searchParams.get("since_seq"); - const sinceSeq = sinceSeqRaw === null ? null : Number.parseInt(sinceSeqRaw, 10); - jsonResponse(res, 200, { - ok: true, - ...buildLogsSnapshot(runtime.monitor, Number.isInteger(sinceSeq) ? sinceSeq : null), - }); - 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); - if (!payload) { - jsonResponse(res, 400, { - error: { - message: "配置保存请求必须是有效 JSON", - code: "invalid_json", - }, - }); - return true; - } - - const nextConfig = buildEditableConfig(runtime.config, payload); - await writeConfig(runtime.configPath, nextConfig); - runtime.config = nextConfig; - runtime.logger( - `[config] updated reasoning_equals=${nextConfig.reasoning_equals.join(",")} endpoints=${nextConfig.endpoints.join(",")}`, - ); - const state = await readRuntimeState(runtime); - jsonResponse(res, 200, { - ok: true, - message: "配置已保存并立即生效", - config: runtime.config, - state, - paths: { - config_path: runtime.configPath, - state_path: runtime.paths.statePath, - state_root: runtime.paths.stateRoot, - log_path: runtime.logPath, - }, - metrics: buildMetricsSnapshot(runtime.monitor), - }); - return true; - } - - if (pathname === RESTORE_API_PATH && req.method === "POST") { - const state = await readRuntimeState(runtime); - if (!state) { - jsonResponse(res, 409, { - error: { - message: "当前未检测到安装状态,无法恢复 Codex 原设置", - code: "state_not_found", - }, - }); - return true; - } - - await restoreRuntimeState(runtime, state); - runtime.logger(`[restore] restored via UI state_root=${runtime.paths.stateRoot}`); - jsonResponse(res, 202, { - ok: true, - message: "原设置已恢复,gateway 即将关闭", - }); - res.on("finish", () => { - const exitTimer = setTimeout(() => { - if (runtime.server) { - runtime.server.close(() => { - process.exit(0); - }); - } else { - process.exit(0); - } - - const hardExitTimer = setTimeout(() => { - process.exit(0); - }, 600); - hardExitTimer.unref(); - }, 120); - exitTimer.unref(); - }); - return true; - } - - return false; -} - -function buildUpstreamUrl(baseUrl, requestUrl) { - const upstream = new URL(baseUrl); - const normalizedBasePath = upstream.pathname.endsWith("/") - ? upstream.pathname.slice(0, -1) - : upstream.pathname; - const incomingPath = requestUrl.pathname; - - let finalPath = incomingPath; - if (normalizedBasePath && normalizedBasePath !== "/") { - if (incomingPath.startsWith(`${normalizedBasePath}/`) || incomingPath === normalizedBasePath) { - finalPath = incomingPath; - } else if (normalizedBasePath.endsWith("/v1") && incomingPath.startsWith("/v1/")) { - finalPath = `${normalizedBasePath}${incomingPath.slice(3)}`; - } else { - finalPath = `${normalizedBasePath}${incomingPath}`; - } - } - - upstream.pathname = finalPath; - upstream.search = requestUrl.search; - return upstream.toString(); -} - -function cloneHeadersForUpstream(headers) { - const outgoing = new Headers(); - for (const [key, value] of Object.entries(headers)) { - if (value === undefined) { - continue; - } - const lowerKey = key.toLowerCase(); - if ( - lowerKey === "host" || - lowerKey === "content-length" || - lowerKey === "connection" || - lowerKey === "transfer-encoding" - ) { - continue; - } - if (Array.isArray(value)) { - for (const item of value) { - outgoing.append(key, item); - } - } else { - outgoing.set(key, value); - } - } - return outgoing; -} - -function copyHeadersToClient(sourceHeaders, target) { - for (const [key, value] of sourceHeaders.entries()) { - const lowerKey = key.toLowerCase(); - if ( - lowerKey === "content-length" || - lowerKey === "transfer-encoding" || - lowerKey === "content-encoding" || - lowerKey === "connection" - ) { - continue; - } - target.setHeader(key, value); - } -} - -async function readRequestBody(req, limitBytes) { - const chunks = []; - let total = 0; - for await (const chunk of req) { - total += chunk.length; - if (total > limitBytes) { - throw new Error(`请求体超过限制: ${limitBytes} bytes`); - } - chunks.push(chunk); - } - return Buffer.concat(chunks); -} - -function parseJsonSafely(buffer) { - try { - return JSON.parse(buffer.toString("utf8")); - } catch { - return null; - } -} - -function matchPath(config, pathname) { - return config.endpoints.includes(normalizePath(pathname)); -} - + +function createMonitor() { + return { + started_at: new Date().toISOString(), + next_log_seq: 1, + next_request_seq: 1, + log_entries: [], + request_entries: [], + total_proxy_request_count: 0, + inspected_response_count: 0, + matched_response_count: 0, + token_totals: { + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + reasoning_tokens: 0, + cached_tokens: 0, + }, + observed_reasoning_counts: {}, + }; +} + +function buildLogEntry(seq, at, message) { + return { + seq, + at, + message, + }; +} + +function parseLogLine(line, seq) { + const match = `${line || ""}`.match(/^(\S+)\s([\s\S]*)$/); + if (!match) { + return buildLogEntry(seq, null, line); + } + return buildLogEntry(seq, match[1], match[2]); +} + +function parseJsonLine(line) { + if (!line.trim()) { + return null; + } + try { + return JSON.parse(line); + } catch { + return null; + } +} + +async function readJsonlFile(filePath) { + const text = await readOptionalText(filePath); + if (!text) { + return []; + } + return text + .split(/\r?\n/) + .map(parseJsonLine) + .filter(Boolean); +} + +async function appendJsonl(filePath, value) { + await mkdir(path.dirname(filePath), { recursive: true }); + await fs.promises.appendFile(filePath, `${JSON.stringify(value)}\n`, "utf8"); +} + +function openRequestsDatabase(dbPath) { + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + const db = new DatabaseSync(dbPath); + db.exec(` + PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS requests ( + seq INTEGER PRIMARY KEY, + started_at TEXT, + finished_at TEXT, + duration_ms INTEGER, + profile_name TEXT, + method TEXT, + path TEXT, + model TEXT, + requested_model TEXT, + forwarded_model TEXT, + request_stream INTEGER, + response_stream INTEGER, + inspected INTEGER, + matched INTEGER, + status_code INTEGER, + upstream_status_code INTEGER, + reasoning_tokens INTEGER, + input_tokens INTEGER, + output_tokens INTEGER, + total_tokens INTEGER, + cached_tokens INTEGER, + error TEXT, + upstream_origin TEXT, + upstream_path TEXT, + upstream_auth_mode TEXT, + upstream_auth_source TEXT, + payload_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_requests_started_at ON requests(started_at DESC); + CREATE INDEX IF NOT EXISTS idx_requests_profile_name ON requests(profile_name); + CREATE INDEX IF NOT EXISTS idx_requests_status_code ON requests(status_code); + CREATE INDEX IF NOT EXISTS idx_requests_matched ON requests(matched); + CREATE INDEX IF NOT EXISTS idx_requests_response_stream ON requests(response_stream); + `); + return db; +} + +function boolToInt(value) { + return value ? 1 : 0; +} + +function usageField(entry, key) { + const value = entry?.usage?.[key]; + return Number.isInteger(value) ? value : null; +} + +function buildPersistedRequestPayload(entry) { + return Object.fromEntries( + Object.entries(entry || {}).filter(([key]) => !key.startsWith("_")), + ); +} + +function requestRowFromEntry(entry) { + const payload = buildPersistedRequestPayload(entry); + return { + seq: entry.seq, + started_at: entry.started_at || null, + finished_at: entry.finished_at || null, + duration_ms: Number.isInteger(entry.duration_ms) ? entry.duration_ms : null, + profile_name: entry.profile_name || null, + method: entry.method || null, + path: entry.path || null, + model: entry.model || null, + requested_model: entry.requested_model || null, + forwarded_model: entry.forwarded_model || null, + request_stream: boolToInt(Boolean(entry.request_stream)), + response_stream: boolToInt(Boolean(entry.response_stream)), + inspected: boolToInt(Boolean(entry.inspected)), + matched: boolToInt(Boolean(entry.matched)), + status_code: Number.isInteger(entry.status_code) ? entry.status_code : null, + upstream_status_code: Number.isInteger(entry.upstream_status_code) ? entry.upstream_status_code : null, + reasoning_tokens: Number.isInteger(entry.reasoning_tokens) ? entry.reasoning_tokens : usageField(entry, "reasoning_tokens"), + input_tokens: usageField(entry, "input_tokens"), + output_tokens: usageField(entry, "output_tokens"), + total_tokens: usageField(entry, "total_tokens"), + cached_tokens: usageField(entry, "cached_tokens"), + error: entry.error || null, + upstream_origin: entry.upstream?.origin || null, + upstream_path: entry.upstream?.path || null, + upstream_auth_mode: entry.upstream?.auth_mode || null, + upstream_auth_source: entry.upstream?.auth_source || null, + payload_json: JSON.stringify(payload), + }; +} + +function insertRequestRow(db, row) { + db.prepare(` + INSERT OR REPLACE INTO requests ( + seq, started_at, finished_at, duration_ms, profile_name, method, path, model, + requested_model, forwarded_model, request_stream, response_stream, inspected, matched, + status_code, upstream_status_code, reasoning_tokens, input_tokens, output_tokens, + total_tokens, cached_tokens, error, upstream_origin, upstream_path, + upstream_auth_mode, upstream_auth_source, payload_json + ) VALUES ( + @seq, @started_at, @finished_at, @duration_ms, @profile_name, @method, @path, @model, + @requested_model, @forwarded_model, @request_stream, @response_stream, @inspected, @matched, + @status_code, @upstream_status_code, @reasoning_tokens, @input_tokens, @output_tokens, + @total_tokens, @cached_tokens, @error, @upstream_origin, @upstream_path, + @upstream_auth_mode, @upstream_auth_source, @payload_json + ) + `).run(row); +} + +async function importRequestsJsonlToDb(db, filePath) { + const rows = await readJsonlFile(filePath); + if (rows.length === 0) { + return 0; + } + const countRow = db.prepare("SELECT COUNT(*) AS count FROM requests").get(); + if ((countRow?.count || 0) >= rows.length) { + return 0; + } + db.exec("BEGIN"); + try { + for (const entry of rows) { + if (!Number.isInteger(entry?.seq)) { + continue; + } + insertRequestRow(db, requestRowFromEntry(entry)); + } + db.exec("COMMIT"); + } catch (error) { + try { + db.exec("ROLLBACK"); + } catch { + // Ignore rollback failures so the original insert error is preserved. + } + throw error; + } + return rows.length; +} + +function parseRequestRowPayload(row) { + if (!row?.payload_json) { + return null; + } + try { + return JSON.parse(row.payload_json); + } catch { + return null; + } +} + +function buildRequestQueryFilters({ query, filter }) { + const clauses = []; + const params = {}; + + const trimmedQuery = `${query || ""}`.trim().toLowerCase(); + if (trimmedQuery) { + clauses.push(` + ( + lower(coalesce(profile_name, '')) LIKE @query OR + lower(coalesce(method, '')) LIKE @query OR + lower(coalesce(path, '')) LIKE @query OR + lower(coalesce(model, '')) LIKE @query OR + lower(coalesce(requested_model, '')) LIKE @query OR + lower(coalesce(forwarded_model, '')) LIKE @query OR + lower(coalesce(error, '')) LIKE @query OR + lower(coalesce(upstream_origin, '')) LIKE @query OR + lower(coalesce(upstream_path, '')) LIKE @query OR + CAST(coalesce(status_code, '') AS TEXT) LIKE @query OR + CAST(coalesce(upstream_status_code, '') AS TEXT) LIKE @query OR + CAST(coalesce(input_tokens, '') AS TEXT) LIKE @query OR + CAST(coalesce(output_tokens, '') AS TEXT) LIKE @query OR + CAST(coalesce(total_tokens, '') AS TEXT) LIKE @query OR + CAST(coalesce(cached_tokens, '') AS TEXT) LIKE @query OR + CAST(coalesce(reasoning_tokens, '') AS TEXT) LIKE @query + ) + `); + params.query = `%${trimmedQuery}%`; + } + + if (filter === "matched") { + clauses.push("matched = 1"); + } else if (filter === "stream") { + clauses.push("response_stream = 1"); + } else if (filter === "error") { + clauses.push("(error IS NOT NULL AND error != '')"); + } + + return { + whereSql: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "", + params, + }; +} + +async function hydrateMonitorFromDisk(monitor, paths, requestHistoryLimit) { + const requestEntries = await readJsonlFile(paths.requestsPath); + monitor.request_entries = []; + monitor.next_request_seq = requestEntries.reduce((maxSeq, entry) => { + return Math.max(maxSeq, Number.isInteger(entry.seq) ? entry.seq + 1 : maxSeq); + }, 1); + for (const entry of requestEntries) { + addTokenTotals(monitor, entry.usage); + } +} + +function createMonitorRecorder(monitor) { + return (message) => { + const entry = { + seq: monitor.next_log_seq, + at: new Date().toISOString(), + message, + }; + monitor.next_log_seq += 1; + monitor.log_entries.push(entry); + return entry; + }; +} + +function createLogger(logPath, recordEntry) { + if (!logPath) { + return (message) => { + const entry = recordEntry ? recordEntry(message) : { at: new Date().toISOString(), message }; + process.stdout.write(`${entry.at} ${entry.message}\n`); + }; + } + + const stream = fs.createWriteStream(logPath, { flags: "a" }); + return (message) => { + const entry = recordEntry ? recordEntry(message) : { at: new Date().toISOString(), message }; + const line = `${entry.at} ${entry.message}\n`; + stream.write(line); + process.stdout.write(line); + }; +} + +function incrementReasoningCount(counter, reasoning) { + if (!Number.isInteger(reasoning)) { + return; + } + const key = `${reasoning}`; + counter[key] = (counter[key] || 0) + 1; +} + +function recordInspectedResponse(monitor, reasoning, matched) { + monitor.inspected_response_count += 1; + incrementReasoningCount(monitor.observed_reasoning_counts, reasoning); + if (matched) { + monitor.matched_response_count += 1; + } +} + +function addTokenTotals(monitor, usage) { + if (!usage) { + return; + } + for (const key of ["input_tokens", "output_tokens", "total_tokens", "reasoning_tokens", "cached_tokens"]) { + const value = usage[key]; + if (Number.isInteger(value)) { + monitor.token_totals[key] += value; + } + } +} + +function upsertRequestEntry(runtime, entry, { persistJsonl = false, includeUsage = false } = {}) { + if (includeUsage) { + addTokenTotals(runtime.monitor, entry.usage); + } + if (runtime.requestsDb) { + insertRequestRow(runtime.requestsDb, requestRowFromEntry(entry)); + } + if (persistJsonl) { + appendJsonl(runtime.paths.requestsPath, buildPersistedRequestPayload(entry)).catch((error) => { + runtime.logger?.(`[requests] failed to persist request seq=${entry.seq}: ${error?.message || error}`); + }); + } +} + +function recordRequestEntry(runtime, entry, limit = DEFAULT_CONFIG.request_history_limit) { + upsertRequestEntry(runtime, entry, { + persistJsonl: true, + includeUsage: true, + }); +} + +function markAndPersistFirstResponse(runtime, entry, at = new Date()) { + if (!markRequestFirstResponse(entry, at)) { + return false; + } + upsertRequestEntry(runtime, entry); + return true; +} + +function summarizeProfileAuthSource(env) { + const mode = env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE || "passthrough"; + if (mode === "auth_json") { + return env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_PATH ? "auth.json path configured" : "~/.codex/auth.json"; + } + if (mode === "manual_bearer") { + const secretPath = env.CODEX_RETRY_GATEWAY_UPSTREAM_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) { + return "token file configured"; + } + if (env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV) { + return `env:${env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV}`; + } + } + return "passthrough"; +} + +function buildProfileFormModel(env) { + const manualSecretFile = env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE || ""; + return { + listen_host: env.CODEX_RETRY_GATEWAY_LISTEN_HOST || "", + listen_port: env.CODEX_RETRY_GATEWAY_LISTEN_PORT || "", + upstream_base_url: env.CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL || "", + auth_mode: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE || "passthrough", + auth_env: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV || "", + auth_file: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE === "manual_bearer" + ? "" + : env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE || "", + manual_secret_file: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE === "manual_bearer" + ? manualSecretFile + : "", + manual_secret_configured: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE === "manual_bearer" + && Boolean(manualSecretFile) + && fs.existsSync(manualSecretFile), + auth_json_path: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_PATH || "", + auth_json_key: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_KEY || "", + request_history_limit: env.CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT || `${DEFAULT_CONFIG.request_history_limit}`, + model_remap: env.CODEX_RETRY_GATEWAY_MODEL_REMAP || "", + reasoning_equals: env.CODEX_RETRY_GATEWAY_REASONING_EQUALS || "", + endpoints: normalizeStringList(env.CODEX_RETRY_GATEWAY_ENDPOINTS || DEFAULT_CONFIG.endpoints, DEFAULT_CONFIG.endpoints), + }; +} + +function buildConfigFromProfileEnv(profileName, env) { + const config = { + ...DEFAULT_CONFIG, + profile_name: profileName, + listen_host: env.CODEX_RETRY_GATEWAY_LISTEN_HOST || DEFAULT_CONFIG.listen_host, + listen_port: env.CODEX_RETRY_GATEWAY_LISTEN_PORT + ? Number.parseInt(`${env.CODEX_RETRY_GATEWAY_LISTEN_PORT}`, 10) + : DEFAULT_CONFIG.listen_port, + upstream_base_url: env.CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL || "", + upstream_auth_mode: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE || DEFAULT_CONFIG.upstream_auth_mode, + upstream_auth_env: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV || DEFAULT_CONFIG.upstream_auth_env, + upstream_auth_file: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE || "", + upstream_auth_json_path: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_PATH || "", + upstream_auth_json_key: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_KEY || DEFAULT_CONFIG.upstream_auth_json_key, + request_body_limit_bytes: env.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES + ? Number.parseInt(`${env.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES}`, 10) + : DEFAULT_CONFIG.request_body_limit_bytes, + request_history_limit: env.CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT + ? Number.parseInt(`${env.CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT}`, 10) + : DEFAULT_CONFIG.request_history_limit, + model_remap: env.CODEX_RETRY_GATEWAY_MODEL_REMAP || "", + endpoints: normalizeStringList(env.CODEX_RETRY_GATEWAY_ENDPOINTS || DEFAULT_CONFIG.endpoints, DEFAULT_CONFIG.endpoints).map(normalizePath), + reasoning_equals: normalizeIntegerList(env.CODEX_RETRY_GATEWAY_REASONING_EQUALS || DEFAULT_CONFIG.reasoning_equals, DEFAULT_CONFIG.reasoning_equals), + non_stream_status_code: env.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE + ? Number.parseInt(`${env.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE}`, 10) + : DEFAULT_CONFIG.non_stream_status_code, + stream_action: env.CODEX_RETRY_GATEWAY_STREAM_ACTION || DEFAULT_CONFIG.stream_action, + log_match: env.CODEX_RETRY_GATEWAY_LOG_MATCH === undefined + ? DEFAULT_CONFIG.log_match + : ["1", "true", "yes", "on"].includes(`${env.CODEX_RETRY_GATEWAY_LOG_MATCH}`.trim().toLowerCase()), + health_path: env.CODEX_RETRY_GATEWAY_HEALTH_PATH || DEFAULT_CONFIG.health_path, + }; + config.model_remap_map = parseModelRemapMap(config.model_remap); + return config; +} + +function assertNoInlineProfileSecret(payload) { + const suspectFields = [ + "auth_env", + "auth_file", + "auth_json_path", + "auth_json_key", + "upstream_base_url", + ]; + for (const field of suspectFields) { + 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 引用"); + } + } +} + +function defaultManualSecretPath(profileName) { + const homeDir = process.env.HOME || ""; + return path.join(homeDir, ".codex-retry-gateway", "secrets", `${profileName}.token`); +} + +async function writeManualSecret(profileName, secretValue) { + const text = `${secretValue || ""}`.trim(); + if (!text) { + return null; + } + + const secretPath = defaultManualSecretPath(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)) { + return text; + } + return JSON.stringify(text); +} + +async function buildProfileEnvText(payload) { + assertNoInlineProfileSecret(payload); + + const name = `${payload?.name || ""}`.trim(); + validateProfileName(name); + + const listenPort = Number.parseInt(`${payload.listen_port || DEFAULT_CONFIG.listen_port}`, 10); + if (!Number.isInteger(listenPort) || listenPort < 1 || listenPort > 65535) { + throw new Error("监听端口必须是 1-65535 的整数"); + } + + const upstreamBaseUrl = `${payload.upstream_base_url || ""}`.trim(); + if (!upstreamBaseUrl) { + throw new Error("上游 Base URL 不能为空"); + } + try { + new URL(upstreamBaseUrl); + } catch { + throw new Error("上游 Base URL 必须是合法 URL"); + } + + const authMode = normalizeAuthMode(payload.auth_mode); + const reasoningEquals = normalizeIntegerList(payload.reasoning_equals, DEFAULT_CONFIG.reasoning_equals); + if (reasoningEquals.length === 0) { + throw new Error("reasoning_equals 不能为空"); + } + + const endpoints = normalizeStringList(payload.endpoints, DEFAULT_CONFIG.endpoints).map(normalizePath); + if (endpoints.length === 0) { + throw new Error("endpoints 不能为空"); + } + + const requestHistoryLimit = Number.parseInt( + `${payload.request_history_limit || DEFAULT_CONFIG.request_history_limit}`, + 10, + ); + if (!Number.isInteger(requestHistoryLimit) || requestHistoryLimit < 0) { + throw new Error("History Limit 必须是 0 或正整数;0 表示不裁剪"); + } + + const envPairs = [ + ["CODEX_RETRY_GATEWAY_LISTEN_HOST", `${payload.listen_host || DEFAULT_CONFIG.listen_host}`.trim()], + ["CODEX_RETRY_GATEWAY_LISTEN_PORT", `${listenPort}`], + ["CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL", upstreamBaseUrl], + ["CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE", authMode], + ["CODEX_RETRY_GATEWAY_REASONING_EQUALS", reasoningEquals.join(",")], + ["CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT", `${requestHistoryLimit}`], + ["CODEX_RETRY_GATEWAY_ENDPOINTS", endpoints.join(",")], + ]; + + const modelRemap = `${payload.model_remap || ""}`.trim(); + if (modelRemap) { + envPairs.push(["CODEX_RETRY_GATEWAY_MODEL_REMAP", modelRemap]); + } + + if (authMode === "manual_bearer") { + const secretPath = + (await writeManualSecret(name, payload.manual_secret)) || + `${payload.manual_secret_file || ""}`.trim() || + defaultManualSecretPath(name); + if (!fs.existsSync(secretPath)) { + throw new Error("manual_bearer 需要手动填入一次 token/password 后才能保存"); + } + envPairs.push(["CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE", secretPath]); + } else if (authMode === "fixed_bearer") { + envPairs.push([ + "CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV", + `${payload.auth_env || DEFAULT_CONFIG.upstream_auth_env}`.trim(), + ]); + if (`${payload.auth_file || ""}`.trim()) { + envPairs.push(["CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE", `${payload.auth_file}`.trim()]); + } + } else if (authMode === "auth_json") { + if (`${payload.auth_json_path || ""}`.trim()) { + envPairs.push(["CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_PATH", `${payload.auth_json_path}`.trim()]); + } + envPairs.push([ + "CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_KEY", + `${payload.auth_json_key || DEFAULT_CONFIG.upstream_auth_json_key}`.trim(), + ]); + } + + const lines = [ + "# Managed by codex-retry-gateway UI.", + "# Do not put raw sk-* secrets here; use env/file/auth.json references.", + ...envPairs.map(([key, value]) => `${key}=${serializeEnvValue(value)}`), + "", + ]; + + return { + name, + content: lines.join("\n"), + }; +} + +async function writeProfile(runtime, payload) { + const { name, content } = await buildProfileEnvText(payload); + await mkdir(runtime.paths.profilesDir, { recursive: true }); + const profilePath = path.join(runtime.paths.profilesDir, `${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; + return { + started_at: monitor.started_at, + total_proxy_request_count: monitor.total_proxy_request_count, + inspected_response_count: inspectedResponseCount, + matched_response_count: monitor.matched_response_count, + reasoning_516_count: reasoning516Count, + reasoning_516_ratio: + inspectedResponseCount === 0 ? 0 : reasoning516Count / inspectedResponseCount, + token_totals: { ...monitor.token_totals }, + observed_reasoning_counts: { ...monitor.observed_reasoning_counts }, + }; +} + +function buildLogsSnapshot(monitor, sinceSeq = null) { + const entries = Number.isInteger(sinceSeq) + ? monitor.log_entries.filter((entry) => entry.seq > sinceSeq) + : monitor.log_entries; + + return { + total_entries: monitor.log_entries.length, + latest_seq: monitor.next_log_seq - 1, + entries, + }; +} + +async function buildPersistentLogsSnapshot(runtime, sinceSeq = null) { + const text = runtime.logPath ? await readOptionalText(runtime.logPath) : null; + if (!text) { + return buildLogsSnapshot(runtime.monitor, sinceSeq); + } + + const allEntries = text + .split(/\r?\n/) + .filter(Boolean) + .map((line, index) => parseLogLine(line, index + 1)); + const entries = Number.isInteger(sinceSeq) + ? allEntries.filter((entry) => entry.seq > sinceSeq) + : allEntries.slice(-500); + + return { + total_entries: allEntries.length, + latest_seq: allEntries.length, + entries, + }; +} + +function buildRequestsSnapshot(monitor, limit = 50) { + const safeLimit = Number.isInteger(limit) && limit > 0 ? Math.min(limit, 500) : 50; + const entries = monitor.request_entries.slice(-safeLimit).reverse(); + return { + total_entries: monitor.request_entries.length, + latest_seq: monitor.next_request_seq - 1, + entries, + }; +} + +async function buildPersistentRequestsSnapshot(runtime, { limit = 50, offset = 0, query = "", filter = "all" } = {}) { + const safeLimit = Number.isInteger(limit) && limit > 0 ? Math.min(limit, 500) : 50; + const safeOffset = Number.isInteger(offset) && offset > 0 ? offset : 0; + if (!runtime.requestsDb) { + const entries = await readJsonlFile(runtime.paths.requestsPath); + return { + total_entries: entries.length, + latest_seq: entries.reduce((maxSeq, entry) => { + return Math.max(maxSeq, Number.isInteger(entry.seq) ? entry.seq : maxSeq); + }, 0), + entries: entries.slice(-safeLimit).reverse(), + }; + } + + const { whereSql, params } = buildRequestQueryFilters({ query, filter }); + const totalRow = runtime.requestsDb.prepare(`SELECT COUNT(*) AS count FROM requests ${whereSql}`).get(params); + const latestRow = runtime.requestsDb.prepare("SELECT MAX(seq) AS latest_seq FROM requests").get(); + const rows = runtime.requestsDb.prepare(` + SELECT payload_json + FROM requests + ${whereSql} + ORDER BY seq DESC + LIMIT @limit OFFSET @offset + `).all({ + ...params, + limit: safeLimit, + offset: safeOffset, + }); + + return { + total_entries: totalRow?.count || 0, + latest_seq: latestRow?.latest_seq || 0, + entries: rows.map(parseRequestRowPayload).filter(Boolean), + }; +} + +function buildRequestEntry({ seq, startedAt, startedMs, req, pathname, requestJson, profileName }) { + return { + seq, + lifecycle_state: "sent", + started_at: startedAt.toISOString(), + first_response_at: null, + first_response_delay_ms: null, + finished_at: null, + duration_ms: null, + profile_name: profileName || "default", + method: req.method, + path: pathname, + request_body_bytes: null, + model: requestJson?.model || null, + requested_model: requestJson?.model || null, + forwarded_model: requestJson?.model || null, + request_stream: Boolean(requestJson?.stream), + response_stream: false, + inspected: false, + matched: false, + status_code: null, + upstream_status_code: null, + upstream: null, + reasoning_tokens: null, + usage: null, + error: null, + _started_ms: startedMs, + }; +} + +function markRequestFirstResponse(entry, at = new Date()) { + if (entry.first_response_at) { + return false; + } + const firstAt = at instanceof Date ? at : new Date(at); + const firstMs = firstAt.getTime(); + entry.first_response_at = firstAt.toISOString(); + entry.first_response_delay_ms = Number.isFinite(entry._started_ms) + ? Math.max(0, firstMs - entry._started_ms) + : null; + entry.lifecycle_state = "receive_first"; + return true; +} + +function finalizeRequestEntry(entry, result = {}) { + const finishedAt = new Date(); + const startedMs = entry._started_ms; + delete entry._started_ms; + return { + ...entry, + ...result, + lifecycle_state: "finish", + finished_at: finishedAt.toISOString(), + duration_ms: Number.isFinite(startedMs) ? Math.max(0, Date.now() - startedMs) : null, + }; +} + +async function loadConfig(configPath) { + const content = await readFile(configPath, "utf8"); + const loaded = JSON.parse(content); + const config = { ...DEFAULT_CONFIG, ...loaded }; + config.model_remap_map = parseModelRemapMap(config.model_remap); + config.endpoints = normalizeStringList(config.endpoints, DEFAULT_CONFIG.endpoints).map(normalizePath); + config.reasoning_equals = normalizeIntegerList( + config.reasoning_equals, + DEFAULT_CONFIG.reasoning_equals, + ); + if (!config.upstream_base_url) { + throw new Error("配置缺少 upstream_base_url"); + } + return config; +} + +function buildRuntimePaths(configPath, logPath) { + const configDirectory = path.dirname(configPath); + const stateRoot = path.dirname(configDirectory); + const homeDir = process.env.HOME || ""; + return { + stateRoot, + statePath: path.join(stateRoot, "state.json"), + pidPath: path.join(stateRoot, "gateway.pid"), + profilesDir: path.join(homeDir, ".config", "codex-retry-gateway", "profiles"), + configPath, + logPath, + requestsPath: path.join(stateRoot, "logs", "requests.jsonl"), + requestsDbPath: path.join(stateRoot, "logs", "requests.sqlite"), + }; +} + +async function readOptionalJson(jsonPath) { + try { + const content = await readFile(jsonPath, "utf8"); + return JSON.parse(content); + } catch { + return null; + } +} + +async function readOptionalText(textPath) { + try { + return await readFile(textPath, "utf8"); + } catch { + return null; + } +} + +function normalizeAuthMode(value) { + const mode = `${value || "passthrough"}`.trim().toLowerCase(); + if (["passthrough", "fixed_bearer", "manual_bearer", "auth_json"].includes(mode)) { + return mode; + } + return "passthrough"; +} + +function sanitizeConfigForStatus(config) { + const { + upstream_auth_file, + upstream_auth_json_path, + upstream_auth_env, + upstream_auth_json_key, + model_remap_map, + ...rest + } = config; + + return { + ...rest, + upstream_auth_env: upstream_auth_env || null, + 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, + }; +} + +function remapRequestModel(config, requestJson) { + if (!requestJson || typeof requestJson !== "object") { + return { requestJson, remapped: false, forwardedModel: null }; + } + const requestedModel = `${requestJson.model || ""}`.trim(); + if (!requestedModel) { + return { requestJson, remapped: false, forwardedModel: null }; + } + const forwardedModel = config.model_remap_map?.[requestedModel]; + if (!forwardedModel || forwardedModel === requestedModel) { + return { requestJson, remapped: false, forwardedModel: requestedModel }; + } + return { + requestJson: { + ...requestJson, + model: forwardedModel, + }, + remapped: true, + forwardedModel, + }; +} + +async function resolveUpstreamAuth(config) { + const mode = normalizeAuthMode(config.upstream_auth_mode); + if (mode === "passthrough") { + return { mode, authorization: null, source: "passthrough" }; + } + + let token = ""; + let source = "missing"; + if (mode === "fixed_bearer") { + const envName = config.upstream_auth_env || DEFAULT_CONFIG.upstream_auth_env; + token = envName ? `${process.env[envName] || ""}`.trim() : ""; + source = token ? "env" : "missing"; + if (!token && config.upstream_auth_file) { + token = `${(await readOptionalText(config.upstream_auth_file)) || ""}`.trim(); + source = token ? "file" : "file_empty"; + } + } else if (mode === "manual_bearer") { + if (config.upstream_auth_file) { + token = `${(await readOptionalText(config.upstream_auth_file)) || ""}`.trim(); + source = token ? "manual_file" : "manual_file_empty"; + } + } else if (mode === "auth_json") { + const authPath = config.upstream_auth_json_path || path.join(process.env.HOME || "", ".codex", "auth.json"); + const key = config.upstream_auth_json_key || DEFAULT_CONFIG.upstream_auth_json_key; + const authJson = await readOptionalJson(authPath); + token = `${authJson?.[key] || ""}`.trim(); + source = token ? "auth_json" : "auth_json_missing"; + } + + if (!token) { + throw new Error(`upstream_auth_mode=${mode} requires a configured token source`); + } + + const authorization = token.toLowerCase().startsWith("bearer ") ? token : `Bearer ${token}`; + return { mode, authorization, source }; +} + +async function writeConfig(configPath, config) { + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); +} + +async function updateRuntimeState(runtime, updates) { + const current = await readOptionalJson(runtime.paths.statePath); + if (!current) { + return; + } + await writeFile( + runtime.paths.statePath, + `${JSON.stringify({ ...current, ...updates }, null, 2)}\n`, + "utf8", + ); +} + +async function listProfiles(runtime) { + let files = []; + try { + files = await readdir(runtime.paths.profilesDir, { withFileTypes: true }); + } catch { + files = []; + } + + const activeProfile = runtime.config.profile_name || "default"; + 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.profilesDir, file.name); + const env = parseEnvText((await readOptionalText(filePath)) || ""); + const form = buildProfileFormModel(env); + profiles.push({ + name, + active: name === activeProfile, + file_path: filePath, + summary: { + listen_host: form.listen_host, + listen_port: form.listen_port, + upstream_base_url: form.upstream_base_url, + auth_mode: form.auth_mode, + auth_env: redactProfileValue( + "CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV", + form.auth_env, + ), + auth_file: form.auth_file ? "[configured]" : "", + auth_json_path: form.auth_json_path ? "[configured]" : "", + auth_json_key: redactProfileValue( + "CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_KEY", + form.auth_json_key, + ), + request_history_limit: form.request_history_limit, + model_remap: form.model_remap || "", + auth_source: summarizeProfileAuthSource(env), + reasoning_equals: form.reasoning_equals, + }, + form, + }); + } + profiles.sort((left, right) => left.name.localeCompare(right.name)); + return profiles; +} + +function runDetached(command, args) { + const child = spawn(command, args, { + detached: true, + stdio: "ignore", + windowsHide: true, + }); + child.unref(); + return child.pid; +} + +async function triggerProfileSwitch(runtime, profileName) { + if (!/^[A-Za-z0-9_.-]+$/.test(profileName)) { + throw new Error("profile 名称只能包含字母、数字、下划线、点和短横线"); + } + + const profilePath = path.join(runtime.paths.profilesDir, `${profileName}.env`); + if (!fs.existsSync(profilePath)) { + throw new Error(`profile 不存在: ${profileName}`); + } + + const currentUnit = `codex-retry-gateway@${runtime.config.profile_name || "default"}.service`; + const targetUnit = `codex-retry-gateway@${profileName}.service`; + const switchScript = [ + "set -e", + `systemctl --user disable --now ${currentUnit}`, + `systemctl --user enable --now ${targetUnit}`, + ].join("\n"); + + const unitName = `codex-retry-gateway-switch-${Date.now()}`; + const pid = runDetached("systemd-run", [ + "--user", + "--collect", + `--unit=${unitName}`, + "--on-active=1s", + "/usr/bin/env", + "bash", + "-lc", + switchScript, + ]); + + return { + profile: profileName, + current_unit: currentUnit, + target_unit: targetUnit, + switch_unit: `${unitName}.service`, + pid, + }; +} + +async function applyProfileConfig(runtime, profileName) { + const { profilePath, config } = await loadProfileConfigForProbe(runtime, profileName); + if (!canHotSwapProfile(runtime.config, config)) { + throw new Error("该 profile 的监听地址或端口与当前实例不同,暂不支持无重启热切换"); + } + + runtime.config = { + ...config, + model_remap_map: parseModelRemapMap(config.model_remap), + }; + + await writeConfig(runtime.configPath, runtime.config); + await updateRuntimeState(runtime, { + profile_name: runtime.config.profile_name || "default", + profile_env_path: profilePath, + gateway_base_url: `http://${runtime.config.listen_host}:${runtime.config.listen_port}`, + last_started_at: new Date().toISOString(), + }); + runtime.logger( + `[profile] hot-swapped profile=${runtime.config.profile_name || "default"} auth=${normalizeAuthMode(runtime.config.upstream_auth_mode)} upstream=${runtime.config.upstream_base_url}`, + ); + + return { + profile: runtime.config.profile_name || "default", + profile_env_path: profilePath, + hot_swapped: true, + listen: `${runtime.config.listen_host}:${runtime.config.listen_port}`, + upstream_base_url: runtime.config.upstream_base_url, + }; +} + +async function loadProfileConfigForProbe(runtime, profileName) { + validateProfileName(profileName); + const profilePath = path.join(runtime.paths.profilesDir, `${profileName}.env`); + if (!fs.existsSync(profilePath)) { + throw new Error(`profile 不存在: ${profileName}`); + } + const env = parseEnvText((await readOptionalText(profilePath)) || ""); + const config = buildConfigFromProfileEnv(profileName, env); + if (!config.upstream_base_url) { + throw new Error(`profile ${profileName} 缺少 upstream_base_url`); + } + return { profilePath, env, config }; +} + +async function deleteProfile(runtime, profileName) { + validateProfileName(profileName); + const activeProfile = runtime.config.profile_name || "default"; + if (profileName === activeProfile) { + throw new Error("不能删除当前正在运行的 profile;请先切换到其他 profile"); + } + + const profilePath = path.join(runtime.paths.profilesDir, `${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(); + let summary = text.slice(0, maxChars); + if (contentType.includes("application/json")) { + try { + const payload = JSON.parse(text); + if (Array.isArray(payload?.data)) { + summary = JSON.stringify(payload.data.slice(0, 8).map((item) => item.id ?? item.display_name ?? item), null, 2); + } else if (payload?.error) { + summary = JSON.stringify(payload.error); + } else { + summary = text.slice(0, maxChars); + } + } catch { + summary = text.slice(0, maxChars); + } + } + return { + content_type: contentType, + body_preview: summary, + }; +} + +async function probeProfile(runtime, payload) { + const profileName = `${payload?.profile || ""}`.trim(); + if (!profileName) { + throw new Error("缺少 profile"); + } + + const { config } = await loadProfileConfigForProbe(runtime, profileName); + const upstreamAuth = await resolveUpstreamAuth(config); + const result = { + profile: profileName, + upstream_base_url: config.upstream_base_url, + auth_mode: upstreamAuth.mode, + auth_source: upstreamAuth.source, + authorization_configured: Boolean(upstreamAuth.authorization), + model_remap: config.model_remap || "", + probes: [], + }; + + const modelsUrl = buildUpstreamUrl(config.upstream_base_url, new URL("http://local/v1/models")); + const modelsResponse = await fetchUpstreamWithRetry(modelsUrl, { + method: "GET", + headers: cloneHeadersForUpstream({}, upstreamAuth), + }, runtime.logger); + result.probes.push({ + kind: "models", + target: "/v1/models", + status: modelsResponse.status, + ...await readProbeBodySummary(modelsResponse, 600), + }); + + const requestedModel = `${payload?.model || ""}`.trim(); + if (requestedModel) { + const { requestJson, forwardedModel } = remapRequestModel(config, { + model: requestedModel, + input: `${payload?.input || "ping"}`, + max_output_tokens: 1, + stream: false, + }); + const responsesUrl = buildUpstreamUrl(config.upstream_base_url, new URL("http://local/v1/responses")); + const responseProbe = await fetchUpstreamWithRetry(responsesUrl, { + method: "POST", + headers: cloneHeadersForUpstream({ "content-type": "application/json" }, upstreamAuth), + body: JSON.stringify(requestJson), + }, runtime.logger); + result.probes.push({ + kind: "responses", + target: "/v1/responses", + requested_model: requestedModel, + forwarded_model: forwardedModel || requestedModel, + status: responseProbe.status, + ...await readProbeBodySummary(responseProbe, 600), + }); + } + + return result; +} + +function extractProviderBaseUrl(content, providerName) { + if (!content || !providerName) { + return null; + } + + const sectionPattern = new RegExp( + String.raw`^\[model_providers\.${providerName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\]\s*$[\s\S]*?(?=^\[|\Z)`, + "m", + ); + const sectionMatch = content.match(sectionPattern); + if (!sectionMatch) { + return null; + } + + const baseUrlMatch = sectionMatch[0].match(/^\s*base_url\s*=\s*"([^"]+)"\s*$/m); + return baseUrlMatch ? baseUrlMatch[1] : null; +} + +async function readRuntimeState(runtime) { + const state = await readOptionalJson(runtime.paths.statePath); + if (!state) { + return null; + } + + let codexCurrentBaseUrl = null; + if (state.codex_config_path && state.provider_name) { + try { + const codexConfig = await readFile(state.codex_config_path, "utf8"); + codexCurrentBaseUrl = extractProviderBaseUrl(codexConfig, state.provider_name); + } catch { + codexCurrentBaseUrl = null; + } + } + + return { + ...state, + codex_current_base_url: codexCurrentBaseUrl, + }; +} + +async function restoreRuntimeState(runtime, state) { + const backupPath = state?.latest_backup_path; + const codexConfigPath = state?.codex_config_path; + + if (!backupPath || !fs.existsSync(backupPath)) { + throw new Error(`未找到可恢复备份: ${backupPath || "unknown"}`); + } + if (!codexConfigPath) { + throw new Error("安装状态里缺少 codex_config_path"); + } + + await copyFile(backupPath, codexConfigPath); + await Promise.all([ + rm(runtime.paths.statePath, { force: true }), + rm(runtime.paths.pidPath, { force: true }), + ]); +} + +function jsonResponse(res, statusCode, payload, headers = {}) { + res.writeHead(statusCode, { + "content-type": "application/json; charset=utf-8", + ...headers, + }); + res.end(JSON.stringify(payload)); +} + + +const STATIC_CONTENT_TYPES = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +function contentTypeForFile(filePath) { + return STATIC_CONTENT_TYPES[path.extname(filePath).toLowerCase()] || "application/octet-stream"; +} + +function safeJoinStatic(root, requestPath) { + const decoded = decodeURIComponent(requestPath); + const relative = decoded.replace(/^\/+/, ""); + const fullPath = path.resolve(root, relative); + const rootPath = path.resolve(root); + if (fullPath !== rootPath && !fullPath.startsWith(`${rootPath}${path.sep}`)) { + return null; + } + return fullPath; +} + +async function serveStaticFile(res, filePath) { + try { + const body = await readFile(filePath); + res.writeHead(200, { + "content-type": contentTypeForFile(filePath), + "cache-control": filePath.includes(`${path.sep}assets${path.sep}`) + ? "public, max-age=31536000, immutable" + : "no-cache", + }); + res.end(body); + return true; + } catch { + return false; + } +} + +async function serveManagementUi(res, requestPathname) { + const uiPrefix = `${UI_PATH}/`; + if (requestPathname === UI_PATH || requestPathname === `${UI_PATH}/`) { + return serveStaticFile(res, path.join(UI_STATIC_ROOT, "index.html")); + } + + if (!requestPathname.startsWith(uiPrefix)) { + return false; + } + + const staticPath = safeJoinStatic(UI_STATIC_ROOT, requestPathname.slice(uiPrefix.length)); + if (!staticPath) { + jsonResponse(res, 403, { + error: { + message: "invalid static path", + code: "invalid_static_path", + }, + }); + return true; + } + + if (await serveStaticFile(res, staticPath)) { + return true; + } + + return serveStaticFile(res, path.join(UI_STATIC_ROOT, "index.html")); +} + +function buildEditableConfig(currentConfig, payload) { + const nextReasoning = normalizeIntegerList(payload.reasoning_equals, currentConfig.reasoning_equals); + const nextEndpoints = normalizeStringList(payload.endpoints, currentConfig.endpoints).map(normalizePath); + const nextStatusCode = + payload.non_stream_status_code === undefined + ? currentConfig.non_stream_status_code + : Number.parseInt(`${payload.non_stream_status_code}`, 10); + + if (nextReasoning.length === 0) { + throw new Error("reasoning_equals 不能为空"); + } + if (nextEndpoints.length === 0) { + throw new Error("endpoints 不能为空"); + } + if (!Number.isInteger(nextStatusCode) || nextStatusCode < 100 || nextStatusCode > 599) { + throw new Error("non_stream_status_code 必须是 100-599 的整数"); + } + + return { + ...currentConfig, + reasoning_equals: nextReasoning, + endpoints: nextEndpoints, + non_stream_status_code: nextStatusCode, + log_match: payload.log_match === undefined ? currentConfig.log_match : Boolean(payload.log_match), + }; +} + +async function handleManagementRequest(runtime, req, res, requestUrl) { + const pathname = normalizePath(requestUrl.pathname); + + if (pathname === UI_PATH || pathname.startsWith(`${UI_PATH}/`)) { + if (!(await serveManagementUi(res, requestUrl.pathname))) { + jsonResponse(res, 503, { + error: { + message: "UI assets were not built. Run: npm run build:ui", + code: "ui_not_built", + }, + }); + } + return true; + } + + if (pathname === STATUS_API_PATH && req.method === "GET") { + const state = await readRuntimeState(runtime); + jsonResponse(res, 200, { + ok: true, + listen: `${runtime.config.listen_host}:${runtime.config.listen_port}`, + config: sanitizeConfigForStatus(runtime.config), + state, + paths: { + config_path: runtime.configPath, + state_path: runtime.paths.statePath, + state_root: runtime.paths.stateRoot, + log_path: runtime.logPath, + requests_path: runtime.paths.requestsPath, + profiles_dir: runtime.paths.profilesDir, + }, + metrics: buildMetricsSnapshot(runtime.monitor), + }); + return true; + } + + if (pathname === LOGS_API_PATH && req.method === "GET") { + const sinceSeqRaw = requestUrl.searchParams.get("since_seq"); + const sinceSeq = sinceSeqRaw === null ? null : Number.parseInt(sinceSeqRaw, 10); + jsonResponse(res, 200, { + ok: true, + ...await buildPersistentLogsSnapshot(runtime, Number.isInteger(sinceSeq) ? sinceSeq : null), + }); + return true; + } + + if (pathname === REQUESTS_API_PATH && req.method === "GET") { + const limitRaw = requestUrl.searchParams.get("limit"); + const offsetRaw = requestUrl.searchParams.get("offset"); + const query = requestUrl.searchParams.get("query") || ""; + const filter = requestUrl.searchParams.get("filter") || "all"; + const limit = limitRaw === null ? 50 : Number.parseInt(limitRaw, 10); + const offset = offsetRaw === null ? 0 : Number.parseInt(offsetRaw, 10); + jsonResponse(res, 200, { + ok: true, + ...await buildPersistentRequestsSnapshot(runtime, { + limit: Number.isInteger(limit) ? limit : 50, + offset: Number.isInteger(offset) ? offset : 0, + query, + filter, + }), + }); + return true; + } + + if (pathname === PROFILES_API_PATH && req.method === "GET") { + jsonResponse(res, 200, { + ok: true, + profiles_dir: runtime.paths.profilesDir, + active_profile: runtime.config.profile_name || "default", + profiles: await listProfiles(runtime), + }); + return true; + } + + if (pathname === PROFILES_API_PATH && req.method === "POST") { + const body = await readRequestBody(req, runtime.config.request_body_limit_bytes); + const payload = parseJsonSafely(body); + if (!payload) { + jsonResponse(res, 400, { + error: { + message: "profile 保存请求必须是有效 JSON", + code: "invalid_json", + }, + }); + return true; + } + + const result = await writeProfile(runtime, payload); + let applied = null; + if (result.name === (runtime.config.profile_name || "default")) { + applied = await applyProfileConfig(runtime, result.name); + } + runtime.logger(`[profile] saved name=${result.name} path=${result.file_path}`); + jsonResponse(res, 200, { + ok: true, + message: applied ? "profile 已保存并已热应用" : "profile 已保存", + saved_profile: result, + applied_profile: applied, + profiles_dir: runtime.paths.profilesDir, + active_profile: runtime.config.profile_name || "default", + profiles: await listProfiles(runtime), + }); + return true; + } + + if (pathname === 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(res, 400, { + error: { + message: "profile probe 请求必须是有效 JSON", + code: "invalid_json", + }, + }); + return true; + } + + const result = await probeProfile(runtime, payload); + runtime.logger( + `[profile-probe] profile=${result.profile} auth=${result.auth_mode}/${result.auth_source} upstream=${result.upstream_base_url}`, + ); + jsonResponse(res, 200, { + ok: true, + ...result, + }); + return true; + } + + if (pathname === 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(res, 400, { + error: { + message: "缺少 profile", + code: "profile_required", + }, + }); + return true; + } + + const result = await applyProfileConfig(runtime, profileName); + jsonResponse(res, 200, { + ok: true, + message: "profile 已热切换,无需重启 gateway", + ...result, + }); + return true; + } + + if (pathname.startsWith(PROFILE_ITEM_API_PREFIX) && req.method === "DELETE") { + const rawName = pathname.slice(PROFILE_ITEM_API_PREFIX.length); + if (!rawName || rawName.includes("/")) { + jsonResponse(res, 400, { + error: { + message: "无效的 profile 名称", + code: "invalid_profile_name", + }, + }); + return true; + } + + const profileName = decodeURIComponent(rawName); + const result = await deleteProfile(runtime, profileName); + runtime.logger(`[profile] deleted name=${result.name} path=${result.file_path}`); + jsonResponse(res, 200, { + ok: true, + message: "profile 已删除", + deleted_profile: result, + profiles_dir: runtime.paths.profilesDir, + active_profile: runtime.config.profile_name || "default", + profiles: await listProfiles(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); + if (!payload) { + jsonResponse(res, 400, { + error: { + message: "配置保存请求必须是有效 JSON", + code: "invalid_json", + }, + }); + return true; + } + + const nextConfig = buildEditableConfig(runtime.config, payload); + await writeConfig(runtime.configPath, nextConfig); + runtime.config = nextConfig; + runtime.logger( + `[config] updated reasoning_equals=${nextConfig.reasoning_equals.join(",")} endpoints=${nextConfig.endpoints.join(",")}`, + ); + const state = await readRuntimeState(runtime); + jsonResponse(res, 200, { + ok: true, + message: "配置已保存并立即生效", + config: sanitizeConfigForStatus(runtime.config), + state, + paths: { + config_path: runtime.configPath, + state_path: runtime.paths.statePath, + state_root: runtime.paths.stateRoot, + log_path: runtime.logPath, + }, + metrics: buildMetricsSnapshot(runtime.monitor), + }); + return true; + } + + if (pathname === RESTORE_API_PATH && req.method === "POST") { + const state = await readRuntimeState(runtime); + if (!state) { + jsonResponse(res, 409, { + error: { + message: "当前未检测到安装状态,无法恢复 Codex 原设置", + code: "state_not_found", + }, + }); + return true; + } + + await restoreRuntimeState(runtime, state); + runtime.logger(`[restore] restored via UI state_root=${runtime.paths.stateRoot}`); + jsonResponse(res, 202, { + ok: true, + message: "原设置已恢复,gateway 即将关闭", + }); + res.on("finish", () => { + const exitTimer = setTimeout(() => { + if (runtime.server) { + runtime.server.close(() => { + process.exit(0); + }); + } else { + process.exit(0); + } + + const hardExitTimer = setTimeout(() => { + process.exit(0); + }, 600); + hardExitTimer.unref(); + }, 120); + exitTimer.unref(); + }); + return true; + } + + return false; +} + +function buildUpstreamUrl(baseUrl, requestUrl) { + const upstream = new URL(baseUrl); + const normalizedBasePath = upstream.pathname.endsWith("/") + ? upstream.pathname.slice(0, -1) + : upstream.pathname; + const incomingPath = requestUrl.pathname; + + let finalPath = incomingPath; + if (normalizedBasePath && normalizedBasePath !== "/") { + if (incomingPath.startsWith(`${normalizedBasePath}/`) || incomingPath === normalizedBasePath) { + finalPath = incomingPath; + } else if (normalizedBasePath.endsWith("/v1") && incomingPath.startsWith("/v1/")) { + finalPath = `${normalizedBasePath}${incomingPath.slice(3)}`; + } else { + finalPath = `${normalizedBasePath}${incomingPath}`; + } + } + + upstream.pathname = finalPath; + upstream.search = requestUrl.search; + return upstream.toString(); +} + +function buildUpstreamSnapshot({ upstreamUrl, upstreamAuth = null, upstreamResponse = null }) { + const parsedUrl = new URL(upstreamUrl); + const snapshot = { + origin: parsedUrl.origin, + path: parsedUrl.pathname, + auth_mode: upstreamAuth?.mode || "unknown", + auth_source: upstreamAuth?.source || "unknown", + authorization_configured: Boolean(upstreamAuth?.authorization), + }; + + if (upstreamResponse) { + snapshot.status = upstreamResponse.status; + snapshot.content_type = upstreamResponse.headers.get("content-type") || ""; + } + + return snapshot; +} + +function cloneHeadersForUpstream(headers, upstreamAuth = null) { + const outgoing = new Headers(); + for (const [key, value] of Object.entries(headers)) { + if (value === undefined) { + continue; + } + const lowerKey = key.toLowerCase(); + if ( + lowerKey === "host" || + lowerKey === "content-length" || + lowerKey === "connection" || + lowerKey === "transfer-encoding" + ) { + continue; + } + if (Array.isArray(value)) { + for (const item of value) { + outgoing.append(key, item); + } + } else { + outgoing.set(key, value); + } + } + if (upstreamAuth?.authorization) { + outgoing.set("authorization", upstreamAuth.authorization); + } + return outgoing; +} + +function copyHeadersToClient(sourceHeaders, target) { + for (const [key, value] of sourceHeaders.entries()) { + const lowerKey = key.toLowerCase(); + if ( + lowerKey === "content-length" || + lowerKey === "transfer-encoding" || + lowerKey === "content-encoding" || + lowerKey === "connection" + ) { + continue; + } + target.setHeader(key, value); + } +} + +async function readRequestBody(req, limitBytes) { + const chunks = []; + let total = 0; + for await (const chunk of req) { + total += chunk.length; + if (total > limitBytes) { + throw new Error(`请求体超过限制: ${limitBytes} bytes`); + } + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + +function parseJsonSafely(buffer) { + try { + return JSON.parse(buffer.toString("utf8")); + } catch { + return null; + } +} + +function matchPath(config, pathname) { + return config.endpoints.includes(normalizePath(pathname)); +} + function reasoningMatched(config, reasoning) { return reasoning !== null && config.reasoning_equals.includes(reasoning); } @@ -1225,6 +2013,10 @@ function isRetryableUpstreamFetchError(error) { return error instanceof TypeError && error.message === "fetch failed"; } +function isUpstreamErrorStatus(statusCode) { + return Number.isInteger(statusCode) && statusCode >= 400; +} + async function fetchUpstreamWithRetry(upstreamUrl, init, logger) { const maxAttempts = 2; let lastError = null; @@ -1243,88 +2035,115 @@ async function fetchUpstreamWithRetry(upstreamUrl, init, logger) { throw lastError; } - -function inspectSseChunk(state, chunk) { - const decoded = state.decoder.decode(chunk, { stream: true }); - state.buffer += decoded; - - const blocks = state.buffer.split(/\r?\n\r?\n/); - state.buffer = blocks.pop() ?? ""; - - for (const block of blocks) { - const lines = block - .split(/\r?\n/) - .map((line) => line.trimEnd()) - .filter(Boolean); - const dataLines = lines - .filter((line) => line.startsWith("data:")) - .map((line) => line.replace(/^data:\s?/, "")); - - if (dataLines.length === 0) { - continue; - } - const payloadText = dataLines.join("\n"); - if (payloadText === "[DONE]") { - continue; - } - try { - const parsed = JSON.parse(payloadText); - const reasoning = extractReasoningTokens(parsed); - if (reasoning !== null) { - return reasoning; - } - } catch { - // ignore malformed SSE payloads - } - } - return null; -} - -async function handleNonStreaming({ - config, - logger, - monitor, - pathname, - upstreamResponse, - res, -}) { - const bodyBuffer = Buffer.from(await upstreamResponse.arrayBuffer()); - const parsed = isJsonContentType(upstreamResponse.headers.get("content-type")) - ? parseJsonSafely(bodyBuffer) - : null; - const reasoning = parsed ? extractReasoningTokens(parsed) : null; - const matched = reasoningMatched(config, reasoning); - - recordInspectedResponse(monitor, reasoning, matched); - - if (matched) { - if (config.log_match) { - logger( - `[match] non-stream path=${pathname} reasoning_tokens=${reasoning} action=status_${config.non_stream_status_code}`, - ); - } - const blockedBody = buildBlockedBody(pathname, reasoning, config.non_stream_status_code); - res.writeHead(config.non_stream_status_code, { - "content-type": "application/json; charset=utf-8", - "x-codex-retry-gateway-reason": "reasoning-guard-triggered", - }); - res.end(blockedBody); - return; - } - - copyHeadersToClient(upstreamResponse.headers, res); - res.writeHead(upstreamResponse.status); - res.end(bodyBuffer); -} - -async function handleStreaming({ + +function inspectSseChunk(state, chunk) { + const decoded = state.decoder.decode(chunk, { stream: true }); + state.buffer += decoded; + + const result = { + reasoning: null, + usage: null, + }; + + const blocks = state.buffer.split(/\r?\n\r?\n/); + state.buffer = blocks.pop() ?? ""; + + for (const block of blocks) { + const lines = block + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter(Boolean); + const dataLines = lines + .filter((line) => line.startsWith("data:")) + .map((line) => line.replace(/^data:\s?/, "")); + + if (dataLines.length === 0) { + continue; + } + const payloadText = dataLines.join("\n"); + if (payloadText === "[DONE]") { + continue; + } + try { + const parsed = JSON.parse(payloadText); + const reasoning = extractReasoningTokens(parsed); + if (reasoning !== null) { + result.reasoning = reasoning; + } + result.usage = mergeUsageSnapshots(result.usage, normalizeUsageSnapshot(parsed)); + } catch { + // ignore malformed SSE payloads + } + } + return result; +} + +async function handleNonStreaming({ + runtime, config, logger, monitor, - pathname, + pathname, + upstreamResponse, + res, + requestEntry, +}) { + markAndPersistFirstResponse(runtime, requestEntry); + const bodyBuffer = Buffer.from(await upstreamResponse.arrayBuffer()); + const parsed = isJsonContentType(upstreamResponse.headers.get("content-type")) + ? parseJsonSafely(bodyBuffer) + : null; + const reasoning = parsed ? extractReasoningTokens(parsed) : null; + const usage = parsed ? normalizeUsageSnapshot(parsed) : null; + const matched = reasoningMatched(config, reasoning); + + recordInspectedResponse(monitor, reasoning, matched); + + if (matched) { + if (config.log_match) { + logger( + `[match] non-stream path=${pathname} reasoning_tokens=${reasoning} action=status_${config.non_stream_status_code}`, + ); + } + const blockedBody = buildBlockedBody(pathname, reasoning, config.non_stream_status_code); + res.writeHead(config.non_stream_status_code, { + "content-type": "application/json; charset=utf-8", + "x-codex-retry-gateway-reason": "reasoning-guard-triggered", + }); + res.end(blockedBody); + return { + inspected: true, + matched, + status_code: config.non_stream_status_code, + upstream_status_code: upstreamResponse.status, + reasoning_tokens: reasoning, + usage, + }; + } + + copyHeadersToClient(upstreamResponse.headers, res); + res.writeHead(upstreamResponse.status); + res.end(bodyBuffer); + return { + inspected: true, + matched, + status_code: upstreamResponse.status, + upstream_status_code: upstreamResponse.status, + reasoning_tokens: reasoning, + usage, + }; +} + +async function handleStreaming({ + runtime, + config, + logger, + monitor, + pathname, upstreamResponse, res, abortController, + requestEntry, }) { const strict502Mode = config.stream_action !== "disconnect"; const reader = upstreamResponse.body.getReader(); @@ -1335,6 +2154,7 @@ async function handleStreaming({ let wroteAnyChunk = false; let observedReasoning = null; + let observedUsage = null; const bufferedChunks = []; if (!strict502Mode) { @@ -1353,10 +2173,27 @@ async function handleStreaming({ logger?.(`[stream] upstream terminated before completion path=${pathname} action=status_502`); res.writeHead(502, { "content-type": "application/json; charset=utf-8" }); res.end(buildGatewayErrorBody("upstream stream terminated before completion")); + return { + inspected: true, + matched: false, + status_code: 502, + upstream_status_code: upstreamResponse.status, + reasoning_tokens: observedReasoning, + usage: observedUsage, + error: "upstream stream terminated before completion", + }; } else { res.end(); + return { + inspected: true, + matched: false, + status_code: upstreamResponse.status, + upstream_status_code: upstreamResponse.status, + reasoning_tokens: observedReasoning, + usage: observedUsage, + error: "upstream stream terminated before completion", + }; } - return; } throw error; } @@ -1371,20 +2208,30 @@ async function handleStreaming({ } else { res.end(); } - return; + return { + inspected: true, + matched: false, + status_code: upstreamResponse.status, + upstream_status_code: upstreamResponse.status, + reasoning_tokens: observedReasoning, + usage: observedUsage, + }; } const chunkBuffer = Buffer.from(value); - const reasoning = inspectSseChunk(sseState, value); + markAndPersistFirstResponse(runtime, requestEntry); + const inspection = inspectSseChunk(sseState, value); + const reasoning = inspection.reasoning; + observedUsage = mergeUsageSnapshots(observedUsage, inspection.usage); if (Number.isInteger(reasoning)) { observedReasoning = reasoning; } if (reasoningMatched(config, reasoning)) { - recordInspectedResponse(monitor, reasoning, true); - if (config.log_match) { - logger( - `[match] stream path=${pathname} reasoning_tokens=${reasoning} action=${config.stream_action}`, - ); + recordInspectedResponse(monitor, reasoning, true); + if (config.log_match) { + logger( + `[match] stream path=${pathname} reasoning_tokens=${reasoning} action=${config.stream_action}`, + ); } if (strict502Mode || !wroteAnyChunk) { @@ -1400,8 +2247,15 @@ async function handleStreaming({ abortController.abort(); reader.cancel().catch(() => {}); res.socket?.destroy(); - } - return; + } + return { + inspected: true, + matched: true, + status_code: config.non_stream_status_code, + upstream_status_code: upstreamResponse.status, + reasoning_tokens: reasoning, + usage: observedUsage, + }; } if (strict502Mode) { @@ -1412,134 +2266,256 @@ async function handleStreaming({ } } } - -async function proxyRequest(runtime, req, res) { - const { logger } = runtime; - const config = runtime.config; - const incomingUrl = new URL(req.url, `http://${req.headers.host || "127.0.0.1"}`); - const pathname = normalizePath(incomingUrl.pathname); - - if (pathname === config.health_path) { - res.writeHead(200, { "content-type": "application/json; charset=utf-8" }); - res.end( - JSON.stringify({ - ok: true, - listen: `${config.listen_host}:${config.listen_port}`, - upstream_base_url: config.upstream_base_url, - ui_path: UI_PATH, - }), - ); - return; - } - - if (await handleManagementRequest(runtime, req, res, incomingUrl)) { - return; - } - - runtime.monitor.total_proxy_request_count += 1; - - const requestBody = await readRequestBody(req, config.request_body_limit_bytes); - const requestJson = isJsonContentType(req.headers["content-type"]) - ? parseJsonSafely(requestBody) - : null; - const requestIsStream = Boolean(requestJson?.stream); - - const upstreamUrl = buildUpstreamUrl(config.upstream_base_url, incomingUrl); - const abortController = new AbortController(); - - const upstreamResponse = await fetchUpstreamWithRetry(upstreamUrl, { - method: req.method, - headers: cloneHeadersForUpstream(req.headers), - body: requestBody.length > 0 ? requestBody : undefined, - signal: abortController.signal, - }, logger); - - const shouldInspect = matchPath(config, pathname); - const responseIsStream = - requestIsStream || isSseContentType(upstreamResponse.headers.get("content-type")); - - if (!shouldInspect) { - copyHeadersToClient(upstreamResponse.headers, res); - res.writeHead(upstreamResponse.status); - const body = Buffer.from(await upstreamResponse.arrayBuffer()); - res.end(body); - return; - } - - if (responseIsStream) { - await handleStreaming({ - config, - logger, - monitor: runtime.monitor, - pathname, - upstreamResponse, - res, - abortController, - }); - return; - } - - await handleNonStreaming({ - config, - logger, - monitor: runtime.monitor, - pathname, - upstreamResponse, - res, - }); -} - -async function main() { - const args = parseArgs(process.argv); - const configPath = args.config || path.join(__dirname, "config.json"); - const config = await loadConfig(configPath); - const monitor = createMonitor(); - - if (args.log) { - await mkdir(path.dirname(args.log), { recursive: true }); - } - const logger = createLogger(args.log, createMonitorRecorder(monitor)); - const runtime = { - config, - configPath, - logPath: args.log || null, - logger, - monitor, - paths: buildRuntimePaths(configPath, args.log || null), - server: null, - }; - - const server = http.createServer(async (req, res) => { - try { - await proxyRequest(runtime, req, res); - } catch (error) { - logger(`[error] ${error?.stack || error}`); - if (!res.headersSent) { - res.writeHead(502, { "content-type": "application/json; charset=utf-8" }); - res.end( - JSON.stringify({ - error: { - message: `${error?.message || error}`, - type: "codex_retry_gateway_error", - code: "gateway_error", - }, - }), - ); - } else { - res.socket?.destroy(); - } - } - }); - runtime.server = server; - - server.listen(config.listen_port, config.listen_host, () => { - logger( - `[start] codex retry gateway listening on http://${config.listen_host}:${config.listen_port} -> ${config.upstream_base_url}`, - ); - }); -} - -main().catch((error) => { - process.stderr.write(`${error?.stack || error}\n`); - process.exit(1); -}); + +async function proxyRequest(runtime, req, res) { + const { logger } = runtime; + const config = runtime.config; + const requestStartedAt = new Date(); + const requestStartedMs = Date.now(); + const incomingUrl = new URL(req.url, `http://${req.headers.host || "127.0.0.1"}`); + const pathname = normalizePath(incomingUrl.pathname); + + if (pathname === "/favicon.ico") { + res.writeHead(204, { "cache-control": "public, max-age=86400" }); + res.end(); + return; + } + + if (pathname === config.health_path) { + res.writeHead(200, { "content-type": "application/json; charset=utf-8" }); + res.end( + JSON.stringify({ + ok: true, + listen: `${config.listen_host}:${config.listen_port}`, + upstream_base_url: config.upstream_base_url, + ui_path: UI_PATH, + }), + ); + return; + } + + if (await handleManagementRequest(runtime, req, res, incomingUrl)) { + return; + } + + runtime.monitor.total_proxy_request_count += 1; + const requestSeq = runtime.monitor.next_request_seq; + runtime.monitor.next_request_seq += 1; + const requestEntry = buildRequestEntry({ + seq: requestSeq, + startedAt: requestStartedAt, + startedMs: requestStartedMs, + req, + pathname, + requestJson: null, + profileName: config.profile_name || "default", + }); + upsertRequestEntry(runtime, requestEntry); + + try { + const rawRequestBody = await readRequestBody(req, config.request_body_limit_bytes); + const parsedRequestJson = isJsonContentType(req.headers["content-type"]) + ? parseJsonSafely(rawRequestBody) + : null; + const { requestJson, remapped, forwardedModel } = remapRequestModel(config, parsedRequestJson); + const requestBody = remapped ? Buffer.from(JSON.stringify(requestJson)) : rawRequestBody; + const requestIsStream = Boolean(requestJson?.stream); + requestEntry.request_body_bytes = rawRequestBody.length; + requestEntry.model = requestJson?.model || null; + requestEntry.requested_model = parsedRequestJson?.model || null; + requestEntry.forwarded_model = forwardedModel || parsedRequestJson?.model || null; + requestEntry.model = requestEntry.forwarded_model; + requestEntry.request_stream = requestIsStream; + upsertRequestEntry(runtime, requestEntry); + + const upstreamUrl = buildUpstreamUrl(config.upstream_base_url, incomingUrl); + const abortController = new AbortController(); + const upstreamAuth = await resolveUpstreamAuth(config); + requestEntry.upstream = buildUpstreamSnapshot({ upstreamUrl, upstreamAuth }); + upsertRequestEntry(runtime, requestEntry); + if (remapped && requestEntry.requested_model && requestEntry.forwarded_model) { + logger?.( + `[model-remap] profile=${config.profile_name || "default"} requested=${requestEntry.requested_model} forwarded=${requestEntry.forwarded_model}`, + ); + } + + const upstreamResponse = await fetchUpstreamWithRetry(upstreamUrl, { + method: req.method, + headers: cloneHeadersForUpstream(req.headers, upstreamAuth), + body: requestBody.length > 0 ? requestBody : undefined, + signal: abortController.signal, + }, logger); + + const shouldInspect = matchPath(config, pathname); + const responseIsStream = + requestIsStream || isSseContentType(upstreamResponse.headers.get("content-type")); + requestEntry.response_stream = responseIsStream; + requestEntry.inspected = shouldInspect; + requestEntry.upstream_status_code = upstreamResponse.status; + requestEntry.upstream = buildUpstreamSnapshot({ upstreamUrl, upstreamAuth, upstreamResponse }); + upsertRequestEntry(runtime, requestEntry); + if (isUpstreamErrorStatus(upstreamResponse.status)) { + logger?.( + `[upstream] status=${upstreamResponse.status} profile=${config.profile_name || "default"} path=${requestEntry.upstream.path} auth=${requestEntry.upstream.auth_mode}/${requestEntry.upstream.auth_source} content_type=${requestEntry.upstream.content_type || "-"}`, + ); + } + + if (!shouldInspect) { + markRequestFirstResponse(requestEntry); + upsertRequestEntry(runtime, requestEntry); + copyHeadersToClient(upstreamResponse.headers, res); + res.writeHead(upstreamResponse.status); + const body = Buffer.from(await upstreamResponse.arrayBuffer()); + res.end(body); + recordRequestEntry( + runtime, + finalizeRequestEntry(requestEntry, { + status_code: upstreamResponse.status, + upstream_status_code: upstreamResponse.status, + inspected: false, + }), + config.request_history_limit, + ); + return; + } + + if (responseIsStream) { + const result = await handleStreaming({ + runtime, + config, + logger, + monitor: runtime.monitor, + pathname, + upstreamResponse, + res, + abortController, + requestEntry, + }); + recordRequestEntry( + runtime, + finalizeRequestEntry(requestEntry, { + response_stream: true, + ...result, + }), + config.request_history_limit, + ); + return; + } + + const result = await handleNonStreaming({ + runtime, + config, + logger, + monitor: runtime.monitor, + pathname, + upstreamResponse, + res, + requestEntry, + }); + recordRequestEntry( + runtime, + finalizeRequestEntry(requestEntry, { + response_stream: false, + ...result, + }), + config.request_history_limit, + ); + return; + } catch (error) { + recordRequestEntry( + runtime, + finalizeRequestEntry(requestEntry, { + status_code: res.headersSent ? null : 502, + error: `${error?.message || error}`, + }), + config.request_history_limit, + ); + throw error; + } +} + +async function main() { + const args = parseArgs(process.argv); + const configPath = args.config || path.join(__dirname, "config.json"); + const config = await loadConfig(configPath); + const monitor = createMonitor(); + + 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 runtime = { + config, + configPath, + logPath: args.log || null, + logger, + monitor, + paths: buildRuntimePaths(configPath, args.log || null), + requestsDb, + server: null, + }; + await hydrateMonitorFromDisk(monitor, runtime.paths, config.request_history_limit); + const importedCount = await importRequestsJsonlToDb(requestsDb, runtime.paths.requestsPath); + logger(`[start] hydrated token totals from jsonl path=${runtime.paths.requestsPath}`); + logger(`[start] requests db ready path=${runtime.paths.requestsDbPath} imported_jsonl_rows=${importedCount}`); + + const server = http.createServer(async (req, res) => { + try { + await proxyRequest(runtime, req, res); + } catch (error) { + logger(`[error] ${error?.stack || error}`); + if (!res.headersSent) { + res.writeHead(502, { "content-type": "application/json; charset=utf-8" }); + res.end( + JSON.stringify({ + error: { + message: `${error?.message || error}`, + type: "codex_retry_gateway_error", + code: "gateway_error", + }, + }), + ); + } else { + res.socket?.destroy(); + } + } + }); + runtime.server = server; + + let shuttingDown = false; + const shutdown = (signal) => { + if (shuttingDown) { + return; + } + shuttingDown = true; + logger(`[stop] received ${signal}, closing gateway`); + server.close(() => { + process.exit(0); + }); + const hardExitTimer = setTimeout(() => { + logger("[stop] forced exit after graceful shutdown timeout"); + process.exit(signal === "SIGTERM" || signal === "SIGINT" ? 0 : 1); + }, 5000); + hardExitTimer.unref(); + }; + + process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => shutdown("SIGINT")); + + server.listen(config.listen_port, 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}`, + }).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)} listening on http://${config.listen_host}:${config.listen_port} -> ${config.upstream_base_url}`, + ); + }); +} + +main().catch((error) => { + process.stderr.write(`${error?.stack || error}\n`); + process.exit(1); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0333e85 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1829 @@ +{ + "name": "codex-retry-gateway", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codex-retry-gateway", + "version": "0.1.0", + "dependencies": { + "react": "^19.2.3", + "react-dom": "^19.2.3" + }, + "devDependencies": { + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "typescript": "^5.9.3", + "vite": "^7.2.7" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.380", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz", + "integrity": "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cc6ce9e --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "codex-retry-gateway", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build:ui": "vite build --config ui-src/vite.config.ts", + "dev:ui": "vite --config ui-src/vite.config.ts --host 0.0.0.0", + "check": "node --check gateway.mjs && node --check scripts/run-profile.mjs", + "check:ui": "tsc --noEmit -p ui-src/tsconfig.json" + }, + "dependencies": { + "react": "^19.2.3", + "react-dom": "^19.2.3" + }, + "devDependencies": { + "@vitejs/plugin-react": "^5.1.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "typescript": "^5.9.3", + "vite": "^7.2.7" + } +} diff --git a/scripts/run-profile.mjs b/scripts/run-profile.mjs new file mode 100644 index 0000000..d9b0a14 --- /dev/null +++ b/scripts/run-profile.mjs @@ -0,0 +1,357 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import { copyFile, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { + DEFAULT_CODEX_CONFIG_PATH, + DEFAULT_HEALTH_PATH, + DEFAULT_LISTEN_HOST, + DEFAULT_LISTEN_PORT, + DEFAULT_STATE_ROOT, + ensureDirectory, + getCodexProviderContext, + getGatewayBaseUrl, + getGatewayStatePaths, + normalizeIntArray, + normalizeStringArray, + parseOptions, + readJsonFile, + setCodexProviderBaseUrl, + waitGatewayHealth, + writeJsonFile, + writeUtf8File, +} from "./admin-lib.mjs"; + +const DEFAULT_PROFILES_DIR = path.join(os.homedir(), ".config", "codex-retry-gateway", "profiles"); + +function parseEnvFile(content) { + const parsed = {}; + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + const separatorIndex = line.indexOf("="); + if (separatorIndex <= 0) { + continue; + } + const key = line.slice(0, separatorIndex).trim(); + let value = line.slice(separatorIndex + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + parsed[key] = value; + } + return parsed; +} + +function getProfileName(options) { + const positional = options._?.[0]; + return `${options.profile || positional || process.env.CODEX_RETRY_GATEWAY_PROFILE || "default"}`; +} + +function resolvePreferredProfileName({ requestedProfileName, existingState, preferStateProfile, profilesDir }) { + if (!preferStateProfile) { + return requestedProfileName; + } + + const stateProfileName = `${existingState?.profile_name || ""}`.trim(); + if (!stateProfileName) { + return requestedProfileName; + } + + const stateProfilePath = path.join(profilesDir, `${stateProfileName}.env`); + if (!fs.existsSync(stateProfilePath)) { + return requestedProfileName; + } + + return stateProfileName; +} + +function boolFromEnv(value, fallback = false) { + if (value === undefined || value === null || value === "") { + return fallback; + } + return ["1", "true", "yes", "on"].includes(`${value}`.trim().toLowerCase()); +} + +function normalizeAuthMode(value) { + const mode = `${value || "passthrough"}`.trim().toLowerCase(); + if (["passthrough", "fixed_bearer", "manual_bearer", "auth_json"].includes(mode)) { + return mode; + } + return "passthrough"; +} + +function inferProfileAuthMode(profileEnv) { + if (profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE) { + return normalizeAuthMode(profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE); + } + if ( + profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_PATH || + profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_KEY + ) { + return "auth_json"; + } + if ( + profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE || + profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV + ) { + return "fixed_bearer"; + } + return "passthrough"; +} + +function buildProfileAuthConfig(profileEnv) { + const authMode = inferProfileAuthMode(profileEnv); + const authConfig = { + upstream_auth_mode: authMode, + upstream_auth_env: "", + upstream_auth_file: "", + upstream_auth_json_path: "", + upstream_auth_json_key: "", + }; + + if (authMode === "fixed_bearer") { + authConfig.upstream_auth_env = + profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV || + "CODEX_RETRY_GATEWAY_UPSTREAM_API_KEY"; + authConfig.upstream_auth_file = profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE || ""; + } else if (authMode === "manual_bearer") { + authConfig.upstream_auth_file = profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE || ""; + } else if (authMode === "auth_json") { + authConfig.upstream_auth_json_path = profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_PATH || ""; + authConfig.upstream_auth_json_key = + profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_KEY || + "OPENAI_API_KEY"; + } + + return authConfig; +} + +async function loadProfileEnv(profileName, profilesDir) { + const profilePath = path.join(profilesDir, `${profileName}.env`); + if (!fs.existsSync(profilePath)) { + throw new Error(`Profile env file was not found: ${profilePath}`); + } + const content = await readFile(profilePath, "utf8"); + return { + profilePath, + env: parseEnvFile(content), + }; +} + +function buildProfileConfig({ profileName, profileEnv, existingGatewayConfig, providerContext, localGatewayBaseUrl }) { + const upstreamBaseUrl = + profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL || + existingGatewayConfig?.upstream_base_url || + (providerContext.currentBaseUrl === localGatewayBaseUrl + ? null + : providerContext.currentBaseUrl); + + if (!upstreamBaseUrl) { + throw new Error("A real upstream base_url could not be determined for this profile."); + } + + const profileAuthConfig = buildProfileAuthConfig(profileEnv); + + return { + profile_name: profileName, + listen_host: profileEnv.CODEX_RETRY_GATEWAY_LISTEN_HOST || DEFAULT_LISTEN_HOST, + listen_port: profileEnv.CODEX_RETRY_GATEWAY_LISTEN_PORT + ? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_LISTEN_PORT}`, 10) + : DEFAULT_LISTEN_PORT, + upstream_base_url: upstreamBaseUrl, + ...profileAuthConfig, + request_body_limit_bytes: profileEnv.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES + ? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES}`, 10) + : Number.parseInt(`${existingGatewayConfig?.request_body_limit_bytes || 10485760}`, 10), + request_history_limit: profileEnv.CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT + ? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT}`, 10) + : Number.parseInt(`${existingGatewayConfig?.request_history_limit || 200}`, 10), + model_remap: profileEnv.CODEX_RETRY_GATEWAY_MODEL_REMAP || existingGatewayConfig?.model_remap || "", + endpoints: normalizeStringArray( + profileEnv.CODEX_RETRY_GATEWAY_ENDPOINTS || existingGatewayConfig?.endpoints, + ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], + ), + reasoning_equals: normalizeIntArray( + profileEnv.CODEX_RETRY_GATEWAY_REASONING_EQUALS || existingGatewayConfig?.reasoning_equals, + [516], + ), + non_stream_status_code: profileEnv.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE + ? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE}`, 10) + : Number.parseInt(`${existingGatewayConfig?.non_stream_status_code || 502}`, 10), + stream_action: profileEnv.CODEX_RETRY_GATEWAY_STREAM_ACTION || existingGatewayConfig?.stream_action || "strict_502", + log_match: profileEnv.CODEX_RETRY_GATEWAY_LOG_MATCH === undefined + ? existingGatewayConfig?.log_match !== false + : boolFromEnv(profileEnv.CODEX_RETRY_GATEWAY_LOG_MATCH, true), + health_path: profileEnv.CODEX_RETRY_GATEWAY_HEALTH_PATH || existingGatewayConfig?.health_path || DEFAULT_HEALTH_PATH, + }; +} + +async function ensureCodexPointsToGateway({ paths, codexConfigPath, providerContext, localGatewayBaseUrl }) { + await ensureDirectory(paths.backupDir); + const existingState = await readJsonFile(paths.statePath); + + let originalBaseUrl = providerContext.currentBaseUrl; + if (providerContext.currentBaseUrl === localGatewayBaseUrl) { + originalBaseUrl = existingState?.original_base_url || null; + } + if (!originalBaseUrl || originalBaseUrl === localGatewayBaseUrl) { + throw new Error("A restorable original Codex base_url could not be determined."); + } + + const backupPath = + existingState?.latest_backup_path || + path.join( + paths.backupDir, + `config-${new Date().toISOString().replace(/[:.]/g, "").replace("T", "-").slice(0, 15)}.toml`, + ); + if (!existingState?.latest_backup_path) { + await copyFile(codexConfigPath, backupPath); + } + + if (providerContext.currentBaseUrl !== localGatewayBaseUrl) { + await setCodexProviderBaseUrl({ + codexConfigPath, + providerName: providerContext.providerName, + newBaseUrl: localGatewayBaseUrl, + }); + } + + return { existingState, originalBaseUrl, backupPath }; +} + +async function main() { + const options = parseOptions(process.argv, { booleanFlags: ["no-codex-config-update", "prefer-state-profile"] }); + const requestedProfileName = getProfileName(options); + const profilesDir = options.profilesDir || DEFAULT_PROFILES_DIR; + const stateRoot = options.stateRoot || process.env.CODEX_RETRY_GATEWAY_STATE_ROOT || DEFAULT_STATE_ROOT; + const codexConfigPath = + options.codexConfigPath || + process.env.CODEX_RETRY_GATEWAY_CODEX_CONFIG_PATH || + DEFAULT_CODEX_CONFIG_PATH; + const paths = getGatewayStatePaths(stateRoot); + + await ensureDirectory(paths.stateRoot); + await ensureDirectory(paths.configDir); + await ensureDirectory(paths.logDir); + await ensureDirectory(paths.backupDir); + + const existingState = await readJsonFile(paths.statePath); + const profileName = resolvePreferredProfileName({ + requestedProfileName, + existingState, + preferStateProfile: Boolean(options.preferStateProfile), + profilesDir, + }); + if (profileName !== requestedProfileName) { + process.stdout.write( + `[run-profile] prefer-state-profile requested=${requestedProfileName} effective=${profileName}\n`, + ); + } + + const { profilePath, env: profileEnv } = await loadProfileEnv(profileName, profilesDir); + for (const [key, value] of Object.entries(profileEnv)) { + process.env[key] = value; + } + + const providerContext = await getCodexProviderContext(codexConfigPath); + const listenHost = profileEnv.CODEX_RETRY_GATEWAY_LISTEN_HOST || DEFAULT_LISTEN_HOST; + const listenPort = profileEnv.CODEX_RETRY_GATEWAY_LISTEN_PORT + ? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_LISTEN_PORT}`, 10) + : DEFAULT_LISTEN_PORT; + const localGatewayBaseUrl = getGatewayBaseUrl(listenHost, listenPort); + const existingGatewayConfig = await readJsonFile(paths.configPath); + + const gatewayConfig = buildProfileConfig({ + profileName, + profileEnv, + existingGatewayConfig, + providerContext, + localGatewayBaseUrl, + }); + + const installState = options.noCodexConfigUpdate + ? { + existingState, + originalBaseUrl: providerContext.currentBaseUrl, + backupPath: null, + } + : await ensureCodexPointsToGateway({ + paths, + codexConfigPath, + providerContext, + localGatewayBaseUrl, + }); + + await writeJsonFile(paths.configPath, gatewayConfig); + await writeJsonFile(paths.statePath, { + ...(installState.existingState || {}), + installed_at: installState.existingState?.installed_at || new Date().toISOString(), + last_started_at: new Date().toISOString(), + profile_name: profileName, + profile_env_path: profilePath, + codex_config_path: codexConfigPath, + provider_name: providerContext.providerName, + original_base_url: installState.originalBaseUrl, + gateway_base_url: localGatewayBaseUrl, + gateway_config_path: paths.configPath, + gateway_log_path: paths.logPath, + gateway_pid_path: paths.pidPath, + latest_backup_path: installState.backupPath || installState.existingState?.latest_backup_path || null, + state_root: paths.stateRoot, + }); + + await writeUtf8File(paths.pidPath, `${process.pid}\n`); + + const gatewayEntry = path.resolve(import.meta.dirname, "..", "gateway.mjs"); + const child = spawn(process.execPath, [gatewayEntry, "--config", paths.configPath, "--log", paths.logPath], { + cwd: path.resolve(import.meta.dirname, ".."), + stdio: "inherit", + windowsHide: true, + }); + + let stoppingBySignal = false; + const forwardSignal = (signal) => { + stoppingBySignal = true; + if (!child.killed) { + child.kill(signal); + } + }; + process.on("SIGTERM", () => forwardSignal("SIGTERM")); + process.on("SIGINT", () => forwardSignal("SIGINT")); + + child.on("exit", async (code, signal) => { + await rm(paths.pidPath, { force: true }).catch(() => {}); + if (stoppingBySignal) { + process.exit(0); + } + process.exit(code ?? (signal ? 0 : 1)); + }); + + try { + await waitGatewayHealth({ + listenHost: gatewayConfig.listen_host, + listenPort: gatewayConfig.listen_port, + healthPath: gatewayConfig.health_path, + }); + } catch (error) { + if (!child.killed) { + child.kill("SIGTERM"); + } + throw error; + } +} + +main().catch((error) => { + process.stderr.write(`${error?.stack || error}\n`); + process.exit(1); +}); diff --git a/scripts/test-gateway-e2e.mjs b/scripts/test-gateway-e2e.mjs index 7992f07..62f1a9d 100644 --- a/scripts/test-gateway-e2e.mjs +++ b/scripts/test-gateway-e2e.mjs @@ -287,7 +287,14 @@ async function run() { const gateway = startGateway(configPath, logPath); try { - await waitForHealth(`http://127.0.0.1:${gatewayPort}${config.health_path}`); + try { + await waitForHealth(`http://127.0.0.1:${gatewayPort}${config.health_path}`); + } catch (error) { + const output = gateway.getOutput(); + throw new Error( + `${error?.message || error}\nstdout:\n${output.stdout || "(empty)"}\nstderr:\n${output.stderr || "(empty)"}`, + ); + } const modelsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/v1/models`); assert(modelsResponse.status === 200, `/v1/models 透传状态异常: ${modelsResponse.status}`); @@ -323,15 +330,25 @@ async function run() { ); } + const recoveredPayload = JSON.stringify({ test_fail_before_response_once: true }); const recoveredResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ test_fail_before_response_once: true }), + body: recoveredPayload, }); const recoveredBody = await recoveredResponse.json(); assert(recoveredResponse.status === 200, `首次 fetch failed 后未自动恢复: ${recoveredResponse.status}`); assert(recoveredBody?.retry_attempt === 2, "首次 fetch failed 后未命中第二次上游请求"); + const requestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`); + const requestsPayload = await requestsResponse.json(); + const recoveredEntry = requestsPayload?.entries?.find((entry) => entry.path === "/responses" && entry.status_code === 200); + assert(requestsResponse.status === 200, `请求历史 API 状态异常: ${requestsResponse.status}`); + assert( + recoveredEntry?.request_body_bytes === Buffer.byteLength(recoveredPayload), + `请求体大小记录异常: ${recoveredEntry?.request_body_bytes}`, + ); + for (const streamPath of [ "/responses", "/v1/responses", diff --git a/ui-src/index.html b/ui-src/index.html new file mode 100644 index 0000000..9e15673 --- /dev/null +++ b/ui-src/index.html @@ -0,0 +1,14 @@ + + + + + + Codex Retry Gateway Console + + + + +
+ + + diff --git a/ui-src/src/App.tsx b/ui-src/src/App.tsx new file mode 100644 index 0000000..94d892d --- /dev/null +++ b/ui-src/src/App.tsx @@ -0,0 +1,1291 @@ +import { FormEvent, useEffect, useState } from "react"; + +type PageKey = "overview" | "requests" | "profiles" | "rules" | "logs"; +type Tone = "" | "success" | "error"; + +type GatewayConfig = { + profile_name?: string; + listen_host?: string; + listen_port?: number; + upstream_base_url?: string; + upstream_auth_mode?: string; + upstream_auth_env?: string | null; + upstream_auth_json_key?: string | null; + request_history_limit?: number; + model_remap?: string; + endpoints?: string[]; + reasoning_equals?: number[]; + non_stream_status_code?: number; + log_match?: boolean; +}; + +type Metrics = { + started_at?: string; + total_proxy_request_count?: number; + inspected_response_count?: number; + matched_response_count?: number; + reasoning_516_count?: number; + reasoning_516_ratio?: number; + token_totals?: { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + reasoning_tokens?: number; + cached_tokens?: number; + }; + observed_reasoning_counts?: Record; +}; + +type StatusPayload = { + ok: boolean; + listen?: string; + config?: GatewayConfig; + state?: { + provider_name?: string; + codex_current_base_url?: string; + latest_backup_path?: string; + }; + paths?: { + config_path?: string; + profiles_dir?: string; + log_path?: string; + }; + metrics?: Metrics; +}; + +type Usage = { + input_tokens?: number | null; + output_tokens?: number | null; + total_tokens?: number | null; + reasoning_tokens?: number | null; + cached_tokens?: number | null; +}; + +type RequestEntry = { + seq: number; + lifecycle_state?: string | null; + started_at?: string; + first_response_at?: string | null; + first_response_delay_ms?: number | null; + finished_at?: string | null; + duration_ms?: number | null; + profile_name?: string; + method?: string; + path?: string; + request_body_bytes?: number | null; + model?: string | null; + requested_model?: string | null; + forwarded_model?: string | null; + response_stream?: boolean; + matched?: boolean; + status_code?: number | null; + upstream_status_code?: number | null; + upstream?: { + origin?: string; + path?: string; + auth_mode?: string; + auth_source?: string; + authorization_configured?: boolean; + status?: number; + content_type?: string; + } | null; + reasoning_tokens?: number | null; + usage?: Usage | null; + error?: string | null; +}; + +type RequestsPayload = { + total_entries?: number; + latest_seq?: number; + entries?: RequestEntry[]; +}; + +type ProfileFormModel = { + listen_host?: string; + listen_port?: string; + upstream_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; + request_history_limit?: string; + model_remap?: string; + reasoning_equals?: string; + endpoints?: string[]; +}; + +type Profile = { + name: string; + active: boolean; + file_path?: string; + summary?: { + listen_host?: string; + listen_port?: string; + upstream_base_url?: string; + auth_mode?: string; + auth_source?: string; + request_history_limit?: string; + model_remap?: string; + reasoning_equals?: string; + }; + form?: ProfileFormModel; +}; + +type ProfilesPayload = { + profiles_dir?: string; + active_profile?: string; + applied_profile?: { + profile?: string; + hot_swapped?: boolean; + listen?: string; + upstream_base_url?: string; + } | null; + profiles?: Profile[]; +}; + +type LogEntry = { + seq: number; + at?: string; + message?: string; +}; + +type LogsPayload = { + total_entries?: number; + latest_seq?: number; + entries?: LogEntry[]; +}; + +type MessageState = { + text: string; + tone: Tone; +}; + +type ProfileFormState = { + name: string; + listen_host: string; + listen_port: string; + upstream_base_url: string; + auth_mode: "passthrough" | "fixed_bearer" | "manual_bearer" | "auth_json"; + 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; + request_history_limit: string; + model_remap: string; + reasoning_equals: string; + endpoints: string; +}; + +type RuleFormState = { + reasoning_equals: string; + endpoints: string; + non_stream_status_code: string; + log_match: boolean; +}; + +const api = { + status: "/__codex_retry_gateway/api/status", + config: "/__codex_retry_gateway/api/config", + logs: "/__codex_retry_gateway/api/logs", + requests: "/__codex_retry_gateway/api/requests", + profiles: "/__codex_retry_gateway/api/profiles", + profileProbe: "/__codex_retry_gateway/api/profiles/probe", + profileSwitch: "/__codex_retry_gateway/api/profiles/switch", + restore: "/__codex_retry_gateway/api/restore", +}; + +type ProfileProbePayload = { + ok: boolean; + profile?: string; + upstream_base_url?: string; + auth_mode?: string; + auth_source?: string; + authorization_configured?: boolean; + model_remap?: string; + probes?: Array<{ + kind?: string; + target?: string; + status?: number; + requested_model?: string; + forwarded_model?: string; + content_type?: string; + body_preview?: string; + }>; +}; + +const pageCopy: Record = { + overview: { + title: "概览", + subtitle: "当前 gateway 运行态和本次启动以来的累计统计。", + index: "01", + }, + requests: { + title: "请求", + subtitle: "每次请求的时间戳、状态、token usage 和命中情况。", + index: "02", + }, + profiles: { + title: "Profiles", + subtitle: "切换 provider base_url 或 auth 来源,并为后续多 profile 统计留好入口。", + index: "03", + }, + rules: { + title: "规则", + subtitle: "热更新当前拦截规则;profile 默认规则请在 Profiles 页保存。", + index: "04", + }, + logs: { + title: "日志", + subtitle: "当前进程的实时日志,不含请求正文或响应正文。", + index: "05", + }, +}; + +const defaultProfileForm: ProfileFormState = { + name: "", + listen_host: "100.115.235.115", + listen_port: "4610", + upstream_base_url: "", + auth_mode: "passthrough", + auth_env: "CODEX_RETRY_GATEWAY_UPSTREAM_API_KEY", + auth_file: "", + manual_secret: "", + manual_secret_file: "", + manual_secret_configured: false, + auth_json_path: "", + auth_json_key: "OPENAI_API_KEY", + request_history_limit: "0", + model_remap: "", + reasoning_equals: "516,1034,1552", + endpoints: "/responses\n/chat/completions\n/v1/responses\n/v1/chat/completions", +}; + +function numberFormat(value: unknown) { + return typeof value === "number" && Number.isFinite(value) + ? new Intl.NumberFormat("zh-CN").format(value) + : "-"; +} + +function timestamp(value?: string | null) { + if (!value) { + return "-"; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString("zh-CN", { hour12: false }); +} + +function durationSeconds(value?: number | null) { + return typeof value === "number" && Number.isFinite(value) ? `${(value / 1000).toFixed(2)} s` : "-"; +} + +function bytesFormat(value?: number | null) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return "-"; + } + if (value < 1024) { + return `${value} B`; + } + if (value < 1024 * 1024) { + return `${(value / 1024).toFixed(1)} KB`; + } + return `${(value / 1024 / 1024).toFixed(2)} MB`; +} + +function lifecycleTone(value?: string | null) { + if (value === "finish") { + return ""; + } + if (value === "receive_first") { + return "warn"; + } + return "pending"; +} + +function percent(value?: number) { + return typeof value === "number" && Number.isFinite(value) ? `${(value * 100).toFixed(2)}%` : "0.00%"; +} + +function effectiveInputTokens(inputTokens?: number | null, cachedTokens?: number | null) { + if (typeof inputTokens !== "number" || !Number.isFinite(inputTokens)) { + return null; + } + const cached = typeof cachedTokens === "number" && Number.isFinite(cachedTokens) ? cachedTokens : 0; + return Math.max(0, inputTokens - cached); +} + +function cachedRatio(inputTokens?: number | null, cachedTokens?: number | null) { + if (typeof inputTokens !== "number" || !Number.isFinite(inputTokens) || inputTokens <= 0) { + return null; + } + const cached = typeof cachedTokens === "number" && Number.isFinite(cachedTokens) ? cachedTokens : 0; + return Math.max(0, Math.min(1, cached / inputTokens)); +} + +function splitList(value: string) { + return value + .split(/[\s,]+/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function splitLines(value: string) { + return value + .split(/\r?\n/) + .map((item) => item.trim()) + .filter(Boolean); +} + +async function fetchJson(url: string, options?: RequestInit): Promise { + const response = await fetch(url, { cache: "no-store", ...options }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload?.error?.message || "请求失败"); + } + return payload as T; +} + +function profileFormFromStatus(status: StatusPayload | null): ProfileFormState { + const config = status?.config || {}; + return { + ...defaultProfileForm, + listen_host: config.listen_host || defaultProfileForm.listen_host, + listen_port: String(config.listen_port || defaultProfileForm.listen_port), + upstream_base_url: config.upstream_base_url || "", + auth_mode: (config.upstream_auth_mode as ProfileFormState["auth_mode"]) || "passthrough", + auth_env: config.upstream_auth_env || defaultProfileForm.auth_env, + manual_secret: "", + manual_secret_file: "", + manual_secret_configured: false, + auth_json_key: config.upstream_auth_json_key || defaultProfileForm.auth_json_key, + request_history_limit: String(config.request_history_limit ?? defaultProfileForm.request_history_limit), + model_remap: config.model_remap || "", + reasoning_equals: Array.isArray(config.reasoning_equals) + ? config.reasoning_equals.join(",") + : defaultProfileForm.reasoning_equals, + endpoints: Array.isArray(config.endpoints) ? config.endpoints.join("\n") : defaultProfileForm.endpoints, + }; +} + +function ruleFormFromStatus(status: StatusPayload | null): RuleFormState { + const config = status?.config || {}; + return { + reasoning_equals: Array.isArray(config.reasoning_equals) ? config.reasoning_equals.join(", ") : "", + endpoints: Array.isArray(config.endpoints) ? config.endpoints.join("\n") : "", + non_stream_status_code: String(config.non_stream_status_code || 502), + log_match: Boolean(config.log_match), + }; +} + +export default function App() { + const [page, setPage] = useState(() => { + const hash = window.location.hash.replace("#", "") as PageKey; + return pageCopy[hash] ? hash : "overview"; + }); + const [status, setStatus] = useState(null); + const [requests, setRequests] = useState([]); + const [requestsMeta, setRequestsMeta] = useState("正在读取请求记录..."); + const [profiles, setProfiles] = useState([]); + const [profilesMeta, setProfilesMeta] = useState("正在读取 profiles..."); + const [logs, setLogs] = useState("正在读取日志..."); + const [logsMeta, setLogsMeta] = useState("正在读取日志..."); + const [latestLogSeq, setLatestLogSeq] = useState(0); + const [requestQuery, setRequestQuery] = useState(""); + const [requestFilter, setRequestFilter] = useState("all"); + const [ruleForm, setRuleForm] = useState(ruleFormFromStatus(null)); + const [profileForm, setProfileForm] = useState(defaultProfileForm); + const [ruleMessage, setRuleMessage] = useState({ text: "", tone: "" }); + const [profileMessage, setProfileMessage] = useState({ text: "", tone: "" }); + const [profileProbeResult, setProfileProbeResult] = useState(null); + const [probingProfile, setProbingProfile] = useState(""); + const [deletingProfile, setDeletingProfile] = useState(""); + const [switchingTo, setSwitchingTo] = useState(""); + const [restoreRequested, setRestoreRequested] = useState(false); + + const metrics = status?.metrics || {}; + const tokens = metrics.token_totals || {}; + const currentPage = pageCopy[page]; + const effectiveInputTotal = effectiveInputTokens(tokens.input_tokens, tokens.cached_tokens); + const cachedTotalRatio = cachedRatio(tokens.input_tokens, tokens.cached_tokens); + + const reasoningChips = Object.entries(metrics.observed_reasoning_counts || {}).sort( + (left, right) => Number(right[1]) - Number(left[1]), + ); + + async function loadStatus(refreshRuleForm = false) { + const payload = await fetchJson(api.status); + setStatus(payload); + if (refreshRuleForm) { + setRuleForm(ruleFormFromStatus(payload)); + setProfileForm(profileFormFromStatus(payload)); + } + } + + async function loadRequests() { + const url = new URL(api.requests, window.location.origin); + url.searchParams.set("limit", "100"); + if (requestQuery.trim()) { + url.searchParams.set("query", requestQuery.trim()); + } + if (requestFilter !== "all") { + url.searchParams.set("filter", requestFilter); + } + const payload = await fetchJson(url.toString()); + const entries = payload.entries || []; + setRequests(entries); + setRequestsMeta(`查询命中 ${payload.total_entries ?? entries.length} 条,最新序号 ${payload.latest_seq ?? 0}。`); + } + + async function loadProfiles() { + const payload = await fetchJson(api.profiles); + const items = payload.profiles || []; + setProfiles(items); + setProfilesMeta(`目录:${payload.profiles_dir || "-"};当前运行:${payload.active_profile || "-"}。`); + } + + async function loadLogs(incremental = false) { + const url = new URL(api.logs, window.location.origin); + if (incremental && latestLogSeq > 0) { + url.searchParams.set("since_seq", String(latestLogSeq)); + } + const payload = await fetchJson(url.toString()); + const rendered = (payload.entries || []) + .map((entry) => `${entry.at || "-"} ${entry.message || ""}`) + .join("\n"); + setLogs((current) => { + if (!incremental || latestLogSeq === 0) { + return rendered || "当前还没有日志。"; + } + return rendered ? `${current.trim()}\n${rendered}` : current; + }); + setLogsMeta(`已载入 ${payload.total_entries ?? payload.entries?.length ?? 0} 条日志,最新序号 ${payload.latest_seq ?? latestLogSeq}。`); + if (typeof payload.latest_seq === "number") { + setLatestLogSeq(payload.latest_seq); + } + } + + async function refreshLiveData() { + if (restoreRequested || switchingTo) { + return; + } + await Promise.all([loadStatus(false), loadRequests(), loadProfiles(), loadLogs(true)]); + } + + useEffect(() => { + window.location.hash = page; + }, [page]); + + useEffect(() => { + const onHashChange = () => { + const next = window.location.hash.replace("#", "") as PageKey; + if (pageCopy[next]) { + setPage(next); + } + }; + window.addEventListener("hashchange", onHashChange); + return () => window.removeEventListener("hashchange", onHashChange); + }, []); + + useEffect(() => { + Promise.all([loadStatus(true), loadRequests(), loadProfiles(), loadLogs(false)]).catch((error) => { + setRuleMessage({ text: error?.message || String(error), tone: "error" }); + }); + }, []); + + useEffect(() => { + const timer = window.setInterval(() => { + refreshLiveData().catch((error) => { + setRuleMessage({ text: error?.message || String(error), tone: "error" }); + }); + }, 2500); + return () => window.clearInterval(timer); + }); + + async function saveRules(event: FormEvent) { + event.preventDefault(); + setRuleMessage({ text: "正在保存配置...", tone: "" }); + try { + const payload = await fetchJson(api.config, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + reasoning_equals: splitList(ruleForm.reasoning_equals) + .map((value) => Number.parseInt(value, 10)) + .filter((value) => Number.isInteger(value)), + endpoints: splitLines(ruleForm.endpoints), + non_stream_status_code: Number.parseInt(ruleForm.non_stream_status_code, 10), + log_match: ruleForm.log_match, + }), + }); + setStatus(payload); + setRuleForm(ruleFormFromStatus(payload)); + await Promise.all([loadRequests(), loadLogs(false)]); + setRuleMessage({ text: "配置已保存,并已对当前 gateway 立即生效。", tone: "success" }); + } catch (error) { + setRuleMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" }); + } + } + + useEffect(() => { + const timer = window.setTimeout(() => { + loadRequests().catch((error) => { + setRuleMessage({ text: error?.message || String(error), tone: "error" }); + }); + }, 180); + return () => window.clearTimeout(timer); + }, [requestQuery, requestFilter]); + + async function saveProfile(event: FormEvent) { + event.preventDefault(); + setProfileMessage({ text: "正在保存 profile...", tone: "" }); + try { + const payload = await fetchJson(api.profiles, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: profileForm.name, + listen_host: profileForm.listen_host, + listen_port: Number.parseInt(profileForm.listen_port, 10), + upstream_base_url: profileForm.upstream_base_url, + auth_mode: profileForm.auth_mode, + auth_env: profileForm.auth_env, + auth_file: profileForm.auth_file, + manual_secret: profileForm.manual_secret, + manual_secret_file: profileForm.manual_secret_file, + manual_secret_configured: profileForm.manual_secret_configured, + auth_json_path: profileForm.auth_json_path, + auth_json_key: profileForm.auth_json_key, + request_history_limit: Number.parseInt(profileForm.request_history_limit, 10), + model_remap: profileForm.model_remap, + reasoning_equals: splitList(profileForm.reasoning_equals), + endpoints: splitLines(profileForm.endpoints), + }), + }); + setProfiles(payload.profiles || []); + setProfilesMeta(`目录:${payload.profiles_dir || "-"};当前运行:${payload.active_profile || "-"}。`); + setProfileForm((current) => ({ ...current, manual_secret: "", manual_secret_configured: current.auth_mode === "manual_bearer" })); + setProfileMessage({ + text: payload.applied_profile ? "profile 已保存,并已对当前运行实例后台热应用。" : "profile 已保存。需要运行它时,点击左侧卡片里的“切换”。", + tone: "success", + }); + } catch (error) { + setProfileMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" }); + } + } + + function editProfile(profile: Profile) { + const form = profile.form || {}; + setProfileForm({ + ...defaultProfileForm, + name: profile.name, + listen_host: form.listen_host || "", + listen_port: form.listen_port || "", + upstream_base_url: form.upstream_base_url || "", + auth_mode: (form.auth_mode as ProfileFormState["auth_mode"]) || "passthrough", + auth_env: form.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 || "OPENAI_API_KEY", + request_history_limit: form.request_history_limit || defaultProfileForm.request_history_limit, + model_remap: form.model_remap || "", + reasoning_equals: form.reasoning_equals || "", + endpoints: Array.isArray(form.endpoints) ? form.endpoints.join("\n") : "", + }); + setProfileMessage({ text: "编辑后保存 profile env;如果保存的是当前运行 profile,后端会直接后台热应用。", tone: "" }); + } + + async function switchProfile(profile: Profile) { + if (profile.active) { + return; + } + if (!window.confirm(`切换到 profile "${profile.name}" 会尝试后台热切换,不重启当前 gateway。确定继续吗?`)) { + return; + } + setSwitchingTo(profile.name); + try { + await fetchJson(api.profileSwitch, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ profile: profile.name }), + }); + waitForProfile(profile.name); + } catch (error) { + setSwitchingTo(""); + setProfileMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" }); + } + } + + async function probeProfile(profile: Profile) { + setProbingProfile(profile.name); + setProfileMessage({ text: `正在探测 profile ${profile.name}...`, tone: "" }); + try { + const payload = await fetchJson(api.profileProbe, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + profile: profile.name, + model: "gpt-5.5-fast", + input: "ping", + }), + }); + setProfileProbeResult(payload); + setProfileMessage({ text: `profile ${profile.name} 探针已完成,不影响当前运行实例。`, tone: "success" }); + } catch (error) { + setProfileMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" }); + } finally { + setProbingProfile(""); + } + } + + async function removeProfile(profile: Profile) { + if (profile.active) { + setProfileMessage({ text: "当前运行的 profile 不能直接删除;请先切换到其他 profile。", tone: "error" }); + return; + } + if (!window.confirm(`删除 profile "${profile.name}" 会移除对应 env 文件。确定继续吗?`)) { + return; + } + setDeletingProfile(profile.name); + try { + const payload = await fetchJson(`${api.profiles}/${encodeURIComponent(profile.name)}`, { + method: "DELETE", + }); + setProfiles(payload.profiles || []); + setProfilesMeta(`目录:${payload.profiles_dir || "-"};当前运行:${payload.active_profile || "-"}。`); + if (profileForm.name === profile.name) { + setProfileForm(defaultProfileForm); + } + if (profileProbeResult?.profile === profile.name) { + setProfileProbeResult(null); + } + setProfileMessage({ text: `profile ${profile.name} 已删除。`, tone: "success" }); + } catch (error) { + setProfileMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" }); + } finally { + setDeletingProfile(""); + } + } + + function waitForProfile(profileName: string) { + const deadline = Date.now() + 12000; + const tick = async () => { + if (Date.now() > deadline) { + setSwitchingTo(""); + setProfileMessage({ text: "热切换已提交,但暂时没有看到当前实例切到目标 profile。", tone: "error" }); + return; + } + try { + const payload = await fetchJson(api.status); + if (payload.config?.profile_name === profileName) { + setSwitchingTo(""); + Promise.all([loadStatus(true), loadRequests(), loadProfiles(), loadLogs(false)]).catch(() => {}); + return; + } + } catch { + // keep polling + } + window.setTimeout(tick, 500); + }; + window.setTimeout(tick, 300); + } + + async function restoreConfig() { + if (!window.confirm("恢复后会关闭当前 gateway,并把 Codex 配置切回原上游。确定继续吗?")) { + return; + } + setRestoreRequested(true); + setRuleMessage({ text: "正在触发恢复,页面很快会失联...", tone: "" }); + try { + await fetchJson(api.restore, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + setRuleMessage({ text: "恢复脚本已启动,等待 gateway 关闭。", tone: "success" }); + } catch (error) { + setRestoreRequested(false); + setRuleMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" }); + } + } + + return ( + <> +
+ + +
+
+
+

{currentPage.title}

+

{currentPage.subtitle}

+
+
+ + + status api + +
+
+ + {page === "overview" && ( +
+
+ +
+ + + + +
+
+ + +
+ + + + + +
+
+ +
+ + + + + + +
+ {reasoningChips.length === 0 ? ( + 还没有 reasoning 观测 + ) : ( + reasoningChips.map(([reasoning, count]) => ( + + reasoning {reasoning}: {count} + + )) + )} +
+
+
+
+
+ )} + + {page === "requests" && ( +
+ + setRequestQuery(event.target.value)} + /> + + +
+ } + > +
+ {requests.length === 0 ? ( +
没有匹配的请求记录。
+ ) : ( + requests.map((entry) => { + const usage = entry.usage || {}; + const upstream = entry.upstream || {}; + const statusTone = entry.error ? "error" : entry.matched ? "warn" : ""; + const effectiveInput = effectiveInputTokens(usage.input_tokens, usage.cached_tokens); + const cachedHitRatio = cachedRatio(usage.input_tokens, usage.cached_tokens); + return ( +
+
+
+
+ {entry.status_code ?? "-"} + {entry.profile_name || status?.config?.profile_name || "-"} + {entry.lifecycle_state || "sent"} + + + {timestamp(entry.started_at)} + + + + {durationSeconds(entry.first_response_delay_ms)} + + + + {durationSeconds(entry.duration_ms)} + + + + {bytesFormat(entry.request_body_bytes)} + + + + {timestamp(entry.finished_at)} + +
+
+ {`${entry.method || "-"} ${entry.path || "-"}`} + {entry.response_stream ? "stream" : "non-stream"} +
+
+
+ {entry.matched ? matched : pass} + {entry.error ? error : null} +
+
+ +
+
+ + {entry.requested_model || entry.model || "-"} + + {entry.forwarded_model && entry.forwarded_model !== entry.requested_model + ? `转发为 ${entry.forwarded_model}` + : entry.forwarded_model || "-"} + +
+ +
+ + {`${upstream.origin || "-"}${upstream.path || ""}`} + + {upstream.auth_mode || "-"} / {upstream.auth_source || "-"} + {upstream.authorization_configured === false ? " / no auth" : ""} + +
+ +
+ +
+ in {numberFormat(effectiveInput)} + out {numberFormat(usage.output_tokens)} + cached {numberFormat(usage.cached_tokens)}{cachedHitRatio !== null ? ` (${percent(cachedHitRatio)})` : ""} + reasoning {numberFormat(entry.reasoning_tokens ?? usage.reasoning_tokens)} + total {numberFormat(usage.total_tokens)} +
+
+
+
+ ); + }) + )} +
+ + + )} + + {page === "profiles" && ( +
+
+ { + setProfileForm(profileFormFromStatus(status)); + setProfileMessage({ text: "", tone: "" }); + }} + > + 新建 Profile + + } + > +
+ {profiles.length === 0 ? ( +
当前还没有 profile env 文件。
+ ) : ( + profiles.map((profile) => ( +
+
+
+

{profile.name}

+
{profile.file_path || ""}
+
+
+ {profile.active ? 当前运行 : 可切换} + + + + +
+
+
+ + + + + + + +
+
+ )) + )} +
+
+ + +
+ + setProfileForm({ ...profileForm, name: event.target.value })} /> + +
+ + setProfileForm({ ...profileForm, listen_host: event.target.value })} /> + + + setProfileForm({ ...profileForm, listen_port: event.target.value })} /> + +
+ + setProfileForm({ ...profileForm, upstream_base_url: event.target.value })} /> + + +