From 7b5833e0b6e6f89c251a43f1f4cd8c3d4c1f5b78 Mon Sep 17 00:00:00 2001 From: lamchun1110 Date: Sat, 8 Aug 2026 13:13:48 +0800 Subject: [PATCH] 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 },