Skip to main content

ai_usagebar/droid/
types.rs

1//! Response projection for the Droid `/api/billing/limits` endpoint.
2//!
3//! The API reports consumed percentages (`usedPercent`) and an ISO-8601
4//! `windowEnd` per window, in two buckets: `standard` and `core` (Droid Core).
5//! A `windowEnd: null` marks a window the account has not started — the
6//! percentage is still reported, so the window exists with no reset.
7use chrono::{DateTime, Duration, Utc};
8use serde_json::Value;
9
10use crate::error::{AppError, Result};
11use crate::usage::{DroidSnapshot, UsageWindow};
12
13pub const SESSION_WINDOW: Duration = Duration::hours(5);
14pub const WEEKLY_WINDOW: Duration = Duration::days(7);
15pub const MONTHLY_WINDOW: Duration = Duration::days(30);
16
17pub fn parse_response(bytes: &[u8]) -> Result<DroidSnapshot> {
18    let root: Value = serde_json::from_slice(bytes)
19        .map_err(|_| AppError::Schema("Droid limits response was not valid JSON".into()))?;
20    let limits = root
21        .get("limits")
22        .and_then(Value::as_object)
23        .ok_or_else(|| AppError::Schema("Droid limits response omitted `limits`".into()))?;
24    if !limits.contains_key("standard") && !limits.contains_key("core") {
25        return Err(AppError::Schema(
26            "Droid limits response contained no recognized quota buckets".into(),
27        ));
28    }
29    let bucket = |name: &str, duration: (Duration, Duration, Duration)| -> Result<_> {
30        Ok(match limits.get(name) {
31            None | Some(Value::Null) => (None, None, None),
32            Some(bucket) => (
33                window(bucket.get("fiveHour"), duration.0)?,
34                window(bucket.get("weekly"), duration.1)?,
35                window(bucket.get("monthly"), duration.2)?,
36            ),
37        })
38    };
39    let (session, weekly, monthly) =
40        bucket("standard", (SESSION_WINDOW, WEEKLY_WINDOW, MONTHLY_WINDOW))?;
41    let (core_session, core_weekly, core_monthly) =
42        bucket("core", (SESSION_WINDOW, WEEKLY_WINDOW, MONTHLY_WINDOW))?;
43    let extra_balance_cents = match root.get("extraUsageBalanceCents") {
44        None | Some(Value::Null) => None,
45        Some(value) => Some(
46            value
47                .as_i64()
48                .ok_or_else(|| drift("extraUsageBalanceCents"))?,
49        ),
50    };
51    Ok(DroidSnapshot {
52        session,
53        weekly,
54        monthly,
55        core_session,
56        core_weekly,
57        core_monthly,
58        extra_balance_cents,
59    })
60}
61
62/// One `{"usedPercent", "windowEnd", "secondsRemaining"}` window. Absent or
63/// null stays absent; a present window with `windowEnd: null` has not started
64/// and carries its percentage with no reset.
65fn window(value: Option<&Value>, duration: Duration) -> Result<Option<UsageWindow>> {
66    let Some(value) = value else { return Ok(None) };
67    if value.is_null() {
68        return Ok(None);
69    }
70    let used = value
71        .get("usedPercent")
72        .and_then(Value::as_i64)
73        .filter(|pct| (0..=100).contains(pct))
74        .ok_or_else(|| drift("usedPercent"))? as i32;
75    let resets_at = match value.get("windowEnd") {
76        None | Some(Value::Null) => None,
77        Some(Value::String(text)) => Some(
78            DateTime::parse_from_rfc3339(text)
79                .map(|at| at.with_timezone(&Utc))
80                .map_err(|_| drift("windowEnd"))?,
81        ),
82        Some(_) => return Err(drift("windowEnd")),
83    };
84    Ok(Some(UsageWindow {
85        utilization_pct: used,
86        resets_at,
87        window_duration: duration,
88    }))
89}
90
91fn drift(field: &str) -> AppError {
92    AppError::Schema(format!(
93        "Droid limits field {field} had an unsupported value"
94    ))
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    /// The real response shape, numbers from a live account on 2026-10-21.
102    const LIVE: &str = r#"{
103      "usesTokenRateLimitsBilling": true,
104      "limits": {
105        "standard": {
106          "fiveHour": {"usedPercent": 2, "windowEnd": "2026-10-22T01:38:45.109Z", "secondsRemaining": 16146},
107          "weekly":   {"usedPercent": 1, "windowEnd": "2026-10-28T20:38:45.109Z", "secondsRemaining": 602946},
108          "monthly":  {"usedPercent": 1, "windowEnd": "2026-11-20T20:38:45.109Z", "secondsRemaining": 2590146}
109        },
110        "core": {
111          "fiveHour": {"usedPercent": 0, "windowEnd": null, "secondsRemaining": null},
112          "weekly":   {"usedPercent": 0, "windowEnd": null, "secondsRemaining": null},
113          "monthly":  {"usedPercent": 0, "windowEnd": null, "secondsRemaining": null}
114        }
115      },
116      "overagePreference": "droidCore",
117      "canManageOverage": true,
118      "extraUsageBalanceCents": 0,
119      "extraUsageAllowed": true
120    }"#;
121
122    #[test]
123    fn the_observed_response_projects_both_buckets_and_the_balance() {
124        let snap = parse_response(LIVE.as_bytes()).unwrap();
125        let session = snap.session.as_ref().unwrap();
126        assert_eq!(session.utilization_pct, 2);
127        assert_eq!(
128            session.resets_at.unwrap().timestamp(),
129            DateTime::parse_from_rfc3339("2026-10-22T01:38:45.109Z")
130                .unwrap()
131                .timestamp()
132        );
133        assert_eq!(session.window_duration, SESSION_WINDOW);
134        let weekly = snap.weekly.as_ref().unwrap();
135        assert_eq!(weekly.utilization_pct, 1);
136        assert_eq!(weekly.window_duration, WEEKLY_WINDOW);
137        assert_eq!(
138            snap.monthly.as_ref().unwrap().window_duration,
139            MONTHLY_WINDOW
140        );
141        // A `windowEnd: null` window is present at 0% with no reset.
142        let core = snap.core_session.as_ref().unwrap();
143        assert_eq!(core.utilization_pct, 0);
144        assert_eq!(core.resets_at, None);
145        assert_eq!(snap.extra_balance_cents, Some(0));
146    }
147
148    #[test]
149    fn an_absent_window_or_bucket_stays_absent() {
150        let snap = parse_response(
151            br#"{"limits": {"standard": {"fiveHour": {"usedPercent": 40, "windowEnd": "2026-10-22T01:38:45.109Z"}}}, "extraUsageBalanceCents": 250}"#,
152        )
153        .unwrap();
154        assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 40);
155        assert!(snap.weekly.is_none());
156        assert!(snap.monthly.is_none());
157        assert!(snap.core_session.is_none());
158        assert_eq!(snap.extra_balance_cents, Some(250));
159    }
160
161    #[test]
162    fn invalid_values_and_envelopes_are_schema_errors() {
163        for body in [
164            br#"{"limits":{"standard":{"fiveHour":{"usedPercent":-1}}}}"#.as_slice(),
165            br#"{"limits":{"standard":{"fiveHour":{"usedPercent":101}}}}"#.as_slice(),
166            br#"{"limits":{"standard":{"fiveHour":{"usedPercent":"5"}}}}"#.as_slice(),
167            br#"{"limits":{"standard":{"fiveHour":{"usedPercent":5,"windowEnd":12}}}}"#.as_slice(),
168            br#"{"limits":{"standard":{"fiveHour":{"usedPercent":5,"windowEnd":"soon"}}}}"#
169                .as_slice(),
170            br#"{"limits":{"standard":{"fiveHour":{}}}}"#.as_slice(),
171            br#"{"limits":{"extraUsageBalanceCents":"5"}}"#.as_slice(),
172            br#"{"limits":{}}"#.as_slice(),
173            br#"{"other":1}"#.as_slice(),
174            b"<html>secret</html>".as_slice(),
175        ] {
176            assert!(
177                matches!(parse_response(body), Err(AppError::Schema(_))),
178                "{body:?}"
179            );
180        }
181    }
182}