use super::qwen::{looks_like_qwen_target, 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: "Xiaomi",
session_id: "xiaomi",
aliases: &["mimo", "xiaomi-mimo"],
is_enabled: |c| c.providers.xiaomi.as_ref().is_some_and(|p| p.enabled),
factory: sync_factory(try_create_xiaomi),
config_field: |c| c.providers.xiaomi.as_ref(),
},
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] = &[
"Xiaomi",
"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(crate) fn auto_qwen_cache_transform(base_url: String) -> BodyTransformFn {
use std::collections::HashSet;
use std::sync::Mutex;
let seen: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
Arc::new(move |body: serde_json::Value| {
let model = body.get("model").and_then(|v| v.as_str()).unwrap_or("");
if looks_like_qwen_target(&base_url, model) {
let key = format!("{base_url}|{model}");
let first_time = {
let mut guard = seen.lock().unwrap_or_else(|p| p.into_inner());
guard.insert(key)
};
if first_time {
tracing::info!(
"Auto-enabled Qwen ephemeral cache_control for custom provider \
(base_url={base_url}, model={model})"
);
}
qwen_body_transform(body)
} else {
body
}
})
}
pub(crate) fn chain_body_transforms(a: BodyTransformFn, b: BodyTransformFn) -> BodyTransformFn {
Arc::new(move |body| b(a(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);
}
}
}
}
match primary {
Some(provider) => {
let provider = wrap_with_fallback_chain(config, provider).await?;
Ok((provider, warning))
}
None => {
if let Some(fallback) = &config.providers.fallback
&& fallback.enabled
{
let chain = fallback_chain(fallback);
for name in &chain {
if let Ok(p) = create_fallback(config, name).await {
tracing::warn!(
"No primary provider enabled, using fallback '{}' as primary",
name
);
return Ok((p, warning));
}
}
}
tracing::info!("No provider configured, using placeholder provider");
Ok((Arc::new(super::PlaceholderProvider), warning))
}
}
}
pub(crate) async fn wrap_with_fallback_chain(
config: &Config,
primary: Arc<dyn Provider>,
) -> Result<Arc<dyn Provider>> {
let Some(fallback) = config.providers.fallback.as_ref().filter(|f| f.enabled) else {
return Ok(primary);
};
let chain = fallback_chain(fallback);
let primary_name = primary.name().to_string();
let mut providers = Vec::new();
for name in &chain {
if *name == primary_name {
tracing::debug!(
"Skipping fallback '{}' — same name as primary, would create a self-loop",
name
);
continue;
}
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);
}
}
}
if providers.is_empty() {
return Ok(primary);
}
tracing::info!(
"Wrapping primary provider '{}' with {} fallback(s)",
primary_name,
providers.len()
);
Ok(Arc::new(super::FallbackProvider::new_with_health(
primary, providers,
)))
}
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);
let cache_transform = auto_qwen_cache_transform(base_url.clone());
let combined_transform = if is_local_base_url(&base_url) {
let enable = custom_config.enable_thinking.unwrap_or(true);
chain_body_transforms(local_thinking_body_transform(enable), cache_transform)
} else {
cache_transform
};
builder = builder.with_body_transform(combined_transform);
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(vision_model) = &cfg.vision_model
{
let api_key = cfg
.api_key
.clone()
.filter(|k| !k.is_empty())
.unwrap_or_default();
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, 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(generation_model) = &cfg.generation_model
{
let api_key = cfg
.api_key
.clone()
.filter(|k| !k.is_empty())
.unwrap_or_default();
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, 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 openrouter_config.cache_enabled.is_none() {
provider = provider.with_cache_enabled(true);
}
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)))
}
const XIAOMI_DEFAULT_BASE_URL: &str = "https://api.xiaomimimo.com/v1/chat/completions";
const XIAOMI_TOKEN_PLAN_URL: &str = "https://token-plan-ams.xiaomimimo.com/v1/chat/completions";
fn try_create_xiaomi(config: &Config) -> Result<Option<Arc<dyn Provider>>> {
let xiaomi_config = match &config.providers.xiaomi {
Some(cfg) => cfg,
None => return Ok(None),
};
let api_key = match xiaomi_config.api_key.clone().filter(|k| !k.is_empty()) {
Some(k) => k,
None => {
tracing::debug!(
"Xiaomi has no api_key; add one in keys.toml ([providers.xiaomi] api_key) to enable it"
);
return Ok(None);
}
};
let base_url = match xiaomi_config.endpoint_type.as_deref() {
Some("token-plan") => XIAOMI_TOKEN_PLAN_URL.to_string(),
_ => {
let url = xiaomi_config
.base_url
.clone()
.unwrap_or_else(|| XIAOMI_DEFAULT_BASE_URL.to_string());
if url.contains("/chat/completions") {
url
} else {
format!("{}/chat/completions", url.trim_end_matches('/'))
}
}
};
let enable_thinking = xiaomi_config.enable_thinking.unwrap_or(true);
tracing::info!("Using Xiaomi at: {base_url} (thinking: {enable_thinking})");
let mut builder = OpenAIProvider::with_base_url(api_key, base_url).with_name("xiaomi");
if !enable_thinking {
builder = builder.with_body_transform(Arc::new(|mut body| {
if let Some(obj) = body.as_object_mut() {
obj.insert(
"thinking".to_string(),
serde_json::json!({ "type": "disabled" }),
);
}
body
}));
}
let provider = configure_openai_compatible(builder, xiaomi_config);
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);
let cache_transform = auto_qwen_cache_transform(base_url.clone());
let combined_transform = if is_local_base_url(&base_url) {
let enable = custom_config.enable_thinking.unwrap_or(true);
chain_body_transforms(local_thinking_body_transform(enable), cache_transform)
} else {
cache_transform
};
builder = builder.with_body_transform(combined_transform);
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);
let mut provider = GeminiProvider::new(api_key).with_model(model);
if let Some(cw) = gemini_config.context_window {
tracing::info!("Gemini context window override: {} tokens", cw);
provider = provider.with_context_window(cw);
}
Ok(Some(Arc::new(provider)))
}
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());
}
if let Some(cw) = cli_config.context_window {
provider = provider.with_context_window(cw);
}
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());
}
if let Some(cw) = cli_config.context_window {
provider = provider.with_context_window(cw);
}
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());
}
if let Some(cw) = cli_config.context_window {
provider = provider.with_context_window(cw);
}
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());
}
if let Some(cw) = anthropic_config.context_window {
tracing::info!("Anthropic context window override: {} tokens", cw);
provider = provider.with_context_window(cw);
}
tracing::info!("Using Anthropic provider");
Ok(Some(Arc::new(provider)))
}