quotas 0.11.0

CLI that auto-detects AI provider credentials (Claude, Codex, MiniMax, Kimi, Z.ai, Grok/xAI, …) and shows your usage quotas in a TUI or JSON.
Documentation
use crate::cache::CacheFile;
use crate::providers::{ProviderKind, ProviderResult, ProviderStatus};
use serde::Serialize;

#[derive(Serialize)]
pub struct JsonOutput {
    pub timestamp: chrono::DateTime<chrono::Utc>,
    pub providers: Vec<ProviderJson>,
}

#[derive(Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ProviderStatusJson {
    Available {
        plan_name: String,
        windows: Vec<WindowJson>,
        unlimited: bool,
    },
    Unavailable {
        reason: String,
        console_url: Option<String>,
    },
    AuthRequired,
    NetworkError {
        message: String,
    },
}

#[derive(Serialize)]
pub struct WindowJson {
    pub window_type: String,
    pub used: i64,
    pub limit: i64,
    pub remaining: i64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reset_at: Option<chrono::DateTime<chrono::Utc>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub period_seconds: Option<i64>,
}

#[derive(Serialize)]
pub struct ProviderJson {
    pub name: String,
    /// Stable instance id (`grok` or `grok:grok-1`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub profile_id: Option<String>,
    pub status: ProviderStatusJson,
    pub fetched_at: chrono::DateTime<chrono::Utc>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cached_at: Option<chrono::DateTime<chrono::Utc>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub raw_response: Option<serde_json::Value>,
}

impl JsonOutput {
    pub fn from_results(results: Vec<ProviderResult>, cache: &CacheFile) -> Self {
        let providers = results
            .into_iter()
            .map(|r| {
                let key = r.cache_key();
                let cached_at = cache
                    .entries
                    .get(&key)
                    .or_else(|| cache.entries.get(r.kind.slug()))
                    .map(|e| e.cached_at);
                ProviderJson {
                    name: r.title().to_lowercase(),
                    id: Some(key),
                    profile_id: r.profile_id.clone(),
                    status: match r.status {
                        ProviderStatus::Available { quota } => ProviderStatusJson::Available {
                            plan_name: quota.plan_name,
                            windows: quota
                                .windows
                                .into_iter()
                                .map(|w| WindowJson {
                                    window_type: w.window_type,
                                    used: w.used,
                                    limit: w.limit,
                                    remaining: w.remaining,
                                    reset_at: w.reset_at,
                                    period_seconds: w.period_seconds,
                                })
                                .collect(),
                            unlimited: quota.unlimited,
                        },
                        ProviderStatus::Unavailable { info } => ProviderStatusJson::Unavailable {
                            reason: info.reason,
                            console_url: info.console_url,
                        },
                        ProviderStatus::AuthRequired => ProviderStatusJson::AuthRequired,
                        ProviderStatus::NetworkError { message } => {
                            ProviderStatusJson::NetworkError { message }
                        }
                    },
                    fetched_at: r.fetched_at,
                    cached_at,
                    raw_response: r.raw_response,
                }
            })
            .collect();

        Self {
            timestamp: chrono::Utc::now(),
            providers,
        }
    }

    pub fn to_json(&self, pretty: bool) -> String {
        if pretty {
            serde_json::to_string_pretty(self).unwrap_or_default()
        } else {
            serde_json::to_string(self).unwrap_or_default()
        }
    }
}

pub fn filter_results(results: Vec<ProviderResult>, kinds: &[ProviderKind]) -> Vec<ProviderResult> {
    if kinds.is_empty() {
        return results;
    }
    results
        .into_iter()
        .filter(|r| kinds.contains(&r.kind))
        .collect()
}