Skip to main content

ai_usagebar/deepseek/
types.rs

1//! Wire types for DeepSeek's `/user/balance` endpoint.
2
3use serde::Deserialize;
4
5use crate::error::{AppError, Result};
6use crate::usage::DeepseekSnapshot;
7
8#[derive(Debug, Clone, Deserialize)]
9pub struct BalanceResponse {
10    pub is_available: bool,
11    pub balance_infos: Vec<BalanceInfo>,
12}
13
14#[derive(Debug, Clone, Deserialize)]
15pub struct BalanceInfo {
16    pub currency: String,
17    pub total_balance: String,
18    pub granted_balance: String,
19    pub topped_up_balance: String,
20}
21
22impl BalanceResponse {
23    /// Project the wire response into the canonical snapshot.
24    ///
25    /// Every monetary field is required: an empty or error body used to
26    /// deserialize into a confident $0.00 that then overwrote a good cache.
27    /// Drift must surface as a schema error instead.
28    pub fn into_snapshot(self) -> Result<DeepseekSnapshot> {
29        // The documented endpoint reports USD and/or CNY. Rendering an unknown
30        // currency through the USD-scale balance UI would attach meaning and
31        // severity thresholds we cannot justify, so do not select one merely
32        // because it happened to be first.
33        fn unique<'a>(infos: &'a [BalanceInfo], currency: &str) -> Result<Option<&'a BalanceInfo>> {
34            let mut matching = infos.iter().filter(|info| info.currency == currency);
35            let first = matching.next();
36            if matching.next().is_some() {
37                return Err(AppError::Schema(format!(
38                    "deepseek: response carried duplicate {currency} balances"
39                )));
40            }
41            Ok(first)
42        }
43        let info = unique(&self.balance_infos, "USD")?
44            .or(unique(&self.balance_infos, "CNY")?)
45            .ok_or_else(|| {
46                let currencies = self
47                    .balance_infos
48                    .iter()
49                    .map(|info| info.currency.as_str())
50                    .collect::<Vec<_>>()
51                    .join(", ");
52                AppError::Schema(if currencies.is_empty() {
53                    "deepseek: response carried no balance records".into()
54                } else {
55                    format!("deepseek: response carried no USD or CNY balance (saw {currencies})")
56                })
57            })?;
58
59        Ok(DeepseekSnapshot {
60            is_available: self.is_available,
61            balance: parse_money(&info.total_balance, "total_balance")?,
62            granted: parse_money(&info.granted_balance, "granted_balance")?,
63            topped_up: parse_money(&info.topped_up_balance, "topped_up_balance")?,
64            currency: info.currency.clone(),
65        })
66    }
67}
68
69/// DeepSeek sends amounts as decimal strings. `"NaN"` and `"inf"` both parse
70/// successfully in Rust, so the finiteness check is load-bearing — an
71/// infinite balance would render and cache as a plausible-looking number.
72fn parse_money(s: &str, field: &str) -> Result<f64> {
73    let n: f64 = s.trim().parse().map_err(|_| {
74        AppError::Schema(format!("deepseek balance '{field}' is not numeric: {s:?}"))
75    })?;
76    if n.is_finite() {
77        Ok(n)
78    } else {
79        Err(AppError::Schema(format!(
80            "deepseek balance '{field}' is not finite"
81        )))
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn parses_balance_response() {
91        let raw = r#"{
92            "is_available": true,
93            "balance_infos": [
94                {"currency": "CNY", "total_balance": "10.00", "granted_balance": "10.00", "topped_up_balance": "0.00"},
95                {"currency": "USD", "total_balance": "1.50", "granted_balance": "1.50", "topped_up_balance": "0.00"}
96            ]
97        }"#;
98        let r: BalanceResponse = serde_json::from_str(raw).unwrap();
99        let snap = r.into_snapshot().unwrap();
100        assert!(snap.is_available);
101        assert_eq!(snap.currency, "USD");
102        assert!((snap.balance - 1.50).abs() < 1e-9);
103    }
104
105    #[test]
106    fn fallback_to_cny_when_no_usd() {
107        let raw = r#"{
108            "is_available": true,
109            "balance_infos": [
110                {"currency": "CNY", "total_balance": "20.00", "granted_balance": "20.00", "topped_up_balance": "0.00"}
111            ]
112        }"#;
113        let r: BalanceResponse = serde_json::from_str(raw).unwrap();
114        let snap = r.into_snapshot().unwrap();
115        assert_eq!(snap.currency, "CNY");
116        assert!((snap.balance - 20.0).abs() < 1e-9);
117    }
118
119    /// The legitimate zero: a drained account still renders, it is not an error.
120    #[test]
121    fn genuine_zero_balance_is_preserved() {
122        let raw = r#"{
123            "is_available": false,
124            "balance_infos": [
125                {"currency": "USD", "total_balance": "0.00", "granted_balance": "0.00", "topped_up_balance": "0.00"}
126            ]
127        }"#;
128        let r: BalanceResponse = serde_json::from_str(raw).unwrap();
129        let snap = r.into_snapshot().unwrap();
130        assert!(!snap.is_available);
131        assert_eq!(snap.balance, 0.0);
132        assert_eq!(snap.currency, "USD");
133    }
134
135    #[test]
136    fn empty_balance_infos_is_schema_error() {
137        let raw = r#"{"is_available": false, "balance_infos": []}"#;
138        let r: BalanceResponse = serde_json::from_str(raw).unwrap();
139        assert!(matches!(r.into_snapshot(), Err(AppError::Schema(_))));
140    }
141
142    #[test]
143    fn unsupported_currency_is_schema_error_not_usd_scaled() {
144        let raw = r#"{
145            "is_available": true,
146            "balance_infos": [
147                {"currency": "EUR", "total_balance": "20.00", "granted_balance": "20.00", "topped_up_balance": "0.00"}
148            ]
149        }"#;
150        let r: BalanceResponse = serde_json::from_str(raw).unwrap();
151        let err = r.into_snapshot().unwrap_err().to_string();
152        assert!(err.contains("no USD or CNY"), "{err}");
153        assert!(err.contains("EUR"), "{err}");
154    }
155
156    #[test]
157    fn duplicate_supported_currency_is_schema_error_not_first_wins() {
158        let raw = r#"{
159            "is_available": true,
160            "balance_infos": [
161                {"currency": "USD", "total_balance": "1.00", "granted_balance": "1.00", "topped_up_balance": "0.00"},
162                {"currency": "USD", "total_balance": "2.00", "granted_balance": "2.00", "topped_up_balance": "0.00"}
163            ]
164        }"#;
165        let r: BalanceResponse = serde_json::from_str(raw).unwrap();
166        let err = r.into_snapshot().unwrap_err().to_string();
167        assert!(err.contains("duplicate USD"), "{err}");
168    }
169
170    #[test]
171    fn non_numeric_amount_is_schema_error() {
172        let raw = r#"{
173            "is_available": true,
174            "balance_infos": [
175                {"currency": "USD", "total_balance": "n/a", "granted_balance": "0.00", "topped_up_balance": "0.00"}
176            ]
177        }"#;
178        let r: BalanceResponse = serde_json::from_str(raw).unwrap();
179        assert!(matches!(r.into_snapshot(), Err(AppError::Schema(_))));
180    }
181
182    #[test]
183    fn empty_amount_is_schema_error() {
184        let raw = r#"{
185            "is_available": true,
186            "balance_infos": [
187                {"currency": "USD", "total_balance": "", "granted_balance": "0.00", "topped_up_balance": "0.00"}
188            ]
189        }"#;
190        let r: BalanceResponse = serde_json::from_str(raw).unwrap();
191        assert!(matches!(r.into_snapshot(), Err(AppError::Schema(_))));
192    }
193
194    /// Rust parses these to real `f64` values, so only the finiteness check
195    /// catches them.
196    #[test]
197    fn non_finite_amounts_are_schema_errors() {
198        for amount in ["NaN", "inf", "-inf", "infinity"] {
199            let raw = format!(
200                r#"{{"is_available": true, "balance_infos": [
201                    {{"currency": "USD", "total_balance": "{amount}",
202                      "granted_balance": "0.00", "topped_up_balance": "0.00"}}
203                ]}}"#
204            );
205            let r: BalanceResponse = serde_json::from_str(&raw).unwrap();
206            assert!(
207                matches!(r.into_snapshot(), Err(AppError::Schema(_))),
208                "{amount} should be rejected"
209            );
210        }
211    }
212
213    /// A non-`total_balance` component is just as unvouchable as the headline.
214    #[test]
215    fn non_numeric_component_is_schema_error() {
216        let raw = r#"{
217            "is_available": true,
218            "balance_infos": [
219                {"currency": "USD", "total_balance": "5.00", "granted_balance": "5.00", "topped_up_balance": "oops"}
220            ]
221        }"#;
222        let r: BalanceResponse = serde_json::from_str(raw).unwrap();
223        assert!(matches!(r.into_snapshot(), Err(AppError::Schema(_))));
224    }
225
226    #[test]
227    fn empty_body_does_not_deserialize() {
228        assert!(serde_json::from_str::<BalanceResponse>("{}").is_err());
229    }
230
231    #[test]
232    fn error_body_does_not_deserialize() {
233        let raw = r#"{"error": {"message": "Authentication Fails"}}"#;
234        assert!(serde_json::from_str::<BalanceResponse>(raw).is_err());
235    }
236
237    #[test]
238    fn missing_money_field_does_not_deserialize() {
239        let raw = r#"{
240            "is_available": true,
241            "balance_infos": [{"currency": "USD", "total_balance": "5.00"}]
242        }"#;
243        assert!(serde_json::from_str::<BalanceResponse>(raw).is_err());
244    }
245}