1use 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
15pub 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
28pub 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 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}