claude_code_stats/source/
web.rs1use anyhow::{Result, anyhow};
2use serde::Deserialize;
3
4use crate::source::SourceError;
5use crate::source::UsageSource;
6use crate::types::{ApiExtraUsage, ApiResponse, ApiWindow};
7
8pub struct WebSource;
9
10impl UsageSource for WebSource {
11 fn name(&self) -> &'static str {
12 "web_api"
13 }
14
15 fn try_fetch(&self) -> Result<ApiResponse, SourceError> {
16 let session_key =
17 get_session_key().map_err(|e| SourceError::NotAvailable(e.to_string()))?;
18 fetch_web_usage(&session_key).map_err(SourceError::Failed)
19 }
20}
21
22fn get_session_key() -> Result<String> {
23 let rt = tokio::runtime::Builder::new_current_thread()
24 .enable_all()
25 .build()?;
26 let options = cookie_scoop::GetCookiesOptions::new("https://claude.ai")
27 .names(vec!["sessionKey".into()])
28 .mode(cookie_scoop::CookieMode::First);
29
30 let result = rt.block_on(cookie_scoop::get_cookies(options));
31
32 let cookie = result
33 .cookies
34 .into_iter()
35 .find(|c| c.name == "sessionKey")
36 .ok_or_else(|| anyhow!("sessionKey cookie not found in any browser"))?;
37
38 if !cookie.value.starts_with("sk-ant-") {
39 return Err(anyhow!("sessionKey cookie has unexpected format"));
40 }
41
42 Ok(cookie.value)
43}
44
45#[derive(Debug, Deserialize)]
46struct Organization {
47 uuid: String,
48 capabilities: Option<Vec<String>>,
49}
50
51#[derive(Debug, Deserialize)]
52struct WebUsageWindow {
53 utilization: Option<f64>,
54 resets_at: Option<String>,
55}
56
57#[derive(Debug, Deserialize)]
58struct WebUsageResponse {
59 five_hour: Option<WebUsageWindow>,
60 seven_day: Option<WebUsageWindow>,
61 seven_day_opus: Option<WebUsageWindow>,
62}
63
64#[derive(Debug, Deserialize)]
65struct OverageSpendLimit {
66 is_enabled: bool,
67 monthly_credit_limit: Option<f64>,
68 used_credits: Option<f64>,
69}
70
71fn build_client(session_key: &str) -> reqwest::blocking::Client {
72 let mut headers = reqwest::header::HeaderMap::new();
73 headers.insert(
74 reqwest::header::COOKIE,
75 reqwest::header::HeaderValue::from_str(&format!("sessionKey={session_key}"))
76 .unwrap_or_else(|_| reqwest::header::HeaderValue::from_static("")),
77 );
78 headers.insert(
79 reqwest::header::ACCEPT,
80 reqwest::header::HeaderValue::from_static("application/json"),
81 );
82 headers.insert(
83 reqwest::header::CONTENT_TYPE,
84 reqwest::header::HeaderValue::from_static("application/json"),
85 );
86
87 reqwest::blocking::Client::builder()
88 .default_headers(headers)
89 .timeout(std::time::Duration::from_secs(30))
90 .build()
91 .expect("failed to build HTTP client")
92}
93
94fn fetch_org_id(client: &reqwest::blocking::Client) -> Result<String> {
95 let resp = client.get("https://claude.ai/api/organizations").send()?;
96
97 let status = resp.status();
98 let body = resp.text()?;
99
100 if !status.is_success() {
101 return Err(anyhow!("organizations API failed ({status}): {body}"));
102 }
103
104 let orgs: Vec<Organization> = serde_json::from_str(&body)
105 .map_err(|e| anyhow!("failed to parse organizations: {e}; body: {body}"))?;
106
107 orgs.into_iter()
108 .find(|org| {
109 org.capabilities
110 .as_ref()
111 .is_some_and(|caps| caps.iter().any(|c| c.contains("chat")))
112 })
113 .map(|org| org.uuid)
114 .ok_or_else(|| anyhow!("no organization with chat capability found"))
115}
116
117fn fetch_web_usage(session_key: &str) -> Result<ApiResponse> {
118 let client = build_client(session_key);
119 let org_id = fetch_org_id(&client)?;
120
121 let usage_url = format!("https://claude.ai/api/organizations/{org_id}/usage");
122 let resp = client.get(&usage_url).send()?;
123 let status = resp.status();
124 let body = resp.text()?;
125
126 if !status.is_success() {
127 return Err(anyhow!("usage API failed ({status}): {body}"));
128 }
129
130 let web_usage: WebUsageResponse = serde_json::from_str(&body)
131 .map_err(|e| anyhow!("failed to parse usage: {e}; body: {body}"))?;
132
133 let extra_usage = fetch_overage_spend_limit(&client, &org_id);
135
136 Ok(ApiResponse {
137 five_hour: web_usage.five_hour.map(|w| ApiWindow {
138 utilization: w.utilization,
139 resets_at: w.resets_at,
140 }),
141 seven_day: web_usage.seven_day.map(|w| ApiWindow {
142 utilization: w.utilization,
143 resets_at: w.resets_at,
144 }),
145 seven_day_sonnet: None,
146 seven_day_opus: web_usage.seven_day_opus.map(|w| ApiWindow {
147 utilization: w.utilization,
148 resets_at: w.resets_at,
149 }),
150 extra_usage,
151 })
152}
153
154fn fetch_overage_spend_limit(
155 client: &reqwest::blocking::Client,
156 org_id: &str,
157) -> Option<ApiExtraUsage> {
158 let url = format!("https://claude.ai/api/organizations/{org_id}/overage_spend_limit");
159 let resp = client.get(&url).send().ok()?;
160
161 if !resp.status().is_success() {
162 return None;
163 }
164
165 let body = resp.text().ok()?;
166 let overage: OverageSpendLimit = serde_json::from_str(&body).ok()?;
167
168 let monthly_limit = overage.monthly_credit_limit.map(|c| c / 100.0);
170 let used_credits = overage.used_credits.map(|c| c / 100.0);
171 let utilization = match (monthly_limit, used_credits) {
172 (Some(limit), Some(used)) if limit > 0.0 => Some((used / limit) * 100.0),
173 _ => None,
174 };
175
176 Some(ApiExtraUsage {
177 is_enabled: overage.is_enabled,
178 monthly_limit,
179 used_credits,
180 utilization,
181 })
182}