use crate::inference::credentials::{KeyStore, resolve_key_with};
use crate::inference::error::InferenceError;
use crate::inference::registry::{ProviderCapabilities, ProviderId, capabilities};
use crate::inference::types::SecretString;
#[derive(Debug, Clone)]
pub struct ResolvedProvider {
provider: ProviderId,
model: String,
key: Option<SecretString>,
}
impl ResolvedProvider {
pub(crate) fn new(provider: ProviderId, model: String, key: Option<SecretString>) -> Self {
Self {
provider,
model,
key,
}
}
pub fn provider(&self) -> ProviderId {
self.provider
}
pub fn model(&self) -> &str {
&self.model
}
pub fn key(&self) -> Option<&SecretString> {
self.key.as_ref()
}
pub fn capabilities(&self) -> &'static ProviderCapabilities {
capabilities(self.provider)
}
}
pub fn provider_for(slug: &str, store: &dyn KeyStore) -> Result<ResolvedProvider, InferenceError> {
if let Some(family) = ProviderId::from_slug_prefix(slug) {
match family.credential_name() {
None => return Ok(ResolvedProvider::new(family, slug.to_string(), None)),
Some(cred) => {
if let Some(key) = resolve_key_with(cred, store) {
return Ok(ResolvedProvider::new(
family,
slug.to_string(),
Some(SecretString::new(key)),
));
}
}
}
}
let or_cred = ProviderId::OpenRouter
.credential_name()
.unwrap_or("openrouter");
match resolve_key_with(or_cred, store) {
Some(key) => Ok(ResolvedProvider::new(
ProviderId::OpenRouter,
slug.to_string(),
Some(SecretString::new(key)),
)),
None => Err(InferenceError::MissingCredential {
provider: ProviderId::OpenRouter,
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::inference::credentials::MemoryKeyStore;
use serial_test::serial;
fn store_with(entries: &[(&str, &str)]) -> MemoryKeyStore {
for var in [
"OPENROUTER_API_KEY",
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"FIREWORKS_API_KEY",
] {
unsafe { std::env::remove_var(var) };
}
let store = MemoryKeyStore::new();
for (p, k) in entries {
store.set(p, k).expect("seed store");
}
store
}
#[test]
#[serial(dotenv_credential_env)]
fn explicit_prefix_with_key_wins() {
let store = store_with(&[("anthropic", "sk-ant-xyz")]); let r = provider_for("anthropic/claude-sonnet-4-5", &store).expect("resolves");
assert_eq!(r.provider(), ProviderId::Anthropic);
assert_eq!(r.model(), "anthropic/claude-sonnet-4-5");
assert_eq!(r.key().map(|k| k.expose()), Some("sk-ant-xyz"));
assert_eq!(r.capabilities().id, ProviderId::Anthropic);
}
#[test]
#[serial(dotenv_credential_env)]
fn explicit_prefix_missing_key_falls_back_to_openrouter() {
let store = store_with(&[("openrouter", "sk-or-abc")]); let r = provider_for("anthropic/claude-sonnet-4-5", &store).expect("resolves");
assert_eq!(r.provider(), ProviderId::OpenRouter);
assert_eq!(r.model(), "anthropic/claude-sonnet-4-5");
assert_eq!(r.key().map(|k| k.expose()), Some("sk-or-abc"));
}
#[test]
#[serial(dotenv_credential_env)]
fn bare_slug_uses_openrouter() {
let store = store_with(&[("openrouter", "sk-or-abc")]); let r = provider_for("some-vendor/some-model", &store).expect("resolves");
assert_eq!(r.provider(), ProviderId::OpenRouter);
}
#[test]
#[serial(dotenv_credential_env)]
fn bedrock_resolves_without_key() {
let store = store_with(&[]);
let r = provider_for("bedrock/us.anthropic.claude-sonnet-4-5", &store).expect("resolves");
assert_eq!(r.provider(), ProviderId::Bedrock);
assert!(r.key().is_none());
}
#[test]
#[serial(dotenv_credential_env)]
fn local_resolves_without_key() {
let store = store_with(&[]);
let r = provider_for("local/llama3.1", &store).expect("resolves");
assert_eq!(r.provider(), ProviderId::Local);
assert!(r.key().is_none());
let r2 = provider_for("ollama/qwen3:30b", &store).expect("resolves");
assert_eq!(r2.provider(), ProviderId::Local);
assert!(r2.key().is_none());
}
#[test]
#[serial(dotenv_credential_env)]
fn no_credential_anywhere_errors() {
let store = store_with(&[]);
let err = provider_for("openai/gpt-4o-mini", &store).expect_err("must error");
assert!(err.is_alarm());
assert!(matches!(
err,
InferenceError::MissingCredential {
provider: ProviderId::OpenRouter
}
));
}
}