feat: add profile ui and request telemetry

This commit is contained in:
2026-06-29 04:40:32 +08:00
parent 7f7f7e5147
commit 4605885262
15 changed files with 7273 additions and 1684 deletions
+15
View File
@@ -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/
+26
View File
@@ -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 或热切换,而不是直接重启当前活跃实例。
+325 -267
View File
@@ -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/<profile>.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`
+18 -11
View File
@@ -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"
}
+2380 -1404
View File
File diff suppressed because it is too large Load Diff
+1829
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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"
}
}
+357
View File
@@ -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);
});
+19 -2
View File
@@ -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",
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Codex Retry Gateway Console</title>
<link rel="icon" href="data:," />
<link rel="shortcut icon" href="data:," />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1291
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+922
View File
@@ -0,0 +1,922 @@
:root {
color-scheme: light;
--bg: #ebe4d6;
--surface: rgba(255, 251, 242, 0.88);
--surface-strong: #fffaf0;
--ink: #1e211d;
--muted: #716a5f;
--faint: #9b9284;
--line: rgba(30, 33, 29, 0.12);
--accent: #166b5c;
--accent-strong: #0f5045;
--accent-soft: #d7eee6;
--amber: #ba7130;
--red: #a43a2d;
--blue: #315f80;
--shadow: 0 24px 70px rgba(71, 48, 15, 0.16);
--mono: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
--sans: "Aptos", "Segoe UI Variable", "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: var(--sans);
color: var(--ink);
background:
radial-gradient(circle at 12% 10%, rgba(22, 107, 92, 0.26), transparent 28%),
radial-gradient(circle at 88% 0%, rgba(186, 113, 48, 0.22), transparent 24%),
linear-gradient(140deg, #f7f1e5 0%, var(--bg) 56%, #e1d4bf 100%);
}
button,
input,
textarea,
select {
font: inherit;
}
button {
border: 0;
border-radius: 999px;
padding: 10px 14px;
font-weight: 760;
cursor: pointer;
transition:
transform 140ms ease,
box-shadow 140ms ease,
opacity 140ms ease;
}
button:hover:not(:disabled) {
transform: translateY(-1px);
}
button:disabled {
cursor: not-allowed;
opacity: 0.58;
}
input,
textarea,
select {
width: 100%;
border: 1px solid var(--line);
border-radius: 15px;
padding: 9px 11px;
color: var(--ink);
background: rgba(255, 253, 248, 0.92);
outline: none;
}
textarea {
min-height: 88px;
resize: vertical;
}
code {
font-family: var(--mono);
font-size: 0.92em;
}
a {
color: inherit;
text-decoration: none;
}
.app-shell {
display: grid;
grid-template-columns: 270px minmax(0, 1fr);
gap: 20px;
max-width: 1500px;
margin: 0 auto;
padding: 22px;
}
.sidebar {
position: sticky;
top: 22px;
align-self: start;
min-height: calc(100vh - 44px);
border: 1px solid var(--line);
border-radius: 30px;
padding: 18px;
background: rgba(255, 250, 240, 0.78);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.brand {
padding: 12px 10px 18px;
}
.eyebrow {
display: inline-flex;
align-items: center;
width: fit-content;
border-radius: 999px;
padding: 6px 10px;
color: var(--accent);
background: var(--accent-soft);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.brand h1 {
margin: 15px 0 8px;
font-size: 28px;
line-height: 1.04;
}
.brand p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.65;
}
.nav {
display: grid;
gap: 8px;
margin-top: 10px;
}
.nav button {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
border: 1px solid transparent;
color: var(--ink);
background: transparent;
text-align: left;
}
.nav button span {
color: var(--faint);
font-size: 12px;
font-weight: 700;
}
.nav button[data-active="true"] {
color: #fffdf6;
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
box-shadow: 0 14px 30px rgba(22, 107, 92, 0.22);
}
.nav button[data-active="true"] span {
color: rgba(255, 255, 255, 0.72);
}
.side-card {
margin-top: 18px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 22px;
background: rgba(255, 253, 247, 0.64);
}
.side-card label {
display: block;
color: var(--muted);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.side-card strong,
.side-card span {
display: block;
margin-top: 7px;
word-break: break-word;
}
.content {
min-width: 0;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-bottom: 18px;
padding: 17px 18px;
border: 1px solid var(--line);
border-radius: 26px;
background: rgba(255, 250, 240, 0.72);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.topbar h2 {
margin: 0;
font-size: 20px;
}
.topbar p {
margin: 3px 0 0;
color: var(--muted);
font-size: 13px;
}
.primary {
color: #fffdf6;
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
box-shadow: 0 12px 24px rgba(22, 107, 92, 0.18);
}
.secondary {
color: var(--accent);
border: 1px solid rgba(22, 107, 92, 0.2);
background: var(--accent-soft);
}
.danger {
color: var(--red);
border: 1px solid rgba(164, 58, 45, 0.22);
background: #fff0ea;
}
.ghost {
color: var(--ink);
border: 1px solid var(--line);
background: rgba(255, 253, 248, 0.75);
}
.page {
animation: liftIn 280ms ease both;
}
@keyframes liftIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.grid {
display: grid;
gap: 16px;
}
.grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid.profile-layout {
grid-template-columns: minmax(0, 1.15fr) minmax(360px, 0.85fr);
}
.card {
border: 1px solid var(--line);
border-radius: 26px;
background: var(--surface);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
overflow: hidden;
}
.card-inner {
padding: 16px;
}
.card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 14px;
margin-bottom: 10px;
}
.card h3 {
margin: 0;
font-size: 17px;
}
.muted {
color: var(--muted);
}
.hint {
color: var(--muted);
font-size: 12px;
line-height: 1.55;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.stat {
min-height: 110px;
padding: 15px;
border: 1px solid rgba(30, 33, 29, 0.08);
border-radius: 22px;
background: var(--surface-strong);
}
.stat label {
display: block;
margin-bottom: 10px;
color: var(--muted);
font-size: 11px;
font-weight: 850;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.stat strong {
display: block;
font-size: clamp(20px, 3vw, 30px);
line-height: 1.08;
}
.token-strip {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
}
.token-card {
padding: 16px;
border-radius: 22px;
color: #fffdf6;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.12), transparent),
#1f5e53;
}
.token-card:nth-child(2) {
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.12), transparent),
#805b2a;
}
.token-card:nth-child(3) {
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.12), transparent),
#315f80;
}
.token-card:nth-child(4) {
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.12), transparent),
#773f33;
}
.token-card:nth-child(5) {
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.12), transparent),
#425268;
}
.token-card label {
display: block;
opacity: 0.78;
font-size: 12px;
font-weight: 800;
}
.token-card strong {
display: block;
margin-top: 10px;
font-size: clamp(22px, 4vw, 34px);
}
.info-list {
display: grid;
gap: 10px;
}
.info-row {
display: grid;
grid-template-columns: 155px minmax(0, 1fr);
gap: 12px;
padding: 11px 0;
border-bottom: 1px solid rgba(30, 33, 29, 0.08);
}
.info-row:last-child {
border-bottom: 0;
}
.info-row label {
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.info-row span {
min-width: 0;
word-break: break-word;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.chip,
.badge {
display: inline-flex;
align-items: center;
width: fit-content;
border-radius: 999px;
padding: 4px 8px;
color: var(--accent);
background: var(--accent-soft);
font-size: 12px;
font-weight: 800;
}
.badge.warn {
color: var(--amber);
background: #fff0d9;
}
.badge.error {
color: var(--red);
background: #ffece7;
}
.badge.pending {
color: #6e5b2e;
background: #f4ead0;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.compact-toolbar {
gap: 8px;
}
.toolbar input,
.toolbar select {
max-width: 270px;
}
.table-wrap {
overflow: auto;
border: 1px solid rgba(30, 33, 29, 0.08);
border-radius: 22px;
background: var(--surface-strong);
}
.request-list {
display: grid;
gap: 8px;
}
.empty-state {
padding: 20px;
border: 1px dashed rgba(30, 33, 29, 0.12);
border-radius: 18px;
color: var(--muted);
background: rgba(255, 250, 240, 0.56);
}
.request-card {
display: grid;
gap: 8px;
padding: 10px 12px;
border: 1px solid rgba(30, 33, 29, 0.08);
border-radius: 20px;
background: var(--surface-strong);
}
.request-head {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: flex-start;
}
.request-title,
.request-subtitle,
.request-token-row,
.request-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.token-pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 8px;
font-size: 11px;
font-weight: 780;
line-height: 1.2;
}
.token-in {
color: #155f53;
background: #d9efe8;
}
.token-out {
color: #2b5675;
background: #dcecf7;
}
.token-cached {
color: #91591f;
background: #fbe8cb;
}
.token-reasoning {
color: #8f4333;
background: #f8ddd5;
}
.token-total {
color: #3e4b5a;
background: #e5eaef;
}
.request-title strong {
font-size: 12px;
line-height: 1.2;
}
.request-subtitle {
margin-top: 2px;
}
.meta-pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 8px;
color: #4e5560;
background: #eceff3;
font-size: 11px;
font-weight: 760;
line-height: 1.2;
}
.meta-key {
margin-right: 5px;
color: #66707c;
font-size: 10px;
font-weight: 820;
letter-spacing: 0.04em;
}
.request-grid {
display: grid;
grid-template-columns: 1.05fr 1.35fr 1.6fr;
gap: 8px;
}
.request-block {
display: grid;
gap: 4px;
min-width: 0;
padding: 8px 9px;
border: 1px solid rgba(30, 33, 29, 0.08);
border-radius: 16px;
background: rgba(255, 250, 240, 0.74);
}
.request-block label {
color: var(--muted);
font-size: 10px;
font-weight: 820;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.request-block code,
.request-block span {
min-width: 0;
word-break: break-word;
font-size: 12px;
}
table {
width: 100%;
min-width: 960px;
border-collapse: collapse;
font-size: 12px;
}
th,
td {
padding: 8px 10px;
border-bottom: 1px solid rgba(30, 33, 29, 0.08);
text-align: left;
vertical-align: top;
white-space: nowrap;
}
th {
position: sticky;
top: 0;
z-index: 1;
color: var(--muted);
background: #f2eadb;
font-size: 11px;
font-weight: 850;
letter-spacing: 0.06em;
text-transform: uppercase;
}
tr:last-child td {
border-bottom: 0;
}
.path-cell {
max-width: 260px;
white-space: normal;
}
.profile-card {
display: grid;
gap: 14px;
padding: 11px 12px;
border: 1px solid rgba(30, 33, 29, 0.08);
border-radius: 20px;
background: var(--surface-strong);
}
.profile-list-grid {
gap: 10px;
}
.compact-profile-card {
gap: 8px;
}
.profile-card[data-active-profile="true"] {
border-color: rgba(22, 107, 92, 0.34);
box-shadow: inset 0 0 0 1px rgba(22, 107, 92, 0.12);
}
.profile-meta {
min-width: 0;
}
.profile-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.profile-head h3 {
margin: 0 0 2px;
font-size: 14px;
line-height: 1.2;
}
.profile-path {
font-size: 11px;
line-height: 1.35;
word-break: break-all;
}
.profile-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 5px;
}
.profile-actions button {
padding: 5px 9px;
font-size: 11px;
}
.profile-actions .badge {
padding: 3px 7px;
font-size: 11px;
}
.mini-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
}
.mini-stat {
padding: 7px 8px;
border: 1px solid rgba(30, 33, 29, 0.08);
border-radius: 14px;
background: rgba(255, 250, 240, 0.76);
}
.mini-stat label {
display: block;
color: var(--muted);
font-size: 10px;
font-weight: 830;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.mini-stat span {
display: block;
margin-top: 3px;
word-break: break-word;
font-size: 11px;
line-height: 1.35;
}
form {
display: grid;
gap: 12px;
}
.field {
display: grid;
gap: 6px;
}
.field span:first-child {
font-size: 13px;
font-weight: 820;
}
.field-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.inline-toggle {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 13px;
border: 1px solid rgba(30, 33, 29, 0.08);
border-radius: 16px;
background: var(--surface-strong);
}
.inline-toggle input {
width: auto;
}
.message {
min-height: 22px;
color: var(--muted);
font-size: 13px;
line-height: 1.55;
}
.message[data-tone="success"] {
color: var(--accent);
}
.message[data-tone="error"] {
color: var(--red);
}
.log-output {
margin: 0;
min-height: 300px;
max-height: 68vh;
overflow: auto;
padding: 14px;
border-radius: 22px;
color: #f7efe2;
background:
radial-gradient(circle at 0% 0%, rgba(22, 107, 92, 0.22), transparent 36%),
#1f211d;
font-family: var(--mono);
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.switch-overlay {
position: fixed;
inset: 0;
z-index: 20;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(34, 30, 24, 0.36);
backdrop-filter: blur(9px);
}
.switch-overlay[data-active="true"] {
display: flex;
}
.switch-dialog {
width: min(520px, 100%);
border: 1px solid rgba(255, 255, 255, 0.38);
border-radius: 28px;
padding: 24px;
background: #fffaf0;
box-shadow: 0 28px 80px rgba(20, 16, 8, 0.28);
}
.progress-line {
height: 9px;
margin-top: 18px;
overflow: hidden;
border-radius: 999px;
background: #e8dcc7;
}
.progress-line span {
display: block;
width: 42%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--accent), var(--amber));
animation: drift 1300ms ease-in-out infinite alternate;
}
@keyframes drift {
from {
transform: translateX(-18%);
}
to {
transform: translateX(150%);
}
}
@media (max-width: 1120px) {
.app-shell,
.grid.profile-layout {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
min-height: auto;
}
.nav {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.nav button {
justify-content: center;
}
.nav button span {
display: none;
}
}
@media (max-width: 820px) {
.app-shell {
padding: 12px;
}
.topbar,
.card-head,
.profile-head {
display: grid;
}
.grid.two,
.stat-grid,
.token-strip,
.mini-stats,
.field-row,
.nav {
grid-template-columns: 1fr;
}
.request-head,
.request-grid {
display: grid;
grid-template-columns: 1fr;
}
.info-row {
grid-template-columns: 1fr;
gap: 4px;
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+23
View File
@@ -0,0 +1,23 @@
import react from "@vitejs/plugin-react";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
const uiRoot = fileURLToPath(new URL(".", import.meta.url));
const repoRoot = fileURLToPath(new URL("..", import.meta.url));
export default defineConfig({
root: uiRoot,
base: "/__codex_retry_gateway/ui/",
plugins: [react()],
build: {
outDir: `${repoRoot}/public/ui`,
emptyOutDir: true,
},
server: {
port: 5174,
proxy: {
"/__codex_retry_gateway/api": "http://127.0.0.1:4610",
"/__codex_retry_gateway/health": "http://127.0.0.1:4610",
},
},
});