1use std::time::Duration;
26
27use crate::app::Config;
28use crate::models::PROVIDER_REGISTRY;
29use crate::providers::factory::resolve_provider_endpoint;
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 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#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ProviderProblem {
64 pub name: String,
65 pub reason: String,
66}
67
68fn bespoke_providers() -> [&'static str; 3] {
73 ["anthropic", "gemini", "meta"]
74}
75
76fn 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
91pub 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
110pub 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
118pub 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
141fn 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
164pub const CATALOG_TIMEOUT: Duration = Duration::from_secs(6);
168
169#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct ProviderCatalog {
172 pub provider: ConfiguredProvider,
174 pub models: Option<Vec<String>>,
179}
180
181fn 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 config
195 .providers
196 .get(name)
197 .and_then(|provider| provider.api_key_env.clone())
198}
199
200const ANTHROPIC_VERSION: &str = "2023-06-01";
202const CATALOG_PAGE_SIZE: usize = 1000;
206
207pub 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 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
238async 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 let api_key = resolve_provider_endpoint(config, name).ok()?.api_key?;
249 let base = provider.endpoint.trim_end_matches('/');
250 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 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 _ => openai_model_ids(&body),
279 };
280 models.sort();
281 models.dedup();
282 Some(models)
283}
284
285fn 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
298fn 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 .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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 {"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}