use crate::types::AccountsCheckResponse; use crate::types::CodeTaskDetailsResponse; use crate::types::CodexUserSettingsResponse; use crate::types::CodexWorkspaceMessagesResponse; use crate::types::ConfigBundleResponse; use crate::types::PaginatedListTaskListItem; use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind; use crate::types::RateLimitStatusPayload; use crate::types::TokenUsageProfile; use crate::types::TurnAttemptsSiblingTurnsResponse; use anyhow::Result; use codex_api::SharedAuthProvider; use codex_http_client::ClientRouteClass; use codex_http_client::HttpClientFactory; use codex_http_client::RouteAwareClientPool; use codex_http_client::RouteAwareRequestBuilder; use codex_login::CodexAuth; use codex_login::default_client::get_codex_user_agent; use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::protocol::CreditsSnapshot; use codex_protocol::protocol::RateLimitReachedType; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; use codex_protocol::protocol::SpendControlLimitSnapshot; use http::Method; use http::StatusCode; use http::header::CACHE_CONTROL; use http::header::CONTENT_TYPE; use http::header::HeaderMap; use http::header::HeaderName; use http::header::HeaderValue; use http::header::USER_AGENT; use serde::Serialize; use serde::de::DeserializeOwned; use std::fmt; pub(crate) mod analytics; mod chatgpt_turn_cost; pub(crate) mod plan_history; pub(crate) mod profile; mod rate_limit_resets; pub(crate) mod task_usage; mod thread_usage; pub(crate) mod turn_usage; pub use chatgpt_turn_cost::ChatgptThreadTurnCosts; pub use chatgpt_turn_cost::ChatgptTurnCost; pub use thread_usage::ThreadUsage; pub use thread_usage::ThreadUsageBreakdownGroup; #[derive(Debug)] pub enum RequestError { UnexpectedStatus { method: String, url: String, status: StatusCode, content_type: String, body: String, }, Other(anyhow::Error), } impl RequestError { pub fn status(&self) -> Option { match self { Self::UnexpectedStatus { status, .. } => Some(*status), Self::Other(_) => None, } } pub fn is_unauthorized(&self) -> bool { self.status() == Some(StatusCode::UNAUTHORIZED) } } impl fmt::Display for RequestError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::UnexpectedStatus { method, url, status, content_type, body, } => write!( f, "{method} {url} failed: {status}; content-type={content_type}; body={body}" ), Self::Other(err) => write!(f, "{err}"), } } } impl std::error::Error for RequestError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::UnexpectedStatus { .. } => None, Self::Other(err) => Some(err.as_ref()), } } } impl From for RequestError { fn from(err: anyhow::Error) -> Self { Self::Other(err) } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum AddCreditsNudgeCreditType { Credits, UsageLimit, } #[derive(Serialize)] struct SendAddCreditsNudgeEmailRequest { credit_type: AddCreditsNudgeCreditType, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PathStyle { /// /api/codex/… CodexApi, /// /wham/… ChatGptApi, } impl PathStyle { pub fn from_base_url(base_url: &str) -> Self { if base_url.contains("/backend-api") { PathStyle::ChatGptApi } else { PathStyle::CodexApi } } } #[derive(Clone)] pub struct Client { base_url: String, http: RouteAwareClientPool, auth_provider: SharedAuthProvider, user_agent: Option, chatgpt_account_id: Option, chatgpt_account_is_fedramp: bool, path_style: PathStyle, } impl fmt::Debug for Client { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Client") .field("base_url", &self.base_url) .field("auth_provider", &"") .field("user_agent", &self.user_agent) .field("chatgpt_account_id", &self.chatgpt_account_id) .field( "chatgpt_account_is_fedramp", &self.chatgpt_account_is_fedramp, ) .field("path_style", &self.path_style) .finish_non_exhaustive() } } impl Client { pub fn new(base_url: impl Into, http_client_factory: HttpClientFactory) -> Self { let http = RouteAwareClientPool::with_chatgpt_cloudflare_cookies_without_request_logging( http_client_factory, ClientRouteClass::Api, ); Self::with_http(base_url.into(), http) } /// Creates a client that never forwards its credentials to a redirect destination. pub fn new_without_redirects( base_url: impl Into, http_client_factory: HttpClientFactory, ) -> Self { let http = RouteAwareClientPool::with_chatgpt_cloudflare_cookies_without_redirects_or_request_logging( http_client_factory, ClientRouteClass::Api, ); Self::with_http(base_url.into(), http) } fn with_http(mut base_url: String, http: RouteAwareClientPool) -> Self { // Normalize common ChatGPT hostnames to include /backend-api so we hit the WHAM paths. // Also trim trailing slashes for consistent URL building. while base_url.ends_with('/') { base_url.pop(); } if (base_url.starts_with("https://chatgpt.com") || base_url.starts_with("https://chat.openai.com")) && !base_url.contains("/backend-api") { base_url = format!("{base_url}/backend-api"); } let path_style = PathStyle::from_base_url(&base_url); Self { base_url, http, auth_provider: codex_model_provider::unauthenticated_auth_provider(), user_agent: None, chatgpt_account_id: None, chatgpt_account_is_fedramp: false, path_style, } } pub fn from_auth( base_url: impl Into, auth: &CodexAuth, http_client_factory: HttpClientFactory, ) -> Self { Self::new(base_url, http_client_factory) .with_user_agent(get_codex_user_agent()) .with_auth_provider(codex_model_provider::auth_provider_from_auth(auth)) } pub fn with_auth_provider(mut self, auth: SharedAuthProvider) -> Self { self.auth_provider = auth; self } pub fn with_user_agent(mut self, ua: impl Into) -> Self { if let Ok(hv) = HeaderValue::from_str(&ua.into()) { self.user_agent = Some(hv); } self } pub fn with_chatgpt_account_id(mut self, account_id: impl Into) -> Self { self.chatgpt_account_id = Some(account_id.into()); self } pub fn with_fedramp_routing_header(mut self) -> Self { self.chatgpt_account_is_fedramp = true; self } pub fn with_path_style(mut self, style: PathStyle) -> Self { self.path_style = style; self } fn headers(&self) -> HeaderMap { let mut h = HeaderMap::new(); if let Some(ua) = &self.user_agent { h.insert(USER_AGENT, ua.clone()); } else { h.insert(USER_AGENT, HeaderValue::from_static("codex-cli")); } self.auth_provider.add_auth_headers(&mut h); if let Some(acc) = &self.chatgpt_account_id && let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id") && let Ok(hv) = HeaderValue::from_str(acc) { h.insert(name, hv); } if self.chatgpt_account_is_fedramp && let Ok(name) = HeaderName::from_bytes(b"X-OpenAI-Fedramp") { h.insert(name, HeaderValue::from_static("true")); } h } fn request(&self, method: Method, url: &str) -> RouteAwareRequestBuilder { self.http.request(method, url) } async fn exec_request( &self, req: RouteAwareRequestBuilder, method: &str, url: &str, ) -> Result<(String, String)> { let res = req.send().await?; let status = res.status(); let ct = res .headers() .get(CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .unwrap_or("") .to_string(); let body = res.text().await.unwrap_or_default(); if !status.is_success() { anyhow::bail!("{method} {url} failed: {status}; content-type={ct}; body={body}"); } Ok((body, ct)) } async fn exec_request_detailed( &self, req: RouteAwareRequestBuilder, method: &str, url: &str, ) -> std::result::Result<(String, String), RequestError> { let res = req.send().await.map_err(anyhow::Error::from)?; let status = res.status(); let content_type = res .headers() .get(CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .unwrap_or("") .to_string(); let body = res.text().await.unwrap_or_default(); if !status.is_success() { return Err(RequestError::UnexpectedStatus { method: method.to_string(), url: url.to_string(), status, content_type, body, }); } Ok((body, content_type)) } fn decode_json(&self, url: &str, ct: &str, body: &str) -> Result { match serde_json::from_str::(body) { Ok(v) => Ok(v), Err(e) => { anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}"); } } } pub async fn get_rate_limits(&self) -> Result { let snapshots = self.get_rate_limits_many().await?; let preferred = snapshots .iter() .find(|snapshot| snapshot.limit_id.as_deref() == Some("codex")) .cloned(); Ok(preferred.unwrap_or_else(|| snapshots[0].clone())) } pub async fn get_rate_limits_many(&self) -> Result> { Ok(self.get_rate_limits_with_reset_credits().await?.rate_limits) } pub async fn get_accounts_check( &self, ) -> std::result::Result { let url = match self.path_style { PathStyle::CodexApi => format!("{}/api/codex/accounts/check", self.base_url), PathStyle::ChatGptApi => format!("{}/wham/accounts/check", self.base_url), }; let req = self.request(Method::GET, &url).headers(self.headers()); let (body, _) = self.exec_request_detailed(req, "GET", &url).await?; serde_json::from_str(&body) .map_err(|_| RequestError::Other(anyhow::anyhow!("Invalid accounts response."))) } pub async fn get_token_usage_profile(&self) -> Result { let url = self.token_usage_profile_url(); let req = self.request(Method::GET, &url).headers(self.headers()); let (body, ct) = self.exec_request(req, "GET", &url).await?; self.decode_json(&url, &ct, &body) } fn token_usage_profile_url(&self) -> String { match self.path_style { PathStyle::CodexApi => format!("{}/api/codex/profiles/me", self.base_url), PathStyle::ChatGptApi => format!("{}/wham/profiles/me", self.base_url), } } pub async fn send_add_credits_nudge_email( &self, credit_type: AddCreditsNudgeCreditType, ) -> std::result::Result<(), RequestError> { let url = self.send_add_credits_nudge_email_url(); let req = self .request(Method::POST, &url) .headers(self.headers()) .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) .json(&SendAddCreditsNudgeEmailRequest { credit_type }); self.exec_request_detailed(req, "POST", &url).await?; Ok(()) } pub async fn list_tasks( &self, limit: Option, task_filter: Option<&str>, environment_id: Option<&str>, cursor: Option<&str>, ) -> Result { let url = self.list_tasks_url(limit, task_filter, environment_id, cursor)?; let req = self.request(Method::GET, &url).headers(self.headers()); let (body, ct) = self.exec_request(req, "GET", &url).await?; self.decode_json::(&url, &ct, &body) } fn list_tasks_url( &self, limit: Option, task_filter: Option<&str>, environment_id: Option<&str>, cursor: Option<&str>, ) -> Result { let url = match self.path_style { PathStyle::CodexApi => format!("{}/api/codex/tasks/list", self.base_url), PathStyle::ChatGptApi => format!("{}/wham/tasks/list", self.base_url), }; if limit.is_none() && task_filter.is_none() && environment_id.is_none() && cursor.is_none() { return Ok(url); } let mut url = url::Url::parse(&url)?; { let mut query = url.query_pairs_mut(); if let Some(limit) = limit { query.append_pair("limit", &limit.to_string()); } if let Some(task_filter) = task_filter { query.append_pair("task_filter", task_filter); } if let Some(cursor) = cursor { query.append_pair("cursor", cursor); } if let Some(environment_id) = environment_id { query.append_pair("environment_id", environment_id); } } Ok(url.to_string()) } pub async fn get_task_details(&self, task_id: &str) -> Result { let (parsed, _body, _ct) = self.get_task_details_with_body(task_id).await?; Ok(parsed) } pub async fn get_task_details_with_body( &self, task_id: &str, ) -> Result<(CodeTaskDetailsResponse, String, String)> { let url = match self.path_style { PathStyle::CodexApi => format!("{}/api/codex/tasks/{}", self.base_url, task_id), PathStyle::ChatGptApi => format!("{}/wham/tasks/{}", self.base_url, task_id), }; let req = self.request(Method::GET, &url).headers(self.headers()); let (body, ct) = self.exec_request(req, "GET", &url).await?; let parsed: CodeTaskDetailsResponse = self.decode_json(&url, &ct, &body)?; Ok((parsed, body, ct)) } pub async fn list_sibling_turns( &self, task_id: &str, turn_id: &str, ) -> Result { let url = match self.path_style { PathStyle::CodexApi => format!( "{}/api/codex/tasks/{}/turns/{}/sibling_turns", self.base_url, task_id, turn_id ), PathStyle::ChatGptApi => format!( "{}/wham/tasks/{}/turns/{}/sibling_turns", self.base_url, task_id, turn_id ), }; let req = self.request(Method::GET, &url).headers(self.headers()); let (body, ct) = self.exec_request(req, "GET", &url).await?; self.decode_json::(&url, &ct, &body) } /// Fetch the selected cloud-managed config bundle from codex-backend. /// /// `GET /api/codex/config/bundle` (Codex API style) or /// `GET /wham/config/bundle` (ChatGPT backend-api style). pub async fn get_config_bundle( &self, ) -> std::result::Result { let url = match self.path_style { PathStyle::CodexApi => format!("{}/api/codex/config/bundle", self.base_url), PathStyle::ChatGptApi => format!("{}/wham/config/bundle", self.base_url), }; let req = self.request(Method::GET, &url).headers(self.headers()); let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?; self.decode_json::(&url, &ct, &body) .map_err(RequestError::from) } /// Fetch authenticated Codex user settings from the active backend route. /// /// Uses `GET /api/codex/settings/user` for Codex API hosts and /// `GET /wham/settings/user` for ChatGPT `backend-api` hosts. pub async fn get_user_settings( &self, ) -> std::result::Result { let url = self.user_settings_url(); let req = self .request(Method::GET, &url) .headers(self.headers()) .header( CACHE_CONTROL, HeaderValue::from_static("no-cache, no-store"), ); let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?; self.decode_json::(&url, &ct, &body) .map_err(RequestError::from) } pub async fn list_workspace_messages( &self, ) -> std::result::Result { let url = self.workspace_messages_url(); let req = self .request(Method::GET, &url) .headers(self.headers()) .header(CACHE_CONTROL, HeaderValue::from_static("no-store")); let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?; self.decode_json::(&url, &ct, &body) .map_err(RequestError::from) } /// Create a new task (user turn) by POSTing to the appropriate backend path /// based on `path_style`. Returns the created task id. pub async fn create_task(&self, request_body: serde_json::Value) -> Result { let url = match self.path_style { PathStyle::CodexApi => format!("{}/api/codex/tasks", self.base_url), PathStyle::ChatGptApi => format!("{}/wham/tasks", self.base_url), }; let req = self .request(Method::POST, &url) .headers(self.headers()) .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) .json(&request_body); let (body, ct) = self.exec_request(req, "POST", &url).await?; // Extract id from JSON: prefer `task.id`; fallback to top-level `id` when present. match serde_json::from_str::(&body) { Ok(v) => { if let Some(id) = v .get("task") .and_then(|t| t.get("id")) .and_then(|s| s.as_str()) { Ok(id.to_string()) } else if let Some(id) = v.get("id").and_then(|s| s.as_str()) { Ok(id.to_string()) } else { anyhow::bail!( "POST {url} succeeded but no task id found; content-type={ct}; body={body}" ); } } Err(e) => anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}"), } } // rate limit helpers fn rate_limit_snapshots_from_payload( payload: RateLimitStatusPayload, ) -> Vec { let plan_type = Some(Self::map_plan_type(payload.plan_type)); let rate_limit_reached_type = payload .rate_limit_reached_type .flatten() .and_then(|details| Self::map_rate_limit_reached_type(details.kind)); let mut snapshots = vec![Self::make_rate_limit_snapshot( Some("codex".to_string()), /*limit_name*/ None, payload.rate_limit.flatten().map(|details| *details), payload.credits.flatten().map(|details| *details), payload.spend_control.flatten().map(|details| *details), plan_type, rate_limit_reached_type, )]; if let Some(additional) = payload.additional_rate_limits.flatten() { snapshots.extend( additional .into_iter() .map(|details| Self::make_additional_rate_limit_snapshot(details, plan_type)), ); } snapshots } fn make_additional_rate_limit_snapshot( details: codex_backend_openapi_models::models::AdditionalRateLimitDetails, plan_type: Option, ) -> RateLimitSnapshot { Self::make_rate_limit_snapshot( Some(details.metered_feature), Some(details.limit_name), details.rate_limit.flatten().map(|rate_limit| *rate_limit), /*credits*/ None, /*spend_control*/ None, plan_type, /*rate_limit_reached_type*/ None, ) } fn make_rate_limit_snapshot( limit_id: Option, limit_name: Option, rate_limit: Option, credits: Option, spend_control: Option, plan_type: Option, rate_limit_reached_type: Option, ) -> RateLimitSnapshot { let (primary, secondary) = match rate_limit { Some(details) => ( Self::map_rate_limit_window(details.primary_window), Self::map_rate_limit_window(details.secondary_window), ), None => (None, None), }; let spend_control_reached = spend_control.as_ref().map(|details| details.reached); let individual_limit = spend_control .and_then(|details| details.individual_limit.flatten()) .map(|details| Self::map_individual_limit(*details)); RateLimitSnapshot { limit_id, limit_name, normal_model_slug: None, primary, secondary, credits: Self::map_credits(credits), individual_limit, spend_control_reached, plan_type, rate_limit_reached_type, } } fn map_rate_limit_reached_type( kind: BackendRateLimitReachedKind, ) -> Option { match kind { BackendRateLimitReachedKind::RateLimitReached => { Some(RateLimitReachedType::RateLimitReached) } BackendRateLimitReachedKind::WorkspaceOwnerCreditsDepleted => { Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted) } BackendRateLimitReachedKind::WorkspaceMemberCreditsDepleted => { Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) } BackendRateLimitReachedKind::WorkspaceOwnerUsageLimitReached => { Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached) } BackendRateLimitReachedKind::WorkspaceMemberUsageLimitReached => { Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached) } BackendRateLimitReachedKind::Unknown => None, } } fn send_add_credits_nudge_email_url(&self) -> String { match self.path_style { PathStyle::CodexApi => format!( "{}/api/codex/accounts/send_add_credits_nudge_email", self.base_url ), PathStyle::ChatGptApi => { format!( "{}/wham/accounts/send_add_credits_nudge_email", self.base_url ) } } } fn workspace_messages_url(&self) -> String { match self.path_style { PathStyle::CodexApi => format!("{}/api/codex/workspace-messages", self.base_url), PathStyle::ChatGptApi => format!("{}/wham/workspace-messages", self.base_url), } } fn user_settings_url(&self) -> String { match self.path_style { PathStyle::CodexApi => format!("{}/api/codex/settings/user", self.base_url), PathStyle::ChatGptApi => format!("{}/wham/settings/user", self.base_url), } } fn map_rate_limit_window( window: Option>>, ) -> Option { let snapshot = window.flatten().map(|details| *details)?; let used_percent = f64::from(snapshot.used_percent); let window_minutes = Self::window_minutes_from_seconds(snapshot.limit_window_seconds); let resets_at = Some(i64::from(snapshot.reset_at)); Some(RateLimitWindow { used_percent, window_minutes, resets_at, }) } fn map_credits(credits: Option) -> Option { let details = credits?; Some(CreditsSnapshot { has_credits: details.has_credits, unlimited: details.unlimited, balance: details.balance.flatten(), }) } fn map_individual_limit( details: crate::types::SpendControlLimitDetails, ) -> SpendControlLimitSnapshot { SpendControlLimitSnapshot { limit: details.limit, used: details.used, remaining_percent: details.remaining_percent, resets_at: i64::from(details.reset_at), } } fn map_plan_type(plan_type: crate::types::PlanType) -> AccountPlanType { match plan_type { crate::types::PlanType::Free => AccountPlanType::Free, crate::types::PlanType::Go => AccountPlanType::Go, crate::types::PlanType::Plus => AccountPlanType::Plus, crate::types::PlanType::Pro => AccountPlanType::Pro, crate::types::PlanType::ProLite => AccountPlanType::ProLite, crate::types::PlanType::Team => AccountPlanType::Team, crate::types::PlanType::SelfServeBusinessProLite => { AccountPlanType::SelfServeBusinessProLite } crate::types::PlanType::SelfServeBusinessUsageBased => { AccountPlanType::SelfServeBusinessUsageBased } crate::types::PlanType::Business => AccountPlanType::Business, crate::types::PlanType::Ent26 => AccountPlanType::Ent26, crate::types::PlanType::EnterpriseCbpAutomation => { AccountPlanType::EnterpriseCbpAutomation } crate::types::PlanType::EnterpriseCbpUsageBased => { AccountPlanType::EnterpriseCbpUsageBased } crate::types::PlanType::Enterprise => AccountPlanType::Enterprise, crate::types::PlanType::Edu | crate::types::PlanType::Education => AccountPlanType::Edu, crate::types::PlanType::EduPlus => AccountPlanType::EduPlus, crate::types::PlanType::EduPro => AccountPlanType::EduPro, crate::types::PlanType::Guest | crate::types::PlanType::FreeWorkspace | crate::types::PlanType::Quorum | crate::types::PlanType::K12 | crate::types::PlanType::Unknown => AccountPlanType::Unknown, } } fn window_minutes_from_seconds(seconds: i32) -> Option { if seconds <= 0 { return None; } let seconds_i64 = i64::from(seconds); Some((seconds_i64 + 59) / 60) } } #[cfg(test)] #[path = "client_request_tests.rs"] mod request_tests; #[cfg(test)] mod tests { use super::*; use codex_backend_openapi_models::models::AdditionalRateLimitDetails; use codex_backend_openapi_models::models::RateLimitReachedKind; use codex_backend_openapi_models::models::RateLimitReachedType as BackendRateLimitReachedType; use pretty_assertions::assert_eq; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; use wiremock::matchers::header_regex; use wiremock::matchers::method; use wiremock::matchers::path; #[test] fn map_plan_type_supports_business_variants() { let business_prolite = serde_json::from_str::("\"self_serve_business_prolite\"") .expect("business ProLite should deserialize"); assert_eq!( Client::map_plan_type(business_prolite), AccountPlanType::SelfServeBusinessProLite ); assert_eq!( Client::map_plan_type(crate::types::PlanType::SelfServeBusinessUsageBased), AccountPlanType::SelfServeBusinessUsageBased ); assert_eq!( Client::map_plan_type(crate::types::PlanType::EnterpriseCbpUsageBased), AccountPlanType::EnterpriseCbpUsageBased ); assert_eq!( Client::map_plan_type(crate::types::PlanType::EnterpriseCbpAutomation), AccountPlanType::EnterpriseCbpAutomation ); let ent26 = serde_json::from_str::("\"ent26\"") .expect("ent26 backend plan should deserialize"); assert_eq!(Client::map_plan_type(ent26), AccountPlanType::Ent26); } #[test] fn usage_payload_maps_primary_and_additional_rate_limits() { let payload = RateLimitStatusPayload { plan_type: crate::types::PlanType::Pro, rate_limit: Some(Some(Box::new(crate::types::RateLimitStatusDetails { primary_window: Some(Some(Box::new(crate::types::RateLimitWindowSnapshot { used_percent: 42, limit_window_seconds: 300, reset_after_seconds: 0, reset_at: 123, }))), secondary_window: Some(Some(Box::new(crate::types::RateLimitWindowSnapshot { used_percent: 84, limit_window_seconds: 3600, reset_after_seconds: 0, reset_at: 456, }))), ..Default::default() }))), additional_rate_limits: Some(Some(vec![AdditionalRateLimitDetails { limit_name: "codex_other".to_string(), metered_feature: "codex_other".to_string(), rate_limit: Some(Some(Box::new(crate::types::RateLimitStatusDetails { primary_window: Some(Some(Box::new(crate::types::RateLimitWindowSnapshot { used_percent: 70, limit_window_seconds: 900, reset_after_seconds: 0, reset_at: 789, }))), secondary_window: None, ..Default::default() }))), }])), credits: Some(Some(Box::new(crate::types::CreditStatusDetails { has_credits: true, unlimited: false, balance: Some(Some("9.99".to_string())), ..Default::default() }))), spend_control: Some(Some(Box::new( codex_backend_openapi_models::models::SpendControlStatusDetails { reached: false, individual_limit: Some(Some(Box::new( crate::types::SpendControlLimitDetails { source: None, limit: "25000".to_string(), used: "8000".to_string(), remaining: "17000".to_string(), used_percent: 32, remaining_percent: 68, reset_after_seconds: 3600, reset_at: 789, }, ))), }, ))), rate_limit_reached_type: Some(Some(BackendRateLimitReachedType { kind: RateLimitReachedKind::WorkspaceMemberCreditsDepleted, })), }; let snapshots = Client::rate_limit_snapshots_from_payload(payload); assert_eq!(snapshots.len(), 2); assert_eq!(snapshots[0].limit_id.as_deref(), Some("codex")); assert_eq!(snapshots[0].limit_name, None); assert_eq!( snapshots[0].primary.as_ref().map(|w| w.used_percent), Some(42.0) ); assert_eq!( snapshots[0].secondary.as_ref().map(|w| w.used_percent), Some(84.0) ); assert_eq!( snapshots[0].credits, Some(CreditsSnapshot { has_credits: true, unlimited: false, balance: Some("9.99".to_string()), }) ); assert_eq!(snapshots[0].plan_type, Some(AccountPlanType::Pro)); assert_eq!(snapshots[0].spend_control_reached, Some(false)); assert_eq!( snapshots[0].rate_limit_reached_type, Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) ); assert_eq!( snapshots[0].individual_limit, Some(SpendControlLimitSnapshot { limit: "25000".to_string(), used: "8000".to_string(), remaining_percent: 68, resets_at: 789, }) ); assert_eq!(snapshots[1].limit_id.as_deref(), Some("codex_other")); assert_eq!(snapshots[1].limit_name.as_deref(), Some("codex_other")); assert_eq!( snapshots[1].primary.as_ref().map(|w| w.used_percent), Some(70.0) ); assert_eq!(snapshots[1].credits, None); assert_eq!(snapshots[1].individual_limit, None); assert_eq!(snapshots[1].spend_control_reached, None); assert_eq!(snapshots[1].plan_type, Some(AccountPlanType::Pro)); assert_eq!(snapshots[1].rate_limit_reached_type, None); } #[test] fn usage_payload_maps_zero_rate_limit_when_primary_absent() { let payload = RateLimitStatusPayload { plan_type: crate::types::PlanType::Plus, rate_limit: None, additional_rate_limits: Some(Some(vec![AdditionalRateLimitDetails { limit_name: "codex_other".to_string(), metered_feature: "codex_other".to_string(), rate_limit: None, }])), credits: None, spend_control: None, rate_limit_reached_type: None, }; let snapshots = Client::rate_limit_snapshots_from_payload(payload); assert_eq!(snapshots.len(), 2); assert_eq!(snapshots[0].limit_id.as_deref(), Some("codex")); assert_eq!(snapshots[0].limit_name, None); assert_eq!(snapshots[0].primary, None); assert_eq!(snapshots[1].limit_id.as_deref(), Some("codex_other")); assert_eq!(snapshots[1].limit_name.as_deref(), Some("codex_other")); } #[test] fn usage_payload_maps_spend_control_reached_without_individual_limit() { let payload = RateLimitStatusPayload { plan_type: crate::types::PlanType::EnterpriseCbpUsageBased, rate_limit: None, additional_rate_limits: None, credits: None, spend_control: Some(Some(Box::new( codex_backend_openapi_models::models::SpendControlStatusDetails { reached: true, individual_limit: None, }, ))), rate_limit_reached_type: None, }; let snapshots = Client::rate_limit_snapshots_from_payload(payload); assert_eq!(snapshots.len(), 1); assert_eq!(snapshots[0].spend_control_reached, Some(true)); assert_eq!(snapshots[0].individual_limit, None); } #[test] fn preferred_snapshot_selection_matches_get_rate_limits_behavior() { let snapshots = [ RateLimitSnapshot { limit_id: Some("codex_other".to_string()), limit_name: Some("codex_other".to_string()), normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 90.0, window_minutes: Some(60), resets_at: Some(1), }), secondary: None, credits: None, individual_limit: None, spend_control_reached: None, plan_type: Some(AccountPlanType::Pro), rate_limit_reached_type: None, }, RateLimitSnapshot { limit_id: Some("codex".to_string()), limit_name: Some("codex".to_string()), normal_model_slug: None, primary: Some(RateLimitWindow { used_percent: 10.0, window_minutes: Some(60), resets_at: Some(2), }), secondary: None, credits: None, individual_limit: None, spend_control_reached: None, plan_type: Some(AccountPlanType::Pro), rate_limit_reached_type: None, }, ]; let preferred = snapshots .iter() .find(|snapshot| snapshot.limit_id.as_deref() == Some("codex")) .cloned() .unwrap_or_else(|| snapshots[0].clone()); assert_eq!(preferred.limit_id.as_deref(), Some("codex")); } #[test] fn usage_payload_maps_every_rate_limit_reached_type() { let cases = [ ( RateLimitReachedKind::RateLimitReached, Some(RateLimitReachedType::RateLimitReached), ), ( RateLimitReachedKind::WorkspaceOwnerCreditsDepleted, Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted), ), ( RateLimitReachedKind::WorkspaceMemberCreditsDepleted, Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted), ), ( RateLimitReachedKind::WorkspaceOwnerUsageLimitReached, Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached), ), ( RateLimitReachedKind::WorkspaceMemberUsageLimitReached, Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached), ), (RateLimitReachedKind::Unknown, None), ]; for (kind, expected) in cases { let payload = RateLimitStatusPayload { plan_type: crate::types::PlanType::Plus, rate_limit: None, credits: None, spend_control: None, additional_rate_limits: None, rate_limit_reached_type: Some(Some(BackendRateLimitReachedType { kind })), }; let snapshots = Client::rate_limit_snapshots_from_payload(payload); assert_eq!(snapshots[0].rate_limit_reached_type, expected); } } #[test] fn usage_payload_preserves_absent_rate_limit_reached_type() { let payload = RateLimitStatusPayload { plan_type: crate::types::PlanType::Plus, rate_limit: None, credits: None, spend_control: None, additional_rate_limits: None, rate_limit_reached_type: None, }; let snapshots = Client::rate_limit_snapshots_from_payload(payload); assert_eq!(snapshots[0].rate_limit_reached_type, None); } #[test] fn add_credits_nudge_email_uses_expected_paths_and_bodies() { let codex_client = test_client("https://example.test", PathStyle::CodexApi); assert_eq!( codex_client.send_add_credits_nudge_email_url(), "https://example.test/api/codex/accounts/send_add_credits_nudge_email" ); let chatgpt_client = test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi); assert_eq!( chatgpt_client.send_add_credits_nudge_email_url(), "https://chatgpt.com/backend-api/wham/accounts/send_add_credits_nudge_email" ); assert_eq!( serde_json::to_value(SendAddCreditsNudgeEmailRequest { credit_type: AddCreditsNudgeCreditType::Credits, }) .unwrap(), serde_json::json!({ "credit_type": "credits" }) ); assert_eq!( serde_json::to_value(SendAddCreditsNudgeEmailRequest { credit_type: AddCreditsNudgeCreditType::UsageLimit, }) .unwrap(), serde_json::json!({ "credit_type": "usage_limit" }) ); } #[test] fn token_usage_profile_uses_expected_paths() { let codex_client = test_client("https://example.test", PathStyle::CodexApi); assert_eq!( codex_client.token_usage_profile_url(), "https://example.test/api/codex/profiles/me" ); let chatgpt_client = test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi); assert_eq!( chatgpt_client.token_usage_profile_url(), "https://chatgpt.com/backend-api/wham/profiles/me" ); } #[test] fn workspace_messages_uses_expected_paths() { let codex_client = test_client("https://example.test", PathStyle::CodexApi); assert_eq!( codex_client.workspace_messages_url(), "https://example.test/api/codex/workspace-messages" ); let chatgpt_client = test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi); assert_eq!( chatgpt_client.workspace_messages_url(), "https://chatgpt.com/backend-api/wham/workspace-messages" ); } #[tokio::test] async fn user_settings_request_uses_expected_paths_and_revalidates_cached_responses() { let server = MockServer::start().await; for (request_path, commit_attribution_enabled) in [ ("/api/codex/settings/user", true), ("/backend-api/wham/settings/user", false), ] { Mock::given(method("GET")) .and(path(request_path)) .and(header_regex("cache-control", "^no-cache, no-store$")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "commit_attribution_enabled": commit_attribution_enabled, }))) .expect(1) .mount(&server) .await; } let codex_response = Client::new( server.uri(), HttpClientFactory::new(codex_http_client::OutboundProxyPolicy::ReqwestDefault), ) .get_user_settings() .await .unwrap(); let chatgpt_response = Client::new( format!("{}/backend-api", server.uri()), HttpClientFactory::new(codex_http_client::OutboundProxyPolicy::ReqwestDefault), ) .get_user_settings() .await .unwrap(); assert_eq!( [codex_response, chatgpt_response], [ CodexUserSettingsResponse { commit_attribution_enabled: true, }, CodexUserSettingsResponse { commit_attribution_enabled: false, }, ] ); } #[test] fn user_settings_missing_attribution_policy_defaults_to_disabled() { assert_eq!( serde_json::from_value::(serde_json::json!({})).unwrap(), CodexUserSettingsResponse { commit_attribution_enabled: false, } ); } #[test] fn authenticated_user_settings_client_uses_active_workspace_headers() { let auth = CodexAuth::from_external_chatgpt_tokens( "e30.e30.c2ln", "workspace-123", Some("enterprise"), ) .unwrap(); let client = Client::from_auth( "https://chatgpt.com/backend-api", &auth, HttpClientFactory::new(codex_http_client::OutboundProxyPolicy::ReqwestDefault), ); let headers = client.headers(); assert_eq!( [ headers .get("authorization") .and_then(|value| value.to_str().ok()), headers .get("chatgpt-account-id") .and_then(|value| value.to_str().ok()), ], [Some("Bearer e30.e30.c2ln"), Some("workspace-123")] ); } fn test_client(base_url: &str, path_style: PathStyle) -> Client { Client { base_url: base_url.to_string(), http: RouteAwareClientPool::new( HttpClientFactory::new(codex_http_client::OutboundProxyPolicy::ReqwestDefault), ClientRouteClass::Api, ), auth_provider: codex_model_provider::unauthenticated_auth_provider(), user_agent: None, chatgpt_account_id: None, chatgpt_account_is_fedramp: false, path_style, } } }