Continue pool-owned WebSocket handshakes after the foreground budget expires, retain late successes for reuse, and cancel them on account eviction or shutdown. Keep breaker accounting and transport metrics aligned with the foreground decision. Co-authored-by: Codex <noreply@openai.com>
24 KiB
Codex Proxy RS 架构
本文定义仓库当前生效的系统结构、责任边界和运行时不变量。设计讨论、故障排查、迁移过程和阶段性验收不属于架构文档;代码边界发生变化时,本文必须在同一变更中同步。
系统边界
Codex Proxy RS 是一个单进程 Rust 网关,同时提供:
- OpenAI Responses 兼容接口:
/v1/*,包括 HTTP JSON、HTTP SSE 和官方 Responses WebSocket。 - 管理端接口:
/api/admin/*。 - Vue 管理端静态资源。
- PostgreSQL 与 Redis 健康检查:
/healthz。 - Codex 账号池调度、上游传输、历史恢复、用量和运维遥测。
网关为每次代理请求选择一个可用上游账号,注入该账号的认证身份和安装身份,再调用 ChatGPT Codex 后端。请求和响应默认保持上游语义;只有模型别名、账号身份隔离、transport control 和具备完整上下文的历史重放允许改写。
运行时依赖:
| 依赖 | 责任 |
|---|---|
| PostgreSQL | 账号、Key、设置、Cookie、指纹和遥测事实的权威存储 |
| Redis | 管理会话、刷新租约、模型快照、响应归属和会话级短期状态 |
| ChatGPT Codex | Responses、模型目录、配额和 OAuth token 刷新 |
.runtime/ |
身份派生密钥、更新状态、日志及本地 PostgreSQL/Redis 数据 |
PostgreSQL 和 Redis 都是启动必需依赖。运行中任一健康检查失败时,/healthz 返回 503。
架构不变量
v1/*使用 Request → Attempt → Stream 三层静态洋葱生命周期;管理端、健康检查和静态资源不进入业务洋葱。- 一个功能只有一个规则 owner。跨入口的账号失败语义属于
fleet/account_failure.rs;v1/*的 retry、exhaustion 和 commit decision 属于对应 controller。 - API 只解析和编码;lifecycle 只编排顺序、retry、commit 和 finalize;transport 只建立连接并产出 typed facts。
- 上游响应一旦可能收到 payload,transport 或账号都不得自动重放同一请求。
- 账号认证身份按 attempt 重建;客户端会话拓扑按原值透传;两者不能共用一个“身份”抽象。
v1/*热路径中的账号明确失效先在线性化的内存路径生效并驱逐 WebSocket,再异步持久化 PostgreSQL;管理端和后台任务可等待同一状态 effect 持久化完成。- 每项生产能力只有一条执行路径,不设置兼容 wrapper、双状态机或运行时切换的新旧实现。
backend/src/只放生产代码;所有测试和 test-only helper 位于backend/tests/。
仓库结构
.
├── backend/
│ ├── src/ Rust 生产代码
│ ├── tests/ 按生产边界组织的集成与契约测试
│ ├── Cargo.toml
│ └── Cargo.lock
├── frontend/ Vue 管理端
├── deploy/ Dockerfile、Compose 和运行配置
├── docs/
│ └── architecture.md 当前架构
├── release/ 版本、平台和发布脚本
└── skills/ 仓库内开发约束
后端分层
backend/src/lib.rs 只声明顶层模块。项目不设置通用 application 层,跨领域对象仅在 bootstrap 装配。
backend/src/
├── main.rs
├── lib.rs
├── infra/
├── upstream/
├── fleet/
├── models/
├── dispatch/
├── telemetry/
├── keys/
├── auth/
├── settings/
├── update/
├── api/
└── bootstrap/
依赖方向:
| 模块 | 责任与依赖边界 |
|---|---|
main |
解析子命令,只进入 bootstrap 或一次性任务 |
bootstrap |
进程 composition root,可装配所有领域模块,不拥有业务规则 |
api |
HTTP/WebSocket 契约、鉴权提取和最终响应编码,不写 SQL/Redis |
dispatch |
编排一次 v1/* 请求,调用 fleet、models、upstream、telemetry |
fleet |
账号、调度、quota、token、Cookie 和管理操作 |
upstream |
Codex 协议与 HTTP/WebSocket transport,不选择账号 |
telemetry |
保存已经确定的成功、失败和聚合事实,不参与调度 |
infra |
PostgreSQL、Redis、日志、时间、路径和身份基础设施,不依赖 API |
infra
infra 提供:
database.rs:PostgreSQL 连接池、迁移、迁移 checksum 和 ping。redis.rs:RedisConnectionManager、统一cpr:前缀和 ping。identity.rs:密码哈希、API Key、会话令牌和 HMAC 伪名。paths.rs:数据目录与identity_hmac_secret。logging.rs:TTY compact 日志与非 TTY/文件 JSON 日志。time.rs、json.rs、format.rs:无业务语义的通用值处理。
upstream
upstream/openai/
├── token_client.rs
├── failure.rs
├── protocol/
│ ├── responses.rs
│ ├── events.rs
│ ├── sse.rs
│ ├── websocket.rs
│ └── schema.rs
├── transport/
│ ├── client.rs
│ ├── client_sse.rs
│ ├── headers.rs
│ ├── tls.rs
│ ├── response_meta.rs
│ ├── diagnostics.rs
│ ├── usage.rs
│ ├── websocket.rs
│ ├── websocket_frames.rs
│ ├── websocket_pool.rs
│ ├── websocket_breaker.rs
│ └── websocket_pump.rs
└── fingerprint/
protocol定义 Responses body、canonical SSE/WS 事实和序列化,不包含账号策略。failure.rs将各 transport 错误规范化为稳定的UpstreamFailureFacts,不解释账号状态。transport拥有 HTTP/SSE、WebSocket、TLS、header、连接池、熔断和 transport metrics。fingerprint拥有 Codex Desktop 指纹的存储、运行时快照和更新。token_client.rs只实现 OAuth token 刷新协议。
fleet
fleet 是账号领域:
store:账号与 quota 的 PostgreSQL 映射和事务写入。pool:进程内账号快照、并发 slot、请求间隔和候选 lease。account_failure.rs:HTTP、SSE、手动额度查询和后台额度刷新共享的账号失败分类与状态 effect。scheduler:候选过滤与排序的唯一 owner,支持smart、quota_reset_priority、round_robin、sticky。scheduler/feedback:进程内 EWMA 错误率和首字延迟。quota:配额查询、窗口与运行时限制。refresh:token 刷新策略、Redis 租约和刷新服务。cookies:账号级 Cloudflare Cookie。manage:导入、导出、OAuth、探测、刷新和账号生命周期。
models
catalog.rs合并官方模型快照与运行时别名。service.rs请求/codex/models,按订阅计划维护模型目录。store.rs在 Redis 中保存计划模型快照。types.rs定义模型、计划和目录值对象。
telemetry、keys、auth、settings、update
telemetry:成功事实usage_records、失败事实ops_error_logs、请求时间桶、账号用量和计费。keys:客户端 API Key 生命周期与 PostgreSQL 鉴权。auth:管理员用户、密码校验和 Redis 登录会话。settings:runtime_settings读写及watch广播。update:Release 查询、checksum 校验、文件替换、状态和回滚。
api 与 bootstrap
api 组合客户端接口、管理端接口、健康检查和 SPA:
api/client/responses解析 HTTP JSON/SSE 与入站 Responses WebSocket。api/client/errors.rs将 typed dispatch error 编码为稳定 OpenAI 错误。api/admin提供管理端路由和统一no-store响应。api/middleware负责 request ID、访问日志和连接排空。api/assets.rs提供静态资源与 Vue Router fallback,未知 API 路径不回退 SPA。
bootstrap 负责配置、依赖装配、启动任务和关闭协调;它不解释账号错误、history 或 transport outcome。
v1 请求生命周期
dispatch 是 v1/* 的业务编排边界:
API adapter
-> Request lifecycle
-> AttemptRunner(account A)
-> controller enter
-> request interval || transport prepare
-> upstream exchange
<- typed observation / decision
-> AttemptRunner(account B) [仅 OpenAttempt 可 retry]
-> Stream lifecycle [CommittedAttempt 无 retry API]
<- controller finalize / API encoding
Request
dispatch/lifecycle/request.rs:
- 解析模型和请求不变量。
- 建立 history、cyber policy、identity、usage 等 request scope。
- 并行读取互不依赖的 Redis 状态,读取有 100ms 上限并 fail-open。
- 合并 preferred/excluded 账号集合。
- 按当前 scheduler 冻结本请求的完整候选顺序。
Request scope 在 attempt 之间传递,但 controller 只能访问自己的状态。
Attempt
dispatch/lifecycle/attempt.rs 对每个候选账号执行:
- 从账号池获取 lease,并在真正获取时重读运行时可用状态。
- 由 quota、Cookie、history 和 identity controller 构造
AccountScopedRequest。 - 并行执行账号请求间隔等待与 prepared transport。
- 发送上游请求,将 HTTP、SSE、WebSocket 结果规范化为
AttemptObservation。 - 由
ControllerSet按静态优先级调用 owner 分类和 effect。 - lifecycle 只解释
Accept、RetrySameAccount、RetryNextCandidate、Return。
OpenAttempt 暴露 decision/retry;CommittedAttempt 只允许建立结果或 stream。提交边界后的类型没有 retry 方法。
Stream
dispatch/transport/canonical.rs 对上游事件只解码一次,同时保留 raw bytes 和 typed facts。prefetch 在首个真实输出或 terminal 前保持请求可恢复;提交后由 dispatch/stream/live.rs 转发事件,并由 consuming finalizer 保证所有结束路径只 finalize 一次。
Stream terminal 包括 completed、incomplete、failed、上游断开、下游取消、shutdown 和 capture limit。已提交流中的明确账号事实仍会更新账号状态,但只影响后续请求。
Controller 所有权
| 功能 | 唯一 owner |
|---|---|
| previous response 所有权、scope、full replay | controllers/history.rs |
| cyber policy 会话排除和 CAS 清理 | controllers/cyber_policy/ |
| v1 quota 与 rate limit attempt decision | controllers/quota.rs |
| v1 封禁、过期、模型不支持的 exhaustion decision | controllers/account_failure.rs |
| transport 错误到稳定上游事实 | upstream/openai/failure.rs |
| 跨入口账号失败分类、状态 effect 与 WS 驱逐 | fleet/account_failure.rs |
| Cloudflare Cookie 与冷却 | controllers/cloudflare.rs |
| 会话亲和写入 | controllers/affinity.rs |
| usage 结算 | controllers/usage.rs |
| 代理事件与 transport metrics | controllers/telemetry/ |
Controller 之间不直接调用。跨功能优先级只在 ControllerSet 声明;API、transport、store 和 lifecycle 不重复识别功能语义。Controller 只把共享账号分类翻译为 v1 生命周期 decision;手动额度查询和后台额度刷新执行同一状态 effect,但各自保留自己的返回与调度职责。
上游双热传输
Responses transport 使用会话级 WebSocket 池与 origin 级 HTTP/2 池两条常热通道。WebSocket 保存 connection-local response 状态;HTTP/2 提供新链和可持久化续链的低延迟后备。transport 只竞速连接准备,不并行发送业务 payload。
TransportRequirement
upstream/openai/protocol/responses.rs 将请求事实归一化为:
| Requirement | 语义 |
|---|---|
HttpRequired |
客户端明确要求 HTTP |
ExplicitWebSocketWarmup |
generate=false + store=false,必须使用可保留 socket 的 WebSocket |
ExactWebSocketContinuation |
只能使用持有指定 connection-local response ID 的 socket |
PersistedContinuation |
previous response 已持久化,可使用 WS 或 HTTP/2 |
ExternalUnknown |
previous response owner 未知,只允许选定账号原样尝试 |
NewChain |
无 previous response 的普通新链 |
generate=false + store=false 的约束优先于本地 HTTP hint,避免产生无法续接的 warmup response。
Prepared typestate 与 fallback
PreparedResponseTransport 只表示“账号、header 和连接已准备,但 payload 尚未发送”。WebSocket opening 与 response.create 发送是单向类型边界:
- 热 WS 可用时立即租用并发送 WS payload。
- 普通新链、persisted continuation 和 external unknown 没有热 WS 时创建一个
Connectingslot;前台从该 slot 的统一起点最多等待 800ms。 - 800ms 内握手成功时,连接先原子进入
Idle,当前请求再租用为Busy并发送 WS payload。 - 800ms 耗尽时,当前请求不发送 WS payload,立即在同账号使用 HTTP/2;未完成的握手由 pool 在后台继续,仍受 15 秒硬连接超时约束。
- 后台握手成功后只进入
Idle,供后续请求复用;最终失败则删除Connectingslot。相同 key 的后续请求沿用原 opening 的绝对 deadline,不会重新获得一段 800ms。 - DNS/TCP/TLS 立即失败、pool 不可用或 breaker 打开时,同账号使用 HTTP/2。
- opening 已返回明确上游响应时,事实直接进入 controller,不追加 HTTP 请求。
- warmup 与 exact continuation 必须等待所需 WS,不使用 HTTP fallback。
- payload 发送后的连接断开、发送超时或首事件超时统一为
PostSendAmbiguous,不得换 transport 或账号。
AttemptRunner 将 prepared transport 与账号请求间隔并行,800ms、熔断阈值和连接状态只存在于 upstream/openai/transport。
WebSocket pool
Pool key 为 (base_url, account_id, local_conversation_id),slot 状态为:
Idle:可租用的热 socket。Busy:单 socket 上已有一个 in-flight response。Connecting:同 key opening 单飞,包含统一 started-at、结果通知和取消令牌。
每条连接记录最新 completed response ID。Exact continuation 只有 ID 完全匹配时才能租用;socket 缺失、busy、失活或 ID 不匹配时不新建连接碰运气,由 HistoryController 决定 full replay 或失败。
池化 socket 默认每 25 秒发送带唯一序列的 Ping,匹配 Pong 的 deadline 为 5 秒,最大寿命为 55 分钟。任意业务入站帧同样证明链路存活;下游背压导致 pump 无法读取 socket 时暂停主动探活。后台 pump 统一处理 ping/pong、EOF、close 和 liveness,复用前只读取原子连接状态,不增加网络往返。
Idle、Busy 和 Connecting 都计入 ws_pool.max_per_account,默认单账号最多 8 个 slot;后台续建不设置另一套队列或全局连接上限。账号驱逐会删除该账号全部 slot、关闭 idle socket 并取消 connecting;busy socket 在归还时因 reservation 已失效而关闭。进程关闭先取消所有 pool-owned opening,再等待维护任务和建连任务退出,迟到连接不能在驱逐或 shutdown 后重新入池。
Origin breaker
Breaker key 为 origin 与 TLS profile,不按账号区分:
- 30 秒窗口内 3 次退化 opening 后打开 30 秒;退化包括 800ms 快路径预算耗尽、transport 失败和 5xx opening 响应。
- 同一次 opening 的前台预算耗尽与后台最终失败只计一次;迟到成功也不抹去已经发生的快路径退化。
- 预算内成功 opening 会清空该 origin 的历史退化记录。
- 打开期间热 WS 仍可使用;普通冷 opening 直接走 HTTP/2。
- 到期后只允许一个 half-open probe。
- 账号或请求级 4xx opening 响应证明 origin 可达并关闭 half-open。
- 5xx 或 transport probe 失败重新打开 breaker。
- 账号驱逐和进程 shutdown 触发的主动取消不计为 origin 故障。
Breaker 不更新账号状态;账号失效只由 controller 的明确 typed fact 触发。
HTTP/2
Reqwest client 按 TLS profile 缓存并自动 ALPN 协商,配置为:
Client::builder()
.pool_idle_timeout(None::<Duration>)
.http2_keep_alive_interval(Duration::from_secs(30))
.http2_keep_alive_timeout(Duration::from_secs(5))
.http2_keep_alive_while_idle(true)
HTTP/2 连接按 origin/TLS profile 共享,账号 Authorization 只属于单个 stream。系统没有强制 HTTP/1.1 配置或第二套 transport builder。
Transport metrics
成功与失败事实记录:
transportDecisionwsConnectMstransportDecisionWaitMsupstreamHeadersMsfirstEventMsfirstTokenMshttpVersionwebsocketPool.kind
wsConnectMs 只表示已经成功的 WS 握手耗时;复用、预算耗尽和其他 HTTP fallback 不填写该字段。transportDecisionWaitMs 表示当前请求为 transport 选择实际等待的时间。后台 opening 另外记录 ws_preconnect_duration_ms、ws_preconnect_outcome 和 foreground_waiting,不把未成功握手的 800ms 伪装成连接耗时。
稳定 decision 包括 ws_reused、ws_connected_fast、ws_exact_required、ws_required、http2_ws_budget_exhausted、http2_breaker_open、http2_pool_unavailable 和 http2_ws_pre_send_failure。
账号调度与失效
Scheduler 先过滤不可用账号,再按运行时 rotation_strategy 排序。候选顺序在 Request 阶段冻结;层级只影响顺序,不缩小 failover 集合。候选账本不会重复租用同一账号。
明确上游事实的处理顺序:
- transport 提取 status、code、type、message、retry-after 等事实。
fleet/account_failure.rs将跨入口账号事实统一分类为 quota、rate limit、expired、disabled、banned 或 model unsupported;Cloudflare 与 cyber policy 保持各自 owner。- v1 controller 把共享分类翻译为 retry/exhaustion decision。
- 对账号全局事实,账号池 mutex 内立即失效 runtime state 并移除 slot。
- 驱逐该账号的 idle/busy/connecting WebSocket entry。
- lifecycle 从冻结候选中选择下一账号。
- PostgreSQL 状态写入后台有序队列。
数据库延迟不会延长当前换号路径,也不会使并发请求重新租到已失效账号。
cyber_policy 是会话级负亲和,不是账号全局状态。它按客户端 API Key 和会话身份隔离,以 revision token 做 CAS 清理,TTL 为 1 小时;不设置功能私有的重试次数,候选耗尽由通用 lifecycle 返回。
Previous response 与重放
成功 response 的 Redis 归属记录保存账号、conversation、turn state、variant 和 continuation scope,不保存请求/响应正文。
Continuation scope:
| Scope | 含义 |
|---|---|
Persisted |
store=true,上游可在新 transport hydration |
ConnectionLocal |
store=false 且 completed socket 仍在池中 |
ReplayRequired |
没有可续接 socket,但当前入站 WS 持有完整 canonical transcript |
Unavailable |
没有 socket,也没有完整 replay snapshot |
ExternalUnknown |
owner 无法由代理确认 |
完整 transcript 只存在于当前入站 Responses WebSocket:
- 每次 completed 后追加本轮 input 与 output。
- full replay 前递归删除 item
id和encrypted_content,保留工具配对所需的call_id。 - transcript 不写 PostgreSQL/Redis,不跨下游连接共享。
- 有完整 transcript 时,HistoryController 可以删除 previous ID 并在同账号或后续候选全量重放。
- 只有归属而没有 transcript 时,只允许 owner 账号续接。
- owner 与 transcript 都未知时,只在首个选定账号原样尝试。
真实输出提交后不执行透明 history recovery。客户端负责在新的 turn 中重新提交完整上下文。
身份隔离
| 字段 | 所属语义 | attempt 行为 |
|---|---|---|
access token、chatgpt-account-id、Cookie |
上游账号认证 | 每次从当前账号重建 |
x-codex-installation-id 及其 metadata 投影 |
安装/设备指纹 | 由实例 HMAC secret 按账号稳定派生 |
x-codex-turn-state |
上游 opaque sticky state | 同账号可复用,换号必须删除 |
previous_response_id |
上游历史句柄 | 只在 owner 账号/socket 使用;full replay 时删除 |
| session/thread/conversation/turn/window/parent ID | 客户端会话拓扑 | 原样透传,不按账号改写 |
prompt_cache_key、x-client-request-id |
会话缓存与请求关联 | 客户端提供时原样透传 |
output item id、encrypted_content |
上游输出引用 | full replay 时删除 |
tool call_id |
工具输入输出配对 | full replay 时保留 |
AccountScopedIdentity 只包含 installation ID;AccountScopedRequest 只有在当前账号认证、Cookie、installation ID 和 turn-state 边界处理完成后才能进入 upstream transport。
数据存储
PostgreSQL
PostgreSQL 保存:
| 数据 | 表 |
|---|---|
| 管理员 | admin_users |
| 客户端 Key | client_api_keys |
| 运行时设置 | runtime_settings |
| 账号与 quota | accounts |
| 账号累计用量 | account_usage |
| 成功请求事实 | usage_records |
| 失败请求事实 | ops_error_logs |
| 请求聚合桶 | request_time_buckets |
| Cloudflare Cookie | account_cookies |
| Codex 指纹 | fingerprints、fingerprint_update_history |
| 迁移记录 | schema_migrations |
时间列使用 timestamptz,应用按 UTC 时间点读写,中国自然日和展示时区由 infra::time 定义。迁移版本严格递增并校验名称与 SQL checksum。
Redis
Redis 使用统一 cpr: 前缀:
| Key | 内容 | 生命周期 |
|---|---|---|
admin:session:<hash> |
管理员会话 | 配置 TTL |
lease:refresh:<account_id> |
token 刷新租约 | PX TTL |
models:plan_snapshots |
计划模型快照 | 刷新替换 |
affinity:v3:resp:<response_id> |
response owner | 4 小时 |
affinity:v3:conv:<conversation_id> |
conversation 索引 | 4 小时 |
affinity:v3:account:<account_id> |
account 索引 | 4 小时 |
affinity:v3:global:* |
全局裁剪索引和字节计数 | 4 小时 |
cyber-policy:v2:session:<hash> |
会话级账号排除 | 1 小时 |
Affinity metadata 单条上限 64 KiB、全局上限 128 MiB,并在写入时惰性裁剪。
本地目录
.runtime/
├── data/
│ ├── identity_hmac_secret
│ ├── update-state.json
│ ├── update.lock
│ └── update-tmp/
├── logs/
├── postgres/
└── redis/
Compose 将应用配置只读挂载到 /app/deploy/config.yaml。应用数据、日志、PostgreSQL 和 Redis 均使用独立 bind mount;Redis 启用 AOF。
启动、任务与关闭
serve 按以下顺序启动:
- 定位并严格解析
deploy/config.yaml。 - 解析相对路径并注入 PostgreSQL/Redis secret。
- 初始化日志和监听地址。
- 连接 PostgreSQL,校验并执行迁移。
- 连接 Redis。
- 读取
runtime_settings。 - 创建领域 store、service、OpenAI client 和账号池。
- 读取或创建
identity_hmac_secret。 - 初始化指纹、管理员和模型快照。
- 启动后台任务并挂载 HTTP router。
后台任务由 TaskCoordinator 管理:
- Cookie 清理和事实表 retention。
- 模型目录刷新。
- token 与 quota 刷新。
- Codex 指纹更新。
- settings 订阅。
- WebSocket pool 生命周期。
关闭时停止接收新请求,结束 HTTP 长流和入站 WebSocket,关闭上游 WebSocket pool 与后台任务。连接总排空上限为 20 秒,单任务等待上限为 5 秒。
前端与路由
Vue 管理端位于 frontend/src,使用 Vue Router、Pinia 和按领域划分的 API client。生产构建写入 frontend/dist 并由 Rust 进程托管。
| 路径 | 作用 |
|---|---|
GET /healthz |
PostgreSQL/Redis 健康检查 |
POST /v1/responses |
Responses JSON 或 SSE |
GET /v1/responses |
Responses WebSocket upgrade |
POST /v1/responses/review |
Review 入口 |
GET /v1/models* |
模型目录和运行信息 |
/api/admin/* |
管理端 API |
| 其他非 API 路径 | Vue SPA 静态资源 |
客户端接口使用 sk_ API Key。管理端支持登录 Cookie 和管理员 API Key。请求 body 与 WebSocket 文本帧上限均为 16 MiB。