use super::qwen::{qwen_body_transform, qwen_extra_headers};
use super::{
Provider,
anthropic::AnthropicProvider,
claude_cli::ClaudeCliProvider,
codex_cli::CodexCliProvider,
codex_oauth::CodexOAuthProvider,
custom_openai_compatible::{BodyTransformFn, OpenAIProvider},
gemini::GeminiProvider,
opencode_cli::OpenCodeCliProvider,
};
use crate::config::{Config, ProviderConfig};
use anyhow::Result;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, LazyLock};
type ProviderFactoryFn = Box<
dyn Fn(&Config) -> Pin<Box<dyn Future<Output = Result<Option<Arc<dyn Provider>>>> + Send + '_>>
+ Send
+ Sync,
>;
type SyncProviderFactoryFn = fn(&Config) -> Result<Option<Arc<dyn Provider>>>;
fn sync_factory(f: SyncProviderFactoryFn) -> ProviderFactoryFn {
Box::new(move |config| Box::pin(async move { f(config) }))
}
struct ProviderRegistration {
display_name: &'static str,
session_id: &'static str,
aliases: &'static [&'static str],
is_enabled: fn(&Config) -> bool,
factory: ProviderFactoryFn,
config_field: fn(&Config) -> Option<&ProviderConfig>,
}
static REGISTRATIONS: LazyLock<Vec<ProviderRegistration>> = LazyLock::new(|| {
vec![
ProviderRegistration {
display_name: "Claude CLI",
session_id: "claude-cli",
aliases: &["claude_cli"],
is_enabled: |c| c.providers.claude_cli.as_ref().is_some_and(|p| p.enabled),
factory: sync_factory(try_create_claude_cli),
config_field: |c| c.providers.claude_cli.as_ref(),
},
ProviderRegistration {
display_name: "OpenCode CLI",
session_id: "opencode-cli",
aliases: &["opencode_cli"],
is_enabled: |c| c.providers.opencode_cli.as_ref().is_some_and(|p| p.enabled),
factory: sync_factory(try_create_opencode_cli),
config_field: |c| c.providers.opencode_cli.as_ref(),
},
ProviderRegistration {
display_name: "Codex CLI",
session_id: "codex-cli",
aliases: &["codex_cli"],
is_enabled: |c| c.providers.codex_cli.as_ref().is_some_and(|p| p.enabled),
factory: sync_factory(try_create_codex_cli),
config_field: |c| c.providers.codex_cli.as_ref(),
},
ProviderRegistration {
display_name: "Codex",
session_id: "codex",
aliases: &["codex_oauth"],
is_enabled: |c| c.providers.codex.as_ref().is_some_and(|p| p.enabled),
factory: sync_factory(try_create_codex_oauth),
config_field: |c| c.providers.codex.as_ref(),
},
ProviderRegistration {
display_name: "OpenCode",
session_id: "opencode",
aliases: &["opencode_api"],
is_enabled: |c| c.providers.opencode.as_ref().is_some_and(|p| p.enabled),
factory: Box::new(|config| Box::pin(try_create_opencode(config))),
config_field: |c| c.providers.opencode.as_ref(),
},
ProviderRegistration {
display_name: "Qwen",
session_id: "qwen",
aliases: &[],
is_enabled: |c| c.providers.qwen.as_ref().is_some_and(|p| p.enabled),
factory: Box::new(|config| Box::pin(try_create_qwen(config))),
config_field: |c| c.providers.qwen.as_ref(),
},
ProviderRegistration {
display_name: "Anthropic",
session_id: "anthropic",
aliases: &[],
is_enabled: |c| c.providers.anthropic.as_ref().is_some_and(|p| p.enabled),
factory: sync_factory(try_create_anthropic),
config_field: |c| c.providers.anthropic.as_ref(),
},
ProviderRegistration {
display_name: "OpenAI",
session_id: "openai",
aliases: &[],
is_enabled: |c| c.providers.openai.as_ref().is_some_and(|p| p.enabled),
factory: sync_factory(try_create_openai),
config_field: |c| c.providers.openai.as_ref(),
},
ProviderRegistration {
display_name: "GitHub Copilot",
session_id: "github",
aliases: &[],
is_enabled: |c| c.providers.github.as_ref().is_some_and(|p| p.enabled),
factory: sync_factory(try_create_github),
config_field: |c| c.providers.github.as_ref(),
},
ProviderRegistration {
display_name: "Google Gemini",
session_id: "gemini",
aliases: &[],
is_enabled: |c| c.providers.gemini.as_ref().is_some_and(|p| p.enabled),
factory: sync_factory(try_create_gemini),
config_field: |c| c.providers.gemini.as_ref(),
},
ProviderRegistration {
display_name: "OpenRouter",
session_id: "openrouter",
aliases: &[],
is_enabled: |c| c.providers.openrouter.as_ref().is_some_and(|p| p.enabled),
factory: sync_factory(try_create_openrouter),
config_field: |c| c.providers.openrouter.as_ref(),
},
ProviderRegistration {
display_name: "Minimax",
session_id: "minimax",
aliases: &[],
is_enabled: |c| c.providers.minimax.as_ref().is_some_and(|p| p.enabled),
factory: sync_factory(try_create_minimax),
config_field: |c| c.providers.minimax.as_ref(),
},
ProviderRegistration {
display_name: "z.ai GLM",
session_id: "zhipu",
aliases: &[],
is_enabled: |c| c.providers.zhipu.as_ref().is_some_and(|p| p.enabled),
factory: sync_factory(try_create_zhipu),
config_field: |c| c.providers.zhipu.as_ref(),
},
ProviderRegistration {
display_name: "Ollama",
session_id: "ollama",
aliases: &[],
is_enabled: |c| c.providers.ollama.as_ref().is_some_and(|p| p.enabled),
factory: sync_factory(try_create_ollama),
config_field: |c| c.providers.ollama.as_ref(),
},
ProviderRegistration {
display_name: "Custom",
session_id: "custom",
aliases: &[],
is_enabled: |c| c.providers.active_custom().is_some(),
factory: sync_factory(try_create_custom),
config_field: |_| None, },
]
});
pub const PROVIDER_NAMES: &[&str] = &[
"Claude CLI",
"OpenCode CLI",
"Codex CLI",
"Codex",
"OpenCode",
"Qwen",
"Anthropic",
"OpenAI",
"GitHub Copilot",
"Google Gemini",
"OpenRouter",
"Minimax",
"z.ai GLM",
"Ollama",
"Custom",
];
fn provider_enabled(config: &Config, idx: usize) -> bool {
REGISTRATIONS
.get(idx)
.is_some_and(|reg| (reg.is_enabled)(config))
}
pub fn provider_session_ids() -> Vec<&'static str> {
REGISTRATIONS.iter().map(|r| r.session_id).collect()
}
pub(crate) fn is_local_base_url(url: &str) -> bool {
let after_scheme = url
.strip_prefix("http://")
.or_else(|| url.strip_prefix("https://"))
.unwrap_or(url);
let host_and_port = after_scheme.split(['/', '?']).next().unwrap_or("");
let bare = if let Some(rest) = host_and_port.strip_prefix('[') {
rest.split_once(']').map(|(h, _)| h).unwrap_or(rest)
} else {
host_and_port
.rsplit_once(':')
.map(|(h, _)| h)
.unwrap_or(host_and_port)
};
let bare = bare.to_ascii_lowercase();
if bare == "localhost"
|| bare == "127.0.0.1"
|| bare == "0.0.0.0"
|| bare == "::1"
|| bare.ends_with(".local")
|| bare.starts_with("192.168.")
|| bare.starts_with("10.")
{
return true;
}
let mut parts = bare.split('.');
if parts.next() == Some("172")
&& let Some(second) = parts.next()
&& let Ok(n) = second.parse::<u8>()
&& (16..=31).contains(&n)
{
return true;
}
false
}
fn local_thinking_body_transform(enable: bool) -> BodyTransformFn {
Arc::new(move |mut body: serde_json::Value| {
if let Some(obj) = body.as_object_mut()
&& !obj.contains_key("chat_template_kwargs")
{
obj.insert(
"chat_template_kwargs".to_string(),
serde_json::json!({ "enable_thinking": enable }),
);
}
body
})
}
pub async fn create_provider(config: &Config) -> Result<Arc<dyn Provider>> {
let (provider, warning) = create_provider_with_warning(config).await?;
if let Some(msg) = &warning {
tracing::warn!("{}", msg);
}
Ok(provider)
}
pub async fn create_provider_with_warning(
config: &Config,
) -> Result<(Arc<dyn Provider>, Option<String>)> {
let mut primary: Option<Arc<dyn Provider>> = None;
let mut failed_name: Option<&str> = None;
let mut warning: Option<String> = None;
for (i, reg) in REGISTRATIONS.iter().enumerate() {
if !provider_enabled(config, i) {
continue;
}
match (reg.factory)(config).await {
Ok(Some(provider)) => {
if let Some(failed) = failed_name {
let msg = format!(
"{} failed to initialize — fell back to {}. Run /onboard:provider to reconfigure.",
failed, reg.display_name
);
tracing::warn!("{}", msg);
warning = Some(msg);
}
tracing::info!("Using enabled provider: {}", reg.display_name);
primary = Some(provider);
break;
}
Ok(None) => {
tracing::warn!(
"{} enabled but could not be created (missing API key?)",
reg.display_name
);
if failed_name.is_none() {
failed_name = Some(reg.display_name);
}
}
Err(e) => {
tracing::error!("{} provider error: {}", reg.display_name, e);
if failed_name.is_none() {
failed_name = Some(reg.display_name);
}
}
}
}
let fallback_providers = if let Some(fallback) = &config.providers.fallback
&& fallback.enabled
{
let chain = fallback_chain(fallback);
let mut providers = Vec::new();
for name in &chain {
match create_fallback(config, name).await {
Ok(p) => {
tracing::info!("Fallback provider '{}' ready", name);
providers.push(p);
}
Err(e) => {
tracing::warn!("Fallback provider '{}' skipped: {}", name, e);
}
}
}
providers
} else {
Vec::new()
};
match primary {
Some(provider) => {
if fallback_providers.is_empty() {
Ok((provider, warning))
} else {
tracing::info!(
"Wrapping primary provider with {} fallback(s)",
fallback_providers.len()
);
Ok((
Arc::new(super::FallbackProvider::new_with_health(
provider,
fallback_providers,
)),
warning,
))
}
}
None => {
if let Some(first) = fallback_providers.into_iter().next() {
tracing::warn!("No primary provider enabled, using first fallback");
Ok((first, warning))
} else {
tracing::info!("No provider configured, using placeholder provider");
Ok((Arc::new(super::PlaceholderProvider), warning))
}
}
}
}
pub async fn create_provider_by_name(config: &Config, name: &str) -> Result<Arc<dyn Provider>> {
if !name.starts_with("custom:")
&& config
.providers
.custom
.as_ref()
.is_some_and(|m| m.contains_key(name))
&& let Some(p) = try_create_custom_by_name(config, name)?
{
return Ok(p);
}
for reg in REGISTRATIONS.iter() {
if reg.session_id == name || reg.aliases.contains(&name) {
let provider = (reg.factory)(config).await?.ok_or_else(|| {
anyhow::anyhow!("{} not configured (missing API key)", reg.display_name)
})?;
return Ok(provider);
}
}
if let Some(custom_name) = name.strip_prefix("custom:") {
return try_create_custom_by_name(config, custom_name)?
.ok_or_else(|| anyhow::anyhow!("Custom provider '{}' not configured", custom_name));
}
try_create_custom_by_name(config, name)?
.ok_or_else(|| anyhow::anyhow!("Unknown provider: {}", name))
}
fn try_create_custom_by_name(config: &Config, name: &str) -> Result<Option<Arc<dyn Provider>>> {
let customs = match &config.providers.custom {
Some(map) => map,
None => return Ok(None),
};
let custom_config = match customs.get(name) {
Some(cfg) => cfg.clone(),
None => {
tracing::warn!("Custom provider '{}' not found in config", name);
return Ok(None);
}
};
let api_key = custom_config.api_key.clone().unwrap_or_default();
let Some(mut base_url) = custom_config.base_url.clone() else {
tracing::warn!(
"Custom provider '{}' has no base_url configured — skipping (run /onboard:provider)",
name
);
return Ok(None);
};
if !base_url.contains("/chat/completions") {
base_url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
}
let key_status = if api_key.is_empty() {
"MISSING".to_string()
} else if api_key == "__EXISTING_KEY__" {
"SENTINEL(__EXISTING_KEY__ never merged)".to_string()
} else {
format!("present (len={})", api_key.len())
};
if api_key.is_empty() || api_key == "__EXISTING_KEY__" {
tracing::warn!(
"Custom provider '{}' being constructed without a real api_key ({}). \
Requests will fail auth. Check keys.toml has [providers.custom.{}] api_key = \"...\" \
and that the key isn't the literal sentinel string.",
name,
key_status,
name,
);
}
tracing::info!(
"Creating custom provider '{}' at: {} (api_key: {})",
name,
base_url,
key_status,
);
let mut builder =
OpenAIProvider::with_base_url(api_key.clone(), base_url.clone()).with_name(name);
if is_local_base_url(&base_url) {
let enable = custom_config.enable_thinking.unwrap_or(true);
builder = builder.with_body_transform(local_thinking_body_transform(enable));
}
let provider = configure_openai_compatible(builder, &custom_config);
Ok(Some(Arc::new(provider)))
}
pub(crate) fn fallback_chain(fallback: &crate::config::FallbackProviderConfig) -> Vec<String> {
let mut chain: Vec<String> = fallback.providers.clone();
if let Some(ref legacy) = fallback.provider
&& !chain.iter().any(|p| p == legacy)
{
chain.push(legacy.clone());
}
chain
}
async fn create_fallback(config: &Config, fallback_type: &str) -> Result<Arc<dyn Provider>> {
if !fallback_type.starts_with("custom:")
&& config
.providers
.custom
.as_ref()
.is_some_and(|m| m.contains_key(fallback_type))
{
tracing::info!("Using fallback: Custom '{}'", fallback_type);
return try_create_custom_by_name(config, fallback_type)?
.ok_or_else(|| anyhow::anyhow!("Custom provider '{}' not configured", fallback_type));
}
if let Some(custom_name) = fallback_type.strip_prefix("custom:") {
tracing::info!("Using fallback: Custom '{}'", custom_name);
return try_create_custom_by_name(config, custom_name)?
.ok_or_else(|| anyhow::anyhow!("Custom provider '{}' not configured", custom_name));
}
for reg in REGISTRATIONS.iter() {
if reg.session_id == fallback_type || reg.aliases.contains(&fallback_type) {
tracing::info!("Using fallback: {}", reg.display_name);
return (reg.factory)(config)
.await?
.ok_or_else(|| anyhow::anyhow!("{} not configured", reg.display_name));
}
}
Err(anyhow::anyhow!(
"Unknown fallback provider: {}",
fallback_type
))
}
pub fn active_provider_vision(config: &Config) -> Option<(String, String, String)> {
let (name, _) = config.providers.active_provider_and_model();
let custom_cfg = if !name.starts_with("custom:")
&& config
.providers
.custom
.as_ref()
.is_some_and(|m| m.contains_key(name.as_str()))
{
config
.providers
.custom
.as_ref()
.and_then(|m| m.get(&name))
.cloned()
} else if let Some(custom_name) = name.strip_prefix("custom:") {
config
.providers
.custom
.as_ref()
.and_then(|m| m.get(custom_name))
.cloned()
} else {
None
};
let native_cfg: Option<&ProviderConfig> = REGISTRATIONS
.iter()
.find(|reg| reg.session_id == name || reg.aliases.contains(&name.as_str()))
.and_then(|reg| (reg.config_field)(config));
let active_cfg = custom_cfg.as_ref().or(native_cfg);
if let Some(cfg) = active_cfg
&& let (Some(api_key), Some(vision_model)) = (&cfg.api_key, &cfg.vision_model)
{
let base_url = cfg
.base_url
.clone()
.unwrap_or_else(|| "https://api.openai.com/v1/chat/completions".to_string());
let base_url = if base_url.contains("/chat/completions") {
base_url
} else {
format!("{}/chat/completions", base_url.trim_end_matches('/'))
};
return Some((api_key.clone(), base_url, vision_model.clone()));
}
None
}
pub fn active_provider_generation(config: &Config) -> Option<(String, String, String)> {
let (name, _) = config.providers.active_provider_and_model();
let custom_cfg = if !name.starts_with("custom:")
&& config
.providers
.custom
.as_ref()
.is_some_and(|m| m.contains_key(name.as_str()))
{
config
.providers
.custom
.as_ref()
.and_then(|m| m.get(&name))
.cloned()
} else if let Some(custom_name) = name.strip_prefix("custom:") {
config
.providers
.custom
.as_ref()
.and_then(|m| m.get(custom_name))
.cloned()
} else {
None
};
let native_cfg: Option<&ProviderConfig> = REGISTRATIONS
.iter()
.find(|reg| reg.session_id == name || reg.aliases.contains(&name.as_str()))
.and_then(|reg| (reg.config_field)(config));
let active_cfg = custom_cfg.as_ref().or(native_cfg);
if let Some(cfg) = active_cfg
&& let (Some(api_key), Some(generation_model)) = (&cfg.api_key, &cfg.generation_model)
{
let raw = cfg
.base_url
.clone()
.unwrap_or_else(|| "https://api.openai.com/v1".to_string());
let base_url = raw
.trim_end_matches("/chat/completions")
.trim_end_matches('/')
.to_string();
return Some((api_key.clone(), base_url, generation_model.clone()));
}
None
}
pub fn effective_generation_model(config: &Config) -> String {
active_provider_generation(config)
.map(|(_, _, m)| m)
.unwrap_or_else(|| config.image.generation.model.clone())
}
fn try_create_github(config: &Config) -> Result<Option<Arc<dyn Provider>>> {
use super::copilot::{COPILOT_CHAT_URL, CopilotTokenManager, copilot_extra_headers};
let github_config = match &config.providers.github {
Some(cfg) => cfg,
None => return Ok(None),
};
let oauth_token = github_config.api_key.clone().filter(|k| !k.is_empty());
let Some(oauth_token) = oauth_token else {
tracing::warn!(
"GitHub Copilot enabled but no OAuth token found. \
Run /onboard:provider to authenticate."
);
return Ok(None);
};
let manager = Arc::new(CopilotTokenManager::new(oauth_token));
manager.clone().start_background_refresh();
let mgr_clone = manager.clone();
let token_fn: super::custom_openai_compatible::TokenFn =
Arc::new(move || mgr_clone.get_cached_token());
let base_url = github_config
.base_url
.clone()
.unwrap_or_else(|| COPILOT_CHAT_URL.to_string());
tracing::info!("Using GitHub Copilot at: {}", base_url);
let provider = configure_openai_compatible(
OpenAIProvider::with_base_url("copilot-managed".to_string(), base_url)
.with_name("GitHub Copilot")
.with_token_fn(token_fn)
.with_extra_headers(copilot_extra_headers()),
github_config,
);
Ok(Some(Arc::new(provider)))
}
const QWEN_DEFAULT_DASHSCOPE_URL: &str =
"https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions";
async fn try_create_qwen(config: &Config) -> Result<Option<Arc<dyn Provider>>> {
let qwen_config = match &config.providers.qwen {
Some(cfg) => cfg,
None => return Ok(None),
};
let Some(api_key) = qwen_config.api_key.as_ref().filter(|k| !k.is_empty()) else {
tracing::warn!("Qwen enabled but no API key configured — run /onboard:provider");
return Ok(None);
};
let base_url = qwen_config
.base_url
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| QWEN_DEFAULT_DASHSCOPE_URL.to_string());
let base_url = if base_url.contains("/chat/completions") {
base_url
} else {
format!("{}/chat/completions", base_url.trim_end_matches('/'))
};
tracing::info!("Using Qwen (DashScope) at: {}", base_url);
let qwen_limiter = Arc::clone(&super::rate_limiter::QWEN_OAUTH_LIMITER);
let enable_thinking = qwen_config.enable_thinking;
let builder = OpenAIProvider::with_base_url(api_key.clone(), base_url)
.with_name("qwen")
.with_extra_headers(qwen_extra_headers())
.with_body_transform(Arc::new(move |body| {
let mut body = qwen_body_transform(body);
if let Some(enable) = enable_thinking
&& let Some(obj) = body.as_object_mut()
{
obj.insert(
"enable_thinking".to_string(),
serde_json::Value::Bool(enable),
);
}
body
}))
.with_rate_limiter(qwen_limiter);
let provider = configure_openai_compatible(builder, qwen_config);
Ok(Some(Arc::new(provider)))
}
fn try_create_openrouter(config: &Config) -> Result<Option<Arc<dyn Provider>>> {
let openrouter_config = match &config.providers.openrouter {
Some(cfg) => cfg,
None => return Ok(None),
};
let Some(api_key) = &openrouter_config.api_key else {
tracing::warn!("OpenRouter enabled but API key missing — check keys.toml");
return Ok(None);
};
let base_url = openrouter_config
.base_url
.clone()
.unwrap_or_else(|| "https://openrouter.ai/api/v1/chat/completions".to_string());
let model = openrouter_config
.default_model
.clone()
.unwrap_or_else(|| "openai/gpt-4o".to_string());
let is_free = model.ends_with(":free");
tracing::info!(
"Using OpenRouter at: {} (model={}, free={})",
base_url,
model,
is_free
);
let mut provider = configure_openai_compatible(
OpenAIProvider::with_base_url(api_key.clone(), base_url)
.with_name("openrouter")
.with_default_model(model.clone())
.with_extra_headers(vec![
("X-Title".to_string(), "Open Crabs".to_string()),
(
"HTTP-Referer".to_string(),
"https://opencrabs.com".to_string(),
),
]),
openrouter_config,
);
if is_free {
let limiter = super::rate_limiter::OPENROUTER_FREE_LIMITERS.get(&model);
provider = provider.with_rate_limiter(limiter);
tracing::info!("Rate limiter attached for :free model: {}", model);
}
Ok(Some(Arc::new(provider)))
}
fn try_create_minimax(config: &Config) -> Result<Option<Arc<dyn Provider>>> {
let minimax_config = match &config.providers.minimax {
Some(cfg) => {
tracing::debug!(
"Minimax config: enabled={}, has_key={}",
cfg.enabled,
cfg.api_key.is_some()
);
cfg
}
None => return Ok(None),
};
let Some(api_key) = &minimax_config.api_key else {
tracing::warn!("Minimax enabled but API key missing — check keys.toml");
return Ok(None);
};
let base_url = minimax_config
.base_url
.clone()
.unwrap_or_else(|| "https://api.minimax.io/v1".to_string());
let full_url = if base_url.contains("minimax.io") && !base_url.contains("/text/") {
format!("{}/text/chatcompletion_v2", base_url.trim_end_matches('/'))
} else {
base_url
};
tracing::info!("Using Minimax at: {}", full_url);
let mut provider = configure_openai_compatible(
OpenAIProvider::with_base_url(api_key.clone(), full_url).with_name("minimax"),
minimax_config,
);
if minimax_config.vision_model.is_none() {
provider = provider.with_vision_model("MiniMax-Text-01".to_string());
}
Ok(Some(Arc::new(provider)))
}
fn try_create_zhipu(config: &Config) -> Result<Option<Arc<dyn Provider>>> {
let zhipu_config = match &config.providers.zhipu {
Some(cfg) => cfg,
None => return Ok(None),
};
let Some(api_key) = &zhipu_config.api_key else {
tracing::warn!("z.ai GLM enabled but API key missing — check keys.toml");
return Ok(None);
};
let base_url = match zhipu_config.endpoint_type.as_deref() {
Some("coding") => "https://api.z.ai/api/coding/paas/v4/chat/completions",
_ => "https://api.z.ai/api/paas/v4/chat/completions",
};
tracing::info!(
"Using z.ai GLM at: {} (endpoint_type: {:?})",
base_url,
zhipu_config.endpoint_type
);
let provider = configure_openai_compatible(
OpenAIProvider::with_base_url(api_key.clone(), base_url.to_string()).with_name("zhipu"),
zhipu_config,
);
Ok(Some(Arc::new(provider)))
}
fn try_create_ollama(config: &Config) -> Result<Option<Arc<dyn Provider>>> {
let ollama_config = match &config.providers.ollama {
Some(cfg) => cfg,
None => return Ok(None),
};
let base_url = ollama_config
.base_url
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "http://localhost:11434/v1/chat/completions".to_string());
let base_url = if base_url.contains("/chat/completions") {
base_url
} else {
format!("{}/chat/completions", base_url.trim_end_matches('/'))
};
let api_key = ollama_config.api_key.clone().unwrap_or_default();
tracing::info!(
"Using Ollama at: {} (has_key={})",
base_url,
!api_key.is_empty()
);
let mut builder = OpenAIProvider::with_base_url(api_key, base_url.clone()).with_name("ollama");
if is_local_base_url(&base_url) {
let enable = ollama_config.enable_thinking.unwrap_or(true);
builder = builder.with_body_transform(local_thinking_body_transform(enable));
}
let provider = configure_openai_compatible(builder, ollama_config);
Ok(Some(Arc::new(provider)))
}
fn try_create_custom(config: &Config) -> Result<Option<Arc<dyn Provider>>> {
let (name, custom_config) = match config.providers.active_custom() {
Some((n, c)) => (n.to_string(), c.clone()),
None => {
tracing::warn!("Custom provider requested but no active custom provider found");
return Ok(None);
}
};
let api_key = custom_config.api_key.clone().unwrap_or_default();
let Some(mut base_url) = custom_config.base_url.clone() else {
tracing::warn!(
"Custom provider '{}' has no base_url configured — skipping (run /onboard:provider)",
name
);
return Ok(None);
};
if !base_url.contains("/chat/completions") {
base_url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
}
tracing::info!(
"Using Custom OpenAI-compatible '{}' at: {} (has_key={})",
name,
base_url,
!api_key.is_empty()
);
let mut builder = OpenAIProvider::with_base_url(api_key, base_url.clone()).with_name(&name);
if is_local_base_url(&base_url) {
let enable = custom_config.enable_thinking.unwrap_or(true);
builder = builder.with_body_transform(local_thinking_body_transform(enable));
}
let provider = configure_openai_compatible(builder, &custom_config);
Ok(Some(Arc::new(provider)))
}
fn configure_openai_compatible(
mut provider: OpenAIProvider,
config: &ProviderConfig,
) -> OpenAIProvider {
tracing::debug!(
"configure_openai_compatible: default_model = {:?}",
config.default_model
);
if let Some(model) = &config.default_model {
tracing::info!("Using custom default model: {}", model);
provider = provider.with_default_model(model.clone());
}
if let Some(vm) = &config.vision_model {
tracing::info!("Vision model configured: {}", vm);
provider = provider.with_vision_model(vm.clone());
}
if let Some(cw) = config.context_window {
tracing::info!("Context window configured: {} tokens", cw);
provider = provider.with_context_window(cw);
}
if !config.models.is_empty() {
tracing::debug!(
"Loaded {} configured models for provider",
config.models.len()
);
provider = provider.with_models(config.models.clone());
}
if let Some(cache) = config.cache_enabled {
provider = provider.with_cache_enabled(cache);
if cache {
tracing::info!("OpenRouter response caching enabled");
}
}
if let Some(ttl) = config.cache_ttl {
provider = provider.with_cache_ttl(ttl);
tracing::info!("OpenRouter cache TTL: {}s", ttl);
}
provider
}
fn try_create_openai(config: &Config) -> Result<Option<Arc<dyn Provider>>> {
let openai_config = match &config.providers.openai {
Some(cfg) => cfg,
None => return Ok(None),
};
if let Some(base_url) = &openai_config.base_url
&& openai_config.api_key.is_none()
{
tracing::info!("Using local LLM at: {}", base_url);
let mut builder = OpenAIProvider::local(base_url.clone()).with_name("openai");
if is_local_base_url(base_url) {
let enable = openai_config.enable_thinking.unwrap_or(true);
builder = builder.with_body_transform(local_thinking_body_transform(enable));
}
let provider = configure_openai_compatible(builder, openai_config);
return Ok(Some(Arc::new(provider)));
}
if let Some(api_key) = &openai_config.api_key {
tracing::info!("Using OpenAI provider");
let provider = configure_openai_compatible(
OpenAIProvider::new(api_key.clone()).with_name("openai"),
openai_config,
);
return Ok(Some(Arc::new(provider)));
}
tracing::warn!("OpenAI enabled but no API key and no base_url — check keys.toml");
Ok(None)
}
fn try_create_gemini(config: &Config) -> Result<Option<Arc<dyn Provider>>> {
let gemini_config = match &config.providers.gemini {
Some(cfg) => cfg,
None => return Ok(None),
};
let api_key = match &gemini_config.api_key {
Some(key) if !key.is_empty() => key.clone(),
_ => {
tracing::warn!("Gemini enabled but API key missing — check keys.toml");
return Ok(None);
}
};
let model = gemini_config
.default_model
.clone()
.unwrap_or_else(|| "gemini-2.0-flash".to_string());
tracing::info!("Using Gemini provider with model: {}", model);
Ok(Some(Arc::new(
GeminiProvider::new(api_key).with_model(model),
)))
}
fn try_create_claude_cli(config: &Config) -> Result<Option<Arc<dyn Provider>>> {
let cli_config = match &config.providers.claude_cli {
Some(cfg) if cfg.enabled => cfg,
_ => return Ok(None),
};
match ClaudeCliProvider::new() {
Ok(mut provider) => {
if let Some(model) = &cli_config.default_model {
provider = provider.with_default_model(model.clone());
}
tracing::info!("Using Claude CLI provider (Max subscription, no API key needed)");
Ok(Some(Arc::new(provider)))
}
Err(e) => {
tracing::warn!("Claude CLI enabled but binary not found: {}", e);
Ok(None)
}
}
}
fn try_create_opencode_cli(config: &Config) -> Result<Option<Arc<dyn Provider>>> {
let cli_config = match &config.providers.opencode_cli {
Some(cfg) if cfg.enabled => cfg,
_ => return Ok(None),
};
match OpenCodeCliProvider::new() {
Ok(mut provider) => {
if let Some(model) = &cli_config.default_model {
provider = provider.with_default_model(model.clone());
}
tracing::info!("Using OpenCode CLI provider (free models, no API key needed)");
Ok(Some(Arc::new(provider)))
}
Err(e) => {
tracing::warn!("OpenCode CLI enabled but binary not found: {}", e);
Ok(None)
}
}
}
fn try_create_codex_cli(config: &Config) -> Result<Option<Arc<dyn Provider>>> {
let cli_config = match &config.providers.codex_cli {
Some(cfg) if cfg.enabled => cfg,
_ => return Ok(None),
};
match CodexCliProvider::new() {
Ok(mut provider) => {
if let Some(model) = &cli_config.default_model {
provider = provider.with_default_model(model.clone());
}
tracing::info!(
"Using Codex CLI provider (ChatGPT/Codex subscription, no API key needed)"
);
Ok(Some(Arc::new(provider)))
}
Err(e) => {
tracing::warn!("Codex CLI enabled but binary not found: {}", e);
Ok(None)
}
}
}
fn try_create_codex_oauth(config: &Config) -> Result<Option<Arc<dyn Provider>>> {
let codex_config = match &config.providers.codex {
Some(cfg) if cfg.enabled => cfg,
_ => return Ok(None),
};
match CodexOAuthProvider::new() {
Ok(mut provider) => {
if let Some(model) = &codex_config.default_model {
provider = provider.with_default_model(model.clone());
}
tracing::info!("Using Codex provider (OAuth authenticated, no API key needed)");
Ok(Some(Arc::new(provider)))
}
Err(e) => {
tracing::warn!("Codex OAuth enabled but not authenticated: {}", e);
Ok(None)
}
}
}
async fn try_create_opencode(config: &Config) -> Result<Option<Arc<dyn Provider>>> {
let opencode_config = match &config.providers.opencode {
Some(cfg) if cfg.enabled => cfg,
_ => return Ok(None),
};
let Some(api_key) = &opencode_config.api_key else {
tracing::warn!("OpenCode enabled but API key missing — check keys.toml");
return Ok(None);
};
let base_url = opencode_config
.base_url
.clone()
.unwrap_or_else(|| "https://opencode.ai/zen/go/v1/chat/completions".to_string());
let model = opencode_config
.default_model
.clone()
.unwrap_or_else(|| "qwen3.6-plus".to_string());
tracing::info!("Using OpenCode API at: {} (model={})", base_url, model);
let provider = configure_openai_compatible(
OpenAIProvider::with_base_url(api_key.clone(), base_url)
.with_name("opencode")
.with_default_model(model.clone()),
opencode_config,
);
Ok(Some(Arc::new(provider)))
}
fn try_create_anthropic(config: &Config) -> Result<Option<Arc<dyn Provider>>> {
let anthropic_config = match &config.providers.anthropic {
Some(cfg) => cfg,
None => return Ok(None),
};
let api_key = match &anthropic_config.api_key {
Some(key) => key.clone(),
None => {
tracing::warn!("Anthropic enabled but API key missing — check keys.toml");
return Ok(None);
}
};
let mut provider = AnthropicProvider::new(api_key);
if let Some(model) = &anthropic_config.default_model {
tracing::info!("Using custom default model: {}", model);
provider = provider.with_default_model(model.clone());
}
tracing::info!("Using Anthropic provider");
Ok(Some(Arc::new(provider)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Config, ProviderConfig, ProviderConfigs};
#[tokio::test]
async fn test_create_provider_with_anthropic() {
let config = Config {
providers: ProviderConfigs {
anthropic: Some(ProviderConfig {
enabled: true,
api_key: Some("test-key".to_string()),
base_url: None,
default_model: None,
models: vec![],
vision_model: None,
..Default::default()
}),
..Default::default()
},
..Default::default()
};
let result = create_provider(&config).await;
assert!(result.is_ok());
let provider = result.unwrap();
assert_eq!(provider.name(), "anthropic");
}
#[tokio::test]
async fn test_create_provider_with_minimax() {
let config = Config {
providers: ProviderConfigs {
minimax: Some(ProviderConfig {
enabled: true,
api_key: Some("test-key".to_string()),
base_url: Some("https://api.minimax.io/v1".to_string()),
default_model: Some("MiniMax-M2.7".to_string()),
models: vec![],
vision_model: None,
..Default::default()
}),
..Default::default()
},
..Default::default()
};
let result = create_provider(&config).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_minimax_takes_priority() {
let config = Config {
providers: ProviderConfigs {
openai: Some(ProviderConfig {
enabled: true,
api_key: Some("openai-key".to_string()),
base_url: None,
default_model: None,
models: vec![],
vision_model: None,
..Default::default()
}),
minimax: Some(ProviderConfig {
enabled: true,
api_key: Some("minimax-key".to_string()),
base_url: Some("https://api.minimax.io/v1".to_string()),
default_model: None,
models: vec![],
vision_model: None,
..Default::default()
}),
..Default::default()
},
..Default::default()
};
let result = create_provider(&config).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_create_provider_no_credentials() {
let config = Config {
providers: ProviderConfigs::default(),
..Default::default()
};
let result = create_provider(&config).await;
assert!(result.is_ok());
}
}