openai: align codex chain

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-15 08:39:28 +08:00
co-authored by Codex
parent 3d85682062
commit 1f77bbda06
100 changed files with 35037 additions and 1061 deletions
Generated
+3
View File
@@ -386,6 +386,8 @@ dependencies = [
"rand 0.10.1",
"reqwest",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"secrecy",
"serde",
"serde_json",
@@ -395,6 +397,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-rustls",
"tokio-tungstenite",
"tower",
"tower-http",
+3
View File
@@ -25,6 +25,8 @@ indexmap = { version = "2.0", features = ["serde"] }
rand = "0.10.1"
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "cookies", "rustls-tls-native-roots", "gzip", "brotli", "zstd", "deflate"] }
rustls = { version = "=0.23.36", default-features = false, features = ["ring", "std", "tls12"] }
rustls-native-certs = "0.8.4"
rustls-pki-types = "1.14.1"
secrecy = "0.10.3"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
@@ -33,6 +35,7 @@ sha2 = "0.11.0"
sqlx = { version = "0.9.0", features = ["runtime-tokio", "tls-rustls", "sqlite", "chrono", "uuid", "migrate"] }
thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "signal", "fs", "time", "sync", "net"] }
tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring", "tls12"] }
tokio-tungstenite = { version = "0.29.0", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
tower = { version = "0.5.3", features = ["util"] }
tower-http = { version = "0.6.11", features = ["trace", "cors", "request-id", "timeout"] }
+7
View File
@@ -16,6 +16,9 @@ auth:
request_interval_ms: 50
rotation_strategy: least_used
tier_priority: []
oauth_client_id: app_EMoamEEZ73f0CkXaXp7hrann
oauth_auth_endpoint: https://auth.openai.com/oauth/authorize
oauth_token_endpoint: https://auth.openai.com/oauth/token
quota:
refresh_interval_minutes: 5
warning_thresholds:
@@ -35,6 +38,10 @@ security:
api_key_pepper_file: data/api-key-pepper.key
tls:
force_http11: false
ws_pool:
enabled: true
max_age_ms: 3300000
max_per_account: 8
admin:
session_ttl_minutes: 1440
default_username: admin
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -165,7 +165,7 @@ curl -X POST http://127.0.0.1:8080/api/admin/accounts/import \
"data": {
"imported": 1,
"skipped": 0,
"sourceFormat": "sub2api"
"sourceFormat": "native"
}
}
```
+576
View File
@@ -0,0 +1,576 @@
# OpenAI 链路 100% 一致性复审计账本
日期:2026-06-14
目标:逐项复核 Rust 版 `codex-proxy-rs` 与原版 Node.js `codex-proxy` 的 OpenAI/Codex 相关链路是否达到行为级 100% 一致。每完成一个链路点,就把证据、结论和缺口写回本文档,避免审计结论只存在于上下文。
原版仓库:`/home/zyy/桌面/Codes/codex-proxy`
Rust 仓库:`/home/zyy/Codes/codex-proxy-rs`
明确非目标:原版的 IP 代理、VPN、本地代理探测、`HttpsProxyAgent`、账号代理池,以及所有非 OpenAI upstream family(例如 Anthropic、Gemini、Ollama、OpenRouter、Sub2API)不属于本项目移植范围,不纳入 100% 一致性判断。
## 审计方法
1. 先读原版实现,再读 Rust 实现,最后查测试是否覆盖该行为。
2. 每个条目必须记录“原版证据”“Rust 证据”“结论”“缺口/后续动作”。
3. 不能因为已有旧文档写过就直接沿用结论;旧文档只能作为索引,最终结论以本轮读到的代码为准。
4. 结论分为:
- `已对齐`:行为、边界条件和测试证据均匹配。
- `部分对齐`:主路径一致,但边界、状态机或测试覆盖不足。
- `未对齐`:原版存在明确行为,Rust 缺失或语义不同。
- `非目标`:用户明确要求不移植的能力。
## 总体结论
本轮 10 个 OpenAI/Codex 链路点已全部复核并写入账本。结论:当前不能声明与原版 100% 一致;除“代码证据索引与审计边界”外,其余链路均为 `部分对齐`。
最高优先级未对齐项:
- 安全模拟仍缺 TLS 指纹证明,以及 WS permessage-deflate 被上游接受后的压缩 frame 解码证明。
- `/v1/responses` 请求构造仍存在非 `/v1` alias route、`use_websocket:false` 本地扩展等差异;`/v1/responses/review` 强制 review subagent 和 `/v1/responses/compact` 已补齐。
- response 转换和错误恢复仍缺最终错误外壳对齐;tuple schema request conversion/reconvert、streaming premature close 合成、reasoning replay、implicit resume failure restore、`response.failed`/`error` 的 429/402/403 分类、账号 fallback、`previous_response_not_found`/unanswered function call strip-and-retry、5xx same-account retry、CF path-block 404、model unsupported fallback、401 token invalid fallback 已补齐。
- session affinity 的显式续链、implicit resume 主路径、implicit resume failure restore、reasoning replay cache、function call continuation 预检、variant_hash 基础算法、variant identity 和 OpenAI chat `user` conversation identity 已接入。
- rate-limit/usage/cookie 持久化主路径已接近,但 image_generation usage、dirty quota verification、失败请求持久计数和 cookie 过期清理仍有差异。
明确不纳入缺口:IP 代理、VPN、本地代理探测、`HttpsProxyAgent`、账号代理池。
明确不移植:非 OpenAI upstream family 的兼容路由、translator、cache usage hint 估算及其 `clientConversationId` 来源不属于本项目目标;后续只按 OpenAI `/v1/responses`、`/v1/responses/compact` 与 `/v1/chat/completions` 链路核对。
## 审计进度
| 序号 | 链路点 | 状态 | 结论 |
| --- | --- | --- | --- |
| 1 | 代码证据索引与审计边界 | 已完成 | 已建立 |
| 2 | 安全指纹与默认 headers | 已完成 | 部分对齐 |
| 3 | OAuth/device/refresh 链路 | 已完成 | 部分对齐 |
| 4 | `/v1/responses` 请求构造与 prompt cache identity | 已完成 | 部分对齐 |
| 5 | HTTP SSE 上游请求链路 | 已完成 | 部分对齐 |
| 6 | WebSocket 上游链路与连接池 | 已完成 | 部分对齐 |
| 7 | response/SSE/WS frame 转换链路 | 已完成 | 部分对齐 |
| 8 | fallback、retry、错误分类 | 已完成 | 部分对齐 |
| 9 | rate-limit、usage、quota、cookie 持久化 | 已完成 | 部分对齐 |
| 10 | session affinity、implicit resume、reasoning replay | 已完成 | 部分对齐 |
## 1. 代码证据索引与审计边界
原版证据:
- OpenAI/Codex 请求核心集中在 `src/proxy/codex-api.ts`、`src/proxy/responses-upstream.ts`、`src/proxy/ws-transport.ts`、`src/proxy/ws-pool.ts`、`src/routes/responses.ts`、`src/routes/shared/*`。
- 账号、OAuth、调度和亲和性集中在 `src/auth/*`。
- 指纹、安全 headers、安装标识和 Cookie 集中在 `src/fingerprint/manager.ts`、`src/proxy/installation-id.ts`、`src/proxy/cookie-jar.ts`。
- 原版还包含 IP 代理相关文件:`src/proxy/proxy-pool.ts`、`src/tls/proxy.ts`、`src/routes/proxies.ts` 等。
Rust 证据:
- OpenAI/Codex 请求核心集中在 `src/codex/gateway/transport/*`、`src/codex/serving/dispatch/*`、`src/codex/serving/responses.rs`、`src/codex/serving/chat.rs`。
- 账号、OAuth、调度和亲和性集中在 `src/codex/accounts/*`、`src/codex/gateway/oauth/*`、`src/codex/serving/dispatch/affinity.rs`。
- 指纹、安全 headers、安装标识和 Cookie 集中在 `src/codex/gateway/fingerprint/*`、`src/codex/gateway/transport/headers.rs`、`src/codex/gateway/installation_id.rs`、`src/codex/accounts/cookies/*`。
- Rust 项目当前没有引入 IP 代理/VPN 作为 OpenAI 上游能力,这符合用户明确要求。
结论:已建立本轮复审计索引。IP 代理/VPN 作为明确非目标,不参与一致性缺口计算。
缺口/后续动作:继续逐项核对安全指纹、请求链路、响应链路和状态机。不得在全部条目完成前声明 100% 一致。
## 2. 安全指纹与默认 headers
原版证据:
- `src/fingerprint/manager.ts:63-139` 从 config/fingerprint 构造 `Authorization`、`ChatGPT-Account-Id`、`originator`、`User-Agent`、`sec-ch-ua`、默认浏览器 headers,并按 `fingerprint.header_order` 重排。
- `config/fingerprint.yaml` 明确 header 顺序和默认 headers;`config/default.yaml` 提供 `originator`、`app_version`、`build_number`、`platform`、`arch`、`chromium_version`。
- `src/proxy/codex-api.ts:320-358` 的 WebSocket 请求追加 `OpenAI-Beta`、`x-openai-internal-codex-residency`、`x-client-request-id`、`x-codex-installation-id`、`session_id`、`x-codex-window-id`、turn/context headers、`x-openai-subagent`,并把 installation/window/turn/parent 写入 `client_metadata`。
- `src/proxy/codex-api.ts:375-414` 的 HTTP SSE 请求追加同一组 Responses 上游 headers,并把安全上下文同步到 body。
- `src/proxy/installation-id.ts:45-66` 的 installation ID 查找顺序是 `~/.codex/installation_id`、项目 data dir、生成并持久化。
- `src/proxy/codex-usage.ts:17-29` 和 `src/proxy/codex-models.ts:11-31` 的 usage/models 辅助请求只在调用方传入的基础 headers 上追加 `Accept: application/json`,非 impersonate transport 下把 `Accept-Encoding` 降到 `gzip, deflate`;不追加 Responses 专属的 `OpenAI-Beta` 和 `Content-Type`。
- `src/auth/health-check.ts:1-110` 的账号健康检查只走 OAuth refresh,不打 `chatgpt.com` Codex API,文件头说明这是为了避免触发风险检测。
Rust 证据:
- `src/codex/gateway/fingerprint/model.rs:17-95` 内置默认 Codex Desktop 指纹,包含 originator、版本、平台、UA 模板、默认 headers、header_order。
- `src/codex/gateway/transport/headers.rs:5-74` 构造认证、账号、originator、residency、request id、turn state、UA、sec-ch 和默认 headers。
- `src/codex/gateway/transport/http_client.rs:452-520` 在请求 headers 中注入 cookie、installation、session、turn metadata、beta features、timing metrics、version、window、parent、`Content-Type`、`OpenAI-Beta`、`x-openai-subagent`。
- `src/codex/gateway/transport/http_client.rs:523-600` 会把 `session_id` 写回 `prompt_cache_key`,并把 installation/window/turn/parent 写入 `client_metadata`。
- `src/codex/gateway/transport/websocket/mod.rs:419-440` WebSocket upgrade 复用同一份 HeaderMap,所以 WS 和 HTTP SSE 主链路共享安全 headers。
- `src/codex/gateway/installation_id.rs:21-79` 实现了与原版一致的 installation ID 查找和持久化顺序。
- `src/runtime/state.rs` 会把应用启动时加载的 fingerprint 传入 `/v1/responses`、`/v1/chat/completions`、显式配额查询和模型刷新使用的 upstream client。
- 本轮修正后,`src/codex/accounts/service/health.rs` 的账号健康检查已回到原版 OAuth refresh-only 语义,不再访问 Codex usage endpoint;`tests/admin/accounts/cookies_quota.rs` 覆盖 health check 不触碰 `/api/codex/usage`。
- 本轮修正后,`src/codex/accounts/service/mod.rs` 持有 AppState 注入的 fingerprint,`fetch_account_usage()` 使用该 fingerprint 创建 Codex client;`tests/admin/accounts/cookies_quota.rs` 覆盖显式配额查询的 `User-Agent` 与 `sec-ch-ua` 来自注入 fingerprint。
- 本轮修正后,`src/codex/models/service.rs` 持有 AppState 注入的 fingerprint,模型刷新使用该 fingerprint 创建 Codex client;`tests/admin/models_route.rs` 覆盖 `/codex/models` 请求的 `User-Agent` 与 `sec-ch-ua` 来自注入 fingerprint。
- 本轮修正后,`src/codex/serving/diagnostics.rs` 持有 AppState 注入的 fingerprint,`/debug/upstream` 探测 `/codex/models` 时使用同一份运行时 fingerprint;`tests/codex_serving/diagnostics_route.rs` 覆盖诊断 JSON 与真实上游探测请求头均使用注入 fingerprint。
- 本轮修正后,`src/codex/gateway/transport/http_client.rs` 已拆分 HTTP SSE、WebSocket、usage/models 辅助请求 header profile:WS 不携带 `Content-Type`/`Accept: text/event-stream`;usage/models 只携带基础身份 header、Cookie、`Accept: application/json` 和 `Accept-Encoding: gzip, deflate`。
- 本轮修正后,`src/codex/gateway/fingerprint/model.rs` 与 `src/codex/gateway/fingerprint/repository.rs` 的 `user_agent_template` 使用原版 `{version}` 占位符,而不是 Rust 临时的 `{app_version}`;`Fingerprint::user_agent()` 也按原版替换 `{version}`。`tests/codex_gateway/headers.rs::fingerprint_user_agent_should_reflect_updated_version_fields` 先以原版模板失败,再修复通过,覆盖数据库/auto-update fingerprint 发往上游时不会泄漏未替换占位符。
- 本轮修正后,`src/codex/gateway/fingerprint/model.rs` 的默认 `header_order` 回到原版 `config/fingerprint.yaml` 形状,不再包含 `session_id`、`x-codex-window-id`、turn metadata、beta features、timing、version、parent 等动态追加头;`tests/codex_gateway/headers.rs::fingerprint_default_header_order_should_match_original_config` 覆盖该配置。
- 本轮修正后,`src/codex/gateway/transport/http_client.rs` 的 HTTP SSE 与 compact header 构造按原版真实顺序执行:基础指纹头先按 `header_order` 排序,Cookie 先注入,随后按 `codex-api.ts` 赋值顺序追加 `Accept`/`OpenAI-Beta`/residency/request id/installation/session/window/turn/context 等动态头;`tests/codex_gateway/http_client.rs::codex_backend_client_should_send_http_sse_headers_in_fingerprint_order` 和 `codex_backend_client_should_send_compact_headers_in_fingerprint_order` 通过 raw TCP 请求验证 wire order。
- `tests/codex_gateway/websocket.rs:273-375` 覆盖 WS 安全 body 字段、`prompt_cache_key`、`client_metadata`、`x-client-request-id`、`x-openai-subagent`,并断言 WS handshake 不带 `Content-Type`/`Accept`。
- `tests/codex_gateway/http_client.rs` 覆盖 usage/models 辅助请求不携带 `Content-Type`、`OpenAI-Beta`、residency、request id、installation id 和 session id。
- `tests/codex_serving/responses_http_sse.rs:403-513` 覆盖 HTTP SSE 安全 headers、派生 identity、`client_metadata` 和 installation UUID。
结论:部分对齐。
已对齐:
- `/v1/responses` 主链路的字段级安全上下文已基本对齐:authorization、account id、originator、residency、request id/session id、installation id、window id、turn metadata、parent thread、subagent、body `client_metadata` 均已进入 HTTP SSE 和 WS 上游路径。
- installation ID 查找顺序和持久化行为与原版一致。
- `User-Agent` 模板占位符已回到原版 `{version}`,auto-update fingerprint 与默认 fingerprint 生成的上游 header 不再使用 Rust 自造的 `{app_version}` 语义。
- 应用主请求链路会使用启动时加载的最新 fingerprint,而不是永远使用硬编码默认值。
- WebSocket handshake headers 已按原版拆分,不再携带 HTTP SSE/body 语义的 `Content-Type` 和 `Accept: text/event-stream`。
- usage/models 辅助请求 headers 已按原版拆分,不再复用 Responses 主链路的 `Content-Type`、`OpenAI-Beta`、residency、request id、installation id、session id。
- HTTP SSE 与 compact 的真实 wire header 相对顺序已按原版构造顺序覆盖,包括默认 `header_order`、Cookie 注入位置和动态安全头追加顺序。
- 账号健康检查已按原版安全边界改为只走 OAuth refresh,不再访问 `chatgpt.com` Codex API。
- 显式配额查询与模型刷新已复用 AppState 加载的 fingerprint,不再各自创建默认 fingerprint。
- 本地诊断的 `/debug/upstream` 探测请求已复用 AppState 加载的 fingerprint,不再创建独立默认 fingerprint。
未完全对齐:
- Rust 当前没有测试证明真实传输层 TLS/HTTP 指纹与原版 native transport 行为完全一致。IP 代理/VPN 明确非目标,但 TLS/header 指纹仍属于安全模拟链路,不能在未验证时声明 100%。
- WebSocket 已按原版 `ws@8.19.0` 发出 `Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits`,但 Rust 后续 frame 解码仍由 `tungstenite` 负责;若上游真的协商并发送压缩 frame,当前还没有真实上游证据证明不会触发 RSV/压缩解码问题。
缺口/后续动作:
- 若目标是安全模拟 100%,需要继续处理 TLS 指纹和 WS compression negotiation 的真实上游验证;TLS 是否要追到 native transport 级别需要单独决策。
## 3. OAuth/device/refresh 链路
原版证据:
- `src/auth/oauth-pkce.ts:101-128` 构造 PKCE 授权 URL,参数包含 `response_type=code`、Codex `client_id`、`redirect_uri`、`scope=openid profile email offline_access`、`code_challenge_method=S256`、`id_token_add_organizations=true`、`codex_cli_simplified_flow=true`、`originator=codex_cli_rs`。
- `src/auth/oauth-pkce.ts:244-276` 固定 OAuth callback 为 `http://localhost:1455/auth/callback`,注释说明 OpenAI 只白名单这个地址。
- `src/auth/oauth-pkce.ts:133-163` 授权码换 token 使用 `application/x-www-form-urlencoded`,字段为 `grant_type=authorization_code`、`client_id`、`code`、`redirect_uri`、`code_verifier`。
- `src/auth/oauth-pkce.ts:171-240` refresh 使用 form body:`grant_type=refresh_token`、`client_id`、`refresh_token`;保护一次性 RT,只有确认请求没到服务器的 pre-flight 错误才继续 fallback。
- `src/auth/oauth-pkce.ts:476-532` device flow 使用 `/oauth/device/code` 和 token endpoint,字段与 RFC device code flow 一致。
- `src/auth/refresh-scheduler.ts:21-32` permanent error 分为 banned 与 expired,且 `PERMANENT_THRESHOLD = 2`,连续两次才标记状态。
- `src/auth/refresh-scheduler.ts:174-192` 全局 refresh 并发由 `auth.refresh_concurrency` 限制。
- `src/auth/refresh-lock.ts:1-70` 每账号 refresh 有跨进程锁,锁 5 分钟过期。
- `src/auth/health-check.ts:1-110` 账号健康检查只调用 OAuth refresh,不访问 Codex API。
Rust 证据:
- 本轮修正后,`src/config/types.rs` 和 `config.yaml` 已加入 `oauth_client_id`、`oauth_auth_endpoint`、`oauth_token_endpoint`;`src/codex/gateway/oauth/client.rs` 通过 `OAuthConfig::from_auth_config()` 从 `AuthConfig` 派生 OAuth 参数,device code endpoint 保持原版固定值。
- 本轮修正后,`src/runtime/bootstrap.rs` 的 `OpenAiOAuthRefresher` 使用配置派生的 `OAuthConfig`,不再硬编码 token endpoint 或 client id。
- 本轮修正后,`src/admin/session/service.rs` 的 PKCE login 使用 AppState 注入的 `OAuthConfig`,`tests/admin/accounts/oauth.rs` 覆盖自定义 auth endpoint 与 client id 会进入授权 URL。
- `src/codex/gateway/oauth/client.rs:363-387` 构造 PKCE 授权 URL,参数集合与原版基本一致。
- 本轮修正后,`src/codex/gateway/oauth/client.rs` 固定 callback 为 `http://localhost:1455/auth/callback`,与原版白名单路径一致。
- `src/admin/api/router.rs` 只注册 `/auth/callback`,不注册 `/auth/openai/callback` 或 `/api/admin/auth/callback`。
- `tests/admin/accounts/oauth.rs` 覆盖授权 URL、code relay、直接 callback route 和非原版 path 404;`tests/codex_gateway/oauth_refresh.rs` 覆盖 authorization code exchange body 中的 redirect URI。
- `src/codex/gateway/oauth/client.rs:214-319` 授权码、device code、device poll、refresh token 请求均为 form body;`tests/codex_gateway/oauth_refresh.rs:10-124` 覆盖了请求字段。
- `src/codex/tasks/token_refresh.rs:36-50` 定义了与原版一致的重试次数、退避基数、恢复延迟、permanent threshold 和 banned/expired 错误集合。
- `src/codex/tasks/token_refresh.rs:52-65` 用 `Semaphore` 实现全局 refresh 并发限制。
- `src/codex/accounts/service/refresh.rs:45-68` 用 SQLite refresh lease 实现跨进程/跨实例防重,TTL 5 分钟。
- 本轮修正后,`src/codex/accounts/service/refresh.rs` 提供 `probe_account_refresh()`,用于执行 OAuth refresh 探测但失败不直接落库状态;admin/manual refresh 仍通过 `refresh_account()` 应用结果。
- 本轮修正后,`src/codex/tasks/token_refresh.rs` 后台调度器调用 `probe_account_refresh()`,只有 `AccountProbeOutcome::Alive` 视为成功,`Dead` 会进入重试与 permanent threshold 判断;第二次 permanent failure 后才写入 expired/banned。
- `src/codex/tasks/token_refresh.rs` 单元测试 `do_refresh_inner_should_mark_expired_only_after_second_permanent_failure` 覆盖连续两次 `InvalidGrant` 前数据库状态仍为 `active`,第二次失败后才写入 `expired`。
- `src/codex/gateway/oauth/client.rs:402-423` 把 `quota`、`banned`、`token_revoked` 等映射为结构化 `RefreshFailure`。
- 本轮修正后,`src/codex/accounts/service/health.rs` 健康检查通过 `refresh_account()` 调用 OAuth refresh token 链路,不再访问 Codex usage endpoint;`tests/admin/accounts/cookies_quota.rs` 断言 `/api/codex/usage` 调用次数为 0。
结论:部分对齐。
已对齐:
- OAuth 授权码交换、refresh token 交换、device code request、device poll 的 form body 字段与原版主路径一致。
- OAuth client id、authorize endpoint、token endpoint 已进入配置面,并由生产 bootstrap、后台 refresh client、admin PKCE login 共用同一份配置。
- PKCE redirect URI 和 callback route 已改为原版固定白名单路径 `http://localhost:1455/auth/callback`。
- PKCE verifier/challenge、state、5 分钟 session TTL、并发交换保护和 completed session 语义基本一致。
- 全局 refresh 并发限制已经存在。
- 每账号 refresh 防重已经存在,Rust 用 SQLite lease 替代文件锁,语义上更适合当前数据库架构。
- refresh 成功时保留未轮换的旧 refresh_token,这一点符合原版保护逻辑。
- 后台 refresh 调度器已不再把 `Dead` probe 误判为成功;scheduler 路径 permanent failure 的状态写入已延后到连续两次失败。
- 账号健康检查已回到原版 OAuth refresh-only 安全边界。
未完全对齐:
- admin/manual refresh 仍在第一次结构化 permanent failure 后立即更新状态;原版 health check 也会立即 mark expired,但后台 scheduler 已按原版 threshold=2。若要所有手动路径也完全同构,需要重新定义 admin 手动刷新语义。
- 原版 refresh 对一次性 RT 的网络错误非常谨慎,只有请求确认未到服务器时才 fallback/retry;Rust 当前 `reqwest` refresh client 不能区分 pre-flight 与 mid-flight 失败,且 runtime 调度器错误处理路径与原版不等价。
缺口/后续动作:
- 增加 transport error 的后台恢复调度测试,覆盖非 permanent 错误耗尽重试后进入 10 分钟恢复调度。
## 4. `/v1/responses` 请求构造与 prompt cache identity
原版证据:
- `src/routes/responses.ts:75-87` 对非对象 JSON body 返回 400,错误码为 `invalid_request`。
- `src/routes/responses.ts:117-202` 构造 `CodexResponsesRequest`,字段包括 `model`、`instructions`、sanitized `input`、`stream: true`、`store: false`、默认 `useWebSocket = true`、`previous_response_id`、`prompt_cache_key`、`include`、`reasoning`、`service_tier`、`tools`、`tool_choice`、`parallel_tool_calls`、`text.format`。
- `src/routes/responses.ts:130-142` 的 `x-openai-subagent` 优先级是 review route 强制 `review`,否则 header 优先,再读 `client_metadata`;`sanitizeClientMetadata()` 只保留字符串值,并移除非法 subagent。
- `src/proxy/reasoning-input-sanitizer.ts:1-75` 对 `input` 中的 `reasoning` 和 `compaction` 项做白名单清洗:reasoning 需要非空 `id` 和 `summary`,只保留合法 `status`、`encrypted_content`、`content`;compaction 需要非空 `encrypted_content`。
- `src/routes/shared/proxy-session-helpers.ts:41-60` 的 prompt cache identity 优先级是显式 `prompt_cache_key`、`clientConversationId`、稳定派生 key、随机 UUID。
- `src/routes/shared/stable-conversation-key.ts:1-43` 稳定派生 key 使用 `model + "\0" + instructions前2000字符 + "\0" + 首条 user 文本`,并会剥离开头的 `<system-reminder>...</system-reminder>`。
- `src/routes/shared/proxy-request-preparation.ts:17-28` 在进入上游前强制写入 `prompt_cache_key`;当存在 `reasoning` 且 `include` 为空时,才设置 `["reasoning.encrypted_content"]`。
- `src/routes/responses.ts:268-274` 还注册 `/v1/responses/review`、`/responses`、`/responses/review`、`/v1/responses/compact`。
Rust 证据:
- `src/codex/serving/http/responses.rs:13-30` 路由层直接读取 raw `Bytes` 并交给 service。
- 本轮修正后,`src/codex/serving/responses.rs` 先按 JSON object 解析 `/v1/responses` body;非法 JSON 或非 object body 直接返回 400 `invalid_request`,不再访问上游。
- `src/codex/serving/responses.rs` 构造 `CodexResponsesRequest` 时按原版宽松读取字段:类型不匹配的字段被忽略,`input` 会先清洗 reasoning/compaction 项,`client_metadata` 只保留字符串值。
- `src/codex/serving/responses.rs` 的 subagent 优先级是 header 优先,再读已清洗的 `client_metadata`,没有 review route 强制逻辑。
- `src/codex/serving/responses.rs:105-112` 的 reasoning effort 与 service tier 主路径和原版一致:显式 body 优先,其次模型后缀,最后配置默认值;`fast` 会在 dispatch 层规范成 `priority`。
- 本轮修正后,`ensure_reasoning_include()` 仅在存在 `reasoning` 且 `include` 为空时设置 `["reasoning.encrypted_content"]`。
- `src/codex/gateway/conversation_identity.rs:58-90` 的稳定 prompt cache key 派生规则与原版 `stable-conversation-key.ts` 对齐,包含 instructions 前 2000 字符、首条 user 文本、system-reminder 剥离、无锚点时随机 UUID。
- `src/codex/serving/dispatch/mod.rs:143-164` 对显式 `previous_response_id` 会继承已记录 conversation id 和 turn state;否则调用 `ensure_prompt_cache_key()`。
- 本轮修正后,`src/codex/serving/http/router.rs` 注册 `/v1/responses/review`,`src/codex/serving/http/responses.rs` 在该路由强制注入 `x-openai-subagent: review`,复用普通 Responses 发送链路。
- 本轮修正后,`src/codex/serving/http/router.rs` 注册 `/v1/responses/compact`,`src/codex/serving/responses.rs` 解析并清洗 compact body,`src/codex/gateway/transport/http_client.rs` 固定 `POST /codex/responses/compact`,使用 JSON response 链路。
- `src/codex/serving/http/router.rs` 仍不注册原版的 `/responses`、`/responses/review` 非 `/v1` alias。
- `tests/codex_serving/responses_http_sse.rs:403-562` 覆盖了字段转发、service tier 规范化、metadata/body 安全字段和默认 reasoning include。
- `tests/codex_serving/responses_websocket.rs:47-110` 覆盖默认 WebSocket 上游、派生 `prompt_cache_key` 和安全 metadata;`tests/codex_gateway/websocket.rs:78-85` 覆盖本地 `use_websocket` 不序列化到上游。
结论:部分对齐。
已对齐:
- `/v1/responses` 主路径字段覆盖基本完整:模型解析、instructions、input、reasoning、service tier、tools、tool choice、parallel tool calls、text format、prompt cache key、include、client metadata、previous response id、Codex context headers 均有对应实现。
- 稳定 prompt cache key 派生规则与原版一致,包含 system-reminder 剥离和随机 UUID 兜底。
- `use_websocket`/`force_http_sse` 是本地传输控制字段,不会序列化给上游,这一点有测试覆盖。
- 非法 JSON 或非 object body 已返回 400 `invalid_request`,并由 `tests/codex_serving/responses_http_sse.rs` 覆盖“不访问上游”。
- `input` reasoning/compaction 白名单清洗已对齐原版,并由 `v1_responses_should_sanitize_reasoning_and_compaction_input_before_upstream` 覆盖。
- `client_metadata` 已只保留字符串值,再注入合法 `x-openai-subagent`。
- `reasoning.encrypted_content` include 策略已改为原版语义:只在 `include` 为空时补齐。
- OpenAI chat 的 `clientConversationId` 来源已由 `/v1/chat/completions` 的 `user` 字段接入;`/v1/responses` 原生入口没有该字段,原版 shared handler 中非 OpenAI 分支的额外来源属于非目标。
- `/v1/responses/review` 已按原版强制 `x-openai-subagent=review` 进入上游 header 和 body `client_metadata`;`tests/codex_serving/responses_http_sse.rs::v1_responses_review_route_should_force_review_subagent_upstream` 覆盖客户端未显式传 subagent 时的上游请求。
- `/v1/responses/compact` 已按原版走非 streaming JSON compact 链路:只转发 `model`、sanitized `input`、`instructions`、非空 `tools`、`parallel_tool_calls`、仅含 `effort/summary` 的 `reasoning` 和 `text.format`,不转发 `stream`、`store`、`prompt_cache_key`。`tests/codex_serving/responses_http_sse.rs::v1_responses_compact_should_post_json_to_codex_compact_upstream` 先以 404 失败,再修复通过。
未完全对齐:
- 路由表面仍有差异。Rust 没有 `/responses`、`/responses/review` 非 `/v1` alias。非 `/v1` alias 属于旧入口兼容,按当前“只要 OpenAI 规范链路”的边界暂不移植。
- 客户端传输开关不一致。原版 `/v1/responses` 总是内部设置 `useWebSocket = true`,不从 body 读取 `use_websocket:false`;Rust 支持 `use_websocket:false` 强制 HTTP SSE。这是本项目明确保留的本地传输控制扩展,不作为上游 OpenAI 字段发送。
缺口/后续动作:
- `use_websocket:false` 已明确作为本项目本地扩展保留;后续审计只需确保它不进入上游 OpenAI 请求体。
- 非 `/v1` alias 若不需要公开,应正式标为非目标;非 OpenAI/Codex adapter 分支不进入本项目实现。
## 5. HTTP SSE 上游请求链路
原版证据:
- `src/proxy/codex-api.ts:365-418` 的 HTTP SSE 请求固定 `POST {baseUrl}/codex/responses`,headers 使用 `buildHeadersWithContentType()` 后追加 `Accept: text/event-stream`、`OpenAI-Beta: responses_websockets=2026-02-06`、`x-openai-internal-codex-residency: us`、`x-client-request-id`、`x-codex-installation-id`、`session_id`、`x-codex-window-id`、turn/context headers、`x-openai-subagent`。
- `src/proxy/codex-api.ts:389-416` 会从 body 中剥离 `previous_response_id`、`useWebSocket`、`turnState`、`turnMetadata`、`betaFeatures`、`version`、`includeTimingMetrics`、`codexWindowId`、`parentThreadId`,再写入上游 `prompt_cache_key` 和 `client_metadata`。
- `src/proxy/codex-api.ts:107-128` 使用 `entryId ?? accountId ?? "anonymous"` 对客户端 `prompt_cache_key` 与 `x-codex-window-id` 做账号作用域哈希,得到 `cp_...` 与 `cw_...`。
- `src/proxy/codex-api.ts:193-207` 从 CookieJar 注入 Cookie,并在 HTTP SSE 响应后 `captureCookies()` 保存 `Set-Cookie`。
- `src/proxy/codex-api.ts:420-449` 对非 2xx 读取最多 1 MiB 错误体并抛 `CodexApiError(status, body, headers)`。
- `src/proxy/codex-api.ts:457-508` 的 compact 请求固定 `POST {baseUrl}/codex/responses/compact`,headers 使用 `buildHeadersWithContentType()` 后追加 `OpenAI-Beta: responses_websockets=2026-02-06`、`x-openai-internal-codex-residency: us`、随机 `x-client-request-id`、`x-codex-installation-id`;不设置 `Accept: text/event-stream`,读取完整 JSON body。
- `src/routes/responses-compact.ts:44-104` 构造 `CodexCompactRequest`,只包含 `model`、sanitized `input`、`instructions`、`tools`、`parallel_tool_calls`、`reasoning.effort/summary`、`text.format`,不包含 `stream`、`store`、`prompt_cache_key`。
- `src/tls/native-transport.ts:75-117` HTTP stream post 接受 `AbortSignal`,并通过 native binding 的 `httpPostStream` 返回 `ReadableStream` 与 `setCookieHeaders`。
- `native/src/lib.rs:31-64` 原版 native addon 缓存 reqwest client,key 为 `(proxy_url, force_http11)`,并设置 `use_rustls_tls()`、`pool_max_idle_per_host(4)`、`tcp_keepalive(30s)`、可选 `http1_only()` 和可选 proxy。
- `native/Cargo.toml:9-24` 原版 native transport 使用 `reqwest 0.12.28`、`rustls = 0.23.36`,并启用 `rustls-tls-native-roots`、`stream`、`gzip`、`brotli`、`zstd`、`deflate`、`http2`、`socks`。
- `src/routes/shared/proxy-stagger.ts:1-25` 在同账号存在 previous slot 时,按 `request_interval_ms` 乘以 ±30% jitter 计算目标间隔,再扣除 elapsed 后等待。
Rust 证据:
- 本轮修正后,`src/codex/gateway/transport/http_client.rs` 的 HTTP SSE 非 2xx 错误体最多读取 1 MiB 后返回 `CodexClientError::Upstream`,成功 SSE body 仍完整读取。
- `src/codex/gateway/transport/http_client.rs:428-438` 实际发送使用 `reqwest::Client::post(url).headers(headers).json(request).send()`。
- `src/codex/gateway/transport/http_client.rs:452-520` headers 会注入认证、account id、originator、request id/session id、Cookie、installation id、session_id、turn metadata、beta features、timing metrics、version、window、parent、`Content-Type: application/json`、`OpenAI-Beta`。
- `src/codex/gateway/transport/http_client.rs:523-600` 会把 `session_id` 写入 `prompt_cache_key`,并把 installation/window/turn metadata/parent 写入 body `client_metadata`;metadata 只保留字符串值。
- `src/codex/serving/dispatch/mod.rs:555-585` 和 `590-660` 分别为非流式与流式 HTTP SSE 请求准备 Cookie、账号作用域 conversation/window identity、installation id、reqwest client 与 `CodexRequestContext`。
- `src/codex/serving/dispatch/mod.rs:338-476` 流式 HTTP SSE 将上游 bytes stream 直接透传给客户端,并每 15 秒补 `: ping` heartbeat;结束后由 `StreamAudit` 记录 usage、affinity 和 rate-limit。
- 本轮修正后,`src/codex/gateway/transport/types.rs` 增加 `CodexCompactRequest`,字段严格限制为原版 compact request;`src/codex/gateway/transport/http_client.rs` 增加 `create_compact_response()`,固定发送 `POST /codex/responses/compact`,使用 compact 专用 headers,不携带 `Accept: text/event-stream`。
- 本轮修正后,`src/codex/serving/dispatch/mod.rs` 增加 compact 上游发送入口,复用账号 Cookie、installation id、rate-limit header 解析和 5xx 同账户重试;不生成 Responses 会话专属的 `session_id`、`x-codex-window-id`、turn/context headers。
- 本轮修正后,`src/codex/serving/responses.rs` 的 `/v1/responses/compact` body 构造只保留原版 compact 字段,并复用 reasoning/compaction input 白名单清洗。
- 本轮修正后,`tests/codex_gateway/http_client.rs` 通过 raw TCP 请求验证 HTTP SSE 和 compact 请求的业务 header 相对顺序:基础指纹头、Cookie 注入位置、HTTP SSE `Accept`、`OpenAI-Beta`、residency、request/session id、installation、window、turn/context headers 与原版 `codex-api.ts` 赋值顺序一致。
- 本轮修正后,`src/codex/serving/dispatch/mod.rs` 的 `stagger_request_with_deps()` 会在同账号 previous slot 存在时按 `request_interval_ms` 的 ±30% jitter 计算等待时间;`tests/codex_serving/responses_http_sse.rs` 覆盖同账号并发请求发送上游前会等待,`jitter_request_interval_ms_with_factor_should_match_original_bounds` 覆盖 0.7/1.3 边界。
- 本轮修正后,`src/codex/gateway/transport/http_client.rs` 的 Rust reqwest client 按 `force_http11` 缓存复用,使用 `reqwest = 0.12.28`、`rustls = 0.23.36`,启用 rustls/native roots、`pool_max_idle_per_host(4)`、`tcp_keepalive(30s)`、gzip/brotli/zstd/deflate,并可选 `http1_only()`;同时显式 `.no_proxy()`。
- `tests/codex_gateway/http_client.rs:13-78` 覆盖 HTTP SSE POST、desktop headers、Cookie 注入、`Set-Cookie` 捕获、turn state 捕获和 usage 提取。
- `tests/codex_serving/responses_http_sse.rs:403-513` 覆盖 `/v1/responses` HTTP SSE 路径的 context headers、account-scoped prompt cache key、metadata 与 service tier。
结论:部分对齐。
已对齐:
- HTTP SSE 主请求的 URL、method、SSE Accept、Content-Type、OpenAI-Beta、residency、request/session id、installation id、window id、turn/context headers、subagent、Cookie 注入、Set-Cookie 捕获、rate-limit/turn-state 捕获均有对应实现。
- 上游 body 会使用账号作用域 `prompt_cache_key`,并将安全链路字段同步到 `client_metadata`。
- 非 2xx 错误体读取上限已对齐原版 1 MiB,并由 `codex_backend_client_should_cap_non_success_error_body_at_one_mib` 覆盖。
- reqwest client 已按 `force_http11` 缓存复用,并对齐原版 `pool_max_idle_per_host(4)` 与 `tcp_keepalive(30s)`;`build_reqwest_client_should_reuse_cached_connection_pool` 覆盖跨 builder 调用的连接池复用。
- `request_interval_ms` 已生效并按原版 ±30% jitter 计算同账号请求间隔。
- Rust 与原版 native transport 使用同一 reqwest/rustls 主版本组合,且都支持 `force_http11`。
- `/v1/responses/compact` 已对齐原版 JSON 上游链路:URL/method、核心 headers、Cookie/Set-Cookie、非 SSE Accept、body 字段白名单、reasoning/text 清洗和 JSON 响应透传均有实现;`v1_responses_compact_should_post_json_to_codex_compact_upstream` 覆盖该路径。
- HTTP SSE 与 compact 请求的真实 wire header 相对顺序已由 raw TCP 测试覆盖,不再只是字段级断言。
- IP 代理/VPN/proxy config/HttpsProxyAgent 是用户明确排除的非目标。Rust `.no_proxy()` 与原版 proxy 支持的差异不计入本项目 100% 一致性缺口,但需要保留边界说明。
未完全对齐:
- 原版 native transport 对 HTTP stream post 使用 JS `AbortSignal` 主动中断;Rust 依赖 response/body stream drop 和 reqwest future 生命周期,中断语义接近但没有对应测试证明客户端断开时上游请求一定及时终止。
- 原版 native client builder 没有显式 `.no_proxy()`,而是通过 proxy 参数控制;Rust 显式 `.no_proxy()` 符合非代理目标,但也意味着环境代理不会生效。此项按非目标处理,不算 OpenAI 链路缺口。
缺口/后续动作:
- 增加 HTTP SSE golden test:断言上游 body 不包含本地控制字段,header/body 中 account-scoped identity、metadata、subagent、turn/context 字段与原版一致。
- 若要宣称安全模拟 100%,还需要处理 WS handshake header order、header casing、TLS 指纹和客户端断开 abort 语义;当前 HTTP SSE/compact order 已有 wire-level 证据。
## 6. WebSocket 上游链路与连接池
原版证据:
- `src/routes/responses.ts:125-131` `/v1/responses` 默认设置 `codexRequest.useWebSocket = true`;`src/proxy/codex-api.ts:276-307` WebSocket 失败时,若带 `previous_response_id` 则不能降级 HTTP SSE,否则可降级 HTTP SSE。
- `src/proxy/codex-api.ts:313-360` WebSocket URL 为 `{baseUrl}/codex/responses` 的 `wss://`/`ws://` 版本,handshake headers 使用 `buildHeaders()` 加 `OpenAI-Beta`、residency、request/session id、installation/window/turn/context/subagent。
- `src/proxy/ws-transport.ts:155-171` 只向 `ws` constructor 传 `{ headers }`;原版依赖 `ws@8.19.0` 默认 opening handshake。抓包证据显示默认顺序包含 `Host`、`Connection`、`Upgrade`、`Sec-WebSocket-Version`、`Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits`、`Sec-WebSocket-Key`,随后按传入 headers 对象顺序输出业务 headers。
- `src/proxy/codex-api.ts:335-359` `response.create` frame 包含 `model`、`instructions`、`input`、`store:false`、`stream:true`、`previous_response_id`、`reasoning`、`tools`、`tool_choice ?? "auto"`、`parallel_tool_calls ?? true`、`text`、normalized `service_tier`、account-scoped `prompt_cache_key`、`include`、`client_metadata`。
- `src/proxy/ws-transport.ts:238-287` 优先使用 pool;busy/cap/dead/disabled/no_key 时 bypass 到 one-shot;复用连接 stale failure 时 one-shot 重试一次。
- `src/proxy/ws-transport.ts:310-316` one-shot WebSocket handshake 有 20 秒 open timeout。
- `src/proxy/ws-transport.ts:411-445` `codex.rate_limits` 内部事件只回调 rate-limit,不转发给客户端;首个可见错误 frame 会被分类成 `CodexApiError`;terminal event 后关闭 stream。
- `src/proxy/ws-pool.ts:535-539` 默认连接池配置为 `enabled=true`、`maxAgeMs=3_300_000`、`maxPerAccount=8`。
- `src/proxy/ws-pool.ts:156-244` pooled WS 每 25 秒 ping,默认 liveness timeout 为 ping interval 的 2.5 倍;busy 时不 ping。
- `src/proxy/ws-pool.ts:566-704` pool 以 `${entryId}:${conversationId}` 为 key,严格单请求 in-flight;busy 时 bypass;超过账号 cap 时 bypass;GC 清理 dead/expired idle;`evictByEntryId()` 会对账号状态变化做级联驱逐。
- `src/routes/shared/proxy-ws-context.ts:21-43` 只有在 `useWebSocket` 且存在稳定 `conversationId` 时才构造 pool context;显式 `previous_response_id` 续链即使 variantHash 变化,也沿用同一 upstream response chain。
- `src/config-schema.ts:172-181` 暴露 `ws_pool.enabled`、`ws_pool.max_age_ms`、`ws_pool.max_per_account` 配置。
Rust 证据:
- `src/codex/gateway/transport/websocket/mod.rs:143-154` 默认 `WebSocketPreferred`,带 `previous_response_id` 时 `WebSocketRequired`,`force_http_sse` 时走 HTTP SSE。
- `src/codex/gateway/transport/http_client.rs:143-179` 普通 create_response 先尝试 WebSocket;若是 preferred 且错误允许降级,则回落 HTTP SSE;`previous_response_id` required 不降级。
- `src/codex/gateway/transport/websocket/mod.rs:405-450` WebSocket URL 构造与原版一致,`https://` 转 `wss://`,`http://` 转 `ws://`,路径为 `/codex/responses`。
- `src/codex/gateway/transport/websocket/codec.rs:14-54` `response.create` frame 字段与原版主路径一致:`tool_choice` 默认 `auto`,`parallel_tool_calls` 默认 `true`,并带 `previous_response_id`、reasoning/tools/text/service_tier/prompt_cache_key/include/client_metadata。
- `src/codex/gateway/transport/http_client.rs:523-600` 在进入 WS 前把 account-scoped `session_id` 写入 `prompt_cache_key`,并写入 body `client_metadata`。
- 本轮修正后,WS handshake header profile 已独立于 HTTP SSE profile,不再携带 `Content-Type` 与 `Accept: text/event-stream`;`tests/codex_gateway/websocket.rs` 已覆盖该行为。
- 本轮修正后,`src/codex/gateway/transport/websocket/opening.rs` 不再使用 `tokio-tungstenite` 默认 `HeaderMap` opening 序列化,而是显式生成原版 `ws@8.19.0` opening request:`Sec-WebSocket-Extensions` 位于 `Sec-WebSocket-Key` 前,业务 headers 按原版对象插入顺序输出,并恢复 `Authorization`、`ChatGPT-Account-Id`、`User-Agent`、`Accept-Encoding`、`Accept-Language`、`OpenAI-Beta`、`Cookie` 等原版大小写。
- `tests/codex_gateway/websocket.rs::websocket_handshake_should_offer_original_permessage_deflate_extension` 通过 raw TCP 断言 WS opening request 的关键 header 顺序、大小写和 permessage-deflate offer 与原版抓包一致。
- `src/codex/gateway/transport/websocket/mod.rs:230-328` pooled/reused WS stale failure 会重试一次 one-shot;内部 `codex.rate_limits` 会转为 rate-limit headers;terminal event 转为 SSE chunk。
- 本轮修正后,`src/codex/gateway/transport/websocket/mod.rs` 的 one-shot WebSocket handshake 使用 20 秒 open timeout;`connect_websocket_with_timeout_should_fail_when_handshake_stalls` 覆盖握手停滞时返回 timeout。
- `src/codex/gateway/transport/websocket/pool.rs:58-76` 默认 `max_age=55min`、`max_per_account=8`、`maintenance_interval=25s`、`ping_interval=25s`、`ping_timeout=5s`、`liveness_timeout=62.5s`。
- `src/codex/gateway/transport/websocket/pool.rs:129-170` pool 以 `(base_url, account_id, conversation_id)` 为 key;busy/checking bypass;超过账号 cap bypass;expired idle 关闭。
- `src/codex/gateway/transport/websocket/pool.rs:225-319` maintenance 会 ping idle connection,并按 max age/liveness timeout 清理。
- `src/codex/accounts/service/refresh.rs:170-195`、`src/codex/serving/dispatch/limits.rs:8-73`、`src/codex/serving/dispatch/fallback.rs:350-470`、`src/codex/accounts/service/lifecycle.rs:67-77` 在账号 refresh、quota cooldown、fallback 状态变化、账号删除等路径会驱逐账号 WS。
- `src/config/types.rs`、`config.yaml`、`src/runtime/state.rs` 已暴露并接入 `ws_pool.enabled/max_age_ms/max_per_account`,默认值与原版 `enabled=true/max_age_ms=3_300_000/max_per_account=8` 一致。
- `src/codex/gateway/transport/websocket/codec.rs` 已按原版区分 one-shot 与 pooled WS 错误分类 profile:one-shot 不提前分类 `websocket_connection_limit_reached`,pooled path 仍将其映射为 503 并标记连接 fatal。
- `tests/codex_serving/responses_websocket.rs:47-110` 覆盖默认 WebSocket 上游;`tests/codex_serving/responses_websocket.rs:300-360` 覆盖 recorded conversation 复用同一 WS。
- `tests/config.rs` 覆盖默认 `ws_pool` 配置和 `local.yaml` 覆盖;`v1_responses_websocket_should_not_reuse_connection_when_pool_is_disabled` 覆盖关闭 pool 后仍走 WebSocket 但不复用物理连接。
- `tests/codex_gateway/websocket.rs::websocket_one_shot_should_passthrough_connection_limit_failed_frame` 与 `websocket_pooled_connection_limit_frame_should_surface_as_503` 覆盖同一错误码在 one-shot/pool 两条路径的原版差异。
- `tests/codex_gateway/websocket.rs:44-76` 覆盖 transport 策略;`tests/codex_gateway/websocket.rs:376-760` 覆盖 handshake 429、首帧 rotatable error、HTTP SSE fallback、handshake headers 捕获、内部 `codex.rate_limits` 不转发、mid-stream close error。
- `tests/codex_gateway/websocket/pool.rs:1-559` 覆盖 busy key bypass、账号 cap bypass、keepalive pong/no-pong、过期 GC、shutdown。
结论:部分对齐。
已对齐:
- 默认上游传输策略已与原版方向一致:普通 `/v1/responses` 优先 WebSocket,`previous_response_id` 必须 WebSocket 且不能安全降级,非历史 WebSocket 传输错误可降级 HTTP SSE。
- WS URL、`response.create` frame 主字段、tool 默认值、service tier、prompt cache key、client metadata、internal rate-limit event、terminal event SSE 包装均已基本对齐。
- WS handshake headers 已去掉原版没有的 `Content-Type` 与 `Accept: text/event-stream`。
- WS opening request 的关键 wire 顺序、大小写和 `Sec-WebSocket-Extensions` offer 已按原版 `ws@8.19.0` 对齐,并由 raw TCP 测试覆盖。
- one-shot WebSocket handshake 已增加原版同等 20 秒 open timeout;普通无历史请求超时后仍可按原版策略降级 HTTP SSE,`previous_response_id` required WS 不降级。
- 连接池核心状态机基本对齐:55 分钟 max age、每账号最多 8 条、严格单 in-flight、busy/cap bypass、stale reuse 一次 fresh retry、idle keepalive、liveness/GC、shutdown、账号状态变更驱逐。
- WebSocket pool 配置面已对齐原版:配置文件显式提供 `ws_pool.enabled/max_age_ms/max_per_account`,运行时按配置启停复用和调整 max age/account cap。
- `websocket_connection_limit_reached` 已按原版保留路径差异:pooled path 视为 503 fatal 并驱逐;one-shot/bypass path 不在 WS transport 层提前分类,而是透传 `response.failed` frame 给上层处理。
- Rust 对账号状态变化的 WS 驱逐覆盖面较完整,refresh、quota cooldown、fallback、删除/禁用等路径都会清理对应账号连接。
未完全对齐:
- pool key 表达不完全一致。原版 key 是 `${entryId}:${rawConversationId}`;Rust key 是 `(base_url, account_id, accountScopedConversationId)`。行为上通过测试证明同 conversation 会复用,但若后续要与原版日志/配置/诊断完全一致,命名与 key 语义仍不同。
- Rust pool maintenance 是集中 sweep ping idle connection;原版是每条 `PersistentWs` 自带 interval,busy 时跳过 ping。默认行为接近,但调度模型不是逐字等价。
- Rust 已发出原版 permessage-deflate offer,但后续 frame 解码仍由 `tungstenite` 负责;需要真实上游或压缩 frame fixture 证明 OpenAI 不会返回需要解压的 RSV1 frame,或补齐压缩解码能力。
缺口/后续动作:
- 增加 WS compression negotiation 验证:覆盖上游接受 `Sec-WebSocket-Extensions` 后的响应 header 与 frame 行为。
## 7. response/SSE/WS frame 转换链路
原版证据:
- `src/proxy/codex-sse.ts:8-41` 的 `parseSSEBlock()` 除标准 `data:` 行外,还解析非标准上游错误流:漂亮打印 JSON 的续行即使没有重复 `data:` 前缀,也会拼入 data;`[DONE]` 会被忽略。
- `src/proxy/codex-sse.ts:44-95` 的 `parseSSEStream()` 对单个未解析 SSE event buffer 设置 64 MiB 上限;如果 response 不是 SSE 但有 body,会合成 `event: error`,code 为 `non_sse_response`。
- `src/routes/shared/response-processor.ts:50-229` streaming 入口每 15 秒写 SSE heartbeat;上游读取错误会写一个 Responses 格式的失败 SSE 后再关闭。
- `src/routes/responses-passthrough.ts:168-333` 的 `streamPassthrough()` 逐个解析 Codex event,记录 response id、usage、function call ids、reasoning replay items;如果上游在 `response.completed`/`response.failed`/`error` 之前关闭,会合成 `response.failed`,code 为 `stream_disconnected`。
- `src/routes/responses-passthrough.ts:343-488` 的 `collectPassthrough()` 会从 event 流重建最终 response,补齐 output/output_text,提取 usage、image usage、function call ids、reasoning replay items;遇到 `error` 或 `response.failed` 会抛 `Codex API error`,供外层错误分类和 retry/fallback 使用。
- `src/routes/responses-passthrough.ts:230-267`、`439-463` 对 tuple schema 做 streaming/collect 双路径 reconvert。
- `src/translation/codex-event-extractor.ts:46-79` 区分 `EmptyResponseError` 与 `UpstreamPrematureCloseError`,后者用于上游无 terminal event 关闭,原版注释明确这种情况不应跨账号重试。
- `src/translation/codex-api-error-from-event.ts:1-31` 将 `error`/`response.failed` SSE event 转换为 HTTP-equivalent `CodexApiError`,让 non-streaming catch 路径能复用 400/401/402/403/429/502 的恢复逻辑。
- 2026-06-15 复核 reasoning replay:`src/proxy/reasoning-replay-cache.ts` 以 55 分钟 TTL、512 entries、256 KiB 单条、4 MiB 总量缓存 `reasoning` 与 `function_call` replay items,key 为 `responseId + entryId + conversationId + variantHash`。
- 2026-06-15 复核 implicit resume:`src/routes/shared/proxy-session-context.ts` 在无显式 `previous_response_id`、存在 continuation input 且 conversation/variant 命中时查找最新 response id;`src/routes/shared/proxy-implicit-resume-request.ts` 会把 `previous_response_id`、`useWebSocket=true`、turnState 和 `reasoningReplayItems + continuationInput` 写回本次上游请求。
Rust 证据:
- 本轮修正后,`src/codex/gateway/transport/sse.rs` 的 parser 支持标准 `event/data/id/retry/comment`,同时按原版处理非 `data:` 前缀的 JSON 续行、忽略 `[DONE]`,并在非 SSE body 场景合成 `error/non_sse_response` 事件。
- `src/codex/gateway/transport/websocket/codec.rs:14-85` 将 WS `response.create` frame 构造成上游 JSON,并把每个可见 WS JSON frame 包装成 `event: <type>\ndata: <raw>` 的 SSE chunk;`codex.rate_limits` 是内部事件,不转发给客户端。
- `src/codex/gateway/transport/websocket/mod.rs:230-328` 在 WS stream 中遇到 terminal event 后结束;mid-stream close 会返回 `ClosedBeforeTerminal` 传输错误。
- `src/codex/serving/dispatch/mod.rs:338-476` HTTP SSE streaming 直接透传上游 bytes,并每 15 秒写 `: ping` heartbeat;上游 bytes stream error 会记录 transport error 并以 stream error 结束。
- `src/codex/serving/dispatch/mod.rs:830-923` WebSocket streaming 将 WS SSE chunk 透传给客户端,并每 15 秒写 heartbeat;WS stream error 会转换为 `std::io::Error` 结束响应流。
- `src/codex/serving/dispatch/stream.rs:48-104` non-streaming collect 会解析 SSE,拼接 output text、output item、completed response;识别 `error`/`response.failed` 为 `CollectedResponse::Failed`。
- 本轮修正后,`src/codex/serving/dispatch/stream.rs` 会从 `response.output_item.done` 和 `response.completed.response.output[]` 收集 function call ids 与 replay items;`reasoning`/`function_call` replay item 会进入后续 implicit resume 缓存。
- 本轮修正后,`src/codex/serving/dispatch/reasoning_replay.rs` 按原版约束缓存 replay items:TTL 55 分钟、512 entries、单项 256 KiB、总量 4 MiB,并对白名单 `reasoning`/`function_call` item 做清洗和去重。
- 本轮修正后,`src/codex/gateway/transport/sse.rs` 增加与原版 `parseSSEStream()` 一致的单个未解析 SSE event buffer 上限:64 MiB。该限制按事件块计数,遇到空行完成事件后重置;`tests/codex_gateway/usage_events.rs::parse_sse_events_should_reject_single_event_buffer_above_original_limit` 先以 oversized event 被接受失败,再修复通过。
- 本轮修正后,`src/codex/gateway/protocol/tuple_schema.rs` 按原版 `tuple-schema.ts`/`prepareSchema()` 实现 OpenAI JSON Schema tuple 处理:请求侧把 `prefixItems` 转为数字键 object schema,并注入 `additionalProperties:false`;响应侧按原始 schema 把 `{"0":...}` 还原为数组。
- 本轮修正后,`/v1/responses` native 链路会把原始 tuple schema 保存在本地 `CodexResponsesRequest::tuple_schema`,该字段 `serde(skip)` 不进入上游;non-streaming collect 和 HTTP SSE/WebSocket streaming 均在返回客户端前 reconvert output text,并按原版从 `output[].content` 同步 `output_text`。`tests/codex_serving/responses_http_sse.rs::v1_responses_should_convert_tuple_schema_before_upstream`、`v1_responses_should_reconvert_tuple_schema_output_for_client`、`v1_responses_stream_should_reconvert_tuple_schema_output_for_client` 覆盖该链路。
- 本轮修正后,`/v1/chat/completions` OpenAI Chat 链路也接入相同 tuple schema 处理:`response_format.json_schema.schema` 上游前会转换,non-streaming message content 和 streaming content delta 会还原给客户端;`tests/codex_serving/chat_completions.rs::chat_completions_should_convert_and_reconvert_tuple_schema` 与 `chat_completions_stream_should_reconvert_tuple_schema` 覆盖该链路。
- 本轮修正后,`src/codex/serving/dispatch/mod.rs` 在无显式 `previous_response_id` 的 full-history continuation 中,会按 conversation/variant 找 55 分钟内最新 response,校验 instructions hash 与 function call continuation,随后写入 `previous_response_id`、强制 WebSocket、截取 continuation input,并在 input 前注入 reasoning replay items;若该隐式续链遇到 previous-response 错误,会恢复原始 full-history request 后同账号重试。
- `tests/codex_serving/responses_websocket.rs::v1_responses_websocket_should_implicitly_resume_full_history_continuation_with_reasoning_replay` 覆盖第二轮上游请求自动携带 `previous_response_id`、复用 prompt cache key,并将 cached reasoning replay item 放在 continuation input 前。
- `tests/codex_serving/responses_websocket.rs::v1_responses_websocket_should_restore_full_history_when_implicit_resume_previous_response_is_missing` 覆盖隐式续链 `previous_response_not_found` 后,下一次上游请求恢复完整 3 条历史 input 且不带 `previous_response_id`。
- 本轮修正后,`src/codex/serving/responses.rs:145-365` non-streaming 对空响应最多跨账号重试 2 次;`CollectedResponse::Failed` 会转换为可分类的 `CodexClientError::Upstream`,429/402/403 会复用账号 retry/fallback 状态机,不再固定降级为 502。
- 本轮修正后,`src/codex/serving/responses.rs` 会复用第一次 SSE collect 结果生成最终响应,不再对同一个 upstream body 二次解析。
- 本轮修正后,`src/codex/serving/dispatch/fallback.rs` 增加 request-level recovery 分类,识别 `previous_response_not_found` 和 `No tool output found for function call`;`src/codex/serving/responses.rs`、`src/codex/serving/dispatch/mod.rs` 在账号 fallback 前先清除 stale `previous_response_id`/`turn_state` 并同账号重试一次。
- `tests/codex_serving/upstream_errors.rs:67-194` 覆盖 non-streaming `response.failed` 按 `rate_limit_exceeded` 返回 429、streaming 透传 `response.failed` 并记录失败审计。
- 本轮新增 `tests/fixtures/responses/http_sse/response_failed_quota.sse` 与 `tests/codex_serving/upstream_fallback.rs::v1_responses_should_classify_non_stream_sse_failure_and_retry_next_account`,覆盖 non-streaming mid-SSE `quota_exceeded` 会标记当前账号 `quota_exhausted` 并 fallback 到下一账号。
- 本轮新增 `tests/fixtures/responses/websocket/previous_response_not_found.json`、`tests/fixtures/responses/websocket/unanswered_function_call.json`,并用 `tests/codex_serving/responses_websocket.rs` 覆盖 non-streaming 与 streaming `previous_response_not_found`、non-streaming unanswered function call 的同账号 strip-and-retry。
- `tests/codex_gateway/usage_events.rs` 覆盖 multiline data、非标准 JSON 续行、`[DONE]` 忽略和非 SSE JSON body 转 `non_sse_response`。
- `tests/codex_gateway/websocket.rs:376-760` 覆盖 WS 内部 rate-limit event 不转发、首个 rotatable error、mid-stream close error 和 fallback。
- 本轮修正后,HTTP SSE streaming 在上游正常 EOF 但缺少 `response.completed`/`response.failed`/`error` 时,会给客户端追加原版格式的 `response.failed`,错误码为 `stream_disconnected`;`tests/codex_serving/upstream_errors.rs::v1_responses_stream_should_synthesize_response_failed_when_http_sse_closes_before_terminal` 覆盖该行为。
- 本轮修正后,WebSocket streaming 在首个可见 frame 已发送后遇到 `ClosedBeforeTerminal` 或其它未见 terminal 的 body stream 错误时,也会追加同样的 `response.failed/stream_disconnected`,不再把 body stream error 直接暴露给客户端;`tests/codex_serving/responses_websocket.rs::v1_responses_websocket_stream_should_synthesize_response_failed_when_closed_before_terminal` 覆盖该行为。
- 本轮修正后,HTTP SSE 与 WebSocket streaming 的 heartbeat 首次触发延后到 15 秒,不会因为 `tokio::time::interval()` 的立即 tick 抢在上游首个可见 frame 前面;`tests/codex_serving/responses_websocket.rs::v1_responses_websocket_should_stream_first_frame_before_terminal_event` 覆盖首个 WebSocket delta 可先于 terminal 返回给客户端。
结论:部分对齐。
已对齐:
- 标准 SSE 事件解析、WS JSON frame 到 SSE chunk 的基本转换、terminal event 判断、`codex.rate_limits` 内部事件过滤、heartbeat 15 秒节奏、usage 提取、function call ids 提取、空响应重试等主路径已经具备。
- SSE parser 已对齐原版的三个容错点:漂亮打印 JSON 续行、`[DONE]` 忽略、非 SSE body 合成 `error/non_sse_response`。
- streaming 路径对客户端保持 SSE 格式,HTTP SSE 与 WebSocket 两种上游传输都能以 SSE 返回给下游。
- `response.failed`/`error` 在 streaming 路径会被透传并写入审计;non-streaming 路径也能识别为上游失败,并已对 429/402/403 进入账号状态机和 fallback。
- request-level `previous_response_not_found` 与 unanswered function call 已按原版优先于账号 fallback 处理:忘记 stale affinity,清除 `previous_response_id`/`turn_state`,同账号重试一次。
- streaming premature close 已按原版对齐:HTTP SSE 正常 EOF、HTTP SSE body stream error、WebSocket body stream error 在缺少 terminal event 时都会合成 `event: response.failed`,错误码 `stream_disconnected`,并进入现有 stream 审计失败记录。
- 单个未解析 SSE event buffer 已按原版限制为 64 MiB,避免异常上游响应在解析阶段无界增长。
- OpenAI Responses 与 OpenAI Chat 的 tuple schema request conversion/reconvert 已对齐原版,不涉及非 OpenAI translator。
未完全对齐:
- mid-SSE/request failure 尚未完全覆盖原版恢复集合。Rust 已补齐 429/402/403 分类与 fallback、401 token invalid fallback,以及 `previous_response_not_found`/unanswered function call strip-and-retry。
- non-streaming upstream error 响应外壳仍不完全一致。原版 Responses pooled path 通过 `PASSTHROUGH_FORMAT` 返回 `codex_api_error`/`rate_limit_exceeded` 风格错误;Rust 仍通过 `codex_client_error_response()` 包装为 OpenAI error 风格的 `upstream_error`。
缺口/后续动作:
- 对齐 Responses pooled path 的最终错误响应外壳,或明确 Rust 统一 OpenAI error 格式为本项目边界。
- 增加 golden tests:标准 SSE、漂亮打印 JSON 续行、非 SSE body、premature close、`response.failed` auth recovery、reasoning replay、WS frame to SSE chunk。
## 8. fallback、retry、错误分类
原版证据:
- `src/proxy/error-classification.ts:37-148` 分类 429 retry-after、402 quota、403 Cloudflare challenge、403 ban、401 token invalid、400 `previous_response_not_found`、400 unanswered function call、空 body 404 Cloudflare path-block、model not supported。
- `src/routes/shared/proxy-retry-classifier.ts:34-68` 的恢复优先级是:implicit resume replay、strip stale `previous_response_id`、再交给通用错误处理。
- `src/routes/shared/proxy-retry-recovery.ts:27-96` 对 `previous_response_not_found` 和 `No tool output found for function call` 只 strip 一次 `previous_response_id`/`turnState`,忘记 stale affinity,并在同账号重试。
- `src/routes/shared/proxy-retry-recovery.ts:98-151` 有 Cascading Ban Defense:只有 preferred account 已 banned/disabled 且实际换账号时,才主动 strip `previous_response_id` 和 `turnState`。
- `src/routes/shared/proxy-error-handler.ts:63-185` 处理上游 `CodexApiError`:model unsupported 只重试一次其他账号;429 写 cachedQuota rate_limit 并 fallback;402 标记 quota_exhausted;CF challenge 进入 cooldown;非 CF 403 标记 banned;401 标记 expired/banned 并 fallback;空 body 404 清 cookie,连续 3 次 auto-disable;其他错误保留 body 返回。
- `src/routes/shared/proxy-error-retry-transition.ts:43-102` 和 `proxy-fallback-account-retry.ts:39-75` 统一处理 release、restore implicit resume、fallback account acquire、API rebuild、无 fallback 时的 account exhaustion response。
- `src/utils/retry.ts:1-25` 对同一上游 attempt 的 5xx `CodexApiError` 做最多 2 次指数退避重试。
- `src/routes/shared/non-streaming-handler.ts:144-204` 把 collect 中的 `CodexApiError` 重新抛给外层统一 catch,确保 mid-SSE `previous_response_not_found`/unanswered function call 与 HTTP-time 错误走同一恢复逻辑。
- `src/routes/shared/proxy-upstream-attempt.ts:71-96` 每次上游请求后记录 egress log,并应用 rate-limit headers。
Rust 证据:
- `src/codex/serving/dispatch/fallback.rs:22-190` 把 429、402、403 Cloudflare challenge、403 ban、空 body 404 Cloudflare path-block、401 token invalid/deactivated 分类成 `UpstreamAccountRetry`。
- `src/codex/serving/dispatch/fallback.rs:350-470` 对 429 设置 quota cooldown 并记录 request attempt;402 设置 `QuotaExhausted`;Cloudflare challenge 删除 cookies 并设置 persisted cooldown;空 body 404 清理当前账号 cookies、记录 1 小时滑窗内 path-block 次数,连续 3 次设置 `Disabled`;401 token invalid 设置 `Expired`,401 deactivated 设置 `Banned`;403 ban 设置 `Banned`;状态变化会 evict WS pool。
- 本轮修正后,`src/codex/serving/dispatch/account_refresh.rs` 已删除;serving request path 不再对上游 401 做同账号 OAuth refresh,refresh 仍保留在账号健康检查、后台调度和 admin/manual refresh 链路。
- `src/codex/serving/dispatch/mod.rs:338-476`、`689-923` 在 HTTP SSE/WS streaming 请求建立前,遇到可分类错误会尝试 acquire fallback account;显式 `previous_response_id` 的 WS history 请求遇到 429/402/403 会保持原账号,不切换 fallback。
- 本轮修正后,`src/codex/serving/responses.rs:145-365` non-streaming 请求前错误与 mid-SSE `error`/`response.failed` 都会先尝试 request-level recovery,再进入 `classify_upstream_account_retry()`;显式 `previous_response_id` 遇到可分类 429/402/403 时只记录并保持原账号。
- 本轮修正后,`src/codex/serving/dispatch/mod.rs` 的 HTTP SSE streaming 与 WebSocket streaming 请求建立失败也会先尝试 request-level recovery,`previous_response_not_found` 可同账号 strip-and-retry。
- 本轮修正后,`src/codex/serving/dispatch/mod.rs` 在 non-streaming、HTTP SSE streaming、WebSocket streaming 的上游 request attempt 外层加入 `retry_upstream_5xx()`:仅对 `CodexClientError::Upstream` 的 5xx 做同账号最多 2 次重试,退避为 1000ms、2000ms,与原版 `withRetry()` 默认策略一致。
- 本轮修正后,`src/codex/serving/dispatch/fallback.rs` 识别 4xx `model not supported`、`model_not_supported`、`model not available`、`model_not_available`,只允许一次备用账号 retry,不标记账号状态,也不驱逐 WebSocket pool。
- 本轮修正后,`src/codex/serving/dispatch/stream.rs` 会把 non-streaming collect 得到的 `response.failed` `model_not_supported`/`model_not_available` 合成为 400 上游错误,使它进入同一 model fallback 状态机。
- `src/codex/serving/http/errors.rs:7-40` 统一把 `CodexClientError::Upstream` 映射为原 status + `upstream_error`,其他 transport 映射为 502;无 fallback 的空 body 404 会按原版 path-block 语义返回 502 `Upstream blocked the request (Cloudflare path-block)`。
- `src/codex/accounts/cloudflare_challenge.rs:1-120` 实现了 CF path-block tracker,连续次数超过 1 小时会重置,阈值为 3。
- `tests/codex_serving/upstream_fallback.rs:40-1400` 覆盖 429 fallback、streaming 429 fallback、402 标记并 fallback、403 ban、Cloudflare challenge cooldown/cookie 清理、Cloudflare path-block 404 清 cookies 并 fallback、连续 3 次 path-block 后禁用账号、401 token invalid 标记 expired 后 fallback、401 deactivated 标记 banned、无 fallback 时返回 401。
- `tests/codex_serving/responses_websocket.rs` 覆盖 WebSocket 非历史请求先因 429 fallback 到备用账号,再因 401 将备用账号标记 expired 并返回 401,不做同账号 refresh。
- 本轮新增 `tests/fixtures/responses/http_sse/after_model_unsupported_retry.sse`、`tests/fixtures/responses/http_sse/response_failed_model_unsupported.sse`,并用 `tests/codex_serving/upstream_fallback.rs` 覆盖 HTTP-time model unsupported fallback、non-streaming mid-SSE model unsupported fallback、以及 model unsupported 最多只重试一次。
- 本轮新增 `tests/fixtures/responses/http_sse/after_5xx_retry.sse`、`tests/fixtures/responses/http_sse/stream_after_5xx_retry.sse`,并用 `tests/codex_serving/upstream_fallback.rs` 覆盖 non-streaming 与 streaming 两条路径在同账号连续 2 次 5xx 后第三次成功,且不会切换到备用账号。
- `tests/codex_serving/responses_websocket.rs:652-927` 覆盖 `previous_response_id` WS 429 不跨账号重试、非历史 WS fallback 和 fallback 账户 refresh。
结论:部分对齐。
已对齐:
- 429、402、403 ban、403 Cloudflare challenge 的主路径已经具备账号状态更新和 fallback account retry。
- non-streaming mid-SSE `response.failed`/`error` 的 429、402、403 已不再固定返回 502,会进入同一账号状态机和 fallback 逻辑;`quota_exceeded` fixture 已覆盖 quota exhausted 标记与备用账号接管。
- `previous_response_not_found` 与 unanswered function call 已优先于账号 fallback 处理,清除 stale history 后同账号重试一次;streaming 与 non-streaming 的 WS 首帧错误均有测试覆盖。
- 5xx same-account retry 已对齐原版:上游 request attempt 对 5xx 同账号最多重试 2 次,退避 1000ms、2000ms;non-streaming 和 streaming HTTP SSE 均有集成测试覆盖,WebSocket streaming 也复用同一请求建立 helper。
- CF path-block 404 已接入主链路:空 body 404 会清理当前账号 cookies、记录 1 小时滑窗内连续次数、尝试 fallback account,达到 3 次后把账号标记为 `disabled`;无 fallback 时返回 502 `upstream_error`。
- model unsupported fallback 已对齐原版:账号 plan 不支持当前 model 时最多换一个备用账号重试一次,不改变账号状态;HTTP-time 错误与 non-streaming mid-SSE `response.failed` 均有测试覆盖。
- 401 token invalid fallback 已对齐原版:request path 不再 refresh 同账号;普通 401 会标记当前账号 `expired` 并尝试备用账号,包含 `deactivated` 的 401 会标记 `banned`;HTTP SSE streaming/non-streaming 与 WebSocket fallback exhausted 均有测试覆盖。
- Rust 的 Cloudflare challenge 处理比原版更持久:会把 cooldown 写入数据库,并清理对应账号 cookies。
- WS history 请求不跨账号重试这一点从安全角度更保守,避免 `previous_response_id` 的 server-side history 静默丢失。
未完全对齐:
- recovery 优先级仍不是完整原版状态机。Rust 已实现 implicit resume restore/replay 与 strip-and-retry 优先于账号 fallback,但状态机仍分散在 responses/dispatch 中,没有抽成原版同等的 `RetryAction` 分层。
- non-streaming mid-SSE failure 仍不是完整原版 catch 状态机。429/402/403、401、model unsupported、400 strip-and-retry 与隐式续链 restore 已补齐;剩余主要是最终错误外壳和 fallback exhausted body。
- `previous_response_id` 限流/配额语义不一致。原版没有显式“history 请求保持原账户”的 retry hold,可能经 fallback 后再通过 `previous_response_not_found` strip-and-retry 恢复;Rust 遇到 429/402/403 会保持原账号并返回错误,保护历史但牺牲可恢复性。
- 无 fallback 时的响应体不一致。原版会把 account pool exhaustion summary 拼进错误;Rust 多数路径直接返回 `Codex upstream error: ...`,缺少账号耗尽上下文。
- CF challenge exhausted status 不一致。原版 Cloudflare challenge fallback exhausted 使用 502 语义;Rust 若无 fallback,最终会走原 403 upstream error。
缺口/后续动作:
- 引入原版级 `UpstreamRecoveryAction` 状态机:implicit resume replay、strip-and-retry、error handler、fallback transition 分层清晰,避免分散在 responses/dispatch 中。
- 继续补 HTTP SSE mid-SSE failure 的 `previous_response_not_found` 与 unanswered function call 覆盖;当前已有 WS 首帧错误覆盖 streaming/non-streaming。
- 增加 fallback exhausted response body 的 parity tests。
## 9. rate-limit、usage、quota、cookie 持久化
原版证据:
- `src/proxy/cookie-jar.ts:27-45` cookie 持久化格式 v2 记录 value 与 expires;自动捕获白名单只有 `cf_clearance`,关键 cookie 立即异步落盘,普通变更 1 秒 debounce。
- `src/proxy/cookie-jar.ts:78-102` 构造 Cookie header 时跳过过期 cookie;`cleanupExpired()` 每 5 分钟清理并持久化。
- `src/proxy/rate-limit-headers.ts:44-85` 从 `x-codex-primary-*`、`x-codex-secondary-*`、`x-codex-code-review-*`/`x-codex-review-*`/`x-code-review-*` 解析 rate-limit,并转为 normalized quota。
- `src/proxy/rate-limit-headers.ts:101-169` 解析 `codex.rate_limits` WS 内部 event,支持 review limit name 归类。
- `src/auth/quota-utils.ts:1-105` 从 `/codex/usage` body 归一化 plan、primary/secondary/code_review/additional_rate_limits/credits。
- `src/routes/shared/proxy-rate-limit.ts:28-49` passive rate-limit 更新 cachedQuota、同步 window_reset_at/limit_window_seconds;若 primary 已达 100 且 reset_at 在未来,会主动写入 rate-limit 侧效应。
- `src/auth/account-registry.ts:215-266` 429 会合成或更新 cachedQuota.rate_limit,且不会缩短已知更晚 reset_at;`countRequest` 时同步增加 request_count/window_request_count。
- `src/auth/account-registry.ts:414-480` release 时记录 request_count、token usage、cached tokens、image_generation tokens、empty response、window counters。
- `src/auth/account-registry.ts:520-584` window 到期会重置窗口计数并推进下一窗口;cachedQuota 离线 reset 后会设置 `quotaVerifyRequired=true`。
- `src/routes/shared/proxy-handler.ts:88-139` 在账号被 dirty quota 标记时,请求进入前最多做 5 次 `/usage` 校验,避免本地窗口推断把仍然限流的账号重新放回池。
Rust 证据:
- `src/codex/accounts/cookies/repository.rs:1-111` cookie 存在 SQLite `account_cookies`,value 加密,唯一键为 `(account_id, domain, name, path)`;自动捕获白名单只有 `cf_clearance`。
- `src/codex/accounts/cookies/repository.rs:70-102` 构造 Cookie header 时按 domain 过滤并跳过过期 cookie;`delete_account_cookies()` 支持按账号清理。
- `src/platform/storage/schema.sql:68-83` `account_usage` 已持久化 `window_request_count`、`window_input_tokens`、`window_output_tokens`、`window_cached_tokens`、`window_started_at`、`window_reset_at`、`limit_window_seconds`、`last_used_at`。
- `src/codex/accounts/repository/usage.rs:12-44` `record_usage()` 会累计 total/window request 和 token 计数,并设置 `last_used_at`。
- `src/codex/accounts/repository/usage.rs:63-99` `sync_rate_limit_window()` 会在 reset_at 漂移超过窗口 50% 或默认 3600 秒时重置窗口计数,否则只同步 reset_at/limit_window_seconds。
- `src/codex/accounts/repository/accounts.rs:329-562` list pool accounts 时会把持久化 usage/window 字段恢复到运行时 `Account`。
- `src/codex/gateway/transport/rate_limits.rs:58-144` rate-limit header 与 `codex.rate_limits` event 解析规则基本对应原版,并在 passive quota 缺 credits 时保留已有 credits。
- `src/codex/serving/dispatch/limits.rs:8-67` response headers 或 WS rate-limit updates 会更新 `quota_json`、同步运行时和数据库窗口,并在 primary limit reached 时写 quota cooldown、驱逐 WS。
- `src/codex/serving/dispatch/usage.rs:7-30` 成功 usage 会同步运行时窗口 token usage,并写入数据库累计 usage。
- `src/codex/accounts/pool.rs:403-441` acquire 时更新运行时 `request_count`、`window_request_count`、`last_used_at`,并在已知窗口长度但无窗口开始时间时推导窗口 reset。
- `src/codex/accounts/pool.rs:502-571` 运行时会在窗口到期时重置窗口计数、清除过期 quota cooldown,并推进下一窗口 reset。
- `tests/codex_accounts/repository.rs:230-326` 覆盖 window usage 持久化与恢复。
- `tests/codex_serving/responses_http_sse.rs:279-350` 覆盖 passive rate-limit headers 缓存 quota、窗口字段和 cooldown。
- `tests/codex_accounts/pool_scheduling.rs:150-466` 覆盖窗口过期重置、least_used 的 quota/window/request_count 排序和 runtime request count。
结论:部分对齐。
已对齐:
- 之前审计中提到的窗口统计字段已经不再缺失:Rust schema、repository、runtime pool 和测试都覆盖了 window request/token/reset/limit_window_seconds。
- Cookie 自动捕获白名单与原版一致,只自动保存 `cf_clearance`,不会保存 `__cf_bm`。
- Rust 使用 SQLite + AES 加密保存 cookie,比原版 JSON 文件更适合当前数据库架构;主请求链路能注入 Cookie 并捕获 `Set-Cookie`。
- primary/secondary/code_review rate-limit header 和 `codex.rate_limits` event 的解析与 quota 归一化主路径基本对齐,并能保留已有 credits。
- usage input/output/cached token 的成功路径持久化、窗口统计和重启恢复已对齐到可用水平。
- least_used 依赖的 request_count、window_reset_at、quota_limited 字段已经进入运行时调度和数据库恢复路径。
未完全对齐:
- image_generation usage 未对齐。原版记录 image input/output tokens、image request success/failure 和对应窗口计数;Rust `TokenUsage` 只有 input/output/cached/total,没有解析或持久化 `tool_usage.image_gen`。
- dirty quota verification 缺失。原版离线 reset cachedQuota 后设置 `quotaVerifyRequired`,下一次请求会先调用 `/usage` 验证;Rust 只在本地窗口到期后清除 cooldown/推进窗口,没有等价 dirty 标记和请求前最多 5 次 upstream usage 校验。
- 失败请求的持久 request_count 语义不一致。原版 release 时默认记录 request_count,错误 release 也计数;Rust runtime acquire 会计运行时 request_count,但数据库只在成功 usage、429 `record_request_attempt()`、empty response 等路径写入,部分 transport/5xx/403/402 fallback 失败尝试重启后不会体现在持久 request_count 中。
- Cookie 过期清理语义不一致。Rust 读取时会跳过过期 cookie,但没有原版每 5 分钟清理并持久删除的后台任务;长期运行时数据库中可能保留过期 cookie 行。
- Cookie domain/path 语义不同。原版 CookieJar 是 per-account 简单 map,不按 domain/path 过滤;Rust 按 domain/path 存储和匹配。对 `chatgpt.com` 主链路更严格,但不是原版逐字行为。
- quota window reset 细节不完全一致。原版 reset 后设置 `window_counters_reset_at` 并可能标记 quotaVerifyRequired;Rust 设置 `window_started_at`,没有 `window_counters_reset_at` 字段,也不触发 quotaVerifyRequired。
- passive header 文档提到 `x-codex-active-limit`,原版和 Rust 当前都没有实际解析该 header;如果 Codex Desktop 当前依赖 active-limit,这两边都需要重新确认。
- usage 提取策略有潜在差异。Rust `extract_sse_usage()` 会合并所有 SSE event 中出现的 usage;原版 Responses passthrough collect/stream 主要在 `response.completed` 中取 usage。若上游多个事件重复携带同一 usage,Rust 可能重复累计。
缺口/后续动作:
- 补 `TokenUsage` 的 image_generation 字段与数据库/运行时窗口字段,或明确 Rust 不支持 image_generation usage 统计。
- 增加 dirty quota verification 机制:窗口离线 reset 后标记需要校验,请求前调用 usage endpoint,失败时保留 dirty 标记并限制放大次数。
- 统一持久 request_count 计数点,决定是否像原版一样在 release/attempt 维度记录所有真实上游尝试;至少补 transport/5xx/403/402 exhausted 的测试。
- 为 `account_cookies` 增加过期清理任务或在读取时顺手删除过期行。
- 明确 cookie domain/path 精细化是 Rust 新架构选择还是需要回到原版 per-account map;如果保留,应补 domain/path 行为测试。
- 为 usage merge 增加测试,覆盖多个 event 带 usage 时是否重复累计。
- 若 OpenAI/Codex 当前返回 `x-codex-active-limit`,补解析和 quota_json 存储;否则将其记录为双方未使用字段。
## 10. session affinity、implicit resume、reasoning replay
原版证据:
- `src/auth/session-affinity.ts:1-181` 维护 response_id 到 account/conversation/turnState/instructionsHash/inputTokens/functionCallIds/variantHash 的映射;TTL 4 小时,每 10 分钟清理。
- `src/routes/shared/proxy-session-context.ts:45-121` 为每个请求构造 explicit/implicit session context:prompt cache identity、continuation input start、chain conversation id、variant hash、implicit previous response candidate、preferred account、turnState、function call 校验输入。
- `src/routes/shared/proxy-session-helpers.ts:8-16` implicit resume 最大年龄为 55 分钟,与 WS pool max age 对齐,避免旧 `previous_response_id` 落到新 LB 后端。
- `src/routes/shared/proxy-session-helpers.ts:34-56` prompt cache identity 优先级为显式 `prompt_cache_key`、`clientConversationId`、稳定派生 key、随机 UUID。
- `src/routes/shared/proxy-session-helpers.ts:58-70` variant identity 由 `codexWindowId` 与稳定 anchor 组成;随后 `computeVariantHash(instructions, tools, variantIdentity)` 区分并发分支。
- `src/routes/shared/proxy-session-helpers.ts:92-147` `evaluateImplicitResume()` 会校验 previous response 是否存在、continuation 是否非空、账号是否匹配、instructions hash 是否一致、function_call_output 是否都能对应上轮 function_call,且识别 self-contained replay。
- `src/routes/shared/proxy-implicit-resume-request.ts:30-78` implicit resume 激活时会写入 `previous_response_id`、强制 `useWebSocket=true`、把 input 截成 continuation,并在前面插入 reasoning replay items;失败恢复时还原原请求。
- `src/routes/shared/proxy-implicit-resume-lifecycle.ts:42-119` 管理 implicit resume 激活、跳过警告、WebSocket failure 后完整历史 replay、usage hint 和 restore。
- 本轮复核确认:原版 `UsageHint` 由 shared implicit resume lifecycle 生成,但 OpenAI chat translator `codex-to-openai.ts` 和 Responses passthrough `responses-passthrough.ts` 不消费它;实际消费点是非 OpenAI translator 的 cache token 估算。按当前边界,非 OpenAI translator 不移植,因此 usage hint 不计入 OpenAI 链路缺口。
- `src/proxy/reasoning-replay-cache.ts:1-247` reasoning replay cache TTL 55 分钟,限制 512 entries、单项 256 KiB、总 4 MiB;只缓存白名单 reasoning/function_call replay item,并按 entry/conversation/variant 匹配。
- `src/routes/shared/streaming-handler.ts:74-108` streaming 成功 completed 后记录 affinity 和 reasoning replay cache;invalid encrypted content 会驱逐 replay identity。
- `src/routes/shared/non-streaming-handler.ts:96-121` collect 成功后记录 affinity、function call ids 和 reasoning replay cache。
Rust 证据:
- `src/codex/serving/dispatch/affinity.rs:1-76` `AffinityEntry` 字段覆盖 account_id、conversation_id、turn_state、instructions_hash、input_tokens、function_call_ids、variant_hash、created_at。
- `src/codex/serving/dispatch/affinity.rs:78-154` `SessionAffinityRepository` 持久化 session affinity 到 SQLite,包含 expires_at 和 function_call_ids_json。
- `src/codex/serving/dispatch/affinity.rs:156-344` runtime `SessionAffinityMap` TTL 4 小时、清理间隔 10 分钟,支持 record/restore/lookup account/conversation/turnState/instructions/inputTokens/functionCallIds/latest/forget。
- `src/runtime/bootstrap.rs` 与 `tests/runtime/startup.rs:197-336` 覆盖启动后从 SQLite 恢复 session affinity,并用 `previous_response_id` 路由回记录账号。
- `src/codex/serving/dispatch/mod.rs:142-164` 显式 `previous_response_id` 会继承已记录 conversation id 到 `prompt_cache_key`,并补 turn_state;无显式 `previous_response_id` 时会先确保 prompt cache key,再尝试 implicit resume。
- `src/codex/serving/dispatch/mod.rs:167-186` 显式 `previous_response_id` 会查 affinity preferred account,并传给 account pool acquire。
- `src/codex/serving/dispatch/mod.rs:760-820` completed response 后记录 affinity,并持久化到 repository;记录 usage input_tokens、function_call_ids、variant_hash,并同步写入 reasoning replay cache。
- `src/codex/gateway/conversation_identity.rs:53-90` 稳定 prompt cache key 派生规则与原版 stable key 对齐。
- `src/codex/serving/dispatch/affinity.rs` 的 `compute_variant_hash()` 已改为原版基础形态:`instructions + "\0" + JSON.stringify(tools) + optional identity` 后取 SHA-256 前 12 位。
- `src/codex/serving/dispatch/implicit_resume.rs` 实现 continuation input start、function_call_output 校验、missing/unanswered 检查和 self-contained replay 跳过。
- `src/codex/serving/dispatch/reasoning_replay.rs` 实现 reasoning replay cache:TTL 55 分钟、512 entries、单项 256 KiB、总量 4 MiB,按 account/conversation/variant 匹配。
- `src/codex/serving/dispatch/stream.rs` 会从 completed SSE 中收集 replay items;streaming 和 non-streaming completed 都通过同一个 affinity 记录函数写入 cache。
- `src/codex/serving/dispatch/implicit_resume.rs` 提供 request snapshot/restore;non-streaming 与 streaming request recovery 在隐式续链 previous-response 错误后会先恢复 snapshot,再清除 `previous_response_id`/`turn_state` 并同账号重试。
- `tests/codex_serving/responses_websocket.rs:476-590` 覆盖显式 previous_response_id 路由到记录账号、继承 previous_response_id、持久化 function_call_ids/input_tokens/expires_at。
- 本轮新增 `tests/fixtures/responses/websocket/completed_with_reasoning_replay.json` 和 `tests/codex_serving/responses_websocket.rs::v1_responses_websocket_should_implicitly_resume_full_history_continuation_with_reasoning_replay`,覆盖 completed response 记录 replay item 后,下一轮 full-history continuation 自动变成 implicit resume request。
- 本轮新增 `tests/codex_serving/responses_websocket.rs::v1_responses_websocket_should_restore_full_history_when_implicit_resume_previous_response_is_missing`,覆盖隐式续链失败后的 full-history restore/replay。
结论:部分对齐。
已对齐:
- 显式 `previous_response_id` 的核心 affinity 已对齐:response_id 能映射回账号、conversation id、turn_state,后续请求可路由到同一账号并继续 WebSocket 链。
- Rust 增加了 SQLite 持久化与启动恢复,这是对当前无历史负担架构更优雅的增强;原版 session affinity 本身是内存单例。
- TTL 4 小时、清理 10 分钟、function_call_ids、instructions hash、input_tokens、variant_hash 字段都已经出现在 Rust 数据模型中。
- prompt cache stable key 派生规则本身与原版对齐。
- stale affinity recovery 已接入:上游 `previous_response_not_found` 或 unanswered function call 时会忘记 stale affinity,清除 `previous_response_id`/`turn_state`,同账号重试一次。
- implicit resume 主路径已接入:无显式 `previous_response_id`、存在 continuation input、conversation/variant 命中、instructions hash 一致且 function call continuation 合法时,Rust 会自动设置 `previous_response_id`、强制 WebSocket、截取 continuation input 并注入 turn_state。
- implicit resume failure restore 已接入:隐式续链遇到 previous-response 错误时,会恢复原始 full-history input,清除 `previous_response_id`/`turn_state` 并同账号重试。
- reasoning replay cache 主路径已接入:completed response 的 `reasoning`/`function_call` replay items 会按原版 TTL/容量/大小约束缓存,并在 implicit resume 时插入 continuation input 前部。
- function call continuation 预检已接入:missing/unanswered/self-contained replay 会阻止错误的 implicit resume。
- variant_hash 已不再序列化整个 request,基础算法已对齐为 instructions/tools/optional identity 的 12 位 hash。
- variant identity 已接入:Rust 会在 session preparation 阶段、implicit resume 改写 input 前按原版语义预先保存 identity;`codexWindowId` 会进入 `window:{id}`,显式 `prompt_cache_key` 且存在 stable anchor 时会进入 `anchor:{derivedConversationId}`,后续 implicit resume lookup、affinity record 和 reasoning replay 使用同一个 variant hash。
- 本轮新增 `tests/codex_serving/responses_websocket.rs::v1_responses_websocket_should_not_implicitly_resume_across_codex_windows`,覆盖同一显式 `prompt_cache_key` 下不同 `codexWindowId` 不会串用 `previous_response_id`;新增 affinity 单元测试覆盖 window identity 和显式 prompt cache anchor。
- invalid encrypted content 驱逐已接入:Rust 会识别 `error`/`response.failed` 中的 `invalid_encrypted_content` 或等价的 invalid/encrypted/content 文本,并按 `account_id + conversation_id + variant_hash` 驱逐 reasoning replay cache;non-streaming、HTTP SSE streaming audit、WebSocket streaming audit 都使用同一驱逐方法。
- 本轮新增 `tests/fixtures/responses/websocket/invalid_encrypted_content.json` 与 `tests/codex_serving/responses_websocket.rs::v1_responses_websocket_should_evict_reasoning_replay_after_invalid_encrypted_content`,覆盖 replay item 被上游判无效后,后续 implicit resume 不再注入同一个 encrypted reasoning item。
已对齐:
- OpenAI chat `clientConversationId` 已接入。原版 `/v1/chat/completions` 会把 OpenAI `user` 字段作为 shared handler 的 `clientConversationId`;Rust 现在将 `ChatCompletionRequest.user` 写入 Codex 请求的内部 `client_conversation_id` 和 `prompt_cache_key`,进入上游前仍按账号作用域转换为 `cp_...` session id。
- 本轮新增 `tests/codex_serving/chat_completions.rs::chat_completions_should_use_user_as_client_conversation_id`,覆盖 OpenAI chat `user` 进入上游 `prompt_cache_key`。
未完全对齐:
- 当前剩余风险主要是覆盖面,而不是已确认的 OpenAI 行为缺失:还需要更多端到端用例证明 missing/unanswered tool calls、self-contained replay、SQLite restore 后 implicit resume 等边界不会回归。
非目标:
- usage hint 不移植。它在原版只影响非 OpenAI cache token 展示/估算,不影响 OpenAI chat translator 或 Responses passthrough;Rust 只保留 OpenAI 链路时不需要该兼容层。
缺口/后续动作:
- 增加端到端测试:missing/unanswered tool calls 跳过、self-contained replay、WS not_found restore full-history、SQLite restore 后继续 implicit resume、variant identity 分支隔离。
+3 -3
View File
@@ -306,7 +306,7 @@ Validation:
- `rotationStrategy` must be `least_used`, `round_robin`, or `sticky`.
- Count/capacity/retention fields that represent concurrency, capacity, intervals, or retention must be greater than zero where zero would disable required service behavior.
- Quota warning thresholds must be between `0` and `100`.
- Removed settings such as `proxyUrl`, `openaiApiKey`, `ollama`, provider settings, proxy API key settings, and Electron/self-update settings return HTTP `400` with body code `40001`.
- Unknown settings fields return HTTP `400` with body code `40001`.
### `GET /api/admin/logs`
@@ -602,7 +602,7 @@ Success response:
### `POST /api/admin/accounts/import`
Imports accounts into encrypted SQLite storage. The request body uses the native Rust import format. The only supported shape is an object with `accounts`; Sub2API payloads, proxy/runtime fields, `credentials`/`tokens` wrappers, snake_case token fields, and other removed old-project aliases are rejected by producing no importable accounts.
Imports accounts into encrypted SQLite storage. The request body uses the native Rust import format. The only supported shape is an object with `accounts`; unknown container or account fields are rejected by producing no importable accounts.
Request:
@@ -641,7 +641,7 @@ Success response:
### `GET /api/admin/accounts/export`
Exports native Rust account backups. The default format is native; `format=native` is the only explicit format accepted. Removed aliases such as `format=full` and removed external formats such as `format=sub2api` return HTTP `400`.
Exports native Rust account backups. The default format is native; `format=native` is the only explicit format accepted. Any other format returns HTTP `400`.
Optional query:
+1 -1
View File
@@ -1114,6 +1114,6 @@ cargo clippy --all-targets --all-features --locked -- -D warnings
- Extract v1 services before admin services because v1 is the core proxy path.
- Keep admin and v1 public response contracts separate.
- Treat local `cpr_` API key management as a Rust-local admin utility, not TypeScript
provider-key parity.
local client key parity.
- Do not add proxy pools, non-OpenAI providers, frontend/Electron flows, or workspace
crate boundaries as part of this cleanup.
+758
View File
@@ -0,0 +1,758 @@
# Codex Proxy RS Design Language
This document defines the visual language for the Codex Proxy RS admin console.
It is the source of truth for Pencil design iterations and future frontend
implementation.
## 1. Design Positioning
Codex Proxy RS is an operational console for account pools, routing, quota,
usage, service health, and logs. The interface should feel quiet, precise, and
technical, closer to Codex/OpenAI tooling than to a colorful SaaS dashboard.
The design language is:
- restrained: white surfaces, soft gray canvas, minimal decoration;
- operational: dense enough for repeated daily use, but not cramped;
- numeric: important values use monospace and stable widths;
- state-driven: color is reserved for status, risk, and selection;
- modern: hierarchy comes from spacing, typography, and subtle shadow, not heavy
borders.
Avoid marketing-page composition, decorative gradients, loud cards, nested card
stacks, heavy borders, and arbitrary accent colors.
## 2. Color System
Use a neutral-first palette. Accent colors must communicate state or selection.
Do not create new blues, greens, grays, or reds unless the token system is
updated.
### Core Tokens
| Token | Value | Usage |
| --- | --- | --- |
| `--cp-bg-page` | `#F6F8FB` | Main app canvas |
| `--cp-bg-surface` | `#FFFFFF` | Sidebar, cards, popovers |
| `--cp-bg-subtle` | `#F8FAFC` | Table rows, inner status areas |
| `--cp-bg-muted` | `#F1F5F9` | Segmented controls, neutral badges |
| `--cp-bg-nav-active` | `#E9EEF5` | Selected sidebar navigation row |
| `--cp-text-primary` | `#0E1726` | Main text and headings |
| `--cp-text-strong` | `#111827` | Brand mark and high emphasis |
| `--cp-text-secondary` | `#64748B` | Descriptions, labels |
| `--cp-text-muted` | `#94A3B8` | Table headers, low-emphasis labels |
| `--cp-white` | `#FFFFFF` | Inverse text |
| `--cp-transparent` | `#FFFFFF00` | Transparent Pencil fills and inactive containers |
| `--cp-overlay-scrim` | `#0E17264D` | Modal and blocking interaction overlay |
### Semantic Color Model
The state system follows the same structure as mature component libraries:
Element Plus exposes `base`, `light-*`, and `dark-*` levels per semantic color;
Ant Design derives alias tokens such as `colorSuccessBg`,
`colorSuccessBgHover`, `colorSuccessBorder`, `colorSuccessHover`, and
`colorSuccessActive` from seed colors. Codex Proxy RS uses the same token shape,
but keeps its own quieter operational palette.
Use `pressed` for the momentary mouse-down state. Use `active` for a persistent
selected/current state. Do not use `active` to mean hover.
### Neutral Interaction Tokens
| Token | Value | Usage |
| --- | --- | --- |
| `--cp-default-bg` | `#F8FAFC` | Default control background |
| `--cp-default-bg-hover` | `#F1F5F9` | Default control hover |
| `--cp-default-bg-active` | `#E9EEF5` | Selected neutral row or nav item |
| `--cp-default-border` | `#E2E8F0` | Subtle control boundary when needed |
| `--cp-default-border-hover` | `#CBD5E1` | Hover boundary when needed |
| `--cp-default-text` | `#0E1726` | Default control text |
| `--cp-default-text-hover` | `#111827` | Hover text |
| `--cp-default-text-active` | `#0B1220` | Active text |
| `--cp-disabled-bg` | `#F1F5F9` | Disabled control background |
| `--cp-disabled-border` | `#E2E8F0` | Disabled boundary |
| `--cp-disabled-text` | `#94A3B8` | Disabled text |
| `--cp-disabled-icon` | `#CBD5E1` | Disabled icon |
### Semantic State Tokens
Each semantic state has the same token shape. `bg` is for quiet badges,
filled rows, selected light controls, and notification icon wells. `solid` is
reserved for rare high-emphasis controls and charts.
| State | Base | Hover | Pressed | Background | Bg Hover | Active Bg | Border | Border Hover | Text |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `info` | `#2563EB` | `#1D4ED8` | `#1E40AF` | `#EEF6FF` | `#DBEAFE` | `#BFDBFE` | `#93C5FD` | `#60A5FA` | `#2563EB` |
| `normal` | `#0F9F9A` | `#0D8D88` | `#0B7370` | `#ECFDFD` | `#DDF7F6` | `#C7EFEE` | `#A7DFDD` | `#6BC6C2` | `#0B7370` |
| `success` | `#12B981` | `#0EA371` | `#0B865F` | `#ECFDF5` | `#DDFBEA` | `#C8F5DC` | `#B7EAD0` | `#82D9B3` | `#047857` |
| `warning` | `#F59E0B` | `#D97706` | `#B45309` | `#FFFBEB` | `#FEF3C7` | `#FDE68A` | `#FCD34D` | `#FBBF24` | `#B45309` |
| `danger` | `#EF4444` | `#DC2626` | `#B91C1C` | `#FEF2F2` | `#FEE2E2` | `#FECACA` | `#FCA5A5` | `#F87171` | `#B91C1C` |
### Solid Foreground Tokens
Do not assume every colored solid background uses white text. Foreground tokens
are selected by contrast against the actual background. Normal text should target
at least `4.5:1` contrast; small data labels should use the stronger foreground
when there is any doubt.
| State | Base Foreground | Hover Foreground | Pressed Foreground |
| --- | --- | --- | --- |
| `info` | `--cp-info-on` `#FFFFFF` | `--cp-info-hover-on` `#FFFFFF` | `--cp-info-pressed-on` `#FFFFFF` |
| `normal` | `--cp-normal-on` `#0B1220` | `--cp-normal-hover-on` `#0B1220` | `--cp-normal-pressed-on` `#FFFFFF` |
| `success` | `--cp-success-on` `#0B1220` | `--cp-success-hover-on` `#0B1220` | `--cp-success-pressed-on` `#FFFFFF` |
| `warning` | `--cp-warning-on` `#0B1220` | `--cp-warning-hover-on` `#0B1220` | `--cp-warning-pressed-on` `#FFFFFF` |
| `danger` | `--cp-danger-on` `#0B1220` | `--cp-danger-hover-on` `#FFFFFF` | `--cp-danger-pressed-on` `#FFFFFF` |
### Compatibility Aliases
The older tint token names remain as aliases during design iteration:
| Alias | Use |
| --- | --- |
| `--cp-accent` | Same as `--cp-info` |
| `--cp-accent-tint` | Same as `--cp-info-bg` |
| `--cp-info-tint` | Same as `--cp-info-bg` |
| `--cp-normal-tint` | Same as `--cp-normal-bg` |
| `--cp-success-tint` | Same as `--cp-success-bg` |
| `--cp-warning-tint` | Same as `--cp-warning-bg` |
| `--cp-danger-tint` | Same as `--cp-danger-bg` |
| `--cp-teal` | Same as `--cp-normal` |
| `--cp-teal-tint` | Same as `--cp-normal-bg` |
Legacy Pencil-only names such as `primary-blue`, `purple`, `success-green`,
`warning-orange`, `error-red`, `cyan`, `info`, `normal`, `bg-card`,
`bg-sidebar`, `text-primary`, `text-secondary`, and `text-muted` are not part of
the design language. Do not use them in new components; migrate existing work to
`cp-*` tokens when touching the affected layer.
### Color Rules
- Use `--cp-bg-nav-active` with `--cp-text-strong` for selected sidebar
navigation.
- Use `--cp-info` for selected tabs, focused actions, and primary data lines.
- Use state colors only when the value is actually stateful.
- Prefer semantic `bg` tokens for component states. Use `solid` state colors
only when a surface must visually dominate.
- When using `base`, `hover`, or `pressed` as a solid background, use the
matching `on` foreground token. Never place white text on warning/success
solids by default.
- Hover uses the `hover` or `bg-hover` token. Mouse-down uses `pressed`.
Persistent selection uses `bg-active` plus state text.
- Default component interactions should stay in the light token layer:
default uses neutral bg, hover uses semantic bg, pressed uses semantic
`bg-active`, and selected uses neutral active bg plus a small semantic
indicator. Do not jump from a light default control directly into a solid
saturated pressed state unless the component is already a solid primary
action.
- Operational status semantics:
- `正常`: `--cp-normal`;
- `成功`: `--cp-success`;
- `警告`: `--cp-warning`;
- `错误`: `--cp-danger`.
- Feedback toast semantics:
- `成功`: `--cp-success`;
- `警告`: `--cp-warning`;
- `失败`: `--cp-danger`;
- `信息`: `--cp-info`.
- Do not assign a unique color to every card.
- Prefer text plus small state color over large filled color blocks.
- Keep charts limited to `accent`, `success`, `warning`, and `danger`.
## 3. Typography
Use two font roles.
| Role | Font | Usage |
| --- | --- | --- |
| Interface | `Inter` | Navigation, labels, headings, controls |
| Numeric/data | `JetBrains Mono` | Metrics, IDs, timestamps, status codes, latency |
### Type Scale
| Token | Size | Line Height | Weight | Usage |
| --- | ---: | ---: | ---: | --- |
| `--cp-text-display` | `34` | `1.15` | `760-800` | Page title only |
| `--cp-text-section` | `20` | `1.15` | `720-760` | Card titles |
| `--cp-text-brand` | `17` | `1.1` | `700-720` | Sidebar brand |
| `--cp-text-body` | `14` | `1.2` | `600-650` | Navigation and body text |
| `--cp-text-label` | `13` | `1.15` | `600-700` | Labels and row names |
| `--cp-text-caption` | `12` | `1.15` | `600` | Metadata and helper text |
| `--cp-text-micro` | `11` | `1.15` | `600` | Dense badges and table hints |
| `--cp-number-lg` | `28` | `1.05` | `760-800` | Top metric values |
| `--cp-number-md` | `24` | `1.1` | `760` | Secondary numeric summaries |
| `--cp-number-sm` | `12` | `1.15` | `650-720` | Table and compact numeric values |
### Typography Rules
- Avoid using `800+` weight except for page title or critical numeric values.
- Labels should not exceed `700`.
- Use `JetBrains Mono` for values that must visually align or compare.
- Do not use negative letter spacing.
- Do not scale font size with viewport width.
## 4. Spacing System
Use a 4px base grid, with `24px` as the primary dashboard rhythm.
| Token | Value | Usage |
| --- | ---: | --- |
| `--cp-space-1` | `4` | Micro gaps |
| `--cp-space-2` | `8` | Icon/text gaps |
| `--cp-space-3` | `12` | Compact padding |
| `--cp-space-4` | `16` | Control padding |
| `--cp-space-5` | `20` | Dense card internals |
| `--cp-space-6` | `24` | Main card gaps and standard card padding |
| `--cp-space-7` | `28` | Large card internal padding |
| `--cp-space-8` | `32` | Header groups |
### Layout Rhythm
- Main card gaps: `24px` horizontally and vertically.
- Large card internal padding: `28px` when content is complex.
- Top metric internal padding: `24px`.
- Table row height: `52-56px`, never below `48px`.
- Popover item height: `36-40px`.
- Sidebar navigation row height: `46px`.
## 5. Radius System
Use separate radius groups for controls and surfaces. Do not let card, nav, or
pill radii leak into form controls.
| Token | Value | Usage |
| --- | ---: | --- |
| `--cp-border-radius-base` | `4` | Inputs, selects, date/time triggers |
| `--cp-button-radius-base` | `6` | Large/default buttons |
| `--cp-button-radius-small` | `4` | Small buttons |
| `--cp-border-radius-small` | `2` | Small square controls |
| `--cp-radius-sm` | `10` | Icon backgrounds, table rows |
| `--cp-radius-md` | `12` | Nav items, segmented controls |
| `--cp-radius-lg` | `16` | Metric cards |
| `--cp-radius-xl` | `18` | Large dashboard cards, popovers |
| `--cp-radius-xxl` | `22` | Component spec panels and demo canvases |
| `--cp-checkbox-radius` | `5` | Checkbox squares |
| `--cp-tag-radius` | `7` | Status tags and removable selected values |
| `--cp-date-cell-radius` | `8` | Date picker cells |
| `--cp-icon-button-radius` | `8` | Compact square icon buttons |
| `--cp-radius-pill` | `999` | Status pills only |
Rules:
- Buttons never use card, nav, or pill radius tokens.
- Main cards: `16-18px`.
- Component specification panels may use `22px` because they act as demo
canvases, not product dashboard cards.
- Nav rows: `10-12px`. Form buttons use a softer Codex Proxy RS override:
base `6px`, small `4px`.
- Tags, checkboxes, date cells, and compact icon buttons use their dedicated
radius tokens instead of borrowing card or button radii.
- Status pills: `999px`.
- Avoid using pill radius on large rectangular UI.
## 6. Shadow System
Shadows create layer hierarchy. They should not look like borders or glow.
| Token | Value | Usage |
| --- | --- | --- |
| `--cp-shadow-sidebar` | `2px 0 12px -12px #0E172607` | Sidebar separation |
| `--cp-shadow-card` | `0 10px 22px -18px #0E172614` | Standard cards |
| `--cp-shadow-control` | `0 8px 18px -16px #0E172614` | Header controls, selected tab |
| `--cp-shadow-popover` | `0 16px 34px -18px #0E172622` | Dropdown and floating menus |
Pencil stores shadow geometry and shadow color separately. Use these color
variables when applying shadow effects in `.pen` files:
| Token | Value | Usage |
| --- | --- | --- |
| `--cp-shadow-sidebar-color` | `#0E172607` | Sidebar shadow color |
| `--cp-shadow-card-color` | `#0E172614` | Card and control shadow color |
| `--cp-shadow-popover-color` | `#0E172622` | Popover and toast shadow color |
Rules:
- Never use borders as primary page separation.
- Card shadows must be weaker than popover shadows.
- Sidebar shadow must be barely visible.
- Avoid stacking multiple heavy shadows in the same local area.
## 7. Element Plus Token Alignment
Use Element Plus as the behavioral reference for base component variables, while
keeping Codex Proxy RS colors and shadow language.
| Element Plus token | Codex Proxy RS token | Value |
| --- | --- | --- |
| `--el-component-size-large` | `--cp-component-size-large` | `40px` |
| `--el-component-size` | `--cp-component-size-default` | `32px` |
| `--el-component-size-small` | `--cp-component-size-small` | `24px` |
| `--el-border-radius-base` | `--cp-border-radius-base` | `4px` |
| `--el-border-radius-small` | `--cp-border-radius-small` | `2px` |
| `--el-border-radius-round` | `--cp-border-radius-round` | `20px` |
| Element Plus button radius + product override | `--cp-button-radius-base` | `6px` |
| Element Plus small button radius + product override | `--cp-button-radius-small` | `4px` |
| `--el-input-padding-horizontal-large` | `--cp-input-padding-x-large` | `16px` |
| `--el-input-padding-horizontal` | `--cp-input-padding-x-default` | `12px` |
| `--el-input-padding-horizontal-small` | `--cp-input-padding-x-small` | `8px` |
| `--el-select-option-height` | `--cp-select-option-height` | `34px` |
| `--el-date-editor-width` | `--cp-date-editor-width` | `220px` |
| `--el-date-editor-daterange-width` | `--cp-date-editor-daterange-width` | `350px` |
| `--el-date-editor-datetimerange-width` | `--cp-date-editor-datetimerange-width` | `400px` |
| `--el-popper-border-radius` | `--cp-popper-radius` | `4px` |
Rules:
- Input, select, date, time, button, and similar form triggers share the same
component size tokens.
- Do not create one-off heights such as `34px`, `36px`, or `46px` for normal
form triggers. `34px` is reserved for select option rows and picker cells.
- Do not create one-off rounded control radii such as `9px`, `10px`, or `12px`.
Use the Element Plus radius model above.
- Buttons intentionally use a slightly softer product override: `6px` for
large/default and `4px` for small. Other form controls stay on the Element
Plus base radius.
## 8. Icon System
Use Lucide icons.
| Context | Size | Color |
| --- | ---: | --- |
| Sidebar nav | `20` | active `text-strong`, inactive `text-secondary` |
| Card icon | `18` | state token |
| Service row icon | `14` | state token |
| Toolbar icon | `16-19` | semantic token |
| Brand icon | `18` | white on dark surface |
| Notification icon | `18-19` | `text-secondary`, badge uses state token |
Rules:
- Icons support recognition; they do not replace essential text in expanded
views.
- Collapsed sidebar may show icons only.
- Sidebar nav icons and labels must share the same vertical center.
- Prefer simple line icons. Avoid custom decorative icon compositions unless
they become a formal brand asset.
## 9. Layout System
### Canvas
- Desktop design target: `1920px` wide.
- Height may grow.
- Sidebar states:
- collapsed: `88px`;
- expanded: `280px`.
### Main Content
- Main content width should be stable across sidebar states.
- Main content outer gutters should feel balanced after accounting for sidebar.
- Main card grid gap: `24px`.
- Prefer two primary columns before three.
- Use full-width rows for high-complexity data sections.
### Sidebar
- Sidebar background: `--cp-bg-surface`.
- Separation: shadow, not visible border.
- Brand area should be compact and quiet.
- Collapse control sits near bottom, not in the brand area.
- Nav starts high enough that the top area does not feel empty.
## 10. Component Rules
### Form Controls
- Basic form controls follow the Element Plus size model while retaining
Codex Proxy RS colors and shadows:
- `large`: `40px`;
- `default`: `32px`;
- `small`: `24px`.
- These values map to Element Plus `--el-component-size-large`,
`--el-component-size`, and `--el-component-size-small`.
- Form control radius follows Element Plus `--el-border-radius-base`: `4px`.
Small square controls use `--el-border-radius-small`: `2px`. Buttons use
`--cp-button-radius-base` and `--cp-button-radius-small`.
- Input horizontal padding follows Element Plus input padding:
`large 16px`, `default 12px`, `small 8px`.
- Default input surface uses `--cp-bg-subtle`; do not depend on heavy borders.
- Focused input uses `--cp-bg-surface` plus `--cp-shadow-control`.
- Error input uses `--cp-danger-tint`, `--cp-danger`, and a short helper row.
- Disabled input uses `--cp-bg-muted` and `--cp-text-muted`.
- Labels use `--cp-text-caption` scale and `--cp-text-secondary`.
- Prefix icons, suffix icons, clear controls, and validation icons must be
vertically centered with the input value.
- Time, IDs, keys, and stable technical values use `JetBrains Mono`.
### Selectors and Time Pickers
- Basic selector trigger sizes follow `large` `40px`, `default` `32px`, and
`small` `24px`; these share the same control height tokens as input and
button.
- Date and time picker triggers are date-editor inputs and therefore also use
the same `40 / 32 / 24px` component size tokens. Do not create a separate
`46px` date trigger height.
- Trigger radius: `4px`.
- Dropdown and picker surfaces use `--cp-bg-surface` and
`--cp-shadow-popover`; do not add borders.
- Dropdown radius follows Element Plus popper radius: `4px`.
- Select option row height follows Element Plus `--el-select-option-height`:
`34px`.
- Selected menu rows use `--cp-info-bg` with `--cp-info-text`; hover rows use
`--cp-default-bg-hover`; disabled rows use `--cp-disabled-text`.
- Multi-select values use compact tags inside the trigger, not separate chips
outside the component. Selected tags must include a close `x` affordance when
the value can be removed.
- Time picker values use `JetBrains Mono`.
### Date Pickers
- Required variants: `单日期`, `日期区间`, and `单时间`.
- Trigger height uses the shared form control size tokens, defaulting to `32px`;
trigger radius uses `4px`.
- Date editor widths follow Element Plus defaults unless the surrounding product
layout requires a wider field: single date/time `220px`, date range `350px`,
datetime range `400px`.
- Date and time picker popovers use `--cp-bg-surface` or
`--cp-bg-subtle` with `--cp-shadow-popover`; avoid visible borders.
- Single date follows the same Element Plus style interaction model as the
range picker: the trigger only displays the current value and clear affordance;
shortcuts live inside the popover sidebar when present; month navigation,
date grid, selected value, and apply actions live inside the popover.
- Date range uses one trigger field, not separate start and end inputs. The
displayed value format is `YYYY-MM-DD - YYYY-MM-DD`.
- Date range follows an Element Plus style interaction model: the trigger only
displays the current value; shortcuts live inside the popover sidebar; the
main popover body shows two adjacent month panels; the footer shows the
current range plus clear, cancel, and confirm actions.
- Range month panels show a stable 7-column by 6-row grid so keyboard movement,
hover preview, and disabled days do not change layout height.
- Date cells are `44-48px` wide and `32-38px` high.
- Date cell text uses `JetBrains Mono` for stable numeric alignment.
- Default dates use transparent fill and `--cp-text-primary`.
- Hover dates use `--cp-default-bg-hover` and `--cp-text-strong`.
- Today uses `--cp-info-bg` with `--cp-info-text`.
- Selected dates use `--cp-info` with `--cp-info-on`.
- Disabled or outside-month dates use `--cp-disabled-bg` or transparent fill
with `--cp-disabled-text`; they should never look selectable.
- Range endpoints use the selected style. Range middle dates use
`--cp-info-bg` with `--cp-info-text` so the interval reads as continuous
without heavy outlines.
- Single-time pickers use fixed columns for hour, minute, and second. Time rows
are `34-36px` high, use `JetBrains Mono`, and must not be clipped by the
popover. The footer actions belong inside the popover, not next to the
trigger.
- Date component state previews should show real trigger and date-cell variants:
default, focus, disabled, error, hover, today, selected, and disabled date
cells. Do not replace state previews with explanatory rule cards.
- Picker action buttons stay quiet: secondary actions use `--cp-bg-subtle`;
primary apply actions use `--cp-info` with `--cp-info-on`.
- Picker footer action buttons use the default button height of `32px`.
`34px` is reserved for picker option rows or date cells, not normal buttons.
### Buttons and Checkboxes
- Buttons follow the Element Plus size model:
- `large`: `40px` height;
- `default`: `32px` height;
- `small`: `24px` height.
- Do not create `34px` default buttons. If a control is visually button-like
and triggers an action, it must use one of the three button sizes above.
- Button radius: `6px` for large/default, `4px` for small.
- Button icon size: `14-16px`; small buttons use `12px` icons.
- Primary actions use `--cp-info` with `--cp-info-on` when they are the main
apply action. Quiet primary-like actions can use `--cp-info-bg` with
`--cp-info-text`.
- Reserve solid accent or dark filled buttons for a single screen-level primary
action only; do not use multiple heavy filled buttons inside component groups.
- Secondary actions use `--cp-bg-subtle`.
- Destructive actions use `--cp-danger-tint` and `--cp-danger`.
- Disabled actions use `--cp-bg-muted`, `--cp-text-muted`, and reduced
opacity.
- Button states must be represented as normal, hover, loading, disabled, and
text-button variants when building component specs.
- Checkbox size: `18px`; radius: `5px`.
- Unchecked checkbox squares use `--cp-bg-surface` with
`--cp-default-border-hover` as a light inner boundary on subtle surfaces.
- Checked checkbox rows may use `--cp-info-tint`; the checkbox square itself
stays light and the check icon uses `--cp-accent`.
- Mixed checkbox squares use `--cp-info-tint` with an `--cp-accent` minus icon.
- Avoid solid accent-filled checkbox squares inside light component groups.
- Checkbox label and box must share a vertical center.
### Brand Area
- Collapsed: only the brand icon.
- Expanded:
- icon: dark square terminal mark;
- primary name: `Codex`;
- secondary text: `Proxy RS · version`.
- Brand should not use a badge that looks like an action.
### Metric Cards
- Use neutral white surfaces.
- The primary value is the visual anchor.
- Detail band uses subtle background, not divider lines.
- Metric values must support large numbers without changing layout.
- Do not use top colored bars.
### Large Cards
- Title at top-left, action controls at top-right.
- Card padding should be visually equal on all sides.
- Do not put card sections inside card-looking nested cards unless the inner
surface is functional.
### Tables and Logs
- Row height: `52-56px`.
- Header height: `40-44px`.
- Use `JetBrains Mono` for time, IDs, codes, and latency.
- Tables follow Element Plus structure: optional selection column, stable
header row, striped body rows, semantic status tags, right-aligned operation
text buttons, and pagination at the footer when row count exceeds the visible
page.
- Table header cells and body cells must share identical column widths and the
same x-origin. Do not position body rows independently from the header.
- Fixed-height log viewport may clip offscreen rows to imply scroll.
- Level labels should be Chinese in the UI.
### Popovers
- Width should match content, not the trigger width.
- Use `--cp-shadow-popover`.
- Do not add borders.
- Menu rows use `36-40px` height.
- Destructive actions use danger text and icon only.
### Modal Dialogs
- Use modals for creation, confirmation, destructive confirmation, and
medium-complexity forms that should not navigate away from the current page.
- Modal overlay uses `--cp-overlay-scrim`.
- Modal surface uses `--cp-bg-surface`, `--cp-shadow-popover`, and no visible
border.
- Default widths: `480px` for confirmation, `560px` for standard forms,
`720px` for dense forms.
- Internal padding: `28px`; header icon well: `36-44px`; close button: `28px`.
- Footer actions are right-aligned. The primary action is visually strongest;
destructive actions use danger tint and danger text rather than a large red
solid block by default.
- Modal semantic variants are `信息`, `警告`, `失败`, and `成功`. Variant examples
should keep the same compact modal anatomy: icon well, title, one short
description, close button, and footer actions only.
- Do not add tip rows, form rows, or unrelated scenario content inside modal
variant examples. If explanation is needed, place it outside the component.
- Modal footer buttons use the default button height of `32px`.
### Feedback Toast
- Feedback toast appears as a floating message after a user or system operation.
- It is not a persistent top-bar notification entry.
- Width: `320-360px`; height: `52-64px`.
- Position: top-right of the active workspace, offset by `24px`.
- Use `--cp-shadow-popover`; do not add borders.
- Icon background uses the matching tint token.
- Toast text is concise:
- success: `成功`;
- warning: `警告`;
- failure: `失败`;
- info: `信息`.
- Use `--cp-success`, `--cp-warning`, `--cp-danger`, and `--cp-info` for the
leading icon and optional state accent.
- Feedback toasts do not include inline actions.
- Every toast includes a right-aligned close button.
- Close button: `28px`, transparent background, `x` icon at `16px`,
`--cp-text-muted`; it stays vertically centered with the icon and text.
### Status Rows
- Use subtle row backgrounds.
- Icon, label, value, and metadata should share a vertical center.
- State color belongs on icon/value, not the entire row.
## 11. Copy Rules
- Use user-facing names, not internal implementation terms.
- Avoid explanatory UI copy such as "moved to top" or "dropdown opened".
- Labels should describe what the user recognizes: `账号`, `请求`, `Token`,
`平均响应`, `服务状态`, `事件日志`.
- Avoid vague system phrases like `链路状态` unless the product has a concrete
user-facing definition.
## 12. Design Quality Checklist
Before accepting a new screen or iteration:
- Main gutters are balanced for the current sidebar state.
- Main card gaps are consistently `24px`.
- Top, bottom, left, and right padding inside each card are visually equal.
- Text is vertically centered inside rows, badges, and buttons.
- Text on colored backgrounds uses the matching `on` foreground token and is
readable at small sizes.
- No major layout boundary depends on a visible border.
- Colors come from this document.
- Font weights do not exceed the defined role.
- Tables have comfortable row height.
- Large numbers fit without layout shift.
- Popovers appear above cards with stronger hierarchy than cards.
- Both collapsed and expanded sidebar states are checked.
## 13. Implementation Token Draft
```css
:root {
--cp-bg-page: #F6F8FB;
--cp-bg-surface: #FFFFFF;
--cp-bg-subtle: #F8FAFC;
--cp-bg-muted: #F1F5F9;
--cp-bg-nav-active: #E9EEF5;
--cp-text-primary: #0E1726;
--cp-text-strong: #111827;
--cp-text-secondary: #64748B;
--cp-text-muted: #94A3B8;
--cp-white: #FFFFFF;
--cp-transparent: #FFFFFF00;
--cp-overlay-scrim: #0E17264D;
--cp-accent: #2563EB;
--cp-component-size-large: 40px;
--cp-component-size-default: 32px;
--cp-component-size-small: 24px;
--cp-border-radius-base: 4px;
--cp-border-radius-small: 2px;
--cp-button-radius-base: 6px;
--cp-button-radius-small: 4px;
--cp-border-radius-round: 20px;
--cp-input-padding-x-large: 16px;
--cp-input-padding-x-default: 12px;
--cp-input-padding-x-small: 8px;
--cp-select-option-height: 34px;
--cp-select-item-height: 24px;
--cp-date-editor-width: 220px;
--cp-date-editor-daterange-width: 350px;
--cp-date-editor-datetimerange-width: 400px;
--cp-popper-radius: 4px;
--cp-default-bg: #F8FAFC;
--cp-default-bg-hover: #F1F5F9;
--cp-default-bg-active: #E9EEF5;
--cp-default-border: #E2E8F0;
--cp-default-border-hover: #CBD5E1;
--cp-default-text: #0E1726;
--cp-default-text-hover: #111827;
--cp-default-text-active: #0B1220;
--cp-disabled-bg: #F1F5F9;
--cp-disabled-border: #E2E8F0;
--cp-disabled-text: #94A3B8;
--cp-disabled-icon: #CBD5E1;
--cp-info: #2563EB;
--cp-info-hover: #1D4ED8;
--cp-info-pressed: #1E40AF;
--cp-info-on: #FFFFFF;
--cp-info-hover-on: #FFFFFF;
--cp-info-pressed-on: #FFFFFF;
--cp-info-bg: #EEF6FF;
--cp-info-bg-hover: #DBEAFE;
--cp-info-bg-active: #BFDBFE;
--cp-info-border: #93C5FD;
--cp-info-border-hover: #60A5FA;
--cp-info-text: #2563EB;
--cp-success: #12B981;
--cp-success-hover: #0EA371;
--cp-success-pressed: #0B865F;
--cp-success-on: #0B1220;
--cp-success-hover-on: #0B1220;
--cp-success-pressed-on: #FFFFFF;
--cp-success-bg: #ECFDF5;
--cp-success-bg-hover: #DDFBEA;
--cp-success-bg-active: #C8F5DC;
--cp-success-border: #B7EAD0;
--cp-success-border-hover: #82D9B3;
--cp-success-text: #047857;
--cp-normal: #0F9F9A;
--cp-normal-hover: #0D8D88;
--cp-normal-pressed: #0B7370;
--cp-normal-on: #0B1220;
--cp-normal-hover-on: #0B1220;
--cp-normal-pressed-on: #FFFFFF;
--cp-normal-bg: #ECFDFD;
--cp-normal-bg-hover: #DDF7F6;
--cp-normal-bg-active: #C7EFEE;
--cp-normal-border: #A7DFDD;
--cp-normal-border-hover: #6BC6C2;
--cp-normal-text: #0B7370;
--cp-warning: #F59E0B;
--cp-warning-hover: #D97706;
--cp-warning-pressed: #B45309;
--cp-warning-on: #0B1220;
--cp-warning-hover-on: #0B1220;
--cp-warning-pressed-on: #FFFFFF;
--cp-warning-bg: #FFFBEB;
--cp-warning-bg-hover: #FEF3C7;
--cp-warning-bg-active: #FDE68A;
--cp-warning-border: #FCD34D;
--cp-warning-border-hover: #FBBF24;
--cp-warning-text: #B45309;
--cp-danger: #EF4444;
--cp-danger-hover: #DC2626;
--cp-danger-pressed: #B91C1C;
--cp-danger-on: #0B1220;
--cp-danger-hover-on: #FFFFFF;
--cp-danger-pressed-on: #FFFFFF;
--cp-danger-bg: #FEF2F2;
--cp-danger-bg-hover: #FEE2E2;
--cp-danger-bg-active: #FECACA;
--cp-danger-border: #FCA5A5;
--cp-danger-border-hover: #F87171;
--cp-danger-text: #B91C1C;
--cp-teal: #0F9F9A;
--cp-accent-tint: #EEF6FF;
--cp-info-tint: #EEF6FF;
--cp-success-tint: #ECFDF5;
--cp-normal-tint: #ECFDFD;
--cp-warning-tint: #FFFBEB;
--cp-danger-tint: #FEF2F2;
--cp-teal-tint: #ECFDFD;
--cp-space-1: 4px;
--cp-space-2: 8px;
--cp-space-3: 12px;
--cp-space-4: 16px;
--cp-space-5: 20px;
--cp-space-6: 24px;
--cp-space-7: 28px;
--cp-space-8: 32px;
--cp-radius-sm: 10px;
--cp-radius-md: 12px;
--cp-radius-lg: 16px;
--cp-radius-xl: 18px;
--cp-radius-xxl: 22px;
--cp-checkbox-radius: 5px;
--cp-tag-radius: 7px;
--cp-date-cell-radius: 8px;
--cp-icon-button-radius: 8px;
--cp-radius-pill: 999px;
--cp-shadow-sidebar: 2px 0 12px -12px #0E172607;
--cp-shadow-card: 0 10px 22px -18px #0E172614;
--cp-shadow-control: 0 8px 18px -16px #0E172614;
--cp-shadow-popover: 0 16px 34px -18px #0E172622;
--cp-shadow-sidebar-color: #0E172607;
--cp-shadow-card-color: #0E172614;
--cp-shadow-popover-color: #0E172622;
}
```
File diff suppressed because one or more lines are too long
@@ -0,0 +1,166 @@
---
name: user-collaboration-standards
description: Use when working with this project owner on codex-proxy-rs design, docs, debugging, refactoring, verification, Pencil files, or agent workflow decisions.
---
# User Collaboration Standards
## Core Principle
Work as a rigorous collaborator, not as a presenter. The user repeatedly pushes
for real operational truth, real product behavior, consistent design language,
and verified outcomes. Do the work in the shared files, verify it, then report
only the important result.
## Required Sub-Skills
- For visual design or Pencil work: use `frontend-design` and `pencil-design`.
- For Rust edits: use `rust-best-practices`.
- Before saying work is complete: use `superpowers:verification-before-completion`.
- When creating or updating a skill: use `skill-creator`; if deploying a real
skill, also use `superpowers:writing-skills`.
## Communication Rules
- Be direct and evidence-based. Avoid cheerleading, vague reassurance, and
decorative language.
- If the user asks for a reusable prompt or wording, provide a ready-to-paste
block, not only conceptual guidance.
- If a warning, mismatch, or suspicious behavior appears, trace the source
through config, process state, logs, database, network route, and code before
concluding.
- Preserve the exact success condition. Do not accept a weaker proof because it
is convenient.
- When blocked or uncertain, state the boundary precisely: what is known, what
was checked, and what remains unproven.
- If the user says to stop or "别改了", stop visual changes immediately and only
summarize the current state.
## Design Collaboration Rules
### Designer Mindset
- Design for the actual operator using the admin console, not for a screenshot.
- Do not add explanatory UI copy that describes the design work itself.
Rejected examples include labels like "账号入口已移至顶部" or text that explains
why a component exists.
- Do not invent product concepts or labels unless the codebase/product already
supports them. Rejected examples include vague terms like "链路状态" and
action labels like "处理入口 / 查看账号池" when they do not match a real user
workflow.
- Use Chinese labels for user-facing UI in this admin product.
- Treat repeated visual criticism as a system issue, not a one-off pixel issue.
If the user points out bad spacing in one row, audit similar rows and tokens.
### Visual Language
- Keep the console quiet, precise, and operational: white surfaces, soft gray
page canvas, restrained color, stable numeric layout, and low-noise hierarchy.
- Avoid heavy borders as layout boundaries. Prefer whitespace, consistent
spacing, and subtle shadows. Use borders only when a control would otherwise
lose affordance, such as unchecked checkboxes or form boundaries.
- Avoid AI-looking decoration: gradient orbs, top colored bars, arbitrary
dividers, explanatory cards, and one-off accent colors.
- Use the existing design tokens first. If a value is repeated or becomes a
rule, create or update a token and apply it across all affected pages.
- Apply variables to every page, not only the page currently visible.
- Keep contrast easy to observe: colored backgrounds require correct foreground
tokens, and light controls need enough boundary clarity.
### Layout and Alignment
- Equal padding matters. Top/bottom and left/right spacing inside cards, rows,
buttons, tags, and form controls must visually match.
- Row contents must be vertically centered. This applies especially to account
emails, status tags, log levels, table cells, checkboxes, and icons.
- Tables and logs need comfortable row height. Do not compress data rows to the
point where they feel cramped.
- Header cells and body cells must share identical column widths and x-origins.
- Large numbers must not deform cards. Use stable widths, numeric fonts, and
layouts that survive future larger values.
- If a table or log is fixed height and scrollable, it is acceptable for
offscreen rows to be clipped; report this as intentional during verification.
### Component Behavior
- Reference Element Plus and Ant Design for mature component behavior before
designing form controls, date pickers, selectors, buttons, tables, modals, or
notifications.
- Keep form control sizes consistent with the design language:
`large 40`, `default 32`, `small 24`.
- Keep form control radius and button radius separate. Do not let card radii
leak into inputs, selects, date triggers, or buttons.
- Model interaction states systematically: default, hover, pressed, active,
disabled, focus, success, warning, danger, and info.
- Design components as real controls, not rule cards. Descriptions belong in
docs or outside the component, not inside the component itself.
- For notifications, support success, warning, failure, and info with close
buttons; do not narrow the pattern to "save" only.
## Pencil Workflow
- Use Pencil MCP tools as the source of visual truth when editing `.pen` files.
- Read current editor state or target nodes before editing; the document may
have changed.
- Prefer focused `batch_design` updates over broad regeneration.
- Preserve existing content and data unless the user explicitly asks to change
it.
- The user saves/exports manually. Do not spend time on save/export instructions
unless asked.
- After visual changes, verify with `snapshot_layout` and screenshots of the
smallest relevant frame.
- When file JSON and Pencil editor state diverge, reconcile both before
claiming the change is durable.
## Engineering Collaboration Rules
- In `codex-proxy-rs`, default to narrow, behavior-preserving changes unless the
user asks for behavior changes.
- For refactors, honor the pattern: split large files first, do not change logic
in the same step, and keep tests passing.
- For Rust changes, read the local code first and follow existing module
boundaries, error handling, and test style.
- Use structured parsers or existing APIs instead of ad hoc string manipulation
when reasonable.
- Do not revert unrelated work in a dirty tree. Work with existing changes.
- For OpenAI/Codex behavior, distinguish Platform flows from Codex-compatible
flows. Validate the exact token type, route, header, or protocol boundary
before claiming success.
- For OAuth, registration, proxy, or protocol questions, include recent active
external examples only when local evidence is not enough.
## Verification Rules
- Do not say done, fixed, aligned, restored, or complete without fresh evidence.
- Use the verification that matches the claim:
- Pencil design: `snapshot_layout`, targeted screenshots, token audits.
- `.pen` file integrity: `jq empty`, token searches, diff review.
- Rust code: `cargo fmt --check`, focused tests, broader tests, and clippy
when the blast radius justifies it.
- Docs: link checks or at least `git diff --check` and targeted review.
- Report failed or skipped verification plainly.
- If a known layout warning is intentional, name why it is intentional.
## Common Mistakes To Avoid
| Mistake | Correct Behavior |
| --- | --- |
| Showing a design explanation inside the UI | Put explanations in docs or outside the component |
| Fixing one row while leaving the same spacing bug elsewhere | Audit the repeated pattern and token |
| Using borders as page boundaries | Use shadow, spacing, or surface contrast |
| Adding new labels because they sound useful | Use product/user vocabulary already grounded in the system |
| Claiming visual alignment by intuition | Verify with layout snapshot and screenshot |
| Accepting partial protocol success | Validate the exact success boundary the user requested |
| Giving abstract advice when asked for wording | Provide a copyable block |
## Completion Checklist
Before final response:
- The newest user request is addressed, not an older request.
- Changes are in the requested location.
- Existing information was preserved unless explicitly changed.
- Tokens and design language are applied consistently.
- Alignment, padding, contrast, and row heights were checked.
- Verification commands were run and their result is understood.
- The final answer is short, factual, and states any remaining caveat.
@@ -4,7 +4,7 @@
**Goal:** Build `codex-proxy-rs`, a lean Rust rewrite of `codex-proxy` that exposes OpenAI-compatible local endpoints backed only by ChatGPT/Codex accounts and the official Codex backend.
**Architecture:** Create a new Rust service at `/home/zyy/桌面/Codes/codex-proxy-rs`. The only upstream is `https://chatgpt.com/backend-api`; remove OpenAI official API key passthrough, Anthropic, Gemini, custom providers, Ollama, Electron, and per-account proxy assignment. Treat Codex Desktop impersonation as a first-class subsystem: locked `reqwest` + `rustls`, exact headers, account-scoped Cookie replay, and fingerprint auto-update. Keep `src/main.rs` as the binary/bootstrap layer and keep reusable logic in `src/lib.rs` modules with typed `thiserror` errors.
**Architecture:** Create a new Rust service at `/home/zyy/桌面/Codes/codex-proxy-rs`. The only upstream is `https://chatgpt.com/backend-api`; remove OpenAI official API key passthrough, non-OpenAI/Codex routing, non-OpenAI local model bridges, Electron, and per-account proxy assignment. Treat Codex Desktop impersonation as a first-class subsystem: locked `reqwest` + `rustls`, exact headers, account-scoped Cookie replay, and fingerprint auto-update. Keep `src/main.rs` as the binary/bootstrap layer and keep reusable logic in `src/lib.rs` modules with typed `thiserror` errors.
**Tech Stack:** Rust 2021, `tokio`, `axum`, `tower-http`, `sqlx` + SQLite WAL, `config`, `serde`, `serde_json`, `serde_yaml`, `tracing`, `tracing-subscriber`, rotating file logging, `argon2`, `rand`, `uuid`, `chrono`, `thiserror`, `anyhow` only in `main.rs` and tests, `base64`, `aes-gcm`, `hmac`, `sha2`, `secrecy`, `zeroize`, `wiremock`, `insta`, `tempfile`. Use the latest stable crate versions at implementation time except dependencies intentionally pinned for Codex Desktop TLS fingerprint parity: `reqwest = 0.12.28` and `rustls = 0.23.36` until a fingerprint review proves a newer pair is equivalent.
@@ -28,8 +28,8 @@ Keep:
Remove:
- OpenAI official API Key direct upstream.
- Anthropic, Gemini, custom provider routing, OpenRouter-style provider pools.
- Ollama bridge and Ollama settings.
- non-OpenAI/Codex provider routing.
- non-OpenAI local model bridge and settings.
- Per-account proxy assignment, proxy health checks, proxy UI, proxy routing.
- Electron desktop packaging and large dashboard UI.
- Legacy compatibility layers, old-project API adapters, and old-project data migration scripts.
@@ -2686,7 +2686,7 @@ Append to `/home/zyy/桌面/Codes/codex-proxy-rs/tests/routes_responses_test.rs`
```rust
#[tokio::test]
async fn responses_route_rejects_non_codex_provider_models() {
async fn responses_route_rejects_unknown_models() {
let app = build_router(AppState::new(test_config()));
let response = app
.oneshot(
@@ -2695,7 +2695,7 @@ async fn responses_route_rejects_non_codex_provider_models() {
.uri("/v1/responses")
.header("authorization", "Bearer cpr_test")
.header("content-type", "application/json")
.body(Body::from(r#"{"model":"claude-3","input":[]}"#))
.body(Body::from(r#"{"model":"unknown-model","input":[]}"#))
.unwrap(),
)
.await
@@ -2709,7 +2709,7 @@ async fn responses_route_rejects_non_codex_provider_models() {
Run:
```bash
cargo test responses_route_rejects_non_codex_provider_models
cargo test responses_route_rejects_unknown_models
```
Expected: fails with `401 Unauthorized`, because client API key validation and model validation are not implemented.
@@ -2756,7 +2756,7 @@ pub async fn responses(headers: HeaderMap, Json(body): Json<ResponsesBody>) -> i
Run:
```bash
cargo test responses_route_rejects_non_codex_provider_models
cargo test responses_route_rejects_unknown_models
```
Expected: pass.
@@ -2886,8 +2886,8 @@ Rust rewrite of Codex Proxy focused only on ChatGPT/Codex accounts and the Codex
## Excluded
- OpenAI official API Key upstream
- Anthropic, Gemini, custom providers, OpenRouter-style routing
- Ollama bridge
- non-OpenAI/Codex provider routing
- non-OpenAI local model bridge
- Per-account proxy assignment
- Electron app
- Legacy compatibility layers and old-project data migration scripts
@@ -2952,7 +2952,7 @@ git commit -m "docs: document codex-proxy-rs scope"
- [x] Add a tested Codex WebSocket boundary. `src/codex/transport/websocket.rs` classifies HTTP SSE versus WebSocket-only requests, adds `previous_response_id`/`use_websocket` request fields, and returns typed errors instead of silently downgrading WebSocket-required traffic.
- [x] Extend account scheduling primitives against the original TypeScript lifecycle. `AccountPool` now covers max concurrent slots, stale slot cleanup, least-used/round-robin/sticky strategies, tier priority, model-plan filtering, cached quota skip, exclude IDs, preferred account selection for session affinity, Cloudflare cooldown skip, request-staggering metadata, and capacity summary.
- [x] Set `gpt-5.5` as the default/test model baseline across code, tests, and plan examples. `/v1/models` now asserts the exact default model instead of only checking for a `gpt` prefix.
- [x] Add `/admin/settings` for the retained Rust settings surface. GET and PATCH are admin-session gated, lower camelCase, exclude proxy URL, Ollama, third-party provider, OpenAI official API key, proxy API key, and Electron/self-update fields, and PATCH writes retained config to root `local.yaml`.
- [x] Add `/admin/settings` for the retained Rust settings surface. GET and PATCH are admin-session gated, lower camelCase, exclude proxy URL, non-OpenAI local model bridge and third-party provider, OpenAI official API key, proxy API key, and Electron/self-update fields, and PATCH writes retained config to root `local.yaml`.
- [x] Add a native static model catalog foundation. It exposes `gpt-5.5`, configured aliases, reasoning/service tier suffix parsing, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, and `/debug/models`. Backend model fetching/cache and `/admin/refresh-models` remain separate tasks.
- [x] Add encrypted admin account import/list foundations. `/admin/accounts/import` stores access/refresh tokens through `SecretBox` and `/admin/accounts` returns paginated sanitized account metadata without tokens or token decryption. The runtime now loads or creates the master key file before constructing repository-backed routes.
- [x] Add the first native Codex upstream lifecycle slice. Imported accounts are synchronized into the process account pool, `/v1/responses` acquires an account, sends Codex Desktop HTTP SSE requests with encrypted Cookie replay/capture, collects `response.completed`, records usage, and releases the slot. Startup pool snapshots, retry/fallback, 401 refresh, rate-limit capture, and WebSocket remain separate tasks.
@@ -2972,10 +2972,10 @@ The Rust project has no legacy compatibility requirement, but the in-scope behav
- [x] **HTTP auth boundary:** restore a real `src/http/auth.rs` module instead of scattered header checks. It validates `Bearer cpr_...` client API keys for `/v1/*`, validates admin session cookies for `/admin/*`, rejects cross-use in both directions, and returns the documented OpenAI/admin response families.
- [x] **Account SQLite repository:** implement `src/codex/accounts/repository.rs` for encrypted access/refresh tokens, account metadata, quota JSON, usage counters, labels, status transitions, pagination, batch-safe mutations, metadata-only auth status reads, and account-wide delete for logout. Do not add TypeScript JSON migration or legacy import shims.
- [x] **Admin account routes:** rebuild `/auth/status`, `/auth/accounts`, import/export, batch delete/status, health-check, per-account refresh, label, delete, reset usage, quota, Cookie CRUD, and quota warnings as `/admin/*` endpoints with admin envelopes and cursor pagination where a list is returned. Encrypted native import/export, manual token add, CLI import, paginated sanitized list, removed Sub2API/proxy-format compatibility, label/status/delete, batch delete/status, Cookie CRUD, health/probe, refresh, quota, quota warnings, reset usage, admin auth status, and admin logout are complete.
- [x] **Admin account routes:** rebuild `/auth/status`, `/auth/accounts`, import/export, batch delete/status, health-check, per-account refresh, label, delete, reset usage, quota, Cookie CRUD, and quota warnings as `/admin/*` endpoints with admin envelopes and cursor pagination where a list is returned. Encrypted native import/export, manual token add, CLI import, paginated sanitized list, removed external legacy/proxy-format compatibility, label/status/delete, batch delete/status, Cookie CRUD, health/probe, refresh, quota, quota warnings, reset usage, admin auth status, and admin logout are complete.
- [x] **Login routes:** implement OAuth PKCE login-start/code-relay/callback, device-login/device-poll, CLI auth import, manual access-token import, and logout as account-login flows. CLI auth import, validated manual token import, device login/poll, OAuth PKCE login-start/code-relay/callback, admin-session-gated auth status, and logout are complete. Keep this separate from admin password login and client API key creation.
- [x] **Client API key management:** implement admin CRUD for local client API keys: list/create/delete/batch-delete/label/status/export/import. Create/list/status/delete/batch-delete/label/export/import and `/v1/*` SQLite verification are complete. Remove provider/model binding semantics from the TypeScript version; Rust client API keys authorize only local `/v1/*` access.
- [x] **Settings:** implement admin settings for server/runtime fields that remain in scope: default model, reasoning effort, service tier, model aliases, refresh enabled/margin/concurrency, max concurrent per account, request interval, rotation strategy, tier priority, quota refresh/thresholds/skip exhausted, logs state/capacity/body capture, and usage history retention. GET and PATCH `/admin/settings` are admin-session gated, PATCH validates retained fields, writes a root `local.yaml` overlay, and rejects proxy URL, Ollama, third-party provider, OpenAI official key, proxy API key, and Electron/self-update settings. Existing constructed runtime services still use construction-time config until restart or a later runtime reconfiguration task.
- [x] **Settings:** implement admin settings for server/runtime fields that remain in scope: default model, reasoning effort, service tier, model aliases, refresh enabled/margin/concurrency, max concurrent per account, request interval, rotation strategy, tier priority, quota refresh/thresholds/skip exhausted, logs state/capacity/body capture, and usage history retention. GET and PATCH `/admin/settings` are admin-session gated, PATCH validates retained fields, writes a root `local.yaml` overlay, and rejects proxy URL, non-OpenAI local model bridge and third-party provider, OpenAI official key, proxy API key, and Electron/self-update settings. Existing constructed runtime services still use construction-time config until restart or a later runtime reconfiguration task.
- [x] **Diagnostics:** expand health/admin diagnostics to include authenticated state, pool summary, capacity summary, transport/fingerprint status, paths, runtime metadata, and test-connection checks. `/admin/diagnostics` returns the non-secret runtime summary behind admin-session auth. Local-only `/debug/diagnostics` returns runtime metadata, paths, account repository/pool state, capacity summary, transport config, and static fingerprint status without secrets. Local-only `/debug/fingerprint` returns the runtime Desktop fingerprint summary. Local-only `/debug/upstream` probes the Codex models endpoint with runtime TLS/fingerprint headers and returns only status metadata. Keep production-local gating for sensitive debug endpoints.
- [ ] **Logs and usage stats:** add `/admin/logs/state`, clear/detail, error-log equivalents if retained, and richer bounded history queries if needed. `GET/PATCH /admin/logs/state`, `/admin/logs/{id}`, `DELETE /admin/logs`, `/admin/logs` kind/level/request/account/route/model/status/search filters, log file rotation, SQLite event pagination, `/admin/usage-stats` pagination, and `/admin/usage-stats/summary` are complete. Remaining: decide whether to retain old error-log grouping/raw/count/seen/report APIs and richer bounded body-history queries.
- [x] **Model catalog:** replace the hard-coded `/v1/models` list with a model store that supports static defaults, configured aliases, `-low/-medium/-high/-xhigh/-fast/-flex` suffix parsing, backend model fetching per plan, cache persistence, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/admin/refresh-models`, and `/debug/models`. Static defaults, aliases, suffix parsing, cached backend snapshots, plan allowlists, admin refresh, read-only model routes, and debug summary are complete.
@@ -2986,7 +2986,7 @@ The Rust project has no legacy compatibility requirement, but the in-scope behav
## 2026-06-12 Detailed Original Coverage Audit
This pass re-read the TypeScript routes and core modules under `/home/zyy/Codes/codex-proxy` and compared them with the Rust implementation. Deleted provider/proxy/Ollama/Electron features stay deleted; the items below are in-scope Codex/OpenAI-compatible behavior that is not yet fully rebuilt.
This pass re-read the TypeScript routes and core modules under `/home/zyy/Codes/codex-proxy` and compared them with the Rust implementation. Deleted non-OpenAI/proxy/Electron features stay deleted; the items below are in-scope Codex/OpenAI-compatible behavior that is not yet fully rebuilt.
- [x] **OpenAI Chat Completions route wiring:** Rust mounts `/v1/chat/completions` to a dedicated Chat handler that parses Chat `messages`, translates to Codex Responses requests, and returns OpenAI Chat completion JSON or SSE chunks.
- [ ] **Responses request semantics:** Rust now retains `service_tier` normalization (`fast` -> `priority` upstream), `prompt_cache_key`, `client_metadata`, `include`, `text.format`, `tool_choice`, `parallel_tool_calls`, Codex context headers, session/window/thread metadata, and reasoning include defaults. Remaining parity gaps include tuple-schema reconversion edge cases, sanitized reasoning replay behavior, and real-client verification for installation/fingerprint metadata.
@@ -2994,7 +2994,7 @@ This pass re-read the TypeScript routes and core modules under `/home/zyy/Codes/
- [x] **Account login flows:** Rust has admin login, native account import/export, Codex CLI auth import, validated manual token import, OpenAI OAuth device login/poll, OAuth PKCE login-start/code-relay/callback, admin-session-gated account logout, and `/auth/status`-equivalent `/admin/auth/status`. These are rebuilt as admin/session-gated account-login endpoints, not by treating local client API keys as passwords.
- [ ] **Admin account management:** Rust has paginated sanitized list, native import/export, manual token add with upstream refresh-token exchange and JWT metadata/expires extraction, device-code token import, label/status/delete, batch delete/status, Cookie get/set/delete, per-account refresh/probe, reset usage, quota fetch/update, quota warnings, auth status, and logout. Missing persistence-health reporting and durable quota-window restore.
- [x] **Client API key management:** Rust creates/lists/status-toggles/deletes/batch-deletes/labels/exports/imports local `cpr_` keys and verifies them for `/v1/*`. Do not port the TypeScript provider/model binding or provider model-fetch APIs.
- [x] **Settings:** Rust exposes admin-session-gated GET/PATCH `/admin/settings` for retained fields. PATCH validates default model, default reasoning effort, service tier, aliases, refresh settings, max concurrent accounts, request interval, rotation strategy, tier priority, quota refresh/threshold/skip settings, log state/capacity/body capture, and usage retention, writes root `local.yaml`, and does not re-add proxy URL, provider, Ollama, proxy API key, or Electron/self-update settings. Existing constructed runtime services still use construction-time config until restart or a later runtime reconfiguration task.
- [x] **Settings:** Rust exposes admin-session-gated GET/PATCH `/admin/settings` for retained fields. PATCH validates default model, default reasoning effort, service tier, aliases, refresh settings, max concurrent accounts, request interval, rotation strategy, tier priority, quota refresh/threshold/skip settings, log state/capacity/body capture, and usage retention, writes root `local.yaml`, and rejects unknown fields. Existing constructed runtime services still use construction-time config until restart or a later runtime reconfiguration task.
- [x] **Logs and error diagnostics:** Rust has cursor-paginated SQLite event logs, rotating files, `GET/PATCH /admin/logs/state`, detail-by-id, clear, and admin log filters for kind, level, request ID, account ID, route, model, status code, and message/metadata search. Missing direction/body-history filters, bounded in-memory request/response body capture, and the original local error-log grouping/raw/count/seen/report/clear APIs if retained for troubleshooting.
- [x] **Usage and quota background jobs:** Rust records cumulative account usage from `/v1/*` and computes admin quota warnings from cached SQLite quota snapshots. **✅ Completed: Usage history snapshots (`raw`, `five_min`, `hourly`, `daily`) with automatic time-bucket alignment, configurable retention, and efficient aggregation.** Remaining: passive quota-window recovery if needed.
- [x] **Model catalog:** Rust has a static default catalog, aliases, suffix parsing, read routes, debug summary, backend Codex model fetch/probe, per-plan snapshots, model-plan index feeding account scheduling, `/admin/refresh-models`, and runtime cache persistence. Missing richer metadata fields and optional external model-cache loading if retained.
@@ -3002,13 +3002,13 @@ This pass re-read the TypeScript routes and core modules under `/home/zyy/Codes/
- [x] **Refresh scheduler parity:** Rust has typed refresh policy and 401-triggered refresh retry. Implemented long-running refresh timers at `exp - margin`, crash recovery for `refreshing`/`expired` accounts, exponential backoff (5s → 15s → 45s → 135s → 300s), recovery scheduling (10min), permanent-failure thresholding (2 consecutive errors), per-account in-flight suppression. Still missing: cross-process refresh locks (optional for multi-instance), and disk refresh-token sync before consuming one-time RTs (optional safety feature).
- [ ] **Codex Desktop fingerprint and transport parity:** Rust uses pinned `reqwest`/`rustls` and core headers, but exact parity is not proven. Missing header-order/default-header parity (`sec-ch-ua`, accept language/encoding/fetch headers), `x-codex-installation-id`, runtime selection of the newest stored fingerprint, Sparkle appcast XML polling, update-state/version-state persistence, optional local Codex.app Chromium extraction, and real Desktop TLS/header capture comparison. `x-openai-internal-codex-residency` currently needs real-client verification because the builder inserts `global` and the reqwest client overwrites it with `us`.
- [x] **Health and diagnostics:** Rust `/health` returns `status: ok`, `/admin/auth/status` returns admin-session-gated authenticated state plus account pool summary, `/admin/diagnostics` returns the non-secret runtime diagnostics summary, local-only `/debug/diagnostics` returns capacity summary, transport/fingerprint status, paths, runtime metadata, and retained settings summary without secrets, and local-only `/debug/upstream` probes the Codex models endpoint without returning tokens or upstream bodies.
- [x] **Background service lifecycle:** Rust startup restores accounts and initializes tracing. Implemented and integrated scheduler tasks: account refresh (RefreshScheduler) with JWT exp-margin timers, crash recovery, exponential backoff, permanent-failure detection, recovery scheduling, and per-account in-flight suppression; session cleanup (SessionCleanupScheduler) with configurable interval and graceful shutdown via tokio::select! in main.rs. Optional schedulers not yet implemented: model refresh, quota refresh, active quota refresh, fingerprint polling. Ollama, proxy health timers, self-update, Electron, and provider router startup remain intentionally deleted.
- [x] **Background service lifecycle:** Rust startup restores accounts and initializes tracing. Implemented and integrated scheduler tasks: account refresh (RefreshScheduler) with JWT exp-margin timers, crash recovery, exponential backoff, permanent-failure detection, recovery scheduling, and per-account in-flight suppression; session cleanup (SessionCleanupScheduler) with configurable interval and graceful shutdown via tokio::select! in main.rs. Optional schedulers not yet implemented: model refresh, quota refresh, active quota refresh, fingerprint polling. Non-OpenAI local model timers, proxy health timers, self-update, Electron, and provider router startup remain intentionally deleted.
### Keep Removed
- [x] Do not rebuild per-account proxy assignment, proxy pool, proxy health checks, or proxy import/export.
- [x] Do not rebuild Ollama bridge/settings.
- [x] Do not rebuild Anthropic, Gemini, OpenRouter/custom provider routing, or OpenAI official API key direct upstream.
- [x] Do not rebuild non-OpenAI local model bridge/settings.
- [x] Do not rebuild non-OpenAI/Codex provider routing, or OpenAI official API key direct upstream.
- [x] Do not rebuild Electron packaging, large dashboard static UI, or proxy self-update installer flows.
- [x] Do not add old TypeScript data migration, deprecated API compatibility adapters, or dual-mode legacy behavior.
@@ -3016,7 +3016,7 @@ This pass re-read the TypeScript routes and core modules under `/home/zyy/Codes/
- [x] Old `/admin/*` settings mutations used the proxy API key as a write gate. Rust settings mutation now uses admin sessions only and rejects client API key cross-use through the admin auth boundary.
- [ ] Old refresh fallback could be dangerous for one-time refresh tokens if retried after a mid-flight failure. Rust must keep the one-time RT preservation rule and retry only when the request definitely did not reach the server.
- [x] Old settings exposed proxy and provider knobs that are out of scope. Rust config and API keep proxy URL, provider, Ollama, OpenAI official key, proxy API key, and Electron/self-update fields out of the retained settings surface.
- [x] Old settings exposed out-of-scope knobs. Rust config and API use a retained-field whitelist and reject unknown settings fields.
- [x] Old route responses mix multiple body shapes. Rust keeps `/v1/*` OpenAI-compatible and `/admin/*` lower camelCase `code/message/data/requestId`, backed by dedicated API contract tests.
- [x] Empty placeholder modules are not acceptable. Current module roots either expose real submodules or owned behavior; no empty Rust source modules remain in `src/`.
@@ -3040,7 +3040,7 @@ This pass re-read the TypeScript routes and core modules under `/home/zyy/Codes/
- Any API/status-code change updates `docs/api.md` or `docs/status-codes.md` in the same commit.
- Any dependency change updates `docs/dependency-policy.md` in the same commit.
- No OpenAI official API Key direct upstream exists in code, config, routes, docs, or tests.
- No Anthropic, Gemini, custom provider, Ollama, Electron, or per-account proxy assignment modules exist.
- No non-OpenAI/Codex provider, Electron, or per-account proxy assignment modules exist.
- `/v1/*` accepts only client API keys with `cpr_` shape and never accepts admin session cookies.
- `/admin/*` accepts only admin session cookies and never accepts client API keys.
- Codex upstream request code uses pinned `reqwest = 0.12.28` and `rustls = 0.23.36`.
@@ -2,13 +2,13 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Port the seven OpenAI GPT/Codex parity gaps from the TypeScript reference into the Rust service without adding outbound proxy pools or non-GPT providers.
**Goal:** Port the seven OpenAI GPT/Codex parity gaps from the TypeScript reference into the Rust service without adding outbound proxy pools or non-OpenAI/Codex routes.
**Architecture:** Add real Chat Completions support beside Responses, expand Codex request types and transport, then harden the shared upstream lifecycle. Keep route parsing, translation, transport, account state, model catalog, and admin operations in focused modules that match the existing Rust layout.
**Tech Stack:** Rust, Axum, Reqwest/rustls, serde, sqlx/SQLite, wiremock, tokio, futures, optional WebSocket transport dependency.
**Checkpoint 2026-06-12:** Commit `b9c30ba` completed Tasks 1-3 for the in-scope OpenAI GPT/Codex path: Chat Completions route/translation/output, Responses request field parity, default Responses streaming, Codex context headers, and local-only WebSocket flag serialization cleanup. Commit `350ffbe` completed Task 4: `previous_response_id` requests use WebSocket `response.create` and return SSE-compatible output. Commit `3ecd5b6` partially completed Task 5 for non-streaming `/v1/responses`: 429/402/403 account-state classification and fallback retry across imported accounts. Commit `3960a35` extends the same fallback path to HTTP SSE Responses setup and Chat Completions. Commit `6d18d2c` completes Task 5 policy for WebSocket-backed Responses: handshake upstream errors are classified before request body send, non-history WebSocket requests may fallback accounts, successful handshakes capture rate-limit headers, and `previous_response_id` requests remain account-affine for streaming and non-streaming clients. Commit `1738f24` adds Task 6 cache groundwork: persisted backend model snapshots and cached `/v1/models*` reads. Commit `0b6cc7e` adds Task 7 import compatibility for Sub2API OpenAI OAuth exports and marks the detected source format while ignoring proxy-only fields. Commit `f75db60` adds `/admin/refresh-models` backed by imported accounts. Commit `4b19846` syncs refreshed model-plan allowlists into the runtime account pool. Commit `db6cb98` adds admin account label/status/delete mutations with runtime pool synchronization. Commit `16eb4ed` adds batch delete/status mutations. Commit `8af350d` adds local client API key status/delete mutations. Commit `203270f` adds encrypted account Cookie get/set/delete. Commit `960bbb2` adds local client API key label and batch-delete mutations. Commit `72438c6` adds native and Sub2API account export. Commit `16938e6` adds manual account creation for Codex OAuth tokens. Commit `869b6f8` narrows `POST /admin/accounts` to TS-like `token`/`refreshToken` import semantics for OpenAI GPT/Codex accounts. Commit `e1f35e0` adds deterministic Codex CLI `auth.json` import through the same validated JWT-claim path. Commit `93cfee3` adds admin health-check, per-account refresh, reset-usage, and quota routes for imported Codex accounts. Commit `bfe561d` maps HTTP SSE terminal failures to OpenAI-compatible upstream errors for non-streaming clients while preserving streaming passthrough. Commit `cf5ff0b` adds Rust-local client API key metadata export/import with target-local key rotation. Commit `a32a53c` adds admin-session-gated `/admin/auth/status` and `/admin/auth/logout`, returning sanitized account/pool state and clearing SQLite accounts plus the runtime pool without token exposure. Commit `1d6c301` adds admin-session-gated `/admin/auth/device-login` and `/admin/auth/device-poll/{device_code}` backed by the OpenAI OAuth device-code flow and shared validated account import path. Commit `a3b4d2b` adds `/admin/auth/login-start`, `/admin/auth/code-relay`, `/admin/auth/callback`, and the registered `/auth/callback` redirect path, with in-memory PKCE sessions, OpenAI authorization-code exchange, and shared validated account import.
**Checkpoint 2026-06-12:** Commit `b9c30ba` completed Tasks 1-3 for the in-scope OpenAI GPT/Codex path: Chat Completions route/translation/output, Responses request field parity, default Responses streaming, Codex context headers, and local-only WebSocket flag serialization cleanup. Commit `350ffbe` completed Task 4: `previous_response_id` requests use WebSocket `response.create` and return SSE-compatible output. Commit `3ecd5b6` partially completed Task 5 for non-streaming `/v1/responses`: 429/402/403 account-state classification and fallback retry across imported accounts. Commit `3960a35` extends the same fallback path to HTTP SSE Responses setup and Chat Completions. Commit `6d18d2c` completes Task 5 policy for WebSocket-backed Responses: handshake upstream errors are classified before request body send, non-history WebSocket requests may fallback accounts, successful handshakes capture rate-limit headers, and `previous_response_id` requests remain account-affine for streaming and non-streaming clients. Commit `1738f24` adds Task 6 cache groundwork: persisted backend model snapshots and cached `/v1/models*` reads. Commit `0b6cc7e` adds Task 7 import compatibility for external legacy OpenAI OAuth exports and marks the detected source format while ignoring proxy-only fields. Commit `f75db60` adds `/admin/refresh-models` backed by imported accounts. Commit `4b19846` syncs refreshed model-plan allowlists into the runtime account pool. Commit `db6cb98` adds admin account label/status/delete mutations with runtime pool synchronization. Commit `16eb4ed` adds batch delete/status mutations. Commit `8af350d` adds local client API key status/delete mutations. Commit `203270f` adds encrypted account Cookie get/set/delete. Commit `960bbb2` adds local client API key label and batch-delete mutations. Commit `72438c6` adds native and external legacy account export. Commit `16938e6` adds manual account creation for Codex OAuth tokens. Commit `869b6f8` narrows `POST /admin/accounts` to TS-like `token`/`refreshToken` import semantics for OpenAI GPT/Codex accounts. Commit `e1f35e0` adds deterministic Codex CLI `auth.json` import through the same validated JWT-claim path. Commit `93cfee3` adds admin health-check, per-account refresh, reset-usage, and quota routes for imported Codex accounts. Commit `bfe561d` maps HTTP SSE terminal failures to OpenAI-compatible upstream errors for non-streaming clients while preserving streaming passthrough. Commit `cf5ff0b` adds Rust-local client API key metadata export/import with target-local key rotation. Commit `a32a53c` adds admin-session-gated `/admin/auth/status` and `/admin/auth/logout`, returning sanitized account/pool state and clearing SQLite accounts plus the runtime pool without token exposure. Commit `1d6c301` adds admin-session-gated `/admin/auth/device-login` and `/admin/auth/device-poll/{device_code}` backed by the OpenAI OAuth device-code flow and shared validated account import path. Commit `a3b4d2b` adds `/admin/auth/login-start`, `/admin/auth/code-relay`, `/admin/auth/callback`, and the registered `/auth/callback` redirect path, with in-memory PKCE sessions, OpenAI authorization-code exchange, and shared validated account import.
**Checkpoint 2026-06-13:** Commit `b879dd6` adds admin-session-gated `/admin/accounts/quota-warnings`, computed from cached SQLite quota snapshots and configured warning thresholds without calling the upstream Codex backend. Commit `0daa96b` adds `0005_account_cooldowns.sql` and persists quota/Cloudflare cooldown state for runtime pool restore. Commit `efbbe8a` clears the challenged account's persisted Cookies when Cloudflare blocks an upstream request.
@@ -22,23 +22,23 @@ This audit is the correction point against the TypeScript reference at `/home/zy
| Area / commit | TypeScript reference behavior | Rust current behavior | Classification | Action |
| --- | --- | --- | --- | --- |
| Sub2API OpenAI OAuth import / `0b6cc7e` | Account import accepts token/refresh-token account exports and Sub2API-shaped OpenAI OAuth data through `AccountImportService`; proxy fields can exist in source data. | Removed. `/admin/accounts/import` now accepts only the native Rust `accounts[]` format and rejects Sub2API/proxy/runtime payloads by producing no importable accounts. | Old-project compatibility, out of scope. | Do not restore unless a new product requirement explicitly names Sub2API as a first-class import target. |
| external legacy OpenAI OAuth import / `0b6cc7e` | Account import accepts token/refresh-token account exports and external legacy-shaped OpenAI OAuth data through `AccountImportService`; proxy fields can exist in source data. | Removed. `/admin/accounts/import` now accepts only the native Rust `accounts[]` format and rejects external legacy/proxy/runtime payloads by producing no importable accounts. | Old-project compatibility, out of scope. | Do not restore unless a new product requirement explicitly names external legacy as a first-class import target. |
| Account label/status/delete, health, refresh, reset, quota, and batch mutations / `db6cb98`, `16eb4ed`, current Task 7 pass | `/auth/accounts/:id/label`, `/status`, delete, batch-delete, batch-status, health-check, refresh/probe, reset-usage, and quota mutate or inspect the account pool. | `/admin/accounts/*` mutates SQLite and synchronizes the runtime account pool with the admin envelope contract. Health/quota call the Codex backend through imported account tokens, refresh uses the existing OAuth token refresher, reset-usage clears local counters and pool last-used state, and quota stores normalized quota JSON. | Partial parity, adapted. | Keep. Finish login/import variants separately. |
| Account Cookie get/set/delete / `203270f` | Account routes can store browser Cookie headers for account-scoped replay. | `/admin/accounts/{id}/cookies` stores, reads, and clears encrypted per-account Cookies. | Original parity, adapted. | Keep; preserve account-scoped replay and encryption invariants. |
| Account export / `72438c6` | `/auth/accounts/export` supports `full`, `minimal`, `cockpit_tools`, `sub2api`, and `cpa`. | `/admin/accounts/export` supports only the native Rust export format. `full`, `sub2api`, and the other TypeScript export variants return `400`. | Native Rust contract. | Keep only native export while the project has no old deployment or external-format compatibility requirement. |
| Account export / `72438c6` | `/auth/accounts/export` supports `full`, `minimal`, `cockpit_tools`, `external`, and `cpa`. | `/admin/accounts/export` supports only the native Rust export format. `full`, `external`, and the other TypeScript export variants return `400`. | Native Rust contract. | Keep only native export while the project has no old deployment or external-format compatibility requirement. |
| Manual account creation / `16938e6` + current correction | `POST /auth/accounts` accepts only `token` and/or `refreshToken`, runs `AccountImportService.importOne`, exchanges refresh-token-only imports, derives account identity/profile from JWT claims, updates an existing `chatgpt_account_id` + `chatgpt_user_id` entry, and never returns tokens. | `POST /admin/accounts` accepts only `token` and/or `refreshToken`, rejects missing/invalid/expired JWTs and tokens without `https://api.openai.com/auth.chatgpt_account_id`, derives email/accountId/userId/planType/expiresAt from JWT claims, ignores caller metadata/status/label, exchanges refresh-token-only imports with `AppState`'s OpenAI refresher, preserves or rotates refresh tokens, encrypts secrets, and synchronizes the runtime account pool. It does not yet cache quota from a verification probe. | Corrected scoped parity. | Keep. Finish quota fetch/health/login variants separately without adding proxy pools or non-OpenAI provider support. |
| Local client API key status/delete / `8af350d` | `/auth/api-keys` manages third-party provider key pools such as OpenAI, Anthropic, Gemini, OpenRouter/custom, plus model bindings and capabilities. | `/admin/api-keys/*` manages local `cpr_` client auth keys used only for this Rust service's `/v1/*` authorization. | Rust local extension, not TS parity. | Keep only as local admin utility if accepted. Do not count as provider API-key parity and do not add non-OpenAI provider pools. |
| Local client API key status/delete / `8af350d` | `/auth/api-keys` manages third-party provider key pools such as OpenAI, non-OpenAI/Codex, plus model bindings and capabilities. | `/admin/api-keys/*` manages local `cpr_` client auth keys used only for this Rust service's `/v1/*` authorization. | Rust local extension, not TS parity. | Keep only as local admin utility if accepted. Do not count as provider API-key parity and do not add non-OpenAI provider pools. |
| Local client API key label/batch-delete / `960bbb2` | TS label/batch-delete applies to third-party provider key pool entries. | Rust label/batch-delete applies to local HMAC-hashed `cpr_` client keys. | Rust local extension, possible drift if strict parity is required. | Reclassify in docs as local admin utility. Remove only if strict original parity excludes local client-key management beyond create/list/status/delete. |
| API key export/import variants | TS can export/import third-party provider keys for reimport. | Rust local `cpr_` key plaintext is shown only at creation and then HMAC-hashed; provider key pools are not implemented. Local `/admin/api-keys/export` exports only metadata, and `/admin/api-keys/import` rotates metadata into new target-local `cpr_` keys with plaintext returned once. | Rust local extension, intentional provider scope cut. | Keep local metadata export/import as client auth utility only. Do not port TS provider key import/export, provider/model binding, proxy pools, or non-OpenAI provider behavior. |
| Model refresh and plan allowlist sync / `f75db60`, `4b19846` | TS has provider/key model catalog behavior mixed with account/provider management. | Rust probes Codex backend models via imported accounts and syncs plan allowlists into the account pool. | Partial parity, scoped Rust adaptation. | Keep; avoid third-party provider catalog behavior. Finish full Codex model metadata parity later if needed. |
| OAuth/device/CLI account login/import variants | Original scope includes ChatGPT/Codex OAuth login, device login, CLI token import, manual validated token import, logout/status, quota, and health checks. | Rust now supports validated manual `token`/`refreshToken` import, deterministic admin CLI import from Codex `auth.json`, OpenAI OAuth device login/poll, admin-session-gated PKCE login-start/code-relay/callback, admin quota fetch, health check, per-account refresh, reset-usage, sanitized admin auth status, and admin logout for imported accounts. | Scoped OpenAI/Codex parity complete for account-login operations. | Keep admin-session-gated Rust routes and do not add proxy pools or non-OpenAI providers. |
| Proxy/provider metadata and routes | TS project includes per-account proxy assignment, proxy health, and non-OpenAI provider routes. | Rust imports ignore proxy fields and has no outbound proxy/provider pools. | Intentional scope cut. | Keep omitted. Do not add proxy routing, proxy UI/API, Anthropic/Gemini/Ollama/custom provider support, or Electron/frontend flows. |
| Proxy/provider metadata and routes | TS project includes per-account proxy assignment, proxy health, and non-OpenAI provider routes. | Rust imports ignore proxy fields and has no outbound proxy/provider pools. | Intentional scope cut. | Keep omitted. Do not add proxy routing, proxy UI/API, non-OpenAI/Codex provider support, or Electron/frontend flows. |
Immediate priority after this audit:
1. Finish remaining in-scope account operation gaps such as account persistence-health reporting and durable quota-window recovery.
2. Run final verification for the completed Task 5 fallback paths, including WebSocket history affinity.
3. Keep local client API key utilities documented as Rust-local auth, not TypeScript provider-key parity.
3. Keep local client API key utilities documented as Rust-local auth, not TypeScript local client key parity.
---
@@ -163,9 +163,9 @@ Immediate priority after this audit:
- Test: `tests/admin_accounts_cookies_quota_test.rs`
- Test: `tests/admin_api_keys_route_test.rs`
- [x] Write failing tests for removed Sub2API OpenAI OAuth import payloads and native payloads that carry proxy/runtime metadata.
- [x] Write failing tests for removed external legacy OpenAI OAuth import payloads and native payloads that carry proxy/runtime metadata.
- [x] Run: `cargo test --test admin_accounts_route_test`
- [x] Remove Sub2API import normalization and keep `sourceFormat` response marking for the native format.
- [x] Remove external legacy import normalization and keep `sourceFormat` response marking for the native format.
- [x] Write failing tests for account label/status/delete mutations and runtime pool synchronization.
- [x] Implement account label/status/delete mutations without adding proxy-pool support.
- [x] Run: `cargo test --test admin_accounts_route_test`
@@ -184,8 +184,8 @@ Immediate priority after this audit:
- [x] Write failing tests for account Cookie get/set/delete routes.
- [x] Implement encrypted account Cookie get/set/delete routes.
- [x] Run: `cargo test --test admin_accounts_route_test`
- [x] Write failing tests for native account export and removed Sub2API/full export formats.
- [x] Implement native account export without Sub2API/full format compatibility.
- [x] Write failing tests for native account export and removed external legacy/full export formats.
- [x] Implement native account export without external legacy/full format compatibility.
- [x] Run: `cargo test --test admin_accounts_route_test`
- [x] Write failing tests for manual account creation.
- [x] Implement manual account creation without proxy/provider compatibility.
@@ -7,7 +7,7 @@ Port the OpenAI GPT/Codex compatibility logic from `/home/zyy/桌面/Codes/codex
## Explicit Non-Goals
- Do not port outbound proxy pools, proxy assignment, or proxy health checks.
- Do not port Anthropic, Gemini, Ollama, custom provider routing, Electron, frontend UI, or update flows.
- Do not port non-OpenAI/Codex routing, non-OpenAI local model bridges, Electron, frontend UI, or update flows.
- Do not add direct third-party OpenAI API-key provider routing. The `/v1` API remains backed by imported ChatGPT/Codex accounts.
## Architecture
@@ -31,7 +31,7 @@ Current progress: Tasks 1-7 are implemented for the OpenAI GPT/Codex scope. Comm
- `src/http/v1/{chat,responses,models,router}.rs` plus `src/service/{chat,responses}.rs`: separate `/v1/responses` and `/v1/chat/completions` handlers delegating orchestration to services.
- `src/codex/accounts/pool.rs` and repositories: fallback selection, quota/rate-limit updates, durable usage release, and account state mutation hooks.
- `src/codex/models/catalog.rs`: static catalog plus persisted backend snapshots, plan allowlist derivation, and admin refresh trigger.
- `src/http/admin/*.rs`, `src/service/{api_key,settings,usage,log,diagnostics}.rs`, and `src/codex/accounts/service/*`: scoped account/API-key/account-health/quota/cookie/admin model operations needed to operate Codex-backed GPT compatibility. Account import/export now uses only the native Rust format; Sub2API and proxy-pool payloads are intentionally removed.
- `src/http/admin/*.rs`, `src/service/{api_key,settings,usage,log,diagnostics}.rs`, and `src/codex/accounts/service/*`: scoped account/API-key/account-health/quota/cookie/admin model operations needed to operate Codex-backed GPT compatibility. Account import/export now uses only the native Rust format; external legacy and proxy-pool payloads are intentionally removed.
## Data Flow
+1 -1
View File
@@ -97,7 +97,7 @@ pub async fn health_check_accounts(
let results = state
.services
.accounts
.health_check_accounts(payload.ids, concurrency, stagger_ms, &request_id)
.health_check_accounts(payload.ids, concurrency, stagger_ms)
.await
.map_err(|error| health_check_error(error, &request_id))?
.into_iter()
+20 -35
View File
@@ -21,6 +21,20 @@ use crate::{
use super::super::{require_admin_session, AdminEnvelope, AdminError, AdminResponse};
use super::validated_account_import_error;
const NATIVE_IMPORT_CONTAINER_KEYS: [&str; 1] = ["accounts"];
const NATIVE_IMPORT_ACCOUNT_KEYS: [&str; 10] = [
"id",
"email",
"accountId",
"userId",
"label",
"planType",
"token",
"refreshToken",
"accessTokenExpiresAt",
"status",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AccountImportFormat {
Native,
@@ -213,7 +227,7 @@ fn parse_account_import_payload(payload: &Value) -> ParsedAccountImportPayload {
}
fn parse_native_account_payload(payload: &Value) -> Vec<AccountImportEntry> {
if has_removed_import_container_field(payload) {
if !has_only_allowed_keys(payload, &NATIVE_IMPORT_CONTAINER_KEYS) {
return Vec::new();
}
let Some(accounts) = payload.get("accounts").and_then(Value::as_array) else {
@@ -226,7 +240,7 @@ fn parse_native_account_payload(payload: &Value) -> Vec<AccountImportEntry> {
}
fn account_entry_from_value(value: &Value) -> Option<AccountImportEntry> {
if has_removed_import_account_field(value) {
if !has_only_allowed_keys(value, &NATIVE_IMPORT_ACCOUNT_KEYS) {
return None;
}
@@ -245,39 +259,10 @@ fn account_entry_from_value(value: &Value) -> Option<AccountImportEntry> {
(entry.token.is_some() || entry.refresh_token.is_some()).then_some(entry)
}
fn has_removed_import_container_field(value: &Value) -> bool {
["proxies", "type", "exported_at"]
.iter()
.any(|field| value.get(*field).is_some())
}
fn has_removed_import_account_field(value: &Value) -> bool {
[
"accessToken",
"access_token",
"refresh_token",
"tokens",
"credentials",
"account_id",
"user_id",
"chatgpt_account_id",
"chatgpt_user_id",
"plan_type",
"access_token_expires_at",
"expires_at",
"name",
"account_name",
"accountName",
"account_note",
"accountNote",
"note",
"proxyApiKey",
"cachedQuota",
"quotaVerifyRequired",
"usage",
]
.iter()
.any(|field| value.get(*field).is_some())
fn has_only_allowed_keys(value: &Value, allowed: &[&str]) -> bool {
value
.as_object()
.is_some_and(|object| object.keys().all(|key| allowed.contains(&key.as_str())))
}
fn label_from_value(value: &Value) -> Option<String> {
+1 -1
View File
@@ -33,7 +33,7 @@ pub fn router() -> Router<AppState> {
.route("/api/admin/auth/logout", post(auth_logout))
.route("/api/admin/auth/login-start", post(auth_login_start))
.route("/api/admin/auth/code-relay", post(auth_code_relay))
.route("/auth/openai/callback", get(auth_callback))
.route("/auth/callback", get(auth_callback))
.route("/api/admin/auth/device-login", post(auth_device_login))
.route(
"/api/admin/auth/device-poll/{device_code}",
+23 -10
View File
@@ -25,11 +25,22 @@ pub struct AdminAuthService {
auth_repository: Option<AdminAuthRepository>,
account_repository: Option<AccountRepository>,
account_pool: Arc<Mutex<AccountPool>>,
oauth_config: OAuthConfig,
oauth_client: Option<Arc<dyn OAuthClient>>,
oauth_sessions: Arc<Mutex<PkceSessionStore>>,
accounts: AccountService,
}
pub struct AdminAuthDependencies {
pub auth_repository: Option<AdminAuthRepository>,
pub account_repository: Option<AccountRepository>,
pub account_pool: Arc<Mutex<AccountPool>>,
pub oauth_config: OAuthConfig,
pub oauth_client: Option<Arc<dyn OAuthClient>>,
pub oauth_sessions: Arc<Mutex<PkceSessionStore>>,
pub accounts: AccountService,
}
#[derive(Debug)]
pub enum AdminAuthServiceError {
DatabaseUnavailable,
@@ -123,20 +134,22 @@ pub enum AdminAuthPkceExchange {
}
impl AdminAuthService {
pub fn new(
config: Arc<AppConfig>,
auth_repository: Option<AdminAuthRepository>,
account_repository: Option<AccountRepository>,
account_pool: Arc<Mutex<AccountPool>>,
oauth_client: Option<Arc<dyn OAuthClient>>,
oauth_sessions: Arc<Mutex<PkceSessionStore>>,
accounts: AccountService,
) -> Self {
pub fn new(config: Arc<AppConfig>, dependencies: AdminAuthDependencies) -> Self {
let AdminAuthDependencies {
auth_repository,
account_repository,
account_pool,
oauth_config,
oauth_client,
oauth_sessions,
accounts,
} = dependencies;
Self {
config,
auth_repository,
account_repository,
account_pool,
oauth_config,
oauth_client,
oauth_sessions,
accounts,
@@ -265,7 +278,7 @@ impl AdminAuthService {
.oauth_sessions
.lock()
.await
.start_login(return_host, &OAuthConfig::codex_default());
.start_login(return_host, &self.oauth_config);
AdminAuthLoginStart {
auth_url: login.auth_url,
state: login.state,
+32 -6
View File
@@ -1,22 +1,31 @@
use std::collections::HashMap;
use std::sync::Arc;
use chrono::{DateTime, Duration, Utc};
use tokio::sync::RwLock;
/// Cloudflare Path Block Tracker
///
/// 追踪每个账户连续遇到的 Cloudflare path-block 404 错误。
/// 当一个账户连续遇到 3 次 path-block 时,自动将其标记为 Banned。
/// 当一个账户连续遇到 3 次 path-block 时,自动将其标记为 Disabled。
///
/// Path-block 的特征:
/// - HTTP 404 状态码
/// - 空响应体或 Cloudflare 页面
/// - 通常发生在 /backend-api/responses 路径
const PATH_BLOCK_THRESHOLD: u32 = 3;
const PATH_BLOCK_STALE_AFTER: Duration = Duration::hours(1);
#[derive(Debug, Clone, Copy)]
struct PathBlockState {
count: u32,
last_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct CfPathBlockTracker {
/// 每个账户的连续 path-block 计数
counts: Arc<RwLock<HashMap<String, u32>>>,
counts: Arc<RwLock<HashMap<String, PathBlockState>>>,
}
impl Default for CfPathBlockTracker {
@@ -37,9 +46,20 @@ impl CfPathBlockTracker {
/// 返回当前连续计数,调用者应该在达到阈值时采取行动
pub async fn record_path_block(&self, account_id: &str) -> u32 {
let mut counts = self.counts.write().await;
let count = counts.entry(account_id.to_string()).or_insert(0);
*count += 1;
*count
let now = Utc::now();
let count = counts
.get(account_id)
.filter(|state| now.signed_duration_since(state.last_at) <= PATH_BLOCK_STALE_AFTER)
.map(|state| state.count + 1)
.unwrap_or(1);
counts.insert(
account_id.to_string(),
PathBlockState {
count,
last_at: now,
},
);
count
}
/// 重置账户的 path-block 计数(成功请求后调用)
@@ -51,7 +71,13 @@ impl CfPathBlockTracker {
/// 获取账户当前的 path-block 计数
pub async fn get_count(&self, account_id: &str) -> u32 {
let counts = self.counts.read().await;
counts.get(account_id).copied().unwrap_or(0)
counts
.get(account_id)
.filter(|state| {
Utc::now().signed_duration_since(state.last_at) <= PATH_BLOCK_STALE_AFTER
})
.map(|state| state.count)
.unwrap_or(0)
}
/// 检查是否应该禁用账户
+18 -54
View File
@@ -12,15 +12,14 @@ use crate::{
model::AccountStatus,
repository::{AccountRepository, StoredAccount},
},
codex::gateway::fingerprint::model::Fingerprint,
codex::gateway::transport::http_client::{
build_reqwest_client, CodexBackendClient, CodexClientError, CodexRequestContext,
},
};
use super::{
cookies::request_domain, quota::quota_from_usage, AccountProbeOutcome, AccountProbeResult,
AccountService, HealthCheckError,
cookies::request_domain, AccountProbeOutcome, AccountProbeResult, AccountService,
HealthCheckError,
};
impl AccountService {
@@ -29,7 +28,6 @@ impl AccountService {
ids: Option<Vec<String>>,
concurrency: usize,
stagger_ms: u64,
request_id: &str,
) -> Result<Vec<AccountProbeResult>, HealthCheckError> {
let repo = self
.repository
@@ -43,8 +41,6 @@ impl AccountService {
let results = stream::iter(accounts.into_iter().enumerate())
.map(|(index, account)| {
let service = self.clone();
let repo = repo.clone();
let request_id = request_id.to_string();
async move {
if stagger_ms > 0 && index > 0 {
let multiplier = index.min(concurrency);
@@ -53,9 +49,7 @@ impl AccountService {
))
.await;
}
service
.probe_account_with_codex_backend(&repo, account, &request_id)
.await
service.probe_account_with_oauth_refresh(account).await
}
})
.buffer_unordered(concurrency)
@@ -64,53 +58,23 @@ impl AccountService {
Ok(results)
}
async fn probe_account_with_codex_backend(
&self,
repo: &AccountRepository,
account: StoredAccount,
request_id: &str,
) -> AccountProbeResult {
if account.status == AccountStatus::Disabled {
return skipped_probe_result(&account, "manually disabled");
}
async fn probe_account_with_oauth_refresh(&self, account: StoredAccount) -> AccountProbeResult {
let started_at = std::time::Instant::now();
let previous_status = account.status;
match fetch_account_usage(self, &account, request_id).await {
Ok(raw) => {
let quota = quota_from_usage(&raw);
let _ = repo
.update_quota_json(&account.id, &quota.to_string())
.await;
if account.status != AccountStatus::Active {
let _ = repo.set_status(&account.id, AccountStatus::Active).await;
self.account_pool
.lock()
.await
.set_status(&account.id, AccountStatus::Active);
}
AccountProbeResult {
id: account.id,
email: account.email,
previous_status,
outcome: AccountProbeOutcome::Alive,
status: Some(AccountStatus::Active),
error: None,
duration_ms: Some(started_at.elapsed().as_millis()),
}
}
Err(error) => {
let status = apply_codex_account_error(self, repo, &account, &error).await;
AccountProbeResult {
id: account.id,
email: account.email,
previous_status,
outcome: AccountProbeOutcome::Dead,
status,
error: Some(public_codex_error(&error)),
duration_ms: Some(started_at.elapsed().as_millis()),
}
match self.refresh_account(&account.id).await {
Ok(mut result) => {
result.previous_status = previous_status;
result
}
Err(error) => AccountProbeResult {
id: account.id,
email: account.email,
previous_status,
outcome: AccountProbeOutcome::Dead,
status: None,
error: Some(error.to_string()),
duration_ms: Some(started_at.elapsed().as_millis()),
},
}
}
}
@@ -124,7 +88,7 @@ pub(super) async fn fetch_account_usage(
let client = CodexBackendClient::new(
build_reqwest_client(service.config.tls.force_http11)?,
service.config.api.base_url.clone(),
Fingerprint::default_codex_desktop(),
service.fingerprint.clone(),
);
client
.fetch_usage(CodexRequestContext {
+26 -10
View File
@@ -20,7 +20,10 @@ use crate::{
AccountRepository, AccountUsageRepository, StoredAccount, StoredAccountMetadata,
},
},
codex::gateway::{oauth::TokenRefresher, transport::websocket::CodexWebSocketPool},
codex::gateway::{
fingerprint::model::Fingerprint, oauth::TokenRefresher,
transport::websocket::CodexWebSocketPool,
},
config::AppConfig,
utils::pagination::Page,
};
@@ -34,6 +37,17 @@ pub struct AccountService {
token_refresher: Option<Arc<dyn TokenRefresher>>,
account_pool: Arc<Mutex<AccountPool>>,
websocket_pool: Arc<CodexWebSocketPool>,
fingerprint: Fingerprint,
}
pub struct AccountServiceDependencies {
pub repository: Option<AccountRepository>,
pub usage_repository: Option<AccountUsageRepository>,
pub cookie_repository: Option<CookieRepository>,
pub token_refresher: Option<Arc<dyn TokenRefresher>>,
pub account_pool: Arc<Mutex<AccountPool>>,
pub websocket_pool: Arc<CodexWebSocketPool>,
pub fingerprint: Fingerprint,
}
#[derive(Debug)]
@@ -241,15 +255,16 @@ pub enum ValidatedAccountImportError {
}
impl AccountService {
pub fn new(
config: Arc<AppConfig>,
repository: Option<AccountRepository>,
usage_repository: Option<AccountUsageRepository>,
cookie_repository: Option<CookieRepository>,
token_refresher: Option<Arc<dyn TokenRefresher>>,
account_pool: Arc<Mutex<AccountPool>>,
websocket_pool: Arc<CodexWebSocketPool>,
) -> Self {
pub fn new(config: Arc<AppConfig>, dependencies: AccountServiceDependencies) -> Self {
let AccountServiceDependencies {
repository,
usage_repository,
cookie_repository,
token_refresher,
account_pool,
websocket_pool,
fingerprint,
} = dependencies;
Self {
config,
repository,
@@ -258,6 +273,7 @@ impl AccountService {
token_refresher,
account_pool,
websocket_pool,
fingerprint,
}
}
+32 -4
View File
@@ -19,10 +19,33 @@ use super::{
const REFRESH_LEASE_TTL_SECONDS: i64 = 5 * 60;
#[derive(Debug, Clone, Copy)]
enum RefreshFailurePolicy {
PersistStatus,
ReportOnly,
}
impl AccountService {
pub async fn refresh_account(
&self,
account_id: &str,
) -> Result<AccountProbeResult, RefreshAccountError> {
self.refresh_account_with_failure_policy(account_id, RefreshFailurePolicy::PersistStatus)
.await
}
pub async fn probe_account_refresh(
&self,
account_id: &str,
) -> Result<AccountProbeResult, RefreshAccountError> {
self.refresh_account_with_failure_policy(account_id, RefreshFailurePolicy::ReportOnly)
.await
}
async fn refresh_account_with_failure_policy(
&self,
account_id: &str,
failure_policy: RefreshFailurePolicy,
) -> Result<AccountProbeResult, RefreshAccountError> {
let repo = self
.repository
@@ -56,7 +79,7 @@ impl AccountService {
}
let result = self
.refresh_account_with_lease(repo, account_id, refresher)
.refresh_account_with_lease(repo, account_id, refresher, failure_policy)
.await;
if let Err(error) = repo.release_refresh_lease(account_id, &lease_owner).await {
tracing::warn!(
@@ -73,6 +96,7 @@ impl AccountService {
repo: &AccountRepository,
account_id: &str,
refresher: Arc<dyn TokenRefresher>,
failure_policy: RefreshFailurePolicy,
) -> Result<AccountProbeResult, RefreshAccountError> {
let account = match repo.get(account_id).await {
Ok(Some(account)) => account,
@@ -110,9 +134,13 @@ impl AccountService {
})
}
Err(failure) => {
let status = self
.apply_refresh_failure_status(repo, &account, failure)
.await;
let status = match failure_policy {
RefreshFailurePolicy::PersistStatus => {
self.apply_refresh_failure_status(repo, &account, failure)
.await
}
RefreshFailurePolicy::ReportOnly => None,
};
Ok(AccountProbeResult {
id: account.id,
email: account.email,
+2 -9
View File
@@ -23,7 +23,7 @@ impl Fingerprint {
platform: "darwin".to_string(),
arch: "arm64".to_string(),
chromium_version: "146".to_string(),
user_agent_template: "Codex Desktop/{app_version} ({platform}; {arch})".to_string(),
user_agent_template: "Codex Desktop/{version} ({platform}; {arch})".to_string(),
default_headers: Self::default_headers(),
header_order: Self::default_header_order(),
}
@@ -56,14 +56,7 @@ impl Fingerprint {
"x-openai-internal-codex-residency".to_string(),
"x-client-request-id".to_string(),
"x-codex-installation-id".to_string(),
"session_id".to_string(),
"x-codex-window-id".to_string(),
"x-codex-turn-state".to_string(),
"x-codex-turn-metadata".to_string(),
"x-codex-beta-features".to_string(),
"x-responsesapi-include-timing-metrics".to_string(),
"x-codex-parent-thread-id".to_string(),
"version".to_string(),
"openai-beta".to_string(),
"user-agent".to_string(),
"sec-ch-ua".to_string(),
@@ -82,7 +75,7 @@ impl Fingerprint {
pub fn user_agent(&self) -> String {
self.user_agent_template
.replace("{app_version}", &self.app_version)
.replace("{version}", &self.app_version)
.replace("{platform}", &self.platform)
.replace("{arch}", &self.arch)
}
+1 -1
View File
@@ -11,7 +11,7 @@ const AUTO_UPDATED_FINGERPRINT_ID: &str = "auto_updated";
const AUTO_UPDATE_SOURCE: &str = "auto_update";
const AUTO_UPDATE_PLATFORM: &str = "darwin";
const AUTO_UPDATE_ARCH: &str = "arm64";
const AUTO_UPDATE_USER_AGENT_TEMPLATE: &str = "Codex Desktop/{app_version} ({platform}; {arch})";
const AUTO_UPDATE_USER_AGENT_TEMPLATE: &str = "Codex Desktop/{version} ({platform}; {arch})";
const DEFAULT_AUTO_UPDATE_CHROMIUM_VERSION: &str = "146";
#[derive(Debug, Clone, PartialEq, Eq)]
+1 -1
View File
@@ -4,7 +4,7 @@
//! 用于路由/亲和性提示,使上游可以将客户端固定到同一后端实例以保持提示缓存温暖。
//!
//! 查找顺序:
//! 1. `~/.codex/installation_id`(兼容真实 Codex Desktop)
//! 1. `~/.codex/installation_id`(真实 Codex Desktop 位置)
//! 2. `<database_dir>/installation_id`(之前持久化的)
//! 3. 生成新 UUID 并持久化到 `<database_dir>/installation_id`
+7 -12
View File
@@ -9,17 +9,16 @@ use serde::Deserialize;
use sha2::{Digest, Sha256};
use thiserror::Error;
use crate::config::AuthConfig;
use super::{
refresh::{RefreshFailure, TokenRefresher},
token::TokenPair,
};
const CODEX_OAUTH_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
const OPENAI_OAUTH_AUTHORIZE_ENDPOINT: &str = "https://auth.openai.com/oauth/authorize";
const OPENAI_OAUTH_DEVICE_CODE_ENDPOINT: &str = "https://auth.openai.com/oauth/device/code";
const OPENAI_OAUTH_TOKEN_ENDPOINT: &str = "https://auth.openai.com/oauth/token";
const OAUTH_CALLBACK_PORT: u16 = 1455;
const OAUTH_CALLBACK_PATH: &str = "/auth/openai/callback";
const OAUTH_CALLBACK_PATH: &str = "/auth/callback";
#[derive(Debug, Clone)]
pub struct OAuthConfig {
@@ -30,12 +29,12 @@ pub struct OAuthConfig {
}
impl OAuthConfig {
pub fn codex_default() -> Self {
pub fn from_auth_config(config: &AuthConfig) -> Self {
Self {
client_id: CODEX_OAUTH_CLIENT_ID.to_string(),
auth_endpoint: OPENAI_OAUTH_AUTHORIZE_ENDPOINT.to_string(),
client_id: config.oauth_client_id.clone(),
auth_endpoint: config.oauth_auth_endpoint.clone(),
device_code_endpoint: OPENAI_OAUTH_DEVICE_CODE_ENDPOINT.to_string(),
token_endpoint: OPENAI_OAUTH_TOKEN_ENDPOINT.to_string(),
token_endpoint: config.oauth_token_endpoint.clone(),
}
}
}
@@ -193,10 +192,6 @@ impl OpenAiOAuthRefresher {
pub fn new(client: Client, config: OAuthConfig) -> Self {
Self { client, config }
}
pub fn codex_default(client: Client) -> Self {
Self::new(client, OAuthConfig::codex_default())
}
}
#[derive(Deserialize)]
+51 -15
View File
@@ -4,7 +4,10 @@ use chrono::Utc;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::codex::gateway::transport::sse::{parse_sse_events, SseError};
use crate::codex::gateway::{
protocol::tuple_schema::reconvert_tuple_values,
transport::sse::{parse_sse_events, SseError},
};
pub fn openai_error(message: &str, code: &str) -> Value {
json!({
@@ -21,6 +24,7 @@ pub fn chat_completion_from_codex_sse(
body: &str,
model: &str,
include_reasoning: bool,
tuple_schema: Option<&Value>,
) -> Result<Option<Value>, SseError> {
let events = parse_sse_events(body)?;
let mut content = String::new();
@@ -77,6 +81,9 @@ pub fn chat_completion_from_codex_sse(
if content.is_empty() {
content = output_text_from_response(&response);
}
if let Some(tuple_schema) = tuple_schema {
content = reconvert_tuple_text(&content, tuple_schema).unwrap_or(content);
}
let mut message = json!({
"role": "assistant",
"content": if content.is_empty() && !tool_calls.is_empty() {
@@ -111,6 +118,7 @@ pub fn chat_completion_stream_from_codex_sse(
body: &str,
model: &str,
include_reasoning: bool,
tuple_schema: Option<&Value>,
) -> Result<Option<String>, SseError> {
let events = parse_sse_events(body)?;
let chunk_id = format!("chatcmpl-{}", Uuid::new_v4().simple());
@@ -122,6 +130,7 @@ pub fn chat_completion_stream_from_codex_sse(
let mut tool_call_indices = BTreeMap::new();
let mut call_ids_with_deltas = BTreeSet::new();
let mut next_tool_call_index = 0usize;
let mut tuple_text_buffer = tuple_schema.map(|_| String::new());
push_sse_data(
&mut output,
@@ -145,20 +154,11 @@ pub fn chat_completion_stream_from_codex_sse(
match event.event.as_deref() {
Some("response.output_text.delta") => {
if let Some(delta) = value.get("delta").and_then(Value::as_str) {
push_sse_data(
&mut output,
json!({
"id": chunk_id,
"object": "chat.completion.chunk",
"created": created,
"model": model,
"choices": [{
"index": 0,
"delta": {"content": delta},
"finish_reason": null,
}],
}),
);
if let Some(buffer) = tuple_text_buffer.as_mut() {
buffer.push_str(delta);
} else {
push_chat_content_delta(&mut output, &chunk_id, created, model, delta);
}
}
}
Some("response.reasoning_summary_text.delta") if include_reasoning => {
@@ -293,6 +293,14 @@ pub fn chat_completion_stream_from_codex_sse(
return Ok(None);
};
if let (Some(tuple_schema), Some(buffer)) = (tuple_schema, tuple_text_buffer.as_ref()) {
if !buffer.is_empty() {
let content =
reconvert_tuple_text(buffer, tuple_schema).unwrap_or_else(|| buffer.clone());
push_chat_content_delta(&mut output, &chunk_id, created, model, &content);
}
}
push_sse_data(
&mut output,
json!({
@@ -313,6 +321,34 @@ pub fn chat_completion_stream_from_codex_sse(
Ok(Some(output))
}
fn push_chat_content_delta(
output: &mut String,
chunk_id: &str,
created: i64,
model: &str,
content: &str,
) {
push_sse_data(
output,
json!({
"id": chunk_id,
"object": "chat.completion.chunk",
"created": created,
"model": model,
"choices": [{
"index": 0,
"delta": {"content": content},
"finish_reason": null,
}],
}),
);
}
fn reconvert_tuple_text(text: &str, tuple_schema: &Value) -> Option<String> {
let parsed = serde_json::from_str::<Value>(text).ok()?;
Some(reconvert_tuple_values(parsed, tuple_schema).to_string())
}
fn output_text_from_response(response: &Value) -> String {
response
.get("output_text")
+1
View File
@@ -2,3 +2,4 @@ pub mod codex_to_openai;
pub mod error;
pub mod openai_to_codex;
pub mod schema;
pub(crate) mod tuple_schema;
+51 -9
View File
@@ -3,6 +3,7 @@ use serde_json::{json, Value};
use crate::{
codex::gateway::protocol::error::{AppError, AppResult},
codex::gateway::protocol::tuple_schema::prepare_schema,
codex::gateway::transport::types::CodexResponsesRequest,
};
@@ -52,6 +53,12 @@ pub fn translate_chat_to_codex(req: ChatCompletionRequest) -> AppResult<CodexRes
));
}
let client_conversation_id = req
.user
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string);
let instructions = chat_instructions(&req.messages);
let mut input = chat_input(req.messages);
if input.is_empty() {
@@ -63,8 +70,12 @@ pub fn translate_chat_to_codex(req: ChatCompletionRequest) -> AppResult<CodexRes
request.tools = codex_tools(req.tools, req.functions);
request.tool_choice = req.tool_choice;
request.parallel_tool_calls = req.parallel_tool_calls;
request.text = response_format_text(req.response_format);
let response_format = response_format_text(req.response_format);
request.text = response_format.text;
request.tuple_schema = response_format.tuple_schema;
request.service_tier = req.service_tier;
request.prompt_cache_key = client_conversation_id.clone();
request.client_conversation_id = client_conversation_id;
if let Some(effort) = req.reasoning_effort {
request.reasoning = Some(json!({"effort": effort, "summary": "auto"}));
}
@@ -243,27 +254,58 @@ fn codex_tools(tools: Option<Vec<Value>>, functions: Option<Vec<Value>>) -> Opti
})
}
fn response_format_text(response_format: Option<Value>) -> Option<Value> {
let format = response_format?;
let kind = format.get("type").and_then(Value::as_str)?;
struct PreparedResponseFormat {
text: Option<Value>,
tuple_schema: Option<Value>,
}
fn response_format_text(response_format: Option<Value>) -> PreparedResponseFormat {
let Some(format) = response_format else {
return PreparedResponseFormat {
text: None,
tuple_schema: None,
};
};
let Some(kind) = format.get("type").and_then(Value::as_str) else {
return PreparedResponseFormat {
text: None,
tuple_schema: None,
};
};
match kind {
"json_object" => Some(json!({"format": {"type": "json_object"}})),
"json_object" => PreparedResponseFormat {
text: Some(json!({"format": {"type": "json_object"}})),
tuple_schema: None,
},
"json_schema" => {
let schema = format.get("json_schema")?;
let Some(schema) = format.get("json_schema") else {
return PreparedResponseFormat {
text: None,
tuple_schema: None,
};
};
let name = schema
.get("name")
.and_then(Value::as_str)
.unwrap_or("response");
let prepared_schema =
prepare_schema(schema.get("schema").cloned().unwrap_or_else(|| json!({})));
let mut codex_format = json!({
"type": "json_schema",
"name": name,
"schema": schema.get("schema").cloned().unwrap_or_else(|| json!({})),
"schema": prepared_schema.schema,
});
if let Some(strict) = schema.get("strict").and_then(Value::as_bool) {
codex_format["strict"] = Value::Bool(strict);
}
Some(json!({"format": codex_format}))
PreparedResponseFormat {
text: Some(json!({"format": codex_format})),
tuple_schema: prepared_schema.original_schema,
}
}
_ => None,
_ => PreparedResponseFormat {
text: None,
tuple_schema: None,
},
}
}
+250
View File
@@ -0,0 +1,250 @@
use serde_json::{Map, Value};
pub(crate) struct PreparedSchema {
pub(crate) schema: Value,
pub(crate) original_schema: Option<Value>,
}
pub(crate) fn prepare_schema(schema: Value) -> PreparedSchema {
let original_schema = has_tuple_schemas(&schema).then(|| schema.clone());
let mut schema = schema;
if original_schema.is_some() {
convert_tuple_schemas(&mut schema);
}
inject_additional_properties(&mut schema);
PreparedSchema {
schema,
original_schema,
}
}
pub(crate) fn reconvert_tuple_values(data: Value, schema: &Value) -> Value {
reconvert_tuple_values_with_root(data, schema, schema)
}
fn reconvert_tuple_values_with_root(data: Value, schema: &Value, root_schema: &Value) -> Value {
let Value::Object(schema_object) = schema else {
return data;
};
if let Some(reference) = schema_object.get("$ref").and_then(Value::as_str) {
let Some(resolved) = resolve_ref(reference, root_schema) else {
return data;
};
return reconvert_tuple_values_with_root(data, resolved, root_schema);
}
if let Some(Value::Array(prefix_items)) = schema_object.get("prefixItems") {
let mut data_object = match data {
Value::Object(data_object) => data_object,
other => return other,
};
let values = prefix_items
.iter()
.enumerate()
.map(|(index, item_schema)| {
let value = data_object
.remove(&index.to_string())
.unwrap_or(Value::Null);
reconvert_tuple_values_with_root(value, item_schema, root_schema)
})
.collect();
return Value::Array(values);
}
if let Some(Value::Object(properties)) = schema_object.get("properties") {
let mut data_object = match data {
Value::Object(data_object) => data_object,
other => return other,
};
for (key, property_schema) in properties {
if let Some(value) = data_object.remove(key) {
data_object.insert(
key.clone(),
reconvert_tuple_values_with_root(value, property_schema, root_schema),
);
}
}
return Value::Object(data_object);
}
if let Some(items_schema) = schema_object.get("items") {
let values = match data {
Value::Array(values) => values,
other => return other,
};
return Value::Array(
values
.into_iter()
.map(|value| reconvert_tuple_values_with_root(value, items_schema, root_schema))
.collect(),
);
}
for key in ["oneOf", "anyOf", "allOf"] {
let Some(Value::Array(branches)) = schema_object.get(key) else {
continue;
};
let Some(branch) = branches.iter().find(|branch| has_tuple_schemas(branch)) else {
continue;
};
return reconvert_tuple_values_with_root(data, branch, root_schema);
}
data
}
fn resolve_ref<'a>(reference: &str, root_schema: &'a Value) -> Option<&'a Value> {
let path = reference
.strip_prefix("#/$defs/")
.map(|path| ("$defs", path))
.or_else(|| {
reference
.strip_prefix("#/definitions/")
.map(|path| ("definitions", path))
})?;
root_schema.get(path.0)?.get(path.1)
}
fn has_tuple_schemas(value: &Value) -> bool {
let Value::Object(object) = value else {
return false;
};
if object
.get("prefixItems")
.is_some_and(serde_json::Value::is_array)
{
return true;
}
object_values_have_tuple_schema(object, "properties")
|| object.get("items").is_some_and(has_tuple_schemas)
|| schema_array_has_tuple_schema(object, "oneOf")
|| schema_array_has_tuple_schema(object, "anyOf")
|| schema_array_has_tuple_schema(object, "allOf")
|| object_values_have_tuple_schema(object, "$defs")
|| object_values_have_tuple_schema(object, "definitions")
|| object.get("if").is_some_and(has_tuple_schemas)
|| object.get("then").is_some_and(has_tuple_schemas)
|| object.get("else").is_some_and(has_tuple_schemas)
|| object.get("not").is_some_and(has_tuple_schemas)
}
fn object_values_have_tuple_schema(object: &Map<String, Value>, key: &str) -> bool {
let Some(Value::Object(values)) = object.get(key) else {
return false;
};
values.values().any(has_tuple_schemas)
}
fn schema_array_has_tuple_schema(object: &Map<String, Value>, key: &str) -> bool {
let Some(Value::Array(values)) = object.get(key) else {
return false;
};
values.iter().any(has_tuple_schemas)
}
fn convert_tuple_schemas(value: &mut Value) {
let Value::Object(object) = value else {
return;
};
if let Some(Value::Array(prefix_items)) = object.remove("prefixItems") {
let mut properties = Map::new();
let mut required = Vec::new();
for (index, mut item) in prefix_items.into_iter().enumerate() {
convert_tuple_schemas(&mut item);
let key = index.to_string();
properties.insert(key.clone(), item);
required.push(Value::String(key));
}
object.insert("type".to_string(), Value::String("object".to_string()));
object.insert("properties".to_string(), Value::Object(properties));
object.insert("required".to_string(), Value::Array(required));
object.insert("additionalProperties".to_string(), Value::Bool(false));
object.remove("items");
return;
}
convert_object_values(object, "properties");
convert_object_value(object, "items");
convert_schema_array(object, "oneOf");
convert_schema_array(object, "anyOf");
convert_schema_array(object, "allOf");
convert_object_values(object, "$defs");
convert_object_values(object, "definitions");
convert_object_value(object, "if");
convert_object_value(object, "then");
convert_object_value(object, "else");
convert_object_value(object, "not");
}
fn convert_object_value(object: &mut Map<String, Value>, key: &str) {
if let Some(value) = object.get_mut(key) {
convert_tuple_schemas(value);
}
}
fn convert_object_values(object: &mut Map<String, Value>, key: &str) {
let Some(Value::Object(values)) = object.get_mut(key) else {
return;
};
for value in values.values_mut() {
convert_tuple_schemas(value);
}
}
fn convert_schema_array(object: &mut Map<String, Value>, key: &str) {
let Some(Value::Array(values)) = object.get_mut(key) else {
return;
};
for value in values {
convert_tuple_schemas(value);
}
}
fn inject_additional_properties(value: &mut Value) {
let Value::Object(object) = value else {
return;
};
if object.get("type").and_then(Value::as_str) == Some("object")
&& !object.contains_key("additionalProperties")
{
object.insert("additionalProperties".to_string(), Value::Bool(false));
}
inject_object_values(object, "properties");
inject_object_value(object, "items");
inject_schema_array(object, "oneOf");
inject_schema_array(object, "anyOf");
inject_schema_array(object, "allOf");
inject_object_values(object, "$defs");
inject_object_values(object, "definitions");
inject_object_value(object, "if");
inject_object_value(object, "then");
inject_object_value(object, "else");
inject_object_value(object, "not");
}
fn inject_object_value(object: &mut Map<String, Value>, key: &str) {
if let Some(value) = object.get_mut(key) {
inject_additional_properties(value);
}
}
fn inject_object_values(object: &mut Map<String, Value>, key: &str) {
let Some(Value::Object(values)) = object.get_mut(key) else {
return;
};
for value in values.values_mut() {
inject_additional_properties(value);
}
}
fn inject_schema_array(object: &mut Map<String, Value>, key: &str) {
let Some(Value::Array(values)) = object.get_mut(key) else {
return;
};
for value in values {
inject_additional_properties(value);
}
}
+31 -13
View File
@@ -2,12 +2,10 @@ use indexmap::IndexMap;
use crate::codex::gateway::fingerprint::model::Fingerprint;
pub fn build_codex_headers(
pub fn build_codex_base_headers(
fp: &Fingerprint,
access_token: &str,
account_id: Option<&str>,
turn_state: Option<&str>,
request_id: &str,
) -> IndexMap<String, String> {
let mut headers = IndexMap::new();
@@ -19,16 +17,6 @@ pub fn build_codex_headers(
headers.insert("chatgpt-account-id".to_string(), id.to_string());
}
headers.insert("originator".to_string(), fp.originator.clone());
headers.insert(
"x-openai-internal-codex-residency".to_string(),
"us".to_string(),
);
headers.insert("x-client-request-id".to_string(), request_id.to_string());
if let Some(state) = turn_state {
headers.insert("x-codex-turn-state".to_string(), state.to_string());
}
headers.insert("user-agent".to_string(), fp.user_agent());
headers.insert("sec-ch-ua".to_string(), fp.sec_ch_ua());
@@ -39,6 +27,27 @@ pub fn build_codex_headers(
}
}
headers
}
pub fn build_codex_headers(
fp: &Fingerprint,
access_token: &str,
account_id: Option<&str>,
turn_state: Option<&str>,
request_id: &str,
) -> IndexMap<String, String> {
let mut headers = build_codex_base_headers(fp, access_token, account_id);
headers.insert(
"x-openai-internal-codex-residency".to_string(),
"us".to_string(),
);
headers.insert("x-client-request-id".to_string(), request_id.to_string());
if let Some(state) = turn_state {
headers.insert("x-codex-turn-state".to_string(), state.to_string());
}
headers.insert("accept".to_string(), "text/event-stream".to_string());
headers
@@ -72,3 +81,12 @@ pub fn build_ordered_codex_headers(
let headers = build_codex_headers(fp, access_token, account_id, turn_state, request_id);
order_headers(headers, &fp.header_order)
}
pub fn build_ordered_codex_base_headers(
fp: &Fingerprint,
access_token: &str,
account_id: Option<&str>,
) -> IndexMap<String, String> {
let headers = build_codex_base_headers(fp, access_token, account_id);
order_headers(headers, &fp.header_order)
}
+237 -46
View File
@@ -1,21 +1,28 @@
use std::sync::Arc;
use std::{
collections::HashMap,
sync::{Arc, Mutex, OnceLock},
time::Duration,
};
use futures::StreamExt;
use indexmap::IndexMap;
use reqwest::{
header::{
HeaderMap, HeaderName, HeaderValue, ACCEPT, CONTENT_TYPE, COOKIE, RETRY_AFTER, SET_COOKIE,
HeaderMap, HeaderName, HeaderValue, ACCEPT, ACCEPT_ENCODING, CONTENT_TYPE, COOKIE,
RETRY_AFTER, SET_COOKIE,
},
Client, Response as ReqwestResponse, StatusCode,
};
use serde_json::{Map, Value};
use thiserror::Error;
use uuid::Uuid;
use crate::{
codex::gateway::fingerprint::model::Fingerprint,
codex::gateway::transport::{
headers::build_ordered_codex_headers,
headers::build_ordered_codex_base_headers,
sse::SseError,
types::CodexResponsesRequest,
types::{CodexCompactRequest, CodexResponsesRequest},
usage_events::{extract_sse_usage, TokenUsage},
websocket::{
append_rate_limit_updates, create_response_via_websocket,
@@ -80,6 +87,13 @@ pub struct CodexBackendResponse {
pub rate_limit_headers: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CodexCompactResponse {
pub body: Value,
pub set_cookie_headers: Vec<String>,
pub rate_limit_headers: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexConnectivityProbe {
pub endpoint: String,
@@ -101,6 +115,8 @@ pub struct CodexBackendWebSocketStream {
pub rate_limit_updates: SharedRateLimitUpdates,
}
const MAX_UPSTREAM_ERROR_BODY_BYTES: usize = 1024 * 1024;
#[derive(Clone)]
pub struct CodexBackendClient {
client: Client,
@@ -147,11 +163,12 @@ impl CodexBackendClient {
) -> CodexClientResult<CodexBackendResponse> {
let transport = transport_for_request(request);
let upstream_request = response_upstream_request(request, context);
let headers = self.request_headers_for_response(&upstream_request, context)?;
if matches!(
transport,
CodexTransport::WebSocketPreferred | CodexTransport::WebSocketRequired
) {
let headers =
self.request_headers_for_websocket_response(&upstream_request, context)?;
match self
.create_response_via_configured_websocket(&upstream_request, headers.clone())
.await
@@ -179,6 +196,7 @@ impl CodexBackendClient {
}
}
let headers = self.request_headers_for_http_response(&upstream_request, context)?;
let response = self
.send_response_request(&upstream_request, headers)
.await?;
@@ -187,7 +205,49 @@ impl CodexBackendClient {
let set_cookie_headers = set_cookie_headers(&response);
let rate_limit_headers = rate_limit_headers(response.headers());
let retry_after_seconds = retry_after_seconds(response.headers(), None);
if !status.is_success() {
let body = read_capped_error_body(response).await?;
return Err(CodexClientError::Upstream {
status,
retry_after_seconds: retry_after_seconds
.or_else(|| retry_after_seconds_from_body(&body)),
body,
});
}
let body = response.text().await?;
let usage = extract_sse_usage(&body)?;
Ok(CodexBackendResponse {
body,
usage,
turn_state,
set_cookie_headers,
rate_limit_headers,
})
}
#[tracing::instrument(
skip(self, request, context),
fields(
request_id = %context.request_id,
account_id = %context.account_id.unwrap_or("unknown"),
model = %request.model,
transport = "http_json",
)
)]
pub async fn create_compact_response(
&self,
request: &CodexCompactRequest,
context: CodexRequestContext<'_>,
) -> CodexClientResult<CodexCompactResponse> {
let headers = self.compact_request_headers(context)?;
let response = self.send_compact_request(request, headers).await?;
let status = response.status();
let set_cookie_headers = set_cookie_headers(&response);
let rate_limit_headers = rate_limit_headers(response.headers());
let retry_after_seconds = retry_after_seconds(response.headers(), None);
let body = response.text().await?;
if !status.is_success() {
return Err(CodexClientError::Upstream {
status,
@@ -196,12 +256,19 @@ impl CodexBackendClient {
body,
});
}
let usage = extract_sse_usage(&body)?;
Ok(CodexBackendResponse {
body,
usage,
turn_state,
let parsed =
serde_json::from_str::<Value>(&body).map_err(|_| CodexClientError::Upstream {
status: StatusCode::BAD_GATEWAY,
retry_after_seconds: None,
body: format!(
"Compact response is not valid JSON: {}",
truncate_for_error(&body)
),
})?;
Ok(CodexCompactResponse {
body: parsed,
set_cookie_headers,
rate_limit_headers,
})
@@ -222,7 +289,7 @@ impl CodexBackendClient {
context: CodexRequestContext<'_>,
) -> CodexClientResult<CodexBackendStream> {
let upstream_request = response_upstream_request(request, context);
let headers = self.request_headers_for_response(&upstream_request, context)?;
let headers = self.request_headers_for_http_response(&upstream_request, context)?;
let response = self
.send_response_request(&upstream_request, headers)
.await?;
@@ -232,7 +299,7 @@ impl CodexBackendClient {
let rate_limit_headers = rate_limit_headers(response.headers());
if !status.is_success() {
let retry_after_seconds = retry_after_seconds(response.headers(), None);
let body = response.text().await?;
let body = read_capped_error_body(response).await?;
return Err(CodexClientError::Upstream {
status,
retry_after_seconds: retry_after_seconds
@@ -264,7 +331,7 @@ impl CodexBackendClient {
context: CodexRequestContext<'_>,
) -> CodexClientResult<CodexBackendWebSocketStream> {
let upstream_request = response_upstream_request(request, context);
let headers = self.request_headers_for_response(&upstream_request, context)?;
let headers = self.request_headers_for_websocket_response(&upstream_request, context)?;
let response = self
.create_stream_via_configured_websocket(&upstream_request, headers)
.await
@@ -350,8 +417,7 @@ impl CodexBackendClient {
];
for endpoint in endpoints {
let mut headers = self.request_headers(context)?;
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
let headers = self.auxiliary_request_headers(context)?;
let response = self.client.get(endpoint).headers(headers).send().await?;
if !response.status().is_success() {
continue;
@@ -374,8 +440,7 @@ impl CodexBackendClient {
"{}/codex/models?client_version={}",
self.base_url, self.fingerprint.app_version
);
let mut headers = self.request_headers(context)?;
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
let headers = self.auxiliary_request_headers(context)?;
let response = self.client.get(&endpoint).headers(headers).send().await?;
Ok(CodexConnectivityProbe {
@@ -387,17 +452,17 @@ impl CodexBackendClient {
pub async fn fetch_usage(&self, context: CodexRequestContext<'_>) -> CodexClientResult<Value> {
let mut last_invalid_body = None;
for endpoint in self.usage_endpoints() {
let mut headers = self.request_headers(context)?;
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
let headers = self.auxiliary_request_headers(context)?;
let response = self.client.get(endpoint).headers(headers).send().await?;
let status = response.status();
let retry_after_seconds = retry_after_seconds(response.headers(), None);
let body = response.text().await?;
if status == StatusCode::NOT_FOUND {
let body = read_capped_error_body(response).await?;
last_invalid_body = Some(body);
continue;
}
if !status.is_success() {
let body = read_capped_error_body(response).await?;
return Err(CodexClientError::Upstream {
status,
retry_after_seconds: retry_after_seconds
@@ -405,6 +470,7 @@ impl CodexBackendClient {
body,
});
}
let body = response.text().await?;
match serde_json::from_str::<Value>(&body) {
Ok(parsed) if parsed.get("rate_limit").is_some() => return Ok(parsed),
_ => last_invalid_body = Some(body),
@@ -435,6 +501,21 @@ impl CodexBackendClient {
.await?)
}
async fn send_compact_request(
&self,
request: &CodexCompactRequest,
headers: HeaderMap,
) -> CodexClientResult<ReqwestResponse> {
let url = format!("{}/codex/responses/compact", self.base_url);
Ok(self
.client
.post(url)
.headers(headers)
.json(request)
.send()
.await?)
}
fn usage_endpoints(&self) -> Vec<String> {
if self.base_url.contains("/backend-api") {
vec![
@@ -451,35 +532,37 @@ impl CodexBackendClient {
fn request_headers(&self, context: CodexRequestContext<'_>) -> CodexClientResult<HeaderMap> {
let request_id = context.session_id.unwrap_or(context.request_id);
let ordered_headers = build_ordered_codex_headers(
let ordered_headers = build_ordered_codex_base_headers(
&self.fingerprint,
context.access_token,
context.account_id,
context.turn_state,
request_id,
);
let mut headers = HeaderMap::new();
for (name, value) in &ordered_headers {
if name == "content-type" || name == "openai-beta" || name == "cookie" {
continue;
}
headers.insert(
HeaderName::from_bytes(name.as_bytes())?,
HeaderValue::from_str(value)?,
);
}
if let Some(cookie_header) = context.cookie_header {
headers.insert(COOKIE, HeaderValue::from_str(cookie_header)?);
}
insert_ordered_headers(&mut headers, &ordered_headers)?;
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
insert_optional_header(&mut headers, "cookie", context.cookie_header)?;
headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream"));
headers.insert(
HeaderName::from_static("openai-beta"),
HeaderValue::from_static("responses_websockets=2026-02-06"),
);
headers.insert(
HeaderName::from_static("x-openai-internal-codex-residency"),
HeaderValue::from_static("us"),
);
headers.insert(
HeaderName::from_static("x-client-request-id"),
HeaderValue::from_str(request_id)?,
);
insert_optional_header(
&mut headers,
"x-codex-installation-id",
context.installation_id,
)?;
insert_optional_header(&mut headers, "session_id", context.session_id)?;
insert_optional_header(&mut headers, "x-codex-window-id", context.codex_window_id)?;
insert_optional_header(&mut headers, "x-codex-turn-state", context.turn_state)?;
insert_optional_header(&mut headers, "x-codex-turn-metadata", context.turn_metadata)?;
insert_optional_header(&mut headers, "x-codex-beta-features", context.beta_features)?;
insert_optional_header(
@@ -488,23 +571,74 @@ impl CodexBackendClient {
context.include_timing_metrics,
)?;
insert_optional_header(&mut headers, "version", context.version)?;
insert_optional_header(&mut headers, "x-codex-window-id", context.codex_window_id)?;
insert_optional_header(
&mut headers,
"x-codex-parent-thread-id",
context.parent_thread_id,
)?;
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
headers.insert(
HeaderName::from_static("openai-beta"),
HeaderValue::from_static("responses_websockets=2026-02-06"),
Ok(headers)
}
fn auxiliary_request_headers(
&self,
context: CodexRequestContext<'_>,
) -> CodexClientResult<HeaderMap> {
let ordered_headers = build_ordered_codex_base_headers(
&self.fingerprint,
context.access_token,
context.account_id,
);
let mut headers = HeaderMap::new();
insert_ordered_headers(&mut headers, &ordered_headers)?;
if let Some(cookie_header) = context.cookie_header {
headers.insert(COOKIE, HeaderValue::from_str(cookie_header)?);
}
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
headers.insert(ACCEPT_ENCODING, HeaderValue::from_static("gzip, deflate"));
Ok(headers)
}
fn request_headers_for_response(
fn compact_request_headers(
&self,
context: CodexRequestContext<'_>,
) -> CodexClientResult<HeaderMap> {
let ordered_headers = build_ordered_codex_base_headers(
&self.fingerprint,
context.access_token,
context.account_id,
);
let mut headers = HeaderMap::new();
insert_ordered_headers(&mut headers, &ordered_headers)?;
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
insert_optional_header(&mut headers, "cookie", context.cookie_header)?;
headers.insert(
HeaderName::from_static("openai-beta"),
HeaderValue::from_static("responses_websockets=2026-02-06"),
);
headers.insert(
HeaderName::from_static("x-openai-internal-codex-residency"),
HeaderValue::from_static("us"),
);
headers.insert(
HeaderName::from_static("x-client-request-id"),
HeaderValue::from_str(&Uuid::new_v4().to_string())?,
);
insert_optional_header(
&mut headers,
"x-codex-installation-id",
context.installation_id,
)?;
Ok(headers)
}
fn request_headers_for_http_response(
&self,
request: &CodexResponsesRequest,
context: CodexRequestContext<'_>,
@@ -518,6 +652,17 @@ impl CodexBackendClient {
}
Ok(headers)
}
fn request_headers_for_websocket_response(
&self,
request: &CodexResponsesRequest,
context: CodexRequestContext<'_>,
) -> CodexClientResult<HeaderMap> {
let mut headers = self.request_headers_for_http_response(request, context)?;
headers.remove(CONTENT_TYPE);
headers.remove(ACCEPT);
Ok(headers)
}
}
fn response_upstream_request(
@@ -532,6 +677,25 @@ fn response_upstream_request(
upstream
}
async fn read_capped_error_body(response: ReqwestResponse) -> Result<String, reqwest::Error> {
let mut stream = response.bytes_stream();
let mut body = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
let remaining = MAX_UPSTREAM_ERROR_BODY_BYTES.saturating_sub(body.len());
if remaining == 0 {
break;
}
if chunk.len() <= remaining {
body.extend_from_slice(&chunk);
} else {
body.extend_from_slice(&chunk[..remaining]);
break;
}
}
Ok(String::from_utf8_lossy(&body).into_owned())
}
async fn collect_websocket_stream_response(
response: CodexWebSocketStreamResponse,
) -> Result<crate::codex::gateway::transport::websocket::CodexWebSocketResponse, CodexWebSocketError>
@@ -660,11 +824,34 @@ fn insert_optional_header(
Ok(())
}
fn insert_ordered_headers(
headers: &mut HeaderMap,
ordered_headers: &IndexMap<String, String>,
) -> CodexClientResult<()> {
for (name, value) in ordered_headers {
headers.insert(
HeaderName::from_bytes(name.as_bytes())?,
HeaderValue::from_str(value)?,
);
}
Ok(())
}
pub fn build_reqwest_client(force_http11: bool) -> Result<Client, reqwest::Error> {
// Codex Desktop 指纹依赖 reqwest/rustls 组合,升级前必须重新验证 TLS 行为。
static CLIENTS: OnceLock<Mutex<HashMap<bool, Client>>> = OnceLock::new();
let cache = CLIENTS.get_or_init(|| Mutex::new(HashMap::new()));
let mut clients = cache
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(client) = clients.get(&force_http11) {
return Ok(client.clone());
}
let builder = Client::builder()
.use_rustls_tls()
.no_proxy()
.pool_max_idle_per_host(4)
.tcp_keepalive(Duration::from_secs(30))
.gzip(true)
.brotli(true)
.zstd(true)
@@ -674,7 +861,9 @@ pub fn build_reqwest_client(force_http11: bool) -> Result<Client, reqwest::Error
} else {
builder
};
builder.build()
let client = builder.build()?;
clients.insert(force_http11, client.clone());
Ok(client)
}
fn turn_state(response: &ReqwestResponse) -> Option<String> {
@@ -755,7 +944,9 @@ fn truncate_for_error(body: &str) -> String {
fn websocket_error_allows_http_sse_fallback(error: &CodexWebSocketError) -> bool {
match error {
CodexWebSocketError::Transport(_) | CodexWebSocketError::EmptyResponse => true,
CodexWebSocketError::Transport(_)
| CodexWebSocketError::OpenTimeout { .. }
| CodexWebSocketError::EmptyResponse => true,
CodexWebSocketError::Upstream { status, .. } => matches!(
*status,
StatusCode::NOT_FOUND
+79
View File
@@ -1,5 +1,7 @@
use thiserror::Error;
pub const MAX_SSE_EVENT_BUFFER_BYTES: usize = 64 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SseEvent {
pub event: Option<String>,
@@ -12,6 +14,8 @@ pub struct SseEvent {
pub enum SseError {
#[error("invalid SSE retry value: {0}")]
InvalidRetry(String),
#[error("SSE buffer exceeded {max_bytes} bytes — aborting stream")]
BufferExceeded { max_bytes: usize },
}
#[derive(Debug, Default)]
@@ -40,6 +44,13 @@ impl EventBuilder {
return None;
}
self.has_data = false;
if self.data == "[DONE]" {
self.event = None;
self.id = None;
self.retry = None;
self.data.clear();
return None;
}
Some(SseEvent {
event: self.event.take(),
data: std::mem::take(&mut self.data),
@@ -52,20 +63,28 @@ impl EventBuilder {
pub fn parse_sse_events(input: &str) -> Result<Vec<SseEvent>, SseError> {
let mut events = Vec::new();
let mut builder = EventBuilder::default();
let mut saw_sse_syntax = false;
let mut event_buffer_bytes = 0usize;
for raw_line in input.lines() {
let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
if line.is_empty() {
event_buffer_bytes = 0;
if let Some(event) = builder.finish() {
events.push(event);
}
continue;
}
track_event_buffer_bytes(&mut event_buffer_bytes, raw_line)?;
if line.starts_with(':') {
saw_sse_syntax = true;
continue;
}
let (field, value) = split_sse_field(line);
if matches!(field, "event" | "data" | "id" | "retry") {
saw_sse_syntax = true;
}
match field {
"event" => builder.event = Some(value.to_string()),
"data" => builder.push_data(value),
@@ -77,6 +96,7 @@ pub fn parse_sse_events(input: &str) -> Result<Vec<SseEvent>, SseError> {
.map_err(|_| SseError::InvalidRetry(value.to_string()))?,
);
}
_ if builder.has_data && !is_sse_metadata_line(line) => builder.push_data(line),
_ => {}
}
}
@@ -84,9 +104,68 @@ pub fn parse_sse_events(input: &str) -> Result<Vec<SseEvent>, SseError> {
if let Some(event) = builder.finish() {
events.push(event);
}
if events.is_empty() && !saw_sse_syntax && !input.trim().is_empty() {
events.push(non_sse_response_event(input.trim()));
}
Ok(events)
}
fn track_event_buffer_bytes(current_bytes: &mut usize, line: &str) -> Result<(), SseError> {
let line_separator_bytes = usize::from(*current_bytes != 0);
*current_bytes = current_bytes
.saturating_add(line_separator_bytes)
.saturating_add(line.len());
if *current_bytes > MAX_SSE_EVENT_BUFFER_BYTES {
return Err(SseError::BufferExceeded {
max_bytes: MAX_SSE_EVENT_BUFFER_BYTES,
});
}
Ok(())
}
fn is_sse_metadata_line(line: &str) -> bool {
line.starts_with("event:")
|| line.starts_with("data:")
|| line.starts_with("id:")
|| line.starts_with("retry:")
|| line.starts_with(':')
}
fn non_sse_response_event(raw: &str) -> SseEvent {
let message = non_sse_error_message(raw);
let data = serde_json::json!({
"error": {
"type": "error",
"code": "non_sse_response",
"message": message,
}
})
.to_string();
SseEvent {
event: Some("error".to_string()),
data,
id: None,
retry: None,
}
}
fn non_sse_error_message(raw: &str) -> String {
let Ok(value) = serde_json::from_str::<serde_json::Value>(raw) else {
return raw.to_string();
};
value
.get("detail")
.and_then(serde_json::Value::as_str)
.or_else(|| {
value
.pointer("/error/message")
.and_then(serde_json::Value::as_str)
})
.filter(|message| !message.trim().is_empty())
.unwrap_or(raw)
.to_string()
}
pub fn encode_sse_event(event: &str, data: &str) -> String {
let mut frame = String::new();
if !event.is_empty() {
+27
View File
@@ -18,12 +18,20 @@ pub struct CodexResponsesRequest {
pub parallel_tool_calls: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<Value>,
#[serde(skip)]
pub tuple_schema: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_response_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>,
#[serde(skip)]
pub explicit_prompt_cache_key: bool,
#[serde(skip)]
pub client_conversation_id: Option<String>,
#[serde(skip)]
pub variant_identity: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -65,9 +73,13 @@ impl CodexResponsesRequest {
tool_choice: None,
parallel_tool_calls: None,
text: None,
tuple_schema: None,
service_tier: None,
previous_response_id: None,
prompt_cache_key: None,
explicit_prompt_cache_key: false,
client_conversation_id: None,
variant_identity: None,
include: None,
client_metadata: None,
use_websocket: false,
@@ -82,3 +94,18 @@ impl CodexResponsesRequest {
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodexCompactRequest {
pub model: String,
pub input: Vec<Value>,
pub instructions: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parallel_tool_calls: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<Value>,
}
+22 -5
View File
@@ -11,6 +11,12 @@ pub(super) struct ClassifiedWebSocketError {
pub(super) connection_fatal: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum WebSocketErrorClassificationProfile {
OneShot,
Pooled,
}
pub(super) fn websocket_request_body(request: &CodexResponsesRequest) -> Value {
let mut body = json!({
"type": "response.create",
@@ -82,7 +88,10 @@ pub(super) fn is_terminal_websocket_event(event: &str) -> bool {
event == "response.completed" || event == "response.failed" || event == "error"
}
pub(super) fn classify_ws_error_frame(raw: &str) -> Option<ClassifiedWebSocketError> {
pub(super) fn classify_ws_error_frame(
raw: &str,
profile: WebSocketErrorClassificationProfile,
) -> Option<ClassifiedWebSocketError> {
let value = serde_json::from_str::<Value>(raw).ok()?;
let event_type = value.get("type").and_then(Value::as_str)?;
if event_type != "error" && event_type != "response.failed" {
@@ -95,10 +104,11 @@ pub(super) fn classify_ws_error_frame(raw: &str) -> Option<ClassifiedWebSocketEr
.or_else(|| value.pointer("/error/type"))
.and_then(Value::as_str)?
.to_ascii_lowercase();
let status = rotatable_error_status(&code)?;
let status = rotatable_error_status(&code, profile)?;
Some(ClassifiedWebSocketError {
status,
connection_fatal: code == "websocket_connection_limit_reached",
connection_fatal: profile == WebSocketErrorClassificationProfile::Pooled
&& code == "websocket_connection_limit_reached",
})
}
@@ -169,7 +179,10 @@ pub(super) fn retry_after_seconds_from_body(body: &str) -> Option<u64> {
(resets_at > now).then_some(resets_at - now)
}
fn rotatable_error_status(code: &str) -> Option<StatusCode> {
fn rotatable_error_status(
code: &str,
profile: WebSocketErrorClassificationProfile,
) -> Option<StatusCode> {
match code {
"usage_limit_reached" | "rate_limit_exceeded" | "rate_limit_reached" => {
Some(StatusCode::TOO_MANY_REQUESTS)
@@ -180,7 +193,11 @@ fn rotatable_error_status(code: &str) -> Option<StatusCode> {
}
"forbidden" | "account_banned" | "banned" => Some(StatusCode::FORBIDDEN),
"previous_response_not_found" => Some(StatusCode::BAD_REQUEST),
"websocket_connection_limit_reached" => Some(StatusCode::SERVICE_UNAVAILABLE),
"websocket_connection_limit_reached"
if profile == WebSocketErrorClassificationProfile::Pooled =>
{
Some(StatusCode::SERVICE_UNAVAILABLE)
}
_ => None,
}
}
+72 -11
View File
@@ -1,12 +1,13 @@
use std::{pin::Pin, sync::Arc, time::Instant};
use std::{
pin::Pin,
sync::Arc,
time::{Duration, Instant},
};
use thiserror::Error;
use tokio::sync::Mutex;
use tokio_tungstenite::{
connect_async,
tungstenite::{
client::IntoClientRequest, http::Request as WsRequest, Error as WsError, Message,
},
use tokio::{sync::Mutex, time::timeout};
use tokio_tungstenite::tungstenite::{
client::IntoClientRequest, http::Request as WsRequest, Error as WsError, Message,
};
use futures::{channel::mpsc, SinkExt, Stream, StreamExt};
@@ -18,12 +19,14 @@ use crate::codex::gateway::transport::{
};
mod codec;
mod opening;
mod pool;
use codec::{
classify_ws_error_frame, codex_websocket_transport_error, is_internal_websocket_event,
is_terminal_websocket_event, retry_after_seconds_from_body, websocket_event_type,
websocket_message_text, websocket_request_body, websocket_sse_chunk,
WebSocketErrorClassificationProfile,
};
use pool::{
CodexWebSocketConnectionMetadata, CodexWsStream, PooledWebSocketConnection,
@@ -31,6 +34,8 @@ use pool::{
};
pub use pool::{CodexWebSocketPool, CodexWebSocketPoolConfig, CodexWebSocketPoolKey};
const WEBSOCKET_OPEN_TIMEOUT: Duration = Duration::from_secs(20);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodexTransport {
HttpSse,
@@ -52,6 +57,8 @@ pub enum CodexWebSocketError {
InvalidRequest(#[from] tokio_tungstenite::tungstenite::http::Error),
#[error("websocket transport error: {0}")]
Transport(#[source] WsError),
#[error("websocket handshake timed out after {timeout:?}")]
OpenTimeout { timeout: Duration },
#[error("websocket handshake returned status {status}: {body}")]
Upstream {
status: StatusCode,
@@ -99,6 +106,14 @@ struct WebSocketPoolReturn {
}
impl ActiveWebSocket {
fn error_classification_profile(&self) -> WebSocketErrorClassificationProfile {
if self.pool_return.is_some() {
WebSocketErrorClassificationProfile::Pooled
} else {
WebSocketErrorClassificationProfile::OneShot
}
}
async fn finish(self) {
let Self {
websocket,
@@ -288,7 +303,8 @@ async fn create_response_via_websocket_stream_inner(
capture_internal_rate_limit_event(&raw, &rate_limit_updates).await;
continue;
}
if let Some(classified) = classify_ws_error_frame(&raw) {
let classification_profile = active.error_classification_profile();
if let Some(classified) = classify_ws_error_frame(&raw, classification_profile) {
if classified.connection_fatal {
active.discard().await;
} else {
@@ -405,11 +421,25 @@ async fn acquire_websocket(
async fn connect_websocket(
base_url: &str,
headers: HeaderMap,
) -> Result<(CodexWsStream, CodexWebSocketConnectionMetadata), CodexWebSocketError> {
connect_websocket_with_timeout(base_url, headers, WEBSOCKET_OPEN_TIMEOUT).await
}
async fn connect_websocket_with_timeout(
base_url: &str,
headers: HeaderMap,
open_timeout: Duration,
) -> Result<(CodexWsStream, CodexWebSocketConnectionMetadata), CodexWebSocketError> {
let ws_request = build_ws_request(base_url, headers)?;
let (websocket, handshake_response) = connect_async(ws_request)
.await
.map_err(codex_websocket_transport_error)?;
let (websocket, handshake_response) = timeout(
open_timeout,
opening::connect_with_original_opening_handshake(ws_request),
)
.await
.map_err(|_| CodexWebSocketError::OpenTimeout {
timeout: open_timeout,
})?
.map_err(codex_websocket_transport_error)?;
Ok((
websocket,
CodexWebSocketConnectionMetadata::from_headers(handshake_response.headers()),
@@ -504,3 +534,34 @@ pub async fn append_rate_limit_updates(
headers.extend(rate_limits_to_header_pairs(update));
}
}
#[cfg(test)]
mod tests {
use reqwest::header::HeaderMap;
use tokio::{net::TcpListener, time::Duration};
use super::{connect_websocket_with_timeout, CodexWebSocketError};
#[tokio::test]
async fn connect_websocket_with_timeout_should_fail_when_handshake_stalls() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (_stream, _) = listener.accept().await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
});
let result = connect_websocket_with_timeout(
&format!("http://{addr}"),
HeaderMap::new(),
Duration::from_millis(10),
)
.await;
let Err(CodexWebSocketError::OpenTimeout { timeout }) = result else {
panic!("expected websocket open timeout");
};
assert_eq!(timeout, Duration::from_millis(10));
server.abort();
}
}
@@ -0,0 +1,374 @@
use std::sync::Arc;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use rustls::{ClientConfig, RootCertStore};
use rustls_pki_types::ServerName;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpStream,
};
use tokio_rustls::TlsConnector;
use tokio_tungstenite::{
tungstenite::{
error::{CapacityError, Error as WsError, ProtocolError, TlsError, UrlError},
handshake::{
client::{generate_key, Response as WsResponse},
derive_accept_key,
},
http::{Request as WsRequest, StatusCode, Uri},
protocol::Role,
},
MaybeTlsStream, WebSocketStream,
};
use super::pool::CodexWsStream;
const MAX_OPENING_RESPONSE_BYTES: usize = 64 * 1024;
const ORIGINAL_WS_PERMESSAGE_DEFLATE_EXTENSION: &str = "permessage-deflate; client_max_window_bits";
const ORIGINAL_WS_HEADER_ORDER: &[&str] = &[
"authorization",
"chatgpt-account-id",
"originator",
"user-agent",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"accept-encoding",
"accept-language",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-dest",
"cookie",
"openai-beta",
"x-openai-internal-codex-residency",
"x-client-request-id",
"x-codex-installation-id",
"session_id",
"x-codex-window-id",
"x-codex-turn-state",
"x-codex-turn-metadata",
"x-codex-beta-features",
"x-responsesapi-include-timing-metrics",
"version",
"x-codex-parent-thread-id",
"x-openai-subagent",
];
pub(super) async fn connect_with_original_opening_handshake(
mut request: WsRequest<()>,
) -> Result<(CodexWsStream, WsResponse), WsError> {
ensure_websocket_key(&mut request)?;
let uri = request.uri().clone();
let mut stream = connect_stream(&uri).await?;
let request_bytes = opening_request_bytes(&request)?;
stream
.write_all(&request_bytes)
.await
.map_err(WsError::Io)?;
stream.flush().await.map_err(WsError::Io)?;
let (response, buffered) = read_opening_response(&mut stream).await?;
if response.status() != StatusCode::SWITCHING_PROTOCOLS {
return Err(WsError::Http(Box::new(response)));
}
verify_accept_key(&request, &response)?;
let websocket =
WebSocketStream::from_partially_read(stream, buffered, Role::Client, None).await;
Ok((websocket, response))
}
fn ensure_websocket_key(request: &mut WsRequest<()>) -> Result<(), WsError> {
if request.headers().contains_key("Sec-WebSocket-Key") {
return Ok(());
}
request
.headers_mut()
.insert("Sec-WebSocket-Key", HeaderValue::from_str(&generate_key())?);
Ok(())
}
async fn connect_stream(uri: &Uri) -> Result<MaybeTlsStream<TcpStream>, WsError> {
let host = uri.host().ok_or(UrlError::NoHostName)?;
if host.is_empty() {
return Err(UrlError::EmptyHostName.into());
}
let port = uri
.port_u16()
.or_else(|| match uri.scheme_str() {
Some("wss") => Some(443),
Some("ws") => Some(80),
_ => None,
})
.ok_or(UrlError::UnsupportedUrlScheme)?;
let socket = TcpStream::connect((host, port))
.await
.map_err(WsError::Io)?;
match uri.scheme_str() {
Some("ws") => Ok(MaybeTlsStream::Plain(socket)),
Some("wss") => {
let server_name = ServerName::try_from(host.to_string())
.map_err(|_| WsError::Tls(TlsError::InvalidDnsName))?;
let connector = TlsConnector::from(Arc::new(rustls_client_config()?));
let stream = connector
.connect(server_name, socket)
.await
.map_err(WsError::Io)?;
Ok(MaybeTlsStream::Rustls(stream))
}
_ => Err(UrlError::UnsupportedUrlScheme.into()),
}
}
fn rustls_client_config() -> Result<ClientConfig, WsError> {
let mut root_store = RootCertStore::empty();
let rustls_native_certs::CertificateResult { certs, errors, .. } =
rustls_native_certs::load_native_certs();
if !errors.is_empty() {
return Err(WsError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("failed to load native root certificates: {errors:?}"),
)));
}
let (added, _) = root_store.add_parsable_certificates(certs);
if added == 0 {
return Err(WsError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"no native root certificates found",
)));
}
Ok(ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth())
}
fn opening_request_bytes(request: &WsRequest<()>) -> Result<Vec<u8>, WsError> {
let path = request
.uri()
.path_and_query()
.ok_or(UrlError::NoPathOrQuery)?
.as_str();
let host = host_header(request.uri())?;
let key = request
.headers()
.get("Sec-WebSocket-Key")
.ok_or_else(|| ProtocolError::MissingSecWebSocketKey)?
.as_bytes();
let mut bytes = Vec::new();
bytes.extend_from_slice(format!("GET {path} HTTP/1.1\r\n").as_bytes());
write_header(&mut bytes, "Host", host.as_bytes());
write_header(&mut bytes, "Connection", b"Upgrade");
write_header(&mut bytes, "Upgrade", b"websocket");
write_header(&mut bytes, "Sec-WebSocket-Version", b"13");
write_header(
&mut bytes,
"Sec-WebSocket-Extensions",
ORIGINAL_WS_PERMESSAGE_DEFLATE_EXTENSION.as_bytes(),
);
write_header(&mut bytes, "Sec-WebSocket-Key", key);
write_original_business_headers(&mut bytes, request.headers());
bytes.extend_from_slice(b"\r\n");
Ok(bytes)
}
fn host_header(uri: &Uri) -> Result<String, WsError> {
let host = uri.host().ok_or(UrlError::NoHostName)?;
if host.is_empty() {
return Err(UrlError::EmptyHostName.into());
}
let Some(port) = uri.port_u16() else {
return Ok(host.to_string());
};
let is_default = matches!(
(uri.scheme_str(), port),
(Some("ws"), 80) | (Some("wss"), 443)
);
if is_default {
Ok(host.to_string())
} else {
Ok(format!("{host}:{port}"))
}
}
fn write_original_business_headers(bytes: &mut Vec<u8>, headers: &HeaderMap) {
let mut emitted = Vec::new();
for name in ORIGINAL_WS_HEADER_ORDER {
if write_named_header(bytes, headers, name) {
emitted.push(*name);
}
}
for (name, value) in headers {
let lower = name.as_str();
if emitted.iter().any(|seen| seen.eq_ignore_ascii_case(lower))
|| is_opening_header(lower)
|| lower == "content-type"
|| lower == "accept"
{
continue;
}
write_header(bytes, original_header_name(lower), value.as_bytes());
}
}
fn write_named_header(bytes: &mut Vec<u8>, headers: &HeaderMap, name: &str) -> bool {
let Some(value) = headers.get(name) else {
return false;
};
write_header(bytes, original_header_name(name), value.as_bytes());
true
}
fn write_header(bytes: &mut Vec<u8>, name: &str, value: &[u8]) {
bytes.extend_from_slice(name.as_bytes());
bytes.extend_from_slice(b": ");
bytes.extend_from_slice(value);
bytes.extend_from_slice(b"\r\n");
}
fn is_opening_header(name: &str) -> bool {
matches!(
name,
"host"
| "connection"
| "upgrade"
| "sec-websocket-version"
| "sec-websocket-extensions"
| "sec-websocket-key"
)
}
fn original_header_name(name: &str) -> &str {
match name {
"authorization" => "Authorization",
"chatgpt-account-id" => "ChatGPT-Account-Id",
"user-agent" => "User-Agent",
"accept-encoding" => "Accept-Encoding",
"accept-language" => "Accept-Language",
"openai-beta" => "OpenAI-Beta",
"cookie" => "Cookie",
"content-type" => "Content-Type",
"accept" => "Accept",
_ => name,
}
}
async fn read_opening_response(
stream: &mut MaybeTlsStream<TcpStream>,
) -> Result<(WsResponse, Vec<u8>), WsError> {
let mut bytes = Vec::new();
let mut chunk = [0_u8; 1024];
let header_end = loop {
let read = stream.read(&mut chunk).await.map_err(WsError::Io)?;
if read == 0 {
return Err(ProtocolError::HandshakeIncomplete.into());
}
bytes.extend_from_slice(&chunk[..read]);
if bytes.len() > MAX_OPENING_RESPONSE_BYTES {
return Err(CapacityError::MessageTooLong {
size: bytes.len(),
max_size: MAX_OPENING_RESPONSE_BYTES,
}
.into());
}
if let Some(index) = find_header_end(&bytes) {
break index;
}
};
let body_start = header_end + 4;
let mut body = bytes[body_start..].to_vec();
let mut response = parse_response_head(&bytes[..header_end])?;
if response.status() != StatusCode::SWITCHING_PROTOCOLS {
read_response_body(stream, response.headers(), &mut body).await?;
*response.body_mut() = Some(body);
return Ok((response, Vec::new()));
}
Ok((response, body))
}
fn find_header_end(bytes: &[u8]) -> Option<usize> {
bytes.windows(4).position(|window| window == b"\r\n\r\n")
}
fn parse_response_head(head: &[u8]) -> Result<WsResponse, WsError> {
let head = std::str::from_utf8(head)?;
let mut lines = head.split("\r\n");
let status_line = lines
.next()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "empty response"))?;
let status = status_line
.split_whitespace()
.nth(1)
.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidData, "missing response status")
})?
.parse::<u16>()
.map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
let mut response = WsResponse::new(None);
*response.status_mut() = StatusCode::from_u16(status)?;
for line in lines {
let Some((name, value)) = line.split_once(':') else {
continue;
};
response.headers_mut().append(
HeaderName::from_bytes(name.trim().as_bytes())?,
HeaderValue::from_bytes(value.trim_start().as_bytes())?,
);
}
Ok(response)
}
async fn read_response_body(
stream: &mut MaybeTlsStream<TcpStream>,
headers: &HeaderMap,
body: &mut Vec<u8>,
) -> Result<(), WsError> {
let Some(content_length) = headers
.get("content-length")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<usize>().ok())
else {
return Ok(());
};
while body.len() < content_length {
let remaining = content_length - body.len();
let mut chunk = vec![0_u8; remaining.min(1024)];
let read = stream.read(&mut chunk).await.map_err(WsError::Io)?;
if read == 0 {
break;
}
body.extend_from_slice(&chunk[..read]);
}
body.truncate(content_length);
Ok(())
}
fn verify_accept_key(request: &WsRequest<()>, response: &WsResponse) -> Result<(), WsError> {
let key = request
.headers()
.get("Sec-WebSocket-Key")
.ok_or(ProtocolError::MissingSecWebSocketKey)?;
let expected = derive_accept_key(key.as_bytes());
let actual = response
.headers()
.get("Sec-WebSocket-Accept")
.ok_or(ProtocolError::SecWebSocketAcceptKeyMismatch)?;
if actual == expected.as_str() {
Ok(())
} else {
Err(ProtocolError::SecWebSocketAcceptKeyMismatch.into())
}
}
+4 -1
View File
@@ -25,6 +25,7 @@ pub struct ModelService {
snapshot_repository: Option<ModelSnapshotRepository>,
account_repository: Option<AccountRepository>,
account_pool: Arc<Mutex<AccountPool>>,
fingerprint: Fingerprint,
}
#[derive(Debug, Clone, Copy)]
@@ -52,12 +53,14 @@ impl ModelService {
snapshot_repository: Option<ModelSnapshotRepository>,
account_repository: Option<AccountRepository>,
account_pool: Arc<Mutex<AccountPool>>,
fingerprint: Fingerprint,
) -> Self {
Self {
config,
snapshot_repository,
account_repository,
account_pool,
fingerprint,
}
}
@@ -105,7 +108,7 @@ impl ModelService {
CodexBackendClient::new(
client,
self.config.api.base_url.clone(),
Fingerprint::default_codex_desktop(),
self.fingerprint.clone(),
)
})
.map_err(|_| ModelServiceError::BuildClient)?;
+15 -4
View File
@@ -109,13 +109,14 @@ impl ChatService {
);
let mut excluded_account_ids = Vec::new();
let mut model_unsupported_retry_used = false;
let response = loop {
self.upstream
.stagger_request(acquired.previous_slot_at)
.await;
let response = self
.upstream
.send_codex_request_with_refresh_retry(
.send_codex_request_with_upstream_retries(
&codex_request,
&acquired.account,
request_id,
@@ -126,7 +127,12 @@ impl ChatService {
match response {
Ok(response) => break response,
Err(error) => {
if let Some(retry) = classify_upstream_account_retry(&error) {
if let Some(retry) =
classify_upstream_account_retry(&error, model_unsupported_retry_used)
{
if retry.is_model_unsupported() {
model_unsupported_retry_used = true;
}
let fallback = self
.upstream
.apply_retry_and_acquire_fallback(
@@ -203,6 +209,7 @@ impl ChatService {
&response.body,
&display_model,
include_reasoning,
codex_request.tuple_schema.as_ref(),
) {
Ok(Some(body)) => Response::builder()
.status(StatusCode::OK)
@@ -237,8 +244,12 @@ impl ChatService {
.into_response(),
}
} else {
match chat_completion_from_codex_sse(&response.body, &display_model, include_reasoning)
{
match chat_completion_from_codex_sse(
&response.body,
&display_model,
include_reasoning,
codex_request.tuple_schema.as_ref(),
) {
Ok(Some(body)) => (StatusCode::OK, Json(body)).into_response(),
Ok(None) => (
StatusCode::BAD_GATEWAY,
+15 -6
View File
@@ -16,6 +16,7 @@ use crate::{
#[derive(Clone)]
pub struct DiagnosticsService {
config: Arc<AppConfig>,
fingerprint: Fingerprint,
}
#[derive(Debug, Serialize)]
@@ -33,17 +34,25 @@ pub struct UpstreamProbeResult {
}
impl DiagnosticsService {
pub fn new(config: Arc<AppConfig>) -> Self {
Self { config }
pub fn new(config: Arc<AppConfig>, fingerprint: Fingerprint) -> Self {
Self {
config,
fingerprint,
}
}
pub(crate) fn fingerprint(&self) -> &Fingerprint {
&self.fingerprint
}
pub async fn probe_upstream(&self, request_id: &str) -> UpstreamProbeResult {
let started_at = Instant::now();
let fingerprint = Fingerprint::default_codex_desktop();
let client = match build_reqwest_client(self.config.tls.force_http11) {
Ok(client) => {
CodexBackendClient::new(client, self.config.api.base_url.clone(), fingerprint)
}
Ok(client) => CodexBackendClient::new(
client,
self.config.api.base_url.clone(),
self.fingerprint.clone(),
),
Err(error) => {
return self.unreachable_probe(
None,
@@ -1,146 +0,0 @@
use axum::http::StatusCode;
use secrecy::SecretString;
use serde_json::json;
use crate::{
codex::accounts::{
model::{Account, AccountStatus},
repository::TokenUpdate,
},
codex::events::event::{EventLevel, EventLog},
codex::gateway::oauth::{RefreshFailure, TokenPair},
codex::gateway::transport::types::CodexResponsesRequest,
};
use super::CodexUpstreamDependencies;
pub(super) async fn refresh_account_after_unauthorized(
deps: &CodexUpstreamDependencies,
request: &CodexResponsesRequest,
account: &Account,
request_id: &str,
) -> Option<Account> {
if !deps.config.auth.refresh_enabled {
return None;
}
let refresh_token = account.refresh_token.as_deref()?;
let refresher = deps.token_refresher.as_ref()?;
match refresher.refresh(refresh_token).await {
Ok(tokens) => persist_refreshed_account(deps, account, tokens).await,
Err(failure) => {
mark_refresh_failure(deps, account, failure, request_id, &request.model).await;
None
}
}
}
async fn persist_refreshed_account(
deps: &CodexUpstreamDependencies,
account: &Account,
tokens: TokenPair,
) -> Option<Account> {
let repo = deps.account_repository.as_ref()?;
let access_token = tokens.access_token;
let refresh_token = tokens.refresh_token;
repo.update_tokens(
&account.id,
TokenUpdate {
access_token: SecretString::new(access_token.clone().into()),
refresh_token: refresh_token
.clone()
.map(|token| SecretString::new(token.into())),
access_token_expires_at: None,
},
)
.await
.ok()?;
let mut refreshed = account.clone();
refreshed.access_token = access_token;
if let Some(refresh_token) = refresh_token {
refreshed.refresh_token = Some(refresh_token);
}
refreshed.status = AccountStatus::Active;
deps.account_pool.lock().await.insert(refreshed.clone());
deps.websocket_pool.evict_account(&account.id).await;
Some(refreshed)
}
async fn mark_refresh_failure(
deps: &CodexUpstreamDependencies,
account: &Account,
failure: RefreshFailure,
request_id: &str,
model: &str,
) {
let status = status_for_refresh_failure(failure);
if let Some(status) = status {
if let Some(repo) = deps.account_repository.as_ref() {
let _ = repo.set_status(&account.id, status).await;
}
let mut updated = account.clone();
updated.status = status;
deps.account_pool.lock().await.insert(updated);
deps.websocket_pool.evict_account(&account.id).await;
}
log_account_refresh_failure(deps, account, failure, status, request_id, model).await;
}
fn status_for_refresh_failure(failure: RefreshFailure) -> Option<AccountStatus> {
match failure {
RefreshFailure::InvalidGrant => Some(AccountStatus::Expired),
RefreshFailure::QuotaExhausted => Some(AccountStatus::QuotaExhausted),
RefreshFailure::Banned => Some(AccountStatus::Banned),
RefreshFailure::Disabled => Some(AccountStatus::Disabled),
RefreshFailure::Transport => None,
}
}
async fn log_account_refresh_failure(
deps: &CodexUpstreamDependencies,
account: &Account,
failure: RefreshFailure,
status: Option<AccountStatus>,
request_id: &str,
model: &str,
) {
let mut event = EventLog::new(
"account.refresh",
EventLevel::Warn,
"上游 401 后账户刷新失败",
);
event.request_id = Some(request_id.to_string());
event.account_id = Some(account.id.clone());
event.route = Some("/v1/responses".to_string());
event.model = Some(model.to_string());
event.status_code = Some(i64::from(StatusCode::UNAUTHORIZED.as_u16()));
event.metadata = json!({
"trigger": "upstream_401",
"failure": refresh_failure_value(failure),
"accountStatus": status.map(account_status_value),
});
if let Err(error) = deps.logs.record(event).await {
tracing::warn!(error = %error, "写入账户刷新事件日志失败");
}
}
fn refresh_failure_value(failure: RefreshFailure) -> &'static str {
match failure {
RefreshFailure::InvalidGrant => "invalidGrant",
RefreshFailure::QuotaExhausted => "quotaExhausted",
RefreshFailure::Banned => "banned",
RefreshFailure::Disabled => "disabled",
RefreshFailure::Transport => "transport",
}
}
fn account_status_value(status: AccountStatus) -> &'static str {
match status {
AccountStatus::Active => "active",
AccountStatus::Expired => "expired",
AccountStatus::QuotaExhausted => "quotaExhausted",
AccountStatus::Refreshing => "refreshing",
AccountStatus::Disabled => "disabled",
AccountStatus::Banned => "banned",
}
}
+126 -15
View File
@@ -3,12 +3,15 @@ use std::sync::Arc;
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::Serialize;
use sha2::{Digest, Sha256};
use sqlx::{Row, SqlitePool};
use thiserror::Error;
use tokio::sync::RwLock;
use tokio::time::interval;
use crate::codex::gateway::conversation_identity::derive_stable_conversation_key;
use crate::codex::gateway::transport::types::CodexResponsesRequest;
/// 会话亲和性条目 - 记录响应ID到账户的映射
#[derive(Debug, Clone)]
pub struct AffinityEntry {
@@ -117,10 +120,64 @@ from session_affinities
where expires_at > ?
order by created_at asc, response_id asc";
pub(super) fn compute_variant_hash(value: &impl Serialize) -> Option<String> {
serde_json::to_string(value)
.ok()
.filter(|serialized| !serialized.is_empty())
pub(super) fn compute_variant_hash(request: &CodexResponsesRequest) -> String {
compute_variant_hash_with_identity(request, request.variant_identity.as_deref())
}
fn compute_variant_hash_with_identity(
request: &CodexResponsesRequest,
identity: Option<&str>,
) -> String {
let tools_json = request
.tools
.as_ref()
.map_or_else(|| "[]".to_string(), |tools| tools_json(tools));
let mut hasher = Sha256::new();
hasher.update(request.instructions.as_bytes());
hasher.update(b"\0");
hasher.update(tools_json.as_bytes());
if let Some(identity) = identity
.map(str::trim)
.filter(|identity| !identity.is_empty())
{
hasher.update(b"\0");
hasher.update(identity.as_bytes());
}
hex::encode(hasher.finalize()).chars().take(12).collect()
}
fn tools_json(tools: &[serde_json::Value]) -> String {
serde_json::to_string(tools).unwrap_or_else(|_| "[]".to_string())
}
pub(super) fn prepare_variant_identity(request: &mut CodexResponsesRequest) {
request.variant_identity = build_variant_identity(request);
}
fn build_variant_identity(request: &CodexResponsesRequest) -> Option<String> {
let mut parts = Vec::with_capacity(2);
if let Some(window_id) = non_empty_string(request.codex_window_id.as_deref()) {
parts.push(format!("window:{window_id}"));
}
if request.explicit_prompt_cache_key
|| non_empty_string(request.client_conversation_id.as_deref()).is_some()
{
if let Some(anchor) = derive_stable_conversation_key(request) {
parts.push(format!("anchor:{anchor}"));
}
}
(!parts.is_empty()).then(|| parts.join("\0"))
}
fn non_empty_string(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
pub(super) fn hash_instructions(instructions: Option<&str>) -> String {
let mut hasher = Sha256::new();
hasher.update(instructions.unwrap_or_default().as_bytes());
hex::encode(hasher.finalize())
}
impl SessionAffinityRepository {
@@ -229,13 +286,7 @@ impl SessionAffinityMap {
function_call_ids: Option<Vec<String>>,
variant_hash: Option<String>,
) -> AffinityEntry {
let instructions_hash = instructions.map(|s| {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(s.as_bytes());
let result = hasher.finalize();
hex::encode(result)
});
let instructions_hash = instructions.map(|value| hash_instructions(Some(value)));
let entry = AffinityEntry {
account_id,
@@ -572,16 +623,76 @@ mod tests {
}
#[test]
fn compute_variant_hash_should_follow_serialized_request_body() {
fn compute_variant_hash_should_follow_original_shape_contract() {
let mut request = CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"You are Codex.",
vec![json!({"role": "user", "content": "hello"})],
);
let first = compute_variant_hash(&request).unwrap();
request.tools = Some(vec![json!({"type": "function", "name": "shell"})]);
let first = compute_variant_hash(&request);
request.input = vec![
json!({"role": "user", "content": "hello"}),
json!({"role": "assistant", "content": "cached"}),
json!({"role": "user", "content": "continue"}),
];
request.previous_response_id = Some("resp_dynamic".to_string());
request.prompt_cache_key = Some("conversation_dynamic".to_string());
request.include = Some(vec!["reasoning.encrypted_content".to_string()]);
let same_shape = compute_variant_hash(&request);
request.instructions = "You are Codex with different instructions.".to_string();
let second = compute_variant_hash(&request).unwrap();
let different_instructions = compute_variant_hash(&request);
assert_eq!(first, same_shape);
assert_ne!(first, different_instructions);
}
#[test]
fn compute_variant_hash_should_include_optional_identity() {
let request = CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"You are Codex.",
vec![json!({"role": "user", "content": "hello"})],
);
let first = compute_variant_hash_with_identity(&request, Some("window:main"));
let second = compute_variant_hash_with_identity(&request, Some("window:review"));
assert_ne!(first, second);
}
#[test]
fn compute_variant_hash_should_include_prepared_codex_window_identity() {
let mut first = CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"You are Codex.",
vec![json!({"role": "user", "content": "hello"})],
);
first.codex_window_id = Some("main-window".to_string());
prepare_variant_identity(&mut first);
let mut second = first.clone();
second.codex_window_id = Some("review-window".to_string());
prepare_variant_identity(&mut second);
assert_ne!(compute_variant_hash(&first), compute_variant_hash(&second));
}
#[test]
fn compute_variant_hash_should_include_explicit_prompt_cache_anchor() {
let mut first = CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"You are Codex.",
vec![json!({"role": "user", "content": "alpha"})],
);
first.prompt_cache_key = Some("shared-session".to_string());
first.explicit_prompt_cache_key = true;
prepare_variant_identity(&mut first);
let mut second = first.clone();
second.input = vec![json!({"role": "user", "content": "beta"})];
prepare_variant_identity(&mut second);
assert_ne!(compute_variant_hash(&first), compute_variant_hash(&second));
}
}
+247 -2
View File
@@ -18,24 +18,46 @@ const CLOUDFLARE_CHALLENGE_COOLDOWN_SECONDS: u64 = 10;
#[derive(Debug, Clone, Copy)]
pub(crate) enum UpstreamAccountRetry {
ModelUnsupported { status: StatusCode },
RateLimited { retry_after_seconds: u64 },
QuotaExhausted,
CloudflareChallenge { cooldown_seconds: u64 },
CloudflarePathBlock,
TokenInvalid { account_status: AccountStatus },
Banned,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum UpstreamRequestRecovery {
StripPreviousResponse { reason: HistoryRecoveryReason },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HistoryRecoveryReason {
PreviousResponseNotFound,
UnansweredFunctionCall,
}
impl UpstreamAccountRetry {
pub(crate) fn status(self) -> StatusCode {
match self {
Self::ModelUnsupported { status } => status,
Self::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS,
Self::QuotaExhausted => StatusCode::PAYMENT_REQUIRED,
Self::CloudflareChallenge { .. } => StatusCode::FORBIDDEN,
Self::CloudflarePathBlock => StatusCode::BAD_GATEWAY,
Self::TokenInvalid { .. } => StatusCode::UNAUTHORIZED,
Self::Banned => StatusCode::FORBIDDEN,
}
}
pub(crate) fn metadata(self, stream: bool) -> Value {
match self {
Self::ModelUnsupported { .. } => json!({
"stream": stream,
"retry": true,
"reason": "modelUnsupported",
}),
Self::RateLimited {
retry_after_seconds,
} => json!({
@@ -55,6 +77,17 @@ impl UpstreamAccountRetry {
"reason": "cloudflareChallenge",
"cooldownSeconds": cooldown_seconds,
}),
Self::CloudflarePathBlock => json!({
"stream": stream,
"retry": true,
"reason": "cloudflarePathBlock",
}),
Self::TokenInvalid { account_status } => json!({
"stream": stream,
"retry": true,
"reason": "tokenInvalid",
"accountStatus": account_status_metadata(account_status),
}),
Self::Banned => json!({
"stream": stream,
"retry": true,
@@ -62,12 +95,68 @@ impl UpstreamAccountRetry {
}),
}
}
pub(crate) fn is_model_unsupported(self) -> bool {
matches!(self, Self::ModelUnsupported { .. })
}
pub(crate) fn preserve_history_account_affinity(self) -> bool {
!self.is_model_unsupported()
}
}
impl UpstreamRequestRecovery {
pub(crate) fn metadata(self, stream: bool, stale_response_id: Option<&str>) -> Value {
match self {
Self::StripPreviousResponse { reason } => json!({
"stream": stream,
"retry": true,
"reason": reason.as_metadata_value(),
"previousResponseId": stale_response_id,
}),
}
}
}
impl HistoryRecoveryReason {
fn as_metadata_value(self) -> &'static str {
match self {
Self::PreviousResponseNotFound => "previousResponseNotFound",
Self::UnansweredFunctionCall => "unansweredFunctionCall",
}
}
}
pub(crate) fn classify_upstream_request_recovery(
error: &CodexClientError,
recovery_already_used: bool,
) -> Option<UpstreamRequestRecovery> {
if recovery_already_used {
return None;
}
if is_previous_response_not_found_error(error) {
return Some(UpstreamRequestRecovery::StripPreviousResponse {
reason: HistoryRecoveryReason::PreviousResponseNotFound,
});
}
if is_unanswered_function_call_error(error) {
return Some(UpstreamRequestRecovery::StripPreviousResponse {
reason: HistoryRecoveryReason::UnansweredFunctionCall,
});
}
None
}
pub(crate) fn classify_upstream_account_retry(
error: &CodexClientError,
model_unsupported_retry_used: bool,
) -> Option<UpstreamAccountRetry> {
match error {
CodexClientError::Upstream { status, .. }
if is_model_not_supported_error(error) && !model_unsupported_retry_used =>
{
Some(UpstreamAccountRetry::ModelUnsupported { status: *status })
}
CodexClientError::Upstream {
status,
retry_after_seconds,
@@ -89,12 +178,101 @@ pub(crate) fn classify_upstream_account_retry(
Some(UpstreamAccountRetry::Banned)
}
}
CodexClientError::Upstream { status, body, .. }
if *status == StatusCode::NOT_FOUND && body.trim().is_empty() =>
{
Some(UpstreamAccountRetry::CloudflarePathBlock)
}
CodexClientError::Upstream { status, body, .. } if *status == StatusCode::UNAUTHORIZED => {
Some(UpstreamAccountRetry::TokenInvalid {
account_status: account_status_for_unauthorized(body),
})
}
_ => None,
}
}
fn is_model_not_supported_error(error: &CodexClientError) -> bool {
let CodexClientError::Upstream { status, body, .. } = error else {
return false;
};
if !status.is_client_error() || *status == StatusCode::TOO_MANY_REQUESTS {
return false;
}
let haystack = format!(
"{} {} {}",
error_code(body).unwrap_or_default(),
error_message(body).unwrap_or_default(),
body
)
.to_ascii_lowercase();
haystack.contains("model")
&& (haystack.contains("not supported")
|| haystack.contains("not_supported")
|| haystack.contains("not available")
|| haystack.contains("not_available"))
}
fn account_status_for_unauthorized(body: &str) -> AccountStatus {
if body.to_ascii_lowercase().contains("deactivated") {
AccountStatus::Banned
} else {
AccountStatus::Expired
}
}
fn is_previous_response_not_found_error(error: &CodexClientError) -> bool {
let CodexClientError::Upstream { body, .. } = error else {
return false;
};
if error_code(body).as_deref() == Some("previous_response_not_found") {
return true;
}
let lower = body.to_ascii_lowercase();
lower.contains("previous_response_not_found")
|| (lower.contains("previous response with id") && lower.contains("not found"))
}
fn is_unanswered_function_call_error(error: &CodexClientError) -> bool {
let CodexClientError::Upstream { status, body, .. } = error else {
return false;
};
if *status != StatusCode::BAD_REQUEST {
return false;
}
body.to_ascii_lowercase()
.contains("no tool output found for function call")
}
fn error_code(body: &str) -> Option<String> {
let value = serde_json::from_str::<Value>(body).ok()?;
value
.pointer("/response/error/code")
.or_else(|| value.pointer("/response/error/type"))
.or_else(|| value.pointer("/error/code"))
.or_else(|| value.pointer("/error/type"))
.and_then(Value::as_str)
.map(|code| code.to_ascii_lowercase())
}
fn error_message(body: &str) -> Option<String> {
let value = serde_json::from_str::<Value>(body).ok()?;
value
.pointer("/response/error/message")
.or_else(|| value.pointer("/error/message"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}
pub(crate) fn websocket_history_retry_metadata(retry: UpstreamAccountRetry, stream: bool) -> Value {
match retry {
UpstreamAccountRetry::ModelUnsupported { .. } => json!({
"stream": stream,
"transport": "websocket",
"retry": false,
"reason": "modelUnsupported",
"accountAffinity": "previousResponseId",
}),
UpstreamAccountRetry::RateLimited {
retry_after_seconds,
} => json!({
@@ -120,6 +298,21 @@ pub(crate) fn websocket_history_retry_metadata(retry: UpstreamAccountRetry, stre
"cooldownSeconds": cooldown_seconds,
"accountAffinity": "previousResponseId",
}),
UpstreamAccountRetry::CloudflarePathBlock => json!({
"stream": stream,
"transport": "websocket",
"retry": false,
"reason": "cloudflarePathBlock",
"accountAffinity": "previousResponseId",
}),
UpstreamAccountRetry::TokenInvalid { account_status } => json!({
"stream": stream,
"transport": "websocket",
"retry": false,
"reason": "tokenInvalid",
"accountStatus": account_status_metadata(account_status),
"accountAffinity": "previousResponseId",
}),
UpstreamAccountRetry::Banned => json!({
"stream": stream,
"transport": "websocket",
@@ -160,7 +353,14 @@ pub(super) async fn apply_upstream_account_retry_with_deps(
account: &Account,
retry: UpstreamAccountRetry,
) {
match retry {
let evict_websocket_pool = match retry {
UpstreamAccountRetry::ModelUnsupported { .. } => {
tracing::warn!(
account_id = %account.id,
"上游账号不支持当前 model,将尝试备用账号"
);
false
}
UpstreamAccountRetry::RateLimited {
retry_after_seconds,
} => {
@@ -189,9 +389,11 @@ pub(super) async fn apply_upstream_account_retry_with_deps(
"记录被 rate limit 的账户请求尝试失败"
);
}
true
}
UpstreamAccountRetry::QuotaExhausted => {
set_account_status(deps, account, AccountStatus::QuotaExhausted).await;
true
}
UpstreamAccountRetry::CloudflareChallenge { cooldown_seconds } => {
let cooldown_until = Utc::now() + Duration::seconds(cooldown_seconds as i64);
@@ -221,12 +423,55 @@ pub(super) async fn apply_upstream_account_retry_with_deps(
.lock()
.await
.set_cloudflare_cooldown_until(&account.id, cooldown_until);
true
}
UpstreamAccountRetry::CloudflarePathBlock => {
if let Some(cookie_repo) = deps.cookie_repository.as_ref() {
if let Err(error) = cookie_repo.delete_account_cookies(&account.id).await {
tracing::warn!(
error = %error,
account_id = %account.id,
"清理 Cloudflare path-block 账户 cookies 失败"
);
}
}
let block_count = deps
.cf_path_block_tracker
.record_path_block(&account.id)
.await;
if deps.cf_path_block_tracker.should_disable(&account.id).await {
set_account_status(deps, account, AccountStatus::Disabled).await;
}
tracing::warn!(
account_id = %account.id,
block_count,
"上游返回 Cloudflare path-block 404,已清理 cookies"
);
true
}
UpstreamAccountRetry::TokenInvalid { account_status } => {
set_account_status(deps, account, account_status).await;
true
}
UpstreamAccountRetry::Banned => {
set_account_status(deps, account, AccountStatus::Banned).await;
true
}
};
if evict_websocket_pool {
deps.websocket_pool.evict_account(&account.id).await;
}
}
fn account_status_metadata(status: AccountStatus) -> &'static str {
match status {
AccountStatus::Active => "active",
AccountStatus::Expired => "expired",
AccountStatus::QuotaExhausted => "quota_exhausted",
AccountStatus::Refreshing => "refreshing",
AccountStatus::Disabled => "disabled",
AccountStatus::Banned => "banned",
}
deps.websocket_pool.evict_account(&account.id).await;
}
async fn set_account_status(
@@ -0,0 +1,100 @@
use std::collections::HashSet;
use serde_json::Value;
use crate::codex::gateway::transport::types::CodexResponsesRequest;
#[derive(Debug, Clone)]
pub(crate) struct ImplicitResumeSnapshot {
input: Vec<Value>,
previous_response_id: Option<String>,
turn_state: Option<String>,
use_websocket: bool,
force_http_sse: bool,
}
impl ImplicitResumeSnapshot {
pub(super) fn capture(request: &CodexResponsesRequest) -> Self {
Self {
input: request.input.clone(),
previous_response_id: request.previous_response_id.clone(),
turn_state: request.turn_state.clone(),
use_websocket: request.use_websocket,
force_http_sse: request.force_http_sse,
}
}
pub(crate) fn restore(self, request: &mut CodexResponsesRequest) {
request.input = self.input;
request.previous_response_id = self.previous_response_id;
request.turn_state = self.turn_state;
request.use_websocket = self.use_websocket;
request.force_http_sse = self.force_http_sse;
}
}
pub(super) fn continuation_input_start(input: &[Value]) -> usize {
let mut last_model_output_index = None;
for (index, item) in input.iter().enumerate() {
if item.get("role").is_some() {
if item.get("role").and_then(Value::as_str) == Some("assistant") {
last_model_output_index = Some(index);
}
continue;
}
if item.get("type").and_then(Value::as_str) == Some("function_call") {
last_model_output_index = Some(index);
}
}
last_model_output_index.map_or(0, |index| index.saturating_add(1))
}
pub(super) fn implicit_resume_allowed(
continuation_input: &[Value],
full_input: &[Value],
stored_function_call_ids: &[String],
) -> bool {
let required_call_ids = function_call_output_ids(continuation_input);
if required_call_ids.is_empty() {
return stored_function_call_ids.is_empty();
}
let inline_call_ids = inline_function_call_ids(full_input);
if required_call_ids
.iter()
.all(|call_id| inline_call_ids.contains(call_id))
{
return false;
}
let stored_call_ids = stored_function_call_ids.iter().collect::<HashSet<_>>();
let required_call_ids = required_call_ids.iter().collect::<HashSet<_>>();
required_call_ids
.iter()
.all(|call_id| stored_call_ids.contains(*call_id))
&& stored_call_ids
.iter()
.all(|call_id| required_call_ids.contains(*call_id))
}
fn function_call_output_ids(input: &[Value]) -> Vec<String> {
input
.iter()
.filter(|item| item.get("role").is_none())
.filter(|item| item.get("type").and_then(Value::as_str) == Some("function_call_output"))
.filter_map(|item| item.get("call_id").and_then(Value::as_str))
.filter(|call_id| !call_id.trim().is_empty())
.map(ToString::to_string)
.collect()
}
fn inline_function_call_ids(input: &[Value]) -> HashSet<String> {
input
.iter()
.filter(|item| item.get("role").is_none())
.filter(|item| item.get("type").and_then(Value::as_str) == Some("function_call"))
.filter_map(|item| item.get("call_id").and_then(Value::as_str))
.filter(|call_id| !call_id.trim().is_empty())
.map(ToString::to_string)
.collect()
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,288 @@
use std::{collections::HashMap, time::Duration};
use chrono::{DateTime, Utc};
use serde_json::{json, Map, Value};
use tokio::sync::RwLock;
const REASONING_REPLAY_TTL: Duration = Duration::from_secs(55 * 60);
const MAX_ENTRIES: usize = 512;
const MAX_ENTRY_BYTES: usize = 256 * 1024;
const MAX_TOTAL_BYTES: usize = 4 * 1024 * 1024;
#[derive(Debug, Clone)]
struct ReasoningReplayEntry {
account_id: String,
conversation_id: String,
variant_hash: String,
items: Vec<Value>,
byte_size: usize,
created_at: DateTime<Utc>,
sequence: u64,
}
#[derive(Debug, Default)]
struct ReasoningReplayState {
entries: HashMap<String, ReasoningReplayEntry>,
total_bytes: usize,
next_sequence: u64,
}
#[derive(Debug, Default)]
pub(super) struct ReasoningReplayCache {
state: RwLock<ReasoningReplayState>,
}
impl ReasoningReplayCache {
pub(super) async fn record(
&self,
response_id: &str,
account_id: &str,
conversation_id: &str,
variant_hash: &str,
items: &[Value],
) -> usize {
let sanitized = sanitize_reasoning_replay_items(items);
let mut state = self.state.write().await;
cleanup_expired(&mut state, Utc::now());
delete_entry(&mut state, response_id);
if sanitized.is_empty() {
return 0;
}
let byte_size = estimate_items_bytes(&sanitized);
if byte_size > MAX_ENTRY_BYTES {
return 0;
}
let sequence = state.next_sequence;
state.next_sequence = state.next_sequence.saturating_add(1);
state.total_bytes = state.total_bytes.saturating_add(byte_size);
state.entries.insert(
response_id.to_string(),
ReasoningReplayEntry {
account_id: account_id.to_string(),
conversation_id: conversation_id.to_string(),
variant_hash: variant_hash.to_string(),
items: sanitized,
byte_size,
created_at: Utc::now(),
sequence,
},
);
evict_overflow(&mut state);
state
.entries
.get(response_id)
.map_or(0, |entry| entry.items.len())
}
pub(super) async fn lookup(
&self,
response_id: &str,
account_id: &str,
conversation_id: &str,
variant_hash: &str,
) -> Vec<Value> {
let mut state = self.state.write().await;
cleanup_expired(&mut state, Utc::now());
let Some(entry) = state.entries.get(response_id) else {
return Vec::new();
};
if entry.account_id != account_id
|| entry.conversation_id != conversation_id
|| entry.variant_hash != variant_hash
{
return Vec::new();
}
entry.items.clone()
}
pub(super) async fn evict_by_identity(
&self,
account_id: &str,
conversation_id: &str,
variant_hash: &str,
) -> usize {
let mut state = self.state.write().await;
cleanup_expired(&mut state, Utc::now());
let response_ids = state
.entries
.iter()
.filter(|(_, entry)| {
entry.account_id == account_id
&& entry.conversation_id == conversation_id
&& entry.variant_hash == variant_hash
})
.map(|(response_id, _)| response_id.clone())
.collect::<Vec<_>>();
let evicted = response_ids.len();
for response_id in response_ids {
delete_entry(&mut state, &response_id);
}
evicted
}
}
fn sanitize_reasoning_replay_items(items: &[Value]) -> Vec<Value> {
let mut seen = std::collections::HashSet::new();
let mut sanitized = Vec::new();
for item in items {
let Some(replay_item) =
sanitize_reasoning_item(item).or_else(|| sanitize_function_call(item))
else {
continue;
};
let Some(key) = replay_item_key(&replay_item) else {
continue;
};
if !seen.insert(key) {
continue;
}
sanitized.push(replay_item);
}
sanitized
}
fn sanitize_reasoning_item(value: &Value) -> Option<Value> {
let object = value.as_object()?;
if object.get("type").and_then(Value::as_str) != Some("reasoning") {
return None;
}
let id = non_empty_str(object.get("id")?)?;
let summary = sanitize_summary(object.get("summary")?)?;
let encrypted_content = non_empty_str(object.get("encrypted_content")?)?;
let mut sanitized = Map::new();
sanitized.insert("type".to_string(), json!("reasoning"));
sanitized.insert("id".to_string(), json!(id));
if let Some(status) = object
.get("status")
.and_then(Value::as_str)
.filter(|status| matches!(*status, "in_progress" | "completed" | "incomplete"))
{
sanitized.insert("status".to_string(), json!(status));
}
sanitized.insert("summary".to_string(), Value::Array(summary));
sanitized.insert("encrypted_content".to_string(), json!(encrypted_content));
if let Some(content) = object.get("content").and_then(sanitize_content) {
sanitized.insert("content".to_string(), Value::Array(content));
}
Some(Value::Object(sanitized))
}
fn sanitize_function_call(value: &Value) -> Option<Value> {
let object = value.as_object()?;
if object.get("type").and_then(Value::as_str) != Some("function_call") {
return None;
}
let call_id = non_empty_str(object.get("call_id")?)?;
let name = non_empty_str(object.get("name")?)?;
let arguments = object.get("arguments")?.as_str()?;
let mut sanitized = Map::new();
sanitized.insert("type".to_string(), json!("function_call"));
if let Some(id) = object.get("id").and_then(non_empty_str) {
sanitized.insert("id".to_string(), json!(id));
}
sanitized.insert("call_id".to_string(), json!(call_id));
sanitized.insert("name".to_string(), json!(name));
sanitized.insert("arguments".to_string(), json!(arguments));
Some(Value::Object(sanitized))
}
fn sanitize_summary(value: &Value) -> Option<Vec<Value>> {
Some(
value
.as_array()?
.iter()
.filter_map(|part| {
let object = part.as_object()?;
if object.get("type").and_then(Value::as_str) != Some("summary_text") {
return None;
}
let text = object.get("text")?.as_str()?;
Some(json!({"type": "summary_text", "text": text}))
})
.collect(),
)
}
fn sanitize_content(value: &Value) -> Option<Vec<Value>> {
let content = value
.as_array()?
.iter()
.filter_map(|part| {
let object = part.as_object()?;
if object.get("type").and_then(Value::as_str) != Some("reasoning_text") {
return None;
}
let text = object.get("text")?.as_str()?;
Some(json!({"type": "reasoning_text", "text": text}))
})
.collect::<Vec<_>>();
(!content.is_empty()).then_some(content)
}
fn replay_item_key(item: &Value) -> Option<String> {
match item.get("type").and_then(Value::as_str)? {
"reasoning" => item
.get("id")
.and_then(Value::as_str)
.map(|id| format!("reasoning:{id}")),
"function_call" => item
.get("call_id")
.and_then(Value::as_str)
.map(|call_id| format!("function_call:{call_id}")),
_ => None,
}
}
fn non_empty_str(value: &Value) -> Option<&str> {
value.as_str().filter(|value| !value.trim().is_empty())
}
fn estimate_items_bytes(items: &[Value]) -> usize {
serde_json::to_vec(items).map_or(usize::MAX, |bytes| bytes.len())
}
fn cleanup_expired(state: &mut ReasoningReplayState, now: DateTime<Utc>) {
let response_ids = state
.entries
.iter()
.filter(|(_, entry)| entry_expired(entry.created_at, now))
.map(|(response_id, _)| response_id.clone())
.collect::<Vec<_>>();
for response_id in response_ids {
delete_entry(state, &response_id);
}
}
fn entry_expired(created_at: DateTime<Utc>, now: DateTime<Utc>) -> bool {
now.signed_duration_since(created_at)
.to_std()
.is_ok_and(|age| age > REASONING_REPLAY_TTL)
}
fn evict_overflow(state: &mut ReasoningReplayState) {
while state.entries.len() > MAX_ENTRIES || state.total_bytes > MAX_TOTAL_BYTES {
let Some(response_id) = oldest_response_id(state) else {
return;
};
delete_entry(state, &response_id);
}
}
fn oldest_response_id(state: &ReasoningReplayState) -> Option<String> {
state
.entries
.iter()
.min_by_key(|(_, entry)| (entry.created_at, entry.sequence))
.map(|(response_id, _)| response_id.clone())
}
fn delete_entry(state: &mut ReasoningReplayState, response_id: &str) {
let Some(entry) = state.entries.remove(response_id) else {
return;
};
state.total_bytes = state.total_bytes.saturating_sub(entry.byte_size);
}
+286 -9
View File
@@ -1,8 +1,16 @@
use std::collections::BTreeSet;
use axum::http::StatusCode;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::codex::gateway::transport::sse::parse_sse_events;
use crate::codex::gateway::{
protocol::tuple_schema::reconvert_tuple_values,
transport::{
http_client::CodexClientError,
sse::{encode_sse_event, parse_sse_events},
},
};
pub(crate) enum CollectedResponse {
Completed(Value),
@@ -15,6 +23,7 @@ pub(crate) enum CollectedResponse {
pub(crate) struct CompletedResponseMetadata {
pub response_id: String,
pub function_call_ids: Vec<String>,
pub replay_items: Vec<Value>,
}
#[derive(Debug, Clone)]
@@ -22,6 +31,7 @@ pub(crate) struct ResponsesSseFailure {
event: String,
message: String,
upstream_code: Option<String>,
invalid_reasoning_replay: bool,
}
impl ResponsesSseFailure {
@@ -30,11 +40,28 @@ impl ResponsesSseFailure {
event: event.to_string(),
message: failure_message(value).unwrap_or_else(|| "Codex upstream SSE failed".into()),
upstream_code: failure_code(value),
invalid_reasoning_replay: contains_invalid_encrypted_content_signal(value),
}
}
pub(crate) fn openai_error_message(&self) -> String {
format!("Codex upstream SSE failed: {}", self.message)
pub(crate) fn invalid_reasoning_replay(&self) -> bool {
self.invalid_reasoning_replay
}
pub(crate) fn upstream_error(&self) -> CodexClientError {
let code = self.upstream_code.as_deref().unwrap_or("error");
CodexClientError::Upstream {
status: status_for_failure_code(code),
retry_after_seconds: None,
body: json!({
"error": {
"type": code,
"code": code,
"message": self.message,
}
})
.to_string(),
}
}
pub(crate) fn metadata(&self, stream: bool) -> Value {
@@ -56,8 +83,34 @@ impl ResponsesSseFailure {
}
}
fn status_for_failure_code(code: &str) -> StatusCode {
let lower = code.to_ascii_lowercase();
if lower.contains("model")
&& (lower.contains("not_supported") || lower.contains("not_available"))
{
return StatusCode::BAD_REQUEST;
}
if lower.contains("invalid_request") || lower.contains("not_found") {
return StatusCode::BAD_REQUEST;
}
if lower.contains("rate_limit") || lower.contains("usage_limit") {
return StatusCode::TOO_MANY_REQUESTS;
}
if lower.contains("unauthorized") || lower.contains("invalid_api_key") {
return StatusCode::UNAUTHORIZED;
}
if lower.contains("forbidden") || lower.contains("banned") {
return StatusCode::FORBIDDEN;
}
if lower.contains("payment") || lower.contains("quota") {
return StatusCode::PAYMENT_REQUIRED;
}
StatusCode::BAD_GATEWAY
}
pub(crate) fn completed_response_json(
body: &str,
tuple_schema: Option<&Value>,
) -> Result<CollectedResponse, crate::codex::gateway::transport::sse::SseError> {
let events = parse_sse_events(body)?;
let mut output_text = String::new();
@@ -103,6 +156,7 @@ pub(crate) fn completed_response_json(
}
ensure_completed_response_output(&mut response, &output_items, &output_text);
reconvert_completed_response_tuple_values(&mut response, tuple_schema);
sync_output_text_from_output(&mut response);
Ok(CollectedResponse::Completed(response))
}
@@ -128,6 +182,116 @@ fn is_empty_response(response: &Value, output_text: &str, output_items: &[Value]
output_tokens == 0
}
pub(super) struct TupleStreamReconverter {
tuple_schema: Option<Value>,
pending: String,
tuple_text_buffer: String,
}
impl TupleStreamReconverter {
pub(super) fn new(tuple_schema: Option<Value>) -> Self {
Self {
tuple_schema,
pending: String::new(),
tuple_text_buffer: String::new(),
}
}
pub(super) fn transform_chunk(&mut self, chunk: &str) -> String {
if self.tuple_schema.is_none() {
return chunk.to_string();
}
self.pending.push_str(chunk);
let mut output = String::new();
while let Some(delimiter_index) = self.pending.find("\n\n") {
let block = self.pending[..delimiter_index].to_string();
self.pending.drain(..delimiter_index + 2);
output.push_str(&self.transform_block(&block));
}
output
}
pub(super) fn finish(&mut self) -> String {
if self.tuple_schema.is_none() || self.pending.trim().is_empty() {
self.pending.clear();
return String::new();
}
let block = std::mem::take(&mut self.pending);
self.transform_block(&block)
}
fn transform_block(&mut self, block: &str) -> String {
if block.trim().is_empty() {
return "\n\n".to_string();
}
let frame = format!("{block}\n\n");
let Ok(mut events) = parse_sse_events(&frame) else {
return frame;
};
if events.len() != 1 {
return frame;
}
let event = events.remove(0);
let Some(event_name) = event.event.as_deref() else {
return frame;
};
match event_name {
"response.output_text.delta" => self.transform_output_text_delta(&event.data, &frame),
"response.completed" => {
self.transform_response_completed(&event.data, event_name, &frame)
}
_ => frame,
}
}
fn transform_output_text_delta(&mut self, data: &str, original_frame: &str) -> String {
let Ok(value) = serde_json::from_str::<Value>(data) else {
return original_frame.to_string();
};
let Some(delta) = value.get("delta").and_then(Value::as_str) else {
return original_frame.to_string();
};
self.tuple_text_buffer.push_str(delta);
String::new()
}
fn transform_response_completed(
&mut self,
data: &str,
event_name: &str,
original_frame: &str,
) -> String {
let Some(tuple_schema) = self.tuple_schema.as_ref() else {
return original_frame.to_string();
};
let Ok(mut value) = serde_json::from_str::<Value>(data) else {
return original_frame.to_string();
};
let mut output = String::new();
if !self.tuple_text_buffer.is_empty() {
let reconverted_text = reconvert_tuple_text(&self.tuple_text_buffer, tuple_schema)
.unwrap_or_else(|| self.tuple_text_buffer.clone());
self.tuple_text_buffer.clear();
output.push_str(&encode_sse_event(
"response.output_text.delta",
&json!({
"type": "response.output_text.delta",
"delta": reconverted_text,
})
.to_string(),
));
}
if let Some(response) = value.get_mut("response") {
reconvert_completed_response_tuple_values(response, Some(tuple_schema));
sync_output_text_from_output(response);
}
output.push_str(&encode_sse_event(event_name, &value.to_string()));
output
}
}
pub(super) fn responses_sse_failure(
body: &str,
) -> Result<Option<ResponsesSseFailure>, crate::codex::gateway::transport::sse::SseError> {
@@ -149,6 +313,7 @@ pub(super) fn completed_response_metadata(
let events = parse_sse_events(body)?;
let mut response_id = None;
let mut function_call_ids = BTreeSet::new();
let mut replay_items = Vec::new();
for event in events {
let Ok(value) = serde_json::from_str::<Value>(&event.data) else {
@@ -156,6 +321,9 @@ pub(super) fn completed_response_metadata(
};
match event.event.as_deref() {
Some("response.output_item.done") => {
if let Some(item) = value.get("item") {
collect_response_replay_items(item, &mut replay_items);
}
if let Some(call_id) = value.pointer("/item/call_id").and_then(Value::as_str) {
function_call_ids.insert(call_id.to_string());
}
@@ -166,6 +334,11 @@ pub(super) fn completed_response_metadata(
.and_then(Value::as_str)
.map(ToString::to_string);
collect_response_function_call_ids(&value, &mut function_call_ids);
if let Some(output) = value.pointer("/response/output").and_then(Value::as_array) {
for item in output {
collect_response_replay_items(item, &mut replay_items);
}
}
}
_ => {}
}
@@ -174,6 +347,7 @@ pub(super) fn completed_response_metadata(
Ok(response_id.map(|response_id| CompletedResponseMetadata {
response_id,
function_call_ids: function_call_ids.into_iter().collect(),
replay_items,
}))
}
@@ -187,6 +361,47 @@ pub(super) fn ensure_stream_metadata(metadata: &mut Value, stream_value: bool) {
.or_insert_with(|| json!(stream_value));
}
pub(super) fn has_terminal_sse_event(
body: &str,
) -> Result<bool, crate::codex::gateway::transport::sse::SseError> {
parse_sse_events(body).map(|events| {
events.iter().any(|event| {
matches!(
event.event.as_deref(),
Some("response.completed" | "response.failed" | "error")
)
})
})
}
pub(super) fn premature_close_failed_event(detail: Option<&str>) -> String {
let message = match detail.filter(|value| !value.trim().is_empty()) {
Some(detail) => format!("Upstream stream closed before response.completed: {detail}"),
None => "Upstream stream closed before response.completed".to_string(),
};
let response_id_suffix: String = Uuid::new_v4()
.simple()
.to_string()
.chars()
.take(24)
.collect();
let error = json!({
"type": "server_error",
"code": "stream_disconnected",
"message": message,
});
let data = json!({
"type": "response.failed",
"response": {
"id": format!("resp_proxy_{response_id_suffix}"),
"status": "failed",
"error": error,
},
"error": error,
});
encode_sse_event("response.failed", &data.to_string())
}
fn collect_response_function_call_ids(value: &Value, function_call_ids: &mut BTreeSet<String>) {
let Some(output) = value.pointer("/response/output").and_then(Value::as_array) else {
return;
@@ -198,6 +413,15 @@ fn collect_response_function_call_ids(value: &Value, function_call_ids: &mut BTr
}
}
fn collect_response_replay_items(item: &Value, replay_items: &mut Vec<Value>) {
if matches!(
item.get("type").and_then(Value::as_str),
Some("reasoning" | "function_call")
) {
replay_items.push(item.clone());
}
}
fn failure_message(value: &Value) -> Option<String> {
value
.pointer("/response/error/message")
@@ -218,6 +442,33 @@ fn failure_code(value: &Value) -> Option<String> {
.map(ToString::to_string)
}
pub(super) fn contains_invalid_encrypted_content_signal(value: &Value) -> bool {
visit_invalid_encrypted_content(value, 0)
}
fn visit_invalid_encrypted_content(value: &Value, depth: usize) -> bool {
if depth > 5 {
return false;
}
match value {
Value::String(value) => string_contains_invalid_encrypted_content(value),
Value::Array(values) => values
.iter()
.any(|value| visit_invalid_encrypted_content(value, depth + 1)),
Value::Object(object) => object.iter().any(|(key, value)| {
string_contains_invalid_encrypted_content(key)
|| visit_invalid_encrypted_content(value, depth + 1)
}),
_ => false,
}
}
fn string_contains_invalid_encrypted_content(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
lower.contains("invalid_encrypted_content")
|| (lower.contains("invalid") && lower.contains("encrypted") && lower.contains("content"))
}
fn ensure_completed_response_output(
response: &mut Value,
output_items: &[Value],
@@ -251,14 +502,40 @@ fn ensure_completed_response_output(
}]);
}
fn sync_output_text_from_output(response: &mut Value) {
let output_text_is_empty = response
.get("output_text")
.and_then(Value::as_str)
.is_none_or(str::is_empty);
if !output_text_is_empty {
fn reconvert_completed_response_tuple_values(response: &mut Value, tuple_schema: Option<&Value>) {
let Some(tuple_schema) = tuple_schema else {
return;
};
let Some(items) = response.get_mut("output").and_then(Value::as_array_mut) else {
return;
};
for item in items {
let Some(content) = item.get_mut("content").and_then(Value::as_array_mut) else {
continue;
};
for part in content {
let part_type = part.get("type").and_then(Value::as_str);
if !matches!(part_type, Some("output_text" | "text")) {
continue;
}
let Some(text) = part.get("text").and_then(Value::as_str) else {
continue;
};
let Ok(parsed) = serde_json::from_str::<Value>(text) else {
continue;
};
let reconverted = reconvert_tuple_values(parsed, tuple_schema);
part["text"] = Value::String(reconverted.to_string());
}
}
}
fn reconvert_tuple_text(text: &str, tuple_schema: &Value) -> Option<String> {
let parsed = serde_json::from_str::<Value>(text).ok()?;
Some(reconvert_tuple_values(parsed, tuple_schema).to_string())
}
fn sync_output_text_from_output(response: &mut Value) {
let Some(items) = response.get("output").and_then(Value::as_array) else {
return;
};
+21 -32
View File
@@ -11,12 +11,13 @@ use crate::{
events::event::EventLevel,
gateway::transport::{
usage_events::extract_sse_usage,
websocket::{append_rate_limit_updates, CodexWebSocketError, SharedRateLimitUpdates},
websocket::{append_rate_limit_updates, SharedRateLimitUpdates},
},
},
};
use super::{
evict_reasoning_replay_with_deps,
limits::apply_rate_limit_headers_with_deps,
log_codex_upstream_response_with_deps, record_response_affinity_with_deps,
record_usage_with_deps,
@@ -29,6 +30,7 @@ pub(super) struct StreamAudit {
context: CodexRequestLogContext,
account_slot: AccountSlotGuard,
account_plan_type: Option<String>,
request: CodexResponsesRequest,
rate_limit_headers: Vec<(String, String)>,
}
@@ -38,6 +40,7 @@ impl StreamAudit {
context: CodexRequestLogContext,
account_id: String,
account_plan_type: Option<String>,
request: CodexResponsesRequest,
rate_limit_headers: Vec<(String, String)>,
) -> Self {
let account_slot = AccountSlotGuard::new(deps.account_pool.clone(), account_id);
@@ -46,6 +49,7 @@ impl StreamAudit {
context,
account_slot,
account_plan_type,
request,
rate_limit_headers,
}
}
@@ -91,6 +95,14 @@ impl StreamAudit {
status = StatusCode::BAD_GATEWAY;
level = EventLevel::Error;
message = "v1 responses stream 上游 SSE 失败";
if failure.invalid_reasoning_replay() {
evict_reasoning_replay_with_deps(
&self.deps,
&self.request,
&self.context.account_id,
)
.await;
}
failure.extend_metadata(&mut metadata);
}
Ok(None) => {}
@@ -112,19 +124,6 @@ impl StreamAudit {
.await;
self.account_slot.release().await;
}
pub(super) async fn log_transport_error(&mut self, error: &reqwest::Error) {
log_codex_upstream_response_with_deps(
&self.deps,
&self.context,
StatusCode::BAD_GATEWAY,
EventLevel::Error,
"v1 responses stream transport 失败",
json!({"stream": true, "transportError": error.to_string()}),
)
.await;
self.account_slot.release().await;
}
}
pub(super) struct WebSocketStreamAudit {
@@ -242,6 +241,14 @@ impl WebSocketStreamAudit {
status = StatusCode::BAD_GATEWAY;
level = EventLevel::Error;
message = "v1 responses WebSocket stream 上游 SSE 失败";
if failure.invalid_reasoning_replay() {
evict_reasoning_replay_with_deps(
&self.deps,
&self.request,
&self.context.account_id,
)
.await;
}
failure.extend_metadata(&mut metadata);
}
Ok(None) => {}
@@ -268,24 +275,6 @@ impl WebSocketStreamAudit {
.await;
self.account_slot.release().await;
}
pub(super) async fn log_transport_error(&mut self, error: &CodexWebSocketError) {
log_codex_upstream_response_with_deps(
&self.deps,
&self.context,
StatusCode::BAD_GATEWAY,
EventLevel::Error,
"v1 responses WebSocket stream transport 失败",
json!({
"stream": true,
"transport": "websocket",
"rateLimitHeaders": self.rate_limit_headers.clone(),
"transportError": error.to_string(),
}),
)
.await;
self.account_slot.release().await;
}
}
struct AccountSlotGuard {
+3 -3
View File
@@ -129,7 +129,7 @@ pub(crate) async fn diagnostics_data(state: &AppState) -> DiagnosticsData {
let config = state.services.settings.current().await;
let auth_status = state.services.admin_auth.status().await.ok();
let capacity = state.services.accounts.runtime_capacity_summary().await;
let fingerprint = Fingerprint::default_codex_desktop();
let fingerprint = state.services.diagnostics.fingerprint().clone();
DiagnosticsData {
status: "ok",
@@ -166,7 +166,7 @@ pub async fn debug_fingerprint(
}
// 从实际服务中获取指纹
let fingerprint = state.services.responses.upstream_fingerprint();
let fingerprint = state.services.diagnostics.fingerprint();
(
StatusCode::OK,
@@ -230,7 +230,7 @@ fn transport_diagnostics(config: &AppConfig, fingerprint: Fingerprint) -> Transp
fn fingerprint_diagnostics(fingerprint: Fingerprint) -> FingerprintDiagnostics {
let user_agent = fingerprint.user_agent();
FingerprintDiagnostics {
source: "staticDefault",
source: "runtime",
originator: fingerprint.originator,
app_version: fingerprint.app_version,
build_number: fingerprint.build_number,
+11
View File
@@ -15,6 +15,17 @@ pub(crate) fn codex_client_error_response(error: CodexClientError) -> (StatusCod
"websocket_required",
)),
),
CodexClientError::Upstream { status, body, .. }
if status == StatusCode::NOT_FOUND && body.trim().is_empty() =>
{
(
StatusCode::BAD_GATEWAY,
Json(openai_error(
"Upstream blocked the request (Cloudflare path-block)",
"upstream_error",
)),
)
}
CodexClientError::Upstream { status, body, .. } => (
status,
Json(openai_error(
+40 -1
View File
@@ -3,7 +3,7 @@ use std::time::Instant;
use axum::{
body::Bytes,
extract::State,
http::HeaderMap,
http::{HeaderMap, HeaderName, HeaderValue},
response::{IntoResponse, Response},
Extension,
};
@@ -12,11 +12,50 @@ use crate::{platform::http::request_id::RequestId, runtime::state::AppState};
use super::{auth::authorize_client_api_key, errors::missing_client_api_key_response};
const OPENAI_SUBAGENT_HEADER: HeaderName = HeaderName::from_static("x-openai-subagent");
pub async fn responses(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
body: Bytes,
) -> Response {
handle_responses(state, request_id, headers, body).await
}
pub async fn review_responses(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
mut headers: HeaderMap,
body: Bytes,
) -> Response {
headers.insert(OPENAI_SUBAGENT_HEADER, HeaderValue::from_static("review"));
handle_responses(state, request_id, headers, body).await
}
pub async fn compact_responses(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let started_at = Instant::now();
if !authorize_client_api_key(&state, &headers).await {
return missing_client_api_key_response().into_response();
}
state
.services
.responses
.handle_compact(request_id.as_str(), body, started_at)
.await
}
async fn handle_responses(
state: AppState,
request_id: RequestId,
headers: HeaderMap,
body: Bytes,
) -> Response {
let started_at = Instant::now();
if !authorize_client_api_key(&state, &headers).await {
+3 -1
View File
@@ -8,12 +8,14 @@ use crate::runtime::state::AppState;
use super::{
chat::chat_completions,
models::{debug_models, model_catalog, model_detail, model_info, models},
responses::responses,
responses::{compact_responses, responses, review_responses},
};
pub fn router() -> Router<AppState> {
Router::new()
.route("/v1/responses", post(responses))
.route("/v1/responses/review", post(review_responses))
.route("/v1/responses/compact", post(compact_responses))
.route("/v1/chat/completions", post(chat_completions))
.route("/v1/models", get(models))
.route("/v1/models/catalog", get(model_catalog))
+669 -80
View File
@@ -6,20 +6,21 @@ use axum::{
response::{IntoResponse, Response},
Json,
};
use serde::Deserialize;
use serde_json::{json, Map, Value};
use crate::{
codex::events::event::EventLevel,
codex::gateway::fingerprint::model::Fingerprint,
codex::gateway::protocol::codex_to_openai::openai_error,
codex::gateway::transport::types::CodexResponsesRequest,
codex::gateway::protocol::tuple_schema::prepare_schema,
codex::gateway::transport::types::{CodexCompactRequest, CodexResponsesRequest},
codex::models::service::ModelService,
codex::serving::dispatch::{
affinity::SessionAffinityRepositoryResult, classify_upstream_account_retry,
completed_response_json, no_available_accounts_response,
normalize_service_tier_for_upstream, websocket_history_retry_metadata,
CodexRequestLogContext, CodexUpstreamService, CollectedResponse,
classify_upstream_request_recovery, completed_response_json,
no_available_accounts_response, normalize_service_tier_for_upstream,
websocket_history_retry_metadata, CodexRequestLogContext, CodexUpstreamService,
CollectedResponse, ImplicitResumeSnapshot, UpstreamRequestRecovery,
},
codex::serving::http::errors::codex_client_error_response,
config::ModelConfig,
@@ -68,8 +69,10 @@ impl ResponsesService {
started_at: Instant,
) -> Response {
let default_model = self.model_config.default_model.clone();
let body = serde_json::from_slice::<ResponsesBody>(&body)
.unwrap_or_else(|_| default_body(default_model.clone()));
let body = match parse_responses_body(&body, default_model.clone()) {
Some(body) => body,
None => return invalid_responses_body_response(),
};
let client_stream = body.stream.unwrap_or(true);
let requested_model = body.model.clone().unwrap_or(default_model);
let catalog = self.models.catalog().await;
@@ -91,6 +94,11 @@ impl ResponsesService {
codex_request.tool_choice = body.tool_choice;
codex_request.parallel_tool_calls = body.parallel_tool_calls;
codex_request.text = body.text;
codex_request.tuple_schema = body.tuple_schema;
codex_request.explicit_prompt_cache_key = body
.prompt_cache_key
.as_deref()
.is_some_and(|value| !value.trim().is_empty());
codex_request.prompt_cache_key = body.prompt_cache_key;
codex_request.include = body.include;
let openai_subagent =
@@ -132,12 +140,13 @@ impl ResponsesService {
match body.use_websocket {
Some(true) => codex_request.use_websocket = true,
Some(false) => {
// 显式 false 是客户端兼容开关;previous_response_id 仍会在 transport 层强制 WebSocket。
// 显式传输控制不覆盖历史续链;previous_response_id 会在 transport 层强制 WebSocket。
codex_request.force_http_sse = true;
}
None => {}
}
self.upstream
let mut implicit_resume = self
.upstream
.prepare_response_session(&mut codex_request)
.await;
let Some(mut acquired) = self
@@ -158,21 +167,23 @@ impl ResponsesService {
if client_stream {
return self
.upstream
.responses_stream(codex_request, acquired, log_context)
.responses_stream(codex_request, acquired, log_context, implicit_resume)
.await;
}
let mut excluded_account_ids = Vec::new();
let mut empty_response_retries = 0;
let mut history_recovery_used = false;
let mut model_unsupported_retry_used = false;
const MAX_EMPTY_RETRIES: u8 = 2;
let response = loop {
let (response, collected_response) = loop {
self.upstream
.stagger_request(acquired.previous_slot_at)
.await;
let response = self
.upstream
.send_codex_request_with_refresh_retry(
.send_codex_request_with_upstream_retries(
&codex_request,
&acquired.account,
request_id,
@@ -182,45 +193,49 @@ impl ResponsesService {
match response {
Ok(response) => {
// 检测空响应
if let Ok(CollectedResponse::Empty) = completed_response_json(&response.body) {
if self
.upstream
.record_empty_response(&acquired.account.id)
.await
.is_err()
{
self.upstream
.log_response(
&log_context,
StatusCode::OK,
EventLevel::Warn,
"v1 responses 记录空响应计数失败",
json!({
"stream": false,
"emptyResponse": true,
"emptyResponseStoreError": true,
}),
)
.await;
}
empty_response_retries += 1;
if empty_response_retries <= MAX_EMPTY_RETRIES {
self.upstream
.log_response(
&log_context,
StatusCode::OK,
EventLevel::Warn,
"v1 responses 空响应,准备重试",
json!({
"stream": false,
"emptyResponse": true,
"retryAttempt": empty_response_retries,
}),
)
.await;
continue;
} else {
let collected_response = completed_response_json(
&response.body,
codex_request.tuple_schema.as_ref(),
);
match &collected_response {
Ok(CollectedResponse::Empty) => {
if self
.upstream
.record_empty_response(&acquired.account.id)
.await
.is_err()
{
self.upstream
.log_response(
&log_context,
StatusCode::OK,
EventLevel::Warn,
"v1 responses 记录空响应计数失败",
json!({
"stream": false,
"emptyResponse": true,
"emptyResponseStoreError": true,
}),
)
.await;
}
empty_response_retries += 1;
if empty_response_retries <= MAX_EMPTY_RETRIES {
self.upstream
.log_response(
&log_context,
StatusCode::OK,
EventLevel::Warn,
"v1 responses 空响应,准备重试",
json!({
"stream": false,
"emptyResponse": true,
"retryAttempt": empty_response_retries,
}),
)
.await;
continue;
}
self.upstream
.log_response(
&log_context,
@@ -243,12 +258,114 @@ impl ResponsesService {
)
.into_response();
}
Ok(CollectedResponse::Failed(failure)) => {
if failure.invalid_reasoning_replay() {
self.upstream
.evict_reasoning_replay(&codex_request, &acquired.account.id)
.await;
}
let error = failure.upstream_error();
if let Some(recovery) =
classify_upstream_request_recovery(&error, history_recovery_used)
{
self.apply_request_recovery(
&mut codex_request,
&mut history_recovery_used,
&mut implicit_resume,
&log_context,
recovery,
)
.await;
continue;
}
if let Some(retry) = classify_upstream_account_retry(
&error,
model_unsupported_retry_used,
) {
if retry.is_model_unsupported() {
model_unsupported_retry_used = true;
}
if codex_request.previous_response_id.is_some()
&& retry.preserve_history_account_affinity()
{
self.upstream
.apply_account_retry(&acquired.account, retry)
.await;
self.upstream
.log_response(
&log_context,
retry.status(),
EventLevel::Warn,
"v1 responses SSE history 失败保持原账户",
websocket_history_retry_metadata(retry, false),
)
.await;
} else {
let fallback = self
.upstream
.apply_retry_and_acquire_fallback(
&acquired.account,
retry,
&codex_request.model,
&mut excluded_account_ids,
)
.await;
self.upstream
.log_response(
&log_context,
retry.status(),
EventLevel::Warn,
"v1 responses 上游 SSE 失败将使用备用账户重试",
retry.metadata(false),
)
.await;
if let Some(fallback) = fallback {
log_context =
log_context.with_account(&fallback.account.id);
acquired = fallback;
continue;
}
}
}
let error_response = codex_client_error_response(error);
self.upstream
.log_response(
&log_context,
error_response.0,
EventLevel::Error,
"v1 responses 上游 SSE 失败",
failure.metadata(false),
)
.await;
return error_response.into_response();
}
_ => {}
}
break response;
break (response, collected_response);
}
Err(error) => {
if let Some(retry) = classify_upstream_account_retry(&error) {
if codex_request.previous_response_id.is_some() {
if let Some(recovery) =
classify_upstream_request_recovery(&error, history_recovery_used)
{
self.apply_request_recovery(
&mut codex_request,
&mut history_recovery_used,
&mut implicit_resume,
&log_context,
recovery,
)
.await;
continue;
}
if let Some(retry) =
classify_upstream_account_retry(&error, model_unsupported_retry_used)
{
if retry.is_model_unsupported() {
model_unsupported_retry_used = true;
}
if codex_request.previous_response_id.is_some()
&& retry.preserve_history_account_affinity()
{
// previous_response_id history is account-affine upstream.
self.upstream
.apply_account_retry(&acquired.account, retry)
@@ -353,7 +470,7 @@ impl ResponsesService {
}
}
match completed_response_json(&response.body) {
match collected_response {
Ok(CollectedResponse::Completed(body)) => {
self.upstream
.record_response_affinity(
@@ -415,23 +532,22 @@ impl ResponsesService {
.into_response()
}
Ok(CollectedResponse::Failed(failure)) => {
if failure.invalid_reasoning_replay() {
self.upstream
.evict_reasoning_replay(&codex_request, &acquired.account.id)
.await;
}
let error_response = codex_client_error_response(failure.upstream_error());
self.upstream
.log_response(
&log_context,
StatusCode::BAD_GATEWAY,
error_response.0,
EventLevel::Error,
"v1 responses 上游 SSE 失败",
failure.metadata(false),
)
.await;
(
StatusCode::BAD_GATEWAY,
Json(openai_error(
&failure.openai_error_message(),
"upstream_error",
)),
)
.into_response()
error_response.into_response()
}
Ok(CollectedResponse::MissingCompleted) => {
self.upstream
@@ -473,9 +589,183 @@ impl ResponsesService {
}
}
}
pub async fn handle_compact(
&self,
request_id: &str,
body: Bytes,
started_at: Instant,
) -> Response {
let default_model = self.model_config.default_model.clone();
let body = match parse_compact_body(&body, default_model) {
Some(body) => body,
None => return invalid_responses_body_response(),
};
let catalog = self.models.catalog().await;
if !catalog.is_recognized_model_name(&body.model) {
return (
StatusCode::NOT_FOUND,
Json(openai_error("Model not found", "model_not_found")),
)
.into_response();
}
let parsed_model = catalog.parse_model_name(&body.model);
let compact_request = CodexCompactRequest {
model: parsed_model.model_id,
input: body.input,
instructions: body.instructions,
tools: body.tools,
parallel_tool_calls: body.parallel_tool_calls,
reasoning: body.reasoning,
text: body.text,
};
let Some(mut acquired) = self.upstream.acquire_account(&compact_request.model).await else {
return no_available_accounts_response().into_response();
};
let mut log_context = CodexRequestLogContext::new(
request_id,
&acquired.account.id,
&compact_request.model,
false,
started_at,
);
let mut excluded_account_ids = Vec::new();
let mut model_unsupported_retry_used = false;
let mut attempts = 0_u8;
const MAX_COMPACT_RETRIES: u8 = 8;
loop {
attempts += 1;
self.upstream
.stagger_request(acquired.previous_slot_at)
.await;
let response = self
.upstream
.send_compact_request_with_upstream_retries(
&compact_request,
&acquired.account,
request_id,
)
.await;
self.upstream.release_account(&acquired.account.id).await;
match response {
Ok(response) => {
if self
.upstream
.persist_cookies(&acquired.account.id, &response.set_cookie_headers)
.await
.is_err()
{
self.upstream
.log_response(
&log_context,
StatusCode::INTERNAL_SERVER_ERROR,
EventLevel::Error,
"v1 responses compact 持久化 cookie 失败",
json!({"stream": false, "compact": true, "cookieStoreError": true}),
)
.await;
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(openai_error(
"Failed to persist upstream cookies",
"cookie_store_error",
)),
)
.into_response();
}
self.upstream
.log_response(
&log_context,
StatusCode::OK,
EventLevel::Info,
"v1 responses compact 已完成",
json!({"stream": false, "compact": true}),
)
.await;
return (StatusCode::OK, Json(response.body)).into_response();
}
Err(error) => {
if attempts < MAX_COMPACT_RETRIES {
if let Some(retry) =
classify_upstream_account_retry(&error, model_unsupported_retry_used)
{
if retry.is_model_unsupported() {
model_unsupported_retry_used = true;
}
let fallback = self
.upstream
.apply_retry_and_acquire_fallback(
&acquired.account,
retry,
&compact_request.model,
&mut excluded_account_ids,
)
.await;
self.upstream
.log_response(
&log_context,
retry.status(),
EventLevel::Warn,
"v1 responses compact 上游请求将使用备用账户重试",
retry.metadata(false),
)
.await;
if let Some(fallback) = fallback {
log_context = log_context.with_account(&fallback.account.id);
acquired = fallback;
continue;
}
}
}
let error_response = codex_client_error_response(error);
self.upstream
.log_response(
&log_context,
error_response.0,
EventLevel::Error,
"v1 responses compact 上游请求失败",
json!({"stream": false, "compact": true}),
)
.await;
return error_response.into_response();
}
}
}
}
async fn apply_request_recovery(
&self,
request: &mut CodexResponsesRequest,
history_recovery_used: &mut bool,
implicit_resume: &mut Option<ImplicitResumeSnapshot>,
log_context: &CodexRequestLogContext,
recovery: UpstreamRequestRecovery,
) {
*history_recovery_used = true;
let stale_response_id = request.previous_response_id.clone();
if let Some(response_id) = stale_response_id.as_deref() {
self.upstream.forget_response_affinity(response_id).await;
}
if let Some(snapshot) = implicit_resume.take() {
snapshot.restore(request);
}
request.previous_response_id = None;
request.turn_state = None;
self.upstream
.log_response(
log_context,
StatusCode::BAD_REQUEST,
EventLevel::Warn,
"v1 responses 上游历史失效,去除 previous_response_id 后重试",
recovery.metadata(false, stale_response_id.as_deref()),
)
.await;
}
}
#[derive(Deserialize)]
struct ResponsesBody {
model: Option<String>,
input: Option<Vec<Value>>,
@@ -486,26 +776,92 @@ struct ResponsesBody {
tool_choice: Option<Value>,
parallel_tool_calls: Option<bool>,
text: Option<Value>,
tuple_schema: Option<Value>,
prompt_cache_key: Option<String>,
include: Option<Vec<String>>,
client_metadata: Option<Value>,
previous_response_id: Option<String>,
stream: Option<bool>,
#[serde(rename = "turnState")]
turn_state: Option<String>,
#[serde(rename = "turnMetadata")]
turn_metadata: Option<String>,
#[serde(rename = "betaFeatures")]
beta_features: Option<String>,
#[serde(rename = "includeTimingMetrics")]
include_timing_metrics: Option<String>,
#[serde(rename = "codexWindowId")]
codex_window_id: Option<String>,
#[serde(rename = "parentThreadId")]
parent_thread_id: Option<String>,
use_websocket: Option<bool>,
}
struct CompactBody {
model: String,
input: Vec<Value>,
instructions: String,
reasoning: Option<Value>,
tools: Option<Vec<Value>>,
parallel_tool_calls: Option<bool>,
text: Option<Value>,
}
fn parse_responses_body(body: &[u8], default_model: String) -> Option<ResponsesBody> {
let Value::Object(mut body) = serde_json::from_slice::<Value>(body).ok()? else {
return None;
};
let mut parsed = default_body(default_model);
parsed.model = take_string(&mut body, "model").or(parsed.model);
parsed.input = take_array(&mut body, "input").map(sanitize_codex_input_items);
parsed.instructions = take_string(&mut body, "instructions").or(parsed.instructions);
parsed.reasoning = take_object_value(&mut body, "reasoning");
parsed.tools = take_non_empty_array(&mut body, "tools");
parsed.service_tier = take_string(&mut body, "service_tier");
parsed.tool_choice = body.remove("tool_choice");
parsed.parallel_tool_calls = take_bool(&mut body, "parallel_tool_calls");
let prepared_text = take_prepared_text_format(&mut body);
parsed.text = prepared_text.as_ref().map(|text| text.text.clone());
parsed.tuple_schema = prepared_text.and_then(|text| text.tuple_schema);
parsed.prompt_cache_key = take_string(&mut body, "prompt_cache_key");
parsed.include = take_string_array(&mut body, "include");
parsed.client_metadata = sanitize_client_metadata(body.remove("client_metadata"));
parsed.previous_response_id = take_string(&mut body, "previous_response_id");
parsed.stream = take_bool(&mut body, "stream");
parsed.turn_state = take_non_empty_string(&mut body, "turnState");
parsed.turn_metadata = take_non_empty_string(&mut body, "turnMetadata");
parsed.beta_features = take_non_empty_string(&mut body, "betaFeatures");
parsed.include_timing_metrics = take_non_empty_string(&mut body, "includeTimingMetrics");
parsed.codex_window_id = take_non_empty_string(&mut body, "codexWindowId");
parsed.parent_thread_id = take_non_empty_string(&mut body, "parentThreadId");
parsed.use_websocket = take_bool(&mut body, "use_websocket");
Some(parsed)
}
fn parse_compact_body(body: &[u8], default_model: String) -> Option<CompactBody> {
let Value::Object(mut body) = serde_json::from_slice::<Value>(body).ok()? else {
return None;
};
Some(CompactBody {
model: take_string(&mut body, "model").unwrap_or(default_model),
input: take_array(&mut body, "input")
.map(sanitize_codex_input_items)
.unwrap_or_default(),
instructions: take_string(&mut body, "instructions").unwrap_or_default(),
reasoning: sanitize_compact_reasoning(take_object_value(&mut body, "reasoning")),
tools: take_non_empty_array(&mut body, "tools"),
parallel_tool_calls: take_bool(&mut body, "parallel_tool_calls"),
text: take_text_format(&mut body),
})
}
fn invalid_responses_body_response() -> Response {
(
StatusCode::BAD_REQUEST,
Json(openai_error(
"Request body must be a JSON object",
"invalid_request",
)),
)
.into_response()
}
fn default_body(default_model: String) -> ResponsesBody {
ResponsesBody {
model: Some(default_model),
@@ -517,6 +873,7 @@ fn default_body(default_model: String) -> ResponsesBody {
tool_choice: None,
parallel_tool_calls: None,
text: None,
tuple_schema: None,
prompt_cache_key: None,
include: None,
client_metadata: None,
@@ -532,6 +889,236 @@ fn default_body(default_model: String) -> ResponsesBody {
}
}
fn take_string(body: &mut Map<String, Value>, key: &str) -> Option<String> {
body.remove(key).and_then(|value| match value {
Value::String(value) => Some(value),
_ => None,
})
}
fn take_non_empty_string(body: &mut Map<String, Value>, key: &str) -> Option<String> {
take_string(body, key).and_then(|value| {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
})
}
fn take_bool(body: &mut Map<String, Value>, key: &str) -> Option<bool> {
body.remove(key).and_then(|value| value.as_bool())
}
fn take_array(body: &mut Map<String, Value>, key: &str) -> Option<Vec<Value>> {
body.remove(key).and_then(|value| match value {
Value::Array(values) => Some(values),
_ => None,
})
}
fn take_non_empty_array(body: &mut Map<String, Value>, key: &str) -> Option<Vec<Value>> {
take_array(body, key).filter(|values| !values.is_empty())
}
fn take_object_value(body: &mut Map<String, Value>, key: &str) -> Option<Value> {
body.remove(key).and_then(|value| match value {
Value::Object(_) => Some(value),
_ => None,
})
}
fn take_string_array(body: &mut Map<String, Value>, key: &str) -> Option<Vec<String>> {
let values = take_array(body, key)?;
values
.into_iter()
.map(|value| match value {
Value::String(value) => Some(value),
_ => None,
})
.collect()
}
struct PreparedTextFormat {
text: Value,
tuple_schema: Option<Value>,
}
fn take_prepared_text_format(body: &mut Map<String, Value>) -> Option<PreparedTextFormat> {
take_text_format_inner(body, true)
}
fn take_text_format(body: &mut Map<String, Value>) -> Option<Value> {
take_text_format_inner(body, false).map(|prepared| prepared.text)
}
fn take_text_format_inner(
body: &mut Map<String, Value>,
prepare_tuple_schema: bool,
) -> Option<PreparedTextFormat> {
let Value::Object(text) = body.remove("text")? else {
return None;
};
let Value::Object(format) = text.get("format")? else {
return None;
};
let format_type = format.get("type")?.as_str()?;
let mut sanitized_format = Map::new();
sanitized_format.insert("type".to_string(), Value::String(format_type.to_string()));
let mut tuple_schema = None;
if let Some(name) = format.get("name").and_then(Value::as_str) {
sanitized_format.insert("name".to_string(), Value::String(name.to_string()));
}
if let Some(Value::Object(schema)) = format.get("schema") {
let schema = Value::Object(schema.clone());
let schema = if prepare_tuple_schema {
let prepared = prepare_schema(schema);
tuple_schema = prepared.original_schema;
prepared.schema
} else {
schema
};
sanitized_format.insert("schema".to_string(), schema);
}
if let Some(strict) = format.get("strict").and_then(Value::as_bool) {
sanitized_format.insert("strict".to_string(), Value::Bool(strict));
}
let mut sanitized_text = Map::new();
sanitized_text.insert("format".to_string(), Value::Object(sanitized_format));
Some(PreparedTextFormat {
text: Value::Object(sanitized_text),
tuple_schema,
})
}
fn sanitize_client_metadata(client_metadata: Option<Value>) -> Option<Value> {
let Value::Object(input) = client_metadata? else {
return None;
};
let metadata: Map<String, Value> = input
.into_iter()
.filter_map(|(key, value)| {
value
.as_str()
.map(|value| (key, Value::String(value.to_string())))
})
.collect();
(!metadata.is_empty()).then_some(Value::Object(metadata))
}
fn sanitize_compact_reasoning(reasoning: Option<Value>) -> Option<Value> {
let Value::Object(input) = reasoning? else {
return None;
};
let mut output = Map::new();
if let Some(effort) = input.get("effort").and_then(Value::as_str) {
output.insert("effort".to_string(), Value::String(effort.to_string()));
}
if let Some(summary) = input.get("summary").and_then(Value::as_str) {
output.insert("summary".to_string(), Value::String(summary.to_string()));
}
(!output.is_empty()).then_some(Value::Object(output))
}
fn sanitize_codex_input_items(input: Vec<Value>) -> Vec<Value> {
input
.into_iter()
.filter_map(|item| {
let Value::Object(object) = item else {
return Some(item);
};
match object.get("type").and_then(Value::as_str) {
Some("reasoning") => sanitize_reasoning_item(&object),
Some("compaction") => sanitize_compaction_item(&object),
_ => Some(Value::Object(object)),
}
})
.collect()
}
fn sanitize_reasoning_item(item: &Map<String, Value>) -> Option<Value> {
let id = non_empty_string(item.get("id"))?;
let summary = sanitize_summary(item.get("summary"))?;
let mut sanitized = Map::new();
sanitized.insert("type".to_string(), Value::String("reasoning".to_string()));
sanitized.insert("id".to_string(), Value::String(id.to_string()));
sanitized.insert("summary".to_string(), Value::Array(summary));
if let Some(status) = item
.get("status")
.and_then(Value::as_str)
.filter(|status| matches!(*status, "in_progress" | "completed" | "incomplete"))
{
sanitized.insert("status".to_string(), Value::String(status.to_string()));
}
if let Some(encrypted_content) = non_empty_string(item.get("encrypted_content")) {
sanitized.insert(
"encrypted_content".to_string(),
Value::String(encrypted_content.to_string()),
);
}
if let Some(content) = sanitize_reasoning_content(item.get("content")) {
sanitized.insert("content".to_string(), Value::Array(content));
}
Some(Value::Object(sanitized))
}
fn sanitize_summary(value: Option<&Value>) -> Option<Vec<Value>> {
let Value::Array(parts) = value? else {
return None;
};
let summary: Vec<Value> = parts
.iter()
.filter_map(|part| {
let Value::Object(part) = part else {
return None;
};
if part.get("type").and_then(Value::as_str) != Some("summary_text") {
return None;
}
let text = part.get("text").and_then(Value::as_str)?;
Some(json!({"type": "summary_text", "text": text}))
})
.collect();
Some(summary)
}
fn sanitize_reasoning_content(value: Option<&Value>) -> Option<Vec<Value>> {
let Value::Array(parts) = value? else {
return None;
};
let content: Vec<Value> = parts
.iter()
.filter_map(|part| {
let Value::Object(part) = part else {
return None;
};
if part.get("type").and_then(Value::as_str) != Some("reasoning_text") {
return None;
}
let text = part.get("text").and_then(Value::as_str)?;
Some(json!({"type": "reasoning_text", "text": text}))
})
.collect();
(!content.is_empty()).then_some(content)
}
fn sanitize_compaction_item(item: &Map<String, Value>) -> Option<Value> {
let encrypted_content = non_empty_string(item.get("encrypted_content"))?;
let mut sanitized = Map::new();
sanitized.insert("type".to_string(), Value::String("compaction".to_string()));
sanitized.insert(
"encrypted_content".to_string(),
Value::String(encrypted_content.to_string()),
);
if let Some(id) = non_empty_string(item.get("id")) {
sanitized.insert("id".to_string(), Value::String(id.to_string()));
}
Some(Value::Object(sanitized))
}
fn non_empty_string(value: Option<&Value>) -> Option<&str> {
let value = value?.as_str()?;
(!value.trim().is_empty()).then_some(value)
}
fn responses_reasoning(
client_reasoning: Option<Value>,
suffix_effort: Option<&str>,
@@ -562,14 +1149,14 @@ fn ensure_reasoning_include(request: &mut CodexResponsesRequest) {
if request.reasoning.is_none() {
return;
}
let include = request.include.get_or_insert_with(Vec::new);
if include
.iter()
.any(|item| item == "reasoning.encrypted_content")
if request
.include
.as_ref()
.is_some_and(|include| !include.is_empty())
{
return;
}
include.push("reasoning.encrypted_content".to_string());
request.include = Some(vec!["reasoning.encrypted_content".to_string()]);
}
fn header_string(headers: &HeaderMap, name: &str) -> Option<String> {
@@ -584,10 +1171,12 @@ fn client_metadata_with_openai_subagent(
client_metadata: Option<Value>,
openai_subagent: Option<String>,
) -> Option<Value> {
let mut metadata = match client_metadata {
Some(Value::Object(metadata)) => metadata,
_ => Map::new(),
};
let mut metadata = sanitize_client_metadata(client_metadata)
.and_then(|value| match value {
Value::Object(metadata) => Some(metadata),
_ => None,
})
.unwrap_or_default();
metadata.remove(OPENAI_SUBAGENT_HEADER);
if let Some(openai_subagent) = openai_subagent {
metadata.insert(
+253 -58
View File
@@ -8,7 +8,10 @@ use tokio::{
use tracing::{debug, error, info, warn};
use crate::{
codex::accounts::{model::AccountStatus, service::AccountService},
codex::accounts::{
model::AccountStatus,
service::{AccountProbeOutcome, AccountService},
},
config::AppConfig,
runtime::tasks::types::{SchedulerError, SchedulerHandle},
};
@@ -44,6 +47,7 @@ const BAN_ERRORS: &[&str] = &["account has been deactivated", "refresh_token_reu
// 表示刷新令牌无效但不一定是封禁的错误
const EXPIRED_ERRORS: &[&str] = &[
"invalid_grant",
"invalidgrant",
"invalid_token",
"access_denied",
"refresh_token_expired",
@@ -294,8 +298,8 @@ impl RefreshScheduler {
let mut permanent_hits = 0;
for attempt in 1..=MAX_ATTEMPTS {
match self.account_service.refresh_account(account_id).await {
Ok(_result) => {
let error = match self.account_service.probe_account_refresh(account_id).await {
Ok(result) if matches!(result.outcome, AccountProbeOutcome::Alive) => {
info!(account_id = %account_id, "token 刷新成功");
// 获取新令牌并重新调度下次刷新
if let Ok(accounts) = self
@@ -315,69 +319,90 @@ impl RefreshScheduler {
}
return Ok(());
}
Err(e) => {
let error_msg = format!("{}", e).to_lowercase();
Ok(result) if matches!(result.outcome, AccountProbeOutcome::Skipped) => {
info!(
account_id = %account_id,
reason = result.error.as_deref().unwrap_or("skipped"),
"token 刷新已跳过"
);
return Ok(());
}
Ok(result) => result
.error
.unwrap_or_else(|| "token refresh failed".to_string()),
Err(error) => error.to_string(),
};
// 检查是否是永久错误
let is_ban = BAN_ERRORS.iter().any(|&err| error_msg.contains(err));
let is_expired = EXPIRED_ERRORS.iter().any(|&err| error_msg.contains(err));
let is_permanent = is_ban || is_expired;
let error_msg = error.to_lowercase();
if is_permanent {
permanent_hits += 1;
if permanent_hits >= PERMANENT_THRESHOLD {
error!(
account_id = %account_id,
hits = permanent_hits,
"检测到永久刷新失败"
);
// refresh_account 已经标记了状态,这里不需要再次标记
return Err(SchedulerError::AccountNotFound(format!(
"Permanent failure: {}",
e
)));
}
warn!(
account_id = %account_id,
hits = permanent_hits,
threshold = PERMANENT_THRESHOLD,
"检测到永久错误,继续重试确认"
);
}
// 检查是否是永久错误
let is_ban = BAN_ERRORS.iter().any(|&err| error_msg.contains(err));
let is_expired = EXPIRED_ERRORS.iter().any(|&err| error_msg.contains(err));
let is_permanent = is_ban || is_expired;
if attempt < MAX_ATTEMPTS {
// 指数退避:5s, 15s, 45s, 135s, 300s(有上限)
let backoff = BASE_DELAY_MS.saturating_mul(3_u64.pow(attempt - 1));
let backoff = backoff.min(300_000);
let delay = Duration::from_millis(backoff);
warn!(
account_id = %account_id,
attempt,
max_attempts = MAX_ATTEMPTS,
delay_secs = delay.as_secs(),
error = %e,
"token 刷新尝试失败,准备重试"
);
tokio::time::sleep(delay).await;
} else {
if is_permanent {
permanent_hits += 1;
if permanent_hits >= PERMANENT_THRESHOLD {
error!(
account_id = %account_id,
hits = permanent_hits,
"检测到永久刷新失败"
);
let status = if is_ban { "banned" } else { "expired" };
if let Err(status_error) =
self.account_service.update_status(account_id, status).await
{
error!(
account_id = %account_id,
attempts = MAX_ATTEMPTS,
error = %e,
"token 刷新重试次数已耗尽"
status,
error = ?status_error,
"写入永久刷新失败状态失败"
);
self.schedule_recovery(
account_id.to_string(),
Duration::from_millis(RECOVERY_DELAY_MS),
)
.await;
return Err(SchedulerError::AccountNotFound(format!(
"Max attempts reached: {}",
e
)));
}
return Err(SchedulerError::AccountNotFound(format!(
"Permanent failure: {}",
error
)));
}
warn!(
account_id = %account_id,
hits = permanent_hits,
threshold = PERMANENT_THRESHOLD,
"检测到永久错误,继续重试确认"
);
}
if attempt < MAX_ATTEMPTS {
// 指数退避:5s, 15s, 45s, 135s, 300s(有上限)
let backoff = BASE_DELAY_MS.saturating_mul(3_u64.pow(attempt - 1));
let backoff = backoff.min(300_000);
let delay = Duration::from_millis(backoff);
warn!(
account_id = %account_id,
attempt,
max_attempts = MAX_ATTEMPTS,
delay_secs = delay.as_secs(),
error = %error,
"token 刷新尝试失败,准备重试"
);
tokio::time::sleep(delay).await;
} else {
error!(
account_id = %account_id,
attempts = MAX_ATTEMPTS,
error = %error,
"token 刷新重试次数已耗尽"
);
self.schedule_recovery(
account_id.to_string(),
Duration::from_millis(RECOVERY_DELAY_MS),
)
.await;
return Err(SchedulerError::AccountNotFound(format!(
"Max attempts reached: {}",
error
)));
}
}
@@ -463,6 +488,34 @@ fn jitter_delay_with_factor(delay: Duration, factor: f64) -> Duration {
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use secrecy::SecretString;
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
use tokio::sync::Mutex;
use crate::{
codex::{
accounts::{
pool::AccountPool,
repository::{AccountRepository, AccountUsageRepository, NewAccount},
service::{AccountService, AccountServiceDependencies},
},
gateway::{
fingerprint::model::Fingerprint,
oauth::{RefreshFailure, TokenPair, TokenRefresher},
transport::websocket::CodexWebSocketPool,
},
},
config::{
AdminConfig, ApiConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig,
QuotaConfig, QuotaWarningThresholds, SecurityConfig, ServerConfig, TlsConfig,
UsageStatsConfig,
},
platform::{crypto::SecretBox, storage::db::connect_sqlite},
};
#[test]
fn refresh_concurrency_limit_should_never_be_zero() {
@@ -480,4 +533,146 @@ mod tests {
Duration::from_secs(120)
);
}
#[tokio::test]
async fn do_refresh_inner_should_mark_expired_only_after_second_permanent_failure() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("refresh-scheduler.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
let repo = AccountRepository::new(pool.clone(), SecretBox::new([41u8; 32]));
repo.insert(NewAccount {
id: "acct_scheduler".to_string(),
email: None,
account_id: None,
user_id: None,
label: None,
plan_type: None,
access_token: SecretString::new("access-old".to_string().into()),
refresh_token: Some(SecretString::new("refresh-old".to_string().into())),
access_token_expires_at: None,
status: AccountStatus::Active,
})
.await
.unwrap();
let observed_statuses = Arc::new(Mutex::new(Vec::new()));
let refresher = AlwaysInvalidGrantRefresher {
repo: repo.clone(),
account_id: "acct_scheduler".to_string(),
calls: Arc::new(AtomicUsize::new(0)),
observed_statuses: observed_statuses.clone(),
};
let config = test_config(url);
let account_service = Arc::new(AccountService::new(
Arc::new(config.clone()),
AccountServiceDependencies {
repository: Some(repo.clone()),
usage_repository: Some(AccountUsageRepository::new(pool)),
cookie_repository: None,
token_refresher: Some(Arc::new(refresher.clone())),
account_pool: Arc::new(Mutex::new(AccountPool::default())),
websocket_pool: Arc::new(CodexWebSocketPool::with_default_max_age()),
fingerprint: Fingerprint::default_for_tests(),
},
));
let scheduler = RefreshScheduler::new(account_service, config);
let result = scheduler.do_refresh_inner("acct_scheduler").await;
assert!(result.is_err());
assert_eq!(refresher.calls.load(Ordering::SeqCst), 2);
assert_eq!(
observed_statuses.lock().await.as_slice(),
[AccountStatus::Active, AccountStatus::Active]
);
let stored = repo.get("acct_scheduler").await.unwrap().unwrap();
assert_eq!(stored.status, AccountStatus::Expired);
}
#[derive(Clone)]
struct AlwaysInvalidGrantRefresher {
repo: AccountRepository,
account_id: String,
calls: Arc<AtomicUsize>,
observed_statuses: Arc<Mutex<Vec<AccountStatus>>>,
}
#[async_trait]
impl TokenRefresher for AlwaysInvalidGrantRefresher {
async fn refresh(&self, _refresh_token: &str) -> Result<TokenPair, RefreshFailure> {
self.calls.fetch_add(1, Ordering::SeqCst);
let status = self
.repo
.get(&self.account_id)
.await
.unwrap()
.unwrap()
.status;
self.observed_statuses.lock().await.push(status);
Err(RefreshFailure::InvalidGrant)
}
}
fn test_config(database_url: String) -> AppConfig {
AppConfig {
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 0,
},
api: ApiConfig {
base_url: "https://chatgpt.com/backend-api".to_string(),
},
model: ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: None,
service_tier: None,
aliases: Default::default(),
},
auth: AuthConfig {
refresh_margin_seconds: 300,
refresh_enabled: true,
refresh_concurrency: 2,
max_concurrent_per_account: 3,
request_interval_ms: 50,
rotation_strategy: "least_used".to_string(),
tier_priority: Vec::new(),
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
oauth_auth_endpoint: "https://auth.openai.com/oauth/authorize".to_string(),
oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(),
},
quota: QuotaConfig {
refresh_interval_minutes: 5,
warning_thresholds: QuotaWarningThresholds {
primary: vec![80, 90],
secondary: vec![80, 90],
},
skip_exhausted: true,
},
usage_stats: UsageStatsConfig {
history_retention_days: None,
},
database: DatabaseConfig { url: database_url },
security: SecurityConfig {
master_key_file: "data/master.key".to_string(),
api_key_pepper_file: "data/api-key-pepper.key".to_string(),
},
tls: TlsConfig {
force_http11: false,
},
ws_pool: Default::default(),
admin: AdminConfig {
session_ttl_minutes: 1440,
default_username: "admin".to_string(),
default_password: "admin".to_string(),
session_cleanup_interval_secs: 3600,
},
logging: LoggingConfig {
directory: "logs".to_string(),
retention_days: 14,
enabled: false,
capacity: 2_000,
capture_body: false,
},
}
}
}
+22
View File
@@ -13,6 +13,7 @@ pub struct AppConfig {
pub database: DatabaseConfig,
pub security: SecurityConfig,
pub tls: TlsConfig,
pub ws_pool: WebSocketPoolConfig,
pub admin: AdminConfig,
pub logging: LoggingConfig,
}
@@ -47,6 +48,9 @@ pub struct AuthConfig {
pub request_interval_ms: u64,
pub rotation_strategy: String,
pub tier_priority: Vec<String>,
pub oauth_client_id: String,
pub oauth_auth_endpoint: String,
pub oauth_token_endpoint: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
@@ -83,6 +87,24 @@ pub struct TlsConfig {
pub force_http11: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WebSocketPoolConfig {
pub enabled: bool,
pub max_age_ms: u64,
pub max_per_account: usize,
}
impl Default for WebSocketPoolConfig {
fn default() -> Self {
Self {
enabled: true,
max_age_ms: 3_300_000,
max_per_account: 8,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct AdminConfig {
pub session_ttl_minutes: u64,
+4 -2
View File
@@ -4,7 +4,7 @@ use crate::{
admin::session::repository::AdminAuthRepository,
codex::accounts::repository::AccountRepositoryError,
codex::gateway::fingerprint::{model::Fingerprint, repository::FingerprintRepository},
codex::gateway::oauth::OpenAiOAuthRefresher,
codex::gateway::oauth::{OAuthConfig, OpenAiOAuthRefresher},
codex::serving::dispatch::affinity::SessionAffinityRepositoryError,
config::AppConfig,
platform::{
@@ -40,11 +40,13 @@ pub type BootstrapResult<T> = Result<T, BootstrapError>;
pub async fn build_state(config: AppConfig) -> BootstrapResult<(AppState, SqlitePool, usize)> {
let secret_box = SecretBox::load_or_create(&config.security.master_key_file)?;
let api_key_hasher = ApiKeyHasher::load_or_create(&config.security.api_key_pepper_file)?;
let oauth_client = OpenAiOAuthRefresher::codex_default(
let oauth_config = OAuthConfig::from_auth_config(&config.auth);
let oauth_client = OpenAiOAuthRefresher::new(
reqwest::Client::builder()
.use_rustls_tls()
.no_proxy()
.build()?,
oauth_config,
);
let pool = connect_sqlite(&config.database.url).await?;
+72 -48
View File
@@ -1,22 +1,25 @@
use std::{path::PathBuf, sync::Arc};
use std::{path::PathBuf, sync::Arc, time::Duration};
use sqlx::SqlitePool;
use tokio::sync::Mutex;
use crate::admin::{
client_keys::service::ApiKeyService,
session::{repository::AdminAuthRepository, service::AdminAuthService},
session::{
repository::AdminAuthRepository,
service::{AdminAuthDependencies, AdminAuthService},
},
settings::SettingsService,
};
use crate::codex::accounts::cookies::repository::CookieRepository;
use crate::codex::accounts::{
pool::{AccountPool, AccountPoolOptions, RotationStrategy},
repository::{AccountRepository, AccountRepositoryResult, AccountUsageRepository},
service::AccountService,
service::{AccountService, AccountServiceDependencies},
};
use crate::codex::gateway::fingerprint::model::Fingerprint;
use crate::codex::gateway::oauth::{OAuthClient, PkceSessionStore, TokenRefresher};
use crate::codex::gateway::transport::websocket::CodexWebSocketPool;
use crate::codex::gateway::oauth::{OAuthClient, OAuthConfig, PkceSessionStore, TokenRefresher};
use crate::codex::gateway::transport::websocket::{CodexWebSocketPool, CodexWebSocketPoolConfig};
use crate::codex::models::repository::ModelSnapshotRepository;
use crate::codex::models::service::ModelService;
use crate::codex::serving::dispatch::affinity::{
@@ -175,6 +178,25 @@ impl AppState {
)
}
pub fn with_pool_secret_api_key_hasher_and_fingerprint(
config: AppConfig,
pool: SqlitePool,
secret_box: SecretBox,
api_key_hasher: ApiKeyHasher,
fingerprint: Fingerprint,
) -> Self {
Self::from_dependencies(
config,
AppStateDependencies {
pool: Some(pool),
secret_box: Some(secret_box),
api_key_hasher: Some(api_key_hasher),
fingerprint: Some(fingerprint),
..AppStateDependencies::default()
},
)
}
pub fn with_pool_secret_api_key_hasher_oauth_client_and_fingerprint<C>(
config: AppConfig,
pool: SqlitePool,
@@ -246,48 +268,41 @@ impl AppState {
let pool_ref = pool.as_ref();
let secret_box_ref = secret_box.as_ref();
let account_pool = account_pool_from_config(&config);
let websocket_pool = Arc::new(CodexWebSocketPool::with_default_max_age());
let websocket_pool = websocket_pool_from_config(&config);
let oauth_sessions = Arc::new(Mutex::new(PkceSessionStore::default()));
let api_keys = api_key_service(pool_ref, api_key_hasher.as_ref());
let config = Arc::new(config);
let logs = log_service(&config, pool_ref);
let usage = usage_service(pool_ref);
let fingerprint = fingerprint.unwrap_or_else(Fingerprint::default_codex_desktop);
let settings = SettingsService::new(
config.clone(),
local_config_path.unwrap_or_else(|| PathBuf::from("local.yaml")),
);
let diagnostics = DiagnosticsService::new(config.clone());
let diagnostics = DiagnosticsService::new(config.clone(), fingerprint.clone());
let service_context = ServiceBuildContext {
config: config.clone(),
pool: pool_ref,
secret_box: secret_box_ref,
account_pool: account_pool.clone(),
websocket_pool: websocket_pool.clone(),
account_pool,
websocket_pool,
};
let accounts = account_service(
config.clone(),
account_repository(pool_ref, secret_box_ref),
pool_ref,
secret_box_ref,
&service_context,
token_refresher.clone(),
account_pool.clone(),
websocket_pool,
fingerprint.clone(),
);
let admin_auth = admin_auth_service(
config.clone(),
pool_ref,
secret_box_ref,
account_pool,
&service_context,
oauth_client,
oauth_sessions,
accounts.clone(),
);
let fingerprint = fingerprint.unwrap_or_else(Fingerprint::default_codex_desktop);
let V1Services {
chat,
responses,
models,
} = v1_services(service_context, logs.clone(), token_refresher, fingerprint);
} = v1_services(service_context, logs.clone(), fingerprint);
Self {
services: Arc::new(AppServices {
config,
@@ -321,6 +336,15 @@ fn account_pool_from_config(config: &AppConfig) -> Arc<Mutex<AccountPool>> {
})))
}
fn websocket_pool_from_config(config: &AppConfig) -> Arc<CodexWebSocketPool> {
Arc::new(CodexWebSocketPool::with_config(CodexWebSocketPoolConfig {
enabled: config.ws_pool.enabled,
max_age: Duration::from_millis(config.ws_pool.max_age_ms),
max_per_account: config.ws_pool.max_per_account,
..CodexWebSocketPoolConfig::default()
}))
}
struct V1Services {
chat: ChatService,
responses: ResponsesService,
@@ -339,15 +363,15 @@ struct ServiceBuildContext<'a> {
fn v1_services(
context: ServiceBuildContext<'_>,
logs: LogService,
token_refresher: Option<Arc<dyn TokenRefresher>>,
fingerprint: Fingerprint,
) -> V1Services {
let upstream = codex_upstream_service(&context, logs, token_refresher, fingerprint);
let upstream = codex_upstream_service(&context, logs, fingerprint.clone());
let models = model_service(
context.config.clone(),
context.pool,
context.secret_box,
context.account_pool,
fingerprint,
);
let model_config = context.config.model.clone();
V1Services {
@@ -362,59 +386,60 @@ fn model_service(
pool: Option<&SqlitePool>,
secret_box: Option<&SecretBox>,
account_pool: Arc<Mutex<AccountPool>>,
fingerprint: Fingerprint,
) -> ModelService {
ModelService::new(
config,
pool.cloned().map(ModelSnapshotRepository::new),
account_repository(pool, secret_box),
account_pool,
fingerprint,
)
}
fn admin_auth_service(
config: Arc<AppConfig>,
pool: Option<&SqlitePool>,
secret_box: Option<&SecretBox>,
account_pool: Arc<Mutex<AccountPool>>,
context: &ServiceBuildContext<'_>,
oauth_client: Option<Arc<dyn OAuthClient>>,
oauth_sessions: Arc<Mutex<PkceSessionStore>>,
accounts: AccountService,
) -> AdminAuthService {
let config = context.config.clone();
AdminAuthService::new(
config,
pool.cloned().map(AdminAuthRepository::new),
account_repository(pool, secret_box),
account_pool,
oauth_client,
oauth_sessions,
accounts,
AdminAuthDependencies {
auth_repository: context.pool.cloned().map(AdminAuthRepository::new),
account_repository: account_repository(context.pool, context.secret_box),
account_pool: context.account_pool.clone(),
oauth_config: OAuthConfig::from_auth_config(&context.config.auth),
oauth_client,
oauth_sessions,
accounts,
},
)
}
fn account_service(
config: Arc<AppConfig>,
repository: Option<AccountRepository>,
pool: Option<&SqlitePool>,
secret_box: Option<&SecretBox>,
context: &ServiceBuildContext<'_>,
token_refresher: Option<Arc<dyn TokenRefresher>>,
account_pool: Arc<Mutex<AccountPool>>,
websocket_pool: Arc<CodexWebSocketPool>,
fingerprint: Fingerprint,
) -> AccountService {
AccountService::new(
config,
repository,
pool.cloned().map(AccountUsageRepository::new),
cookie_repository(pool, secret_box),
token_refresher,
account_pool,
websocket_pool,
context.config.clone(),
AccountServiceDependencies {
repository: account_repository(context.pool, context.secret_box),
usage_repository: context.pool.cloned().map(AccountUsageRepository::new),
cookie_repository: cookie_repository(context.pool, context.secret_box),
token_refresher,
account_pool: context.account_pool.clone(),
websocket_pool: context.websocket_pool.clone(),
fingerprint,
},
)
}
fn codex_upstream_service(
context: &ServiceBuildContext<'_>,
logs: LogService,
token_refresher: Option<Arc<dyn TokenRefresher>>,
fingerprint: Fingerprint,
) -> CodexUpstreamService {
CodexUpstreamService::new(
@@ -426,7 +451,6 @@ fn codex_upstream_service(
session_affinity: context.pool.cloned().map(SessionAffinityRepository::new),
},
logs,
token_refresher,
fingerprint,
context.websocket_pool.clone(),
)
+85 -31
View File
@@ -1,5 +1,6 @@
use std::sync::Arc;
use async_trait::async_trait;
use axum::{
body::Body,
http::{Request, StatusCode},
@@ -12,11 +13,15 @@ use wiremock::{
Mock, MockServer, ResponseTemplate,
};
use codex_proxy_rs::codex::gateway::oauth::{RefreshFailure, TokenPair};
use codex_proxy_rs::codex::gateway::{
fingerprint::model::Fingerprint,
oauth::{RefreshFailure, TokenPair, TokenRefresher},
};
use crate::support::{
admin_accounts::{
admin_accounts_test_app, admin_accounts_test_app_with_base_url,
admin_accounts_test_app, admin_accounts_test_app_with_base_url_and_fingerprint,
admin_accounts_test_app_with_base_url_and_refresher,
admin_accounts_test_app_with_refresher, import_test_account, test_jwt,
FailingTokenRefresher, StaticTokenRefresher,
},
@@ -312,6 +317,9 @@ async fn admin_account_reset_usage_should_clear_local_counters_and_pool_last_use
#[tokio::test]
async fn admin_account_quota_should_fetch_usage_store_quota_and_not_return_secrets() {
let server = MockServer::start().await;
let fingerprint = quota_test_fingerprint();
let expected_user_agent = fingerprint.user_agent();
let expected_sec_ch_ua = fingerprint.sec_ch_ua();
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.and(header("authorization", "Bearer access-acct_quota"))
@@ -340,8 +348,13 @@ async fn admin_account_quota_should_fetch_usage_store_quota_and_not_return_secre
.expect(1)
.mount(&server)
.await;
let (app, _state, pool, _dir) =
admin_accounts_test_app_with_base_url("admin-account-quota.sqlite", 31, server.uri()).await;
let (app, _state, pool, _dir) = admin_accounts_test_app_with_base_url_and_fingerprint(
"admin-account-quota.sqlite",
31,
server.uri(),
fingerprint,
)
.await;
import_test_account(&app, "session_1", "acct_quota").await;
let response = app
@@ -375,6 +388,30 @@ async fn admin_account_quota_should_fetch_usage_store_quota_and_not_return_secre
.unwrap();
assert!(stored.0.contains("\"remaining_percent\":75"));
assert!(!stored.0.contains("access-acct_quota"));
let requests = server.received_requests().await.unwrap();
assert_eq!(
requests[0]
.headers
.get("user-agent")
.and_then(|value| value.to_str().ok()),
Some(expected_user_agent.as_str())
);
assert_eq!(
requests[0]
.headers
.get("sec-ch-ua")
.and_then(|value| value.to_str().ok()),
Some(expected_sec_ch_ua.as_str())
);
}
fn quota_test_fingerprint() -> Fingerprint {
Fingerprint {
app_version: "27.111.222".to_string(),
build_number: "9001".to_string(),
chromium_version: "155".to_string(),
..Fingerprint::default_for_tests()
}
}
#[tokio::test]
@@ -483,36 +520,24 @@ async fn admin_account_quota_warnings_should_return_threshold_matches_from_cache
}
#[tokio::test]
async fn admin_accounts_health_check_should_probe_backend_and_mark_invalid_accounts() {
async fn admin_accounts_health_check_should_refresh_oauth_without_touching_codex_backend() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.and(header("authorization", "Bearer access-acct_health_alive"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plan_type": "plus",
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": null,
"secondary_window": null
},
"code_review_rate_limit": null
})))
.expect(1)
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.and(header("authorization", "Bearer access-acct_health_dead"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"error": {"message": "invalid token"}
})))
.expect(1)
.mount(&server)
.await;
let (app, _state, pool, _dir) =
admin_accounts_test_app_with_base_url("admin-account-health.sqlite", 32, server.uri())
.await;
let calls = Arc::new(Mutex::new(Vec::new()));
let (app, _state, pool, _dir) = admin_accounts_test_app_with_base_url_and_refresher(
"admin-account-health.sqlite",
32,
server.uri(),
HealthCheckTokenRefresher {
calls: calls.clone(),
},
)
.await;
import_test_account(&app, "session_1", "acct_health_alive").await;
import_test_account(&app, "session_1", "acct_health_dead").await;
@@ -542,6 +567,15 @@ async fn admin_accounts_health_check_should_probe_backend_and_mark_invalid_accou
assert_eq!(body["data"]["summary"]["alive"], 1);
assert_eq!(body["data"]["summary"]["dead"], 1);
assert_eq!(body["data"]["summary"]["skipped"], 0);
let mut refresh_calls = calls.lock().await.clone();
refresh_calls.sort();
assert_eq!(
refresh_calls,
vec![
"refresh-acct_health_alive".to_string(),
"refresh-acct_health_dead".to_string(),
]
);
let serialized = serde_json::to_string(&body).unwrap();
assert!(!serialized.contains("access-acct_health_alive"));
assert!(!serialized.contains("access-acct_health_dead"));
@@ -554,9 +588,9 @@ async fn admin_accounts_health_check_should_probe_backend_and_mark_invalid_accou
}
#[tokio::test]
async fn admin_accounts_health_check_should_reject_removed_stagger_ms_field() {
async fn admin_accounts_health_check_should_reject_unsupported_stagger_ms_field() {
let (app, _state, _pool, _dir) =
admin_accounts_test_app("admin-account-health-removed-field.sqlite", 32).await;
admin_accounts_test_app("admin-account-health-unsupported-field.sqlite", 32).await;
let response = app
.oneshot(
@@ -575,3 +609,23 @@ async fn admin_accounts_health_check_should_reject_removed_stagger_ms_field() {
let body = response_json(response).await;
assert_eq!(body["code"], 40001);
}
#[derive(Clone)]
struct HealthCheckTokenRefresher {
calls: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl TokenRefresher for HealthCheckTokenRefresher {
async fn refresh(&self, refresh_token: &str) -> Result<TokenPair, RefreshFailure> {
self.calls.lock().await.push(refresh_token.to_string());
match refresh_token {
"refresh-acct_health_alive" => Ok(TokenPair {
access_token: "new-health-access".to_string(),
refresh_token: Some("new-health-refresh".to_string()),
}),
"refresh-acct_health_dead" => Err(RefreshFailure::InvalidGrant),
_ => Err(RefreshFailure::Transport),
}
}
}
+66 -39
View File
@@ -135,9 +135,9 @@ async fn admin_accounts_import_should_store_tokens_encrypted_and_list_sanitized_
}
#[tokio::test]
async fn admin_accounts_import_should_reject_sub2api_oauth_export() {
async fn admin_accounts_import_should_reject_non_native_export_shape() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-sub2api.sqlite");
let db = dir.path().join("admin-external-import.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
@@ -147,31 +147,20 @@ async fn admin_accounts_import_should_reject_sub2api_oauth_export() {
SecretBox::new([14u8; 32]),
));
let import_body = json!({
"type": "sub2api-data",
"type": "external-data",
"version": 1,
"proxies": [],
"legacy": [],
"accounts": [
{
"name": "Sub2API Team",
"platform": "openai",
"type": "oauth",
"credentials": {
"access_token": "Bearer sub2api-access-secret",
"refresh_token": "rt_sub2api",
"access_token": "Bearer external-access-secret",
"refresh_token": "rt_external",
"email": "team@example.com",
"chatgpt_account_id": "chatgpt-account",
"chatgpt_user_id": "chatgpt-user",
"plan_type": "team"
},
"concurrency": 0,
"priority": 0
},
{
"name": "Other Provider",
"platform": "anthropic",
"type": "oauth",
"credentials": {
"access_token": "ignored-secret"
}
}
]
@@ -184,7 +173,7 @@ async fn admin_accounts_import_should_reject_sub2api_oauth_export() {
.uri("/api/admin/accounts/import")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_sub2api")
.header("x-request-id", "req_external_import")
.body(Body::from(import_body.to_string()))
.unwrap(),
)
@@ -202,9 +191,9 @@ async fn admin_accounts_import_should_reject_sub2api_oauth_export() {
}
#[tokio::test]
async fn admin_accounts_import_should_reject_native_payload_with_removed_proxy_fields() {
async fn admin_accounts_import_should_reject_native_payload_with_unknown_account_fields() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-sub2api-native.sqlite");
let db = dir.path().join("admin-native-unknown-field.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
@@ -215,24 +204,16 @@ async fn admin_accounts_import_should_reject_native_payload_with_removed_proxy_f
));
let import_body = json!({
"accounts": [{
"id": "acct_sub2api_native",
"id": "acct_native_unknown",
"token": "native-access-secret",
"refreshToken": "native-refresh-secret",
"email": "native@example.com",
"accountId": "native-account",
"userId": "native-user",
"label": "Native Sub2API",
"label": "Native Unknown",
"planType": "plus",
"proxyApiKey": "ignored-proxy-secret",
"status": "active",
"usage": {
"request_count": 1,
"input_tokens": 0,
"output_tokens": 0,
"cached_tokens": 0
},
"cachedQuota": null,
"quotaVerifyRequired": false
"legacyField": "ignored-secret",
"status": "active"
}]
});
@@ -243,7 +224,53 @@ async fn admin_accounts_import_should_reject_native_payload_with_removed_proxy_f
.uri("/api/admin/accounts/import")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_sub2api_native")
.header("x-request-id", "req_native_unknown")
.body(Body::from(import_body.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(import_response.status(), StatusCode::BAD_REQUEST);
let body = response_json(import_response).await;
assert_eq!(body["message"], "No importable accounts found");
}
#[tokio::test]
async fn admin_accounts_import_should_reject_native_payload_with_unknown_container_fields() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-native-container-field.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let app = build_router(AppState::with_pool_and_secret_box(
test_config(url),
pool,
SecretBox::new([15u8; 32]),
));
let import_body = json!({
"accounts": [{
"id": "acct_native_extra_container",
"token": "native-access-secret",
"refreshToken": "native-refresh-secret",
"email": "native@example.com",
"accountId": "native-account",
"userId": "native-user",
"label": "Native",
"planType": "plus",
"status": "active"
}],
"legacyContainer": true
});
let import_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/accounts/import")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_native_container")
.body(Body::from(import_body.to_string()))
.unwrap(),
)
@@ -284,13 +311,13 @@ async fn admin_accounts_export_should_return_native_accounts_with_tokens_and_fil
body["data"]["accounts"][0]["refreshToken"],
"refresh-acct_export_a"
);
assert!(body["data"]["accounts"][0].get("proxyApiKey").is_none());
assert!(body["data"]["accounts"][0].get("legacyField").is_none());
let invalid = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/accounts/export?format=proxy")
.uri("/api/admin/accounts/export?format=external")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
@@ -301,15 +328,15 @@ async fn admin_accounts_export_should_return_native_accounts_with_tokens_and_fil
}
#[tokio::test]
async fn admin_accounts_export_should_reject_removed_sub2api_format() {
async fn admin_accounts_export_should_reject_unsupported_external_format() {
let (app, _state, _pool, _dir) =
admin_accounts_test_app("admin-account-export-sub2api.sqlite", 25).await;
admin_accounts_test_app("admin-account-export-external.sqlite", 25).await;
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/accounts/export?format=sub2api")
.uri("/api/admin/accounts/export?format=external")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
@@ -320,7 +347,7 @@ async fn admin_accounts_export_should_reject_removed_sub2api_format() {
}
#[tokio::test]
async fn admin_accounts_export_should_reject_removed_full_format_alias() {
async fn admin_accounts_export_should_reject_unsupported_full_format() {
let (app, _state, _pool, _dir) =
admin_accounts_test_app("admin-account-export-full-alias.sqlite", 25).await;
+63 -14
View File
@@ -17,8 +17,9 @@ use codex_proxy_rs::{
use crate::support::{
admin_accounts::{
admin_accounts_test_app, admin_accounts_test_app_with_oauth_client, import_test_account,
test_jwt, StaticOAuthClient,
admin_accounts_test_app, admin_accounts_test_app_with_config,
admin_accounts_test_app_with_oauth_client, import_test_account, test_config, test_jwt,
StaticOAuthClient,
},
response_json,
};
@@ -408,15 +409,46 @@ async fn admin_auth_login_start_should_return_pkce_auth_url_and_state() {
assert!(auth_url.starts_with("https://auth.openai.com/oauth/authorize?"));
assert!(auth_url.contains("response_type=code"));
assert!(auth_url.contains("client_id=app_EMoamEEZ73f0CkXaXp7hrann"));
assert!(
auth_url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fopenai%2Fcallback")
);
assert!(auth_url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback"));
assert!(auth_url.contains("scope=openid%20profile%20email%20offline_access"));
assert!(auth_url.contains("code_challenge_method=S256"));
assert!(auth_url.contains("originator=codex_cli_rs"));
assert!(auth_url.contains(&format!("state={state}")));
}
#[tokio::test]
async fn admin_auth_login_start_should_use_configured_oauth_authorize_endpoint() {
let dir = tempfile::tempdir().unwrap();
let db = dir
.path()
.join("admin-auth-login-start-configured-oauth.sqlite");
let mut config = test_config(format!("sqlite://{}", db.display()));
config.auth.oauth_client_id = "app_configured_client".to_string();
config.auth.oauth_auth_endpoint = "https://auth.example.test/oauth/authorize".to_string();
config.auth.oauth_token_endpoint = "https://auth.example.test/oauth/token".to_string();
let (app, _state, _pool, _dir) = admin_accounts_test_app_with_config(dir, config, 42).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/auth/login-start")
.header("cookie", "cpr_admin_session=session_1")
.header("host", "127.0.0.1:8080")
.header("x-request-id", "req_login_start_configured_oauth")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
let auth_url = body["data"]["authUrl"].as_str().unwrap();
assert!(auth_url.starts_with("https://auth.example.test/oauth/authorize?"));
assert!(auth_url.contains("client_id=app_configured_client"));
}
#[tokio::test]
async fn admin_auth_code_relay_should_exchange_code_and_import_account() {
let token = test_jwt(
@@ -455,7 +487,7 @@ async fn admin_auth_code_relay_should_exchange_code_and_import_account() {
let start_body = response_json(start).await;
let state_value = start_body["data"]["state"].as_str().unwrap();
let callback_url =
format!("http://localhost:1455/auth/openai/callback?code=oauth-code&state={state_value}");
format!("http://localhost:1455/auth/callback?code=oauth-code&state={state_value}");
let response = app
.oneshot(
@@ -482,10 +514,7 @@ async fn admin_auth_code_relay_should_exchange_code_and_import_account() {
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].code, "oauth-code");
assert!(!calls[0].code_verifier.is_empty());
assert_eq!(
calls[0].redirect_uri,
"http://localhost:1455/auth/openai/callback"
);
assert_eq!(calls[0].redirect_uri, "http://localhost:1455/auth/callback");
drop(calls);
let stored = AccountRepository::new(pool.clone(), SecretBox::new([43; 32]))
.find_by_chatgpt_identity("pkce-account", Some("pkce-user"))
@@ -580,7 +609,7 @@ async fn admin_auth_callback_should_exchange_code_and_redirect_to_return_host()
.unwrap();
let start_body = response_json(start).await;
let state_value = start_body["data"]["state"].as_str().unwrap();
let callback_path = format!("/auth/openai/callback?code=callback-code&state={state_value}");
let callback_path = format!("/auth/callback?code=callback-code&state={state_value}");
let response = app
.oneshot(
@@ -609,15 +638,35 @@ async fn admin_auth_callback_should_exchange_code_and_redirect_to_return_host()
}
#[tokio::test]
async fn admin_auth_callback_should_not_expose_removed_admin_callback_alias() {
async fn admin_auth_callback_should_not_expose_unsupported_admin_callback_path() {
let (app, _state, _pool, _dir) =
admin_accounts_test_app("admin-auth-callback-removed-alias.sqlite", 45).await;
admin_accounts_test_app("admin-auth-callback-unsupported-admin.sqlite", 45).await;
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/auth/callback?code=callback-code&state=removed")
.uri("/api/admin/auth/callback?code=callback-code&state=unsupported")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn admin_auth_callback_should_not_expose_unsupported_openai_callback_path() {
let (app, _state, _pool, _dir) =
admin_accounts_test_app("admin-auth-callback-unsupported-openai.sqlite", 46).await;
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/auth/openai/callback?code=callback-code&state=unsupported")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
+4
View File
@@ -45,6 +45,9 @@ fn test_config(database_url: String) -> AppConfig {
request_interval_ms: 50,
rotation_strategy: "least_used".to_string(),
tier_priority: Vec::new(),
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
oauth_auth_endpoint: "https://auth.openai.com/oauth/authorize".to_string(),
oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(),
},
quota: QuotaConfig {
refresh_interval_minutes: 5,
@@ -65,6 +68,7 @@ fn test_config(database_url: String) -> AppConfig {
tls: TlsConfig {
force_http11: false,
},
ws_pool: Default::default(),
admin: AdminConfig {
session_ttl_minutes: 1440,
default_username: "admin".to_string(),
+4
View File
@@ -46,6 +46,9 @@ fn test_config(database_url: String) -> AppConfig {
request_interval_ms: 50,
rotation_strategy: "least_used".to_string(),
tier_priority: Vec::new(),
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
oauth_auth_endpoint: "https://auth.openai.com/oauth/authorize".to_string(),
oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(),
},
quota: QuotaConfig {
refresh_interval_minutes: 5,
@@ -66,6 +69,7 @@ fn test_config(database_url: String) -> AppConfig {
tls: TlsConfig {
force_http11: false,
},
ws_pool: Default::default(),
admin: AdminConfig {
session_ttl_minutes: 1440,
default_username: "admin".to_string(),
+39 -1
View File
@@ -17,6 +17,7 @@ use codex_proxy_rs::{
model::{Account, AccountStatus},
repository::{AccountRepository, NewAccount},
},
codex::gateway::fingerprint::model::Fingerprint,
codex::models::repository::ModelSnapshotRepository,
config::{
AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig,
@@ -53,6 +54,9 @@ fn test_config(database_url: String, base_url: String) -> AppConfig {
request_interval_ms: 50,
rotation_strategy: "least_used".to_string(),
tier_priority: Vec::new(),
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
oauth_auth_endpoint: "https://auth.openai.com/oauth/authorize".to_string(),
oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(),
},
quota: QuotaConfig {
refresh_interval_minutes: 5,
@@ -73,6 +77,7 @@ fn test_config(database_url: String, base_url: String) -> AppConfig {
tls: TlsConfig {
force_http11: false,
},
ws_pool: Default::default(),
admin: AdminConfig {
session_ttl_minutes: 1440,
default_username: "admin".to_string(),
@@ -123,6 +128,9 @@ async fn admin_refresh_models_should_require_admin_session_cookie() {
#[tokio::test]
async fn admin_refresh_models_should_fetch_snapshots_for_distinct_account_plans() {
let server = MockServer::start().await;
let fingerprint = models_test_fingerprint();
let expected_user_agent = fingerprint.user_agent();
let expected_sec_ch_ua = fingerprint.sec_ch_ua();
Mock::given(method("GET"))
.and(path("/codex/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
@@ -157,11 +165,12 @@ async fn admin_refresh_models_should_fetch_snapshots_for_distinct_account_plans(
insert_account(&account_repo, "acct_plus_a", "plus", "access-plus-a").await;
insert_account(&account_repo, "acct_plus_b", "plus", "access-plus-b").await;
insert_account(&account_repo, "acct_team", "team", "access-team").await;
let state = AppState::with_pool_secret_and_api_key_hasher(
let state = AppState::with_pool_secret_api_key_hasher_and_fingerprint(
test_config(url, server.uri()),
pool.clone(),
secret_box,
hasher,
fingerprint,
);
state
.services
@@ -205,6 +214,26 @@ async fn admin_refresh_models_should_fetch_snapshots_for_distinct_account_plans(
.unwrap();
assert_eq!(snapshots.len(), 2);
assert_eq!(snapshots[0].models[0].id, "gpt-refresh");
let requests = server.received_requests().await.unwrap();
let model_requests = requests
.iter()
.filter(|request| request.url.path() == "/codex/models")
.collect::<Vec<_>>();
assert_eq!(model_requests.len(), 2);
assert!(model_requests.iter().all(|request| {
request
.headers
.get("user-agent")
.and_then(|value| value.to_str().ok())
== Some(expected_user_agent.as_str())
}));
assert!(model_requests.iter().all(|request| {
request
.headers
.get("sec-ch-ua")
.and_then(|value| value.to_str().ok())
== Some(expected_sec_ch_ua.as_str())
}));
let catalog_response = app
.oneshot(
@@ -230,6 +259,15 @@ async fn admin_refresh_models_should_fetch_snapshots_for_distinct_account_plans(
assert_ne!(acquired.plan_type.as_deref(), Some("free"));
}
fn models_test_fingerprint() -> Fingerprint {
Fingerprint {
app_version: "27.333.444".to_string(),
build_number: "9002".to_string(),
chromium_version: "156".to_string(),
..Fingerprint::default_for_tests()
}
}
async fn insert_account(
repo: &AccountRepository,
id: &'static str,
+4
View File
@@ -43,6 +43,9 @@ fn test_config(database_url: String) -> AppConfig {
request_interval_ms: 50,
rotation_strategy: "least_used".to_string(),
tier_priority: Vec::new(),
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
oauth_auth_endpoint: "https://auth.openai.com/oauth/authorize".to_string(),
oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(),
},
quota: QuotaConfig {
refresh_interval_minutes: 5,
@@ -63,6 +66,7 @@ fn test_config(database_url: String) -> AppConfig {
tls: TlsConfig {
force_http11: false,
},
ws_pool: Default::default(),
admin: AdminConfig {
session_ttl_minutes: 60,
default_username: "admin".to_string(),
+6 -19
View File
@@ -45,6 +45,9 @@ fn test_config(database_url: String) -> AppConfig {
request_interval_ms: 50,
rotation_strategy: "least_used".to_string(),
tier_priority: vec!["team".to_string(), "plus".to_string()],
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
oauth_auth_endpoint: "https://auth.openai.com/oauth/authorize".to_string(),
oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(),
},
quota: QuotaConfig {
refresh_interval_minutes: 5,
@@ -65,6 +68,7 @@ fn test_config(database_url: String) -> AppConfig {
tls: TlsConfig {
force_http11: false,
},
ws_pool: Default::default(),
admin: AdminConfig {
session_ttl_minutes: 1440,
default_username: "admin".to_string(),
@@ -208,10 +212,6 @@ async fn admin_settings_patch_should_persist_retained_fields_to_local_yaml() {
json!([75, 90])
);
let local_yaml = fs::read_to_string(dir.path().join("local.yaml")).unwrap();
assert!(!local_yaml.contains("proxyUrl"));
assert!(!local_yaml.contains("openaiApiKey"));
assert!(!local_yaml.contains("ollama"));
let reloaded = AppConfig::load_from_dir(dir.path()).unwrap();
assert_eq!(reloaded.model.default_model, "gpt-6");
assert_eq!(
@@ -240,7 +240,7 @@ async fn admin_settings_patch_should_persist_retained_fields_to_local_yaml() {
}
#[tokio::test]
async fn admin_settings_patch_should_reject_removed_or_invalid_fields() {
async fn admin_settings_patch_should_reject_unsupported_or_invalid_fields() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-settings-patch-invalid.sqlite");
let url = format!("sqlite://{}", db.display());
@@ -264,7 +264,7 @@ async fn admin_settings_patch_should_reject_removed_or_invalid_fields() {
.header("x-request-id", "req_settings_patch_invalid")
.body(Body::from(
json!({
"proxyUrl": "http://127.0.0.1:8080",
"unknownSetting": true,
"rotationStrategy": "random"
})
.to_string(),
@@ -333,17 +333,4 @@ async fn admin_settings_should_return_only_in_scope_runtime_fields() {
"usageHistoryRetentionDays": 30
})
);
for removed_key in [
"proxyUrl",
"openaiApiKey",
"ollama",
"provider",
"proxyApiKey",
"autoUpdate",
] {
assert!(
body["data"].get(removed_key).is_none(),
"{removed_key} must stay out of the Rust settings API"
);
}
}
+4
View File
@@ -43,6 +43,9 @@ fn test_config(database_url: String) -> AppConfig {
request_interval_ms: 50,
rotation_strategy: "least_used".to_string(),
tier_priority: Vec::new(),
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
oauth_auth_endpoint: "https://auth.openai.com/oauth/authorize".to_string(),
oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(),
},
quota: QuotaConfig {
refresh_interval_minutes: 5,
@@ -63,6 +66,7 @@ fn test_config(database_url: String) -> AppConfig {
tls: TlsConfig {
force_http11: false,
},
ws_pool: Default::default(),
admin: AdminConfig {
session_ttl_minutes: 1440,
default_username: "admin".to_string(),
+66 -7
View File
@@ -16,9 +16,10 @@ use codex_proxy_rs::{
model::AccountStatus,
pool::AccountPool,
repository::{AccountRepository, AccountUsageRepository, NewAccount, TokenUpdate},
service::AccountService,
service::{AccountService, AccountServiceDependencies},
},
gateway::{
fingerprint::model::Fingerprint,
oauth::{RefreshFailure, TokenPair, TokenRefresher},
transport::websocket::CodexWebSocketPool,
},
@@ -57,12 +58,15 @@ async fn account_service_refresh_should_retry_with_latest_disk_refresh_token_aft
};
let service = AccountService::new(
Arc::new(test_config(url)),
Some(repo),
Some(AccountUsageRepository::new(pool)),
None,
Some(Arc::new(refresher)),
Arc::new(Mutex::new(AccountPool::default())),
Arc::new(CodexWebSocketPool::with_default_max_age()),
AccountServiceDependencies {
repository: Some(repo),
usage_repository: Some(AccountUsageRepository::new(pool)),
cookie_repository: None,
token_refresher: Some(Arc::new(refresher)),
account_pool: Arc::new(Mutex::new(AccountPool::default())),
websocket_pool: Arc::new(CodexWebSocketPool::with_default_max_age()),
fingerprint: Fingerprint::default_for_tests(),
},
);
let result = service.refresh_account("acct_stale").await.unwrap();
@@ -74,6 +78,51 @@ async fn account_service_refresh_should_retry_with_latest_disk_refresh_token_aft
);
}
#[tokio::test]
async fn account_service_refresh_probe_should_not_persist_permanent_status_on_first_failure() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("accounts_scheduler.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
let repo = AccountRepository::new(pool.clone(), SecretBox::new([20u8; 32]));
repo.insert(NewAccount {
id: "acct_scheduler".to_string(),
email: None,
account_id: None,
user_id: None,
label: None,
plan_type: None,
access_token: SecretString::new("access-old".to_string().into()),
refresh_token: Some(SecretString::new("refresh-old".to_string().into())),
access_token_expires_at: None,
status: AccountStatus::Active,
})
.await
.unwrap();
let service = AccountService::new(
Arc::new(test_config(url)),
AccountServiceDependencies {
repository: Some(repo.clone()),
usage_repository: Some(AccountUsageRepository::new(pool)),
cookie_repository: None,
token_refresher: Some(Arc::new(AlwaysFailingRefresher)),
account_pool: Arc::new(Mutex::new(AccountPool::default())),
websocket_pool: Arc::new(CodexWebSocketPool::with_default_max_age()),
fingerprint: Fingerprint::default_for_tests(),
},
);
let result = service
.probe_account_refresh("acct_scheduler")
.await
.unwrap();
assert_eq!(result.outcome.as_str(), "dead");
assert!(result.status.is_none());
let stored = repo.get("acct_scheduler").await.unwrap().unwrap();
assert_eq!(stored.status, AccountStatus::Active);
}
#[derive(Clone)]
struct DiskRotatingRefresher {
repo: AccountRepository,
@@ -81,6 +130,16 @@ struct DiskRotatingRefresher {
first_call_seen: Arc<AtomicUsize>,
}
#[derive(Clone)]
struct AlwaysFailingRefresher;
#[async_trait]
impl TokenRefresher for AlwaysFailingRefresher {
async fn refresh(&self, _refresh_token: &str) -> Result<TokenPair, RefreshFailure> {
Err(RefreshFailure::InvalidGrant)
}
}
#[async_trait]
impl TokenRefresher for DiskRotatingRefresher {
async fn refresh(&self, refresh_token: &str) -> Result<TokenPair, RefreshFailure> {
+44 -20
View File
@@ -31,6 +31,35 @@ fn codex_headers_include_desktop_identity_and_turn_state() {
assert_eq!(headers.get("x-client-request-id").unwrap(), "rid_1");
}
#[test]
fn fingerprint_default_header_order_should_match_original_config() {
assert_eq!(
Fingerprint::default_header_order(),
vec![
"authorization",
"chatgpt-account-id",
"originator",
"x-openai-internal-codex-residency",
"x-client-request-id",
"x-codex-installation-id",
"x-codex-turn-state",
"openai-beta",
"user-agent",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"accept-encoding",
"accept-language",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-dest",
"content-type",
"accept",
"cookie",
]
);
}
#[test]
fn update_manifest_updates_app_version_and_build_number() {
let manifest = r#"{"version":"26.600.12345","build_number":"4001"}"#;
@@ -52,7 +81,7 @@ async fn database_fingerprint_headers_should_match_expected_format() {
platform: "darwin".to_string(),
arch: "arm64".to_string(),
chromium_version: "146".to_string(),
user_agent_template: "Codex Desktop/{app_version} ({platform}; {arch})".to_string(),
user_agent_template: "Codex Desktop/{version} ({platform}; {arch})".to_string(),
default_headers: Fingerprint::default_headers(),
header_order: Fingerprint::default_header_order(),
};
@@ -108,43 +137,38 @@ async fn database_fingerprint_headers_should_match_expected_format() {
);
}
/// 验证新旧版本指纹的差异
#[test]
fn old_vs_new_fingerprint_user_agent_comparison() {
let old_fingerprint = Fingerprint::default_codex_desktop();
let new_fingerprint = Fingerprint {
fn fingerprint_user_agent_should_reflect_updated_version_fields() {
let baseline_fingerprint = Fingerprint::default_codex_desktop();
let updated_fingerprint = Fingerprint {
originator: "Codex Desktop".to_string(),
app_version: "26.609.41114".to_string(),
build_number: "3888".to_string(),
platform: "darwin".to_string(),
arch: "arm64".to_string(),
chromium_version: "146".to_string(),
user_agent_template: "Codex Desktop/{app_version} ({platform}; {arch})".to_string(),
user_agent_template: "Codex Desktop/{version} ({platform}; {arch})".to_string(),
default_headers: Fingerprint::default_headers(),
header_order: Fingerprint::default_header_order(),
};
let old_ua = old_fingerprint.user_agent();
let new_ua = new_fingerprint.user_agent();
let baseline_ua = baseline_fingerprint.user_agent();
let updated_ua = updated_fingerprint.user_agent();
// 验证格式一致性
assert!(old_ua.starts_with("Codex Desktop/"));
assert!(new_ua.starts_with("Codex Desktop/"));
assert!(old_ua.contains("darwin"));
assert!(new_ua.contains("darwin"));
assert!(baseline_ua.starts_with("Codex Desktop/"));
assert!(updated_ua.starts_with("Codex Desktop/"));
assert!(baseline_ua.contains("darwin"));
assert!(updated_ua.contains("darwin"));
// 验证版本号不同
assert_ne!(
old_ua, new_ua,
"New and old fingerprints should have different User-Agent"
baseline_ua, updated_ua,
"fingerprint version fields should affect User-Agent"
);
// 验证新版本包含更新的版本号
assert!(new_ua.contains("26.609.41114"));
assert!(updated_ua.contains("26.609.41114"));
// 验证 sec-ch-ua 格式
assert_eq!(
new_fingerprint.sec_ch_ua(),
updated_fingerprint.sec_ch_ua(),
"\"Chromium\";v=\"146\", \"Not:A-Brand\";v=\"24\""
);
}
+452 -2
View File
@@ -1,4 +1,10 @@
use std::time::Duration;
use serde_json::json;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::{TcpListener, TcpStream},
};
use wiremock::{
matchers::{body_json, header, method, path},
Mock, MockServer, ResponseTemplate,
@@ -7,8 +13,10 @@ use wiremock::{
use codex_proxy_rs::{
codex::gateway::fingerprint::model::Fingerprint,
codex::gateway::transport::{
http_client::{build_reqwest_client, CodexBackendClient, CodexRequestContext},
types::CodexResponsesRequest,
http_client::{
build_reqwest_client, CodexBackendClient, CodexClientError, CodexRequestContext,
},
types::{CodexCompactRequest, CodexResponsesRequest},
usage_events::TokenUsage,
},
};
@@ -91,3 +99,445 @@ async fn codex_backend_client_should_send_desktop_headers_and_capture_response_m
vec!["cf_clearance=new; Domain=.chatgpt.com; Path=/".to_string()]
);
}
#[tokio::test]
async fn codex_backend_client_usage_should_use_original_auxiliary_headers() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"rate_limit": {
"limit_reached": false
}
})))
.mount(&server)
.await;
let client = CodexBackendClient::new(
build_reqwest_client(false).unwrap(),
server.uri(),
Fingerprint::default_for_tests(),
);
let usage = client
.fetch_usage(CodexRequestContext {
access_token: "access-token",
account_id: Some("chatgpt-account"),
request_id: "req_aux",
turn_state: Some("turn-state"),
turn_metadata: Some("turn-meta"),
beta_features: Some("feature-a"),
include_timing_metrics: Some("true"),
version: Some("26.318.11754"),
codex_window_id: Some("cw_1"),
parent_thread_id: Some("parent-1"),
cookie_header: Some("cf_clearance=old"),
installation_id: Some("install-1"),
session_id: Some("session-1"),
})
.await
.unwrap();
assert_eq!(usage["rate_limit"]["limit_reached"], false);
let requests = server.received_requests().await.unwrap();
let headers = &requests[0].headers;
assert_eq!(
headers.get("accept").and_then(|value| value.to_str().ok()),
Some("application/json")
);
assert_eq!(
headers
.get("accept-encoding")
.and_then(|value| value.to_str().ok()),
Some("gzip, deflate")
);
assert!(headers.get("content-type").is_none());
assert!(headers.get("openai-beta").is_none());
assert!(headers.get("x-openai-internal-codex-residency").is_none());
assert!(headers.get("x-client-request-id").is_none());
assert!(headers.get("x-codex-installation-id").is_none());
assert!(headers.get("session_id").is_none());
}
#[tokio::test]
async fn codex_backend_client_models_should_use_original_auxiliary_headers() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/codex/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"models": [
{"slug": "gpt-5.5", "title": "GPT 5.5"}
]
})))
.mount(&server)
.await;
let client = CodexBackendClient::new(
build_reqwest_client(false).unwrap(),
server.uri(),
Fingerprint::default_for_tests(),
);
let models = client
.fetch_models(CodexRequestContext {
access_token: "access-token",
account_id: Some("chatgpt-account"),
request_id: "req_models",
turn_state: Some("turn-state"),
turn_metadata: None,
beta_features: None,
include_timing_metrics: None,
version: None,
codex_window_id: None,
parent_thread_id: None,
cookie_header: None,
installation_id: Some("install-1"),
session_id: Some("session-1"),
})
.await
.unwrap();
assert_eq!(models.len(), 1);
let requests = server.received_requests().await.unwrap();
let models_request = requests
.iter()
.find(|request| request.url.path() == "/codex/models")
.unwrap();
let headers = &models_request.headers;
assert_eq!(
headers.get("accept").and_then(|value| value.to_str().ok()),
Some("application/json")
);
assert!(headers.get("content-type").is_none());
assert!(headers.get("openai-beta").is_none());
assert!(headers.get("x-openai-internal-codex-residency").is_none());
assert!(headers.get("x-client-request-id").is_none());
assert!(headers.get("x-codex-installation-id").is_none());
assert!(headers.get("session_id").is_none());
}
#[tokio::test]
async fn codex_backend_client_should_cap_non_success_error_body_at_one_mib() {
let server = MockServer::start().await;
let large_error_body = "x".repeat(1024 * 1024 + 17);
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(ResponseTemplate::new(500).set_body_string(large_error_body))
.mount(&server)
.await;
let client = CodexBackendClient::new(
build_reqwest_client(false).unwrap(),
server.uri(),
Fingerprint::default_for_tests(),
);
let mut request = CodexResponsesRequest::new_http_sse("gpt-5.5", "", Vec::new());
request.force_http_sse = true;
let result = client
.create_response(
&request,
CodexRequestContext {
access_token: "access-token",
account_id: Some("chatgpt-account"),
request_id: "req_large_error",
turn_state: None,
turn_metadata: None,
beta_features: None,
include_timing_metrics: None,
version: None,
codex_window_id: None,
parent_thread_id: None,
cookie_header: None,
installation_id: None,
session_id: None,
},
)
.await;
let Err(CodexClientError::Upstream { status, body, .. }) = result else {
panic!("expected upstream error");
};
assert_eq!(status, reqwest::StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(body.len(), 1024 * 1024);
}
#[tokio::test]
async fn codex_backend_client_should_send_http_sse_headers_in_fingerprint_order() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut stream).await;
write_completed_sse_response(&mut stream).await;
request
});
let mut request = CodexResponsesRequest::new_http_sse("gpt-5.5", "", Vec::new());
request.force_http_sse = true;
request.turn_metadata = Some("turn-meta".to_string());
request.beta_features = Some("beta-a".to_string());
request.include_timing_metrics = Some("true".to_string());
request.version = Some("26.519.81530".to_string());
request.codex_window_id = Some("cw_1".to_string());
request.parent_thread_id = Some("parent-1".to_string());
let client = CodexBackendClient::new(
build_reqwest_client(true).unwrap(),
format!("http://{addr}"),
Fingerprint::default_for_tests(),
);
client
.create_response(
&request,
CodexRequestContext {
access_token: "access-token",
account_id: Some("chatgpt-account"),
request_id: "req_order",
turn_state: Some("turn-state"),
turn_metadata: request.turn_metadata.as_deref(),
beta_features: request.beta_features.as_deref(),
include_timing_metrics: request.include_timing_metrics.as_deref(),
version: request.version.as_deref(),
codex_window_id: request.codex_window_id.as_deref(),
parent_thread_id: request.parent_thread_id.as_deref(),
cookie_header: Some("cf_clearance=old"),
installation_id: Some("install-1"),
session_id: Some("session-1"),
},
)
.await
.unwrap();
let raw_request = server.await.unwrap();
let header_names = header_names(&raw_request);
assert_header_subsequence(
&header_names,
&[
"authorization",
"chatgpt-account-id",
"originator",
"user-agent",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"accept-encoding",
"accept-language",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-dest",
"content-type",
"cookie",
"accept",
"openai-beta",
"x-openai-internal-codex-residency",
"x-client-request-id",
"x-codex-installation-id",
"session_id",
"x-codex-window-id",
"x-codex-turn-state",
"x-codex-turn-metadata",
"x-codex-beta-features",
"x-responsesapi-include-timing-metrics",
"version",
"x-codex-parent-thread-id",
],
);
}
#[tokio::test]
async fn codex_backend_client_should_send_compact_headers_in_fingerprint_order() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut stream).await;
write_compact_json_response(&mut stream).await;
request
});
let client = CodexBackendClient::new(
build_reqwest_client(true).unwrap(),
format!("http://{addr}"),
Fingerprint::default_for_tests(),
);
client
.create_compact_response(
&CodexCompactRequest {
model: "gpt-5.5".to_string(),
input: Vec::new(),
instructions: String::new(),
tools: None,
parallel_tool_calls: None,
reasoning: None,
text: None,
},
CodexRequestContext {
access_token: "access-token",
account_id: Some("chatgpt-account"),
request_id: "req_compact",
turn_state: None,
turn_metadata: None,
beta_features: None,
include_timing_metrics: None,
version: None,
codex_window_id: None,
parent_thread_id: None,
cookie_header: Some("cf_clearance=old"),
installation_id: Some("install-1"),
session_id: None,
},
)
.await
.unwrap();
let raw_request = server.await.unwrap();
let header_names = header_names(&raw_request);
assert_header_subsequence(
&header_names,
&[
"authorization",
"chatgpt-account-id",
"originator",
"user-agent",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"accept-encoding",
"accept-language",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-dest",
"content-type",
"cookie",
"openai-beta",
"x-openai-internal-codex-residency",
"x-client-request-id",
"x-codex-installation-id",
],
);
}
#[tokio::test]
async fn build_reqwest_client_should_reuse_cached_connection_pool() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut first_stream, _) = listener.accept().await.unwrap();
read_http_request(&mut first_stream).await;
write_empty_http_response(&mut first_stream).await;
tokio::select! {
request = read_http_request(&mut first_stream) => {
write_empty_http_response(&mut first_stream).await;
!request.is_empty()
}
accepted = listener.accept() => {
let (mut second_stream, _) = accepted.unwrap();
read_http_request(&mut second_stream).await;
write_empty_http_response(&mut second_stream).await;
false
}
_ = tokio::time::sleep(Duration::from_millis(500)) => false,
}
});
let url = format!("http://{addr}/reuse");
let first_client = build_reqwest_client(false).unwrap();
first_client
.get(&url)
.send()
.await
.unwrap()
.text()
.await
.unwrap();
let second_client = build_reqwest_client(false).unwrap();
second_client
.get(&url)
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert!(server.await.unwrap());
}
fn header_names(request: &str) -> Vec<String> {
request
.lines()
.skip(1)
.take_while(|line| !line.is_empty())
.filter_map(|line| {
line.split_once(':')
.map(|(name, _)| name.to_ascii_lowercase())
})
.collect()
}
fn assert_header_subsequence(actual: &[String], expected: &[&str]) {
let mut offset = 0;
for expected_name in expected {
let Some(position) = actual[offset..]
.iter()
.position(|actual_name| actual_name == expected_name)
else {
panic!("missing header {expected_name}; actual order: {actual:?}");
};
offset += position + 1;
}
}
async fn read_http_request(stream: &mut TcpStream) -> String {
let mut request = Vec::new();
let mut buffer = [0u8; 1024];
loop {
let read = stream.read(&mut buffer).await.unwrap();
if read == 0 {
break;
}
request.extend_from_slice(&buffer[..read]);
if request.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
String::from_utf8(request).unwrap()
}
async fn write_empty_http_response(stream: &mut TcpStream) {
stream
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n")
.await
.unwrap();
}
async fn write_completed_sse_response(stream: &mut TcpStream) {
let body = concat!(
"event: response.completed\n",
"data: {\"response\":{\"id\":\"resp_order\",\"status\":\"completed\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n",
"\n",
);
stream
.write_all(
format!(
"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\n\r\n{body}",
body.len()
)
.as_bytes(),
)
.await
.unwrap();
}
async fn write_compact_json_response(stream: &mut TcpStream) {
let body = r#"{"output":[]}"#;
stream
.write_all(
format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{body}",
body.len()
)
.as_bytes(),
)
.await
.unwrap();
}
+2 -2
View File
@@ -84,7 +84,7 @@ async fn openai_oauth_client_should_exchange_authorization_code_with_pkce_verifi
.exchange_code(
"oauth-code",
"pkce-verifier",
"http://localhost:1455/auth/openai/callback",
"http://localhost:1455/auth/callback",
)
.await
.unwrap();
@@ -96,7 +96,7 @@ async fn openai_oauth_client_should_exchange_authorization_code_with_pkce_verifi
assert!(body.contains("grant_type=authorization_code"));
assert!(body.contains("client_id=codex-client"));
assert!(body.contains("code=oauth-code"));
assert!(body.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fopenai%2Fcallback"));
assert!(body.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback"));
assert!(body.contains("code_verifier=pkce-verifier"));
}
+50 -3
View File
@@ -1,7 +1,7 @@
use serde_json::json;
use codex_proxy_rs::codex::gateway::transport::{
sse::{encode_sse_event, parse_sse_events},
sse::{encode_sse_event, parse_sse_events, MAX_SSE_EVENT_BUFFER_BYTES},
usage_events::{extract_usage, TokenUsage},
};
@@ -30,10 +30,57 @@ fn parse_sse_events_combines_multiline_data_and_metadata() {
}
#[test]
fn parse_sse_events_accepts_done_sentinel_without_event_type() {
fn parse_sse_events_should_keep_non_prefixed_json_continuation_lines() {
let input = concat!(
"event: error\n",
"data: {\n",
" \"error\": {\n",
" \"message\": \"bad upstream\"\n",
" }\n",
"}\n",
"\n",
);
let events = parse_sse_events(input).unwrap();
assert_eq!(events.len(), 1);
assert_eq!(
events[0].data,
"{\n \"error\": {\n \"message\": \"bad upstream\"\n }\n}"
);
}
#[test]
fn parse_sse_events_should_ignore_done_sentinel_without_event_type() {
let events = parse_sse_events("data: [DONE]\r\n\r\n").unwrap();
assert_eq!(events[0].data, "[DONE]");
assert!(events.is_empty());
}
#[test]
fn parse_sse_events_should_convert_non_sse_json_body_to_error_event() {
let events = parse_sse_events(r#"{"error":{"message":"not an SSE stream"}}"#).unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].event.as_deref(), Some("error"));
let data: serde_json::Value = serde_json::from_str(&events[0].data).unwrap();
assert_eq!(data["error"]["code"], "non_sse_response");
assert_eq!(data["error"]["message"], "not an SSE stream");
}
#[test]
fn parse_sse_events_should_reject_single_event_buffer_above_original_limit() {
let mut input = String::from("event: response.output_text.delta\ndata: ");
input.extend(std::iter::repeat_n('x', MAX_SSE_EVENT_BUFFER_BYTES));
let Err(error) = parse_sse_events(&input) else {
panic!("oversized SSE event was accepted");
};
assert_eq!(
error.to_string(),
"SSE buffer exceeded 67108864 bytes — aborting stream"
);
}
#[test]
+169 -2
View File
@@ -9,7 +9,7 @@ use tokio::{
time::sleep,
};
use tokio_tungstenite::{
accept_hdr_async,
accept_async, accept_hdr_async,
tungstenite::{
handshake::server::{Request as WsRequest, Response as WsResponse},
Message,
@@ -370,6 +370,8 @@ async fn websocket_request_should_forward_security_chain_body_fields() {
assert!(headers
.iter()
.any(|(name, value)| { name == "x-openai-subagent" && value == "review" }));
assert!(headers.iter().all(|(name, _)| name != "content-type"));
assert!(headers.iter().all(|(name, _)| name != "accept"));
}
#[tokio::test]
@@ -380,7 +382,7 @@ async fn websocket_handshake_429_should_surface_as_upstream_error_before_body_is
let (mut stream, _) = listener.accept().await.unwrap();
let request = read_http_upgrade_request(&mut stream).await;
assert!(request.starts_with("GET /codex/responses HTTP/1.1"));
assert!(request.contains("authorization: Bearer access-token"));
assert!(request.contains("Authorization: Bearer access-token"));
let body = r#"{"error":{"message":"rate limited"}}"#;
let response = format!(
"HTTP/1.1 429 Too Many Requests\r\nretry-after: 33\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
@@ -433,6 +435,63 @@ async fn websocket_handshake_429_should_surface_as_upstream_error_before_body_is
assert!(body.contains("rate limited"));
}
#[tokio::test]
async fn websocket_handshake_should_offer_original_permessage_deflate_extension() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (request_tx, request_rx) = oneshot::channel();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let request = read_http_upgrade_request(&mut stream).await;
request_tx.send(request).unwrap();
stream
.write_all(
b"HTTP/1.1 400 Bad Request\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
)
.await
.unwrap();
});
let client = CodexBackendClient::new(
build_reqwest_client(false).unwrap(),
format!("http://{addr}"),
codex_proxy_rs::codex::gateway::fingerprint::model::Fingerprint::default_for_tests(),
);
let result = client
.websocket_stream_response(&base_request(), request_context("req_ws_extensions", None))
.await;
assert!(result.is_err());
server.await.unwrap();
let raw_request = request_rx.await.unwrap();
assert_headers_appear_in_order(
&raw_request,
&[
"Host: ",
"Connection: Upgrade\r\n",
"Upgrade: websocket\r\n",
"Sec-WebSocket-Version: 13\r\n",
"Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits\r\n",
"Sec-WebSocket-Key: ",
"Authorization: Bearer access-token\r\n",
"ChatGPT-Account-Id: chatgpt-account\r\n",
"originator: Codex Desktop\r\n",
"User-Agent: Codex Desktop/26.519.81530 (darwin; arm64)\r\n",
"sec-ch-ua: \"Chromium\";v=\"146\", \"Not:A-Brand\";v=\"24\"\r\n",
"sec-ch-ua-mobile: ?0\r\n",
"sec-ch-ua-platform: \"macOS\"\r\n",
"Accept-Encoding: gzip, deflate, br, zstd\r\n",
"Accept-Language: en-US,en;q=0.9\r\n",
"sec-fetch-site: same-origin\r\n",
"sec-fetch-mode: cors\r\n",
"sec-fetch-dest: empty\r\n",
"OpenAI-Beta: responses_websockets=2026-02-06\r\n",
"x-openai-internal-codex-residency: us\r\n",
"x-client-request-id: req_ws_extensions\r\n",
],
);
}
#[tokio::test]
#[expect(
clippy::result_large_err,
@@ -521,6 +580,104 @@ async fn websocket_first_error_frame_should_surface_as_upstream_error_without_ht
assert!(body.contains("usage_limit_reached"));
}
#[tokio::test]
async fn websocket_one_shot_should_passthrough_connection_limit_failed_frame() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_async(stream).await.unwrap();
let _message = websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Text(
json!({
"type": "response.failed",
"response": {
"id": "resp_ws_connection_limit",
"error": {
"code": "websocket_connection_limit_reached",
"message": "connection limit reached"
}
}
})
.to_string()
.into(),
))
.await
.unwrap();
websocket.close(None).await.unwrap();
});
let client = CodexBackendClient::new(
build_reqwest_client(false).unwrap(),
format!("http://{addr}"),
codex_proxy_rs::codex::gateway::fingerprint::model::Fingerprint::default_for_tests(),
);
let response = client
.create_response(&base_request(), request_context("req_ws_limit", None))
.await
.unwrap();
server.await.unwrap();
assert!(response.body.contains("event: response.failed"));
assert!(response.body.contains("websocket_connection_limit_reached"));
}
#[tokio::test]
async fn websocket_pooled_connection_limit_frame_should_surface_as_503() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_async(stream).await.unwrap();
let _message = websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Text(
json!({
"type": "response.failed",
"response": {
"id": "resp_pooled_ws_connection_limit",
"error": {
"code": "websocket_connection_limit_reached",
"message": "connection limit reached"
}
}
})
.to_string()
.into(),
))
.await
.unwrap();
websocket.close(None).await.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::with_config(manual_pool_config(
Duration::from_secs(60),
8,
)));
let client = CodexBackendClient::new(
build_reqwest_client(false).unwrap(),
format!("http://{addr}"),
codex_proxy_rs::codex::gateway::fingerprint::model::Fingerprint::default_for_tests(),
)
.with_websocket_pool(pool, "chatgpt-account");
let mut request = base_request();
request.prompt_cache_key = Some("chatgpt-account:conversation".to_string());
let error = client
.create_response(&request, request_context("req_pooled_ws_limit", None))
.await
.unwrap_err();
server.await.unwrap();
let CodexClientError::Upstream { status, body, .. } = error else {
panic!("expected upstream error, found {error:?}");
};
assert_eq!(status, reqwest::StatusCode::SERVICE_UNAVAILABLE);
assert!(body.contains("websocket_connection_limit_reached"));
}
#[tokio::test]
async fn ordinary_response_should_fallback_to_http_sse_when_websocket_transport_fails() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -880,3 +1037,13 @@ async fn read_http_upgrade_request(stream: &mut tokio::net::TcpStream) -> String
}
String::from_utf8(request).unwrap()
}
fn assert_headers_appear_in_order(raw_request: &str, expected_headers: &[&str]) {
let mut offset = 0;
for expected in expected_headers {
let Some(index) = raw_request[offset..].find(expected) else {
panic!("missing websocket handshake header `{expected}` in:\n{raw_request}");
};
offset += index + expected.len();
}
}
+197
View File
@@ -12,6 +12,7 @@ use wiremock::{
};
use codex_proxy_rs::{
codex::gateway::conversation_identity::build_conversation_identity,
config::{
AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig,
QuotaConfig, QuotaWarningThresholds, SecurityConfig, ServerConfig, TlsConfig,
@@ -34,6 +35,7 @@ const CHAT_PARALLEL_TOOLS_SUCCESS_SSE: &str =
const CHAT_TOOL_REASONING_COMPLETE_SSE: &str =
include_str!("../fixtures/chat/tool_reasoning_complete.sse");
const CHAT_TOOL_STREAM_SSE: &str = include_str!("../fixtures/chat/tool_stream.sse");
const CHAT_TUPLE_OBJECT_SSE: &str = include_str!("../fixtures/chat/tuple_object.sse");
struct ImportedApp {
app: axum::Router,
@@ -69,6 +71,9 @@ fn test_config(database_url: String, base_url: String) -> AppConfig {
request_interval_ms: 50,
rotation_strategy: "least_used".to_string(),
tier_priority: Vec::new(),
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
oauth_auth_endpoint: "https://auth.openai.com/oauth/authorize".to_string(),
oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(),
},
quota: QuotaConfig {
refresh_interval_minutes: 5,
@@ -89,6 +94,7 @@ fn test_config(database_url: String, base_url: String) -> AppConfig {
tls: TlsConfig {
force_http11: false,
},
ws_pool: Default::default(),
admin: AdminConfig {
session_ttl_minutes: 1440,
default_username: "admin".to_string(),
@@ -187,6 +193,56 @@ async fn single_upstream_post_body(server: &MockServer) -> Value {
serde_json::from_slice(&post_requests[0].body).unwrap()
}
#[tokio::test]
async fn chat_completions_should_use_user_as_client_conversation_id() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-secret"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(CHAT_NON_STREAM_TEXT_SSE),
)
.mount(&server)
.await;
let imported = build_imported_app(server.uri()).await;
let response = imported
.app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header(
"authorization",
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"user": "chat-client-session",
"messages": [
{"role": "user", "content": "Say ok"}
]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let upstream_body = single_upstream_post_body(&server).await;
let identity = build_conversation_identity(Some("chat-client-session"), None, "acct_chat");
assert_eq!(
upstream_body["prompt_cache_key"],
identity.conversation_id.unwrap()
);
}
#[tokio::test]
async fn chat_completions_stream_should_retry_next_account_after_429_retry_after() {
let server = MockServer::start().await;
@@ -331,6 +387,75 @@ async fn chat_completions_translates_messages_and_returns_openai_response() {
assert!(upstream_body["client_metadata"]["x-codex-installation-id"].is_string());
}
#[tokio::test]
async fn chat_completions_should_convert_and_reconvert_tuple_schema() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(CHAT_TUPLE_OBJECT_SSE),
)
.expect(1)
.mount(&server)
.await;
let imported = build_imported_app(server.uri()).await;
let response = imported
.app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header(
"authorization",
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"messages": [{"role": "user", "content": "Return JSON"}],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "TupleAnswer",
"schema": {
"type": "object",
"properties": {
"point": {
"type": "array",
"prefixItems": [
{"type": "number"},
{"type": "number"}
]
}
}
},
"strict": true
}
}
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(
body["choices"][0]["message"]["content"],
"{\"point\":[1,2]}"
);
let upstream_body = single_upstream_post_body(&server).await;
let schema = &upstream_body["text"]["format"]["schema"];
assert!(schema["properties"]["point"].get("prefixItems").is_none());
assert_eq!(schema["properties"]["point"]["type"], "object");
}
#[tokio::test]
async fn chat_completions_streams_openai_chunks_from_codex_sse() {
let server = MockServer::start().await;
@@ -397,6 +522,78 @@ async fn chat_completions_streams_openai_chunks_from_codex_sse() {
);
}
#[tokio::test]
async fn chat_completions_stream_should_reconvert_tuple_schema() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(CHAT_TUPLE_OBJECT_SSE),
)
.expect(1)
.mount(&server)
.await;
let imported = build_imported_app(server.uri()).await;
let response = imported
.app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header(
"authorization",
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"stream": true,
"messages": [{"role": "user", "content": "Return JSON"}],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "TupleAnswer",
"schema": {
"type": "object",
"properties": {
"point": {
"type": "array",
"prefixItems": [
{"type": "number"},
{"type": "number"}
]
}
}
},
"strict": true
}
}
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let frames = response_sse_data(response).await;
let chunks = frames
.iter()
.filter(|frame| frame.as_str() != "[DONE]")
.map(|frame| serde_json::from_str::<Value>(frame).unwrap())
.collect::<Vec<_>>();
assert_eq!(
chunks[1]["choices"][0]["delta"]["content"],
"{\"point\":[1,2]}"
);
}
#[tokio::test]
async fn chat_completions_forwards_parallel_tools_and_model_suffix_options() {
let server = MockServer::start().await;
+108 -4
View File
@@ -8,7 +8,26 @@ use wiremock::{
Mock, MockServer, ResponseTemplate,
};
use crate::support::{response_json, upstream::build_imported_app};
use codex_proxy_rs::codex::gateway::fingerprint::model::Fingerprint;
use crate::support::{
response_json,
upstream::{build_imported_app, build_imported_app_with_fingerprint},
};
fn diagnostics_fingerprint() -> Fingerprint {
Fingerprint {
originator: "Codex Desktop".to_string(),
app_version: "27.100.200".to_string(),
build_number: "9001".to_string(),
platform: "linux".to_string(),
arch: "x64".to_string(),
chromium_version: "147".to_string(),
user_agent_template: "Codex Desktop/{version} ({platform}; {arch})".to_string(),
default_headers: Fingerprint::default_headers(),
header_order: Fingerprint::default_header_order(),
}
}
#[tokio::test]
async fn debug_diagnostics_should_return_local_runtime_pool_transport_and_path_summary() {
@@ -59,6 +78,38 @@ async fn debug_diagnostics_should_return_local_runtime_pool_transport_and_path_s
assert!(!serialized.contains("refresh-secret"));
}
#[tokio::test]
async fn debug_diagnostics_should_report_runtime_fingerprint() {
let imported = build_imported_app_with_fingerprint(
"https://chatgpt.test/backend-api".to_string(),
diagnostics_fingerprint(),
)
.await;
let response = imported
.app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/debug/diagnostics")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["transport"]["fingerprint"]["appVersion"], "27.100.200");
assert_eq!(body["transport"]["fingerprint"]["buildNumber"], "9001");
assert_eq!(body["transport"]["fingerprint"]["chromiumVersion"], "147");
assert_eq!(
body["transport"]["fingerprint"]["userAgent"],
"Codex Desktop/27.100.200 (linux; x64)"
);
}
#[tokio::test]
async fn debug_diagnostics_should_reject_forwarded_remote_requests() {
let imported = build_imported_app("https://chatgpt.test/backend-api".to_string()).await;
@@ -80,7 +131,7 @@ async fn debug_diagnostics_should_reject_forwarded_remote_requests() {
}
#[tokio::test]
async fn debug_fingerprint_should_return_local_static_fingerprint_summary() {
async fn debug_fingerprint_should_return_runtime_fingerprint_summary() {
let imported = build_imported_app("https://chatgpt.test/backend-api".to_string()).await;
let response = imported
@@ -98,7 +149,7 @@ async fn debug_fingerprint_should_return_local_static_fingerprint_summary() {
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["source"], "staticDefault");
assert_eq!(body["source"], "runtime");
assert_eq!(body["originator"], "Codex Desktop");
assert_eq!(body["appVersion"], "26.519.81530");
assert_eq!(body["buildNumber"], "3178");
@@ -128,13 +179,62 @@ async fn debug_fingerprint_should_reject_forwarded_remote_requests() {
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn debug_upstream_should_probe_with_runtime_fingerprint() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/codex/models"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"error": {
"message": "missing or invalid token"
}
})))
.expect(1)
.mount(&server)
.await;
let imported =
build_imported_app_with_fingerprint(server.uri(), diagnostics_fingerprint()).await;
let response = imported
.app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/debug/upstream")
.header("x-request-id", "req_debug_probe_fingerprint")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
let requests = server.received_requests().await.unwrap();
assert_eq!(body["statusCode"], 401);
assert_eq!(requests.len(), 1);
let headers = &requests[0].headers;
assert_eq!(
headers
.get("user-agent")
.and_then(|value| value.to_str().ok()),
Some("Codex Desktop/27.100.200 (linux; x64)")
);
assert_eq!(
headers
.get("sec-ch-ua")
.and_then(|value| value.to_str().ok()),
Some("\"Chromium\";v=\"147\", \"Not:A-Brand\";v=\"24\"")
);
}
#[tokio::test]
async fn debug_upstream_should_probe_codex_models_endpoint_without_returning_secrets() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/codex/models"))
.and(header("originator", "Codex Desktop"))
.and(header("x-client-request-id", "req_debug_probe"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"error": {
"message": "missing or invalid token"
@@ -171,6 +271,10 @@ async fn debug_upstream_should_probe_codex_models_endpoint_without_returning_sec
let serialized = serde_json::to_string(&body).unwrap();
assert!(!serialized.contains("access-secret"));
assert!(!serialized.contains("refresh-secret"));
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 1);
assert!(requests[0].headers.get("x-client-request-id").is_none());
}
#[tokio::test]
+589 -2
View File
@@ -45,7 +45,68 @@ const DONE_ITEM_COMPLETED_SSE: &str =
include_str!("../fixtures/responses/http_sse/done_item_completed.sse");
const STREAM_USAGE_SSE: &str = include_str!("../fixtures/responses/http_sse/stream_usage.sse");
const DEFAULT_STREAM_SSE: &str = include_str!("../fixtures/responses/http_sse/default_stream.sse");
const EMPTY_COMPLETED_SSE: &str = "event: response.completed\ndata: {\"response\":{\"id\":\"resp_empty\",\"object\":\"response\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\n";
const EMPTY_COMPLETED_SSE: &str =
include_str!("../fixtures/responses/http_sse/empty_completed.sse");
const COMPLETED_TUPLE_OBJECT_SSE: &str =
include_str!("../fixtures/responses/http_sse/completed_tuple_object.sse");
#[tokio::test]
async fn v1_responses_should_reject_invalid_json_without_upstream_request() {
let server = MockServer::start().await;
let imported = build_imported_app(server.uri()).await;
let response = imported
.app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header(
"authorization",
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.body(Body::from("{"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response_json(response).await;
assert_eq!(body["error"]["code"], "invalid_request");
let requests = server.received_requests().await.unwrap();
assert!(requests.is_empty());
}
#[tokio::test]
async fn v1_responses_should_reject_non_object_json_without_upstream_request() {
let server = MockServer::start().await;
let imported = build_imported_app(server.uri()).await;
let response = imported
.app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header(
"authorization",
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.body(Body::from("[]"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response_json(response).await;
assert_eq!(body["error"]["code"], "invalid_request");
let requests = server.received_requests().await.unwrap();
assert!(requests.is_empty());
}
#[tokio::test]
async fn v1_responses_should_skip_event_log_when_logging_disabled() {
@@ -202,7 +263,7 @@ async fn v1_responses_should_stagger_same_account_requests_before_sending_upstre
assert_eq!(times.len(), 2);
let elapsed = times[1].duration_since(times[0]);
assert!(
elapsed >= Duration::from_millis(220),
elapsed >= Duration::from_millis(180),
"second upstream request was sent too early: {elapsed:?}"
);
}
@@ -513,6 +574,390 @@ async fn v1_responses_should_forward_parity_fields_and_context_headers() {
assert!(upstream_body.get("use_websocket").is_none());
}
#[tokio::test]
async fn v1_responses_should_convert_tuple_schema_before_upstream() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(COMPLETED_FIELDS_SSE),
)
.expect(1)
.mount(&server)
.await;
let imported = build_imported_app(server.uri()).await;
let response = imported
.app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header(
"authorization",
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"stream": false,
"use_websocket": false,
"input": [],
"text": {
"format": {
"type": "json_schema",
"name": "TupleAnswer",
"schema": {
"type": "object",
"properties": {
"point": {
"type": "array",
"prefixItems": [
{"type": "number"},
{"type": "number"}
],
"items": false
}
},
"required": ["point"]
},
"strict": true
}
}
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let requests = server.received_requests().await.unwrap();
let upstream_body: Value = serde_json::from_slice(&requests[0].body).unwrap();
let schema = &upstream_body["text"]["format"]["schema"];
assert!(schema["properties"]["point"].get("prefixItems").is_none());
assert!(schema["properties"]["point"].get("items").is_none());
assert_eq!(schema["additionalProperties"], false);
assert_eq!(
schema["properties"]["point"],
json!({
"type": "object",
"properties": {
"0": {"type": "number"},
"1": {"type": "number"}
},
"required": ["0", "1"],
"additionalProperties": false
})
);
}
#[tokio::test]
async fn v1_responses_should_reconvert_tuple_schema_output_for_client() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(COMPLETED_TUPLE_OBJECT_SSE),
)
.expect(1)
.mount(&server)
.await;
let imported = build_imported_app(server.uri()).await;
let response = imported
.app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header(
"authorization",
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"stream": false,
"use_websocket": false,
"input": [],
"text": {
"format": {
"type": "json_schema",
"name": "TupleAnswer",
"schema": {
"type": "object",
"properties": {
"point": {
"type": "array",
"prefixItems": [
{"type": "number"},
{"type": "number"}
]
}
}
},
"strict": true
}
}
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["output_text"], "{\"point\":[1,2]}");
assert_eq!(body["output"][0]["content"][0]["text"], "{\"point\":[1,2]}");
}
#[tokio::test]
async fn v1_responses_stream_should_reconvert_tuple_schema_output_for_client() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(COMPLETED_TUPLE_OBJECT_SSE),
)
.expect(1)
.mount(&server)
.await;
let imported = build_imported_app(server.uri()).await;
let response = imported
.app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header(
"authorization",
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"stream": true,
"use_websocket": false,
"input": [],
"text": {
"format": {
"type": "json_schema",
"name": "TupleAnswer",
"schema": {
"type": "object",
"properties": {
"point": {
"type": "array",
"prefixItems": [
{"type": "number"},
{"type": "number"}
]
}
}
},
"strict": true
}
}
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_text(response).await;
assert!(body.contains(r#""delta":"{\"point\":[1,2]}""#));
assert!(body.contains(r#""text":"{\"point\":[1,2]}""#));
assert!(!body.contains(r#""point\":{\"0":1,"1":2}"#));
}
#[tokio::test]
async fn v1_responses_review_route_should_force_review_subagent_upstream() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("x-openai-subagent", "review"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(COMPLETED_FIELDS_SSE),
)
.expect(1)
.mount(&server)
.await;
let imported = build_imported_app(server.uri()).await;
let response = imported
.app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses/review")
.header(
"authorization",
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"stream": false,
"use_websocket": false,
"input": []
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let requests = server.received_requests().await.unwrap();
let post_request = requests
.iter()
.find(|request| request.method.as_str() == "POST" && !request.body.is_empty())
.unwrap();
let upstream_body: Value = serde_json::from_slice(&post_request.body).unwrap();
assert_eq!(
upstream_body["client_metadata"]["x-openai-subagent"],
"review"
);
}
#[tokio::test]
async fn v1_responses_compact_should_post_json_to_codex_compact_upstream() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses/compact"))
.and(header("authorization", "Bearer access-secret"))
.and(header("chatgpt-account-id", "chatgpt-account"))
.and(header("content-type", "application/json"))
.and(header("openai-beta", "responses_websockets=2026-02-06"))
.and(header("x-openai-internal-codex-residency", "us"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"output": [
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "compacted"}]
}
]
})))
.expect(1)
.mount(&server)
.await;
let imported = build_imported_app(server.uri()).await;
let response = imported
.app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses/compact")
.header(
"authorization",
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5-fast",
"instructions": "compress the session",
"input": [
{"role": "user", "content": "hello"},
{
"type": "reasoning",
"id": "rs_1",
"status": "completed",
"summary": [{"type": "summary_text", "text": "kept"}],
"ignored": "drop"
},
{"type": "compaction", "encrypted_content": "enc_compact"},
{"type": "compaction", "id": "drop_missing_encrypted"}
],
"tools": [{"type": "function", "name": "lookup"}],
"parallel_tool_calls": false,
"reasoning": {"effort": "high", "summary": "auto", "extra": "drop"},
"text": {
"format": {
"type": "json_schema",
"name": "Compact",
"schema": {"type": "object"},
"strict": true
}
},
"stream": true,
"store": true,
"prompt_cache_key": "must_not_forward"
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["output"][0]["content"][0]["text"], "compacted");
let requests = server.received_requests().await.unwrap();
let compact_request = requests
.iter()
.find(|request| request.url.path() == "/codex/responses/compact")
.unwrap();
assert_ne!(
compact_request
.headers
.get("accept")
.and_then(|value| value.to_str().ok()),
Some("text/event-stream")
);
assert!(compact_request
.headers
.get("x-codex-installation-id")
.is_some());
let upstream_body: Value = serde_json::from_slice(&compact_request.body).unwrap();
assert_eq!(upstream_body["model"], "gpt-5.5");
assert_eq!(upstream_body["instructions"], "compress the session");
assert_eq!(upstream_body["parallel_tool_calls"], false);
assert_eq!(
upstream_body["reasoning"],
json!({"effort": "high", "summary": "auto"})
);
assert_eq!(
upstream_body["tools"],
json!([{"type": "function", "name": "lookup"}])
);
assert_eq!(upstream_body["text"]["format"]["type"], "json_schema");
assert!(upstream_body.get("stream").is_none());
assert!(upstream_body.get("store").is_none());
assert!(upstream_body.get("prompt_cache_key").is_none());
assert_eq!(upstream_body["input"].as_array().unwrap().len(), 3);
assert!(upstream_body["input"][1].get("ignored").is_none());
assert_eq!(
upstream_body["input"][2]["encrypted_content"],
"enc_compact"
);
}
#[tokio::test]
async fn v1_responses_should_include_encrypted_reasoning_by_default() {
let server = MockServer::start().await;
@@ -562,6 +1007,148 @@ async fn v1_responses_should_include_encrypted_reasoning_by_default() {
assert_eq!(upstream_body["reasoning"]["summary"], "auto");
}
#[tokio::test]
async fn v1_responses_should_not_add_reasoning_include_when_client_include_is_non_empty() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(COMPLETED_REASONING_INCLUDE_SSE),
)
.mount(&server)
.await;
let imported = build_imported_app(server.uri()).await;
let response = imported
.app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header(
"authorization",
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"stream": false,
"use_websocket": false,
"input": [],
"reasoning": {"effort": "high"},
"include": ["file_search_call.results"]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let requests = server.received_requests().await.unwrap();
let upstream_body: Value = serde_json::from_slice(&requests[0].body).unwrap();
assert_eq!(
upstream_body["include"],
json!(["file_search_call.results"])
);
}
#[tokio::test]
async fn v1_responses_should_sanitize_reasoning_and_compaction_input_before_upstream() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(COMPLETED_USAGE_SSE),
)
.mount(&server)
.await;
let imported = build_imported_app(server.uri()).await;
let response = imported
.app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header(
"authorization",
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"stream": false,
"use_websocket": false,
"input": [
{
"type": "reasoning",
"id": "rs_1",
"status": "completed",
"summary": [
{"type": "summary_text", "text": "valid summary"},
{"type": "ignored", "text": "drop"}
],
"encrypted_content": "enc_reasoning",
"content": [
{"type": "reasoning_text", "text": "valid reasoning"},
{"type": "ignored", "text": "drop"}
],
"extra": "drop"
},
{
"type": "reasoning",
"id": "",
"summary": [{"type": "summary_text", "text": "drop"}]
},
{
"type": "compaction",
"id": "cmp_1",
"encrypted_content": "enc_compaction",
"extra": "drop"
},
{"type": "compaction", "id": "cmp_drop"},
{"type": "message", "role": "user", "content": "keep me", "extra": 42}
]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let requests = server.received_requests().await.unwrap();
let upstream_body: Value = serde_json::from_slice(&requests[0].body).unwrap();
assert_eq!(
upstream_body["input"],
json!([
{
"type": "reasoning",
"id": "rs_1",
"summary": [{"type": "summary_text", "text": "valid summary"}],
"status": "completed",
"encrypted_content": "enc_reasoning",
"content": [{"type": "reasoning_text", "text": "valid reasoning"}]
},
{
"type": "compaction",
"encrypted_content": "enc_compaction",
"id": "cmp_1"
},
{"type": "message", "role": "user", "content": "keep me", "extra": 42}
])
);
}
#[tokio::test]
async fn v1_responses_should_reconstruct_non_stream_output_text_from_sse_deltas() {
let server = MockServer::start().await;
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -50,6 +50,9 @@ fn test_config() -> AppConfig {
request_interval_ms: 50,
rotation_strategy: "least_used".to_string(),
tier_priority: Vec::new(),
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
oauth_auth_endpoint: "https://auth.openai.com/oauth/authorize".to_string(),
oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(),
},
quota: QuotaConfig {
refresh_interval_minutes: 5,
@@ -72,6 +75,7 @@ fn test_config() -> AppConfig {
tls: TlsConfig {
force_http11: false,
},
ws_pool: Default::default(),
admin: AdminConfig {
session_ttl_minutes: 1440,
default_username: "admin".to_string(),
@@ -163,7 +167,7 @@ async fn v1_requires_client_api_key_not_admin_cookie() {
}
#[tokio::test]
async fn responses_route_rejects_non_codex_provider_models() {
async fn responses_route_rejects_unknown_models() {
let (app, api_key, _dir) = test_app_with_client_api_key().await;
let response = app
.oneshot(
@@ -172,7 +176,7 @@ async fn responses_route_rejects_non_codex_provider_models() {
.uri("/v1/responses")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(r#"{"model":"claude-3","input":[]}"#))
.body(Body::from(r#"{"model":"unknown-model","input":[]}"#))
.unwrap(),
)
.await
+50 -1
View File
@@ -19,6 +19,8 @@ const STREAM_ERROR_EVENT_SSE: &str =
include_str!("../fixtures/responses/http_sse/stream_error_event.sse");
const STREAM_FAILED_EVENT_SSE: &str =
include_str!("../fixtures/responses/http_sse/stream_failed_event.sse");
const STREAM_PREMATURE_CLOSE_SSE: &str =
include_str!("../fixtures/responses/http_sse/stream_premature_close.sse");
#[tokio::test]
async fn v1_responses_non_stream_should_return_upstream_error_when_sse_error_event_arrives() {
@@ -96,7 +98,7 @@ async fn v1_responses_non_stream_should_return_upstream_error_when_response_fail
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
let body = response_json(response).await;
assert_eq!(body["error"]["code"], "upstream_error");
assert!(body["error"]["message"]
@@ -192,3 +194,50 @@ async fn v1_responses_stream_should_passthrough_response_failed_and_log_failure(
assert_eq!(event.3, 502);
assert_eq!(event.4["failureEvent"], "response.failed");
}
#[tokio::test]
async fn v1_responses_stream_should_synthesize_response_failed_when_http_sse_closes_before_terminal(
) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(STREAM_PREMATURE_CLOSE_SSE),
)
.mount(&server)
.await;
let imported = build_imported_app(server.uri()).await;
enable_runtime_logging(&imported.app).await;
let response = imported
.app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header(
"authorization",
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.header("x-request-id", "req_stream_premature_http_sse")
.body(Body::from(
r#"{"model":"gpt-5.5","input":[],"stream":true,"use_websocket":false}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_text(response).await;
assert!(body.contains("event: response.output_text.delta"));
assert!(body.contains("event: response.failed"));
assert!(body.contains("stream_disconnected"));
let event = fetch_v1_event_log(&imported.pool, "req_stream_premature_http_sse").await;
assert_eq!(event.3, 502);
assert_eq!(event.4["failureEvent"], "response.failed");
assert_eq!(event.4["upstreamCode"], "stream_disconnected");
}
+801 -62
View File
@@ -3,26 +3,18 @@ use axum::{
http::{Request, StatusCode},
};
use chrono::{DateTime, Utc};
use secrecy::ExposeSecret;
use serde_json::{json, Value};
use serde_json::json;
use tower::ServiceExt;
use wiremock::{
matchers::{header, method, path},
Mock, MockServer, ResponseTemplate,
};
use codex_proxy_rs::{
codex::accounts::{cookies::repository::CookieRepository, repository::AccountRepository},
codex::gateway::oauth::RefreshFailure,
};
use codex_proxy_rs::codex::accounts::cookies::repository::CookieRepository;
use crate::support::{
response_json, response_text,
upstream::{
build_imported_app_with_accounts, build_imported_app_with_refresher,
build_imported_app_with_token_refresher, enable_runtime_logging, FailingTokenRefresher,
ImportAccount,
},
upstream::{build_imported_app_with_accounts, ImportAccount},
};
const AFTER_429_SSE: &str = include_str!("../fixtures/responses/http_sse/after_429.sse");
@@ -32,9 +24,20 @@ const AFTER_402_SSE: &str = include_str!("../fixtures/responses/http_sse/after_4
const AFTER_403_SSE: &str = include_str!("../fixtures/responses/http_sse/after_403.sse");
const AFTER_CLOUDFLARE_403_SSE: &str =
include_str!("../fixtures/responses/http_sse/after_cloudflare_403.sse");
const AFTER_REFRESH_SSE: &str = include_str!("../fixtures/responses/http_sse/after_refresh.sse");
const STREAM_AFTER_REFRESH_SSE: &str =
include_str!("../fixtures/responses/http_sse/stream_after_refresh.sse");
const AFTER_401_FALLBACK_SSE: &str =
include_str!("../fixtures/responses/http_sse/after_401_fallback.sse");
const STREAM_AFTER_401_FALLBACK_SSE: &str =
include_str!("../fixtures/responses/http_sse/stream_after_401_fallback.sse");
const SSE_RESPONSE_FAILED_QUOTA: &str =
include_str!("../fixtures/responses/http_sse/response_failed_quota.sse");
const SSE_RESPONSE_FAILED_MODEL_UNSUPPORTED: &str =
include_str!("../fixtures/responses/http_sse/response_failed_model_unsupported.sse");
const AFTER_5XX_RETRY_SSE: &str =
include_str!("../fixtures/responses/http_sse/after_5xx_retry.sse");
const STREAM_AFTER_5XX_RETRY_SSE: &str =
include_str!("../fixtures/responses/http_sse/stream_after_5xx_retry.sse");
const AFTER_MODEL_UNSUPPORTED_RETRY_SSE: &str =
include_str!("../fixtures/responses/http_sse/after_model_unsupported_retry.sse");
#[tokio::test]
async fn v1_responses_should_retry_next_account_after_429_retry_after() {
@@ -211,6 +214,188 @@ async fn v1_responses_stream_should_retry_next_account_after_429_retry_after() {
assert!(body.contains("resp_stream_after_429"));
}
#[tokio::test]
async fn v1_responses_should_retry_same_account_after_5xx_before_fallback() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-a"))
.respond_with(ResponseTemplate::new(500).set_body_json(json!({
"error": {"message": "temporary upstream failure"}
})))
.up_to_n_times(2)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-a"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(AFTER_5XX_RETRY_SSE),
)
.mount(&server)
.await;
let imported = build_imported_app_with_accounts(
server.uri(),
&[
ImportAccount {
id: "acct_5xx_a",
account_id: "chatgpt-a",
token: "access-a",
refresh_token: "refresh-a",
},
ImportAccount {
id: "acct_5xx_b",
account_id: "chatgpt-b",
token: "access-b",
refresh_token: "refresh-b",
},
],
)
.await;
let response = imported
.app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header(
"authorization",
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.body(Body::from(
r#"{"model":"gpt-5.5","input":[],"stream":false}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["id"], "resp_after_5xx_retry");
let requests = server.received_requests().await.unwrap();
let access_a_posts = requests
.iter()
.filter(|request| {
request.method.as_str() == "POST"
&& request
.headers
.get("authorization")
.and_then(|value| value.to_str().ok())
== Some("Bearer access-a")
})
.count();
let access_b_posts = requests
.iter()
.filter(|request| {
request.method.as_str() == "POST"
&& request
.headers
.get("authorization")
.and_then(|value| value.to_str().ok())
== Some("Bearer access-b")
})
.count();
assert_eq!(access_a_posts, 3);
assert_eq!(access_b_posts, 0);
}
#[tokio::test]
async fn v1_responses_stream_should_retry_same_account_after_5xx_before_fallback() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-a"))
.respond_with(ResponseTemplate::new(500).set_body_json(json!({
"error": {"message": "temporary upstream failure"}
})))
.up_to_n_times(2)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-a"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(STREAM_AFTER_5XX_RETRY_SSE),
)
.mount(&server)
.await;
let imported = build_imported_app_with_accounts(
server.uri(),
&[
ImportAccount {
id: "acct_5xx_stream_a",
account_id: "chatgpt-a",
token: "access-a",
refresh_token: "refresh-a",
},
ImportAccount {
id: "acct_5xx_stream_b",
account_id: "chatgpt-b",
token: "access-b",
refresh_token: "refresh-b",
},
],
)
.await;
let response = imported
.app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header(
"authorization",
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.body(Body::from(
r#"{"model":"gpt-5.5","input":[],"stream":true}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_text(response).await;
assert!(body.contains("resp_stream_after_5xx_retry"));
let requests = server.received_requests().await.unwrap();
let access_a_posts = requests
.iter()
.filter(|request| {
request.method.as_str() == "POST"
&& request
.headers
.get("authorization")
.and_then(|value| value.to_str().ok())
== Some("Bearer access-a")
})
.count();
let access_b_posts = requests
.iter()
.filter(|request| {
request.method.as_str() == "POST"
&& request
.headers
.get("authorization")
.and_then(|value| value.to_str().ok())
== Some("Bearer access-b")
})
.count();
assert_eq!(access_a_posts, 3);
assert_eq!(access_b_posts, 0);
}
#[tokio::test]
async fn v1_responses_should_mark_quota_exhausted_after_402_and_retry_next_account() {
let server = MockServer::start().await;
@@ -282,6 +467,322 @@ async fn v1_responses_should_mark_quota_exhausted_after_402_and_retry_next_accou
assert_eq!(account_status.0, "quota_exhausted");
}
#[tokio::test]
async fn v1_responses_should_classify_non_stream_sse_failure_and_retry_next_account() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-a"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(SSE_RESPONSE_FAILED_QUOTA),
)
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-b"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(AFTER_402_SSE),
)
.mount(&server)
.await;
let imported = build_imported_app_with_accounts(
server.uri(),
&[
ImportAccount {
id: "acct_sse_failed_a",
account_id: "chatgpt-a",
token: "access-a",
refresh_token: "refresh-a",
},
ImportAccount {
id: "acct_sse_failed_b",
account_id: "chatgpt-b",
token: "access-b",
refresh_token: "refresh-b",
},
],
)
.await;
let response = imported
.app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header(
"authorization",
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.body(Body::from(
r#"{"model":"gpt-5.5","input":[],"stream":false}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["id"], "resp_after_402");
let first_status: (String,) = sqlx::query_as("select status from accounts where id = ?")
.bind("acct_sse_failed_a")
.fetch_one(&imported.pool)
.await
.unwrap();
assert_eq!(first_status.0, "quota_exhausted");
}
#[tokio::test]
async fn v1_responses_should_retry_next_account_when_model_not_supported() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-a"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!({
"error": {
"message": "Model gpt-5.5 is not supported on this account plan",
"code": "model_not_supported"
}
})))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-b"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(AFTER_MODEL_UNSUPPORTED_RETRY_SSE),
)
.mount(&server)
.await;
let imported = build_imported_app_with_accounts(
server.uri(),
&[
ImportAccount {
id: "acct_model_a",
account_id: "chatgpt-a",
token: "access-a",
refresh_token: "refresh-a",
},
ImportAccount {
id: "acct_model_b",
account_id: "chatgpt-b",
token: "access-b",
refresh_token: "refresh-b",
},
],
)
.await;
let response = imported
.app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header(
"authorization",
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.body(Body::from(
r#"{"model":"gpt-5.5","input":[],"stream":false,"use_websocket":false}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["id"], "resp_after_model_unsupported_retry");
let requests = server.received_requests().await.unwrap();
let access_a_posts = requests
.iter()
.filter(|request| {
request.method.as_str() == "POST"
&& request
.headers
.get("authorization")
.and_then(|value| value.to_str().ok())
== Some("Bearer access-a")
})
.count();
let access_b_posts = requests
.iter()
.filter(|request| {
request.method.as_str() == "POST"
&& request
.headers
.get("authorization")
.and_then(|value| value.to_str().ok())
== Some("Bearer access-b")
})
.count();
assert_eq!(access_a_posts, 1);
assert_eq!(access_b_posts, 1);
}
#[tokio::test]
async fn v1_responses_should_classify_sse_model_not_supported_and_retry_next_account() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-a"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(SSE_RESPONSE_FAILED_MODEL_UNSUPPORTED),
)
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-b"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(AFTER_MODEL_UNSUPPORTED_RETRY_SSE),
)
.mount(&server)
.await;
let imported = build_imported_app_with_accounts(
server.uri(),
&[
ImportAccount {
id: "acct_sse_model_a",
account_id: "chatgpt-a",
token: "access-a",
refresh_token: "refresh-a",
},
ImportAccount {
id: "acct_sse_model_b",
account_id: "chatgpt-b",
token: "access-b",
refresh_token: "refresh-b",
},
],
)
.await;
let response = imported
.app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header(
"authorization",
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.body(Body::from(
r#"{"model":"gpt-5.5","input":[],"stream":false,"use_websocket":false}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["id"], "resp_after_model_unsupported_retry");
}
#[tokio::test]
async fn v1_responses_should_retry_model_not_supported_only_once() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!({
"error": {
"message": "Model gpt-5.5 is not available on this account plan",
"code": "model_not_available"
}
})))
.up_to_n_times(2)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(AFTER_MODEL_UNSUPPORTED_RETRY_SSE),
)
.mount(&server)
.await;
let imported = build_imported_app_with_accounts(
server.uri(),
&[
ImportAccount {
id: "acct_model_once_a",
account_id: "chatgpt-a",
token: "access-a",
refresh_token: "refresh-a",
},
ImportAccount {
id: "acct_model_once_b",
account_id: "chatgpt-b",
token: "access-b",
refresh_token: "refresh-b",
},
ImportAccount {
id: "acct_model_once_c",
account_id: "chatgpt-c",
token: "access-c",
refresh_token: "refresh-c",
},
],
)
.await;
let response = imported
.app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header(
"authorization",
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.body(Body::from(
r#"{"model":"gpt-5.5","input":[],"stream":false,"use_websocket":false}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response_json(response).await;
assert_eq!(
body["error"]["message"],
"Codex upstream error: {\"error\":{\"code\":\"model_not_available\",\"message\":\"Model gpt-5.5 is not available on this account plan\"}}"
);
let requests = server.received_requests().await.unwrap();
let post_count = requests
.iter()
.filter(|request| request.method.as_str() == "POST")
.count();
assert_eq!(post_count, 2);
}
#[tokio::test]
async fn v1_responses_should_mark_banned_after_403_and_retry_next_account() {
let server = MockServer::start().await;
@@ -468,28 +969,53 @@ async fn v1_responses_should_cool_down_cloudflare_403_and_retry_next_account() {
}
#[tokio::test]
async fn v1_responses_should_refresh_after_401_and_retry_non_stream() {
async fn v1_responses_should_clear_cookies_and_retry_next_account_after_cloudflare_path_block_404()
{
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-secret"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"error": {"code": "token_revoked"}
})))
.and(header("authorization", "Bearer access-a"))
.respond_with(ResponseTemplate::new(404))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer refreshed-access"))
.and(header("authorization", "Bearer access-b"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(AFTER_REFRESH_SSE),
.set_body_string(AFTER_CLOUDFLARE_403_SSE),
)
.mount(&server)
.await;
let imported = build_imported_app_with_refresher(server.uri(), "refreshed-access").await;
let imported = build_imported_app_with_accounts(
server.uri(),
&[
ImportAccount {
id: "acct_cf_path_a",
account_id: "chatgpt-a",
token: "access-a",
refresh_token: "refresh-a",
},
ImportAccount {
id: "acct_cf_path_b",
account_id: "chatgpt-b",
token: "access-b",
refresh_token: "refresh-b",
},
],
)
.await;
let cookie_repo = CookieRepository::new(imported.pool.clone(), imported.secret_box.clone());
cookie_repo
.set_cookie_header("acct_cf_path_a", "cf_clearance=old")
.await
.unwrap();
cookie_repo
.set_cookie_header("acct_cf_path_b", "cf_clearance=keep")
.await
.unwrap();
let response = imported
.app
@@ -502,9 +1028,8 @@ async fn v1_responses_should_refresh_after_401_and_retry_non_stream() {
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.header("x-request-id", "req_refresh_non_stream")
.body(Body::from(
r#"{"model":"gpt-5.5","input":[],"stream":false}"#,
r#"{"model":"gpt-5.5","input":[],"stream":false,"use_websocket":false}"#,
))
.unwrap(),
)
@@ -513,39 +1038,133 @@ async fn v1_responses_should_refresh_after_401_and_retry_non_stream() {
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["id"], "resp_after_refresh");
let repo = AccountRepository::new(imported.pool.clone(), imported.secret_box);
let account = repo.get("acct_imported").await.unwrap().unwrap();
assert_eq!(account.access_token.expose_secret(), "refreshed-access");
assert_eq!(body["id"], "resp_after_cf");
assert_eq!(
account.refresh_token.unwrap().expose_secret(),
"refresh-secret"
cookie_repo
.cookie_header("acct_cf_path_a", "chatgpt.com")
.await
.unwrap(),
None
);
assert_eq!(
cookie_repo
.cookie_header("acct_cf_path_b", "chatgpt.com")
.await
.unwrap()
.as_deref(),
Some("cf_clearance=keep")
);
let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?")
.bind("acct_cf_path_a")
.fetch_one(&imported.pool)
.await
.unwrap();
assert_eq!(account_status.0, "active");
}
#[tokio::test]
async fn v1_responses_should_refresh_after_401_and_retry_stream() {
async fn v1_responses_should_disable_account_after_three_cloudflare_path_block_404s() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-secret"))
.and(header("authorization", "Bearer access-a"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let imported = build_imported_app_with_accounts(
server.uri(),
&[ImportAccount {
id: "acct_cf_path_disabled",
account_id: "chatgpt-a",
token: "access-a",
refresh_token: "refresh-a",
}],
)
.await;
let cookie_repo = CookieRepository::new(imported.pool.clone(), imported.secret_box.clone());
cookie_repo
.set_cookie_header("acct_cf_path_disabled", "cf_clearance=old")
.await
.unwrap();
for _ in 0..3 {
let response = imported
.app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header(
"authorization",
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.body(Body::from(
r#"{"model":"gpt-5.5","input":[],"stream":false,"use_websocket":false}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
}
assert_eq!(
cookie_repo
.cookie_header("acct_cf_path_disabled", "chatgpt.com")
.await
.unwrap(),
None
);
let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?")
.bind("acct_cf_path_disabled")
.fetch_one(&imported.pool)
.await
.unwrap();
assert_eq!(account_status.0, "disabled");
}
#[tokio::test]
async fn v1_responses_should_mark_expired_after_401_and_retry_next_account_non_stream() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-a"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"error": {"code": "token_revoked"}
"error": {"code": "token_revoked", "message": "token revoked"}
})))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer refreshed-stream-access"))
.and(header("authorization", "Bearer access-b"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(STREAM_AFTER_REFRESH_SSE),
.set_body_string(AFTER_401_FALLBACK_SSE),
)
.mount(&server)
.await;
let imported = build_imported_app_with_refresher(server.uri(), "refreshed-stream-access").await;
let imported = build_imported_app_with_accounts(
server.uri(),
&[
ImportAccount {
id: "acct_401_a",
account_id: "chatgpt-a",
token: "access-a",
refresh_token: "refresh-a",
},
ImportAccount {
id: "acct_401_b",
account_id: "chatgpt-b",
token: "access-b",
refresh_token: "refresh-b",
},
],
)
.await;
let response = imported
.app
@@ -558,9 +1177,81 @@ async fn v1_responses_should_refresh_after_401_and_retry_stream() {
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.header("x-request-id", "req_refresh_stream")
.header("x-request-id", "req_401_non_stream")
.body(Body::from(
r#"{"model":"gpt-5.5","input":[],"stream":true}"#,
r#"{"model":"gpt-5.5","input":[],"stream":false,"use_websocket":false}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["id"], "resp_after_401_fallback");
let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?")
.bind("acct_401_a")
.fetch_one(&imported.pool)
.await
.unwrap();
assert_eq!(account_status.0, "expired");
}
#[tokio::test]
async fn v1_responses_should_mark_expired_after_401_and_retry_next_account_stream() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-a"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"error": {"code": "token_revoked", "message": "token revoked"}
})))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-b"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(STREAM_AFTER_401_FALLBACK_SSE),
)
.mount(&server)
.await;
let imported = build_imported_app_with_accounts(
server.uri(),
&[
ImportAccount {
id: "acct_401_stream_a",
account_id: "chatgpt-a",
token: "access-a",
refresh_token: "refresh-a",
},
ImportAccount {
id: "acct_401_stream_b",
account_id: "chatgpt-b",
token: "access-b",
refresh_token: "refresh-b",
},
],
)
.await;
let response = imported
.app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header(
"authorization",
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.header("x-request-id", "req_401_stream")
.body(Body::from(
r#"{"model":"gpt-5.5","input":[],"stream":true,"use_websocket":false}"#,
))
.unwrap(),
)
@@ -577,35 +1268,44 @@ async fn v1_responses_should_refresh_after_401_and_retry_stream() {
assert!(content_type.starts_with("text/event-stream"));
let body = response_text(response).await;
assert!(body.contains("event: response.completed"));
assert!(body.contains("resp_stream_after_401_fallback"));
let usage: (i64, i64, i64, i64) = sqlx::query_as(
"select request_count, input_tokens, output_tokens, cached_tokens from account_usage where account_id = ?",
)
.bind("acct_imported")
.bind("acct_401_stream_b")
.fetch_one(&imported.pool)
.await
.unwrap();
assert_eq!(usage, (1, 4, 2, 0));
let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?")
.bind("acct_401_stream_a")
.fetch_one(&imported.pool)
.await
.unwrap();
assert_eq!(account_status.0, "expired");
}
#[tokio::test]
async fn v1_responses_should_log_refresh_failure_after_401() {
async fn v1_responses_should_mark_expired_and_return_401_when_401_has_no_fallback() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-secret"))
.and(header("authorization", "Bearer access-a"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"error": {"code": "token_revoked"}
"error": {"code": "token_revoked", "message": "token revoked"}
})))
.mount(&server)
.await;
let imported = build_imported_app_with_token_refresher(
let imported = build_imported_app_with_accounts(
server.uri(),
FailingTokenRefresher {
failure: RefreshFailure::InvalidGrant,
},
&[ImportAccount {
id: "acct_401_single",
account_id: "chatgpt-a",
token: "access-a",
refresh_token: "refresh-a",
}],
)
.await;
enable_runtime_logging(&imported.app).await;
let response = imported
.app
@@ -618,8 +1318,10 @@ async fn v1_responses_should_log_refresh_failure_after_401() {
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.header("x-request-id", "req_refresh_failed")
.body(Body::from(r#"{"model":"gpt-5.5","input":[]}"#))
.header("x-request-id", "req_401_no_fallback")
.body(Body::from(
r#"{"model":"gpt-5.5","input":[],"use_websocket":false}"#,
))
.unwrap(),
)
.await
@@ -627,22 +1329,59 @@ async fn v1_responses_should_log_refresh_failure_after_401() {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?")
.bind("acct_imported")
.bind("acct_401_single")
.fetch_one(&imported.pool)
.await
.unwrap();
assert_eq!(account_status.0, "expired");
let refresh_event: (String, String, String) = sqlx::query_as(
"select level, message, metadata_json from event_logs where request_id = ? and kind = 'account.refresh'",
}
#[tokio::test]
async fn v1_responses_should_mark_banned_when_401_says_account_deactivated() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-a"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"error": {"message": "account deactivated"}
})))
.mount(&server)
.await;
let imported = build_imported_app_with_accounts(
server.uri(),
&[ImportAccount {
id: "acct_401_deactivated",
account_id: "chatgpt-a",
token: "access-a",
refresh_token: "refresh-a",
}],
)
.bind("req_refresh_failed")
.fetch_one(&imported.pool)
.await
.unwrap();
let metadata: Value = serde_json::from_str(&refresh_event.2).unwrap();
assert_eq!(refresh_event.0, "warn");
assert_eq!(refresh_event.1, "上游 401 后账户刷新失败");
assert_eq!(metadata["failure"], "invalidGrant");
assert_eq!(metadata["accountStatus"], "expired");
assert_eq!(metadata["trigger"], "upstream_401");
.await;
let response = imported
.app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header(
"authorization",
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.body(Body::from(
r#"{"model":"gpt-5.5","input":[],"use_websocket":false}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?")
.bind("acct_401_deactivated")
.fetch_one(&imported.pool)
.await
.unwrap();
assert_eq!(account_status.0, "banned");
}
+36 -1
View File
@@ -9,8 +9,20 @@ fn default_config_keeps_only_codex_backend() {
assert_eq!(cfg.model.default_model, "gpt-5.5");
assert_eq!(cfg.auth.refresh_margin_seconds, 300);
assert_eq!(cfg.auth.rotation_strategy, "least_used");
assert_eq!(cfg.auth.oauth_client_id, "app_EMoamEEZ73f0CkXaXp7hrann");
assert_eq!(
cfg.auth.oauth_auth_endpoint,
"https://auth.openai.com/oauth/authorize"
);
assert_eq!(
cfg.auth.oauth_token_endpoint,
"https://auth.openai.com/oauth/token"
);
assert_eq!(cfg.database.url, "sqlite://data/codex-proxy-rs.sqlite");
assert_eq!(cfg.security.master_key_file, "data/master.key");
assert!(cfg.ws_pool.enabled);
assert_eq!(cfg.ws_pool.max_age_ms, 3_300_000);
assert_eq!(cfg.ws_pool.max_per_account, 8);
}
#[test]
@@ -37,6 +49,9 @@ auth:
request_interval_ms: 50
rotation_strategy: least_used
tier_priority: []
oauth_client_id: app_test_client
oauth_auth_endpoint: https://auth.example.test/oauth/authorize
oauth_token_endpoint: https://auth.example.test/oauth/token
quota:
refresh_interval_minutes: 5
warning_thresholds:
@@ -56,6 +71,10 @@ security:
api_key_pepper_file: data/api-key-pepper.key
tls:
force_http11: false
ws_pool:
enabled: true
max_age_ms: 3300000
max_per_account: 8
admin:
session_ttl_minutes: 1440
logging:
@@ -74,6 +93,10 @@ server:
host: 0.0.0.0
logging:
directory: local-logs
ws_pool:
enabled: false
max_age_ms: 120000
max_per_account: 2
"#,
)
.unwrap();
@@ -85,12 +108,24 @@ logging:
assert_eq!(cfg.database.url, "sqlite://data/codex-proxy-rs.sqlite");
assert_eq!(cfg.model.default_model, "gpt-5.5");
assert_eq!(cfg.auth.max_concurrent_per_account, 3);
assert_eq!(cfg.auth.oauth_client_id, "app_test_client");
assert_eq!(
cfg.auth.oauth_auth_endpoint,
"https://auth.example.test/oauth/authorize"
);
assert_eq!(
cfg.auth.oauth_token_endpoint,
"https://auth.example.test/oauth/token"
);
assert!(!cfg.ws_pool.enabled);
assert_eq!(cfg.ws_pool.max_age_ms, 120_000);
assert_eq!(cfg.ws_pool.max_per_account, 2);
assert_eq!(cfg.logging.directory, "local-logs");
assert_eq!(cfg.logging.retention_days, 14);
}
#[test]
fn logging_config_rejects_removed_size_rotation_field() {
fn logging_config_rejects_unsupported_size_rotation_field() {
let err = serde_yml::from_str::<LoggingConfig>(
r#"
directory: logs
+5
View File
@@ -0,0 +1,5 @@
event: response.output_text.delta
data: {"type":"response.output_text.delta","delta":"{\"point\":{\"0\":1,\"1\":2}}"}
event: response.completed
data: {"response":{"id":"resp_chat_tuple","object":"response","status":"completed","output_text":"{\"point\":{\"0\":0,\"1\":0}}","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"{\"point\":{\"0\":1,\"1\":2}}"}]}],"usage":{"input_tokens":1,"output_tokens":1}}}
@@ -0,0 +1,2 @@
event: response.completed
data: {"response":{"id":"resp_after_401_fallback","object":"response","usage":{"input_tokens":3,"output_tokens":1}}}
+2
View File
@@ -0,0 +1,2 @@
event: response.completed
data: {"response":{"id":"resp_after_5xx_retry","object":"response","usage":{"input_tokens":7,"output_tokens":2}}}
@@ -0,0 +1,2 @@
event: response.completed
data: {"response":{"id":"resp_after_model_unsupported_retry","object":"response","usage":{"input_tokens":8,"output_tokens":2}}}
@@ -0,0 +1,5 @@
event: response.output_text.delta
data: {"type":"response.output_text.delta","delta":"{\"point\":{\"0\":1,\"1\":2}}"}
event: response.completed
data: {"response":{"id":"resp_tuple","object":"response","status":"completed","output_text":"{\"point\":{\"0\":0,\"1\":0}}","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"{\"point\":{\"0\":1,\"1\":2}}"}]}],"usage":{"input_tokens":1,"output_tokens":1}}}
+2
View File
@@ -0,0 +1,2 @@
event: response.completed
data: {"response":{"id":"resp_empty","object":"response","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":0}}}
@@ -0,0 +1,2 @@
event: response.failed
data: {"response":{"id":"resp_sse_model_unsupported","object":"response","status":"failed","error":{"code":"model_not_supported","message":"Model gpt-5.5 is not supported on this account plan"}}}
@@ -0,0 +1,2 @@
event: response.failed
data: {"response":{"id":"resp_sse_quota_failed","object":"response","status":"failed","error":{"code":"quota_exceeded","message":"quota exhausted"}}}
@@ -0,0 +1,5 @@
event: response.output_text.delta
data: {"delta":"ok"}
event: response.completed
data: {"response":{"id":"resp_stream_after_401_fallback","object":"response","usage":{"input_tokens":4,"output_tokens":2}}}
@@ -0,0 +1,5 @@
event: response.output_text.delta
data: {"delta":"ok"}
event: response.completed
data: {"response":{"id":"resp_stream_after_5xx_retry","object":"response","usage":{"input_tokens":7,"output_tokens":2}}}
@@ -0,0 +1,2 @@
event: response.output_text.delta
data: {"delta":"partial"}
@@ -0,0 +1,25 @@
{
"type": "response.completed",
"response": {
"id": "resp_implicit_resume_first",
"object": "response",
"output": [
{
"type": "reasoning",
"id": "rs_implicit_resume",
"status": "completed",
"summary": [
{
"type": "summary_text",
"text": "cached reasoning"
}
],
"encrypted_content": "enc_reasoning_replay"
}
],
"usage": {
"input_tokens": 8,
"output_tokens": 2
}
}
}
@@ -0,0 +1 @@
{"type":"response.failed","response":{"id":"resp_invalid_encrypted_content","object":"response","status":"failed","error":{"code":"invalid_encrypted_content","message":"Invalid encrypted content in reasoning replay"}},"error":{"code":"invalid_encrypted_content","message":"Invalid encrypted content in reasoning replay"}}
+1
View File
@@ -0,0 +1 @@
{"type":"response.output_text.delta","delta":"partial"}
@@ -0,0 +1,7 @@
{
"type": "error",
"error": {
"code": "previous_response_not_found",
"message": "Previous response with id resp_missing was not found"
}
}
@@ -0,0 +1,7 @@
{
"type": "error",
"error": {
"code": "invalid_request",
"message": "No tool output found for function call call_missing"
}
}
+6 -6
View File
@@ -66,14 +66,14 @@ async fn sqlite_schema_should_persist_session_affinity_function_call_ids() {
}
#[tokio::test]
async fn connect_sqlite_should_not_patch_legacy_tables() {
async fn connect_sqlite_should_not_patch_incomplete_existing_tables() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("legacy.sqlite");
let db = dir.path().join("incomplete.sqlite");
let url = format!("sqlite://{}", db.display());
let legacy_options = SqliteConnectOptions::from_str(&url)
let incomplete_options = SqliteConnectOptions::from_str(&url)
.unwrap()
.create_if_missing(true);
let legacy_pool = SqlitePool::connect_with(legacy_options).await.unwrap();
let incomplete_pool = SqlitePool::connect_with(incomplete_options).await.unwrap();
sqlx::raw_sql(
"
create table account_usage (
@@ -97,10 +97,10 @@ async fn connect_sqlite_should_not_patch_legacy_tables() {
);
",
)
.execute(&legacy_pool)
.execute(&incomplete_pool)
.await
.unwrap();
legacy_pool.close().await;
incomplete_pool.close().await;
let pool = connect_sqlite(&url).await.unwrap();
let window_column: Option<(String,)> = sqlx::query_as(
+4
View File
@@ -57,6 +57,9 @@ fn test_config(database_url: String) -> AppConfig {
request_interval_ms: 50,
rotation_strategy: "least_used".to_string(),
tier_priority: Vec::new(),
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
oauth_auth_endpoint: "https://auth.openai.com/oauth/authorize".to_string(),
oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(),
},
quota: QuotaConfig {
refresh_interval_minutes: 5,
@@ -77,6 +80,7 @@ fn test_config(database_url: String) -> AppConfig {
tls: TlsConfig {
force_http11: false,
},
ws_pool: Default::default(),
admin: AdminConfig {
session_ttl_minutes: 1440,
default_username: "admin".to_string(),
+61 -3
View File
@@ -13,8 +13,9 @@ use tokio::sync::Mutex;
use tower::ServiceExt;
use codex_proxy_rs::{
codex::gateway::oauth::{
DeviceCode, OAuthClient, OAuthError, RefreshFailure, TokenPair, TokenRefresher,
codex::gateway::{
fingerprint::model::Fingerprint,
oauth::{DeviceCode, OAuthClient, OAuthError, RefreshFailure, TokenPair, TokenRefresher},
},
config::{
AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig,
@@ -128,6 +129,9 @@ pub fn test_config(database_url: String) -> AppConfig {
request_interval_ms: 50,
rotation_strategy: "least_used".to_string(),
tier_priority: Vec::new(),
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
oauth_auth_endpoint: "https://auth.openai.com/oauth/authorize".to_string(),
oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(),
},
quota: QuotaConfig {
refresh_interval_minutes: 5,
@@ -148,6 +152,7 @@ pub fn test_config(database_url: String) -> AppConfig {
tls: TlsConfig {
force_http11: false,
},
ws_pool: Default::default(),
admin: AdminConfig {
session_ttl_minutes: 1440,
default_username: "admin".to_string(),
@@ -187,16 +192,44 @@ pub async fn admin_accounts_test_app_with_base_url(
db_name: &str,
key_byte: u8,
base_url: String,
) -> (Router, AppState, sqlx::SqlitePool, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join(db_name);
let url = format!("sqlite://{}", db.display());
let config = test_config_with_base_url(url, base_url);
admin_accounts_test_app_with_config(dir, config, key_byte).await
}
pub async fn admin_accounts_test_app_with_config(
dir: tempfile::TempDir,
config: AppConfig,
key_byte: u8,
) -> (Router, AppState, sqlx::SqlitePool, tempfile::TempDir) {
let pool = connect_sqlite(&config.database.url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let state =
AppState::with_pool_and_secret_box(config, pool.clone(), SecretBox::new([key_byte; 32]));
let app = build_router(state.clone());
(app, state, pool, dir)
}
pub async fn admin_accounts_test_app_with_base_url_and_fingerprint(
db_name: &str,
key_byte: u8,
base_url: String,
fingerprint: Fingerprint,
) -> (Router, AppState, sqlx::SqlitePool, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join(db_name);
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let state = AppState::with_pool_and_secret_box(
let state = AppState::with_pool_secret_api_key_hasher_and_fingerprint(
test_config_with_base_url(url, base_url),
pool.clone(),
SecretBox::new([key_byte; 32]),
ApiKeyHasher::new([key_byte; 32]),
fingerprint,
);
let app = build_router(state.clone());
(app, state, pool, dir)
@@ -226,6 +259,31 @@ where
(app, state, pool, dir)
}
pub async fn admin_accounts_test_app_with_base_url_and_refresher<C>(
db_name: &str,
key_byte: u8,
base_url: String,
token_refresher: C,
) -> (Router, AppState, sqlx::SqlitePool, tempfile::TempDir)
where
C: TokenRefresher,
{
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join(db_name);
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let state = AppState::with_pool_secret_api_key_hasher_and_token_refresher(
test_config_with_base_url(url, base_url),
pool.clone(),
SecretBox::new([key_byte; 32]),
ApiKeyHasher::new([key_byte; 32]),
token_refresher,
);
let app = build_router(state.clone());
(app, state, pool, dir)
}
pub async fn admin_accounts_test_app_with_oauth_client<C>(
db_name: &str,
key_byte: u8,
+49 -6
View File
@@ -9,6 +9,7 @@ use serde_json::{json, Value};
use tower::ServiceExt;
use codex_proxy_rs::{
codex::gateway::fingerprint::model::Fingerprint,
codex::gateway::oauth::{RefreshFailure, TokenPair, TokenRefresher},
config::{
AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig,
@@ -88,6 +89,9 @@ pub fn test_config(database_url: String, base_url: String) -> AppConfig {
request_interval_ms: 50,
rotation_strategy: "least_used".to_string(),
tier_priority: Vec::new(),
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
oauth_auth_endpoint: "https://auth.openai.com/oauth/authorize".to_string(),
oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(),
},
quota: QuotaConfig {
refresh_interval_minutes: 5,
@@ -108,6 +112,7 @@ pub fn test_config(database_url: String, base_url: String) -> AppConfig {
tls: TlsConfig {
force_http11: false,
},
ws_pool: Default::default(),
admin: AdminConfig {
session_ttl_minutes: 1440,
default_username: "admin".to_string(),
@@ -148,6 +153,34 @@ pub async fn build_imported_app_with_accounts_and_config(
base_url: String,
accounts: &[ImportAccount],
configure: impl FnOnce(&mut AppConfig),
) -> ImportedApp {
build_imported_app_with_accounts_config_and_fingerprint(base_url, accounts, configure, None)
.await
}
pub async fn build_imported_app_with_fingerprint(
base_url: String,
fingerprint: Fingerprint,
) -> ImportedApp {
build_imported_app_with_accounts_config_and_fingerprint(
base_url,
&[ImportAccount {
id: "acct_imported",
account_id: "chatgpt-account",
token: "access-secret",
refresh_token: "refresh-secret",
}],
|_| {},
Some(fingerprint),
)
.await
}
async fn build_imported_app_with_accounts_config_and_fingerprint(
base_url: String,
accounts: &[ImportAccount],
configure: impl FnOnce(&mut AppConfig),
fingerprint: Option<Fingerprint>,
) -> ImportedApp {
let tempdir = tempfile::tempdir().unwrap();
let db = tempdir.path().join("v1-upstream.sqlite");
@@ -163,12 +196,22 @@ pub async fn build_imported_app_with_accounts_and_config(
.unwrap();
let mut config = test_config(url, base_url);
configure(&mut config);
let app = build_router(AppState::with_pool_secret_and_api_key_hasher(
config,
pool.clone(),
secret_box.clone(),
hasher,
));
let state = match fingerprint {
Some(fingerprint) => AppState::with_pool_secret_api_key_hasher_and_fingerprint(
config,
pool.clone(),
secret_box.clone(),
hasher,
fingerprint,
),
None => AppState::with_pool_secret_and_api_key_hasher(
config,
pool.clone(),
secret_box.clone(),
hasher,
),
};
let app = build_router(state);
let accounts = accounts
.iter()
.map(|account| {