Compare commits
13
Commits
5d48ef5880
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca8935d73e | ||
|
|
9e27778e05 | ||
|
|
066e2d9b61 | ||
|
|
bf01fcd727 | ||
|
|
29eb91e0c3 | ||
|
|
7d0a40e1ea | ||
|
|
92d3845eca | ||
|
|
f62c4d2fed | ||
|
|
0d244e596c | ||
|
|
2ff38c71bb | ||
|
|
73811daaac | ||
|
|
26070fb7d5 | ||
|
|
f9a71663f6 |
@@ -1,5 +1,19 @@
|
||||
# Codex Retry Gateway
|
||||
|
||||
- 文档层级: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-retry-gateway/`
|
||||
- 备注:token 与 provider secret 只保留在目标用户配置或 secret 文件中,不进入 repo。
|
||||
|
||||
一个不依赖 `cc-switch` 路由模式的独立本地网关。
|
||||
|
||||
目标:
|
||||
@@ -7,9 +21,10 @@
|
||||
- 保持 Codex 继续使用现有 `auth.json`
|
||||
- 只把 `config.toml` 的当前 provider `base_url` 改成本地网关
|
||||
- 非流式命中 `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` 但返回体本质是错误、以及流式失败事件里携带同样文案的情况
|
||||
- 流式命中时默认先缓存并判断;一旦命中 `516`,统一返回 `502`
|
||||
- 流式成功响应在严格检查模式下仍会先缓存完成;成功后会按真实且规范化的 Responses 生命周期与输出顺序逐块回放给 Codex,每个 SSE 块之间至少间隔 5ms,并关闭 TCP 小包聚合,避免大量 delta 在同一事件循环突发到达;不会伪造缺少 response ID 的生命周期事件
|
||||
- 默认同时拦截 root 路径和 `/v1` 路径:
|
||||
- `/responses`
|
||||
- `/chat/completions`
|
||||
@@ -127,12 +142,20 @@ bash ./scripts/install-for-current-provider.sh
|
||||
|
||||
在 Linux 上需要常驻运行时,推荐用 `scripts/run-profile.mjs` 作为 systemd 的 ExecStart。它会读取 profile env,生成运行时 `config.json`,并把当前 Codex provider 的 `base_url` 指向本机 gateway。
|
||||
|
||||
profile env 默认放在:
|
||||
文本 profile env 默认放在:
|
||||
|
||||
```text
|
||||
~/.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`
|
||||
@@ -155,18 +178,96 @@ profile env 默认放在:
|
||||
- `fixed_bearer`:从环境变量或文件读取 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
|
||||
node ./scripts/run-profile.mjs default
|
||||
```
|
||||
|
||||
指定图片 profile:
|
||||
|
||||
```bash
|
||||
node ./scripts/run-profile.mjs default --image-profile images
|
||||
```
|
||||
|
||||
如果已经由 state 记录了当前活跃 profile,也可以直接不传 profile 参数:
|
||||
|
||||
```bash
|
||||
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:
|
||||
@@ -217,10 +318,11 @@ gateway 运行时只负责 API 与静态文件服务,不再把复杂 UI 硬写
|
||||
- 请求时间戳、首字耗时、总耗时、请求体大小、路径、模型、状态码
|
||||
- 相同重发请求会带相同的 `request_id`
|
||||
- `usage` 中的 input / output / total / reasoning tokens
|
||||
- 管理 profiles
|
||||
- 新建 / 编辑 profile env
|
||||
- 切换 provider `base_url`
|
||||
- 切换 `passthrough` / `manual_bearer` / `fixed_bearer` / `auth_json` 认证模式
|
||||
- 分别管理文本 profiles 与图片 profiles
|
||||
- 各自新建 / 编辑 / 探测 / 切换 / 删除 profile env
|
||||
- 各自导出 / 导入可移植 profile JSON;导出前必须再次验证该 profile 当前实际使用的 API key
|
||||
- 文本切换 provider `base_url` 不会改写图片分流,图片切换只影响 `/images/*` 与 `/v1/images/*`
|
||||
- 各自切换 `passthrough` / `manual_bearer` / `fixed_bearer` / `auth_json` 认证模式
|
||||
- 改 `reasoning_equals`
|
||||
- 改 capacity error 的匹配状态码和错误文案
|
||||
- 改 `endpoints`
|
||||
@@ -240,9 +342,25 @@ gateway 运行时只负责 API 与静态文件服务,不再把复杂 UI 硬写
|
||||
- 请求历史只记录元数据、请求体字节数和 token usage,不保存请求正文或响应正文;默认展示最近 200 条
|
||||
- gateway 日志持久化到 `~/.codex-retry-gateway/logs/gateway.log`
|
||||
- 请求记录持久化到 `~/.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 导入与导出
|
||||
|
||||
文本与图片 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 key;gateway 会从现有 `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、上传到普通日志或长期放在共享下载目录。
|
||||
|
||||
## 如何调整拦截条件
|
||||
|
||||
编辑:
|
||||
@@ -316,6 +434,10 @@ macOS / Linux: ~/.codex-retry-gateway
|
||||
- `test-gateway-e2e.ps1`
|
||||
- 已通过
|
||||
- 验证 `/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`
|
||||
- 已通过
|
||||
- 验证安装、透传、UI 页面、热更新配置、实时日志、516 统计、恢复闭环
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"profile_name": "default",
|
||||
"image_profile_name": "",
|
||||
"listen_host": "127.0.0.1",
|
||||
"listen_port": 4610,
|
||||
"upstream_base_url": "https://api.openai.com",
|
||||
@@ -8,6 +9,12 @@
|
||||
"upstream_auth_file": "",
|
||||
"upstream_auth_json_path": "",
|
||||
"upstream_auth_json_key": "OPENAI_API_KEY",
|
||||
"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,
|
||||
"endpoints": ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
|
||||
|
||||
+1957
-105
File diff suppressed because it is too large
Load Diff
+210
-4
@@ -2,7 +2,7 @@
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
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 path from "node:path";
|
||||
|
||||
@@ -32,8 +32,17 @@ import {
|
||||
} from "./admin-lib.mjs";
|
||||
|
||||
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) {
|
||||
const parsed = {};
|
||||
@@ -67,6 +76,10 @@ function getProfileName(options) {
|
||||
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) {
|
||||
const stateProfileName = `${existingState?.profile_name || ""}`.trim();
|
||||
if (stateProfileName) {
|
||||
@@ -93,6 +106,31 @@ function resolvePreferredProfileName({ requestedProfileName, existingState, pref
|
||||
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) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return fallback;
|
||||
@@ -165,6 +203,58 @@ function buildProfileAuthConfig(profileEnv) {
|
||||
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) {
|
||||
const profilePath = path.join(profilesDir, `${profileName}.env`);
|
||||
if (!fs.existsSync(profilePath)) {
|
||||
@@ -177,7 +267,86 @@ 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,
|
||||
localGatewayBaseUrl,
|
||||
}) {
|
||||
const upstreamBaseUrl =
|
||||
profileEnv.CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL ||
|
||||
existingGatewayConfig?.upstream_base_url ||
|
||||
@@ -190,6 +359,7 @@ function buildProfileConfig({ profileName, profileEnv, existingGatewayConfig, pr
|
||||
}
|
||||
|
||||
const profileAuthConfig = buildProfileAuthConfig(profileEnv);
|
||||
const imageProfileAuthConfig = buildImageProfileAuthConfig(imageProfileEnv || {});
|
||||
const reasoningMatchMode = normalizeReasoningMatchMode(
|
||||
profileEnv.CODEX_RETRY_GATEWAY_REASONING_MATCH_MODE ||
|
||||
existingGatewayConfig?.reasoning_match_mode ||
|
||||
@@ -204,6 +374,9 @@ function buildProfileConfig({ profileName, profileEnv, existingGatewayConfig, pr
|
||||
: DEFAULT_LISTEN_PORT,
|
||||
upstream_base_url: upstreamBaseUrl,
|
||||
...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
|
||||
? normalizeRequestBodyLimitBytes(profileEnv.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES)
|
||||
: normalizeRequestBodyLimitBytes(existingGatewayConfig?.request_body_limit_bytes),
|
||||
@@ -288,6 +461,7 @@ async function ensureCodexPointsToGateway({ paths, codexConfigPath, providerCont
|
||||
async function main() {
|
||||
const options = parseOptions(process.argv, { booleanFlags: ["no-codex-config-update", "prefer-state-profile"] });
|
||||
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 codexConfigPath =
|
||||
options.codexConfigPath ||
|
||||
@@ -315,7 +489,37 @@ async function main() {
|
||||
}
|
||||
|
||||
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)) {
|
||||
if (key.startsWith("CODEX_RETRY_GATEWAY_IMAGE_")) {
|
||||
continue;
|
||||
}
|
||||
process.env[key] = value;
|
||||
}
|
||||
|
||||
@@ -325,11 +529,11 @@ async function main() {
|
||||
? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_LISTEN_PORT}`, 10)
|
||||
: DEFAULT_LISTEN_PORT;
|
||||
const localGatewayBaseUrl = getGatewayBaseUrl(listenHost, listenPort);
|
||||
const existingGatewayConfig = await readJsonFile(paths.configPath);
|
||||
|
||||
const gatewayConfig = buildProfileConfig({
|
||||
profileName,
|
||||
profileEnv,
|
||||
imageProfileName,
|
||||
imageProfileEnv,
|
||||
existingGatewayConfig,
|
||||
providerContext,
|
||||
localGatewayBaseUrl,
|
||||
@@ -355,6 +559,8 @@ async function main() {
|
||||
last_started_at: new Date().toISOString(),
|
||||
profile_name: profileName,
|
||||
profile_env_path: profilePath,
|
||||
image_profile_name: imageProfileName || "",
|
||||
image_profile_env_path: imageProfilePath,
|
||||
codex_config_path: codexConfigPath,
|
||||
provider_name: providerContext.providerName,
|
||||
original_base_url: installState.originalBaseUrl,
|
||||
|
||||
+957
-25
File diff suppressed because it is too large
Load Diff
+1138
-77
File diff suppressed because it is too large
Load Diff
@@ -678,6 +678,17 @@ a {
|
||||
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);
|
||||
@@ -718,6 +729,31 @@ a {
|
||||
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 {
|
||||
width: 100%;
|
||||
min-width: 960px;
|
||||
@@ -768,6 +804,15 @@ tr:last-child td {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.transfer-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.transfer-pane {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.compact-profile-card {
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1006,6 +1051,7 @@ form {
|
||||
.token-strip,
|
||||
.mini-stats,
|
||||
.field-row,
|
||||
.transfer-grid,
|
||||
.nav {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user