Skip to main content

everruns_core/
credential_provider.rs

1// Pluggable provider credential source (specs/llm-drivers.md, specs/providers.md)
2//
3// Driver crates never read the process environment for credentials. Reading
4// `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, etc. from a shared host environment is
5// unsafe in the multitenant server: a platform-level key would silently fund
6// tenant execution (the fail-closed Key Resolution Contract in
7// `specs/llm-drivers.md`).
8//
9// Instead, credential loading is an explicit, injectable concern. A caller that
10// wants env-based credentials — a CLI, a dev entrypoint, a standalone embedder —
11// constructs an [`EnvCredentialProvider`] and passes it in. The server never
12// constructs one; it resolves credentials from the encrypted database. This is
13// the single, common seam across every driver, so adding a new driver does not
14// add a new place that touches the environment.
15
16use crate::provider::DriverId;
17
18/// Credentials resolved for a single driver: the API key and an optional base
19/// URL override. This mirrors the credential fields a driver constructor or a
20/// `DriverConfig` accepts, decoupled from where they came from.
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct ProviderCredentials {
23    /// API key / secret for the provider account.
24    pub api_key: Option<String>,
25    /// Optional endpoint override (OpenAI-compatible proxies, self-hosted, etc.).
26    pub base_url: Option<String>,
27}
28
29impl ProviderCredentials {
30    /// Whether any credential value is present.
31    pub fn is_empty(&self) -> bool {
32        self.api_key.is_none() && self.base_url.is_none()
33    }
34}
35
36/// A source of provider credentials, injected by the caller.
37///
38/// Drivers and dev stores depend on this trait, not on the environment. The
39/// multitenant server path resolves credentials from the encrypted database and
40/// does not use a `CredentialProvider`; only explicit standalone/dev entrypoints
41/// construct one (typically [`EnvCredentialProvider`]).
42pub trait CredentialProvider: Send + Sync {
43    /// Resolve credentials for the given driver, or `None` when this source has
44    /// none for it.
45    fn resolve(&self, driver: &DriverId) -> Option<ProviderCredentials>;
46}
47
48/// A [`CredentialProvider`] that reads credentials from the process environment.
49///
50/// This is the shared library implementation for env-based credentials and the
51/// sanctioned pattern for any caller that wants them: driver/library code never
52/// reads credential env vars itself, so new env-credential logic belongs here.
53/// (Some standalone examples and `#[ignore]` live tests still read `*_API_KEY`
54/// directly to gate themselves; that caller-side code should prefer this type.)
55///
56/// It is intended for standalone/CLI/dev use and MUST NOT be constructed on
57/// org-scoped server execution paths — doing so would reopen the env fallback the
58/// Key Resolution Contract forbids.
59///
60/// Recognized variables (per driver):
61///
62/// | Driver | API key | Base URL |
63/// |--------|---------|----------|
64/// | `openai`, `openai_completions` | `OPENAI_API_KEY` | `OPENAI_BASE_URL` |
65/// | `openrouter` | `OPENROUTER_API_KEY` | `OPENROUTER_BASE_URL` |
66/// | `anthropic` | `ANTHROPIC_API_KEY` | `ANTHROPIC_BASE_URL` |
67/// | `gemini` | `GEMINI_API_KEY` | `GEMINI_BASE_URL` |
68///
69/// Other drivers (Azure OpenAI, Bedrock, MAI, external) return `None`; their
70/// dev/standalone credentials are supplied explicitly by the caller.
71#[derive(Debug, Clone, Copy, Default)]
72pub struct EnvCredentialProvider;
73
74impl EnvCredentialProvider {
75    /// Construct the env-backed credential provider.
76    pub fn new() -> Self {
77        Self
78    }
79
80    /// Resolve credentials using an injectable lookup (testable without
81    /// touching the real process environment).
82    fn resolve_with<F>(driver: &DriverId, lookup: F) -> Option<ProviderCredentials>
83    where
84        F: Fn(&str) -> Option<String>,
85    {
86        let (key_var, url_var) = match driver {
87            DriverId::OpenAI | DriverId::OpenAICompletions => ("OPENAI_API_KEY", "OPENAI_BASE_URL"),
88            DriverId::OpenRouter => ("OPENROUTER_API_KEY", "OPENROUTER_BASE_URL"),
89            DriverId::Anthropic => ("ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"),
90            DriverId::Gemini => ("GEMINI_API_KEY", "GEMINI_BASE_URL"),
91            _ => return None,
92        };
93
94        let non_empty = |s: String| (!s.is_empty()).then_some(s);
95        let api_key = lookup(key_var).and_then(non_empty);
96        let base_url = lookup(url_var).and_then(non_empty);
97
98        let creds = ProviderCredentials { api_key, base_url };
99        (!creds.is_empty()).then_some(creds)
100    }
101}
102
103impl CredentialProvider for EnvCredentialProvider {
104    fn resolve(&self, driver: &DriverId) -> Option<ProviderCredentials> {
105        Self::resolve_with(driver, |name| std::env::var(name).ok())
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use std::collections::HashMap;
113
114    fn lookup_from(map: &HashMap<&'static str, &'static str>) -> impl Fn(&str) -> Option<String> {
115        let owned: HashMap<String, String> = map
116            .iter()
117            .map(|(k, v)| (k.to_string(), v.to_string()))
118            .collect();
119        move |name: &str| owned.get(name).cloned()
120    }
121
122    #[test]
123    fn openai_reads_key_and_base_url() {
124        let env = HashMap::from([
125            ("OPENAI_API_KEY", "sk-test"),
126            ("OPENAI_BASE_URL", "https://proxy.example/v1"),
127        ]);
128        let creds = EnvCredentialProvider::resolve_with(&DriverId::OpenAI, lookup_from(&env))
129            .expect("credentials");
130        assert_eq!(creds.api_key.as_deref(), Some("sk-test"));
131        assert_eq!(creds.base_url.as_deref(), Some("https://proxy.example/v1"));
132    }
133
134    #[test]
135    fn openai_completions_shares_openai_key() {
136        let env = HashMap::from([("OPENAI_API_KEY", "sk-test")]);
137        let creds =
138            EnvCredentialProvider::resolve_with(&DriverId::OpenAICompletions, lookup_from(&env))
139                .expect("credentials");
140        assert_eq!(creds.api_key.as_deref(), Some("sk-test"));
141        assert!(creds.base_url.is_none());
142    }
143
144    #[test]
145    fn anthropic_and_gemini_keys() {
146        let env = HashMap::from([("ANTHROPIC_API_KEY", "sk-ant"), ("GEMINI_API_KEY", "g-key")]);
147        let lookup = lookup_from(&env);
148        assert_eq!(
149            EnvCredentialProvider::resolve_with(&DriverId::Anthropic, &lookup)
150                .and_then(|c| c.api_key)
151                .as_deref(),
152            Some("sk-ant"),
153        );
154        assert_eq!(
155            EnvCredentialProvider::resolve_with(&DriverId::Gemini, &lookup)
156                .and_then(|c| c.api_key)
157                .as_deref(),
158            Some("g-key"),
159        );
160    }
161
162    #[test]
163    fn empty_or_missing_yields_none() {
164        let env = HashMap::from([("OPENAI_API_KEY", "")]);
165        assert!(
166            EnvCredentialProvider::resolve_with(&DriverId::OpenAI, lookup_from(&env)).is_none()
167        );
168
169        let empty = HashMap::new();
170        assert!(
171            EnvCredentialProvider::resolve_with(&DriverId::Anthropic, lookup_from(&empty))
172                .is_none()
173        );
174    }
175
176    #[test]
177    fn unsupported_drivers_return_none() {
178        let env = HashMap::from([("AWS_ACCESS_KEY_ID", "x")]);
179        let lookup = lookup_from(&env);
180        assert!(EnvCredentialProvider::resolve_with(&DriverId::Bedrock, &lookup).is_none());
181        assert!(EnvCredentialProvider::resolve_with(&DriverId::LlmSim, &lookup).is_none());
182    }
183}