diff --git a/.env.example b/.env.example index 10b2d487..50014af6 100644 --- a/.env.example +++ b/.env.example @@ -44,12 +44,18 @@ CODEGRAPH_OLLAMA_URL=http://localhost:11434 # CODEGRAPH_LMSTUDIO_URL=http://localhost:1234 # OPENAI_API_KEY=sk-... # ANTHROPIC_API_KEY=... +# MINIMAX_API_KEY=... +# MINIMAX_REGION=global_en +# MINIMAX_OPENAI_BASE_URL=https://api.minimax.io/v1 +# MINIMAX_CN_OPENAI_BASE_URL=https://api.minimaxi.com/v1 +# MINIMAX_ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic +# MINIMAX_CN_ANTHROPIC_BASE_URL=https://api.minimaxi.com/anthropic # JINA_API_KEY=... # XAI_API_KEY=... # OPENAI_API_BASE=https://api.openai.com/v1 # for openai-compatible providers # --- LLM (for agent responses) --- -CODEGRAPH_LLM_PROVIDER=ollama # ollama | openai | anthropic | openai-compatible | xai | lmstudio +CODEGRAPH_LLM_PROVIDER=ollama # ollama | openai | anthropic | minimax | minimax-anthropic | openai-compatible | xai | lmstudio CODEGRAPH_MODEL=qwen2.5-coder:14b CODEGRAPH_CONTEXT_WINDOW=32768 #MCP_CODE_AGENT_MAX_OUTPUT_TOKENS=52000 # Hard-cap since f.ex. claude code doesn't support more than 64K and might crash on such outputs diff --git a/config/example.toml b/config/example.toml index 9c4d895d..c8a3be2b 100644 --- a/config/example.toml +++ b/config/example.toml @@ -14,10 +14,16 @@ ollama_url = "http://localhost:11434" # skip_chunking = false [llm] -provider = "ollama" # or openai / anthropic / openai-compatible / xai / lmstudio +provider = "ollama" # or openai / anthropic / minimax / minimax-anthropic / openai-compatible / xai / lmstudio model = "qwen3:4b" context_window = 252000 max_retries = 3 +# MiniMax supports global_en and cn_zh endpoint pairs for both protocols. +# minimax_region = "global_en" +# minimax_openai_base_url = "https://api.minimax.io/v1" +# minimax_cn_openai_base_url = "https://api.minimaxi.com/v1" +# minimax_anthropic_base_url = "https://api.minimax.io/anthropic" +# minimax_cn_anthropic_base_url = "https://api.minimaxi.com/anthropic" [rerank] # Optional reranking provider: jina | lmstudio diff --git a/crates/codegraph-ai/src/anthropic_provider.rs b/crates/codegraph-ai/src/anthropic_provider.rs index 5cc63250..806ff3cf 100644 --- a/crates/codegraph-ai/src/anthropic_provider.rs +++ b/crates/codegraph-ai/src/anthropic_provider.rs @@ -5,7 +5,7 @@ use reqwest::Client; use serde::{Deserialize, Serialize}; use std::time::{Duration, Instant}; -const ANTHROPIC_API_BASE: &str = "https://api.anthropic.com/v1"; +const ANTHROPIC_API_BASE: &str = "https://api.anthropic.com"; const DEFAULT_MODEL: &str = "claude-3-5-sonnet-20241022"; const API_VERSION: &str = "2023-06-01"; const STRUCTURED_OUTPUTS_BETA: &str = "structured-outputs-2025-11-13"; @@ -15,6 +15,8 @@ const STRUCTURED_OUTPUTS_BETA: &str = "structured-outputs-2025-11-13"; pub struct AnthropicConfig { /// API key for Anthropic pub api_key: String, + /// Base URL for the Anthropic-compatible API + pub base_url: String, /// Model to use (e.g., "claude-3-5-sonnet-20241022") pub model: String, /// Maximum context window @@ -29,6 +31,8 @@ impl Default for AnthropicConfig { fn default() -> Self { Self { api_key: std::env::var("ANTHROPIC_API_KEY").unwrap_or_default(), + base_url: std::env::var("ANTHROPIC_BASE_URL") + .unwrap_or_else(|_| ANTHROPIC_API_BASE.to_string()), model: DEFAULT_MODEL.to_string(), context_window: 200_000, timeout_secs: 120, @@ -198,7 +202,10 @@ impl AnthropicProvider { let mut request_builder = self .client - .post(format!("{}/messages", ANTHROPIC_API_BASE)) + .post(format!( + "{}/v1/messages", + self.config.base_url.trim_end_matches('/') + )) .header("x-api-key", &self.config.api_key) .header("anthropic-version", API_VERSION) .header("content-type", "application/json"); @@ -532,6 +539,9 @@ struct Usage { #[cfg(test)] mod tests { use super::*; + use crate::llm_provider::LLMProvider; + use std::io::{Read, Write}; + use std::net::TcpListener; #[test] fn test_config_from_env() { @@ -548,4 +558,46 @@ mod tests { }; assert!(AnthropicProvider::new(config).is_err()); } + + #[tokio::test] + async fn test_custom_base_url_appends_v1_messages() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let address = listener.local_addr().expect("read test server address"); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept test request"); + let mut request = [0_u8; 4096]; + let size = stream.read(&mut request).expect("read test request"); + let request = String::from_utf8_lossy(&request[..size]); + let path = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or_default() + .to_string(); + let body = r#"{"id":"msg_test","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"model":"MiniMax-M3","stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}"#; + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .expect("write test response"); + path + }); + + let provider = AnthropicProvider::new(AnthropicConfig { + api_key: "test-key".to_string(), + base_url: format!("http://{}/anthropic", address), + model: "MiniMax-M3".to_string(), + ..Default::default() + }) + .expect("create test provider"); + + let response = provider.generate("hello").await.expect("generate response"); + assert_eq!(response.content, "ok"); + assert_eq!( + server.join().expect("join test server"), + "/anthropic/v1/messages" + ); + } } diff --git a/crates/codegraph-ai/src/llm_factory.rs b/crates/codegraph-ai/src/llm_factory.rs index e8c974bf..26bcc107 100644 --- a/crates/codegraph-ai/src/llm_factory.rs +++ b/crates/codegraph-ai/src/llm_factory.rs @@ -15,6 +15,43 @@ use crate::openai_llm_provider::{OpenAIConfig, OpenAIProvider}; #[cfg(feature = "openai-compatible")] use crate::openai_compatible_provider::{OpenAICompatibleConfig, OpenAICompatibleProvider}; +#[cfg(any(feature = "openai-compatible", feature = "anthropic"))] +const MINIMAX_MODELS: [&str; 2] = ["MiniMax-M3", "MiniMax-M2.7"]; + +#[cfg(any(feature = "openai-compatible", feature = "anthropic"))] +fn minimax_model(config: &LLMConfig) -> String { + config + .model + .clone() + .unwrap_or_else(|| MINIMAX_MODELS[0].to_string()) +} + +#[cfg(any(feature = "openai-compatible", feature = "anthropic"))] +fn minimax_is_cn_region(config: &LLMConfig) -> bool { + matches!( + config.minimax_region.to_ascii_lowercase().as_str(), + "cn_zh" | "cn" | "china" + ) +} + +#[cfg(feature = "openai-compatible")] +fn minimax_openai_base_url(config: &LLMConfig) -> String { + if minimax_is_cn_region(config) { + config.minimax_cn_openai_base_url.clone() + } else { + config.minimax_openai_base_url.clone() + } +} + +#[cfg(feature = "anthropic")] +fn minimax_anthropic_base_url(config: &LLMConfig) -> String { + if minimax_is_cn_region(config) { + config.minimax_cn_anthropic_base_url.clone() + } else { + config.minimax_anthropic_base_url.clone() + } +} + /// Factory for creating LLM providers based on configuration pub struct LLMProviderFactory; @@ -33,17 +70,21 @@ impl LLMProviderFactory { "lmstudio" => Self::create_lmstudio_provider(config), #[cfg(feature = "anthropic")] "anthropic" => Self::create_anthropic_provider(config), + #[cfg(feature = "anthropic")] + "minimax-anthropic" => Self::create_minimax_anthropic_provider(config), #[cfg(feature = "openai-llm")] "openai" => Self::create_openai_provider(config), #[cfg(feature = "openai-llm")] "xai" => Self::create_xai_provider(config), #[cfg(feature = "openai-compatible")] "openai-compatible" => Self::create_openai_compatible_provider(config), + #[cfg(feature = "openai-compatible")] + "minimax" | "minimax-openai" => Self::create_minimax_openai_provider(config), _ => Err(anyhow!( "Unsupported LLM provider: {}. Available providers: ollama, lmstudio{}{}{}", provider_name, if cfg!(feature = "anthropic") { - ", anthropic" + ", anthropic, minimax-anthropic" } else { "" }, @@ -53,7 +94,7 @@ impl LLMProviderFactory { "" }, if cfg!(feature = "openai-compatible") { - ", openai-compatible" + ", openai-compatible, minimax" } else { "" } @@ -155,6 +196,7 @@ impl LLMProviderFactory { let anthropic_config = AnthropicConfig { api_key, + base_url: config.anthropic_base_url.clone(), model: config.model.clone().unwrap_or_else(|| "claude".to_string()), context_window: config.context_window, timeout_secs: config.timeout_secs, @@ -164,6 +206,58 @@ impl LLMProviderFactory { Ok(Arc::new(AnthropicProvider::new(anthropic_config)?)) } + /// Create a MiniMax provider using its OpenAI-compatible endpoint. + #[cfg(feature = "openai-compatible")] + fn create_minimax_openai_provider(config: &LLMConfig) -> Result> { + let api_key = config + .minimax_api_key + .clone() + .or_else(|| std::env::var("MINIMAX_API_KEY").ok()) + .ok_or_else(|| { + anyhow!( + "MiniMax API key not found. Set 'minimax_api_key' in config or MINIMAX_API_KEY environment variable" + ) + })?; + + let compat_config = OpenAICompatibleConfig { + base_url: minimax_openai_base_url(config), + model: minimax_model(config), + context_window: config.context_window, + timeout_secs: config.timeout_secs, + max_retries: 3, + api_key: Some(api_key), + provider_name: "minimax".to_string(), + use_responses_api: false, + }; + + Ok(Arc::new(OpenAICompatibleProvider::new(compat_config)?)) + } + + /// Create a MiniMax provider using its Anthropic-compatible endpoint. + #[cfg(feature = "anthropic")] + fn create_minimax_anthropic_provider(config: &LLMConfig) -> Result> { + let api_key = config + .minimax_api_key + .clone() + .or_else(|| std::env::var("MINIMAX_API_KEY").ok()) + .ok_or_else(|| { + anyhow!( + "MiniMax API key not found. Set 'minimax_api_key' in config or MINIMAX_API_KEY environment variable" + ) + })?; + + let anthropic_config = AnthropicConfig { + api_key, + base_url: minimax_anthropic_base_url(config), + model: minimax_model(config), + context_window: config.context_window, + timeout_secs: config.timeout_secs, + max_retries: 3, + }; + + Ok(Arc::new(AnthropicProvider::new(anthropic_config)?)) + } + /// Create an OpenAI provider #[cfg(feature = "openai-llm")] fn create_openai_provider(config: &LLMConfig) -> Result> { @@ -276,9 +370,15 @@ impl LLMProviderFactory { #[cfg(feature = "openai-compatible")] providers.push("openai-compatible"); + #[cfg(feature = "openai-compatible")] + providers.push("minimax"); + #[cfg(feature = "anthropic")] providers.push("anthropic"); + #[cfg(feature = "anthropic")] + providers.push("minimax-anthropic"); + #[cfg(feature = "openai-llm")] providers.push("openai"); @@ -330,4 +430,46 @@ mod tests { let err = result.err().unwrap(); assert!(err.to_string().contains("LLM is not enabled")); } + + #[cfg(any(feature = "openai-compatible", feature = "anthropic"))] + #[test] + fn test_minimax_model_defaults() { + assert_eq!(MINIMAX_MODELS, ["MiniMax-M3", "MiniMax-M2.7"]); + assert_eq!(minimax_model(&LLMConfig::default()), "MiniMax-M3"); + } + + #[cfg(feature = "openai-compatible")] + #[test] + fn test_minimax_openai_provider_uses_cn_endpoint() { + let config = LLMConfig { + enabled: true, + provider: "minimax".to_string(), + minimax_api_key: Some("test-key".to_string()), + minimax_region: "cn_zh".to_string(), + ..Default::default() + }; + + assert_eq!( + minimax_openai_base_url(&config), + "https://api.minimaxi.com/v1" + ); + assert!(LLMProviderFactory::create_from_config(&config).is_ok()); + } + + #[cfg(feature = "anthropic")] + #[test] + fn test_minimax_anthropic_provider_uses_global_endpoint() { + let config = LLMConfig { + enabled: true, + provider: "minimax-anthropic".to_string(), + minimax_api_key: Some("test-key".to_string()), + ..Default::default() + }; + + assert_eq!( + minimax_anthropic_base_url(&config), + "https://api.minimax.io/anthropic" + ); + assert!(LLMProviderFactory::create_from_config(&config).is_ok()); + } } diff --git a/crates/codegraph-ai/src/openai_compatible_provider.rs b/crates/codegraph-ai/src/openai_compatible_provider.rs index 64f80d3a..6564e8a7 100644 --- a/crates/codegraph-ai/src/openai_compatible_provider.rs +++ b/crates/codegraph-ai/src/openai_compatible_provider.rs @@ -335,9 +335,9 @@ impl OpenAICompatibleProvider { } }); - // Skip reasoning_effort for Ollama - it doesn't support this parameter - // and may interpret it incorrectly as "think" - let reasoning_effort = if self.config.provider_name == "ollama" { + // Skip reasoning_effort for providers that use model-specific thinking defaults. + let reasoning_effort = if matches!(self.config.provider_name.as_str(), "ollama" | "minimax") + { None } else { config.reasoning_effort.clone() diff --git a/crates/codegraph-core/src/config_manager.rs b/crates/codegraph-core/src/config_manager.rs index dcb0b334..ba640cd4 100644 --- a/crates/codegraph-core/src/config_manager.rs +++ b/crates/codegraph-core/src/config_manager.rs @@ -174,6 +174,34 @@ pub struct LLMConfig { #[serde(default = "default_xai_base_url")] pub xai_base_url: String, + /// Anthropic-compatible base URL (default: https://api.anthropic.com) + #[serde(default = "default_anthropic_base_url")] + pub anthropic_base_url: String, + + /// MiniMax API key + #[serde(default)] + pub minimax_api_key: Option, + + /// MiniMax endpoint region: "global_en" or "cn_zh" + #[serde(default = "default_minimax_region")] + pub minimax_region: String, + + /// MiniMax OpenAI-compatible global endpoint + #[serde(default = "default_minimax_openai_base_url")] + pub minimax_openai_base_url: String, + + /// MiniMax OpenAI-compatible China endpoint + #[serde(default = "default_minimax_cn_openai_base_url")] + pub minimax_cn_openai_base_url: String, + + /// MiniMax Anthropic-compatible global endpoint + #[serde(default = "default_minimax_anthropic_base_url")] + pub minimax_anthropic_base_url: String, + + /// MiniMax Anthropic-compatible China endpoint + #[serde(default = "default_minimax_cn_anthropic_base_url")] + pub minimax_cn_anthropic_base_url: String, + /// Context window size #[serde(default = "default_context_window")] pub context_window: usize, @@ -230,6 +258,13 @@ impl Default for LLMConfig { openai_api_key: None, xai_api_key: None, xai_base_url: default_xai_base_url(), + anthropic_base_url: default_anthropic_base_url(), + minimax_api_key: None, + minimax_region: default_minimax_region(), + minimax_openai_base_url: default_minimax_openai_base_url(), + minimax_cn_openai_base_url: default_minimax_cn_openai_base_url(), + minimax_anthropic_base_url: default_minimax_anthropic_base_url(), + minimax_cn_anthropic_base_url: default_minimax_cn_anthropic_base_url(), context_window: default_context_window(), temperature: default_temperature(), insights_mode: default_insights_mode(), @@ -522,6 +557,24 @@ fn default_llm_provider() -> String { fn default_xai_base_url() -> String { "https://api.x.ai/v1".to_string() } +fn default_anthropic_base_url() -> String { + "https://api.anthropic.com".to_string() +} +fn default_minimax_region() -> String { + "global_en".to_string() +} +fn default_minimax_openai_base_url() -> String { + "https://api.minimax.io/v1".to_string() +} +fn default_minimax_cn_openai_base_url() -> String { + "https://api.minimaxi.com/v1".to_string() +} +fn default_minimax_anthropic_base_url() -> String { + "https://api.minimax.io/anthropic".to_string() +} +fn default_minimax_cn_anthropic_base_url() -> String { + "https://api.minimaxi.com/anthropic".to_string() +} fn default_context_window() -> usize { 32000 @@ -795,6 +848,28 @@ impl ConfigManager { config.llm.reasoning_effort = Some(effort); } + if let Ok(base_url) = std::env::var("ANTHROPIC_BASE_URL") { + config.llm.anthropic_base_url = base_url; + } + if let Ok(key) = std::env::var("MINIMAX_API_KEY") { + config.llm.minimax_api_key = Some(key); + } + if let Ok(region) = std::env::var("MINIMAX_REGION") { + config.llm.minimax_region = region; + } + if let Ok(base_url) = std::env::var("MINIMAX_OPENAI_BASE_URL") { + config.llm.minimax_openai_base_url = base_url; + } + if let Ok(base_url) = std::env::var("MINIMAX_CN_OPENAI_BASE_URL") { + config.llm.minimax_cn_openai_base_url = base_url; + } + if let Ok(base_url) = std::env::var("MINIMAX_ANTHROPIC_BASE_URL") { + config.llm.minimax_anthropic_base_url = base_url; + } + if let Ok(base_url) = std::env::var("MINIMAX_CN_ANTHROPIC_BASE_URL") { + config.llm.minimax_cn_anthropic_base_url = base_url; + } + if let Ok(max_output) = std::env::var("MCP_CODE_AGENT_MAX_OUTPUT_TOKENS") { if let Ok(tokens) = max_output.parse() { config.llm.mcp_code_agent_max_output_tokens = Some(tokens); diff --git a/crates/codegraph-mcp-rig/src/adapter/llm_adapter.rs b/crates/codegraph-mcp-rig/src/adapter/llm_adapter.rs index 8dc308a8..6e4d96d4 100644 --- a/crates/codegraph-mcp-rig/src/adapter/llm_adapter.rs +++ b/crates/codegraph-mcp-rig/src/adapter/llm_adapter.rs @@ -20,9 +20,57 @@ pub enum RigProvider { }, /// LM Studio - uses OpenAI-compatible API LMStudio, + /// MiniMax using its OpenAI-compatible API + MiniMaxOpenAI { + base_url: String, + }, + /// MiniMax using its Anthropic-compatible API + MiniMaxAnthropic { + base_url: String, + }, } impl RigProvider { + fn minimax_is_cn_region() -> bool { + matches!( + env::var("MINIMAX_REGION") + .unwrap_or_else(|_| "global_en".to_string()) + .to_ascii_lowercase() + .as_str(), + "cn_zh" | "cn" | "china" + ) + } + + fn minimax_openai_base_url() -> String { + let variable = if Self::minimax_is_cn_region() { + "MINIMAX_CN_OPENAI_BASE_URL" + } else { + "MINIMAX_OPENAI_BASE_URL" + }; + env::var(variable).unwrap_or_else(|_| { + if Self::minimax_is_cn_region() { + "https://api.minimaxi.com/v1".to_string() + } else { + "https://api.minimax.io/v1".to_string() + } + }) + } + + fn minimax_anthropic_base_url() -> String { + let variable = if Self::minimax_is_cn_region() { + "MINIMAX_CN_ANTHROPIC_BASE_URL" + } else { + "MINIMAX_ANTHROPIC_BASE_URL" + }; + env::var(variable).unwrap_or_else(|_| { + if Self::minimax_is_cn_region() { + "https://api.minimaxi.com/anthropic".to_string() + } else { + "https://api.minimax.io/anthropic".to_string() + } + }) + } + /// Detect provider from environment variables /// Priority: CODEGRAPH_LLM_PROVIDER > API key presence pub fn from_env() -> Result { @@ -35,6 +83,11 @@ impl RigProvider { if env::var("XAI_API_KEY").is_ok() { return Ok(Self::XAI); } + if env::var("MINIMAX_API_KEY").is_ok() { + return Ok(Self::MiniMaxOpenAI { + base_url: Self::minimax_openai_base_url(), + }); + } if env::var("ANTHROPIC_API_KEY").is_ok() { return Ok(Self::Anthropic); } @@ -61,6 +114,12 @@ impl RigProvider { "ollama" => Ok(Self::Ollama), "xai" => Ok(Self::XAI), "lmstudio" => Ok(Self::LMStudio), + "minimax" | "minimax-openai" => Ok(Self::MiniMaxOpenAI { + base_url: Self::minimax_openai_base_url(), + }), + "minimax-anthropic" => Ok(Self::MiniMaxAnthropic { + base_url: Self::minimax_anthropic_base_url(), + }), "openai-compatible" => { let base_url = env::var("CODEGRAPH_OPENAI_COMPATIBLE_URL") .or_else(|_| env::var("OPENAI_COMPATIBLE_URL")) @@ -68,7 +127,7 @@ impl RigProvider { Ok(Self::OpenAICompatible { base_url }) } _ => Err(anyhow!( - "Unknown provider: {}. Supported: openai, anthropic, ollama, xai, lmstudio, openai-compatible", + "Unknown provider: {}. Supported: openai, anthropic, ollama, xai, lmstudio, minimax, minimax-anthropic, openai-compatible", name )), } @@ -90,6 +149,9 @@ fn default_model_for_provider() -> String { Ok(RigProvider::Ollama) => "llama3.2".to_string(), Ok(RigProvider::XAI) => "grok-3-latest".to_string(), Ok(RigProvider::LMStudio) => "default".to_string(), + Ok(RigProvider::MiniMaxOpenAI { .. }) | Ok(RigProvider::MiniMaxAnthropic { .. }) => { + "MiniMax-M3".to_string() + } Ok(RigProvider::OpenAICompatible { .. }) => "default".to_string(), Err(_) => "gpt-4o".to_string(), } @@ -131,6 +193,26 @@ impl RigLLMAdapter { rig::providers::anthropic::Client::from_env() } + /// Create a MiniMax OpenAI-compatible client using Chat Completions. + #[cfg(feature = "openai")] + pub fn minimax_openai_client(base_url: &str) -> rig::providers::openai::CompletionsClient { + let api_key = env::var("MINIMAX_API_KEY").unwrap_or_else(|_| "no-key".to_string()); + env::set_var("OPENAI_API_KEY", &api_key); + env::set_var("OPENAI_BASE_URL", base_url); + rig::providers::openai::Client::from_env().completions_api() + } + + /// Create a MiniMax Anthropic-compatible client with its direct base URL. + #[cfg(feature = "anthropic")] + pub fn minimax_anthropic_client(base_url: &str) -> rig::providers::anthropic::Client { + let api_key = env::var("MINIMAX_API_KEY").unwrap_or_else(|_| "no-key".to_string()); + rig::providers::anthropic::Client::builder() + .api_key(api_key) + .base_url(base_url) + .build() + .expect("MiniMax Anthropic client configuration is invalid") + } + /// Create Ollama client from environment #[cfg(feature = "ollama")] pub fn ollama_client() -> rig::providers::ollama::Client { @@ -235,6 +317,18 @@ mod tests { assert!(RigProvider::from_name("unknown").is_err()); } + #[test] + fn test_minimax_provider_names() { + assert!(matches!( + RigProvider::from_name("minimax").unwrap(), + RigProvider::MiniMaxOpenAI { .. } + )); + assert!(matches!( + RigProvider::from_name("minimax-anthropic").unwrap(), + RigProvider::MiniMaxAnthropic { .. } + )); + } + #[test] fn test_default_max_turns() { // Without env var, should return 8 (conservative default) diff --git a/crates/codegraph-mcp-rig/src/agent/builder.rs b/crates/codegraph-mcp-rig/src/agent/builder.rs index c012d853..0a023430 100644 --- a/crates/codegraph-mcp-rig/src/agent/builder.rs +++ b/crates/codegraph-mcp-rig/src/agent/builder.rs @@ -10,10 +10,10 @@ use crate::agent::lats::LatsAgent; use crate::agent::react::AnthropicAgent; #[cfg(feature = "ollama")] use crate::agent::react::OllamaAgent; -#[cfg(feature = "openai")] -use crate::agent::react::OpenAIAgent; #[cfg(feature = "xai")] use crate::agent::react::XAIAgent; +#[cfg(feature = "openai")] +use crate::agent::react::{MiniMaxOpenAIAgent, OpenAIAgent}; #[allow(unused_imports)] use crate::agent::reflexion::ReflexionAgent; use crate::prompts::{get_max_turns, get_tier_system_prompt, AnalysisType}; @@ -171,6 +171,10 @@ impl RigAgentBuilder { RigProvider::OpenAI => Ok(Box::new(self.build_openai_react()?)), #[cfg(feature = "anthropic")] RigProvider::Anthropic => Ok(Box::new(self.build_anthropic_react()?)), + #[cfg(feature = "anthropic")] + RigProvider::MiniMaxAnthropic { ref base_url } => { + Ok(Box::new(self.build_minimax_anthropic_react(base_url)?)) + } #[cfg(feature = "ollama")] RigProvider::Ollama => Ok(Box::new(self.build_ollama_react()?)), #[cfg(feature = "xai")] @@ -181,6 +185,10 @@ impl RigAgentBuilder { RigProvider::OpenAICompatible { ref base_url } => { Ok(Box::new(self.build_openai_compatible_react(base_url)?)) } + #[cfg(feature = "openai")] + RigProvider::MiniMaxOpenAI { ref base_url } => { + Ok(Box::new(self.build_minimax_openai_react(base_url)?)) + } #[allow(unreachable_patterns)] _ => Err(anyhow!( "Provider {:?} not enabled in build features", @@ -216,13 +224,38 @@ impl RigAgentBuilder { tier: self.tier, })) } + #[cfg(feature = "anthropic")] + RigProvider::MiniMaxAnthropic { ref base_url } => { + let client = RigLLMAdapter::minimax_anthropic_client(base_url); + let model = client.completion_model(&model_name); + Ok(Box::new(LatsAgent { + model, + factory, + max_turns: self.max_turns, + tier: self.tier, + })) + } + #[cfg(feature = "openai")] + RigProvider::MiniMaxOpenAI { ref base_url } => { + let client = RigLLMAdapter::minimax_openai_client(base_url); + let model = client.completion_model(&model_name); + Ok(Box::new(LatsAgent { + model, + factory, + max_turns: self.max_turns, + tier: self.tier, + })) + } // Add other providers as needed, mostly mimicking the above pattern - #[allow(unreachable_patterns)] + #[allow(unreachable_patterns)] _ => { let _ = model_name; let _ = factory; - Err(anyhow!("LATS not yet supported for provider {:?}", provider)) - }, + Err(anyhow!( + "LATS not yet supported for provider {:?}", + provider + )) + } } } @@ -297,6 +330,36 @@ impl RigAgentBuilder { }) } + #[cfg(feature = "anthropic")] + fn build_minimax_anthropic_react(self, base_url: &str) -> Result { + let client = RigLLMAdapter::minimax_anthropic_client(base_url); + let model = get_model_name(); + let system_prompt = self.system_prompt(); + let max_output_tokens = self.get_max_output_tokens(); + let factory = GraphToolFactory::new(self.executor); + + let agent = client + .agent(&model) + .preamble(&system_prompt) + .max_tokens(max_output_tokens) + .tool(factory.transitive_dependencies()) + .tool(factory.circular_dependencies()) + .tool(factory.call_chain()) + .tool(factory.coupling_metrics()) + .tool(factory.hub_nodes()) + .tool(factory.reverse_dependencies()) + .tool(factory.semantic_search()) + .tool(factory.complexity_hotspots()) + .build(); + + Ok(AnthropicAgent { + agent, + factory, + max_turns: self.max_turns, + tier: self.tier, + }) + } + #[cfg(feature = "ollama")] fn build_ollama_react(self) -> Result { let client = RigLLMAdapter::ollama_client(); @@ -414,4 +477,34 @@ impl RigAgentBuilder { tier: self.tier, }) } -} \ No newline at end of file + + #[cfg(feature = "openai")] + fn build_minimax_openai_react(self, base_url: &str) -> Result { + let client = RigLLMAdapter::minimax_openai_client(base_url); + let model = get_model_name(); + let system_prompt = self.system_prompt(); + let max_output_tokens = self.get_max_output_tokens(); + let factory = GraphToolFactory::new(self.executor); + + let agent = client + .agent(&model) + .preamble(&system_prompt) + .max_tokens(max_output_tokens) + .tool(factory.transitive_dependencies()) + .tool(factory.circular_dependencies()) + .tool(factory.call_chain()) + .tool(factory.coupling_metrics()) + .tool(factory.hub_nodes()) + .tool(factory.reverse_dependencies()) + .tool(factory.semantic_search()) + .tool(factory.complexity_hotspots()) + .build(); + + Ok(MiniMaxOpenAIAgent { + agent, + factory, + max_turns: self.max_turns, + tier: self.tier, + }) + } +} diff --git a/crates/codegraph-mcp-rig/src/agent/react.rs b/crates/codegraph-mcp-rig/src/agent/react.rs index 65d5ec3a..d67a1090 100644 --- a/crates/codegraph-mcp-rig/src/agent/react.rs +++ b/crates/codegraph-mcp-rig/src/agent/react.rs @@ -73,6 +73,61 @@ impl RigAgentTrait for OpenAIAgent { } } +/// MiniMax OpenAI-compatible Rig agent using Chat Completions. +#[cfg(feature = "openai")] +pub struct MiniMaxOpenAIAgent { + pub(crate) agent: rig::agent::Agent, + pub(crate) factory: GraphToolFactory, + pub(crate) max_turns: usize, + pub(crate) tier: ContextTier, +} + +#[cfg(feature = "openai")] +#[async_trait] +impl RigAgentTrait for MiniMaxOpenAIAgent { + async fn execute(&self, query: &str) -> Result { + use rig::agent::PromptRequest; + + let mut chat_history = vec![]; + let response = PromptRequest::new(&self.agent, query) + .multi_turn(self.max_turns) + .with_history(&mut chat_history) + .await + .map_err(|e| anyhow!("Agent execution failed: {}", e))?; + + Ok(response) + } + + async fn execute_stream( + &self, + query: &str, + ) -> Result> + Send>>> { + let response = self.execute(query).await?; + let events = vec![ + Ok(AgentEvent::Thinking("Agent processing...".to_string())), + Ok(AgentEvent::OutputChunk(response)), + Ok(AgentEvent::Done), + ]; + Ok(Box::pin(stream::iter(events))) + } + + fn tier(&self) -> ContextTier { + self.tier + } + + fn max_turns(&self) -> usize { + self.max_turns + } + + fn take_tool_call_count(&self) -> usize { + self.factory.take_call_count() + } + + fn take_tool_traces(&self) -> Vec { + self.factory.take_traces() + } +} + /// Anthropic-based Rig agent #[cfg(feature = "anthropic")] pub struct AnthropicAgent { diff --git a/docs/AI_PROVIDERS.md b/docs/AI_PROVIDERS.md index 072ac03d..66395bfc 100644 --- a/docs/AI_PROVIDERS.md +++ b/docs/AI_PROVIDERS.md @@ -216,6 +216,8 @@ Valid values for `llm.provider` (availability depends on build features): - `ollama` - `lmstudio` - `anthropic` +- `minimax` +- `minimax-anthropic` - `openai` - `xai` - `openai-compatible` @@ -258,12 +260,54 @@ Config inputs: - `llm.model` - `llm.xai_base_url` (default `https://api.x.ai/v1`) +### `llm.provider = "minimax"` + +Uses the MiniMax OpenAI-compatible Chat Completions endpoint. The provider keeps both regional endpoint sets in configuration and selects one with `llm.minimax_region`. + +```toml +[llm] +enabled = true +provider = "minimax" +model = "MiniMax-M3" +minimax_region = "global_en" # global_en or cn_zh +minimax_openai_base_url = "https://api.minimax.io/v1" +minimax_cn_openai_base_url = "https://api.minimaxi.com/v1" +context_window = 1000000 +``` + +Set the API key with `MINIMAX_API_KEY`. The first two supported model identifiers are `MiniMax-M3` and `MiniMax-M2.7`; an explicit `llm.model` value is preserved. + +### `llm.provider = "minimax-anthropic"` + +Uses the MiniMax Anthropic-compatible Messages endpoint. The configured Anthropic base URL is used directly, and the client appends `/v1/messages`. + +```toml +[llm] +enabled = true +provider = "minimax-anthropic" +model = "MiniMax-M3" +minimax_region = "global_en" # global_en or cn_zh +minimax_anthropic_base_url = "https://api.minimax.io/anthropic" +minimax_cn_anthropic_base_url = "https://api.minimaxi.com/anthropic" +context_window = 1000000 +``` + +MiniMax model capability reference: + +| Model | Context window | Input modalities | Thinking modes | +| --- | ---: | --- | --- | +| `MiniMax-M3` | 1,000,000 | text, image, video | adaptive, disabled | +| `MiniMax-M2.7` | 204,800 | text | always_on | + +The standard-tier prices are `$0.30/$1.20` per million input/output tokens up to 512,000 input tokens and `$0.60/$2.40` above that threshold. Priority-tier prices are `$0.45/$1.80` and `$0.90/$3.60` for the same two ranges. Cache-read prices are `$0.06`, `$0.12`, `$0.09`, and `$0.18`; `MiniMax-M2.7` also has a `$0.375` per million cache-write price. + ### `llm.provider = "anthropic"` Config inputs: - `ANTHROPIC_API_KEY` (or `llm.anthropic_api_key`) - `llm.model` +- `ANTHROPIC_BASE_URL` (or `llm.anthropic_base_url`; defaults to `https://api.anthropic.com`) ### `llm.provider = "openai-compatible"`