Skip to main content

ai_usagebar/moonshot/
fetch.rs

1//! Moonshot / Kimi fetch — reads the account balance from
2//! `/v1/users/me/balance` under the shared cache + flock primitives. The host
3//! (and therefore the currency) is region-dependent: `api.moonshot.ai` → USD,
4//! `api.moonshot.cn` → CNY. The caller passes the matching currency label.
5
6use std::time::Duration;
7
8use crate::cache::{Cache, acquire_lock_async};
9use crate::error::{AppError, Result};
10use crate::usage::{MoonshotSnapshot, finite_amount};
11use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
12
13use super::types::{BalanceEnvelope, to_snapshot};
14
15pub const BASE_GLOBAL: &str = "https://api.moonshot.ai";
16pub const BASE_CN: &str = "https://api.moonshot.cn";
17const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
18const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
19
20#[derive(Debug, Clone)]
21pub struct Endpoints {
22    pub balance: String,
23}
24
25impl Endpoints {
26    /// Pick the host by region. Returns the endpoints plus the currency label
27    /// implied by that host (`"CNY"` for `cn`, `"USD"` otherwise).
28    pub fn for_region(region: &str) -> (Self, &'static str) {
29        if region.eq_ignore_ascii_case("cn") {
30            (
31                Self {
32                    balance: format!("{BASE_CN}/v1/users/me/balance"),
33                },
34                "CNY",
35            )
36        } else {
37            (
38                Self {
39                    balance: format!("{BASE_GLOBAL}/v1/users/me/balance"),
40                },
41                "USD",
42            )
43        }
44    }
45}
46
47impl Default for Endpoints {
48    fn default() -> Self {
49        Self::for_region("global").0
50    }
51}
52
53/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
54/// specialised to its snapshot.
55pub type FetchOutcome = crate::outcome::Outcome<MoonshotSnapshot>;
56
57pub async fn fetch_snapshot(
58    client: &reqwest::Client,
59    api_key: &str,
60    cache: &Cache,
61    endpoints: &Endpoints,
62    cache_ttl: Duration,
63    currency: &str,
64) -> Result<FetchOutcome> {
65    cache.ensure_dir()?;
66    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
67
68    let target = target_key(endpoints, currency);
69
70    if let Some(bytes) = cache.fresh_payload(cache_ttl)?
71        && let Ok(outcome) = reuse_cache(&bytes, cache, false, &target)
72    {
73        return Ok(outcome);
74    }
75
76    match fetch_live(client, endpoints, api_key, currency).await {
77        Ok(snap) => {
78            let bytes = serde_json::to_vec(
79                &serde_json::json!({ "target": target, "snapshot": serde_repr(&snap) }),
80            )?;
81            cache.write_payload(&bytes)?;
82            Ok(crate::outcome::Outcome::fresh(snap))
83        }
84        Err(e) if e.is_transient() => fallback_silent(cache, &target, e),
85        Err(AppError::Http { status, body }) => {
86            cache.mark_stale();
87            let diag = cache.write_last_error(status, &body);
88            fallback_with_error(cache, Some(diag), &target, AppError::Http { status, body })
89        }
90        Err(e) => {
91            cache.mark_stale();
92            let diag = cache.write_last_error(0, &e.to_string());
93            fallback_with_error(cache, Some(diag), &target, e)
94        }
95    }
96}
97
98/// Identity of the account+region the cached figure belongs to. `.ai` reports
99/// USD and `.cn` reports CNY, so a cached number is meaningless — and actively
100/// misleading — once the user points the vendor at the other region.
101fn target_key(endpoints: &Endpoints, currency: &str) -> String {
102    format!("{}|{}", endpoints.balance, currency)
103}
104
105fn fallback_silent(cache: &Cache, target: &str, original: AppError) -> Result<FetchOutcome> {
106    crate::outcome::fallback(cache, None, original, |bytes| parse_cache(bytes, target))
107}
108
109fn fallback_with_error(
110    cache: &Cache,
111    last_error: Option<(u16, String)>,
112    target: &str,
113    original: AppError,
114) -> Result<FetchOutcome> {
115    crate::outcome::fallback(cache, last_error, original, |bytes| {
116        parse_cache(bytes, target)
117    })
118}
119
120fn reuse_cache(bytes: &[u8], cache: &Cache, stale: bool, target: &str) -> Result<FetchOutcome> {
121    let snap = parse_cache(bytes, target)?;
122    Ok(crate::outcome::Outcome::cached(snap, cache, stale))
123}
124
125fn serde_repr(snap: &MoonshotSnapshot) -> serde_json::Value {
126    serde_json::json!({
127        "available": snap.available,
128        "voucher": snap.voucher,
129        "cash": snap.cash,
130        "currency": snap.currency,
131    })
132}
133
134fn parse_cache(bytes: &[u8], target: &str) -> Result<MoonshotSnapshot> {
135    let v: serde_json::Value = serde_json::from_slice(bytes)?;
136    // Payloads written before the target was recorded cannot be attributed to
137    // a region/currency, so they are discarded rather than shown against this one.
138    let cached_target = v.get("target").and_then(serde_json::Value::as_str);
139    if cached_target != Some(target) {
140        return Err(AppError::Schema(format!(
141            "moonshot cache belongs to a different endpoint/currency ({}); refetching",
142            cached_target.unwrap_or("unknown")
143        )));
144    }
145    let s = v
146        .get("snapshot")
147        .ok_or_else(|| AppError::Schema("moonshot cache missing 'snapshot' field".into()))?;
148    let field = |name: &str| -> Result<f64> {
149        let v = s[name]
150            .as_f64()
151            .ok_or_else(|| AppError::Schema(format!("moonshot cache missing '{name}'")))?;
152        finite_amount("moonshot cache", name, v)
153    };
154    Ok(MoonshotSnapshot {
155        available: field("available")?,
156        voucher: field("voucher")?,
157        cash: field("cash")?,
158        currency: s["currency"]
159            .as_str()
160            .ok_or_else(|| AppError::Schema("moonshot cache missing 'currency'".into()))?
161            .to_string(),
162    })
163}
164
165async fn fetch_live(
166    client: &reqwest::Client,
167    endpoints: &Endpoints,
168    api_key: &str,
169    currency: &str,
170) -> Result<MoonshotSnapshot> {
171    let resp = tokio::time::timeout(
172        HTTP_TIMEOUT,
173        client
174            .get(&endpoints.balance)
175            .header("Authorization", format!("Bearer {api_key}"))
176            .send(),
177    )
178    .await
179    .map_err(|_| AppError::Transport(format!("moonshot timeout: {}", endpoints.balance)))??;
180
181    let status = resp.status();
182    let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
183
184    if !status.is_success() {
185        let body = String::from_utf8_lossy(&bytes).chars().take(200).collect();
186        return Err(AppError::Http {
187            status: status.as_u16(),
188            body,
189        });
190    }
191    let env: BalanceEnvelope = serde_json::from_slice(&bytes)
192        .map_err(|e| AppError::Schema(format!("moonshot {}: {e}", endpoints.balance)))?;
193    // A 200 can still carry the documented in-band failure indicators.
194    env.check_ok()?;
195    to_snapshot(env.data, currency)
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use tempfile::TempDir;
202
203    fn cache_fixture() -> (TempDir, Cache) {
204        let td = TempDir::new().unwrap();
205        let cache = Cache::at(td.path().join("moonshot"));
206        cache.ensure_dir().unwrap();
207        (td, cache)
208    }
209
210    #[test]
211    fn region_picks_host_and_currency() {
212        let (global, cur) = Endpoints::for_region("global");
213        assert!(global.balance.starts_with("https://api.moonshot.ai"));
214        assert_eq!(cur, "USD");
215        let (cn, cur_cn) = Endpoints::for_region("cn");
216        assert!(cn.balance.starts_with("https://api.moonshot.cn"));
217        assert_eq!(cur_cn, "CNY");
218    }
219
220    #[tokio::test]
221    async fn live_fetch_reads_available_balance() {
222        let mut server = mockito::Server::new_async().await;
223        server
224            .mock("GET", "/v1/users/me/balance")
225            .match_header("authorization", "Bearer ms-test")
226            .with_status(200)
227            .with_body(
228                r#"{"code":0,"data":{"available_balance":49.58894,
229                    "voucher_balance":46.58893,"cash_balance":3.00001},
230                    "scode":"0x0","status":true}"#,
231            )
232            .create_async()
233            .await;
234
235        let (_td, cache) = cache_fixture();
236        let client = reqwest::Client::new();
237        let endpoints = Endpoints {
238            balance: format!("{}/v1/users/me/balance", server.url()),
239        };
240        let out = fetch_snapshot(
241            &client,
242            "ms-test",
243            &cache,
244            &endpoints,
245            Duration::from_secs(0),
246            "USD",
247        )
248        .await
249        .unwrap();
250        assert!((out.snapshot.available - 49.58894).abs() < 1e-6);
251        assert_eq!(out.snapshot.currency, "USD");
252        assert!(!out.stale);
253    }
254
255    #[tokio::test]
256    async fn http_error_falls_back_to_cache_when_present() {
257        let mut server = mockito::Server::new_async().await;
258        server
259            .mock("GET", "/v1/users/me/balance")
260            .with_status(401)
261            .with_body(r#"{"error":"auth"}"#)
262            .create_async()
263            .await;
264
265        let (_td, cache) = cache_fixture();
266        let endpoints = Endpoints {
267            balance: format!("{}/v1/users/me/balance", server.url()),
268        };
269        let seed = serde_json::json!({
270            "target": target_key(&endpoints, "USD"),
271            "snapshot": {
272                "available": 49.0, "voucher": 46.0, "cash": 3.0, "currency": "USD"
273            },
274        });
275        cache.write_payload(seed.to_string().as_bytes()).unwrap();
276
277        let client = reqwest::Client::new();
278        let out = fetch_snapshot(
279            &client,
280            "k",
281            &cache,
282            &endpoints,
283            Duration::from_secs(0),
284            "USD",
285        )
286        .await
287        .unwrap();
288        assert!(out.stale);
289        assert_eq!(out.snapshot.available, 49.0);
290        assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
291    }
292
293    #[tokio::test]
294    async fn in_band_failure_on_200_is_not_a_zero_balance() {
295        // The documented failure shape: HTTP 200 with status:false.
296        let mut server = mockito::Server::new_async().await;
297        server
298            .mock("GET", "/v1/users/me/balance")
299            .with_status(200)
300            .with_body(r#"{"code":40100,"data":{"available_balance":0.0,"voucher_balance":0.0,"cash_balance":0.0},"status":false,"scode":"0x1"}"#)
301            .create_async()
302            .await;
303
304        let (_td, cache) = cache_fixture();
305        let client = reqwest::Client::new();
306        let endpoints = Endpoints {
307            balance: format!("{}/v1/users/me/balance", server.url()),
308        };
309        let out = fetch_snapshot(
310            &client,
311            "k",
312            &cache,
313            &endpoints,
314            Duration::from_secs(0),
315            "USD",
316        )
317        .await;
318        assert!(out.is_err(), "expected a schema error, got {out:?}");
319    }
320
321    #[tokio::test]
322    async fn switching_region_refetches_instead_of_reusing_the_cache() {
323        // A CNY figure cached for .cn must never be shown as the .ai USD balance.
324        let mut server = mockito::Server::new_async().await;
325        server
326            .mock("GET", "/v1/users/me/balance")
327            .with_status(200)
328            .with_body(
329                r#"{"code":0,"data":{"available_balance":12.0,"voucher_balance":0.0,
330                    "cash_balance":12.0},"status":true}"#,
331            )
332            .create_async()
333            .await;
334
335        let (_td, cache) = cache_fixture();
336        let endpoints = Endpoints {
337            balance: format!("{}/v1/users/me/balance", server.url()),
338        };
339        // Fresh, but cached against the CNY target.
340        let seed = serde_json::json!({
341            "target": target_key(&endpoints, "CNY"),
342            "snapshot": {
343                "available": 999.0, "voucher": 0.0, "cash": 999.0, "currency": "CNY"
344            },
345        });
346        cache.write_payload(seed.to_string().as_bytes()).unwrap();
347
348        let client = reqwest::Client::new();
349        let out = fetch_snapshot(
350            &client,
351            "k",
352            &cache,
353            &endpoints,
354            Duration::from_secs(3600),
355            "USD",
356        )
357        .await
358        .unwrap();
359        assert_eq!(out.snapshot.available, 12.0);
360        assert_eq!(out.snapshot.currency, "USD");
361        assert!(!out.stale);
362    }
363}