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