From 6dfc96d3fdd530b6baee4b757ae91e0f9a5c05bb Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 9 Sep 2026 23:00:08 -0600 Subject: [PATCH] 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 `_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 --- docs/internal/llm-client-resolution.md | 3 +- docs/internal/server-secrets-strategy.md | 16 +- docs/public/integrations/modal.mdx | 2 +- docs/public/reference/sdk.mdx | 12 +- lib/apps/fabro-cli/src/commands/install.rs | 4 +- lib/apps/fabro-server/src/install.rs | 2 +- .../fabro-agent/tests/it/parity_matrix.rs | 4 +- lib/components/fabro-llm/src/api.rs | 2 +- .../fabro-workflow/tests/it/integration.rs | 5 +- lib/foundation/fabro-auth/Cargo.toml | 2 +- .../fabro-auth/src/api_key_source.rs | 96 +- .../fabro-auth/src/credential_ref.rs | 124 --- .../fabro-auth/src/credential_source.rs | 6 +- lib/foundation/fabro-auth/src/env_source.rs | 106 --- lib/foundation/fabro-auth/src/error.rs | 83 ++ lib/foundation/fabro-auth/src/lib.rs | 17 +- lib/foundation/fabro-auth/src/resolve.rs | 871 ------------------ lib/foundation/fabro-auth/src/secrets.rs | 47 + .../fabro-auth/src/strategies/api_key.rs | 5 +- lib/foundation/fabro-auth/src/strategy.rs | 2 +- lib/foundation/fabro-auth/src/test_support.rs | 22 +- lib/foundation/fabro-auth/src/vault_source.rs | 712 ++++++++++++-- .../fabro-types/src/provider_ids.rs | 13 +- 23 files changed, 919 insertions(+), 1237 deletions(-) delete mode 100644 lib/foundation/fabro-auth/src/credential_ref.rs delete mode 100644 lib/foundation/fabro-auth/src/env_source.rs create mode 100644 lib/foundation/fabro-auth/src/error.rs delete mode 100644 lib/foundation/fabro-auth/src/resolve.rs create mode 100644 lib/foundation/fabro-auth/src/secrets.rs diff --git a/docs/internal/llm-client-resolution.md b/docs/internal/llm-client-resolution.md index 575ca69bb..3187b22de 100644 --- a/docs/internal/llm-client-resolution.md +++ b/docs/internal/llm-client-resolution.md @@ -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` locally, then pass it explicitly. - `GenerateParams::new(model, client)` always receives an explicit `Arc`. - 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 diff --git a/docs/internal/server-secrets-strategy.md b/docs/internal/server-secrets-strategy.md index a6546f751..3f492ea46 100644 --- a/docs/internal/server-secrets-strategy.md +++ b/docs/internal/server-secrets-strategy.md @@ -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 }}" ``` diff --git a/docs/public/integrations/modal.mdx b/docs/public/integrations/modal.mdx index 8d66df21e..d9f97eb44 100644 --- a/docs/public/integrations/modal.mdx +++ b/docs/public/integrations/modal.mdx @@ -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`. diff --git a/docs/public/reference/sdk.mdx b/docs/public/reference/sdk.mdx index 40536e052..37b7bfecc 100644 --- a/docs/public/reference/sdk.mdx +++ b/docs/public/reference/sdk.mdx @@ -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> { 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> { 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 `_API_KEY` for an operator-defined provider); Fabro's vault is keyed by those same names. #### Requests and responses diff --git a/lib/apps/fabro-cli/src/commands/install.rs b/lib/apps/fabro-cli/src/commands/install.rs index 1e459711e..37449bb7a 100644 --- a/lib/apps/fabro-cli/src/commands/install.rs +++ b/lib/apps/fabro-cli/src/commands/install.rs @@ -91,14 +91,14 @@ fn install_llm_provider_ids(catalog: &Catalog) -> Vec { 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())) } diff --git a/lib/apps/fabro-server/src/install.rs b/lib/apps/fabro-server/src/install.rs index cb44626ed..9e812929c 100644 --- a/lib/apps/fabro-server/src/install.rs +++ b/lib/apps/fabro-server/src/install.rs @@ -860,7 +860,7 @@ fn install_catalog_provider(provider: &ProviderId) -> Result<&'static CatalogPro fn provider_secret_name(provider: &ProviderId) -> Result { 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")) } diff --git a/lib/components/fabro-agent/tests/it/parity_matrix.rs b/lib/components/fabro-agent/tests/it/parity_matrix.rs index 249577234..4c8c7d416 100644 --- a/lib/components/fabro-agent/tests/it/parity_matrix.rs +++ b/lib/components/fabro-agent/tests/it/parity_matrix.rs @@ -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") diff --git a/lib/components/fabro-llm/src/api.rs b/lib/components/fabro-llm/src/api.rs index 023133b3c..2b45982eb 100644 --- a/lib/components/fabro-llm/src/api.rs +++ b/lib/components/fabro-llm/src/api.rs @@ -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), } } diff --git a/lib/components/fabro-workflow/tests/it/integration.rs b/lib/components/fabro-workflow/tests/it/integration.rs index 94571848b..c3ab08e63 100644 --- a/lib/components/fabro-workflow/tests/it/integration.rs +++ b/lib/components/fabro-workflow/tests/it/integration.rs @@ -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 = Arc::new(EnvCredentialSource::new()); + let source: Arc = + Arc::new(VaultCredentialSource::environment_only()); Some(Arc::new( fabro_llm::build_client( Catalog::clone(&super::default_catalog()), diff --git a/lib/foundation/fabro-auth/Cargo.toml b/lib/foundation/fabro-auth/Cargo.toml index ed3f5f2b0..81ffce750 100644 --- a/lib/foundation/fabro-auth/Cargo.toml +++ b/lib/foundation/fabro-auth/Cargo.toml @@ -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 diff --git a/lib/foundation/fabro-auth/src/api_key_source.rs b/lib/foundation/fabro-auth/src/api_key_source.rs index 8cebcd4e3..17b706498 100644 --- a/lib/foundation/fabro-auth/src/api_key_source.rs +++ b/lib/foundation/fabro-auth/src/api_key_source.rs @@ -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>) -> 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 { + 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 { 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 { @@ -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())); + } +} diff --git a/lib/foundation/fabro-auth/src/credential_ref.rs b/lib/foundation/fabro-auth/src/credential_ref.rs deleted file mode 100644 index 821637f99..000000000 --- a/lib/foundation/fabro-auth/src/credential_ref.rs +++ /dev/null @@ -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 { - 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(&self, serializer: S) -> Result { - serializer.serialize_str(&self.to_string()) - } -} - -impl<'de> Deserialize<'de> for CredentialRef { - fn deserialize>(deserializer: D) -> Result { - 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:`, `env:`, 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::().unwrap(), - CredentialRef::Vault("OPENAI_CODEX".into()) - ); - assert_eq!( - "env:KIMI_API_KEY".parse::().unwrap(), - CredentialRef::Env("KIMI_API_KEY".into()) - ); - assert_eq!( - "aws_sigv4".parse::().unwrap(), - CredentialRef::AwsSigv4 - ); - } - - #[test] - fn rejects_literal_secrets_without_echoing_them() { - let err = "sk-ant-1234".parse::().unwrap_err(); - assert_eq!(err, CredentialRefParseError::Invalid); - assert!(!err.to_string().contains("sk-ant")); - assert_eq!( - "vault:".parse::().unwrap_err(), - CredentialRefParseError::EmptyVault - ); - assert_eq!( - "env:".parse::().unwrap_err(), - CredentialRefParseError::EmptyEnv - ); - } - - #[test] - fn round_trips_through_serde_strings() { - let value: Vec = - 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::>(r#"["sk-literal"]"#).is_err()); - } -} diff --git a/lib/foundation/fabro-auth/src/credential_source.rs b/lib/foundation/fabro-auth/src/credential_source.rs index 6529bcef6..69b677385 100644 --- a/lib/foundation/fabro-auth/src/credential_source.rs +++ b/lib/foundation/fabro-auth/src/credential_source.rs @@ -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(_)) => {} diff --git a/lib/foundation/fabro-auth/src/env_source.rs b/lib/foundation/fabro-auth/src/env_source.rs deleted file mode 100644 index b4b16762b..000000000 --- a/lib/foundation/fabro-auth/src/env_source.rs +++ /dev/null @@ -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:`. -/// -/// 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 { - self.inner.credentials(provider).await - } - - async fn configured_providers(&self, catalog: &Catalog) -> Vec { - 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 = 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 { .. })); - } -} diff --git a/lib/foundation/fabro-auth/src/error.rs b/lib/foundation/fabro-auth/src/error.rs new file mode 100644 index 000000000..7986637da --- /dev/null +++ b/lib/foundation/fabro-auth/src/error.rs @@ -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") + } + } +} diff --git a/lib/foundation/fabro-auth/src/lib.rs b/lib/foundation/fabro-auth/src/lib.rs index a4e656ea2..34cd2590e 100644 --- a/lib/foundation/fabro-auth/src/lib.rs +++ b/lib/foundation/fabro-auth/src/lib.rs @@ -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"; diff --git a/lib/foundation/fabro-auth/src/resolve.rs b/lib/foundation/fabro-auth/src/resolve.rs deleted file mode 100644 index a089af5c7..000000000 --- a/lib/foundation/fabro-auth/src/resolve.rs +++ /dev/null @@ -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 Option + 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, - 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()"), - 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, ResolveError> { - credential_refs_from_policy(provider.id(), &catalog_policy::provider_policy(provider)) -} - -fn credential_refs_from_policy( - provider: &ProviderId, - policy: &ProviderPolicy, -) -> Result, 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 { - 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 { - 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 { - 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, 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, - ctx: &mut ResolveCtx<'_>, -) -> Result, 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, - env_lookup: Option<&EnvLookup>, -) -> Result { - 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) -> Credentials { - let mut credentials = HttpCredentials::new(auth); - credentials.extra_headers = extra_headers; - Credentials::Http(credentials) -} - -#[derive(Clone)] -pub struct CredentialResolver { - vault: Arc>, - 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>) -> Self { - Self::with_env_lookup(vault, Arc::new(|name| std::env::var(name).ok())) - } - - #[must_use] - pub fn with_env_lookup(vault: Arc>, 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 { - 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 { - 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 { - 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, 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) -> 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")); - } -} diff --git a/lib/foundation/fabro-auth/src/secrets.rs b/lib/foundation/fabro-auth/src/secrets.rs new file mode 100644 index 000000000..3661f660f --- /dev/null +++ b/lib/foundation/fabro-auth/src/secrets.rs @@ -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 { + 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 { + 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) +} diff --git a/lib/foundation/fabro-auth/src/strategies/api_key.rs b/lib/foundation/fabro-auth/src/strategies/api_key.rs index 225f1db34..5029943f7 100644 --- a/lib/foundation/fabro-auth/src/strategies/api_key.rs +++ b/lib/foundation/fabro-auth/src/strategies/api_key.rs @@ -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), } } } diff --git a/lib/foundation/fabro-auth/src/strategy.rs b/lib/foundation/fabro-auth/src/strategy.rs index 60945227c..4c91b2486 100644 --- a/lib/foundation/fabro-auth/src/strategy.rs +++ b/lib/foundation/fabro-auth/src/strategy.rs @@ -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); diff --git a/lib/foundation/fabro-auth/src/test_support.rs b/lib/foundation/fabro-auth/src/test_support.rs index b1cc1271c..0b12e04d7 100644 --- a/lib/foundation/fabro-auth/src/test_support.rs +++ b/lib/foundation/fabro-auth/src/test_support.rs @@ -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. diff --git a/lib/foundation/fabro-auth/src/vault_source.rs b/lib/foundation/fabro-auth/src/vault_source.rs index cbf25a66b..f99654d91 100644 --- a/lib/foundation/fabro-auth/src/vault_source.rs +++ b/lib/foundation/fabro-auth/src/vault_source.rs @@ -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 Option + 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>, - resolver: CredentialResolver, + vault: Arc>, + 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>) -> 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 + 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>) -> 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, 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, + ) -> 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 { - 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 { - 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, 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) -> 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 + 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" + ); + } } diff --git a/lib/foundation/fabro-types/src/provider_ids.rs b/lib/foundation/fabro-types/src/provider_ids.rs index f58d0d7d6..e3ab92142 100644 --- a/lib/foundation/fabro-types/src/provider_ids.rs +++ b/lib/foundation/fabro-types/src/provider_ids.rs @@ -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)