1use std::collections::BTreeMap;
7
8use crate::registry::RepoProviderConfig;
9
10fn 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), }
20}
21
22pub 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
74pub 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
110pub 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}