usage_monitor_cli/provider/
minimax.rs1use 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
41fn used_from_remaining(remaining: f64) -> u64 {
44 (100.0 - remaining).clamp(0.0, 100.0).round() as u64
45}
46
47pub 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 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 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 assert_eq!(snap.primary_rate_window.unwrap().used, Some(75));
300 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 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 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}