Skip to main content

codex_backend_client/
client.rs

1use crate::types::AccountsCheckResponse;
2use crate::types::CodeTaskDetailsResponse;
3use crate::types::CodexUserSettingsResponse;
4use crate::types::CodexWorkspaceMessagesResponse;
5use crate::types::ConfigBundleResponse;
6use crate::types::PaginatedListTaskListItem;
7use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind;
8use crate::types::RateLimitStatusPayload;
9use crate::types::TokenUsageProfile;
10use crate::types::TurnAttemptsSiblingTurnsResponse;
11use anyhow::Result;
12use codex_api::SharedAuthProvider;
13use codex_http_client::ClientRouteClass;
14use codex_http_client::HttpClientFactory;
15use codex_http_client::RouteAwareClientPool;
16use codex_http_client::RouteAwareRequestBuilder;
17use codex_login::CodexAuth;
18use codex_login::default_client::get_codex_user_agent;
19use codex_protocol::account::PlanType as AccountPlanType;
20use codex_protocol::protocol::CreditsSnapshot;
21use codex_protocol::protocol::RateLimitReachedType;
22use codex_protocol::protocol::RateLimitSnapshot;
23use codex_protocol::protocol::RateLimitWindow;
24use codex_protocol::protocol::SpendControlLimitSnapshot;
25use http::Method;
26use http::StatusCode;
27use http::header::CACHE_CONTROL;
28use http::header::CONTENT_TYPE;
29use http::header::HeaderMap;
30use http::header::HeaderName;
31use http::header::HeaderValue;
32use http::header::USER_AGENT;
33use serde::Serialize;
34use serde::de::DeserializeOwned;
35use std::fmt;
36
37mod rate_limit_resets;
38
39#[derive(Debug)]
40pub enum RequestError {
41    UnexpectedStatus {
42        method: String,
43        url: String,
44        status: StatusCode,
45        content_type: String,
46        body: String,
47    },
48    Other(anyhow::Error),
49}
50
51impl RequestError {
52    pub fn status(&self) -> Option<StatusCode> {
53        match self {
54            Self::UnexpectedStatus { status, .. } => Some(*status),
55            Self::Other(_) => None,
56        }
57    }
58
59    pub fn is_unauthorized(&self) -> bool {
60        self.status() == Some(StatusCode::UNAUTHORIZED)
61    }
62}
63
64impl fmt::Display for RequestError {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        match self {
67            Self::UnexpectedStatus {
68                method,
69                url,
70                status,
71                content_type,
72                body,
73            } => write!(
74                f,
75                "{method} {url} failed: {status}; content-type={content_type}; body={body}"
76            ),
77            Self::Other(err) => write!(f, "{err}"),
78        }
79    }
80}
81
82impl std::error::Error for RequestError {
83    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
84        match self {
85            Self::UnexpectedStatus { .. } => None,
86            Self::Other(err) => Some(err.as_ref()),
87        }
88    }
89}
90
91impl From<anyhow::Error> for RequestError {
92    fn from(err: anyhow::Error) -> Self {
93        Self::Other(err)
94    }
95}
96
97#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
98#[serde(rename_all = "snake_case")]
99pub enum AddCreditsNudgeCreditType {
100    Credits,
101    UsageLimit,
102}
103
104#[derive(Serialize)]
105struct SendAddCreditsNudgeEmailRequest {
106    credit_type: AddCreditsNudgeCreditType,
107}
108
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110pub enum PathStyle {
111    /// /api/codex/…
112    CodexApi,
113    /// /wham/…
114    ChatGptApi,
115}
116
117impl PathStyle {
118    pub fn from_base_url(base_url: &str) -> Self {
119        if base_url.contains("/backend-api") {
120            PathStyle::ChatGptApi
121        } else {
122            PathStyle::CodexApi
123        }
124    }
125}
126
127#[derive(Clone)]
128pub struct Client {
129    base_url: String,
130    http: RouteAwareClientPool,
131    auth_provider: SharedAuthProvider,
132    user_agent: Option<HeaderValue>,
133    chatgpt_account_id: Option<String>,
134    chatgpt_account_is_fedramp: bool,
135    path_style: PathStyle,
136}
137
138impl fmt::Debug for Client {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        f.debug_struct("Client")
141            .field("base_url", &self.base_url)
142            .field("auth_provider", &"<provider>")
143            .field("user_agent", &self.user_agent)
144            .field("chatgpt_account_id", &self.chatgpt_account_id)
145            .field(
146                "chatgpt_account_is_fedramp",
147                &self.chatgpt_account_is_fedramp,
148            )
149            .field("path_style", &self.path_style)
150            .finish_non_exhaustive()
151    }
152}
153
154impl Client {
155    pub fn new(base_url: impl Into<String>, http_client_factory: HttpClientFactory) -> Self {
156        let mut base_url = base_url.into();
157        // Normalize common ChatGPT hostnames to include /backend-api so we hit the WHAM paths.
158        // Also trim trailing slashes for consistent URL building.
159        while base_url.ends_with('/') {
160            base_url.pop();
161        }
162        if (base_url.starts_with("https://chatgpt.com")
163            || base_url.starts_with("https://chat.openai.com"))
164            && !base_url.contains("/backend-api")
165        {
166            base_url = format!("{base_url}/backend-api");
167        }
168        let http = RouteAwareClientPool::with_chatgpt_cloudflare_cookies_without_request_logging(
169            http_client_factory,
170            ClientRouteClass::Api,
171        );
172        let path_style = PathStyle::from_base_url(&base_url);
173        Self {
174            base_url,
175            http,
176            auth_provider: codex_model_provider::unauthenticated_auth_provider(),
177            user_agent: None,
178            chatgpt_account_id: None,
179            chatgpt_account_is_fedramp: false,
180            path_style,
181        }
182    }
183
184    pub fn from_auth(
185        base_url: impl Into<String>,
186        auth: &CodexAuth,
187        http_client_factory: HttpClientFactory,
188    ) -> Self {
189        Self::new(base_url, http_client_factory)
190            .with_user_agent(get_codex_user_agent())
191            .with_auth_provider(codex_model_provider::auth_provider_from_auth(auth))
192    }
193
194    pub fn with_auth_provider(mut self, auth: SharedAuthProvider) -> Self {
195        self.auth_provider = auth;
196        self
197    }
198
199    pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
200        if let Ok(hv) = HeaderValue::from_str(&ua.into()) {
201            self.user_agent = Some(hv);
202        }
203        self
204    }
205
206    pub fn with_chatgpt_account_id(mut self, account_id: impl Into<String>) -> Self {
207        self.chatgpt_account_id = Some(account_id.into());
208        self
209    }
210
211    pub fn with_fedramp_routing_header(mut self) -> Self {
212        self.chatgpt_account_is_fedramp = true;
213        self
214    }
215
216    pub fn with_path_style(mut self, style: PathStyle) -> Self {
217        self.path_style = style;
218        self
219    }
220
221    fn headers(&self) -> HeaderMap {
222        let mut h = HeaderMap::new();
223        if let Some(ua) = &self.user_agent {
224            h.insert(USER_AGENT, ua.clone());
225        } else {
226            h.insert(USER_AGENT, HeaderValue::from_static("codex-cli"));
227        }
228        self.auth_provider.add_auth_headers(&mut h);
229        if let Some(acc) = &self.chatgpt_account_id
230            && let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id")
231            && let Ok(hv) = HeaderValue::from_str(acc)
232        {
233            h.insert(name, hv);
234        }
235        if self.chatgpt_account_is_fedramp
236            && let Ok(name) = HeaderName::from_bytes(b"X-OpenAI-Fedramp")
237        {
238            h.insert(name, HeaderValue::from_static("true"));
239        }
240        h
241    }
242
243    fn request(&self, method: Method, url: &str) -> RouteAwareRequestBuilder {
244        self.http.request(method, url)
245    }
246
247    async fn exec_request(
248        &self,
249        req: RouteAwareRequestBuilder,
250        method: &str,
251        url: &str,
252    ) -> Result<(String, String)> {
253        let res = req.send().await?;
254        let status = res.status();
255        let ct = res
256            .headers()
257            .get(CONTENT_TYPE)
258            .and_then(|v| v.to_str().ok())
259            .unwrap_or("")
260            .to_string();
261        let body = res.text().await.unwrap_or_default();
262        if !status.is_success() {
263            anyhow::bail!("{method} {url} failed: {status}; content-type={ct}; body={body}");
264        }
265        Ok((body, ct))
266    }
267
268    async fn exec_request_detailed(
269        &self,
270        req: RouteAwareRequestBuilder,
271        method: &str,
272        url: &str,
273    ) -> std::result::Result<(String, String), RequestError> {
274        let res = req.send().await.map_err(anyhow::Error::from)?;
275        let status = res.status();
276        let content_type = res
277            .headers()
278            .get(CONTENT_TYPE)
279            .and_then(|v| v.to_str().ok())
280            .unwrap_or("")
281            .to_string();
282        let body = res.text().await.unwrap_or_default();
283        if !status.is_success() {
284            return Err(RequestError::UnexpectedStatus {
285                method: method.to_string(),
286                url: url.to_string(),
287                status,
288                content_type,
289                body,
290            });
291        }
292        Ok((body, content_type))
293    }
294
295    fn decode_json<T: DeserializeOwned>(&self, url: &str, ct: &str, body: &str) -> Result<T> {
296        match serde_json::from_str::<T>(body) {
297            Ok(v) => Ok(v),
298            Err(e) => {
299                anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}");
300            }
301        }
302    }
303
304    pub async fn get_rate_limits(&self) -> Result<RateLimitSnapshot> {
305        let snapshots = self.get_rate_limits_many().await?;
306        let preferred = snapshots
307            .iter()
308            .find(|snapshot| snapshot.limit_id.as_deref() == Some("codex"))
309            .cloned();
310        Ok(preferred.unwrap_or_else(|| snapshots[0].clone()))
311    }
312
313    pub async fn get_rate_limits_many(&self) -> Result<Vec<RateLimitSnapshot>> {
314        Ok(self.get_rate_limits_with_reset_credits().await?.rate_limits)
315    }
316
317    pub async fn get_accounts_check(&self) -> Result<AccountsCheckResponse> {
318        let url = match self.path_style {
319            PathStyle::CodexApi => format!("{}/api/codex/accounts/check", self.base_url),
320            PathStyle::ChatGptApi => format!("{}/wham/accounts/check", self.base_url),
321        };
322        let req = self.request(Method::GET, &url).headers(self.headers());
323        let (body, ct) = self.exec_request(req, "GET", &url).await?;
324        self.decode_json(&url, &ct, &body)
325    }
326
327    pub async fn get_token_usage_profile(&self) -> Result<TokenUsageProfile> {
328        let url = self.token_usage_profile_url();
329        let req = self.request(Method::GET, &url).headers(self.headers());
330        let (body, ct) = self.exec_request(req, "GET", &url).await?;
331        self.decode_json(&url, &ct, &body)
332    }
333
334    fn token_usage_profile_url(&self) -> String {
335        match self.path_style {
336            PathStyle::CodexApi => format!("{}/api/codex/profiles/me", self.base_url),
337            PathStyle::ChatGptApi => format!("{}/wham/profiles/me", self.base_url),
338        }
339    }
340
341    pub async fn send_add_credits_nudge_email(
342        &self,
343        credit_type: AddCreditsNudgeCreditType,
344    ) -> std::result::Result<(), RequestError> {
345        let url = self.send_add_credits_nudge_email_url();
346        let req = self
347            .request(Method::POST, &url)
348            .headers(self.headers())
349            .header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
350            .json(&SendAddCreditsNudgeEmailRequest { credit_type });
351        self.exec_request_detailed(req, "POST", &url).await?;
352        Ok(())
353    }
354
355    pub async fn list_tasks(
356        &self,
357        limit: Option<i32>,
358        task_filter: Option<&str>,
359        environment_id: Option<&str>,
360        cursor: Option<&str>,
361    ) -> Result<PaginatedListTaskListItem> {
362        let url = self.list_tasks_url(limit, task_filter, environment_id, cursor)?;
363        let req = self.request(Method::GET, &url).headers(self.headers());
364        let (body, ct) = self.exec_request(req, "GET", &url).await?;
365        self.decode_json::<PaginatedListTaskListItem>(&url, &ct, &body)
366    }
367
368    fn list_tasks_url(
369        &self,
370        limit: Option<i32>,
371        task_filter: Option<&str>,
372        environment_id: Option<&str>,
373        cursor: Option<&str>,
374    ) -> Result<String> {
375        let url = match self.path_style {
376            PathStyle::CodexApi => format!("{}/api/codex/tasks/list", self.base_url),
377            PathStyle::ChatGptApi => format!("{}/wham/tasks/list", self.base_url),
378        };
379        if limit.is_none() && task_filter.is_none() && environment_id.is_none() && cursor.is_none()
380        {
381            return Ok(url);
382        }
383        let mut url = url::Url::parse(&url)?;
384        {
385            let mut query = url.query_pairs_mut();
386            if let Some(limit) = limit {
387                query.append_pair("limit", &limit.to_string());
388            }
389            if let Some(task_filter) = task_filter {
390                query.append_pair("task_filter", task_filter);
391            }
392            if let Some(cursor) = cursor {
393                query.append_pair("cursor", cursor);
394            }
395            if let Some(environment_id) = environment_id {
396                query.append_pair("environment_id", environment_id);
397            }
398        }
399        Ok(url.to_string())
400    }
401
402    pub async fn get_task_details(&self, task_id: &str) -> Result<CodeTaskDetailsResponse> {
403        let (parsed, _body, _ct) = self.get_task_details_with_body(task_id).await?;
404        Ok(parsed)
405    }
406
407    pub async fn get_task_details_with_body(
408        &self,
409        task_id: &str,
410    ) -> Result<(CodeTaskDetailsResponse, String, String)> {
411        let url = match self.path_style {
412            PathStyle::CodexApi => format!("{}/api/codex/tasks/{}", self.base_url, task_id),
413            PathStyle::ChatGptApi => format!("{}/wham/tasks/{}", self.base_url, task_id),
414        };
415        let req = self.request(Method::GET, &url).headers(self.headers());
416        let (body, ct) = self.exec_request(req, "GET", &url).await?;
417        let parsed: CodeTaskDetailsResponse = self.decode_json(&url, &ct, &body)?;
418        Ok((parsed, body, ct))
419    }
420
421    pub async fn list_sibling_turns(
422        &self,
423        task_id: &str,
424        turn_id: &str,
425    ) -> Result<TurnAttemptsSiblingTurnsResponse> {
426        let url = match self.path_style {
427            PathStyle::CodexApi => format!(
428                "{}/api/codex/tasks/{}/turns/{}/sibling_turns",
429                self.base_url, task_id, turn_id
430            ),
431            PathStyle::ChatGptApi => format!(
432                "{}/wham/tasks/{}/turns/{}/sibling_turns",
433                self.base_url, task_id, turn_id
434            ),
435        };
436        let req = self.request(Method::GET, &url).headers(self.headers());
437        let (body, ct) = self.exec_request(req, "GET", &url).await?;
438        self.decode_json::<TurnAttemptsSiblingTurnsResponse>(&url, &ct, &body)
439    }
440
441    /// Fetch the selected cloud-managed config bundle from codex-backend.
442    ///
443    /// `GET /api/codex/config/bundle` (Codex API style) or
444    /// `GET /wham/config/bundle` (ChatGPT backend-api style).
445    pub async fn get_config_bundle(
446        &self,
447    ) -> std::result::Result<ConfigBundleResponse, RequestError> {
448        let url = match self.path_style {
449            PathStyle::CodexApi => format!("{}/api/codex/config/bundle", self.base_url),
450            PathStyle::ChatGptApi => format!("{}/wham/config/bundle", self.base_url),
451        };
452        let req = self.request(Method::GET, &url).headers(self.headers());
453        let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?;
454        self.decode_json::<ConfigBundleResponse>(&url, &ct, &body)
455            .map_err(RequestError::from)
456    }
457
458    /// Fetch authenticated Codex user settings from the active backend route.
459    ///
460    /// Uses `GET /api/codex/settings/user` for Codex API hosts and
461    /// `GET /wham/settings/user` for ChatGPT `backend-api` hosts.
462    pub async fn get_user_settings(
463        &self,
464    ) -> std::result::Result<CodexUserSettingsResponse, RequestError> {
465        let url = self.user_settings_url();
466        let req = self
467            .request(Method::GET, &url)
468            .headers(self.headers())
469            .header(
470                CACHE_CONTROL,
471                HeaderValue::from_static("no-cache, no-store"),
472            );
473        let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?;
474        self.decode_json::<CodexUserSettingsResponse>(&url, &ct, &body)
475            .map_err(RequestError::from)
476    }
477
478    pub async fn list_workspace_messages(
479        &self,
480    ) -> std::result::Result<CodexWorkspaceMessagesResponse, RequestError> {
481        let url = self.workspace_messages_url();
482        let req = self
483            .request(Method::GET, &url)
484            .headers(self.headers())
485            .header(CACHE_CONTROL, HeaderValue::from_static("no-store"));
486        let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?;
487        self.decode_json::<CodexWorkspaceMessagesResponse>(&url, &ct, &body)
488            .map_err(RequestError::from)
489    }
490
491    /// Create a new task (user turn) by POSTing to the appropriate backend path
492    /// based on `path_style`. Returns the created task id.
493    pub async fn create_task(&self, request_body: serde_json::Value) -> Result<String> {
494        let url = match self.path_style {
495            PathStyle::CodexApi => format!("{}/api/codex/tasks", self.base_url),
496            PathStyle::ChatGptApi => format!("{}/wham/tasks", self.base_url),
497        };
498        let req = self
499            .request(Method::POST, &url)
500            .headers(self.headers())
501            .header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
502            .json(&request_body);
503        let (body, ct) = self.exec_request(req, "POST", &url).await?;
504        // Extract id from JSON: prefer `task.id`; fallback to top-level `id` when present.
505        match serde_json::from_str::<serde_json::Value>(&body) {
506            Ok(v) => {
507                if let Some(id) = v
508                    .get("task")
509                    .and_then(|t| t.get("id"))
510                    .and_then(|s| s.as_str())
511                {
512                    Ok(id.to_string())
513                } else if let Some(id) = v.get("id").and_then(|s| s.as_str()) {
514                    Ok(id.to_string())
515                } else {
516                    anyhow::bail!(
517                        "POST {url} succeeded but no task id found; content-type={ct}; body={body}"
518                    );
519                }
520            }
521            Err(e) => anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}"),
522        }
523    }
524
525    // rate limit helpers
526    fn rate_limit_snapshots_from_payload(
527        payload: RateLimitStatusPayload,
528    ) -> Vec<RateLimitSnapshot> {
529        let plan_type = Some(Self::map_plan_type(payload.plan_type));
530        let rate_limit_reached_type = payload
531            .rate_limit_reached_type
532            .flatten()
533            .and_then(|details| Self::map_rate_limit_reached_type(details.kind));
534        let mut snapshots = vec![Self::make_rate_limit_snapshot(
535            Some("codex".to_string()),
536            /*limit_name*/ None,
537            payload.rate_limit.flatten().map(|details| *details),
538            payload.credits.flatten().map(|details| *details),
539            payload.spend_control.flatten().map(|details| *details),
540            plan_type,
541            rate_limit_reached_type,
542        )];
543        if let Some(additional) = payload.additional_rate_limits.flatten() {
544            snapshots.extend(additional.into_iter().map(|details| {
545                Self::make_rate_limit_snapshot(
546                    Some(details.metered_feature),
547                    Some(details.limit_name),
548                    details.rate_limit.flatten().map(|rate_limit| *rate_limit),
549                    /*credits*/ None,
550                    /*spend_control*/ None,
551                    plan_type,
552                    /*rate_limit_reached_type*/ None,
553                )
554            }));
555        }
556        snapshots
557    }
558
559    fn make_rate_limit_snapshot(
560        limit_id: Option<String>,
561        limit_name: Option<String>,
562        rate_limit: Option<crate::types::RateLimitStatusDetails>,
563        credits: Option<crate::types::CreditStatusDetails>,
564        spend_control: Option<codex_backend_openapi_models::models::SpendControlStatusDetails>,
565        plan_type: Option<AccountPlanType>,
566        rate_limit_reached_type: Option<RateLimitReachedType>,
567    ) -> RateLimitSnapshot {
568        let (primary, secondary) = match rate_limit {
569            Some(details) => (
570                Self::map_rate_limit_window(details.primary_window),
571                Self::map_rate_limit_window(details.secondary_window),
572            ),
573            None => (None, None),
574        };
575        let spend_control_reached = spend_control.as_ref().map(|details| details.reached);
576        let individual_limit = spend_control
577            .and_then(|details| details.individual_limit.flatten())
578            .map(|details| Self::map_individual_limit(*details));
579        RateLimitSnapshot {
580            limit_id,
581            limit_name,
582            primary,
583            secondary,
584            credits: Self::map_credits(credits),
585            individual_limit,
586            spend_control_reached,
587            plan_type,
588            rate_limit_reached_type,
589        }
590    }
591
592    fn map_rate_limit_reached_type(
593        kind: BackendRateLimitReachedKind,
594    ) -> Option<RateLimitReachedType> {
595        match kind {
596            BackendRateLimitReachedKind::RateLimitReached => {
597                Some(RateLimitReachedType::RateLimitReached)
598            }
599            BackendRateLimitReachedKind::WorkspaceOwnerCreditsDepleted => {
600                Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted)
601            }
602            BackendRateLimitReachedKind::WorkspaceMemberCreditsDepleted => {
603                Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted)
604            }
605            BackendRateLimitReachedKind::WorkspaceOwnerUsageLimitReached => {
606                Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached)
607            }
608            BackendRateLimitReachedKind::WorkspaceMemberUsageLimitReached => {
609                Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached)
610            }
611            BackendRateLimitReachedKind::Unknown => None,
612        }
613    }
614
615    fn send_add_credits_nudge_email_url(&self) -> String {
616        match self.path_style {
617            PathStyle::CodexApi => format!(
618                "{}/api/codex/accounts/send_add_credits_nudge_email",
619                self.base_url
620            ),
621            PathStyle::ChatGptApi => {
622                format!(
623                    "{}/wham/accounts/send_add_credits_nudge_email",
624                    self.base_url
625                )
626            }
627        }
628    }
629
630    fn workspace_messages_url(&self) -> String {
631        match self.path_style {
632            PathStyle::CodexApi => format!("{}/api/codex/workspace-messages", self.base_url),
633            PathStyle::ChatGptApi => format!("{}/wham/workspace-messages", self.base_url),
634        }
635    }
636
637    fn user_settings_url(&self) -> String {
638        match self.path_style {
639            PathStyle::CodexApi => format!("{}/api/codex/settings/user", self.base_url),
640            PathStyle::ChatGptApi => format!("{}/wham/settings/user", self.base_url),
641        }
642    }
643
644    fn map_rate_limit_window(
645        window: Option<Option<Box<crate::types::RateLimitWindowSnapshot>>>,
646    ) -> Option<RateLimitWindow> {
647        let snapshot = window.flatten().map(|details| *details)?;
648
649        let used_percent = f64::from(snapshot.used_percent);
650        let window_minutes = Self::window_minutes_from_seconds(snapshot.limit_window_seconds);
651        let resets_at = Some(i64::from(snapshot.reset_at));
652        Some(RateLimitWindow {
653            used_percent,
654            window_minutes,
655            resets_at,
656        })
657    }
658
659    fn map_credits(credits: Option<crate::types::CreditStatusDetails>) -> Option<CreditsSnapshot> {
660        let details = credits?;
661
662        Some(CreditsSnapshot {
663            has_credits: details.has_credits,
664            unlimited: details.unlimited,
665            balance: details.balance.flatten(),
666        })
667    }
668
669    fn map_individual_limit(
670        details: crate::types::SpendControlLimitDetails,
671    ) -> SpendControlLimitSnapshot {
672        SpendControlLimitSnapshot {
673            limit: details.limit,
674            used: details.used,
675            remaining_percent: details.remaining_percent,
676            resets_at: i64::from(details.reset_at),
677        }
678    }
679
680    fn map_plan_type(plan_type: crate::types::PlanType) -> AccountPlanType {
681        match plan_type {
682            crate::types::PlanType::Free => AccountPlanType::Free,
683            crate::types::PlanType::Go => AccountPlanType::Go,
684            crate::types::PlanType::Plus => AccountPlanType::Plus,
685            crate::types::PlanType::Pro => AccountPlanType::Pro,
686            crate::types::PlanType::ProLite => AccountPlanType::ProLite,
687            crate::types::PlanType::Team => AccountPlanType::Team,
688            crate::types::PlanType::SelfServeBusinessUsageBased => {
689                AccountPlanType::SelfServeBusinessUsageBased
690            }
691            crate::types::PlanType::Business => AccountPlanType::Business,
692            crate::types::PlanType::EnterpriseCbpUsageBased => {
693                AccountPlanType::EnterpriseCbpUsageBased
694            }
695            crate::types::PlanType::Enterprise => AccountPlanType::Enterprise,
696            crate::types::PlanType::Edu | crate::types::PlanType::Education => AccountPlanType::Edu,
697            crate::types::PlanType::Guest
698            | crate::types::PlanType::FreeWorkspace
699            | crate::types::PlanType::Quorum
700            | crate::types::PlanType::K12
701            | crate::types::PlanType::Unknown => AccountPlanType::Unknown,
702        }
703    }
704
705    fn window_minutes_from_seconds(seconds: i32) -> Option<i64> {
706        if seconds <= 0 {
707            return None;
708        }
709
710        let seconds_i64 = i64::from(seconds);
711        Some((seconds_i64 + 59) / 60)
712    }
713}
714
715#[cfg(test)]
716#[path = "client_request_tests.rs"]
717mod request_tests;
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722    use codex_backend_openapi_models::models::AdditionalRateLimitDetails;
723    use codex_backend_openapi_models::models::RateLimitReachedKind;
724    use codex_backend_openapi_models::models::RateLimitReachedType as BackendRateLimitReachedType;
725    use pretty_assertions::assert_eq;
726    use wiremock::Mock;
727    use wiremock::MockServer;
728    use wiremock::ResponseTemplate;
729    use wiremock::matchers::header_regex;
730    use wiremock::matchers::method;
731    use wiremock::matchers::path;
732
733    #[test]
734    fn map_plan_type_supports_usage_based_business_variants() {
735        assert_eq!(
736            Client::map_plan_type(crate::types::PlanType::SelfServeBusinessUsageBased),
737            AccountPlanType::SelfServeBusinessUsageBased
738        );
739        assert_eq!(
740            Client::map_plan_type(crate::types::PlanType::EnterpriseCbpUsageBased),
741            AccountPlanType::EnterpriseCbpUsageBased
742        );
743    }
744
745    #[test]
746    fn usage_payload_maps_primary_and_additional_rate_limits() {
747        let payload = RateLimitStatusPayload {
748            plan_type: crate::types::PlanType::Pro,
749            rate_limit: Some(Some(Box::new(crate::types::RateLimitStatusDetails {
750                primary_window: Some(Some(Box::new(crate::types::RateLimitWindowSnapshot {
751                    used_percent: 42,
752                    limit_window_seconds: 300,
753                    reset_after_seconds: 0,
754                    reset_at: 123,
755                }))),
756                secondary_window: Some(Some(Box::new(crate::types::RateLimitWindowSnapshot {
757                    used_percent: 84,
758                    limit_window_seconds: 3600,
759                    reset_after_seconds: 0,
760                    reset_at: 456,
761                }))),
762                ..Default::default()
763            }))),
764            additional_rate_limits: Some(Some(vec![AdditionalRateLimitDetails {
765                limit_name: "codex_other".to_string(),
766                metered_feature: "codex_other".to_string(),
767                rate_limit: Some(Some(Box::new(crate::types::RateLimitStatusDetails {
768                    primary_window: Some(Some(Box::new(crate::types::RateLimitWindowSnapshot {
769                        used_percent: 70,
770                        limit_window_seconds: 900,
771                        reset_after_seconds: 0,
772                        reset_at: 789,
773                    }))),
774                    secondary_window: None,
775                    ..Default::default()
776                }))),
777            }])),
778            credits: Some(Some(Box::new(crate::types::CreditStatusDetails {
779                has_credits: true,
780                unlimited: false,
781                balance: Some(Some("9.99".to_string())),
782                ..Default::default()
783            }))),
784            spend_control: Some(Some(Box::new(
785                codex_backend_openapi_models::models::SpendControlStatusDetails {
786                    reached: false,
787                    individual_limit: Some(Some(Box::new(
788                        crate::types::SpendControlLimitDetails {
789                            source: None,
790                            limit: "25000".to_string(),
791                            used: "8000".to_string(),
792                            remaining: "17000".to_string(),
793                            used_percent: 32,
794                            remaining_percent: 68,
795                            reset_after_seconds: 3600,
796                            reset_at: 789,
797                        },
798                    ))),
799                },
800            ))),
801            rate_limit_reached_type: Some(Some(BackendRateLimitReachedType {
802                kind: RateLimitReachedKind::WorkspaceMemberCreditsDepleted,
803            })),
804        };
805
806        let snapshots = Client::rate_limit_snapshots_from_payload(payload);
807        assert_eq!(snapshots.len(), 2);
808
809        assert_eq!(snapshots[0].limit_id.as_deref(), Some("codex"));
810        assert_eq!(snapshots[0].limit_name, None);
811        assert_eq!(
812            snapshots[0].primary.as_ref().map(|w| w.used_percent),
813            Some(42.0)
814        );
815        assert_eq!(
816            snapshots[0].secondary.as_ref().map(|w| w.used_percent),
817            Some(84.0)
818        );
819        assert_eq!(
820            snapshots[0].credits,
821            Some(CreditsSnapshot {
822                has_credits: true,
823                unlimited: false,
824                balance: Some("9.99".to_string()),
825            })
826        );
827        assert_eq!(snapshots[0].plan_type, Some(AccountPlanType::Pro));
828        assert_eq!(snapshots[0].spend_control_reached, Some(false));
829        assert_eq!(
830            snapshots[0].rate_limit_reached_type,
831            Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted)
832        );
833        assert_eq!(
834            snapshots[0].individual_limit,
835            Some(SpendControlLimitSnapshot {
836                limit: "25000".to_string(),
837                used: "8000".to_string(),
838                remaining_percent: 68,
839                resets_at: 789,
840            })
841        );
842
843        assert_eq!(snapshots[1].limit_id.as_deref(), Some("codex_other"));
844        assert_eq!(snapshots[1].limit_name.as_deref(), Some("codex_other"));
845        assert_eq!(
846            snapshots[1].primary.as_ref().map(|w| w.used_percent),
847            Some(70.0)
848        );
849        assert_eq!(snapshots[1].credits, None);
850        assert_eq!(snapshots[1].individual_limit, None);
851        assert_eq!(snapshots[1].spend_control_reached, None);
852        assert_eq!(snapshots[1].plan_type, Some(AccountPlanType::Pro));
853        assert_eq!(snapshots[1].rate_limit_reached_type, None);
854    }
855
856    #[test]
857    fn usage_payload_maps_zero_rate_limit_when_primary_absent() {
858        let payload = RateLimitStatusPayload {
859            plan_type: crate::types::PlanType::Plus,
860            rate_limit: None,
861            additional_rate_limits: Some(Some(vec![AdditionalRateLimitDetails {
862                limit_name: "codex_other".to_string(),
863                metered_feature: "codex_other".to_string(),
864                rate_limit: None,
865            }])),
866            credits: None,
867            spend_control: None,
868            rate_limit_reached_type: None,
869        };
870
871        let snapshots = Client::rate_limit_snapshots_from_payload(payload);
872        assert_eq!(snapshots.len(), 2);
873        assert_eq!(snapshots[0].limit_id.as_deref(), Some("codex"));
874        assert_eq!(snapshots[0].limit_name, None);
875        assert_eq!(snapshots[0].primary, None);
876        assert_eq!(snapshots[1].limit_id.as_deref(), Some("codex_other"));
877        assert_eq!(snapshots[1].limit_name.as_deref(), Some("codex_other"));
878    }
879
880    #[test]
881    fn usage_payload_maps_spend_control_reached_without_individual_limit() {
882        let payload = RateLimitStatusPayload {
883            plan_type: crate::types::PlanType::EnterpriseCbpUsageBased,
884            rate_limit: None,
885            additional_rate_limits: None,
886            credits: None,
887            spend_control: Some(Some(Box::new(
888                codex_backend_openapi_models::models::SpendControlStatusDetails {
889                    reached: true,
890                    individual_limit: None,
891                },
892            ))),
893            rate_limit_reached_type: None,
894        };
895
896        let snapshots = Client::rate_limit_snapshots_from_payload(payload);
897
898        assert_eq!(snapshots.len(), 1);
899        assert_eq!(snapshots[0].spend_control_reached, Some(true));
900        assert_eq!(snapshots[0].individual_limit, None);
901    }
902
903    #[test]
904    fn preferred_snapshot_selection_matches_get_rate_limits_behavior() {
905        let snapshots = [
906            RateLimitSnapshot {
907                limit_id: Some("codex_other".to_string()),
908                limit_name: Some("codex_other".to_string()),
909                primary: Some(RateLimitWindow {
910                    used_percent: 90.0,
911                    window_minutes: Some(60),
912                    resets_at: Some(1),
913                }),
914                secondary: None,
915                credits: None,
916                individual_limit: None,
917                spend_control_reached: None,
918                plan_type: Some(AccountPlanType::Pro),
919                rate_limit_reached_type: None,
920            },
921            RateLimitSnapshot {
922                limit_id: Some("codex".to_string()),
923                limit_name: Some("codex".to_string()),
924                primary: Some(RateLimitWindow {
925                    used_percent: 10.0,
926                    window_minutes: Some(60),
927                    resets_at: Some(2),
928                }),
929                secondary: None,
930                credits: None,
931                individual_limit: None,
932                spend_control_reached: None,
933                plan_type: Some(AccountPlanType::Pro),
934                rate_limit_reached_type: None,
935            },
936        ];
937
938        let preferred = snapshots
939            .iter()
940            .find(|snapshot| snapshot.limit_id.as_deref() == Some("codex"))
941            .cloned()
942            .unwrap_or_else(|| snapshots[0].clone());
943        assert_eq!(preferred.limit_id.as_deref(), Some("codex"));
944    }
945
946    #[test]
947    fn usage_payload_maps_every_rate_limit_reached_type() {
948        let cases = [
949            (
950                RateLimitReachedKind::RateLimitReached,
951                Some(RateLimitReachedType::RateLimitReached),
952            ),
953            (
954                RateLimitReachedKind::WorkspaceOwnerCreditsDepleted,
955                Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted),
956            ),
957            (
958                RateLimitReachedKind::WorkspaceMemberCreditsDepleted,
959                Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted),
960            ),
961            (
962                RateLimitReachedKind::WorkspaceOwnerUsageLimitReached,
963                Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached),
964            ),
965            (
966                RateLimitReachedKind::WorkspaceMemberUsageLimitReached,
967                Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached),
968            ),
969            (RateLimitReachedKind::Unknown, None),
970        ];
971
972        for (kind, expected) in cases {
973            let payload = RateLimitStatusPayload {
974                plan_type: crate::types::PlanType::Plus,
975                rate_limit: None,
976                credits: None,
977                spend_control: None,
978                additional_rate_limits: None,
979                rate_limit_reached_type: Some(Some(BackendRateLimitReachedType { kind })),
980            };
981
982            let snapshots = Client::rate_limit_snapshots_from_payload(payload);
983            assert_eq!(snapshots[0].rate_limit_reached_type, expected);
984        }
985    }
986
987    #[test]
988    fn usage_payload_preserves_absent_rate_limit_reached_type() {
989        let payload = RateLimitStatusPayload {
990            plan_type: crate::types::PlanType::Plus,
991            rate_limit: None,
992            credits: None,
993            spend_control: None,
994            additional_rate_limits: None,
995            rate_limit_reached_type: None,
996        };
997
998        let snapshots = Client::rate_limit_snapshots_from_payload(payload);
999        assert_eq!(snapshots[0].rate_limit_reached_type, None);
1000    }
1001
1002    #[test]
1003    fn add_credits_nudge_email_uses_expected_paths_and_bodies() {
1004        let codex_client = test_client("https://example.test", PathStyle::CodexApi);
1005        assert_eq!(
1006            codex_client.send_add_credits_nudge_email_url(),
1007            "https://example.test/api/codex/accounts/send_add_credits_nudge_email"
1008        );
1009
1010        let chatgpt_client = test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi);
1011        assert_eq!(
1012            chatgpt_client.send_add_credits_nudge_email_url(),
1013            "https://chatgpt.com/backend-api/wham/accounts/send_add_credits_nudge_email"
1014        );
1015
1016        assert_eq!(
1017            serde_json::to_value(SendAddCreditsNudgeEmailRequest {
1018                credit_type: AddCreditsNudgeCreditType::Credits,
1019            })
1020            .unwrap(),
1021            serde_json::json!({ "credit_type": "credits" })
1022        );
1023        assert_eq!(
1024            serde_json::to_value(SendAddCreditsNudgeEmailRequest {
1025                credit_type: AddCreditsNudgeCreditType::UsageLimit,
1026            })
1027            .unwrap(),
1028            serde_json::json!({ "credit_type": "usage_limit" })
1029        );
1030    }
1031
1032    #[test]
1033    fn token_usage_profile_uses_expected_paths() {
1034        let codex_client = test_client("https://example.test", PathStyle::CodexApi);
1035        assert_eq!(
1036            codex_client.token_usage_profile_url(),
1037            "https://example.test/api/codex/profiles/me"
1038        );
1039
1040        let chatgpt_client = test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi);
1041        assert_eq!(
1042            chatgpt_client.token_usage_profile_url(),
1043            "https://chatgpt.com/backend-api/wham/profiles/me"
1044        );
1045    }
1046
1047    #[test]
1048    fn workspace_messages_uses_expected_paths() {
1049        let codex_client = test_client("https://example.test", PathStyle::CodexApi);
1050        assert_eq!(
1051            codex_client.workspace_messages_url(),
1052            "https://example.test/api/codex/workspace-messages"
1053        );
1054
1055        let chatgpt_client = test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi);
1056        assert_eq!(
1057            chatgpt_client.workspace_messages_url(),
1058            "https://chatgpt.com/backend-api/wham/workspace-messages"
1059        );
1060    }
1061
1062    #[tokio::test]
1063    async fn user_settings_request_uses_expected_paths_and_revalidates_cached_responses() {
1064        let server = MockServer::start().await;
1065        for (request_path, commit_attribution_enabled) in [
1066            ("/api/codex/settings/user", true),
1067            ("/backend-api/wham/settings/user", false),
1068        ] {
1069            Mock::given(method("GET"))
1070                .and(path(request_path))
1071                .and(header_regex("cache-control", "^no-cache, no-store$"))
1072                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1073                    "commit_attribution_enabled": commit_attribution_enabled,
1074                })))
1075                .expect(1)
1076                .mount(&server)
1077                .await;
1078        }
1079
1080        let codex_response = Client::new(
1081            server.uri(),
1082            HttpClientFactory::new(codex_http_client::OutboundProxyPolicy::ReqwestDefault),
1083        )
1084        .get_user_settings()
1085        .await
1086        .unwrap();
1087        let chatgpt_response = Client::new(
1088            format!("{}/backend-api", server.uri()),
1089            HttpClientFactory::new(codex_http_client::OutboundProxyPolicy::ReqwestDefault),
1090        )
1091        .get_user_settings()
1092        .await
1093        .unwrap();
1094
1095        assert_eq!(
1096            [codex_response, chatgpt_response],
1097            [
1098                CodexUserSettingsResponse {
1099                    commit_attribution_enabled: true,
1100                },
1101                CodexUserSettingsResponse {
1102                    commit_attribution_enabled: false,
1103                },
1104            ]
1105        );
1106    }
1107
1108    #[test]
1109    fn user_settings_missing_attribution_policy_defaults_to_disabled() {
1110        assert_eq!(
1111            serde_json::from_value::<CodexUserSettingsResponse>(serde_json::json!({})).unwrap(),
1112            CodexUserSettingsResponse {
1113                commit_attribution_enabled: false,
1114            }
1115        );
1116    }
1117
1118    #[test]
1119    fn authenticated_user_settings_client_uses_active_workspace_headers() {
1120        let auth = CodexAuth::from_external_chatgpt_tokens(
1121            "e30.e30.c2ln",
1122            "workspace-123",
1123            Some("enterprise"),
1124        )
1125        .unwrap();
1126        let client = Client::from_auth(
1127            "https://chatgpt.com/backend-api",
1128            &auth,
1129            HttpClientFactory::new(codex_http_client::OutboundProxyPolicy::ReqwestDefault),
1130        );
1131        let headers = client.headers();
1132
1133        assert_eq!(
1134            [
1135                headers
1136                    .get("authorization")
1137                    .and_then(|value| value.to_str().ok()),
1138                headers
1139                    .get("chatgpt-account-id")
1140                    .and_then(|value| value.to_str().ok()),
1141            ],
1142            [Some("Bearer e30.e30.c2ln"), Some("workspace-123")]
1143        );
1144    }
1145
1146    fn test_client(base_url: &str, path_style: PathStyle) -> Client {
1147        Client {
1148            base_url: base_url.to_string(),
1149            http: RouteAwareClientPool::new(
1150                HttpClientFactory::new(codex_http_client::OutboundProxyPolicy::ReqwestDefault),
1151                ClientRouteClass::Api,
1152            ),
1153            auth_provider: codex_model_provider::unauthenticated_auth_provider(),
1154            user_agent: None,
1155            chatgpt_account_id: None,
1156            chatgpt_account_is_fedramp: false,
1157            path_style,
1158        }
1159    }
1160}