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 = resolve_overridable_base_url(
128 "anthropic",
129 user_cfg.and_then(|c| c.base_url.clone()),
130 "https://api.anthropic.com/v1",
131 )?;
132 let api_key_env = user_cfg
133 .and_then(|c| c.api_key_env.as_deref())
134 .unwrap_or("ANTHROPIC_API_KEY");
135 let api_key = require_key("anthropic", api_key_env)?;
136 let p = AnthropicProvider::new(api_key, model_name.to_string(), base_url)?;
137 return Ok(Box::new(p));
138 }
139
140 if provider_lc == "gemini" {
142 let user_cfg = config.providers.get("gemini");
143 let base_url = resolve_overridable_base_url(
144 "gemini",
145 user_cfg.and_then(|c| c.base_url.clone()),
146 "https://generativelanguage.googleapis.com/v1beta",
147 )?;
148 let api_key = match user_cfg.and_then(|c| c.api_key_env.as_deref()) {
149 Some(api_key_env) => require_key("gemini", api_key_env)?,
150 None => {
151 require_key_with_fallback("gemini", GEMINI_API_KEY_ENV, GEMINI_LEGACY_API_KEY_ENV)?
152 },
153 };
154 let p = GeminiProvider::new(api_key, model_name.to_string(), base_url)?;
155 return Ok(Box::new(p));
156 }
157
158 if let Some(profile) = lookup_provider(&provider_lc) {
160 let user_cfg = config.providers.get(&provider_lc);
161 let base_url = resolve_overridable_base_url(
162 &provider_lc,
163 user_cfg.and_then(|c| c.base_url.clone()),
164 profile.base_url,
165 )?;
166 let api_key_env = user_cfg
167 .and_then(|c| c.api_key_env.as_deref())
168 .unwrap_or(profile.api_key_env);
169 let api_key = require_key(&provider_lc, api_key_env)?;
170 let extra_headers = user_cfg
171 .map(|c| c.extra_headers.clone())
172 .unwrap_or_default();
173 let p = OpenAICompatProvider::new(
174 profile,
175 base_url,
176 api_key,
177 model_name.to_string(),
178 extra_headers,
179 )?;
180 return Ok(Box::new(p));
181 }
182
183 if let Some(user_cfg) = config.providers.get(&provider_lc)
186 && let Some(profile) = user_profile_to_static(&provider_lc, user_cfg)
187 {
188 let base_url = user_cfg.base_url.clone().ok_or_else(|| {
189 ModelError::InvalidRequest(format!(
190 "custom provider '{}' requires base_url in config",
191 provider_lc
192 ))
193 })?;
194 validate_provider_base_url(&base_url)?;
195 let api_key_env = user_cfg.api_key_env.as_deref().ok_or_else(|| {
196 ModelError::InvalidRequest(format!(
197 "custom provider '{}' requires api_key_env in config",
198 provider_lc
199 ))
200 })?;
201 let api_key = require_key(&provider_lc, api_key_env)?;
202 let p = OpenAICompatProvider::new(
203 profile,
204 base_url,
205 api_key,
206 model_name.to_string(),
207 user_cfg.extra_headers.clone(),
208 )?;
209 return Ok(Box::new(p));
210 }
211
212 Err(ModelError::InvalidRequest(format!(
213 "Unknown provider '{}' (model_id: {})",
214 provider, model_id
215 )))
216}
217
218fn normalize_cache_key(model_id: &str) -> String {
222 let (provider, model) = parse_model_id(model_id);
223 format!("{}/{}", provider.to_lowercase(), model)
224}
225
226fn parse_model_id(model_id: &str) -> (String, &str) {
229 match model_id.split_once('/') {
230 Some((p, m)) => (p.to_string(), m),
231 None => ("ollama".to_string(), model_id),
232 }
233}
234
235pub(crate) fn ollama_backend_config(config: &Config) -> BackendConfig {
236 BackendConfig {
237 ollama_url: format!("{}:{}", config.ollama.host, config.ollama.port),
240 max_idle_per_host: 10,
241 timeout_secs: 10,
242 ollama_autostart: config.ollama.auto_start,
243 }
244}
245
246static PROFILE_CACHE: std::sync::LazyLock<
250 std::sync::Mutex<std::collections::HashMap<String, &'static crate::models::ProviderProfile>>,
251> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
252
253fn user_profile_to_static(
267 name: &str,
268 user_cfg: &crate::app::UserProviderConfig,
269) -> Option<&'static crate::models::ProviderProfile> {
270 use crate::models::{ProviderProfile, ReasoningExtraction, ReasoningStrategy};
271
272 let compat = user_cfg.compat.as_deref().unwrap_or("openai");
273 let base_url = user_cfg.base_url.clone().unwrap_or_default();
274 let api_key_env = user_cfg.api_key_env.clone().unwrap_or_default();
275
276 let cache_key = format!("{name}\u{0}{base_url}\u{0}{api_key_env}\u{0}{compat}");
279
280 let mut cache = PROFILE_CACHE
281 .lock()
282 .unwrap_or_else(|poisoned| poisoned.into_inner());
283 if let Some(profile) = cache.get(cache_key.as_str()) {
284 return Some(*profile);
287 }
288
289 let strategy = match compat {
290 "openai" => ReasoningStrategy::None,
291 "openai-effort" => ReasoningStrategy::Effort,
292 "openrouter" => ReasoningStrategy::OpenRouterShape,
293 _ => ReasoningStrategy::None,
294 };
295
296 let profile = Box::new(ProviderProfile {
297 name: Box::leak(name.to_string().into_boxed_str()),
298 base_url: Box::leak(base_url.into_boxed_str()),
299 api_key_env: Box::leak(api_key_env.into_boxed_str()),
300 extra_headers: &[],
301 reasoning_strategy: strategy,
302 reasoning_extraction: ReasoningExtraction::None,
303 max_tokens_param: crate::models::MaxTokensParam::MaxTokens,
304 disable_parallel_tool_calls_for: &[],
305 });
306 let leaked: &'static ProviderProfile = Box::leak(profile);
307 cache.insert(cache_key, leaked);
308 Some(leaked)
309}
310
311fn validate_provider_base_url(url: &str) -> Result<()> {
317 let parsed = reqwest::Url::parse(url).map_err(|e| {
318 ModelError::InvalidRequest(format!("invalid provider base_url '{url}': {e}"))
319 })?;
320 match parsed.scheme() {
321 "https" => Ok(()),
322 "http"
327 if crate::utils::classify_host(parsed.host_str().unwrap_or_default()).is_loopback() =>
328 {
329 Ok(())
330 },
331 "http" => Err(ModelError::InvalidRequest(format!(
332 "provider base_url '{url}' uses http:// to a non-loopback host — refusing to send the \
333 API key in cleartext. Use https, or http://localhost for a local server."
334 ))),
335 other => Err(ModelError::InvalidRequest(format!(
336 "provider base_url '{url}' has unsupported scheme '{other}' (use http or https)"
337 ))),
338 }
339}
340
341fn resolve_overridable_base_url(
357 provider: &str,
358 override_url: Option<String>,
359 default_url: &str,
360) -> Result<String> {
361 match override_url {
362 Some(url) => {
363 validate_provider_base_url(&url)?;
364 warn_overridden_provider_host(provider, &url);
365 Ok(url)
366 },
367 None => Ok(default_url.to_string()),
368 }
369}
370
371static WARNED_OVERRIDE_HOSTS: std::sync::LazyLock<
375 std::sync::Mutex<std::collections::HashSet<String>>,
376> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
377
378fn warn_overridden_provider_host(provider: &str, base_url: &str) {
382 let host = provider_host(base_url);
383 if should_warn_once(&format!("{provider}@{host}")) {
384 tracing::warn!(
385 "built-in provider '{}' base_url overridden in config: the {} API key will be sent to \
386 host '{}' instead of the trusted default endpoint",
387 provider,
388 provider,
389 host
390 );
391 }
392}
393
394fn provider_host(base_url: &str) -> String {
397 reqwest::Url::parse(base_url)
398 .ok()
399 .and_then(|u| u.host_str().map(str::to_string))
400 .unwrap_or_else(|| "<unknown>".to_string())
401}
402
403fn should_warn_once(key: &str) -> bool {
406 let mut warned = WARNED_OVERRIDE_HOSTS
407 .lock()
408 .unwrap_or_else(|poisoned| poisoned.into_inner());
409 warned.insert(key.to_string())
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[test]
417 fn base_url_requires_https_for_remote_hosts() {
418 assert!(validate_provider_base_url("http://api.example.com/v1").is_err());
420 assert!(validate_provider_base_url("ftp://example.com").is_err());
421 assert!(validate_provider_base_url("https://api.example.com/v1").is_ok());
423 assert!(validate_provider_base_url("http://localhost:11434/v1").is_ok());
424 assert!(validate_provider_base_url("http://127.0.0.1:8000").is_ok());
425 assert!(validate_provider_base_url("http://[::1]:8000").is_ok());
426 assert!(validate_provider_base_url("http://192.168.1.5:8080").is_err());
429 assert!(validate_provider_base_url("http://169.254.169.254").is_err());
430 }
431 use std::sync::atomic::{AtomicUsize, Ordering};
432
433 fn unique_env(prefix: &str) -> String {
434 static N: AtomicUsize = AtomicUsize::new(0);
435 format!(
436 "{}_{}_{}",
437 prefix,
438 std::process::id(),
439 N.fetch_add(1, Ordering::SeqCst)
440 )
441 }
442
443 #[test]
444 fn parse_bare_name_defaults_to_ollama() {
445 let (p, m) = parse_model_id("qwen3-coder:30b");
446 assert_eq!(p, "ollama");
447 assert_eq!(m, "qwen3-coder:30b");
448 }
449
450 #[test]
451 fn parse_prefixed() {
452 let (p, m) = parse_model_id("anthropic/claude-opus-4-7");
453 assert_eq!(p, "anthropic");
454 assert_eq!(m, "claude-opus-4-7");
455 }
456
457 #[test]
458 fn gemini_key_resolution_accepts_legacy_fallback() {
459 let primary = unique_env("MERMAID_FACTORY_GEMINI_PRIMARY");
460 let legacy = unique_env("MERMAID_FACTORY_GEMINI_LEGACY");
461 temp_env::with_vars(
462 [(primary.as_str(), None), (legacy.as_str(), Some("legacy"))],
463 || {
464 let resolved = require_key_with_fallback("gemini", &primary, &legacy)
465 .expect("legacy fallback should resolve");
466 assert_eq!(resolved, "legacy");
467 },
468 );
469 }
470
471 #[test]
472 fn gemini_key_resolution_prefers_google_primary() {
473 let primary = unique_env("MERMAID_FACTORY_GEMINI_PRIMARY2");
474 let legacy = unique_env("MERMAID_FACTORY_GEMINI_LEGACY2");
475 temp_env::with_vars(
476 [
477 (primary.as_str(), Some("google")),
478 (legacy.as_str(), Some("legacy")),
479 ],
480 || {
481 let resolved = require_key_with_fallback("gemini", &primary, &legacy)
482 .expect("primary should resolve");
483 assert_eq!(resolved, "google");
484 },
485 );
486 }
487
488 #[tokio::test]
489 async fn factory_reports_unknown_provider_clearly() {
490 let cfg = Config::default();
491 let f = ProviderFactory::new(cfg);
492 match f.resolve("totally-made-up/model").await {
493 Ok(_) => panic!("expected error"),
494 Err(e) => {
495 let msg = format!("{}", e);
496 assert!(
497 msg.contains("totally-made-up") || msg.contains("Unknown provider"),
498 "error message: {}",
499 msg
500 );
501 },
502 }
503 }
504
505 #[test]
506 fn normalize_cache_key_lowercases_provider_only() {
507 assert_eq!(
509 normalize_cache_key("Anthropic/Claude-X"),
510 "anthropic/Claude-X"
511 );
512 assert_eq!(
513 normalize_cache_key("anthropic/Claude-X"),
514 "anthropic/Claude-X"
515 );
516 assert_eq!(normalize_cache_key("qwen3:30b"), "ollama/qwen3:30b");
518 }
519
520 #[tokio::test]
521 async fn resolve_is_single_flight_and_cached() {
522 let f = ProviderFactory::new(Config::default());
526 let (a, b) = tokio::join!(
527 f.resolve("ollama/test-model"),
528 f.resolve("Ollama/test-model"),
529 );
530 let a = a.expect("resolve a");
531 let b = b.expect("resolve b");
532 assert!(
533 Arc::ptr_eq(&a, &b),
534 "expected one cached provider for casing variants + concurrent resolve"
535 );
536 }
537
538 #[test]
542 fn builtin_base_url_override_validated_and_resolved() {
543 assert_eq!(
545 resolve_overridable_base_url("anthropic", None, "https://api.anthropic.com/v1")
546 .unwrap(),
547 "https://api.anthropic.com/v1"
548 );
549 assert_eq!(
551 resolve_overridable_base_url(
552 "anthropic",
553 Some("https://proxy.internal/v1".to_string()),
554 "https://api.anthropic.com/v1",
555 )
556 .unwrap(),
557 "https://proxy.internal/v1"
558 );
559 assert!(
562 resolve_overridable_base_url(
563 "anthropic",
564 Some("http://attacker.example/v1".to_string()),
565 "https://api.anthropic.com/v1",
566 )
567 .is_err()
568 );
569 assert!(
571 resolve_overridable_base_url(
572 "openai",
573 Some("http://localhost:8080/v1".to_string()),
574 "https://api.openai.com/v1",
575 )
576 .is_ok()
577 );
578 }
579
580 #[test]
581 fn provider_host_extracts_host_or_unknown() {
582 assert_eq!(
583 provider_host("https://attacker.example/v1"),
584 "attacker.example"
585 );
586 assert_eq!(provider_host("http://127.0.0.1:8080"), "127.0.0.1");
587 assert_eq!(provider_host("not a url"), "<unknown>");
588 }
589
590 #[test]
591 fn override_host_warning_is_deduped() {
592 let key = unique_env("MERMAID_FACTORY_WARN_KEY");
596 assert!(should_warn_once(&key), "first warn for a key must fire");
597 assert!(
598 !should_warn_once(&key),
599 "subsequent warns for the same key must be suppressed"
600 );
601 }
602
603 #[test]
607 fn custom_profile_is_memoized_per_key() {
608 use crate::app::UserProviderConfig;
609 let cfg = UserProviderConfig {
610 base_url: Some("https://api.custom.test/v1".to_string()),
611 api_key_env: Some("CUSTOM_KEY".to_string()),
612 compat: Some("openai".to_string()),
613 ..Default::default()
614 };
615 let a = user_profile_to_static("mermaid_test_customx", &cfg).unwrap();
618 let b = user_profile_to_static("mermaid_test_customx", &cfg).unwrap();
619 assert!(
620 std::ptr::eq(a, b),
621 "identical custom-provider inputs must reuse one leaked &'static profile"
622 );
623 assert_eq!(a.base_url, "https://api.custom.test/v1");
624 assert_eq!(a.api_key_env, "CUSTOM_KEY");
625
626 let cfg2 = UserProviderConfig {
628 base_url: Some("https://api.custom.test/v2".to_string()),
629 ..cfg.clone()
630 };
631 let c = user_profile_to_static("mermaid_test_customx", &cfg2).unwrap();
632 assert!(
633 !std::ptr::eq(a, c),
634 "a different base_url must leak a distinct profile"
635 );
636 }
637}