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
576/// Whether `model_id`'s provider could be built on this machine right now.
577///
578/// Ollama ids (bare or `ollama/…`) always pass — the local backend needs no
579/// credential. Remote ids pass iff [`resolve_provider_endpoint`] does, the
580/// same single definition of "buildable" that discovery and `doctor` use.
581///
582/// This gates persisting `last_used_model`: a `--model` whose provider
583/// provably cannot be built here — a keyless test invocation, a typo'd
584/// provider name — must not become the startup default that every later
585/// session resolves first. (Test binaries writing `anthropic/pty-*-test`
586/// into the developer's real config were exactly this hole.)
587#[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        // Scheme-less: `normalize_url` in the adapter picks http (loopback/LAN)
596        // vs https (public) by host class (#86).
597        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
604/// Process-wide memo of leaked custom-provider profiles, keyed by the
605/// profile-determining inputs (see [`user_profile_to_static`]). Guarantees each
606/// distinct custom provider leaks its `&'static ProviderProfile` at most once.
607static 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
613/// Convert a user-defined `[providers.<name>]` entry into a `&'static
614/// ProviderProfile`. `ProviderProfile`'s fields are `&'static` (tied to the
615/// registry constants), so a custom provider needs a leaked, owned copy to
616/// participate without redesigning the profile type.
617///
618/// The leak is memoized (F67). `build_provider` runs once per distinct *model
619/// id*, so without a cache this leaked a fresh profile for every custom
620/// `provider/model` pair — a permanent, per-distinct-model_id growth, not the
621/// "0-3" the old comment claimed. The profile's content depends only on
622/// (provider name, `base_url`, `api_key_env`, compat) and NOT on the model, so we
623/// key the cache on exactly those and leak at most once per distinct
624/// combination; repeated resolves (including different models of the same custom
625/// provider) reuse the same `&'static`.
626fn 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    // Stable key over exactly the fields baked into the profile below. NUL
637    // separates components so distinct inputs can't collide into one key.
638    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        // `&'static ProviderProfile` is Copy, so this hands back the same leaked
645        // allocation as the first resolve — no new leak.
646        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
672/// Build Cloudflare Workers AI's account-scoped OpenAI-compatible base URL. The
673/// adapter appends `/chat/completions`, so this ends at `/ai/v1`. Kept pure and
674/// separate so it's directly unit-testable (a built provider exposes no `base_url`
675/// getter).
676fn 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
683/// Best-effort `base_url` for *discovery* surfaces (`doctor`'s provider list,
684/// the `/models` probe) — chat requests never use this; `build_provider` has its
685/// own resolution. A user override wins for any provider; cloudflare synthesizes
686/// its account-scoped URL from `CLOUDFLARE_ACCOUNT_ID` and yields `None` when
687/// the var is unset (there is no real endpoint then — probing the registry
688/// placeholder would just guarantee a 404); everything else uses the profile's
689/// static default.
690pub(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
705/// Resolve the Cloudflare account id from `CLOUDFLARE_ACCOUNT_ID` (trimmed,
706/// non-empty), or a clear actionable error. It isn't a secret, but it's required
707/// to construct the account-scoped Workers AI endpoint; `resolve_api_key` is
708/// reused only for its empty→None handling.
709fn 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
722/// Validate a provider `base_url` before it's handed an API key. Requires
723/// http/https, and **requires https for any non-loopback, non-private host** —
724/// a typo'd or hostile `http://` endpoint would otherwise receive the bearer
725/// key in cleartext. Plain http stays allowed for loopback / RFC-1918 hosts so
726/// local model servers (Ollama, vLLM) keep working.
727fn 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        // Plaintext http is only safe to LOOPBACK: a key sent over http to any
734        // other host — including a LAN/private one — crosses the wire in
735        // cleartext. Every caller here is a key-bearing provider; keyless local
736        // Ollama uses a separate path that doesn't validate.
737        "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
753/// Resolve a *built-in* provider's `base_url`, honoring a user override but
754/// hardening it (F66). Built-in providers (anthropic, gemini, and the
755/// registry-backed OpenAI-compatible ones) ship a trusted default endpoint; a
756/// `[providers.<name>] base_url` override redirects that provider's API key to a
757/// host the user chose. The override lives in the user's own config, so we allow
758/// it — but we also:
759///   * validate the scheme via [`validate_provider_base_url`], which already
760///     requires https for any non-loopback host, so an `http://` override can't
761///     leak the key in cleartext (http stays allowed only for
762///     `localhost/127.0.0.1/::1` local dev), and
763///   * emit a one-time warning naming the host the key will be sent to, so a
764///     redirect to an unexpected host (e.g. `https://attacker.example`) is
765///     visible rather than silent.
766///
767/// With no override the trusted default is returned unchanged (no warning).
768fn 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
783/// Hosts already warned about (per provider) for a built-in `base_url` override,
784/// so the F66 warning fires once per process rather than on every `resolve`.
785/// Keyed by `"<provider>@<host>"`.
786static 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
790/// Emit a one-time `tracing::warn!` (deduped per provider+host) naming the host a
791/// built-in provider's API key will be sent to because its trusted default
792/// `base_url` was overridden in config (F66).
793fn 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
806/// Host portion of a `base_url` for the override warning, or `"<unknown>"` if it
807/// can't be parsed (the caller already validated it, so this is belt-and-braces).
808fn 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
815/// Record `key` in the process-wide warned-set, returning `true` the first time
816/// (i.e. "warn now") and `false` thereafter. Poison-tolerant.
817fn 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        // No user config: the static profile headers survive (the latent-bug fix).
841        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        // User extra_headers merge on top and can override a static one.
848        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        // Remote http is refused (would leak the bearer key in cleartext).
882        assert!(validate_provider_base_url("http://api.example.com/v1").is_err());
883        assert!(validate_provider_base_url("ftp://example.com").is_err());
884        // https anywhere and http to LOOPBACK are fine.
885        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        // #26: http to a non-loopback host (even a private LAN one) is refused —
890        // the API key would otherwise cross the wire in cleartext.
891        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        // Trims surrounding whitespace (e.g. a trailing newline from `export`).
902        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        // Unset → clear, actionable error naming the env var.
911        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        // Whitespace-only is treated as unset.
916        temp_env::with_var("CLOUDFLARE_ACCOUNT_ID", Some("   "), || {
917            assert!(require_cloudflare_account_id().is_err());
918        });
919        // A real value resolves, trimmed.
920        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        // A user override always wins, for any provider.
930        assert_eq!(
931            discovery_base_url(cf, Some("https://gw.example/v1".to_string())),
932            Some("https://gw.example/v1".to_string())
933        );
934        // Non-cloudflare: the static registry default.
935        assert_eq!(
936            discovery_base_url(openai, None),
937            Some(openai.base_url.to_string())
938        );
939        // Cloudflare synthesizes the account-scoped URL from env...
940        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        // ...and yields None when it's unset — nothing real to probe.
947        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        // #83: provider segment is lowercased; the model segment is preserved.
1101        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        // Bare names default to the ollama provider.
1110        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        // Ollama is keyless and builds no network connection, so this resolves
1116        // offline. Two concurrent resolves with different provider casing must
1117        // return the same cached instance (#83 normalization + #84 single-flight).
1118        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    // F66: a built-in provider's base_url override is hardened — validated for
1132    // scheme and otherwise honored (the warning is a side effect we don't assert
1133    // here, but the dedup helper is tested separately below).
1134    #[test]
1135    fn builtin_base_url_override_validated_and_resolved() {
1136        // No override → trusted default, unchanged, no validation needed.
1137        assert_eq!(
1138            resolve_overridable_base_url("anthropic", None, "https://api.anthropic.com/v1")
1139                .unwrap(),
1140            "https://api.anthropic.com/v1"
1141        );
1142        // https override is honored (the key would go there — warned, not blocked).
1143        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        // http override to a NON-loopback host is refused (would leak the key in
1153        // cleartext) — the F66 https requirement.
1154        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        // http override to loopback stays allowed for local dev (don't break it).
1163        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        // The F66 warning must be one-time per key: first call fires, the rest
1186        // are suppressed. Use a process-unique key so this test doesn't race the
1187        // shared warned-set with any other test.
1188        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    // F67: identical custom-provider inputs must reuse one leaked &'static
1197    // profile, and distinct inputs must each leak exactly one — no per-model_id
1198    // growth.
1199    #[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        // Two resolves with identical inputs (as happens for two different models
1209        // of the same custom provider) return the SAME leaked pointer.
1210        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        // A different base_url is a different key → a distinct leaked profile.
1220        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}