Skip to main content

mermaid_cli/models/
backend.rs

1//! Model factory - creates model instances from identifiers
2//!
3//! Parses model identifiers like "ollama/llama3" or "groq/qwen-qwq-32b"
4//! and creates the appropriate adapter implementing the Model trait.
5//! Also provides static convenience methods for common operations.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9use std::time::Duration;
10
11use super::adapters::openai_compat::OpenAICompatAdapter;
12use super::config::BackendConfig;
13use super::error::{BackendError, ModelError, Result};
14use super::providers::{
15    CompatStyle, ProviderProfile, ReasoningExtraction, ReasoningStrategy, lookup_provider,
16};
17use super::traits::Model;
18use crate::app::Config;
19use crate::utils::resolve_api_key;
20
21/// Model factory - creates model instances.
22///
23/// Holds the resolved Ollama `BackendConfig` for the existing OllamaAdapter
24/// path AND an optional `Arc<Config>` for the OpenAI-compatible dispatch
25/// path (which needs `config.providers` to resolve user overrides + custom
26/// providers). Old callers that go through `ModelFactory::new(BackendConfig)`
27/// without supplying a `Config` still work — they just can't dispatch
28/// non-Ollama providers and will get a clear error.
29pub struct ModelFactory {
30    config: Arc<BackendConfig>,
31    user_config: Option<Arc<Config>>,
32}
33
34impl ModelFactory {
35    /// Create a new model factory with explicit backend config.
36    /// (Ollama-only path; non-Ollama dispatch will error with a clear
37    /// message until the factory is given a `Config` via `from_config`.)
38    pub fn new(config: BackendConfig) -> Self {
39        Self {
40            config: Arc::new(config),
41            user_config: None,
42        }
43    }
44
45    /// Create a factory from app::Config — preserves the user's provider
46    /// configuration so OpenAI-compat dispatch can find overrides + custom
47    /// providers later.
48    pub fn from_config(config: &Config) -> Self {
49        Self {
50            config: Arc::new(Self::config_to_backend_config(config)),
51            user_config: Some(Arc::new(config.clone())),
52        }
53    }
54
55    /// Create a model from a full identifier (e.g., "ollama/llama3" or
56    /// "groq/qwen-qwq-32b"). Provider name resolution order:
57    ///   1. `"ollama"` → existing OllamaAdapter.
58    ///   2. Built-in OpenAI-compat registry (`groq`, `openai`, etc.) →
59    ///      OpenAICompatAdapter, with optional user overrides applied.
60    ///   3. User-defined provider in `config.providers.<name>` → custom
61    ///      OpenAICompatAdapter built from the user's `compat = "..."`
62    ///      profile spec.
63    ///   4. Otherwise: clear "unknown provider" error.
64    pub async fn create_model(&self, model_id: &str) -> Result<Box<dyn Model>> {
65        let (provider, model_name) = parse_model_id(model_id);
66        let provider_lc = provider.to_lowercase();
67
68        // Path 1: Ollama (existing behavior, unchanged).
69        if provider_lc == "ollama" {
70            use super::adapters::ollama::OllamaAdapter;
71            let adapter = OllamaAdapter::new(model_name, self.config.clone()).await?;
72            return Ok(Box::new(adapter));
73        }
74
75        // Paths 2 + 3 + 4 require a user config to look up provider overrides
76        // and custom-provider definitions.
77        let user_config = self.user_config.as_ref().ok_or_else(|| {
78            ModelError::InvalidRequest(format!(
79                "Provider '{}' requires app config; use ModelFactory::from_config",
80                provider
81            ))
82        })?;
83
84        // Path 2: Anthropic — bespoke Messages API (not OpenAI-compat).
85        if provider_lc == "anthropic" {
86            use super::adapters::anthropic::AnthropicAdapter;
87            let user_cfg = user_config.providers.get("anthropic");
88            let base_url = user_cfg
89                .and_then(|c| c.base_url.clone())
90                .unwrap_or_else(|| "https://api.anthropic.com/v1".to_string());
91            let api_key = resolve_api_key(
92                "ANTHROPIC_API_KEY",
93                user_cfg.and_then(|c| c.api_key_env.as_deref()),
94            )
95            .ok_or_else(|| {
96                ModelError::Authentication(
97                    "ANTHROPIC_API_KEY not set (or set [providers.anthropic].api_key_env to a \
98                     custom env var)"
99                        .to_string(),
100                )
101            })?;
102            let adapter = AnthropicAdapter::new(api_key, model_name.to_string(), base_url)?;
103            return Ok(Box::new(adapter));
104        }
105
106        // Path 3: Gemini — bespoke generateContent API (not OpenAI-compat).
107        if provider_lc == "gemini" {
108            use super::adapters::gemini::GeminiAdapter;
109            let user_cfg = user_config.providers.get("gemini");
110            let base_url = user_cfg
111                .and_then(|c| c.base_url.clone())
112                .unwrap_or_else(|| "https://generativelanguage.googleapis.com/v1beta".to_string());
113            let api_key = resolve_api_key(
114                "GOOGLE_API_KEY",
115                user_cfg.and_then(|c| c.api_key_env.as_deref()),
116            )
117            .ok_or_else(|| {
118                ModelError::Authentication(
119                    "GOOGLE_API_KEY not set (or set [providers.gemini].api_key_env to a custom \
120                     env var)"
121                        .to_string(),
122                )
123            })?;
124            let adapter = GeminiAdapter::new(api_key, model_name.to_string(), base_url)?;
125            return Ok(Box::new(adapter));
126        }
127
128        // Path 4: built-in OpenAI-compat registry.
129        if let Some(profile) = lookup_provider(&provider_lc) {
130            let user_cfg = user_config.providers.get(&provider_lc);
131            let base_url = user_cfg
132                .and_then(|c| c.base_url.clone())
133                .unwrap_or_else(|| profile.base_url.to_string());
134            let api_key = resolve_api_key(
135                profile.api_key_env,
136                user_cfg.and_then(|c| c.api_key_env.as_deref()),
137            )
138            .ok_or_else(|| {
139                ModelError::Authentication(format!(
140                    "{} API key not set (env: {})",
141                    provider_lc, profile.api_key_env
142                ))
143            })?;
144            let mut headers: HashMap<String, String> = profile
145                .extra_headers
146                .iter()
147                .map(|(k, v)| (k.to_string(), v.to_string()))
148                .collect();
149            if let Some(c) = user_cfg {
150                for (k, v) in &c.extra_headers {
151                    headers.insert(k.clone(), v.clone());
152                }
153            }
154            let adapter = OpenAICompatAdapter::new(
155                profile,
156                base_url,
157                api_key,
158                model_name.to_string(),
159                headers,
160            )?;
161            return Ok(Box::new(adapter));
162        }
163
164        // Path 5: fully custom provider (must declare `compat = "..."` +
165        // base_url + api_key_env).
166        if let Some(user_cfg) = user_config.providers.get(&provider_lc) {
167            let base_url = user_cfg.base_url.clone().ok_or_else(|| {
168                ModelError::Config(super::error::ConfigError::MissingRequired(format!(
169                    "providers.{}.base_url",
170                    provider_lc
171                )))
172            })?;
173            let api_key_env = user_cfg.api_key_env.as_deref().ok_or_else(|| {
174                ModelError::Config(super::error::ConfigError::MissingRequired(format!(
175                    "providers.{}.api_key_env",
176                    provider_lc
177                )))
178            })?;
179            let api_key = resolve_api_key(api_key_env, None).ok_or_else(|| {
180                ModelError::Authentication(format!(
181                    "{} API key not set (env: {})",
182                    provider_lc, api_key_env
183                ))
184            })?;
185            let compat_style: CompatStyle = match user_cfg.compat.as_deref() {
186                Some(s) => {
187                    serde_json::from_value::<CompatStyle>(serde_json::Value::String(s.to_string()))
188                        .map_err(|_| {
189                            ModelError::Config(super::error::ConfigError::InvalidValue {
190                                field: format!("providers.{}.compat", provider_lc),
191                                value: s.to_string(),
192                                reason: "must be one of: openai, openai-effort, openrouter"
193                                    .to_string(),
194                            })
195                        })?
196                },
197                None => CompatStyle::Openai,
198            };
199            // Synthesize a profile from the user's compat declaration.
200            // Leak the strings to give them 'static lifetime — the
201            // ProviderProfile lives for the rest of the process anyway,
202            // and adapter creation happens infrequently.
203            let profile = synthesize_custom_profile(&provider_lc, compat_style);
204            let headers: HashMap<String, String> = user_cfg.extra_headers.clone();
205            let adapter = OpenAICompatAdapter::new(
206                profile,
207                base_url,
208                api_key,
209                model_name.to_string(),
210                headers,
211            )?;
212            return Ok(Box::new(adapter));
213        }
214
215        Err(ModelError::InvalidRequest(format!(
216            "Unknown provider: '{}'. Built-in providers: ollama, anthropic, gemini, openai, \
217             groq, openrouter, cerebras, deepinfra, together. Add custom providers to \
218             ~/.config/mermaid/config.toml under [providers.<name>].",
219            provider
220        )))
221    }
222
223    // --- Static convenience API ---
224
225    /// Create a model instance from a model identifier with optional app config.
226    ///
227    /// Format examples:
228    /// - "ollama/qwen3-coder:30b" - Explicit Ollama provider
229    /// - "qwen3-coder:30b" - Defaults to Ollama
230    /// - "kimi-k2.5:cloud" - Ollama cloud model
231    /// - "groq/qwen-qwq-32b" - Groq via OpenAICompatAdapter (requires GROQ_API_KEY)
232    /// - "openai/gpt-5-mini" - OpenAI via OpenAICompatAdapter (requires OPENAI_API_KEY)
233    pub async fn create(model_id: &str, config: Option<&Config>) -> Result<Box<dyn Model>> {
234        let factory = match config {
235            Some(c) => Self::from_config(c),
236            None => Self::new(BackendConfig::default()),
237        };
238        factory.create_model(model_id).await
239    }
240
241    /// List all models from all available providers.
242    pub async fn list_all_models(config: &Config) -> Result<Vec<String>> {
243        let factory = Self::from_config(config);
244        let providers = factory.available_providers_impl().await;
245
246        let mut all_models = Vec::new();
247        for provider in providers {
248            if let Ok(models) = factory.list_models(&provider).await {
249                for model_name in models {
250                    all_models.push(format!("{}/{}", provider, model_name));
251                }
252            }
253        }
254
255        all_models.sort();
256        Ok(all_models)
257    }
258
259    /// Get list of available providers (static convenience)
260    pub async fn available_providers() -> Vec<String> {
261        let factory = Self::new(BackendConfig::default());
262        factory.available_providers_impl().await
263    }
264
265    /// List available providers using this factory's config (instance method)
266    pub async fn available_providers_pub(&self) -> Vec<String> {
267        self.available_providers_impl().await
268    }
269
270    // --- Instance methods ---
271
272    /// List available providers with a fast single-shot health check.
273    ///
274    /// Ollama gets a `GET /api/tags` probe with a 2s timeout. OpenAI-compat
275    /// providers configured by the user are listed if their API key
276    /// resolves (no network probe — would slow `mermaid status` and many
277    /// providers don't expose a public `/health`).
278    async fn available_providers_impl(&self) -> Vec<String> {
279        let mut providers = Vec::new();
280
281        // Quick Ollama check: single GET with 2s timeout, no retries.
282        let url = format!(
283            "{}/api/tags",
284            self.config.ollama_url.trim().trim_end_matches('/')
285        );
286        if let Ok(client) = reqwest::Client::builder()
287            .timeout(Duration::from_secs(2))
288            .build()
289            && let Ok(resp) = client.get(&url).send().await
290            && resp.status().is_success()
291        {
292            providers.push("ollama".to_string());
293        }
294
295        // Anthropic: listed if ANTHROPIC_API_KEY resolves (or
296        // [providers.anthropic].api_key_env override). No network probe —
297        // Anthropic has no public health endpoint and the call would slow
298        // `mermaid status`.
299        if let Some(user_config) = self.user_config.as_ref() {
300            let user_cfg = user_config.providers.get("anthropic");
301            if resolve_api_key(
302                "ANTHROPIC_API_KEY",
303                user_cfg.and_then(|c| c.api_key_env.as_deref()),
304            )
305            .is_some()
306            {
307                providers.push("anthropic".to_string());
308            }
309        }
310
311        // Gemini: listed if GOOGLE_API_KEY resolves (or override). Same
312        // no-network-probe rationale as Anthropic — keeps `mermaid status`
313        // snappy.
314        if let Some(user_config) = self.user_config.as_ref() {
315            let user_cfg = user_config.providers.get("gemini");
316            if resolve_api_key(
317                "GOOGLE_API_KEY",
318                user_cfg.and_then(|c| c.api_key_env.as_deref()),
319            )
320            .is_some()
321            {
322                providers.push("gemini".to_string());
323            }
324        }
325
326        // OpenAI-compat providers: include any built-in or user-defined
327        // provider whose API key resolves. No network probe.
328        if let Some(user_config) = self.user_config.as_ref() {
329            // Built-in providers configured via env var or [providers.<name>].
330            for profile in super::providers::REGISTRY {
331                let user_cfg = user_config.providers.get(profile.name);
332                let api_key_present = resolve_api_key(
333                    profile.api_key_env,
334                    user_cfg.and_then(|c| c.api_key_env.as_deref()),
335                )
336                .is_some();
337                if api_key_present {
338                    providers.push(profile.name.to_string());
339                }
340            }
341            // Custom providers (not in registry) — listed if they have
342            // both base_url and api_key_env declared and the env resolves.
343            for (name, cfg) in &user_config.providers {
344                if lookup_provider(name).is_some() {
345                    continue; // already handled above
346                }
347                if let (Some(_url), Some(env)) = (&cfg.base_url, cfg.api_key_env.as_deref())
348                    && resolve_api_key(env, None).is_some()
349                {
350                    providers.push(name.clone());
351                }
352            }
353        }
354
355        providers
356    }
357
358    /// List all models from a provider without creating a model instance.
359    pub async fn list_models(&self, provider: &str) -> Result<Vec<String>> {
360        let lc = provider.to_lowercase();
361        // Anthropic has no public model-listing endpoint. Return a curated
362        // list of supported Claude models so `mermaid list` is useful.
363        // Users can still pass any Claude model name — the adapter doesn't
364        // validate against this list.
365        if lc == "anthropic" {
366            return Ok(vec![
367                "claude-opus-4-7".to_string(),
368                "claude-sonnet-4-6".to_string(),
369                "claude-opus-4-6".to_string(),
370                "claude-sonnet-4-5".to_string(),
371                "claude-opus-4-5".to_string(),
372                "claude-haiku-4-5".to_string(),
373            ]);
374        }
375        // Gemini: same rationale as Anthropic — curated list keeps
376        // `mermaid list` snappy. Adapter accepts any model name; this
377        // surfaces the recommended set.
378        //
379        // As of April 2026, Gemini 3 is preview-only — the bare IDs
380        // `gemini-3-pro`, `gemini-3-flash`, `gemini-3-flash-lite` are
381        // NOT valid and the original `gemini-3-pro-preview` was
382        // shut down 2026-03-09. Current preview IDs are listed below,
383        // plus the `-latest` aliases which repoint as Google ships
384        // new previews. Re-verify against ai.google.dev quarterly.
385        if lc == "gemini" {
386            return Ok(vec![
387                "gemini-pro-latest".to_string(),
388                "gemini-flash-latest".to_string(),
389                "gemini-3.1-pro-preview".to_string(),
390                "gemini-3-flash-preview".to_string(),
391                "gemini-3.1-flash-lite-preview".to_string(),
392                "gemini-2.5-pro".to_string(),
393                "gemini-2.5-flash".to_string(),
394                "gemini-2.5-flash-lite".to_string(),
395            ]);
396        }
397        if lc == "ollama" {
398            let url = format!(
399                "{}/api/tags",
400                self.config.ollama_url.trim().trim_end_matches('/')
401            );
402            let client = reqwest::Client::builder()
403                .timeout(Duration::from_secs(5))
404                .build()
405                .map_err(|e| {
406                    ModelError::Backend(BackendError::ConnectionFailed {
407                        backend: "ollama".to_string(),
408                        url: url.clone(),
409                        reason: e.to_string(),
410                    })
411                })?;
412            let response = client.get(&url).send().await.map_err(|e| {
413                ModelError::Backend(BackendError::ConnectionFailed {
414                    backend: "ollama".to_string(),
415                    url: url.clone(),
416                    reason: e.to_string(),
417                })
418            })?;
419            if !response.status().is_success() {
420                return Err(ModelError::Backend(BackendError::HttpError {
421                    status: response.status().as_u16(),
422                    message: "Failed to list models".to_string(),
423                }));
424            }
425            let tags: super::adapters::ollama::OllamaTagsResponse =
426                response.json().await.map_err(|e| ModelError::ParseError {
427                    message: format!("Failed to parse tags response: {}", e),
428                    raw: None,
429                })?;
430            return Ok(tags.models.into_iter().map(|m| m.name).collect());
431        }
432
433        // OpenAI-compat providers: build the adapter and ask it. The
434        // adapter handles the `/models` endpoint and the providers that
435        // 404 it.
436        //
437        // Use a synthetic model name ("_") because list_models() doesn't
438        // need a specific model — it just needs the URL + key + headers.
439        let synthetic_model_id = format!("{}/_", lc);
440        let adapter = self.create_model(&synthetic_model_id).await?;
441        adapter.list_models().await
442    }
443
444    /// Convert app::Config to BackendConfig (Ollama-specific).
445    fn config_to_backend_config(config: &Config) -> BackendConfig {
446        let ollama_url = format!("http://{}:{}", config.ollama.host, config.ollama.port);
447        BackendConfig {
448            ollama_url,
449            timeout_secs: 10,
450            max_idle_per_host: 10,
451        }
452    }
453}
454
455/// Synthesize a `'static` `ProviderProfile` for a fully custom provider
456/// the user declared in config.toml. We leak the name/url strings into
457/// `'static` because the profile is reused for the life of the process
458/// (model creation runs at most a few times per session). For
459/// `extra_headers` we use the empty slice — user headers come through
460/// the adapter's per-instance `extra_headers` HashMap, not the profile.
461fn synthesize_custom_profile(name: &str, compat: CompatStyle) -> &'static ProviderProfile {
462    // Box::leak gives us a 'static reference with no copy. Names are
463    // small (typical: 5-15 bytes) and one-per-custom-provider, so the
464    // leak is bounded by the number of custom providers in config.toml.
465    let leaked_name: &'static str = Box::leak(name.to_string().into_boxed_str());
466    let extraction = match compat {
467        CompatStyle::Openai => ReasoningExtraction::None,
468        CompatStyle::OpenaiEffort => ReasoningExtraction::None,
469        CompatStyle::Openrouter => ReasoningExtraction::DeltaContentField("reasoning"),
470    };
471    let profile = ProviderProfile {
472        name: leaked_name,
473        // base_url is overridden at adapter-construction time from the
474        // user_cfg, so the placeholder here is only for `name()`-style
475        // diagnostics. Use the leaked-name to make it discoverable.
476        base_url: "user-defined",
477        api_key_env: "user-defined",
478        extra_headers: &[],
479        reasoning_strategy: match compat {
480            CompatStyle::Openai => ReasoningStrategy::None,
481            CompatStyle::OpenaiEffort => ReasoningStrategy::Effort,
482            CompatStyle::Openrouter => ReasoningStrategy::OpenRouterShape,
483        },
484        reasoning_extraction: extraction,
485    };
486    Box::leak(Box::new(profile))
487}
488
489/// Parse a model identifier into provider and model name.
490///
491/// Formats:
492/// - "ollama/llama3" -> ("ollama", "llama3")
493/// - "llama3" -> ("ollama", "llama3")  // defaults to ollama
494/// - "llama3:latest" -> ("ollama", "llama3:latest")  // ollama tag format
495/// - "groq/qwen-qwq-32b" -> ("groq", "qwen-qwq-32b")
496/// - "together/deepseek-ai/DeepSeek-R1" -> ("together", "deepseek-ai/DeepSeek-R1")
497///   (Together model names contain `/`; we split on the FIRST `/` only.)
498fn parse_model_id(model_id: &str) -> (&str, &str) {
499    if let Some(idx) = model_id.find('/') {
500        let provider = &model_id[..idx];
501        let model = &model_id[idx + 1..];
502        (provider, model)
503    } else {
504        ("ollama", model_id)
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn test_parse_model_id_with_provider() {
514        let (provider, model) = parse_model_id("ollama/llama3");
515        assert_eq!(provider, "ollama");
516        assert_eq!(model, "llama3");
517    }
518
519    #[test]
520    fn test_parse_model_id_bare_name() {
521        let (provider, model) = parse_model_id("llama3");
522        assert_eq!(provider, "ollama");
523        assert_eq!(model, "llama3");
524    }
525
526    #[test]
527    fn test_parse_model_id_with_tag() {
528        let (provider, model) = parse_model_id("ollama/llama3:latest");
529        assert_eq!(provider, "ollama");
530        assert_eq!(model, "llama3:latest");
531    }
532
533    #[test]
534    fn test_parse_model_id_bare_with_tag() {
535        let (provider, model) = parse_model_id("llama3:7b");
536        assert_eq!(provider, "ollama");
537        assert_eq!(model, "llama3:7b");
538    }
539
540    /// Together / DeepInfra model names contain `/` (e.g.
541    /// `deepseek-ai/DeepSeek-R1`). The split must keep everything after
542    /// the FIRST slash as the model name so the dispatcher gets the
543    /// provider correct and passes the full model path through.
544    #[test]
545    fn test_parse_model_id_keeps_slashes_in_model_name() {
546        let (provider, model) = parse_model_id("together/deepseek-ai/DeepSeek-R1");
547        assert_eq!(provider, "together");
548        assert_eq!(model, "deepseek-ai/DeepSeek-R1");
549    }
550
551    #[test]
552    fn test_model_spec_parsing() {
553        let specs = vec![
554            ("ollama/tinyllama", Some("ollama"), "tinyllama"),
555            ("qwen3-coder:30b", None, "qwen3-coder:30b"),
556            ("kimi-k2.5:cloud", None, "kimi-k2.5:cloud"),
557        ];
558        for (spec, expected_provider, expected_model) in specs {
559            let parts: Vec<&str> = spec.split('/').collect();
560            if parts.len() == 2 {
561                assert_eq!(Some(parts[0]), expected_provider);
562                assert_eq!(parts[1], expected_model);
563            } else {
564                assert_eq!(None, expected_provider);
565                assert_eq!(spec, expected_model);
566            }
567        }
568    }
569
570    #[test]
571    fn test_provider_extraction() {
572        fn extract_provider(spec: &str) -> Option<&str> {
573            spec.split('/').next().filter(|_| spec.contains('/'))
574        }
575        assert_eq!(extract_provider("ollama/tinyllama"), Some("ollama"));
576        assert_eq!(extract_provider("qwen3-coder:30b"), None);
577    }
578
579    #[tokio::test]
580    async fn unknown_provider_returns_clear_error() {
581        let cfg = Config::default();
582        let factory = ModelFactory::from_config(&cfg);
583        // `Box<dyn Model>` doesn't implement Debug, so we can't use
584        // `.unwrap_err()`; pattern-match the result instead.
585        match factory.create_model("nonexistent/foo").await {
586            Ok(_) => panic!("expected unknown-provider error"),
587            Err(e) => {
588                let msg = e.to_string();
589                assert!(
590                    msg.contains("Unknown provider"),
591                    "expected 'Unknown provider' in: {}",
592                    msg
593                );
594                assert!(
595                    msg.contains("nonexistent"),
596                    "error should name the bad provider; got: {}",
597                    msg
598                );
599            },
600        }
601    }
602
603    #[tokio::test]
604    async fn missing_api_key_returns_authentication_error() {
605        // `temp_env::async_with_vars` scopes the unset to this test only
606        // and restores the prior value afterward — no `unsafe` needed,
607        // and safe under `--test-threads > 1`.
608        temp_env::async_with_vars([("GROQ_API_KEY", None::<&str>)], async {
609            let cfg = Config::default();
610            let factory = ModelFactory::from_config(&cfg);
611            match factory.create_model("groq/qwen-qwq-32b").await {
612                Ok(_) => panic!("expected auth error"),
613                Err(e) => {
614                    let msg = e.to_string();
615                    assert!(
616                        msg.contains("API key") || msg.contains("Authentication"),
617                        "expected auth error, got: {}",
618                        msg
619                    );
620                    assert!(
621                        msg.contains("GROQ_API_KEY"),
622                        "error should name the env var; got: {}",
623                        msg
624                    );
625                },
626            }
627        })
628        .await;
629    }
630
631    /// `anthropic` is dispatched as a separate path from the OpenAI-compat
632    /// registry. Without `ANTHROPIC_API_KEY` set we should get a clear
633    /// auth error pointing at the env var, not "Unknown provider".
634    #[tokio::test]
635    async fn anthropic_missing_api_key_returns_authentication_error() {
636        temp_env::async_with_vars([("ANTHROPIC_API_KEY", None::<&str>)], async {
637            let cfg = Config::default();
638            let factory = ModelFactory::from_config(&cfg);
639            match factory.create_model("anthropic/claude-sonnet-4-6").await {
640                Ok(_) => panic!("expected auth error"),
641                Err(e) => {
642                    let msg = e.to_string();
643                    assert!(
644                        msg.contains("ANTHROPIC_API_KEY"),
645                        "error should name the env var; got: {}",
646                        msg
647                    );
648                    assert!(
649                        !msg.contains("Unknown provider"),
650                        "anthropic must be a known provider; got: {}",
651                        msg
652                    );
653                },
654            }
655        })
656        .await;
657    }
658
659    /// `mermaid list` should surface a curated list of Claude models even
660    /// though Anthropic has no public discovery endpoint. The adapter
661    /// itself returns Unsupported; the curated list lives in
662    /// `ModelFactory::list_models`.
663    #[tokio::test]
664    async fn anthropic_list_models_returns_curated_list() {
665        let cfg = Config::default();
666        let factory = ModelFactory::from_config(&cfg);
667        let models = factory
668            .list_models("anthropic")
669            .await
670            .expect("curated list should always succeed");
671        assert!(
672            models.iter().any(|m| m == "claude-sonnet-4-6"),
673            "expected sonnet-4-6 in curated list; got {:?}",
674            models
675        );
676        assert!(
677            models.iter().any(|m| m == "claude-opus-4-7"),
678            "expected opus-4-7 in curated list; got {:?}",
679            models
680        );
681    }
682
683    /// Same auth-error contract for Gemini as Anthropic. Without
684    /// `GOOGLE_API_KEY` we should get a clear pointer at the env var
685    /// (and the override path), not "Unknown provider".
686    #[tokio::test]
687    async fn gemini_missing_api_key_returns_authentication_error() {
688        temp_env::async_with_vars([("GOOGLE_API_KEY", None::<&str>)], async {
689            let cfg = Config::default();
690            let factory = ModelFactory::from_config(&cfg);
691            // Use a current preview ID — `gemini-3-pro` was shut down
692            // 2026-03-09 and would produce a different code path.
693            match factory.create_model("gemini/gemini-3.1-pro-preview").await {
694                Ok(_) => panic!("expected auth error"),
695                Err(e) => {
696                    let msg = e.to_string();
697                    assert!(
698                        msg.contains("GOOGLE_API_KEY"),
699                        "error should name the env var; got: {}",
700                        msg
701                    );
702                    assert!(
703                        !msg.contains("Unknown provider"),
704                        "gemini must be a known provider; got: {}",
705                        msg
706                    );
707                },
708            }
709        })
710        .await;
711    }
712
713    #[tokio::test]
714    async fn gemini_list_models_returns_curated_list() {
715        let cfg = Config::default();
716        let factory = ModelFactory::from_config(&cfg);
717        let models = factory
718            .list_models("gemini")
719            .await
720            .expect("curated list should always succeed");
721        // The `-latest` aliases are the user-friendly entry points —
722        // they repoint as Google ships new previews, so we surface them
723        // first.
724        assert!(
725            models.iter().any(|m| m == "gemini-pro-latest"),
726            "expected gemini-pro-latest alias in curated list; got {:?}",
727            models
728        );
729        // A concrete preview ID verified as of 2026-04-16.
730        assert!(
731            models.iter().any(|m| m == "gemini-3.1-pro-preview"),
732            "expected gemini-3.1-pro-preview in curated list; got {:?}",
733            models
734        );
735        // The deprecated bare `gemini-3-pro` must NOT be in the list —
736        // it's not a valid API ID and its preview was shut down in
737        // 2026-03.
738        assert!(
739            !models.iter().any(|m| m == "gemini-3-pro"),
740            "gemini-3-pro is not a valid API ID as of 2026-04; \
741             must not be in curated list; got {:?}",
742            models
743        );
744    }
745}