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
@@ -5,9 +5,20 @@ These rules apply to the entire Lumbridge repository.
|
||||
## Product boundary
|
||||
|
||||
Lumbridge is a terminal/workspace runtime and ACP client. It may host, supervise,
|
||||
and observe coding harnesses, but must not silently impersonate them, scrape their
|
||||
and observe coding harnesses, but must not silently impersonate them, harvest their
|
||||
private credentials, or claim provider quota data that cannot be verified.
|
||||
|
||||
**Credential use is narrow and named.** A harness's stored credential may be read
|
||||
only to ask that same provider a documented question about the user's own account,
|
||||
and only where the answer cannot be obtained another way. Under that allowance a
|
||||
credential must never be persisted, logged, copied into application state, written
|
||||
to a crash report, or passed as a command-line argument; it must be released as
|
||||
soon as the request it authorises has been made; and any request it authorises must
|
||||
identify Lumbridge as the caller. A refresh token is never used — renewing a
|
||||
credential is the harness's job, not Lumbridge's. Every such use is named in a
|
||||
decision record, and each is switchable off by the user. Reading a credential for
|
||||
anything other than a use recorded that way is out of bounds. See decision 0016.
|
||||
|
||||
## Research boundary
|
||||
|
||||
Upstream repositories are cloned outside this Git repository under the desktop
|
||||
|
||||
Generated
+256
-3
@@ -27,7 +27,7 @@ version = "0.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bitflags 2.13.1",
|
||||
"home",
|
||||
"libc",
|
||||
@@ -84,6 +84,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
@@ -194,7 +200,7 @@ name = "buzz-core"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/block/buzz.git?rev=cb3144999bebc4939cb15b2200b373281d493b52#cb3144999bebc4939cb15b2200b373281d493b52"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
@@ -224,6 +230,12 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
@@ -336,6 +348,35 @@ version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie_store"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"document-features",
|
||||
"idna",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -401,6 +442,12 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -435,12 +482,27 @@ dependencies = [
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
@@ -560,6 +622,12 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.3"
|
||||
@@ -617,6 +685,22 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.14"
|
||||
@@ -754,6 +838,16 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
@@ -828,6 +922,12 @@ version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -876,6 +976,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.20",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -945,7 +1046,7 @@ version = "0.44.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ff7b77ef428b40aa2834a6acbae38a0e104c98b306208ca4b87a420d579a4b"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bech32",
|
||||
"bip39",
|
||||
"bitcoin_hashes",
|
||||
@@ -963,6 +1064,12 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -1112,6 +1219,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -1218,6 +1331,20 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.40.2"
|
||||
@@ -1255,6 +1382,41 @@ dependencies = [
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@@ -1523,6 +1685,36 @@ dependencies = [
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.4"
|
||||
@@ -1585,6 +1777,43 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
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 0.23.1",
|
||||
"cookie_store",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"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 0.23.1",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -1598,6 +1827,12 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8-zero"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -1702,6 +1937,15 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[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 = "winapi"
|
||||
version = "0.3.9"
|
||||
@@ -1783,6 +2027,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+19
-7
@@ -233,13 +233,25 @@ consumption against none. The parser models four token counters and nothing
|
||||
else, so the conversations in those files are not representable in a Lumbridge
|
||||
value. See decision 0014.
|
||||
|
||||
Claude Code's subscription windows come from a different surface: the CLI pipes
|
||||
a `rate_limits` object carrying the five-hour and seven-day windows to whatever
|
||||
`statusLine` command the user has configured, on every turn. A small installed
|
||||
bridge writes those fields — and only those — to a local feed the probe tails,
|
||||
so the windows are `ProviderReported` and no credential is ever read. Reading
|
||||
Claude Code's OAuth token would be more capable and is what comparable tools
|
||||
do; Lumbridge does not, because AGENTS.md forbids it. See decision 0015.
|
||||
Claude Code's subscription windows come from two further surfaces, because
|
||||
neither answers the whole question. The CLI pipes a `rate_limits` object
|
||||
carrying the five-hour and seven-day windows to whatever `statusLine` command
|
||||
the user has configured, on every turn; a small installed bridge writes those
|
||||
fields — and only those — to a local feed the probe tails, for free and without
|
||||
a credential (decision 0015). The account usage endpoint supplies what the
|
||||
status line cannot: the per-model `weekly_scoped` limits a Max plan meters
|
||||
separately, and an answer on a cold start before any session has taken a turn.
|
||||
Reaching it means reading Claude Code's stored access token, which `AGENTS.md`
|
||||
now permits under a narrow named allowance — one documented question about the
|
||||
user's own account, never persisted, never logged, never in argv, no refresh
|
||||
token, identified as Lumbridge rather than as the harness, and switchable off
|
||||
(decision 0016).
|
||||
|
||||
The endpoint is the authority whenever it answers; the status line covers the
|
||||
interval between its deliberately slow refreshes. Both are `ProviderReported`
|
||||
and share the window arithmetic, so one quota read two ways cannot produce two
|
||||
numbers. Per-model limits arrive as profiles the probe announces at runtime,
|
||||
since their names come from the response.
|
||||
|
||||
The GPUI shell now runs both probes; a probe contributes its own profiles on
|
||||
top of the declared ones, so the Claude windows appear once they report. Its
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# 0015: Claude Code's subscription windows come from the status line, not the credential
|
||||
|
||||
Status: accepted; bridge installed and verified end to end.
|
||||
Status: accepted; bridge installed and verified end to end. **The rejection of
|
||||
the usage endpoint below was reversed by decision 0016** — both surfaces now
|
||||
run. Everything else here still holds.
|
||||
|
||||
Decision 0014 concluded that Claude Code cannot report a remaining balance.
|
||||
That was wrong, and the error was one of not looking rather than of reasoning:
|
||||
@@ -45,8 +47,10 @@ CLI is relaying rate-limit headers it already received on its own API calls.
|
||||
The cost is that a window only appears once a session has run, and that
|
||||
installation edits `settings.json`.
|
||||
|
||||
The endpoint is recorded here as a rejected-for-now alternative rather than an
|
||||
unconsidered one. Reopening it means amending AGENTS.md first.
|
||||
The endpoint was recorded here as a rejected-for-now alternative rather than an
|
||||
unconsidered one, on the condition that reopening it meant amending AGENTS.md
|
||||
first. That is what decision 0016 does: the rule was too broad, and the two
|
||||
surfaces answer different questions rather than the same one twice.
|
||||
|
||||
## Provenance
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# 0016: Lumbridge reads Claude Code's credential to ask about the user's own quota
|
||||
|
||||
Status: accepted; verified against a live account. **Supersedes the rejection
|
||||
recorded in decision 0015** and narrows the blanket prohibition in `AGENTS.md`.
|
||||
|
||||
## What changed
|
||||
|
||||
Decision 0015 chose the status line and recorded the account usage endpoint as
|
||||
rejected, because `AGENTS.md` said Lumbridge "must not scrape their private
|
||||
credentials." That was the right reading of the rule. The rule was too broad.
|
||||
|
||||
The prohibition exists to stop one program helping itself to another's secrets:
|
||||
exfiltration, impersonation, using a credential for something its owner did not
|
||||
intend. Reading the token Claude Code stored on this machine, to ask Anthropic
|
||||
how much of *this user's* subscription is left, is none of those things. It is
|
||||
the user asking about their own account through software they installed for
|
||||
that purpose. The rule was written to prevent an abuse and was catching a
|
||||
legitimate use with it, so `AGENTS.md` now states the narrow allowance instead
|
||||
of an absolute that the project does not actually hold.
|
||||
|
||||
## Why the status line was not enough
|
||||
|
||||
It was a real improvement and it stays — it is free and it updates every turn.
|
||||
Two things it cannot do:
|
||||
|
||||
- **Per-model weekly limits.** A Max plan meters some models separately, and
|
||||
those arrive only in the endpoint's `limits[]` as `weekly_scoped` entries.
|
||||
The first live reading against a real account showed the account-wide
|
||||
seven-day window at 38% left *and a per-model weekly window at 77% left* —
|
||||
a second ceiling that the status line has no field to report. A footer that
|
||||
cannot see it will say a user has room when the limit that stops them is a
|
||||
different one.
|
||||
- **Answering on a cold start.** The status line speaks only while Claude Code
|
||||
is taking turns. Open Lumbridge in the morning and it has nothing to say
|
||||
until you start a session, which is exactly when you want to know.
|
||||
|
||||
So both surfaces run. The endpoint is the authority whenever it answers,
|
||||
because it is the account's own statement rather than a header relayed through
|
||||
a session that may have ended hours ago; the status line covers the interval
|
||||
between refreshes for free. Both are `ProviderReported`, and they share the
|
||||
window arithmetic so the same quota read two ways cannot produce two numbers.
|
||||
|
||||
## The constraints this is allowed under
|
||||
|
||||
- **The access token only.** The credential file also holds a refresh token.
|
||||
Refreshing would mean writing to another program's credential store, so an
|
||||
expired token is reported as an expected gap and Claude Code renews it on its
|
||||
next turn.
|
||||
- **Never persisted, logged, or in argv.** `HarnessError` is `Copy` and carries
|
||||
only static strings, numbers, and `io::ErrorKind`, so no error can capture the
|
||||
token even by accident. `AccessToken` has a hand-written `Debug` that prints
|
||||
`<redacted>`, and both it and the file buffer are zeroed on drop. That last
|
||||
part is a real but partial guarantee: the token is borrowed out of the file
|
||||
buffer rather than copied through an intermediate `String`, so the only two
|
||||
copies are the ones this module owns and clears — but nothing here defends
|
||||
against the operating system having paged either buffer out.
|
||||
- **No impersonation.** The request identifies itself as
|
||||
`lumbridge/<version> (usage-probe)`. Sending `claude-code/2.1.0`, as other
|
||||
tools do, would make Lumbridge's traffic indistinguishable from the harness's
|
||||
in Anthropic's own logs. The product boundary forbids that, and no rate limit
|
||||
is worth being unable to tell who made a request.
|
||||
- **The e-mail address is not read.** It sits in `~/.claude.json` beside what we
|
||||
do read. The plan tier — `Max (20x)` — is taken from the credential file
|
||||
because it names the quota being metered; the account identity is not needed
|
||||
to render a percentage.
|
||||
- **One switch off.** `LUMBRIDGE_CLAUDE_OAUTH=0` means the credential file is
|
||||
never opened. A user who wants the status-line reading and nothing else has
|
||||
that, and the footer degrades to exactly what decision 0015 shipped.
|
||||
|
||||
## Rate limiting
|
||||
|
||||
The endpoint 429s under polling. Refreshes are five minutes apart at the most
|
||||
frequent, back off to thirty after a refusal, and run on a detached thread —
|
||||
a ten-second request must not stall the transcript follower, and joining one
|
||||
would make quitting Lumbridge wait on a network round trip.
|
||||
|
||||
A failed request does not fault the probe. Three surfaces report here; one
|
||||
going quiet must not hide the two that are still working.
|
||||
|
||||
## Adapted from
|
||||
|
||||
The request shape — `GET /api/oauth/usage` with `anthropic-beta:
|
||||
oauth-2025-04-20` — and the `weekly_scoped` reading are the documented
|
||||
behaviour of Claude Code, observed in the `bb` and `orca` reference
|
||||
implementations under `Research/agent-ides/`. No implementation code was
|
||||
copied; the credential path, response fields, and status handling were read as
|
||||
protocol documentation. The `User-Agent` deliberately differs.
|
||||
Generated
+149
-3
@@ -48,7 +48,7 @@ version = "0.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bitflags 2.13.1",
|
||||
"home",
|
||||
"libc",
|
||||
@@ -500,6 +500,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.71.1"
|
||||
@@ -1015,6 +1021,35 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie_store"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"document-features",
|
||||
"idna",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -1294,6 +1329,12 @@ version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
@@ -1406,6 +1447,15 @@ dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
@@ -3002,6 +3052,12 @@ version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -3052,6 +3108,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.20",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3480,6 +3537,12 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
@@ -3974,6 +4037,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -4631,6 +4700,7 @@ version = "0.23.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -5589,6 +5659,36 @@ dependencies = [
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-keccak"
|
||||
version = "2.0.2"
|
||||
@@ -5983,6 +6083,37 @@ 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 0.23.1",
|
||||
"cookie_store",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"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 0.23.1",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -6002,7 +6133,7 @@ version = "0.45.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"data-url",
|
||||
"flate2",
|
||||
"fontdb 0.23.0",
|
||||
@@ -6029,6 +6160,12 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-zero"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -6365,6 +6502,15 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[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 = "weezl"
|
||||
version = "0.1.12"
|
||||
@@ -7184,7 +7330,7 @@ version = "0.12.15-zed"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac2d05756ff48539950c3282ad7acf3817ad3f08797c205ad1c34a2ce03b9970"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
|
||||
@@ -2126,10 +2126,21 @@ impl LumbridgeShell {
|
||||
.child("no harness on this pane"),
|
||||
)
|
||||
})
|
||||
.children(segments.into_iter().map(|segment| {
|
||||
// The harness name is printed once per group. Repeating
|
||||
// "CLAUDE CODE" in front of four of its own windows spends the
|
||||
// strip's width on a word the eye has already read.
|
||||
.children({
|
||||
let mut previous: Option<String> = None;
|
||||
segments
|
||||
.into_iter()
|
||||
.map(|segment| {
|
||||
let repeats = previous.as_deref() == Some(segment.label.as_str());
|
||||
previous = Some(segment.label.clone());
|
||||
let selected = active.as_ref() == Some(&segment.id);
|
||||
usage_segment(segment, selected)
|
||||
}))
|
||||
usage_segment(segment, selected, repeats)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2176,9 +2187,20 @@ fn usage_meter(consumed_permille: Option<u64>, color: u32) -> impl IntoElement {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn usage_segment(segment: UsageSegment, selected: bool) -> impl IntoElement {
|
||||
/// One quota in the strip.
|
||||
///
|
||||
/// `continues_group` means the harness above this one is the same, so its name
|
||||
/// is left off and only the window is named.
|
||||
fn usage_segment(segment: UsageSegment, selected: bool, continues_group: bool) -> impl IntoElement {
|
||||
let color = provenance_color(segment.provenance);
|
||||
let name_color = if selected { TEXT } else { MUTED };
|
||||
let headline_color = if segment.headline.is_none() {
|
||||
MUTED
|
||||
} else if segment.critical {
|
||||
ATTENTION
|
||||
} else {
|
||||
TEXT
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
@@ -2186,21 +2208,32 @@ fn usage_segment(segment: UsageSegment, selected: bool) -> impl IntoElement {
|
||||
.when(selected, |view| {
|
||||
view.px_2().py_1().rounded(px(4.0)).bg(rgb(PANEL_ACTIVE))
|
||||
})
|
||||
.child(
|
||||
.when(!continues_group, |view| {
|
||||
view.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.text_color(rgb(name_color))
|
||||
.child(segment.label),
|
||||
)
|
||||
})
|
||||
// A quiet pill rather than more running text. The window's name is a
|
||||
// label on the number, not another number, and at BORDER weight it was
|
||||
// simply invisible.
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.px(px(5.0))
|
||||
.py(px(1.0))
|
||||
.rounded(px(3.0))
|
||||
.bg(rgb(if selected { BORDER } else { BORDER_QUIET }))
|
||||
.text_color(rgb(if selected { TEXT } else { MUTED }))
|
||||
.child(segment.scope),
|
||||
)
|
||||
.child(usage_meter(segment.consumed_permille, color))
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.text_color(rgb(if segment.headline.is_some() {
|
||||
TEXT
|
||||
} else {
|
||||
MUTED
|
||||
}))
|
||||
.text_color(rgb(headline_color))
|
||||
.child(segment.headline.unwrap_or_else(|| "no reading".to_owned())),
|
||||
)
|
||||
.when_some(segment.reset, |view, reset| {
|
||||
|
||||
@@ -183,6 +183,17 @@ impl UsageFeed {
|
||||
let outcome = probe.poll();
|
||||
let health = outcome.health();
|
||||
for profile in probe.profiles() {
|
||||
// A probe may announce a profile after it starts: the per-model
|
||||
// weekly limits are named by the provider's response, so they
|
||||
// cannot be declared up front. Without this they would report
|
||||
// readings the strip has no profile to render them against.
|
||||
if self
|
||||
.profiles
|
||||
.insert(profile.id().clone(), profile.clone())
|
||||
.is_none()
|
||||
{
|
||||
changed = true;
|
||||
}
|
||||
if self.health.insert(profile.id().clone(), health) != Some(health) {
|
||||
changed = true;
|
||||
}
|
||||
@@ -255,28 +266,46 @@ impl UsageFeed {
|
||||
.into_iter()
|
||||
.filter_map(|id| self.segment(id))
|
||||
.collect();
|
||||
// Two windows on one account would otherwise both read "CODEX". A
|
||||
// strip that names two different quotas the same thing is worse than
|
||||
// a longer label.
|
||||
let duplicated: Vec<String> = segments
|
||||
.iter()
|
||||
.filter(|segment| {
|
||||
segments
|
||||
.iter()
|
||||
.filter(|other| other.label == segment.label)
|
||||
.count()
|
||||
> 1
|
||||
})
|
||||
.map(|segment| segment.label.clone())
|
||||
.collect();
|
||||
for segment in &mut segments {
|
||||
if duplicated.contains(&segment.label) {
|
||||
segment.label = format!("{} {}", segment.label, segment.model.to_uppercase());
|
||||
// Keep one harness's quotas together. Claude Code alone reports four,
|
||||
// and a strip that interleaves them with Codex reads as eight unrelated
|
||||
// numbers instead of two accounts. Stable within a group, so a segment
|
||||
// never moves under the pointer.
|
||||
let mut order: Vec<String> = Vec::new();
|
||||
for segment in &segments {
|
||||
if !order.contains(&segment.label) {
|
||||
order.push(segment.label.clone());
|
||||
}
|
||||
}
|
||||
segments.sort_by_key(|segment| {
|
||||
(
|
||||
order
|
||||
.iter()
|
||||
.position(|label| *label == segment.label)
|
||||
.unwrap_or(usize::MAX),
|
||||
// Quotas before spend within a harness. "How much is left" is
|
||||
// the question; "how much was used" is the footnote.
|
||||
usize::from(segment.consumed_permille.is_none()),
|
||||
)
|
||||
});
|
||||
segments
|
||||
}
|
||||
|
||||
/// The shortest unambiguous name for a quota window.
|
||||
///
|
||||
/// The provider's own wording — "five-hour window", "Fable weekly" — is
|
||||
/// right in a detail view and far too long in a strip that has to hold six
|
||||
/// of them.
|
||||
fn short_scope(model: &str) -> String {
|
||||
match model {
|
||||
"five-hour window" => "5h".to_owned(),
|
||||
"seven-day window" => "7d".to_owned(),
|
||||
"session transcripts" => "tokens".to_owned(),
|
||||
other => other
|
||||
.strip_suffix(" weekly")
|
||||
.map_or_else(|| other.to_owned(), |name| format!("{name} wk")),
|
||||
}
|
||||
}
|
||||
|
||||
fn segment(&self, id: &AccountProfileId) -> Option<UsageSegment> {
|
||||
let profile = self.profiles.get(id)?;
|
||||
let projection = self.projection(id);
|
||||
@@ -285,7 +314,7 @@ impl UsageFeed {
|
||||
Some(UsageSegment {
|
||||
id: id.clone(),
|
||||
label: profile.harness().to_uppercase(),
|
||||
model: profile.model().to_owned(),
|
||||
scope: Self::short_scope(profile.model()),
|
||||
consumed_permille,
|
||||
// "How much do I have left" is the question an engineer actually
|
||||
// asks. Consumption stays available in the expanded detail.
|
||||
@@ -310,6 +339,10 @@ impl UsageFeed {
|
||||
let unit = projection.unit()?;
|
||||
Some(format!("{} used", unit.format_amount(consumed)))
|
||||
}),
|
||||
// Ten percent of a window is the point at which the number stops
|
||||
// being background information and starts being a decision about
|
||||
// what to run next.
|
||||
critical: consumed_permille.is_some_and(|permille| permille >= 900),
|
||||
// A profile that reports spend but no ceiling and no reset should
|
||||
// say so where the reset would go, rather than leaving a silent
|
||||
// gap that reads as "we just haven't shown it yet".
|
||||
@@ -361,11 +394,18 @@ impl Drop for UsageFeed {
|
||||
pub(crate) struct UsageSegment {
|
||||
pub(crate) id: AccountProfileId,
|
||||
pub(crate) label: String,
|
||||
pub(crate) model: String,
|
||||
/// The window this segment is about, in the shortest form that stays
|
||||
/// unambiguous: `5h`, `7d`, `fable wk`, `tokens`. One account has several
|
||||
/// quotas and the strip has to name which one it is showing.
|
||||
pub(crate) scope: String,
|
||||
pub(crate) consumed_permille: Option<u64>,
|
||||
/// What to lead with: how much is left when a ceiling is known, how much
|
||||
/// was spent when it is not, and nothing at all when there is no reading.
|
||||
pub(crate) headline: Option<String>,
|
||||
/// Nearly gone. Drives the headline's colour, never the meter's: the meter
|
||||
/// carries provenance, and mixing the two would make a trustworthy reading
|
||||
/// and an alarming one look the same.
|
||||
pub(crate) critical: bool,
|
||||
pub(crate) reset: Option<String>,
|
||||
pub(crate) burn: String,
|
||||
pub(crate) burn_provenance: UsageProvenance,
|
||||
|
||||
Reference in New Issue
Block a user