1use mentra::BuiltinProvider;
25use thiserror::Error;
26
27const CANDIDATES: &[(BuiltinProvider, &str)] = &[
34 (BuiltinProvider::Anthropic, "ANTHROPIC_API_KEY"),
35 (BuiltinProvider::OpenAI, "OPENAI_API_KEY"),
36 (BuiltinProvider::Gemini, "GEMINI_API_KEY"),
37 (BuiltinProvider::OpenRouter, "OPENROUTER_API_KEY"),
38];
39
40const BASE_URL_VARS: &[&str] = &["BASIS_BASE_URL", "OPENAI_BASE_URL"];
44
45const COMPATIBLE_KEY_VARS: &[&str] = &["BASIS_API_KEY", "OPENAI_API_KEY"];
47
48#[derive(Clone)]
50pub struct ProviderChoice {
51 pub provider: BuiltinProvider,
52 pub api_key: String,
53 pub source_var: Option<&'static str>,
55 pub base_url: Option<String>,
59}
60
61impl std::fmt::Debug for ProviderChoice {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 f.debug_struct("ProviderChoice")
70 .field("provider", &self.provider)
71 .field("api_key", &"<redacted>")
72 .field("source_var", &self.source_var)
73 .field("base_url", &self.base_url)
74 .finish()
75 }
76}
77
78impl ProviderChoice {
79 pub fn is_compatible_endpoint(&self) -> bool {
81 self.base_url.is_some()
82 }
83}
84
85#[derive(Debug, Error)]
86pub enum ProviderError {
87 #[error(
88 "no provider credential found; set one of: {}",
89 CANDIDATES.iter().map(|(_, var)| *var).collect::<Vec<_>>().join(", ")
90 )]
91 NoCredential,
92
93 #[error("{provider} selected but {var} is not set")]
94 MissingCredential {
95 provider: BuiltinProvider,
96 var: &'static str,
97 },
98
99 #[error(
100 "unknown provider '{0}'; expected one of: anthropic, openai, gemini, openrouter, ollama, lmstudio"
101 )]
102 Unknown(String),
103
104 #[error("{0} has no API-key environment variable; it is a local provider")]
105 NotKeyed(BuiltinProvider),
106
107 #[error(
108 "a base URL was given but no key; set one of: {}",
109 COMPATIBLE_KEY_VARS.join(", ")
110 )]
111 NoCompatibleCredential,
112
113 #[error("base URL must be an absolute http(s) URL, got '{0}'")]
114 InvalidBaseUrl(String),
115
116 #[error("an API key was supplied with no provider and no base URL to attribute it to")]
117 UnattributedCredential,
118}
119
120pub fn normalize_base_url(raw: &str) -> Result<String, ProviderError> {
128 let trimmed = raw.trim();
129 let rest = trimmed
130 .strip_prefix("http://")
131 .or_else(|| trimmed.strip_prefix("https://"))
132 .ok_or_else(|| ProviderError::InvalidBaseUrl(raw.to_string()))?;
133
134 let host = rest.split('/').next().unwrap_or_default();
137 if host.is_empty() {
138 return Err(ProviderError::InvalidBaseUrl(raw.to_string()));
139 }
140
141 let without_slash = trimmed.trim_end_matches('/');
142 let without_version = without_slash
143 .strip_suffix("/v1")
144 .unwrap_or(without_slash)
145 .trim_end_matches('/');
146
147 if without_version.is_empty() {
148 return Err(ProviderError::InvalidBaseUrl(raw.to_string()));
149 }
150
151 Ok(format!("{without_version}/"))
153}
154
155pub fn parse(name: &str) -> Result<BuiltinProvider, ProviderError> {
157 match name.trim().to_ascii_lowercase().as_str() {
158 "anthropic" => Ok(BuiltinProvider::Anthropic),
159 "openai" => Ok(BuiltinProvider::OpenAI),
160 "gemini" => Ok(BuiltinProvider::Gemini),
161 "openrouter" => Ok(BuiltinProvider::OpenRouter),
162 "ollama" => Ok(BuiltinProvider::Ollama),
163 "lmstudio" | "lm-studio" => Ok(BuiltinProvider::LmStudio),
164 other => Err(ProviderError::Unknown(other.to_string())),
165 }
166}
167
168pub fn key_var(provider: BuiltinProvider) -> Option<&'static str> {
170 CANDIDATES
171 .iter()
172 .find(|(candidate, _)| *candidate == provider)
173 .map(|(_, var)| *var)
174}
175
176pub fn resolve(
183 requested: Option<BuiltinProvider>,
184 base_url: Option<&str>,
185) -> Result<ProviderChoice, ProviderError> {
186 resolve_with(requested, base_url, None)
187}
188
189pub fn resolve_with(
201 requested: Option<BuiltinProvider>,
202 base_url: Option<&str>,
203 api_key: Option<&str>,
204) -> Result<ProviderChoice, ProviderError> {
205 resolve_against(&|var| std::env::var(var).ok(), requested, base_url, api_key)
206}
207
208fn resolve_against(
217 lookup: &dyn Fn(&str) -> Option<String>,
218 requested: Option<BuiltinProvider>,
219 base_url: Option<&str>,
220 api_key: Option<&str>,
221) -> Result<ProviderChoice, ProviderError> {
222 if let Some(raw) = base_url
223 .map(str::to_string)
224 .or_else(|| env_base_url(lookup))
225 {
226 return resolve_compatible(lookup, &raw, requested, api_key);
227 }
228
229 match (requested, api_key) {
230 (Some(provider), Some(api_key)) => Ok(ProviderChoice {
231 provider,
232 api_key: api_key.to_string(),
233 source_var: None,
234 base_url: None,
235 }),
236 (None, Some(_)) => Err(ProviderError::UnattributedCredential),
237 (Some(provider), None) => {
238 let var = key_var(provider).ok_or(ProviderError::NotKeyed(provider))?;
239 let api_key =
240 read(lookup, var).ok_or(ProviderError::MissingCredential { provider, var })?;
241 Ok(ProviderChoice {
242 provider,
243 api_key,
244 source_var: Some(var),
245 base_url: None,
246 })
247 }
248 (None, None) => CANDIDATES
249 .iter()
250 .find_map(|(provider, var)| {
251 read(lookup, var).map(|api_key| ProviderChoice {
252 provider: *provider,
253 api_key,
254 source_var: Some(var),
255 base_url: None,
256 })
257 })
258 .ok_or(ProviderError::NoCredential),
259 }
260}
261
262fn resolve_compatible(
265 lookup: &dyn Fn(&str) -> Option<String>,
266 raw: &str,
267 requested: Option<BuiltinProvider>,
268 api_key: Option<&str>,
269) -> Result<ProviderChoice, ProviderError> {
270 let base_url = normalize_base_url(raw)?;
271 let (api_key, source_var) = match api_key {
272 Some(api_key) => (api_key.to_string(), None),
273 None => COMPATIBLE_KEY_VARS
274 .iter()
275 .find_map(|var| read(lookup, var).map(|key| (key, Some(*var))))
276 .ok_or(ProviderError::NoCompatibleCredential)?,
277 };
278
279 Ok(ProviderChoice {
280 provider: requested.unwrap_or(BuiltinProvider::OpenAI),
281 api_key,
282 source_var,
283 base_url: Some(base_url),
284 })
285}
286
287fn env_base_url(lookup: &dyn Fn(&str) -> Option<String>) -> Option<String> {
288 BASE_URL_VARS.iter().find_map(|var| read(lookup, var))
289}
290
291fn read(lookup: &dyn Fn(&str) -> Option<String>, var: &str) -> Option<String> {
295 lookup(var).filter(|value| !value.trim().is_empty())
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 fn exporting(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
307 let vars: Vec<(String, String)> = vars
308 .iter()
309 .map(|(var, value)| (var.to_string(), value.to_string()))
310 .collect();
311
312 move |name| {
313 vars.iter()
314 .find(|(var, _)| var == name)
315 .map(|(_, value)| value.clone())
316 }
317 }
318
319 fn nothing_exported() -> impl Fn(&str) -> Option<String> {
320 exporting(&[])
321 }
322
323 #[test]
324 fn provider_names_parse_case_insensitively() {
325 assert_eq!(parse("OpenAI").expect("parses"), BuiltinProvider::OpenAI);
326 assert_eq!(
327 parse(" anthropic ").expect("parses"),
328 BuiltinProvider::Anthropic
329 );
330 assert_eq!(
331 parse("lm-studio").expect("parses"),
332 BuiltinProvider::LmStudio
333 );
334 }
335
336 #[test]
337 fn an_unknown_provider_names_the_alternatives() {
338 let error = parse("hal9000").expect_err("rejected");
339
340 assert!(matches!(error, ProviderError::Unknown(name) if name == "hal9000"));
341 }
342
343 #[test]
344 fn hosted_providers_have_a_key_variable_and_local_ones_do_not() {
345 assert_eq!(
346 key_var(BuiltinProvider::Anthropic),
347 Some("ANTHROPIC_API_KEY")
348 );
349 assert_eq!(key_var(BuiltinProvider::Ollama), None);
350 }
351
352 #[test]
353 fn detection_order_prefers_the_first_candidate() {
354 let vars: Vec<&str> = CANDIDATES.iter().map(|(_, var)| *var).collect();
355
356 assert_eq!(vars.first(), Some(&"ANTHROPIC_API_KEY"));
357 assert_eq!(
358 vars.len(),
359 4,
360 "local providers must not be auto-detection candidates"
361 );
362 }
363
364 #[test]
365 fn detection_takes_the_first_candidate_the_environment_offers() {
366 let choice = resolve_against(
367 &exporting(&[
368 ("OPENAI_API_KEY", "openai-key"),
369 ("ANTHROPIC_API_KEY", "anthropic-key"),
370 ]),
371 None,
372 None,
373 None,
374 )
375 .expect("a key is exported");
376
377 assert_eq!(choice.provider, BuiltinProvider::Anthropic);
378 assert_eq!(choice.source_var, Some("ANTHROPIC_API_KEY"));
379 }
380
381 #[test]
382 fn a_named_provider_reads_its_own_variable_and_says_which() {
383 let choice = resolve_against(
384 &exporting(&[
385 ("ANTHROPIC_API_KEY", "anthropic-key"),
386 ("GEMINI_API_KEY", "gemini-key"),
387 ]),
388 Some(BuiltinProvider::Gemini),
389 None,
390 None,
391 )
392 .expect("the named provider's key is exported");
393
394 assert_eq!(choice.api_key, "gemini-key");
395 assert_eq!(choice.source_var, Some("GEMINI_API_KEY"));
396 }
397
398 #[test]
399 fn a_variable_set_to_whitespace_is_treated_as_absent() {
400 let error = resolve_against(
403 &exporting(&[("ANTHROPIC_API_KEY", " ")]),
404 None,
405 None,
406 None,
407 )
408 .expect_err("rejected");
409
410 assert!(matches!(error, ProviderError::NoCredential));
411 }
412
413 #[test]
414 fn an_environment_base_url_outranks_provider_detection() {
415 let choice = resolve_against(
418 &exporting(&[
419 ("ANTHROPIC_API_KEY", "anthropic-key"),
420 ("BASIS_BASE_URL", "http://127.0.0.1:3455/v1"),
421 ("BASIS_API_KEY", "gateway-key"),
422 ]),
423 None,
424 None,
425 None,
426 )
427 .expect("a base URL and a key are enough");
428
429 assert_eq!(choice.base_url.as_deref(), Some("http://127.0.0.1:3455/"));
430 assert_eq!(choice.api_key, "gateway-key");
431 assert_eq!(choice.source_var, Some("BASIS_API_KEY"));
432 }
433
434 #[test]
435 fn a_base_url_with_no_key_anywhere_is_refused() {
436 let error = resolve_against(
437 &exporting(&[("BASIS_BASE_URL", "http://127.0.0.1:3455/v1")]),
438 None,
439 None,
440 None,
441 )
442 .expect_err("rejected");
443
444 assert!(matches!(error, ProviderError::NoCompatibleCredential));
445 }
446
447 #[test]
448 fn selecting_a_local_provider_by_key_is_rejected() {
449 let error = resolve_against(
450 ¬hing_exported(),
451 Some(BuiltinProvider::Ollama),
452 None,
453 None,
454 )
455 .expect_err("rejected");
456
457 assert!(matches!(error, ProviderError::NotKeyed(_)));
458 }
459
460 #[test]
461 fn a_named_provider_with_no_key_names_the_variable_it_wanted() {
462 let error = resolve_against(
463 ¬hing_exported(),
464 Some(BuiltinProvider::OpenRouter),
465 None,
466 None,
467 )
468 .expect_err("rejected");
469
470 assert!(matches!(
471 error,
472 ProviderError::MissingCredential {
473 var: "OPENROUTER_API_KEY",
474 ..
475 }
476 ));
477 }
478
479 #[test]
480 fn a_supplied_key_is_used_instead_of_the_environment() {
481 let choice = resolve_against(
484 &exporting(&[("ANTHROPIC_API_KEY", "exported-key")]),
485 Some(BuiltinProvider::Anthropic),
486 None,
487 Some("supplied-key"),
488 )
489 .expect("a named provider and a key need no lookup");
490
491 assert_eq!(choice.api_key, "supplied-key");
492 assert_eq!(choice.provider, BuiltinProvider::Anthropic);
493 assert_eq!(
494 choice.source_var, None,
495 "no variable was read, so none may be named"
496 );
497 }
498
499 #[test]
500 fn a_supplied_key_reaches_a_compatible_endpoint() {
501 let choice = resolve_against(
502 ¬hing_exported(),
503 None,
504 Some("http://127.0.0.1:3455/v1"),
505 Some("supplied-key"),
506 )
507 .expect("a base URL and a key are enough");
508
509 assert_eq!(choice.api_key, "supplied-key");
510 assert_eq!(choice.base_url.as_deref(), Some("http://127.0.0.1:3455/"));
511 assert!(choice.is_compatible_endpoint());
512 }
513
514 #[test]
515 fn a_key_with_nothing_to_attribute_it_to_is_refused() {
516 let error = resolve_against(¬hing_exported(), None, None, Some("supplied-key"))
519 .expect_err("rejected");
520
521 assert!(matches!(error, ProviderError::UnattributedCredential));
522 }
523
524 #[test]
525 fn a_resolved_credential_is_not_printed() {
526 let choice = resolve_against(
530 &exporting(&[("ANTHROPIC_API_KEY", "sk-secret-value")]),
531 None,
532 None,
533 None,
534 )
535 .expect("a key is exported");
536
537 let printed = format!("{choice:?}");
538
539 assert!(!printed.contains("sk-secret-value"));
540 assert!(printed.contains("redacted"));
541 assert!(
542 printed.contains("ANTHROPIC_API_KEY"),
543 "which variable answered is not the secret, and is how a caller debugs this"
544 );
545 }
546
547 #[test]
548 fn a_published_base_url_keeps_its_host_and_loses_its_version_suffix() {
549 assert_eq!(
552 normalize_base_url("http://127.0.0.1:3455/v1").expect("normalizes"),
553 "http://127.0.0.1:3455/"
554 );
555 assert_eq!(
556 normalize_base_url("https://gateway.example.com/v1/").expect("normalizes"),
557 "https://gateway.example.com/"
558 );
559 }
560
561 #[test]
562 fn a_base_url_without_a_version_suffix_is_left_alone() {
563 assert_eq!(
564 normalize_base_url("https://gateway.example.com").expect("normalizes"),
565 "https://gateway.example.com/"
566 );
567 }
568
569 #[test]
570 fn a_path_prefix_survives_normalization() {
571 assert_eq!(
574 normalize_base_url("https://example.com/openai/v1").expect("normalizes"),
575 "https://example.com/openai/"
576 );
577 }
578
579 #[test]
580 fn a_base_url_must_be_absolute_http() {
581 for raw in ["127.0.0.1:3455/v1", "ftp://example.com", "", "https://"] {
582 assert!(
583 normalize_base_url(raw).is_err(),
584 "'{raw}' must be rejected before it reaches the transport"
585 );
586 }
587 }
588
589 #[test]
590 fn an_endpoint_is_flagged_as_compatible() {
591 let choice = ProviderChoice {
592 provider: BuiltinProvider::OpenAI,
593 api_key: "k".to_string(),
594 source_var: None,
595 base_url: Some("http://localhost:1/".to_string()),
596 };
597
598 assert!(choice.is_compatible_endpoint());
599 }
600}