A 401 from the usage endpoint means the stored access token has expired. Refreshing it here is not an option: Anthropic's OAuth rotates the refresh token, so a second refresher invalidates the CLI's copy and forces a re-login on a machine that usually has a live session on it. So run the CLI there instead and re-read what it wrote. `doctor` rather than `auth status`: probed against 2.1.258 with an invalid token, `auth status` answers loggedIn:true from the file alone and never reaches the network. The same probe showed a failed refresh blanks both tokens, which is why this stays on the 401 path. Also gives ProviderConfig one program() so the CLI's default path is not written down twice.
924 lines
38 KiB
Rust
924 lines
38 KiB
Rust
//! 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: send `User-Agent:
|
|
//! claude-code/<version>` (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, 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.
|
|
//! 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 setup that offers
|
|
//! Claude.
|
|
//!
|
|
//! 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::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 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,
|
|
/// ISO-8601, as the API sends it; absent for windows that never reset.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub resets_at: Option<String>,
|
|
/// 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. "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 {
|
|
/// 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<UsageWindow>,
|
|
/// 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";
|
|
/// 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.
|
|
fn fetch(&self) -> UsageSnapshot;
|
|
/// 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 setup: String,
|
|
pub setup_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<UsageWindow>) -> 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.
|
|
fn access_token(&self) -> Result<String, UsageState> {
|
|
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::<Value>(&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 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))
|
|
}
|
|
}
|
|
|
|
/// 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<Value, Refused> {
|
|
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<Value, UsageState> {
|
|
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 },
|
|
})
|
|
}
|
|
|
|
/// 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::Failed {
|
|
detail: format!(
|
|
"the Claude login on {} has expired and could not be refreshed; run `{} /login` there",
|
|
self.setup_name, self.program
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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<UsageWindow> {
|
|
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()
|
|
}
|
|
|
|
/// 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<Mutex<Option<Reported>>>,
|
|
}
|
|
|
|
/// 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<UsageWindow>);
|
|
|
|
/// 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<Reported> {
|
|
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::<f64>() {
|
|
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<UsageWindow> {
|
|
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,
|
|
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,
|
|
},
|
|
]
|
|
}
|
|
|
|
/// `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::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 {
|
|
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(),
|
|
}
|
|
}
|
|
|
|
/// 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 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<Box<dyn UsageProvider>> {
|
|
let mut found: Vec<Box<dyn UsageProvider>> = Vec::new();
|
|
for provider in &setup.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 {
|
|
setup: setup.id.clone(),
|
|
setup_name: setup.name.clone(),
|
|
transport: Transport::for_setup(setup),
|
|
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 {
|
|
setup: setup.id.clone(),
|
|
setup_name: setup.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 -- 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)]
|
|
/// 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<Cached>,
|
|
/// 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()),
|
|
fixture,
|
|
}
|
|
}
|
|
|
|
/// 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<UsageSnapshot> {
|
|
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()
|
|
{
|
|
// Cached numbers, but the machine's *name* is read fresh: a
|
|
// rename should show immediately rather than waiting out a
|
|
// 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.
|
|
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::*;
|
|
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(),
|
|
};
|
|
// 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::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() {
|
|
// 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()],
|
|
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();
|
|
// 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. 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_setup(), &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));
|
|
// 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());
|
|
}
|
|
}
|