diff --git a/README.md b/README.md index 08208ec12..020db444a 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ docker pull ghcr.io/stakpak/agent:latest ``` ## Usage -You can [use your own Anthropic or OpenAI API keys](#option-b-running-without-a-stakpak-api-key), [custom OpenAI compatible endpoint](#option-b-running-without-a-stakpak-api-key), or [a Stakpak API key](#option-a-running-with-a-stakpak-api-key). +You can [use your own Anthropic or OpenAI API keys](#option-b-running-without-a-stakpak-api-key), [custom OpenAI compatible endpoint](#option-b-running-without-a-stakpak-api-key), [MiniMax API keys](#option-b-running-without-a-stakpak-api-key), or [a Stakpak API key](#option-a-running-with-a-stakpak-api-key). ### Option A: Running with a Stakpak API Key (no card required) @@ -207,6 +207,9 @@ stakpak auth login --provider openai --api-key $OPENAI_API_KEY # Gemini stakpak auth login --provider gemini --api-key $GEMINI_API_KEY + +# MiniMax +stakpak auth login --provider minimax --api-key $MINIMAX_API_KEY ``` #### Manual configuration @@ -222,7 +225,7 @@ provider = "local" model = "anthropic/claude-sonnet-4-5" # Built-in providers - credentials can also be set via environment variables -# (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY) +# (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, MINIMAX_API_KEY) [profiles.byok.providers.anthropic] type = "anthropic" api_key = "sk-ant-..." @@ -235,6 +238,10 @@ api_key = "sk-..." type = "gemini" api_key = "..." +[profiles.byok.providers.minimax] +type = "minimax" +api_key = "..." + [settings] ``` diff --git a/cli/src/commands/auth/login.rs b/cli/src/commands/auth/login.rs index e7b22cd79..0ec1f58f4 100644 --- a/cli/src/commands/auth/login.rs +++ b/cli/src/commands/auth/login.rs @@ -4,7 +4,7 @@ use crate::config::AppConfig; use crate::config::{ProfileConfig, ProviderType}; use crate::onboarding::config_templates::{ generate_anthropic_profile, generate_gemini_profile, generate_github_copilot_profile, - generate_openai_profile, + generate_minimax_profile, generate_openai_profile, }; use crate::onboarding::menu::{prompt_password, select_option_no_header}; use crate::onboarding::navigation::NavResult; @@ -397,6 +397,7 @@ async fn handle_non_interactive_api_setup( "anthropic" => generate_anthropic_profile(), "openai" => generate_openai_profile(), "gemini" => generate_gemini_profile(), + "minimax" => generate_minimax_profile(), "github-copilot" => { return Err("GitHub Copilot does not support API key authentication.\n\ It requires OAuth via your GitHub account.\n\n\ @@ -409,7 +410,7 @@ async fn handle_non_interactive_api_setup( } _ => { return Err(format!( - "Unsupported provider '{}'. Supported: anthropic, openai, gemini, stakpak, amazon-bedrock, github-copilot\n\ + "Unsupported provider '{}'. Supported: anthropic, openai, gemini, minimax, stakpak, amazon-bedrock, github-copilot\n\ For bedrock, use: stakpak auth login --provider amazon-bedrock --region \n\ For github-copilot, run without --api-key to use the device flow.", provider_id diff --git a/cli/src/config/app.rs b/cli/src/config/app.rs index 7e487dd79..2ceba48ba 100644 --- a/cli/src/config/app.rs +++ b/cli/src/config/app.rs @@ -410,6 +410,7 @@ impl AppConfig { "anthropic" => "ANTHROPIC_API_KEY", "openai" => "OPENAI_API_KEY", "gemini" => "GEMINI_API_KEY", + "minimax" => "MINIMAX_API_KEY", _ => return None, }; @@ -835,7 +836,7 @@ impl AppConfig { } let config_provider = Some("Local".to_string()); - let builtin_providers = ["anthropic", "openai", "gemini"]; + let builtin_providers = ["anthropic", "openai", "gemini", "minimax"]; for provider_name in builtin_providers { if let Some(auth) = self.resolve_provider_auth(provider_name) { @@ -843,6 +844,7 @@ impl AppConfig { "anthropic" => "Anthropic", "openai" => "OpenAI", "gemini" => "Gemini", + "minimax" => "MiniMax", _ => provider_name, }; diff --git a/cli/src/config/profile.rs b/cli/src/config/profile.rs index 33dc0118a..87e84d170 100644 --- a/cli/src/config/profile.rs +++ b/cli/src/config/profile.rs @@ -285,6 +285,15 @@ impl ProfileConfig { } } } + ProviderConfig::MiniMax { api_endpoint, .. } => { + if let Some(ep) = api_endpoint { + let clean = Self::clean_api_endpoint(Some(ep.clone())); + if clean.as_ref() != Some(ep) { + *api_endpoint = clean; + cleaned = true; + } + } + } } } diff --git a/cli/src/onboarding/config_templates.rs b/cli/src/onboarding/config_templates.rs index f28c14ddd..30aa04a19 100644 --- a/cli/src/onboarding/config_templates.rs +++ b/cli/src/onboarding/config_templates.rs @@ -132,6 +132,24 @@ pub fn generate_anthropic_profile() -> ProfileConfig { profile } +/// Generate MiniMax profile configuration (credentials stored separately in config.toml auth field) +pub fn generate_minimax_profile() -> ProfileConfig { + let mut profile = ProfileConfig { + provider: Some(ProviderType::Local), + model: Some("minimax/MiniMax-M2.7".to_string()), + ..ProfileConfig::default() + }; + profile.providers.insert( + "minimax".to_string(), + ProviderConfig::MiniMax { + api_key: None, + api_endpoint: None, + auth: None, + }, + ); + profile +} + /// Generate custom provider profile configuration /// /// This creates a profile with a custom OpenAI-compatible provider (e.g., LiteLLM, Ollama). @@ -181,6 +199,7 @@ pub enum BuiltinProvider { OpenAI, Gemini, Anthropic, + MiniMax, } impl BuiltinProvider { @@ -189,6 +208,7 @@ impl BuiltinProvider { BuiltinProvider::OpenAI => "OpenAI", BuiltinProvider::Gemini => "Gemini", BuiltinProvider::Anthropic => "Anthropic", + BuiltinProvider::MiniMax => "MiniMax", } } @@ -197,6 +217,7 @@ impl BuiltinProvider { BuiltinProvider::OpenAI => "gpt-4.1", BuiltinProvider::Gemini => "gemini-2.5-pro", BuiltinProvider::Anthropic => DEFAULT_MODEL, + BuiltinProvider::MiniMax => "minimax/MiniMax-M2.7", } } } @@ -259,6 +280,16 @@ pub fn generate_multi_provider_profile( }, ); } + BuiltinProvider::MiniMax => { + profile.providers.insert( + "minimax".to_string(), + ProviderConfig::MiniMax { + api_key: Some(setup.api_key), + api_endpoint: None, + auth: None, + }, + ); + } } } @@ -395,6 +426,22 @@ pub fn config_to_toml_preview(profile: &ProfileConfig, profile_name: &str) -> St toml.push_str(&format!("# auth: {} (set)\n", a.auth_type_display())); } } + ProviderConfig::MiniMax { + api_key, + api_endpoint, + .. + } => { + toml.push_str("type = \"minimax\"\n"); + if let Some(key) = api_key { + toml.push_str(&format!( + "api_key = \"{}\"\n", + if key.is_empty() { "" } else { "***" } + )); + } + if let Some(endpoint) = api_endpoint { + toml.push_str(&format!("api_endpoint = \"{}\"\n", endpoint)); + } + } } } diff --git a/libs/ai/src/client/builder.rs b/libs/ai/src/client/builder.rs index ed4f4e872..754e9a6a9 100644 --- a/libs/ai/src/client/builder.rs +++ b/libs/ai/src/client/builder.rs @@ -4,8 +4,8 @@ use super::{ClientConfig, Inference, InferenceConfig}; use crate::error::Result; use crate::provider::Provider; use crate::providers::{ - anthropic::AnthropicProvider, gemini::GeminiProvider, openai::OpenAIProvider, - stakpak::StakpakProvider, + anthropic::AnthropicProvider, gemini::GeminiProvider, minimax::MiniMaxProvider, + openai::OpenAIProvider, stakpak::StakpakProvider, }; use crate::registry::ProviderRegistry; @@ -72,6 +72,13 @@ impl ClientBuilder { registry = registry.register("stakpak", provider); } + // Register MiniMax if configured + if let Some(config) = inference_config.minimax_config + && let Ok(provider) = MiniMaxProvider::new(config) + { + registry = registry.register("minimax", provider); + } + // Register Bedrock if configured #[cfg(feature = "bedrock")] if let Some(config) = inference_config.bedrock_config { diff --git a/libs/ai/src/client/config.rs b/libs/ai/src/client/config.rs index b51b3d89d..f7a51cfff 100644 --- a/libs/ai/src/client/config.rs +++ b/libs/ai/src/client/config.rs @@ -1,8 +1,8 @@ //! Client configuration use crate::providers::{ - anthropic::AnthropicConfig, gemini::GeminiConfig, openai::OpenAIConfig, - stakpak::StakpakProviderConfig, + anthropic::AnthropicConfig, gemini::GeminiConfig, minimax::MiniMaxConfig, + openai::OpenAIConfig, stakpak::StakpakProviderConfig, }; #[cfg(feature = "bedrock")] @@ -67,6 +67,7 @@ pub struct InferenceConfig { pub(crate) anthropic_config: Option, pub(crate) gemini_config: Option, pub(crate) stakpak_config: Option, + pub(crate) minimax_config: Option, #[cfg(feature = "bedrock")] pub(crate) bedrock_config: Option, pub(crate) client_config: ClientConfig, @@ -257,6 +258,48 @@ impl InferenceConfig { self } + /// Configure MiniMax provider with API key and optional base URL + /// + /// MiniMax provides OpenAI-compatible API access to MiniMax-M2.7 + /// and MiniMax-M2.5 series models. + /// + /// # Example + /// + /// ```rust,no_run + /// # use stakai::InferenceConfig; + /// let config = InferenceConfig::new() + /// .minimax("your-api-key", None); + /// + /// // With custom base URL + /// let config = InferenceConfig::new() + /// .minimax("your-api-key", Some("https://custom.minimax.io/v1".to_string())); + /// ``` + pub fn minimax(mut self, api_key: impl Into, base_url: Option) -> Self { + let mut config = MiniMaxConfig::new(api_key); + if let Some(url) = base_url { + config = config.with_base_url(url); + } + self.minimax_config = Some(config); + self + } + + /// Configure MiniMax provider with full MiniMaxConfig + /// + /// # Example + /// + /// ```rust,no_run + /// # use stakai::{InferenceConfig, providers::minimax::MiniMaxConfig}; + /// let minimax_config = MiniMaxConfig::new("your-api-key") + /// .with_base_url("https://custom.minimax.io/v1"); + /// + /// let config = InferenceConfig::new() + /// .minimax_config(minimax_config); + /// ``` + pub fn minimax_config(mut self, config: MiniMaxConfig) -> Self { + self.minimax_config = Some(config); + self + } + /// Configure AWS Bedrock provider with a region /// /// Uses the AWS credential chain for authentication (env vars, shared credentials, diff --git a/libs/ai/src/provider/dispatcher.rs b/libs/ai/src/provider/dispatcher.rs index 38d9aaa42..55cc2bbb7 100644 --- a/libs/ai/src/provider/dispatcher.rs +++ b/libs/ai/src/provider/dispatcher.rs @@ -12,6 +12,8 @@ pub enum ProviderKind { Anthropic, /// Google Gemini provider (future) Google, + /// MiniMax provider + MiniMax, } impl ProviderKind { @@ -21,6 +23,7 @@ impl ProviderKind { Self::OpenAI => "openai", Self::Anthropic => "anthropic", Self::Google => "google", + Self::MiniMax => "minimax", } } } @@ -33,6 +36,7 @@ impl std::str::FromStr for ProviderKind { "openai" => Ok(Self::OpenAI), "anthropic" => Ok(Self::Anthropic), "google" | "gemini" => Ok(Self::Google), + "minimax" => Ok(Self::MiniMax), _ => Err(Error::UnknownProvider(s.to_string())), } } diff --git a/libs/ai/src/providers/minimax/convert.rs b/libs/ai/src/providers/minimax/convert.rs new file mode 100644 index 000000000..72e747928 --- /dev/null +++ b/libs/ai/src/providers/minimax/convert.rs @@ -0,0 +1,392 @@ +//! Conversion from SDK types to MiniMax's OpenAI-compatible request format +//! +//! MiniMax's API is OpenAI-compatible, so we reuse the OpenAI wire types. +//! Key difference: temperature must be in the range (0.0, 1.0]. + +use crate::providers::openai::types::{ + ChatCompletionRequest, ChatMessage, OpenAIFunctionCall, OpenAIToolCall, StreamOptions, +}; +use crate::types::{ContentPart, GenerateRequest, ImageDetail, Message, Role}; +use serde_json::json; + +/// Clamp temperature to MiniMax's valid range (0.0, 1.0]. +/// MiniMax rejects temperature = 0.0, so we use a small epsilon. +fn clamp_temperature(temp: Option) -> f32 { + match temp { + Some(t) if t <= 0.0 => 0.01, + Some(t) if t > 1.0 => 1.0, + Some(t) => t, + None => 0.01, + } +} + +/// Convert an SDK request to a MiniMax-compatible OpenAI chat completion request. +pub fn to_minimax_request(req: &GenerateRequest, stream: bool) -> ChatCompletionRequest { + let tools = req.options.tools.as_ref().map(|tools| { + tools + .iter() + .map(|tool| { + json!({ + "type": tool.tool_type, + "function": { + "name": tool.function.name, + "description": tool.function.description, + "parameters": tool.function.parameters, + } + }) + }) + .collect::>() + }); + + let tool_choice = req.options.tool_choice.as_ref().map(|choice| match choice { + crate::types::ToolChoice::Auto => json!("auto"), + crate::types::ToolChoice::None => json!("none"), + crate::types::ToolChoice::Required { name } => json!({ + "type": "function", + "function": { "name": name } + }), + }); + + let messages: Vec = req.messages.iter().flat_map(to_minimax_messages).collect(); + + let stream_options = if stream { + Some(StreamOptions { + include_usage: true, + }) + } else { + None + }; + + ChatCompletionRequest { + model: req.model.id.clone(), + messages, + temperature: Some(clamp_temperature(req.options.temperature)), + max_completion_tokens: req.options.max_tokens, + top_p: req.options.top_p, + stop: req.options.stop_sequences.clone(), + stream: Some(stream), + stream_options, + tools, + tool_choice, + } +} + +/// Convert a single SDK message into one or more OpenAI-format `ChatMessage`s. +/// +/// Messages with multiple `ToolResult` parts are expanded so that each tool result +/// gets its own message with the correct `tool_call_id`. +fn to_minimax_messages(msg: &Message) -> Vec { + let role = match msg.role { + Role::System => "system", + Role::User => "user", + Role::Assistant => "assistant", + Role::Tool => "tool", + }; + + let parts = msg.parts(); + + // Collect tool results -- if more than one, each needs its own ChatMessage + let tool_results: Vec<_> = parts + .iter() + .filter_map(|part| match part { + ContentPart::ToolResult { + tool_call_id, + content, + .. + } => Some((tool_call_id.clone(), content.clone())), + _ => None, + }) + .collect(); + + if tool_results.len() > 1 { + return tool_results + .into_iter() + .map(|(tool_call_id, content)| ChatMessage { + role: "tool".to_string(), + content: Some(content), + name: msg.name.clone(), + tool_calls: None, + tool_call_id: Some(tool_call_id), + }) + .collect(); + } + + // Single tool result or non-tool message -- standard conversion + let tool_call_id = parts.iter().find_map(|part| match part { + ContentPart::ToolResult { tool_call_id, .. } => Some(tool_call_id.clone()), + _ => None, + }); + + let tool_calls = { + let calls: Vec<_> = parts + .iter() + .filter_map(|part| match part { + ContentPart::ToolCall { + id, + name, + arguments, + .. + } => Some(OpenAIToolCall { + id: id.clone(), + type_: "function".to_string(), + function: OpenAIFunctionCall { + name: name.clone(), + arguments: arguments.to_string(), + }, + }), + _ => None, + }) + .collect(); + if calls.is_empty() { None } else { Some(calls) } + }; + + let content = if parts.len() == 1 { + match &parts[0] { + ContentPart::Text { text, .. } => Some(json!(text)), + ContentPart::Image { url, detail, .. } => Some(json!([{ + "type": "image_url", + "image_url": { + "url": url, + "detail": detail.map(|d| match d { + ImageDetail::Low => "low", + ImageDetail::High => "high", + ImageDetail::Auto => "auto", + }) + } + }])), + ContentPart::ToolCall { .. } => None, + ContentPart::ToolResult { content, .. } => Some(content.clone()), + } + } else { + Some(json!( + parts + .iter() + .filter_map(|part| match part { + ContentPart::Text { text, .. } => Some(json!({ + "type": "text", + "text": text + })), + ContentPart::Image { url, detail, .. } => Some(json!({ + "type": "image_url", + "image_url": { + "url": url, + "detail": detail.map(|d| match d { + ImageDetail::Low => "low", + ImageDetail::High => "high", + ImageDetail::Auto => "auto", + }) + } + })), + ContentPart::ToolCall { .. } => None, // Handled via tool_calls field + ContentPart::ToolResult { .. } => None, // Handled via tool_call_id field + }) + .collect::>() + )) + }; + + vec![ChatMessage { + role: role.to_string(), + content, + name: msg.name.clone(), + tool_calls, + tool_call_id, + }] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{MessageContent, Model}; + + fn make_request(messages: Vec) -> GenerateRequest { + GenerateRequest::new( + Model::custom("MiniMax-M2.7", "minimax"), + messages, + ) + } + + #[test] + fn test_clamp_temperature_zero() { + assert_eq!(clamp_temperature(Some(0.0)), 0.01); + } + + #[test] + fn test_clamp_temperature_negative() { + assert_eq!(clamp_temperature(Some(-1.0)), 0.01); + } + + #[test] + fn test_clamp_temperature_above_one() { + assert_eq!(clamp_temperature(Some(2.0)), 1.0); + } + + #[test] + fn test_clamp_temperature_valid() { + assert_eq!(clamp_temperature(Some(0.7)), 0.7); + } + + #[test] + fn test_clamp_temperature_none() { + assert_eq!(clamp_temperature(None), 0.01); + } + + #[test] + fn test_basic_user_message() { + let req = make_request(vec![Message::new(Role::User, "Hello")]); + let result = to_minimax_request(&req, false); + + assert_eq!(result.messages.len(), 1); + assert_eq!(result.messages[0].role, "user"); + assert_eq!(result.messages[0].content, Some(json!("Hello"))); + // Temperature should be clamped (default 0.01 when not set) + assert!(result.temperature.unwrap() > 0.0); + } + + #[test] + fn test_system_message_stays_system() { + let req = make_request(vec![ + Message::new(Role::System, "You are helpful"), + Message::new(Role::User, "Hi"), + ]); + let result = to_minimax_request(&req, false); + + assert_eq!(result.messages.len(), 2); + assert_eq!(result.messages[0].role, "system"); + } + + #[test] + fn test_single_tool_result_not_expanded() { + let tool_msg = Message { + role: Role::Tool, + content: MessageContent::Parts(vec![ContentPart::tool_result( + "call_1", + json!("result 1"), + )]), + name: None, + provider_options: None, + }; + + let req = make_request(vec![tool_msg]); + let result = to_minimax_request(&req, false); + + assert_eq!(result.messages.len(), 1); + assert_eq!(result.messages[0].role, "tool"); + assert_eq!(result.messages[0].tool_call_id, Some("call_1".to_string())); + } + + #[test] + fn test_merged_tool_results_expanded() { + let merged_tool_msg = Message { + role: Role::Tool, + content: MessageContent::Parts(vec![ + ContentPart::tool_result("call_1", json!("result 1")), + ContentPart::tool_result("call_2", json!("result 2")), + ContentPart::tool_result("call_3", json!("result 3")), + ]), + name: None, + provider_options: None, + }; + + let req = make_request(vec![merged_tool_msg]); + let result = to_minimax_request(&req, false); + + assert_eq!(result.messages.len(), 3); + for (i, msg) in result.messages.iter().enumerate() { + assert_eq!(msg.role, "tool"); + assert_eq!(msg.tool_call_id, Some(format!("call_{}", i + 1))); + assert_eq!(msg.content, Some(json!(format!("result {}", i + 1)))); + } + } + + #[test] + fn test_streaming_request() { + let req = make_request(vec![Message::new(Role::User, "Hello")]); + let result = to_minimax_request(&req, true); + + assert_eq!(result.stream, Some(true)); + assert!(result.stream_options.is_some()); + assert!( + result + .stream_options + .as_ref() + .map(|o| o.include_usage) + .unwrap_or(false) + ); + } + + #[test] + fn test_non_streaming_request() { + let req = make_request(vec![Message::new(Role::User, "Hello")]); + let result = to_minimax_request(&req, false); + + assert_eq!(result.stream, Some(false)); + assert!(result.stream_options.is_none()); + } + + #[test] + fn test_assistant_tool_calls_converted() { + let msg = Message { + role: Role::Assistant, + content: MessageContent::Parts(vec![ContentPart::ToolCall { + id: "call_abc".to_string(), + name: "get_weather".to_string(), + arguments: json!({"location": "NYC"}), + provider_options: None, + metadata: None, + }]), + name: None, + provider_options: None, + }; + + let req = make_request(vec![msg]); + let result = to_minimax_request(&req, false); + + assert_eq!(result.messages.len(), 1); + let tool_calls = result.messages[0] + .tool_calls + .as_ref() + .expect("should have tool_calls"); + assert_eq!(tool_calls.len(), 1); + assert_eq!(tool_calls[0].id, "call_abc"); + assert_eq!(tool_calls[0].function.name, "get_weather"); + } + + #[test] + fn test_tools_converted() { + use crate::types::{GenerateOptions, Tool, ToolFunction}; + + let mut req = make_request(vec![Message::new(Role::User, "Hello")]); + req.options = GenerateOptions { + tools: Some(vec![Tool { + tool_type: "function".to_string(), + function: ToolFunction { + name: "get_weather".to_string(), + description: "Get weather".to_string(), + parameters: json!({"type": "object"}), + }, + provider_options: None, + }]), + ..Default::default() + }; + + let result = to_minimax_request(&req, false); + assert!(result.tools.is_some()); + let tools = result.tools.as_ref().unwrap(); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0]["function"]["name"], "get_weather"); + } + + #[test] + fn test_temperature_clamped_in_request() { + use crate::types::GenerateOptions; + + let mut req = make_request(vec![Message::new(Role::User, "Hello")]); + req.options = GenerateOptions { + temperature: Some(0.0), + ..Default::default() + }; + + let result = to_minimax_request(&req, false); + assert_eq!(result.temperature, Some(0.01)); + } +} diff --git a/libs/ai/src/providers/minimax/mod.rs b/libs/ai/src/providers/minimax/mod.rs new file mode 100644 index 000000000..2fd1069af --- /dev/null +++ b/libs/ai/src/providers/minimax/mod.rs @@ -0,0 +1,13 @@ +//! MiniMax provider implementation +//! +//! MiniMax provides an OpenAI-compatible API at `https://api.minimax.io/v1`. +//! This provider supports MiniMax-M2.7, MiniMax-M2.7-highspeed, MiniMax-M2.5, +//! and MiniMax-M2.5-highspeed models. + +mod convert; +mod provider; +mod stream; +mod types; + +pub use provider::MiniMaxProvider; +pub use types::MiniMaxConfig; diff --git a/libs/ai/src/providers/minimax/provider.rs b/libs/ai/src/providers/minimax/provider.rs new file mode 100644 index 000000000..0b1feef82 --- /dev/null +++ b/libs/ai/src/providers/minimax/provider.rs @@ -0,0 +1,365 @@ +//! MiniMax provider implementation + +use super::convert::to_minimax_request; +use super::stream::create_stream; +use super::types::MiniMaxConfig; +use crate::error::{Error, Result}; +use crate::provider::Provider; +use crate::providers::openai::types::{ChatCompletionResponse, ChatUsage}; +use crate::providers::tls::create_platform_tls_client; +use crate::types::{ + FinishReason, FinishReasonKind, GenerateRequest, GenerateResponse, GenerateStream, Headers, + InputTokenDetails, Model, ModelCost, ModelLimit, OutputTokenDetails, ResponseContent, ToolCall, + Usage, +}; +use async_trait::async_trait; +use reqwest::Client; +use reqwest_eventsource::EventSource; +use serde_json::json; + +/// MiniMax provider +/// +/// Routes inference requests through MiniMax's OpenAI-compatible API. +pub struct MiniMaxProvider { + config: MiniMaxConfig, + client: Client, +} + +impl MiniMaxProvider { + /// Create a new MiniMax provider + pub fn new(config: MiniMaxConfig) -> Result { + if config.api_key.is_empty() { + return Err(Error::MissingApiKey("minimax".to_string())); + } + + let client = create_platform_tls_client()?; + Ok(Self { config, client }) + } + + /// Create provider from environment + pub fn from_env() -> Result { + Self::new(MiniMaxConfig::default()) + } + + /// Return the static list of available MiniMax models + fn static_models() -> Vec { + vec![ + Model { + id: "MiniMax-M2.7".to_string(), + provider: "minimax".to_string(), + name: "MiniMax M2.7".to_string(), + reasoning: false, + cost: Some(ModelCost::new(0.2, 1.1)), + limit: ModelLimit::new(1_000_000, 131_072), + release_date: None, + }, + Model { + id: "MiniMax-M2.7-highspeed".to_string(), + provider: "minimax".to_string(), + name: "MiniMax M2.7 Highspeed".to_string(), + reasoning: false, + cost: Some(ModelCost::new(0.2, 1.1)), + limit: ModelLimit::new(1_000_000, 131_072), + release_date: None, + }, + Model { + id: "MiniMax-M2.5".to_string(), + provider: "minimax".to_string(), + name: "MiniMax M2.5".to_string(), + reasoning: false, + cost: Some(ModelCost::new(0.2, 1.1)), + limit: ModelLimit::new(204_000, 131_072), + release_date: None, + }, + Model { + id: "MiniMax-M2.5-highspeed".to_string(), + provider: "minimax".to_string(), + name: "MiniMax M2.5 Highspeed".to_string(), + reasoning: false, + cost: Some(ModelCost::new(0.2, 1.1)), + limit: ModelLimit::new(204_000, 131_072), + release_date: None, + }, + ] + } +} + +#[async_trait] +impl Provider for MiniMaxProvider { + fn provider_id(&self) -> &str { + "minimax" + } + + fn build_headers(&self, custom_headers: Option<&Headers>) -> Headers { + let mut headers = Headers::new(); + + headers.insert("Authorization", format!("Bearer {}", self.config.api_key)); + headers.insert("Content-Type", "application/json"); + + if let Some(custom) = custom_headers { + headers.merge_with(custom); + } + + headers + } + + async fn generate(&self, request: GenerateRequest) -> Result { + let url = format!("{}/chat/completions", self.config.base_url); + + let minimax_req = to_minimax_request(&request, false); + + let headers = self.build_headers(request.options.headers.as_ref()); + + let response = self + .client + .post(&url) + .headers(headers.to_reqwest_headers()) + .json(&minimax_req) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + let friendly_error = parse_minimax_error(&error_text, status.as_u16()); + return Err(Error::provider_error(friendly_error)); + } + + let resp: ChatCompletionResponse = response.json().await?; + from_minimax_response(resp) + } + + async fn stream(&self, request: GenerateRequest) -> Result { + let url = format!("{}/chat/completions", self.config.base_url); + + let minimax_req = to_minimax_request(&request, true); + + let headers = self.build_headers(request.options.headers.as_ref()); + + let req_builder = self + .client + .post(&url) + .headers(headers.to_reqwest_headers()) + .json(&minimax_req); + + let event_source = EventSource::new(req_builder) + .map_err(|e| Error::stream_error(format!("Failed to create event source: {}", e)))?; + + create_stream(event_source).await + } + + async fn list_models(&self) -> Result> { + Ok(Self::static_models()) + } +} + +/// Convert MiniMax (OpenAI-compatible) response to SDK response +fn from_minimax_response(resp: ChatCompletionResponse) -> Result { + let choice = resp + .choices + .first() + .ok_or_else(|| Error::invalid_response("No choices in response"))?; + + let content = parse_minimax_message(&choice.message)?; + + let finish_reason = match choice.finish_reason.as_deref() { + Some("stop") => FinishReason::with_raw(FinishReasonKind::Stop, "stop"), + Some("length") => FinishReason::with_raw(FinishReasonKind::Length, "length"), + Some("tool_calls") => FinishReason::with_raw(FinishReasonKind::ToolCalls, "tool_calls"), + Some("content_filter") => { + FinishReason::with_raw(FinishReasonKind::ContentFilter, "content_filter") + } + Some(raw) => FinishReason::with_raw(FinishReasonKind::Other, raw), + None => FinishReason::other(), + }; + + let usage = usage_from_chat_usage(&resp.usage); + + Ok(GenerateResponse { + content, + usage, + finish_reason, + metadata: Some(json!({ + "id": resp.id, + "model": resp.model, + "created": resp.created, + "object": resp.object, + })), + warnings: None, + }) +} + +/// Convert OpenAI-compatible ChatUsage to SDK Usage +fn usage_from_chat_usage(usage: &ChatUsage) -> Usage { + let cache_read = usage + .prompt_tokens_details + .as_ref() + .and_then(|d| d.cached_tokens) + .unwrap_or(0); + + Usage::with_details( + InputTokenDetails { + total: Some(usage.prompt_tokens), + no_cache: Some(usage.prompt_tokens.saturating_sub(cache_read)), + cache_read: (cache_read > 0).then_some(cache_read), + cache_write: None, + }, + OutputTokenDetails { + total: Some(usage.completion_tokens), + text: None, + reasoning: usage + .completion_tokens_details + .as_ref() + .and_then(|d| d.reasoning_tokens), + }, + Some(serde_json::to_value(usage).unwrap_or_default()), + ) +} + +/// Parse MiniMax message content +fn parse_minimax_message( + msg: &crate::providers::openai::types::ChatMessage, +) -> Result> { + let mut content = Vec::new(); + + // Handle text content + if let Some(content_value) = &msg.content + && let Some(text) = content_value.as_str() + && !text.is_empty() + { + content.push(ResponseContent::Text { + text: text.to_string(), + }); + } + + // Handle tool calls + if let Some(tool_calls) = &msg.tool_calls { + for tc in tool_calls { + content.push(ResponseContent::ToolCall(ToolCall { + id: tc.id.clone(), + name: tc.function.name.clone(), + arguments: serde_json::from_str(&tc.function.arguments) + .unwrap_or_else(|_| json!({})), + metadata: None, + })); + } + } + + Ok(content) +} + +/// Parse MiniMax API error and return user-friendly message +pub(crate) fn parse_minimax_error(error_text: &str, status_code: u16) -> String { + // Try to parse as JSON error + if let Ok(json) = serde_json::from_str::(error_text) + && let Some(error) = json.get("error") + { + let message = error.get("message").and_then(|m| m.as_str()).unwrap_or(""); + let error_type = error.get("type").and_then(|t| t.as_str()).unwrap_or(""); + + // Check for rate limit + if error_type == "rate_limit_error" || status_code == 429 { + return format!( + "Rate limited. Please wait a moment and try again. {}", + message + ); + } + + // Check for authentication errors + if error_type == "authentication_error" || status_code == 401 { + return format!( + "Authentication failed. Please check your MiniMax API key. {}", + message + ); + } + + // Return the message if we have one + if !message.is_empty() { + return message.to_string(); + } + } + + // Fallback to raw error + format!("MiniMax API error {}: {}", status_code, error_text) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_provider_id() { + let config = MiniMaxConfig::new("test-key"); + let provider = MiniMaxProvider::new(config).unwrap(); + assert_eq!(provider.provider_id(), "minimax"); + } + + #[test] + fn test_missing_api_key() { + let config = MiniMaxConfig::new(""); + let result = MiniMaxProvider::new(config); + assert!(result.is_err()); + } + + #[test] + fn test_build_headers() { + let config = MiniMaxConfig::new("test-key"); + let provider = MiniMaxProvider::new(config).unwrap(); + let headers = provider.build_headers(None); + let reqwest_headers = headers.to_reqwest_headers(); + assert_eq!( + reqwest_headers.get("authorization").unwrap(), + "Bearer test-key" + ); + assert_eq!( + reqwest_headers.get("content-type").unwrap(), + "application/json" + ); + } + + #[test] + fn test_static_models() { + let models = MiniMaxProvider::static_models(); + assert_eq!(models.len(), 4); + assert!(models.iter().any(|m| m.id == "MiniMax-M2.7")); + assert!(models.iter().any(|m| m.id == "MiniMax-M2.7-highspeed")); + assert!(models.iter().any(|m| m.id == "MiniMax-M2.5")); + assert!(models.iter().any(|m| m.id == "MiniMax-M2.5-highspeed")); + for model in &models { + assert_eq!(model.provider, "minimax"); + } + } + + #[test] + fn test_parse_minimax_error_auth() { + let error = r#"{"error":{"type":"authentication_error","message":"Invalid API key"}}"#; + let result = parse_minimax_error(error, 401); + assert!(result.contains("Authentication failed")); + assert!(result.contains("Invalid API key")); + } + + #[test] + fn test_parse_minimax_error_rate_limit() { + let error = r#"{"error":{"type":"rate_limit_error","message":"Too many requests"}}"#; + let result = parse_minimax_error(error, 429); + assert!(result.contains("Rate limited")); + } + + #[test] + fn test_parse_minimax_error_fallback() { + let result = parse_minimax_error("not json", 500); + assert!(result.contains("MiniMax API error 500")); + } + + #[test] + fn test_default_config_base_url() { + let config = MiniMaxConfig::new("key"); + assert_eq!(config.base_url, "https://api.minimax.io/v1"); + } + + #[test] + fn test_config_with_base_url() { + let config = MiniMaxConfig::new("key").with_base_url("https://custom.example.com/v1"); + assert_eq!(config.base_url, "https://custom.example.com/v1"); + } +} diff --git a/libs/ai/src/providers/minimax/stream.rs b/libs/ai/src/providers/minimax/stream.rs new file mode 100644 index 000000000..7f6582e57 --- /dev/null +++ b/libs/ai/src/providers/minimax/stream.rs @@ -0,0 +1,237 @@ +//! MiniMax streaming implementation +//! +//! Reuses OpenAI-compatible SSE streaming format. + +use crate::error::{Error, Result}; +use crate::providers::openai::types::{ChatCompletionChunk, ChatUsage}; +use crate::types::{ + FinishReason, FinishReasonKind, GenerateStream, InputTokenDetails, OutputTokenDetails, + StreamEvent, Usage, +}; +use futures::StreamExt; +use reqwest_eventsource::{Event, EventSource}; +use std::error::Error as StdError; + +/// Track state for each tool call during streaming +#[derive(Debug, Clone)] +struct ToolCallState { + id: String, + name: String, + arguments: String, +} + +/// Create a streaming response from MiniMax +pub async fn create_stream(event_source: EventSource) -> Result { + let stream = async_stream::stream! { + let mut event_stream = event_source; + let mut accumulated_usage: Option = None; + let mut tool_calls: std::collections::HashMap = std::collections::HashMap::new(); + + while let Some(event) = event_stream.next().await { + match event { + Ok(Event::Open) => {} + Ok(Event::Message(message)) => { + if message.data == "[DONE]" { + break; + } + + match parse_chunk(&message.data, &mut accumulated_usage, &mut tool_calls) { + Ok(events) => { + for event in events { + yield Ok(event); + } + } + Err(e) => yield Err(e), + } + } + Err(reqwest_eventsource::Error::StreamEnded) => { + break; + } + Err(reqwest_eventsource::Error::InvalidStatusCode(status, response)) => { + let error_body = response + .text() + .await + .unwrap_or_else(|_| "Unable to read error body".to_string()); + let friendly_error = super::provider::parse_minimax_error(&error_body, status.as_u16()); + yield Err(Error::provider_error(friendly_error)); + break; + } + Err(reqwest_eventsource::Error::Transport(e)) => { + yield Err(Error::stream_error(format!( + "Transport error: {} | source: {:?}", + e, + e.source() + ))); + break; + } + Err(reqwest_eventsource::Error::Utf8(e)) => { + yield Err(Error::stream_error(format!( + "UTF-8 decode error in stream: {}", + e + ))); + break; + } + Err(reqwest_eventsource::Error::Parser(e)) => { + yield Err(Error::stream_error(format!( + "SSE parser error: {}", + e + ))); + break; + } + Err(reqwest_eventsource::Error::InvalidContentType(content_type, _)) => { + yield Err(Error::stream_error(format!( + "Invalid content type from server: {:?} (expected text/event-stream)", + content_type + ))); + break; + } + Err(e) => { + yield Err(Error::stream_error(format!("Stream error: {}", e))); + break; + } + } + } + }; + + Ok(GenerateStream::new(Box::pin(stream))) +} + +/// Parse a streaming chunk from MiniMax (OpenAI-compatible format) +fn parse_chunk( + data: &str, + accumulated_usage: &mut Option, + tool_calls: &mut std::collections::HashMap, +) -> Result> { + let chunk: ChatCompletionChunk = match serde_json::from_str(data) { + Ok(c) => c, + Err(_) => { + return Err(Error::from_unparseable_chunk( + data, + "Failed to parse MiniMax chunk", + )); + } + }; + + // Capture usage + if let Some(usage) = &chunk.usage { + *accumulated_usage = Some(usage_from_chat_usage(usage)); + } + + let choice = match chunk.choices.first() { + Some(c) => c, + None => return Ok(Vec::new()), + }; + + let mut events = Vec::new(); + + // Handle tool calls + if let Some(tc_deltas) = &choice.delta.tool_calls { + for tc in tc_deltas { + let tool_call = tool_calls.entry(tc.index).or_insert_with(|| ToolCallState { + id: String::new(), + name: String::new(), + arguments: String::new(), + }); + + if let Some(id) = &tc.id + && !id.is_empty() + { + tool_call.id = id.clone(); + } + + if let Some(function) = &tc.function { + if let Some(name) = &function.name { + tool_call.name = name.clone(); + events.push(StreamEvent::tool_call_start( + tool_call.id.clone(), + name.clone(), + )); + } + + if let Some(args) = &function.arguments { + tool_call.arguments.push_str(args); + events.push(StreamEvent::tool_call_delta( + tool_call.id.clone(), + args.clone(), + )); + } + } + } + } + + // Handle finish reason + if let Some(reason) = &choice.finish_reason { + let finish_reason = match reason.as_str() { + "stop" => FinishReason::with_raw(FinishReasonKind::Stop, "stop"), + "length" => FinishReason::with_raw(FinishReasonKind::Length, "length"), + "tool_calls" => FinishReason::with_raw(FinishReasonKind::ToolCalls, "tool_calls"), + "content_filter" => { + FinishReason::with_raw(FinishReasonKind::ContentFilter, "content_filter") + } + raw => FinishReason::with_raw(FinishReasonKind::Other, raw), + }; + + // Emit ToolCallEnd for all accumulated tool calls + if finish_reason.unified == FinishReasonKind::ToolCalls { + let mut sorted_indices: Vec<_> = tool_calls.keys().cloned().collect(); + sorted_indices.sort(); + + for index in sorted_indices { + if let Some(tc) = tool_calls.remove(&index) { + let args_json = if tc.arguments.is_empty() { + serde_json::json!({}) + } else { + serde_json::from_str(&tc.arguments).unwrap_or(serde_json::json!({})) + }; + events.push(StreamEvent::tool_call_end(tc.id, tc.name, args_json)); + } + } + } + + events.push(StreamEvent::finish( + accumulated_usage.clone().unwrap_or_default(), + finish_reason, + )); + + return Ok(events); + } + + // Handle content delta + if let Some(content) = &choice.delta.content { + events.push(StreamEvent::text_delta(chunk.id.clone(), content.clone())); + } + + // Start event + if choice.delta.role.is_some() && events.is_empty() { + events.push(StreamEvent::start(chunk.id)); + } + + Ok(events) +} + +/// Convert OpenAI-compatible ChatUsage to SDK Usage +fn usage_from_chat_usage(usage: &ChatUsage) -> Usage { + let cache_read = usage + .prompt_tokens_details + .as_ref() + .and_then(|d| d.cached_tokens) + .unwrap_or(0); + + Usage::with_details( + InputTokenDetails { + total: Some(usage.prompt_tokens), + no_cache: Some(usage.prompt_tokens.saturating_sub(cache_read)), + cache_read: (cache_read > 0).then_some(cache_read), + cache_write: None, + }, + OutputTokenDetails { + total: Some(usage.completion_tokens), + text: None, + reasoning: usage + .completion_tokens_details + .as_ref() + .and_then(|d| d.reasoning_tokens), + }, + Some(serde_json::to_value(usage).unwrap_or_default()), + ) +} diff --git a/libs/ai/src/providers/minimax/types.rs b/libs/ai/src/providers/minimax/types.rs new file mode 100644 index 000000000..c694aaa5e --- /dev/null +++ b/libs/ai/src/providers/minimax/types.rs @@ -0,0 +1,35 @@ +//! MiniMax-specific types + +/// Configuration for MiniMax provider +#[derive(Debug, Clone)] +pub struct MiniMaxConfig { + /// API key + pub api_key: String, + /// Base URL (default: https://api.minimax.io/v1) + pub base_url: String, +} + +impl MiniMaxConfig { + /// Create new config with API key + pub fn new(api_key: impl Into) -> Self { + Self { + api_key: api_key.into(), + base_url: "https://api.minimax.io/v1".to_string(), + } + } + + /// Set base URL + pub fn with_base_url(mut self, base_url: impl Into) -> Self { + self.base_url = base_url.into(); + self + } +} + +impl Default for MiniMaxConfig { + fn default() -> Self { + Self { + api_key: std::env::var("MINIMAX_API_KEY").unwrap_or_else(|_| String::new()), + base_url: "https://api.minimax.io/v1".to_string(), + } + } +} diff --git a/libs/ai/src/providers/mod.rs b/libs/ai/src/providers/mod.rs index 43fb94f25..4f2e5bdf2 100644 --- a/libs/ai/src/providers/mod.rs +++ b/libs/ai/src/providers/mod.rs @@ -5,6 +5,7 @@ pub mod anthropic; pub mod bedrock; pub mod copilot; pub mod gemini; +pub mod minimax; pub mod openai; pub mod stakpak; pub(crate) mod tls; @@ -15,5 +16,6 @@ pub use anthropic::AnthropicProvider; pub use bedrock::BedrockProvider; pub use copilot::{CopilotConfig, CopilotProvider}; pub use gemini::GeminiProvider; +pub use minimax::MiniMaxProvider; pub use openai::OpenAIProvider; pub use stakpak::StakpakProvider; diff --git a/libs/ai/src/registry/mod.rs b/libs/ai/src/registry/mod.rs index 307527bd0..e31e5fef1 100644 --- a/libs/ai/src/registry/mod.rs +++ b/libs/ai/src/registry/mod.rs @@ -106,6 +106,15 @@ impl Default for ProviderRegistry { registry = registry.register("google", provider); } + // Register MiniMax if API key is available + use crate::providers::minimax::{MiniMaxConfig, MiniMaxProvider}; + if let Ok(api_key) = std::env::var("MINIMAX_API_KEY") + && !api_key.is_empty() + && let Ok(provider) = MiniMaxProvider::new(MiniMaxConfig::new(api_key)) + { + registry = registry.register("minimax", provider); + } + registry } } diff --git a/libs/ai/tests/integration/minimax.rs b/libs/ai/tests/integration/minimax.rs new file mode 100644 index 000000000..5fc739a7e --- /dev/null +++ b/libs/ai/tests/integration/minimax.rs @@ -0,0 +1,107 @@ +//! MiniMax integration tests +//! +//! Run with: cargo test --test lib -- --ignored minimax + +use futures::StreamExt; +use stakai::{GenerateRequest, Inference, Message, Model, Role, StreamEvent}; + +#[tokio::test] +#[ignore] // Requires MINIMAX_API_KEY +async fn test_minimax_generate() { + let client = Inference::new(); + + let mut request = GenerateRequest::new( + Model::custom("MiniMax-M2.7", "minimax"), + vec![Message { + role: Role::User, + content: "Say 'Hello, World!' and nothing else".into(), + name: None, + provider_options: None, + }], + ); + request.options.temperature = Some(0.5); + request.options.max_tokens = Some(500); + + let response = client.generate(&request).await; + + assert!(response.is_ok(), "Request failed: {:?}", response.err()); + let response = response.unwrap(); + + assert!(!response.text().is_empty()); + assert!(response.usage.total_tokens > 0); +} + +#[tokio::test] +#[ignore] // Requires MINIMAX_API_KEY +async fn test_minimax_streaming() { + let client = Inference::new(); + + let mut request = GenerateRequest::new( + Model::custom("MiniMax-M2.7", "minimax"), + vec![Message { + role: Role::User, + content: "Count from 1 to 3".into(), + name: None, + provider_options: None, + }], + ); + request.options.temperature = Some(0.5); + request.options.max_tokens = Some(200); + + let stream = client.stream(&request).await; + assert!(stream.is_ok(), "Stream creation failed: {:?}", stream.err()); + + let mut stream = stream.unwrap(); + let mut text = String::new(); + let mut finished = false; + + while let Some(event) = stream.next().await { + match event { + Ok(StreamEvent::TextDelta { delta, .. }) => { + text.push_str(&delta); + } + Ok(StreamEvent::Finish { .. }) => { + finished = true; + break; + } + Ok(_) => {} + Err(e) => panic!("Stream error: {:?}", e), + } + } + + assert!(finished, "Stream should finish"); + assert!(!text.is_empty(), "Should receive text content"); +} + +#[tokio::test] +#[ignore] // Requires MINIMAX_API_KEY +async fn test_minimax_with_system_message() { + let client = Inference::new(); + + let mut request = GenerateRequest::new( + Model::custom("MiniMax-M2.7", "minimax"), + vec![ + Message { + role: Role::System, + content: "You are a helpful assistant that always responds in exactly one word." + .into(), + name: None, + provider_options: None, + }, + Message { + role: Role::User, + content: "What color is the sky?".into(), + name: None, + provider_options: None, + }, + ], + ); + request.options.temperature = Some(0.1); + request.options.max_tokens = Some(50); + + let response = client.generate(&request).await; + + assert!(response.is_ok(), "Request failed: {:?}", response.err()); + let response = response.unwrap(); + assert!(!response.text().is_empty()); +} diff --git a/libs/ai/tests/integration/mod.rs b/libs/ai/tests/integration/mod.rs index 212dc2170..491d74938 100644 --- a/libs/ai/tests/integration/mod.rs +++ b/libs/ai/tests/integration/mod.rs @@ -12,3 +12,6 @@ mod anthropic; #[cfg(test)] mod gemini; + +#[cfg(test)] +mod minimax; diff --git a/libs/shared/src/models/llm.rs b/libs/shared/src/models/llm.rs index bed7e2b9c..f9d1fc332 100644 --- a/libs/shared/src/models/llm.rs +++ b/libs/shared/src/models/llm.rs @@ -246,6 +246,34 @@ pub enum ProviderConfig { #[serde(skip_serializing_if = "Option::is_none")] auth: Option, }, + /// MiniMax provider configuration + /// + /// MiniMax provides OpenAI-compatible API access to MiniMax-M2.7 + /// and MiniMax-M2.5 series models with up to 1M context window. + /// + /// # Example TOML + /// ```toml + /// [profiles.myprofile.providers.minimax] + /// type = "minimax" + /// + /// [profiles.myprofile.providers.minimax.auth] + /// type = "api" + /// key = "your-minimax-api-key" + /// + /// # Then use models as: + /// model = "minimax/MiniMax-M2.7" + /// ``` + MiniMax { + /// Legacy API key field (prefer `auth` field) + #[serde(skip_serializing_if = "Option::is_none")] + api_key: Option, + /// API endpoint URL (default: https://api.minimax.io/v1) + #[serde(skip_serializing_if = "Option::is_none")] + api_endpoint: Option, + /// Authentication credentials (preferred over api_key) + #[serde(skip_serializing_if = "Option::is_none")] + auth: Option, + }, } impl ProviderConfig { @@ -259,6 +287,7 @@ impl ProviderConfig { ProviderConfig::Stakpak { .. } => "stakpak", ProviderConfig::Bedrock { .. } => "amazon-bedrock", ProviderConfig::GitHubCopilot { .. } => "github-copilot", + ProviderConfig::MiniMax { .. } => "minimax", } } @@ -277,6 +306,7 @@ impl ProviderConfig { ProviderConfig::Gemini { api_key, .. } => api_key.as_deref(), ProviderConfig::Custom { api_key, .. } => api_key.as_deref(), ProviderConfig::Stakpak { api_key, .. } => api_key.as_deref(), + ProviderConfig::MiniMax { api_key, .. } => api_key.as_deref(), ProviderConfig::Bedrock { .. } => None, // AWS credential chain, no API key ProviderConfig::GitHubCopilot { .. } => None, // OAuth only, no API key } @@ -290,6 +320,7 @@ impl ProviderConfig { ProviderConfig::Gemini { auth, .. } => auth.as_ref(), ProviderConfig::Custom { auth, .. } => auth.as_ref(), ProviderConfig::Stakpak { auth, .. } => auth.as_ref(), + ProviderConfig::MiniMax { auth, .. } => auth.as_ref(), ProviderConfig::Bedrock { .. } => None, ProviderConfig::GitHubCopilot { auth, .. } => auth.as_ref(), } @@ -312,7 +343,8 @@ impl ProviderConfig { ProviderConfig::OpenAI { api_key, .. } | ProviderConfig::Gemini { api_key, .. } | ProviderConfig::Custom { api_key, .. } - | ProviderConfig::Stakpak { api_key, .. } => { + | ProviderConfig::Stakpak { api_key, .. } + | ProviderConfig::MiniMax { api_key, .. } => { api_key.as_ref().map(ProviderAuth::api_key) } ProviderConfig::Anthropic { @@ -363,6 +395,11 @@ impl ProviderConfig { auth: auth_field, api_key, .. + } + | ProviderConfig::MiniMax { + auth: auth_field, + api_key, + .. } => { *auth_field = Some(auth); *api_key = None; @@ -413,6 +450,11 @@ impl ProviderConfig { auth: auth_field, api_key, .. + } + | ProviderConfig::MiniMax { + auth: auth_field, + api_key, + .. } => { *auth_field = None; *api_key = None; @@ -446,6 +488,7 @@ impl ProviderConfig { ProviderConfig::Gemini { api_endpoint, .. } => api_endpoint.as_deref(), ProviderConfig::Custom { api_endpoint, .. } => Some(api_endpoint.as_str()), ProviderConfig::Stakpak { api_endpoint, .. } => api_endpoint.as_deref(), + ProviderConfig::MiniMax { api_endpoint, .. } => api_endpoint.as_deref(), ProviderConfig::Bedrock { .. } => None, // No custom endpoint in config ProviderConfig::GitHubCopilot { api_endpoint, .. } => api_endpoint.as_deref(), } @@ -461,6 +504,7 @@ impl ProviderConfig { | ProviderConfig::Anthropic { api_endpoint, .. } | ProviderConfig::Gemini { api_endpoint, .. } | ProviderConfig::Stakpak { api_endpoint, .. } + | ProviderConfig::MiniMax { api_endpoint, .. } | ProviderConfig::GitHubCopilot { api_endpoint, .. } => { *api_endpoint = endpoint; } @@ -583,6 +627,24 @@ impl ProviderConfig { } } + /// Create a MiniMax provider config (legacy, uses api_key field) + pub fn minimax(api_key: String, api_endpoint: Option) -> Self { + ProviderConfig::MiniMax { + api_key: Some(api_key), + api_endpoint, + auth: None, + } + } + + /// Create a MiniMax provider config with auth + pub fn minimax_with_auth(auth: ProviderAuth, api_endpoint: Option) -> Self { + ProviderConfig::MiniMax { + api_key: None, + api_endpoint, + auth: Some(auth), + } + } + /// Create a GitHub Copilot provider config with auth (OAuth token from device flow) pub fn github_copilot_with_auth(auth: ProviderAuth) -> Self { ProviderConfig::GitHubCopilot { @@ -646,6 +708,11 @@ impl ProviderConfig { api_endpoint: None, auth: None, }), + "minimax" => Some(ProviderConfig::MiniMax { + api_key: None, + api_endpoint: None, + auth: None, + }), // Custom providers need an endpoint, Bedrock uses AWS credential chain _ => None, } diff --git a/libs/shared/src/models/stakai_adapter.rs b/libs/shared/src/models/stakai_adapter.rs index 1b1508f37..c72e990f1 100644 --- a/libs/shared/src/models/stakai_adapter.rs +++ b/libs/shared/src/models/stakai_adapter.rs @@ -462,6 +462,12 @@ pub fn build_inference_config(config: &LLMProviderConfig) -> Result { + if let Some(api_key) = provider_config.api_key() { + inference_config = + inference_config.minimax(api_key.to_string(), api_endpoint.clone()); + } + } ProviderConfig::GitHubCopilot { .. } => { tracing::debug!( provider = %name, @@ -574,6 +580,20 @@ fn build_provider_registry_direct(config: &LLMProviderConfig) -> Result { + if let Some(api_key) = provider_config.api_key() { + use stakai::providers::minimax::{ + MiniMaxConfig as StakaiMiniMaxConfig, MiniMaxProvider, + }; + let mut minimax_config = StakaiMiniMaxConfig::new(api_key.to_string()); + if let Some(endpoint) = api_endpoint { + minimax_config = minimax_config.with_base_url(endpoint.clone()); + } + let provider = MiniMaxProvider::new(minimax_config) + .map_err(|e| format!("Failed to create MiniMax provider: {}", e))?; + registry = registry.register("minimax", provider); + } + } ProviderConfig::GitHubCopilot { api_endpoint, .. } => { if let Some(access_token) = provider_config.access_token() { let mut copilot_config = CopilotConfig::new(access_token.to_string());