From 9cc6bc9b31443d93ae8dc1fa7a5108bb73100f30 Mon Sep 17 00:00:00 2001 From: fleetingtime20026 Date: Fri, 26 Jun 2026 10:07:55 +0800 Subject: [PATCH] feat: publish codex retry gateway --- .gitignore | 3 + README.md | 263 ++++ build.md | 124 ++ config.example.json | 12 + err.md | 147 +++ gateway.mjs | 1465 ++++++++++++++++++++++ scripts/admin-lib.mjs | 620 +++++++++ scripts/common.ps1 | 296 +++++ scripts/install-for-current-provider.mjs | 32 + scripts/install-for-current-provider.ps1 | 109 ++ scripts/install-for-current-provider.sh | 32 + scripts/launch-ui.mjs | 31 + scripts/launch-ui.ps1 | 130 ++ scripts/launch-ui.sh | 32 + scripts/restore-codex-config.mjs | 25 + scripts/restore-codex-config.ps1 | 28 + scripts/restore-codex-config.sh | 32 + scripts/start-gateway.mjs | 28 + scripts/start-gateway.ps1 | 84 ++ scripts/start-gateway.sh | 32 + scripts/stop-gateway.mjs | 24 + scripts/stop-gateway.ps1 | 36 + scripts/stop-gateway.sh | 32 + scripts/test-gateway-e2e.mjs | 316 +++++ scripts/test-gateway-e2e.ps1 | 9 + scripts/test-install-restore.mjs | 293 +++++ scripts/test-install-restore.ps1 | 9 + scripts/test-launch-ui-unix.mjs | 194 +++ scripts/test-launch-ui-unix.ps1 | 9 + scripts/test-launch-ui.mjs | 218 ++++ scripts/test-launch-ui.ps1 | 9 + 31 files changed, 4674 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build.md create mode 100644 config.example.json create mode 100644 err.md create mode 100644 gateway.mjs create mode 100644 scripts/admin-lib.mjs create mode 100644 scripts/common.ps1 create mode 100644 scripts/install-for-current-provider.mjs create mode 100644 scripts/install-for-current-provider.ps1 create mode 100644 scripts/install-for-current-provider.sh create mode 100644 scripts/launch-ui.mjs create mode 100644 scripts/launch-ui.ps1 create mode 100644 scripts/launch-ui.sh create mode 100644 scripts/restore-codex-config.mjs create mode 100644 scripts/restore-codex-config.ps1 create mode 100644 scripts/restore-codex-config.sh create mode 100644 scripts/start-gateway.mjs create mode 100644 scripts/start-gateway.ps1 create mode 100644 scripts/start-gateway.sh create mode 100644 scripts/stop-gateway.mjs create mode 100644 scripts/stop-gateway.ps1 create mode 100644 scripts/stop-gateway.sh create mode 100644 scripts/test-gateway-e2e.mjs create mode 100644 scripts/test-gateway-e2e.ps1 create mode 100644 scripts/test-install-restore.mjs create mode 100644 scripts/test-install-restore.ps1 create mode 100644 scripts/test-launch-ui-unix.mjs create mode 100644 scripts/test-launch-ui-unix.ps1 create mode 100644 scripts/test-launch-ui.mjs create mode 100644 scripts/test-launch-ui.ps1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..71c5423 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +Thumbs.db +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..5fcd760 --- /dev/null +++ b/README.md @@ -0,0 +1,263 @@ +# Codex Retry Gateway + +一个不依赖 `cc-switch` 路由模式的独立本地网关。 + +目标: + +- 保持 Codex 继续使用现有 `auth.json` +- 只把 `config.toml` 的当前 provider `base_url` 改成本地网关 +- 非流式命中 `reasoning_tokens = 516` 时返回 `502` +- 流式命中时直接断开连接,让 Codex 自行重试 +- 默认同时拦截 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` + - 默认 `disconnect` +- `log_match` + - 是否记录命中日志 + +改完后重启: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\start-gateway.ps1 -RestartIfRunning +``` + +```bash +bash ./scripts/start-gateway.sh --restart-if-running +``` + +如果你已经打开管理页,优先直接在页面里改,通常不需要手改 `config.json`。 + +## 其他机器如何应用 + +在其他 Windows 机器上: + +1. 复制整个仓库目录 +2. 确保本机有 `Node.js 18+` +3. 不需要安装 `cc-switch`,也不需要使用 `cc-switch` 路由模式 +4. 在仓库根目录执行 `powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1` +5. 如需回滚,优先在 UI 里点“恢复 Codex 原设置并关闭网关”;脚本级回滚仍可执行 `powershell -ExecutionPolicy Bypass -File .\scripts\restore-codex-config.ps1` + +在其他 macOS / Linux 机器上: + +1. 复制整个仓库目录 +2. 确保本机有 `bash` +3. 确保本机有 `Node.js 18+` +4. 不需要安装 `cc-switch`,也不需要使用 `cc-switch` 路由模式 +5. 在仓库根目录执行 `bash ./scripts/launch-ui.sh` +6. 如需回滚,优先在 UI 里点“恢复 Codex 原设置并关闭网关”;脚本级回滚仍可执行 `bash ./scripts/restore-codex-config.sh` + +运行时状态默认写到当前用户目录: + +```text +Windows: %USERPROFILE%\.codex-retry-gateway +macOS / Linux: ~/.codex-retry-gateway +``` + +## 已验证事项 + +- `test-gateway-e2e.ps1` + - 已通过 + - 验证 `/responses`、`/chat/completions`、`/v1/responses`、`/v1/chat/completions` +- `test-install-restore.ps1` + - 已通过 + - 验证安装、透传、UI 页面、热更新配置、实时日志、516 统计、恢复闭环 +- `test-launch-ui.ps1` + - 已通过 + - 验证首次一键启动自动安装、再次启动自动复用、UI 可访问、默认 516 拦截仍生效 +- `test-launch-ui-unix.ps1` + - 已通过 + - 在当前 Windows 主机的 Bash 环境里验证 Unix `.sh` 入口能完成启动、透传、恢复闭环 +- `bash ./scripts/launch-ui.sh --no-open` + - 已通过 + - 当前机器实测返回 `mode=reuse` + - 后续 `GET /__codex_retry_gateway/health`、`GET /__codex_retry_gateway/ui`、`GET /v1/models` 都返回 `200` +- `codex exec` + - 已通过 + - 在 Bash 默认入口重新拉起 gateway 后,当前机器再次返回 `OK` +- 当前实机验证示例 + - `GET http://127.0.0.1:4610/__codex_retry_gateway/health` 已通过 + - `GET http://127.0.0.1:4610/v1/models` 已通过,并成功透传到配置里的真实上游 + - `GET http://127.0.0.1:4610/__codex_retry_gateway/ui` 已实际打开并确认页面内容 +- `codex exec` 历史现象 + - gateway 关闭时,真实报错地址为 `http://127.0.0.1:4610/responses` + - gateway 恢复后,`codex exec` 已再次成功返回 `OK` diff --git a/build.md b/build.md new file mode 100644 index 0000000..b0a648e --- /dev/null +++ b/build.md @@ -0,0 +1,124 @@ +# build.md + +## 环境要求 + +- Windows 需要 PowerShell 5.1+ 或 PowerShell 7+ +- macOS / Linux 需要 `bash` +- Node.js 18+ + +## 直接运行网关 + +```powershell +node .\gateway.mjs --config .\config.example.json +``` + +## 推荐用法 + +Windows: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1 +``` + +macOS / Linux: + +```bash +bash ./scripts/launch-ui.sh +``` + +说明: + +- 第一次运行会自动安装并接管当前 Codex provider +- 再次运行会自动拉起或重启 gateway,并重新打开 UI +- 不依赖 `cc-switch` 安装本体,也不依赖 `cc-switch` 路由模式 +- macOS / Linux 入口依赖 `bash` 和 `Node.js 18+` +- 推荐显式使用 `bash ...sh`,避免跨平台复制后可执行位丢失 + +## 只启动不自动开浏览器 + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1 -NoOpen +``` + +```bash +bash ./scripts/launch-ui.sh --no-open +``` + +## 手工安装入口 + +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 +``` + +## 打开管理页面 + +```text +http://127.0.0.1:4610/__codex_retry_gateway/ui +``` + +页面支持: + +- 查看当前接管状态 +- 查看本次启动以来的实时日志 +- 查看 `516` 命中次数与 `516` 占比 +- 热更新 `reasoning_equals` / `endpoints` / `non_stream_status_code` / `log_match` +- 一键恢复 Codex 原设置并关闭 gateway + +## 本地验证 + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\test-launch-ui.ps1 +powershell -ExecutionPolicy Bypass -File .\scripts\test-launch-ui-unix.ps1 +powershell -ExecutionPolicy Bypass -File .\scripts\test-gateway-e2e.ps1 +powershell -ExecutionPolicy Bypass -File .\scripts\test-install-restore.ps1 +``` + +## 本机真实验证命令 + +```powershell +Invoke-WebRequest -UseBasicParsing 'http://127.0.0.1:4610/__codex_retry_gateway/health' +``` + +```powershell +Invoke-WebRequest -UseBasicParsing 'http://127.0.0.1:4610/__codex_retry_gateway/ui' +``` + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1 -NoOpen +``` + +```powershell +$auth = Get-Content -Raw (Join-Path $env:USERPROFILE '.codex\auth.json') | ConvertFrom-Json +$headers = @{ Authorization = "Bearer $($auth.OPENAI_API_KEY)" } +Invoke-WebRequest -UseBasicParsing 'http://127.0.0.1:4610/v1/models' -Headers $headers +``` + +```powershell +codex exec --ephemeral --skip-git-repo-check --color never --dangerously-bypass-approvals-and-sandbox -m gpt-5.4-mini -C $env:TEMP --output-last-message (Join-Path $env:TEMP 'codex-retry-gateway-clean-smoke.txt') '只回复OK' +``` + +```bash +bash ./scripts/launch-ui.sh --no-open +``` diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..e36ce18 --- /dev/null +++ b/config.example.json @@ -0,0 +1,12 @@ +{ + "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, + "stream_action": "disconnect", + "log_match": true, + "health_path": "/__codex_retry_gateway/health" +} diff --git a/err.md b/err.md new file mode 100644 index 0000000..6428cc6 --- /dev/null +++ b/err.md @@ -0,0 +1,147 @@ +# err.md + +## 2026-06-26 独立 Codex Retry Gateway + +### 设计边界 + +- 只解决 Codex 已可访问上游时的 `reasoning_tokens = 516` 重试问题 +- 不替代 `cc-switch` 的协议路由转换 +- 流式场景默认策略是: + - 先实时透传 + - 一旦检测到命中 `516` + - 直接断开连接 + +### 当前已知限制 + +- 如果上游只支持 Chat Completions、而 Codex 当前链路需要 Responses 协议转换,这个项目不处理该转换 +- 这个项目依赖 Codex / Codex Desktop 自身的自动重试能力 + +### 本次已确认并修复的问题 + +1. `gateway.mjs` 非流式透传发头顺序错误 + - 现象:`ERR_HTTP_HEADERS_SENT` + - 根因:`writeHead()` 在 `copyHeadersToClient()` 之前调用 + - 结果:正常 `128` 响应也会被打断 + +2. PowerShell 脚本在 `powershell.exe` 下的解析兼容性 + - 现象:脚本乱码并伴随解析异常 + - 根因:新脚本初版包含中文运行时字符串,且 `param(...)` 不在文件最前 + - 处理:运行时输出改成 ASCII,并把 `param(...)` 提前到文件顶部 + +3. `stop-gateway.ps1` 与 PowerShell 内置只读变量 `$PID` 冲突 + - 现象:安装脚本在重启 gateway 时失败 + - 处理:改用 `$gatewayPid` + +4. `start-gateway.ps1` 启动 Node 时路径带空格 + - 现象:gateway 进程启动后立刻退出 + - 根因:`Start-Process` 参数未显式带引号 + - 处理:改为手工拼带引号的 `ArgumentList` + +5. PowerShell 单元素数组落盘时被拆成标量 + - 现象:`reasoning_equals` 被写成 `516`,不是 `[516]` + - 处理:在公共归一化函数里强制返回数组 + +6. 旧脏配置迁移后出现嵌套/拼接 endpoints + - 现象:`endpoints` 可能变成嵌套数组,或出现一条用空格拼接的脏字符串 + - 处理:安装脚本合并 endpoints 时做递归拍平和空白拆分 + +7. 真实 Codex 客户端请求路径不是 `/v1/responses` + - 现象:`codex exec` 在 gateway 关闭时真实报错地址是 `http://127.0.0.1:4610/responses` + - 结论:默认配置必须同时覆盖: + - `/responses` + - `/chat/completions` + - `/v1/responses` + - `/v1/chat/completions` + +8. UI 恢复动作最初采用“子进程拉起 restore 脚本”方案 + - 现象:浏览器拿到 `202`,但临时 `config.toml`、`state.json`、`gateway.pid` 都没有变化 + - 根因:恢复动作通过 detached 子进程接力时,链路可靠性不足,实际没有把恢复流程真正执行完 + - 处理:改为当前 gateway 进程直接复制备份、清理状态并自我退出 + +9. 新增内嵌 UI 管理页 + - 入口:`/__codex_retry_gateway/ui` + - 能力: + - 查看当前接管状态 + - 热更新 `reasoning_equals` + - 热更新 `endpoints` + - 热更新 `non_stream_status_code` + - 开关 `log_match` + - 一键恢复 Codex 原设置 + +10. 用户不接受 `cc-switch` 路由模式,且不希望手工改设置 + - 现象:仅有安装脚本和 UI 还不够,首次接管、再次拉起、重新打开 UI 仍需要手工串命令 + - 处理:新增 `launch-ui.ps1` + - 结果: + - 首次运行自动安装并打开 UI + - 再次运行自动复用 `state.json + config.json` 并重启 gateway + - 平时规则调整和恢复统一回到 UI 内完成 + +11. UI 需要动态显示实时日志、`516` 次数和占比 + - 现象:原 UI 只能改配置,看不到运行中的命中趋势 + - 处理: + - 在 `gateway.mjs` 内增加运行期统计 + - 增加日志接口 + - UI 轮询显示“被检查响应总数 / 516 命中次数 / 516 占比 / 实时日志” + - 统计口径: + - 按本次 gateway 启动以来累计 + - `516` 占比 = `reasoning_tokens = 516` 的响应次数 / 被检查响应总数 + +12. macOS / Linux 不能直接使用现有 PowerShell 管理脚本 + - 现象:`launch-ui.ps1`、`restore-codex-config.ps1` 等入口绑定了 PowerShell 和 Windows 进程控制 + - 处理: + - 新增跨平台 `node` 管理核心 + - 新增 `.sh` 包装入口: + - `launch-ui.sh` + - `restore-codex-config.sh` + - `install-for-current-provider.sh` + - `start-gateway.sh` + - `stop-gateway.sh` + - 结果: + - Windows 继续走 `.ps1` + - macOS / Linux 直接走 `.sh` + - UI、状态文件、gateway 主逻辑保持同一套 + +13. Windows 主机上模拟 Unix shell 入口时存在路径与 Node 版本兼容问题 + - 现象: + - Bash 入口最初找不到脚本路径 + - Bash 默认 `node` 版本过老,不支持现代语法 + - `node.exe` 需要 Windows 路径,而 shell 侧是 POSIX 路径 + - 处理: + - 测试改成相对 POSIX 路径执行 `.sh` + - `.sh` 优先选择 `node.exe` + - 在 WSL / Bash 场景下把路径参数转换回 Windows 路径后再交给 `node.exe` + +### 2026-06-26 实测证据 + +- 假上游 E2E + - `test-gateway-e2e.ps1` 通过 + - 已验证 root 路径和 `/v1` 路径都能区分 `516` 与 `128` +- 安装/恢复闭环 + - `test-install-restore.ps1` 通过 + - 已验证 UI 页面、状态接口、日志接口、516 统计、热更新配置、UI 恢复闭环 +- 一键启动入口 + - `test-launch-ui.ps1` 通过 + - 已验证首次启动自动安装、再次启动自动复用、UI 页面可达、默认 `516 -> 502` 规则仍生效 +- Unix shell 入口 + - `test-launch-ui-unix.ps1` 通过 + - 已验证 `.sh` 入口能完成启动、透传、恢复闭环 +- Bash 默认入口实机验证 + - `bash ./scripts/launch-ui.sh --no-open` 通过 + - 输出 `mode=reuse` + - `GET /__codex_retry_gateway/health` 返回 `200` + - `GET /__codex_retry_gateway/ui` 返回 `200` + - `GET /v1/models` 返回 `200`,并继续透传到真实上游 +- Bash 入口后的 `codex exec` 实机验证 + - 命令退出码 `0` + - 最后一条消息文件返回 `OK` +- 当前真实 provider + - 当前 Codex 配置里的 `base_url` 已可切到 `http://127.0.0.1:4610` + - 当前 gateway 运行配置里的 `upstream_base_url` 会指向用户自己的真实上游 + - `GET /__codex_retry_gateway/health` 返回 `ok=true` + - `GET /v1/models` 已经经本地 gateway 成功透传到真实上游 + - `GET /__codex_retry_gateway/ui` 已实机打开,页面显示当前 upstream、provider、config 路径和 516 规则 +- 真实 `codex exec` + - gateway 停止时,CLI 真实提示: + - `url: http://127.0.0.1:4610/responses` + - 并自动进入 `Reconnecting...` + - gateway 恢复后,`codex exec` 在临时目录再次成功返回 `OK` diff --git a/gateway.mjs b/gateway.mjs new file mode 100644 index 0000000..6badeb9 --- /dev/null +++ b/gateway.mjs @@ -0,0 +1,1465 @@ +#!/usr/bin/env node + +import http from "node:http"; +import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import fs from "node:fs"; +import path from "node:path"; +import { TextDecoder } from "node:util"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const ADMIN_BASE_PATH = "/__codex_retry_gateway"; +const UI_PATH = `${ADMIN_BASE_PATH}/ui`; +const STATUS_API_PATH = `${ADMIN_BASE_PATH}/api/status`; +const CONFIG_API_PATH = `${ADMIN_BASE_PATH}/api/config`; +const LOGS_API_PATH = `${ADMIN_BASE_PATH}/api/logs`; +const RESTORE_API_PATH = `${ADMIN_BASE_PATH}/api/restore`; + +const DEFAULT_CONFIG = { + listen_host: "127.0.0.1", + listen_port: 4610, + upstream_base_url: "", + request_body_limit_bytes: 10 * 1024 * 1024, + endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], + reasoning_equals: [516], + non_stream_status_code: 502, + stream_action: "disconnect", + log_match: true, + health_path: "/__codex_retry_gateway/health", +}; + +const REASONING_POINTERS = [ + "/usage/output_tokens_details/reasoning_tokens", + "/usage/completion_tokens_details/reasoning_tokens", + "/response/usage/output_tokens_details/reasoning_tokens", + "/response/usage/completion_tokens_details/reasoning_tokens", +]; + +function parseArgs(argv) { + const args = { config: null, log: null }; + for (let i = 2; i < argv.length; i += 1) { + const current = argv[i]; + if (current === "--config") { + args.config = argv[i + 1]; + i += 1; + } else if (current === "--log") { + args.log = argv[i + 1]; + i += 1; + } else if (current === "--help" || current === "-h") { + printHelp(); + process.exit(0); + } + } + return args; +} + +function printHelp() { + process.stdout.write( + [ + "用法:", + " node gateway.mjs --config [--log ]", + "", + "说明:", + " 独立 Codex 本地重试网关。", + " 非流式命中 reasoning_tokens=516 时返回 502。", + " 流式命中时默认直接断开连接,交给 Codex 自身重试。", + "", + ].join("\n"), + ); +} + +function normalizePath(inputPath) { + const [withoutQuery] = `${inputPath || "/"}`.split("?"); + const trimmed = withoutQuery.length > 1 ? withoutQuery.replace(/\/+$/, "") : withoutQuery; + return trimmed || "/"; +} + +function flattenValues(value) { + if (Array.isArray(value)) { + return value.flatMap((item) => flattenValues(item)); + } + return [value]; +} + +function isJsonContentType(contentType) { + return `${contentType || ""}`.toLowerCase().includes("application/json"); +} + +function isSseContentType(contentType) { + return `${contentType || ""}`.toLowerCase().includes("text/event-stream"); +} + +function jsonPointerGet(value, pointer) { + if (!pointer.startsWith("/")) { + return undefined; + } + return pointer + .slice(1) + .split("/") + .map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~")) + .reduce((current, segment) => { + if (current === null || current === undefined) { + return undefined; + } + return current[segment]; + }, value); +} + +function extractReasoningTokens(payload) { + for (const pointer of REASONING_POINTERS) { + const raw = jsonPointerGet(payload, pointer); + if (Number.isInteger(raw)) { + return raw; + } + } + return null; +} + +function normalizeIntegerList(values, fallback = []) { + const source = values === undefined || values === null ? fallback : values; + const normalized = flattenValues(source) + .flatMap((value) => { + if (typeof value === "string") { + return value.split(/[\s,]+/).filter(Boolean); + } + return [value]; + }) + .map((value) => Number.parseInt(`${value}`, 10)) + .filter((value) => Number.isInteger(value)); + + return [...new Set(normalized)]; +} + +function normalizeStringList(values, fallback = []) { + const source = values === undefined || values === null ? fallback : values; + const normalized = flattenValues(source) + .flatMap((value) => `${value ?? ""}`.split(/[\s,]+/)) + .map((value) => value.trim()) + .filter(Boolean); + + return [...new Set(normalized)]; +} + +function buildBlockedBody(pathname, reasoning, statusCode) { + return JSON.stringify({ + error: { + message: `codex retry gateway blocked suspicious reasoning response on ${pathname}`, + type: "codex_retry_gateway", + code: "reasoning_guard_triggered", + reasoning_tokens: reasoning, + status_code: statusCode, + }, + }); +} + +function createMonitor() { + return { + started_at: new Date().toISOString(), + next_log_seq: 1, + log_entries: [], + total_proxy_request_count: 0, + inspected_response_count: 0, + matched_response_count: 0, + observed_reasoning_counts: {}, + }; +} + +function createMonitorRecorder(monitor) { + return (message) => { + const entry = { + seq: monitor.next_log_seq, + at: new Date().toISOString(), + message, + }; + monitor.next_log_seq += 1; + monitor.log_entries.push(entry); + return entry; + }; +} + +function createLogger(logPath, recordEntry) { + if (!logPath) { + return (message) => { + const entry = recordEntry ? recordEntry(message) : { at: new Date().toISOString(), message }; + process.stdout.write(`${entry.at} ${entry.message}\n`); + }; + } + + const stream = fs.createWriteStream(logPath, { flags: "a" }); + return (message) => { + const entry = recordEntry ? recordEntry(message) : { at: new Date().toISOString(), message }; + const line = `${entry.at} ${entry.message}\n`; + stream.write(line); + process.stdout.write(line); + }; +} + +function incrementReasoningCount(counter, reasoning) { + if (!Number.isInteger(reasoning)) { + return; + } + const key = `${reasoning}`; + counter[key] = (counter[key] || 0) + 1; +} + +function recordInspectedResponse(monitor, reasoning, matched) { + monitor.inspected_response_count += 1; + incrementReasoningCount(monitor.observed_reasoning_counts, reasoning); + if (matched) { + monitor.matched_response_count += 1; + } +} + +function buildMetricsSnapshot(monitor) { + const reasoning516Count = monitor.observed_reasoning_counts["516"] || 0; + const inspectedResponseCount = monitor.inspected_response_count; + return { + started_at: monitor.started_at, + total_proxy_request_count: monitor.total_proxy_request_count, + inspected_response_count: inspectedResponseCount, + matched_response_count: monitor.matched_response_count, + reasoning_516_count: reasoning516Count, + reasoning_516_ratio: + inspectedResponseCount === 0 ? 0 : reasoning516Count / inspectedResponseCount, + observed_reasoning_counts: { ...monitor.observed_reasoning_counts }, + }; +} + +function buildLogsSnapshot(monitor, sinceSeq = null) { + const entries = Number.isInteger(sinceSeq) + ? monitor.log_entries.filter((entry) => entry.seq > sinceSeq) + : monitor.log_entries; + + return { + total_entries: monitor.log_entries.length, + latest_seq: monitor.next_log_seq - 1, + entries, + }; +} + +async function loadConfig(configPath) { + const content = await readFile(configPath, "utf8"); + const loaded = JSON.parse(content); + const config = { ...DEFAULT_CONFIG, ...loaded }; + config.endpoints = normalizeStringList(config.endpoints, DEFAULT_CONFIG.endpoints).map(normalizePath); + config.reasoning_equals = normalizeIntegerList( + config.reasoning_equals, + DEFAULT_CONFIG.reasoning_equals, + ); + if (!config.upstream_base_url) { + throw new Error("配置缺少 upstream_base_url"); + } + return config; +} + +function buildRuntimePaths(configPath, logPath) { + const configDirectory = path.dirname(configPath); + const stateRoot = path.dirname(configDirectory); + return { + stateRoot, + statePath: path.join(stateRoot, "state.json"), + pidPath: path.join(stateRoot, "gateway.pid"), + configPath, + logPath, + }; +} + +async function readOptionalJson(jsonPath) { + try { + const content = await readFile(jsonPath, "utf8"); + return JSON.parse(content); + } catch { + return null; + } +} + +async function writeConfig(configPath, config) { + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); +} + +function extractProviderBaseUrl(content, providerName) { + if (!content || !providerName) { + return null; + } + + const sectionPattern = new RegExp( + String.raw`^\[model_providers\.${providerName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\]\s*$[\s\S]*?(?=^\[|\Z)`, + "m", + ); + const sectionMatch = content.match(sectionPattern); + if (!sectionMatch) { + return null; + } + + const baseUrlMatch = sectionMatch[0].match(/^\s*base_url\s*=\s*"([^"]+)"\s*$/m); + return baseUrlMatch ? baseUrlMatch[1] : null; +} + +async function readRuntimeState(runtime) { + const state = await readOptionalJson(runtime.paths.statePath); + if (!state) { + return null; + } + + let codexCurrentBaseUrl = null; + if (state.codex_config_path && state.provider_name) { + try { + const codexConfig = await readFile(state.codex_config_path, "utf8"); + codexCurrentBaseUrl = extractProviderBaseUrl(codexConfig, state.provider_name); + } catch { + codexCurrentBaseUrl = null; + } + } + + return { + ...state, + codex_current_base_url: codexCurrentBaseUrl, + }; +} + +async function restoreRuntimeState(runtime, state) { + const backupPath = state?.latest_backup_path; + const codexConfigPath = state?.codex_config_path; + + if (!backupPath || !fs.existsSync(backupPath)) { + throw new Error(`未找到可恢复备份: ${backupPath || "unknown"}`); + } + if (!codexConfigPath) { + throw new Error("安装状态里缺少 codex_config_path"); + } + + await copyFile(backupPath, codexConfigPath); + await Promise.all([ + rm(runtime.paths.statePath, { force: true }), + rm(runtime.paths.pidPath, { force: true }), + ]); +} + +function jsonResponse(res, statusCode, payload, headers = {}) { + res.writeHead(statusCode, { + "content-type": "application/json; charset=utf-8", + ...headers, + }); + res.end(JSON.stringify(payload)); +} + +function htmlResponse(res, html) { + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + res.end(html); +} + +function buildEditableConfig(currentConfig, payload) { + const nextReasoning = normalizeIntegerList(payload.reasoning_equals, currentConfig.reasoning_equals); + const nextEndpoints = normalizeStringList(payload.endpoints, currentConfig.endpoints).map(normalizePath); + const nextStatusCode = + payload.non_stream_status_code === undefined + ? currentConfig.non_stream_status_code + : Number.parseInt(`${payload.non_stream_status_code}`, 10); + + if (nextReasoning.length === 0) { + throw new Error("reasoning_equals 不能为空"); + } + if (nextEndpoints.length === 0) { + throw new Error("endpoints 不能为空"); + } + if (!Number.isInteger(nextStatusCode) || nextStatusCode < 100 || nextStatusCode > 599) { + throw new Error("non_stream_status_code 必须是 100-599 的整数"); + } + + return { + ...currentConfig, + reasoning_equals: nextReasoning, + endpoints: nextEndpoints, + non_stream_status_code: nextStatusCode, + log_match: payload.log_match === undefined ? currentConfig.log_match : Boolean(payload.log_match), + }; +} + +function buildManagementHtml() { + const uiConfig = { + statusPath: STATUS_API_PATH, + configPath: CONFIG_API_PATH, + logsPath: LOGS_API_PATH, + restorePath: RESTORE_API_PATH, + }; + + return ` + + + + + Codex Retry Gateway + + + +
+
+
本地管理页
+

Codex Retry Gateway

+

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

+
+ +
+
+
+

运行状态

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

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

+
+
+ +
+
+

拦截规则

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

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

+
+
+ +
+
+

实时日志

+

正在读取日志...

+
正在读取日志...
+
+
+
+
+ + + +`; +} + +async function handleManagementRequest(runtime, req, res, requestUrl) { + const pathname = normalizePath(requestUrl.pathname); + + if (pathname === UI_PATH) { + htmlResponse(res, buildManagementHtml()); + return true; + } + + if (pathname === STATUS_API_PATH && req.method === "GET") { + const state = await readRuntimeState(runtime); + jsonResponse(res, 200, { + ok: true, + listen: `${runtime.config.listen_host}:${runtime.config.listen_port}`, + config: runtime.config, + state, + paths: { + config_path: runtime.configPath, + state_path: runtime.paths.statePath, + state_root: runtime.paths.stateRoot, + log_path: runtime.logPath, + }, + metrics: buildMetricsSnapshot(runtime.monitor), + }); + return true; + } + + if (pathname === LOGS_API_PATH && req.method === "GET") { + const sinceSeqRaw = requestUrl.searchParams.get("since_seq"); + const sinceSeq = sinceSeqRaw === null ? null : Number.parseInt(sinceSeqRaw, 10); + jsonResponse(res, 200, { + ok: true, + ...buildLogsSnapshot(runtime.monitor, Number.isInteger(sinceSeq) ? sinceSeq : null), + }); + return true; + } + + if (pathname === CONFIG_API_PATH && req.method === "POST") { + const body = await readRequestBody(req, runtime.config.request_body_limit_bytes); + const payload = parseJsonSafely(body); + if (!payload) { + jsonResponse(res, 400, { + error: { + message: "配置保存请求必须是有效 JSON", + code: "invalid_json", + }, + }); + return true; + } + + const nextConfig = buildEditableConfig(runtime.config, payload); + await writeConfig(runtime.configPath, nextConfig); + runtime.config = nextConfig; + runtime.logger( + `[config] updated reasoning_equals=${nextConfig.reasoning_equals.join(",")} endpoints=${nextConfig.endpoints.join(",")}`, + ); + const state = await readRuntimeState(runtime); + jsonResponse(res, 200, { + ok: true, + message: "配置已保存并立即生效", + config: runtime.config, + state, + paths: { + config_path: runtime.configPath, + state_path: runtime.paths.statePath, + state_root: runtime.paths.stateRoot, + log_path: runtime.logPath, + }, + metrics: buildMetricsSnapshot(runtime.monitor), + }); + return true; + } + + if (pathname === RESTORE_API_PATH && req.method === "POST") { + const state = await readRuntimeState(runtime); + if (!state) { + jsonResponse(res, 409, { + error: { + message: "当前未检测到安装状态,无法恢复 Codex 原设置", + code: "state_not_found", + }, + }); + return true; + } + + await restoreRuntimeState(runtime, state); + runtime.logger(`[restore] restored via UI state_root=${runtime.paths.stateRoot}`); + jsonResponse(res, 202, { + ok: true, + message: "原设置已恢复,gateway 即将关闭", + }); + res.on("finish", () => { + const exitTimer = setTimeout(() => { + if (runtime.server) { + runtime.server.close(() => { + process.exit(0); + }); + } else { + process.exit(0); + } + + const hardExitTimer = setTimeout(() => { + process.exit(0); + }, 600); + hardExitTimer.unref(); + }, 120); + exitTimer.unref(); + }); + return true; + } + + return false; +} + +function buildUpstreamUrl(baseUrl, requestUrl) { + const upstream = new URL(baseUrl); + const normalizedBasePath = upstream.pathname.endsWith("/") + ? upstream.pathname.slice(0, -1) + : upstream.pathname; + const incomingPath = requestUrl.pathname; + + let finalPath = incomingPath; + if (normalizedBasePath && normalizedBasePath !== "/") { + if (incomingPath.startsWith(`${normalizedBasePath}/`) || incomingPath === normalizedBasePath) { + finalPath = incomingPath; + } else if (normalizedBasePath.endsWith("/v1") && incomingPath.startsWith("/v1/")) { + finalPath = `${normalizedBasePath}${incomingPath.slice(3)}`; + } else { + finalPath = `${normalizedBasePath}${incomingPath}`; + } + } + + upstream.pathname = finalPath; + upstream.search = requestUrl.search; + return upstream.toString(); +} + +function cloneHeadersForUpstream(headers) { + const outgoing = new Headers(); + for (const [key, value] of Object.entries(headers)) { + if (value === undefined) { + continue; + } + const lowerKey = key.toLowerCase(); + if ( + lowerKey === "host" || + lowerKey === "content-length" || + lowerKey === "connection" || + lowerKey === "transfer-encoding" + ) { + continue; + } + if (Array.isArray(value)) { + for (const item of value) { + outgoing.append(key, item); + } + } else { + outgoing.set(key, value); + } + } + return outgoing; +} + +function copyHeadersToClient(sourceHeaders, target) { + for (const [key, value] of sourceHeaders.entries()) { + const lowerKey = key.toLowerCase(); + if ( + lowerKey === "content-length" || + lowerKey === "transfer-encoding" || + lowerKey === "content-encoding" || + lowerKey === "connection" + ) { + continue; + } + target.setHeader(key, value); + } +} + +async function readRequestBody(req, limitBytes) { + const chunks = []; + let total = 0; + for await (const chunk of req) { + total += chunk.length; + if (total > limitBytes) { + throw new Error(`请求体超过限制: ${limitBytes} bytes`); + } + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + +function parseJsonSafely(buffer) { + try { + return JSON.parse(buffer.toString("utf8")); + } catch { + return null; + } +} + +function matchPath(config, pathname) { + return config.endpoints.includes(normalizePath(pathname)); +} + +function reasoningMatched(config, reasoning) { + return reasoning !== null && config.reasoning_equals.includes(reasoning); +} + +function inspectSseChunk(state, chunk) { + const decoded = state.decoder.decode(chunk, { stream: true }); + state.buffer += decoded; + + const blocks = state.buffer.split(/\r?\n\r?\n/); + state.buffer = blocks.pop() ?? ""; + + for (const block of blocks) { + const lines = block + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter(Boolean); + const dataLines = lines + .filter((line) => line.startsWith("data:")) + .map((line) => line.replace(/^data:\s?/, "")); + + if (dataLines.length === 0) { + continue; + } + const payloadText = dataLines.join("\n"); + if (payloadText === "[DONE]") { + continue; + } + try { + const parsed = JSON.parse(payloadText); + const reasoning = extractReasoningTokens(parsed); + if (reasoning !== null) { + return reasoning; + } + } catch { + // ignore malformed SSE payloads + } + } + return null; +} + +async function handleNonStreaming({ + config, + logger, + monitor, + pathname, + upstreamResponse, + res, +}) { + const bodyBuffer = Buffer.from(await upstreamResponse.arrayBuffer()); + const parsed = isJsonContentType(upstreamResponse.headers.get("content-type")) + ? parseJsonSafely(bodyBuffer) + : null; + const reasoning = parsed ? extractReasoningTokens(parsed) : null; + const matched = reasoningMatched(config, reasoning); + + recordInspectedResponse(monitor, reasoning, matched); + + if (matched) { + if (config.log_match) { + logger( + `[match] non-stream path=${pathname} reasoning_tokens=${reasoning} action=status_${config.non_stream_status_code}`, + ); + } + const blockedBody = buildBlockedBody(pathname, reasoning, config.non_stream_status_code); + res.writeHead(config.non_stream_status_code, { + "content-type": "application/json; charset=utf-8", + "x-codex-retry-gateway-reason": "reasoning-guard-triggered", + }); + res.end(blockedBody); + return; + } + + copyHeadersToClient(upstreamResponse.headers, res); + res.writeHead(upstreamResponse.status); + res.end(bodyBuffer); +} + +async function handleStreaming({ + config, + logger, + monitor, + pathname, + upstreamResponse, + res, + abortController, +}) { + copyHeadersToClient(upstreamResponse.headers, res); + res.writeHead(upstreamResponse.status); + + const reader = upstreamResponse.body.getReader(); + const sseState = { + decoder: new TextDecoder("utf8"), + buffer: "", + }; + + let wroteAnyChunk = false; + let observedReasoning = null; + while (true) { + const { done, value } = await reader.read(); + if (done) { + recordInspectedResponse(monitor, observedReasoning, false); + res.end(); + return; + } + + const reasoning = inspectSseChunk(sseState, value); + if (Number.isInteger(reasoning)) { + observedReasoning = reasoning; + } + if (reasoningMatched(config, reasoning)) { + recordInspectedResponse(monitor, reasoning, true); + if (config.log_match) { + logger( + `[match] stream path=${pathname} reasoning_tokens=${reasoning} action=${config.stream_action}`, + ); + } + + if (!wroteAnyChunk) { + const blockedBody = buildBlockedBody(pathname, reasoning, config.non_stream_status_code); + if (!res.headersSent) { + res.writeHead(config.non_stream_status_code, { + "content-type": "application/json; charset=utf-8", + "x-codex-retry-gateway-reason": "reasoning-guard-triggered", + }); + } + res.end(blockedBody); + } else { + abortController.abort(); + reader.cancel().catch(() => {}); + res.socket?.destroy(); + } + return; + } + + wroteAnyChunk = true; + res.write(Buffer.from(value)); + } +} + +async function proxyRequest(runtime, req, res) { + const { logger } = runtime; + const config = runtime.config; + const incomingUrl = new URL(req.url, `http://${req.headers.host || "127.0.0.1"}`); + const pathname = normalizePath(incomingUrl.pathname); + + if (pathname === config.health_path) { + res.writeHead(200, { "content-type": "application/json; charset=utf-8" }); + res.end( + JSON.stringify({ + ok: true, + listen: `${config.listen_host}:${config.listen_port}`, + upstream_base_url: config.upstream_base_url, + ui_path: UI_PATH, + }), + ); + return; + } + + if (await handleManagementRequest(runtime, req, res, incomingUrl)) { + return; + } + + runtime.monitor.total_proxy_request_count += 1; + + const requestBody = await readRequestBody(req, config.request_body_limit_bytes); + const requestJson = isJsonContentType(req.headers["content-type"]) + ? parseJsonSafely(requestBody) + : null; + const requestIsStream = Boolean(requestJson?.stream); + + const upstreamUrl = buildUpstreamUrl(config.upstream_base_url, incomingUrl); + const abortController = new AbortController(); + + const upstreamResponse = await fetch(upstreamUrl, { + method: req.method, + headers: cloneHeadersForUpstream(req.headers), + body: requestBody.length > 0 ? requestBody : undefined, + signal: abortController.signal, + }); + + const shouldInspect = matchPath(config, pathname); + const responseIsStream = + requestIsStream || isSseContentType(upstreamResponse.headers.get("content-type")); + + if (!shouldInspect) { + copyHeadersToClient(upstreamResponse.headers, res); + res.writeHead(upstreamResponse.status); + const body = Buffer.from(await upstreamResponse.arrayBuffer()); + res.end(body); + return; + } + + if (responseIsStream) { + await handleStreaming({ + config, + logger, + monitor: runtime.monitor, + pathname, + upstreamResponse, + res, + abortController, + }); + return; + } + + await handleNonStreaming({ + config, + logger, + monitor: runtime.monitor, + pathname, + upstreamResponse, + res, + }); +} + +async function main() { + const args = parseArgs(process.argv); + const configPath = args.config || path.join(__dirname, "config.json"); + const config = await loadConfig(configPath); + const monitor = createMonitor(); + + if (args.log) { + await mkdir(path.dirname(args.log), { recursive: true }); + } + const logger = createLogger(args.log, createMonitorRecorder(monitor)); + const runtime = { + config, + configPath, + logPath: args.log || null, + logger, + monitor, + paths: buildRuntimePaths(configPath, args.log || null), + server: null, + }; + + const server = http.createServer(async (req, res) => { + try { + await proxyRequest(runtime, req, res); + } catch (error) { + logger(`[error] ${error?.stack || error}`); + if (!res.headersSent) { + res.writeHead(502, { "content-type": "application/json; charset=utf-8" }); + res.end( + JSON.stringify({ + error: { + message: `${error?.message || error}`, + type: "codex_retry_gateway_error", + code: "gateway_error", + }, + }), + ); + } else { + res.socket?.destroy(); + } + } + }); + runtime.server = server; + + server.listen(config.listen_port, config.listen_host, () => { + logger( + `[start] codex retry gateway listening on http://${config.listen_host}:${config.listen_port} -> ${config.upstream_base_url}`, + ); + }); +} + +main().catch((error) => { + process.stderr.write(`${error?.stack || error}\n`); + process.exit(1); +}); diff --git a/scripts/admin-lib.mjs b/scripts/admin-lib.mjs new file mode 100644 index 0000000..b7cb502 --- /dev/null +++ b/scripts/admin-lib.mjs @@ -0,0 +1,620 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export const DEFAULT_STATE_ROOT = path.join(os.homedir(), ".codex-retry-gateway"); +export const DEFAULT_CODEX_CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml"); +export const DEFAULT_LISTEN_HOST = "127.0.0.1"; +export const DEFAULT_LISTEN_PORT = 4610; +export const DEFAULT_HEALTH_PATH = "/__codex_retry_gateway/health"; + +function escapeRegExp(value) { + return `${value}`.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function parseOptions(argv, { booleanFlags = [] } = {}) { + const options = { _: [] }; + const booleanSet = new Set(booleanFlags); + + for (let index = 2; index < argv.length; index += 1) { + const current = argv[index]; + if (!current.startsWith("--")) { + options._.push(current); + continue; + } + + const flagName = current.slice(2); + const optionKey = flagName.replace(/-([a-z])/g, (_, char) => char.toUpperCase()); + if (booleanSet.has(flagName)) { + options[optionKey] = true; + continue; + } + + const nextValue = argv[index + 1]; + if (nextValue === undefined) { + throw new Error(`Missing value for --${flagName}`); + } + options[optionKey] = nextValue; + index += 1; + } + + return options; +} + +export function getGatewayRoot() { + return path.resolve(import.meta.dirname, ".."); +} + +export function getGatewayStatePaths(stateRoot = DEFAULT_STATE_ROOT) { + return { + stateRoot, + configDir: path.join(stateRoot, "config"), + logDir: path.join(stateRoot, "logs"), + backupDir: path.join(stateRoot, "backups"), + configPath: path.join(stateRoot, "config", "config.json"), + logPath: path.join(stateRoot, "logs", "gateway.log"), + statePath: path.join(stateRoot, "state.json"), + pidPath: path.join(stateRoot, "gateway.pid"), + }; +} + +export function getGatewayBaseUrl(listenHost, listenPort) { + return `http://${listenHost}:${listenPort}`; +} + +export function getGatewayBaseUrlFromConfig(gatewayConfig) { + if (!gatewayConfig) { + return null; + } + if (!gatewayConfig.listen_host || gatewayConfig.listen_port === undefined || gatewayConfig.listen_port === null) { + return null; + } + return getGatewayBaseUrl(`${gatewayConfig.listen_host}`, Number.parseInt(`${gatewayConfig.listen_port}`, 10)); +} + +export async function ensureDirectory(targetPath) { + await mkdir(targetPath, { recursive: true }); +} + +export async function writeUtf8File(targetPath, content) { + const parent = path.dirname(targetPath); + if (parent && parent !== ".") { + await ensureDirectory(parent); + } + await writeFile(targetPath, content, "utf8"); +} + +export async function readJsonFile(filePath) { + if (!fs.existsSync(filePath)) { + return null; + } + const raw = await readFile(filePath, "utf8"); + if (!raw.trim()) { + return null; + } + return JSON.parse(raw); +} + +export async function writeJsonFile(filePath, value) { + await writeUtf8File(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +export async function getCodexProviderContext(codexConfigPath) { + const content = await readFile(codexConfigPath, "utf8"); + const providerMatch = content.match(/^\s*model_provider\s*=\s*"([^"]+)"\s*$/m); + if (!providerMatch) { + throw new Error(`model_provider was not found in ${codexConfigPath}`); + } + + const providerName = providerMatch[1]; + const sectionHeaderRegex = new RegExp(`^\\[model_providers\\.${escapeRegExp(providerName)}\\]\\s*$`, "m"); + const sectionHeaderMatch = sectionHeaderRegex.exec(content); + if (!sectionHeaderMatch) { + throw new Error(`[model_providers.${providerName}] was not found in ${codexConfigPath}`); + } + + const sectionIndex = sectionHeaderMatch.index; + const headerEndIndex = sectionIndex + sectionHeaderMatch[0].length; + const remainder = content.slice(headerEndIndex); + const nextSectionMatch = /^\[.*$/m.exec(remainder); + const sectionEndIndex = nextSectionMatch ? headerEndIndex + nextSectionMatch.index : content.length; + const sectionText = content.slice(sectionIndex, sectionEndIndex); + const baseUrlMatch = sectionText.match(/^\s*base_url\s*=\s*"([^"]+)"\s*$/m); + if (!baseUrlMatch) { + throw new Error(`base_url was not found in [model_providers.${providerName}]`); + } + + return { + content, + providerName, + sectionText, + sectionIndex, + sectionLength: sectionText.length, + currentBaseUrl: baseUrlMatch[1], + baseUrlLineText: baseUrlMatch[0], + }; +} + +export async function setCodexProviderBaseUrl({ codexConfigPath, providerName, newBaseUrl }) { + const context = await getCodexProviderContext(codexConfigPath); + if (context.providerName !== providerName) { + throw new Error(`model_provider changed unexpectedly: expected ${providerName}, actual ${context.providerName}`); + } + + let replaced = false; + const updatedSection = context.sectionText.replace( + /^(\s*base_url\s*=\s*")([^"]*)("\s*)$/m, + (_, prefix, __existing, suffix) => { + replaced = true; + return `${prefix}${newBaseUrl}${suffix}`; + }, + ); + if (!replaced) { + throw new Error(`base_url was not found in [model_providers.${providerName}]`); + } + + const updatedContent = + context.content.slice(0, context.sectionIndex) + + updatedSection + + context.content.slice(context.sectionIndex + context.sectionLength); + + await writeUtf8File(codexConfigPath, updatedContent); +} + +export function normalizeIntArray(values, fallback = [516]) { + const source = values === undefined || values === null ? fallback : values; + const queue = Array.isArray(source) ? source.flat(Infinity) : [source]; + const normalized = queue + .map((value) => (typeof value === "string" ? value.split(/[\s,]+/).filter(Boolean) : [value])) + .flat() + .map((value) => Number.parseInt(`${value}`, 10)) + .filter((value) => Number.isInteger(value)); + + return normalized.length > 0 ? [...new Set(normalized)] : [...fallback]; +} + +export function normalizeStringArray(values, fallback = []) { + const source = values === undefined || values === null ? fallback : values; + const queue = Array.isArray(source) ? source.flat(Infinity) : [source]; + const normalized = queue + .flatMap((value) => `${value ?? ""}`.split(/[\s,]+/)) + .map((value) => value.trim()) + .filter(Boolean); + + return normalized.length > 0 ? [...new Set(normalized)] : [...fallback]; +} + +export function isProcessAlive(processId) { + try { + process.kill(processId, 0); + return true; + } catch { + return false; + } +} + +export async function waitGatewayHealth({ + listenHost, + listenPort, + healthPath, + timeoutSeconds = 10, +}) { + const deadline = Date.now() + timeoutSeconds * 1000; + const healthUrl = `${getGatewayBaseUrl(listenHost, listenPort)}${healthPath}`; + + while (Date.now() < deadline) { + try { + const response = await fetch(healthUrl, { signal: AbortSignal.timeout(2000) }); + if (response.status === 200) { + return response; + } + } catch { + // ignore and retry + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + + throw new Error(`Gateway health check timed out: ${healthUrl}`); +} + +async function readTail(filePath, lineCount = 20) { + if (!fs.existsSync(filePath)) { + return ""; + } + const raw = await readFile(filePath, "utf8"); + return raw.split(/\r?\n/).slice(-lineCount).join("\n").trim(); +} + +function openUrl(url) { + let command; + let args; + if (process.platform === "win32") { + command = "cmd"; + args = ["/c", "start", "", url]; + } else if (process.platform === "darwin") { + command = "open"; + args = [url]; + } else { + command = "xdg-open"; + args = [url]; + } + + const child = spawn(command, args, { + detached: true, + stdio: "ignore", + windowsHide: true, + }); + child.unref(); +} + +export async function stopGateway({ stateRoot = DEFAULT_STATE_ROOT, quiet = false }) { + const paths = getGatewayStatePaths(stateRoot); + if (!fs.existsSync(paths.pidPath)) { + return quiet ? null : "No running gateway PID file was found."; + } + + const pidRaw = (await readFile(paths.pidPath, "utf8")).trim(); + if (!pidRaw) { + await rm(paths.pidPath, { force: true }); + return quiet ? null : "Gateway PID file was empty and has been removed."; + } + + const gatewayPid = Number.parseInt(pidRaw, 10); + if (Number.isInteger(gatewayPid) && isProcessAlive(gatewayPid)) { + try { + process.kill(gatewayPid); + } catch { + // ignore first failure + } + + const deadline = Date.now() + 3000; + while (Date.now() < deadline && isProcessAlive(gatewayPid)) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + if (isProcessAlive(gatewayPid)) { + try { + process.kill(gatewayPid, "SIGKILL"); + } catch { + // ignore hard kill failure + } + } + } + + await rm(paths.pidPath, { force: true }); + return quiet ? null : `Gateway stopped. PID=${gatewayPid}`; +} + +export async function startGateway({ + stateRoot = DEFAULT_STATE_ROOT, + configPath, + logPath, + restartIfRunning = false, +}) { + const paths = getGatewayStatePaths(stateRoot); + const effectiveConfigPath = configPath || paths.configPath; + const effectiveLogPath = logPath || paths.logPath; + + if (!fs.existsSync(effectiveConfigPath)) { + throw new Error(`Gateway config file was not found: ${effectiveConfigPath}`); + } + + await ensureDirectory(path.dirname(effectiveLogPath)); + + if (fs.existsSync(paths.pidPath)) { + const existingPidRaw = (await readFile(paths.pidPath, "utf8")).trim(); + if (existingPidRaw) { + const existingPid = Number.parseInt(existingPidRaw, 10); + if (Number.isInteger(existingPid) && isProcessAlive(existingPid)) { + if (restartIfRunning) { + await stopGateway({ stateRoot, quiet: true }); + } else { + return `Gateway is already running. PID=${existingPid}`; + } + } else { + await rm(paths.pidPath, { force: true }); + } + } + } + + const gatewayConfig = await readJsonFile(effectiveConfigPath); + if (!gatewayConfig) { + throw new Error(`Gateway config file could not be read: ${effectiveConfigPath}`); + } + + const gatewayRoot = getGatewayRoot(); + const gatewayEntry = path.join(gatewayRoot, "gateway.mjs"); + if (!fs.existsSync(gatewayEntry)) { + throw new Error(`Gateway entry file was not found: ${gatewayEntry}`); + } + + const child = spawn(process.execPath, [gatewayEntry, "--config", effectiveConfigPath, "--log", effectiveLogPath], { + cwd: gatewayRoot, + detached: true, + stdio: "ignore", + windowsHide: true, + }); + child.unref(); + + await writeUtf8File(paths.pidPath, `${child.pid}`); + + await new Promise((resolve) => setTimeout(resolve, 300)); + if (!isProcessAlive(child.pid)) { + const logTail = await readTail(effectiveLogPath, 20); + throw new Error(`Gateway exited right after startup. PID=${child.pid}\n${logTail}`); + } + + await waitGatewayHealth({ + listenHost: `${gatewayConfig.listen_host}`, + listenPort: Number.parseInt(`${gatewayConfig.listen_port}`, 10), + healthPath: `${gatewayConfig.health_path || DEFAULT_HEALTH_PATH}`, + }); + + return `Gateway started. PID=${child.pid}. Listen=${getGatewayBaseUrl(gatewayConfig.listen_host, gatewayConfig.listen_port)}`; +} + +export async function installForCurrentProvider({ + codexConfigPath = DEFAULT_CODEX_CONFIG_PATH, + stateRoot = DEFAULT_STATE_ROOT, + listenHost = DEFAULT_LISTEN_HOST, + listenPort = DEFAULT_LISTEN_PORT, +}) { + const paths = getGatewayStatePaths(stateRoot); + await ensureDirectory(paths.stateRoot); + await ensureDirectory(paths.configDir); + await ensureDirectory(paths.logDir); + await ensureDirectory(paths.backupDir); + + if (!fs.existsSync(codexConfigPath)) { + throw new Error(`Codex config file was not found: ${codexConfigPath}`); + } + + const providerContext = await getCodexProviderContext(codexConfigPath); + const localGatewayBaseUrl = getGatewayBaseUrl(listenHost, listenPort); + const existingState = await readJsonFile(paths.statePath); + + let originalBaseUrl = providerContext.currentBaseUrl; + if (providerContext.currentBaseUrl === localGatewayBaseUrl) { + if (!existingState?.original_base_url) { + throw new Error("Provider already points to the local gateway, but original_base_url is missing from state."); + } + originalBaseUrl = `${existingState.original_base_url}`; + } + + if (originalBaseUrl === localGatewayBaseUrl) { + throw new Error("A real upstream_base_url could not be determined."); + } + + const backupPath = path.join(paths.backupDir, `config-${new Date().toISOString().replace(/[:.]/g, "").replace("T", "-").slice(0, 15)}.toml`); + await copyFile(codexConfigPath, backupPath); + + const existingGatewayConfig = await readJsonFile(paths.configPath); + const defaultEndpoints = ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"]; + const mergedEndpoints = []; + for (const endpoint of [ + ...normalizeStringArray(existingGatewayConfig?.endpoints, []), + ...defaultEndpoints, + ]) { + if (!mergedEndpoints.includes(endpoint)) { + mergedEndpoints.push(endpoint); + } + } + + const gatewayConfig = { + listen_host: listenHost, + listen_port: listenPort, + upstream_base_url: originalBaseUrl, + request_body_limit_bytes: + existingGatewayConfig?.request_body_limit_bytes === undefined || existingGatewayConfig?.request_body_limit_bytes === null + ? 10485760 + : Number.parseInt(`${existingGatewayConfig.request_body_limit_bytes}`, 10), + endpoints: mergedEndpoints, + reasoning_equals: normalizeIntArray(existingGatewayConfig?.reasoning_equals, [516]), + non_stream_status_code: + existingGatewayConfig?.non_stream_status_code === undefined || existingGatewayConfig?.non_stream_status_code === null + ? 502 + : Number.parseInt(`${existingGatewayConfig.non_stream_status_code}`, 10), + stream_action: existingGatewayConfig?.stream_action || "disconnect", + log_match: existingGatewayConfig?.log_match === undefined ? true : Boolean(existingGatewayConfig.log_match), + health_path: existingGatewayConfig?.health_path || DEFAULT_HEALTH_PATH, + }; + + const previousConfigContent = await readFile(codexConfigPath, "utf8"); + + try { + await writeJsonFile(paths.configPath, gatewayConfig); + await setCodexProviderBaseUrl({ + codexConfigPath, + providerName: providerContext.providerName, + newBaseUrl: localGatewayBaseUrl, + }); + + await startGateway({ + stateRoot, + configPath: paths.configPath, + logPath: paths.logPath, + restartIfRunning: true, + }); + + const state = { + installed_at: new Date().toISOString(), + codex_config_path: codexConfigPath, + provider_name: providerContext.providerName, + original_base_url: originalBaseUrl, + gateway_base_url: localGatewayBaseUrl, + gateway_config_path: paths.configPath, + gateway_log_path: paths.logPath, + gateway_pid_path: paths.pidPath, + latest_backup_path: backupPath, + state_root: paths.stateRoot, + }; + await writeJsonFile(paths.statePath, state); + + return { + provider: providerContext.providerName, + upstream: originalBaseUrl, + gateway: localGatewayBaseUrl, + configPath: paths.configPath, + backupPath, + }; + } catch (error) { + await writeUtf8File(codexConfigPath, previousConfigContent); + await stopGateway({ stateRoot, quiet: true }); + throw error; + } +} + +export async function restoreCodexConfig({ + stateRoot = DEFAULT_STATE_ROOT, + codexConfigPath = DEFAULT_CODEX_CONFIG_PATH, +}) { + const paths = getGatewayStatePaths(stateRoot); + const state = await readJsonFile(paths.statePath); + if (!state) { + throw new Error(`Install state file was not found: ${paths.statePath}`); + } + + const backupPath = `${state.latest_backup_path || ""}`; + if (!backupPath || !fs.existsSync(backupPath)) { + throw new Error(`A restorable backup file was not found: ${backupPath}`); + } + + await stopGateway({ stateRoot, quiet: true }); + await copyFile(backupPath, codexConfigPath); + await rm(paths.statePath, { force: true }); + + return { + configPath: codexConfigPath, + restoredFrom: backupPath, + }; +} + +export async function launchUi({ + codexConfigPath = DEFAULT_CODEX_CONFIG_PATH, + stateRoot = DEFAULT_STATE_ROOT, + listenHost = DEFAULT_LISTEN_HOST, + listenPort = DEFAULT_LISTEN_PORT, + noOpen = false, +}) { + const paths = getGatewayStatePaths(stateRoot); + await ensureDirectory(paths.stateRoot); + await ensureDirectory(paths.configDir); + await ensureDirectory(paths.logDir); + await ensureDirectory(paths.backupDir); + + if (!fs.existsSync(codexConfigPath)) { + throw new Error(`Codex config file was not found: ${codexConfigPath}`); + } + + const providerContext = await getCodexProviderContext(codexConfigPath); + const currentBaseUrl = `${providerContext.currentBaseUrl}`; + const requestedGatewayBaseUrl = getGatewayBaseUrl(listenHost, listenPort); + const existingState = await readJsonFile(paths.statePath); + const existingGatewayConfig = await readJsonFile(paths.configPath); + const stateGatewayBaseUrl = existingState?.gateway_base_url ? `${existingState.gateway_base_url}` : null; + const configGatewayBaseUrl = getGatewayBaseUrlFromConfig(existingGatewayConfig); + const managedGatewayBaseUrls = [requestedGatewayBaseUrl]; + for (const candidate of [stateGatewayBaseUrl, configGatewayBaseUrl]) { + if (candidate && !managedGatewayBaseUrls.includes(candidate)) { + managedGatewayBaseUrls.push(candidate); + } + } + + const originalBaseUrl = + existingState?.original_base_url + ? `${existingState.original_base_url}` + : existingGatewayConfig?.upstream_base_url + ? `${existingGatewayConfig.upstream_base_url}` + : null; + + const canReuseExistingInstall = + existingGatewayConfig && + originalBaseUrl && + managedGatewayBaseUrls.includes(currentBaseUrl); + + let mode = "install"; + if (!canReuseExistingInstall) { + await installForCurrentProvider({ + codexConfigPath, + stateRoot, + listenHost, + listenPort, + }); + } else { + mode = "reuse"; + const previousCodexConfigContent = await readFile(codexConfigPath, "utf8"); + const previousGatewayConfigContent = fs.existsSync(paths.configPath) + ? await readFile(paths.configPath, "utf8") + : null; + const previousStateContent = fs.existsSync(paths.statePath) + ? await readFile(paths.statePath, "utf8") + : null; + + try { + existingGatewayConfig.listen_host = listenHost; + existingGatewayConfig.listen_port = listenPort; + if (!existingGatewayConfig.health_path) { + existingGatewayConfig.health_path = DEFAULT_HEALTH_PATH; + } + await writeJsonFile(paths.configPath, existingGatewayConfig); + + if (currentBaseUrl !== requestedGatewayBaseUrl) { + await setCodexProviderBaseUrl({ + codexConfigPath, + providerName: providerContext.providerName, + newBaseUrl: requestedGatewayBaseUrl, + }); + } + + await startGateway({ + stateRoot, + configPath: paths.configPath, + logPath: paths.logPath, + restartIfRunning: true, + }); + + const statePayload = { + installed_at: existingState?.installed_at ? `${existingState.installed_at}` : new Date().toISOString(), + last_started_at: new Date().toISOString(), + codex_config_path: codexConfigPath, + provider_name: providerContext.providerName, + original_base_url: originalBaseUrl, + gateway_base_url: requestedGatewayBaseUrl, + gateway_config_path: paths.configPath, + gateway_log_path: paths.logPath, + gateway_pid_path: paths.pidPath, + latest_backup_path: existingState?.latest_backup_path ? `${existingState.latest_backup_path}` : "", + state_root: paths.stateRoot, + }; + await writeJsonFile(paths.statePath, statePayload); + } catch (error) { + await writeUtf8File(codexConfigPath, previousCodexConfigContent); + if (previousGatewayConfigContent !== null) { + await writeUtf8File(paths.configPath, previousGatewayConfigContent); + } + if (previousStateContent !== null) { + await writeUtf8File(paths.statePath, previousStateContent); + } + await stopGateway({ stateRoot, quiet: true }); + throw error; + } + } + + const effectiveGatewayConfig = await readJsonFile(paths.configPath); + const effectiveGatewayBaseUrl = getGatewayBaseUrlFromConfig(effectiveGatewayConfig) || requestedGatewayBaseUrl; + const uiUrl = `${effectiveGatewayBaseUrl}/__codex_retry_gateway/ui`; + + if (!noOpen) { + openUrl(uiUrl); + } + + return { + mode, + uiUrl, + gatewayBaseUrl: effectiveGatewayBaseUrl, + }; +} diff --git a/scripts/common.ps1 b/scripts/common.ps1 new file mode 100644 index 0000000..dc1cd67 --- /dev/null +++ b/scripts/common.ps1 @@ -0,0 +1,296 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Get-GatewayRoot { + return Split-Path -Parent $PSScriptRoot +} + +function Get-GatewayBaseUrl { + param( + [Parameter(Mandatory = $true)] + [string]$ListenHost, + [Parameter(Mandatory = $true)] + [int]$ListenPort + ) + + return "http://{0}:{1}" -f $ListenHost, $ListenPort +} + +function Get-GatewayStatePaths { + param( + [string]$StateRoot = (Join-Path $HOME ".codex-retry-gateway") + ) + + return [pscustomobject]@{ + StateRoot = $StateRoot + ConfigDir = Join-Path $StateRoot "config" + LogDir = Join-Path $StateRoot "logs" + BackupDir = Join-Path $StateRoot "backups" + ConfigPath = Join-Path $StateRoot "config\config.json" + LogPath = Join-Path $StateRoot "logs\gateway.log" + StatePath = Join-Path $StateRoot "state.json" + PidPath = Join-Path $StateRoot "gateway.pid" + } +} + +function Get-GatewayBaseUrlFromConfig { + param( + $GatewayConfig + ) + + if ($null -eq $GatewayConfig) { + return $null + } + + if ([string]::IsNullOrWhiteSpace([string]$GatewayConfig.listen_host) -or $null -eq $GatewayConfig.listen_port) { + return $null + } + + return Get-GatewayBaseUrl ` + -ListenHost ([string]$GatewayConfig.listen_host) ` + -ListenPort ([int]$GatewayConfig.listen_port) +} + +function Ensure-Directory { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + New-Item -ItemType Directory -Path $Path -Force | Out-Null +} + +function Write-Utf8NoBomFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Content + ) + + $parent = Split-Path -Parent $Path + if ($parent) { + Ensure-Directory -Path $parent + } + + $encoding = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::WriteAllText($Path, $Content, $encoding) +} + +function Read-JsonFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + return $null + } + + $raw = Get-Content -LiteralPath $Path -Raw + if ([string]::IsNullOrWhiteSpace($raw)) { + return $null + } + + return $raw | ConvertFrom-Json +} + +function Write-JsonFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + $Value + ) + + $json = $Value | ConvertTo-Json -Depth 20 + Write-Utf8NoBomFile -Path $Path -Content ($json + "`n") +} + +function Get-CodexProviderContext { + param( + [Parameter(Mandatory = $true)] + [string]$CodexConfigPath + ) + + $content = Get-Content -LiteralPath $CodexConfigPath -Raw + $providerMatch = [regex]::Match($content, '(?m)^\s*model_provider\s*=\s*"([^"]+)"\s*$') + if (-not $providerMatch.Success) { + throw "model_provider was not found in $CodexConfigPath" + } + + $providerName = $providerMatch.Groups[1].Value + $sectionPattern = "(?ms)^\[model_providers\." + [regex]::Escape($providerName) + "\]\s*$.*?(?=^\[|\z)" + $sectionMatch = [regex]::Match($content, $sectionPattern) + if (-not $sectionMatch.Success) { + throw "[model_providers.$providerName] was not found in $CodexConfigPath" + } + + $sectionText = $sectionMatch.Value + $baseUrlMatch = [regex]::Match($sectionText, '(?m)^\s*base_url\s*=\s*"([^"]+)"\s*$') + if (-not $baseUrlMatch.Success) { + throw "base_url was not found in [model_providers.$providerName]" + } + + return [pscustomobject]@{ + Content = $content + ProviderName = $providerName + SectionText = $sectionText + SectionIndex = $sectionMatch.Index + SectionLength = $sectionMatch.Length + CurrentBaseUrl = $baseUrlMatch.Groups[1].Value + BaseUrlLineText = $baseUrlMatch.Value + } +} + +function Set-CodexProviderBaseUrl { + param( + [Parameter(Mandatory = $true)] + [string]$CodexConfigPath, + [Parameter(Mandatory = $true)] + [string]$ProviderName, + [Parameter(Mandatory = $true)] + [string]$NewBaseUrl + ) + + $context = Get-CodexProviderContext -CodexConfigPath $CodexConfigPath + if ($context.ProviderName -ne $ProviderName) { + throw "model_provider changed unexpectedly: expected $ProviderName, actual $($context.ProviderName)" + } + + $updatedSection = [regex]::Replace( + $context.SectionText, + '(?m)^(\s*base_url\s*=\s*")([^"]*)("\s*)$', + { + param($match) + return $match.Groups[1].Value + $NewBaseUrl + $match.Groups[3].Value + }, + 1 + ) + + $updatedContent = + $context.Content.Substring(0, $context.SectionIndex) + + $updatedSection + + $context.Content.Substring($context.SectionIndex + $context.SectionLength) + + Write-Utf8NoBomFile -Path $CodexConfigPath -Content $updatedContent +} + +function Test-ProcessAlive { + param( + [Parameter(Mandatory = $true)] + [int]$ProcessId + ) + + try { + $null = Get-Process -Id $ProcessId -ErrorAction Stop + return $true + } catch { + return $false + } +} + +function Wait-GatewayHealth { + param( + [Parameter(Mandatory = $true)] + [string]$ListenHost, + [Parameter(Mandatory = $true)] + [int]$ListenPort, + [Parameter(Mandatory = $true)] + [string]$HealthPath, + [int]$TimeoutSeconds = 10 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $healthUrl = "http://{0}:{1}{2}" -f $ListenHost, $ListenPort, $HealthPath + + while ((Get-Date) -lt $deadline) { + try { + $response = Invoke-WebRequest -Uri $healthUrl -UseBasicParsing -TimeoutSec 2 + if ($response.StatusCode -eq 200) { + return $response + } + } catch { + Start-Sleep -Milliseconds 200 + } + } + + throw "Gateway health check timed out: $healthUrl" +} + +function Normalize-IntArray { + param( + $Values, + [int[]]$Default = @(516) + ) + + if ($null -eq $Values) { + return ,@($Default) + } + + $queue = New-Object System.Collections.Generic.List[object] + foreach ($item in @($Values)) { + $queue.Add($item) + } + + $normalized = @() + foreach ($value in $queue) { + if ($null -eq $value) { + continue + } + if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) { + foreach ($nestedValue in @($value)) { + if ($null -eq $nestedValue) { + continue + } + $normalized += [int]$nestedValue + } + continue + } + $normalized += [int]$value + } + + if ($normalized.Count -eq 0) { + return ,@($Default) + } + + return ,@($normalized) +} + +function Normalize-StringArray { + param( + $Values, + [string[]]$Default + ) + + if ($null -eq $Values) { + return ,@($Default) + } + + $normalized = @() + foreach ($value in @($Values)) { + if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) { + foreach ($nestedValue in @($value)) { + if ([string]::IsNullOrWhiteSpace([string]$nestedValue)) { + continue + } + foreach ($part in ([string]$nestedValue).Split(@(" ", "`t", "`r", "`n"), [System.StringSplitOptions]::RemoveEmptyEntries)) { + $normalized += $part + } + } + continue + } + if ([string]::IsNullOrWhiteSpace([string]$value)) { + continue + } + foreach ($part in ([string]$value).Split(@(" ", "`t", "`r", "`n"), [System.StringSplitOptions]::RemoveEmptyEntries)) { + $normalized += $part + } + } + + if ($normalized.Count -eq 0) { + return ,@($Default) + } + + return ,@($normalized) +} diff --git a/scripts/install-for-current-provider.mjs b/scripts/install-for-current-provider.mjs new file mode 100644 index 0000000..848bbac --- /dev/null +++ b/scripts/install-for-current-provider.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +import { + DEFAULT_CODEX_CONFIG_PATH, + DEFAULT_LISTEN_HOST, + DEFAULT_LISTEN_PORT, + DEFAULT_STATE_ROOT, + installForCurrentProvider, + parseOptions, +} from "./admin-lib.mjs"; + +async function main() { + const options = parseOptions(process.argv); + const result = await installForCurrentProvider({ + codexConfigPath: options.codexConfigPath || DEFAULT_CODEX_CONFIG_PATH, + stateRoot: options.stateRoot || DEFAULT_STATE_ROOT, + listenHost: options.listenHost || DEFAULT_LISTEN_HOST, + listenPort: options.listenPort ? Number.parseInt(`${options.listenPort}`, 10) : DEFAULT_LISTEN_PORT, + }); + + process.stdout.write("Installed Codex Retry Gateway\n"); + process.stdout.write(`provider=${result.provider}\n`); + process.stdout.write(`upstream=${result.upstream}\n`); + process.stdout.write(`gateway=${result.gateway}\n`); + process.stdout.write(`config=${result.configPath}\n`); + process.stdout.write(`backup=${result.backupPath}\n`); +} + +main().catch((error) => { + process.stderr.write(`${error?.stack || error}\n`); + process.exit(1); +}); diff --git a/scripts/install-for-current-provider.ps1 b/scripts/install-for-current-provider.ps1 new file mode 100644 index 0000000..d14aec0 --- /dev/null +++ b/scripts/install-for-current-provider.ps1 @@ -0,0 +1,109 @@ +param( + [string]$CodexConfigPath = "$HOME\.codex\config.toml", + [string]$StateRoot = "$HOME\.codex-retry-gateway", + [string]$ListenHost = "127.0.0.1", + [int]$ListenPort = 4610 +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot "common.ps1") + +$paths = Get-GatewayStatePaths -StateRoot $StateRoot +Ensure-Directory -Path $paths.StateRoot +Ensure-Directory -Path $paths.ConfigDir +Ensure-Directory -Path $paths.LogDir +Ensure-Directory -Path $paths.BackupDir + +if (-not (Test-Path -LiteralPath $CodexConfigPath)) { + throw "Codex config file was not found: $CodexConfigPath" +} + +$providerContext = Get-CodexProviderContext -CodexConfigPath $CodexConfigPath +$localGatewayBaseUrl = "http://{0}:{1}" -f $ListenHost, $ListenPort +$existingState = Read-JsonFile -Path $paths.StatePath + +$originalBaseUrl = $providerContext.CurrentBaseUrl +if ($providerContext.CurrentBaseUrl -eq $localGatewayBaseUrl) { + if ($null -eq $existingState -or [string]::IsNullOrWhiteSpace([string]$existingState.original_base_url)) { + throw "Provider already points to the local gateway, but original_base_url is missing from state." + } + $originalBaseUrl = [string]$existingState.original_base_url +} + +if ($originalBaseUrl -eq $localGatewayBaseUrl) { + throw "A real upstream_base_url could not be determined." +} + +$backupPath = Join-Path $paths.BackupDir ("config-" + (Get-Date -Format "yyyyMMdd-HHmmss") + ".toml") +Copy-Item -LiteralPath $CodexConfigPath -Destination $backupPath -Force + +$existingGatewayConfig = Read-JsonFile -Path $paths.ConfigPath +$defaultEndpoints = @("/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions") +$mergedEndpoints = @() +foreach ($endpoint in @( + $(if ($existingGatewayConfig) { Normalize-StringArray -Values $existingGatewayConfig.endpoints -Default @() } else { @() }) + + $defaultEndpoints +)) { + if ([string]::IsNullOrWhiteSpace([string]$endpoint)) { + continue + } + if ($mergedEndpoints -notcontains [string]$endpoint) { + $mergedEndpoints += [string]$endpoint + } +} + +$gatewayConfig = [ordered]@{ + listen_host = $ListenHost + listen_port = $ListenPort + upstream_base_url = $originalBaseUrl + request_body_limit_bytes = if ($existingGatewayConfig -and $null -ne $existingGatewayConfig.request_body_limit_bytes) { [int]$existingGatewayConfig.request_body_limit_bytes } else { 10485760 } + endpoints = @($mergedEndpoints) + reasoning_equals = Normalize-IntArray -Values $(if ($existingGatewayConfig) { $existingGatewayConfig.reasoning_equals } else { $null }) -Default @(516) + non_stream_status_code = if ($existingGatewayConfig -and $null -ne $existingGatewayConfig.non_stream_status_code) { [int]$existingGatewayConfig.non_stream_status_code } else { 502 } + stream_action = if ($existingGatewayConfig -and -not [string]::IsNullOrWhiteSpace([string]$existingGatewayConfig.stream_action)) { [string]$existingGatewayConfig.stream_action } else { "disconnect" } + log_match = if ($existingGatewayConfig -and $null -ne $existingGatewayConfig.log_match) { [bool]$existingGatewayConfig.log_match } else { $true } + health_path = if ($existingGatewayConfig -and -not [string]::IsNullOrWhiteSpace([string]$existingGatewayConfig.health_path)) { [string]$existingGatewayConfig.health_path } else { "/__codex_retry_gateway/health" } +} + +$previousConfigContent = Get-Content -LiteralPath $CodexConfigPath -Raw + +try { + Write-JsonFile -Path $paths.ConfigPath -Value $gatewayConfig + Set-CodexProviderBaseUrl ` + -CodexConfigPath $CodexConfigPath ` + -ProviderName $providerContext.ProviderName ` + -NewBaseUrl $localGatewayBaseUrl + + & (Join-Path $PSScriptRoot "start-gateway.ps1") ` + -StateRoot $StateRoot ` + -ConfigPath $paths.ConfigPath ` + -LogPath $paths.LogPath ` + -RestartIfRunning + + $state = [ordered]@{ + installed_at = (Get-Date).ToString("o") + codex_config_path = $CodexConfigPath + provider_name = $providerContext.ProviderName + original_base_url = $originalBaseUrl + gateway_base_url = $localGatewayBaseUrl + gateway_config_path = $paths.ConfigPath + gateway_log_path = $paths.LogPath + gateway_pid_path = $paths.PidPath + latest_backup_path = $backupPath + state_root = $paths.StateRoot + } + Write-JsonFile -Path $paths.StatePath -Value $state + + Write-Output "Installed Codex Retry Gateway" + Write-Output "provider=$($providerContext.ProviderName)" + Write-Output "upstream=$originalBaseUrl" + Write-Output "gateway=$localGatewayBaseUrl" + Write-Output "config=$($paths.ConfigPath)" + Write-Output "backup=$backupPath" +} catch { + Write-Utf8NoBomFile -Path $CodexConfigPath -Content $previousConfigContent + & (Join-Path $PSScriptRoot "stop-gateway.ps1") -StateRoot $StateRoot -Quiet + throw +} diff --git a/scripts/install-for-current-provider.sh b/scripts/install-for-current-provider.sh new file mode 100644 index 0000000..4cb85d9 --- /dev/null +++ b/scripts/install-for-current-provider.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NODE_BIN="node" +ARGS=("$@") +if command -v node.exe >/dev/null 2>&1; then + NODE_BIN="node.exe" + if command -v wslpath >/dev/null 2>&1; then + SCRIPT_DIR="$(wslpath -w "$SCRIPT_DIR")" + else + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -W)" + fi + NORMALIZED_ARGS=() + EXPECT_PATH_VALUE=0 + for ARG in "${ARGS[@]}"; do + if [[ "$EXPECT_PATH_VALUE" == 1 ]]; then + if command -v wslpath >/dev/null 2>&1; then + ARG="$(wslpath -w "$ARG")" + fi + EXPECT_PATH_VALUE=0 + fi + case "$ARG" in + --codex-config-path|--state-root) + EXPECT_PATH_VALUE=1 + ;; + esac + NORMALIZED_ARGS+=("$ARG") + done + ARGS=("${NORMALIZED_ARGS[@]}") +fi +"$NODE_BIN" "$SCRIPT_DIR/install-for-current-provider.mjs" "${ARGS[@]}" diff --git a/scripts/launch-ui.mjs b/scripts/launch-ui.mjs new file mode 100644 index 0000000..e935cf0 --- /dev/null +++ b/scripts/launch-ui.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +import { + DEFAULT_CODEX_CONFIG_PATH, + DEFAULT_LISTEN_HOST, + DEFAULT_LISTEN_PORT, + DEFAULT_STATE_ROOT, + launchUi, + parseOptions, +} from "./admin-lib.mjs"; + +async function main() { + const options = parseOptions(process.argv, { booleanFlags: ["no-open"] }); + const result = await launchUi({ + codexConfigPath: options.codexConfigPath || DEFAULT_CODEX_CONFIG_PATH, + stateRoot: options.stateRoot || DEFAULT_STATE_ROOT, + listenHost: options.listenHost || DEFAULT_LISTEN_HOST, + listenPort: options.listenPort ? Number.parseInt(`${options.listenPort}`, 10) : DEFAULT_LISTEN_PORT, + noOpen: Boolean(options.noOpen), + }); + + process.stdout.write("Codex Retry Gateway UI is ready\n"); + process.stdout.write(`mode=${result.mode}\n`); + process.stdout.write(`ui=${result.uiUrl}\n`); + process.stdout.write(`gateway=${result.gatewayBaseUrl}\n`); +} + +main().catch((error) => { + process.stderr.write(`${error?.stack || error}\n`); + process.exit(1); +}); diff --git a/scripts/launch-ui.ps1 b/scripts/launch-ui.ps1 new file mode 100644 index 0000000..f0881db --- /dev/null +++ b/scripts/launch-ui.ps1 @@ -0,0 +1,130 @@ +param( + [string]$CodexConfigPath = "$HOME\.codex\config.toml", + [string]$StateRoot = "$HOME\.codex-retry-gateway", + [string]$ListenHost = "127.0.0.1", + [int]$ListenPort = 4610, + [switch]$NoOpen +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot "common.ps1") + +$paths = Get-GatewayStatePaths -StateRoot $StateRoot +Ensure-Directory -Path $paths.StateRoot +Ensure-Directory -Path $paths.ConfigDir +Ensure-Directory -Path $paths.LogDir +Ensure-Directory -Path $paths.BackupDir + +if (-not (Test-Path -LiteralPath $CodexConfigPath)) { + throw "Codex config file was not found: $CodexConfigPath" +} + +$providerContext = Get-CodexProviderContext -CodexConfigPath $CodexConfigPath +$currentBaseUrl = [string]$providerContext.CurrentBaseUrl +$requestedGatewayBaseUrl = Get-GatewayBaseUrl -ListenHost $ListenHost -ListenPort $ListenPort +$existingState = Read-JsonFile -Path $paths.StatePath +$existingGatewayConfig = Read-JsonFile -Path $paths.ConfigPath +$stateGatewayBaseUrl = if ($existingState -and -not [string]::IsNullOrWhiteSpace([string]$existingState.gateway_base_url)) { [string]$existingState.gateway_base_url } else { $null } +$configGatewayBaseUrl = Get-GatewayBaseUrlFromConfig -GatewayConfig $existingGatewayConfig +$managedGatewayBaseUrls = @($requestedGatewayBaseUrl) +foreach ($candidate in @($stateGatewayBaseUrl, $configGatewayBaseUrl)) { + if ([string]::IsNullOrWhiteSpace([string]$candidate)) { + continue + } + if ($managedGatewayBaseUrls -notcontains [string]$candidate) { + $managedGatewayBaseUrls += [string]$candidate + } +} + +$originalBaseUrl = if ($existingState -and -not [string]::IsNullOrWhiteSpace([string]$existingState.original_base_url)) { + [string]$existingState.original_base_url +} elseif ($existingGatewayConfig -and -not [string]::IsNullOrWhiteSpace([string]$existingGatewayConfig.upstream_base_url)) { + [string]$existingGatewayConfig.upstream_base_url +} else { + $null +} + +$canReuseExistingInstall = + ($null -ne $existingGatewayConfig) -and + (-not [string]::IsNullOrWhiteSpace([string]$originalBaseUrl)) -and + ($managedGatewayBaseUrls -contains $currentBaseUrl) + +$mode = "install" + +if (-not $canReuseExistingInstall) { + & (Join-Path $PSScriptRoot "install-for-current-provider.ps1") ` + -CodexConfigPath $CodexConfigPath ` + -StateRoot $StateRoot ` + -ListenHost $ListenHost ` + -ListenPort $ListenPort +} else { + $mode = "reuse" + $previousCodexConfigContent = Get-Content -LiteralPath $CodexConfigPath -Raw + $previousGatewayConfigContent = if (Test-Path -LiteralPath $paths.ConfigPath) { Get-Content -LiteralPath $paths.ConfigPath -Raw } else { $null } + $previousStateContent = if (Test-Path -LiteralPath $paths.StatePath) { Get-Content -LiteralPath $paths.StatePath -Raw } else { $null } + + try { + $existingGatewayConfig.listen_host = $ListenHost + $existingGatewayConfig.listen_port = $ListenPort + if ([string]::IsNullOrWhiteSpace([string]$existingGatewayConfig.health_path)) { + $existingGatewayConfig.health_path = "/__codex_retry_gateway/health" + } + Write-JsonFile -Path $paths.ConfigPath -Value $existingGatewayConfig + + if ($currentBaseUrl -ne $requestedGatewayBaseUrl) { + Set-CodexProviderBaseUrl ` + -CodexConfigPath $CodexConfigPath ` + -ProviderName $providerContext.ProviderName ` + -NewBaseUrl $requestedGatewayBaseUrl + } + + & (Join-Path $PSScriptRoot "start-gateway.ps1") ` + -StateRoot $StateRoot ` + -ConfigPath $paths.ConfigPath ` + -LogPath $paths.LogPath ` + -RestartIfRunning + + $statePayload = [ordered]@{ + installed_at = if ($existingState -and $existingState.installed_at) { [string]$existingState.installed_at } else { (Get-Date).ToString("o") } + last_started_at = (Get-Date).ToString("o") + codex_config_path = $CodexConfigPath + provider_name = $providerContext.ProviderName + original_base_url = $originalBaseUrl + gateway_base_url = $requestedGatewayBaseUrl + gateway_config_path = $paths.ConfigPath + gateway_log_path = $paths.LogPath + gateway_pid_path = $paths.PidPath + latest_backup_path = if ($existingState -and $existingState.latest_backup_path) { [string]$existingState.latest_backup_path } else { "" } + state_root = $paths.StateRoot + } + Write-JsonFile -Path $paths.StatePath -Value $statePayload + } catch { + Write-Utf8NoBomFile -Path $CodexConfigPath -Content $previousCodexConfigContent + if ($null -ne $previousGatewayConfigContent) { + Write-Utf8NoBomFile -Path $paths.ConfigPath -Content $previousGatewayConfigContent + } + if ($null -ne $previousStateContent) { + Write-Utf8NoBomFile -Path $paths.StatePath -Content $previousStateContent + } + & (Join-Path $PSScriptRoot "stop-gateway.ps1") -StateRoot $StateRoot -Quiet + throw + } +} + +$effectiveGatewayConfig = Read-JsonFile -Path $paths.ConfigPath +$effectiveGatewayBaseUrl = Get-GatewayBaseUrlFromConfig -GatewayConfig $effectiveGatewayConfig +if ([string]::IsNullOrWhiteSpace([string]$effectiveGatewayBaseUrl)) { + $effectiveGatewayBaseUrl = $requestedGatewayBaseUrl +} + +$uiUrl = $effectiveGatewayBaseUrl + "/__codex_retry_gateway/ui" +if (-not $NoOpen) { + Start-Process $uiUrl | Out-Null +} + +Write-Output "Codex Retry Gateway UI is ready" +Write-Output "mode=$mode" +Write-Output "ui=$uiUrl" +Write-Output "gateway=$effectiveGatewayBaseUrl" diff --git a/scripts/launch-ui.sh b/scripts/launch-ui.sh new file mode 100644 index 0000000..e4eb335 --- /dev/null +++ b/scripts/launch-ui.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NODE_BIN="node" +ARGS=("$@") +if command -v node.exe >/dev/null 2>&1; then + NODE_BIN="node.exe" + if command -v wslpath >/dev/null 2>&1; then + SCRIPT_DIR="$(wslpath -w "$SCRIPT_DIR")" + else + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -W)" + fi + NORMALIZED_ARGS=() + EXPECT_PATH_VALUE=0 + for ARG in "${ARGS[@]}"; do + if [[ "$EXPECT_PATH_VALUE" == 1 ]]; then + if command -v wslpath >/dev/null 2>&1; then + ARG="$(wslpath -w "$ARG")" + fi + EXPECT_PATH_VALUE=0 + fi + case "$ARG" in + --codex-config-path|--state-root) + EXPECT_PATH_VALUE=1 + ;; + esac + NORMALIZED_ARGS+=("$ARG") + done + ARGS=("${NORMALIZED_ARGS[@]}") +fi +"$NODE_BIN" "$SCRIPT_DIR/launch-ui.mjs" "${ARGS[@]}" diff --git a/scripts/restore-codex-config.mjs b/scripts/restore-codex-config.mjs new file mode 100644 index 0000000..bd1fd04 --- /dev/null +++ b/scripts/restore-codex-config.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node + +import { + DEFAULT_CODEX_CONFIG_PATH, + DEFAULT_STATE_ROOT, + parseOptions, + restoreCodexConfig, +} from "./admin-lib.mjs"; + +async function main() { + const options = parseOptions(process.argv); + const result = await restoreCodexConfig({ + stateRoot: options.stateRoot || DEFAULT_STATE_ROOT, + codexConfigPath: options.codexConfigPath || DEFAULT_CODEX_CONFIG_PATH, + }); + + process.stdout.write("Restored Codex config\n"); + process.stdout.write(`config=${result.configPath}\n`); + process.stdout.write(`restored_from=${result.restoredFrom}\n`); +} + +main().catch((error) => { + process.stderr.write(`${error?.stack || error}\n`); + process.exit(1); +}); diff --git a/scripts/restore-codex-config.ps1 b/scripts/restore-codex-config.ps1 new file mode 100644 index 0000000..c7c1b58 --- /dev/null +++ b/scripts/restore-codex-config.ps1 @@ -0,0 +1,28 @@ +param( + [string]$StateRoot = "$HOME\.codex-retry-gateway", + [string]$CodexConfigPath = "$HOME\.codex\config.toml" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot "common.ps1") + +$paths = Get-GatewayStatePaths -StateRoot $StateRoot +$state = Read-JsonFile -Path $paths.StatePath +if ($null -eq $state) { + throw "Install state file was not found: $($paths.StatePath)" +} + +$backupPath = [string]$state.latest_backup_path +if ([string]::IsNullOrWhiteSpace($backupPath) -or -not (Test-Path -LiteralPath $backupPath)) { + throw "A restorable backup file was not found: $backupPath" +} + +& (Join-Path $PSScriptRoot "stop-gateway.ps1") -StateRoot $StateRoot -Quiet +Copy-Item -LiteralPath $backupPath -Destination $CodexConfigPath -Force +Remove-Item -LiteralPath $paths.StatePath -Force -ErrorAction SilentlyContinue + +Write-Output "Restored Codex config" +Write-Output "config=$CodexConfigPath" +Write-Output "restored_from=$backupPath" diff --git a/scripts/restore-codex-config.sh b/scripts/restore-codex-config.sh new file mode 100644 index 0000000..c41a6d2 --- /dev/null +++ b/scripts/restore-codex-config.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NODE_BIN="node" +ARGS=("$@") +if command -v node.exe >/dev/null 2>&1; then + NODE_BIN="node.exe" + if command -v wslpath >/dev/null 2>&1; then + SCRIPT_DIR="$(wslpath -w "$SCRIPT_DIR")" + else + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -W)" + fi + NORMALIZED_ARGS=() + EXPECT_PATH_VALUE=0 + for ARG in "${ARGS[@]}"; do + if [[ "$EXPECT_PATH_VALUE" == 1 ]]; then + if command -v wslpath >/dev/null 2>&1; then + ARG="$(wslpath -w "$ARG")" + fi + EXPECT_PATH_VALUE=0 + fi + case "$ARG" in + --codex-config-path|--state-root) + EXPECT_PATH_VALUE=1 + ;; + esac + NORMALIZED_ARGS+=("$ARG") + done + ARGS=("${NORMALIZED_ARGS[@]}") +fi +"$NODE_BIN" "$SCRIPT_DIR/restore-codex-config.mjs" "${ARGS[@]}" diff --git a/scripts/start-gateway.mjs b/scripts/start-gateway.mjs new file mode 100644 index 0000000..51d22ba --- /dev/null +++ b/scripts/start-gateway.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +import { + DEFAULT_STATE_ROOT, + getGatewayStatePaths, + parseOptions, + startGateway, +} from "./admin-lib.mjs"; + +async function main() { + const options = parseOptions(process.argv, { booleanFlags: ["restart-if-running"] }); + const stateRoot = options.stateRoot || DEFAULT_STATE_ROOT; + const paths = getGatewayStatePaths(stateRoot); + + const message = await startGateway({ + stateRoot, + configPath: options.configPath || paths.configPath, + logPath: options.logPath || paths.logPath, + restartIfRunning: Boolean(options.restartIfRunning), + }); + + process.stdout.write(`${message}\n`); +} + +main().catch((error) => { + process.stderr.write(`${error?.stack || error}\n`); + process.exit(1); +}); diff --git a/scripts/start-gateway.ps1 b/scripts/start-gateway.ps1 new file mode 100644 index 0000000..240617e --- /dev/null +++ b/scripts/start-gateway.ps1 @@ -0,0 +1,84 @@ +param( + [string]$StateRoot = "$HOME\.codex-retry-gateway", + [string]$ConfigPath, + [string]$LogPath, + [switch]$RestartIfRunning +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot "common.ps1") + +$paths = Get-GatewayStatePaths -StateRoot $StateRoot +if (-not $ConfigPath) { + $ConfigPath = $paths.ConfigPath +} +if (-not $LogPath) { + $LogPath = $paths.LogPath +} + +if (-not (Test-Path -LiteralPath $ConfigPath)) { + throw "Gateway config file was not found: $ConfigPath" +} + +Ensure-Directory -Path (Split-Path -Parent $LogPath) + +if (Test-Path -LiteralPath $paths.PidPath) { + $existingPidRaw = (Get-Content -LiteralPath $paths.PidPath -Raw).Trim() + if ($existingPidRaw) { + $existingPid = [int]$existingPidRaw + if (Test-ProcessAlive -ProcessId $existingPid) { + if ($RestartIfRunning) { + & (Join-Path $PSScriptRoot "stop-gateway.ps1") -StateRoot $StateRoot -Quiet + } else { + Write-Output "Gateway is already running. PID=$existingPid" + exit 0 + } + } else { + Remove-Item -LiteralPath $paths.PidPath -Force + } + } +} + +$gatewayConfig = Read-JsonFile -Path $ConfigPath +if ($null -eq $gatewayConfig) { + throw "Gateway config file could not be read: $ConfigPath" +} + +$gatewayRoot = Get-GatewayRoot +$gatewayEntry = Join-Path $gatewayRoot "gateway.mjs" +if (-not (Test-Path -LiteralPath $gatewayEntry)) { + throw "Gateway entry file was not found: $gatewayEntry" +} + +$nodeCommand = (Get-Command node -ErrorAction Stop).Source +$argumentLine = @( + ('"{0}"' -f $gatewayEntry), + "--config", + ('"{0}"' -f $ConfigPath), + "--log", + ('"{0}"' -f $LogPath) +) -join " " + +$process = Start-Process ` + -FilePath $nodeCommand ` + -ArgumentList $argumentLine ` + -WorkingDirectory $gatewayRoot ` + -WindowStyle Hidden ` + -PassThru + +Set-Content -LiteralPath $paths.PidPath -Value $process.Id -NoNewline + +Start-Sleep -Milliseconds 300 +if ($process.HasExited) { + $logTail = if (Test-Path -LiteralPath $LogPath) { Get-Content -LiteralPath $LogPath -Tail 20 | Out-String } else { "" } + throw "Gateway exited right after startup. PID=$($process.Id)`n$logTail" +} + +$null = Wait-GatewayHealth ` + -ListenHost ([string]$gatewayConfig.listen_host) ` + -ListenPort ([int]$gatewayConfig.listen_port) ` + -HealthPath ([string]$gatewayConfig.health_path) + +Write-Output ("Gateway started. PID={0}. Listen=http://{1}:{2}" -f $process.Id, $gatewayConfig.listen_host, $gatewayConfig.listen_port) diff --git a/scripts/start-gateway.sh b/scripts/start-gateway.sh new file mode 100644 index 0000000..f543192 --- /dev/null +++ b/scripts/start-gateway.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NODE_BIN="node" +ARGS=("$@") +if command -v node.exe >/dev/null 2>&1; then + NODE_BIN="node.exe" + if command -v wslpath >/dev/null 2>&1; then + SCRIPT_DIR="$(wslpath -w "$SCRIPT_DIR")" + else + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -W)" + fi + NORMALIZED_ARGS=() + EXPECT_PATH_VALUE=0 + for ARG in "${ARGS[@]}"; do + if [[ "$EXPECT_PATH_VALUE" == 1 ]]; then + if command -v wslpath >/dev/null 2>&1; then + ARG="$(wslpath -w "$ARG")" + fi + EXPECT_PATH_VALUE=0 + fi + case "$ARG" in + --state-root|--config-path|--log-path) + EXPECT_PATH_VALUE=1 + ;; + esac + NORMALIZED_ARGS+=("$ARG") + done + ARGS=("${NORMALIZED_ARGS[@]}") +fi +"$NODE_BIN" "$SCRIPT_DIR/start-gateway.mjs" "${ARGS[@]}" diff --git a/scripts/stop-gateway.mjs b/scripts/stop-gateway.mjs new file mode 100644 index 0000000..3ba6e6a --- /dev/null +++ b/scripts/stop-gateway.mjs @@ -0,0 +1,24 @@ +#!/usr/bin/env node + +import { + DEFAULT_STATE_ROOT, + parseOptions, + stopGateway, +} from "./admin-lib.mjs"; + +async function main() { + const options = parseOptions(process.argv, { booleanFlags: ["quiet"] }); + const message = await stopGateway({ + stateRoot: options.stateRoot || DEFAULT_STATE_ROOT, + quiet: Boolean(options.quiet), + }); + + if (message) { + process.stdout.write(`${message}\n`); + } +} + +main().catch((error) => { + process.stderr.write(`${error?.stack || error}\n`); + process.exit(1); +}); diff --git a/scripts/stop-gateway.ps1 b/scripts/stop-gateway.ps1 new file mode 100644 index 0000000..7318978 --- /dev/null +++ b/scripts/stop-gateway.ps1 @@ -0,0 +1,36 @@ +param( + [string]$StateRoot = "$HOME\.codex-retry-gateway", + [switch]$Quiet +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot "common.ps1") + +$paths = Get-GatewayStatePaths -StateRoot $StateRoot +if (-not (Test-Path -LiteralPath $paths.PidPath)) { + if (-not $Quiet) { + Write-Output "No running gateway PID file was found." + } + exit 0 +} + +$pidRaw = (Get-Content -LiteralPath $paths.PidPath -Raw).Trim() +if (-not $pidRaw) { + Remove-Item -LiteralPath $paths.PidPath -Force + if (-not $Quiet) { + Write-Output "Gateway PID file was empty and has been removed." + } + exit 0 +} + +$gatewayPid = [int]$pidRaw +if (Test-ProcessAlive -ProcessId $gatewayPid) { + Stop-Process -Id $gatewayPid -Force +} + +Remove-Item -LiteralPath $paths.PidPath -Force -ErrorAction SilentlyContinue +if (-not $Quiet) { + Write-Output "Gateway stopped. PID=$gatewayPid" +} diff --git a/scripts/stop-gateway.sh b/scripts/stop-gateway.sh new file mode 100644 index 0000000..674ac53 --- /dev/null +++ b/scripts/stop-gateway.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NODE_BIN="node" +ARGS=("$@") +if command -v node.exe >/dev/null 2>&1; then + NODE_BIN="node.exe" + if command -v wslpath >/dev/null 2>&1; then + SCRIPT_DIR="$(wslpath -w "$SCRIPT_DIR")" + else + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -W)" + fi + NORMALIZED_ARGS=() + EXPECT_PATH_VALUE=0 + for ARG in "${ARGS[@]}"; do + if [[ "$EXPECT_PATH_VALUE" == 1 ]]; then + if command -v wslpath >/dev/null 2>&1; then + ARG="$(wslpath -w "$ARG")" + fi + EXPECT_PATH_VALUE=0 + fi + case "$ARG" in + --state-root) + EXPECT_PATH_VALUE=1 + ;; + esac + NORMALIZED_ARGS+=("$ARG") + done + ARGS=("${NORMALIZED_ARGS[@]}") +fi +"$NODE_BIN" "$SCRIPT_DIR/stop-gateway.mjs" "${ARGS[@]}" diff --git a/scripts/test-gateway-e2e.mjs b/scripts/test-gateway-e2e.mjs new file mode 100644 index 0000000..de8058e --- /dev/null +++ b/scripts/test-gateway-e2e.mjs @@ -0,0 +1,316 @@ +#!/usr/bin/env node + +import http from "node:http"; +import net from "node:net"; +import { once } from "node:events"; +import { spawn } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +const gatewayRoot = path.resolve(import.meta.dirname, ".."); +const gatewayEntry = path.join(gatewayRoot, "gateway.mjs"); + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +async function getFreePort() { + const server = net.createServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + const port = address && typeof address === "object" ? address.port : null; + server.close(); + await once(server, "close"); + if (!port) { + throw new Error("无法分配空闲端口"); + } + return port; +} + +function createJsonResponse(res, statusCode, body, extraHeaders = {}) { + res.writeHead(statusCode, { + "content-type": "application/json; charset=utf-8", + ...extraHeaders, + }); + res.end(JSON.stringify(body)); +} + +function createSseResponse(res, chunks) { + res.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache", + connection: "keep-alive", + "x-upstream-test": "sse", + }); + + let index = 0; + const timer = setInterval(() => { + if (index >= chunks.length) { + clearInterval(timer); + res.end(); + return; + } + res.write(chunks[index]); + index += 1; + }, 20); + + res.on("close", () => { + clearInterval(timer); + }); +} + +function startFakeUpstream(port) { + const server = http.createServer((req, res) => { + const responsePaths = new Set(["/responses", "/v1/responses"]); + const chatCompletionPaths = new Set(["/chat/completions", "/v1/chat/completions"]); + + if (req.method === "GET" && req.url === "/v1/models") { + createJsonResponse( + res, + 200, + { + object: "list", + data: [{ id: "fake-model" }], + }, + { "x-upstream-test": "models-ok" }, + ); + return; + } + + if (req.method === "POST" && responsePaths.has(req.url)) { + let body = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + const parsed = JSON.parse(body || "{}"); + const reasoning = parsed.test_reasoning_tokens ?? 128; + createJsonResponse( + res, + 200, + { + id: "resp_test", + usage: { + output_tokens_details: { + reasoning_tokens: reasoning, + }, + }, + }, + { "x-upstream-test": `responses-${reasoning}` }, + ); + }); + return; + } + + if (req.method === "POST" && chatCompletionPaths.has(req.url)) { + let body = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + const parsed = JSON.parse(body || "{}"); + const reasoning = parsed.test_reasoning_tokens ?? 128; + if (reasoning === 516) { + createSseResponse(res, [ + 'data: {"id":"chunk-1","choices":[{"delta":{"content":"hello"}}]}\n\n', + 'data: {"usage":{"completion_tokens_details":{"reasoning_tokens":516}}}\n\n', + "data: [DONE]\n\n", + ]); + return; + } + + createSseResponse(res, [ + 'data: {"id":"chunk-1","choices":[{"delta":{"content":"hello"}}]}\n\n', + 'data: {"usage":{"completion_tokens_details":{"reasoning_tokens":128}}}\n\n', + "data: [DONE]\n\n", + ]); + }); + return; + } + + createJsonResponse(res, 404, { error: "not found" }); + }); + + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => resolve(server)); + }); +} + +async function waitForHealth(url, timeoutMs = 5000) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + try { + const response = await fetch(url); + if (response.ok) { + return; + } + } catch { + // ignore startup race + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`等待网关健康检查超时: ${url}`); +} + +function startGateway(configPath, logPath) { + const child = spawn(process.execPath, [gatewayEntry, "--config", configPath, "--log", logPath], { + cwd: gatewayRoot, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + return { + child, + getOutput() { + return { stdout, stderr }; + }, + }; +} + +async function readSseUntilClose(url, requestBody) { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(requestBody), + }); + + const reader = response.body.getReader(); + const decoder = new TextDecoder("utf8"); + let text = ""; + let closedByError = false; + + while (true) { + try { + const { done, value } = await reader.read(); + if (done) { + break; + } + text += decoder.decode(value, { stream: true }); + } catch (error) { + closedByError = true; + text += `\n[[reader-error:${error?.name || "unknown"}]]`; + break; + } + } + + text += decoder.decode(); + return { + status: response.status, + headers: response.headers, + text, + closedByError, + }; +} + +async function run() { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "codex-retry-gateway-")); + const upstreamPort = await getFreePort(); + const gatewayPort = await getFreePort(); + const configPath = path.join(tempRoot, "config.json"); + const logPath = path.join(tempRoot, "gateway.log"); + + const config = { + listen_host: "127.0.0.1", + listen_port: gatewayPort, + upstream_base_url: `http://127.0.0.1:${upstreamPort}`, + request_body_limit_bytes: 10 * 1024 * 1024, + endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], + reasoning_equals: [516], + non_stream_status_code: 502, + stream_action: "disconnect", + log_match: true, + health_path: "/__codex_retry_gateway/health", + }; + + await writeFile(configPath, JSON.stringify(config, null, 2), "utf8"); + + const upstream = await startFakeUpstream(upstreamPort); + const gateway = startGateway(configPath, logPath); + + try { + await waitForHealth(`http://127.0.0.1:${gatewayPort}${config.health_path}`); + + const modelsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/v1/models`); + assert(modelsResponse.status === 200, `/v1/models 透传状态异常: ${modelsResponse.status}`); + assert( + modelsResponse.headers.get("x-upstream-test") === "models-ok", + "/v1/models 未保留上游头", + ); + + for (const responsePath of ["/responses", "/v1/responses"]) { + const blockedResponse = await fetch(`http://127.0.0.1:${gatewayPort}${responsePath}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ test_reasoning_tokens: 516 }), + }); + const blockedBody = await blockedResponse.json(); + assert(blockedResponse.status === 502, `${responsePath} 516 未返回 502: ${blockedResponse.status}`); + assert( + blockedBody?.error?.code === "reasoning_guard_triggered", + `${responsePath} 516 返回体不正确`, + ); + + const okResponse = await fetch(`http://127.0.0.1:${gatewayPort}${responsePath}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ test_reasoning_tokens: 128 }), + }); + const okBody = await okResponse.json(); + assert(okResponse.status === 200, `${responsePath} 128 透传状态异常: ${okResponse.status}`); + assert(okResponse.headers.get("x-upstream-test") === "responses-128", `${responsePath} 128 未保留头`); + assert( + okBody?.usage?.output_tokens_details?.reasoning_tokens === 128, + `${responsePath} 128 返回体异常`, + ); + } + + for (const streamPath of ["/chat/completions", "/v1/chat/completions"]) { + const blockedStream = await readSseUntilClose( + `http://127.0.0.1:${gatewayPort}${streamPath}`, + { stream: true, test_reasoning_tokens: 516 }, + ); + assert(blockedStream.status === 200, `${streamPath} 516 首状态异常: ${blockedStream.status}`); + assert(blockedStream.text.includes('"content":"hello"'), `${streamPath} 流式 516 未先透传正常 chunk`); + assert(!blockedStream.text.includes("[DONE]"), `${streamPath} 流式 516 不应完整结束`); + assert( + blockedStream.closedByError || blockedStream.text.includes("[[reader-error:"), + `${streamPath} 流式 516 未表现为中途断开`, + ); + + const okStream = await readSseUntilClose( + `http://127.0.0.1:${gatewayPort}${streamPath}`, + { stream: true, test_reasoning_tokens: 128 }, + ); + assert(okStream.status === 200, `${streamPath} 128 首状态异常: ${okStream.status}`); + assert(okStream.text.includes("[DONE]"), `${streamPath} 流式 128 未完整结束`); + assert(!okStream.closedByError, `${streamPath} 流式 128 不应异常断开`); + } + + process.stdout.write("PASS codex-retry-gateway e2e\n"); + } finally { + gateway.child.kill(); + upstream.close(); + await once(upstream, "close"); + await rm(tempRoot, { recursive: true, force: true }); + } +} + +run().catch((error) => { + process.stderr.write(`${error?.stack || error}\n`); + process.exit(1); +}); diff --git a/scripts/test-gateway-e2e.ps1 b/scripts/test-gateway-e2e.ps1 new file mode 100644 index 0000000..533dd46 --- /dev/null +++ b/scripts/test-gateway-e2e.ps1 @@ -0,0 +1,9 @@ +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$nodeScript = Join-Path $scriptDir "test-gateway-e2e.mjs" + +node $nodeScript +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} diff --git a/scripts/test-install-restore.mjs b/scripts/test-install-restore.mjs new file mode 100644 index 0000000..f4e59db --- /dev/null +++ b/scripts/test-install-restore.mjs @@ -0,0 +1,293 @@ +#!/usr/bin/env node + +import http from "node:http"; +import net from "node:net"; +import { once } from "node:events"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; + +const scriptsRoot = import.meta.dirname; +const installScript = path.join(scriptsRoot, "install-for-current-provider.ps1"); +const restoreScript = path.join(scriptsRoot, "restore-codex-config.ps1"); + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +async function getFreePort() { + const server = net.createServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + const port = address && typeof address === "object" ? address.port : null; + server.close(); + await once(server, "close"); + if (!port) { + throw new Error("Failed to allocate a free port"); + } + return port; +} + +function startFakeUpstream(port) { + const server = http.createServer((req, res) => { + if (req.method === "GET" && req.url === "/v1/models") { + res.writeHead(200, { + "content-type": "application/json; charset=utf-8", + "x-upstream-test": "install-flow-ok", + }); + res.end(JSON.stringify({ object: "list", data: [{ id: "install-test-model" }] })); + return; + } + + if (req.method === "POST" && (req.url === "/responses" || req.url === "/v1/responses")) { + let body = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + const parsed = JSON.parse(body || "{}"); + const reasoning = parsed.test_reasoning_tokens ?? 128; + res.writeHead(200, { "content-type": "application/json; charset=utf-8" }); + res.end( + JSON.stringify({ + id: "install-test-response", + usage: { + output_tokens_details: { + reasoning_tokens: reasoning, + }, + }, + }), + ); + }); + return; + } + + res.writeHead(404, { "content-type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "not found" })); + }); + + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => resolve(server)); + }); +} + +async function runPowerShellScript(scriptPath, args) { + const child = spawn( + "powershell", + ["-ExecutionPolicy", "Bypass", "-File", scriptPath, ...args], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + const [exitCode] = await once(child, "exit"); + if (exitCode !== 0) { + throw new Error(`PowerShell script failed: ${scriptPath}\nstdout:\n${stdout}\nstderr:\n${stderr}`); + } + + return { stdout, stderr }; +} + +async function run() { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "codex-retry-gateway-install-")); + const codexDir = path.join(tempRoot, ".codex"); + const stateRoot = path.join(tempRoot, ".codex-retry-gateway"); + const codexConfigPath = path.join(codexDir, "config.toml"); + const upstreamPort = await getFreePort(); + const gatewayPort = await getFreePort(); + + await mkdir(codexDir, { recursive: true }); + await writeFile( + codexConfigPath, + [ + 'model_provider = "custom"', + "", + "[model_providers.custom]", + 'name = "Install Test"', + `base_url = "http://127.0.0.1:${upstreamPort}"`, + 'wire_api = "responses"', + "", + ].join("\n"), + "utf8", + ); + + const upstream = await startFakeUpstream(upstreamPort); + + try { + await runPowerShellScript(installScript, [ + "-CodexConfigPath", + codexConfigPath, + "-StateRoot", + stateRoot, + "-ListenPort", + String(gatewayPort), + ]); + + const updatedConfig = await readFile(codexConfigPath, "utf8"); + assert( + updatedConfig.includes(`base_url = "http://127.0.0.1:${gatewayPort}"`), + "Install script did not redirect base_url to local gateway", + ); + + const gatewayConfig = JSON.parse( + await readFile(path.join(stateRoot, "config", "config.json"), "utf8"), + ); + assert( + gatewayConfig.upstream_base_url === `http://127.0.0.1:${upstreamPort}`, + "Gateway config did not preserve original upstream_base_url", + ); + assert(Array.isArray(gatewayConfig.endpoints), "Gateway config endpoints must be an array"); + assert( + gatewayConfig.endpoints.includes("/responses") && + gatewayConfig.endpoints.includes("/chat/completions") && + gatewayConfig.endpoints.includes("/v1/responses") && + gatewayConfig.endpoints.includes("/v1/chat/completions"), + "Gateway config endpoints did not include both root and /v1 variants", + ); + + const proxiedModels = await fetch(`http://127.0.0.1:${gatewayPort}/v1/models`); + assert(proxiedModels.status === 200, `/v1/models through installed gateway failed: ${proxiedModels.status}`); + assert( + proxiedModels.headers.get("x-upstream-test") === "install-flow-ok", + "Installed gateway did not preserve upstream header", + ); + + const uiResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/ui`); + const uiHtml = await uiResponse.text(); + assert(uiResponse.status === 200, `Management UI failed to load: ${uiResponse.status}`); + assert(uiHtml.includes("Codex Retry Gateway"), "Management UI HTML did not include expected title"); + assert(uiHtml.includes("516 命中次数"), "Management UI HTML did not include 516 match stats"); + assert(uiHtml.includes("516 占比"), "Management UI HTML did not include 516 ratio stats"); + assert(uiHtml.includes("实时日志"), "Management UI HTML did not include live log panel"); + + const statusResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`); + const statusPayload = await statusResponse.json(); + assert(statusResponse.status === 200, `Status API failed: ${statusResponse.status}`); + assert(statusPayload.config?.upstream_base_url === `http://127.0.0.1:${upstreamPort}`, "Status API did not expose config"); + assert(statusPayload.state?.original_base_url === `http://127.0.0.1:${upstreamPort}`, "Status API did not expose install state"); + assert(statusPayload.metrics?.inspected_response_count === 0, "Status API did not expose initial inspected count"); + assert(statusPayload.metrics?.reasoning_516_count === 0, "Status API did not expose initial 516 count"); + + const normalResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ test_reasoning_tokens: 128 }), + }); + assert(normalResponse.status === 200, `Expected a passthrough response before 516 test: ${normalResponse.status}`); + + const blocked516Response = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ test_reasoning_tokens: 516 }), + }); + assert(blocked516Response.status === 502, `Default 516 block did not trigger: ${blocked516Response.status}`); + + const metricsStatusResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`); + const metricsStatusPayload = await metricsStatusResponse.json(); + assert(metricsStatusResponse.status === 200, `Status API failed after traffic: ${metricsStatusResponse.status}`); + assert(metricsStatusPayload.metrics?.inspected_response_count === 2, "Status API inspected count was not updated"); + assert(metricsStatusPayload.metrics?.matched_response_count === 1, "Status API matched count was not updated"); + assert(metricsStatusPayload.metrics?.reasoning_516_count === 1, "Status API 516 count was not updated"); + assert(metricsStatusPayload.metrics?.reasoning_516_ratio === 0.5, "Status API 516 ratio was not updated"); + + const logsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/logs`); + const logsPayload = await logsResponse.json(); + assert(logsResponse.status === 200, `Logs API failed: ${logsResponse.status}`); + assert(Array.isArray(logsPayload.entries), "Logs API did not return entries array"); + assert( + logsPayload.entries.some((entry) => `${entry.message || ""}`.includes("[start]")), + "Logs API did not include gateway start log", + ); + assert( + logsPayload.entries.some((entry) => `${entry.message || ""}`.includes("reasoning_tokens=516")), + "Logs API did not include 516 match log", + ); + + const saveConfigResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/config`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + reasoning_equals: [1024], + endpoints: ["/responses", "/v1/responses"], + non_stream_status_code: 503, + log_match: false, + }), + }); + const saveConfigPayload = await saveConfigResponse.json(); + assert(saveConfigResponse.status === 200, `Save config API failed: ${saveConfigResponse.status}`); + assert(saveConfigPayload.config?.non_stream_status_code === 503, "Save config API did not return updated config"); + + const updatedGatewayConfig = JSON.parse( + await readFile(path.join(stateRoot, "config", "config.json"), "utf8"), + ); + assert( + JSON.stringify(updatedGatewayConfig.reasoning_equals) === JSON.stringify([1024]), + "Saved config file did not persist reasoning_equals", + ); + + const incrementalLogsResponse = await fetch( + `http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/logs?since_seq=${logsPayload.latest_seq}`, + ); + const incrementalLogsPayload = await incrementalLogsResponse.json(); + assert(incrementalLogsResponse.status === 200, `Incremental logs API failed: ${incrementalLogsResponse.status}`); + assert( + incrementalLogsPayload.entries.some((entry) => `${entry.message || ""}`.includes("[config] updated")), + "Incremental logs API did not include config update log", + ); + + const blockedAfterSave = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ test_reasoning_tokens: 1024 }), + }); + assert(blockedAfterSave.status === 503, `Hot reloaded config did not take effect: ${blockedAfterSave.status}`); + + const restoreViaUiResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + const restoreViaUiPayload = await restoreViaUiResponse.json(); + assert(restoreViaUiResponse.status === 202, `Restore API failed: ${restoreViaUiResponse.status}`); + assert(restoreViaUiPayload.ok === true, "Restore API did not acknowledge the restore request"); + + const restoreStartedAt = Date.now(); + while (Date.now() - restoreStartedAt < 10000) { + const restoredCandidate = await readFile(codexConfigPath, "utf8"); + if (restoredCandidate.includes(`base_url = "http://127.0.0.1:${upstreamPort}"`)) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + + const restoredConfig = await readFile(codexConfigPath, "utf8"); + assert( + restoredConfig.includes(`base_url = "http://127.0.0.1:${upstreamPort}"`), + "Restore script did not recover original base_url", + ); + + process.stdout.write("PASS install-restore flow\n"); + } finally { + upstream.close(); + await once(upstream, "close"); + await rm(tempRoot, { recursive: true, force: true }); + } +} + +run().catch((error) => { + process.stderr.write(`${error?.stack || error}\n`); + process.exit(1); +}); diff --git a/scripts/test-install-restore.ps1 b/scripts/test-install-restore.ps1 new file mode 100644 index 0000000..e8b5a4e --- /dev/null +++ b/scripts/test-install-restore.ps1 @@ -0,0 +1,9 @@ +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$nodeScript = Join-Path $scriptDir "test-install-restore.mjs" + +node $nodeScript +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} diff --git a/scripts/test-launch-ui-unix.mjs b/scripts/test-launch-ui-unix.mjs new file mode 100644 index 0000000..ef73a5d --- /dev/null +++ b/scripts/test-launch-ui-unix.mjs @@ -0,0 +1,194 @@ +#!/usr/bin/env node + +import http from "node:http"; +import net from "node:net"; +import { once } from "node:events"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; + +const scriptsRoot = import.meta.dirname; +const launchScript = path.join(scriptsRoot, "launch-ui.sh"); +const restoreScript = path.join(scriptsRoot, "restore-codex-config.sh"); + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function toUnixPathForBash(inputPath) { + if (process.platform !== "win32") { + return inputPath; + } + return `/mnt/${inputPath.slice(0, 1).toLowerCase()}${inputPath.slice(2).replace(/\\/g, "/")}`; +} + +async function getFreePort() { + const server = net.createServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + const port = address && typeof address === "object" ? address.port : null; + server.close(); + await once(server, "close"); + if (!port) { + throw new Error("Failed to allocate a free port"); + } + return port; +} + +function startFakeUpstream(port) { + const server = http.createServer((req, res) => { + if (req.method === "GET" && req.url === "/v1/models") { + res.writeHead(200, { + "content-type": "application/json; charset=utf-8", + "x-upstream-test": "unix-launch-ok", + }); + res.end(JSON.stringify({ object: "list", data: [{ id: "unix-launch-model" }] })); + return; + } + + if (req.method === "POST" && (req.url === "/responses" || req.url === "/v1/responses")) { + let body = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + const parsed = JSON.parse(body || "{}"); + const reasoning = parsed.test_reasoning_tokens ?? 128; + res.writeHead(200, { "content-type": "application/json; charset=utf-8" }); + res.end( + JSON.stringify({ + id: "unix-launch-response", + usage: { + output_tokens_details: { + reasoning_tokens: reasoning, + }, + }, + }), + ); + }); + return; + } + + res.writeHead(404, { "content-type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "not found" })); + }); + + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => resolve(server)); + }); +} + +async function runBashScript(scriptPath, args) { + const bashScriptPath = + process.platform === "win32" + ? path.relative(process.cwd(), scriptPath).split(path.sep).join("/") + : scriptPath; + + const bashArgs = [bashScriptPath, ...args]; + + const child = spawn("bash", bashArgs, { + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + const [exitCode] = await once(child, "exit"); + if (exitCode !== 0) { + throw new Error(`Bash script failed: ${scriptPath}\nstdout:\n${stdout}\nstderr:\n${stderr}`); + } + + return { stdout, stderr }; +} + +async function run() { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "codex-retry-gateway-unix-")); + const codexDir = path.join(tempRoot, ".codex"); + const stateRoot = path.join(tempRoot, ".codex-retry-gateway"); + const codexConfigPath = path.join(codexDir, "config.toml"); + const upstreamPort = await getFreePort(); + const gatewayPort = await getFreePort(); + const gatewayBaseUrl = `http://127.0.0.1:${gatewayPort}`; + const upstreamBaseUrl = `http://127.0.0.1:${upstreamPort}`; + + await mkdir(codexDir, { recursive: true }); + await writeFile( + codexConfigPath, + [ + 'model_provider = "custom"', + "", + "[model_providers.custom]", + 'name = "Unix Launch Test"', + `base_url = "${upstreamBaseUrl}"`, + 'wire_api = "responses"', + "", + ].join("\n"), + "utf8", + ); + + const upstream = await startFakeUpstream(upstreamPort); + + try { + await runBashScript(launchScript, [ + "--codex-config-path", + toUnixPathForBash(codexConfigPath), + "--state-root", + toUnixPathForBash(stateRoot), + "--listen-port", + String(gatewayPort), + "--no-open", + ]); + + const installedConfig = await readFile(codexConfigPath, "utf8"); + assert( + installedConfig.includes(`base_url = "${gatewayBaseUrl}"`), + "Unix launch did not redirect the current provider to the local gateway", + ); + + const uiResponse = await fetch(`${gatewayBaseUrl}/__codex_retry_gateway/ui`); + assert(uiResponse.status === 200, `Unix UI page was not reachable: ${uiResponse.status}`); + + const proxiedModels = await fetch(`${gatewayBaseUrl}/v1/models`); + assert(proxiedModels.status === 200, `/v1/models through unix launch flow failed: ${proxiedModels.status}`); + assert( + proxiedModels.headers.get("x-upstream-test") === "unix-launch-ok", + "Unix launch gateway did not preserve upstream headers", + ); + + await runBashScript(restoreScript, [ + "--codex-config-path", + toUnixPathForBash(codexConfigPath), + "--state-root", + toUnixPathForBash(stateRoot), + ]); + + const restoredConfig = await readFile(codexConfigPath, "utf8"); + assert( + restoredConfig.includes(`base_url = "${upstreamBaseUrl}"`), + "Unix restore did not recover original base_url", + ); + + process.stdout.write("PASS unix launch-ui flow\n"); + } finally { + upstream.close(); + await once(upstream, "close"); + await rm(tempRoot, { recursive: true, force: true }); + } +} + +run().catch((error) => { + process.stderr.write(`${error?.stack || error}\n`); + process.exit(1); +}); diff --git a/scripts/test-launch-ui-unix.ps1 b/scripts/test-launch-ui-unix.ps1 new file mode 100644 index 0000000..ee6739c --- /dev/null +++ b/scripts/test-launch-ui-unix.ps1 @@ -0,0 +1,9 @@ +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$nodeScript = Join-Path $scriptDir "test-launch-ui-unix.mjs" + +node $nodeScript +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} diff --git a/scripts/test-launch-ui.mjs b/scripts/test-launch-ui.mjs new file mode 100644 index 0000000..42fa535 --- /dev/null +++ b/scripts/test-launch-ui.mjs @@ -0,0 +1,218 @@ +#!/usr/bin/env node + +import http from "node:http"; +import net from "node:net"; +import { once } from "node:events"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; + +const scriptsRoot = import.meta.dirname; +const launchScript = path.join(scriptsRoot, "launch-ui.ps1"); +const restoreScript = path.join(scriptsRoot, "restore-codex-config.ps1"); + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +async function getFreePort() { + const server = net.createServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + const port = address && typeof address === "object" ? address.port : null; + server.close(); + await once(server, "close"); + if (!port) { + throw new Error("Failed to allocate a free port"); + } + return port; +} + +function startFakeUpstream(port) { + const server = http.createServer((req, res) => { + if (req.method === "GET" && req.url === "/v1/models") { + res.writeHead(200, { + "content-type": "application/json; charset=utf-8", + "x-upstream-test": "launch-ui-ok", + }); + res.end(JSON.stringify({ object: "list", data: [{ id: "launch-ui-test-model" }] })); + return; + } + + if (req.method === "POST" && (req.url === "/responses" || req.url === "/v1/responses")) { + let body = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + const parsed = JSON.parse(body || "{}"); + const reasoning = parsed.test_reasoning_tokens ?? 128; + res.writeHead(200, { "content-type": "application/json; charset=utf-8" }); + res.end( + JSON.stringify({ + id: "launch-ui-response", + usage: { + output_tokens_details: { + reasoning_tokens: reasoning, + }, + }, + }), + ); + }); + return; + } + + res.writeHead(404, { "content-type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "not found" })); + }); + + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => resolve(server)); + }); +} + +async function runPowerShellScript(scriptPath, args) { + const child = spawn( + "powershell", + ["-ExecutionPolicy", "Bypass", "-File", scriptPath, ...args], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + const [exitCode] = await once(child, "exit"); + if (exitCode !== 0) { + throw new Error(`PowerShell script failed: ${scriptPath}\nstdout:\n${stdout}\nstderr:\n${stderr}`); + } + + return { stdout, stderr }; +} + +async function run() { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "codex-retry-gateway-launch-")); + const codexDir = path.join(tempRoot, ".codex"); + const stateRoot = path.join(tempRoot, ".codex-retry-gateway"); + const codexConfigPath = path.join(codexDir, "config.toml"); + const upstreamPort = await getFreePort(); + const gatewayPort = await getFreePort(); + const gatewayBaseUrl = `http://127.0.0.1:${gatewayPort}`; + const upstreamBaseUrl = `http://127.0.0.1:${upstreamPort}`; + + await mkdir(codexDir, { recursive: true }); + await writeFile( + codexConfigPath, + [ + 'model_provider = "custom"', + "", + "[model_providers.custom]", + 'name = "Launch UI Test"', + `base_url = "${upstreamBaseUrl}"`, + 'wire_api = "responses"', + "", + ].join("\n"), + "utf8", + ); + + const upstream = await startFakeUpstream(upstreamPort); + + try { + await runPowerShellScript(launchScript, [ + "-CodexConfigPath", + codexConfigPath, + "-StateRoot", + stateRoot, + "-ListenPort", + String(gatewayPort), + "-NoOpen", + ]); + + const installedConfig = await readFile(codexConfigPath, "utf8"); + assert( + installedConfig.includes(`base_url = "${gatewayBaseUrl}"`), + "First launch did not redirect the current provider to the local gateway", + ); + + const uiResponse = await fetch(`${gatewayBaseUrl}/__codex_retry_gateway/ui`); + assert(uiResponse.status === 200, `UI page was not reachable after first launch: ${uiResponse.status}`); + + const statusResponse = await fetch(`${gatewayBaseUrl}/__codex_retry_gateway/api/status`); + const statusPayload = await statusResponse.json(); + assert(statusResponse.status === 200, `Status API failed after first launch: ${statusResponse.status}`); + assert( + statusPayload.state?.original_base_url === upstreamBaseUrl, + "First launch did not persist the original upstream base URL", + ); + + const firstStateRaw = await readFile(path.join(stateRoot, "state.json"), "utf8"); + const firstState = JSON.parse(firstStateRaw); + + await runPowerShellScript(launchScript, [ + "-CodexConfigPath", + codexConfigPath, + "-StateRoot", + stateRoot, + "-ListenPort", + String(gatewayPort), + "-NoOpen", + ]); + + const secondStateRaw = await readFile(path.join(stateRoot, "state.json"), "utf8"); + const secondState = JSON.parse(secondStateRaw); + assert( + secondState.original_base_url === firstState.original_base_url, + "Second launch overwrote original_base_url unexpectedly", + ); + assert( + secondState.gateway_base_url === gatewayBaseUrl, + "Second launch did not preserve gateway_base_url", + ); + + const proxiedModels = await fetch(`${gatewayBaseUrl}/v1/models`); + assert(proxiedModels.status === 200, `/v1/models through launch UI flow failed: ${proxiedModels.status}`); + assert( + proxiedModels.headers.get("x-upstream-test") === "launch-ui-ok", + "Gateway did not preserve upstream headers after second launch", + ); + + const blockedResponse = await fetch(`${gatewayBaseUrl}/responses`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ test_reasoning_tokens: 516 }), + }); + assert(blockedResponse.status === 502, `Default 516 interception was not active: ${blockedResponse.status}`); + + process.stdout.write("PASS launch-ui flow\n"); + } finally { + try { + await runPowerShellScript(restoreScript, [ + "-CodexConfigPath", + codexConfigPath, + "-StateRoot", + stateRoot, + ]); + } catch { + // 测试清理阶段允许忽略恢复失败,避免覆盖主失败原因。 + } + upstream.close(); + await once(upstream, "close"); + await rm(tempRoot, { recursive: true, force: true }); + } +} + +run().catch((error) => { + process.stderr.write(`${error?.stack || error}\n`); + process.exit(1); +}); diff --git a/scripts/test-launch-ui.ps1 b/scripts/test-launch-ui.ps1 new file mode 100644 index 0000000..cddb87f --- /dev/null +++ b/scripts/test-launch-ui.ps1 @@ -0,0 +1,9 @@ +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$nodeScript = Join-Path $scriptDir "test-launch-ui.mjs" + +node $nodeScript +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +}