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