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