From 8d9117a0e3ebe750021d501d7b36e6ce4e14355c Mon Sep 17 00:00:00 2001 From: lamchun1110 Date: Sat, 8 Aug 2026 13:13:48 +0800 Subject: [PATCH 01/10] feat: add Kimi (Moonshot) coding provider Adds a Kimi provider reporting Session (5-hour rolling window) and Weekly (main usage) quotas from GET https://api.kimi.com/coding/v1/usages using a Bearer API key. Modeled on providers/zai: client + mapper + auth, keychain- backed key (zeroized), with unit tests for the captured payload, error states, and plan detection. Registered in the runtime list and covered by the provider-registry contract and links test. --- .../verify-provider-registry-contract.js | 3 +- src-tauri/src/lib.rs | 5 +- src-tauri/src/providers/kimi/auth.rs | 63 ++++ src-tauri/src/providers/kimi/client.rs | 69 ++++ src-tauri/src/providers/kimi/mapper.rs | 250 ++++++++++++++ src-tauri/src/providers/kimi/mod.rs | 307 ++++++++++++++++++ src-tauri/src/providers/mod.rs | 13 +- src/assets/provider-icons/kimi.svg | 4 + src/lib/providerIconPaths.ts | 2 + 9 files changed, 712 insertions(+), 4 deletions(-) create mode 100644 src-tauri/src/providers/kimi/auth.rs create mode 100644 src-tauri/src/providers/kimi/client.rs create mode 100644 src-tauri/src/providers/kimi/mapper.rs create mode 100644 src-tauri/src/providers/kimi/mod.rs create mode 100644 src/assets/provider-icons/kimi.svg diff --git a/scripts/verify/verify-provider-registry-contract.js b/scripts/verify/verify-provider-registry-contract.js index a2516ba..5749793 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)["']/; for (const file of rustConsumers) { const source = fs.readFileSync(new URL(file, root), 'utf8').split('#[cfg(test)]')[0]; @@ -90,6 +90,7 @@ const expectedRuntimeOrder = [ 'OpenCodeProvider', 'OpenRouterProvider', 'ZaiProvider', + 'KimiProvider', ]; if (runtimeOrder.join(',') !== expectedRuntimeOrder.join(',')) { throw new Error( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c1ced18..763de41 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -49,8 +49,8 @@ use crate::{ 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, + kimi::KimiProvider, opencode::OpenCodeProvider, openrouter::OpenRouterProvider, + zai::ZaiProvider, ProviderRegistry, UsageProvider, }, storage::Storage, window::{ @@ -240,6 +240,7 @@ 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, ]); let registry = Arc::new(ProviderRegistry::new(providers)?); let (settings_service, credential_detection_plan) = diff --git a/src-tauri/src/providers/kimi/auth.rs b/src-tauri/src/providers/kimi/auth.rs new file mode 100644 index 0000000..7752b12 --- /dev/null +++ b/src-tauri/src/providers/kimi/auth.rs @@ -0,0 +1,63 @@ +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..7987242 --- /dev/null +++ b/src-tauri/src/providers/kimi/mapper.rs @@ -0,0 +1,250 @@ +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 weekly = weekly_quota(body)?; + let mut quotas = Vec::new(); + if let Some(session) = session_quota(body)? { + quotas.push(session); + } + quotas.push(weekly); + Ok(quotas) +} + +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::Count, + used_value: Some(used), + limit_value: Some(limit), + unit: Some("uses".into()), + 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.first()) + 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 = entry + .get("window") + .and_then(|window| number(window.get("duration")).filter(|value| *value > 0.0)) + .and_then(|duration| { + let time_unit = entry + .get("window") + .and_then(|window| window.get("timeUnit")) + .and_then(Value::as_str); + let factor = match time_unit { + Some("TIME_UNIT_HOUR") => 3600.0, + Some("TIME_UNIT_SECOND") => 1.0, + _ => 60.0, + }; + Some((duration * factor) as u64) + }) + .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::Count, + used_value: Some(used), + limit_value: Some(limit), + unit: Some("uses".into()), + estimated: false, + source_note: None, + })) +} + +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}; + + fn captured() -> Value { + serde_json::json!({ + "user": {"userId":"d63jf5am52tc032su6e0","region":"REGION_OVERSEA", + "membership":{"level":"LEVEL_BASIC"},"businessId":""}, + "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.used_value, Some(25.0)); + assert_eq!(weekly.limit_value, Some(100.0)); + 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 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..fe9ad82 --- /dev/null +++ b/src-tauri/src/providers/kimi/mod.rs @@ -0,0 +1,307 @@ +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://platform.moonshot.ai/"), + ProviderLink::new("API Keys", "https://platform.moonshot.ai/console/api-keys"), + ], + 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 on platform.moonshot.ai.")] + 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 | 403) => ProviderErrorKind::Authentication, + 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 | StatusCode::FORBIDDEN + ) { + 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); + + for status in [401, 403] { + let invalid = KimiProvider::with_dependencies( + auth(Some("bad-key")), + KimiClient::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 = 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/mod.rs b/src-tauri/src/providers/mod.rs index adddc9c..2bdc6ca 100644 --- a/src-tauri/src/providers/mod.rs +++ b/src-tauri/src/providers/mod.rs @@ -9,6 +9,7 @@ mod daily_usage; mod detection; pub mod devin; pub mod grok; +pub mod kimi; mod log_usage; pub mod opencode; pub mod openrouter; @@ -157,7 +158,7 @@ pub trait UsageProvider: Send + Sync { #[cfg(test)] mod tests { use super::{ - antigravity, claude, codex, copilot, cursor, devin, grok, opencode, openrouter, + antigravity, claude, codex, copilot, cursor, devin, grok, kimi, opencode, openrouter, remember_default_account, zai, ProviderError, }; use crate::models::ProviderErrorKind; @@ -279,5 +280,15 @@ mod tests { ), ] ); + assert_eq!( + links(kimi::definition()), + [ + ("Dashboard".into(), "https://platform.moonshot.ai/".into()), + ( + "API Keys".into(), + "https://platform.moonshot.ai/console/api-keys".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/lib/providerIconPaths.ts b/src/lib/providerIconPaths.ts index 0ad399f..8cca71b 100644 --- a/src/lib/providerIconPaths.ts +++ b/src/lib/providerIconPaths.ts @@ -5,6 +5,7 @@ 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 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 +18,7 @@ const visuals: Record = { cursor: { source: cursor, color: null }, devin: { source: devin, color: null }, grok: { source: grok, color: null }, + kimi: { source: kimi, color: '#1783FF' }, opencode: { source: opencode, color: null }, openrouter: { source: openrouter, color: null }, zai: { source: zai, color: null }, From 67a99bae5a7cb75d21cb8188448ce2e8a14d2f4d Mon Sep 17 00:00:00 2001 From: lamchun1110 Date: Sat, 8 Aug 2026 13:17:20 +0800 Subject: [PATCH 02/10] feat: add MiniMax token-plan provider Adds a MiniMax provider reporting Session (per-interval) and Weekly remaining quotas from GET https://www.minimax.io/v1/token_plan/remains using a Bearer API key. Modeled on providers/zai: client + mapper + auth, keychain-backed key (zeroized), success checked via base_resp.status_code, with a 'no token plan' -> permission error. Registered + contract/links covered, with unit tests. --- .../verify-provider-registry-contract.js | 3 +- src-tauri/src/lib.rs | 4 +- src-tauri/src/providers/minimax/auth.rs | 63 ++++ src-tauri/src/providers/minimax/client.rs | 72 ++++ src-tauri/src/providers/minimax/mapper.rs | 229 ++++++++++++ src-tauri/src/providers/minimax/mod.rs | 329 ++++++++++++++++++ src-tauri/src/providers/mod.rs | 19 +- src/assets/provider-icons/minimax.svg | 3 + src/lib/providerIconPaths.ts | 2 + 9 files changed, 719 insertions(+), 5 deletions(-) create mode 100644 src-tauri/src/providers/minimax/auth.rs create mode 100644 src-tauri/src/providers/minimax/client.rs create mode 100644 src-tauri/src/providers/minimax/mapper.rs create mode 100644 src-tauri/src/providers/minimax/mod.rs create mode 100644 src/assets/provider-icons/minimax.svg diff --git a/scripts/verify/verify-provider-registry-contract.js b/scripts/verify/verify-provider-registry-contract.js index 5749793..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|kimi)["']/; + /["'](?: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]; @@ -91,6 +91,7 @@ const expectedRuntimeOrder = [ '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 763de41..02503f9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -49,7 +49,8 @@ use crate::{ antigravity::AntigravityProvider, claude, codex::reset_claim::CodexResetClaimService, codex::CodexProvider, copilot::CopilotProvider, cursor::CursorProvider, detect_local_credentials, devin::DevinProvider, grok::GrokProvider, - kimi::KimiProvider, opencode::OpenCodeProvider, openrouter::OpenRouterProvider, + kimi::KimiProvider, minimax::MiniMaxProvider, opencode::OpenCodeProvider, + openrouter::OpenRouterProvider, zai::ZaiProvider, ProviderRegistry, UsageProvider, }, storage::Storage, @@ -241,6 +242,7 @@ pub fn run() { 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/providers/minimax/auth.rs b/src-tauri/src/providers/minimax/auth.rs new file mode 100644 index 0000000..934051b --- /dev/null +++ b/src-tauri/src/providers/minimax/auth.rs @@ -0,0 +1,63 @@ +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", "MINIMAXI_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..9a85991 --- /dev/null +++ b/src-tauri/src/providers/minimax/client.rs @@ -0,0 +1,72 @@ +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..9a4d5e4 --- /dev/null +++ b/src-tauri/src/providers/minimax/mapper.rs @@ -0,0 +1,229 @@ +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 plan") || normalized.contains("token plan") || normalized.contains("subscribe") + { + 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")) + .or_else(|| models.first()) + .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: vec![session, weekly], + }) +} + +#[derive(Clone, Copy)] +enum Window { + Weekly, + Interval, +} + +fn quota_from_model(model: &Value, window: Window) -> Result { + let (remaining_key, end_key, start_key, id, label, default_period) = match window { + Window::Weekly => ( + "current_weekly_remaining_percent", + "weekly_end_time", + "weekly_start_time", + "weekly", + "Weekly", + WEEKLY_PERIOD_SECONDS, + ), + Window::Interval => ( + "current_interval_remaining_percent", + "end_time", + "start_time", + "session", + "Session", + DEFAULT_INTERVAL_PERIOD_SECONDS, + ), + }; + let remaining = number(model.get(remaining_key)) + .filter(|value| (0.0..=100.0).contains(value)) + .ok_or(MiniMaxError::InvalidResponse)?; + let used_percent = (100.0 - remaining).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(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 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_maps_session_and_weekly_for_general() { + 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); + + let weekly = &mapped.quotas[1]; + assert_eq!(weekly.used_percent, 0.0); + assert_eq!( + weekly.resets_at, + Utc.timestamp_millis_opt(1_786_320_000_000).single() + ); + // weekly window: 1786320000000 - 1785715200000 = 604_800_000 ms = 7 days + assert_eq!(weekly.period_seconds, 7 * 24 * 60 * 60); + } + + #[test] + fn falls_back_to_first_model_when_general_is_absent() { + let mapped = 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} + })) + .unwrap(); + let weekly = mapped.quotas.iter().find(|q| q.id == "weekly").unwrap(); + assert_eq!(weekly.used_percent, 60.0); + } + + #[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":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..23d4f58 --- /dev/null +++ b/src-tauri/src/providers/minimax/mod.rs @@ -0,0 +1,329 @@ +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://www.minimax.io/"), + ProviderLink::new("API Keys", "https://platform.minimaxi.com/"), + ], + 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"] + ); + } + + #[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 2bdc6ca..51377c7 100644 --- a/src-tauri/src/providers/mod.rs +++ b/src-tauri/src/providers/mod.rs @@ -10,6 +10,7 @@ mod detection; pub mod devin; pub mod grok; pub mod kimi; +pub mod minimax; mod log_usage; pub mod opencode; pub mod openrouter; @@ -158,7 +159,8 @@ pub trait UsageProvider: Send + Sync { #[cfg(test)] mod tests { use super::{ - antigravity, claude, codex, copilot, cursor, devin, grok, kimi, opencode, openrouter, + antigravity, claude, codex, copilot, cursor, devin, grok, kimi, minimax, opencode, + openrouter, remember_default_account, zai, ProviderError, }; use crate::models::ProviderErrorKind; @@ -283,12 +285,23 @@ mod tests { assert_eq!( links(kimi::definition()), [ - ("Dashboard".into(), "https://platform.moonshot.ai/".into()), + ("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.moonshot.ai/console/api-keys".into() + "https://platform.minimax.io/console/access".into() ), ] + ] ); } } diff --git a/src/assets/provider-icons/minimax.svg b/src/assets/provider-icons/minimax.svg new file mode 100644 index 0000000..46df602 --- /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 8cca71b..21b13a0 100644 --- a/src/lib/providerIconPaths.ts +++ b/src/lib/providerIconPaths.ts @@ -6,6 +6,7 @@ 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'; @@ -19,6 +20,7 @@ const visuals: Record = { 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 }, From 0d07106304980987db956b86985ab19d9cfc2e07 Mon Sep 17 00:00:00 2001 From: Jacky Lam Date: Sat, 8 Aug 2026 20:39:55 +0800 Subject: [PATCH 03/10] fix(menu-bar): render kimi/minimax logos and show Kimi usage as a percentage - Wire kimi + minimax SVG icons into the tray status-bar renderer's provider map so their logos show instead of the fallback circle. - Add elliptical-arc (A/a) support to the SVG path parser so the MiniMax logo, which uses arc commands, parses correctly. - Switch Kimi's session and weekly quotas from QuotaFormat::Count to QuotaFormat::Percent so the status bar shows 'NN%' like the other percentage-based providers (Moonshot's API still returns absolute counts internally; used_percent was already computed). --- src-tauri/src/menu_bar.rs | 210 ++++++++++++++++++++++++- src-tauri/src/providers/kimi/mapper.rs | 27 ++-- 2 files changed, 222 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/menu_bar.rs b/src-tauri/src/menu_bar.rs index f31fc79..89a8778 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, } } @@ -399,13 +405,177 @@ fn parse_svg_path(source: &str) -> Result { current = subpath_start; previous_cubic_control = None; } - _ => return Err("only M, L, H, V, C, S and Z path commands are supported".into()), + PathSegment::EllipticalArc { + abs, + rx, + ry, + x_axis_rotation, + large_arc, + sweep, + x, + y, + } => { + let (current_x, current_y) = + current.ok_or_else(|| "arc has no current point".to_owned())?; + let end = if abs { + (x as f32, y as f32) + } else { + (current_x + x as f32, current_y + y as f32) + }; + for (control1, control2, endpoint) in arc_to_cubics( + (current_x, current_y), + (rx as f32, ry as f32), + x_axis_rotation as f32, + large_arc, + sweep, + end, + ) { + builder.cubic_to( + control1.0, + control1.1, + control2.0, + control2.1, + endpoint.0, + endpoint.1, + ); + } + current = Some(end); + previous_cubic_control = None; + } + _ => { + return Err( + "only M, L, H, V, C, S, A and Z path commands are supported".into(), + ) + } } } } builder.finish().ok_or_else(|| "path is empty".into()) } +type Point = (f32, f32); + +/// A cubic Bézier segment expressed as two control points and an endpoint. +type BezierCubic = (Point, Point, Point); + +/// Convert an SVG elliptical arc into a sequence of cubic Bézier segments. +/// +/// Implements the endpoint-to-center parameterization from the SVG spec +/// (F.6.5) and subdivides the arc into sweeps of at most 90°, approximating +/// each with the standard cubic Bézier whose control points sit at +/// `4/3 * tan(angle / 4)` along the tangent. Falls back to a straight line +/// for degenerate arcs (no extent, or zero radii). +fn arc_to_cubics( + start: Point, + radii: Point, + x_axis_rotation: f32, + large_arc: bool, + sweep: bool, + end: Point, +) -> Vec { + let (x1, y1) = start; + let (x2, y2) = end; + let (mut rx, mut ry) = radii; + let phi = x_axis_rotation.to_radians(); + let cos_phi = phi.cos(); + let sin_phi = phi.sin(); + + // Degenerate arc: start equals end → no segments to emit. + if (x1 - x2).abs() <= f32::EPSILON && (y1 - y2).abs() <= f32::EPSILON { + return Vec::new(); + } + // Zero radii → the arc collapses to a straight line from start to end. + if rx.abs() <= f32::EPSILON || ry.abs() <= f32::EPSILON { + return vec![(start, end, end)]; + } + + rx = rx.abs(); + ry = ry.abs(); + + // Step 1: compute (x1', y1') — start point in the arc's coordinate frame. + let dx = (x1 - x2) / 2.0; + let dy = (y1 - y2) / 2.0; + let x1p = cos_phi * dx + sin_phi * dy; + let y1p = -sin_phi * dx + cos_phi * dy; + + // Correction of out-of-range radii (F.6.6.6). + let lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry); + if lambda > 1.0 { + let sqrt_lambda = lambda.sqrt(); + rx *= sqrt_lambda; + ry *= sqrt_lambda; + } + + // Step 2: compute (cx', cy'). + let rx_sq = rx * rx; + let ry_sq = ry * ry; + let x1p_sq = x1p * x1p; + let y1p_sq = y1p * y1p; + let denom = rx_sq * y1p_sq + ry_sq * x1p_sq; + let mut num = rx_sq * ry_sq - denom; + if num < 0.0 { + num = 0.0; + } + let factor = (num / denom).sqrt(); + let sign = if large_arc == sweep { -1.0 } else { 1.0 }; + let cxp = sign * factor * (rx * y1p) / ry; + let cyp = sign * factor * -(ry * x1p) / rx; + + // Step 3: translate (cx', cy') back to the user frame. + let cx = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2.0; + let cy = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2.0; + + // Step 4: compute the angular extent. + let theta = angle((1.0, 0.0), ((x1p - cxp) / rx, (y1p - cyp) / ry)); + let delta_theta = angle( + ((x1p - cxp) / rx, (y1p - cyp) / ry), + ((-x1p - cxp) / rx, (-y1p - cyp) / ry), + ); + let delta_theta = if !sweep && delta_theta > 0.0 { + delta_theta - 2.0 * std::f32::consts::PI + } else if sweep && delta_theta < 0.0 { + delta_theta + 2.0 * std::f32::consts::PI + } else { + delta_theta + }; + + // Subdivide into ≤90° segments and emit a cubic Bézier per segment. + let segments = ((delta_theta.abs() / std::f32::consts::FRAC_PI_2).ceil() as usize).max(1); + let segment_angle = delta_theta / segments as f32; + let alpha = (4.0 / 3.0) * (segment_angle / 4.0).tan(); + + // Map a point on the unit circle (in arc-local space) to user space. + let to_user = |unit_x: f32, unit_y: f32| -> Point { + ( + cos_phi * (unit_x * rx) - sin_phi * (unit_y * ry) + cx, + sin_phi * (unit_x * rx) + cos_phi * (unit_y * ry) + cy, + ) + }; + + let mut result = Vec::with_capacity(segments); + let mut current_theta = theta; + for _ in 0..segments { + let next_theta = current_theta + segment_angle; + let (cos_c, sin_c) = (current_theta.cos(), current_theta.sin()); + let (cos_n, sin_n) = (next_theta.cos(), next_theta.sin()); + + result.push(( + to_user(cos_c - alpha * sin_c, sin_c + alpha * cos_c), + to_user(cos_n + alpha * sin_n, sin_n - alpha * cos_c), + to_user(cos_n, sin_n), + )); + current_theta = next_theta; + } + result +} + +/// Signed angle from `a` to `b`, in the range (-π, π]. +fn angle(a: Point, b: Point) -> f32 { + let dot = a.0 * b.0 + a.1 * b.1; + let cross = a.0 * b.1 - a.1 * b.0; + cross.atan2(dot) +} + fn render_bar_rgba(fractions: &[f64]) -> Vec { let size = ICON_POINTS; let mut pixmap = Pixmap::new(ICON_SIZE, ICON_SIZE).expect("menu bar icon dimensions are valid"); @@ -567,8 +737,9 @@ fn fill_rounded_bar( #[cfg(test)] mod tests { use super::{ - bar_fill, bar_icon, parse_svg_path, provider_path, render_bar_rgba, render_text_strip, - text_icon, visual_bar_fraction, TextGroup, ICON_SIZE, MAX_BARS, TEXT_HEIGHT, + arc_to_cubics, bar_fill, bar_icon, parse_svg_path, provider_path, render_bar_rgba, + render_text_strip, text_icon, visual_bar_fraction, MINIMAX_ICON, TextGroup, ICON_SIZE, + MAX_BARS, TEXT_HEIGHT, }; fn text_group(provider_id: &str, values: &[&str]) -> TextGroup { @@ -591,6 +762,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); @@ -719,4 +892,35 @@ mod tests { let icon = bar_icon(&[0.5]); assert_eq!((icon.width(), icon.height()), (36, 36)); } + + #[test] + fn elliptical_arc_subdivides_into_visible_cubic_segments() { + // A semicircle from (0, 0) to (2, 0) must produce a non-empty chain of + // cubic segments whose last endpoint lands on (2, 0). + let segments = arc_to_cubics((0.0, 0.0), (1.0, 1.0), 0.0, false, true, (2.0, 0.0)); + assert!(!segments.is_empty()); + let (_, _, last) = segments.last().unwrap(); + assert!((last.0 - 2.0).abs() < 1e-3); + assert!(last.1.abs() < 1e-3); + } + + #[test] + fn degenerate_arcs_collapse_to_no_or_single_segments() { + // Identical endpoints emit nothing. + assert!(arc_to_cubics((1.0, 1.0), (5.0, 5.0), 0.0, true, true, (1.0, 1.0)).is_empty()); + // Zero radii collapse to a single straight-line cubic. + let zero = arc_to_cubics((0.0, 0.0), (0.0, 0.0), 0.0, false, false, (3.0, 4.0)); + assert_eq!(zero.len(), 1); + let (_, _, end) = zero[0]; + assert_eq!(end, (3.0, 4.0)); + } + + #[test] + fn minimax_mark_parses_despite_arc_commands() { + // The bundled MiniMax logo uses elliptical-arc path data; it must parse + // to a real path with extent rather than panicking or returning empty. + let path = parse_svg_path(MINIMAX_ICON).expect("minimax SVG should parse"); + assert!(path.bounds().width() > 0.0); + assert!(path.bounds().height() > 0.0); + } } diff --git a/src-tauri/src/providers/kimi/mapper.rs b/src-tauri/src/providers/kimi/mapper.rs index 7987242..9451fdd 100644 --- a/src-tauri/src/providers/kimi/mapper.rs +++ b/src-tauri/src/providers/kimi/mapper.rs @@ -88,10 +88,10 @@ fn weekly_quota(body: &Value) -> Result { used_percent, resets_at: iso_time(usage.get("resetTime")), period_seconds: WEEKLY_PERIOD_SECONDS, - format: QuotaFormat::Count, - used_value: Some(used), - limit_value: Some(limit), - unit: Some("uses".into()), + format: QuotaFormat::Percent, + used_value: None, + limit_value: None, + unit: None, estimated: false, source_note: None, }) @@ -124,7 +124,7 @@ fn session_quota(body: &Value) -> Result, KimiError> { let period_seconds = entry .get("window") .and_then(|window| number(window.get("duration")).filter(|value| *value > 0.0)) - .and_then(|duration| { + .map(|duration| { let time_unit = entry .get("window") .and_then(|window| window.get("timeUnit")) @@ -134,7 +134,7 @@ fn session_quota(body: &Value) -> Result, KimiError> { Some("TIME_UNIT_SECOND") => 1.0, _ => 60.0, }; - Some((duration * factor) as u64) + (duration * factor) as u64 }) .unwrap_or(DEFAULT_WINDOW_PERIOD_SECONDS); @@ -144,10 +144,10 @@ fn session_quota(body: &Value) -> Result, KimiError> { used_percent, resets_at: iso_time(detail.get("resetTime")), period_seconds, - format: QuotaFormat::Count, - used_value: Some(used), - limit_value: Some(limit), - unit: Some("uses".into()), + format: QuotaFormat::Percent, + used_value: None, + limit_value: None, + unit: None, estimated: false, source_note: None, })) @@ -176,6 +176,7 @@ mod tests { use serde_json::{json, Value}; use super::{map_usage, plan_name}; + use crate::models::QuotaFormat; fn captured() -> Value { serde_json::json!({ @@ -209,8 +210,10 @@ mod tests { let weekly = &mapped.quotas[1]; assert_eq!(weekly.used_percent, 25.0); - assert_eq!(weekly.used_value, Some(25.0)); - assert_eq!(weekly.limit_value, Some(100.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()), From 7be450e03660f1ba5cb461deae3be25928bc418a Mon Sep 17 00:00:00 2001 From: Jacky Lam Date: Sun, 9 Aug 2026 07:11:32 +0800 Subject: [PATCH 04/10] fix: address Kimi and MiniMax provider review --- README.md | 10 ++- docs/providers/kimi.md | 19 ++++ docs/providers/minimax.md | 15 ++++ src-tauri/src/providers/kimi/auth.rs | 4 +- src-tauri/src/providers/kimi/mapper.rs | 72 ++++++++++----- src-tauri/src/providers/kimi/mod.rs | 39 +++++--- src-tauri/src/providers/minimax/auth.rs | 8 +- src-tauri/src/providers/minimax/client.rs | 4 +- src-tauri/src/providers/minimax/mapper.rs | 104 +++++++++++++++------- src-tauri/src/providers/minimax/mod.rs | 25 ++++-- src-tauri/src/providers/mod.rs | 3 +- 11 files changed, 217 insertions(+), 86 deletions(-) create mode 100644 docs/providers/kimi.md create mode 100644 docs/providers/minimax.md 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..abc4f3b --- /dev/null +++ b/docs/providers/kimi.md @@ -0,0 +1,19 @@ +# Kimi + +OpenQuota tracks the Session (rolling five-hour) and Weekly quotas of a Kimi Code membership. + +## 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..faae94a --- /dev/null +++ b/docs/providers/minimax.md @@ -0,0 +1,15 @@ +# MiniMax + +OpenQuota tracks the Session and Weekly quotas of a MiniMax Token Plan. + +## 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/src-tauri/src/providers/kimi/auth.rs b/src-tauri/src/providers/kimi/auth.rs index 7752b12..e353b16 100644 --- a/src-tauri/src/providers/kimi/auth.rs +++ b/src-tauri/src/providers/kimi/auth.rs @@ -34,7 +34,9 @@ impl KimiAuthStore { } pub fn status(&self) -> Result { - self.store.status().map_err(|_| KimiError::CredentialStorage) + self.store + .status() + .map_err(|_| KimiError::CredentialStorage) } pub fn save(&self, value: &str) -> Result<(), KimiError> { diff --git a/src-tauri/src/providers/kimi/mapper.rs b/src-tauri/src/providers/kimi/mapper.rs index 9451fdd..4645bbf 100644 --- a/src-tauri/src/providers/kimi/mapper.rs +++ b/src-tauri/src/providers/kimi/mapper.rs @@ -101,7 +101,11 @@ fn session_quota(body: &Value) -> Result, KimiError> { let Some(entry) = body .get("limits") .and_then(Value::as_array) - .and_then(|limits| limits.first()) + .and_then(|limits| { + limits + .iter() + .find(|entry| window_period_seconds(entry) == Some(DEFAULT_WINDOW_PERIOD_SECONDS)) + }) else { return Ok(None); }; @@ -121,22 +125,7 @@ fn session_quota(body: &Value) -> Result, KimiError> { } else { 0.0 }; - let period_seconds = entry - .get("window") - .and_then(|window| number(window.get("duration")).filter(|value| *value > 0.0)) - .map(|duration| { - let time_unit = entry - .get("window") - .and_then(|window| window.get("timeUnit")) - .and_then(Value::as_str); - let factor = match time_unit { - Some("TIME_UNIT_HOUR") => 3600.0, - Some("TIME_UNIT_SECOND") => 1.0, - _ => 60.0, - }; - (duration * factor) as u64 - }) - .unwrap_or(DEFAULT_WINDOW_PERIOD_SECONDS); + let period_seconds = window_period_seconds(entry).unwrap_or(DEFAULT_WINDOW_PERIOD_SECONDS); Ok(Some(QuotaWindow { id: "session".into(), @@ -153,6 +142,18 @@ fn session_quota(body: &Value) -> Result, KimiError> { })) } +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| { @@ -175,13 +176,12 @@ mod tests { use chrono::{TimeZone, Utc}; use serde_json::{json, Value}; - use super::{map_usage, plan_name}; + use super::{map_usage, plan_name, DEFAULT_WINDOW_PERIOD_SECONDS}; use crate::models::QuotaFormat; fn captured() -> Value { serde_json::json!({ - "user": {"userId":"d63jf5am52tc032su6e0","region":"REGION_OVERSEA", - "membership":{"level":"LEVEL_BASIC"},"businessId":""}, + "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", @@ -243,10 +243,38 @@ mod tests { 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 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":{"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 index fe9ad82..561a061 100644 --- a/src-tauri/src/providers/kimi/mod.rs +++ b/src-tauri/src/providers/kimi/mod.rs @@ -29,8 +29,8 @@ pub(crate) fn definition() -> ProviderDefinition { fallback_enabled: false, local_usage_source_note: None, links: vec![ - ProviderLink::new("Dashboard", "https://platform.moonshot.ai/"), - ProviderLink::new("API Keys", "https://platform.moonshot.ai/console/api-keys"), + ProviderLink::new("Dashboard", "https://www.kimi.com/code/console"), + ProviderLink::new("API Keys", "https://www.kimi.com/code/console"), ], metrics: vec![ MetricDefinition::quota( @@ -79,7 +79,7 @@ impl From for ProviderError { KimiError::MissingKey | KimiError::InvalidKey => ProviderErrorKind::Authentication, KimiError::ConnectionFailed => ProviderErrorKind::Network, KimiError::RequestFailed(429) => ProviderErrorKind::RateLimited, - KimiError::RequestFailed(401 | 403) => ProviderErrorKind::Authentication, + KimiError::RequestFailed(401) => ProviderErrorKind::Authentication, KimiError::RequestFailed(_) | KimiError::InvalidResponse => { ProviderErrorKind::InvalidResponse } @@ -162,10 +162,7 @@ fn required_response( response: Result, ) -> Result { let response = response?; - if matches!( - response.status, - StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN - ) { + if matches!(response.status, StatusCode::UNAUTHORIZED) { return Err(KimiError::InvalidKey); } if !response.status.is_success() { @@ -264,16 +261,22 @@ mod tests { 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)), + KimiClient::for_test( + &test_http::serve_once(200, &[], QUOTA_BODY), + Duration::from_secs(1), + ), ) .refresh() .unwrap_err(); assert_eq!(missing.kind(), ProviderErrorKind::Authentication); - for status in [401, 403] { + for status in [401] { let invalid = KimiProvider::with_dependencies( auth(Some("bad-key")), - KimiClient::for_test(&test_http::serve_once(status, &[], "{}"), Duration::from_secs(1)), + KimiClient::for_test( + &test_http::serve_once(status, &[], "{}"), + Duration::from_secs(1), + ), ) .refresh() .unwrap_err(); @@ -281,9 +284,23 @@ mod tests { 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::InvalidResponse); + let rate_limited = KimiProvider::with_dependencies( auth(Some("secret")), - KimiClient::for_test(&test_http::serve_once(429, &[], "{}"), Duration::from_secs(1)), + KimiClient::for_test( + &test_http::serve_once(429, &[], "{}"), + Duration::from_secs(1), + ), ) .refresh() .unwrap_err(); diff --git a/src-tauri/src/providers/minimax/auth.rs b/src-tauri/src/providers/minimax/auth.rs index 934051b..d690436 100644 --- a/src-tauri/src/providers/minimax/auth.rs +++ b/src-tauri/src/providers/minimax/auth.rs @@ -26,7 +26,9 @@ impl MiniMaxAuthStore { } pub fn load(&self) -> Result, MiniMaxError> { - self.store.load().map_err(|_| MiniMaxError::CredentialStorage) + self.store + .load() + .map_err(|_| MiniMaxError::CredentialStorage) } pub fn has_local_credentials(&self) -> bool { @@ -34,7 +36,9 @@ impl MiniMaxAuthStore { } pub fn status(&self) -> Result { - self.store.status().map_err(|_| MiniMaxError::CredentialStorage) + self.store + .status() + .map_err(|_| MiniMaxError::CredentialStorage) } pub fn save(&self, value: &str) -> Result<(), MiniMaxError> { diff --git a/src-tauri/src/providers/minimax/client.rs b/src-tauri/src/providers/minimax/client.rs index 9a85991..5c5d7c5 100644 --- a/src-tauri/src/providers/minimax/client.rs +++ b/src-tauri/src/providers/minimax/client.rs @@ -56,9 +56,7 @@ impl MiniMaxClient { status.as_u16(), started.elapsed().as_millis() ); - let text = response - .text() - .map_err(|_| MiniMaxError::InvalidResponse)?; + let text = response.text().map_err(|_| MiniMaxError::InvalidResponse)?; let body = serde_json::from_str(&text).unwrap_or(Value::Null); Ok(EndpointResponse { status, body }) } diff --git a/src-tauri/src/providers/minimax/mapper.rs b/src-tauri/src/providers/minimax/mapper.rs index 9a4d5e4..1762fcf 100644 --- a/src-tauri/src/providers/minimax/mapper.rs +++ b/src-tauri/src/providers/minimax/mapper.rs @@ -33,7 +33,9 @@ pub fn api_error_message(body: &Value) -> Option { 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 plan") || normalized.contains("token plan") || normalized.contains("subscribe") + if normalized.contains("no plan") + || normalized.contains("token plan") + || normalized.contains("subscribe") { return Err(MiniMaxError::NoTokenPlan); } @@ -47,14 +49,13 @@ pub fn map_usage(body: &Value) -> Result { let general = models .iter() .find(|model| model.get("model_name").and_then(Value::as_str) == Some("general")) - .or_else(|| models.first()) .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: vec![session, weekly], + quotas: [session, weekly].into_iter().flatten().collect(), }) } @@ -64,10 +65,11 @@ enum Window { Interval, } -fn quota_from_model(model: &Value, window: Window) -> Result { - let (remaining_key, end_key, start_key, id, label, default_period) = match window { +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", @@ -76,6 +78,7 @@ fn quota_from_model(model: &Value, window: Window) -> Result ( "current_interval_remaining_percent", + "current_interval_status", "end_time", "start_time", "session", @@ -83,10 +86,20 @@ fn quota_from_model(model: &Value, window: Window) -> Result Result Result Option> { @@ -157,7 +170,7 @@ mod tests { } #[test] - fn captured_payload_maps_session_and_weekly_for_general() { + fn captured_payload_omits_an_unlimited_weekly_window() { let mapped = map_usage(&captured()).unwrap(); assert_eq!(mapped.plan.as_deref(), Some("Token Plan")); @@ -167,7 +180,7 @@ mod tests { .iter() .map(|quota| quota.id.as_str()) .collect::>(), - ["session", "weekly"] + ["session"] ); let session = &mapped.quotas[0]; @@ -177,31 +190,50 @@ mod tests { 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) + )); + } - let weekly = &mapped.quotas[1]; + #[test] + fn weekly_boost_extends_the_allowance() { + let mut body = captured(); + body["model_remains"][0]["current_weekly_status"] = json!(2); + body["model_remains"][0]["weekly_boost_permille"] = json!(500); + 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); - assert_eq!( - weekly.resets_at, - Utc.timestamp_millis_opt(1_786_320_000_000).single() - ); - // weekly window: 1786320000000 - 1785715200000 = 604_800_000 ms = 7 days - assert_eq!(weekly.period_seconds, 7 * 24 * 60 * 60); } #[test] - fn falls_back_to_first_model_when_general_is_absent() { - let mapped = 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} - })) - .unwrap(); - let weekly = mapped.quotas.iter().find(|q| q.id == "weekly").unwrap(); - assert_eq!(weekly.used_percent, 60.0); + fn unlimited_windows_are_omitted() { + let mapped = map_usage(&captured()).unwrap(); + assert_eq!( + mapped + .quotas + .iter() + .map(|quota| quota.id.as_str()) + .collect::>(), + ["session"] + ); } #[test] @@ -211,13 +243,17 @@ mod tests { None ); assert_eq!( - api_error_message(&json!({"base_resp":{"status_code":1001,"status_msg":"no token plan"}})) - .as_deref(), + 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"}})), + map_usage( + &json!({"base_resp":{"status_code":1001,"status_msg":"user has no token plan"}}) + ), Err(MiniMaxError::NoTokenPlan) )); assert!(matches!( diff --git a/src-tauri/src/providers/minimax/mod.rs b/src-tauri/src/providers/minimax/mod.rs index 23d4f58..f1a14e1 100644 --- a/src-tauri/src/providers/minimax/mod.rs +++ b/src-tauri/src/providers/minimax/mod.rs @@ -29,8 +29,8 @@ pub(crate) fn definition() -> ProviderDefinition { fallback_enabled: false, local_usage_source_note: None, links: vec![ - ProviderLink::new("Dashboard", "https://www.minimax.io/"), - ProviderLink::new("API Keys", "https://platform.minimaxi.com/"), + ProviderLink::new("Dashboard", "https://platform.minimax.io/console/plan"), + ProviderLink::new("API Keys", "https://platform.minimax.io/console/access"), ], metrics: vec![ MetricDefinition::quota( @@ -78,7 +78,9 @@ pub(super) enum MiniMaxError { impl From for ProviderError { fn from(error: MiniMaxError) -> Self { let kind = match error { - MiniMaxError::MissingKey | MiniMaxError::InvalidKey => ProviderErrorKind::Authentication, + MiniMaxError::MissingKey | MiniMaxError::InvalidKey => { + ProviderErrorKind::Authentication + } MiniMaxError::ConnectionFailed => ProviderErrorKind::Network, MiniMaxError::RequestFailed(429) => ProviderErrorKind::RateLimited, MiniMaxError::RequestFailed(401 | 403) => ProviderErrorKind::Authentication, @@ -263,7 +265,7 @@ mod tests { .iter() .map(|quota| quota.id.as_str()) .collect::>(), - ["session", "weekly"] + ["session"] ); } @@ -271,7 +273,10 @@ mod tests { 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)), + MiniMaxClient::for_test( + &test_http::serve_once(200, &[], REMAINS_BODY), + Duration::from_secs(1), + ), ) .refresh() .unwrap_err(); @@ -280,7 +285,10 @@ mod tests { 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)), + MiniMaxClient::for_test( + &test_http::serve_once(status, &[], "{}"), + Duration::from_secs(1), + ), ) .refresh() .unwrap_err(); @@ -290,7 +298,10 @@ mod tests { let rate_limited = MiniMaxProvider::with_dependencies( auth(Some("secret")), - MiniMaxClient::for_test(&test_http::serve_once(429, &[], "{}"), Duration::from_secs(1)), + MiniMaxClient::for_test( + &test_http::serve_once(429, &[], "{}"), + Duration::from_secs(1), + ), ) .refresh() .unwrap_err(); diff --git a/src-tauri/src/providers/mod.rs b/src-tauri/src/providers/mod.rs index 51377c7..6205ebd 100644 --- a/src-tauri/src/providers/mod.rs +++ b/src-tauri/src/providers/mod.rs @@ -10,8 +10,8 @@ mod detection; pub mod devin; pub mod grok; pub mod kimi; -pub mod minimax; mod log_usage; +pub mod minimax; pub mod opencode; pub mod openrouter; mod pi_usage; @@ -301,7 +301,6 @@ mod tests { "https://platform.minimax.io/console/access".into() ), ] - ] ); } } From 652e33ce9454f20db7a7d2d95ee3caf63eb248ce Mon Sep 17 00:00:00 2001 From: Jacky Lam Date: Sun, 9 Aug 2026 07:11:32 +0800 Subject: [PATCH 05/10] refactor: move status-bar icon rendering out of provider PR --- src-tauri/src/menu_bar.rs | 210 +------------------------------------- 1 file changed, 3 insertions(+), 207 deletions(-) diff --git a/src-tauri/src/menu_bar.rs b/src-tauri/src/menu_bar.rs index 89a8778..f31fc79 100644 --- a/src-tauri/src/menu_bar.rs +++ b/src-tauri/src/menu_bar.rs @@ -32,8 +32,6 @@ 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 { @@ -279,8 +277,6 @@ 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)), @@ -292,8 +288,6 @@ 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, } } @@ -405,177 +399,13 @@ fn parse_svg_path(source: &str) -> Result { current = subpath_start; previous_cubic_control = None; } - PathSegment::EllipticalArc { - abs, - rx, - ry, - x_axis_rotation, - large_arc, - sweep, - x, - y, - } => { - let (current_x, current_y) = - current.ok_or_else(|| "arc has no current point".to_owned())?; - let end = if abs { - (x as f32, y as f32) - } else { - (current_x + x as f32, current_y + y as f32) - }; - for (control1, control2, endpoint) in arc_to_cubics( - (current_x, current_y), - (rx as f32, ry as f32), - x_axis_rotation as f32, - large_arc, - sweep, - end, - ) { - builder.cubic_to( - control1.0, - control1.1, - control2.0, - control2.1, - endpoint.0, - endpoint.1, - ); - } - current = Some(end); - previous_cubic_control = None; - } - _ => { - return Err( - "only M, L, H, V, C, S, A and Z path commands are supported".into(), - ) - } + _ => return Err("only M, L, H, V, C, S and Z path commands are supported".into()), } } } builder.finish().ok_or_else(|| "path is empty".into()) } -type Point = (f32, f32); - -/// A cubic Bézier segment expressed as two control points and an endpoint. -type BezierCubic = (Point, Point, Point); - -/// Convert an SVG elliptical arc into a sequence of cubic Bézier segments. -/// -/// Implements the endpoint-to-center parameterization from the SVG spec -/// (F.6.5) and subdivides the arc into sweeps of at most 90°, approximating -/// each with the standard cubic Bézier whose control points sit at -/// `4/3 * tan(angle / 4)` along the tangent. Falls back to a straight line -/// for degenerate arcs (no extent, or zero radii). -fn arc_to_cubics( - start: Point, - radii: Point, - x_axis_rotation: f32, - large_arc: bool, - sweep: bool, - end: Point, -) -> Vec { - let (x1, y1) = start; - let (x2, y2) = end; - let (mut rx, mut ry) = radii; - let phi = x_axis_rotation.to_radians(); - let cos_phi = phi.cos(); - let sin_phi = phi.sin(); - - // Degenerate arc: start equals end → no segments to emit. - if (x1 - x2).abs() <= f32::EPSILON && (y1 - y2).abs() <= f32::EPSILON { - return Vec::new(); - } - // Zero radii → the arc collapses to a straight line from start to end. - if rx.abs() <= f32::EPSILON || ry.abs() <= f32::EPSILON { - return vec![(start, end, end)]; - } - - rx = rx.abs(); - ry = ry.abs(); - - // Step 1: compute (x1', y1') — start point in the arc's coordinate frame. - let dx = (x1 - x2) / 2.0; - let dy = (y1 - y2) / 2.0; - let x1p = cos_phi * dx + sin_phi * dy; - let y1p = -sin_phi * dx + cos_phi * dy; - - // Correction of out-of-range radii (F.6.6.6). - let lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry); - if lambda > 1.0 { - let sqrt_lambda = lambda.sqrt(); - rx *= sqrt_lambda; - ry *= sqrt_lambda; - } - - // Step 2: compute (cx', cy'). - let rx_sq = rx * rx; - let ry_sq = ry * ry; - let x1p_sq = x1p * x1p; - let y1p_sq = y1p * y1p; - let denom = rx_sq * y1p_sq + ry_sq * x1p_sq; - let mut num = rx_sq * ry_sq - denom; - if num < 0.0 { - num = 0.0; - } - let factor = (num / denom).sqrt(); - let sign = if large_arc == sweep { -1.0 } else { 1.0 }; - let cxp = sign * factor * (rx * y1p) / ry; - let cyp = sign * factor * -(ry * x1p) / rx; - - // Step 3: translate (cx', cy') back to the user frame. - let cx = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2.0; - let cy = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2.0; - - // Step 4: compute the angular extent. - let theta = angle((1.0, 0.0), ((x1p - cxp) / rx, (y1p - cyp) / ry)); - let delta_theta = angle( - ((x1p - cxp) / rx, (y1p - cyp) / ry), - ((-x1p - cxp) / rx, (-y1p - cyp) / ry), - ); - let delta_theta = if !sweep && delta_theta > 0.0 { - delta_theta - 2.0 * std::f32::consts::PI - } else if sweep && delta_theta < 0.0 { - delta_theta + 2.0 * std::f32::consts::PI - } else { - delta_theta - }; - - // Subdivide into ≤90° segments and emit a cubic Bézier per segment. - let segments = ((delta_theta.abs() / std::f32::consts::FRAC_PI_2).ceil() as usize).max(1); - let segment_angle = delta_theta / segments as f32; - let alpha = (4.0 / 3.0) * (segment_angle / 4.0).tan(); - - // Map a point on the unit circle (in arc-local space) to user space. - let to_user = |unit_x: f32, unit_y: f32| -> Point { - ( - cos_phi * (unit_x * rx) - sin_phi * (unit_y * ry) + cx, - sin_phi * (unit_x * rx) + cos_phi * (unit_y * ry) + cy, - ) - }; - - let mut result = Vec::with_capacity(segments); - let mut current_theta = theta; - for _ in 0..segments { - let next_theta = current_theta + segment_angle; - let (cos_c, sin_c) = (current_theta.cos(), current_theta.sin()); - let (cos_n, sin_n) = (next_theta.cos(), next_theta.sin()); - - result.push(( - to_user(cos_c - alpha * sin_c, sin_c + alpha * cos_c), - to_user(cos_n + alpha * sin_n, sin_n - alpha * cos_c), - to_user(cos_n, sin_n), - )); - current_theta = next_theta; - } - result -} - -/// Signed angle from `a` to `b`, in the range (-π, π]. -fn angle(a: Point, b: Point) -> f32 { - let dot = a.0 * b.0 + a.1 * b.1; - let cross = a.0 * b.1 - a.1 * b.0; - cross.atan2(dot) -} - fn render_bar_rgba(fractions: &[f64]) -> Vec { let size = ICON_POINTS; let mut pixmap = Pixmap::new(ICON_SIZE, ICON_SIZE).expect("menu bar icon dimensions are valid"); @@ -737,9 +567,8 @@ fn fill_rounded_bar( #[cfg(test)] mod tests { use super::{ - arc_to_cubics, bar_fill, bar_icon, parse_svg_path, provider_path, render_bar_rgba, - render_text_strip, text_icon, visual_bar_fraction, MINIMAX_ICON, TextGroup, ICON_SIZE, - MAX_BARS, TEXT_HEIGHT, + bar_fill, bar_icon, parse_svg_path, provider_path, render_bar_rgba, render_text_strip, + text_icon, visual_bar_fraction, TextGroup, ICON_SIZE, MAX_BARS, TEXT_HEIGHT, }; fn text_group(provider_id: &str, values: &[&str]) -> TextGroup { @@ -762,8 +591,6 @@ mod tests { "opencode", "openrouter", "zai", - "kimi", - "minimax", ] { let path = provider_path(provider).expect("known provider mark should exist"); assert!(path.bounds().width() > 0.0); @@ -892,35 +719,4 @@ mod tests { let icon = bar_icon(&[0.5]); assert_eq!((icon.width(), icon.height()), (36, 36)); } - - #[test] - fn elliptical_arc_subdivides_into_visible_cubic_segments() { - // A semicircle from (0, 0) to (2, 0) must produce a non-empty chain of - // cubic segments whose last endpoint lands on (2, 0). - let segments = arc_to_cubics((0.0, 0.0), (1.0, 1.0), 0.0, false, true, (2.0, 0.0)); - assert!(!segments.is_empty()); - let (_, _, last) = segments.last().unwrap(); - assert!((last.0 - 2.0).abs() < 1e-3); - assert!(last.1.abs() < 1e-3); - } - - #[test] - fn degenerate_arcs_collapse_to_no_or_single_segments() { - // Identical endpoints emit nothing. - assert!(arc_to_cubics((1.0, 1.0), (5.0, 5.0), 0.0, true, true, (1.0, 1.0)).is_empty()); - // Zero radii collapse to a single straight-line cubic. - let zero = arc_to_cubics((0.0, 0.0), (0.0, 0.0), 0.0, false, false, (3.0, 4.0)); - assert_eq!(zero.len(), 1); - let (_, _, end) = zero[0]; - assert_eq!(end, (3.0, 4.0)); - } - - #[test] - fn minimax_mark_parses_despite_arc_commands() { - // The bundled MiniMax logo uses elliptical-arc path data; it must parse - // to a real path with extent rather than panicking or returning empty. - let path = parse_svg_path(MINIMAX_ICON).expect("minimax SVG should parse"); - assert!(path.bounds().width() > 0.0); - assert!(path.bounds().height() > 0.0); - } } From 477a763c0195b13f8692b11b747822d2c55787b9 Mon Sep 17 00:00:00 2001 From: Jacky Lam Date: Sun, 9 Aug 2026 07:17:13 +0800 Subject: [PATCH 06/10] feat: render Kimi and MiniMax status-bar icons --- src-tauri/src/menu_bar.rs | 8 ++++++++ src/assets/provider-icons/minimax.svg | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) 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/assets/provider-icons/minimax.svg b/src/assets/provider-icons/minimax.svg index 46df602..15a3868 100644 --- a/src/assets/provider-icons/minimax.svg +++ b/src/assets/provider-icons/minimax.svg @@ -1,3 +1,3 @@ - + From 21db56fde773977c4a6e22e71d999e2883e193f3 Mon Sep 17 00:00:00 2001 From: Jacky Lam Date: Sun, 9 Aug 2026 07:42:14 +0800 Subject: [PATCH 07/10] fix: classify Kimi access denials correctly --- src-tauri/src/providers/kimi/mod.rs | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/providers/kimi/mod.rs b/src-tauri/src/providers/kimi/mod.rs index 561a061..5ece087 100644 --- a/src-tauri/src/providers/kimi/mod.rs +++ b/src-tauri/src/providers/kimi/mod.rs @@ -61,7 +61,7 @@ pub(crate) fn definition() -> ProviderDefinition { 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 on platform.moonshot.ai.")] + #[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, @@ -80,6 +80,7 @@ impl From for ProviderError { 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 } @@ -270,19 +271,17 @@ mod tests { .unwrap_err(); assert_eq!(missing.kind(), ProviderErrorKind::Authentication); - for status in [401] { - let invalid = KimiProvider::with_dependencies( - auth(Some("bad-key")), - KimiClient::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 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")), @@ -293,7 +292,7 @@ mod tests { ) .refresh() .unwrap_err(); - assert_eq!(forbidden.kind(), ProviderErrorKind::InvalidResponse); + assert_eq!(forbidden.kind(), ProviderErrorKind::Permission); let rate_limited = KimiProvider::with_dependencies( auth(Some("secret")), From 945283e9fbf2258ad3289c5b33e691225addfbcf Mon Sep 17 00:00:00 2001 From: Jacky Lam Date: Mon, 10 Aug 2026 00:26:17 +0800 Subject: [PATCH 08/10] fix: handle provider quota states independently --- src-tauri/src/providers/kimi/mapper.rs | 43 ++++++++++-- src-tauri/src/providers/minimax/auth.rs | 2 +- src-tauri/src/providers/minimax/mapper.rs | 81 +++++++++++++++++++---- 3 files changed, 107 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/providers/kimi/mapper.rs b/src-tauri/src/providers/kimi/mapper.rs index 4645bbf..82553b1 100644 --- a/src-tauri/src/providers/kimi/mapper.rs +++ b/src-tauri/src/providers/kimi/mapper.rs @@ -57,13 +57,16 @@ fn title_case(value: &str) -> String { 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 weekly = weekly_quota(body)?; let mut quotas = Vec::new(); - if let Some(session) = session_quota(body)? { + if let Ok(Some(session)) = session_quota(body) { quotas.push(session); } - quotas.push(weekly); - Ok(quotas) + 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 { @@ -265,6 +268,38 @@ mod tests { 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!( diff --git a/src-tauri/src/providers/minimax/auth.rs b/src-tauri/src/providers/minimax/auth.rs index d690436..f96bae7 100644 --- a/src-tauri/src/providers/minimax/auth.rs +++ b/src-tauri/src/providers/minimax/auth.rs @@ -6,7 +6,7 @@ use crate::{ use super::MiniMaxError; const CONFIG_PATHS: &[&str] = &["~/.config/openquota/minimax.json"]; -const ENVIRONMENT_NAMES: &[&str] = &["MINIMAX_API_KEY", "MINIMAXI_API_KEY"]; +const ENVIRONMENT_NAMES: &[&str] = &["MINIMAX_API_KEY"]; #[derive(Clone)] pub struct MiniMaxAuthStore { diff --git a/src-tauri/src/providers/minimax/mapper.rs b/src-tauri/src/providers/minimax/mapper.rs index 1762fcf..50cad61 100644 --- a/src-tauri/src/providers/minimax/mapper.rs +++ b/src-tauri/src/providers/minimax/mapper.rs @@ -33,10 +33,7 @@ pub fn api_error_message(body: &Value) -> Option { 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 plan") - || normalized.contains("token plan") - || normalized.contains("subscribe") - { + if normalized.contains("no token plan") { return Err(MiniMaxError::NoTokenPlan); } return Err(MiniMaxError::InvalidResponse); @@ -86,12 +83,16 @@ fn quota_from_model(model: &Value, window: Window) -> Result DEFAULT_INTERVAL_PERIOD_SECONDS, ), }; - let status = number(model.get(status_key)).ok_or(MiniMaxError::InvalidResponse)? as u16; - if status == 3 { - return Ok(None); + 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(0.0) / 10.0 + 100.0 * number(model.get("weekly_boost_permille")).unwrap_or(1000.0) / 1000.0 } else { 100.0 }; @@ -124,6 +125,27 @@ fn quota_from_model(model: &Value, window: Window) -> Result })) } +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; @@ -170,7 +192,7 @@ mod tests { } #[test] - fn captured_payload_omits_an_unlimited_weekly_window() { + fn captured_payload_includes_an_unlimited_weekly_window() { let mapped = map_usage(&captured()).unwrap(); assert_eq!(mapped.plan.as_deref(), Some("Token Plan")); @@ -180,7 +202,7 @@ mod tests { .iter() .map(|quota| quota.id.as_str()) .collect::>(), - ["session"] + ["session", "weekly"] ); let session = &mapped.quotas[0]; @@ -209,10 +231,10 @@ mod tests { } #[test] - fn weekly_boost_extends_the_allowance() { + 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!(500); + 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 @@ -224,7 +246,26 @@ mod tests { } #[test] - fn unlimited_windows_are_omitted() { + 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 @@ -232,8 +273,14 @@ mod tests { .iter() .map(|quota| quota.id.as_str()) .collect::>(), - ["session"] + ["session", "weekly"] ); + let weekly = mapped + .quotas + .iter() + .find(|quota| quota.id == "weekly") + .unwrap(); + assert_eq!(weekly.label, "Weekly (Unlimited)"); } #[test] @@ -256,6 +303,12 @@ mod tests { ), 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) From b43dbd6632c7a398d79682d411aea98a0db3a586 Mon Sep 17 00:00:00 2001 From: Jacky Lam Date: Mon, 10 Aug 2026 00:29:20 +0800 Subject: [PATCH 09/10] test: cover MiniMax unlimited quotas --- src-tauri/src/lib.rs | 5 ++--- src-tauri/src/providers/minimax/mod.rs | 3 ++- src-tauri/src/providers/mod.rs | 13 +++++++++---- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 02503f9..027d7e6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -48,9 +48,8 @@ use crate::{ providers::{ antigravity::AntigravityProvider, claude, codex::reset_claim::CodexResetClaimService, codex::CodexProvider, copilot::CopilotProvider, cursor::CursorProvider, - detect_local_credentials, devin::DevinProvider, grok::GrokProvider, - kimi::KimiProvider, minimax::MiniMaxProvider, opencode::OpenCodeProvider, - openrouter::OpenRouterProvider, + detect_local_credentials, devin::DevinProvider, grok::GrokProvider, kimi::KimiProvider, + minimax::MiniMaxProvider, opencode::OpenCodeProvider, openrouter::OpenRouterProvider, zai::ZaiProvider, ProviderRegistry, UsageProvider, }, storage::Storage, diff --git a/src-tauri/src/providers/minimax/mod.rs b/src-tauri/src/providers/minimax/mod.rs index f1a14e1..3c7a62a 100644 --- a/src-tauri/src/providers/minimax/mod.rs +++ b/src-tauri/src/providers/minimax/mod.rs @@ -265,8 +265,9 @@ mod tests { .iter() .map(|quota| quota.id.as_str()) .collect::>(), - ["session"] + ["session", "weekly"] ); + assert_eq!(snapshot.quotas[1].label, "Weekly (Unlimited)"); } #[test] diff --git a/src-tauri/src/providers/mod.rs b/src-tauri/src/providers/mod.rs index 6205ebd..09623f1 100644 --- a/src-tauri/src/providers/mod.rs +++ b/src-tauri/src/providers/mod.rs @@ -160,8 +160,7 @@ pub trait UsageProvider: Send + Sync { mod tests { use super::{ antigravity, claude, codex, copilot, cursor, devin, grok, kimi, minimax, opencode, - openrouter, - remember_default_account, zai, ProviderError, + openrouter, remember_default_account, zai, ProviderError, }; use crate::models::ProviderErrorKind; use tempfile::tempdir; @@ -285,8 +284,14 @@ 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()), + ( + "Dashboard".into(), + "https://www.kimi.com/code/console".into() + ), + ( + "API Keys".into(), + "https://www.kimi.com/code/console".into() + ), ] ); assert_eq!( From 30e33f7735c1c7161a9e318e7a74cdfd7b83a412 Mon Sep 17 00:00:00 2001 From: Jacky Lam Date: Mon, 10 Aug 2026 08:07:43 +0800 Subject: [PATCH 10/10] docs: describe Kimi and MiniMax tracked quotas --- docs/providers/kimi.md | 7 +++++++ docs/providers/minimax.md | 21 +++++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/providers/kimi.md b/docs/providers/kimi.md index abc4f3b..c03f9c1 100644 --- a/docs/providers/kimi.md +++ b/docs/providers/kimi.md @@ -2,6 +2,13 @@ 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 diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index faae94a..3800e9e 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -2,14 +2,27 @@ 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. +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. +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). +- **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.