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 # Codex Retry Gateway
一个不依赖 `cc-switch` 路由模式的独立本地网关。 一个不依赖 `cc-switch` 路由模式的独立本地网关。
目标: 目标:
- 保持 Codex 继续使用现有 `auth.json` - 保持 Codex 继续使用现有 `auth.json`
- 只把 `config.toml` 的当前 provider `base_url` 改成本地网关 - 只把 `config.toml` 的当前 provider `base_url` 改成本地网关
- 非流式命中 `reasoning_tokens = 516` 时返回 `502` - 非流式命中 `reasoning_tokens = 516` 时返回 `502`
- 流式命中时直接断开连接,让 Codex 自行重试 - 流式命中时默认先缓存并判断;一旦命中 `516`,统一返回 `502`
- 默认同时拦截 root 路径和 `/v1` 路径: - 默认同时拦截 root 路径和 `/v1` 路径:
- `/responses` - `/responses`
- `/chat/completions` - `/chat/completions`
- `/v1/responses` - `/v1/responses`
- `/v1/chat/completions` - `/v1/chat/completions`
限制: 限制:
- 这个网关不负责 `Responses` 和 `Chat Completions` 协议互转 - 这个网关不负责 `Responses` 和 `Chat Completions` 协议互转
- 如果你的上游本身不支持 Codex 当前使用的协议,这个网关不会替你补齐转换能力 - 如果你的上游本身不支持 Codex 当前使用的协议,这个网关不会替你补齐转换能力
## 默认路径 ## 默认路径
Windows: Windows:
- Codex 配置:`%USERPROFILE%\.codex\config.toml` - Codex 配置:`%USERPROFILE%\.codex\config.toml`
- Gateway 状态目录:`%USERPROFILE%\.codex-retry-gateway` - Gateway 状态目录:`%USERPROFILE%\.codex-retry-gateway`
macOS / Linux: macOS / Linux:
- Codex 配置:`~/.codex/config.toml` - Codex 配置:`~/.codex/config.toml`
- Gateway 状态目录:`~/.codex-retry-gateway` - Gateway 状态目录:`~/.codex-retry-gateway`
## 当前版本说明 ## 当前版本说明
- 这是一个可独立发布、独立运行的仓库 - 这是一个可独立发布、独立运行的仓库
- 默认监听地址是 `http://127.0.0.1:4610` - 默认监听地址是 `http://127.0.0.1:4610`
- 默认示例上游见 `config.example.json` - 默认示例上游见 `config.example.json`
- 实际运行时配置会写到当前用户目录下的 gateway 状态目录 - 实际运行时配置会写到当前用户目录下的 gateway 状态目录
## 一键启动并打开管理页 ## 一键启动并打开管理页
在仓库根目录执行: 在仓库根目录执行:
Windows: Windows:
```powershell ```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1 powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1
``` ```
macOS / Linux: macOS / Linux:
```bash ```bash
bash ./scripts/launch-ui.sh bash ./scripts/launch-ui.sh
``` ```
这个脚本是默认入口,执行后会自动完成: 这个脚本是默认入口,执行后会自动完成:
- 第一次运行时: - 第一次运行时:
- 备份当前用户目录下的 Codex `config.toml` - 备份当前用户目录下的 Codex `config.toml`
- 生成当前用户目录下的 gateway `config.json` - 生成当前用户目录下的 gateway `config.json`
- 启动本地 gateway - 启动本地 gateway
- 把当前 `model_provider` 对应的 `base_url` 改到本地 gateway - 把当前 `model_provider` 对应的 `base_url` 改到本地 gateway
- 之后再次运行时: - 之后再次运行时:
- 自动复用现有安装状态 - 自动复用现有安装状态
- 自动重启或拉起 gateway - 自动重启或拉起 gateway
- 自动再次打开管理页 - 自动再次打开管理页
默认会打开: 默认会打开:
```text ```text
http://127.0.0.1:4610/__codex_retry_gateway/ui http://127.0.0.1:4610/__codex_retry_gateway/ui
``` ```
如果你只想启动、不自动开浏览器: 如果你只想启动、不自动开浏览器:
```powershell ```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1 -NoOpen powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1 -NoOpen
``` ```
```bash ```bash
bash ./scripts/launch-ui.sh --no-open bash ./scripts/launch-ui.sh --no-open
``` ```
常用参数: 常用参数:
- Windows 参数: - Windows 参数:
- `-CodexConfigPath` - `-CodexConfigPath`
- `-StateRoot` - `-StateRoot`
- `-ListenHost` - `-ListenHost`
- `-ListenPort` - `-ListenPort`
- `-NoOpen` - `-NoOpen`
- macOS / Linux 参数: - macOS / Linux 参数:
- `--codex-config-path` - `--codex-config-path`
- `--state-root` - `--state-root`
- `--listen-host` - `--listen-host`
- `--listen-port` - `--listen-port`
- `--no-open` - `--no-open`
macOS / Linux 说明: macOS / Linux 说明:
- 需要 `bash` - 需要 `bash`
- 需要 `Node.js 18+` - 需要 `Node.js 18+`
- Unix 入口会调用跨平台 `node` 管理核心,不依赖 PowerShell - Unix 入口会调用跨平台 `node` 管理核心,不依赖 PowerShell
- 推荐显式使用 `bash ...sh` - 推荐显式使用 `bash ...sh`
- 这样即使目录是从 Windows 或压缩包复制过来、没有可执行位,也能直接运行 - 这样即使目录是从 Windows 或压缩包复制过来、没有可执行位,也能直接运行
## 手工安装入口 ## 手工安装入口
如果你明确只想做脚本级安装,不想自动打开 UI,也可以直接执行: 如果你明确只想做脚本级安装,不想自动打开 UI,也可以直接执行:
Windows: Windows:
```powershell ```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\install-for-current-provider.ps1 powershell -ExecutionPolicy Bypass -File .\scripts\install-for-current-provider.ps1
``` ```
macOS / Linux: macOS / Linux:
```bash ```bash
bash ./scripts/install-for-current-provider.sh bash ./scripts/install-for-current-provider.sh
``` ```
## 如何恢复 ## 如何恢复
Windows: Windows:
```powershell ```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\restore-codex-config.ps1 powershell -ExecutionPolicy Bypass -File .\scripts\restore-codex-config.ps1
``` ```
macOS / Linux: macOS / Linux:
```bash ```bash
bash ./scripts/restore-codex-config.sh bash ./scripts/restore-codex-config.sh
``` ```
这个脚本会: 这个脚本会:
- 停掉本地 gateway - 停掉本地 gateway
- 用最近一次备份恢复当前用户目录下的 Codex `config.toml` - 用最近一次备份恢复当前用户目录下的 Codex `config.toml`
- 删除当前安装状态文件 - 删除当前安装状态文件
## 管理页面 ## 管理页面
页面入口: 页面入口:
```text ```text
http://127.0.0.1:4610/__codex_retry_gateway/ui http://127.0.0.1:4610/__codex_retry_gateway/ui
``` ```
页面里可以直接做这几件事: 页面里可以直接做这几件事:
- 看当前监听地址、真实上游、当前 provider、当前 Codex base URL - 看当前监听地址、真实上游、当前 provider、当前 Codex base URL
- 看本次 gateway 启动以来的实时统计 - 看本次 gateway 启动以来的实时统计
- 代理请求总数 - 代理请求总数
- 被检查响应总数 - 被检查响应总数
- `516` 命中次数 - `516` 命中次数
- `516` 占比 - `516` 占比
- 改 `reasoning_equals` - 改 `reasoning_equals`
- 改 `endpoints` - 改 `endpoints`
- 改 `non_stream_status_code` - 改 `non_stream_status_code`
- 开关 `log_match` - 开关 `log_match`
- 动态查看当前 gateway 的实时日志 - 动态查看当前 gateway 的实时日志
- 一键恢复 Codex 原设置 - 一键恢复 Codex 原设置
说明: 说明:
- 页面保存配置后会立即热生效,不需要重启 gateway - 页面保存配置后会立即热生效,不需要重启 gateway
- 页面点“恢复 Codex 原设置并关闭网关”后,当前页面会失联,这是预期行为 - 页面点“恢复 Codex 原设置并关闭网关”后,当前页面会失联,这是预期行为
- 日常恢复优先用 UI;`restore-codex-config.ps1` 作为脚本级应急回滚入口保留 - 日常恢复优先用 UI;`restore-codex-config.ps1` 作为脚本级应急回滚入口保留
- UI 恢复不会再额外拉起恢复子进程,而是由当前 gateway 直接完成恢复并退出 - UI 恢复不会再额外拉起恢复子进程,而是由当前 gateway 直接完成恢复并退出
- 统计口径默认按“本次 gateway 启动以来”累计 - 统计口径默认按“本次 gateway 启动以来”累计
- `516` 占比 = `reasoning_tokens = 516` 的响应次数 / 被检查响应总数 - `516` 占比 = `reasoning_tokens = 516` 的响应次数 / 被检查响应总数
## 如何调整拦截条件 ## 如何调整拦截条件
编辑: 编辑:
```text ```text
Windows: %USERPROFILE%\.codex-retry-gateway\config\config.json Windows: %USERPROFILE%\.codex-retry-gateway\config\config.json
macOS / Linux: ~/.codex-retry-gateway/config/config.json macOS / Linux: ~/.codex-retry-gateway/config/config.json
``` ```
常用字段: 常用字段:
- `reasoning_equals` - `reasoning_equals`
- 例如 `[516]` - 例如 `[516]`
- `endpoints` - `endpoints`
- 默认包含 root 与 `/v1` 两套路径 - 默认包含 root 与 `/v1` 两套路径
- `non_stream_status_code` - `non_stream_status_code`
- 默认 `502` - 默认 `502`
- `stream_action` - `stream_action`
- 默认 `disconnect` - 默认 `strict_502`
- `log_match` - `strict_502`:先缓存整个流,命中 `516` 时统一返回 `502`
- 是否记录命中日志 - `disconnect`:兼容旧行为;若命中发生在已透传 chunk 之后,则直接断开连接
- `log_match`
改完后重启: - 是否记录命中日志
```powershell 改完后重启:
powershell -ExecutionPolicy Bypass -File .\scripts\start-gateway.ps1 -RestartIfRunning
``` ```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\start-gateway.ps1 -RestartIfRunning
```bash ```
bash ./scripts/start-gateway.sh --restart-if-running
``` ```bash
bash ./scripts/start-gateway.sh --restart-if-running
如果你已经打开管理页,优先直接在页面里改,通常不需要手改 `config.json`。 ```
## 其他机器如何应用 如果你已经打开管理页,优先直接在页面里改,通常不需要手改 `config.json`。
在其他 Windows 机器上: ## 其他机器如何应用
1. 复制整个仓库目录 在其他 Windows 机器上:
2. 确保本机有 `Node.js 18+`
3. 不需要安装 `cc-switch`,也不需要使用 `cc-switch` 路由模式 1. 复制整个仓库目录
4. 在仓库根目录执行 `powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1` 2. 确保本机有 `Node.js 18+`
5. 如需回滚,优先在 UI 里点“恢复 Codex 原设置并关闭网关”;脚本级回滚仍可执行 `powershell -ExecutionPolicy Bypass -File .\scripts\restore-codex-config.ps1` 3. 不需要安装 `cc-switch`,也不需要使用 `cc-switch` 路由模式
4. 在仓库根目录执行 `powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1`
在其他 macOS / Linux 机器上: 5. 如需回滚,优先在 UI 里点“恢复 Codex 原设置并关闭网关”;脚本级回滚仍可执行 `powershell -ExecutionPolicy Bypass -File .\scripts\restore-codex-config.ps1`
1. 复制整个仓库目录 在其他 macOS / Linux 机器上:
2. 确保本机有 `bash`
3. 确保本机有 `Node.js 18+` 1. 复制整个仓库目录
4. 不需要安装 `cc-switch`,也不需要使用 `cc-switch` 路由模式 2. 确保本机有 `bash`
5. 在仓库根目录执行 `bash ./scripts/launch-ui.sh` 3. 确保本机有 `Node.js 18+`
6. 如需回滚,优先在 UI 里点“恢复 Codex 原设置并关闭网关”;脚本级回滚仍可执行 `bash ./scripts/restore-codex-config.sh` 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 ```text
``` Windows: %USERPROFILE%\.codex-retry-gateway
macOS / Linux: ~/.codex-retry-gateway
## 已验证事项 ```
- `test-gateway-e2e.ps1` ## 已验证事项
- 已通过
- 验证 `/responses`、`/chat/completions`、`/v1/responses`、`/v1/chat/completions` - `test-gateway-e2e.ps1`
- `test-install-restore.ps1` - 已通过
- 已通过 - 验证 `/responses`、`/chat/completions`、`/v1/responses`、`/v1/chat/completions`
- 验证安装、透传、UI 页面、热更新配置、实时日志、516 统计、恢复闭环 - `test-install-restore.ps1`
- `test-launch-ui.ps1` - 已通过
- 已通过 - 验证安装、透传、UI 页面、热更新配置、实时日志、516 统计、恢复闭环
- 验证首次一键启动自动安装、再次启动自动复用、UI 可访问、默认 516 拦截仍生效 - `test-launch-ui.ps1`
- `test-launch-ui-unix.ps1` - 已通过
- 已通过 - 验证首次一键启动自动安装、再次启动自动复用、UI 可访问、默认 516 拦截仍生效
- 在当前 Windows 主机的 Bash 环境里验证 Unix `.sh` 入口能完成启动、透传、恢复闭环 - `test-launch-ui-unix.ps1`
- `bash ./scripts/launch-ui.sh --no-open` - 已通过
- 已通过 - 在当前 Windows 主机的 Bash 环境里验证 Unix `.sh` 入口能完成启动、透传、恢复闭环
- 当前机器实测返回 `mode=reuse` - `bash ./scripts/launch-ui.sh --no-open`
- 后续 `GET /__codex_retry_gateway/health`、`GET /__codex_retry_gateway/ui`、`GET /v1/models` 都返回 `200` - 已通过
- `codex exec` - 当前机器实测返回 `mode=reuse`
- 已通过 - 后续 `GET /__codex_retry_gateway/health`、`GET /__codex_retry_gateway/ui`、`GET /v1/models` 都返回 `200`
- 在 Bash 默认入口重新拉起 gateway 后,当前机器再次返回 `OK` - `codex exec`
- 当前实机验证示例 - 已通过
- `GET http://127.0.0.1:4610/__codex_retry_gateway/health` 已通过 - 在 Bash 默认入口重新拉起 gateway 后,当前机器再次返回 `OK`
- `GET http://127.0.0.1:4610/v1/models` 已通过,并成功透传到配置里的真实上游 - 当前实机验证示例
- `GET http://127.0.0.1:4610/__codex_retry_gateway/ui` 已实际打开并确认页面内容 - `GET http://127.0.0.1:4610/__codex_retry_gateway/health` 已通过
- `codex exec` 历史现象 - `GET http://127.0.0.1:4610/v1/models` 已通过,并成功透传到配置里的真实上游
- gateway 关闭时,真实报错地址为 `http://127.0.0.1:4610/responses` - `GET http://127.0.0.1:4610/__codex_retry_gateway/ui` 已实际打开并确认页面内容
- gateway 恢复后,`codex exec` 已再次成功返回 `OK` - `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_host": "127.0.0.1",
"listen_port": 4610, "listen_port": 4610,
"upstream_base_url": "https://api.openai.com", "upstream_base_url": "https://api.openai.com",
"request_body_limit_bytes": 10485760, "request_body_limit_bytes": 10485760,
"endpoints": ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], "endpoints": ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
"reasoning_equals": [516], "reasoning_equals": [516],
"non_stream_status_code": 502, "non_stream_status_code": 502,
"stream_action": "disconnect", "stream_action": "strict_502",
"log_match": true, "log_match": true,
"health_path": "/__codex_retry_gateway/health" "health_path": "/__codex_retry_gateway/health"
} }
+161 -135
View File
@@ -1,106 +1,106 @@
# err.md # err.md
## 2026-06-26 独立 Codex Retry Gateway ## 2026-06-26 独立 Codex Retry Gateway
### 设计边界 ### 设计边界
- 只解决 Codex 已可访问上游时的 `reasoning_tokens = 516` 重试问题 - 只解决 Codex 已可访问上游时的 `reasoning_tokens = 516` 重试问题
- 不替代 `cc-switch` 的协议路由转换 - 不替代 `cc-switch` 的协议路由转换
- 流式场景默认策略是: - 流式场景默认策略是:
- 先实时透传 - 先缓存上游流
- 一旦检测到命中 `516` - 一旦检测到命中 `516`
- 直接断开连接 - 统一返回 `502`
### 当前已知限制 ### 当前已知限制
- 如果上游只支持 Chat Completions、而 Codex 当前链路需要 Responses 协议转换,这个项目不处理该转换 - 如果上游只支持 Chat Completions、而 Codex 当前链路需要 Responses 协议转换,这个项目不处理该转换
- 这个项目依赖 Codex / Codex Desktop 自身的自动重试能力 - 这个项目依赖 Codex / Codex Desktop 自身的自动重试能力
### 本次已确认并修复的问题 ### 本次已确认并修复的问题
1. `gateway.mjs` 非流式透传发头顺序错误 1. `gateway.mjs` 非流式透传发头顺序错误
- 现象:`ERR_HTTP_HEADERS_SENT` - 现象:`ERR_HTTP_HEADERS_SENT`
- 根因:`writeHead()` 在 `copyHeadersToClient()` 之前调用 - 根因:`writeHead()` 在 `copyHeadersToClient()` 之前调用
- 结果:正常 `128` 响应也会被打断 - 结果:正常 `128` 响应也会被打断
2. PowerShell 脚本在 `powershell.exe` 下的解析兼容性 2. PowerShell 脚本在 `powershell.exe` 下的解析兼容性
- 现象:脚本乱码并伴随解析异常 - 现象:脚本乱码并伴随解析异常
- 根因:新脚本初版包含中文运行时字符串,且 `param(...)` 不在文件最前 - 根因:新脚本初版包含中文运行时字符串,且 `param(...)` 不在文件最前
- 处理:运行时输出改成 ASCII,并把 `param(...)` 提前到文件顶部 - 处理:运行时输出改成 ASCII,并把 `param(...)` 提前到文件顶部
3. `stop-gateway.ps1` 与 PowerShell 内置只读变量 `$PID` 冲突 3. `stop-gateway.ps1` 与 PowerShell 内置只读变量 `$PID` 冲突
- 现象:安装脚本在重启 gateway 时失败 - 现象:安装脚本在重启 gateway 时失败
- 处理:改用 `$gatewayPid` - 处理:改用 `$gatewayPid`
4. `start-gateway.ps1` 启动 Node 时路径带空格 4. `start-gateway.ps1` 启动 Node 时路径带空格
- 现象:gateway 进程启动后立刻退出 - 现象:gateway 进程启动后立刻退出
- 根因:`Start-Process` 参数未显式带引号 - 根因:`Start-Process` 参数未显式带引号
- 处理:改为手工拼带引号的 `ArgumentList` - 处理:改为手工拼带引号的 `ArgumentList`
5. PowerShell 单元素数组落盘时被拆成标量 5. PowerShell 单元素数组落盘时被拆成标量
- 现象:`reasoning_equals` 被写成 `516`,不是 `[516]` - 现象:`reasoning_equals` 被写成 `516`,不是 `[516]`
- 处理:在公共归一化函数里强制返回数组 - 处理:在公共归一化函数里强制返回数组
6. 旧脏配置迁移后出现嵌套/拼接 endpoints 6. 旧脏配置迁移后出现嵌套/拼接 endpoints
- 现象:`endpoints` 可能变成嵌套数组,或出现一条用空格拼接的脏字符串 - 现象:`endpoints` 可能变成嵌套数组,或出现一条用空格拼接的脏字符串
- 处理:安装脚本合并 endpoints 时做递归拍平和空白拆分 - 处理:安装脚本合并 endpoints 时做递归拍平和空白拆分
7. 真实 Codex 客户端请求路径不是 `/v1/responses` 7. 真实 Codex 客户端请求路径不是 `/v1/responses`
- 现象:`codex exec` 在 gateway 关闭时真实报错地址是 `http://127.0.0.1:4610/responses` - 现象:`codex exec` 在 gateway 关闭时真实报错地址是 `http://127.0.0.1:4610/responses`
- 结论:默认配置必须同时覆盖: - 结论:默认配置必须同时覆盖:
- `/responses` - `/responses`
- `/chat/completions` - `/chat/completions`
- `/v1/responses` - `/v1/responses`
- `/v1/chat/completions` - `/v1/chat/completions`
8. UI 恢复动作最初采用“子进程拉起 restore 脚本”方案 8. UI 恢复动作最初采用“子进程拉起 restore 脚本”方案
- 现象:浏览器拿到 `202`,但临时 `config.toml`、`state.json`、`gateway.pid` 都没有变化 - 现象:浏览器拿到 `202`,但临时 `config.toml`、`state.json`、`gateway.pid` 都没有变化
- 根因:恢复动作通过 detached 子进程接力时,链路可靠性不足,实际没有把恢复流程真正执行完 - 根因:恢复动作通过 detached 子进程接力时,链路可靠性不足,实际没有把恢复流程真正执行完
- 处理:改为当前 gateway 进程直接复制备份、清理状态并自我退出 - 处理:改为当前 gateway 进程直接复制备份、清理状态并自我退出
9. 新增内嵌 UI 管理页 9. 新增内嵌 UI 管理页
- 入口:`/__codex_retry_gateway/ui` - 入口:`/__codex_retry_gateway/ui`
- 能力: - 能力:
- 查看当前接管状态 - 查看当前接管状态
- 热更新 `reasoning_equals` - 热更新 `reasoning_equals`
- 热更新 `endpoints` - 热更新 `endpoints`
- 热更新 `non_stream_status_code` - 热更新 `non_stream_status_code`
- 开关 `log_match` - 开关 `log_match`
- 一键恢复 Codex 原设置 - 一键恢复 Codex 原设置
10. 用户不接受 `cc-switch` 路由模式,且不希望手工改设置 10. 用户不接受 `cc-switch` 路由模式,且不希望手工改设置
- 现象:仅有安装脚本和 UI 还不够,首次接管、再次拉起、重新打开 UI 仍需要手工串命令 - 现象:仅有安装脚本和 UI 还不够,首次接管、再次拉起、重新打开 UI 仍需要手工串命令
- 处理:新增 `launch-ui.ps1` - 处理:新增 `launch-ui.ps1`
- 结果: - 结果:
- 首次运行自动安装并打开 UI - 首次运行自动安装并打开 UI
- 再次运行自动复用 `state.json + config.json` 并重启 gateway - 再次运行自动复用 `state.json + config.json` 并重启 gateway
- 平时规则调整和恢复统一回到 UI 内完成 - 平时规则调整和恢复统一回到 UI 内完成
11. UI 需要动态显示实时日志、`516` 次数和占比 11. UI 需要动态显示实时日志、`516` 次数和占比
- 现象:原 UI 只能改配置,看不到运行中的命中趋势 - 现象:原 UI 只能改配置,看不到运行中的命中趋势
- 处理: - 处理:
- 在 `gateway.mjs` 内增加运行期统计 - 在 `gateway.mjs` 内增加运行期统计
- 增加日志接口 - 增加日志接口
- UI 轮询显示“被检查响应总数 / 516 命中次数 / 516 占比 / 实时日志” - UI 轮询显示“被检查响应总数 / 516 命中次数 / 516 占比 / 实时日志”
- 统计口径: - 统计口径:
- 按本次 gateway 启动以来累计 - 按本次 gateway 启动以来累计
- `516` 占比 = `reasoning_tokens = 516` 的响应次数 / 被检查响应总数 - `516` 占比 = `reasoning_tokens = 516` 的响应次数 / 被检查响应总数
12. macOS / Linux 不能直接使用现有 PowerShell 管理脚本 12. macOS / Linux 不能直接使用现有 PowerShell 管理脚本
- 现象:`launch-ui.ps1`、`restore-codex-config.ps1` 等入口绑定了 PowerShell 和 Windows 进程控制 - 现象:`launch-ui.ps1`、`restore-codex-config.ps1` 等入口绑定了 PowerShell 和 Windows 进程控制
- 处理: - 处理:
- 新增跨平台 `node` 管理核心 - 新增跨平台 `node` 管理核心
- 新增 `.sh` 包装入口: - 新增 `.sh` 包装入口:
- `launch-ui.sh` - `launch-ui.sh`
- `restore-codex-config.sh` - `restore-codex-config.sh`
- `install-for-current-provider.sh` - `install-for-current-provider.sh`
- `start-gateway.sh` - `start-gateway.sh`
- `stop-gateway.sh` - `stop-gateway.sh`
- 结果: - 结果:
- Windows 继续走 `.ps1` - Windows 继续走 `.ps1`
- macOS / Linux 直接走 `.sh` - macOS / Linux 直接走 `.sh`
- UI、状态文件、gateway 主逻辑保持同一套 - UI、状态文件、gateway 主逻辑保持同一套
13. Windows 主机上模拟 Unix shell 入口时存在路径与 Node 版本兼容问题 13. Windows 主机上模拟 Unix shell 入口时存在路径与 Node 版本兼容问题
- 现象: - 现象:
- Bash 入口最初找不到脚本路径 - Bash 入口最初找不到脚本路径
@@ -111,37 +111,63 @@
- `.sh` 优先选择 `node.exe` - `.sh` 优先选择 `node.exe`
- 在 WSL / Bash 场景下把路径参数转换回 Windows 路径后再交给 `node.exe` - 在 WSL / Bash 场景下把路径参数转换回 Windows 路径后再交给 `node.exe`
### 2026-06-26 实测证据 14. 上游流式连接中途终止时被误记为网关错误,首次瞬断也缺少最小重试
- 现象:
- 假上游 E2E - 日志出现:
- `test-gateway-e2e.ps1` 通过 - `TypeError: terminated`
- 已验证 root 路径和 `/v1` 路径都能区分 `516` 与 `128` - `TypeError: fetch failed`
- 安装/恢复闭环 - 其中一部分来自上游 SSE 中途断流,另一部分来自上游首次连接瞬时失败
- `test-install-restore.ps1` 通过 - 根因:
- 已验证 UI 页面、状态接口、日志接口、516 统计、热更新配置、UI 恢复闭环 - `handleStreaming()` 直接把 `reader.read()` 抛出的 `AbortError` / `TypeError: terminated` 冒到统一错误处理
- 一键启动入口 - `proxyRequest()` 对上游 `fetch()` 没有做一次轻量重试,首个瞬断会直接返回 `502`
- `test-launch-ui.ps1` 通过 - 处理:
- 已验证首次启动自动安装、再次启动自动复用、UI 页面可达、默认 `516 -> 502` 规则仍生效 - 新增预期流终止识别:
- Unix shell 入口 - `AbortError`
- `test-launch-ui-unix.ps1` 通过 - `TypeError: terminated`
- 已验证 `.sh` 入口能完成启动、透传、恢复闭环 - 这两类在流式处理中按“连接已结束”收口,不再记 `[error]`
- Bash 默认入口实机验证 - 新增上游 `fetch failed` 的一次自动重试
- `bash ./scripts/launch-ui.sh --no-open` 通过 - 新增严格 `502` 流式模式:
- 输出 `mode=reuse` - 默认不再抢先透传 `200` 头和首个 chunk
- `GET /__codex_retry_gateway/health` 返回 `200` - 先缓存流,再根据 `reasoning_tokens` 决定透传或返回 `502`
- `GET /__codex_retry_gateway/ui` 返回 `200` - 验证:
- `GET /v1/models` 返回 `200`,并继续透传到真实上游 - `scripts/test-gateway-e2e.mjs`
- Bash 入口后的 `codex exec` 实机验证 - 新增 `/responses` 流式覆盖
- 命令退出码 `0` - 新增“上游半路断流不刷 error 日志”断言
- 最后一条消息文件返回 `OK` - 新增“首次 fetch failed 后第二次成功恢复”断言
- 当前真实 provider - 新增“流式 `516` 统一返回 `502`,不再先透传半截 chunk”断言
- 当前 Codex 配置里的 `base_url` 已可切到 `http://127.0.0.1:4610` - `scripts/test-install-restore.mjs` 继续通过
- 当前 gateway 运行配置里的 `upstream_base_url` 会指向用户自己的真实上游
- `GET /__codex_retry_gateway/health` 返回 `ok=true` ### 2026-06-26 实测证据
- `GET /v1/models` 已经经本地 gateway 成功透传到真实上游
- `GET /__codex_retry_gateway/ui` 已实机打开,页面显示当前 upstream、provider、config 路径和 516 规则 - 假上游 E2E
- 真实 `codex exec` - `test-gateway-e2e.ps1` 通过
- gateway 停止时,CLI 真实提示: - 已验证 root 路径和 `/v1` 路径都能区分 `516` 与 `128`
- `url: http://127.0.0.1:4610/responses` - 安装/恢复闭环
- 并自动进入 `Reconnecting...` - `test-install-restore.ps1` 通过
- gateway 恢复后,`codex exec` 在临时目录再次成功返回 `OK` - 已验证 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( param(
[string]$CodexConfigPath = "$HOME\.codex\config.toml", [string]$CodexConfigPath = "$HOME\.codex\config.toml",
[string]$StateRoot = "$HOME\.codex-retry-gateway", [string]$StateRoot = "$HOME\.codex-retry-gateway",
[string]$ListenHost = "127.0.0.1", [string]$ListenHost = "127.0.0.1",
[int]$ListenPort = 4610 [int]$ListenPort = 4610
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
. (Join-Path $PSScriptRoot "common.ps1") . (Join-Path $PSScriptRoot "common.ps1")
$paths = Get-GatewayStatePaths -StateRoot $StateRoot $paths = Get-GatewayStatePaths -StateRoot $StateRoot
Ensure-Directory -Path $paths.StateRoot Ensure-Directory -Path $paths.StateRoot
Ensure-Directory -Path $paths.ConfigDir Ensure-Directory -Path $paths.ConfigDir
Ensure-Directory -Path $paths.LogDir Ensure-Directory -Path $paths.LogDir
Ensure-Directory -Path $paths.BackupDir Ensure-Directory -Path $paths.BackupDir
if (-not (Test-Path -LiteralPath $CodexConfigPath)) { if (-not (Test-Path -LiteralPath $CodexConfigPath)) {
throw "Codex config file was not found: $CodexConfigPath" throw "Codex config file was not found: $CodexConfigPath"
} }
$providerContext = Get-CodexProviderContext -CodexConfigPath $CodexConfigPath $providerContext = Get-CodexProviderContext -CodexConfigPath $CodexConfigPath
$localGatewayBaseUrl = "http://{0}:{1}" -f $ListenHost, $ListenPort $localGatewayBaseUrl = "http://{0}:{1}" -f $ListenHost, $ListenPort
$existingState = Read-JsonFile -Path $paths.StatePath $existingState = Read-JsonFile -Path $paths.StatePath
$originalBaseUrl = $providerContext.CurrentBaseUrl $originalBaseUrl = $providerContext.CurrentBaseUrl
if ($providerContext.CurrentBaseUrl -eq $localGatewayBaseUrl) { if ($providerContext.CurrentBaseUrl -eq $localGatewayBaseUrl) {
if ($null -eq $existingState -or [string]::IsNullOrWhiteSpace([string]$existingState.original_base_url)) { 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." throw "Provider already points to the local gateway, but original_base_url is missing from state."
} }
$originalBaseUrl = [string]$existingState.original_base_url $originalBaseUrl = [string]$existingState.original_base_url
} }
if ($originalBaseUrl -eq $localGatewayBaseUrl) { if ($originalBaseUrl -eq $localGatewayBaseUrl) {
throw "A real upstream_base_url could not be determined." throw "A real upstream_base_url could not be determined."
} }
$backupPath = Join-Path $paths.BackupDir ("config-" + (Get-Date -Format "yyyyMMdd-HHmmss") + ".toml") $backupPath = Join-Path $paths.BackupDir ("config-" + (Get-Date -Format "yyyyMMdd-HHmmss") + ".toml")
Copy-Item -LiteralPath $CodexConfigPath -Destination $backupPath -Force Copy-Item -LiteralPath $CodexConfigPath -Destination $backupPath -Force
$existingGatewayConfig = Read-JsonFile -Path $paths.ConfigPath $existingGatewayConfig = Read-JsonFile -Path $paths.ConfigPath
$defaultEndpoints = @("/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions") $defaultEndpoints = @("/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions")
$mergedEndpoints = @() $mergedEndpoints = @()
foreach ($endpoint in @( foreach ($endpoint in @(
$(if ($existingGatewayConfig) { Normalize-StringArray -Values $existingGatewayConfig.endpoints -Default @() } else { @() }) + $(if ($existingGatewayConfig) { Normalize-StringArray -Values $existingGatewayConfig.endpoints -Default @() } else { @() }) +
$defaultEndpoints $defaultEndpoints
)) { )) {
if ([string]::IsNullOrWhiteSpace([string]$endpoint)) { if ([string]::IsNullOrWhiteSpace([string]$endpoint)) {
continue continue
} }
if ($mergedEndpoints -notcontains [string]$endpoint) { if ($mergedEndpoints -notcontains [string]$endpoint) {
$mergedEndpoints += [string]$endpoint $mergedEndpoints += [string]$endpoint
} }
} }
$gatewayConfig = [ordered]@{ $gatewayConfig = [ordered]@{
listen_host = $ListenHost listen_host = $ListenHost
listen_port = $ListenPort listen_port = $ListenPort
upstream_base_url = $originalBaseUrl 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 } request_body_limit_bytes = if ($existingGatewayConfig -and $null -ne $existingGatewayConfig.request_body_limit_bytes) { [int]$existingGatewayConfig.request_body_limit_bytes } else { 10485760 }
endpoints = @($mergedEndpoints) endpoints = @($mergedEndpoints)
reasoning_equals = Normalize-IntArray -Values $(if ($existingGatewayConfig) { $existingGatewayConfig.reasoning_equals } else { $null }) -Default @(516) 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 } 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" } 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 } 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" } 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 $previousConfigContent = Get-Content -LiteralPath $CodexConfigPath -Raw
try { try {
Write-JsonFile -Path $paths.ConfigPath -Value $gatewayConfig Write-JsonFile -Path $paths.ConfigPath -Value $gatewayConfig
Set-CodexProviderBaseUrl ` Set-CodexProviderBaseUrl `
-CodexConfigPath $CodexConfigPath ` -CodexConfigPath $CodexConfigPath `
-ProviderName $providerContext.ProviderName ` -ProviderName $providerContext.ProviderName `
-NewBaseUrl $localGatewayBaseUrl -NewBaseUrl $localGatewayBaseUrl
& (Join-Path $PSScriptRoot "start-gateway.ps1") ` & (Join-Path $PSScriptRoot "start-gateway.ps1") `
-StateRoot $StateRoot ` -StateRoot $StateRoot `
-ConfigPath $paths.ConfigPath ` -ConfigPath $paths.ConfigPath `
-LogPath $paths.LogPath ` -LogPath $paths.LogPath `
-RestartIfRunning -RestartIfRunning
$state = [ordered]@{ $state = [ordered]@{
installed_at = (Get-Date).ToString("o") installed_at = (Get-Date).ToString("o")
codex_config_path = $CodexConfigPath codex_config_path = $CodexConfigPath
provider_name = $providerContext.ProviderName provider_name = $providerContext.ProviderName
original_base_url = $originalBaseUrl original_base_url = $originalBaseUrl
gateway_base_url = $localGatewayBaseUrl gateway_base_url = $localGatewayBaseUrl
gateway_config_path = $paths.ConfigPath gateway_config_path = $paths.ConfigPath
gateway_log_path = $paths.LogPath gateway_log_path = $paths.LogPath
gateway_pid_path = $paths.PidPath gateway_pid_path = $paths.PidPath
latest_backup_path = $backupPath latest_backup_path = $backupPath
state_root = $paths.StateRoot state_root = $paths.StateRoot
} }
Write-JsonFile -Path $paths.StatePath -Value $state Write-JsonFile -Path $paths.StatePath -Value $state
Write-Output "Installed Codex Retry Gateway" Write-Output "Installed Codex Retry Gateway"
Write-Output "provider=$($providerContext.ProviderName)" Write-Output "provider=$($providerContext.ProviderName)"
Write-Output "upstream=$originalBaseUrl" Write-Output "upstream=$originalBaseUrl"
Write-Output "gateway=$localGatewayBaseUrl" Write-Output "gateway=$localGatewayBaseUrl"
Write-Output "config=$($paths.ConfigPath)" Write-Output "config=$($paths.ConfigPath)"
Write-Output "backup=$backupPath" Write-Output "backup=$backupPath"
} catch { } catch {
Write-Utf8NoBomFile -Path $CodexConfigPath -Content $previousConfigContent Write-Utf8NoBomFile -Path $CodexConfigPath -Content $previousConfigContent
& (Join-Path $PSScriptRoot "stop-gateway.ps1") -StateRoot $StateRoot -Quiet & (Join-Path $PSScriptRoot "stop-gateway.ps1") -StateRoot $StateRoot -Quiet
throw throw
} }
+353 -281
View File
@@ -1,316 +1,388 @@
#!/usr/bin/env node #!/usr/bin/env node
import http from "node:http"; import http from "node:http";
import net from "node:net"; import net from "node:net";
import { once } from "node:events"; import { once } from "node:events";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
const gatewayRoot = path.resolve(import.meta.dirname, ".."); const gatewayRoot = path.resolve(import.meta.dirname, "..");
const gatewayEntry = path.join(gatewayRoot, "gateway.mjs"); const gatewayEntry = path.join(gatewayRoot, "gateway.mjs");
function assert(condition, message) { function assert(condition, message) {
if (!condition) { if (!condition) {
throw new Error(message); throw new Error(message);
} }
} }
async function getFreePort() { async function getFreePort() {
const server = net.createServer(); const server = net.createServer();
server.listen(0, "127.0.0.1"); server.listen(0, "127.0.0.1");
await once(server, "listening"); await once(server, "listening");
const address = server.address(); const address = server.address();
const port = address && typeof address === "object" ? address.port : null; const port = address && typeof address === "object" ? address.port : null;
server.close(); server.close();
await once(server, "close"); await once(server, "close");
if (!port) { if (!port) {
throw new Error("无法分配空闲端口"); throw new Error("无法分配空闲端口");
} }
return port; return port;
} }
function createJsonResponse(res, statusCode, body, extraHeaders = {}) { function createJsonResponse(res, statusCode, body, extraHeaders = {}) {
res.writeHead(statusCode, { res.writeHead(statusCode, {
"content-type": "application/json; charset=utf-8", "content-type": "application/json; charset=utf-8",
...extraHeaders, ...extraHeaders,
}); });
res.end(JSON.stringify(body)); res.end(JSON.stringify(body));
} }
function createSseResponse(res, chunks) { function createSseResponse(res, chunks) {
res.writeHead(200, { res.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8", "content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache", "cache-control": "no-cache",
connection: "keep-alive", connection: "keep-alive",
"x-upstream-test": "sse", "x-upstream-test": "sse",
}); });
let index = 0; let index = 0;
const timer = setInterval(() => { const timer = setInterval(() => {
if (index >= chunks.length) { if (index >= chunks.length) {
clearInterval(timer); clearInterval(timer);
res.end(); res.end();
return; return;
} }
res.write(chunks[index]); res.write(chunks[index]);
index += 1; index += 1;
}, 20); }, 20);
res.on("close", () => { res.on("close", () => {
clearInterval(timer); 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) { function startFakeUpstream(port) {
const failBeforeResponseCounts = new Map();
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
const responsePaths = new Set(["/responses", "/v1/responses"]); const responsePaths = new Set(["/responses", "/v1/responses"]);
const chatCompletionPaths = new Set(["/chat/completions", "/v1/chat/completions"]); const chatCompletionPaths = new Set(["/chat/completions", "/v1/chat/completions"]);
if (req.method === "GET" && req.url === "/v1/models") { if (req.method === "GET" && req.url === "/v1/models") {
createJsonResponse( createJsonResponse(
res, res,
200, 200,
{ {
object: "list", object: "list",
data: [{ id: "fake-model" }], data: [{ id: "fake-model" }],
}, },
{ "x-upstream-test": "models-ok" }, { "x-upstream-test": "models-ok" },
); );
return; return;
} }
if (req.method === "POST" && responsePaths.has(req.url)) { if (req.method === "POST" && responsePaths.has(req.url)) {
let body = ""; let body = "";
req.setEncoding("utf8"); req.setEncoding("utf8");
req.on("data", (chunk) => { req.on("data", (chunk) => {
body += chunk; body += chunk;
}); });
req.on("end", () => { req.on("end", () => {
const parsed = JSON.parse(body || "{}"); const parsed = JSON.parse(body || "{}");
const reasoning = parsed.test_reasoning_tokens ?? 128; 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( createJsonResponse(
res, res,
200, 200,
{ {
id: "resp_test", id: "resp_test",
retry_attempt: parsed.test_fail_before_response_once
? failBeforeResponseCounts.get(`${req.url}:fail-before-response-once`) || 0
: 0,
usage: { usage: {
output_tokens_details: { output_tokens_details: {
reasoning_tokens: reasoning, reasoning_tokens: reasoning,
}, },
}, },
}, },
{ "x-upstream-test": `responses-${reasoning}` }, { "x-upstream-test": `responses-${reasoning}` },
); );
}); });
return; return;
} }
if (req.method === "POST" && chatCompletionPaths.has(req.url)) { if (req.method === "POST" && chatCompletionPaths.has(req.url)) {
let body = ""; let body = "";
req.setEncoding("utf8"); req.setEncoding("utf8");
req.on("data", (chunk) => { req.on("data", (chunk) => {
body += chunk; body += chunk;
}); });
req.on("end", () => { req.on("end", () => {
const parsed = JSON.parse(body || "{}"); const parsed = JSON.parse(body || "{}");
const reasoning = parsed.test_reasoning_tokens ?? 128; const reasoning = parsed.test_reasoning_tokens ?? 128;
if (reasoning === 516) { if (reasoning === 516) {
createSseResponse(res, [ createSseResponse(res, [
'data: {"id":"chunk-1","choices":[{"delta":{"content":"hello"}}]}\n\n', 'data: {"id":"chunk-1","choices":[{"delta":{"content":"hello"}}]}\n\n',
'data: {"usage":{"completion_tokens_details":{"reasoning_tokens":516}}}\n\n', 'data: {"usage":{"completion_tokens_details":{"reasoning_tokens":516}}}\n\n',
"data: [DONE]\n\n", "data: [DONE]\n\n",
]); ]);
return; return;
} }
createSseResponse(res, [ createSseResponse(res, [
'data: {"id":"chunk-1","choices":[{"delta":{"content":"hello"}}]}\n\n', 'data: {"id":"chunk-1","choices":[{"delta":{"content":"hello"}}]}\n\n',
'data: {"usage":{"completion_tokens_details":{"reasoning_tokens":128}}}\n\n', 'data: {"usage":{"completion_tokens_details":{"reasoning_tokens":128}}}\n\n',
"data: [DONE]\n\n", "data: [DONE]\n\n",
]); ]);
}); });
return; return;
} }
createJsonResponse(res, 404, { error: "not found" }); createJsonResponse(res, 404, { error: "not found" });
}); });
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
server.once("error", reject); server.once("error", reject);
server.listen(port, "127.0.0.1", () => resolve(server)); server.listen(port, "127.0.0.1", () => resolve(server));
}); });
} }
async function waitForHealth(url, timeoutMs = 5000) { async function waitForHealth(url, timeoutMs = 5000) {
const startedAt = Date.now(); const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) { while (Date.now() - startedAt < timeoutMs) {
try { try {
const response = await fetch(url); const response = await fetch(url);
if (response.ok) { if (response.ok) {
return; return;
} }
} catch { } catch {
// ignore startup race // ignore startup race
} }
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
} }
throw new Error(`等待网关健康检查超时: ${url}`); throw new Error(`等待网关健康检查超时: ${url}`);
} }
function startGateway(configPath, logPath) { function startGateway(configPath, logPath) {
const child = spawn(process.execPath, [gatewayEntry, "--config", configPath, "--log", logPath], { const child = spawn(process.execPath, [gatewayEntry, "--config", configPath, "--log", logPath], {
cwd: gatewayRoot, cwd: gatewayRoot,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
}); });
let stdout = ""; let stdout = "";
let stderr = ""; let stderr = "";
child.stdout.on("data", (chunk) => { child.stdout.on("data", (chunk) => {
stdout += chunk.toString(); stdout += chunk.toString();
}); });
child.stderr.on("data", (chunk) => { child.stderr.on("data", (chunk) => {
stderr += chunk.toString(); stderr += chunk.toString();
}); });
return { return {
child, child,
getOutput() { getOutput() {
return { stdout, stderr }; return { stdout, stderr };
}, },
}; };
} }
async function readSseUntilClose(url, requestBody) { async function readSseUntilClose(url, requestBody) {
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify(requestBody), body: JSON.stringify(requestBody),
}); });
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder("utf8"); const decoder = new TextDecoder("utf8");
let text = ""; let text = "";
let closedByError = false; let closedByError = false;
while (true) { while (true) {
try { try {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) { if (done) {
break; break;
} }
text += decoder.decode(value, { stream: true }); text += decoder.decode(value, { stream: true });
} catch (error) { } catch (error) {
closedByError = true; closedByError = true;
text += `\n[[reader-error:${error?.name || "unknown"}]]`; text += `\n[[reader-error:${error?.name || "unknown"}]]`;
break; break;
} }
} }
text += decoder.decode(); text += decoder.decode();
return { return {
status: response.status, status: response.status,
headers: response.headers, headers: response.headers,
text, text,
closedByError, closedByError,
}; };
} }
async function run() { async function run() {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "codex-retry-gateway-")); const tempRoot = await mkdtemp(path.join(os.tmpdir(), "codex-retry-gateway-"));
const upstreamPort = await getFreePort(); const upstreamPort = await getFreePort();
const gatewayPort = await getFreePort(); const gatewayPort = await getFreePort();
const configPath = path.join(tempRoot, "config.json"); const configPath = path.join(tempRoot, "config.json");
const logPath = path.join(tempRoot, "gateway.log"); const logPath = path.join(tempRoot, "gateway.log");
const config = { const config = {
listen_host: "127.0.0.1", listen_host: "127.0.0.1",
listen_port: gatewayPort, listen_port: gatewayPort,
upstream_base_url: `http://127.0.0.1:${upstreamPort}`, upstream_base_url: `http://127.0.0.1:${upstreamPort}`,
request_body_limit_bytes: 10 * 1024 * 1024, request_body_limit_bytes: 10 * 1024 * 1024,
endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
reasoning_equals: [516], reasoning_equals: [516],
non_stream_status_code: 502, non_stream_status_code: 502,
stream_action: "disconnect", stream_action: "strict_502",
log_match: true, log_match: true,
health_path: "/__codex_retry_gateway/health", 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"); for (const streamPath of [
"/responses",
const upstream = await startFakeUpstream(upstreamPort); "/v1/responses",
const gateway = startGateway(configPath, logPath); "/chat/completions",
"/v1/chat/completions",
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( const blockedStream = await readSseUntilClose(
`http://127.0.0.1:${gatewayPort}${streamPath}`, `http://127.0.0.1:${gatewayPort}${streamPath}`,
{ stream: true, test_reasoning_tokens: 516 }, { stream: true, test_reasoning_tokens: 516 },
); );
assert(blockedStream.status === 200, `${streamPath} 516 首状态异常: ${blockedStream.status}`); assert(blockedStream.status === 502, `${streamPath} 516 未返回 502: ${blockedStream.status}`);
assert(blockedStream.text.includes('"content":"hello"'), `${streamPath} 流式 516 未先透传正常 chunk`); assert(!blockedStream.text.includes("hello"), `${streamPath} 严格 502 模式不应先透传正常 chunk`);
assert(!blockedStream.text.includes("[DONE]"), `${streamPath} 流式 516 不应完整结束`); assert(!blockedStream.text.includes("[DONE]"), `${streamPath} 严格 502 模式不应回放 DONE`);
const blockedStreamBody = JSON.parse(blockedStream.text);
assert( assert(
blockedStream.closedByError || blockedStream.text.includes("[[reader-error:"), blockedStreamBody?.error?.code === "reasoning_guard_triggered",
`${streamPath} 流式 516 未表现为中途断开`, `${streamPath} 流式 516 返回体不正确`,
);
const okStream = await readSseUntilClose(
`http://127.0.0.1:${gatewayPort}${streamPath}`,
{ stream: true, test_reasoning_tokens: 128 },
); );
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.status === 200, `${streamPath} 128 首状态异常: ${okStream.status}`);
assert(okStream.text.includes("[DONE]"), `${streamPath} 流式 128 未完整结束`); assert(okStream.text.includes("[DONE]"), `${streamPath} 流式 128 未完整结束`);
assert(!okStream.closedByError, `${streamPath} 流式 128 不应异常断开`); assert(!okStream.closedByError, `${streamPath} 流式 128 不应异常断开`);
} }
process.stdout.write("PASS codex-retry-gateway e2e\n"); const terminatedStream = await readSseUntilClose(
} finally { `http://127.0.0.1:${gatewayPort}/responses`,
gateway.child.kill(); { stream: true, test_force_terminate: true },
upstream.close(); );
await once(upstream, "close"); assert(terminatedStream.status === 502, `/responses 上游半路断流未返回 502: ${terminatedStream.status}`);
await rm(tempRoot, { recursive: true, force: true });
}
}
run().catch((error) => { await new Promise((resolve) => setTimeout(resolve, 120));
process.stderr.write(`${error?.stack || error}\n`); const logText = await readFile(logPath, "utf8");
process.exit(1); 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);
});