tuitbot-core 0.1.47

Core library for Tuitbot autonomous X growth assistant
Documentation
//! Provider factory for creating LLM providers from configuration.
//!
//! Reads the `LlmConfig` and returns the correct provider instance,
//! abstracting away provider-specific construction details.

use super::anthropic::AnthropicProvider;
use super::openai_compat::OpenAiCompatProvider;
use super::LlmProvider;
use crate::config::LlmConfig;
use crate::error::LlmError;

/// Create an LLM provider from configuration.
///
/// Returns `Box<dyn LlmProvider>` so callers are decoupled from the concrete type.
/// Logs the constructed provider at info level (without the API key).
pub fn create_provider(config: &LlmConfig) -> Result<Box<dyn LlmProvider>, LlmError> {
    match config.provider.as_str() {
        "openai" => {
            let api_key = config
                .api_key
                .as_deref()
                .filter(|k| !k.is_empty())
                .ok_or(LlmError::NotConfigured)?
                .to_string();

            let base_url = config
                .base_url
                .as_deref()
                .filter(|u| !u.is_empty())
                .unwrap_or("https://api.openai.com/v1")
                .to_string();

            let model = if config.model.is_empty() {
                "gpt-4o-mini".to_string()
            } else {
                config.model.clone()
            };

            tracing::info!(provider = "openai", model = %model, base_url = %base_url, "Creating LLM provider");

            Ok(Box::new(OpenAiCompatProvider::new(
                base_url,
                api_key,
                model,
                "openai".to_string(),
            )))
        }
        "ollama" => {
            let base_url = config
                .base_url
                .as_deref()
                .filter(|u| !u.is_empty())
                .unwrap_or("http://localhost:11434/v1")
                .to_string();

            let model = if config.model.is_empty() {
                "llama3.1".to_string()
            } else {
                config.model.clone()
            };

            tracing::info!(provider = "ollama", model = %model, base_url = %base_url, "Creating LLM provider");

            Ok(Box::new(OpenAiCompatProvider::new(
                base_url,
                "ollama".to_string(),
                model,
                "ollama".to_string(),
            )))
        }
        "anthropic" => {
            let api_key = config
                .api_key
                .as_deref()
                .filter(|k| !k.is_empty())
                .ok_or(LlmError::NotConfigured)?
                .to_string();

            let model = if config.model.is_empty() {
                "claude-sonnet-4-5-20250514".to_string()
            } else {
                config.model.clone()
            };

            tracing::info!(provider = "anthropic", model = %model, "Creating LLM provider");

            if let Some(base_url) = config.base_url.as_deref().filter(|u| !u.is_empty()) {
                Ok(Box::new(AnthropicProvider::with_base_url(
                    api_key,
                    model,
                    base_url.to_string(),
                )))
            } else {
                Ok(Box::new(AnthropicProvider::new(api_key, model)))
            }
        }
        "groq" => {
            let api_key = config
                .api_key
                .as_deref()
                .filter(|k| !k.is_empty())
                .ok_or(LlmError::NotConfigured)?
                .to_string();

            let base_url = config
                .base_url
                .as_deref()
                .filter(|u| !u.is_empty())
                .unwrap_or("https://api.groq.com/openai/v1")
                .to_string();

            let model = if config.model.is_empty() {
                "llama-3.3-70b-versatile".to_string()
            } else {
                config.model.clone()
            };

            tracing::info!(provider = "groq", model = %model, base_url = %base_url, "Creating LLM provider");

            Ok(Box::new(OpenAiCompatProvider::new(
                base_url,
                api_key,
                model,
                "groq".to_string(),
            )))
        }
        "" => Err(LlmError::NotConfigured),
        _other => Err(LlmError::NotConfigured),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::LlmConfig;

    #[test]
    fn create_openai_provider() {
        let config = LlmConfig {
            provider: "openai".to_string(),
            api_key: Some("sk-test".to_string()),
            model: "gpt-4o".to_string(),
            base_url: None,
        };
        let provider = create_provider(&config).expect("create");
        assert_eq!(provider.name(), "openai");
    }

    #[test]
    fn create_openai_requires_api_key() {
        let config = LlmConfig {
            provider: "openai".to_string(),
            api_key: None,
            model: String::new(),
            base_url: None,
        };
        assert!(matches!(
            create_provider(&config),
            Err(LlmError::NotConfigured)
        ));
    }

    #[test]
    fn create_ollama_provider() {
        let config = LlmConfig {
            provider: "ollama".to_string(),
            api_key: None,
            model: String::new(),
            base_url: None,
        };
        let provider = create_provider(&config).expect("create");
        assert_eq!(provider.name(), "ollama");
    }

    #[test]
    fn create_anthropic_provider() {
        let config = LlmConfig {
            provider: "anthropic".to_string(),
            api_key: Some("sk-ant-test".to_string()),
            model: String::new(),
            base_url: None,
        };
        let provider = create_provider(&config).expect("create");
        assert_eq!(provider.name(), "anthropic");
    }

    #[test]
    fn create_anthropic_requires_api_key() {
        let config = LlmConfig {
            provider: "anthropic".to_string(),
            api_key: None,
            model: String::new(),
            base_url: None,
        };
        assert!(matches!(
            create_provider(&config),
            Err(LlmError::NotConfigured)
        ));
    }

    #[test]
    fn create_unknown_provider_returns_not_configured() {
        let config = LlmConfig {
            provider: "unknown".to_string(),
            api_key: None,
            model: String::new(),
            base_url: None,
        };
        assert!(matches!(
            create_provider(&config),
            Err(LlmError::NotConfigured)
        ));
    }

    #[test]
    fn create_empty_provider_returns_not_configured() {
        let config = LlmConfig::default();
        assert!(matches!(
            create_provider(&config),
            Err(LlmError::NotConfigured)
        ));
    }

    #[test]
    fn create_openai_with_custom_base_url() {
        let config = LlmConfig {
            provider: "openai".to_string(),
            api_key: Some("key".to_string()),
            model: String::new(),
            base_url: Some("https://custom.api.com/v1".to_string()),
        };
        let provider = create_provider(&config).expect("create");
        assert_eq!(provider.name(), "openai");
    }

    #[test]
    fn create_ollama_with_custom_base_url() {
        let config = LlmConfig {
            provider: "ollama".to_string(),
            api_key: None,
            model: "custom-model".to_string(),
            base_url: Some("http://remote:11434/v1".to_string()),
        };
        let provider = create_provider(&config).expect("create");
        assert_eq!(provider.name(), "ollama");
    }

    #[test]
    fn create_anthropic_with_custom_base_url() {
        let config = LlmConfig {
            provider: "anthropic".to_string(),
            api_key: Some("sk-ant-test".to_string()),
            model: "claude-sonnet-4-5-20250514".to_string(),
            base_url: Some("https://custom.anthropic.com".to_string()),
        };
        let provider = create_provider(&config).expect("create");
        assert_eq!(provider.name(), "anthropic");
    }

    #[test]
    fn create_openai_empty_api_key_returns_not_configured() {
        let config = LlmConfig {
            provider: "openai".to_string(),
            api_key: Some(String::new()),
            model: String::new(),
            base_url: None,
        };
        assert!(matches!(
            create_provider(&config),
            Err(LlmError::NotConfigured)
        ));
    }

    #[test]
    fn create_anthropic_empty_api_key_returns_not_configured() {
        let config = LlmConfig {
            provider: "anthropic".to_string(),
            api_key: Some(String::new()),
            model: String::new(),
            base_url: None,
        };
        assert!(matches!(
            create_provider(&config),
            Err(LlmError::NotConfigured)
        ));
    }

    #[test]
    fn create_openai_default_model_when_empty() {
        let config = LlmConfig {
            provider: "openai".to_string(),
            api_key: Some("sk-test".to_string()),
            model: String::new(),
            base_url: None,
        };
        // Should succeed with default model gpt-4o-mini
        let provider = create_provider(&config).expect("create");
        assert_eq!(provider.name(), "openai");
    }

    #[test]
    fn create_ollama_default_model_when_empty() {
        let config = LlmConfig {
            provider: "ollama".to_string(),
            api_key: None,
            model: String::new(),
            base_url: None,
        };
        let provider = create_provider(&config).expect("create");
        assert_eq!(provider.name(), "ollama");
    }

    #[test]
    fn create_anthropic_default_model_when_empty() {
        let config = LlmConfig {
            provider: "anthropic".to_string(),
            api_key: Some("sk-ant-key".to_string()),
            model: String::new(),
            base_url: None,
        };
        let provider = create_provider(&config).expect("create");
        assert_eq!(provider.name(), "anthropic");
    }

    #[test]
    fn create_openai_empty_base_url_uses_default() {
        let config = LlmConfig {
            provider: "openai".to_string(),
            api_key: Some("sk-test".to_string()),
            model: "gpt-4o".to_string(),
            base_url: Some(String::new()),
        };
        let provider = create_provider(&config).expect("create");
        assert_eq!(provider.name(), "openai");
    }

    #[test]
    fn create_groq_provider() {
        let config = LlmConfig {
            provider: "groq".to_string(),
            api_key: Some("gsk_test".to_string()),
            model: "llama-3.3-70b-versatile".to_string(),
            base_url: None,
        };
        let provider = create_provider(&config).expect("create");
        assert_eq!(provider.name(), "groq");
    }

    #[test]
    fn create_groq_requires_api_key() {
        let config = LlmConfig {
            provider: "groq".to_string(),
            api_key: None,
            model: String::new(),
            base_url: None,
        };
        assert!(matches!(
            create_provider(&config),
            Err(LlmError::NotConfigured)
        ));
    }

    #[test]
    fn create_groq_default_model_when_empty() {
        let config = LlmConfig {
            provider: "groq".to_string(),
            api_key: Some("gsk_test".to_string()),
            model: String::new(),
            base_url: None,
        };
        let provider = create_provider(&config).expect("create");
        assert_eq!(provider.name(), "groq");
    }

    #[test]
    fn create_anthropic_empty_base_url_uses_default() {
        let config = LlmConfig {
            provider: "anthropic".to_string(),
            api_key: Some("sk-ant-test".to_string()),
            model: "claude-sonnet-4-5-20250514".to_string(),
            base_url: Some(String::new()),
        };
        let provider = create_provider(&config).expect("create");
        assert_eq!(provider.name(), "anthropic");
    }
}