Skip to main content

seher/claude/
client.rs

1use super::error::{ClaudeApiError, Result};
2use super::types::UsageResponse;
3use crate::Cookie;
4
5fn urldecode(s: &str) -> String {
6    let mut result = String::with_capacity(s.len());
7    let mut chars = s.bytes();
8    while let Some(b) = chars.next() {
9        if b == b'%' {
10            let hi = chars.next().and_then(|c| (c as char).to_digit(16));
11            let lo = chars.next().and_then(|c| (c as char).to_digit(16));
12            if let (Some(h), Some(l)) = (hi, lo)
13                && let Ok(byte) = u8::try_from(h * 16 + l)
14            {
15                result.push(byte as char);
16            }
17        } else {
18            result.push(b as char);
19        }
20    }
21    result
22}
23
24fn extract_uuid(s: &str) -> Option<String> {
25    // Find a UUID pattern (8-4-4-4-12 hex digits)
26    let bytes = s.as_bytes();
27    let hex = |b: u8| b.is_ascii_hexdigit();
28    for i in 0..bytes.len() {
29        if i + 36 > bytes.len() {
30            break;
31        }
32        let candidate = &bytes[i..i + 36];
33        if candidate[8] == b'-'
34            && candidate[13] == b'-'
35            && candidate[18] == b'-'
36            && candidate[23] == b'-'
37            && candidate[..8].iter().all(|b| hex(*b))
38            && candidate[9..13].iter().all(|b| hex(*b))
39            && candidate[14..18].iter().all(|b| hex(*b))
40            && candidate[19..23].iter().all(|b| hex(*b))
41            && candidate[24..36].iter().all(|b| hex(*b))
42        {
43            return Some(String::from_utf8_lossy(candidate).to_string());
44        }
45    }
46    None
47}
48
49pub struct ClaudeClient;
50
51const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
52    AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36";
53
54impl ClaudeClient {
55    /// # Errors
56    ///
57    /// Returns an error if the org ID cookie is missing, the API request fails, or the response
58    /// cannot be parsed.
59    pub async fn fetch_usage(cookies: &[Cookie]) -> Result<UsageResponse> {
60        let org_id = Self::find_org_id(cookies)?;
61        let cookie_header = Self::build_cookie_header(cookies);
62
63        let url = format!("https://claude.ai/api/organizations/{org_id}/usage");
64
65        let client = reqwest::Client::builder().user_agent(USER_AGENT).build()?;
66
67        let response = client
68            .get(&url)
69            .header("Cookie", &cookie_header)
70            .header("Accept", "application/json")
71            .header("Accept-Language", "en-US,en;q=0.9")
72            .header("Referer", "https://claude.ai/")
73            .header("Origin", "https://claude.ai")
74            .header("DNT", "1")
75            .header("sec-ch-ua-platform", "\"macOS\"")
76            .header("sec-fetch-dest", "empty")
77            .header("sec-fetch-mode", "cors")
78            .header("sec-fetch-site", "same-origin")
79            .send()
80            .await?;
81
82        let status = response.status();
83        if !status.is_success() {
84            let body = response.text().await.unwrap_or_default();
85            // Truncate Cloudflare HTML for readability
86            let body = if body.len() > 200 {
87                format!("{}...", &body[..200])
88            } else {
89                body
90            };
91            return Err(ClaudeApiError::ApiError {
92                status: status.as_u16(),
93                body,
94            });
95        }
96
97        let usage: UsageResponse = response.json().await?;
98        Ok(usage)
99    }
100
101    fn find_org_id(cookies: &[Cookie]) -> Result<String> {
102        let raw = cookies
103            .iter()
104            .find(|c| c.name == "lastActiveOrg")
105            .map(|c| c.value.clone())
106            .ok_or_else(|| {
107                ClaudeApiError::CookieNotFound("lastActiveOrg cookie not found".to_string())
108            })?;
109
110        // URL-decode and extract UUID pattern
111        let decoded = urldecode(&raw);
112        extract_uuid(&decoded).ok_or_else(|| {
113            ClaudeApiError::CookieNotFound(format!(
114                "lastActiveOrg does not contain a valid UUID: {decoded}"
115            ))
116        })
117    }
118
119    fn build_cookie_header(cookies: &[Cookie]) -> String {
120        cookies
121            .iter()
122            .map(|c| format!("{}={}", c.name, c.value))
123            .collect::<Vec<_>>()
124            .join("; ")
125    }
126}