1use 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 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 let diag = cache.write_last_error(status, &body);
97 fallback_with_error(cache, Some(diag), &target, AppError::Http { status, body })
98 }
99 Err(e) => {
100 cache.mark_stale();
101 let diag = cache.write_last_error(0, &e.to_string());
102 fallback_with_error(cache, Some(diag), &target, e)
103 }
104 }
105}
106
107fn target_key(endpoints: &Endpoints, currency: &str) -> String {
111 format!("{}|{}", endpoints.balance, currency)
112}
113
114fn fallback_silent(cache: &Cache, target: &str, original: AppError) -> Result<FetchOutcome> {
115 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
116 return Err(original);
117 };
118 reuse_cache(&bytes, cache, true, target)
119}
120
121fn fallback_with_error(
125 cache: &Cache,
126 last_error: Option<(u16, String)>,
127 target: &str,
128 original: AppError,
129) -> Result<FetchOutcome> {
130 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
131 return Err(original);
132 };
133 let Ok(mut outcome) = reuse_cache(&bytes, cache, true, target) else {
135 return Err(original);
136 };
137 outcome.last_error = last_error;
138 Ok(outcome)
139}
140
141fn reuse_cache(bytes: &[u8], cache: &Cache, stale: bool, target: &str) -> Result<FetchOutcome> {
142 let snap = parse_cache(bytes, target)?;
143 Ok(FetchOutcome {
144 snapshot: snap,
145 stale,
146 last_error: cache.read_last_error(),
147 cache_age: cache.payload_age(),
148 })
149}
150
151fn serde_repr(snap: &MoonshotSnapshot) -> serde_json::Value {
152 serde_json::json!({
153 "available": snap.available,
154 "voucher": snap.voucher,
155 "cash": snap.cash,
156 "currency": snap.currency,
157 })
158}
159
160fn parse_cache(bytes: &[u8], target: &str) -> Result<MoonshotSnapshot> {
161 let v: serde_json::Value = serde_json::from_slice(bytes)?;
162 let cached_target = v.get("target").and_then(serde_json::Value::as_str);
165 if cached_target != Some(target) {
166 return Err(AppError::Schema(format!(
167 "moonshot cache belongs to a different endpoint/currency ({}); refetching",
168 cached_target.unwrap_or("unknown")
169 )));
170 }
171 let s = v
172 .get("snapshot")
173 .ok_or_else(|| AppError::Schema("moonshot cache missing 'snapshot' field".into()))?;
174 let field = |name: &str| -> Result<f64> {
175 let v = s[name]
176 .as_f64()
177 .ok_or_else(|| AppError::Schema(format!("moonshot cache missing '{name}'")))?;
178 finite_amount("moonshot cache", name, v)
179 };
180 Ok(MoonshotSnapshot {
181 available: field("available")?,
182 voucher: field("voucher")?,
183 cash: field("cash")?,
184 currency: s["currency"]
185 .as_str()
186 .ok_or_else(|| AppError::Schema("moonshot cache missing 'currency'".into()))?
187 .to_string(),
188 })
189}
190
191async fn fetch_live(
192 client: &reqwest::Client,
193 endpoints: &Endpoints,
194 api_key: &str,
195 currency: &str,
196) -> Result<MoonshotSnapshot> {
197 let resp = tokio::time::timeout(
198 HTTP_TIMEOUT,
199 client
200 .get(&endpoints.balance)
201 .header("Authorization", format!("Bearer {api_key}"))
202 .send(),
203 )
204 .await
205 .map_err(|_| AppError::Transport(format!("moonshot timeout: {}", endpoints.balance)))??;
206
207 let status = resp.status();
208 let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
209
210 if !status.is_success() {
211 let body = String::from_utf8_lossy(&bytes).chars().take(200).collect();
212 return Err(AppError::Http {
213 status: status.as_u16(),
214 body,
215 });
216 }
217 let env: BalanceEnvelope = serde_json::from_slice(&bytes)
218 .map_err(|e| AppError::Schema(format!("moonshot {}: {e}", endpoints.balance)))?;
219 env.check_ok()?;
221 to_snapshot(env.data, currency)
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use tempfile::TempDir;
228
229 fn cache_fixture() -> (TempDir, Cache) {
230 let td = TempDir::new().unwrap();
231 let cache = Cache::at(td.path().join("moonshot"));
232 cache.ensure_dir().unwrap();
233 (td, cache)
234 }
235
236 #[test]
237 fn region_picks_host_and_currency() {
238 let (global, cur) = Endpoints::for_region("global");
239 assert!(global.balance.starts_with("https://api.moonshot.ai"));
240 assert_eq!(cur, "USD");
241 let (cn, cur_cn) = Endpoints::for_region("cn");
242 assert!(cn.balance.starts_with("https://api.moonshot.cn"));
243 assert_eq!(cur_cn, "CNY");
244 }
245
246 #[tokio::test]
247 async fn live_fetch_reads_available_balance() {
248 let mut server = mockito::Server::new_async().await;
249 server
250 .mock("GET", "/v1/users/me/balance")
251 .match_header("authorization", "Bearer ms-test")
252 .with_status(200)
253 .with_body(
254 r#"{"code":0,"data":{"available_balance":49.58894,
255 "voucher_balance":46.58893,"cash_balance":3.00001},
256 "scode":"0x0","status":true}"#,
257 )
258 .create_async()
259 .await;
260
261 let (_td, cache) = cache_fixture();
262 let client = reqwest::Client::new();
263 let endpoints = Endpoints {
264 balance: format!("{}/v1/users/me/balance", server.url()),
265 };
266 let out = fetch_snapshot(
267 &client,
268 "ms-test",
269 &cache,
270 &endpoints,
271 Duration::from_secs(0),
272 "USD",
273 )
274 .await
275 .unwrap();
276 assert!((out.snapshot.available - 49.58894).abs() < 1e-6);
277 assert_eq!(out.snapshot.currency, "USD");
278 assert!(!out.stale);
279 }
280
281 #[tokio::test]
282 async fn http_error_falls_back_to_cache_when_present() {
283 let mut server = mockito::Server::new_async().await;
284 server
285 .mock("GET", "/v1/users/me/balance")
286 .with_status(401)
287 .with_body(r#"{"error":"auth"}"#)
288 .create_async()
289 .await;
290
291 let (_td, cache) = cache_fixture();
292 let endpoints = Endpoints {
293 balance: format!("{}/v1/users/me/balance", server.url()),
294 };
295 let seed = serde_json::json!({
296 "target": target_key(&endpoints, "USD"),
297 "snapshot": {
298 "available": 49.0, "voucher": 46.0, "cash": 3.0, "currency": "USD"
299 },
300 });
301 cache.write_payload(seed.to_string().as_bytes()).unwrap();
302
303 let client = reqwest::Client::new();
304 let out = fetch_snapshot(
305 &client,
306 "k",
307 &cache,
308 &endpoints,
309 Duration::from_secs(0),
310 "USD",
311 )
312 .await
313 .unwrap();
314 assert!(out.stale);
315 assert_eq!(out.snapshot.available, 49.0);
316 assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
317 }
318
319 #[tokio::test]
320 async fn in_band_failure_on_200_is_not_a_zero_balance() {
321 let mut server = mockito::Server::new_async().await;
323 server
324 .mock("GET", "/v1/users/me/balance")
325 .with_status(200)
326 .with_body(r#"{"code":40100,"data":{"available_balance":0.0,"voucher_balance":0.0,"cash_balance":0.0},"status":false,"scode":"0x1"}"#)
327 .create_async()
328 .await;
329
330 let (_td, cache) = cache_fixture();
331 let client = reqwest::Client::new();
332 let endpoints = Endpoints {
333 balance: format!("{}/v1/users/me/balance", server.url()),
334 };
335 let out = fetch_snapshot(
336 &client,
337 "k",
338 &cache,
339 &endpoints,
340 Duration::from_secs(0),
341 "USD",
342 )
343 .await;
344 assert!(out.is_err(), "expected a schema error, got {out:?}");
345 }
346
347 #[tokio::test]
348 async fn switching_region_refetches_instead_of_reusing_the_cache() {
349 let mut server = mockito::Server::new_async().await;
351 server
352 .mock("GET", "/v1/users/me/balance")
353 .with_status(200)
354 .with_body(
355 r#"{"code":0,"data":{"available_balance":12.0,"voucher_balance":0.0,
356 "cash_balance":12.0},"status":true}"#,
357 )
358 .create_async()
359 .await;
360
361 let (_td, cache) = cache_fixture();
362 let endpoints = Endpoints {
363 balance: format!("{}/v1/users/me/balance", server.url()),
364 };
365 let seed = serde_json::json!({
367 "target": target_key(&endpoints, "CNY"),
368 "snapshot": {
369 "available": 999.0, "voucher": 0.0, "cash": 999.0, "currency": "CNY"
370 },
371 });
372 cache.write_payload(seed.to_string().as_bytes()).unwrap();
373
374 let client = reqwest::Client::new();
375 let out = fetch_snapshot(
376 &client,
377 "k",
378 &cache,
379 &endpoints,
380 Duration::from_secs(3600),
381 "USD",
382 )
383 .await
384 .unwrap();
385 assert_eq!(out.snapshot.available, 12.0);
386 assert_eq!(out.snapshot.currency, "USD");
387 assert!(!out.stale);
388 }
389}