diff --git a/scripts/verify/verify-provider-registry-contract.js b/scripts/verify/verify-provider-registry-contract.js index a2516ba..61fb155 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|minimax)["']/; 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', + 'MiniMaxProvider', ]; if (runtimeOrder.join(',') !== expectedRuntimeOrder.join(',')) { throw new Error( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c1ced18..3ffb595 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, + minimax::MiniMaxProvider, 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(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 adddc9c..76ced98 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 minimax; 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, minimax, opencode, openrouter, remember_default_account, zai, ProviderError, }; use crate::models::ProviderErrorKind; @@ -279,5 +280,12 @@ mod tests { ), ] ); + assert_eq!( + links(minimax::definition()), + [ + ("Dashboard".into(), "https://www.minimax.io/".into()), + ("API Keys".into(), "https://platform.minimaxi.com/".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 0ad399f..96c0ce8 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 minimax from '../assets/provider-icons/minimax.svg?raw'; import opencode from '../assets/provider-icons/opencode.svg?raw'; import openrouter from '../assets/provider-icons/openrouter.svg?raw'; import zai from '../assets/provider-icons/zai.svg?raw'; @@ -17,6 +18,7 @@ const visuals: Record = { cursor: { source: cursor, color: null }, devin: { source: devin, color: null }, grok: { source: grok, color: null }, + minimax: { source: minimax, color: '#E2167E' }, opencode: { source: opencode, color: null }, openrouter: { source: openrouter, color: null }, zai: { source: zai, color: null },