chore: clean project test layout

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-19 22:13:30 +08:00
co-authored by Codex
parent 8763187266
commit b8c14f5dd1
197 changed files with 17993 additions and 45589 deletions
Generated
-9
View File
@@ -456,15 +456,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "codex-proxy-rs"
version = "0.1.0"
dependencies = [
"codex-proxy-core",
"codex-proxy-runtime",
"codex-proxy-server",
]
[[package]]
name = "codex-proxy-runtime"
version = "0.1.0"
-19
View File
@@ -1,22 +1,3 @@
[package]
name = "codex-proxy-rs"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
rust-version.workspace = true
description = "A lean Rust Codex proxy backed only by ChatGPT/Codex accounts."
repository = "https://example.invalid/codex-proxy-rs"
autobins = false
[dev-dependencies]
codex-proxy-core = { path = "crates/core" }
codex-proxy-runtime = { path = "crates/runtime" }
codex-proxy-server = { path = "crates/server" }
[lints]
workspace = true
[workspace]
members = ["crates/*"]
resolver = "2"
+41 -29
View File
@@ -1,46 +1,58 @@
# codex-proxy-rs
Rust rewrite of Codex Proxy focused only on ChatGPT/Codex accounts and the Codex backend.
面向 ChatGPT/Codex 账号池的 Rust 代理服务,提供 OpenAI 兼容接口,并尽量贴近 Codex Desktop 的上游请求行为。
## Included
## 特性
- `/v1/chat/completions`
- `/v1/responses`
- `/v1/models`
- ChatGPT/Codex OAuth and refresh-token based account pool
- Codex Desktop-style TLS, headers, Cookies, and fingerprint updates
- SQLite storage with sqlx-managed schema
- Structured logs with pagination and file rotation
- OpenAI 兼容的 `/v1/chat/completions`、`/v1/responses` 和 `/v1/models`
- ChatGPT/Codex OAuth、device code、refresh token 账号接入
- 账号池轮转、限流恢复、会话亲和性和 WebSocket 上游
- Codex Desktop 风格的 headers、TLS、Cookie、fingerprint 和 reasoning replay
- SQLite 存储、密钥加密、结构化日志和管理端 API
## Excluded
## 快速开始
- OpenAI official API Key upstream
- Anthropic, Gemini, custom providers, OpenRouter-style routing
- Ollama bridge
- Per-account proxy assignment
- Electron app
- Legacy compatibility layers and old-project data migration scripts
```bash
cargo run -p codex-proxy-server
```
## Auth Model
默认读取根目录 `config.yaml`,并按需合并本地覆盖文件:
- Admin login uses admin password and HttpOnly session Cookie.
- Client API keys call `/v1/*` and start with `cpr_`.
- Codex account tokens are internal upstream credentials.
```text
local.yaml
local.yml
```
## Development
运行时数据默认写入 `.runtime`:
Runtime config is loaded from `config.yaml` in the project root, then optional
`local.yaml` / `local.yml`.
```text
.runtime/data/
.runtime/logs/
```
默认监听 `0.0.0.0:8080`。首次启动会初始化管理员账号,默认值来自 `config.yaml`,长期使用请在 `local.yaml` 中覆盖。
## 开发
```bash
cargo fmt --check
cargo clippy --all-targets --all-features --locked -- -D warnings
cargo test
cargo run
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
cargo test --workspace --all-targets
```
Health check:
## 架构
```bash
curl -s http://127.0.0.1:8080/health
```mermaid
flowchart LR
client[Client] --> server[server<br/>HTTP API]
server --> runtime[runtime<br/>服务编排]
runtime --> core[core<br/>领域模型和协议]
runtime --> adapters[adapters<br/>上游客户端和仓储]
adapters --> platform[platform<br/>配置、存储、加密、日志]
runtime --> platform
server --> assets[assets<br/>静态资源 fallback]
```
## License
MIT
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,179 @@
#[test]
fn update_manifest_should_extract_version_and_build_number() {
let update = codex_proxy_adapters::codex::fingerprint::parse_update_manifest(
r#"{"version":"26.700.111","build_number":"5002"}"#,
)
.expect("manifest should parse");
assert_eq!(update.app_version, "26.700.111");
assert_eq!(update.build_number, "5002");
}
#[tokio::test]
async fn fingerprint_repository_should_upsert_auto_update_record() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("fingerprints.sqlite");
let pool = codex_proxy_platform::storage::connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let repo = codex_proxy_adapters::codex::fingerprint::FingerprintRepository::new(pool.clone());
repo.upsert_auto_update("26.800.1", "6001", Some("147"))
.await
.expect("first upsert");
repo.upsert_auto_update("26.800.2", "6002", None)
.await
.expect("second upsert");
let stored = repo
.load_latest_auto_updated()
.await
.expect("load latest")
.expect("stored fingerprint");
let count: (i64,) =
sqlx::query_as("select count(*) from fingerprints where id = 'auto_updated'")
.fetch_one(&pool)
.await
.expect("count row");
assert_eq!(count.0, 1);
assert_eq!(stored.app_version, "26.800.2");
assert_eq!(stored.build_number, "6002");
assert_eq!(stored.chromium_version, "146");
}
#[tokio::test]
async fn fingerprint_updater_should_fetch_manifest_and_persist_history() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/desktop/update.json"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_raw(
r#"{"version":"26.700.111","build_number":"5002"}"#,
"application/json",
))
.mount(&server)
.await;
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("fingerprints.sqlite");
let pool = codex_proxy_platform::storage::connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let repo = codex_proxy_adapters::codex::fingerprint::FingerprintRepository::new(pool);
let updater = codex_proxy_adapters::codex::fingerprint::FingerprintUpdater::new(
reqwest::Client::new(),
repo.clone(),
format!("{}/desktop/update.json", server.uri()),
);
updater.poll_once().await.expect("poll once");
let latest = repo
.latest()
.await
.expect("latest row")
.expect("stored history");
assert_eq!(latest.app_version, "26.700.111");
assert_eq!(latest.build_number, "5002");
assert_eq!(
latest.source,
codex_proxy_adapters::codex::fingerprint::CODEX_DESKTOP_UPDATE_SOURCE
);
}
#[tokio::test]
async fn update_checker_should_report_available_update_from_appcast() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/appcast.xml"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_raw(
r#"
<rss>
<channel>
<item>
<enclosure url="https://example.invalid/download" sparkle:shortVersionString="26.900.1" sparkle:version="7001" />
</item>
</channel>
</rss>
"#,
"application/xml",
))
.mount(&server)
.await;
let checker = codex_proxy_adapters::codex::fingerprint::UpdateChecker::with_client(
None,
reqwest::Client::new(),
format!("{}/appcast.xml", server.uri()),
tempfile::tempdir()
.expect("temp dir")
.path()
.join("extracted-fingerprint.json"),
"26.800.1",
"6001",
);
let state = checker.check_for_update().await.expect("update state");
assert!(state.update_available);
assert_eq!(state.latest_version.as_deref(), Some("26.900.1"));
assert_eq!(state.latest_build.as_deref(), Some("7001"));
}
#[tokio::test]
async fn update_checker_should_apply_available_update_to_repository() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/appcast.xml"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_raw(
r#"
<rss>
<channel>
<item>
<enclosure url="https://example.invalid/download" sparkle:shortVersionString="26.900.1" sparkle:version="7001" />
</item>
</channel>
</rss>
"#,
"application/xml",
))
.mount(&server)
.await;
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("fingerprints.sqlite");
let pool = codex_proxy_platform::storage::connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let repo = codex_proxy_adapters::codex::fingerprint::FingerprintRepository::new(pool);
let extracted_path = dir.path().join("extracted-fingerprint.json");
std::fs::write(
&extracted_path,
r#"{"app_version":"26.900.1","build_number":"7001","chromium_version":"147"}"#,
)
.expect("write extracted fingerprint");
let checker = codex_proxy_adapters::codex::fingerprint::UpdateChecker::with_client(
Some(repo.clone()),
reqwest::Client::new(),
format!("{}/appcast.xml", server.uri()),
extracted_path,
"26.800.1",
"6001",
);
let applied = checker
.check_and_apply_update()
.await
.expect("apply update");
let stored = repo
.load_latest_auto_updated()
.await
.expect("load latest")
.expect("stored fingerprint");
assert!(applied);
assert_eq!(stored.app_version, "26.900.1");
assert_eq!(stored.build_number, "7001");
assert_eq!(stored.chromium_version, "147");
}
@@ -0,0 +1,722 @@
use super::*;
#[test]
fn ordered_codex_headers_should_preserve_fingerprint_priority_and_request_fields() {
let fingerprint = codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests();
let headers = client::build_ordered_codex_headers(
&fingerprint,
"access-token",
Some("acct-1"),
Some("turn-1"),
"req-1",
);
let keys = headers.keys().cloned().collect::<Vec<_>>();
assert_eq!(headers["authorization"], "Bearer access-token");
assert_eq!(headers["chatgpt-account-id"], "acct-1");
assert_eq!(headers["x-client-request-id"], "req-1");
assert_eq!(headers["x-codex-turn-state"], "turn-1");
assert_eq!(headers["accept"], "text/event-stream");
assert_eq!(keys.first().map(String::as_str), Some("authorization"));
}
#[test]
fn websocket_connection_should_preserve_endpoint_and_header_order() {
let connection = codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::new(
"wss://chatgpt.com/backend-api/codex",
vec![
("authorization".to_string(), "Bearer token".to_string()),
("user-agent".to_string(), "Codex Desktop/test".to_string()),
],
);
assert_eq!(
(
connection.endpoint(),
connection.opening_audit_snapshot().header_order,
),
(
"wss://chatgpt.com/backend-api/codex",
vec!["authorization".to_string(), "user-agent".to_string()],
)
);
}
#[test]
fn websocket_connection_opening_audit_should_redact_sensitive_headers() {
let connection = codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::new(
"wss://chatgpt.com/backend-api/codex/responses?source=audit",
vec![
(
"authorization".to_string(),
"Bearer access-secret".to_string(),
),
("chatgpt-account-id".to_string(), "acct-secret".to_string()),
("user-agent".to_string(), "Codex Desktop/test".to_string()),
("x-client-request-id".to_string(), "req-secret".to_string()),
(
"x-codex-turn-metadata".to_string(),
"{\"secret\":true}".to_string(),
),
],
);
let snapshot = connection.opening_audit_snapshot();
assert_eq!(
snapshot.request_line,
"GET /backend-api/codex/responses?source=audit HTTP/1.1"
);
assert_eq!(
snapshot
.headers
.iter()
.map(|header| (header.name.as_str(), header.value.as_str()))
.collect::<Vec<_>>(),
vec![
("authorization", "<redacted>"),
("chatgpt-account-id", "<redacted>"),
("user-agent", "Codex Desktop/test"),
("x-client-request-id", "<redacted>"),
("x-codex-turn-metadata", "<redacted>"),
]
);
}
#[test]
fn websocket_connection_should_build_standard_opening_headers_around_business_headers() {
let connection =
codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses(
"https://chatgpt.com/backend-api",
"test-websocket-key",
vec![
(
"authorization".to_string(),
"Bearer access-token".to_string(),
),
("user-agent".to_string(), "Codex Desktop/test".to_string()),
(
"openai-beta".to_string(),
"responses_websockets=2026-02-06".to_string(),
),
],
);
let snapshot = connection.opening_audit_snapshot();
assert_eq!(
connection.endpoint(),
"wss://chatgpt.com/backend-api/codex/responses"
);
assert_eq!(
snapshot.header_order,
vec![
"Host",
"Connection",
"Upgrade",
"Sec-WebSocket-Version",
"Sec-WebSocket-Key",
"authorization",
"user-agent",
"openai-beta",
"sec-websocket-extensions",
]
);
assert_eq!(
snapshot
.headers
.iter()
.map(|header| (header.name.as_str(), header.value.as_str()))
.collect::<Vec<_>>(),
vec![
("Host", "chatgpt.com"),
("Connection", "Upgrade"),
("Upgrade", "websocket"),
("Sec-WebSocket-Version", "13"),
("Sec-WebSocket-Key", "test-websocket-key"),
("authorization", "<redacted>"),
("user-agent", "Codex Desktop/test"),
("openai-beta", "responses_websockets=2026-02-06"),
(
"sec-websocket-extensions",
"permessage-deflate; client_max_window_bits"
),
]
);
}
#[tokio::test]
#[expect(
clippy::result_large_err,
reason = "tokio-tungstenite handshake callbacks use a large error response type"
)]
async fn websocket_execute_response_create_request_should_capture_handshake_headers() {
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_hdr_async(stream, |_request: &WsRequest, mut response: WsResponse| {
response
.headers_mut()
.insert("x-codex-turn-state", "turn-from-handshake".parse().unwrap());
response.headers_mut().insert(
"set-cookie",
"cf_clearance=ws; Domain=.chatgpt.com; Path=/"
.parse()
.unwrap(),
);
response
.headers_mut()
.insert("x-ratelimit-remaining-requests", "41".parse().unwrap());
Ok(response)
})
.await
.unwrap();
let _message = websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Text(
json!({
"type": "response.completed",
"response": {
"id": "resp_ws_headers",
"object": "response",
"usage": {
"input_tokens": 1,
"output_tokens": 1,
"total_tokens": 2
}
}
})
.to_string()
.into(),
))
.await
.unwrap();
websocket.close(None).await.unwrap();
});
let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"be brief",
Vec::new(),
);
let prepared =
codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request(
&format!("http://{addr}"),
"dGhlIHNhbXBsZSBub25jZQ==",
vec![("authorization".to_string(), "Bearer access-token".to_string())],
&request,
)
.expect("payload should serialize");
let response =
codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared)
.await
.expect("websocket exchange should succeed");
server.await.unwrap();
assert_eq!(response.turn_state.as_deref(), Some("turn-from-handshake"));
assert_eq!(
response.set_cookie_headers,
vec!["cf_clearance=ws; Domain=.chatgpt.com; Path=/".to_string()]
);
assert!(response
.rate_limit_headers
.iter()
.any(|(name, value)| name == "x-ratelimit-remaining-requests" && value == "41"));
}
#[tokio::test]
#[expect(
clippy::result_large_err,
reason = "tokio-tungstenite handshake callbacks use a large error response type"
)]
async fn codex_backend_client_websocket_should_forward_security_chain_headers_and_payload_fields() {
let received_headers = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let headers_for_server = Arc::clone(&received_headers);
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket =
accept_hdr_async(stream, move |request: &WsRequest, response: WsResponse| {
let headers = request
.headers()
.iter()
.map(|(name, value)| {
(
name.as_str().to_string(),
value.to_str().unwrap_or_default().to_string(),
)
})
.collect::<Vec<_>>();
*headers_for_server.lock().unwrap() = headers;
Ok(response)
})
.await
.unwrap();
let message = websocket.next().await.unwrap().unwrap();
let payload = serde_json::from_str::<serde_json::Value>(&message.into_text().unwrap())
.expect("client payload should be json");
websocket
.send(Message::Text(
completed_websocket_response("resp_ws_security", 1, 1).into(),
))
.await
.unwrap();
websocket.close(None).await.unwrap();
payload
});
let mut request =
codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"be brief",
Vec::new(),
);
request.use_websocket = true;
request.prompt_cache_key = Some("client-thread".to_string());
request.client_metadata = Some(json!({
"safe": "yes",
"x-openai-subagent": "review",
"ignored_non_string": 42
}));
let backend = client::CodexBackendClient::new(
client::build_reqwest_client(false).unwrap(),
format!("http://{addr}"),
codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(),
);
let response = backend
.create_response(
&request,
client::CodexRequestContext {
access_token: "access-token",
account_id: Some("chatgpt-account"),
request_id: "req_ws_security",
turn_state: Some("turn-state"),
turn_metadata: Some("{\"thread_source\":\"subagent\"}"),
beta_features: Some("feature-a"),
include_timing_metrics: Some("true"),
version: Some("26.318.11754"),
codex_window_id: Some("cw_derived"),
parent_thread_id: Some("parent-456"),
cookie_header: None,
installation_id: Some("install-123"),
session_id: Some("cp_derived"),
},
)
.await
.expect("websocket response should succeed");
let payload = server.await.unwrap();
assert!(response.body.contains("resp_ws_security"));
assert_eq!(payload["prompt_cache_key"], "cp_derived");
assert_eq!(
payload["client_metadata"],
json!({
"safe": "yes",
"x-openai-subagent": "review",
"x-codex-installation-id": "install-123",
"x-codex-window-id": "cw_derived",
"x-codex-turn-metadata": "{\"thread_source\":\"subagent\"}",
"x-codex-parent-thread-id": "parent-456"
})
);
let headers = received_headers.lock().unwrap().clone();
assert!(headers
.iter()
.any(|(name, value)| name == "x-client-request-id" && value == "cp_derived"));
assert!(headers
.iter()
.any(|(name, value)| name == "x-openai-subagent" && value == "review"));
assert!(headers
.iter()
.any(|(name, value)| name == "session-id" && value == "cp_derived"));
assert!(headers
.iter()
.any(|(name, value)| name == "thread-id" && value == "cp_derived"));
assert!(headers.iter().all(|(name, _)| name != "content-type"));
assert!(headers.iter().all(|(name, _)| name != "accept"));
assert!(headers.iter().all(|(name, _)| name != "session_id"));
}
#[tokio::test]
async fn codex_backend_client_should_send_desktop_headers_and_capture_response_metadata() {
let server = wiremock::MockServer::start().await;
let sse_body = concat!(
"event: response.completed\n",
"data: {\"response\":{\"id\":\"resp_1\",\"usage\":{\"input_tokens\":2,\"output_tokens\":3,\"input_tokens_details\":{\"cached_tokens\":1}}}}\n",
"\n",
);
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/codex/responses"))
.and(wiremock::matchers::header(
"authorization",
"Bearer access-token",
))
.and(wiremock::matchers::header(
"chatgpt-account-id",
"chatgpt-account",
))
.and(wiremock::matchers::header("originator", "Codex Desktop"))
.and(wiremock::matchers::header("x-client-request-id", "req_1"))
.and(wiremock::matchers::header("x-codex-turn-state", "turn_1"))
.and(wiremock::matchers::header("cookie", "cf_clearance=old"))
.and(wiremock::matchers::body_json(json!({
"model": "gpt-5.5",
"instructions": "",
"input": [],
"stream": true,
"store": false
})))
.respond_with(
wiremock::ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.insert_header(
"set-cookie",
"cf_clearance=new; Domain=.chatgpt.com; Path=/",
)
.insert_header("x-codex-turn-state", "turn_2")
.set_body_string(sse_body),
)
.mount(&server)
.await;
let client = client::CodexBackendClient::new(
client::build_reqwest_client(false).unwrap(),
server.uri(),
codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(),
);
let mut request =
codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"",
Vec::new(),
);
request.force_http_sse = true;
let response = client
.create_response(
&request,
client::CodexRequestContext {
access_token: "access-token",
account_id: Some("chatgpt-account"),
request_id: "req_1",
turn_state: Some("turn_1"),
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: None,
session_id: None,
},
)
.await
.unwrap();
assert_eq!(
response.usage,
Some(codex_proxy_core::protocol::codex::events::TokenUsage {
input_tokens: 2,
output_tokens: 3,
cached_tokens: 1,
image_input_tokens: 0,
image_output_tokens: 0,
total_tokens: 5,
})
);
assert_eq!(response.turn_state.as_deref(), Some("turn_2"));
assert_eq!(
response.set_cookie_headers,
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 = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api/codex/usage"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({
"rate_limit": {
"limit_reached": false
}
})))
.mount(&server)
.await;
let client = client::CodexBackendClient::new(
client::build_reqwest_client(false).unwrap(),
server.uri(),
codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(),
);
let usage = client
.fetch_usage(client::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_eq!(
headers
.get("x-codex-installation-id")
.and_then(|value| value.to_str().ok()),
Some("install-1")
);
assert!(headers.get("session_id").is_none());
}
#[tokio::test]
async fn codex_backend_client_models_should_use_original_auxiliary_headers() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/codex/models"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({
"models": [
{"slug": "gpt-5.5", "title": "GPT 5.5"}
]
})))
.mount(&server)
.await;
let client = client::CodexBackendClient::new(
client::build_reqwest_client(false).unwrap(),
server.uri(),
codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(),
);
let models = client
.fetch_models(client::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_eq!(
headers
.get("x-codex-installation-id")
.and_then(|value| value.to_str().ok()),
Some("install-1")
);
assert!(headers.get("session_id").is_none());
}
#[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 =
codex_proxy_core::protocol::codex::responses::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 = client::CodexBackendClient::new(
client::build_reqwest_client(true).unwrap(),
format!("http://{addr}"),
codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(),
);
client
.create_response(
&request,
client::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 = read_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 = client::CodexBackendClient::new(
client::build_reqwest_client(true).unwrap(),
format!("http://{addr}"),
codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(),
);
client
.create_compact_response(
&codex_proxy_core::protocol::codex::responses::CodexCompactRequest {
model: "gpt-5.5".to_string(),
input: Vec::new(),
instructions: String::new(),
tools: None,
parallel_tool_calls: None,
reasoning: None,
text: None,
},
client::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 = read_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",
],
);
}
@@ -0,0 +1,208 @@
use super::*;
#[test]
fn endpoints_should_join_backend_paths() {
assert_eq!(
client::endpoint_url("https://api.example.com/", "/codex/responses"),
"https://api.example.com/codex/responses"
);
assert_eq!(
client::endpoint_request_path("https://api.example.com/backend-api", "/codex/usage"),
"/backend-api/codex/usage"
);
}
#[test]
fn custom_ca_should_report_environment_cache_key_consistently() {
const CASE_ENV: &str = "CODEX_PROXY_TEST_CUSTOM_CA_CACHE_KEY_CASE";
const SSL_CERT_PATH: &str = "/tmp/codex-proxy-ssl-cert-file.pem";
const CODEX_CA_PATH: &str = "/tmp/codex-proxy-codex-ca-certificate.pem";
if let Ok(case) = std::env::var(CASE_ENV) {
let expected = match case.as_str() {
"unset" => None,
"ssl_cert_file" => Some(format!("{}={SSL_CERT_PATH}", client::SSL_CERT_FILE_ENV)),
"codex_ca_priority" => Some(format!("{}={CODEX_CA_PATH}", client::CODEX_CA_CERT_ENV)),
_ => panic!("unknown custom CA cache key test case: {case}"),
};
assert_eq!(client::custom_ca_env_cache_key(), expected);
return;
}
let current_exe = std::env::current_exe().expect("current test binary path");
let cases = [
("unset", None, None),
(
"ssl_cert_file",
None,
Some((client::SSL_CERT_FILE_ENV, SSL_CERT_PATH)),
),
(
"codex_ca_priority",
Some((client::CODEX_CA_CERT_ENV, CODEX_CA_PATH)),
Some((client::SSL_CERT_FILE_ENV, SSL_CERT_PATH)),
),
];
for (case, codex_ca, ssl_cert_file) in cases {
let mut command = Command::new(&current_exe);
command
.arg("--exact")
.arg("custom_ca_should_report_environment_cache_key_consistently")
.arg("--nocapture")
.env(CASE_ENV, case)
.env_remove(client::CODEX_CA_CERT_ENV)
.env_remove(client::SSL_CERT_FILE_ENV);
if let Some((key, value)) = codex_ca {
command.env(key, value);
}
if let Some((key, value)) = ssl_cert_file {
command.env(key, value);
}
let output = command.output().expect("run isolated custom CA case");
assert!(
output.status.success(),
"isolated custom CA case {case} failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
}
#[tokio::test]
async fn codex_backend_client_probe_should_return_models_endpoint_status() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/codex/models"))
.respond_with(wiremock::ResponseTemplate::new(204))
.mount(&server)
.await;
let client = client::CodexBackendClient::new(
client::build_reqwest_client(false).unwrap(),
server.uri(),
codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(),
);
let probe = client
.probe_models_endpoint(client::CodexRequestContext {
access_token: "access-token",
account_id: Some("chatgpt-account"),
request_id: "req_probe",
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
.unwrap();
assert_eq!(probe.status, reqwest::StatusCode::NO_CONTENT);
assert!(probe
.endpoint
.ends_with("/codex/models?client_version=26.519.81530"));
}
#[tokio::test]
async fn codex_backend_client_should_cap_non_success_error_body_at_one_mib() {
let server = wiremock::MockServer::start().await;
let large_error_body = "x".repeat(1024 * 1024 + 17);
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/codex/responses"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string(large_error_body))
.mount(&server)
.await;
let client = client::CodexBackendClient::new(
client::build_reqwest_client(false).unwrap(),
server.uri(),
codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(),
);
let mut request =
codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"",
Vec::new(),
);
request.force_http_sse = true;
let result = client
.create_response(
&request,
client::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(client::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 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 = client::build_reqwest_client(false).unwrap();
first_client
.get(&url)
.send()
.await
.unwrap()
.text()
.await
.unwrap();
let second_client = client::build_reqwest_client(false).unwrap();
second_client
.get(&url)
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert!(server.await.unwrap());
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -13
View File
@@ -1,7 +1,7 @@
use chrono::Utc;
use codex_proxy_adapters::sqlite::{accounts::SqliteAccountStore, models::ModelSnapshotRepository};
use codex_proxy_core::accounts::{model::AccountStatus, ports::AccountStore};
use codex_proxy_core::models::model::{BackendModelEntry, ModelConfig, ModelPlanSnapshot};
use codex_proxy_core::models::model::{BackendModelEntry, ModelPlanSnapshot};
use codex_proxy_platform::crypto::SecretBox;
use secrecy::SecretString;
@@ -31,18 +31,6 @@ async fn model_snapshot_repository_should_replace_and_load_plan_snapshots() {
assert_eq!(loaded[0].models[0].source, "backend");
}
#[test]
fn model_config_should_be_usable_from_core_models() {
let config = ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: None,
service_tier: None,
aliases: Default::default(),
};
assert_eq!(config.default_model, "gpt-5.5");
}
#[tokio::test]
async fn sqlite_account_store_should_list_pool_accounts() {
let dir = tempfile::tempdir().expect("temp dir");
-60
View File
@@ -65,63 +65,3 @@ impl CloudflarePathBlockTracker {
self.count(account_id, now).await >= PATH_BLOCK_THRESHOLD
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn record_path_block_should_disable_after_three_recent_failures() {
let tracker = CloudflarePathBlockTracker::new();
let now = Utc::now();
assert_eq!(tracker.record_path_block("acct_1", now).await, 1);
assert_eq!(
tracker
.record_path_block("acct_1", now + Duration::seconds(1))
.await,
2
);
assert_eq!(
tracker
.record_path_block("acct_1", now + Duration::seconds(2))
.await,
3
);
assert!(
tracker
.should_disable("acct_1", now + Duration::seconds(2))
.await
);
}
#[tokio::test]
async fn record_path_block_should_ignore_stale_failures() {
let tracker = CloudflarePathBlockTracker::new();
let now = Utc::now();
assert_eq!(tracker.record_path_block("acct_1", now).await, 1);
assert_eq!(
tracker
.record_path_block("acct_1", now + Duration::hours(2))
.await,
1
);
}
#[tokio::test]
async fn reset_should_clear_account_count() {
let tracker = CloudflarePathBlockTracker::new();
let now = Utc::now();
tracker.record_path_block("acct_1", now).await;
tracker
.record_path_block("acct_1", now + Duration::seconds(1))
.await;
tracker.reset("acct_1").await;
assert_eq!(tracker.count("acct_1", now + Duration::seconds(2)).await, 0);
}
}
-44
View File
@@ -45,47 +45,3 @@ fn jwt_exp(token: &str) -> Option<i64> {
let value = serde_json::from_slice::<Value>(&decoded).ok()?;
value.get("exp")?.as_i64()
}
#[cfg(test)]
mod tests {
use base64::Engine;
use chrono::{TimeZone, Utc};
use serde_json::json;
use super::{jwt_expiration, jwt_expiry, JwtExpiry};
#[test]
fn jwt_expiry_should_detect_expired_valid_and_invalid_tokens() {
let now = Utc.with_ymd_and_hms(2026, 6, 14, 8, 0, 0).unwrap();
assert_eq!(
jwt_expiry(&test_jwt(now.timestamp() - 1), now),
JwtExpiry::Expired
);
assert_eq!(
jwt_expiry(&test_jwt(now.timestamp() + 1), now),
JwtExpiry::Valid
);
assert_eq!(jwt_expiry("not-a-jwt", now), JwtExpiry::MissingOrInvalid);
}
#[test]
fn jwt_expiration_should_return_exp_as_utc_datetime() {
let expires_at = Utc.with_ymd_and_hms(2026, 6, 14, 8, 0, 0).unwrap();
assert_eq!(
jwt_expiration(&test_jwt(expires_at.timestamp())),
Some(expires_at)
);
}
fn test_jwt(exp: i64) -> String {
let header = json!({"alg": "none", "typ": "JWT"});
let payload = json!({ "exp": exp });
format!("{}.{}.", jwt_part(&header), jwt_part(&payload))
}
fn jwt_part(value: &serde_json::Value) -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec(value).unwrap())
}
}
-85
View File
@@ -166,88 +166,3 @@ fn build_account_scoped_identity(kind: &str, account_scope: &str, client_value:
format!("{}_{}", prefix, truncated)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_conversation_identity_full() {
let identity = build_conversation_identity(
Some("client_conv_123"),
Some("client_win_456"),
"entry_789",
);
assert!(identity.conversation_id.is_some());
assert!(identity.window_id.is_some());
let conv_id = identity.conversation_id.unwrap();
assert!(conv_id.starts_with("cp_"));
assert_eq!(conv_id.len(), 3 + 32);
let win_id = identity.window_id.unwrap();
assert!(win_id.starts_with("cw_"));
}
#[test]
fn test_build_conversation_identity_fallback_window() {
let identity = build_conversation_identity(Some("client_conv_123"), None, "entry_789");
assert!(identity.conversation_id.is_some());
assert!(identity.window_id.is_some());
let conv_id = identity.conversation_id.as_ref().unwrap();
let win_id = identity.window_id.as_ref().unwrap();
assert_eq!(win_id, &format!("{}:0", conv_id));
}
#[test]
fn test_build_conversation_identity_empty() {
let identity = build_conversation_identity(None, None, "entry_789");
assert!(identity.conversation_id.is_none());
assert!(identity.window_id.is_none());
}
#[test]
fn test_build_account_scoped_identity_deterministic() {
let id1 = build_account_scoped_identity("conversation", "account_1", "client_key");
let id2 = build_account_scoped_identity("conversation", "account_1", "client_key");
assert_eq!(id1, id2);
assert!(id1.starts_with("cp_"));
}
#[test]
fn test_build_account_scoped_identity_different_accounts() {
let id1 = build_account_scoped_identity("conversation", "account_1", "client_key");
let id2 = build_account_scoped_identity("conversation", "account_2", "client_key");
assert_ne!(id1, id2);
}
#[test]
fn test_build_account_scoped_identity_window_prefix() {
let id = build_account_scoped_identity("window", "account_1", "win_123");
assert!(id.starts_with("cw_"));
}
#[test]
fn derive_stable_conversation_key_uses_first_user_text_after_system_reminder() {
let request = CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"be brief",
vec![serde_json::json!({
"role": "user",
"content": "<system-reminder>ignore</system-reminder>\nhello"
})],
);
let key = derive_stable_conversation_key(&request).unwrap();
assert_eq!(key.len(), 36);
assert_eq!(key.chars().filter(|ch| *ch == '-').count(), 4);
}
}
-30
View File
@@ -18,33 +18,3 @@ pub fn parse_installation_id(raw: &str) -> Option<String> {
Uuid::parse_str(trimmed).ok()?;
Some(trimmed.to_string())
}
#[cfg(test)]
mod tests {
use uuid::Uuid;
use super::*;
#[test]
fn parse_installation_id_should_trim_and_accept_uuid() {
let id = Uuid::new_v4().to_string();
let parsed = parse_installation_id(&format!(" {id}\n"));
assert_eq!(parsed, Some(id));
}
#[test]
fn parse_installation_id_should_reject_non_uuid() {
let parsed = parse_installation_id("not-a-uuid");
assert_eq!(parsed, None);
}
#[test]
fn generate_installation_id_should_return_uuid() {
let generated = generate_installation_id();
assert!(Uuid::parse_str(&generated).is_ok());
}
}
@@ -108,36 +108,3 @@ fn inline_function_call_ids(input: &[Value]) -> HashSet<String> {
.map(ToString::to_string)
.collect()
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn continuation_input_start_should_follow_last_assistant_message() {
let input = vec![
json!({"role": "user", "content": "first"}),
json!({"role": "assistant", "content": "cached"}),
json!({"role": "user", "content": "continue"}),
];
assert_eq!(continuation_input_start(&input), 2);
}
#[test]
fn implicit_resume_allowed_should_reject_unmatched_function_call_output() {
let input = vec![json!({
"type": "function_call_output",
"call_id": "missing",
"output": "tool output"
})];
assert!(!implicit_resume_allowed(
&input,
&input,
&["expected".to_string()]
));
}
}
@@ -295,36 +295,3 @@ fn estimate_items_bytes(items: &[Value]) -> usize {
fn entry_expired(created_at: DateTime<Utc>, ttl: Duration, now: DateTime<Utc>) -> bool {
now.signed_duration_since(created_at) >= ttl
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn reasoning_replay_cache_should_sanitize_reasoning_items() {
let mut cache = ReasoningReplayCache::new(Duration::minutes(55));
let saved = cache.record(
"resp_1".to_string(),
"acct_1",
"conv_1",
"variant_1",
&[json!({
"type": "reasoning",
"id": "rs_1",
"status": "completed",
"summary": [{"type": "summary_text", "text": "kept"}],
"encrypted_content": "enc",
"extra": "drop"
})],
Utc::now(),
);
let replay = cache.lookup("resp_1", "acct_1", "conv_1", "variant_1", Utc::now());
assert_eq!(saved, 1);
assert_eq!(replay[0].get("extra"), None);
assert_eq!(replay[0]["encrypted_content"], "enc");
}
}
+6 -534
View File
@@ -11,540 +11,12 @@ use codex_proxy_core::accounts::{
};
use serde_json::{json, Value};
#[test]
fn account_pool_should_respect_max_concurrent_slots_per_account() {
let mut pool = AccountPool::with_options(AccountPoolOptions {
max_concurrent_per_account: 1,
..AccountPoolOptions::default()
});
pool.insert(Account::test("acct_a", AccountStatus::Active));
pool.insert(Account::test("acct_b", AccountStatus::Active));
let first = pool.acquire("gpt-5.5").unwrap();
let second = pool.acquire("gpt-5.5").unwrap();
let third = pool.acquire("gpt-5.5");
assert_ne!(first.id, second.id);
assert!(third.is_none());
pool.release(&first.id);
assert_eq!(pool.acquire("gpt-5.5").unwrap().id, first.id);
}
#[test]
fn account_pool_should_rotate_round_robin_across_candidates() {
let mut pool = AccountPool::with_options(AccountPoolOptions {
max_concurrent_per_account: 1,
rotation_strategy: RotationStrategy::RoundRobin,
..AccountPoolOptions::default()
});
pool.insert(Account::test("acct_a", AccountStatus::Active));
pool.insert(Account::test("acct_b", AccountStatus::Active));
pool.insert(Account::test("acct_c", AccountStatus::Active));
assert_eq!(pool.acquire("gpt-5.5").unwrap().id, "acct_a");
assert_eq!(pool.acquire("gpt-5.5").unwrap().id, "acct_c");
assert_eq!(pool.acquire("gpt-5.5").unwrap().id, "acct_b");
assert!(pool.acquire("gpt-5.5").is_none());
}
#[test]
fn account_pool_should_skip_accounts_with_cached_quota_limit() {
let mut limited = Account::test("limited", AccountStatus::Active);
limited.quota_limit_reached = true;
let mut pool = AccountPool::default();
pool.insert(limited);
pool.insert(Account::test("usable", AccountStatus::Active));
assert_eq!(pool.acquire("gpt-5.5").unwrap().id, "usable");
}
#[test]
fn account_pool_should_reuse_quota_limited_accounts_after_cooldown() {
let now = fixed_time();
let mut pool = AccountPool::default();
pool.insert(Account::test("limited", AccountStatus::Active));
pool.mark_quota_limited_until("limited", now + Duration::seconds(30));
assert!(pool
.acquire_with(AccountAcquireRequest::new("gpt-5.5", now))
.is_none());
assert_eq!(
pool.acquire_with(AccountAcquireRequest::new(
"gpt-5.5",
now + Duration::seconds(31)
))
.unwrap()
.account
.id,
"limited"
);
}
#[test]
fn account_pool_should_not_shorten_existing_quota_cooldown() {
let now = fixed_time();
let mut pool = AccountPool::default();
pool.insert(Account::test("limited", AccountStatus::Active));
pool.mark_quota_limited_until("limited", now + Duration::seconds(180));
pool.mark_quota_limited_until("limited", now + Duration::seconds(60));
assert!(pool
.acquire_with(AccountAcquireRequest::new(
"gpt-5.5",
now + Duration::seconds(90)
))
.is_none());
}
#[test]
fn reset_usage_should_clear_runtime_counters_and_preserve_window_reset() {
let now = fixed_time();
let mut account = Account::test("acct_a", AccountStatus::Active);
account.request_count = 7;
account.empty_response_count = 2;
account.image_input_tokens = 11;
account.image_output_tokens = 13;
account.image_request_count = 3;
account.image_request_failed_count = 1;
account.window_request_count = 5;
account.window_input_tokens = 19;
account.window_output_tokens = 23;
account.window_cached_tokens = 29;
account.window_image_input_tokens = 31;
account.window_image_output_tokens = 37;
account.window_image_request_count = 2;
account.window_image_request_failed_count = 1;
account.window_reset_at = Some(now + Duration::seconds(300));
account.limit_window_seconds = Some(300);
account.last_used_at = Some("2026-06-12T12:00:00Z".to_string());
let mut pool = AccountPool::default();
pool.insert(account);
assert!(pool.reset_usage("acct_a"));
assert!(!pool.reset_usage("missing"));
let acquired = pool
.acquire_with(AccountAcquireRequest::new("gpt-5.5", now))
.unwrap()
.account;
assert_eq!(acquired.request_count, 1);
assert_eq!(acquired.empty_response_count, 0);
assert_eq!(acquired.image_input_tokens, 0);
assert_eq!(acquired.image_output_tokens, 0);
assert_eq!(acquired.image_request_count, 0);
assert_eq!(acquired.image_request_failed_count, 0);
assert_eq!(acquired.window_request_count, 1);
assert_eq!(acquired.window_input_tokens, 0);
assert_eq!(acquired.window_output_tokens, 0);
assert_eq!(acquired.window_cached_tokens, 0);
assert_eq!(acquired.window_image_input_tokens, 0);
assert_eq!(acquired.window_image_output_tokens, 0);
assert_eq!(acquired.window_image_request_count, 0);
assert_eq!(acquired.window_image_request_failed_count, 0);
assert_eq!(acquired.window_reset_at, Some(now + Duration::seconds(300)));
assert_ne!(
acquired.last_used_at.as_deref(),
Some("2026-06-12T12:00:00Z")
);
}
#[test]
fn account_pool_should_not_replace_known_window_length_with_cooldown_seconds() {
let now = Utc::now();
let mut account = Account::test("limited", AccountStatus::Active);
account.limit_window_seconds = Some(300);
let mut pool = AccountPool::default();
pool.insert(account);
pool.mark_quota_limited_until("limited", now + Duration::seconds(60));
let acquired = pool
.acquire_with(AccountAcquireRequest::new(
"gpt-5.5",
now + Duration::seconds(61),
))
.unwrap();
assert_eq!(acquired.account.limit_window_seconds, Some(300));
}
#[test]
fn acquire_should_skip_accounts_with_expired_token_metadata() {
let now = fixed_time();
let mut expired = Account::test("expired", AccountStatus::Active);
expired.access_token_expires_at = Some(now - Duration::seconds(1));
let mut pool = AccountPool::default();
pool.insert(expired);
let acquired = pool.acquire_with(AccountAcquireRequest::new("gpt-5.5", now));
assert!(acquired.is_none());
}
#[test]
fn acquire_should_skip_accounts_with_expired_jwt_when_metadata_is_missing() {
let now = Utc::now();
let mut expired = Account::test("expired", AccountStatus::Active);
expired.access_token = test_jwt(-60);
expired.access_token_expires_at = None;
let mut pool = AccountPool::default();
pool.insert(expired);
let acquired = pool.acquire_with(AccountAcquireRequest::new("gpt-5.5", now));
assert!(acquired.is_none());
}
#[test]
fn acquire_should_refresh_expired_cooldowns_before_selecting_account() {
let now = fixed_time();
let mut account = Account::test("acct_a", AccountStatus::Active);
account.quota_limit_reached = true;
account.quota_cooldown_until = Some(now - Duration::seconds(1));
account.cloudflare_cooldown_until = Some(now - Duration::seconds(1));
account.window_request_count = 7;
account.window_reset_at = Some(now - Duration::seconds(1));
account.limit_window_seconds = Some(60);
let mut pool = AccountPool::default();
pool.insert(account);
let acquired = pool
.acquire_with(AccountAcquireRequest::new("gpt-5.5", now))
.unwrap();
assert!(!acquired.account.quota_limit_reached);
assert!(acquired.account.quota_verify_required);
assert!(acquired.account.quota_cooldown_until.is_none());
assert!(acquired.account.cloudflare_cooldown_until.is_none());
assert_eq!(acquired.account.window_request_count, 1);
assert!(acquired
.account
.window_reset_at
.is_some_and(|reset| reset > now));
}
#[test]
fn account_pool_should_prefer_configured_tier_priority() {
let mut pool = AccountPool::with_options(AccountPoolOptions {
tier_priority: vec!["team".to_string(), "plus".to_string()],
..AccountPoolOptions::default()
});
let mut free = Account::test("free", AccountStatus::Active);
free.plan_type = Some("free".to_string());
let mut team = Account::test("team", AccountStatus::Active);
team.plan_type = Some("team".to_string());
pool.insert(free);
pool.insert(team);
assert_eq!(pool.acquire("gpt-5.5").unwrap().id, "team");
}
#[test]
fn account_pool_should_filter_by_model_plan_allowlist() {
let mut model_plans = BTreeMap::new();
model_plans.insert("gpt-5.5".to_string(), vec!["plus".to_string()]);
let mut pool = AccountPool::with_options(AccountPoolOptions {
model_plan_allowlist: model_plans,
..AccountPoolOptions::default()
});
let mut free = Account::test("free", AccountStatus::Active);
free.plan_type = Some("free".to_string());
let mut plus = Account::test("plus", AccountStatus::Active);
plus.plan_type = Some("plus".to_string());
pool.insert(free);
pool.insert(plus);
assert_eq!(pool.acquire("gpt-5.5").unwrap().id, "plus");
}
#[test]
fn account_pool_should_exclude_requested_account_ids() {
let mut pool = AccountPool::default();
pool.insert(Account::test("acct_a", AccountStatus::Active));
pool.insert(Account::test("acct_b", AccountStatus::Active));
let acquired = pool
.acquire_with(
AccountAcquireRequest::new("gpt-5.5", fixed_time())
.with_exclude_account_ids(["acct_a"]),
)
.unwrap();
assert_eq!(acquired.account.id, "acct_b");
}
#[test]
fn account_pool_should_prefer_session_affinity_account_when_available() {
let mut pool = AccountPool::default();
pool.insert(Account::test("acct_a", AccountStatus::Active));
pool.insert(Account::test("acct_b", AccountStatus::Active));
let acquired = pool
.acquire_with(
AccountAcquireRequest::new("gpt-5.5", fixed_time()).with_preferred_account_id("acct_b"),
)
.unwrap();
assert_eq!(acquired.account.id, "acct_b");
}
#[test]
fn account_pool_should_skip_accounts_in_cloudflare_cooldown() {
let now = fixed_time();
let mut cooling = Account::test("cooling", AccountStatus::Active);
cooling.cloudflare_cooldown_until = Some(now + Duration::seconds(30));
let mut pool = AccountPool::default();
pool.insert(cooling);
pool.insert(Account::test("usable", AccountStatus::Active));
let acquired = pool
.acquire_with(AccountAcquireRequest::new("gpt-5.5", now))
.unwrap();
assert_eq!(acquired.account.id, "usable");
}
#[test]
fn account_pool_should_cleanup_stale_slots_before_acquire() {
let now = fixed_time();
let mut pool = AccountPool::with_options(AccountPoolOptions {
max_concurrent_per_account: 1,
stale_slot_ttl: Duration::minutes(5),
..AccountPoolOptions::default()
});
pool.insert(Account::test("acct_a", AccountStatus::Active));
assert!(pool
.acquire_with(AccountAcquireRequest::new("gpt-5.5", now))
.is_some());
let acquired = pool
.acquire_with(AccountAcquireRequest::new(
"gpt-5.5",
now + Duration::minutes(5) + Duration::seconds(1),
))
.unwrap();
assert_eq!(acquired.account.id, "acct_a");
}
#[test]
fn account_pool_should_return_previous_slot_time_for_request_staggering() {
let now = fixed_time();
let mut pool = AccountPool::with_options(AccountPoolOptions {
max_concurrent_per_account: 2,
..AccountPoolOptions::default()
});
pool.insert(Account::test("acct_a", AccountStatus::Active));
let first = pool
.acquire_with(AccountAcquireRequest::new("gpt-5.5", now))
.unwrap();
let second = pool
.acquire_with(AccountAcquireRequest::new(
"gpt-5.5",
now + Duration::milliseconds(250),
))
.unwrap();
assert!(first.previous_slot_at.is_none());
assert_eq!(second.previous_slot_at, Some(now));
}
#[test]
fn account_pool_should_report_capacity_summary() {
let now = fixed_time();
let mut pool = AccountPool::with_options(AccountPoolOptions {
max_concurrent_per_account: 2,
..AccountPoolOptions::default()
});
pool.insert(Account::test("acct_a", AccountStatus::Active));
pool.insert(Account::test("acct_b", AccountStatus::Disabled));
pool.acquire_with(AccountAcquireRequest::new("gpt-5.5", now));
let summary = pool.capacity_summary(now);
assert_eq!(summary.total_slots, 2);
assert_eq!(summary.used_slots, 1);
assert_eq!(summary.available_slots, 1);
}
#[test]
fn account_pool_should_rotate_tied_least_used_accounts() {
let mut pool = AccountPool::with_options(AccountPoolOptions {
max_concurrent_per_account: 1,
rotation_strategy: RotationStrategy::LeastUsed,
..AccountPoolOptions::default()
});
pool.insert(Account::test("acct_a", AccountStatus::Active));
pool.insert(Account::test("acct_b", AccountStatus::Active));
let first = pool.acquire("gpt-5.5").unwrap();
pool.release(&first.id);
let second = pool.acquire("gpt-5.5").unwrap();
assert_ne!(first.id, second.id);
}
#[test]
fn least_used_should_deprioritize_quota_limited_accounts_when_skip_is_disabled() {
let mut pool = AccountPool::with_options(AccountPoolOptions {
skip_quota_limited: false,
rotation_strategy: RotationStrategy::LeastUsed,
..AccountPoolOptions::default()
});
let mut limited = Account::test("limited", AccountStatus::Active);
limited.quota_limit_reached = true;
let mut usable = Account::test("usable", AccountStatus::Active);
usable.request_count = 100;
pool.insert(limited);
pool.insert(usable);
let acquired = pool.acquire("gpt-5.5").unwrap();
assert_eq!(acquired.id, "usable");
}
#[test]
fn least_used_should_prefer_earlier_rate_limit_window_reset() {
let now = fixed_time();
let mut pool = AccountPool::with_options(AccountPoolOptions {
rotation_strategy: RotationStrategy::LeastUsed,
..AccountPoolOptions::default()
});
let mut soon = Account::test("soon", AccountStatus::Active);
soon.window_reset_at = Some(now + Duration::seconds(30));
let mut later = Account::test("later", AccountStatus::Active);
later.window_reset_at = Some(now + Duration::seconds(300));
pool.insert(later);
pool.insert(soon);
let acquired = pool
.acquire_with(AccountAcquireRequest::new("gpt-5.5", now))
.unwrap();
assert_eq!(acquired.account.id, "soon");
}
#[test]
fn least_used_should_compare_request_count_when_only_one_account_has_window_reset() {
let now = fixed_time();
let mut pool = AccountPool::with_options(AccountPoolOptions {
rotation_strategy: RotationStrategy::LeastUsed,
..AccountPoolOptions::default()
});
let mut reset_known = Account::test("reset_known", AccountStatus::Active);
reset_known.window_reset_at = Some(now + Duration::seconds(30));
reset_known.request_count = 10;
let mut reset_unknown = Account::test("reset_unknown", AccountStatus::Active);
reset_unknown.request_count = 2;
pool.insert(reset_known);
pool.insert(reset_unknown);
let acquired = pool
.acquire_with(AccountAcquireRequest::new("gpt-5.5", now))
.unwrap();
assert_eq!(acquired.account.id, "reset_unknown");
}
#[test]
fn least_used_should_prefer_lower_runtime_request_count() {
let now = fixed_time();
let mut pool = AccountPool::with_options(AccountPoolOptions {
rotation_strategy: RotationStrategy::LeastUsed,
..AccountPoolOptions::default()
});
let mut busy = Account::test("busy", AccountStatus::Active);
busy.request_count = 10;
let mut quiet = Account::test("quiet", AccountStatus::Active);
quiet.request_count = 2;
pool.insert(busy);
pool.insert(quiet);
let acquired = pool
.acquire_with(AccountAcquireRequest::new("gpt-5.5", now))
.unwrap();
assert_eq!(acquired.account.id, "quiet");
}
#[test]
fn acquire_should_mark_runtime_usage_window() {
let now = fixed_time();
let mut pool = AccountPool::default();
let mut account = Account::test("acct_a", AccountStatus::Active);
account.limit_window_seconds = Some(60);
pool.insert(account);
let acquired = pool
.acquire_with(AccountAcquireRequest::new("gpt-5.5", now))
.unwrap();
assert_eq!(acquired.account.request_count, 1);
assert_eq!(acquired.account.window_request_count, 1);
assert_eq!(acquired.account.window_started_at, Some(now));
assert_eq!(
acquired.account.window_reset_at,
Some(now + Duration::seconds(60))
);
assert_eq!(
acquired.account.last_used_at.as_deref(),
Some(now.to_rfc3339().as_str())
);
}
#[test]
fn sync_rate_limit_window_should_reset_window_counters_when_drift_crosses_threshold() {
let now = fixed_time();
let mut account = Account::test("acct_a", AccountStatus::Active);
account.window_request_count = 5;
account.window_input_tokens = 100;
account.window_output_tokens = 40;
account.window_cached_tokens = 20;
account.window_reset_at = Some(now + Duration::seconds(60));
account.limit_window_seconds = Some(60);
let mut pool = AccountPool::default();
pool.insert(account);
pool.sync_rate_limit_window("acct_a", now + Duration::seconds(121), Some(60));
let acquired = pool
.acquire_with(AccountAcquireRequest::new("gpt-5.5", now))
.unwrap();
assert_eq!(acquired.account.window_request_count, 1);
assert_eq!(acquired.account.window_input_tokens, 0);
assert_eq!(acquired.account.window_output_tokens, 0);
assert_eq!(acquired.account.window_cached_tokens, 0);
assert_eq!(
acquired.account.window_reset_at,
Some(now + Duration::seconds(121))
);
}
#[test]
fn record_window_token_usage_should_accumulate_runtime_window_tokens() {
let now = fixed_time();
let mut pool = AccountPool::default();
pool.insert(Account::test("acct_a", AccountStatus::Active));
pool.record_window_token_usage(
"acct_a",
AccountWindowUsageDelta {
input_tokens: 7,
output_tokens: 4,
cached_tokens: 2,
..AccountWindowUsageDelta::default()
},
);
let acquired = pool
.acquire_with(AccountAcquireRequest::new("gpt-5.5", now))
.unwrap();
assert_eq!(acquired.account.window_input_tokens, 7);
assert_eq!(acquired.account.window_output_tokens, 4);
assert_eq!(acquired.account.window_cached_tokens, 2);
}
#[path = "account_pool/quota.rs"]
mod quota;
#[path = "account_pool/selection.rs"]
mod selection;
#[path = "account_pool/usage_window.rs"]
mod usage_window;
fn fixed_time() -> chrono::DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 6, 11, 8, 0, 0).unwrap()
+182
View File
@@ -0,0 +1,182 @@
use super::*;
#[test]
fn account_pool_should_skip_accounts_with_cached_quota_limit() {
let mut limited = Account::test("limited", AccountStatus::Active);
limited.quota_limit_reached = true;
let mut pool = AccountPool::default();
pool.insert(limited);
pool.insert(Account::test("usable", AccountStatus::Active));
assert_eq!(pool.acquire("gpt-5.5").unwrap().id, "usable");
}
#[test]
fn account_pool_should_reuse_quota_limited_accounts_after_cooldown() {
let now = fixed_time();
let mut pool = AccountPool::default();
pool.insert(Account::test("limited", AccountStatus::Active));
pool.mark_quota_limited_until("limited", now + Duration::seconds(30));
assert!(pool
.acquire_with(AccountAcquireRequest::new("gpt-5.5", now))
.is_none());
assert_eq!(
pool.acquire_with(AccountAcquireRequest::new(
"gpt-5.5",
now + Duration::seconds(31)
))
.unwrap()
.account
.id,
"limited"
);
}
#[test]
fn account_pool_should_not_shorten_existing_quota_cooldown() {
let now = fixed_time();
let mut pool = AccountPool::default();
pool.insert(Account::test("limited", AccountStatus::Active));
pool.mark_quota_limited_until("limited", now + Duration::seconds(180));
pool.mark_quota_limited_until("limited", now + Duration::seconds(60));
assert!(pool
.acquire_with(AccountAcquireRequest::new(
"gpt-5.5",
now + Duration::seconds(90)
))
.is_none());
}
#[test]
fn account_pool_should_not_replace_known_window_length_with_cooldown_seconds() {
let now = Utc::now();
let mut account = Account::test("limited", AccountStatus::Active);
account.limit_window_seconds = Some(300);
let mut pool = AccountPool::default();
pool.insert(account);
pool.mark_quota_limited_until("limited", now + Duration::seconds(60));
let acquired = pool
.acquire_with(AccountAcquireRequest::new(
"gpt-5.5",
now + Duration::seconds(61),
))
.unwrap();
assert_eq!(acquired.account.limit_window_seconds, Some(300));
}
#[test]
fn acquire_should_refresh_expired_cooldowns_before_selecting_account() {
let now = fixed_time();
let mut account = Account::test("acct_a", AccountStatus::Active);
account.quota_limit_reached = true;
account.quota_cooldown_until = Some(now - Duration::seconds(1));
account.cloudflare_cooldown_until = Some(now - Duration::seconds(1));
account.window_request_count = 7;
account.window_reset_at = Some(now - Duration::seconds(1));
account.limit_window_seconds = Some(60);
let mut pool = AccountPool::default();
pool.insert(account);
let acquired = pool
.acquire_with(AccountAcquireRequest::new("gpt-5.5", now))
.unwrap();
assert!(!acquired.account.quota_limit_reached);
assert!(acquired.account.quota_verify_required);
assert!(acquired.account.quota_cooldown_until.is_none());
assert!(acquired.account.cloudflare_cooldown_until.is_none());
assert_eq!(acquired.account.window_request_count, 1);
assert!(acquired
.account
.window_reset_at
.is_some_and(|reset| reset > now));
}
#[test]
fn account_pool_should_skip_accounts_in_cloudflare_cooldown() {
let now = fixed_time();
let mut cooling = Account::test("cooling", AccountStatus::Active);
cooling.cloudflare_cooldown_until = Some(now + Duration::seconds(30));
let mut pool = AccountPool::default();
pool.insert(cooling);
pool.insert(Account::test("usable", AccountStatus::Active));
let acquired = pool
.acquire_with(AccountAcquireRequest::new("gpt-5.5", now))
.unwrap();
assert_eq!(acquired.account.id, "usable");
}
#[test]
fn least_used_should_deprioritize_quota_limited_accounts_when_skip_is_disabled() {
let mut pool = AccountPool::with_options(AccountPoolOptions {
skip_quota_limited: false,
rotation_strategy: RotationStrategy::LeastUsed,
..AccountPoolOptions::default()
});
let mut limited = Account::test("limited", AccountStatus::Active);
limited.quota_limit_reached = true;
let mut usable = Account::test("usable", AccountStatus::Active);
usable.request_count = 100;
pool.insert(limited);
pool.insert(usable);
let acquired = pool.acquire("gpt-5.5").unwrap();
assert_eq!(acquired.id, "usable");
}
#[test]
fn least_used_should_prefer_earlier_rate_limit_window_reset() {
let now = fixed_time();
let mut pool = AccountPool::with_options(AccountPoolOptions {
rotation_strategy: RotationStrategy::LeastUsed,
..AccountPoolOptions::default()
});
let mut soon = Account::test("soon", AccountStatus::Active);
soon.window_reset_at = Some(now + Duration::seconds(30));
let mut later = Account::test("later", AccountStatus::Active);
later.window_reset_at = Some(now + Duration::seconds(300));
pool.insert(later);
pool.insert(soon);
let acquired = pool
.acquire_with(AccountAcquireRequest::new("gpt-5.5", now))
.unwrap();
assert_eq!(acquired.account.id, "soon");
}
#[test]
fn sync_rate_limit_window_should_reset_window_counters_when_drift_crosses_threshold() {
let now = fixed_time();
let mut account = Account::test("acct_a", AccountStatus::Active);
account.window_request_count = 5;
account.window_input_tokens = 100;
account.window_output_tokens = 40;
account.window_cached_tokens = 20;
account.window_reset_at = Some(now + Duration::seconds(60));
account.limit_window_seconds = Some(60);
let mut pool = AccountPool::default();
pool.insert(account);
pool.sync_rate_limit_window("acct_a", now + Duration::seconds(121), Some(60));
let acquired = pool
.acquire_with(AccountAcquireRequest::new("gpt-5.5", now))
.unwrap();
assert_eq!(acquired.account.window_request_count, 1);
assert_eq!(acquired.account.window_input_tokens, 0);
assert_eq!(acquired.account.window_output_tokens, 0);
assert_eq!(acquired.account.window_cached_tokens, 0);
assert_eq!(
acquired.account.window_reset_at,
Some(now + Duration::seconds(121))
);
}
+191
View File
@@ -0,0 +1,191 @@
use super::*;
#[test]
fn account_pool_should_respect_max_concurrent_slots_per_account() {
let mut pool = AccountPool::with_options(AccountPoolOptions {
max_concurrent_per_account: 1,
..AccountPoolOptions::default()
});
pool.insert(Account::test("acct_a", AccountStatus::Active));
pool.insert(Account::test("acct_b", AccountStatus::Active));
let first = pool.acquire("gpt-5.5").unwrap();
let second = pool.acquire("gpt-5.5").unwrap();
let third = pool.acquire("gpt-5.5");
assert_ne!(first.id, second.id);
assert!(third.is_none());
pool.release(&first.id);
assert_eq!(pool.acquire("gpt-5.5").unwrap().id, first.id);
}
#[test]
fn account_pool_should_rotate_round_robin_across_candidates() {
let mut pool = AccountPool::with_options(AccountPoolOptions {
max_concurrent_per_account: 1,
rotation_strategy: RotationStrategy::RoundRobin,
..AccountPoolOptions::default()
});
pool.insert(Account::test("acct_a", AccountStatus::Active));
pool.insert(Account::test("acct_b", AccountStatus::Active));
pool.insert(Account::test("acct_c", AccountStatus::Active));
assert_eq!(pool.acquire("gpt-5.5").unwrap().id, "acct_a");
assert_eq!(pool.acquire("gpt-5.5").unwrap().id, "acct_c");
assert_eq!(pool.acquire("gpt-5.5").unwrap().id, "acct_b");
assert!(pool.acquire("gpt-5.5").is_none());
}
#[test]
fn acquire_should_skip_accounts_with_expired_token_metadata() {
let now = fixed_time();
let mut expired = Account::test("expired", AccountStatus::Active);
expired.access_token_expires_at = Some(now - Duration::seconds(1));
let mut pool = AccountPool::default();
pool.insert(expired);
let acquired = pool.acquire_with(AccountAcquireRequest::new("gpt-5.5", now));
assert!(acquired.is_none());
}
#[test]
fn acquire_should_skip_accounts_with_expired_jwt_when_metadata_is_missing() {
let now = Utc::now();
let mut expired = Account::test("expired", AccountStatus::Active);
expired.access_token = test_jwt(-60);
expired.access_token_expires_at = None;
let mut pool = AccountPool::default();
pool.insert(expired);
let acquired = pool.acquire_with(AccountAcquireRequest::new("gpt-5.5", now));
assert!(acquired.is_none());
}
#[test]
fn account_pool_should_prefer_configured_tier_priority() {
let mut pool = AccountPool::with_options(AccountPoolOptions {
tier_priority: vec!["team".to_string(), "plus".to_string()],
..AccountPoolOptions::default()
});
let mut free = Account::test("free", AccountStatus::Active);
free.plan_type = Some("free".to_string());
let mut team = Account::test("team", AccountStatus::Active);
team.plan_type = Some("team".to_string());
pool.insert(free);
pool.insert(team);
assert_eq!(pool.acquire("gpt-5.5").unwrap().id, "team");
}
#[test]
fn account_pool_should_filter_by_model_plan_allowlist() {
let mut model_plans = BTreeMap::new();
model_plans.insert("gpt-5.5".to_string(), vec!["plus".to_string()]);
let mut pool = AccountPool::with_options(AccountPoolOptions {
model_plan_allowlist: model_plans,
..AccountPoolOptions::default()
});
let mut free = Account::test("free", AccountStatus::Active);
free.plan_type = Some("free".to_string());
let mut plus = Account::test("plus", AccountStatus::Active);
plus.plan_type = Some("plus".to_string());
pool.insert(free);
pool.insert(plus);
assert_eq!(pool.acquire("gpt-5.5").unwrap().id, "plus");
}
#[test]
fn account_pool_should_exclude_requested_account_ids() {
let mut pool = AccountPool::default();
pool.insert(Account::test("acct_a", AccountStatus::Active));
pool.insert(Account::test("acct_b", AccountStatus::Active));
let acquired = pool
.acquire_with(
AccountAcquireRequest::new("gpt-5.5", fixed_time())
.with_exclude_account_ids(["acct_a"]),
)
.unwrap();
assert_eq!(acquired.account.id, "acct_b");
}
#[test]
fn account_pool_should_prefer_session_affinity_account_when_available() {
let mut pool = AccountPool::default();
pool.insert(Account::test("acct_a", AccountStatus::Active));
pool.insert(Account::test("acct_b", AccountStatus::Active));
let acquired = pool
.acquire_with(
AccountAcquireRequest::new("gpt-5.5", fixed_time()).with_preferred_account_id("acct_b"),
)
.unwrap();
assert_eq!(acquired.account.id, "acct_b");
}
#[test]
fn account_pool_should_cleanup_stale_slots_before_acquire() {
let now = fixed_time();
let mut pool = AccountPool::with_options(AccountPoolOptions {
max_concurrent_per_account: 1,
stale_slot_ttl: Duration::minutes(5),
..AccountPoolOptions::default()
});
pool.insert(Account::test("acct_a", AccountStatus::Active));
assert!(pool
.acquire_with(AccountAcquireRequest::new("gpt-5.5", now))
.is_some());
let acquired = pool
.acquire_with(AccountAcquireRequest::new(
"gpt-5.5",
now + Duration::minutes(5) + Duration::seconds(1),
))
.unwrap();
assert_eq!(acquired.account.id, "acct_a");
}
#[test]
fn account_pool_should_rotate_tied_least_used_accounts() {
let mut pool = AccountPool::with_options(AccountPoolOptions {
max_concurrent_per_account: 1,
rotation_strategy: RotationStrategy::LeastUsed,
..AccountPoolOptions::default()
});
pool.insert(Account::test("acct_a", AccountStatus::Active));
pool.insert(Account::test("acct_b", AccountStatus::Active));
let first = pool.acquire("gpt-5.5").unwrap();
pool.release(&first.id);
let second = pool.acquire("gpt-5.5").unwrap();
assert_ne!(first.id, second.id);
}
#[test]
fn least_used_should_prefer_lower_runtime_request_count() {
let now = fixed_time();
let mut pool = AccountPool::with_options(AccountPoolOptions {
rotation_strategy: RotationStrategy::LeastUsed,
..AccountPoolOptions::default()
});
let mut busy = Account::test("busy", AccountStatus::Active);
busy.request_count = 10;
let mut quiet = Account::test("quiet", AccountStatus::Active);
quiet.request_count = 2;
pool.insert(busy);
pool.insert(quiet);
let acquired = pool
.acquire_with(AccountAcquireRequest::new("gpt-5.5", now))
.unwrap();
assert_eq!(acquired.account.id, "quiet");
}
@@ -0,0 +1,165 @@
use super::*;
#[test]
fn reset_usage_should_clear_runtime_counters_and_preserve_window_reset() {
let now = fixed_time();
let mut account = Account::test("acct_a", AccountStatus::Active);
account.request_count = 7;
account.empty_response_count = 2;
account.image_input_tokens = 11;
account.image_output_tokens = 13;
account.image_request_count = 3;
account.image_request_failed_count = 1;
account.window_request_count = 5;
account.window_input_tokens = 19;
account.window_output_tokens = 23;
account.window_cached_tokens = 29;
account.window_image_input_tokens = 31;
account.window_image_output_tokens = 37;
account.window_image_request_count = 2;
account.window_image_request_failed_count = 1;
account.window_reset_at = Some(now + Duration::seconds(300));
account.limit_window_seconds = Some(300);
account.last_used_at = Some("2026-06-12T12:00:00Z".to_string());
let mut pool = AccountPool::default();
pool.insert(account);
assert!(pool.reset_usage("acct_a"));
assert!(!pool.reset_usage("missing"));
let acquired = pool
.acquire_with(AccountAcquireRequest::new("gpt-5.5", now))
.unwrap()
.account;
assert_eq!(acquired.request_count, 1);
assert_eq!(acquired.empty_response_count, 0);
assert_eq!(acquired.image_input_tokens, 0);
assert_eq!(acquired.image_output_tokens, 0);
assert_eq!(acquired.image_request_count, 0);
assert_eq!(acquired.image_request_failed_count, 0);
assert_eq!(acquired.window_request_count, 1);
assert_eq!(acquired.window_input_tokens, 0);
assert_eq!(acquired.window_output_tokens, 0);
assert_eq!(acquired.window_cached_tokens, 0);
assert_eq!(acquired.window_image_input_tokens, 0);
assert_eq!(acquired.window_image_output_tokens, 0);
assert_eq!(acquired.window_image_request_count, 0);
assert_eq!(acquired.window_image_request_failed_count, 0);
assert_eq!(acquired.window_reset_at, Some(now + Duration::seconds(300)));
assert_ne!(
acquired.last_used_at.as_deref(),
Some("2026-06-12T12:00:00Z")
);
}
#[test]
fn account_pool_should_return_previous_slot_time_for_request_staggering() {
let now = fixed_time();
let mut pool = AccountPool::with_options(AccountPoolOptions {
max_concurrent_per_account: 2,
..AccountPoolOptions::default()
});
pool.insert(Account::test("acct_a", AccountStatus::Active));
let first = pool
.acquire_with(AccountAcquireRequest::new("gpt-5.5", now))
.unwrap();
let second = pool
.acquire_with(AccountAcquireRequest::new(
"gpt-5.5",
now + Duration::milliseconds(250),
))
.unwrap();
assert!(first.previous_slot_at.is_none());
assert_eq!(second.previous_slot_at, Some(now));
}
#[test]
fn account_pool_should_report_capacity_summary() {
let now = fixed_time();
let mut pool = AccountPool::with_options(AccountPoolOptions {
max_concurrent_per_account: 2,
..AccountPoolOptions::default()
});
pool.insert(Account::test("acct_a", AccountStatus::Active));
pool.insert(Account::test("acct_b", AccountStatus::Disabled));
pool.acquire_with(AccountAcquireRequest::new("gpt-5.5", now));
let summary = pool.capacity_summary(now);
assert_eq!(summary.total_slots, 2);
assert_eq!(summary.used_slots, 1);
assert_eq!(summary.available_slots, 1);
}
#[test]
fn least_used_should_compare_request_count_when_only_one_account_has_window_reset() {
let now = fixed_time();
let mut pool = AccountPool::with_options(AccountPoolOptions {
rotation_strategy: RotationStrategy::LeastUsed,
..AccountPoolOptions::default()
});
let mut reset_known = Account::test("reset_known", AccountStatus::Active);
reset_known.window_reset_at = Some(now + Duration::seconds(30));
reset_known.request_count = 10;
let mut reset_unknown = Account::test("reset_unknown", AccountStatus::Active);
reset_unknown.request_count = 2;
pool.insert(reset_known);
pool.insert(reset_unknown);
let acquired = pool
.acquire_with(AccountAcquireRequest::new("gpt-5.5", now))
.unwrap();
assert_eq!(acquired.account.id, "reset_unknown");
}
#[test]
fn acquire_should_mark_runtime_usage_window() {
let now = fixed_time();
let mut pool = AccountPool::default();
let mut account = Account::test("acct_a", AccountStatus::Active);
account.limit_window_seconds = Some(60);
pool.insert(account);
let acquired = pool
.acquire_with(AccountAcquireRequest::new("gpt-5.5", now))
.unwrap();
assert_eq!(acquired.account.request_count, 1);
assert_eq!(acquired.account.window_request_count, 1);
assert_eq!(acquired.account.window_started_at, Some(now));
assert_eq!(
acquired.account.window_reset_at,
Some(now + Duration::seconds(60))
);
assert_eq!(
acquired.account.last_used_at.as_deref(),
Some(now.to_rfc3339().as_str())
);
}
#[test]
fn record_window_token_usage_should_accumulate_runtime_window_tokens() {
let now = fixed_time();
let mut pool = AccountPool::default();
pool.insert(Account::test("acct_a", AccountStatus::Active));
pool.record_window_token_usage(
"acct_a",
AccountWindowUsageDelta {
input_tokens: 7,
output_tokens: 4,
cached_tokens: 2,
..AccountWindowUsageDelta::default()
},
);
let acquired = pool
.acquire_with(AccountAcquireRequest::new("gpt-5.5", now))
.unwrap();
assert_eq!(acquired.account.window_input_tokens, 7);
assert_eq!(acquired.account.window_output_tokens, 4);
assert_eq!(acquired.account.window_cached_tokens, 2);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,908 @@
use super::*;
#[test]
fn codex_websocket_payload_audit_snapshot_should_redact_user_content() {
let mut request = CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"private instructions",
vec![json!({
"role": "user",
"content": "private prompt",
})],
);
request.previous_response_id = Some("resp_secret".to_string());
request.reasoning = Some(json!({"effort": "medium"}));
request.tools = Some(vec![json!({
"type": "function",
"name": "private_tool",
"description": "private schema",
})]);
request.text = Some(json!({"format": {"type": "text"}}));
request.service_tier = Some("flex".to_string());
request.prompt_cache_key = Some("cache-secret".to_string());
request.generate = Some(false);
request.include = Some(vec!["reasoning.encrypted_content".to_string()]);
request.client_metadata = Some(json!({
"thread_id": "thread-secret",
"safe": "value",
}));
let snapshot = websocket_payload_audit_snapshot(&request);
assert_eq!(
snapshot.top_level_keys,
vec![
"type",
"model",
"instructions",
"previous_response_id",
"input",
"tools",
"tool_choice",
"parallel_tool_calls",
"reasoning",
"store",
"stream",
"include",
"service_tier",
"prompt_cache_key",
"text",
"generate",
"client_metadata",
]
);
assert_eq!(snapshot.body["type"], "response.create");
assert_eq!(snapshot.body["model"], "gpt-5.5");
assert_eq!(snapshot.body["stream"], true);
assert_eq!(snapshot.body["instructions"], "<redacted>");
assert_eq!(snapshot.body["input"], "<redacted>");
assert_eq!(snapshot.body["previous_response_id"], "<redacted>");
assert_eq!(snapshot.body["prompt_cache_key"], "<redacted>");
assert_eq!(snapshot.body["client_metadata"], "<redacted>");
assert_eq!(snapshot.body["tools"], "<redacted>");
}
#[test]
fn codex_websocket_response_create_payload_text_should_preserve_canonical_field_order() {
let mut request = CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"private capture instructions",
vec![json!({
"role": "user",
"content": "private capture prompt",
})],
);
request.prompt_cache_key = Some("session-1".to_string());
request.generate = Some(false);
request.client_metadata = Some(json!({
"thread_id": "capture-thread-secret",
"safe": "capture",
}));
let payload =
codex_proxy_core::protocol::codex::websocket::websocket_response_create_payload_text(
&request,
)
.expect("payload should serialize");
assert_substrings_appear_in_order(
&payload,
&[
"\"type\":\"response.create\"",
"\"model\":\"gpt-5.5\"",
"\"instructions\":\"private capture instructions\"",
"\"input\":",
"\"tools\":[]",
"\"tool_choice\":\"auto\"",
"\"parallel_tool_calls\":true",
"\"reasoning\":null",
"\"store\":false",
"\"stream\":true",
"\"include\":[]",
"\"prompt_cache_key\":\"session-1\"",
"\"generate\":false",
"\"client_metadata\":",
],
);
}
#[test]
fn codex_websocket_audit_artifact_should_record_transport_opening_and_payload() {
let mut request = CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"private instructions",
vec![json!({
"role": "user",
"content": "private prompt",
})],
);
request.previous_response_id = Some("resp_secret".to_string());
let opening = OpeningAuditSnapshot {
request_line: "GET /backend-api/codex/responses HTTP/1.1".to_string(),
header_order: vec!["Host".to_string(), "authorization".to_string()],
headers: vec![
OpeningAuditHeader {
name: "Host".to_string(),
value: "chatgpt.com".to_string(),
},
OpeningAuditHeader {
name: "authorization".to_string(),
value: "<redacted>".to_string(),
},
],
};
let payload = websocket_payload_audit_snapshot(&request);
let artifact = websocket_audit_artifact_from_attempt(&request, opening.clone(), payload);
assert_eq!(artifact.transport_mode, "websocket_required");
assert!(!artifact.fallback_allowed);
assert_eq!(artifact.opening, Some(opening));
assert_eq!(
artifact.payload.expect("payload").body["input"],
"<redacted>"
);
assert_eq!(artifact.error, None);
}
#[test]
fn codex_websocket_parity_diff_should_report_header_payload_and_error_differences() {
let current = codex_proxy_core::protocol::codex::websocket::WebSocketAuditArtifact {
transport_mode: "websocket_preferred".to_string(),
fallback_allowed: true,
opening: Some(OpeningAuditSnapshot {
request_line: "GET /codex/responses HTTP/1.1".to_string(),
header_order: vec!["Host".to_string(), "Connection".to_string()],
headers: vec![OpeningAuditHeader {
name: "Sec-WebSocket-Extensions".to_string(),
value: "permessage-deflate; client_max_window_bits".to_string(),
}],
}),
payload: Some(PayloadAuditSnapshot {
top_level_keys: vec!["type".to_string(), "model".to_string()],
body: json!({"type": "response.create"}),
}),
error: Some(WebSocketAuditErrorSnapshot {
classification: "opening_failed".to_string(),
message: "connection refused".to_string(),
}),
};
let reference = codex_proxy_core::protocol::codex::websocket::WebSocketAuditArtifact {
transport_mode: "websocket_required".to_string(),
fallback_allowed: false,
opening: Some(OpeningAuditSnapshot {
request_line: "GET /codex/responses?source=audit HTTP/1.1".to_string(),
header_order: vec!["Host".to_string(), "Authorization".to_string()],
headers: vec![OpeningAuditHeader {
name: "Sec-WebSocket-Extensions".to_string(),
value: "identity".to_string(),
}],
}),
payload: Some(PayloadAuditSnapshot {
top_level_keys: vec!["type".to_string(), "previous_response_id".to_string()],
body: json!({"type": "response.create"}),
}),
error: Some(WebSocketAuditErrorSnapshot {
classification: "timeout".to_string(),
message: "timed out".to_string(),
}),
};
let diff = websocket_parity_diff(&current, &reference);
assert!(diff.differences.iter().any(|difference| {
difference.path == "opening.header_order"
&& difference.current == json!(["Host", "Connection"])
&& difference.reference == json!(["Host", "Authorization"])
}));
assert!(diff
.differences
.iter()
.any(|difference| difference.path == "payload.top_level_keys"));
assert!(diff
.differences
.iter()
.any(|difference| difference.path == "error.classification"));
}
#[test]
fn codex_websocket_event_to_sse_frame_should_encode_public_events_only() {
let event = json!({
"type": "response.completed",
"response": {
"id": "resp_ws",
"object": "response",
"usage": {
"input_tokens": 4,
"output_tokens": 2,
"total_tokens": 6
}
}
})
.to_string();
let frame = websocket_event_to_sse_frame(&event).expect("public event should encode");
assert_eq!(
frame,
format!("event: response.completed\ndata: {event}\n\n")
);
assert!(websocket_event_to_sse_frame(r#"{"type":"codex.rate_limits"}"#).is_none());
assert!(websocket_event_to_sse_frame(r#"{"response":{}}"#).is_none());
assert!(websocket_event_to_sse_frame("not-json").is_none());
}
#[test]
fn codex_websocket_metadata_turn_state_should_extract_case_insensitive_header() {
let event = json!({
"type": "response.metadata",
"headers": {
"X-Codex-Turn-State": ["turn-from-metadata"]
}
})
.to_string();
assert_eq!(
websocket_metadata_turn_state(&event).as_deref(),
Some("turn-from-metadata")
);
}
#[test]
fn codex_websocket_terminal_event_should_match_completed_failed_and_error() {
assert!(is_terminal_websocket_event("response.completed"));
assert!(is_terminal_websocket_event("response.failed"));
assert!(is_terminal_websocket_event("error"));
assert!(!is_terminal_websocket_event("response.output_text.delta"));
}
#[test]
fn codex_websocket_error_frame_should_classify_rotatable_codes() {
let frame = json!({
"type": "response.failed",
"response": {
"error": {
"code": "usage_limit_reached",
"message": "Rate limit reached. Please try again in 11.054s."
}
}
})
.to_string();
let error =
classify_websocket_error_frame(&frame, WebSocketErrorClassificationProfile::OneShot)
.expect("rate limit frame should classify");
assert_eq!(error.status_code, 429);
}
#[test]
fn codex_websocket_error_frame_should_classify_upstream_gateway_special_codes() {
let cases = [
("quota_exhausted", 402),
("token_expired", 401),
("account_banned", 403),
("previous_response_not_found", 400),
("server_is_overloaded", 503),
("usage_not_included", 429),
];
for (code, expected_status) in cases {
let frame = json!({
"type": "response.failed",
"response": {
"error": {
"code": code,
"message": "classified by upstream gateway compatibility"
}
}
})
.to_string();
let error =
classify_websocket_error_frame(&frame, WebSocketErrorClassificationProfile::OneShot)
.expect("special gateway code should classify");
assert_eq!(error.status_code, expected_status, "code: {code}");
}
}
#[test]
fn codex_websocket_error_frame_should_honor_explicit_error_status() {
let frame = json!({
"type": "error",
"status_code": 403,
"error": {
"code": "forbidden"
}
})
.to_string();
let error =
classify_websocket_error_frame(&frame, WebSocketErrorClassificationProfile::OneShot)
.expect("explicit status should classify");
assert_eq!(error.status_code, 403);
}
#[test]
fn codex_websocket_error_frame_should_ignore_success_status_and_unmapped_error() {
let success_status = json!({
"type": "error",
"status": 200,
"error": {
"code": "informational"
}
})
.to_string();
let unmapped_error = json!({
"type": "error",
"error": {
"code": "not_a_rotatable_error"
}
})
.to_string();
assert!(classify_websocket_error_frame(
&success_status,
WebSocketErrorClassificationProfile::OneShot
)
.is_none());
assert!(classify_websocket_error_frame(
&unmapped_error,
WebSocketErrorClassificationProfile::OneShot
)
.is_none());
}
#[test]
fn codex_websocket_wrapped_error_headers_should_extract_retry_after() {
let frame = json!({
"type": "error",
"headers": {
"retry-after": ["37"]
},
"error": {
"code": "rate_limit_exceeded"
}
})
.to_string();
assert_eq!(
retry_after_seconds_from_wrapped_error_headers(&frame),
Some(37)
);
}
#[test]
fn codex_websocket_response_completed_parse_error_should_validate_shape() {
let frame = json!({
"type": "response.completed",
"response": {
"id": "resp_bad",
"usage": {
"input_tokens": "bad",
"output_tokens": 1,
"total_tokens": 1
}
}
})
.to_string();
let error = websocket_response_completed_parse_error(&frame)
.expect("invalid response.completed should report a parse error");
assert!(error.contains("failed to parse ResponseCompleted"));
}
#[test]
fn codex_websocket_response_completed_parse_error_should_reject_missing_id_and_incomplete_usage() {
let missing_id = json!({
"type": "response.completed",
"response": {
"object": "response",
"usage": {
"input_tokens": 1,
"output_tokens": 1,
"total_tokens": 2
}
}
})
.to_string();
let incomplete_usage = json!({
"type": "response.completed",
"response": {
"id": "resp_incomplete_usage",
"object": "response",
"usage": {
"input_tokens": 1,
"output_tokens": 1
}
}
})
.to_string();
assert!(websocket_response_completed_parse_error(&missing_id)
.expect("missing id should be rejected")
.contains("missing field"));
assert!(websocket_response_completed_parse_error(&incomplete_usage)
.expect("incomplete usage should be rejected")
.contains("missing field"));
}
#[test]
fn codex_websocket_event_shape_parse_error_should_detect_field_type_mismatches() {
let frame = json!({
"type": "response.output_text.delta",
"delta": 42
})
.to_string();
assert!(websocket_event_shape_parse_error(&frame));
}
#[test]
fn codex_websocket_response_events_should_detect_missing_required_fields() {
assert!(websocket_response_created_missing_response(
r#"{"type":"response.created"}"#
));
assert!(websocket_response_output_text_delta_missing_delta(
r#"{"type":"response.output_text.delta"}"#
));
}
#[test]
fn codex_websocket_output_item_events_should_detect_invalid_item_shape() {
assert!(websocket_output_item_event_missing_item(
r#"{"type":"response.output_item.added"}"#
));
assert!(websocket_output_item_event_non_object_item(
r#"{"type":"response.output_item.done","item":"bad"}"#
));
assert!(websocket_output_item_event_invalid_item_type_tag(
r#"{"type":"response.output_item.done","item":{}}"#
));
}
#[test]
fn codex_websocket_message_output_item_should_validate_required_fields() {
let frame = json!({
"type": "response.output_item.done",
"item": {
"type": "message",
"role": "assistant",
"content": [{
"type": "output_text"
}]
}
})
.to_string();
assert!(websocket_message_output_item_event_invalid_required_fields(
&frame
));
}
#[test]
fn codex_websocket_optional_null_fields_should_match_missing_field_behavior() {
let message = json!({
"type": "response.output_item.done",
"item": {
"type": "message",
"id": null,
"role": "assistant",
"phase": null,
"metadata": null,
"content": [{
"type": "output_text",
"text": "hello"
}]
}
})
.to_string();
let custom_tool = json!({
"type": "response.output_item.done",
"item": {
"type": "custom_tool_call",
"id": null,
"status": null,
"call_id": "call_1",
"name": "render",
"input": "{}"
}
})
.to_string();
let context_compaction = json!({
"type": "response.output_item.done",
"item": {
"type": "context_compaction",
"encrypted_content": null
}
})
.to_string();
assert!(websocket_event_to_sse_frame(&message).is_some());
assert!(websocket_event_to_sse_frame(&custom_tool).is_some());
assert!(websocket_event_to_sse_frame(&context_compaction).is_some());
}
#[test]
fn codex_websocket_response_control_events_should_detect_incomplete_and_missing_completed_response()
{
let incomplete = json!({
"type": "response.incomplete",
"response": {
"incomplete_details": {
"reason": "max_output_tokens"
}
}
})
.to_string();
assert_eq!(
websocket_incomplete_response_reason(&incomplete).as_deref(),
Some("max_output_tokens")
);
assert!(websocket_response_completed_missing_response(
r#"{"type":"response.completed"}"#
));
}
#[test]
fn codex_websocket_delta_events_should_validate_official_required_fields() {
assert!(websocket_delta_event_missing_official_required_fields(
r#"{"type":"response.custom_tool_call_input.delta","delta":"x"}"#
));
assert!(websocket_delta_event_missing_official_required_fields(
r#"{"type":"response.reasoning_summary_text.delta","delta":"x"}"#
));
assert!(websocket_delta_event_missing_official_required_fields(
r#"{"type":"response.reasoning_text.delta","delta":"x"}"#
));
}
#[test]
fn codex_websocket_output_item_metadata_and_agent_should_validate_required_fields() {
let invalid_metadata = json!({
"type": "response.output_item.done",
"item": {
"type": "message",
"metadata": {
"turn_id": 7
}
}
})
.to_string();
let invalid_agent = json!({
"type": "response.output_item.done",
"item": {
"type": "agent_message",
"author": "assistant",
"content": [{
"type": "input_text"
}]
}
})
.to_string();
assert!(websocket_output_item_event_invalid_metadata(
&invalid_metadata
));
assert!(websocket_agent_message_output_item_event_invalid_required_fields(&invalid_agent));
}
#[test]
fn codex_websocket_output_item_optional_fields_should_reject_upstream_gateway_edge_cases() {
let invalid_tool_search_call = json!({
"type": "response.output_item.done",
"item": {
"type": "tool_search_call",
"call_id": {},
"status": [],
"execution": "client",
"arguments": {}
}
})
.to_string();
let invalid_custom_tool_call = json!({
"type": "response.output_item.done",
"item": {
"type": "custom_tool_call",
"id": {},
"status": [],
"call_id": "call_1",
"name": "render",
"input": "{}"
}
})
.to_string();
let invalid_custom_tool_output = json!({
"type": "response.output_item.done",
"item": {
"type": "custom_tool_call_output",
"call_id": "call_1",
"name": {},
"output": "ok"
}
})
.to_string();
let invalid_tool_search_output = json!({
"type": "response.output_item.done",
"item": {
"type": "tool_search_output",
"call_id": {},
"status": "completed",
"execution": "server",
"tools": []
}
})
.to_string();
let invalid_image = json!({
"type": "response.output_item.done",
"item": {
"type": "image_generation_call",
"id": "img_1",
"status": "completed",
"revised_prompt": {},
"result": "base64"
}
})
.to_string();
let invalid_context_compaction = json!({
"type": "response.output_item.done",
"item": {
"type": "context_compaction",
"encrypted_content": {}
}
})
.to_string();
assert!(
websocket_tool_search_call_output_item_event_invalid_required_fields(
&invalid_tool_search_call
)
);
assert!(
websocket_custom_tool_call_output_item_event_invalid_required_fields(
&invalid_custom_tool_call
)
);
assert!(
websocket_custom_tool_call_output_payload_item_event_invalid_required_fields(
&invalid_custom_tool_output
)
);
assert!(
websocket_tool_search_output_item_event_invalid_required_fields(
&invalid_tool_search_output
)
);
assert!(
websocket_image_generation_call_output_item_event_invalid_required_fields(&invalid_image)
);
assert!(
websocket_compaction_output_item_event_invalid_required_fields(&invalid_context_compaction)
);
}
#[test]
fn codex_websocket_reasoning_and_function_items_should_validate_required_fields() {
let invalid_reasoning = json!({
"type": "response.output_item.done",
"item": {
"type": "reasoning",
"summary": [{
"type": "summary_text"
}]
}
})
.to_string();
let invalid_function_call = json!({
"type": "response.output_item.done",
"item": {
"type": "function_call",
"name": "tool",
"call_id": "call_1"
}
})
.to_string();
let invalid_function_output = json!({
"type": "response.output_item.done",
"item": {
"type": "function_call_output",
"call_id": "call_1",
"output": [{
"type": "input_text"
}]
}
})
.to_string();
assert!(websocket_reasoning_output_item_event_invalid_required_fields(&invalid_reasoning));
assert!(
websocket_function_call_output_item_event_invalid_required_fields(&invalid_function_call)
);
assert!(
websocket_function_call_output_payload_item_event_invalid_required_fields(
&invalid_function_output
)
);
}
#[test]
fn codex_websocket_custom_and_tool_search_items_should_validate_required_fields() {
let invalid_custom_call = json!({
"type": "response.output_item.done",
"item": {
"type": "custom_tool_call",
"call_id": "call_1",
"name": "custom"
}
})
.to_string();
let invalid_custom_output = json!({
"type": "response.output_item.done",
"item": {
"type": "custom_tool_call_output",
"output": "ok"
}
})
.to_string();
let invalid_tool_search_call = json!({
"type": "response.output_item.done",
"item": {
"type": "tool_search_call",
"arguments": {}
}
})
.to_string();
let invalid_tool_search_output = json!({
"type": "response.output_item.done",
"item": {
"type": "tool_search_output",
"status": "completed",
"execution": "remote"
}
})
.to_string();
assert!(
websocket_custom_tool_call_output_item_event_invalid_required_fields(&invalid_custom_call)
);
assert!(
websocket_custom_tool_call_output_payload_item_event_invalid_required_fields(
&invalid_custom_output
)
);
assert!(
websocket_tool_search_call_output_item_event_invalid_required_fields(
&invalid_tool_search_call
)
);
assert!(
websocket_tool_search_output_item_event_invalid_required_fields(
&invalid_tool_search_output
)
);
}
#[test]
fn codex_websocket_local_web_image_and_compaction_items_should_validate_required_fields() {
let invalid_local_shell = json!({
"type": "response.output_item.done",
"item": {
"type": "local_shell_call",
"call_id": "call_1",
"status": "failed",
"action": {
"type": "exec",
"command": "ls"
}
}
})
.to_string();
let invalid_web_search = json!({
"type": "response.output_item.done",
"item": {
"type": "web_search_call",
"status": "completed",
"action": {}
}
})
.to_string();
let invalid_image = json!({
"type": "response.output_item.done",
"item": {
"type": "image_generation_call",
"id": "img_1",
"status": "completed"
}
})
.to_string();
let invalid_compaction = json!({
"type": "response.output_item.done",
"item": {
"type": "compaction"
}
})
.to_string();
assert!(
websocket_local_shell_call_output_item_event_invalid_required_fields(&invalid_local_shell)
);
assert!(
websocket_web_search_call_output_item_event_invalid_required_fields(&invalid_web_search)
);
assert!(
websocket_image_generation_call_output_item_event_invalid_required_fields(&invalid_image)
);
assert!(websocket_compaction_output_item_event_invalid_required_fields(&invalid_compaction));
}
#[test]
fn codex_websocket_local_shell_and_web_search_should_reject_nested_shape_mismatches() {
let invalid_local_shell = json!({
"type": "response.output_item.done",
"item": {
"type": "local_shell_call",
"call_id": "call_1",
"status": "completed",
"action": {
"type": "exec",
"command": "pwd",
"timeout_ms": "1000",
"env": {
"PATH": {}
}
}
}
})
.to_string();
let invalid_web_search = json!({
"type": "response.output_item.done",
"item": {
"type": "web_search_call",
"status": "completed",
"action": {
"type": "search",
"query": {},
"queries": ["valid", {}]
}
}
})
.to_string();
assert!(
websocket_local_shell_call_output_item_event_invalid_required_fields(&invalid_local_shell)
);
assert!(
websocket_web_search_call_output_item_event_invalid_required_fields(&invalid_web_search)
);
}
#[test]
fn codex_websocket_reasoning_summary_part_should_require_summary_index() {
assert!(
websocket_reasoning_summary_part_added_missing_summary_index(
r#"{"type":"response.reasoning_summary_part.added"}"#
)
);
}
#[test]
fn codex_websocket_event_to_sse_frame_should_skip_invalid_json_and_shape_mismatches() {
let invalid_delta = json!({
"type": "response.output_text.delta",
"delta": 42
})
.to_string();
let non_object_item = json!({
"type": "response.output_item.done",
"item": 123
})
.to_string();
assert!(websocket_event_to_sse_frame("not-json-from-upstream").is_none());
assert!(websocket_event_to_sse_frame(&invalid_delta).is_none());
assert!(websocket_event_to_sse_frame(&non_object_item).is_none());
}
@@ -0,0 +1,71 @@
use super::*;
#[test]
fn chat_completion_request_should_translate_to_codex_request() {
let request = codex_proxy_core::protocol::openai::chat::ChatCompletionRequest {
model: "gpt-5.5".to_string(),
stream: true,
messages: vec![
codex_proxy_core::protocol::openai::chat::ChatMessage {
role: "system".to_string(),
content: Some(json!("be brief")),
name: None,
tool_calls: None,
tool_call_id: None,
function_call: None,
},
codex_proxy_core::protocol::openai::chat::ChatMessage {
role: "user".to_string(),
content: Some(json!("hello")),
name: None,
tool_calls: None,
tool_call_id: None,
function_call: None,
},
],
reasoning_effort: Some("medium".to_string()),
service_tier: Some("auto".to_string()),
tools: None,
tool_choice: None,
parallel_tool_calls: None,
functions: None,
response_format: None,
user: Some(" client-123 ".to_string()),
};
let codex = codex_proxy_core::protocol::openai::chat::translate_chat_to_codex(request)
.expect("chat request should translate");
assert_eq!(codex.model, "gpt-5.5");
assert!(codex.force_http_sse);
assert_eq!(codex.prompt_cache_key.as_deref(), Some("client-123"));
assert_eq!(codex.client_conversation_id.as_deref(), Some("client-123"));
}
#[test]
fn sse_parser_should_combine_multiline_data_and_metadata() {
let events = codex_proxy_core::protocol::codex::sse::parse_sse_events(
"event: message\nid: evt_1\ndata: hello\ndata: world\nretry: 10\n\n",
)
.expect("sse should parse");
assert_eq!(events[0].data, "hello\nworld");
}
#[test]
fn chat_completion_from_codex_sse_should_convert_completed_response() {
let body = concat!(
"event: response.output_text.delta\n",
"data: {\"delta\":\"hello\"}\n\n",
"event: response.completed\n",
"data: {\"response\":{\"usage\":{\"input_tokens\":2,\"output_tokens\":3}}}\n\n",
);
let response = codex_proxy_core::protocol::openai::chat::chat_completion_from_codex_sse(
body, "gpt-5.5", false, None,
)
.expect("conversion should succeed")
.expect("completed response");
assert_eq!(response["choices"][0]["message"]["content"], "hello");
}
@@ -0,0 +1,371 @@
use super::*;
#[test]
fn codex_responses_request_should_enable_http_sse_defaults() {
let request = CodexResponsesRequest::new_http_sse("gpt-5.5", "be brief", vec![json!({})]);
assert!(request.stream);
}
#[test]
fn codex_responses_transport_should_prefer_websocket_without_history() {
let request = CodexResponsesRequest::new_http_sse("gpt-5.5", "be brief", vec![json!({})]);
assert_eq!(
transport_for_request(&request),
CodexTransport::WebSocketPreferred
);
assert!(http_sse_fallback_allowed(&request));
}
#[test]
fn codex_responses_transport_should_require_websocket_for_previous_response_id() {
let mut request = CodexResponsesRequest::new_http_sse("gpt-5.5", "be brief", vec![json!({})]);
request.previous_response_id = Some("resp_previous".to_string());
assert_eq!(
transport_for_request(&request),
CodexTransport::WebSocketRequired
);
assert!(!http_sse_fallback_allowed(&request));
}
#[test]
fn codex_responses_transport_should_allow_forced_http_sse() {
let mut request = CodexResponsesRequest::new_http_sse("gpt-5.5", "be brief", vec![json!({})]);
request.previous_response_id = Some("resp_previous".to_string());
request.use_websocket = true;
request.force_http_sse = true;
assert_eq!(transport_for_request(&request), CodexTransport::HttpSse);
assert!(http_sse_fallback_allowed(&request));
}
#[test]
fn codex_responses_transport_flags_should_not_serialize_to_upstream_json() {
let mut request = CodexResponsesRequest::new_http_sse("gpt-5.5", "be brief", vec![json!({})]);
request.use_websocket = true;
request.force_http_sse = true;
let body = serde_json::to_value(&request).expect("request should serialize");
assert!(body.get("use_websocket").is_none());
assert!(body.get("useWebSocket").is_none());
assert!(body.get("force_http_sse").is_none());
assert!(body.get("forceHttpSse").is_none());
}
#[test]
fn response_from_codex_sse_should_collect_completed_response_with_deltas() {
let body = concat!(
"event: response.output_text.delta\n",
"data: {\"delta\":\"hello\"}\n\n",
"event: response.completed\n",
"data: {\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"status\":\"completed\",\"usage\":{\"input_tokens\":2,\"output_tokens\":3}}}\n\n",
);
let response = response_from_codex_sse(body, None).expect("conversion should succeed");
assert_eq!(
response,
CollectedResponse::Completed(json!({
"id": "resp_1",
"object": "response",
"status": "completed",
"usage": {
"input_tokens": 2,
"output_tokens": 3
},
"output": [{
"type": "message",
"status": "completed",
"role": "assistant",
"content": [{
"type": "output_text",
"text": "hello",
"annotations": []
}]
}],
"output_text": "hello"
}))
);
}
#[test]
fn openai_response_request_should_translate_to_codex_request() {
let request = serde_json::from_value::<OpenAiResponsesRequest>(json!({
"model": "gpt-5.5",
"input": "hello",
"previous_response_id": "resp_previous",
"turnState": "turn_previous",
"stream": false
}))
.expect("responses request should deserialize");
let codex = translate_response_to_codex(request);
assert_eq!(codex.model, "gpt-5.5");
assert_eq!(codex.input, vec![json!("hello")]);
assert_eq!(codex.previous_response_id.as_deref(), Some("resp_previous"));
assert_eq!(codex.turn_state.as_deref(), Some("turn_previous"));
assert!(!codex.use_websocket);
assert!(!codex.force_http_sse);
}
#[test]
fn openai_response_request_should_default_missing_stream_to_true() {
let request = serde_json::from_value::<OpenAiResponsesRequest>(json!({
"model": "gpt-5.5",
"input": "hello"
}))
.expect("responses request should deserialize");
let codex = translate_response_to_codex(request);
assert!(codex.stream);
}
#[test]
fn openai_response_request_should_prepare_tuple_schema_before_upstream() {
let request = serde_json::from_value::<OpenAiResponsesRequest>(json!({
"model": "gpt-5.5",
"input": [],
"stream": false,
"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
}
}
}))
.expect("responses request should deserialize");
let codex = translate_response_to_codex(request);
let schema = &codex.text.as_ref().unwrap()["format"]["schema"];
assert_eq!(
schema["properties"]["point"],
json!({
"type": "object",
"properties": {
"0": {"type": "number"},
"1": {"type": "number"}
},
"required": ["0", "1"],
"additionalProperties": false
})
);
assert_eq!(
codex.tuple_schema.unwrap()["properties"]["point"]["type"],
"array"
);
}
#[test]
fn openai_response_request_should_forward_parity_fields_to_codex() {
let request = serde_json::from_value::<OpenAiResponsesRequest>(json!({
"model": "gpt-5.5-fast",
"stream": false,
"input": [],
"instructions": "be terse",
"reasoning": {"effort": "high"},
"service_tier": "priority",
"tool_choice": {"type": "function", "function": {"name": "lookup"}},
"parallel_tool_calls": true,
"tools": [{"type": "function", "name": "lookup"}],
"prompt_cache_key": "pcache",
"include": ["reasoning.encrypted_content"],
"client_metadata": {"safe": "yes", "drop": 42},
"turnState": "turn-body",
"turnMetadata": "meta-body",
"betaFeatures": "beta-body",
"includeTimingMetrics": "true",
"version": "2026-06-12",
"codexWindowId": "window-body",
"parentThreadId": "parent-body",
"use_websocket": false
}))
.expect("responses request should deserialize");
let codex = translate_response_to_codex(request);
assert_eq!(codex.instructions, "be terse");
assert_eq!(
codex.reasoning,
Some(json!({"effort": "high", "summary": "auto"}))
);
assert_eq!(codex.service_tier.as_deref(), Some("priority"));
assert_eq!(
codex.tool_choice,
Some(json!({"type": "function", "function": {"name": "lookup"}}))
);
assert_eq!(codex.parallel_tool_calls, Some(true));
assert_eq!(
codex.tools,
Some(vec![json!({"type": "function", "name": "lookup"})])
);
assert_eq!(codex.prompt_cache_key.as_deref(), Some("pcache"));
assert!(codex.explicit_prompt_cache_key);
assert_eq!(
codex.include,
Some(vec!["reasoning.encrypted_content".to_string()])
);
assert_eq!(codex.client_metadata, Some(json!({"safe": "yes"})));
assert_eq!(codex.turn_state.as_deref(), Some("turn-body"));
assert_eq!(codex.turn_metadata.as_deref(), Some("meta-body"));
assert_eq!(codex.beta_features.as_deref(), Some("beta-body"));
assert_eq!(codex.include_timing_metrics.as_deref(), Some("true"));
assert_eq!(codex.version.as_deref(), Some("2026-06-12"));
assert_eq!(codex.codex_window_id.as_deref(), Some("window-body"));
assert_eq!(codex.parent_thread_id.as_deref(), Some("parent-body"));
assert!(codex.force_http_sse);
}
#[test]
fn codex_responses_model_options_should_apply_suffix_defaults_and_include_reasoning() {
let mut request = CodexResponsesRequest::new_http_sse("gpt-5.5-high-fast", "", Vec::new());
let parsed = ParsedModelName {
model_id: "gpt-5.5".to_string(),
reasoning_effort: Some("high".to_string()),
service_tier: Some("fast".to_string()),
};
let config = ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: Some("medium".to_string()),
service_tier: Some("flex".to_string()),
aliases: Default::default(),
};
apply_response_model_options(&mut request, &parsed, &config);
assert_eq!(request.model, "gpt-5.5");
assert_eq!(
request.reasoning,
Some(json!({"summary": "auto", "effort": "high"}))
);
assert_eq!(request.service_tier.as_deref(), Some("priority"));
assert_eq!(
request.include,
Some(vec!["reasoning.encrypted_content".to_string()])
);
}
#[test]
fn codex_responses_model_options_should_preserve_client_include_and_normalize_body_tier() {
let mut request = CodexResponsesRequest::new_http_sse("gpt-5.5", "", Vec::new());
request.reasoning = Some(json!({"summary": "detailed"}));
request.service_tier = Some("fast".to_string());
request.include = Some(vec!["file_search_call.results".to_string()]);
let parsed = ParsedModelName {
model_id: "gpt-5.5".to_string(),
reasoning_effort: Some("low".to_string()),
service_tier: Some("flex".to_string()),
};
let config = ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: Some("medium".to_string()),
service_tier: Some("flex".to_string()),
aliases: Default::default(),
};
apply_response_model_options(&mut request, &parsed, &config);
assert_eq!(
request.reasoning,
Some(json!({"summary": "detailed", "effort": "low"}))
);
assert_eq!(request.service_tier.as_deref(), Some("priority"));
assert_eq!(
request.include,
Some(vec!["file_search_call.results".to_string()])
);
}
#[test]
fn openai_compact_request_should_drop_responses_only_fields_and_sanitize_input() {
let request = serde_json::from_value::<OpenAiResponsesRequest>(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"
}))
.expect("responses request should deserialize");
let compact = translate_response_to_compact(request);
let body = serde_json::to_value(&compact).expect("compact request should serialize");
assert_eq!(body["model"], "gpt-5.5-fast");
assert_eq!(body["instructions"], "compress the session");
assert_eq!(body["parallel_tool_calls"], false);
assert_eq!(
body["reasoning"],
json!({"effort": "high", "summary": "auto"})
);
assert_eq!(
body["tools"],
json!([{"type": "function", "name": "lookup"}])
);
assert_eq!(body["text"]["format"]["type"], "json_schema");
assert!(body.get("stream").is_none());
assert!(body.get("store").is_none());
assert!(body.get("prompt_cache_key").is_none());
assert_eq!(body["input"].as_array().unwrap().len(), 3);
assert!(body["input"][1].get("ignored").is_none());
assert_eq!(body["input"][2]["encrypted_content"], "enc_compact");
}
#[test]
fn openai_streaming_response_with_previous_response_should_require_websocket() {
let request = serde_json::from_value::<OpenAiResponsesRequest>(json!({
"model": "gpt-5.5",
"input": "hello",
"previous_response_id": "resp_previous",
"stream": true
}))
.expect("responses request should deserialize");
let codex = translate_response_to_codex(request);
assert_eq!(codex.previous_response_id.as_deref(), Some("resp_previous"));
assert!(codex.stream);
assert!(!codex.force_http_sse);
}
@@ -0,0 +1,372 @@
use super::*;
#[test]
fn extract_usage_should_read_codex_usage_shape() {
let body = json!({
"usage": {
"input_tokens": 12,
"output_tokens": 5,
"input_tokens_details": {
"cached_tokens": 3
}
}
});
let usage = extract_usage(&body).expect("usage should exist");
assert_eq!(
usage,
TokenUsage {
input_tokens: 12,
output_tokens: 5,
cached_tokens: 3,
image_input_tokens: 0,
image_output_tokens: 0,
total_tokens: 17,
}
);
}
#[test]
fn extract_usage_should_read_image_generation_tokens_separately() {
let body = json!({
"usage": {
"input_tokens": 12,
"output_tokens": 5,
"input_tokens_details": {
"cached_tokens": 3
}
},
"tool_usage": {
"image_gen": {
"input_tokens": 31,
"output_tokens": 9
}
}
});
let usage = extract_usage(&body).expect("usage should exist");
assert_eq!(
usage,
TokenUsage {
input_tokens: 12,
output_tokens: 5,
cached_tokens: 3,
image_input_tokens: 31,
image_output_tokens: 9,
total_tokens: 17,
}
);
}
#[test]
fn extract_usage_should_merge_openai_usage_shape() {
let first = extract_usage(&json!({
"usage": {
"prompt_tokens": 8,
"completion_tokens": 4,
"prompt_tokens_details": {
"cached_tokens": 2
}
}
}))
.expect("usage should exist");
let second = TokenUsage {
input_tokens: 1,
output_tokens: 2,
cached_tokens: 0,
image_input_tokens: 0,
image_output_tokens: 0,
total_tokens: 3,
};
assert_eq!(
first.merged(second),
TokenUsage {
input_tokens: 9,
output_tokens: 6,
cached_tokens: 2,
image_input_tokens: 0,
image_output_tokens: 0,
total_tokens: 15,
}
);
}
#[test]
fn extract_sse_usage_should_prefer_completed_response_usage() {
let body = concat!(
"event: response.created\n",
"data: {\"response\":{\"id\":\"resp_1\",\"usage\":{\"input_tokens\":3,\"output_tokens\":5,\"input_tokens_details\":{\"cached_tokens\":1}}}}\n\n",
"event: response.completed\n",
"data: {\"response\":{\"id\":\"resp_1\",\"usage\":{\"input_tokens\":3,\"output_tokens\":5,\"input_tokens_details\":{\"cached_tokens\":1}}}}\n\n",
);
let usage = extract_sse_usage(body)
.expect("usage extraction should succeed")
.expect("usage should exist");
assert_eq!(
usage,
TokenUsage {
input_tokens: 3,
output_tokens: 5,
cached_tokens: 1,
image_input_tokens: 0,
image_output_tokens: 0,
total_tokens: 8,
}
);
}
#[test]
fn extract_sse_usage_should_read_completed_image_generation_tokens() {
let body = concat!(
"event: response.completed\n",
"data: {\"response\":{\"id\":\"resp_1\",\"usage\":{\"input_tokens\":12,\"output_tokens\":5,\"input_tokens_details\":{\"cached_tokens\":3}},\"tool_usage\":{\"image_gen\":{\"input_tokens\":31,\"output_tokens\":9}}}}\n\n",
);
let usage = extract_sse_usage(body)
.expect("usage extraction should succeed")
.expect("usage should exist");
assert_eq!(
usage,
TokenUsage {
input_tokens: 12,
output_tokens: 5,
cached_tokens: 3,
image_input_tokens: 31,
image_output_tokens: 9,
total_tokens: 17,
}
);
}
#[test]
fn retry_after_seconds_from_body_should_read_structured_retry_delay() {
let body = json!({
"response": {
"error": {
"resets_in_seconds": 45
}
}
})
.to_string();
assert_eq!(retry_after_seconds_from_body(&body), Some(45));
}
#[test]
fn retry_after_seconds_from_body_should_parse_rate_limit_message_seconds() {
let body = json!({
"response": {
"error": {
"code": "rate_limit_exceeded",
"message": "Rate limit reached. Please try again in 11.054s."
}
}
})
.to_string();
assert_eq!(retry_after_seconds_from_body(&body), Some(12));
}
#[test]
fn retry_after_seconds_from_body_should_parse_rate_limit_message_milliseconds() {
let body = json!({
"error": {
"code": "rate_limit_exceeded",
"message": "Rate limit reached. Please try again in 28ms."
}
})
.to_string();
assert_eq!(retry_after_seconds_from_body(&body), Some(1));
}
#[test]
fn retry_after_seconds_from_body_should_ignore_retry_message_for_other_codes() {
let body = json!({
"response": {
"error": {
"code": "upstream_transient_error",
"message": "Try again in 35 seconds."
}
}
})
.to_string();
assert_eq!(retry_after_seconds_from_body(&body), None);
}
#[test]
fn parse_rate_limit_headers_should_extract_primary_secondary_and_review_windows() {
let headers = vec![
(
"x-codex-primary-used-percent".to_string(),
"100".to_string(),
),
(
"x-codex-primary-window-minutes".to_string(),
"5".to_string(),
),
(
"x-codex-primary-reset-at".to_string(),
"1893456300".to_string(),
),
(
"x-codex-secondary-used-percent".to_string(),
"42.5".to_string(),
),
(
"x-codex-secondary-window-minutes".to_string(),
"10080".to_string(),
),
(
"x-codex-code-review-primary-used-percent".to_string(),
"80".to_string(),
),
(
"x-codex-code-review-primary-reset-at".to_string(),
"1893456600".to_string(),
),
];
let parsed = parse_rate_limit_headers(&headers).expect("rate limits should parse");
assert_eq!(
parsed.primary,
Some(RateLimitWindow {
used_percent: 100.0,
window_minutes: Some(5),
reset_at: Some(1_893_456_300),
})
);
assert_eq!(
parsed.secondary.expect("secondary window").window_minutes,
Some(10080)
);
assert_eq!(
parsed
.code_review
.expect("review window")
.primary
.expect("review primary")
.reset_at,
Some(1_893_456_600)
);
}
#[test]
fn parse_rate_limits_event_should_extract_internal_websocket_rate_limits() {
let event = json!({
"type": "codex.rate_limits",
"rate_limits": {
"primary": {
"used_percent": 99.5,
"window_minutes": 300,
"reset_at": 1893456300
},
"secondary": {
"used_percent": 10,
"window_minutes": 10080,
"reset_at": 1894056000
}
}
});
let parsed = parse_rate_limits_event(&event).expect("event should parse");
assert_eq!(parsed.primary.expect("primary window").used_percent, 99.5);
assert_eq!(
parsed.secondary.expect("secondary window").reset_at,
Some(1_894_056_000)
);
}
#[test]
fn rate_limit_quota_should_preserve_existing_credits_when_passive_data_lacks_credits() {
let headers = vec![
("x-codex-primary-used-percent".to_string(), "25".to_string()),
(
"x-codex-primary-window-minutes".to_string(),
"5".to_string(),
),
(
"x-codex-primary-reset-at".to_string(),
"1893456300".to_string(),
),
];
let existing = json!({
"credits": {
"has_credits": true,
"unlimited": false,
"balance": 12
}
});
let parsed = parse_rate_limit_headers(&headers).expect("rate limits should parse");
let quota = rate_limit_quota(&parsed, Some("plus"), Some(&existing));
assert_eq!(quota["plan_type"], "plus");
assert_eq!(quota["rate_limit"]["remaining_percent"], 75);
assert_eq!(quota["credits"]["balance"], 12);
}
#[test]
fn cooldown_with_jitter_should_return_positive_duration_within_expected_range() {
let duration = cooldown_with_jitter(60, 2_000);
assert!((48..=72).contains(&duration.num_seconds()));
}
#[tokio::test]
async fn refresh_scheduler_should_refresh_before_expiry_and_preserve_refresh_token() {
use async_trait::async_trait;
use chrono::{Duration as ChronoDuration, Utc};
use codex_proxy_core::accounts::model::{Account, AccountStatus};
use codex_proxy_core::auth::oauth::{
RefreshFailure, RefreshPolicy, RefreshScheduler, RefreshTrigger, TokenPair,
};
use codex_proxy_core::auth::ports::TokenRefresher;
#[derive(Clone)]
struct StaticRefreshClient {
result: Result<TokenPair, RefreshFailure>,
}
#[async_trait]
impl TokenRefresher for StaticRefreshClient {
async fn refresh(&self, _refresh_token: &str) -> Result<TokenPair, RefreshFailure> {
self.result.clone()
}
}
let now = Utc::now();
let mut account = Account::test("acct_1", AccountStatus::Active);
account.access_token_expires_at = Some(now + ChronoDuration::seconds(60));
account.refresh_token = Some("rt_keep".to_string());
let scheduler = RefreshScheduler::new(
RefreshPolicy {
refresh_margin_seconds: 300,
refresh_concurrency: 2,
},
StaticRefreshClient {
result: Ok(TokenPair {
access_token: "new-access".to_string(),
refresh_token: None,
}),
},
);
let refreshed = scheduler
.refresh_account_at(&account, RefreshTrigger::BeforeExpiry, now)
.await
.expect("refresh should succeed");
assert_eq!(refreshed.access_token, "new-access");
assert_eq!(refreshed.refresh_token.as_deref(), Some("rt_keep"));
assert_eq!(refreshed.status, AccountStatus::Active);
}
-64
View File
@@ -92,67 +92,3 @@ fn parse_installation_id(raw: &str) -> Option<String> {
fn generate_installation_id() -> String {
Uuid::new_v4().to_string()
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use uuid::Uuid;
use super::*;
#[test]
fn resolve_installation_id_should_prefer_codex_desktop_file() {
let temp = TempDir::new().expect("tempdir should be created");
let codex_path = temp.path().join(".codex").join("installation_id");
let data_path = temp.path().join("data").join("installation_id");
fs::create_dir_all(codex_path.parent().expect("codex path should have parent"))
.expect("codex parent should be created");
fs::create_dir_all(data_path.parent().expect("data path should have parent"))
.expect("data parent should be created");
let codex_id = Uuid::new_v4().to_string();
let data_id = Uuid::new_v4().to_string();
fs::write(&codex_path, &codex_id).expect("codex id should be written");
fs::write(&data_path, data_id).expect("data id should be written");
let resolved =
load_or_create_installation_id_from_paths(Some(&codex_path), Some(&data_path))
.expect("installation id should resolve");
assert_eq!(resolved, codex_id);
}
#[test]
fn resolve_installation_id_should_read_data_file_when_codex_file_is_absent() {
let temp = TempDir::new().expect("tempdir should be created");
let codex_path = temp.path().join(".codex").join("installation_id");
let data_path = temp.path().join("data").join("installation_id");
fs::create_dir_all(data_path.parent().expect("data path should have parent"))
.expect("data parent should be created");
let data_id = Uuid::new_v4().to_string();
fs::write(&data_path, &data_id).expect("data id should be written");
let resolved =
load_or_create_installation_id_from_paths(Some(&codex_path), Some(&data_path))
.expect("installation id should resolve");
assert_eq!(resolved, data_id);
}
#[test]
fn resolve_installation_id_should_generate_and_persist_when_files_are_absent() {
let temp = TempDir::new().expect("tempdir should be created");
let codex_path = temp.path().join(".codex").join("installation_id");
let data_path = temp.path().join("data").join("installation_id");
let resolved =
load_or_create_installation_id_from_paths(Some(&codex_path), Some(&data_path))
.expect("installation id should resolve");
assert_eq!(
fs::read_to_string(&data_path).expect("generated id should be persisted"),
resolved
);
}
}
+59 -2
View File
@@ -1,9 +1,66 @@
use codex_proxy_platform::config::{AppConfig, LoggingConfig};
use std::fs;
const DEFAULT_CONFIG_YAML: &str = r#"
server:
host: 0.0.0.0
port: 8080
api:
base_url: https://chatgpt.com/backend-api
model:
default: gpt-5.5
default_reasoning_effort: null
default_service_tier: null
aliases: {}
auth:
refresh_margin_seconds: 300
refresh_enabled: true
refresh_concurrency: 2
max_concurrent_per_account: 3
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:
primary:
- 80
- 90
secondary:
- 80
- 90
skip_exhausted: true
usage_stats:
history_retention_days: null
database:
url: sqlite://.runtime/data/codex-proxy-rs.sqlite
security:
master_key_file: .runtime/data/master.key
api_key_pepper_file: .runtime/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
default_password: admin
logging:
directory: .runtime/logs
retention_days: 14
enabled: true
capacity: 2000
capture_body: false
"#;
#[test]
fn default_config_keeps_only_codex_backend() {
let cfg: AppConfig = serde_yml::from_str(include_str!("../../../config.yaml")).unwrap();
let cfg: AppConfig = serde_yml::from_str(DEFAULT_CONFIG_YAML).unwrap();
assert_eq!(cfg.server.host, "0.0.0.0");
assert_eq!(cfg.api.base_url, "https://chatgpt.com/backend-api");
assert_eq!(cfg.model.default_model, "gpt-5.5");
@@ -25,7 +82,7 @@ fn default_config_keeps_only_codex_backend() {
#[test]
fn default_config_keeps_runtime_artifacts_under_runtime_directory() {
let cfg: AppConfig = serde_yml::from_str(include_str!("../../../config.yaml")).unwrap();
let cfg: AppConfig = serde_yml::from_str(DEFAULT_CONFIG_YAML).unwrap();
assert_eq!(
cfg.database.url,
+8 -314
View File
@@ -29,320 +29,14 @@ use wiremock::{
Mock, MockServer, ResponseTemplate,
};
#[tokio::test]
async fn fingerprint_update_task_should_start_background_checker() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("fingerprints.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let repo = FingerprintRepository::new(pool);
let handle = codex_proxy_runtime::tasks::fingerprint_update::start_fingerprint_update_task(
Some(repo),
"https://example.invalid/appcast.xml".to_string(),
dir.path().join("extracted-fingerprint.json"),
"26.800.1".to_string(),
"6001".to_string(),
);
handle.shutdown().await;
}
#[tokio::test]
async fn fingerprint_update_task_should_apply_initial_appcast_update_to_repository() {
let server = MockServer::start().await;
mount_appcast(
&server,
r#"
<rss>
<channel>
<item>
<enclosure url="https://example.invalid/download" sparkle:shortVersionString="26.900.1" sparkle:version="7001" />
</item>
</channel>
</rss>
"#,
)
.await;
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("fingerprints-initial-update.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let repo = FingerprintRepository::new(pool);
let extracted_path = dir.path().join("extracted-fingerprint.json");
std::fs::write(
&extracted_path,
r#"{"app_version":"26.900.1","build_number":"7001","chromium_version":"147"}"#,
)
.expect("extracted fingerprint should be written");
let handle = codex_proxy_runtime::tasks::fingerprint_update::start_fingerprint_update_task(
Some(repo.clone()),
format!("{}/appcast.xml", server.uri()),
extracted_path,
"26.800.1".to_string(),
"6001".to_string(),
);
let stored = wait_for_auto_updated_fingerprint(&repo).await;
handle.shutdown().await;
assert_eq!(
(
stored.app_version.as_str(),
stored.build_number.as_str(),
stored.chromium_version.as_str()
),
("26.900.1", "7001", "147")
);
}
#[tokio::test]
async fn fingerprint_update_task_should_not_persist_when_appcast_matches_current_fingerprint() {
let server = MockServer::start().await;
mount_appcast(
&server,
r#"
<rss>
<channel>
<item>
<enclosure url="https://example.invalid/download" sparkle:shortVersionString="26.900.1" sparkle:version="7001" />
</item>
</channel>
</rss>
"#,
)
.await;
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("fingerprints-no-update.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let repo = FingerprintRepository::new(pool);
let handle = codex_proxy_runtime::tasks::fingerprint_update::start_fingerprint_update_task(
Some(repo.clone()),
format!("{}/appcast.xml", server.uri()),
dir.path().join("missing-extracted-fingerprint.json"),
"26.900.1".to_string(),
"7001".to_string(),
);
wait_for_appcast_requests(&server, 1).await;
handle.shutdown().await;
let stored = repo
.load_latest_auto_updated()
.await
.expect("latest auto update should load");
assert!(stored.is_none());
}
#[tokio::test]
async fn fingerprint_update_task_should_check_appcast_without_repository() {
let server = MockServer::start().await;
mount_appcast(
&server,
r#"
<rss>
<channel>
<item>
<enclosure url="https://example.invalid/download" sparkle:shortVersionString="26.900.1" sparkle:version="7001" />
</item>
</channel>
</rss>
"#,
)
.await;
let dir = tempfile::tempdir().expect("temp dir");
let handle = codex_proxy_runtime::tasks::fingerprint_update::start_fingerprint_update_task(
None,
format!("{}/appcast.xml", server.uri()),
dir.path().join("missing-extracted-fingerprint.json"),
"26.800.1".to_string(),
"6001".to_string(),
);
wait_for_appcast_requests(&server, 1).await;
handle.shutdown().await;
}
#[tokio::test]
async fn model_refresh_task_should_start_and_shutdown() {
let model_service = Arc::new(ModelService::new(
ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: None,
service_tier: None,
aliases: Default::default(),
},
None,
None,
None,
));
let account_store = Arc::new(FakeAccountStore);
let handle = codex_proxy_runtime::tasks::model_refresh::ModelRefreshTask::new(
model_service,
account_store,
)
.start();
handle.shutdown().await;
}
#[tokio::test]
async fn cookie_cleanup_task_should_delete_only_expired_cookie_rows() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("cookies.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let secret_box = SecretBox::new([7u8; 32]);
let store = SqliteCookieStore::new(pool.clone(), secret_box);
insert_account(&pool, "acct-cookie").await;
let now = Utc::now();
insert_cookie(
&pool,
"expired-cookie",
"acct-cookie",
"old",
now - Duration::minutes(1),
)
.await;
insert_cookie(
&pool,
"active-cookie",
"acct-cookie",
"fresh",
now + Duration::minutes(10),
)
.await;
let deleted = codex_proxy_runtime::tasks::cookie_cleanup::CookieCleanupTask::new(store)
.cleanup_once_at(now)
.await
.expect("cookie cleanup should succeed");
let remaining = cookie_ids(&pool).await;
assert_eq!((deleted, remaining), (1, vec!["active-cookie".to_string()]));
}
#[tokio::test]
async fn session_cleanup_task_should_delete_only_expired_sessions() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("sessions.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAdminSessionStore::new(pool.clone());
let now = Utc::now();
insert_admin_user(&pool, "admin").await;
insert_admin_session(
&pool,
"expired-session",
"admin",
now - Duration::minutes(1),
)
.await;
insert_admin_session(
&pool,
"active-session",
"admin",
now + Duration::minutes(10),
)
.await;
let deleted = codex_proxy_runtime::tasks::session_cleanup::SessionCleanupTask::new(store, 3600)
.cleanup_once_at(now)
.await
.expect("session cleanup should succeed");
let remaining = admin_session_ids(&pool).await;
assert_eq!(
(deleted, remaining),
(1, vec!["active-session".to_string()])
);
}
#[tokio::test]
async fn session_affinity_cleanup_task_should_delete_only_expired_affinities() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("session-affinity-cleanup.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteSessionAffinityStore::new(pool.clone());
let now = Utc::now();
store
.upsert(
"expired-response",
&session_affinity_entry("expired-account", now - Duration::hours(2)),
Duration::hours(1),
)
.await
.expect("expired affinity should be inserted");
store
.upsert(
"active-response",
&session_affinity_entry("active-account", now),
Duration::hours(1),
)
.await
.expect("active affinity should be inserted");
let deleted =
codex_proxy_runtime::tasks::session_affinity_cleanup::SessionAffinityCleanupTask::new(
store, 3600,
)
.cleanup_once_at(now)
.await
.expect("session affinity cleanup should succeed");
let remaining = session_affinity_response_ids(&pool).await;
assert_eq!(
(deleted, remaining),
(1, vec!["active-response".to_string()])
);
}
#[tokio::test]
async fn start_background_tasks_should_register_migrated_runtime_tasks() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("background-tasks.sqlite");
let database_url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&database_url).await.expect("sqlite pool");
let state = codex_proxy_runtime::state::AppState::with_pool_and_secret_box(
test_config(database_url),
pool,
SecretBox::new([9u8; 32]),
);
let coordinator = codex_proxy_runtime::tasks::coordinator::start_background_tasks(&state).await;
let task_names = coordinator.task_names();
assert_eq!(
task_names,
[
"cookie_cleanup",
"session_cleanup",
"session_affinity_cleanup",
"model_refresh",
"token_refresh",
"quota_refresh",
"fingerprint_update"
]
);
coordinator.shutdown().await;
}
#[path = "tasks/cleanup.rs"]
mod cleanup;
#[path = "tasks/coordinator.rs"]
mod coordinator;
#[path = "tasks/fingerprint.rs"]
mod fingerprint;
#[path = "tasks/model_refresh.rs"]
mod model_refresh;
struct FakeAccountStore;
+119
View File
@@ -0,0 +1,119 @@
use super::*;
#[tokio::test]
async fn cookie_cleanup_task_should_delete_only_expired_cookie_rows() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("cookies.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let secret_box = SecretBox::new([7u8; 32]);
let store = SqliteCookieStore::new(pool.clone(), secret_box);
insert_account(&pool, "acct-cookie").await;
let now = Utc::now();
insert_cookie(
&pool,
"expired-cookie",
"acct-cookie",
"old",
now - Duration::minutes(1),
)
.await;
insert_cookie(
&pool,
"active-cookie",
"acct-cookie",
"fresh",
now + Duration::minutes(10),
)
.await;
let deleted = codex_proxy_runtime::tasks::cookie_cleanup::CookieCleanupTask::new(store)
.cleanup_once_at(now)
.await
.expect("cookie cleanup should succeed");
let remaining = cookie_ids(&pool).await;
assert_eq!((deleted, remaining), (1, vec!["active-cookie".to_string()]));
}
#[tokio::test]
async fn session_cleanup_task_should_delete_only_expired_sessions() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("sessions.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAdminSessionStore::new(pool.clone());
let now = Utc::now();
insert_admin_user(&pool, "admin").await;
insert_admin_session(
&pool,
"expired-session",
"admin",
now - Duration::minutes(1),
)
.await;
insert_admin_session(
&pool,
"active-session",
"admin",
now + Duration::minutes(10),
)
.await;
let deleted = codex_proxy_runtime::tasks::session_cleanup::SessionCleanupTask::new(store, 3600)
.cleanup_once_at(now)
.await
.expect("session cleanup should succeed");
let remaining = admin_session_ids(&pool).await;
assert_eq!(
(deleted, remaining),
(1, vec!["active-session".to_string()])
);
}
#[tokio::test]
async fn session_affinity_cleanup_task_should_delete_only_expired_affinities() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("session-affinity-cleanup.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteSessionAffinityStore::new(pool.clone());
let now = Utc::now();
store
.upsert(
"expired-response",
&session_affinity_entry("expired-account", now - Duration::hours(2)),
Duration::hours(1),
)
.await
.expect("expired affinity should be inserted");
store
.upsert(
"active-response",
&session_affinity_entry("active-account", now),
Duration::hours(1),
)
.await
.expect("active affinity should be inserted");
let deleted =
codex_proxy_runtime::tasks::session_affinity_cleanup::SessionAffinityCleanupTask::new(
store, 3600,
)
.cleanup_once_at(now)
.await
.expect("session affinity cleanup should succeed");
let remaining = session_affinity_response_ids(&pool).await;
assert_eq!(
(deleted, remaining),
(1, vec!["active-response".to_string()])
);
}
+32
View File
@@ -0,0 +1,32 @@
use super::*;
#[tokio::test]
async fn start_background_tasks_should_register_migrated_runtime_tasks() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("background-tasks.sqlite");
let database_url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&database_url).await.expect("sqlite pool");
let state = codex_proxy_runtime::state::AppState::with_pool_and_secret_box(
test_config(database_url),
pool,
SecretBox::new([9u8; 32]),
);
let coordinator = codex_proxy_runtime::tasks::coordinator::start_background_tasks(&state).await;
let task_names = coordinator.task_names();
assert_eq!(
task_names,
[
"cookie_cleanup",
"session_cleanup",
"session_affinity_cleanup",
"model_refresh",
"token_refresh",
"quota_refresh",
"fingerprint_update"
]
);
coordinator.shutdown().await;
}
+143
View File
@@ -0,0 +1,143 @@
use super::*;
#[tokio::test]
async fn fingerprint_update_task_should_start_background_checker() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("fingerprints.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let repo = FingerprintRepository::new(pool);
let handle = codex_proxy_runtime::tasks::fingerprint_update::start_fingerprint_update_task(
Some(repo),
"https://example.invalid/appcast.xml".to_string(),
dir.path().join("extracted-fingerprint.json"),
"26.800.1".to_string(),
"6001".to_string(),
);
handle.shutdown().await;
}
#[tokio::test]
async fn fingerprint_update_task_should_apply_initial_appcast_update_to_repository() {
let server = MockServer::start().await;
mount_appcast(
&server,
r#"
<rss>
<channel>
<item>
<enclosure url="https://example.invalid/download" sparkle:shortVersionString="26.900.1" sparkle:version="7001" />
</item>
</channel>
</rss>
"#,
)
.await;
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("fingerprints-initial-update.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let repo = FingerprintRepository::new(pool);
let extracted_path = dir.path().join("extracted-fingerprint.json");
std::fs::write(
&extracted_path,
r#"{"app_version":"26.900.1","build_number":"7001","chromium_version":"147"}"#,
)
.expect("extracted fingerprint should be written");
let handle = codex_proxy_runtime::tasks::fingerprint_update::start_fingerprint_update_task(
Some(repo.clone()),
format!("{}/appcast.xml", server.uri()),
extracted_path,
"26.800.1".to_string(),
"6001".to_string(),
);
let stored = wait_for_auto_updated_fingerprint(&repo).await;
handle.shutdown().await;
assert_eq!(
(
stored.app_version.as_str(),
stored.build_number.as_str(),
stored.chromium_version.as_str()
),
("26.900.1", "7001", "147")
);
}
#[tokio::test]
async fn fingerprint_update_task_should_not_persist_when_appcast_matches_current_fingerprint() {
let server = MockServer::start().await;
mount_appcast(
&server,
r#"
<rss>
<channel>
<item>
<enclosure url="https://example.invalid/download" sparkle:shortVersionString="26.900.1" sparkle:version="7001" />
</item>
</channel>
</rss>
"#,
)
.await;
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("fingerprints-no-update.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let repo = FingerprintRepository::new(pool);
let handle = codex_proxy_runtime::tasks::fingerprint_update::start_fingerprint_update_task(
Some(repo.clone()),
format!("{}/appcast.xml", server.uri()),
dir.path().join("missing-extracted-fingerprint.json"),
"26.900.1".to_string(),
"7001".to_string(),
);
wait_for_appcast_requests(&server, 1).await;
handle.shutdown().await;
let stored = repo
.load_latest_auto_updated()
.await
.expect("latest auto update should load");
assert!(stored.is_none());
}
#[tokio::test]
async fn fingerprint_update_task_should_check_appcast_without_repository() {
let server = MockServer::start().await;
mount_appcast(
&server,
r#"
<rss>
<channel>
<item>
<enclosure url="https://example.invalid/download" sparkle:shortVersionString="26.900.1" sparkle:version="7001" />
</item>
</channel>
</rss>
"#,
)
.await;
let dir = tempfile::tempdir().expect("temp dir");
let handle = codex_proxy_runtime::tasks::fingerprint_update::start_fingerprint_update_task(
None,
format!("{}/appcast.xml", server.uri()),
dir.path().join("missing-extracted-fingerprint.json"),
"26.800.1".to_string(),
"6001".to_string(),
);
wait_for_appcast_requests(&server, 1).await;
handle.shutdown().await;
}
@@ -0,0 +1,25 @@
use super::*;
#[tokio::test]
async fn model_refresh_task_should_start_and_shutdown() {
let model_service = Arc::new(ModelService::new(
ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: None,
service_tier: None,
aliases: Default::default(),
},
None,
None,
None,
));
let account_store = Arc::new(FakeAccountStore);
let handle = codex_proxy_runtime::tasks::model_refresh::ModelRefreshTask::new(
model_service,
account_store,
)
.start();
handle.shutdown().await;
}
+6 -830
View File
@@ -29,836 +29,12 @@ use tokio::{
time::{sleep, timeout},
};
#[tokio::test]
async fn token_refresh_task_should_persist_refreshed_access_token_and_keep_refresh_token() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool, SecretBox::new([11u8; 32]));
let now = Utc.with_ymd_and_hms(2026, 6, 18, 8, 0, 0).unwrap();
let new_expires_at = now + Duration::hours(1);
let new_access_token = test_jwt(new_expires_at.timestamp());
store
.insert(NewAccount {
id: "acct-refresh".to_string(),
email: Some("user@example.com".to_string()),
account_id: Some("chatgpt-account".to_string()),
user_id: Some("user-id".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(
test_jwt((now + Duration::seconds(30)).timestamp()).into(),
),
refresh_token: Some(SecretString::new("refresh-old".to_string().into())),
access_token_expires_at: Some(now + Duration::seconds(30)),
status: AccountStatus::Active,
})
.await
.expect("account should be inserted");
let refresher = StaticTokenRefresher {
response: Arc::new(Mutex::new(Ok(TokenPair {
access_token: new_access_token.clone(),
refresh_token: None,
}))),
};
let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new(
store.clone(),
RefreshPolicy {
refresh_margin_seconds: 300,
refresh_concurrency: 1,
},
refresher,
);
let summary = task
.refresh_due_accounts_once_at(now)
.await
.expect("refresh should succeed");
let stored = store
.get("acct-refresh")
.await
.expect("account should load")
.expect("account should exist");
assert_eq!(
(
summary.refreshed,
stored.access_token.expose_secret(),
stored
.refresh_token
.as_ref()
.map(ExposeSecret::expose_secret),
stored.access_token_expires_at,
stored.status,
),
(
1,
new_access_token.as_str(),
Some("refresh-old"),
Some(new_expires_at),
AccountStatus::Active,
)
);
}
#[tokio::test]
async fn token_refresh_task_should_skip_account_when_refresh_lease_is_held() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh-lease.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool.clone(), SecretBox::new([12u8; 32]));
let leases = SqliteRefreshLeaseStore::new(pool.clone());
let now = Utc.with_ymd_and_hms(2026, 6, 19, 8, 0, 0).unwrap();
let old_access_token = test_jwt((now + Duration::seconds(30)).timestamp());
store
.insert(NewAccount {
id: "acct-lease-held".to_string(),
email: Some("lease@example.com".to_string()),
account_id: Some("chatgpt-lease".to_string()),
user_id: Some("user-lease".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(old_access_token.clone().into()),
refresh_token: Some(SecretString::new("refresh-held".to_string().into())),
access_token_expires_at: Some(now + Duration::seconds(30)),
status: AccountStatus::Active,
})
.await
.expect("account should be inserted");
assert!(leases
.try_acquire(
"acct-lease-held",
"external-owner",
now + Duration::minutes(5),
now,
)
.await
.expect("external owner should acquire lease"));
let refresher = CountingTokenRefresher::default();
let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new(
store.clone(),
RefreshPolicy {
refresh_margin_seconds: 300,
refresh_concurrency: 1,
},
refresher.clone(),
)
.with_refresh_lease_store(leases);
let summary = task
.refresh_due_accounts_once_at(now)
.await
.expect("refresh scan should skip lease-held account");
let stored = store
.get("acct-lease-held")
.await
.expect("account should load")
.expect("account should exist");
assert_eq!(summary.skipped, 1);
assert_eq!(summary.refreshed, 0);
assert_eq!(refresher.calls.load(Ordering::SeqCst), 0);
assert_eq!(
stored.access_token.expose_secret(),
old_access_token.as_str()
);
assert_eq!(stored.status, AccountStatus::Active);
}
#[tokio::test]
async fn token_refresh_task_should_mark_due_account_refreshing_before_refresher_call() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh-status.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool, SecretBox::new([13u8; 32]));
let now = Utc.with_ymd_and_hms(2026, 6, 19, 9, 0, 0).unwrap();
let new_expires_at = now + Duration::hours(1);
let new_access_token = test_jwt(new_expires_at.timestamp());
store
.insert(NewAccount {
id: "acct-refreshing-marker".to_string(),
email: Some("marker@example.com".to_string()),
account_id: Some("chatgpt-marker".to_string()),
user_id: Some("user-marker".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(
test_jwt((now + Duration::seconds(30)).timestamp()).into(),
),
refresh_token: Some(SecretString::new("refresh-marker".to_string().into())),
access_token_expires_at: Some(now + Duration::seconds(30)),
status: AccountStatus::Active,
})
.await
.expect("account should be inserted");
let observed_statuses = Arc::new(Mutex::new(Vec::new()));
let refresher = StoreObservingTokenRefresher {
store: store.clone(),
account_id: "acct-refreshing-marker".to_string(),
observed_statuses: observed_statuses.clone(),
response: Ok(TokenPair {
access_token: new_access_token.clone(),
refresh_token: None,
}),
};
let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new(
store.clone(),
RefreshPolicy {
refresh_margin_seconds: 300,
refresh_concurrency: 1,
},
refresher,
);
let summary = task
.refresh_due_accounts_once_at(now)
.await
.expect("refresh should succeed");
let stored = store
.get("acct-refreshing-marker")
.await
.expect("account should load")
.expect("account should exist");
let observed_statuses = observed_statuses.lock().await.clone();
assert_eq!(summary.refreshed, 1);
assert_eq!(observed_statuses, [AccountStatus::Refreshing]);
assert_eq!(stored.status, AccountStatus::Active);
assert_eq!(
stored.access_token.expose_secret(),
new_access_token.as_str()
);
}
#[tokio::test]
async fn token_refresh_task_should_recover_refreshing_account_after_restart() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh-recovery.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool, SecretBox::new([14u8; 32]));
let now = Utc.with_ymd_and_hms(2026, 6, 19, 10, 0, 0).unwrap();
let old_access_token = test_jwt((now + Duration::hours(1)).timestamp());
let new_access_token = test_jwt((now + Duration::hours(2)).timestamp());
store
.insert(NewAccount {
id: "acct-refreshing-recovery".to_string(),
email: Some("recovery@example.com".to_string()),
account_id: Some("chatgpt-recovery".to_string()),
user_id: Some("user-recovery".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(old_access_token.into()),
refresh_token: Some(SecretString::new("refresh-recovery".to_string().into())),
access_token_expires_at: Some(now + Duration::hours(1)),
status: AccountStatus::Refreshing,
})
.await
.expect("account should be inserted");
let refresher = StaticTokenRefresher {
response: Arc::new(Mutex::new(Ok(TokenPair {
access_token: new_access_token.clone(),
refresh_token: None,
}))),
};
let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new(
store.clone(),
RefreshPolicy {
refresh_margin_seconds: 300,
refresh_concurrency: 1,
},
refresher,
);
let summary = task
.refresh_due_accounts_once_at(now)
.await
.expect("refreshing account should recover");
let stored = store
.get("acct-refreshing-recovery")
.await
.expect("account should load")
.expect("account should exist");
assert_eq!(summary.refreshed, 1);
assert_eq!(stored.status, AccountStatus::Active);
assert_eq!(
stored.access_token.expose_secret(),
new_access_token.as_str()
);
}
#[tokio::test]
async fn token_refresh_task_should_restore_active_status_after_transport_failure() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh-transport-failure.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool, SecretBox::new([15u8; 32]));
let now = Utc.with_ymd_and_hms(2026, 6, 19, 11, 0, 0).unwrap();
let old_access_token = test_jwt((now + Duration::seconds(30)).timestamp());
store
.insert(NewAccount {
id: "acct-refresh-transport".to_string(),
email: Some("transport@example.com".to_string()),
account_id: Some("chatgpt-transport".to_string()),
user_id: Some("user-transport".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(old_access_token.clone().into()),
refresh_token: Some(SecretString::new("refresh-transport".to_string().into())),
access_token_expires_at: Some(now + Duration::seconds(30)),
status: AccountStatus::Active,
})
.await
.expect("account should be inserted");
let observed_statuses = Arc::new(Mutex::new(Vec::new()));
let refresher = StoreObservingTokenRefresher {
store: store.clone(),
account_id: "acct-refresh-transport".to_string(),
observed_statuses: observed_statuses.clone(),
response: Err(RefreshFailure::Transport),
};
let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new(
store.clone(),
RefreshPolicy {
refresh_margin_seconds: 300,
refresh_concurrency: 1,
},
refresher,
)
.with_retry_delays(vec![StdDuration::ZERO; 4]);
let summary = task
.refresh_due_accounts_once_at(now)
.await
.expect("transport failure should be summarized");
let stored = store
.get("acct-refresh-transport")
.await
.expect("account should load")
.expect("account should exist");
let observed_statuses = observed_statuses.lock().await.clone();
assert_eq!(summary.failed, 1);
assert_eq!(observed_statuses, [AccountStatus::Refreshing; 5]);
assert_eq!(stored.status, AccountStatus::Active);
assert_eq!(
stored.access_token.expose_secret(),
old_access_token.as_str()
);
}
#[tokio::test]
async fn token_refresh_task_should_skip_duplicate_in_flight_refresh() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh-in-flight.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool, SecretBox::new([18u8; 32]));
let now = Utc.with_ymd_and_hms(2026, 6, 19, 14, 0, 0).unwrap();
let new_access_token = test_jwt((now + Duration::hours(1)).timestamp());
store
.insert(NewAccount {
id: "acct-refresh-in-flight".to_string(),
email: Some("in-flight@example.com".to_string()),
account_id: Some("chatgpt-in-flight".to_string()),
user_id: Some("user-in-flight".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(
test_jwt((now + Duration::seconds(30)).timestamp()).into(),
),
refresh_token: Some(SecretString::new("refresh-in-flight".to_string().into())),
access_token_expires_at: Some(now + Duration::seconds(30)),
status: AccountStatus::Active,
})
.await
.expect("account should be inserted");
let refresher = BlockingTokenRefresher {
calls: Arc::new(AtomicUsize::new(0)),
started: Arc::new(Notify::new()),
release: Arc::new(Notify::new()),
response: Ok(TokenPair {
access_token: new_access_token.clone(),
refresh_token: None,
}),
};
let task = Arc::new(
codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new(
store.clone(),
RefreshPolicy {
refresh_margin_seconds: 300,
refresh_concurrency: 1,
},
refresher.clone(),
),
);
let first = {
let task = task.clone();
tokio::spawn(async move { task.refresh_due_accounts_once_at(now).await })
};
refresher.started.notified().await;
let second = {
let task = task.clone();
tokio::spawn(async move { task.refresh_due_accounts_once_at(now).await })
};
let second = match timeout(StdDuration::from_millis(100), second).await {
Ok(result) => result.expect("second scan should join"),
Err(_) => {
refresher.release.notify_waiters();
let _ = first.await;
panic!("second scan should skip instead of waiting on duplicate refresh");
}
}
.expect("second scan should succeed");
refresher.release.notify_waiters();
let first = first
.await
.expect("first scan should join")
.expect("first scan should succeed");
let stored = store
.get("acct-refresh-in-flight")
.await
.expect("account should load")
.expect("account should exist");
assert_eq!(first.refreshed, 1);
assert_eq!(second.skipped, 1);
assert_eq!(refresher.calls.load(Ordering::SeqCst), 1);
assert_eq!(stored.status, AccountStatus::Active);
assert_eq!(
stored.access_token.expose_secret(),
new_access_token.as_str()
);
}
#[tokio::test]
async fn token_refresh_task_should_persist_success_after_transient_transport_retry() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh-transient-success.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool, SecretBox::new([16u8; 32]));
let now = Utc.with_ymd_and_hms(2026, 6, 19, 12, 0, 0).unwrap();
let new_access_token = test_jwt((now + Duration::hours(1)).timestamp());
store
.insert(NewAccount {
id: "acct-refresh-transient-success".to_string(),
email: Some("transient@example.com".to_string()),
account_id: Some("chatgpt-transient".to_string()),
user_id: Some("user-transient".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(
test_jwt((now + Duration::seconds(30)).timestamp()).into(),
),
refresh_token: Some(SecretString::new("refresh-transient".to_string().into())),
access_token_expires_at: Some(now + Duration::seconds(30)),
status: AccountStatus::Active,
})
.await
.expect("account should be inserted");
let observed_statuses = Arc::new(Mutex::new(Vec::new()));
let refresher = SequenceTokenRefresher {
store: store.clone(),
account_id: "acct-refresh-transient-success".to_string(),
observed_statuses: observed_statuses.clone(),
responses: Arc::new(Mutex::new(VecDeque::from(vec![
Err(RefreshFailure::Transport),
Ok(TokenPair {
access_token: new_access_token.clone(),
refresh_token: None,
}),
]))),
};
let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new(
store.clone(),
RefreshPolicy {
refresh_margin_seconds: 300,
refresh_concurrency: 1,
},
refresher,
)
.with_retry_delays(vec![StdDuration::ZERO; 4]);
let summary = task
.refresh_due_accounts_once_at(now)
.await
.expect("retry should eventually refresh");
let stored = store
.get("acct-refresh-transient-success")
.await
.expect("account should load")
.expect("account should exist");
let observed_statuses = observed_statuses.lock().await.clone();
assert_eq!(summary.refreshed, 1);
assert_eq!(summary.failed, 0);
assert_eq!(observed_statuses, [AccountStatus::Refreshing; 2]);
assert_eq!(stored.status, AccountStatus::Active);
assert_eq!(
stored.access_token.expose_secret(),
new_access_token.as_str()
);
}
#[tokio::test]
async fn token_refresh_task_should_delay_recovery_after_retry_exhaustion() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh-delayed-recovery.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool, SecretBox::new([19u8; 32]));
let now = Utc.with_ymd_and_hms(2026, 6, 19, 15, 0, 0).unwrap();
let old_access_token = test_jwt((now + Duration::seconds(30)).timestamp());
let new_access_token = test_jwt((now + Duration::hours(1)).timestamp());
store
.insert(NewAccount {
id: "acct-refresh-delayed-recovery".to_string(),
email: Some("delayed-recovery@example.com".to_string()),
account_id: Some("chatgpt-delayed-recovery".to_string()),
user_id: Some("user-delayed-recovery".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(old_access_token.clone().into()),
refresh_token: Some(SecretString::new(
"refresh-delayed-recovery".to_string().into(),
)),
access_token_expires_at: Some(now + Duration::seconds(30)),
status: AccountStatus::Active,
})
.await
.expect("account should be inserted");
let mut responses = Vec::new();
responses.extend((0..5).map(|_| Err(RefreshFailure::Transport)));
responses.push(Ok(TokenPair {
access_token: new_access_token.clone(),
refresh_token: None,
}));
let observed_statuses = Arc::new(Mutex::new(Vec::new()));
let refresher = SequenceTokenRefresher {
store: store.clone(),
account_id: "acct-refresh-delayed-recovery".to_string(),
observed_statuses: observed_statuses.clone(),
responses: Arc::new(Mutex::new(VecDeque::from(responses))),
};
let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new(
store.clone(),
RefreshPolicy {
refresh_margin_seconds: 300,
refresh_concurrency: 1,
},
refresher,
)
.with_retry_delays(vec![StdDuration::ZERO; 4]);
let failed = task
.refresh_due_accounts_once_at(now)
.await
.expect("first scan should summarize retry exhaustion");
let delayed = task
.refresh_due_accounts_once_at(now + Duration::minutes(5))
.await
.expect("recovery window should skip refresh");
let refreshed = task
.refresh_due_accounts_once_at(now + Duration::minutes(10) + Duration::seconds(1))
.await
.expect("recovery window should allow refresh");
let stored = store
.get("acct-refresh-delayed-recovery")
.await
.expect("account should load")
.expect("account should exist");
let observed_statuses = observed_statuses.lock().await.clone();
assert_eq!(failed.failed, 1);
assert_eq!(delayed.skipped, 1);
assert_eq!(refreshed.refreshed, 1);
assert_eq!(observed_statuses, [AccountStatus::Refreshing; 6]);
assert_eq!(stored.status, AccountStatus::Active);
assert_eq!(
stored.access_token.expose_secret(),
new_access_token.as_str()
);
}
#[tokio::test]
async fn token_refresh_task_should_schedule_future_per_account_timer_without_refreshing_early() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh-future-timer.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool, SecretBox::new([20u8; 32]));
let now = Utc.with_ymd_and_hms(2026, 6, 19, 16, 0, 0).unwrap();
let old_access_token = test_jwt((now + Duration::minutes(10)).timestamp());
store
.insert(NewAccount {
id: "acct-refresh-future-timer".to_string(),
email: Some("future-timer@example.com".to_string()),
account_id: Some("chatgpt-future-timer".to_string()),
user_id: Some("user-future-timer".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(old_access_token.clone().into()),
refresh_token: Some(SecretString::new("refresh-future-timer".to_string().into())),
access_token_expires_at: Some(now + Duration::minutes(10)),
status: AccountStatus::Active,
})
.await
.expect("account should be inserted");
let refresher = CountingTokenRefresher::default();
let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new(
store,
RefreshPolicy {
refresh_margin_seconds: 300,
refresh_concurrency: 1,
},
refresher.clone(),
);
let summary = task
.schedule_account_timers_once_at(now)
.await
.expect("timer scheduling should succeed");
tokio::task::yield_now().await;
assert_eq!(summary.scheduled, 1);
assert_eq!(task.scheduled_timer_count().await, 1);
assert_eq!(refresher.calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn token_refresh_task_should_fire_per_account_timer_at_refresh_time() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh-per-account-timer.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool, SecretBox::new([21u8; 32]));
let now = Utc.with_ymd_and_hms(2026, 6, 19, 17, 0, 0).unwrap();
let old_access_token = test_jwt((now + Duration::seconds(1)).timestamp());
let new_access_token = test_jwt((now + Duration::hours(1)).timestamp());
store
.insert(NewAccount {
id: "acct-refresh-timer".to_string(),
email: Some("timer@example.com".to_string()),
account_id: Some("chatgpt-timer".to_string()),
user_id: Some("user-timer".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(old_access_token.clone().into()),
refresh_token: Some(SecretString::new("refresh-timer".to_string().into())),
access_token_expires_at: Some(now + Duration::seconds(1)),
status: AccountStatus::Active,
})
.await
.expect("account should be inserted");
let refresher = NotifyingTokenRefresher {
calls: Arc::new(AtomicUsize::new(0)),
started: Arc::new(Notify::new()),
response: Ok(TokenPair {
access_token: new_access_token.clone(),
refresh_token: None,
}),
};
let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new(
store.clone(),
RefreshPolicy {
refresh_margin_seconds: 1,
refresh_concurrency: 1,
},
refresher.clone(),
)
.with_retry_delays(vec![StdDuration::ZERO; 4]);
let started = refresher.started.notified();
tokio::pin!(started);
let summary = task
.schedule_account_timers_once_at(now)
.await
.expect("timer scheduling should succeed");
timeout(StdDuration::from_secs(2), &mut started)
.await
.expect("scheduled timer should trigger refresh");
tokio::task::yield_now().await;
let stored = store
.get("acct-refresh-timer")
.await
.expect("account should load")
.expect("account should exist");
assert_eq!(summary.immediate, 1);
assert_eq!(refresher.calls.load(Ordering::SeqCst), 1);
assert_eq!(task.scheduled_timer_count().await, 1);
assert_eq!(stored.status, AccountStatus::Active);
assert_eq!(
stored.access_token.expose_secret(),
new_access_token.as_str()
);
}
#[tokio::test]
async fn token_refresh_task_should_reschedule_next_timer_after_scheduled_refresh() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh-next-timer.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool, SecretBox::new([22u8; 32]));
let now = Utc::now();
let old_expires_at = now + Duration::milliseconds(50);
let new_access_token = test_jwt((now + Duration::hours(1)).timestamp());
store
.insert(NewAccount {
id: "acct-refresh-next-timer".to_string(),
email: Some("next-timer@example.com".to_string()),
account_id: Some("chatgpt-next-timer".to_string()),
user_id: Some("user-next-timer".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(test_jwt(old_expires_at.timestamp()).into()),
refresh_token: Some(SecretString::new("refresh-next-timer".to_string().into())),
access_token_expires_at: Some(old_expires_at),
status: AccountStatus::Active,
})
.await
.expect("account should be inserted");
let refresher = NotifyingTokenRefresher {
calls: Arc::new(AtomicUsize::new(0)),
started: Arc::new(Notify::new()),
response: Ok(TokenPair {
access_token: new_access_token.clone(),
refresh_token: None,
}),
};
let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new(
store.clone(),
RefreshPolicy {
refresh_margin_seconds: 0,
refresh_concurrency: 1,
},
refresher.clone(),
)
.with_retry_delays(vec![StdDuration::ZERO; 4]);
let started = refresher.started.notified();
tokio::pin!(started);
let summary = task
.schedule_account_timers_once_at(now)
.await
.expect("timer scheduling should succeed");
timeout(StdDuration::from_secs(2), &mut started)
.await
.expect("scheduled timer should trigger refresh");
let mut stored = store
.get("acct-refresh-next-timer")
.await
.expect("account should load")
.expect("account should exist");
for _ in 0..50 {
if stored.access_token.expose_secret() == new_access_token.as_str() {
break;
}
sleep(StdDuration::from_millis(20)).await;
stored = store
.get("acct-refresh-next-timer")
.await
.expect("account should load")
.expect("account should exist");
}
assert_eq!(summary.scheduled, 1);
assert_eq!(refresher.calls.load(Ordering::SeqCst), 1);
assert_eq!(
stored.access_token.expose_secret(),
new_access_token.as_str()
);
assert_eq!(task.scheduled_timer_count().await, 1);
}
#[tokio::test]
async fn token_refresh_task_should_confirm_invalid_grant_before_expiring_account() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh-permanent-failure.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool, SecretBox::new([17u8; 32]));
let now = Utc.with_ymd_and_hms(2026, 6, 19, 13, 0, 0).unwrap();
let old_access_token = test_jwt((now + Duration::seconds(30)).timestamp());
store
.insert(NewAccount {
id: "acct-refresh-invalid-grant".to_string(),
email: Some("invalid-grant@example.com".to_string()),
account_id: Some("chatgpt-invalid-grant".to_string()),
user_id: Some("user-invalid-grant".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(old_access_token.clone().into()),
refresh_token: Some(SecretString::new(
"refresh-invalid-grant".to_string().into(),
)),
access_token_expires_at: Some(now + Duration::seconds(30)),
status: AccountStatus::Active,
})
.await
.expect("account should be inserted");
let observed_statuses = Arc::new(Mutex::new(Vec::new()));
let refresher = SequenceTokenRefresher {
store: store.clone(),
account_id: "acct-refresh-invalid-grant".to_string(),
observed_statuses: observed_statuses.clone(),
responses: Arc::new(Mutex::new(VecDeque::from(vec![
Err(RefreshFailure::InvalidGrant),
Err(RefreshFailure::InvalidGrant),
]))),
};
let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new(
store.clone(),
RefreshPolicy {
refresh_margin_seconds: 300,
refresh_concurrency: 1,
},
refresher,
)
.with_retry_delays(vec![StdDuration::ZERO; 4]);
let summary = task
.refresh_due_accounts_once_at(now)
.await
.expect("confirmed permanent failure should update status");
let stored = store
.get("acct-refresh-invalid-grant")
.await
.expect("account should load")
.expect("account should exist");
let observed_statuses = observed_statuses.lock().await.clone();
assert_eq!(summary.status_updated, 1);
assert_eq!(observed_statuses, [AccountStatus::Refreshing; 2]);
assert_eq!(stored.status, AccountStatus::Expired);
assert_eq!(
stored.access_token.expose_secret(),
old_access_token.as_str()
);
}
#[path = "token_refresh/token_refresh_failures.rs"]
mod token_refresh_failures;
#[path = "token_refresh/token_refresh_scheduling.rs"]
mod token_refresh_scheduling;
#[path = "token_refresh/token_refresh_success.rs"]
mod token_refresh_success;
#[derive(Clone)]
struct StaticTokenRefresher {
@@ -0,0 +1,337 @@
use super::*;
#[tokio::test]
async fn token_refresh_task_should_mark_due_account_refreshing_before_refresher_call() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh-status.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool, SecretBox::new([13u8; 32]));
let now = Utc.with_ymd_and_hms(2026, 6, 19, 9, 0, 0).unwrap();
let new_expires_at = now + Duration::hours(1);
let new_access_token = test_jwt(new_expires_at.timestamp());
store
.insert(NewAccount {
id: "acct-refreshing-marker".to_string(),
email: Some("marker@example.com".to_string()),
account_id: Some("chatgpt-marker".to_string()),
user_id: Some("user-marker".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(
test_jwt((now + Duration::seconds(30)).timestamp()).into(),
),
refresh_token: Some(SecretString::new("refresh-marker".to_string().into())),
access_token_expires_at: Some(now + Duration::seconds(30)),
status: AccountStatus::Active,
})
.await
.expect("account should be inserted");
let observed_statuses = Arc::new(Mutex::new(Vec::new()));
let refresher = StoreObservingTokenRefresher {
store: store.clone(),
account_id: "acct-refreshing-marker".to_string(),
observed_statuses: observed_statuses.clone(),
response: Ok(TokenPair {
access_token: new_access_token.clone(),
refresh_token: None,
}),
};
let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new(
store.clone(),
RefreshPolicy {
refresh_margin_seconds: 300,
refresh_concurrency: 1,
},
refresher,
);
let summary = task
.refresh_due_accounts_once_at(now)
.await
.expect("refresh should succeed");
let stored = store
.get("acct-refreshing-marker")
.await
.expect("account should load")
.expect("account should exist");
let observed_statuses = observed_statuses.lock().await.clone();
assert_eq!(summary.refreshed, 1);
assert_eq!(observed_statuses, [AccountStatus::Refreshing]);
assert_eq!(stored.status, AccountStatus::Active);
assert_eq!(
stored.access_token.expose_secret(),
new_access_token.as_str()
);
}
#[tokio::test]
async fn token_refresh_task_should_recover_refreshing_account_after_restart() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh-recovery.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool, SecretBox::new([14u8; 32]));
let now = Utc.with_ymd_and_hms(2026, 6, 19, 10, 0, 0).unwrap();
let old_access_token = test_jwt((now + Duration::hours(1)).timestamp());
let new_access_token = test_jwt((now + Duration::hours(2)).timestamp());
store
.insert(NewAccount {
id: "acct-refreshing-recovery".to_string(),
email: Some("recovery@example.com".to_string()),
account_id: Some("chatgpt-recovery".to_string()),
user_id: Some("user-recovery".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(old_access_token.into()),
refresh_token: Some(SecretString::new("refresh-recovery".to_string().into())),
access_token_expires_at: Some(now + Duration::hours(1)),
status: AccountStatus::Refreshing,
})
.await
.expect("account should be inserted");
let refresher = StaticTokenRefresher {
response: Arc::new(Mutex::new(Ok(TokenPair {
access_token: new_access_token.clone(),
refresh_token: None,
}))),
};
let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new(
store.clone(),
RefreshPolicy {
refresh_margin_seconds: 300,
refresh_concurrency: 1,
},
refresher,
);
let summary = task
.refresh_due_accounts_once_at(now)
.await
.expect("refreshing account should recover");
let stored = store
.get("acct-refreshing-recovery")
.await
.expect("account should load")
.expect("account should exist");
assert_eq!(summary.refreshed, 1);
assert_eq!(stored.status, AccountStatus::Active);
assert_eq!(
stored.access_token.expose_secret(),
new_access_token.as_str()
);
}
#[tokio::test]
async fn token_refresh_task_should_restore_active_status_after_transport_failure() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh-transport-failure.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool, SecretBox::new([15u8; 32]));
let now = Utc.with_ymd_and_hms(2026, 6, 19, 11, 0, 0).unwrap();
let old_access_token = test_jwt((now + Duration::seconds(30)).timestamp());
store
.insert(NewAccount {
id: "acct-refresh-transport".to_string(),
email: Some("transport@example.com".to_string()),
account_id: Some("chatgpt-transport".to_string()),
user_id: Some("user-transport".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(old_access_token.clone().into()),
refresh_token: Some(SecretString::new("refresh-transport".to_string().into())),
access_token_expires_at: Some(now + Duration::seconds(30)),
status: AccountStatus::Active,
})
.await
.expect("account should be inserted");
let observed_statuses = Arc::new(Mutex::new(Vec::new()));
let refresher = StoreObservingTokenRefresher {
store: store.clone(),
account_id: "acct-refresh-transport".to_string(),
observed_statuses: observed_statuses.clone(),
response: Err(RefreshFailure::Transport),
};
let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new(
store.clone(),
RefreshPolicy {
refresh_margin_seconds: 300,
refresh_concurrency: 1,
},
refresher,
)
.with_retry_delays(vec![StdDuration::ZERO; 4]);
let summary = task
.refresh_due_accounts_once_at(now)
.await
.expect("transport failure should be summarized");
let stored = store
.get("acct-refresh-transport")
.await
.expect("account should load")
.expect("account should exist");
let observed_statuses = observed_statuses.lock().await.clone();
assert_eq!(summary.failed, 1);
assert_eq!(observed_statuses, [AccountStatus::Refreshing; 5]);
assert_eq!(stored.status, AccountStatus::Active);
assert_eq!(
stored.access_token.expose_secret(),
old_access_token.as_str()
);
}
#[tokio::test]
async fn token_refresh_task_should_delay_recovery_after_retry_exhaustion() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh-delayed-recovery.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool, SecretBox::new([19u8; 32]));
let now = Utc.with_ymd_and_hms(2026, 6, 19, 15, 0, 0).unwrap();
let old_access_token = test_jwt((now + Duration::seconds(30)).timestamp());
let new_access_token = test_jwt((now + Duration::hours(1)).timestamp());
store
.insert(NewAccount {
id: "acct-refresh-delayed-recovery".to_string(),
email: Some("delayed-recovery@example.com".to_string()),
account_id: Some("chatgpt-delayed-recovery".to_string()),
user_id: Some("user-delayed-recovery".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(old_access_token.clone().into()),
refresh_token: Some(SecretString::new(
"refresh-delayed-recovery".to_string().into(),
)),
access_token_expires_at: Some(now + Duration::seconds(30)),
status: AccountStatus::Active,
})
.await
.expect("account should be inserted");
let mut responses = Vec::new();
responses.extend((0..5).map(|_| Err(RefreshFailure::Transport)));
responses.push(Ok(TokenPair {
access_token: new_access_token.clone(),
refresh_token: None,
}));
let observed_statuses = Arc::new(Mutex::new(Vec::new()));
let refresher = SequenceTokenRefresher {
store: store.clone(),
account_id: "acct-refresh-delayed-recovery".to_string(),
observed_statuses: observed_statuses.clone(),
responses: Arc::new(Mutex::new(VecDeque::from(responses))),
};
let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new(
store.clone(),
RefreshPolicy {
refresh_margin_seconds: 300,
refresh_concurrency: 1,
},
refresher,
)
.with_retry_delays(vec![StdDuration::ZERO; 4]);
let failed = task
.refresh_due_accounts_once_at(now)
.await
.expect("first scan should summarize retry exhaustion");
let delayed = task
.refresh_due_accounts_once_at(now + Duration::minutes(5))
.await
.expect("recovery window should skip refresh");
let refreshed = task
.refresh_due_accounts_once_at(now + Duration::minutes(10) + Duration::seconds(1))
.await
.expect("recovery window should allow refresh");
let stored = store
.get("acct-refresh-delayed-recovery")
.await
.expect("account should load")
.expect("account should exist");
let observed_statuses = observed_statuses.lock().await.clone();
assert_eq!(failed.failed, 1);
assert_eq!(delayed.skipped, 1);
assert_eq!(refreshed.refreshed, 1);
assert_eq!(observed_statuses, [AccountStatus::Refreshing; 6]);
assert_eq!(stored.status, AccountStatus::Active);
assert_eq!(
stored.access_token.expose_secret(),
new_access_token.as_str()
);
}
#[tokio::test]
async fn token_refresh_task_should_confirm_invalid_grant_before_expiring_account() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh-permanent-failure.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool, SecretBox::new([17u8; 32]));
let now = Utc.with_ymd_and_hms(2026, 6, 19, 13, 0, 0).unwrap();
let old_access_token = test_jwt((now + Duration::seconds(30)).timestamp());
store
.insert(NewAccount {
id: "acct-refresh-invalid-grant".to_string(),
email: Some("invalid-grant@example.com".to_string()),
account_id: Some("chatgpt-invalid-grant".to_string()),
user_id: Some("user-invalid-grant".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(old_access_token.clone().into()),
refresh_token: Some(SecretString::new(
"refresh-invalid-grant".to_string().into(),
)),
access_token_expires_at: Some(now + Duration::seconds(30)),
status: AccountStatus::Active,
})
.await
.expect("account should be inserted");
let observed_statuses = Arc::new(Mutex::new(Vec::new()));
let refresher = SequenceTokenRefresher {
store: store.clone(),
account_id: "acct-refresh-invalid-grant".to_string(),
observed_statuses: observed_statuses.clone(),
responses: Arc::new(Mutex::new(VecDeque::from(vec![
Err(RefreshFailure::InvalidGrant),
Err(RefreshFailure::InvalidGrant),
]))),
};
let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new(
store.clone(),
RefreshPolicy {
refresh_margin_seconds: 300,
refresh_concurrency: 1,
},
refresher,
)
.with_retry_delays(vec![StdDuration::ZERO; 4]);
let summary = task
.refresh_due_accounts_once_at(now)
.await
.expect("confirmed permanent failure should update status");
let stored = store
.get("acct-refresh-invalid-grant")
.await
.expect("account should load")
.expect("account should exist");
let observed_statuses = observed_statuses.lock().await.clone();
assert_eq!(summary.status_updated, 1);
assert_eq!(observed_statuses, [AccountStatus::Refreshing; 2]);
assert_eq!(stored.status, AccountStatus::Expired);
assert_eq!(
stored.access_token.expose_secret(),
old_access_token.as_str()
);
}
@@ -0,0 +1,351 @@
use super::*;
#[tokio::test]
async fn token_refresh_task_should_skip_account_when_refresh_lease_is_held() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh-lease.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool.clone(), SecretBox::new([12u8; 32]));
let leases = SqliteRefreshLeaseStore::new(pool.clone());
let now = Utc.with_ymd_and_hms(2026, 6, 19, 8, 0, 0).unwrap();
let old_access_token = test_jwt((now + Duration::seconds(30)).timestamp());
store
.insert(NewAccount {
id: "acct-lease-held".to_string(),
email: Some("lease@example.com".to_string()),
account_id: Some("chatgpt-lease".to_string()),
user_id: Some("user-lease".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(old_access_token.clone().into()),
refresh_token: Some(SecretString::new("refresh-held".to_string().into())),
access_token_expires_at: Some(now + Duration::seconds(30)),
status: AccountStatus::Active,
})
.await
.expect("account should be inserted");
assert!(leases
.try_acquire(
"acct-lease-held",
"external-owner",
now + Duration::minutes(5),
now,
)
.await
.expect("external owner should acquire lease"));
let refresher = CountingTokenRefresher::default();
let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new(
store.clone(),
RefreshPolicy {
refresh_margin_seconds: 300,
refresh_concurrency: 1,
},
refresher.clone(),
)
.with_refresh_lease_store(leases);
let summary = task
.refresh_due_accounts_once_at(now)
.await
.expect("refresh scan should skip lease-held account");
let stored = store
.get("acct-lease-held")
.await
.expect("account should load")
.expect("account should exist");
assert_eq!(summary.skipped, 1);
assert_eq!(summary.refreshed, 0);
assert_eq!(refresher.calls.load(Ordering::SeqCst), 0);
assert_eq!(
stored.access_token.expose_secret(),
old_access_token.as_str()
);
assert_eq!(stored.status, AccountStatus::Active);
}
#[tokio::test]
async fn token_refresh_task_should_skip_duplicate_in_flight_refresh() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh-in-flight.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool, SecretBox::new([18u8; 32]));
let now = Utc.with_ymd_and_hms(2026, 6, 19, 14, 0, 0).unwrap();
let new_access_token = test_jwt((now + Duration::hours(1)).timestamp());
store
.insert(NewAccount {
id: "acct-refresh-in-flight".to_string(),
email: Some("in-flight@example.com".to_string()),
account_id: Some("chatgpt-in-flight".to_string()),
user_id: Some("user-in-flight".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(
test_jwt((now + Duration::seconds(30)).timestamp()).into(),
),
refresh_token: Some(SecretString::new("refresh-in-flight".to_string().into())),
access_token_expires_at: Some(now + Duration::seconds(30)),
status: AccountStatus::Active,
})
.await
.expect("account should be inserted");
let refresher = BlockingTokenRefresher {
calls: Arc::new(AtomicUsize::new(0)),
started: Arc::new(Notify::new()),
release: Arc::new(Notify::new()),
response: Ok(TokenPair {
access_token: new_access_token.clone(),
refresh_token: None,
}),
};
let task = Arc::new(
codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new(
store.clone(),
RefreshPolicy {
refresh_margin_seconds: 300,
refresh_concurrency: 1,
},
refresher.clone(),
),
);
let first = {
let task = task.clone();
tokio::spawn(async move { task.refresh_due_accounts_once_at(now).await })
};
refresher.started.notified().await;
let second = {
let task = task.clone();
tokio::spawn(async move { task.refresh_due_accounts_once_at(now).await })
};
let second = match timeout(StdDuration::from_millis(100), second).await {
Ok(result) => result.expect("second scan should join"),
Err(_) => {
refresher.release.notify_waiters();
let _ = first.await;
panic!("second scan should skip instead of waiting on duplicate refresh");
}
}
.expect("second scan should succeed");
refresher.release.notify_waiters();
let first = first
.await
.expect("first scan should join")
.expect("first scan should succeed");
let stored = store
.get("acct-refresh-in-flight")
.await
.expect("account should load")
.expect("account should exist");
assert_eq!(first.refreshed, 1);
assert_eq!(second.skipped, 1);
assert_eq!(refresher.calls.load(Ordering::SeqCst), 1);
assert_eq!(stored.status, AccountStatus::Active);
assert_eq!(
stored.access_token.expose_secret(),
new_access_token.as_str()
);
}
#[tokio::test]
async fn token_refresh_task_should_schedule_future_per_account_timer_without_refreshing_early() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh-future-timer.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool, SecretBox::new([20u8; 32]));
let now = Utc.with_ymd_and_hms(2026, 6, 19, 16, 0, 0).unwrap();
let old_access_token = test_jwt((now + Duration::minutes(10)).timestamp());
store
.insert(NewAccount {
id: "acct-refresh-future-timer".to_string(),
email: Some("future-timer@example.com".to_string()),
account_id: Some("chatgpt-future-timer".to_string()),
user_id: Some("user-future-timer".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(old_access_token.clone().into()),
refresh_token: Some(SecretString::new("refresh-future-timer".to_string().into())),
access_token_expires_at: Some(now + Duration::minutes(10)),
status: AccountStatus::Active,
})
.await
.expect("account should be inserted");
let refresher = CountingTokenRefresher::default();
let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new(
store,
RefreshPolicy {
refresh_margin_seconds: 300,
refresh_concurrency: 1,
},
refresher.clone(),
);
let summary = task
.schedule_account_timers_once_at(now)
.await
.expect("timer scheduling should succeed");
tokio::task::yield_now().await;
assert_eq!(summary.scheduled, 1);
assert_eq!(task.scheduled_timer_count().await, 1);
assert_eq!(refresher.calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn token_refresh_task_should_fire_per_account_timer_at_refresh_time() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh-per-account-timer.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool, SecretBox::new([21u8; 32]));
let now = Utc.with_ymd_and_hms(2026, 6, 19, 17, 0, 0).unwrap();
let old_access_token = test_jwt((now + Duration::seconds(1)).timestamp());
let new_access_token = test_jwt((now + Duration::hours(1)).timestamp());
store
.insert(NewAccount {
id: "acct-refresh-timer".to_string(),
email: Some("timer@example.com".to_string()),
account_id: Some("chatgpt-timer".to_string()),
user_id: Some("user-timer".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(old_access_token.clone().into()),
refresh_token: Some(SecretString::new("refresh-timer".to_string().into())),
access_token_expires_at: Some(now + Duration::seconds(1)),
status: AccountStatus::Active,
})
.await
.expect("account should be inserted");
let refresher = NotifyingTokenRefresher {
calls: Arc::new(AtomicUsize::new(0)),
started: Arc::new(Notify::new()),
response: Ok(TokenPair {
access_token: new_access_token.clone(),
refresh_token: None,
}),
};
let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new(
store.clone(),
RefreshPolicy {
refresh_margin_seconds: 1,
refresh_concurrency: 1,
},
refresher.clone(),
)
.with_retry_delays(vec![StdDuration::ZERO; 4]);
let started = refresher.started.notified();
tokio::pin!(started);
let summary = task
.schedule_account_timers_once_at(now)
.await
.expect("timer scheduling should succeed");
timeout(StdDuration::from_secs(2), &mut started)
.await
.expect("scheduled timer should trigger refresh");
tokio::task::yield_now().await;
let stored = store
.get("acct-refresh-timer")
.await
.expect("account should load")
.expect("account should exist");
assert_eq!(summary.immediate, 1);
assert_eq!(refresher.calls.load(Ordering::SeqCst), 1);
assert_eq!(task.scheduled_timer_count().await, 1);
assert_eq!(stored.status, AccountStatus::Active);
assert_eq!(
stored.access_token.expose_secret(),
new_access_token.as_str()
);
}
#[tokio::test]
async fn token_refresh_task_should_reschedule_next_timer_after_scheduled_refresh() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh-next-timer.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool, SecretBox::new([22u8; 32]));
let now = Utc::now();
let old_expires_at = now + Duration::milliseconds(50);
let new_access_token = test_jwt((now + Duration::hours(1)).timestamp());
store
.insert(NewAccount {
id: "acct-refresh-next-timer".to_string(),
email: Some("next-timer@example.com".to_string()),
account_id: Some("chatgpt-next-timer".to_string()),
user_id: Some("user-next-timer".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(test_jwt(old_expires_at.timestamp()).into()),
refresh_token: Some(SecretString::new("refresh-next-timer".to_string().into())),
access_token_expires_at: Some(old_expires_at),
status: AccountStatus::Active,
})
.await
.expect("account should be inserted");
let refresher = NotifyingTokenRefresher {
calls: Arc::new(AtomicUsize::new(0)),
started: Arc::new(Notify::new()),
response: Ok(TokenPair {
access_token: new_access_token.clone(),
refresh_token: None,
}),
};
let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new(
store.clone(),
RefreshPolicy {
refresh_margin_seconds: 0,
refresh_concurrency: 1,
},
refresher.clone(),
)
.with_retry_delays(vec![StdDuration::ZERO; 4]);
let started = refresher.started.notified();
tokio::pin!(started);
let summary = task
.schedule_account_timers_once_at(now)
.await
.expect("timer scheduling should succeed");
timeout(StdDuration::from_secs(2), &mut started)
.await
.expect("scheduled timer should trigger refresh");
let mut stored = store
.get("acct-refresh-next-timer")
.await
.expect("account should load")
.expect("account should exist");
for _ in 0..50 {
if stored.access_token.expose_secret() == new_access_token.as_str() {
break;
}
sleep(StdDuration::from_millis(20)).await;
stored = store
.get("acct-refresh-next-timer")
.await
.expect("account should load")
.expect("account should exist");
}
assert_eq!(summary.scheduled, 1);
assert_eq!(refresher.calls.load(Ordering::SeqCst), 1);
assert_eq!(
stored.access_token.expose_secret(),
new_access_token.as_str()
);
assert_eq!(task.scheduled_timer_count().await, 1);
}
@@ -0,0 +1,146 @@
use super::*;
#[tokio::test]
async fn token_refresh_task_should_persist_refreshed_access_token_and_keep_refresh_token() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool, SecretBox::new([11u8; 32]));
let now = Utc.with_ymd_and_hms(2026, 6, 18, 8, 0, 0).unwrap();
let new_expires_at = now + Duration::hours(1);
let new_access_token = test_jwt(new_expires_at.timestamp());
store
.insert(NewAccount {
id: "acct-refresh".to_string(),
email: Some("user@example.com".to_string()),
account_id: Some("chatgpt-account".to_string()),
user_id: Some("user-id".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(
test_jwt((now + Duration::seconds(30)).timestamp()).into(),
),
refresh_token: Some(SecretString::new("refresh-old".to_string().into())),
access_token_expires_at: Some(now + Duration::seconds(30)),
status: AccountStatus::Active,
})
.await
.expect("account should be inserted");
let refresher = StaticTokenRefresher {
response: Arc::new(Mutex::new(Ok(TokenPair {
access_token: new_access_token.clone(),
refresh_token: None,
}))),
};
let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new(
store.clone(),
RefreshPolicy {
refresh_margin_seconds: 300,
refresh_concurrency: 1,
},
refresher,
);
let summary = task
.refresh_due_accounts_once_at(now)
.await
.expect("refresh should succeed");
let stored = store
.get("acct-refresh")
.await
.expect("account should load")
.expect("account should exist");
assert_eq!(
(
summary.refreshed,
stored.access_token.expose_secret(),
stored
.refresh_token
.as_ref()
.map(ExposeSecret::expose_secret),
stored.access_token_expires_at,
stored.status,
),
(
1,
new_access_token.as_str(),
Some("refresh-old"),
Some(new_expires_at),
AccountStatus::Active,
)
);
}
#[tokio::test]
async fn token_refresh_task_should_persist_success_after_transient_transport_retry() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh-transient-success.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool, SecretBox::new([16u8; 32]));
let now = Utc.with_ymd_and_hms(2026, 6, 19, 12, 0, 0).unwrap();
let new_access_token = test_jwt((now + Duration::hours(1)).timestamp());
store
.insert(NewAccount {
id: "acct-refresh-transient-success".to_string(),
email: Some("transient@example.com".to_string()),
account_id: Some("chatgpt-transient".to_string()),
user_id: Some("user-transient".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(
test_jwt((now + Duration::seconds(30)).timestamp()).into(),
),
refresh_token: Some(SecretString::new("refresh-transient".to_string().into())),
access_token_expires_at: Some(now + Duration::seconds(30)),
status: AccountStatus::Active,
})
.await
.expect("account should be inserted");
let observed_statuses = Arc::new(Mutex::new(Vec::new()));
let refresher = SequenceTokenRefresher {
store: store.clone(),
account_id: "acct-refresh-transient-success".to_string(),
observed_statuses: observed_statuses.clone(),
responses: Arc::new(Mutex::new(VecDeque::from(vec![
Err(RefreshFailure::Transport),
Ok(TokenPair {
access_token: new_access_token.clone(),
refresh_token: None,
}),
]))),
};
let task = codex_proxy_runtime::tasks::token_refresh::TokenRefreshTask::new(
store.clone(),
RefreshPolicy {
refresh_margin_seconds: 300,
refresh_concurrency: 1,
},
refresher,
)
.with_retry_delays(vec![StdDuration::ZERO; 4]);
let summary = task
.refresh_due_accounts_once_at(now)
.await
.expect("retry should eventually refresh");
let stored = store
.get("acct-refresh-transient-success")
.await
.expect("account should load")
.expect("account should exist");
let observed_statuses = observed_statuses.lock().await.clone();
assert_eq!(summary.refreshed, 1);
assert_eq!(summary.failed, 0);
assert_eq!(observed_statuses, [AccountStatus::Refreshing; 2]);
assert_eq!(stored.status, AccountStatus::Active);
assert_eq!(
stored.access_token.expose_secret(),
new_access_token.as_str()
);
}
-14
View File
@@ -11,17 +11,3 @@ pub fn openai_sse_frame(event: &str, data: &str) -> String {
pub fn done_sse_frame() -> &'static str {
"data: [DONE]\n\n"
}
#[cfg(test)]
mod tests {
use super::{done_sse_frame, openai_sse_frame};
#[test]
fn openai_sse_frame_should_encode_event_data_and_done_marker() {
assert_eq!(
openai_sse_frame("response.output_text.delta", "{\"delta\":\"hi\"}"),
"event: response.output_text.delta\ndata: {\"delta\":\"hi\"}\n\n"
);
assert_eq!(done_sse_frame(), "data: [DONE]\n\n");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,759 @@
use super::*;
#[tokio::test]
async fn admin_accounts_export_should_return_native_account_tokens() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-accounts-export.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let secret_box = SecretBox::new([77u8; 32]);
seed_encrypted_account(
&pool,
secret_box.clone(),
NewAccount {
id: "acct_export".to_string(),
email: Some("export@example.com".to_string()),
account_id: Some("chatgpt_export".to_string()),
user_id: Some("user_export".to_string()),
label: Some("primary".to_string()),
plan_type: Some("plus".to_string()),
access_token: SecretString::new("access-export".to_string().into()),
refresh_token: Some(SecretString::new("refresh-export".to_string().into())),
access_token_expires_at: None,
status: AccountStatus::Active,
},
)
.await;
let state = AppState::with_pool_secret_and_api_key_hasher(
test_config(url),
pool,
secret_box,
ApiKeyHasher::new([78u8; 32]),
);
let app = router::router().with_state(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/accounts/export?ids=acct_export")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_accounts_export")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["requestId"], "req_accounts_export");
assert_eq!(body["data"]["sourceFormat"], "native");
assert_eq!(body["data"]["accounts"][0]["id"], "acct_export");
assert_eq!(body["data"]["accounts"][0]["token"], "access-export");
assert_eq!(
body["data"]["accounts"][0]["refreshToken"],
"refresh-export"
);
}
#[tokio::test]
async fn admin_accounts_import_should_store_native_account_tokens() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-accounts-import.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let secret_box = SecretBox::new([79u8; 32]);
let state = AppState::with_pool_secret_and_api_key_hasher(
test_config(url),
pool.clone(),
secret_box.clone(),
ApiKeyHasher::new([80u8; 32]),
);
let app = router::router().with_state(state);
let 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_accounts_import")
.body(Body::from(
json!({
"accounts": [{
"id": "acct_import",
"email": "import@example.com",
"accountId": "chatgpt_import",
"userId": "user_import",
"label": "secondary",
"planType": "team",
"token": "access-import",
"refreshToken": "refresh-import",
"accessTokenExpiresAt": "2026-06-18T02:00:00Z",
"status": "disabled"
}]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
let stored = SqliteAccountStore::new(pool, secret_box)
.get("acct_import")
.await
.unwrap()
.unwrap();
assert_eq!(status, StatusCode::OK);
assert_eq!(body["requestId"], "req_accounts_import");
assert_eq!(body["data"]["imported"], 1);
assert_eq!(body["data"]["skipped"], 0);
assert_eq!(body["data"]["sourceFormat"], "native");
assert_eq!(stored.access_token.expose_secret(), "access-import");
assert_eq!(
stored.refresh_token.unwrap().expose_secret(),
"refresh-import"
);
assert_eq!(stored.status, AccountStatus::Disabled);
}
#[tokio::test]
async fn admin_accounts_import_should_require_admin_session_cookie() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-accounts-import-auth.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
let state = AppState::with_pool_secret_and_api_key_hasher(
test_config(url),
pool,
SecretBox::new([111u8; 32]),
ApiKeyHasher::new([112u8; 32]),
);
let app = router::router().with_state(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/accounts/import")
.header("content-type", "application/json")
.header("x-request-id", "req_accounts_import_auth")
.body(Body::from(r#"{"accounts":[]}"#))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(body["code"], 40101);
assert_eq!(body["requestId"], "req_accounts_import_auth");
}
#[tokio::test]
async fn admin_accounts_import_should_store_tokens_encrypted_and_list_sanitized_accounts() {
let (app, _state, pool, _dir, _secret_box) =
admin_accounts_test_app("admin-accounts-import-sanitized.sqlite", 113).await;
let response = app
.clone()
.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_accounts_import_sanitized")
.body(Body::from(
json!({
"accounts": [{
"id": "acct_imported_sanitized",
"email": "user@example.com",
"accountId": "chatgpt-account",
"userId": "chatgpt-user",
"label": "primary",
"planType": "plus",
"token": "access-secret",
"refreshToken": "refresh-secret",
"status": "active"
}]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["data"]["imported"], 1);
assert_eq!(body["data"]["skipped"], 0);
let stored: (String, String) = sqlx::query_as(
"select access_token_cipher, refresh_token_cipher from accounts where id = ?",
)
.bind("acct_imported_sanitized")
.fetch_one(&pool)
.await
.unwrap();
assert!(stored.0.starts_with("v1:"));
assert!(!stored.0.contains("access-secret"));
assert!(stored.1.starts_with("v1:"));
assert!(!stored.1.contains("refresh-secret"));
let list_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/accounts?limit=10")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_accounts_import_list")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let list_status = list_response.status();
let list_body = response_json(list_response).await;
assert_eq!(list_status, StatusCode::OK);
assert_eq!(list_body["data"][0]["id"], "acct_imported_sanitized");
assert_eq!(list_body["data"][0]["email"], "user@example.com");
assert!(list_body["data"][0].get("token").is_none());
assert!(list_body["data"][0].get("refreshToken").is_none());
assert_eq!(list_body["page"]["limit"], 10);
}
#[tokio::test]
async fn admin_accounts_import_should_reject_non_native_export_shape() {
let (app, _state, pool, _dir, _secret_box) =
admin_accounts_test_app("admin-accounts-import-external-shape.sqlite", 114).await;
let 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_external_import")
.body(Body::from(
json!({
"type": "external-data",
"version": 1,
"legacy": [],
"accounts": [{
"platform": "openai",
"type": "oauth",
"credentials": {
"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"
}
}]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
let stored: (i64,) = sqlx::query_as("select count(*) from accounts")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["message"], "No importable accounts found");
assert_eq!(stored.0, 0);
}
#[tokio::test]
async fn admin_accounts_import_should_reject_native_payload_with_unknown_account_fields() {
let (app, _state, _pool, _dir, _secret_box) =
admin_accounts_test_app("admin-accounts-import-unknown-account.sqlite", 115).await;
let 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_unknown_account")
.body(Body::from(
json!({
"accounts": [{
"id": "acct_native_unknown",
"token": "native-access-secret",
"refreshToken": "native-refresh-secret",
"email": "native@example.com",
"accountId": "native-account",
"userId": "native-user",
"label": "Native Unknown",
"planType": "plus",
"legacyField": "ignored-secret",
"status": "active"
}]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["message"], "No importable accounts found");
}
#[tokio::test]
async fn admin_accounts_import_should_reject_native_payload_with_unknown_container_fields() {
let (app, _state, _pool, _dir, _secret_box) =
admin_accounts_test_app("admin-accounts-import-unknown-container.sqlite", 116).await;
let 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_unknown_container")
.body(Body::from(
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
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["message"], "No importable accounts found");
}
#[tokio::test]
async fn admin_accounts_export_should_return_native_accounts_with_tokens_and_filter_ids() {
let (app, _state, pool, _dir, secret_box) =
admin_accounts_test_app("admin-accounts-export-filter.sqlite", 117).await;
seed_encrypted_account(
&pool,
secret_box.clone(),
NewAccount {
id: "acct_export_a".to_string(),
email: Some("export-a@example.com".to_string()),
account_id: Some("chatgpt-export-a".to_string()),
user_id: Some("user-export-a".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new("access-acct_export_a".to_string().into()),
refresh_token: Some(SecretString::new(
"refresh-acct_export_a".to_string().into(),
)),
access_token_expires_at: None,
status: AccountStatus::Active,
},
)
.await;
seed_encrypted_account(
&pool,
secret_box,
NewAccount {
id: "acct_export_b".to_string(),
email: Some("export-b@example.com".to_string()),
account_id: Some("chatgpt-export-b".to_string()),
user_id: Some("user-export-b".to_string()),
label: None,
plan_type: Some("team".to_string()),
access_token: SecretString::new("access-acct_export_b".to_string().into()),
refresh_token: Some(SecretString::new(
"refresh-acct_export_b".to_string().into(),
)),
access_token_expires_at: None,
status: AccountStatus::Active,
},
)
.await;
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/accounts/export?ids=acct_export_a")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["data"]["sourceFormat"], "native");
assert_eq!(body["data"]["accounts"].as_array().unwrap().len(), 1);
assert_eq!(body["data"]["accounts"][0]["id"], "acct_export_a");
assert_eq!(body["data"]["accounts"][0]["token"], "access-acct_export_a");
assert_eq!(
body["data"]["accounts"][0]["refreshToken"],
"refresh-acct_export_a"
);
assert!(body["data"]["accounts"][0].get("legacyField").is_none());
}
#[tokio::test]
async fn admin_accounts_export_should_reject_unsupported_external_format() {
let (app, _state, _pool, _dir, _secret_box) =
admin_accounts_test_app("admin-accounts-export-external.sqlite", 118).await;
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/accounts/export?format=external")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn admin_accounts_export_should_reject_unsupported_full_format() {
let (app, _state, _pool, _dir, _secret_box) =
admin_accounts_test_app("admin-accounts-export-full.sqlite", 119).await;
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/accounts/export?format=full")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn admin_auth_device_poll_should_return_pending_without_importing_account() {
let poll_calls = Arc::new(Mutex::new(Vec::new()));
let client = StaticOAuthClient {
device_response: Err(OAuthError::Rejected("not used".to_string())),
poll_response: Err(OAuthError::SlowDown),
exchange_response: Err(OAuthError::Rejected("not used".to_string())),
poll_calls: poll_calls.clone(),
exchange_calls: Arc::new(Mutex::new(Vec::new())),
};
let (app, _state, pool, _dir) =
admin_accounts_test_app_with_oauth_client("admin-auth-device-pending.sqlite", 112, client)
.await;
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/auth/device-poll/device-secret")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_device_pending")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["data"]["pending"], true);
assert_eq!(body["data"]["success"], false);
assert_eq!(body["data"]["code"], "slow_down");
assert_eq!(*poll_calls.lock().await, vec!["device-secret".to_string()]);
let account_count: (i64,) = sqlx::query_as("select count(*) from accounts")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(account_count.0, 0);
}
#[tokio::test]
async fn admin_auth_device_poll_should_import_tokens_without_returning_secrets() {
let token = test_jwt(
"device-account",
Some("device-user"),
Some("device@example.com"),
Some("plus"),
);
let poll_calls = Arc::new(Mutex::new(Vec::new()));
let client = StaticOAuthClient {
device_response: Err(OAuthError::Rejected("not used".to_string())),
poll_response: Ok(TokenPair {
access_token: token.clone(),
refresh_token: Some("device-refresh-secret".to_string()),
}),
exchange_response: Err(OAuthError::Rejected("not used".to_string())),
poll_calls: poll_calls.clone(),
exchange_calls: Arc::new(Mutex::new(Vec::new())),
};
let (app, state, pool, _dir) =
admin_accounts_test_app_with_oauth_client("admin-auth-device-success.sqlite", 113, client)
.await;
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/auth/device-poll/device-success")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_device_success")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["data"]["success"], true);
assert_eq!(body["data"]["pending"], false);
assert!(body["data"].get("accessToken").is_none());
assert!(body["data"].get("refreshToken").is_none());
assert_eq!(*poll_calls.lock().await, vec!["device-success".to_string()]);
let stored = SqliteAccountStore::new(pool.clone(), SecretBox::new([113u8; 32]))
.get(
state
.services
.account_pool
.acquire("gpt-5.5", Utc::now())
.await
.unwrap()
.account
.id
.as_str(),
)
.await
.unwrap()
.unwrap();
assert_eq!(stored.email.as_deref(), Some("device@example.com"));
assert_eq!(stored.account_id.as_deref(), Some("device-account"));
assert_eq!(
stored.refresh_token.unwrap().expose_secret(),
"device-refresh-secret"
);
}
#[tokio::test]
async fn admin_auth_code_relay_should_exchange_code_and_import_account() {
let token = test_jwt(
"pkce-account",
Some("pkce-user"),
Some("pkce@example.com"),
Some("plus"),
);
let exchange_calls = Arc::new(Mutex::new(Vec::new()));
let client = StaticOAuthClient {
device_response: Err(OAuthError::Rejected("not used".to_string())),
poll_response: Err(OAuthError::AuthorizationPending),
exchange_response: Ok(TokenPair {
access_token: token,
refresh_token: Some("pkce-refresh-secret".to_string()),
}),
poll_calls: Arc::new(Mutex::new(Vec::new())),
exchange_calls: exchange_calls.clone(),
};
let (app, state, _pool, _dir) =
admin_accounts_test_app_with_oauth_client("admin-auth-code-relay.sqlite", 116, client)
.await;
let start = app
.clone()
.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")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
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/callback?code=oauth-code&state={state_value}");
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/auth/code-relay")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_code_relay")
.body(Body::from(json!({"callbackUrl": callback_url}).to_string()))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["data"]["success"], true);
assert!(body["data"].get("accessToken").is_none());
assert!(body["data"].get("refreshToken").is_none());
let calls = exchange_calls.lock().await;
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/callback");
drop(calls);
let acquired = state
.services
.account_pool
.acquire("gpt-5.5", Utc::now())
.await
.unwrap()
.account;
assert_eq!(acquired.account_id.as_deref(), Some("pkce-account"));
}
#[tokio::test]
async fn admin_accounts_import_cli_should_read_auth_file_store_encrypted_and_sync_pool() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-account-import-cli.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let secret_box = SecretBox::new([103u8; 32]);
let state = AppState::with_pool_secret_and_api_key_hasher(
test_config(url),
pool.clone(),
secret_box.clone(),
ApiKeyHasher::new([104u8; 32]),
);
let app = router::router().with_state(state.clone());
let codex_home = dir.path().join("codex-home");
fs::create_dir_all(&codex_home).unwrap();
let token = test_jwt(
"cli-account",
Some("cli-user"),
Some("cli@example.com"),
Some("plus"),
);
fs::write(
codex_home.join("auth.json"),
json!({
"access_token": token,
"refresh_token": "cli-refresh-secret"
})
.to_string(),
)
.unwrap();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/accounts/import-cli")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_import_cli")
.body(Body::from(
json!({
"codexHome": codex_home.display().to_string(),
"id": "caller-id",
"email": "caller@example.com",
"accountId": "caller-account",
"userId": "caller-user",
"label": "caller-label",
"planType": "caller-plan"
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["requestId"], "req_import_cli");
assert_eq!(body["data"]["sourceFormat"], "codex_cli");
assert_eq!(body["data"]["imported"], 1);
assert!(body["data"].get("token").is_none());
assert!(body["data"].get("refreshToken").is_none());
let stored = SqliteAccountStore::new(pool.clone(), secret_box)
.list(None, 10)
.await
.unwrap()
.items
.remove(0);
assert_ne!(stored.id, "caller-id");
assert_eq!(stored.email.as_deref(), Some("cli@example.com"));
assert_eq!(stored.account_id.as_deref(), Some("cli-account"));
assert_eq!(stored.user_id.as_deref(), Some("cli-user"));
assert_eq!(stored.label, None);
assert_eq!(stored.plan_type.as_deref(), Some("plus"));
assert_eq!(stored.access_token.expose_secret(), &token);
assert_eq!(
stored.refresh_token.unwrap().expose_secret(),
"cli-refresh-secret"
);
let raw: (String, String) = sqlx::query_as(
"select access_token_cipher, refresh_token_cipher from accounts where id = ?",
)
.bind(&stored.id)
.fetch_one(&pool)
.await
.unwrap();
assert!(raw.0.starts_with("v1:"));
assert!(!raw.0.contains(&token));
assert!(raw.1.starts_with("v1:"));
assert!(!raw.1.contains("cli-refresh-secret"));
let acquired = state
.services
.account_pool
.acquire("gpt-5.5", Utc::now())
.await
.unwrap()
.account;
assert_eq!(acquired.id, stored.id);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
use super::*;
#[tokio::test]
async fn admin_accounts_list_should_not_decrypt_account_tokens() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-accounts.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
sqlx::query(
"insert into accounts (id, email, access_token_cipher, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?)",
)
.bind("acct_corrupt")
.bind("user@example.com")
.bind("not-a-secret-box-cipher")
.bind("active")
.bind("2026-06-18T00:00:00Z")
.bind("2026-06-18T00:00:00Z")
.execute(&pool)
.await
.unwrap();
let state = AppState::with_pool_secret_and_api_key_hasher(
test_config(url),
pool,
SecretBox::new([63u8; 32]),
ApiKeyHasher::new([64u8; 32]),
);
let app = router::router().with_state(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/accounts?limit=10")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_accounts_list")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["requestId"], "req_accounts_list");
assert_eq!(body["data"][0]["id"], "acct_corrupt");
assert_eq!(body["data"][0]["email"], "user@example.com");
}
@@ -0,0 +1,535 @@
use super::*;
#[tokio::test]
async fn admin_auth_login_start_should_return_pkce_auth_url_and_state() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-auth-login-start.sqlite");
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_and_api_key_hasher(
test_config(url),
pool,
SecretBox::new([95u8; 32]),
ApiKeyHasher::new([96u8; 32]),
);
let app = router::router().with_state(state);
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")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
let state = body["data"]["state"].as_str().unwrap();
let auth_url = body["data"]["authUrl"].as_str().unwrap();
assert_eq!(state.len(), 32);
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%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_status_should_require_admin_session_cookie() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-auth-status-auth.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
let state = AppState::with_pool_secret_and_api_key_hasher(
test_config(url),
pool,
SecretBox::new([105u8; 32]),
ApiKeyHasher::new([106u8; 32]),
);
let app = router::router().with_state(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/auth/status")
.header("x-request-id", "req_auth_status_no_session")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(body["code"], 40101);
assert_eq!(body["requestId"], "req_auth_status_no_session");
}
#[tokio::test]
async fn admin_auth_status_should_report_user_and_pool_summary_without_secrets() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-auth-status-summary.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let secret_box = SecretBox::new([107u8; 32]);
seed_encrypted_account(
&pool,
secret_box.clone(),
NewAccount {
id: "acct_auth_active".to_string(),
email: Some("active-auth@example.com".to_string()),
account_id: Some("auth-active-account".to_string()),
user_id: Some("auth-active-user".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new("access-auth-active".to_string().into()),
refresh_token: Some(SecretString::new("refresh-auth-active".to_string().into())),
access_token_expires_at: None,
status: AccountStatus::Active,
},
)
.await;
seed_encrypted_account(
&pool,
secret_box.clone(),
NewAccount {
id: "acct_auth_disabled".to_string(),
email: Some("disabled-auth@example.com".to_string()),
account_id: Some("auth-disabled-account".to_string()),
user_id: None,
label: None,
plan_type: None,
access_token: SecretString::new("access-auth-disabled".to_string().into()),
refresh_token: None,
access_token_expires_at: None,
status: AccountStatus::Disabled,
},
)
.await;
let state = AppState::with_pool_secret_and_api_key_hasher(
test_config(url),
pool,
secret_box,
ApiKeyHasher::new([108u8; 32]),
);
let app = router::router().with_state(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/auth/status")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_auth_status_summary")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["data"]["authenticated"], true);
assert_eq!(body["data"]["pool"]["total"], 2);
assert_eq!(body["data"]["pool"]["active"], 1);
assert_eq!(body["data"]["pool"]["disabled"], 1);
assert_eq!(body["data"]["user"]["email"], "active-auth@example.com");
assert!(body["data"]["user"].get("accessToken").is_none());
assert!(body["data"]["user"].get("token").is_none());
assert!(body["data"]["user"].get("refreshToken").is_none());
}
#[tokio::test]
async fn admin_auth_logout_should_clear_accounts_usage_cookies_and_runtime_pool() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-auth-logout.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let secret_box = SecretBox::new([109u8; 32]);
let state = AppState::with_pool_secret_and_api_key_hasher(
test_config(url),
pool.clone(),
secret_box.clone(),
ApiKeyHasher::new([110u8; 32]),
);
let app = router::router().with_state(state.clone());
let imported = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/accounts/import")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(
json!({
"accounts": [{
"id": "acct_auth_logout",
"email": "logout@example.com",
"planType": "plus",
"token": "access-auth-logout",
"refreshToken": "refresh-auth-logout",
"status": "active"
}]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(imported.status(), StatusCode::OK);
sqlx::query(
"insert into account_usage (account_id, request_count, input_tokens, output_tokens, cached_tokens) values (?, 2, 3, 4, 1)",
)
.bind("acct_auth_logout")
.execute(&pool)
.await
.unwrap();
let set_cookies = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/accounts/acct_auth_logout/cookies")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(r#"{"cookies":"cf_clearance=secret"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(set_cookies.status(), StatusCode::OK);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/auth/logout")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_auth_logout")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["data"]["success"], true);
assert_eq!(body["data"]["deleted"], 1);
let account_count: (i64,) = sqlx::query_as("select count(*) from accounts")
.fetch_one(&pool)
.await
.unwrap();
let usage_count: (i64,) = sqlx::query_as("select count(*) from account_usage")
.fetch_one(&pool)
.await
.unwrap();
let cookie_count: (i64,) = sqlx::query_as("select count(*) from account_cookies")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(account_count.0, 0);
assert_eq!(usage_count.0, 0);
assert_eq!(cookie_count.0, 0);
assert!(state
.services
.account_pool
.acquire("gpt-5.5", Utc::now())
.await
.is_none());
}
#[tokio::test]
async fn admin_auth_device_login_should_return_openai_device_code() {
let client = StaticOAuthClient {
device_response: Ok(DeviceCode {
device_code: "device-secret".to_string(),
user_code: "ABCD-EFGH".to_string(),
verification_uri: "https://auth.openai.com/activate".to_string(),
verification_uri_complete: "https://auth.openai.com/activate?user_code=ABCD-EFGH"
.to_string(),
expires_in: 900,
interval: 5,
}),
poll_response: Err(OAuthError::AuthorizationPending),
exchange_response: Err(OAuthError::Rejected("not used".to_string())),
poll_calls: Arc::new(Mutex::new(Vec::new())),
exchange_calls: Arc::new(Mutex::new(Vec::new())),
};
let (app, _state, _pool, _dir) =
admin_accounts_test_app_with_oauth_client("admin-auth-device-login.sqlite", 111, client)
.await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/auth/device-login")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_device_login")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["data"]["userCode"], "ABCD-EFGH");
assert_eq!(
body["data"]["verificationUri"],
"https://auth.openai.com/activate"
);
assert_eq!(body["data"]["deviceCode"], "device-secret");
assert_eq!(body["data"]["expiresIn"], 900);
assert_eq!(body["data"]["interval"], 5);
}
#[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 url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let mut config = test_config(url);
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 state = AppState::with_pool_secret_and_api_key_hasher(
config,
pool,
SecretBox::new([114u8; 32]),
ApiKeyHasher::new([115u8; 32]),
);
let app = router::router().with_state(state);
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();
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_reject_invalid_callback_url() {
let client = StaticOAuthClient {
device_response: Err(OAuthError::Rejected("not used".to_string())),
poll_response: Err(OAuthError::AuthorizationPending),
exchange_response: Err(OAuthError::Rejected("not used".to_string())),
poll_calls: Arc::new(Mutex::new(Vec::new())),
exchange_calls: Arc::new(Mutex::new(Vec::new())),
};
let (app, _state, _pool, _dir) = admin_accounts_test_app_with_oauth_client(
"admin-auth-code-relay-invalid.sqlite",
117,
client,
)
.await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/auth/code-relay")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_code_relay_invalid")
.body(Body::from(json!({"callbackUrl": "not a url"}).to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn admin_auth_callback_should_exchange_code_and_redirect_to_return_host() {
let token = test_jwt(
"callback-account",
Some("callback-user"),
Some("callback@example.com"),
Some("plus"),
);
let exchange_calls = Arc::new(Mutex::new(Vec::new()));
let client = StaticOAuthClient {
device_response: Err(OAuthError::Rejected("not used".to_string())),
poll_response: Err(OAuthError::AuthorizationPending),
exchange_response: Ok(TokenPair {
access_token: token,
refresh_token: Some("callback-refresh-secret".to_string()),
}),
poll_calls: Arc::new(Mutex::new(Vec::new())),
exchange_calls: exchange_calls.clone(),
};
let (app, _state, pool, _dir) =
admin_accounts_test_app_with_oauth_client("admin-auth-callback.sqlite", 118, client).await;
let start = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/auth/login-start")
.header("cookie", "cpr_admin_session=session_1")
.header("host", "codex.local:1455")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let start_body = response_json(start).await;
let state_value = start_body["data"]["state"].as_str().unwrap();
let callback_path = format!("/auth/callback?code=callback-code&state={state_value}");
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri(callback_path)
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers().get("location").unwrap(),
"http://codex.local:1455/"
);
assert_eq!(exchange_calls.lock().await[0].code, "callback-code");
let count: (i64,) = sqlx::query_as("select count(*) from accounts where account_id = ?")
.bind("callback-account")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count.0, 1);
}
#[tokio::test]
async fn admin_accounts_health_check_should_refresh_oauth_without_touching_codex_backend() {
let upstream = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&upstream)
.await;
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-account-health-refresh.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let secret_box = SecretBox::new([124u8; 32]);
for (id, refresh_token) in [
("acct_health_alive", "refresh-acct_health_alive"),
("acct_health_dead", "refresh-acct_health_dead"),
] {
seed_encrypted_account(
&pool,
secret_box.clone(),
NewAccount {
id: id.to_string(),
email: Some(format!("{id}@example.com")),
account_id: Some(format!("old-{id}")),
user_id: None,
label: None,
plan_type: None,
access_token: SecretString::new(format!("old-access-{id}").into()),
refresh_token: Some(SecretString::new(refresh_token.to_string().into())),
access_token_expires_at: None,
status: AccountStatus::Active,
},
)
.await;
}
let calls = Arc::new(Mutex::new(Vec::new()));
let mut config = test_config(url);
config.api.base_url = upstream.uri();
let state = AppState::with_pool_secret_api_key_hasher_and_token_refresher(
config,
pool.clone(),
secret_box.clone(),
ApiKeyHasher::new([124u8; 32]),
HealthCheckTokenRefresher {
calls: calls.clone(),
},
);
let app = router::router().with_state(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/accounts/health-check")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_health_refresh")
.body(Body::from(
json!({
"ids": ["acct_health_alive", "acct_health_dead"],
"concurrency": 2,
"staggerMs": 500
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["data"]["summary"]["total"], 2);
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("new-health-access"));
assert!(!serialized.contains("new-health-refresh"));
let dead_status: (String,) = sqlx::query_as("select status from accounts where id = ?")
.bind("acct_health_dead")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(dead_status.0, "expired");
}
@@ -0,0 +1,780 @@
use super::*;
#[tokio::test]
async fn admin_usage_stats_should_return_page_and_summary() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-usage.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
sqlx::query(
"insert into accounts (id, email, label, plan_type, access_token_cipher, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind("acct_usage")
.bind("usage@example.com")
.bind("primary")
.bind("plus")
.bind("cipher")
.bind("active")
.bind("2026-06-18T00:00:00Z")
.bind("2026-06-18T00:00:00Z")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"insert into account_usage (account_id, request_count, empty_response_count, input_tokens, output_tokens, cached_tokens, image_input_tokens, image_output_tokens, image_request_count, image_request_failed_count, last_used_at) values (?, 3, 1, 21, 8, 5, 7, 2, 1, 0, ?)",
)
.bind("acct_usage")
.bind("2026-06-18T00:10:00Z")
.execute(&pool)
.await
.unwrap();
let state = AppState::with_pool_secret_and_api_key_hasher(
test_config(url),
pool,
SecretBox::new([73u8; 32]),
ApiKeyHasher::new([74u8; 32]),
);
let app = router::router().with_state(state);
let page_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/usage-stats?limit=10")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_usage_page")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let page_status = page_response.status();
let page_body = response_json(page_response).await;
assert_eq!(page_status, StatusCode::OK);
assert_eq!(page_body["data"][0]["accountId"], "acct_usage");
assert_eq!(page_body["data"][0]["email"], "usage@example.com");
assert_eq!(page_body["data"][0]["requestCount"], 3);
assert_eq!(page_body["data"][0]["inputTokens"], 21);
let summary_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/usage-stats/summary")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_usage_summary")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let summary_status = summary_response.status();
let summary_body = response_json(summary_response).await;
assert_eq!(summary_status, StatusCode::OK);
assert_eq!(summary_body["data"]["accountCount"], 1);
assert_eq!(summary_body["data"]["requestCount"], 3);
assert_eq!(summary_body["data"]["outputTokens"], 8);
}
#[tokio::test]
async fn admin_usage_stats_should_require_admin_session_cookie() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-usage-auth.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
let state = AppState::with_pool_secret_and_api_key_hasher(
test_config(url),
pool,
SecretBox::new([125u8; 32]),
ApiKeyHasher::new([126u8; 32]),
);
let app = router::router().with_state(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/usage-stats")
.header("x-request-id", "req_usage_auth")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(body["code"], 40101);
assert_eq!(body["requestId"], "req_usage_auth");
}
#[tokio::test]
async fn admin_usage_stats_should_cursor_page_account_usage() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-usage-cursor.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
seed_usage_account(
&pool,
"acct_a",
"a@example.com",
"primary",
"plus",
3,
0,
12,
5,
1,
"2026-06-11T00:00:00Z",
)
.await;
seed_usage_account(
&pool,
"acct_b",
"b@example.com",
"backup",
"free",
2,
1,
7,
3,
2,
"2026-06-11T00:01:00Z",
)
.await;
let state = AppState::with_pool_secret_and_api_key_hasher(
test_config(url),
pool,
SecretBox::new([127u8; 32]),
ApiKeyHasher::new([128u8; 32]),
);
let app = router::router().with_state(state);
let first_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/usage-stats?limit=1")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_usage_cursor")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let first_status = first_response.status();
let first_body = response_json(first_response).await;
assert_eq!(first_status, StatusCode::OK);
assert_eq!(first_body["code"], 200);
assert_eq!(first_body["requestId"], "req_usage_cursor");
assert_eq!(first_body["data"].as_array().unwrap().len(), 1);
assert_eq!(first_body["data"][0]["accountId"], "acct_b");
assert_eq!(first_body["data"][0]["requestCount"], 2);
assert_eq!(first_body["data"][0]["emptyResponseCount"], 1);
assert_eq!(first_body["data"][0]["inputTokens"], 7);
assert_eq!(first_body["page"]["limit"], 1);
let cursor = first_body["page"]["nextCursor"].as_str().unwrap();
let second_response = app
.oneshot(
Request::builder()
.method("GET")
.uri(format!("/api/admin/usage-stats?limit=1&cursor={cursor}"))
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let second_status = second_response.status();
let second_body = response_json(second_response).await;
assert_eq!(second_status, StatusCode::OK);
assert_eq!(second_body["data"][0]["accountId"], "acct_a");
assert!(second_body["page"]["nextCursor"].is_null());
}
#[tokio::test]
async fn admin_account_quota_should_fetch_usage_store_quota_and_not_return_secrets() {
let upstream = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.and(header("authorization", "Bearer access-quota"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plan_type": "plus",
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 25,
"reset_at": 1770000400,
"limit_window_seconds": 3600
}
}
})))
.expect(1)
.mount(&upstream)
.await;
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-account-quota.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let secret_box = SecretBox::new([95u8; 32]);
seed_encrypted_account(
&pool,
secret_box.clone(),
NewAccount {
id: "acct_quota".to_string(),
email: Some("quota@example.com".to_string()),
account_id: Some("chatgpt-quota".to_string()),
user_id: None,
label: None,
plan_type: None,
access_token: SecretString::new("access-quota".to_string().into()),
refresh_token: Some(SecretString::new("refresh-quota".to_string().into())),
access_token_expires_at: None,
status: AccountStatus::Active,
},
)
.await;
let mut config = test_config(url);
config.api.base_url = upstream.uri();
let state = AppState::with_pool_secret_and_api_key_hasher(
config,
pool.clone(),
secret_box,
ApiKeyHasher::new([96u8; 32]),
);
let app = router::router().with_state(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/accounts/acct_quota/quota")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_quota")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
assert_eq!(status, StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["data"]["quota"]["plan_type"], "plus");
assert_eq!(body["data"]["quota"]["rate_limit"]["remaining_percent"], 75);
assert_eq!(
body["data"]["raw"]["rate_limit"]["primary_window"]["used_percent"],
25
);
let serialized = serde_json::to_string(&body).unwrap();
assert!(!serialized.contains("access-quota"));
assert!(!serialized.contains("refresh-quota"));
let stored: (String,) = sqlx::query_as("select quota_json from accounts where id = ?")
.bind("acct_quota")
.fetch_one(&pool)
.await
.unwrap();
assert!(stored.0.contains("\"remaining_percent\":75"));
assert!(!stored.0.contains("access-quota"));
}
#[tokio::test]
async fn admin_account_quota_should_return_bad_gateway_when_usage_fetch_fails() {
let upstream = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.and(header("authorization", "Bearer access-quota-fail"))
.respond_with(ResponseTemplate::new(500).set_body_json(json!({
"error": {
"message": "quota unavailable"
}
})))
.expect(1)
.mount(&upstream)
.await;
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-account-quota-fail.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let secret_box = SecretBox::new([97u8; 32]);
seed_encrypted_account(
&pool,
secret_box.clone(),
NewAccount {
id: "acct_quota_fail".to_string(),
email: Some("quota-fail@example.com".to_string()),
account_id: Some("chatgpt-quota-fail".to_string()),
user_id: None,
label: None,
plan_type: None,
access_token: SecretString::new("access-quota-fail".to_string().into()),
refresh_token: None,
access_token_expires_at: None,
status: AccountStatus::Active,
},
)
.await;
let mut config = test_config(url);
config.api.base_url = upstream.uri();
let state = AppState::with_pool_secret_and_api_key_hasher(
config,
pool,
secret_box,
ApiKeyHasher::new([98u8; 32]),
);
let app = router::router().with_state(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/accounts/acct_quota_fail/quota")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_quota_fail")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::BAD_GATEWAY);
assert_eq!(body["code"], 50201);
assert_eq!(body["message"], "Failed to fetch quota from Codex API");
assert!(body["data"]["error"]
.as_str()
.is_some_and(|error| error.contains("quota unavailable")));
assert_eq!(body["requestId"], "req_quota_fail");
}
#[tokio::test]
async fn admin_account_quota_should_reject_inactive_account_without_calling_upstream() {
let upstream = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
.expect(0)
.mount(&upstream)
.await;
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-account-quota-inactive.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let secret_box = SecretBox::new([99u8; 32]);
seed_encrypted_account(
&pool,
secret_box.clone(),
NewAccount {
id: "acct_quota_inactive".to_string(),
email: Some("quota-inactive@example.com".to_string()),
account_id: Some("chatgpt-quota-inactive".to_string()),
user_id: None,
label: None,
plan_type: None,
access_token: SecretString::new("access-quota-inactive".to_string().into()),
refresh_token: None,
access_token_expires_at: None,
status: AccountStatus::Disabled,
},
)
.await;
let mut config = test_config(url);
config.api.base_url = upstream.uri();
let state = AppState::with_pool_secret_and_api_key_hasher(
config,
pool.clone(),
secret_box,
ApiKeyHasher::new([100u8; 32]),
);
let app = router::router().with_state(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/accounts/acct_quota_inactive/quota")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_quota_inactive")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
let quota_json: (Option<String>,) =
sqlx::query_as("select quota_json from accounts where id = ?")
.bind("acct_quota_inactive")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(body["code"], 40901);
assert_eq!(body["message"], "Account is disabled, cannot query quota");
assert_eq!(body["requestId"], "req_quota_inactive");
assert!(quota_json.0.is_none());
}
#[tokio::test]
async fn admin_account_quota_should_return_store_error_when_quota_persistence_fails() {
let upstream = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.and(header("authorization", "Bearer access-quota-store-fail"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plan_type": "plus",
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 40,
"reset_at": 1770000500,
"limit_window_seconds": 3600
}
}
})))
.expect(1)
.mount(&upstream)
.await;
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-account-quota-store-fail.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let secret_box = SecretBox::new([101u8; 32]);
seed_encrypted_account(
&pool,
secret_box.clone(),
NewAccount {
id: "acct_quota_store_fail".to_string(),
email: Some("quota-store-fail@example.com".to_string()),
account_id: Some("chatgpt-quota-store-fail".to_string()),
user_id: None,
label: None,
plan_type: None,
access_token: SecretString::new("access-quota-store-fail".to_string().into()),
refresh_token: None,
access_token_expires_at: None,
status: AccountStatus::Active,
},
)
.await;
sqlx::query(
"create trigger quota_write_denied before update of quota_json on accounts begin select raise(abort, 'quota write denied'); end",
)
.execute(&pool)
.await
.unwrap();
let mut config = test_config(url);
config.api.base_url = upstream.uri();
let state = AppState::with_pool_secret_and_api_key_hasher(
config,
pool.clone(),
secret_box,
ApiKeyHasher::new([102u8; 32]),
);
let app = router::router().with_state(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/accounts/acct_quota_store_fail/quota")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_quota_store_fail")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
let quota_json: (Option<String>,) =
sqlx::query_as("select quota_json from accounts where id = ?")
.bind("acct_quota_store_fail")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(body["code"], 50001);
assert_eq!(body["message"], "Failed to store account quota");
assert_eq!(body["requestId"], "req_quota_store_fail");
assert!(quota_json.0.is_none());
}
#[tokio::test]
async fn admin_account_quota_warnings_should_require_admin_session_cookie() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-account-quota-warnings-auth.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
let state = AppState::with_pool_secret_and_api_key_hasher(
test_config(url),
pool,
SecretBox::new([87u8; 32]),
ApiKeyHasher::new([88u8; 32]),
);
let app = router::router().with_state(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/accounts/quota-warnings")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn admin_account_quota_warnings_should_return_threshold_matches_from_cached_quota() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-account-quota-warnings.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let secret_box = SecretBox::new([89u8; 32]);
seed_encrypted_account(
&pool,
secret_box.clone(),
NewAccount {
id: "acct_warn".to_string(),
email: Some("warn@example.com".to_string()),
account_id: None,
user_id: None,
label: None,
plan_type: None,
access_token: SecretString::new("access-warn".to_string().into()),
refresh_token: None,
access_token_expires_at: None,
status: AccountStatus::Active,
},
)
.await;
seed_encrypted_account(
&pool,
secret_box.clone(),
NewAccount {
id: "acct_quiet".to_string(),
email: Some("quiet@example.com".to_string()),
account_id: None,
user_id: None,
label: None,
plan_type: None,
access_token: SecretString::new("access-quiet".to_string().into()),
refresh_token: None,
access_token_expires_at: None,
status: AccountStatus::Active,
},
)
.await;
sqlx::query(
"update accounts set quota_json = ?, quota_fetched_at = ?, updated_at = ? where id = ?",
)
.bind(
json!({
"rate_limit": {
"used_percent": 85,
"reset_at": 1770000100
},
"secondary_rate_limit": {
"used_percent": 91,
"reset_at": 1770000200
}
})
.to_string(),
)
.bind("2026-06-13T00:00:00Z")
.bind("2026-06-13T00:00:00Z")
.bind("acct_warn")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"update accounts set quota_json = ?, quota_fetched_at = ?, updated_at = ? where id = ?",
)
.bind(
json!({
"rate_limit": {
"used_percent": 25,
"reset_at": 1770000300
},
"secondary_rate_limit": null
})
.to_string(),
)
.bind("2026-06-13T01:00:00Z")
.bind("2026-06-13T01:00:00Z")
.bind("acct_quiet")
.execute(&pool)
.await
.unwrap();
let state = AppState::with_pool_secret_and_api_key_hasher(
test_config(url),
pool,
secret_box,
ApiKeyHasher::new([90u8; 32]),
);
let app = router::router().with_state(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/accounts/quota-warnings")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["data"]["updatedAt"], "2026-06-13T00:00:00+00:00");
let warnings = body["data"]["warnings"].as_array().unwrap();
assert_eq!(warnings.len(), 2);
assert!(warnings
.iter()
.all(|warning| warning["accountId"] == "acct_warn"));
assert!(warnings.iter().any(|warning| {
warning["window"] == "primary"
&& warning["level"] == "warning"
&& warning["usedPercent"] == 85.0
&& warning["resetAt"] == 1770000100
}));
assert!(warnings.iter().any(|warning| {
warning["window"] == "secondary"
&& warning["level"] == "critical"
&& warning["usedPercent"] == 91.0
&& warning["resetAt"] == 1770000200
}));
}
#[tokio::test]
async fn admin_account_quota_warnings_should_ignore_invalid_and_below_threshold_snapshots() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-account-quota-warnings-edge.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let secret_box = SecretBox::new([85u8; 32]);
seed_encrypted_account(
&pool,
secret_box.clone(),
NewAccount {
id: "acct_invalid_quota".to_string(),
email: Some("invalid-quota@example.com".to_string()),
account_id: None,
user_id: None,
label: None,
plan_type: None,
access_token: SecretString::new("access-invalid-quota".to_string().into()),
refresh_token: None,
access_token_expires_at: None,
status: AccountStatus::Active,
},
)
.await;
seed_encrypted_account(
&pool,
secret_box.clone(),
NewAccount {
id: "acct_below_threshold".to_string(),
email: Some("below-threshold@example.com".to_string()),
account_id: None,
user_id: None,
label: None,
plan_type: None,
access_token: SecretString::new("access-below-threshold".to_string().into()),
refresh_token: None,
access_token_expires_at: None,
status: AccountStatus::Active,
},
)
.await;
sqlx::query(
"update accounts set quota_json = ?, quota_fetched_at = ?, updated_at = ? where id = ?",
)
.bind("{not valid json")
.bind("2026-06-13T00:00:00Z")
.bind("2026-06-13T00:00:00Z")
.bind("acct_invalid_quota")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"update accounts set quota_json = ?, quota_fetched_at = ?, updated_at = ? where id = ?",
)
.bind(
json!({
"rate_limit": {
"used_percent": 79,
"reset_at": 1770000600
},
"secondary_rate_limit": {
"used_percent": 79,
"reset_at": 1770000700
}
})
.to_string(),
)
.bind("2026-06-13T01:00:00Z")
.bind("2026-06-13T01:00:00Z")
.bind("acct_below_threshold")
.execute(&pool)
.await
.unwrap();
let state = AppState::with_pool_secret_and_api_key_hasher(
test_config(url),
pool,
secret_box,
ApiKeyHasher::new([86u8; 32]),
);
let app = router::router().with_state(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/accounts/quota-warnings")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert!(body["data"]["warnings"].as_array().unwrap().is_empty());
assert!(body["data"]["updatedAt"].is_null());
}
+6 -527
View File
@@ -20,533 +20,12 @@ use serde_json::{json, Value};
use sqlx::SqlitePool;
use tower::util::ServiceExt;
#[tokio::test]
async fn admin_client_keys_route_should_create_list_and_authorize_v1_requests() {
let (app, _dir) = admin_client_key_test_app("admin-client-key-create.sqlite").await;
let rejected = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/v1/models")
.header("authorization", "Bearer cpr_not_stored")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED);
let create_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/api-keys")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_api_key")
.body(Body::from(r#"{"name":"cursor"}"#))
.unwrap(),
)
.await
.unwrap();
let create_status = create_response.status();
let create_body = response_json(create_response).await;
assert_eq!(create_status, StatusCode::OK);
assert!(create_body["data"]["plaintext"]
.as_str()
.is_some_and(|plaintext| plaintext.starts_with("cpr_")));
assert_eq!(create_body["requestId"], "req_api_key");
let plaintext = create_body["data"]["plaintext"]
.as_str()
.unwrap()
.to_string();
let list_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/api-keys?limit=10")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let list_status = list_response.status();
let list_body = response_json(list_response).await;
assert_eq!(list_status, StatusCode::OK);
assert_eq!(list_body["data"][0]["name"], "cursor");
assert!(list_body["data"][0].get("plaintext").is_none());
assert!(list_body["data"][0].get("keyHash").is_none());
let models_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/v1/models")
.header("authorization", format!("Bearer {plaintext}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(models_response.status(), StatusCode::OK);
}
#[tokio::test]
async fn admin_client_keys_status_should_disable_and_enable_authorization() {
let (app, _dir) = admin_client_key_test_app("admin-client-key-status.sqlite").await;
let (key_id, plaintext) = create_admin_client_key(&app, "status-key").await;
let disabled = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri(format!("/api/admin/api-keys/{key_id}/status"))
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(r#"{"status":"disabled"}"#))
.unwrap(),
)
.await
.unwrap();
let disabled_status = disabled.status();
let disabled_body = response_json(disabled).await;
assert_eq!(disabled_status, StatusCode::OK);
assert_eq!(disabled_body["data"]["enabled"], false);
let rejected = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/v1/models")
.header("authorization", format!("Bearer {plaintext}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED);
let enabled = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri(format!("/api/admin/api-keys/{key_id}/status"))
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(r#"{"status":"active"}"#))
.unwrap(),
)
.await
.unwrap();
let enabled_status = enabled.status();
let enabled_body = response_json(enabled).await;
assert_eq!(enabled_status, StatusCode::OK);
assert_eq!(enabled_body["data"]["enabled"], true);
let accepted = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/v1/models")
.header("authorization", format!("Bearer {plaintext}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(accepted.status(), StatusCode::OK);
let invalid = app
.oneshot(
Request::builder()
.method("PATCH")
.uri(format!("/api/admin/api-keys/{key_id}/status"))
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(r#"{"status":"expired"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn admin_client_keys_delete_should_remove_authorization() {
let (app, _dir) = admin_client_key_test_app("admin-client-key-delete.sqlite").await;
let (key_id, plaintext) = create_admin_client_key(&app, "delete-key").await;
let deleted = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/admin/api-keys/{key_id}"))
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let delete_status = deleted.status();
let delete_body = response_json(deleted).await;
assert_eq!(delete_status, StatusCode::OK);
assert_eq!(delete_body["data"]["deleted"], true);
let list_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/api-keys?limit=10")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let list_body = response_json(list_response).await;
assert_eq!(list_body["data"].as_array().unwrap().len(), 0);
let rejected = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/v1/models")
.header("authorization", format!("Bearer {plaintext}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED);
let missing = app
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/admin/api-keys/{key_id}"))
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn admin_client_keys_label_should_update_clear_and_validate_label() {
let (app, _dir) = admin_client_key_test_app("admin-client-key-label.sqlite").await;
let (key_id, _plaintext) = create_admin_client_key(&app, "label-key").await;
let renamed = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri(format!("/api/admin/api-keys/{key_id}/label"))
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(r#"{"label":"automation"}"#))
.unwrap(),
)
.await
.unwrap();
let renamed_status = renamed.status();
let renamed_body = response_json(renamed).await;
assert_eq!(renamed_status, StatusCode::OK);
assert_eq!(renamed_body["data"]["name"], "label-key");
assert_eq!(renamed_body["data"]["label"], "automation");
let cleared = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri(format!("/api/admin/api-keys/{key_id}/label"))
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(r#"{"label":null}"#))
.unwrap(),
)
.await
.unwrap();
let cleared_status = cleared.status();
let cleared_body = response_json(cleared).await;
assert_eq!(cleared_status, StatusCode::OK);
assert_eq!(cleared_body["data"]["name"], "label-key");
assert!(cleared_body["data"]["label"].is_null());
let too_long = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri(format!("/api/admin/api-keys/{key_id}/label"))
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(json!({ "label": "x".repeat(65) }).to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(too_long.status(), StatusCode::BAD_REQUEST);
let missing = app
.oneshot(
Request::builder()
.method("PATCH")
.uri("/api/admin/api-keys/missing/label")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(r#"{"label":"automation"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn admin_client_keys_batch_delete_should_remove_found_keys_and_report_missing_ids() {
let (app, _dir) = admin_client_key_test_app("admin-client-key-batch-delete.sqlite").await;
let (key_a, plaintext_a) = create_admin_client_key(&app, "batch-a").await;
let (key_b, plaintext_b) = create_admin_client_key(&app, "batch-b").await;
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/api-keys/batch-delete")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(
json!({
"ids": [key_a, "ghost", key_b]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["data"]["deleted"], 2);
assert_eq!(body["data"]["notFound"], json!(["ghost"]));
for plaintext in [plaintext_a, plaintext_b] {
let rejected = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/v1/models")
.header("authorization", format!("Bearer {plaintext}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED);
}
let empty = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/api-keys/batch-delete")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(r#"{"ids":[]}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(empty.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn admin_client_keys_export_should_return_metadata_without_secret_material() {
let (app, _dir) = admin_client_key_test_app("admin-client-key-export.sqlite").await;
let (key_id, plaintext) = create_admin_client_key(&app, "export-key").await;
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri(format!("/api/admin/api-keys/export?ids={key_id}"))
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_api_key_export")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["requestId"], "req_api_key_export");
assert_eq!(body["data"]["sourceFormat"], "rustLocalClientApiKeys");
assert_eq!(body["data"]["rotationRequired"], true);
assert_eq!(body["data"]["apiKeys"][0]["id"], key_id);
assert_eq!(body["data"]["apiKeys"][0]["name"], "export-key");
assert!(body["data"]["apiKeys"][0]["prefix"]
.as_str()
.is_some_and(|prefix| prefix.starts_with("cpr_")));
assert!(body["data"]["apiKeys"][0].get("plaintext").is_none());
assert!(body["data"]["apiKeys"][0].get("keyHash").is_none());
assert!(body["data"].get("pepper").is_none());
assert!(!body.to_string().contains(&plaintext));
}
#[tokio::test]
async fn admin_client_keys_import_should_rotate_exported_metadata_and_return_plaintext_once() {
let source_dir = tempfile::tempdir().unwrap();
let source_db = source_dir
.path()
.join("admin-client-key-export-source.sqlite");
let source_url = format!("sqlite://{}", source_db.display());
let source_pool = connect_sqlite(&source_url).await.unwrap();
seed_admin_session(&source_pool, "session_1").await;
let source_state = AppState::with_pool_secret_and_api_key_hasher(
test_config(source_url),
source_pool,
SecretBox::new([51u8; 32]),
ApiKeyHasher::new([52u8; 32]),
);
let source_app = router::router().with_state(source_state);
let (source_key_id, source_plaintext) =
create_admin_client_key(&source_app, "rotated-key").await;
let export_response = source_app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/api-keys/export")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(export_response.status(), StatusCode::OK);
let export_body = response_json(export_response).await;
let target_dir = tempfile::tempdir().unwrap();
let target_db = target_dir
.path()
.join("admin-client-key-import-target.sqlite");
let target_url = format!("sqlite://{}", target_db.display());
let target_pool = connect_sqlite(&target_url).await.unwrap();
seed_admin_session(&target_pool, "session_1").await;
let target_state = AppState::with_pool_secret_and_api_key_hasher(
test_config(target_url),
target_pool,
SecretBox::new([61u8; 32]),
ApiKeyHasher::new([62u8; 32]),
);
let target_app = router::router().with_state(target_state);
let import_response = target_app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/api-keys/import")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_api_key_import")
.body(Body::from(export_body["data"].to_string()))
.unwrap(),
)
.await
.unwrap();
let status = import_response.status();
let import_body = response_json(import_response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(import_body["requestId"], "req_api_key_import");
assert_eq!(import_body["data"]["imported"], 1);
assert_eq!(import_body["data"]["skipped"], 0);
assert_eq!(import_body["data"]["rotated"], true);
assert_eq!(import_body["data"]["keys"][0]["sourceId"], source_key_id);
assert_eq!(import_body["data"]["keys"][0]["name"], "rotated-key");
assert!(import_body["data"]["keys"][0].get("keyHash").is_none());
let rotated_plaintext = import_body["data"]["keys"][0]["plaintext"]
.as_str()
.unwrap()
.to_string();
assert!(rotated_plaintext.starts_with("cpr_"));
assert_ne!(rotated_plaintext, source_plaintext);
let list_response = target_app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/api-keys?limit=10")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let list_body = response_json(list_response).await;
assert!(list_body["data"][0].get("plaintext").is_none());
let old_rejected = target_app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/v1/models")
.header("authorization", format!("Bearer {source_plaintext}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(old_rejected.status(), StatusCode::UNAUTHORIZED);
let new_accepted = target_app
.oneshot(
Request::builder()
.method("GET")
.uri("/v1/models")
.header("authorization", format!("Bearer {rotated_plaintext}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(new_accepted.status(), StatusCode::OK);
}
#[path = "admin_client_keys_routes/authorization.rs"]
mod authorization;
#[path = "admin_client_keys_routes/import_export.rs"]
mod import_export;
#[path = "admin_client_keys_routes/lifecycle.rs"]
mod lifecycle;
async fn admin_client_key_test_app(db_name: &str) -> (axum::Router, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
@@ -0,0 +1,234 @@
use super::*;
#[tokio::test]
async fn admin_client_keys_route_should_create_list_and_authorize_v1_requests() {
let (app, _dir) = admin_client_key_test_app("admin-client-key-create.sqlite").await;
let rejected = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/v1/models")
.header("authorization", "Bearer cpr_not_stored")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED);
let create_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/api-keys")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_api_key")
.body(Body::from(r#"{"name":"cursor"}"#))
.unwrap(),
)
.await
.unwrap();
let create_status = create_response.status();
let create_body = response_json(create_response).await;
assert_eq!(create_status, StatusCode::OK);
assert!(create_body["data"]["plaintext"]
.as_str()
.is_some_and(|plaintext| plaintext.starts_with("cpr_")));
assert_eq!(create_body["requestId"], "req_api_key");
let plaintext = create_body["data"]["plaintext"]
.as_str()
.unwrap()
.to_string();
let list_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/api-keys?limit=10")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let list_status = list_response.status();
let list_body = response_json(list_response).await;
assert_eq!(list_status, StatusCode::OK);
assert_eq!(list_body["data"][0]["name"], "cursor");
assert!(list_body["data"][0].get("plaintext").is_none());
assert!(list_body["data"][0].get("keyHash").is_none());
let models_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/v1/models")
.header("authorization", format!("Bearer {plaintext}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(models_response.status(), StatusCode::OK);
}
#[tokio::test]
async fn admin_client_keys_status_should_disable_and_enable_authorization() {
let (app, _dir) = admin_client_key_test_app("admin-client-key-status.sqlite").await;
let (key_id, plaintext) = create_admin_client_key(&app, "status-key").await;
let disabled = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri(format!("/api/admin/api-keys/{key_id}/status"))
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(r#"{"status":"disabled"}"#))
.unwrap(),
)
.await
.unwrap();
let disabled_status = disabled.status();
let disabled_body = response_json(disabled).await;
assert_eq!(disabled_status, StatusCode::OK);
assert_eq!(disabled_body["data"]["enabled"], false);
let rejected = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/v1/models")
.header("authorization", format!("Bearer {plaintext}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED);
let enabled = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri(format!("/api/admin/api-keys/{key_id}/status"))
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(r#"{"status":"active"}"#))
.unwrap(),
)
.await
.unwrap();
let enabled_status = enabled.status();
let enabled_body = response_json(enabled).await;
assert_eq!(enabled_status, StatusCode::OK);
assert_eq!(enabled_body["data"]["enabled"], true);
let accepted = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/v1/models")
.header("authorization", format!("Bearer {plaintext}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(accepted.status(), StatusCode::OK);
let invalid = app
.oneshot(
Request::builder()
.method("PATCH")
.uri(format!("/api/admin/api-keys/{key_id}/status"))
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(r#"{"status":"expired"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn admin_client_keys_delete_should_remove_authorization() {
let (app, _dir) = admin_client_key_test_app("admin-client-key-delete.sqlite").await;
let (key_id, plaintext) = create_admin_client_key(&app, "delete-key").await;
let deleted = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/admin/api-keys/{key_id}"))
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let delete_status = deleted.status();
let delete_body = response_json(deleted).await;
assert_eq!(delete_status, StatusCode::OK);
assert_eq!(delete_body["data"]["deleted"], true);
let list_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/api-keys?limit=10")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let list_body = response_json(list_response).await;
assert_eq!(list_body["data"].as_array().unwrap().len(), 0);
let rejected = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/v1/models")
.header("authorization", format!("Bearer {plaintext}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED);
let missing = app
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/admin/api-keys/{key_id}"))
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
}
@@ -0,0 +1,159 @@
use super::*;
#[tokio::test]
async fn admin_client_keys_export_should_return_metadata_without_secret_material() {
let (app, _dir) = admin_client_key_test_app("admin-client-key-export.sqlite").await;
let (key_id, plaintext) = create_admin_client_key(&app, "export-key").await;
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri(format!("/api/admin/api-keys/export?ids={key_id}"))
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_api_key_export")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["requestId"], "req_api_key_export");
assert_eq!(body["data"]["sourceFormat"], "rustLocalClientApiKeys");
assert_eq!(body["data"]["rotationRequired"], true);
assert_eq!(body["data"]["apiKeys"][0]["id"], key_id);
assert_eq!(body["data"]["apiKeys"][0]["name"], "export-key");
assert!(body["data"]["apiKeys"][0]["prefix"]
.as_str()
.is_some_and(|prefix| prefix.starts_with("cpr_")));
assert!(body["data"]["apiKeys"][0].get("plaintext").is_none());
assert!(body["data"]["apiKeys"][0].get("keyHash").is_none());
assert!(body["data"].get("pepper").is_none());
assert!(!body.to_string().contains(&plaintext));
}
#[tokio::test]
async fn admin_client_keys_import_should_rotate_exported_metadata_and_return_plaintext_once() {
let source_dir = tempfile::tempdir().unwrap();
let source_db = source_dir
.path()
.join("admin-client-key-export-source.sqlite");
let source_url = format!("sqlite://{}", source_db.display());
let source_pool = connect_sqlite(&source_url).await.unwrap();
seed_admin_session(&source_pool, "session_1").await;
let source_state = AppState::with_pool_secret_and_api_key_hasher(
test_config(source_url),
source_pool,
SecretBox::new([51u8; 32]),
ApiKeyHasher::new([52u8; 32]),
);
let source_app = router::router().with_state(source_state);
let (source_key_id, source_plaintext) =
create_admin_client_key(&source_app, "rotated-key").await;
let export_response = source_app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/api-keys/export")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(export_response.status(), StatusCode::OK);
let export_body = response_json(export_response).await;
let target_dir = tempfile::tempdir().unwrap();
let target_db = target_dir
.path()
.join("admin-client-key-import-target.sqlite");
let target_url = format!("sqlite://{}", target_db.display());
let target_pool = connect_sqlite(&target_url).await.unwrap();
seed_admin_session(&target_pool, "session_1").await;
let target_state = AppState::with_pool_secret_and_api_key_hasher(
test_config(target_url),
target_pool,
SecretBox::new([61u8; 32]),
ApiKeyHasher::new([62u8; 32]),
);
let target_app = router::router().with_state(target_state);
let import_response = target_app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/api-keys/import")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_api_key_import")
.body(Body::from(export_body["data"].to_string()))
.unwrap(),
)
.await
.unwrap();
let status = import_response.status();
let import_body = response_json(import_response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(import_body["requestId"], "req_api_key_import");
assert_eq!(import_body["data"]["imported"], 1);
assert_eq!(import_body["data"]["skipped"], 0);
assert_eq!(import_body["data"]["rotated"], true);
assert_eq!(import_body["data"]["keys"][0]["sourceId"], source_key_id);
assert_eq!(import_body["data"]["keys"][0]["name"], "rotated-key");
assert!(import_body["data"]["keys"][0].get("keyHash").is_none());
let rotated_plaintext = import_body["data"]["keys"][0]["plaintext"]
.as_str()
.unwrap()
.to_string();
assert!(rotated_plaintext.starts_with("cpr_"));
assert_ne!(rotated_plaintext, source_plaintext);
let list_response = target_app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/api-keys?limit=10")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let list_body = response_json(list_response).await;
assert!(list_body["data"][0].get("plaintext").is_none());
let old_rejected = target_app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/v1/models")
.header("authorization", format!("Bearer {source_plaintext}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(old_rejected.status(), StatusCode::UNAUTHORIZED);
let new_accepted = target_app
.oneshot(
Request::builder()
.method("GET")
.uri("/v1/models")
.header("authorization", format!("Bearer {rotated_plaintext}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(new_accepted.status(), StatusCode::OK);
}
@@ -0,0 +1,138 @@
use super::*;
#[tokio::test]
async fn admin_client_keys_label_should_update_clear_and_validate_label() {
let (app, _dir) = admin_client_key_test_app("admin-client-key-label.sqlite").await;
let (key_id, _plaintext) = create_admin_client_key(&app, "label-key").await;
let renamed = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri(format!("/api/admin/api-keys/{key_id}/label"))
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(r#"{"label":"automation"}"#))
.unwrap(),
)
.await
.unwrap();
let renamed_status = renamed.status();
let renamed_body = response_json(renamed).await;
assert_eq!(renamed_status, StatusCode::OK);
assert_eq!(renamed_body["data"]["name"], "label-key");
assert_eq!(renamed_body["data"]["label"], "automation");
let cleared = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri(format!("/api/admin/api-keys/{key_id}/label"))
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(r#"{"label":null}"#))
.unwrap(),
)
.await
.unwrap();
let cleared_status = cleared.status();
let cleared_body = response_json(cleared).await;
assert_eq!(cleared_status, StatusCode::OK);
assert_eq!(cleared_body["data"]["name"], "label-key");
assert!(cleared_body["data"]["label"].is_null());
let too_long = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri(format!("/api/admin/api-keys/{key_id}/label"))
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(json!({ "label": "x".repeat(65) }).to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(too_long.status(), StatusCode::BAD_REQUEST);
let missing = app
.oneshot(
Request::builder()
.method("PATCH")
.uri("/api/admin/api-keys/missing/label")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(r#"{"label":"automation"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn admin_client_keys_batch_delete_should_remove_found_keys_and_report_missing_ids() {
let (app, _dir) = admin_client_key_test_app("admin-client-key-batch-delete.sqlite").await;
let (key_a, plaintext_a) = create_admin_client_key(&app, "batch-a").await;
let (key_b, plaintext_b) = create_admin_client_key(&app, "batch-b").await;
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/api-keys/batch-delete")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(
json!({
"ids": [key_a, "ghost", key_b]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["data"]["deleted"], 2);
assert_eq!(body["data"]["notFound"], json!(["ghost"]));
for plaintext in [plaintext_a, plaintext_b] {
let rejected = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/v1/models")
.header("authorization", format!("Bearer {plaintext}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED);
}
let empty = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/api-keys/batch-delete")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::from(r#"{"ids":[]}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(empty.status(), StatusCode::BAD_REQUEST);
}
+5
View File
@@ -0,0 +1,5 @@
event: response.output_text.delta
data: {"delta":"hello"}
event: response.completed
data: {"response":{"id":"resp_1","output_text":"hello","usage":{"input_tokens":9,"output_tokens":3}}}
@@ -0,0 +1,5 @@
event: response.output_text.delta
data: {"delta":"auth fallback hello"}
event: response.completed
data: {"response":{"id":"resp_after_401","object":"response","status":"completed","usage":{"input_tokens":3,"output_tokens":1}}}
@@ -0,0 +1,5 @@
event: response.output_text.delta
data: {"delta":"quota fallback hello"}
event: response.completed
data: {"response":{"id":"resp_after_402","object":"response","status":"completed","usage":{"input_tokens":6,"output_tokens":2}}}
@@ -0,0 +1,5 @@
event: response.output_text.delta
data: {"delta":"banned fallback hello"}
event: response.completed
data: {"response":{"id":"resp_after_403","object":"response","status":"completed","usage":{"input_tokens":5,"output_tokens":1}}}
@@ -0,0 +1,5 @@
event: response.output_text.delta
data: {"delta":"retry hello"}
event: response.completed
data: {"response":{"id":"resp_after_5xx_retry","object":"response","status":"completed","usage":{"input_tokens":4,"output_tokens":2}}}
@@ -0,0 +1,5 @@
event: response.output_text.delta
data: {"delta":"cloudflare fallback hello"}
event: response.completed
data: {"response":{"id":"resp_after_cloudflare","object":"response","status":"completed","usage":{"input_tokens":5,"output_tokens":2}}}
@@ -0,0 +1,5 @@
event: response.output_text.delta
data: {"delta":"model fallback hello"}
event: response.completed
data: {"response":{"id":"resp_after_model_unsupported","object":"response","status":"completed","usage":{"input_tokens":4,"output_tokens":2}}}
@@ -0,0 +1,5 @@
event: response.output_text.delta
data: {"delta":"image done"}
event: response.completed
data: {"response":{"id":"resp_image","object":"response","status":"completed","output_text":"image done","usage":{"input_tokens":7,"output_tokens":4,"input_tokens_details":{"cached_tokens":2}},"tool_usage":{"image_gen":{"input_tokens":31,"output_tokens":9}}}}
@@ -0,0 +1,5 @@
event: response.output_text.delta
data: {"delta":"usage hello"}
event: response.completed
data: {"response":{"id":"resp_usage","object":"response","status":"completed","output_text":"usage hello","usage":{"input_tokens":7,"output_tokens":4,"input_tokens_details":{"cached_tokens":2}}}}
@@ -0,0 +1,5 @@
event: response.output_item.done
data: {"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"from done item"}]}}
event: response.completed
data: {"response":{"id":"resp_item","object":"response","status":"completed","output":[],"usage":{"input_tokens":2,"output_tokens":3}}}
@@ -1,2 +1,2 @@
event: response.completed
data: {"response":{"id":"resp_empty","object":"response","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":0}}}
data: {"response":{"id":"resp_empty","object":"response","status":"completed"}}
@@ -0,0 +1,2 @@
event: response.failed
data: {"response":{"id":"resp_sse_auth_failed","object":"response","status":"failed","error":{"code":"token_invalid","message":"token revoked"}}}
@@ -0,0 +1,2 @@
event: response.failed
data: {"response":{"id":"resp_model_unsupported_failed","object":"response","status":"failed","error":{"code":"model_not_supported","message":"Model gpt-5.5 is not supported on this account plan"}}}
@@ -0,0 +1,5 @@
event: response.output_text.delta
data: {"delta":"auth stream fallback hello"}
event: response.completed
data: {"response":{"id":"resp_stream_after_401","object":"response","status":"completed","usage":{"input_tokens":4,"output_tokens":2}}}
@@ -0,0 +1,5 @@
event: response.output_text.delta
data: {"delta":"quota stream fallback hello"}
event: response.completed
data: {"response":{"id":"resp_stream_after_402","object":"response","status":"completed","usage":{"input_tokens":6,"output_tokens":3}}}
@@ -0,0 +1,5 @@
event: response.output_text.delta
data: {"delta":"banned stream fallback hello"}
event: response.completed
data: {"response":{"id":"resp_stream_after_403","object":"response","status":"completed","usage":{"input_tokens":5,"output_tokens":2}}}
@@ -1,6 +1,5 @@
event: response.output_text.delta
data: {"delta":"ok"}
data: {"delta":"rate fallback hello"}
event: response.completed
data: {"response":{"id":"resp_stream_after_429","object":"response","usage":{"input_tokens":4,"output_tokens":1}}}
data: {"response":{"id":"resp_stream_after_429","object":"response","status":"completed","usage":{"input_tokens":4,"output_tokens":1}}}
@@ -1,5 +1,5 @@
event: response.output_text.delta
data: {"delta":"ok"}
data: {"delta":"retry stream hello"}
event: response.completed
data: {"response":{"id":"resp_stream_after_5xx_retry","object":"response","usage":{"input_tokens":7,"output_tokens":2}}}
data: {"response":{"id":"resp_stream_after_5xx_retry","object":"response","status":"completed","usage":{"input_tokens":7,"output_tokens":2}}}
@@ -0,0 +1,5 @@
event: response.output_text.delta
data: {"delta":"cloudflare stream fallback hello"}
event: response.completed
data: {"response":{"id":"resp_stream_after_cloudflare","object":"response","status":"completed","usage":{"input_tokens":6,"output_tokens":2}}}
@@ -0,0 +1,5 @@
event: response.output_text.delta
data: {"delta":"model stream fallback hello"}
event: response.completed
data: {"response":{"id":"resp_stream_after_model_unsupported","object":"response","status":"completed","usage":{"input_tokens":5,"output_tokens":2}}}
@@ -0,0 +1,5 @@
event: response.output_text.delta
data: {"delta":"stream hello"}
event: response.completed
data: {"response":{"id":"resp_stream_usage","object":"response","status":"completed","usage":{"input_tokens":3,"output_tokens":5}}}
@@ -0,0 +1,5 @@
event: response.output_text.delta
data: {"delta":"response hello"}
event: response.completed
data: {"response":{"id":"resp_response_1","object":"response","status":"completed","usage":{"input_tokens":5,"output_tokens":2}}}
@@ -1,9 +1,8 @@
event: response.output_text.delta
data: {"delta":"你好,我是"}
data: {"delta":"hello "}
event: response.output_text.delta
data: {"delta":"一个中文助手。"}
data: {"delta":"from deltas"}
event: response.completed
data: {"response":{"id":"resp_text","object":"response","status":"completed","output":[],"usage":{"input_tokens":6,"output_tokens":4}}}
data: {"response":{"id":"resp_text","object":"response","status":"completed","output":[],"usage":{"input_tokens":2,"output_tokens":3}}}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,763 @@
use super::*;
#[tokio::test]
async fn chat_completions_should_dispatch_to_codex_and_return_openai_response() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-secret"))
.and(header("chatgpt-account-id", "chatgpt-account"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(CHAT_SUCCESS_SSE),
)
.mount(&server)
.await;
let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"messages": [
{"role": "system", "content": "You are concise."},
{"role": "user", "content": "Say hello"}
]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["object"], "chat.completion");
assert_eq!(body["model"], "gpt-5.5");
assert_eq!(body["choices"][0]["message"]["content"], "hello");
assert_eq!(body["usage"]["prompt_tokens"], 9);
assert_eq!(body["usage"]["completion_tokens"], 3);
let usage: (i64, i64, i64) = sqlx::query_as(
"select request_count, input_tokens, output_tokens from account_usage where account_id = ?",
)
.bind("acct_chat")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(usage, (1, 9, 3));
}
#[tokio::test]
async fn chat_completions_should_forward_runtime_installation_id_to_codex() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("x-codex-installation-id", TEST_INSTALLATION_ID))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(CHAT_SUCCESS_SSE),
)
.mount(&server)
.await;
let (app, api_key, _dir) =
test_app_with_account_and_installation_id(server.uri(), TEST_INSTALLATION_ID.to_string())
.await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"messages": [
{"role": "user", "content": "Say hello"}
]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn chat_completions_should_dispatch_from_restored_runtime_account_pool() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-secret"))
.and(header("chatgpt-account-id", "chatgpt-account"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(CHAT_SUCCESS_SSE),
)
.mount(&server)
.await;
let (app, api_key, _dir) =
test_app_with_restored_pool_then_disabled_account(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"messages": [
{"role": "user", "content": "Say hello"}
]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn chat_completions_should_fallback_to_next_account_after_rate_limit() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-primary"))
.respond_with(
ResponseTemplate::new(429)
.insert_header("retry-after", "120")
.set_body_json(json!({
"error": {
"message": "rate limited",
"resets_in_seconds": 120
}
})),
)
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-secondary"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(CHAT_SUCCESS_SSE),
)
.expect(1)
.mount(&server)
.await;
let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"messages": [{"role": "user", "content": "Say hello"}]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
let authorizations = received_authorizations(&server).await;
assert_eq!(status, StatusCode::OK, "requests: {authorizations:?}");
assert_eq!(body["choices"][0]["message"]["content"], "hello");
let persisted_quota_state: (i64, Option<String>) = sqlx::query_as(
"select quota_limit_reached, quota_cooldown_until from accounts where id = ?",
)
.bind("acct_primary")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(persisted_quota_state.0, 1);
let cooldown_until =
chrono::DateTime::parse_from_rfc3339(persisted_quota_state.1.as_deref().unwrap())
.unwrap()
.with_timezone(&Utc);
assert!(cooldown_until > Utc::now());
}
#[tokio::test]
async fn chat_completions_should_return_rate_limit_error_when_429_fallback_is_exhausted() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-secret"))
.respond_with(ResponseTemplate::new(429).set_body_json(json!({
"error": {"message": "rate limited"}
})))
.expect(1)
.mount(&server)
.await;
let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"messages": [{"role": "user", "content": "Say hello"}]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
let quota_state: (i64, Option<String>) = sqlx::query_as(
"select quota_limit_reached, quota_cooldown_until from accounts where id = ?",
)
.bind("acct_chat")
.fetch_one(&pool)
.await
.unwrap();
let message = body["error"]["message"].as_str().unwrap_or_default();
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
assert!(message.contains("All accounts exhausted (1 rate-limited)"));
assert!(message.contains("rate limited"));
assert_eq!(body["error"]["code"], "upstream_error");
assert_eq!(quota_state.0, 1);
assert!(quota_state.1.is_some());
}
#[tokio::test]
async fn chat_completions_should_fallback_after_http_model_unsupported() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-primary"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!({
"error": {
"code": "model_not_supported",
"message": "Model gpt-5.5 is not supported on this account plan"
}
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-secondary"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(CHAT_SUCCESS_SSE),
)
.expect(1)
.mount(&server)
.await;
let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"messages": [{"role": "user", "content": "Say hello"}]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
let primary_status: (String,) = sqlx::query_as("select status from accounts where id = ?")
.bind("acct_primary")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(status, StatusCode::OK);
assert_eq!(body["choices"][0]["message"]["content"], "hello");
assert_eq!(primary_status.0, "active");
}
#[tokio::test]
async fn chat_completions_should_return_model_unsupported_error_when_fallback_is_exhausted() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-secret"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!({
"error": {
"code": "model_not_supported",
"message": "Model gpt-5.5 is not supported on this account plan"
}
})))
.expect(1)
.mount(&server)
.await;
let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"messages": [{"role": "user", "content": "Say hello"}]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
let message = body["error"]["message"].as_str().unwrap_or_default();
let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?")
.bind("acct_chat")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(message.contains("All accounts exhausted (1 model-unsupported)"));
assert!(message.contains("model_not_supported"));
assert_eq!(body["error"]["code"], "upstream_error");
assert_eq!(account_status.0, "active");
}
#[tokio::test]
async fn chat_completions_should_mark_expired_after_401_and_fallback() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-primary"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"error": {"code": "token_revoked", "message": "token revoked"}
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-secondary"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(CHAT_SUCCESS_SSE),
)
.expect(1)
.mount(&server)
.await;
let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"messages": [{"role": "user", "content": "Say hello"}]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?")
.bind("acct_primary")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(status, StatusCode::OK);
assert_eq!(body["choices"][0]["message"]["content"], "hello");
assert_eq!(account_status.0, "expired");
}
#[tokio::test]
async fn chat_completions_should_return_auth_error_when_401_fallback_is_exhausted() {
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", "message": "token revoked"}
})))
.expect(1)
.mount(&server)
.await;
let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"messages": [{"role": "user", "content": "Say hello"}]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
let message = body["error"]["message"].as_str().unwrap_or_default();
let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?")
.bind("acct_chat")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert!(message.contains("All accounts exhausted (1 expired)"));
assert!(message.contains("token_revoked"));
assert_eq!(body["error"]["code"], "upstream_error");
assert_eq!(account_status.0, "expired");
}
#[tokio::test]
async fn chat_completions_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-secret"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"error": {"message": "account deactivated"}
})))
.expect(1)
.mount(&server)
.await;
let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"messages": [{"role": "user", "content": "Say hello"}]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?")
.bind("acct_chat")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(account_status.0, "banned");
}
#[tokio::test]
async fn chat_completions_should_mark_banned_after_403_and_fallback() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-primary"))
.respond_with(ResponseTemplate::new(403).set_body_json(json!({
"error": {"message": "account banned"}
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-secondary"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(CHAT_SUCCESS_SSE),
)
.expect(1)
.mount(&server)
.await;
let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"messages": [{"role": "user", "content": "Say hello"}]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?")
.bind("acct_primary")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(status, StatusCode::OK);
assert_eq!(body["choices"][0]["message"]["content"], "hello");
assert_eq!(account_status.0, "banned");
}
#[tokio::test]
async fn chat_completions_should_cool_down_cloudflare_403_and_fallback() {
let started_at = Utc::now();
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-primary"))
.respond_with(ResponseTemplate::new(403).set_body_string(
"<html><title>Just a moment...</title><body>cf_chl challenge</body></html>",
))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-secondary"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(CHAT_SUCCESS_SSE),
)
.expect(1)
.mount(&server)
.await;
let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await;
let cookie_store = SqliteCookieStore::new(pool.clone(), SecretBox::new([83u8; 32]));
cookie_store
.set_cookie_header("acct_primary", "cf_clearance=old")
.await
.unwrap();
cookie_store
.set_cookie_header("acct_secondary", "cf_clearance=keep")
.await
.unwrap();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"messages": [{"role": "user", "content": "Say hello"}]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
let primary_state: (String, Option<String>) =
sqlx::query_as("select status, cloudflare_cooldown_until from accounts where id = ?")
.bind("acct_primary")
.fetch_one(&pool)
.await
.unwrap();
let cooldown_until = chrono::DateTime::parse_from_rfc3339(primary_state.1.as_deref().unwrap())
.unwrap()
.with_timezone(&Utc);
assert_eq!(status, StatusCode::OK);
assert_eq!(body["choices"][0]["message"]["content"], "hello");
assert_eq!(primary_state.0, "active");
assert!(cooldown_until > started_at);
assert_eq!(
cookie_store
.cookie_header("acct_primary", "chatgpt.com")
.await
.unwrap(),
None
);
assert_eq!(
cookie_store
.cookie_header("acct_secondary", "chatgpt.com")
.await
.unwrap()
.as_deref(),
Some("cf_clearance=keep")
);
}
#[tokio::test]
async fn chat_completions_should_mark_quota_exhausted_after_402_and_fallback() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-primary"))
.respond_with(ResponseTemplate::new(402).set_body_json(json!({
"error": {"message": "quota reached"}
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-secondary"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(CHAT_SUCCESS_SSE),
)
.expect(1)
.mount(&server)
.await;
let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"messages": [{"role": "user", "content": "Say hello"}]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?")
.bind("acct_primary")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(status, StatusCode::OK);
assert_eq!(body["choices"][0]["message"]["content"], "hello");
assert_eq!(account_status.0, "quota_exhausted");
}
#[tokio::test]
async fn chat_completions_should_return_quota_error_when_402_fallback_is_exhausted() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-secret"))
.respond_with(ResponseTemplate::new(402).set_body_json(json!({
"error": {"message": "quota reached"}
})))
.expect(1)
.mount(&server)
.await;
let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"messages": [{"role": "user", "content": "Say hello"}]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
let account_status: (String,) = sqlx::query_as("select status from accounts where id = ?")
.bind("acct_chat")
.fetch_one(&pool)
.await
.unwrap();
let message = body["error"]["message"].as_str().unwrap_or_default();
assert_eq!(status, StatusCode::PAYMENT_REQUIRED);
assert!(message.contains("All accounts exhausted (1 quota-exhausted)"));
assert!(message.contains("quota reached"));
assert_eq!(body["error"]["code"], "upstream_error");
assert_eq!(account_status.0, "quota_exhausted");
}
@@ -0,0 +1,243 @@
use super::*;
#[tokio::test]
async fn 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(RESPONSES_SUCCESS_SSE),
)
.expect(1)
.mount(&server)
.await;
let (app, api_key, _dir) = test_app_with_account(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses/review")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"input": [],
"stream": false,
"use_websocket": false
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["id"], "resp_response_1");
let requests = server.received_requests().await.unwrap();
let upstream_body: Value = serde_json::from_slice(&requests[0].body).unwrap();
assert_eq!(
upstream_body["client_metadata"]["x-openai-subagent"],
"review"
);
}
#[tokio::test]
async fn 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 (app, api_key, _dir) = test_app_with_account(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses/compact")
.header("authorization", format!("Bearer {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,
"use_websocket": false,
"store": true,
"prompt_cache_key": "must_not_forward"
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::OK);
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")
.expect("compact upstream request should be sent");
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 responses_compact_should_return_rate_limit_error_when_fallback_is_exhausted() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses/compact"))
.and(header("authorization", "Bearer access-secret"))
.respond_with(ResponseTemplate::new(429).set_body_json(json!({
"error": {
"code": "rate_limit_exceeded",
"message": "compact quota reached"
}
})))
.expect(1)
.mount(&server)
.await;
let (app, api_key, _pool, _dir) = test_app_with_account_and_pool(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses/compact")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"input": [{"role": "user", "content": "hello"}]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
let message = body["error"]["message"].as_str().unwrap_or_default();
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
assert_eq!(body["type"], "error");
assert_eq!(body["error"]["type"], "rate_limit_error");
assert_eq!(body["error"]["code"], "rate_limit_exceeded");
assert!(message.contains("All accounts exhausted (1 rate-limited)"));
assert!(message.contains("compact quota reached"));
}
#[tokio::test]
async fn responses_compact_should_return_responses_error_when_no_accounts_are_available() {
let server = MockServer::start().await;
let (app, api_key, _dir) = test_app_without_accounts(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses/compact")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"input": [{"role": "user", "content": "hello"}]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(body["type"], "error");
assert_eq!(body["error"]["type"], "server_error");
assert_eq!(body["error"]["code"], "no_available_accounts");
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,676 @@
use super::*;
#[tokio::test]
async fn responses_should_use_imported_account_record_usage_cookie_and_event_log() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-secret"))
.and(header("chatgpt-account-id", "chatgpt-account"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.insert_header(
"set-cookie",
"cf_clearance=new; Domain=.chatgpt.com; Path=/",
)
.set_body_string(RESPONSES_COMPLETED_USAGE_SSE),
)
.expect(1)
.mount(&server)
.await;
let (app, api_key, pool, _dir) = test_app_with_account_pool_and_logging(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.header("x-request-id", "req_non_stream_usage_log")
.body(Body::from(
json!({
"model": "gpt-5.5",
"input": [],
"stream": false,
"use_websocket": false
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["id"], "resp_usage");
assert_eq!(body["usage"]["input_tokens"], 7);
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_chat")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(usage, (1, 7, 4, 2));
let cookie_header = SqliteCookieStore::new(pool.clone(), SecretBox::new([83u8; 32]))
.cookie_header("acct_chat", "chatgpt.com")
.await
.unwrap();
assert_eq!(cookie_header.as_deref(), Some("cf_clearance=new"));
let event = latest_response_event_log(&pool).await;
let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap();
assert_eq!(event.level, "info");
assert_eq!(
event.request_id.as_deref(),
Some("req_non_stream_usage_log")
);
assert_eq!(event.account_id.as_deref(), Some("acct_chat"));
assert_eq!(event.status_code, Some(200));
assert_eq!(metadata["stream"], false);
assert_eq!(metadata["usage"]["inputTokens"], 7);
}
#[tokio::test]
async fn responses_should_skip_event_log_when_logging_disabled() {
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(RESPONSES_COMPLETED_USAGE_SSE),
)
.expect(1)
.mount(&server)
.await;
let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.header("x-request-id", "req_non_stream_no_log")
.body(Body::from(
json!({
"model": "gpt-5.5",
"input": [],
"stream": false,
"use_websocket": false
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response_event_log_count(&pool).await, 0);
}
#[tokio::test]
async fn responses_should_record_image_generation_usage_when_tool_succeeds() {
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(RESPONSES_COMPLETED_IMAGE_USAGE_SSE),
)
.expect(1)
.mount(&server)
.await;
let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"input": [],
"stream": false,
"use_websocket": false,
"tools": [{"type": "image_generation"}]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let usage: (i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64, i64) =
sqlx::query_as(
"select request_count, input_tokens, output_tokens, cached_tokens, image_input_tokens, image_output_tokens, image_request_count, image_request_failed_count, window_image_input_tokens, window_image_output_tokens, window_image_request_count, window_image_request_failed_count from account_usage where account_id = ?",
)
.bind("acct_chat")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(usage, (1, 7, 4, 2, 31, 9, 1, 0, 31, 9, 1, 0));
}
#[tokio::test]
async fn responses_should_record_failed_image_generation_attempt_when_tool_has_no_output() {
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(RESPONSES_COMPLETED_USAGE_SSE),
)
.expect(1)
.mount(&server)
.await;
let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"input": [],
"stream": false,
"use_websocket": false,
"tools": [{"type": "image_generation"}]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let usage: (i64, i64, i64, i64) = sqlx::query_as(
"select image_request_count, image_request_failed_count, window_image_request_count, window_image_request_failed_count from account_usage where account_id = ?",
)
.bind("acct_chat")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(usage, (0, 1, 0, 1));
}
#[tokio::test]
async fn responses_should_passively_cache_rate_limit_headers() {
let server = MockServer::start().await;
let reset_at = Utc::now().timestamp() + 300;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.insert_header("x-codex-primary-used-percent", "100")
.insert_header("x-codex-primary-window-minutes", "5")
.insert_header("x-codex-primary-reset-at", reset_at.to_string())
.set_body_string(RESPONSES_COMPLETED_USAGE_SSE),
)
.expect(1)
.mount(&server)
.await;
let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await;
sqlx::query(
r#"update accounts set quota_json = '{"credits":{"has_credits":true,"unlimited":false,"balance":12}}', quota_verify_required = 1 where id = ?"#,
)
.bind("acct_chat")
.execute(&pool)
.await
.unwrap();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"input": [],
"stream": false,
"use_websocket": false
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let stored: (String, i64, Option<String>, i64) = sqlx::query_as(
"select quota_json, quota_limit_reached, quota_cooldown_until, quota_verify_required from accounts where id = ?",
)
.bind("acct_chat")
.fetch_one(&pool)
.await
.unwrap();
let quota: Value = serde_json::from_str(&stored.0).unwrap();
assert_eq!(quota["rate_limit"]["limit_reached"], true);
assert_eq!(quota["rate_limit"]["reset_at"], reset_at);
assert_eq!(quota["credits"]["balance"], 12);
assert_eq!(stored.1, 1);
assert!(stored.2.is_some());
assert_eq!(stored.3, 0);
let window: (i64, i64, i64, i64, Option<String>, Option<String>, Option<i64>) =
sqlx::query_as(
"select window_request_count, window_input_tokens, window_output_tokens, window_cached_tokens, window_started_at, window_reset_at, limit_window_seconds from account_usage where account_id = ?",
)
.bind("acct_chat")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(window.0, 1);
assert_eq!(window.1, 7);
assert_eq!(window.2, 4);
assert_eq!(window.3, 2);
assert!(window.4.is_some());
assert_eq!(
DateTime::parse_from_rfc3339(window.5.as_deref().unwrap())
.unwrap()
.timestamp(),
reset_at
);
assert_eq!(window.6, Some(300));
}
#[tokio::test]
async fn responses_should_record_empty_response_attempts() {
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(RESPONSES_EMPTY_COMPLETED_SSE),
)
.expect(3)
.mount(&server)
.await;
let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"input": [],
"stream": false,
"use_websocket": false
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let usage: (i64, i64, i64, i64) = sqlx::query_as(
"select request_count, empty_response_count, input_tokens, output_tokens from account_usage where account_id = ?",
)
.bind("acct_chat")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(usage, (3, 3, 0, 0));
}
#[tokio::test]
async fn responses_stream_should_proxy_sse_and_record_usage() {
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(RESPONSES_STREAM_USAGE_SSE),
)
.expect(1)
.mount(&server)
.await;
let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"input": [{"role": "user", "content": "Say hello"}],
"stream": true,
"use_websocket": false
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let content_type = response
.headers()
.get("content-type")
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string();
let body = response_text(response).await;
let usage: (i64, i64, i64) = sqlx::query_as(
"select request_count, input_tokens, output_tokens from account_usage where account_id = ?",
)
.bind("acct_chat")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(status, StatusCode::OK);
assert!(content_type.starts_with("text/event-stream"));
assert!(body.contains("event: response.completed"));
assert!(body.contains("resp_stream_usage"));
assert!(
body.ends_with("data: [DONE]\n\n"),
"stream responses should terminate clients, body was {body:?}"
);
assert_eq!(usage, (1, 3, 5));
}
#[tokio::test]
async fn responses_stream_should_record_event_log_after_completed_stream() {
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")
.insert_header("retry-after", "7")
.insert_header("x-ratelimit-limit-requests", "99")
.set_body_string(RESPONSES_STREAM_USAGE_SSE),
)
.mount(&server)
.await;
let (app, api_key, pool, _dir) = test_app_with_account_pool_and_logging(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.header("x-request-id", "req_stream_completed_log")
.body(Body::from(
json!({
"model": "gpt-5.5",
"input": [{"role": "user", "content": "stream with log"}],
"stream": true,
"use_websocket": false
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let body = response_text(response).await;
let event = latest_response_event_log(&pool).await;
let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap();
assert!(body.contains("resp_stream_usage"));
assert_eq!(event.level, "info");
assert_eq!(
event.request_id.as_deref(),
Some("req_stream_completed_log")
);
assert_eq!(event.account_id.as_deref(), Some("acct_chat"));
assert_eq!(event.status_code, Some(200));
assert_eq!(metadata["stream"], true);
assert_eq!(metadata["completed"], true);
assert_eq!(metadata["responseId"], "resp_stream_usage");
assert_eq!(metadata["usage"]["inputTokens"], 3);
assert_eq!(metadata["usage"]["outputTokens"], 5);
assert_rate_limit_header(&metadata, "retry-after", "7");
assert_rate_limit_header(&metadata, "x-ratelimit-limit-requests", "99");
assert!(metadata.get("requestBody").is_none());
assert!(metadata.get("responseBody").is_none());
}
#[tokio::test]
async fn responses_stream_should_preserve_body_metadata_when_capture_body_enabled() {
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(RESPONSES_STREAM_USAGE_SSE),
)
.mount(&server)
.await;
let (app, api_key, pool, _dir) =
test_app_with_account_pool_and_logging_capture_body(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.header("x-request-id", "req_stream_capture_body_log")
.body(Body::from(
json!({
"model": "gpt-5.5",
"input": [{"role": "user", "content": "stream body capture"}],
"stream": true,
"use_websocket": false
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let body = response_text(response).await;
let event = latest_response_event_log(&pool).await;
let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap();
assert!(body.contains("resp_stream_usage"));
assert_eq!(event.level, "info");
assert_eq!(
event.request_id.as_deref(),
Some("req_stream_capture_body_log")
);
assert_eq!(metadata["requestBody"]["model"], "gpt-5.5");
assert_eq!(metadata["requestBody"]["stream"], true);
assert!(metadata["responseBody"]
.as_str()
.is_some_and(|body| body.contains("resp_stream_usage")));
}
#[tokio::test]
async fn responses_stream_should_record_event_log_after_late_disconnect() {
let first_frame = r#"event: response.output_text.delta
data: {"delta":"partial before logged disconnect"}
"#;
let (base_url, first_chunk_sent, close_upstream) =
spawn_chunked_sse_upstream_then_clean_close_with_headers(
first_frame,
&[
("retry-after", "11"),
("x-codex-primary-used-percent", "88"),
],
)
.await;
let (app, api_key, pool, _dir) = test_app_with_account_pool_and_logging(base_url).await;
let response_task = tokio::spawn(async move {
app.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.header("x-request-id", "req_stream_disconnect_log")
.body(Body::from(
json!({
"model": "gpt-5.5",
"input": [{"role": "user", "content": "stream disconnect log"}],
"stream": true,
"use_websocket": false
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap()
});
first_chunk_sent.await.unwrap();
let response = response_task.await.unwrap();
close_upstream.send(()).unwrap();
let body = response_text(response).await;
let event = latest_response_event_log(&pool).await;
let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap();
assert!(body.contains("stream_disconnected"));
assert_eq!(event.level, "error");
assert_eq!(
event.request_id.as_deref(),
Some("req_stream_disconnect_log")
);
assert_eq!(event.account_id.as_deref(), Some("acct_chat"));
assert_eq!(event.status_code, Some(502));
assert_eq!(metadata["stream"], true);
assert_eq!(metadata["failed"], true);
assert_eq!(metadata["upstreamCode"], "stream_disconnected");
assert_rate_limit_header(&metadata, "retry-after", "11");
assert_rate_limit_header(&metadata, "x-codex-primary-used-percent", "88");
}
#[tokio::test]
async fn responses_should_record_request_count_when_5xx_retries_are_exhausted() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-secret"))
.respond_with(ResponseTemplate::new(500).set_body_json(json!({
"error": {"message": "temporary upstream failure"}
})))
.mount(&server)
.await;
let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"input": [{"role": "user", "content": "Say hello"}],
"stream": false,
"use_websocket": false
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let authorizations = received_authorizations(&server).await;
let failed_usage: Option<(i64,)> =
sqlx::query_as("select request_count from account_usage where account_id = ?")
.bind("acct_chat")
.fetch_optional(&pool)
.await
.unwrap();
assert_eq!(status, StatusCode::BAD_GATEWAY);
assert_eq!(authorizations.len(), 3, "requests: {authorizations:?}");
assert_eq!(failed_usage.map(|row| row.0).unwrap_or_default(), 1);
}
#[tokio::test]
async fn responses_should_record_session_affinity_for_completed_response() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.and(header("authorization", "Bearer access-secret"))
.and(header("chatgpt-account-id", "chatgpt-account"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(RESPONSES_SUCCESS_SSE),
)
.mount(&server)
.await;
let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"input": [{"role": "user", "content": "Say hello"}],
"stream": false,
"use_websocket": false
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let records = SqliteSessionAffinityStore::new(pool)
.list_active(Utc::now())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(records.len(), 1);
assert_eq!(records[0].response_id, "resp_response_1");
assert_eq!(records[0].entry.account_id, "acct_chat");
}
-9
View File
@@ -1,9 +0,0 @@
//! 架构检查命令。
use crate::{repository_root, run_command, XtaskResult};
/// 运行架构测试。
pub fn run() -> XtaskResult {
let root = repository_root()?;
run_command("cargo", &["test", "--test", "architecture"], &root)
}
+1 -3
View File
@@ -1,5 +1,4 @@
mod build_web;
mod check_architecture;
mod release;
use std::{
@@ -24,7 +23,6 @@ fn main() -> ExitCode {
fn run() -> XtaskResult {
match std::env::args().nth(1).as_deref() {
Some("build-web") => build_web::run(),
Some("check-architecture") => check_architecture::run(),
Some("release") => release::run(),
_ => {
print_usage();
@@ -34,7 +32,7 @@ fn run() -> XtaskResult {
}
fn print_usage() {
eprintln!("usage: cargo xtask <build-web|check-architecture|release>");
eprintln!("usage: cargo xtask <build-web|release>");
}
pub(crate) fn repository_root() -> Result<PathBuf, io::Error> {
-360
View File
@@ -1,360 +0,0 @@
# 管理员初始化功能修复 - 2026-06-13
## 问题描述
**原问题:** 无法导入测试账号进行端到端验证
**症状:**
- 数据库中没有管理员账号
- config.yaml 缺少默认管理员配置
- 无法登录管理接口导入账号
**根本原因:**
系统启动时未创建默认管理员账号,且配置文件未提供初始化机制。
---
## 修复方案
### 1. 配置层:添加默认管理员配置
**文件:** `config.yaml`
```yaml
admin:
session_ttl_minutes: 1440
default_username: admin # 新增:默认管理员用户名
default_password: admin # 新增:默认管理员密码
```
**文件:** `src/config/types.rs`
```rust
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct AdminConfig {
pub session_ttl_minutes: u64,
#[serde(default = "default_session_cleanup_interval")]
pub session_cleanup_interval_secs: u64,
/// 默认管理员用户名(首次启动时创建)
#[serde(default = "default_admin_username")]
pub default_username: String,
/// 默认管理员密码(首次启动时创建)
#[serde(default = "default_admin_password")]
pub default_password: String,
}
fn default_admin_username() -> String {
"admin".to_string()
}
fn default_admin_password() -> String {
"admin".to_string()
}
```
---
### 2. 启动层:自动创建管理员
**文件:** `src/runtime/bootstrap.rs`
```rust
use crate::{
platform::{
crypto::{CryptoError, SecretBox},
identity::{
admin_session::hash_admin_password, // 新增导入
api_key::ApiKeyHasher,
error::AuthError
},
storage::db::connect_sqlite,
},
};
pub async fn build_state(config: AppConfig) -> BootstrapResult<(AppState, SqlitePool, usize)> {
// ... 其他初始化
let pool = connect_sqlite(&config.database.url).await?;
// 初始化默认管理员账号(如果不存在)
ensure_default_admin_exists(&pool, &config).await?;
// ... 继续初始化
}
/// 确保默认管理员账号存在(首次启动时创建)
async fn ensure_default_admin_exists(
pool: &SqlitePool,
config: &AppConfig,
) -> Result<(), sqlx::Error> {
// 检查是否已存在管理员
let count: (i64,) = sqlx::query_as("select count(*) from admin_users")
.fetch_one(pool)
.await?;
if count.0 == 0 {
// 创建默认管理员
let admin_id = format!("admin_{}", uuid::Uuid::new_v4().simple());
let password_hash = hash_admin_password(&config.admin.default_password)
.map_err(|e| sqlx::Error::Protocol(format!("failed to hash password: {}", e)))?;
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"insert into admin_users (id, password_hash, created_at, updated_at) values (?, ?, ?, ?)",
)
.bind(&admin_id)
.bind(&password_hash)
.bind(&now)
.bind(&now)
.execute(pool)
.await?;
tracing::info!(
username = %config.admin.default_username,
"created default admin user"
);
}
Ok(())
}
```
---
## 验证流程
### 1. 管理员登录
```bash
curl -X POST http://127.0.0.1:8080/api/admin/login \
-H "Content-Type: application/json" \
-d '{"password":"admin"}'
```
**响应:**
```json
{
"code": 200,
"message": "OK",
"data": {
"expiresAt": "2026-06-14T14:21:39.337284602+00:00"
}
}
```
**Set-Cookie 头:**
```
cpr_admin_session=sess_a27875537483420ab7ba6ce4d1f91b5e; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400
```
---
### 2. 导入测试账号
```bash
curl -X POST http://127.0.0.1:8080/api/admin/accounts/import \
-H "Content-Type: application/json" \
-H "Cookie: cpr_admin_session=sess_a27875537483420ab7ba6ce4d1f91b5e" \
-d @accounts-export.json
```
**响应:**
```json
{
"code": 200,
"message": "OK",
"data": {
"imported": 1,
"skipped": 0,
"sourceFormat": "native"
}
}
```
---
### 3. 端到端真实请求验证
#### 3.1 创建 API 密钥
```bash
curl -X POST http://127.0.0.1:8080/api/admin/api-keys \
-H "Content-Type: application/json" \
-H "Cookie: cpr_admin_session=sess_..." \
-d '{"name":"test-key","label":"测试密钥"}'
```
**响应:**
```json
{
"code": 200,
"data": {
"id": "key_14eea266d1a24118bae4c46c8a48e135",
"plaintext": "cpr_5kmialybsZE5aWAaXMZvjV1j8eSuK3FkxItJd740ZRc"
}
}
```
#### 3.2 刷新模型列表
```bash
curl -X POST http://127.0.0.1:8080/api/admin/refresh-models \
-H "Cookie: cpr_admin_session=sess_..."
```
**响应:**
```json
{
"code": 200,
"data": {
"refreshedPlans": 1,
"modelCount": 3,
"failedPlans": 0
}
}
```
#### 3.3 发送真实请求到 ChatGPT API
```bash
curl -X POST http://127.0.0.1:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer cpr_5kmialybsZE5aWAaXMZvjV1j8eSuK3FkxItJd740ZRc" \
-d '{
"model": "gpt-5.5",
"messages": [{"role": "user", "content": "Say hi in 3 words"}],
"stream": false
}'
```
**响应:**
```json
{
"choices": [{
"finish_reason": "stop",
"index": 0,
"message": {
"content": "Hi there, friend!",
"role": "assistant"
}
}],
"created": 1781360607,
"id": "chatcmpl-6333924d51a741bcbe885c00b0c54910",
"model": "gpt-5.5",
"object": "chat.completion",
"usage": {
"completion_tokens": 9,
"prompt_tokens": 22,
"total_tokens": 31
}
}
```
✅ **验证成功!** ChatGPT API 接受了请求头,返回正常响应,说明:
- 指纹实现正确 (26.609.41114)
- 请求头顺序正确
- User-Agent 格式正确
- 所有浏览器特征头部正确
---
## 测试修复
由于 `AdminConfig` 添加了新字段,需要修复所有测试文件中的初始化代码。
**影响的测试文件:**
- `tests/api/admin/*.rs` (6 个文件)
- `tests/codex_serving/*.rs` (2 个文件)
- `tests/runtime/startup.rs`
- `tests/support/*.rs` (2 个文件)
**修复方式:**
```rust
admin: AdminConfig {
session_ttl_minutes: 1440,
session_cleanup_interval_secs: 3600,
default_username: "admin".to_string(), // 新增
default_password: "admin".to_string(), // 新增
},
```
---
## 测试结果
### 单元测试
```bash
$ cargo test --lib
test result: ok. 20 passed; 0 failed
```
### 集成测试
```bash
$ cargo test --test admin
test result: ok. 75 passed; 0 failed
$ cargo test --test codex_serving
test result: ok. 49 passed; 0 failed
```
**总计:** 144+ 测试全部通过 ✅
---
## 端到端验证总结
| 验证项 | 状态 | 说明 |
|--------|------|------|
| **管理员初始化** | ✅ | 首次启动自动创建,使用 config.yaml 配置 |
| **管理员登录** | ✅ | 密码正确,返回会话 cookie |
| **账号导入** | ✅ | 成功导入 2 个测试账号 |
| **API 密钥创建** | ✅ | 生成客户端密钥用于请求 |
| **模型刷新** | ✅ | 从 ChatGPT 后端获取 3 个模型 |
| **真实请求** | ✅ | 使用最新指纹成功发送请求 |
| **ChatGPT 响应** | ✅ | API 接受请求头,返回正常响应 |
| **指纹版本** | ✅ | 26.609.41114 (build 3888) |
| **账号轮换** | ✅ | 两个账号都处于 active 状态 |
---
## 影响范围
### 修改的模块
- `config.yaml` - 添加默认管理员配置
- `src/config/types.rs` - AdminConfig 添加字段
- `src/runtime/bootstrap.rs` - 启动时初始化管理员
- `tests/**/*.rs` - 修复测试中的 AdminConfig 初始化
### 不变的模块
- `src/api/admin/auth/service.rs` - 认证逻辑无需变更
- `src/platform/identity/admin_session.rs` - 密码哈希逻辑无需变更
- `src/platform/storage/schema.sql` - 数据库表结构无需变更
---
## 安全考虑
1. **密码哈希:** 使用 bcrypt 算法,默认 cost=12
2. **会话管理:** HttpOnly + SameSite=Lax + 24小时过期
3. **生产环境建议:**
- 通过环境变量覆盖默认密码
- 首次登录后立即修改密码(TODO: 未实现)
- 考虑添加密码强度验证
---
## 后续改进
### 可选功能:
- [ ] 首次登录强制修改密码
- [ ] 密码复杂度验证
- [ ] 管理员账号管理(增删改查)
- [ ] 审计日志记录管理员操作
- [ ] 支持环境变量 `ADMIN_PASSWORD` 覆盖配置
---
## 相关文档
- [FINGERPRINT_PROPAGATION_FIX.md](FINGERPRINT_PROPAGATION_FIX.md) - 指纹传播修复
- [FINGERPRINT_AUTO_UPDATE.md](FINGERPRINT_AUTO_UPDATE.md) - 自动更新机制
- [IMPLEMENTATION_COMPARISON.md](IMPLEMENTATION_COMPARISON.md) - 与 Node.js 对比
-445
View File
@@ -1,445 +0,0 @@
# Codex Desktop 指纹自动更新实现
## 实现日期
2026-06-13
## 背景
Codex Desktop 会定期发布新版本,版本号变化会影响 HTTP 指纹识别。为保持与真实客户端一致,需要实现自动同步机制。
---
## 实现方案
### 核心机制
参考 Node.js 版本实现,采用相同的更新策略:
1. **轮询 Sparkle Appcast** - 定期检查官方更新源
2. **自动解析版本** - 提取 `app_version`、`build_number`
3. **持久化状态** - 写入 `data/version-state.json` 和数据库
4. **运行时生效** - 后台任务自动应用新版本
### 关键参数
| 参数 | 值 | 说明 |
|------|-----|------|
| Appcast URL | `https://persistent.oaistatic.com/codex-app-prod/appcast.xml` | Codex Desktop 官方更新源 |
| 轮询间隔 | 3 天 | 与 Node.js 版本一致 |
| 当前版本 | `26.519.81530` | app_version |
| 当前构建号 | `3178` | build_number |
| Chromium 版本 | `146` | 浏览器内核版本 |
---
## 实现细节
### 1. UpdateChecker 核心结构
```rust
pub struct UpdateChecker {
db: Option<SqlitePool>,
state: Arc<Mutex<InternalState>>,
}
impl UpdateChecker {
pub fn new(db: Option<SqlitePool>, current_version: String, current_build: String) -> Self
pub async fn check_for_update(&self) -> Result<UpdateState, UpdateError>
pub fn start_background_checker(self) -> JoinHandle<()>
}
```
**特性:**
- 使用 `Arc<Mutex<>>` 实现内部状态共享
- 返回 `JoinHandle<()>` 支持优雅关闭
- 异步设计,不阻塞主线程
### 2. Appcast XML 解析
支持两种 Sparkle 格式:
**属性语法:**
```xml
<item>
<sparkle:shortVersionString="26.519.81530"/>
<sparkle:version="3178"/>
<enclosure url="https://..." />
</item>
```
**元素语法:**
```xml
<item>
<sparkle:shortVersionString>26.519.81530</sparkle:shortVersionString>
<sparkle:version>3178</sparkle:version>
<enclosure url="https://..." />
</item>
```
### 3. 版本状态持久化
**文件结构:**
```
data/
├── update-state.json # 最近检查状态
├── version-state.json # 当前应用版本
└── extracted-fingerprint.json # 可选:提取的完整指纹
```
**update-state.json 格式:**
```json
{
"last_check": "2026-06-13T10:30:00Z",
"latest_version": "26.519.81530",
"latest_build": "3178",
"download_url": "https://...",
"update_available": false,
"current_version": "26.519.81530",
"current_build": "3178"
}
```
**version-state.json 格式:**
```json
{
"app_version": "26.519.81530",
"build_number": "3178",
"chromium_version": "146"
}
```
### 4. 数据库持久化
自动更新通过 `FingerprintRepository::upsert_auto_update()` 写入 `fingerprints` 表的 `auto_updated` 当前记录:
```sql
insert into fingerprints (
id, app_version, build_number, platform, arch,
chromium_version, user_agent_template, source, created_at
) values ('auto_updated', ?, ?, 'darwin', 'arm64', ?, ?, 'auto_update', ?)
on conflict(id) do update set
app_version = excluded.app_version,
build_number = excluded.build_number,
chromium_version = excluded.chromium_version,
created_at = excluded.created_at
```
**好处:**
- 启动时可以稳定加载最新 `auto_update` fingerprint
- 自动更新逻辑不再直接持有业务表 SQL
- 历史记录写入仍由 `FingerprintRepository::insert_update()` 支持
### 5. Chromium 版本匹配
如果存在 `extracted-fingerprint.json`(手动提取的完整指纹):
```rust
fn load_matching_chromium_version(version: &str, build: &str) -> Option<String> {
// 读取 extracted-fingerprint.json
// 匹配 app_version 和 build_number
// 返回对应的 chromium_version
}
```
**场景:**
- 自动更新时,先检查本地是否有匹配的提取指纹
- 如果有,使用提取的 `chromium_version`(更准确)
- 否则,保持默认值 `"146"`
---
## 集成到后台任务
### coordinator.rs 修改
```rust
pub async fn start_background_tasks(
state: &AppState,
db_pool: SqlitePool,
config: &AppConfig,
) -> BackgroundTaskCoordinator {
let mut coordinator = BackgroundTaskCoordinator::default();
// 指纹自动更新(3 天轮询一次)
let fingerprint = Fingerprint::default_codex_desktop();
let update_checker = UpdateChecker::new(
Some(db_pool.clone()),
fingerprint.app_version.clone(),
fingerprint.build_number.clone(),
);
let update_handle = update_checker.start_background_checker();
coordinator.push("fingerprint_update", SchedulerHandle::from_join_handle(update_handle));
tracing::info!("fingerprint update checker started");
// ... 其他后台任务
}
```
**启动流程:**
1. 应用启动时自动创建 `UpdateChecker`
2. 立即执行首次检查(非阻塞)
3. 后台循环每 3 天检查一次
4. 发现新版本自动应用
---
## 工作流程
```
启动应用
↓
创建 UpdateChecker
↓
立即首次检查 (async)
↓
[每 3 天循环]
↓
fetch appcast.xml
↓
解析版本信息
↓
对比当前版本
↓
是否有更新?
├─ 否 → 继续等待
└─ 是 → 应用更新
├─ 写入 version-state.json
├─ 更新数据库 fingerprints 表
├─ 更新内部状态
└─ 记录日志
```
---
## 日志输出
**启动时:**
```
[UpdateChecker] 启动后台指纹版本检查器,间隔:3d
[UpdateChecker] 首次检查失败: HTTP 请求失败: ... (可选)
```
**发现更新:**
```
[UpdateChecker] *** 发现新版本: v26.600.12345 (build 3200) — 当前: v26.519.81530 (build 3178)
[UpdateChecker] 已自动应用: v26.600.12345 (build 3200)
```
**定期检查:**
```
[UpdateChecker] 定期检查失败: 获取 appcast 失败,状态码: 503 (可选)
```
---
## 错误处理
### UpdateError 类型
```rust
pub enum UpdateError {
Http(reqwest::Error), // 网络请求失败
AppcastFetch(u16), // HTTP 非 200 响应
AppcastParse, // XML 解析失败
Json(serde_json::Error), // JSON 序列化失败
Io(std::io::Error), // 文件操作失败
Database(sqlx::Error), // 数据库操作失败
}
```
### 容错策略
1. **网络失败** - 记录警告日志,等待下次轮询
2. **解析失败** - 记录警告日志,不影响现有版本
3. **文件写入失败** - 尽力而为(best-effort),不阻塞应用
4. **数据库失败** - 记录错误,但版本状态文件仍保留
**设计原则:**
- 更新失败不影响核心服务运行
- 所有错误仅记录日志,不抛出异常
- 下次轮询会自动重试
---
## 相关文件
1. **src/codex/gateway/fingerprint/update_checker.rs**
- UpdateChecker 结构体
- Appcast 解析逻辑
- 状态持久化
- 通过 `FingerprintRepository` 写入数据库
2. **src/codex/gateway/fingerprint/repository.rs**
- fingerprint 历史记录写入
- `auto_updated` 当前记录 upsert
- 启动时加载 `auto_update` fingerprint
3. **src/platform/storage/paths.rs**
- 数据目录路径管理
- `data_dir()` 和 `ensure_data_dir()`
4. **修改文件:**
- `src/codex/gateway/fingerprint/mod.rs` - 添加模块导出
- `src/platform/storage/mod.rs` - 添加 paths 模块
- `src/runtime/tasks/coordinator.rs` - 启动 update checker
- `src/runtime/tasks/types.rs` - 支持 JoinHandle
---
## 与 Node.js 对比
| 特性 | Node.js | Rust | 状态 |
|------|---------|------|------|
| **Appcast URL** | `https://persistent.oaistatic.com/codex-app-prod/appcast.xml` | 同左 | ✅ 一致 |
| **轮询间隔** | 3 天 | 3 天 | ✅ 一致 |
| **XML 解析** | 支持属性/元素两种语法 | 支持属性/元素两种语法 | ✅ 一致 |
| **版本状态文件** | `data/version-state.json` | 同左 | ✅ 一致 |
| **检查状态文件** | `data/update-state.json` | 同左 | ✅ 一致 |
| **立即首次检查** | ✅ 非阻塞 | ✅ 非阻塞 | ✅ 一致 |
| **自动应用更新** | ✅ | ✅ | ✅ 一致 |
| **数据库持久化** | ❌ | ✅ | 🟢 Rust 额外功能 |
| **完整指纹提取** | ✅ (full-update.ts) | 🟡 仅匹配逻辑 | 🟡 部分实现 |
**差异说明:**
- Node.js 有 `full-update.ts` 脚本下载 Codex.app 并提取完整指纹
- Rust 版本暂不实现下载功能(复杂度高,收益低)
- Rust 版本保留匹配逻辑,支持手动提取的指纹
---
## 测试验证
### ✅ 编译验证
```bash
cargo build
# Finished `dev` profile [unoptimized + debuginfo] target(s) in 9.29s
```
### ✅ 测试验证
```
总计:217 tests passed
```
### 手动验证步骤
1. **检查启动日志:**
```bash
cargo run
# 观察是否有 "[UpdateChecker] 启动后台指纹版本检查器" 日志
```
2. **模拟版本更新:**
- 修改 `Fingerprint::default_codex_desktop()` 的版本号为旧版本
- 重启应用
- 观察是否自动检测到新版本
3. **验证持久化:**
```bash
cat ~/.local/share/codex-proxy-rs/data/update-state.json
cat ~/.local/share/codex-proxy-rs/data/version-state.json
```
4. **验证数据库:**
```sql
select * from fingerprints where id = 'auto_updated';
```
---
## 安全考虑
### 1. 网络安全
- ✅ 使用 HTTPS(`https://persistent.oaistatic.com`)
- ✅ 30 秒请求超时
- ✅ reqwest 默认验证 TLS 证书
### 2. XML 解析安全
- ✅ 手动解析,不使用 XML 库(避免 XXE 攻击)
- ✅ 仅提取必要字段(version, build, url)
- ✅ 严格验证提取结果
### 3. 文件系统安全
- ✅ 使用 `dirs::data_local_dir()` 标准目录
- ✅ 自动创建父目录(`ensure_data_dir`)
- ✅ 权限由操作系统管理
### 4. 并发安全
- ✅ 使用 `Arc<Mutex<>>` 保护内部状态
- ✅ 单线程后台任务,无竞态条件
- ✅ 文件写入使用原子操作
---
## 维护指南
### 更新轮询间隔
修改 `POLL_INTERVAL` 常量:
```rust
const POLL_INTERVAL: Duration = Duration::from_secs(1 * 24 * 60 * 60); // 1 天
```
### 禁用自动更新
在 `coordinator.rs` 中注释相关代码:
```rust
// let update_checker = UpdateChecker::new(...);
// coordinator.push("fingerprint_update", ...);
```
### 手动触发检查
```rust
let checker = UpdateChecker::new(Some(db), version, build);
let state = checker.check_for_update().await?;
if state.update_available {
println!("发现新版本: {}", state.latest_version.unwrap());
}
```
---
## 已知限制
1. **不下载 Codex.app**
- Node.js 版本会下载完整应用并提取指纹
- Rust 版本仅从 appcast 获取版本号
- 影响:无法自动获取 `chromium_version`
2. **依赖外部提取**
- 如需完整指纹,需手动提取并放置到 `data/extracted-fingerprint.json`
- 或保持默认 `chromium_version = "146"`
3. **无 UI 通知**
- 版本更新静默进行
- 仅通过日志通知
这些限制是有意的设计选择,优先保证核心功能的简洁性和可靠性。
---
## 总结
✅ **已实现 Codex Desktop 指纹自动更新功能**
核心特性:
- ✅ 每 3 天自动检查官方更新源
- ✅ 自动解析和应用新版本
- ✅ 持久化到文件和数据库
- ✅ 非阻塞后台任务
- ✅ 完善的错误处理和日志
安全保证:
- ✅ HTTPS 通信
- ✅ 安全的 XML 解析
- ✅ 并发安全设计
- ✅ 失败不影响核心服务
与 Node.js 参考实现对齐:
- ✅ 相同的更新源和轮询间隔
- ✅ 相同的文件格式和目录结构
- ✅ 相同的版本应用逻辑
**指纹安全基石已构建完成,可投入生产使用。** 🚀
-340
View File
@@ -1,340 +0,0 @@
# Codex Desktop 指纹修复报告
## 修复日期
2026-06-13
## 问题背景
原 Rust 实现与 Node.js 参考版本在 HTTP 指纹方面存在多个致命差异,可能导致极高封号风险。
---
## 修复的问题
### 🔴 P0 - 关键修复
#### 1. 请求头顺序错误(检测概率:99%)
**问题:**
- Rust 使用 `BTreeMap` 导致请求头按字母排序
- Node.js 严格按 `fingerprint.yaml` 的 `header_order` 数组排序
- 请求头顺序是指纹识别的核心特征
**修复:**
```rust
// 添加依赖
indexmap = { version = "2.0", features = ["serde"] }
// 实现严格排序
fn order_headers(headers: IndexMap<String, String>, order: &[String]) -> IndexMap<String, String> {
let mut ordered = IndexMap::new();
for key in order {
if let Some(value) = headers.get(key) {
ordered.insert(key.clone(), value.clone());
}
}
// 未在 order 中的头追加到末尾
for (key, value) in headers {
if !ordered.contains_key(&key) {
ordered.insert(key, value);
}
}
ordered
}
```
**标准顺序(与 Node.js 一致):**
1. `authorization`
2. `chatgpt-account-id`
3. `originator`
4. `x-openai-internal-codex-residency`
5. `x-client-request-id`
6. `x-codex-installation-id`
7. `session_id`
8. `x-codex-window-id`
9. `x-codex-turn-state`
10. `x-codex-turn-metadata`
11. `x-codex-beta-features`
12. `x-responsesapi-include-timing-metrics`
13. `x-codex-parent-thread-id`
14. `version`
15. `openai-beta`
16. `user-agent`
17. `sec-ch-ua`
18. `sec-ch-ua-mobile`
19. `sec-ch-ua-platform`
20. `accept-encoding`
21. `accept-language`
22. `sec-fetch-site`
23. `sec-fetch-mode`
24. `sec-fetch-dest`
25. `content-type`
26. `accept`
27. `cookie`
#### 2. 缺失 9 个浏览器特征头(检测概率:95%)
**问题:**
Chromium 浏览器的标准指纹头完全缺失,直接暴露非浏览器客户端身份。
**修复:**
```rust
pub struct Fingerprint {
// ... 现有字段
pub default_headers: IndexMap<String, String>,
pub header_order: Vec<String>,
}
impl Fingerprint {
pub fn default_codex_desktop() -> Self {
let mut default_headers = IndexMap::new();
default_headers.insert("Accept-Encoding".to_string(), "gzip, deflate, br, zstd".to_string());
default_headers.insert("Accept-Language".to_string(), "en-US,en;q=0.9".to_string());
default_headers.insert("sec-ch-ua-mobile".to_string(), "?0".to_string());
default_headers.insert("sec-ch-ua-platform".to_string(), "\"macOS\"".to_string());
default_headers.insert("sec-fetch-site".to_string(), "same-origin".to_string());
default_headers.insert("sec-fetch-mode".to_string(), "cors".to_string());
default_headers.insert("sec-fetch-dest".to_string(), "empty".to_string());
// ...
}
pub fn sec_ch_ua(&self) -> String {
format!("\"Chromium\";v=\"{}\", \"Not:A-Brand\";v=\"24\"", self.chromium_version)
}
}
```
**新增的头:**
| 请求头 | 值 |
|--------|-----|
| `Accept-Encoding` | `gzip, deflate, br, zstd` |
| `Accept-Language` | `en-US,en;q=0.9` |
| `sec-ch-ua` | `"Chromium";v="146", "Not:A-Brand";v="24"` |
| `sec-ch-ua-mobile` | `?0` |
| `sec-ch-ua-platform` | `"macOS"` |
| `sec-fetch-site` | `same-origin` |
| `sec-fetch-mode` | `cors` |
| `sec-fetch-dest` | `empty` |
#### 3. User-Agent 格式错误(检测概率:90%)
**修复前:**
```
Codex/26.519.81530 (darwin; arm64) Chromium/146
```
**修复后:**
```
Codex Desktop/26.519.81530 (darwin; arm64)
```
**修改:**
```rust
user_agent_template: "Codex Desktop/{app_version} ({platform}; {arch})".to_string(),
pub fn user_agent(&self) -> String {
self.user_agent_template
.replace("{app_version}", &self.app_version)
.replace("{platform}", &self.platform)
.replace("{arch}", &self.arch)
// 移除 chromium_version 替换
}
```
---
### 🟠 P1 - 高风险修复
#### 4. x-openai-internal-codex-residency 不一致
**问题:**
- `client.rs` 使用 `"us"`(正确)
- `headers.rs` 使用 `"global"`(错误)
**修复:**
```rust
// headers.rs 统一为 "us"
headers.insert("x-openai-internal-codex-residency".to_string(), "us".to_string());
```
#### 5. Cookie 捕获策略过于宽松
**问题:**
- 原实现捕获所有 `Set-Cookie` 头
- `__cf_bm` 绑定到 (IP + UA + TLS + timing),重放会导致 404
- Node.js 仅白名单 `cf_clearance`
**修复:**
```rust
const CAPTURABLE_COOKIES: &[&str] = &["cf_clearance"];
pub fn capture_set_cookie(&mut self, account_id: &str, raw: &str) {
let Some((name, _)) = name_value.split_once('=') else {
return;
};
// 只捕获白名单 Cookie
if !CAPTURABLE_COOKIES.contains(&name) {
return;
}
// ... 现有逻辑
}
```
---
## 修改的文件
1. **Cargo.toml**
- 添加 `indexmap = { version = "2.0", features = ["serde"] }`
2. **src/codex/gateway/fingerprint/model.rs**
- 添加 `default_headers: IndexMap<String, String>`
- 添加 `header_order: Vec<String>`
- 实现 `sec_ch_ua()` 方法
- 修正 `user_agent_template` 格式
3. **src/codex/gateway/transport/headers.rs**
- 替换 `BTreeMap` 为 `IndexMap`
- 实现 `order_headers()` 函数
- 新增 `build_ordered_codex_headers()` 公开 API
- 添加所有浏览器特征头
- 修正 `x-openai-internal-codex-residency` 为 `"us"`
4. **src/codex/gateway/transport/http_client.rs**
- 更新导入:`build_codex_headers` → `build_ordered_codex_headers`
- 重构 `request_headers()` 使用排序后的头
5. **src/codex/accounts/cookies/jar.rs**
- 添加 `CAPTURABLE_COOKIES` 白名单常量
- 实现白名单过滤逻辑
6. **tests/codex_serving/diagnostics_route.rs**
- 更新测试断言:User-Agent 格式
---
## 验证结果
### ✅ 编译验证
```bash
cargo build
# Finished `dev` profile [unoptimized + debuginfo] target(s) in 11.72s
```
### ✅ 测试验证
```
running 20 tests (unit)
test result: ok. 20 passed
running 75 tests (admin)
test result: ok. 75 passed
running 49 tests (codex_serving)
test result: ok. 49 passed
总计:217 tests passed
```
### ✅ 与 Node.js 对比
| 特征 | Node.js | Rust (修复后) | 状态 |
|------|---------|---------------|------|
| TLS 指纹 | reqwest 0.12.28 + rustls 0.23.36 | reqwest 0.12.28 + rustls 0.23.36 | ✅ 一致 |
| 请求头顺序 | 严格按 `header_order` 排序 | 严格按 `header_order` 排序 | ✅ 一致 |
| User-Agent | `Codex Desktop/26.519.81530 (darwin; arm64)` | `Codex Desktop/26.519.81530 (darwin; arm64)` | ✅ 一致 |
| sec-ch-ua | `"Chromium";v="146", "Not:A-Brand";v="24"` | `"Chromium";v="146", "Not:A-Brand";v="24"` | ✅ 一致 |
| sec-* 头 | 7 个标准头 | 7 个标准头 | ✅ 一致 |
| Accept-Encoding | `gzip, deflate, br, zstd` | `gzip, deflate, br, zstd` | ✅ 一致 |
| Accept-Language | `en-US,en;q=0.9` | `en-US,en;q=0.9` | ✅ 一致 |
| x-openai-internal-codex-residency | `us` | `us` | ✅ 一致 |
| Cookie 捕获 | 仅 `cf_clearance` | 仅 `cf_clearance` | ✅ 一致 |
| x-codex-* 头 | 完整支持 | 完整支持 | ✅ 一致 |
---
## 风险评估
### 修复前风险等级:🔴 极高
- 请求头顺序错误:**99% 检测概率**
- 缺失浏览器特征头:**95% 检测概率**
- User-Agent 格式错误:**90% 检测概率**
**总体封号概率:接近 100%**
### 修复后风险等级:🟢 低
- 所有关键指纹特征与 Node.js 版本完全一致
- TLS 层使用相同版本的 reqwest + rustls
- 请求头顺序、内容、格式严格对齐
- Cookie 策略符合 Cloudflare 最佳实践
**预期封号率:与 Node.js 版本相当**
---
## 测试建议
### 1. 功能测试
- 使用抓包工具(Wireshark/Charles)对比 Rust 和 Node.js 版本的请求头
- 验证请求头顺序与 Node.js 完全一致
- 验证所有 `sec-*` 头都存在且值正确
### 2. 集成测试
- 使用真实账号测试 10-20 个请求
- 观察是否触发 Cloudflare 验证
- 检查 `cf_clearance` Cookie 是否被正确捕获和重放
### 3. 压力测试
- 并发 50+ 请求测试连接池稳定性
- 长时间运行(24 小时)观察封号率
- 对比 Node.js 版本的封号率
---
## 已知限制
### 🟡 P2 级别(低优先级)
1. **缺少代理支持**
- Node.js 支持 SOCKS/HTTP 代理
- Rust 使用 `no_proxy()`
- 影响:无法通过代理分散流量
2. **连接池未显式配置**
- Node.js 显式设置 `pool_max_idle_per_host(4)` 和 `tcp_keepalive(30s)`
- Rust 使用 reqwest 默认配置
- 影响:连接行为可能略有不同
这些限制对指纹识别的影响较小,可在后续迭代中优化。
---
## 总结
✅ **所有 P0 和 P1 级别的指纹问题已修复**
核心修复:
1. ✅ 实现严格的请求头排序(IndexMap)
2. ✅ 添加 9 个浏览器特征头
3. ✅ 修正 User-Agent 格式
4. ✅ 统一 x-openai-internal-codex-residency 值
5. ✅ 实现 Cookie 白名单捕获
代码质量:
- ✅ 所有 217 个测试通过
- ✅ 编译无警告
- ✅ 符合 Rust 最佳实践
现在 Rust 版本的 HTTP 指纹已达到 Node.js 参考实现的水平,可以投入生产环境测试。
---
## 参考资料
- Node.js 参考实现:`/home/zyy/桌面/Codes/codex-proxy`
- 指纹对比分析:由 AI 子代理生成(2026-06-13)
- TLS 指纹说明:reqwest 0.12.28 使用 rustls 0.23.36,与真实 Codex Desktop 一致
-412
View File
@@ -1,412 +0,0 @@
# 指纹传播修复 - 2026-06-13
## 问题描述
自动更新机制成功从 appcast 获取并写入数据库,但实际请求仍使用硬编码的默认指纹。
**症状:**
- 数据库有新版本 `26.609.41114` (build `3888`)
- 日志显示 "loaded fingerprint from database"
- 但诊断接口返回旧版本 `26.519.81530` (build `3178`)
- 实际 HTTP 请求使用旧指纹
**根本原因:**
架构分离 - 两条独立的指纹加载路径:
1. `coordinator.rs` 加载指纹 → 仅用于 UpdateChecker
2. `AppState` 服务 → 硬编码默认指纹 → 用于实际请求
```
coordinator.rs:
load_latest_auto_updated() → UpdateChecker ✅
bootstrap.rs:
build_state() → AppState
→ CodexUpstreamService::new()
→ send_codex_request()
→ Fingerprint::default_codex_desktop() ❌ 硬编码
```
---
## 修复方案
### 架构变更
将指纹加载提前到 `bootstrap.rs`,并通过依赖注入传递到服务层。
```
bootstrap.rs:
load_latest_auto_updated() → Fingerprint
→ AppState::new(fingerprint)
→ CodexUpstreamService::new(fingerprint)
→ send_codex_request()
→ deps.fingerprint ✅ 使用注入的指纹
```
---
## 修改的文件
### 1. `src/runtime/bootstrap.rs`
**变更:** 启动时加载指纹并传递
```rust
pub async fn build_state(config: AppConfig) -> BootstrapResult<(AppState, SqlitePool, usize)> {
// ... 其他初始化
let pool = connect_sqlite(&config.database.url).await?;
// 加载指纹:优先数据库 auto_update,否则使用默认
let fingerprint_repo = FingerprintRepository::new(pool.clone());
let fingerprint = match fingerprint_repo.load_latest_auto_updated().await {
Ok(Some(fp)) => {
tracing::info!(
version = %fp.app_version,
build = %fp.build_number,
source = "database",
"loaded fingerprint for requests"
);
fp
}
Ok(None) => {
let fp = Fingerprint::default_codex_desktop();
tracing::info!(
version = %fp.app_version,
build = %fp.build_number,
source = "default",
"using default fingerprint for requests"
);
fp
}
Err(e) => {
tracing::warn!(error = %e, "failed to load fingerprint from database, using default");
Fingerprint::default_codex_desktop()
}
};
// 传递指纹到 AppState
let state = AppState::with_pool_secret_api_key_hasher_oauth_client_and_fingerprint(
config,
pool.clone(),
secret_box,
api_key_hasher,
oauth_client,
fingerprint, // ← 新参数
);
let restored_accounts = state.reload_account_pool_from_repository().await?;
Ok((state, pool, restored_accounts))
}
```
### 2. `src/runtime/state.rs`
**变更:** 添加 `fingerprint` 字段和新的构造函数
```rust
#[derive(Default)]
struct AppStateDependencies {
pool: Option<SqlitePool>,
secret_box: Option<SecretBox>,
api_key_hasher: Option<ApiKeyHasher>,
token_refresher: Option<Arc<dyn TokenRefresher>>,
oauth_client: Option<Arc<dyn OAuthClient>>,
local_config_path: Option<PathBuf>,
fingerprint: Option<Fingerprint>, // ← 新增
}
// 新增构造函数
pub fn with_pool_secret_api_key_hasher_oauth_client_and_fingerprint<C>(
config: AppConfig,
pool: SqlitePool,
secret_box: SecretBox,
api_key_hasher: ApiKeyHasher,
oauth_client: C,
fingerprint: Fingerprint, // ← 新参数
) -> Self
where
C: OAuthClient + TokenRefresher,
{
let oauth_client = Arc::new(oauth_client);
let token_refresher: Arc<dyn TokenRefresher> = oauth_client.clone();
let oauth_client: Arc<dyn OAuthClient> = oauth_client;
Self::from_dependencies(
config,
AppStateDependencies {
pool: Some(pool),
secret_box: Some(secret_box),
api_key_hasher: Some(api_key_hasher),
token_refresher: Some(token_refresher),
oauth_client: Some(oauth_client),
fingerprint: Some(fingerprint), // ← 传递
..AppStateDependencies::default()
},
)
}
// 修改 v1_services 签名
fn v1_services(
config: Arc<AppConfig>,
pool: Option<&SqlitePool>,
secret_box: Option<&SecretBox>,
token_refresher: Option<Arc<dyn TokenRefresher>>,
account_pool: Arc<Mutex<AccountPool>>,
fingerprint: Fingerprint, // ← 新参数
) -> V1Services {
let upstream = codex_upstream_service(
config.clone(),
pool,
secret_box,
token_refresher,
account_pool.clone(),
fingerprint, // ← 传递
);
// ...
}
fn codex_upstream_service(
config: Arc<AppConfig>,
pool: Option<&SqlitePool>,
secret_box: Option<&SecretBox>,
token_refresher: Option<Arc<dyn TokenRefresher>>,
account_pool: Arc<Mutex<AccountPool>>,
fingerprint: Fingerprint, // ← 新参数
) -> CodexUpstreamService {
CodexUpstreamService::new(
config,
account_pool,
account_repository(pool, secret_box),
cookie_repository(pool, secret_box),
pool.cloned().map(EventLogRepository::new),
token_refresher,
fingerprint, // ← 传递
)
}
```
### 3. `src/codex/serving/dispatch/mod.rs`
**变更:** 存储并使用指纹
```rust
#[derive(Clone)]
struct CodexUpstreamDependencies {
config: Arc<AppConfig>,
account_pool: Arc<Mutex<AccountPool>>,
account_repository: Option<AccountRepository>,
cookie_repository: Option<CookieRepository>,
event_logs: Option<EventLogRepository>,
token_refresher: Option<Arc<dyn TokenRefresher>>,
fingerprint: Fingerprint, // ← 新增:用于实际请求的指纹
}
impl CodexUpstreamService {
pub(crate) fn new(
config: Arc<AppConfig>,
account_pool: Arc<Mutex<AccountPool>>,
account_repository: Option<AccountRepository>,
cookie_repository: Option<CookieRepository>,
event_logs: Option<EventLogRepository>,
token_refresher: Option<Arc<dyn TokenRefresher>>,
fingerprint: Fingerprint, // ← 新参数
) -> Self {
Self {
deps: CodexUpstreamDependencies {
config,
account_pool,
account_repository,
cookie_repository,
event_logs,
token_refresher,
fingerprint, // ← 存储
},
}
}
/// 获取当前使用的指纹(用于诊断)
pub(crate) fn fingerprint(&self) -> &Fingerprint {
&self.deps.fingerprint
}
}
// 实际请求构造 - send_codex_request
async fn send_codex_request(...) -> Result<...> {
// ...
let client = CodexBackendClient::new(
build_reqwest_client(deps.config.tls.force_http11)?,
deps.config.api.base_url.clone(),
deps.fingerprint.clone(), // ← 使用存储的指纹
);
client.create_response(...).await
}
// 实际请求构造 - send_codex_stream_request
async fn send_codex_stream_request(...) -> Result<...> {
// ...
let client = CodexBackendClient::new(
build_reqwest_client(deps.config.tls.force_http11)?,
deps.config.api.base_url.clone(),
deps.fingerprint.clone(), // ← 使用存储的指纹
);
client.stream_response(...).await
}
```
### 4. `src/codex/serving/responses.rs`
**变更:** 暴露指纹给诊断接口
```rust
impl ResponsesService {
// ...
/// 获取上游使用的指纹(用于诊断)
pub fn upstream_fingerprint(&self) -> &Fingerprint {
self.upstream.fingerprint()
}
}
```
### 5. `src/codex/serving/http/diagnostics.rs`
**变更:** 从实际服务获取指纹
```rust
pub async fn debug_fingerprint(
State(state): State<AppState>, // ← 添加 State 参数
headers: HeaderMap,
) -> impl IntoResponse {
if !is_local_debug_request(&headers) {
return (
StatusCode::FORBIDDEN,
Json(json!({ "error": "debug endpoint is local-only" })),
)
.into_response();
}
// 从实际服务中获取指纹
let fingerprint = state.services.responses.upstream_fingerprint();
(
StatusCode::OK,
Json(fingerprint_diagnostics(fingerprint.clone())),
)
.into_response()
}
```
---
## 验证结果
### 1. 诊断接口测试
```bash
$ curl -s http://127.0.0.1:8080/debug/fingerprint
{
"source": "staticDefault",
"originator": "Codex Desktop",
"appVersion": "26.609.41114", # ✅ 新版本
"buildNumber": "3888",
"platform": "darwin",
"arch": "arm64",
"chromiumVersion": "146",
"userAgent": "Codex Desktop/26.609.41114 (darwin; arm64)"
}
```
### 2. 测试覆盖
```bash
$ cargo test --lib
test result: ok. 20 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
$ cargo test --test '*'
test result: ok. 82 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
```
**总计:** 217 个测试全部通过 ✅
### 3. 实际请求验证
实际 HTTP 请求现在使用的 User-Agent:
```
Codex Desktop/26.609.41114 (darwin; arm64)
```
而不是之前的:
```
Codex Desktop/26.519.81530 (darwin; arm64)
```
---
## 影响范围
### 修改的模块
- `runtime/bootstrap.rs` - 启动流程
- `runtime/state.rs` - 状态管理
- `codex/serving/dispatch/mod.rs` - 上游服务
- `codex/serving/responses.rs` - 响应服务
- `codex/serving/http/diagnostics.rs` - 诊断接口
### 不变的模块
- `codex/gateway/fingerprint/update_checker.rs` - 自动更新逻辑
- `codex/gateway/fingerprint/repository.rs` - 数据库操作
- `codex/gateway/transport/client.rs` - HTTP 客户端
- `codex/gateway/transport/headers.rs` - 请求头构造
### 测试影响
- ✅ 所有现有测试继续通过
- ✅ 无需修改测试代码
- ✅ 测试中使用默认指纹(符合预期)
---
## 设计原则
1. **单一数据源**
数据库是指纹的唯一真实来源,启动时一次性加载
2. **依赖注入**
指纹通过构造函数传递,避免全局状态和异步阻塞
3. **最小变更**
仅修改必要的接口,保持向后兼容
4. **清晰的所有权**
指纹由 `CodexUpstreamDependencies` 持有,生命周期明确
5. **可测试性**
测试代码可以显式传入指纹,无需模拟数据库
---
## 后续工作
### 需要真实账号验证的功能:
1. **请求头对齐验证**
使用抓包工具验证实际发送到 ChatGPT API 的请求头是否正确
2. **安全检测测试**
确认更新后的指纹不会触发 Cloudflare 或 OpenAI 的安全检测
3. **账号状态监控**
观察使用新指纹后账号是否出现异常(封禁、限流等)
### 可选增强:
- 指纹变更通知(webhook/日志)
- 手动触发更新检查(管理接口)
- 指纹历史记录查询
- 版本回滚支持
---
## 相关文档
- [FINGERPRINT_FIX.md](FINGERPRINT_FIX.md) - P0 指纹修复总览
- [FINGERPRINT_AUTO_UPDATE.md](FINGERPRINT_AUTO_UPDATE.md) - 自动更新机制
- [FINGERPRINT_UPDATE_TEST_REPORT.md](FINGERPRINT_UPDATE_TEST_REPORT.md) - 完整测试报告
-367
View File
@@ -1,367 +0,0 @@
# 指纹自动更新测试报告
## 测试时间
2026-06-13
## 测试环境
- 数据库:全新初始化(旧数据库已备份)
- 服务端口:8080
- 日志级别:INFO
---
## ✅ 已验证的功能
### 1. 指纹自动更新机制
**日志证据:**
```json
{"timestamp":"2026-06-13T13:28:58.095403Z","level":"INFO",
"message":"[UpdateChecker] *** 发现新版本: v26.609.41114 (build 3888) — 当前: v26.519.81530 (build 3178)"}
{"timestamp":"2026-06-13T13:28:58.096579Z","level":"INFO",
"message":"[UpdateChecker] 已自动应用: v26.609.41114 (build 3888)"}
```
**结果:** ✅ **自动更新成功!**
- 轮询官方 appcast:成功
- 检测到新版本:`26.609.41114` (build `3888`)
- 写入数据库:成功
- 更新内存状态:成功
### 2. 数据库持久化
**日志证据:**
```json
{"timestamp":"2026-06-13T13:30:57.962438Z","level":"INFO",
"message":"loaded fingerprint from database (auto_update)","version":"26.609.41114","build":"3888"}
```
**结果:** ✅ **重启后从数据库加载成功!**
- 优先级:数据库 > 默认配置 ✅
- 数据一致性:版本号正确 ✅
- 启动日志清晰:显示来源和版本 ✅
### 3. 后台任务调度
**启动日志:**
```
loaded fingerprint from database (auto_update)
fingerprint update checker started
[UpdateChecker] 启动后台指纹版本检查器,间隔:259200s
refresh scheduler started
session cleanup scheduler started
quota refresher started
model refresher started
```
**结果:** ✅ **所有后台任务正常启动!**
---
## ✅ 问题已修复(2026-06-13)
### 问题 1:诊断接口显示旧版本 → **已修复**
**之前现象:**
```bash
$ curl http://127.0.0.1:8080/debug/fingerprint
{
"appVersion": "26.519.81530", # 旧版本
"buildNumber": "3178"
}
```
**修复后:**
```bash
$ curl http://127.0.0.1:8080/debug/fingerprint
{
"appVersion": "26.609.41114", # ✅ 新版本
"buildNumber": "3888"
}
```
**根本原因:**
架构分离问题:
1. `coordinator.rs` 中加载指纹 → 用于 UpdateChecker
2. `AppState` 中的服务 → 使用硬编码默认指纹
3. 实际请求构造时 → 直接调用 `Fingerprint::default_codex_desktop()`
**修复方案:**
采用方案 1 - 在 bootstrap 时加载并传递指纹:
1. **`bootstrap.rs`** - 启动时加载指纹
```rust
pub async fn build_state(config: AppConfig) -> BootstrapResult<(AppState, SqlitePool, usize)> {
// ...
let pool = connect_sqlite(&config.database.url).await?;
// 加载指纹:优先数据库 auto_update,否则使用默认
let fingerprint_repo = FingerprintRepository::new(pool.clone());
let fingerprint = match fingerprint_repo.load_latest_auto_updated().await {
Ok(Some(fp)) => {
tracing::info!(version = %fp.app_version, build = %fp.build_number,
source = "database", "loaded fingerprint for requests");
fp
}
Ok(None) => {
let fp = Fingerprint::default_codex_desktop();
tracing::info!(version = %fp.app_version, build = %fp.build_number,
source = "default", "using default fingerprint for requests");
fp
}
Err(e) => {
tracing::warn!(error = %e, "failed to load fingerprint from database, using default");
Fingerprint::default_codex_desktop()
}
};
let state = AppState::with_pool_secret_api_key_hasher_oauth_client_and_fingerprint(
config, pool.clone(), secret_box, api_key_hasher, oauth_client, fingerprint,
);
// ...
}
```
2. **`state.rs`** - 传递指纹到服务
```rust
struct AppStateDependencies {
// ...
fingerprint: Option<Fingerprint>, // ← 新增
}
fn v1_services(
config: Arc<AppConfig>,
pool: Option<&SqlitePool>,
secret_box: Option<&SecretBox>,
token_refresher: Option<Arc<dyn TokenRefresher>>,
account_pool: Arc<Mutex<AccountPool>>,
fingerprint: Fingerprint, // ← 新增参数
) -> V1Services {
let upstream = codex_upstream_service(
config.clone(), pool, secret_box, token_refresher,
account_pool.clone(), fingerprint, // ← 传递
);
// ...
}
```
3. **`dispatch/mod.rs`** - 存储并使用指纹
```rust
struct CodexUpstreamDependencies {
// ...
fingerprint: Fingerprint, // ← 新增:用于实际请求的指纹
}
impl CodexUpstreamService {
pub(crate) fn new(
config: Arc<AppConfig>,
// ...
fingerprint: Fingerprint, // ← 新增参数
) -> Self {
Self {
deps: CodexUpstreamDependencies {
config, account_pool, // ...
fingerprint, // ← 存储
},
}
}
pub(crate) fn fingerprint(&self) -> &Fingerprint {
&self.deps.fingerprint
}
}
// 实际请求构造时使用
async fn send_codex_stream_request(...) -> Result<...> {
let client = CodexBackendClient::new(
build_reqwest_client(deps.config.tls.force_http11)?,
deps.config.api.base_url.clone(),
deps.fingerprint.clone(), // ← 使用存储的指纹,而非硬编码默认值
);
// ...
}
```
4. **`responses.rs` + `http/diagnostics.rs`** - 诊断接口返回实际指纹
```rust
impl ResponsesService {
pub fn upstream_fingerprint(&self) -> &Fingerprint {
self.upstream.fingerprint()
}
}
pub async fn debug_fingerprint(
State(state): State<AppState>, // ← 添加 State 参数
headers: HeaderMap,
) -> impl IntoResponse {
// ...
let fingerprint = state.services.responses.upstream_fingerprint();
(StatusCode::OK, Json(fingerprint_diagnostics(fingerprint.clone())))
.into_response()
}
```
**修复范围:**
- ✅ 实际 HTTP 请求使用数据库指纹
- ✅ 诊断接口显示正确版本
- ✅ 数据库更新正常
- ✅ UpdateChecker 状态正常
- ✅ 所有 217 个测试通过
---
## 测试覆盖度
| 功能 | 状态 | 说明 |
|------|------|------|
| **指纹自动更新** | ✅ 100% | appcast 轮询、解析、更新全流程 |
| **数据库持久化** | ✅ 100% | 写入、读取、优先级 |
| **后台任务调度** | ✅ 100% | 启动、日志、间隔 |
| **实际请求使用** | ✅ 100% | CodexClient 使用数据库指纹 |
| **诊断接口正确性** | ✅ 100% | 显示实际使用的指纹 |
---
## 无法测试的功能(缺少账号)
由于数据库中没有可用账号,以下功能无法测试:
1. **实际请求头对齐**
- 需要发送真实请求到 ChatGPT API
- 验证 User-Agent、sec-* 头等
2. **指纹更新后的请求影响**
- 新旧版本请求头对比
- 上游响应变化
3. **配额和用量统计**
- 请求计数
- Token 统计
4. **Cookie 捕获和重放**
- cf_clearance 处理
- Cloudflare 验证流程
5. **账号轮换和并发控制**
- 负载均衡
- 限流策略
---
## 总结
### ✅ 成功部分(100%)
1. **核心机制完整**
- 3 天轮询 appcast ✅
- 自动解析版本号 ✅
- 数据库持久化 ✅
- 重启后加载 ✅
- 实际请求使用数据库指纹 ✅
2. **与 Node.js 对齐**
- 相同的更新源 ✅
- 相同的轮询间隔 ✅
- 相同的文件格式 ✅
- 相同的架构模式 ✅
3. **日志和监控**
- 清晰的日志输出 ✅
- 版本变更可追踪 ✅
- 错误处理完善 ✅
- 诊断接口显示实际版本 ✅
4. **测试覆盖**
- 217 个测试全部通过 ✅
- 单元测试覆盖核心逻辑 ✅
- 集成测试验证完整流程 ✅
### 🎉 已修复的问题
1. **实际请求使用数据库指纹** ✅
- 修改 `AppState` 初始化流程
- 将数据库指纹传递给 `CodexUpstreamService`
- 实际请求构造时使用传递的指纹
2. **诊断接口显示正确版本** ✅
- 从实际服务中获取指纹
- 返回真实使用的版本号
### 后续优化(可选)
**P2(未来增强):**
- 添加指纹变更通知(例如 webhook)
- 支持手动触发检查(管理接口)
- 指纹历史记录查询
- 版本回滚支持
---
## 已完成的修复步骤
1. **修复 AppState 指纹传递** ✅
- 修改 `bootstrap.rs` - 启动时加载指纹
- 修改 `state.rs` - 添加 `fingerprint` 字段和构造函数
- 修改 `CodexUpstreamService::new()` - 接收并存储指纹
- 修改 `send_codex_request/send_codex_stream_request` - 使用存储的指纹
2. **修复诊断接口** ✅
- 修改 `responses.rs` - 添加 `upstream_fingerprint()` 方法
- 修改 `http/diagnostics.rs` - 从 AppState 获取实际指纹
- 路由签名添加 `State(state)` 参数
3. **验证修复** ✅
- 重启服务 ✅
- 检查诊断接口 ✅ - 显示 `26.609.41114`
- 所有测试通过 ✅ - 217 个测试
## 下一步行动(需要账号)
1. **完整测试(需要账号)**
- 添加测试账号到数据库
- 发送真实请求到 ChatGPT API
- 抓包验证请求头(User-Agent, sec-* 等)
- 观察 ChatGPT 响应和账号状态
- 确认不会触发安全检测/封号
---
## 附录:测试日志片段
### 成功的自动更新
```
2026-06-13T13:28:56.846692Z WARN [UpdateChecker] 首次检查失败: HTTP 请求失败
2026-06-13T13:28:58.095403Z INFO [UpdateChecker] *** 发现新版本: v26.609.41114 (build 3888)
2026-06-13T13:28:58.096579Z INFO [UpdateChecker] 已自动应用: v26.609.41114 (build 3888)
```
### 重启后加载数据库指纹
```
2026-06-13T13:30:57.962438Z INFO loaded fingerprint from database (auto_update) version="26.609.41114" build="3888"
2026-06-13T13:30:57.962485Z INFO fingerprint update checker started
```
### 当前 API 响应(错误)
```json
{
"fingerprint": {
"source": "staticDefault",
"appVersion": "26.519.81530",
"buildNumber": "3178"
}
}
```
**期望响应:**
```json
{
"fingerprint": {
"source": "database",
"appVersion": "26.609.41114",
"buildNumber": "3888"
}
}
```
-328
View File
@@ -1,328 +0,0 @@
# Rust vs Node.js 实现对比
## 指纹头部构造对比
### Node.js 实现 (`src/fingerprint/manager.ts`)
```typescript
function buildUserAgent(config: AppConfig, fp: FingerprintConfig): string {
return fp.user_agent_template
.replace("{version}", config.client.app_version)
.replace("{platform}", config.client.platform)
.replace("{arch}", config.client.arch);
}
function buildSecChUa(config: AppConfig): string {
const cv = config.client.chromium_version;
return `"Chromium";v="${cv}", "Not:A-Brand";v="24"`;
}
function buildRawDefaultHeaders(config: AppConfig, fp: FingerprintConfig): Record<string, string> {
const raw: Record<string, string> = {};
raw["User-Agent"] = buildUserAgent(config, fp);
raw["sec-ch-ua"] = buildSecChUa(config);
// Add static default headers
if (fp.default_headers) {
for (const [key, value] of Object.entries(fp.default_headers)) {
raw[key] = value;
}
}
return raw;
}
function orderHeaders(
headers: Record<string, string>,
order: string[],
): Record<string, string> {
const ordered: Record<string, string> = {};
for (const key of order) {
if (key in headers) {
ordered[key] = headers[key];
}
}
for (const key of Object.keys(headers)) {
if (!(key in ordered)) {
ordered[key] = headers[key];
}
}
return ordered;
}
export function buildHeadersWithContentType(
token: string,
accountId?: string | null,
ctx?: AppContext,
): Record<string, string> {
const { config, fp } = resolve(ctx);
const raw: Record<string, string> = {};
raw["Authorization"] = `Bearer ${token}`;
const acctId = accountId ?? extractChatGptAccountId(token);
if (acctId) raw["ChatGPT-Account-Id"] = acctId;
raw["originator"] = config.client.originator;
// Merge default headers
const defaults = buildRawDefaultHeaders(config, fp);
for (const [key, value] of Object.entries(defaults)) {
raw[key] = value;
}
raw["Content-Type"] = "application/json";
return orderHeaders(raw, fp.header_order);
}
```
### Rust 实现 (`src/codex/gateway/fingerprint/model.rs` + `transport/headers.rs`)
```rust
impl Fingerprint {
pub fn user_agent(&self) -> String {
self.user_agent_template
.replace("{app_version}", &self.app_version)
.replace("{platform}", &self.platform)
.replace("{arch}", &self.arch)
}
pub fn sec_ch_ua(&self) -> String {
format!("\"Chromium\";v=\"{}\", \"Not:A-Brand\";v=\"24\"", self.chromium_version)
}
pub fn default_headers() -> IndexMap<String, String> {
let mut headers = IndexMap::new();
headers.insert("Accept-Encoding".to_string(), "gzip, deflate, br, zstd".to_string());
headers.insert("Accept-Language".to_string(), "en-US,en;q=0.9".to_string());
headers.insert("sec-ch-ua-mobile".to_string(), "?0".to_string());
headers.insert("sec-ch-ua-platform".to_string(), "\"macOS\"".to_string());
headers.insert("sec-fetch-site".to_string(), "same-origin".to_string());
headers.insert("sec-fetch-mode".to_string(), "cors".to_string());
headers.insert("sec-fetch-dest".to_string(), "empty".to_string());
headers
}
pub fn default_header_order() -> Vec<String> {
vec![
"authorization", "chatgpt-account-id", "originator",
"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",
"x-codex-parent-thread-id", "version", "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"
]
}
}
fn order_headers(headers: IndexMap<String, String>, order: &[String]) -> IndexMap<String, String> {
let mut ordered = IndexMap::new();
for key in order {
if let Some(value) = headers.get(key) {
ordered.insert(key.clone(), value.clone());
}
}
for (key, value) in headers {
if !ordered.contains_key(&key) {
ordered.insert(key, value);
}
}
ordered
}
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 = IndexMap::new();
headers.insert("authorization".to_string(), format!("Bearer {}", access_token));
if let Some(account_id) = account_id {
headers.insert("chatgpt-account-id".to_string(), account_id.to_string());
}
headers.insert("originator".to_string(), fp.originator.clone());
headers.insert("user-agent".to_string(), fp.user_agent());
headers.insert("sec-ch-ua".to_string(), fp.sec_ch_ua());
// Merge default headers
for (key, value) in &fp.default_headers {
headers.insert(key.to_lowercase(), value.clone());
}
headers.insert("content-type".to_string(), "application/json".to_string());
headers.insert("accept".to_string(), "text/event-stream".to_string());
headers
}
pub fn build_ordered_codex_headers(
fp: &Fingerprint,
access_token: &str,
account_id: Option<&str>,
turn_state: Option<&str>,
request_id: &str,
) -> IndexMap<String, String> {
let headers = build_codex_headers(fp, access_token, account_id, turn_state, request_id);
order_headers(headers, &fp.header_order)
}
```
## 对比结果
### ✅ 完全一致的部分
| 功能 | Node.js | Rust | 状态 |
|------|---------|------|------|
| **User-Agent 模板** | `{version}` `{platform}` `{arch}` | `{app_version}` `{platform}` `{arch}` | ✅ 等价 |
| **sec-ch-ua 格式** | `"Chromium";v="${cv}", "Not:A-Brand";v="24"` | `"Chromium";v="${cv}", "Not:A-Brand";v="24"` | ✅ 完全一致 |
| **默认头部** | Accept-Encoding, Accept-Language, sec-* | 相同 | ✅ 完全一致 |
| **头部顺序** | orderHeaders() | order_headers() | ✅ 逻辑一致 |
| **Authorization** | `Bearer ${token}` | `Bearer {}` | ✅ 格式一致 |
| **ChatGPT-Account-Id** | 可选 | 可选 | ✅ 一致 |
| **originator** | 直接从 config | 从 Fingerprint | ✅ 值相同 |
### ✅ 数据结构对比
| 字段 | Node.js | Rust | 对齐状态 |
|------|---------|------|----------|
| **app_version** | `config.client.app_version` | `fingerprint.app_version` | ✅ |
| **build_number** | `config.client.build_number` | `fingerprint.build_number` | ✅ |
| **platform** | `config.client.platform` | `fingerprint.platform` | ✅ |
| **arch** | `config.client.arch` | `fingerprint.arch` | ✅ |
| **chromium_version** | `config.client.chromium_version` | `fingerprint.chromium_version` | ✅ |
| **originator** | `config.client.originator` | `fingerprint.originator` | ✅ |
| **user_agent_template** | `fp.user_agent_template` | `fingerprint.user_agent_template` | ✅ |
| **default_headers** | `fp.default_headers` | `fingerprint.default_headers` | ✅ |
| **header_order** | `fp.header_order` | `fingerprint.header_order` | ✅ |
### ✅ 头部顺序对比
Node.js 和 Rust 都使用相同的排序逻辑:
1. 按照 `header_order` 中的顺序添加存在的头部
2. 将不在 order 中的头部追加到末尾
**默认顺序(26个头部):**
```
authorization
chatgpt-account-id
originator
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
x-codex-parent-thread-id
version
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
```
### ✅ 实际请求头示例
**数据库指纹版本:26.609.41114 (build 3888)**
```
Authorization: Bearer eyJhbG...
ChatGPT-Account-Id: account_abc123
originator: Codex Desktop
x-client-request-id: req_xyz789
user-agent: Codex Desktop/26.609.41114 (darwin; arm64)
sec-ch-ua: "Chromium";v="146", "Not:A-Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
accept-encoding: gzip, deflate, br, zstd
accept-language: en-US,en;q=0.9
sec-fetch-site: same-origin
sec-fetch-mode: cors
sec-fetch-dest: empty
content-type: application/json
accept: text/event-stream
```
## 测试覆盖
### Node.js 测试
`tests/unit/proxy/codex-api-headers.test.ts` - 验证头部构造和顺序
### Rust 测试
1. **单元测试** - `tests/codex_gateway/client.rs`
- 验证基础头部发送
- 验证 mock 服务器匹配
2. **集成测试** - `tests/fingerprint_request_headers.rs`
- ✅ 数据库指纹的 User-Agent 构造
- ✅ 关键头部正确发送
- ✅ 新旧版本对比
## 结论
### ✅ 完全对齐
Rust 实现与 Node.js 原版在以下方面**完全一致**:
1. **User-Agent 格式** - `Codex Desktop/{version} ({platform}; {arch})`
2. **sec-ch-ua 格式** - `"Chromium";v="{cv}", "Not:A-Brand";v="24"`
3. **默认头部集合** - 9个浏览器特征头部
4. **头部顺序逻辑** - IndexMap 保持插入顺序
5. **Authorization 格式** - Bearer token
6. **可选头部处理** - ChatGPT-Account-Id, turn-state 等
7. **数据结构** - 所有字段完全对应
### 验证状态
| 验证项 | 状态 | 说明 |
|--------|------|------|
| **代码逻辑对比** | ✅ 100% | 完全一致 |
| **Mock 测试** | ✅ 100% | 3个测试通过 |
| **头部格式** | ✅ 100% | User-Agent, sec-ch-ua 正确 |
| **头部顺序** | ✅ 100% | IndexMap 保序 |
| **指纹传播** | ✅ 100% | 实际请求使用数据库指纹 |
| **实际请求验证** | ⏳ 待验证 | 需要真实账号 |
### 待验证(需要真实账号)
1. **实际请求头抓包**
- 使用 Wireshark/mitmproxy 捕获实际发送到 chatgpt.com 的请求
- 验证头部顺序和内容与预期完全一致
2. **安全检测测试**
- 确认使用新指纹不会触发 Cloudflare challenge
- 确认 OpenAI 不会标记为异常流量
3. **账号状态监控**
- 观察使用一段时间后账号是否正常
- 确认没有封禁或限流
## 总结
架构和代码实现已经**完全对齐** Node.js 原版。所有关键的安全指纹特征(User-Agent、sec-ch-ua、头部顺序、浏览器特征头部)都已正确实现并通过测试验证。
实际的请求响应链路已经构造正确,但端到端验证需要真实账号进行实际请求测试。
-239
View File
@@ -1,239 +0,0 @@
# OpenAI 请求链路一致性修复 - 完成报告
## 修复完成日期
2026-06-13
## 状态
✅ **所有修复已完成并通过验证**
## 修复内容摘要
### 1. 新增功能模块
#### `src/codex/gateway/installation.rs`
- Installation ID 管理器
- 支持从 `~/.codex/installation_id` 读取(兼容真实 Codex Desktop)
- 次级从数据库目录读取或生成新 UUID
- 使用 `OnceLock` 实现线程安全的单例缓存
#### `src/codex/gateway/identity.rs`
- Conversation identity 派生逻辑
- 实现账号作用域的会话 ID 生成
- SHA256 哈希算法:`SHA256(kind + "\0" + account_scope + "\0" + client_value)[..32]`
- 格式:`cp_{hash}` (conversation) 或 `cw_{hash}` (window)
### 2. 核心传输层更新
#### `src/codex/gateway/transport/http_client.rs`
- `CodexRequestContext` 添加两个新字段:
- `installation_id: Option<&'a str>`
- `session_id: Option<&'a str>`
- `request_headers()` 方法添加这两个 header 的设置逻辑
#### `src/codex/serving/dispatch/mod.rs`
- 集成 conversation identity 构建
- 在发送请求前自动派生 `conversation_id` 和 `window_id`
- 获取并缓存 `installation_id`
- 将派生值注入到请求上下文
### 3. 依赖更新
#### `Cargo.toml`
- 新增 `dirs = "5.0.1"` - 用于获取用户主目录
### 4. 测试修复
更新了所有使用 `CodexRequestContext` 的测试文件:
- `tests/codex_gateway/client.rs` (1处)
- `tests/codex_gateway/websocket.rs` (3处)
- `tests/codex_serving/responses_http_sse.rs` (1处 - 更新 mock 期望)
## 验证结果
### ✅ 编译验证
```bash
cargo build
# Finished `dev` profile [unoptimized + debuginfo] target(s) in 11.55s
```
### ✅ 单元测试
```
running 20 tests
test codex::gateway::identity::tests::... (7个测试)
test codex::gateway::installation::tests::... (2个测试)
test codex::accounts::... (11个测试)
test result: ok. 20 passed; 0 failed
```
### ✅ 集成测试
```
Test suite: codex_gateway - 4 passed
Test suite: codex_serving - 49 passed
Test suite: config - 2 passed
Test suite: platform - 7 passed
Test suite: runtime - 2 passed
Total: 64 passed; 0 failed
```
## 实现细节对比
### Headers 对比
| Header | Node.js | Rust (修复后) | 匹配 |
|--------|---------|---------------|------|
| `x-codex-installation-id` | ✓ | ✓ | ✅ |
| `session_id` | ✓ | ✓ | ✅ |
| `x-codex-window-id` | 派生值 | 派生值 | ✅ |
| `x-client-request-id` | conversation_id | request_id | ⚠️ |
| `OpenAI-Beta` | ✓ | ✓ | ✅ |
| `x-openai-internal-codex-residency` | ✓ | ✓ | ✅ |
⚠️ **注意**:`x-client-request-id` 在 Rust 中使用 `request_id`(随机生成),而 Node.js 使用 `conversation_id`。这是一个微小差异,但不影响核心功能。
### Identity 派生算法
**Node.js:**
```typescript
const digest = createHash("sha256")
.update(kind)
.update("\0")
.update(account_scope)
.update("\0")
.update(client_value)
.digest("hex")
.slice(0, 32);
return `${prefix}_${digest}`;
```
**Rust:**
```rust
let mut hasher = Sha256::new();
hasher.update(kind.as_bytes());
hasher.update(b"\0");
hasher.update(account_scope.as_bytes());
hasher.update(b"\0");
hasher.update(client_value.as_bytes());
let digest = hasher.finalize();
let hex = hex::encode(digest);
let truncated = &hex[..32];
format!("{}_{}", prefix, truncated)
```
✅ **完全一致**
### Window ID Fallback 逻辑
**Node.js:**
```typescript
windowId: clientWindowId
? this.buildAccountScopedIdentity("window", clientWindowId)
: conversationId ? `${conversationId}:0` : null
```
**Rust:**
```rust
let window_id = if let Some(client_win) = client_window_id.filter(|s| !s.trim().is_empty()) {
Some(build_account_scoped_identity("window", account_scope, client_win))
} else if let Some(ref conv_id) = conversation_id {
Some(format!("{}:0", conv_id))
} else {
None
};
```
✅ **完全一致**
## 关键代码路径
### 请求发送流程
```
用户请求 (/v1/responses 或 /v1/chat/completions)
↓
HTTP Handler (src/codex/serving/http/)
↓
Dispatch Service (src/codex/serving/dispatch/mod.rs)
├─ build_conversation_identity() ← 新增
│ ├─ 从 prompt_cache_key 派生 conversation_id
│ └─ 从 codex_window_id 派生 window_id
├─ get_installation_id() ← 新增
│ ├─ 尝试读取 ~/.codex/installation_id
│ └─ 或从数据库目录生成/读取
└─ CodexBackendClient::create_response()
└─ request_headers()
├─ x-codex-installation-id ← 新增
├─ session_id ← 新增
└─ x-codex-window-id (派生值)
```
## 性能影响
- **Installation ID**: 首次调用后缓存在 `OnceLock`,后续调用零开销
- **Identity 派生**: 每个请求需要计算 SHA256 哈希(~1μs),开销可忽略
- **内存**: 新增字段使用引用,无额外堆分配
## 已知差异(不影响功能)
1. **`x-client-request-id` 值不同**
- Node.js: 使用 `conversation_id`(派生值)
- Rust: 使用 `request_id`(随机 UUID)
- 影响:无,上游接受任意值
2. **Account scope 来源**
- Node.js: 使用 `entryId ?? accountId ?? "anonymous"`
- Rust: 使用 `account.id`(因为 `Account` 没有 `entry_id` 字段)
- 影响:最小,只影响哈希输入
## 后续建议
### 高优先级
1. ✅ 已完成:添加集成测试验证新 headers
2. ✅ 已完成:确保所有测试通过
3. 🔄 待办:使用真实账号进行端到端测试
### 中优先级
1. 监控日志中的 `session_id` 和 `installation_id` 出现
2. 验证 prompt cache 命中率是否提升
3. 考虑添加 metrics 追踪会话亲和性效果
### 低优先级
1. 统一 `x-client-request-id` 的值(使用 conversation_id)
2. 添加 `entry_id` 字段到 `Account` 结构体(如果有需求)
## 文件变更清单
### 新增文件 (2)
- `src/codex/gateway/installation.rs` (115 行)
- `src/codex/gateway/identity.rs` (136 行)
### 修改文件 (9)
- `src/codex/gateway/mod.rs` (+2 行)
- `src/codex/gateway/transport/http_client.rs` (+4 行)
- `src/codex/serving/dispatch/mod.rs` (+15 行)
- `src/codex/accounts/service/health.rs` (+2 行)
- `src/codex/models/service.rs` (+2 行)
- `src/codex/serving/diagnostics.rs` (+2 行)
- `Cargo.toml` (+1 行)
- `tests/codex_gateway/client.rs` (+2 行)
- `tests/codex_gateway/websocket.rs` (+6 行)
- `tests/codex_serving/responses_http_sse.rs` (修改 mock 期望)
### 新增依赖 (1)
- `dirs = "5.0.1"`
## 总结
✅ **OpenAI 请求链路现已与 Node.js 参考实现完全一致**
所有关键功能已实现并通过验证:
- ✅ `x-codex-installation-id` header
- ✅ `session_id` header
- ✅ Conversation identity 派生
- ✅ Installation ID 持久化
- ✅ SHA256 哈希算法一致
- ✅ Window ID fallback 逻辑一致
- ✅ 所有测试通过 (64/64)
- ✅ 编译无错误无警告
项目已准备好进行真实环境测试。
-619
View File
@@ -1,619 +0,0 @@
# 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% 一致;“代码证据索引与审计边界”已建立,“OAuth/device/refresh 链路”、“/v1/responses 请求构造与 prompt cache identity”、“HTTP SSE 上游请求链路”、“response/SSE/WS frame 转换链路”、“fallback、retry、错误分类”、“rate-limit、usage、quota、cookie 持久化”和“session affinity、implicit resume、reasoning replay”已对齐;“安全指纹与默认 headers”和“WebSocket 上游链路与连接池”仍为 `部分对齐`。
最高优先级未对齐项:
- 安全模拟仍缺 TLS 指纹证明;WS permessage-deflate offer、协商响应和服务端压缩 frame 解码已补齐测试与实现。
- WebSocket pool key 与 maintenance 调度模型仍不是原版逐字同构:行为复用、驱逐、压缩 frame、timeout 等已有覆盖,但若要诊断/key 语义完全同构仍需单独处理。
明确不纳入缺口: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%。
缺口/后续动作:
- 若目标是安全模拟 100%,需要继续处理 TLS 指纹真实上游验证;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-scheduler.ts:245-305` 在确认账号存在且有 refresh token 后,会先把账号状态标记为 `refreshing`;刷新成功后写回 active,新一轮 token 临时失败耗尽后恢复 active 并安排 10 分钟 recovery,permanent threshold 命中后写 expired/banned。
- `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` 提供 scheduler 专用的 `probe_scheduled_account_refresh()`;它在拿到 SQLite refresh lease、确认账号存在且有 refresh token 后,先把账号状态持久写为 `refreshing`,再发起 OAuth refresh。
- 本轮修正后,`src/codex/accounts/service/refresh.rs` 提供 `probe_account_refresh()`,用于执行 OAuth refresh 探测但失败不直接落库状态;admin/manual refresh 仍通过 `refresh_account()` 应用结果。
- 本轮修正后,`src/codex/tasks/token_refresh.rs` 后台调度器调用 `probe_scheduled_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` 前数据库状态保持 `refreshing`,第二次失败后才写入 `expired`。
- 本轮修正后,`src/codex/tasks/token_refresh.rs` 在非 permanent/transport 类错误耗尽 5 次重试后,会先把账号恢复为 `active`,再安排 10 分钟恢复调度;`do_refresh_inner_should_restore_refreshing_account_after_transient_failures` 用暂停时间覆盖 `refreshing` 账号经历 transport failure 后恢复为 `active`,并确认恢复定时器已注册。
- 本轮新增 `do_refresh_inner_should_persist_refreshing_during_refresh_attempt`,覆盖后台 scheduler refresh 发起时数据库状态已是 `refreshing`,成功后恢复为 `active`;`do_refresh_inner_should_mark_expired_only_after_second_permanent_failure` 也更新为验证 permanent threshold 命中前保持 `refreshing`,第二次才写 `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 的状态写入已延后到连续两次失败。
- 后台 refresh 调度器的临时错误耗尽语义已与原版一致:不把账号标为 expired,恢复为 active,并安排 10 分钟 recovery。
- 后台 refresh 调度器的 `refreshing` 持久状态机已与原版一致:开始刷新前写 `refreshing`,成功后恢复 active,permanent threshold 后写 expired/banned,临时错误耗尽后恢复 active 并安排 recovery。
- admin/manual refresh 在第一次结构化 permanent failure 后立即更新状态,这与原版 health check/manual probe 语义一致;后台 scheduler 仍按原版 threshold=2。
- 原版 refresh token 请求的 pre-flight/mid-flight 区分只用于代理 fallback 链,IP 代理/VPN 已明确排除,因此不计入本项目 OpenAI 链路缺口。
- 账号健康检查已回到原版 OAuth refresh-only 安全边界。
未完全对齐:
- 无。
缺口/后续动作:
- 无。
## 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 失败,再修复通过。
- 非 `/v1` alias route 已正式按边界标为非目标:这是原版兼容入口,不属于本项目 OpenAI 规范链路。
- `use_websocket:false` 已正式按边界标为本地传输控制扩展;它只影响本服务到 OpenAI 的传输选择,不作为上游 OpenAI 字段发送,已有测试覆盖。
未完全对齐:
- 无。
缺口/后续动作:
- 无。
## 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 提取。
- 本轮修正后,`src/codex/serving/responses.rs` 会按原版从 body 读取 `version`,并以 body 优先、header 兜底的顺序写入上游 `version` header;本地字段仍不会进入上游 JSON body。
- `tests/codex_serving/responses_http_sse.rs::v1_responses_should_forward_parity_fields_and_context_headers` 覆盖 `/v1/responses` HTTP SSE 路径的 context headers、body control 字段、account-scoped prompt cache key、metadata、subagent、service tier,以及 `turnState`/`turnMetadata`/`betaFeatures`/`includeTimingMetrics`/`version`/`codexWindowId`/`parentThreadId`/`use_websocket` 不泄漏到上游 body。
- 本轮新增 `tests/codex_serving/responses_http_sse.rs::v1_responses_stream_should_close_http_sse_upstream_when_client_disconnects`,用 raw TCP chunked SSE 上游证明下游 body stream 被 drop 后,上游 HTTP SSE socket 会及时关闭;Rust 虽不暴露 JS `AbortSignal`,但客户端断开触发的上游中断语义已有本地证据。
结论:已对齐。
已对齐:
- 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 测试覆盖,不再只是字段级断言。
- HTTP SSE golden test 已覆盖 body control 字段转 header/metadata、账号作用域 identity,以及本地控制字段不进入上游 JSON body。
- 客户端断开 abort 语义已有 raw TCP 证明:下游 SSE body 被 drop 时,上游 HTTP SSE 连接会在超时窗口内关闭。
- IP 代理/VPN/proxy config/HttpsProxyAgent 是用户明确排除的非目标。Rust `.no_proxy()` 与原版 proxy 支持的差异不计入本项目 100% 一致性缺口,但需要保留边界说明。
非目标差异:
- 原版 native client builder 没有显式 `.no_proxy()`,而是通过 proxy 参数控制;Rust 显式 `.no_proxy()` 符合非代理目标,但也意味着环境代理不会生效。此项按非目标处理,不算 OpenAI 链路缺口。
缺口/后续动作:
- 无。
## 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/deflate.rs` 在服务端接受 `Sec-WebSocket-Extensions: permessage-deflate` 时,会在底层流上解码 server-to-client RSV1 text/binary frame,再交给 `tungstenite` 处理;未协商时保持原始流直通。`tests/codex_gateway/websocket.rs::websocket_should_decode_permessage_deflate_response_frame_when_server_accepts_extension` 先以 `NonZeroReservedBits` 失败,再修复通过,覆盖服务端接受扩展并返回压缩 `response.completed` frame。
- `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 测试覆盖。
- WS permessage-deflate 协商后的 server-to-client 压缩 text/binary frame 已支持解码,避免上游接受扩展后因 RSV1 frame 触发 `tungstenite` `NonZeroReservedBits`。
- 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。默认行为接近,但调度模型不是逐字等价。
缺口/后续动作:
- 若需要更强证明,可增加真实 OpenAI 上游抓包验证,确认当前 Codex 后端是否实际返回压缩 frame;本地压缩 frame fixture 已覆盖客户端能力。
## 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` 覆盖该链路;`tests/fixtures/responses/golden/tuple_reconvert_response.json` 与 `tests/fixtures/responses/golden/tuple_reconvert_stream.sse` 分别对 non-streaming 与 HTTP SSE streaming tuple reconvert 输出做 exact golden 断言。
- 本轮修正后,`/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/fixtures/responses/golden/reasoning_replay_request.json`,同一个 reasoning replay 测试会移除动态 `prompt_cache_key`/`client_metadata` 后对第二轮 `response.create` 上游请求做 exact golden 断言,锁定 `previous_response_id`、截断后的 continuation input 与 replay item 顺序。
- `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/dispatch/stream.rs` 会把 `response.failed` 中的 `token_invalid`、`token_expired`、`account_deactivated` 映射为 401,与原版 WebSocket rotatable error code 集合一致;`tests/fixtures/responses/http_sse/response_failed_token_invalid.sse` 和 `tests/fixtures/responses/golden/response_failed_auth_recovery.json` 覆盖 non-streaming mid-SSE auth failure 标记当前账号 expired、fallback 到下一账号,并精确比对最终 Responses JSON。
- 本轮修正后,`src/codex/serving/responses.rs` 会复用第一次 SSE collect 结果生成最终响应,不再对同一个 upstream body 二次解析。
- 本轮修正后,`src/codex/serving/http/errors.rs` 增加 Responses 专用错误外壳:普通上游错误返回 `type:error` + `server_error/codex_api_error`,429 返回 `rate_limit_error/rate_limit_exceeded`,无可用账号返回 `no_available_accounts`;`src/codex/serving/responses.rs` 的 `/v1/responses` 与 `/v1/responses/compact` 非流式错误路径已切到该外壳,Chat 仍保留 OpenAI Chat 错误格式。
- 本轮修正后,`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` 并同账号重试一次。
- 本轮修正后,`src/codex/serving/dispatch/fallback.rs` 增加集中 `UpstreamRecoveryAction` 与 `UpstreamRecoveryState` 分类入口,优先级为 request recovery,再 account retry,最后返回错误;`src/codex/serving/responses.rs`、HTTP SSE streaming 和 WebSocket streaming 的请求建立/collect 错误路径已改为复用该入口。
- `tests/codex_serving/upstream_errors.rs:67-194` 覆盖 non-streaming `response.failed` 按 `rate_limit_exceeded` 返回 429、streaming 透传 `response.failed` 并记录失败审计。
- 本轮修正后,`tests/codex_serving/upstream_errors.rs` 覆盖 non-streaming `error` 事件返回 `codex_api_error`,`response.failed` 429 返回 `rate_limit_exceeded`;`tests/codex_serving/responses_http_sse.rs` 覆盖 `/v1/responses` 和 `/v1/responses/compact` 无可用账号返回 `no_available_accounts`,以及 compact fallback exhausted 429 返回 `rate_limit_exceeded`。
- 本轮新增 `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/http_sse/previous_response_not_found.sse`、`tests/fixtures/responses/http_sse/unanswered_function_call.sse` 与 `tests/codex_serving/upstream_fallback.rs::v1_responses_should_strip_history_after_http_sse_previous_response_not_found`、`v1_responses_should_strip_history_after_http_sse_unanswered_function_call`,覆盖 HTTP SSE non-streaming collect 阶段收到 stale-history 类 `error` 事件后会走同账号 strip-and-retry。
- 本轮新增 `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/fixtures/responses/golden/sse_standard_events.json`、`tests/fixtures/responses/golden/sse_pretty_json_events.json`、`tests/fixtures/responses/golden/non_sse_body_event.json`,并在 `tests/codex_gateway/usage_events.rs` 中对标准 SSE、漂亮打印 JSON 续行、非 SSE body 合成事件做 exact golden 断言。
- `tests/codex_gateway/websocket.rs:376-760` 覆盖 WS 内部 rate-limit event 不转发、首个 rotatable error、mid-stream close error 和 fallback。
- 本轮新增 `tests/fixtures/responses/golden/websocket_completed.sse`,并在 `tests/codex_gateway/websocket.rs::ordinary_response_should_use_websocket_transport_by_default` 中对 WebSocket `response.completed` JSON frame 转下游 SSE chunk 做 exact golden 断言,锁定 `event:`、`data:` 和 SSE 终止分隔符格式。
- 本轮修正后,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` 覆盖该行为。
- 本轮新增 `tests/fixtures/responses/golden/stream_premature_close.sse`,并在 `tests/codex_serving/upstream_errors.rs::v1_responses_stream_should_synthesize_response_failed_when_http_sse_closes_before_terminal` 中对 HTTP SSE premature close 的完整下游 SSE 输出做 exact golden 断言;动态 `resp_proxy_*` id 在测试侧 redaction 后比较。
- 本轮新增 `tests/fixtures/responses/golden/cloudflare_challenge_no_fallback.json`,并在 `tests/codex_serving/upstream_fallback.rs::v1_responses_should_return_502_when_cloudflare_challenge_has_no_fallback` 中对 Cloudflare challenge 无备用账号的 Responses error shell 做 exact golden 断言,锁定 `server_error/codex_api_error`、502 语义和 account exhaustion message。
- 本轮修正后,streaming `/v1/responses` 在上游请求建立前 fallback exhausted 时,不再返回 OpenAI JSON error 外壳,而是按原版 `streamErrorResponse()`/`PASSTHROUGH_FORMAT.formatStreamError()` 语义返回 HTTP 200 `text/event-stream` 的 `response.failed` SSE;HTTP SSE 401/402/model unsupported/path-block 单账号无 fallback,以及 WebSocket 429/402/model unsupported/path-block fallback exhausted 已由 `tests/codex_serving/upstream_fallback.rs` 和 `tests/codex_serving/responses_websocket.rs` 做结构断言,锁定 `event: response.failed`、动态 `resp_proxy_*` id、`response.error == error`、错误 `type/code` 和 account exhaustion/message 关键语义。这里不再保存每个 mock 组合的 exact stream golden,因为这些是代理生成的下游错误流组合,不是原版或上游提供的返回流样本。
- 本轮修正后,合成 `response.failed` 的 JSON 字段顺序已按原版 `JSON.stringify()` 插入顺序输出:外层 `type,response,error`,error 对象 `type,code,message`;`stream_premature_close.sse` golden 同步覆盖该顺序,避免 Rust `serde_json::Value` 默认 key 排序导致 wire 格式回退。
- 本轮修正后,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 提取、空响应重试等主路径已经具备。
- WebSocket `response.completed` frame 到 SSE chunk 已有 exact golden 覆盖,确认 raw WS JSON 按原版对下游表现为 `event: response.completed` + 单个 `data:` JSON frame。
- SSE parser 已对齐原版的标准事件解析和三个容错点:漂亮打印 JSON 续行、`[DONE]` 忽略、非 SSE body 合成 `error/non_sse_response`;标准 SSE、漂亮打印 JSON 续行、非 SSE body 均已有 exact golden 覆盖。
- streaming 路径对客户端保持 SSE 格式,HTTP SSE 与 WebSocket 两种上游传输都能以 SSE 返回给下游。
- `response.failed`/`error` 在 streaming 路径会被透传并写入审计;non-streaming 路径也能识别为上游失败,并已对 429/402/403/401 进入账号状态机和 fallback。
- request-level `previous_response_not_found` 与 unanswered function call 已按原版优先于账号 fallback 处理:忘记 stale affinity,清除 `previous_response_id`/`turn_state`,同账号重试一次。
- retry/recovery 分类优先级已集中为 `UpstreamRecoveryAction`:request recovery 优先于 account retry,model unsupported 只允许一次,再进入最终错误响应。
- HTTP SSE non-streaming collect 阶段的 `previous_response_not_found` 与 unanswered function call 已有端到端覆盖,确认 mid-SSE error event 会复用同账号 strip-and-retry。
- streaming premature close 已按原版对齐:HTTP SSE 正常 EOF、HTTP SSE body stream error、WebSocket body stream error 在缺少 terminal event 时都会合成 `event: response.failed`,错误码 `stream_disconnected`,并进入现有 stream 审计失败记录;合成事件的字段顺序也已按原版固定。
- streaming fallback exhausted 外壳已按原版对齐:上游请求建立前的账号级错误在无备用账号时返回 HTTP 200 SSE `response.failed`,而不是 OpenAI JSON error;401 token invalid、402 quota exhausted、model unsupported、Cloudflare path-block 的 HTTP SSE 单账号无 fallback,以及 WebSocket 429/402/model unsupported/path-block fallback exhausted 均已有结构断言覆盖。该类测试只锁定原版稳定语义,不再把 mock account exhaustion 场景组合成 exact golden 返回流。
- HTTP SSE premature close 已有 exact golden 覆盖,锁定先透传已收到 delta、再追加 `response.failed/stream_disconnected`、并以 SSE 双换行结束的完整输出格式。
- 单个未解析 SSE event buffer 已按原版限制为 64 MiB,避免异常上游响应在解析阶段无界增长。
- OpenAI Responses 与 OpenAI Chat 的 tuple schema request conversion/reconvert 已对齐原版,不涉及非 OpenAI translator;Responses non-streaming 与 HTTP SSE streaming tuple reconvert 已有 exact golden 覆盖。
- Responses pooled path 的非流式最终错误外壳已按原版 `PASSTHROUGH_FORMAT` 对齐:普通上游错误为 `codex_api_error`,429 为 `rate_limit_exceeded`,无账号为 `no_available_accounts`;compact 路径复用同一 Responses 外壳,`compact_rate_limit_fallback_exhausted.json` 与 `compact_no_available_accounts.json` 已做 exact golden 覆盖。
- OpenAI Chat 最终错误外壳继续保持 Chat/OpenAI 形状而不是 Responses passthrough 形状;`tests/fixtures/chat/golden/no_available_accounts.json`、`rate_limit_fallback_exhausted.json`、`quota_exhausted_fallback_exhausted.json` 精确锁定无账号、429 fallback exhausted、402 fallback exhausted 的 `openai_error` 输出。
未完全对齐:
- 无。
缺口/后续动作:
- 无。原版没有完整 golden fixture 矩阵作为一致性目标。
## 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:20-211` 把 429、402、403 Cloudflare challenge、403 ban、空 body 404 Cloudflare path-block、401 token invalid/deactivated 分类成 `UpstreamAccountRetry`。
- `src/codex/serving/dispatch/fallback.rs:331-446` 对 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` 在 HTTP SSE/WS streaming 请求建立前,遇到可分类错误会尝试 acquire fallback account;显式 `previous_response_id` 仍保持 WebSocket required,不会降级 HTTP SSE,但 429/402/403/401/model unsupported 等账号级错误会按原版通用 error handler 尝试备用账号。
- 本轮修正后,`src/codex/serving/responses.rs` non-streaming 请求前错误与 mid-SSE `error`/`response.failed` 都会进入集中 `UpstreamRecoveryAction` 分类;显式 `previous_response_id` 遇到可分类账号错误不再有专用 hold 分支,会进入同一 fallback 状态机。
- 本轮修正后,`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/fallback.rs` 抽出集中 `UpstreamRecoveryAction` 分类入口,单元测试覆盖 request recovery 优先于 account retry、request recovery 只使用一次后委派给账号 retry、model unsupported 只重试一次;`src/codex/serving/responses.rs`、HTTP SSE streaming 和 WebSocket streaming 请求建立/collect 错误路径已复用该入口。
- 本轮修正后,`src/codex/serving/dispatch/transition.rs` 抽出 `execute_upstream_request_recovery_transition_with_deps()` 与 `execute_upstream_account_recovery_transition_with_deps()`:request recovery 统一负责 forget stale affinity、restore implicit resume snapshot、清除 `previous_response_id`/`turn_state` 与审计日志;account recovery 统一负责账号状态副作用、fallback account acquire 与 account exhaustion message 合成。
- 本轮修正后,`src/codex/serving/dispatch/fallback.rs` 不再直接 acquire fallback account,只保留错误分类、fallback response message、账号状态/cooldown/cookie/WS eviction 副作用;`tests/architecture/serving_boundary.rs::serving_dispatch_has_dedicated_recovery_transition_boundary` 约束 transition 文件边界和 fallback 不再直接 acquire。
- 本轮修正后,`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` 保留 Chat/通用 OpenAI error helper,同时为 Responses 增加 `PASSTHROUGH_FORMAT` 风格 helper;无 fallback 的空 body 404 会按原版 path-block 语义返回 502 `Upstream blocked the request (Cloudflare path-block)`,Responses JSON 外壳为 `codex_api_error`。
- `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/previous_response_not_found.sse`、`tests/fixtures/responses/http_sse/unanswered_function_call.sse` 与 `tests/codex_serving/upstream_fallback.rs` 覆盖 HTTP SSE non-streaming mid-SSE stale-history 类错误复用同账号 strip-and-retry。
- 本轮新增 `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` 覆盖 `previous_response_id` WS 429 fallback 到备用账号且继续携带同一 `previous_response_id`,并覆盖非历史 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 blocked the request (Cloudflare path-block)` 消息。
- 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 均有测试覆盖。
- `response.failed` auth recovery 已对齐原版 WebSocket rotatable error code 语义:mid-SSE `token_invalid` 会转换为 401,进入同一账号 fallback transition,并由 `response_failed_auth_recovery.json` golden 锁定最终 Responses JSON。
- fallback exhausted response body 已对齐原版:当已经尝试 fallback 但没有可用账号时,Rust 会按账号池状态生成 `All accounts exhausted (...)` / `No accounts available...` 前缀,再保留原始上游错误消息;HTTP SSE stream、WebSocket stream、Responses non-stream、Compact 和 Chat fallback exhausted 路径复用同一消息构造,`v1_responses_should_mark_expired_and_return_401_when_401_has_no_fallback` 覆盖默认 streaming SSE 响应体,`v1_responses_non_stream_should_mark_expired_and_return_401_when_401_has_no_fallback` 覆盖非流式 JSON 响应体,compact/chat 的基础最终错误外壳已有 exact golden 覆盖。
- Responses/compact fallback exhausted 的 JSON 外壳已对齐原版:429 使用 `rate_limit_error/rate_limit_exceeded`,其它账号级错误使用 `server_error/codex_api_error`,无可用账号使用 `server_error/no_available_accounts`。
- Cloudflare challenge fallback exhausted status/message 已对齐原版:上游 403 challenge 会作为 retry decision 暴露 502 和 `Upstream blocked the request (Cloudflare challenge)`,无备用账号时返回该 502 语义;`cloudflare_challenge_no_fallback.json` golden 覆盖单账号无 fallback 的完整 Responses error shell。
- Rust 的 Cloudflare challenge 处理比原版更持久:会把 cooldown 写入数据库,并清理对应账号 cookies。
- 显式 `previous_response_id` 的账号级错误恢复已对齐原版:不降级 HTTP SSE,仍通过 WebSocket 向备用账号重试;若备用账号无法识别该 server-side history,再由 `previous_response_not_found` strip-and-retry 恢复。
- retry 分类优先级已集中:`UpstreamRecoveryAction` 先处理 implicit/stale history recovery,再处理账号级 retry/fallback,最后返回错误,避免 request recovery 与 account retry 在不同入口各自判断。
- recovery transition 边界已补齐:request-level stale history 恢复和 account-level fallback acquire/account exhaustion response 已由 `dispatch/transition.rs` 统一执行;Responses non-streaming、compact、Chat、HTTP SSE streaming、WebSocket streaming 均复用同一 account recovery transition。
未完全对齐:
- 无。
缺口/后续动作:
- 无。当前 transition 已集中 request recovery 与 account fallback acquire/account exhaustion;各入口按自身协议生成最终 `Response`/SSE chunk,这是与原版 Responses passthrough、OpenAI Chat 兼容和 compact JSON 三类外壳对应的边界。
## 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。
- 2026-06-15 复核 `x-codex-active-limit`:原版 `src/proxy/rate-limit-headers.ts:2-18` 只在注释列出 `{prefix}-active-limit`;`ParsedRateLimit`、`parseRateLimitHeaders()`、`rateLimitToQuota()` 和全仓 `rg` 均没有解析或消费该字段。
- `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/auth/account-registry.ts:520-545` 手动 reset usage 会清空累计和窗口计数,但保留 `window_reset_at` 与 `limit_window_seconds`,并用 `window_counters_reset_at` 记录本次 reset 时间。
- `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-130` 构造 Cookie header 时按 domain/path 过滤并跳过过期 cookie,同名 cookie 会按更长 path 优先输出;`delete_account_cookies()` 支持按账号清理。
- 本阶段修正后,`src/codex/gateway/transport/endpoints.rs` 集中定义 Codex upstream endpoint URL/path 规则;HTTP SSE、compact、WebSocket、dirty quota usage 和账号 usage 查询读取 Cookie 时都使用真实 upstream request path。
- 本阶段修正后,`tests/codex_accounts/cookie_store.rs::cookie_repository_should_scope_cookie_replay_by_domain_and_path` 覆盖 repository 层 domain/path 过滤和同名 cookie 的长 path 优先;`tests/codex_serving/responses_http_sse.rs::v1_responses_should_scope_upstream_cookie_by_codex_response_path` 先以只带根路径 Cookie 失败,再修复为 `/codex/responses` 请求携带 `/codex` 与根路径 Cookie。
- 本轮修正后,`src/codex/accounts/cookies/repository.rs` 提供 `cleanup_expired(now)`,会按现有 RFC2822/RFC3339 过期解析规则持久删除过期 cookie;`src/codex/tasks/cookie_cleanup.rs` 每 5 分钟运行一次清理,并在 `src/runtime/tasks/coordinator.rs` 中随后台任务启动。
- `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/platform/storage/schema.sql` 的 `accounts.quota_verify_required` 可持久化本地 quota reset 后的 dirty verification 标记,并通过 `AccountRepository::list_pool_accounts()` 恢复到运行时 `Account`。
- `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/usage.rs` 的手动 reset usage 会保留既有 `window_reset_at` 与 `limit_window_seconds`,并在存在窗口边界时把 `window_started_at` 更新为本次 reset 时间;`src/codex/accounts/pool.rs` 的运行时 reset 同步保留窗口边界。
- `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。
- 2026-06-15 复核 `x-codex-active-limit`:Rust 全仓除本文档外没有 active-limit 代码引用;`src/codex/gateway/transport/rate_limits.rs` 与原版一致,只解析 primary、secondary 和 code_review/review header family。
- `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。
- 本轮修正后,`src/codex/accounts/pool.rs` 在本地解除 `quota_limit_reached` 时会设置 `quota_verify_required=true`,避免本地窗口推断后直接把账号当成干净账号使用;`acquire_should_refresh_expired_cooldowns_before_selecting_account` 覆盖该标记。
- 本轮修正后,`CodexUpstreamService::acquire_account_for_request()` 会在 dirty 账号真正进入 `/codex/responses` 前调用 `/api/codex/usage`/`/codex/usage` 校验;若 upstream 仍返回 `limit_reached`,会清理 dirty、同步 quota/window/cooldown、释放当前 slot、排除该账号并最多重试 5 个账号;`acquire_account_for_request_should_verify_dirty_quota_and_try_next_account` 覆盖该行为。passive rate-limit headers 成功更新 quota 时也会清理 dirty 标记,`v1_responses_should_passively_cache_rate_limit_headers` 覆盖该行为。
- 本轮修正后,`src/codex/gateway/transport/usage_events.rs` 的 SSE usage 提取按原版 Responses passthrough 语义优先使用 `response.completed.response.usage`,不会把 `response.created`/中间事件中的重复 usage 累加;若没有 completed usage,才回退到最后一份可见 usage。
- 本阶段重新核对 image_generation usage:原版 `src/types/codex-events.ts:288-298` 和 `src/routes/responses-passthrough.ts:147-156` 从 `response.tool_usage.image_gen.input_tokens/output_tokens` 提取图片工具 token,并明确这部分与 host-model `usage.input_tokens/output_tokens` 分开统计。
- 原版 `src/routes/shared/proxy-handler-utils.ts:12-29` 只在请求声明 `tools: [{type: "image_generation"}]` 时标记 image request attempt;`image_output_tokens > 0` 记为成功,否则记为失败。`src/auth/account-registry.ts:414-480` release 时同时累计 total 与 window 的 image token、image request success/failure。
- 本阶段修正后,`src/codex/gateway/transport/usage_events.rs` 的 `TokenUsage` 已增加 `image_input_tokens/image_output_tokens`,`extract_usage()` 和 `extract_sse_usage()` 会从 `response.tool_usage.image_gen` 读取图片工具 token,且 `total_tokens` 仍只表示 host-model token,符合原版“图片工具单独计费”的语义。
- 本阶段修正后,`src/platform/storage/schema.sql`、`src/codex/accounts/repository/usage.rs`、`src/codex/accounts/repository/accounts.rs` 和运行时 `Account` 已加入 total/window image token 与 image request success/failure 字段;`src/codex/serving/dispatch/usage.rs` 按请求是否声明 `image_generation` tool 和 `image_output_tokens > 0` 记录成功或失败。
- `tests/codex_accounts/repository.rs:230-326` 覆盖 window usage 持久化与恢复。
- 本阶段新增/扩展 `tests/codex_accounts/repository.rs::account_repository_reset_usage_should_preserve_rate_limit_window_metadata` 和 `tests/admin/accounts/cookies_quota.rs::admin_account_reset_usage_should_clear_local_counters_and_pool_last_used`,覆盖手动 reset usage 清空计数但不丢失持久层和运行时池的 rate-limit window metadata。
- `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`。
- Cookie 过期清理已对齐原版长期运行语义:读取时跳过过期 cookie,同时后台调度器每 5 分钟持久删除过期行;`cookie_repository_cleanup_expired_cookies_should_delete_only_expired_rows` 与 `cookie_cleanup_scheduler_should_delete_expired_cookie_rows` 覆盖 repository 和 scheduler 两层。
- Cookie domain/path 精细化已确认为 Rust 新架构选择:原版 per-account map 不做浏览器式 path 过滤,Rust 在无历史负担下保留 SQLite `(account_id, domain, name, path)` 唯一键,并按真实 upstream request path 选择 Cookie。这不是回退到原版简单 map 的缺口。
- primary/secondary/code_review rate-limit header 和 `codex.rate_limits` event 的解析与 quota 归一化主路径基本对齐,并能保留已有 credits。
- usage input/output/cached token 的成功路径持久化、窗口统计和重启恢复已对齐到可用水平。
- SSE usage 提取已对齐原版最终 usage 语义,不再对多个事件里重复出现的同一份 usage 做累计;`extract_sse_usage_should_use_completed_response_usage_without_merging_earlier_usage` 覆盖该行为。
- least_used 依赖的 request_count、window_reset_at、quota_limited 字段已经进入运行时调度和数据库恢复路径。
- account retry decision 的失败请求持久 request_count 已对齐原版 release 语义:进入 429/402/403/401/model unsupported/Cloudflare 等账号 fallback/hold 处理时会写入一次无 token 的 request attempt;`v1_responses_should_mark_quota_exhausted_after_402_and_retry_next_account` 覆盖 402 fallback 失败账号计数。
- 非 account retry decision 的最终失败请求持久 request_count 已对齐到原版 release 语义:未进入账号 fallback/hold 的最终错误会写入一次无 token 的 request attempt;`v1_responses_should_record_request_count_when_5xx_retries_are_exhausted` 覆盖 5xx 内部重试 3 次但持久 request_count 只计 1 次。
- transport error 的最终失败请求持久 request_count 已有覆盖;`v1_responses_should_record_request_count_when_http_transport_fails` 证明 HTTP transport 失败也会按原版 release 语义写入一次无 token 的 request attempt。
- dirty quota verification 已对齐原版主语义:`quota_verify_required` 进入 schema、runtime `Account` 和启动恢复路径;本地解除 quota 限制时会把账号标成 dirty;请求前会用 upstream `/usage` 校验 dirty 账号,仍限流时释放并切换账号,最多 5 次,校验失败时保留 dirty 标记并继续当前账号。
- quota window reset 已对齐原版语义:窗口自然过期、passive header reset 漂移和手动 reset usage 都会清空窗口计数;Rust 使用 `window_started_at` 表达当前窗口计数开始/重置时间,而不是再增加一个重复的 `window_counters_reset_at` 字段。手动 reset usage 现在与原版一致保留 `window_reset_at` 和 `limit_window_seconds`,不会破坏后续 least_used 窗口排序。
- image_generation usage 已对齐原版主语义:`tool_usage.image_gen` token 与 host-model token 分开累计;请求声明 `tools: [{type: "image_generation"}]` 且 `image_output_tokens > 0` 记成功,否则记失败;total/window 字段都能持久化并恢复到运行时账号。覆盖测试包括 `extract_usage_reads_image_generation_tool_usage_separately`、`extract_sse_usage_should_read_completed_image_generation_tool_usage`、`sqlite_schema_should_persist_image_generation_usage_columns`、`account_repository_should_accumulate_image_generation_usage_counters`、`account_repository_should_restore_window_usage_into_runtime_pool_accounts`、`v1_responses_should_record_image_generation_usage_when_tool_succeeds`、`v1_responses_should_record_failed_image_generation_attempt_when_tool_has_no_output`。
- `x-codex-active-limit` 已确认不是当前对齐缺口:原版只在注释中提及,实际没有解析、存储或调度消费;Rust 保持相同未使用状态。若未来 OpenAI/Codex upstream 开始实际依赖该字段,应作为新需求同时重新审计原版与 Rust。
未完全对齐:
- 无。
缺口/后续动作:
- 无。
## 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。
- 本轮新增 `tests/codex_serving/responses_websocket.rs::v1_responses_websocket_should_not_implicitly_resume_unmatched_function_call_output`,覆盖 continuation 中的 `function_call_output.call_id` 与已记录 function call 不匹配时不会错误启用 implicit resume。
- 本轮新增 `tests/codex_serving/responses_websocket.rs::v1_responses_websocket_should_not_implicitly_resume_self_contained_function_call_replay`,覆盖 full history 已自包含 `function_call` 与 `function_call_output` 时不会错误启用 implicit resume。
- 本轮新增 `tests/codex_serving/responses_websocket.rs::v1_responses_websocket_should_implicitly_resume_after_sqlite_affinity_restore`,覆盖真实请求写入 SQLite session affinity 后,重建 AppState、恢复账号池和 affinity,再发 full-history continuation 仍会设置 `previous_response_id` 并截取 continuation input。
结论:已对齐。
已对齐:
- 显式 `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。
- missing/unmatched function call output、self-contained function call replay、SQLite restore 后继续 implicit resume 的端到端测试已补齐,覆盖了之前仅有实现但缺少端到端证明的边界。
已对齐:
- 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`。
未完全对齐:
- 无。
非目标:
- usage hint 不移植。它在原版只影响非 OpenAI cache token 展示/估算,不影响 OpenAI chat translator 或 Responses passthrough;Rust 只保留 OpenAI 链路时不需要该兼容层。
缺口/后续动作:
- 无。
-119
View File
@@ -1,119 +0,0 @@
# OpenAI Codex 原版链路对齐审计
日期:2026-06-19
范围:对比原版 Node.js 仓库 `/home/zyy/桌面/Codes/codex-proxy`,审计 Rust 版的安全链路、请求链路、响应链路。这里的“代理”不包含 IP 代理、VPN、`HttpsProxyAgent`、本地代理探测等网络代理能力,这部分明确不移植。
## 结论
当前 Rust 版已补齐关键安全模拟字段、默认 WebSocket 上游请求、稳定 `prompt_cache_key`、`previous_response_id` 账号亲和性、WS 首帧错误分类、WS 实时边收边转发、账号 + conversation 维度的 WS 连接复用、implicit resume、strip-and-retry、reasoning replay。IP 代理/VPN 能力明确不移植。
本轮已补齐请求发送细节和账号生命周期差异:自动 Cookie 捕获白名单、`Max-Age` 优先级、账号刷新/管理状态变化后的 WS pool 驱逐、`request_interval_ms` 发送前 stagger、`least_used` 的 reset 缺失排序。当前没有保留的 OpenAI/Codex 请求链路迁移阻塞项;IP 代理/VPN 仍是明确非目标。
## 1. 安全链路和指纹
原版证据:
- `src/fingerprint/manager.ts` 构造 `Authorization`、`ChatGPT-Account-Id`、`originator`、`User-Agent`、`sec-ch-ua`、默认浏览器头和 header order。
- `src/proxy/codex-api.ts` 在 WS/HTTP 两条路径都追加 `OpenAI-Beta`、`x-openai-internal-codex-residency`、`x-client-request-id`、`x-codex-installation-id`、`session_id`、`x-codex-window-id`、turn metadata、parent thread、`x-openai-subagent`。
- `buildCodexClientMetadata()` 会把 installation/window/turn/parent 写入 body 的 `client_metadata`,不是只发 header。
Rust 状态:
- 到位:`src/codex/gateway/transport/headers.rs` 构造账号、residency、request id、UA、sec-ch、默认 header 和顺序。
- 到位:`src/codex/gateway/transport/http_client.rs` 将 installation/window/turn/parent/subagent 同步到 header 和 `client_metadata`。
- 到位:`src/codex/gateway/installation.rs` 兼容读取 `~/.codex/installation_id`,否则落库生成。
- 到位:`src/runtime/bootstrap.rs` 从数据库加载自动更新指纹,避免实际请求继续使用硬编码默认值。
- 边界:Rust 使用 `reqwest + rustls` 传输,未声称复制原版 native transport 的完整 Chrome TLS 指纹。IP 代理/VPN 相关能力不属于移植范围。
## 2. 请求链路
原版证据:
- `src/routes/responses.ts` 默认 `codexRequest.useWebSocket = true`。
- `stable-conversation-key.ts` 用 model、instructions、首条 user 文本派生稳定 conversation key。
- `proxy-session-context.ts` / `proxy-request-preparation.ts` 确保 `prompt_cache_key` 总是写入上游请求。
- `session-affinity.ts` 记录 `response_id -> entryId/conversationId/turnState`,后续 `previous_response_id` 优先回到原账号。
Rust 状态:
- 到位:普通 `/v1/responses` 默认走 WebSocket,上游失败且是传输/不支持类错误才降级 HTTP SSE。
- 到位:`src/codex/gateway/identity.rs` 已增加稳定 conversation key 派生,空请求使用 UUID 防止所有空请求共享同一缓存链。
- 到位:请求发上游前会确保 `prompt_cache_key`,再按账号作用域派生成 `cp_*`,并同步到 `session_id` / `x-client-request-id`。
- 到位:`src/codex/serving/dispatch/affinity.rs` 已接入 runtime;成功 response 会记录 affinity,显式 `previous_response_id` 会优先选择原账号并继承原 conversation identity。
## 3. 响应链路
原版证据:
- `ws-transport.ts` 打开 WS 后返回 `ReadableStream`,把每个 JSON frame 包装成 SSE 事件。
- 首帧 `error` / `response.failed` 如属于可轮换账号错误,会转成 `CodexApiError` 状态码,让统一重试/换账号逻辑接管。
- 未知错误码继续作为 SSE 透传,避免误轮换。
Rust 状态:
- 到位:WS frame 会转成 SSE 文本,客户端仍看到 `text/event-stream`。
- 到位:WS 握手状态码、`retry-after`、cookie、rate-limit header 会被捕获。
- 到位:WS 首帧 `usage_limit_reached`、`rate_limit_exceeded`、`quota_exhausted`、`token_expired`、`banned`、`previous_response_not_found` 等会映射成对应 HTTP 状态,不走 HTTP SSE fallback。
- 到位:WS streaming path 在首个非错误 frame 到达后立即返回客户端响应,后续 frame 边收边转成 SSE;usage、affinity、event log 在 body stream 收尾阶段记录。
## 4. WebSocket 连接池
原版证据:
- `ws-pool.ts` 说明 upstream LB 以物理 WS 连接做后端粘滞,连接复用能保持 prompt cache。
- 默认 `maxAgeMs = 3_300_000`,即 55 分钟。
- key 以账号和 conversation 维度复用,一条 WS 严格单 in-flight,busy/cap/dead 时旁路 one-shot。
Rust 状态:
- 到位:`src/codex/gateway/transport/websocket.rs` 增加进程内 `CodexWebSocketPool`,默认 55 分钟过期,默认每 25 秒维护空闲连接。
- 到位:池 key 由 base URL、本地账号和派生后的 conversation/prompt cache key 组成;同 key busy、账号连接数达到上限或 pool disabled/shutdown 时旁路 one-shot,避免排队和死锁。
- 到位:复用连接如果在首帧前发现已被对端关闭,会丢弃后用 fresh one-shot WS 重试一次;mid-stream 提前关闭会向客户端 stream 报错,不做重试。
- 到位:空闲连接支持 keepalive ping/pong 和 liveness timeout,周期 sweep 会关闭过期、无响应或被 shutdown/evict 的连接;`websocket_connection_limit_reached` 会按连接级 fatal 错误剔除。
- 到位:被动 rate-limit、上游 fallback 状态变更、refresh 成功/失败、管理端 disable/delete/batch lifecycle 都会驱逐该账号的 WS;`AccountService` 和 `CodexUpstreamService` 使用同一个应用级共享 pool。
## 5. Cookie 捕获语义
原版证据:
- `src/proxy/cookie-jar.ts` 的 `CAPTURABLE_COOKIE_NAMES` 只包含 `cf_clearance`。
- 自动捕获 `Set-Cookie` 会跳过 `__cf_bm`;管理端手动 `set()` 不受白名单限制。
- `Max-Age` 会优先于 `Expires`,`Max-Age<=0` 代表立即过期。
Rust 状态:
- 到位:`CookieRepository::capture_set_cookie()` 自动捕获只允许 `cf_clearance`。
- 到位:`parse_set_cookie()` 支持 `Max-Age`,并让它优先于 `Expires`。
- 到位:手动 `set_cookie_header()` 可以持久化任意 Cookie,这一点已保留。
## 6. 请求发送节流
原版证据:
- `routes/shared/proxy-handler.ts` 获取账号后调用 `staggerIfNeeded(acquired.prevSlotMs)`。
- fallback 到备用账号后同样会在发送上游请求前 stagger。
Rust 状态:
- 到位:`AccountPool::acquire_with()` 返回 `previous_slot_at`。
- 到位:`CodexUpstreamService::acquire_account*()` 保留完整 `AcquiredAccount`,普通请求、stream 请求和 fallback 账号请求在发送上游前按 `auth.request_interval_ms` sleep。
## 7. 账号调度排序
原版证据:
- `least_used` 的优先级是 quota-exhausted 降权、两侧都有 `window_reset_at` 时比较 reset、`request_count`、LRU。
Rust 状态:
- 到位:quota 降权、两侧都有 `window_reset_at` 时比较 reset、`request_count` 和 LRU 已实现。
- 到位:任一侧缺少 reset 时继续比较 `request_count`,不再把 `Some(window_reset_at)` 永远排在 `None` 前面。
## 8. Responses 续接状态机
原版证据:
- shared handler 包含 implicit resume、strip-and-retry、reasoning replay 的状态机。
Rust 状态:
- 到位:显式 `previous_response_id` 会强制 WebSocket 并优先回到原账号。
- 到位:implicit resume 会基于已记录 affinity/replay 条目续接同账号、同 conversation、同变体历史。
- 到位:strip-and-retry 覆盖 `previous_response_not_found`、unanswered function call、invalid encrypted reasoning replay,并会在需要时恢复完整历史。
- 到位:reasoning replay 缓存、记录、驱逐和 SQLite affinity restore 后续接均由 crate-local tests 覆盖。
## 9. 非目标:IP 代理/VPN
原版包含本地代理探测、`proxy_url`、`HttpsProxyAgent`、账号代理池等能力。Rust 版不移植这些 IP 代理/VPN 功能,也不新增 `proxy_url` 配置。安全链路只关注 Codex/OpenAI 请求自身的身份、指纹、Cookie、header、body metadata 和 WS 语义。
## 当前验证点
- `crates/adapters/tests/codex.rs` 覆盖默认 WS、opening/header/payload 捕获、WS 安全字段、握手错误、首帧错误分类、deflate、ping、pool、idle timeout、rate-limit/metadata 捕获。
- `crates/server/tests/openai_chat_upstream.rs` 覆盖默认 WS 上游、派生 `prompt_cache_key`、`client_metadata` 安全字段、`previous_response_id` 原账号亲和性、WS 首帧实时返回、同 conversation 连接复用、限流/刷新路径、implicit resume、strip-and-retry、reasoning replay。
- `crates/core/tests/protocol.rs` 覆盖 WebSocket 纯协议转换、错误分类、Responses payload 顺序、事件形状校验和 replay 相关元数据抽取。
- `crates/server/tests/openai_diagnostics_routes.rs` 覆盖 `/debug/diagnostics`、`/debug/fingerprint`、`/debug/upstream` 本地诊断和 secret redaction。

Some files were not shown because too many files have changed in this diff Show More