ai_usagebar/deepinfra/
types.rs1use 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}