Skip to main content

linesmith_core/data_context/
usage.rs

1//! OAuth `/api/oauth/usage` response + internal usage-data types.
2//!
3//! Two shapes live here:
4//!
5//! - [`UsageApiResponse`] mirrors the endpoint's wire JSON per
6//!   [ADR-0011](../../../../docs/adrs/0011-rate-limit-data-source.md)
7//!   §Endpoint contract. Recognized buckets sit in named `Option`
8//!   fields; codenamed/unreleased buckets land in `unknown_buckets`
9//!   via `#[serde(flatten)]` so forward-compat is lossless.
10//! - [`UsageData`] is the enum segments consume after the fallback
11//!   cascade lands. Per [ADR-0013](../../../../docs/adrs/0013-jsonl-fallback-carries-token-counts.md),
12//!   the variant IS the provenance tag: `Endpoint(EndpointUsage)`
13//!   carries authoritative endpoint data; `Jsonl(JsonlUsage)` carries
14//!   raw token counts aggregated from transcripts so segments can
15//!   render `~5h: 420k` instead of synthesizing a percentage against a
16//!   tier ceiling we don't know.
17//!
18//! The endpoint client converts wire → internal via
19//! [`UsageApiResponse::into_endpoint_usage`]; the JSONL-mode cascade
20//! constructs a [`JsonlUsage`] directly from the aggregator output in
21//! `cascade.rs`.
22
23use std::collections::HashMap;
24
25use jiff::Timestamp;
26use serde::{Deserialize, Serialize};
27
28use super::jsonl::TokenCounts;
29use crate::input::Percent;
30
31// --- Wire shape ---------------------------------------------------------
32
33/// Shape of the OAuth `/api/oauth/usage` endpoint response. Every
34/// recognized bucket is `Option` because the endpoint omits (or emits
35/// `null` for) buckets that don't apply to the account's tier, and
36/// `unknown_buckets` captures codenamed / unreleased buckets Anthropic
37/// may add without notice (`omelette_*`, `iguana_*`, `cowork`, etc.
38/// observed live 2026-04-18). See `docs/research/claude-data-files.md`
39/// §Raw data for the reference capture.
40#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
41#[non_exhaustive]
42pub struct UsageApiResponse {
43    #[serde(default)]
44    pub five_hour: Option<UsageBucket>,
45
46    #[serde(default)]
47    pub seven_day: Option<UsageBucket>,
48
49    #[serde(default)]
50    pub seven_day_opus: Option<UsageBucket>,
51
52    #[serde(default)]
53    pub seven_day_sonnet: Option<UsageBucket>,
54
55    #[serde(default)]
56    pub seven_day_oauth_apps: Option<UsageBucket>,
57
58    #[serde(default)]
59    pub extra_usage: Option<ExtraUsage>,
60
61    /// Forward-compat catch-all. Any top-level key not matched above
62    /// lands here as raw JSON so we preserve what the endpoint sent
63    /// even when we don't yet know what to do with it.
64    #[serde(flatten)]
65    pub unknown_buckets: HashMap<String, serde_json::Value>,
66}
67
68/// Names of every recognized top-level field on
69/// [`UsageApiResponse`]. Exported so `linesmith doctor` can check
70/// "did the endpoint return any forward-compat keys?" without
71/// duplicating the field list — the [`KNOWN_BUCKETS_PARITY`] test
72/// below pins this against `UsageApiResponse` so the two can't
73/// drift.
74pub const KNOWN_BUCKETS: &[&str] = &[
75    "five_hour",
76    "seven_day",
77    "seven_day_opus",
78    "seven_day_sonnet",
79    "seven_day_oauth_apps",
80    "extra_usage",
81];
82
83/// Codenamed forward-compat buckets observed in the live endpoint
84/// during research captures (see `docs/research/claude-data-files.md`
85/// §Raw data, 2026-04-18 capture). These are unrecognized by
86/// `UsageApiResponse`'s strict struct fields but Anthropic ships
87/// them on every response — gating the doctor's
88/// "endpoint.shape_current" WARN on this list keeps the report quiet
89/// on healthy accounts while preserving the WARN for *new* unknown
90/// keys (the actual signal a maintainer wants).
91///
92/// Refresh whenever the research doc captures a new live response.
93pub const RESEARCH_DOCUMENTED_BUCKETS: &[&str] = &[
94    "iguana_necktie",
95    "omelette_promotional",
96    "seven_day_cowork",
97    "seven_day_omelette",
98    "tangelo",
99];
100
101/// Utilization plus reset-time for a single rolling window.
102///
103/// `resets_at` is `Option` because the live endpoint has been observed
104/// to emit `null` for codenamed buckets (e.g. `seven_day_omelette`
105/// in the 2026-04-18 capture) and we can't rule out the same for
106/// recognized buckets under some account states.
107#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq)]
108pub struct UsageBucket {
109    /// Percent used within the window. Clamped to `[0, 100]` during
110    /// deserialization per `rate-limit-segments.md` §Edge cases
111    /// ("clamp silently ... defends against unexpected API changes").
112    #[serde(deserialize_with = "deserialize_clamped_percent")]
113    pub utilization: Percent,
114
115    #[serde(default)]
116    pub resets_at: Option<Timestamp>,
117}
118
119/// Overage-credit tracking for accounts with extra-usage enabled.
120/// `is_enabled` is the load-bearing flag: when `false`, every other
121/// field is typically `null` in the live endpoint.
122#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
123#[non_exhaustive]
124pub struct ExtraUsage {
125    #[serde(default)]
126    pub is_enabled: Option<bool>,
127
128    #[serde(default, deserialize_with = "deserialize_optional_clamped_percent")]
129    pub utilization: Option<Percent>,
130
131    #[serde(default)]
132    pub monthly_limit: Option<f64>,
133
134    #[serde(default)]
135    pub used_credits: Option<f64>,
136
137    /// ISO-4217 currency code. Segments render `$` for `"USD"` or
138    /// null/missing, and the code as a prefix (e.g. `"EUR 12.50"`)
139    /// otherwise, per `rate-limit-segments.md` §Precision and
140    /// clamping.
141    #[serde(default)]
142    pub currency: Option<String>,
143}
144
145// --- Internal shape -----------------------------------------------------
146
147/// What [`DataContext::usage`](super::DataContext::usage) surfaces
148/// after the cascade in `docs/specs/data-fetching.md` §OAuth fallback
149/// cascade finishes. The variant IS the provenance tag per
150/// [ADR-0013](../../../../docs/adrs/0013-jsonl-fallback-carries-token-counts.md):
151/// segments dispatch on it to pick between percent rendering
152/// (endpoint) and raw-token rendering (JSONL). `#[non_exhaustive]`
153/// leaves room for a future third source without a SemVer break.
154#[derive(Debug, Clone, PartialEq)]
155#[non_exhaustive]
156pub enum UsageData {
157    Endpoint(EndpointUsage),
158    Jsonl(JsonlUsage),
159}
160
161/// Data from a successful OAuth `/api/oauth/usage` response (possibly
162/// served from cache). `unknown_buckets` carries codenamed buckets
163/// forward so plugins can inspect them; core segments don't read it.
164#[derive(Debug, Clone, PartialEq)]
165#[non_exhaustive]
166pub struct EndpointUsage {
167    pub five_hour: Option<UsageBucket>,
168    pub seven_day: Option<UsageBucket>,
169    pub seven_day_opus: Option<UsageBucket>,
170    pub seven_day_sonnet: Option<UsageBucket>,
171    pub seven_day_oauth_apps: Option<UsageBucket>,
172    pub extra_usage: Option<ExtraUsage>,
173    pub unknown_buckets: HashMap<String, serde_json::Value>,
174}
175
176/// Data derived from the JSONL transcript aggregator. `seven_day` is
177/// always populated (zero-valued on an empty transcript); `five_hour`
178/// is `None` when the current 5h block has no recent activity, per
179/// `docs/specs/jsonl-aggregation.md`. Fields are `pub(crate)` so the
180/// aggregator+cascade own the construction invariants; segments in
181/// this crate read them directly.
182#[derive(Debug, Clone, PartialEq)]
183pub struct JsonlUsage {
184    pub(crate) five_hour: Option<FiveHourWindow>,
185    pub(crate) seven_day: SevenDayWindow,
186}
187
188impl JsonlUsage {
189    #[must_use]
190    pub(crate) fn new(five_hour: Option<FiveHourWindow>, seven_day: SevenDayWindow) -> Self {
191        Self {
192            five_hour,
193            seven_day,
194        }
195    }
196}
197
198/// Active-block window surfaced to segments under JSONL fallback.
199///
200/// # Invariants
201///
202/// - `ends_at()` is derived as `start + 5h`, so the "block lasts 5
203///   hours" invariant is structural rather than prose — the window
204///   cannot drift from its anchor after construction.
205/// - `start` is expected to be UTC-floor-to-hour in production,
206///   matching [`FiveHourBlock::start`] from the aggregator. The
207///   cascade honors this precondition; `FiveHourWindow::new` itself
208///   does not enforce it because legitimate test fixtures pass
209///   mid-hour starts to exercise minute-level countdown rendering
210///   that wouldn't occur with a real (hour-aligned) aggregator output.
211#[derive(Debug, Clone, PartialEq)]
212pub struct FiveHourWindow {
213    pub(crate) tokens: TokenCounts,
214    pub(crate) start: Timestamp,
215}
216
217impl FiveHourWindow {
218    #[must_use]
219    pub(crate) fn new(tokens: TokenCounts, start: Timestamp) -> Self {
220        Self { tokens, start }
221    }
222
223    /// Nominal close of the block: `start + 5h`. When the window was
224    /// built from a `FiveHourBlock` via the cascade, this equals
225    /// [`FiveHourBlock::end`]; otherwise it's just the direct
226    /// derivation from whatever `start` the caller passed.
227    #[must_use]
228    pub(crate) fn ends_at(&self) -> Timestamp {
229        self.start + jiff::SignedDuration::from_hours(5)
230    }
231}
232
233/// Rolling 7-day window under JSONL fallback. No `resets_at`: this is
234/// a rolling window, not a hard-reset bucket, so the `rate_limit_7d_reset`
235/// segment hides entirely under JSONL per
236/// `docs/specs/rate-limit-segments.md` §JSONL-fallback display.
237#[derive(Debug, Clone, PartialEq)]
238pub struct SevenDayWindow {
239    pub(crate) tokens: TokenCounts,
240}
241
242impl SevenDayWindow {
243    #[must_use]
244    pub(crate) fn new(tokens: TokenCounts) -> Self {
245        Self { tokens }
246    }
247}
248
249impl UsageApiResponse {
250    /// Convert the wire shape into the internal [`EndpointUsage`].
251    /// Unknown buckets are preserved so plugin-facing mirrors can
252    /// surface them; the wire `UsageApiResponse` is not retained.
253    #[must_use]
254    pub fn into_endpoint_usage(self) -> EndpointUsage {
255        EndpointUsage {
256            five_hour: self.five_hour,
257            seven_day: self.seven_day,
258            seven_day_opus: self.seven_day_opus,
259            seven_day_sonnet: self.seven_day_sonnet,
260            seven_day_oauth_apps: self.seven_day_oauth_apps,
261            extra_usage: self.extra_usage,
262            unknown_buckets: self.unknown_buckets,
263        }
264    }
265}
266
267// --- Deserializer helpers ----------------------------------------------
268
269fn deserialize_clamped_percent<'de, D>(de: D) -> Result<Percent, D::Error>
270where
271    D: serde::Deserializer<'de>,
272{
273    let raw = f64::deserialize(de)?;
274    if raw.is_nan() {
275        return Err(serde::de::Error::custom("utilization is NaN"));
276    }
277    let clamped = raw.clamp(0.0, 100.0);
278    Percent::from_f64(clamped).ok_or_else(|| {
279        serde::de::Error::custom(format!("utilization {raw} failed to clamp into [0, 100]"))
280    })
281}
282
283fn deserialize_optional_clamped_percent<'de, D>(de: D) -> Result<Option<Percent>, D::Error>
284where
285    D: serde::Deserializer<'de>,
286{
287    let raw: Option<f64> = Option::deserialize(de)?;
288    match raw {
289        None => Ok(None),
290        Some(v) if v.is_nan() => Err(serde::de::Error::custom("utilization is NaN")),
291        Some(v) => {
292            let clamped = v.clamp(0.0, 100.0);
293            Percent::from_f64(clamped).map(Some).ok_or_else(|| {
294                serde::de::Error::custom(format!("utilization {v} failed to clamp into [0, 100]"))
295            })
296        }
297    }
298}
299
300// --- Tests --------------------------------------------------------------
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    /// Tripwire: `KNOWN_BUCKETS` must list every recognized field
307    /// on `UsageApiResponse`. If a new bucket lands here without
308    /// the const being updated, `linesmith doctor` would WARN
309    /// forever on every healthy endpoint response. Build a struct
310    /// with all known fields populated and verify a JSON round-trip
311    /// produces exactly the expected key set.
312    #[test]
313    fn known_buckets_matches_usage_api_response_fields() {
314        let response = UsageApiResponse {
315            five_hour: Some(UsageBucket {
316                utilization: Percent::new(0.0).expect("0 percent"),
317                resets_at: None,
318            }),
319            seven_day: Some(UsageBucket {
320                utilization: Percent::new(0.0).expect("0 percent"),
321                resets_at: None,
322            }),
323            seven_day_opus: Some(UsageBucket {
324                utilization: Percent::new(0.0).expect("0 percent"),
325                resets_at: None,
326            }),
327            seven_day_sonnet: Some(UsageBucket {
328                utilization: Percent::new(0.0).expect("0 percent"),
329                resets_at: None,
330            }),
331            seven_day_oauth_apps: Some(UsageBucket {
332                utilization: Percent::new(0.0).expect("0 percent"),
333                resets_at: None,
334            }),
335            extra_usage: Some(ExtraUsage {
336                is_enabled: Some(false),
337                utilization: None,
338                monthly_limit: None,
339                used_credits: None,
340                currency: None,
341            }),
342            unknown_buckets: HashMap::new(),
343        };
344        let value = serde_json::to_value(&response).expect("serialize");
345        let mut keys: Vec<String> = value
346            .as_object()
347            .expect("response is an object")
348            .keys()
349            .cloned()
350            .collect();
351        keys.sort();
352        let mut expected: Vec<String> = KNOWN_BUCKETS.iter().map(|s| (*s).to_string()).collect();
353        expected.sort();
354        assert_eq!(
355            keys, expected,
356            "KNOWN_BUCKETS drifted from UsageApiResponse; update both lists",
357        );
358    }
359
360    /// Live `/api/oauth/usage` capture from 2026-04-18 (Max-tier user),
361    /// payload-equivalent to `docs/research/claude-data-files.md`
362    /// §Raw data (whitespace differs, fields preserved). Keep in sync
363    /// if the research doc is refreshed.
364    const LIVE_CAPTURE: &str = r#"{
365        "five_hour": {
366            "utilization": 22.0,
367            "resets_at": "2026-04-19T05:00:00.112536+00:00"
368        },
369        "seven_day": {
370            "utilization": 33.0,
371            "resets_at": "2026-04-23T19:00:01.112554+00:00"
372        },
373        "seven_day_oauth_apps": null,
374        "seven_day_opus": null,
375        "seven_day_sonnet": {
376            "utilization": 0.0,
377            "resets_at": "2026-04-24T16:00:00.112562+00:00"
378        },
379        "seven_day_cowork": null,
380        "seven_day_omelette": { "utilization": 0.0, "resets_at": null },
381        "iguana_necktie": null,
382        "omelette_promotional": null,
383        "extra_usage": {
384            "is_enabled": false,
385            "monthly_limit": null,
386            "used_credits": null,
387            "utilization": null,
388            "currency": null
389        }
390    }"#;
391
392    #[test]
393    fn parses_live_capture_losslessly() {
394        let resp: UsageApiResponse = serde_json::from_str(LIVE_CAPTURE).expect("parse");
395
396        assert_eq!(resp.five_hour.unwrap().utilization.value(), 22.0);
397        assert_eq!(resp.seven_day.unwrap().utilization.value(), 33.0);
398        assert_eq!(resp.seven_day_sonnet.unwrap().utilization.value(), 0.0);
399        assert!(resp.seven_day_opus.is_none());
400        assert!(resp.seven_day_oauth_apps.is_none());
401
402        let extra = resp.extra_usage.unwrap();
403        assert_eq!(extra.is_enabled, Some(false));
404        assert!(extra.monthly_limit.is_none());
405        assert!(extra.currency.is_none());
406
407        // Codenamed buckets land in the catch-all.
408        assert_eq!(resp.unknown_buckets.len(), 4);
409        for key in [
410            "seven_day_cowork",
411            "seven_day_omelette",
412            "iguana_necktie",
413            "omelette_promotional",
414        ] {
415            assert!(
416                resp.unknown_buckets.contains_key(key),
417                "expected {key} in unknown_buckets",
418            );
419        }
420    }
421
422    #[test]
423    fn parses_empty_response() {
424        let resp: UsageApiResponse = serde_json::from_str("{}").expect("parse");
425        assert!(resp.five_hour.is_none());
426        assert!(resp.seven_day.is_none());
427        assert!(resp.extra_usage.is_none());
428        assert!(resp.unknown_buckets.is_empty());
429    }
430
431    #[test]
432    fn injected_codename_lands_in_unknown_buckets() {
433        let json = r#"{
434            "five_hour": { "utilization": 10.0, "resets_at": "2026-04-19T05:00:00Z" },
435            "quokka_experimental": { "utilization": 99.0, "resets_at": null }
436        }"#;
437        let resp: UsageApiResponse = serde_json::from_str(json).expect("parse");
438        assert!(resp.five_hour.is_some());
439        assert!(resp.unknown_buckets.contains_key("quokka_experimental"));
440    }
441
442    #[test]
443    fn bucket_resets_at_accepts_null() {
444        let json = r#"{ "utilization": 0.0, "resets_at": null }"#;
445        let bucket: UsageBucket = serde_json::from_str(json).expect("parse");
446        assert_eq!(bucket.utilization.value(), 0.0);
447        assert!(bucket.resets_at.is_none());
448    }
449
450    #[test]
451    fn utilization_clamps_above_one_hundred() {
452        let json = r#"{ "utilization": 150.5, "resets_at": "2026-04-19T05:00:00Z" }"#;
453        let bucket: UsageBucket = serde_json::from_str(json).expect("parse");
454        assert_eq!(bucket.utilization.value(), 100.0);
455    }
456
457    #[test]
458    fn utilization_clamps_below_zero() {
459        let json = r#"{ "utilization": -5.0, "resets_at": "2026-04-19T05:00:00Z" }"#;
460        let bucket: UsageBucket = serde_json::from_str(json).expect("parse");
461        assert_eq!(bucket.utilization.value(), 0.0);
462    }
463
464    #[test]
465    fn utilization_rejects_non_number() {
466        let json = r#"{ "utilization": "hello", "resets_at": null }"#;
467        assert!(serde_json::from_str::<UsageBucket>(json).is_err());
468    }
469
470    #[test]
471    fn extra_usage_null_utilization_parses_as_none() {
472        let json = r#"{
473            "is_enabled": true,
474            "utilization": null,
475            "monthly_limit": 100.0,
476            "used_credits": null,
477            "currency": null
478        }"#;
479        let extra: ExtraUsage = serde_json::from_str(json).expect("parse");
480        assert_eq!(extra.is_enabled, Some(true));
481        assert!(extra.utilization.is_none());
482        assert_eq!(extra.monthly_limit, Some(100.0));
483    }
484
485    #[test]
486    fn extra_usage_utilization_clamps() {
487        let json = r#"{ "utilization": 250.0 }"#;
488        let extra: ExtraUsage = serde_json::from_str(json).expect("parse");
489        assert_eq!(extra.utilization.unwrap().value(), 100.0);
490    }
491
492    #[test]
493    fn into_endpoint_usage_preserves_unknown_buckets() {
494        // Forward-compat: codenamed buckets survive the wire→internal
495        // hop so plugin ctx mirrors can surface them. The pre-ADR-0013
496        // shape dropped `unknown_buckets` at this boundary.
497        let resp: UsageApiResponse = serde_json::from_str(LIVE_CAPTURE).expect("parse");
498        assert_eq!(resp.unknown_buckets.len(), 4);
499
500        let endpoint = resp.into_endpoint_usage();
501        assert!(endpoint.five_hour.is_some());
502        assert!(endpoint.seven_day.is_some());
503        assert!(endpoint.extra_usage.is_some());
504        assert_eq!(endpoint.unknown_buckets.len(), 4);
505    }
506
507    #[test]
508    fn jsonl_usage_smart_ctor_stores_windows() {
509        let seven = SevenDayWindow::new(TokenCounts::default());
510        let jsonl = JsonlUsage::new(None, seven.clone());
511        assert!(jsonl.five_hour.is_none());
512        assert_eq!(jsonl.seven_day, seven);
513    }
514}