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 crate::app::Config;
13use crate::models::config::BackendConfig;
14use crate::models::{ModelError, Result, lookup_provider};
15use crate::utils::{resolve_api_key, resolve_api_key_with_fallback};
16
17const GEMINI_API_KEY_ENV: &str = "GOOGLE_API_KEY";
18const GEMINI_LEGACY_API_KEY_ENV: &str = "GEMINI_API_KEY";
19
20/// Resolve an API key or return a clear `ModelError` when the env
21/// var isn't set. Takes `default_env` (the registry-default name)
22/// and allows no override — the factory passes the already-resolved
23/// env name.
24fn require_key(provider: &str, env_var: &str) -> Result<String> {
25    resolve_api_key(env_var, None).ok_or_else(|| {
26        ModelError::Authentication(format!("{} requires env var {}", provider, env_var))
27    })
28}
29
30fn require_key_with_fallback(
31    provider: &str,
32    env_var: &str,
33    fallback_env_var: &str,
34) -> Result<String> {
35    resolve_api_key_with_fallback(env_var, fallback_env_var, None).ok_or_else(|| {
36        ModelError::Authentication(format!(
37            "{} requires env var {} (or legacy {})",
38            provider, env_var, fallback_env_var
39        ))
40    })
41}
42
43use super::model::{
44    AnthropicProvider, GeminiProvider, ModelProvider, OllamaProvider, OpenAICompatProvider,
45};
46
47/// A lazily-built, shared provider. `OnceCell` gives single-flight construction
48/// (#84); the outer `Arc` lets `resolve` clone the cell out from under the cache
49/// lock and initialize it without holding the lock across the build.
50type ProviderCell = Arc<tokio::sync::OnceCell<Arc<dyn ModelProvider>>>;
51
52/// Per-process provider cache. Providers are expensive to construct
53/// (HTTP client, connection pool, capability lookup) so the effect
54/// runner asks for them lazily and reuses across turns.
55pub struct ProviderFactory {
56    config: Arc<Config>,
57    /// Per-key cache of built providers, keyed by normalized model id (#83). The
58    /// `Mutex` is only held to get-or-insert a cell, never across the build.
59    cache: Mutex<std::collections::HashMap<String, ProviderCell>>,
60}
61
62impl ProviderFactory {
63    pub fn new(config: Config) -> Self {
64        Self {
65            config: Arc::new(config),
66            cache: Mutex::new(std::collections::HashMap::new()),
67        }
68    }
69
70    pub fn config(&self) -> &Config {
71        &self.config
72    }
73
74    /// Resolve (or lazily construct) a provider for the given model
75    /// ID. Hits the cache on the second and subsequent calls for the
76    /// same ID.
77    pub async fn resolve(&self, model_id: &str) -> Result<Arc<dyn ModelProvider>> {
78        let key = normalize_cache_key(model_id);
79        // Get-or-insert the per-key cell under a brief lock, then initialize it
80        // exactly once outside the lock (#84). A failed build isn't cached, so a
81        // transient error can be retried on the next call.
82        let cell = {
83            let mut cache = self.cache.lock().await;
84            Arc::clone(
85                cache
86                    .entry(key)
87                    .or_insert_with(|| Arc::new(tokio::sync::OnceCell::new())),
88            )
89        };
90        let provider = cell
91            .get_or_try_init(|| async {
92                let p = build_provider(&self.config, model_id).await?;
93                Ok::<Arc<dyn ModelProvider>, ModelError>(Arc::from(p))
94            })
95            .await?;
96        Ok(Arc::clone(provider))
97    }
98}
99
100/// Build a provider for the given `model_id`:
101///   1. `ollama/<model>` → OllamaProvider.
102///   2. `anthropic/<model>` → AnthropicProvider.
103///   3. `gemini/<model>` → GeminiProvider.
104///   4. Other builtin providers (openai, openrouter, groq, …) → OpenAICompatProvider.
105///   5. User-defined `[providers.<name>]` → custom OpenAICompatProvider.
106///   6. Bare model name → OllamaProvider.
107async fn build_provider(config: &Config, model_id: &str) -> Result<Box<dyn ModelProvider>> {
108    let (provider, model_name) = parse_model_id(model_id);
109    let provider_lc = provider.to_lowercase();
110
111    // 1. Ollama (and bare names). F11: pass Arc<Config> so the wrapper
112    // can forward Ollama hardware options to the adapter.
113    if provider_lc == "ollama" {
114        let backend = ollama_backend_config(config);
115        let p = OllamaProvider::with_app_config(
116            model_name,
117            Arc::new(backend),
118            Arc::new(config.clone()),
119        )
120        .await?;
121        return Ok(Box::new(p));
122    }
123
124    // 2. Anthropic — bespoke API shape.
125    if provider_lc == "anthropic" {
126        let user_cfg = config.providers.get("anthropic");
127        let base_url = resolve_overridable_base_url(
128            "anthropic",
129            user_cfg.and_then(|c| c.base_url.clone()),
130            "https://api.anthropic.com/v1",
131        )?;
132        let api_key_env = user_cfg
133            .and_then(|c| c.api_key_env.as_deref())
134            .unwrap_or("ANTHROPIC_API_KEY");
135        let api_key = require_key("anthropic", api_key_env)?;
136        let p = AnthropicProvider::new(api_key, model_name.to_string(), base_url)?;
137        return Ok(Box::new(p));
138    }
139
140    // 3. Gemini — GCP AI Studio shape.
141    if provider_lc == "gemini" {
142        let user_cfg = config.providers.get("gemini");
143        let base_url = resolve_overridable_base_url(
144            "gemini",
145            user_cfg.and_then(|c| c.base_url.clone()),
146            "https://generativelanguage.googleapis.com/v1beta",
147        )?;
148        let api_key = match user_cfg.and_then(|c| c.api_key_env.as_deref()) {
149            Some(api_key_env) => require_key("gemini", api_key_env)?,
150            None => {
151                require_key_with_fallback("gemini", GEMINI_API_KEY_ENV, GEMINI_LEGACY_API_KEY_ENV)?
152            },
153        };
154        let p = GeminiProvider::new(api_key, model_name.to_string(), base_url)?;
155        return Ok(Box::new(p));
156    }
157
158    // 4 + 5. OpenAI-compatible registry or user-custom.
159    if let Some(profile) = lookup_provider(&provider_lc) {
160        let user_cfg = config.providers.get(&provider_lc);
161        let base_url = resolve_overridable_base_url(
162            &provider_lc,
163            user_cfg.and_then(|c| c.base_url.clone()),
164            profile.base_url,
165        )?;
166        let api_key_env = user_cfg
167            .and_then(|c| c.api_key_env.as_deref())
168            .unwrap_or(profile.api_key_env);
169        let api_key = require_key(&provider_lc, api_key_env)?;
170        let extra_headers = user_cfg
171            .map(|c| c.extra_headers.clone())
172            .unwrap_or_default();
173        let p = OpenAICompatProvider::new(
174            profile,
175            base_url,
176            api_key,
177            model_name.to_string(),
178            extra_headers,
179        )?;
180        return Ok(Box::new(p));
181    }
182
183    // User-custom: no registry entry, but the user has [providers.<name>]
184    // in config with a declared `compat` field.
185    if let Some(user_cfg) = config.providers.get(&provider_lc)
186        && let Some(profile) = user_profile_to_static(&provider_lc, user_cfg)
187    {
188        let base_url = user_cfg.base_url.clone().ok_or_else(|| {
189            ModelError::InvalidRequest(format!(
190                "custom provider '{}' requires base_url in config",
191                provider_lc
192            ))
193        })?;
194        validate_provider_base_url(&base_url)?;
195        let api_key_env = user_cfg.api_key_env.as_deref().ok_or_else(|| {
196            ModelError::InvalidRequest(format!(
197                "custom provider '{}' requires api_key_env in config",
198                provider_lc
199            ))
200        })?;
201        let api_key = require_key(&provider_lc, api_key_env)?;
202        let p = OpenAICompatProvider::new(
203            profile,
204            base_url,
205            api_key,
206            model_name.to_string(),
207            user_cfg.extra_headers.clone(),
208        )?;
209        return Ok(Box::new(p));
210    }
211
212    Err(ModelError::InvalidRequest(format!(
213        "Unknown provider '{}' (model_id: {})",
214        provider, model_id
215    )))
216}
217
218/// Cache key for a model id: the provider segment lowercased (matching
219/// `build_provider`'s own normalization) so `Anthropic/x` and `anthropic/x`
220/// resolve to one cached provider instead of building two identical ones (#83).
221fn normalize_cache_key(model_id: &str) -> String {
222    let (provider, model) = parse_model_id(model_id);
223    format!("{}/{}", provider.to_lowercase(), model)
224}
225
226/// Parse `provider/model` → `(provider, model)`. Bare strings are
227/// Ollama by convention.
228fn parse_model_id(model_id: &str) -> (String, &str) {
229    match model_id.split_once('/') {
230        Some((p, m)) => (p.to_string(), m),
231        None => ("ollama".to_string(), model_id),
232    }
233}
234
235pub(crate) fn ollama_backend_config(config: &Config) -> BackendConfig {
236    BackendConfig {
237        // Scheme-less: `normalize_url` in the adapter picks http (loopback/LAN)
238        // vs https (public) by host class (#86).
239        ollama_url: format!("{}:{}", config.ollama.host, config.ollama.port),
240        max_idle_per_host: 10,
241        timeout_secs: 10,
242        ollama_autostart: config.ollama.auto_start,
243    }
244}
245
246/// Process-wide memo of leaked custom-provider profiles, keyed by the
247/// profile-determining inputs (see [`user_profile_to_static`]). Guarantees each
248/// distinct custom provider leaks its `&'static ProviderProfile` at most once.
249static PROFILE_CACHE: std::sync::LazyLock<
250    std::sync::Mutex<std::collections::HashMap<String, &'static crate::models::ProviderProfile>>,
251> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
252
253/// Convert a user-defined `[providers.<name>]` entry into a `&'static
254/// ProviderProfile`. `ProviderProfile`'s fields are `&'static` (tied to the
255/// registry constants), so a custom provider needs a leaked, owned copy to
256/// participate without redesigning the profile type.
257///
258/// The leak is memoized (F67). `build_provider` runs once per distinct *model
259/// id*, so without a cache this leaked a fresh profile for every custom
260/// `provider/model` pair — a permanent, per-distinct-model_id growth, not the
261/// "0-3" the old comment claimed. The profile's content depends only on
262/// (provider name, base_url, api_key_env, compat) and NOT on the model, so we
263/// key the cache on exactly those and leak at most once per distinct
264/// combination; repeated resolves (including different models of the same custom
265/// provider) reuse the same `&'static`.
266fn user_profile_to_static(
267    name: &str,
268    user_cfg: &crate::app::UserProviderConfig,
269) -> Option<&'static crate::models::ProviderProfile> {
270    use crate::models::{ProviderProfile, ReasoningExtraction, ReasoningStrategy};
271
272    let compat = user_cfg.compat.as_deref().unwrap_or("openai");
273    let base_url = user_cfg.base_url.clone().unwrap_or_default();
274    let api_key_env = user_cfg.api_key_env.clone().unwrap_or_default();
275
276    // Stable key over exactly the fields baked into the profile below. NUL
277    // separates components so distinct inputs can't collide into one key.
278    let cache_key = format!("{name}\u{0}{base_url}\u{0}{api_key_env}\u{0}{compat}");
279
280    let mut cache = PROFILE_CACHE
281        .lock()
282        .unwrap_or_else(|poisoned| poisoned.into_inner());
283    if let Some(profile) = cache.get(cache_key.as_str()) {
284        // `&'static ProviderProfile` is Copy, so this hands back the same leaked
285        // allocation as the first resolve — no new leak.
286        return Some(*profile);
287    }
288
289    let strategy = match compat {
290        "openai" => ReasoningStrategy::None,
291        "openai-effort" => ReasoningStrategy::Effort,
292        "openrouter" => ReasoningStrategy::OpenRouterShape,
293        _ => ReasoningStrategy::None,
294    };
295
296    let profile = Box::new(ProviderProfile {
297        name: Box::leak(name.to_string().into_boxed_str()),
298        base_url: Box::leak(base_url.into_boxed_str()),
299        api_key_env: Box::leak(api_key_env.into_boxed_str()),
300        extra_headers: &[],
301        reasoning_strategy: strategy,
302        reasoning_extraction: ReasoningExtraction::None,
303        max_tokens_param: crate::models::MaxTokensParam::MaxTokens,
304        disable_parallel_tool_calls_for: &[],
305    });
306    let leaked: &'static ProviderProfile = Box::leak(profile);
307    cache.insert(cache_key, leaked);
308    Some(leaked)
309}
310
311/// Validate a provider `base_url` before it's handed an API key. Requires
312/// http/https, and **requires https for any non-loopback, non-private host** —
313/// a typo'd or hostile `http://` endpoint would otherwise receive the bearer
314/// key in cleartext. Plain http stays allowed for loopback / RFC-1918 hosts so
315/// local model servers (Ollama, vLLM) keep working.
316fn validate_provider_base_url(url: &str) -> Result<()> {
317    let parsed = reqwest::Url::parse(url).map_err(|e| {
318        ModelError::InvalidRequest(format!("invalid provider base_url '{url}': {e}"))
319    })?;
320    match parsed.scheme() {
321        "https" => Ok(()),
322        // Plaintext http is only safe to LOOPBACK: a key sent over http to any
323        // other host — including a LAN/private one — crosses the wire in
324        // cleartext. Every caller here is a key-bearing provider; keyless local
325        // Ollama uses a separate path that doesn't validate.
326        "http"
327            if crate::utils::classify_host(parsed.host_str().unwrap_or_default()).is_loopback() =>
328        {
329            Ok(())
330        },
331        "http" => Err(ModelError::InvalidRequest(format!(
332            "provider base_url '{url}' uses http:// to a non-loopback host — refusing to send the \
333             API key in cleartext. Use https, or http://localhost for a local server."
334        ))),
335        other => Err(ModelError::InvalidRequest(format!(
336            "provider base_url '{url}' has unsupported scheme '{other}' (use http or https)"
337        ))),
338    }
339}
340
341/// Resolve a *built-in* provider's `base_url`, honoring a user override but
342/// hardening it (F66). Built-in providers (anthropic, gemini, and the
343/// registry-backed OpenAI-compatible ones) ship a trusted default endpoint; a
344/// `[providers.<name>] base_url` override redirects that provider's API key to a
345/// host the user chose. The override lives in the user's own config, so we allow
346/// it — but we also:
347///   * validate the scheme via [`validate_provider_base_url`], which already
348///     requires https for any non-loopback host, so an `http://` override can't
349///     leak the key in cleartext (http stays allowed only for
350///     localhost/127.0.0.1/::1 local dev), and
351///   * emit a one-time warning naming the host the key will be sent to, so a
352///     redirect to an unexpected host (e.g. `https://attacker.example`) is
353///     visible rather than silent.
354///
355/// With no override the trusted default is returned unchanged (no warning).
356fn resolve_overridable_base_url(
357    provider: &str,
358    override_url: Option<String>,
359    default_url: &str,
360) -> Result<String> {
361    match override_url {
362        Some(url) => {
363            validate_provider_base_url(&url)?;
364            warn_overridden_provider_host(provider, &url);
365            Ok(url)
366        },
367        None => Ok(default_url.to_string()),
368    }
369}
370
371/// Hosts already warned about (per provider) for a built-in `base_url` override,
372/// so the F66 warning fires once per process rather than on every `resolve`.
373/// Keyed by `"<provider>@<host>"`.
374static WARNED_OVERRIDE_HOSTS: std::sync::LazyLock<
375    std::sync::Mutex<std::collections::HashSet<String>>,
376> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
377
378/// Emit a one-time `tracing::warn!` (deduped per provider+host) naming the host a
379/// built-in provider's API key will be sent to because its trusted default
380/// `base_url` was overridden in config (F66).
381fn warn_overridden_provider_host(provider: &str, base_url: &str) {
382    let host = provider_host(base_url);
383    if should_warn_once(&format!("{provider}@{host}")) {
384        tracing::warn!(
385            "built-in provider '{}' base_url overridden in config: the {} API key will be sent to \
386             host '{}' instead of the trusted default endpoint",
387            provider,
388            provider,
389            host
390        );
391    }
392}
393
394/// Host portion of a `base_url` for the override warning, or `"<unknown>"` if it
395/// can't be parsed (the caller already validated it, so this is belt-and-braces).
396fn provider_host(base_url: &str) -> String {
397    reqwest::Url::parse(base_url)
398        .ok()
399        .and_then(|u| u.host_str().map(str::to_string))
400        .unwrap_or_else(|| "<unknown>".to_string())
401}
402
403/// Record `key` in the process-wide warned-set, returning `true` the first time
404/// (i.e. "warn now") and `false` thereafter. Poison-tolerant.
405fn should_warn_once(key: &str) -> bool {
406    let mut warned = WARNED_OVERRIDE_HOSTS
407        .lock()
408        .unwrap_or_else(|poisoned| poisoned.into_inner());
409    warned.insert(key.to_string())
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn base_url_requires_https_for_remote_hosts() {
418        // Remote http is refused (would leak the bearer key in cleartext).
419        assert!(validate_provider_base_url("http://api.example.com/v1").is_err());
420        assert!(validate_provider_base_url("ftp://example.com").is_err());
421        // https anywhere and http to LOOPBACK are fine.
422        assert!(validate_provider_base_url("https://api.example.com/v1").is_ok());
423        assert!(validate_provider_base_url("http://localhost:11434/v1").is_ok());
424        assert!(validate_provider_base_url("http://127.0.0.1:8000").is_ok());
425        assert!(validate_provider_base_url("http://[::1]:8000").is_ok());
426        // #26: http to a non-loopback host (even a private LAN one) is refused —
427        // the API key would otherwise cross the wire in cleartext.
428        assert!(validate_provider_base_url("http://192.168.1.5:8080").is_err());
429        assert!(validate_provider_base_url("http://169.254.169.254").is_err());
430    }
431    use std::sync::atomic::{AtomicUsize, Ordering};
432
433    fn unique_env(prefix: &str) -> String {
434        static N: AtomicUsize = AtomicUsize::new(0);
435        format!(
436            "{}_{}_{}",
437            prefix,
438            std::process::id(),
439            N.fetch_add(1, Ordering::SeqCst)
440        )
441    }
442
443    #[test]
444    fn parse_bare_name_defaults_to_ollama() {
445        let (p, m) = parse_model_id("qwen3-coder:30b");
446        assert_eq!(p, "ollama");
447        assert_eq!(m, "qwen3-coder:30b");
448    }
449
450    #[test]
451    fn parse_prefixed() {
452        let (p, m) = parse_model_id("anthropic/claude-opus-4-7");
453        assert_eq!(p, "anthropic");
454        assert_eq!(m, "claude-opus-4-7");
455    }
456
457    #[test]
458    fn gemini_key_resolution_accepts_legacy_fallback() {
459        let primary = unique_env("MERMAID_FACTORY_GEMINI_PRIMARY");
460        let legacy = unique_env("MERMAID_FACTORY_GEMINI_LEGACY");
461        temp_env::with_vars(
462            [(primary.as_str(), None), (legacy.as_str(), Some("legacy"))],
463            || {
464                let resolved = require_key_with_fallback("gemini", &primary, &legacy)
465                    .expect("legacy fallback should resolve");
466                assert_eq!(resolved, "legacy");
467            },
468        );
469    }
470
471    #[test]
472    fn gemini_key_resolution_prefers_google_primary() {
473        let primary = unique_env("MERMAID_FACTORY_GEMINI_PRIMARY2");
474        let legacy = unique_env("MERMAID_FACTORY_GEMINI_LEGACY2");
475        temp_env::with_vars(
476            [
477                (primary.as_str(), Some("google")),
478                (legacy.as_str(), Some("legacy")),
479            ],
480            || {
481                let resolved = require_key_with_fallback("gemini", &primary, &legacy)
482                    .expect("primary should resolve");
483                assert_eq!(resolved, "google");
484            },
485        );
486    }
487
488    #[tokio::test]
489    async fn factory_reports_unknown_provider_clearly() {
490        let cfg = Config::default();
491        let f = ProviderFactory::new(cfg);
492        match f.resolve("totally-made-up/model").await {
493            Ok(_) => panic!("expected error"),
494            Err(e) => {
495                let msg = format!("{}", e);
496                assert!(
497                    msg.contains("totally-made-up") || msg.contains("Unknown provider"),
498                    "error message: {}",
499                    msg
500                );
501            },
502        }
503    }
504
505    #[test]
506    fn normalize_cache_key_lowercases_provider_only() {
507        // #83: provider segment is lowercased; the model segment is preserved.
508        assert_eq!(
509            normalize_cache_key("Anthropic/Claude-X"),
510            "anthropic/Claude-X"
511        );
512        assert_eq!(
513            normalize_cache_key("anthropic/Claude-X"),
514            "anthropic/Claude-X"
515        );
516        // Bare names default to the ollama provider.
517        assert_eq!(normalize_cache_key("qwen3:30b"), "ollama/qwen3:30b");
518    }
519
520    #[tokio::test]
521    async fn resolve_is_single_flight_and_cached() {
522        // Ollama is keyless and builds no network connection, so this resolves
523        // offline. Two concurrent resolves with different provider casing must
524        // return the same cached instance (#83 normalization + #84 single-flight).
525        let f = ProviderFactory::new(Config::default());
526        let (a, b) = tokio::join!(
527            f.resolve("ollama/test-model"),
528            f.resolve("Ollama/test-model"),
529        );
530        let a = a.expect("resolve a");
531        let b = b.expect("resolve b");
532        assert!(
533            Arc::ptr_eq(&a, &b),
534            "expected one cached provider for casing variants + concurrent resolve"
535        );
536    }
537
538    // F66: a built-in provider's base_url override is hardened — validated for
539    // scheme and otherwise honored (the warning is a side effect we don't assert
540    // here, but the dedup helper is tested separately below).
541    #[test]
542    fn builtin_base_url_override_validated_and_resolved() {
543        // No override → trusted default, unchanged, no validation needed.
544        assert_eq!(
545            resolve_overridable_base_url("anthropic", None, "https://api.anthropic.com/v1")
546                .unwrap(),
547            "https://api.anthropic.com/v1"
548        );
549        // https override is honored (the key would go there — warned, not blocked).
550        assert_eq!(
551            resolve_overridable_base_url(
552                "anthropic",
553                Some("https://proxy.internal/v1".to_string()),
554                "https://api.anthropic.com/v1",
555            )
556            .unwrap(),
557            "https://proxy.internal/v1"
558        );
559        // http override to a NON-loopback host is refused (would leak the key in
560        // cleartext) — the F66 https requirement.
561        assert!(
562            resolve_overridable_base_url(
563                "anthropic",
564                Some("http://attacker.example/v1".to_string()),
565                "https://api.anthropic.com/v1",
566            )
567            .is_err()
568        );
569        // http override to loopback stays allowed for local dev (don't break it).
570        assert!(
571            resolve_overridable_base_url(
572                "openai",
573                Some("http://localhost:8080/v1".to_string()),
574                "https://api.openai.com/v1",
575            )
576            .is_ok()
577        );
578    }
579
580    #[test]
581    fn provider_host_extracts_host_or_unknown() {
582        assert_eq!(
583            provider_host("https://attacker.example/v1"),
584            "attacker.example"
585        );
586        assert_eq!(provider_host("http://127.0.0.1:8080"), "127.0.0.1");
587        assert_eq!(provider_host("not a url"), "<unknown>");
588    }
589
590    #[test]
591    fn override_host_warning_is_deduped() {
592        // The F66 warning must be one-time per key: first call fires, the rest
593        // are suppressed. Use a process-unique key so this test doesn't race the
594        // shared warned-set with any other test.
595        let key = unique_env("MERMAID_FACTORY_WARN_KEY");
596        assert!(should_warn_once(&key), "first warn for a key must fire");
597        assert!(
598            !should_warn_once(&key),
599            "subsequent warns for the same key must be suppressed"
600        );
601    }
602
603    // F67: identical custom-provider inputs must reuse one leaked &'static
604    // profile, and distinct inputs must each leak exactly one — no per-model_id
605    // growth.
606    #[test]
607    fn custom_profile_is_memoized_per_key() {
608        use crate::app::UserProviderConfig;
609        let cfg = UserProviderConfig {
610            base_url: Some("https://api.custom.test/v1".to_string()),
611            api_key_env: Some("CUSTOM_KEY".to_string()),
612            compat: Some("openai".to_string()),
613            ..Default::default()
614        };
615        // Two resolves with identical inputs (as happens for two different models
616        // of the same custom provider) return the SAME leaked pointer.
617        let a = user_profile_to_static("mermaid_test_customx", &cfg).unwrap();
618        let b = user_profile_to_static("mermaid_test_customx", &cfg).unwrap();
619        assert!(
620            std::ptr::eq(a, b),
621            "identical custom-provider inputs must reuse one leaked &'static profile"
622        );
623        assert_eq!(a.base_url, "https://api.custom.test/v1");
624        assert_eq!(a.api_key_env, "CUSTOM_KEY");
625
626        // A different base_url is a different key → a distinct leaked profile.
627        let cfg2 = UserProviderConfig {
628            base_url: Some("https://api.custom.test/v2".to_string()),
629            ..cfg.clone()
630        };
631        let c = user_profile_to_static("mermaid_test_customx", &cfg2).unwrap();
632        assert!(
633            !std::ptr::eq(a, c),
634            "a different base_url must leak a distinct profile"
635        );
636    }
637}