basis 0.4.0

The basis SDK: workspace discovery, run lifecycle, one event stream, and the two seams. No protocol, no transport, no TTY.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
//! Choosing a provider and finding its credential.
//!
//! Which model answers is configuration, not a basis opinion — but *finding* the
//! credential is glue every embedder would otherwise write, so basis does it
//! once, by the environment-variable names the ecosystem already uses.
//!
//! # Nothing here repeats what it read
//!
//! The value this module goes looking for is a credential, so
//! [`ProviderChoice`]'s `Debug` redacts it. That is not hypothetical tidiness:
//! a resolution test that failed with the wrong variables exported printed a
//! live key into a terminal, because `expect` formats the `Ok` it did not
//! want. The same rule as [`WorkspaceBuilder`](crate::WorkspaceBuilder)'s own
//! `Debug`.
//!
//! # The environment is a parameter
//!
//! Resolution consults the environment in three places — the base URL, the
//! compatible-endpoint key, and auto-detection — which is enough to make every
//! test of it a test of the shell that started it. So the lookup is passed in,
//! exactly as [`crate::mcp`] passes one to `${VAR}` expansion, and the rules
//! below can be pinned without mutating the process's own environment.

use mentra::BuiltinProvider;
use thiserror::Error;

/// A hosted provider basis can select automatically, paired with the environment
/// variable holding its key.
///
/// Order is the auto-detection preference when several keys are present.
/// Local providers (Ollama, LM Studio) are deliberately absent: they have no
/// key to detect, so selecting one is always an explicit choice.
const CANDIDATES: &[(BuiltinProvider, &str)] = &[
    (BuiltinProvider::Anthropic, "ANTHROPIC_API_KEY"),
    (BuiltinProvider::OpenAI, "OPENAI_API_KEY"),
    (BuiltinProvider::Gemini, "GEMINI_API_KEY"),
    (BuiltinProvider::OpenRouter, "OPENROUTER_API_KEY"),
];

/// Environment variables naming a custom OpenAI-compatible endpoint, in
/// preference order. `OPENAI_BASE_URL` is honored because gateways and proxies
/// already tell their users to set it.
const BASE_URL_VARS: &[&str] = &["BASIS_BASE_URL", "OPENAI_BASE_URL"];

/// Environment variables holding the key for a custom endpoint.
const COMPATIBLE_KEY_VARS: &[&str] = &["BASIS_API_KEY", "OPENAI_API_KEY"];

/// A provider together with the key it will authenticate with.
#[derive(Clone)]
pub struct ProviderChoice {
    pub provider: BuiltinProvider,
    pub api_key: String,
    /// The variable the key came from, or `None` when it was passed directly.
    pub source_var: Option<&'static str>,
    /// Set when the model lives behind an OpenAI-compatible endpoint rather
    /// than the provider's own service. Already normalized by
    /// [`normalize_base_url`].
    pub base_url: Option<String>,
}

/// Hand-written so a resolved credential cannot reach a log — or a panicking
/// test's output — through a `{:?}`. This is the struct an `expect` on a
/// resolution prints, and the field is a key basis has just read out of the
/// environment, in plain text. Everything else is printed as it is, including
/// `source_var`: naming the variable a key came from is how a caller debugs
/// which one won, and it says nothing about the value.
impl std::fmt::Debug for ProviderChoice {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ProviderChoice")
            .field("provider", &self.provider)
            .field("api_key", &"<redacted>")
            .field("source_var", &self.source_var)
            .field("base_url", &self.base_url)
            .finish()
    }
}

impl ProviderChoice {
    /// Whether this choice points at a custom OpenAI-compatible endpoint.
    pub fn is_compatible_endpoint(&self) -> bool {
        self.base_url.is_some()
    }
}

#[derive(Debug, Error)]
pub enum ProviderError {
    #[error(
        "no provider credential found; set one of: {}",
        CANDIDATES.iter().map(|(_, var)| *var).collect::<Vec<_>>().join(", ")
    )]
    NoCredential,

    #[error("{provider} selected but {var} is not set")]
    MissingCredential {
        provider: BuiltinProvider,
        var: &'static str,
    },

    #[error(
        "unknown provider '{0}'; expected one of: anthropic, openai, gemini, openrouter, ollama, lmstudio"
    )]
    Unknown(String),

    #[error("{0} has no API-key environment variable; it is a local provider")]
    NotKeyed(BuiltinProvider),

    #[error(
        "a base URL was given but no key; set one of: {}",
        COMPATIBLE_KEY_VARS.join(", ")
    )]
    NoCompatibleCredential,

    #[error("base URL must be an absolute http(s) URL, got '{0}'")]
    InvalidBaseUrl(String),

    #[error("an API key was supplied with no provider and no base URL to attribute it to")]
    UnattributedCredential,
}

/// Trims a base URL to what mentra's Responses transport expects.
///
/// The transport appends `v1/responses` and `v1/models` itself, but every
/// gateway publishes its URL *with* `/v1` on the end, because that is the form
/// the OpenAI SDKs take. Pasting the published URL would otherwise produce
/// `/v1/v1/responses` and a puzzling 404, so strip a trailing `/v1` here
/// rather than making each user discover the difference.
pub fn normalize_base_url(raw: &str) -> Result<String, ProviderError> {
    let trimmed = raw.trim();
    let rest = trimmed
        .strip_prefix("http://")
        .or_else(|| trimmed.strip_prefix("https://"))
        .ok_or_else(|| ProviderError::InvalidBaseUrl(raw.to_string()))?;

    // A scheme with no authority ("https://") would otherwise survive to
    // produce a nonsense request URL.
    let host = rest.split('/').next().unwrap_or_default();
    if host.is_empty() {
        return Err(ProviderError::InvalidBaseUrl(raw.to_string()));
    }

    let without_slash = trimmed.trim_end_matches('/');
    let without_version = without_slash
        .strip_suffix("/v1")
        .unwrap_or(without_slash)
        .trim_end_matches('/');

    if without_version.is_empty() {
        return Err(ProviderError::InvalidBaseUrl(raw.to_string()));
    }

    // A trailing slash is what `url_for_path` expects to join against.
    Ok(format!("{without_version}/"))
}

/// Parses a provider name as written on a command line or in config.
pub fn parse(name: &str) -> Result<BuiltinProvider, ProviderError> {
    match name.trim().to_ascii_lowercase().as_str() {
        "anthropic" => Ok(BuiltinProvider::Anthropic),
        "openai" => Ok(BuiltinProvider::OpenAI),
        "gemini" => Ok(BuiltinProvider::Gemini),
        "openrouter" => Ok(BuiltinProvider::OpenRouter),
        "ollama" => Ok(BuiltinProvider::Ollama),
        "lmstudio" | "lm-studio" => Ok(BuiltinProvider::LmStudio),
        other => Err(ProviderError::Unknown(other.to_string())),
    }
}

/// The environment variable holding `provider`'s key, if it has one.
pub fn key_var(provider: BuiltinProvider) -> Option<&'static str> {
    CANDIDATES
        .iter()
        .find(|(candidate, _)| *candidate == provider)
        .map(|(_, var)| *var)
}

/// Resolves how basis will reach a model, with the credential read from the
/// environment.
///
/// A base URL — passed in, or found in the environment — wins over provider
/// auto-detection: pointing at a specific endpoint is always deliberate, so it
/// should not be silently overridden by whichever key happens to be exported.
pub fn resolve(
    requested: Option<BuiltinProvider>,
    base_url: Option<&str>,
) -> Result<ProviderChoice, ProviderError> {
    resolve_with(requested, base_url, None)
}

/// Resolves how basis will reach a model, with the credential supplied rather
/// than looked up.
///
/// `api_key` of `None` is [`resolve`] — the environment answers. A host that
/// holds its key somewhere basis cannot read, a vault or a token it just
/// exchanged, passes it here instead of exporting a variable for basis to find
/// again ([`RuntimeBuilder::with_api_key`](crate::RuntimeBuilder::with_api_key)).
///
/// A supplied key still has to say *where it is for*: with neither a provider
/// nor a base URL, basis would be choosing a service to send someone's credential
/// to, so that combination is refused.
pub fn resolve_with(
    requested: Option<BuiltinProvider>,
    base_url: Option<&str>,
    api_key: Option<&str>,
) -> Result<ProviderChoice, ProviderError> {
    resolve_against(&|var| std::env::var(var).ok(), requested, base_url, api_key)
}

/// The same, against an explicit environment, so the rules are testable
/// without mutating the process's own.
///
/// Private, and meant to stay that way: a host whose credential lives
/// somewhere basis cannot read passes it to
/// [`RuntimeBuilder::with_api_key`](crate::RuntimeBuilder::with_api_key),
/// and a second, wider way to supply one would be a second thing to keep
/// honest.
fn resolve_against(
    lookup: &dyn Fn(&str) -> Option<String>,
    requested: Option<BuiltinProvider>,
    base_url: Option<&str>,
    api_key: Option<&str>,
) -> Result<ProviderChoice, ProviderError> {
    if let Some(raw) = base_url
        .map(str::to_string)
        .or_else(|| env_base_url(lookup))
    {
        return resolve_compatible(lookup, &raw, requested, api_key);
    }

    match (requested, api_key) {
        (Some(provider), Some(api_key)) => Ok(ProviderChoice {
            provider,
            api_key: api_key.to_string(),
            source_var: None,
            base_url: None,
        }),
        (None, Some(_)) => Err(ProviderError::UnattributedCredential),
        (Some(provider), None) => {
            let var = key_var(provider).ok_or(ProviderError::NotKeyed(provider))?;
            let api_key =
                read(lookup, var).ok_or(ProviderError::MissingCredential { provider, var })?;
            Ok(ProviderChoice {
                provider,
                api_key,
                source_var: Some(var),
                base_url: None,
            })
        }
        (None, None) => CANDIDATES
            .iter()
            .find_map(|(provider, var)| {
                read(lookup, var).map(|api_key| ProviderChoice {
                    provider: *provider,
                    api_key,
                    source_var: Some(var),
                    base_url: None,
                })
            })
            .ok_or(ProviderError::NoCredential),
    }
}

/// A custom endpoint speaks the OpenAI Responses wire format, so it is
/// registered under the OpenAI provider id unless the caller named another.
fn resolve_compatible(
    lookup: &dyn Fn(&str) -> Option<String>,
    raw: &str,
    requested: Option<BuiltinProvider>,
    api_key: Option<&str>,
) -> Result<ProviderChoice, ProviderError> {
    let base_url = normalize_base_url(raw)?;
    let (api_key, source_var) = match api_key {
        Some(api_key) => (api_key.to_string(), None),
        None => COMPATIBLE_KEY_VARS
            .iter()
            .find_map(|var| read(lookup, var).map(|key| (key, Some(*var))))
            .ok_or(ProviderError::NoCompatibleCredential)?,
    };

    Ok(ProviderChoice {
        provider: requested.unwrap_or(BuiltinProvider::OpenAI),
        api_key,
        source_var,
        base_url: Some(base_url),
    })
}

fn env_base_url(lookup: &dyn Fn(&str) -> Option<String>) -> Option<String> {
    BASE_URL_VARS.iter().find_map(|var| read(lookup, var))
}

/// Treats a variable set to whitespace as absent — an empty key produces a
/// confusing authentication failure much later, and an empty base URL a
/// request to nowhere.
fn read(lookup: &dyn Fn(&str) -> Option<String>, var: &str) -> Option<String> {
    lookup(var).filter(|value| !value.trim().is_empty())
}

#[cfg(test)]
mod tests {
    use super::*;

    /// An environment fixed by the test rather than by the shell that started
    /// it. Every resolution test goes through one of these, because the
    /// variables this module reads are exactly the ones a person working on basis
    /// is likely to have exported.
    fn exporting(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
        let vars: Vec<(String, String)> = vars
            .iter()
            .map(|(var, value)| (var.to_string(), value.to_string()))
            .collect();

        move |name| {
            vars.iter()
                .find(|(var, _)| var == name)
                .map(|(_, value)| value.clone())
        }
    }

    fn nothing_exported() -> impl Fn(&str) -> Option<String> {
        exporting(&[])
    }

    #[test]
    fn provider_names_parse_case_insensitively() {
        assert_eq!(parse("OpenAI").expect("parses"), BuiltinProvider::OpenAI);
        assert_eq!(
            parse("  anthropic  ").expect("parses"),
            BuiltinProvider::Anthropic
        );
        assert_eq!(
            parse("lm-studio").expect("parses"),
            BuiltinProvider::LmStudio
        );
    }

    #[test]
    fn an_unknown_provider_names_the_alternatives() {
        let error = parse("hal9000").expect_err("rejected");

        assert!(matches!(error, ProviderError::Unknown(name) if name == "hal9000"));
    }

    #[test]
    fn hosted_providers_have_a_key_variable_and_local_ones_do_not() {
        assert_eq!(
            key_var(BuiltinProvider::Anthropic),
            Some("ANTHROPIC_API_KEY")
        );
        assert_eq!(key_var(BuiltinProvider::Ollama), None);
    }

    #[test]
    fn detection_order_prefers_the_first_candidate() {
        let vars: Vec<&str> = CANDIDATES.iter().map(|(_, var)| *var).collect();

        assert_eq!(vars.first(), Some(&"ANTHROPIC_API_KEY"));
        assert_eq!(
            vars.len(),
            4,
            "local providers must not be auto-detection candidates"
        );
    }

    #[test]
    fn detection_takes_the_first_candidate_the_environment_offers() {
        let choice = resolve_against(
            &exporting(&[
                ("OPENAI_API_KEY", "openai-key"),
                ("ANTHROPIC_API_KEY", "anthropic-key"),
            ]),
            None,
            None,
            None,
        )
        .expect("a key is exported");

        assert_eq!(choice.provider, BuiltinProvider::Anthropic);
        assert_eq!(choice.source_var, Some("ANTHROPIC_API_KEY"));
    }

    #[test]
    fn a_named_provider_reads_its_own_variable_and_says_which() {
        let choice = resolve_against(
            &exporting(&[
                ("ANTHROPIC_API_KEY", "anthropic-key"),
                ("GEMINI_API_KEY", "gemini-key"),
            ]),
            Some(BuiltinProvider::Gemini),
            None,
            None,
        )
        .expect("the named provider's key is exported");

        assert_eq!(choice.api_key, "gemini-key");
        assert_eq!(choice.source_var, Some("GEMINI_API_KEY"));
    }

    #[test]
    fn a_variable_set_to_whitespace_is_treated_as_absent() {
        // Otherwise the run fails at the first request, with an
        // authentication error that names nothing useful.
        let error = resolve_against(
            &exporting(&[("ANTHROPIC_API_KEY", "   ")]),
            None,
            None,
            None,
        )
        .expect_err("rejected");

        assert!(matches!(error, ProviderError::NoCredential));
    }

    #[test]
    fn an_environment_base_url_outranks_provider_detection() {
        // Pointing at an endpoint is always deliberate; whichever key happens
        // to be exported is not.
        let choice = resolve_against(
            &exporting(&[
                ("ANTHROPIC_API_KEY", "anthropic-key"),
                ("BASIS_BASE_URL", "http://127.0.0.1:3455/v1"),
                ("BASIS_API_KEY", "gateway-key"),
            ]),
            None,
            None,
            None,
        )
        .expect("a base URL and a key are enough");

        assert_eq!(choice.base_url.as_deref(), Some("http://127.0.0.1:3455/"));
        assert_eq!(choice.api_key, "gateway-key");
        assert_eq!(choice.source_var, Some("BASIS_API_KEY"));
    }

    #[test]
    fn a_base_url_with_no_key_anywhere_is_refused() {
        let error = resolve_against(
            &exporting(&[("BASIS_BASE_URL", "http://127.0.0.1:3455/v1")]),
            None,
            None,
            None,
        )
        .expect_err("rejected");

        assert!(matches!(error, ProviderError::NoCompatibleCredential));
    }

    #[test]
    fn selecting_a_local_provider_by_key_is_rejected() {
        let error = resolve_against(
            &nothing_exported(),
            Some(BuiltinProvider::Ollama),
            None,
            None,
        )
        .expect_err("rejected");

        assert!(matches!(error, ProviderError::NotKeyed(_)));
    }

    #[test]
    fn a_named_provider_with_no_key_names_the_variable_it_wanted() {
        let error = resolve_against(
            &nothing_exported(),
            Some(BuiltinProvider::OpenRouter),
            None,
            None,
        )
        .expect_err("rejected");

        assert!(matches!(
            error,
            ProviderError::MissingCredential {
                var: "OPENROUTER_API_KEY",
                ..
            }
        ));
    }

    #[test]
    fn a_supplied_key_is_used_instead_of_the_environment() {
        // The point of supplying one: a host whose credential lives in a vault
        // wants its own key used even where basis could have found another.
        let choice = resolve_against(
            &exporting(&[("ANTHROPIC_API_KEY", "exported-key")]),
            Some(BuiltinProvider::Anthropic),
            None,
            Some("supplied-key"),
        )
        .expect("a named provider and a key need no lookup");

        assert_eq!(choice.api_key, "supplied-key");
        assert_eq!(choice.provider, BuiltinProvider::Anthropic);
        assert_eq!(
            choice.source_var, None,
            "no variable was read, so none may be named"
        );
    }

    #[test]
    fn a_supplied_key_reaches_a_compatible_endpoint() {
        let choice = resolve_against(
            &nothing_exported(),
            None,
            Some("http://127.0.0.1:3455/v1"),
            Some("supplied-key"),
        )
        .expect("a base URL and a key are enough");

        assert_eq!(choice.api_key, "supplied-key");
        assert_eq!(choice.base_url.as_deref(), Some("http://127.0.0.1:3455/"));
        assert!(choice.is_compatible_endpoint());
    }

    #[test]
    fn a_key_with_nothing_to_attribute_it_to_is_refused() {
        // Guessing here would mean picking a service to send someone's
        // credential to.
        let error = resolve_against(&nothing_exported(), None, None, Some("supplied-key"))
            .expect_err("rejected");

        assert!(matches!(error, ProviderError::UnattributedCredential));
    }

    #[test]
    fn a_resolved_credential_is_not_printed() {
        // How this was found: a resolution test failed with a gateway's
        // variables exported, and `expect` printed the live key it had just
        // read into the terminal.
        let choice = resolve_against(
            &exporting(&[("ANTHROPIC_API_KEY", "sk-secret-value")]),
            None,
            None,
            None,
        )
        .expect("a key is exported");

        let printed = format!("{choice:?}");

        assert!(!printed.contains("sk-secret-value"));
        assert!(printed.contains("redacted"));
        assert!(
            printed.contains("ANTHROPIC_API_KEY"),
            "which variable answered is not the secret, and is how a caller debugs this"
        );
    }

    #[test]
    fn a_published_base_url_keeps_its_host_and_loses_its_version_suffix() {
        // The form every gateway publishes, because it is what the OpenAI
        // SDKs want. mentra's transport adds `v1/...` itself.
        assert_eq!(
            normalize_base_url("http://127.0.0.1:3455/v1").expect("normalizes"),
            "http://127.0.0.1:3455/"
        );
        assert_eq!(
            normalize_base_url("https://gateway.example.com/v1/").expect("normalizes"),
            "https://gateway.example.com/"
        );
    }

    #[test]
    fn a_base_url_without_a_version_suffix_is_left_alone() {
        assert_eq!(
            normalize_base_url("https://gateway.example.com").expect("normalizes"),
            "https://gateway.example.com/"
        );
    }

    #[test]
    fn a_path_prefix_survives_normalization() {
        // A gateway mounted under a path must keep it; only the trailing
        // version segment is ours to remove.
        assert_eq!(
            normalize_base_url("https://example.com/openai/v1").expect("normalizes"),
            "https://example.com/openai/"
        );
    }

    #[test]
    fn a_base_url_must_be_absolute_http() {
        for raw in ["127.0.0.1:3455/v1", "ftp://example.com", "", "https://"] {
            assert!(
                normalize_base_url(raw).is_err(),
                "'{raw}' must be rejected before it reaches the transport"
            );
        }
    }

    #[test]
    fn an_endpoint_is_flagged_as_compatible() {
        let choice = ProviderChoice {
            provider: BuiltinProvider::OpenAI,
            api_key: "k".to_string(),
            source_var: None,
            base_url: Some("http://localhost:1/".to_string()),
        };

        assert!(choice.is_compatible_endpoint());
    }
}