1use std::time::Duration;
11
12use crate::cache::{Cache, acquire_lock_async};
13use crate::error::{AppError, Result};
14use crate::usage::{MinimaxSnapshot, UsageWindow};
15use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
16
17use super::types::{RemainsEnvelope, is_auth_failure, to_snapshot};
18
19pub const BASE_GLOBAL: &str = "https://api.minimax.io";
20pub const BASE_CN: &str = "https://api.minimaxi.com";
21const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
22const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
23
24pub const PLAN_LABEL: &str = "MiniMax Token Plan";
28
29#[derive(Debug, Clone)]
30pub struct Endpoints {
31 pub remains: String,
32}
33
34impl Endpoints {
35 pub fn for_region(region: &str) -> Self {
39 let base = if region.eq_ignore_ascii_case("cn") {
40 BASE_CN
41 } else {
42 BASE_GLOBAL
43 };
44 Self {
45 remains: format!("{base}/v1/token_plan/remains"),
46 }
47 }
48}
49
50impl Default for Endpoints {
51 fn default() -> Self {
52 Self::for_region("global")
53 }
54}
55
56pub type FetchOutcome = crate::outcome::Outcome<MinimaxSnapshot>;
59
60pub async fn fetch_snapshot(
61 client: &reqwest::Client,
62 api_key: &str,
63 cache: &Cache,
64 endpoints: &Endpoints,
65 cache_ttl: Duration,
66) -> Result<FetchOutcome> {
67 cache.ensure_dir()?;
68 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
69
70 let target = target_key(endpoints, api_key);
71
72 if let Some(bytes) = cache.fresh_payload(cache_ttl)?
73 && let Ok(outcome) = reuse_cache(&bytes, cache, false, &target)
74 {
75 return Ok(outcome);
76 }
77
78 match fetch_live(client, endpoints, api_key).await {
79 Ok(snap) => {
80 let bytes = serde_json::to_vec(
81 &serde_json::json!({ "target": target, "snapshot": serde_repr(&snap) }),
82 )?;
83 cache.write_payload(&bytes)?;
84 Ok(crate::outcome::Outcome::fresh(snap))
85 }
86 Err(e) if e.is_transient() => fallback_silent(cache, &target, e),
87 Err(AppError::Http { status, body }) => {
88 cache.mark_stale();
89 let diag = cache.write_last_error(status, &body);
90 fallback_with_error(cache, Some(diag), &target, AppError::Http { status, body })
91 }
92 Err(e) => {
93 cache.mark_stale();
94 let diag = cache.write_last_error(0, &e.to_string());
95 fallback_with_error(cache, Some(diag), &target, e)
96 }
97 }
98}
99
100fn target_key(endpoints: &Endpoints, api_key: &str) -> String {
105 use std::hash::{Hash, Hasher};
106 let mut hasher = std::collections::hash_map::DefaultHasher::new();
107 api_key.hash(&mut hasher);
108 format!("{}|key:{:016x}", endpoints.remains, hasher.finish())
109}
110
111fn fallback_silent(cache: &Cache, target: &str, original: AppError) -> Result<FetchOutcome> {
112 crate::outcome::fallback(cache, None, original, |bytes| parse_cache(bytes, target))
113}
114
115fn fallback_with_error(
116 cache: &Cache,
117 last_error: Option<(u16, String)>,
118 target: &str,
119 original: AppError,
120) -> Result<FetchOutcome> {
121 crate::outcome::fallback(cache, last_error, original, |bytes| {
122 parse_cache(bytes, target)
123 })
124}
125
126fn reuse_cache(bytes: &[u8], cache: &Cache, stale: bool, target: &str) -> Result<FetchOutcome> {
127 let snap = parse_cache(bytes, target)?;
128 Ok(crate::outcome::Outcome::cached(snap, cache, stale))
129}
130
131fn window_repr(w: &UsageWindow) -> serde_json::Value {
132 serde_json::json!({
133 "pct": w.utilization_pct,
134 "resets_at": w.resets_at.map(|t| t.to_rfc3339()),
135 "window_secs": w.window_duration.num_seconds(),
136 })
137}
138
139fn serde_repr(snap: &MinimaxSnapshot) -> serde_json::Value {
140 serde_json::json!({
141 "plan": snap.plan,
142 "session": window_repr(&snap.session),
143 "weekly": window_repr(&snap.weekly),
144 "video_session": snap.video_session.as_ref().map(window_repr),
145 "video_weekly": snap.video_weekly.as_ref().map(window_repr),
146 })
147}
148
149fn parse_window(v: &serde_json::Value, what: &str) -> Result<UsageWindow> {
150 let pct = v["pct"]
151 .as_i64()
152 .ok_or_else(|| AppError::Schema(format!("minimax cache missing '{what}.pct'")))?;
153 let resets_at = match v["resets_at"].as_str() {
154 Some(s) => Some(
155 chrono::DateTime::parse_from_rfc3339(s)
156 .map_err(|e| AppError::Schema(format!("minimax cache '{what}.resets_at': {e}")))?
157 .with_timezone(&chrono::Utc),
158 ),
159 None => None,
160 };
161 let secs = v["window_secs"]
162 .as_i64()
163 .ok_or_else(|| AppError::Schema(format!("minimax cache missing '{what}.window_secs'")))?;
164 if secs <= 0 {
165 return Err(AppError::Schema(format!(
166 "minimax cache '{what}.window_secs' must be greater than zero"
167 )));
168 }
169 Ok(UsageWindow {
170 utilization_pct: pct.clamp(0, 100) as i32,
171 resets_at,
172 window_duration: chrono::Duration::seconds(secs),
173 })
174}
175
176fn parse_cache(bytes: &[u8], target: &str) -> Result<MinimaxSnapshot> {
177 let v: serde_json::Value = serde_json::from_slice(bytes)?;
178 let cached_target = v.get("target").and_then(serde_json::Value::as_str);
179 if cached_target != Some(target) {
180 return Err(AppError::Schema(format!(
181 "minimax cache belongs to a different instance ({}); refetching",
182 cached_target.unwrap_or("unknown")
183 )));
184 }
185 let s = v
186 .get("snapshot")
187 .ok_or_else(|| AppError::Schema("minimax cache missing 'snapshot' field".into()))?;
188 let optional = |name: &str| -> Result<Option<UsageWindow>> {
189 match s.get(name) {
190 None | Some(serde_json::Value::Null) => Ok(None),
191 Some(w) => parse_window(w, name).map(Some),
192 }
193 };
194 Ok(MinimaxSnapshot {
195 plan: s["plan"].as_str().unwrap_or(PLAN_LABEL).to_string(),
196 session: parse_window(&s["session"], "session")?,
197 weekly: parse_window(&s["weekly"], "weekly")?,
198 video_session: optional("video_session")?,
199 video_weekly: optional("video_weekly")?,
200 })
201}
202
203async fn fetch_live(
204 client: &reqwest::Client,
205 endpoints: &Endpoints,
206 api_key: &str,
207) -> Result<MinimaxSnapshot> {
208 let resp = tokio::time::timeout(
209 HTTP_TIMEOUT,
210 client
211 .get(&endpoints.remains)
212 .header("Authorization", format!("Bearer {api_key}"))
213 .send(),
214 )
215 .await
216 .map_err(|_| AppError::Transport(format!("minimax timeout: {}", endpoints.remains)))??;
217
218 let status = resp.status();
219 let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
220
221 if !status.is_success() {
222 let body = if matches!(status.as_u16(), 401 | 403) {
225 "MiniMax authentication failed".to_string()
226 } else {
227 format!("MiniMax API returned HTTP {}", status.as_u16())
228 };
229 return Err(AppError::Http {
230 status: status.as_u16(),
231 body,
232 });
233 }
234
235 let env: RemainsEnvelope = serde_json::from_slice(&bytes)
236 .map_err(|e| AppError::Schema(format!("minimax {}: {e}", endpoints.remains)))?;
237
238 if is_auth_failure(env.base_resp.status_code) {
242 return Err(AppError::Http {
243 status: 401,
244 body: "MiniMax authentication failed".to_string(),
245 });
246 }
247 env.check_ok()?;
248 to_snapshot(env, PLAN_LABEL)
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use tempfile::TempDir;
255
256 const LIVE_BODY: &str = r#"{
257 "model_remains": [
258 {"start_time":1785164400000,"end_time":1785182400000,"model_name":"general",
259 "current_interval_remaining_percent":99,
260 "weekly_start_time":1785110400000,"weekly_end_time":1785715200000,
261 "current_weekly_remaining_percent":80},
262 {"start_time":1785110400000,"end_time":1785196800000,"model_name":"video",
263 "current_interval_remaining_percent":100,
264 "weekly_start_time":1785110400000,"weekly_end_time":1785715200000,
265 "current_weekly_remaining_percent":100}
266 ],
267 "base_resp": {"status_code":0,"status_msg":"success"}
268 }"#;
269
270 fn cache_fixture() -> (TempDir, Cache) {
271 let td = TempDir::new().unwrap();
272 let cache = Cache::at(td.path().join("minimax"));
273 cache.ensure_dir().unwrap();
274 (td, cache)
275 }
276
277 fn endpoints_for(server: &mockito::Server) -> Endpoints {
278 Endpoints {
279 remains: format!("{}/v1/token_plan/remains", server.url()),
280 }
281 }
282
283 #[test]
284 fn region_picks_the_instance_host() {
285 assert!(
286 Endpoints::for_region("global")
287 .remains
288 .starts_with(BASE_GLOBAL)
289 );
290 assert!(Endpoints::for_region("cn").remains.starts_with(BASE_CN));
291 assert!(Endpoints::for_region("CN").remains.starts_with(BASE_CN));
292 assert!(Endpoints::for_region("").remains.starts_with(BASE_GLOBAL));
294 }
295
296 #[tokio::test]
297 async fn live_fetch_reads_both_pools() {
298 let mut server = mockito::Server::new_async().await;
299 server
300 .mock("GET", "/v1/token_plan/remains")
301 .match_header("authorization", "Bearer mm-test")
302 .with_status(200)
303 .with_body(LIVE_BODY)
304 .create_async()
305 .await;
306
307 let (_td, cache) = cache_fixture();
308 let out = fetch_snapshot(
309 &reqwest::Client::new(),
310 "mm-test",
311 &cache,
312 &endpoints_for(&server),
313 Duration::from_secs(0),
314 )
315 .await
316 .unwrap();
317
318 assert_eq!(out.snapshot.session.utilization_pct, 1);
319 assert_eq!(out.snapshot.weekly.utilization_pct, 20);
320 assert_eq!(out.snapshot.video_session.unwrap().utilization_pct, 0);
321 assert!(!out.stale);
322 assert_eq!(out.snapshot.plan, PLAN_LABEL);
323 }
324
325 #[tokio::test]
328 async fn in_band_auth_failure_is_reported_as_401() {
329 let mut server = mockito::Server::new_async().await;
330 server
331 .mock("GET", "/v1/token_plan/remains")
332 .with_status(200)
333 .with_body(r#"{"base_resp":{"status_code":2049,"status_msg":"invalid api key"}}"#)
334 .create_async()
335 .await;
336
337 let (_td, cache) = cache_fixture();
338 let err = fetch_snapshot(
339 &reqwest::Client::new(),
340 "bad",
341 &cache,
342 &endpoints_for(&server),
343 Duration::from_secs(0),
344 )
345 .await
346 .unwrap_err();
347
348 match err {
349 AppError::Http { status, ref body } => {
350 assert_eq!(status, 401);
351 assert!(body.contains("authentication"), "body was {body:?}");
352 }
353 other => panic!("expected HTTP 401, got {other:?}"),
354 }
355 }
356
357 #[tokio::test]
359 async fn cache_from_the_other_instance_is_rejected() {
360 let mut server = mockito::Server::new_async().await;
361 server
362 .mock("GET", "/v1/token_plan/remains")
363 .with_status(200)
364 .with_body(LIVE_BODY)
365 .create_async()
366 .await;
367
368 let (_td, cache) = cache_fixture();
369 let other_instance = Endpoints::for_region("cn");
370 let seed = serde_json::json!({
371 "target": target_key(&other_instance, "mm-test"),
372 "snapshot": {
373 "plan": "MiniMax Token Plan",
374 "session": {"pct": 77, "resets_at": null, "window_secs": 18000},
375 "weekly": {"pct": 77, "resets_at": null, "window_secs": 604800},
376 }
377 });
378 cache
379 .write_payload(&serde_json::to_vec(&seed).unwrap())
380 .unwrap();
381
382 let out = fetch_snapshot(
385 &reqwest::Client::new(),
386 "mm-test",
387 &cache,
388 &endpoints_for(&server),
389 Duration::from_secs(3600),
390 )
391 .await
392 .unwrap();
393 assert_eq!(out.snapshot.session.utilization_pct, 1, "refetched, not 77");
394 }
395
396 #[tokio::test]
400 async fn cache_from_another_key_is_rejected() {
401 let mut server = mockito::Server::new_async().await;
402 server
403 .mock("GET", "/v1/token_plan/remains")
404 .match_header("authorization", "Bearer new-key")
405 .with_status(200)
406 .with_body(LIVE_BODY)
407 .expect(1)
408 .create_async()
409 .await;
410
411 let (_td, cache) = cache_fixture();
412 let endpoints = endpoints_for(&server);
413 let seed = serde_json::json!({
414 "target": target_key(&endpoints, "old-key"),
415 "snapshot": {
416 "plan": "MiniMax Token Plan",
417 "session": {"pct": 77, "resets_at": null, "window_secs": 18000},
418 "weekly": {"pct": 77, "resets_at": null, "window_secs": 604800},
419 }
420 });
421 cache
422 .write_payload(&serde_json::to_vec(&seed).unwrap())
423 .unwrap();
424
425 let out = fetch_snapshot(
426 &reqwest::Client::new(),
427 "new-key",
428 &cache,
429 &endpoints,
430 Duration::from_secs(3600),
431 )
432 .await
433 .unwrap();
434 assert_eq!(out.snapshot.session.utilization_pct, 1, "refetched, not 77");
435
436 let stored = std::fs::read(cache.payload_path()).unwrap();
437 let stored = String::from_utf8(stored).unwrap();
438 assert!(!stored.contains("new-key"), "cache leaked the API key");
439 }
440
441 #[tokio::test]
442 async fn http_error_falls_back_to_matching_cache() {
443 let mut server = mockito::Server::new_async().await;
444 server
445 .mock("GET", "/v1/token_plan/remains")
446 .with_status(500)
447 .with_body("upstream exploded")
448 .create_async()
449 .await;
450
451 let (_td, cache) = cache_fixture();
452 let endpoints = endpoints_for(&server);
453 let seed = serde_json::json!({
454 "target": target_key(&endpoints, "mm-test"),
455 "snapshot": {
456 "plan": "MiniMax Token Plan",
457 "session": {"pct": 42, "resets_at": null, "window_secs": 18000},
458 "weekly": {"pct": 43, "resets_at": null, "window_secs": 604800},
459 }
460 });
461 cache
462 .write_payload(&serde_json::to_vec(&seed).unwrap())
463 .unwrap();
464
465 let out = fetch_snapshot(
466 &reqwest::Client::new(),
467 "mm-test",
468 &cache,
469 &endpoints,
470 Duration::from_secs(0),
471 )
472 .await
473 .unwrap();
474
475 assert!(out.stale);
476 assert_eq!(out.snapshot.session.utilization_pct, 42);
477 let (code, body) = out.last_error.expect("error recorded alongside the figure");
478 assert_eq!(code, 500);
479 assert!(
480 !body.contains("exploded"),
481 "upstream body must not be surfaced: {body:?}"
482 );
483 }
484
485 #[test]
486 fn cache_round_trips_windows_including_reset_and_duration() {
487 let endpoints = Endpoints::default();
488 let snap = MinimaxSnapshot {
489 plan: PLAN_LABEL.to_string(),
490 session: UsageWindow {
491 utilization_pct: 12,
492 resets_at: chrono::DateTime::from_timestamp_millis(1785182400000),
493 window_duration: chrono::Duration::hours(5),
494 },
495 weekly: UsageWindow {
496 utilization_pct: 34,
497 resets_at: None,
498 window_duration: chrono::Duration::days(7),
499 },
500 video_session: None,
501 video_weekly: None,
502 };
503 let bytes = serde_json::to_vec(&serde_json::json!({
504 "target": target_key(&endpoints, "mm-test"),
505 "snapshot": serde_repr(&snap),
506 }))
507 .unwrap();
508 let back = parse_cache(&bytes, &target_key(&endpoints, "mm-test")).unwrap();
509 assert_eq!(back, snap);
510 }
511
512 #[test]
513 fn cache_rejects_non_positive_window_duration() {
514 let endpoints = Endpoints::default();
515 for seconds in [0, -1] {
516 let bytes = serde_json::to_vec(&serde_json::json!({
517 "target": target_key(&endpoints, "mm-test"),
518 "snapshot": {
519 "plan": "MiniMax Token Plan",
520 "session": {"pct": 1, "resets_at": null, "window_secs": seconds},
521 "weekly": {"pct": 2, "resets_at": null, "window_secs": 604800},
522 }
523 }))
524 .unwrap();
525 let error = parse_cache(&bytes, &target_key(&endpoints, "mm-test")).unwrap_err();
526 assert!(error.to_string().contains("greater than zero"), "{error:?}");
527 }
528 }
529}