Skip to main content

mermaid_cli/providers/
factory.rs

1//! Runtime provider construction.
2//!
3//! `ProviderFactory` turns `(Config, model_id)` into the right
4//! `Arc<dyn ModelProvider>`. The effect runner holds one of these
5//! and asks it to build a provider the first time a new model is
6//! referenced; subsequent lookups hit the cache.
7
8use 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
21/// Resolve an API key (env first, then the OS keyring via
22/// `mermaid login <provider>`) or return a clear `ModelError`. A
23/// per-provider `override_env` is authoritative — no keyring fallback.
24fn 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
46/// Which env var supplied a key, or `None` when the keyring did.
47///
48/// Mirrors [`resolve_provider_key`]'s precedence exactly — it must, because the
49/// two are asked the same question by different callers and a disagreement
50/// would show up as `status` naming a var the factory never read.
51fn key_env_source(default_env: &str, override_env: Option<&str>) -> Option<String> {
52    // Only ever called after a key resolved, so "not in this env var" means the
53    // keyring supplied it. An explicit override is authoritative and never
54    // reaches the keyring, so for that case this always matches.
55    let env = override_env.unwrap_or(default_env);
56    resolve_api_key(env, None).map(|_| env.to_string())
57}
58
59/// [`key_env_source`] for the one legacy-fallback provider (Gemini).
60fn 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/// What `build_provider` resolves before it constructs anything: the endpoint it
71/// would dial and the credential it would send.
72///
73/// This exists so that "which providers can this machine actually talk to" has
74/// exactly ONE definition. It used to have four — `build_provider`'s dispatch
75/// chain plus three hand-rolled walks in `doctor`, `status`, and startup model
76/// resolution — and all four disagreed about custom providers. Everything that
77/// wants the answer now reads it through `providers::discovery`, which calls
78/// this and treats `Err` as "not usable".
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub(crate) struct ProviderEndpoint {
81    /// The base URL the adapter would be built with, overrides applied.
82    pub(crate) base_url: String,
83    /// `None` only where a keyless endpoint is legal: a loopback/LAN host
84    /// running an unauthenticated OpenAI-compatible server.
85    pub(crate) api_key: Option<String>,
86    /// Env var the key came from. `None` means the keyring supplied it, or the
87    /// endpoint is keyless.
88    pub(crate) key_env: Option<String>,
89}
90
91impl ProviderEndpoint {
92    /// The key, for the bespoke adapters that cannot run keyless.
93    ///
94    /// Total in practice: [`resolve_provider_endpoint`] has already failed with
95    /// the provider's own actionable message if a required key was missing.
96    /// This exists so those branches need no `expect`.
97    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
104/// True when Mermaid knows how to build `provider` at all — before asking
105/// whether this machine has the credentials for it.
106fn 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/// Resolve `provider`'s endpoint and credential exactly as `build_provider`
113/// would, without constructing an adapter or touching the network.
114///
115/// `Err` is the honest answer to "can this machine use this provider": a missing
116/// key, a Cloudflare account id that was never set, a custom provider with no
117/// `base_url`. The error text is the same one the user would hit on a real
118/// request, so `status` and `doctor` can print it verbatim.
119#[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    // Bespoke adapters (Anthropic, Gemini, Meta). All three are public
133    // endpoints with no keyless deployment story, so the key is required.
134    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            // The legacy var is consulted only when the user has NOT pointed at
152            // a specific one (matches `require_key_with_fallback`).
153            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    // Cloudflare Workers AI — OpenAI-compatible, but the endpoint URL embeds a
170    // per-account id, so the base_url is synthesized at runtime from
171    // CLOUDFLARE_ACCOUNT_ID (or a full [providers.cloudflare].base_url override,
172    // e.g. AI Gateway). Must precede the generic registry branch, which would
173    // otherwise route it through the placeholder profile.base_url.
174    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            // Override present (AI Gateway / proxy): validate + warn like any
179            // built-in override. The account id isn't needed in this case.
180            Some(url) => {
181                validate_provider_base_url(&url)?;
182                warn_overridden_provider_host("cloudflare", &url);
183                url
184            },
185            // Standard path: synthesize the account-scoped endpoint from env. A
186            // fresh setup missing the token as well gets one error naming both
187            // vars, not two fix-and-retry round-trips.
188            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    // The OpenAI-compatible registry.
223    if let Some(profile) = lookup_provider(&provider_lc) {
224        let base_url = resolve_overridable_base_url(&provider_lc, override_url, profile.base_url)?;
225        // Keyless when the key is unset and the endpoint is local (a registry
226        // provider pointed at a loopback/LAN base_url); otherwise a clear,
227        // hint-carrying auth error.
228        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    // User-custom: no registry entry, but the user has [providers.<name>] in
247    // config. `base_url` is mandatory here — there is no default to fall back on.
248    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        // api_key_env is optional: a local (loopback/LAN) endpoint may run
255        // keyless. When a key IS used, harden the URL so it can't be sent in
256        // cleartext; a keyless endpoint must be local (no secret to leak).
257        // For a CUSTOM provider its api_key_env is the default (not an override
258        // of a registry default), so the keyring may fill the gap; a stored key
259        // alone also works with no api_key_env at all.
260        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
296/// Resolve an API key, or `None` when the key is absent AND the endpoint is a
297/// loopback/LAN host — local OpenAI-compatible servers (llama.cpp, vLLM, LM
298/// Studio) legitimately run without auth. A missing key for a public endpoint
299/// is a hard error carrying the provider's `hint` so the message is actionable.
300fn 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
322/// True when `base_url`'s host is a loopback or private/LAN address — where a
323/// local model server legitimately runs without auth.
324fn 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
334/// The header set sent on every request: the profile's static `extra_headers`
335/// first, then user `extra_headers` overrides, then env-sourced `env_headers`
336/// (header name -> env var, resolved now since env is process-static). This
337/// restores the profile's static headers (e.g. OpenRouter analytics) that the
338/// prior registry path dropped.
339fn 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
366/// A lazily-built, shared provider. `OnceCell` gives single-flight construction
367/// (#84); the outer `Arc` lets `resolve` clone the cell out from under the cache
368/// lock and initialize it without holding the lock across the build.
369type ProviderCell = Arc<tokio::sync::OnceCell<Arc<dyn ModelProvider>>>;
370
371/// Per-process provider cache. Providers are expensive to construct
372/// (HTTP client, connection pool, capability lookup) so the effect
373/// runner asks for them lazily and reuses across turns.
374pub struct ProviderFactory {
375    config: Arc<Config>,
376    /// Per-key cache of built providers, keyed by normalized model id (#83). The
377    /// `Mutex` is only held to get-or-insert a cell, never across the build.
378    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    /// A factory with providers already resolved for the given model ids.
391    ///
392    /// `resolve` hands these back instead of building anything, so a caller
393    /// can substitute a provider it constructed itself. This is how tests
394    /// drive the real agent loop — reducer, effect runner, tool registry,
395    /// subagents — against a scripted model instead of a network: the seam
396    /// is the provider, so everything below it is the production path.
397    ///
398    /// Seeding is additive. A model id that was not seeded still builds
399    /// normally, which lets a test stub one model and leave the rest alone.
400    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    /// Resolve (or lazily construct) a provider for the given model
422    /// ID. Hits the cache on the second and subsequent calls for the
423    /// same ID.
424    ///
425    /// # Errors
426    ///
427    /// Whatever building the provider fails with: an unknown provider prefix,
428    /// a provider whose API key does not resolve, or the adapter's own client
429    /// build. A failed build is deliberately not cached, so a transient error
430    /// is retried on the next call rather than pinned for the session.
431    pub async fn resolve(&self, model_id: &str) -> Result<Arc<dyn ModelProvider>> {
432        let key = normalize_cache_key(model_id);
433        // Get-or-insert the per-key cell under a brief lock, then initialize it
434        // exactly once outside the lock (#84). A failed build isn't cached, so a
435        // transient error can be retried on the next call.
436        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
454/// Build a provider for the given `model_id`:
455///   1. `ollama/<model>` → `OllamaProvider`.
456///   2. `anthropic/<model>` → `AnthropicProvider`.
457///   3. `gemini/<model>` → `GeminiProvider`.
458///   4. `meta/<model>` → `MetaProvider` using the Responses API.
459///   5. Other builtin providers (openai, openrouter, groq, …) → `OpenAICompatProvider`.
460///   6. User-defined `[providers.<name>]` → custom `OpenAICompatProvider`.
461///   7. Bare model name → `OllamaProvider`.
462async 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    // 1. Ollama (and bare names). F11: pass Arc<Config> so the wrapper
467    // can forward Ollama hardware options to the adapter.
468    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    // Everything past Ollama is a remote provider, and ONE function decides
480    // where it lives and what credential it takes — the same one `discovery`
481    // asks on behalf of `status`, `doctor`, `list`, and `/model`. The branches
482    // below only turn that answer into the right adapter.
483    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    // 2. Anthropic — bespoke API shape.
492    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    // 3. Gemini — GCP AI Studio shape.
502    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    // 4. Meta — Responses is required for encrypted reasoning continuity across
512    // Mermaid's tool loop even though Meta also exposes Chat Completions.
513    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    // 5 + 6. Everything else is OpenAI-compatible: the built-in registry
534    // (Cloudflare's account-scoped URL included — `resolve_provider_endpoint`
535    // synthesized it) or a user-defined `[providers.<name>]`.
536    let profile = match lookup_provider(&provider_lc) {
537        Some(profile) => profile,
538        // `is_known_provider` already proved the config entry exists, and
539        // `user_profile_to_static` is total, so this cannot fall through.
540        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
559/// Cache key for a model id: the provider segment lowercased (matching
560/// `build_provider`'s own normalization) so `Anthropic/x` and `anthropic/x`
561/// resolve to one cached provider instead of building two identical ones (#83).
562fn normalize_cache_key(model_id: &str) -> String {
563    let (provider, model) = parse_model_id(model_id);
564    format!("{}/{}", provider.to_lowercase(), model)
565}
566
567/// Parse `provider/model` → `(provider, model)`. Bare strings are
568/// Ollama by convention.
569fn 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
576pub(crate) fn ollama_backend_config(config: &Config) -> BackendConfig {
577    BackendConfig {
578        // Scheme-less: `normalize_url` in the adapter picks http (loopback/LAN)
579        // vs https (public) by host class (#86).
580        ollama_url: format!("{}:{}", config.ollama.host, config.ollama.port),
581        max_idle_per_host: 10,
582        timeout_secs: 10,
583        ollama_autostart: config.ollama.auto_start,
584    }
585}
586
587/// Process-wide memo of leaked custom-provider profiles, keyed by the
588/// profile-determining inputs (see [`user_profile_to_static`]). Guarantees each
589/// distinct custom provider leaks its `&'static ProviderProfile` at most once.
590static PROFILE_CACHE: std::sync::LazyLock<
591    std::sync::Mutex<
592        std::collections::HashMap<String, &'static mermaid_model::models::ProviderProfile>,
593    >,
594> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
595
596/// Convert a user-defined `[providers.<name>]` entry into a `&'static
597/// ProviderProfile`. `ProviderProfile`'s fields are `&'static` (tied to the
598/// registry constants), so a custom provider needs a leaked, owned copy to
599/// participate without redesigning the profile type.
600///
601/// The leak is memoized (F67). `build_provider` runs once per distinct *model
602/// id*, so without a cache this leaked a fresh profile for every custom
603/// `provider/model` pair — a permanent, per-distinct-model_id growth, not the
604/// "0-3" the old comment claimed. The profile's content depends only on
605/// (provider name, `base_url`, `api_key_env`, compat) and NOT on the model, so we
606/// key the cache on exactly those and leak at most once per distinct
607/// combination; repeated resolves (including different models of the same custom
608/// provider) reuse the same `&'static`.
609fn user_profile_to_static(
610    name: &str,
611    user_cfg: &mermaid_domain::UserProviderConfig,
612) -> Option<&'static mermaid_model::models::ProviderProfile> {
613    use mermaid_model::models::{ProviderProfile, ReasoningExtraction, ReasoningStrategy};
614
615    let compat = user_cfg.compat.as_deref().unwrap_or("openai");
616    let base_url = user_cfg.base_url.clone().unwrap_or_default();
617    let api_key_env = user_cfg.api_key_env.clone().unwrap_or_default();
618
619    // Stable key over exactly the fields baked into the profile below. NUL
620    // separates components so distinct inputs can't collide into one key.
621    let cache_key = format!("{name}\u{0}{base_url}\u{0}{api_key_env}\u{0}{compat}");
622
623    let mut cache = PROFILE_CACHE
624        .lock()
625        .unwrap_or_else(|poisoned| poisoned.into_inner());
626    if let Some(profile) = cache.get(cache_key.as_str()) {
627        // `&'static ProviderProfile` is Copy, so this hands back the same leaked
628        // allocation as the first resolve — no new leak.
629        return Some(*profile);
630    }
631
632    let strategy = match compat {
633        "openai" => ReasoningStrategy::None,
634        "openai-effort" => ReasoningStrategy::Effort,
635        "openrouter" => ReasoningStrategy::OpenRouterShape,
636        _ => ReasoningStrategy::None,
637    };
638
639    let profile = Box::new(ProviderProfile {
640        name: Box::leak(name.to_string().into_boxed_str()),
641        base_url: Box::leak(base_url.into_boxed_str()),
642        api_key_env: Box::leak(api_key_env.into_boxed_str()),
643        key_hint: None,
644        extra_headers: &[],
645        reasoning_strategy: strategy,
646        reasoning_extraction: ReasoningExtraction::None,
647        max_tokens_param: mermaid_model::models::MaxTokensParam::MaxTokens,
648        disable_parallel_tool_calls_for: &[],
649    });
650    let leaked: &'static ProviderProfile = Box::leak(profile);
651    cache.insert(cache_key, leaked);
652    Some(leaked)
653}
654
655/// Build Cloudflare Workers AI's account-scoped OpenAI-compatible base URL. The
656/// adapter appends `/chat/completions`, so this ends at `/ai/v1`. Kept pure and
657/// separate so it's directly unit-testable (a built provider exposes no `base_url`
658/// getter).
659fn cloudflare_base_url(account_id: &str) -> String {
660    format!(
661        "https://api.cloudflare.com/client/v4/accounts/{}/ai/v1",
662        account_id.trim()
663    )
664}
665
666/// Best-effort `base_url` for *discovery* surfaces (`doctor`'s provider list,
667/// the `/models` probe) — chat requests never use this; `build_provider` has its
668/// own resolution. A user override wins for any provider; cloudflare synthesizes
669/// its account-scoped URL from `CLOUDFLARE_ACCOUNT_ID` and yields `None` when
670/// the var is unset (there is no real endpoint then — probing the registry
671/// placeholder would just guarantee a 404); everything else uses the profile's
672/// static default.
673pub(crate) fn discovery_base_url(
674    profile: &mermaid_model::models::ProviderProfile,
675    override_url: Option<String>,
676) -> Option<String> {
677    if override_url.is_some() {
678        return override_url;
679    }
680    if profile.name == "cloudflare" {
681        return require_cloudflare_account_id()
682            .ok()
683            .map(|id| cloudflare_base_url(&id));
684    }
685    Some(profile.base_url.to_string())
686}
687
688/// Resolve the Cloudflare account id from `CLOUDFLARE_ACCOUNT_ID` (trimmed,
689/// non-empty), or a clear actionable error. It isn't a secret, but it's required
690/// to construct the account-scoped Workers AI endpoint; `resolve_api_key` is
691/// reused only for its empty→None handling.
692fn require_cloudflare_account_id() -> Result<String> {
693    resolve_api_key("CLOUDFLARE_ACCOUNT_ID", None)
694        .map(|s| s.trim().to_string())
695        .filter(|s| !s.is_empty())
696        .ok_or_else(|| {
697            ModelError::Authentication(
698                "cloudflare requires env var CLOUDFLARE_ACCOUNT_ID (your Cloudflare account id) — \
699                 find it on your Cloudflare dashboard, or set [providers.cloudflare].base_url"
700                    .to_string(),
701            )
702        })
703}
704
705/// Validate a provider `base_url` before it's handed an API key. Requires
706/// http/https, and **requires https for any non-loopback, non-private host** —
707/// a typo'd or hostile `http://` endpoint would otherwise receive the bearer
708/// key in cleartext. Plain http stays allowed for loopback / RFC-1918 hosts so
709/// local model servers (Ollama, vLLM) keep working.
710fn validate_provider_base_url(url: &str) -> Result<()> {
711    let parsed = reqwest::Url::parse(url).map_err(|e| {
712        ModelError::InvalidRequest(format!("invalid provider base_url '{url}': {e}"))
713    })?;
714    match parsed.scheme() {
715        "https" => Ok(()),
716        // Plaintext http is only safe to LOOPBACK: a key sent over http to any
717        // other host — including a LAN/private one — crosses the wire in
718        // cleartext. Every caller here is a key-bearing provider; keyless local
719        // Ollama uses a separate path that doesn't validate.
720        "http"
721            if mermaid_model::utils::classify_host(parsed.host_str().unwrap_or_default())
722                .is_loopback() =>
723        {
724            Ok(())
725        },
726        "http" => Err(ModelError::InvalidRequest(format!(
727            "provider base_url '{url}' uses http:// to a non-loopback host — refusing to send the \
728             API key in cleartext. Use https, or http://localhost for a local server."
729        ))),
730        other => Err(ModelError::InvalidRequest(format!(
731            "provider base_url '{url}' has unsupported scheme '{other}' (use http or https)"
732        ))),
733    }
734}
735
736/// Resolve a *built-in* provider's `base_url`, honoring a user override but
737/// hardening it (F66). Built-in providers (anthropic, gemini, and the
738/// registry-backed OpenAI-compatible ones) ship a trusted default endpoint; a
739/// `[providers.<name>] base_url` override redirects that provider's API key to a
740/// host the user chose. The override lives in the user's own config, so we allow
741/// it — but we also:
742///   * validate the scheme via [`validate_provider_base_url`], which already
743///     requires https for any non-loopback host, so an `http://` override can't
744///     leak the key in cleartext (http stays allowed only for
745///     `localhost/127.0.0.1/::1` local dev), and
746///   * emit a one-time warning naming the host the key will be sent to, so a
747///     redirect to an unexpected host (e.g. `https://attacker.example`) is
748///     visible rather than silent.
749///
750/// With no override the trusted default is returned unchanged (no warning).
751fn resolve_overridable_base_url(
752    provider: &str,
753    override_url: Option<String>,
754    default_url: &str,
755) -> Result<String> {
756    match override_url {
757        Some(url) => {
758            validate_provider_base_url(&url)?;
759            warn_overridden_provider_host(provider, &url);
760            Ok(url)
761        },
762        None => Ok(default_url.to_string()),
763    }
764}
765
766/// Hosts already warned about (per provider) for a built-in `base_url` override,
767/// so the F66 warning fires once per process rather than on every `resolve`.
768/// Keyed by `"<provider>@<host>"`.
769static WARNED_OVERRIDE_HOSTS: std::sync::LazyLock<
770    std::sync::Mutex<std::collections::HashSet<String>>,
771> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
772
773/// Emit a one-time `tracing::warn!` (deduped per provider+host) naming the host a
774/// built-in provider's API key will be sent to because its trusted default
775/// `base_url` was overridden in config (F66).
776fn warn_overridden_provider_host(provider: &str, base_url: &str) {
777    let host = provider_host(base_url);
778    if should_warn_once(&format!("{provider}@{host}")) {
779        tracing::warn!(
780            "built-in provider '{}' base_url overridden in config: the {} API key will be sent to \
781             host '{}' instead of the trusted default endpoint",
782            provider,
783            provider,
784            host
785        );
786    }
787}
788
789/// Host portion of a `base_url` for the override warning, or `"<unknown>"` if it
790/// can't be parsed (the caller already validated it, so this is belt-and-braces).
791fn provider_host(base_url: &str) -> String {
792    reqwest::Url::parse(base_url)
793        .ok()
794        .and_then(|u| u.host_str().map(str::to_string))
795        .unwrap_or_else(|| "<unknown>".to_string())
796}
797
798/// Record `key` in the process-wide warned-set, returning `true` the first time
799/// (i.e. "warn now") and `false` thereafter. Poison-tolerant.
800fn should_warn_once(key: &str) -> bool {
801    let mut warned = WARNED_OVERRIDE_HOSTS
802        .lock()
803        .unwrap_or_else(|poisoned| poisoned.into_inner());
804    warned.insert(key.to_string())
805}
806
807#[cfg(test)]
808mod tests {
809    use super::*;
810
811    #[test]
812    fn base_url_is_local_classifies_hosts() {
813        assert!(base_url_is_local("http://127.0.0.1:8000/v1"));
814        assert!(base_url_is_local("http://localhost:1234/v1"));
815        assert!(base_url_is_local("http://192.168.1.5:8000/v1"));
816        assert!(!base_url_is_local("https://api.openai.com/v1"));
817        assert!(!base_url_is_local("not a url"));
818    }
819
820    #[test]
821    fn merged_headers_keeps_static_profile_headers_and_user_overrides() {
822        let profile = mermaid_model::models::lookup_provider("openrouter").unwrap();
823        // No user config: the static profile headers survive (the latent-bug fix).
824        let base = merged_headers(profile, None);
825        assert_eq!(
826            base.get("X-OpenRouter-Title").map(String::as_str),
827            Some("Mermaid")
828        );
829        assert!(base.contains_key("HTTP-Referer"));
830        // User extra_headers merge on top and can override a static one.
831        let mut cfg = mermaid_domain::UserProviderConfig::default();
832        cfg.extra_headers.insert("X-Custom".into(), "v".into());
833        cfg.extra_headers
834            .insert("X-OpenRouter-Title".into(), "Override".into());
835        let merged = merged_headers(profile, Some(&cfg));
836        assert_eq!(merged.get("X-Custom").map(String::as_str), Some("v"));
837        assert_eq!(
838            merged.get("X-OpenRouter-Title").map(String::as_str),
839            Some("Override")
840        );
841        assert!(merged.contains_key("HTTP-Referer"));
842    }
843
844    #[test]
845    fn merged_headers_resolves_env_headers_and_skips_missing() {
846        let profile = mermaid_model::models::lookup_provider("openai").unwrap();
847        let mut cfg = mermaid_domain::UserProviderConfig::default();
848        cfg.env_headers
849            .insert("X-Gateway-Token".into(), "MERMAID_TEST_GW_TOKEN".into());
850        temp_env::with_var("MERMAID_TEST_GW_TOKEN", Some("secret123"), || {
851            let merged = merged_headers(profile, Some(&cfg));
852            assert_eq!(
853                merged.get("X-Gateway-Token").map(String::as_str),
854                Some("secret123")
855            );
856        });
857        temp_env::with_var("MERMAID_TEST_GW_TOKEN", None::<&str>, || {
858            assert!(!merged_headers(profile, Some(&cfg)).contains_key("X-Gateway-Token"));
859        });
860    }
861
862    #[test]
863    fn base_url_requires_https_for_remote_hosts() {
864        // Remote http is refused (would leak the bearer key in cleartext).
865        assert!(validate_provider_base_url("http://api.example.com/v1").is_err());
866        assert!(validate_provider_base_url("ftp://example.com").is_err());
867        // https anywhere and http to LOOPBACK are fine.
868        assert!(validate_provider_base_url("https://api.example.com/v1").is_ok());
869        assert!(validate_provider_base_url("http://localhost:11434/v1").is_ok());
870        assert!(validate_provider_base_url("http://127.0.0.1:8000").is_ok());
871        assert!(validate_provider_base_url("http://[::1]:8000").is_ok());
872        // #26: http to a non-loopback host (even a private LAN one) is refused —
873        // the API key would otherwise cross the wire in cleartext.
874        assert!(validate_provider_base_url("http://192.168.1.5:8080").is_err());
875        assert!(validate_provider_base_url("http://169.254.169.254").is_err());
876    }
877
878    #[test]
879    fn cloudflare_base_url_synthesizes_account_scoped_endpoint() {
880        assert_eq!(
881            cloudflare_base_url("acct123"),
882            "https://api.cloudflare.com/client/v4/accounts/acct123/ai/v1"
883        );
884        // Trims surrounding whitespace (e.g. a trailing newline from `export`).
885        assert_eq!(
886            cloudflare_base_url("  acct123\n"),
887            "https://api.cloudflare.com/client/v4/accounts/acct123/ai/v1"
888        );
889    }
890
891    #[test]
892    fn cloudflare_account_id_required_and_non_blank() {
893        // Unset → clear, actionable error naming the env var.
894        temp_env::with_var("CLOUDFLARE_ACCOUNT_ID", None::<&str>, || {
895            let err = require_cloudflare_account_id().expect_err("must error when unset");
896            assert!(format!("{err}").contains("CLOUDFLARE_ACCOUNT_ID"));
897        });
898        // Whitespace-only is treated as unset.
899        temp_env::with_var("CLOUDFLARE_ACCOUNT_ID", Some("   "), || {
900            assert!(require_cloudflare_account_id().is_err());
901        });
902        // A real value resolves, trimmed.
903        temp_env::with_var("CLOUDFLARE_ACCOUNT_ID", Some(" acct123 "), || {
904            assert_eq!(require_cloudflare_account_id().unwrap(), "acct123");
905        });
906    }
907
908    #[test]
909    fn discovery_base_url_resolves_per_provider() {
910        let cf = lookup_provider("cloudflare").expect("cloudflare is in the registry");
911        let openai = lookup_provider("openai").expect("openai is in the registry");
912        // A user override always wins, for any provider.
913        assert_eq!(
914            discovery_base_url(cf, Some("https://gw.example/v1".to_string())),
915            Some("https://gw.example/v1".to_string())
916        );
917        // Non-cloudflare: the static registry default.
918        assert_eq!(
919            discovery_base_url(openai, None),
920            Some(openai.base_url.to_string())
921        );
922        // Cloudflare synthesizes the account-scoped URL from env...
923        temp_env::with_var("CLOUDFLARE_ACCOUNT_ID", Some("acct123"), || {
924            assert_eq!(
925                discovery_base_url(cf, None),
926                Some("https://api.cloudflare.com/client/v4/accounts/acct123/ai/v1".to_string())
927            );
928        });
929        // ...and yields None when it's unset — nothing real to probe.
930        temp_env::with_var("CLOUDFLARE_ACCOUNT_ID", None::<&str>, || {
931            assert_eq!(discovery_base_url(cf, None), None);
932        });
933    }
934
935    #[tokio::test]
936    async fn cloudflare_missing_both_env_vars_is_one_combined_error() {
937        temp_env::async_with_vars(
938            [
939                ("CLOUDFLARE_ACCOUNT_ID", None::<&str>),
940                ("CLOUDFLARE_API_TOKEN", None),
941            ],
942            async {
943                let f = ProviderFactory::new(Config::default());
944                let err = match f.resolve("cloudflare/@cf/zai-org/glm-5.2").await {
945                    Ok(_) => panic!("must fail with neither env var set"),
946                    Err(e) => e,
947                };
948                let msg = format!("{err}");
949                assert!(
950                    msg.contains("CLOUDFLARE_API_TOKEN") && msg.contains("CLOUDFLARE_ACCOUNT_ID"),
951                    "one error must name both missing vars, got: {msg}"
952                );
953            },
954        )
955        .await;
956    }
957
958    use std::sync::atomic::{AtomicUsize, Ordering};
959
960    fn unique_env(prefix: &str) -> String {
961        static N: AtomicUsize = AtomicUsize::new(0);
962        format!(
963            "{}_{}_{}",
964            prefix,
965            std::process::id(),
966            N.fetch_add(1, Ordering::SeqCst)
967        )
968    }
969
970    #[test]
971    fn parse_bare_name_defaults_to_ollama() {
972        let (p, m) = parse_model_id("qwen3-coder:30b");
973        assert_eq!(p, "ollama");
974        assert_eq!(m, "qwen3-coder:30b");
975    }
976
977    #[test]
978    fn parse_prefixed() {
979        let (p, m) = parse_model_id("anthropic/claude-opus-4-7");
980        assert_eq!(p, "anthropic");
981        assert_eq!(m, "claude-opus-4-7");
982    }
983
984    #[tokio::test]
985    async fn meta_requires_its_documented_api_key_env() {
986        temp_env::async_with_vars(
987            [(
988                crate::providers::model::meta::DEFAULT_API_KEY_ENV,
989                None::<&str>,
990            )],
991            async {
992                let factory = ProviderFactory::new(Config::default());
993                let error = match factory.resolve("meta/muse-spark-1.1").await {
994                    Ok(_) => panic!("Meta must require an API key"),
995                    Err(error) => error,
996                };
997                assert!(
998                    error
999                        .to_string()
1000                        .contains(crate::providers::model::meta::DEFAULT_API_KEY_ENV)
1001                );
1002            },
1003        )
1004        .await;
1005    }
1006
1007    #[tokio::test]
1008    async fn meta_routes_to_responses_provider_with_muse_capabilities() {
1009        temp_env::async_with_vars(
1010            [(
1011                crate::providers::model::meta::DEFAULT_API_KEY_ENV,
1012                Some("test-key"),
1013            )],
1014            async {
1015                let factory = ProviderFactory::new(Config::default());
1016                let provider = factory.resolve("meta/muse-spark-1.1").await.unwrap();
1017                let capabilities = provider.capabilities();
1018                assert!(capabilities.supports_tools);
1019                assert!(capabilities.supports_vision);
1020                assert!(capabilities.emits_provider_continuation);
1021                assert_eq!(
1022                    capabilities.max_context_tokens,
1023                    Some(mermaid_model::constants::META_MUSE_SPARK_CONTEXT_WINDOW)
1024                );
1025                assert_eq!(
1026                    capabilities.max_output_tokens,
1027                    Some(mermaid_model::constants::META_MUSE_SPARK_MAX_OUTPUT_TOKENS)
1028                );
1029            },
1030        )
1031        .await;
1032    }
1033
1034    #[test]
1035    fn gemini_key_resolution_accepts_legacy_fallback() {
1036        let primary = unique_env("MERMAID_FACTORY_GEMINI_PRIMARY");
1037        let legacy = unique_env("MERMAID_FACTORY_GEMINI_LEGACY");
1038        temp_env::with_vars(
1039            [(primary.as_str(), None), (legacy.as_str(), Some("legacy"))],
1040            || {
1041                let resolved = require_key_with_fallback("gemini", &primary, &legacy)
1042                    .expect("legacy fallback should resolve");
1043                assert_eq!(resolved, "legacy");
1044            },
1045        );
1046    }
1047
1048    #[test]
1049    fn gemini_key_resolution_prefers_google_primary() {
1050        let primary = unique_env("MERMAID_FACTORY_GEMINI_PRIMARY2");
1051        let legacy = unique_env("MERMAID_FACTORY_GEMINI_LEGACY2");
1052        temp_env::with_vars(
1053            [
1054                (primary.as_str(), Some("google")),
1055                (legacy.as_str(), Some("legacy")),
1056            ],
1057            || {
1058                let resolved = require_key_with_fallback("gemini", &primary, &legacy)
1059                    .expect("primary should resolve");
1060                assert_eq!(resolved, "google");
1061            },
1062        );
1063    }
1064
1065    #[tokio::test]
1066    async fn factory_reports_unknown_provider_clearly() {
1067        let cfg = Config::default();
1068        let f = ProviderFactory::new(cfg);
1069        match f.resolve("totally-made-up/model").await {
1070            Ok(_) => panic!("expected error"),
1071            Err(e) => {
1072                let msg = format!("{e}");
1073                assert!(
1074                    msg.contains("totally-made-up") || msg.contains("Unknown provider"),
1075                    "error message: {msg}"
1076                );
1077            },
1078        }
1079    }
1080
1081    #[test]
1082    fn normalize_cache_key_lowercases_provider_only() {
1083        // #83: provider segment is lowercased; the model segment is preserved.
1084        assert_eq!(
1085            normalize_cache_key("Anthropic/Claude-X"),
1086            "anthropic/Claude-X"
1087        );
1088        assert_eq!(
1089            normalize_cache_key("anthropic/Claude-X"),
1090            "anthropic/Claude-X"
1091        );
1092        // Bare names default to the ollama provider.
1093        assert_eq!(normalize_cache_key("qwen3:30b"), "ollama/qwen3:30b");
1094    }
1095
1096    #[tokio::test]
1097    async fn resolve_is_single_flight_and_cached() {
1098        // Ollama is keyless and builds no network connection, so this resolves
1099        // offline. Two concurrent resolves with different provider casing must
1100        // return the same cached instance (#83 normalization + #84 single-flight).
1101        let f = ProviderFactory::new(Config::default());
1102        let (a, b) = tokio::join!(
1103            f.resolve("ollama/test-model"),
1104            f.resolve("Ollama/test-model"),
1105        );
1106        let a = a.expect("resolve a");
1107        let b = b.expect("resolve b");
1108        assert!(
1109            Arc::ptr_eq(&a, &b),
1110            "expected one cached provider for casing variants + concurrent resolve"
1111        );
1112    }
1113
1114    // F66: a built-in provider's base_url override is hardened — validated for
1115    // scheme and otherwise honored (the warning is a side effect we don't assert
1116    // here, but the dedup helper is tested separately below).
1117    #[test]
1118    fn builtin_base_url_override_validated_and_resolved() {
1119        // No override → trusted default, unchanged, no validation needed.
1120        assert_eq!(
1121            resolve_overridable_base_url("anthropic", None, "https://api.anthropic.com/v1")
1122                .unwrap(),
1123            "https://api.anthropic.com/v1"
1124        );
1125        // https override is honored (the key would go there — warned, not blocked).
1126        assert_eq!(
1127            resolve_overridable_base_url(
1128                "anthropic",
1129                Some("https://proxy.internal/v1".to_string()),
1130                "https://api.anthropic.com/v1",
1131            )
1132            .unwrap(),
1133            "https://proxy.internal/v1"
1134        );
1135        // http override to a NON-loopback host is refused (would leak the key in
1136        // cleartext) — the F66 https requirement.
1137        assert!(
1138            resolve_overridable_base_url(
1139                "anthropic",
1140                Some("http://attacker.example/v1".to_string()),
1141                "https://api.anthropic.com/v1",
1142            )
1143            .is_err()
1144        );
1145        // http override to loopback stays allowed for local dev (don't break it).
1146        assert!(
1147            resolve_overridable_base_url(
1148                "openai",
1149                Some("http://localhost:8080/v1".to_string()),
1150                "https://api.openai.com/v1",
1151            )
1152            .is_ok()
1153        );
1154    }
1155
1156    #[test]
1157    fn provider_host_extracts_host_or_unknown() {
1158        assert_eq!(
1159            provider_host("https://attacker.example/v1"),
1160            "attacker.example"
1161        );
1162        assert_eq!(provider_host("http://127.0.0.1:8080"), "127.0.0.1");
1163        assert_eq!(provider_host("not a url"), "<unknown>");
1164    }
1165
1166    #[test]
1167    fn override_host_warning_is_deduped() {
1168        // The F66 warning must be one-time per key: first call fires, the rest
1169        // are suppressed. Use a process-unique key so this test doesn't race the
1170        // shared warned-set with any other test.
1171        let key = unique_env("MERMAID_FACTORY_WARN_KEY");
1172        assert!(should_warn_once(&key), "first warn for a key must fire");
1173        assert!(
1174            !should_warn_once(&key),
1175            "subsequent warns for the same key must be suppressed"
1176        );
1177    }
1178
1179    // F67: identical custom-provider inputs must reuse one leaked &'static
1180    // profile, and distinct inputs must each leak exactly one — no per-model_id
1181    // growth.
1182    #[test]
1183    fn custom_profile_is_memoized_per_key() {
1184        use mermaid_domain::UserProviderConfig;
1185        let cfg = UserProviderConfig {
1186            base_url: Some("https://api.custom.test/v1".to_string()),
1187            api_key_env: Some("CUSTOM_KEY".to_string()),
1188            compat: Some("openai".to_string()),
1189            ..Default::default()
1190        };
1191        // Two resolves with identical inputs (as happens for two different models
1192        // of the same custom provider) return the SAME leaked pointer.
1193        let a = user_profile_to_static("mermaid_test_customx", &cfg).unwrap();
1194        let b = user_profile_to_static("mermaid_test_customx", &cfg).unwrap();
1195        assert!(
1196            std::ptr::eq(a, b),
1197            "identical custom-provider inputs must reuse one leaked &'static profile"
1198        );
1199        assert_eq!(a.base_url, "https://api.custom.test/v1");
1200        assert_eq!(a.api_key_env, "CUSTOM_KEY");
1201
1202        // A different base_url is a different key → a distinct leaked profile.
1203        let cfg2 = UserProviderConfig {
1204            base_url: Some("https://api.custom.test/v2".to_string()),
1205            ..cfg.clone()
1206        };
1207        let c = user_profile_to_static("mermaid_test_customx", &cfg2).unwrap();
1208        assert!(
1209            !std::ptr::eq(a, c),
1210            "a different base_url must leak a distinct profile"
1211        );
1212    }
1213}