From 79792eea85bb057c5ab89362a2ade732f397c39c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 01:35:50 +0100 Subject: [PATCH 01/11] usage-meter-poll: the usage poll, the QuotaCell's second sensor usage_poll asks the subscription's usage endpoint when the seven_day reading is 15 min old or older, maps five_hour and seven_day onto PlanQuota with status polled, and honours Retry-After; failures wait, log once per change of state, and never touch the cell. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 378 +++++++++++ crates/claude-connector/Cargo.toml | 2 + crates/claude-connector/src/lib.rs | 1 + crates/claude-connector/src/quota.rs | 10 +- crates/claude-connector/src/usage_poll.rs | 729 ++++++++++++++++++++++ 5 files changed, 1116 insertions(+), 4 deletions(-) create mode 100644 crates/claude-connector/src/usage_poll.rs diff --git a/Cargo.lock b/Cargo.lock index efa3d84..a1da85a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,6 +139,7 @@ name = "claude-connector" version = "0.1.0" dependencies = [ "anyhow", + "chrono", "cultcache-rs", "hmac", "libc", @@ -149,6 +150,7 @@ dependencies = [ "serde_json", "sha2", "thiserror", + "ureq", "zeroize", ] @@ -296,6 +298,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "dyn-clone" version = "1.0.20" @@ -338,6 +351,15 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fs2" version = "0.4.3" @@ -459,6 +481,110 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "inout" version = "0.1.4" @@ -491,6 +617,12 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "log" version = "0.4.34" @@ -554,6 +686,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -582,6 +720,15 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -698,6 +845,20 @@ dependencies = [ "syn 3.0.4", ] +[[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 = "rmp" version = "0.8.15" @@ -737,6 +898,41 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +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.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -881,6 +1077,12 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + [[package]] name = "socket2" version = "0.5.10" @@ -901,6 +1103,12 @@ dependencies = [ "der", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "stacker" version = "0.1.25" @@ -942,6 +1150,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "thiserror" version = "2.0.20" @@ -962,6 +1181,16 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "typenum" version = "1.20.1" @@ -984,6 +1213,54 @@ 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 = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + [[package]] name = "uuid" version = "1.25.0" @@ -1061,6 +1338,24 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.11", +] + +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1239,6 +1534,35 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.63" @@ -1259,12 +1583,66 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", + "synstructure", +] + [[package]] name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/crates/claude-connector/Cargo.toml b/crates/claude-connector/Cargo.toml index 7b310e5..bc28e9f 100644 --- a/crates/claude-connector/Cargo.toml +++ b/crates/claude-connector/Cargo.toml @@ -13,6 +13,7 @@ path = "src/main.rs" [dependencies] anyhow = "1" +chrono = { version = "0.4", default-features = false } hmac = "0.12" cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a4eae9056152335662f1115f97bf35fb00ab6d23", package = "cultcache-rs" } model-connector = { path = "../model-connector", features = ["daemon"] } @@ -21,6 +22,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" thiserror = "2" +ureq = { version = "2", default-features = false, features = ["tls"] } zeroize = "1" [target.'cfg(unix)'.dependencies] diff --git a/crates/claude-connector/src/lib.rs b/crates/claude-connector/src/lib.rs index 70a2e53..1b01d30 100644 --- a/crates/claude-connector/src/lib.rs +++ b/crates/claude-connector/src/lib.rs @@ -15,6 +15,7 @@ mod quota; mod stream; #[cfg(target_os = "linux")] mod unit; +mod usage_poll; pub use backend::{ClaudeCodeBackend, CredentialTokenSource, StderrSink, TokenSource}; pub use config::{ClaudeCodeConfig, ClaudeCodeLimits}; diff --git a/crates/claude-connector/src/quota.rs b/crates/claude-connector/src/quota.rs index 450d3e2..1b3d27e 100644 --- a/crates/claude-connector/src/quota.rs +++ b/crates/claude-connector/src/quota.rs @@ -1,7 +1,9 @@ -//! The plan's own usage windows, as the CLI reports them. The subscription -//! decides how much a plan may burn; the CLI's `rate_limit_event` is the only -//! place that number appears, so the latest observation of each window is kept -//! here and published as the usage readout and lent with each lease. +//! The plan's own usage windows, as the subscription reports them. The +//! subscription decides how much a plan may burn, and two sensors observe it: +//! the CLI's `rate_limit_event` on the connector's own calls, and the usage +//! poll of `usage_poll` while that stream is quiet. The latest observation of +//! each window, from either, is kept here and published as the usage readout +//! and lent with each lease. use std::collections::BTreeMap; use std::sync::Mutex; diff --git a/crates/claude-connector/src/usage_poll.rs b/crates/claude-connector/src/usage_poll.rs new file mode 100644 index 0000000..469e234 --- /dev/null +++ b/crates/claude-connector/src/usage_poll.rs @@ -0,0 +1,729 @@ +//! The second sensor of the plan's usage windows. The CLI's `rate_limit_event` +//! only arrives when the connector's own calls run, so while the swarm is +//! quiet the weekly reading goes stale; this thread asks the subscription's +//! usage endpoint when the `seven_day` reading is `STALE` or older, and +//! records the answer in the same [`QuotaCell`] the stream feeds. Neither +//! sensor owns the reading: the newest observation of a window wins. A failed +//! poll never touches the cell; only the cell's own reset rule retires a +//! window. +//! +//! The endpoint is undocumented (the shape is the one Claude Code 2.1.312 +//! parses), so every answer it can give is handled and none is trusted to +//! change the schedule beyond the rules below. + +use std::io::Read; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use model_connector::PlanQuota; +use serde_json::Value; +use zeroize::Zeroizing; + +use crate::backend::TokenSource; +use crate::quota::QuotaCell; + +/// The usage endpoint. +const ENDPOINT: &str = "https://api.anthropic.com/api/oauth/usage"; +/// A reading this old, or older, is stale; also the least gap between polls. +const STALE: Duration = Duration::from_secs(15 * 60); +/// The longest any backoff may wait. +const CEILING: Duration = Duration::from_secs(6 * 60 * 60); +const TIMEOUT: Duration = Duration::from_secs(10); +/// A usage body is a few hundred bytes; this is a ceiling, not an expectation. +const MAX_BODY: u64 = 1 << 20; +/// The status every polled window carries. +const POLLED: &str = "polled"; + +/// What the endpoint said, reduced to what the schedule needs. It never holds +/// the token. +#[derive(Debug, PartialEq, Eq)] +enum Answer { + Body(String), + Status(u16, Option), + Unreachable, +} + +/// Why the poller is waiting; logged once per change. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum State { + Polled, + NoToken, + RateLimited, + Refused, + NotFound, + Unreadable, + Unexpected, + Unreachable, +} + +impl State { + fn line(self) -> &'static str { + match self { + Self::Polled => "usage poll: reading recorded", + Self::NoToken => "usage poll: no token to ask with", + Self::RateLimited => "usage poll: rate limited, waiting", + Self::Refused => { + "usage poll: refused (401/403), the token likely lacks the user:profile scope; polling stopped until restart" + } + Self::NotFound => "usage poll: endpoint not found, backing off", + Self::Unreadable => "usage poll: answer not readable, backing off", + Self::Unexpected => "usage poll: unexpected status, retrying later", + Self::Unreachable => "usage poll: endpoint unreachable, retrying later", + } + } +} + +#[derive(Debug, PartialEq, Eq)] +enum Next { + Wait(Duration), + Stop, +} + +#[derive(Debug, PartialEq)] +struct Verdict { + windows: Vec, + next: Next, + state: State, +} + +/// What one answer means: the windows to record (only a readable body has +/// any), how long to wait, and the state to report. +fn judge(answer: &Answer) -> Verdict { + let (windows, next, state) = match answer { + Answer::Body(body) => match parse_usage(body) { + Some(windows) => (windows, Next::Wait(STALE), State::Polled), + None => (Vec::new(), Next::Wait(CEILING), State::Unreadable), + }, + Answer::Status(429, retry_after) => ( + Vec::new(), + Next::Wait(retry_after.unwrap_or_default().clamp(STALE, CEILING)), + State::RateLimited, + ), + Answer::Status(401 | 403, _) => (Vec::new(), Next::Stop, State::Refused), + Answer::Status(404, _) => (Vec::new(), Next::Wait(CEILING), State::NotFound), + Answer::Status(..) => (Vec::new(), Next::Wait(STALE), State::Unexpected), + Answer::Unreachable => (Vec::new(), Next::Wait(STALE), State::Unreachable), + }; + Verdict { + windows, + next, + state, + } +} + +/// The plan windows a usage body reports: `five_hour` and `seven_day`, each +/// `{utilization: percent 0-100, resets_at: ISO 8601}`. A window missing +/// either field, or reporting nonsense, is skipped, and an unknown reading is +/// never zero. Other keys are ignored. `None` when the body is not a JSON +/// object at all. +fn parse_usage(body: &str) -> Option> { + let usage: Value = serde_json::from_str(body).ok()?; + let usage = usage.as_object()?; + Some( + ["five_hour", "seven_day"] + .into_iter() + .filter_map(|window| { + let reading = usage.get(window)?; + let percent = reading.get("utilization")?.as_f64()?; + let resets_at = reading.get("resets_at")?.as_str()?; + if !percent.is_finite() || percent < 0.0 { + return None; + } + let resets_at = chrono::DateTime::parse_from_rfc3339(resets_at).ok()?; + Some(PlanQuota { + window: window.to_string(), + // `as` saturates a float at the integer's bounds. + utilization_permille: Some((percent * 10.0).round() as u32), + resets_at_unix_ms: u64::try_from(resets_at.timestamp_millis()).ok()?, + status: POLLED.to_string(), + // Stamped by the cell that keeps it. + observed_at_unix_ms: 0, + }) + }) + .collect(), + ) +} + +/// The blocking HTTPS client: 10 s end to end, no redirects (the token must +/// not follow one anywhere). +struct UsageClient { + agent: ureq::Agent, + url: String, +} + +impl UsageClient { + fn new(url: impl Into) -> Self { + Self { + agent: ureq::AgentBuilder::new() + .timeout(TIMEOUT) + .redirects(0) + .build(), + url: url.into(), + } + } + + fn ask(&self, token: &Zeroizing) -> Answer { + let bearer = Zeroizing::new(format!("Bearer {}", token.as_str())); + let sent = self + .agent + .get(&self.url) + .set("Authorization", bearer.as_str()) + .set("anthropic-beta", "oauth-2025-04-20") + .call(); + match sent { + Ok(response) if response.status() == 200 => { + let mut body = String::new(); + match response + .into_reader() + .take(MAX_BODY) + .read_to_string(&mut body) + { + Ok(_) => Answer::Body(body), + Err(_) => Answer::Unreachable, + } + } + Ok(response) => Answer::Status(response.status(), None), + Err(ureq::Error::Status(code, response)) => { + // Whole seconds only; the HTTP-date form counts as absent. + let retry_after = response + .header("retry-after") + .and_then(|seconds| seconds.trim().parse::().ok()) + .map(Duration::from_secs); + Answer::Status(code, retry_after) + } + Err(ureq::Error::Transport(_)) => Answer::Unreachable, + } + } +} + +/// The schedule and the thing it drives. Time comes in as an argument so the +/// schedule is testable without waiting. +struct Poller { + cell: Arc, + tokens: Box, + client: UsageClient, + log: Box, + /// No poll before this instant, unix ms: the last answer's own wait. + not_before_ms: u64, + started: bool, + stopped: bool, + last_state: Option, +} + +impl Poller { + fn new( + cell: Arc, + tokens: Box, + client: UsageClient, + log: Box, + ) -> Self { + Self { + cell, + tokens, + client, + log, + not_before_ms: 0, + started: false, + stopped: false, + last_state: None, + } + } + + /// Polls if a poll is due at `now_ms`; returns how long to wait before + /// asking again, or `None` once polling has stopped for good. + /// + /// The first call polls. After that a poll is due only when the cell's + /// `seven_day` observation is `STALE` old or older (or absent), and never + /// before the last answer's own wait has passed. + fn tick(&mut self, now_ms: u64) -> Option { + if self.stopped { + return None; + } + let due_at = self.due_at_ms(); + if now_ms < due_at { + return Some(Duration::from_millis(due_at - now_ms)); + } + self.started = true; + let verdict = match self.tokens.read() { + Some(token) => judge(&self.client.ask(&token)), + None => Verdict { + windows: Vec::new(), + next: Next::Wait(STALE), + state: State::NoToken, + }, + }; + self.cell.observe(verdict.windows); + if self.last_state != Some(verdict.state) { + (self.log)(verdict.state.line()); + self.last_state = Some(verdict.state); + } + match verdict.next { + Next::Stop => { + self.stopped = true; + None + } + Next::Wait(wait) => { + self.not_before_ms = now_ms.saturating_add(duration_ms(wait)); + Some(Duration::from_millis( + self.due_at_ms().saturating_sub(now_ms), + )) + } + } + } + + /// The earliest instant the next poll may happen. + fn due_at_ms(&self) -> u64 { + if !self.started { + return 0; + } + let stale_at = self + .cell + .snapshot() + .windows + .iter() + .find(|quota| quota.window == "seven_day") + .map_or(0, |quota| { + quota.observed_at_unix_ms.saturating_add(duration_ms(STALE)) + }); + stale_at.max(self.not_before_ms) + } + + fn run(mut self) { + while let Some(wait) = self.tick(unix_ms()) { + std::thread::sleep(wait); + } + } +} + +fn duration_ms(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + +fn unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, duration_ms) +} + +/// Starts the poll thread. It reads the token afresh at each poll, writes +/// only to `cell`, and logs one line to stderr when its state changes. +pub fn start( + cell: Arc, + tokens: Box, +) -> std::io::Result> { + let poller = Poller::new( + cell, + tokens, + UsageClient::new(ENDPOINT), + Box::new(|line| eprintln!("claude-connector: {line}")), + ); + std::thread::Builder::new() + .name("claude-usage-poll".to_string()) + .spawn(move || poller.run()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::net::TcpListener; + use std::sync::Mutex; + + const MIN: u64 = 60_000; + const CANARY: &str = "sk-ant-oat01-CANARY-must-never-surface"; + const FAR: &str = "2100-01-01T00:00:00+00:00"; + const FAR_MS: u64 = 4_102_444_800_000; + + fn body(five: &str, seven: &str) -> String { + format!( + r#"{{"five_hour":{five},"seven_day":{seven},"seven_day_opus":null,"extra_usage":{{"x":1}}}}"# + ) + } + + fn reading(percent: f64) -> String { + format!(r#"{{"utilization":{percent},"resets_at":"{FAR}"}}"#) + } + + fn secs(s: u64) -> Duration { + Duration::from_secs(s) + } + + // ---- mapping ---- + + #[test] + fn usage_body_maps_percent_to_permille() { + let windows = parse_usage(&body(&reading(6.0), &reading(35.0))).unwrap(); + let expect = |window: &str, permille| PlanQuota { + window: window.to_string(), + utilization_permille: Some(permille), + resets_at_unix_ms: FAR_MS, + status: "polled".to_string(), + observed_at_unix_ms: 0, + }; + assert_eq!( + windows, + vec![expect("five_hour", 60), expect("seven_day", 350)] + ); + } + + #[test] + fn usage_percent_rounds_to_the_nearest_permille() { + let windows = parse_usage(&body(&reading(12.34), &reading(0.06))).unwrap(); + assert_eq!(windows[0].utilization_permille, Some(123)); + assert_eq!(windows[1].utilization_permille, Some(1)); + let zero = parse_usage(&body(&reading(0.0), "null")).unwrap(); + assert_eq!(zero[0].utilization_permille, Some(0), "a real zero stays"); + } + + #[test] + fn resets_at_is_parsed_with_its_offset_to_unix_ms() { + let seven = r#"{"utilization":1.0,"resets_at":"2026-11-07T12:30:15.250+02:00"}"#; + let windows = parse_usage(&body("null", seven)).unwrap(); + // 2026-11-07T10:30:15.250Z + assert_eq!(windows[0].resets_at_unix_ms, 1_794_047_415_250); + } + + #[test] + fn a_window_missing_a_field_or_reading_nonsense_is_skipped_not_zero() { + let no_percent = format!(r#"{{"utilization":null,"resets_at":"{FAR}"}}"#); + let no_reset = r#"{"utilization":50.0,"resets_at":null}"#; + assert_eq!(parse_usage(&body(&no_percent, no_reset)).unwrap(), vec![]); + let junk = r#"{"utilization":5.0,"resets_at":"soon"}"#; + assert_eq!(parse_usage(&body(&reading(-1.0), junk)).unwrap(), vec![]); + let before_epoch = r#"{"utilization":5.0,"resets_at":"1969-01-01T00:00:00Z"}"#; + assert_eq!(parse_usage(&body(before_epoch, "null")).unwrap(), vec![]); + // One good window survives its neighbour's null. + let one = parse_usage(&body(&no_percent, &reading(35.0))).unwrap(); + assert_eq!(one.len(), 1); + assert_eq!(one[0].window, "seven_day"); + } + + #[test] + fn a_body_that_is_not_an_object_is_unreadable() { + for bad in ["", "not json", "[1,2]", "42", "null"] { + assert_eq!(parse_usage(bad), None, "{bad:?}"); + } + assert_eq!(parse_usage("{}"), Some(vec![])); + } + + // ---- judgement ---- + + fn wait_of(answer: &Answer) -> Next { + judge(answer).next + } + + #[test] + fn rate_limit_waits_the_longer_of_retry_after_and_the_floor_capped() { + let limited = |after| wait_of(&Answer::Status(429, after)); + assert_eq!(limited(Some(secs(3600))), Next::Wait(secs(3600))); + assert_eq!(limited(Some(secs(20 * 60))), Next::Wait(secs(20 * 60))); + assert_eq!(limited(Some(secs(10))), Next::Wait(STALE)); + assert_eq!(limited(None), Next::Wait(STALE)); + assert_eq!(limited(Some(secs(100_000))), Next::Wait(CEILING)); + assert_eq!(limited(Some(secs(6 * 3600))), Next::Wait(CEILING)); + assert_eq!(judge(&Answer::Status(429, None)).state, State::RateLimited); + } + + #[test] + fn refusal_stops_polling_and_the_rest_back_off() { + assert_eq!(wait_of(&Answer::Status(401, None)), Next::Stop); + assert_eq!(wait_of(&Answer::Status(403, Some(secs(5)))), Next::Stop); + assert_eq!(judge(&Answer::Status(403, None)).state, State::Refused); + assert_eq!(wait_of(&Answer::Status(404, None)), Next::Wait(CEILING)); + assert_eq!( + wait_of(&Answer::Body("".to_string())), + Next::Wait(CEILING) + ); + assert_eq!(wait_of(&Answer::Unreachable), Next::Wait(STALE)); + assert_eq!(wait_of(&Answer::Status(503, None)), Next::Wait(STALE)); + assert_eq!( + wait_of(&Answer::Body(body(&reading(1.0), "null"))), + Next::Wait(STALE) + ); + } + + // ---- a fake server, a poller, and the cell ---- + + /// Serves each canned reply to one connection in turn and remembers the + /// requests. + fn serve(replies: Vec) -> (String, Arc>>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/api/oauth/usage", listener.local_addr().unwrap()); + let seen = Arc::new(Mutex::new(Vec::new())); + let record = seen.clone(); + std::thread::spawn(move || { + for reply in replies { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + let mut request = vec![0u8; 4096]; + let n = stream.read(&mut request).unwrap_or(0); + record + .lock() + .unwrap() + .push(String::from_utf8_lossy(&request[..n]).into_owned()); + let _ = stream.write_all(reply.as_bytes()); + } + }); + (url, seen) + } + + fn http(status: &str, headers: &str, body: &str) -> String { + format!( + "HTTP/1.1 {status}\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + } + + struct Fixed(Option<&'static str>); + impl TokenSource for Fixed { + fn read(&self) -> Option> { + self.0.map(|t| Zeroizing::new(t.to_string())) + } + } + + type Lines = Arc>>; + + fn poller(cell: &Arc, url: &str, token: Option<&'static str>) -> (Poller, Lines) { + let lines = Arc::new(Mutex::new(Vec::new())); + let sink = lines.clone(); + let poller = Poller::new( + cell.clone(), + Box::new(Fixed(token)), + UsageClient::new(url), + Box::new(move |line| sink.lock().unwrap().push(line.to_string())), + ); + (poller, lines) + } + + fn window(cell: &QuotaCell, name: &str) -> Option { + cell.snapshot() + .windows + .into_iter() + .find(|q| q.window == name) + } + + fn stream_reading(window: &str, permille: u32) -> PlanQuota { + PlanQuota { + window: window.to_string(), + utilization_permille: Some(permille), + resets_at_unix_ms: FAR_MS, + status: "allowed".to_string(), + observed_at_unix_ms: 0, + } + } + + fn requests(seen: &Arc>>) -> usize { + seen.lock().unwrap().len() + } + + #[test] + fn the_first_tick_polls_and_records_what_the_endpoint_said() { + let (url, seen) = serve(vec![http( + "200 OK", + "Content-Type: application/json\r\n", + &body(&reading(6.0), &reading(35.0)), + )]); + let cell = Arc::new(QuotaCell::new()); + let (mut poller, lines) = poller(&cell, &url, Some(CANARY)); + let wait = poller.tick(unix_ms()).unwrap(); + let windows = cell.snapshot().windows; + assert_eq!(windows.len(), 2); + assert_eq!(windows[0].status, "polled"); + assert_eq!(windows[1].utilization_permille, Some(350)); + assert!( + (STALE..STALE + secs(5)).contains(&wait), + "a fresh reading waits until it is stale: {wait:?}" + ); + let request = seen.lock().unwrap()[0].to_lowercase(); + assert!(request.contains(&format!("authorization: bearer {}", CANARY.to_lowercase()))); + assert!(request.contains("anthropic-beta: oauth-2025-04-20")); + assert_eq!(lines.lock().unwrap().len(), 1); + } + + #[test] + fn stale_only_polls() { + let (url, seen) = serve(vec![http("200 OK", "", &body("null", &reading(35.0))); 3]); + let cell = Arc::new(QuotaCell::new()); + let (mut poller, _) = poller(&cell, &url, Some(CANARY)); + poller.tick(unix_ms()); + assert_eq!(requests(&seen), 1, "the startup poll"); + let observed = window(&cell, "seven_day").unwrap().observed_at_unix_ms; + + // 5 min old: no request, and the wait is the time left. + let wait = poller.tick(observed + 5 * MIN).unwrap(); + assert_eq!(requests(&seen), 1); + assert_eq!(wait, Duration::from_millis(10 * MIN)); + // One millisecond short of 15 min: still none. + poller.tick(observed + 15 * MIN - 1); + assert_eq!(requests(&seen), 1); + // 16 min old: one request. + poller.tick(observed + 16 * MIN); + assert_eq!(requests(&seen), 2); + // That poll's answer holds its own 15 min, whatever the stamp says. + poller.tick(observed + 16 * MIN + 1); + assert_eq!(requests(&seen), 2); + } + + #[test] + fn a_reading_is_stale_at_exactly_fifteen_minutes() { + let (url, seen) = serve(vec![http("200 OK", "", &body("null", &reading(35.0))); 2]); + let cell = Arc::new(QuotaCell::new()); + let (mut poller, _) = poller(&cell, &url, Some(CANARY)); + poller.tick(unix_ms()); + let observed = window(&cell, "seven_day").unwrap().observed_at_unix_ms; + poller.tick(observed + 15 * MIN); + assert_eq!(requests(&seen), 2); + } + + #[test] + fn a_stream_observation_keeps_the_poll_quiet() { + let (url, seen) = serve(vec![http("200 OK", "", &body("null", &reading(35.0)))]); + let cell = Arc::new(QuotaCell::new()); + let (mut poller, _) = poller(&cell, &url, Some(CANARY)); + poller.tick(unix_ms()); + std::thread::sleep(Duration::from_millis(20)); + // The stream reports seven_day afresh, after the poll. + cell.observe([stream_reading("seven_day", 400)]); + let observed = window(&cell, "seven_day").unwrap().observed_at_unix_ms; + let wait = poller.tick(observed + 14 * MIN).unwrap(); + assert_eq!(requests(&seen), 1); + assert_eq!(wait, Duration::from_millis(MIN)); + } + + #[test] + fn failures_never_touch_the_cell() { + let limited = |headers: &str| http("429 Too Many Requests", headers, "{}"); + let cases = [ + (limited("Retry-After: 3600\r\n"), Some(60)), + (limited(""), Some(15)), + ( + limited("Retry-After: Wed, 21 Oct 2026 07:28:00 GMT\r\n"), + Some(15), + ), + (http("403 Forbidden", "", "{}"), None), + (http("401 Unauthorized", "", "{}"), None), + (http("404 Not Found", "", "{}"), Some(360)), + (http("200 OK", "", "not json"), Some(360)), + ( + http("302 Found", "Location: http://127.0.0.1:9/\r\n", ""), + Some(15), + ), + ]; + for (reply, minutes) in cases { + let (url, _) = serve(vec![reply.clone()]); + let cell = Arc::new(QuotaCell::new()); + cell.observe([ + stream_reading("five_hour", 120), + stream_reading("seven_day", 300), + ]); + let before = cell.snapshot(); + let (mut poller, lines) = poller(&cell, &url, Some(CANARY)); + let wait = poller.tick(unix_ms()); + assert_eq!(cell.snapshot(), before, "{reply}"); + assert_eq!(wait.map(|w| w.as_secs() / 60), minutes, "{reply}"); + assert_eq!(lines.lock().unwrap().len(), 1, "{reply}"); + assert_eq!(poller.tick(unix_ms()).is_none(), minutes.is_none()); + } + } + + #[test] + fn an_unreachable_endpoint_waits_the_floor_and_a_missing_token_never_asks() { + // Nothing listens here: bind, learn the port, drop. + let port = TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + let cell = Arc::new(QuotaCell::new()); + let (mut poller, lines) = + poller(&cell, &format!("http://127.0.0.1:{port}/u"), Some(CANARY)); + assert_eq!(poller.tick(unix_ms()), Some(STALE)); + assert!(cell.snapshot().windows.is_empty()); + assert_eq!(lines.lock().unwrap().len(), 1); + + let (url, seen) = serve(vec![http("200 OK", "", "{}")]); + let (mut poller, lines) = poller(&cell, &url, None); + assert_eq!(poller.tick(unix_ms()), Some(STALE)); + assert_eq!(requests(&seen), 0); + assert_eq!(lines.lock().unwrap()[0], State::NoToken.line()); + } + + #[test] + fn a_state_is_logged_once_until_it_changes() { + let limited = http("429 Too Many Requests", "", "{}"); + let (url, _) = serve(vec![ + limited.clone(), + limited, + http("200 OK", "", &body("null", &reading(35.0))), + ]); + let cell = Arc::new(QuotaCell::new()); + let (mut poller, lines) = poller(&cell, &url, Some(CANARY)); + let t = unix_ms(); + poller.tick(t); + poller.tick(t + 16 * MIN); + assert_eq!(lines.lock().unwrap().len(), 1, "same state, one line"); + poller.tick(t + 32 * MIN); + let lines = lines.lock().unwrap(); + assert_eq!(lines.len(), 2); + assert_eq!(lines[1], State::Polled.line()); + } + + #[test] + fn nothing_logged_or_stored_holds_the_token() { + let echo = format!(r#"{{"echo":"{CANARY}"}}"#); + let (url, _) = serve(vec![ + http("429 Too Many Requests", "Retry-After: 60\r\n", &echo), + http( + "200 OK", + "", + &format!(r#"{{"five_hour":{},"note":"{CANARY}"}}"#, reading(1.0)), + ), + http("403 Forbidden", "", CANARY), + ]); + let cell = Arc::new(QuotaCell::new()); + let (mut poller, lines) = poller(&cell, &url, Some(CANARY)); + let t = unix_ms(); + poller.tick(t); + poller.tick(t + 16 * MIN); + assert!(poller.tick(t + 32 * MIN).is_none()); + let lines = lines.lock().unwrap(); + assert_eq!(lines.len(), 3, "one line per change of state"); + let seen = format!("{lines:?} {:?}", cell.snapshot()); + assert!(!seen.contains("CANARY"), "{seen}"); + } + + #[test] + fn stream_and_poll_share_one_cell() { + let (url, _) = serve(vec![http( + "200 OK", + "", + &body(&reading(6.0), &reading(35.0)), + )]); + let cell = Arc::new(QuotaCell::new()); + let (mut poller, _) = poller(&cell, &url, Some(CANARY)); + poller.tick(unix_ms()); + assert_eq!(window(&cell, "five_hour").unwrap().status, "polled"); + // A later stream reading of one window replaces the poll's; the other + // window keeps the poll's. + cell.observe([stream_reading("five_hour", 90)]); + let five = window(&cell, "five_hour").unwrap(); + assert_eq!( + (five.status.as_str(), five.utilization_permille), + ("allowed", Some(90)) + ); + assert_eq!(window(&cell, "seven_day").unwrap().status, "polled"); + // And a later poll replaces the stream's. + let (url, _) = serve(vec![http("200 OK", "", &body(&reading(7.0), "null"))]); + let (mut again, _) = poller_at(&cell, &url); + again.tick(unix_ms()); + assert_eq!( + window(&cell, "five_hour").unwrap().utilization_permille, + Some(70) + ); + } + + fn poller_at(cell: &Arc, url: &str) -> (Poller, Lines) { + poller(cell, url, Some(CANARY)) + } +} From 9143d2c5e2ba933e0e3000e85e785f39a1f60944 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 01:35:51 +0100 Subject: [PATCH 02/11] usage-meter-poll: serve_config starts the poll beside the leases Co-Authored-By: Claude Sonnet 5.5 --- crates/claude-connector/src/daemon.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/claude-connector/src/daemon.rs b/crates/claude-connector/src/daemon.rs index 1a1a80b..f344197 100644 --- a/crates/claude-connector/src/daemon.rs +++ b/crates/claude-connector/src/daemon.rs @@ -556,6 +556,13 @@ pub fn serve_config(path: &Path) -> Result<(), ConfigError> { if let Some(socket) = &config.lease_socket { start_leases(&config, socket, service.clone(), backend.quota())?; } + crate::usage_poll::start( + backend.quota(), + Box::new(CredentialTokenSource::from_environment( + config.token_credential.clone(), + )), + ) + .map_err(|_| ConfigError::Serving("usage poll"))?; serve_service(listener, config.serve_limits(), service, backend)?; Ok(()) } From 7f3a46313e092f547652fabf67239ad1daf0bbd8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 01:37:29 +0100 Subject: [PATCH 03/11] usage-meter-poll: test helper no longer shadowed by its own binding Co-Authored-By: Claude Sonnet 5.5 --- crates/claude-connector/src/usage_poll.rs | 32 +++++++++++------------ 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/claude-connector/src/usage_poll.rs b/crates/claude-connector/src/usage_poll.rs index 469e234..8bfaaf9 100644 --- a/crates/claude-connector/src/usage_poll.rs +++ b/crates/claude-connector/src/usage_poll.rs @@ -484,7 +484,11 @@ mod tests { type Lines = Arc>>; - fn poller(cell: &Arc, url: &str, token: Option<&'static str>) -> (Poller, Lines) { + fn make_poller( + cell: &Arc, + url: &str, + token: Option<&'static str>, + ) -> (Poller, Lines) { let lines = Arc::new(Mutex::new(Vec::new())); let sink = lines.clone(); let poller = Poller::new( @@ -525,7 +529,7 @@ mod tests { &body(&reading(6.0), &reading(35.0)), )]); let cell = Arc::new(QuotaCell::new()); - let (mut poller, lines) = poller(&cell, &url, Some(CANARY)); + let (mut poller, lines) = make_poller(&cell, &url, Some(CANARY)); let wait = poller.tick(unix_ms()).unwrap(); let windows = cell.snapshot().windows; assert_eq!(windows.len(), 2); @@ -545,7 +549,7 @@ mod tests { fn stale_only_polls() { let (url, seen) = serve(vec![http("200 OK", "", &body("null", &reading(35.0))); 3]); let cell = Arc::new(QuotaCell::new()); - let (mut poller, _) = poller(&cell, &url, Some(CANARY)); + let (mut poller, _) = make_poller(&cell, &url, Some(CANARY)); poller.tick(unix_ms()); assert_eq!(requests(&seen), 1, "the startup poll"); let observed = window(&cell, "seven_day").unwrap().observed_at_unix_ms; @@ -569,7 +573,7 @@ mod tests { fn a_reading_is_stale_at_exactly_fifteen_minutes() { let (url, seen) = serve(vec![http("200 OK", "", &body("null", &reading(35.0))); 2]); let cell = Arc::new(QuotaCell::new()); - let (mut poller, _) = poller(&cell, &url, Some(CANARY)); + let (mut poller, _) = make_poller(&cell, &url, Some(CANARY)); poller.tick(unix_ms()); let observed = window(&cell, "seven_day").unwrap().observed_at_unix_ms; poller.tick(observed + 15 * MIN); @@ -580,7 +584,7 @@ mod tests { fn a_stream_observation_keeps_the_poll_quiet() { let (url, seen) = serve(vec![http("200 OK", "", &body("null", &reading(35.0)))]); let cell = Arc::new(QuotaCell::new()); - let (mut poller, _) = poller(&cell, &url, Some(CANARY)); + let (mut poller, _) = make_poller(&cell, &url, Some(CANARY)); poller.tick(unix_ms()); std::thread::sleep(Duration::from_millis(20)); // The stream reports seven_day afresh, after the poll. @@ -618,7 +622,7 @@ mod tests { stream_reading("seven_day", 300), ]); let before = cell.snapshot(); - let (mut poller, lines) = poller(&cell, &url, Some(CANARY)); + let (mut poller, lines) = make_poller(&cell, &url, Some(CANARY)); let wait = poller.tick(unix_ms()); assert_eq!(cell.snapshot(), before, "{reply}"); assert_eq!(wait.map(|w| w.as_secs() / 60), minutes, "{reply}"); @@ -637,13 +641,13 @@ mod tests { .port(); let cell = Arc::new(QuotaCell::new()); let (mut poller, lines) = - poller(&cell, &format!("http://127.0.0.1:{port}/u"), Some(CANARY)); + make_poller(&cell, &format!("http://127.0.0.1:{port}/u"), Some(CANARY)); assert_eq!(poller.tick(unix_ms()), Some(STALE)); assert!(cell.snapshot().windows.is_empty()); assert_eq!(lines.lock().unwrap().len(), 1); let (url, seen) = serve(vec![http("200 OK", "", "{}")]); - let (mut poller, lines) = poller(&cell, &url, None); + let (mut poller, lines) = make_poller(&cell, &url, None); assert_eq!(poller.tick(unix_ms()), Some(STALE)); assert_eq!(requests(&seen), 0); assert_eq!(lines.lock().unwrap()[0], State::NoToken.line()); @@ -658,7 +662,7 @@ mod tests { http("200 OK", "", &body("null", &reading(35.0))), ]); let cell = Arc::new(QuotaCell::new()); - let (mut poller, lines) = poller(&cell, &url, Some(CANARY)); + let (mut poller, lines) = make_poller(&cell, &url, Some(CANARY)); let t = unix_ms(); poller.tick(t); poller.tick(t + 16 * MIN); @@ -682,7 +686,7 @@ mod tests { http("403 Forbidden", "", CANARY), ]); let cell = Arc::new(QuotaCell::new()); - let (mut poller, lines) = poller(&cell, &url, Some(CANARY)); + let (mut poller, lines) = make_poller(&cell, &url, Some(CANARY)); let t = unix_ms(); poller.tick(t); poller.tick(t + 16 * MIN); @@ -701,7 +705,7 @@ mod tests { &body(&reading(6.0), &reading(35.0)), )]); let cell = Arc::new(QuotaCell::new()); - let (mut poller, _) = poller(&cell, &url, Some(CANARY)); + let (mut poller, _) = make_poller(&cell, &url, Some(CANARY)); poller.tick(unix_ms()); assert_eq!(window(&cell, "five_hour").unwrap().status, "polled"); // A later stream reading of one window replaces the poll's; the other @@ -715,15 +719,11 @@ mod tests { assert_eq!(window(&cell, "seven_day").unwrap().status, "polled"); // And a later poll replaces the stream's. let (url, _) = serve(vec![http("200 OK", "", &body(&reading(7.0), "null"))]); - let (mut again, _) = poller_at(&cell, &url); + let (mut again, _) = make_poller(&cell, &url, Some(CANARY)); again.tick(unix_ms()); assert_eq!( window(&cell, "five_hour").unwrap().utilization_permille, Some(70) ); } - - fn poller_at(cell: &Arc, url: &str) -> (Poller, Lines) { - poller(cell, url, Some(CANARY)) - } } From 6982cbca7eb44d25f8eb8a37dea39527ba2c0afc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 01:44:35 +0100 Subject: [PATCH 04/11] usage-meter-poll: pin the log lines and the thread loop Co-Authored-By: Claude Sonnet 5.5 --- crates/claude-connector/src/usage_poll.rs | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/claude-connector/src/usage_poll.rs b/crates/claude-connector/src/usage_poll.rs index 8bfaaf9..013269f 100644 --- a/crates/claude-connector/src/usage_poll.rs +++ b/crates/claude-connector/src/usage_poll.rs @@ -726,4 +726,33 @@ mod tests { Some(70) ); } + + #[test] + fn run_polls_until_the_endpoint_refuses_then_returns() { + let (url, seen) = serve(vec![http("403 Forbidden", "", "{}")]); + let cell = Arc::new(QuotaCell::new()); + let (poller, lines) = make_poller(&cell, &url, Some(CANARY)); + poller.run(); + assert_eq!(requests(&seen), 1); + let lines = lines.lock().unwrap(); + assert_eq!(lines.len(), 1); + assert!(lines[0].contains("user:profile"), "{lines:?}"); + } + + #[test] + fn every_state_has_its_own_line() { + let states = [ + State::Polled, + State::NoToken, + State::RateLimited, + State::Refused, + State::NotFound, + State::Unreadable, + State::Unexpected, + State::Unreachable, + ]; + let lines: std::collections::BTreeSet<_> = states.iter().map(|s| s.line()).collect(); + assert_eq!(lines.len(), states.len()); + assert!(lines.iter().all(|line| line.starts_with("usage poll: "))); + } } From c1cc349b19a4d1acf4fbbd541753826f171f8499 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 02:49:16 +0100 Subject: [PATCH 05/11] usage-meter-poll: the cell decides how a reading combines; a poll is as old as its question QuotaCell::observe_at is the one decision for both sensors: a reading older than the held one is dropped, and a rejection stands against a reading that only reports utilization (status polled) until its window resets or a reading that carries its own verdict replaces it. The poll stamps its answer with the moment it asked. Also: Retry-After in HTTP-date form is honoured; a refused token is not sent again until it reads differently (the poll thread no longer exits); the client's timeout is a parameter so a silent endpoint is tested; tests pin redirects(0), the two-window mapping, the slow-server race, and the late poll against a rejection. Co-Authored-By: Claude Sonnet 5.5 --- crates/claude-connector/src/quota.rs | 98 +++++- crates/claude-connector/src/usage_poll.rs | 402 ++++++++++++++++------ 2 files changed, 398 insertions(+), 102 deletions(-) diff --git a/crates/claude-connector/src/quota.rs b/crates/claude-connector/src/quota.rs index 1b3d27e..b319d4c 100644 --- a/crates/claude-connector/src/quota.rs +++ b/crates/claude-connector/src/quota.rs @@ -23,18 +23,35 @@ impl QuotaCell { Self::default() } - /// Records the windows one event reported, stamping each with now. Each - /// replaces that window's earlier observation, and windows the event did - /// not mention stay as they were, stamp included. + /// Records the windows one event reported, stamping each with now. See + /// [`QuotaCell::observe_at`] for how a reading combines with what is held. pub fn observe(&self, windows: impl IntoIterator) { self.observe_at(unix_ms(), windows); } - fn observe_at(&self, now: u64, windows: impl IntoIterator) { + /// Records readings taken at `at` (unix ms): the moment the sensor asked, + /// not the moment its answer arrived. The cell alone decides how a reading + /// combines with the one it holds for that window, and every sensor goes + /// through this decision: + /// + /// - a reading older than the held one is dropped, so a slow answer cannot + /// replace what a faster sensor saw meanwhile; + /// - the provider's rejection stands until its window resets, or until a + /// reading that itself carries a verdict from the provider replaces it: + /// a [`POLLED`] reading reports utilization only, and says nothing about + /// whether the provider accepts calls. + /// + /// Windows the readings did not mention stay as they were, stamp included. + pub fn observe_at(&self, at: u64, windows: impl IntoIterator) { let mut held = self.0.lock().unwrap_or_else(|e| e.into_inner()); for mut quota in windows { - quota.observed_at_unix_ms = now; - held.insert(quota.window.clone(), quota); + quota.observed_at_unix_ms = at; + if held + .get("a.window) + .is_none_or(|current| supersedes(current, "a)) + { + held.insert(quota.window.clone(), quota); + } } } @@ -65,6 +82,23 @@ impl QuotaCell { /// The status of a window whose reset has passed since it was observed. const EXPIRED: &str = "expired"; +/// The status of a window the provider refuses calls on. +pub(crate) const REJECTED: &str = "rejected"; +/// The status of a window read from the usage endpoint: utilization and reset +/// time, with no verdict on whether calls are accepted. +pub(crate) const POLLED: &str = "polled"; + +/// Whether `incoming` (already stamped with its own reading time) replaces +/// `current` for the same window. +fn supersedes(current: &PlanQuota, incoming: &PlanQuota) -> bool { + if incoming.observed_at_unix_ms < current.observed_at_unix_ms { + return false; + } + let rejection_stands = current.status == REJECTED + && incoming.status == POLLED + && incoming.observed_at_unix_ms < current.resets_at_unix_ms; + !rejection_stands +} fn unix_ms() -> u64 { SystemTime::now() @@ -135,6 +169,58 @@ mod tests { assert_eq!(expired[0].resets_at_unix_ms, RESET); } + fn with_status(mut quota: PlanQuota, status: &str) -> PlanQuota { + quota.status = status.to_string(); + quota + } + + fn held(cell: &QuotaCell) -> (String, Option, u64) { + let window = cell.snapshot_at(250).windows.remove(0); + ( + window.status, + window.utilization_permille, + window.observed_at_unix_ms, + ) + } + + #[test] + fn a_reading_older_than_the_held_one_is_dropped_and_an_equal_one_replaces() { + let cell = QuotaCell::new(); + cell.observe_at(200, [quota("five_hour", 80, 0)]); + cell.observe_at(100, [quota("five_hour", 10, 0)]); + assert_eq!(held(&cell), ("allowed".to_string(), Some(80), 200)); + cell.observe_at(200, [quota("five_hour", 90, 0)]); + assert_eq!(held(&cell), ("allowed".to_string(), Some(90), 200)); + } + + #[test] + fn a_rejection_stands_against_a_poll_until_the_window_resets() { + let cell = QuotaCell::new(); + let polled = with_status(quota("five_hour", 500, 0), POLLED); + cell.observe_at(100, [with_status(quota("five_hour", 1000, 0), REJECTED)]); + cell.observe_at(200, [polled.clone()]); + assert_eq!(held(&cell), (REJECTED.to_string(), Some(1000), 100)); + // The window the rejection described is over: a poll of the next one lands. + cell.observe_at(RESET, [polled]); + assert_eq!(cell.snapshot_at(RESET - 1).windows[0].status, POLLED); + } + + #[test] + fn a_newer_reading_that_carries_its_own_verdict_lifts_a_rejection() { + let cell = QuotaCell::new(); + cell.observe_at(100, [with_status(quota("five_hour", 1000, 0), REJECTED)]); + cell.observe_at(200, [quota("five_hour", 300, 0)]); + assert_eq!(held(&cell), ("allowed".to_string(), Some(300), 200)); + } + + #[test] + fn a_poll_replaces_what_is_not_a_rejection() { + let cell = QuotaCell::new(); + cell.observe_at(100, [quota("five_hour", 300, 0)]); + cell.observe_at(200, [with_status(quota("five_hour", 400, 0), POLLED)]); + assert_eq!(held(&cell), (POLLED.to_string(), Some(400), 200)); + } + #[test] fn the_live_clock_stamps_and_expires() { let cell = QuotaCell::new(); diff --git a/crates/claude-connector/src/usage_poll.rs b/crates/claude-connector/src/usage_poll.rs index 013269f..e23a74f 100644 --- a/crates/claude-connector/src/usage_poll.rs +++ b/crates/claude-connector/src/usage_poll.rs @@ -3,7 +3,8 @@ //! quiet the weekly reading goes stale; this thread asks the subscription's //! usage endpoint when the `seven_day` reading is `STALE` or older, and //! records the answer in the same [`QuotaCell`] the stream feeds. Neither -//! sensor owns the reading: the newest observation of a window wins. A failed +//! sensor owns the reading: the cell decides how a reading combines with the +//! one it holds, and a poll is stamped with the moment it asked. A failed //! poll never touches the cell; only the cell's own reset rule retires a //! window. //! @@ -17,10 +18,11 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use model_connector::PlanQuota; use serde_json::Value; +use sha2::{Digest, Sha256}; use zeroize::Zeroizing; use crate::backend::TokenSource; -use crate::quota::QuotaCell; +use crate::quota::{POLLED, QuotaCell}; /// The usage endpoint. const ENDPOINT: &str = "https://api.anthropic.com/api/oauth/usage"; @@ -31,8 +33,6 @@ const CEILING: Duration = Duration::from_secs(6 * 60 * 60); const TIMEOUT: Duration = Duration::from_secs(10); /// A usage body is a few hundred bytes; this is a ceiling, not an expectation. const MAX_BODY: u64 = 1 << 20; -/// The status every polled window carries. -const POLLED: &str = "polled"; /// What the endpoint said, reduced to what the schedule needs. It never holds /// the token. @@ -63,7 +63,7 @@ impl State { Self::NoToken => "usage poll: no token to ask with", Self::RateLimited => "usage poll: rate limited, waiting", Self::Refused => { - "usage poll: refused (401/403), the token likely lacks the user:profile scope; polling stopped until restart" + "usage poll: refused (401/403), the token likely lacks the user:profile scope; not asking again until the token changes" } Self::NotFound => "usage poll: endpoint not found, backing off", Self::Unreadable => "usage poll: answer not readable, backing off", @@ -73,40 +73,35 @@ impl State { } } -#[derive(Debug, PartialEq, Eq)] -enum Next { - Wait(Duration), - Stop, -} - #[derive(Debug, PartialEq)] struct Verdict { windows: Vec, - next: Next, + /// How long until the next poll may happen. + wait: Duration, state: State, } /// What one answer means: the windows to record (only a readable body has /// any), how long to wait, and the state to report. fn judge(answer: &Answer) -> Verdict { - let (windows, next, state) = match answer { + let (windows, wait, state) = match answer { Answer::Body(body) => match parse_usage(body) { - Some(windows) => (windows, Next::Wait(STALE), State::Polled), - None => (Vec::new(), Next::Wait(CEILING), State::Unreadable), + Some(windows) => (windows, STALE, State::Polled), + None => (Vec::new(), CEILING, State::Unreadable), }, Answer::Status(429, retry_after) => ( Vec::new(), - Next::Wait(retry_after.unwrap_or_default().clamp(STALE, CEILING)), + retry_after.unwrap_or_default().clamp(STALE, CEILING), State::RateLimited, ), - Answer::Status(401 | 403, _) => (Vec::new(), Next::Stop, State::Refused), - Answer::Status(404, _) => (Vec::new(), Next::Wait(CEILING), State::NotFound), - Answer::Status(..) => (Vec::new(), Next::Wait(STALE), State::Unexpected), - Answer::Unreachable => (Vec::new(), Next::Wait(STALE), State::Unreachable), + Answer::Status(401 | 403, _) => (Vec::new(), STALE, State::Refused), + Answer::Status(404, _) => (Vec::new(), CEILING, State::NotFound), + Answer::Status(..) => (Vec::new(), STALE, State::Unexpected), + Answer::Unreachable => (Vec::new(), STALE, State::Unreachable), }; Verdict { windows, - next, + wait, state, } } @@ -153,15 +148,22 @@ struct UsageClient { impl UsageClient { fn new(url: impl Into) -> Self { + Self::with_timeout(url, TIMEOUT) + } + + fn with_timeout(url: impl Into, timeout: Duration) -> Self { Self { agent: ureq::AgentBuilder::new() - .timeout(TIMEOUT) + .timeout(timeout) .redirects(0) .build(), url: url.into(), } } + /// Asks the endpoint. Our copy of the header value is zeroized when it + /// drops; ureq copies it into its own request buffers, which are freed + /// without zeroing. fn ask(&self, token: &Zeroizing) -> Answer { let bearer = Zeroizing::new(format!("Bearer {}", token.as_str())); let sent = self @@ -184,11 +186,9 @@ impl UsageClient { } Ok(response) => Answer::Status(response.status(), None), Err(ureq::Error::Status(code, response)) => { - // Whole seconds only; the HTTP-date form counts as absent. let retry_after = response .header("retry-after") - .and_then(|seconds| seconds.trim().parse::().ok()) - .map(Duration::from_secs); + .and_then(|value| retry_after(value, unix_ms())); Answer::Status(code, retry_after) } Err(ureq::Error::Transport(_)) => Answer::Unreachable, @@ -196,6 +196,19 @@ impl UsageClient { } } +/// A `Retry-After` value as a wait from `now_ms`: delay-seconds, or an +/// HTTP-date (RFC 9110 10.2.3) measured from now, a date already past being a +/// zero wait. `None` when it is neither. +fn retry_after(value: &str, now_ms: u64) -> Option { + let value = value.trim(); + if let Ok(seconds) = value.parse::() { + return Some(Duration::from_secs(seconds)); + } + let date = chrono::DateTime::parse_from_rfc2822(value).ok()?; + let until = u64::try_from(date.timestamp_millis()).ok()?; + Some(Duration::from_millis(until.saturating_sub(now_ms))) +} + /// The schedule and the thing it drives. Time comes in as an argument so the /// schedule is testable without waiting. struct Poller { @@ -206,7 +219,9 @@ struct Poller { /// No poll before this instant, unix ms: the last answer's own wait. not_before_ms: u64, started: bool, - stopped: bool, + /// The digest of the token the endpoint last refused: while the token + /// reads the same, the poller does not ask again. + refused_token: Option<[u8; 32]>, last_state: Option, } @@ -224,51 +239,58 @@ impl Poller { log, not_before_ms: 0, started: false, - stopped: false, + refused_token: None, last_state: None, } } /// Polls if a poll is due at `now_ms`; returns how long to wait before - /// asking again, or `None` once polling has stopped for good. + /// asking again. /// /// The first call polls. After that a poll is due only when the cell's /// `seven_day` observation is `STALE` old or older (or absent), and never - /// before the last answer's own wait has passed. - fn tick(&mut self, now_ms: u64) -> Option { - if self.stopped { - return None; - } + /// before the last answer's own wait has passed. A token the endpoint + /// refused is not sent again until it reads differently. + fn tick(&mut self, now_ms: u64) -> Duration { let due_at = self.due_at_ms(); if now_ms < due_at { - return Some(Duration::from_millis(due_at - now_ms)); + return Duration::from_millis(due_at - now_ms); } self.started = true; - let verdict = match self.tokens.read() { - Some(token) => judge(&self.client.ask(&token)), + let token = self.tokens.read(); + if let (Some(refused), Some(token)) = (&self.refused_token, &token) + && digest(token) != *refused + { + self.refused_token = None; + } + let verdict = match token { + _ if self.refused_token.is_some() => Verdict { + windows: Vec::new(), + wait: STALE, + state: State::Refused, + }, + Some(token) => { + let verdict = judge(&self.client.ask(&token)); + if verdict.state == State::Refused { + self.refused_token = Some(digest(&token)); + } + verdict + } None => Verdict { windows: Vec::new(), - next: Next::Wait(STALE), + wait: STALE, state: State::NoToken, }, }; - self.cell.observe(verdict.windows); + // The reading is as old as the question: an answer that arrives after + // the stream spoke does not outrank it. + self.cell.observe_at(now_ms, verdict.windows); if self.last_state != Some(verdict.state) { (self.log)(verdict.state.line()); self.last_state = Some(verdict.state); } - match verdict.next { - Next::Stop => { - self.stopped = true; - None - } - Next::Wait(wait) => { - self.not_before_ms = now_ms.saturating_add(duration_ms(wait)); - Some(Duration::from_millis( - self.due_at_ms().saturating_sub(now_ms), - )) - } - } + self.not_before_ms = now_ms.saturating_add(duration_ms(verdict.wait)); + Duration::from_millis(self.due_at_ms().saturating_sub(now_ms)) } /// The earliest instant the next poll may happen. @@ -289,12 +311,17 @@ impl Poller { } fn run(mut self) { - while let Some(wait) = self.tick(unix_ms()) { + loop { + let wait = self.tick(unix_ms()); std::thread::sleep(wait); } } } +fn digest(token: &Zeroizing) -> [u8; 32] { + Sha256::digest(token.as_bytes()).into() +} + fn duration_ms(duration: Duration) -> u64 { u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) } @@ -408,38 +435,32 @@ mod tests { // ---- judgement ---- - fn wait_of(answer: &Answer) -> Next { - judge(answer).next + fn wait_of(answer: &Answer) -> Duration { + judge(answer).wait } #[test] fn rate_limit_waits_the_longer_of_retry_after_and_the_floor_capped() { let limited = |after| wait_of(&Answer::Status(429, after)); - assert_eq!(limited(Some(secs(3600))), Next::Wait(secs(3600))); - assert_eq!(limited(Some(secs(20 * 60))), Next::Wait(secs(20 * 60))); - assert_eq!(limited(Some(secs(10))), Next::Wait(STALE)); - assert_eq!(limited(None), Next::Wait(STALE)); - assert_eq!(limited(Some(secs(100_000))), Next::Wait(CEILING)); - assert_eq!(limited(Some(secs(6 * 3600))), Next::Wait(CEILING)); + assert_eq!(limited(Some(secs(3600))), secs(3600)); + assert_eq!(limited(Some(secs(20 * 60))), secs(20 * 60)); + assert_eq!(limited(Some(secs(10))), STALE); + assert_eq!(limited(None), STALE); + assert_eq!(limited(Some(secs(100_000))), CEILING); + assert_eq!(limited(Some(secs(6 * 3600))), CEILING); assert_eq!(judge(&Answer::Status(429, None)).state, State::RateLimited); } #[test] - fn refusal_stops_polling_and_the_rest_back_off() { - assert_eq!(wait_of(&Answer::Status(401, None)), Next::Stop); - assert_eq!(wait_of(&Answer::Status(403, Some(secs(5)))), Next::Stop); + fn refusal_backs_off_like_the_rest() { + assert_eq!(wait_of(&Answer::Status(401, None)), STALE); + assert_eq!(wait_of(&Answer::Status(403, Some(secs(5)))), STALE); assert_eq!(judge(&Answer::Status(403, None)).state, State::Refused); - assert_eq!(wait_of(&Answer::Status(404, None)), Next::Wait(CEILING)); - assert_eq!( - wait_of(&Answer::Body("".to_string())), - Next::Wait(CEILING) - ); - assert_eq!(wait_of(&Answer::Unreachable), Next::Wait(STALE)); - assert_eq!(wait_of(&Answer::Status(503, None)), Next::Wait(STALE)); - assert_eq!( - wait_of(&Answer::Body(body(&reading(1.0), "null"))), - Next::Wait(STALE) - ); + assert_eq!(wait_of(&Answer::Status(404, None)), CEILING); + assert_eq!(wait_of(&Answer::Body("".to_string())), CEILING); + assert_eq!(wait_of(&Answer::Unreachable), STALE); + assert_eq!(wait_of(&Answer::Status(503, None)), STALE); + assert_eq!(wait_of(&Answer::Body(body(&reading(1.0), "null"))), STALE); } // ---- a fake server, a poller, and the cell ---- @@ -530,7 +551,7 @@ mod tests { )]); let cell = Arc::new(QuotaCell::new()); let (mut poller, lines) = make_poller(&cell, &url, Some(CANARY)); - let wait = poller.tick(unix_ms()).unwrap(); + let wait = poller.tick(unix_ms()); let windows = cell.snapshot().windows; assert_eq!(windows.len(), 2); assert_eq!(windows[0].status, "polled"); @@ -555,7 +576,7 @@ mod tests { let observed = window(&cell, "seven_day").unwrap().observed_at_unix_ms; // 5 min old: no request, and the wait is the time left. - let wait = poller.tick(observed + 5 * MIN).unwrap(); + let wait = poller.tick(observed + 5 * MIN); assert_eq!(requests(&seen), 1); assert_eq!(wait, Duration::from_millis(10 * MIN)); // One millisecond short of 15 min: still none. @@ -590,7 +611,7 @@ mod tests { // The stream reports seven_day afresh, after the poll. cell.observe([stream_reading("seven_day", 400)]); let observed = window(&cell, "seven_day").unwrap().observed_at_unix_ms; - let wait = poller.tick(observed + 14 * MIN).unwrap(); + let wait = poller.tick(observed + 14 * MIN); assert_eq!(requests(&seen), 1); assert_eq!(wait, Duration::from_millis(MIN)); } @@ -599,19 +620,23 @@ mod tests { fn failures_never_touch_the_cell() { let limited = |headers: &str| http("429 Too Many Requests", headers, "{}"); let cases = [ - (limited("Retry-After: 3600\r\n"), Some(60)), - (limited(""), Some(15)), + (limited("Retry-After: 3600\r\n"), 60), + (limited(""), 15), ( limited("Retry-After: Wed, 21 Oct 2026 07:28:00 GMT\r\n"), - Some(15), + 15, ), - (http("403 Forbidden", "", "{}"), None), - (http("401 Unauthorized", "", "{}"), None), - (http("404 Not Found", "", "{}"), Some(360)), - (http("200 OK", "", "not json"), Some(360)), + ( + limited("Retry-After: Fri, 31 Dec 2100 23:59:59 GMT\r\n"), + 360, + ), + (http("403 Forbidden", "", "{}"), 15), + (http("401 Unauthorized", "", "{}"), 15), + (http("404 Not Found", "", "{}"), 360), + (http("200 OK", "", "not json"), 360), ( http("302 Found", "Location: http://127.0.0.1:9/\r\n", ""), - Some(15), + 15, ), ]; for (reply, minutes) in cases { @@ -625,9 +650,8 @@ mod tests { let (mut poller, lines) = make_poller(&cell, &url, Some(CANARY)); let wait = poller.tick(unix_ms()); assert_eq!(cell.snapshot(), before, "{reply}"); - assert_eq!(wait.map(|w| w.as_secs() / 60), minutes, "{reply}"); + assert_eq!(wait.as_secs() / 60, minutes, "{reply}"); assert_eq!(lines.lock().unwrap().len(), 1, "{reply}"); - assert_eq!(poller.tick(unix_ms()).is_none(), minutes.is_none()); } } @@ -642,13 +666,13 @@ mod tests { let cell = Arc::new(QuotaCell::new()); let (mut poller, lines) = make_poller(&cell, &format!("http://127.0.0.1:{port}/u"), Some(CANARY)); - assert_eq!(poller.tick(unix_ms()), Some(STALE)); + assert_eq!(poller.tick(unix_ms()), STALE); assert!(cell.snapshot().windows.is_empty()); assert_eq!(lines.lock().unwrap().len(), 1); let (url, seen) = serve(vec![http("200 OK", "", "{}")]); let (mut poller, lines) = make_poller(&cell, &url, None); - assert_eq!(poller.tick(unix_ms()), Some(STALE)); + assert_eq!(poller.tick(unix_ms()), STALE); assert_eq!(requests(&seen), 0); assert_eq!(lines.lock().unwrap()[0], State::NoToken.line()); } @@ -690,7 +714,7 @@ mod tests { let t = unix_ms(); poller.tick(t); poller.tick(t + 16 * MIN); - assert!(poller.tick(t + 32 * MIN).is_none()); + poller.tick(t + 32 * MIN); let lines = lines.lock().unwrap(); assert_eq!(lines.len(), 3, "one line per change of state"); let seen = format!("{lines:?} {:?}", cell.snapshot()); @@ -727,16 +751,202 @@ mod tests { ); } + /// A token the test can rotate. + struct Rotating(Arc>>); + impl TokenSource for Rotating { + fn read(&self) -> Option> { + self.0.lock().unwrap().clone().map(Zeroizing::new) + } + } + #[test] - fn run_polls_until_the_endpoint_refuses_then_returns() { - let (url, seen) = serve(vec![http("403 Forbidden", "", "{}")]); + fn a_refused_token_is_not_sent_again_until_it_changes() { + let (url, seen) = serve(vec![ + http("403 Forbidden", "", "{}"), + http("200 OK", "", &body("null", &reading(35.0))), + ]); + let token = Arc::new(Mutex::new(Some("first".to_string()))); + let lines = Arc::new(Mutex::new(Vec::new())); + let sink = lines.clone(); let cell = Arc::new(QuotaCell::new()); - let (poller, lines) = make_poller(&cell, &url, Some(CANARY)); - poller.run(); + let mut poller = Poller::new( + cell.clone(), + Box::new(Rotating(token.clone())), + UsageClient::new(&url), + Box::new(move |line| sink.lock().unwrap().push(line.to_string())), + ); + let t = unix_ms(); + poller.tick(t); assert_eq!(requests(&seen), 1); - let lines = lines.lock().unwrap(); - assert_eq!(lines.len(), 1); - assert!(lines[0].contains("user:profile"), "{lines:?}"); + assert!(lines.lock().unwrap()[0].contains("user:profile")); + // The same token, a quarter hour on and again: the endpoint is not asked. + assert_eq!(poller.tick(t + 16 * MIN), STALE); + poller.tick(t + 32 * MIN); + assert_eq!(requests(&seen), 1); + assert_eq!(lines.lock().unwrap().len(), 1, "one state, one line"); + // A credential that vanishes does not count as a new one. + *token.lock().unwrap() = None; + poller.tick(t + 48 * MIN); + assert_eq!(requests(&seen), 1); + // A rotated credential is tried at the next due time. + *token.lock().unwrap() = Some("second".to_string()); + poller.tick(t + 64 * MIN); + assert_eq!(requests(&seen), 2); + assert!(window(&cell, "seven_day").is_some()); + assert_eq!(lines.lock().unwrap()[1], State::Polled.line()); + } + + #[test] + fn retry_after_reads_seconds_and_http_dates() { + const DATE: &str = "Wed, 21 Oct 2026 07:28:00 GMT"; + let at = chrono::DateTime::parse_from_rfc2822(DATE) + .unwrap() + .timestamp_millis() as u64; + let three_hours = 3 * 3600 * 1000; + assert_eq!(retry_after(DATE, at - three_hours), Some(secs(3 * 3600))); + assert_eq!(retry_after(DATE, at), Some(Duration::ZERO)); + assert_eq!(retry_after(DATE, at + 1), Some(Duration::ZERO), "past"); + assert_eq!(retry_after("3600", at), Some(secs(3600))); + assert_eq!(retry_after(" 3600 ", at), Some(secs(3600))); + for bad in ["", "soon", "-5", "3600.5", "1, 2", "Wed, 21 Oct 2026"] { + assert_eq!(retry_after(bad, at), None, "{bad:?}"); + } + } + + #[test] + fn keys_beyond_the_two_plan_windows_are_not_windows() { + let populated = reading(77.0); + let body = format!( + r#"{{"five_hour":{},"seven_day":{},"seven_day_opus":{populated},"seven_day_sonnet":{populated},"extra_usage":{populated}}}"#, + reading(6.0), + reading(35.0) + ); + let names: Vec<_> = parse_usage(&body) + .unwrap() + .into_iter() + .map(|quota| quota.window) + .collect(); + assert_eq!(names, ["five_hour", "seven_day"]); + } + + #[test] + fn a_redirect_is_not_followed_with_the_token() { + let (target, target_seen) = serve(vec![http("200 OK", "", &body("null", &reading(35.0)))]); + let (url, _) = serve(vec![http( + "302 Found", + &format!("Location: {target}\r\n"), + "", + )]); + let cell = Arc::new(QuotaCell::new()); + let (mut poller, lines) = make_poller(&cell, &url, Some(CANARY)); + poller.tick(unix_ms()); + assert_eq!(requests(&target_seen), 0, "the redirect target was asked"); + assert!(cell.snapshot().windows.is_empty()); + assert_eq!(lines.lock().unwrap()[0], State::Unexpected.line()); + } + + #[test] + fn a_silent_endpoint_frees_the_poll_at_its_timeout() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/u", listener.local_addr().unwrap()); + let holder = std::thread::spawn(move || { + // Accept, say nothing, keep the connection open. + let held = listener.accept(); + std::thread::sleep(Duration::from_secs(3)); + drop(held); + }); + let started = std::time::Instant::now(); + let answer = UsageClient::with_timeout(url, Duration::from_millis(300)) + .ask(&Zeroizing::new(CANARY.to_string())); + assert_eq!(answer, Answer::Unreachable); + assert!(started.elapsed() < Duration::from_secs(2)); + holder.join().unwrap(); + } + + #[test] + fn the_real_timeout_frees_the_thread_within_a_minute() { + assert!(TIMEOUT >= secs(1) && TIMEOUT <= secs(60), "{TIMEOUT:?}"); + } + + /// Answers one connection after `delay`, as a slow endpoint does. + fn serve_slowly(delay: Duration, reply: String) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/api/oauth/usage", listener.local_addr().unwrap()); + std::thread::spawn(move || { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + let mut request = vec![0u8; 4096]; + let _ = stream.read(&mut request); + std::thread::sleep(delay); + let _ = stream.write_all(reply.as_bytes()); + }); + url + } + + fn stream_event(window: &str, permille: u32, status: &str) -> PlanQuota { + PlanQuota { + status: status.to_string(), + ..stream_reading(window, permille) + } + } + + /// The stream speaks while a poll is in flight; the poll then answers + /// with a reading older than the stream's. Returns the cell after both. + fn stream_speaks_while_the_poll_is_in_flight(status: &str) -> Arc { + let url = serve_slowly( + Duration::from_millis(500), + http("200 OK", "", &body(&reading(50.0), &reading(35.0))), + ); + let cell = Arc::new(QuotaCell::new()); + let (mut poller, _) = make_poller(&cell, &url, Some(CANARY)); + std::thread::scope(|scope| { + let polling = scope.spawn(|| poller.tick(unix_ms())); + std::thread::sleep(Duration::from_millis(150)); + cell.observe([stream_event("five_hour", 1000, status)]); + polling.join().unwrap(); + }); + cell + } + + #[test] + fn a_poll_answered_late_does_not_replace_what_the_stream_saw_meanwhile() { + let cell = stream_speaks_while_the_poll_is_in_flight("allowed"); + let five = window(&cell, "five_hour").unwrap(); + assert_eq!( + (five.status.as_str(), five.utilization_permille), + ("allowed", Some(1000)) + ); + let seven = window(&cell, "seven_day").unwrap(); + assert_eq!( + (seven.status.as_str(), seven.utilization_permille), + ("polled", Some(350)), + "the window the stream did not name takes the poll's reading" + ); + } + + #[test] + fn a_slow_poll_cannot_lift_the_rejection_the_stream_reported_meanwhile() { + let cell = stream_speaks_while_the_poll_is_in_flight("rejected"); + let five = window(&cell, "five_hour").unwrap(); + assert_eq!( + (five.status.as_str(), five.utilization_permille), + ("rejected", Some(1000)) + ); + } + + #[test] + fn a_later_poll_does_not_lift_a_rejection_the_provider_reported_earlier() { + let (url, _) = serve(vec![http("200 OK", "", &body(&reading(50.0), "null"))]); + let cell = Arc::new(QuotaCell::new()); + cell.observe([stream_event("five_hour", 1000, "rejected")]); + let (mut poller, _) = make_poller(&cell, &url, Some(CANARY)); + poller.tick(unix_ms() + MIN); + let five = window(&cell, "five_hour").unwrap(); + assert_eq!( + (five.status.as_str(), five.utilization_permille), + ("rejected", Some(1000)) + ); } #[test] From e17ef16d48ffc5dff278120e072dcb2180cbe3bc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 02:49:16 +0100 Subject: [PATCH 06/11] usage-meter-poll: a subprocess test that serve --config starts the poll Spawns the claude-connector binary on a ready document and reads the usage poll state line from its stderr; the connector must also stay up. Co-Authored-By: Claude Sonnet 5.5 --- crates/claude-connector/tests/serve_config.rs | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 crates/claude-connector/tests/serve_config.rs diff --git a/crates/claude-connector/tests/serve_config.rs b/crates/claude-connector/tests/serve_config.rs new file mode 100644 index 0000000..50a6279 --- /dev/null +++ b/crates/claude-connector/tests/serve_config.rs @@ -0,0 +1,163 @@ +//! `claude-connector serve --config` as a process: it binds, stays up, and +//! starts the usage poll beside the lease listener. Only a spawned binary +//! shows that `serve_config` calls the poll start at all; the poll's own +//! behaviour is tested against fake servers in `usage_poll`. +//! +//! The usage endpoint is a constant of the connector, so this test's poll +//! asks the real host with a made-up token and sees an answer or an +//! unreachable network; either way it logs a `usage poll:` state line, and +//! that line is what is asserted. +#![cfg(unix)] + +use std::io::{BufRead, BufReader}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use sha2::{Digest, Sha256}; + +const TOKEN: &str = "sk-ant-oat01-CANARY-must-never-surface"; + +struct Scratch(PathBuf); + +impl Scratch { + fn new() -> Self { + let dir = + std::env::temp_dir().join(format!("claude-connector-serve-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + + fn path(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// Kills the served process however the test ends. +struct Served(Child); + +impl Drop for Served { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn run(args: &[&str]) { + let made = Command::new(env!("CARGO_BIN_EXE_claude-connector")) + .args(args) + .env_clear() + .output() + .unwrap(); + assert!(made.status.success(), "{made:?}"); +} + +/// A document the connector can serve: a pinned stand-in for the claude +/// binary, a 0700 prompt directory, and the unit's credentials directory. +fn ready_document(scratch: &Scratch) -> (PathBuf, PathBuf) { + let path = |name: &str| scratch.path(name); + let text = |name: &str| path(name).to_string_lossy().into_owned(); + std::fs::write(path("claude"), b"not a real claude binary").unwrap(); + let pin = hex(&Sha256::digest(b"not a real claude binary")); + for dir in ["work", "home", "prompts", "credentials"] { + std::fs::create_dir(path(dir)).unwrap(); + } + std::fs::set_permissions(path("prompts"), std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::write(path("caller.key"), "k".repeat(48)).unwrap(); + std::fs::write(path("credentials/claude-token"), TOKEN).unwrap(); + let config = text("connector.cc"); + run(&[ + "config", + "init", + &config, + "--listen", + "127.0.0.1:0", + "--replay-store", + &text("replay.cc"), + "--claude-binary", + &text("claude"), + "--claude-sha256", + &pin, + "--work-dir", + &text("work"), + "--config-dir", + &text("home"), + "--prompt-dir", + &text("prompts"), + "--instance", + "persona-a", + "--token-credential", + "claude-token", + "--handle-secret-credential", + "claude-handle-secret", + "--handle-secret-file", + &text("credentials/claude-handle-secret"), + "--caller-id", + "persona-a-caller", + "--connection-key-file", + &text("caller.key"), + "--connection-key-epoch", + "1", + "--allowed-model", + "claude-haiku-5-5", + ]); + (path("connector.cc"), path("credentials")) +} + +fn serve(config: &Path, credentials: &Path) -> (Served, mpsc::Receiver) { + let mut child = Command::new(env!("CARGO_BIN_EXE_claude-connector")) + .args(["serve", "--config"]) + .arg(config) + .env_clear() + .env("CREDENTIALS_DIRECTORY", credentials) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let stderr = child.stderr.take().unwrap(); + let (lines, received) = mpsc::channel(); + std::thread::spawn(move || { + for line in BufReader::new(stderr).lines().map_while(Result::ok) { + if lines.send(line).is_err() { + return; + } + } + }); + (Served(child), received) +} + +#[test] +fn serve_config_starts_the_usage_poll_and_stays_up() { + let scratch = Scratch::new(); + let (config, credentials) = ready_document(&scratch); + let (mut served, lines) = serve(&config, &credentials); + + let deadline = Instant::now() + Duration::from_secs(60); + let mut seen = Vec::new(); + let poll_line = loop { + let left = deadline.saturating_duration_since(Instant::now()); + match lines.recv_timeout(left) { + Ok(line) if line.contains("usage poll:") => break line, + Ok(line) => seen.push(line), + Err(_) => panic!("no usage poll state line; the connector said {seen:?}"), + } + }; + assert!( + served.0.try_wait().unwrap().is_none(), + "the connector exited after starting the poll" + ); + assert!(!poll_line.contains("CANARY"), "{poll_line}"); +} From 481428a7cefb09aa89c60089c689930dc4ebc2c2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 03:17:08 +0100 Subject: [PATCH 07/11] usage-meter-poll: Retry-After reads all three HTTP-date forms (finding retry-after-obsolete-date-forms) RFC 9110 10.2.3 makes a recipient accept IMF-fixdate, RFC 850 and asctime; the last two read as absent and the poll asked again at the floor. The two-digit RFC 850 year more than 50 years ahead is the latest past year. Co-Authored-By: Claude Sonnet 5.5 --- crates/claude-connector/src/usage_poll.rs | 108 +++++++++++- crates/claude-connector/tests/serve_config.rs | 163 ------------------ 2 files changed, 103 insertions(+), 168 deletions(-) delete mode 100644 crates/claude-connector/tests/serve_config.rs diff --git a/crates/claude-connector/src/usage_poll.rs b/crates/claude-connector/src/usage_poll.rs index e23a74f..ff61482 100644 --- a/crates/claude-connector/src/usage_poll.rs +++ b/crates/claude-connector/src/usage_poll.rs @@ -197,18 +197,69 @@ impl UsageClient { } /// A `Retry-After` value as a wait from `now_ms`: delay-seconds, or an -/// HTTP-date (RFC 9110 10.2.3) measured from now, a date already past being a -/// zero wait. `None` when it is neither. +/// HTTP-date (RFC 9110 10.2.3, all three forms) measured from now, a date +/// already past being a zero wait. `None` when it is neither. fn retry_after(value: &str, now_ms: u64) -> Option { let value = value.trim(); if let Ok(seconds) = value.parse::() { return Some(Duration::from_secs(seconds)); } - let date = chrono::DateTime::parse_from_rfc2822(value).ok()?; - let until = u64::try_from(date.timestamp_millis()).ok()?; + let until = http_date_ms(value, now_ms)?; Some(Duration::from_millis(until.saturating_sub(now_ms))) } +/// An HTTP-date as Unix milliseconds: IMF-fixdate (RFC 2822 reads it), the +/// obsolete RFC 850 form `Sunday, 06-Nov-94 08:49:37 GMT`, or asctime +/// `Sun Nov 6 08:49:37 1994`. Weekday names are not checked. +fn http_date_ms(value: &str, now_ms: u64) -> Option { + use chrono::Datelike; + if let Ok(date) = chrono::DateTime::parse_from_rfc2822(value) { + return u64::try_from(date.timestamp_millis()).ok(); + } + let fields: Vec<&str> = value.split_whitespace().collect(); + let (day, month, year, time) = match fields.as_slice() { + [weekday, date, time, "GMT"] if weekday.ends_with(',') => { + let mut parts = date.split('-'); + let (day, month, year) = (parts.next()?, parts.next()?, parts.next()?); + if parts.next().is_some() || year.len() != 2 { + return None; + } + // A two-digit year more than 50 years ahead is the latest past + // year with those digits. + let now_year = + chrono::DateTime::from_timestamp_millis(i64::try_from(now_ms).ok()?)?.year(); + let year = now_year - now_year.rem_euclid(100) + year.parse::().ok()?; + let year = if year > now_year + 50 { + year - 100 + } else { + year + }; + (day, month, year, *time) + } + [_, month, day, time, year] => (*day, *month, year.parse::().ok()?, *time), + _ => return None, + }; + const MONTHS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + let month = MONTHS + .iter() + .position(|name| name.eq_ignore_ascii_case(month))?; + let mut clock = time.split(':'); + let (hour, minute, second) = (clock.next()?, clock.next()?, clock.next()?); + if clock.next().is_some() { + return None; + } + let moment = + chrono::NaiveDate::from_ymd_opt(year, u32::try_from(month + 1).ok()?, day.parse().ok()?)? + .and_hms_opt( + hour.parse().ok()?, + minute.parse().ok()?, + second.parse().ok()?, + )?; + u64::try_from(moment.and_utc().timestamp_millis()).ok() +} + /// The schedule and the thing it drives. Time comes in as an argument so the /// schedule is testable without waiting. struct Poller { @@ -796,6 +847,39 @@ mod tests { assert_eq!(lines.lock().unwrap()[1], State::Polled.line()); } + /// 2026-11-01 07:28:00 UTC, a Sunday. + fn sunday_morning_ms() -> u64 { + chrono::NaiveDate::from_ymd_opt(2026, 11, 1) + .unwrap() + .and_hms_opt(7, 28, 0) + .unwrap() + .and_utc() + .timestamp_millis() as u64 + } + + #[test] + fn retry_after_reads_all_three_http_date_forms() { + let now = sunday_morning_ms(); + for form in [ + "Sun, 01 Nov 2026 08:28:00 GMT", + "Sunday, 01-Nov-26 08:28:00 GMT", + "Sun Nov 1 08:28:00 2026", + "Sun Nov 1 08:28:00 2026", + ] { + assert_eq!(retry_after(form, now), Some(secs(3600)), "{form}"); + } + } + + #[test] + fn a_two_digit_year_more_than_fifty_years_ahead_is_the_past() { + let now = sunday_morning_ms(); + let on = |yy: &str| retry_after(&format!("Friday, 01-Jan-{yy} 00:00:00 GMT"), now); + assert!(on("76").unwrap() > Duration::ZERO, "2076 is 50 years ahead"); + assert_eq!(on("77"), Some(Duration::ZERO), "1977, not 2077"); + assert!(on("33").unwrap() > Duration::ZERO, "2033"); + assert_eq!(on("94"), Some(Duration::ZERO), "1994"); + } + #[test] fn retry_after_reads_seconds_and_http_dates() { const DATE: &str = "Wed, 21 Oct 2026 07:28:00 GMT"; @@ -808,7 +892,21 @@ mod tests { assert_eq!(retry_after(DATE, at + 1), Some(Duration::ZERO), "past"); assert_eq!(retry_after("3600", at), Some(secs(3600))); assert_eq!(retry_after(" 3600 ", at), Some(secs(3600))); - for bad in ["", "soon", "-5", "3600.5", "1, 2", "Wed, 21 Oct 2026"] { + for bad in [ + "", + "soon", + "-5", + "3600.5", + "1, 2", + "Wed, 21 Oct 2026", + "Wednesday, 21-Oct-26-1 07:28:00 GMT", + "Wednesday, 21-Oct-2026 07:28:00 GMT", + "Wednesday, 21-Foo-26 07:28:00 GMT", + "Wednesday, 21-Oct-26 07:28 GMT", + "Wed Oct 21 07:28:00:01 2026", + "Wed Oct 32 07:28:00 2026", + "Wed Oct 21 25:28:00 2026", + ] { assert_eq!(retry_after(bad, at), None, "{bad:?}"); } } diff --git a/crates/claude-connector/tests/serve_config.rs b/crates/claude-connector/tests/serve_config.rs deleted file mode 100644 index 50a6279..0000000 --- a/crates/claude-connector/tests/serve_config.rs +++ /dev/null @@ -1,163 +0,0 @@ -//! `claude-connector serve --config` as a process: it binds, stays up, and -//! starts the usage poll beside the lease listener. Only a spawned binary -//! shows that `serve_config` calls the poll start at all; the poll's own -//! behaviour is tested against fake servers in `usage_poll`. -//! -//! The usage endpoint is a constant of the connector, so this test's poll -//! asks the real host with a made-up token and sees an answer or an -//! unreachable network; either way it logs a `usage poll:` state line, and -//! that line is what is asserted. -#![cfg(unix)] - -use std::io::{BufRead, BufReader}; -use std::os::unix::fs::PermissionsExt; -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; -use std::sync::mpsc; -use std::time::{Duration, Instant}; - -use sha2::{Digest, Sha256}; - -const TOKEN: &str = "sk-ant-oat01-CANARY-must-never-surface"; - -struct Scratch(PathBuf); - -impl Scratch { - fn new() -> Self { - let dir = - std::env::temp_dir().join(format!("claude-connector-serve-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - Self(dir) - } - - fn path(&self, name: &str) -> PathBuf { - self.0.join(name) - } -} - -impl Drop for Scratch { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} - -/// Kills the served process however the test ends. -struct Served(Child); - -impl Drop for Served { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|byte| format!("{byte:02x}")).collect() -} - -fn run(args: &[&str]) { - let made = Command::new(env!("CARGO_BIN_EXE_claude-connector")) - .args(args) - .env_clear() - .output() - .unwrap(); - assert!(made.status.success(), "{made:?}"); -} - -/// A document the connector can serve: a pinned stand-in for the claude -/// binary, a 0700 prompt directory, and the unit's credentials directory. -fn ready_document(scratch: &Scratch) -> (PathBuf, PathBuf) { - let path = |name: &str| scratch.path(name); - let text = |name: &str| path(name).to_string_lossy().into_owned(); - std::fs::write(path("claude"), b"not a real claude binary").unwrap(); - let pin = hex(&Sha256::digest(b"not a real claude binary")); - for dir in ["work", "home", "prompts", "credentials"] { - std::fs::create_dir(path(dir)).unwrap(); - } - std::fs::set_permissions(path("prompts"), std::fs::Permissions::from_mode(0o700)).unwrap(); - std::fs::write(path("caller.key"), "k".repeat(48)).unwrap(); - std::fs::write(path("credentials/claude-token"), TOKEN).unwrap(); - let config = text("connector.cc"); - run(&[ - "config", - "init", - &config, - "--listen", - "127.0.0.1:0", - "--replay-store", - &text("replay.cc"), - "--claude-binary", - &text("claude"), - "--claude-sha256", - &pin, - "--work-dir", - &text("work"), - "--config-dir", - &text("home"), - "--prompt-dir", - &text("prompts"), - "--instance", - "persona-a", - "--token-credential", - "claude-token", - "--handle-secret-credential", - "claude-handle-secret", - "--handle-secret-file", - &text("credentials/claude-handle-secret"), - "--caller-id", - "persona-a-caller", - "--connection-key-file", - &text("caller.key"), - "--connection-key-epoch", - "1", - "--allowed-model", - "claude-haiku-5-5", - ]); - (path("connector.cc"), path("credentials")) -} - -fn serve(config: &Path, credentials: &Path) -> (Served, mpsc::Receiver) { - let mut child = Command::new(env!("CARGO_BIN_EXE_claude-connector")) - .args(["serve", "--config"]) - .arg(config) - .env_clear() - .env("CREDENTIALS_DIRECTORY", credentials) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - let stderr = child.stderr.take().unwrap(); - let (lines, received) = mpsc::channel(); - std::thread::spawn(move || { - for line in BufReader::new(stderr).lines().map_while(Result::ok) { - if lines.send(line).is_err() { - return; - } - } - }); - (Served(child), received) -} - -#[test] -fn serve_config_starts_the_usage_poll_and_stays_up() { - let scratch = Scratch::new(); - let (config, credentials) = ready_document(&scratch); - let (mut served, lines) = serve(&config, &credentials); - - let deadline = Instant::now() + Duration::from_secs(60); - let mut seen = Vec::new(); - let poll_line = loop { - let left = deadline.saturating_duration_since(Instant::now()); - match lines.recv_timeout(left) { - Ok(line) if line.contains("usage poll:") => break line, - Ok(line) => seen.push(line), - Err(_) => panic!("no usage poll state line; the connector said {seen:?}"), - } - }; - assert!( - served.0.try_wait().unwrap().is_none(), - "the connector exited after starting the poll" - ); - assert!(!poll_line.contains("CANARY"), "{poll_line}"); -} From f242290ca595b34944f189839a498ba3e02dc443 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 03:17:08 +0100 Subject: [PATCH 08/11] usage-meter-poll: pin what the production client is built with (finding new-client-timeout-unpinned) UsageClient carries its timeout and url; UsageClient::production() is what start() builds, and the test asserts that client's url and 10 s timeout as spec r2 says, replacing a 1..=60 s bound no rule names. Co-Authored-By: Claude Sonnet 5.5 --- crates/claude-connector/src/usage_poll.rs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/claude-connector/src/usage_poll.rs b/crates/claude-connector/src/usage_poll.rs index ff61482..94b23ec 100644 --- a/crates/claude-connector/src/usage_poll.rs +++ b/crates/claude-connector/src/usage_poll.rs @@ -141,13 +141,20 @@ fn parse_usage(body: &str) -> Option> { /// The blocking HTTPS client: 10 s end to end, no redirects (the token must /// not follow one anywhere). -struct UsageClient { +pub(crate) struct UsageClient { agent: ureq::Agent, url: String, + timeout: Duration, } impl UsageClient { - fn new(url: impl Into) -> Self { + /// The client that asks the provider's usage endpoint. + pub(crate) fn production() -> Self { + Self::new(ENDPOINT) + } + + /// A client for another endpoint: a test's fake server. + pub(crate) fn new(url: impl Into) -> Self { Self::with_timeout(url, TIMEOUT) } @@ -158,6 +165,7 @@ impl UsageClient { .redirects(0) .build(), url: url.into(), + timeout, } } @@ -392,7 +400,7 @@ pub fn start( let poller = Poller::new( cell, tokens, - UsageClient::new(ENDPOINT), + UsageClient::production(), Box::new(|line| eprintln!("claude-connector: {line}")), ); std::thread::Builder::new() @@ -962,8 +970,11 @@ mod tests { } #[test] - fn the_real_timeout_frees_the_thread_within_a_minute() { - assert!(TIMEOUT >= secs(1) && TIMEOUT <= secs(60), "{TIMEOUT:?}"); + fn the_production_client_asks_the_provider_and_gives_up_after_ten_seconds() { + let client = UsageClient::production(); + assert_eq!(client.url, "https://api.anthropic.com/api/oauth/usage"); + assert_eq!(client.timeout, secs(10)); + assert_eq!(TIMEOUT, secs(10)); } /// Answers one connection after `delay`, as a slow endpoint does. From 8fb8b5a71df6ac93ff34a9a77302aa974528ba0b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 03:17:08 +0100 Subject: [PATCH 09/11] usage-meter-poll: the serve test asks a loopback fake, never the provider (finding serve-test-asks-real-provider) serve_config is the public wrapper over a crate-private serve_config_with that takes the usage client; the test runs it in-process against a fake and asserts the fake received GET /api/oauth/usage with the bearer. The endpoint is not a document field or env, so whoever writes the config cannot redirect the token. The subprocess test that reached api.anthropic.com is deleted. Co-Authored-By: Claude Sonnet 5.5 --- crates/claude-connector/src/daemon.rs | 102 ++++++++++++++++++++++ crates/claude-connector/src/usage_poll.rs | 5 +- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/crates/claude-connector/src/daemon.rs b/crates/claude-connector/src/daemon.rs index f344197..e54c8d1 100644 --- a/crates/claude-connector/src/daemon.rs +++ b/crates/claude-connector/src/daemon.rs @@ -22,6 +22,8 @@ use crate::backend::readiness; use crate::lease::{LeaseCaller, LeaseLimits, serve_leases, user_uid}; #[cfg(target_os = "linux")] use crate::unit::SystemdUnits; +#[cfg(unix)] +use crate::usage_poll::UsageClient; use crate::{ ClaudeCodeBackend, ClaudeCodeConfig, ClaudeCodeLimits, CredentialTokenSource, StderrSink, }; @@ -535,6 +537,13 @@ pub fn check_ready(config: &ClaudeConnectorConfig) -> Result<(), ConfigError> { /// Loads the document and serves until the listener fails. #[cfg(unix)] pub fn serve_config(path: &Path) -> Result<(), ConfigError> { + serve_config_with(path, UsageClient::production()) +} + +/// [`serve_config`] with the usage poll asking `usage`: the endpoint is not a +/// setting of the document, so a test reaches it only through here. +#[cfg(unix)] +fn serve_config_with(path: &Path, usage: UsageClient) -> Result<(), ConfigError> { let config = load_config(path)?; let admissions = readiness_of(&config)?; let listen = config.validate()?; @@ -561,6 +570,7 @@ pub fn serve_config(path: &Path) -> Result<(), ConfigError> { Box::new(CredentialTokenSource::from_environment( config.token_credential.clone(), )), + usage, ) .map_err(|_| ConfigError::Serving("usage poll"))?; serve_service(listener, config.serve_limits(), service, backend)?; @@ -571,6 +581,8 @@ pub fn serve_config(path: &Path) -> Result<(), ConfigError> { mod tests { use std::sync::{Arc, Mutex}; + use sha2::Digest; + use super::*; /// A log that a test can read back. @@ -587,6 +599,96 @@ mod tests { } } + /// `serve` starts the usage poll beside the listener, and the poll asks + /// where it was told: a fake on loopback receives its request, bearer + /// token and all, and nothing reaches the provider. + #[cfg(unix)] + #[test] + fn serve_config_starts_the_usage_poll_and_stays_up() { + use std::io::{Read, Write}; + use std::os::unix::fs::PermissionsExt; + + const TOKEN: &str = "sk-ant-oat01-fake-token-for-the-serve-test"; + let dir = + std::env::temp_dir().join(format!("claude-connector-serve-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let at = |name: &str| dir.join(name); + for sub in ["work", "home", "prompts", "credentials"] { + std::fs::create_dir_all(at(sub)).unwrap(); + } + std::fs::set_permissions(at("prompts"), std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::write(at("claude"), b"not a real claude binary").unwrap(); + std::fs::write(at("caller.key"), "k".repeat(48)).unwrap(); + std::fs::write(at("credentials/claude-token"), TOKEN).unwrap(); + std::fs::write(at("credentials/claude-handle-secret"), "s".repeat(48)).unwrap(); + let config = ClaudeConnectorConfig { + listen: "127.0.0.1:0".to_string(), + replay_store: at("replay.cc"), + claude_binary: at("claude"), + claude_binary_sha256: sha2::Sha256::digest(b"not a real claude binary").into(), + work_dir: at("work"), + config_dir: at("home"), + prompt_dir: at("prompts"), + instance: "persona-a".to_string(), + token_credential: "claude-token".to_string(), + handle_secret_credential: "claude-handle-secret".to_string(), + deadline_ms: 600_000, + max_line_bytes: 4 << 20, + max_total_bytes: 64 << 20, + stderr_tail_bytes: 8192, + max_frame_bytes: 2 << 20, + max_connections: 8, + socket_timeout_ms: 10_000, + max_expiry_skew_ms: 300_000, + callers: vec![CallerConfig { + caller_runtime_id: "persona-a-caller".to_string(), + connection_key_file: at("caller.key"), + connection_key_epoch: 1, + allowed_models: vec!["claude-haiku-5-5".to_string()], + max_concurrent_requests: 4, + max_payload_bytes: 1 << 20, + max_output_tokens: 32_768, + }], + lease_socket: None, + lease_callers: Vec::new(), + lease_max_ms: 3_600_000, + }; + write_config(&at("connector.cc"), &config).unwrap(); + // SAFETY: set once, to a directory only this test reads, before the + // serve thread it starts. + unsafe { std::env::set_var("CREDENTIALS_DIRECTORY", at("credentials")) }; + + let fake = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/api/oauth/usage", fake.local_addr().unwrap()); + let (asked, request) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let (mut stream, _) = fake.accept().unwrap(); + let mut text = vec![0u8; 4096]; + let n = stream.read(&mut text).unwrap(); + let _ = asked.send(String::from_utf8_lossy(&text[..n]).into_owned()); + let _ = stream.write_all( + b"HTTP/1.1 429 Too Many Requests +Content-Length: 0 +Connection: close + +", + ); + }); + let path = at("connector.cc"); + let served = std::thread::spawn(move || serve_config_with(&path, UsageClient::new(url))); + + let request = request + .recv_timeout(Duration::from_secs(60)) + .expect("the usage poll never asked the fake endpoint"); + assert!(request.starts_with("GET /api/oauth/usage "), "{request}"); + assert!(request.contains(&format!("Bearer {TOKEN}")), "{request}"); + assert!( + !served.is_finished(), + "serve returned after starting the poll" + ); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn serve_errors_name_a_field_or_stage_and_never_a_stored_value() { const CANARY: &str = "canary-path-8e2a"; diff --git a/crates/claude-connector/src/usage_poll.rs b/crates/claude-connector/src/usage_poll.rs index 94b23ec..2021cca 100644 --- a/crates/claude-connector/src/usage_poll.rs +++ b/crates/claude-connector/src/usage_poll.rs @@ -393,14 +393,15 @@ fn unix_ms() -> u64 { /// Starts the poll thread. It reads the token afresh at each poll, writes /// only to `cell`, and logs one line to stderr when its state changes. -pub fn start( +pub(crate) fn start( cell: Arc, tokens: Box, + client: UsageClient, ) -> std::io::Result> { let poller = Poller::new( cell, tokens, - UsageClient::production(), + client, Box::new(|line| eprintln!("claude-connector: {line}")), ); std::thread::Builder::new() From 7d4c1bf56e4f05da9d9e1ca761e9a9aada650479 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 03:30:55 +0100 Subject: [PATCH 10/11] usage-meter-poll: gate fixes: the timeout is read per request, serve has a refusal test, the RFC 850 guard is pinned The timeout field was read only by a test (dead_code under -D warnings), so ask() applies it to each request. The serve wrapper is reached by a subprocess refusal test (no network); the serve test fails inside 30 s instead of hanging; "Wednesday 21-Oct-26 ..." (no comma) is refused. Co-Authored-By: Claude Sonnet 5.5 --- crates/claude-connector/src/daemon.rs | 2 +- crates/claude-connector/src/usage_poll.rs | 7 +++---- crates/claude-connector/tests/config_document.rs | 9 +++++++++ 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/claude-connector/src/daemon.rs b/crates/claude-connector/src/daemon.rs index e54c8d1..f7ef82f 100644 --- a/crates/claude-connector/src/daemon.rs +++ b/crates/claude-connector/src/daemon.rs @@ -678,7 +678,7 @@ Connection: close let served = std::thread::spawn(move || serve_config_with(&path, UsageClient::new(url))); let request = request - .recv_timeout(Duration::from_secs(60)) + .recv_timeout(Duration::from_secs(30)) .expect("the usage poll never asked the fake endpoint"); assert!(request.starts_with("GET /api/oauth/usage "), "{request}"); assert!(request.contains(&format!("Bearer {TOKEN}")), "{request}"); diff --git a/crates/claude-connector/src/usage_poll.rs b/crates/claude-connector/src/usage_poll.rs index 2021cca..f5e4578 100644 --- a/crates/claude-connector/src/usage_poll.rs +++ b/crates/claude-connector/src/usage_poll.rs @@ -160,10 +160,7 @@ impl UsageClient { fn with_timeout(url: impl Into, timeout: Duration) -> Self { Self { - agent: ureq::AgentBuilder::new() - .timeout(timeout) - .redirects(0) - .build(), + agent: ureq::AgentBuilder::new().redirects(0).build(), url: url.into(), timeout, } @@ -177,6 +174,7 @@ impl UsageClient { let sent = self .agent .get(&self.url) + .timeout(self.timeout) .set("Authorization", bearer.as_str()) .set("anthropic-beta", "oauth-2025-04-20") .call(); @@ -911,6 +909,7 @@ mod tests { "Wednesday, 21-Oct-26-1 07:28:00 GMT", "Wednesday, 21-Oct-2026 07:28:00 GMT", "Wednesday, 21-Foo-26 07:28:00 GMT", + "Wednesday 21-Oct-26 07:28:00 GMT", "Wednesday, 21-Oct-26 07:28 GMT", "Wed Oct 21 07:28:00:01 2026", "Wed Oct 32 07:28:00 2026", diff --git a/crates/claude-connector/tests/config_document.rs b/crates/claude-connector/tests/config_document.rs index d2af150..65c7b5d 100644 --- a/crates/claude-connector/tests/config_document.rs +++ b/crates/claude-connector/tests/config_document.rs @@ -286,3 +286,12 @@ fn the_dollar_flags_are_not_flags() { ); } } + +#[test] +fn serve_refuses_a_document_that_is_not_there() { + let scratch = Scratch::new(); + let missing = scratch.path("missing.cc"); + let served = run(&["serve", "--config", missing.to_str().unwrap()]); + assert!(!served.status.success(), "{served:?}"); + assert!(!String::from_utf8_lossy(&served.stderr).is_empty()); +} From 549962cb8a6b6d5c7ee567a9309432a1f4cf6630 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 3 Nov 2026 13:12:31 +0100 Subject: [PATCH 11/11] quota meter: the weekly reading comes from the CLI's own rate-limit event; while a lease is live and the reading is stale the connector makes the smallest real call. The usage poll and its HTTP client go: the setup token can never reach that endpoint (squash of eureka-body/quota-meter-stream 7d4c1bf..c298d9d; Soul s4-s5) Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 377 ------ crates/claude-connector/Cargo.toml | 1 - crates/claude-connector/src/backend.rs | 34 +- crates/claude-connector/src/backend/tests.rs | 54 +- crates/claude-connector/src/daemon.rs | 107 +- crates/claude-connector/src/launch.rs | 9 +- crates/claude-connector/src/lease.rs | 14 +- crates/claude-connector/src/lib.rs | 2 +- crates/claude-connector/src/meter.rs | 543 +++++++++ crates/claude-connector/src/quota.rs | 294 ++++- crates/claude-connector/src/stream.rs | 53 +- crates/claude-connector/src/usage_poll.rs | 1077 ----------------- .../fixtures/claude-2.1.312-meter-call.jsonl | 4 + 13 files changed, 988 insertions(+), 1581 deletions(-) create mode 100644 crates/claude-connector/src/meter.rs delete mode 100644 crates/claude-connector/src/usage_poll.rs create mode 100644 crates/claude-connector/tests/fixtures/claude-2.1.312-meter-call.jsonl diff --git a/Cargo.lock b/Cargo.lock index a1da85a..3709c14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -150,7 +150,6 @@ dependencies = [ "serde_json", "sha2", "thiserror", - "ureq", "zeroize", ] @@ -298,17 +297,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "displaydoc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.4", -] - [[package]] name = "dyn-clone" version = "1.0.20" @@ -351,15 +339,6 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - [[package]] name = "fs2" version = "0.4.3" @@ -481,110 +460,6 @@ dependencies = [ "cc", ] -[[package]] -name = "icu_collections" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" - -[[package]] -name = "icu_properties" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" - -[[package]] -name = "icu_provider" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - [[package]] name = "inout" version = "0.1.4" @@ -617,12 +492,6 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" -[[package]] -name = "litemap" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" - [[package]] name = "log" version = "0.4.34" @@ -686,12 +555,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -720,15 +583,6 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "potential_utf" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" -dependencies = [ - "zerovec", -] - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -845,20 +699,6 @@ dependencies = [ "syn 3.0.4", ] -[[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 = "rmp" version = "0.8.15" @@ -898,41 +738,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustls" -version = "0.23.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" -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.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" version = "1.0.23" @@ -1077,12 +882,6 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" -[[package]] -name = "smallvec" -version = "1.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" - [[package]] name = "socket2" version = "0.5.10" @@ -1103,12 +902,6 @@ dependencies = [ "der", ] -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - [[package]] name = "stacker" version = "0.1.25" @@ -1150,17 +943,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "synstructure" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.4", -] - [[package]] name = "thiserror" version = "2.0.20" @@ -1181,16 +963,6 @@ dependencies = [ "syn 3.0.4", ] -[[package]] -name = "tinystr" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" -dependencies = [ - "displaydoc", - "zerovec", -] - [[package]] name = "typenum" version = "1.20.1" @@ -1213,54 +985,6 @@ 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 = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" -dependencies = [ - "base64", - "log", - "once_cell", - "rustls", - "rustls-pki-types", - "url", - "webpki-roots 0.26.11", -] - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" -dependencies = [ - "utf_types", -] - -[[package]] -name = "utf_types" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" - [[package]] name = "uuid" version = "1.25.0" @@ -1338,24 +1062,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.11", -] - -[[package]] -name = "webpki-roots" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "winapi" version = "0.3.9" @@ -1534,35 +1240,6 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "writeable" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" - -[[package]] -name = "yoke" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.4", - "synstructure", -] - [[package]] name = "zerocopy" version = "0.8.63" @@ -1583,66 +1260,12 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.4", - "synstructure", -] - [[package]] name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" -[[package]] -name = "zerotrie" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.4", -] - [[package]] name = "zmij" version = "1.0.23" diff --git a/crates/claude-connector/Cargo.toml b/crates/claude-connector/Cargo.toml index bc28e9f..6cb9e45 100644 --- a/crates/claude-connector/Cargo.toml +++ b/crates/claude-connector/Cargo.toml @@ -22,7 +22,6 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" thiserror = "2" -ureq = { version = "2", default-features = false, features = ["tls"] } zeroize = "1" [target.'cfg(unix)'.dependencies] diff --git a/crates/claude-connector/src/backend.rs b/crates/claude-connector/src/backend.rs index 4833521..0bc2d77 100644 --- a/crates/claude-connector/src/backend.rs +++ b/crates/claude-connector/src/backend.rs @@ -20,6 +20,12 @@ use crate::process::{ProcessPort, RunningChild, StdoutRead}; use crate::quota::QuotaCell; use crate::stream::{Failure, Finished, Step, StreamState}; +/// The meter's call: the cheapest model, a trivial prompt, and an answer cap +/// that only bounds a runaway. +const METER_MODEL: &str = "claude-haiku-5-5"; +const METER_CALLER: &str = "meter"; +const METER_MAX_OUTPUT_TOKENS: u32 = 16; + /// Where the child's credential comes from, read afresh at every use. The /// connector's own environment is never a source (ruling /// claude-connector-token-credential). @@ -156,16 +162,40 @@ impl ClaudeCodeBackend { self.quota.clone() } + /// The meter's call: the smallest real one, through the same child, + /// stream and cell as any other. The stream's `rate_limit_event` is what + /// the caller wants; this returns what the call billed, or the failure's + /// kind (never its message). + pub(crate) fn read_meter(&self) -> Result { + let mut request = ProviderRequest::new( + METER_CALLER, + METER_CALLER, + METER_MODEL, + "Reply with the single word ok.", + METER_MAX_OUTPUT_TOKENS, + ); + request.input = vec![InputItem::UserText { + text: "ok?".to_string(), + }]; + match self.run(&request, METER_CALLER, true).1 { + TransportOutcome::Completed { usage, .. } => Ok(usage), + TransportOutcome::Failed { kind, .. } => Err(format!("{kind:?}")), + } + } + /// The one pin check: `ready` and every spawn use it, and nothing caches /// its answer. fn binary_matches_pin(&self) -> bool { binary_matches_pin(&self.config) } + /// `ephemeral` leaves no transcript behind: the meter keeps no handle and + /// resumes nothing, every other call is resumed by its session. fn run( &self, request: &ProviderRequest, caller: &str, + ephemeral: bool, ) -> (Vec, TransportOutcome) { let Some(secret) = usable_secret(self.handle_secret.as_ref()) else { return failed( @@ -209,7 +239,7 @@ impl ClaudeCodeBackend { "claude-code: the system prompt could not be staged", )); }; - let argv = argv(&self.config, request, &plan, prompt.path()); + let argv = argv(&self.config, request, &plan, prompt.path(), ephemeral); let env = child_env(&self.config, request); let deadline = Instant::now() + self.config.limits.deadline; @@ -323,7 +353,7 @@ impl ProviderBackend for ClaudeCodeBackend { } fn execute(&self, call: &ProviderCall) -> TransportResult { - let (events, outcome) = self.run(call.request(), call.caller_runtime_id()); + let (events, outcome) = self.run(call.request(), call.caller_runtime_id(), false); call.transported(events, outcome) } diff --git a/crates/claude-connector/src/backend/tests.rs b/crates/claude-connector/src/backend/tests.rs index 61ee59d..ecd98fa 100644 --- a/crates/claude-connector/src/backend/tests.rs +++ b/crates/claude-connector/src/backend/tests.rs @@ -2160,7 +2160,7 @@ fn rate_limit_event_updates_quota_cell() { #[test] fn a_rejected_turn_still_reports_its_windows_and_a_bad_window_costs_only_itself() { - let event = r#"{"type":"rate_limit_event","rate_limit_info":{"status":"rejected","unifiedWindows":{"five_hour":{"utilization":0.9996,"resetsAt":4102444800,"status":"rejected"},"seven_day":{"resetsAt":4102531200,"status":"allowed"},"idle":{"utilization":0,"resetsAt":4102444800,"status":"allowed"},"odd":{"utilization":"high","resetsAt":1,"status":"allowed"},"negative":{"utilization":-0.5,"resetsAt":1,"status":"allowed"}}}}"#; + let event = r#"{"type":"rate_limit_event","rate_limit_info":{"status":"rejected","unifiedWindows":{"five_hour":{"utilization":0.9996,"resetsAt":4102444800,"status":"rejected"},"seven_day":{"utilization":0,"resetsAt":4102531200,"status":"allowed"},"seven_day_overage_included":{"utilization":1,"resetsAt":4102531200,"status":"rejected"},"idle":{"utilization":0,"resetsAt":4102444800,"status":"allowed"}}}}"#; let rig = rig(vec![Script::lines(vec![ init_line(MODEL, SESSION), event.to_string(), @@ -2170,12 +2170,12 @@ fn a_rejected_turn_still_reports_its_windows_and_a_bad_window_costs_only_itself( assert_eq!(kind, FailureKind::RateLimited); let mut rejected = window("five_hour", 1000, 4_102_444_800_000); rejected.status = "rejected".to_string(); - // Unknown is never zero: a window missing a field or of another type is - // skipped, not read as empty. - // A window at exactly zero use is a reading, and is kept. + // A window at exactly zero use is a reading, and is kept. Only the two + // windows pacing reads are taken: a per-model bucket that reads rejected + // must not close every lease, and no other name is a window of the plan. assert_eq!( readings(&rig), - vec![rejected, window("idle", 0, 4_102_444_800_000)] + vec![rejected, window("seven_day", 0, 4_102_531_200_000)] ); } @@ -2746,3 +2746,47 @@ fn a_turn_after_a_schema_turn_is_seeded_not_resumed() { assert!(!argv.iter().any(|a| a == "--resume"), "{argv:?}"); assert!(!argv.iter().any(|a| a == "--json-schema")); } + +// ---- the meter's call ---- + +/// One real call of the pinned CLI (2.1.312), captured on Yggdrasil on +/// 2026-11-03 with the connector's own argv: haiku, no tools, the trivial +/// prompt. Its `rate_limit_event` names only the week. +const METER_CALL: &str = include_str!("../../tests/fixtures/claude-2.1.312-meter-call.jsonl"); + +#[test] +fn the_meter_call_reads_the_week_from_a_real_captured_event() { + let rig = rig(vec![Script::lines( + METER_CALL.lines().map(str::to_string).collect(), + )]); + let usage = rig + .backend + .read_meter() + .expect("the captured call completes"); + assert_eq!((usage.input_tokens, usage.output_tokens), (384, 4)); + // 0.06 of the week used; it resets at 1794261600 (unix s). + assert_eq!( + readings(&rig), + vec![window("seven_day", 60, 1_794_261_600_000)] + ); + let argv = rig.seen().argv; + let after = |flag: &str| argv[argv.iter().position(|a| a == flag).unwrap() + 1].clone(); + assert_eq!(after("--model"), METER_MODEL); + assert_eq!(after("--tools"), "", "the call can use no tool"); + assert!( + argv.iter().any(|a| a == "--no-session-persistence"), + "the meter's transcript is nobody's to keep: {argv:?}" + ); + assert_eq!(rig.spawns(), 1, "one child, never run again"); +} + +#[test] +fn a_failed_meter_call_names_its_kind_and_leaves_the_cell_alone() { + let rig = rig(vec![Script::lines(vec![ + init_line(MODEL, SESSION), + error_result_line(r#","api_error_status":500"#), + ])]); + let failed = rig.backend.read_meter().expect_err("the call fails"); + assert!(!failed.is_empty() && !failed.contains(' '), "{failed}"); + assert!(quota_of(&rig).windows.is_empty()); +} diff --git a/crates/claude-connector/src/daemon.rs b/crates/claude-connector/src/daemon.rs index f7ef82f..e64e473 100644 --- a/crates/claude-connector/src/daemon.rs +++ b/crates/claude-connector/src/daemon.rs @@ -23,7 +23,7 @@ use crate::lease::{LeaseCaller, LeaseLimits, serve_leases, user_uid}; #[cfg(target_os = "linux")] use crate::unit::SystemdUnits; #[cfg(unix)] -use crate::usage_poll::UsageClient; +use crate::meter; use crate::{ ClaudeCodeBackend, ClaudeCodeConfig, ClaudeCodeLimits, CredentialTokenSource, StderrSink, }; @@ -537,13 +537,16 @@ pub fn check_ready(config: &ClaudeConnectorConfig) -> Result<(), ConfigError> { /// Loads the document and serves until the listener fails. #[cfg(unix)] pub fn serve_config(path: &Path) -> Result<(), ConfigError> { - serve_config_with(path, UsageClient::production()) + serve_config_with(path, |_| ()) } -/// [`serve_config`] with the usage poll asking `usage`: the endpoint is not a -/// setting of the document, so a test reaches it only through here. +/// [`serve_config`] handing the quota cell to `started` once the meter runs: +/// a test stands in for the swarm's lease requests only through here. #[cfg(unix)] -fn serve_config_with(path: &Path, usage: UsageClient) -> Result<(), ConfigError> { +fn serve_config_with( + path: &Path, + started: impl FnOnce(&Arc), +) -> Result<(), ConfigError> { let config = load_config(path)?; let admissions = readiness_of(&config)?; let listen = config.validate()?; @@ -561,18 +564,16 @@ fn serve_config_with(path: &Path, usage: UsageClient) -> Result<(), ConfigError> ) .map_err(|error| ConfigError::from(ServeError::Service(error)))?, )); + // The meter is running before the first lease can ask, so that lease can + // wait for its first read. + let quota = backend.quota(); + meter::start(quota.clone(), Box::new(backend.clone())) + .map_err(|_| ConfigError::Serving("meter"))?; + started("a); #[cfg(target_os = "linux")] if let Some(socket) = &config.lease_socket { - start_leases(&config, socket, service.clone(), backend.quota())?; + start_leases(&config, socket, service.clone(), quota.clone())?; } - crate::usage_poll::start( - backend.quota(), - Box::new(CredentialTokenSource::from_environment( - config.token_credential.clone(), - )), - usage, - ) - .map_err(|_| ConfigError::Serving("usage poll"))?; serve_service(listener, config.serve_limits(), service, backend)?; Ok(()) } @@ -599,16 +600,16 @@ mod tests { } } - /// `serve` starts the usage poll beside the listener, and the poll asks - /// where it was told: a fake on loopback receives its request, bearer - /// token and all, and nothing reaches the provider. + /// `serve` starts the meter beside the listener, wired to the backend's own + /// cell: it makes no call while nobody asks, and once a lease is asked for + /// it runs the claude binary and serve stays up. The binary is a script + /// that replays a real captured call. #[cfg(unix)] #[test] - fn serve_config_starts_the_usage_poll_and_stays_up() { - use std::io::{Read, Write}; + fn serve_config_starts_the_meter_and_stays_up() { use std::os::unix::fs::PermissionsExt; - const TOKEN: &str = "sk-ant-oat01-fake-token-for-the-serve-test"; + const CALL: &str = include_str!("../tests/fixtures/claude-2.1.312-meter-call.jsonl"); let dir = std::env::temp_dir().join(format!("claude-connector-serve-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); @@ -617,15 +618,22 @@ mod tests { std::fs::create_dir_all(at(sub)).unwrap(); } std::fs::set_permissions(at("prompts"), std::fs::Permissions::from_mode(0o700)).unwrap(); - std::fs::write(at("claude"), b"not a real claude binary").unwrap(); + std::fs::write(at("call.jsonl"), CALL).unwrap(); + let script = format!( + "#!/bin/sh\ncat > /dev/null\necho spawned >> '{}'\ncat '{}'\n", + at("spawned").display(), + at("call.jsonl").display() + ); + std::fs::write(at("claude"), &script).unwrap(); + std::fs::set_permissions(at("claude"), std::fs::Permissions::from_mode(0o755)).unwrap(); std::fs::write(at("caller.key"), "k".repeat(48)).unwrap(); - std::fs::write(at("credentials/claude-token"), TOKEN).unwrap(); + std::fs::write(at("credentials/claude-token"), "sk-ant-oat01-fake-token").unwrap(); std::fs::write(at("credentials/claude-handle-secret"), "s".repeat(48)).unwrap(); let config = ClaudeConnectorConfig { listen: "127.0.0.1:0".to_string(), replay_store: at("replay.cc"), claude_binary: at("claude"), - claude_binary_sha256: sha2::Sha256::digest(b"not a real claude binary").into(), + claude_binary_sha256: sha2::Sha256::digest(script.as_bytes()).into(), work_dir: at("work"), config_dir: at("home"), prompt_dir: at("prompts"), @@ -658,34 +666,35 @@ mod tests { // serve thread it starts. unsafe { std::env::set_var("CREDENTIALS_DIRECTORY", at("credentials")) }; - let fake = TcpListener::bind("127.0.0.1:0").unwrap(); - let url = format!("http://{}/api/oauth/usage", fake.local_addr().unwrap()); - let (asked, request) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let (mut stream, _) = fake.accept().unwrap(); - let mut text = vec![0u8; 4096]; - let n = stream.read(&mut text).unwrap(); - let _ = asked.send(String::from_utf8_lossy(&text[..n]).into_owned()); - let _ = stream.write_all( - b"HTTP/1.1 429 Too Many Requests -Content-Length: 0 -Connection: close - -", - ); - }); let path = at("connector.cc"); - let served = std::thread::spawn(move || serve_config_with(&path, UsageClient::new(url))); + let lease = std::sync::Arc::new(std::sync::Mutex::new(None)); + let swarm = lease.clone(); + let served = std::thread::spawn(move || { + serve_config_with(&path, |cell| { + *swarm.lock().unwrap() = Some(cell.clone()); + }) + }); - let request = request - .recv_timeout(Duration::from_secs(30)) - .expect("the usage poll never asked the fake endpoint"); - assert!(request.starts_with("GET /api/oauth/usage "), "{request}"); - assert!(request.contains(&format!("Bearer {TOKEN}")), "{request}"); - assert!( - !served.is_finished(), - "serve returned after starting the poll" - ); + let deadline = std::time::Instant::now() + Duration::from_secs(30); + let cell = loop { + if let Some(cell) = lease.lock().unwrap().clone() { + break cell; + } + assert!(std::time::Instant::now() < deadline, "serve never started"); + std::thread::sleep(Duration::from_millis(20)); + }; + std::thread::sleep(Duration::from_millis(500)); + assert!(!at("spawned").exists(), "a quiet swarm spends nothing"); + let _lease = cell.asking(); + + while !at("spawned").exists() { + assert!( + std::time::Instant::now() < deadline, + "the meter never ran the claude binary" + ); + std::thread::sleep(Duration::from_millis(50)); + } + assert!(!served.is_finished(), "serve returned after starting the meter"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/claude-connector/src/launch.rs b/crates/claude-connector/src/launch.rs index b233c09..1f8dbd0 100644 --- a/crates/claude-connector/src/launch.rs +++ b/crates/claude-connector/src/launch.rs @@ -107,13 +107,15 @@ fn effort(effort: ReasoningEffort) -> &'static str { /// rather than an argument because one argument is capped near 128 KiB /// (E2BIG) and instructions are not, the child has /// no tools, no MCP servers, no settings, no slash commands and nobody to ask -/// for permission, and a resume always forks. A request's output schema goes -/// to the CLI as `--json-schema`; without one the flag is absent. +/// for permission, and a resume always forks. An ephemeral call (the +/// meter's) keeps no session on disk. A request's output schema goes to the +/// CLI as `--json-schema`; without one the flag is absent. pub(crate) fn argv( config: &ClaudeCodeConfig, request: &ProviderRequest, plan: &Plan, system_prompt_file: &Path, + ephemeral: bool, ) -> Vec { let mut argv: Vec = [ config.claude_binary.to_string_lossy().as_ref(), @@ -139,6 +141,9 @@ pub(crate) fn argv( .into_iter() .map(str::to_string) .collect(); + if ephemeral { + argv.push("--no-session-persistence".to_string()); + } if let Some(level) = request.reasoning_effort { argv.extend(["--effort".to_string(), effort(level).to_string()]); } diff --git a/crates/claude-connector/src/lease.rs b/crates/claude-connector/src/lease.rs index 0d4a582..7e503aa 100644 --- a/crates/claude-connector/src/lease.rs +++ b/crates/claude-connector/src/lease.rs @@ -468,8 +468,14 @@ impl Lender { ); } // The lease is claimed before the token is read, let alone lent. The - // plan the refusal reads is the one this process last observed. - let plan = self.quota.snapshot(); + // plan the refusal reads is the one this process last observed, but a + // cell with no weekly reading (a restart empties it) gets the meter's + // first read, bounded, before the lease is lent "unobserved" for its + // whole life. The request is the swarm's sign of life, however it + // ends: the meter reads while one is held or lately asked for. + let _asking = self.quota.asking(); + let plan = self.quota.snapshot_with_week(FIRST_READ_WAIT); + let now = unix_ms(); let admission = self .service .lock() @@ -603,6 +609,10 @@ impl Lender { } } +/// The longest a lease waits for the meter's first read of the week. A call +/// takes a few seconds; the holder's own wait for the grant is far longer. +const FIRST_READ_WAIT: Duration = Duration::from_secs(10); + /// How often a held lease looks again at its holder's unit invocation. const HOLD_POLL_MS: u64 = 500; diff --git a/crates/claude-connector/src/lib.rs b/crates/claude-connector/src/lib.rs index 1b01d30..671ee36 100644 --- a/crates/claude-connector/src/lib.rs +++ b/crates/claude-connector/src/lib.rs @@ -10,12 +10,12 @@ mod handle; mod launch; #[cfg(target_os = "linux")] mod lease; +mod meter; mod process; mod quota; mod stream; #[cfg(target_os = "linux")] mod unit; -mod usage_poll; pub use backend::{ClaudeCodeBackend, CredentialTokenSource, StderrSink, TokenSource}; pub use config::{ClaudeCodeConfig, ClaudeCodeLimits}; diff --git a/crates/claude-connector/src/meter.rs b/crates/claude-connector/src/meter.rs new file mode 100644 index 0000000..2f572a2 --- /dev/null +++ b/crates/claude-connector/src/meter.rs @@ -0,0 +1,543 @@ +//! The meter: keeps the plan's weekly reading fresh while the swarm is +//! working. The subscription reports its windows only inside the CLI's +//! `rate_limit_event`, on a call the CLI makes, and Personas make theirs under +//! leases that report nothing back. So when the `seven_day` reading in the +//! [`QuotaCell`] is `STALE` old, the meter makes the smallest real call +//! through the backend's own path (one `claude -p` on the cheapest model with +//! a trivial prompt) and the stream fills the cell exactly as it does for any +//! call. The meter holds no credential, speaks no protocol and writes nothing +//! itself. +//! +//! The meter is driven by the connector's own lease state. It reads only +//! while a lease is held or one was asked for less than `STALE` ago: a held +//! swarm brake stops the units, they ask for nothing, and a braked or quiet +//! swarm spends nothing. A call that fails, or completes without refreshing +//! the reading, is not retried at the staleness period: the wait doubles per +//! consecutive miss, up to `CEILING`. + +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use model_connector::Usage; + +use crate::backend::ClaudeCodeBackend; +use crate::quota::{EXPIRED, QuotaCell, WEEK}; + +/// A reading this old, or older, is stale; also the least gap between calls. +const STALE: Duration = Duration::from_secs(15 * 60); +/// The longest any backoff may wait; also how long an idle meter sleeps +/// before looking again, since a lease request wakes it. +const CEILING: Duration = Duration::from_secs(6 * 60 * 60); + +/// The smallest real call, as the backend makes it. +pub(crate) trait Probe: Send { + /// What the call billed, or the failure's kind. Never a message: a + /// provider's words do not enter the meter's log. + fn call(&self) -> Result; +} + +impl Probe for Arc { + fn call(&self) -> Result { + self.read_meter() + } +} + +/// Why the meter is waiting; logged once per change. +#[derive(Debug, Clone, PartialEq, Eq)] +enum State { + Read, + Idle, + /// The call failed; the kind is the failure's kind, never its message. + Failed(String), + Unread, +} + +impl State { + fn line(&self) -> String { + match self { + Self::Read => "meter: weekly reading refreshed".to_string(), + Self::Idle => "meter: no lease asked lately, making no call".to_string(), + Self::Failed(kind) => format!("meter: the call failed ({kind}), backing off"), + Self::Unread => { + "meter: the call completed without a weekly reading, backing off".to_string() + } + } + } +} + +struct Meter { + cell: Arc, + probe: Box, + log: Box, + /// No call before this instant, unix ms. + not_before_ms: u64, + /// Consecutive calls that left the reading unrefreshed. + misses: u32, + last_state: Option, +} + +impl Meter { + fn new(cell: Arc, probe: Box, log: Box) -> Self { + Self { + cell, + probe, + log, + not_before_ms: 0, + misses: 0, + last_state: None, + } + } + + /// Makes the call if one is due at `now_ms`; returns how long to wait + /// before looking again. Nothing is due while no lease is held or lately + /// asked for, nor before the last miss's backoff has passed, nor while the + /// weekly reading is younger than `STALE`. + fn tick(&mut self, now_ms: u64) -> Duration { + if !self.cell.wanted_at(now_ms, STALE) { + self.report(State::Idle); + return CEILING; + } + let due_at = self.due_at_ms(); + if now_ms < due_at { + return Duration::from_millis(due_at - now_ms); + } + // A failed call may still have refreshed the reading: a rejected turn + // reports its windows and then errors. Only the cell says whether the + // week was read. + let called = self.probe.call(); + let state = if self.refreshed_since(now_ms) { + self.misses = 0; + State::Read + } else { + self.misses = self.misses.saturating_add(1); + match called { + Ok(_) => State::Unread, + Err(kind) => State::Failed(kind), + } + }; + self.report(state); + self.not_before_ms = now_ms.saturating_add(duration_ms(backoff(self.misses))); + Duration::from_millis(self.due_at_ms().saturating_sub(now_ms)) + } + + /// The earliest instant the next call may happen. A window past its reset + /// described a week that is over, so it is due at once. + fn due_at_ms(&self) -> u64 { + let stale_at = self + .cell + .snapshot() + .windows + .iter() + .find(|quota| quota.window == WEEK) + .map_or(0, |quota| { + if quota.status == EXPIRED { + 0 + } else { + quota.observed_at_unix_ms.saturating_add(duration_ms(STALE)) + } + }); + stale_at.max(self.not_before_ms) + } + + fn refreshed_since(&self, since_ms: u64) -> bool { + self.cell + .snapshot() + .windows + .iter() + .any(|quota| quota.window == WEEK && quota.observed_at_unix_ms >= since_ms) + } + + fn report(&mut self, state: State) { + if self.last_state.as_ref() != Some(&state) { + (self.log)(&state.line()); + self.last_state = Some(state); + } + } + + fn run(mut self) { + loop { + let seen = self.cell.asks_so_far(); + let wait = self.tick(unix_ms()); + self.cell.sleep_until_asked(seen, wait); + } + } +} + +/// The wait after `misses` consecutive unrefreshed calls: the staleness +/// period, doubling per miss beyond the first, up to the ceiling. +fn backoff(misses: u32) -> Duration { + let doublings = misses.saturating_sub(1).min(16); + STALE.saturating_mul(1 << doublings).min(CEILING) +} + +fn duration_ms(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + +fn unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, duration_ms) +} + +/// Starts the meter thread. It writes only through the calls it makes, and +/// logs one line to stderr when its state changes. From here a lease asked of +/// a cell with no weekly reading waits for the meter's first read. +pub(crate) fn start( + cell: Arc, + probe: Box, +) -> std::io::Result> { + cell.attach_meter(); + let meter = Meter::new( + cell, + probe, + Box::new(|line| eprintln!("claude-connector: {line}")), + ); + std::thread::Builder::new() + .name("claude-meter".to_string()) + .spawn(move || meter.run()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::quota::Asking; + use model_connector::PlanQuota; + use std::sync::Mutex; + use std::sync::atomic::{AtomicUsize, Ordering}; + + const MIN: u64 = 60_000; + const NOW: u64 = 1_800_000_000_000; + const RESET: u64 = 4_102_444_800_000; + + fn reading(status: &str) -> Vec { + vec![PlanQuota { + window: WEEK.to_string(), + utilization_permille: Some(60), + resets_at_unix_ms: RESET, + status: status.to_string(), + observed_at_unix_ms: 0, + }] + } + + /// What the next call does: the instant its stream stamps a weekly + /// reading (if any) and the kind it then fails with (if any). + #[derive(Clone, Default)] + struct Script { + reading_at: Option, + status: String, + fails_with: Option, + } + + /// A call that answers as scripted and feeds the cell the way the stream + /// does, before it fails, as a rejected turn does. + struct Calls { + cell: Arc, + made: Arc, + script: Arc>, + } + + impl Probe for Calls { + fn call(&self) -> Result { + self.made.fetch_add(1, Ordering::SeqCst); + let script = self.script.lock().unwrap().clone(); + if let Some(at) = script.reading_at { + self.cell.observe_at(at, reading(&script.status)); + } + if let Some(kind) = script.fails_with { + return Err(kind); + } + Ok(Usage { + input_tokens: 385, + cache_read_tokens: 0, + cache_write_tokens: 0, + output_tokens: 4, + reasoning_tokens: None, + }) + } + } + + struct Rig { + meter: Meter, + cell: Arc, + made: Arc, + script: Arc>, + log: Arc>>, + /// The lease the swarm holds until a test lets go of it. + lease: Option, + } + + fn rig() -> Rig { + let cell = Arc::new(QuotaCell::new()); + let made = Arc::new(AtomicUsize::new(0)); + let script = Arc::new(Mutex::new(Script::default())); + let log = Arc::new(Mutex::new(Vec::new())); + let sink = log.clone(); + let meter = Meter::new( + cell.clone(), + Box::new(Calls { + cell: cell.clone(), + made: made.clone(), + script: script.clone(), + }), + Box::new(move |line| sink.lock().unwrap().push(line.to_string())), + ); + let lease = Some(cell.asking_at(NOW)); + Rig { + meter, + cell, + made, + script, + log, + lease, + } + } + + impl Rig { + fn reads(&self, at: u64) { + *self.script.lock().unwrap() = Script { + reading_at: Some(at), + status: "allowed".to_string(), + fails_with: None, + }; + } + fn reads_nothing(&self) { + *self.script.lock().unwrap() = Script::default(); + } + fn fails(&self, kind: &str) { + *self.script.lock().unwrap() = Script { + fails_with: Some(kind.to_string()), + ..Script::default() + }; + } + fn calls(&self) -> usize { + self.made.load(Ordering::SeqCst) + } + fn lines(&self) -> Vec { + self.log.lock().unwrap().clone() + } + } + + #[test] + fn an_unread_week_triggers_one_call_and_the_fresh_reading_ends_the_asking() { + let mut rig = rig(); + rig.reads(NOW); + assert_eq!(rig.meter.tick(NOW), STALE, "fresh for the staleness period"); + assert_eq!(rig.calls(), 1); + assert_eq!( + rig.meter.tick(NOW + 5 * MIN), + Duration::from_millis(10 * MIN) + ); + assert_eq!(rig.calls(), 1, "a fresh reading makes no call"); + } + + #[test] + fn a_stale_reading_triggers_a_call_at_the_period_and_not_before() { + let mut rig = rig(); + rig.cell.observe_at(NOW, reading("allowed")); + rig.reads(NOW + 15 * MIN); + rig.meter.tick(NOW + 15 * MIN - 1); + assert_eq!(rig.calls(), 0, "one millisecond short of stale"); + rig.meter.tick(NOW + 15 * MIN); + assert_eq!(rig.calls(), 1); + } + + #[test] + fn a_window_past_its_reset_is_read_at_once_not_after_the_period() { + let mut rig = rig(); + let now = unix_ms(); + let mut over = reading("allowed").remove(0); + over.resets_at_unix_ms = 1; + rig.cell.observe_at(now, vec![over]); + rig.reads(now); + rig.meter.tick(now); + assert_eq!(rig.calls(), 1); + } + + #[test] + fn a_swarm_that_asks_for_nothing_spends_nothing_however_stale_the_reading() { + let mut rig = rig(); + drop(rig.lease.take()); + for step in 0..10 { + let at = NOW + 15 * MIN + step * 60 * MIN; + assert_eq!(rig.meter.tick(at), CEILING); + } + assert_eq!(rig.calls(), 0); + assert_eq!(rig.lines(), vec![State::Idle.line()]); + } + + #[test] + fn a_lease_asked_for_wakes_the_meter_and_the_empty_cell_is_read() { + let mut rig = rig(); + drop(rig.lease.take()); + rig.reads(NOW + 180 * MIN); + rig.meter.tick(NOW + 180 * MIN); + assert_eq!(rig.calls(), 0, "asked three hours ago and let go"); + let _later = rig.cell.asking_at(NOW + 180 * MIN); + rig.meter.tick(NOW + 180 * MIN); + assert_eq!(rig.calls(), 1); + } + + #[test] + fn a_lease_let_go_is_still_wanted_for_exactly_one_staleness_period() { + let mut rig = rig(); + drop(rig.lease.take()); + rig.reads(NOW + 15 * MIN - 1); + rig.meter.tick(NOW + 15 * MIN - 1); + assert_eq!(rig.calls(), 1, "one millisecond inside the period"); + rig.reads(NOW + 30 * MIN); + rig.meter.tick(NOW + 30 * MIN); + assert_eq!(rig.calls(), 1, "a period after the last ask: unwanted"); + } + + #[test] + fn a_lease_held_for_four_hours_keeps_the_week_read_throughout() { + let mut rig = rig(); + for step in 0..=16 { + let at = NOW + step * 15 * MIN; + rig.reads(at); + rig.meter.tick(at); + } + assert_eq!(rig.calls(), 17, "one read per period, the lease never lapsing"); + } + + #[test] + fn a_failed_call_leaves_the_cell_and_backs_off_instead_of_hammering() { + let mut rig = rig(); + rig.cell.observe_at(NOW, reading("allowed")); + let before = rig.cell.snapshot(); + rig.fails("Provider"); + let mut at = NOW + 15 * MIN; + let mut waits = Vec::new(); + for _ in 0..4 { + waits.push(rig.meter.tick(at)); + at += duration_ms(*waits.last().unwrap()); + } + assert_eq!(rig.calls(), 4); + assert_eq!(rig.cell.snapshot(), before, "the cell is untouched"); + assert_eq!( + waits, + [15, 30, 60, 120].map(|m| Duration::from_secs(m * 60)), + "the wait doubles per consecutive miss" + ); + rig.meter.tick(at - MIN); + assert_eq!(rig.calls(), 4, "nothing is asked before the backoff ends"); + assert_eq!( + rig.lines(), + vec!["meter: the call failed (Provider), backing off"] + ); + } + + #[test] + fn the_failure_kind_is_logged_again_when_it_changes_and_not_when_it_repeats() { + let mut rig = rig(); + rig.fails("Authentication"); + rig.meter.tick(NOW); + rig.meter.tick(NOW + 15 * MIN); + rig.fails("InvalidRequest"); + rig.meter.tick(NOW + 45 * MIN); + assert_eq!( + rig.lines(), + vec![ + "meter: the call failed (Authentication), backing off", + "meter: the call failed (InvalidRequest), backing off", + ] + ); + } + + #[test] + fn the_backoff_stops_at_the_ceiling_and_one_good_reading_resets_it() { + assert_eq!(backoff(0), STALE); + assert_eq!(backoff(1), STALE); + assert_eq!(backoff(u32::MAX), CEILING); + let mut rig = rig(); + rig.fails("Provider"); + rig.meter.tick(NOW); + rig.meter.tick(NOW + 15 * MIN); + assert_eq!(rig.meter.misses, 2); + rig.reads(NOW + 45 * MIN); + rig.meter.tick(NOW + 45 * MIN); + assert_eq!(rig.meter.misses, 0); + } + + #[test] + fn a_call_that_completes_without_a_weekly_reading_is_a_miss_too() { + let mut rig = rig(); + rig.reads_nothing(); + assert_eq!(rig.meter.tick(NOW), STALE); + assert_eq!(rig.meter.tick(NOW + 15 * MIN), 2 * STALE); + assert_eq!(rig.calls(), 2); + assert_eq!(rig.lines(), vec![State::Unread.line()]); + } + + #[test] + fn a_rejected_turn_that_refreshed_the_reading_and_then_errored_is_no_miss() { + let mut rig = rig(); + *rig.script.lock().unwrap() = Script { + reading_at: Some(NOW), + status: "rejected".to_string(), + fails_with: Some("RateLimited".to_string()), + }; + assert_eq!(rig.meter.tick(NOW), STALE, "the next read is a period away"); + assert_eq!(rig.meter.misses, 0); + assert_eq!(rig.lines(), vec![State::Read.line()]); + rig.reads(NOW + 15 * MIN); + rig.meter.tick(NOW + 15 * MIN); + assert_eq!(rig.calls(), 2, "a rejected reading is kept fresh"); + } + + #[test] + fn a_reading_from_before_the_call_does_not_count_as_its_answer() { + let mut rig = rig(); + // A slow concurrent sensor stamped earlier than this call began. + rig.reads(NOW - 1); + rig.meter.tick(NOW); + assert_eq!(rig.meter.misses, 1); + } + + #[test] + fn the_log_says_what_the_meter_is_doing_in_words() { + assert_eq!( + State::Idle.line(), + "meter: no lease asked lately, making no call" + ); + assert_eq!(State::Read.line(), "meter: weekly reading refreshed"); + assert_eq!( + State::Unread.line(), + "meter: the call completed without a weekly reading, backing off" + ); + } + + #[test] + fn the_clock_is_the_system_clock_in_unix_milliseconds() { + let before = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); + let read = unix_ms(); + let after = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); + assert!((before.as_millis() as u64..=after.as_millis() as u64).contains(&read)); + } + + #[test] + fn a_started_meter_sleeps_until_a_lease_asks_and_then_reads() { + let cell = Arc::new(QuotaCell::new()); + let made = Arc::new(AtomicUsize::new(0)); + let probe = Calls { + cell: cell.clone(), + made: made.clone(), + script: Arc::new(Mutex::new(Script { + reading_at: Some(unix_ms() + 60 * MIN), + status: "allowed".to_string(), + fails_with: None, + })), + }; + start(cell.clone(), Box::new(probe)).unwrap(); + std::thread::sleep(Duration::from_millis(300)); + assert_eq!(made.load(Ordering::SeqCst), 0, "no lease, no call"); + let _lease = cell.asking(); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while made.load(Ordering::SeqCst) == 0 { + assert!(std::time::Instant::now() < deadline, "the ask woke nobody"); + std::thread::sleep(Duration::from_millis(20)); + } + assert_eq!(cell.snapshot_with_week(Duration::ZERO).windows.len(), 1); + } +} diff --git a/crates/claude-connector/src/quota.rs b/crates/claude-connector/src/quota.rs index b319d4c..fe71383 100644 --- a/crates/claude-connector/src/quota.rs +++ b/crates/claude-connector/src/quota.rs @@ -1,54 +1,147 @@ //! The plan's own usage windows, as the subscription reports them. The -//! subscription decides how much a plan may burn, and two sensors observe it: -//! the CLI's `rate_limit_event` on the connector's own calls, and the usage -//! poll of `usage_poll` while that stream is quiet. The latest observation of -//! each window, from either, is kept here and published as the usage readout -//! and lent with each lease. +//! subscription decides how much a plan may burn, and one sensor observes it: +//! the CLI's `rate_limit_event` on the connector's own calls, which the +//! `meter` makes when the week's reading goes stale. The latest observation of +//! each window is kept here and published as the usage readout and lent with +//! each lease. use std::collections::BTreeMap; -use std::sync::Mutex; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use model_connector::{PlanQuota, PlanQuotaSnapshot}; +/// The window the weekly pacing reads, and the one the meter keeps fresh. +pub(crate) const WEEK: &str = "seven_day"; +/// The windows the pacing reads, and so the only ones the cell keeps. +pub(crate) const PACED: [&str; 2] = ["five_hour", WEEK]; + +/// How often a lease waiting for its first reading looks at the cell again. +const FIRST_READ_POLL: Duration = Duration::from_millis(50); + /// The latest observation of each plan window, shared by the backend that /// feeds it and whatever reads it out. Each window carries the time of its /// own observation: an event that names one window says nothing about the /// others. +/// +/// It also keeps who is asking: a lease request is the swarm's only sign of +/// life the connector sees, and the meter reads the plan only while someone +/// asks. #[derive(Default)] -pub struct QuotaCell(Mutex>); +pub struct QuotaCell { + windows: Mutex>, + asks: Mutex, + asked: Condvar, + metered: AtomicBool, +} + +#[derive(Default)] +struct Asks { + /// Lease requests in flight or held. + live: usize, + /// Requests ever made; the meter sleeps until it changes. + count: u64, + /// When the latest request came, unix ms. + last_ms: Option, +} + +/// A lease request while it is in flight or held. Dropping it is the lease +/// ending, by whatever road. +pub(crate) struct Asking(Arc); + +impl Drop for Asking { + fn drop(&mut self) { + let mut asks = self.0.asks.lock().unwrap_or_else(|e| e.into_inner()); + asks.live = asks.live.saturating_sub(1); + } +} impl QuotaCell { pub fn new() -> Self { Self::default() } + /// Notes a lease request, now, and wakes the meter. + pub(crate) fn asking(self: &Arc) -> Asking { + self.asking_at(unix_ms()) + } + + pub(crate) fn asking_at(self: &Arc, now: u64) -> Asking { + { + let mut asks = self.asks.lock().unwrap_or_else(|e| e.into_inner()); + asks.live += 1; + asks.count += 1; + asks.last_ms = Some(now); + } + self.asked.notify_all(); + Asking(self.clone()) + } + + /// Whether a lease is held, or one was asked for less than `within` ago. + pub(crate) fn wanted_at(&self, now: u64, within: Duration) -> bool { + let asks = self.asks.lock().unwrap_or_else(|e| e.into_inner()); + asks.live > 0 + || asks.last_ms.is_some_and(|last| { + now < last.saturating_add(u64::try_from(within.as_millis()).unwrap_or(u64::MAX)) + }) + } + + pub(crate) fn asks_so_far(&self) -> u64 { + self.asks.lock().unwrap_or_else(|e| e.into_inner()).count + } + + /// Sleeps up to `limit`, or until a request beyond the first `seen` comes. + pub(crate) fn sleep_until_asked(&self, seen: u64, limit: Duration) { + let asks = self.asks.lock().unwrap_or_else(|e| e.into_inner()); + drop( + self.asked + .wait_timeout_while(asks, limit, |asks| asks.count == seen) + .unwrap_or_else(|e| e.into_inner()), + ); + } + + /// Declares that a meter reads this cell, so a first lease may wait for it. + pub(crate) fn attach_meter(&self) { + self.metered.store(true, Ordering::SeqCst); + } + + /// The windows as they stand, but with no weekly reading yet (a restart + /// leaves the cell empty) a metered cell is given up to `within` to get + /// one, so a lease is not lent "unobserved" for want of a few seconds. + pub(crate) fn snapshot_with_week(&self, within: Duration) -> PlanQuotaSnapshot { + let deadline = Instant::now() + within; + loop { + let snapshot = self.snapshot(); + let read = snapshot + .windows + .iter() + .any(|quota| quota.window == WEEK && quota.status != EXPIRED); + let now = Instant::now(); + if read || !self.metered.load(Ordering::SeqCst) || now >= deadline { + return snapshot; + } + std::thread::sleep(FIRST_READ_POLL.min(deadline - now)); + } + } + /// Records the windows one event reported, stamping each with now. See /// [`QuotaCell::observe_at`] for how a reading combines with what is held. pub fn observe(&self, windows: impl IntoIterator) { self.observe_at(unix_ms(), windows); } - /// Records readings taken at `at` (unix ms): the moment the sensor asked, - /// not the moment its answer arrived. The cell alone decides how a reading - /// combines with the one it holds for that window, and every sensor goes - /// through this decision: - /// - /// - a reading older than the held one is dropped, so a slow answer cannot - /// replace what a faster sensor saw meanwhile; - /// - the provider's rejection stands until its window resets, or until a - /// reading that itself carries a verdict from the provider replaces it: - /// a [`POLLED`] reading reports utilization only, and says nothing about - /// whether the provider accepts calls. - /// - /// Windows the readings did not mention stay as they were, stamp included. + /// Records readings taken at `at` (unix ms). A reading older than the + /// held one is dropped, so a slow call cannot replace what a faster one + /// saw meanwhile. Windows the readings did not mention stay as they were, + /// stamp included. pub fn observe_at(&self, at: u64, windows: impl IntoIterator) { - let mut held = self.0.lock().unwrap_or_else(|e| e.into_inner()); + let mut held = self.windows.lock().unwrap_or_else(|e| e.into_inner()); for mut quota in windows { quota.observed_at_unix_ms = at; if held .get("a.window) - .is_none_or(|current| supersedes(current, "a)) + .is_none_or(|current| quota.observed_at_unix_ms >= current.observed_at_unix_ms) { held.insert(quota.window.clone(), quota); } @@ -63,7 +156,7 @@ impl QuotaCell { /// `expired` with no utilization: the reading described a window that no /// longer exists. fn snapshot_at(&self, now: u64) -> PlanQuotaSnapshot { - let held = self.0.lock().unwrap_or_else(|e| e.into_inner()); + let held = self.windows.lock().unwrap_or_else(|e| e.into_inner()); PlanQuotaSnapshot { windows: held .values() @@ -81,24 +174,7 @@ impl QuotaCell { } /// The status of a window whose reset has passed since it was observed. -const EXPIRED: &str = "expired"; -/// The status of a window the provider refuses calls on. -pub(crate) const REJECTED: &str = "rejected"; -/// The status of a window read from the usage endpoint: utilization and reset -/// time, with no verdict on whether calls are accepted. -pub(crate) const POLLED: &str = "polled"; - -/// Whether `incoming` (already stamped with its own reading time) replaces -/// `current` for the same window. -fn supersedes(current: &PlanQuota, incoming: &PlanQuota) -> bool { - if incoming.observed_at_unix_ms < current.observed_at_unix_ms { - return false; - } - let rejection_stands = current.status == REJECTED - && incoming.status == POLLED - && incoming.observed_at_unix_ms < current.resets_at_unix_ms; - !rejection_stands -} +pub(crate) const EXPIRED: &str = "expired"; fn unix_ms() -> u64 { SystemTime::now() @@ -194,33 +270,13 @@ mod tests { } #[test] - fn a_rejection_stands_against_a_poll_until_the_window_resets() { + fn a_newer_reading_lifts_a_rejection() { let cell = QuotaCell::new(); - let polled = with_status(quota("five_hour", 500, 0), POLLED); - cell.observe_at(100, [with_status(quota("five_hour", 1000, 0), REJECTED)]); - cell.observe_at(200, [polled.clone()]); - assert_eq!(held(&cell), (REJECTED.to_string(), Some(1000), 100)); - // The window the rejection described is over: a poll of the next one lands. - cell.observe_at(RESET, [polled]); - assert_eq!(cell.snapshot_at(RESET - 1).windows[0].status, POLLED); - } - - #[test] - fn a_newer_reading_that_carries_its_own_verdict_lifts_a_rejection() { - let cell = QuotaCell::new(); - cell.observe_at(100, [with_status(quota("five_hour", 1000, 0), REJECTED)]); + cell.observe_at(100, [with_status(quota("five_hour", 1000, 0), "rejected")]); cell.observe_at(200, [quota("five_hour", 300, 0)]); assert_eq!(held(&cell), ("allowed".to_string(), Some(300), 200)); } - #[test] - fn a_poll_replaces_what_is_not_a_rejection() { - let cell = QuotaCell::new(); - cell.observe_at(100, [quota("five_hour", 300, 0)]); - cell.observe_at(200, [with_status(quota("five_hour", 400, 0), POLLED)]); - assert_eq!(held(&cell), (POLLED.to_string(), Some(400), 200)); - } - #[test] fn the_live_clock_stamps_and_expires() { let cell = QuotaCell::new(); @@ -236,4 +292,114 @@ mod tests { assert_eq!(windows[0].utilization_permille, Some(80)); assert_eq!(windows[1].status, "expired"); } + + const HOUR: u64 = 3_600_000; + + #[test] + fn nobody_wants_the_plan_before_anyone_asks() { + let cell = Arc::new(QuotaCell::new()); + assert!(!cell.wanted_at(0, Duration::from_secs(900))); + } + + #[test] + fn a_lease_in_flight_or_held_is_wanted_for_as_long_as_it_lives() { + let cell = Arc::new(QuotaCell::new()); + let lease = cell.asking_at(1_000); + let other = cell.asking_at(2_000); + let day = Duration::from_secs(86_400); + assert!(cell.wanted_at(1_000 + 4 * HOUR, Duration::from_secs(1))); + drop(lease); + assert!(cell.wanted_at(1_000 + 4 * HOUR, Duration::from_secs(1)), "one lease still lives"); + drop(other); + assert!(!cell.wanted_at(1_000 + 4 * HOUR, Duration::from_secs(1))); + assert!(cell.wanted_at(2_000 + 4 * HOUR, day), "within the window of the last ask"); + } + + #[test] + fn a_lease_let_go_is_wanted_until_exactly_the_period_after_its_ask() { + let cell = Arc::new(QuotaCell::new()); + drop(cell.asking_at(10_000)); + let period = Duration::from_millis(900); + assert!(cell.wanted_at(10_899, period)); + assert!(!cell.wanted_at(10_900, period)); + } + + #[test] + fn each_ask_is_counted_and_wakes_a_sleeper_at_once() { + let cell = Arc::new(QuotaCell::new()); + assert_eq!(cell.asks_so_far(), 0); + let sleeper = { + let cell = cell.clone(); + std::thread::spawn(move || { + let started = Instant::now(); + cell.sleep_until_asked(0, Duration::from_secs(30)); + started.elapsed() + }) + }; + std::thread::sleep(Duration::from_millis(100)); + drop(cell.asking()); + assert!(sleeper.join().unwrap() < Duration::from_secs(10), "woken, not timed out"); + assert_eq!(cell.asks_so_far(), 1); + // An ask that came after `seen` was read ends the sleep before it starts. + let started = Instant::now(); + cell.sleep_until_asked(0, Duration::from_secs(30)); + assert!(started.elapsed() < Duration::from_secs(10)); + // Nothing new: the sleep is the whole limit. + let started = Instant::now(); + cell.sleep_until_asked(1, Duration::from_millis(200)); + assert!(started.elapsed() >= Duration::from_millis(200)); + } + + #[test] + fn a_metered_cell_with_no_week_gives_a_first_lease_the_reading_when_it_lands() { + let cell = Arc::new(QuotaCell::new()); + cell.attach_meter(); + let feeder = { + let cell = cell.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(200)); + cell.observe([quota(WEEK, 60, 0)]); + }) + }; + let plan = cell.snapshot_with_week(Duration::from_secs(20)); + feeder.join().unwrap(); + assert_eq!(plan.windows.len(), 1, "the lease waited for the week"); + assert_eq!(plan.windows[0].window, WEEK); + } + + #[test] + fn the_wait_for_the_first_reading_is_bounded() { + let cell = Arc::new(QuotaCell::new()); + cell.attach_meter(); + let started = Instant::now(); + let plan = cell.snapshot_with_week(Duration::from_millis(300)); + let waited = started.elapsed(); + assert!(plan.windows.is_empty(), "no reading came: unobserved, not hung"); + assert!(waited >= Duration::from_millis(300), "{waited:?}"); + assert!(waited < Duration::from_secs(10), "{waited:?}"); + } + + #[test] + fn a_cell_nobody_meters_or_that_holds_the_week_is_never_waited_on() { + let bare = QuotaCell::new(); + let started = Instant::now(); + assert!(bare.snapshot_with_week(Duration::from_secs(20)).windows.is_empty()); + let held = QuotaCell::new(); + held.attach_meter(); + held.observe([quota(WEEK, 60, 0)]); + assert_eq!(held.snapshot_with_week(Duration::from_secs(20)).windows.len(), 1); + assert!(started.elapsed() < Duration::from_secs(5)); + } + + #[test] + fn a_week_past_its_reset_is_waited_on_like_no_week() { + let cell = QuotaCell::new(); + cell.attach_meter(); + let mut over = quota(WEEK, 60, 0); + over.resets_at_unix_ms = 1; + cell.observe([over]); + let started = Instant::now(); + cell.snapshot_with_week(Duration::from_millis(300)); + assert!(started.elapsed() >= Duration::from_millis(300)); + } } diff --git a/crates/claude-connector/src/stream.rs b/crates/claude-connector/src/stream.rs index c5e2e41..039ff75 100644 --- a/crates/claude-connector/src/stream.rs +++ b/crates/claude-connector/src/stream.rs @@ -7,7 +7,7 @@ use model_connector::{FailureKind, PlanQuota, StopReason, Usage}; use serde::Deserialize; use serde_json::Value; -use crate::quota::QuotaCell; +use crate::quota::{PACED, QuotaCell}; /// The tool the CLI gives the model to deliver an answer that must match a /// `--json-schema`. Its call is the structured output, not a tool exchange. @@ -320,12 +320,16 @@ impl<'a> StreamState<'a> { /// `rate_limit_info.unifiedWindows`: utilization as a fraction of the window, /// reset as unix seconds. A window missing any of its three fields is skipped; /// an unknown reading is never zero. +/// Only the windows pacing reads are taken ([`PACED`]): the event also names +/// per-model buckets, and one of those reading `rejected` must not close every +/// lease. fn plan_windows(info: &Value) -> Vec { let Some(windows) = info.get("unifiedWindows").and_then(Value::as_object) else { return Vec::new(); }; windows .iter() + .filter(|(window, _)| PACED.contains(&window.as_str())) .filter_map(|(window, reading)| { let utilization = reading.get("utilization")?.as_f64()?; let resets_at = reading.get("resetsAt")?.as_u64()?; @@ -392,3 +396,50 @@ fn sum_usage(models: impl Iterator) -> Usage { } usage } + +#[cfg(test)] +mod tests { + use super::*; + + fn windows(body: &str) -> Vec<(String, Option, u64)> { + let info: Value = serde_json::from_str(&format!(r#"{{"unifiedWindows":{body}}}"#)).unwrap(); + plan_windows(&info) + .into_iter() + .map(|q| (q.window, q.utilization_permille, q.resets_at_unix_ms)) + .collect() + } + + #[test] + fn only_the_five_hour_and_seven_day_windows_are_taken() { + let taken = windows( + r#"{"five_hour":{"utilization":0.5,"resetsAt":2,"status":"allowed"}, + "seven_day":{"utilization":0.25,"resetsAt":3,"status":"allowed"}, + "seven_day_opus":{"utilization":0.1,"resetsAt":4,"status":"allowed"}, + "seven_day_overage_included":{"utilization":1,"resetsAt":5,"status":"rejected"}}"#, + ); + assert_eq!( + taken, + vec![ + ("five_hour".to_string(), Some(500), 2000), + ("seven_day".to_string(), Some(250), 3000), + ] + ); + } + + #[test] + fn a_window_missing_a_field_or_of_the_wrong_type_costs_only_itself() { + // Unknown is never zero: such a window is skipped, not read as empty. + for broken in [ + r#"{"resetsAt":2,"status":"allowed"}"#, + r#"{"utilization":0.5,"status":"allowed"}"#, + r#"{"utilization":0.5,"resetsAt":2}"#, + r#"{"utilization":"high","resetsAt":2,"status":"allowed"}"#, + r#"{"utilization":-0.5,"resetsAt":2,"status":"allowed"}"#, + ] { + let both = windows(&format!( + r#"{{"five_hour":{broken},"seven_day":{{"utilization":0,"resetsAt":3,"status":"allowed"}}}}"# + )); + assert_eq!(both, vec![("seven_day".to_string(), Some(0), 3000)], "{broken}"); + } + } +} diff --git a/crates/claude-connector/src/usage_poll.rs b/crates/claude-connector/src/usage_poll.rs deleted file mode 100644 index f5e4578..0000000 --- a/crates/claude-connector/src/usage_poll.rs +++ /dev/null @@ -1,1077 +0,0 @@ -//! The second sensor of the plan's usage windows. The CLI's `rate_limit_event` -//! only arrives when the connector's own calls run, so while the swarm is -//! quiet the weekly reading goes stale; this thread asks the subscription's -//! usage endpoint when the `seven_day` reading is `STALE` or older, and -//! records the answer in the same [`QuotaCell`] the stream feeds. Neither -//! sensor owns the reading: the cell decides how a reading combines with the -//! one it holds, and a poll is stamped with the moment it asked. A failed -//! poll never touches the cell; only the cell's own reset rule retires a -//! window. -//! -//! The endpoint is undocumented (the shape is the one Claude Code 2.1.312 -//! parses), so every answer it can give is handled and none is trusted to -//! change the schedule beyond the rules below. - -use std::io::Read; -use std::sync::Arc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use model_connector::PlanQuota; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use zeroize::Zeroizing; - -use crate::backend::TokenSource; -use crate::quota::{POLLED, QuotaCell}; - -/// The usage endpoint. -const ENDPOINT: &str = "https://api.anthropic.com/api/oauth/usage"; -/// A reading this old, or older, is stale; also the least gap between polls. -const STALE: Duration = Duration::from_secs(15 * 60); -/// The longest any backoff may wait. -const CEILING: Duration = Duration::from_secs(6 * 60 * 60); -const TIMEOUT: Duration = Duration::from_secs(10); -/// A usage body is a few hundred bytes; this is a ceiling, not an expectation. -const MAX_BODY: u64 = 1 << 20; - -/// What the endpoint said, reduced to what the schedule needs. It never holds -/// the token. -#[derive(Debug, PartialEq, Eq)] -enum Answer { - Body(String), - Status(u16, Option), - Unreachable, -} - -/// Why the poller is waiting; logged once per change. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum State { - Polled, - NoToken, - RateLimited, - Refused, - NotFound, - Unreadable, - Unexpected, - Unreachable, -} - -impl State { - fn line(self) -> &'static str { - match self { - Self::Polled => "usage poll: reading recorded", - Self::NoToken => "usage poll: no token to ask with", - Self::RateLimited => "usage poll: rate limited, waiting", - Self::Refused => { - "usage poll: refused (401/403), the token likely lacks the user:profile scope; not asking again until the token changes" - } - Self::NotFound => "usage poll: endpoint not found, backing off", - Self::Unreadable => "usage poll: answer not readable, backing off", - Self::Unexpected => "usage poll: unexpected status, retrying later", - Self::Unreachable => "usage poll: endpoint unreachable, retrying later", - } - } -} - -#[derive(Debug, PartialEq)] -struct Verdict { - windows: Vec, - /// How long until the next poll may happen. - wait: Duration, - state: State, -} - -/// What one answer means: the windows to record (only a readable body has -/// any), how long to wait, and the state to report. -fn judge(answer: &Answer) -> Verdict { - let (windows, wait, state) = match answer { - Answer::Body(body) => match parse_usage(body) { - Some(windows) => (windows, STALE, State::Polled), - None => (Vec::new(), CEILING, State::Unreadable), - }, - Answer::Status(429, retry_after) => ( - Vec::new(), - retry_after.unwrap_or_default().clamp(STALE, CEILING), - State::RateLimited, - ), - Answer::Status(401 | 403, _) => (Vec::new(), STALE, State::Refused), - Answer::Status(404, _) => (Vec::new(), CEILING, State::NotFound), - Answer::Status(..) => (Vec::new(), STALE, State::Unexpected), - Answer::Unreachable => (Vec::new(), STALE, State::Unreachable), - }; - Verdict { - windows, - wait, - state, - } -} - -/// The plan windows a usage body reports: `five_hour` and `seven_day`, each -/// `{utilization: percent 0-100, resets_at: ISO 8601}`. A window missing -/// either field, or reporting nonsense, is skipped, and an unknown reading is -/// never zero. Other keys are ignored. `None` when the body is not a JSON -/// object at all. -fn parse_usage(body: &str) -> Option> { - let usage: Value = serde_json::from_str(body).ok()?; - let usage = usage.as_object()?; - Some( - ["five_hour", "seven_day"] - .into_iter() - .filter_map(|window| { - let reading = usage.get(window)?; - let percent = reading.get("utilization")?.as_f64()?; - let resets_at = reading.get("resets_at")?.as_str()?; - if !percent.is_finite() || percent < 0.0 { - return None; - } - let resets_at = chrono::DateTime::parse_from_rfc3339(resets_at).ok()?; - Some(PlanQuota { - window: window.to_string(), - // `as` saturates a float at the integer's bounds. - utilization_permille: Some((percent * 10.0).round() as u32), - resets_at_unix_ms: u64::try_from(resets_at.timestamp_millis()).ok()?, - status: POLLED.to_string(), - // Stamped by the cell that keeps it. - observed_at_unix_ms: 0, - }) - }) - .collect(), - ) -} - -/// The blocking HTTPS client: 10 s end to end, no redirects (the token must -/// not follow one anywhere). -pub(crate) struct UsageClient { - agent: ureq::Agent, - url: String, - timeout: Duration, -} - -impl UsageClient { - /// The client that asks the provider's usage endpoint. - pub(crate) fn production() -> Self { - Self::new(ENDPOINT) - } - - /// A client for another endpoint: a test's fake server. - pub(crate) fn new(url: impl Into) -> Self { - Self::with_timeout(url, TIMEOUT) - } - - fn with_timeout(url: impl Into, timeout: Duration) -> Self { - Self { - agent: ureq::AgentBuilder::new().redirects(0).build(), - url: url.into(), - timeout, - } - } - - /// Asks the endpoint. Our copy of the header value is zeroized when it - /// drops; ureq copies it into its own request buffers, which are freed - /// without zeroing. - fn ask(&self, token: &Zeroizing) -> Answer { - let bearer = Zeroizing::new(format!("Bearer {}", token.as_str())); - let sent = self - .agent - .get(&self.url) - .timeout(self.timeout) - .set("Authorization", bearer.as_str()) - .set("anthropic-beta", "oauth-2025-04-20") - .call(); - match sent { - Ok(response) if response.status() == 200 => { - let mut body = String::new(); - match response - .into_reader() - .take(MAX_BODY) - .read_to_string(&mut body) - { - Ok(_) => Answer::Body(body), - Err(_) => Answer::Unreachable, - } - } - Ok(response) => Answer::Status(response.status(), None), - Err(ureq::Error::Status(code, response)) => { - let retry_after = response - .header("retry-after") - .and_then(|value| retry_after(value, unix_ms())); - Answer::Status(code, retry_after) - } - Err(ureq::Error::Transport(_)) => Answer::Unreachable, - } - } -} - -/// A `Retry-After` value as a wait from `now_ms`: delay-seconds, or an -/// HTTP-date (RFC 9110 10.2.3, all three forms) measured from now, a date -/// already past being a zero wait. `None` when it is neither. -fn retry_after(value: &str, now_ms: u64) -> Option { - let value = value.trim(); - if let Ok(seconds) = value.parse::() { - return Some(Duration::from_secs(seconds)); - } - let until = http_date_ms(value, now_ms)?; - Some(Duration::from_millis(until.saturating_sub(now_ms))) -} - -/// An HTTP-date as Unix milliseconds: IMF-fixdate (RFC 2822 reads it), the -/// obsolete RFC 850 form `Sunday, 06-Nov-94 08:49:37 GMT`, or asctime -/// `Sun Nov 6 08:49:37 1994`. Weekday names are not checked. -fn http_date_ms(value: &str, now_ms: u64) -> Option { - use chrono::Datelike; - if let Ok(date) = chrono::DateTime::parse_from_rfc2822(value) { - return u64::try_from(date.timestamp_millis()).ok(); - } - let fields: Vec<&str> = value.split_whitespace().collect(); - let (day, month, year, time) = match fields.as_slice() { - [weekday, date, time, "GMT"] if weekday.ends_with(',') => { - let mut parts = date.split('-'); - let (day, month, year) = (parts.next()?, parts.next()?, parts.next()?); - if parts.next().is_some() || year.len() != 2 { - return None; - } - // A two-digit year more than 50 years ahead is the latest past - // year with those digits. - let now_year = - chrono::DateTime::from_timestamp_millis(i64::try_from(now_ms).ok()?)?.year(); - let year = now_year - now_year.rem_euclid(100) + year.parse::().ok()?; - let year = if year > now_year + 50 { - year - 100 - } else { - year - }; - (day, month, year, *time) - } - [_, month, day, time, year] => (*day, *month, year.parse::().ok()?, *time), - _ => return None, - }; - const MONTHS: [&str; 12] = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", - ]; - let month = MONTHS - .iter() - .position(|name| name.eq_ignore_ascii_case(month))?; - let mut clock = time.split(':'); - let (hour, minute, second) = (clock.next()?, clock.next()?, clock.next()?); - if clock.next().is_some() { - return None; - } - let moment = - chrono::NaiveDate::from_ymd_opt(year, u32::try_from(month + 1).ok()?, day.parse().ok()?)? - .and_hms_opt( - hour.parse().ok()?, - minute.parse().ok()?, - second.parse().ok()?, - )?; - u64::try_from(moment.and_utc().timestamp_millis()).ok() -} - -/// The schedule and the thing it drives. Time comes in as an argument so the -/// schedule is testable without waiting. -struct Poller { - cell: Arc, - tokens: Box, - client: UsageClient, - log: Box, - /// No poll before this instant, unix ms: the last answer's own wait. - not_before_ms: u64, - started: bool, - /// The digest of the token the endpoint last refused: while the token - /// reads the same, the poller does not ask again. - refused_token: Option<[u8; 32]>, - last_state: Option, -} - -impl Poller { - fn new( - cell: Arc, - tokens: Box, - client: UsageClient, - log: Box, - ) -> Self { - Self { - cell, - tokens, - client, - log, - not_before_ms: 0, - started: false, - refused_token: None, - last_state: None, - } - } - - /// Polls if a poll is due at `now_ms`; returns how long to wait before - /// asking again. - /// - /// The first call polls. After that a poll is due only when the cell's - /// `seven_day` observation is `STALE` old or older (or absent), and never - /// before the last answer's own wait has passed. A token the endpoint - /// refused is not sent again until it reads differently. - fn tick(&mut self, now_ms: u64) -> Duration { - let due_at = self.due_at_ms(); - if now_ms < due_at { - return Duration::from_millis(due_at - now_ms); - } - self.started = true; - let token = self.tokens.read(); - if let (Some(refused), Some(token)) = (&self.refused_token, &token) - && digest(token) != *refused - { - self.refused_token = None; - } - let verdict = match token { - _ if self.refused_token.is_some() => Verdict { - windows: Vec::new(), - wait: STALE, - state: State::Refused, - }, - Some(token) => { - let verdict = judge(&self.client.ask(&token)); - if verdict.state == State::Refused { - self.refused_token = Some(digest(&token)); - } - verdict - } - None => Verdict { - windows: Vec::new(), - wait: STALE, - state: State::NoToken, - }, - }; - // The reading is as old as the question: an answer that arrives after - // the stream spoke does not outrank it. - self.cell.observe_at(now_ms, verdict.windows); - if self.last_state != Some(verdict.state) { - (self.log)(verdict.state.line()); - self.last_state = Some(verdict.state); - } - self.not_before_ms = now_ms.saturating_add(duration_ms(verdict.wait)); - Duration::from_millis(self.due_at_ms().saturating_sub(now_ms)) - } - - /// The earliest instant the next poll may happen. - fn due_at_ms(&self) -> u64 { - if !self.started { - return 0; - } - let stale_at = self - .cell - .snapshot() - .windows - .iter() - .find(|quota| quota.window == "seven_day") - .map_or(0, |quota| { - quota.observed_at_unix_ms.saturating_add(duration_ms(STALE)) - }); - stale_at.max(self.not_before_ms) - } - - fn run(mut self) { - loop { - let wait = self.tick(unix_ms()); - std::thread::sleep(wait); - } - } -} - -fn digest(token: &Zeroizing) -> [u8; 32] { - Sha256::digest(token.as_bytes()).into() -} - -fn duration_ms(duration: Duration) -> u64 { - u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) -} - -fn unix_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_or(0, duration_ms) -} - -/// Starts the poll thread. It reads the token afresh at each poll, writes -/// only to `cell`, and logs one line to stderr when its state changes. -pub(crate) fn start( - cell: Arc, - tokens: Box, - client: UsageClient, -) -> std::io::Result> { - let poller = Poller::new( - cell, - tokens, - client, - Box::new(|line| eprintln!("claude-connector: {line}")), - ); - std::thread::Builder::new() - .name("claude-usage-poll".to_string()) - .spawn(move || poller.run()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - use std::net::TcpListener; - use std::sync::Mutex; - - const MIN: u64 = 60_000; - const CANARY: &str = "sk-ant-oat01-CANARY-must-never-surface"; - const FAR: &str = "2100-01-01T00:00:00+00:00"; - const FAR_MS: u64 = 4_102_444_800_000; - - fn body(five: &str, seven: &str) -> String { - format!( - r#"{{"five_hour":{five},"seven_day":{seven},"seven_day_opus":null,"extra_usage":{{"x":1}}}}"# - ) - } - - fn reading(percent: f64) -> String { - format!(r#"{{"utilization":{percent},"resets_at":"{FAR}"}}"#) - } - - fn secs(s: u64) -> Duration { - Duration::from_secs(s) - } - - // ---- mapping ---- - - #[test] - fn usage_body_maps_percent_to_permille() { - let windows = parse_usage(&body(&reading(6.0), &reading(35.0))).unwrap(); - let expect = |window: &str, permille| PlanQuota { - window: window.to_string(), - utilization_permille: Some(permille), - resets_at_unix_ms: FAR_MS, - status: "polled".to_string(), - observed_at_unix_ms: 0, - }; - assert_eq!( - windows, - vec![expect("five_hour", 60), expect("seven_day", 350)] - ); - } - - #[test] - fn usage_percent_rounds_to_the_nearest_permille() { - let windows = parse_usage(&body(&reading(12.34), &reading(0.06))).unwrap(); - assert_eq!(windows[0].utilization_permille, Some(123)); - assert_eq!(windows[1].utilization_permille, Some(1)); - let zero = parse_usage(&body(&reading(0.0), "null")).unwrap(); - assert_eq!(zero[0].utilization_permille, Some(0), "a real zero stays"); - } - - #[test] - fn resets_at_is_parsed_with_its_offset_to_unix_ms() { - let seven = r#"{"utilization":1.0,"resets_at":"2026-11-07T12:30:15.250+02:00"}"#; - let windows = parse_usage(&body("null", seven)).unwrap(); - // 2026-11-07T10:30:15.250Z - assert_eq!(windows[0].resets_at_unix_ms, 1_794_047_415_250); - } - - #[test] - fn a_window_missing_a_field_or_reading_nonsense_is_skipped_not_zero() { - let no_percent = format!(r#"{{"utilization":null,"resets_at":"{FAR}"}}"#); - let no_reset = r#"{"utilization":50.0,"resets_at":null}"#; - assert_eq!(parse_usage(&body(&no_percent, no_reset)).unwrap(), vec![]); - let junk = r#"{"utilization":5.0,"resets_at":"soon"}"#; - assert_eq!(parse_usage(&body(&reading(-1.0), junk)).unwrap(), vec![]); - let before_epoch = r#"{"utilization":5.0,"resets_at":"1969-01-01T00:00:00Z"}"#; - assert_eq!(parse_usage(&body(before_epoch, "null")).unwrap(), vec![]); - // One good window survives its neighbour's null. - let one = parse_usage(&body(&no_percent, &reading(35.0))).unwrap(); - assert_eq!(one.len(), 1); - assert_eq!(one[0].window, "seven_day"); - } - - #[test] - fn a_body_that_is_not_an_object_is_unreadable() { - for bad in ["", "not json", "[1,2]", "42", "null"] { - assert_eq!(parse_usage(bad), None, "{bad:?}"); - } - assert_eq!(parse_usage("{}"), Some(vec![])); - } - - // ---- judgement ---- - - fn wait_of(answer: &Answer) -> Duration { - judge(answer).wait - } - - #[test] - fn rate_limit_waits_the_longer_of_retry_after_and_the_floor_capped() { - let limited = |after| wait_of(&Answer::Status(429, after)); - assert_eq!(limited(Some(secs(3600))), secs(3600)); - assert_eq!(limited(Some(secs(20 * 60))), secs(20 * 60)); - assert_eq!(limited(Some(secs(10))), STALE); - assert_eq!(limited(None), STALE); - assert_eq!(limited(Some(secs(100_000))), CEILING); - assert_eq!(limited(Some(secs(6 * 3600))), CEILING); - assert_eq!(judge(&Answer::Status(429, None)).state, State::RateLimited); - } - - #[test] - fn refusal_backs_off_like_the_rest() { - assert_eq!(wait_of(&Answer::Status(401, None)), STALE); - assert_eq!(wait_of(&Answer::Status(403, Some(secs(5)))), STALE); - assert_eq!(judge(&Answer::Status(403, None)).state, State::Refused); - assert_eq!(wait_of(&Answer::Status(404, None)), CEILING); - assert_eq!(wait_of(&Answer::Body("".to_string())), CEILING); - assert_eq!(wait_of(&Answer::Unreachable), STALE); - assert_eq!(wait_of(&Answer::Status(503, None)), STALE); - assert_eq!(wait_of(&Answer::Body(body(&reading(1.0), "null"))), STALE); - } - - // ---- a fake server, a poller, and the cell ---- - - /// Serves each canned reply to one connection in turn and remembers the - /// requests. - fn serve(replies: Vec) -> (String, Arc>>) { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let url = format!("http://{}/api/oauth/usage", listener.local_addr().unwrap()); - let seen = Arc::new(Mutex::new(Vec::new())); - let record = seen.clone(); - std::thread::spawn(move || { - for reply in replies { - let Ok((mut stream, _)) = listener.accept() else { - return; - }; - let mut request = vec![0u8; 4096]; - let n = stream.read(&mut request).unwrap_or(0); - record - .lock() - .unwrap() - .push(String::from_utf8_lossy(&request[..n]).into_owned()); - let _ = stream.write_all(reply.as_bytes()); - } - }); - (url, seen) - } - - fn http(status: &str, headers: &str, body: &str) -> String { - format!( - "HTTP/1.1 {status}\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ) - } - - struct Fixed(Option<&'static str>); - impl TokenSource for Fixed { - fn read(&self) -> Option> { - self.0.map(|t| Zeroizing::new(t.to_string())) - } - } - - type Lines = Arc>>; - - fn make_poller( - cell: &Arc, - url: &str, - token: Option<&'static str>, - ) -> (Poller, Lines) { - let lines = Arc::new(Mutex::new(Vec::new())); - let sink = lines.clone(); - let poller = Poller::new( - cell.clone(), - Box::new(Fixed(token)), - UsageClient::new(url), - Box::new(move |line| sink.lock().unwrap().push(line.to_string())), - ); - (poller, lines) - } - - fn window(cell: &QuotaCell, name: &str) -> Option { - cell.snapshot() - .windows - .into_iter() - .find(|q| q.window == name) - } - - fn stream_reading(window: &str, permille: u32) -> PlanQuota { - PlanQuota { - window: window.to_string(), - utilization_permille: Some(permille), - resets_at_unix_ms: FAR_MS, - status: "allowed".to_string(), - observed_at_unix_ms: 0, - } - } - - fn requests(seen: &Arc>>) -> usize { - seen.lock().unwrap().len() - } - - #[test] - fn the_first_tick_polls_and_records_what_the_endpoint_said() { - let (url, seen) = serve(vec![http( - "200 OK", - "Content-Type: application/json\r\n", - &body(&reading(6.0), &reading(35.0)), - )]); - let cell = Arc::new(QuotaCell::new()); - let (mut poller, lines) = make_poller(&cell, &url, Some(CANARY)); - let wait = poller.tick(unix_ms()); - let windows = cell.snapshot().windows; - assert_eq!(windows.len(), 2); - assert_eq!(windows[0].status, "polled"); - assert_eq!(windows[1].utilization_permille, Some(350)); - assert!( - (STALE..STALE + secs(5)).contains(&wait), - "a fresh reading waits until it is stale: {wait:?}" - ); - let request = seen.lock().unwrap()[0].to_lowercase(); - assert!(request.contains(&format!("authorization: bearer {}", CANARY.to_lowercase()))); - assert!(request.contains("anthropic-beta: oauth-2025-04-20")); - assert_eq!(lines.lock().unwrap().len(), 1); - } - - #[test] - fn stale_only_polls() { - let (url, seen) = serve(vec![http("200 OK", "", &body("null", &reading(35.0))); 3]); - let cell = Arc::new(QuotaCell::new()); - let (mut poller, _) = make_poller(&cell, &url, Some(CANARY)); - poller.tick(unix_ms()); - assert_eq!(requests(&seen), 1, "the startup poll"); - let observed = window(&cell, "seven_day").unwrap().observed_at_unix_ms; - - // 5 min old: no request, and the wait is the time left. - let wait = poller.tick(observed + 5 * MIN); - assert_eq!(requests(&seen), 1); - assert_eq!(wait, Duration::from_millis(10 * MIN)); - // One millisecond short of 15 min: still none. - poller.tick(observed + 15 * MIN - 1); - assert_eq!(requests(&seen), 1); - // 16 min old: one request. - poller.tick(observed + 16 * MIN); - assert_eq!(requests(&seen), 2); - // That poll's answer holds its own 15 min, whatever the stamp says. - poller.tick(observed + 16 * MIN + 1); - assert_eq!(requests(&seen), 2); - } - - #[test] - fn a_reading_is_stale_at_exactly_fifteen_minutes() { - let (url, seen) = serve(vec![http("200 OK", "", &body("null", &reading(35.0))); 2]); - let cell = Arc::new(QuotaCell::new()); - let (mut poller, _) = make_poller(&cell, &url, Some(CANARY)); - poller.tick(unix_ms()); - let observed = window(&cell, "seven_day").unwrap().observed_at_unix_ms; - poller.tick(observed + 15 * MIN); - assert_eq!(requests(&seen), 2); - } - - #[test] - fn a_stream_observation_keeps_the_poll_quiet() { - let (url, seen) = serve(vec![http("200 OK", "", &body("null", &reading(35.0)))]); - let cell = Arc::new(QuotaCell::new()); - let (mut poller, _) = make_poller(&cell, &url, Some(CANARY)); - poller.tick(unix_ms()); - std::thread::sleep(Duration::from_millis(20)); - // The stream reports seven_day afresh, after the poll. - cell.observe([stream_reading("seven_day", 400)]); - let observed = window(&cell, "seven_day").unwrap().observed_at_unix_ms; - let wait = poller.tick(observed + 14 * MIN); - assert_eq!(requests(&seen), 1); - assert_eq!(wait, Duration::from_millis(MIN)); - } - - #[test] - fn failures_never_touch_the_cell() { - let limited = |headers: &str| http("429 Too Many Requests", headers, "{}"); - let cases = [ - (limited("Retry-After: 3600\r\n"), 60), - (limited(""), 15), - ( - limited("Retry-After: Wed, 21 Oct 2026 07:28:00 GMT\r\n"), - 15, - ), - ( - limited("Retry-After: Fri, 31 Dec 2100 23:59:59 GMT\r\n"), - 360, - ), - (http("403 Forbidden", "", "{}"), 15), - (http("401 Unauthorized", "", "{}"), 15), - (http("404 Not Found", "", "{}"), 360), - (http("200 OK", "", "not json"), 360), - ( - http("302 Found", "Location: http://127.0.0.1:9/\r\n", ""), - 15, - ), - ]; - for (reply, minutes) in cases { - let (url, _) = serve(vec![reply.clone()]); - let cell = Arc::new(QuotaCell::new()); - cell.observe([ - stream_reading("five_hour", 120), - stream_reading("seven_day", 300), - ]); - let before = cell.snapshot(); - let (mut poller, lines) = make_poller(&cell, &url, Some(CANARY)); - let wait = poller.tick(unix_ms()); - assert_eq!(cell.snapshot(), before, "{reply}"); - assert_eq!(wait.as_secs() / 60, minutes, "{reply}"); - assert_eq!(lines.lock().unwrap().len(), 1, "{reply}"); - } - } - - #[test] - fn an_unreachable_endpoint_waits_the_floor_and_a_missing_token_never_asks() { - // Nothing listens here: bind, learn the port, drop. - let port = TcpListener::bind("127.0.0.1:0") - .unwrap() - .local_addr() - .unwrap() - .port(); - let cell = Arc::new(QuotaCell::new()); - let (mut poller, lines) = - make_poller(&cell, &format!("http://127.0.0.1:{port}/u"), Some(CANARY)); - assert_eq!(poller.tick(unix_ms()), STALE); - assert!(cell.snapshot().windows.is_empty()); - assert_eq!(lines.lock().unwrap().len(), 1); - - let (url, seen) = serve(vec![http("200 OK", "", "{}")]); - let (mut poller, lines) = make_poller(&cell, &url, None); - assert_eq!(poller.tick(unix_ms()), STALE); - assert_eq!(requests(&seen), 0); - assert_eq!(lines.lock().unwrap()[0], State::NoToken.line()); - } - - #[test] - fn a_state_is_logged_once_until_it_changes() { - let limited = http("429 Too Many Requests", "", "{}"); - let (url, _) = serve(vec![ - limited.clone(), - limited, - http("200 OK", "", &body("null", &reading(35.0))), - ]); - let cell = Arc::new(QuotaCell::new()); - let (mut poller, lines) = make_poller(&cell, &url, Some(CANARY)); - let t = unix_ms(); - poller.tick(t); - poller.tick(t + 16 * MIN); - assert_eq!(lines.lock().unwrap().len(), 1, "same state, one line"); - poller.tick(t + 32 * MIN); - let lines = lines.lock().unwrap(); - assert_eq!(lines.len(), 2); - assert_eq!(lines[1], State::Polled.line()); - } - - #[test] - fn nothing_logged_or_stored_holds_the_token() { - let echo = format!(r#"{{"echo":"{CANARY}"}}"#); - let (url, _) = serve(vec![ - http("429 Too Many Requests", "Retry-After: 60\r\n", &echo), - http( - "200 OK", - "", - &format!(r#"{{"five_hour":{},"note":"{CANARY}"}}"#, reading(1.0)), - ), - http("403 Forbidden", "", CANARY), - ]); - let cell = Arc::new(QuotaCell::new()); - let (mut poller, lines) = make_poller(&cell, &url, Some(CANARY)); - let t = unix_ms(); - poller.tick(t); - poller.tick(t + 16 * MIN); - poller.tick(t + 32 * MIN); - let lines = lines.lock().unwrap(); - assert_eq!(lines.len(), 3, "one line per change of state"); - let seen = format!("{lines:?} {:?}", cell.snapshot()); - assert!(!seen.contains("CANARY"), "{seen}"); - } - - #[test] - fn stream_and_poll_share_one_cell() { - let (url, _) = serve(vec![http( - "200 OK", - "", - &body(&reading(6.0), &reading(35.0)), - )]); - let cell = Arc::new(QuotaCell::new()); - let (mut poller, _) = make_poller(&cell, &url, Some(CANARY)); - poller.tick(unix_ms()); - assert_eq!(window(&cell, "five_hour").unwrap().status, "polled"); - // A later stream reading of one window replaces the poll's; the other - // window keeps the poll's. - cell.observe([stream_reading("five_hour", 90)]); - let five = window(&cell, "five_hour").unwrap(); - assert_eq!( - (five.status.as_str(), five.utilization_permille), - ("allowed", Some(90)) - ); - assert_eq!(window(&cell, "seven_day").unwrap().status, "polled"); - // And a later poll replaces the stream's. - let (url, _) = serve(vec![http("200 OK", "", &body(&reading(7.0), "null"))]); - let (mut again, _) = make_poller(&cell, &url, Some(CANARY)); - again.tick(unix_ms()); - assert_eq!( - window(&cell, "five_hour").unwrap().utilization_permille, - Some(70) - ); - } - - /// A token the test can rotate. - struct Rotating(Arc>>); - impl TokenSource for Rotating { - fn read(&self) -> Option> { - self.0.lock().unwrap().clone().map(Zeroizing::new) - } - } - - #[test] - fn a_refused_token_is_not_sent_again_until_it_changes() { - let (url, seen) = serve(vec![ - http("403 Forbidden", "", "{}"), - http("200 OK", "", &body("null", &reading(35.0))), - ]); - let token = Arc::new(Mutex::new(Some("first".to_string()))); - let lines = Arc::new(Mutex::new(Vec::new())); - let sink = lines.clone(); - let cell = Arc::new(QuotaCell::new()); - let mut poller = Poller::new( - cell.clone(), - Box::new(Rotating(token.clone())), - UsageClient::new(&url), - Box::new(move |line| sink.lock().unwrap().push(line.to_string())), - ); - let t = unix_ms(); - poller.tick(t); - assert_eq!(requests(&seen), 1); - assert!(lines.lock().unwrap()[0].contains("user:profile")); - // The same token, a quarter hour on and again: the endpoint is not asked. - assert_eq!(poller.tick(t + 16 * MIN), STALE); - poller.tick(t + 32 * MIN); - assert_eq!(requests(&seen), 1); - assert_eq!(lines.lock().unwrap().len(), 1, "one state, one line"); - // A credential that vanishes does not count as a new one. - *token.lock().unwrap() = None; - poller.tick(t + 48 * MIN); - assert_eq!(requests(&seen), 1); - // A rotated credential is tried at the next due time. - *token.lock().unwrap() = Some("second".to_string()); - poller.tick(t + 64 * MIN); - assert_eq!(requests(&seen), 2); - assert!(window(&cell, "seven_day").is_some()); - assert_eq!(lines.lock().unwrap()[1], State::Polled.line()); - } - - /// 2026-11-01 07:28:00 UTC, a Sunday. - fn sunday_morning_ms() -> u64 { - chrono::NaiveDate::from_ymd_opt(2026, 11, 1) - .unwrap() - .and_hms_opt(7, 28, 0) - .unwrap() - .and_utc() - .timestamp_millis() as u64 - } - - #[test] - fn retry_after_reads_all_three_http_date_forms() { - let now = sunday_morning_ms(); - for form in [ - "Sun, 01 Nov 2026 08:28:00 GMT", - "Sunday, 01-Nov-26 08:28:00 GMT", - "Sun Nov 1 08:28:00 2026", - "Sun Nov 1 08:28:00 2026", - ] { - assert_eq!(retry_after(form, now), Some(secs(3600)), "{form}"); - } - } - - #[test] - fn a_two_digit_year_more_than_fifty_years_ahead_is_the_past() { - let now = sunday_morning_ms(); - let on = |yy: &str| retry_after(&format!("Friday, 01-Jan-{yy} 00:00:00 GMT"), now); - assert!(on("76").unwrap() > Duration::ZERO, "2076 is 50 years ahead"); - assert_eq!(on("77"), Some(Duration::ZERO), "1977, not 2077"); - assert!(on("33").unwrap() > Duration::ZERO, "2033"); - assert_eq!(on("94"), Some(Duration::ZERO), "1994"); - } - - #[test] - fn retry_after_reads_seconds_and_http_dates() { - const DATE: &str = "Wed, 21 Oct 2026 07:28:00 GMT"; - let at = chrono::DateTime::parse_from_rfc2822(DATE) - .unwrap() - .timestamp_millis() as u64; - let three_hours = 3 * 3600 * 1000; - assert_eq!(retry_after(DATE, at - three_hours), Some(secs(3 * 3600))); - assert_eq!(retry_after(DATE, at), Some(Duration::ZERO)); - assert_eq!(retry_after(DATE, at + 1), Some(Duration::ZERO), "past"); - assert_eq!(retry_after("3600", at), Some(secs(3600))); - assert_eq!(retry_after(" 3600 ", at), Some(secs(3600))); - for bad in [ - "", - "soon", - "-5", - "3600.5", - "1, 2", - "Wed, 21 Oct 2026", - "Wednesday, 21-Oct-26-1 07:28:00 GMT", - "Wednesday, 21-Oct-2026 07:28:00 GMT", - "Wednesday, 21-Foo-26 07:28:00 GMT", - "Wednesday 21-Oct-26 07:28:00 GMT", - "Wednesday, 21-Oct-26 07:28 GMT", - "Wed Oct 21 07:28:00:01 2026", - "Wed Oct 32 07:28:00 2026", - "Wed Oct 21 25:28:00 2026", - ] { - assert_eq!(retry_after(bad, at), None, "{bad:?}"); - } - } - - #[test] - fn keys_beyond_the_two_plan_windows_are_not_windows() { - let populated = reading(77.0); - let body = format!( - r#"{{"five_hour":{},"seven_day":{},"seven_day_opus":{populated},"seven_day_sonnet":{populated},"extra_usage":{populated}}}"#, - reading(6.0), - reading(35.0) - ); - let names: Vec<_> = parse_usage(&body) - .unwrap() - .into_iter() - .map(|quota| quota.window) - .collect(); - assert_eq!(names, ["five_hour", "seven_day"]); - } - - #[test] - fn a_redirect_is_not_followed_with_the_token() { - let (target, target_seen) = serve(vec![http("200 OK", "", &body("null", &reading(35.0)))]); - let (url, _) = serve(vec![http( - "302 Found", - &format!("Location: {target}\r\n"), - "", - )]); - let cell = Arc::new(QuotaCell::new()); - let (mut poller, lines) = make_poller(&cell, &url, Some(CANARY)); - poller.tick(unix_ms()); - assert_eq!(requests(&target_seen), 0, "the redirect target was asked"); - assert!(cell.snapshot().windows.is_empty()); - assert_eq!(lines.lock().unwrap()[0], State::Unexpected.line()); - } - - #[test] - fn a_silent_endpoint_frees_the_poll_at_its_timeout() { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let url = format!("http://{}/u", listener.local_addr().unwrap()); - let holder = std::thread::spawn(move || { - // Accept, say nothing, keep the connection open. - let held = listener.accept(); - std::thread::sleep(Duration::from_secs(3)); - drop(held); - }); - let started = std::time::Instant::now(); - let answer = UsageClient::with_timeout(url, Duration::from_millis(300)) - .ask(&Zeroizing::new(CANARY.to_string())); - assert_eq!(answer, Answer::Unreachable); - assert!(started.elapsed() < Duration::from_secs(2)); - holder.join().unwrap(); - } - - #[test] - fn the_production_client_asks_the_provider_and_gives_up_after_ten_seconds() { - let client = UsageClient::production(); - assert_eq!(client.url, "https://api.anthropic.com/api/oauth/usage"); - assert_eq!(client.timeout, secs(10)); - assert_eq!(TIMEOUT, secs(10)); - } - - /// Answers one connection after `delay`, as a slow endpoint does. - fn serve_slowly(delay: Duration, reply: String) -> String { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let url = format!("http://{}/api/oauth/usage", listener.local_addr().unwrap()); - std::thread::spawn(move || { - let Ok((mut stream, _)) = listener.accept() else { - return; - }; - let mut request = vec![0u8; 4096]; - let _ = stream.read(&mut request); - std::thread::sleep(delay); - let _ = stream.write_all(reply.as_bytes()); - }); - url - } - - fn stream_event(window: &str, permille: u32, status: &str) -> PlanQuota { - PlanQuota { - status: status.to_string(), - ..stream_reading(window, permille) - } - } - - /// The stream speaks while a poll is in flight; the poll then answers - /// with a reading older than the stream's. Returns the cell after both. - fn stream_speaks_while_the_poll_is_in_flight(status: &str) -> Arc { - let url = serve_slowly( - Duration::from_millis(500), - http("200 OK", "", &body(&reading(50.0), &reading(35.0))), - ); - let cell = Arc::new(QuotaCell::new()); - let (mut poller, _) = make_poller(&cell, &url, Some(CANARY)); - std::thread::scope(|scope| { - let polling = scope.spawn(|| poller.tick(unix_ms())); - std::thread::sleep(Duration::from_millis(150)); - cell.observe([stream_event("five_hour", 1000, status)]); - polling.join().unwrap(); - }); - cell - } - - #[test] - fn a_poll_answered_late_does_not_replace_what_the_stream_saw_meanwhile() { - let cell = stream_speaks_while_the_poll_is_in_flight("allowed"); - let five = window(&cell, "five_hour").unwrap(); - assert_eq!( - (five.status.as_str(), five.utilization_permille), - ("allowed", Some(1000)) - ); - let seven = window(&cell, "seven_day").unwrap(); - assert_eq!( - (seven.status.as_str(), seven.utilization_permille), - ("polled", Some(350)), - "the window the stream did not name takes the poll's reading" - ); - } - - #[test] - fn a_slow_poll_cannot_lift_the_rejection_the_stream_reported_meanwhile() { - let cell = stream_speaks_while_the_poll_is_in_flight("rejected"); - let five = window(&cell, "five_hour").unwrap(); - assert_eq!( - (five.status.as_str(), five.utilization_permille), - ("rejected", Some(1000)) - ); - } - - #[test] - fn a_later_poll_does_not_lift_a_rejection_the_provider_reported_earlier() { - let (url, _) = serve(vec![http("200 OK", "", &body(&reading(50.0), "null"))]); - let cell = Arc::new(QuotaCell::new()); - cell.observe([stream_event("five_hour", 1000, "rejected")]); - let (mut poller, _) = make_poller(&cell, &url, Some(CANARY)); - poller.tick(unix_ms() + MIN); - let five = window(&cell, "five_hour").unwrap(); - assert_eq!( - (five.status.as_str(), five.utilization_permille), - ("rejected", Some(1000)) - ); - } - - #[test] - fn every_state_has_its_own_line() { - let states = [ - State::Polled, - State::NoToken, - State::RateLimited, - State::Refused, - State::NotFound, - State::Unreadable, - State::Unexpected, - State::Unreachable, - ]; - let lines: std::collections::BTreeSet<_> = states.iter().map(|s| s.line()).collect(); - assert_eq!(lines.len(), states.len()); - assert!(lines.iter().all(|line| line.starts_with("usage poll: "))); - } -} diff --git a/crates/claude-connector/tests/fixtures/claude-2.1.312-meter-call.jsonl b/crates/claude-connector/tests/fixtures/claude-2.1.312-meter-call.jsonl new file mode 100644 index 0000000..f7238e1 --- /dev/null +++ b/crates/claude-connector/tests/fixtures/claude-2.1.312-meter-call.jsonl @@ -0,0 +1,4 @@ +{"type":"system","subtype":"init","cwd":"/tmp/meter-probe.Ye68uq/work","session_id":"1c3d29e9-8277-4ee0-86df-efc86d994f01","tools":[],"mcp_servers":[],"model":"claude-haiku-5-5","permissionMode":"bypassPermissions","slash_commands":[],"apiKeySource":"none","claude_code_version":"2.1.312","output_style":"default","agents":["claude","Explore","general-purpose","Plan","statusline-setup"],"skills":[],"plugins":[{"name":"cc-plugin-sec-default","path":"builtin","source":"cc-plugin-sec-default@builtin"},{"name":"cc-plugin-agents-md","path":"builtin","source":"cc-plugin-agents-md@builtin"},{"name":"cc-plugin-telemetry","path":"builtin","source":"cc-plugin-telemetry@builtin"},{"name":"cc-plugin-plugin-authoring","path":"builtin","source":"cc-plugin-plugin-authoring@builtin"}],"capabilities":["interrupt_receipt_v1","interrupt_cancel_queued_v1","interrupt_send_now_v1","interrupt_requeue_taken_v1","msg_lifecycle_v1","request_marker_lists_v1","ask_user_question_unanswered_v1","lifecycle_wakeup_source_v1","sdk_mcp_tools_list_changed","sdk_mcp_manifests","mcp_read_resource_v1","mcp_tool_ui_meta_v1","ui_surface_v1","mcp_call_result_not_for_model_v1"],"analytics_disabled":false,"product_feedback_disabled":false,"uuid":"8ad7f8d7-2831-43f2-a221-8af6323dcbd9","memory_paths":{"auto":"/tmp/meter-probe.Ye68uq/home/projects/-tmp-meter-probe-Ye68uq-work/memory/"},"original_cwd":"/tmp/meter-probe.Ye68uq/work","additional_directories":[],"messaging_socket_path":"/tmp/cc-socks/2838104.sock","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","per_turn_effort_active":true,"view_mode":"default"} +{"type":"assistant","message":{"model":"claude-haiku-5-5","id":"msg_011Cgfu1sQvRAVmjwWNHc58s","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":384,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"1c3d29e9-8277-4ee0-86df-efc86d994f01","uuid":"f80db687-b098-415c-ac44-51c40a6cb63a","timestamp":"2026-11-03T11:14:35.024Z","request_id":"req_011Cgfu1sCXBsDrfz8a1LoMd"} +{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1794261600,"rateLimitType":"seven_day","overageStatus":"rejected","overageDisabledReason":"org_level_disabled","isUsingOverage":false,"unifiedWindows":{"seven_day":{"utilization":0.06,"resetsAt":1794261600,"status":"allowed"}}},"uuid":"acb3b364-a218-4965-b967-f73028254f8b","session_id":"1c3d29e9-8277-4ee0-86df-efc86d994f01"} +{"duration_api_ms":995,"stop_reason":"end_turn","session_id":"1c3d29e9-8277-4ee0-86df-efc86d994f01","total_cost_usd":0.000040400000000000006,"usage":{"input_tokens":384,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":4,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"global","iterations":[{"input_tokens":384,"output_tokens":4,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard","fallback_credit":null},"modelUsage":{"claude-haiku-5-5":{"inputTokens":384,"outputTokens":4,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.000040400000000000006,"contextWindow":1000000,"maxOutputTokens":128000,"thinkingTokens":0,"cacheCreationEphemeral5mInputTokens":0,"cacheCreationEphemeral1hInputTokens":0,"canonicalModel":"claude-haiku-5-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"safety_stops":0,"is_error":false,"num_turns":1,"subtype":"success","api_error_status":null,"result":"ok","ttft_ms":1210,"type":"result","duration_ms":1228,"uuid":"d0b1b31b-1428-45aa-b90e-d35903c9b91b","ttft_stream_ms":1209,"time_to_request_ms":232,"first_content_frame_ms":1209,"queued_turn_count":0,"result_index":0,"model_usage_since_last_result":{"claude-haiku-5-5":{"inputTokens":384,"outputTokens":4,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.000040400000000000006,"contextWindow":1000000,"maxOutputTokens":128000,"thinkingTokens":0,"cacheCreationEphemeral5mInputTokens":0,"cacheCreationEphemeral1hInputTokens":0,"canonicalModel":"claude-haiku-5-5","provider":"firstParty","costBasis":"list"}}}