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 "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), }
19}
20
21pub 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
73pub 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
119pub 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 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}