feat: publish codex retry gateway

This commit is contained in:
fleetingtime20026
2026-06-26 10:07:55 +08:00
commit 9cc6bc9b31
31 changed files with 4674 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
.DS_Store
Thumbs.db
*.log
+263
View File
@@ -0,0 +1,263 @@
# Codex Retry Gateway
一个不依赖 `cc-switch` 路由模式的独立本地网关。
目标:
- 保持 Codex 继续使用现有 `auth.json`
- 只把 `config.toml` 的当前 provider `base_url` 改成本地网关
- 非流式命中 `reasoning_tokens = 516` 时返回 `502`
- 流式命中时直接断开连接,让 Codex 自行重试
- 默认同时拦截 root 路径和 `/v1` 路径:
- `/responses`
- `/chat/completions`
- `/v1/responses`
- `/v1/chat/completions`
限制:
- 这个网关不负责 `Responses``Chat Completions` 协议互转
- 如果你的上游本身不支持 Codex 当前使用的协议,这个网关不会替你补齐转换能力
## 默认路径
Windows:
- Codex 配置:`%USERPROFILE%\.codex\config.toml`
- Gateway 状态目录:`%USERPROFILE%\.codex-retry-gateway`
macOS / Linux:
- Codex 配置:`~/.codex/config.toml`
- Gateway 状态目录:`~/.codex-retry-gateway`
## 当前版本说明
- 这是一个可独立发布、独立运行的仓库
- 默认监听地址是 `http://127.0.0.1:4610`
- 默认示例上游见 `config.example.json`
- 实际运行时配置会写到当前用户目录下的 gateway 状态目录
## 一键启动并打开管理页
在仓库根目录执行:
Windows:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1
```
macOS / Linux:
```bash
bash ./scripts/launch-ui.sh
```
这个脚本是默认入口,执行后会自动完成:
- 第一次运行时:
- 备份当前用户目录下的 Codex `config.toml`
- 生成当前用户目录下的 gateway `config.json`
- 启动本地 gateway
- 把当前 `model_provider` 对应的 `base_url` 改到本地 gateway
- 之后再次运行时:
- 自动复用现有安装状态
- 自动重启或拉起 gateway
- 自动再次打开管理页
默认会打开:
```text
http://127.0.0.1:4610/__codex_retry_gateway/ui
```
如果你只想启动、不自动开浏览器:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1 -NoOpen
```
```bash
bash ./scripts/launch-ui.sh --no-open
```
常用参数:
- Windows 参数:
- `-CodexConfigPath`
- `-StateRoot`
- `-ListenHost`
- `-ListenPort`
- `-NoOpen`
- macOS / Linux 参数:
- `--codex-config-path`
- `--state-root`
- `--listen-host`
- `--listen-port`
- `--no-open`
macOS / Linux 说明:
- 需要 `bash`
- 需要 `Node.js 18+`
- Unix 入口会调用跨平台 `node` 管理核心,不依赖 PowerShell
- 推荐显式使用 `bash ...sh`
- 这样即使目录是从 Windows 或压缩包复制过来、没有可执行位,也能直接运行
## 手工安装入口
如果你明确只想做脚本级安装,不想自动打开 UI,也可以直接执行:
Windows:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\install-for-current-provider.ps1
```
macOS / Linux:
```bash
bash ./scripts/install-for-current-provider.sh
```
## 如何恢复
Windows:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\restore-codex-config.ps1
```
macOS / Linux:
```bash
bash ./scripts/restore-codex-config.sh
```
这个脚本会:
- 停掉本地 gateway
- 用最近一次备份恢复当前用户目录下的 Codex `config.toml`
- 删除当前安装状态文件
## 管理页面
页面入口:
```text
http://127.0.0.1:4610/__codex_retry_gateway/ui
```
页面里可以直接做这几件事:
- 看当前监听地址、真实上游、当前 provider、当前 Codex base URL
- 看本次 gateway 启动以来的实时统计
- 代理请求总数
- 被检查响应总数
- `516` 命中次数
- `516` 占比
-`reasoning_equals`
-`endpoints`
-`non_stream_status_code`
- 开关 `log_match`
- 动态查看当前 gateway 的实时日志
- 一键恢复 Codex 原设置
说明:
- 页面保存配置后会立即热生效,不需要重启 gateway
- 页面点“恢复 Codex 原设置并关闭网关”后,当前页面会失联,这是预期行为
- 日常恢复优先用 UI`restore-codex-config.ps1` 作为脚本级应急回滚入口保留
- UI 恢复不会再额外拉起恢复子进程,而是由当前 gateway 直接完成恢复并退出
- 统计口径默认按“本次 gateway 启动以来”累计
- `516` 占比 = `reasoning_tokens = 516` 的响应次数 / 被检查响应总数
## 如何调整拦截条件
编辑:
```text
Windows: %USERPROFILE%\.codex-retry-gateway\config\config.json
macOS / Linux: ~/.codex-retry-gateway/config/config.json
```
常用字段:
- `reasoning_equals`
- 例如 `[516]`
- `endpoints`
- 默认包含 root 与 `/v1` 两套路径
- `non_stream_status_code`
- 默认 `502`
- `stream_action`
- 默认 `disconnect`
- `log_match`
- 是否记录命中日志
改完后重启:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\start-gateway.ps1 -RestartIfRunning
```
```bash
bash ./scripts/start-gateway.sh --restart-if-running
```
如果你已经打开管理页,优先直接在页面里改,通常不需要手改 `config.json`
## 其他机器如何应用
在其他 Windows 机器上:
1. 复制整个仓库目录
2. 确保本机有 `Node.js 18+`
3. 不需要安装 `cc-switch`,也不需要使用 `cc-switch` 路由模式
4. 在仓库根目录执行 `powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1`
5. 如需回滚,优先在 UI 里点“恢复 Codex 原设置并关闭网关”;脚本级回滚仍可执行 `powershell -ExecutionPolicy Bypass -File .\scripts\restore-codex-config.ps1`
在其他 macOS / Linux 机器上:
1. 复制整个仓库目录
2. 确保本机有 `bash`
3. 确保本机有 `Node.js 18+`
4. 不需要安装 `cc-switch`,也不需要使用 `cc-switch` 路由模式
5. 在仓库根目录执行 `bash ./scripts/launch-ui.sh`
6. 如需回滚,优先在 UI 里点“恢复 Codex 原设置并关闭网关”;脚本级回滚仍可执行 `bash ./scripts/restore-codex-config.sh`
运行时状态默认写到当前用户目录:
```text
Windows: %USERPROFILE%\.codex-retry-gateway
macOS / Linux: ~/.codex-retry-gateway
```
## 已验证事项
- `test-gateway-e2e.ps1`
- 已通过
- 验证 `/responses``/chat/completions``/v1/responses``/v1/chat/completions`
- `test-install-restore.ps1`
- 已通过
- 验证安装、透传、UI 页面、热更新配置、实时日志、516 统计、恢复闭环
- `test-launch-ui.ps1`
- 已通过
- 验证首次一键启动自动安装、再次启动自动复用、UI 可访问、默认 516 拦截仍生效
- `test-launch-ui-unix.ps1`
- 已通过
- 在当前 Windows 主机的 Bash 环境里验证 Unix `.sh` 入口能完成启动、透传、恢复闭环
- `bash ./scripts/launch-ui.sh --no-open`
- 已通过
- 当前机器实测返回 `mode=reuse`
- 后续 `GET /__codex_retry_gateway/health``GET /__codex_retry_gateway/ui``GET /v1/models` 都返回 `200`
- `codex exec`
- 已通过
- 在 Bash 默认入口重新拉起 gateway 后,当前机器再次返回 `OK`
- 当前实机验证示例
- `GET http://127.0.0.1:4610/__codex_retry_gateway/health` 已通过
- `GET http://127.0.0.1:4610/v1/models` 已通过,并成功透传到配置里的真实上游
- `GET http://127.0.0.1:4610/__codex_retry_gateway/ui` 已实际打开并确认页面内容
- `codex exec` 历史现象
- gateway 关闭时,真实报错地址为 `http://127.0.0.1:4610/responses`
- gateway 恢复后,`codex exec` 已再次成功返回 `OK`
+124
View File
@@ -0,0 +1,124 @@
# build.md
## 环境要求
- Windows 需要 PowerShell 5.1+ 或 PowerShell 7+
- macOS / Linux 需要 `bash`
- Node.js 18+
## 直接运行网关
```powershell
node .\gateway.mjs --config .\config.example.json
```
## 推荐用法
Windows:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1
```
macOS / Linux:
```bash
bash ./scripts/launch-ui.sh
```
说明:
- 第一次运行会自动安装并接管当前 Codex provider
- 再次运行会自动拉起或重启 gateway,并重新打开 UI
- 不依赖 `cc-switch` 安装本体,也不依赖 `cc-switch` 路由模式
- macOS / Linux 入口依赖 `bash``Node.js 18+`
- 推荐显式使用 `bash ...sh`,避免跨平台复制后可执行位丢失
## 只启动不自动开浏览器
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1 -NoOpen
```
```bash
bash ./scripts/launch-ui.sh --no-open
```
## 手工安装入口
Windows:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\install-for-current-provider.ps1
```
macOS / Linux:
```bash
bash ./scripts/install-for-current-provider.sh
```
## 恢复原配置
Windows:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\restore-codex-config.ps1
```
macOS / Linux:
```bash
bash ./scripts/restore-codex-config.sh
```
## 打开管理页面
```text
http://127.0.0.1:4610/__codex_retry_gateway/ui
```
页面支持:
- 查看当前接管状态
- 查看本次启动以来的实时日志
- 查看 `516` 命中次数与 `516` 占比
- 热更新 `reasoning_equals` / `endpoints` / `non_stream_status_code` / `log_match`
- 一键恢复 Codex 原设置并关闭 gateway
## 本地验证
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\test-launch-ui.ps1
powershell -ExecutionPolicy Bypass -File .\scripts\test-launch-ui-unix.ps1
powershell -ExecutionPolicy Bypass -File .\scripts\test-gateway-e2e.ps1
powershell -ExecutionPolicy Bypass -File .\scripts\test-install-restore.ps1
```
## 本机真实验证命令
```powershell
Invoke-WebRequest -UseBasicParsing 'http://127.0.0.1:4610/__codex_retry_gateway/health'
```
```powershell
Invoke-WebRequest -UseBasicParsing 'http://127.0.0.1:4610/__codex_retry_gateway/ui'
```
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\launch-ui.ps1 -NoOpen
```
```powershell
$auth = Get-Content -Raw (Join-Path $env:USERPROFILE '.codex\auth.json') | ConvertFrom-Json
$headers = @{ Authorization = "Bearer $($auth.OPENAI_API_KEY)" }
Invoke-WebRequest -UseBasicParsing 'http://127.0.0.1:4610/v1/models' -Headers $headers
```
```powershell
codex exec --ephemeral --skip-git-repo-check --color never --dangerously-bypass-approvals-and-sandbox -m gpt-5.4-mini -C $env:TEMP --output-last-message (Join-Path $env:TEMP 'codex-retry-gateway-clean-smoke.txt') '只回复OK'
```
```bash
bash ./scripts/launch-ui.sh --no-open
```
+12
View File
@@ -0,0 +1,12 @@
{
"listen_host": "127.0.0.1",
"listen_port": 4610,
"upstream_base_url": "https://api.openai.com",
"request_body_limit_bytes": 10485760,
"endpoints": ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
"reasoning_equals": [516],
"non_stream_status_code": 502,
"stream_action": "disconnect",
"log_match": true,
"health_path": "/__codex_retry_gateway/health"
}
+147
View File
@@ -0,0 +1,147 @@
# err.md
## 2026-06-26 独立 Codex Retry Gateway
### 设计边界
- 只解决 Codex 已可访问上游时的 `reasoning_tokens = 516` 重试问题
- 不替代 `cc-switch` 的协议路由转换
- 流式场景默认策略是:
- 先实时透传
- 一旦检测到命中 `516`
- 直接断开连接
### 当前已知限制
- 如果上游只支持 Chat Completions、而 Codex 当前链路需要 Responses 协议转换,这个项目不处理该转换
- 这个项目依赖 Codex / Codex Desktop 自身的自动重试能力
### 本次已确认并修复的问题
1. `gateway.mjs` 非流式透传发头顺序错误
- 现象:`ERR_HTTP_HEADERS_SENT`
- 根因:`writeHead()``copyHeadersToClient()` 之前调用
- 结果:正常 `128` 响应也会被打断
2. PowerShell 脚本在 `powershell.exe` 下的解析兼容性
- 现象:脚本乱码并伴随解析异常
- 根因:新脚本初版包含中文运行时字符串,且 `param(...)` 不在文件最前
- 处理:运行时输出改成 ASCII,并把 `param(...)` 提前到文件顶部
3. `stop-gateway.ps1` 与 PowerShell 内置只读变量 `$PID` 冲突
- 现象:安装脚本在重启 gateway 时失败
- 处理:改用 `$gatewayPid`
4. `start-gateway.ps1` 启动 Node 时路径带空格
- 现象:gateway 进程启动后立刻退出
- 根因:`Start-Process` 参数未显式带引号
- 处理:改为手工拼带引号的 `ArgumentList`
5. PowerShell 单元素数组落盘时被拆成标量
- 现象:`reasoning_equals` 被写成 `516`,不是 `[516]`
- 处理:在公共归一化函数里强制返回数组
6. 旧脏配置迁移后出现嵌套/拼接 endpoints
- 现象:`endpoints` 可能变成嵌套数组,或出现一条用空格拼接的脏字符串
- 处理:安装脚本合并 endpoints 时做递归拍平和空白拆分
7. 真实 Codex 客户端请求路径不是 `/v1/responses`
- 现象:`codex exec` 在 gateway 关闭时真实报错地址是 `http://127.0.0.1:4610/responses`
- 结论:默认配置必须同时覆盖:
- `/responses`
- `/chat/completions`
- `/v1/responses`
- `/v1/chat/completions`
8. UI 恢复动作最初采用“子进程拉起 restore 脚本”方案
- 现象:浏览器拿到 `202`,但临时 `config.toml``state.json``gateway.pid` 都没有变化
- 根因:恢复动作通过 detached 子进程接力时,链路可靠性不足,实际没有把恢复流程真正执行完
- 处理:改为当前 gateway 进程直接复制备份、清理状态并自我退出
9. 新增内嵌 UI 管理页
- 入口:`/__codex_retry_gateway/ui`
- 能力:
- 查看当前接管状态
- 热更新 `reasoning_equals`
- 热更新 `endpoints`
- 热更新 `non_stream_status_code`
- 开关 `log_match`
- 一键恢复 Codex 原设置
10. 用户不接受 `cc-switch` 路由模式,且不希望手工改设置
- 现象:仅有安装脚本和 UI 还不够,首次接管、再次拉起、重新打开 UI 仍需要手工串命令
- 处理:新增 `launch-ui.ps1`
- 结果:
- 首次运行自动安装并打开 UI
- 再次运行自动复用 `state.json + config.json` 并重启 gateway
- 平时规则调整和恢复统一回到 UI 内完成
11. UI 需要动态显示实时日志、`516` 次数和占比
- 现象:原 UI 只能改配置,看不到运行中的命中趋势
- 处理:
-`gateway.mjs` 内增加运行期统计
- 增加日志接口
- UI 轮询显示“被检查响应总数 / 516 命中次数 / 516 占比 / 实时日志”
- 统计口径:
- 按本次 gateway 启动以来累计
- `516` 占比 = `reasoning_tokens = 516` 的响应次数 / 被检查响应总数
12. macOS / Linux 不能直接使用现有 PowerShell 管理脚本
- 现象:`launch-ui.ps1``restore-codex-config.ps1` 等入口绑定了 PowerShell 和 Windows 进程控制
- 处理:
- 新增跨平台 `node` 管理核心
- 新增 `.sh` 包装入口:
- `launch-ui.sh`
- `restore-codex-config.sh`
- `install-for-current-provider.sh`
- `start-gateway.sh`
- `stop-gateway.sh`
- 结果:
- Windows 继续走 `.ps1`
- macOS / Linux 直接走 `.sh`
- UI、状态文件、gateway 主逻辑保持同一套
13. Windows 主机上模拟 Unix shell 入口时存在路径与 Node 版本兼容问题
- 现象:
- Bash 入口最初找不到脚本路径
- Bash 默认 `node` 版本过老,不支持现代语法
- `node.exe` 需要 Windows 路径,而 shell 侧是 POSIX 路径
- 处理:
- 测试改成相对 POSIX 路径执行 `.sh`
- `.sh` 优先选择 `node.exe`
- 在 WSL / Bash 场景下把路径参数转换回 Windows 路径后再交给 `node.exe`
### 2026-06-26 实测证据
- 假上游 E2E
- `test-gateway-e2e.ps1` 通过
- 已验证 root 路径和 `/v1` 路径都能区分 `516``128`
- 安装/恢复闭环
- `test-install-restore.ps1` 通过
- 已验证 UI 页面、状态接口、日志接口、516 统计、热更新配置、UI 恢复闭环
- 一键启动入口
- `test-launch-ui.ps1` 通过
- 已验证首次启动自动安装、再次启动自动复用、UI 页面可达、默认 `516 -> 502` 规则仍生效
- Unix shell 入口
- `test-launch-ui-unix.ps1` 通过
- 已验证 `.sh` 入口能完成启动、透传、恢复闭环
- Bash 默认入口实机验证
- `bash ./scripts/launch-ui.sh --no-open` 通过
- 输出 `mode=reuse`
- `GET /__codex_retry_gateway/health` 返回 `200`
- `GET /__codex_retry_gateway/ui` 返回 `200`
- `GET /v1/models` 返回 `200`,并继续透传到真实上游
- Bash 入口后的 `codex exec` 实机验证
- 命令退出码 `0`
- 最后一条消息文件返回 `OK`
- 当前真实 provider
- 当前 Codex 配置里的 `base_url` 已可切到 `http://127.0.0.1:4610`
- 当前 gateway 运行配置里的 `upstream_base_url` 会指向用户自己的真实上游
- `GET /__codex_retry_gateway/health` 返回 `ok=true`
- `GET /v1/models` 已经经本地 gateway 成功透传到真实上游
- `GET /__codex_retry_gateway/ui` 已实机打开,页面显示当前 upstream、provider、config 路径和 516 规则
- 真实 `codex exec`
- gateway 停止时,CLI 真实提示:
- `url: http://127.0.0.1:4610/responses`
- 并自动进入 `Reconnecting...`
- gateway 恢复后,`codex exec` 在临时目录再次成功返回 `OK`
+1465
View File
File diff suppressed because it is too large Load Diff
+620
View File
@@ -0,0 +1,620 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import fs from "node:fs";
import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
export const DEFAULT_STATE_ROOT = path.join(os.homedir(), ".codex-retry-gateway");
export const DEFAULT_CODEX_CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml");
export const DEFAULT_LISTEN_HOST = "127.0.0.1";
export const DEFAULT_LISTEN_PORT = 4610;
export const DEFAULT_HEALTH_PATH = "/__codex_retry_gateway/health";
function escapeRegExp(value) {
return `${value}`.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function parseOptions(argv, { booleanFlags = [] } = {}) {
const options = { _: [] };
const booleanSet = new Set(booleanFlags);
for (let index = 2; index < argv.length; index += 1) {
const current = argv[index];
if (!current.startsWith("--")) {
options._.push(current);
continue;
}
const flagName = current.slice(2);
const optionKey = flagName.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
if (booleanSet.has(flagName)) {
options[optionKey] = true;
continue;
}
const nextValue = argv[index + 1];
if (nextValue === undefined) {
throw new Error(`Missing value for --${flagName}`);
}
options[optionKey] = nextValue;
index += 1;
}
return options;
}
export function getGatewayRoot() {
return path.resolve(import.meta.dirname, "..");
}
export function getGatewayStatePaths(stateRoot = DEFAULT_STATE_ROOT) {
return {
stateRoot,
configDir: path.join(stateRoot, "config"),
logDir: path.join(stateRoot, "logs"),
backupDir: path.join(stateRoot, "backups"),
configPath: path.join(stateRoot, "config", "config.json"),
logPath: path.join(stateRoot, "logs", "gateway.log"),
statePath: path.join(stateRoot, "state.json"),
pidPath: path.join(stateRoot, "gateway.pid"),
};
}
export function getGatewayBaseUrl(listenHost, listenPort) {
return `http://${listenHost}:${listenPort}`;
}
export function getGatewayBaseUrlFromConfig(gatewayConfig) {
if (!gatewayConfig) {
return null;
}
if (!gatewayConfig.listen_host || gatewayConfig.listen_port === undefined || gatewayConfig.listen_port === null) {
return null;
}
return getGatewayBaseUrl(`${gatewayConfig.listen_host}`, Number.parseInt(`${gatewayConfig.listen_port}`, 10));
}
export async function ensureDirectory(targetPath) {
await mkdir(targetPath, { recursive: true });
}
export async function writeUtf8File(targetPath, content) {
const parent = path.dirname(targetPath);
if (parent && parent !== ".") {
await ensureDirectory(parent);
}
await writeFile(targetPath, content, "utf8");
}
export async function readJsonFile(filePath) {
if (!fs.existsSync(filePath)) {
return null;
}
const raw = await readFile(filePath, "utf8");
if (!raw.trim()) {
return null;
}
return JSON.parse(raw);
}
export async function writeJsonFile(filePath, value) {
await writeUtf8File(filePath, `${JSON.stringify(value, null, 2)}\n`);
}
export async function getCodexProviderContext(codexConfigPath) {
const content = await readFile(codexConfigPath, "utf8");
const providerMatch = content.match(/^\s*model_provider\s*=\s*"([^"]+)"\s*$/m);
if (!providerMatch) {
throw new Error(`model_provider was not found in ${codexConfigPath}`);
}
const providerName = providerMatch[1];
const sectionHeaderRegex = new RegExp(`^\\[model_providers\\.${escapeRegExp(providerName)}\\]\\s*$`, "m");
const sectionHeaderMatch = sectionHeaderRegex.exec(content);
if (!sectionHeaderMatch) {
throw new Error(`[model_providers.${providerName}] was not found in ${codexConfigPath}`);
}
const sectionIndex = sectionHeaderMatch.index;
const headerEndIndex = sectionIndex + sectionHeaderMatch[0].length;
const remainder = content.slice(headerEndIndex);
const nextSectionMatch = /^\[.*$/m.exec(remainder);
const sectionEndIndex = nextSectionMatch ? headerEndIndex + nextSectionMatch.index : content.length;
const sectionText = content.slice(sectionIndex, sectionEndIndex);
const baseUrlMatch = sectionText.match(/^\s*base_url\s*=\s*"([^"]+)"\s*$/m);
if (!baseUrlMatch) {
throw new Error(`base_url was not found in [model_providers.${providerName}]`);
}
return {
content,
providerName,
sectionText,
sectionIndex,
sectionLength: sectionText.length,
currentBaseUrl: baseUrlMatch[1],
baseUrlLineText: baseUrlMatch[0],
};
}
export async function setCodexProviderBaseUrl({ codexConfigPath, providerName, newBaseUrl }) {
const context = await getCodexProviderContext(codexConfigPath);
if (context.providerName !== providerName) {
throw new Error(`model_provider changed unexpectedly: expected ${providerName}, actual ${context.providerName}`);
}
let replaced = false;
const updatedSection = context.sectionText.replace(
/^(\s*base_url\s*=\s*")([^"]*)("\s*)$/m,
(_, prefix, __existing, suffix) => {
replaced = true;
return `${prefix}${newBaseUrl}${suffix}`;
},
);
if (!replaced) {
throw new Error(`base_url was not found in [model_providers.${providerName}]`);
}
const updatedContent =
context.content.slice(0, context.sectionIndex) +
updatedSection +
context.content.slice(context.sectionIndex + context.sectionLength);
await writeUtf8File(codexConfigPath, updatedContent);
}
export function normalizeIntArray(values, fallback = [516]) {
const source = values === undefined || values === null ? fallback : values;
const queue = Array.isArray(source) ? source.flat(Infinity) : [source];
const normalized = queue
.map((value) => (typeof value === "string" ? value.split(/[\s,]+/).filter(Boolean) : [value]))
.flat()
.map((value) => Number.parseInt(`${value}`, 10))
.filter((value) => Number.isInteger(value));
return normalized.length > 0 ? [...new Set(normalized)] : [...fallback];
}
export function normalizeStringArray(values, fallback = []) {
const source = values === undefined || values === null ? fallback : values;
const queue = Array.isArray(source) ? source.flat(Infinity) : [source];
const normalized = queue
.flatMap((value) => `${value ?? ""}`.split(/[\s,]+/))
.map((value) => value.trim())
.filter(Boolean);
return normalized.length > 0 ? [...new Set(normalized)] : [...fallback];
}
export function isProcessAlive(processId) {
try {
process.kill(processId, 0);
return true;
} catch {
return false;
}
}
export async function waitGatewayHealth({
listenHost,
listenPort,
healthPath,
timeoutSeconds = 10,
}) {
const deadline = Date.now() + timeoutSeconds * 1000;
const healthUrl = `${getGatewayBaseUrl(listenHost, listenPort)}${healthPath}`;
while (Date.now() < deadline) {
try {
const response = await fetch(healthUrl, { signal: AbortSignal.timeout(2000) });
if (response.status === 200) {
return response;
}
} catch {
// ignore and retry
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
throw new Error(`Gateway health check timed out: ${healthUrl}`);
}
async function readTail(filePath, lineCount = 20) {
if (!fs.existsSync(filePath)) {
return "";
}
const raw = await readFile(filePath, "utf8");
return raw.split(/\r?\n/).slice(-lineCount).join("\n").trim();
}
function openUrl(url) {
let command;
let args;
if (process.platform === "win32") {
command = "cmd";
args = ["/c", "start", "", url];
} else if (process.platform === "darwin") {
command = "open";
args = [url];
} else {
command = "xdg-open";
args = [url];
}
const child = spawn(command, args, {
detached: true,
stdio: "ignore",
windowsHide: true,
});
child.unref();
}
export async function stopGateway({ stateRoot = DEFAULT_STATE_ROOT, quiet = false }) {
const paths = getGatewayStatePaths(stateRoot);
if (!fs.existsSync(paths.pidPath)) {
return quiet ? null : "No running gateway PID file was found.";
}
const pidRaw = (await readFile(paths.pidPath, "utf8")).trim();
if (!pidRaw) {
await rm(paths.pidPath, { force: true });
return quiet ? null : "Gateway PID file was empty and has been removed.";
}
const gatewayPid = Number.parseInt(pidRaw, 10);
if (Number.isInteger(gatewayPid) && isProcessAlive(gatewayPid)) {
try {
process.kill(gatewayPid);
} catch {
// ignore first failure
}
const deadline = Date.now() + 3000;
while (Date.now() < deadline && isProcessAlive(gatewayPid)) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (isProcessAlive(gatewayPid)) {
try {
process.kill(gatewayPid, "SIGKILL");
} catch {
// ignore hard kill failure
}
}
}
await rm(paths.pidPath, { force: true });
return quiet ? null : `Gateway stopped. PID=${gatewayPid}`;
}
export async function startGateway({
stateRoot = DEFAULT_STATE_ROOT,
configPath,
logPath,
restartIfRunning = false,
}) {
const paths = getGatewayStatePaths(stateRoot);
const effectiveConfigPath = configPath || paths.configPath;
const effectiveLogPath = logPath || paths.logPath;
if (!fs.existsSync(effectiveConfigPath)) {
throw new Error(`Gateway config file was not found: ${effectiveConfigPath}`);
}
await ensureDirectory(path.dirname(effectiveLogPath));
if (fs.existsSync(paths.pidPath)) {
const existingPidRaw = (await readFile(paths.pidPath, "utf8")).trim();
if (existingPidRaw) {
const existingPid = Number.parseInt(existingPidRaw, 10);
if (Number.isInteger(existingPid) && isProcessAlive(existingPid)) {
if (restartIfRunning) {
await stopGateway({ stateRoot, quiet: true });
} else {
return `Gateway is already running. PID=${existingPid}`;
}
} else {
await rm(paths.pidPath, { force: true });
}
}
}
const gatewayConfig = await readJsonFile(effectiveConfigPath);
if (!gatewayConfig) {
throw new Error(`Gateway config file could not be read: ${effectiveConfigPath}`);
}
const gatewayRoot = getGatewayRoot();
const gatewayEntry = path.join(gatewayRoot, "gateway.mjs");
if (!fs.existsSync(gatewayEntry)) {
throw new Error(`Gateway entry file was not found: ${gatewayEntry}`);
}
const child = spawn(process.execPath, [gatewayEntry, "--config", effectiveConfigPath, "--log", effectiveLogPath], {
cwd: gatewayRoot,
detached: true,
stdio: "ignore",
windowsHide: true,
});
child.unref();
await writeUtf8File(paths.pidPath, `${child.pid}`);
await new Promise((resolve) => setTimeout(resolve, 300));
if (!isProcessAlive(child.pid)) {
const logTail = await readTail(effectiveLogPath, 20);
throw new Error(`Gateway exited right after startup. PID=${child.pid}\n${logTail}`);
}
await waitGatewayHealth({
listenHost: `${gatewayConfig.listen_host}`,
listenPort: Number.parseInt(`${gatewayConfig.listen_port}`, 10),
healthPath: `${gatewayConfig.health_path || DEFAULT_HEALTH_PATH}`,
});
return `Gateway started. PID=${child.pid}. Listen=${getGatewayBaseUrl(gatewayConfig.listen_host, gatewayConfig.listen_port)}`;
}
export async function installForCurrentProvider({
codexConfigPath = DEFAULT_CODEX_CONFIG_PATH,
stateRoot = DEFAULT_STATE_ROOT,
listenHost = DEFAULT_LISTEN_HOST,
listenPort = DEFAULT_LISTEN_PORT,
}) {
const paths = getGatewayStatePaths(stateRoot);
await ensureDirectory(paths.stateRoot);
await ensureDirectory(paths.configDir);
await ensureDirectory(paths.logDir);
await ensureDirectory(paths.backupDir);
if (!fs.existsSync(codexConfigPath)) {
throw new Error(`Codex config file was not found: ${codexConfigPath}`);
}
const providerContext = await getCodexProviderContext(codexConfigPath);
const localGatewayBaseUrl = getGatewayBaseUrl(listenHost, listenPort);
const existingState = await readJsonFile(paths.statePath);
let originalBaseUrl = providerContext.currentBaseUrl;
if (providerContext.currentBaseUrl === localGatewayBaseUrl) {
if (!existingState?.original_base_url) {
throw new Error("Provider already points to the local gateway, but original_base_url is missing from state.");
}
originalBaseUrl = `${existingState.original_base_url}`;
}
if (originalBaseUrl === localGatewayBaseUrl) {
throw new Error("A real upstream_base_url could not be determined.");
}
const backupPath = path.join(paths.backupDir, `config-${new Date().toISOString().replace(/[:.]/g, "").replace("T", "-").slice(0, 15)}.toml`);
await copyFile(codexConfigPath, backupPath);
const existingGatewayConfig = await readJsonFile(paths.configPath);
const defaultEndpoints = ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"];
const mergedEndpoints = [];
for (const endpoint of [
...normalizeStringArray(existingGatewayConfig?.endpoints, []),
...defaultEndpoints,
]) {
if (!mergedEndpoints.includes(endpoint)) {
mergedEndpoints.push(endpoint);
}
}
const gatewayConfig = {
listen_host: listenHost,
listen_port: listenPort,
upstream_base_url: originalBaseUrl,
request_body_limit_bytes:
existingGatewayConfig?.request_body_limit_bytes === undefined || existingGatewayConfig?.request_body_limit_bytes === null
? 10485760
: Number.parseInt(`${existingGatewayConfig.request_body_limit_bytes}`, 10),
endpoints: mergedEndpoints,
reasoning_equals: normalizeIntArray(existingGatewayConfig?.reasoning_equals, [516]),
non_stream_status_code:
existingGatewayConfig?.non_stream_status_code === undefined || existingGatewayConfig?.non_stream_status_code === null
? 502
: Number.parseInt(`${existingGatewayConfig.non_stream_status_code}`, 10),
stream_action: existingGatewayConfig?.stream_action || "disconnect",
log_match: existingGatewayConfig?.log_match === undefined ? true : Boolean(existingGatewayConfig.log_match),
health_path: existingGatewayConfig?.health_path || DEFAULT_HEALTH_PATH,
};
const previousConfigContent = await readFile(codexConfigPath, "utf8");
try {
await writeJsonFile(paths.configPath, gatewayConfig);
await setCodexProviderBaseUrl({
codexConfigPath,
providerName: providerContext.providerName,
newBaseUrl: localGatewayBaseUrl,
});
await startGateway({
stateRoot,
configPath: paths.configPath,
logPath: paths.logPath,
restartIfRunning: true,
});
const state = {
installed_at: new Date().toISOString(),
codex_config_path: codexConfigPath,
provider_name: providerContext.providerName,
original_base_url: originalBaseUrl,
gateway_base_url: localGatewayBaseUrl,
gateway_config_path: paths.configPath,
gateway_log_path: paths.logPath,
gateway_pid_path: paths.pidPath,
latest_backup_path: backupPath,
state_root: paths.stateRoot,
};
await writeJsonFile(paths.statePath, state);
return {
provider: providerContext.providerName,
upstream: originalBaseUrl,
gateway: localGatewayBaseUrl,
configPath: paths.configPath,
backupPath,
};
} catch (error) {
await writeUtf8File(codexConfigPath, previousConfigContent);
await stopGateway({ stateRoot, quiet: true });
throw error;
}
}
export async function restoreCodexConfig({
stateRoot = DEFAULT_STATE_ROOT,
codexConfigPath = DEFAULT_CODEX_CONFIG_PATH,
}) {
const paths = getGatewayStatePaths(stateRoot);
const state = await readJsonFile(paths.statePath);
if (!state) {
throw new Error(`Install state file was not found: ${paths.statePath}`);
}
const backupPath = `${state.latest_backup_path || ""}`;
if (!backupPath || !fs.existsSync(backupPath)) {
throw new Error(`A restorable backup file was not found: ${backupPath}`);
}
await stopGateway({ stateRoot, quiet: true });
await copyFile(backupPath, codexConfigPath);
await rm(paths.statePath, { force: true });
return {
configPath: codexConfigPath,
restoredFrom: backupPath,
};
}
export async function launchUi({
codexConfigPath = DEFAULT_CODEX_CONFIG_PATH,
stateRoot = DEFAULT_STATE_ROOT,
listenHost = DEFAULT_LISTEN_HOST,
listenPort = DEFAULT_LISTEN_PORT,
noOpen = false,
}) {
const paths = getGatewayStatePaths(stateRoot);
await ensureDirectory(paths.stateRoot);
await ensureDirectory(paths.configDir);
await ensureDirectory(paths.logDir);
await ensureDirectory(paths.backupDir);
if (!fs.existsSync(codexConfigPath)) {
throw new Error(`Codex config file was not found: ${codexConfigPath}`);
}
const providerContext = await getCodexProviderContext(codexConfigPath);
const currentBaseUrl = `${providerContext.currentBaseUrl}`;
const requestedGatewayBaseUrl = getGatewayBaseUrl(listenHost, listenPort);
const existingState = await readJsonFile(paths.statePath);
const existingGatewayConfig = await readJsonFile(paths.configPath);
const stateGatewayBaseUrl = existingState?.gateway_base_url ? `${existingState.gateway_base_url}` : null;
const configGatewayBaseUrl = getGatewayBaseUrlFromConfig(existingGatewayConfig);
const managedGatewayBaseUrls = [requestedGatewayBaseUrl];
for (const candidate of [stateGatewayBaseUrl, configGatewayBaseUrl]) {
if (candidate && !managedGatewayBaseUrls.includes(candidate)) {
managedGatewayBaseUrls.push(candidate);
}
}
const originalBaseUrl =
existingState?.original_base_url
? `${existingState.original_base_url}`
: existingGatewayConfig?.upstream_base_url
? `${existingGatewayConfig.upstream_base_url}`
: null;
const canReuseExistingInstall =
existingGatewayConfig &&
originalBaseUrl &&
managedGatewayBaseUrls.includes(currentBaseUrl);
let mode = "install";
if (!canReuseExistingInstall) {
await installForCurrentProvider({
codexConfigPath,
stateRoot,
listenHost,
listenPort,
});
} else {
mode = "reuse";
const previousCodexConfigContent = await readFile(codexConfigPath, "utf8");
const previousGatewayConfigContent = fs.existsSync(paths.configPath)
? await readFile(paths.configPath, "utf8")
: null;
const previousStateContent = fs.existsSync(paths.statePath)
? await readFile(paths.statePath, "utf8")
: null;
try {
existingGatewayConfig.listen_host = listenHost;
existingGatewayConfig.listen_port = listenPort;
if (!existingGatewayConfig.health_path) {
existingGatewayConfig.health_path = DEFAULT_HEALTH_PATH;
}
await writeJsonFile(paths.configPath, existingGatewayConfig);
if (currentBaseUrl !== requestedGatewayBaseUrl) {
await setCodexProviderBaseUrl({
codexConfigPath,
providerName: providerContext.providerName,
newBaseUrl: requestedGatewayBaseUrl,
});
}
await startGateway({
stateRoot,
configPath: paths.configPath,
logPath: paths.logPath,
restartIfRunning: true,
});
const statePayload = {
installed_at: existingState?.installed_at ? `${existingState.installed_at}` : new Date().toISOString(),
last_started_at: new Date().toISOString(),
codex_config_path: codexConfigPath,
provider_name: providerContext.providerName,
original_base_url: originalBaseUrl,
gateway_base_url: requestedGatewayBaseUrl,
gateway_config_path: paths.configPath,
gateway_log_path: paths.logPath,
gateway_pid_path: paths.pidPath,
latest_backup_path: existingState?.latest_backup_path ? `${existingState.latest_backup_path}` : "",
state_root: paths.stateRoot,
};
await writeJsonFile(paths.statePath, statePayload);
} catch (error) {
await writeUtf8File(codexConfigPath, previousCodexConfigContent);
if (previousGatewayConfigContent !== null) {
await writeUtf8File(paths.configPath, previousGatewayConfigContent);
}
if (previousStateContent !== null) {
await writeUtf8File(paths.statePath, previousStateContent);
}
await stopGateway({ stateRoot, quiet: true });
throw error;
}
}
const effectiveGatewayConfig = await readJsonFile(paths.configPath);
const effectiveGatewayBaseUrl = getGatewayBaseUrlFromConfig(effectiveGatewayConfig) || requestedGatewayBaseUrl;
const uiUrl = `${effectiveGatewayBaseUrl}/__codex_retry_gateway/ui`;
if (!noOpen) {
openUrl(uiUrl);
}
return {
mode,
uiUrl,
gatewayBaseUrl: effectiveGatewayBaseUrl,
};
}
+296
View File
@@ -0,0 +1,296 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Get-GatewayRoot {
return Split-Path -Parent $PSScriptRoot
}
function Get-GatewayBaseUrl {
param(
[Parameter(Mandatory = $true)]
[string]$ListenHost,
[Parameter(Mandatory = $true)]
[int]$ListenPort
)
return "http://{0}:{1}" -f $ListenHost, $ListenPort
}
function Get-GatewayStatePaths {
param(
[string]$StateRoot = (Join-Path $HOME ".codex-retry-gateway")
)
return [pscustomobject]@{
StateRoot = $StateRoot
ConfigDir = Join-Path $StateRoot "config"
LogDir = Join-Path $StateRoot "logs"
BackupDir = Join-Path $StateRoot "backups"
ConfigPath = Join-Path $StateRoot "config\config.json"
LogPath = Join-Path $StateRoot "logs\gateway.log"
StatePath = Join-Path $StateRoot "state.json"
PidPath = Join-Path $StateRoot "gateway.pid"
}
}
function Get-GatewayBaseUrlFromConfig {
param(
$GatewayConfig
)
if ($null -eq $GatewayConfig) {
return $null
}
if ([string]::IsNullOrWhiteSpace([string]$GatewayConfig.listen_host) -or $null -eq $GatewayConfig.listen_port) {
return $null
}
return Get-GatewayBaseUrl `
-ListenHost ([string]$GatewayConfig.listen_host) `
-ListenPort ([int]$GatewayConfig.listen_port)
}
function Ensure-Directory {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
New-Item -ItemType Directory -Path $Path -Force | Out-Null
}
function Write-Utf8NoBomFile {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[string]$Content
)
$parent = Split-Path -Parent $Path
if ($parent) {
Ensure-Directory -Path $parent
}
$encoding = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText($Path, $Content, $encoding)
}
function Read-JsonFile {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
if (-not (Test-Path -LiteralPath $Path)) {
return $null
}
$raw = Get-Content -LiteralPath $Path -Raw
if ([string]::IsNullOrWhiteSpace($raw)) {
return $null
}
return $raw | ConvertFrom-Json
}
function Write-JsonFile {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
$Value
)
$json = $Value | ConvertTo-Json -Depth 20
Write-Utf8NoBomFile -Path $Path -Content ($json + "`n")
}
function Get-CodexProviderContext {
param(
[Parameter(Mandatory = $true)]
[string]$CodexConfigPath
)
$content = Get-Content -LiteralPath $CodexConfigPath -Raw
$providerMatch = [regex]::Match($content, '(?m)^\s*model_provider\s*=\s*"([^"]+)"\s*$')
if (-not $providerMatch.Success) {
throw "model_provider was not found in $CodexConfigPath"
}
$providerName = $providerMatch.Groups[1].Value
$sectionPattern = "(?ms)^\[model_providers\." + [regex]::Escape($providerName) + "\]\s*$.*?(?=^\[|\z)"
$sectionMatch = [regex]::Match($content, $sectionPattern)
if (-not $sectionMatch.Success) {
throw "[model_providers.$providerName] was not found in $CodexConfigPath"
}
$sectionText = $sectionMatch.Value
$baseUrlMatch = [regex]::Match($sectionText, '(?m)^\s*base_url\s*=\s*"([^"]+)"\s*$')
if (-not $baseUrlMatch.Success) {
throw "base_url was not found in [model_providers.$providerName]"
}
return [pscustomobject]@{
Content = $content
ProviderName = $providerName
SectionText = $sectionText
SectionIndex = $sectionMatch.Index
SectionLength = $sectionMatch.Length
CurrentBaseUrl = $baseUrlMatch.Groups[1].Value
BaseUrlLineText = $baseUrlMatch.Value
}
}
function Set-CodexProviderBaseUrl {
param(
[Parameter(Mandatory = $true)]
[string]$CodexConfigPath,
[Parameter(Mandatory = $true)]
[string]$ProviderName,
[Parameter(Mandatory = $true)]
[string]$NewBaseUrl
)
$context = Get-CodexProviderContext -CodexConfigPath $CodexConfigPath
if ($context.ProviderName -ne $ProviderName) {
throw "model_provider changed unexpectedly: expected $ProviderName, actual $($context.ProviderName)"
}
$updatedSection = [regex]::Replace(
$context.SectionText,
'(?m)^(\s*base_url\s*=\s*")([^"]*)("\s*)$',
{
param($match)
return $match.Groups[1].Value + $NewBaseUrl + $match.Groups[3].Value
},
1
)
$updatedContent =
$context.Content.Substring(0, $context.SectionIndex) +
$updatedSection +
$context.Content.Substring($context.SectionIndex + $context.SectionLength)
Write-Utf8NoBomFile -Path $CodexConfigPath -Content $updatedContent
}
function Test-ProcessAlive {
param(
[Parameter(Mandatory = $true)]
[int]$ProcessId
)
try {
$null = Get-Process -Id $ProcessId -ErrorAction Stop
return $true
} catch {
return $false
}
}
function Wait-GatewayHealth {
param(
[Parameter(Mandatory = $true)]
[string]$ListenHost,
[Parameter(Mandatory = $true)]
[int]$ListenPort,
[Parameter(Mandatory = $true)]
[string]$HealthPath,
[int]$TimeoutSeconds = 10
)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
$healthUrl = "http://{0}:{1}{2}" -f $ListenHost, $ListenPort, $HealthPath
while ((Get-Date) -lt $deadline) {
try {
$response = Invoke-WebRequest -Uri $healthUrl -UseBasicParsing -TimeoutSec 2
if ($response.StatusCode -eq 200) {
return $response
}
} catch {
Start-Sleep -Milliseconds 200
}
}
throw "Gateway health check timed out: $healthUrl"
}
function Normalize-IntArray {
param(
$Values,
[int[]]$Default = @(516)
)
if ($null -eq $Values) {
return ,@($Default)
}
$queue = New-Object System.Collections.Generic.List[object]
foreach ($item in @($Values)) {
$queue.Add($item)
}
$normalized = @()
foreach ($value in $queue) {
if ($null -eq $value) {
continue
}
if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) {
foreach ($nestedValue in @($value)) {
if ($null -eq $nestedValue) {
continue
}
$normalized += [int]$nestedValue
}
continue
}
$normalized += [int]$value
}
if ($normalized.Count -eq 0) {
return ,@($Default)
}
return ,@($normalized)
}
function Normalize-StringArray {
param(
$Values,
[string[]]$Default
)
if ($null -eq $Values) {
return ,@($Default)
}
$normalized = @()
foreach ($value in @($Values)) {
if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) {
foreach ($nestedValue in @($value)) {
if ([string]::IsNullOrWhiteSpace([string]$nestedValue)) {
continue
}
foreach ($part in ([string]$nestedValue).Split(@(" ", "`t", "`r", "`n"), [System.StringSplitOptions]::RemoveEmptyEntries)) {
$normalized += $part
}
}
continue
}
if ([string]::IsNullOrWhiteSpace([string]$value)) {
continue
}
foreach ($part in ([string]$value).Split(@(" ", "`t", "`r", "`n"), [System.StringSplitOptions]::RemoveEmptyEntries)) {
$normalized += $part
}
}
if ($normalized.Count -eq 0) {
return ,@($Default)
}
return ,@($normalized)
}
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env node
import {
DEFAULT_CODEX_CONFIG_PATH,
DEFAULT_LISTEN_HOST,
DEFAULT_LISTEN_PORT,
DEFAULT_STATE_ROOT,
installForCurrentProvider,
parseOptions,
} from "./admin-lib.mjs";
async function main() {
const options = parseOptions(process.argv);
const result = await installForCurrentProvider({
codexConfigPath: options.codexConfigPath || DEFAULT_CODEX_CONFIG_PATH,
stateRoot: options.stateRoot || DEFAULT_STATE_ROOT,
listenHost: options.listenHost || DEFAULT_LISTEN_HOST,
listenPort: options.listenPort ? Number.parseInt(`${options.listenPort}`, 10) : DEFAULT_LISTEN_PORT,
});
process.stdout.write("Installed Codex Retry Gateway\n");
process.stdout.write(`provider=${result.provider}\n`);
process.stdout.write(`upstream=${result.upstream}\n`);
process.stdout.write(`gateway=${result.gateway}\n`);
process.stdout.write(`config=${result.configPath}\n`);
process.stdout.write(`backup=${result.backupPath}\n`);
}
main().catch((error) => {
process.stderr.write(`${error?.stack || error}\n`);
process.exit(1);
});
+109
View File
@@ -0,0 +1,109 @@
param(
[string]$CodexConfigPath = "$HOME\.codex\config.toml",
[string]$StateRoot = "$HOME\.codex-retry-gateway",
[string]$ListenHost = "127.0.0.1",
[int]$ListenPort = 4610
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
. (Join-Path $PSScriptRoot "common.ps1")
$paths = Get-GatewayStatePaths -StateRoot $StateRoot
Ensure-Directory -Path $paths.StateRoot
Ensure-Directory -Path $paths.ConfigDir
Ensure-Directory -Path $paths.LogDir
Ensure-Directory -Path $paths.BackupDir
if (-not (Test-Path -LiteralPath $CodexConfigPath)) {
throw "Codex config file was not found: $CodexConfigPath"
}
$providerContext = Get-CodexProviderContext -CodexConfigPath $CodexConfigPath
$localGatewayBaseUrl = "http://{0}:{1}" -f $ListenHost, $ListenPort
$existingState = Read-JsonFile -Path $paths.StatePath
$originalBaseUrl = $providerContext.CurrentBaseUrl
if ($providerContext.CurrentBaseUrl -eq $localGatewayBaseUrl) {
if ($null -eq $existingState -or [string]::IsNullOrWhiteSpace([string]$existingState.original_base_url)) {
throw "Provider already points to the local gateway, but original_base_url is missing from state."
}
$originalBaseUrl = [string]$existingState.original_base_url
}
if ($originalBaseUrl -eq $localGatewayBaseUrl) {
throw "A real upstream_base_url could not be determined."
}
$backupPath = Join-Path $paths.BackupDir ("config-" + (Get-Date -Format "yyyyMMdd-HHmmss") + ".toml")
Copy-Item -LiteralPath $CodexConfigPath -Destination $backupPath -Force
$existingGatewayConfig = Read-JsonFile -Path $paths.ConfigPath
$defaultEndpoints = @("/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions")
$mergedEndpoints = @()
foreach ($endpoint in @(
$(if ($existingGatewayConfig) { Normalize-StringArray -Values $existingGatewayConfig.endpoints -Default @() } else { @() }) +
$defaultEndpoints
)) {
if ([string]::IsNullOrWhiteSpace([string]$endpoint)) {
continue
}
if ($mergedEndpoints -notcontains [string]$endpoint) {
$mergedEndpoints += [string]$endpoint
}
}
$gatewayConfig = [ordered]@{
listen_host = $ListenHost
listen_port = $ListenPort
upstream_base_url = $originalBaseUrl
request_body_limit_bytes = if ($existingGatewayConfig -and $null -ne $existingGatewayConfig.request_body_limit_bytes) { [int]$existingGatewayConfig.request_body_limit_bytes } else { 10485760 }
endpoints = @($mergedEndpoints)
reasoning_equals = Normalize-IntArray -Values $(if ($existingGatewayConfig) { $existingGatewayConfig.reasoning_equals } else { $null }) -Default @(516)
non_stream_status_code = if ($existingGatewayConfig -and $null -ne $existingGatewayConfig.non_stream_status_code) { [int]$existingGatewayConfig.non_stream_status_code } else { 502 }
stream_action = if ($existingGatewayConfig -and -not [string]::IsNullOrWhiteSpace([string]$existingGatewayConfig.stream_action)) { [string]$existingGatewayConfig.stream_action } else { "disconnect" }
log_match = if ($existingGatewayConfig -and $null -ne $existingGatewayConfig.log_match) { [bool]$existingGatewayConfig.log_match } else { $true }
health_path = if ($existingGatewayConfig -and -not [string]::IsNullOrWhiteSpace([string]$existingGatewayConfig.health_path)) { [string]$existingGatewayConfig.health_path } else { "/__codex_retry_gateway/health" }
}
$previousConfigContent = Get-Content -LiteralPath $CodexConfigPath -Raw
try {
Write-JsonFile -Path $paths.ConfigPath -Value $gatewayConfig
Set-CodexProviderBaseUrl `
-CodexConfigPath $CodexConfigPath `
-ProviderName $providerContext.ProviderName `
-NewBaseUrl $localGatewayBaseUrl
& (Join-Path $PSScriptRoot "start-gateway.ps1") `
-StateRoot $StateRoot `
-ConfigPath $paths.ConfigPath `
-LogPath $paths.LogPath `
-RestartIfRunning
$state = [ordered]@{
installed_at = (Get-Date).ToString("o")
codex_config_path = $CodexConfigPath
provider_name = $providerContext.ProviderName
original_base_url = $originalBaseUrl
gateway_base_url = $localGatewayBaseUrl
gateway_config_path = $paths.ConfigPath
gateway_log_path = $paths.LogPath
gateway_pid_path = $paths.PidPath
latest_backup_path = $backupPath
state_root = $paths.StateRoot
}
Write-JsonFile -Path $paths.StatePath -Value $state
Write-Output "Installed Codex Retry Gateway"
Write-Output "provider=$($providerContext.ProviderName)"
Write-Output "upstream=$originalBaseUrl"
Write-Output "gateway=$localGatewayBaseUrl"
Write-Output "config=$($paths.ConfigPath)"
Write-Output "backup=$backupPath"
} catch {
Write-Utf8NoBomFile -Path $CodexConfigPath -Content $previousConfigContent
& (Join-Path $PSScriptRoot "stop-gateway.ps1") -StateRoot $StateRoot -Quiet
throw
}
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NODE_BIN="node"
ARGS=("$@")
if command -v node.exe >/dev/null 2>&1; then
NODE_BIN="node.exe"
if command -v wslpath >/dev/null 2>&1; then
SCRIPT_DIR="$(wslpath -w "$SCRIPT_DIR")"
else
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -W)"
fi
NORMALIZED_ARGS=()
EXPECT_PATH_VALUE=0
for ARG in "${ARGS[@]}"; do
if [[ "$EXPECT_PATH_VALUE" == 1 ]]; then
if command -v wslpath >/dev/null 2>&1; then
ARG="$(wslpath -w "$ARG")"
fi
EXPECT_PATH_VALUE=0
fi
case "$ARG" in
--codex-config-path|--state-root)
EXPECT_PATH_VALUE=1
;;
esac
NORMALIZED_ARGS+=("$ARG")
done
ARGS=("${NORMALIZED_ARGS[@]}")
fi
"$NODE_BIN" "$SCRIPT_DIR/install-for-current-provider.mjs" "${ARGS[@]}"
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env node
import {
DEFAULT_CODEX_CONFIG_PATH,
DEFAULT_LISTEN_HOST,
DEFAULT_LISTEN_PORT,
DEFAULT_STATE_ROOT,
launchUi,
parseOptions,
} from "./admin-lib.mjs";
async function main() {
const options = parseOptions(process.argv, { booleanFlags: ["no-open"] });
const result = await launchUi({
codexConfigPath: options.codexConfigPath || DEFAULT_CODEX_CONFIG_PATH,
stateRoot: options.stateRoot || DEFAULT_STATE_ROOT,
listenHost: options.listenHost || DEFAULT_LISTEN_HOST,
listenPort: options.listenPort ? Number.parseInt(`${options.listenPort}`, 10) : DEFAULT_LISTEN_PORT,
noOpen: Boolean(options.noOpen),
});
process.stdout.write("Codex Retry Gateway UI is ready\n");
process.stdout.write(`mode=${result.mode}\n`);
process.stdout.write(`ui=${result.uiUrl}\n`);
process.stdout.write(`gateway=${result.gatewayBaseUrl}\n`);
}
main().catch((error) => {
process.stderr.write(`${error?.stack || error}\n`);
process.exit(1);
});
+130
View File
@@ -0,0 +1,130 @@
param(
[string]$CodexConfigPath = "$HOME\.codex\config.toml",
[string]$StateRoot = "$HOME\.codex-retry-gateway",
[string]$ListenHost = "127.0.0.1",
[int]$ListenPort = 4610,
[switch]$NoOpen
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
. (Join-Path $PSScriptRoot "common.ps1")
$paths = Get-GatewayStatePaths -StateRoot $StateRoot
Ensure-Directory -Path $paths.StateRoot
Ensure-Directory -Path $paths.ConfigDir
Ensure-Directory -Path $paths.LogDir
Ensure-Directory -Path $paths.BackupDir
if (-not (Test-Path -LiteralPath $CodexConfigPath)) {
throw "Codex config file was not found: $CodexConfigPath"
}
$providerContext = Get-CodexProviderContext -CodexConfigPath $CodexConfigPath
$currentBaseUrl = [string]$providerContext.CurrentBaseUrl
$requestedGatewayBaseUrl = Get-GatewayBaseUrl -ListenHost $ListenHost -ListenPort $ListenPort
$existingState = Read-JsonFile -Path $paths.StatePath
$existingGatewayConfig = Read-JsonFile -Path $paths.ConfigPath
$stateGatewayBaseUrl = if ($existingState -and -not [string]::IsNullOrWhiteSpace([string]$existingState.gateway_base_url)) { [string]$existingState.gateway_base_url } else { $null }
$configGatewayBaseUrl = Get-GatewayBaseUrlFromConfig -GatewayConfig $existingGatewayConfig
$managedGatewayBaseUrls = @($requestedGatewayBaseUrl)
foreach ($candidate in @($stateGatewayBaseUrl, $configGatewayBaseUrl)) {
if ([string]::IsNullOrWhiteSpace([string]$candidate)) {
continue
}
if ($managedGatewayBaseUrls -notcontains [string]$candidate) {
$managedGatewayBaseUrls += [string]$candidate
}
}
$originalBaseUrl = if ($existingState -and -not [string]::IsNullOrWhiteSpace([string]$existingState.original_base_url)) {
[string]$existingState.original_base_url
} elseif ($existingGatewayConfig -and -not [string]::IsNullOrWhiteSpace([string]$existingGatewayConfig.upstream_base_url)) {
[string]$existingGatewayConfig.upstream_base_url
} else {
$null
}
$canReuseExistingInstall =
($null -ne $existingGatewayConfig) -and
(-not [string]::IsNullOrWhiteSpace([string]$originalBaseUrl)) -and
($managedGatewayBaseUrls -contains $currentBaseUrl)
$mode = "install"
if (-not $canReuseExistingInstall) {
& (Join-Path $PSScriptRoot "install-for-current-provider.ps1") `
-CodexConfigPath $CodexConfigPath `
-StateRoot $StateRoot `
-ListenHost $ListenHost `
-ListenPort $ListenPort
} else {
$mode = "reuse"
$previousCodexConfigContent = Get-Content -LiteralPath $CodexConfigPath -Raw
$previousGatewayConfigContent = if (Test-Path -LiteralPath $paths.ConfigPath) { Get-Content -LiteralPath $paths.ConfigPath -Raw } else { $null }
$previousStateContent = if (Test-Path -LiteralPath $paths.StatePath) { Get-Content -LiteralPath $paths.StatePath -Raw } else { $null }
try {
$existingGatewayConfig.listen_host = $ListenHost
$existingGatewayConfig.listen_port = $ListenPort
if ([string]::IsNullOrWhiteSpace([string]$existingGatewayConfig.health_path)) {
$existingGatewayConfig.health_path = "/__codex_retry_gateway/health"
}
Write-JsonFile -Path $paths.ConfigPath -Value $existingGatewayConfig
if ($currentBaseUrl -ne $requestedGatewayBaseUrl) {
Set-CodexProviderBaseUrl `
-CodexConfigPath $CodexConfigPath `
-ProviderName $providerContext.ProviderName `
-NewBaseUrl $requestedGatewayBaseUrl
}
& (Join-Path $PSScriptRoot "start-gateway.ps1") `
-StateRoot $StateRoot `
-ConfigPath $paths.ConfigPath `
-LogPath $paths.LogPath `
-RestartIfRunning
$statePayload = [ordered]@{
installed_at = if ($existingState -and $existingState.installed_at) { [string]$existingState.installed_at } else { (Get-Date).ToString("o") }
last_started_at = (Get-Date).ToString("o")
codex_config_path = $CodexConfigPath
provider_name = $providerContext.ProviderName
original_base_url = $originalBaseUrl
gateway_base_url = $requestedGatewayBaseUrl
gateway_config_path = $paths.ConfigPath
gateway_log_path = $paths.LogPath
gateway_pid_path = $paths.PidPath
latest_backup_path = if ($existingState -and $existingState.latest_backup_path) { [string]$existingState.latest_backup_path } else { "" }
state_root = $paths.StateRoot
}
Write-JsonFile -Path $paths.StatePath -Value $statePayload
} catch {
Write-Utf8NoBomFile -Path $CodexConfigPath -Content $previousCodexConfigContent
if ($null -ne $previousGatewayConfigContent) {
Write-Utf8NoBomFile -Path $paths.ConfigPath -Content $previousGatewayConfigContent
}
if ($null -ne $previousStateContent) {
Write-Utf8NoBomFile -Path $paths.StatePath -Content $previousStateContent
}
& (Join-Path $PSScriptRoot "stop-gateway.ps1") -StateRoot $StateRoot -Quiet
throw
}
}
$effectiveGatewayConfig = Read-JsonFile -Path $paths.ConfigPath
$effectiveGatewayBaseUrl = Get-GatewayBaseUrlFromConfig -GatewayConfig $effectiveGatewayConfig
if ([string]::IsNullOrWhiteSpace([string]$effectiveGatewayBaseUrl)) {
$effectiveGatewayBaseUrl = $requestedGatewayBaseUrl
}
$uiUrl = $effectiveGatewayBaseUrl + "/__codex_retry_gateway/ui"
if (-not $NoOpen) {
Start-Process $uiUrl | Out-Null
}
Write-Output "Codex Retry Gateway UI is ready"
Write-Output "mode=$mode"
Write-Output "ui=$uiUrl"
Write-Output "gateway=$effectiveGatewayBaseUrl"
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NODE_BIN="node"
ARGS=("$@")
if command -v node.exe >/dev/null 2>&1; then
NODE_BIN="node.exe"
if command -v wslpath >/dev/null 2>&1; then
SCRIPT_DIR="$(wslpath -w "$SCRIPT_DIR")"
else
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -W)"
fi
NORMALIZED_ARGS=()
EXPECT_PATH_VALUE=0
for ARG in "${ARGS[@]}"; do
if [[ "$EXPECT_PATH_VALUE" == 1 ]]; then
if command -v wslpath >/dev/null 2>&1; then
ARG="$(wslpath -w "$ARG")"
fi
EXPECT_PATH_VALUE=0
fi
case "$ARG" in
--codex-config-path|--state-root)
EXPECT_PATH_VALUE=1
;;
esac
NORMALIZED_ARGS+=("$ARG")
done
ARGS=("${NORMALIZED_ARGS[@]}")
fi
"$NODE_BIN" "$SCRIPT_DIR/launch-ui.mjs" "${ARGS[@]}"
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env node
import {
DEFAULT_CODEX_CONFIG_PATH,
DEFAULT_STATE_ROOT,
parseOptions,
restoreCodexConfig,
} from "./admin-lib.mjs";
async function main() {
const options = parseOptions(process.argv);
const result = await restoreCodexConfig({
stateRoot: options.stateRoot || DEFAULT_STATE_ROOT,
codexConfigPath: options.codexConfigPath || DEFAULT_CODEX_CONFIG_PATH,
});
process.stdout.write("Restored Codex config\n");
process.stdout.write(`config=${result.configPath}\n`);
process.stdout.write(`restored_from=${result.restoredFrom}\n`);
}
main().catch((error) => {
process.stderr.write(`${error?.stack || error}\n`);
process.exit(1);
});
+28
View File
@@ -0,0 +1,28 @@
param(
[string]$StateRoot = "$HOME\.codex-retry-gateway",
[string]$CodexConfigPath = "$HOME\.codex\config.toml"
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
. (Join-Path $PSScriptRoot "common.ps1")
$paths = Get-GatewayStatePaths -StateRoot $StateRoot
$state = Read-JsonFile -Path $paths.StatePath
if ($null -eq $state) {
throw "Install state file was not found: $($paths.StatePath)"
}
$backupPath = [string]$state.latest_backup_path
if ([string]::IsNullOrWhiteSpace($backupPath) -or -not (Test-Path -LiteralPath $backupPath)) {
throw "A restorable backup file was not found: $backupPath"
}
& (Join-Path $PSScriptRoot "stop-gateway.ps1") -StateRoot $StateRoot -Quiet
Copy-Item -LiteralPath $backupPath -Destination $CodexConfigPath -Force
Remove-Item -LiteralPath $paths.StatePath -Force -ErrorAction SilentlyContinue
Write-Output "Restored Codex config"
Write-Output "config=$CodexConfigPath"
Write-Output "restored_from=$backupPath"
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NODE_BIN="node"
ARGS=("$@")
if command -v node.exe >/dev/null 2>&1; then
NODE_BIN="node.exe"
if command -v wslpath >/dev/null 2>&1; then
SCRIPT_DIR="$(wslpath -w "$SCRIPT_DIR")"
else
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -W)"
fi
NORMALIZED_ARGS=()
EXPECT_PATH_VALUE=0
for ARG in "${ARGS[@]}"; do
if [[ "$EXPECT_PATH_VALUE" == 1 ]]; then
if command -v wslpath >/dev/null 2>&1; then
ARG="$(wslpath -w "$ARG")"
fi
EXPECT_PATH_VALUE=0
fi
case "$ARG" in
--codex-config-path|--state-root)
EXPECT_PATH_VALUE=1
;;
esac
NORMALIZED_ARGS+=("$ARG")
done
ARGS=("${NORMALIZED_ARGS[@]}")
fi
"$NODE_BIN" "$SCRIPT_DIR/restore-codex-config.mjs" "${ARGS[@]}"
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env node
import {
DEFAULT_STATE_ROOT,
getGatewayStatePaths,
parseOptions,
startGateway,
} from "./admin-lib.mjs";
async function main() {
const options = parseOptions(process.argv, { booleanFlags: ["restart-if-running"] });
const stateRoot = options.stateRoot || DEFAULT_STATE_ROOT;
const paths = getGatewayStatePaths(stateRoot);
const message = await startGateway({
stateRoot,
configPath: options.configPath || paths.configPath,
logPath: options.logPath || paths.logPath,
restartIfRunning: Boolean(options.restartIfRunning),
});
process.stdout.write(`${message}\n`);
}
main().catch((error) => {
process.stderr.write(`${error?.stack || error}\n`);
process.exit(1);
});
+84
View File
@@ -0,0 +1,84 @@
param(
[string]$StateRoot = "$HOME\.codex-retry-gateway",
[string]$ConfigPath,
[string]$LogPath,
[switch]$RestartIfRunning
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
. (Join-Path $PSScriptRoot "common.ps1")
$paths = Get-GatewayStatePaths -StateRoot $StateRoot
if (-not $ConfigPath) {
$ConfigPath = $paths.ConfigPath
}
if (-not $LogPath) {
$LogPath = $paths.LogPath
}
if (-not (Test-Path -LiteralPath $ConfigPath)) {
throw "Gateway config file was not found: $ConfigPath"
}
Ensure-Directory -Path (Split-Path -Parent $LogPath)
if (Test-Path -LiteralPath $paths.PidPath) {
$existingPidRaw = (Get-Content -LiteralPath $paths.PidPath -Raw).Trim()
if ($existingPidRaw) {
$existingPid = [int]$existingPidRaw
if (Test-ProcessAlive -ProcessId $existingPid) {
if ($RestartIfRunning) {
& (Join-Path $PSScriptRoot "stop-gateway.ps1") -StateRoot $StateRoot -Quiet
} else {
Write-Output "Gateway is already running. PID=$existingPid"
exit 0
}
} else {
Remove-Item -LiteralPath $paths.PidPath -Force
}
}
}
$gatewayConfig = Read-JsonFile -Path $ConfigPath
if ($null -eq $gatewayConfig) {
throw "Gateway config file could not be read: $ConfigPath"
}
$gatewayRoot = Get-GatewayRoot
$gatewayEntry = Join-Path $gatewayRoot "gateway.mjs"
if (-not (Test-Path -LiteralPath $gatewayEntry)) {
throw "Gateway entry file was not found: $gatewayEntry"
}
$nodeCommand = (Get-Command node -ErrorAction Stop).Source
$argumentLine = @(
('"{0}"' -f $gatewayEntry),
"--config",
('"{0}"' -f $ConfigPath),
"--log",
('"{0}"' -f $LogPath)
) -join " "
$process = Start-Process `
-FilePath $nodeCommand `
-ArgumentList $argumentLine `
-WorkingDirectory $gatewayRoot `
-WindowStyle Hidden `
-PassThru
Set-Content -LiteralPath $paths.PidPath -Value $process.Id -NoNewline
Start-Sleep -Milliseconds 300
if ($process.HasExited) {
$logTail = if (Test-Path -LiteralPath $LogPath) { Get-Content -LiteralPath $LogPath -Tail 20 | Out-String } else { "" }
throw "Gateway exited right after startup. PID=$($process.Id)`n$logTail"
}
$null = Wait-GatewayHealth `
-ListenHost ([string]$gatewayConfig.listen_host) `
-ListenPort ([int]$gatewayConfig.listen_port) `
-HealthPath ([string]$gatewayConfig.health_path)
Write-Output ("Gateway started. PID={0}. Listen=http://{1}:{2}" -f $process.Id, $gatewayConfig.listen_host, $gatewayConfig.listen_port)
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NODE_BIN="node"
ARGS=("$@")
if command -v node.exe >/dev/null 2>&1; then
NODE_BIN="node.exe"
if command -v wslpath >/dev/null 2>&1; then
SCRIPT_DIR="$(wslpath -w "$SCRIPT_DIR")"
else
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -W)"
fi
NORMALIZED_ARGS=()
EXPECT_PATH_VALUE=0
for ARG in "${ARGS[@]}"; do
if [[ "$EXPECT_PATH_VALUE" == 1 ]]; then
if command -v wslpath >/dev/null 2>&1; then
ARG="$(wslpath -w "$ARG")"
fi
EXPECT_PATH_VALUE=0
fi
case "$ARG" in
--state-root|--config-path|--log-path)
EXPECT_PATH_VALUE=1
;;
esac
NORMALIZED_ARGS+=("$ARG")
done
ARGS=("${NORMALIZED_ARGS[@]}")
fi
"$NODE_BIN" "$SCRIPT_DIR/start-gateway.mjs" "${ARGS[@]}"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env node
import {
DEFAULT_STATE_ROOT,
parseOptions,
stopGateway,
} from "./admin-lib.mjs";
async function main() {
const options = parseOptions(process.argv, { booleanFlags: ["quiet"] });
const message = await stopGateway({
stateRoot: options.stateRoot || DEFAULT_STATE_ROOT,
quiet: Boolean(options.quiet),
});
if (message) {
process.stdout.write(`${message}\n`);
}
}
main().catch((error) => {
process.stderr.write(`${error?.stack || error}\n`);
process.exit(1);
});
+36
View File
@@ -0,0 +1,36 @@
param(
[string]$StateRoot = "$HOME\.codex-retry-gateway",
[switch]$Quiet
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
. (Join-Path $PSScriptRoot "common.ps1")
$paths = Get-GatewayStatePaths -StateRoot $StateRoot
if (-not (Test-Path -LiteralPath $paths.PidPath)) {
if (-not $Quiet) {
Write-Output "No running gateway PID file was found."
}
exit 0
}
$pidRaw = (Get-Content -LiteralPath $paths.PidPath -Raw).Trim()
if (-not $pidRaw) {
Remove-Item -LiteralPath $paths.PidPath -Force
if (-not $Quiet) {
Write-Output "Gateway PID file was empty and has been removed."
}
exit 0
}
$gatewayPid = [int]$pidRaw
if (Test-ProcessAlive -ProcessId $gatewayPid) {
Stop-Process -Id $gatewayPid -Force
}
Remove-Item -LiteralPath $paths.PidPath -Force -ErrorAction SilentlyContinue
if (-not $Quiet) {
Write-Output "Gateway stopped. PID=$gatewayPid"
}
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NODE_BIN="node"
ARGS=("$@")
if command -v node.exe >/dev/null 2>&1; then
NODE_BIN="node.exe"
if command -v wslpath >/dev/null 2>&1; then
SCRIPT_DIR="$(wslpath -w "$SCRIPT_DIR")"
else
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -W)"
fi
NORMALIZED_ARGS=()
EXPECT_PATH_VALUE=0
for ARG in "${ARGS[@]}"; do
if [[ "$EXPECT_PATH_VALUE" == 1 ]]; then
if command -v wslpath >/dev/null 2>&1; then
ARG="$(wslpath -w "$ARG")"
fi
EXPECT_PATH_VALUE=0
fi
case "$ARG" in
--state-root)
EXPECT_PATH_VALUE=1
;;
esac
NORMALIZED_ARGS+=("$ARG")
done
ARGS=("${NORMALIZED_ARGS[@]}")
fi
"$NODE_BIN" "$SCRIPT_DIR/stop-gateway.mjs" "${ARGS[@]}"
+316
View File
@@ -0,0 +1,316 @@
#!/usr/bin/env node
import http from "node:http";
import net from "node:net";
import { once } from "node:events";
import { spawn } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
const gatewayRoot = path.resolve(import.meta.dirname, "..");
const gatewayEntry = path.join(gatewayRoot, "gateway.mjs");
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
async function getFreePort() {
const server = net.createServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
const port = address && typeof address === "object" ? address.port : null;
server.close();
await once(server, "close");
if (!port) {
throw new Error("无法分配空闲端口");
}
return port;
}
function createJsonResponse(res, statusCode, body, extraHeaders = {}) {
res.writeHead(statusCode, {
"content-type": "application/json; charset=utf-8",
...extraHeaders,
});
res.end(JSON.stringify(body));
}
function createSseResponse(res, chunks) {
res.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
connection: "keep-alive",
"x-upstream-test": "sse",
});
let index = 0;
const timer = setInterval(() => {
if (index >= chunks.length) {
clearInterval(timer);
res.end();
return;
}
res.write(chunks[index]);
index += 1;
}, 20);
res.on("close", () => {
clearInterval(timer);
});
}
function startFakeUpstream(port) {
const server = http.createServer((req, res) => {
const responsePaths = new Set(["/responses", "/v1/responses"]);
const chatCompletionPaths = new Set(["/chat/completions", "/v1/chat/completions"]);
if (req.method === "GET" && req.url === "/v1/models") {
createJsonResponse(
res,
200,
{
object: "list",
data: [{ id: "fake-model" }],
},
{ "x-upstream-test": "models-ok" },
);
return;
}
if (req.method === "POST" && responsePaths.has(req.url)) {
let body = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
const parsed = JSON.parse(body || "{}");
const reasoning = parsed.test_reasoning_tokens ?? 128;
createJsonResponse(
res,
200,
{
id: "resp_test",
usage: {
output_tokens_details: {
reasoning_tokens: reasoning,
},
},
},
{ "x-upstream-test": `responses-${reasoning}` },
);
});
return;
}
if (req.method === "POST" && chatCompletionPaths.has(req.url)) {
let body = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
const parsed = JSON.parse(body || "{}");
const reasoning = parsed.test_reasoning_tokens ?? 128;
if (reasoning === 516) {
createSseResponse(res, [
'data: {"id":"chunk-1","choices":[{"delta":{"content":"hello"}}]}\n\n',
'data: {"usage":{"completion_tokens_details":{"reasoning_tokens":516}}}\n\n',
"data: [DONE]\n\n",
]);
return;
}
createSseResponse(res, [
'data: {"id":"chunk-1","choices":[{"delta":{"content":"hello"}}]}\n\n',
'data: {"usage":{"completion_tokens_details":{"reasoning_tokens":128}}}\n\n',
"data: [DONE]\n\n",
]);
});
return;
}
createJsonResponse(res, 404, { error: "not found" });
});
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => resolve(server));
});
}
async function waitForHealth(url, timeoutMs = 5000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
const response = await fetch(url);
if (response.ok) {
return;
}
} catch {
// ignore startup race
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`等待网关健康检查超时: ${url}`);
}
function startGateway(configPath, logPath) {
const child = spawn(process.execPath, [gatewayEntry, "--config", configPath, "--log", logPath], {
cwd: gatewayRoot,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
return {
child,
getOutput() {
return { stdout, stderr };
},
};
}
async function readSseUntilClose(url, requestBody) {
const response = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(requestBody),
});
const reader = response.body.getReader();
const decoder = new TextDecoder("utf8");
let text = "";
let closedByError = false;
while (true) {
try {
const { done, value } = await reader.read();
if (done) {
break;
}
text += decoder.decode(value, { stream: true });
} catch (error) {
closedByError = true;
text += `\n[[reader-error:${error?.name || "unknown"}]]`;
break;
}
}
text += decoder.decode();
return {
status: response.status,
headers: response.headers,
text,
closedByError,
};
}
async function run() {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "codex-retry-gateway-"));
const upstreamPort = await getFreePort();
const gatewayPort = await getFreePort();
const configPath = path.join(tempRoot, "config.json");
const logPath = path.join(tempRoot, "gateway.log");
const config = {
listen_host: "127.0.0.1",
listen_port: gatewayPort,
upstream_base_url: `http://127.0.0.1:${upstreamPort}`,
request_body_limit_bytes: 10 * 1024 * 1024,
endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
reasoning_equals: [516],
non_stream_status_code: 502,
stream_action: "disconnect",
log_match: true,
health_path: "/__codex_retry_gateway/health",
};
await writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
const upstream = await startFakeUpstream(upstreamPort);
const gateway = startGateway(configPath, logPath);
try {
await waitForHealth(`http://127.0.0.1:${gatewayPort}${config.health_path}`);
const modelsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/v1/models`);
assert(modelsResponse.status === 200, `/v1/models 透传状态异常: ${modelsResponse.status}`);
assert(
modelsResponse.headers.get("x-upstream-test") === "models-ok",
"/v1/models 未保留上游头",
);
for (const responsePath of ["/responses", "/v1/responses"]) {
const blockedResponse = await fetch(`http://127.0.0.1:${gatewayPort}${responsePath}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ test_reasoning_tokens: 516 }),
});
const blockedBody = await blockedResponse.json();
assert(blockedResponse.status === 502, `${responsePath} 516 未返回 502: ${blockedResponse.status}`);
assert(
blockedBody?.error?.code === "reasoning_guard_triggered",
`${responsePath} 516 返回体不正确`,
);
const okResponse = await fetch(`http://127.0.0.1:${gatewayPort}${responsePath}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ test_reasoning_tokens: 128 }),
});
const okBody = await okResponse.json();
assert(okResponse.status === 200, `${responsePath} 128 透传状态异常: ${okResponse.status}`);
assert(okResponse.headers.get("x-upstream-test") === "responses-128", `${responsePath} 128 未保留头`);
assert(
okBody?.usage?.output_tokens_details?.reasoning_tokens === 128,
`${responsePath} 128 返回体异常`,
);
}
for (const streamPath of ["/chat/completions", "/v1/chat/completions"]) {
const blockedStream = await readSseUntilClose(
`http://127.0.0.1:${gatewayPort}${streamPath}`,
{ stream: true, test_reasoning_tokens: 516 },
);
assert(blockedStream.status === 200, `${streamPath} 516 首状态异常: ${blockedStream.status}`);
assert(blockedStream.text.includes('"content":"hello"'), `${streamPath} 流式 516 未先透传正常 chunk`);
assert(!blockedStream.text.includes("[DONE]"), `${streamPath} 流式 516 不应完整结束`);
assert(
blockedStream.closedByError || blockedStream.text.includes("[[reader-error:"),
`${streamPath} 流式 516 未表现为中途断开`,
);
const okStream = await readSseUntilClose(
`http://127.0.0.1:${gatewayPort}${streamPath}`,
{ stream: true, test_reasoning_tokens: 128 },
);
assert(okStream.status === 200, `${streamPath} 128 首状态异常: ${okStream.status}`);
assert(okStream.text.includes("[DONE]"), `${streamPath} 流式 128 未完整结束`);
assert(!okStream.closedByError, `${streamPath} 流式 128 不应异常断开`);
}
process.stdout.write("PASS codex-retry-gateway e2e\n");
} finally {
gateway.child.kill();
upstream.close();
await once(upstream, "close");
await rm(tempRoot, { recursive: true, force: true });
}
}
run().catch((error) => {
process.stderr.write(`${error?.stack || error}\n`);
process.exit(1);
});
+9
View File
@@ -0,0 +1,9 @@
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$nodeScript = Join-Path $scriptDir "test-gateway-e2e.mjs"
node $nodeScript
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
+293
View File
@@ -0,0 +1,293 @@
#!/usr/bin/env node
import http from "node:http";
import net from "node:net";
import { once } from "node:events";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
const scriptsRoot = import.meta.dirname;
const installScript = path.join(scriptsRoot, "install-for-current-provider.ps1");
const restoreScript = path.join(scriptsRoot, "restore-codex-config.ps1");
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
async function getFreePort() {
const server = net.createServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
const port = address && typeof address === "object" ? address.port : null;
server.close();
await once(server, "close");
if (!port) {
throw new Error("Failed to allocate a free port");
}
return port;
}
function startFakeUpstream(port) {
const server = http.createServer((req, res) => {
if (req.method === "GET" && req.url === "/v1/models") {
res.writeHead(200, {
"content-type": "application/json; charset=utf-8",
"x-upstream-test": "install-flow-ok",
});
res.end(JSON.stringify({ object: "list", data: [{ id: "install-test-model" }] }));
return;
}
if (req.method === "POST" && (req.url === "/responses" || req.url === "/v1/responses")) {
let body = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
const parsed = JSON.parse(body || "{}");
const reasoning = parsed.test_reasoning_tokens ?? 128;
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
res.end(
JSON.stringify({
id: "install-test-response",
usage: {
output_tokens_details: {
reasoning_tokens: reasoning,
},
},
}),
);
});
return;
}
res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "not found" }));
});
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => resolve(server));
});
}
async function runPowerShellScript(scriptPath, args) {
const child = spawn(
"powershell",
["-ExecutionPolicy", "Bypass", "-File", scriptPath, ...args],
{ stdio: ["ignore", "pipe", "pipe"] },
);
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
const [exitCode] = await once(child, "exit");
if (exitCode !== 0) {
throw new Error(`PowerShell script failed: ${scriptPath}\nstdout:\n${stdout}\nstderr:\n${stderr}`);
}
return { stdout, stderr };
}
async function run() {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "codex-retry-gateway-install-"));
const codexDir = path.join(tempRoot, ".codex");
const stateRoot = path.join(tempRoot, ".codex-retry-gateway");
const codexConfigPath = path.join(codexDir, "config.toml");
const upstreamPort = await getFreePort();
const gatewayPort = await getFreePort();
await mkdir(codexDir, { recursive: true });
await writeFile(
codexConfigPath,
[
'model_provider = "custom"',
"",
"[model_providers.custom]",
'name = "Install Test"',
`base_url = "http://127.0.0.1:${upstreamPort}"`,
'wire_api = "responses"',
"",
].join("\n"),
"utf8",
);
const upstream = await startFakeUpstream(upstreamPort);
try {
await runPowerShellScript(installScript, [
"-CodexConfigPath",
codexConfigPath,
"-StateRoot",
stateRoot,
"-ListenPort",
String(gatewayPort),
]);
const updatedConfig = await readFile(codexConfigPath, "utf8");
assert(
updatedConfig.includes(`base_url = "http://127.0.0.1:${gatewayPort}"`),
"Install script did not redirect base_url to local gateway",
);
const gatewayConfig = JSON.parse(
await readFile(path.join(stateRoot, "config", "config.json"), "utf8"),
);
assert(
gatewayConfig.upstream_base_url === `http://127.0.0.1:${upstreamPort}`,
"Gateway config did not preserve original upstream_base_url",
);
assert(Array.isArray(gatewayConfig.endpoints), "Gateway config endpoints must be an array");
assert(
gatewayConfig.endpoints.includes("/responses") &&
gatewayConfig.endpoints.includes("/chat/completions") &&
gatewayConfig.endpoints.includes("/v1/responses") &&
gatewayConfig.endpoints.includes("/v1/chat/completions"),
"Gateway config endpoints did not include both root and /v1 variants",
);
const proxiedModels = await fetch(`http://127.0.0.1:${gatewayPort}/v1/models`);
assert(proxiedModels.status === 200, `/v1/models through installed gateway failed: ${proxiedModels.status}`);
assert(
proxiedModels.headers.get("x-upstream-test") === "install-flow-ok",
"Installed gateway did not preserve upstream header",
);
const uiResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/ui`);
const uiHtml = await uiResponse.text();
assert(uiResponse.status === 200, `Management UI failed to load: ${uiResponse.status}`);
assert(uiHtml.includes("Codex Retry Gateway"), "Management UI HTML did not include expected title");
assert(uiHtml.includes("516 命中次数"), "Management UI HTML did not include 516 match stats");
assert(uiHtml.includes("516 占比"), "Management UI HTML did not include 516 ratio stats");
assert(uiHtml.includes("实时日志"), "Management UI HTML did not include live log panel");
const statusResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`);
const statusPayload = await statusResponse.json();
assert(statusResponse.status === 200, `Status API failed: ${statusResponse.status}`);
assert(statusPayload.config?.upstream_base_url === `http://127.0.0.1:${upstreamPort}`, "Status API did not expose config");
assert(statusPayload.state?.original_base_url === `http://127.0.0.1:${upstreamPort}`, "Status API did not expose install state");
assert(statusPayload.metrics?.inspected_response_count === 0, "Status API did not expose initial inspected count");
assert(statusPayload.metrics?.reasoning_516_count === 0, "Status API did not expose initial 516 count");
const normalResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ test_reasoning_tokens: 128 }),
});
assert(normalResponse.status === 200, `Expected a passthrough response before 516 test: ${normalResponse.status}`);
const blocked516Response = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ test_reasoning_tokens: 516 }),
});
assert(blocked516Response.status === 502, `Default 516 block did not trigger: ${blocked516Response.status}`);
const metricsStatusResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`);
const metricsStatusPayload = await metricsStatusResponse.json();
assert(metricsStatusResponse.status === 200, `Status API failed after traffic: ${metricsStatusResponse.status}`);
assert(metricsStatusPayload.metrics?.inspected_response_count === 2, "Status API inspected count was not updated");
assert(metricsStatusPayload.metrics?.matched_response_count === 1, "Status API matched count was not updated");
assert(metricsStatusPayload.metrics?.reasoning_516_count === 1, "Status API 516 count was not updated");
assert(metricsStatusPayload.metrics?.reasoning_516_ratio === 0.5, "Status API 516 ratio was not updated");
const logsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/logs`);
const logsPayload = await logsResponse.json();
assert(logsResponse.status === 200, `Logs API failed: ${logsResponse.status}`);
assert(Array.isArray(logsPayload.entries), "Logs API did not return entries array");
assert(
logsPayload.entries.some((entry) => `${entry.message || ""}`.includes("[start]")),
"Logs API did not include gateway start log",
);
assert(
logsPayload.entries.some((entry) => `${entry.message || ""}`.includes("reasoning_tokens=516")),
"Logs API did not include 516 match log",
);
const saveConfigResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/config`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
reasoning_equals: [1024],
endpoints: ["/responses", "/v1/responses"],
non_stream_status_code: 503,
log_match: false,
}),
});
const saveConfigPayload = await saveConfigResponse.json();
assert(saveConfigResponse.status === 200, `Save config API failed: ${saveConfigResponse.status}`);
assert(saveConfigPayload.config?.non_stream_status_code === 503, "Save config API did not return updated config");
const updatedGatewayConfig = JSON.parse(
await readFile(path.join(stateRoot, "config", "config.json"), "utf8"),
);
assert(
JSON.stringify(updatedGatewayConfig.reasoning_equals) === JSON.stringify([1024]),
"Saved config file did not persist reasoning_equals",
);
const incrementalLogsResponse = await fetch(
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/logs?since_seq=${logsPayload.latest_seq}`,
);
const incrementalLogsPayload = await incrementalLogsResponse.json();
assert(incrementalLogsResponse.status === 200, `Incremental logs API failed: ${incrementalLogsResponse.status}`);
assert(
incrementalLogsPayload.entries.some((entry) => `${entry.message || ""}`.includes("[config] updated")),
"Incremental logs API did not include config update log",
);
const blockedAfterSave = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ test_reasoning_tokens: 1024 }),
});
assert(blockedAfterSave.status === 503, `Hot reloaded config did not take effect: ${blockedAfterSave.status}`);
const restoreViaUiResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});
const restoreViaUiPayload = await restoreViaUiResponse.json();
assert(restoreViaUiResponse.status === 202, `Restore API failed: ${restoreViaUiResponse.status}`);
assert(restoreViaUiPayload.ok === true, "Restore API did not acknowledge the restore request");
const restoreStartedAt = Date.now();
while (Date.now() - restoreStartedAt < 10000) {
const restoredCandidate = await readFile(codexConfigPath, "utf8");
if (restoredCandidate.includes(`base_url = "http://127.0.0.1:${upstreamPort}"`)) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
const restoredConfig = await readFile(codexConfigPath, "utf8");
assert(
restoredConfig.includes(`base_url = "http://127.0.0.1:${upstreamPort}"`),
"Restore script did not recover original base_url",
);
process.stdout.write("PASS install-restore flow\n");
} finally {
upstream.close();
await once(upstream, "close");
await rm(tempRoot, { recursive: true, force: true });
}
}
run().catch((error) => {
process.stderr.write(`${error?.stack || error}\n`);
process.exit(1);
});
+9
View File
@@ -0,0 +1,9 @@
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$nodeScript = Join-Path $scriptDir "test-install-restore.mjs"
node $nodeScript
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env node
import http from "node:http";
import net from "node:net";
import { once } from "node:events";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
const scriptsRoot = import.meta.dirname;
const launchScript = path.join(scriptsRoot, "launch-ui.sh");
const restoreScript = path.join(scriptsRoot, "restore-codex-config.sh");
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function toUnixPathForBash(inputPath) {
if (process.platform !== "win32") {
return inputPath;
}
return `/mnt/${inputPath.slice(0, 1).toLowerCase()}${inputPath.slice(2).replace(/\\/g, "/")}`;
}
async function getFreePort() {
const server = net.createServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
const port = address && typeof address === "object" ? address.port : null;
server.close();
await once(server, "close");
if (!port) {
throw new Error("Failed to allocate a free port");
}
return port;
}
function startFakeUpstream(port) {
const server = http.createServer((req, res) => {
if (req.method === "GET" && req.url === "/v1/models") {
res.writeHead(200, {
"content-type": "application/json; charset=utf-8",
"x-upstream-test": "unix-launch-ok",
});
res.end(JSON.stringify({ object: "list", data: [{ id: "unix-launch-model" }] }));
return;
}
if (req.method === "POST" && (req.url === "/responses" || req.url === "/v1/responses")) {
let body = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
const parsed = JSON.parse(body || "{}");
const reasoning = parsed.test_reasoning_tokens ?? 128;
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
res.end(
JSON.stringify({
id: "unix-launch-response",
usage: {
output_tokens_details: {
reasoning_tokens: reasoning,
},
},
}),
);
});
return;
}
res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "not found" }));
});
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => resolve(server));
});
}
async function runBashScript(scriptPath, args) {
const bashScriptPath =
process.platform === "win32"
? path.relative(process.cwd(), scriptPath).split(path.sep).join("/")
: scriptPath;
const bashArgs = [bashScriptPath, ...args];
const child = spawn("bash", bashArgs, {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
const [exitCode] = await once(child, "exit");
if (exitCode !== 0) {
throw new Error(`Bash script failed: ${scriptPath}\nstdout:\n${stdout}\nstderr:\n${stderr}`);
}
return { stdout, stderr };
}
async function run() {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "codex-retry-gateway-unix-"));
const codexDir = path.join(tempRoot, ".codex");
const stateRoot = path.join(tempRoot, ".codex-retry-gateway");
const codexConfigPath = path.join(codexDir, "config.toml");
const upstreamPort = await getFreePort();
const gatewayPort = await getFreePort();
const gatewayBaseUrl = `http://127.0.0.1:${gatewayPort}`;
const upstreamBaseUrl = `http://127.0.0.1:${upstreamPort}`;
await mkdir(codexDir, { recursive: true });
await writeFile(
codexConfigPath,
[
'model_provider = "custom"',
"",
"[model_providers.custom]",
'name = "Unix Launch Test"',
`base_url = "${upstreamBaseUrl}"`,
'wire_api = "responses"',
"",
].join("\n"),
"utf8",
);
const upstream = await startFakeUpstream(upstreamPort);
try {
await runBashScript(launchScript, [
"--codex-config-path",
toUnixPathForBash(codexConfigPath),
"--state-root",
toUnixPathForBash(stateRoot),
"--listen-port",
String(gatewayPort),
"--no-open",
]);
const installedConfig = await readFile(codexConfigPath, "utf8");
assert(
installedConfig.includes(`base_url = "${gatewayBaseUrl}"`),
"Unix launch did not redirect the current provider to the local gateway",
);
const uiResponse = await fetch(`${gatewayBaseUrl}/__codex_retry_gateway/ui`);
assert(uiResponse.status === 200, `Unix UI page was not reachable: ${uiResponse.status}`);
const proxiedModels = await fetch(`${gatewayBaseUrl}/v1/models`);
assert(proxiedModels.status === 200, `/v1/models through unix launch flow failed: ${proxiedModels.status}`);
assert(
proxiedModels.headers.get("x-upstream-test") === "unix-launch-ok",
"Unix launch gateway did not preserve upstream headers",
);
await runBashScript(restoreScript, [
"--codex-config-path",
toUnixPathForBash(codexConfigPath),
"--state-root",
toUnixPathForBash(stateRoot),
]);
const restoredConfig = await readFile(codexConfigPath, "utf8");
assert(
restoredConfig.includes(`base_url = "${upstreamBaseUrl}"`),
"Unix restore did not recover original base_url",
);
process.stdout.write("PASS unix launch-ui flow\n");
} finally {
upstream.close();
await once(upstream, "close");
await rm(tempRoot, { recursive: true, force: true });
}
}
run().catch((error) => {
process.stderr.write(`${error?.stack || error}\n`);
process.exit(1);
});
+9
View File
@@ -0,0 +1,9 @@
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$nodeScript = Join-Path $scriptDir "test-launch-ui-unix.mjs"
node $nodeScript
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env node
import http from "node:http";
import net from "node:net";
import { once } from "node:events";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
const scriptsRoot = import.meta.dirname;
const launchScript = path.join(scriptsRoot, "launch-ui.ps1");
const restoreScript = path.join(scriptsRoot, "restore-codex-config.ps1");
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
async function getFreePort() {
const server = net.createServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
const port = address && typeof address === "object" ? address.port : null;
server.close();
await once(server, "close");
if (!port) {
throw new Error("Failed to allocate a free port");
}
return port;
}
function startFakeUpstream(port) {
const server = http.createServer((req, res) => {
if (req.method === "GET" && req.url === "/v1/models") {
res.writeHead(200, {
"content-type": "application/json; charset=utf-8",
"x-upstream-test": "launch-ui-ok",
});
res.end(JSON.stringify({ object: "list", data: [{ id: "launch-ui-test-model" }] }));
return;
}
if (req.method === "POST" && (req.url === "/responses" || req.url === "/v1/responses")) {
let body = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
const parsed = JSON.parse(body || "{}");
const reasoning = parsed.test_reasoning_tokens ?? 128;
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
res.end(
JSON.stringify({
id: "launch-ui-response",
usage: {
output_tokens_details: {
reasoning_tokens: reasoning,
},
},
}),
);
});
return;
}
res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "not found" }));
});
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => resolve(server));
});
}
async function runPowerShellScript(scriptPath, args) {
const child = spawn(
"powershell",
["-ExecutionPolicy", "Bypass", "-File", scriptPath, ...args],
{ stdio: ["ignore", "pipe", "pipe"] },
);
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
const [exitCode] = await once(child, "exit");
if (exitCode !== 0) {
throw new Error(`PowerShell script failed: ${scriptPath}\nstdout:\n${stdout}\nstderr:\n${stderr}`);
}
return { stdout, stderr };
}
async function run() {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "codex-retry-gateway-launch-"));
const codexDir = path.join(tempRoot, ".codex");
const stateRoot = path.join(tempRoot, ".codex-retry-gateway");
const codexConfigPath = path.join(codexDir, "config.toml");
const upstreamPort = await getFreePort();
const gatewayPort = await getFreePort();
const gatewayBaseUrl = `http://127.0.0.1:${gatewayPort}`;
const upstreamBaseUrl = `http://127.0.0.1:${upstreamPort}`;
await mkdir(codexDir, { recursive: true });
await writeFile(
codexConfigPath,
[
'model_provider = "custom"',
"",
"[model_providers.custom]",
'name = "Launch UI Test"',
`base_url = "${upstreamBaseUrl}"`,
'wire_api = "responses"',
"",
].join("\n"),
"utf8",
);
const upstream = await startFakeUpstream(upstreamPort);
try {
await runPowerShellScript(launchScript, [
"-CodexConfigPath",
codexConfigPath,
"-StateRoot",
stateRoot,
"-ListenPort",
String(gatewayPort),
"-NoOpen",
]);
const installedConfig = await readFile(codexConfigPath, "utf8");
assert(
installedConfig.includes(`base_url = "${gatewayBaseUrl}"`),
"First launch did not redirect the current provider to the local gateway",
);
const uiResponse = await fetch(`${gatewayBaseUrl}/__codex_retry_gateway/ui`);
assert(uiResponse.status === 200, `UI page was not reachable after first launch: ${uiResponse.status}`);
const statusResponse = await fetch(`${gatewayBaseUrl}/__codex_retry_gateway/api/status`);
const statusPayload = await statusResponse.json();
assert(statusResponse.status === 200, `Status API failed after first launch: ${statusResponse.status}`);
assert(
statusPayload.state?.original_base_url === upstreamBaseUrl,
"First launch did not persist the original upstream base URL",
);
const firstStateRaw = await readFile(path.join(stateRoot, "state.json"), "utf8");
const firstState = JSON.parse(firstStateRaw);
await runPowerShellScript(launchScript, [
"-CodexConfigPath",
codexConfigPath,
"-StateRoot",
stateRoot,
"-ListenPort",
String(gatewayPort),
"-NoOpen",
]);
const secondStateRaw = await readFile(path.join(stateRoot, "state.json"), "utf8");
const secondState = JSON.parse(secondStateRaw);
assert(
secondState.original_base_url === firstState.original_base_url,
"Second launch overwrote original_base_url unexpectedly",
);
assert(
secondState.gateway_base_url === gatewayBaseUrl,
"Second launch did not preserve gateway_base_url",
);
const proxiedModels = await fetch(`${gatewayBaseUrl}/v1/models`);
assert(proxiedModels.status === 200, `/v1/models through launch UI flow failed: ${proxiedModels.status}`);
assert(
proxiedModels.headers.get("x-upstream-test") === "launch-ui-ok",
"Gateway did not preserve upstream headers after second launch",
);
const blockedResponse = await fetch(`${gatewayBaseUrl}/responses`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ test_reasoning_tokens: 516 }),
});
assert(blockedResponse.status === 502, `Default 516 interception was not active: ${blockedResponse.status}`);
process.stdout.write("PASS launch-ui flow\n");
} finally {
try {
await runPowerShellScript(restoreScript, [
"-CodexConfigPath",
codexConfigPath,
"-StateRoot",
stateRoot,
]);
} catch {
// 测试清理阶段允许忽略恢复失败,避免覆盖主失败原因。
}
upstream.close();
await once(upstream, "close");
await rm(tempRoot, { recursive: true, force: true });
}
}
run().catch((error) => {
process.stderr.write(`${error?.stack || error}\n`);
process.exit(1);
});
+9
View File
@@ -0,0 +1,9 @@
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$nodeScript = Join-Path $scriptDir "test-launch-ui.mjs"
node $nodeScript
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}