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::{ModelError, Result, lookup_provider};
14use mermaid_model::utils::{
15    resolve_api_key, resolve_provider_key, resolve_provider_key_with_fallback,
16};
17
18use super::model::{anthropic, gemini, meta};
19
20/// Resolve an API key (env first, then the OS keyring via
21/// `mermaid login <provider>`) or return a clear `ModelError`. A
22/// per-provider `override_env` is authoritative — no keyring fallback.
23fn require_key(provider: &str, default_env: &str, override_env: Option<&str>) -> Result<String> {
24    resolve_provider_key(provider, default_env, override_env).ok_or_else(|| {
25        let env = override_env.unwrap_or(default_env);
26        ModelError::Authentication(format!(
27            "{provider} requires env var {env} (or `mermaid login {provider}`)"
28        ))
29    })
30}
31
32fn require_key_with_fallback(
33    provider: &str,
34    env_var: &str,
35    fallback_env_var: &str,
36) -> Result<String> {
37    resolve_provider_key_with_fallback(provider, env_var, fallback_env_var, None).ok_or_else(|| {
38        ModelError::Authentication(format!(
39            "{provider} requires env var {env_var} (or legacy {fallback_env_var}, or \
40                 `mermaid login {provider}`)"
41        ))
42    })
43}
44
45/// Which env var supplied a key, or `None` when the keyring did.
46///
47/// Mirrors [`resolve_provider_key`]'s precedence exactly — it must, because the
48/// two are asked the same question by different callers and a disagreement
49/// would show up as `status` naming a var the factory never read.
50fn key_env_source(default_env: &str, override_env: Option<&str>) -> Option<String> {
51    // Only ever called after a key resolved, so "not in this env var" means the
52    // keyring supplied it. An explicit override is authoritative and never
53    // reaches the keyring, so for that case this always matches.
54    let env = override_env.unwrap_or(default_env);
55    resolve_api_key(env, None).map(|_| env.to_string())
56}
57
58/// [`key_env_source`] for the one legacy-fallback provider (Gemini).
59fn key_env_source_with_fallback(default_env: &str, fallback_env: &str) -> Option<String> {
60    if resolve_api_key(default_env, None).is_some() {
61        return Some(default_env.to_string());
62    }
63    if resolve_api_key(fallback_env, None).is_some() {
64        return Some(fallback_env.to_string());
65    }
66    None
67}
68
69/// What `build_provider` resolves before it constructs anything: the endpoint it
70/// would dial and the credential it would send.
71///
72/// This exists so that "which providers can this machine actually talk to" has
73/// exactly ONE definition. It used to have four — `build_provider`'s dispatch
74/// chain plus three hand-rolled walks in `doctor`, `status`, and startup model
75/// resolution — and all four disagreed about custom providers. Everything that
76/// wants the answer now reads it through `providers::discovery`, which calls
77/// this and treats `Err` as "not usable".
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub(crate) struct ProviderEndpoint {
80    /// The base URL the adapter would be built with, overrides applied.
81    pub(crate) base_url: String,
82    /// `None` only where a keyless endpoint is legal: a loopback/LAN host
83    /// running an unauthenticated OpenAI-compatible server.
84    pub(crate) api_key: Option<String>,
85    /// Env var the key came from. `None` means the keyring supplied it, or the
86    /// endpoint is keyless.
87    pub(crate) key_env: Option<String>,
88}
89
90impl ProviderEndpoint {
91    /// The key, for the bespoke adapters that cannot run keyless.
92    ///
93    /// Total in practice: [`resolve_provider_endpoint`] has already failed with
94    /// the provider's own actionable message if a required key was missing.
95    /// This exists so those branches need no `expect`.
96    fn require_key(&self, provider: &str) -> Result<String> {
97        self.api_key
98            .clone()
99            .ok_or_else(|| ModelError::Authentication(format!("{provider} requires an API key")))
100    }
101}
102
103/// True when Mermaid knows how to build `provider` at all — before asking
104/// whether this machine has the credentials for it.
105fn is_known_provider(config: &Config, provider_lc: &str) -> bool {
106    matches!(provider_lc, "anthropic" | "gemini" | "meta")
107        || lookup_provider(provider_lc).is_some()
108        || config.providers.contains_key(provider_lc)
109}
110
111/// Resolve `provider`'s endpoint and credential exactly as `build_provider`
112/// would, without constructing an adapter or touching the network.
113///
114/// `Err` is the honest answer to "can this machine use this provider": a missing
115/// key, a Cloudflare account id that was never set, a custom provider with no
116/// `base_url`. The error text is the same one the user would hit on a real
117/// request, so `status` and `doctor` can print it verbatim.
118pub(crate) fn resolve_provider_endpoint(
119    config: &Config,
120    provider: &str,
121) -> Result<ProviderEndpoint> {
122    let provider_lc = provider.to_lowercase();
123    let user_cfg = config.providers.get(&provider_lc);
124    let override_env = user_cfg.and_then(|c| c.api_key_env.as_deref());
125    let override_url = user_cfg.and_then(|c| c.base_url.clone());
126
127    // Bespoke adapters (Anthropic, Gemini, Meta). All three are public
128    // endpoints with no keyless deployment story, so the key is required.
129    let bespoke = match provider_lc.as_str() {
130        "anthropic" => Some((
131            anthropic::DEFAULT_API_KEY_ENV,
132            None,
133            anthropic::DEFAULT_BASE_URL,
134        )),
135        "gemini" => Some((
136            gemini::DEFAULT_API_KEY_ENV,
137            Some(gemini::LEGACY_API_KEY_ENV),
138            gemini::DEFAULT_BASE_URL,
139        )),
140        "meta" => Some((meta::DEFAULT_API_KEY_ENV, None, meta::DEFAULT_BASE_URL)),
141        _ => None,
142    };
143    if let Some((default_env, legacy_env, default_url)) = bespoke {
144        let base_url = resolve_overridable_base_url(&provider_lc, override_url, default_url)?;
145        let (api_key, key_env) = match legacy_env {
146            // The legacy var is consulted only when the user has NOT pointed at
147            // a specific one (matches `require_key_with_fallback`).
148            Some(legacy) if override_env.is_none() => (
149                require_key_with_fallback(&provider_lc, default_env, legacy)?,
150                key_env_source_with_fallback(default_env, legacy),
151            ),
152            _ => (
153                require_key(&provider_lc, default_env, override_env)?,
154                key_env_source(default_env, override_env),
155            ),
156        };
157        return Ok(ProviderEndpoint {
158            base_url,
159            api_key: Some(api_key),
160            key_env,
161        });
162    }
163
164    // Cloudflare Workers AI — OpenAI-compatible, but the endpoint URL embeds a
165    // per-account id, so the base_url is synthesized at runtime from
166    // CLOUDFLARE_ACCOUNT_ID (or a full [providers.cloudflare].base_url override,
167    // e.g. AI Gateway). Must precede the generic registry branch, which would
168    // otherwise route it through the placeholder profile.base_url.
169    if provider_lc == "cloudflare" {
170        return resolve_cloudflare_endpoint(&provider_lc, override_env, override_url);
171    }
172
173    // The OpenAI-compatible registry.
174    if let Some(profile) = lookup_provider(&provider_lc) {
175        let base_url = resolve_overridable_base_url(&provider_lc, override_url, profile.base_url)?;
176        // Keyless when the key is unset and the endpoint is local (a registry
177        // provider pointed at a loopback/LAN base_url); otherwise a clear,
178        // hint-carrying auth error.
179        let api_key = resolve_optional_key(
180            &provider_lc,
181            profile.api_key_env,
182            override_env,
183            &base_url,
184            profile.key_hint,
185        )?;
186        let key_env = api_key
187            .is_some()
188            .then(|| key_env_source(profile.api_key_env, override_env))
189            .flatten();
190        return Ok(ProviderEndpoint {
191            base_url,
192            api_key,
193            key_env,
194        });
195    }
196
197    // User-custom: no registry entry, but the user has [providers.<name>] in
198    // config. `base_url` is mandatory here — there is no default to fall back on.
199    if user_cfg.is_some() {
200        return resolve_custom_endpoint(&provider_lc, override_env, override_url);
201    }
202
203    Err(ModelError::InvalidRequest(format!(
204        "Unknown provider '{provider}'"
205    )))
206}
207
208/// Cloudflare Workers AI — OpenAI-compatible, but the endpoint URL embeds a
209/// per-account id, so the `base_url` is synthesized at runtime from
210/// `CLOUDFLARE_ACCOUNT_ID` (or a full `[providers.cloudflare].base_url`
211/// override,
212/// e.g. AI Gateway).
213fn resolve_cloudflare_endpoint(
214    provider_lc: &str,
215    override_env: Option<&str>,
216    override_url: Option<String>,
217) -> Result<ProviderEndpoint> {
218    let profile = lookup_provider("cloudflare").expect("cloudflare is in the registry");
219    let api_key_env = override_env.unwrap_or(profile.api_key_env);
220    let base_url = match override_url {
221        // Override present (AI Gateway / proxy): validate + warn like any
222        // built-in override. The account id isn't needed in this case.
223        Some(url) => {
224            validate_provider_base_url(&url)?;
225            warn_overridden_provider_host("cloudflare", &url);
226            url
227        },
228        // Standard path: synthesize the account-scoped endpoint from env. A
229        // fresh setup missing the token as well gets one error naming both
230        // vars, not two fix-and-retry round-trips.
231        None => match require_cloudflare_account_id() {
232            Ok(id) => cloudflare_base_url(&id),
233            Err(_)
234                if resolve_provider_key("cloudflare", profile.api_key_env, override_env)
235                    .is_none() =>
236            {
237                return Err(ModelError::Authentication(format!(
238                    "cloudflare requires env vars {api_key_env} and CLOUDFLARE_ACCOUNT_ID — \
239                     create a token at https://dash.cloudflare.com/profile/api-tokens; the \
240                     account id is on your Cloudflare dashboard (or set \
241                     [providers.cloudflare].base_url)"
242                )));
243            },
244            Err(e) => return Err(e),
245        },
246    };
247    let api_key = resolve_optional_key(
248        provider_lc,
249        profile.api_key_env,
250        override_env,
251        &base_url,
252        profile.key_hint,
253    )?;
254    let key_env = api_key
255        .is_some()
256        .then(|| key_env_source(profile.api_key_env, override_env))
257        .flatten();
258    Ok(ProviderEndpoint {
259        base_url,
260        api_key,
261        key_env,
262    })
263}
264
265/// User-custom: no registry entry, but the user has `[providers.<name>]` in
266/// config. `base_url` is mandatory here — there is no default to fall back on.
267fn resolve_custom_endpoint(
268    provider_lc: &str,
269    override_env: Option<&str>,
270    override_url: Option<String>,
271) -> Result<ProviderEndpoint> {
272    let base_url = override_url.ok_or_else(|| {
273        ModelError::InvalidRequest(format!(
274            "custom provider '{provider_lc}' requires base_url in config"
275        ))
276    })?;
277    // api_key_env is optional: a local (loopback/LAN) endpoint may run
278    // keyless. When a key IS used, harden the URL so it can't be sent in
279    // cleartext; a keyless endpoint must be local (no secret to leak).
280    // For a CUSTOM provider its api_key_env is the default (not an override
281    // of a registry default), so the keyring may fill the gap; a stored key
282    // alone also works with no api_key_env at all.
283    let resolved = match override_env {
284        Some(env) => resolve_provider_key(provider_lc, env, None),
285        None => mermaid_model::utils::default_store().get(provider_lc),
286    };
287    let (api_key, key_env) = match resolved {
288        Some(key) => {
289            validate_provider_base_url(&base_url)?;
290            let env = override_env.and_then(|env| key_env_source(env, None));
291            (Some(key), env)
292        },
293        None if base_url_is_local(&base_url) => (None, None),
294        None => {
295            let reason = match override_env {
296                Some(env) => format!(
297                    "requires env var {env} (or `mermaid login {provider_lc}`, or a \
298                     loopback/LAN base_url)"
299                ),
300                None => "requires api_key_env, or a loopback/LAN base_url".to_string(),
301            };
302            return Err(ModelError::Authentication(format!(
303                "custom provider '{provider_lc}' {reason}"
304            )));
305        },
306    };
307    Ok(ProviderEndpoint {
308        base_url,
309        api_key,
310        key_env,
311    })
312}
313
314/// Resolve an API key, or `None` when the key is absent AND the endpoint is a
315/// loopback/LAN host — local OpenAI-compatible servers (llama.cpp, vLLM, LM
316/// Studio) legitimately run without auth. A missing key for a public endpoint
317/// is a hard error carrying the provider's `hint` so the message is actionable.
318fn resolve_optional_key(
319    provider: &str,
320    default_env: &str,
321    override_env: Option<&str>,
322    base_url: &str,
323    hint: Option<&str>,
324) -> Result<Option<String>> {
325    if let Some(key) = resolve_provider_key(provider, default_env, override_env) {
326        return Ok(Some(key));
327    }
328    if base_url_is_local(base_url) {
329        return Ok(None);
330    }
331    let env = override_env.unwrap_or(default_env);
332    let mut msg = format!("{provider} requires env var {env} (or `mermaid login {provider}`)");
333    if let Some(h) = hint {
334        msg.push_str(" — ");
335        msg.push_str(h);
336    }
337    Err(ModelError::Authentication(msg))
338}
339
340/// True when `base_url`'s host is a loopback or private/LAN address — where a
341/// local model server legitimately runs without auth.
342fn base_url_is_local(base_url: &str) -> bool {
343    reqwest::Url::parse(base_url)
344        .ok()
345        .and_then(|u| {
346            u.host_str()
347                .map(|h| mermaid_model::utils::classify_host(h).is_internal())
348        })
349        .unwrap_or(false)
350}
351
352/// The header set sent on every request: the profile's static `extra_headers`
353/// first, then user `extra_headers` overrides, then env-sourced `env_headers`
354/// (header name -> env var, resolved now since env is process-static). This
355/// restores the profile's static headers (e.g. OpenRouter analytics) that the
356/// prior registry path dropped.
357fn merged_headers(
358    profile: &mermaid_model::models::ProviderProfile,
359    user_cfg: Option<&mermaid_domain::UserProviderConfig>,
360) -> std::collections::HashMap<String, String> {
361    let mut headers: std::collections::HashMap<String, String> = profile
362        .extra_headers
363        .iter()
364        .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
365        .collect();
366    if let Some(cfg) = user_cfg {
367        for (k, v) in &cfg.extra_headers {
368            headers.insert(k.clone(), v.clone());
369        }
370        for (header, env_var) in &cfg.env_headers {
371            if let Ok(val) = std::env::var(env_var) {
372                headers.insert(header.clone(), val);
373            }
374        }
375    }
376    headers
377}
378
379use super::model::{
380    AnthropicProvider, GeminiProvider, MetaProvider, ModelProvider, OllamaProvider,
381    OpenAICompatProvider,
382};
383
384/// A lazily-built, shared provider. `OnceCell` gives single-flight construction
385/// (#84); the outer `Arc` lets `resolve` clone the cell out from under the cache
386/// lock and initialize it without holding the lock across the build.
387type ProviderCell = Arc<tokio::sync::OnceCell<Arc<dyn ModelProvider>>>;
388
389/// Per-process provider cache. Providers are expensive to construct
390/// (HTTP client, connection pool, capability lookup) so the effect
391/// runner asks for them lazily and reuses across turns.
392pub struct ProviderFactory {
393    config: Arc<Config>,
394    /// Per-key cache of built providers, keyed by normalized model id (#83). The
395    /// `Mutex` is only held to get-or-insert a cell, never across the build.
396    cache: Mutex<std::collections::HashMap<String, ProviderCell>>,
397}
398
399impl ProviderFactory {
400    #[must_use]
401    pub fn new(config: Config) -> Self {
402        Self {
403            config: Arc::new(config),
404            cache: Mutex::new(std::collections::HashMap::new()),
405        }
406    }
407
408    /// A factory with providers already resolved for the given model ids.
409    ///
410    /// `resolve` hands these back instead of building anything, so a caller
411    /// can substitute a provider it constructed itself. This is how tests
412    /// drive the real agent loop — reducer, effect runner, tool registry,
413    /// subagents — against a scripted model instead of a network: the seam
414    /// is the provider, so everything below it is the production path.
415    ///
416    /// Seeding is additive. A model id that was not seeded still builds
417    /// normally, which lets a test stub one model and leave the rest alone.
418    pub fn with_seeded_providers(
419        config: Config,
420        seeds: impl IntoIterator<Item = (String, Arc<dyn ModelProvider>)>,
421    ) -> Self {
422        let cache = seeds
423            .into_iter()
424            .map(|(model_id, provider)| {
425                let cell = tokio::sync::OnceCell::new_with(Some(provider));
426                (normalize_cache_key(&model_id), Arc::new(cell))
427            })
428            .collect();
429        Self {
430            config: Arc::new(config),
431            cache: Mutex::new(cache),
432        }
433    }
434
435    pub fn config(&self) -> &Config {
436        &self.config
437    }
438
439    /// Resolve (or lazily construct) a provider for the given model
440    /// ID. Hits the cache on the second and subsequent calls for the
441    /// same ID.
442    ///
443    /// # Errors
444    ///
445    /// Whatever building the provider fails with: an unknown provider prefix,
446    /// a provider whose API key does not resolve, or the adapter's own client
447    /// build. A failed build is deliberately not cached, so a transient error
448    /// is retried on the next call rather than pinned for the session.
449    pub async fn resolve(&self, model_id: &str) -> Result<Arc<dyn ModelProvider>> {
450        let key = normalize_cache_key(model_id);
451        // Get-or-insert the per-key cell under a brief lock, then initialize it
452        // exactly once outside the lock (#84). A failed build isn't cached, so a
453        // transient error can be retried on the next call.
454        let cell = {
455            let mut cache = self.cache.lock().await;
456            Arc::clone(
457                cache
458                    .entry(key)
459                    .or_insert_with(|| Arc::new(tokio::sync::OnceCell::new())),
460            )
461        };
462        let provider = cell
463            .get_or_try_init(|| async {
464                let p = build_provider(&self.config, model_id).await?;
465                Ok::<Arc<dyn ModelProvider>, ModelError>(Arc::from(p))
466            })
467            .await?;
468        Ok(Arc::clone(provider))
469    }
470}
471
472/// Build a provider for the given `model_id`:
473///   1. `ollama/<model>` → `OllamaProvider`.
474///   2. `anthropic/<model>` → `AnthropicProvider`.
475///   3. `gemini/<model>` → `GeminiProvider`.
476///   4. `meta/<model>` → `MetaProvider` using the Responses API.
477///   5. Other builtin providers (openai, openrouter, groq, …) → `OpenAICompatProvider`.
478///   6. User-defined `[providers.<name>]` → custom `OpenAICompatProvider`.
479///   7. Bare model name → `OllamaProvider`.
480async fn build_provider(config: &Config, model_id: &str) -> Result<Box<dyn ModelProvider>> {
481    let (provider, model_name) = parse_model_id(model_id);
482    let provider_lc = provider.to_lowercase();
483
484    // 1. Ollama (and bare names). F11: pass Arc<Config> so the wrapper
485    // can forward Ollama hardware options to the adapter.
486    if provider_lc == "ollama" {
487        let backend = crate::ollama::backend_config(config);
488        let p = OllamaProvider::with_app_config(
489            model_name,
490            Arc::new(backend),
491            Arc::new(config.clone()),
492        )
493        .await?;
494        return Ok(Box::new(p));
495    }
496
497    // Everything past Ollama is a remote provider, and ONE function decides
498    // where it lives and what credential it takes — the same one `discovery`
499    // asks on behalf of `status`, `doctor`, `list`, and `/model`. The branches
500    // below only turn that answer into the right adapter.
501    if !is_known_provider(config, &provider_lc) {
502        return Err(ModelError::InvalidRequest(format!(
503            "Unknown provider '{provider}' (model_id: {model_id})"
504        )));
505    }
506    let endpoint = resolve_provider_endpoint(config, &provider_lc)?;
507    let user_cfg = config.providers.get(&provider_lc);
508
509    // 2. Anthropic — bespoke API shape.
510    if provider_lc == "anthropic" {
511        let p = AnthropicProvider::new(
512            endpoint.require_key(&provider_lc)?,
513            model_name.to_string(),
514            endpoint.base_url,
515        )?;
516        return Ok(Box::new(p));
517    }
518
519    // 3. Gemini — GCP AI Studio shape.
520    if provider_lc == "gemini" {
521        let p = GeminiProvider::new(
522            endpoint.require_key(&provider_lc)?,
523            model_name.to_string(),
524            endpoint.base_url,
525        )?;
526        return Ok(Box::new(p));
527    }
528
529    // 4. Meta — Responses is required for encrypted reasoning continuity across
530    // Mermaid's tool loop even though Meta also exposes Chat Completions.
531    if provider_lc == "meta" {
532        let api_key = endpoint.require_key(&provider_lc)?;
533        let mut extra_headers = std::collections::HashMap::new();
534        if let Some(cfg) = user_cfg {
535            extra_headers.extend(cfg.extra_headers.clone());
536            for (header, env_var) in &cfg.env_headers {
537                if let Ok(value) = std::env::var(env_var) {
538                    extra_headers.insert(header.clone(), value);
539                }
540            }
541        }
542        let p = MetaProvider::new(
543            api_key,
544            model_name.to_string(),
545            endpoint.base_url,
546            extra_headers,
547        )?;
548        return Ok(Box::new(p));
549    }
550
551    // 5 + 6. Everything else is OpenAI-compatible: the built-in registry
552    // (Cloudflare's account-scoped URL included — `resolve_provider_endpoint`
553    // synthesized it) or a user-defined `[providers.<name>]`.
554    let profile = match lookup_provider(&provider_lc) {
555        Some(profile) => profile,
556        // `is_known_provider` already proved the config entry exists, and
557        // `user_profile_to_static` is total, so this cannot fall through.
558        None => user_cfg
559            .and_then(|cfg| user_profile_to_static(&provider_lc, cfg))
560            .ok_or_else(|| {
561                ModelError::InvalidRequest(format!(
562                    "Unknown provider '{provider}' (model_id: {model_id})"
563                ))
564            })?,
565    };
566    let extra_headers = merged_headers(profile, user_cfg);
567    let p = OpenAICompatProvider::new(
568        profile,
569        endpoint.base_url,
570        endpoint.api_key,
571        model_name.to_string(),
572        extra_headers,
573    )?;
574    Ok(Box::new(p))
575}
576
577/// Cache key for a model id: the provider segment lowercased (matching
578/// `build_provider`'s own normalization) so `Anthropic/x` and `anthropic/x`
579/// resolve to one cached provider instead of building two identical ones (#83).
580fn normalize_cache_key(model_id: &str) -> String {
581    let (provider, model) = parse_model_id(model_id);
582    format!("{}/{}", provider.to_lowercase(), model)
583}
584
585/// Parse `provider/model` → `(provider, model)`. Bare strings are
586/// Ollama by convention.
587fn parse_model_id(model_id: &str) -> (String, &str) {
588    match model_id.split_once('/') {
589        Some((p, m)) => (p.to_string(), m),
590        None => ("ollama".to_string(), model_id),
591    }
592}
593
594/// Whether `model_id`'s provider could be built on this machine right now.
595///
596/// Ollama ids (bare or `ollama/…`) always pass — the local backend needs no
597/// credential. Remote ids pass iff [`resolve_provider_endpoint`] does, the
598/// same single definition of "buildable" that discovery and `doctor` use.
599///
600/// This gates persisting `last_used_model`: a `--model` whose provider
601/// provably cannot be built here — a keyless test invocation, a typo'd
602/// provider name — must not become the startup default that every later
603/// session resolves first. (Test binaries writing `anthropic/pty-*-test`
604/// into the developer's real config were exactly this hole.)
605#[must_use]
606pub fn model_provider_resolves(config: &Config, model_id: &str) -> bool {
607    let (provider, _) = parse_model_id(model_id);
608    provider.eq_ignore_ascii_case("ollama") || resolve_provider_endpoint(config, &provider).is_ok()
609}
610
611/// Process-wide memo of leaked custom-provider profiles, keyed by the
612/// profile-determining inputs (see [`user_profile_to_static`]). Guarantees each
613/// distinct custom provider leaks its `&'static ProviderProfile` at most once.
614static PROFILE_CACHE: std::sync::LazyLock<
615    std::sync::Mutex<
616        std::collections::HashMap<String, &'static mermaid_model::models::ProviderProfile>,
617    >,
618> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
619
620/// Convert a user-defined `[providers.<name>]` entry into a `&'static
621/// ProviderProfile`. `ProviderProfile`'s fields are `&'static` (tied to the
622/// registry constants), so a custom provider needs a leaked, owned copy to
623/// participate without redesigning the profile type.
624///
625/// The leak is memoized (F67). `build_provider` runs once per distinct *model
626/// id*, so without a cache this leaked a fresh profile for every custom
627/// `provider/model` pair — a permanent, per-distinct-model_id growth, not the
628/// "0-3" the old comment claimed. The profile's content depends only on
629/// (provider name, `base_url`, `api_key_env`, compat) and NOT on the model, so we
630/// key the cache on exactly those and leak at most once per distinct
631/// combination; repeated resolves (including different models of the same custom
632/// provider) reuse the same `&'static`.
633fn user_profile_to_static(
634    name: &str,
635    user_cfg: &mermaid_domain::UserProviderConfig,
636) -> Option<&'static mermaid_model::models::ProviderProfile> {
637    use mermaid_model::models::{ProviderProfile, ReasoningExtraction, ReasoningStrategy};
638
639    let compat = user_cfg.compat.as_deref().unwrap_or("openai");
640    let base_url = user_cfg.base_url.clone().unwrap_or_default();
641    let api_key_env = user_cfg.api_key_env.clone().unwrap_or_default();
642
643    // Stable key over exactly the fields baked into the profile below. NUL
644    // separates components so distinct inputs can't collide into one key.
645    let cache_key = format!("{name}\u{0}{base_url}\u{0}{api_key_env}\u{0}{compat}");
646
647    let mut cache = PROFILE_CACHE
648        .lock()
649        .unwrap_or_else(|poisoned| poisoned.into_inner());
650    if let Some(profile) = cache.get(cache_key.as_str()) {
651        // `&'static ProviderProfile` is Copy, so this hands back the same leaked
652        // allocation as the first resolve — no new leak.
653        return Some(*profile);
654    }
655
656    let strategy = match compat {
657        "openai" => ReasoningStrategy::None,
658        "openai-effort" => ReasoningStrategy::Effort,
659        "openrouter" => ReasoningStrategy::OpenRouterShape,
660        _ => ReasoningStrategy::None,
661    };
662
663    let profile = Box::new(ProviderProfile {
664        name: Box::leak(name.to_string().into_boxed_str()),
665        base_url: Box::leak(base_url.into_boxed_str()),
666        api_key_env: Box::leak(api_key_env.into_boxed_str()),
667        key_hint: None,
668        extra_headers: &[],
669        reasoning_strategy: strategy,
670        reasoning_extraction: ReasoningExtraction::None,
671        max_tokens_param: mermaid_model::models::MaxTokensParam::MaxTokens,
672        disable_parallel_tool_calls_for: &[],
673    });
674    let leaked: &'static ProviderProfile = Box::leak(profile);
675    cache.insert(cache_key, leaked);
676    Some(leaked)
677}
678
679/// Build Cloudflare Workers AI's account-scoped OpenAI-compatible base URL. The
680/// adapter appends `/chat/completions`, so this ends at `/ai/v1`. Kept pure and
681/// separate so it's directly unit-testable (a built provider exposes no `base_url`
682/// getter).
683fn cloudflare_base_url(account_id: &str) -> String {
684    format!(
685        "https://api.cloudflare.com/client/v4/accounts/{}/ai/v1",
686        account_id.trim()
687    )
688}
689
690/// Best-effort `base_url` for *discovery* surfaces (`doctor`'s provider list,
691/// the `/models` probe) — chat requests never use this; `build_provider` has its
692/// own resolution. A user override wins for any provider; cloudflare synthesizes
693/// its account-scoped URL from `CLOUDFLARE_ACCOUNT_ID` and yields `None` when
694/// the var is unset (there is no real endpoint then — probing the registry
695/// placeholder would just guarantee a 404); everything else uses the profile's
696/// static default.
697pub(crate) fn discovery_base_url(
698    profile: &mermaid_model::models::ProviderProfile,
699    override_url: Option<String>,
700) -> Option<String> {
701    if override_url.is_some() {
702        return override_url;
703    }
704    if profile.name == "cloudflare" {
705        return require_cloudflare_account_id()
706            .ok()
707            .map(|id| cloudflare_base_url(&id));
708    }
709    Some(profile.base_url.to_string())
710}
711
712/// Resolve the Cloudflare account id from `CLOUDFLARE_ACCOUNT_ID` (trimmed,
713/// non-empty), or a clear actionable error. It isn't a secret, but it's required
714/// to construct the account-scoped Workers AI endpoint; `resolve_api_key` is
715/// reused only for its empty→None handling.
716fn require_cloudflare_account_id() -> Result<String> {
717    resolve_api_key("CLOUDFLARE_ACCOUNT_ID", None)
718        .map(|s| s.trim().to_string())
719        .filter(|s| !s.is_empty())
720        .ok_or_else(|| {
721            ModelError::Authentication(
722                "cloudflare requires env var CLOUDFLARE_ACCOUNT_ID (your Cloudflare account id) — \
723                 find it on your Cloudflare dashboard, or set [providers.cloudflare].base_url"
724                    .to_string(),
725            )
726        })
727}
728
729/// Validate a provider `base_url` before it's handed an API key. Requires
730/// http/https, and **requires https for any non-loopback, non-private host** —
731/// a typo'd or hostile `http://` endpoint would otherwise receive the bearer
732/// key in cleartext. Plain http stays allowed for loopback / RFC-1918 hosts so
733/// local model servers (Ollama, vLLM) keep working.
734fn validate_provider_base_url(url: &str) -> Result<()> {
735    let parsed = reqwest::Url::parse(url).map_err(|e| {
736        ModelError::InvalidRequest(format!("invalid provider base_url '{url}': {e}"))
737    })?;
738    match parsed.scheme() {
739        "https" => Ok(()),
740        // Plaintext http is only safe to LOOPBACK: a key sent over http to any
741        // other host — including a LAN/private one — crosses the wire in
742        // cleartext. Every caller here is a key-bearing provider; keyless local
743        // Ollama uses a separate path that doesn't validate.
744        "http"
745            if mermaid_model::utils::classify_host(parsed.host_str().unwrap_or_default())
746                .is_loopback() =>
747        {
748            Ok(())
749        },
750        "http" => Err(ModelError::InvalidRequest(format!(
751            "provider base_url '{url}' uses http:// to a non-loopback host — refusing to send the \
752             API key in cleartext. Use https, or http://localhost for a local server."
753        ))),
754        other => Err(ModelError::InvalidRequest(format!(
755            "provider base_url '{url}' has unsupported scheme '{other}' (use http or https)"
756        ))),
757    }
758}
759
760/// Resolve a *built-in* provider's `base_url`, honoring a user override but
761/// hardening it (F66). Built-in providers (anthropic, gemini, and the
762/// registry-backed OpenAI-compatible ones) ship a trusted default endpoint; a
763/// `[providers.<name>] base_url` override redirects that provider's API key to a
764/// host the user chose. The override lives in the user's own config, so we allow
765/// it — but we also:
766///   * validate the scheme via [`validate_provider_base_url`], which already
767///     requires https for any non-loopback host, so an `http://` override can't
768///     leak the key in cleartext (http stays allowed only for
769///     `localhost/127.0.0.1/::1` local dev), and
770///   * emit a one-time warning naming the host the key will be sent to, so a
771///     redirect to an unexpected host (e.g. `https://attacker.example`) is
772///     visible rather than silent.
773///
774/// With no override the trusted default is returned unchanged (no warning).
775fn resolve_overridable_base_url(
776    provider: &str,
777    override_url: Option<String>,
778    default_url: &str,
779) -> Result<String> {
780    match override_url {
781        Some(url) => {
782            validate_provider_base_url(&url)?;
783            warn_overridden_provider_host(provider, &url);
784            Ok(url)
785        },
786        None => Ok(default_url.to_string()),
787    }
788}
789
790/// Hosts already warned about (per provider) for a built-in `base_url` override,
791/// so the F66 warning fires once per process rather than on every `resolve`.
792/// Keyed by `"<provider>@<host>"`.
793static WARNED_OVERRIDE_HOSTS: std::sync::LazyLock<
794    std::sync::Mutex<std::collections::HashSet<String>>,
795> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
796
797/// Emit a one-time `tracing::warn!` (deduped per provider+host) naming the host a
798/// built-in provider's API key will be sent to because its trusted default
799/// `base_url` was overridden in config (F66).
800fn warn_overridden_provider_host(provider: &str, base_url: &str) {
801    let host = provider_host(base_url);
802    if should_warn_once(&format!("{provider}@{host}")) {
803        tracing::warn!(
804            "built-in provider '{}' base_url overridden in config: the {} API key will be sent to \
805             host '{}' instead of the trusted default endpoint",
806            provider,
807            provider,
808            host
809        );
810    }
811}
812
813/// Host portion of a `base_url` for the override warning, or `"<unknown>"` if it
814/// can't be parsed (the caller already validated it, so this is belt-and-braces).
815fn provider_host(base_url: &str) -> String {
816    reqwest::Url::parse(base_url)
817        .ok()
818        .and_then(|u| u.host_str().map(str::to_string))
819        .unwrap_or_else(|| "<unknown>".to_string())
820}
821
822/// Record `key` in the process-wide warned-set, returning `true` the first time
823/// (i.e. "warn now") and `false` thereafter. Poison-tolerant.
824fn should_warn_once(key: &str) -> bool {
825    let mut warned = WARNED_OVERRIDE_HOSTS
826        .lock()
827        .unwrap_or_else(|poisoned| poisoned.into_inner());
828    warned.insert(key.to_string())
829}
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834
835    #[test]
836    fn base_url_is_local_classifies_hosts() {
837        assert!(base_url_is_local("http://127.0.0.1:8000/v1"));
838        assert!(base_url_is_local("http://localhost:1234/v1"));
839        assert!(base_url_is_local("http://192.168.1.5:8000/v1"));
840        assert!(!base_url_is_local("https://api.openai.com/v1"));
841        assert!(!base_url_is_local("not a url"));
842    }
843
844    #[test]
845    fn merged_headers_keeps_static_profile_headers_and_user_overrides() {
846        let profile = mermaid_model::models::lookup_provider("openrouter").unwrap();
847        // No user config: the static profile headers survive (the latent-bug fix).
848        let base = merged_headers(profile, None);
849        assert_eq!(
850            base.get("X-OpenRouter-Title").map(String::as_str),
851            Some("Mermaid")
852        );
853        assert!(base.contains_key("HTTP-Referer"));
854        // User extra_headers merge on top and can override a static one.
855        let mut cfg = mermaid_domain::UserProviderConfig::default();
856        cfg.extra_headers.insert("X-Custom".into(), "v".into());
857        cfg.extra_headers
858            .insert("X-OpenRouter-Title".into(), "Override".into());
859        let merged = merged_headers(profile, Some(&cfg));
860        assert_eq!(merged.get("X-Custom").map(String::as_str), Some("v"));
861        assert_eq!(
862            merged.get("X-OpenRouter-Title").map(String::as_str),
863            Some("Override")
864        );
865        assert!(merged.contains_key("HTTP-Referer"));
866    }
867
868    #[test]
869    fn merged_headers_resolves_env_headers_and_skips_missing() {
870        let profile = mermaid_model::models::lookup_provider("openai").unwrap();
871        let mut cfg = mermaid_domain::UserProviderConfig::default();
872        cfg.env_headers
873            .insert("X-Gateway-Token".into(), "MERMAID_TEST_GW_TOKEN".into());
874        temp_env::with_var("MERMAID_TEST_GW_TOKEN", Some("secret123"), || {
875            let merged = merged_headers(profile, Some(&cfg));
876            assert_eq!(
877                merged.get("X-Gateway-Token").map(String::as_str),
878                Some("secret123")
879            );
880        });
881        temp_env::with_var("MERMAID_TEST_GW_TOKEN", None::<&str>, || {
882            assert!(!merged_headers(profile, Some(&cfg)).contains_key("X-Gateway-Token"));
883        });
884    }
885
886    #[test]
887    fn base_url_requires_https_for_remote_hosts() {
888        // Remote http is refused (would leak the bearer key in cleartext).
889        assert!(validate_provider_base_url("http://api.example.com/v1").is_err());
890        assert!(validate_provider_base_url("ftp://example.com").is_err());
891        // https anywhere and http to LOOPBACK are fine.
892        assert!(validate_provider_base_url("https://api.example.com/v1").is_ok());
893        assert!(validate_provider_base_url("http://localhost:11434/v1").is_ok());
894        assert!(validate_provider_base_url("http://127.0.0.1:8000").is_ok());
895        assert!(validate_provider_base_url("http://[::1]:8000").is_ok());
896        // #26: http to a non-loopback host (even a private LAN one) is refused —
897        // the API key would otherwise cross the wire in cleartext.
898        assert!(validate_provider_base_url("http://192.168.1.5:8080").is_err());
899        assert!(validate_provider_base_url("http://169.254.169.254").is_err());
900    }
901
902    #[test]
903    fn cloudflare_base_url_synthesizes_account_scoped_endpoint() {
904        assert_eq!(
905            cloudflare_base_url("acct123"),
906            "https://api.cloudflare.com/client/v4/accounts/acct123/ai/v1"
907        );
908        // Trims surrounding whitespace (e.g. a trailing newline from `export`).
909        assert_eq!(
910            cloudflare_base_url("  acct123\n"),
911            "https://api.cloudflare.com/client/v4/accounts/acct123/ai/v1"
912        );
913    }
914
915    #[test]
916    fn cloudflare_account_id_required_and_non_blank() {
917        // Unset → clear, actionable error naming the env var.
918        temp_env::with_var("CLOUDFLARE_ACCOUNT_ID", None::<&str>, || {
919            let err = require_cloudflare_account_id().expect_err("must error when unset");
920            assert!(format!("{err}").contains("CLOUDFLARE_ACCOUNT_ID"));
921        });
922        // Whitespace-only is treated as unset.
923        temp_env::with_var("CLOUDFLARE_ACCOUNT_ID", Some("   "), || {
924            assert!(require_cloudflare_account_id().is_err());
925        });
926        // A real value resolves, trimmed.
927        temp_env::with_var("CLOUDFLARE_ACCOUNT_ID", Some(" acct123 "), || {
928            assert_eq!(require_cloudflare_account_id().unwrap(), "acct123");
929        });
930    }
931
932    #[test]
933    fn discovery_base_url_resolves_per_provider() {
934        let cf = lookup_provider("cloudflare").expect("cloudflare is in the registry");
935        let openai = lookup_provider("openai").expect("openai is in the registry");
936        // A user override always wins, for any provider.
937        assert_eq!(
938            discovery_base_url(cf, Some("https://gw.example/v1".to_string())),
939            Some("https://gw.example/v1".to_string())
940        );
941        // Non-cloudflare: the static registry default.
942        assert_eq!(
943            discovery_base_url(openai, None),
944            Some(openai.base_url.to_string())
945        );
946        // Cloudflare synthesizes the account-scoped URL from env...
947        temp_env::with_var("CLOUDFLARE_ACCOUNT_ID", Some("acct123"), || {
948            assert_eq!(
949                discovery_base_url(cf, None),
950                Some("https://api.cloudflare.com/client/v4/accounts/acct123/ai/v1".to_string())
951            );
952        });
953        // ...and yields None when it's unset — nothing real to probe.
954        temp_env::with_var("CLOUDFLARE_ACCOUNT_ID", None::<&str>, || {
955            assert_eq!(discovery_base_url(cf, None), None);
956        });
957    }
958
959    #[tokio::test]
960    async fn cloudflare_missing_both_env_vars_is_one_combined_error() {
961        temp_env::async_with_vars(
962            [
963                ("CLOUDFLARE_ACCOUNT_ID", None::<&str>),
964                ("CLOUDFLARE_API_TOKEN", None),
965            ],
966            async {
967                let f = ProviderFactory::new(Config::default());
968                let err = match f.resolve("cloudflare/@cf/zai-org/glm-5.2").await {
969                    Ok(_) => panic!("must fail with neither env var set"),
970                    Err(e) => e,
971                };
972                let msg = format!("{err}");
973                assert!(
974                    msg.contains("CLOUDFLARE_API_TOKEN") && msg.contains("CLOUDFLARE_ACCOUNT_ID"),
975                    "one error must name both missing vars, got: {msg}"
976                );
977            },
978        )
979        .await;
980    }
981
982    use std::sync::atomic::{AtomicUsize, Ordering};
983
984    fn unique_env(prefix: &str) -> String {
985        static N: AtomicUsize = AtomicUsize::new(0);
986        format!(
987            "{}_{}_{}",
988            prefix,
989            std::process::id(),
990            N.fetch_add(1, Ordering::SeqCst)
991        )
992    }
993
994    #[test]
995    fn parse_bare_name_defaults_to_ollama() {
996        let (p, m) = parse_model_id("qwen3-coder:30b");
997        assert_eq!(p, "ollama");
998        assert_eq!(m, "qwen3-coder:30b");
999    }
1000
1001    #[test]
1002    fn parse_prefixed() {
1003        let (p, m) = parse_model_id("anthropic/claude-opus-4-7");
1004        assert_eq!(p, "anthropic");
1005        assert_eq!(m, "claude-opus-4-7");
1006    }
1007
1008    #[tokio::test]
1009    async fn meta_requires_its_documented_api_key_env() {
1010        temp_env::async_with_vars(
1011            [(
1012                crate::providers::model::meta::DEFAULT_API_KEY_ENV,
1013                None::<&str>,
1014            )],
1015            async {
1016                let factory = ProviderFactory::new(Config::default());
1017                let error = match factory.resolve("meta/muse-spark-1.1").await {
1018                    Ok(_) => panic!("Meta must require an API key"),
1019                    Err(error) => error,
1020                };
1021                assert!(
1022                    error
1023                        .to_string()
1024                        .contains(crate::providers::model::meta::DEFAULT_API_KEY_ENV)
1025                );
1026            },
1027        )
1028        .await;
1029    }
1030
1031    #[tokio::test]
1032    async fn meta_routes_to_responses_provider_with_muse_capabilities() {
1033        temp_env::async_with_vars(
1034            [(
1035                crate::providers::model::meta::DEFAULT_API_KEY_ENV,
1036                Some("test-key"),
1037            )],
1038            async {
1039                let factory = ProviderFactory::new(Config::default());
1040                let provider = factory.resolve("meta/muse-spark-1.1").await.unwrap();
1041                let capabilities = provider.capabilities();
1042                assert!(capabilities.supports_tools);
1043                assert!(capabilities.supports_vision);
1044                assert!(capabilities.emits_provider_continuation);
1045                assert_eq!(
1046                    capabilities.max_context_tokens,
1047                    Some(mermaid_model::constants::META_MUSE_SPARK_CONTEXT_WINDOW)
1048                );
1049                assert_eq!(
1050                    capabilities.max_output_tokens,
1051                    Some(mermaid_model::constants::META_MUSE_SPARK_MAX_OUTPUT_TOKENS)
1052                );
1053            },
1054        )
1055        .await;
1056    }
1057
1058    #[test]
1059    fn gemini_key_resolution_accepts_legacy_fallback() {
1060        let primary = unique_env("MERMAID_FACTORY_GEMINI_PRIMARY");
1061        let legacy = unique_env("MERMAID_FACTORY_GEMINI_LEGACY");
1062        temp_env::with_vars(
1063            [(primary.as_str(), None), (legacy.as_str(), Some("legacy"))],
1064            || {
1065                let resolved = require_key_with_fallback("gemini", &primary, &legacy)
1066                    .expect("legacy fallback should resolve");
1067                assert_eq!(resolved, "legacy");
1068            },
1069        );
1070    }
1071
1072    #[test]
1073    fn gemini_key_resolution_prefers_google_primary() {
1074        let primary = unique_env("MERMAID_FACTORY_GEMINI_PRIMARY2");
1075        let legacy = unique_env("MERMAID_FACTORY_GEMINI_LEGACY2");
1076        temp_env::with_vars(
1077            [
1078                (primary.as_str(), Some("google")),
1079                (legacy.as_str(), Some("legacy")),
1080            ],
1081            || {
1082                let resolved = require_key_with_fallback("gemini", &primary, &legacy)
1083                    .expect("primary should resolve");
1084                assert_eq!(resolved, "google");
1085            },
1086        );
1087    }
1088
1089    #[tokio::test]
1090    async fn factory_reports_unknown_provider_clearly() {
1091        let cfg = Config::default();
1092        let f = ProviderFactory::new(cfg);
1093        match f.resolve("totally-made-up/model").await {
1094            Ok(_) => panic!("expected error"),
1095            Err(e) => {
1096                let msg = format!("{e}");
1097                assert!(
1098                    msg.contains("totally-made-up") || msg.contains("Unknown provider"),
1099                    "error message: {msg}"
1100                );
1101            },
1102        }
1103    }
1104
1105    #[test]
1106    fn normalize_cache_key_lowercases_provider_only() {
1107        // #83: provider segment is lowercased; the model segment is preserved.
1108        assert_eq!(
1109            normalize_cache_key("Anthropic/Claude-X"),
1110            "anthropic/Claude-X"
1111        );
1112        assert_eq!(
1113            normalize_cache_key("anthropic/Claude-X"),
1114            "anthropic/Claude-X"
1115        );
1116        // Bare names default to the ollama provider.
1117        assert_eq!(normalize_cache_key("qwen3:30b"), "ollama/qwen3:30b");
1118    }
1119
1120    #[tokio::test]
1121    async fn resolve_is_single_flight_and_cached() {
1122        // Ollama is keyless and builds no network connection, so this resolves
1123        // offline. Two concurrent resolves with different provider casing must
1124        // return the same cached instance (#83 normalization + #84 single-flight).
1125        let f = ProviderFactory::new(Config::default());
1126        let (a, b) = tokio::join!(
1127            f.resolve("ollama/test-model"),
1128            f.resolve("Ollama/test-model"),
1129        );
1130        let a = a.expect("resolve a");
1131        let b = b.expect("resolve b");
1132        assert!(
1133            Arc::ptr_eq(&a, &b),
1134            "expected one cached provider for casing variants + concurrent resolve"
1135        );
1136    }
1137
1138    // F66: a built-in provider's base_url override is hardened — validated for
1139    // scheme and otherwise honored (the warning is a side effect we don't assert
1140    // here, but the dedup helper is tested separately below).
1141    #[test]
1142    fn builtin_base_url_override_validated_and_resolved() {
1143        // No override → trusted default, unchanged, no validation needed.
1144        assert_eq!(
1145            resolve_overridable_base_url("anthropic", None, "https://api.anthropic.com/v1")
1146                .unwrap(),
1147            "https://api.anthropic.com/v1"
1148        );
1149        // https override is honored (the key would go there — warned, not blocked).
1150        assert_eq!(
1151            resolve_overridable_base_url(
1152                "anthropic",
1153                Some("https://proxy.internal/v1".to_string()),
1154                "https://api.anthropic.com/v1",
1155            )
1156            .unwrap(),
1157            "https://proxy.internal/v1"
1158        );
1159        // http override to a NON-loopback host is refused (would leak the key in
1160        // cleartext) — the F66 https requirement.
1161        assert!(
1162            resolve_overridable_base_url(
1163                "anthropic",
1164                Some("http://attacker.example/v1".to_string()),
1165                "https://api.anthropic.com/v1",
1166            )
1167            .is_err()
1168        );
1169        // http override to loopback stays allowed for local dev (don't break it).
1170        assert!(
1171            resolve_overridable_base_url(
1172                "openai",
1173                Some("http://localhost:8080/v1".to_string()),
1174                "https://api.openai.com/v1",
1175            )
1176            .is_ok()
1177        );
1178    }
1179
1180    #[test]
1181    fn provider_host_extracts_host_or_unknown() {
1182        assert_eq!(
1183            provider_host("https://attacker.example/v1"),
1184            "attacker.example"
1185        );
1186        assert_eq!(provider_host("http://127.0.0.1:8080"), "127.0.0.1");
1187        assert_eq!(provider_host("not a url"), "<unknown>");
1188    }
1189
1190    #[test]
1191    fn override_host_warning_is_deduped() {
1192        // The F66 warning must be one-time per key: first call fires, the rest
1193        // are suppressed. Use a process-unique key so this test doesn't race the
1194        // shared warned-set with any other test.
1195        let key = unique_env("MERMAID_FACTORY_WARN_KEY");
1196        assert!(should_warn_once(&key), "first warn for a key must fire");
1197        assert!(
1198            !should_warn_once(&key),
1199            "subsequent warns for the same key must be suppressed"
1200        );
1201    }
1202
1203    // F67: identical custom-provider inputs must reuse one leaked &'static
1204    // profile, and distinct inputs must each leak exactly one — no per-model_id
1205    // growth.
1206    #[test]
1207    fn custom_profile_is_memoized_per_key() {
1208        use mermaid_domain::UserProviderConfig;
1209        let cfg = UserProviderConfig {
1210            base_url: Some("https://api.custom.test/v1".to_string()),
1211            api_key_env: Some("CUSTOM_KEY".to_string()),
1212            compat: Some("openai".to_string()),
1213            ..Default::default()
1214        };
1215        // Two resolves with identical inputs (as happens for two different models
1216        // of the same custom provider) return the SAME leaked pointer.
1217        let a = user_profile_to_static("mermaid_test_customx", &cfg).unwrap();
1218        let b = user_profile_to_static("mermaid_test_customx", &cfg).unwrap();
1219        assert!(
1220            std::ptr::eq(a, b),
1221            "identical custom-provider inputs must reuse one leaked &'static profile"
1222        );
1223        assert_eq!(a.base_url, "https://api.custom.test/v1");
1224        assert_eq!(a.api_key_env, "CUSTOM_KEY");
1225
1226        // A different base_url is a different key → a distinct leaked profile.
1227        let cfg2 = UserProviderConfig {
1228            base_url: Some("https://api.custom.test/v2".to_string()),
1229            ..cfg.clone()
1230        };
1231        let c = user_profile_to_static("mermaid_test_customx", &cfg2).unwrap();
1232        assert!(
1233            !std::ptr::eq(a, c),
1234            "a different base_url must leak a distinct profile"
1235        );
1236    }
1237}