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 CodexApi,
113 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 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 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 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 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 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 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 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 None,
550 None,
551 plan_type,
552 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}