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, MAX_STALE, 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#[derive(Debug, Clone)]
54pub struct FetchOutcome {
55    pub snapshot: MoonshotSnapshot,
56    pub stale: bool,
57    pub last_error: Option<(u16, String)>,
58    pub cache_age: Option<Duration>,
59}
60
61pub async fn fetch_snapshot(
62    client: &reqwest::Client,
63    api_key: &str,
64    cache: &Cache,
65    endpoints: &Endpoints,
66    cache_ttl: Duration,
67    currency: &str,
68) -> Result<FetchOutcome> {
69    cache.ensure_dir()?;
70    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
71
72    let target = target_key(endpoints, currency);
73
74    if let Some(bytes) = cache.fresh_payload(cache_ttl)?
75        && let Ok(outcome) = reuse_cache(&bytes, cache, false, &target)
76    {
77        return Ok(outcome);
78    }
79
80    match fetch_live(client, endpoints, api_key, currency).await {
81        Ok(snap) => {
82            let bytes = serde_json::to_vec(
83                &serde_json::json!({ "target": target, "snapshot": serde_repr(&snap) }),
84            )?;
85            cache.write_payload(&bytes)?;
86            Ok(FetchOutcome {
87                snapshot: snap,
88                stale: false,
89                last_error: None,
90                cache_age: Some(Duration::ZERO),
91            })
92        }
93        Err(e) if e.is_transient() => fallback_silent(cache, &target, e),
94        Err(AppError::Http { status, body }) => {
95            cache.mark_stale();
96            cache.write_last_error(status, &body);
97            let diag = (status, body.clone());
98            fallback_with_error(cache, Some(diag), &target, AppError::Http { status, body })
99        }
100        Err(e) => {
101            cache.mark_stale();
102            cache.write_last_error(0, &e.to_string());
103            let diag = (0, e.to_string());
104            fallback_with_error(cache, Some(diag), &target, e)
105        }
106    }
107}
108
109/// Identity of the account+region the cached figure belongs to. `.ai` reports
110/// USD and `.cn` reports CNY, so a cached number is meaningless — and actively
111/// misleading — once the user points the vendor at the other region.
112fn target_key(endpoints: &Endpoints, currency: &str) -> String {
113    format!("{}|{}", endpoints.balance, currency)
114}
115
116fn fallback_silent(cache: &Cache, target: &str, original: AppError) -> Result<FetchOutcome> {
117    let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
118        return Err(original);
119    };
120    reuse_cache(&bytes, cache, true, target)
121}
122
123/// On failure we show the last good figure with the error alongside it. With
124/// nothing usable cached there is nothing to show, so the **original** error is
125/// returned rather than a generic "no usable cache" that hides what went wrong.
126fn fallback_with_error(
127    cache: &Cache,
128    last_error: Option<(u16, String)>,
129    target: &str,
130    original: AppError,
131) -> Result<FetchOutcome> {
132    let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
133        return Err(original);
134    };
135    // A cache we cannot attribute to this target is no better than no cache.
136    let Ok(mut outcome) = reuse_cache(&bytes, cache, true, target) else {
137        return Err(original);
138    };
139    outcome.last_error = last_error;
140    Ok(outcome)
141}
142
143fn reuse_cache(bytes: &[u8], cache: &Cache, stale: bool, target: &str) -> Result<FetchOutcome> {
144    let snap = parse_cache(bytes, target)?;
145    Ok(FetchOutcome {
146        snapshot: snap,
147        stale,
148        last_error: cache.read_last_error(),
149        cache_age: cache.payload_age(),
150    })
151}
152
153fn serde_repr(snap: &MoonshotSnapshot) -> serde_json::Value {
154    serde_json::json!({
155        "available": snap.available,
156        "voucher": snap.voucher,
157        "cash": snap.cash,
158        "currency": snap.currency,
159    })
160}
161
162fn parse_cache(bytes: &[u8], target: &str) -> Result<MoonshotSnapshot> {
163    let v: serde_json::Value = serde_json::from_slice(bytes)?;
164    // Payloads written before the target was recorded cannot be attributed to
165    // a region/currency, so they are discarded rather than shown against this one.
166    let cached_target = v.get("target").and_then(serde_json::Value::as_str);
167    if cached_target != Some(target) {
168        return Err(AppError::Schema(format!(
169            "moonshot cache belongs to a different endpoint/currency ({}); refetching",
170            cached_target.unwrap_or("unknown")
171        )));
172    }
173    let s = v
174        .get("snapshot")
175        .ok_or_else(|| AppError::Schema("moonshot cache missing 'snapshot' field".into()))?;
176    let field = |name: &str| -> Result<f64> {
177        let v = s[name]
178            .as_f64()
179            .ok_or_else(|| AppError::Schema(format!("moonshot cache missing '{name}'")))?;
180        finite_amount("moonshot cache", name, v)
181    };
182    Ok(MoonshotSnapshot {
183        available: field("available")?,
184        voucher: field("voucher")?,
185        cash: field("cash")?,
186        currency: s["currency"]
187            .as_str()
188            .ok_or_else(|| AppError::Schema("moonshot cache missing 'currency'".into()))?
189            .to_string(),
190    })
191}
192
193async fn fetch_live(
194    client: &reqwest::Client,
195    endpoints: &Endpoints,
196    api_key: &str,
197    currency: &str,
198) -> Result<MoonshotSnapshot> {
199    let resp = tokio::time::timeout(
200        HTTP_TIMEOUT,
201        client
202            .get(&endpoints.balance)
203            .header("Authorization", format!("Bearer {api_key}"))
204            .send(),
205    )
206    .await
207    .map_err(|_| AppError::Transport(format!("moonshot timeout: {}", endpoints.balance)))??;
208
209    let status = resp.status();
210    let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
211
212    if !status.is_success() {
213        let body = String::from_utf8_lossy(&bytes).chars().take(200).collect();
214        return Err(AppError::Http {
215            status: status.as_u16(),
216            body,
217        });
218    }
219    let env: BalanceEnvelope = serde_json::from_slice(&bytes)
220        .map_err(|e| AppError::Schema(format!("moonshot {}: {e}", endpoints.balance)))?;
221    // A 200 can still carry the documented in-band failure indicators.
222    env.check_ok()?;
223    to_snapshot(env.data, currency)
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use tempfile::TempDir;
230
231    fn cache_fixture() -> (TempDir, Cache) {
232        let td = TempDir::new().unwrap();
233        let cache = Cache::at(td.path().join("moonshot"));
234        cache.ensure_dir().unwrap();
235        (td, cache)
236    }
237
238    #[test]
239    fn region_picks_host_and_currency() {
240        let (global, cur) = Endpoints::for_region("global");
241        assert!(global.balance.starts_with("https://api.moonshot.ai"));
242        assert_eq!(cur, "USD");
243        let (cn, cur_cn) = Endpoints::for_region("cn");
244        assert!(cn.balance.starts_with("https://api.moonshot.cn"));
245        assert_eq!(cur_cn, "CNY");
246    }
247
248    #[tokio::test]
249    async fn live_fetch_reads_available_balance() {
250        let mut server = mockito::Server::new_async().await;
251        server
252            .mock("GET", "/v1/users/me/balance")
253            .match_header("authorization", "Bearer ms-test")
254            .with_status(200)
255            .with_body(
256                r#"{"code":0,"data":{"available_balance":49.58894,
257                    "voucher_balance":46.58893,"cash_balance":3.00001},
258                    "scode":"0x0","status":true}"#,
259            )
260            .create_async()
261            .await;
262
263        let (_td, cache) = cache_fixture();
264        let client = reqwest::Client::new();
265        let endpoints = Endpoints {
266            balance: format!("{}/v1/users/me/balance", server.url()),
267        };
268        let out = fetch_snapshot(
269            &client,
270            "ms-test",
271            &cache,
272            &endpoints,
273            Duration::from_secs(0),
274            "USD",
275        )
276        .await
277        .unwrap();
278        assert!((out.snapshot.available - 49.58894).abs() < 1e-6);
279        assert_eq!(out.snapshot.currency, "USD");
280        assert!(!out.stale);
281    }
282
283    #[tokio::test]
284    async fn http_error_falls_back_to_cache_when_present() {
285        let mut server = mockito::Server::new_async().await;
286        server
287            .mock("GET", "/v1/users/me/balance")
288            .with_status(401)
289            .with_body(r#"{"error":"auth"}"#)
290            .create_async()
291            .await;
292
293        let (_td, cache) = cache_fixture();
294        let endpoints = Endpoints {
295            balance: format!("{}/v1/users/me/balance", server.url()),
296        };
297        let seed = serde_json::json!({
298            "target": target_key(&endpoints, "USD"),
299            "snapshot": {
300                "available": 49.0, "voucher": 46.0, "cash": 3.0, "currency": "USD"
301            },
302        });
303        cache.write_payload(seed.to_string().as_bytes()).unwrap();
304
305        let client = reqwest::Client::new();
306        let out = fetch_snapshot(
307            &client,
308            "k",
309            &cache,
310            &endpoints,
311            Duration::from_secs(0),
312            "USD",
313        )
314        .await
315        .unwrap();
316        assert!(out.stale);
317        assert_eq!(out.snapshot.available, 49.0);
318        assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
319    }
320
321    #[tokio::test]
322    async fn in_band_failure_on_200_is_not_a_zero_balance() {
323        // The documented failure shape: HTTP 200 with status:false.
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(r#"{"code":40100,"data":{"available_balance":0.0,"voucher_balance":0.0,"cash_balance":0.0},"status":false,"scode":"0x1"}"#)
329            .create_async()
330            .await;
331
332        let (_td, cache) = cache_fixture();
333        let client = reqwest::Client::new();
334        let endpoints = Endpoints {
335            balance: format!("{}/v1/users/me/balance", server.url()),
336        };
337        let out = fetch_snapshot(
338            &client,
339            "k",
340            &cache,
341            &endpoints,
342            Duration::from_secs(0),
343            "USD",
344        )
345        .await;
346        assert!(out.is_err(), "expected a schema error, got {out:?}");
347    }
348
349    #[tokio::test]
350    async fn switching_region_refetches_instead_of_reusing_the_cache() {
351        // A CNY figure cached for .cn must never be shown as the .ai USD balance.
352        let mut server = mockito::Server::new_async().await;
353        server
354            .mock("GET", "/v1/users/me/balance")
355            .with_status(200)
356            .with_body(
357                r#"{"code":0,"data":{"available_balance":12.0,"voucher_balance":0.0,
358                    "cash_balance":12.0},"status":true}"#,
359            )
360            .create_async()
361            .await;
362
363        let (_td, cache) = cache_fixture();
364        let endpoints = Endpoints {
365            balance: format!("{}/v1/users/me/balance", server.url()),
366        };
367        // Fresh, but cached against the CNY target.
368        let seed = serde_json::json!({
369            "target": target_key(&endpoints, "CNY"),
370            "snapshot": {
371                "available": 999.0, "voucher": 0.0, "cash": 999.0, "currency": "CNY"
372            },
373        });
374        cache.write_payload(seed.to_string().as_bytes()).unwrap();
375
376        let client = reqwest::Client::new();
377        let out = fetch_snapshot(
378            &client,
379            "k",
380            &cache,
381            &endpoints,
382            Duration::from_secs(3600),
383            "USD",
384        )
385        .await
386        .unwrap();
387        assert_eq!(out.snapshot.available, 12.0);
388        assert_eq!(out.snapshot.currency, "USD");
389        assert!(!out.stale);
390    }
391}