Codex Handbook
backend-client/src/client.rs 950 lines
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,        }    }}