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    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}