1use super::types::CodexUsageResponse;
2use crate::Cookie;
3use serde::Deserialize;
4use std::time::Duration;
5
6const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
7 AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36";
8const SESSION_URL: &str = "https://chatgpt.com/api/auth/session";
9const USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
10const USAGE_REFERER: &str = "https://chatgpt.com/codex/settings/usage";
11
12#[derive(Debug, Deserialize)]
13struct SessionResponse {
14 #[serde(rename = "accessToken")]
15 access_token: Option<String>,
16 error: Option<String>,
17}
18
19pub struct CodexClient;
20
21impl CodexClient {
22 pub async fn fetch_usage(
23 cookies: &[Cookie],
24 ) -> Result<CodexUsageResponse, Box<dyn std::error::Error>> {
25 let cookie_header = Self::build_cookie_header(cookies);
26 let client = reqwest::Client::builder()
27 .timeout(Duration::from_secs(30))
28 .user_agent(USER_AGENT)
29 .build()?;
30
31 let access_token = Self::fetch_access_token(&client, &cookie_header).await?;
32
33 let response = client
34 .get(USAGE_URL)
35 .header("Cookie", &cookie_header)
36 .header("Accept", "application/json")
37 .header("Authorization", format!("Bearer {}", access_token))
38 .header("Referer", USAGE_REFERER)
39 .send()
40 .await?;
41
42 let status = response.status();
43 if !status.is_success() {
44 let body = response.text().await.unwrap_or_default();
45 let body = Self::truncate_body(&body);
46 return Err(format!("Codex usage API error: {} - {}", status, body).into());
47 }
48
49 Ok(response.json().await?)
50 }
51
52 async fn fetch_access_token(
53 client: &reqwest::Client,
54 cookie_header: &str,
55 ) -> Result<String, Box<dyn std::error::Error>> {
56 let response = client
57 .get(SESSION_URL)
58 .header("Cookie", cookie_header)
59 .header("Accept", "application/json")
60 .send()
61 .await?;
62
63 let status = response.status();
64 if !status.is_success() {
65 let body = response.text().await.unwrap_or_default();
66 let body = Self::truncate_body(&body);
67 return Err(format!("Codex session API error: {} - {}", status, body).into());
68 }
69
70 let session: SessionResponse = response.json().await?;
71 match session.access_token {
72 Some(token) if !token.is_empty() => Ok(token),
73 _ => {
74 let detail = session
75 .error
76 .unwrap_or_else(|| "missing access token".to_string());
77 Err(format!("Codex session did not return an access token: {}", detail).into())
78 }
79 }
80 }
81
82 fn build_cookie_header(cookies: &[Cookie]) -> String {
83 cookies
84 .iter()
85 .filter(|c| !c.value.bytes().any(|b| b < 0x20 || b == 0x7f))
86 .map(|c| format!("{}={}", c.name, c.value))
87 .collect::<Vec<_>>()
88 .join("; ")
89 }
90
91 fn truncate_body(body: &str) -> String {
92 let mut chars = body.chars();
93 let preview: String = chars.by_ref().take(200).collect();
94 if chars.next().is_some() {
95 format!("{}...", preview)
96 } else {
97 preview
98 }
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::CodexClient;
105
106 #[test]
107 fn truncate_body_preserves_utf8_boundaries() {
108 let body = "あ".repeat(201);
109
110 let truncated = CodexClient::truncate_body(&body);
111
112 assert!(truncated.ends_with("..."));
113 assert_eq!(truncated.chars().count(), 203);
114 assert!(truncated.starts_with(&"あ".repeat(200)));
115 }
116}