1use std::sync::Arc;
9
10use tokio::sync::Mutex;
11
12use mermaid_domain::Config;
13use mermaid_model::models::config::BackendConfig;
14use mermaid_model::models::{ModelError, Result, lookup_provider};
15use mermaid_model::utils::{
16 resolve_api_key, resolve_provider_key, resolve_provider_key_with_fallback,
17};
18
19use super::model::{anthropic, gemini, meta};
20
21fn require_key(provider: &str, default_env: &str, override_env: Option<&str>) -> Result<String> {
25 resolve_provider_key(provider, default_env, override_env).ok_or_else(|| {
26 let env = override_env.unwrap_or(default_env);
27 ModelError::Authentication(format!(
28 "{provider} requires env var {env} (or `mermaid login {provider}`)"
29 ))
30 })
31}
32
33fn require_key_with_fallback(
34 provider: &str,
35 env_var: &str,
36 fallback_env_var: &str,
37) -> Result<String> {
38 resolve_provider_key_with_fallback(provider, env_var, fallback_env_var, None).ok_or_else(|| {
39 ModelError::Authentication(format!(
40 "{provider} requires env var {env_var} (or legacy {fallback_env_var}, or \
41 `mermaid login {provider}`)"
42 ))
43 })
44}
45
46fn key_env_source(default_env: &str, override_env: Option<&str>) -> Option<String> {
52 let env = override_env.unwrap_or(default_env);
56 resolve_api_key(env, None).map(|_| env.to_string())
57}
58
59fn key_env_source_with_fallback(default_env: &str, fallback_env: &str) -> Option<String> {
61 if resolve_api_key(default_env, None).is_some() {
62 return Some(default_env.to_string());
63 }
64 if resolve_api_key(fallback_env, None).is_some() {
65 return Some(fallback_env.to_string());
66 }
67 None
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
80pub(crate) struct ProviderEndpoint {
81 pub(crate) base_url: String,
83 pub(crate) api_key: Option<String>,
86 pub(crate) key_env: Option<String>,
89}
90
91impl ProviderEndpoint {
92 fn require_key(&self, provider: &str) -> Result<String> {
98 self.api_key
99 .clone()
100 .ok_or_else(|| ModelError::Authentication(format!("{provider} requires an API key")))
101 }
102}
103
104fn is_known_provider(config: &Config, provider_lc: &str) -> bool {
107 matches!(provider_lc, "anthropic" | "gemini" | "meta")
108 || lookup_provider(provider_lc).is_some()
109 || config.providers.contains_key(provider_lc)
110}
111
112#[expect(
120 clippy::too_many_lines,
121 reason = "predates the lint; see .github/baselines/expect_budget.txt"
122)]
123pub(crate) fn resolve_provider_endpoint(
124 config: &Config,
125 provider: &str,
126) -> Result<ProviderEndpoint> {
127 let provider_lc = provider.to_lowercase();
128 let user_cfg = config.providers.get(&provider_lc);
129 let override_env = user_cfg.and_then(|c| c.api_key_env.as_deref());
130 let override_url = user_cfg.and_then(|c| c.base_url.clone());
131
132 let bespoke = match provider_lc.as_str() {
135 "anthropic" => Some((
136 anthropic::DEFAULT_API_KEY_ENV,
137 None,
138 anthropic::DEFAULT_BASE_URL,
139 )),
140 "gemini" => Some((
141 gemini::DEFAULT_API_KEY_ENV,
142 Some(gemini::LEGACY_API_KEY_ENV),
143 gemini::DEFAULT_BASE_URL,
144 )),
145 "meta" => Some((meta::DEFAULT_API_KEY_ENV, None, meta::DEFAULT_BASE_URL)),
146 _ => None,
147 };
148 if let Some((default_env, legacy_env, default_url)) = bespoke {
149 let base_url = resolve_overridable_base_url(&provider_lc, override_url, default_url)?;
150 let (api_key, key_env) = match legacy_env {
151 Some(legacy) if override_env.is_none() => (
154 require_key_with_fallback(&provider_lc, default_env, legacy)?,
155 key_env_source_with_fallback(default_env, legacy),
156 ),
157 _ => (
158 require_key(&provider_lc, default_env, override_env)?,
159 key_env_source(default_env, override_env),
160 ),
161 };
162 return Ok(ProviderEndpoint {
163 base_url,
164 api_key: Some(api_key),
165 key_env,
166 });
167 }
168
169 if provider_lc == "cloudflare" {
175 let profile = lookup_provider("cloudflare").expect("cloudflare is in the registry");
176 let api_key_env = override_env.unwrap_or(profile.api_key_env);
177 let base_url = match override_url {
178 Some(url) => {
181 validate_provider_base_url(&url)?;
182 warn_overridden_provider_host("cloudflare", &url);
183 url
184 },
185 None => match require_cloudflare_account_id() {
189 Ok(id) => cloudflare_base_url(&id),
190 Err(_)
191 if resolve_provider_key("cloudflare", profile.api_key_env, override_env)
192 .is_none() =>
193 {
194 return Err(ModelError::Authentication(format!(
195 "cloudflare requires env vars {api_key_env} and CLOUDFLARE_ACCOUNT_ID — \
196 create a token at https://dash.cloudflare.com/profile/api-tokens; the \
197 account id is on your Cloudflare dashboard (or set \
198 [providers.cloudflare].base_url)"
199 )));
200 },
201 Err(e) => return Err(e),
202 },
203 };
204 let api_key = resolve_optional_key(
205 &provider_lc,
206 profile.api_key_env,
207 override_env,
208 &base_url,
209 profile.key_hint,
210 )?;
211 let key_env = api_key
212 .is_some()
213 .then(|| key_env_source(profile.api_key_env, override_env))
214 .flatten();
215 return Ok(ProviderEndpoint {
216 base_url,
217 api_key,
218 key_env,
219 });
220 }
221
222 if let Some(profile) = lookup_provider(&provider_lc) {
224 let base_url = resolve_overridable_base_url(&provider_lc, override_url, profile.base_url)?;
225 let api_key = resolve_optional_key(
229 &provider_lc,
230 profile.api_key_env,
231 override_env,
232 &base_url,
233 profile.key_hint,
234 )?;
235 let key_env = api_key
236 .is_some()
237 .then(|| key_env_source(profile.api_key_env, override_env))
238 .flatten();
239 return Ok(ProviderEndpoint {
240 base_url,
241 api_key,
242 key_env,
243 });
244 }
245
246 if user_cfg.is_some() {
249 let base_url = override_url.ok_or_else(|| {
250 ModelError::InvalidRequest(format!(
251 "custom provider '{provider_lc}' requires base_url in config"
252 ))
253 })?;
254 let resolved = match override_env {
261 Some(env) => resolve_provider_key(&provider_lc, env, None),
262 None => mermaid_model::utils::default_store().get(&provider_lc),
263 };
264 let (api_key, key_env) = match resolved {
265 Some(key) => {
266 validate_provider_base_url(&base_url)?;
267 let env = override_env.and_then(|env| key_env_source(env, None));
268 (Some(key), env)
269 },
270 None if base_url_is_local(&base_url) => (None, None),
271 None => {
272 let reason = match override_env {
273 Some(env) => format!(
274 "requires env var {env} (or `mermaid login {provider_lc}`, or a \
275 loopback/LAN base_url)"
276 ),
277 None => "requires api_key_env, or a loopback/LAN base_url".to_string(),
278 };
279 return Err(ModelError::Authentication(format!(
280 "custom provider '{provider_lc}' {reason}"
281 )));
282 },
283 };
284 return Ok(ProviderEndpoint {
285 base_url,
286 api_key,
287 key_env,
288 });
289 }
290
291 Err(ModelError::InvalidRequest(format!(
292 "Unknown provider '{provider}'"
293 )))
294}
295
296fn resolve_optional_key(
301 provider: &str,
302 default_env: &str,
303 override_env: Option<&str>,
304 base_url: &str,
305 hint: Option<&str>,
306) -> Result<Option<String>> {
307 if let Some(key) = resolve_provider_key(provider, default_env, override_env) {
308 return Ok(Some(key));
309 }
310 if base_url_is_local(base_url) {
311 return Ok(None);
312 }
313 let env = override_env.unwrap_or(default_env);
314 let mut msg = format!("{provider} requires env var {env} (or `mermaid login {provider}`)");
315 if let Some(h) = hint {
316 msg.push_str(" — ");
317 msg.push_str(h);
318 }
319 Err(ModelError::Authentication(msg))
320}
321
322fn base_url_is_local(base_url: &str) -> bool {
325 reqwest::Url::parse(base_url)
326 .ok()
327 .and_then(|u| {
328 u.host_str()
329 .map(|h| mermaid_model::utils::classify_host(h).is_internal())
330 })
331 .unwrap_or(false)
332}
333
334fn merged_headers(
340 profile: &mermaid_model::models::ProviderProfile,
341 user_cfg: Option<&mermaid_domain::UserProviderConfig>,
342) -> std::collections::HashMap<String, String> {
343 let mut headers: std::collections::HashMap<String, String> = profile
344 .extra_headers
345 .iter()
346 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
347 .collect();
348 if let Some(cfg) = user_cfg {
349 for (k, v) in &cfg.extra_headers {
350 headers.insert(k.clone(), v.clone());
351 }
352 for (header, env_var) in &cfg.env_headers {
353 if let Ok(val) = std::env::var(env_var) {
354 headers.insert(header.clone(), val);
355 }
356 }
357 }
358 headers
359}
360
361use super::model::{
362 AnthropicProvider, GeminiProvider, MetaProvider, ModelProvider, OllamaProvider,
363 OpenAICompatProvider,
364};
365
366type ProviderCell = Arc<tokio::sync::OnceCell<Arc<dyn ModelProvider>>>;
370
371pub struct ProviderFactory {
375 config: Arc<Config>,
376 cache: Mutex<std::collections::HashMap<String, ProviderCell>>,
379}
380
381impl ProviderFactory {
382 #[must_use]
383 pub fn new(config: Config) -> Self {
384 Self {
385 config: Arc::new(config),
386 cache: Mutex::new(std::collections::HashMap::new()),
387 }
388 }
389
390 pub fn with_seeded_providers(
401 config: Config,
402 seeds: impl IntoIterator<Item = (String, Arc<dyn ModelProvider>)>,
403 ) -> Self {
404 let cache = seeds
405 .into_iter()
406 .map(|(model_id, provider)| {
407 let cell = tokio::sync::OnceCell::new_with(Some(provider));
408 (normalize_cache_key(&model_id), Arc::new(cell))
409 })
410 .collect();
411 Self {
412 config: Arc::new(config),
413 cache: Mutex::new(cache),
414 }
415 }
416
417 pub fn config(&self) -> &Config {
418 &self.config
419 }
420
421 pub async fn resolve(&self, model_id: &str) -> Result<Arc<dyn ModelProvider>> {
432 let key = normalize_cache_key(model_id);
433 let cell = {
437 let mut cache = self.cache.lock().await;
438 Arc::clone(
439 cache
440 .entry(key)
441 .or_insert_with(|| Arc::new(tokio::sync::OnceCell::new())),
442 )
443 };
444 let provider = cell
445 .get_or_try_init(|| async {
446 let p = build_provider(&self.config, model_id).await?;
447 Ok::<Arc<dyn ModelProvider>, ModelError>(Arc::from(p))
448 })
449 .await?;
450 Ok(Arc::clone(provider))
451 }
452}
453
454async fn build_provider(config: &Config, model_id: &str) -> Result<Box<dyn ModelProvider>> {
463 let (provider, model_name) = parse_model_id(model_id);
464 let provider_lc = provider.to_lowercase();
465
466 if provider_lc == "ollama" {
469 let backend = ollama_backend_config(config);
470 let p = OllamaProvider::with_app_config(
471 model_name,
472 Arc::new(backend),
473 Arc::new(config.clone()),
474 )
475 .await?;
476 return Ok(Box::new(p));
477 }
478
479 if !is_known_provider(config, &provider_lc) {
484 return Err(ModelError::InvalidRequest(format!(
485 "Unknown provider '{provider}' (model_id: {model_id})"
486 )));
487 }
488 let endpoint = resolve_provider_endpoint(config, &provider_lc)?;
489 let user_cfg = config.providers.get(&provider_lc);
490
491 if provider_lc == "anthropic" {
493 let p = AnthropicProvider::new(
494 endpoint.require_key(&provider_lc)?,
495 model_name.to_string(),
496 endpoint.base_url,
497 )?;
498 return Ok(Box::new(p));
499 }
500
501 if provider_lc == "gemini" {
503 let p = GeminiProvider::new(
504 endpoint.require_key(&provider_lc)?,
505 model_name.to_string(),
506 endpoint.base_url,
507 )?;
508 return Ok(Box::new(p));
509 }
510
511 if provider_lc == "meta" {
514 let api_key = endpoint.require_key(&provider_lc)?;
515 let mut extra_headers = std::collections::HashMap::new();
516 if let Some(cfg) = user_cfg {
517 extra_headers.extend(cfg.extra_headers.clone());
518 for (header, env_var) in &cfg.env_headers {
519 if let Ok(value) = std::env::var(env_var) {
520 extra_headers.insert(header.clone(), value);
521 }
522 }
523 }
524 let p = MetaProvider::new(
525 api_key,
526 model_name.to_string(),
527 endpoint.base_url,
528 extra_headers,
529 )?;
530 return Ok(Box::new(p));
531 }
532
533 let profile = match lookup_provider(&provider_lc) {
537 Some(profile) => profile,
538 None => user_cfg
541 .and_then(|cfg| user_profile_to_static(&provider_lc, cfg))
542 .ok_or_else(|| {
543 ModelError::InvalidRequest(format!(
544 "Unknown provider '{provider}' (model_id: {model_id})"
545 ))
546 })?,
547 };
548 let extra_headers = merged_headers(profile, user_cfg);
549 let p = OpenAICompatProvider::new(
550 profile,
551 endpoint.base_url,
552 endpoint.api_key,
553 model_name.to_string(),
554 extra_headers,
555 )?;
556 Ok(Box::new(p))
557}
558
559fn normalize_cache_key(model_id: &str) -> String {
563 let (provider, model) = parse_model_id(model_id);
564 format!("{}/{}", provider.to_lowercase(), model)
565}
566
567fn parse_model_id(model_id: &str) -> (String, &str) {
570 match model_id.split_once('/') {
571 Some((p, m)) => (p.to_string(), m),
572 None => ("ollama".to_string(), model_id),
573 }
574}
575
576#[must_use]
588pub fn model_provider_resolves(config: &Config, model_id: &str) -> bool {
589 let (provider, _) = parse_model_id(model_id);
590 provider.eq_ignore_ascii_case("ollama") || resolve_provider_endpoint(config, &provider).is_ok()
591}
592
593pub(crate) fn ollama_backend_config(config: &Config) -> BackendConfig {
594 BackendConfig {
595 ollama_url: config.ollama.base_url(),
598 max_idle_per_host: 10,
599 timeout_secs: 10,
600 ollama_autostart: config.ollama.auto_start,
601 }
602}
603
604static PROFILE_CACHE: std::sync::LazyLock<
608 std::sync::Mutex<
609 std::collections::HashMap<String, &'static mermaid_model::models::ProviderProfile>,
610 >,
611> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
612
613fn user_profile_to_static(
627 name: &str,
628 user_cfg: &mermaid_domain::UserProviderConfig,
629) -> Option<&'static mermaid_model::models::ProviderProfile> {
630 use mermaid_model::models::{ProviderProfile, ReasoningExtraction, ReasoningStrategy};
631
632 let compat = user_cfg.compat.as_deref().unwrap_or("openai");
633 let base_url = user_cfg.base_url.clone().unwrap_or_default();
634 let api_key_env = user_cfg.api_key_env.clone().unwrap_or_default();
635
636 let cache_key = format!("{name}\u{0}{base_url}\u{0}{api_key_env}\u{0}{compat}");
639
640 let mut cache = PROFILE_CACHE
641 .lock()
642 .unwrap_or_else(|poisoned| poisoned.into_inner());
643 if let Some(profile) = cache.get(cache_key.as_str()) {
644 return Some(*profile);
647 }
648
649 let strategy = match compat {
650 "openai" => ReasoningStrategy::None,
651 "openai-effort" => ReasoningStrategy::Effort,
652 "openrouter" => ReasoningStrategy::OpenRouterShape,
653 _ => ReasoningStrategy::None,
654 };
655
656 let profile = Box::new(ProviderProfile {
657 name: Box::leak(name.to_string().into_boxed_str()),
658 base_url: Box::leak(base_url.into_boxed_str()),
659 api_key_env: Box::leak(api_key_env.into_boxed_str()),
660 key_hint: None,
661 extra_headers: &[],
662 reasoning_strategy: strategy,
663 reasoning_extraction: ReasoningExtraction::None,
664 max_tokens_param: mermaid_model::models::MaxTokensParam::MaxTokens,
665 disable_parallel_tool_calls_for: &[],
666 });
667 let leaked: &'static ProviderProfile = Box::leak(profile);
668 cache.insert(cache_key, leaked);
669 Some(leaked)
670}
671
672fn cloudflare_base_url(account_id: &str) -> String {
677 format!(
678 "https://api.cloudflare.com/client/v4/accounts/{}/ai/v1",
679 account_id.trim()
680 )
681}
682
683pub(crate) fn discovery_base_url(
691 profile: &mermaid_model::models::ProviderProfile,
692 override_url: Option<String>,
693) -> Option<String> {
694 if override_url.is_some() {
695 return override_url;
696 }
697 if profile.name == "cloudflare" {
698 return require_cloudflare_account_id()
699 .ok()
700 .map(|id| cloudflare_base_url(&id));
701 }
702 Some(profile.base_url.to_string())
703}
704
705fn require_cloudflare_account_id() -> Result<String> {
710 resolve_api_key("CLOUDFLARE_ACCOUNT_ID", None)
711 .map(|s| s.trim().to_string())
712 .filter(|s| !s.is_empty())
713 .ok_or_else(|| {
714 ModelError::Authentication(
715 "cloudflare requires env var CLOUDFLARE_ACCOUNT_ID (your Cloudflare account id) — \
716 find it on your Cloudflare dashboard, or set [providers.cloudflare].base_url"
717 .to_string(),
718 )
719 })
720}
721
722fn validate_provider_base_url(url: &str) -> Result<()> {
728 let parsed = reqwest::Url::parse(url).map_err(|e| {
729 ModelError::InvalidRequest(format!("invalid provider base_url '{url}': {e}"))
730 })?;
731 match parsed.scheme() {
732 "https" => Ok(()),
733 "http"
738 if mermaid_model::utils::classify_host(parsed.host_str().unwrap_or_default())
739 .is_loopback() =>
740 {
741 Ok(())
742 },
743 "http" => Err(ModelError::InvalidRequest(format!(
744 "provider base_url '{url}' uses http:// to a non-loopback host — refusing to send the \
745 API key in cleartext. Use https, or http://localhost for a local server."
746 ))),
747 other => Err(ModelError::InvalidRequest(format!(
748 "provider base_url '{url}' has unsupported scheme '{other}' (use http or https)"
749 ))),
750 }
751}
752
753fn resolve_overridable_base_url(
769 provider: &str,
770 override_url: Option<String>,
771 default_url: &str,
772) -> Result<String> {
773 match override_url {
774 Some(url) => {
775 validate_provider_base_url(&url)?;
776 warn_overridden_provider_host(provider, &url);
777 Ok(url)
778 },
779 None => Ok(default_url.to_string()),
780 }
781}
782
783static WARNED_OVERRIDE_HOSTS: std::sync::LazyLock<
787 std::sync::Mutex<std::collections::HashSet<String>>,
788> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
789
790fn warn_overridden_provider_host(provider: &str, base_url: &str) {
794 let host = provider_host(base_url);
795 if should_warn_once(&format!("{provider}@{host}")) {
796 tracing::warn!(
797 "built-in provider '{}' base_url overridden in config: the {} API key will be sent to \
798 host '{}' instead of the trusted default endpoint",
799 provider,
800 provider,
801 host
802 );
803 }
804}
805
806fn provider_host(base_url: &str) -> String {
809 reqwest::Url::parse(base_url)
810 .ok()
811 .and_then(|u| u.host_str().map(str::to_string))
812 .unwrap_or_else(|| "<unknown>".to_string())
813}
814
815fn should_warn_once(key: &str) -> bool {
818 let mut warned = WARNED_OVERRIDE_HOSTS
819 .lock()
820 .unwrap_or_else(|poisoned| poisoned.into_inner());
821 warned.insert(key.to_string())
822}
823
824#[cfg(test)]
825mod tests {
826 use super::*;
827
828 #[test]
829 fn base_url_is_local_classifies_hosts() {
830 assert!(base_url_is_local("http://127.0.0.1:8000/v1"));
831 assert!(base_url_is_local("http://localhost:1234/v1"));
832 assert!(base_url_is_local("http://192.168.1.5:8000/v1"));
833 assert!(!base_url_is_local("https://api.openai.com/v1"));
834 assert!(!base_url_is_local("not a url"));
835 }
836
837 #[test]
838 fn merged_headers_keeps_static_profile_headers_and_user_overrides() {
839 let profile = mermaid_model::models::lookup_provider("openrouter").unwrap();
840 let base = merged_headers(profile, None);
842 assert_eq!(
843 base.get("X-OpenRouter-Title").map(String::as_str),
844 Some("Mermaid")
845 );
846 assert!(base.contains_key("HTTP-Referer"));
847 let mut cfg = mermaid_domain::UserProviderConfig::default();
849 cfg.extra_headers.insert("X-Custom".into(), "v".into());
850 cfg.extra_headers
851 .insert("X-OpenRouter-Title".into(), "Override".into());
852 let merged = merged_headers(profile, Some(&cfg));
853 assert_eq!(merged.get("X-Custom").map(String::as_str), Some("v"));
854 assert_eq!(
855 merged.get("X-OpenRouter-Title").map(String::as_str),
856 Some("Override")
857 );
858 assert!(merged.contains_key("HTTP-Referer"));
859 }
860
861 #[test]
862 fn merged_headers_resolves_env_headers_and_skips_missing() {
863 let profile = mermaid_model::models::lookup_provider("openai").unwrap();
864 let mut cfg = mermaid_domain::UserProviderConfig::default();
865 cfg.env_headers
866 .insert("X-Gateway-Token".into(), "MERMAID_TEST_GW_TOKEN".into());
867 temp_env::with_var("MERMAID_TEST_GW_TOKEN", Some("secret123"), || {
868 let merged = merged_headers(profile, Some(&cfg));
869 assert_eq!(
870 merged.get("X-Gateway-Token").map(String::as_str),
871 Some("secret123")
872 );
873 });
874 temp_env::with_var("MERMAID_TEST_GW_TOKEN", None::<&str>, || {
875 assert!(!merged_headers(profile, Some(&cfg)).contains_key("X-Gateway-Token"));
876 });
877 }
878
879 #[test]
880 fn base_url_requires_https_for_remote_hosts() {
881 assert!(validate_provider_base_url("http://api.example.com/v1").is_err());
883 assert!(validate_provider_base_url("ftp://example.com").is_err());
884 assert!(validate_provider_base_url("https://api.example.com/v1").is_ok());
886 assert!(validate_provider_base_url("http://localhost:11434/v1").is_ok());
887 assert!(validate_provider_base_url("http://127.0.0.1:8000").is_ok());
888 assert!(validate_provider_base_url("http://[::1]:8000").is_ok());
889 assert!(validate_provider_base_url("http://192.168.1.5:8080").is_err());
892 assert!(validate_provider_base_url("http://169.254.169.254").is_err());
893 }
894
895 #[test]
896 fn cloudflare_base_url_synthesizes_account_scoped_endpoint() {
897 assert_eq!(
898 cloudflare_base_url("acct123"),
899 "https://api.cloudflare.com/client/v4/accounts/acct123/ai/v1"
900 );
901 assert_eq!(
903 cloudflare_base_url(" acct123\n"),
904 "https://api.cloudflare.com/client/v4/accounts/acct123/ai/v1"
905 );
906 }
907
908 #[test]
909 fn cloudflare_account_id_required_and_non_blank() {
910 temp_env::with_var("CLOUDFLARE_ACCOUNT_ID", None::<&str>, || {
912 let err = require_cloudflare_account_id().expect_err("must error when unset");
913 assert!(format!("{err}").contains("CLOUDFLARE_ACCOUNT_ID"));
914 });
915 temp_env::with_var("CLOUDFLARE_ACCOUNT_ID", Some(" "), || {
917 assert!(require_cloudflare_account_id().is_err());
918 });
919 temp_env::with_var("CLOUDFLARE_ACCOUNT_ID", Some(" acct123 "), || {
921 assert_eq!(require_cloudflare_account_id().unwrap(), "acct123");
922 });
923 }
924
925 #[test]
926 fn discovery_base_url_resolves_per_provider() {
927 let cf = lookup_provider("cloudflare").expect("cloudflare is in the registry");
928 let openai = lookup_provider("openai").expect("openai is in the registry");
929 assert_eq!(
931 discovery_base_url(cf, Some("https://gw.example/v1".to_string())),
932 Some("https://gw.example/v1".to_string())
933 );
934 assert_eq!(
936 discovery_base_url(openai, None),
937 Some(openai.base_url.to_string())
938 );
939 temp_env::with_var("CLOUDFLARE_ACCOUNT_ID", Some("acct123"), || {
941 assert_eq!(
942 discovery_base_url(cf, None),
943 Some("https://api.cloudflare.com/client/v4/accounts/acct123/ai/v1".to_string())
944 );
945 });
946 temp_env::with_var("CLOUDFLARE_ACCOUNT_ID", None::<&str>, || {
948 assert_eq!(discovery_base_url(cf, None), None);
949 });
950 }
951
952 #[tokio::test]
953 async fn cloudflare_missing_both_env_vars_is_one_combined_error() {
954 temp_env::async_with_vars(
955 [
956 ("CLOUDFLARE_ACCOUNT_ID", None::<&str>),
957 ("CLOUDFLARE_API_TOKEN", None),
958 ],
959 async {
960 let f = ProviderFactory::new(Config::default());
961 let err = match f.resolve("cloudflare/@cf/zai-org/glm-5.2").await {
962 Ok(_) => panic!("must fail with neither env var set"),
963 Err(e) => e,
964 };
965 let msg = format!("{err}");
966 assert!(
967 msg.contains("CLOUDFLARE_API_TOKEN") && msg.contains("CLOUDFLARE_ACCOUNT_ID"),
968 "one error must name both missing vars, got: {msg}"
969 );
970 },
971 )
972 .await;
973 }
974
975 use std::sync::atomic::{AtomicUsize, Ordering};
976
977 fn unique_env(prefix: &str) -> String {
978 static N: AtomicUsize = AtomicUsize::new(0);
979 format!(
980 "{}_{}_{}",
981 prefix,
982 std::process::id(),
983 N.fetch_add(1, Ordering::SeqCst)
984 )
985 }
986
987 #[test]
988 fn parse_bare_name_defaults_to_ollama() {
989 let (p, m) = parse_model_id("qwen3-coder:30b");
990 assert_eq!(p, "ollama");
991 assert_eq!(m, "qwen3-coder:30b");
992 }
993
994 #[test]
995 fn parse_prefixed() {
996 let (p, m) = parse_model_id("anthropic/claude-opus-4-7");
997 assert_eq!(p, "anthropic");
998 assert_eq!(m, "claude-opus-4-7");
999 }
1000
1001 #[tokio::test]
1002 async fn meta_requires_its_documented_api_key_env() {
1003 temp_env::async_with_vars(
1004 [(
1005 crate::providers::model::meta::DEFAULT_API_KEY_ENV,
1006 None::<&str>,
1007 )],
1008 async {
1009 let factory = ProviderFactory::new(Config::default());
1010 let error = match factory.resolve("meta/muse-spark-1.1").await {
1011 Ok(_) => panic!("Meta must require an API key"),
1012 Err(error) => error,
1013 };
1014 assert!(
1015 error
1016 .to_string()
1017 .contains(crate::providers::model::meta::DEFAULT_API_KEY_ENV)
1018 );
1019 },
1020 )
1021 .await;
1022 }
1023
1024 #[tokio::test]
1025 async fn meta_routes_to_responses_provider_with_muse_capabilities() {
1026 temp_env::async_with_vars(
1027 [(
1028 crate::providers::model::meta::DEFAULT_API_KEY_ENV,
1029 Some("test-key"),
1030 )],
1031 async {
1032 let factory = ProviderFactory::new(Config::default());
1033 let provider = factory.resolve("meta/muse-spark-1.1").await.unwrap();
1034 let capabilities = provider.capabilities();
1035 assert!(capabilities.supports_tools);
1036 assert!(capabilities.supports_vision);
1037 assert!(capabilities.emits_provider_continuation);
1038 assert_eq!(
1039 capabilities.max_context_tokens,
1040 Some(mermaid_model::constants::META_MUSE_SPARK_CONTEXT_WINDOW)
1041 );
1042 assert_eq!(
1043 capabilities.max_output_tokens,
1044 Some(mermaid_model::constants::META_MUSE_SPARK_MAX_OUTPUT_TOKENS)
1045 );
1046 },
1047 )
1048 .await;
1049 }
1050
1051 #[test]
1052 fn gemini_key_resolution_accepts_legacy_fallback() {
1053 let primary = unique_env("MERMAID_FACTORY_GEMINI_PRIMARY");
1054 let legacy = unique_env("MERMAID_FACTORY_GEMINI_LEGACY");
1055 temp_env::with_vars(
1056 [(primary.as_str(), None), (legacy.as_str(), Some("legacy"))],
1057 || {
1058 let resolved = require_key_with_fallback("gemini", &primary, &legacy)
1059 .expect("legacy fallback should resolve");
1060 assert_eq!(resolved, "legacy");
1061 },
1062 );
1063 }
1064
1065 #[test]
1066 fn gemini_key_resolution_prefers_google_primary() {
1067 let primary = unique_env("MERMAID_FACTORY_GEMINI_PRIMARY2");
1068 let legacy = unique_env("MERMAID_FACTORY_GEMINI_LEGACY2");
1069 temp_env::with_vars(
1070 [
1071 (primary.as_str(), Some("google")),
1072 (legacy.as_str(), Some("legacy")),
1073 ],
1074 || {
1075 let resolved = require_key_with_fallback("gemini", &primary, &legacy)
1076 .expect("primary should resolve");
1077 assert_eq!(resolved, "google");
1078 },
1079 );
1080 }
1081
1082 #[tokio::test]
1083 async fn factory_reports_unknown_provider_clearly() {
1084 let cfg = Config::default();
1085 let f = ProviderFactory::new(cfg);
1086 match f.resolve("totally-made-up/model").await {
1087 Ok(_) => panic!("expected error"),
1088 Err(e) => {
1089 let msg = format!("{e}");
1090 assert!(
1091 msg.contains("totally-made-up") || msg.contains("Unknown provider"),
1092 "error message: {msg}"
1093 );
1094 },
1095 }
1096 }
1097
1098 #[test]
1099 fn normalize_cache_key_lowercases_provider_only() {
1100 assert_eq!(
1102 normalize_cache_key("Anthropic/Claude-X"),
1103 "anthropic/Claude-X"
1104 );
1105 assert_eq!(
1106 normalize_cache_key("anthropic/Claude-X"),
1107 "anthropic/Claude-X"
1108 );
1109 assert_eq!(normalize_cache_key("qwen3:30b"), "ollama/qwen3:30b");
1111 }
1112
1113 #[tokio::test]
1114 async fn resolve_is_single_flight_and_cached() {
1115 let f = ProviderFactory::new(Config::default());
1119 let (a, b) = tokio::join!(
1120 f.resolve("ollama/test-model"),
1121 f.resolve("Ollama/test-model"),
1122 );
1123 let a = a.expect("resolve a");
1124 let b = b.expect("resolve b");
1125 assert!(
1126 Arc::ptr_eq(&a, &b),
1127 "expected one cached provider for casing variants + concurrent resolve"
1128 );
1129 }
1130
1131 #[test]
1135 fn builtin_base_url_override_validated_and_resolved() {
1136 assert_eq!(
1138 resolve_overridable_base_url("anthropic", None, "https://api.anthropic.com/v1")
1139 .unwrap(),
1140 "https://api.anthropic.com/v1"
1141 );
1142 assert_eq!(
1144 resolve_overridable_base_url(
1145 "anthropic",
1146 Some("https://proxy.internal/v1".to_string()),
1147 "https://api.anthropic.com/v1",
1148 )
1149 .unwrap(),
1150 "https://proxy.internal/v1"
1151 );
1152 assert!(
1155 resolve_overridable_base_url(
1156 "anthropic",
1157 Some("http://attacker.example/v1".to_string()),
1158 "https://api.anthropic.com/v1",
1159 )
1160 .is_err()
1161 );
1162 assert!(
1164 resolve_overridable_base_url(
1165 "openai",
1166 Some("http://localhost:8080/v1".to_string()),
1167 "https://api.openai.com/v1",
1168 )
1169 .is_ok()
1170 );
1171 }
1172
1173 #[test]
1174 fn provider_host_extracts_host_or_unknown() {
1175 assert_eq!(
1176 provider_host("https://attacker.example/v1"),
1177 "attacker.example"
1178 );
1179 assert_eq!(provider_host("http://127.0.0.1:8080"), "127.0.0.1");
1180 assert_eq!(provider_host("not a url"), "<unknown>");
1181 }
1182
1183 #[test]
1184 fn override_host_warning_is_deduped() {
1185 let key = unique_env("MERMAID_FACTORY_WARN_KEY");
1189 assert!(should_warn_once(&key), "first warn for a key must fire");
1190 assert!(
1191 !should_warn_once(&key),
1192 "subsequent warns for the same key must be suppressed"
1193 );
1194 }
1195
1196 #[test]
1200 fn custom_profile_is_memoized_per_key() {
1201 use mermaid_domain::UserProviderConfig;
1202 let cfg = UserProviderConfig {
1203 base_url: Some("https://api.custom.test/v1".to_string()),
1204 api_key_env: Some("CUSTOM_KEY".to_string()),
1205 compat: Some("openai".to_string()),
1206 ..Default::default()
1207 };
1208 let a = user_profile_to_static("mermaid_test_customx", &cfg).unwrap();
1211 let b = user_profile_to_static("mermaid_test_customx", &cfg).unwrap();
1212 assert!(
1213 std::ptr::eq(a, b),
1214 "identical custom-provider inputs must reuse one leaked &'static profile"
1215 );
1216 assert_eq!(a.base_url, "https://api.custom.test/v1");
1217 assert_eq!(a.api_key_env, "CUSTOM_KEY");
1218
1219 let cfg2 = UserProviderConfig {
1221 base_url: Some("https://api.custom.test/v2".to_string()),
1222 ..cfg.clone()
1223 };
1224 let c = user_profile_to_static("mermaid_test_customx", &cfg2).unwrap();
1225 assert!(
1226 !std::ptr::eq(a, c),
1227 "a different base_url must leak a distinct profile"
1228 );
1229 }
1230}