feat: add profile ui and request telemetry
This commit is contained in:
+15
@@ -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/
|
||||
|
||||
@@ -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 或热切换,而不是直接重启当前活跃实例。
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Generated
+1829
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user