use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use serde::Serialize; use serde_json::Value; use crate::config::SetupConfig; use crate::session::transport::{Launch, Transport}; const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; const MIN_POLL_INTERVAL: Duration = Duration::from_secs(180); /// Matched to the CLI version the wire formats were pinned against. const USER_AGENT: &str = "claude-code/2.1.237"; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct UsageWindow { /// Carried beside the label because a caller that wants one particular /// window has to ask for it without matching on display text: the label is /// written for a person and would silently select nothing the day it /// changes. pub kind: String, pub label: String, pub percent: f64, /// ISO-8601, as the API sends it; absent for windows that never reset. #[serde(skip_serializing_if = "Option::is_none")] pub resets_at: Option, pub active: bool, } /// Four answers rather than a flag and a message, because the screen has to /// treat them differently. "Nobody is logged in here" is a machine working /// exactly as configured, while "I could not reach it" is a fault worth /// chasing, and "the endpoint refused me" says nothing about the machine at /// all. Collapsing them into one `error` string made the first look like the /// last, so a perfectly healthy setup read as broken. #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(tag = "state", rename_all = "camelCase")] pub enum UsageState { Ok, NotLoggedIn, Unreachable { detail: String }, Failed { detail: String }, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct UsageSnapshot { pub provider: String, pub setup: String, pub setup_name: String, #[serde(flatten)] pub state: UsageState, pub windows: Vec, /// Epoch seconds the numbers were fetched (they can be up to the poll /// interval old). pub fetched_at: f64, } /// The name of each meter, said in one place because two lists have to /// agree on it: [`UsageSnapshot::provider`], which is what `GET /usage` /// labels a row with, and [`crate::config::DriverKind::usage_provider`], /// which is how a session says which of those rows is about it. pub const CLAUDE: &str = "claude"; pub const ECHO: &str = "echo"; pub trait UsageProvider: Send + Sync { fn name(&self) -> &'static str; fn fetch(&self) -> UsageSnapshot; /// A property of the provider rather than of the cache, because what /// sets it is what asking costs: [`ClaudeUsage`] makes a network call /// against an endpoint that rate-limits impatient callers, and the /// fixture below reads a mutex. Caching the fixture for three minutes /// would mean a test setting a number and watching the old one for /// most of that, which reads exactly like the command not working. fn poll_interval(&self) -> Duration { MIN_POLL_INTERVAL } } pub struct ClaudeUsage { pub setup: String, pub setup_name: String, pub transport: Transport, pub program: String, } const CREDENTIALS: &str = "$HOME/.claude/.credentials.json"; impl ClaudeUsage { fn snapshot(&self, state: UsageState, windows: Vec) -> UsageSnapshot { UsageSnapshot { provider: self.name().to_string(), setup: self.setup.clone(), setup_name: self.setup_name.clone(), state, windows, fetched_at: crate::session::now(), } } fn access_token(&self) -> Result { let launch = Launch::new( "sh", vec!["-c".to_string(), format!("cat {CREDENTIALS}")], None, ); let text = self .transport .capture_blocking(&launch) .map_err(|err| why_no_credentials(&format!("{err:#}")))?; serde_json::from_str::(&text) .ok() .and_then(|creds| { creds .get("claudeAiOauth")? .get("accessToken")? .as_str() .map(String::from) }) .ok_or(UsageState::NotLoggedIn) } fn call(&self, token: &str) -> Result { let text = ureq::get(USAGE_URL) .header("Authorization", &format!("Bearer {token}")) .header("anthropic-beta", "oauth-2025-04-20") .header("User-Agent", USER_AGENT) .call() .and_then(|mut response| response.body_mut().read_to_string()) // The error string can embed the URL but never the token. .map_err(|err| match err { ureq::Error::StatusCode(401) => Refused::Unauthorized, other => Refused::Other(why(&other)), })?; serde_json::from_str(&text) .map_err(|err| Refused::Other(format!("usage endpoint sent non-JSON: {err}"))) } /// **The CLI does the refresh, never this.** Anthropic's OAuth rotates the /// refresh token, so whoever refreshes second presents a dead one and the /// machine is logged out until somebody runs `/login` on it -- and the /// machine we would be refreshing on is usually one with a live session of /// its own. Running the CLI keeps it the only writer of /// `.credentials.json`. fn after_cli_refresh(&self, stale: &str) -> Result { let launch = Launch::new(&self.program, vec!["doctor".to_string()], None); if let Err(err) = self.transport.capture_blocking(&launch) { return Err(UsageState::Failed { detail: format!( "the Claude login on {} has expired, and `{} doctor` couldn't be run there to refresh it: {err:#}", self.setup_name, self.program ), }); } let fresh = self.access_token()?; if fresh == stale { return Err(self.still_expired()); } self.call(&fresh).map_err(|err| match err { Refused::Unauthorized => self.still_expired(), Refused::Other(detail) => UsageState::Failed { detail }, }) } fn still_expired(&self) -> UsageState { UsageState::Failed { detail: format!( "the Claude login on {} has expired and could not be refreshed; run `{} /login` there", self.setup_name, self.program ), } } } impl UsageProvider for ClaudeUsage { fn name(&self) -> &'static str { CLAUDE } fn fetch(&self) -> UsageSnapshot { let token = match self.access_token() { Ok(token) => token, Err(state) => return self.snapshot(state, Vec::new()), }; let body = match self.call(&token) { Ok(body) => body, Err(Refused::Other(detail)) => { return self.snapshot(UsageState::Failed { detail }, Vec::new()); } Err(Refused::Unauthorized) => match self.after_cli_refresh(&token) { Ok(body) => body, Err(state) => return self.snapshot(state, Vec::new()), }, }; self.snapshot(UsageState::Ok, parse_windows(&body)) } } /// 401 is apart from the rest because it is the only one with a way out: the /// endpoint answered, and it means the access token has expired rather than /// that anything is broken. enum Refused { Unauthorized, Other(String), } /// A status is not a network fault and must not be reported as one: the /// endpoint answered. 401 never reaches here -- it has its own way out in /// [`ClaudeUsage::after_cli_refresh`] -- so what is left is a refusal nobody /// on this side can fix. fn why(err: &ureq::Error) -> String { match err { ureq::Error::StatusCode(code) => format!("usage endpoint refused the request: HTTP {code}"), other => format!("usage endpoint unreachable: {other}"), } } /// Matched on the shell's own words rather than an exit status because there is /// only one that survives being wrapped in `sh -c` and passed back through ssh. fn why_no_credentials(detail: &str) -> UsageState { let missing = ["No such file", "no such file", "can't open", "cannot open"]; if missing.iter().any(|phrase| detail.contains(phrase)) { UsageState::NotLoggedIn } else { UsageState::Unreachable { detail: detail.to_string(), } } } fn parse_windows(body: &Value) -> Vec { let Some(limits) = body.get("limits").and_then(Value::as_array) else { return Vec::new(); }; limits .iter() .filter_map(|limit| { let percent = limit.get("percent")?.as_f64()?; let kind = limit .get("kind") .and_then(Value::as_str) .unwrap_or("unknown"); let scope_model = limit .get("scope") .and_then(|scope| scope.get("model")) .and_then(|model| model.get("display_name")) .and_then(Value::as_str); let label = match (kind, scope_model) { ("session", _) => "5-hour window".to_string(), ("weekly_all", _) => "Weekly (all models)".to_string(), ("weekly_scoped", Some(model)) => format!("Weekly ({model})"), (other, Some(model)) => format!("{other} ({model})"), (other, None) => other.to_string(), }; Some(UsageWindow { kind: kind.to_string(), label, percent, resets_at: limit .get("resets_at") .and_then(Value::as_str) .map(String::from), active: limit .get("is_active") .and_then(Value::as_bool) .unwrap_or(false), }) }) .collect() } /// Every state the usage bar and the usage dialog can be in is otherwise /// reachable only by spending somebody's quota or by breaking a machine: /// a number near the top, a machine nobody has logged into, one that /// cannot be reached, a window between blocks with no reset time. Those /// are exactly the states worth looking at, and the ones nobody looks at /// because arranging them costs real turns. An echo session sets this /// with `/usage` (see `session::echo`), which is the same bargain the /// rest of that driver makes: the fixture is invented, what is real is /// the path it travels. #[derive(Clone, Default)] pub struct Fixture { said: Arc>>, } type Reported = (UsageState, Vec); const FIXTURE_MINUTES: i64 = 125; impl Fixture { pub fn new() -> Self { Self::default() } fn is_set(&self) -> bool { self.said.lock().unwrap().is_some() } fn read(&self) -> Option { self.said.lock().unwrap().clone() } pub fn command(&self, words: &str) -> String { let mut words = words.split_whitespace(); let Some(first) = words.next() else { return match self.read() { Some((state, windows)) => format!("usage fixture: {}", describe(&state, &windows)), None => "usage fixture: unset, so this session meters nothing. \ `/usage 42` puts up a five-hour window at 42%." .to_string(), }; }; let rest: Vec<&str> = words.collect(); let detail = || { if rest.is_empty() { "set by /usage".to_string() } else { rest.join(" ") } }; let (state, windows) = match first { "off" | "none" | "clear" => { *self.said.lock().unwrap() = None; return "usage fixture cleared: this session meters nothing again".to_string(); } "notloggedin" | "logged-out" => (UsageState::NotLoggedIn, Vec::new()), "unreachable" => (UsageState::Unreachable { detail: detail() }, Vec::new()), "failed" => (UsageState::Failed { detail: detail() }, Vec::new()), percent => match percent.parse::() { Ok(percent) => ( UsageState::Ok, fixture_windows(percent.clamp(0.0, 100.0), rest.first().copied()), ), Err(_) => { return format!( "\"{percent}\" is not one of this fixture's answers. Say a percentage \ (`/usage 42`, optionally with `90` minutes left, `never` for a window \ between blocks, or `unreadable` for a reset time that cannot be read), \ or one of `notloggedin`, `unreachable`, `failed`, `off`." ); } }, }; let said = describe(&state, &windows); *self.said.lock().unwrap() = Some((state, windows)); format!("usage fixture set: {said}") } } /// Three rather than one because the bar under a session header reads the /// five-hour window and the dialog behind the button draws all of them, /// and a fixture with one window leaves half the screen untested. The /// weekly ones are derived from the same figure so that the worst of them /// -- which is what colours the button -- is still the one asked for. fn fixture_windows(percent: f64, reset: Option<&str>) -> Vec { let resets_at = match reset { Some("never") | Some("none") => None, Some("unreadable") | Some("bad") => Some("whenever it feels like it".to_string()), other => Some(reset_in( other .and_then(|word| word.parse().ok()) .unwrap_or(FIXTURE_MINUTES), )), }; vec![ UsageWindow { kind: "session".to_string(), label: "5-hour window".to_string(), percent, resets_at: resets_at.clone(), active: true, }, UsageWindow { kind: "weekly_all".to_string(), label: "Weekly (all models)".to_string(), percent: percent / 2.0, resets_at: resets_at.as_ref().map(|_| reset_in(FIXTURE_MINUTES * 40)), active: false, }, UsageWindow { kind: "weekly_scoped".to_string(), label: "Weekly (Echo)".to_string(), percent: percent / 4.0, resets_at: resets_at.as_ref().map(|_| reset_in(FIXTURE_MINUTES * 40)), active: false, }, ] } fn reset_in(minutes: i64) -> String { let at = time::OffsetDateTime::now_utc() + time::Duration::minutes(minutes); at.format(&time::format_description::well_known::Rfc3339) .unwrap_or_else(|_| "unformattable".to_string()) } fn describe(state: &UsageState, windows: &[UsageWindow]) -> String { match state { UsageState::Ok => match windows.first() { Some(window) => format!( "{}% of the five-hour window, {}", window.percent, match &window.resets_at { Some(at) => format!("resetting at {at}"), None => "with no reset time (the between-blocks state)".to_string(), } ), None => "no windows at all".to_string(), }, UsageState::NotLoggedIn => "nobody is logged in on this machine".to_string(), UsageState::Unreachable { detail } => format!("machine unreachable ({detail})"), UsageState::Failed { detail } => format!("the meter failed ({detail})"), } } struct EchoUsage { setup: String, setup_name: String, fixture: Fixture, } impl UsageProvider for EchoUsage { fn name(&self) -> &'static str { ECHO } fn fetch(&self) -> UsageSnapshot { let (state, windows) = self .fixture .read() // Only ever built for a fixture that is set; a race with // `/usage off` between the two reads lands here, and "the // machine could not be asked" is the honest word for it. .unwrap_or(( UsageState::Unreachable { detail: "the usage fixture was cleared".to_string(), }, Vec::new(), )); UsageSnapshot { provider: self.name().to_string(), setup: self.setup.clone(), setup_name: self.setup_name.clone(), state, windows, fetched_at: crate::session::now(), } } fn poll_interval(&self) -> Duration { Duration::ZERO } } /// Derived from what the setup says it can run, so a machine with no Claude /// provider is not asked about Claude limits -- it has none, and a row saying /// so would be a fact about nothing. /// /// Which meter a provider has is [`DriverKind::usage_provider`]'s answer rather /// than a second match on kinds here, because the phone pairs a session with /// one of these rows by that same name: two lists that disagreed would leave a /// session looking for a snapshot nothing produces. A second service later is a /// name there and an impl beside [`ClaudeUsage`], not a screen. fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec> { let mut found: Vec> = Vec::new(); for provider in &setup.providers { let Some(name) = provider.kind.usage_provider() else { continue; }; if found.iter().any(|already| already.name() == name) { continue; } match name { CLAUDE => found.push(Box::new(ClaudeUsage { setup: setup.id.clone(), setup_name: setup.name.clone(), transport: Transport::for_setup(setup), program: provider.program().to_string(), })), ECHO if fixture.is_set() => found.push(Box::new(EchoUsage { setup: setup.id.clone(), setup_name: setup.name.clone(), fixture: fixture.clone(), })), _ => {} } } found } type Cached = HashMap<(String, &'static str), (Instant, UsageSnapshot)>; #[derive(Default)] pub struct UsageMonitor { cache: Mutex, fixture: Fixture, } impl UsageMonitor { pub fn new(fixture: Fixture) -> Self { Self { cache: Mutex::new(Cached::new()), fixture, } } pub fn snapshots(&self, setups: &[SetupConfig]) -> Vec { let mut fresh = Vec::new(); for setup in setups { for provider in providers_for(setup, &self.fixture) { let key = (setup.id.clone(), provider.name()); if let Some((fetched, snapshot)) = self.cache.lock().unwrap().get(&key) && fetched.elapsed() < provider.poll_interval() { let mut snapshot = snapshot.clone(); snapshot.setup_name = setup.name.clone(); fresh.push(snapshot); continue; } let snapshot = provider.fetch(); self.cache .lock() .unwrap() .insert(key, (Instant::now(), snapshot.clone())); fresh.push(snapshot); } } let live: std::collections::HashSet<&str> = setups.iter().map(|setup| setup.id.as_str()).collect(); self.cache .lock() .unwrap() .retain(|(setup, _), _| live.contains(setup.as_str())); fresh } } #[cfg(test)] mod tests { use super::*; use crate::config::DriverKind; #[test] fn a_refusal_the_endpoint_answered_is_not_reported_as_an_unreachable_one() { assert!(why(&ureq::Error::StatusCode(500)).contains("HTTP 500")); assert!(why(&ureq::Error::HostNotFound).contains("unreachable")); } #[test] fn an_expired_login_says_where_to_log_in_rather_than_naming_the_network() { let provider = ClaudeUsage { setup: "far".to_string(), setup_name: "somewhere else".to_string(), transport: Transport::for_setup(&unreachable_setup()), program: "/opt/claude".to_string(), }; let UsageState::Failed { detail } = provider .after_cli_refresh("stale") .expect_err("an unreachable machine cannot refresh anything") else { panic!("an expired login is a fault to report, not a logged-out machine"); }; assert!(detail.contains("somewhere else"), "{detail}"); assert!(detail.contains("/opt/claude doctor"), "{detail}"); assert!( !detail.contains("stale"), "the token must never be quoted back" ); let UsageState::Failed { detail } = provider.still_expired() else { panic!("still expired is a fault"); }; assert!(detail.contains("/opt/claude /login"), "{detail}"); assert!(!detail.contains("unreachable"), "{detail}"); } #[test] fn parses_the_limits_array_defensively() { let body: Value = serde_json::from_str( r#"{"limits":[ {"kind":"session","group":"session","percent":70,"severity":"normal","resets_at":"2026-08-25T04:29:59+00:00","scope":null,"is_active":true}, {"kind":"weekly_all","group":"weekly","percent":25,"resets_at":"2026-08-28T21:59:59+00:00","is_active":false}, {"kind":"weekly_scoped","percent":15,"resets_at":"2026-08-28T21:59:59+00:00","scope":{"model":{"id":null,"display_name":"Fable"}},"is_active":false}, {"kind":"mystery_new_window","percent":5}, {"kind":"broken_entry_without_percent"} ]}"#, ) .expect("json"); let windows = parse_windows(&body); assert_eq!(windows.len(), 4); assert_eq!(windows[0].label, "5-hour window"); assert_eq!(windows[0].percent, 70.0); assert!(windows[0].active); assert_eq!(windows[1].label, "Weekly (all models)"); assert_eq!(windows[2].label, "Weekly (Fable)"); assert_eq!(windows[3].label, "mystery_new_window"); assert_eq!(windows[3].resets_at, None); } fn unreachable_setup() -> SetupConfig { SetupConfig { id: "far".to_string(), name: "somewhere else".to_string(), ssh: Some(crate::config::SshConfig { address: "no-such-host.invalid".to_string(), port: None, identity_file: None, options: vec!["ConnectTimeout=1".to_string()], models_dir: None, attachments_dir: None, }), providers: vec![crate::config::ProviderConfig { name: "claude-cli".to_string(), kind: DriverKind::ClaudeCli, command: None, models: vec![], }], } } #[test] fn a_machine_that_cannot_be_asked_says_so_rather_than_looking_logged_out() { let provider = ClaudeUsage { setup: "far".to_string(), setup_name: "somewhere else".to_string(), transport: Transport::for_setup(&unreachable_setup()), program: "claude".to_string(), }; let snapshot = provider.fetch(); assert!( matches!(snapshot.state, UsageState::Unreachable { .. }), "{:?}", snapshot.state ); assert_eq!(snapshot.setup, "far"); assert_eq!(snapshot.setup_name, "somewhere else"); assert!(snapshot.windows.is_empty()); } #[test] fn a_missing_credential_file_is_a_choice_and_anything_else_is_a_fault() { assert_eq!( why_no_credentials("cat: /home/x/.claude/.credentials.json: No such file or directory"), UsageState::NotLoggedIn ); assert_eq!( why_no_credentials("cat: can't open '/home/x/.claude/.credentials.json'"), UsageState::NotLoggedIn ); let refused = why_no_credentials("ssh: connect to host vm port 22: Connection refused"); assert!( matches!(&refused, UsageState::Unreachable { detail } if detail.contains("refused")), "{refused:?}" ); assert!(matches!( why_no_credentials("something nobody has seen before"), UsageState::Unreachable { .. } )); } #[test] fn only_machines_that_can_run_claude_are_asked_about_it() { let mut echo_only = unreachable_setup(); echo_only.providers = vec![crate::config::ProviderConfig { name: "echo".to_string(), kind: DriverKind::Echo, command: None, models: vec![], }]; let unset = Fixture::new(); assert!(providers_for(&echo_only, &unset).is_empty()); assert_eq!(providers_for(&unreachable_setup(), &unset).len(), 1); let fixture = Fixture::new(); fixture.command("42"); let found = providers_for(&echo_only, &fixture); assert_eq!(found.len(), 1); assert_eq!(found[0].name(), ECHO); assert_eq!(DriverKind::Echo.usage_provider(), Some(ECHO)); assert_eq!(DriverKind::ClaudeCli.usage_provider(), Some(CLAUDE)); assert_eq!(DriverKind::LlamaCpp.usage_provider(), None); } #[test] fn the_fixture_says_each_state_the_screens_have_to_draw() { let fixture = Fixture::new(); assert!(fixture.read().is_none(), "unset until somebody sets it"); fixture.command("42 90"); let (state, windows) = fixture.read().expect("set"); assert_eq!(state, UsageState::Ok); assert_eq!(windows[0].kind, "session"); assert_eq!(windows[0].percent, 42.0); assert!(windows[0].resets_at.is_some()); fixture.command("42 never"); assert_eq!(fixture.read().expect("set").1[0].resets_at, None); fixture.command("unreachable no route to host"); assert!(matches!( fixture.read().expect("set").0, UsageState::Unreachable { detail } if detail == "no route to host", )); fixture.command("off"); assert!(fixture.read().is_none()); fixture.command("42"); let refused = fixture.command("sideways"); assert!( refused.contains("not one of this fixture's answers"), "{refused}" ); assert_eq!(fixture.read().expect("still set").1[0].percent, 42.0); } #[test] fn an_empty_or_alien_body_yields_no_windows() { assert!(parse_windows(&serde_json::json!({})).is_empty()); assert!(parse_windows(&serde_json::json!({"limits": "what"})).is_empty()); } }