1use std::sync::Arc;
9
10use tokio::sync::Mutex;
11
12use mermaid_domain::Config;
13use mermaid_model::models::{ModelError, Result, lookup_provider};
14use mermaid_model::utils::{
15 resolve_api_key, resolve_provider_key, resolve_provider_key_with_fallback,
16};
17
18use super::model::{anthropic, gemini, meta};
19
20fn require_key(provider: &str, default_env: &str, override_env: Option<&str>) -> Result<String> {
24 resolve_provider_key(provider, default_env, override_env).ok_or_else(|| {
25 let env = override_env.unwrap_or(default_env);
26 ModelError::Authentication(format!(
27 "{provider} requires env var {env} (or `mermaid login {provider}`)"
28 ))
29 })
30}
31
32fn require_key_with_fallback(
33 provider: &str,
34 env_var: &str,
35 fallback_env_var: &str,
36) -> Result<String> {
37 resolve_provider_key_with_fallback(provider, env_var, fallback_env_var, None).ok_or_else(|| {
38 ModelError::Authentication(format!(
39 "{provider} requires env var {env_var} (or legacy {fallback_env_var}, or \
40 `mermaid login {provider}`)"
41 ))
42 })
43}
44
45fn key_env_source(default_env: &str, override_env: Option<&str>) -> Option<String> {
51 let env = override_env.unwrap_or(default_env);
55 resolve_api_key(env, None).map(|_| env.to_string())
56}
57
58fn key_env_source_with_fallback(default_env: &str, fallback_env: &str) -> Option<String> {
60 if resolve_api_key(default_env, None).is_some() {
61 return Some(default_env.to_string());
62 }
63 if resolve_api_key(fallback_env, None).is_some() {
64 return Some(fallback_env.to_string());
65 }
66 None
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
79pub(crate) struct ProviderEndpoint {
80 pub(crate) base_url: String,
82 pub(crate) api_key: Option<String>,
85 pub(crate) key_env: Option<String>,
88}
89
90impl ProviderEndpoint {
91 fn require_key(&self, provider: &str) -> Result<String> {
97 self.api_key
98 .clone()
99 .ok_or_else(|| ModelError::Authentication(format!("{provider} requires an API key")))
100 }
101}
102
103fn is_known_provider(config: &Config, provider_lc: &str) -> bool {
106 matches!(provider_lc, "anthropic" | "gemini" | "meta")
107 || lookup_provider(provider_lc).is_some()
108 || config.providers.contains_key(provider_lc)
109}
110
111pub(crate) fn resolve_provider_endpoint(
119 config: &Config,
120 provider: &str,
121) -> Result<ProviderEndpoint> {
122 let provider_lc = provider.to_lowercase();
123 let user_cfg = config.providers.get(&provider_lc);
124 let override_env = user_cfg.and_then(|c| c.api_key_env.as_deref());
125 let override_url = user_cfg.and_then(|c| c.base_url.clone());
126
127 let bespoke = match provider_lc.as_str() {
130 "anthropic" => Some((
131 anthropic::DEFAULT_API_KEY_ENV,
132 None,
133 anthropic::DEFAULT_BASE_URL,
134 )),
135 "gemini" => Some((
136 gemini::DEFAULT_API_KEY_ENV,
137 Some(gemini::LEGACY_API_KEY_ENV),
138 gemini::DEFAULT_BASE_URL,
139 )),
140 "meta" => Some((meta::DEFAULT_API_KEY_ENV, None, meta::DEFAULT_BASE_URL)),
141 _ => None,
142 };
143 if let Some((default_env, legacy_env, default_url)) = bespoke {
144 let base_url = resolve_overridable_base_url(&provider_lc, override_url, default_url)?;
145 let (api_key, key_env) = match legacy_env {
146 Some(legacy) if override_env.is_none() => (
149 require_key_with_fallback(&provider_lc, default_env, legacy)?,
150 key_env_source_with_fallback(default_env, legacy),
151 ),
152 _ => (
153 require_key(&provider_lc, default_env, override_env)?,
154 key_env_source(default_env, override_env),
155 ),
156 };
157 return Ok(ProviderEndpoint {
158 base_url,
159 api_key: Some(api_key),
160 key_env,
161 });
162 }
163
164 if provider_lc == "cloudflare" {
170 return resolve_cloudflare_endpoint(&provider_lc, override_env, override_url);
171 }
172
173 if let Some(profile) = lookup_provider(&provider_lc) {
175 let base_url = resolve_overridable_base_url(&provider_lc, override_url, profile.base_url)?;
176 let api_key = resolve_optional_key(
180 &provider_lc,
181 profile.api_key_env,
182 override_env,
183 &base_url,
184 profile.key_hint,
185 )?;
186 let key_env = api_key
187 .is_some()
188 .then(|| key_env_source(profile.api_key_env, override_env))
189 .flatten();
190 return Ok(ProviderEndpoint {
191 base_url,
192 api_key,
193 key_env,
194 });
195 }
196
197 if user_cfg.is_some() {
200 return resolve_custom_endpoint(&provider_lc, override_env, override_url);
201 }
202
203 Err(ModelError::InvalidRequest(format!(
204 "Unknown provider '{provider}'"
205 )))
206}
207
208fn resolve_cloudflare_endpoint(
214 provider_lc: &str,
215 override_env: Option<&str>,
216 override_url: Option<String>,
217) -> Result<ProviderEndpoint> {
218 let profile = lookup_provider("cloudflare").expect("cloudflare is in the registry");
219 let api_key_env = override_env.unwrap_or(profile.api_key_env);
220 let base_url = match override_url {
221 Some(url) => {
224 validate_provider_base_url(&url)?;
225 warn_overridden_provider_host("cloudflare", &url);
226 url
227 },
228 None => match require_cloudflare_account_id() {
232 Ok(id) => cloudflare_base_url(&id),
233 Err(_)
234 if resolve_provider_key("cloudflare", profile.api_key_env, override_env)
235 .is_none() =>
236 {
237 return Err(ModelError::Authentication(format!(
238 "cloudflare requires env vars {api_key_env} and CLOUDFLARE_ACCOUNT_ID — \
239 create a token at https://dash.cloudflare.com/profile/api-tokens; the \
240 account id is on your Cloudflare dashboard (or set \
241 [providers.cloudflare].base_url)"
242 )));
243 },
244 Err(e) => return Err(e),
245 },
246 };
247 let api_key = resolve_optional_key(
248 provider_lc,
249 profile.api_key_env,
250 override_env,
251 &base_url,
252 profile.key_hint,
253 )?;
254 let key_env = api_key
255 .is_some()
256 .then(|| key_env_source(profile.api_key_env, override_env))
257 .flatten();
258 Ok(ProviderEndpoint {
259 base_url,
260 api_key,
261 key_env,
262 })
263}
264
265fn resolve_custom_endpoint(
268 provider_lc: &str,
269 override_env: Option<&str>,
270 override_url: Option<String>,
271) -> Result<ProviderEndpoint> {
272 let base_url = override_url.ok_or_else(|| {
273 ModelError::InvalidRequest(format!(
274 "custom provider '{provider_lc}' requires base_url in config"
275 ))
276 })?;
277 let resolved = match override_env {
284 Some(env) => resolve_provider_key(provider_lc, env, None),
285 None => mermaid_model::utils::default_store().get(provider_lc),
286 };
287 let (api_key, key_env) = match resolved {
288 Some(key) => {
289 validate_provider_base_url(&base_url)?;
290 let env = override_env.and_then(|env| key_env_source(env, None));
291 (Some(key), env)
292 },
293 None if base_url_is_local(&base_url) => (None, None),
294 None => {
295 let reason = match override_env {
296 Some(env) => format!(
297 "requires env var {env} (or `mermaid login {provider_lc}`, or a \
298 loopback/LAN base_url)"
299 ),
300 None => "requires api_key_env, or a loopback/LAN base_url".to_string(),
301 };
302 return Err(ModelError::Authentication(format!(
303 "custom provider '{provider_lc}' {reason}"
304 )));
305 },
306 };
307 Ok(ProviderEndpoint {
308 base_url,
309 api_key,
310 key_env,
311 })
312}
313
314fn resolve_optional_key(
319 provider: &str,
320 default_env: &str,
321 override_env: Option<&str>,
322 base_url: &str,
323 hint: Option<&str>,
324) -> Result<Option<String>> {
325 if let Some(key) = resolve_provider_key(provider, default_env, override_env) {
326 return Ok(Some(key));
327 }
328 if base_url_is_local(base_url) {
329 return Ok(None);
330 }
331 let env = override_env.unwrap_or(default_env);
332 let mut msg = format!("{provider} requires env var {env} (or `mermaid login {provider}`)");
333 if let Some(h) = hint {
334 msg.push_str(" — ");
335 msg.push_str(h);
336 }
337 Err(ModelError::Authentication(msg))
338}
339
340fn base_url_is_local(base_url: &str) -> bool {
343 reqwest::Url::parse(base_url)
344 .ok()
345 .and_then(|u| {
346 u.host_str()
347 .map(|h| mermaid_model::utils::classify_host(h).is_internal())
348 })
349 .unwrap_or(false)
350}
351
352fn merged_headers(
358 profile: &mermaid_model::models::ProviderProfile,
359 user_cfg: Option<&mermaid_domain::UserProviderConfig>,
360) -> std::collections::HashMap<String, String> {
361 let mut headers: std::collections::HashMap<String, String> = profile
362 .extra_headers
363 .iter()
364 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
365 .collect();
366 if let Some(cfg) = user_cfg {
367 for (k, v) in &cfg.extra_headers {
368 headers.insert(k.clone(), v.clone());
369 }
370 for (header, env_var) in &cfg.env_headers {
371 if let Ok(val) = std::env::var(env_var) {
372 headers.insert(header.clone(), val);
373 }
374 }
375 }
376 headers
377}
378
379use super::model::{
380 AnthropicProvider, GeminiProvider, MetaProvider, ModelProvider, OllamaProvider,
381 OpenAICompatProvider,
382};
383
384type ProviderCell = Arc<tokio::sync::OnceCell<Arc<dyn ModelProvider>>>;
388
389pub struct ProviderFactory {
393 config: Arc<Config>,
394 cache: Mutex<std::collections::HashMap<String, ProviderCell>>,
397}
398
399impl ProviderFactory {
400 #[must_use]
401 pub fn new(config: Config) -> Self {
402 Self {
403 config: Arc::new(config),
404 cache: Mutex::new(std::collections::HashMap::new()),
405 }
406 }
407
408 pub fn with_seeded_providers(
419 config: Config,
420 seeds: impl IntoIterator<Item = (String, Arc<dyn ModelProvider>)>,
421 ) -> Self {
422 let cache = seeds
423 .into_iter()
424 .map(|(model_id, provider)| {
425 let cell = tokio::sync::OnceCell::new_with(Some(provider));
426 (normalize_cache_key(&model_id), Arc::new(cell))
427 })
428 .collect();
429 Self {
430 config: Arc::new(config),
431 cache: Mutex::new(cache),
432 }
433 }
434
435 pub fn config(&self) -> &Config {
436 &self.config
437 }
438
439 pub async fn resolve(&self, model_id: &str) -> Result<Arc<dyn ModelProvider>> {
450 let key = normalize_cache_key(model_id);
451 let cell = {
455 let mut cache = self.cache.lock().await;
456 Arc::clone(
457 cache
458 .entry(key)
459 .or_insert_with(|| Arc::new(tokio::sync::OnceCell::new())),
460 )
461 };
462 let provider = cell
463 .get_or_try_init(|| async {
464 let p = build_provider(&self.config, model_id).await?;
465 Ok::<Arc<dyn ModelProvider>, ModelError>(Arc::from(p))
466 })
467 .await?;
468 Ok(Arc::clone(provider))
469 }
470}
471
472async fn build_provider(config: &Config, model_id: &str) -> Result<Box<dyn ModelProvider>> {
481 let (provider, model_name) = parse_model_id(model_id);
482 let provider_lc = provider.to_lowercase();
483
484 if provider_lc == "ollama" {
487 let backend = crate::ollama::backend_config(config);
488 let p = OllamaProvider::with_app_config(
489 model_name,
490 Arc::new(backend),
491 Arc::new(config.clone()),
492 )
493 .await?;
494 return Ok(Box::new(p));
495 }
496
497 if !is_known_provider(config, &provider_lc) {
502 return Err(ModelError::InvalidRequest(format!(
503 "Unknown provider '{provider}' (model_id: {model_id})"
504 )));
505 }
506 let endpoint = resolve_provider_endpoint(config, &provider_lc)?;
507 let user_cfg = config.providers.get(&provider_lc);
508
509 if provider_lc == "anthropic" {
511 let p = AnthropicProvider::new(
512 endpoint.require_key(&provider_lc)?,
513 model_name.to_string(),
514 endpoint.base_url,
515 )?;
516 return Ok(Box::new(p));
517 }
518
519 if provider_lc == "gemini" {
521 let p = GeminiProvider::new(
522 endpoint.require_key(&provider_lc)?,
523 model_name.to_string(),
524 endpoint.base_url,
525 )?;
526 return Ok(Box::new(p));
527 }
528
529 if provider_lc == "meta" {
532 let api_key = endpoint.require_key(&provider_lc)?;
533 let mut extra_headers = std::collections::HashMap::new();
534 if let Some(cfg) = user_cfg {
535 extra_headers.extend(cfg.extra_headers.clone());
536 for (header, env_var) in &cfg.env_headers {
537 if let Ok(value) = std::env::var(env_var) {
538 extra_headers.insert(header.clone(), value);
539 }
540 }
541 }
542 let p = MetaProvider::new(
543 api_key,
544 model_name.to_string(),
545 endpoint.base_url,
546 extra_headers,
547 )?;
548 return Ok(Box::new(p));
549 }
550
551 let profile = match lookup_provider(&provider_lc) {
555 Some(profile) => profile,
556 None => user_cfg
559 .and_then(|cfg| user_profile_to_static(&provider_lc, cfg))
560 .ok_or_else(|| {
561 ModelError::InvalidRequest(format!(
562 "Unknown provider '{provider}' (model_id: {model_id})"
563 ))
564 })?,
565 };
566 let extra_headers = merged_headers(profile, user_cfg);
567 let p = OpenAICompatProvider::new(
568 profile,
569 endpoint.base_url,
570 endpoint.api_key,
571 model_name.to_string(),
572 extra_headers,
573 )?;
574 Ok(Box::new(p))
575}
576
577fn normalize_cache_key(model_id: &str) -> String {
581 let (provider, model) = parse_model_id(model_id);
582 format!("{}/{}", provider.to_lowercase(), model)
583}
584
585fn parse_model_id(model_id: &str) -> (String, &str) {
588 match model_id.split_once('/') {
589 Some((p, m)) => (p.to_string(), m),
590 None => ("ollama".to_string(), model_id),
591 }
592}
593
594#[must_use]
606pub fn model_provider_resolves(config: &Config, model_id: &str) -> bool {
607 let (provider, _) = parse_model_id(model_id);
608 provider.eq_ignore_ascii_case("ollama") || resolve_provider_endpoint(config, &provider).is_ok()
609}
610
611static PROFILE_CACHE: std::sync::LazyLock<
615 std::sync::Mutex<
616 std::collections::HashMap<String, &'static mermaid_model::models::ProviderProfile>,
617 >,
618> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
619
620fn user_profile_to_static(
634 name: &str,
635 user_cfg: &mermaid_domain::UserProviderConfig,
636) -> Option<&'static mermaid_model::models::ProviderProfile> {
637 use mermaid_model::models::{ProviderProfile, ReasoningExtraction, ReasoningStrategy};
638
639 let compat = user_cfg.compat.as_deref().unwrap_or("openai");
640 let base_url = user_cfg.base_url.clone().unwrap_or_default();
641 let api_key_env = user_cfg.api_key_env.clone().unwrap_or_default();
642
643 let cache_key = format!("{name}\u{0}{base_url}\u{0}{api_key_env}\u{0}{compat}");
646
647 let mut cache = PROFILE_CACHE
648 .lock()
649 .unwrap_or_else(|poisoned| poisoned.into_inner());
650 if let Some(profile) = cache.get(cache_key.as_str()) {
651 return Some(*profile);
654 }
655
656 let strategy = match compat {
657 "openai" => ReasoningStrategy::None,
658 "openai-effort" => ReasoningStrategy::Effort,
659 "openrouter" => ReasoningStrategy::OpenRouterShape,
660 _ => ReasoningStrategy::None,
661 };
662
663 let profile = Box::new(ProviderProfile {
664 name: Box::leak(name.to_string().into_boxed_str()),
665 base_url: Box::leak(base_url.into_boxed_str()),
666 api_key_env: Box::leak(api_key_env.into_boxed_str()),
667 key_hint: None,
668 extra_headers: &[],
669 reasoning_strategy: strategy,
670 reasoning_extraction: ReasoningExtraction::None,
671 max_tokens_param: mermaid_model::models::MaxTokensParam::MaxTokens,
672 disable_parallel_tool_calls_for: &[],
673 });
674 let leaked: &'static ProviderProfile = Box::leak(profile);
675 cache.insert(cache_key, leaked);
676 Some(leaked)
677}
678
679fn cloudflare_base_url(account_id: &str) -> String {
684 format!(
685 "https://api.cloudflare.com/client/v4/accounts/{}/ai/v1",
686 account_id.trim()
687 )
688}
689
690pub(crate) fn discovery_base_url(
698 profile: &mermaid_model::models::ProviderProfile,
699 override_url: Option<String>,
700) -> Option<String> {
701 if override_url.is_some() {
702 return override_url;
703 }
704 if profile.name == "cloudflare" {
705 return require_cloudflare_account_id()
706 .ok()
707 .map(|id| cloudflare_base_url(&id));
708 }
709 Some(profile.base_url.to_string())
710}
711
712fn require_cloudflare_account_id() -> Result<String> {
717 resolve_api_key("CLOUDFLARE_ACCOUNT_ID", None)
718 .map(|s| s.trim().to_string())
719 .filter(|s| !s.is_empty())
720 .ok_or_else(|| {
721 ModelError::Authentication(
722 "cloudflare requires env var CLOUDFLARE_ACCOUNT_ID (your Cloudflare account id) — \
723 find it on your Cloudflare dashboard, or set [providers.cloudflare].base_url"
724 .to_string(),
725 )
726 })
727}
728
729fn validate_provider_base_url(url: &str) -> Result<()> {
735 let parsed = reqwest::Url::parse(url).map_err(|e| {
736 ModelError::InvalidRequest(format!("invalid provider base_url '{url}': {e}"))
737 })?;
738 match parsed.scheme() {
739 "https" => Ok(()),
740 "http"
745 if mermaid_model::utils::classify_host(parsed.host_str().unwrap_or_default())
746 .is_loopback() =>
747 {
748 Ok(())
749 },
750 "http" => Err(ModelError::InvalidRequest(format!(
751 "provider base_url '{url}' uses http:// to a non-loopback host — refusing to send the \
752 API key in cleartext. Use https, or http://localhost for a local server."
753 ))),
754 other => Err(ModelError::InvalidRequest(format!(
755 "provider base_url '{url}' has unsupported scheme '{other}' (use http or https)"
756 ))),
757 }
758}
759
760fn resolve_overridable_base_url(
776 provider: &str,
777 override_url: Option<String>,
778 default_url: &str,
779) -> Result<String> {
780 match override_url {
781 Some(url) => {
782 validate_provider_base_url(&url)?;
783 warn_overridden_provider_host(provider, &url);
784 Ok(url)
785 },
786 None => Ok(default_url.to_string()),
787 }
788}
789
790static WARNED_OVERRIDE_HOSTS: std::sync::LazyLock<
794 std::sync::Mutex<std::collections::HashSet<String>>,
795> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
796
797fn warn_overridden_provider_host(provider: &str, base_url: &str) {
801 let host = provider_host(base_url);
802 if should_warn_once(&format!("{provider}@{host}")) {
803 tracing::warn!(
804 "built-in provider '{}' base_url overridden in config: the {} API key will be sent to \
805 host '{}' instead of the trusted default endpoint",
806 provider,
807 provider,
808 host
809 );
810 }
811}
812
813fn provider_host(base_url: &str) -> String {
816 reqwest::Url::parse(base_url)
817 .ok()
818 .and_then(|u| u.host_str().map(str::to_string))
819 .unwrap_or_else(|| "<unknown>".to_string())
820}
821
822fn should_warn_once(key: &str) -> bool {
825 let mut warned = WARNED_OVERRIDE_HOSTS
826 .lock()
827 .unwrap_or_else(|poisoned| poisoned.into_inner());
828 warned.insert(key.to_string())
829}
830
831#[cfg(test)]
832mod tests {
833 use super::*;
834
835 #[test]
836 fn base_url_is_local_classifies_hosts() {
837 assert!(base_url_is_local("http://127.0.0.1:8000/v1"));
838 assert!(base_url_is_local("http://localhost:1234/v1"));
839 assert!(base_url_is_local("http://192.168.1.5:8000/v1"));
840 assert!(!base_url_is_local("https://api.openai.com/v1"));
841 assert!(!base_url_is_local("not a url"));
842 }
843
844 #[test]
845 fn merged_headers_keeps_static_profile_headers_and_user_overrides() {
846 let profile = mermaid_model::models::lookup_provider("openrouter").unwrap();
847 let base = merged_headers(profile, None);
849 assert_eq!(
850 base.get("X-OpenRouter-Title").map(String::as_str),
851 Some("Mermaid")
852 );
853 assert!(base.contains_key("HTTP-Referer"));
854 let mut cfg = mermaid_domain::UserProviderConfig::default();
856 cfg.extra_headers.insert("X-Custom".into(), "v".into());
857 cfg.extra_headers
858 .insert("X-OpenRouter-Title".into(), "Override".into());
859 let merged = merged_headers(profile, Some(&cfg));
860 assert_eq!(merged.get("X-Custom").map(String::as_str), Some("v"));
861 assert_eq!(
862 merged.get("X-OpenRouter-Title").map(String::as_str),
863 Some("Override")
864 );
865 assert!(merged.contains_key("HTTP-Referer"));
866 }
867
868 #[test]
869 fn merged_headers_resolves_env_headers_and_skips_missing() {
870 let profile = mermaid_model::models::lookup_provider("openai").unwrap();
871 let mut cfg = mermaid_domain::UserProviderConfig::default();
872 cfg.env_headers
873 .insert("X-Gateway-Token".into(), "MERMAID_TEST_GW_TOKEN".into());
874 temp_env::with_var("MERMAID_TEST_GW_TOKEN", Some("secret123"), || {
875 let merged = merged_headers(profile, Some(&cfg));
876 assert_eq!(
877 merged.get("X-Gateway-Token").map(String::as_str),
878 Some("secret123")
879 );
880 });
881 temp_env::with_var("MERMAID_TEST_GW_TOKEN", None::<&str>, || {
882 assert!(!merged_headers(profile, Some(&cfg)).contains_key("X-Gateway-Token"));
883 });
884 }
885
886 #[test]
887 fn base_url_requires_https_for_remote_hosts() {
888 assert!(validate_provider_base_url("http://api.example.com/v1").is_err());
890 assert!(validate_provider_base_url("ftp://example.com").is_err());
891 assert!(validate_provider_base_url("https://api.example.com/v1").is_ok());
893 assert!(validate_provider_base_url("http://localhost:11434/v1").is_ok());
894 assert!(validate_provider_base_url("http://127.0.0.1:8000").is_ok());
895 assert!(validate_provider_base_url("http://[::1]:8000").is_ok());
896 assert!(validate_provider_base_url("http://192.168.1.5:8080").is_err());
899 assert!(validate_provider_base_url("http://169.254.169.254").is_err());
900 }
901
902 #[test]
903 fn cloudflare_base_url_synthesizes_account_scoped_endpoint() {
904 assert_eq!(
905 cloudflare_base_url("acct123"),
906 "https://api.cloudflare.com/client/v4/accounts/acct123/ai/v1"
907 );
908 assert_eq!(
910 cloudflare_base_url(" acct123\n"),
911 "https://api.cloudflare.com/client/v4/accounts/acct123/ai/v1"
912 );
913 }
914
915 #[test]
916 fn cloudflare_account_id_required_and_non_blank() {
917 temp_env::with_var("CLOUDFLARE_ACCOUNT_ID", None::<&str>, || {
919 let err = require_cloudflare_account_id().expect_err("must error when unset");
920 assert!(format!("{err}").contains("CLOUDFLARE_ACCOUNT_ID"));
921 });
922 temp_env::with_var("CLOUDFLARE_ACCOUNT_ID", Some(" "), || {
924 assert!(require_cloudflare_account_id().is_err());
925 });
926 temp_env::with_var("CLOUDFLARE_ACCOUNT_ID", Some(" acct123 "), || {
928 assert_eq!(require_cloudflare_account_id().unwrap(), "acct123");
929 });
930 }
931
932 #[test]
933 fn discovery_base_url_resolves_per_provider() {
934 let cf = lookup_provider("cloudflare").expect("cloudflare is in the registry");
935 let openai = lookup_provider("openai").expect("openai is in the registry");
936 assert_eq!(
938 discovery_base_url(cf, Some("https://gw.example/v1".to_string())),
939 Some("https://gw.example/v1".to_string())
940 );
941 assert_eq!(
943 discovery_base_url(openai, None),
944 Some(openai.base_url.to_string())
945 );
946 temp_env::with_var("CLOUDFLARE_ACCOUNT_ID", Some("acct123"), || {
948 assert_eq!(
949 discovery_base_url(cf, None),
950 Some("https://api.cloudflare.com/client/v4/accounts/acct123/ai/v1".to_string())
951 );
952 });
953 temp_env::with_var("CLOUDFLARE_ACCOUNT_ID", None::<&str>, || {
955 assert_eq!(discovery_base_url(cf, None), None);
956 });
957 }
958
959 #[tokio::test]
960 async fn cloudflare_missing_both_env_vars_is_one_combined_error() {
961 temp_env::async_with_vars(
962 [
963 ("CLOUDFLARE_ACCOUNT_ID", None::<&str>),
964 ("CLOUDFLARE_API_TOKEN", None),
965 ],
966 async {
967 let f = ProviderFactory::new(Config::default());
968 let err = match f.resolve("cloudflare/@cf/zai-org/glm-5.2").await {
969 Ok(_) => panic!("must fail with neither env var set"),
970 Err(e) => e,
971 };
972 let msg = format!("{err}");
973 assert!(
974 msg.contains("CLOUDFLARE_API_TOKEN") && msg.contains("CLOUDFLARE_ACCOUNT_ID"),
975 "one error must name both missing vars, got: {msg}"
976 );
977 },
978 )
979 .await;
980 }
981
982 use std::sync::atomic::{AtomicUsize, Ordering};
983
984 fn unique_env(prefix: &str) -> String {
985 static N: AtomicUsize = AtomicUsize::new(0);
986 format!(
987 "{}_{}_{}",
988 prefix,
989 std::process::id(),
990 N.fetch_add(1, Ordering::SeqCst)
991 )
992 }
993
994 #[test]
995 fn parse_bare_name_defaults_to_ollama() {
996 let (p, m) = parse_model_id("qwen3-coder:30b");
997 assert_eq!(p, "ollama");
998 assert_eq!(m, "qwen3-coder:30b");
999 }
1000
1001 #[test]
1002 fn parse_prefixed() {
1003 let (p, m) = parse_model_id("anthropic/claude-opus-4-7");
1004 assert_eq!(p, "anthropic");
1005 assert_eq!(m, "claude-opus-4-7");
1006 }
1007
1008 #[tokio::test]
1009 async fn meta_requires_its_documented_api_key_env() {
1010 temp_env::async_with_vars(
1011 [(
1012 crate::providers::model::meta::DEFAULT_API_KEY_ENV,
1013 None::<&str>,
1014 )],
1015 async {
1016 let factory = ProviderFactory::new(Config::default());
1017 let error = match factory.resolve("meta/muse-spark-1.1").await {
1018 Ok(_) => panic!("Meta must require an API key"),
1019 Err(error) => error,
1020 };
1021 assert!(
1022 error
1023 .to_string()
1024 .contains(crate::providers::model::meta::DEFAULT_API_KEY_ENV)
1025 );
1026 },
1027 )
1028 .await;
1029 }
1030
1031 #[tokio::test]
1032 async fn meta_routes_to_responses_provider_with_muse_capabilities() {
1033 temp_env::async_with_vars(
1034 [(
1035 crate::providers::model::meta::DEFAULT_API_KEY_ENV,
1036 Some("test-key"),
1037 )],
1038 async {
1039 let factory = ProviderFactory::new(Config::default());
1040 let provider = factory.resolve("meta/muse-spark-1.1").await.unwrap();
1041 let capabilities = provider.capabilities();
1042 assert!(capabilities.supports_tools);
1043 assert!(capabilities.supports_vision);
1044 assert!(capabilities.emits_provider_continuation);
1045 assert_eq!(
1046 capabilities.max_context_tokens,
1047 Some(mermaid_model::constants::META_MUSE_SPARK_CONTEXT_WINDOW)
1048 );
1049 assert_eq!(
1050 capabilities.max_output_tokens,
1051 Some(mermaid_model::constants::META_MUSE_SPARK_MAX_OUTPUT_TOKENS)
1052 );
1053 },
1054 )
1055 .await;
1056 }
1057
1058 #[test]
1059 fn gemini_key_resolution_accepts_legacy_fallback() {
1060 let primary = unique_env("MERMAID_FACTORY_GEMINI_PRIMARY");
1061 let legacy = unique_env("MERMAID_FACTORY_GEMINI_LEGACY");
1062 temp_env::with_vars(
1063 [(primary.as_str(), None), (legacy.as_str(), Some("legacy"))],
1064 || {
1065 let resolved = require_key_with_fallback("gemini", &primary, &legacy)
1066 .expect("legacy fallback should resolve");
1067 assert_eq!(resolved, "legacy");
1068 },
1069 );
1070 }
1071
1072 #[test]
1073 fn gemini_key_resolution_prefers_google_primary() {
1074 let primary = unique_env("MERMAID_FACTORY_GEMINI_PRIMARY2");
1075 let legacy = unique_env("MERMAID_FACTORY_GEMINI_LEGACY2");
1076 temp_env::with_vars(
1077 [
1078 (primary.as_str(), Some("google")),
1079 (legacy.as_str(), Some("legacy")),
1080 ],
1081 || {
1082 let resolved = require_key_with_fallback("gemini", &primary, &legacy)
1083 .expect("primary should resolve");
1084 assert_eq!(resolved, "google");
1085 },
1086 );
1087 }
1088
1089 #[tokio::test]
1090 async fn factory_reports_unknown_provider_clearly() {
1091 let cfg = Config::default();
1092 let f = ProviderFactory::new(cfg);
1093 match f.resolve("totally-made-up/model").await {
1094 Ok(_) => panic!("expected error"),
1095 Err(e) => {
1096 let msg = format!("{e}");
1097 assert!(
1098 msg.contains("totally-made-up") || msg.contains("Unknown provider"),
1099 "error message: {msg}"
1100 );
1101 },
1102 }
1103 }
1104
1105 #[test]
1106 fn normalize_cache_key_lowercases_provider_only() {
1107 assert_eq!(
1109 normalize_cache_key("Anthropic/Claude-X"),
1110 "anthropic/Claude-X"
1111 );
1112 assert_eq!(
1113 normalize_cache_key("anthropic/Claude-X"),
1114 "anthropic/Claude-X"
1115 );
1116 assert_eq!(normalize_cache_key("qwen3:30b"), "ollama/qwen3:30b");
1118 }
1119
1120 #[tokio::test]
1121 async fn resolve_is_single_flight_and_cached() {
1122 let f = ProviderFactory::new(Config::default());
1126 let (a, b) = tokio::join!(
1127 f.resolve("ollama/test-model"),
1128 f.resolve("Ollama/test-model"),
1129 );
1130 let a = a.expect("resolve a");
1131 let b = b.expect("resolve b");
1132 assert!(
1133 Arc::ptr_eq(&a, &b),
1134 "expected one cached provider for casing variants + concurrent resolve"
1135 );
1136 }
1137
1138 #[test]
1142 fn builtin_base_url_override_validated_and_resolved() {
1143 assert_eq!(
1145 resolve_overridable_base_url("anthropic", None, "https://api.anthropic.com/v1")
1146 .unwrap(),
1147 "https://api.anthropic.com/v1"
1148 );
1149 assert_eq!(
1151 resolve_overridable_base_url(
1152 "anthropic",
1153 Some("https://proxy.internal/v1".to_string()),
1154 "https://api.anthropic.com/v1",
1155 )
1156 .unwrap(),
1157 "https://proxy.internal/v1"
1158 );
1159 assert!(
1162 resolve_overridable_base_url(
1163 "anthropic",
1164 Some("http://attacker.example/v1".to_string()),
1165 "https://api.anthropic.com/v1",
1166 )
1167 .is_err()
1168 );
1169 assert!(
1171 resolve_overridable_base_url(
1172 "openai",
1173 Some("http://localhost:8080/v1".to_string()),
1174 "https://api.openai.com/v1",
1175 )
1176 .is_ok()
1177 );
1178 }
1179
1180 #[test]
1181 fn provider_host_extracts_host_or_unknown() {
1182 assert_eq!(
1183 provider_host("https://attacker.example/v1"),
1184 "attacker.example"
1185 );
1186 assert_eq!(provider_host("http://127.0.0.1:8080"), "127.0.0.1");
1187 assert_eq!(provider_host("not a url"), "<unknown>");
1188 }
1189
1190 #[test]
1191 fn override_host_warning_is_deduped() {
1192 let key = unique_env("MERMAID_FACTORY_WARN_KEY");
1196 assert!(should_warn_once(&key), "first warn for a key must fire");
1197 assert!(
1198 !should_warn_once(&key),
1199 "subsequent warns for the same key must be suppressed"
1200 );
1201 }
1202
1203 #[test]
1207 fn custom_profile_is_memoized_per_key() {
1208 use mermaid_domain::UserProviderConfig;
1209 let cfg = UserProviderConfig {
1210 base_url: Some("https://api.custom.test/v1".to_string()),
1211 api_key_env: Some("CUSTOM_KEY".to_string()),
1212 compat: Some("openai".to_string()),
1213 ..Default::default()
1214 };
1215 let a = user_profile_to_static("mermaid_test_customx", &cfg).unwrap();
1218 let b = user_profile_to_static("mermaid_test_customx", &cfg).unwrap();
1219 assert!(
1220 std::ptr::eq(a, b),
1221 "identical custom-provider inputs must reuse one leaked &'static profile"
1222 );
1223 assert_eq!(a.base_url, "https://api.custom.test/v1");
1224 assert_eq!(a.api_key_env, "CUSTOM_KEY");
1225
1226 let cfg2 = UserProviderConfig {
1228 base_url: Some("https://api.custom.test/v2".to_string()),
1229 ..cfg.clone()
1230 };
1231 let c = user_profile_to_static("mermaid_test_customx", &cfg2).unwrap();
1232 assert!(
1233 !std::ptr::eq(a, c),
1234 "a different base_url must leak a distinct profile"
1235 );
1236 }
1237}