1use std::sync::Arc;
9
10use tokio::sync::Mutex;
11
12use crate::app::Config;
13use crate::models::config::BackendConfig;
14use crate::models::{ModelError, Result, lookup_provider};
15use crate::utils::{resolve_api_key, resolve_api_key_with_fallback};
16
17const GEMINI_API_KEY_ENV: &str = "GOOGLE_API_KEY";
18const GEMINI_LEGACY_API_KEY_ENV: &str = "GEMINI_API_KEY";
19
20fn require_key(provider: &str, env_var: &str) -> Result<String> {
25 resolve_api_key(env_var, None).ok_or_else(|| {
26 ModelError::Authentication(format!("{} requires env var {}", provider, env_var))
27 })
28}
29
30fn require_key_with_fallback(
31 provider: &str,
32 env_var: &str,
33 fallback_env_var: &str,
34) -> Result<String> {
35 resolve_api_key_with_fallback(env_var, fallback_env_var, None).ok_or_else(|| {
36 ModelError::Authentication(format!(
37 "{} requires env var {} (or legacy {})",
38 provider, env_var, fallback_env_var
39 ))
40 })
41}
42
43use super::model::{
44 AnthropicProvider, GeminiProvider, ModelProvider, OllamaProvider, OpenAICompatProvider,
45};
46
47type ProviderCell = Arc<tokio::sync::OnceCell<Arc<dyn ModelProvider>>>;
51
52pub struct ProviderFactory {
56 config: Arc<Config>,
57 cache: Mutex<std::collections::HashMap<String, ProviderCell>>,
60}
61
62impl ProviderFactory {
63 pub fn new(config: Config) -> Self {
64 Self {
65 config: Arc::new(config),
66 cache: Mutex::new(std::collections::HashMap::new()),
67 }
68 }
69
70 pub fn config(&self) -> &Config {
71 &self.config
72 }
73
74 pub async fn resolve(&self, model_id: &str) -> Result<Arc<dyn ModelProvider>> {
78 let key = normalize_cache_key(model_id);
79 let cell = {
83 let mut cache = self.cache.lock().await;
84 Arc::clone(
85 cache
86 .entry(key)
87 .or_insert_with(|| Arc::new(tokio::sync::OnceCell::new())),
88 )
89 };
90 let provider = cell
91 .get_or_try_init(|| async {
92 let p = build_provider(&self.config, model_id).await?;
93 Ok::<Arc<dyn ModelProvider>, ModelError>(Arc::from(p))
94 })
95 .await?;
96 Ok(Arc::clone(provider))
97 }
98}
99
100async fn build_provider(config: &Config, model_id: &str) -> Result<Box<dyn ModelProvider>> {
108 let (provider, model_name) = parse_model_id(model_id);
109 let provider_lc = provider.to_lowercase();
110
111 if provider_lc == "ollama" {
114 let backend = ollama_backend_config(config);
115 let p = OllamaProvider::with_app_config(
116 model_name,
117 Arc::new(backend),
118 Arc::new(config.clone()),
119 )
120 .await?;
121 return Ok(Box::new(p));
122 }
123
124 if provider_lc == "anthropic" {
126 let user_cfg = config.providers.get("anthropic");
127 let base_url = user_cfg
128 .and_then(|c| c.base_url.clone())
129 .unwrap_or_else(|| "https://api.anthropic.com/v1".to_string());
130 validate_provider_base_url(&base_url)?;
131 let api_key_env = user_cfg
132 .and_then(|c| c.api_key_env.as_deref())
133 .unwrap_or("ANTHROPIC_API_KEY");
134 let api_key = require_key("anthropic", api_key_env)?;
135 let p = AnthropicProvider::new(api_key, model_name.to_string(), base_url)?;
136 return Ok(Box::new(p));
137 }
138
139 if provider_lc == "gemini" {
141 let user_cfg = config.providers.get("gemini");
142 let base_url = user_cfg
143 .and_then(|c| c.base_url.clone())
144 .unwrap_or_else(|| "https://generativelanguage.googleapis.com/v1beta".to_string());
145 validate_provider_base_url(&base_url)?;
146 let api_key = match user_cfg.and_then(|c| c.api_key_env.as_deref()) {
147 Some(api_key_env) => require_key("gemini", api_key_env)?,
148 None => {
149 require_key_with_fallback("gemini", GEMINI_API_KEY_ENV, GEMINI_LEGACY_API_KEY_ENV)?
150 },
151 };
152 let p = GeminiProvider::new(api_key, model_name.to_string(), base_url)?;
153 return Ok(Box::new(p));
154 }
155
156 if let Some(profile) = lookup_provider(&provider_lc) {
158 let user_cfg = config.providers.get(&provider_lc);
159 let base_url = user_cfg
160 .and_then(|c| c.base_url.clone())
161 .unwrap_or_else(|| profile.base_url.to_string());
162 validate_provider_base_url(&base_url)?;
163 let api_key_env = user_cfg
164 .and_then(|c| c.api_key_env.as_deref())
165 .unwrap_or(profile.api_key_env);
166 let api_key = require_key(&provider_lc, api_key_env)?;
167 let extra_headers = user_cfg
168 .map(|c| c.extra_headers.clone())
169 .unwrap_or_default();
170 let p = OpenAICompatProvider::new(
171 profile,
172 base_url,
173 api_key,
174 model_name.to_string(),
175 extra_headers,
176 )?;
177 return Ok(Box::new(p));
178 }
179
180 if let Some(user_cfg) = config.providers.get(&provider_lc)
183 && let Some(profile) = user_profile_to_static(&provider_lc, user_cfg)
184 {
185 let base_url = user_cfg.base_url.clone().ok_or_else(|| {
186 ModelError::InvalidRequest(format!(
187 "custom provider '{}' requires base_url in config",
188 provider_lc
189 ))
190 })?;
191 validate_provider_base_url(&base_url)?;
192 let api_key_env = user_cfg.api_key_env.as_deref().ok_or_else(|| {
193 ModelError::InvalidRequest(format!(
194 "custom provider '{}' requires api_key_env in config",
195 provider_lc
196 ))
197 })?;
198 let api_key = require_key(&provider_lc, api_key_env)?;
199 let p = OpenAICompatProvider::new(
200 profile,
201 base_url,
202 api_key,
203 model_name.to_string(),
204 user_cfg.extra_headers.clone(),
205 )?;
206 return Ok(Box::new(p));
207 }
208
209 Err(ModelError::InvalidRequest(format!(
210 "Unknown provider '{}' (model_id: {})",
211 provider, model_id
212 )))
213}
214
215fn normalize_cache_key(model_id: &str) -> String {
219 let (provider, model) = parse_model_id(model_id);
220 format!("{}/{}", provider.to_lowercase(), model)
221}
222
223fn parse_model_id(model_id: &str) -> (String, &str) {
226 match model_id.split_once('/') {
227 Some((p, m)) => (p.to_string(), m),
228 None => ("ollama".to_string(), model_id),
229 }
230}
231
232pub(crate) fn ollama_backend_config(config: &Config) -> BackendConfig {
233 BackendConfig {
234 ollama_url: format!("{}:{}", config.ollama.host, config.ollama.port),
237 max_idle_per_host: 10,
238 timeout_secs: 10,
239 }
240}
241
242fn user_profile_to_static(
249 name: &str,
250 user_cfg: &crate::app::UserProviderConfig,
251) -> Option<&'static crate::models::ProviderProfile> {
252 use crate::models::{ProviderProfile, ReasoningExtraction, ReasoningStrategy};
253
254 let compat = user_cfg.compat.as_deref().unwrap_or("openai");
255 let strategy = match compat {
256 "openai" => ReasoningStrategy::None,
257 "openai-effort" => ReasoningStrategy::Effort,
258 "openrouter" => ReasoningStrategy::OpenRouterShape,
259 _ => ReasoningStrategy::None,
260 };
261
262 let profile = Box::new(ProviderProfile {
263 name: Box::leak(name.to_string().into_boxed_str()),
264 base_url: Box::leak(
265 user_cfg
266 .base_url
267 .clone()
268 .unwrap_or_default()
269 .into_boxed_str(),
270 ),
271 api_key_env: Box::leak(
272 user_cfg
273 .api_key_env
274 .clone()
275 .unwrap_or_default()
276 .into_boxed_str(),
277 ),
278 extra_headers: &[],
279 reasoning_strategy: strategy,
280 reasoning_extraction: ReasoningExtraction::None,
281 max_tokens_param: crate::models::MaxTokensParam::MaxTokens,
282 disable_parallel_tool_calls_for: &[],
283 });
284 Some(Box::leak(profile))
285}
286
287fn validate_provider_base_url(url: &str) -> Result<()> {
293 let parsed = reqwest::Url::parse(url).map_err(|e| {
294 ModelError::InvalidRequest(format!("invalid provider base_url '{url}': {e}"))
295 })?;
296 match parsed.scheme() {
297 "https" => Ok(()),
298 "http"
303 if crate::utils::classify_host(parsed.host_str().unwrap_or_default()).is_loopback() =>
304 {
305 Ok(())
306 },
307 "http" => Err(ModelError::InvalidRequest(format!(
308 "provider base_url '{url}' uses http:// to a non-loopback host — refusing to send the \
309 API key in cleartext. Use https, or http://localhost for a local server."
310 ))),
311 other => Err(ModelError::InvalidRequest(format!(
312 "provider base_url '{url}' has unsupported scheme '{other}' (use http or https)"
313 ))),
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn base_url_requires_https_for_remote_hosts() {
323 assert!(validate_provider_base_url("http://api.example.com/v1").is_err());
325 assert!(validate_provider_base_url("ftp://example.com").is_err());
326 assert!(validate_provider_base_url("https://api.example.com/v1").is_ok());
328 assert!(validate_provider_base_url("http://localhost:11434/v1").is_ok());
329 assert!(validate_provider_base_url("http://127.0.0.1:8000").is_ok());
330 assert!(validate_provider_base_url("http://[::1]:8000").is_ok());
331 assert!(validate_provider_base_url("http://192.168.1.5:8080").is_err());
334 assert!(validate_provider_base_url("http://169.254.169.254").is_err());
335 }
336 use std::sync::atomic::{AtomicUsize, Ordering};
337
338 fn unique_env(prefix: &str) -> String {
339 static N: AtomicUsize = AtomicUsize::new(0);
340 format!(
341 "{}_{}_{}",
342 prefix,
343 std::process::id(),
344 N.fetch_add(1, Ordering::SeqCst)
345 )
346 }
347
348 #[test]
349 fn parse_bare_name_defaults_to_ollama() {
350 let (p, m) = parse_model_id("qwen3-coder:30b");
351 assert_eq!(p, "ollama");
352 assert_eq!(m, "qwen3-coder:30b");
353 }
354
355 #[test]
356 fn parse_prefixed() {
357 let (p, m) = parse_model_id("anthropic/claude-opus-4-7");
358 assert_eq!(p, "anthropic");
359 assert_eq!(m, "claude-opus-4-7");
360 }
361
362 #[test]
363 fn gemini_key_resolution_accepts_legacy_fallback() {
364 let primary = unique_env("MERMAID_FACTORY_GEMINI_PRIMARY");
365 let legacy = unique_env("MERMAID_FACTORY_GEMINI_LEGACY");
366 temp_env::with_vars(
367 [(primary.as_str(), None), (legacy.as_str(), Some("legacy"))],
368 || {
369 let resolved = require_key_with_fallback("gemini", &primary, &legacy)
370 .expect("legacy fallback should resolve");
371 assert_eq!(resolved, "legacy");
372 },
373 );
374 }
375
376 #[test]
377 fn gemini_key_resolution_prefers_google_primary() {
378 let primary = unique_env("MERMAID_FACTORY_GEMINI_PRIMARY2");
379 let legacy = unique_env("MERMAID_FACTORY_GEMINI_LEGACY2");
380 temp_env::with_vars(
381 [
382 (primary.as_str(), Some("google")),
383 (legacy.as_str(), Some("legacy")),
384 ],
385 || {
386 let resolved = require_key_with_fallback("gemini", &primary, &legacy)
387 .expect("primary should resolve");
388 assert_eq!(resolved, "google");
389 },
390 );
391 }
392
393 #[tokio::test]
394 async fn factory_reports_unknown_provider_clearly() {
395 let cfg = Config::default();
396 let f = ProviderFactory::new(cfg);
397 match f.resolve("totally-made-up/model").await {
398 Ok(_) => panic!("expected error"),
399 Err(e) => {
400 let msg = format!("{}", e);
401 assert!(
402 msg.contains("totally-made-up") || msg.contains("Unknown provider"),
403 "error message: {}",
404 msg
405 );
406 },
407 }
408 }
409
410 #[test]
411 fn normalize_cache_key_lowercases_provider_only() {
412 assert_eq!(
414 normalize_cache_key("Anthropic/Claude-X"),
415 "anthropic/Claude-X"
416 );
417 assert_eq!(
418 normalize_cache_key("anthropic/Claude-X"),
419 "anthropic/Claude-X"
420 );
421 assert_eq!(normalize_cache_key("qwen3:30b"), "ollama/qwen3:30b");
423 }
424
425 #[tokio::test]
426 async fn resolve_is_single_flight_and_cached() {
427 let f = ProviderFactory::new(Config::default());
431 let (a, b) = tokio::join!(
432 f.resolve("ollama/test-model"),
433 f.resolve("Ollama/test-model"),
434 );
435 let a = a.expect("resolve a");
436 let b = b.expect("resolve b");
437 assert!(
438 Arc::ptr_eq(&a, &b),
439 "expected one cached provider for casing variants + concurrent resolve"
440 );
441 }
442}