Skip to main content

seher/codex/
client.rs

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    /// # Errors
23    ///
24    /// Returns an error if fetching the session or usage API fails, or the response cannot be
25    /// parsed.
26    pub async fn fetch_usage(
27        cookies: &[Cookie],
28    ) -> Result<CodexUsageResponse, Box<dyn std::error::Error>> {
29        let cookie_header = Self::build_cookie_header(cookies);
30        let client = Self::build_client()?;
31
32        let access_token = Self::fetch_access_token(&client, &cookie_header).await?;
33
34        let response = client
35            .get(USAGE_URL)
36            .header("Cookie", &cookie_header)
37            .header("Accept", "application/json")
38            .header("Authorization", format!("Bearer {access_token}"))
39            .header("Referer", USAGE_REFERER)
40            .send()
41            .await?;
42
43        let status = response.status();
44        if !status.is_success() {
45            let body = response.text().await.unwrap_or_default();
46            let body = Self::truncate_body(&body);
47            return Err(format!("Codex usage API error: {status} - {body}").into());
48        }
49
50        Ok(response.json().await?)
51    }
52
53    /// # Errors
54    ///
55    /// Returns an error if the session API request fails or the response cannot be parsed.
56    pub async fn session_has_access_token(
57        cookies: &[Cookie],
58    ) -> Result<bool, Box<dyn std::error::Error>> {
59        let cookie_header = Self::build_cookie_header(cookies);
60        let client = Self::build_client()?;
61        let session = Self::fetch_session(&client, &cookie_header).await?;
62
63        Ok(Self::extract_access_token(session).is_ok())
64    }
65
66    fn build_client() -> Result<reqwest::Client, reqwest::Error> {
67        reqwest::Client::builder()
68            .timeout(Duration::from_secs(30))
69            .user_agent(USER_AGENT)
70            .build()
71    }
72
73    async fn fetch_access_token(
74        client: &reqwest::Client,
75        cookie_header: &str,
76    ) -> Result<String, Box<dyn std::error::Error>> {
77        let session = Self::fetch_session(client, cookie_header).await?;
78
79        Self::extract_access_token(session).map_err(|detail| {
80            format!("Codex session did not return an access token: {detail}").into()
81        })
82    }
83
84    async fn fetch_session(
85        client: &reqwest::Client,
86        cookie_header: &str,
87    ) -> Result<SessionResponse, Box<dyn std::error::Error>> {
88        let response = client
89            .get(SESSION_URL)
90            .header("Cookie", cookie_header)
91            .header("Accept", "application/json")
92            .send()
93            .await?;
94
95        let status = response.status();
96        if !status.is_success() {
97            let body = response.text().await.unwrap_or_default();
98            let body = Self::truncate_body(&body);
99            return Err(format!("Codex session API error: {status} - {body}").into());
100        }
101
102        Ok(response.json().await?)
103    }
104
105    fn extract_access_token(session: SessionResponse) -> Result<String, String> {
106        match session.access_token {
107            Some(token) if !token.is_empty() => Ok(token),
108            _ => Err(session
109                .error
110                .unwrap_or_else(|| "missing access token".to_string())),
111        }
112    }
113
114    fn build_cookie_header(cookies: &[Cookie]) -> String {
115        cookies
116            .iter()
117            .filter(|c| !c.value.bytes().any(|b| b < 0x20 || b == 0x7f))
118            .map(|c| format!("{}={}", c.name, c.value))
119            .collect::<Vec<_>>()
120            .join("; ")
121    }
122
123    fn truncate_body(body: &str) -> String {
124        let mut chars = body.chars();
125        let preview: String = chars.by_ref().take(200).collect();
126        if chars.next().is_some() {
127            format!("{preview}...")
128        } else {
129            preview
130        }
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::{CodexClient, SessionResponse};
137
138    #[test]
139    fn extract_access_token_returns_token_when_present() {
140        let session = SessionResponse {
141            access_token: Some("token-123".to_string()),
142            error: Some("ignored".to_string()),
143        };
144
145        let result = CodexClient::extract_access_token(session);
146
147        assert!(result.is_ok());
148        assert_eq!(result.ok().as_deref(), Some("token-123"));
149    }
150
151    #[test]
152    fn extract_access_token_returns_missing_when_absent() {
153        let session = SessionResponse {
154            access_token: None,
155            error: None,
156        };
157
158        let result = CodexClient::extract_access_token(session);
159
160        assert!(result.is_err());
161        assert_eq!(result.err().as_deref(), Some("missing access token"));
162    }
163
164    #[test]
165    fn extract_access_token_prefers_server_error_when_present() {
166        let session = SessionResponse {
167            access_token: None,
168            error: Some("session expired".to_string()),
169        };
170
171        let result = CodexClient::extract_access_token(session);
172
173        assert!(result.is_err());
174        assert_eq!(result.err().as_deref(), Some("session expired"));
175    }
176
177    #[test]
178    fn truncate_body_preserves_utf8_boundaries() {
179        let body = "あ".repeat(201);
180
181        let truncated = CodexClient::truncate_body(&body);
182
183        assert!(truncated.ends_with("..."));
184        assert_eq!(truncated.chars().count(), 203);
185        assert!(truncated.starts_with(&"あ".repeat(200)));
186    }
187}