1use serde::Deserialize;
24
25use crate::error::{AppError, Result};
26use crate::usage::{MinimaxSnapshot, UsageWindow};
27
28const BUCKET_GENERAL: &str = "general";
30const BUCKET_VIDEO: &str = "video";
32
33const DEFAULT_INTERVAL: chrono::Duration = chrono::Duration::hours(5);
36const DEFAULT_WEEKLY: chrono::Duration = chrono::Duration::days(7);
37
38#[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 #[serde(default)]
51 pub model_remains: Vec<ModelRemains>,
52 pub base_resp: BaseResp,
53}
54
55impl RemainsEnvelope {
56 pub fn check_ok(&self) -> Result<()> {
60 if self.base_resp.status_code == 0 {
61 return Ok(());
62 }
63 Err(AppError::Schema(format!(
67 "minimax: API reported failure (status_code {})",
68 self.base_resp.status_code
69 )))
70 }
71}
72
73pub fn is_auth_failure(status_code: i64) -> bool {
79 matches!(status_code, 1004 | 2049)
80}
81
82#[derive(Debug, Clone, Deserialize)]
86pub struct ModelRemains {
87 pub model_name: String,
88 pub start_time: i64,
90 pub end_time: i64,
91 pub current_interval_remaining_percent: i64,
93 pub weekly_start_time: i64,
95 pub weekly_end_time: i64,
96 pub current_weekly_remaining_percent: i64,
98}
99
100fn consumed_pct(remaining: i64) -> i32 {
104 (100 - remaining.clamp(0, 100)) as i32
105}
106
107fn 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
116fn 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
144pub 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
344 fn clamps_out_of_range_percentages() {
345 assert_eq!(consumed_pct(150), 0);
346 assert_eq!(consumed_pct(-5), 100);
347 }
348}