1292 lines
52 KiB
Rust
1292 lines
52 KiB
Rust
//! 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/<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 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<u64>,
|
|
/// 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.
|
|
///
|
|
/// 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<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub limit_name: Option<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";
|
|
/// 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<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 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<UsageWindow>) -> 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<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) -> Vec<UsageSnapshot> {
|
|
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<UsageWindow>) -> 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<UsageSnapshot> {
|
|
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<UsageWindow> {
|
|
[("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<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.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<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,
|
|
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<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,
|
|
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<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(),
|
|
));
|
|
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<Box<dyn UsageProvider>> {
|
|
let mut found: Vec<Box<dyn UsageProvider>> = 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<UsageSnapshot>)>;
|
|
type ProviderGate = Arc<Mutex<()>>;
|
|
|
|
#[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>,
|
|
/// 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<HashMap<(String, &'static str), ProviderGate>>,
|
|
/// 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<Duration>,
|
|
) -> Option<Vec<UsageSnapshot>> {
|
|
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<UsageSnapshot> {
|
|
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![],
|
|
}],
|
|
}
|
|
}
|
|
|
|
#[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![],
|
|
}];
|
|
// 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));
|
|
}
|
|
}
|