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