capo-agent 0.1.0

Coding-agent library built on motosan-agent-loop. Composable, embeddable.
Documentation
#![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};

/// Build an `Arc<dyn LlmClient>` based on `settings.model.provider`.
///
/// Dispatch:
/// - `"anthropic"` → direct HTTPS to `settings.anthropic.base_url`
///   (default `https://api.anthropic.com`; override via
///   `CAPO_ANTHROPIC_BASE_URL` env or `anthropic.base_url` in
///   `settings.json` to route through a proxy). API key resolved from
///   `ANTHROPIC_API_KEY` env first, then `auth.api_key("anthropic")`.
///   Fails with `AppError::MissingEnv("ANTHROPIC_API_KEY")` if neither is set.
/// - `"claude-code"` → motosan-ai `Provider::ClaudeCode`, which shells out
///   to the `claude` binary. The binary handles its own auth. `auth` is
///   not consulted.
/// - `"codex-cli"` → motosan-ai `Provider::CodexCli`, which shells out to
///   `codex exec --json`. The binary handles its own auth. `auth` is not
///   consulted.
///
/// `model.name` and `model.max_tokens` are passed to all providers; the
/// CLI wrappers ignore `max_tokens` and use the underlying CLI's defaults.
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>> {
    // Env first, then auth.json (matches the precedence documented in spec §4
    // and preserved by M3 Phase 0 fix 5d5c608).
    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)
        // ClientBuilder requires `.api_key(...)` even when the underlying
        // provider ignores it (CLI manages its own auth). Pass a sentinel.
        .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();
        // No env, no auth — CLI providers don't need either at build time.
        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"));
    }
}