use crate::types::AccountsCheckResponse;use crate::types::CodeTaskDetailsResponse;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_client::build_reqwest_client_with_custom_ca;use codex_client::with_chatgpt_cloudflare_cookie_store;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 reqwest::StatusCode;use reqwest::header::CONTENT_TYPE;use reqwest::header::HeaderMap;use reqwest::header::HeaderName;use reqwest::header::HeaderValue;use reqwest::header::USER_AGENT;use serde::Serialize;use serde::de::DeserializeOwned;use std::fmt;mod rate_limit_resets;#[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<StatusCode> { 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<anyhow::Error> 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: reqwest::Client, auth_provider: SharedAuthProvider, user_agent: Option<HeaderValue>, chatgpt_account_id: Option<String>, 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", &"<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<String>) -> Result<Self> { let mut base_url = base_url.into(); // 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 http = build_reqwest_client_with_custom_ca(with_chatgpt_cloudflare_cookie_store( reqwest::Client::builder(), ))?; let path_style = PathStyle::from_base_url(&base_url); Ok(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<String>, auth: &CodexAuth) -> Result<Self> { Ok(Self::new(base_url)? .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<String>) -> 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<String>) -> 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 } async fn exec_request( &self, req: reqwest::RequestBuilder, 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: reqwest::RequestBuilder, 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<T: DeserializeOwned>(&self, url: &str, ct: &str, body: &str) -> Result<T> { match serde_json::from_str::<T>(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<RateLimitSnapshot> { 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<Vec<RateLimitSnapshot>> { Ok(self.get_rate_limits_with_reset_credits().await?.rate_limits) } pub async fn get_accounts_check(&self) -> Result<AccountsCheckResponse> { 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.http.get(&url).headers(self.headers()); let (body, ct) = self.exec_request(req, "GET", &url).await?; self.decode_json(&url, &ct, &body) } pub async fn get_token_usage_profile(&self) -> Result<TokenUsageProfile> { let url = self.token_usage_profile_url(); let req = self.http.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 .http .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<i32>, task_filter: Option<&str>, environment_id: Option<&str>, cursor: Option<&str>, ) -> Result<PaginatedListTaskListItem> { 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), }; let req = self.http.get(&url).headers(self.headers()); let req = if let Some(lim) = limit { req.query(&[("limit", lim)]) } else { req }; let req = if let Some(tf) = task_filter { req.query(&[("task_filter", tf)]) } else { req }; let req = if let Some(c) = cursor { req.query(&[("cursor", c)]) } else { req }; let req = if let Some(id) = environment_id { req.query(&[("environment_id", id)]) } else { req }; let (body, ct) = self.exec_request(req, "GET", &url).await?; self.decode_json::<PaginatedListTaskListItem>(&url, &ct, &body) } pub async fn get_task_details(&self, task_id: &str) -> Result<CodeTaskDetailsResponse> { 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.http.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<TurnAttemptsSiblingTurnsResponse> { 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.http.get(&url).headers(self.headers()); let (body, ct) = self.exec_request(req, "GET", &url).await?; self.decode_json::<TurnAttemptsSiblingTurnsResponse>(&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<ConfigBundleResponse, RequestError> { 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.http.get(&url).headers(self.headers()); let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?; self.decode_json::<ConfigBundleResponse>(&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<String> { 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 .http .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::<serde_json::Value>(&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<RateLimitSnapshot> { 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 individual_limit = payload .spend_control .flatten() .and_then(|details| details.individual_limit.flatten()) .map(|details| Self::map_individual_limit(*details)); 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), individual_limit, plan_type, rate_limit_reached_type, )]; if let Some(additional) = payload.additional_rate_limits.flatten() { snapshots.extend(additional.into_iter().map(|details| { Self::make_rate_limit_snapshot( Some(details.metered_feature), Some(details.limit_name), details.rate_limit.flatten().map(|rate_limit| *rate_limit), /*credits*/ None, /*individual_limit*/ None, plan_type, /*rate_limit_reached_type*/ None, ) })); } snapshots } fn make_rate_limit_snapshot( limit_id: Option<String>, limit_name: Option<String>, rate_limit: Option<crate::types::RateLimitStatusDetails>, credits: Option<crate::types::CreditStatusDetails>, individual_limit: Option<SpendControlLimitSnapshot>, plan_type: Option<AccountPlanType>, rate_limit_reached_type: Option<RateLimitReachedType>, ) -> 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), }; RateLimitSnapshot { limit_id, limit_name, primary, secondary, credits: Self::map_credits(credits), individual_limit, plan_type, rate_limit_reached_type, } } fn map_rate_limit_reached_type( kind: BackendRateLimitReachedKind, ) -> Option<RateLimitReachedType> { 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 map_rate_limit_window( window: Option<Option<Box<crate::types::RateLimitWindowSnapshot>>>, ) -> Option<RateLimitWindow> { 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<crate::types::CreditStatusDetails>) -> Option<CreditsSnapshot> { 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::SelfServeBusinessUsageBased => { AccountPlanType::SelfServeBusinessUsageBased } crate::types::PlanType::Business => AccountPlanType::Business, 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::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<i64> { if seconds <= 0 { return None; } let seconds_i64 = i64::from(seconds); Some((seconds_i64 + 59) / 60) }}#[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; #[test] fn map_plan_type_supports_usage_based_business_variants() { assert_eq!( Client::map_plan_type(crate::types::PlanType::SelfServeBusinessUsageBased), AccountPlanType::SelfServeBusinessUsageBased ); assert_eq!( Client::map_plan_type(crate::types::PlanType::EnterpriseCbpUsageBased), AccountPlanType::EnterpriseCbpUsageBased ); } #[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].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].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 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()), primary: Some(RateLimitWindow { used_percent: 90.0, window_minutes: Some(60), resets_at: Some(1), }), secondary: None, credits: None, individual_limit: None, plan_type: Some(AccountPlanType::Pro), rate_limit_reached_type: None, }, RateLimitSnapshot { limit_id: Some("codex".to_string()), limit_name: Some("codex".to_string()), primary: Some(RateLimitWindow { used_percent: 10.0, window_minutes: Some(60), resets_at: Some(2), }), secondary: None, credits: None, individual_limit: 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" ); } fn test_client(base_url: &str, path_style: PathStyle) -> Client { Client { base_url: base_url.to_string(), http: reqwest::Client::new(), auth_provider: codex_model_provider::unauthenticated_auth_provider(), user_agent: None, chatgpt_account_id: None, chatgpt_account_is_fedramp: false, path_style, } }}