Download codex-rs/backend-client/src/client.rs from SaylorTwift/codex: direct link, hf CLI and curl.
- Browser
- Download file 45.7 kB
-
https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/backend-client/src/client.rs
- Command line
-
hf download hf://SaylorTwift/codex/codex-rs/backend-client/src/client.rs
-
curl -L -o client.rs https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/backend-client/src/client.rs
45.7 kB
| use crate::types::AccountsCheckResponse; | |
| use crate::types::CodeTaskDetailsResponse; | |
| use crate::types::CodexUserSettingsResponse; | |
| use crate::types::CodexWorkspaceMessagesResponse; | |
| use crate::types::ConfigBundleResponse; | |
| use crate::types::PaginatedListTaskListItem; | |
| use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind; | |
| use crate::types::RateLimitStatusPayload; | |
| use crate::types::TokenUsageProfile; | |
| use crate::types::TurnAttemptsSiblingTurnsResponse; | |
| use anyhow::Result; | |
| use codex_api::SharedAuthProvider; | |
| use codex_http_client::ClientRouteClass; | |
| use codex_http_client::HttpClientFactory; | |
| use codex_http_client::RouteAwareClientPool; | |
| use codex_http_client::RouteAwareRequestBuilder; | |
| use codex_login::CodexAuth; | |
| use codex_login::default_client::get_codex_user_agent; | |
| use codex_protocol::account::PlanType as AccountPlanType; | |
| use codex_protocol::protocol::CreditsSnapshot; | |
| use codex_protocol::protocol::RateLimitReachedType; | |
| use codex_protocol::protocol::RateLimitSnapshot; | |
| use codex_protocol::protocol::RateLimitWindow; | |
| use codex_protocol::protocol::SpendControlLimitSnapshot; | |
| use http::Method; | |
| use http::StatusCode; | |
| use http::header::CACHE_CONTROL; | |
| use http::header::CONTENT_TYPE; | |
| use http::header::HeaderMap; | |
| use http::header::HeaderName; | |
| use http::header::HeaderValue; | |
| use http::header::USER_AGENT; | |
| use serde::Serialize; | |
| use serde::de::DeserializeOwned; | |
| use std::fmt; | |
| pub(crate) mod analytics; | |
| mod chatgpt_turn_cost; | |
| pub(crate) mod plan_history; | |
| pub(crate) mod profile; | |
| mod rate_limit_resets; | |
| pub(crate) mod task_usage; | |
| mod thread_usage; | |
| pub(crate) mod turn_usage; | |
| pub use chatgpt_turn_cost::ChatgptThreadTurnCosts; | |
| pub use chatgpt_turn_cost::ChatgptTurnCost; | |
| pub use thread_usage::ThreadUsage; | |
| pub use thread_usage::ThreadUsageBreakdownGroup; | |
| 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) | |
| } | |
| } | |
| pub enum AddCreditsNudgeCreditType { | |
| Credits, | |
| UsageLimit, | |
| } | |
| struct SendAddCreditsNudgeEmailRequest { | |
| credit_type: AddCreditsNudgeCreditType, | |
| } | |
| 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 | |
| } | |
| } | |
| } | |
| pub struct Client { | |
| base_url: String, | |
| http: RouteAwareClientPool, | |
| 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>, http_client_factory: HttpClientFactory) -> Self { | |
| let http = RouteAwareClientPool::with_chatgpt_cloudflare_cookies_without_request_logging( | |
| http_client_factory, | |
| ClientRouteClass::Api, | |
| ); | |
| Self::with_http(base_url.into(), http) | |
| } | |
| /// Creates a client that never forwards its credentials to a redirect destination. | |
| pub fn new_without_redirects( | |
| base_url: impl Into<String>, | |
| http_client_factory: HttpClientFactory, | |
| ) -> Self { | |
| let http = | |
| RouteAwareClientPool::with_chatgpt_cloudflare_cookies_without_redirects_or_request_logging( | |
| http_client_factory, | |
| ClientRouteClass::Api, | |
| ); | |
| Self::with_http(base_url.into(), http) | |
| } | |
| fn with_http(mut base_url: String, http: RouteAwareClientPool) -> Self { | |
| // Normalize common ChatGPT hostnames to include /backend-api so we hit the WHAM paths. | |
| // Also trim trailing slashes for consistent URL building. | |
| while base_url.ends_with('/') { | |
| base_url.pop(); | |
| } | |
| if (base_url.starts_with("https://chatgpt.com") | |
| || base_url.starts_with("https://chat.openai.com")) | |
| && !base_url.contains("/backend-api") | |
| { | |
| base_url = format!("{base_url}/backend-api"); | |
| } | |
| let path_style = PathStyle::from_base_url(&base_url); | |
| Self { | |
| base_url, | |
| http, | |
| auth_provider: codex_model_provider::unauthenticated_auth_provider(), | |
| user_agent: None, | |
| chatgpt_account_id: None, | |
| chatgpt_account_is_fedramp: false, | |
| path_style, | |
| } | |
| } | |
| pub fn from_auth( | |
| base_url: impl Into<String>, | |
| auth: &CodexAuth, | |
| http_client_factory: HttpClientFactory, | |
| ) -> Self { | |
| Self::new(base_url, http_client_factory) | |
| .with_user_agent(get_codex_user_agent()) | |
| .with_auth_provider(codex_model_provider::auth_provider_from_auth(auth)) | |
| } | |
| pub fn with_auth_provider(mut self, auth: SharedAuthProvider) -> Self { | |
| self.auth_provider = auth; | |
| self | |
| } | |
| pub fn with_user_agent(mut self, ua: impl Into<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 | |
| } | |
| fn request(&self, method: Method, url: &str) -> RouteAwareRequestBuilder { | |
| self.http.request(method, url) | |
| } | |
| async fn exec_request( | |
| &self, | |
| req: RouteAwareRequestBuilder, | |
| method: &str, | |
| url: &str, | |
| ) -> Result<(String, String)> { | |
| let res = req.send().await?; | |
| let status = res.status(); | |
| let ct = res | |
| .headers() | |
| .get(CONTENT_TYPE) | |
| .and_then(|v| v.to_str().ok()) | |
| .unwrap_or("") | |
| .to_string(); | |
| let body = res.text().await.unwrap_or_default(); | |
| if !status.is_success() { | |
| anyhow::bail!("{method} {url} failed: {status}; content-type={ct}; body={body}"); | |
| } | |
| Ok((body, ct)) | |
| } | |
| async fn exec_request_detailed( | |
| &self, | |
| req: RouteAwareRequestBuilder, | |
| method: &str, | |
| url: &str, | |
| ) -> std::result::Result<(String, String), RequestError> { | |
| let res = req.send().await.map_err(anyhow::Error::from)?; | |
| let status = res.status(); | |
| let content_type = res | |
| .headers() | |
| .get(CONTENT_TYPE) | |
| .and_then(|v| v.to_str().ok()) | |
| .unwrap_or("") | |
| .to_string(); | |
| let body = res.text().await.unwrap_or_default(); | |
| if !status.is_success() { | |
| return Err(RequestError::UnexpectedStatus { | |
| method: method.to_string(), | |
| url: url.to_string(), | |
| status, | |
| content_type, | |
| body, | |
| }); | |
| } | |
| Ok((body, content_type)) | |
| } | |
| fn decode_json<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, | |
| ) -> std::result::Result<AccountsCheckResponse, RequestError> { | |
| let url = match self.path_style { | |
| PathStyle::CodexApi => format!("{}/api/codex/accounts/check", self.base_url), | |
| PathStyle::ChatGptApi => format!("{}/wham/accounts/check", self.base_url), | |
| }; | |
| let req = self.request(Method::GET, &url).headers(self.headers()); | |
| let (body, _) = self.exec_request_detailed(req, "GET", &url).await?; | |
| serde_json::from_str(&body) | |
| .map_err(|_| RequestError::Other(anyhow::anyhow!("Invalid accounts response."))) | |
| } | |
| pub async fn get_token_usage_profile(&self) -> Result<TokenUsageProfile> { | |
| let url = self.token_usage_profile_url(); | |
| let req = self.request(Method::GET, &url).headers(self.headers()); | |
| let (body, ct) = self.exec_request(req, "GET", &url).await?; | |
| self.decode_json(&url, &ct, &body) | |
| } | |
| fn token_usage_profile_url(&self) -> String { | |
| match self.path_style { | |
| PathStyle::CodexApi => format!("{}/api/codex/profiles/me", self.base_url), | |
| PathStyle::ChatGptApi => format!("{}/wham/profiles/me", self.base_url), | |
| } | |
| } | |
| pub async fn send_add_credits_nudge_email( | |
| &self, | |
| credit_type: AddCreditsNudgeCreditType, | |
| ) -> std::result::Result<(), RequestError> { | |
| let url = self.send_add_credits_nudge_email_url(); | |
| let req = self | |
| .request(Method::POST, &url) | |
| .headers(self.headers()) | |
| .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) | |
| .json(&SendAddCreditsNudgeEmailRequest { credit_type }); | |
| self.exec_request_detailed(req, "POST", &url).await?; | |
| Ok(()) | |
| } | |
| pub async fn list_tasks( | |
| &self, | |
| limit: Option<i32>, | |
| task_filter: Option<&str>, | |
| environment_id: Option<&str>, | |
| cursor: Option<&str>, | |
| ) -> Result<PaginatedListTaskListItem> { | |
| let url = self.list_tasks_url(limit, task_filter, environment_id, cursor)?; | |
| let req = self.request(Method::GET, &url).headers(self.headers()); | |
| let (body, ct) = self.exec_request(req, "GET", &url).await?; | |
| self.decode_json::<PaginatedListTaskListItem>(&url, &ct, &body) | |
| } | |
| fn list_tasks_url( | |
| &self, | |
| limit: Option<i32>, | |
| task_filter: Option<&str>, | |
| environment_id: Option<&str>, | |
| cursor: Option<&str>, | |
| ) -> Result<String> { | |
| let url = match self.path_style { | |
| PathStyle::CodexApi => format!("{}/api/codex/tasks/list", self.base_url), | |
| PathStyle::ChatGptApi => format!("{}/wham/tasks/list", self.base_url), | |
| }; | |
| if limit.is_none() && task_filter.is_none() && environment_id.is_none() && cursor.is_none() | |
| { | |
| return Ok(url); | |
| } | |
| let mut url = url::Url::parse(&url)?; | |
| { | |
| let mut query = url.query_pairs_mut(); | |
| if let Some(limit) = limit { | |
| query.append_pair("limit", &limit.to_string()); | |
| } | |
| if let Some(task_filter) = task_filter { | |
| query.append_pair("task_filter", task_filter); | |
| } | |
| if let Some(cursor) = cursor { | |
| query.append_pair("cursor", cursor); | |
| } | |
| if let Some(environment_id) = environment_id { | |
| query.append_pair("environment_id", environment_id); | |
| } | |
| } | |
| Ok(url.to_string()) | |
| } | |
| pub async fn get_task_details(&self, task_id: &str) -> Result<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.request(Method::GET, &url).headers(self.headers()); | |
| let (body, ct) = self.exec_request(req, "GET", &url).await?; | |
| let parsed: CodeTaskDetailsResponse = self.decode_json(&url, &ct, &body)?; | |
| Ok((parsed, body, ct)) | |
| } | |
| pub async fn list_sibling_turns( | |
| &self, | |
| task_id: &str, | |
| turn_id: &str, | |
| ) -> Result<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.request(Method::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.request(Method::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) | |
| } | |
| /// Fetch authenticated Codex user settings from the active backend route. | |
| /// | |
| /// Uses `GET /api/codex/settings/user` for Codex API hosts and | |
| /// `GET /wham/settings/user` for ChatGPT `backend-api` hosts. | |
| pub async fn get_user_settings( | |
| &self, | |
| ) -> std::result::Result<CodexUserSettingsResponse, RequestError> { | |
| let url = self.user_settings_url(); | |
| let req = self | |
| .request(Method::GET, &url) | |
| .headers(self.headers()) | |
| .header( | |
| CACHE_CONTROL, | |
| HeaderValue::from_static("no-cache, no-store"), | |
| ); | |
| let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?; | |
| self.decode_json::<CodexUserSettingsResponse>(&url, &ct, &body) | |
| .map_err(RequestError::from) | |
| } | |
| pub async fn list_workspace_messages( | |
| &self, | |
| ) -> std::result::Result<CodexWorkspaceMessagesResponse, RequestError> { | |
| let url = self.workspace_messages_url(); | |
| let req = self | |
| .request(Method::GET, &url) | |
| .headers(self.headers()) | |
| .header(CACHE_CONTROL, HeaderValue::from_static("no-store")); | |
| let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?; | |
| self.decode_json::<CodexWorkspaceMessagesResponse>(&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 | |
| .request(Method::POST, &url) | |
| .headers(self.headers()) | |
| .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) | |
| .json(&request_body); | |
| let (body, ct) = self.exec_request(req, "POST", &url).await?; | |
| // Extract id from JSON: prefer `task.id`; fallback to top-level `id` when present. | |
| match serde_json::from_str::<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 mut snapshots = vec![Self::make_rate_limit_snapshot( | |
| Some("codex".to_string()), | |
| /*limit_name*/ None, | |
| payload.rate_limit.flatten().map(|details| *details), | |
| payload.credits.flatten().map(|details| *details), | |
| payload.spend_control.flatten().map(|details| *details), | |
| plan_type, | |
| rate_limit_reached_type, | |
| )]; | |
| if let Some(additional) = payload.additional_rate_limits.flatten() { | |
| snapshots.extend( | |
| additional | |
| .into_iter() | |
| .map(|details| Self::make_additional_rate_limit_snapshot(details, plan_type)), | |
| ); | |
| } | |
| snapshots | |
| } | |
| fn make_additional_rate_limit_snapshot( | |
| details: codex_backend_openapi_models::models::AdditionalRateLimitDetails, | |
| plan_type: Option<AccountPlanType>, | |
| ) -> RateLimitSnapshot { | |
| Self::make_rate_limit_snapshot( | |
| Some(details.metered_feature), | |
| Some(details.limit_name), | |
| details.rate_limit.flatten().map(|rate_limit| *rate_limit), | |
| /*credits*/ None, | |
| /*spend_control*/ None, | |
| plan_type, | |
| /*rate_limit_reached_type*/ None, | |
| ) | |
| } | |
| fn make_rate_limit_snapshot( | |
| limit_id: Option<String>, | |
| limit_name: Option<String>, | |
| rate_limit: Option<crate::types::RateLimitStatusDetails>, | |
| credits: Option<crate::types::CreditStatusDetails>, | |
| spend_control: Option<codex_backend_openapi_models::models::SpendControlStatusDetails>, | |
| 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), | |
| }; | |
| let spend_control_reached = spend_control.as_ref().map(|details| details.reached); | |
| let individual_limit = spend_control | |
| .and_then(|details| details.individual_limit.flatten()) | |
| .map(|details| Self::map_individual_limit(*details)); | |
| RateLimitSnapshot { | |
| limit_id, | |
| limit_name, | |
| normal_model_slug: None, | |
| primary, | |
| secondary, | |
| credits: Self::map_credits(credits), | |
| individual_limit, | |
| spend_control_reached, | |
| plan_type, | |
| rate_limit_reached_type, | |
| } | |
| } | |
| fn map_rate_limit_reached_type( | |
| kind: BackendRateLimitReachedKind, | |
| ) -> Option<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 workspace_messages_url(&self) -> String { | |
| match self.path_style { | |
| PathStyle::CodexApi => format!("{}/api/codex/workspace-messages", self.base_url), | |
| PathStyle::ChatGptApi => format!("{}/wham/workspace-messages", self.base_url), | |
| } | |
| } | |
| fn user_settings_url(&self) -> String { | |
| match self.path_style { | |
| PathStyle::CodexApi => format!("{}/api/codex/settings/user", self.base_url), | |
| PathStyle::ChatGptApi => format!("{}/wham/settings/user", self.base_url), | |
| } | |
| } | |
| fn map_rate_limit_window( | |
| window: Option<Option<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::SelfServeBusinessProLite => { | |
| AccountPlanType::SelfServeBusinessProLite | |
| } | |
| crate::types::PlanType::SelfServeBusinessUsageBased => { | |
| AccountPlanType::SelfServeBusinessUsageBased | |
| } | |
| crate::types::PlanType::Business => AccountPlanType::Business, | |
| crate::types::PlanType::Ent26 => AccountPlanType::Ent26, | |
| crate::types::PlanType::EnterpriseCbpAutomation => { | |
| AccountPlanType::EnterpriseCbpAutomation | |
| } | |
| crate::types::PlanType::EnterpriseCbpUsageBased => { | |
| AccountPlanType::EnterpriseCbpUsageBased | |
| } | |
| crate::types::PlanType::Enterprise => AccountPlanType::Enterprise, | |
| crate::types::PlanType::Edu | crate::types::PlanType::Education => AccountPlanType::Edu, | |
| crate::types::PlanType::EduPlus => AccountPlanType::EduPlus, | |
| crate::types::PlanType::EduPro => AccountPlanType::EduPro, | |
| crate::types::PlanType::Guest | |
| | crate::types::PlanType::FreeWorkspace | |
| | crate::types::PlanType::Quorum | |
| | crate::types::PlanType::K12 | |
| | crate::types::PlanType::Unknown => AccountPlanType::Unknown, | |
| } | |
| } | |
| fn window_minutes_from_seconds(seconds: i32) -> Option<i64> { | |
| if seconds <= 0 { | |
| return None; | |
| } | |
| let seconds_i64 = i64::from(seconds); | |
| Some((seconds_i64 + 59) / 60) | |
| } | |
| } | |
| mod request_tests; | |
| mod tests { | |
| use super::*; | |
| use codex_backend_openapi_models::models::AdditionalRateLimitDetails; | |
| use codex_backend_openapi_models::models::RateLimitReachedKind; | |
| use codex_backend_openapi_models::models::RateLimitReachedType as BackendRateLimitReachedType; | |
| use pretty_assertions::assert_eq; | |
| use wiremock::Mock; | |
| use wiremock::MockServer; | |
| use wiremock::ResponseTemplate; | |
| use wiremock::matchers::header_regex; | |
| use wiremock::matchers::method; | |
| use wiremock::matchers::path; | |
| fn map_plan_type_supports_business_variants() { | |
| let business_prolite = | |
| serde_json::from_str::<crate::types::PlanType>("\"self_serve_business_prolite\"") | |
| .expect("business ProLite should deserialize"); | |
| assert_eq!( | |
| Client::map_plan_type(business_prolite), | |
| AccountPlanType::SelfServeBusinessProLite | |
| ); | |
| assert_eq!( | |
| Client::map_plan_type(crate::types::PlanType::SelfServeBusinessUsageBased), | |
| AccountPlanType::SelfServeBusinessUsageBased | |
| ); | |
| assert_eq!( | |
| Client::map_plan_type(crate::types::PlanType::EnterpriseCbpUsageBased), | |
| AccountPlanType::EnterpriseCbpUsageBased | |
| ); | |
| assert_eq!( | |
| Client::map_plan_type(crate::types::PlanType::EnterpriseCbpAutomation), | |
| AccountPlanType::EnterpriseCbpAutomation | |
| ); | |
| let ent26 = serde_json::from_str::<crate::types::PlanType>("\"ent26\"") | |
| .expect("ent26 backend plan should deserialize"); | |
| assert_eq!(Client::map_plan_type(ent26), AccountPlanType::Ent26); | |
| } | |
| fn usage_payload_maps_primary_and_additional_rate_limits() { | |
| let payload = RateLimitStatusPayload { | |
| plan_type: crate::types::PlanType::Pro, | |
| rate_limit: Some(Some(Box::new(crate::types::RateLimitStatusDetails { | |
| primary_window: Some(Some(Box::new(crate::types::RateLimitWindowSnapshot { | |
| used_percent: 42, | |
| limit_window_seconds: 300, | |
| reset_after_seconds: 0, | |
| reset_at: 123, | |
| }))), | |
| secondary_window: Some(Some(Box::new(crate::types::RateLimitWindowSnapshot { | |
| used_percent: 84, | |
| limit_window_seconds: 3600, | |
| reset_after_seconds: 0, | |
| reset_at: 456, | |
| }))), | |
| ..Default::default() | |
| }))), | |
| additional_rate_limits: Some(Some(vec![AdditionalRateLimitDetails { | |
| limit_name: "codex_other".to_string(), | |
| metered_feature: "codex_other".to_string(), | |
| rate_limit: Some(Some(Box::new(crate::types::RateLimitStatusDetails { | |
| primary_window: Some(Some(Box::new(crate::types::RateLimitWindowSnapshot { | |
| used_percent: 70, | |
| limit_window_seconds: 900, | |
| reset_after_seconds: 0, | |
| reset_at: 789, | |
| }))), | |
| secondary_window: None, | |
| ..Default::default() | |
| }))), | |
| }])), | |
| credits: Some(Some(Box::new(crate::types::CreditStatusDetails { | |
| has_credits: true, | |
| unlimited: false, | |
| balance: Some(Some("9.99".to_string())), | |
| ..Default::default() | |
| }))), | |
| spend_control: Some(Some(Box::new( | |
| codex_backend_openapi_models::models::SpendControlStatusDetails { | |
| reached: false, | |
| individual_limit: Some(Some(Box::new( | |
| crate::types::SpendControlLimitDetails { | |
| source: None, | |
| limit: "25000".to_string(), | |
| used: "8000".to_string(), | |
| remaining: "17000".to_string(), | |
| used_percent: 32, | |
| remaining_percent: 68, | |
| reset_after_seconds: 3600, | |
| reset_at: 789, | |
| }, | |
| ))), | |
| }, | |
| ))), | |
| rate_limit_reached_type: Some(Some(BackendRateLimitReachedType { | |
| kind: RateLimitReachedKind::WorkspaceMemberCreditsDepleted, | |
| })), | |
| }; | |
| let snapshots = Client::rate_limit_snapshots_from_payload(payload); | |
| assert_eq!(snapshots.len(), 2); | |
| assert_eq!(snapshots[0].limit_id.as_deref(), Some("codex")); | |
| assert_eq!(snapshots[0].limit_name, None); | |
| assert_eq!( | |
| snapshots[0].primary.as_ref().map(|w| w.used_percent), | |
| Some(42.0) | |
| ); | |
| assert_eq!( | |
| snapshots[0].secondary.as_ref().map(|w| w.used_percent), | |
| Some(84.0) | |
| ); | |
| assert_eq!( | |
| snapshots[0].credits, | |
| Some(CreditsSnapshot { | |
| has_credits: true, | |
| unlimited: false, | |
| balance: Some("9.99".to_string()), | |
| }) | |
| ); | |
| assert_eq!(snapshots[0].plan_type, Some(AccountPlanType::Pro)); | |
| assert_eq!(snapshots[0].spend_control_reached, Some(false)); | |
| assert_eq!( | |
| snapshots[0].rate_limit_reached_type, | |
| Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) | |
| ); | |
| assert_eq!( | |
| snapshots[0].individual_limit, | |
| Some(SpendControlLimitSnapshot { | |
| limit: "25000".to_string(), | |
| used: "8000".to_string(), | |
| remaining_percent: 68, | |
| resets_at: 789, | |
| }) | |
| ); | |
| assert_eq!(snapshots[1].limit_id.as_deref(), Some("codex_other")); | |
| assert_eq!(snapshots[1].limit_name.as_deref(), Some("codex_other")); | |
| assert_eq!( | |
| snapshots[1].primary.as_ref().map(|w| w.used_percent), | |
| Some(70.0) | |
| ); | |
| assert_eq!(snapshots[1].credits, None); | |
| assert_eq!(snapshots[1].individual_limit, None); | |
| assert_eq!(snapshots[1].spend_control_reached, None); | |
| assert_eq!(snapshots[1].plan_type, Some(AccountPlanType::Pro)); | |
| assert_eq!(snapshots[1].rate_limit_reached_type, None); | |
| } | |
| 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")); | |
| } | |
| fn usage_payload_maps_spend_control_reached_without_individual_limit() { | |
| let payload = RateLimitStatusPayload { | |
| plan_type: crate::types::PlanType::EnterpriseCbpUsageBased, | |
| rate_limit: None, | |
| additional_rate_limits: None, | |
| credits: None, | |
| spend_control: Some(Some(Box::new( | |
| codex_backend_openapi_models::models::SpendControlStatusDetails { | |
| reached: true, | |
| individual_limit: None, | |
| }, | |
| ))), | |
| rate_limit_reached_type: None, | |
| }; | |
| let snapshots = Client::rate_limit_snapshots_from_payload(payload); | |
| assert_eq!(snapshots.len(), 1); | |
| assert_eq!(snapshots[0].spend_control_reached, Some(true)); | |
| assert_eq!(snapshots[0].individual_limit, None); | |
| } | |
| fn preferred_snapshot_selection_matches_get_rate_limits_behavior() { | |
| let snapshots = [ | |
| RateLimitSnapshot { | |
| limit_id: Some("codex_other".to_string()), | |
| limit_name: Some("codex_other".to_string()), | |
| normal_model_slug: None, | |
| primary: Some(RateLimitWindow { | |
| used_percent: 90.0, | |
| window_minutes: Some(60), | |
| resets_at: Some(1), | |
| }), | |
| secondary: None, | |
| credits: None, | |
| individual_limit: None, | |
| spend_control_reached: None, | |
| plan_type: Some(AccountPlanType::Pro), | |
| rate_limit_reached_type: None, | |
| }, | |
| RateLimitSnapshot { | |
| limit_id: Some("codex".to_string()), | |
| limit_name: Some("codex".to_string()), | |
| normal_model_slug: None, | |
| primary: Some(RateLimitWindow { | |
| used_percent: 10.0, | |
| window_minutes: Some(60), | |
| resets_at: Some(2), | |
| }), | |
| secondary: None, | |
| credits: None, | |
| individual_limit: None, | |
| spend_control_reached: None, | |
| plan_type: Some(AccountPlanType::Pro), | |
| rate_limit_reached_type: None, | |
| }, | |
| ]; | |
| let preferred = snapshots | |
| .iter() | |
| .find(|snapshot| snapshot.limit_id.as_deref() == Some("codex")) | |
| .cloned() | |
| .unwrap_or_else(|| snapshots[0].clone()); | |
| assert_eq!(preferred.limit_id.as_deref(), Some("codex")); | |
| } | |
| 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); | |
| } | |
| } | |
| 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); | |
| } | |
| 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" }) | |
| ); | |
| } | |
| 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 workspace_messages_uses_expected_paths() { | |
| let codex_client = test_client("https://example.test", PathStyle::CodexApi); | |
| assert_eq!( | |
| codex_client.workspace_messages_url(), | |
| "https://example.test/api/codex/workspace-messages" | |
| ); | |
| let chatgpt_client = test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi); | |
| assert_eq!( | |
| chatgpt_client.workspace_messages_url(), | |
| "https://chatgpt.com/backend-api/wham/workspace-messages" | |
| ); | |
| } | |
| async fn user_settings_request_uses_expected_paths_and_revalidates_cached_responses() { | |
| let server = MockServer::start().await; | |
| for (request_path, commit_attribution_enabled) in [ | |
| ("/api/codex/settings/user", true), | |
| ("/backend-api/wham/settings/user", false), | |
| ] { | |
| Mock::given(method("GET")) | |
| .and(path(request_path)) | |
| .and(header_regex("cache-control", "^no-cache, no-store$")) | |
| .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ | |
| "commit_attribution_enabled": commit_attribution_enabled, | |
| }))) | |
| .expect(1) | |
| .mount(&server) | |
| .await; | |
| } | |
| let codex_response = Client::new( | |
| server.uri(), | |
| HttpClientFactory::new(codex_http_client::OutboundProxyPolicy::ReqwestDefault), | |
| ) | |
| .get_user_settings() | |
| .await | |
| .unwrap(); | |
| let chatgpt_response = Client::new( | |
| format!("{}/backend-api", server.uri()), | |
| HttpClientFactory::new(codex_http_client::OutboundProxyPolicy::ReqwestDefault), | |
| ) | |
| .get_user_settings() | |
| .await | |
| .unwrap(); | |
| assert_eq!( | |
| [codex_response, chatgpt_response], | |
| [ | |
| CodexUserSettingsResponse { | |
| commit_attribution_enabled: true, | |
| }, | |
| CodexUserSettingsResponse { | |
| commit_attribution_enabled: false, | |
| }, | |
| ] | |
| ); | |
| } | |
| fn user_settings_missing_attribution_policy_defaults_to_disabled() { | |
| assert_eq!( | |
| serde_json::from_value::<CodexUserSettingsResponse>(serde_json::json!({})).unwrap(), | |
| CodexUserSettingsResponse { | |
| commit_attribution_enabled: false, | |
| } | |
| ); | |
| } | |
| fn authenticated_user_settings_client_uses_active_workspace_headers() { | |
| let auth = CodexAuth::from_external_chatgpt_tokens( | |
| "e30.e30.c2ln", | |
| "workspace-123", | |
| Some("enterprise"), | |
| ) | |
| .unwrap(); | |
| let client = Client::from_auth( | |
| "https://chatgpt.com/backend-api", | |
| &auth, | |
| HttpClientFactory::new(codex_http_client::OutboundProxyPolicy::ReqwestDefault), | |
| ); | |
| let headers = client.headers(); | |
| assert_eq!( | |
| [ | |
| headers | |
| .get("authorization") | |
| .and_then(|value| value.to_str().ok()), | |
| headers | |
| .get("chatgpt-account-id") | |
| .and_then(|value| value.to_str().ok()), | |
| ], | |
| [Some("Bearer e30.e30.c2ln"), Some("workspace-123")] | |
| ); | |
| } | |
| fn test_client(base_url: &str, path_style: PathStyle) -> Client { | |
| Client { | |
| base_url: base_url.to_string(), | |
| http: RouteAwareClientPool::new( | |
| HttpClientFactory::new(codex_http_client::OutboundProxyPolicy::ReqwestDefault), | |
| ClientRouteClass::Api, | |
| ), | |
| auth_provider: codex_model_provider::unauthenticated_auth_provider(), | |
| user_agent: None, | |
| chatgpt_account_id: None, | |
| chatgpt_account_is_fedramp: false, | |
| path_style, | |
| } | |
| } | |
| } | |