Skip to main content

basis/
provider.rs

1//! Choosing a provider and finding its credential.
2//!
3//! Which model answers is configuration, not a basis opinion — but *finding* the
4//! credential is glue every embedder would otherwise write, so basis does it
5//! once, by the environment-variable names the ecosystem already uses.
6//!
7//! # Nothing here repeats what it read
8//!
9//! The value this module goes looking for is a credential, so
10//! [`ProviderChoice`]'s `Debug` redacts it. That is not hypothetical tidiness:
11//! a resolution test that failed with the wrong variables exported printed a
12//! live key into a terminal, because `expect` formats the `Ok` it did not
13//! want. The same rule as [`WorkspaceBuilder`](crate::WorkspaceBuilder)'s own
14//! `Debug`.
15//!
16//! # The environment is a parameter
17//!
18//! Resolution consults the environment in three places — the base URL, the
19//! compatible-endpoint key, and auto-detection — which is enough to make every
20//! test of it a test of the shell that started it. So the lookup is passed in,
21//! exactly as [`crate::mcp`] passes one to `${VAR}` expansion, and the rules
22//! below can be pinned without mutating the process's own environment.
23
24use mentra::BuiltinProvider;
25use thiserror::Error;
26
27/// A hosted provider basis can select automatically, paired with the environment
28/// variable holding its key.
29///
30/// Order is the auto-detection preference when several keys are present.
31/// Local providers (Ollama, LM Studio) are deliberately absent: they have no
32/// key to detect, so selecting one is always an explicit choice.
33const 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
40/// Environment variables naming a custom OpenAI-compatible endpoint, in
41/// preference order. `OPENAI_BASE_URL` is honored because gateways and proxies
42/// already tell their users to set it.
43const BASE_URL_VARS: &[&str] = &["BASIS_BASE_URL", "OPENAI_BASE_URL"];
44
45/// Environment variables holding the key for a custom endpoint.
46const COMPATIBLE_KEY_VARS: &[&str] = &["BASIS_API_KEY", "OPENAI_API_KEY"];
47
48/// A provider together with the key it will authenticate with.
49#[derive(Clone)]
50pub struct ProviderChoice {
51    pub provider: BuiltinProvider,
52    pub api_key: String,
53    /// The variable the key came from, or `None` when it was passed directly.
54    pub source_var: Option<&'static str>,
55    /// Set when the model lives behind an OpenAI-compatible endpoint rather
56    /// than the provider's own service. Already normalized by
57    /// [`normalize_base_url`].
58    pub base_url: Option<String>,
59}
60
61/// Hand-written so a resolved credential cannot reach a log — or a panicking
62/// test's output — through a `{:?}`. This is the struct an `expect` on a
63/// resolution prints, and the field is a key basis has just read out of the
64/// environment, in plain text. Everything else is printed as it is, including
65/// `source_var`: naming the variable a key came from is how a caller debugs
66/// which one won, and it says nothing about the value.
67impl 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    /// Whether this choice points at a custom OpenAI-compatible endpoint.
80    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
120/// Trims a base URL to what mentra's Responses transport expects.
121///
122/// The transport appends `v1/responses` and `v1/models` itself, but every
123/// gateway publishes its URL *with* `/v1` on the end, because that is the form
124/// the OpenAI SDKs take. Pasting the published URL would otherwise produce
125/// `/v1/v1/responses` and a puzzling 404, so strip a trailing `/v1` here
126/// rather than making each user discover the difference.
127pub 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    // A scheme with no authority ("https://") would otherwise survive to
135    // produce a nonsense request URL.
136    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    // A trailing slash is what `url_for_path` expects to join against.
152    Ok(format!("{without_version}/"))
153}
154
155/// Parses a provider name as written on a command line or in config.
156pub 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
168/// The environment variable holding `provider`'s key, if it has one.
169pub fn key_var(provider: BuiltinProvider) -> Option<&'static str> {
170    CANDIDATES
171        .iter()
172        .find(|(candidate, _)| *candidate == provider)
173        .map(|(_, var)| *var)
174}
175
176/// Resolves how basis will reach a model, with the credential read from the
177/// environment.
178///
179/// A base URL — passed in, or found in the environment — wins over provider
180/// auto-detection: pointing at a specific endpoint is always deliberate, so it
181/// should not be silently overridden by whichever key happens to be exported.
182pub fn resolve(
183    requested: Option<BuiltinProvider>,
184    base_url: Option<&str>,
185) -> Result<ProviderChoice, ProviderError> {
186    resolve_with(requested, base_url, None)
187}
188
189/// Resolves how basis will reach a model, with the credential supplied rather
190/// than looked up.
191///
192/// `api_key` of `None` is [`resolve`] — the environment answers. A host that
193/// holds its key somewhere basis cannot read, a vault or a token it just
194/// exchanged, passes it here instead of exporting a variable for basis to find
195/// again ([`RuntimeBuilder::with_api_key`](crate::RuntimeBuilder::with_api_key)).
196///
197/// A supplied key still has to say *where it is for*: with neither a provider
198/// nor a base URL, basis would be choosing a service to send someone's credential
199/// to, so that combination is refused.
200pub 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
208/// The same, against an explicit environment, so the rules are testable
209/// without mutating the process's own.
210///
211/// Private, and meant to stay that way: a host whose credential lives
212/// somewhere basis cannot read passes it to
213/// [`RuntimeBuilder::with_api_key`](crate::RuntimeBuilder::with_api_key),
214/// and a second, wider way to supply one would be a second thing to keep
215/// honest.
216fn 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
262/// A custom endpoint speaks the OpenAI Responses wire format, so it is
263/// registered under the OpenAI provider id unless the caller named another.
264fn 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
291/// Treats a variable set to whitespace as absent — an empty key produces a
292/// confusing authentication failure much later, and an empty base URL a
293/// request to nowhere.
294fn 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    /// An environment fixed by the test rather than by the shell that started
303    /// it. Every resolution test goes through one of these, because the
304    /// variables this module reads are exactly the ones a person working on basis
305    /// is likely to have exported.
306    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        // Otherwise the run fails at the first request, with an
401        // authentication error that names nothing useful.
402        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        // Pointing at an endpoint is always deliberate; whichever key happens
416        // to be exported is not.
417        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            &nothing_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            &nothing_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        // The point of supplying one: a host whose credential lives in a vault
482        // wants its own key used even where basis could have found another.
483        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            &nothing_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        // Guessing here would mean picking a service to send someone's
517        // credential to.
518        let error = resolve_against(&nothing_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        // How this was found: a resolution test failed with a gateway's
527        // variables exported, and `expect` printed the live key it had just
528        // read into the terminal.
529        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        // The form every gateway publishes, because it is what the OpenAI
550        // SDKs want. mentra's transport adds `v1/...` itself.
551        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        // A gateway mounted under a path must keep it; only the trailing
572        // version segment is ours to remove.
573        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}