diff --git a/README.md b/README.md index 12b2fb5..63d4e11 100644 --- a/README.md +++ b/README.md @@ -58,11 +58,13 @@ OpenQuota checks for updates automatically. Installable updates are cryptographi - **[OpenRouter](docs/providers/openrouter.md)** — credit balance and daily, weekly and monthly spend (API key) - **[Z.ai](docs/providers/zai.md)** — GLM Coding Plan session, weekly, and web-search quotas (API key) +- **[Kimi](docs/providers/kimi.md)** — Kimi Code session and weekly quotas (API key) +- **[MiniMax](docs/providers/minimax.md)** — Token Plan session and weekly quotas (API key) -Most providers use credentials already available on your computer. OpenRouter and Z.ai require API -keys, which you can add in Customize; OpenQuota stores them securely in your operating system's -credential store. Codex subscription limits require a ChatGPT login and are not available in -API-key-only sessions. +Most providers use credentials already available on your computer. OpenRouter, Z.ai, Kimi, and +MiniMax require API keys, which you can add in Customize; OpenQuota stores them securely in your +operating system's credential store. Codex subscription limits require a ChatGPT login and are not +available in API-key-only sessions. ## Features diff --git a/docs/providers/kimi.md b/docs/providers/kimi.md new file mode 100644 index 0000000..c03f9c1 --- /dev/null +++ b/docs/providers/kimi.md @@ -0,0 +1,26 @@ +# Kimi + +OpenQuota tracks the Session (rolling five-hour) and Weekly quotas of a Kimi Code membership. + +## What it tracks + +| Metric | Meaning | +| ------- | -------------------------------------------- | +| Session | Usage remaining in the rolling 5-hour window | +| Weekly | Usage remaining in the rolling 7-day window | + +## Setup + +Create a Kimi Code API key in the [Kimi Code Console](https://www.kimi.com/code/console), then add +it in **Customize** in OpenQuota. Saved keys are stored in the operating system's credential store. +OpenQuota also checks `KIMI_API_KEY` and `~/.config/openquota/kimi.json`; a key saved in the app +takes priority. + +This provider uses the Kimi Code endpoint, `https://api.kimi.com/coding/v1`. Kimi Code keys are not +interchangeable with Kimi Open Platform keys. + +## Troubleshooting + +- **Add an API key** — add a Kimi Code key in Customize or provide it through a supported external source. +- **API key invalid** — create or verify the key in the [Kimi Code Console](https://www.kimi.com/code/console). +- **Usage unavailable** — check the connection and refresh again. diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md new file mode 100644 index 0000000..3800e9e --- /dev/null +++ b/docs/providers/minimax.md @@ -0,0 +1,28 @@ +# MiniMax + +OpenQuota tracks the Session and Weekly quotas of a MiniMax Token Plan. + +## What it tracks + +| Metric | Meaning | +| ------- | -------------------------------------------- | +| Session | Usage remaining in the rolling 5-hour window | +| Weekly | Usage remaining in the rolling 7-day window | + +## Setup + +Create or view the Token Plan subscription key in the [MiniMax global console](https://platform.minimax.io/console/plan), +then add it in **Customize** in OpenQuota. Saved keys are stored in the operating system's credential +store. OpenQuota also checks `MINIMAX_API_KEY` and `~/.config/openquota/minimax.json`; a key saved +in the app takes priority. + +This provider uses MiniMax's global endpoint, `https://www.minimax.io/v1/token_plan/remains`. Use a +key from the global console; keys from the mainland China platform are a separate account system. + +## Troubleshooting + +- **Add an API key** — add a MiniMax Token Plan key in Customize or provide it through a supported + external source. +- **No active token plan** — subscribe to a Token Plan in the + [MiniMax global console](https://platform.minimax.io/console/plan). +- **Usage unavailable** — check the connection and refresh again. diff --git a/scripts/verify/verify-provider-registry-contract.js b/scripts/verify/verify-provider-registry-contract.js index a2516ba..9c47d51 100644 --- a/scripts/verify/verify-provider-registry-contract.js +++ b/scripts/verify/verify-provider-registry-contract.js @@ -22,7 +22,7 @@ const frontendConsumers = [ 'src/lib/shareCard.ts', ]; const providerLiteral = - /["'](?:claude|codex|cursor|antigravity|copilot|devin|grok|opencode|openrouter|zai)["']/; + /["'](?:claude|codex|cursor|antigravity|copilot|devin|grok|opencode|openrouter|zai|kimi|minimax)["']/; for (const file of rustConsumers) { const source = fs.readFileSync(new URL(file, root), 'utf8').split('#[cfg(test)]')[0]; @@ -90,6 +90,8 @@ const expectedRuntimeOrder = [ 'OpenCodeProvider', 'OpenRouterProvider', 'ZaiProvider', + 'KimiProvider', + 'MiniMaxProvider', ]; if (runtimeOrder.join(',') !== expectedRuntimeOrder.join(',')) { throw new Error( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c1ced18..027d7e6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -48,9 +48,9 @@ use crate::{ providers::{ antigravity::AntigravityProvider, claude, codex::reset_claim::CodexResetClaimService, codex::CodexProvider, copilot::CopilotProvider, cursor::CursorProvider, - detect_local_credentials, devin::DevinProvider, grok::GrokProvider, - opencode::OpenCodeProvider, openrouter::OpenRouterProvider, zai::ZaiProvider, - ProviderRegistry, UsageProvider, + detect_local_credentials, devin::DevinProvider, grok::GrokProvider, kimi::KimiProvider, + minimax::MiniMaxProvider, opencode::OpenCodeProvider, openrouter::OpenRouterProvider, + zai::ZaiProvider, ProviderRegistry, UsageProvider, }, storage::Storage, window::{ @@ -240,6 +240,8 @@ pub fn run() { Arc::new(OpenCodeProvider::new(pricing.clone())) as Arc, Arc::new(OpenRouterProvider::new()?) as Arc, Arc::new(ZaiProvider::new()?) as Arc, + Arc::new(KimiProvider::new()?) as Arc, + Arc::new(MiniMaxProvider::new()?) as Arc, ]); let registry = Arc::new(ProviderRegistry::new(providers)?); let (settings_service, credential_detection_plan) = diff --git a/src-tauri/src/menu_bar.rs b/src-tauri/src/menu_bar.rs index f31fc79..c17ed8b 100644 --- a/src-tauri/src/menu_bar.rs +++ b/src-tauri/src/menu_bar.rs @@ -32,6 +32,8 @@ const GROK_ICON: &str = include_str!("../../src/assets/provider-icons/grok.svg") const OPENCODE_ICON: &str = include_str!("../../src/assets/provider-icons/opencode.svg"); const OPENROUTER_ICON: &str = include_str!("../../src/assets/provider-icons/openrouter.svg"); const ZAI_ICON: &str = include_str!("../../src/assets/provider-icons/zai.svg"); +const KIMI_ICON: &str = include_str!("../../src/assets/provider-icons/kimi.svg"); +const MINIMAX_ICON: &str = include_str!("../../src/assets/provider-icons/minimax.svg"); #[derive(Debug, Clone, PartialEq, Eq)] pub struct TextGroup { @@ -277,6 +279,8 @@ fn provider_path(provider_id: &str) -> Option<&'static Path> { static OPENCODE: OnceLock = OnceLock::new(); static OPENROUTER: OnceLock = OnceLock::new(); static ZAI: OnceLock = OnceLock::new(); + static KIMI: OnceLock = OnceLock::new(); + static MINIMAX: OnceLock = OnceLock::new(); match crate::providers::provider_family(provider_id) { "claude" => Some(parsed(CLAUDE_ICON, &CLAUDE)), "codex" => Some(parsed(CODEX_ICON, &CODEX)), @@ -288,6 +292,8 @@ fn provider_path(provider_id: &str) -> Option<&'static Path> { "opencode" => Some(parsed(OPENCODE_ICON, &OPENCODE)), "openrouter" => Some(parsed(OPENROUTER_ICON, &OPENROUTER)), "zai" => Some(parsed(ZAI_ICON, &ZAI)), + "kimi" => Some(parsed(KIMI_ICON, &KIMI)), + "minimax" => Some(parsed(MINIMAX_ICON, &MINIMAX)), _ => None, } } @@ -591,6 +597,8 @@ mod tests { "opencode", "openrouter", "zai", + "kimi", + "minimax", ] { let path = provider_path(provider).expect("known provider mark should exist"); assert!(path.bounds().width() > 0.0); diff --git a/src-tauri/src/providers/kimi/auth.rs b/src-tauri/src/providers/kimi/auth.rs new file mode 100644 index 0000000..e353b16 --- /dev/null +++ b/src-tauri/src/providers/kimi/auth.rs @@ -0,0 +1,65 @@ +use crate::{ + models::ApiKeyStatus, + providers::api_key::{ApiKeyStore, SecretString}, +}; + +use super::KimiError; + +const CONFIG_PATHS: &[&str] = &["~/.config/openquota/kimi.json"]; +const ENVIRONMENT_NAMES: &[&str] = &["KIMI_API_KEY"]; + +#[derive(Clone)] +pub struct KimiAuthStore { + store: ApiKeyStore, +} + +impl KimiAuthStore { + pub fn new() -> Self { + Self { + store: ApiKeyStore::new_with_sources("kimi", ENVIRONMENT_NAMES, CONFIG_PATHS), + } + } + + #[cfg(test)] + pub(super) fn with_store(store: ApiKeyStore) -> Self { + Self { store } + } + + pub fn load(&self) -> Result, KimiError> { + self.store.load().map_err(|_| KimiError::CredentialStorage) + } + + pub fn has_local_credentials(&self) -> bool { + self.load().is_ok_and(|secret| secret.is_some()) + } + + pub fn status(&self) -> Result { + self.store + .status() + .map_err(|_| KimiError::CredentialStorage) + } + + pub fn save(&self, value: &str) -> Result<(), KimiError> { + self.store.save(value).map_err(|_| { + if value.trim().is_empty() { + KimiError::MissingKey + } else { + crate::app_warn!("auth:kimi", "system credential store write failed"); + KimiError::CredentialStorage + } + }) + } + + pub fn delete(&self) -> Result<(), KimiError> { + self.store.delete().map_err(|_| { + crate::app_warn!("auth:kimi", "system credential store delete failed"); + KimiError::CredentialStorage + }) + } +} + +impl Default for KimiAuthStore { + fn default() -> Self { + Self::new() + } +} diff --git a/src-tauri/src/providers/kimi/client.rs b/src-tauri/src/providers/kimi/client.rs new file mode 100644 index 0000000..96a7434 --- /dev/null +++ b/src-tauri/src/providers/kimi/client.rs @@ -0,0 +1,69 @@ +use std::time::Duration; + +use reqwest::{blocking::Client, StatusCode}; +use serde_json::Value; + +use super::KimiError; + +const USAGES_URL: &str = "https://api.kimi.com/coding/v1/usages"; + +#[derive(Debug)] +pub struct EndpointResponse { + pub status: StatusCode, + pub body: Value, +} + +pub struct KimiClient { + client: Client, + url: String, +} + +impl KimiClient { + pub fn new() -> Result { + Self::with_endpoint(USAGES_URL, Duration::from_secs(15)) + } + + fn with_endpoint(url: &str, timeout: Duration) -> Result { + let client = Client::builder() + .connect_timeout(Duration::from_secs(8)) + .timeout(timeout) + .user_agent(concat!("OpenQuota/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|_| KimiError::ConnectionFailed)?; + Ok(Self { + client, + url: url.to_owned(), + }) + } + + pub fn fetch(&self, api_key: &str) -> Result { + let started = std::time::Instant::now(); + let response = self + .client + .get(&self.url) + .bearer_auth(api_key) + .header("Accept", "application/json") + .send() + .map_err(|_| { + crate::app_warn!("http", "kimi usages request failed (transport)"); + KimiError::ConnectionFailed + })?; + let status = response.status(); + crate::app_debug!( + "http", + "kimi usages HTTP {} ({}ms)", + status.as_u16(), + started.elapsed().as_millis() + ); + let text = response.text().map_err(|_| KimiError::InvalidResponse)?; + let body = serde_json::from_str(&text).unwrap_or(Value::Null); + Ok(EndpointResponse { status, body }) + } +} + +#[cfg(test)] +impl KimiClient { + pub fn for_test(url: &str, timeout: Duration) -> Self { + Self::with_endpoint(url, timeout).unwrap() + } +} diff --git a/src-tauri/src/providers/kimi/mapper.rs b/src-tauri/src/providers/kimi/mapper.rs new file mode 100644 index 0000000..82553b1 --- /dev/null +++ b/src-tauri/src/providers/kimi/mapper.rs @@ -0,0 +1,316 @@ +use chrono::{DateTime, Utc}; +use serde_json::Value; + +use crate::models::{QuotaFormat, QuotaWindow}; + +use super::KimiError; + +const WEEKLY_PERIOD_SECONDS: u64 = 7 * 24 * 60 * 60; +const DEFAULT_WINDOW_PERIOD_SECONDS: u64 = 5 * 60 * 60; + +#[derive(Debug, PartialEq)] +pub struct KimiMappedUsage { + pub plan: Option, + pub quotas: Vec, +} + +pub fn map_usage(body: &Value) -> Result { + Ok(KimiMappedUsage { + plan: plan_name(body), + quotas: map_quotas(body)?, + }) +} + +fn plan_name(body: &Value) -> Option { + let raw = body + .get("user")? + .get("membership")? + .get("level")? + .as_str()?; + let stripped = raw.trim().strip_prefix("LEVEL_").unwrap_or(raw).trim(); + if stripped.is_empty() { + None + } else { + Some(title_case(stripped)) + } +} + +fn title_case(value: &str) -> String { + let mut out = String::new(); + let mut new_word = true; + for ch in value.chars() { + if ch == '_' { + new_word = true; + out.push(' '); + continue; + } + if new_word { + out.extend(ch.to_uppercase()); + new_word = false; + } else { + out.extend(ch.to_lowercase()); + } + } + out +} + +fn map_quotas(body: &Value) -> Result, KimiError> { + // Convention: the short rolling window is the Session quota and the main usage quota is the + // Weekly quota. Session is shown first. + let mut quotas = Vec::new(); + if let Ok(Some(session)) = session_quota(body) { + quotas.push(session); + } + if let Ok(weekly) = weekly_quota(body) { + quotas.push(weekly); + } + (!quotas.is_empty()) + .then_some(quotas) + .ok_or(KimiError::InvalidResponse) +} + +fn weekly_quota(body: &Value) -> Result { + let usage = body + .get("usage") + .and_then(Value::as_object) + .ok_or(KimiError::InvalidResponse)?; + let limit = number(usage.get("limit")) + .filter(|value| *value >= 0.0) + .ok_or(KimiError::InvalidResponse)?; + let used = number(usage.get("used")) + .filter(|value| *value >= 0.0) + .ok_or(KimiError::InvalidResponse)?; + let used_percent = if limit > 0.0 { + (used / limit * 100.0).clamp(0.0, 100.0) + } else { + 0.0 + }; + Ok(QuotaWindow { + id: "weekly".into(), + label: "Weekly".into(), + used_percent, + resets_at: iso_time(usage.get("resetTime")), + period_seconds: WEEKLY_PERIOD_SECONDS, + format: QuotaFormat::Percent, + used_value: None, + limit_value: None, + unit: None, + estimated: false, + source_note: None, + }) +} + +fn session_quota(body: &Value) -> Result, KimiError> { + let Some(entry) = body + .get("limits") + .and_then(Value::as_array) + .and_then(|limits| { + limits + .iter() + .find(|entry| window_period_seconds(entry) == Some(DEFAULT_WINDOW_PERIOD_SECONDS)) + }) + else { + return Ok(None); + }; + let detail = entry + .get("detail") + .and_then(Value::as_object) + .ok_or(KimiError::InvalidResponse)?; + let limit = number(detail.get("limit")) + .filter(|value| *value >= 0.0) + .ok_or(KimiError::InvalidResponse)?; + let remaining = number(detail.get("remaining")) + .filter(|value| *value >= 0.0) + .ok_or(KimiError::InvalidResponse)?; + let used = (limit - remaining).max(0.0); + let used_percent = if limit > 0.0 { + (used / limit * 100.0).clamp(0.0, 100.0) + } else { + 0.0 + }; + let period_seconds = window_period_seconds(entry).unwrap_or(DEFAULT_WINDOW_PERIOD_SECONDS); + + Ok(Some(QuotaWindow { + id: "session".into(), + label: "Session".into(), + used_percent, + resets_at: iso_time(detail.get("resetTime")), + period_seconds, + format: QuotaFormat::Percent, + used_value: None, + limit_value: None, + unit: None, + estimated: false, + source_note: None, + })) +} + +fn window_period_seconds(entry: &Value) -> Option { + let window = entry.get("window")?; + let duration = number(window.get("duration")).filter(|value| *value > 0.0)?; + let factor = match window.get("timeUnit").and_then(Value::as_str) { + Some("TIME_UNIT_HOUR") => 3600.0, + Some("TIME_UNIT_SECOND") => 1.0, + Some("TIME_UNIT_MINUTE") => 60.0, + _ => return None, + }; + Some((duration * factor) as u64) +} + +fn number(value: Option<&Value>) -> Option { + value + .and_then(|value| { + value + .as_f64() + .or_else(|| value.as_str().and_then(|text| text.trim().parse().ok())) + }) + .filter(|value| value.is_finite()) +} + +fn iso_time(value: Option<&Value>) -> Option> { + let text = value?.as_str()?; + DateTime::parse_from_rfc3339(text) + .ok() + .map(|datetime| datetime.with_timezone(&Utc)) +} + +#[cfg(test)] +mod tests { + use chrono::{TimeZone, Utc}; + use serde_json::{json, Value}; + + use super::{map_usage, plan_name, DEFAULT_WINDOW_PERIOD_SECONDS}; + use crate::models::QuotaFormat; + + fn captured() -> Value { + serde_json::json!({ + "user": {"membership":{"level":"LEVEL_BASIC"}}, + "usage": {"limit":"100","used":"25","resetTime":"2026-08-10T02:17:43.139020Z"}, + "limits": [{"window":{"duration":300,"timeUnit":"TIME_UNIT_MINUTE"}, + "detail":{"limit":"100","remaining":"80", + "resetTime":"2026-08-07T06:17:43.139020Z"}}], + "parallel": {"limit":"10"} + }) + } + + #[test] + fn captured_payload_maps_usage_window_and_plan() { + let mapped = map_usage(&captured()).unwrap(); + + assert_eq!(mapped.plan.as_deref(), Some("Basic")); + assert_eq!( + mapped + .quotas + .iter() + .map(|quota| quota.id.as_str()) + .collect::>(), + ["session", "weekly"] + ); + + let session = &mapped.quotas[0]; + assert_eq!(session.used_percent, 20.0); + assert_eq!(session.period_seconds, 5 * 60 * 60); + + let weekly = &mapped.quotas[1]; + assert_eq!(weekly.used_percent, 25.0); + assert_eq!(weekly.format, QuotaFormat::Percent); + assert_eq!(weekly.used_value, None); + assert_eq!(weekly.limit_value, None); + assert_eq!(weekly.unit, None); + assert_eq!(weekly.period_seconds, 7 * 24 * 60 * 60); + assert_eq!( + weekly.resets_at.map(|datetime| datetime.timestamp()), + Some( + Utc.with_ymd_and_hms(2026, 8, 10, 2, 17, 43) + .single() + .unwrap() + .timestamp() + ) + ); + } + + #[test] + fn missing_session_is_optional_but_weekly_is_required() { + let mapped = map_usage(&json!({ + "user": {"membership": {"level": "LEVEL_PRO"}}, + "usage": {"limit": "50", "used": "0", "resetTime": null} + })) + .unwrap(); + + assert_eq!(mapped.plan.as_deref(), Some("Pro")); + assert_eq!(mapped.quotas.len(), 1); + assert_eq!(mapped.quotas[0].id, "weekly"); + assert_eq!(mapped.quotas[0].used_percent, 0.0); + assert_eq!(mapped.quotas[0].resets_at, None); + + assert!(map_usage(&json!({"limits":[]})).is_err()); + } + + #[test] + fn session_quota_selects_the_five_hour_window_regardless_of_order() { + let mapped = map_usage(&json!({ + "user": {"membership": {"level": "LEVEL_PRO"}}, + "usage": {"limit": "100", "used": "0"}, + "limits": [ + {"window": {"duration": 1, "timeUnit": "TIME_UNIT_HOUR"}, + "detail": {"limit": "100", "remaining": "0"}}, + {"window": {"duration": 5, "timeUnit": "TIME_UNIT_HOUR"}, + "detail": {"limit": "100", "remaining": "75"}} + ] + })) + .unwrap(); + let session = mapped + .quotas + .iter() + .find(|quota| quota.id == "session") + .unwrap(); + assert_eq!(session.used_percent, 25.0); + assert_eq!(session.period_seconds, DEFAULT_WINDOW_PERIOD_SECONDS); + } + + #[test] + fn an_invalid_weekly_section_keeps_a_valid_session_quota() { + let mut body = captured(); + body["usage"]["used"] = json!("not-a-number"); + + let mapped = map_usage(&body).unwrap(); + assert_eq!( + mapped + .quotas + .iter() + .map(|quota| quota.id.as_str()) + .collect::>(), + ["session"] + ); + } + + #[test] + fn an_invalid_session_section_keeps_a_valid_weekly_quota() { + let mut body = captured(); + body["limits"][0]["detail"]["remaining"] = json!("not-a-number"); + + let mapped = map_usage(&body).unwrap(); + assert_eq!( + mapped + .quotas + .iter() + .map(|quota| quota.id.as_str()) + .collect::>(), + ["weekly"] + ); + } + + #[test] + fn plan_level_is_optional_and_title_cased() { + assert_eq!( + plan_name(&json!({"user":{"membership":{"level":"LEVEL_BASIC"}}})).as_deref(), + Some("Basic") + ); + assert_eq!( + plan_name(&json!({"user":{"membership":{"level":"LEVEL_YEARLY_PRO"}}})).as_deref(), + Some("Yearly Pro") + ); + assert_eq!(plan_name(&json!({"user":{"membership":{}}})), None); + assert_eq!(plan_name(&json!({})), None); + } +} diff --git a/src-tauri/src/providers/kimi/mod.rs b/src-tauri/src/providers/kimi/mod.rs new file mode 100644 index 0000000..5ece087 --- /dev/null +++ b/src-tauri/src/providers/kimi/mod.rs @@ -0,0 +1,323 @@ +mod auth; +mod client; +mod mapper; + +use std::sync::Arc; + +use chrono::Utc; +use reqwest::StatusCode; +use thiserror::Error; + +use crate::models::{ + ApiKeyStatus, MetricDefinition, MetricSection, ProviderDefinition, ProviderErrorKind, + ProviderLink, ProviderSnapshot, UsageHistory, +}; + +use self::{ + auth::KimiAuthStore, + client::{EndpointResponse, KimiClient}, + mapper::map_usage, +}; + +use super::{ProviderError, UsageProvider}; + +pub(crate) fn definition() -> ProviderDefinition { + ProviderDefinition { + id: "kimi".into(), + display_name: "Kimi".into(), + short_name: "K".into(), + fallback_enabled: false, + local_usage_source_note: None, + links: vec![ + ProviderLink::new("Dashboard", "https://www.kimi.com/code/console"), + ProviderLink::new("API Keys", "https://www.kimi.com/code/console"), + ], + metrics: vec![ + MetricDefinition::quota( + "kimi.session", + "Session", + "session", + false, + true, + MetricSection::AlwaysVisible, + true, + "S", + ), + MetricDefinition::quota( + "kimi.weekly", + "Weekly", + "weekly", + false, + true, + MetricSection::AlwaysVisible, + true, + "W", + ), + ], + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub(super) enum KimiError { + #[error("Add a Kimi API key in Customize or set KIMI_API_KEY.")] + MissingKey, + #[error("The Kimi API key is invalid. Check it in the Kimi Code console.")] + InvalidKey, + #[error("Could not reach Kimi. Check your internet connection.")] + ConnectionFailed, + #[error("Kimi usage data is temporarily unavailable.")] + InvalidResponse, + #[error("Kimi request failed (HTTP {0}).")] + RequestFailed(u16), + #[error("The Kimi API key could not be read or updated.")] + CredentialStorage, +} + +impl From for ProviderError { + fn from(error: KimiError) -> Self { + let kind = match error { + KimiError::MissingKey | KimiError::InvalidKey => ProviderErrorKind::Authentication, + KimiError::ConnectionFailed => ProviderErrorKind::Network, + KimiError::RequestFailed(429) => ProviderErrorKind::RateLimited, + KimiError::RequestFailed(401) => ProviderErrorKind::Authentication, + KimiError::RequestFailed(403) => ProviderErrorKind::Permission, + KimiError::RequestFailed(_) | KimiError::InvalidResponse => { + ProviderErrorKind::InvalidResponse + } + KimiError::CredentialStorage => ProviderErrorKind::CredentialStorage, + }; + ProviderError::new(kind, error.to_string()) + } +} + +pub struct KimiProvider { + auth: KimiAuthStore, + client: Arc, +} + +impl KimiProvider { + pub fn new() -> Result { + Ok(Self { + auth: KimiAuthStore::new(), + client: Arc::new(KimiClient::new().map_err(ProviderError::from)?), + }) + } + + #[cfg(test)] + fn with_dependencies(auth: KimiAuthStore, client: KimiClient) -> Self { + Self { + auth, + client: Arc::new(client), + } + } + + fn refresh_snapshot(&self, api_key: &str) -> Result { + let response = required_response(self.client.fetch(api_key))?; + let mapped = map_usage(&response.body)?; + Ok(ProviderSnapshot { + provider_id: "kimi".into(), + plan: mapped.plan, + quotas: mapped.quotas, + value_metrics: Vec::new(), + status_metrics: Vec::new(), + notices: Vec::new(), + usage: UsageHistory::default(), + warnings: Vec::new(), + refreshed_at: Utc::now(), + }) + } +} + +impl UsageProvider for KimiProvider { + fn definition(&self) -> ProviderDefinition { + definition() + } + + fn has_local_credentials(&self) -> bool { + self.auth.has_local_credentials() + } + + fn refresh(&self) -> Result { + let api_key = self + .auth + .load() + .map_err(ProviderError::from)? + .ok_or_else(|| ProviderError::from(KimiError::MissingKey))?; + self.refresh_snapshot(api_key.as_str()) + } + + fn api_key_status(&self) -> Option> { + Some(self.auth.status().map_err(ProviderError::from)) + } + + fn save_api_key(&self, value: &str) -> Result<(), ProviderError> { + self.auth.save(value).map_err(ProviderError::from) + } + + fn delete_api_key(&self) -> Result<(), ProviderError> { + self.auth.delete().map_err(ProviderError::from) + } +} + +fn required_response( + response: Result, +) -> Result { + let response = response?; + if matches!(response.status, StatusCode::UNAUTHORIZED) { + return Err(KimiError::InvalidKey); + } + if !response.status.is_success() { + return Err(KimiError::RequestFailed(response.status.as_u16())); + } + Ok(response) +} + +#[cfg(test)] +mod tests { + use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + time::Duration, + }; + + use crate::{ + models::ProviderErrorKind, + providers::{api_key::*, test_http, UsageProvider}, + }; + + use super::{auth::KimiAuthStore, client::KimiClient, definition, KimiProvider}; + + #[derive(Default)] + struct MemorySecrets(Mutex>>); + + impl SecretBackend for MemorySecrets { + fn read(&self, account: &str) -> Result, String> { + Ok(self + .0 + .lock() + .unwrap() + .get(account) + .cloned() + .map(SecretBytes::new)) + } + fn write(&self, account: &str, value: &[u8]) -> Result<(), String> { + self.0 + .lock() + .unwrap() + .insert(account.to_owned(), value.to_vec()); + Ok(()) + } + fn delete(&self, account: &str) -> Result<(), String> { + self.0.lock().unwrap().remove(account); + Ok(()) + } + } + + struct Environment(HashMap); + impl EnvironmentReader for Environment { + fn value(&self, name: &str) -> Option { + self.0.get(name).cloned() + } + } + + fn auth(key: Option<&str>) -> KimiAuthStore { + KimiAuthStore::with_store(ApiKeyStore::with_backends( + "kimi", + "KIMI_API_KEY", + Arc::new(MemorySecrets::default()), + Arc::new(Environment( + key.map(|value| HashMap::from([("KIMI_API_KEY".into(), value.into())])) + .unwrap_or_default(), + )), + )) + } + + const QUOTA_BODY: &str = r#"{"user":{"membership":{"level":"LEVEL_BASIC"}}, + "usage":{"limit":"100","used":"25","resetTime":"2026-08-10T02:17:43.139020Z"}, + "limits":[{"window":{"duration":300,"timeUnit":"TIME_UNIT_MINUTE"}, + "detail":{"limit":"100","remaining":"80","resetTime":"2026-08-07T06:17:43.139020Z"}}]}"#; + + #[test] + fn refresh_maps_usage_and_window() { + let url = test_http::serve_once(200, &[], QUOTA_BODY); + let provider = KimiProvider::with_dependencies( + auth(Some("secret")), + KimiClient::for_test(&url, Duration::from_secs(1)), + ); + + let snapshot = provider.refresh().unwrap(); + assert_eq!(snapshot.provider_id, "kimi"); + assert_eq!(snapshot.plan.as_deref(), Some("Basic")); + assert_eq!( + snapshot + .quotas + .iter() + .map(|quota| quota.id.as_str()) + .collect::>(), + ["session", "weekly"] + ); + } + + #[test] + fn missing_invalid_and_rate_limited_keys_are_distinct() { + let missing = KimiProvider::with_dependencies( + auth(None), + KimiClient::for_test( + &test_http::serve_once(200, &[], QUOTA_BODY), + Duration::from_secs(1), + ), + ) + .refresh() + .unwrap_err(); + assert_eq!(missing.kind(), ProviderErrorKind::Authentication); + + let invalid = KimiProvider::with_dependencies( + auth(Some("bad-key")), + KimiClient::for_test( + &test_http::serve_once(401, &[], "{}"), + Duration::from_secs(1), + ), + ) + .refresh() + .unwrap_err(); + assert_eq!(invalid.kind(), ProviderErrorKind::Authentication); + assert!(!invalid.to_string().contains("bad-key")); + + let forbidden = KimiProvider::with_dependencies( + auth(Some("secret")), + KimiClient::for_test( + &test_http::serve_once(403, &[], "{}"), + Duration::from_secs(1), + ), + ) + .refresh() + .unwrap_err(); + assert_eq!(forbidden.kind(), ProviderErrorKind::Permission); + + let rate_limited = KimiProvider::with_dependencies( + auth(Some("secret")), + KimiClient::for_test( + &test_http::serve_once(429, &[], "{}"), + Duration::from_secs(1), + ), + ) + .refresh() + .unwrap_err(); + assert_eq!(rate_limited.kind(), ProviderErrorKind::RateLimited); + } + + #[test] + fn definition_exposes_expected_identity_and_metrics() { + let definition = definition(); + assert_eq!(definition.id, "kimi"); + assert_eq!(definition.display_name, "Kimi"); + assert_eq!( + definition + .links + .iter() + .map(|link| link.label.as_str()) + .collect::>(), + ["Dashboard", "API Keys"] + ); + } +} diff --git a/src-tauri/src/providers/minimax/auth.rs b/src-tauri/src/providers/minimax/auth.rs new file mode 100644 index 0000000..f96bae7 --- /dev/null +++ b/src-tauri/src/providers/minimax/auth.rs @@ -0,0 +1,67 @@ +use crate::{ + models::ApiKeyStatus, + providers::api_key::{ApiKeyStore, SecretString}, +}; + +use super::MiniMaxError; + +const CONFIG_PATHS: &[&str] = &["~/.config/openquota/minimax.json"]; +const ENVIRONMENT_NAMES: &[&str] = &["MINIMAX_API_KEY"]; + +#[derive(Clone)] +pub struct MiniMaxAuthStore { + store: ApiKeyStore, +} + +impl MiniMaxAuthStore { + pub fn new() -> Self { + Self { + store: ApiKeyStore::new_with_sources("minimax", ENVIRONMENT_NAMES, CONFIG_PATHS), + } + } + + #[cfg(test)] + pub(super) fn with_store(store: ApiKeyStore) -> Self { + Self { store } + } + + pub fn load(&self) -> Result, MiniMaxError> { + self.store + .load() + .map_err(|_| MiniMaxError::CredentialStorage) + } + + pub fn has_local_credentials(&self) -> bool { + self.load().is_ok_and(|secret| secret.is_some()) + } + + pub fn status(&self) -> Result { + self.store + .status() + .map_err(|_| MiniMaxError::CredentialStorage) + } + + pub fn save(&self, value: &str) -> Result<(), MiniMaxError> { + self.store.save(value).map_err(|_| { + if value.trim().is_empty() { + MiniMaxError::MissingKey + } else { + crate::app_warn!("auth:minimax", "system credential store write failed"); + MiniMaxError::CredentialStorage + } + }) + } + + pub fn delete(&self) -> Result<(), MiniMaxError> { + self.store.delete().map_err(|_| { + crate::app_warn!("auth:minimax", "system credential store delete failed"); + MiniMaxError::CredentialStorage + }) + } +} + +impl Default for MiniMaxAuthStore { + fn default() -> Self { + Self::new() + } +} diff --git a/src-tauri/src/providers/minimax/client.rs b/src-tauri/src/providers/minimax/client.rs new file mode 100644 index 0000000..5c5d7c5 --- /dev/null +++ b/src-tauri/src/providers/minimax/client.rs @@ -0,0 +1,70 @@ +use std::time::Duration; + +use reqwest::{blocking::Client, StatusCode}; +use serde_json::Value; + +use super::MiniMaxError; + +const REMAINS_URL: &str = "https://www.minimax.io/v1/token_plan/remains"; + +#[derive(Debug)] +pub struct EndpointResponse { + pub status: StatusCode, + pub body: Value, +} + +pub struct MiniMaxClient { + client: Client, + url: String, +} + +impl MiniMaxClient { + pub fn new() -> Result { + Self::with_endpoint(REMAINS_URL, Duration::from_secs(15)) + } + + fn with_endpoint(url: &str, timeout: Duration) -> Result { + let client = Client::builder() + .connect_timeout(Duration::from_secs(8)) + .timeout(timeout) + .user_agent(concat!("OpenQuota/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|_| MiniMaxError::ConnectionFailed)?; + Ok(Self { + client, + url: url.to_owned(), + }) + } + + pub fn fetch(&self, api_key: &str) -> Result { + let started = std::time::Instant::now(); + let response = self + .client + .get(&self.url) + .bearer_auth(api_key) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .send() + .map_err(|_| { + crate::app_warn!("http", "minimax token_plan request failed (transport)"); + MiniMaxError::ConnectionFailed + })?; + let status = response.status(); + crate::app_debug!( + "http", + "minimax token_plan HTTP {} ({}ms)", + status.as_u16(), + started.elapsed().as_millis() + ); + let text = response.text().map_err(|_| MiniMaxError::InvalidResponse)?; + let body = serde_json::from_str(&text).unwrap_or(Value::Null); + Ok(EndpointResponse { status, body }) + } +} + +#[cfg(test)] +impl MiniMaxClient { + pub fn for_test(url: &str, timeout: Duration) -> Self { + Self::with_endpoint(url, timeout).unwrap() + } +} diff --git a/src-tauri/src/providers/minimax/mapper.rs b/src-tauri/src/providers/minimax/mapper.rs new file mode 100644 index 0000000..50cad61 --- /dev/null +++ b/src-tauri/src/providers/minimax/mapper.rs @@ -0,0 +1,318 @@ +use chrono::{DateTime, Utc}; +use serde_json::Value; + +use crate::models::{QuotaFormat, QuotaWindow}; + +use super::MiniMaxError; + +const WEEKLY_PERIOD_SECONDS: u64 = 7 * 24 * 60 * 60; +const DEFAULT_INTERVAL_PERIOD_SECONDS: u64 = 5 * 60 * 60; + +#[derive(Debug, PartialEq)] +pub struct MiniMaxMappedUsage { + pub plan: Option, + pub quotas: Vec, +} + +/// Returns the API-level error message when `base_resp.status_code` is non-zero. +pub fn api_error_message(body: &Value) -> Option { + let code = number(body.get("base_resp")?.get("status_code"))?; + if code == 0.0 { + None + } else { + Some( + body.get("base_resp") + .and_then(|base| base.get("status_msg")) + .and_then(Value::as_str) + .unwrap_or("MiniMax error") + .to_owned(), + ) + } +} + +pub fn map_usage(body: &Value) -> Result { + if let Some(message) = api_error_message(body) { + let normalized = message.to_ascii_lowercase(); + if normalized.contains("no token plan") { + return Err(MiniMaxError::NoTokenPlan); + } + return Err(MiniMaxError::InvalidResponse); + } + + let models = body + .get("model_remains") + .and_then(Value::as_array) + .ok_or(MiniMaxError::InvalidResponse)?; + let general = models + .iter() + .find(|model| model.get("model_name").and_then(Value::as_str) == Some("general")) + .ok_or(MiniMaxError::InvalidResponse)?; + + let weekly = quota_from_model(general, Window::Weekly)?; + let session = quota_from_model(general, Window::Interval)?; + Ok(MiniMaxMappedUsage { + plan: Some("Token Plan".into()), + quotas: [session, weekly].into_iter().flatten().collect(), + }) +} + +#[derive(Clone, Copy)] +enum Window { + Weekly, + Interval, +} + +fn quota_from_model(model: &Value, window: Window) -> Result, MiniMaxError> { + let (remaining_key, status_key, end_key, start_key, id, label, default_period) = match window { + Window::Weekly => ( + "current_weekly_remaining_percent", + "current_weekly_status", + "weekly_end_time", + "weekly_start_time", + "weekly", + "Weekly", + WEEKLY_PERIOD_SECONDS, + ), + Window::Interval => ( + "current_interval_remaining_percent", + "current_interval_status", + "end_time", + "start_time", + "session", + "Session", + DEFAULT_INTERVAL_PERIOD_SECONDS, + ), + }; + if number(model.get(status_key)).map(|status| status as u16) == Some(3) { + return Ok(Some(unlimited_quota( + id, + label, + number(model.get(end_key)).and_then(millis_time), + default_period, + ))); + } + let allowance_percent = if matches!(window, Window::Weekly) { + 100.0 * number(model.get("weekly_boost_permille")).unwrap_or(1000.0) / 1000.0 + } else { + 100.0 + }; + let remaining = number(model.get(remaining_key)) + .filter(|value| (0.0..=allowance_percent).contains(value)) + .ok_or(MiniMaxError::InvalidResponse)?; + let used_percent = + ((allowance_percent - remaining) / allowance_percent * 100.0).clamp(0.0, 100.0); + + let end = number(model.get(end_key)); + let start = number(model.get(start_key)); + let period_seconds = match (start, end) { + (Some(start), Some(end)) if end > start => ((end - start) / 1000.0) as u64, + _ => default_period, + }; + let resets_at = end.and_then(millis_time); + + Ok(Some(QuotaWindow { + id: id.into(), + label: label.into(), + used_percent, + resets_at, + period_seconds, + format: QuotaFormat::Percent, + used_value: None, + limit_value: None, + unit: None, + estimated: false, + source_note: None, + })) +} + +fn unlimited_quota( + id: &str, + label: &str, + resets_at: Option>, + period_seconds: u64, +) -> QuotaWindow { + QuotaWindow { + id: id.into(), + label: format!("{label} (Unlimited)"), + used_percent: 0.0, + resets_at, + period_seconds, + format: QuotaFormat::Percent, + used_value: None, + limit_value: None, + unit: None, + estimated: false, + source_note: None, + } +} + +fn millis_time(milliseconds: f64) -> Option> { + if milliseconds < i64::MIN as f64 || milliseconds > i64::MAX as f64 { + return None; + } + DateTime::from_timestamp_millis(milliseconds.trunc() as i64) +} + +fn number(value: Option<&Value>) -> Option { + value + .and_then(|value| { + value + .as_f64() + .or_else(|| value.as_str().and_then(|text| text.trim().parse().ok())) + }) + .filter(|value| value.is_finite()) +} + +#[cfg(test)] +mod tests { + use chrono::{TimeZone, Utc}; + use serde_json::{json, Value}; + + use super::{api_error_message, map_usage}; + use crate::providers::minimax::MiniMaxError; + + fn captured() -> Value { + serde_json::from_str( + r#"{ + "model_remains":[{ + "start_time":1786060800000,"end_time":1786078800000, + "remains_time":2185461, + "current_interval_total_count":0,"current_interval_usage_count":0, + "model_name":"general", + "current_weekly_total_count":0,"current_weekly_usage_count":0, + "weekly_start_time":1785715200000,"weekly_end_time":1786320000000, + "weekly_remains_time":243385461, + "current_interval_status":2,"current_interval_remaining_percent":0, + "current_weekly_status":3,"current_weekly_remaining_percent":100 + }], + "base_resp":{"status_code":0,"status_msg":"success"} + }"#, + ) + .unwrap() + } + + #[test] + fn captured_payload_includes_an_unlimited_weekly_window() { + let mapped = map_usage(&captured()).unwrap(); + + assert_eq!(mapped.plan.as_deref(), Some("Token Plan")); + assert_eq!( + mapped + .quotas + .iter() + .map(|quota| quota.id.as_str()) + .collect::>(), + ["session", "weekly"] + ); + + let session = &mapped.quotas[0]; + assert_eq!(session.used_percent, 100.0); + assert_eq!( + session.resets_at, + Utc.timestamp_millis_opt(1_786_078_800_000).single() + ); + assert_eq!(session.period_seconds, 5 * 60 * 60); + } + + #[test] + fn rejects_payloads_without_the_general_model() { + assert!(matches!( + map_usage(&json!({ + "model_remains":[{ + "model_name":"video","current_weekly_remaining_percent":40, + "current_interval_remaining_percent":90, + "weekly_start_time":0,"weekly_end_time":604800000, + "start_time":0,"end_time":18000000 + }], + "base_resp":{"status_code":0} + })), + Err(MiniMaxError::InvalidResponse) + )); + } + + #[test] + fn weekly_boost_uses_multiplier_semantics() { + let mut body = captured(); + body["model_remains"][0]["current_weekly_status"] = json!(2); + body["model_remains"][0]["weekly_boost_permille"] = json!(1500); + body["model_remains"][0]["current_weekly_remaining_percent"] = json!(150); + let mapped = map_usage(&body).unwrap(); + let weekly = mapped + .quotas + .iter() + .find(|quota| quota.id == "weekly") + .unwrap(); + assert_eq!(weekly.used_percent, 0.0); + } + + #[test] + fn optional_status_fields_do_not_discard_valid_quota_data() { + let mut body = captured(); + body["model_remains"][0] + .as_object_mut() + .unwrap() + .remove("current_interval_status"); + body["model_remains"][0] + .as_object_mut() + .unwrap() + .remove("current_weekly_status"); + let mapped = map_usage(&body).unwrap(); + assert_eq!(mapped.quotas.len(), 2); + assert!(mapped + .quotas + .iter() + .all(|quota| !quota.label.contains("Unlimited"))); + } + + #[test] + fn unlimited_windows_are_not_reported_as_unavailable() { + let mapped = map_usage(&captured()).unwrap(); + assert_eq!( + mapped + .quotas + .iter() + .map(|quota| quota.id.as_str()) + .collect::>(), + ["session", "weekly"] + ); + let weekly = mapped + .quotas + .iter() + .find(|quota| quota.id == "weekly") + .unwrap(); + assert_eq!(weekly.label, "Weekly (Unlimited)"); + } + + #[test] + fn api_error_messages_classify_no_plan_and_generic_failures() { + assert_eq!( + api_error_message(&json!({"base_resp":{"status_code":0}})), + None + ); + assert_eq!( + api_error_message( + &json!({"base_resp":{"status_code":1001,"status_msg":"no token plan"}}) + ) + .as_deref(), + Some("no token plan") + ); + + assert!(matches!( + map_usage( + &json!({"base_resp":{"status_code":1001,"status_msg":"user has no token plan"}}) + ), + Err(MiniMaxError::NoTokenPlan) + )); + assert!(matches!( + map_usage( + &json!({"base_resp":{"status_code":1001,"status_msg":"subscribe to a plan"}}) + ), + Err(MiniMaxError::InvalidResponse) + )); + assert!(matches!( + map_usage(&json!({"base_resp":{"status_code":500,"status_msg":"internal error"}})), + Err(MiniMaxError::InvalidResponse) + )); + assert!(map_usage(&json!({"base_resp":{"status_code":0}})).is_err()); + } +} diff --git a/src-tauri/src/providers/minimax/mod.rs b/src-tauri/src/providers/minimax/mod.rs new file mode 100644 index 0000000..3c7a62a --- /dev/null +++ b/src-tauri/src/providers/minimax/mod.rs @@ -0,0 +1,341 @@ +mod auth; +mod client; +mod mapper; + +use std::sync::Arc; + +use chrono::Utc; +use reqwest::StatusCode; +use thiserror::Error; + +use crate::models::{ + ApiKeyStatus, MetricDefinition, MetricSection, ProviderDefinition, ProviderErrorKind, + ProviderLink, ProviderSnapshot, UsageHistory, +}; + +use self::{ + auth::MiniMaxAuthStore, + client::{EndpointResponse, MiniMaxClient}, + mapper::map_usage, +}; + +use super::{ProviderError, UsageProvider}; + +pub(crate) fn definition() -> ProviderDefinition { + ProviderDefinition { + id: "minimax".into(), + display_name: "MiniMax".into(), + short_name: "M".into(), + fallback_enabled: false, + local_usage_source_note: None, + links: vec![ + ProviderLink::new("Dashboard", "https://platform.minimax.io/console/plan"), + ProviderLink::new("API Keys", "https://platform.minimax.io/console/access"), + ], + metrics: vec![ + MetricDefinition::quota( + "minimax.session", + "Session", + "session", + false, + true, + MetricSection::AlwaysVisible, + true, + "S", + ), + MetricDefinition::quota( + "minimax.weekly", + "Weekly", + "weekly", + false, + true, + MetricSection::AlwaysVisible, + true, + "W", + ), + ], + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub(super) enum MiniMaxError { + #[error("Add a MiniMax API key in Customize or set MINIMAX_API_KEY.")] + MissingKey, + #[error("The MiniMax API key is invalid. Check it at minimax.io.")] + InvalidKey, + #[error("Could not reach MiniMax. Check your internet connection.")] + ConnectionFailed, + #[error("MiniMax usage data is temporarily unavailable.")] + InvalidResponse, + #[error("MiniMax request failed (HTTP {0}).")] + RequestFailed(u16), + #[error("No active MiniMax token plan. Subscribe at minimax.io to view usage.")] + NoTokenPlan, + #[error("The MiniMax API key could not be read or updated.")] + CredentialStorage, +} + +impl From for ProviderError { + fn from(error: MiniMaxError) -> Self { + let kind = match error { + MiniMaxError::MissingKey | MiniMaxError::InvalidKey => { + ProviderErrorKind::Authentication + } + MiniMaxError::ConnectionFailed => ProviderErrorKind::Network, + MiniMaxError::RequestFailed(429) => ProviderErrorKind::RateLimited, + MiniMaxError::RequestFailed(401 | 403) => ProviderErrorKind::Authentication, + MiniMaxError::NoTokenPlan => ProviderErrorKind::Permission, + MiniMaxError::RequestFailed(_) | MiniMaxError::InvalidResponse => { + ProviderErrorKind::InvalidResponse + } + MiniMaxError::CredentialStorage => ProviderErrorKind::CredentialStorage, + }; + ProviderError::new(kind, error.to_string()) + } +} + +pub struct MiniMaxProvider { + auth: MiniMaxAuthStore, + client: Arc, +} + +impl MiniMaxProvider { + pub fn new() -> Result { + Ok(Self { + auth: MiniMaxAuthStore::new(), + client: Arc::new(MiniMaxClient::new().map_err(ProviderError::from)?), + }) + } + + #[cfg(test)] + fn with_dependencies(auth: MiniMaxAuthStore, client: MiniMaxClient) -> Self { + Self { + auth, + client: Arc::new(client), + } + } + + fn refresh_snapshot(&self, api_key: &str) -> Result { + let response = required_response(self.client.fetch(api_key))?; + let mapped = map_usage(&response.body)?; + Ok(ProviderSnapshot { + provider_id: "minimax".into(), + plan: mapped.plan, + quotas: mapped.quotas, + value_metrics: Vec::new(), + status_metrics: Vec::new(), + notices: Vec::new(), + usage: UsageHistory::default(), + warnings: Vec::new(), + refreshed_at: Utc::now(), + }) + } +} + +impl UsageProvider for MiniMaxProvider { + fn definition(&self) -> ProviderDefinition { + definition() + } + + fn has_local_credentials(&self) -> bool { + self.auth.has_local_credentials() + } + + fn refresh(&self) -> Result { + let api_key = self + .auth + .load() + .map_err(ProviderError::from)? + .ok_or_else(|| ProviderError::from(MiniMaxError::MissingKey))?; + self.refresh_snapshot(api_key.as_str()) + } + + fn api_key_status(&self) -> Option> { + Some(self.auth.status().map_err(ProviderError::from)) + } + + fn save_api_key(&self, value: &str) -> Result<(), ProviderError> { + self.auth.save(value).map_err(ProviderError::from) + } + + fn delete_api_key(&self) -> Result<(), ProviderError> { + self.auth.delete().map_err(ProviderError::from) + } +} + +fn required_response( + response: Result, +) -> Result { + let response = response?; + if matches!( + response.status, + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN + ) { + return Err(MiniMaxError::InvalidKey); + } + if !response.status.is_success() { + return Err(MiniMaxError::RequestFailed(response.status.as_u16())); + } + Ok(response) +} + +#[cfg(test)] +mod tests { + use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + time::Duration, + }; + + use crate::{ + models::ProviderErrorKind, + providers::{api_key::*, test_http, UsageProvider}, + }; + + use super::{auth::MiniMaxAuthStore, client::MiniMaxClient, definition, MiniMaxProvider}; + + #[derive(Default)] + struct MemorySecrets(Mutex>>); + + impl SecretBackend for MemorySecrets { + fn read(&self, account: &str) -> Result, String> { + Ok(self + .0 + .lock() + .unwrap() + .get(account) + .cloned() + .map(SecretBytes::new)) + } + fn write(&self, account: &str, value: &[u8]) -> Result<(), String> { + self.0 + .lock() + .unwrap() + .insert(account.to_owned(), value.to_vec()); + Ok(()) + } + fn delete(&self, account: &str) -> Result<(), String> { + self.0.lock().unwrap().remove(account); + Ok(()) + } + } + + struct Environment(HashMap); + impl EnvironmentReader for Environment { + fn value(&self, name: &str) -> Option { + self.0.get(name).cloned() + } + } + + fn auth(key: Option<&str>) -> MiniMaxAuthStore { + MiniMaxAuthStore::with_store(ApiKeyStore::with_backends( + "minimax", + "MINIMAX_API_KEY", + Arc::new(MemorySecrets::default()), + Arc::new(Environment( + key.map(|value| HashMap::from([("MINIMAX_API_KEY".into(), value.into())])) + .unwrap_or_default(), + )), + )) + } + + const REMAINS_BODY: &str = r#"{"model_remains":[{ + "start_time":1786060800000,"end_time":1786078800000,"remains_time":2185461, + "current_interval_total_count":0,"current_interval_usage_count":0,"model_name":"general", + "current_weekly_total_count":0,"current_weekly_usage_count":0, + "weekly_start_time":1785715200000,"weekly_end_time":1786320000000,"weekly_remains_time":243385461, + "current_interval_status":2,"current_interval_remaining_percent":0, + "current_weekly_status":3,"current_weekly_remaining_percent":100}], + "base_resp":{"status_code":0,"status_msg":"success"}}"#; + + #[test] + fn refresh_maps_weekly_and_interval() { + let url = test_http::serve_once(200, &[], REMAINS_BODY); + let provider = MiniMaxProvider::with_dependencies( + auth(Some("secret")), + MiniMaxClient::for_test(&url, Duration::from_secs(1)), + ); + + let snapshot = provider.refresh().unwrap(); + assert_eq!(snapshot.provider_id, "minimax"); + assert_eq!(snapshot.plan.as_deref(), Some("Token Plan")); + assert_eq!( + snapshot + .quotas + .iter() + .map(|quota| quota.id.as_str()) + .collect::>(), + ["session", "weekly"] + ); + assert_eq!(snapshot.quotas[1].label, "Weekly (Unlimited)"); + } + + #[test] + fn missing_invalid_and_rate_limited_keys_are_distinct() { + let missing = MiniMaxProvider::with_dependencies( + auth(None), + MiniMaxClient::for_test( + &test_http::serve_once(200, &[], REMAINS_BODY), + Duration::from_secs(1), + ), + ) + .refresh() + .unwrap_err(); + assert_eq!(missing.kind(), ProviderErrorKind::Authentication); + + for status in [401, 403] { + let invalid = MiniMaxProvider::with_dependencies( + auth(Some("bad-key")), + MiniMaxClient::for_test( + &test_http::serve_once(status, &[], "{}"), + Duration::from_secs(1), + ), + ) + .refresh() + .unwrap_err(); + assert_eq!(invalid.kind(), ProviderErrorKind::Authentication); + assert!(!invalid.to_string().contains("bad-key")); + } + + let rate_limited = MiniMaxProvider::with_dependencies( + auth(Some("secret")), + MiniMaxClient::for_test( + &test_http::serve_once(429, &[], "{}"), + Duration::from_secs(1), + ), + ) + .refresh() + .unwrap_err(); + assert_eq!(rate_limited.kind(), ProviderErrorKind::RateLimited); + } + + #[test] + fn no_token_plan_is_a_permission_error() { + let url = test_http::serve_once( + 200, + &[], + r#"{"base_resp":{"status_code":1001,"status_msg":"user has no token plan"}}"#, + ); + let provider = MiniMaxProvider::with_dependencies( + auth(Some("secret")), + MiniMaxClient::for_test(&url, Duration::from_secs(1)), + ); + let error = provider.refresh().unwrap_err(); + assert_eq!(error.kind(), ProviderErrorKind::Permission); + } + + #[test] + fn definition_exposes_expected_identity_and_metrics() { + let definition = definition(); + assert_eq!(definition.id, "minimax"); + assert_eq!(definition.display_name, "MiniMax"); + assert_eq!( + definition + .links + .iter() + .map(|link| link.label.as_str()) + .collect::>(), + ["Dashboard", "API Keys"] + ); + } +} diff --git a/src-tauri/src/providers/mod.rs b/src-tauri/src/providers/mod.rs index adddc9c..09623f1 100644 --- a/src-tauri/src/providers/mod.rs +++ b/src-tauri/src/providers/mod.rs @@ -9,7 +9,9 @@ mod daily_usage; mod detection; pub mod devin; pub mod grok; +pub mod kimi; mod log_usage; +pub mod minimax; pub mod opencode; pub mod openrouter; mod pi_usage; @@ -157,8 +159,8 @@ pub trait UsageProvider: Send + Sync { #[cfg(test)] mod tests { use super::{ - antigravity, claude, codex, copilot, cursor, devin, grok, opencode, openrouter, - remember_default_account, zai, ProviderError, + antigravity, claude, codex, copilot, cursor, devin, grok, kimi, minimax, opencode, + openrouter, remember_default_account, zai, ProviderError, }; use crate::models::ProviderErrorKind; use tempfile::tempdir; @@ -279,5 +281,31 @@ mod tests { ), ] ); + assert_eq!( + links(kimi::definition()), + [ + ( + "Dashboard".into(), + "https://www.kimi.com/code/console".into() + ), + ( + "API Keys".into(), + "https://www.kimi.com/code/console".into() + ), + ] + ); + assert_eq!( + links(minimax::definition()), + [ + ( + "Dashboard".into(), + "https://platform.minimax.io/console/plan".into() + ), + ( + "API Keys".into(), + "https://platform.minimax.io/console/access".into() + ), + ] + ); } } diff --git a/src/assets/provider-icons/kimi.svg b/src/assets/provider-icons/kimi.svg new file mode 100644 index 0000000..43e5236 --- /dev/null +++ b/src/assets/provider-icons/kimi.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/provider-icons/minimax.svg b/src/assets/provider-icons/minimax.svg new file mode 100644 index 0000000..15a3868 --- /dev/null +++ b/src/assets/provider-icons/minimax.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/providerIconPaths.ts b/src/lib/providerIconPaths.ts index 0ad399f..21b13a0 100644 --- a/src/lib/providerIconPaths.ts +++ b/src/lib/providerIconPaths.ts @@ -5,6 +5,8 @@ import copilot from '../assets/provider-icons/copilot.svg?raw'; import cursor from '../assets/provider-icons/cursor.svg?raw'; import devin from '../assets/provider-icons/devin.svg?raw'; import grok from '../assets/provider-icons/grok.svg?raw'; +import kimi from '../assets/provider-icons/kimi.svg?raw'; +import minimax from '../assets/provider-icons/minimax.svg?raw'; import opencode from '../assets/provider-icons/opencode.svg?raw'; import openrouter from '../assets/provider-icons/openrouter.svg?raw'; import zai from '../assets/provider-icons/zai.svg?raw'; @@ -17,6 +19,8 @@ const visuals: Record = { cursor: { source: cursor, color: null }, devin: { source: devin, color: null }, grok: { source: grok, color: null }, + kimi: { source: kimi, color: '#1783FF' }, + minimax: { source: minimax, color: '#E2167E' }, opencode: { source: opencode, color: null }, openrouter: { source: openrouter, color: null }, zai: { source: zai, color: null },