Skip to main content

claude_code_stats/source/
web.rs

1use 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    // Best-effort: fetch overage spend limit
134    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    // API returns cents, convert to dollars
169    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}