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