use std::time::Duration;
use crate::app::Config;
use crate::models::PROVIDER_REGISTRY;
use crate::providers::factory::resolve_provider_endpoint;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfiguredProvider {
pub name: String,
pub env_var: Option<String>,
pub endpoint: String,
pub keyless: bool,
}
impl ConfiguredProvider {
pub fn source_label(&self) -> String {
match (&self.env_var, self.keyless) {
(Some(env), _) => format!("via ${env}"),
(None, true) => "no key needed — local endpoint".to_string(),
(None, false) => "via keyring".to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderProblem {
pub name: String,
pub reason: String,
}
fn bespoke_providers() -> [&'static str; 3] {
["anthropic", "gemini", "meta"]
}
fn candidate_providers(config: &Config) -> Vec<String> {
let mut names: Vec<String> = bespoke_providers()
.iter()
.map(|name| name.to_string())
.chain(PROVIDER_REGISTRY.iter().map(|p| p.name.to_string()))
.chain(config.providers.keys().cloned())
.collect();
names.sort();
names.dedup();
names
}
pub fn configured_remote_providers(config: &Config) -> Vec<ConfiguredProvider> {
candidate_providers(config)
.into_iter()
.filter_map(|name| {
let endpoint = resolve_provider_endpoint(config, &name).ok()?;
Some(ConfiguredProvider {
name,
env_var: endpoint.key_env,
keyless: endpoint.api_key.is_none(),
endpoint: endpoint.base_url,
})
})
.collect()
}
pub fn configured_remote_provider_names(config: &Config) -> Vec<String> {
configured_remote_providers(config)
.into_iter()
.map(|entry| entry.name)
.collect()
}
pub fn provider_problems(config: &Config) -> Vec<ProviderProblem> {
candidate_providers(config)
.into_iter()
.filter_map(|name| {
let Err(error) = resolve_provider_endpoint(config, &name) else {
return None;
};
let attempted = config.providers.contains_key(&name) || any_key_resolves(config, &name);
attempted.then(|| ProviderProblem {
name,
reason: error.to_string(),
})
})
.collect()
}
fn any_key_resolves(config: &Config, name: &str) -> bool {
let override_env = config
.providers
.get(name)
.and_then(|provider| provider.api_key_env.as_deref());
if name == "gemini" {
return crate::utils::resolve_provider_key_with_fallback(
name,
crate::providers::model::gemini::DEFAULT_API_KEY_ENV,
crate::providers::model::gemini::LEGACY_API_KEY_ENV,
override_env,
)
.is_some();
}
let Some(default_env) = default_env_for(config, name) else {
return false;
};
crate::utils::resolve_provider_key(name, &default_env, override_env).is_some()
}
pub const CATALOG_TIMEOUT: Duration = Duration::from_secs(6);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderCatalog {
pub provider: ConfiguredProvider,
pub models: Option<Vec<String>>,
}
fn default_env_for(config: &Config, name: &str) -> Option<String> {
match name {
"anthropic" => return Some(crate::providers::model::anthropic::DEFAULT_API_KEY_ENV.into()),
"gemini" => return Some(crate::providers::model::gemini::DEFAULT_API_KEY_ENV.into()),
"meta" => return Some(crate::providers::model::meta::DEFAULT_API_KEY_ENV.into()),
_ => {},
}
if let Some(profile) = crate::models::lookup_provider(name) {
return Some(profile.api_key_env.to_string());
}
config
.providers
.get(name)
.and_then(|provider| provider.api_key_env.clone())
}
const ANTHROPIC_VERSION: &str = "2023-06-01";
const CATALOG_PAGE_SIZE: usize = 1000;
pub async fn provider_catalogs(config: &Config) -> Vec<ProviderCatalog> {
let providers = configured_remote_providers(config);
let client = match reqwest::Client::builder().timeout(CATALOG_TIMEOUT).build() {
Ok(client) => client,
Err(_) => {
return providers
.into_iter()
.map(|provider| ProviderCatalog {
provider,
models: None,
})
.collect();
},
};
futures::future::join_all(providers.into_iter().map(|provider| {
let client = client.clone();
async move {
let models = fetch_catalog(&client, config, &provider).await;
ProviderCatalog { provider, models }
}
}))
.await
}
async fn fetch_catalog(
client: &reqwest::Client,
config: &Config,
provider: &ConfiguredProvider,
) -> Option<Vec<String>> {
let name = provider.name.as_str();
let api_key = resolve_provider_endpoint(config, name).ok()?.api_key?;
let base = provider.endpoint.trim_end_matches('/');
let mut request = match name {
"gemini" => client
.get(format!("{base}/models?pageSize={CATALOG_PAGE_SIZE}"))
.header("x-goog-api-key", &api_key),
"anthropic" => client
.get(format!("{base}/models?limit={CATALOG_PAGE_SIZE}"))
.header("x-api-key", &api_key)
.header("anthropic-version", ANTHROPIC_VERSION),
_ => client.get(format!("{base}/models")).bearer_auth(&api_key),
};
if let Some(profile) = crate::models::lookup_provider(name) {
for (header, value) in profile.extra_headers {
request = request.header(*header, *value);
}
}
let response = request.send().await.ok()?;
if !response.status().is_success() {
return None;
}
let body = response.json::<serde_json::Value>().await.ok()?;
let mut models = match name {
"gemini" => gemini_model_ids(&body),
_ => openai_model_ids(&body),
};
models.sort();
models.dedup();
Some(models)
}
fn openai_model_ids(body: &serde_json::Value) -> Vec<String> {
body.get("data")
.and_then(|data| data.as_array())
.map(|rows| {
rows.iter()
.filter_map(|row| row.get("id").and_then(|id| id.as_str()))
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
fn gemini_model_ids(body: &serde_json::Value) -> Vec<String> {
body.get("models")
.and_then(|models| models.as_array())
.map(|rows| {
rows.iter()
.filter(|row| {
row.get("supportedGenerationMethods")
.and_then(|methods| methods.as_array())
.is_none_or(|methods| {
methods
.iter()
.any(|method| method.as_str() == Some("generateContent"))
})
})
.filter_map(|row| row.get("name").and_then(|name| name.as_str()))
.map(|name| name.trim_start_matches("models/").to_string())
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::UserProviderConfig;
#[test]
fn anthropic_key_alone_counts_as_a_configured_provider() {
temp_env::with_vars([("ANTHROPIC_API_KEY", Some("sk-test"))], || {
let names = configured_remote_provider_names(&Config::default());
assert!(names.contains(&"anthropic".to_string()), "got {names:?}");
});
}
#[test]
fn gemini_accepts_the_legacy_env_var() {
temp_env::with_vars(
[
("GOOGLE_API_KEY", None),
("GEMINI_API_KEY", Some("sk-legacy")),
],
|| {
let found = configured_remote_providers(&Config::default());
let gemini = found
.iter()
.find(|entry| entry.name == "gemini")
.expect("legacy GEMINI_API_KEY still configures gemini");
assert_eq!(
gemini.env_var.as_deref(),
Some(crate::providers::model::gemini::LEGACY_API_KEY_ENV)
);
},
);
}
#[test]
fn empty_environment_configures_nothing() {
temp_env::with_vars(cleared_provider_env(), || {
let from_env: Vec<_> = configured_remote_providers(&Config::default())
.into_iter()
.filter(|entry| entry.env_var.is_some())
.collect();
assert!(from_env.is_empty(), "got {from_env:?}");
});
}
fn cleared_provider_env() -> Vec<(&'static str, Option<&'static str>)> {
[
crate::providers::model::anthropic::DEFAULT_API_KEY_ENV,
crate::providers::model::gemini::DEFAULT_API_KEY_ENV,
crate::providers::model::gemini::LEGACY_API_KEY_ENV,
crate::providers::model::meta::DEFAULT_API_KEY_ENV,
"CLOUDFLARE_ACCOUNT_ID",
]
.iter()
.map(|env| (*env, None))
.chain(PROVIDER_REGISTRY.iter().map(|p| (p.api_key_env, None)))
.collect()
}
#[test]
fn api_key_env_override_is_authoritative() {
let mut config = Config::default();
config.providers.insert(
"anthropic".to_string(),
UserProviderConfig {
api_key_env: Some("MY_ANTHROPIC_KEY".to_string()),
..Default::default()
},
);
temp_env::with_vars(
[
("ANTHROPIC_API_KEY", Some("sk-default")),
("MY_ANTHROPIC_KEY", None),
],
|| {
let names = configured_remote_provider_names(&config);
assert!(!names.contains(&"anthropic".to_string()), "got {names:?}");
},
);
temp_env::with_vars(
[
("ANTHROPIC_API_KEY", None),
("MY_ANTHROPIC_KEY", Some("sk-override")),
],
|| {
let found = configured_remote_providers(&config);
let anthropic = found
.iter()
.find(|entry| entry.name == "anthropic")
.expect("the override env resolves the key");
assert_eq!(anthropic.env_var.as_deref(), Some("MY_ANTHROPIC_KEY"));
assert_eq!(anthropic.source_label(), "via $MY_ANTHROPIC_KEY");
},
);
}
#[test]
fn registry_providers_are_still_detected() {
temp_env::with_vars([("GROQ_API_KEY", Some("gsk-test"))], || {
let names = configured_remote_provider_names(&Config::default());
assert!(names.contains(&"groq".to_string()), "got {names:?}");
});
}
#[test]
fn bespoke_providers_are_candidates_and_resolve_an_endpoint() {
let config = Config::default();
let candidates = candidate_providers(&config);
for name in bespoke_providers() {
assert!(
candidates.contains(&name.to_string()),
"{name} is not a candidate provider"
);
assert!(
default_env_for(&config, name).is_some(),
"{name} lost its default env var"
);
}
temp_env::with_vars(
[
("ANTHROPIC_API_KEY", Some("sk-a")),
("GOOGLE_API_KEY", Some("sk-g")),
("MODEL_API_KEY", Some("sk-m")),
],
|| {
let found = configured_remote_providers(&config);
for name in bespoke_providers() {
let entry = found
.iter()
.find(|entry| entry.name == name)
.unwrap_or_else(|| panic!("{name} did not resolve"));
assert!(!entry.endpoint.is_empty(), "{name} has no endpoint");
assert!(!entry.keyless, "{name} has no keyless mode");
}
},
);
}
#[test]
fn base_url_override_wins_and_is_reported() {
let mut config = Config::default();
config.providers.insert(
"meta".to_string(),
UserProviderConfig {
base_url: Some("https://gw.example/v1".to_string()),
..Default::default()
},
);
temp_env::with_vars(
[
("MODEL_API_KEY", Some("sk-m")),
("GROQ_API_KEY", Some("gsk-g")),
],
|| {
let found = configured_remote_providers(&config);
let endpoint = |name: &str| {
found
.iter()
.find(|entry| entry.name == name)
.map(|entry| entry.endpoint.clone())
};
assert_eq!(endpoint("meta").as_deref(), Some("https://gw.example/v1"));
assert_eq!(
endpoint("groq").as_deref(),
Some("https://api.groq.com/openai/v1")
);
},
);
}
#[test]
fn a_keyless_local_endpoint_counts_as_configured() {
let mut config = Config::default();
config.providers.insert(
"llamacpp".to_string(),
UserProviderConfig {
base_url: Some("http://127.0.0.1:8080/v1".to_string()),
..Default::default()
},
);
temp_env::with_vars(cleared_provider_env(), || {
let found = configured_remote_providers(&config);
let local = found
.iter()
.find(|entry| entry.name == "llamacpp")
.expect("a keyless loopback provider is usable");
assert!(local.keyless);
assert_eq!(local.env_var, None);
assert_eq!(local.source_label(), "no key needed — local endpoint");
});
}
#[test]
fn a_custom_provider_without_a_base_url_is_a_problem_not_a_provider() {
let mut config = Config::default();
config.providers.insert(
"acme".to_string(),
UserProviderConfig {
api_key_env: Some("ACME_KEY".to_string()),
..Default::default()
},
);
temp_env::with_vars(
cleared_provider_env()
.into_iter()
.chain([("ACME_KEY", Some("sk-acme"))])
.collect::<Vec<_>>(),
|| {
let names = configured_remote_provider_names(&config);
assert!(!names.contains(&"acme".to_string()), "got {names:?}");
let problems = provider_problems(&config);
let acme = problems
.iter()
.find(|problem| problem.name == "acme")
.expect("a half-configured provider is reported");
assert!(acme.reason.contains("base_url"), "got {}", acme.reason);
},
);
}
#[test]
fn untouched_providers_are_not_reported_as_problems() {
temp_env::with_vars(cleared_provider_env(), || {
let problems = provider_problems(&Config::default());
assert!(problems.is_empty(), "got {problems:?}");
});
}
#[test]
fn cloudflare_without_an_account_id_is_a_problem() {
temp_env::with_vars(
cleared_provider_env()
.into_iter()
.chain([("CLOUDFLARE_API_TOKEN", Some("cf-token"))])
.collect::<Vec<_>>(),
|| {
let config = Config::default();
assert!(
!configured_remote_provider_names(&config).contains(&"cloudflare".to_string())
);
let problem = provider_problems(&config)
.into_iter()
.find(|problem| problem.name == "cloudflare")
.expect("a token without an account id is reported");
assert!(
problem.reason.contains("CLOUDFLARE_ACCOUNT_ID"),
"got {}",
problem.reason
);
},
);
}
#[test]
fn listing_agrees_with_what_the_factory_can_build() {
let mut config = Config::default();
config.providers.insert(
"llamacpp".to_string(),
UserProviderConfig {
base_url: Some("http://127.0.0.1:8080/v1".to_string()),
..Default::default()
},
);
config.providers.insert(
"acme".to_string(),
UserProviderConfig {
api_key_env: Some("ACME_KEY".to_string()),
..Default::default()
},
);
temp_env::with_vars(
cleared_provider_env()
.into_iter()
.chain([
("ACME_KEY", Some("sk-acme")),
("ANTHROPIC_API_KEY", Some("sk-a")),
("CLOUDFLARE_API_TOKEN", Some("cf-token")),
])
.collect::<Vec<_>>(),
|| {
let listed = configured_remote_provider_names(&config);
for name in candidate_providers(&config) {
let buildable = resolve_provider_endpoint(&config, &name).is_ok();
assert_eq!(
listed.contains(&name),
buildable,
"{name}: listed={} buildable={buildable}",
listed.contains(&name),
);
}
},
);
}
#[test]
fn openai_shape_yields_ids_and_survives_junk() {
let body = serde_json::json!({
"object": "list",
"data": [
{"id": "muse-spark-1.2-contributor", "object": "model"},
{"object": "model"},
{"id": "muse-spark-1.1"},
],
});
assert_eq!(
openai_model_ids(&body),
vec!["muse-spark-1.2-contributor", "muse-spark-1.1"]
);
assert!(openai_model_ids(&serde_json::json!({"error": "nope"})).is_empty());
}
#[test]
fn gemini_shape_strips_the_prefix_and_drops_non_chat_models() {
let body = serde_json::json!({
"models": [
{
"name": "models/gemini-3-pro",
"supportedGenerationMethods": ["generateContent", "countTokens"],
},
{
"name": "models/text-embedding-004",
"supportedGenerationMethods": ["embedContent"],
},
{"name": "models/gemini-future"},
],
});
assert_eq!(
gemini_model_ids(&body),
vec!["gemini-3-pro", "gemini-future"]
);
}
#[test]
fn results_are_sorted_and_deduped() {
temp_env::with_vars(
[
("GROQ_API_KEY", Some("gsk-test")),
("ANTHROPIC_API_KEY", Some("sk-test")),
],
|| {
let names = configured_remote_provider_names(&Config::default());
let mut sorted = names.clone();
sorted.sort();
sorted.dedup();
assert_eq!(names, sorted);
},
);
}
}