#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
use std::sync::Arc;
use motosan_agent_loop::{LlmClient, MotosanAiClient};
use crate::auth::Auth;
use crate::error::{AppError, Result};
use crate::settings::{LlmProviderKind, Settings};
pub fn build_llm_client(settings: &Settings, auth: &Auth) -> Result<Arc<dyn LlmClient>> {
let kind = LlmProviderKind::parse(&settings.model.provider).map_err(AppError::Config)?;
match kind {
LlmProviderKind::Anthropic => build_anthropic(settings, auth),
LlmProviderKind::ClaudeCode => build_claude_code(settings),
LlmProviderKind::CodexCli => build_codex_cli(settings),
}
}
fn build_anthropic(settings: &Settings, auth: &Auth) -> Result<Arc<dyn LlmClient>> {
let api_key = std::env::var("ANTHROPIC_API_KEY")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(|| auth.api_key("anthropic").map(str::to_string))
.ok_or_else(|| AppError::MissingEnv("ANTHROPIC_API_KEY".to_string()))?;
let client = motosan_ai::Client::builder()
.provider(motosan_ai::Provider::Anthropic)
.api_key(&api_key)
.anthropic_base_url(&settings.anthropic.base_url)
.model(&settings.model.name)
.build()
.map_err(|e| AppError::Llm(format!("failed to build motosan-ai Anthropic client: {e}")))?;
Ok(
Arc::new(MotosanAiClient::new(client).with_max_tokens(settings.model.max_tokens))
as Arc<dyn LlmClient>,
)
}
fn build_claude_code(settings: &Settings) -> Result<Arc<dyn LlmClient>> {
let client = motosan_ai::Client::builder()
.provider(motosan_ai::Provider::ClaudeCode)
.api_key("cli-managed")
.model(&settings.model.name)
.build()
.map_err(|e| {
AppError::Llm(format!(
"failed to build motosan-ai claude-code client: {e}"
))
})?;
Ok(
Arc::new(MotosanAiClient::new(client).with_max_tokens(settings.model.max_tokens))
as Arc<dyn LlmClient>,
)
}
fn build_codex_cli(settings: &Settings) -> Result<Arc<dyn LlmClient>> {
let client = motosan_ai::Client::builder()
.provider(motosan_ai::Provider::CodexCli)
.api_key("cli-managed")
.model(&settings.model.name)
.build()
.map_err(|e| AppError::Llm(format!("failed to build motosan-ai codex-cli client: {e}")))?;
Ok(
Arc::new(MotosanAiClient::new(client).with_max_tokens(settings.model.max_tokens))
as Arc<dyn LlmClient>,
)
}
#[cfg(test)]
mod multi_provider_tests {
use super::*;
use crate::auth::{Auth, ProviderAuth};
use crate::settings::Settings;
use std::sync::{Mutex, MutexGuard};
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn env_lock() -> MutexGuard<'static, ()> {
match ENV_LOCK.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
fn settings_with_provider(provider: &str) -> Settings {
let mut s = Settings::default();
s.model.provider = provider.to_string();
s
}
#[test]
fn build_anthropic_with_env_key_succeeds() {
let _env_guard = env_lock();
std::env::set_var("ANTHROPIC_API_KEY", "sk-ant-test");
let client = build_llm_client(&settings_with_provider("anthropic"), &Auth::default());
std::env::remove_var("ANTHROPIC_API_KEY");
assert!(client.is_ok(), "build failed: {:?}", client.err());
}
#[test]
fn build_anthropic_with_auth_json_key_succeeds() {
let _env_guard = env_lock();
std::env::remove_var("ANTHROPIC_API_KEY");
let mut auth = Auth::default();
auth.0.insert(
"anthropic".into(),
ProviderAuth::ApiKey {
key: "sk-ant-test".into(),
},
);
let client = build_llm_client(&settings_with_provider("anthropic"), &auth);
assert!(client.is_ok(), "build failed: {:?}", client.err());
}
#[test]
fn build_anthropic_with_no_key_fails_missing_env() {
let _env_guard = env_lock();
std::env::remove_var("ANTHROPIC_API_KEY");
let err = match build_llm_client(&settings_with_provider("anthropic"), &Auth::default()) {
Ok(_) => panic!("must fail without API key"),
Err(err) => err,
};
assert!(
matches!(err, AppError::MissingEnv(ref name) if name == "ANTHROPIC_API_KEY"),
"wrong error variant: {err}"
);
}
#[test]
fn build_claude_code_succeeds_without_api_key() {
let _env_guard = env_lock();
std::env::remove_var("ANTHROPIC_API_KEY");
let client = build_llm_client(&settings_with_provider("claude-code"), &Auth::default());
assert!(client.is_ok(), "build failed: {:?}", client.err());
}
#[test]
fn build_codex_cli_succeeds_without_api_key() {
let _env_guard = env_lock();
std::env::remove_var("ANTHROPIC_API_KEY");
let client = build_llm_client(&settings_with_provider("codex-cli"), &Auth::default());
assert!(client.is_ok(), "build failed: {:?}", client.err());
}
#[test]
fn build_claude_code_accepts_underscore_alias_without_api_key() {
let _env_guard = env_lock();
std::env::remove_var("ANTHROPIC_API_KEY");
let client = build_llm_client(&settings_with_provider("claude_code"), &Auth::default());
assert!(client.is_ok(), "build failed: {:?}", client.err());
}
#[test]
fn build_codex_cli_accepts_underscore_alias_without_api_key() {
let _env_guard = env_lock();
std::env::remove_var("ANTHROPIC_API_KEY");
let client = build_llm_client(&settings_with_provider("codex_cli"), &Auth::default());
assert!(client.is_ok(), "build failed: {:?}", client.err());
}
#[test]
fn build_with_unknown_provider_returns_config_error() {
let err = match build_llm_client(&settings_with_provider("gpt-4"), &Auth::default()) {
Ok(_) => panic!("must fail for unknown provider"),
Err(err) => err,
};
let msg = format!("{err}");
assert!(msg.contains("unknown LLM provider"), "wrong message: {msg}");
assert!(msg.contains("gpt-4"));
}
}