Skip to main content

ai_usagebar/deepinfra/
types.rs

1//! Wire types for DeepInfra billing endpoints.
2
3use serde::Deserialize;
4
5use crate::error::{AppError, Result};
6use crate::usage::DeepInfraSnapshot;
7
8#[derive(Debug, Clone, Deserialize)]
9pub struct Checklist {
10    #[serde(deserialize_with = "de_finite")]
11    pub stripe_balance: f64,
12    #[serde(deserialize_with = "de_nonnegative_finite")]
13    pub recent: f64,
14    #[serde(default, deserialize_with = "de_opt_finite")]
15    pub limit: Option<f64>,
16}
17
18#[derive(Debug, Clone, Deserialize)]
19pub struct UsageResponse {
20    pub months: Vec<UsageMonth>,
21}
22
23#[derive(Debug, Clone, Deserialize)]
24pub struct UsageMonth {
25    pub period: String,
26    #[serde(deserialize_with = "de_nonnegative_finite")]
27    pub total_cost: f64,
28}
29
30fn checked_finite<E: serde::de::Error>(value: f64) -> std::result::Result<f64, E> {
31    if value.is_finite() {
32        Ok(value)
33    } else {
34        Err(E::custom("money value is not finite"))
35    }
36}
37
38fn checked_nonnegative<E: serde::de::Error>(value: f64) -> std::result::Result<f64, E> {
39    let value = checked_finite(value)?;
40    if value >= 0.0 {
41        Ok(value)
42    } else {
43        Err(E::custom("money value cannot be negative"))
44    }
45}
46
47fn de_finite<'de, D>(deserializer: D) -> std::result::Result<f64, D::Error>
48where
49    D: serde::Deserializer<'de>,
50{
51    checked_finite(f64::deserialize(deserializer)?)
52}
53
54fn de_nonnegative_finite<'de, D>(deserializer: D) -> std::result::Result<f64, D::Error>
55where
56    D: serde::Deserializer<'de>,
57{
58    checked_nonnegative(f64::deserialize(deserializer)?)
59}
60
61fn de_opt_finite<'de, D>(deserializer: D) -> std::result::Result<Option<f64>, D::Error>
62where
63    D: serde::Deserializer<'de>,
64{
65    Option::<f64>::deserialize(deserializer)?
66        .map(checked_finite)
67        .transpose()
68}
69
70pub fn combine(checklist: Checklist, usage: UsageResponse) -> Result<DeepInfraSnapshot> {
71    let month = usage
72        .months
73        .into_iter()
74        .max_by(|left, right| left.period.cmp(&right.period))
75        .ok_or_else(|| AppError::Schema("deepinfra usage response carried no months".into()))?;
76    if !valid_period(&month.period) {
77        return Err(AppError::Schema(
78            "deepinfra usage response carried an invalid period".into(),
79        ));
80    }
81    if !month.total_cost.is_finite() || month.total_cost < 0.0 {
82        return Err(AppError::Schema(
83            "deepinfra usage response carried an invalid total_cost".into(),
84        ));
85    }
86
87    Ok(DeepInfraSnapshot {
88        balance: -checklist.stripe_balance - checklist.recent,
89        monthly_spend: month.total_cost / 100.0,
90        monthly_limit: checklist.limit.filter(|limit| *limit >= 0.0),
91        period: month.period,
92    })
93}
94
95fn valid_period(period: &str) -> bool {
96    let Some((year, month)) = period.split_once('.') else {
97        return false;
98    };
99    year.len() == 4
100        && year.bytes().all(|byte| byte.is_ascii_digit())
101        && month.len() == 2
102        && month.bytes().all(|byte| byte.is_ascii_digit())
103        && month
104            .parse::<u8>()
105            .is_ok_and(|month| (1..=12).contains(&month))
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    fn checklist() -> Checklist {
113        Checklist {
114            stripe_balance: -5.0,
115            recent: 2.08,
116            limit: None,
117        }
118    }
119
120    fn usage(total_cost: f64) -> UsageResponse {
121        UsageResponse {
122            months: vec![UsageMonth {
123                period: "2026.09".into(),
124                total_cost,
125            }],
126        }
127    }
128
129    #[test]
130    fn combines_prepaid_balance_and_cent_usage() {
131        let snapshot = combine(checklist(), usage(208.0)).unwrap();
132        assert!((snapshot.balance - 2.92).abs() < 1e-9);
133        assert!((snapshot.monthly_spend - 2.08).abs() < 1e-9);
134        assert_eq!(snapshot.period, "2026.09");
135        assert_eq!(snapshot.monthly_limit, None);
136    }
137
138    #[test]
139    fn normalizes_negative_limit_to_unlimited() {
140        let snapshot = combine(
141            Checklist {
142                limit: Some(-1.0),
143                ..checklist()
144            },
145            usage(0.0),
146        )
147        .unwrap();
148        assert_eq!(snapshot.monthly_limit, None);
149    }
150
151    #[test]
152    fn rejects_missing_month_and_negative_cost() {
153        assert!(
154            combine(checklist(), UsageResponse { months: vec![] })
155                .unwrap_err()
156                .to_string()
157                .contains("no months")
158        );
159        assert!(combine(checklist(), usage(-1.0)).is_err());
160    }
161
162    #[test]
163    fn selects_the_latest_period_and_accepts_fractional_cents() {
164        let snapshot = combine(
165            checklist(),
166            UsageResponse {
167                months: vec![
168                    UsageMonth {
169                        period: "2026.08".into(),
170                        total_cost: 500.0,
171                    },
172                    UsageMonth {
173                        period: "2026.09".into(),
174                        total_cost: 208.5,
175                    },
176                ],
177            },
178        )
179        .unwrap();
180        assert!((snapshot.monthly_spend - 2.085).abs() < 1e-9);
181        assert_eq!(snapshot.period, "2026.09");
182    }
183
184    #[test]
185    fn rejects_invalid_money() {
186        for raw in [
187            r#"{"stripe_balance":1e400,"recent":0,"limit":null}"#,
188            r#"{"stripe_balance":0,"recent":-1,"limit":null}"#,
189            r#"{"stripe_balance":0,"recent":0,"limit":1e400}"#,
190        ] {
191            assert!(serde_json::from_str::<Checklist>(raw).is_err(), "{raw}");
192        }
193        assert!(
194            serde_json::from_str::<UsageResponse>(
195                r#"{"months":[{"period":"2026.09","total_cost":-1}]}"#
196            )
197            .is_err()
198        );
199    }
200}