//! Usage-limit reporting -- the same numbers as Claude Code's `/usage`. //! //! Polls `https://api.anthropic.com/api/oauth/usage` with the OAuth access //! token from Claude Code's local credential store. The endpoint is //! undocumented and has changed before, so everything here is best-effort: //! every field is optional, and failure degrades to an "unavailable" //! snapshot with the reason, never an error that breaks the screen. //! //! Two rules learned from others hitting this endpoint (see PLAN.md's //! references): send `User-Agent: claude-code/` (without it, //! requests land in an aggressively rate-limited bucket) and poll no more //! often than every 180 s. The cache below enforces the latter across any //! number of phone refreshes; there is no background poll at all -- the //! screen's fetch is the trigger, so no session activity means no traffic. //! //! One [`UsageProvider`] per paid service, so a second service later is a //! new impl behind the same snapshot shape, not a parallel screen. //! //! **Asked of the machine that spends the tokens, not of this one.** A //! session runs wherever its setup says, so the account being billed is //! that machine's, and reading this machine's credentials reports on an //! account that may have run nothing. In the layout this project is aiming //! at that is not a rounding error: `ai-server` belongs on the host, the //! host has no `claude` CLI, and the CLI machine is a remote -- so the one //! set of numbers the screen could show would be the numbers of an account //! with no sessions. Credentials are therefore read through the session //! `Transport`, one snapshot per setup that offers Claude. //! //! The token is read *to* the backend and the HTTP call is made from here, //! rather than running the request on the far machine: it needs no tooling //! there beyond a shell, and it keeps the one place that knows the wire //! format in one place. The cost is that a remote machine's token is in //! this process's memory for the length of a fetch, which is the same //! trust the backend already has over that machine (it can start processes //! on it). use std::collections::HashMap; use std::sync::Mutex; use std::time::{Duration, Instant}; use serde::Serialize; use serde_json::Value; use crate::config::{DriverKind, 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"; /// One rate-limit window, as the phone renders it: a labeled bar. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct UsageWindow { /// The API's own word for which window this is -- `session` for the /// five-hour one, `weekly_all`, `weekly_scoped`, or whatever new kind /// it starts sending. /// /// Carried beside the label because a caller that wants one /// particular window has to be able to ask for it without matching on /// display text: the label is written for a person, is translated the /// moment anybody translates this app, and would silently select /// nothing the day it changes. The session screen's bar picks /// `session` by this field. pub kind: String, pub label: String, /// 0-100. 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, /// Whether this window is currently the binding one. pub active: bool, } /// What came back when a machine was asked about its limits. /// /// Four answers rather than a flag and a message, because the screen has to /// treat them differently and a reader has to. "Nobody is logged in here" /// is a machine working exactly as configured -- somebody chose not to put /// an account on it -- while "I could not reach it" is a fault worth /// chasing, and "the endpoint refused me" is a third thing that 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 { /// Numbers were fetched; `windows` has them. Ok, /// The machine answered and has no Claude credentials. A choice, not a /// fault: nothing to report and nothing to fix. NotLoggedIn, /// The machine could not be asked at all. Unreachable { detail: String }, /// The machine is logged in, but the usage endpoint did not answer. Failed { detail: String }, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct UsageSnapshot { pub provider: String, /// Which machine these are the numbers for. The point of the whole /// module: they belong to an account on a particular box. pub setup: String, /// That machine's current label, resolved when the snapshot is built, /// so renaming a setup renames it here too. 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, } pub trait UsageProvider: Send + Sync { fn name(&self) -> &'static str; /// Blocking -- call off the async workers. fn fetch(&self) -> UsageSnapshot; } /// Reads the numbers behind Claude Code's `/usage` from one machine, using /// the credentials that machine stores -- nothing to configure, and it /// reports on exactly the account whose CLI runs the sessions there. pub struct ClaudeUsage { pub setup: String, pub setup_name: String, /// How to reach that machine. `Here` for the backend's own. pub transport: Transport, } /// Where Claude Code keeps its credentials, as a shell word rather than a /// path: `$HOME` is expanded by the shell on the machine being asked, /// which is the only place that knows what it is. 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(), } } /// The machine's stored OAuth token, or which of the two ways of not /// having one this is. /// /// Read through `sh -c` so `$HOME` resolves on the far machine; a path /// built here would be this machine's home directory, which over ssh /// is somebody else's. 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) }) // A file that exists but carries no token is the same situation // as no file: nobody has logged in here yet. .ok_or(UsageState::NotLoggedIn) } } 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 text = match 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()) { Ok(text) => text, Err(err) => { // The error string can embed the URL but never the token. return self.snapshot( UsageState::Failed { detail: format!("usage endpoint unreachable: {err}"), }, Vec::new(), ); } }; let body: Value = match serde_json::from_str(&text) { Ok(body) => body, Err(err) => { return self.snapshot( UsageState::Failed { detail: format!("usage endpoint sent non-JSON: {err}"), }, Vec::new(), ); } }; self.snapshot(UsageState::Ok, parse_windows(&body)) } } /// Which kind of "no credentials" a failed read was. /// /// The distinction is the point of having both states. `cat` failing /// because the file is not there is a machine nobody has logged in on -- /// a decision somebody made, with nothing to fix. Anything else is a /// machine this server could not ask, which is a fault and reads as one. /// /// Matched on the shell's own words rather than an exit status because /// there is only one: `cat` exits 1 for a missing file and ssh exits 255 /// for a connection it could not make, but the message is what survives /// being wrapped in `sh -c` and passed back through ssh. fn why_no_credentials(detail: &str) -> UsageState { // "No such file or directory" is GNU and BSD coreutils; busybox says // "can't open". Anything unrecognised is treated as unreachable, // which is the answer that gets looked at rather than ignored. 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(), } } } /// Pulls the `limits` array apart, defensively: entries with no percent /// are skipped, unknown kinds keep their raw name as the label rather /// than being dropped -- a new window appearing should show up, not /// vanish. 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() } /// Which paid services a machine can be asked about. /// /// 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. A second service later /// adds a branch here and an impl beside [`ClaudeUsage`], not a screen. fn providers_for(setup: &SetupConfig) -> Vec> { let mut found: Vec> = Vec::new(); if setup .providers .iter() .any(|provider| provider.kind == DriverKind::ClaudeCli) { found.push(Box::new(ClaudeUsage { setup: setup.id.clone(), setup_name: setup.name.clone(), transport: Transport::for_setup(setup), })); } found } /// The cache in front of whatever machines exist: at most one real fetch /// per machine per service per [`MIN_POLL_INTERVAL`], no matter how often /// the phone asks. /// /// One machine's numbers for one service, and when they were fetched. /// /// Keyed by the machine and the service rather than by position: the set /// is no longer fixed at startup -- setups are added, renamed and removed /// from the phone -- and a positional cache would hand one machine's /// numbers to another the moment the list shifted. type Cached = HashMap<(String, &'static str), (Instant, UsageSnapshot)>; #[derive(Default)] pub struct UsageMonitor { cache: Mutex, } impl UsageMonitor { pub fn new() -> Self { Self::default() } /// One snapshot per machine that offers a paid service, in the order /// the machines are configured. /// /// Blocking -- call via `spawn_blocking`. Takes the setups rather than /// holding the manager, so this module stays below the session layer /// rather than reaching up into it. pub fn snapshots(&self, setups: &[SetupConfig]) -> Vec { let mut fresh = Vec::new(); for setup in setups { for provider in providers_for(setup) { let key = (setup.id.clone(), provider.name()); if let Some((fetched, snapshot)) = self.cache.lock().unwrap().get(&key) && fetched.elapsed() < MIN_POLL_INTERVAL { // Cached numbers, but the machine's *name* is read // fresh: a rename should show immediately rather than // waiting out the poll interval it has nothing to do // with. let mut snapshot = snapshot.clone(); snapshot.setup_name = setup.name.clone(); fresh.push(snapshot); continue; } // Fetched without the lock held: this makes a network call // per machine, and holding the cache across them would // serialise every phone asking for the screen behind the // slowest ssh connection. let snapshot = provider.fetch(); self.cache .lock() .unwrap() .insert(key, (Instant::now(), snapshot.clone())); fresh.push(snapshot); } } // Machines that have gone away should not keep their numbers alive. 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::*; #[test] fn parses_the_limits_array_defensively() { // Trimmed from a live 2026-08-24 response. 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)"); // Unknown kinds surface under their raw name instead of vanishing. assert_eq!(windows[3].label, "mystery_new_window"); assert_eq!(windows[3].resets_at, None); } /// A setup naming a machine that cannot be dialled, so nothing here /// touches the network beyond ssh failing to resolve it. 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()], 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()), }; let snapshot = provider.fetch(); // The distinction the old single `error` string could not make: // this machine was never reached, which is not the same as a // machine that answered and has nobody logged in. 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() { // What a real shell says when nobody has logged in on that // machine. Nothing to fix, so it must not read as an error. 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 ); // What ssh says when the machine is not there. Worth chasing, and // the detail is carried so somebody can. 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:?}" ); // Anything unrecognised errs towards the state that gets looked // at, rather than silently claiming nobody is logged in. 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![], }]; // A machine with no Claude on it has no Claude limits, and a row // reporting on it would be a fact about nothing. assert!(providers_for(&echo_only).is_empty()); assert_eq!(providers_for(&unreachable_setup()).len(), 1); } #[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()); } }