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>,
}
#[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,
},
ProviderSpec {
name: "openai",
model_keywords: &["openai", "gpt"],
runtime_supported: true,
default_base_url: None,
backend: "openai",
default_auth_header: None,
default_api_version: None,
},
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,
},
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,
},
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,
},
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,
},
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,
},
ProviderSpec {
name: "ollama",
model_keywords: &["ollama", "llama", "mistral", "phi", "qwen"],
runtime_supported: true,
default_base_url: Some("http://localhost:11434/v1"),
backend: "openai",
default_auth_header: None,
default_api_version: None,
},
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,
},
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,
},
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,
},
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"),
},
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,
},
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,
},
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,
},
];
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(),
"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(),
_ => 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| {
configured_api_key(provider_config_by_name(config, spec.name)).map(|_| spec.name)
})
.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)?;
configured_api_key(Some(prov))?;
let model = prov.model.as_ref()?.clone();
if model.is_empty() {
return None;
}
Some((spec.name.to_string(), model))
})
.collect()
}
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.name, &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(
provider_name: &str,
auth_method: &AuthMethod,
provider_config: Option<&ProviderConfig>,
token_store: Option<&crate::auth::store::TokenStore>,
) -> Option<(ResolvedCredential, String)> {
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()))
}
}
};
#[cfg(not(test))]
if result.is_none() && provider_name == "anthropic" {
if let Some(token_set) = crate::auth::claude_import::read_claude_credentials() {
tracing::warn!(
"Using Claude subscription token (unofficial). \
This may violate Anthropic's Terms of Service. \
Set ZEPTOCLAW_PROVIDERS_ANTHROPIC_API_KEY for official API access."
);
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_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 (credential, api_key) = resolve_credential(
"anthropic",
&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_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");
}
}