Skip to main content

ai_usagebar/synthetic/
types.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Deserializer, Serialize};
3use serde_json::Value;
4
5use crate::error::{AppError, Result};
6use crate::usage::{SyntheticSnapshot, UsageWindow, WeeklyCredits};
7
8/// Synthetic `/v2/quotas` payload. The weekly window is the sole metric:
9/// `planWeeklyLimit` carries it as a percentage (subscription plans) and
10/// `weeklyTokenLimit` as a credit budget (usage-based plans). The legacy
11/// five-hour `subscription` counter is still echoed by the API but is no
12/// longer tracked, so it is deliberately ignored.
13#[derive(Debug, Clone, Deserialize, Serialize)]
14#[serde(rename_all = "camelCase")]
15pub struct Response {
16    /// Preferred by Synthetic's own tooling when both objects appear.
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub plan_weekly_limit: Option<PlanWeeklyLimit>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub weekly_token_limit: Option<WeeklyTokenLimit>,
21}
22
23#[derive(Debug, Clone, Deserialize, Serialize)]
24#[serde(rename_all = "camelCase")]
25pub struct PlanWeeklyLimit {
26    pub next_regen_at: String,
27    pub percent_remaining: f64,
28    pub next_regen_percent: f64,
29}
30
31#[derive(Debug, Clone, Deserialize, Serialize)]
32#[serde(rename_all = "camelCase")]
33pub struct WeeklyTokenLimit {
34    pub next_regen_at: String,
35    pub percent_remaining: f64,
36    /// The API reports credit amounts as numeric strings ("24.72").
37    #[serde(deserialize_with = "de_credits")]
38    pub max_credits: f64,
39    #[serde(deserialize_with = "de_credits")]
40    pub remaining_credits: f64,
41    #[serde(deserialize_with = "de_credits")]
42    pub next_regen_credits: f64,
43}
44
45struct WeeklyFigures {
46    percent_remaining: f64,
47    renews_at: DateTime<Utc>,
48    next_regen_percent: Option<f64>,
49    credits: Option<WeeklyCredits>,
50}
51
52impl WeeklyFigures {
53    fn snapshot(self) -> SyntheticSnapshot {
54        let utilization_pct = (100.0 - self.percent_remaining).round() as i32;
55        let utilization_precise = ((100.0 - self.percent_remaining) * 100.0).round() / 100.0;
56        SyntheticSnapshot {
57            plan: None,
58            weekly: UsageWindow {
59                utilization_pct,
60                resets_at: Some(self.renews_at),
61                // The API documents the next regeneration tick, not a fixed
62                // cycle length: the allowance refills ~2% every 202 minutes
63                // instead of resetting wholesale. Zero deliberately disables
64                // guessed pacing.
65                window_duration: chrono::Duration::zero(),
66            },
67            credits: self.credits,
68            next_regen_percent: self.next_regen_percent,
69            utilization_precise,
70        }
71    }
72}
73
74impl PlanWeeklyLimit {
75    fn figures(&self) -> Result<WeeklyFigures> {
76        Ok(WeeklyFigures {
77            percent_remaining: parse_percent(self.percent_remaining)?,
78            renews_at: parse_renews_at(&self.next_regen_at)?,
79            next_regen_percent: Some(parse_regens(self.next_regen_percent)?),
80            credits: None,
81        })
82    }
83}
84
85impl WeeklyTokenLimit {
86    fn figures(&self) -> Result<WeeklyFigures> {
87        let (max, remaining, next_regen) = (
88            self.max_credits,
89            self.remaining_credits,
90            self.next_regen_credits,
91        );
92        if !max.is_finite()
93            || max <= 0.0
94            || !remaining.is_finite()
95            || remaining < 0.0
96            || !next_regen.is_finite()
97            || next_regen < 0.0
98        {
99            return Err(AppError::Schema(
100                "Synthetic weekly credit budget contains an invalid amount".into(),
101            ));
102        }
103        Ok(WeeklyFigures {
104            percent_remaining: parse_percent(self.percent_remaining)?,
105            renews_at: parse_renews_at(&self.next_regen_at)?,
106            next_regen_percent: None,
107            credits: Some(WeeklyCredits {
108                used: (max - remaining).max(0.0),
109                max,
110                next_regen,
111            }),
112        })
113    }
114}
115
116impl Response {
117    pub fn snapshot(&self) -> Result<SyntheticSnapshot> {
118        // Synthetic's own tooling prefers the percentage-only plan object
119        // when both ride along; keep that precedence and attach the credit
120        // figures from the token variant when they are usable.
121        let mut figures = match (&self.plan_weekly_limit, &self.weekly_token_limit) {
122            (Some(plan), _) => plan.figures()?,
123            (None, Some(tokens)) => tokens.figures()?,
124            (None, None) => {
125                return Err(AppError::Schema(
126                    "Synthetic quota response carries no weekly limit".into(),
127                ));
128            }
129        };
130        if figures.credits.is_none()
131            && let Some(tokens) = &self.weekly_token_limit
132            && let Ok(extra) = tokens.figures()
133        {
134            figures.credits = extra.credits;
135        }
136        Ok(figures.snapshot())
137    }
138}
139
140fn parse_percent(value: f64) -> Result<f64> {
141    if !value.is_finite() || value < 0.0 {
142        return Err(AppError::Schema(
143            "Synthetic weekly quota contains an invalid remaining percentage".into(),
144        ));
145    }
146    Ok(value.clamp(0.0, 100.0))
147}
148
149fn parse_regens(value: f64) -> Result<f64> {
150    if !value.is_finite() || value < 0.0 {
151        return Err(AppError::Schema(
152            "Synthetic weekly quota contains an invalid regeneration amount".into(),
153        ));
154    }
155    Ok(value)
156}
157
158fn parse_renews_at(raw: &str) -> Result<DateTime<Utc>> {
159    DateTime::parse_from_rfc3339(raw)
160        .map(|dt| dt.with_timezone(&Utc))
161        .map_err(|_| AppError::Schema("Synthetic weekly quota renewal is invalid".into()))
162}
163
164/// Credit amounts arrive as numeric strings and may carry currency
165/// decoration; accept a bare JSON number as well.
166fn de_credits<'de, D>(deserializer: D) -> std::result::Result<f64, D::Error>
167where
168    D: Deserializer<'de>,
169{
170    use serde::de::Error as _;
171    match Value::deserialize(deserializer)? {
172        Value::Number(number) => number
173            .as_f64()
174            .ok_or_else(|| D::Error::custom("credit amount is not a finite number")),
175        Value::String(raw) => {
176            let cleaned = raw.trim().trim_start_matches('$').replace(',', "");
177            cleaned
178                .parse::<f64>()
179                .map_err(|_| D::Error::custom("credit amount is not a number"))
180        }
181        _ => Err(D::Error::custom(
182            "credit amount must be a number or numeric string",
183        )),
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    const PLAN: &str = r#"{"planWeeklyLimit":{"nextRegenAt":"2026-10-27T01:09:02.000Z","percentRemaining":68.97415291835814,"nextRegenPercent":2}}"#;
192    const TOKENS: &str = r#"{"weeklyTokenLimit":{"nextRegenAt":"2026-10-27T01:09:02.000Z","percentRemaining":82.4,"maxCredits":"30.00","remainingCredits":"24.72","nextRegenCredits":"0.60"}}"#;
193
194    #[test]
195    fn plan_shape_projects_the_documented_weekly_example() {
196        // Captured verbatim from the live /v2/quotas response.
197        let response: Response = serde_json::from_str(PLAN).unwrap();
198        let snapshot = response.snapshot().unwrap();
199        assert_eq!(snapshot.weekly.utilization_pct, 31);
200        assert_eq!(snapshot.utilization_precise, 31.03);
201        assert_eq!(
202            snapshot.weekly.resets_at,
203            Some("2026-10-27T01:09:02.000Z".parse().unwrap())
204        );
205        assert_eq!(snapshot.weekly.window_duration, chrono::Duration::zero());
206        assert_eq!(snapshot.credits, None);
207        assert_eq!(snapshot.next_regen_percent, Some(2.0));
208    }
209
210    #[test]
211    fn token_shape_reports_the_credit_budget() {
212        let response: Response = serde_json::from_str(TOKENS).unwrap();
213        let snapshot = response.snapshot().unwrap();
214        assert_eq!(snapshot.weekly.utilization_pct, 18);
215        let credits = snapshot.credits.expect("credit figures");
216        assert!((credits.used - 5.28).abs() < 1e-9);
217        assert_eq!(credits.max, 30.0);
218        assert_eq!(credits.next_regen, 0.6);
219    }
220
221    #[test]
222    fn plan_shape_wins_but_adopts_token_credits() {
223        let response: Response = serde_json::from_str(
224            r#"{"planWeeklyLimit":{"nextRegenAt":"2026-10-27T01:09:02.000Z","percentRemaining":68.97415291835814,"nextRegenPercent":2},
225                "weeklyTokenLimit":{"nextRegenAt":"2026-10-27T05:00:00.000Z","percentRemaining":82.4,"maxCredits":"30.00","remainingCredits":"24.72","nextRegenCredits":"0.60"}}"#,
226        )
227        .unwrap();
228        let snapshot = response.snapshot().unwrap();
229        // The plan object's percentage and regeneration clock win...
230        assert_eq!(snapshot.weekly.utilization_pct, 31);
231        assert_eq!(
232            snapshot.weekly.resets_at,
233            Some("2026-10-27T01:09:02.000Z".parse().unwrap())
234        );
235        assert_eq!(snapshot.next_regen_percent, Some(2.0));
236        // ...while the token variant still supplies the credit figures.
237        let credits = snapshot.credits.expect("credit figures");
238        assert!((credits.used - 5.28).abs() < 1e-9);
239        assert_eq!(credits.max, 30.0);
240    }
241
242    #[test]
243    fn invalid_or_ambiguous_numbers_are_rejected() {
244        for raw in [
245            r#"{"planWeeklyLimit":{"nextRegenAt":"2026-11-01T00:00:00Z","percentRemaining":-1,"nextRegenPercent":2}}"#,
246            r#"{"planWeeklyLimit":{"nextRegenAt":"2026-11-01T00:00:00Z","percentRemaining":50,"nextRegenPercent":-2}}"#,
247            r#"{"planWeeklyLimit":{"nextRegenAt":"not-a-time","percentRemaining":50,"nextRegenPercent":2}}"#,
248            r#"{"weeklyTokenLimit":{"nextRegenAt":"2026-11-01T00:00:00Z","percentRemaining":50,"maxCredits":"0","remainingCredits":"1","nextRegenCredits":"0.60"}}"#,
249            r#"{"weeklyTokenLimit":{"nextRegenAt":"2026-11-01T00:00:00Z","percentRemaining":50,"maxCredits":"30.00","remainingCredits":"-1","nextRegenCredits":"0.60"}}"#,
250            r#"{"subscription":{"limit":135,"requests":27,"renewsAt":"2026-11-01T00:00:00Z"}}"#,
251            r#"{"error":"unauthorized"}"#,
252        ] {
253            let result = serde_json::from_str::<Response>(raw)
254                .map_err(AppError::from)
255                .and_then(|response| response.snapshot());
256            assert!(result.is_err(), "accepted {raw}");
257        }
258    }
259
260    #[test]
261    fn over_quota_remaining_is_bounded_for_frontends() {
262        let response: Response = serde_json::from_str(
263            r#"{"planWeeklyLimit":{"nextRegenAt":"2026-11-01T00:00:00Z","percentRemaining":120,"nextRegenPercent":2}}"#,
264        )
265        .unwrap();
266        assert_eq!(response.snapshot().unwrap().weekly.utilization_pct, 0);
267
268        let response: Response = serde_json::from_str(
269            r#"{"planWeeklyLimit":{"nextRegenAt":"2026-11-01T00:00:00Z","percentRemaining":-0.4,"nextRegenPercent":2}}"#,
270        )
271        .unwrap();
272        // Negative remaining is invalid wire data, not just over quota.
273        assert!(response.snapshot().is_err());
274    }
275}