mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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,3 +1,4 @@
|
||||
pub mod endpoints;
|
||||
pub mod headers;
|
||||
pub mod http_client;
|
||||
pub mod rate_limits;
|
||||
|
||||
@@ -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://") {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user