Skip to main content

ai_usagebar/moonshot/
types.rs

1//! Wire types for Moonshot / Kimi's `/v1/users/me/balance`.
2//!
3//! Confirmed against the official docs (platform.moonshot.ai/docs/api/balance
4//! and the `.cn` equivalent). The response wraps the balance in
5//! `{ code, data, scode, status }`; the three `data.*` fields are JSON numbers.
6//! `available_balance` = cash + voucher (the spendable figure; `<= 0` blocks the
7//! inference API). There is **no currency field** — the unit is USD on
8//! `api.moonshot.ai` and CNY on `api.moonshot.cn`, so the caller supplies it.
9
10use serde::Deserialize;
11
12use crate::error::{AppError, Result};
13use crate::usage::{MoonshotSnapshot, finite_amount};
14
15/// The documented envelope. `code`/`status` are the API's **in-band failure
16/// indicators**: a 200 response can still carry `status: false` with a non-zero
17/// `code`, in which case `data` is not a balance and must not be shown as one.
18#[derive(Debug, Clone, Deserialize)]
19pub struct BalanceEnvelope {
20    pub code: i64,
21    pub data: BalanceData,
22    pub status: bool,
23}
24
25impl BalanceEnvelope {
26    /// Reject the documented failure shape before any field is read as money.
27    pub fn check_ok(&self) -> Result<()> {
28        if self.status && self.code == 0 {
29            return Ok(());
30        }
31        Err(AppError::Schema(format!(
32            "moonshot: API reported failure (code {}, status {})",
33            self.code, self.status
34        )))
35    }
36}
37
38/// All three balances are documented as always present. Defaulting a missing
39/// one to zero would report a fabricated balance as authoritative.
40#[derive(Debug, Clone, Deserialize)]
41pub struct BalanceData {
42    pub available_balance: f64,
43    pub voucher_balance: f64,
44    pub cash_balance: f64,
45}
46
47pub fn to_snapshot(data: BalanceData, currency: &str) -> Result<MoonshotSnapshot> {
48    Ok(MoonshotSnapshot {
49        available: finite_amount("moonshot", "available_balance", data.available_balance)?,
50        voucher: finite_amount("moonshot", "voucher_balance", data.voucher_balance)?,
51        cash: finite_amount("moonshot", "cash_balance", data.cash_balance)?,
52        currency: currency.to_string(),
53    })
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59
60    #[test]
61    fn parses_documented_envelope() {
62        let raw = r#"{
63            "code": 0,
64            "data": {
65                "available_balance": 49.58894,
66                "voucher_balance": 46.58893,
67                "cash_balance": 3.00001
68            },
69            "scode": "0x0",
70            "status": true
71        }"#;
72        let env: BalanceEnvelope = serde_json::from_str(raw).unwrap();
73        assert_eq!(env.code, 0);
74        assert!(env.status);
75        env.check_ok().unwrap();
76        let snap = to_snapshot(env.data, "USD").unwrap();
77        assert!((snap.available - 49.58894).abs() < 1e-9);
78        assert!((snap.voucher - 46.58893).abs() < 1e-9);
79        assert!((snap.cash - 3.00001).abs() < 1e-9);
80        assert_eq!(snap.currency, "USD");
81    }
82
83    #[test]
84    fn missing_message_field_is_fine() {
85        // The docs example has no `message` field; parsing must not require it.
86        let raw = r#"{"code":0,"data":{"available_balance":1.0,"voucher_balance":0.0,
87            "cash_balance":1.0},"status":true}"#;
88        let env: BalanceEnvelope = serde_json::from_str(raw).unwrap();
89        assert_eq!(env.data.available_balance, 1.0);
90    }
91
92    #[test]
93    fn in_band_failure_is_rejected() {
94        // A 200 carrying the documented failure indicators is not a balance.
95        let raw = r#"{"code":40100,"data":{"available_balance":0.0,
96            "voucher_balance":0.0,"cash_balance":0.0},"status":false}"#;
97        let env: BalanceEnvelope = serde_json::from_str(raw).unwrap();
98        assert!(env.check_ok().is_err());
99
100        // status true but a non-zero code is equally untrustworthy.
101        let raw2 = r#"{"code":500,"data":{"available_balance":0.0,
102            "voucher_balance":0.0,"cash_balance":0.0},"status":true}"#;
103        let env2: BalanceEnvelope = serde_json::from_str(raw2).unwrap();
104        assert!(env2.check_ok().is_err());
105    }
106
107    #[test]
108    fn missing_fields_are_a_schema_error_not_zero() {
109        assert!(serde_json::from_str::<BalanceEnvelope>("{}").is_err());
110        // Envelope present but `data` incomplete is drift, not a zero balance.
111        assert!(
112            serde_json::from_str::<BalanceEnvelope>(
113                r#"{"code":0,"data":{"available_balance":1.0},"status":true}"#
114            )
115            .is_err()
116        );
117    }
118
119    #[test]
120    fn non_finite_balance_is_rejected() {
121        let d = BalanceData {
122            available_balance: f64::INFINITY,
123            voucher_balance: 0.0,
124            cash_balance: 0.0,
125        };
126        assert!(to_snapshot(d, "USD").is_err());
127    }
128}