use std::collections::BTreeMap;
use crate::credential_schema::assemble_credential_document;
use crate::driver_registry::DriverDescriptor;
#[derive(Clone, Default, PartialEq, Eq)]
pub struct ProviderCredentials {
fields: BTreeMap<String, String>,
base_url: Option<String>,
}
impl ProviderCredentials {
pub fn new(fields: BTreeMap<String, String>, base_url: Option<String>) -> Self {
Self { fields, base_url }
}
pub fn api_key(&self) -> Option<&str> {
self.field("api_key")
}
pub fn base_url(&self) -> Option<&str> {
self.base_url.as_deref()
}
pub fn field(&self, name: &str) -> Option<&str> {
self.fields.get(name).map(String::as_str)
}
pub fn fields(&self) -> &BTreeMap<String, String> {
&self.fields
}
pub fn document(&self) -> Option<String> {
assemble_credential_document(&self.fields)
}
pub fn is_empty(&self) -> bool {
self.fields.is_empty() && 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(
"fields",
&self
.fields
.keys()
.map(|name| (name.as_str(), "[REDACTED]"))
.collect::<BTreeMap<_, _>>(),
)
.field("base_url", &self.base_url)
.finish()
}
}
pub trait CredentialProvider: Send + Sync {
fn resolve(&self, driver: &DriverDescriptor) -> Option<ProviderCredentials>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct EnvCredentialProvider;
impl EnvCredentialProvider {
pub fn new() -> Self {
Self
}
pub fn resolve_with<F>(driver: &DriverDescriptor, lookup: F) -> Option<ProviderCredentials>
where
F: Fn(&str) -> Option<String>,
{
let credentials = ProviderCredentials::new(
driver.credential_schema.resolve_from_env(&lookup),
driver.base_url_from_env(&lookup),
);
(!credentials.is_empty()).then_some(credentials)
}
}
impl CredentialProvider for EnvCredentialProvider {
fn resolve(&self, driver: &DriverDescriptor) -> Option<ProviderCredentials> {
Self::resolve_with(driver, |name| std::env::var(name).ok())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum EnvCredentialError {
Missing {
driver: String,
variables: Vec<String>,
},
NoChatService {
driver: String,
},
}
impl std::fmt::Display for EnvCredentialError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EnvCredentialError::Missing { driver, variables } if variables.is_empty() => {
write!(f, "driver {driver} declares no environment variables")
}
EnvCredentialError::Missing { driver, variables } => write!(
f,
"no credentials for {driver} in the environment; set {}",
variables.join(" or ")
),
EnvCredentialError::NoChatService { driver } => {
write!(f, "driver {driver} does not implement the chat service")
}
}
}
}
impl std::error::Error for EnvCredentialError {}
pub fn provider_from_env(
driver: &DriverDescriptor,
id: impl Into<crate::runtime_provider::ProviderKey>,
) -> Result<crate::runtime_provider::Provider, EnvCredentialError> {
provider_from_env_with(driver, id, |name| std::env::var(name).ok())
}
pub fn provider_from_env_with<F>(
driver: &DriverDescriptor,
id: impl Into<crate::runtime_provider::ProviderKey>,
lookup: F,
) -> Result<crate::runtime_provider::Provider, EnvCredentialError>
where
F: Fn(&str) -> Option<String>,
{
let factory = driver
.chat
.as_ref()
.ok_or_else(|| EnvCredentialError::NoChatService {
driver: driver.id.to_string(),
})?;
let missing = || EnvCredentialError::Missing {
driver: driver.id.to_string(),
variables: driver.declared_env_vars(),
};
let document = EnvCredentialProvider::resolve_with(driver, lookup)
.and_then(|credentials| {
credentials
.document()
.map(|document| (document, credentials.base_url().map(str::to_owned)))
})
.ok_or_else(missing)?;
let (document, base_url) = document;
let id = id.into();
let config =
crate::driver_registry::ProviderConfig::for_provider(id.clone(), driver.id.clone())
.with_api_key(document);
let config = match base_url {
Some(base_url) => config.with_base_url(base_url),
None => config,
};
let config = crate::driver_registry::DriverConfig::from_provider_config(&config);
Ok(crate::runtime_provider::Provider::from_driver(
id,
factory(&config).into(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::credential_schema::{CredentialFormSchema, FormField};
use crate::driver_registry::BoxedChatDriver;
use crate::provider::DriverId;
fn driver(
id: &'static str,
fields: Vec<FormField>,
base_url_env: Option<&str>,
) -> DriverDescriptor {
DriverDescriptor {
credential_schema: CredentialFormSchema {
fields,
instructions_markdown: String::new(),
},
base_url_env: base_url_env.map(str::to_owned),
..DriverDescriptor::chat_only(DriverId::external(id), |_| -> BoxedChatDriver {
unreachable!("credential resolution never constructs the driver")
})
}
}
fn resolve(driver: &DriverDescriptor, entries: &[(&str, &str)]) -> Option<ProviderCredentials> {
EnvCredentialProvider::resolve_with(driver, |name| {
entries
.iter()
.find(|(key, _)| *key == name)
.map(|(_, value)| value.to_string())
})
}
#[test]
fn a_driver_resolves_only_the_variables_it_declares() {
let anthropic = driver(
"anthropic",
vec![
FormField::password("api_key", "API Key")
.required()
.env("ANTHROPIC_API_KEY"),
],
Some("ANTHROPIC_BASE_URL"),
);
let resolved = resolve(
&anthropic,
&[
("ANTHROPIC_API_KEY", "key"),
("ANTHROPIC_BASE_URL", "https://proxy.example"),
("OPENAI_API_KEY", "someone else's"),
],
)
.expect("declared variables are set");
assert_eq!(resolved.api_key(), Some("key"));
assert_eq!(resolved.base_url(), Some("https://proxy.example"));
assert_eq!(resolved.document(), Some("key".to_string()));
assert_eq!(resolve(&anthropic, &[("OPENAI_API_KEY", "other")]), None);
assert_eq!(resolve(&anthropic, &[("ANTHROPIC_API_KEY", "")]), None);
assert_eq!(resolve(&anthropic, &[]), None);
}
#[test]
fn a_multi_field_driver_resolves_every_declared_field() {
let bedrock = driver(
"bedrock",
vec![
FormField::password("access_key_id", "Access Key ID")
.required()
.env("AWS_ACCESS_KEY_ID"),
FormField::password("secret_access_key", "Secret Access Key")
.required()
.env("AWS_SECRET_ACCESS_KEY"),
FormField::text("region", "Region")
.env("AWS_REGION")
.env_fallback("AWS_DEFAULT_REGION"),
],
None,
);
let resolved = resolve(
&bedrock,
&[
("AWS_ACCESS_KEY_ID", "AKIA"),
("AWS_SECRET_ACCESS_KEY", "secret"),
("AWS_DEFAULT_REGION", "eu-west-1"),
],
)
.expect("AWS variables are set");
assert_eq!(resolved.field("access_key_id"), Some("AKIA"));
assert_eq!(resolved.field("region"), Some("eu-west-1"));
assert_eq!(
resolved.document(),
Some(
r#"{"access_key_id":"AKIA","region":"eu-west-1","secret_access_key":"secret"}"#
.to_string()
)
);
}
#[test]
fn a_driver_that_declares_nothing_never_resolves_from_the_environment() {
let undeclared = driver(
"custom-driver.v2",
vec![FormField::password("api_key", "API Key").required()],
None,
);
assert_eq!(
resolve(
&undeclared,
&[
("API_KEY", "set"),
("CUSTOM_DRIVER_V2_API_KEY", "set"),
("OPENAI_API_KEY", "set"),
]
),
None
);
assert_eq!(resolve(&driver("llmsim", vec![], None), &[]), None);
}
#[test]
fn provider_from_env_builds_through_the_drivers_own_factory() {
use crate::driver_registry::{BoxedChatDriver, DriverConfig};
fn factory(config: &DriverConfig) -> BoxedChatDriver {
assert_eq!(
config.credentials.get("api_key").map(String::as_str),
Some("key")
);
assert_eq!(config.base_url.as_deref(), Some("https://proxy.example"));
assert_eq!(config.provider.as_str(), "my-openai");
crate::runtime_provider::Provider::new(config.provider.clone(), NoopDriver)
.into_boxed_driver()
}
let descriptor = DriverDescriptor {
credential_schema: CredentialFormSchema::api_key("VENDOR_API_KEY", ""),
base_url_env: Some("VENDOR_BASE_URL".into()),
..DriverDescriptor::chat_only(DriverId::external("vendor"), factory)
};
let provider = provider_from_env_with(&descriptor, "my-openai", |name| match name {
"VENDOR_API_KEY" => Some("key".to_string()),
"VENDOR_BASE_URL" => Some("https://proxy.example".to_string()),
_ => None,
})
.expect("declared variables are set");
assert_eq!(provider.id().as_str(), "my-openai");
}
#[test]
fn a_missing_credential_names_the_variables_the_driver_declares() {
let descriptor = DriverDescriptor {
credential_schema: CredentialFormSchema::api_key("VENDOR_API_KEY", ""),
base_url_env: Some("VENDOR_BASE_URL".into()),
..DriverDescriptor::chat_only(DriverId::external("vendor"), |_| -> BoxedChatDriver {
unreachable!("no credential, no driver")
})
};
let error = provider_from_env_with(&descriptor, "vendor", |_| None).unwrap_err();
assert_eq!(
error,
EnvCredentialError::Missing {
driver: "vendor".to_string(),
variables: vec!["VENDOR_API_KEY".to_string(), "VENDOR_BASE_URL".to_string()],
}
);
assert_eq!(
error.to_string(),
"no credentials for vendor in the environment; set VENDOR_API_KEY or VENDOR_BASE_URL"
);
}
struct NoopDriver;
#[async_trait::async_trait]
impl crate::driver_registry::ChatDriver for NoopDriver {
async fn chat_completion_stream(
&self,
_endpoint: &crate::runtime_provider::ProviderEndpoint,
_messages: Vec<crate::driver_registry::LlmMessage>,
_config: &crate::driver_registry::LlmCallConfig,
) -> crate::error::Result<crate::driver_registry::LlmResponseStream> {
unreachable!("credential wiring never sends a request")
}
}
#[test]
fn debug_output_redacts_every_field_value() {
let credentials = ProviderCredentials::new(
BTreeMap::from([
("api_key".to_string(), "sk-super-secret".to_string()),
("client_secret".to_string(), "also-secret".to_string()),
]),
Some("https://proxy.example".into()),
);
assert_eq!(
format!("{credentials:?}"),
"ProviderCredentials { fields: {\"api_key\": \"[REDACTED]\", \"client_secret\": \"[REDACTED]\"}, base_url: Some(\"https://proxy.example\") }"
);
}
}