Skip to main content

prospero_core/
provider_env.rs

1//! Pure resolution of a repo's caliband environment overlay.
2//!
3//! Combines the prosperod-level default env, a repo's curated provider fields,
4//! and its raw env map into one overlay applied to the caliband process.
5
6use std::collections::BTreeMap;
7
8use crate::registry::RepoProviderConfig;
9
10/// `(base_url_var, api_key_var)` for a provider, or `(None, None)` for
11/// provider-only backends (bedrock/vertex use ambient cloud credentials).
12fn provider_vars(provider: &str) -> (Option<&'static str>, Option<&'static str>) {
13    match provider {
14        "ollama" => (Some("OLLAMA_BASE_URL"), None),
15        "anthropic" => (Some("ANTHROPIC_BASE_URL"), Some("ANTHROPIC_API_KEY")),
16        "openai" => (Some("OPENAI_BASE_URL"), Some("OPENAI_API_KEY")),
17        "google" => (Some("GEMINI_BASE_URL"), Some("GEMINI_API_KEY")),
18        _ => (None, None), // bedrock, vertex, unknown
19    }
20}
21
22/// Resolve the environment overlay for a repo's caliband daemon.
23///
24/// Layered lowest → highest: `default_env` (global) → curated provider fields →
25/// `cfg.env` (raw). `process_env` looks up prosperod's own environment for
26/// `api_key_from_env` references.
27pub fn resolve_env(
28    default_env: &BTreeMap<String, String>,
29    cfg: &RepoProviderConfig,
30    process_env: &dyn Fn(&str) -> Option<String>,
31) -> BTreeMap<String, String> {
32    let mut out = default_env.clone();
33
34    if let Some(provider) = &cfg.provider {
35        out.insert("CALIBAN_PROVIDER".to_string(), provider.clone());
36        let (base_var, key_var) = provider_vars(provider);
37        if let Some(base_url) = &cfg.base_url {
38            match base_var {
39                Some(var) => {
40                    out.insert(var.to_string(), base_url.clone());
41                }
42                None => tracing::warn!(
43                    target: "prospero_provider_env",
44                    provider, "base_url set but provider has no base-URL env var; ignored"
45                ),
46            }
47        }
48        if let Some(name) = &cfg.api_key_from_env {
49            match key_var {
50                Some(var) => match process_env(name) {
51                    Some(value) => {
52                        out.insert(var.to_string(), value);
53                    }
54                    None => tracing::warn!(
55                        target: "prospero_provider_env",
56                        env_var = %name,
57                        "api_key_from_env references an unset variable; skipped"
58                    ),
59                },
60                None => tracing::warn!(
61                    target: "prospero_provider_env",
62                    provider, "api_key_from_env set but provider has no api-key env var; ignored"
63                ),
64            }
65        }
66    }
67
68    for (k, v) in &cfg.env {
69        out.insert(k.clone(), v.clone());
70    }
71    out
72}
73
74/// Check that a *resolved* env satisfies the selected provider's API-key
75/// requirement, returning an actionable message when it does not.
76///
77/// This complements [`resolve_env`], which only `warn!`s on a dangling
78/// `api_key_from_env` reference and then proceeds. Validating the resolved map
79/// (rather than the raw config) means a key supplied through any layer —
80/// curated `api_key_from_env`, raw `cfg.env`, or the global `default_env` —
81/// counts, so there are no false positives. Providers without a key var
82/// (ollama, bedrock, vertex) and an unset provider always pass.
83pub fn validate_provider_env(
84    cfg: &RepoProviderConfig,
85    resolved: &BTreeMap<String, String>,
86) -> std::result::Result<(), String> {
87    let Some(provider) = &cfg.provider else {
88        return Ok(());
89    };
90    let (_base_var, key_var) = provider_vars(provider);
91    let Some(key_var) = key_var else {
92        return Ok(());
93    };
94    if resolved.get(key_var).is_some_and(|v| !v.is_empty()) {
95        return Ok(());
96    }
97    Err(match &cfg.api_key_from_env {
98        Some(name) => format!(
99            "provider '{provider}' requires {key_var}, but api_key_from_env \
100             references '{name}', which is unset in prosperod's environment"
101        ),
102        None => format!(
103            "provider '{provider}' requires {key_var}, but no value is configured \
104             (set api_key_from_env to a variable in prosperod's environment, or \
105             supply {key_var} in the repo env)"
106        ),
107    })
108}
109
110/// Statically validate a repo's provider config for internal coherence,
111/// independent of the runtime environment — so settings that would be silently
112/// dropped at spawn/poll time are rejected at config-set time with an
113/// actionable message instead.
114///
115/// Today this catches an `api_key_from_env` on a provider that has no api-key
116/// env var (ollama, bedrock, vertex, unknown): [`resolve_env`] only `warn!`s
117/// `"api_key_from_env set but provider has no api-key env var; ignored"` and
118/// proceeds, so the setting looks accepted but never takes effect. Surfacing it
119/// here lets the config-set path return a `400` (#120).
120///
121/// An unset provider always passes (nothing to validate).
122pub fn validate_provider_config(cfg: &RepoProviderConfig) -> std::result::Result<(), String> {
123    let Some(provider) = &cfg.provider else {
124        return Ok(());
125    };
126    let (_base_var, key_var) = provider_vars(provider);
127    if cfg.api_key_from_env.is_some() && key_var.is_none() {
128        return Err(format!(
129            "provider '{provider}' has no api-key env var, so api_key_from_env \
130             would be ignored; remove api_key_from_env for this provider (it is \
131             only meaningful for anthropic/openai/google)"
132        ));
133    }
134    Ok(())
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    fn cfg() -> RepoProviderConfig {
142        RepoProviderConfig::default()
143    }
144    fn no_env(_: &str) -> Option<String> {
145        None
146    }
147
148    #[test]
149    fn provider_and_base_url_map_to_env_vars() {
150        let mut c = cfg();
151        c.provider = Some("ollama".into());
152        c.base_url = Some("http://h:11434".into());
153        let out = resolve_env(&BTreeMap::new(), &c, &no_env);
154        assert_eq!(out.get("CALIBAN_PROVIDER").unwrap(), "ollama");
155        assert_eq!(out.get("OLLAMA_BASE_URL").unwrap(), "http://h:11434");
156    }
157
158    #[test]
159    fn api_key_from_env_is_resolved_from_process_env() {
160        let mut c = cfg();
161        c.provider = Some("anthropic".into());
162        c.api_key_from_env = Some("MY_KEY".into());
163        let proc = |k: &str| (k == "MY_KEY").then(|| "secret-value".to_string());
164        let out = resolve_env(&BTreeMap::new(), &c, &proc);
165        assert_eq!(out.get("ANTHROPIC_API_KEY").unwrap(), "secret-value");
166    }
167
168    #[test]
169    fn dangling_api_key_reference_is_skipped() {
170        let mut c = cfg();
171        c.provider = Some("anthropic".into());
172        c.api_key_from_env = Some("UNSET_VAR".into());
173        let out = resolve_env(&BTreeMap::new(), &c, &no_env);
174        assert!(!out.contains_key("ANTHROPIC_API_KEY"));
175    }
176
177    #[test]
178    fn precedence_is_global_then_curated_then_raw() {
179        let mut default_env = BTreeMap::new();
180        default_env.insert("CALIBAN_PROVIDER".into(), "openai".into());
181        default_env.insert("KEEP".into(), "from-global".into());
182        let mut c = cfg();
183        c.provider = Some("ollama".into());
184        c.env.insert("CALIBAN_PROVIDER".into(), "raw-wins".into());
185        let out = resolve_env(&default_env, &c, &no_env);
186        assert_eq!(out.get("CALIBAN_PROVIDER").unwrap(), "raw-wins");
187        assert_eq!(out.get("KEEP").unwrap(), "from-global");
188    }
189
190    #[test]
191    fn provider_only_backend_ignores_base_url() {
192        let mut c = cfg();
193        c.provider = Some("bedrock".into());
194        c.base_url = Some("http://ignored".into());
195        let out = resolve_env(&BTreeMap::new(), &c, &no_env);
196        assert_eq!(out.get("CALIBAN_PROVIDER").unwrap(), "bedrock");
197        assert!(out.keys().all(|k| k == "CALIBAN_PROVIDER"));
198    }
199
200    #[test]
201    fn empty_config_passes_default_env_through() {
202        let mut default_env = BTreeMap::new();
203        default_env.insert("FOO".into(), "bar".into());
204        let out = resolve_env(&default_env, &cfg(), &no_env);
205        assert_eq!(out.get("FOO").unwrap(), "bar");
206        assert_eq!(out.len(), 1);
207    }
208
209    #[test]
210    fn validate_rejects_unset_api_key() {
211        let mut c = cfg();
212        c.provider = Some("anthropic".into());
213        let resolved = resolve_env(&BTreeMap::new(), &c, &no_env);
214        let err = validate_provider_env(&c, &resolved).unwrap_err();
215        assert!(err.contains("anthropic"), "message names provider: {err}");
216        assert!(
217            err.contains("ANTHROPIC_API_KEY"),
218            "message names key var: {err}"
219        );
220    }
221
222    #[test]
223    fn validate_rejects_dangling_api_key_reference() {
224        let mut c = cfg();
225        c.provider = Some("openai".into());
226        c.api_key_from_env = Some("UNSET_VAR".into());
227        let resolved = resolve_env(&BTreeMap::new(), &c, &no_env);
228        let err = validate_provider_env(&c, &resolved).unwrap_err();
229        assert!(
230            err.contains("UNSET_VAR"),
231            "message names the dangling reference: {err}"
232        );
233        assert!(
234            err.contains("OPENAI_API_KEY"),
235            "message names key var: {err}"
236        );
237    }
238
239    #[test]
240    fn validate_accepts_key_resolved_from_env() {
241        let mut c = cfg();
242        c.provider = Some("anthropic".into());
243        c.api_key_from_env = Some("MY_KEY".into());
244        let proc = |k: &str| (k == "MY_KEY").then(|| "secret".to_string());
245        let resolved = resolve_env(&BTreeMap::new(), &c, &proc);
246        assert!(validate_provider_env(&c, &resolved).is_ok());
247    }
248
249    #[test]
250    fn validate_accepts_key_supplied_via_default_env() {
251        let mut default_env = BTreeMap::new();
252        default_env.insert("ANTHROPIC_API_KEY".into(), "from-global".into());
253        let mut c = cfg();
254        c.provider = Some("anthropic".into());
255        let resolved = resolve_env(&default_env, &c, &no_env);
256        assert!(validate_provider_env(&c, &resolved).is_ok());
257    }
258
259    #[test]
260    fn validate_treats_empty_key_value_as_unset() {
261        let mut default_env = BTreeMap::new();
262        default_env.insert("ANTHROPIC_API_KEY".into(), String::new());
263        let mut c = cfg();
264        c.provider = Some("anthropic".into());
265        let resolved = resolve_env(&default_env, &c, &no_env);
266        assert!(validate_provider_env(&c, &resolved).is_err());
267    }
268
269    #[test]
270    fn validate_accepts_keyless_provider() {
271        let mut c = cfg();
272        c.provider = Some("ollama".into());
273        let resolved = resolve_env(&BTreeMap::new(), &c, &no_env);
274        assert!(validate_provider_env(&c, &resolved).is_ok());
275    }
276
277    #[test]
278    fn validate_accepts_provider_only_backend() {
279        let mut c = cfg();
280        c.provider = Some("bedrock".into());
281        let resolved = resolve_env(&BTreeMap::new(), &c, &no_env);
282        assert!(validate_provider_env(&c, &resolved).is_ok());
283    }
284
285    #[test]
286    fn validate_accepts_no_provider() {
287        let resolved = resolve_env(&BTreeMap::new(), &cfg(), &no_env);
288        assert!(validate_provider_env(&cfg(), &resolved).is_ok());
289    }
290
291    #[test]
292    fn validate_config_rejects_api_key_on_keyless_provider() {
293        let mut c = cfg();
294        c.provider = Some("ollama".into());
295        c.api_key_from_env = Some("SOME_VAR".into());
296        let err = validate_provider_config(&c).unwrap_err();
297        assert!(err.contains("ollama"), "message names provider: {err}");
298        assert!(
299            err.contains("api_key_from_env"),
300            "message names the offending field: {err}"
301        );
302    }
303
304    #[test]
305    fn validate_config_rejects_api_key_on_provider_only_backend() {
306        let mut c = cfg();
307        c.provider = Some("bedrock".into());
308        c.api_key_from_env = Some("SOME_VAR".into());
309        assert!(validate_provider_config(&c).is_err());
310    }
311
312    #[test]
313    fn validate_config_accepts_api_key_on_keyed_provider() {
314        let mut c = cfg();
315        c.provider = Some("anthropic".into());
316        c.api_key_from_env = Some("ANTHROPIC_KEY_VAR".into());
317        assert!(validate_provider_config(&c).is_ok());
318    }
319
320    #[test]
321    fn validate_config_accepts_keyless_provider_without_api_key() {
322        let mut c = cfg();
323        c.provider = Some("ollama".into());
324        assert!(validate_provider_config(&c).is_ok());
325    }
326
327    #[test]
328    fn validate_config_accepts_no_provider() {
329        assert!(validate_provider_config(&cfg()).is_ok());
330    }
331}