Read Claude Code's own quota endpoint, not just its status line

Decision 0015 rejected the account usage endpoint because AGENTS.md forbade
reading a harness's credential. The rule was written to stop one program
helping itself to another's secrets, and it was catching a legitimate use with
it: the user asking about their own subscription, through software they
installed to do that. AGENTS.md now states the narrow allowance instead of an
absolute the project does not hold, and 0016 records it.

The status line stays. It is free and it speaks every turn. What it cannot do
is report the per-model weekly limits a Max plan meters separately, or answer
at all before a session has taken a turn. The first live reading found the
account-wide seven-day window at 38% left and a per-model weekly window at 77%
left — a second ceiling the footer previously could not see.

Constraints the credential is read under, all enforced in code: access token
only, never the refresh token; zeroed on drop, along with the file buffer it
was borrowed out of; unprintable by construction, since HarnessError carries no
owned strings and AccessToken's Debug is hand-written; identified as
lumbridge/<version>, because sending claude-code/2.1.0 would make our traffic
indistinguishable from the harness's in Anthropic's logs; and off entirely
under LUMBRIDGE_CLAUDE_OAUTH=0.

The request runs on a detached thread with a slow refresh and a 429 backoff, so
a ten-second round trip cannot stall the transcript follower or make quitting
wait on the network, and one surface failing does not fault the other two.

Footer polish on top: the harness name prints once per group instead of in
front of each of its four windows, each quota carries a short scope pill
(5h, 7d, Fable wk, tokens) where an invisible BORDER-weight label used to be,
quotas sort ahead of spend, and a window under ten percent turns its headline
amber — value colour on the number, provenance colour on the meter, never
mixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Metal Agent
2026-08-31 22:04:34 -07:00
co-authored by Claude Opus 5
parent ef52aa7ce2
commit 219c674aea
13 changed files with 1674 additions and 73 deletions
+1
View File
@@ -12,6 +12,7 @@ lumbridge-core = { path = "../lumbridge-core" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2.0"
ureq = { version = "3.4.0", default-features = false, features = ["rustls", "json"] }
[target.'cfg(unix)'.dependencies]
nix = { version = "0.28", features = ["process", "signal"] }
@@ -0,0 +1,57 @@
//! Takes one reading from Claude Code's account usage endpoint and prints it.
//!
//! `cargo run -p lumbridge-harness --example claude_usage`
//!
//! Useful for answering "what does the provider actually say right now?"
//! without launching the shell. It performs exactly one request — the endpoint
//! rate-limits under polling — and prints only the parsed windows. The token is
//! never printed, because there is no code path here that could reach it.
use lumbridge_harness::MonotonicWallClock;
use lumbridge_harness::claude::WindowReading;
use lumbridge_harness::claude::oauth::{ClaudeOauthOptions, RefreshOutcome, refresh};
fn main() {
let options = ClaudeOauthOptions::default();
if !options.enabled {
println!("the usage endpoint is disabled (LUMBRIDGE_CLAUDE_OAUTH=0)");
return;
}
let now_ms = MonotonicWallClock::start().now_ms();
println!("GET {}", options.endpoint);
match refresh(&options, now_ms) {
RefreshOutcome::Snapshot(snapshot, plan) => {
println!("plan: {}", plan.label.as_deref().unwrap_or("unreported"));
print_window("five hour", snapshot.five_hour, now_ms);
print_window("seven day", snapshot.seven_day, now_ms);
for scoped in snapshot.scoped {
print_window(&scoped.model, scoped.reading, now_ms);
}
}
RefreshOutcome::RateLimited => println!("rate limited; try again later"),
RefreshOutcome::Unauthenticated => {
println!("no usable credential (not signed in, expired, or disabled)");
}
RefreshOutcome::Failed(error) => println!("failed: {error}"),
}
}
fn print_window(label: &str, reading: WindowReading, now_ms: u64) {
match reading {
WindowReading::Usable { permille, window } => {
let left = (1_000 - permille.min(1_000)) / 10;
let resets = window.map_or_else(
|| "no reset reported".to_owned(),
|window| {
format!(
"resets in {}",
lumbridge_core::format_duration_ms(window.remaining_ms(now_ms))
)
},
);
println!("{label:>22}: {left}% left · {resets}");
}
WindowReading::Absent => println!("{label:>22}: not reported"),
}
}
+220 -17
View File
@@ -1,14 +1,23 @@
//! The Claude Code usage adapter, over two different surfaces.
//!
//! **Subscription windows** ([`statusline`]) are the real quota. Claude Code
//! 2.1.80 and later pipe a `rate_limits` object carrying `five_hour` and
//! `seven_day` — each with `used_percentage` and `resets_at` — to the
//! configured `statusLine` command on every turn. Those are rate-limit headers
//! the CLI already received on its own API responses, so reading them costs
//! nothing and they are `ProviderReported`. Lumbridge does not call the
//! account's usage endpoint for them: that would mean reading Claude Code's
//! OAuth credential, which AGENTS.md forbids. The status line is pushed to a
//! command the user installs, so no credential is ever touched.
//! **Subscription windows** come from two surfaces that answer the same
//! question, because neither is sufficient alone.
//!
//! [`statusline`] is the free continuous one. Claude Code 2.1.80 and later pipe
//! a `rate_limits` object carrying `five_hour` and `seven_day` — each with
//! `used_percentage` and `resets_at` — to the configured `statusLine` command on
//! every turn. Those are rate-limit headers the CLI already received on its own
//! API responses, so reading them costs nothing. They only arrive while a
//! session is taking turns, and they omit per-model limits.
//!
//! [`oauth`] fills both gaps by asking the account's usage endpoint directly,
//! which answers on a cold start and reports the `weekly_scoped` per-model
//! limits a Max plan meters separately. It reads Claude Code's stored access
//! token to do so, under the narrow allowance in AGENTS.md recorded by decision
//! 0016, and it refreshes slowly because that endpoint rate-limits under
//! polling. Whichever surface spoke most recently wins; both are
//! `ProviderReported`, and the numbers agree because the window arithmetic is
//! shared.
//!
//! **Token consumption** ([`transcript`]) comes from the session transcripts
//! Claude Code writes under `~/.claude/projects`. Those record the API's
@@ -20,6 +29,7 @@
//! was appended since, which is what makes the token total monotonic — the
//! ledger rejects an observation that moves a profile backwards.
pub mod oauth;
mod statusline;
mod transcript;
@@ -36,6 +46,7 @@ use std::time::Duration;
use lumbridge_core::{AccountProfile, UsageObservation, UsageProvenance, UsageUnit};
use crate::HarnessError;
use crate::claude::oauth::{ClaudeOauthOptions, PlanIdentity, RefreshOutcome};
use crate::claude::statusline::parse_feed_line;
pub use crate::claude::statusline::{ClaudeWindowKind, WindowReading};
use crate::claude::transcript::MAX_RECORD_BYTES;
@@ -67,6 +78,9 @@ pub struct ClaudeCodeProbeOptions {
/// Absent until the user installs the bridge, which is why the windows read
/// as unavailable rather than as zero before then.
pub rate_limit_feed: PathBuf,
/// Whether and how often to ask the account usage endpoint. Disabled here
/// means the credential file is never opened.
pub oauth: ClaudeOauthOptions,
pub poll_interval: Duration,
}
@@ -75,6 +89,7 @@ impl Default for ClaudeCodeProbeOptions {
Self {
projects_root: default_projects_root(),
rate_limit_feed: default_rate_limit_feed(),
oauth: ClaudeOauthOptions::default(),
poll_interval: Duration::from_secs(10),
}
}
@@ -121,6 +136,9 @@ pub fn default_projects_root() -> PathBuf {
enum WorkerEvent {
Observation(UsageObservation),
Health(ProbeHealth),
/// A per-model weekly limit the usage endpoint reported. These cannot be
/// declared at startup because their names come from the response.
Profile(Box<AccountProfile>),
}
/// A running Claude Code usage probe.
@@ -134,13 +152,19 @@ pub struct ClaudeCodeProbe {
}
impl ClaudeCodeProbe {
/// The static, account-free profiles this probe reports under: one per
/// documented subscription window, plus the transcript token total.
/// The account-free profiles this probe reports under: one per documented
/// subscription window, plus the transcript token total.
///
/// The windows come first because they are the quota — what a user means
/// by "how much do I have left" — and the token total is the supporting
/// fact about spend.
fn profiles_for() -> Result<Vec<AccountProfile>, HarnessError> {
///
/// `plan` labels them with the tier the quota belongs to (`Max (20x)`),
/// which is read from the local credential file without a network call. It
/// is a plan tier and not an account identifier; the e-mail address sitting
/// beside it is deliberately not read.
fn profiles_for(plan: &PlanIdentity) -> Result<Vec<AccountProfile>, HarnessError> {
let account = plan.label.as_deref().unwrap_or("subscription");
let mut profiles = Vec::new();
for kind in ClaudeWindowKind::ALL {
profiles.push(
@@ -149,7 +173,7 @@ impl ClaudeCodeProbe {
"Claude Code",
"Anthropic",
kind.scope(),
"subscription",
account,
)
.map_err(|_| HarnessError::EmptyProgram)?,
);
@@ -160,7 +184,7 @@ impl ClaudeCodeProbe {
"Claude Code",
"Anthropic",
"session transcripts",
"subscription",
account,
)
.map_err(|_| HarnessError::EmptyProgram)?,
);
@@ -179,7 +203,8 @@ impl ClaudeCodeProbe {
if options.poll_interval < MIN_POLL_INTERVAL {
return Err(HarnessError::ProbeIntervalTooShort);
}
let profiles = Self::profiles_for()?;
let plan = oauth::read_plan_identity(&options.oauth, MonotonicWallClock::start().now_ms());
let profiles = Self::profiles_for(&plan)?;
let worker_profiles = profiles.clone();
let (event_sender, events) = mpsc::sync_channel(EVENT_QUEUE);
let stop = Arc::new(AtomicBool::new(false));
@@ -221,6 +246,11 @@ impl UsageProbe for ClaudeCodeProbe {
match events.try_recv() {
Ok(WorkerEvent::Observation(observation)) => observations.push(observation),
Ok(WorkerEvent::Health(health)) => self.health = health,
Ok(WorkerEvent::Profile(profile)) => {
if !self.profiles.iter().any(|known| known.id() == profile.id()) {
self.profiles.push(*profile);
}
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
if !self.health.is_faulted() {
@@ -264,6 +294,9 @@ struct FollowState {
/// Read position in the status-line feed, and the newest reading seen.
feed_offset: u64,
windows: BTreeMap<&'static str, WindowReading>,
/// Per-model weekly limits, keyed by their derived profile identifier. Only
/// the usage endpoint reports these; the status line has no field for them.
scoped: BTreeMap<String, (String, WindowReading)>,
}
/// Reads whatever the status-line bridge appended and keeps the newest line.
@@ -318,6 +351,154 @@ fn follow_feed(path: &Path, state: &mut FollowState, observed_at_ms: u64) {
}
}
/// Drives the account usage endpoint on its own slow cadence.
///
/// The request runs off the worker thread. A ten-second round trip must not
/// stall the transcript follower, and joining one would make quitting Lumbridge
/// wait on the network.
struct UsageEndpoint {
/// Zero means "ask now": the point of this surface is that it answers
/// before any session has taken a turn.
next_ms: u64,
interval_ms: u64,
in_flight: Arc<AtomicBool>,
sender: mpsc::Sender<RefreshOutcome>,
results: Receiver<RefreshOutcome>,
}
impl UsageEndpoint {
fn new(options: &ClaudeOauthOptions) -> Self {
let (sender, results) = mpsc::channel();
Self {
next_ms: 0,
interval_ms: u64::try_from(
options
.refresh_interval
.max(oauth::MIN_REFRESH_INTERVAL)
.as_millis(),
)
.unwrap_or(u64::MAX),
in_flight: Arc::new(AtomicBool::new(false)),
sender,
results,
}
}
/// Starts a refresh if one is due, then folds in whatever has come back.
fn pump(&mut self, options: &ClaudeOauthOptions, now_ms: u64, state: &mut FollowState) {
if options.enabled
&& now_ms >= self.next_ms
&& !self.in_flight.swap(true, Ordering::Relaxed)
{
self.next_ms = now_ms.saturating_add(self.interval_ms);
let request = options.clone();
let sender = self.sender.clone();
let flag = Arc::clone(&self.in_flight);
// Detached on purpose: nothing joins it, the send simply fails once
// the probe is gone, and the request carries its own timeout.
if thread::Builder::new()
.name("lumbridge-claude-usage".to_owned())
.spawn(move || {
let outcome = oauth::refresh(&request, now_ms);
let _ = sender.send(outcome);
flag.store(false, Ordering::Relaxed);
})
.is_err()
{
self.in_flight.store(false, Ordering::Relaxed);
}
}
while let Ok(outcome) = self.results.try_recv() {
match outcome {
RefreshOutcome::Snapshot(snapshot, _) => {
// The endpoint is the authority when it answers: it is the
// account's own statement rather than a header relayed
// through a session that may have ended hours ago.
state
.windows
.insert(ClaudeWindowKind::FiveHour.profile_id(), snapshot.five_hour);
state
.windows
.insert(ClaudeWindowKind::SevenDay.profile_id(), snapshot.seven_day);
for scoped in snapshot.scoped {
state
.scoped
.insert(scoped.profile_id, (scoped.model, scoped.reading));
}
}
// Asking again sooner would only earn another refusal, and the
// status line keeps reporting in the meantime.
RefreshOutcome::RateLimited => {
self.next_ms = now_ms.saturating_add(
u64::try_from(oauth::BACKOFF_AFTER_429.as_millis()).unwrap_or(u64::MAX),
);
}
// Neither of these is a fault. Not being signed in is a state,
// not a breakage, and it is not this probe's job to refresh
// another program's credential. A failed request is one surface
// of three going quiet — faulting the whole probe would hide two
// working readings behind one unreachable endpoint. Both leave
// the windows on whatever the status line last said.
RefreshOutcome::Unauthenticated | RefreshOutcome::Failed(_) => {}
}
}
}
}
/// Bookkeeping for the per-model weekly limits, which only the usage endpoint
/// reports and whose names are unknown until it answers.
struct ScopedState {
account: String,
last: BTreeMap<String, WindowReading>,
declared: Vec<String>,
}
/// Emits changed per-model limits. Returns false once the receiver is gone.
///
/// A profile has to be announced before its reading means anything, so the two
/// travel together the first time a model appears.
fn emit_scoped(
scoped: &mut ScopedState,
state: &FollowState,
events: &SyncSender<WorkerEvent>,
now_ms: u64,
) -> bool {
for (profile_id, (model, reading)) in &state.scoped {
if scoped.last.get(profile_id) == Some(reading) {
continue;
}
let Ok(profile) = AccountProfile::new(
profile_id.clone(),
"Claude Code",
"Anthropic",
format!("{model} weekly"),
scoped.account.as_str(),
) else {
continue;
};
scoped.last.insert(profile_id.clone(), *reading);
if !scoped.declared.contains(profile_id) {
scoped.declared.push(profile_id.clone());
if matches!(
events.try_send(WorkerEvent::Profile(Box::new(profile.clone()))),
Err(mpsc::TrySendError::Disconnected(_))
) {
return false;
}
}
if matches!(
events.try_send(WorkerEvent::Observation(window_observation(
&profile, *reading, now_ms
))),
Err(mpsc::TrySendError::Disconnected(_))
) {
return false;
}
}
true
}
fn run_worker(
options: &ClaudeCodeProbeOptions,
profiles: &[AccountProfile],
@@ -334,7 +515,18 @@ fn run_worker(
let mut last_poll_ms = 0;
let mut last_emitted = TokenTally::default();
let mut last_windows: BTreeMap<&'static str, WindowReading> = BTreeMap::new();
// Scoped profiles inherit the plan label the declared ones were built with,
// so one footer does not show two different accounts for one subscription.
let mut scoped_state = ScopedState {
account: profiles.first().map_or_else(
|| "subscription".to_owned(),
|profile| profile.account().to_owned(),
),
last: BTreeMap::new(),
declared: Vec::new(),
};
let mut primed = false;
let mut endpoint = UsageEndpoint::new(&options.oauth);
loop {
if stop.load(Ordering::Relaxed) {
@@ -354,6 +546,13 @@ fn run_worker(
// status-line reading is a complete snapshot, so it can be reported
// immediately rather than waiting for priming.
follow_feed(&options.rate_limit_feed, &mut state, now_ms);
endpoint.pump(&options.oauth, now_ms, &mut state);
if !emit_scoped(&mut scoped_state, &state, events, now_ms) {
return;
}
for kind in ClaudeWindowKind::ALL {
let Some(reading) = state.windows.get(kind.profile_id()).copied() else {
continue;
@@ -554,8 +753,8 @@ fn follow(path: &Path, state: &mut FollowState, budget: &mut usize) -> bool {
#[cfg(test)]
mod tests {
use super::{
ClaudeCodeProbe, ClaudeCodeProbeOptions, FollowState, TokenTally, default_projects_root,
scan,
ClaudeCodeProbe, ClaudeCodeProbeOptions, ClaudeOauthOptions, FollowState, TokenTally,
default_projects_root, scan,
};
use crate::HarnessError;
use crate::probe::{ProbeHealth, UsageProbe};
@@ -693,6 +892,7 @@ mod tests {
let mut probe = ClaudeCodeProbe::start(ClaudeCodeProbeOptions {
projects_root: root.clone(),
rate_limit_feed: root.join("feed.jsonl"),
oauth: ClaudeOauthOptions::disabled(),
poll_interval: Duration::from_secs(1),
})
.expect("the probe starts");
@@ -746,6 +946,7 @@ mod tests {
let mut probe = ClaudeCodeProbe::start(ClaudeCodeProbeOptions {
projects_root: root.clone(),
rate_limit_feed: feed,
oauth: ClaudeOauthOptions::disabled(),
poll_interval: Duration::from_secs(1),
})
.expect("the probe starts");
@@ -788,6 +989,7 @@ mod tests {
let mut probe = ClaudeCodeProbe::start(ClaudeCodeProbeOptions {
projects_root: root.clone(),
rate_limit_feed: feed,
oauth: ClaudeOauthOptions::disabled(),
poll_interval: Duration::from_secs(1),
})
.expect("starts");
@@ -814,6 +1016,7 @@ mod tests {
let mut probe = ClaudeCodeProbe::start(ClaudeCodeProbeOptions {
projects_root: root.clone(),
rate_limit_feed: root.join("feed.jsonl"),
oauth: ClaudeOauthOptions::disabled(),
poll_interval: Duration::from_secs(60),
})
.expect("starts");
@@ -0,0 +1,729 @@
//! Claude Code's account usage endpoint.
//!
//! This is the one place Lumbridge reads another harness's credential, and it
//! does so under a narrow, written allowance in `AGENTS.md`: the harness's own
//! access token, used only against that provider's documented usage endpoint,
//! never persisted, never logged, never passed as a command-line argument.
//! Decision 0016 records why the earlier blanket prohibition was narrowed.
//!
//! Two things the status line cannot give us come from here:
//!
//! - **Per-model weekly limits.** A Max plan meters some models separately, and
//! `limits[]` reports each as its own `weekly_scoped` entry. The status line
//! carries only the two account-wide windows, so a user who has burned a
//! model-specific limit sees nothing there.
//! - **A reading without a session.** The status line only speaks when Claude
//! Code takes a turn. This answers on demand, which is what makes the footer
//! truthful on a cold start.
//!
//! It is *not* the continuous source. The endpoint rate-limits under polling,
//! so the status line remains the free per-turn feed and this refreshes slowly
//! behind it.
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::Deserialize;
use crate::HarnessError;
use crate::claude::statusline::{
ClaudeWindowKind, WEEKLY_MS, WindowReading, unix_seconds_to_millis, window_reading,
};
/// The documented endpoint, as used by Claude Code itself.
pub const USAGE_ENDPOINT: &str = "https://api.anthropic.com/api/oauth/usage";
/// The beta header the endpoint requires for an OAuth token.
const OAUTH_BETA: &str = "oauth-2025-04-20";
/// Identifies the caller honestly: Lumbridge is not Claude Code, and says so.
/// `AGENTS.md` forbids silently impersonating a harness, and a support engineer
/// reading these logs should be able to tell who actually made the request.
const USER_AGENT: &str = concat!("lumbridge/", env!("CARGO_PKG_VERSION"), " (usage-probe)");
/// A response larger than this is not the small JSON document we expect.
const MAX_RESPONSE_BYTES: u64 = 256 * 1024;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
/// The endpoint rate-limits under polling, so this is deliberately slow. The
/// status line covers the gap between refreshes for free.
pub const MIN_REFRESH_INTERVAL: Duration = Duration::from_secs(300);
/// How long to stand down after the endpoint says we are asking too often.
pub const BACKOFF_AFTER_429: Duration = Duration::from_secs(1_800);
/// A bound on how many per-model limits will be tracked, so a response cannot
/// grow the footer without limit.
const MAX_SCOPED_WINDOWS: usize = 6;
/// A bound on a model display name before it is used to build a profile.
const MAX_LABEL_BYTES: usize = 48;
/// An access token, held for the length of one request.
///
/// The bytes are overwritten when this is dropped. That is a real but partial
/// guarantee, and it is worth stating exactly: the file is read into a buffer
/// that is also zeroed, and the token is borrowed out of it rather than being
/// copied through an intermediate `String`, so the only copies are the two this
/// type owns. It does not defend against the OS having paged either buffer out.
struct AccessToken(Vec<u8>);
impl AccessToken {
fn header_value(&self) -> Option<String> {
let token = std::str::from_utf8(&self.0).ok()?;
Some(format!("Bearer {token}"))
}
}
impl Drop for AccessToken {
fn drop(&mut self) {
self.0.fill(0);
}
}
/// Deliberately opaque: a token must not be printable by accident.
impl std::fmt::Debug for AccessToken {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("AccessToken(<redacted>)")
}
}
/// What the credential file says, beyond the token.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct PlanIdentity {
/// `"Max (20x)"`, `"Pro"`, or nothing. A plan tier, not an account
/// identifier — the account e-mail sits in the same file and is not read.
pub label: Option<String>,
}
/// A credential in hand: the token plus what the file said about the plan.
#[derive(Debug)]
struct Credential {
token: AccessToken,
expires_at_ms: Option<u64>,
identity: PlanIdentity,
}
#[derive(Debug, Deserialize)]
struct CredentialFileWire<'a> {
#[serde(borrow, rename = "claudeAiOauth")]
oauth: Option<OauthWire<'a>>,
}
#[derive(Debug, Deserialize)]
struct OauthWire<'a> {
/// Borrowed rather than owned so the token is never copied into a `String`
/// whose buffer this module cannot zero.
#[serde(borrow, rename = "accessToken")]
access_token: Option<&'a str>,
#[serde(default, rename = "expiresAt")]
expires_at: Option<i64>,
#[serde(default, borrow, rename = "subscriptionType")]
subscription_type: Option<&'a str>,
#[serde(default, borrow, rename = "rateLimitTier")]
rate_limit_tier: Option<&'a str>,
}
/// Where the credential lives and whether we are allowed to read it.
#[derive(Clone, Debug)]
pub struct ClaudeOauthOptions {
/// Off means the file is never opened. This is the kill switch for a user
/// who wants the status-line reading and nothing else.
pub enabled: bool,
pub credentials_path: PathBuf,
pub endpoint: String,
pub refresh_interval: Duration,
}
impl Default for ClaudeOauthOptions {
fn default() -> Self {
Self {
enabled: std::env::var_os("LUMBRIDGE_CLAUDE_OAUTH").is_none_or(|value| value != "0"),
credentials_path: default_credentials_path(),
endpoint: USAGE_ENDPOINT.to_owned(),
refresh_interval: MIN_REFRESH_INTERVAL,
}
}
}
impl ClaudeOauthOptions {
/// Options that never open the credential file.
///
/// Tests use this: a test must never read the developer's real credential,
/// and a synthetic fixture is passed by path where one is wanted.
#[must_use]
pub fn disabled() -> Self {
Self {
enabled: false,
..Self::default()
}
}
}
/// The documented default location of Claude Code's stored credential.
#[must_use]
pub fn default_credentials_path() -> PathBuf {
if let Some(configured) = std::env::var_os("CLAUDE_CONFIG_DIR") {
return PathBuf::from(configured).join(".credentials.json");
}
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_default()
.join(".claude")
.join(".credentials.json")
}
/// One per-model weekly limit.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ScopedWindow {
/// The provider's own display name, e.g. `Claude Opus 4.6`.
pub model: String,
/// A stable, account-free profile identifier derived from it.
pub profile_id: String,
pub reading: WindowReading,
}
/// A complete answer from the usage endpoint.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct UsageSnapshot {
pub five_hour: WindowReading,
pub seven_day: WindowReading,
pub scoped: Vec<ScopedWindow>,
}
/// Reads the credential file.
///
/// A missing or unparsable file is [`HarnessError::NotAuthenticated`] — an
/// expected gap, not a fault. So is an expired token: Claude Code refreshes it
/// on its next turn, and Lumbridge does not perform that refresh, because
/// refreshing means writing to another program's credential store.
fn read_credential(path: &Path, now_ms: u64) -> Result<Credential, HarnessError> {
let mut raw = fs::read(path).map_err(|_| HarnessError::NotAuthenticated)?;
let parsed = read_credential_bytes(&raw, now_ms);
// The whole file, not just the token: it also holds the refresh token.
raw.fill(0);
parsed
}
fn read_credential_bytes(raw: &[u8], now_ms: u64) -> Result<Credential, HarnessError> {
let file: CredentialFileWire<'_> =
serde_json::from_slice(raw).map_err(|_| HarnessError::NotAuthenticated)?;
let oauth = file.oauth.ok_or(HarnessError::NotAuthenticated)?;
let token = oauth
.access_token
.filter(|value| !value.is_empty())
.ok_or(HarnessError::NotAuthenticated)?;
let expires_at_ms = oauth.expires_at.and_then(|value| u64::try_from(value).ok());
if expires_at_ms.is_some_and(|expiry| now_ms >= expiry) {
return Err(HarnessError::NotAuthenticated);
}
Ok(Credential {
token: AccessToken(token.as_bytes().to_vec()),
expires_at_ms,
identity: PlanIdentity {
label: plan_label(oauth.subscription_type, oauth.rate_limit_tier),
},
})
}
/// `default_claude_max_20x` reads as `Max (20x)`; otherwise the plain
/// subscription type. Neither identifies the account.
fn plan_label(subscription: Option<&str>, tier: Option<&str>) -> Option<String> {
if let Some(tier) = tier
&& let Some(multiplier) = tier
.rsplit("max_")
.next()
.and_then(|rest| rest.strip_suffix('x'))
&& !multiplier.is_empty()
&& multiplier.bytes().all(|byte| byte.is_ascii_digit())
&& tier.contains("max_")
{
return Some(format!("Max ({multiplier}x)"));
}
let subscription = subscription?.trim();
if subscription.is_empty() {
return None;
}
let mut characters = subscription.chars();
let first = characters.next()?;
Some(first.to_uppercase().collect::<String>() + characters.as_str())
}
/// The plan tier, without making a network call.
///
/// Read at startup so a profile can be labelled with the plan it meters before
/// any request has been made.
#[must_use]
pub fn read_plan_identity(options: &ClaudeOauthOptions, now_ms: u64) -> PlanIdentity {
if !options.enabled {
return PlanIdentity::default();
}
read_credential(&options.credentials_path, now_ms)
.map(|credential| credential.identity)
.unwrap_or_default()
}
/// What one refresh produced.
#[derive(Debug)]
pub enum RefreshOutcome {
/// A reading. The plan identity comes along because the same file supplies
/// both and it can change when the user upgrades.
Snapshot(Box<UsageSnapshot>, PlanIdentity),
/// The endpoint asked us to slow down. The caller backs off; it does not
/// retry, and it does not treat this as a broken probe.
RateLimited,
/// No number, and no fault: not signed in, or the token has expired and
/// Claude Code has not yet refreshed it.
Unauthenticated,
/// Something went wrong that is worth surfacing as a degraded probe.
Failed(HarnessError),
}
/// Fetches one reading.
///
/// The token reaches the request as a header value built at the call site and
/// dropped with it. It is never written to a file, a log, an error, or an
/// argument vector.
#[must_use]
pub fn refresh(options: &ClaudeOauthOptions, now_ms: u64) -> RefreshOutcome {
if !options.enabled {
return RefreshOutcome::Unauthenticated;
}
let Ok(credential) = read_credential(&options.credentials_path, now_ms) else {
return RefreshOutcome::Unauthenticated;
};
let Some(authorization) = credential.token.header_value() else {
return RefreshOutcome::Unauthenticated;
};
let _ = credential.expires_at_ms;
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(REQUEST_TIMEOUT))
.build()
.into();
let response = agent
.get(&options.endpoint)
.header("Authorization", &authorization)
.header("Accept", "application/json")
.header("anthropic-beta", OAUTH_BETA)
.header("User-Agent", USER_AGENT)
.call();
drop(authorization);
let mut response = match response {
Ok(response) => response,
Err(ureq::Error::StatusCode(401 | 403)) => return RefreshOutcome::Unauthenticated,
Err(ureq::Error::StatusCode(429)) => return RefreshOutcome::RateLimited,
Err(_) => return RefreshOutcome::Failed(HarnessError::Malformed),
};
let Ok(body) = response
.body_mut()
.with_config()
.limit(MAX_RESPONSE_BYTES)
.read_to_string()
else {
return RefreshOutcome::Failed(HarnessError::Malformed);
};
match parse_usage(&body, now_ms) {
Some(snapshot) => RefreshOutcome::Snapshot(Box::new(snapshot), credential.identity),
None => RefreshOutcome::Failed(HarnessError::Malformed),
}
}
#[derive(Debug, Deserialize)]
struct UsageResponseWire {
#[serde(default)]
five_hour: Option<EndpointWindowWire>,
#[serde(default)]
seven_day: Option<EndpointWindowWire>,
#[serde(default)]
limits: Option<Vec<Option<ScopedLimitWire>>>,
}
#[derive(Debug, Deserialize)]
struct EndpointWindowWire {
#[serde(default)]
utilization: Option<f64>,
#[serde(default)]
used_percentage: Option<f64>,
#[serde(default)]
resets_at: Option<ResetsAtWire>,
}
#[derive(Debug, Deserialize)]
struct ScopedLimitWire {
#[serde(default)]
kind: Option<String>,
#[serde(default)]
scope: Option<ScopeWire>,
#[serde(default)]
percent: Option<f64>,
#[serde(default)]
utilization: Option<f64>,
#[serde(default)]
resets_at: Option<ResetsAtWire>,
}
#[derive(Debug, Deserialize)]
struct ScopeWire {
#[serde(default)]
model: Option<ScopeModelWire>,
}
#[derive(Debug, Deserialize)]
struct ScopeModelWire {
#[serde(default)]
display_name: Option<String>,
}
/// The endpoint spells its reset as an RFC 3339 string; the status line spells
/// the same instant as epoch seconds. Accept either rather than going dark on
/// whichever one changes.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum ResetsAtWire {
Text(String),
Epoch(i64),
}
impl ResetsAtWire {
fn to_millis(&self) -> Option<u64> {
match self {
Self::Epoch(seconds) => unix_seconds_to_millis(*seconds),
Self::Text(text) => rfc3339_to_millis(text),
}
}
}
/// Parses an RFC 3339 timestamp to epoch milliseconds.
///
/// Hand-rolled rather than pulling in a calendar: the accepted shape is fixed
/// and narrow, and every field is bounds-checked before it is used. Anything
/// that does not match exactly yields no reset, which costs a forecast and
/// never produces a wrong one.
fn rfc3339_to_millis(text: &str) -> Option<u64> {
let bytes = text.as_bytes();
if bytes.len() < 20 || bytes[4] != b'-' || bytes[7] != b'-' {
return None;
}
if !matches!(bytes[10], b'T' | b't' | b' ') || bytes[13] != b':' || bytes[16] != b':' {
return None;
}
let year: i64 = text.get(0..4)?.parse().ok()?;
let month: i64 = text.get(5..7)?.parse().ok()?;
let day: i64 = text.get(8..10)?.parse().ok()?;
let hour: i64 = text.get(11..13)?.parse().ok()?;
let minute: i64 = text.get(14..16)?.parse().ok()?;
let second: i64 = text.get(17..19)?.parse().ok()?;
if !(1..=12).contains(&month)
|| !(1..=31).contains(&day)
|| hour > 23
|| minute > 59
// A leap second is a real value the provider may send.
|| second > 60
{
return None;
}
// Only UTC is accepted. An offset would need to be applied, and the
// endpoint documents its resets in UTC; a misread offset would move a reset
// by hours, which is worse than reporting no reset at all.
let suffix = text.get(19..)?;
let suffix =
suffix.trim_start_matches(|character: char| character == '.' || character.is_ascii_digit());
if !matches!(suffix, "Z" | "z" | "+00:00" | "-00:00" | "+0000" | "") {
return None;
}
let days = days_from_civil(year, month, day)?;
let seconds = days
.checked_mul(86_400)?
.checked_add(hour * 3_600 + minute * 60 + second)?;
unix_seconds_to_millis(seconds)
}
/// Days since 1970-01-01 for a proleptic Gregorian date.
///
/// Howard Hinnant's `days_from_civil`, which is the standard formulation of
/// this conversion and is exact for every year in range.
fn days_from_civil(year: i64, month: i64, day: i64) -> Option<i64> {
let year = if month <= 2 { year - 1 } else { year };
let era = if year >= 0 { year } else { year - 399 } / 400;
let year_of_era = year - era * 400;
let day_of_year = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day - 1;
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
era.checked_mul(146_097)?.checked_add(day_of_era - 719_468)
}
/// A stable profile identifier for a per-model limit.
///
/// Derived from the display name so it survives a restart, and reduced to
/// lowercase ASCII so it cannot smuggle formatting into a UI label.
fn scoped_profile_id(model: &str) -> Option<String> {
let mut id = String::from("claude-code-weekly-");
let mut last_was_dash = true;
for character in model.chars().take(MAX_LABEL_BYTES) {
if character.is_ascii_alphanumeric() {
id.extend(character.to_lowercase());
last_was_dash = false;
} else if !last_was_dash {
id.push('-');
last_was_dash = true;
}
}
let id = id.trim_end_matches('-').to_owned();
(id.len() > "claude-code-weekly-".len() - 1).then_some(id)
}
/// Reads a usage response.
///
/// Total: every input produces a decision and none of them panics.
#[must_use]
pub fn parse_usage(body: &str, observed_at_ms: u64) -> Option<UsageSnapshot> {
let wire: UsageResponseWire = serde_json::from_str(body).ok()?;
let window = |value: Option<&EndpointWindowWire>, kind: ClaudeWindowKind| {
value.map_or(WindowReading::Absent, |wire| {
wire.utilization
.or(wire.used_percentage)
.map_or(WindowReading::Absent, |percent| {
window_reading(
percent,
wire.resets_at.as_ref().and_then(ResetsAtWire::to_millis),
kind.length_ms(),
observed_at_ms,
)
})
})
};
let mut scoped = Vec::new();
let mut seen = Vec::new();
for limit in wire.limits.into_iter().flatten().flatten() {
if scoped.len() >= MAX_SCOPED_WINDOWS {
break;
}
// Only weekly per-model limits. Another `kind` means something this
// adapter has not been taught to read, and guessing at its units is how
// a footer ends up confidently wrong.
if limit.kind.as_deref() != Some("weekly_scoped") {
continue;
}
let Some(model) = limit
.scope
.and_then(|scope| scope.model)
.and_then(|model| model.display_name)
.map(|name| name.trim().to_owned())
.filter(|name| !name.is_empty())
else {
continue;
};
let Some(profile_id) = scoped_profile_id(&model) else {
continue;
};
if seen.contains(&profile_id) {
continue;
}
let Some(percent) = limit.percent.or(limit.utilization) else {
continue;
};
let reading = window_reading(
percent,
limit.resets_at.as_ref().and_then(ResetsAtWire::to_millis),
WEEKLY_MS,
observed_at_ms,
);
seen.push(profile_id.clone());
scoped.push(ScopedWindow {
model,
profile_id,
reading,
});
}
Some(UsageSnapshot {
five_hour: window(wire.five_hour.as_ref(), ClaudeWindowKind::FiveHour),
seven_day: window(wire.seven_day.as_ref(), ClaudeWindowKind::SevenDay),
scoped,
})
}
#[cfg(test)]
mod tests {
use super::{
AccessToken, PlanIdentity, ScopedWindow, parse_usage, plan_label, read_credential_bytes,
rfc3339_to_millis, scoped_profile_id,
};
use crate::HarnessError;
use crate::claude::statusline::WindowReading;
const NOW_MS: u64 = 1_700_000_000_000;
#[test]
fn a_credential_file_yields_a_token_and_a_plan_but_never_the_refresh_token() {
let raw = br#"{"claudeAiOauth":{"accessToken":"sk-test-token","refreshToken":"sk-refresh",
"expiresAt":1800000000000,"subscriptionType":"max",
"rateLimitTier":"default_claude_max_20x"}}"#;
let credential = read_credential_bytes(raw, NOW_MS).expect("a valid credential");
assert_eq!(
credential.identity,
PlanIdentity {
label: Some("Max (20x)".to_owned())
}
);
// The refresh token has no field to land in, so it cannot be carried.
let rendered = format!("{:?}", credential.token);
assert!(!rendered.contains("sk-"), "a token must not be printable");
assert_eq!(rendered, "AccessToken(<redacted>)");
}
#[test]
fn an_expired_token_is_an_expected_gap_rather_than_a_fault() {
let raw = br#"{"claudeAiOauth":{"accessToken":"t","expiresAt":1000}}"#;
let error = read_credential_bytes(raw, NOW_MS).expect_err("expired");
assert_eq!(error, HarnessError::NotAuthenticated);
assert!(
error.is_expected_gap(),
"a signed-out user is not a broken probe"
);
}
#[test]
fn a_missing_or_malformed_credential_never_produces_a_number() {
for raw in [
&b"{}"[..],
b"not json",
br#"{"claudeAiOauth":{}}"#,
br#"{"claudeAiOauth":{"accessToken":""}}"#,
] {
assert_eq!(
read_credential_bytes(raw, NOW_MS).err(),
Some(HarnessError::NotAuthenticated)
);
}
}
#[test]
fn a_dropped_token_leaves_no_bytes_behind() {
let mut token = AccessToken(b"secret".to_vec());
token.0.fill(0);
assert!(token.0.iter().all(|byte| *byte == 0));
}
#[test]
fn plan_labels_read_the_tier_before_the_subscription_type() {
assert_eq!(
plan_label(Some("max"), Some("default_claude_max_20x")).as_deref(),
Some("Max (20x)")
);
assert_eq!(
plan_label(Some("max"), Some("default_claude_max_5x")).as_deref(),
Some("Max (5x)")
);
assert_eq!(plan_label(Some("pro"), None).as_deref(), Some("Pro"));
assert_eq!(plan_label(None, Some("something_else")), None);
assert_eq!(plan_label(None, None), None);
}
#[test]
fn the_documented_response_yields_both_windows_and_the_scoped_ones() {
let body = r#"{
"five_hour": {"utilization": 42, "resets_at": "2023-11-15T00:00:00Z"},
"seven_day": {"utilization": 8.5, "resets_at": "2023-11-20T00:00:00Z"},
"limits": [
{"kind":"weekly_scoped","percent":12,
"scope":{"model":{"display_name":"Claude Opus 4.6"}},
"resets_at":"2023-11-20T00:00:00Z"},
{"kind":"weekly_scoped","percent":3,
"scope":{"model":{"display_name":"Claude Sonnet 4.6"}}},
{"kind":"something_new","percent":99,
"scope":{"model":{"display_name":"Unknown"}}}
]
}"#;
let snapshot = parse_usage(body, NOW_MS).expect("a valid response");
assert!(matches!(
snapshot.five_hour,
WindowReading::Usable { permille: 420, .. }
));
assert!(matches!(
snapshot.seven_day,
WindowReading::Usable { permille: 85, .. }
));
assert_eq!(
snapshot.scoped.len(),
2,
"an unknown kind is not guessed at"
);
let ScopedWindow {
model,
profile_id,
reading,
} = &snapshot.scoped[0];
assert_eq!(model, "Claude Opus 4.6");
assert_eq!(profile_id, "claude-code-weekly-claude-opus-4-6");
assert!(matches!(
reading,
WindowReading::Usable { permille: 120, .. }
));
}
#[test]
fn a_response_with_nothing_in_it_reports_absent_rather_than_zero() {
let snapshot = parse_usage("{}", NOW_MS).expect("an empty object still parses");
assert_eq!(snapshot.five_hour, WindowReading::Absent);
assert_eq!(snapshot.seven_day, WindowReading::Absent);
assert!(snapshot.scoped.is_empty());
assert!(parse_usage("not json", NOW_MS).is_none());
}
#[test]
fn duplicate_and_excess_scoped_limits_are_bounded() {
let entry = |name: &str| {
format!(
r#"{{"kind":"weekly_scoped","percent":1,"scope":{{"model":{{"display_name":"{name}"}}}}}}"#
)
};
let mut entries: Vec<String> = (0..20)
.map(|index| entry(&format!("Model {index}")))
.collect();
entries.push(entry("Model 0"));
let body = format!(r#"{{"limits":[{}]}}"#, entries.join(","));
let snapshot = parse_usage(&body, NOW_MS).expect("valid");
assert_eq!(snapshot.scoped.len(), 6, "the footer cannot grow unbounded");
}
#[test]
fn rfc3339_resets_convert_and_anything_else_yields_no_reset() {
assert_eq!(rfc3339_to_millis("1970-01-01T00:00:00Z"), Some(0));
assert_eq!(
rfc3339_to_millis("2023-11-15T00:00:00Z"),
Some(1_700_006_400_000)
);
assert_eq!(
rfc3339_to_millis("2023-11-15T00:00:00.123456Z"),
Some(1_700_006_400_000),
"a fractional second does not move the whole second"
);
for bad in [
"",
"yesterday",
"2023-11-15",
"2023-13-15T00:00:00Z",
"2023-11-15T25:00:00Z",
// An offset is refused rather than silently read as UTC.
"2023-11-15T00:00:00+05:00",
] {
assert_eq!(rfc3339_to_millis(bad), None, "{bad:?} must not parse");
}
}
#[test]
fn scoped_identifiers_are_account_free_and_stable() {
assert_eq!(
scoped_profile_id("Claude Opus 4.6").as_deref(),
Some("claude-code-weekly-claude-opus-4-6")
);
assert_eq!(
scoped_profile_id(" Opus / Weekly ").as_deref(),
Some("claude-code-weekly-opus-weekly")
);
assert_eq!(scoped_profile_id("///"), None);
for name in ["Claude Opus 4.6", "user@example.com"] {
let id = scoped_profile_id(name).expect("an identifier");
assert!(!id.contains('@'), "an identifier must not carry an account");
}
}
}
@@ -93,7 +93,7 @@ impl ClaudeWindowKind {
}
}
const fn length_ms(self) -> u64 {
pub(crate) const fn length_ms(self) -> u64 {
match self {
Self::FiveHour => FIVE_HOUR_MS,
Self::SevenDay => SEVEN_DAY_MS,
@@ -101,8 +101,15 @@ impl ClaudeWindowKind {
}
}
/// What one window in the feed means at a point in time.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
/// The documented length of a per-model weekly limit, which the usage endpoint
/// reports without one.
pub(crate) const WEEKLY_MS: u64 = SEVEN_DAY_MS;
/// What one window means at a point in time.
///
/// Absent is the default because a window nobody has reported is missing, not
/// empty — the distinction the whole usage model rests on.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum WindowReading {
/// A usable share of the window.
Usable {
@@ -110,6 +117,7 @@ pub enum WindowReading {
window: Option<UsageWindow>,
},
/// The window is absent, or the account has no plan limits at all.
#[default]
Absent,
}
@@ -124,6 +132,25 @@ pub(crate) fn parse_window(
let Some(percent) = wire.used_percentage.or(wire.utilization) else {
return WindowReading::Absent;
};
let resets_at_ms = wire.resets_at.and_then(unix_seconds_to_millis);
window_reading(percent, resets_at_ms, kind.length_ms(), observed_at_ms)
}
/// Builds a reading from a percentage and an optional reset instant.
///
/// Shared with the usage endpoint, which reports the same two windows plus
/// per-model weekly ones. The window arithmetic and the clamping rules must not
/// differ by surface: the same quota read two ways has to produce the same
/// number, or the footer's provenance chip is describing a difference the user
/// cannot see.
///
/// Total: every input produces a decision and none of them panics.
pub(crate) fn window_reading(
percent: f64,
resets_at_ms: Option<u64>,
length_ms: u64,
observed_at_ms: u64,
) -> WindowReading {
if !percent.is_finite() || percent < 0.0 {
return WindowReading::Absent;
}
@@ -140,15 +167,13 @@ pub(crate) fn parse_window(
)]
let permille = (percent.clamp(0.0, MAX_PERCENT) * PERMILLE_PER_PERCENT).round() as u64;
let window = wire
.resets_at
.and_then(unix_seconds_to_millis)
let window = resets_at_ms
// The CLI documents that a window is present only while its resets_at
// has not passed. One that has is stale, so its start-and-reset pair is
// dropped and only the percentage survives.
.filter(|resets_at_ms| *resets_at_ms > observed_at_ms)
.and_then(|resets_at_ms| {
let started_at_ms = resets_at_ms.saturating_sub(kind.length_ms());
let started_at_ms = resets_at_ms.saturating_sub(length_ms);
if started_at_ms <= observed_at_ms {
UsageWindow::new(started_at_ms, resets_at_ms).ok()
} else {
@@ -161,7 +186,7 @@ pub(crate) fn parse_window(
WindowReading::Usable { permille, window }
}
fn unix_seconds_to_millis(seconds: i64) -> Option<u64> {
pub(crate) fn unix_seconds_to_millis(seconds: i64) -> Option<u64> {
u64::try_from(seconds.checked_mul(MILLIS_PER_SECOND)?).ok()
}