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:
co-authored by
Claude Opus 5
parent
ef52aa7ce2
commit
219c674aea
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user