Skip to main content

pitboard_core/
usage.rs

1//! One view of "how much is left", whatever shape it arrived in. Usage comes as a
2//! `limits[]` array or as named `five_hour`/`seven_day` objects; both are normalised at the
3//! boundary, and a value that fails to normalise is dropped rather than drawn.
4
5use crate::time;
6use serde_json::Value;
7
8#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
9pub struct Window {
10    pub kind: String,
11    pub scope: Option<String>,
12    pub percent: f64,
13    pub resets_at: Option<i64>,
14    pub is_active: bool,
15    /// How Anthropic grades this row, when it grades it. Its word, not a threshold of
16    /// pitboard's own, and absent in a reading taken before pitboard read this field.
17    #[serde(default)]
18    pub severity: Option<String>,
19    /// How long the window runs, in seconds, where that is known.
20    ///
21    /// What makes a limit comparable to itself over time: a reset time alone cannot say
22    /// how long a window is, because the time left shrinks as the window runs out. Stated
23    /// outright by OpenAI, implied by the kind for Anthropic, and absent from a reading
24    /// taken before pitboard kept it.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub length_seconds: Option<i64>,
27}
28
29/// How long one of Anthropic's windows runs, from its kind.
30///
31/// Anthropic names its windows rather than timing them: `session` and the older
32/// `five_hour` are the five-hour limit, and every `weekly_` kind, like the older
33/// `seven_day`, runs a week. A kind not listed here has no length pitboard can vouch for.
34pub fn anthropic_window_length(kind: &str) -> Option<i64> {
35    match kind {
36        "session" | "five_hour" => Some(5 * 3600),
37        "seven_day" => Some(7 * 86_400),
38        weekly if weekly.starts_with("weekly_") => Some(7 * 86_400),
39        _ => None,
40    }
41}
42
43/// Where a measurement came from, so a stale number is never shown as a live one.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum Source {
47    /// Asked of Anthropic just now.
48    Live,
49    /// Copied from Claude Code's own cache, which it refreshes only when it asks.
50    ClaudeCodeCache,
51    /// The last live reading pitboard took itself.
52    Remembered,
53}
54
55#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
56pub struct Snapshot {
57    pub windows: Vec<Window>,
58    pub observed_at: Option<i64>,
59    pub account_uuid: Option<String>,
60    pub source: Source,
61}
62
63/// A share of a limit. Past 100 is real, once a limit is exceeded; below zero is not.
64fn percent(v: &Value) -> Option<f64> {
65    let p = v.as_f64()?;
66    (p.is_finite() && p >= 0.0).then_some(p)
67}
68
69fn window_from_limit(l: &Value) -> Option<Window> {
70    // A row is scoped to a model or to a surface; either way the scope is what makes it
71    // narrower than the account's own limit.
72    let named = |what: &str| {
73        l.get("scope")
74            .and_then(|s| s.get(what))
75            .and_then(|m| m.get("display_name"))
76            .and_then(Value::as_str)
77            .map(str::to_owned)
78    };
79    Some(Window {
80        kind: l.get("kind")?.as_str()?.to_string(),
81        scope: named("model").or_else(|| named("surface")),
82        severity: l.get("severity").and_then(Value::as_str).map(str::to_owned),
83        percent: percent(l.get("percent")?)?,
84        resets_at: l
85            .get("resets_at")
86            .and_then(Value::as_str)
87            .and_then(time::parse),
88        is_active: l.get("is_active").and_then(Value::as_bool).unwrap_or(false),
89        length_seconds: l
90            .get("kind")
91            .and_then(Value::as_str)
92            .and_then(anthropic_window_length),
93    })
94}
95
96fn window_from_named(kind: &str, v: &Value) -> Option<Window> {
97    Some(Window {
98        kind: kind.to_string(),
99        scope: None,
100        severity: None,
101        percent: percent(v.get("utilization")?)?,
102        resets_at: v
103            .get("resets_at")
104            .and_then(Value::as_str)
105            .and_then(time::parse),
106        is_active: false,
107        length_seconds: anthropic_window_length(kind),
108    })
109}
110
111/// The API answer and Claude Code's cached copy of it share this shape.
112fn windows_of(u: &Value) -> Vec<Window> {
113    let mut windows: Vec<Window> = u
114        .get("limits")
115        .and_then(Value::as_array)
116        .map(|ls| ls.iter().filter_map(window_from_limit).collect())
117        .unwrap_or_default();
118    if windows.is_empty() {
119        for kind in ["five_hour", "seven_day"] {
120            if let Some(w) = u.get(kind).and_then(|v| window_from_named(kind, v)) {
121                windows.push(w);
122            }
123        }
124    }
125    windows
126}
127
128/// A reading taken from Anthropic's usage endpoint just now.
129pub fn from_usage_object(u: &Value, observed_at: i64) -> Snapshot {
130    Snapshot {
131        windows: windows_of(u),
132        observed_at: Some(observed_at),
133        account_uuid: None,
134        source: Source::Live,
135    }
136}
137
138/// Claude Code's own cache. It records the account it was measured for, so a reading for
139/// another account can be told apart and ignored.
140pub fn from_config_cache(config: &Value) -> Option<Snapshot> {
141    let c = config.get("cachedUsageUtilization")?;
142    Some(Snapshot {
143        windows: windows_of(c.get("utilization")?),
144        observed_at: c
145            .get("fetchedAtMs")
146            .and_then(Value::as_i64)
147            .map(|ms| ms / 1000),
148        account_uuid: c
149            .get("accountUuid")
150            .and_then(Value::as_str)
151            .map(str::to_owned),
152        source: Source::ClaudeCodeCache,
153    })
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    /// Trimmed from this machine's real `~/.claude.json`.
161    fn real_config() -> Value {
162        serde_json::json!({"cachedUsageUtilization": {
163        "fetchedAtMs": 1789933772292i64,
164        "accountUuid": "1f0e2d3c-4b5a-4968-8776-a5b4c3d2e1f0",
165        "utilization": {
166            "five_hour": {"utilization": 62, "resets_at": "2026-09-20T22:20:00.095287+00:00"},
167            "seven_day": {"utilization": 48, "resets_at": "2026-09-27T02:00:00.095306+00:00"},
168            "limits": [
169                {"kind": "session", "group": "session", "percent": 62,
170                 "resets_at": "2026-09-20T22:20:00.095287+00:00", "scope": null, "is_active": true},
171                {"kind": "weekly_all", "group": "weekly", "percent": 48,
172                 "resets_at": "2026-09-27T02:00:00.095306+00:00", "scope": null, "is_active": false},
173                {"kind": "weekly_scoped", "group": "weekly", "percent": 0,
174                 "resets_at": "2026-09-27T02:00:00+00:00",
175                 "scope": {"model": {"id": null, "display_name": "Fable"}}, "is_active": false}
176            ]}}})
177    }
178
179    #[test]
180    fn reads_the_real_cache_shape() {
181        let s = from_config_cache(&real_config()).expect("should parse");
182        assert_eq!(s.windows.len(), 3);
183        assert_eq!(
184            s.account_uuid.as_deref(),
185            Some("1f0e2d3c-4b5a-4968-8776-a5b4c3d2e1f0")
186        );
187        assert_eq!(s.observed_at, Some(1789933772));
188        let scoped = s
189            .windows
190            .iter()
191            .find(|w| w.kind == "weekly_scoped")
192            .unwrap();
193        assert_eq!(scoped.scope.as_deref(), Some("Fable"));
194    }
195
196    #[test]
197    fn falls_back_to_the_named_windows_when_limits_is_missing() {
198        let mut c = real_config();
199        c["cachedUsageUtilization"]["utilization"]
200            .as_object_mut()
201            .unwrap()
202            .remove("limits");
203        let s = from_config_cache(&c).unwrap();
204        assert_eq!(s.windows.len(), 2);
205        assert_eq!(s.windows[0].kind, "five_hour");
206        assert_eq!(s.windows[0].percent, 62.0);
207    }
208
209    #[test]
210    fn a_nonsense_percentage_is_dropped_rather_than_drawn() {
211        for nonsense in [serde_json::json!(-5), serde_json::json!("75")] {
212            let mut c = real_config();
213            c["cachedUsageUtilization"]["utilization"]["limits"][0]["percent"] = nonsense;
214            assert_eq!(from_config_cache(&c).unwrap().windows.len(), 2);
215        }
216    }
217
218    #[test]
219    fn an_exceeded_limit_is_kept_not_dropped() {
220        let mut c = real_config();
221        c["cachedUsageUtilization"]["utilization"]["limits"][0]["percent"] = serde_json::json!(104);
222        let s = from_config_cache(&c).unwrap();
223        assert_eq!(s.windows.len(), 3);
224        assert_eq!(s.windows[0].percent, 104.0);
225    }
226
227    #[test]
228    fn missing_cache_is_not_an_error() {
229        assert!(from_config_cache(&serde_json::json!({})).is_none());
230    }
231}