use crate::auth::{AuthMethod, ResolvedCredential};
use crate::config::{Config, ProviderConfig};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProviderSpec {
pub name: &'static str,
pub model_keywords: &'static [&'static str],
pub runtime_supported: bool,
pub default_base_url: Option<&'static str>,
pub backend: &'static str,
pub default_auth_header: Option<&'static str>,
pub default_api_version: Option<&'static str>,
pub api_key_required: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeProviderSelection {
pub name: &'static str,
pub api_key: String,
pub api_base: Option<String>,
pub backend: &'static str,
pub credential: ResolvedCredential,
pub model: Option<String>,
pub auth_header: Option<String>,
pub api_version: Option<String>,
}
pub const PROVIDER_REGISTRY: &[ProviderSpec] = &[
ProviderSpec {
name: "anthropic",
model_keywords: &["anthropic", "claude"],
runtime_supported: true,
default_base_url: None,
backend: "anthropic",
default_auth_header: None,
default_api_version: None,
api_key_required: true,
},
ProviderSpec {
name: "openai",
model_keywords: &["openai", "gpt"],
runtime_supported: true,
default_base_url: None,
backend: "openai",
default_auth_header: None,
default_api_version: None,
api_key_required: true,
},
ProviderSpec {
name: "openrouter",
model_keywords: &["openrouter"],
runtime_supported: true,
default_base_url: Some("https://openrouter.ai/api/v1"),
backend: "openai",
default_auth_header: None,
default_api_version: None,
api_key_required: true,
},
ProviderSpec {
name: "groq",
model_keywords: &["groq"],
runtime_supported: true,
default_base_url: Some("https://api.groq.com/openai/v1"),
backend: "openai",
default_auth_header: None,
default_api_version: None,
api_key_required: true,
},
ProviderSpec {
name: "zhipu",
model_keywords: &["zhipu", "glm", "zai"],
runtime_supported: true,
default_base_url: Some("https://open.bigmodel.cn/api/paas/v4"),
backend: "openai",
default_auth_header: None,
default_api_version: None,
api_key_required: true,
},
ProviderSpec {
name: "vllm",
model_keywords: &["vllm"],
runtime_supported: true,
default_base_url: Some("http://localhost:8000/v1"),
backend: "openai",
default_auth_header: None,
default_api_version: None,
api_key_required: false,
},
ProviderSpec {
name: "gemini",
model_keywords: &["gemini"],
runtime_supported: true,
default_base_url: Some("https://generativelanguage.googleapis.com/v1beta/openai"),
backend: "openai",
default_auth_header: None,
default_api_version: None,
api_key_required: true,
},
ProviderSpec {
name: "vertex",
model_keywords: &["vertex"],
runtime_supported: true,
default_base_url: None, backend: "vertex",
default_auth_header: None,
default_api_version: None,
api_key_required: false, },
ProviderSpec {
name: "ollama",
model_keywords: &["ollama"],
runtime_supported: true,
default_base_url: Some("http://localhost:11434/v1"),
backend: "openai",
default_auth_header: None,
default_api_version: None,
api_key_required: false,
},
ProviderSpec {
name: "nvidia",
model_keywords: &["nvidia", "nim"],
runtime_supported: true,
default_base_url: Some("https://integrate.api.nvidia.com/v1"),
backend: "openai",
default_auth_header: None,
default_api_version: None,
api_key_required: true,
},
ProviderSpec {
name: "deepseek",
model_keywords: &["deepseek"],
runtime_supported: true,
default_base_url: Some("https://api.deepseek.com/v1"),
backend: "openai",
default_auth_header: None,
default_api_version: None,
api_key_required: true,
},
ProviderSpec {
name: "kimi",
model_keywords: &["kimi", "moonshot"],
runtime_supported: true,
default_base_url: Some("https://api.moonshot.cn/v1"),
backend: "openai",
default_auth_header: None,
default_api_version: None,
api_key_required: true,
},
ProviderSpec {
name: "azure",
model_keywords: &["azure"],
runtime_supported: true,
default_base_url: None, backend: "openai",
default_auth_header: Some("api-key"),
default_api_version: Some("2024-08-01-preview"),
api_key_required: true,
},
ProviderSpec {
name: "bedrock",
model_keywords: &["bedrock", "anthropic.claude", "meta.llama", "amazon.titan"],
runtime_supported: true,
default_base_url: None, backend: "openai",
default_auth_header: None, default_api_version: None,
api_key_required: true,
},
ProviderSpec {
name: "xai",
model_keywords: &["xai", "grok"],
runtime_supported: true,
default_base_url: Some("https://api.x.ai/v1"),
backend: "openai",
default_auth_header: None,
default_api_version: None,
api_key_required: true,
},
ProviderSpec {
name: "qianfan",
model_keywords: &["qianfan", "ernie", "baidu"],
runtime_supported: true,
default_base_url: Some("https://qianfan.baidubce.com/v2"),
backend: "openai",
default_auth_header: None,
default_api_version: None,
api_key_required: true,
},
ProviderSpec {
name: "novita",
model_keywords: &["novita"],
runtime_supported: true,
default_base_url: Some("https://api.novita.ai/openai"),
backend: "openai",
default_auth_header: None,
default_api_version: None,
api_key_required: true,
},
];
pub fn provider_config_by_name<'a>(config: &'a Config, name: &str) -> Option<&'a ProviderConfig> {
match name {
"anthropic" => config.providers.anthropic.as_ref(),
"openai" => config.providers.openai.as_ref(),
"openrouter" => config.providers.openrouter.as_ref(),
"groq" => config.providers.groq.as_ref(),
"zhipu" => config.providers.zhipu.as_ref(),
"vllm" => config.providers.vllm.as_ref(),
"gemini" => config.providers.gemini.as_ref(),
"vertex" => config.providers.vertex.as_ref(),
"ollama" => config.providers.ollama.as_ref(),
"nvidia" => config.providers.nvidia.as_ref(),
"deepseek" => config.providers.deepseek.as_ref(),
"kimi" => config.providers.kimi.as_ref(),
"azure" => config.providers.azure.as_ref(),
"bedrock" => config.providers.bedrock.as_ref(),
"xai" => config.providers.xai.as_ref(),
"qianfan" => config.providers.qianfan.as_ref(),
"novita" => config.providers.novita.as_ref(),
_ => None,
}
}
fn configured_api_key(provider: Option<&ProviderConfig>) -> Option<&str> {
provider
.and_then(|p| p.api_key.as_deref())
.and_then(|k| if k.is_empty() { None } else { Some(k) })
}
pub fn configured_provider_names(config: &Config) -> Vec<&'static str> {
PROVIDER_REGISTRY
.iter()
.filter_map(|spec| {
let provider = provider_config_by_name(config, spec.name)?;
if !spec.api_key_required || configured_api_key(Some(provider)).is_some() {
Some(spec.name)
} else {
None
}
})
.collect()
}
pub fn configured_provider_models(config: &Config) -> Vec<(String, String)> {
PROVIDER_REGISTRY
.iter()
.filter_map(|spec| {
let prov = provider_config_by_name(config, spec.name)?;
if spec.api_key_required && configured_api_key(Some(prov)).is_none() {
return None;
}
let model = prov.model.as_ref()?.clone();
if model.is_empty() {
return None;
}
Some((spec.name.to_string(), model))
})
.collect()
}
fn infer_provider_name_for_model(
model: &str,
available_providers: &[&str],
) -> Option<&'static str> {
let model_lower = model.to_ascii_lowercase();
if let Some((prefix, suffix)) = model_lower.split_once('/') {
if prefix == "openrouter" || available_providers.contains(&"openrouter") {
return Some("openrouter");
}
if let Some(spec) = PROVIDER_REGISTRY
.iter()
.filter(|spec| spec.runtime_supported)
.find(|spec| spec.name == prefix)
{
if available_providers.contains(&spec.name) {
return Some(spec.name);
}
}
return PROVIDER_REGISTRY
.iter()
.filter(|spec| spec.runtime_supported)
.find(|spec| spec.model_keywords.iter().any(|kw| suffix.contains(kw)))
.map(|spec| spec.name);
}
PROVIDER_REGISTRY
.iter()
.filter(|spec| spec.runtime_supported)
.find(|spec| {
spec.model_keywords
.iter()
.any(|kw| model_lower.contains(kw))
})
.map(|spec| spec.name)
}
pub fn provider_name_for_model(model: &str) -> Option<&'static str> {
infer_provider_name_for_model(model, &[])
}
pub fn provider_name_for_model_with_available(
model: &str,
available_providers: &[&str],
) -> Option<&'static str> {
infer_provider_name_for_model(model, available_providers)
}
pub fn configured_unsupported_provider_names(config: &Config) -> Vec<&'static str> {
PROVIDER_REGISTRY
.iter()
.filter_map(|spec| {
if spec.runtime_supported {
None
} else {
configured_api_key(provider_config_by_name(config, spec.name)).map(|_| spec.name)
}
})
.collect()
}
pub fn resolve_runtime_provider(config: &Config) -> Option<RuntimeProviderSelection> {
resolve_runtime_providers(config).into_iter().next()
}
pub fn resolve_runtime_providers(config: &Config) -> Vec<RuntimeProviderSelection> {
let mut resolved = Vec::new();
let token_store = crate::security::encryption::resolve_master_key(false)
.ok()
.map(crate::auth::store::TokenStore::new);
for spec in PROVIDER_REGISTRY
.iter()
.filter(|spec| spec.runtime_supported)
{
let provider = provider_config_by_name(config, spec.name);
let auth_method = provider
.map(|p| p.resolved_auth_method())
.unwrap_or_default();
let (credential, api_key_str) =
match resolve_credential(spec, &auth_method, provider, token_store.as_ref()) {
Some(pair) => pair,
None => continue, };
let user_base = provider.and_then(|p| p.api_base.clone()).and_then(|base| {
if base.is_empty() {
None
} else {
Some(base)
}
});
let api_base = user_base.or_else(|| spec.default_base_url.map(String::from));
let effective_auth_header = provider
.and_then(|p| p.auth_header.as_deref())
.map(str::trim)
.filter(|h| !h.is_empty())
.map(|h| h.to_string())
.or_else(|| spec.default_auth_header.map(String::from));
let effective_api_version = provider
.and_then(|p| p.api_version.as_deref())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(|v| v.to_string())
.or_else(|| spec.default_api_version.map(String::from));
resolved.push(RuntimeProviderSelection {
name: spec.name,
api_key: api_key_str,
api_base,
backend: spec.backend,
credential,
model: provider.and_then(|p| p.model.clone()),
auth_header: effective_auth_header,
api_version: effective_api_version,
});
}
resolved
}
fn resolve_credential(
spec: &ProviderSpec,
auth_method: &AuthMethod,
provider_config: Option<&ProviderConfig>,
token_store: Option<&crate::auth::store::TokenStore>,
) -> Option<(ResolvedCredential, String)> {
let provider_name = spec.name;
let api_key = configured_api_key(provider_config);
let result = match auth_method {
AuthMethod::ApiKey => {
api_key.map(|key| (ResolvedCredential::ApiKey(key.to_string()), key.to_string()))
}
AuthMethod::OAuth => {
try_load_oauth_token(provider_name, token_store)
.map(|token| (token, api_key.unwrap_or("").to_string()))
}
AuthMethod::Auto => {
if let Some(token) = try_load_oauth_token(provider_name, token_store) {
Some((token, api_key.unwrap_or("").to_string()))
} else {
api_key.map(|key| (ResolvedCredential::ApiKey(key.to_string()), key.to_string()))
}
}
};
if result.is_none() && !spec.api_key_required && provider_config.is_some() {
return Some((ResolvedCredential::ApiKey(String::new()), String::new()));
}
#[cfg(not(test))]
if result.is_none() && spec.name == "anthropic" {
if let Some(token_set) = crate::auth::claude_import::read_claude_credentials() {
static WARN_ONCE: std::sync::Once = std::sync::Once::new();
WARN_ONCE.call_once(|| {
eprintln!(
"\x1b[2mUsing Claude subscription token (unofficial). \
Set ZEPTOCLAW_PROVIDERS_ANTHROPIC_API_KEY for official API access.\x1b[0m"
);
});
let credential = ResolvedCredential::BearerToken {
access_token: token_set.access_token,
expires_at: token_set.expires_at,
};
return Some((credential, String::new()));
}
}
result
}
fn try_load_oauth_token(
provider_name: &str,
token_store: Option<&crate::auth::store::TokenStore>,
) -> Option<ResolvedCredential> {
let store = token_store?;
let token_set = store.load(provider_name).ok()??;
if token_set.is_expired() {
return None;
}
Some(ResolvedCredential::BearerToken {
access_token: token_set.access_token,
expires_at: token_set.expires_at,
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_configured_provider_names_registry_order() {
let mut config = Config::default();
config.providers.openai = Some(ProviderConfig {
api_key: Some("sk-openai".to_string()),
..Default::default()
});
config.providers.anthropic = Some(ProviderConfig {
api_key: Some("sk-ant".to_string()),
..Default::default()
});
let names = configured_provider_names(&config);
assert_eq!(names, vec!["anthropic", "openai"]);
}
#[test]
fn test_configured_unsupported_provider_names_empty_when_all_supported() {
let mut config = Config::default();
config.providers.openrouter = Some(ProviderConfig {
api_key: Some("sk-or".to_string()),
..Default::default()
});
config.providers.groq = Some(ProviderConfig {
api_key: Some("sk-groq".to_string()),
..Default::default()
});
let names = configured_unsupported_provider_names(&config);
assert!(names.is_empty());
}
#[test]
fn test_resolve_runtime_provider_priority() {
let mut config = Config::default();
config.providers.openai = Some(ProviderConfig {
api_key: Some("sk-openai".to_string()),
api_base: Some("https://example.com/v1".to_string()),
..Default::default()
});
config.providers.anthropic = Some(ProviderConfig {
api_key: Some("sk-ant".to_string()),
..Default::default()
});
let selected = resolve_runtime_provider(&config).expect("provider should resolve");
assert_eq!(selected.name, "anthropic");
assert_eq!(selected.api_key, "sk-ant");
assert_eq!(selected.api_base, None);
}
#[test]
fn test_resolve_runtime_provider_openai_base_url() {
let mut config = Config::default();
config.providers.openai = Some(ProviderConfig {
api_key: Some("sk-openai".to_string()),
api_base: Some("https://example.com/v1".to_string()),
..Default::default()
});
let selected = resolve_runtime_provider(&config).expect("provider should resolve");
assert_eq!(selected.name, "openai");
assert_eq!(selected.api_key, "sk-openai");
assert_eq!(selected.api_base.as_deref(), Some("https://example.com/v1"));
}
#[test]
fn test_resolve_runtime_providers_returns_all_supported() {
let mut config = Config::default();
config.providers.anthropic = Some(ProviderConfig {
api_key: Some("sk-ant".to_string()),
..Default::default()
});
config.providers.openai = Some(ProviderConfig {
api_key: Some("sk-openai".to_string()),
..Default::default()
});
let resolved = resolve_runtime_providers(&config);
assert_eq!(resolved.len(), 2);
assert_eq!(resolved[0].name, "anthropic");
assert_eq!(resolved[1].name, "openai");
}
#[test]
fn test_runtime_supported_constant_stays_in_sync() {
let runtime_supported: Vec<&str> = PROVIDER_REGISTRY
.iter()
.filter(|spec| spec.runtime_supported)
.map(|spec| spec.name)
.collect();
assert_eq!(
runtime_supported,
crate::providers::RUNTIME_SUPPORTED_PROVIDERS
);
}
#[test]
fn test_groq_resolves_with_default_base_url() {
let mut config = Config::default();
config.providers.groq = Some(ProviderConfig {
api_key: Some("gsk-test".to_string()),
..Default::default()
});
let selected = resolve_runtime_provider(&config).expect("provider should resolve");
assert_eq!(selected.name, "groq");
assert_eq!(selected.backend, "openai");
assert_eq!(
selected.api_base.as_deref(),
Some("https://api.groq.com/openai/v1")
);
}
#[test]
fn test_ollama_resolves_with_default_base_url() {
let mut config = Config::default();
config.providers.ollama = Some(ProviderConfig {
api_key: Some("ollama".to_string()),
..Default::default()
});
let selected = resolve_runtime_provider(&config).expect("provider should resolve");
assert_eq!(selected.name, "ollama");
assert_eq!(selected.backend, "openai");
assert_eq!(
selected.api_base.as_deref(),
Some("http://localhost:11434/v1")
);
}
#[test]
fn test_gemini_resolves_with_default_base_url() {
let mut config = Config::default();
config.providers.gemini = Some(ProviderConfig {
api_key: Some("AIza-test".to_string()),
..Default::default()
});
let selected = resolve_runtime_provider(&config).expect("provider should resolve");
assert_eq!(selected.name, "gemini");
assert_eq!(selected.backend, "openai");
assert!(selected
.api_base
.as_deref()
.unwrap()
.contains("generativelanguage"));
}
#[test]
fn test_user_base_url_overrides_default() {
let mut config = Config::default();
config.providers.groq = Some(ProviderConfig {
api_key: Some("gsk-test".to_string()),
api_base: Some("https://custom.groq.example/v1".to_string()),
..Default::default()
});
let selected = resolve_runtime_provider(&config).expect("provider should resolve");
assert_eq!(selected.name, "groq");
assert_eq!(
selected.api_base.as_deref(),
Some("https://custom.groq.example/v1")
);
}
#[test]
fn test_anthropic_has_no_default_base_url() {
let mut config = Config::default();
config.providers.anthropic = Some(ProviderConfig {
api_key: Some("sk-ant".to_string()),
..Default::default()
});
let selected = resolve_runtime_provider(&config).expect("provider should resolve");
assert_eq!(selected.name, "anthropic");
assert_eq!(selected.backend, "anthropic");
assert_eq!(selected.api_base, None);
}
#[test]
fn test_nvidia_resolves_with_default_base_url() {
let mut config = Config::default();
config.providers.nvidia = Some(ProviderConfig {
api_key: Some("nvapi-test".to_string()),
..Default::default()
});
let selected = resolve_runtime_provider(&config).expect("provider should resolve");
assert_eq!(selected.name, "nvidia");
assert_eq!(selected.backend, "openai");
assert_eq!(
selected.api_base.as_deref(),
Some("https://integrate.api.nvidia.com/v1")
);
}
#[test]
fn test_zhipu_resolves_with_default_base_url() {
let mut config = Config::default();
config.providers.zhipu = Some(ProviderConfig {
api_key: Some("zhipu-test-key".to_string()),
..Default::default()
});
let selected = resolve_runtime_provider(&config).expect("provider should resolve");
assert_eq!(selected.name, "zhipu");
assert_eq!(selected.backend, "openai");
assert_eq!(
selected.api_base.as_deref(),
Some("https://open.bigmodel.cn/api/paas/v4")
);
}
#[test]
fn test_openai_has_no_default_base_url() {
let mut config = Config::default();
config.providers.openai = Some(ProviderConfig {
api_key: Some("sk-openai".to_string()),
..Default::default()
});
let selected = resolve_runtime_provider(&config).expect("provider should resolve");
assert_eq!(selected.name, "openai");
assert_eq!(selected.backend, "openai");
assert_eq!(selected.api_base, None);
}
fn test_token_store_with_token(
token: crate::auth::OAuthTokenSet,
) -> (TempDir, crate::auth::store::TokenStore) {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tokens.json.enc");
let store = crate::auth::store::TokenStore::with_path(
path,
crate::security::encryption::SecretEncryption::from_raw_key(&[0x42u8; 32]),
);
store.save(&token).unwrap();
(tmp, store)
}
#[test]
fn test_try_load_oauth_token_skips_expired_token_even_with_refresh_token() {
let token = crate::auth::OAuthTokenSet {
provider: "anthropic".to_string(),
access_token: "expired-access".to_string(),
refresh_token: Some("refresh-token".to_string()),
expires_at: Some(chrono::Utc::now().timestamp() - 60),
token_type: "Bearer".to_string(),
scope: None,
obtained_at: chrono::Utc::now().timestamp() - 3600,
client_id: Some("zeptoclaw".to_string()),
};
let (_tmp, store) = test_token_store_with_token(token);
let resolved = try_load_oauth_token("anthropic", Some(&store));
assert!(resolved.is_none());
}
#[test]
fn test_resolve_credential_auto_falls_back_to_api_key_when_oauth_token_expired() {
let token = crate::auth::OAuthTokenSet {
provider: "anthropic".to_string(),
access_token: "expired-access".to_string(),
refresh_token: Some("refresh-token".to_string()),
expires_at: Some(chrono::Utc::now().timestamp() - 60),
token_type: "Bearer".to_string(),
scope: None,
obtained_at: chrono::Utc::now().timestamp() - 3600,
client_id: Some("zeptoclaw".to_string()),
};
let (_tmp, store) = test_token_store_with_token(token);
let provider = ProviderConfig {
api_key: Some("sk-ant-fallback".to_string()),
auth_method: Some("auto".to_string()),
..Default::default()
};
let spec = PROVIDER_REGISTRY
.iter()
.find(|s| s.name == "anthropic")
.expect("anthropic spec must exist");
let (credential, api_key) =
resolve_credential(spec, &AuthMethod::Auto, Some(&provider), Some(&store))
.expect("credential should resolve");
assert_eq!(api_key, "sk-ant-fallback");
assert!(matches!(credential, ResolvedCredential::ApiKey(_)));
}
#[test]
fn test_runtime_selection_carries_provider_model() {
let mut config = Config::default();
config.providers.anthropic = Some(ProviderConfig {
api_key: Some("sk-test".to_string()),
model: Some("claude-opus-4-20250514".to_string()),
..Default::default()
});
let resolved = resolve_runtime_providers(&config);
let anthropic = resolved.iter().find(|s| s.name == "anthropic").unwrap();
assert_eq!(anthropic.model, Some("claude-opus-4-20250514".to_string()));
}
#[test]
fn test_runtime_selection_model_none_when_not_configured() {
let mut config = Config::default();
config.providers.anthropic = Some(ProviderConfig {
api_key: Some("sk-test".to_string()),
..Default::default()
});
let resolved = resolve_runtime_providers(&config);
let anthropic = resolved.iter().find(|s| s.name == "anthropic").unwrap();
assert_eq!(anthropic.model, None);
}
#[test]
fn test_azure_provider_resolves_with_auth_header_and_api_version() {
let mut config = Config::default();
config.providers.azure = Some(ProviderConfig {
api_key: Some("my-azure-key".to_string()),
api_base: Some("https://myco.openai.azure.com/openai/deployments/gpt-4o".to_string()),
..Default::default()
});
let selected = resolve_runtime_provider(&config).expect("should resolve");
assert_eq!(selected.name, "azure");
assert_eq!(selected.backend, "openai");
assert_eq!(selected.auth_header.as_deref(), Some("api-key"));
assert_eq!(selected.api_version.as_deref(), Some("2024-08-01-preview"));
assert_eq!(
selected.api_base.as_deref(),
Some("https://myco.openai.azure.com/openai/deployments/gpt-4o")
);
}
#[test]
fn test_azure_user_can_override_api_version() {
let mut config = Config::default();
config.providers.azure = Some(ProviderConfig {
api_key: Some("my-azure-key".to_string()),
api_version: Some("2025-01-01-preview".to_string()),
..Default::default()
});
let selected = resolve_runtime_provider(&config).expect("should resolve");
assert_eq!(selected.name, "azure");
assert_eq!(selected.api_version.as_deref(), Some("2025-01-01-preview"));
}
#[test]
fn test_bedrock_provider_resolves_with_default_base_url() {
let mut config = Config::default();
config.providers.bedrock = Some(ProviderConfig {
api_key: Some("aws-sig-placeholder".to_string()),
api_base: Some("https://my-sigv4-proxy.example.com/v1".to_string()),
..Default::default()
});
let selected = resolve_runtime_provider(&config).expect("should resolve");
assert_eq!(selected.name, "bedrock");
assert_eq!(selected.backend, "openai");
assert!(selected.auth_header.is_none());
assert!(selected.api_version.is_none());
assert_eq!(
selected.api_base.as_deref(),
Some("https://my-sigv4-proxy.example.com/v1")
);
}
#[test]
fn test_standard_provider_auth_header_is_none() {
let mut config = Config::default();
config.providers.openai = Some(ProviderConfig {
api_key: Some("sk-openai".to_string()),
..Default::default()
});
let selected = resolve_runtime_provider(&config).expect("should resolve");
assert_eq!(selected.name, "openai");
assert!(selected.auth_header.is_none());
assert!(selected.api_version.is_none());
}
#[test]
fn test_runtime_supported_constant_includes_azure_and_bedrock() {
assert!(crate::providers::RUNTIME_SUPPORTED_PROVIDERS.contains(&"azure"));
assert!(crate::providers::RUNTIME_SUPPORTED_PROVIDERS.contains(&"bedrock"));
}
#[test]
fn test_configured_provider_models_returns_providers_with_model_set() {
let mut config = Config::default();
config.providers.nvidia = Some(ProviderConfig {
api_key: Some("nvapi-test".to_string()),
model: Some("nvidia/llama-3.3-70b".to_string()),
..Default::default()
});
config.providers.anthropic = Some(ProviderConfig {
api_key: Some("sk-ant".to_string()),
..Default::default()
});
let models = configured_provider_models(&config);
assert_eq!(models.len(), 1);
assert_eq!(models[0].0, "nvidia");
assert_eq!(models[0].1, "nvidia/llama-3.3-70b");
}
#[test]
fn test_configured_provider_models_skips_empty_model() {
let mut config = Config::default();
config.providers.openai = Some(ProviderConfig {
api_key: Some("sk-openai".to_string()),
model: Some("".to_string()),
..Default::default()
});
let models = configured_provider_models(&config);
assert!(models.is_empty());
}
#[test]
fn test_configured_provider_models_skips_no_api_key() {
let mut config = Config::default();
config.providers.nvidia = Some(ProviderConfig {
model: Some("nvidia/llama-3.3-70b".to_string()),
..Default::default()
});
let models = configured_provider_models(&config);
assert!(models.is_empty());
}
#[test]
fn test_empty_auth_header_falls_through_to_spec_default() {
let mut config = Config::default();
config.providers.azure = Some(ProviderConfig {
api_key: Some("my-azure-key".to_string()),
api_base: Some("https://myco.openai.azure.com/openai/deployments/gpt-4o".to_string()),
auth_header: Some("".to_string()), ..Default::default()
});
let selected = resolve_runtime_provider(&config).expect("should resolve");
assert_eq!(selected.name, "azure");
assert_eq!(selected.auth_header.as_deref(), Some("api-key"));
}
#[test]
fn test_xai_resolves_with_default_base_url() {
let mut config = Config::default();
config.providers.xai = Some(ProviderConfig {
api_key: Some("xai-test-key".to_string()),
..Default::default()
});
let selected = resolve_runtime_provider(&config).expect("provider should resolve");
assert_eq!(selected.name, "xai");
assert_eq!(selected.backend, "openai");
assert_eq!(selected.api_base.as_deref(), Some("https://api.x.ai/v1"));
}
#[test]
fn test_qianfan_resolves_with_default_base_url() {
let mut config = Config::default();
config.providers.qianfan = Some(ProviderConfig {
api_key: Some("qf-test-key".to_string()),
..Default::default()
});
let selected = resolve_runtime_provider(&config).expect("provider should resolve");
assert_eq!(selected.name, "qianfan");
assert_eq!(selected.backend, "openai");
assert_eq!(
selected.api_base.as_deref(),
Some("https://qianfan.baidubce.com/v2")
);
}
#[test]
fn test_novita_resolves_with_default_base_url() {
let mut config = Config::default();
config.providers.novita = Some(ProviderConfig {
api_key: Some("novita-test-key".to_string()),
..Default::default()
});
let selected = resolve_runtime_provider(&config).expect("provider should resolve");
assert_eq!(selected.name, "novita");
assert_eq!(selected.backend, "openai");
assert_eq!(
selected.api_base.as_deref(),
Some("https://api.novita.ai/openai")
);
}
#[test]
fn test_xai_and_qianfan_in_fallback_chain() {
let mut config = Config::default();
config.providers.xai = Some(ProviderConfig {
api_key: Some("xai-key".to_string()),
..Default::default()
});
config.providers.qianfan = Some(ProviderConfig {
api_key: Some("qf-key".to_string()),
..Default::default()
});
let resolved = resolve_runtime_providers(&config);
assert_eq!(resolved.len(), 2);
assert_eq!(resolved[0].name, "xai");
assert_eq!(resolved[1].name, "qianfan");
}
#[test]
fn test_ollama_resolves_without_api_key() {
let mut config = Config::default();
config.providers.ollama = Some(ProviderConfig {
api_base: Some("http://localhost:11434/v1".to_string()),
..Default::default()
});
let selected =
resolve_runtime_provider(&config).expect("ollama should resolve without api key");
assert_eq!(selected.name, "ollama");
assert_eq!(selected.api_key, "");
assert_eq!(
selected.api_base.as_deref(),
Some("http://localhost:11434/v1")
);
}
#[test]
fn test_vllm_resolves_without_api_key() {
let mut config = Config::default();
config.providers.vllm = Some(ProviderConfig {
api_base: Some("http://gpu-server:8000/v1".to_string()),
..Default::default()
});
let selected =
resolve_runtime_provider(&config).expect("vllm should resolve without api key");
assert_eq!(selected.name, "vllm");
assert_eq!(selected.api_key, "");
}
#[test]
fn test_ollama_bare_config_resolves_with_defaults() {
let mut config = Config::default();
config.providers.ollama = Some(ProviderConfig::default());
let selected =
resolve_runtime_provider(&config).expect("bare ollama config should resolve");
assert_eq!(selected.name, "ollama");
assert_eq!(selected.api_key, "");
assert_eq!(
selected.api_base.as_deref(),
Some("http://localhost:11434/v1")
);
}
#[test]
fn test_ollama_with_api_key_still_resolves_with_key() {
let mut config = Config::default();
config.providers.ollama = Some(ProviderConfig {
api_key: Some("secret-cloud-key".to_string()),
api_base: Some("https://cloud-ollama.example.com/v1".to_string()),
..Default::default()
});
let selected = resolve_runtime_provider(&config).expect("ollama with key should resolve");
assert_eq!(selected.name, "ollama");
assert_eq!(selected.api_key, "secret-cloud-key");
assert_eq!(
selected.api_base.as_deref(),
Some("https://cloud-ollama.example.com/v1")
);
}
#[test]
fn test_configured_provider_names_includes_keyless_ollama() {
let mut config = Config::default();
config.providers.ollama = Some(ProviderConfig::default());
let names = configured_provider_names(&config);
assert!(
names.contains(&"ollama"),
"keyless ollama should appear in configured providers"
);
}
#[test]
fn test_configured_provider_models_includes_keyless_provider_with_model() {
let mut config = Config::default();
config.providers.ollama = Some(ProviderConfig {
model: Some("llama3.3".to_string()),
..Default::default()
});
let models = configured_provider_models(&config);
assert_eq!(models.len(), 1);
assert_eq!(models[0].0, "ollama");
assert_eq!(models[0].1, "llama3.3");
}
#[test]
fn test_provider_name_for_model_openai() {
assert_eq!(provider_name_for_model("gpt-4o"), Some("openai"));
assert_eq!(provider_name_for_model("gpt-4o-mini"), Some("openai"));
assert_eq!(provider_name_for_model("o3-mini"), None);
}
#[test]
fn test_provider_name_for_model_anthropic() {
assert_eq!(
provider_name_for_model("claude-sonnet-4-5-20250929"),
Some("anthropic")
);
assert_eq!(
provider_name_for_model("claude-3-haiku-20240307"),
Some("anthropic")
);
}
#[test]
fn test_provider_name_for_model_gemini() {
assert_eq!(provider_name_for_model("gemini-2.0-flash"), Some("gemini"));
}
#[test]
fn test_provider_name_for_model_deepseek() {
assert_eq!(provider_name_for_model("deepseek-chat"), Some("deepseek"));
}
#[test]
fn test_provider_name_for_model_ollama_keyword() {
assert_eq!(provider_name_for_model("ollama-custom"), Some("ollama"));
assert_eq!(provider_name_for_model("llama3.3"), None);
assert_eq!(provider_name_for_model("mistral-7b"), None);
assert_eq!(provider_name_for_model("qwen2.5-72b"), None);
}
#[test]
fn test_provider_name_for_model_prefixed_ids() {
assert_eq!(
provider_name_for_model("openrouter/auto"),
Some("openrouter")
);
assert_eq!(
provider_name_for_model("anthropic/claude-sonnet-4-6"),
Some("anthropic")
);
assert_eq!(provider_name_for_model("openai/gpt-5.4"), Some("openai"));
assert_eq!(provider_name_for_model("meta/llama-4-scout-17b"), None);
}
#[test]
fn test_provider_name_for_model_prefers_openrouter_when_available() {
assert_eq!(
provider_name_for_model_with_available("anthropic/claude-sonnet-4-6", &["openrouter"]),
Some("openrouter")
);
assert_eq!(
provider_name_for_model_with_available("openai/gpt-5.4", &["anthropic", "openrouter"]),
Some("openrouter")
);
assert_eq!(
provider_name_for_model_with_available("anthropic/claude-sonnet-4-6", &["anthropic"]),
Some("anthropic")
);
}
#[test]
fn test_provider_name_for_model_case_insensitive() {
assert_eq!(
provider_name_for_model("Claude-Sonnet-4"),
Some("anthropic")
);
assert_eq!(provider_name_for_model("GPT-4o"), Some("openai"));
}
#[test]
fn test_provider_name_for_model_no_match() {
assert_eq!(provider_name_for_model("some-unknown-model"), None);
assert_eq!(provider_name_for_model(""), None);
}
}