//! Claude Code's account usage endpoint. //! //! This is the one place Lumbridge reads another harness's credential, and it //! does so under a narrow, written allowance in `AGENTS.md`: the harness's own //! access token, used only against that provider's documented usage endpoint, //! never persisted, never logged, never passed as a command-line argument. //! Decision 0016 records why the earlier blanket prohibition was narrowed. //! //! Two things the status line cannot give us come from here: //! //! - **Per-model weekly limits.** A Max plan meters some models separately, and //! `limits[]` reports each as its own `weekly_scoped` entry. The status line //! carries only the two account-wide windows, so a user who has burned a //! model-specific limit sees nothing there. //! - **A reading without a session.** The status line only speaks when Claude //! Code takes a turn. This answers on demand, which is what makes the footer //! truthful on a cold start. //! //! It is *not* the continuous source. The endpoint rate-limits under polling, //! so the status line remains the free per-turn feed and this refreshes slowly //! behind it. use std::fs; use std::path::{Path, PathBuf}; use std::time::Duration; use serde::Deserialize; use crate::HarnessError; use crate::claude::statusline::{ ClaudeWindowKind, WEEKLY_MS, WindowReading, unix_seconds_to_millis, window_reading, }; /// The documented endpoint, as used by Claude Code itself. pub const USAGE_ENDPOINT: &str = "https://api.anthropic.com/api/oauth/usage"; /// The beta header the endpoint requires for an OAuth token. const OAUTH_BETA: &str = "oauth-2025-04-20"; /// Identifies the caller honestly: Lumbridge is not Claude Code, and says so. /// `AGENTS.md` forbids silently impersonating a harness, and a support engineer /// reading these logs should be able to tell who actually made the request. const USER_AGENT: &str = concat!("lumbridge/", env!("CARGO_PKG_VERSION"), " (usage-probe)"); /// A response larger than this is not the small JSON document we expect. const MAX_RESPONSE_BYTES: u64 = 256 * 1024; const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); /// The endpoint rate-limits under polling, so this is deliberately slow. The /// status line covers the gap between refreshes for free. pub const MIN_REFRESH_INTERVAL: Duration = Duration::from_mins(5); /// How long to stand down after the endpoint says we are asking too often. pub const BACKOFF_AFTER_429: Duration = Duration::from_mins(30); /// A bound on how many per-model limits will be tracked, so a response cannot /// grow the footer without limit. const MAX_SCOPED_WINDOWS: usize = 6; /// A bound on a model display name before it is used to build a profile. const MAX_LABEL_BYTES: usize = 48; /// An access token, held for the length of one request. /// /// The bytes are overwritten when this is dropped. That is a real but partial /// guarantee, and it is worth stating exactly: the file is read into a buffer /// that is also zeroed, and the token is borrowed out of it rather than being /// copied through an intermediate `String`, so the only copies are the two this /// type owns. It does not defend against the OS having paged either buffer out. struct AccessToken(Vec); impl AccessToken { fn header_value(&self) -> Option { let token = std::str::from_utf8(&self.0).ok()?; Some(format!("Bearer {token}")) } } impl Drop for AccessToken { fn drop(&mut self) { self.0.fill(0); } } /// Deliberately opaque: a token must not be printable by accident. impl std::fmt::Debug for AccessToken { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter.write_str("AccessToken()") } } /// What the credential file says, beyond the token. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct PlanIdentity { /// `"Max (20x)"`, `"Pro"`, or nothing. A plan tier, not an account /// identifier — the account e-mail sits in the same file and is not read. pub label: Option, } /// A credential in hand: the token plus what the file said about the plan. #[derive(Debug)] struct Credential { token: AccessToken, expires_at_ms: Option, identity: PlanIdentity, } #[derive(Debug, Deserialize)] struct CredentialFileWire<'a> { #[serde(borrow, rename = "claudeAiOauth")] oauth: Option>, } #[derive(Debug, Deserialize)] struct OauthWire<'a> { /// Borrowed rather than owned so the token is never copied into a `String` /// whose buffer this module cannot zero. #[serde(borrow, rename = "accessToken")] access_token: Option<&'a str>, #[serde(default, rename = "expiresAt")] expires_at: Option, #[serde(default, borrow, rename = "subscriptionType")] subscription_type: Option<&'a str>, #[serde(default, borrow, rename = "rateLimitTier")] rate_limit_tier: Option<&'a str>, } /// Where the credential lives and whether we are allowed to read it. #[derive(Clone, Debug)] pub struct ClaudeOauthOptions { /// Off means the file is never opened. This is the kill switch for a user /// who wants the status-line reading and nothing else. pub enabled: bool, pub credentials_path: PathBuf, pub endpoint: String, pub refresh_interval: Duration, } impl Default for ClaudeOauthOptions { fn default() -> Self { Self { enabled: std::env::var_os("LUMBRIDGE_CLAUDE_OAUTH").is_none_or(|value| value != "0"), credentials_path: default_credentials_path(), endpoint: USAGE_ENDPOINT.to_owned(), refresh_interval: MIN_REFRESH_INTERVAL, } } } impl ClaudeOauthOptions { /// Options that never open the credential file. /// /// Tests use this: a test must never read the developer's real credential, /// and a synthetic fixture is passed by path where one is wanted. #[must_use] pub fn disabled() -> Self { Self { enabled: false, ..Self::default() } } } /// The documented default location of Claude Code's stored credential. #[must_use] pub fn default_credentials_path() -> PathBuf { if let Some(configured) = std::env::var_os("CLAUDE_CONFIG_DIR") { return PathBuf::from(configured).join(".credentials.json"); } std::env::var_os("HOME") .map(PathBuf::from) .unwrap_or_default() .join(".claude") .join(".credentials.json") } /// One per-model weekly limit. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ScopedWindow { /// The provider's own display name, e.g. `Claude Opus 4.6`. pub model: String, /// A stable, account-free profile identifier derived from it. pub profile_id: String, pub reading: WindowReading, } /// A complete answer from the usage endpoint. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct UsageSnapshot { pub five_hour: WindowReading, pub seven_day: WindowReading, pub scoped: Vec, } /// Reads the credential file. /// /// A missing or unparsable file is [`HarnessError::NotAuthenticated`] — an /// expected gap, not a fault. So is an expired token: Claude Code refreshes it /// on its next turn, and Lumbridge does not perform that refresh, because /// refreshing means writing to another program's credential store. fn read_credential(path: &Path, now_ms: u64) -> Result { let mut raw = fs::read(path).map_err(|_| HarnessError::NotAuthenticated)?; let parsed = read_credential_bytes(&raw, now_ms); // The whole file, not just the token: it also holds the refresh token. raw.fill(0); parsed } fn read_credential_bytes(raw: &[u8], now_ms: u64) -> Result { let file: CredentialFileWire<'_> = serde_json::from_slice(raw).map_err(|_| HarnessError::NotAuthenticated)?; let oauth = file.oauth.ok_or(HarnessError::NotAuthenticated)?; let token = oauth .access_token .filter(|value| !value.is_empty()) .ok_or(HarnessError::NotAuthenticated)?; let expires_at_ms = oauth.expires_at.and_then(|value| u64::try_from(value).ok()); if expires_at_ms.is_some_and(|expiry| now_ms >= expiry) { return Err(HarnessError::NotAuthenticated); } Ok(Credential { token: AccessToken(token.as_bytes().to_vec()), expires_at_ms, identity: PlanIdentity { label: plan_label(oauth.subscription_type, oauth.rate_limit_tier), }, }) } /// `default_claude_max_20x` reads as `Max (20x)`; otherwise the plain /// subscription type. Neither identifies the account. fn plan_label(subscription: Option<&str>, tier: Option<&str>) -> Option { if let Some(tier) = tier && let Some(multiplier) = tier .rsplit("max_") .next() .and_then(|rest| rest.strip_suffix('x')) && !multiplier.is_empty() && multiplier.bytes().all(|byte| byte.is_ascii_digit()) && tier.contains("max_") { return Some(format!("Max ({multiplier}x)")); } let subscription = subscription?.trim(); if subscription.is_empty() { return None; } let mut characters = subscription.chars(); let first = characters.next()?; Some(first.to_uppercase().collect::() + characters.as_str()) } /// The plan tier, without making a network call. /// /// Read at startup so a profile can be labelled with the plan it meters before /// any request has been made. #[must_use] pub fn read_plan_identity(options: &ClaudeOauthOptions, now_ms: u64) -> PlanIdentity { if !options.enabled { return PlanIdentity::default(); } read_credential(&options.credentials_path, now_ms) .map(|credential| credential.identity) .unwrap_or_default() } /// What one refresh produced. #[derive(Debug)] pub enum RefreshOutcome { /// A reading. The plan identity comes along because the same file supplies /// both and it can change when the user upgrades. Snapshot(Box, PlanIdentity), /// The endpoint asked us to slow down. The caller backs off; it does not /// retry, and it does not treat this as a broken probe. RateLimited, /// No number, and no fault: not signed in, or the token has expired and /// Claude Code has not yet refreshed it. Unauthenticated, /// Something went wrong that is worth surfacing as a degraded probe. Failed(HarnessError), } /// Fetches one reading. /// /// The token reaches the request as a header value built at the call site and /// dropped with it. It is never written to a file, a log, an error, or an /// argument vector. #[must_use] pub fn refresh(options: &ClaudeOauthOptions, now_ms: u64) -> RefreshOutcome { if !options.enabled { return RefreshOutcome::Unauthenticated; } let Ok(credential) = read_credential(&options.credentials_path, now_ms) else { return RefreshOutcome::Unauthenticated; }; let Some(authorization) = credential.token.header_value() else { return RefreshOutcome::Unauthenticated; }; let _ = credential.expires_at_ms; let agent: ureq::Agent = ureq::Agent::config_builder() .timeout_global(Some(REQUEST_TIMEOUT)) .build() .into(); let response = agent .get(&options.endpoint) .header("Authorization", &authorization) .header("Accept", "application/json") .header("anthropic-beta", OAUTH_BETA) .header("User-Agent", USER_AGENT) .call(); drop(authorization); let mut response = match response { Ok(response) => response, Err(ureq::Error::StatusCode(401 | 403)) => return RefreshOutcome::Unauthenticated, Err(ureq::Error::StatusCode(429)) => return RefreshOutcome::RateLimited, Err(_) => return RefreshOutcome::Failed(HarnessError::Malformed), }; let Ok(body) = response .body_mut() .with_config() .limit(MAX_RESPONSE_BYTES) .read_to_string() else { return RefreshOutcome::Failed(HarnessError::Malformed); }; match parse_usage(&body, now_ms) { Some(snapshot) => RefreshOutcome::Snapshot(Box::new(snapshot), credential.identity), None => RefreshOutcome::Failed(HarnessError::Malformed), } } #[derive(Debug, Deserialize)] struct UsageResponseWire { #[serde(default)] five_hour: Option, #[serde(default)] seven_day: Option, #[serde(default)] limits: Option>>, } #[derive(Debug, Deserialize)] struct EndpointWindowWire { #[serde(default)] utilization: Option, #[serde(default)] used_percentage: Option, #[serde(default)] resets_at: Option, } #[derive(Debug, Deserialize)] struct ScopedLimitWire { #[serde(default)] kind: Option, #[serde(default)] scope: Option, #[serde(default)] percent: Option, #[serde(default)] utilization: Option, #[serde(default)] resets_at: Option, } #[derive(Debug, Deserialize)] struct ScopeWire { #[serde(default)] model: Option, } #[derive(Debug, Deserialize)] struct ScopeModelWire { #[serde(default)] display_name: Option, } /// The endpoint spells its reset as an RFC 3339 string; the status line spells /// the same instant as epoch seconds. Accept either rather than going dark on /// whichever one changes. #[derive(Debug, Deserialize)] #[serde(untagged)] enum ResetsAtWire { Text(String), Epoch(i64), } impl ResetsAtWire { fn to_millis(&self) -> Option { match self { Self::Epoch(seconds) => unix_seconds_to_millis(*seconds), Self::Text(text) => rfc3339_to_millis(text), } } } /// Parses an RFC 3339 timestamp to epoch milliseconds. /// /// Hand-rolled rather than pulling in a calendar: the accepted shape is fixed /// and narrow, and every field is bounds-checked before it is used. Anything /// that does not match exactly yields no reset, which costs a forecast and /// never produces a wrong one. fn rfc3339_to_millis(text: &str) -> Option { let bytes = text.as_bytes(); if bytes.len() < 20 || bytes[4] != b'-' || bytes[7] != b'-' { return None; } if !matches!(bytes[10], b'T' | b't' | b' ') || bytes[13] != b':' || bytes[16] != b':' { return None; } let year: i64 = text.get(0..4)?.parse().ok()?; let month: i64 = text.get(5..7)?.parse().ok()?; let day: i64 = text.get(8..10)?.parse().ok()?; let hour: i64 = text.get(11..13)?.parse().ok()?; let minute: i64 = text.get(14..16)?.parse().ok()?; let second: i64 = text.get(17..19)?.parse().ok()?; if !(1..=12).contains(&month) || !(1..=31).contains(&day) || hour > 23 || minute > 59 // A leap second is a real value the provider may send. || second > 60 { return None; } // Only UTC is accepted. An offset would need to be applied, and the // endpoint documents its resets in UTC; a misread offset would move a reset // by hours, which is worse than reporting no reset at all. let suffix = text.get(19..)?; let suffix = suffix.trim_start_matches(|character: char| character == '.' || character.is_ascii_digit()); if !matches!(suffix, "Z" | "z" | "+00:00" | "-00:00" | "+0000" | "") { return None; } let days = days_from_civil(year, month, day)?; let seconds = days .checked_mul(86_400)? .checked_add(hour * 3_600 + minute * 60 + second)?; unix_seconds_to_millis(seconds) } /// Days since 1970-01-01 for a proleptic Gregorian date. /// /// Howard Hinnant's `days_from_civil`, which is the standard formulation of /// this conversion and is exact for every year in range. fn days_from_civil(year: i64, month: i64, day: i64) -> Option { let year = if month <= 2 { year - 1 } else { year }; let era = if year >= 0 { year } else { year - 399 } / 400; let year_of_era = year - era * 400; let day_of_year = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day - 1; let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; era.checked_mul(146_097)?.checked_add(day_of_era - 719_468) } /// A stable profile identifier for a per-model limit. /// /// Derived from the display name so it survives a restart, and reduced to /// lowercase ASCII so it cannot smuggle formatting into a UI label. fn scoped_profile_id(model: &str) -> Option { let mut id = String::from("claude-code-weekly-"); let mut last_was_dash = true; for character in model.chars().take(MAX_LABEL_BYTES) { if character.is_ascii_alphanumeric() { id.extend(character.to_lowercase()); last_was_dash = false; } else if !last_was_dash { id.push('-'); last_was_dash = true; } } let id = id.trim_end_matches('-').to_owned(); (id.len() > "claude-code-weekly-".len() - 1).then_some(id) } /// Reads a usage response. /// /// Total: every input produces a decision and none of them panics. #[must_use] pub fn parse_usage(body: &str, observed_at_ms: u64) -> Option { let wire: UsageResponseWire = serde_json::from_str(body).ok()?; let window = |value: Option<&EndpointWindowWire>, kind: ClaudeWindowKind| { value.map_or(WindowReading::Absent, |wire| { wire.utilization .or(wire.used_percentage) .map_or(WindowReading::Absent, |percent| { window_reading( percent, wire.resets_at.as_ref().and_then(ResetsAtWire::to_millis), kind.length_ms(), observed_at_ms, ) }) }) }; let mut scoped = Vec::new(); let mut seen = Vec::new(); for limit in wire.limits.into_iter().flatten().flatten() { if scoped.len() >= MAX_SCOPED_WINDOWS { break; } // Only weekly per-model limits. Another `kind` means something this // adapter has not been taught to read, and guessing at its units is how // a footer ends up confidently wrong. if limit.kind.as_deref() != Some("weekly_scoped") { continue; } let Some(model) = limit .scope .and_then(|scope| scope.model) .and_then(|model| model.display_name) .map(|name| name.trim().to_owned()) .filter(|name| !name.is_empty()) else { continue; }; let Some(profile_id) = scoped_profile_id(&model) else { continue; }; if seen.contains(&profile_id) { continue; } let Some(percent) = limit.percent.or(limit.utilization) else { continue; }; let reading = window_reading( percent, limit.resets_at.as_ref().and_then(ResetsAtWire::to_millis), WEEKLY_MS, observed_at_ms, ); seen.push(profile_id.clone()); scoped.push(ScopedWindow { model, profile_id, reading, }); } Some(UsageSnapshot { five_hour: window(wire.five_hour.as_ref(), ClaudeWindowKind::FiveHour), seven_day: window(wire.seven_day.as_ref(), ClaudeWindowKind::SevenDay), scoped, }) } #[cfg(test)] mod tests { use super::{ AccessToken, PlanIdentity, ScopedWindow, parse_usage, plan_label, read_credential_bytes, rfc3339_to_millis, scoped_profile_id, }; use crate::HarnessError; use crate::claude::statusline::WindowReading; const NOW_MS: u64 = 1_700_000_000_000; #[test] fn a_credential_file_yields_a_token_and_a_plan_but_never_the_refresh_token() { let raw = br#"{"claudeAiOauth":{"accessToken":"sk-test-token","refreshToken":"sk-refresh", "expiresAt":1800000000000,"subscriptionType":"max", "rateLimitTier":"default_claude_max_20x"}}"#; let credential = read_credential_bytes(raw, NOW_MS).expect("a valid credential"); assert_eq!( credential.identity, PlanIdentity { label: Some("Max (20x)".to_owned()) } ); // The refresh token has no field to land in, so it cannot be carried. let rendered = format!("{:?}", credential.token); assert!(!rendered.contains("sk-"), "a token must not be printable"); assert_eq!(rendered, "AccessToken()"); } #[test] fn an_expired_token_is_an_expected_gap_rather_than_a_fault() { let raw = br#"{"claudeAiOauth":{"accessToken":"t","expiresAt":1000}}"#; let error = read_credential_bytes(raw, NOW_MS).expect_err("expired"); assert_eq!(error, HarnessError::NotAuthenticated); assert!( error.is_expected_gap(), "a signed-out user is not a broken probe" ); } #[test] fn a_missing_or_malformed_credential_never_produces_a_number() { for raw in [ &b"{}"[..], b"not json", br#"{"claudeAiOauth":{}}"#, br#"{"claudeAiOauth":{"accessToken":""}}"#, ] { assert_eq!( read_credential_bytes(raw, NOW_MS).err(), Some(HarnessError::NotAuthenticated) ); } } #[test] fn a_dropped_token_leaves_no_bytes_behind() { let mut token = AccessToken(b"secret".to_vec()); token.0.fill(0); assert!(token.0.iter().all(|byte| *byte == 0)); } #[test] fn plan_labels_read_the_tier_before_the_subscription_type() { assert_eq!( plan_label(Some("max"), Some("default_claude_max_20x")).as_deref(), Some("Max (20x)") ); assert_eq!( plan_label(Some("max"), Some("default_claude_max_5x")).as_deref(), Some("Max (5x)") ); assert_eq!(plan_label(Some("pro"), None).as_deref(), Some("Pro")); assert_eq!(plan_label(None, Some("something_else")), None); assert_eq!(plan_label(None, None), None); } #[test] fn the_documented_response_yields_both_windows_and_the_scoped_ones() { let body = r#"{ "five_hour": {"utilization": 42, "resets_at": "2023-11-15T00:00:00Z"}, "seven_day": {"utilization": 8.5, "resets_at": "2023-11-20T00:00:00Z"}, "limits": [ {"kind":"weekly_scoped","percent":12, "scope":{"model":{"display_name":"Claude Opus 4.6"}}, "resets_at":"2023-11-20T00:00:00Z"}, {"kind":"weekly_scoped","percent":3, "scope":{"model":{"display_name":"Claude Sonnet 4.6"}}}, {"kind":"something_new","percent":99, "scope":{"model":{"display_name":"Unknown"}}} ] }"#; let snapshot = parse_usage(body, NOW_MS).expect("a valid response"); assert!(matches!( snapshot.five_hour, WindowReading::Usable { permille: 420, .. } )); assert!(matches!( snapshot.seven_day, WindowReading::Usable { permille: 85, .. } )); assert_eq!( snapshot.scoped.len(), 2, "an unknown kind is not guessed at" ); let ScopedWindow { model, profile_id, reading, } = &snapshot.scoped[0]; assert_eq!(model, "Claude Opus 4.6"); assert_eq!(profile_id, "claude-code-weekly-claude-opus-4-6"); assert!(matches!( reading, WindowReading::Usable { permille: 120, .. } )); } #[test] fn a_response_with_nothing_in_it_reports_absent_rather_than_zero() { let snapshot = parse_usage("{}", NOW_MS).expect("an empty object still parses"); assert_eq!(snapshot.five_hour, WindowReading::Absent); assert_eq!(snapshot.seven_day, WindowReading::Absent); assert!(snapshot.scoped.is_empty()); assert!(parse_usage("not json", NOW_MS).is_none()); } #[test] fn duplicate_and_excess_scoped_limits_are_bounded() { let entry = |name: &str| { format!( r#"{{"kind":"weekly_scoped","percent":1,"scope":{{"model":{{"display_name":"{name}"}}}}}}"# ) }; let mut entries: Vec = (0..20) .map(|index| entry(&format!("Model {index}"))) .collect(); entries.push(entry("Model 0")); let body = format!(r#"{{"limits":[{}]}}"#, entries.join(",")); let snapshot = parse_usage(&body, NOW_MS).expect("valid"); assert_eq!(snapshot.scoped.len(), 6, "the footer cannot grow unbounded"); } #[test] fn rfc3339_resets_convert_and_anything_else_yields_no_reset() { assert_eq!(rfc3339_to_millis("1970-01-01T00:00:00Z"), Some(0)); assert_eq!( rfc3339_to_millis("2023-11-15T00:00:00Z"), Some(1_700_006_400_000) ); assert_eq!( rfc3339_to_millis("2023-11-15T00:00:00.123456Z"), Some(1_700_006_400_000), "a fractional second does not move the whole second" ); for bad in [ "", "yesterday", "2023-11-15", "2023-13-15T00:00:00Z", "2023-11-15T25:00:00Z", // An offset is refused rather than silently read as UTC. "2023-11-15T00:00:00+05:00", ] { assert_eq!(rfc3339_to_millis(bad), None, "{bad:?} must not parse"); } } #[test] fn scoped_identifiers_are_account_free_and_stable() { assert_eq!( scoped_profile_id("Claude Opus 4.6").as_deref(), Some("claude-code-weekly-claude-opus-4-6") ); assert_eq!( scoped_profile_id(" Opus / Weekly ").as_deref(), Some("claude-code-weekly-opus-weekly") ); assert_eq!(scoped_profile_id("///"), None); for name in ["Claude Opus 4.6", "user@example.com"] { let id = scoped_profile_id(name).expect("an identifier"); assert!(!id.contains('@'), "an identifier must not carry an account"); } } }