feat: publish codex retry gateway
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.log
|
||||
@@ -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`
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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`
|
||||
+1465
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[@]}"
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
@@ -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[@]}"
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
@@ -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[@]}"
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)
|
||||
@@ -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[@]}"
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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[@]}"
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user