Add strict 502 mode and upstream retry handling for streaming responses

This commit is contained in:
nonononull
2026-06-26 11:24:00 +08:00
parent 9cc6bc9b31
commit d2e1acbaf4
7 changed files with 2997 additions and 2817 deletions
+263 -261
View File
@@ -1,263 +1,265 @@
# Codex Retry Gateway
一个不依赖 `cc-switch` 路由模式的独立本地网关。
目标:
- 保持 Codex 继续使用现有 `auth.json`
- 只把 `config.toml` 的当前 provider `base_url` 改成本地网关
# 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`
- 流式命中时默认先缓存并判断;一旦命中 `516`,统一返回 `502`
- 默认同时拦截 root 路径和 `/v1` 路径:
- `/responses`
- `/chat/completions`
- `/v1/responses`
- `/v1/chat/completions`
限制:
- 这个网关不负责 `Responses``Chat Completions` 协议互转
- 如果你的上游本身不支持 Codex 当前使用的协议,这个网关不会替你补齐转换能力
## 默认路径
Windows:
- Codex 配置:`%USERPROFILE%\.codex\config.toml`
- Gateway 状态目录:`%USERPROFILE%\.codex-retry-gateway`
macOS / Linux:
- Codex 配置:`~/.codex/config.toml`
- Gateway 状态目录:`~/.codex-retry-gateway`
## 当前版本说明
- 这是一个可独立发布、独立运行的仓库
- 默认监听地址是 `http://127.0.0.1:4610`
- 默认示例上游见 `config.example.json`
- 实际运行时配置会写到当前用户目录下的 gateway 状态目录
## 一键启动并打开管理页
在仓库根目录执行:
Windows:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1
```
macOS / Linux:
```bash
bash ./scripts/launch-ui.sh
```
这个脚本是默认入口,执行后会自动完成:
- 第一次运行时:
- 备份当前用户目录下的 Codex `config.toml`
- 生成当前用户目录下的 gateway `config.json`
- 启动本地 gateway
- 把当前 `model_provider` 对应的 `base_url` 改到本地 gateway
- 之后再次运行时:
- 自动复用现有安装状态
- 自动重启或拉起 gateway
- 自动再次打开管理页
默认会打开:
```text
http://127.0.0.1:4610/__codex_retry_gateway/ui
```
如果你只想启动、不自动开浏览器:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1 -NoOpen
```
```bash
bash ./scripts/launch-ui.sh --no-open
```
常用参数:
- Windows 参数:
- `-CodexConfigPath`
- `-StateRoot`
- `-ListenHost`
- `-ListenPort`
- `-NoOpen`
- macOS / Linux 参数:
- `--codex-config-path`
- `--state-root`
- `--listen-host`
- `--listen-port`
- `--no-open`
macOS / Linux 说明:
- 需要 `bash`
- 需要 `Node.js 18+`
- Unix 入口会调用跨平台 `node` 管理核心,不依赖 PowerShell
- 推荐显式使用 `bash ...sh`
- 这样即使目录是从 Windows 或压缩包复制过来、没有可执行位,也能直接运行
## 手工安装入口
如果你明确只想做脚本级安装,不想自动打开 UI,也可以直接执行:
Windows:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\install-for-current-provider.ps1
```
macOS / Linux:
```bash
bash ./scripts/install-for-current-provider.sh
```
## 如何恢复
Windows:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\restore-codex-config.ps1
```
macOS / Linux:
```bash
bash ./scripts/restore-codex-config.sh
```
这个脚本会:
- 停掉本地 gateway
- 用最近一次备份恢复当前用户目录下的 Codex `config.toml`
- 删除当前安装状态文件
## 管理页面
页面入口:
```text
http://127.0.0.1:4610/__codex_retry_gateway/ui
```
页面里可以直接做这几件事:
- 看当前监听地址、真实上游、当前 provider、当前 Codex base URL
- 看本次 gateway 启动以来的实时统计
- 代理请求总数
- 被检查响应总数
- `516` 命中次数
- `516` 占比
-`reasoning_equals`
-`endpoints`
-`non_stream_status_code`
- 开关 `log_match`
- 动态查看当前 gateway 的实时日志
- 一键恢复 Codex 原设置
说明:
- 页面保存配置后会立即热生效,不需要重启 gateway
- 页面点“恢复 Codex 原设置并关闭网关”后,当前页面会失联,这是预期行为
- 日常恢复优先用 UI`restore-codex-config.ps1` 作为脚本级应急回滚入口保留
- UI 恢复不会再额外拉起恢复子进程,而是由当前 gateway 直接完成恢复并退出
- 统计口径默认按“本次 gateway 启动以来”累计
- `516` 占比 = `reasoning_tokens = 516` 的响应次数 / 被检查响应总数
## 如何调整拦截条件
编辑:
```text
Windows: %USERPROFILE%\.codex-retry-gateway\config\config.json
macOS / Linux: ~/.codex-retry-gateway/config/config.json
```
常用字段:
- `reasoning_equals`
- 例如 `[516]`
- `endpoints`
- 默认包含 root 与 `/v1` 两套路径
- `non_stream_status_code`
- 默认 `502`
- `stream_action`
- 默认 `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`
- 默认 `strict_502`
- `strict_502`:先缓存整个流,命中 `516` 时统一返回 `502`
- `disconnect`:兼容旧行为;若命中发生在已透传 chunk 之后,则直接断开连接
- `log_match`
- 是否记录命中日志
改完后重启:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\start-gateway.ps1 -RestartIfRunning
```
```bash
bash ./scripts/start-gateway.sh --restart-if-running
```
如果你已经打开管理页,优先直接在页面里改,通常不需要手改 `config.json`
## 其他机器如何应用
在其他 Windows 机器上:
1. 复制整个仓库目录
2. 确保本机有 `Node.js 18+`
3. 不需要安装 `cc-switch`,也不需要使用 `cc-switch` 路由模式
4. 在仓库根目录执行 `powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1`
5. 如需回滚,优先在 UI 里点“恢复 Codex 原设置并关闭网关”;脚本级回滚仍可执行 `powershell -ExecutionPolicy Bypass -File .\scripts\restore-codex-config.ps1`
在其他 macOS / Linux 机器上:
1. 复制整个仓库目录
2. 确保本机有 `bash`
3. 确保本机有 `Node.js 18+`
4. 不需要安装 `cc-switch`,也不需要使用 `cc-switch` 路由模式
5. 在仓库根目录执行 `bash ./scripts/launch-ui.sh`
6. 如需回滚,优先在 UI 里点“恢复 Codex 原设置并关闭网关”;脚本级回滚仍可执行 `bash ./scripts/restore-codex-config.sh`
运行时状态默认写到当前用户目录:
```text
Windows: %USERPROFILE%\.codex-retry-gateway
macOS / Linux: ~/.codex-retry-gateway
```
## 已验证事项
- `test-gateway-e2e.ps1`
- 已通过
- 验证 `/responses``/chat/completions``/v1/responses``/v1/chat/completions`
- `test-install-restore.ps1`
- 已通过
- 验证安装、透传、UI 页面、热更新配置、实时日志、516 统计、恢复闭环
- `test-launch-ui.ps1`
- 已通过
- 验证首次一键启动自动安装、再次启动自动复用、UI 可访问、默认 516 拦截仍生效
- `test-launch-ui-unix.ps1`
- 已通过
- 在当前 Windows 主机的 Bash 环境里验证 Unix `.sh` 入口能完成启动、透传、恢复闭环
- `bash ./scripts/launch-ui.sh --no-open`
- 已通过
- 当前机器实测返回 `mode=reuse`
- 后续 `GET /__codex_retry_gateway/health``GET /__codex_retry_gateway/ui``GET /v1/models` 都返回 `200`
- `codex exec`
- 已通过
- 在 Bash 默认入口重新拉起 gateway 后,当前机器再次返回 `OK`
- 当前实机验证示例
- `GET http://127.0.0.1:4610/__codex_retry_gateway/health` 已通过
- `GET http://127.0.0.1:4610/v1/models` 已通过,并成功透传到配置里的真实上游
- `GET http://127.0.0.1:4610/__codex_retry_gateway/ui` 已实际打开并确认页面内容
- `codex exec` 历史现象
- gateway 关闭时,真实报错地址为 `http://127.0.0.1:4610/responses`
- gateway 恢复后,`codex exec` 已再次成功返回 `OK`
+12 -12
View File
@@ -1,12 +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"
}
{
"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": "strict_502",
"log_match": true,
"health_path": "/__codex_retry_gateway/health"
}
+161 -135
View File
@@ -1,106 +1,106 @@
# err.md
## 2026-06-26 独立 Codex Retry Gateway
### 设计边界
- 只解决 Codex 已可访问上游时的 `reasoning_tokens = 516` 重试问题
- 不替代 `cc-switch` 的协议路由转换
# 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 主逻辑保持同一套
- 统一返回 `502`
### 当前已知限制
- 如果上游只支持 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 入口最初找不到脚本路径
@@ -111,37 +111,63 @@
- `.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`
14. 上游流式连接中途终止时被误记为网关错误,首次瞬断也缺少最小重试
- 现象:
- 日志出现:
- `TypeError: terminated`
- `TypeError: fetch failed`
- 其中一部分来自上游 SSE 中途断流,另一部分来自上游首次连接瞬时失败
- 根因:
- `handleStreaming()` 直接把 `reader.read()` 抛出的 `AbortError` / `TypeError: terminated` 冒到统一错误处理
- `proxyRequest()` 对上游 `fetch()` 没有做一次轻量重试,首个瞬断会直接返回 `502`
- 处理:
- 新增预期流终止识别:
- `AbortError`
- `TypeError: terminated`
- 这两类在流式处理中按“连接已结束”收口,不再记 `[error]`
- 新增上游 `fetch failed` 的一次自动重试
- 新增严格 `502` 流式模式:
- 默认不再抢先透传 `200` 头和首个 chunk
- 先缓存流,再根据 `reasoning_tokens` 决定透传或返回 `502`
- 验证:
- `scripts/test-gateway-e2e.mjs`
- 新增 `/responses` 流式覆盖
- 新增“上游半路断流不刷 error 日志”断言
- 新增“首次 fetch failed 后第二次成功恢复”断言
- 新增“流式 `516` 统一返回 `502`,不再先透传半截 chunk”断言
- `scripts/test-install-restore.mjs` 继续通过
### 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`
+1479 -1399
View File
File diff suppressed because it is too large Load Diff
+620 -620
View File
File diff suppressed because it is too large Load Diff
+109 -109
View File
@@ -1,109 +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
}
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 { "strict_502" }
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
}
+353 -281
View File
@@ -1,316 +1,388 @@
#!/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));
}
#!/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, readFile, 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.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 createTerminatedSseResponse(res, chunks, destroyDelayMs = 20) {
res.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
connection: "keep-alive",
"x-upstream-test": "sse-terminated",
});
for (const chunk of chunks) {
res.write(chunk);
}
setTimeout(() => {
res.socket?.destroy();
}, destroyDelayMs);
}
function startFakeUpstream(port) {
const failBeforeResponseCounts = new Map();
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;
});
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;
if (parsed.test_fail_before_response_once) {
const failKey = `${req.url}:fail-before-response-once`;
const failCount = (failBeforeResponseCounts.get(failKey) || 0) + 1;
failBeforeResponseCounts.set(failKey, failCount);
if (failCount === 1) {
res.socket?.destroy();
return;
}
}
if (parsed.test_force_terminate) {
createTerminatedSseResponse(res, [
'data: {"type":"response.output_text.delta","delta":"hello"}\n\n',
]);
return;
}
if (parsed.stream) {
createSseResponse(res, [
'data: {"type":"response.output_text.delta","delta":"hello"}\n\n',
`data: {"response":{"usage":{"output_tokens_details":{"reasoning_tokens":${reasoning}}}}}\n\n`,
"data: [DONE]\n\n",
]);
return;
}
createJsonResponse(
res,
200,
{
id: "resp_test",
retry_attempt: parsed.test_fail_before_response_once
? failBeforeResponseCounts.get(`${req.url}:fail-before-response-once`) || 0
: 0,
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");
},
{ "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",
};
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: "strict_502",
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 返回体异常`,
);
}
const recoveredResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ test_fail_before_response_once: true }),
});
const recoveredBody = await recoveredResponse.json();
assert(recoveredResponse.status === 200, `首次 fetch failed 后未自动恢复: ${recoveredResponse.status}`);
assert(recoveredBody?.retry_attempt === 2, "首次 fetch failed 后未命中第二次上游请求");
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"]) {
for (const streamPath of [
"/responses",
"/v1/responses",
"/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.status === 502, `${streamPath} 516 未返回 502: ${blockedStream.status}`);
assert(!blockedStream.text.includes("hello"), `${streamPath} 严格 502 模式不应先透传正常 chunk`);
assert(!blockedStream.text.includes("[DONE]"), `${streamPath} 严格 502 模式不应回放 DONE`);
const blockedStreamBody = JSON.parse(blockedStream.text);
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 },
blockedStreamBody?.error?.code === "reasoning_guard_triggered",
`${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 });
}
}
const terminatedStream = await readSseUntilClose(
`http://127.0.0.1:${gatewayPort}/responses`,
{ stream: true, test_force_terminate: true },
);
assert(terminatedStream.status === 502, `/responses 上游半路断流未返回 502: ${terminatedStream.status}`);
run().catch((error) => {
process.stderr.write(`${error?.stack || error}\n`);
process.exit(1);
});
await new Promise((resolve) => setTimeout(resolve, 120));
const logText = await readFile(logPath, "utf8");
assert(
!logText.includes("[error] TypeError: terminated"),
"上游半路断流后不应记录 terminated error 日志",
);
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);
});