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