quotas 0.3.0

CLI that auto-detects AI provider credentials (Claude, Codex, MiniMax, Kimi, Z.ai) and shows your usage quotas in a TUI or JSON.
Documentation
use crate::auth::{AuthCredential, AuthResolver, MultiResolver};
use crate::providers::{ProviderKind, ProviderQuota, ProviderResult, ProviderStatus, QuotaWindow};
use crate::{Error, Result};
use async_trait::async_trait;
use chrono::{TimeZone, Utc};
use reqwest::Client;
use serde::Deserialize;

pub struct MinimaxProvider {
    http: Client,
    auth: Box<dyn AuthResolver>,
}

impl MinimaxProvider {
    pub fn new(auth: Box<dyn AuthResolver>) -> Self {
        Self {
            http: Client::new(),
            auth,
        }
    }

    pub fn with_multi_resolver(auth: MultiResolver) -> Self {
        Self {
            http: Client::new(),
            auth: Box::new(auth),
        }
    }

    async fn fetch_intl(&self, key: &str) -> Result<ProviderResult> {
        let url = "https://api.minimax.io/v1/api/openplatform/coding_plan/remains";
        let resp = self
            .http
            .get(url)
            .header("Authorization", format!("Bearer {}", key))
            .header("Content-Type", "application/json")
            .send()
            .await?;

        let status = resp.status();
        let body: serde_json::Value = resp.json().await?;

        if status.as_u16() == 200 {
            let code = body.get("base_resp").and_then(|b| b.get("status_code"));
            if code == Some(&serde_json::json!(0)) || code.is_none() {
                let quota = self.parse_response(&body)?;
                return Ok(ProviderResult {
                    kind: ProviderKind::Minimax,
                    status: ProviderStatus::Available { quota },
                    fetched_at: Utc::now(),
                    raw_response: Some(body),
                });
            }
        }

        let msg = body
            .get("base_resp")
            .and_then(|b| b.get("status_msg"))
            .and_then(|m| m.as_str())
            .unwrap_or("unknown error");

        Ok(ProviderResult {
            kind: ProviderKind::Minimax,
            status: ProviderStatus::Unavailable {
                info: crate::providers::UnavailableInfo {
                    reason: msg.to_string(),
                    console_url: Some(
                        "https://platform.minimax.io/user-center/payment/coding-plan".into(),
                    ),
                },
            },
            fetched_at: Utc::now(),
            raw_response: Some(body),
        })
    }

    fn parse_response(&self, body: &serde_json::Value) -> Result<ProviderQuota> {
        parse_response(body)
    }
}

pub(crate) fn parse_response(body: &serde_json::Value) -> Result<ProviderQuota> {
    #[derive(Deserialize)]
    #[allow(dead_code)]
    struct ModelRemain {
        model_name: String,
        start_time: i64,
        end_time: i64,
        #[serde(default)]
        remains_time: i64,
        #[serde(rename = "current_interval_total_count")]
        total_count: i64,
        #[serde(rename = "current_interval_usage_count")]
        usage_count: i64,
    }

    #[derive(Deserialize)]
    struct Response {
        model_remains: Vec<ModelRemain>,
    }

    let resp: Response = serde_json::from_value(body.clone())
        .map_err(|e| Error::Provider(format!("parse error: {}", e)))?;

    let windows: Vec<QuotaWindow> = resp
        .model_remains
        .iter()
        .map(|m| {
            let remaining = m.usage_count;
            let used = m.total_count - m.usage_count;
            QuotaWindow {
                window_type: "5h".to_string(),
                used,
                limit: m.total_count,
                remaining,
                reset_at: Utc.timestamp_millis_opt(m.end_time).single(),
            }
        })
        .collect();

    let plan_name = resp
        .model_remains
        .first()
        .map(|m| m.model_name.clone())
        .unwrap_or_else(|| "Token Plan".to_string());

    Ok(ProviderQuota {
        plan_name,
        windows,
        unlimited: false,
    })
}

#[async_trait]
impl crate::providers::Provider for MinimaxProvider {
    fn kind(&self) -> ProviderKind {
        ProviderKind::Minimax
    }

    async fn fetch(&self) -> Result<ProviderResult> {
        let auth = self.auth.resolve().await?;
        let key = match &auth.credential {
            AuthCredential::Bearer(k) => k.clone(),
            AuthCredential::Token(t) => t.clone(),
        };

        match self.fetch_intl(&key).await {
            Ok(mut r) => {
                r.raw_response = Some(r.raw_response.unwrap_or_default());
                Ok(r)
            }
            Err(e) => Ok(ProviderResult {
                kind: ProviderKind::Minimax,
                status: ProviderStatus::NetworkError {
                    message: e.to_string(),
                },
                fetched_at: Utc::now(),
                raw_response: None,
            }),
        }
    }

    fn auth_resolver(&self) -> &dyn AuthResolver {
        &*self.auth
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_minimax_remains_payload() {
        let body = serde_json::json!({
            "base_resp": {"status_code": 0, "status_msg": ""},
            "model_remains": [{
                "model_name": "MiniMax-M2.7",
                "start_time": 1712937600000i64,
                "end_time": 1712955600000i64,
                "remains_time": 14400000i64,
                "current_interval_total_count": 200,
                "current_interval_usage_count": 187
            }]
        });
        let quota = parse_response(&body).unwrap();
        assert_eq!(quota.plan_name, "MiniMax-M2.7");
        assert_eq!(quota.windows.len(), 1);
        let w = &quota.windows[0];
        assert_eq!(w.window_type, "5h");
        assert_eq!(w.limit, 200);
        assert_eq!(w.remaining, 187);
        assert_eq!(w.used, 13);
        assert!(w.reset_at.is_some());
    }
}