use super::model::{ApiProtocol, ModelConfig};
use super::traits::*;
use crate::types::*;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::mpsc;
pub struct ProviderRegistry {
providers: HashMap<ApiProtocol, Arc<dyn StreamProvider>>,
}
impl ProviderRegistry {
pub fn new() -> Self {
Self {
providers: HashMap::new(),
}
}
pub fn register(&mut self, protocol: ApiProtocol, provider: impl StreamProvider + 'static) {
self.providers.insert(protocol, Arc::new(provider));
}
pub fn resolve(&self, protocol: &ApiProtocol) -> Option<Arc<dyn StreamProvider>> {
self.providers.get(protocol).cloned()
}
pub fn get(&self, protocol: &ApiProtocol) -> Option<&dyn StreamProvider> {
self.providers.get(protocol).map(|p| p.as_ref())
}
pub fn has(&self, protocol: &ApiProtocol) -> bool {
self.providers.contains_key(protocol)
}
pub fn protocols(&self) -> Vec<ApiProtocol> {
self.providers.keys().copied().collect()
}
pub async fn stream(
&self,
model: &ModelConfig,
config: StreamConfig,
tx: mpsc::UnboundedSender<StreamEvent>,
cancel: tokio_util::sync::CancellationToken,
) -> Result<Message, ProviderError> {
let provider = self.providers.get(&model.api).ok_or_else(|| {
ProviderError::Other(format!(
"No provider registered for protocol: {}",
model.api
))
})?;
provider.stream(config, tx, cancel).await
}
}
impl Default for ProviderRegistry {
fn default() -> Self {
use crate::provider::{
AnthropicProvider, AzureOpenAiProvider, BedrockProvider, GoogleProvider,
GoogleVertexProvider, OpenAiCompatProvider, OpenAiResponsesProvider,
};
let mut registry = Self::new();
registry.register(ApiProtocol::AnthropicMessages, AnthropicProvider);
registry.register(ApiProtocol::OpenAiCompletions, OpenAiCompatProvider);
registry.register(ApiProtocol::OpenAiResponses, OpenAiResponsesProvider);
registry.register(ApiProtocol::GoogleGenerativeAi, GoogleProvider);
registry.register(ApiProtocol::GoogleVertex, GoogleVertexProvider);
registry.register(ApiProtocol::BedrockConverseStream, BedrockProvider);
registry.register(ApiProtocol::AzureOpenAiResponses, AzureOpenAiProvider);
registry
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_registry_has_all_providers() {
let registry = ProviderRegistry::default();
assert!(registry.has(&ApiProtocol::AnthropicMessages));
assert!(registry.has(&ApiProtocol::OpenAiCompletions));
assert!(registry.has(&ApiProtocol::OpenAiResponses));
assert!(registry.has(&ApiProtocol::GoogleGenerativeAi));
assert!(registry.has(&ApiProtocol::GoogleVertex));
assert!(registry.has(&ApiProtocol::BedrockConverseStream));
assert!(registry.has(&ApiProtocol::AzureOpenAiResponses));
}
#[test]
fn test_registry_protocols() {
let registry = ProviderRegistry::default();
let protocols = registry.protocols();
assert_eq!(protocols.len(), 7);
}
#[test]
fn test_custom_registry() {
let mut registry = ProviderRegistry::new();
assert!(!registry.has(&ApiProtocol::AnthropicMessages));
registry.register(
ApiProtocol::AnthropicMessages,
crate::provider::AnthropicProvider,
);
assert!(registry.has(&ApiProtocol::AnthropicMessages));
}
#[test]
fn api_key_variables_lists_every_credential_once() {
let names = super::api_key_variables();
for name in [
"ANTHROPIC_API_KEY",
"GOOGLE_API_KEY",
"MODEL_API_KEY",
"OPENCODE_API_KEY",
"AZURE_OPENAI_API_KEY",
"AWS_BEARER_TOKEN_BEDROCK",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"YOAGENT_API_KEY",
"API_KEY",
"TYPESAFE_API_KEY",
"CLOUDFLARE_AUTH_TOKEN",
] {
assert!(names.contains(&name), "{name} missing from {names:?}");
}
let mut unique = names.clone();
unique.sort_unstable();
unique.dedup();
assert_eq!(unique.len(), names.len(), "a name listed twice: {names:?}");
assert_eq!(&names[..2], ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]);
}
#[cfg(feature = "decision")]
#[test]
fn decision_key_variables_match_the_presets() {
use crate::decision::{
CLOUDFLARE_API_TOKEN_ENV, CLOUDFLARE_AUTH_TOKEN_ENV, TYPESAFE_API_KEY_ENV,
};
assert_eq!(
super::DECISION_KEY_VARIABLES,
[
TYPESAFE_API_KEY_ENV,
CLOUDFLARE_API_TOKEN_ENV,
CLOUDFLARE_AUTH_TOKEN_ENV
]
);
}
}
const PROVIDER_KEY_VARIABLES: &[(&str, &[&str])] = &[
("anthropic", &["ANTHROPIC_API_KEY"]),
("openai", &["OPENAI_API_KEY"]),
("google", &["GEMINI_API_KEY", "GOOGLE_API_KEY"]),
("xai", &["XAI_API_KEY"]),
("groq", &["GROQ_API_KEY"]),
("deepseek", &["DEEPSEEK_API_KEY"]),
("mistral", &["MISTRAL_API_KEY"]),
("zai", &["ZAI_API_KEY"]),
("minimax", &["MINIMAX_API_KEY"]),
("meta", &["META_API_KEY", "MODEL_API_KEY"]),
("openrouter", &["OPENROUTER_API_KEY"]),
("cerebras", &["CEREBRAS_API_KEY"]),
("qwen", &["DASHSCOPE_API_KEY"]),
("opencode-zen", &["OPENCODE_API_KEY"]),
("opencode-go", &["OPENCODE_API_KEY"]),
("azure", &["AZURE_OPENAI_API_KEY"]),
];
const FALLBACK_KEY_VARIABLES: &[&str] = &["YOAGENT_API_KEY", "API_KEY"];
const BEDROCK_KEY_VARIABLES: &[&str] = &[
crate::provider::bedrock::BEARER_TOKEN_ENV,
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
];
const DECISION_KEY_VARIABLES: &[&str] = &[
"TYPESAFE_API_KEY",
"CLOUDFLARE_API_TOKEN",
"CLOUDFLARE_AUTH_TOKEN",
];
pub fn api_key_variables() -> Vec<&'static str> {
let mut names: Vec<&'static str> = Vec::new();
let all = PROVIDER_KEY_VARIABLES
.iter()
.flat_map(|(_, vars)| vars.iter())
.chain(BEDROCK_KEY_VARIABLES)
.chain(FALLBACK_KEY_VARIABLES)
.chain(DECISION_KEY_VARIABLES);
for name in all {
if !names.contains(name) {
names.push(name);
}
}
names
}
pub fn resolve_api_key(provider: &str) -> Option<String> {
use std::env::var;
let first = |names: &[&str]| {
names.iter().find_map(|n| {
var(n).ok().inspect(|_| {
tracing::debug!("resolved API key for provider '{}' from ${}", provider, n)
})
})
};
match provider {
"bedrock" => {
let set = |n: &str| var(n).ok().filter(|v| !v.trim().is_empty());
if let Some(token) = set(crate::provider::bedrock::BEARER_TOKEN_ENV) {
tracing::debug!(
"resolved API key for provider 'bedrock' from ${}",
crate::provider::bedrock::BEARER_TOKEN_ENV
);
return Some(token);
}
let [_, access, secret, token] = BEDROCK_KEY_VARIABLES else {
unreachable!("BEDROCK_KEY_VARIABLES lists four names")
};
let access = set(access)?;
let secret = set(secret)?;
Some(match set(token) {
Some(token) => format!("{}:{}:{}", access, secret, token),
None => format!("{}:{}", access, secret),
})
}
"vertex" => None,
"local" | "ollama" => Some(String::new()),
other => first(
PROVIDER_KEY_VARIABLES
.iter()
.find(|(name, _)| *name == other)
.map_or(FALLBACK_KEY_VARIABLES, |(_, vars)| vars),
),
}
}
pub(crate) fn resolve_api_key_or_warn(config: Option<&ModelConfig>) -> String {
if let Some(config) = config {
if config.api == ApiProtocol::BedrockConverseStream {
if resolve_api_key("bedrock").is_none() {
tracing::warn!(
"no API key found for provider '{}' (Amazon Bedrock): {}; requests \
will fail with an authentication error",
config.provider,
api_key_env_hint("bedrock")
);
}
return String::new();
}
}
let provider = config.map(|c| c.provider.as_str()).unwrap_or("anthropic");
match resolve_api_key(provider) {
Some(key) => key,
None => {
tracing::warn!(
"no API key found for provider '{}': {}; requests will fail \
with an authentication error",
provider,
api_key_env_hint(provider)
);
String::new()
}
}
}
fn api_key_env_hint(provider: &str) -> &'static str {
match provider {
"anthropic" => "set ANTHROPIC_API_KEY or call .with_api_key(...)",
"openai" => "set OPENAI_API_KEY or call .with_api_key(...)",
"google" => "set GEMINI_API_KEY (or GOOGLE_API_KEY) or call .with_api_key(...)",
"xai" => "set XAI_API_KEY or call .with_api_key(...)",
"groq" => "set GROQ_API_KEY or call .with_api_key(...)",
"deepseek" => "set DEEPSEEK_API_KEY or call .with_api_key(...)",
"mistral" => "set MISTRAL_API_KEY or call .with_api_key(...)",
"zai" => "set ZAI_API_KEY or call .with_api_key(...)",
"minimax" => "set MINIMAX_API_KEY or call .with_api_key(...)",
"meta" => "set META_API_KEY (or MODEL_API_KEY) or call .with_api_key(...)",
"openrouter" => "set OPENROUTER_API_KEY or call .with_api_key(...)",
"cerebras" => "set CEREBRAS_API_KEY or call .with_api_key(...)",
"qwen" => "set DASHSCOPE_API_KEY or call .with_api_key(...)",
"opencode-zen" | "opencode-go" => "set OPENCODE_API_KEY or call .with_api_key(...)",
"azure" => "set AZURE_OPENAI_API_KEY or call .with_api_key(...)",
"bedrock" => {
"set AWS_BEARER_TOKEN_BEDROCK, or AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY \
(+ AWS_SESSION_TOKEN), or call .with_api_key(...) with a Bedrock API key \
or \"access:secret[:token]\""
}
"vertex" => "pass a short-lived OAuth token via .with_api_key(...)",
_ => "set YOAGENT_API_KEY (or API_KEY) or call .with_api_key(...)",
}
}
#[cfg(test)]
mod resolve_key_tests {
use super::resolve_api_key;
#[test]
fn test_deterministic_branches() {
assert_eq!(resolve_api_key("local").as_deref(), Some(""));
assert_eq!(resolve_api_key("ollama").as_deref(), Some(""));
assert_eq!(resolve_api_key("vertex"), None);
}
#[test]
fn test_meta_env_resolution() {
std::env::remove_var("META_API_KEY");
std::env::set_var("MODEL_API_KEY", "official-var");
assert_eq!(resolve_api_key("meta").as_deref(), Some("official-var"));
std::env::set_var("META_API_KEY", "preferred-var");
assert_eq!(resolve_api_key("meta").as_deref(), Some("preferred-var"));
std::env::remove_var("META_API_KEY");
std::env::remove_var("MODEL_API_KEY");
}
#[test]
fn test_env_resolution() {
std::env::set_var("YOAGENT_API_KEY", "from-generic-fallback");
assert_eq!(
resolve_api_key("some-unknown-gateway").as_deref(),
Some("from-generic-fallback")
);
std::env::remove_var("YOAGENT_API_KEY");
}
}
#[cfg(test)]
mod registry_tests {
use super::*;
fn all_protocols() -> Vec<ApiProtocol> {
fn _assert_exhaustive(p: ApiProtocol) {
match p {
ApiProtocol::AnthropicMessages
| ApiProtocol::OpenAiCompletions
| ApiProtocol::OpenAiResponses
| ApiProtocol::AzureOpenAiResponses
| ApiProtocol::GoogleGenerativeAi
| ApiProtocol::GoogleVertex
| ApiProtocol::BedrockConverseStream => {}
}
}
vec![
ApiProtocol::AnthropicMessages,
ApiProtocol::OpenAiCompletions,
ApiProtocol::OpenAiResponses,
ApiProtocol::AzureOpenAiResponses,
ApiProtocol::GoogleGenerativeAi,
ApiProtocol::GoogleVertex,
ApiProtocol::BedrockConverseStream,
]
}
#[test]
fn default_registry_covers_all_protocols() {
let registry = ProviderRegistry::default();
for api in all_protocols() {
assert!(
registry.resolve(&api).is_some(),
"default registry missing a provider for {api}"
);
}
}
#[test]
fn default_registry_maps_each_protocol_to_its_own_provider() {
let registry = ProviderRegistry::default();
for api in all_protocols() {
let provider = registry.resolve(&api).expect("registered");
assert_eq!(
provider.protocol(),
Some(api),
"protocol {api} resolved to a provider that reports {:?}",
provider.protocol()
);
}
}
#[test]
fn empty_registry_resolves_nothing() {
assert!(ProviderRegistry::new()
.resolve(&ApiProtocol::AnthropicMessages)
.is_none());
}
}