Phase 3: usage screen

GET /usage serves the numbers behind Claude Code's /usage, read with the
CLI's own stored OAuth credentials (nothing to configure). The endpoint
is undocumented, so parsing is defensive -- the generic limits[] array
becomes labeled window bars, unknown kinds surface under their raw name,
and any failure degrades to an 'unavailable' snapshot with the reason.
One UsageProvider per paid service behind a caching monitor that
enforces the >=180s minimum poll regardless of phone refreshes; no
background polling at all. ureq (rustls) does the outbound call, with
the process-level CryptoProvider now chosen explicitly in main -- ureq
brings ring while axum-server brings aws-lc-rs, and with both in the
graph rustls refuses to guess.

App: a Usage screen off the session list -- per-window bars colored by
utilization with relative reset times. Verified live: 74%/26%/16%
windows rendered against the real endpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Fable 5 committed 2026-08-24 21:47:51 -04:00
1 parent f2430671a2
commit 3c97a5ef28
10 files changed
+606 -10

No files matched your search

+89
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aho-corasick"
version = "1.1.5"
@@ -23,6 +29,7 @@ dependencies = [
"if-addrs",
"qrcode",
"rand",
"rustls",
"serde",
"serde_json",
"sha2",
@@ -34,6 +41,7 @@ dependencies = [
"tower",
"tracing",
"tracing-subscriber",
"ureq",
]
[[package]]
@@ -337,6 +345,15 @@ dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
dependencies = [
"cfg-if",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
@@ -406,6 +423,16 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -713,6 +740,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.2"
@@ -878,7 +915,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
@@ -1020,6 +1059,12 @@ dependencies = [
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "slab"
version = "0.4.12"
@@ -1300,6 +1345,41 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
dependencies = [
"base64",
"flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
dependencies = [
"base64",
"http",
"httparse",
"log",
]
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8parse"
version = "0.2.2"
@@ -1324,6 +1404,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "windows-link"
version = "0.2.1"
+8
View File
@@ -30,6 +30,14 @@ qrcode = { version = "0.14", default-features = false }
# The wg0-bound listener needs the interface's address; the stdlib has no
# getifaddrs. This is the smallest crate that wraps just that.
if-addrs = "0.15"
# Outbound HTTPS for the usage endpoint. A small blocking client fits an
# every-few-minutes poll better than pulling in reqwest's tower stack;
# rustls-backed like the rest of the TLS here.
ureq = "3"
# Direct dependency only to pick the process-level CryptoProvider in main:
# ureq pulls rustls-with-ring, axum-server rustls-with-aws-lc-rs, and with
# both in the graph rustls refuses to auto-select one.
rustls = "0.23"
[dev-dependencies]
tempfile = "3"
+20 -4
View File
@@ -17,6 +17,7 @@ mod auth;
mod config;
mod routes;
mod session;
mod usage;
use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf};
@@ -119,6 +120,13 @@ fn print_enrollment(host: IpAddr, port: u16, token: &str) -> Result<()> {
#[tokio::main]
async fn main() -> Result<()> {
// Both rustls crypto providers are in the dependency graph (ureq
// brings ring, axum-server brings aws-lc-rs), so rustls refuses to
// pick one itself; choose before anything touches TLS.
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.expect("no other TLS crypto provider is installed before main");
tracing_subscriber::fmt().with_env_filter("info").init();
let args = Args::parse();
@@ -174,13 +182,21 @@ async fn main() -> Result<()> {
.await
.context("failed to load TLS cert/key")?;
let monitor = Arc::new(usage::UsageMonitor::new(vec![Box::new(usage::ClaudeUsage {
credentials_path: std::env::home_dir()
.unwrap_or_else(|| PathBuf::from("/"))
.join(".claude/.credentials.json"),
})]));
// The bearer-token middleware wraps the entire router -- routes and
// fallback alike -- here and only here, so a new route can't forget
// auth. Zero unauthenticated endpoints.
let app = routes::router(Arc::clone(&manager)).layer(axum::middleware::from_fn_with_state(
Arc::clone(&manager),
auth::require_token,
));
let app = routes::router(Arc::clone(&manager))
.merge(routes::usage_router(monitor))
.layer(axum::middleware::from_fn_with_state(
Arc::clone(&manager),
auth::require_token,
));
let addr = SocketAddr::new(bind_ip, args.port);
tracing::info!("serving https://{addr}");
+28 -2
View File
@@ -14,10 +14,10 @@
//! POST /sessions/{id}/attachments multipart image upload -> {id}, referenced by /message
//! GET /sessions/{id}/files/{name} images the session produced or was sent
//! DELETE /sessions/{id} kill process, delete transcript + files
//! GET /usage cached usage windows per provider
//! ```
//!
//! Later phases add: `GET /usage`, `GET|PUT /hosts` and `/models` -- see
//! PLAN.md's table.
//! Later phases add: `GET|PUT /hosts` and `/models` -- see PLAN.md's table.
//!
//! Everything here works purely in the common event model; nothing may
//! branch on the session kind (that's what drivers are for).
@@ -26,6 +26,8 @@ use std::convert::Infallible;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Context;
use axum::Router;
use axum::extract::{Path as UrlPath, Query, State};
use axum::http::{HeaderMap, StatusCode};
@@ -69,6 +71,8 @@ enum ApiError {
UnknownRoute,
#[error("{0}")]
BadRequest(String),
#[error(transparent)]
Internal(#[from] anyhow::Error),
}
impl IntoResponse for ApiError {
@@ -76,6 +80,12 @@ impl IntoResponse for ApiError {
let status = match self {
Self::UnknownSession(_) | Self::UnknownRoute => StatusCode::NOT_FOUND,
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
Self::Internal(err) => {
// The only variant whose real cause isn't safe to hand
// back verbatim, and the only one worth a log line.
tracing::error!("{err:#}");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
(status, self.to_string()).into_response()
}
@@ -186,6 +196,22 @@ async fn interrupt(
Ok(StatusCode::NO_CONTENT)
}
/// Separate router because its state is the usage monitor, not the
/// session manager; merged (and auth-wrapped) with the rest in `main`.
pub fn usage_router(monitor: Arc<crate::usage::UsageMonitor>) -> Router {
Router::new().route("/usage", get(usage)).with_state(monitor)
}
async fn usage(
State(monitor): State<Arc<crate::usage::UsageMonitor>>,
) -> Result<axum::Json<Vec<crate::usage::UsageSnapshot>>, ApiError> {
// The fetch is blocking by design (see `usage`); off the workers.
let snapshots = tokio::task::spawn_blocking(move || monitor.snapshots())
.await
.context("usage fetch panicked")?;
Ok(axum::Json(snapshots))
}
#[derive(Deserialize)]
struct ModelRequest {
model: String,
+255
View File
@@ -0,0 +1,255 @@
//! Usage-limit reporting -- the same numbers as Claude Code's `/usage`.
//!
//! Polls `https://api.anthropic.com/api/oauth/usage` with the OAuth access
//! token from Claude Code's local credential store. The endpoint is
//! undocumented and has changed before, so everything here is best-effort:
//! every field is optional, and failure degrades to an "unavailable"
//! snapshot with the reason, never an error that breaks the screen.
//!
//! Two rules learned from others hitting this endpoint (see PLAN.md's
//! references): send `User-Agent: claude-code/<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 -- the
//! screen's fetch is the trigger, so no session activity means no traffic.
//!
//! One [`UsageProvider`] per paid service, so a second service later is a
//! new impl behind the same snapshot shape, not a parallel screen.
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use serde::Serialize;
use serde_json::Value;
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 {
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,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageSnapshot {
pub provider: String,
pub available: bool,
pub windows: Vec<UsageWindow>,
/// Why `available` is false, written for the screen.
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
/// Epoch seconds the numbers were fetched (they can be up to the poll
/// interval old).
pub fetched_at: f64,
}
pub trait UsageProvider: Send + Sync {
fn name(&self) -> &'static str;
/// Blocking -- call off the async workers.
fn fetch(&self) -> UsageSnapshot;
}
/// Reads the numbers behind Claude Code's `/usage` using its own stored
/// credentials -- nothing to configure, and it reports on exactly the
/// account the local CLI runs as.
pub struct ClaudeUsage {
pub credentials_path: PathBuf,
}
impl ClaudeUsage {
fn unavailable(&self, error: String) -> UsageSnapshot {
UsageSnapshot {
provider: self.name().to_string(),
available: false,
windows: Vec::new(),
error: Some(error),
fetched_at: crate::session::now(),
}
}
fn access_token(&self) -> Result<String, String> {
let text = std::fs::read_to_string(&self.credentials_path).map_err(|err| {
format!(
"couldn't read {} ({err}) -- is Claude Code logged in on this machine?",
self.credentials_path.display()
)
})?;
serde_json::from_str::<Value>(&text)
.ok()
.and_then(|creds| {
creds.get("claudeAiOauth")?.get("accessToken")?.as_str().map(String::from)
})
.ok_or_else(|| {
"credential file has no claudeAiOauth.accessToken -- log in with `claude` once"
.to_string()
})
}
}
impl UsageProvider for ClaudeUsage {
fn name(&self) -> &'static str {
"claude"
}
fn fetch(&self) -> UsageSnapshot {
let token = match self.access_token() {
Ok(token) => token,
Err(error) => return self.unavailable(error),
};
let text = match ureq::get(USAGE_URL)
.header("Authorization", &format!("Bearer {token}"))
.header("anthropic-beta", "oauth-2025-04-20")
.header("User-Agent", USER_AGENT)
.call()
.and_then(|mut response| response.body_mut().read_to_string())
{
Ok(text) => text,
Err(err) => {
// The error string can embed the URL but never the token.
return self.unavailable(format!("usage endpoint unreachable: {err}"));
}
};
let body: Value = match serde_json::from_str(&text) {
Ok(body) => body,
Err(err) => return self.unavailable(format!("usage endpoint sent non-JSON: {err}")),
};
UsageSnapshot {
provider: self.name().to_string(),
available: true,
windows: parse_windows(&body),
error: None,
fetched_at: crate::session::now(),
}
}
}
/// Pulls the `limits` array apart, defensively: entries with no percent
/// are skipped, unknown kinds keep their raw name as the label rather
/// than being dropped -- a new window appearing should show up, not
/// vanish.
fn parse_windows(body: &Value) -> Vec<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 {
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()
}
/// The cache in front of whatever providers exist: at most one real fetch
/// per provider per [`MIN_POLL_INTERVAL`], no matter how often the phone
/// asks.
pub struct UsageMonitor {
providers: Vec<Box<dyn UsageProvider>>,
cache: Mutex<Vec<(Instant, UsageSnapshot)>>,
}
impl UsageMonitor {
pub fn new(providers: Vec<Box<dyn UsageProvider>>) -> Self {
Self { providers, cache: Mutex::new(Vec::new()) }
}
/// Blocking -- call via `spawn_blocking`.
pub fn snapshots(&self) -> Vec<UsageSnapshot> {
let mut cache = self.cache.lock().unwrap();
self.providers
.iter()
.enumerate()
.map(|(i, provider)| {
if let Some((fetched, snapshot)) = cache.get(i)
&& fetched.elapsed() < MIN_POLL_INTERVAL
{
return snapshot.clone();
}
let snapshot = provider.fetch();
match cache.get_mut(i) {
Some(slot) => *slot = (Instant::now(), snapshot.clone()),
None => cache.push((Instant::now(), snapshot.clone())),
}
snapshot
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_the_limits_array_defensively() {
// Trimmed from a live 2026-08-24 response.
let body: Value = serde_json::from_str(
r#"{"limits":[
{"kind":"session","group":"session","percent":70,"severity":"normal","resets_at":"2026-08-25T04:29:59+00:00","scope":null,"is_active":true},
{"kind":"weekly_all","group":"weekly","percent":25,"resets_at":"2026-08-28T21:59:59+00:00","is_active":false},
{"kind":"weekly_scoped","percent":15,"resets_at":"2026-08-28T21:59:59+00:00","scope":{"model":{"id":null,"display_name":"Fable"}},"is_active":false},
{"kind":"mystery_new_window","percent":5},
{"kind":"broken_entry_without_percent"}
]}"#,
)
.expect("json");
let windows = parse_windows(&body);
assert_eq!(windows.len(), 4);
assert_eq!(windows[0].label, "5-hour window");
assert_eq!(windows[0].percent, 70.0);
assert!(windows[0].active);
assert_eq!(windows[1].label, "Weekly (all models)");
assert_eq!(windows[2].label, "Weekly (Fable)");
// Unknown kinds surface under their raw name instead of vanishing.
assert_eq!(windows[3].label, "mystery_new_window");
assert_eq!(windows[3].resets_at, None);
}
#[test]
fn missing_credentials_degrade_to_unavailable() {
let provider = ClaudeUsage { credentials_path: PathBuf::from("/nonexistent/creds.json") };
let snapshot = provider.fetch();
assert!(!snapshot.available);
assert!(snapshot.error.expect("reason").contains("logged in"));
}
#[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());
}
}