Skip to main content

ai_usagebar/anthropic/
types.rs

1//! Wire types for the Anthropic OAuth usage endpoint.
2//!
3//! Every field is `Option<T>` or has `#[serde(default)]` — the endpoint is
4//! undocumented and the shape varies across plan tiers and over time. The
5//! lossy `serde(default)` approach matches claudebar's jq pattern of
6//! `.field // empty`.
7
8use serde::{Deserialize, Serialize};
9
10use crate::usage::{AnthropicSnapshot, Cents, ExtraUsage, ScopedWindow, UsageWindow};
11
12/// Top-level response from `GET /api/oauth/usage`.
13#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
14pub struct UsageResponse {
15    #[serde(default)]
16    pub five_hour: Option<Window>,
17    #[serde(default)]
18    pub seven_day: Option<Window>,
19    #[serde(default)]
20    pub seven_day_sonnet: Option<Window>,
21    #[serde(default)]
22    pub extra_usage: Option<ExtraUsageBlock>,
23    /// Newer per-limit array. Carries model-scoped weekly windows
24    /// (`kind == "weekly_scoped"`, e.g. the Fable weekly cap) that have no
25    /// dedicated `seven_day_*` field.
26    #[serde(default)]
27    pub limits: Vec<LimitEntry>,
28}
29
30/// One entry of the `limits[]` array. Only `weekly_scoped` entries with a
31/// model display name are lifted into the snapshot; everything else in the
32/// array duplicates `five_hour`/`seven_day`.
33#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
34pub struct LimitEntry {
35    #[serde(default)]
36    pub kind: Option<String>,
37    #[serde(default)]
38    pub percent: Option<f64>,
39    #[serde(default)]
40    pub resets_at: Option<String>,
41    #[serde(default)]
42    pub scope: Option<LimitScope>,
43}
44
45#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
46pub struct LimitScope {
47    #[serde(default)]
48    pub model: Option<LimitModel>,
49}
50
51#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
52pub struct LimitModel {
53    #[serde(default)]
54    pub display_name: Option<String>,
55}
56
57/// A single usage window — `utilization` is `0..=100` (integer percent).
58#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
59pub struct Window {
60    #[serde(default)]
61    pub utilization: f64,
62    #[serde(default)]
63    pub resets_at: Option<String>,
64}
65
66/// Pay-as-you-go extra usage. Both money values are integer cents, but the
67/// API sometimes returns them as floats (e.g. `0.0`) so we accept either.
68#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
69pub struct ExtraUsageBlock {
70    #[serde(default)]
71    pub is_enabled: bool,
72    #[serde(default, deserialize_with = "de_int_or_float")]
73    pub monthly_limit: i64,
74    #[serde(default, deserialize_with = "de_int_or_float")]
75    pub used_credits: i64,
76}
77
78/// Accept JSON int or float, truncating floats. Mirrors claudebar's
79/// `(.field // 0) | floor` jq pattern.
80fn de_int_or_float<'de, D>(d: D) -> std::result::Result<i64, D::Error>
81where
82    D: serde::Deserializer<'de>,
83{
84    let v = serde_json::Value::deserialize(d)?;
85    match v {
86        serde_json::Value::Null => Ok(0),
87        serde_json::Value::Number(n) => {
88            if let Some(i) = n.as_i64() {
89                Ok(i)
90            } else if let Some(f) = n.as_f64() {
91                Ok(f as i64)
92            } else {
93                Err(serde::de::Error::custom("number out of i64 range"))
94            }
95        }
96        other => Err(serde::de::Error::custom(format!(
97            "expected number or null, got {other:?}"
98        ))),
99    }
100}
101
102impl UsageResponse {
103    /// Lift the wire response into our canonical [`AnthropicSnapshot`].
104    ///
105    /// `plan_label` is the rendered plan name ("Max 5x" etc.), derived from
106    /// the credentials file (since the usage endpoint doesn't include it).
107    pub fn into_snapshot(self, plan_label: String) -> AnthropicSnapshot {
108        // Window durations are constants per claudebar:172-173.
109        const SESSION: chrono::Duration = chrono::Duration::hours(5);
110        const WEEKLY: chrono::Duration = chrono::Duration::days(7);
111
112        fn to_window(w: Option<Window>, dur: chrono::Duration) -> UsageWindow {
113            let Some(w) = w else {
114                return UsageWindow {
115                    utilization_pct: 0,
116                    resets_at: None,
117                    window_duration: dur,
118                };
119            };
120            UsageWindow {
121                // Round to nearest, matching claudebar's `| round` jq filter.
122                utilization_pct: w.utilization.round() as i32,
123                resets_at: w
124                    .resets_at
125                    .as_deref()
126                    .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
127                    .map(|dt| dt.with_timezone(&chrono::Utc)),
128                window_duration: dur,
129            }
130        }
131
132        let session = to_window(self.five_hour, SESSION);
133        let weekly = to_window(self.seven_day, WEEKLY);
134        let sonnet = self.seven_day_sonnet.map(|w| to_window(Some(w), WEEKLY));
135        let extra = self
136            .extra_usage
137            .filter(|e| e.is_enabled)
138            .map(|e| ExtraUsage {
139                limit: Cents(e.monthly_limit),
140                spent: Cents(e.used_credits),
141            });
142        let scoped = self
143            .limits
144            .into_iter()
145            .filter(|l| l.kind.as_deref() == Some("weekly_scoped"))
146            .filter_map(|l| {
147                let label = l.scope?.model?.display_name?;
148                let window = to_window(
149                    Some(Window {
150                        utilization: l.percent.unwrap_or(0.0),
151                        resets_at: l.resets_at,
152                    }),
153                    WEEKLY,
154                );
155                Some(ScopedWindow { label, window })
156            })
157            .collect();
158
159        AnthropicSnapshot {
160            plan: plan_label,
161            session,
162            weekly,
163            sonnet,
164            scoped,
165            extra,
166        }
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn parses_full_response() {
176        let raw = r#"{
177            "five_hour":         {"utilization": 42.7, "resets_at": "2026-05-23T17:30:00Z"},
178            "seven_day":         {"utilization": 27.0, "resets_at": "2026-05-30T12:00:00Z"},
179            "seven_day_sonnet":  {"utilization":  4.2, "resets_at": "2026-05-30T12:00:00Z"},
180            "extra_usage":       {"is_enabled": true, "monthly_limit": 5000, "used_credits": 250}
181        }"#;
182        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
183        let snap = resp.into_snapshot("Max 5x".into());
184        assert_eq!(snap.session.utilization_pct, 43); // rounded
185        assert_eq!(snap.weekly.utilization_pct, 27);
186        assert_eq!(snap.sonnet.as_ref().unwrap().utilization_pct, 4);
187        assert_eq!(snap.extra.unwrap().limit.0, 5000);
188        assert_eq!(snap.extra.unwrap().spent.0, 250);
189        assert!(snap.session.resets_at.is_some());
190    }
191
192    #[test]
193    fn parses_weekly_scoped_limits() {
194        // Real shape observed 2026-07-08: the Fable weekly cap only exists
195        // inside `limits[]`; there is no `seven_day_fable` field.
196        let raw = r#"{
197            "five_hour": {"utilization": 10.0, "resets_at": "2026-07-08T22:59:59Z"},
198            "seven_day": {"utilization": 55.0, "resets_at": "2026-07-10T10:59:59Z"},
199            "limits": [
200                {"kind": "session", "group": "session", "percent": 10,
201                 "severity": "normal", "resets_at": "2026-07-08T22:59:59Z",
202                 "scope": null, "is_active": false},
203                {"kind": "weekly_all", "group": "weekly", "percent": 55,
204                 "severity": "normal", "resets_at": "2026-07-10T10:59:59Z",
205                 "scope": null, "is_active": false},
206                {"kind": "weekly_scoped", "group": "weekly", "percent": 84,
207                 "severity": "warning", "resets_at": "2026-07-10T10:59:59Z",
208                 "scope": {"model": {"id": null, "display_name": "Fable"}, "surface": null},
209                 "is_active": true}
210            ]
211        }"#;
212        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
213        let snap = resp.into_snapshot("Pro".into());
214        assert_eq!(snap.scoped.len(), 1);
215        assert_eq!(snap.scoped[0].label, "Fable");
216        assert_eq!(snap.scoped[0].window.utilization_pct, 84);
217        assert!(snap.scoped[0].window.resets_at.is_some());
218        // Unscoped entries never duplicate into `scoped`.
219        assert_eq!(snap.weekly.utilization_pct, 55);
220    }
221
222    #[test]
223    fn missing_limits_array_yields_empty_scoped() {
224        let raw = r#"{
225            "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
226            "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
227        }"#;
228        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
229        let snap = resp.into_snapshot("Pro".into());
230        assert!(snap.scoped.is_empty());
231    }
232
233    #[test]
234    fn missing_sonnet_and_extra_are_none() {
235        let raw = r#"{
236            "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
237            "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
238        }"#;
239        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
240        let snap = resp.into_snapshot("Pro".into());
241        assert!(snap.sonnet.is_none());
242        assert!(snap.extra.is_none());
243    }
244
245    #[test]
246    fn disabled_extra_usage_becomes_none() {
247        let raw = r#"{
248            "five_hour": {"utilization": 0},
249            "seven_day": {"utilization": 0},
250            "extra_usage": {"is_enabled": false, "monthly_limit": 5000, "used_credits": 0}
251        }"#;
252        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
253        let snap = resp.into_snapshot("Pro".into());
254        assert!(snap.extra.is_none());
255    }
256
257    #[test]
258    fn empty_object_yields_neutral_snapshot() {
259        let resp: UsageResponse = serde_json::from_str("{}").unwrap();
260        let snap = resp.into_snapshot("Unknown".into());
261        assert_eq!(snap.session.utilization_pct, 0);
262        assert_eq!(snap.weekly.utilization_pct, 0);
263        assert!(snap.session.resets_at.is_none());
264    }
265
266    #[test]
267    fn unparseable_reset_becomes_none() {
268        let raw = r#"{
269            "five_hour": {"utilization": 50, "resets_at": "not a date"},
270            "seven_day": {"utilization": 0}
271        }"#;
272        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
273        let snap = resp.into_snapshot("Pro".into());
274        assert!(snap.session.resets_at.is_none());
275        assert_eq!(snap.session.utilization_pct, 50);
276    }
277}