codex: scope cookies

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-15 11:03:56 +08:00
co-authored by Codex
parent 44c3306023
commit e8557932bd
10 changed files with 278 additions and 49 deletions
+5 -4
View File
@@ -30,7 +30,7 @@ Rust 仓库:`/home/zyy/Codes/codex-proxy-rs`
- `/v1/responses` 请求构造仍存在非 `/v1` alias route、`use_websocket:false` 本地扩展等差异;`/v1/responses/review` 强制 review subagent 和 `/v1/responses/compact` 已补齐。
- response 转换和错误恢复仍缺最终错误外壳对齐;tuple schema request conversion/reconvert、streaming premature close 合成、reasoning replay、implicit resume failure restore、`response.failed`/`error` 的 429/402/403 分类、账号 fallback、`previous_response_not_found`/unanswered function call strip-and-retry、5xx same-account retry、CF path-block 404、model unsupported fallback、401 token invalid fallback 已补齐。
- session affinity 的显式续链、implicit resume 主路径、implicit resume failure restore、reasoning replay cache、function call continuation 预检、variant_hash 基础算法、variant identity 和 OpenAI chat `user` conversation identity 已接入。
- rate-limit/usage/cookie 持久化主路径已接近;dirty quota verification、失败请求持久计数和 image_generation usage 已补齐,剩余差异集中在 Cookie domain/path、quota window reset 细节和双方都未解析的 active-limit header。
- rate-limit/usage/cookie 持久化主路径已接近;dirty quota verification、失败请求持久计数、image_generation usage 和 Cookie domain/path 语义已补齐,剩余差异集中在 quota window reset 细节和双方都未解析的 active-limit header。
明确不纳入缺口:IP 代理、VPN、本地代理探测、`HttpsProxyAgent`、账号代理池。
@@ -467,7 +467,9 @@ Rust 证据:
Rust 证据:
- `src/codex/accounts/cookies/repository.rs:1-111` cookie 存在 SQLite `account_cookies`,value 加密,唯一键为 `(account_id, domain, name, path)`;自动捕获白名单只有 `cf_clearance`。
- `src/codex/accounts/cookies/repository.rs:70-102` 构造 Cookie header 时按 domain 过滤并跳过过期 cookie;`delete_account_cookies()` 支持按账号清理。
- `src/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`。
@@ -497,6 +499,7 @@ Rust 证据:
- 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` 覆盖该行为。
@@ -508,12 +511,10 @@ Rust 证据:
- 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`。
未完全对齐:
- Cookie domain/path 语义不同。原版 CookieJar 是 per-account 简单 map,不按 domain/path 过滤;Rust 按 domain/path 存储和匹配。对 `chatgpt.com` 主链路更严格,但不是原版逐字行为。
- quota window reset 细节不完全一致。原版 reset 后设置 `window_counters_reset_at`;Rust 设置 `window_started_at` 并触发 `quota_verify_required`,但没有 `window_counters_reset_at` 字段。
- passive header 文档提到 `x-codex-active-limit`,原版和 Rust 当前都没有实际解析该 header;如果 Codex Desktop 当前依赖 active-limit,这两边都需要重新确认。
缺口/后续动作:
- 明确 cookie domain/path 精细化是 Rust 新架构选择还是需要回到原版 per-account map;如果保留,应补 domain/path 行为测试。
- 若 OpenAI/Codex 当前返回 `x-codex-active-limit`,补解析和 quota_json 存储;否则将其记录为双方未使用字段。
## 10. session affinity、implicit resume、reasoning replay
+70 -4
View File
@@ -73,9 +73,19 @@ impl CookieRepository {
&self,
account_id: &str,
request_domain: &str,
) -> CookieResult<Option<String>> {
self.cookie_header_for_request(account_id, request_domain, "/")
.await
}
pub async fn cookie_header_for_request(
&self,
account_id: &str,
request_domain: &str,
request_path: &str,
) -> CookieResult<Option<String>> {
let rows = sqlx::query(
"select domain, name, value_cipher, expires_at from account_cookies where account_id = ? order by name asc",
"select domain, name, value_cipher, path, expires_at from account_cookies where account_id = ?",
)
.bind(account_id)
.fetch_all(&self.pool)
@@ -87,18 +97,38 @@ impl CookieRepository {
if !domain_matches(request_domain, &domain) {
continue;
}
let path = row.get::<String, _>("path");
if !path_matches(request_path, &path) {
continue;
}
if cookie_is_expired(row.get::<Option<String>, _>("expires_at").as_deref(), now) {
continue;
}
let name = row.get::<String, _>("name");
let value_cipher = row.get::<String, _>("value_cipher");
let value = self.secret_box.decrypt(&value_cipher)?;
pairs.push(format!("{name}={}", value.expose_secret()));
pairs.push(CookieHeaderPair {
path_len: path.len(),
name: name.clone(),
value: format!("{name}={}", value.expose_secret()),
});
}
if pairs.is_empty() {
Ok(None)
} else {
Ok(Some(pairs.join("; ")))
pairs.sort_by(|left, right| {
right
.path_len
.cmp(&left.path_len)
.then_with(|| left.name.cmp(&right.name))
});
Ok(Some(
pairs
.into_iter()
.map(|pair| pair.value)
.collect::<Vec<_>>()
.join("; "),
))
}
}
@@ -139,6 +169,12 @@ struct ParsedCookie {
expires_at: Option<String>,
}
struct CookieHeaderPair {
path_len: usize,
name: String,
value: String,
}
fn parse_set_cookie(raw: &str) -> Option<ParsedCookie> {
let mut parts = raw.split(';').map(str::trim);
let (name, value) = parts.next()?.split_once('=')?;
@@ -151,7 +187,7 @@ fn parse_set_cookie(raw: &str) -> Option<ParsedCookie> {
};
match attribute.trim().to_ascii_lowercase().as_str() {
"domain" => domain = value.trim_start_matches('.').to_string(),
"path" => path = value.to_string(),
"path" => path = normalize_cookie_path(value),
"max-age" => {
if let Ok(seconds) = value.parse::<i64>() {
expires_at = Some(max_age_expires_at(seconds));
@@ -207,6 +243,36 @@ fn domain_matches(request_domain: &str, cookie_domain: &str) -> bool {
.is_some_and(|prefix| prefix.ends_with('.'))
}
fn normalize_cookie_path(path: &str) -> String {
let path = path.trim();
if path.starts_with('/') {
path.to_string()
} else {
"/".to_string()
}
}
fn path_matches(request_path: &str, cookie_path: &str) -> bool {
let request_path = normalize_request_path(request_path);
let cookie_path = normalize_cookie_path(cookie_path);
request_path == cookie_path
|| (request_path.starts_with(&cookie_path)
&& (cookie_path.ends_with('/')
|| request_path
.as_bytes()
.get(cookie_path.len())
.is_some_and(|byte| *byte == b'/')))
}
fn normalize_request_path(path: &str) -> String {
let path = path.trim();
if path.starts_with('/') {
path.to_string()
} else {
format!("/{path}")
}
}
fn cookie_is_expired(expires_at: Option<&str>, now: DateTime<Utc>) -> bool {
expires_at
.and_then(parse_cookie_expires_at)
+3 -1
View File
@@ -12,6 +12,7 @@ use crate::{
model::AccountStatus,
repository::{AccountRepository, StoredAccount},
},
codex::gateway::transport::endpoints::primary_usage_request_path,
codex::gateway::transport::http_client::{
build_reqwest_client, CodexBackendClient, CodexClientError, CodexRequestContext,
},
@@ -111,10 +112,11 @@ pub(super) async fn fetch_account_usage(
async fn account_cookie_header(service: &AccountService, account_id: &str) -> Option<String> {
let domain = request_domain(&service.config.api.base_url)?;
let request_path = primary_usage_request_path(&service.config.api.base_url);
service
.cookie_repository
.as_ref()?
.cookie_header(account_id, &domain)
.cookie_header_for_request(account_id, &domain, &request_path)
.await
.ok()
.flatten()
+61
View File
@@ -0,0 +1,61 @@
use reqwest::Url;
pub(crate) const CODEX_RESPONSES_PATH: &str = "/codex/responses";
pub(crate) const CODEX_RESPONSES_COMPACT_PATH: &str = "/codex/responses/compact";
pub(crate) const CODEX_USAGE_PATH: &str = "/codex/usage";
pub(crate) const CODEX_USAGE_API_PATH: &str = "/api/codex/usage";
pub(crate) const WHAM_USAGE_PATH: &str = "/wham/usage";
pub(crate) fn endpoint_url(base_url: &str, endpoint_path: &str) -> String {
format!(
"{}/{}",
base_url.trim_end_matches('/'),
endpoint_path.trim_start_matches('/')
)
}
pub(crate) fn endpoint_request_path(base_url: &str, endpoint_path: &str) -> String {
let endpoint_path = endpoint_path.trim_start_matches('/');
let base_path = Url::parse(base_url)
.ok()
.map(|url| url.path().trim_end_matches('/').to_string())
.filter(|path| !path.is_empty())
.unwrap_or_default();
if base_path.is_empty() {
format!("/{endpoint_path}")
} else {
format!("{base_path}/{endpoint_path}")
}
}
pub(crate) fn usage_endpoint_urls(base_url: &str) -> Vec<String> {
usage_endpoint_paths(base_url)
.into_iter()
.map(|path| endpoint_url(base_url, path))
.collect()
}
pub(crate) fn primary_usage_request_path(base_url: &str) -> String {
let endpoint_path = usage_endpoint_paths(base_url)
.into_iter()
.next()
.unwrap_or(CODEX_USAGE_API_PATH);
endpoint_request_path(base_url, endpoint_path)
}
fn usage_endpoint_paths(base_url: &str) -> Vec<&'static str> {
if has_backend_api_base_path(base_url) {
vec![WHAM_USAGE_PATH, CODEX_USAGE_PATH]
} else {
vec![CODEX_USAGE_API_PATH, CODEX_USAGE_PATH]
}
}
fn has_backend_api_base_path(base_url: &str) -> bool {
Url::parse(base_url).ok().is_some_and(|url| {
url.path()
.split('/')
.any(|segment| segment == "backend-api")
})
}
+6 -13
View File
@@ -20,6 +20,9 @@ use uuid::Uuid;
use crate::{
codex::gateway::fingerprint::model::Fingerprint,
codex::gateway::transport::{
endpoints::{
endpoint_url, usage_endpoint_urls, CODEX_RESPONSES_COMPACT_PATH, CODEX_RESPONSES_PATH,
},
headers::build_ordered_codex_base_headers,
sse::SseError,
types::{CodexCompactRequest, CodexResponsesRequest},
@@ -491,7 +494,7 @@ impl CodexBackendClient {
request: &CodexResponsesRequest,
headers: HeaderMap,
) -> CodexClientResult<ReqwestResponse> {
let url = format!("{}/codex/responses", self.base_url);
let url = endpoint_url(&self.base_url, CODEX_RESPONSES_PATH);
Ok(self
.client
.post(url)
@@ -506,7 +509,7 @@ impl CodexBackendClient {
request: &CodexCompactRequest,
headers: HeaderMap,
) -> CodexClientResult<ReqwestResponse> {
let url = format!("{}/codex/responses/compact", self.base_url);
let url = endpoint_url(&self.base_url, CODEX_RESPONSES_COMPACT_PATH);
Ok(self
.client
.post(url)
@@ -517,17 +520,7 @@ impl CodexBackendClient {
}
fn usage_endpoints(&self) -> Vec<String> {
if self.base_url.contains("/backend-api") {
vec![
format!("{}/wham/usage", self.base_url),
format!("{}/codex/usage", self.base_url),
]
} else {
vec![
format!("{}/api/codex/usage", self.base_url),
format!("{}/codex/usage", self.base_url),
]
}
usage_endpoint_urls(&self.base_url)
}
fn request_headers(&self, context: CodexRequestContext<'_>) -> CodexClientResult<HeaderMap> {
+1
View File
@@ -1,3 +1,4 @@
pub mod endpoints;
pub mod headers;
pub mod http_client;
pub mod rate_limits;
+2 -1
View File
@@ -14,6 +14,7 @@ use futures::{channel::mpsc, SinkExt, Stream, StreamExt};
use reqwest::{header::HeaderMap, StatusCode};
use crate::codex::gateway::transport::{
endpoints::{endpoint_url, CODEX_RESPONSES_PATH},
rate_limits::{parse_rate_limits_event_raw, rate_limits_to_header_pairs, ParsedRateLimits},
types::CodexResponsesRequest,
};
@@ -471,7 +472,7 @@ fn build_ws_request(
}
fn websocket_url(base_url: &str) -> String {
let url = format!("{}/codex/responses", base_url.trim_end_matches('/'));
let url = endpoint_url(base_url, CODEX_RESPONSES_PATH);
if let Some(rest) = url.strip_prefix("https://") {
format!("wss://{rest}")
} else if let Some(rest) = url.strip_prefix("http://") {
+37 -26
View File
@@ -48,6 +48,10 @@ use crate::{
codex::gateway::installation_id::get_installation_id,
codex::gateway::protocol::codex_to_openai::openai_error,
codex::gateway::transport::{
endpoints::{
endpoint_request_path, primary_usage_request_path, CODEX_RESPONSES_COMPACT_PATH,
CODEX_RESPONSES_PATH,
},
http_client::{
build_reqwest_client, CodexBackendClient, CodexBackendStream,
CodexBackendWebSocketStream, CodexClientError, CodexCompactResponse,
@@ -90,7 +94,6 @@ use self::reasoning_replay::ReasoningReplayCache;
use self::stream_audit::{StreamAudit, WebSocketStreamAudit};
use self::{
limits::apply_rate_limit_headers_with_deps,
routing::request_domain,
stream::{
completed_response_metadata, ensure_stream_metadata, has_terminal_sse_event,
premature_close_failed_event, TupleStreamReconverter,
@@ -471,6 +474,28 @@ async fn record_failed_request_attempt_with_deps(
}
}
async fn upstream_cookie_header_for_endpoint(
deps: &CodexUpstreamDependencies,
account_id: &str,
endpoint_path: &str,
) -> Option<String> {
let request_path = endpoint_request_path(&deps.config.api.base_url, endpoint_path);
upstream_cookie_header_for_request_path(deps, account_id, &request_path).await
}
async fn upstream_cookie_header_for_request_path(
deps: &CodexUpstreamDependencies,
account_id: &str,
request_path: &str,
) -> Option<String> {
let repo = deps.cookie_repository.as_ref()?;
let domain = routing::request_domain(&deps.config.api.base_url)?;
repo.cookie_header_for_request(account_id, &domain, request_path)
.await
.ok()
.flatten()
}
struct DirtyQuotaVerification {
limit_reached: bool,
}
@@ -495,11 +520,9 @@ async fn fetch_dirty_account_usage_with_deps(
account: &Account,
request_id: &str,
) -> Result<Value, CodexClientError> {
let request_domain = request_domain(&deps.config.api.base_url);
let cookie_header = match (deps.cookie_repository.as_ref(), request_domain.as_deref()) {
(Some(repo), Some(domain)) => repo.cookie_header(&account.id, domain).await.ok().flatten(),
_ => None,
};
let usage_path = primary_usage_request_path(&deps.config.api.base_url);
let cookie_header =
upstream_cookie_header_for_request_path(deps, &account.id, &usage_path).await;
let client = CodexBackendClient::new(
build_reqwest_client(deps.config.tls.force_http11)?,
deps.config.api.base_url.clone(),
@@ -1157,11 +1180,8 @@ async fn send_codex_request(
account: &Account,
request_id: &str,
) -> Result<crate::codex::gateway::transport::http_client::CodexBackendResponse, CodexClientError> {
let request_domain = request_domain(&deps.config.api.base_url);
let cookie_header = match (deps.cookie_repository.as_ref(), request_domain.as_deref()) {
(Some(repo), Some(domain)) => repo.cookie_header(&account.id, domain).await.ok().flatten(),
_ => None,
};
let cookie_header =
upstream_cookie_header_for_endpoint(deps, &account.id, CODEX_RESPONSES_PATH).await;
let account_scope = &account.id;
let identity = build_conversation_identity(
@@ -1206,11 +1226,8 @@ async fn send_compact_request(
account: &Account,
request_id: &str,
) -> Result<CodexCompactResponse, CodexClientError> {
let request_domain = request_domain(&deps.config.api.base_url);
let cookie_header = match (deps.cookie_repository.as_ref(), request_domain.as_deref()) {
(Some(repo), Some(domain)) => repo.cookie_header(&account.id, domain).await.ok().flatten(),
_ => None,
};
let cookie_header =
upstream_cookie_header_for_endpoint(deps, &account.id, CODEX_RESPONSES_COMPACT_PATH).await;
let installation_id = get_installation_id(Some(&deps.config.database.url));
let client = CodexBackendClient::new(
@@ -1261,11 +1278,8 @@ async fn send_codex_stream_request(
account: &Account,
request_id: &str,
) -> Result<CodexBackendStream, CodexClientError> {
let request_domain = request_domain(&deps.config.api.base_url);
let cookie_header = match (deps.cookie_repository.as_ref(), request_domain.as_deref()) {
(Some(repo), Some(domain)) => repo.cookie_header(&account.id, domain).await.ok().flatten(),
_ => None,
};
let cookie_header =
upstream_cookie_header_for_endpoint(deps, &account.id, CODEX_RESPONSES_PATH).await;
// Build conversation identity for session affinity
// Use account.id as the scope since entry_id doesn't exist
@@ -1371,11 +1385,8 @@ async fn send_codex_websocket_stream_request(
account: &Account,
request_id: &str,
) -> Result<CodexBackendWebSocketStream, CodexClientError> {
let request_domain = request_domain(&deps.config.api.base_url);
let cookie_header = match (deps.cookie_repository.as_ref(), request_domain.as_deref()) {
(Some(repo), Some(domain)) => repo.cookie_header(&account.id, domain).await.ok().flatten(),
_ => None,
};
let cookie_header =
upstream_cookie_header_for_endpoint(deps, &account.id, CODEX_RESPONSES_PATH).await;
let account_scope = &account.id;
let identity = build_conversation_identity(
+30
View File
@@ -66,6 +66,36 @@ async fn cookie_repository_encrypts_and_replays_account_scoped_cookies() {
);
}
#[tokio::test]
async fn cookie_repository_should_scope_cookie_replay_by_domain_and_path() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("path-scoped-cookies.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
seed_account(&pool, "acct_a").await;
let repo = CookieRepository::new(pool, SecretBox::new([11u8; 32]));
repo.capture_set_cookie("acct_a", "cf_clearance=root; Domain=.chatgpt.com; Path=/")
.await
.unwrap();
repo.capture_set_cookie("acct_a", "cf_clearance=api; Domain=.chatgpt.com; Path=/api")
.await
.unwrap();
assert_eq!(
repo.cookie_header_for_request("acct_a", "chatgpt.com", "/codex/responses")
.await
.unwrap(),
Some("cf_clearance=root".to_string())
);
assert_eq!(
repo.cookie_header_for_request("acct_a", "chatgpt.com", "/api/codex/usage")
.await
.unwrap(),
Some("cf_clearance=api; cf_clearance=root".to_string())
);
}
#[tokio::test]
async fn cookie_repository_should_only_auto_capture_cf_clearance() {
let dir = tempfile::tempdir().unwrap();
+63
View File
@@ -429,6 +429,69 @@ async fn v1_responses_should_record_failed_image_generation_attempt_when_tool_ha
assert_eq!(usage, (0, 1, 0, 1));
}
#[tokio::test]
async fn v1_responses_should_scope_upstream_cookie_by_codex_response_path() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(COMPLETED_USAGE_SSE),
)
.mount(&server)
.await;
let imported = build_imported_app(server.uri()).await;
let cookie_domain = reqwest::Url::parse(&server.uri())
.unwrap()
.host_str()
.unwrap()
.to_string();
let cookie_repo = CookieRepository::new(imported.pool.clone(), imported.secret_box.clone());
cookie_repo
.capture_set_cookie(
"acct_imported",
&format!("cf_clearance=root; Domain={cookie_domain}; Path=/"),
)
.await
.unwrap();
cookie_repo
.capture_set_cookie(
"acct_imported",
&format!("cf_clearance=codex; Domain={cookie_domain}; Path=/codex"),
)
.await
.unwrap();
let response = imported
.app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header(
"authorization",
format!("Bearer {}", imported.client_api_key),
)
.header("content-type", "application/json")
.body(Body::from(
r#"{"model":"gpt-5.5","input":[],"stream":false,"use_websocket":false}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let requests = server.received_requests().await.unwrap();
let cookie_header = requests
.iter()
.find(|request| request.url.path() == "/codex/responses")
.and_then(|request| request.headers.get("cookie"))
.and_then(|value| value.to_str().ok());
assert_eq!(cookie_header, Some("cf_clearance=codex; cf_clearance=root"));
}
#[tokio::test]
async fn v1_responses_should_passively_cache_rate_limit_headers() {
let server = MockServer::start().await;