1use std::time::Duration;
26
27use crate::providers::factory::resolve_provider_endpoint;
28use mermaid_domain::Config;
29use mermaid_model::models::PROVIDER_REGISTRY;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ConfiguredProvider {
34 pub name: String,
36 pub env_var: Option<String>,
39 pub endpoint: String,
43 pub keyless: bool,
46}
47
48impl ConfiguredProvider {
49 #[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#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct ProviderProblem {
65 pub name: String,
66 pub reason: String,
67}
68
69fn bespoke_providers() -> [&'static str; 3] {
74 ["anthropic", "gemini", "meta"]
75}
76
77fn 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#[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#[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#[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
145fn 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
168pub const CATALOG_TIMEOUT: Duration = Duration::from_secs(6);
172
173#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct ProviderCatalog {
176 pub provider: ConfiguredProvider,
178 pub models: Option<Vec<String>>,
183}
184
185fn 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 config
199 .providers
200 .get(name)
201 .and_then(|provider| provider.api_key_env.clone())
202}
203
204const ANTHROPIC_VERSION: &str = "2023-06-01";
206const CATALOG_PAGE_SIZE: usize = 1000;
210
211pub 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 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
242async 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 let api_key = resolve_provider_endpoint(config, name).ok()?.api_key?;
253 let base = provider.endpoint.trim_end_matches('/');
254 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 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 _ => openai_model_ids(&body),
283 };
284 models.sort();
285 models.dedup();
286 Some(models)
287}
288
289fn 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
302fn 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 .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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 {"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}