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