Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion config/example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 54 additions & 2 deletions crates/codegraph-ai/src/anthropic_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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() {
Expand All @@ -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"
);
}
}
146 changes: 144 additions & 2 deletions crates/codegraph-ai/src/llm_factory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 {
""
},
Expand All @@ -53,7 +94,7 @@ impl LLMProviderFactory {
""
},
if cfg!(feature = "openai-compatible") {
", openai-compatible"
", openai-compatible, minimax"
} else {
""
}
Expand Down Expand Up @@ -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,
Expand All @@ -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<Arc<dyn LLMProvider>> {
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<Arc<dyn LLMProvider>> {
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<Arc<dyn LLMProvider>> {
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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());
}
}
6 changes: 3 additions & 3 deletions crates/codegraph-ai/src/openai_compatible_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading