use crate::provider::DriverId;
#[derive(Clone, Default, PartialEq, Eq)]
pub struct ProviderCredentials {
pub api_key: Option<String>,
pub base_url: Option<String>,
}
impl ProviderCredentials {
pub fn is_empty(&self) -> bool {
self.api_key.is_none() && self.base_url.is_none()
}
}
impl std::fmt::Debug for ProviderCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProviderCredentials")
.field("api_key", &self.api_key.as_ref().map(|_| "[REDACTED]"))
.field("base_url", &self.base_url)
.finish()
}
}
pub trait CredentialProvider: Send + Sync {
fn resolve(&self, driver: &DriverId) -> Option<ProviderCredentials>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct EnvCredentialProvider;
impl EnvCredentialProvider {
pub fn new() -> Self {
Self
}
fn resolve_with<F>(driver: &DriverId, lookup: F) -> Option<ProviderCredentials>
where
F: Fn(&str) -> Option<String>,
{
let stem = driver
.as_str()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_uppercase()
} else {
'_'
}
})
.collect::<String>();
let key_var = format!("{stem}_API_KEY");
let url_var = format!("{stem}_BASE_URL");
let non_empty = |s: String| (!s.is_empty()).then_some(s);
let mut api_key = lookup(&key_var).and_then(non_empty);
let mut base_url = lookup(&url_var).and_then(non_empty);
if driver == &DriverId::OpenAICompletions {
api_key = api_key.or_else(|| lookup("OPENAI_API_KEY").and_then(non_empty));
base_url = base_url.or_else(|| lookup("OPENAI_BASE_URL").and_then(non_empty));
}
let creds = ProviderCredentials { api_key, base_url };
(!creds.is_empty()).then_some(creds)
}
}
impl CredentialProvider for EnvCredentialProvider {
fn resolve(&self, driver: &DriverId) -> Option<ProviderCredentials> {
Self::resolve_with(driver, |name| std::env::var(name).ok())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn lookup_from(map: &HashMap<&'static str, &'static str>) -> impl Fn(&str) -> Option<String> {
let owned: HashMap<String, String> = map
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
move |name: &str| owned.get(name).cloned()
}
#[test]
fn openai_reads_key_and_base_url() {
let env = HashMap::from([
("OPENAI_API_KEY", "sk-test"),
("OPENAI_BASE_URL", "https://proxy.example/v1"),
]);
let creds = EnvCredentialProvider::resolve_with(&DriverId::OpenAI, lookup_from(&env))
.expect("credentials");
assert_eq!(creds.api_key.as_deref(), Some("sk-test"));
assert_eq!(creds.base_url.as_deref(), Some("https://proxy.example/v1"));
}
#[test]
fn openai_completions_shares_openai_key() {
let env = HashMap::from([("OPENAI_API_KEY", "sk-test")]);
let creds =
EnvCredentialProvider::resolve_with(&DriverId::OpenAICompletions, lookup_from(&env))
.expect("credentials");
assert_eq!(creds.api_key.as_deref(), Some("sk-test"));
assert!(creds.base_url.is_none());
}
#[test]
fn anthropic_and_gemini_keys() {
let env = HashMap::from([("ANTHROPIC_API_KEY", "sk-ant"), ("GEMINI_API_KEY", "g-key")]);
let lookup = lookup_from(&env);
assert_eq!(
EnvCredentialProvider::resolve_with(&DriverId::Anthropic, &lookup)
.and_then(|c| c.api_key)
.as_deref(),
Some("sk-ant"),
);
assert_eq!(
EnvCredentialProvider::resolve_with(&DriverId::Gemini, &lookup)
.and_then(|c| c.api_key)
.as_deref(),
Some("g-key"),
);
}
#[test]
fn empty_or_missing_yields_none() {
let env = HashMap::from([("OPENAI_API_KEY", "")]);
assert!(
EnvCredentialProvider::resolve_with(&DriverId::OpenAI, lookup_from(&env)).is_none()
);
let empty = HashMap::new();
assert!(
EnvCredentialProvider::resolve_with(&DriverId::Anthropic, lookup_from(&empty))
.is_none()
);
}
#[test]
fn debug_output_redacts_the_api_key() {
let creds = ProviderCredentials {
api_key: Some("sk-super-secret".to_string()),
base_url: Some("https://proxy.example/v1".to_string()),
};
let rendered = format!("{creds:?}");
assert!(!rendered.contains("sk-super-secret"), "{rendered}");
assert!(rendered.contains("[REDACTED]"));
assert!(
rendered.contains("https://proxy.example/v1"),
"base URL is not a secret: {rendered}"
);
}
#[test]
fn unsupported_drivers_return_none() {
let env = HashMap::from([("AWS_ACCESS_KEY_ID", "x")]);
let lookup = lookup_from(&env);
assert!(EnvCredentialProvider::resolve_with(&DriverId::Bedrock, &lookup).is_none());
assert!(EnvCredentialProvider::resolve_with(&DriverId::LlmSim, &lookup).is_none());
}
}