//! Usage-limit reporting -- the same numbers the provider CLIs show. //! //! 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: 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. //! //! One [`UsageProvider`] per paid service keeps each provider's wire format //! behind the same snapshot shape. //! //! **Asked of the machine that spends the tokens, not of this one.** A session //! runs wherever its machine says, so the account being billed is that machine's. //! In the layout this project aims at, `ai-server` is 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 an account with no sessions. Credentials are //! read through the session `Transport`, one snapshot per machine that offers //! that provider. //! //! The token is read *to* the backend and the HTTP call is made from here, so //! the far machine needs nothing beyond a shell and the wire format stays 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. use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use serde::Serialize; use serde_json::Value; use crate::config::MachineConfig; 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 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, /// 0-100. pub percent: f64, /// Length of the cycle when the provider reports one. #[serde(skip_serializing_if = "Option::is_none")] pub duration_minutes: Option, /// 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. /// /// Named 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 machine 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 }, /// An explicit Claude login is waiting for its browser code. Kept apart /// from a failure because nothing is broken while somebody is completing /// the operation on the phone. Authenticating, /// Credentials exist, but the CLI could not renew them. Unlike a generic /// provider failure, this has a direct action the phone can offer. LoginRequired { 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 machine: String, /// That machine's current label, resolved when the snapshot is built, so /// renaming a machine renames it here too. pub machine_name: String, /// The provider's billing pool, when it exposes more than one. Codex uses /// this to keep regular usage separate from its Luna reserve allowance. #[serde(skip_serializing_if = "Option::is_none")] pub limit_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub limit_name: Option, #[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"; /// ChatGPT-backed Codex CLI subscription usage. pub const CODEX: &str = "codex"; /// The invented one, for testing the screens that draw these -- see /// [`Fixture`]. pub const ECHO: &str = "echo"; pub trait UsageProvider: Send + Sync { fn name(&self) -> &'static str; /// Blocking -- call off the async workers. A provider may expose more than /// one billing pool, so each answer is a separately labeled snapshot. fn fetch(&self) -> Vec; /// How long an answer from this one may be reused. /// /// 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 } } /// 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 machine: String, pub machine_name: String, /// How to reach that machine. `Here` for the backend's own. pub transport: Transport, /// The CLI to run there, for the one thing this asks of it: refreshing its /// own expired token. The provider's, so a machine with the CLI somewhere /// odd is asked at the same path its sessions run. pub program: String, } /// 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(), machine: self.machine.clone(), machine_name: self.machine_name.clone(), limit_id: None, limit_name: None, 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. 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) -> Vec { let token = match self.access_token() { Ok(token) => token, Err(state) => return vec![self.snapshot(state, Vec::new())], }; let body = match self.call(&token) { Ok(body) => body, Err(Refused::Other(detail)) => { return vec![self.snapshot(UsageState::Failed { detail }, Vec::new())]; } Err(Refused::Unauthorized) => match self.after_cli_refresh(&token) { Ok(body) => body, Err(state) => return vec![self.snapshot(state, Vec::new())], }, }; vec![self.snapshot(UsageState::Ok, parse_windows(&body))] } } /// Reads the same snapshot as Codex's status display through the CLI's local /// app-server protocol. The CLI owns authentication and token refresh; this /// process never opens or copies its credentials. pub struct CodexUsage { pub machine: String, pub machine_name: String, pub transport: Transport, pub program: String, } impl CodexUsage { fn snapshot(&self, state: UsageState, windows: Vec) -> UsageSnapshot { UsageSnapshot { provider: CODEX.to_string(), machine: self.machine.clone(), machine_name: self.machine_name.clone(), limit_id: None, limit_name: None, state, windows, fetched_at: crate::session::now(), } } } impl UsageProvider for CodexUsage { fn name(&self) -> &'static str { CODEX } fn fetch(&self) -> Vec { let launch = Launch::new( &self.program, vec!["app-server".to_string(), "--stdio".to_string()], None, ); let initialized = serde_json::json!({ "id": 1, "method": "initialize", "params": {"clientInfo": {"name": "ai-app", "title": "AI Sessions", "version": env!("CARGO_PKG_VERSION")}} }); let requests = [ serde_json::json!({"method": "initialized"}), serde_json::json!({"id": 2, "method": "account/rateLimits/read"}), ]; let answer = match self .transport .request_json_blocking(&launch, &initialized, &requests, 2) { Ok(answer) => answer, Err(err) => { return vec![self.snapshot( UsageState::Unreachable { detail: format!("couldn't ask Codex on {}: {err:#}", self.machine_name), }, Vec::new(), )]; } }; if let Some(error) = answer.pointer("/error/message").and_then(Value::as_str) { let state = if error.to_ascii_lowercase().contains("login") || error.to_ascii_lowercase().contains("authentication") { UsageState::NotLoggedIn } else { UsageState::Failed { detail: error.to_string(), } }; return vec![self.snapshot(state, Vec::new())]; } let Some(limits) = answer.pointer("/result/rateLimits") else { return vec![self.snapshot( UsageState::Failed { detail: "Codex returned no rate-limit snapshot".to_string(), }, Vec::new(), )]; }; let mut snapshots = vec![self.snapshot_for_limit("codex", None, limits)]; if let Some(pools) = answer .pointer("/result/rateLimitsByLimitId") .and_then(Value::as_object) { for (key, pool) in pools { let limit_id = pool.get("limitId").and_then(Value::as_str).unwrap_or(key); if limit_id == "codex" { continue; } let limit_name = pool.get("limitName").and_then(Value::as_str); snapshots.push(self.snapshot_for_limit(limit_id, limit_name, pool)); } } snapshots } } impl CodexUsage { fn snapshot_for_limit( &self, limit_id: &str, limit_name: Option<&str>, limits: &Value, ) -> UsageSnapshot { let mut snapshot = self.snapshot(UsageState::Ok, parse_codex_windows(limits)); snapshot.limit_id = Some(limit_id.to_string()); snapshot.limit_name = limit_name.map(str::to_string); snapshot } } fn parse_codex_windows(limits: &Value) -> Vec { [("primary", true), ("secondary", false)] .into_iter() .filter_map(|(kind, primary)| { let window = limits.get(kind)?; if window.is_null() { return None; } let minutes = window.get("windowDurationMins").and_then(Value::as_u64); let label = match minutes { Some(300) => "5-hour window".to_string(), Some(10_080) => "Weekly".to_string(), Some(minutes) if minutes % 1_440 == 0 => { format!("{}-day window", minutes / 1_440) } Some(minutes) if minutes % 60 == 0 => { format!("{}-hour window", minutes / 60) } Some(minutes) => format!("{minutes}-minute window"), None if primary => "Primary window".to_string(), None => "Secondary window".to_string(), }; Some(UsageWindow { // Keep the common semantic names where the duration establishes them. `primary` // is only the protocol's position and can itself be a weekly window. kind: match minutes { Some(300) => "session", Some(10_080) => "weekly_all", _ if primary => "primary", _ => "secondary", } .to_string(), label, percent: window.get("usedPercent")?.as_f64()?, duration_minutes: minutes, resets_at: window .get("resetsAt") .and_then(Value::as_i64) .and_then(|seconds| time::OffsetDateTime::from_unix_timestamp(seconds).ok()) .and_then(|at| { at.format(&time::format_description::well_known::Rfc3339) .ok() }), // Unlike Claude's response, Codex does not say which window is binding. active: false, }) }) .collect() } /// Why one call to the usage endpoint did not produce numbers. /// /// 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), } impl ClaudeUsage { /// One call to the endpoint with one token. 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}"))) } /// Have the machine's own CLI refresh its token, then ask once more. /// /// **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`. /// /// `doctor` rather than the `auth status` it reads like, measured against /// this CLI (2.1.258) on 2026-09-05 with a deliberately invalid token: /// `auth status` reports `loggedIn: true` off the file alone and never /// touches the network, so it would have refreshed nothing while looking /// like it had. `doctor` resolves the account, which is what makes it /// refresh, and it spends no quota. The same probe showed what a *failed* /// refresh does -- the CLI blanks both tokens -- so this must stay on the /// 401 path, where the access token is already dead, and never be used to /// refresh speculatively. /// /// Only a token that actually changed is retried, so a CLI that refreshed /// nothing costs one call rather than two, and this cannot become a loop. 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.machine_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 }, }) } /// A login the CLI could not renew: the one state here somebody has to act /// on, so it says where and what to run. fn still_expired(&self) -> UsageState { UsageState::LoginRequired { detail: format!( "The Claude login on {} has expired and could not be refreshed.", self.machine_name ), } } } /// What a failed call to the usage endpoint should say. /// /// 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}"), } } /// 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 that 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, and 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, duration_minutes: match kind { "session" => Some(300), "weekly_all" | "weekly_scoped" => Some(10_080), _ => None, }, 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() } /// An invented answer, so the screens that draw these can be exercised /// without an account. /// /// 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. /// /// Shared by the session layer, which writes it, and [`UsageMonitor`], /// which reads it. Empty until something sets it, and an empty fixture /// produces no snapshot at all -- an echo session meters nothing, and /// nothing is what the phone should draw. #[derive(Clone, Default)] pub struct Fixture { said: Arc>>, } /// What a meter answered: which of the four states it is in, and whatever /// windows go with it. Empty for every state but [`UsageState::Ok`]. type Reported = (UsageState, Vec); /// How long the invented five-hour window has left, when nothing says. 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() } /// Acts on the words typed after `/usage`, and says what it did. /// /// The vocabulary lives here rather than in the echo driver because /// these are this module's states: a driver spelling them out would /// be a second place that has to learn about a fifth one. 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}") } } /// The three windows Claude reports today, invented around one number. /// /// 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 { // The state a real response is in between blocks: there is no // window running, so there is nothing to reset. It is not a // missing value, and the phone words it differently. Some("never") | Some("none") => None, // A timestamp that arrives and cannot be read, which is the one // case that really is "we could not find out". 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, duration_minutes: Some(300), resets_at: resets_at.clone(), active: true, }, UsageWindow { kind: "weekly_all".to_string(), label: "Weekly (all models)".to_string(), percent: percent / 2.0, duration_minutes: Some(10_080), 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, duration_minutes: Some(10_080), resets_at: resets_at.as_ref().map(|_| reset_in(FIXTURE_MINUTES * 40)), active: false, }, ] } /// `minutes` from now, in the format the real endpoint sends. fn reset_in(minutes: i64) -> String { let at = time::OffsetDateTime::now_utc() + time::Duration::minutes(minutes); at.format(&time::format_description::well_known::Rfc3339) // Formatting a timestamp cannot fail for any input this builds; // saying so beats a fixture that silently has no reset time. .unwrap_or_else(|_| "unformattable".to_string()) } /// One line naming what a fixture is currently claiming, for the reply /// the echo session writes back. 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::Authenticating => "sign-in is in progress".to_string(), UsageState::LoginRequired { detail } => format!("sign-in required ({detail})"), UsageState::Failed { detail } => format!("the meter failed ({detail})"), } } /// The fixture, as a provider, so it travels the same route and the same /// cache as a real meter rather than being spliced in at the screen. struct EchoUsage { machine: String, machine_name: String, fixture: Fixture, } impl UsageProvider for EchoUsage { fn name(&self) -> &'static str { ECHO } fn fetch(&self) -> Vec { 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(), )); vec![UsageSnapshot { provider: self.name().to_string(), machine: self.machine.clone(), machine_name: self.machine_name.clone(), limit_id: None, limit_name: None, state, windows, fetched_at: crate::session::now(), }] } /// Read from memory, and set by somebody who is about to look at the /// screen it changes. fn poll_interval(&self) -> Duration { Duration::ZERO } } /// Which paid services a machine can be asked about. /// /// Derived from what the machine 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(machine: &MachineConfig, fixture: &Fixture) -> Vec> { let mut found: Vec> = Vec::new(); for provider in &machine.providers { let Some(name) = provider.kind.usage_provider() else { continue; }; // A machine offering two Claude providers has one account, not // two: the meter belongs to the machine and the service, which is // exactly what the cache is keyed by. if found.iter().any(|already| already.name() == name) { continue; } match name { CLAUDE => found.push(Box::new(ClaudeUsage { machine: machine.id.clone(), machine_name: machine.name.clone(), transport: Transport::for_machine(machine), program: provider.program().to_string(), })), CODEX => found.push(Box::new(CodexUsage { machine: machine.id.clone(), machine_name: machine.name.clone(), transport: Transport::for_machine(machine), program: provider.program().to_string(), })), // Nothing at all until a test has asked for something: an // echo session costs nothing, so the honest answer is no row // rather than a row saying zero. ECHO if fixture.is_set() => found.push(Box::new(EchoUsage { machine: machine.id.clone(), machine_name: machine.name.clone(), fixture: fixture.clone(), })), _ => {} } } found } /// 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 not /// fixed at startup -- machines 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, Vec)>; type ProviderGate = Arc>; #[derive(Default)] /// The cache in front of whatever machines exist: at most one real fetch per /// machine per service per [`MIN_POLL_INTERVAL`], however often the phone asks. pub struct UsageMonitor { cache: Mutex, /// One gate per machine and metered provider. The cache lock cannot cover /// a network call without making an unreachable machine stall every other /// one, but releasing it used to let concurrent `/usage` requests launch /// two token refreshers against the same rotating credential. gates: Mutex>, /// The invented meter an echo session can put up; empty unless one /// has. Shared with the session layer, which is where the command /// that sets it is typed -- see [`Fixture`]. fixture: Fixture, } impl UsageMonitor { pub fn new(fixture: Fixture) -> Self { Self { cache: Mutex::new(Cached::new()), gates: Mutex::new(HashMap::new()), fixture, } } fn gate_for(&self, key: &(String, &'static str)) -> ProviderGate { self.gates .lock() .unwrap() .entry(key.clone()) .or_default() .clone() } fn cached( &self, key: &(String, &'static str), machine_name: &str, max_age: Option, ) -> Option> { let cache = self.cache.lock().unwrap(); let (fetched, snapshots) = cache.get(key)?; if max_age.is_some_and(|age| fetched.elapsed() >= age) { return None; } let mut snapshots = snapshots.clone(); for snapshot in &mut snapshots { snapshot.machine_name = machine_name.to_string(); } Some(snapshots) } /// Prevents automatic refresh from overlapping an explicit login. The /// fresh cache entry closes the small gap before the login worker acquires /// the same gate, while an attempt lasting beyond the normal cache window /// is protected by the worker holding it. pub(crate) fn claude_authentication_started(&self, machine: &MachineConfig) -> ProviderGate { let key = (machine.id.clone(), CLAUDE); let gate = self.gate_for(&key); let guard = gate.lock().unwrap(); self.cache.lock().unwrap().insert( key, ( Instant::now(), vec![UsageSnapshot { provider: CLAUDE.to_string(), machine: machine.id.clone(), machine_name: machine.name.clone(), limit_id: None, limit_name: None, state: UsageState::Authenticating, windows: Vec::new(), fetched_at: crate::session::now(), }], ), ); drop(guard); gate } /// Makes the first read after a login ask the provider instead of keeping /// the pre-login state for the ordinary cache interval. pub(crate) fn claude_authentication_finished(&self, machine: &str) { self.cache .lock() .unwrap() .remove(&(machine.to_string(), CLAUDE)); } /// One or more snapshots per machine that offers a paid service, in the /// order the machines are configured and the provider reports them. /// /// Blocking -- call via `spawn_blocking`. Takes the machines rather than /// holding the manager, so this module stays below the session layer rather /// than reaching up into it. pub fn snapshots(&self, machines: &[MachineConfig]) -> Vec { let mut fresh = Vec::new(); for machine in machines { for provider in providers_for(machine, &self.fixture) { let key = (machine.id.clone(), provider.name()); if let Some(snapshots) = self.cached(&key, &machine.name, Some(provider.poll_interval())) { fresh.extend(snapshots); continue; } let gate = self.gate_for(&key); let _guard = match gate.try_lock() { Ok(guard) => guard, Err(std::sync::TryLockError::WouldBlock) => { // A refresh already in flight can keep serving the last // measured answer. An explicit login seeds its own // authenticating answer before taking the gate. if let Some(snapshots) = self.cached(&key, &machine.name, None) { fresh.extend(snapshots); continue; } // The first-ever fetch has no honest stale answer. Wait // for its one producer, then recheck below. gate.lock().unwrap() } Err(std::sync::TryLockError::Poisoned(err)) => err.into_inner(), }; if let Some(snapshots) = self.cached(&key, &machine.name, Some(provider.poll_interval())) { fresh.extend(snapshots); continue; } let snapshots = provider.fetch(); self.cache .lock() .unwrap() .insert(key, (Instant::now(), snapshots.clone())); fresh.extend(snapshots); } } // Machines that have gone away should not keep their numbers alive. let live: std::collections::HashSet<&str> = machines.iter().map(|machine| machine.id.as_str()).collect(); self.cache .lock() .unwrap() .retain(|(machine, _), _| live.contains(machine.as_str())); self.gates.lock().unwrap().retain(|(machine, _), gate| { live.contains(machine.as_str()) || Arc::strong_count(gate) > 1 }); fresh } } #[cfg(test)] mod tests { use super::*; use crate::config::DriverKind; #[test] fn refresh_gates_are_shared_per_machine_and_provider_only() { let monitor = UsageMonitor::new(Fixture::default()); let claude_here = monitor.gate_for(&("here".to_string(), CLAUDE)); let same = monitor.gate_for(&("here".to_string(), CLAUDE)); let claude_there = monitor.gate_for(&("there".to_string(), CLAUDE)); let codex_here = monitor.gate_for(&("here".to_string(), CODEX)); let held = claude_here.lock().unwrap(); assert!( matches!(same.try_lock(), Err(std::sync::TryLockError::WouldBlock)), "the same machine/provider pair must have one credential writer" ); assert!( claude_there.try_lock().is_ok(), "another machine must not wait" ); assert!( codex_here.try_lock().is_ok(), "another provider must not wait" ); drop(held); assert!(same.try_lock().is_ok()); } #[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 { machine: "far".to_string(), machine_name: "somewhere else".to_string(), transport: Transport::for_machine(&unreachable_machine()), program: "/opt/claude".to_string(), }; // The machine cannot be reached, so the refresh attempt fails there // rather than at the endpoint -- and the message still has to name the // machine and the command, since that is all anybody gets to act on. 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::LoginRequired { detail } = provider.still_expired() else { panic!("still expired must offer a new login"); }; assert!(detail.contains("somewhere else"), "{detail}"); assert!(!detail.contains("unreachable"), "{detail}"); } #[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].kind, "session"); 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 machine naming a machine that cannot be dialled, so nothing here touches /// the network beyond ssh failing to resolve it. fn unreachable_machine() -> MachineConfig { MachineConfig { 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![], mcp_servers: Vec::new(), }], } } #[test] fn a_machine_that_cannot_be_asked_says_so_rather_than_looking_logged_out() { let provider = ClaudeUsage { machine: "far".to_string(), machine_name: "somewhere else".to_string(), transport: Transport::for_machine(&unreachable_machine()), program: "claude".to_string(), }; let snapshot = provider.fetch().remove(0); // 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.machine, "far"); assert_eq!(snapshot.machine_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_machine(); echo_only.providers = vec![crate::config::ProviderConfig { name: "echo".to_string(), kind: DriverKind::Echo, command: None, models: vec![], mcp_servers: Vec::new(), }]; // A machine with no Claude on it has no Claude limits, and a row // reporting on it would be a fact about nothing. Echo included: // an echo session spends nothing, so until a fixture says // otherwise there is no meter to report. let unset = Fixture::new(); assert!(providers_for(&echo_only, &unset).is_empty()); assert_eq!(providers_for(&unreachable_machine(), &unset).len(), 1); // And with one set, that machine has exactly the invented meter // -- under the name the session's `usageProvider` will name. 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::CodexCli.usage_provider(), Some(CODEX)); // A local model costs nothing to run, so it meters nothing. assert_eq!(DriverKind::LlamaCpp.usage_provider(), None); } /// The states the fixture exists to make reachable, and the one thing /// it must not do: invent a reset time for a window that has 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()); // Between blocks: no reset time, which the phone words as the // window not running rather than as a time it could not read. 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()); // A word it does not know changes nothing and says what it takes. 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()); } #[test] fn parses_codex_primary_and_secondary_windows() { let limits = serde_json::json!({ "primary": {"usedPercent": 10, "windowDurationMins": 300, "resetsAt": 1788853003_i64}, "secondary": {"usedPercent": 2, "windowDurationMins": 10080, "resetsAt": 1789439803_i64} }); let windows = parse_codex_windows(&limits); assert_eq!(windows.len(), 2); assert_eq!(windows[0].label, "5-hour window"); assert_eq!(windows[0].percent, 10.0); assert_eq!(windows[0].duration_minutes, Some(300)); assert!(!windows[0].active); assert_eq!(windows[1].kind, "weekly_all"); assert_eq!(windows[1].label, "Weekly"); assert_eq!(windows[1].duration_minutes, Some(10_080)); assert!( windows[1] .resets_at .as_deref() .is_some_and(|at| at.ends_with('Z')) ); } #[test] fn keeps_codex_reserve_as_a_named_pool() { let provider = CodexUsage { machine: "local".to_string(), machine_name: "this machine".to_string(), transport: Transport::Here, program: "codex".to_string(), }; let snapshot = provider.snapshot_for_limit( "base_model_inference", Some("gpt-reserve"), &serde_json::json!({ "primary": { "usedPercent": 12, "windowDurationMins": 10080, "resetsAt": 1789446343_i64 }, "secondary": null }), ); assert_eq!(snapshot.limit_id.as_deref(), Some("base_model_inference")); assert_eq!(snapshot.limit_name.as_deref(), Some("gpt-reserve")); assert_eq!(snapshot.windows.len(), 1); assert_eq!(snapshot.windows[0].kind, "weekly_all"); assert_eq!(snapshot.windows[0].label, "Weekly"); assert_eq!(snapshot.windows[0].duration_minutes, Some(10_080)); } }