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, 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
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#[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
109/// Identity of the account and instance the cached quota belongs to. Global
110/// and CN are separate deployments, and changing the Token Plan key on the
111/// same deployment can change accounts. Store only a fingerprint of the key:
112/// it is a cache change detector, not an authentication secret.
113fn 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
127/// On failure we show the last good figure with the error alongside it. With
128/// nothing usable cached there is nothing to show, so the **original** error is
129/// returned rather than a generic "no usable cache" that hides what went wrong.
130fn 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        // Never surface upstream/proxy bodies: they can carry credentials or
248        // arbitrary markup. Keep the cached diagnostic useful but generic.
249        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    // MiniMax answers auth failures with HTTP 200 and the real status in the
264    // envelope. Those are reported as HTTP 401 so the UI says "authentication
265    // failed" instead of filing a wrong key under schema drift.
266    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        // Anything unrecognized stays on the global instance.
318        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    /// The whole point of the in-band check: a 200 carrying `2049` is an auth
351    /// failure, and must not be cached as a valid zero-usage plan.
352    #[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    /// A cache written against the other instance is wrong, not merely stale.
383    #[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        // A long TTL would normally serve the cache; the target mismatch must
408        // force a refetch instead of showing the other instance's numbers.
409        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    /// Replacing the configured key can select another Token Plan account on
422    /// the same host. A fresh cache from the previous key must not cross that
423    /// account boundary.
424    #[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}