Skip to main content

ai_usagebar/minimax/
fetch.rs

1//! MiniMax fetch — reads Token Plan quota from `/v1/token_plan/remains` under
2//! the shared cache + flock primitives.
3//!
4//! The host is region-dependent (`api.minimax.io` global, `api.minimaxi.com`
5//! CN) and, unlike Moonshot's regions, the two are *separate instances*: a key
6//! issued for one is rejected by the other. That makes a cached figure from the
7//! other region not merely stale but wrong, so the endpoint is recorded in the
8//! payload and a mismatched cache is discarded.
9
10use 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
24/// Label shown as the plan name. The quota endpoint doesn't name the plan, and
25/// inventing a tier from the numbers would be a guess, so the product name is
26/// used as-is.
27pub const PLAN_LABEL: &str = "MiniMax Token Plan";
28
29#[derive(Debug, Clone)]
30pub struct Endpoints {
31    pub remains: String,
32}
33
34impl Endpoints {
35    /// Pick the host by region. `cn` → `api.minimaxi.com`, anything else →
36    /// `api.minimax.io`. No currency is implied: MiniMax reports quota as
37    /// percentages, not money.
38    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/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
57/// specialised to its snapshot.
58pub 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
100/// Identity of the account and instance the cached quota belongs to. Global
101/// and CN are separate deployments, and changing the Token Plan key on the
102/// same deployment can change accounts. Store only a fingerprint of the key:
103/// it is a cache change detector, not an authentication secret.
104fn 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        // Never surface upstream/proxy bodies: they can carry credentials or
223        // arbitrary markup. Keep the cached diagnostic useful but generic.
224        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    // MiniMax answers auth failures with HTTP 200 and the real status in the
239    // envelope. Those are reported as HTTP 401 so the UI says "authentication
240    // failed" instead of filing a wrong key under schema drift.
241    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        // Anything unrecognized stays on the global instance.
293        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    /// The whole point of the in-band check: a 200 carrying `2049` is an auth
326    /// failure, and must not be cached as a valid zero-usage plan.
327    #[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    /// A cache written against the other instance is wrong, not merely stale.
358    #[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        // A long TTL would normally serve the cache; the target mismatch must
383        // force a refetch instead of showing the other instance's numbers.
384        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    /// Replacing the configured key can select another Token Plan account on
397    /// the same host. A fresh cache from the previous key must not cross that
398    /// account boundary.
399    #[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}