Skip to main content

ai_usagebar/minimax/
types.rs

1//! Wire types for MiniMax's Token Plan quota endpoint,
2//! `GET /v1/token_plan/remains`.
3//!
4//! Captured against the live global endpoint (`api.minimax.io`); MiniMax does
5//! not publish a schema for it, so every field here is one observed on the
6//! wire. The response is `{ model_remains: [...], base_resp: { … } }` with one
7//! row per model bucket (`general` for text/coding, `video`), each carrying a
8//! rolling interval window and a weekly window.
9//!
10//! Four properties of this API drive the code below, and all four are easy to
11//! get backwards:
12//!
13//! 1. **HTTP 200 always.** Auth failures come back `200` with the real status
14//!    in `base_resp.status_code` (`1004` no key, `2049` invalid key). The HTTP
15//!    status must never be read as success.
16//! 2. **The percentages are what REMAINS**, not what was consumed. They are
17//!    inverted here so the rest of the app keeps its consumed-% convention.
18//! 3. **The interval length is not fixed** — `general` rolls every 5h but
19//!    `video` rolls every 24h — so the window duration comes from each row's
20//!    own `start_time`/`end_time` instead of a constant.
21//! 4. **All timestamps are epoch milliseconds**, not seconds.
22
23use serde::Deserialize;
24
25use crate::error::{AppError, Result};
26use crate::usage::{MinimaxSnapshot, UsageWindow};
27
28/// Bucket name of the text/coding pool — the one that drives the bars.
29const BUCKET_GENERAL: &str = "general";
30/// Bucket name of the video-generation pool, rendered as the secondary pool.
31const BUCKET_VIDEO: &str = "video";
32
33/// Fallbacks used only when a row's own start/end can't yield a positive
34/// duration (a malformed row); the pacing math needs a non-zero window.
35const DEFAULT_INTERVAL: chrono::Duration = chrono::Duration::hours(5);
36const DEFAULT_WEEKLY: chrono::Duration = chrono::Duration::days(7);
37
38/// MiniMax's standard envelope. Present on every response, including the ones
39/// that carry no data because authentication failed.
40#[derive(Debug, Clone, Deserialize)]
41pub struct BaseResp {
42    pub status_code: i64,
43    #[serde(default)]
44    pub status_msg: String,
45}
46
47#[derive(Debug, Clone, Deserialize)]
48pub struct RemainsEnvelope {
49    /// Absent (not merely empty) on failure responses.
50    #[serde(default)]
51    pub model_remains: Vec<ModelRemains>,
52    pub base_resp: BaseResp,
53}
54
55impl RemainsEnvelope {
56    /// Reject the in-band failure shape before any field is read as a quota.
57    /// `status_code == 0` is the documented success value; everything else —
58    /// including the auth failures that arrive as HTTP 200 — is an error.
59    pub fn check_ok(&self) -> Result<()> {
60        if self.base_resp.status_code == 0 {
61            return Ok(());
62        }
63        // Do not surface `status_msg`: it is arbitrary upstream text and can
64        // contain request details. The stable numeric code is sufficient for
65        // diagnostics and safe to persist in the shared error cache.
66        Err(AppError::Schema(format!(
67            "minimax: API reported failure (status_code {})",
68            self.base_resp.status_code
69        )))
70    }
71}
72
73/// Envelope codes that mean "the credential was rejected" rather than "the
74/// service failed": `1004` no key supplied, `2049` the key is not valid for
75/// this instance (the code a global key gets from the CN host, and vice versa).
76/// The caller maps these onto an HTTP 401 so the UI reports an auth problem
77/// instead of filing a wrong key under schema drift.
78pub fn is_auth_failure(status_code: i64) -> bool {
79    matches!(status_code, 1004 | 2049)
80}
81
82/// One model bucket's quota. The `*_count` fields are request counters that
83/// only some buckets populate (`general` reports zeros and is governed by the
84/// percentages), so they are not part of the snapshot.
85#[derive(Debug, Clone, Deserialize)]
86pub struct ModelRemains {
87    pub model_name: String,
88    /// Rolling interval window, epoch **milliseconds**.
89    pub start_time: i64,
90    pub end_time: i64,
91    /// Percentage of the interval quota still available (0..=100).
92    pub current_interval_remaining_percent: i64,
93    /// Weekly window, epoch **milliseconds**.
94    pub weekly_start_time: i64,
95    pub weekly_end_time: i64,
96    /// Percentage of the weekly quota still available (0..=100).
97    pub current_weekly_remaining_percent: i64,
98}
99
100/// Consumed percent from MiniMax's remaining percent, clamped to the 0..=100
101/// the renderers expect. An out-of-range value upstream would otherwise paint
102/// a bar past its track.
103fn consumed_pct(remaining: i64) -> i32 {
104    (100 - remaining.clamp(0, 100)) as i32
105}
106
107/// Epoch milliseconds to a UTC instant. Non-positive values mean "unreported"
108/// rather than 1970, so they become `None` and the row simply shows no reset.
109fn at_millis(ms: i64) -> Option<chrono::DateTime<chrono::Utc>> {
110    if ms <= 0 {
111        return None;
112    }
113    chrono::DateTime::from_timestamp_millis(ms)
114}
115
116/// Window length from the row's own bounds, falling back to `default` when the
117/// pair is unusable — never zero, which would make the pace marker divide by
118/// nothing.
119fn span(start_ms: i64, end_ms: i64, default: chrono::Duration) -> chrono::Duration {
120    let delta = end_ms.saturating_sub(start_ms);
121    if delta > 0 {
122        chrono::Duration::milliseconds(delta)
123    } else {
124        default
125    }
126}
127
128fn interval_window(row: &ModelRemains) -> UsageWindow {
129    UsageWindow {
130        utilization_pct: consumed_pct(row.current_interval_remaining_percent),
131        resets_at: at_millis(row.end_time),
132        window_duration: span(row.start_time, row.end_time, DEFAULT_INTERVAL),
133    }
134}
135
136fn weekly_window(row: &ModelRemains) -> UsageWindow {
137    UsageWindow {
138        utilization_pct: consumed_pct(row.current_weekly_remaining_percent),
139        resets_at: at_millis(row.weekly_end_time),
140        window_duration: span(row.weekly_start_time, row.weekly_end_time, DEFAULT_WEEKLY),
141    }
142}
143
144/// Build the snapshot from the parsed rows.
145///
146/// The `general` bucket is required — it is what the bars represent. If a plan
147/// ever names its text bucket something else, the first non-video row is used
148/// rather than failing outright; only a payload with no usable row at all is an
149/// error, because rendering a plan as 0% used would be a silent lie.
150pub fn to_snapshot(env: RemainsEnvelope, plan: &str) -> Result<MinimaxSnapshot> {
151    let rows = &env.model_remains;
152    let general = rows
153        .iter()
154        .find(|r| r.model_name == BUCKET_GENERAL)
155        .or_else(|| rows.iter().find(|r| r.model_name != BUCKET_VIDEO))
156        .ok_or_else(|| {
157            AppError::Schema("minimax: response carried no usable model bucket".to_string())
158        })?;
159    let video = rows.iter().find(|r| r.model_name == BUCKET_VIDEO);
160
161    Ok(MinimaxSnapshot {
162        plan: plan.to_string(),
163        session: interval_window(general),
164        weekly: weekly_window(general),
165        video_session: video.map(interval_window),
166        video_weekly: video.map(weekly_window),
167    })
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    /// Verbatim shape of a live successful response (values from a real Token
175    /// Plan account; `general` at 99% remaining on a 5h window, `video` at
176    /// 100% on a 24h one).
177    const LIVE: &str = r#"{
178        "model_remains": [
179            {
180                "start_time": 1785164400000,
181                "end_time": 1785182400000,
182                "remains_time": 1877492,
183                "current_interval_total_count": 0,
184                "current_interval_usage_count": 0,
185                "model_name": "general",
186                "current_weekly_total_count": 0,
187                "current_weekly_usage_count": 0,
188                "weekly_start_time": 1785110400000,
189                "weekly_end_time": 1785715200000,
190                "weekly_remains_time": 534677492,
191                "current_interval_status": 1,
192                "current_interval_remaining_percent": 99,
193                "current_weekly_status": 1,
194                "current_weekly_remaining_percent": 99
195            },
196            {
197                "start_time": 1785110400000,
198                "end_time": 1785196800000,
199                "remains_time": 16277492,
200                "current_interval_total_count": 3,
201                "current_interval_usage_count": 0,
202                "model_name": "video",
203                "current_weekly_total_count": 21,
204                "current_weekly_usage_count": 0,
205                "weekly_start_time": 1785110400000,
206                "weekly_end_time": 1785715200000,
207                "weekly_remains_time": 534677492,
208                "current_interval_status": 1,
209                "current_interval_remaining_percent": 100,
210                "current_weekly_status": 1,
211                "current_weekly_remaining_percent": 100
212            }
213        ],
214        "base_resp": { "status_code": 0, "status_msg": "success" }
215    }"#;
216
217    fn parse(raw: &str) -> RemainsEnvelope {
218        serde_json::from_str(raw).expect("envelope parses")
219    }
220
221    #[test]
222    fn parses_live_envelope() {
223        let env = parse(LIVE);
224        env.check_ok().expect("status_code 0 is success");
225        assert_eq!(env.model_remains.len(), 2);
226        assert_eq!(env.model_remains[0].model_name, "general");
227    }
228
229    /// The API reports what is LEFT; the app renders what was USED. A snapshot
230    /// that echoed 99 here would show a nearly-exhausted plan as nearly-full.
231    #[test]
232    fn inverts_remaining_percent_into_consumed() {
233        let snap = to_snapshot(parse(LIVE), "Token Plan").unwrap();
234        assert_eq!(snap.session.utilization_pct, 1);
235        assert_eq!(snap.weekly.utilization_pct, 1);
236        assert_eq!(snap.video_session.unwrap().utilization_pct, 0);
237    }
238
239    /// The interval length differs per bucket, so it must come from the row.
240    #[test]
241    fn derives_window_length_from_the_row_not_a_constant() {
242        let snap = to_snapshot(parse(LIVE), "Token Plan").unwrap();
243        assert_eq!(snap.session.window_duration, chrono::Duration::hours(5));
244        assert_eq!(snap.weekly.window_duration, chrono::Duration::days(7));
245        assert_eq!(
246            snap.video_session.unwrap().window_duration,
247            chrono::Duration::hours(24),
248            "video rolls daily, not on general's 5h cadence"
249        );
250    }
251
252    #[test]
253    fn reset_comes_from_end_time_in_milliseconds() {
254        let snap = to_snapshot(parse(LIVE), "Token Plan").unwrap();
255        assert_eq!(
256            snap.session.resets_at,
257            chrono::DateTime::from_timestamp_millis(1785182400000)
258        );
259    }
260
261    /// Auth failures arrive as HTTP 200 — the envelope is the only signal.
262    #[test]
263    fn rejects_in_band_auth_failure() {
264        for raw in [
265            r#"{"base_resp":{"status_code":1004,"status_msg":"login fail: Please carry the API secret key in the 'Authorization' field of the request header"}}"#,
266            r#"{"base_resp":{"status_code":2049,"status_msg":"invalid api key"}}"#,
267        ] {
268            let env = parse(raw);
269            assert!(env.model_remains.is_empty());
270            let err = env.check_ok().unwrap_err();
271            assert!(
272                matches!(err, AppError::Schema(ref m) if m.contains("minimax")),
273                "unexpected error: {err:?}"
274            );
275        }
276    }
277
278    #[test]
279    fn in_band_failure_does_not_surface_upstream_message() {
280        let env =
281            parse(r#"{"base_resp":{"status_code":9001,"status_msg":"secret request detail"}}"#);
282        let error = env.check_ok().unwrap_err().to_string();
283        assert!(error.contains("9001"), "{error}");
284        assert!(!error.contains("secret request detail"), "{error}");
285    }
286
287    /// A plan without video quota is normal, not an error.
288    #[test]
289    fn video_bucket_is_optional() {
290        let raw = r#"{
291            "model_remains": [{
292                "start_time": 1785164400000, "end_time": 1785182400000,
293                "model_name": "general",
294                "current_interval_remaining_percent": 40,
295                "weekly_start_time": 1785110400000, "weekly_end_time": 1785715200000,
296                "current_weekly_remaining_percent": 55
297            }],
298            "base_resp": {"status_code": 0, "status_msg": "success"}
299        }"#;
300        let snap = to_snapshot(parse(raw), "Token Plan").unwrap();
301        assert_eq!(snap.session.utilization_pct, 60);
302        assert_eq!(snap.weekly.utilization_pct, 45);
303        assert!(snap.video_session.is_none());
304        assert!(snap.video_weekly.is_none());
305    }
306
307    /// A response whose only row is `video` has no bar to draw — surfacing that
308    /// beats rendering an empty general pool as 0% used.
309    #[test]
310    fn errors_when_no_text_bucket_is_present() {
311        let raw = r#"{
312            "model_remains": [{
313                "start_time": 1, "end_time": 2, "model_name": "video",
314                "current_interval_remaining_percent": 100,
315                "weekly_start_time": 1, "weekly_end_time": 2,
316                "current_weekly_remaining_percent": 100
317            }],
318            "base_resp": {"status_code": 0, "status_msg": "success"}
319        }"#;
320        assert!(to_snapshot(parse(raw), "Token Plan").is_err());
321    }
322
323    /// Degenerate bounds must not produce a zero-length window: the pace marker
324    /// divides by it.
325    #[test]
326    fn falls_back_to_a_positive_window_on_degenerate_bounds() {
327        let raw = r#"{
328            "model_remains": [{
329                "start_time": 0, "end_time": 0, "model_name": "general",
330                "current_interval_remaining_percent": 100,
331                "weekly_start_time": 0, "weekly_end_time": 0,
332                "current_weekly_remaining_percent": 100
333            }],
334            "base_resp": {"status_code": 0, "status_msg": "success"}
335        }"#;
336        let snap = to_snapshot(parse(raw), "Token Plan").unwrap();
337        assert_eq!(snap.session.window_duration, DEFAULT_INTERVAL);
338        assert_eq!(snap.weekly.window_duration, DEFAULT_WEEKLY);
339        assert_eq!(snap.session.resets_at, None, "epoch 0 is unreported");
340    }
341
342    /// Upstream sending something outside 0..=100 must not paint past the track.
343    #[test]
344    fn clamps_out_of_range_percentages() {
345        assert_eq!(consumed_pct(150), 0);
346        assert_eq!(consumed_pct(-5), 100);
347    }
348}