Skip to main content

usage_monitor_cli/provider/
minimax.rs

1use async_trait::async_trait;
2
3use crate::error::SpendPanelError;
4use crate::model::{PlanInfo, RateWindow, UsageSnapshot};
5use crate::provider::{ProviderContext, ProviderMetadata, UsageProvider};
6
7#[derive(Debug, serde::Deserialize)]
8struct MiniMaxResponse {
9    #[serde(default)]
10    data: Option<MiniMaxData>,
11    #[serde(default, rename = "base_resp")]
12    base_resp: Option<MiniMaxBaseResp>,
13}
14
15#[derive(Debug, serde::Deserialize)]
16struct MiniMaxData {
17    #[serde(default, rename = "base_resp")]
18    base_resp: Option<MiniMaxBaseResp>,
19    #[serde(default, rename = "model_remains")]
20    model_remains: Vec<MiniMaxModelRemains>,
21}
22
23#[derive(Debug, serde::Deserialize)]
24struct MiniMaxBaseResp {
25    #[serde(default, rename = "status_code")]
26    status_code: i64,
27    #[serde(default, rename = "status_msg")]
28    status_msg: String,
29}
30
31#[derive(Debug, serde::Deserialize)]
32struct MiniMaxModelRemains {
33    #[serde(default, rename = "model_name")]
34    model_name: Option<String>,
35    #[serde(default, rename = "current_interval_remaining_percent")]
36    interval_remaining: Option<f64>,
37    #[serde(default, rename = "current_weekly_remaining_percent")]
38    weekly_remaining: Option<f64>,
39}
40
41/// `current_*_remaining_percent` is already 0–100 (per CodexBar), so used is
42/// simply `100 − remaining`.
43fn used_from_remaining(remaining: f64) -> u64 {
44    (100.0 - remaining).clamp(0.0, 100.0).round() as u64
45}
46
47/// MiniMax coding/token-plan quota provider (API-key auth).
48pub struct MiniMaxProvider {
49    metadata: ProviderMetadata,
50    base_url: Option<String>,
51}
52
53impl MiniMaxProvider {
54    pub fn new() -> Self {
55        Self {
56            metadata: ProviderMetadata {
57                id: "minimax",
58                name: "MiniMax",
59                description: "MiniMax coding/token-plan quota monitor",
60                auth_methods: &["api_key", "env"],
61                website: Some("https://www.minimax.io"),
62            },
63            base_url: None,
64        }
65    }
66
67    pub fn with_base_url(url: &str) -> Self {
68        let mut p = Self::new();
69        p.base_url = Some(url.to_string());
70        p
71    }
72
73    fn clean(raw: &str) -> String {
74        let mut v = raw.trim();
75        if v.len() >= 2
76            && ((v.starts_with('"') && v.ends_with('"'))
77                || (v.starts_with('\'') && v.ends_with('\'')))
78        {
79            v = &v[1..v.len() - 1];
80        }
81        v.trim().to_string()
82    }
83
84    /// Candidate remains endpoints (token-plan first, then coding-plan).
85    fn endpoints(&self, ctx: &ProviderContext) -> Vec<String> {
86        let base = self
87            .base_url
88            .clone()
89            .or_else(|| {
90                ctx.config
91                    .get("base_url")
92                    .map(|s| Self::clean(s))
93                    .filter(|s| !s.is_empty())
94            })
95            .unwrap_or_else(|| "https://api.minimax.io".to_string());
96        let base = base.trim_end_matches('/');
97        vec![
98            format!("{}/v1/token_plan/remains", base),
99            format!("{}/v1/api/openplatform/coding_plan/remains", base),
100        ]
101    }
102
103    fn resolve_key(ctx: &ProviderContext) -> Result<String, SpendPanelError> {
104        for key in ["api_key", "token"] {
105            if let Some(v) = ctx.config.get(key) {
106                let c = Self::clean(v);
107                if !c.is_empty() {
108                    return Ok(c);
109                }
110            }
111        }
112        for env in ["MINIMAX_CODING_API_KEY", "MINIMAX_API_KEY"] {
113            if let Ok(v) = std::env::var(env) {
114                let c = Self::clean(&v);
115                if !c.is_empty() {
116                    return Ok(c);
117                }
118            }
119        }
120        Err(SpendPanelError::AuthFailed(
121            "minimax".into(),
122            "no API key in api_key/token config, MINIMAX_CODING_API_KEY, or MINIMAX_API_KEY".into(),
123        ))
124    }
125
126    fn build_client(ctx: &ProviderContext) -> Result<reqwest::Client, SpendPanelError> {
127        reqwest::Client::builder()
128            .timeout(std::time::Duration::from_secs(ctx.timeout_secs))
129            .build()
130            .map_err(|e| SpendPanelError::NetworkError(e.to_string()))
131    }
132
133    fn parse(body: &str) -> Result<UsageSnapshot, SpendPanelError> {
134        let resp: MiniMaxResponse = serde_json::from_str(body)
135            .map_err(|e| SpendPanelError::ParseError("minimax".into(), e.to_string()))?;
136        let data = resp.data.ok_or_else(|| {
137            SpendPanelError::ParseError("minimax".into(), "missing data in response".into())
138        })?;
139
140        let base = data.base_resp.as_ref().or(resp.base_resp.as_ref());
141        if let Some(b) = base.filter(|b| b.status_code != 0) {
142            let lower = b.status_msg.to_lowercase();
143            if b.status_code == 1004 || lower.contains("login") || lower.contains("cookie") {
144                return Err(SpendPanelError::AuthFailed(
145                    "minimax".into(),
146                    b.status_msg.clone(),
147                ));
148            }
149            return Err(SpendPanelError::ProviderError(
150                "minimax".into(),
151                b.status_msg.clone(),
152            ));
153        }
154
155        if data.model_remains.is_empty() {
156            return Err(SpendPanelError::ParseError(
157                "minimax".into(),
158                "no model_remains in response".into(),
159            ));
160        }
161
162        // Headline: the most-consumed model per window.
163        let interval = data
164            .model_remains
165            .iter()
166            .filter_map(|m| m.interval_remaining)
167            .min_by(|a, b| a.total_cmp(b));
168        let weekly = data
169            .model_remains
170            .iter()
171            .filter_map(|m| m.weekly_remaining)
172            .min_by(|a, b| a.total_cmp(b));
173
174        let mut snapshot = UsageSnapshot::new("minimax");
175        if let Some(r) = interval {
176            snapshot.primary_rate_window =
177                Some(RateWindow::new(used_from_remaining(r), 100, "Interval", 0));
178        }
179        if let Some(r) = weekly {
180            snapshot.secondary_rate_window = Some(RateWindow::new(
181                used_from_remaining(r),
182                100,
183                "Weekly",
184                7 * 24 * 60,
185            ));
186        }
187        if snapshot.primary_rate_window.is_none() && snapshot.secondary_rate_window.is_none() {
188            return Err(SpendPanelError::ParseError(
189                "minimax".into(),
190                "no interval/weekly remaining percentages in response".into(),
191            ));
192        }
193
194        if let Some(name) = data.model_remains.iter().find_map(|m| m.model_name.clone()) {
195            snapshot.plan = Some(PlanInfo {
196                name,
197                tier: None,
198                features: Vec::new(),
199                price: None,
200                currency: None,
201                billing_period: None,
202            });
203        }
204        Ok(snapshot)
205    }
206}
207
208impl Default for MiniMaxProvider {
209    fn default() -> Self {
210        Self::new()
211    }
212}
213
214#[async_trait]
215impl UsageProvider for MiniMaxProvider {
216    fn metadata(&self) -> &ProviderMetadata {
217        &self.metadata
218    }
219
220    fn detect_credentials(&self) -> bool {
221        ["MINIMAX_CODING_API_KEY", "MINIMAX_API_KEY"]
222            .iter()
223            .any(|e| {
224                std::env::var(e)
225                    .map(|v| !v.trim().is_empty())
226                    .unwrap_or(false)
227            })
228    }
229
230    async fn fetch_usage(&self, ctx: &ProviderContext) -> Result<UsageSnapshot, SpendPanelError> {
231        let key = Self::resolve_key(ctx)?;
232        let client = Self::build_client(ctx)?;
233        let mut last_err = None;
234        for url in self.endpoints(ctx) {
235            let resp = client
236                .get(&url)
237                .header("Authorization", format!("Bearer {}", key))
238                .header("Accept", "application/json")
239                .header("MM-API-Source", "UsageMonitor")
240                .send()
241                .await
242                .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
243            let status = resp.status();
244            let body = resp
245                .text()
246                .await
247                .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
248            if status == reqwest::StatusCode::UNAUTHORIZED
249                || status == reqwest::StatusCode::FORBIDDEN
250            {
251                return Err(SpendPanelError::AuthFailed(
252                    "minimax".into(),
253                    format!("invalid API key (HTTP {})", status.as_u16()),
254                ));
255            }
256            if !status.is_success() {
257                last_err = Some(SpendPanelError::ProviderError(
258                    "minimax".into(),
259                    format!("HTTP {}: {}", status, body),
260                ));
261                continue;
262            }
263            match Self::parse(&body) {
264                Ok(snap) => return Ok(snap),
265                Err(e) => last_err = Some(e),
266            }
267        }
268        Err(last_err.unwrap_or_else(|| {
269            SpendPanelError::ProviderError("minimax".into(), "no remains endpoint succeeded".into())
270        }))
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use pretty_assertions::assert_eq;
278    use wiremock::matchers::{method, path};
279    use wiremock::{Mock, MockServer, ResponseTemplate};
280
281    const SAMPLE: &str = r#"{
282      "data": {
283        "base_resp": {"status_code": 0, "status_msg": "success"},
284        "model_remains": [
285          {"model_name": "MiniMax-M2", "current_interval_remaining_percent": 25, "current_weekly_remaining_percent": 80}
286        ]
287      }
288    }"#;
289
290    #[test]
291    fn test_metadata() {
292        assert_eq!(MiniMaxProvider::new().metadata().id, "minimax");
293    }
294
295    #[test]
296    fn test_parse_remaining_to_used() {
297        let snap = MiniMaxProvider::parse(SAMPLE).unwrap();
298        // 25% remaining → 75% used
299        assert_eq!(snap.primary_rate_window.unwrap().used, Some(75));
300        // 80% remaining → 20% used
301        assert_eq!(snap.secondary_rate_window.unwrap().used, Some(20));
302        assert_eq!(snap.plan.unwrap().name, "MiniMax-M2");
303    }
304
305    #[test]
306    fn test_remaining_percent_not_rescaled() {
307        // A remaining of 1(%) must read as 99% used, not 0% (regression: the
308        // value is already a percent, not a 0–1 fraction).
309        let body = r#"{"data":{"model_remains":[
310          {"model_name":"M","current_interval_remaining_percent":1,"current_weekly_remaining_percent":99}
311        ]}}"#;
312        let snap = MiniMaxProvider::parse(body).unwrap();
313        assert_eq!(snap.primary_rate_window.unwrap().used, Some(99));
314        assert_eq!(snap.secondary_rate_window.unwrap().used, Some(1));
315    }
316
317    #[test]
318    fn test_lowest_remaining_across_models() {
319        // Two models: primary window uses the most-consumed (lowest remaining).
320        let body = r#"{"data":{"model_remains":[
321          {"model_name":"A","current_interval_remaining_percent":60},
322          {"model_name":"B","current_interval_remaining_percent":10}
323        ]}}"#;
324        let snap = MiniMaxProvider::parse(body).unwrap();
325        assert_eq!(snap.primary_rate_window.unwrap().used, Some(90));
326    }
327
328    #[test]
329    fn test_base_resp_login_error() {
330        let body = r#"{"data":{"base_resp":{"status_code":1004,"status_msg":"please login"},"model_remains":[]}}"#;
331        assert!(matches!(
332            MiniMaxProvider::parse(body).unwrap_err(),
333            SpendPanelError::AuthFailed(_, _)
334        ));
335    }
336
337    #[tokio::test]
338    async fn test_fetch_success() {
339        let server = MockServer::start().await;
340        Mock::given(method("GET"))
341            .and(path("/v1/token_plan/remains"))
342            .respond_with(ResponseTemplate::new(200).set_body_raw(SAMPLE, "application/json"))
343            .mount(&server)
344            .await;
345        let provider = MiniMaxProvider::with_base_url(&server.uri());
346        let mut ctx = ProviderContext::new();
347        ctx.config.insert("api_key".into(), "mm".into());
348        let snap = provider.fetch_usage(&ctx).await.unwrap();
349        assert_eq!(snap.primary_rate_window.unwrap().used, Some(75));
350    }
351
352    #[tokio::test]
353    async fn test_fetch_401() {
354        let server = MockServer::start().await;
355        Mock::given(method("GET"))
356            .and(path("/v1/token_plan/remains"))
357            .respond_with(ResponseTemplate::new(401))
358            .mount(&server)
359            .await;
360        let provider = MiniMaxProvider::with_base_url(&server.uri());
361        let mut ctx = ProviderContext::new();
362        ctx.config.insert("api_key".into(), "bad".into());
363        assert!(matches!(
364            provider.fetch_usage(&ctx).await.unwrap_err(),
365            SpendPanelError::AuthFailed(_, _)
366        ));
367    }
368}