22 Commits

Author SHA1 Message Date
shujakuin 14fae29618 fix: normalize wildcard gateway URLs for Codex 2026-07-20 10:01:19 +08:00
shujakuin 31e673f777 feat: add SSE passthrough stream action 2026-07-20 00:40:14 +08:00
shujakuin ca8935d73e docs: clarify gateway rollout verification 2026-07-17 22:47:00 +08:00
shujakuin 9e27778e05 feat: add verified profile import and export 2026-07-11 19:31:23 +08:00
shujakuin 066e2d9b61 docs: use gpt-5.6-terra for Codex smoke tests 2026-07-11 13:49:37 +08:00
shujakuin bf01fcd727 fix: retry Codex overload error codes 2026-07-11 13:44:12 +08:00
shujakuin 29eb91e0c3 fix: pace captured SSE replay 2026-07-11 13:16:40 +08:00
shujakuin 7d0a40e1ea fix: preserve responses SSE lifecycle during replay 2026-07-11 02:36:00 +08:00
shujakuin 92d3845eca fix: exclude management paths from request history 2026-07-10 07:38:13 +08:00
shujakuin f62c4d2fed fix: migrate legacy runtime image configuration 2026-07-10 07:09:35 +08:00
shujakuin 0d244e596c feat: separate image profiles from text routing 2026-07-10 07:05:22 +08:00
shujakuin 2ff38c71bb docs: add rollout testing guidance 2026-07-09 06:20:03 +08:00
shujakuin 73811daaac Normalize responses SSE events for Codex 2026-07-09 05:51:09 +08:00
shujakuin 26070fb7d5 fix: replay buffered success streams incrementally 2026-07-08 21:27:39 +08:00
shujakuin f9a71663f6 docs: add infra app metadata 2026-07-08 16:03:23 +08:00
shujakuin 5d48ef5880 fix: parse escaped retryable error messages 2026-07-08 10:08:25 +08:00
shujakuin a62f2cf1b9 feat: add retry wave visibility controls 2026-07-07 20:28:24 +08:00
shujakuin 446a0e99a8 fix: raise request body limit to 1GB 2026-07-01 16:26:59 +08:00
shujakuin c8cc3b153b feat: support access-key protected admin 2026-06-30 21:06:20 +08:00
shujakuin d90a1cd94f feat: gate admin ui with optional access key 2026-06-30 19:46:29 +08:00
shujakuin 41357f5e7a Update README.md 2026-06-30 17:43:41 +08:00
shujakuin 5e237993dd fix: broaden thread id capture 2026-06-30 17:00:33 +08:00
13 changed files with 7216 additions and 589 deletions
+132 -8
View File
@@ -1,6 +1,18 @@
# Codex Retry Gateway # Codex Retry Gateway
tg群:https://t.me/AI_INPUT_IM - 文档层级:app 项目真源
- 应用性质:自有 infra 工具
- 源码来源:git submodule
- 运行关系:多实例服务
- 部署模型:managed_systemd_apps
- 对应服务:`services/pc/systemd-user/codex-retry-gateway/current/README.md``services/a100/systemd-user/codex-retry-gateway/current/README.md`
- 对应 stack:无
- Secret 边界:本地用户配置
- 数据来源:本地 Codex provider 配置、gateway profile state 与上游 OpenAI-compatible API 响应。
- 输出边界:仅返回代理后的 Codex API 响应和本地健康/状态信息。
- 写入边界:只写本地用户态 gateway 状态目录,以及当前 Codex provider 的 `base_url` 与其可恢复备份。
- 本地状态路径:`~/.codex-retry-gateway/`
- 备注:token 与 provider secret 只保留在目标用户配置或 secret 文件中,不进入 repo。
一个不依赖 `cc-switch` 路由模式的独立本地网关。 一个不依赖 `cc-switch` 路由模式的独立本地网关。
@@ -9,9 +21,11 @@ tg群:https://t.me/AI_INPUT_IM
- 保持 Codex 继续使用现有 `auth.json` - 保持 Codex 继续使用现有 `auth.json`
- 只把 `config.toml` 的当前 provider `base_url` 改成本地网关 - 只把 `config.toml` 的当前 provider `base_url` 改成本地网关
- 非流式命中 `reasoning_tokens = 516` 时返回 `502` - 非流式命中 `reasoning_tokens = 516` 时返回 `502`
- 上游若返回明确的容量错误(默认匹配错误文案 `Selected model is at capacity. Please try a different model.`,以及 `stream disconnected before completion: Concurrency limit exceeded for account, please retry later`),也会自动重试;重试耗尽后转成本地 `502` - 上游若返回明确的容量错误(默认匹配错误文案 `Selected model is at capacity. Please try a different model.``stream disconnected before completion: Concurrency limit exceeded for account, please retry later`,以及 Responses 错误码 `server_is_overloaded` / `slow_down`),也会自动重试;重试耗尽后转成本地 `502`
- 除了 `429/503` JSON 错误响应,也会识别 `200` 但返回体本质是错误、以及流式失败事件里携带同样文案的情况 - 除了 `429/503` JSON 错误响应,也会识别 `200` 但返回体本质是错误、以及流式失败事件里携带同样文案的情况
- 流式命中时默认先缓存并判断;一旦命中 `516`,统一返回 `502` - 流式命中时默认先缓存并判断;一旦命中 `516`,统一返回 `502`
- 可选 `passthrough` 直通模式会对 `stream: true` 请求跳过 reasoning retry;若上游实际返回 `text/event-stream`,则把原始 SSE chunk 直接转发给客户端,不解析、缓存、规范化或拦截
- 流式成功响应在严格检查模式下仍会先缓存完成;成功后会按真实且规范化的 Responses 生命周期与输出顺序逐块回放给 Codex,每个 SSE 块之间至少间隔 5ms,并关闭 TCP 小包聚合,避免大量 delta 在同一事件循环突发到达;不会伪造缺少 response ID 的生命周期事件
- 默认同时拦截 root 路径和 `/v1` 路径: - 默认同时拦截 root 路径和 `/v1` 路径:
- `/responses` - `/responses`
- `/chat/completions` - `/chat/completions`
@@ -39,6 +53,8 @@ macOS / Linux:
- 这是一个可独立发布、独立运行的仓库 - 这是一个可独立发布、独立运行的仓库
- 默认监听地址是 `http://127.0.0.1:4610` - 默认监听地址是 `http://127.0.0.1:4610`
- Profile 可以把服务绑定到 `0.0.0.0`;这只影响服务监听。Codex `config.toml`、同机 health 探测和本地 UI 链接仍会使用 `http://127.0.0.1:<listen-port>`,不会把不可作为客户端目标的 `0.0.0.0` 写入 provider `base_url`
- 从管理页保存当前正在运行的文本 Profile 时,会只同步当前 `model_provider``base_url`;首次需要改写时保留/创建 `config.toml` 备份,并保留已知的原始上游地址。
- 默认示例上游见 `config.example.json` - 默认示例上游见 `config.example.json`
- 实际运行时配置会写到当前用户目录下的 gateway 状态目录 - 实际运行时配置会写到当前用户目录下的 gateway 状态目录
@@ -129,12 +145,20 @@ bash ./scripts/install-for-current-provider.sh
在 Linux 上需要常驻运行时,推荐用 `scripts/run-profile.mjs` 作为 systemd 的 ExecStart。它会读取 profile env,生成运行时 `config.json`,并把当前 Codex provider 的 `base_url` 指向本机 gateway。 在 Linux 上需要常驻运行时,推荐用 `scripts/run-profile.mjs` 作为 systemd 的 ExecStart。它会读取 profile env,生成运行时 `config.json`,并把当前 Codex provider 的 `base_url` 指向本机 gateway。
profile env 默认放在: 文本 profile env 默认放在:
```text ```text
~/.config/codex-retry-gateway/profiles/<profile>.env ~/.config/codex-retry-gateway/profiles/<profile>.env
``` ```
图片 profile env 独立放在:
```text
~/.config/codex-retry-gateway/image-profiles/<profile>.env
```
当前文本 profile 与当前图片 profile 分别由 state 中的 `profile_name``image_profile_name` 记录;切换其中任意一方不会改写另一方。首次升级时,当前文本 profile 中已有的 `CODEX_RETRY_GATEWAY_IMAGE_*` 字段会复制到同名图片 profile,旧文本文件会保留作为兼容回退,但后续 UI/TUI 保存文本 profile 不再写入图片字段。
常用字段: 常用字段:
- `CODEX_RETRY_GATEWAY_LISTEN_HOST` - `CODEX_RETRY_GATEWAY_LISTEN_HOST`
@@ -157,18 +181,96 @@ profile env 默认放在:
- `fixed_bearer`:从环境变量或文件读取 token,并覆盖上游 `Authorization: Bearer ...` - `fixed_bearer`:从环境变量或文件读取 token,并覆盖上游 `Authorization: Bearer ...`
- `auth_json`:从 Codex `auth.json` 的指定 key 读取 token,并覆盖上游 `Authorization: Bearer ...` - `auth_json`:从 Codex `auth.json` 的指定 key 读取 token,并覆盖上游 `Authorization: Bearer ...`
图片 profile 常用字段:
- `CODEX_RETRY_GATEWAY_IMAGE_BASE_URL`
- `CODEX_RETRY_GATEWAY_IMAGE_AUTH_MODE`
- `CODEX_RETRY_GATEWAY_IMAGE_AUTH_ENV`
- `CODEX_RETRY_GATEWAY_IMAGE_AUTH_FILE`
- `CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_PATH`
- `CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_KEY`
- 配置 `CODEX_RETRY_GATEWAY_IMAGE_BASE_URL` 后,`/images/*``/v1/images/*` 会改用该地址;root 图片路径会在上游规范化为 `/v1/images/*`,其他请求仍使用 `CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL`
- 图片认证字段与普通上游同义,使用 `CODEX_RETRY_GATEWAY_IMAGE_AUTH_*`;默认 `fixed_bearer``CODEX_RETRY_GATEWAY_IMAGE_API_KEY` 读取 key。
- 不要把 key 直接写进 profile env;优先通过 `IMAGE_AUTH_FILE` 或管理页的 `manual_bearer` 写入用户级受限 secret 文件。
示例: 示例:
```bash ```bash
node ./scripts/run-profile.mjs default node ./scripts/run-profile.mjs default
``` ```
指定图片 profile
```bash
node ./scripts/run-profile.mjs default --image-profile images
```
如果已经由 state 记录了当前活跃 profile,也可以直接不传 profile 参数: 如果已经由 state 记录了当前活跃 profile,也可以直接不传 profile 参数:
```bash ```bash
node ./scripts/run-profile.mjs --prefer-state-profile node ./scripts/run-profile.mjs --prefer-state-profile
``` ```
## 测试与发布
这个服务会改写目标用户的 Codex `base_url`,属于当前操作链路上的高风险本地网关。后续变更默认按“先对侧宿主、再当前宿主”发布。
`pc``a100` 的模型都显式设置了 `systemd_unit_manage_state: false`。因此下面的主干 playbook 只负责同步源码、安装 npm 依赖、构建 UI 和渲染 unit,不会自动 restart 或收敛 gateway 的运行状态;playbook 成功不代表新代码已经由当前进程加载。每台宿主完成同步和构建后,必须由操作者在目标服务用户的 user systemd 中显式重启 `codex-retry-gateway.service`,再开始验证。
推荐顺序:
1. 先部署 `a100` 上的 `codex-retry-gateway`,然后以 `zhouyunyao` 用户显式重启 `codex-retry-gateway.service`
2. 确认重启后的 unit 为 `active`,再检查 health 和管理 API,避免对仍在运行的旧进程做出错误判断。
3. 在对侧机器的目标用户环境里发起真实 Codex 冒烟请求,不要先改当前正在依赖本机 `4610` 的会话。
4. 确认健康检查、管理 API、响应完整性与关键重试行为正常。
5. 通过后再以相同顺序部署 `pc`:同步和构建、以 `shujakuin` 用户显式重启、验证 unit 与 API、最后做真实 Codex 冒烟。
推荐部署命令:
```bash
uv run ansible-playbook playbooks/deploy_managed_systemd_apps.yml -i inventory/hosts.yml --limit a100 -e managed_systemd_apps_selected_names=codex-retry-gateway
```
同步和构建完成后,在 `a100``zhouyunyao` 用户会话中执行:
```bash
systemctl --user restart codex-retry-gateway.service
systemctl --user is-active codex-retry-gateway.service
```
验证通过后,再切到本机:
```bash
uv run ansible-playbook playbooks/deploy_managed_systemd_apps.yml -i inventory/hosts.yml --limit pc -e managed_systemd_apps_selected_names=codex-retry-gateway
```
随后在 `pc``shujakuin` 用户会话中执行同样的受控重启和状态检查:
```bash
systemctl --user restart codex-retry-gateway.service
systemctl --user is-active codex-retry-gateway.service
```
真实 Codex 网关冒烟固定显式使用 `gpt-5.6-terra`,不再使用 `gpt-5.4`
```bash
codex --dangerously-bypass-approvals-and-sandbox -c model="gpt-5.6-terra" hello
```
建议同时检查:
- `curl http://<listen-host>:4610/__codex_retry_gateway/health`
- `/__codex_retry_gateway/api/profiles` 等本次变更涉及的管理 API 能正常返回,且当前文本 / 图片 profile 没有被意外切换
- `/responses` 成功流能完整结束,不会卡 pending;`response.created` 必须带真实 response ID,随后依次可见 `response.in_progress`、输出 delta 与 `response.completed`
- 容量错误 `Selected model is at capacity. Please try a different model.` 仍能按网关策略自动重试
通过标准是重启后的 unit 保持 `active`、health 和相关管理 API 正常、真实 Codex 请求完整结束且关键重试行为未回归。任一检查失败都停止后续滚动,不要继续处理 `pc`;应先恢复上一版源码或修复问题,并重新从 `a100` 试点。
R2 `--check --diff` 不安装依赖、不构建 UI、也不重启 unit,只验证目标宿主当前已有源码、依赖、构建产物和运行前置是否满足模型声明。它不能替代上述发布后的运行验证,也不能证明新提交已经被目标进程加载。
如果当前对话依赖本机 `4610`,不要把本机作为首个发布目标;先在另一台已接管相同 profile 的机器验证,再回到本机切换,且 `pc` 必须是最后一个处理的宿主。
## 如何恢复 ## 如何恢复
Windows: Windows:
@@ -219,10 +321,11 @@ gateway 运行时只负责 API 与静态文件服务,不再把复杂 UI 硬写
- 请求时间戳、首字耗时、总耗时、请求体大小、路径、模型、状态码 - 请求时间戳、首字耗时、总耗时、请求体大小、路径、模型、状态码
- 相同重发请求会带相同的 `request_id` - 相同重发请求会带相同的 `request_id`
- `usage` 中的 input / output / total / reasoning tokens - `usage` 中的 input / output / total / reasoning tokens
- 管理 profiles - 分别管理文本 profiles 与图片 profiles
- 新建 / 编辑 profile env - 各自新建 / 编辑 / 探测 / 切换 / 删除 profile env
- 切换 provider `base_url` - 各自导出 / 导入可移植 profile JSON;导出前必须再次验证该 profile 当前实际使用的 API key
- 切换 `passthrough` / `manual_bearer` / `fixed_bearer` / `auth_json` 认证模式 - 文本切换 provider `base_url` 不会改写图片分流,图片切换只影响 `/images/*` `/v1/images/*`
- 各自切换 `passthrough` / `manual_bearer` / `fixed_bearer` / `auth_json` 认证模式
-`reasoning_equals` -`reasoning_equals`
- 改 capacity error 的匹配状态码和错误文案 - 改 capacity error 的匹配状态码和错误文案
-`endpoints` -`endpoints`
@@ -242,9 +345,25 @@ gateway 运行时只负责 API 与静态文件服务,不再把复杂 UI 硬写
- 请求历史只记录元数据、请求体字节数和 token usage,不保存请求正文或响应正文;默认展示最近 200 条 - 请求历史只记录元数据、请求体字节数和 token usage,不保存请求正文或响应正文;默认展示最近 200 条
- gateway 日志持久化到 `~/.codex-retry-gateway/logs/gateway.log` - gateway 日志持久化到 `~/.codex-retry-gateway/logs/gateway.log`
- 请求记录持久化到 `~/.codex-retry-gateway/logs/requests.jsonl``~/.codex-retry-gateway/logs/requests.sqlite`,重启后会用于 UI 请求页以及 overview 的累计 token / reasoning 统计 - 请求记录持久化到 `~/.codex-retry-gateway/logs/requests.jsonl``~/.codex-retry-gateway/logs/requests.sqlite`,重启后会用于 UI 请求页以及 overview 的累计 token / reasoning 统计
- `manual_bearer` 的手动 token/password 只写入系统 secret 文件,API/UI 不读回明文;profile env 只保存 secret 文件路径 - `manual_bearer` 的手动 token/password 只写入系统 secret 文件,普通 profile API/UI 不读回明文;只有再次验证当前 key 的显式导出接口会把它放进下载文件,profile env 只保存 secret 文件路径
- 其他 profile env 不保存明文 `sk-...`;固定密钥请使用 env/file 引用,或用 `auth_json` 指向 `auth.json` 字段名 - 其他 profile env 不保存明文 `sk-...`;固定密钥请使用 env/file 引用,或用 `auth_json` 指向 `auth.json` 字段名
### Profile 导入与导出
文本与图片 profile 都支持从 Profiles 页面导出和导入:
- 文本接口:`POST /__codex_retry_gateway/api/profiles/export``POST /__codex_retry_gateway/api/profiles/import`
- 图片接口:`POST /__codex_retry_gateway/api/image-profiles/export``POST /__codex_retry_gateway/api/image-profiles/import`
- 导出前必须再次提交该 profile 当前实际使用的 API keygateway 会从现有 `manual_bearer` secret 文件、`fixed_bearer` env/file 或 `auth_json` 解析当前 key,并做常量时间比对
- `passthrough` 没有 profile-owned key,无法完成再次验证,因此拒绝导出
- 验证成功后才返回 `codex-retry-gateway-profile` v1 JSON;文件包含明文 API key,文本 profile 若配置了管理 Access key 也会一并保留
- 导出响应使用 `Cache-Control: no-store`,服务端不生成或保留导出副本,日志只记录 profile 名称和认证来源,不记录 key
- 导入只接受该格式的 JSON,并把包内 key 写入目标用户的默认 secret 文件,目录权限为 `0700`、文件权限为 `0600`profile env 仍只保存 secret 文件路径
- 为避免当前控制链路被意外改写,导入不会自动切换 profile,也不允许直接覆盖当前活跃 profile;同名非活跃 profile 需要显式确认覆盖
- 导入后的认证统一落为可移植的 `manual_bearer`;先用 profile 探针验证,再手动热切换
导出文件本身就是明文 secret 载体,不应提交到 Git、上传到普通日志或长期放在共享下载目录。
## 如何调整拦截条件 ## 如何调整拦截条件
编辑: 编辑:
@@ -272,6 +391,7 @@ macOS / Linux: ~/.codex-retry-gateway/config/config.json
- 默认 `strict_502` - 默认 `strict_502`
- `strict_502`:先缓存整个流,命中 `516` 时统一返回 `502` - `strict_502`:先缓存整个流,命中 `516` 时统一返回 `502`
- `disconnect`:兼容旧行为;若命中发生在已透传 chunk 之后,则直接断开连接 - `disconnect`:兼容旧行为;若命中发生在已透传 chunk 之后,则直接断开连接
- `passthrough`:对 `stream: true` 请求关闭 reasoning retry;只有上游实际返回 `text/event-stream` 时原样转发 SSE chunk,非 SSE 响应继续按单次既有规则处理
- `log_match` - `log_match`
- 是否记录命中日志 - 是否记录命中日志
@@ -318,6 +438,10 @@ macOS / Linux: ~/.codex-retry-gateway
- `test-gateway-e2e.ps1` - `test-gateway-e2e.ps1`
- 已通过 - 已通过
- 验证 `/responses``/chat/completions``/v1/responses``/v1/chat/completions` - 验证 `/responses``/chat/completions``/v1/responses``/v1/chat/completions`
- `node scripts/test-gateway-e2e.mjs`
- 已通过
- 验证 strict capture 下 `/responses` 生命周期不注入空事件,长流 delta 按序完整回放并以 `response.completed` 结束
- 验证文本 / 图片 profile 导出必须重新校验 key,错误 key 不泄露 secret;导入后 key 只进入 `0600` secret 文件,不进入 profile env 或普通 API 响应
- `test-install-restore.ps1` - `test-install-restore.ps1`
- 已通过 - 已通过
- 验证安装、透传、UI 页面、热更新配置、实时日志、516 统计、恢复闭环 - 验证安装、透传、UI 页面、热更新配置、实时日志、516 统计、恢复闭环
+11 -2
View File
@@ -1,5 +1,6 @@
{ {
"profile_name": "default", "profile_name": "default",
"image_profile_name": "",
"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",
@@ -8,15 +9,23 @@
"upstream_auth_file": "", "upstream_auth_file": "",
"upstream_auth_json_path": "", "upstream_auth_json_path": "",
"upstream_auth_json_key": "OPENAI_API_KEY", "upstream_auth_json_key": "OPENAI_API_KEY",
"request_body_limit_bytes": 10485760, "image_base_url": "",
"image_auth_mode": "fixed_bearer",
"image_auth_env": "CODEX_RETRY_GATEWAY_IMAGE_API_KEY",
"image_auth_file": "",
"image_auth_json_path": "",
"image_auth_json_key": "OPENAI_API_KEY",
"request_body_limit_bytes": 1073741824,
"request_history_limit": 200, "request_history_limit": 200,
"endpoints": ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], "endpoints": ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
"reasoning_equals": [516], "reasoning_match_mode": "formula_518n_minus_2",
"reasoning_equals": [516, 1034, 1552],
"retryable_status_codes": [429, 503], "retryable_status_codes": [429, 503],
"retryable_error_messages": [ "retryable_error_messages": [
"Selected model is at capacity. Please try a different model.", "Selected model is at capacity. Please try a different model.",
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later" "stream disconnected before completion: Concurrency limit exceeded for account, please retry later"
], ],
"management_access_key": "",
"non_stream_status_code": 502, "non_stream_status_code": 502,
"stream_action": "strict_502", "stream_action": "strict_502",
"log_match": true, "log_match": true,
+3560 -311
View File
File diff suppressed because it is too large Load Diff
+50 -9
View File
@@ -7,10 +7,21 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
export const DEFAULT_STATE_ROOT = path.join(os.homedir(), ".codex-retry-gateway"); export const DEFAULT_STATE_ROOT = path.join(os.homedir(), ".codex-retry-gateway");
const DEFAULT_REQUEST_BODY_LIMIT_BYTES = 1024 * 1024 * 1024;
const LEGACY_DEFAULT_REQUEST_BODY_LIMIT_BYTES = 10 * 1024 * 1024;
export const DEFAULT_CODEX_CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml"); 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_HOST = "127.0.0.1";
export const DEFAULT_LISTEN_PORT = 4610; export const DEFAULT_LISTEN_PORT = 4610;
export const DEFAULT_HEALTH_PATH = "/__codex_retry_gateway/health"; export const DEFAULT_HEALTH_PATH = "/__codex_retry_gateway/health";
export const DEFAULT_REASONING_MATCH_MODE = "formula_518n_minus_2";
export const DEFAULT_REASONING_EQUALS = [516, 1034, 1552];
export function expandEscapedLineBreaks(value) {
return `${value ?? ""}`
.replace(/\\r\\n/g, "\n")
.replace(/\\n/g, "\n")
.replace(/\\r/g, "\n");
}
function escapeRegExp(value) { function escapeRegExp(value) {
return `${value}`.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return `${value}`.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -62,10 +73,24 @@ export function getGatewayStatePaths(stateRoot = DEFAULT_STATE_ROOT) {
}; };
} }
export function getGatewayBaseUrl(listenHost, listenPort) { export function getGatewayListenerBaseUrl(listenHost, listenPort) {
return `http://${listenHost}:${listenPort}`; return `http://${listenHost}:${listenPort}`;
} }
export function getGatewayBaseUrl(listenHost, listenPort) {
// 0.0.0.0 is a valid bind address, but not a client destination. Keep the
// listener wildcard intact and use loopback for Codex, health probes, and UI
// links that originate on the same machine.
const clientHost = `${listenHost ?? ""}`.trim() === "0.0.0.0" ? "127.0.0.1" : listenHost;
return getGatewayListenerBaseUrl(clientHost, listenPort);
}
export function isGatewayBaseUrlForListener(baseUrl, listenHost, listenPort) {
const candidate = `${baseUrl ?? ""}`.trim();
return candidate === getGatewayBaseUrl(listenHost, listenPort)
|| candidate === getGatewayListenerBaseUrl(listenHost, listenPort);
}
export function getGatewayBaseUrlFromConfig(gatewayConfig) { export function getGatewayBaseUrlFromConfig(gatewayConfig) {
if (!gatewayConfig) { if (!gatewayConfig) {
return null; return null;
@@ -165,7 +190,7 @@ export async function setCodexProviderBaseUrl({ codexConfigPath, providerName, n
await writeUtf8File(codexConfigPath, updatedContent); await writeUtf8File(codexConfigPath, updatedContent);
} }
export function normalizeIntArray(values, fallback = [516]) { export function normalizeIntArray(values, fallback = DEFAULT_REASONING_EQUALS) {
const source = values === undefined || values === null ? fallback : values; const source = values === undefined || values === null ? fallback : values;
const queue = Array.isArray(source) ? source.flat(Infinity) : [source]; const queue = Array.isArray(source) ? source.flat(Infinity) : [source];
const normalized = queue const normalized = queue
@@ -188,11 +213,19 @@ export function normalizeStringArray(values, fallback = []) {
return normalized.length > 0 ? [...new Set(normalized)] : [...fallback]; return normalized.length > 0 ? [...new Set(normalized)] : [...fallback];
} }
export function normalizeReasoningMatchMode(value) {
const mode = `${value || DEFAULT_REASONING_MATCH_MODE}`.trim().toLowerCase();
if (["formula_518n_minus_2", "manual"].includes(mode)) {
return mode;
}
return DEFAULT_REASONING_MATCH_MODE;
}
export function normalizePhraseArray(values, fallback = []) { export function normalizePhraseArray(values, fallback = []) {
const source = values === undefined || values === null ? fallback : values; const source = values === undefined || values === null ? fallback : values;
const queue = Array.isArray(source) ? source.flat(Infinity) : [source]; const queue = Array.isArray(source) ? source.flat(Infinity) : [source];
const normalized = queue const normalized = queue
.flatMap((value) => (typeof value === "string" ? value.split(/\r?\n/) : [value])) .flatMap((value) => (typeof value === "string" ? expandEscapedLineBreaks(value).split(/\r?\n/) : [value]))
.map((value) => `${value ?? ""}`.trim()) .map((value) => `${value ?? ""}`.trim())
.filter(Boolean); .filter(Boolean);
@@ -389,14 +422,14 @@ export async function installForCurrentProvider({
const existingState = await readJsonFile(paths.statePath); const existingState = await readJsonFile(paths.statePath);
let originalBaseUrl = providerContext.currentBaseUrl; let originalBaseUrl = providerContext.currentBaseUrl;
if (providerContext.currentBaseUrl === localGatewayBaseUrl) { if (isGatewayBaseUrlForListener(providerContext.currentBaseUrl, listenHost, listenPort)) {
if (!existingState?.original_base_url) { if (!existingState?.original_base_url) {
throw new Error("Provider already points to the local gateway, but original_base_url is missing from state."); throw new Error("Provider already points to the local gateway, but original_base_url is missing from state.");
} }
originalBaseUrl = `${existingState.original_base_url}`; originalBaseUrl = `${existingState.original_base_url}`;
} }
if (originalBaseUrl === localGatewayBaseUrl) { if (isGatewayBaseUrlForListener(originalBaseUrl, listenHost, listenPort)) {
throw new Error("A real upstream_base_url could not be determined."); throw new Error("A real upstream_base_url could not be determined.");
} }
@@ -421,10 +454,15 @@ export async function installForCurrentProvider({
upstream_base_url: originalBaseUrl, upstream_base_url: originalBaseUrl,
request_body_limit_bytes: request_body_limit_bytes:
existingGatewayConfig?.request_body_limit_bytes === undefined || existingGatewayConfig?.request_body_limit_bytes === null existingGatewayConfig?.request_body_limit_bytes === undefined || existingGatewayConfig?.request_body_limit_bytes === null
? 10485760 ? DEFAULT_REQUEST_BODY_LIMIT_BYTES
: Number.parseInt(`${existingGatewayConfig.request_body_limit_bytes}`, 10), : (
Number.parseInt(`${existingGatewayConfig.request_body_limit_bytes}`, 10) === LEGACY_DEFAULT_REQUEST_BODY_LIMIT_BYTES
? DEFAULT_REQUEST_BODY_LIMIT_BYTES
: Number.parseInt(`${existingGatewayConfig.request_body_limit_bytes}`, 10)
),
endpoints: mergedEndpoints, endpoints: mergedEndpoints,
reasoning_equals: normalizeIntArray(existingGatewayConfig?.reasoning_equals, [516]), reasoning_match_mode: normalizeReasoningMatchMode(existingGatewayConfig?.reasoning_match_mode),
reasoning_equals: normalizeIntArray(existingGatewayConfig?.reasoning_equals, DEFAULT_REASONING_EQUALS),
retryable_status_codes: normalizeIntArray(existingGatewayConfig?.retryable_status_codes, [429, 503]), retryable_status_codes: normalizeIntArray(existingGatewayConfig?.retryable_status_codes, [429, 503]),
retryable_error_messages: normalizePhraseArray(existingGatewayConfig?.retryable_error_messages, [ retryable_error_messages: normalizePhraseArray(existingGatewayConfig?.retryable_error_messages, [
"Selected model is at capacity. Please try a different model.", "Selected model is at capacity. Please try a different model.",
@@ -533,7 +571,10 @@ export async function launchUi({
const existingGatewayConfig = await readJsonFile(paths.configPath); const existingGatewayConfig = await readJsonFile(paths.configPath);
const stateGatewayBaseUrl = existingState?.gateway_base_url ? `${existingState.gateway_base_url}` : null; const stateGatewayBaseUrl = existingState?.gateway_base_url ? `${existingState.gateway_base_url}` : null;
const configGatewayBaseUrl = getGatewayBaseUrlFromConfig(existingGatewayConfig); const configGatewayBaseUrl = getGatewayBaseUrlFromConfig(existingGatewayConfig);
const managedGatewayBaseUrls = [requestedGatewayBaseUrl]; const managedGatewayBaseUrls = [
requestedGatewayBaseUrl,
getGatewayListenerBaseUrl(listenHost, listenPort),
];
for (const candidate of [stateGatewayBaseUrl, configGatewayBaseUrl]) { for (const candidate of [stateGatewayBaseUrl, configGatewayBaseUrl]) {
if (candidate && !managedGatewayBaseUrls.includes(candidate)) { if (candidate && !managedGatewayBaseUrls.includes(candidate)) {
managedGatewayBaseUrls.push(candidate); managedGatewayBaseUrls.push(candidate);
+1 -1
View File
@@ -221,7 +221,7 @@ function Wait-GatewayHealth {
function Normalize-IntArray { function Normalize-IntArray {
param( param(
$Values, $Values,
[int[]]$Default = @(516) [int[]]$Default = @(516, 1034, 1552)
) )
if ($null -eq $Values) { if ($null -eq $Values) {
+9 -2
View File
@@ -58,9 +58,16 @@ $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) {
if ([int]$existingGatewayConfig.request_body_limit_bytes -eq 10485760) { 1073741824 } else { [int]$existingGatewayConfig.request_body_limit_bytes }
} else { 1073741824 }
endpoints = @($mergedEndpoints) endpoints = @($mergedEndpoints)
reasoning_equals = Normalize-IntArray -Values $(if ($existingGatewayConfig) { $existingGatewayConfig.reasoning_equals } else { $null }) -Default @(516) reasoning_match_mode = if (
$existingGatewayConfig -and
$existingGatewayConfig.reasoning_match_mode -and
@("formula_518n_minus_2", "manual") -contains ([string]$existingGatewayConfig.reasoning_match_mode)
) { [string]$existingGatewayConfig.reasoning_match_mode } else { "formula_518n_minus_2" }
reasoning_equals = Normalize-IntArray -Values $(if ($existingGatewayConfig) { $existingGatewayConfig.reasoning_equals } else { $null }) -Default @(516, 1034, 1552)
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 { "strict_502" } stream_action = if ($existingGatewayConfig -and -not [string]::IsNullOrWhiteSpace([string]$existingGatewayConfig.stream_action)) { [string]$existingGatewayConfig.stream_action } else { "strict_502" }
log_match = if ($existingGatewayConfig -and $null -ne $existingGatewayConfig.log_match) { [bool]$existingGatewayConfig.log_match } else { $true } log_match = if ($existingGatewayConfig -and $null -ne $existingGatewayConfig.log_match) { [bool]$existingGatewayConfig.log_match } else { $true }
+274 -17
View File
@@ -2,7 +2,7 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import fs from "node:fs"; import fs from "node:fs";
import { copyFile, readFile, rm } from "node:fs/promises"; import { copyFile, mkdir, 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";
@@ -11,13 +11,18 @@ import {
DEFAULT_HEALTH_PATH, DEFAULT_HEALTH_PATH,
DEFAULT_LISTEN_HOST, DEFAULT_LISTEN_HOST,
DEFAULT_LISTEN_PORT, DEFAULT_LISTEN_PORT,
DEFAULT_REASONING_EQUALS,
DEFAULT_REASONING_MATCH_MODE,
DEFAULT_STATE_ROOT, DEFAULT_STATE_ROOT,
ensureDirectory, ensureDirectory,
expandEscapedLineBreaks,
getCodexProviderContext, getCodexProviderContext,
getGatewayBaseUrl, getGatewayBaseUrl,
getGatewayStatePaths, getGatewayStatePaths,
isGatewayBaseUrlForListener,
normalizeIntArray, normalizeIntArray,
normalizePhraseArray, normalizePhraseArray,
normalizeReasoningMatchMode,
normalizeStringArray, normalizeStringArray,
parseOptions, parseOptions,
readJsonFile, readJsonFile,
@@ -28,6 +33,17 @@ import {
} from "./admin-lib.mjs"; } from "./admin-lib.mjs";
const DEFAULT_PROFILES_DIR = path.join(os.homedir(), ".config", "codex-retry-gateway", "profiles"); const DEFAULT_PROFILES_DIR = path.join(os.homedir(), ".config", "codex-retry-gateway", "profiles");
const DEFAULT_IMAGE_PROFILES_DIR = path.join(os.homedir(), ".config", "codex-retry-gateway", "image-profiles");
const DEFAULT_REQUEST_BODY_LIMIT_BYTES = 1024 * 1024 * 1024;
const LEGACY_DEFAULT_REQUEST_BODY_LIMIT_BYTES = 10 * 1024 * 1024;
const IMAGE_PROFILE_ENV_KEYS = [
"CODEX_RETRY_GATEWAY_IMAGE_BASE_URL",
"CODEX_RETRY_GATEWAY_IMAGE_AUTH_MODE",
"CODEX_RETRY_GATEWAY_IMAGE_AUTH_ENV",
"CODEX_RETRY_GATEWAY_IMAGE_AUTH_FILE",
"CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_PATH",
"CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_KEY",
];
function parseEnvFile(content) { function parseEnvFile(content) {
const parsed = {}; const parsed = {};
@@ -42,13 +58,16 @@ function parseEnvFile(content) {
} }
const key = line.slice(0, separatorIndex).trim(); const key = line.slice(0, separatorIndex).trim();
let value = line.slice(separatorIndex + 1).trim(); let value = line.slice(separatorIndex + 1).trim();
if ( if (value.startsWith('"') && value.endsWith('"')) {
(value.startsWith('"') && value.endsWith('"')) || try {
(value.startsWith("'") && value.endsWith("'")) value = JSON.parse(value);
) { } catch {
value = value.slice(1, -1);
}
} else if (value.startsWith("'") && value.endsWith("'")) {
value = value.slice(1, -1); value = value.slice(1, -1);
} }
parsed[key] = value; parsed[key] = typeof value === "string" ? expandEscapedLineBreaks(value) : value;
} }
return parsed; return parsed;
} }
@@ -58,6 +77,10 @@ function getProfileName(options) {
return `${options.profile || positional || process.env.CODEX_RETRY_GATEWAY_PROFILE || ""}`.trim(); return `${options.profile || positional || process.env.CODEX_RETRY_GATEWAY_PROFILE || ""}`.trim();
} }
function getImageProfileName(options) {
return `${options.imageProfile || process.env.CODEX_RETRY_GATEWAY_IMAGE_PROFILE || ""}`.trim();
}
function resolveDefaultRequestedProfileName(existingState) { function resolveDefaultRequestedProfileName(existingState) {
const stateProfileName = `${existingState?.profile_name || ""}`.trim(); const stateProfileName = `${existingState?.profile_name || ""}`.trim();
if (stateProfileName) { if (stateProfileName) {
@@ -84,6 +107,31 @@ function resolvePreferredProfileName({ requestedProfileName, existingState, pref
return stateProfileName; return stateProfileName;
} }
function profileFileExists(profilesDir, profileName) {
return /^[A-Za-z0-9_.-]+$/.test(`${profileName || ""}`)
&& fs.existsSync(path.join(profilesDir, `${profileName}.env`));
}
function resolveImageProfileName({
requestedImageProfileName,
textProfileName,
existingState,
preferStateProfile,
imageProfilesDir,
}) {
if (requestedImageProfileName) {
return profileFileExists(imageProfilesDir, requestedImageProfileName)
? requestedImageProfileName
: "";
}
const candidates = [];
if (preferStateProfile) {
candidates.push(`${existingState?.image_profile_name || ""}`.trim());
}
candidates.push(textProfileName);
return candidates.find((profileName) => profileFileExists(imageProfilesDir, profileName)) || "";
}
function boolFromEnv(value, fallback = false) { function boolFromEnv(value, fallback = false) {
if (value === undefined || value === null || value === "") { if (value === undefined || value === null || value === "") {
return fallback; return fallback;
@@ -91,6 +139,17 @@ function boolFromEnv(value, fallback = false) {
return ["1", "true", "yes", "on"].includes(`${value}`.trim().toLowerCase()); return ["1", "true", "yes", "on"].includes(`${value}`.trim().toLowerCase());
} }
function normalizeRequestBodyLimitBytes(value) {
const parsed = Number.parseInt(`${value ?? ""}`, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_REQUEST_BODY_LIMIT_BYTES;
}
if (parsed === LEGACY_DEFAULT_REQUEST_BODY_LIMIT_BYTES) {
return DEFAULT_REQUEST_BODY_LIMIT_BYTES;
}
return parsed;
}
function normalizeAuthMode(value) { function normalizeAuthMode(value) {
const mode = `${value || "passthrough"}`.trim().toLowerCase(); const mode = `${value || "passthrough"}`.trim().toLowerCase();
if (["passthrough", "fixed_bearer", "manual_bearer", "auth_json"].includes(mode)) { if (["passthrough", "fixed_bearer", "manual_bearer", "auth_json"].includes(mode)) {
@@ -145,6 +204,58 @@ function buildProfileAuthConfig(profileEnv) {
return authConfig; return authConfig;
} }
function inferImageProfileAuthMode(imageProfileEnv) {
if (imageProfileEnv.CODEX_RETRY_GATEWAY_IMAGE_AUTH_MODE) {
return normalizeAuthMode(imageProfileEnv.CODEX_RETRY_GATEWAY_IMAGE_AUTH_MODE);
}
if (
imageProfileEnv.CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_PATH ||
imageProfileEnv.CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_KEY
) {
return "auth_json";
}
if (
imageProfileEnv.CODEX_RETRY_GATEWAY_IMAGE_AUTH_FILE ||
imageProfileEnv.CODEX_RETRY_GATEWAY_IMAGE_AUTH_ENV
) {
return "fixed_bearer";
}
return "fixed_bearer";
}
function buildImageProfileAuthConfig(imageProfileEnv) {
const authMode = inferImageProfileAuthMode(imageProfileEnv);
const authConfig = {
image_auth_mode: authMode,
image_auth_env: "",
image_auth_file: "",
image_auth_json_path: "",
image_auth_json_key: "",
};
if (authMode === "fixed_bearer") {
authConfig.image_auth_env =
imageProfileEnv.CODEX_RETRY_GATEWAY_IMAGE_AUTH_ENV ||
"CODEX_RETRY_GATEWAY_IMAGE_API_KEY";
authConfig.image_auth_file =
imageProfileEnv.CODEX_RETRY_GATEWAY_IMAGE_AUTH_FILE ||
"";
} else if (authMode === "manual_bearer") {
authConfig.image_auth_file =
imageProfileEnv.CODEX_RETRY_GATEWAY_IMAGE_AUTH_FILE ||
"";
} else if (authMode === "auth_json") {
authConfig.image_auth_json_path =
imageProfileEnv.CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_PATH ||
"";
authConfig.image_auth_json_key =
imageProfileEnv.CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_KEY ||
"OPENAI_API_KEY";
}
return authConfig;
}
async function loadProfileEnv(profileName, profilesDir) { async function loadProfileEnv(profileName, profilesDir) {
const profilePath = path.join(profilesDir, `${profileName}.env`); const profilePath = path.join(profilesDir, `${profileName}.env`);
if (!fs.existsSync(profilePath)) { if (!fs.existsSync(profilePath)) {
@@ -157,11 +268,92 @@ async function loadProfileEnv(profileName, profilesDir) {
}; };
} }
function buildProfileConfig({ profileName, profileEnv, existingGatewayConfig, providerContext, localGatewayBaseUrl }) { function serializeEnvValue(value) {
const text = `${value ?? ""}`;
if (/^[A-Za-z0-9_./:@?&=,+-]*$/.test(text)) {
return text;
}
return JSON.stringify(text);
}
function hasLegacyImageProfileConfig(profileEnv) {
return Boolean(`${profileEnv?.CODEX_RETRY_GATEWAY_IMAGE_BASE_URL || ""}`.trim());
}
function imageProfileEnvFromGatewayConfig(config) {
const baseUrl = `${config?.image_base_url || ""}`.trim();
if (!baseUrl) {
return {};
}
return {
CODEX_RETRY_GATEWAY_IMAGE_BASE_URL: baseUrl,
CODEX_RETRY_GATEWAY_IMAGE_AUTH_MODE: config?.image_auth_mode || "fixed_bearer",
CODEX_RETRY_GATEWAY_IMAGE_AUTH_ENV: config?.image_auth_env || "",
CODEX_RETRY_GATEWAY_IMAGE_AUTH_FILE: config?.image_auth_file || "",
CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_PATH: config?.image_auth_json_path || "",
CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_KEY: config?.image_auth_json_key || "",
};
}
async function migrateLegacyImageProfile(profileName, profileEnv, imageProfilesDir, existingGatewayConfig = null) {
const sourceEnv = hasLegacyImageProfileConfig(profileEnv)
? profileEnv
: imageProfileEnvFromGatewayConfig(existingGatewayConfig);
if (!hasLegacyImageProfileConfig(sourceEnv)) {
return null;
}
const imageProfilePath = path.join(imageProfilesDir, `${profileName}.env`);
if (fs.existsSync(imageProfilePath)) {
return { profilePath: imageProfilePath, migrated: false };
}
const pairs = IMAGE_PROFILE_ENV_KEYS
.filter((key) => sourceEnv[key] !== undefined && sourceEnv[key] !== "")
.map((key) => [key, sourceEnv[key]]);
await mkdir(imageProfilesDir, { recursive: true });
await writeFile(
imageProfilePath,
[
"# Migrated from a legacy text profile by codex-retry-gateway.",
"# Image configuration is now independent from text profiles.",
...pairs.map(([key, value]) => `${key}=${serializeEnvValue(value)}`),
"",
].join("\n"),
{ encoding: "utf8", mode: 0o600 },
);
return { profilePath: imageProfilePath, migrated: true };
}
async function loadImageProfileEnv(profileName, imageProfilesDir) {
if (!profileName) {
return { profilePath: null, env: {} };
}
const profilePath = path.join(imageProfilesDir, `${profileName}.env`);
if (!fs.existsSync(profilePath)) {
throw new Error(`Image profile env file was not found: ${profilePath}`);
}
const content = await readFile(profilePath, "utf8");
return {
profilePath,
env: parseEnvFile(content),
};
}
function buildProfileConfig({
profileName,
profileEnv,
imageProfileName,
imageProfileEnv,
existingGatewayConfig,
providerContext,
listenHost,
listenPort,
localGatewayBaseUrl,
}) {
const upstreamBaseUrl = const upstreamBaseUrl =
profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL || profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL ||
existingGatewayConfig?.upstream_base_url || existingGatewayConfig?.upstream_base_url ||
(providerContext.currentBaseUrl === localGatewayBaseUrl (isGatewayBaseUrlForListener(providerContext.currentBaseUrl, listenHost, listenPort)
? null ? null
: providerContext.currentBaseUrl); : providerContext.currentBaseUrl);
@@ -170,6 +362,12 @@ function buildProfileConfig({ profileName, profileEnv, existingGatewayConfig, pr
} }
const profileAuthConfig = buildProfileAuthConfig(profileEnv); const profileAuthConfig = buildProfileAuthConfig(profileEnv);
const imageProfileAuthConfig = buildImageProfileAuthConfig(imageProfileEnv || {});
const reasoningMatchMode = normalizeReasoningMatchMode(
profileEnv.CODEX_RETRY_GATEWAY_REASONING_MATCH_MODE ||
existingGatewayConfig?.reasoning_match_mode ||
DEFAULT_REASONING_MATCH_MODE,
);
return { return {
profile_name: profileName, profile_name: profileName,
@@ -179,9 +377,12 @@ function buildProfileConfig({ profileName, profileEnv, existingGatewayConfig, pr
: DEFAULT_LISTEN_PORT, : DEFAULT_LISTEN_PORT,
upstream_base_url: upstreamBaseUrl, upstream_base_url: upstreamBaseUrl,
...profileAuthConfig, ...profileAuthConfig,
image_profile_name: imageProfileName || "",
image_base_url: imageProfileEnv?.CODEX_RETRY_GATEWAY_IMAGE_BASE_URL || "",
...imageProfileAuthConfig,
request_body_limit_bytes: profileEnv.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES request_body_limit_bytes: profileEnv.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES
? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES}`, 10) ? normalizeRequestBodyLimitBytes(profileEnv.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES)
: Number.parseInt(`${existingGatewayConfig?.request_body_limit_bytes || 10485760}`, 10), : normalizeRequestBodyLimitBytes(existingGatewayConfig?.request_body_limit_bytes),
request_history_limit: profileEnv.CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT request_history_limit: profileEnv.CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT
? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT}`, 10) ? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT}`, 10)
: Number.parseInt(`${existingGatewayConfig?.request_history_limit || 200}`, 10), : Number.parseInt(`${existingGatewayConfig?.request_history_limit || 200}`, 10),
@@ -190,9 +391,10 @@ function buildProfileConfig({ profileName, profileEnv, existingGatewayConfig, pr
profileEnv.CODEX_RETRY_GATEWAY_ENDPOINTS || existingGatewayConfig?.endpoints, profileEnv.CODEX_RETRY_GATEWAY_ENDPOINTS || existingGatewayConfig?.endpoints,
["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
), ),
reasoning_match_mode: reasoningMatchMode,
reasoning_equals: normalizeIntArray( reasoning_equals: normalizeIntArray(
profileEnv.CODEX_RETRY_GATEWAY_REASONING_EQUALS || existingGatewayConfig?.reasoning_equals, profileEnv.CODEX_RETRY_GATEWAY_REASONING_EQUALS || existingGatewayConfig?.reasoning_equals,
[516], DEFAULT_REASONING_EQUALS,
), ),
retryable_status_codes: normalizeIntArray( retryable_status_codes: normalizeIntArray(
profileEnv.CODEX_RETRY_GATEWAY_RETRYABLE_STATUS_CODES || existingGatewayConfig?.retryable_status_codes, profileEnv.CODEX_RETRY_GATEWAY_RETRYABLE_STATUS_CODES || existingGatewayConfig?.retryable_status_codes,
@@ -205,6 +407,10 @@ function buildProfileConfig({ profileName, profileEnv, existingGatewayConfig, pr
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later", "stream disconnected before completion: Concurrency limit exceeded for account, please retry later",
], ],
), ),
management_access_key:
profileEnv.CODEX_RETRY_GATEWAY_MANAGEMENT_ACCESS_KEY ||
existingGatewayConfig?.management_access_key ||
"",
upstream_fetch_retry_attempts: profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS upstream_fetch_retry_attempts: profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS
? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS}`, 10) ? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS}`, 10)
: Number.parseInt(`${existingGatewayConfig?.upstream_fetch_retry_attempts || 5}`, 10), : Number.parseInt(`${existingGatewayConfig?.upstream_fetch_retry_attempts || 5}`, 10),
@@ -222,15 +428,22 @@ function buildProfileConfig({ profileName, profileEnv, existingGatewayConfig, pr
}; };
} }
async function ensureCodexPointsToGateway({ paths, codexConfigPath, providerContext, localGatewayBaseUrl }) { async function ensureCodexPointsToGateway({
paths,
codexConfigPath,
providerContext,
listenHost,
listenPort,
localGatewayBaseUrl,
}) {
await ensureDirectory(paths.backupDir); await ensureDirectory(paths.backupDir);
const existingState = await readJsonFile(paths.statePath); const existingState = await readJsonFile(paths.statePath);
let originalBaseUrl = providerContext.currentBaseUrl; let originalBaseUrl = providerContext.currentBaseUrl;
if (providerContext.currentBaseUrl === localGatewayBaseUrl) { if (isGatewayBaseUrlForListener(providerContext.currentBaseUrl, listenHost, listenPort)) {
originalBaseUrl = existingState?.original_base_url || null; originalBaseUrl = existingState?.original_base_url || null;
} }
if (!originalBaseUrl || originalBaseUrl === localGatewayBaseUrl) { if (!originalBaseUrl || isGatewayBaseUrlForListener(originalBaseUrl, listenHost, listenPort)) {
throw new Error("A restorable original Codex base_url could not be determined."); throw new Error("A restorable original Codex base_url could not be determined.");
} }
@@ -258,6 +471,7 @@ async function ensureCodexPointsToGateway({ paths, codexConfigPath, providerCont
async function main() { async function main() {
const options = parseOptions(process.argv, { booleanFlags: ["no-codex-config-update", "prefer-state-profile"] }); const options = parseOptions(process.argv, { booleanFlags: ["no-codex-config-update", "prefer-state-profile"] });
const profilesDir = options.profilesDir || DEFAULT_PROFILES_DIR; const profilesDir = options.profilesDir || DEFAULT_PROFILES_DIR;
const imageProfilesDir = options.imageProfilesDir || DEFAULT_IMAGE_PROFILES_DIR;
const stateRoot = options.stateRoot || process.env.CODEX_RETRY_GATEWAY_STATE_ROOT || DEFAULT_STATE_ROOT; const stateRoot = options.stateRoot || process.env.CODEX_RETRY_GATEWAY_STATE_ROOT || DEFAULT_STATE_ROOT;
const codexConfigPath = const codexConfigPath =
options.codexConfigPath || options.codexConfigPath ||
@@ -285,7 +499,37 @@ async function main() {
} }
const { profilePath, env: profileEnv } = await loadProfileEnv(profileName, profilesDir); const { profilePath, env: profileEnv } = await loadProfileEnv(profileName, profilesDir);
const existingGatewayConfig = await readJsonFile(paths.configPath);
const migration = await migrateLegacyImageProfile(
profileName,
profileEnv,
imageProfilesDir,
existingGatewayConfig,
);
const requestedImageProfileName = getImageProfileName(options);
const imageProfileName = resolveImageProfileName({
requestedImageProfileName,
textProfileName: profileName,
existingState,
preferStateProfile: Boolean(options.preferStateProfile),
imageProfilesDir,
});
if (requestedImageProfileName && !imageProfileName) {
throw new Error(`Image profile env file was not found: ${path.join(imageProfilesDir, `${requestedImageProfileName}.env`)}`);
}
const { profilePath: imageProfilePath, env: imageProfileEnv } = await loadImageProfileEnv(
imageProfileName,
imageProfilesDir,
);
if (migration?.migrated) {
process.stdout.write(
`[run-profile] migrated legacy image settings text=${profileName} image=${imageProfileName || profileName}\n`,
);
}
for (const [key, value] of Object.entries(profileEnv)) { for (const [key, value] of Object.entries(profileEnv)) {
if (key.startsWith("CODEX_RETRY_GATEWAY_IMAGE_")) {
continue;
}
process.env[key] = value; process.env[key] = value;
} }
@@ -295,26 +539,37 @@ async function main() {
? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_LISTEN_PORT}`, 10) ? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_LISTEN_PORT}`, 10)
: DEFAULT_LISTEN_PORT; : DEFAULT_LISTEN_PORT;
const localGatewayBaseUrl = getGatewayBaseUrl(listenHost, listenPort); const localGatewayBaseUrl = getGatewayBaseUrl(listenHost, listenPort);
const existingGatewayConfig = await readJsonFile(paths.configPath);
const gatewayConfig = buildProfileConfig({ const gatewayConfig = buildProfileConfig({
profileName, profileName,
profileEnv, profileEnv,
imageProfileName,
imageProfileEnv,
existingGatewayConfig, existingGatewayConfig,
providerContext, providerContext,
listenHost,
listenPort,
localGatewayBaseUrl, localGatewayBaseUrl,
}); });
const installState = options.noCodexConfigUpdate const installState = options.noCodexConfigUpdate
? { ? {
existingState, existingState,
originalBaseUrl: providerContext.currentBaseUrl, originalBaseUrl: isGatewayBaseUrlForListener(providerContext.currentBaseUrl, listenHost, listenPort)
? (
existingState?.original_base_url
&& !isGatewayBaseUrlForListener(existingState.original_base_url, listenHost, listenPort)
? `${existingState.original_base_url}`
: null
)
: providerContext.currentBaseUrl,
backupPath: null, backupPath: null,
} }
: await ensureCodexPointsToGateway({ : await ensureCodexPointsToGateway({
paths, paths,
codexConfigPath, codexConfigPath,
providerContext, providerContext,
listenHost,
listenPort,
localGatewayBaseUrl, localGatewayBaseUrl,
}); });
@@ -325,6 +580,8 @@ async function main() {
last_started_at: new Date().toISOString(), last_started_at: new Date().toISOString(),
profile_name: profileName, profile_name: profileName,
profile_env_path: profilePath, profile_env_path: profilePath,
image_profile_name: imageProfileName || "",
image_profile_env_path: imageProfilePath,
codex_config_path: codexConfigPath, codex_config_path: codexConfigPath,
provider_name: providerContext.providerName, provider_name: providerContext.providerName,
original_base_url: installState.originalBaseUrl, original_base_url: installState.originalBaseUrl,
File diff suppressed because it is too large Load Diff
+24
View File
@@ -149,6 +149,10 @@ async function run() {
gatewayConfig.upstream_base_url === `http://127.0.0.1:${upstreamPort}`, gatewayConfig.upstream_base_url === `http://127.0.0.1:${upstreamPort}`,
"Gateway config did not preserve original upstream_base_url", "Gateway config did not preserve original upstream_base_url",
); );
assert(
gatewayConfig.reasoning_match_mode === "formula_518n_minus_2",
"Gateway config did not default reasoning_match_mode to formula_518n_minus_2",
);
assert(Array.isArray(gatewayConfig.endpoints), "Gateway config endpoints must be an array"); assert(Array.isArray(gatewayConfig.endpoints), "Gateway config endpoints must be an array");
assert( assert(
gatewayConfig.endpoints.includes("/responses") && gatewayConfig.endpoints.includes("/responses") &&
@@ -195,6 +199,13 @@ async function run() {
}); });
assert(blocked516Response.status === 502, `Default 516 block did not trigger: ${blocked516Response.status}`); assert(blocked516Response.status === 502, `Default 516 block did not trigger: ${blocked516Response.status}`);
const blocked2070Response = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ test_reasoning_tokens: 2070 }),
});
assert(blocked2070Response.status === 502, `Default 2070 formula block did not trigger: ${blocked2070Response.status}`);
const metricsStatusResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`); const metricsStatusResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`);
const metricsStatusPayload = await metricsStatusResponse.json(); const metricsStatusPayload = await metricsStatusResponse.json();
assert(metricsStatusResponse.status === 200, `Status API failed after traffic: ${metricsStatusResponse.status}`); assert(metricsStatusResponse.status === 200, `Status API failed after traffic: ${metricsStatusResponse.status}`);
@@ -220,6 +231,7 @@ async function run() {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
reasoning_match_mode: "manual",
reasoning_equals: [1024], reasoning_equals: [1024],
retryable_status_codes: [429, 503, 529], retryable_status_codes: [429, 503, 529],
retryable_error_messages: ["Selected model is at capacity. Please try a different model."], retryable_error_messages: ["Selected model is at capacity. Please try a different model."],
@@ -231,10 +243,15 @@ async function run() {
const saveConfigPayload = await saveConfigResponse.json(); const saveConfigPayload = await saveConfigResponse.json();
assert(saveConfigResponse.status === 200, `Save config API failed: ${saveConfigResponse.status}`); 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"); assert(saveConfigPayload.config?.non_stream_status_code === 503, "Save config API did not return updated config");
assert(saveConfigPayload.config?.reasoning_match_mode === "manual", "Save config API did not return updated reasoning_match_mode");
const updatedGatewayConfig = JSON.parse( const updatedGatewayConfig = JSON.parse(
await readFile(path.join(stateRoot, "config", "config.json"), "utf8"), await readFile(path.join(stateRoot, "config", "config.json"), "utf8"),
); );
assert(
updatedGatewayConfig.reasoning_match_mode === "manual",
"Saved config file did not persist reasoning_match_mode",
);
assert( assert(
JSON.stringify(updatedGatewayConfig.reasoning_equals) === JSON.stringify([1024]), JSON.stringify(updatedGatewayConfig.reasoning_equals) === JSON.stringify([1024]),
"Saved config file did not persist reasoning_equals", "Saved config file did not persist reasoning_equals",
@@ -261,6 +278,13 @@ async function run() {
}); });
assert(blockedAfterSave.status === 503, `Hot reloaded config did not take effect: ${blockedAfterSave.status}`); assert(blockedAfterSave.status === 503, `Hot reloaded config did not take effect: ${blockedAfterSave.status}`);
const manualModePassthrough = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ test_reasoning_tokens: 2070 }),
});
assert(manualModePassthrough.status === 200, `manual 模式下 2070 不应继续被拦截: ${manualModePassthrough.status}`);
const restoreViaUiResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/restore`, { const restoreViaUiResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/restore`, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
+6
View File
@@ -8,6 +8,8 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { getGatewayBaseUrl } from "./admin-lib.mjs";
const scriptsRoot = import.meta.dirname; const scriptsRoot = import.meta.dirname;
const launchScript = path.join(scriptsRoot, "launch-ui.sh"); const launchScript = path.join(scriptsRoot, "launch-ui.sh");
const restoreScript = path.join(scriptsRoot, "restore-codex-config.sh"); const restoreScript = path.join(scriptsRoot, "restore-codex-config.sh");
@@ -122,6 +124,10 @@ async function run() {
const gatewayPort = await getFreePort(); const gatewayPort = await getFreePort();
const gatewayBaseUrl = `http://127.0.0.1:${gatewayPort}`; const gatewayBaseUrl = `http://127.0.0.1:${gatewayPort}`;
const upstreamBaseUrl = `http://127.0.0.1:${upstreamPort}`; const upstreamBaseUrl = `http://127.0.0.1:${upstreamPort}`;
assert(
getGatewayBaseUrl("0.0.0.0", gatewayPort) === gatewayBaseUrl,
"Wildcard listener must normalize to loopback for local gateway URLs",
);
await mkdir(codexDir, { recursive: true }); await mkdir(codexDir, { recursive: true });
await writeFile( await writeFile(
+11
View File
@@ -155,6 +155,10 @@ async function run() {
statusPayload.state?.original_base_url === upstreamBaseUrl, statusPayload.state?.original_base_url === upstreamBaseUrl,
"First launch did not persist the original upstream base URL", "First launch did not persist the original upstream base URL",
); );
assert(
statusPayload.config?.reasoning_match_mode === "formula_518n_minus_2",
"First launch did not default reasoning_match_mode to formula_518n_minus_2",
);
const firstStateRaw = await readFile(path.join(stateRoot, "state.json"), "utf8"); const firstStateRaw = await readFile(path.join(stateRoot, "state.json"), "utf8");
const firstState = JSON.parse(firstStateRaw); const firstState = JSON.parse(firstStateRaw);
@@ -194,6 +198,13 @@ async function run() {
}); });
assert(blockedResponse.status === 502, `Default 516 interception was not active: ${blockedResponse.status}`); assert(blockedResponse.status === 502, `Default 516 interception was not active: ${blockedResponse.status}`);
const blockedFormulaResponse = await fetch(`${gatewayBaseUrl}/responses`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ test_reasoning_tokens: 2070 }),
});
assert(blockedFormulaResponse.status === 502, `Default 2070 formula interception was not active: ${blockedFormulaResponse.status}`);
process.stdout.write("PASS launch-ui flow\n"); process.stdout.write("PASS launch-ui flow\n");
} finally { } finally {
try { try {
+1474 -73
View File
File diff suppressed because it is too large Load Diff
+115 -2
View File
@@ -450,6 +450,11 @@ a {
background: #fff0d9; background: #fff0d9;
} }
.badge.success {
color: var(--accent);
background: #d7eee6;
}
.badge.error { .badge.error {
color: var(--red); color: var(--red);
background: #ffece7; background: #ffece7;
@@ -622,9 +627,29 @@ a {
background: #e5eaef; background: #e5eaef;
} }
.meta-retry {
color: #0f5045;
background: #d2eee4;
}
.meta-first-list-pill {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
}
.meta-first-mini {
border-radius: 999px;
padding: 2px 6px;
background: rgba(198, 84, 37, 0.09);
color: #8d421e;
font-size: 11px;
font-weight: 700;
}
.request-grid { .request-grid {
display: grid; display: grid;
grid-template-columns: 1.05fr 1.35fr 1.6fr; grid-template-columns: minmax(150px, 1fr) minmax(220px, 1.25fr) minmax(260px, 1.5fr) minmax(180px, 1.05fr);
gap: 8px; gap: 8px;
} }
@@ -653,6 +678,82 @@ a {
font-size: 12px; font-size: 12px;
} }
.thread-rule-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.thread-rule-actions button {
min-width: 0;
}
.reasoning-retry-block {
grid-column: span 2;
border-color: rgba(22, 107, 92, 0.16);
background:
radial-gradient(circle at 0% 0%, rgba(22, 107, 92, 0.1), transparent 42%),
rgba(255, 250, 240, 0.82);
}
.retry-chip-row {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.retry-first-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.retry-first-chip {
border: 1px solid rgba(22, 107, 92, 0.18);
border-radius: 999px;
padding: 4px 8px;
color: #12463c;
background: rgba(22, 107, 92, 0.1);
font-size: 11px;
font-weight: 750;
}
.retry-first-chip.pending {
color: var(--muted);
background: rgba(30, 33, 29, 0.05);
}
.compact-chip {
padding: 3px 7px;
font-size: 11px;
}
.rules-layout {
align-items: start;
}
.thread-rule-list {
display: grid;
gap: 8px;
}
.thread-rule-card {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid rgba(30, 33, 29, 0.08);
border-radius: 18px;
background: rgba(255, 250, 240, 0.74);
}
.thread-rule-head {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: flex-start;
}
table { table {
width: 100%; width: 100%;
min-width: 960px; min-width: 960px;
@@ -703,6 +804,15 @@ tr:last-child td {
gap: 10px; gap: 10px;
} }
.transfer-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.transfer-pane {
align-content: start;
}
.compact-profile-card { .compact-profile-card {
gap: 8px; gap: 8px;
} }
@@ -941,14 +1051,17 @@ form {
.token-strip, .token-strip,
.mini-stats, .mini-stats,
.field-row, .field-row,
.transfer-grid,
.nav { .nav {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.request-head, .request-head,
.request-grid { .request-grid,
.reasoning-retry-block {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-column: auto;
} }
.info-row { .info-row {