Skip to main content

mermaid_cli/providers/
discovery.rs

1//! Which model backends this machine can actually reach right now.
2//!
3//! Ollama is Mermaid's default backend, not a prerequisite: a machine with
4//! only `ANTHROPIC_API_KEY` set and no Ollama installed must still be able to
5//! run `mermaid`. Three surfaces need the same answer to "what is configured"
6//! — startup model resolution (`app::resolve_model_id`), `doctor`, and
7//! `mermaid list` — so they all read it from here instead of each rebuilding
8//! their own idea of the provider set.
9//!
10//! "Configured" means one thing here, and it is not "a key resolves": it is
11//! **`ProviderFactory` would successfully build this provider**. That question
12//! has exactly one implementation — [`crate::providers::factory::
13//! resolve_provider_endpoint`] — and this module asks it rather than
14//! re-deriving the answer. Three earlier hand-rolled walks each got it subtly
15//! wrong in a different direction: all of them missed a keyless loopback
16//! endpoint and a keyring-only custom provider, and two disagreed with each
17//! other about whether `base_url` was required.
18//!
19//! [`provider_catalogs`] goes one step further and asks each configured
20//! provider what models it serves. The three bespoke providers (Anthropic,
21//! Gemini, Meta) each speak their own catalog dialect, so enumerating only the
22//! OpenAI-compatible registry — which is what `/model` and `mermaid list` used
23//! to do — hid every bespoke model, `meta/muse-spark-*` included.
24
25use std::time::Duration;
26
27use crate::app::Config;
28use crate::models::PROVIDER_REGISTRY;
29use crate::providers::factory::resolve_provider_endpoint;
30
31/// A remote provider this machine can actually use right now.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ConfiguredProvider {
34    /// Provider name as it appears in a model id (`anthropic`, `groq`, …).
35    pub name: String,
36    /// Env var the key came from, or `None` when it came from the keyring
37    /// (`mermaid login <provider>`) or the endpoint takes no key at all.
38    pub env_var: Option<String>,
39    /// The base URL requests would go to, overrides applied. Worth showing:
40    /// a provider pointed at a proxy is the single most confusing state to
41    /// debug from a bare provider name.
42    pub endpoint: String,
43    /// The endpoint runs without auth — legal only for a loopback/LAN host,
44    /// which is how a local llama.cpp or vLLM server is reached.
45    pub keyless: bool,
46}
47
48impl ConfiguredProvider {
49    /// Human-readable provenance for the listing surfaces.
50    pub fn source_label(&self) -> String {
51        match (&self.env_var, self.keyless) {
52            (Some(env), _) => format!("via ${env}"),
53            (None, true) => "no key needed — local endpoint".to_string(),
54            (None, false) => "via keyring".to_string(),
55        }
56    }
57}
58
59/// A provider the user has evidently tried to configure, and the reason it
60/// cannot be used. Carries the factory's own error, so the text is the same one
61/// a real request would produce.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ProviderProblem {
64    pub name: String,
65    pub reason: String,
66}
67
68/// Remote providers with a bespoke (non-OpenAI-compatible) adapter, and so
69/// absent from `PROVIDER_REGISTRY`. Listing them here is what stops an
70/// Anthropic-only machine from being reported as having no remote provider at
71/// all.
72fn bespoke_providers() -> [&'static str; 3] {
73    ["anthropic", "gemini", "meta"]
74}
75
76/// Every provider name Mermaid could build, before asking whether this machine
77/// has the credentials: the bespoke three, the registry, and anything the user
78/// declared in `[providers.<name>]`. Sorted and deduped.
79fn candidate_providers(config: &Config) -> Vec<String> {
80    let mut names: Vec<String> = bespoke_providers()
81        .iter()
82        .map(|name| name.to_string())
83        .chain(PROVIDER_REGISTRY.iter().map(|p| p.name.to_string()))
84        .chain(config.providers.keys().cloned())
85        .collect();
86    names.sort();
87    names.dedup();
88    names
89}
90
91/// Every remote provider this machine can use right now, sorted by name.
92///
93/// Empty means only a local Ollama is reachable — the one case where a missing
94/// Ollama is genuinely a dead end.
95pub fn configured_remote_providers(config: &Config) -> Vec<ConfiguredProvider> {
96    candidate_providers(config)
97        .into_iter()
98        .filter_map(|name| {
99            let endpoint = resolve_provider_endpoint(config, &name).ok()?;
100            Some(ConfiguredProvider {
101                name,
102                env_var: endpoint.key_env,
103                keyless: endpoint.api_key.is_none(),
104                endpoint: endpoint.base_url,
105            })
106        })
107        .collect()
108}
109
110/// Just the names from [`configured_remote_providers`].
111pub fn configured_remote_provider_names(config: &Config) -> Vec<String> {
112    configured_remote_providers(config)
113        .into_iter()
114        .map(|entry| entry.name)
115        .collect()
116}
117
118/// Providers the user has started configuring that still cannot be used.
119///
120/// "Started configuring" is deliberately narrow — a `[providers.<name>]` block
121/// exists, or a key resolves — because every provider in the registry is
122/// unusable on a machine with no keys, and reporting fifteen of those as
123/// problems would bury the one that matters. The classic hit is Cloudflare with
124/// a token but no `CLOUDFLARE_ACCOUNT_ID`.
125pub fn provider_problems(config: &Config) -> Vec<ProviderProblem> {
126    candidate_providers(config)
127        .into_iter()
128        .filter_map(|name| {
129            let Err(error) = resolve_provider_endpoint(config, &name) else {
130                return None;
131            };
132            let attempted = config.providers.contains_key(&name) || any_key_resolves(config, &name);
133            attempted.then(|| ProviderProblem {
134                name,
135                reason: error.to_string(),
136            })
137        })
138        .collect()
139}
140
141/// Whether a key for `name` resolves from any of its accepted sources. Used
142/// only to decide if the user *meant* to configure a provider that then failed
143/// for some other reason — never as the definition of "configured".
144fn any_key_resolves(config: &Config, name: &str) -> bool {
145    let override_env = config
146        .providers
147        .get(name)
148        .and_then(|provider| provider.api_key_env.as_deref());
149    if name == "gemini" {
150        return crate::utils::resolve_provider_key_with_fallback(
151            name,
152            crate::providers::model::gemini::DEFAULT_API_KEY_ENV,
153            crate::providers::model::gemini::LEGACY_API_KEY_ENV,
154            override_env,
155        )
156        .is_some();
157    }
158    let Some(default_env) = default_env_for(config, name) else {
159        return false;
160    };
161    crate::utils::resolve_provider_key(name, &default_env, override_env).is_some()
162}
163
164/// How long one provider's catalog request may take. The `/model` picker opens
165/// immediately and fills in, so a slow provider costs a late row, not a stalled
166/// UI — but the request must not hang the discovery task forever either.
167pub const CATALOG_TIMEOUT: Duration = Duration::from_secs(6);
168
169/// What one configured provider serves, as reported by its catalog endpoint.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct ProviderCatalog {
172    /// The provider and where its key came from.
173    pub provider: ConfiguredProvider,
174    /// Bare model ids (no `provider/` prefix), sorted. `None` when the catalog
175    /// could not be read — a network failure, a rejected key, or a provider
176    /// with no listing endpoint. Distinct from `Some(vec![])`, which means the
177    /// provider answered and serves nothing.
178    pub models: Option<Vec<String>>,
179}
180
181/// The env var a provider's key lives in by default — before any per-provider
182/// `api_key_env` override, which the callers apply themselves.
183fn default_env_for(config: &Config, name: &str) -> Option<String> {
184    match name {
185        "anthropic" => return Some(crate::providers::model::anthropic::DEFAULT_API_KEY_ENV.into()),
186        "gemini" => return Some(crate::providers::model::gemini::DEFAULT_API_KEY_ENV.into()),
187        "meta" => return Some(crate::providers::model::meta::DEFAULT_API_KEY_ENV.into()),
188        _ => {},
189    }
190    if let Some(profile) = crate::models::lookup_provider(name) {
191        return Some(profile.api_key_env.to_string());
192    }
193    // A user-defined `[providers.<name>]`: its `api_key_env` IS the default.
194    config
195        .providers
196        .get(name)
197        .and_then(|provider| provider.api_key_env.clone())
198}
199
200/// Anthropic pins its wire format by date header, same as the chat adapter.
201const ANTHROPIC_VERSION: &str = "2023-06-01";
202/// One page big enough to hold every provider's catalog. Anthropic and Gemini
203/// both paginate with small defaults (20 and 50), and both cap the page at
204/// 1000 — so one page is the whole list in practice.
205const CATALOG_PAGE_SIZE: usize = 1000;
206
207/// Every configured provider's model catalog, fetched concurrently.
208///
209/// Best-effort throughout: a provider that cannot be reached yields
210/// `models: None` rather than failing the batch, because the caller's job is to
211/// show the user what IS available.
212pub async fn provider_catalogs(config: &Config) -> Vec<ProviderCatalog> {
213    let providers = configured_remote_providers(config);
214    let client = match reqwest::Client::builder().timeout(CATALOG_TIMEOUT).build() {
215        Ok(client) => client,
216        // No HTTP client means no catalog anywhere — still report the providers
217        // themselves, which is what the key resolution already proved.
218        Err(_) => {
219            return providers
220                .into_iter()
221                .map(|provider| ProviderCatalog {
222                    provider,
223                    models: None,
224                })
225                .collect();
226        },
227    };
228    futures::future::join_all(providers.into_iter().map(|provider| {
229        let client = client.clone();
230        async move {
231            let models = fetch_catalog(&client, config, &provider).await;
232            ProviderCatalog { provider, models }
233        }
234    }))
235    .await
236}
237
238/// One provider's bare model ids, or `None` if the catalog could not be read.
239async fn fetch_catalog(
240    client: &reqwest::Client,
241    config: &Config,
242    provider: &ConfiguredProvider,
243) -> Option<Vec<String>> {
244    let name = provider.name.as_str();
245    // The endpoint is already on `provider`; the key is not (it is a secret and
246    // has no business sitting in a struct the CLI prints). Re-resolve it here,
247    // through the same function that decided the provider was usable at all.
248    let api_key = resolve_provider_endpoint(config, name).ok()?.api_key?;
249    let base = provider.endpoint.trim_end_matches('/');
250    // Gemini reports far more than chat models (embedders, tuned copies), so it
251    // needs both a bigger page and its own row filter below.
252    let mut request = match name {
253        "gemini" => client
254            .get(format!("{base}/models?pageSize={CATALOG_PAGE_SIZE}"))
255            .header("x-goog-api-key", &api_key),
256        "anthropic" => client
257            .get(format!("{base}/models?limit={CATALOG_PAGE_SIZE}"))
258            .header("x-api-key", &api_key)
259            .header("anthropic-version", ANTHROPIC_VERSION),
260        _ => client.get(format!("{base}/models")).bearer_auth(&api_key),
261    };
262    // Registry providers can require analytics headers (OpenRouter) — the same
263    // ones the chat adapter sends.
264    if let Some(profile) = crate::models::lookup_provider(name) {
265        for (header, value) in profile.extra_headers {
266            request = request.header(*header, *value);
267        }
268    }
269    let response = request.send().await.ok()?;
270    if !response.status().is_success() {
271        return None;
272    }
273    let body = response.json::<serde_json::Value>().await.ok()?;
274    let mut models = match name {
275        "gemini" => gemini_model_ids(&body),
276        // Anthropic, Meta, and every registry provider answer in the
277        // OpenAI-compatible `{ "data": [{ "id": … }] }` shape.
278        _ => openai_model_ids(&body),
279    };
280    models.sort();
281    models.dedup();
282    Some(models)
283}
284
285/// Ids from an OpenAI-compatible `{ "data": [{ "id": … }] }` listing.
286fn openai_model_ids(body: &serde_json::Value) -> Vec<String> {
287    body.get("data")
288        .and_then(|data| data.as_array())
289        .map(|rows| {
290            rows.iter()
291                .filter_map(|row| row.get("id").and_then(|id| id.as_str()))
292                .map(str::to_string)
293                .collect()
294        })
295        .unwrap_or_default()
296}
297
298/// Ids from Gemini's `{ "models": [{ "name": "models/…" }] }` listing, keeping
299/// only what `/model` could actually select: the `generateContent` models.
300/// Gemini also serves embedders and tuned copies through the same endpoint.
301fn gemini_model_ids(body: &serde_json::Value) -> Vec<String> {
302    body.get("models")
303        .and_then(|models| models.as_array())
304        .map(|rows| {
305            rows.iter()
306                .filter(|row| {
307                    row.get("supportedGenerationMethods")
308                        .and_then(|methods| methods.as_array())
309                        // Absent field: keep the row rather than silently drop
310                        // a model on a response-shape change.
311                        .is_none_or(|methods| {
312                            methods
313                                .iter()
314                                .any(|method| method.as_str() == Some("generateContent"))
315                        })
316                })
317                .filter_map(|row| row.get("name").and_then(|name| name.as_str()))
318                .map(|name| name.trim_start_matches("models/").to_string())
319                .collect()
320        })
321        .unwrap_or_default()
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use crate::app::UserProviderConfig;
328
329    /// The bespoke providers were the whole point: before this module they
330    /// were absent from the "configured remote providers" set, so a machine
331    /// with only `ANTHROPIC_API_KEY` reported none.
332    #[test]
333    fn anthropic_key_alone_counts_as_a_configured_provider() {
334        temp_env::with_vars([("ANTHROPIC_API_KEY", Some("sk-test"))], || {
335            let names = configured_remote_provider_names(&Config::default());
336            assert!(names.contains(&"anthropic".to_string()), "got {names:?}");
337        });
338    }
339
340    #[test]
341    fn gemini_accepts_the_legacy_env_var() {
342        temp_env::with_vars(
343            [
344                ("GOOGLE_API_KEY", None),
345                ("GEMINI_API_KEY", Some("sk-legacy")),
346            ],
347            || {
348                let found = configured_remote_providers(&Config::default());
349                let gemini = found
350                    .iter()
351                    .find(|entry| entry.name == "gemini")
352                    .expect("legacy GEMINI_API_KEY still configures gemini");
353                assert_eq!(
354                    gemini.env_var.as_deref(),
355                    Some(crate::providers::model::gemini::LEGACY_API_KEY_ENV)
356                );
357            },
358        );
359    }
360
361    #[test]
362    fn empty_environment_configures_nothing() {
363        temp_env::with_vars(cleared_provider_env(), || {
364            // The keyring is the machine's, not the test's, so only assert on
365            // the env-var half: nothing here may come from an env var.
366            let from_env: Vec<_> = configured_remote_providers(&Config::default())
367                .into_iter()
368                .filter(|entry| entry.env_var.is_some())
369                .collect();
370            assert!(from_env.is_empty(), "got {from_env:?}");
371        });
372    }
373
374    /// Every provider env var unset, so a test asserts on its own config rather
375    /// than on whatever the developer's shell happens to export.
376    fn cleared_provider_env() -> Vec<(&'static str, Option<&'static str>)> {
377        [
378            crate::providers::model::anthropic::DEFAULT_API_KEY_ENV,
379            crate::providers::model::gemini::DEFAULT_API_KEY_ENV,
380            crate::providers::model::gemini::LEGACY_API_KEY_ENV,
381            crate::providers::model::meta::DEFAULT_API_KEY_ENV,
382            "CLOUDFLARE_ACCOUNT_ID",
383        ]
384        .iter()
385        .map(|env| (*env, None))
386        .chain(PROVIDER_REGISTRY.iter().map(|p| (p.api_key_env, None)))
387        .collect()
388    }
389
390    /// A per-provider `api_key_env` override is authoritative — the default
391    /// env var must not resolve the key behind its back.
392    #[test]
393    fn api_key_env_override_is_authoritative() {
394        let mut config = Config::default();
395        config.providers.insert(
396            "anthropic".to_string(),
397            UserProviderConfig {
398                api_key_env: Some("MY_ANTHROPIC_KEY".to_string()),
399                ..Default::default()
400            },
401        );
402        temp_env::with_vars(
403            [
404                ("ANTHROPIC_API_KEY", Some("sk-default")),
405                ("MY_ANTHROPIC_KEY", None),
406            ],
407            || {
408                let names = configured_remote_provider_names(&config);
409                assert!(!names.contains(&"anthropic".to_string()), "got {names:?}");
410            },
411        );
412        temp_env::with_vars(
413            [
414                ("ANTHROPIC_API_KEY", None),
415                ("MY_ANTHROPIC_KEY", Some("sk-override")),
416            ],
417            || {
418                let found = configured_remote_providers(&config);
419                let anthropic = found
420                    .iter()
421                    .find(|entry| entry.name == "anthropic")
422                    .expect("the override env resolves the key");
423                assert_eq!(anthropic.env_var.as_deref(), Some("MY_ANTHROPIC_KEY"));
424                assert_eq!(anthropic.source_label(), "via $MY_ANTHROPIC_KEY");
425            },
426        );
427    }
428
429    /// Registry providers keep working exactly as before this module existed.
430    #[test]
431    fn registry_providers_are_still_detected() {
432        temp_env::with_vars([("GROQ_API_KEY", Some("gsk-test"))], || {
433            let names = configured_remote_provider_names(&Config::default());
434            assert!(names.contains(&"groq".to_string()), "got {names:?}");
435        });
436    }
437
438    /// The bug this module's catalog half exists to fix: `/model` and
439    /// `mermaid list` enumerated only `PROVIDER_REGISTRY`, so every bespoke
440    /// provider's models were invisible. All three must be candidates, and all
441    /// three must resolve an endpoint once their key is present.
442    #[test]
443    fn bespoke_providers_are_candidates_and_resolve_an_endpoint() {
444        let config = Config::default();
445        let candidates = candidate_providers(&config);
446        for name in bespoke_providers() {
447            assert!(
448                candidates.contains(&name.to_string()),
449                "{name} is not a candidate provider"
450            );
451            assert!(
452                default_env_for(&config, name).is_some(),
453                "{name} lost its default env var"
454            );
455        }
456        temp_env::with_vars(
457            [
458                ("ANTHROPIC_API_KEY", Some("sk-a")),
459                ("GOOGLE_API_KEY", Some("sk-g")),
460                ("MODEL_API_KEY", Some("sk-m")),
461            ],
462            || {
463                let found = configured_remote_providers(&config);
464                for name in bespoke_providers() {
465                    let entry = found
466                        .iter()
467                        .find(|entry| entry.name == name)
468                        .unwrap_or_else(|| panic!("{name} did not resolve"));
469                    assert!(!entry.endpoint.is_empty(), "{name} has no endpoint");
470                    assert!(!entry.keyless, "{name} has no keyless mode");
471                }
472            },
473        );
474    }
475
476    #[test]
477    fn base_url_override_wins_and_is_reported() {
478        let mut config = Config::default();
479        config.providers.insert(
480            "meta".to_string(),
481            UserProviderConfig {
482                base_url: Some("https://gw.example/v1".to_string()),
483                ..Default::default()
484            },
485        );
486        temp_env::with_vars(
487            [
488                ("MODEL_API_KEY", Some("sk-m")),
489                ("GROQ_API_KEY", Some("gsk-g")),
490            ],
491            || {
492                let found = configured_remote_providers(&config);
493                let endpoint = |name: &str| {
494                    found
495                        .iter()
496                        .find(|entry| entry.name == name)
497                        .map(|entry| entry.endpoint.clone())
498                };
499                assert_eq!(endpoint("meta").as_deref(), Some("https://gw.example/v1"));
500                assert_eq!(
501                    endpoint("groq").as_deref(),
502                    Some("https://api.groq.com/openai/v1")
503                );
504            },
505        );
506    }
507
508    /// The whole point of routing through the factory: a keyless loopback
509    /// endpoint is a real, buildable provider, and every hand-rolled walk that
510    /// preceded this module dropped it on the floor because no key resolved.
511    #[test]
512    fn a_keyless_local_endpoint_counts_as_configured() {
513        let mut config = Config::default();
514        config.providers.insert(
515            "llamacpp".to_string(),
516            UserProviderConfig {
517                base_url: Some("http://127.0.0.1:8080/v1".to_string()),
518                ..Default::default()
519            },
520        );
521        temp_env::with_vars(cleared_provider_env(), || {
522            let found = configured_remote_providers(&config);
523            let local = found
524                .iter()
525                .find(|entry| entry.name == "llamacpp")
526                .expect("a keyless loopback provider is usable");
527            assert!(local.keyless);
528            assert_eq!(local.env_var, None);
529            assert_eq!(local.source_label(), "no key needed — local endpoint");
530        });
531    }
532
533    /// The mirror case: a custom provider with a key but no `base_url` cannot
534    /// be built, so it is NOT configured — and it earns a problem row naming
535    /// the missing field, because the user clearly meant to set it up.
536    #[test]
537    fn a_custom_provider_without_a_base_url_is_a_problem_not_a_provider() {
538        let mut config = Config::default();
539        config.providers.insert(
540            "acme".to_string(),
541            UserProviderConfig {
542                api_key_env: Some("ACME_KEY".to_string()),
543                ..Default::default()
544            },
545        );
546        temp_env::with_vars(
547            cleared_provider_env()
548                .into_iter()
549                .chain([("ACME_KEY", Some("sk-acme"))])
550                .collect::<Vec<_>>(),
551            || {
552                let names = configured_remote_provider_names(&config);
553                assert!(!names.contains(&"acme".to_string()), "got {names:?}");
554                let problems = provider_problems(&config);
555                let acme = problems
556                    .iter()
557                    .find(|problem| problem.name == "acme")
558                    .expect("a half-configured provider is reported");
559                assert!(acme.reason.contains("base_url"), "got {}", acme.reason);
560            },
561        );
562    }
563
564    /// A provider nobody configured must stay silent. Fifteen registry entries
565    /// with no key are not fifteen problems.
566    #[test]
567    fn untouched_providers_are_not_reported_as_problems() {
568        temp_env::with_vars(cleared_provider_env(), || {
569            let problems = provider_problems(&Config::default());
570            assert!(problems.is_empty(), "got {problems:?}");
571        });
572    }
573
574    /// Cloudflare's endpoint embeds an account id. A token without one is the
575    /// canonical half-configured provider, and the reason must name the var.
576    #[test]
577    fn cloudflare_without_an_account_id_is_a_problem() {
578        temp_env::with_vars(
579            cleared_provider_env()
580                .into_iter()
581                .chain([("CLOUDFLARE_API_TOKEN", Some("cf-token"))])
582                .collect::<Vec<_>>(),
583            || {
584                let config = Config::default();
585                assert!(
586                    !configured_remote_provider_names(&config).contains(&"cloudflare".to_string())
587                );
588                let problem = provider_problems(&config)
589                    .into_iter()
590                    .find(|problem| problem.name == "cloudflare")
591                    .expect("a token without an account id is reported");
592                assert!(
593                    problem.reason.contains("CLOUDFLARE_ACCOUNT_ID"),
594                    "got {}",
595                    problem.reason
596                );
597            },
598        );
599    }
600
601    /// The invariant the whole refactor exists to hold: discovery lists a
602    /// provider if and only if the factory can build one. Asserted across the
603    /// configs that used to split the two apart.
604    #[test]
605    fn listing_agrees_with_what_the_factory_can_build() {
606        let mut config = Config::default();
607        config.providers.insert(
608            "llamacpp".to_string(),
609            UserProviderConfig {
610                base_url: Some("http://127.0.0.1:8080/v1".to_string()),
611                ..Default::default()
612            },
613        );
614        config.providers.insert(
615            "acme".to_string(),
616            UserProviderConfig {
617                api_key_env: Some("ACME_KEY".to_string()),
618                ..Default::default()
619            },
620        );
621        temp_env::with_vars(
622            cleared_provider_env()
623                .into_iter()
624                .chain([
625                    ("ACME_KEY", Some("sk-acme")),
626                    ("ANTHROPIC_API_KEY", Some("sk-a")),
627                    ("CLOUDFLARE_API_TOKEN", Some("cf-token")),
628                ])
629                .collect::<Vec<_>>(),
630            || {
631                let listed = configured_remote_provider_names(&config);
632                for name in candidate_providers(&config) {
633                    let buildable = resolve_provider_endpoint(&config, &name).is_ok();
634                    assert_eq!(
635                        listed.contains(&name),
636                        buildable,
637                        "{name}: listed={} buildable={buildable}",
638                        listed.contains(&name),
639                    );
640                }
641            },
642        );
643    }
644
645    #[test]
646    fn openai_shape_yields_ids_and_survives_junk() {
647        let body = serde_json::json!({
648            "object": "list",
649            "data": [
650                {"id": "muse-spark-1.2-contributor", "object": "model"},
651                {"object": "model"},
652                {"id": "muse-spark-1.1"},
653            ],
654        });
655        assert_eq!(
656            openai_model_ids(&body),
657            vec!["muse-spark-1.2-contributor", "muse-spark-1.1"]
658        );
659        assert!(openai_model_ids(&serde_json::json!({"error": "nope"})).is_empty());
660    }
661
662    #[test]
663    fn gemini_shape_strips_the_prefix_and_drops_non_chat_models() {
664        let body = serde_json::json!({
665            "models": [
666                {
667                    "name": "models/gemini-3-pro",
668                    "supportedGenerationMethods": ["generateContent", "countTokens"],
669                },
670                {
671                    "name": "models/text-embedding-004",
672                    "supportedGenerationMethods": ["embedContent"],
673                },
674                // No methods field: kept, so a response-shape change can't
675                // silently empty the list.
676                {"name": "models/gemini-future"},
677            ],
678        });
679        assert_eq!(
680            gemini_model_ids(&body),
681            vec!["gemini-3-pro", "gemini-future"]
682        );
683    }
684
685    #[test]
686    fn results_are_sorted_and_deduped() {
687        temp_env::with_vars(
688            [
689                ("GROQ_API_KEY", Some("gsk-test")),
690                ("ANTHROPIC_API_KEY", Some("sk-test")),
691            ],
692            || {
693                let names = configured_remote_provider_names(&Config::default());
694                let mut sorted = names.clone();
695                sorted.sort();
696                sorted.dedup();
697                assert_eq!(names, sorted);
698            },
699        );
700    }
701}