Resolve provider secrets through lithos conventional credentials

lithos-llm now owns which named secrets each provider reads and how they
shape into its auth scheme, including a derived `<PROVIDER>_API_KEY` for
operator-defined providers. Fabro's job shrinks to supplying the store:
`VaultCredentialSource` hands lithos a lookup that reads the process
environment, then the vault, under the same conventional names.

What Fabro still adds on top: the Codex OAuth credential in the vault,
refreshed and persisted when it expires; `{{ secrets.NAME }}` tokens in a
provider's `default_headers`, resolved against the vault and re-sent as
credential headers; and OpenAI organization and project headers from the
environment.

Deleted with the `metadata.fabro.credentials` list: `CredentialRef`,
`CredentialResolver`, `EnvCredentialSource` (now
`VaultCredentialSource::environment_only`), and the `env_var_names` /
`expected_vault_secret_name` helpers, replaced by `secret_names` and
`expected_secret_name` over the lithos table. `openai-codex` joins the
first-party provider id constants.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-09 23:00:08 -06:00
parent 0319b24585
commit 6dfc96d3fd
No known key found for this signature in database
23 changed files with 919 additions and 1237 deletions

View file

@ -10,8 +10,7 @@ This document defines how Fabro resolves LLM credentials and constructs `fabro-l
- Standalone setup and tests that use default settings build a default `Arc<Catalog>` locally, then pass it explicitly.
- `GenerateParams::new(model, client)` always receives an explicit `Arc<Client>`.
- When a caller needs diagnostics in runtime request-serving paths, call `source.resolve(catalog)` directly and consume both `credentials` and `auth_issues`.
- `EnvCredentialSource` is the env-backed source for env-only or no-vault contexts.
- `VaultCredentialSource` is the normal source for vault-backed runtime contexts.
- `VaultCredentialSource` is the normal source for vault-backed runtime contexts; `VaultCredentialSource::environment_only()` serves env-only or no-vault contexts.
## Why

View file

@ -43,11 +43,10 @@ the vault:
`FABRO_JWT_PRIVATE_KEY` and `FABRO_JWT_PUBLIC_KEY` are removed. `SESSION_SECRET` is the single auth root.
Provisioning into the vault is not the same as the resolver being vault-only. `CredentialResolver`
owns a documented process-env fallback that runs after the vault lookup
(`lib/foundation/fabro-auth/src/resolve.rs:198-204`), and `CredentialRef::Env(name)` is a
first-class credential source (`resolve.rs:350`). Which paths that fallback is live on is a
per-process question:
Provisioning into the vault is not the same as the resolver being vault-only. `VaultCredentialSource`
(`lib/foundation/fabro-auth/src/vault_source.rs`) reads each secret name lithos-llm asks for from
the process environment first and the vault second, under the same conventional names. Which paths
that environment lookup is live on is a per-process question:
- **Server process** — inert. `lib/apps/fabro-server/src/server.rs:2453` builds
`SqlVaultCredentialSource::vault_only(...)`, so the env lookup always returns `None`.
@ -85,11 +84,12 @@ consumption time) and `vars` (non-sensitive run variables, substituted early at
`{{ env.NAME }}` tokens still parse but never resolve; they fail loudly with a migration message. A
token whose namespace is unavailable in the resolution context also fails loudly.
The reference implementation is LLM provider `extra_headers`, resolved against the vault at
`lib/foundation/fabro-auth/src/resolve.rs:376-378`:
The reference implementation is LLM provider `default_headers`, whose `{{ secrets.* }}` values are
resolved against the vault in `lib/foundation/fabro-auth/src/vault_source.rs`
(`interpolated_headers`) and re-sent as credential headers:
```toml
[llm.providers.example.extra_headers]
[llm.providers.example.default_headers]
authorization = "Bearer {{ secrets.EXAMPLE_TOKEN }}"
```

View file

@ -115,7 +115,7 @@ digraph Example {
## Direct SDK environment credentials
The built-in Modal provider authenticates with two headers, `Modal-Key` and `Modal-Secret`, read from the vault secrets `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`. `EnvCredentialSource` does not configure Modal automatically because Modal uses two headers instead of one API-key reference.
The built-in Modal provider authenticates with two headers, `Modal-Key` and `Modal-Secret`, read from the secrets `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`. Direct SDK use reads the same two names from the process environment.
For direct SDK use, enable Modal and set its endpoint URL in the `[llm]` overlay, then build the client with `fabro_llm::build_client` over a `VaultCredentialSource` whose vault holds both secrets. The catalog you pass to the client must be built from the same settings file with `fabro_llm::build_catalog`.

View file

@ -30,7 +30,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use fabro_agent::{AgentProfile, AgentProfileBuilder, LocalSandbox, Session, SessionOptions};
use fabro_auth::EnvCredentialSource;
use fabro_auth::VaultCredentialSource;
use fabro_llm::ClientOptions;
use fabro_types::{AgentProfileKind, provider_ids};
@ -39,7 +39,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let catalog = Arc::new(fabro_llm::default_catalog());
let client = fabro_llm::build_client(
(*catalog).clone(),
Arc::new(EnvCredentialSource::new()),
Arc::new(VaultCredentialSource::environment_only()),
ClientOptions::standard(),
)
.await?
@ -325,12 +325,12 @@ serde_json = "1"
### Quick start
Build a catalog, build a client over a credential source, then send a lithos `Request`. `EnvCredentialSource` reads provider keys such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `GEMINI_API_KEY` from the process environment.
Build a catalog, build a client over a credential source, then send a lithos `Request`. `VaultCredentialSource::environment_only()` reads provider keys such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `GEMINI_API_KEY` from the process environment.
```rust
use std::sync::Arc;
use fabro_auth::EnvCredentialSource;
use fabro_auth::VaultCredentialSource;
use fabro_llm::{ClientOptions, Request};
#[tokio::main]
@ -338,7 +338,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let catalog = fabro_llm::default_catalog();
let built = fabro_llm::build_client(
catalog,
Arc::new(EnvCredentialSource::new()),
Arc::new(VaultCredentialSource::environment_only()),
ClientOptions::standard(),
)
.await?;
@ -378,7 +378,7 @@ The `fabro_llm::catalog` module reads Fabro policy from the catalog: `enabled_pr
`ClientOptions::standard()` turns on the lithos retry middleware (three attempts with short exponential backoff) and local attachment inlining. Add middleware with `with_middleware`, replace a provider's adapter with `with_adapter`, or set `http` to inject a configured HTTP client. `fabro_llm::build_offline_client(catalog, options)` builds a client whose only providers are custom adapters, which is how `fabro exec --server` routes every call through a Fabro server.
Credential sources live in `fabro-auth`: `EnvCredentialSource` for the process environment, `VaultCredentialSource` for a Fabro vault with optional environment fallback, and `SqlVaultCredentialSource` for the server's secret store. Fabro looks up a provider's secret through the `metadata.fabro.credentials` refs on its catalog entry.
Credential sources live in `fabro-auth`: `VaultCredentialSource` reads a Fabro vault with an optional process-environment fallback (`VaultCredentialSource::environment_only()` for SDK callers with no vault), and `SqlVaultCredentialSource` reads the server's secret store. lithos-llm decides which secret names a provider reads (`OPENAI_API_KEY`, `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`, or `<PROVIDER>_API_KEY` for an operator-defined provider); Fabro's vault is keyed by those same names.
#### Requests and responses

View file

@ -91,14 +91,14 @@ fn install_llm_provider_ids(catalog: &Catalog) -> Vec<ProviderId> {
fn provider_env_var_label(provider: &ProviderId, catalog: &Catalog) -> String {
catalog::provider(catalog, provider.as_str())
.map(|entry| fabro_auth::env_var_names(entry.provider).join(" / "))
.map(|entry| fabro_auth::secret_names(entry.provider).join(" / "))
.filter(|label| !label.is_empty())
.unwrap_or_else(|| "API_KEY".to_string())
}
fn provider_vault_secret_name(provider: &ProviderId, catalog: &Catalog) -> String {
catalog::provider(catalog, provider.as_str())
.and_then(|entry| fabro_auth::expected_vault_secret_name(entry.provider))
.and_then(|entry| fabro_auth::expected_secret_name(entry.provider))
.unwrap_or_else(|| format!("{}_API_KEY", provider.to_string().to_uppercase()))
}

View file

@ -860,7 +860,7 @@ fn install_catalog_provider(provider: &ProviderId) -> Result<&'static CatalogPro
fn provider_secret_name(provider: &ProviderId) -> Result<String, String> {
let catalog_provider = install_catalog_provider(provider)?;
fabro_auth::expected_vault_secret_name(catalog_provider)
fabro_auth::expected_secret_name(catalog_provider)
.ok_or_else(|| format!("provider '{provider}' does not define a vault credential path"))
}

View file

@ -12,7 +12,7 @@ use fabro_agent::{
AgentEvent, AgentProfile, AgentProfileBuilder, LocalSandbox, OpenAiProfile, Session,
SessionOptions, SubAgentSupervisor, ToolSecrets, WebFetchSummarizer,
};
use fabro_auth::EnvCredentialSource;
use fabro_auth::VaultCredentialSource;
use fabro_config::LlmLayer;
use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::test_support::client_from_env;
@ -146,7 +146,7 @@ async fn make_client(provider: &Provider, twin: Option<&OpenAiTwinOptions>) -> C
return make_twin_client(twin.expect("openai twin config should be provided")).await;
}
let source = Arc::new(EnvCredentialSource::new());
let source = Arc::new(VaultCredentialSource::environment_only());
fabro_llm::build_client(live_catalog(), source, ClientOptions::standard())
.await
.expect("LLM client should build")

View file

@ -102,7 +102,7 @@ fn provider_view(entry: &ProviderEntry<'_>, configured: bool) -> Provider {
model_count: u32::try_from(catalog::provider_models(provider).len()).unwrap_or(u32::MAX),
default_model: provider.default_model().map(str::to_string),
configured,
expected_secret_name: fabro_auth::expected_vault_secret_name(provider),
expected_secret_name: fabro_auth::expected_secret_name(provider),
}
}

View file

@ -7303,7 +7303,7 @@ mod real_llm {
use std::sync::Arc;
use async_trait::async_trait;
use fabro_auth::EnvCredentialSource;
use fabro_auth::VaultCredentialSource;
use fabro_graphviz::graph::Node;
use fabro_llm::{Client, ClientOptions, Request};
use fabro_types::WorkflowSettings;
@ -7398,7 +7398,8 @@ mod real_llm {
}
fabro_test::require_env("ANTHROPIC_API_KEY")?;
let source: Arc<dyn fabro_auth::CredentialSource> = Arc::new(EnvCredentialSource::new());
let source: Arc<dyn fabro_auth::CredentialSource> =
Arc::new(VaultCredentialSource::environment_only());
Some(Arc::new(
fabro_llm::build_client(
Catalog::clone(&super::default_catalog()),

View file

@ -23,7 +23,7 @@ fabro-redact.workspace = true
fabro-static.workspace = true
fabro-types = { path = "../fabro-types" }
fabro-vault = { path = "../fabro-vault" }
lithos-llm = { workspace = true, features = ["runtime"] }
lithos-llm = { workspace = true, features = ["runtime", "environment-credentials", "bedrock-aws"] }
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true

View file

@ -1,7 +1,8 @@
//! A credential source holding one operator-supplied API key.
//!
//! Used to validate a pasted key before it is stored: the key is shaped into
//! the provider's declared auth scheme and offered for that provider only.
//! Used to validate a pasted key before it is stored: the key stands in for
//! the first secret the provider conventionally reads, so lithos shapes it
//! into the provider's auth scheme exactly as a stored secret would be.
use std::collections::HashMap;
use std::sync::Arc;
@ -9,11 +10,13 @@ use std::sync::Arc;
use async_trait::async_trait;
use fabro_vault::Vault;
use lithos_llm::catalog::{Catalog, CatalogProvider, ProviderId};
use lithos_llm::credentials::Credentials;
use lithos_llm::credentials::{ConventionalCredentials, CredentialProvider, Credentials};
use tokio::sync::RwLock as AsyncRwLock;
use crate::credential_source::CredentialSource;
use crate::resolve::{ResolveError, credentials_for_api_key};
use crate::error::ResolveError;
use crate::secrets::expected_secret_name;
use crate::vault_source::{auth_scheme_name, interpolated_headers, resolve_error};
pub struct ApiKeyCredentialSource {
provider: ProviderId,
@ -22,8 +25,8 @@ pub struct ApiKeyCredentialSource {
}
impl ApiKeyCredentialSource {
/// A source for `provider` with no vault behind it, so extra headers that
/// interpolate vault secrets fail to resolve.
/// A source for `provider` with no vault behind it, so header secrets the
/// provider interpolates from the vault fail to resolve.
#[must_use]
pub fn new(provider: ProviderId, key: String) -> Self {
Self::with_vault(
@ -33,7 +36,8 @@ impl ApiKeyCredentialSource {
)
}
/// A source for `provider` whose extra headers resolve against `vault`.
/// A source for `provider` whose interpolated headers resolve against
/// `vault`.
#[must_use]
pub fn with_vault(provider: ProviderId, key: String, vault: Arc<AsyncRwLock<Vault>>) -> Self {
Self {
@ -52,14 +56,38 @@ impl std::fmt::Debug for ApiKeyCredentialSource {
}
}
/// Shapes a caller-supplied API key into the provider's credentials.
pub(crate) async fn credentials_for_api_key(
provider: &CatalogProvider,
key: String,
vault: &Vault,
) -> Result<Credentials, ResolveError> {
let Some(name) = expected_secret_name(provider) else {
return Err(ResolveError::SchemeMismatch {
provider: provider.id().clone(),
scheme: auth_scheme_name(provider.auth()).to_string(),
});
};
let interpolated = interpolated_headers(vault, provider)?;
let mut credentials = ConventionalCredentials::new()
.with_lookup(move |candidate| (candidate == name).then(|| key.clone()))
.credentials(provider)
.await
.map_err(|err| resolve_error(provider, &err))?;
if let Credentials::Http(http) = &mut credentials {
http.extra_headers.extend(interpolated);
}
Ok(credentials)
}
#[async_trait]
impl CredentialSource for ApiKeyCredentialSource {
async fn credentials(&self, provider: &CatalogProvider) -> Result<Credentials, ResolveError> {
if provider.id() != &self.provider {
return Err(ResolveError::NotConfigured(provider.id().clone()));
}
let vault = self.vault.read().await;
credentials_for_api_key(provider, self.key.clone(), &vault)
let vault = self.vault.read().await.clone();
credentials_for_api_key(provider, self.key.clone(), &vault).await
}
async fn configured_providers(&self, catalog: &Catalog) -> Vec<ProviderId> {
@ -70,3 +98,53 @@ impl CredentialSource for ApiKeyCredentialSource {
.unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use lithos_llm::credentials::{HttpAuthentication, HttpCredentials};
use super::*;
use crate::secrets::accepts_api_key;
use crate::test_support::test_catalog;
#[tokio::test]
async fn api_key_credentials_follow_the_provider_scheme() {
let catalog = test_catalog();
let vault = Vault::from_entries(HashMap::new());
let openai = credentials_for_api_key(
catalog.provider("openai").unwrap(),
"sk-test".to_string(),
&vault,
)
.await
.unwrap();
assert!(matches!(
openai,
Credentials::Http(HttpCredentials {
auth: HttpAuthentication::Bearer(secret),
..
}) if secret.expose_secret() == "sk-test"
));
let bedrock = credentials_for_api_key(
catalog.provider("bedrock").unwrap(),
"sk-test".to_string(),
&vault,
)
.await
.unwrap();
assert!(matches!(bedrock, Credentials::BedrockBearer(_)));
let modal = credentials_for_api_key(
catalog.provider("modal").unwrap(),
"sk-test".to_string(),
&vault,
)
.await;
assert!(modal.is_err(), "modal has no single-key scheme");
assert!(!accepts_api_key(catalog.provider("modal").unwrap()));
assert!(accepts_api_key(catalog.provider("openai").unwrap()));
assert!(accepts_api_key(catalog.provider("bedrock").unwrap()));
assert!(!accepts_api_key(catalog.provider("ollama").unwrap()));
}
}

View file

@ -1,124 +0,0 @@
//! Credential references declared in `metadata.fabro.credentials`.
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize, de};
/// Where one provider secret comes from.
///
/// A provider lists these in order; the first that resolves wins.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CredentialRef {
/// A token or OAuth entry in the Fabro vault.
Vault(String),
/// A process environment variable.
Env(String),
/// The AWS default credential chain. Resolves without a secret; the
/// Bedrock adapter signs each request.
AwsSigv4,
}
impl std::fmt::Display for CredentialRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Vault(name) => write!(f, "vault:{name}"),
Self::Env(name) => write!(f, "env:{name}"),
Self::AwsSigv4 => f.write_str("aws_sigv4"),
}
}
}
impl FromStr for CredentialRef {
type Err = CredentialRefParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
if value == "aws_sigv4" {
return Ok(Self::AwsSigv4);
}
if let Some(name) = value.strip_prefix("vault:") {
return if name.is_empty() {
Err(CredentialRefParseError::EmptyVault)
} else {
Ok(Self::Vault(name.to_string()))
};
}
if let Some(name) = value.strip_prefix("env:") {
return if name.is_empty() {
Err(CredentialRefParseError::EmptyEnv)
} else {
Ok(Self::Env(name.to_string()))
};
}
Err(CredentialRefParseError::Invalid)
}
}
impl Serialize for CredentialRef {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for CredentialRef {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
value.parse().map_err(de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum CredentialRefParseError {
#[error("credential reference must be `vault:<name>`, `env:<NAME>`, or `aws_sigv4`")]
Invalid,
#[error("credential reference is missing a name after `vault:`")]
EmptyVault,
#[error("credential reference is missing a name after `env:`")]
EmptyEnv,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_each_form() {
assert_eq!(
"vault:OPENAI_CODEX".parse::<CredentialRef>().unwrap(),
CredentialRef::Vault("OPENAI_CODEX".into())
);
assert_eq!(
"env:KIMI_API_KEY".parse::<CredentialRef>().unwrap(),
CredentialRef::Env("KIMI_API_KEY".into())
);
assert_eq!(
"aws_sigv4".parse::<CredentialRef>().unwrap(),
CredentialRef::AwsSigv4
);
}
#[test]
fn rejects_literal_secrets_without_echoing_them() {
let err = "sk-ant-1234".parse::<CredentialRef>().unwrap_err();
assert_eq!(err, CredentialRefParseError::Invalid);
assert!(!err.to_string().contains("sk-ant"));
assert_eq!(
"vault:".parse::<CredentialRef>().unwrap_err(),
CredentialRefParseError::EmptyVault
);
assert_eq!(
"env:".parse::<CredentialRef>().unwrap_err(),
CredentialRefParseError::EmptyEnv
);
}
#[test]
fn round_trips_through_serde_strings() {
let value: Vec<CredentialRef> =
serde_json::from_str(r#"["env:A", "vault:b", "aws_sigv4"]"#).unwrap();
assert_eq!(
serde_json::to_string(&value).unwrap(),
r#"["env:A","vault:b","aws_sigv4"]"#
);
assert!(serde_json::from_str::<Vec<CredentialRef>>(r#"["sk-literal"]"#).is_err());
}
}

View file

@ -9,7 +9,6 @@
use std::sync::Arc;
use async_trait::async_trait;
use fabro_types::catalog_policy;
use lithos_llm::catalog::{Catalog, CatalogProvider, ProviderId};
use lithos_llm::credentials::{CredentialError, CredentialProvider, Credentials};
@ -50,10 +49,7 @@ pub trait CredentialSource: Send + Sync {
/// the providers that have material but cannot use it.
async fn resolve_all(&self, catalog: &Catalog) -> ResolvedCredentials {
let mut resolved = ResolvedCredentials::default();
for provider in catalog.providers() {
if !catalog_policy::provider_policy(provider).is_enabled() {
continue;
}
for provider in catalog.providers().filter(|provider| provider.is_enabled()) {
match self.credentials(provider).await {
Ok(_) => resolved.ready.push(provider.id().clone()),
Err(ResolveError::NotConfigured(_)) => {}

View file

@ -1,106 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use fabro_vault::Vault;
use lithos_llm::catalog::{Catalog, CatalogProvider, ProviderId};
use lithos_llm::credentials::Credentials;
use tokio::sync::RwLock as AsyncRwLock;
use crate::{CredentialSource, EnvLookup, ResolveError, VaultCredentialSource};
/// A credential source for provider credentials declared as `env:<NAME>`.
///
/// This public SDK facade does not resolve `{{ secrets.NAME }}` header
/// interpolation, so providers whose headers come from the vault stay
/// unconfigured here.
#[derive(Clone)]
pub struct EnvCredentialSource {
inner: VaultCredentialSource,
}
impl EnvCredentialSource {
#[must_use]
#[expect(
clippy::disallowed_methods,
reason = "EnvCredentialSource is the provider credential process-env facade."
)]
pub fn new() -> Self {
Self::with_env_lookup(Arc::new(|name| std::env::var(name).ok()))
}
#[must_use]
pub fn with_env_lookup(env_lookup: EnvLookup) -> Self {
let vault = Arc::new(AsyncRwLock::new(Vault::from_entries(HashMap::new())));
let inner = VaultCredentialSource::with_env_lookup(vault, move |name| env_lookup(name));
Self { inner }
}
}
impl std::fmt::Debug for EnvCredentialSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EnvCredentialSource")
.finish_non_exhaustive()
}
}
impl Default for EnvCredentialSource {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl CredentialSource for EnvCredentialSource {
async fn credentials(&self, provider: &CatalogProvider) -> Result<Credentials, ResolveError> {
self.inner.credentials(provider).await
}
async fn configured_providers(&self, catalog: &Catalog) -> Vec<ProviderId> {
self.inner.configured_providers(catalog).await
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use lithos_llm::catalog::ProviderId;
use super::EnvCredentialSource;
use crate::CredentialSource;
use crate::test_support::test_catalog;
fn test_source(entries: &[(&str, &str)]) -> EnvCredentialSource {
let entries: HashMap<String, String> = entries
.iter()
.map(|(key, value)| ((*key).to_string(), (*value).to_string()))
.collect();
EnvCredentialSource::with_env_lookup(Arc::new(move |name| entries.get(name).cloned()))
}
#[tokio::test]
async fn configured_providers_reads_injected_provider_env() {
let source = test_source(&[("ANTHROPIC_API_KEY", "anthropic-key")]);
assert_eq!(source.configured_providers(&test_catalog()).await, vec![
ProviderId::new("anthropic")
]);
}
#[tokio::test]
async fn modal_env_vars_do_not_replace_vault_secrets() {
let source = test_source(&[
("MODAL_TOKEN_ID", "wk-test"),
("MODAL_TOKEN_SECRET", "ws-test"),
]);
let catalog = test_catalog();
let modal = ProviderId::new("modal");
assert!(!source.configured_providers(&catalog).await.contains(&modal));
let err = source
.credentials(catalog.provider("modal").unwrap())
.await
.unwrap_err();
assert!(matches!(err, crate::ResolveError::Interpolation { .. }));
}
}

View file

@ -0,0 +1,83 @@
//! Why a provider's credentials could not be resolved.
use fabro_types::settings::ResolveError as InterpResolveError;
use fabro_vault::SecretType;
use lithos_llm::catalog::ProviderId;
#[derive(Debug, thiserror::Error)]
pub enum ResolveError {
#[error("{0} is not configured")]
NotConfigured(ProviderId),
#[error("{provider} header interpolation failed: {source}")]
Interpolation {
provider: ProviderId,
#[source]
source: InterpResolveError,
},
#[error("{provider} vault credential '{name}' is not valid Oauth JSON: {source}")]
VaultDecodeFailed {
provider: ProviderId,
name: String,
#[source]
source: serde_json::Error,
},
#[error("{provider} vault credential '{name}' has schema {actual:?}, expected Token or Oauth")]
VaultSchemaMismatch {
provider: ProviderId,
name: String,
actual: SecretType,
},
#[error("{provider} requires re-authentication: {source}")]
RefreshFailed {
provider: ProviderId,
#[source]
source: anyhow::Error,
},
#[error("{0} requires re-authentication: missing refresh token")]
RefreshTokenMissing(ProviderId),
#[error("{provider} resolved a secret its `{scheme}` auth scheme cannot use")]
SchemeMismatch {
provider: ProviderId,
scheme: String,
},
}
impl ResolveError {
#[must_use]
pub fn provider(&self) -> &ProviderId {
match self {
Self::NotConfigured(provider)
| Self::RefreshTokenMissing(provider)
| Self::Interpolation { provider, .. }
| Self::VaultDecodeFailed { provider, .. }
| Self::VaultSchemaMismatch { provider, .. }
| Self::RefreshFailed { provider, .. }
| Self::SchemeMismatch { provider, .. } => provider,
}
}
}
#[must_use]
pub fn auth_issue_message(provider: &ProviderId, err: &ResolveError) -> String {
match err {
ResolveError::NotConfigured(_) => format!("{provider} is not configured"),
ResolveError::Interpolation { source, .. } => {
format!("{provider} header interpolation failed: {source}")
}
ResolveError::VaultDecodeFailed { name, source, .. } => {
format!("{provider} vault credential '{name}' is not valid OAuth JSON: {source}")
}
ResolveError::VaultSchemaMismatch { name, actual, .. } => format!(
"{provider} vault credential '{name}' has schema {actual:?}, expected Token or Oauth"
),
ResolveError::RefreshFailed { source, .. } => {
format!("{provider} requires re-authentication: {source}")
}
ResolveError::RefreshTokenMissing(_) => {
format!("{provider} requires re-authentication: refresh token missing")
}
ResolveError::SchemeMismatch { scheme, .. } => {
format!("{provider} resolved a secret its `{scheme}` auth scheme cannot use")
}
}
}

View file

@ -1,12 +1,11 @@
mod api_key_source;
mod context;
mod credential;
mod credential_ref;
mod credential_source;
mod env_source;
mod error;
mod extra_headers_source;
mod refresh;
mod resolve;
mod secrets;
mod sql_vault_source;
mod strategy;
#[cfg(any(test, feature = "test-support"))]
@ -19,15 +18,11 @@ pub mod strategies;
pub use api_key_source::ApiKeyCredentialSource;
pub use context::{AuthContextRequest, AuthContextResponse};
pub use credential::{OAuthConfig, OAuthCredential, OAuthTokens};
pub use credential_ref::{CredentialRef, CredentialRefParseError};
pub use credential_source::{CredentialSource, ResolvedCredentials, lithos_credentials};
pub use env_source::EnvCredentialSource;
pub use error::{ResolveError, auth_issue_message};
pub use extra_headers_source::ExtraHeadersCredentialSource;
pub use refresh::refresh_oauth_credential;
pub use resolve::{
CredentialResolver, EnvLookup, ResolveError, accepts_api_key, auth_issue_message,
credential_refs, credentials_for_api_key, env_var_names, expected_vault_secret_name,
};
pub use secrets::{accepts_api_key, expected_secret_name, secret_names};
pub use sql_vault_source::SqlVaultCredentialSource;
pub use strategy::{
AuthMethod, AuthStrategy, CODEX_AUTH_URL, CODEX_CLIENT_ID, CODEX_TOKEN_URL, LoginResult,
@ -36,6 +31,8 @@ pub use strategy::{
pub use vault_ext::{
VaultLookupError, vault_get_oauth, vault_get_token, vault_set_oauth, vault_set_token,
};
pub use vault_source::VaultCredentialSource;
pub use vault_source::{EnvLookup, VaultCredentialSource};
/// The vault entry holding the Codex OAuth credential that serves the
/// `openai-codex` provider.
pub const OPENAI_CODEX_VAULT_SECRET_NAME: &str = "OPENAI_CODEX";

View file

@ -1,871 +0,0 @@
//! Secret resolution for one catalog provider.
//!
//! A provider's `metadata.fabro.credentials` names where its secret lives.
//! [`CredentialResolver`] walks that list against the vault and the process
//! environment, refreshes an expired OAuth credential, and shapes the result
//! into the lithos [`Credentials`] the provider's declared auth scheme
//! expects.
use std::collections::BTreeMap;
use std::sync::Arc;
use fabro_static::EnvVars;
use fabro_types::catalog_policy::{self, ProviderPolicy};
use fabro_types::provider_ids;
use fabro_types::settings::{InterpString, ResolveCtx, ResolveError as InterpResolveError};
use fabro_vault::{SecretType, Vault};
use lithos_llm::catalog::{AuthScheme, Catalog, CatalogProvider, ProviderId};
use lithos_llm::credentials::{
CredentialHeader, Credentials, HttpAuthentication, HttpCredentials, SecretValue,
};
use tokio::sync::RwLock as AsyncRwLock;
use tokio::task::spawn_blocking;
use crate::credential::OAuthCredential;
use crate::credential_ref::{CredentialRef, CredentialRefParseError};
use crate::refresh::refresh_oauth_credential;
use crate::vault_ext::{
VaultLookupError, vault_get_oauth, vault_get_token, vault_set_oauth, vault_token_lookup,
};
pub type EnvLookup = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
const CHATGPT_ACCOUNT_ID_HEADER: &str = "ChatGPT-Account-Id";
const OPENAI_ORGANIZATION_HEADER: &str = "OpenAI-Organization";
const OPENAI_PROJECT_HEADER: &str = "OpenAI-Project";
/// A secret found for a provider, before it is shaped into [`Credentials`].
#[derive(Clone, PartialEq, Eq)]
pub(crate) enum ResolvedSecret {
ApiKey(String),
OAuth {
credential: Box<OAuthCredential>,
vault_name: String,
},
/// No static secret: the adapter signs with the AWS default chain.
AwsDefaultChain,
}
impl std::fmt::Debug for ResolvedSecret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ApiKey(_) => f.write_str("ApiKey(<redacted>)"),
Self::OAuth { vault_name, .. } => f
.debug_struct("OAuth")
.field("vault_name", vault_name)
.finish_non_exhaustive(),
Self::AwsDefaultChain => f.write_str("AwsDefaultChain"),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ResolveError {
#[error("{0} is not configured")]
NotConfigured(ProviderId),
#[error("{provider} declares an invalid credential reference `{reference}`: {source}")]
InvalidCredentialRef {
provider: ProviderId,
reference: String,
#[source]
source: CredentialRefParseError,
},
#[error("{provider} header interpolation failed: {source}")]
Interpolation {
provider: ProviderId,
#[source]
source: InterpResolveError,
},
#[error("{provider} vault credential '{name}' has schema {actual:?}, expected Token or Oauth")]
VaultSchemaMismatch {
provider: ProviderId,
name: String,
actual: SecretType,
},
#[error("{provider} vault credential '{name}' is not valid Oauth JSON: {source}")]
VaultDecodeFailed {
provider: ProviderId,
name: String,
#[source]
source: serde_json::Error,
},
#[error("{provider} requires re-authentication: {source}")]
RefreshFailed {
provider: ProviderId,
#[source]
source: anyhow::Error,
},
#[error("{0} requires re-authentication: missing refresh token")]
RefreshTokenMissing(ProviderId),
#[error("{provider} resolved a secret its `{scheme}` auth scheme cannot use")]
SchemeMismatch {
provider: ProviderId,
scheme: String,
},
}
impl ResolveError {
#[must_use]
pub fn provider(&self) -> &ProviderId {
match self {
Self::NotConfigured(provider)
| Self::RefreshTokenMissing(provider)
| Self::InvalidCredentialRef { provider, .. }
| Self::Interpolation { provider, .. }
| Self::VaultSchemaMismatch { provider, .. }
| Self::VaultDecodeFailed { provider, .. }
| Self::RefreshFailed { provider, .. }
| Self::SchemeMismatch { provider, .. } => provider,
}
}
}
#[must_use]
pub fn auth_issue_message(provider: &ProviderId, err: &ResolveError) -> String {
match err {
ResolveError::NotConfigured(_) => format!("{provider} is not configured"),
ResolveError::InvalidCredentialRef {
reference, source, ..
} => format!("{provider} declares an invalid credential reference `{reference}`: {source}"),
ResolveError::Interpolation { source, .. } => {
format!("{provider} header interpolation failed: {source}")
}
ResolveError::VaultSchemaMismatch { name, actual, .. } => format!(
"{provider} vault credential '{name}' has schema {actual:?}, expected Token or Oauth"
),
ResolveError::VaultDecodeFailed { name, source, .. } => {
format!("{provider} vault credential '{name}' is not valid OAuth JSON: {source}")
}
ResolveError::RefreshFailed { source, .. } => {
format!("{provider} requires re-authentication: {source}")
}
ResolveError::RefreshTokenMissing(_) => {
format!("{provider} requires re-authentication: refresh token missing")
}
ResolveError::SchemeMismatch { scheme, .. } => {
format!("{provider} resolved a secret its `{scheme}` auth scheme cannot use")
}
}
}
/// The credential references a provider declares, in resolution order.
pub fn credential_refs(provider: &CatalogProvider) -> Result<Vec<CredentialRef>, ResolveError> {
credential_refs_from_policy(provider.id(), &catalog_policy::provider_policy(provider))
}
fn credential_refs_from_policy(
provider: &ProviderId,
policy: &ProviderPolicy,
) -> Result<Vec<CredentialRef>, ResolveError> {
policy
.credentials
.iter()
.map(|reference| {
reference
.parse()
.map_err(|source| ResolveError::InvalidCredentialRef {
provider: provider.clone(),
reference: reference.clone(),
source,
})
})
.collect()
}
/// The vault entry an operator should create to configure `provider`, when
/// the provider reads a vault secret.
#[must_use]
pub fn expected_vault_secret_name(provider: &CatalogProvider) -> Option<String> {
credential_refs(provider)
.ok()?
.into_iter()
.find_map(|reference| match reference {
CredentialRef::Vault(name) => Some(name),
CredentialRef::Env(_) | CredentialRef::AwsSigv4 => None,
})
}
/// The environment variables an operator can set to configure `provider`.
#[must_use]
pub fn env_var_names(provider: &CatalogProvider) -> Vec<String> {
credential_refs(provider)
.unwrap_or_default()
.into_iter()
.filter_map(|reference| match reference {
CredentialRef::Env(name) => Some(name),
CredentialRef::Vault(_) | CredentialRef::AwsSigv4 => None,
})
.collect()
}
/// Whether the provider takes a single API key an operator can paste in.
#[must_use]
pub fn accepts_api_key(provider: &CatalogProvider) -> bool {
matches!(
provider.auth(),
AuthScheme::Bearer { .. } | AuthScheme::Header { .. } | AuthScheme::BedrockBearer
) && credential_refs(provider).is_ok_and(|refs| {
refs.iter()
.any(|reference| matches!(reference, CredentialRef::Vault(_) | CredentialRef::Env(_)))
})
}
fn auth_scheme_name(scheme: &AuthScheme) -> &'static str {
match scheme {
AuthScheme::None => "none",
AuthScheme::Bearer { .. } => "bearer",
AuthScheme::Header { .. } => "header",
AuthScheme::Headers => "headers",
AuthScheme::Aws { .. } => "aws",
AuthScheme::BedrockBearer => "bedrock_bearer",
_ => "unknown",
}
}
/// Shapes a caller-supplied API key into the provider's credentials.
///
/// Used to validate a key before it is stored. Extra headers that need vault
/// secrets are resolved against `vault`.
pub fn credentials_for_api_key(
provider: &CatalogProvider,
key: String,
vault: &Vault,
) -> Result<Credentials, ResolveError> {
let extra_headers = resolved_extra_headers(vault, provider)?;
shape_secret(provider, ResolvedSecret::ApiKey(key), extra_headers, None)
}
/// Resolves a provider's `extra_headers` interpolation against the vault.
///
/// Resolved header values may contain secrets; keep this path free of value
/// logging.
fn resolved_extra_headers(
vault: &Vault,
provider: &CatalogProvider,
) -> Result<Vec<CredentialHeader>, ResolveError> {
let policy = catalog_policy::provider_policy(provider);
let mut ctx =
ResolveCtx::new().with_secrets(|secret_name| vault_token_lookup(vault, secret_name));
resolve_extra_headers(provider.id(), &policy.extra_headers, &mut ctx)
}
pub(crate) fn resolve_extra_headers(
provider: &ProviderId,
headers: &BTreeMap<String, String>,
ctx: &mut ResolveCtx<'_>,
) -> Result<Vec<CredentialHeader>, ResolveError> {
headers
.iter()
.map(|(name, source)| {
let value = InterpString::parse(source)
.resolve_with(ctx)
.map_err(|source| ResolveError::Interpolation {
provider: provider.clone(),
source,
})?;
Ok(CredentialHeader::new(name.clone(), SecretValue::new(value)))
})
.collect()
}
fn shape_secret(
provider: &CatalogProvider,
secret: ResolvedSecret,
mut extra_headers: Vec<CredentialHeader>,
env_lookup: Option<&EnvLookup>,
) -> Result<Credentials, ResolveError> {
let scheme = provider.auth();
let mismatch = || ResolveError::SchemeMismatch {
provider: provider.id().clone(),
scheme: auth_scheme_name(scheme).to_string(),
};
if let Some(env_lookup) = env_lookup.filter(|_| provider.id().as_str() == provider_ids::OPENAI)
{
for (variable, header) in [
(EnvVars::OPENAI_ORG_ID, OPENAI_ORGANIZATION_HEADER),
(EnvVars::OPENAI_PROJECT_ID, OPENAI_PROJECT_HEADER),
] {
if let Some(value) = env_lookup(variable) {
extra_headers.push(CredentialHeader::new(header, SecretValue::new(value)));
}
}
}
match (scheme, secret) {
(AuthScheme::Bearer { .. }, ResolvedSecret::ApiKey(key)) => Ok(http_credentials(
HttpAuthentication::Bearer(SecretValue::new(key)),
extra_headers,
)),
(AuthScheme::Bearer { .. }, ResolvedSecret::OAuth { credential, .. }) => {
if let Some(account_id) = &credential.account_id {
extra_headers.push(CredentialHeader::new(
CHATGPT_ACCOUNT_ID_HEADER,
SecretValue::new(account_id.clone()),
));
}
Ok(http_credentials(
HttpAuthentication::Bearer(SecretValue::new(
credential.tokens.access_token.clone(),
)),
extra_headers,
))
}
(AuthScheme::Header { name }, ResolvedSecret::ApiKey(key)) => Ok(http_credentials(
HttpAuthentication::Header(CredentialHeader::new(name.clone(), SecretValue::new(key))),
extra_headers,
)),
(AuthScheme::BedrockBearer | AuthScheme::Aws { .. }, ResolvedSecret::ApiKey(key)) => {
Ok(Credentials::BedrockBearer(SecretValue::new(key)))
}
(AuthScheme::Aws { region }, ResolvedSecret::AwsDefaultChain) => {
Ok(Credentials::AwsDefaultChain {
region: region.clone(),
})
}
_ => Err(mismatch()),
}
}
fn http_credentials(auth: HttpAuthentication, extra_headers: Vec<CredentialHeader>) -> Credentials {
let mut credentials = HttpCredentials::new(auth);
credentials.extra_headers = extra_headers;
Credentials::Http(credentials)
}
#[derive(Clone)]
pub struct CredentialResolver {
vault: Arc<AsyncRwLock<Vault>>,
env_lookup: EnvLookup,
}
impl CredentialResolver {
#[must_use]
#[expect(
clippy::disallowed_methods,
reason = "CredentialResolver owns the process-env fallback used after vault lookup."
)]
pub fn new(vault: Arc<AsyncRwLock<Vault>>) -> Self {
Self::with_env_lookup(vault, Arc::new(|name| std::env::var(name).ok()))
}
#[must_use]
pub fn with_env_lookup(vault: Arc<AsyncRwLock<Vault>>, env_lookup: EnvLookup) -> Self {
Self { vault, env_lookup }
}
/// Resolves `provider`'s credentials for one request attempt.
///
/// An expired OAuth credential is refreshed and the refreshed tokens are
/// written back to the vault before the credentials are returned.
pub async fn resolve(&self, provider: &CatalogProvider) -> Result<Credentials, ResolveError> {
let provider_id = provider.id().clone();
match provider.auth() {
AuthScheme::None => {
let vault = self.vault.read().await;
let headers = resolved_extra_headers(&vault, provider)?;
return Ok(Credentials::headers(headers));
}
AuthScheme::Headers => {
let vault = self.vault.read().await;
let headers = resolved_extra_headers(&vault, provider)?;
if headers.is_empty() {
return Err(ResolveError::NotConfigured(provider_id));
}
return Ok(Credentials::headers(headers));
}
_ => {}
}
let (initial_secret, extra_headers) = {
let vault = self.vault.read().await;
(
self.find_secret(&vault, provider)?,
resolved_extra_headers(&vault, provider)?,
)
};
let secret = match initial_secret {
ResolvedSecret::OAuth {
credential,
vault_name,
} if credential.needs_refresh() => {
if credential.tokens.refresh_token.is_none() {
return Err(ResolveError::RefreshTokenMissing(provider_id));
}
let refreshed = refresh_oauth_credential(&credential)
.await
.map_err(|source| ResolveError::RefreshFailed {
provider: provider_id.clone(),
source,
})?;
self.persist_oauth(&provider_id, &vault_name, &refreshed)
.await?;
ResolvedSecret::OAuth {
credential: Box::new(refreshed),
vault_name,
}
}
secret => secret,
};
shape_secret(provider, secret, extra_headers, Some(&self.env_lookup))
}
async fn persist_oauth(
&self,
provider: &ProviderId,
vault_name: &str,
refreshed: &OAuthCredential,
) -> Result<(), ResolveError> {
let refreshed = refreshed.clone();
let vault_name = vault_name.to_string();
let vault = Arc::clone(&self.vault);
spawn_blocking(move || {
let mut vault = vault.blocking_write();
vault_set_oauth(&mut vault, &vault_name, &refreshed)
.map(|_| ())
.map_err(anyhow::Error::from)
})
.await
.map_err(|join_err| ResolveError::RefreshFailed {
provider: provider.clone(),
source: anyhow::Error::from(join_err),
})?
.map_err(|source| ResolveError::RefreshFailed {
provider: provider.clone(),
source,
})
}
/// Providers with credential material present, without refreshing
/// anything. Disabled providers are skipped.
#[must_use]
pub fn configured_providers(&self, vault: &Vault, catalog: &Catalog) -> Vec<ProviderId> {
catalog
.providers()
.filter(|provider| catalog_policy::provider_policy(provider).is_enabled())
.filter(|provider| self.has_credential_material(vault, provider))
.map(|provider| provider.id().clone())
.collect()
}
fn has_credential_material(&self, vault: &Vault, provider: &CatalogProvider) -> bool {
match provider.auth() {
AuthScheme::None => resolved_extra_headers(vault, provider).is_ok(),
AuthScheme::Headers => {
resolved_extra_headers(vault, provider).is_ok_and(|headers| !headers.is_empty())
}
_ => self.find_secret(vault, provider).is_ok(),
}
}
fn find_secret(
&self,
vault: &Vault,
provider: &CatalogProvider,
) -> Result<ResolvedSecret, ResolveError> {
for reference in credential_refs(provider)? {
if let Some(secret) = self.secret_from_ref(vault, provider.id(), &reference)? {
return Ok(secret);
}
}
Err(ResolveError::NotConfigured(provider.id().clone()))
}
fn secret_from_ref(
&self,
vault: &Vault,
provider: &ProviderId,
reference: &CredentialRef,
) -> Result<Option<ResolvedSecret>, ResolveError> {
match reference {
CredentialRef::Vault(name) => match vault_get_token(vault, name) {
Ok(Some(token)) => Ok(Some(ResolvedSecret::ApiKey(token))),
Ok(None) => Ok(None),
Err(VaultLookupError::SchemaMismatch {
actual: SecretType::Oauth,
..
}) => vault_get_oauth(vault, name)
.map(|credential| {
credential.map(|credential| ResolvedSecret::OAuth {
credential: Box::new(credential),
vault_name: name.clone(),
})
})
.map_err(|err| vault_lookup_error(provider, name, err)),
Err(err) => Err(vault_lookup_error(provider, name, err)),
},
CredentialRef::Env(name) => Ok((self.env_lookup)(name).map(ResolvedSecret::ApiKey)),
CredentialRef::AwsSigv4 => Ok(Some(ResolvedSecret::AwsDefaultChain)),
}
}
}
fn vault_lookup_error(provider: &ProviderId, name: &str, err: VaultLookupError) -> ResolveError {
match err {
VaultLookupError::SchemaMismatch { actual, .. } => ResolveError::VaultSchemaMismatch {
provider: provider.clone(),
name: name.to_string(),
actual,
},
VaultLookupError::DecodeFailed { source, .. } => ResolveError::VaultDecodeFailed {
provider: provider.clone(),
name: name.to_string(),
source,
},
}
}
#[cfg(test)]
mod tests {
use chrono::{Duration, Utc};
use httpmock::Method::POST;
use httpmock::MockServer;
use super::*;
use crate::credential::{OAuthConfig, OAuthCredential, OAuthTokens};
use crate::test_support::test_catalog;
use crate::vault_ext::{vault_get_oauth, vault_set_oauth, vault_set_token};
fn oauth_credential(token_url: String, expires_at: chrono::DateTime<Utc>) -> OAuthCredential {
OAuthCredential {
tokens: OAuthTokens {
access_token: "expired-access".to_string(),
refresh_token: Some("refresh-token".to_string()),
expires_at,
},
config: OAuthConfig {
auth_url: "https://auth.openai.com".to_string(),
token_url,
client_id: "test-client".to_string(),
scopes: vec!["openid".to_string()],
redirect_uri: Some("https://auth.openai.com/deviceauth/callback".to_string()),
use_pkce: true,
},
account_id: Some("acct_123".to_string()),
}
}
fn test_resolver(vault: Vault, env_lookup: EnvLookup) -> CredentialResolver {
CredentialResolver::with_env_lookup(Arc::new(AsyncRwLock::new(vault)), env_lookup)
}
fn empty_vault() -> Vault {
Vault::from_entries(std::collections::HashMap::new())
}
fn bearer_secret(credentials: &Credentials) -> &str {
match credentials {
Credentials::Http(HttpCredentials {
auth: HttpAuthentication::Bearer(secret),
..
}) => secret.expose_secret(),
_ => panic!("expected bearer credentials"),
}
}
fn header_value<'a>(credentials: &'a Credentials, name: &str) -> Option<&'a str> {
match credentials {
Credentials::Http(http) => http
.extra_headers
.iter()
.find(|header| header.name.eq_ignore_ascii_case(name))
.map(|header| header.value.expose_secret()),
_ => None,
}
}
#[tokio::test]
async fn env_listed_first_wins_over_vault() {
let mut vault = empty_vault();
vault_set_token(&mut vault, "OPENAI_API_KEY", "vault-key").unwrap();
let resolver = test_resolver(
vault,
Arc::new(|name| (name == "OPENAI_API_KEY").then(|| "env-key".to_string())),
);
let catalog = test_catalog();
let credentials = resolver
.resolve(catalog.provider("openai").unwrap())
.await
.unwrap();
assert_eq!(bearer_secret(&credentials), "env-key");
}
#[tokio::test]
async fn moonshot_falls_back_to_kimi_env_key() {
let resolver = test_resolver(
empty_vault(),
Arc::new(|name| (name == EnvVars::KIMI_API_KEY).then(|| "kimi-key".to_string())),
);
let catalog = test_catalog();
let credentials = resolver
.resolve(catalog.provider("moonshot").unwrap())
.await
.unwrap();
assert_eq!(bearer_secret(&credentials), "kimi-key");
}
#[tokio::test]
async fn anthropic_uses_its_header_scheme() {
let mut vault = empty_vault();
vault_set_token(&mut vault, "ANTHROPIC_API_KEY", "anthropic-key").unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let catalog = test_catalog();
let credentials = resolver
.resolve(catalog.provider("anthropic").unwrap())
.await
.unwrap();
match credentials {
Credentials::Http(HttpCredentials {
auth: HttpAuthentication::Header(header),
..
}) => {
assert_eq!(header.name, "x-api-key");
assert_eq!(header.value.expose_secret(), "anthropic-key");
}
_ => panic!("expected header credentials"),
}
}
#[tokio::test]
async fn codex_oauth_becomes_a_bearer_with_account_header() {
let mut vault = empty_vault();
vault_set_oauth(
&mut vault,
crate::OPENAI_CODEX_VAULT_SECRET_NAME,
&oauth_credential(
"https://auth.openai.com/oauth/token".to_string(),
Utc::now() + Duration::hours(1),
),
)
.unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let catalog = test_catalog();
let credentials = resolver
.resolve(catalog.provider("openai-codex").unwrap())
.await
.unwrap();
assert_eq!(bearer_secret(&credentials), "expired-access");
assert_eq!(
header_value(&credentials, CHATGPT_ACCOUNT_ID_HEADER),
Some("acct_123")
);
}
#[tokio::test]
async fn openai_api_key_attaches_org_and_project_from_env() {
let resolver = test_resolver(
empty_vault(),
Arc::new(|name| match name {
"OPENAI_API_KEY" => Some("key".to_string()),
"OPENAI_ORG_ID" => Some("org".to_string()),
"OPENAI_PROJECT_ID" => Some("proj".to_string()),
_ => None,
}),
);
let catalog = test_catalog();
let credentials = resolver
.resolve(catalog.provider("openai").unwrap())
.await
.unwrap();
assert_eq!(
header_value(&credentials, "OpenAI-Organization"),
Some("org")
);
assert_eq!(header_value(&credentials, "OpenAI-Project"), Some("proj"));
}
#[tokio::test]
async fn bedrock_falls_back_to_the_aws_default_chain() {
let resolver = test_resolver(empty_vault(), Arc::new(|_| None));
let catalog = test_catalog();
let credentials = resolver
.resolve(catalog.provider("bedrock").unwrap())
.await
.unwrap();
assert!(matches!(credentials, Credentials::AwsDefaultChain { .. }));
let resolver = test_resolver(
empty_vault(),
Arc::new(|name| (name == "BEDROCK_API_KEY").then(|| "bearer".to_string())),
);
let credentials = resolver
.resolve(catalog.provider("bedrock").unwrap())
.await
.unwrap();
assert!(matches!(credentials, Credentials::BedrockBearer(_)));
}
#[tokio::test]
async fn missing_provider_material_is_not_configured() {
let resolver = test_resolver(empty_vault(), Arc::new(|_| None));
let catalog = test_catalog();
let err = resolver
.resolve(catalog.provider("anthropic").unwrap())
.await
.unwrap_err();
assert!(matches!(
err,
ResolveError::NotConfigured(provider) if provider.as_str() == "anthropic"
));
}
#[tokio::test]
async fn modal_resolves_both_vault_proxy_headers_without_authorization() {
let mut vault = empty_vault();
vault_set_token(&mut vault, "MODAL_TOKEN_ID", "wk-test").unwrap();
vault_set_token(&mut vault, "MODAL_TOKEN_SECRET", "ws-test").unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let catalog = test_catalog();
let modal = catalog.provider("modal").unwrap();
{
let vault = resolver.vault.read().await;
assert!(resolver.has_credential_material(&vault, modal));
}
let credentials = resolver.resolve(modal).await.unwrap();
match &credentials {
Credentials::Http(http) => assert!(matches!(http.auth, HttpAuthentication::None)),
_ => panic!("expected header-only credentials"),
}
assert_eq!(header_value(&credentials, "Modal-Key"), Some("wk-test"));
assert_eq!(header_value(&credentials, "Modal-Secret"), Some("ws-test"));
}
#[tokio::test]
async fn modal_is_not_configured_with_only_one_vault_proxy_token() {
let mut vault = empty_vault();
vault_set_token(&mut vault, "MODAL_TOKEN_ID", "wk-test").unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let catalog = test_catalog();
let modal = catalog.provider("modal").unwrap();
let err = resolver.resolve(modal).await.unwrap_err();
assert!(matches!(err, ResolveError::Interpolation { .. }), "{err}");
assert!(!err.to_string().contains("wk-test"));
}
#[tokio::test]
async fn configured_providers_reads_vault_and_env_without_refreshing() {
let mut vault = empty_vault();
vault_set_token(&mut vault, "OPENAI_API_KEY", "vault-key").unwrap();
let resolver = test_resolver(
vault,
Arc::new(|name| (name == "ANTHROPIC_API_KEY").then(|| "env".to_string())),
);
let catalog = test_catalog();
let vault = resolver.vault.read().await;
let configured = resolver.configured_providers(&vault, &catalog);
assert!(configured.contains(&ProviderId::new("openai")));
assert!(configured.contains(&ProviderId::new("anthropic")));
// Bedrock always resolves through the AWS chain but ships disabled.
assert!(!configured.contains(&ProviderId::new("bedrock")));
}
#[tokio::test]
async fn refreshes_expired_oauth_credentials_and_persists_them() {
let server = MockServer::start_async().await;
let refresh_mock = server
.mock_async(|when, then| {
when.method(POST)
.path("/oauth/token")
.form_urlencoded_tuple("grant_type", "refresh_token")
.form_urlencoded_tuple("client_id", "test-client")
.form_urlencoded_tuple("refresh_token", "refresh-token");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"access_token": "new-access",
"refresh_token": "new-refresh",
"expires_in": 3600
})
.to_string(),
);
})
.await;
let mut vault = empty_vault();
vault_set_oauth(
&mut vault,
crate::OPENAI_CODEX_VAULT_SECRET_NAME,
&oauth_credential(
server.url("/oauth/token"),
Utc::now() - Duration::minutes(1),
),
)
.unwrap();
let vault = Arc::new(AsyncRwLock::new(vault));
let resolver = CredentialResolver::with_env_lookup(Arc::clone(&vault), Arc::new(|_| None));
let catalog = test_catalog();
let credentials = resolver
.resolve(catalog.provider("openai-codex").unwrap())
.await
.unwrap();
assert_eq!(bearer_secret(&credentials), "new-access");
let stored = {
let vault = vault.read().await;
vault_get_oauth(&vault, crate::OPENAI_CODEX_VAULT_SECRET_NAME)
.unwrap()
.unwrap()
};
assert_eq!(stored.tokens.access_token, "new-access");
assert_eq!(stored.tokens.refresh_token.as_deref(), Some("new-refresh"));
assert_eq!(stored.account_id.as_deref(), Some("acct_123"));
refresh_mock.assert_async().await;
}
#[tokio::test]
async fn expired_oauth_without_refresh_token_requires_reauthentication() {
let mut vault = empty_vault();
let mut credential = oauth_credential(
"https://auth.openai.com/oauth/token".to_string(),
Utc::now() - Duration::minutes(1),
);
credential.tokens.refresh_token = None;
vault_set_oauth(
&mut vault,
crate::OPENAI_CODEX_VAULT_SECRET_NAME,
&credential,
)
.unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let catalog = test_catalog();
let err = resolver
.resolve(catalog.provider("openai-codex").unwrap())
.await
.unwrap_err();
assert!(matches!(err, ResolveError::RefreshTokenMissing(_)));
assert_eq!(
auth_issue_message(&ProviderId::new("openai-codex"), &err),
"openai-codex requires re-authentication: refresh token missing"
);
}
#[test]
fn api_key_credentials_follow_the_provider_scheme() {
let catalog = test_catalog();
let vault = empty_vault();
let openai = credentials_for_api_key(
catalog.provider("openai").unwrap(),
"sk-test".to_string(),
&vault,
)
.unwrap();
assert_eq!(bearer_secret(&openai), "sk-test");
let modal = credentials_for_api_key(
catalog.provider("modal").unwrap(),
"sk-test".to_string(),
&vault,
);
assert!(modal.is_err(), "modal has no single-key scheme");
assert!(!accepts_api_key(catalog.provider("modal").unwrap()));
assert!(accepts_api_key(catalog.provider("openai").unwrap()));
assert!(!accepts_api_key(catalog.provider("ollama").unwrap()));
}
#[test]
fn resolved_secret_debug_redacts_material() {
let debug = format!("{:?}", ResolvedSecret::ApiKey("sk-test".to_string()));
assert!(!debug.contains("sk-test"));
}
}

View file

@ -0,0 +1,47 @@
//! Which secrets a provider reads.
//!
//! lithos-llm owns the convention: `openai` reads `OPENAI_API_KEY`, `gemini`
//! reads `GEMINI_API_KEY` then `GOOGLE_API_KEY`, and an operator-defined
//! provider reads a name derived from its id. Fabro stores secrets in its
//! vault under those same names, so the vault entry an operator creates and
//! the environment variable a shell exports are spelled alike.
use fabro_types::provider_ids;
use lithos_llm::catalog::{AuthScheme, CatalogProvider, ProviderId};
use lithos_llm::credentials::ConventionalCredentials;
use crate::OPENAI_CODEX_VAULT_SECRET_NAME;
/// The secret names `provider` reads, preferred name first.
#[must_use]
pub fn secret_names(provider: &CatalogProvider) -> Vec<String> {
ConventionalCredentials::new().secret_names(provider)
}
/// The secret an operator creates to configure `provider`, when the provider
/// reads one.
#[must_use]
pub fn expected_secret_name(provider: &CatalogProvider) -> Option<String> {
secret_names(provider).into_iter().next()
}
/// Whether the provider takes a single API key an operator can paste in.
///
/// Providers that read several secrets (Modal's two proxy-token headers) or
/// none at all (Ollama) do not.
#[must_use]
pub fn accepts_api_key(provider: &CatalogProvider) -> bool {
matches!(
provider.auth(),
AuthScheme::Bearer { .. }
| AuthScheme::Header { .. }
| AuthScheme::BedrockBearer
| AuthScheme::Aws { .. }
) && !secret_names(provider).is_empty()
}
/// The vault entry holding `provider`'s OAuth credential, for the providers
/// Fabro can log into with a browser flow.
pub(crate) fn oauth_secret_name(provider: &ProviderId) -> Option<&'static str> {
(provider.as_str() == provider_ids::OPENAI_CODEX).then_some(OPENAI_CODEX_VAULT_SECRET_NAME)
}

View file

@ -1,5 +1,4 @@
use async_trait::async_trait;
use fabro_types::catalog_policy;
use lithos_llm::catalog::{CatalogProvider, ProviderId};
use crate::context::{AuthContextRequest, AuthContextResponse};
@ -18,8 +17,8 @@ impl ApiKeyStrategy {
Self {
provider_id: provider.id().clone(),
display_name: provider.display_name().to_string(),
env_var_names: crate::env_var_names(provider),
api_key_url: catalog_policy::provider_policy(provider).api_key_url,
env_var_names: crate::secret_names(provider),
api_key_url: provider.api_key_url().map(str::to_string),
}
}
}

View file

@ -101,7 +101,7 @@ mod tests {
}
#[tokio::test]
async fn api_key_strategy_uses_provider_env_names() {
async fn api_key_strategy_uses_provider_secret_names() {
let catalog = test_catalog();
let provider = catalog.provider("anthropic").unwrap();
let mut strategy = ApiKeyStrategy::new(provider);

View file

@ -14,20 +14,24 @@ use tokio::sync::RwLock as AsyncRwLock;
use crate::credential_source::CredentialSource;
use crate::vault_source::VaultCredentialSource;
/// Fabro's policy layer, checked in under `fabro-llm`. Tests in this crate
/// need the built-in catalog with `metadata.fabro.credentials` attached.
pub const FABRO_POLICY_TOML: &str =
include_str!("../../../components/fabro-llm/catalog/fabro-policy.toml");
/// The lithos built-in catalog with Fabro's policy layer applied.
/// The lithos built-in catalog.
#[must_use]
pub fn test_catalog() -> Catalog {
Catalog::builder()
.with_builtin()
.toml_layer("fabro-policy.toml", FABRO_POLICY_TOML)
.expect("fabro policy layer should parse")
.build()
.expect("built-in catalog with fabro policy should build")
.expect("built-in catalog should build")
}
/// The built-in catalog with an operator overlay applied.
#[must_use]
pub fn test_catalog_with_overlay(overlay: &str) -> Catalog {
Catalog::builder()
.with_builtin()
.overlay_toml(&format!("schema_version = 1\n{overlay}"))
.expect("overlay should parse")
.build()
.expect("built-in catalog with overlay should build")
}
/// A detached in-memory vault holding no secrets.

View file

@ -1,26 +1,63 @@
//! Credentials from Fabro's vault, with the process environment as a second
//! store when the caller allows it.
//!
//! lithos-llm knows which named secrets each provider reads and how they shape
//! into the provider's authentication scheme. This source supplies the store:
//! a name is looked up in the environment first, then in the vault, under the
//! same conventional spelling (`OPENAI_API_KEY`, `MODAL_TOKEN_ID`). On top of
//! that lithos table, Fabro adds what only it knows about:
//!
//! - an OAuth credential in the vault (the Codex login), refreshed when it
//! expires and written back;
//! - `{{ secrets.NAME }}` tokens in a provider's `default_headers`, resolved
//! against the vault and re-sent as credential headers so the literal token
//! never reaches the wire;
//! - OpenAI organization and project headers from the environment.
use std::sync::Arc;
use async_trait::async_trait;
use fabro_vault::Vault;
use lithos_llm::catalog::{Catalog, CatalogProvider, ProviderId};
use lithos_llm::credentials::Credentials;
use fabro_static::EnvVars;
use fabro_types::provider_ids;
use fabro_types::settings::{InterpString, ResolveCtx};
use fabro_vault::{SecretType, Vault};
use lithos_llm::catalog::{AuthScheme, Catalog, CatalogProvider, ProviderId};
use lithos_llm::credentials::{
ConventionalCredentials, CredentialError, CredentialHeader, CredentialProvider, Credentials,
HttpAuthentication, HttpCredentials, SecretValue,
};
use tokio::sync::RwLock as AsyncRwLock;
use tokio::task::spawn_blocking;
use crate::credential::OAuthCredential;
use crate::credential_source::CredentialSource;
use crate::{CredentialResolver, EnvLookup, ResolveError};
use crate::error::ResolveError;
use crate::refresh::refresh_oauth_credential;
use crate::secrets::oauth_secret_name;
use crate::vault_ext::{VaultLookupError, vault_get_oauth, vault_set_oauth, vault_token_lookup};
pub type EnvLookup = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
const CHATGPT_ACCOUNT_ID_HEADER: &str = "ChatGPT-Account-Id";
const OPENAI_ORGANIZATION_HEADER: &str = "OpenAI-Organization";
const OPENAI_PROJECT_HEADER: &str = "OpenAI-Project";
/// Credentials backed by an in-memory [`Vault`] plus an environment lookup.
#[derive(Clone)]
pub struct VaultCredentialSource {
vault: Arc<AsyncRwLock<Vault>>,
resolver: CredentialResolver,
vault: Arc<AsyncRwLock<Vault>>,
env_lookup: EnvLookup,
}
impl VaultCredentialSource {
/// A source over `vault` that falls back to the process environment.
#[must_use]
#[expect(
clippy::disallowed_methods,
reason = "VaultCredentialSource::new owns the process-env fallback used after vault lookup."
)]
pub fn new(vault: Arc<AsyncRwLock<Vault>>) -> Self {
let resolver = CredentialResolver::new(Arc::clone(&vault));
Self { vault, resolver }
Self::with_env_lookup(vault, |name| std::env::var(name).ok())
}
#[must_use]
@ -28,19 +65,135 @@ impl VaultCredentialSource {
where
F: Fn(&str) -> Option<String> + Send + Sync + 'static,
{
let env_lookup: EnvLookup = Arc::new(env_lookup);
let resolver = CredentialResolver::with_env_lookup(Arc::clone(&vault), env_lookup);
Self { vault, resolver }
Self {
vault,
env_lookup: Arc::new(env_lookup),
}
}
/// A source that reads the vault and nothing else.
#[must_use]
pub fn vault_only(vault: Arc<AsyncRwLock<Vault>>) -> Self {
Self::with_env_lookup(vault, |_| None)
}
/// A source over an empty vault, so every secret comes from the process
/// environment. For SDK callers and tools that have no Fabro vault.
#[must_use]
pub fn environment_only() -> Self {
Self::new(Arc::new(AsyncRwLock::new(Vault::from_entries(
std::collections::HashMap::new(),
))))
}
pub(crate) async fn snapshot(&self) -> Vault {
self.vault.read().await.clone()
}
/// The lithos conventional table reading from the environment, then the
/// vault.
fn conventional(&self, vault: &Vault) -> ConventionalCredentials {
let vault = vault.clone();
let env_lookup = Arc::clone(&self.env_lookup);
ConventionalCredentials::new()
.with_lookup(move |name| env_lookup(name).or_else(|| vault_token_lookup(&vault, name)))
}
/// The vault's OAuth credential for `provider`, refreshed and persisted
/// when it has expired. `None` when the provider has no OAuth path or the
/// vault holds nothing under its name.
async fn oauth_credentials(
&self,
provider: &CatalogProvider,
vault: &Vault,
) -> Result<Option<Credentials>, ResolveError> {
let Some(name) = oauth_secret_name(provider.id()) else {
return Ok(None);
};
let Some(entry) = vault.get_entry(name) else {
return Ok(None);
};
if entry.secret_type == SecretType::Token {
// A pasted API key stored under the OAuth name still works.
return Ok(Some(Credentials::bearer(SecretValue::new(
entry.value.clone(),
))));
}
let credential = vault_get_oauth(vault, name)
.map_err(|err| vault_lookup_error(provider.id(), name, err))?
.expect("entry is present");
let credential = if credential.needs_refresh() {
if credential.tokens.refresh_token.is_none() {
return Err(ResolveError::RefreshTokenMissing(provider.id().clone()));
}
let refreshed = refresh_oauth_credential(&credential)
.await
.map_err(|source| ResolveError::RefreshFailed {
provider: provider.id().clone(),
source,
})?;
self.persist_oauth(provider.id(), name, &refreshed).await?;
refreshed
} else {
credential
};
Ok(Some(oauth_bearer(&credential)))
}
async fn persist_oauth(
&self,
provider: &ProviderId,
name: &str,
refreshed: &OAuthCredential,
) -> Result<(), ResolveError> {
let refreshed = refreshed.clone();
let name = name.to_string();
let vault = Arc::clone(&self.vault);
let failed = |source| ResolveError::RefreshFailed {
provider: provider.clone(),
source,
};
spawn_blocking(move || {
let mut vault = vault.blocking_write();
vault_set_oauth(&mut vault, &name, &refreshed)
.map(|_| ())
.map_err(anyhow::Error::from)
})
.await
.map_err(|join_err| failed(anyhow::Error::from(join_err)))?
.map_err(failed)
}
/// Headers Fabro adds on top of what lithos shaped: interpolated
/// `default_headers` and, for OpenAI, the organization and project ids
/// from the environment.
fn decorate(
&self,
provider: &CatalogProvider,
mut credentials: Credentials,
interpolated: Vec<CredentialHeader>,
) -> Credentials {
if let Credentials::Http(http) = &mut credentials {
http.extra_headers.extend(interpolated);
if provider.id().as_str() == provider_ids::OPENAI {
for (variable, header) in [
(EnvVars::OPENAI_ORG_ID, OPENAI_ORGANIZATION_HEADER),
(EnvVars::OPENAI_PROJECT_ID, OPENAI_PROJECT_HEADER),
] {
if let Some(value) = (self.env_lookup)(variable) {
http.extra_headers
.push(CredentialHeader::new(header, SecretValue::new(value)));
}
}
}
}
credentials
}
async fn has_credential_material(&self, vault: &Vault, provider: &CatalogProvider) -> bool {
oauth_secret_name(provider.id()).is_some_and(|name| vault.get_entry(name).is_some())
|| self.conventional(vault).credentials(provider).await.is_ok()
}
}
impl std::fmt::Debug for VaultCredentialSource {
@ -53,67 +206,433 @@ impl std::fmt::Debug for VaultCredentialSource {
#[async_trait]
impl CredentialSource for VaultCredentialSource {
async fn credentials(&self, provider: &CatalogProvider) -> Result<Credentials, ResolveError> {
self.resolver.resolve(provider).await
let vault = self.snapshot().await;
let interpolated = interpolated_headers(&vault, provider)?;
if let Some(oauth) = self.oauth_credentials(provider, &vault).await? {
return Ok(self.decorate(provider, oauth, interpolated));
}
let credentials = self
.conventional(&vault)
.credentials(provider)
.await
.map_err(|err| resolve_error(provider, &err))?;
Ok(self.decorate(provider, credentials, interpolated))
}
async fn configured_providers(&self, catalog: &Catalog) -> Vec<ProviderId> {
let vault = self.vault.read().await;
self.resolver.configured_providers(&vault, catalog)
let vault = self.snapshot().await;
let mut configured = Vec::new();
for provider in catalog.providers().filter(|provider| provider.is_enabled()) {
if self.has_credential_material(&vault, provider).await {
configured.push(provider.id().clone());
}
}
configured
}
}
/// Shapes a Codex OAuth credential into the bearer the deployment expects.
fn oauth_bearer(credential: &OAuthCredential) -> Credentials {
let mut http = HttpCredentials::new(HttpAuthentication::Bearer(SecretValue::new(
credential.tokens.access_token.clone(),
)));
if let Some(account_id) = &credential.account_id {
http.extra_headers.push(CredentialHeader::new(
CHATGPT_ACCOUNT_ID_HEADER,
SecretValue::new(account_id.clone()),
));
}
Credentials::Http(http)
}
/// The provider's `default_headers` whose values reference `{{ secrets.* }}`,
/// resolved against the vault.
///
/// lithos sends `default_headers` verbatim and lets credential headers of the
/// same name win, so only the interpolated ones are re-sent here. Resolved
/// values may contain secrets; keep this path free of value logging.
pub(crate) fn interpolated_headers(
vault: &Vault,
provider: &CatalogProvider,
) -> Result<Vec<CredentialHeader>, ResolveError> {
let mut ctx =
ResolveCtx::new().with_secrets(|secret_name| vault_token_lookup(vault, secret_name));
provider
.default_headers()
.iter()
.map(|(name, source)| (name, InterpString::parse(source)))
.filter(|(_, template)| !template.is_literal())
.map(|(name, template)| {
let value =
template
.resolve_with(&mut ctx)
.map_err(|source| ResolveError::Interpolation {
provider: provider.id().clone(),
source,
})?;
Ok(CredentialHeader::new(name.clone(), SecretValue::new(value)))
})
.collect()
}
pub(crate) fn auth_scheme_name(scheme: &AuthScheme) -> &'static str {
match scheme {
AuthScheme::None => "none",
AuthScheme::Bearer { .. } => "bearer",
AuthScheme::Header { .. } => "header",
AuthScheme::Headers => "headers",
AuthScheme::Aws { .. } => "aws",
AuthScheme::BedrockBearer => "bedrock_bearer",
_ => "unknown",
}
}
/// Maps a lithos lookup failure onto Fabro's vocabulary. A missing secret is
/// not an issue to report; the provider is simply not configured.
pub(crate) fn resolve_error(provider: &CatalogProvider, err: &CredentialError) -> ResolveError {
match err {
CredentialError::SchemeMismatch { .. } => ResolveError::SchemeMismatch {
provider: provider.id().clone(),
scheme: auth_scheme_name(provider.auth()).to_string(),
},
_ => ResolveError::NotConfigured(provider.id().clone()),
}
}
fn vault_lookup_error(provider: &ProviderId, name: &str, err: VaultLookupError) -> ResolveError {
match err {
VaultLookupError::SchemaMismatch { actual, .. } => ResolveError::VaultSchemaMismatch {
provider: provider.clone(),
name: name.to_string(),
actual,
},
VaultLookupError::DecodeFailed { source, .. } => ResolveError::VaultDecodeFailed {
provider: provider.clone(),
name: name.to_string(),
source,
},
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use chrono::{Duration, Utc};
use fabro_vault::Vault;
use lithos_llm::catalog::ProviderId;
use tokio::sync::RwLock as AsyncRwLock;
use httpmock::Method::POST;
use httpmock::MockServer;
use lithos_llm::catalog::Catalog;
use super::VaultCredentialSource;
use crate::credential::{OAuthConfig, OAuthCredential, OAuthTokens};
use crate::test_support::test_catalog;
use crate::vault_ext::{vault_set_oauth, vault_set_token};
use crate::{CredentialSource, ResolveError};
use super::*;
use crate::credential::{OAuthConfig, OAuthTokens};
use crate::test_support::{test_catalog, test_catalog_with_overlay};
use crate::vault_ext::vault_set_token;
use crate::{OPENAI_CODEX_VAULT_SECRET_NAME, auth_issue_message};
fn expired_openai_credential() -> OAuthCredential {
fn oauth_credential(token_url: String, expires_at: chrono::DateTime<Utc>) -> OAuthCredential {
OAuthCredential {
tokens: OAuthTokens {
access_token: "expired-access".to_string(),
access_token: "expired-access".to_string(),
refresh_token: Some("refresh-token".to_string()),
expires_at: Utc::now() - Duration::hours(1),
expires_at,
},
config: OAuthConfig {
auth_url: "https://auth.openai.com".to_string(),
token_url: "http://127.0.0.1:9/oauth/token".to_string(),
client_id: "client".to_string(),
scopes: vec!["openid".to_string()],
redirect_uri: Some("https://example.com/callback".to_string()),
use_pkce: true,
auth_url: "https://auth.openai.com".to_string(),
token_url,
client_id: "test-client".to_string(),
scopes: vec!["openid".to_string()],
redirect_uri: Some("https://auth.openai.com/deviceauth/callback".to_string()),
use_pkce: true,
},
account_id: Some("acct_123".to_string()),
}
}
fn empty_vault() -> Vault {
Vault::from_entries(HashMap::new())
}
fn source_with(
vault: Vault,
env: impl Fn(&str) -> Option<String> + Send + Sync + 'static,
) -> VaultCredentialSource {
VaultCredentialSource::with_env_lookup(Arc::new(AsyncRwLock::new(vault)), env)
}
fn bearer_secret(credentials: &Credentials) -> &str {
match credentials {
Credentials::Http(HttpCredentials {
auth: HttpAuthentication::Bearer(secret),
..
}) => secret.expose_secret(),
_ => panic!("expected bearer credentials"),
}
}
/// A header the credentials carry, whether as the primary `auth` header
/// or as an extra header.
fn header_value<'a>(credentials: &'a Credentials, name: &str) -> Option<&'a str> {
let Credentials::Http(http) = credentials else {
return None;
};
let primary = match &http.auth {
HttpAuthentication::Header(header) => Some(header),
_ => None,
};
primary
.into_iter()
.chain(http.extra_headers.iter())
.find(|header| header.name.eq_ignore_ascii_case(name))
.map(|header| header.value.expose_secret())
}
/// An operator-defined gateway whose header secret lives in the vault.
fn gateway_catalog() -> Catalog {
test_catalog_with_overlay(
r#"
[providers.gateway]
display_name = "Gateway"
adapter = "openai-compatible"
codec = "openai-chat"
base_url = "https://gateway.test/v1"
auth = { type = "bearer" }
default_headers = { "x-portkey-api-key" = "{{ secrets.PORTKEY_API_KEY }}", "x-portkey-config" = "@prod" }
[providers.gateway.models.large]
display_name = "Large"
api_model = "large"
"#,
)
}
#[tokio::test]
async fn resolve_all_separates_ready_providers_from_auth_issues() {
let mut vault = Vault::from_entries(HashMap::new());
async fn environment_wins_over_the_vault() {
let mut vault = empty_vault();
vault_set_token(&mut vault, "OPENAI_API_KEY", "vault-key").unwrap();
let source = source_with(vault, |name| {
(name == "OPENAI_API_KEY").then(|| "env-key".to_string())
});
let catalog = test_catalog();
let credentials = source
.credentials(catalog.provider("openai").unwrap())
.await
.unwrap();
assert_eq!(bearer_secret(&credentials), "env-key");
}
#[tokio::test]
async fn conventional_fallback_names_apply_to_the_vault() {
let mut vault = empty_vault();
vault_set_token(&mut vault, EnvVars::KIMI_API_KEY, "kimi-key").unwrap();
let source = source_with(vault, |_| None);
let catalog = test_catalog();
let credentials = source
.credentials(catalog.provider("moonshot").unwrap())
.await
.unwrap();
assert_eq!(bearer_secret(&credentials), "kimi-key");
}
#[tokio::test]
async fn anthropic_uses_its_header_scheme() {
let mut vault = empty_vault();
vault_set_token(&mut vault, "ANTHROPIC_API_KEY", "anthropic-key").unwrap();
let source = source_with(vault, |_| None);
let catalog = test_catalog();
let credentials = source
.credentials(catalog.provider("anthropic").unwrap())
.await
.unwrap();
match credentials {
Credentials::Http(HttpCredentials {
auth: HttpAuthentication::Header(header),
..
}) => {
assert_eq!(header.name, "x-api-key");
assert_eq!(header.value.expose_secret(), "anthropic-key");
}
_ => panic!("expected header credentials"),
}
}
#[tokio::test]
async fn an_unlisted_provider_reads_its_derived_secret_name() {
let mut vault = empty_vault();
vault_set_token(&mut vault, "GATEWAY_API_KEY", "gw-key").unwrap();
vault_set_token(&mut vault, "PORTKEY_API_KEY", "pk-key").unwrap();
let source = source_with(vault, |_| None);
let catalog = gateway_catalog();
let credentials = source
.credentials(catalog.provider("gateway").unwrap())
.await
.unwrap();
assert_eq!(bearer_secret(&credentials), "gw-key");
assert_eq!(
header_value(&credentials, "x-portkey-api-key"),
Some("pk-key")
);
assert_eq!(
header_value(&credentials, "x-portkey-config"),
None,
"literal default headers are lithos's to send"
);
}
#[tokio::test]
async fn a_missing_header_secret_is_an_interpolation_issue() {
let mut vault = empty_vault();
vault_set_token(&mut vault, "GATEWAY_API_KEY", "gw-key").unwrap();
let source = source_with(vault, |_| None);
let catalog = gateway_catalog();
let err = source
.credentials(catalog.provider("gateway").unwrap())
.await
.unwrap_err();
assert!(matches!(err, ResolveError::Interpolation { .. }), "{err}");
assert!(!err.to_string().contains("gw-key"));
}
#[tokio::test]
async fn codex_oauth_becomes_a_bearer_with_account_header() {
let mut vault = empty_vault();
vault_set_oauth(
&mut vault,
crate::OPENAI_CODEX_VAULT_SECRET_NAME,
&expired_openai_credential(),
OPENAI_CODEX_VAULT_SECRET_NAME,
&oauth_credential(
"https://auth.openai.com/oauth/token".to_string(),
Utc::now() + Duration::hours(1),
),
)
.unwrap();
let source = source_with(vault, |_| None);
let catalog = test_catalog();
let credentials = source
.credentials(catalog.provider("openai-codex").unwrap())
.await
.unwrap();
assert_eq!(bearer_secret(&credentials), "expired-access");
assert_eq!(
header_value(&credentials, CHATGPT_ACCOUNT_ID_HEADER),
Some("acct_123")
);
}
#[tokio::test]
async fn openai_api_key_attaches_org_and_project_from_env() {
let source = source_with(empty_vault(), |name| match name {
"OPENAI_API_KEY" => Some("key".to_string()),
"OPENAI_ORG_ID" => Some("org".to_string()),
"OPENAI_PROJECT_ID" => Some("proj".to_string()),
_ => None,
});
let catalog = test_catalog();
let credentials = source
.credentials(catalog.provider("openai").unwrap())
.await
.unwrap();
assert_eq!(
header_value(&credentials, "OpenAI-Organization"),
Some("org")
);
assert_eq!(header_value(&credentials, "OpenAI-Project"), Some("proj"));
}
#[tokio::test]
async fn bedrock_takes_a_bearer_and_falls_back_to_the_aws_default_chain() {
let catalog = test_catalog();
let source = source_with(empty_vault(), |_| None);
let credentials = source
.credentials(catalog.provider("bedrock").unwrap())
.await
.unwrap();
assert!(matches!(credentials, Credentials::AwsDefaultChain { .. }));
let source = source_with(empty_vault(), |name| {
(name == "BEDROCK_API_KEY").then(|| "bearer".to_string())
});
let credentials = source
.credentials(catalog.provider("bedrock").unwrap())
.await
.unwrap();
assert!(matches!(credentials, Credentials::BedrockBearer(_)));
}
#[tokio::test]
async fn missing_provider_material_is_not_configured() {
let source = source_with(empty_vault(), |_| None);
let catalog = test_catalog();
let err = source
.credentials(catalog.provider("anthropic").unwrap())
.await
.unwrap_err();
assert!(matches!(
err,
ResolveError::NotConfigured(provider) if provider.as_str() == "anthropic"
));
}
#[tokio::test]
async fn modal_needs_both_proxy_tokens() {
let mut vault = empty_vault();
vault_set_token(&mut vault, "MODAL_TOKEN_ID", "wk-test").unwrap();
let source = source_with(vault, |_| None);
let catalog = test_catalog();
let modal = catalog.provider("modal").unwrap();
assert!(matches!(
source.credentials(modal).await.unwrap_err(),
ResolveError::NotConfigured(_)
));
let mut vault = empty_vault();
vault_set_token(&mut vault, "MODAL_TOKEN_ID", "wk-test").unwrap();
vault_set_token(&mut vault, "MODAL_TOKEN_SECRET", "ws-test").unwrap();
let source = VaultCredentialSource::vault_only(Arc::new(AsyncRwLock::new(vault)));
let credentials = source.credentials(modal).await.unwrap();
assert_eq!(header_value(&credentials, "Modal-Key"), Some("wk-test"));
assert_eq!(header_value(&credentials, "Modal-Secret"), Some("ws-test"));
}
#[tokio::test]
async fn configured_providers_reads_vault_and_env_without_refreshing() {
let mut vault = empty_vault();
vault_set_token(&mut vault, "OPENAI_API_KEY", "vault-key").unwrap();
vault_set_oauth(
&mut vault,
OPENAI_CODEX_VAULT_SECRET_NAME,
&oauth_credential(
"http://127.0.0.1:9/oauth/token".to_string(),
Utc::now() - Duration::hours(1),
),
)
.unwrap();
let source = source_with(vault, |name| {
(name == "ANTHROPIC_API_KEY").then(|| "env".to_string())
});
let catalog = test_catalog();
let configured = source.configured_providers(&catalog).await;
assert!(configured.contains(&ProviderId::new("openai")));
assert!(configured.contains(&ProviderId::new("anthropic")));
assert!(configured.contains(&ProviderId::new("openai-codex")));
// Bedrock always resolves through the AWS chain but ships disabled.
assert!(!configured.contains(&ProviderId::new("bedrock")));
assert!(!configured.contains(&ProviderId::new("gemini")));
}
#[tokio::test]
async fn resolve_all_separates_ready_providers_from_auth_issues() {
let mut vault = empty_vault();
vault_set_oauth(
&mut vault,
OPENAI_CODEX_VAULT_SECRET_NAME,
&oauth_credential(
"http://127.0.0.1:9/oauth/token".to_string(),
Utc::now() - Duration::hours(1),
),
)
.unwrap();
vault_set_token(&mut vault, "ANTHROPIC_API_KEY", "anthropic-key").unwrap();
let source =
VaultCredentialSource::with_env_lookup(Arc::new(AsyncRwLock::new(vault)), |_| None);
let catalog = test_catalog();
let resolved = source.resolve_all(&catalog).await;
let source = source_with(vault, |_| None);
let resolved = source.resolve_all(&test_catalog()).await;
assert_eq!(resolved.ready, vec![ProviderId::new("anthropic")]);
assert_eq!(resolved.auth_issues.len(), 1);
assert!(matches!(
@ -123,37 +642,90 @@ mod tests {
}
#[tokio::test]
async fn configured_providers_reads_from_vault_without_refreshing() {
let mut vault = Vault::from_entries(HashMap::new());
vault_set_token(&mut vault, "OPENAI_API_KEY", "openai-key").unwrap();
vault_set_token(&mut vault, "ANTHROPIC_API_KEY", "anthropic-key").unwrap();
let source =
VaultCredentialSource::with_env_lookup(Arc::new(AsyncRwLock::new(vault)), |_| None);
async fn vault_only_ignores_the_environment() {
let catalog = test_catalog();
assert_eq!(source.configured_providers(&catalog).await, vec![
ProviderId::new("anthropic"),
ProviderId::new("openai")
]);
}
#[tokio::test]
async fn vault_only_ignores_env_lookup_values() {
let catalog = test_catalog();
let env_backed = VaultCredentialSource::with_env_lookup(
Arc::new(AsyncRwLock::new(Vault::from_entries(HashMap::new()))),
|name| (name == "OPENAI_API_KEY").then(|| "env-openai-key".to_string()),
);
assert_eq!(env_backed.configured_providers(&catalog).await, vec![
ProviderId::new("openai")
]);
let vault_only = VaultCredentialSource::vault_only(Arc::new(AsyncRwLock::new(
Vault::from_entries(HashMap::new()),
)));
let vault_only =
VaultCredentialSource::vault_only(Arc::new(AsyncRwLock::new(empty_vault())));
assert!(vault_only.configured_providers(&catalog).await.is_empty());
let resolved = vault_only.resolve_all(&catalog).await;
assert!(resolved.ready.is_empty());
assert!(resolved.auth_issues.is_empty());
}
#[tokio::test]
async fn refreshes_expired_oauth_credentials_and_persists_them() {
let server = MockServer::start_async().await;
let refresh_mock = server
.mock_async(|when, then| {
when.method(POST)
.path("/oauth/token")
.form_urlencoded_tuple("grant_type", "refresh_token")
.form_urlencoded_tuple("client_id", "test-client")
.form_urlencoded_tuple("refresh_token", "refresh-token");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"access_token": "new-access",
"refresh_token": "new-refresh",
"expires_in": 3600
})
.to_string(),
);
})
.await;
let mut vault = empty_vault();
vault_set_oauth(
&mut vault,
OPENAI_CODEX_VAULT_SECRET_NAME,
&oauth_credential(
server.url("/oauth/token"),
Utc::now() - Duration::minutes(1),
),
)
.unwrap();
let vault = Arc::new(AsyncRwLock::new(vault));
let source = VaultCredentialSource::vault_only(Arc::clone(&vault));
let catalog = test_catalog();
let credentials = source
.credentials(catalog.provider("openai-codex").unwrap())
.await
.unwrap();
assert_eq!(bearer_secret(&credentials), "new-access");
let stored = {
let vault = vault.read().await;
vault_get_oauth(&vault, OPENAI_CODEX_VAULT_SECRET_NAME)
.unwrap()
.unwrap()
};
assert_eq!(stored.tokens.access_token, "new-access");
assert_eq!(stored.tokens.refresh_token.as_deref(), Some("new-refresh"));
assert_eq!(stored.account_id.as_deref(), Some("acct_123"));
refresh_mock.assert_async().await;
}
#[tokio::test]
async fn expired_oauth_without_refresh_token_requires_reauthentication() {
let mut vault = empty_vault();
let mut credential = oauth_credential(
"https://auth.openai.com/oauth/token".to_string(),
Utc::now() - Duration::minutes(1),
);
credential.tokens.refresh_token = None;
vault_set_oauth(&mut vault, OPENAI_CODEX_VAULT_SECRET_NAME, &credential).unwrap();
let source = source_with(vault, |_| None);
let catalog = test_catalog();
let err = source
.credentials(catalog.provider("openai-codex").unwrap())
.await
.unwrap_err();
assert!(matches!(err, ResolveError::RefreshTokenMissing(_)));
assert_eq!(
auth_issue_message(&ProviderId::new("openai-codex"), &err),
"openai-codex requires re-authentication: refresh token missing"
);
}
}

View file

@ -1,14 +1,16 @@
//! Well-known provider identifiers.
//!
//! Provider identity is open-ended catalog data, so [`ProviderId`] is a plain
//! string newtype. The three first-party providers are named here because
//! code paths such as Codex login and the install flow refer to them
//! directly.
//! string newtype. The first-party providers are named here because code
//! paths such as Codex login and the install flow refer to them directly.
use lithos_llm::catalog::ProviderId;
pub const ANTHROPIC: &str = "anthropic";
pub const OPENAI: &str = "openai";
/// The ChatGPT-subscription deployment that stands in for [`OPENAI`] when a
/// Codex OAuth credential is present.
pub const OPENAI_CODEX: &str = "openai-codex";
pub const GEMINI: &str = "gemini";
#[must_use]
@ -21,6 +23,11 @@ pub fn openai() -> ProviderId {
ProviderId::new(OPENAI)
}
#[must_use]
pub fn openai_codex() -> ProviderId {
ProviderId::new(OPENAI_CODEX)
}
#[must_use]
pub fn gemini() -> ProviderId {
ProviderId::new(GEMINI)