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