Skip to main content

evolve_llm/
factory.rs

1//! Runtime client selection.
2
3use crate::anthropic::AnthropicHaikuClient;
4use crate::client::LlmClient;
5use crate::error::LlmError;
6use crate::ollama::OllamaClient;
7use std::time::Duration;
8
9const OLLAMA_BASE_URL_ENV: &str = "OLLAMA_BASE_URL";
10const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
11const DEFAULT_OLLAMA_ENDPOINT: &str = "http://localhost:11434";
12const DEFAULT_ANTHROPIC_ENDPOINT: &str = "https://api.anthropic.com";
13const PROBE_TIMEOUT: Duration = Duration::from_millis(500);
14
15/// Select an LLM client at runtime, reading the environment:
16///
17/// 1. Probe Ollama at `OLLAMA_BASE_URL` (default `http://localhost:11434`)
18///    via `GET /api/version`. If reachable in 500ms, use it.
19/// 2. Otherwise, if `ANTHROPIC_API_KEY` is set, use [`AnthropicHaikuClient`].
20/// 3. Otherwise, return [`LlmError::NoLlmAvailable`].
21pub async fn pick_default_client() -> Result<Box<dyn LlmClient>, LlmError> {
22    let ollama_endpoint =
23        std::env::var(OLLAMA_BASE_URL_ENV).unwrap_or_else(|_| DEFAULT_OLLAMA_ENDPOINT.to_string());
24    let anthropic_key = std::env::var(ANTHROPIC_API_KEY_ENV).ok();
25    pick_with(&ollama_endpoint, anthropic_key.as_deref()).await
26}
27
28/// Same selection logic as [`pick_default_client`] but with explicit inputs —
29/// lets tests exercise the branches without touching process environment.
30pub async fn pick_with(
31    ollama_endpoint: &str,
32    anthropic_key: Option<&str>,
33) -> Result<Box<dyn LlmClient>, LlmError> {
34    if ollama_reachable(ollama_endpoint).await {
35        return Ok(Box::new(OllamaClient::with_endpoint(ollama_endpoint)));
36    }
37    match anthropic_key {
38        Some(key) => Ok(Box::new(AnthropicHaikuClient::with_endpoint(
39            key,
40            DEFAULT_ANTHROPIC_ENDPOINT,
41        ))),
42        None => Err(LlmError::NoLlmAvailable),
43    }
44}
45
46async fn ollama_reachable(endpoint: &str) -> bool {
47    let url = format!("{endpoint}/api/version");
48    let client = match reqwest::Client::builder().timeout(PROBE_TIMEOUT).build() {
49        Ok(c) => c,
50        Err(_) => return false,
51    };
52    client
53        .get(&url)
54        .send()
55        .await
56        .map(|r| r.status().is_success())
57        .unwrap_or(false)
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63    use wiremock::matchers::{method, path};
64    use wiremock::{Mock, MockServer, ResponseTemplate};
65
66    #[tokio::test]
67    async fn picks_ollama_when_reachable() {
68        let server = MockServer::start().await;
69        Mock::given(method("GET"))
70            .and(path("/api/version"))
71            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"version":"0.1.0"}"#))
72            .mount(&server)
73            .await;
74
75        let client = pick_with(&server.uri(), None).await.unwrap();
76        assert!(
77            !client.model_id().contains("claude"),
78            "expected Ollama model id, got {}",
79            client.model_id(),
80        );
81    }
82
83    #[tokio::test]
84    async fn falls_back_to_anthropic_when_ollama_unreachable_and_key_set() {
85        // Point Ollama at a likely-closed port.
86        let client = pick_with("http://127.0.0.1:1", Some("test-key"))
87            .await
88            .unwrap();
89        assert_eq!(client.model_id(), "claude-haiku-4-5-20251001");
90    }
91
92    #[tokio::test]
93    async fn returns_no_llm_available_when_nothing_configured() {
94        let result = pick_with("http://127.0.0.1:1", None).await;
95        assert!(matches!(result, Err(LlmError::NoLlmAvailable)));
96    }
97}