feat(llm): register providers from resolved catalog

Resolve LLM credentials and adapter registration through the runtime catalog so settings-defined providers can be used for requests. This keeps built-in behavior default-equivalent while supporting provider IDs, aliases, extra headers, header-only auth, base URLs, and provider API model IDs at the adapter boundary.
This commit is contained in:
Bryan Helmkamp 2026-05-12 19:58:14 -04:00
parent 264cac3c64
commit cfb4ea91a3
No known key found for this signature in database
47 changed files with 1614 additions and 273 deletions

2
Cargo.lock generated
View file

@ -1682,6 +1682,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.18",
"tokio",
"toml 0.8.23",
]
[[package]]
@ -2055,6 +2056,7 @@ dependencies = [
"tokio",
"tokio-stream",
"tokio-util",
"toml 0.8.23",
"tracing",
"uuid",
]

View file

@ -5,10 +5,12 @@ This document defines how Fabro resolves LLM credentials and constructs `fabro-l
## Core Rules
- `fabro_auth::CredentialSource` is the credential authority.
- Long-lived runtime contexts store `Arc<dyn CredentialSource>`, not `Client`.
- Call `fabro_llm::client::Client::from_source(&source).await?` at the point of use.
- Long-lived runtime contexts store `Arc<dyn CredentialSource>` and `Arc<Catalog>`, not `Client`.
- Call `fabro_llm::client::Client::from_source_with_catalog(&source, catalog).await?` at the point of use when runtime catalog settings are available.
- `Client::from_source(&source).await?` is the built-in-catalog fallback for setup, tests, and standalone contexts that do not have resolved runtime catalog settings.
- `GenerateParams::new(model, client)` always receives an explicit `Arc<Client>`.
- When a caller needs diagnostics, call `source.resolve()` directly and consume both `credentials` and `auth_issues`.
- When a caller needs diagnostics in runtime request-serving paths, call `source.resolve_for_catalog(catalog)` directly and consume both `credentials` and `auth_issues`.
- Use `source.resolve()` only in built-in-catalog fallback contexts.
- `EnvCredentialSource` is the env-backed source for env-only or no-vault contexts.
- `VaultCredentialSource` is the normal source for vault-backed runtime contexts.
@ -16,17 +18,19 @@ This document defines how Fabro resolves LLM credentials and constructs `fabro-l
- Rebuilding a client from the source at point of use preserves OAuth refresh behavior on long-running processes.
- Holding the source on contexts avoids process-global installs and cross-context leakage.
- Threading the catalog into credential resolution keeps custom providers, aliases, header-only providers, and API model IDs consistent across auth, client registration, and request translation.
- Requiring an explicit client on `GenerateParams` makes the old silent fallback bug unrepresentable.
## Application
- Workflow state lives on `RunServices.llm_source`.
- Server state lives on `AppState.llm_source`.
- Hooks and other long-lived executors receive a source and derive clients when they actually generate.
- One-shot CLI commands may resolve a source locally, then derive a client once for that operation.
- Workflow state lives on `RunServices.llm_source` and `RunServices.catalog`.
- Server state lives on `AppState.llm_source` and `AppState.catalog()`.
- Hooks and other long-lived executors receive a source plus catalog and derive clients when they actually generate.
- One-shot CLI commands may resolve a source locally, then derive a client once for that operation. Use the built-in-catalog path only when those commands do not load runtime catalog settings.
## Enforcement
- Do not add new `Client::from_env`-style shortcuts in production paths.
- Do not cache a long-lived `Client` where OAuth refresh or storage-dir rebinding matters.
- Mirror [server-secrets-strategy.md](/Users/bhelmkamp/p/fabro-sh/fabro-6/docs-internal/server-secrets-strategy.md): production credential resolution should be explicit about where secrets come from and how they flow into subprocesses.
- Do not route runtime request-serving paths through `Client::from_source` or `CredentialSource::resolve()` if they have an `Arc<Catalog>`.
- Mirror [server-secrets-strategy.md](server-secrets-strategy.md): production credential resolution should be explicit about where secrets come from and how they flow into subprocesses.

View file

@ -1,7 +1,7 @@
use std::sync::Arc;
use fabro_llm::types::ToolDefinition;
use fabro_model::{Catalog, Provider};
use fabro_model::{Catalog, Provider, ProviderId};
use tokio::sync::Mutex;
use crate::profiles::EnvContext;
@ -15,6 +15,9 @@ use crate::tool_registry::ToolRegistry;
pub trait AgentProfile: Send + Sync {
fn provider(&self) -> Provider;
fn provider_id(&self) -> ProviderId {
self.provider().id()
}
fn model(&self) -> &str;
fn tool_registry(&self) -> &ToolRegistry;
fn tool_registry_mut(&mut self) -> &mut ToolRegistry;

View file

@ -111,7 +111,7 @@ function names, error messages, and exact values. Omit pleasantries and conversa
"Here is the conversation to summarize:\n\n{rendered}"
)),
],
provider: Some(provider_profile.provider().to_string()),
provider: Some(provider_profile.provider_id().to_string()),
tools: None,
tool_choice: None,
response_format: None,

View file

@ -1,4 +1,4 @@
use fabro_model::Provider;
use fabro_model::{Provider, ProviderId};
use super::EnvContext;
use crate::agent_profile::AgentProfile;
@ -36,6 +36,7 @@ impl AnthropicProfile {
Self {
base: BaseProfile {
provider: Provider::Anthropic,
provider_id: Provider::Anthropic.id(),
model: model.into(),
registry,
},
@ -47,6 +48,14 @@ impl AnthropicProfile {
#[must_use]
pub fn with_provider(mut self, provider: Provider) -> Self {
self.base.provider = provider;
self.base.provider_id = provider.id();
self
}
/// Override the provider ID while retaining the adapter/profile behavior.
#[must_use]
pub fn with_provider_id(mut self, provider_id: ProviderId) -> Self {
self.base.provider_id = provider_id;
self
}
}
@ -56,6 +65,10 @@ impl AgentProfile for AnthropicProfile {
self.base.provider
}
fn provider_id(&self) -> ProviderId {
self.base.provider_id.clone()
}
fn model(&self) -> &str {
&self.base.model
}

View file

@ -1,4 +1,4 @@
use fabro_model::Provider;
use fabro_model::{Provider, ProviderId};
use super::EnvContext;
use crate::agent_profile::AgentProfile;
@ -38,11 +38,27 @@ impl GeminiProfile {
Self {
base: BaseProfile {
provider: Provider::Gemini,
provider_id: Provider::Gemini.id(),
model: model.into(),
registry,
},
}
}
/// Override the provider identity.
#[must_use]
pub fn with_provider(mut self, provider: Provider) -> Self {
self.base.provider = provider;
self.base.provider_id = provider.id();
self
}
/// Override the provider ID while retaining the adapter/profile behavior.
#[must_use]
pub fn with_provider_id(mut self, provider_id: ProviderId) -> Self {
self.base.provider_id = provider_id;
self
}
}
impl AgentProfile for GeminiProfile {
@ -50,6 +66,10 @@ impl AgentProfile for GeminiProfile {
self.base.provider
}
fn provider_id(&self) -> ProviderId {
self.base.provider_id.clone()
}
fn model(&self) -> &str {
&self.base.model
}

View file

@ -3,7 +3,7 @@ pub mod gemini;
pub mod openai;
pub use anthropic::AnthropicProfile;
use fabro_model::Provider;
use fabro_model::{Provider, ProviderId};
pub use gemini::GeminiProfile;
pub use openai::OpenAiProfile;
@ -16,9 +16,10 @@ use crate::tool_registry::ToolRegistry;
/// Each concrete profile embeds this struct and delegates `provider()`,
/// `model()`, `tool_registry()`, and `tool_registry_mut()` to it.
pub struct BaseProfile {
pub provider: Provider,
pub model: String,
pub registry: ToolRegistry,
pub provider: Provider,
pub provider_id: ProviderId,
pub model: String,
pub registry: ToolRegistry,
}
/// Additional context for building environment blocks

View file

@ -1,4 +1,4 @@
use fabro_model::Provider;
use fabro_model::{Provider, ProviderId};
use super::EnvContext;
use crate::agent_profile::AgentProfile;
@ -34,6 +34,7 @@ impl OpenAiProfile {
Self {
base: BaseProfile {
provider: Provider::OpenAi,
provider_id: Provider::OpenAi.id(),
model: model.into(),
registry,
},
@ -45,6 +46,14 @@ impl OpenAiProfile {
#[must_use]
pub fn with_provider(mut self, provider: Provider) -> Self {
self.base.provider = provider;
self.base.provider_id = provider.id();
self
}
/// Override the provider ID while retaining the adapter/profile behavior.
#[must_use]
pub fn with_provider_id(mut self, provider_id: ProviderId) -> Self {
self.base.provider_id = provider_id;
self
}
@ -65,6 +74,10 @@ impl AgentProfile for OpenAiProfile {
self.base.provider
}
fn provider_id(&self) -> ProviderId {
self.base.provider_id.clone()
}
fn model(&self) -> &str {
&self.base.model
}

View file

@ -13,7 +13,7 @@ use fabro_llm::types::{
use fabro_llm::{Error as LlmError, retry};
use fabro_mcp::config::{McpServerSettings, McpTransport};
use fabro_mcp::connection_manager::McpConnectionManager;
use fabro_model::{ModelRef, Provider, Speed};
use fabro_model::{Catalog, ModelRef, Provider, Speed};
use fabro_types::Principal;
use futures::StreamExt;
use tokio::sync::{Mutex as AsyncMutex, Notify, broadcast};
@ -329,6 +329,24 @@ impl Session {
))
}
pub async fn from_source_with_catalog(
source: &dyn CredentialSource,
catalog: Arc<Catalog>,
provider_profile: Arc<dyn AgentProfile>,
sandbox: Arc<dyn Sandbox>,
config: SessionOptions,
subagent_manager: Option<Arc<AsyncMutex<SubAgentManager>>>,
) -> Result<Self, LlmError> {
let client = Client::from_source_with_catalog(source, catalog).await?;
Ok(Self::new(
client,
provider_profile,
sandbox,
config,
subagent_manager,
))
}
pub fn set_tool_env_provider(&mut self, provider: Arc<dyn ToolEnvProvider>) {
self.tool_env_provider = Some(provider);
}
@ -347,6 +365,11 @@ impl Session {
self.provider_profile.provider()
}
#[must_use]
pub fn provider_id(&self) -> fabro_model::ProviderId {
self.provider_profile.provider_id()
}
#[must_use]
pub fn model(&self) -> &str {
self.provider_profile.model()
@ -364,7 +387,7 @@ impl Session {
self.event_emitter
.emit(self.id.clone(), AgentEvent::SessionStarted {
provider: Some(self.provider_profile.provider().to_string()),
provider: Some(self.provider_profile.provider_id().to_string()),
model: Some(self.provider_profile.model().to_string()),
});
@ -1125,7 +1148,7 @@ impl Session {
// Call LLM (streaming) with retry for transient errors
let retry_emitter = self.event_emitter.clone();
let retry_session_id = self.id.clone();
let retry_provider = self.provider_profile.provider().to_string();
let retry_provider = self.provider_profile.provider_id().to_string();
let retry_model = self.provider_profile.model().to_string();
let retry_policy = RetryPolicy {
max_retries: 3,
@ -1327,7 +1350,7 @@ impl Session {
.as_deref()
.and_then(|value| value.parse::<Speed>().ok());
let model = ModelRef {
provider: self.provider_profile.provider().id(),
provider: self.provider_profile.provider_id(),
model_id: if response.model.is_empty() {
self.provider_profile.model().to_string()
} else {
@ -1525,7 +1548,7 @@ impl Session {
Request {
model: self.provider_profile.model().to_string(),
messages,
provider: Some(self.provider_profile.provider().to_string()),
provider: Some(self.provider_profile.provider_id().to_string()),
tools: if has_tools { Some(tools) } else { None },
tool_choice: if has_tools {
Some(ToolChoice::Auto)

View file

@ -31,3 +31,4 @@ tokio.workspace = true
httpmock = "0.8"
tempfile = "3"
tokio = { workspace = true, features = ["macros", "test-util"] }
toml.workspace = true

View file

@ -1,5 +1,5 @@
use async_trait::async_trait;
use fabro_model::ProviderId;
use fabro_model::{Catalog, ProviderId};
use crate::{ApiCredential, ResolveError};
@ -14,4 +14,14 @@ pub trait CredentialSource: Send + Sync {
async fn resolve(&self) -> anyhow::Result<ResolvedCredentials>;
async fn configured_providers(&self) -> Vec<ProviderId>;
async fn resolve_for_catalog(&self, catalog: &Catalog) -> anyhow::Result<ResolvedCredentials> {
let _ = catalog;
self.resolve().await
}
async fn configured_providers_for_catalog(&self, catalog: &Catalog) -> Vec<ProviderId> {
let _ = catalog;
self.configured_providers().await
}
}

View file

@ -2,11 +2,11 @@ use std::sync::Arc;
use async_trait::async_trait;
use fabro_model::catalog::CatalogProvider;
use fabro_model::{Catalog, CredentialRef, HeaderValueRef, Provider, ProviderId};
use fabro_model::{Catalog, CredentialRef, HeaderValueRef, Provider, ProviderId, adapter};
use fabro_static::EnvVars;
use crate::credential_source::{CredentialSource, ResolvedCredentials};
use crate::{ApiCredential, EnvLookup};
use crate::{ApiCredential, EnvLookup, ResolveError, build_api_key_header};
#[derive(Clone)]
pub struct EnvCredentialSource {
@ -32,20 +32,48 @@ impl EnvCredentialSource {
(self.env_lookup)(name)
}
fn credential_for(&self, provider: &CatalogProvider) -> Option<ApiCredential> {
fn credential_for(
&self,
provider: &CatalogProvider,
) -> Result<Option<ApiCredential>, ResolveError> {
let key = provider.credentials.iter().find_map(|credential_ref| {
let CredentialRef::Env(name) = credential_ref else {
return None;
};
self.lookup(name)
})?;
});
let mut cred = ApiCredential::from_api_key(provider.id.clone(), key);
if key.is_none() && provider.credentials.is_empty() && provider.extra_headers.is_empty() {
return Ok(None);
}
let extra_headers = self.resolved_extra_headers(provider)?;
if key.is_none() && (!provider.credentials.is_empty() || extra_headers.is_empty()) {
return Ok(None);
}
let auth_header = key.map(|key| {
let policy = adapter::get(&provider.adapter)
.map_or(fabro_model::ApiKeyHeaderPolicy::Bearer, |adapter| {
adapter.api_key_header
});
build_api_key_header(policy, key)
});
let mut cred = ApiCredential {
provider: provider.id.clone(),
auth_header,
extra_headers,
base_url: None,
codex_mode: false,
org_id: None,
project_id: None,
};
cred.base_url = self
.env_base_url(&provider.id)
.or_else(|| provider.base_url.clone());
cred.extra_headers = self.resolved_extra_headers(provider)?;
if provider.id == Provider::OpenAi.id() {
if provider.id == Provider::OpenAi.id() && cred.auth_header.is_some() {
cred.org_id = self.lookup(EnvVars::OPENAI_ORG_ID);
cred.project_id = self.lookup(EnvVars::OPENAI_PROJECT_ID);
if let Some(account_id) = self.lookup(EnvVars::CHATGPT_ACCOUNT_ID) {
@ -57,7 +85,7 @@ impl EnvCredentialSource {
.insert("originator".to_string(), "fabro".to_string());
}
}
Some(cred)
Ok(Some(cred))
}
fn env_base_url(&self, provider: &ProviderId) -> Option<String> {
@ -74,7 +102,7 @@ impl EnvCredentialSource {
fn resolved_extra_headers(
&self,
provider: &CatalogProvider,
) -> Option<std::collections::HashMap<String, String>> {
) -> Result<std::collections::HashMap<String, String>, ResolveError> {
provider
.extra_headers
.iter()
@ -83,8 +111,9 @@ impl EnvCredentialSource {
HeaderValueRef::Literal(value) => Some(value.clone()),
HeaderValueRef::Env(name) => self.lookup(name),
HeaderValueRef::Credential(_) => None,
}?;
Some((name.clone(), value))
}
.ok_or_else(|| ResolveError::NotConfigured(provider.id.clone()))?;
Ok((name.clone(), value))
})
.collect()
}
@ -106,20 +135,35 @@ impl Default for EnvCredentialSource {
#[async_trait]
impl CredentialSource for EnvCredentialSource {
async fn resolve(&self) -> anyhow::Result<ResolvedCredentials> {
let credentials = Catalog::builtin()
.providers()
.iter()
.filter_map(|provider| self.credential_for(provider))
.collect();
self.resolve_for_catalog(Catalog::builtin()).await
}
async fn resolve_for_catalog(&self, catalog: &Catalog) -> anyhow::Result<ResolvedCredentials> {
let mut credentials = Vec::new();
let mut auth_issues = Vec::new();
for provider in catalog.providers() {
match self.credential_for(provider) {
Ok(Some(credential)) => credentials.push(credential),
Ok(None) => {}
Err(ResolveError::NotConfigured(_)) if !provider.credentials.is_empty() => {}
Err(err) => auth_issues.push((provider.id.clone(), err)),
}
}
Ok(ResolvedCredentials {
credentials,
auth_issues: Vec::new(),
auth_issues,
})
}
async fn configured_providers(&self) -> Vec<ProviderId> {
Catalog::builtin()
self.configured_providers_for_catalog(Catalog::builtin())
.await
}
async fn configured_providers_for_catalog(&self, catalog: &Catalog) -> Vec<ProviderId> {
catalog
.providers()
.iter()
.filter(|provider| {
@ -129,6 +173,9 @@ impl CredentialSource for EnvCredentialSource {
.any(|credential_ref| {
matches!(credential_ref, CredentialRef::Env(name) if self.lookup(name).is_some())
})
|| (!provider.extra_headers.is_empty()
&& provider.credentials.is_empty()
&& self.resolved_extra_headers(provider).is_ok())
})
.map(|provider| provider.id.clone())
.collect()
@ -140,7 +187,8 @@ mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use fabro_model::Provider;
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::{Catalog, Provider, ProviderId};
use super::EnvCredentialSource;
use crate::CredentialSource;
@ -153,6 +201,11 @@ mod tests {
EnvCredentialSource::with_env_lookup(Arc::new(move |name| entries.get(name).cloned()))
}
fn catalog_with(overrides: &str) -> Catalog {
let settings: LlmCatalogSettings = toml::from_str(overrides).unwrap();
Catalog::from_builtin_with_overrides(&settings).unwrap()
}
#[tokio::test]
async fn configured_providers_reads_injected_env() {
let source = test_source(&[("ANTHROPIC_API_KEY", "anthropic-key")]);
@ -209,4 +262,145 @@ mod tests {
Some("https://api.moonshot.ai/v1")
);
}
#[tokio::test]
async fn resolve_for_catalog_registers_custom_env_backed_provider() {
let catalog = catalog_with(
r#"
[providers.venice]
display_name = "Venice"
adapter = "openai_compatible"
base_url = "https://api.venice.ai/api/v1"
credentials = ["env:VENICE_API_KEY"]
[models."venice-large"]
provider = "venice"
display_name = "Venice Large"
family = "venice"
default = true
[models."venice-large".limits]
context_window = 128000
[models."venice-large".features]
tools = true
vision = false
reasoning = false
effort = false
"#,
);
let source = test_source(&[("VENICE_API_KEY", "venice-key")]);
let resolved = source.resolve_for_catalog(&catalog).await.unwrap();
let credential = resolved
.credentials
.iter()
.find(|credential| credential.provider == ProviderId::new("venice"))
.expect("custom provider should resolve from the supplied catalog");
assert_eq!(
credential.auth_header.as_ref().unwrap(),
&crate::ApiKeyHeader::Bearer("venice-key".to_string(),)
);
assert_eq!(
credential.base_url.as_deref(),
Some("https://api.venice.ai/api/v1")
);
}
#[tokio::test]
async fn resolve_for_catalog_registers_header_only_provider() {
let catalog = catalog_with(
r#"
[providers.portkey]
display_name = "Portkey Bedrock"
adapter = "anthropic"
base_url = "https://api.portkey.ai/v1"
[providers.portkey.extra_headers]
x-portkey-api-key = { env = "PORTKEY_API_KEY" }
x-portkey-provider = { literal = "@bedrock-prod" }
[models."portkey-claude"]
provider = "portkey"
display_name = "Portkey Claude"
family = "claude"
default = true
[models."portkey-claude".limits]
context_window = 200000
[models."portkey-claude".features]
tools = true
vision = true
reasoning = true
effort = true
"#,
);
let source = test_source(&[("PORTKEY_API_KEY", "pk-live")]);
let resolved = source.resolve_for_catalog(&catalog).await.unwrap();
let credential = resolved
.credentials
.iter()
.find(|credential| credential.provider == ProviderId::new("portkey"))
.expect("header-only provider should register when all headers resolve");
assert!(credential.auth_header.is_none());
assert_eq!(
credential.extra_headers.get("x-portkey-api-key"),
Some(&"pk-live".to_string())
);
assert_eq!(
credential.extra_headers.get("x-portkey-provider"),
Some(&"@bedrock-prod".to_string())
);
}
#[tokio::test]
async fn resolve_for_catalog_reports_missing_required_header() {
let catalog = catalog_with(
r#"
[providers.portkey]
display_name = "Portkey Bedrock"
adapter = "anthropic"
base_url = "https://api.portkey.ai/v1"
[providers.portkey.extra_headers]
x-portkey-api-key = { env = "PORTKEY_API_KEY" }
[models."portkey-claude"]
provider = "portkey"
display_name = "Portkey Claude"
family = "claude"
default = true
[models."portkey-claude".limits]
context_window = 200000
[models."portkey-claude".features]
tools = true
vision = true
reasoning = true
effort = true
"#,
);
let source = test_source(&[]);
let resolved = source.resolve_for_catalog(&catalog).await.unwrap();
assert!(
!resolved
.credentials
.iter()
.any(|credential| credential.provider == ProviderId::new("portkey"))
);
assert!(
resolved
.auth_issues
.iter()
.any(|(provider, issue)| provider == &ProviderId::new("portkey")
&& matches!(issue, crate::ResolveError::NotConfigured(_)))
);
}
}

View file

@ -1,6 +1,7 @@
use std::collections::HashMap;
use std::sync::Arc;
use fabro_model::catalog::CatalogProvider;
use fabro_model::{
ApiKeyHeaderPolicy, Catalog, CredentialRef, HeaderValueRef, Provider, ProviderId, adapter,
};
@ -32,7 +33,7 @@ pub enum CredentialUsage {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiCredential {
pub provider: ProviderId,
pub auth_header: ApiKeyHeader,
pub auth_header: Option<ApiKeyHeader>,
pub extra_headers: HashMap<String, String>,
pub base_url: Option<String>,
pub codex_mode: bool,
@ -51,7 +52,7 @@ impl ApiCredential {
let auth_header = auth_header_for_provider(&provider, key);
Self {
provider,
auth_header,
auth_header: Some(auth_header),
extra_headers: HashMap::new(),
base_url: None,
codex_mode: false,
@ -86,6 +87,12 @@ fn auth_header_for_provider(provider: &ProviderId, key: String) -> ApiKeyHeader
build_api_key_header(policy, key)
}
fn auth_header_for_catalog_provider(provider: &CatalogProvider, key: String) -> ApiKeyHeader {
let policy = adapter::get(&provider.adapter)
.map_or(ApiKeyHeaderPolicy::Bearer, |adapter| adapter.api_key_header);
build_api_key_header(policy, key)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliCredential {
pub env_vars: HashMap<String, String>,
@ -214,10 +221,18 @@ impl CredentialResolver {
#[must_use]
pub fn configured_providers(&self, vault: &Vault) -> Vec<ProviderId> {
Catalog::builtin()
self.configured_providers_for_catalog(vault, Catalog::builtin())
}
pub fn configured_providers_for_catalog(
&self,
vault: &Vault,
catalog: &Catalog,
) -> Vec<ProviderId> {
catalog
.providers()
.iter()
.filter(|provider| self.has_credential_material(vault, &provider.id))
.filter(|provider| self.has_credential_material(vault, provider, catalog))
.map(|provider| provider.id.clone())
.collect()
}
@ -252,16 +267,50 @@ impl CredentialResolver {
Err(ResolveError::NotConfigured(provider.clone()))
}
fn has_credential_material(&self, vault: &Vault, provider: &ProviderId) -> bool {
Catalog::builtin().provider(provider).map_or_else(
|| vault_get_credential(vault, provider.as_str()).is_some(),
|catalog_provider| {
catalog_provider.credentials.iter().any(|credential_ref| {
self.credential_from_ref(vault, provider, credential_ref)
.is_some()
})
},
)
fn find_credential_for_catalog(
&self,
vault: &Vault,
provider: &CatalogProvider,
usage: CredentialUsage,
) -> Result<AuthCredential, ResolveError> {
if provider.id == Provider::OpenAi.id()
&& usage == CredentialUsage::CliAgent(CliAgentKind::Codex)
{
for credential_id in ["openai_codex", "openai"] {
if let Some(credential) = vault_get_credential(vault, credential_id) {
return Ok(credential);
}
}
}
for credential_ref in &provider.credentials {
if let Some(credential) = self.credential_from_ref(vault, &provider.id, credential_ref)
{
return Ok(credential);
}
}
if let Some(credential) = vault_get_credential(vault, provider.id.as_str()) {
return Ok(credential);
}
Err(ResolveError::NotConfigured(provider.id.clone()))
}
fn has_credential_material(
&self,
vault: &Vault,
provider: &CatalogProvider,
catalog: &Catalog,
) -> bool {
provider.credentials.iter().any(|credential_ref| {
self.credential_from_ref(vault, &provider.id, credential_ref)
.is_some()
}) || (!provider.extra_headers.is_empty()
&& provider.credentials.is_empty()
&& self
.resolved_extra_headers_for_catalog(vault, &provider.id, catalog)
.is_ok())
}
fn credential_from_ref(
@ -286,7 +335,12 @@ impl CredentialResolver {
(self.env_lookup)(name).or_else(|| vault.get(name).map(str::to_string))
}
fn provider_base_url(&self, vault: &Vault, provider: &ProviderId) -> Option<String> {
fn provider_base_url_for_catalog(
&self,
vault: &Vault,
provider: &ProviderId,
catalog: &Catalog,
) -> Option<String> {
let env_base_url = match Provider::from_id(provider) {
Some(Provider::Anthropic) => {
self.lookup_env_or_vault(vault, EnvVars::ANTHROPIC_BASE_URL)
@ -300,18 +354,19 @@ impl CredentialResolver {
}
};
env_base_url.or_else(|| {
Catalog::builtin()
catalog
.provider(provider)
.and_then(|provider| provider.base_url.clone())
})
}
fn resolved_extra_headers(
fn resolved_extra_headers_for_catalog(
&self,
vault: &Vault,
provider: &ProviderId,
catalog: &Catalog,
) -> Result<HashMap<String, String>, ResolveError> {
let Some(catalog_provider) = Catalog::builtin().provider(provider) else {
let Some(catalog_provider) = catalog.provider(provider) else {
return Ok(HashMap::new());
};
catalog_provider
@ -334,13 +389,34 @@ impl CredentialResolver {
vault: &Vault,
credential: &AuthCredential,
) -> Result<ApiCredential, ResolveError> {
let base_url = self.provider_base_url(vault, &credential.provider);
self.to_api_credential_for_catalog(vault, credential, Catalog::builtin())
}
fn to_api_credential_for_catalog(
&self,
vault: &Vault,
credential: &AuthCredential,
catalog: &Catalog,
) -> Result<ApiCredential, ResolveError> {
let base_url = self.provider_base_url_for_catalog(vault, &credential.provider, catalog);
match &credential.details {
AuthDetails::ApiKey { key } => {
let mut cred =
ApiCredential::from_api_key(credential.provider.clone(), key.clone());
let auth_header = catalog.provider(&credential.provider).map_or_else(
|| auth_header_for_provider(&credential.provider, key.clone()),
|provider| auth_header_for_catalog_provider(provider, key.clone()),
);
let mut cred = ApiCredential {
provider: credential.provider.clone(),
auth_header: Some(auth_header),
extra_headers: HashMap::new(),
base_url: None,
codex_mode: false,
org_id: None,
project_id: None,
};
cred.base_url = base_url;
cred.extra_headers = self.resolved_extra_headers(vault, &credential.provider)?;
cred.extra_headers =
self.resolved_extra_headers_for_catalog(vault, &credential.provider, catalog)?;
if credential.provider == Provider::OpenAi.id() {
cred.org_id = self.lookup_env_or_vault(vault, EnvVars::OPENAI_ORG_ID);
cred.project_id = self.lookup_env_or_vault(vault, EnvVars::OPENAI_PROJECT_ID);
@ -357,7 +433,7 @@ impl CredentialResolver {
}
Ok(ApiCredential {
provider: credential.provider.clone(),
auth_header: ApiKeyHeader::Bearer(tokens.access_token.clone()),
auth_header: Some(ApiKeyHeader::Bearer(tokens.access_token.clone())),
extra_headers,
base_url: Some("https://chatgpt.com/backend-api/codex".to_string()),
codex_mode: true,
@ -368,6 +444,72 @@ impl CredentialResolver {
}
}
pub async fn resolve_for_catalog(
&self,
provider: impl Into<ProviderId>,
usage: CredentialUsage,
catalog: &Catalog,
) -> Result<ResolvedCredential, ResolveError> {
let provider_id = provider.into();
let Some(catalog_provider) = catalog.provider(&provider_id) else {
return self.resolve(provider_id, usage).await;
};
let initial_credential = {
let vault = self.vault.read().await;
self.find_credential_for_catalog(&vault, catalog_provider, usage)?
};
let credential = if initial_credential.needs_refresh() {
let AuthDetails::CodexOAuth { tokens, .. } = &initial_credential.details else {
unreachable!("only OAuth credentials can need refresh");
};
if tokens.refresh_token.is_none() {
return Err(ResolveError::RefreshTokenMissing(provider_id.clone()));
}
refresh_oauth_credential(&initial_credential)
.await
.map_err(|source| ResolveError::RefreshFailed {
provider: provider_id.clone(),
source,
})?
} else {
initial_credential
};
let vault = self.vault.read().await;
match usage {
CredentialUsage::ApiRequest => self
.to_api_credential_for_catalog(&vault, &credential, catalog)
.map(ResolvedCredential::Api),
CredentialUsage::CliAgent(kind) => Ok(ResolvedCredential::Cli(
Self::to_cli_credential(&credential, kind),
)),
}
}
pub async fn header_only_api_credential_for_catalog(
&self,
provider: &CatalogProvider,
catalog: &Catalog,
) -> Result<Option<ApiCredential>, ResolveError> {
if !provider.credentials.is_empty() || provider.extra_headers.is_empty() {
return Ok(None);
}
let vault = self.vault.read().await;
let extra_headers =
self.resolved_extra_headers_for_catalog(&vault, &provider.id, catalog)?;
Ok(Some(ApiCredential {
provider: provider.id.clone(),
auth_header: None,
extra_headers,
base_url: provider.base_url.clone(),
codex_mode: false,
org_id: None,
project_id: None,
}))
}
fn to_cli_credential(credential: &AuthCredential, kind: CliAgentKind) -> CliCredential {
let mut env_vars = HashMap::new();
let provider = Provider::from_id(&credential.provider);
@ -535,7 +677,7 @@ mod tests {
};
assert_eq!(
api.auth_header,
ApiKeyHeader::Bearer("vault-key".to_string())
Some(ApiKeyHeader::Bearer("vault-key".to_string()))
);
}
@ -564,7 +706,7 @@ mod tests {
};
assert_eq!(
api.auth_header,
ApiKeyHeader::Bearer("expired-access".to_string())
Some(ApiKeyHeader::Bearer("expired-access".to_string()))
);
assert!(api.codex_mode);
assert_eq!(
@ -610,10 +752,13 @@ mod tests {
panic!("expected api credential");
};
assert_eq!(api.auth_header, ApiKeyHeader::Custom {
name: "x-api-key".to_string(),
value: "anthropic-key".to_string(),
});
assert_eq!(
api.auth_header,
Some(ApiKeyHeader::Custom {
name: "x-api-key".to_string(),
value: "anthropic-key".to_string(),
})
);
}
#[tokio::test]
@ -646,7 +791,7 @@ mod tests {
};
assert_eq!(
api.auth_header,
ApiKeyHeader::Bearer("compat-key".to_string())
Some(ApiKeyHeader::Bearer("compat-key".to_string()))
);
assert_eq!(
api.base_url.as_deref(),
@ -976,7 +1121,7 @@ mod tests {
fn api_credential_debug_redacts_secret_material() {
let credential = ApiCredential {
provider: Provider::OpenAi.id(),
auth_header: ApiKeyHeader::Bearer("sk-test".to_string()),
auth_header: Some(ApiKeyHeader::Bearer("sk-test".to_string())),
extra_headers: HashMap::new(),
base_url: None,
codex_mode: false,

View file

@ -42,17 +42,32 @@ impl std::fmt::Debug for VaultCredentialSource {
#[async_trait]
impl CredentialSource for VaultCredentialSource {
async fn resolve(&self) -> anyhow::Result<ResolvedCredentials> {
self.resolve_for_catalog(Catalog::builtin()).await
}
async fn resolve_for_catalog(&self, catalog: &Catalog) -> anyhow::Result<ResolvedCredentials> {
let mut credentials = Vec::new();
let mut auth_issues = Vec::new();
for provider in Catalog::builtin().providers() {
for provider in catalog.providers() {
match self
.resolver
.resolve(provider.id.clone(), CredentialUsage::ApiRequest)
.resolve_for_catalog(provider.id.clone(), CredentialUsage::ApiRequest, catalog)
.await
{
Ok(ResolvedCredential::Api(credential)) => credentials.push(credential),
Ok(ResolvedCredential::Cli(_)) | Err(ResolveError::NotConfigured(_)) => {}
Ok(ResolvedCredential::Cli(_)) => {}
Err(ResolveError::NotConfigured(_)) => {
match self
.resolver
.header_only_api_credential_for_catalog(provider, catalog)
.await
{
Ok(Some(credential)) => credentials.push(credential),
Ok(None) => {}
Err(err) => auth_issues.push((provider.id.clone(), err)),
}
}
Err(err) => auth_issues.push((provider.id.clone(), err)),
}
}
@ -64,8 +79,14 @@ impl CredentialSource for VaultCredentialSource {
}
async fn configured_providers(&self) -> Vec<ProviderId> {
self.configured_providers_for_catalog(Catalog::builtin())
.await
}
async fn configured_providers_for_catalog(&self, catalog: &Catalog) -> Vec<ProviderId> {
let vault = self.vault.read().await;
self.resolver.configured_providers(&vault)
self.resolver
.configured_providers_for_catalog(&vault, catalog)
}
}

View file

@ -15,6 +15,8 @@ use fabro_config::{ServerSettingsBuilder, Storage};
use fabro_interview::{
AnswerSubmission, ControlInterviewer, WorkerControlEnvelope, WorkerControlMessage,
};
use fabro_model::Catalog;
use fabro_model::catalog::LlmCatalogSettings;
use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer};
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{RunMode, RunNamespace};
@ -93,6 +95,10 @@ pub(crate) async fn execute(
let run_control = RunControlState::new();
install_signal_handlers(Arc::clone(&run_control), cancel_token.clone())?;
let vault = load_worker_vault(storage_dir.as_deref())?;
let catalog = Arc::new(
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
.context("failed to build default LLM catalog")?,
);
let github_app = {
let vault_guard = match &vault {
Some(arc) => Some(arc.read().await),
@ -127,6 +133,7 @@ pub(crate) async fn execute(
.github
.resolve_permissions(process_env_var),
vault,
catalog,
on_node: None,
registry_override: None,
};

View file

@ -75,6 +75,7 @@ mod tests {
use std::path::Path;
use std::sync::Mutex;
use fabro_model::Catalog;
use fabro_types::fixtures;
use super::*;
@ -96,6 +97,7 @@ mod tests {
_sandbox: Arc<dyn Sandbox>,
_work_dir: Option<&Path>,
_llm_source: &dyn fabro_auth::CredentialSource,
_catalog: Arc<Catalog>,
) -> HookResult {
self.captured_contexts.lock().unwrap().push(context.clone());
HookResult {

View file

@ -12,6 +12,7 @@ use fabro_auth::CredentialSource;
use fabro_llm::client::Client as LlmClient;
use fabro_llm::generate::{GenerateParams, generate_object};
use fabro_llm::types::{Message, Request, ToolResult};
use fabro_model::Catalog;
use fabro_template::{TemplateContext, render as render_template};
use fabro_types::settings::InterpString;
use fabro_util::env::{Env, SystemEnv};
@ -50,6 +51,7 @@ pub trait HookExecutor: Send + Sync {
sandbox: Arc<dyn Sandbox>,
work_dir: Option<&Path>,
llm_source: &dyn CredentialSource,
catalog: Arc<Catalog>,
) -> HookResult;
}
@ -253,9 +255,9 @@ impl HookExecutorImpl {
}
/// Resolve a model alias (e.g. "haiku") to a concrete model ID.
fn resolve_model(model: Option<&str>) -> String {
fn resolve_model(model: Option<&str>, catalog: &Catalog) -> String {
let model_id = model.unwrap_or("haiku");
let model_info = fabro_model::Catalog::builtin().get(model_id);
let model_info = catalog.get(model_id);
model_info.map_or(model_id, |m| m.id.as_str()).to_string()
}
@ -291,6 +293,7 @@ impl HookExecutorImpl {
context: &HookContext,
env: &E,
llm_source: &dyn CredentialSource,
catalog: Arc<Catalog>,
) -> HookDecision
where
E: Env + Clone + Send + Sync + fmt::Debug + 'static,
@ -300,11 +303,11 @@ impl HookExecutorImpl {
return HookDecision::Proceed;
};
let resolved_model = Self::resolve_model(model.as_deref());
let resolved_model = Self::resolve_model(model.as_deref(), catalog.as_ref());
let user_msg = Self::build_hook_user_message(&prompt, context);
Self::execute_llm_with_timeout(definition.timeout(), "prompt", || async move {
let client = match LlmClient::from_source(llm_source).await {
let client = match LlmClient::from_source_with_catalog(llm_source, catalog).await {
Ok(client) => Arc::new(client),
Err(e) => {
tracing::warn!(error = %e, "prompt hook client creation failed, proceeding");
@ -354,6 +357,7 @@ impl HookExecutorImpl {
sandbox: Arc<dyn Sandbox>,
env: &E,
llm_source: &dyn CredentialSource,
catalog: Arc<Catalog>,
) -> HookDecision
where
E: Env + Clone + Send + Sync + fmt::Debug + 'static,
@ -363,11 +367,11 @@ impl HookExecutorImpl {
return HookDecision::Proceed;
};
let resolved_model = Self::resolve_model(model.as_deref());
let resolved_model = Self::resolve_model(model.as_deref(), catalog.as_ref());
let user_msg = Self::build_hook_user_message(&prompt, context);
Self::execute_llm_with_timeout(definition.timeout(), "agent", || async move {
let client = match LlmClient::from_source(llm_source).await {
let client = match LlmClient::from_source_with_catalog(llm_source, catalog).await {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %e, "agent hook client creation failed, proceeding");
@ -617,6 +621,7 @@ impl HookExecutor for HookExecutorImpl {
sandbox: Arc<dyn Sandbox>,
work_dir: Option<&Path>,
llm_source: &dyn CredentialSource,
catalog: Arc<Catalog>,
) -> HookResult {
use std::sync::OnceLock;
static HTTP_CLIENTS: OnceLock<HttpClientCache> = OnceLock::new();
@ -675,6 +680,7 @@ impl HookExecutor for HookExecutorImpl {
context,
&env,
llm_source,
Arc::clone(&catalog),
)
.await
}
@ -699,6 +705,7 @@ impl HookExecutor for HookExecutorImpl {
sandbox,
&env,
llm_source,
Arc::clone(&catalog),
)
.await
}
@ -719,6 +726,7 @@ impl HookExecutor for HookExecutorImpl {
#[cfg(test)]
mod tests {
use fabro_auth::{CredentialSource, EnvCredentialSource};
use fabro_model::catalog::LlmCatalogSettings;
use fabro_types::fixtures;
use fabro_util::env::TestEnv;
@ -740,6 +748,10 @@ mod tests {
Arc::new(EnvCredentialSource::new())
}
fn test_catalog() -> Arc<Catalog> {
Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap())
}
fn test_http_client() -> fabro_http::HttpClient {
HookExecutorImpl::build_http_client(TlsMode::Off)
}
@ -822,7 +834,7 @@ mod tests {
let sandbox = make_sandbox();
let source = test_llm_source();
let result = executor
.execute(&def, &ctx, sandbox, None, source.as_ref())
.execute(&def, &ctx, sandbox, None, source.as_ref(), test_catalog())
.await;
assert_eq!(result.decision, HookDecision::Proceed);
assert_eq!(result.hook_name.as_deref(), Some("test-hook"));
@ -836,7 +848,7 @@ mod tests {
let sandbox = make_sandbox();
let source = test_llm_source();
let result = executor
.execute(&def, &ctx, sandbox, None, source.as_ref())
.execute(&def, &ctx, sandbox, None, source.as_ref(), test_catalog())
.await;
assert!(matches!(result.decision, HookDecision::Block { .. }));
}
@ -849,7 +861,7 @@ mod tests {
let sandbox = make_sandbox();
let source = test_llm_source();
let result = executor
.execute(&def, &ctx, sandbox, None, source.as_ref())
.execute(&def, &ctx, sandbox, None, source.as_ref(), test_catalog())
.await;
assert!(matches!(result.decision, HookDecision::Block { .. }));
}
@ -862,7 +874,7 @@ mod tests {
let sandbox = make_sandbox();
let source = test_llm_source();
let result = executor
.execute(&def, &ctx, sandbox, None, source.as_ref())
.execute(&def, &ctx, sandbox, None, source.as_ref(), test_catalog())
.await;
assert_eq!(result.decision, HookDecision::Skip {
reason: Some("test skip".into()),
@ -879,7 +891,7 @@ mod tests {
let sandbox = make_sandbox();
let source = test_llm_source();
let result = executor
.execute(&def, &ctx, sandbox, None, source.as_ref())
.execute(&def, &ctx, sandbox, None, source.as_ref(), test_catalog())
.await;
assert_eq!(result.decision, HookDecision::Proceed);
}
@ -901,7 +913,7 @@ mod tests {
let sandbox = make_sandbox();
let source = test_llm_source();
let result = executor
.execute(&def, &ctx, sandbox, None, source.as_ref())
.execute(&def, &ctx, sandbox, None, source.as_ref(), test_catalog())
.await;
assert!(matches!(result.decision, HookDecision::Block { .. }));
}
@ -1296,7 +1308,7 @@ mod tests {
let sandbox = make_sandbox();
let source = test_llm_source();
let result = executor
.execute(&def, &ctx, sandbox, None, source.as_ref())
.execute(&def, &ctx, sandbox, None, source.as_ref(), test_catalog())
.await;
mock.assert_async().await;
@ -1329,6 +1341,7 @@ mod tests {
&make_context(),
&test_env(&[]),
test_llm_source().as_ref(),
test_catalog(),
)
.await;
@ -1346,6 +1359,7 @@ mod tests {
make_sandbox(),
&test_env(&[]),
test_llm_source().as_ref(),
test_catalog(),
)
.await;

View file

@ -6,6 +6,9 @@ use fabro_agent::Sandbox;
use fabro_auth::CredentialSource;
#[cfg(test)]
use fabro_auth::EnvCredentialSource;
use fabro_model::Catalog;
#[cfg(test)]
use fabro_model::catalog::LlmCatalogSettings;
use crate::config::{HookDefinition, HookSettings};
use crate::executor::{HookExecutor, HookExecutorImpl};
@ -17,18 +20,24 @@ pub struct HookRunner {
config: HookSettings,
executor: Arc<dyn HookExecutor>,
llm_source: Arc<dyn CredentialSource>,
catalog: Arc<Catalog>,
/// Pre-compiled regexes keyed by matcher pattern string.
compiled_matchers: HashMap<String, regex::Regex>,
}
impl HookRunner {
#[must_use]
pub fn new(config: HookSettings, llm_source: Arc<dyn CredentialSource>) -> Self {
pub fn new(
config: HookSettings,
llm_source: Arc<dyn CredentialSource>,
catalog: Arc<Catalog>,
) -> Self {
let compiled_matchers = Self::compile_matchers(&config);
Self {
config,
executor: Arc::new(HookExecutorImpl),
llm_source,
catalog,
compiled_matchers,
}
}
@ -41,6 +50,10 @@ impl HookRunner {
config,
executor,
llm_source: Arc::new(EnvCredentialSource::new()),
catalog: Arc::new(
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
.expect("default catalog should build"),
),
compiled_matchers,
}
}
@ -152,6 +165,7 @@ impl HookRunner {
sandbox.clone(),
work_dir,
self.llm_source.as_ref(),
Arc::clone(&self.catalog),
)
.await;
tracing::debug!(
@ -206,6 +220,7 @@ impl HookRunner {
sandbox.clone(),
work_dir,
self.llm_source.as_ref(),
Arc::clone(&self.catalog),
)
.await;
tracing::debug!(
@ -249,6 +264,7 @@ mod tests {
_sandbox: Arc<dyn Sandbox>,
_work_dir: Option<&Path>,
_llm_source: &dyn CredentialSource,
_catalog: Arc<Catalog>,
) -> HookResult {
HookResult {
hook_name: definition.name.clone(),
@ -272,6 +288,13 @@ mod tests {
Arc::new(EnvCredentialSource::new())
}
fn test_catalog() -> Arc<Catalog> {
Arc::new(
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
.expect("default catalog should build"),
)
}
fn make_hook(event: HookEvent, name: &str) -> HookDefinition {
HookDefinition {
name: Some(name.into()),
@ -287,7 +310,7 @@ mod tests {
#[tokio::test]
async fn no_hooks_returns_proceed() {
let runner = HookRunner::new(HookSettings::default(), test_llm_source());
let runner = HookRunner::new(HookSettings::default(), test_llm_source(), test_catalog());
let ctx = make_context(HookEvent::RunStart);
let sandbox = make_sandbox();
let decision = runner.run(&ctx, sandbox.clone(), None).await;
@ -452,7 +475,7 @@ mod tests {
h
}],
};
let runner = HookRunner::new(config, test_llm_source());
let runner = HookRunner::new(config, test_llm_source(), test_catalog());
let ctx = make_context(HookEvent::RunStart);
let sandbox = make_sandbox();
let decision = runner.run(&ctx, sandbox.clone(), None).await;
@ -468,7 +491,7 @@ mod tests {
h
}],
};
let runner = HookRunner::new(config, test_llm_source());
let runner = HookRunner::new(config, test_llm_source(), test_catalog());
let ctx = make_context(HookEvent::RunStart);
let sandbox = make_sandbox();
let decision = runner.run(&ctx, sandbox.clone(), None).await;

View file

@ -45,5 +45,6 @@ insta = { workspace = true }
tokio = { workspace = true, features = ["test-util", "macros"] }
httpmock = "0.8"
serde_json.workspace = true
toml.workspace = true
fabro-macros = { path = "../fabro-macros" }
fabro-test = { workspace = true }

View file

@ -16,6 +16,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use fabro_auth::ApiKeyHeader;
use fabro_model::Catalog;
use fabro_model::adapter::{self as model_adapter, AdapterMetadata};
use crate::client::auth_value;
@ -32,7 +33,7 @@ pub struct AdapterConfig {
pub provider_id: String,
/// Authentication header constructed by `fabro-auth` from the resolved
/// credential and the adapter's [`fabro_model::ApiKeyHeaderPolicy`].
pub auth_header: ApiKeyHeader,
pub auth_header: Option<ApiKeyHeader>,
/// Provider base URL override. `None` means use the adapter's built-in
/// default.
pub base_url: Option<String>,
@ -44,19 +45,21 @@ pub struct AdapterConfig {
pub org_id: Option<String>,
/// OpenAI-only: project ID.
pub project_id: Option<String>,
pub catalog: Option<Arc<Catalog>>,
}
impl AdapterConfig {
/// Construct a minimal config with just provider ID and auth header.
pub fn new(provider_id: impl Into<String>, auth_header: ApiKeyHeader) -> Self {
Self {
provider_id: provider_id.into(),
auth_header,
base_url: None,
provider_id: provider_id.into(),
auth_header: Some(auth_header),
base_url: None,
extra_headers: HashMap::new(),
codex_mode: false,
org_id: None,
project_id: None,
codex_mode: false,
org_id: None,
project_id: None,
catalog: None,
}
}
}
@ -69,14 +72,24 @@ impl AdapterConfig {
/// rather than re-shaping every existing factory.
pub type AdapterFactory = fn(AdapterConfig) -> Arc<dyn ProviderAdapter>;
fn auth_value_optional(auth_header: Option<&ApiKeyHeader>) -> Option<String> {
auth_header.map(auth_value)
}
fn build_anthropic_adapter(config: AdapterConfig) -> providers::AnthropicAdapter {
let mut adapter = providers::AnthropicAdapter::new(auth_value(&config.auth_header));
let mut adapter = providers::AnthropicAdapter::new_optional_auth(auth_value_optional(
config.auth_header.as_ref(),
))
.with_name(config.provider_id.clone());
if let Some(base_url) = config.base_url {
adapter = adapter.with_base_url(base_url);
}
if !config.extra_headers.is_empty() {
adapter = adapter.with_default_headers(config.extra_headers);
}
if let Some(catalog) = config.catalog {
adapter = adapter.with_catalog(catalog);
}
adapter
}
@ -85,7 +98,10 @@ fn build_anthropic(config: AdapterConfig) -> Arc<dyn ProviderAdapter> {
}
fn build_openai_adapter(config: AdapterConfig) -> providers::OpenAiAdapter {
let mut adapter = providers::OpenAiAdapter::new(auth_value(&config.auth_header));
let mut adapter = providers::OpenAiAdapter::new_optional_auth(auth_value_optional(
config.auth_header.as_ref(),
))
.with_name(config.provider_id.clone());
if let Some(base_url) = config.base_url {
adapter = adapter.with_base_url(base_url);
}
@ -101,6 +117,9 @@ fn build_openai_adapter(config: AdapterConfig) -> providers::OpenAiAdapter {
if let Some(project_id) = config.project_id {
adapter = adapter.with_project_id(project_id);
}
if let Some(catalog) = config.catalog {
adapter = adapter.with_catalog(catalog);
}
adapter
}
@ -109,13 +128,19 @@ fn build_openai(config: AdapterConfig) -> Arc<dyn ProviderAdapter> {
}
fn build_gemini_adapter(config: AdapterConfig) -> providers::GeminiAdapter {
let mut adapter = providers::GeminiAdapter::new(auth_value(&config.auth_header));
let mut adapter = providers::GeminiAdapter::new_optional_auth(auth_value_optional(
config.auth_header.as_ref(),
))
.with_name(config.provider_id.clone());
if let Some(base_url) = config.base_url {
adapter = adapter.with_base_url(base_url);
}
if !config.extra_headers.is_empty() {
adapter = adapter.with_default_headers(config.extra_headers);
}
if let Some(catalog) = config.catalog {
adapter = adapter.with_catalog(catalog);
}
adapter
}
@ -132,12 +157,17 @@ fn build_openai_compatible_adapter(config: AdapterConfig) -> providers::OpenAiCo
"openai_compatible adapter requires a base_url; resolve it from provider settings before \
building AdapterConfig",
);
let mut adapter =
providers::OpenAiCompatibleAdapter::new(auth_value(&config.auth_header), base_url)
.with_name(config.provider_id);
let mut adapter = providers::OpenAiCompatibleAdapter::new_optional_auth(
auth_value_optional(config.auth_header.as_ref()),
base_url,
)
.with_name(config.provider_id);
if !config.extra_headers.is_empty() {
adapter = adapter.with_default_headers(config.extra_headers);
}
if let Some(catalog) = config.catalog {
adapter = adapter.with_catalog(catalog);
}
adapter
}
@ -228,12 +258,13 @@ mod tests {
fn openai_compatible_factory_uses_provider_id_for_name() {
let config = AdapterConfig {
provider_id: "kimi".to_string(),
auth_header: ApiKeyHeader::Bearer("k".to_string()),
auth_header: Some(ApiKeyHeader::Bearer("k".to_string())),
base_url: Some("https://api.moonshot.ai/v1".to_string()),
extra_headers: HashMap::new(),
codex_mode: false,
org_id: None,
project_id: None,
catalog: None,
};
let adapter = factory_for("openai_compatible").unwrap()(config);
assert_eq!(adapter.name(), "kimi");
@ -243,7 +274,7 @@ mod tests {
fn openai_compatible_factory_preserves_extra_headers() {
let config = AdapterConfig {
provider_id: "portkey".to_string(),
auth_header: ApiKeyHeader::Bearer("unused-primary-key".to_string()),
auth_header: Some(ApiKeyHeader::Bearer("unused-primary-key".to_string())),
base_url: Some("https://api.portkey.ai/v1".to_string()),
extra_headers: HashMap::from([
(
@ -258,6 +289,7 @@ mod tests {
codex_mode: false,
org_id: None,
project_id: None,
catalog: None,
};
let adapter = build_openai_compatible_adapter(config);
@ -277,10 +309,10 @@ mod tests {
fn anthropic_factory_preserves_extra_headers() {
let config = AdapterConfig {
provider_id: "anthropic-through-portkey".to_string(),
auth_header: ApiKeyHeader::Custom {
auth_header: Some(ApiKeyHeader::Custom {
name: "x-api-key".to_string(),
value: "unused-primary-key".to_string(),
},
}),
base_url: Some("https://api.portkey.ai/v1".to_string()),
extra_headers: HashMap::from([(
"x-portkey-api-key".to_string(),
@ -289,11 +321,12 @@ mod tests {
codex_mode: false,
org_id: None,
project_id: None,
catalog: None,
};
let adapter = build_anthropic_adapter(config);
assert_eq!(adapter.name(), "anthropic");
assert_eq!(adapter.name(), "anthropic-through-portkey");
assert_eq!(
adapter.http.default_headers.get("x-portkey-api-key"),
Some(&"resolved-portkey-key".to_string()),

View file

@ -2,6 +2,8 @@ use std::collections::HashMap;
use std::sync::Arc;
use fabro_auth::{ApiCredential, ApiKeyHeader, CredentialSource};
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::{Catalog, ProviderId};
use tracing::debug;
use crate::adapter_registry::{AdapterConfig, factory_for};
@ -16,6 +18,7 @@ pub struct Client {
providers: HashMap<String, Arc<dyn ProviderAdapter>>,
default_provider: Option<String>,
middleware: Vec<Arc<dyn Middleware>>,
catalog: Option<Arc<Catalog>>,
}
impl Client {
@ -30,6 +33,7 @@ impl Client {
providers,
default_provider,
middleware,
catalog: None,
}
}
@ -47,21 +51,52 @@ impl Client {
Self::from_credentials(resolved.credentials).await
}
pub async fn from_source_with_catalog(
source: &dyn CredentialSource,
catalog: Arc<Catalog>,
) -> Result<Self, Error> {
let resolved =
source
.resolve_for_catalog(&catalog)
.await
.map_err(|err| Error::Configuration {
message: format!("Failed to resolve LLM credentials: {err}"),
source: None,
})?;
Self::from_credentials_with_catalog(resolved.credentials, catalog).await
}
/// Create a Client from typed provider credentials.
///
/// # Errors
///
/// Returns `Error` if any provider adapter fails to initialize.
pub async fn from_credentials(credentials: Vec<ApiCredential>) -> Result<Self, Error> {
let catalog = Arc::new(
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).map_err(
|err| Error::Configuration {
message: "Failed to build bootstrap LLM catalog".to_string(),
source: Some(Arc::new(err)),
},
)?,
);
Self::from_credentials_with_catalog(credentials, catalog).await
}
pub async fn from_credentials_with_catalog(
credentials: Vec<ApiCredential>,
catalog: Arc<Catalog>,
) -> Result<Self, Error> {
let mut client = Self {
providers: HashMap::new(),
default_provider: None,
middleware: Vec::new(),
catalog: Some(Arc::clone(&catalog)),
};
for credential in credentials {
let provider_id = credential.provider.clone();
let Some(provider) = fabro_model::Catalog::builtin().provider(&provider_id) else {
let Some(provider) = catalog.provider(&provider_id) else {
return Err(Error::Configuration {
message: format!(
"Provider \"{provider_id}\" is not supported by credential-only registration"
@ -87,6 +122,7 @@ impl Client {
codex_mode: credential.codex_mode,
org_id: credential.org_id,
project_id: credential.project_id,
catalog: Some(Arc::clone(&catalog)),
});
client.register_provider(adapter).await?;
}
@ -125,11 +161,23 @@ impl Client {
self.middleware.push(mw);
}
fn canonical_provider_name(&self, provider_name: &str) -> String {
self.catalog
.as_ref()
.and_then(|catalog| catalog.provider(&ProviderId::new(provider_name)))
.map_or_else(
|| provider_name.to_string(),
|provider| provider.id.to_string(),
)
}
/// Resolve the provider for a request.
fn resolve_provider(&self, request: &Request) -> Result<Arc<dyn ProviderAdapter>, Error> {
let catalog_provider = fabro_model::Catalog::builtin()
.get(&request.model)
.map(|info| info.provider.to_string());
let catalog_provider = self.catalog.as_ref().and_then(|catalog| {
catalog
.get(&request.model)
.map(|info| info.provider.to_string())
});
let provider_name = request
.provider
@ -140,9 +188,10 @@ impl Client {
message: "No provider specified and no default provider set".into(),
source: None,
})?;
let provider_name = self.canonical_provider_name(provider_name);
self.providers
.get(provider_name)
.get(&provider_name)
.cloned()
.ok_or_else(|| Error::Configuration {
message: format!("Provider '{provider_name}' not registered"),
@ -241,6 +290,11 @@ impl Client {
#[must_use]
pub fn has_provider(&self, name: &str) -> bool {
self.providers.contains_key(name)
|| self
.catalog
.as_ref()
.and_then(|catalog| catalog.provider(&ProviderId::new(name)))
.is_some_and(|provider| self.providers.contains_key(provider.id.as_str()))
}
/// Get the default provider name.
@ -260,6 +314,7 @@ pub(crate) fn auth_value(auth_header: &ApiKeyHeader) -> String {
mod tests {
use async_trait::async_trait;
use fabro_auth::{CredentialSource, ResolvedCredentials};
use fabro_model::catalog::LlmCatalogSettings;
use futures::stream;
use super::*;
@ -352,6 +407,11 @@ mod tests {
credentials: Vec<ApiCredential>,
}
fn catalog_with(overrides: &str) -> Arc<Catalog> {
let settings: LlmCatalogSettings = toml::from_str(overrides).unwrap();
Arc::new(Catalog::from_builtin_with_overrides(&settings).unwrap())
}
#[async_trait]
impl CredentialSource for StubSource {
async fn resolve(&self) -> anyhow::Result<ResolvedCredentials> {
@ -428,10 +488,10 @@ mod tests {
let client = Client::from_credentials(vec![
ApiCredential {
provider: fabro_model::Provider::Anthropic.id(),
auth_header: ApiKeyHeader::Custom {
auth_header: Some(ApiKeyHeader::Custom {
name: "x-api-key".to_string(),
value: "anthropic-key".to_string(),
},
}),
extra_headers: HashMap::new(),
base_url: None,
codex_mode: false,
@ -440,7 +500,7 @@ mod tests {
},
ApiCredential {
provider: fabro_model::Provider::OpenAi.id(),
auth_header: ApiKeyHeader::Bearer("openai-key".to_string()),
auth_header: Some(ApiKeyHeader::Bearer("openai-key".to_string())),
extra_headers: HashMap::new(),
base_url: None,
codex_mode: false,
@ -461,7 +521,7 @@ mod tests {
async fn from_credentials_supports_openai_compatible_provider_constants() {
let client = Client::from_credentials(vec![ApiCredential {
provider: fabro_model::Provider::Kimi.id(),
auth_header: ApiKeyHeader::Bearer("kimi-key".to_string()),
auth_header: Some(ApiKeyHeader::Bearer("kimi-key".to_string())),
extra_headers: HashMap::new(),
base_url: None,
codex_mode: false,
@ -479,7 +539,7 @@ mod tests {
async fn from_credentials_rejects_custom_provider_id_without_adapter() {
let result = Client::from_credentials(vec![ApiCredential {
provider: fabro_model::ProviderId::new("venice"),
auth_header: ApiKeyHeader::Bearer("venice-key".to_string()),
auth_header: Some(ApiKeyHeader::Bearer("venice-key".to_string())),
extra_headers: HashMap::new(),
base_url: None,
codex_mode: false,
@ -505,10 +565,10 @@ mod tests {
let source = StubSource {
credentials: vec![ApiCredential {
provider: fabro_model::Provider::Anthropic.id(),
auth_header: ApiKeyHeader::Custom {
auth_header: Some(ApiKeyHeader::Custom {
name: "x-api-key".to_string(),
value: "anthropic-key".to_string(),
},
}),
extra_headers: HashMap::new(),
base_url: None,
codex_mode: false,
@ -522,6 +582,154 @@ mod tests {
assert_eq!(client.provider_names(), vec!["anthropic"]);
}
#[tokio::test]
async fn from_credentials_with_catalog_registers_custom_openai_compatible_provider() {
let catalog = catalog_with(
r#"
[providers.venice]
display_name = "Venice"
adapter = "openai_compatible"
base_url = "https://api.venice.ai/api/v1"
credentials = ["env:VENICE_API_KEY"]
aliases = ["venice-ai"]
[models."venice-large"]
provider = "venice"
display_name = "Venice Large"
family = "venice"
default = true
[models."venice-large".limits]
context_window = 128000
[models."venice-large".features]
tools = true
vision = false
reasoning = false
effort = false
"#,
);
let client = Client::from_credentials_with_catalog(
vec![ApiCredential {
provider: fabro_model::ProviderId::new("venice"),
auth_header: Some(ApiKeyHeader::Bearer("venice-key".to_string())),
extra_headers: HashMap::new(),
base_url: None,
codex_mode: false,
org_id: None,
project_id: None,
}],
Arc::clone(&catalog),
)
.await
.unwrap();
assert_eq!(client.provider_names(), vec!["venice"]);
assert!(client.has_provider("venice"));
assert!(client.has_provider("venice-ai"));
}
#[tokio::test]
async fn resolve_provider_accepts_catalog_provider_alias() {
let catalog = catalog_with(
r#"
[providers.venice]
display_name = "Venice"
adapter = "openai_compatible"
base_url = "https://api.venice.ai/api/v1"
credentials = ["env:VENICE_API_KEY"]
aliases = ["venice-ai"]
[models."venice-large"]
provider = "venice"
display_name = "Venice Large"
family = "venice"
default = true
[models."venice-large".limits]
context_window = 128000
[models."venice-large".features]
tools = true
vision = false
reasoning = false
effort = false
"#,
);
let client = Client::from_credentials_with_catalog(
vec![ApiCredential {
provider: fabro_model::ProviderId::new("venice"),
auth_header: Some(ApiKeyHeader::Bearer("venice-key".to_string())),
extra_headers: HashMap::new(),
base_url: None,
codex_mode: false,
org_id: None,
project_id: None,
}],
Arc::clone(&catalog),
)
.await
.unwrap();
let mut request = test_request();
request.provider = Some("venice-ai".to_string());
let provider = client.resolve_provider(&request).unwrap();
assert_eq!(provider.name(), "venice");
}
#[tokio::test]
async fn from_credentials_with_catalog_registers_header_only_provider() {
let catalog = catalog_with(
r#"
[providers.portkey]
display_name = "Portkey Bedrock"
adapter = "anthropic"
base_url = "https://api.portkey.ai/v1"
[providers.portkey.extra_headers]
x-portkey-api-key = { literal = "pk-live" }
[models."portkey-claude"]
provider = "portkey"
display_name = "Portkey Claude"
family = "claude"
default = true
[models."portkey-claude".limits]
context_window = 200000
[models."portkey-claude".features]
tools = true
vision = true
reasoning = true
effort = true
"#,
);
let client = Client::from_credentials_with_catalog(
vec![ApiCredential {
provider: fabro_model::ProviderId::new("portkey"),
auth_header: None,
extra_headers: HashMap::from([(
"x-portkey-api-key".to_string(),
"pk-live".to_string(),
)]),
base_url: None,
codex_mode: false,
org_id: None,
project_id: None,
}],
Arc::clone(&catalog),
)
.await
.unwrap();
assert_eq!(client.provider_names(), vec!["portkey"]);
}
#[tokio::test]
async fn from_source_supports_empty_credentials() {
let source = StubSource {

View file

@ -1,5 +1,8 @@
use std::sync::Arc;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use fabro_model::Catalog;
use futures::stream;
use crate::error::{Error, error_from_status_code};
@ -18,14 +21,21 @@ use crate::types::{
pub struct Adapter {
pub(crate) http: super::http_api::HttpApi,
provider_name: String,
catalog: Option<Arc<Catalog>>,
}
impl Adapter {
#[must_use]
pub fn new(api_key: impl Into<String>) -> Self {
Self::new_optional_auth(Some(api_key.into()))
}
#[must_use]
pub fn new_optional_auth(api_key: Option<String>) -> Self {
Self {
http: super::http_api::HttpApi::new(api_key, DEFAULT_BASE_URL),
http: super::http_api::HttpApi::new_optional(api_key, DEFAULT_BASE_URL),
provider_name: "anthropic".to_string(),
catalog: None,
}
}
@ -49,6 +59,12 @@ impl Adapter {
}
}
#[must_use]
pub fn with_catalog(mut self, catalog: Arc<Catalog>) -> Self {
self.catalog = Some(catalog);
self
}
#[must_use]
pub fn with_timeout(self, timeout: AdapterTimeout) -> Self {
Self {
@ -1145,7 +1161,8 @@ async fn build_api_request(
// Check whether this model supports the `output_config.effort` parameter.
// Older reasoning models (e.g. claude-sonnet-4-5) need `thinking` with
// `budget_tokens` instead.
let model_info = fabro_model::Catalog::builtin().get(&request.model);
let model_info = common::catalog_model(adapter.catalog.as_deref(), &request.model)
.or_else(|| Catalog::builtin().get(&request.model));
let supports_effort = model_info.is_none_or(|m| m.features.effort);
let mut resolved_max_tokens = request
@ -1195,7 +1212,7 @@ async fn build_api_request(
let is_fast = request.speed.as_deref() == Some("fast");
let api_request = ApiRequest {
model: request.model.clone(),
model: common::api_model_id(adapter.catalog.as_deref(), &request.model),
messages: api_messages,
max_tokens: resolved_max_tokens,
system: system_value,
@ -1219,9 +1236,10 @@ async fn build_api_request(
}
if adapter.provider_name == "anthropic" {
req_builder = req_builder
.header("x-api-key", &adapter.http.api_key)
.header("anthropic-version", "2023-06-01");
if let Some(api_key) = &adapter.http.api_key {
req_builder = req_builder.header("x-api-key", api_key);
}
req_builder = req_builder.header("anthropic-version", "2023-06-01");
let include_1m_context = model_info.is_some_and(|m| m.context_window() >= 1_000_000);
if let Some(beta_str) = build_beta_header(
@ -1232,8 +1250,8 @@ async fn build_api_request(
) {
req_builder = req_builder.header("anthropic-beta", beta_str);
}
} else {
req_builder = req_builder.bearer_auth(&adapter.http.api_key);
} else if let Some(api_key) = &adapter.http.api_key {
req_builder = req_builder.bearer_auth(api_key);
}
let req_builder = req_builder.json(&merge_provider_options(

View file

@ -1,6 +1,7 @@
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use fabro_http::HeaderMap;
use fabro_model::{Catalog, Model};
use fabro_static::EnvVars;
use tokio::{fs, time};
use tracing::warn;
@ -8,6 +9,18 @@ use tracing::warn;
use crate::error::{Error, error_from_status_code};
use crate::types::{Message, RateLimitInfo, Role};
#[must_use]
pub fn catalog_model<'a>(catalog: Option<&'a Catalog>, model: &str) -> Option<&'a Model> {
catalog.and_then(|catalog| catalog.get(model))
}
#[must_use]
pub fn api_model_id(catalog: Option<&Catalog>, model: &str) -> String {
catalog
.and_then(|catalog| catalog.model_settings(model))
.map_or_else(|| model.to_string(), |settings| settings.api_id.clone())
}
/// Parse an error response body, extracting the message and error code.
///
/// `error_code_field` is the JSON field name for the error code (e.g. "type" or

View file

@ -1,6 +1,9 @@
use std::sync::Arc;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use fabro_http::HeaderMap;
use fabro_model::Catalog;
use futures::stream;
use crate::error::{
@ -22,16 +25,31 @@ const DEFAULT_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta
/// Provider adapter for the Google Gemini `generateContent` API.
pub struct Adapter {
pub(crate) http: super::http_api::HttpApi,
provider_name: String,
catalog: Option<Arc<Catalog>>,
}
impl Adapter {
#[must_use]
pub fn new(api_key: impl Into<String>) -> Self {
Self::new_optional_auth(Some(api_key.into()))
}
#[must_use]
pub fn new_optional_auth(api_key: Option<String>) -> Self {
Self {
http: super::http_api::HttpApi::new(api_key, DEFAULT_BASE_URL),
http: super::http_api::HttpApi::new_optional(api_key, DEFAULT_BASE_URL),
provider_name: "gemini".to_string(),
catalog: None,
}
}
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.provider_name = name.into();
self
}
#[must_use]
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.http.base_url = base_url.into();
@ -42,13 +60,21 @@ impl Adapter {
pub fn with_default_headers(self, headers: std::collections::HashMap<String, String>) -> Self {
Self {
http: self.http.with_default_headers(headers),
..self
}
}
#[must_use]
pub fn with_catalog(mut self, catalog: Arc<Catalog>) -> Self {
self.catalog = Some(catalog);
self
}
#[must_use]
pub fn with_timeout(self, timeout: AdapterTimeout) -> Self {
Self {
http: self.http.with_timeout(timeout),
..self
}
}
}
@ -894,8 +920,8 @@ impl SseStreamState {
#[async_trait::async_trait]
impl ProviderAdapter for Adapter {
fn name(&self) -> &'static str {
"gemini"
fn name(&self) -> &str {
&self.provider_name
}
async fn complete(&self, request: &Request) -> Result<Response, Error> {
@ -904,16 +930,16 @@ impl ProviderAdapter for Adapter {
}
let api_body = build_api_request(request).await;
let api_model = common::api_model_id(self.catalog.as_deref(), &request.model);
let url = format!(
"{}/models/{}:generateContent",
self.http.base_url, request.model
self.http.base_url, api_model
);
let mut req = self
.http
.client
.post(&url)
.header("x-goog-api-key", &self.http.api_key);
let mut req = self.http.client.post(&url);
if let Some(api_key) = &self.http.api_key {
req = req.header("x-goog-api-key", api_key);
}
for (key, value) in &self.http.default_headers {
req = req.header(key, value);
}
@ -953,7 +979,7 @@ impl ProviderAdapter for Adapter {
Ok(Response {
id: uuid::Uuid::new_v4().to_string(),
model: request.model.clone(),
provider: "gemini".to_string(),
provider: self.provider_name.clone(),
message: Message {
role: Role::Assistant,
content: content_parts,
@ -974,16 +1000,16 @@ impl ProviderAdapter for Adapter {
}
let api_body = build_api_request(request).await;
let api_model = common::api_model_id(self.catalog.as_deref(), &request.model);
let url = format!(
"{}/models/{}:streamGenerateContent?alt=sse",
self.http.base_url, request.model
self.http.base_url, api_model
);
let mut req = self
.http
.client
.post(&url)
.header("x-goog-api-key", &self.http.api_key);
let mut req = self.http.client.post(&url);
if let Some(api_key) = &self.http.api_key {
req = req.header("x-goog-api-key", api_key);
}
for (key, value) in &self.http.default_headers {
req = req.header(key, value);
}

View file

@ -9,7 +9,7 @@ use crate::types::AdapterTimeout;
/// configuration that every provider needs. Provider-specific fields live on
/// the adapter struct itself.
pub struct HttpApi {
pub(crate) api_key: String,
pub(crate) api_key: Option<String>,
pub(crate) base_url: String,
pub(crate) default_headers: HashMap<String, String>,
pub(crate) client: fabro_http::HttpClient,
@ -27,10 +27,15 @@ impl HttpApi {
#[must_use]
pub fn new(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
Self::new_optional(Some(api_key.into()), base_url)
}
#[must_use]
pub fn new_optional(api_key: Option<String>, base_url: impl Into<String>) -> Self {
let timeout = AdapterTimeout::default();
let client = Self::build_client(timeout);
Self {
api_key: api_key.into(),
api_key,
base_url: base_url.into(),
default_headers: HashMap::new(),
client,

View file

@ -1,5 +1,8 @@
use std::sync::Arc;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use fabro_model::Catalog;
use futures::{StreamExt, stream};
use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind, error_from_status_code};
@ -25,6 +28,8 @@ pub struct Adapter {
pub(crate) http: super::http_api::HttpApi,
org_id: Option<String>,
project_id: Option<String>,
provider_name: String,
catalog: Option<Arc<Catalog>>,
/// When true, always use streaming (required by the Codex endpoint).
codex_mode: bool,
}
@ -32,14 +37,27 @@ pub struct Adapter {
impl Adapter {
#[must_use]
pub fn new(api_key: impl Into<String>) -> Self {
Self::new_optional_auth(Some(api_key.into()))
}
#[must_use]
pub fn new_optional_auth(api_key: Option<String>) -> Self {
Self {
http: super::http_api::HttpApi::new(api_key, DEFAULT_BASE_URL),
org_id: None,
project_id: None,
codex_mode: false,
http: super::http_api::HttpApi::new_optional(api_key, DEFAULT_BASE_URL),
org_id: None,
project_id: None,
provider_name: "openai".to_string(),
catalog: None,
codex_mode: false,
}
}
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.provider_name = name.into();
self
}
#[must_use]
pub fn with_codex_mode(mut self) -> Self {
self.codex_mode = true;
@ -72,6 +90,12 @@ impl Adapter {
}
}
#[must_use]
pub fn with_catalog(mut self, catalog: Arc<Catalog>) -> Self {
self.catalog = Some(catalog);
self
}
#[must_use]
pub fn with_timeout(self, timeout: AdapterTimeout) -> Self {
Self {
@ -88,7 +112,9 @@ impl Adapter {
for (key, value) in &self.http.default_headers {
req = req.header(key, value);
}
req = req.bearer_auth(&self.http.api_key);
if let Some(api_key) = &self.http.api_key {
req = req.bearer_auth(api_key);
}
if let Some(org_id) = &self.org_id {
req = req.header("OpenAI-Organization", org_id);
}
@ -471,7 +497,12 @@ fn translate_response_format(format: &ResponseFormat) -> Option<serde_json::Valu
/// When `codex_mode` is true, unsupported fields (`temperature`,
/// `max_output_tokens`, `top_p`) are omitted and empty instructions are sent as
/// `""` (required by the Codex endpoint).
async fn build_api_request(request: &Request, stream: bool, codex_mode: bool) -> ApiRequest {
async fn build_api_request(
request: &Request,
stream: bool,
codex_mode: bool,
catalog: Option<&Catalog>,
) -> ApiRequest {
let (instructions, input) = translate_input(&request.messages).await;
let api_tools = request.tools.as_ref().map(|t| translate_tools(t));
let tool_choice = request.tool_choice.as_ref().map(translate_tool_choice);
@ -493,7 +524,7 @@ async fn build_api_request(request: &Request, stream: bool, codex_mode: bool) ->
};
ApiRequest {
model: request.model.clone(),
model: common::api_model_id(catalog, &request.model),
input,
instructions,
temperature: if codex_mode {
@ -520,12 +551,22 @@ async fn build_api_request(request: &Request, stream: bool, codex_mode: bool) ->
/// Serialize an `ApiRequest` to JSON and merge any `provider_options.openai`
/// keys into it.
#[cfg(test)]
async fn build_request_body(
request: &Request,
stream: bool,
codex_mode: bool,
) -> serde_json::Value {
let api_request = build_api_request(request, stream, codex_mode).await;
build_request_body_with_catalog(request, stream, codex_mode, None).await
}
async fn build_request_body_with_catalog(
request: &Request,
stream: bool,
codex_mode: bool,
catalog: Option<&Catalog>,
) -> serde_json::Value {
let api_request = build_api_request(request, stream, codex_mode, catalog).await;
let mut body = serde_json::to_value(&api_request).unwrap_or_else(|_| serde_json::json!({}));
if let Some(openai_opts) = request
@ -1024,8 +1065,8 @@ fn handle_response_completed(
#[async_trait::async_trait]
impl ProviderAdapter for Adapter {
fn name(&self) -> &'static str {
"openai"
fn name(&self) -> &str {
&self.provider_name
}
async fn complete(&self, request: &Request) -> Result<Response, Error> {
@ -1037,7 +1078,8 @@ impl ProviderAdapter for Adapter {
if let Some(tc) = &request.tool_choice {
validate_tool_choice(self, tc)?;
}
let request_body = build_request_body(request, false, false).await;
let request_body =
build_request_body_with_catalog(request, false, false, self.catalog.as_deref()).await;
let url = format!("{}/responses", self.http.base_url);
let mut req = self.build_request(&url).json(&request_body);
@ -1076,7 +1118,13 @@ impl ProviderAdapter for Adapter {
if let Some(tc) = &request.tool_choice {
validate_tool_choice(self, tc)?;
}
let request_body = build_request_body(request, true, self.codex_mode).await;
let request_body = build_request_body_with_catalog(
request,
true,
self.codex_mode,
self.catalog.as_deref(),
)
.await;
let url = format!("{}/responses", self.http.base_url);
let http_resp = self

View file

@ -1,9 +1,13 @@
use std::sync::Arc;
use fabro_model::Catalog;
use futures::{StreamExt, stream};
use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind, error_from_status_code};
use crate::provider::{ProviderAdapter, StreamEventStream, validate_tool_choice};
use crate::providers::common::{
parse_error_body, parse_rate_limit_headers, parse_retry_after, send_and_read_response,
api_model_id, parse_error_body, parse_rate_limit_headers, parse_retry_after,
send_and_read_response,
};
use crate::types::{
AdapterTimeout, ContentPart, FinishReason, Message, RateLimitInfo, Request, Response,
@ -21,14 +25,21 @@ use crate::types::{
pub struct Adapter {
pub(crate) http: super::http_api::HttpApi,
provider_name: String,
catalog: Option<Arc<Catalog>>,
}
impl Adapter {
#[must_use]
pub fn new(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
Self::new_optional_auth(Some(api_key.into()), base_url)
}
#[must_use]
pub fn new_optional_auth(api_key: Option<String>, base_url: impl Into<String>) -> Self {
Self {
http: super::http_api::HttpApi::new(api_key, base_url),
http: super::http_api::HttpApi::new_optional(api_key, base_url),
provider_name: "openai-compatible".to_string(),
catalog: None,
}
}
@ -46,6 +57,12 @@ impl Adapter {
}
}
#[must_use]
pub fn with_catalog(mut self, catalog: Arc<Catalog>) -> Self {
self.catalog = Some(catalog);
self
}
#[must_use]
pub fn with_timeout(self, timeout: AdapterTimeout) -> Self {
Self {
@ -61,7 +78,10 @@ impl Adapter {
for (key, value) in &self.http.default_headers {
req = req.header(key, value);
}
req.bearer_auth(&self.http.api_key)
if let Some(api_key) = &self.http.api_key {
req = req.bearer_auth(api_key);
}
req
}
}
@ -393,10 +413,20 @@ fn translate_response_format(format: &ResponseFormat) -> serde_json::Value {
///
/// Returns a `serde_json::Value` so that `provider_options.<provider_name>`
/// fields can be merged into the request before sending.
#[cfg(test)]
fn build_api_request(
request: &Request,
stream: Option<bool>,
provider_name: &str,
) -> serde_json::Value {
build_api_request_with_catalog(request, stream, provider_name, None)
}
fn build_api_request_with_catalog(
request: &Request,
stream: Option<bool>,
provider_name: &str,
catalog: Option<&Catalog>,
) -> serde_json::Value {
let chat_messages = translate_messages(&request.messages);
let tools = request.tools.as_ref().map(|t| translate_tools(t));
@ -407,7 +437,7 @@ fn build_api_request(
.map(translate_response_format);
let api_request = ApiRequest {
model: request.model.clone(),
model: api_model_id(catalog, &request.model),
messages: chat_messages,
temperature: request.temperature,
max_tokens: request.max_tokens,
@ -460,7 +490,12 @@ impl ProviderAdapter for Adapter {
if let Some(tc) = &request.tool_choice {
validate_tool_choice(self, tc)?;
}
let api_body = build_api_request(request, None, &self.provider_name);
let api_body = build_api_request_with_catalog(
request,
None,
&self.provider_name,
self.catalog.as_deref(),
);
let url = format!("{}/chat/completions", self.http.base_url);
let mut req = self.build_request(&url).json(&api_body);
@ -538,7 +573,12 @@ impl ProviderAdapter for Adapter {
if let Some(tc) = &request.tool_choice {
validate_tool_choice(self, tc)?;
}
let api_body = build_api_request(request, Some(true), &self.provider_name);
let api_body = build_api_request_with_catalog(
request,
Some(true),
&self.provider_name,
self.catalog.as_deref(),
);
let url = format!("{}/chat/completions", self.http.base_url);
let http_resp = self
@ -913,6 +953,8 @@ impl StreamState {
#[cfg(test)]
mod tests {
use fabro_model::catalog::LlmCatalogSettings;
use super::*;
use crate::types::{AudioData, DocumentData};
@ -1312,6 +1354,44 @@ mod tests {
assert!(body.get("stream").is_none());
}
#[test]
fn catalog_api_id_is_used_for_provider_request_body() {
let settings: LlmCatalogSettings = toml::from_str(
r#"
[providers.venice]
display_name = "Venice"
adapter = "openai_compatible"
base_url = "https://api.venice.ai/api/v1"
credentials = ["env:VENICE_API_KEY"]
[models."venice-large"]
provider = "venice"
api_id = "venice/model-large"
display_name = "Venice Large"
family = "venice"
default = true
[models."venice-large".limits]
context_window = 128000
[models."venice-large".features]
tools = true
vision = false
reasoning = false
effort = false
"#,
)
.unwrap();
let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap();
let mut request = minimal_request();
request.model = "venice-large".to_string();
let body = build_api_request_with_catalog(&request, None, "venice", Some(&catalog));
assert_eq!(request.model, "venice-large");
assert_eq!(body["model"], "venice/model-large");
}
#[test]
fn provider_options_matching_name_merged() {
let mut request = minimal_request();

View file

@ -435,8 +435,11 @@ async fn build_preflight_report(
));
}
let configured_providers = state.llm_source.configured_providers().await;
let catalog = state.catalog();
let configured_providers = state
.llm_source
.configured_providers_for_catalog(catalog.as_ref())
.await;
let materialized = materialize_run(
prepared.settings.clone(),
graph,

View file

@ -700,7 +700,7 @@ impl AppState {
}
pub(crate) async fn resolve_llm_client(&self) -> anyhow::Result<LlmClientResult> {
resolve_llm_client_from_source(self.llm_source.as_ref()).await
resolve_llm_client_from_source(self.llm_source.as_ref(), self.catalog()).await
}
pub(crate) fn vault_or_env(&self, name: &str) -> Option<String> {
@ -863,12 +863,13 @@ impl AppState {
async fn resolve_llm_client_from_source(
source: &dyn CredentialSource,
catalog: Arc<Catalog>,
) -> anyhow::Result<LlmClientResult> {
let resolved = source
.resolve()
.resolve_for_catalog(catalog.as_ref())
.await
.context("resolving LLM credentials")?;
let client = LlmClient::from_credentials(resolved.credentials)
let client = LlmClient::from_credentials_with_catalog(resolved.credentials, catalog)
.await
.context("creating LLM client")?;
@ -3033,6 +3034,7 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
github_app,
github_permissions,
vault: Some(Arc::clone(&state.vault)),
catalog: state.catalog(),
on_node: None,
registry_override,
};

View file

@ -40,14 +40,14 @@ async fn list_models(
let query = params.query.as_ref().map(|value| value.to_lowercase());
let limit = params.limit.clamp(1, 100) as usize;
let offset = params.offset.min(MAX_PAGE_OFFSET) as usize;
let catalog = state.catalog();
let configured: HashSet<ProviderId> = state
.llm_source
.configured_providers()
.configured_providers_for_catalog(catalog.as_ref())
.await
.into_iter()
.collect();
let catalog = state.catalog();
let mut models = catalog
.list(provider_id.as_ref())
.into_iter()

View file

@ -247,13 +247,14 @@ async fn create_run_pull_request(
let model = if let Some(model) = body.model {
model
} else {
let configured = state.llm_source.configured_providers().await;
state
.catalog()
.default_for_configured_ids(&configured)
.id
.clone()
let catalog = state.catalog();
let configured = state
.llm_source
.configured_providers_for_catalog(catalog.as_ref())
.await;
catalog.default_for_configured_ids(&configured).id.clone()
};
let catalog = state.catalog();
let run_store_handle = run_store.clone().into();
let request = pull_request::OpenPullRequestRequest {
@ -268,6 +269,7 @@ async fn create_run_pull_request(
auto_merge: None,
run_store: &run_store_handle,
llm_source: state.llm_source.as_ref(),
catalog,
conclusion: Some(inputs.conclusion),
run_state: Some(&run_state),
};

View file

@ -402,7 +402,11 @@ async fn create_run(
info!(run_id = %run_id, "Run created");
let web_url = state.run_web_url(&run_id);
let configured_providers = state.llm_source.configured_providers().await;
let catalog = state.catalog();
let configured_providers = state
.llm_source
.configured_providers_for_catalog(catalog.as_ref())
.await;
let mut create_input =
run_manifest::create_run_input(prepared.clone(), configured_providers, web_url.clone());
create_input.run_id = Some(run_id);
@ -419,10 +423,11 @@ async fn create_run(
.into_response();
}
};
let created = match Box::pin(operations::create(
let created = match Box::pin(operations::create_with_catalog(
state.store.as_ref(),
create_input,
storage_root,
catalog,
))
.await
{

View file

@ -17,7 +17,7 @@ use fabro_interview::{
};
use fabro_llm::types::{Message as LlmMessage, Request as LlmRequest};
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::{ModelRef, Provider};
use fabro_model::{Catalog, ModelRef, Provider};
use fabro_types::settings::ServerAuthMethod;
use fabro_types::{
AttrValue, AuthMethod, CommandTermination, FailureCategory, FailureDetail, Graph,
@ -93,6 +93,13 @@ fn spa_fixture_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/spa")
}
fn state_test_catalog() -> Arc<Catalog> {
Arc::new(
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
.expect("default catalog should build"),
)
}
fn test_app_with_scheduler(state: Arc<AppState>) -> Router {
spawn_scheduler(Arc::clone(&state));
crate::test_support::build_test_router(state)
@ -1227,7 +1234,8 @@ impl CredentialSource for FailingCredentialSource {
#[tokio::test]
async fn resolve_llm_client_from_source_preserves_credential_source_chain() {
let Err(err) = resolve_llm_client_from_source(&FailingCredentialSource).await else {
let catalog = state_test_catalog();
let Err(err) = resolve_llm_client_from_source(&FailingCredentialSource, catalog).await else {
panic!("expected credential resolution to fail");
};
let chain = err.chain().map(ToString::to_string).collect::<Vec<_>>();
@ -1264,9 +1272,14 @@ async fn llm_source_configured_providers_reads_openai_codex_from_vault() {
)
.unwrap();
assert_eq!(state.llm_source.configured_providers().await, vec![
Provider::OpenAi.id()
]);
let catalog = state.catalog();
assert_eq!(
state
.llm_source
.configured_providers_for_catalog(catalog.as_ref())
.await,
vec![Provider::OpenAi.id()]
);
}
#[tokio::test]

View file

@ -13,7 +13,8 @@ use fabro_graphviz::graph::Node;
use fabro_llm::client::Client;
use fabro_llm::types::{Message, Request, TokenCounts};
use fabro_mcp::config::McpServerSettings;
use fabro_model::{FallbackTarget, Provider};
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::{AgentProfileKind, Catalog, FallbackTarget, Provider, ProviderId, adapter};
use fabro_types::{SessionCapability, StageId};
use tokio::sync::Mutex as TokioMutex;
use tokio::task::JoinHandle;
@ -98,6 +99,13 @@ enum AgentApiErrorDisposition {
Terminal(Error),
}
#[derive(Clone)]
struct ProviderContext {
provider: Provider,
provider_id: ProviderId,
profile_kind: AgentProfileKind,
}
fn classify_agent_error(err: fabro_agent::Error, allow_failover: bool) -> AgentApiErrorDisposition {
match err {
fabro_agent::Error::Interrupted(fabro_agent::InterruptReason::Cancelled) => {
@ -128,7 +136,7 @@ fn begin_session_lifecycle(
emitter.emit(&Event::AgentSessionStarted {
session_id: session.id().to_string(),
parent_session_id,
provider: Some(session.provider().to_string()),
provider: Some(session.provider_id().to_string()),
model: Some(session.model().to_string()),
});
}
@ -150,19 +158,57 @@ fn discard_session(
}
}
fn build_profile(model: &str, provider: Provider) -> Box<dyn AgentProfile> {
fn build_profile(
model: &str,
provider: Provider,
provider_id: ProviderId,
profile_kind: AgentProfileKind,
) -> Box<dyn AgentProfile> {
match profile_kind {
AgentProfileKind::OpenAi => Box::new(
OpenAiProfile::new(model)
.with_provider(provider)
.with_provider_id(provider_id),
),
AgentProfileKind::Gemini => Box::new(
GeminiProfile::new(model)
.with_provider(provider)
.with_provider_id(provider_id),
),
AgentProfileKind::Anthropic => Box::new(
AnthropicProfile::new(model)
.with_provider(provider)
.with_provider_id(provider_id),
),
}
}
fn default_profile_kind(provider: Provider) -> AgentProfileKind {
match provider {
Provider::OpenAi => Box::new(OpenAiProfile::new(model)),
Provider::Kimi
Provider::Anthropic => AgentProfileKind::Anthropic,
Provider::Gemini => AgentProfileKind::Gemini,
Provider::OpenAi
| Provider::Kimi
| Provider::Zai
| Provider::Minimax
| Provider::Inception
| Provider::OpenAiCompatible => Box::new(OpenAiProfile::new(model).with_provider(provider)),
Provider::Gemini => Box::new(GeminiProfile::new(model)),
Provider::Anthropic => Box::new(AnthropicProfile::new(model)),
| Provider::OpenAiCompatible => AgentProfileKind::OpenAi,
}
}
fn profile_provider_for_catalog_provider(
provider_id: &ProviderId,
profile_kind: AgentProfileKind,
adapter: &str,
) -> Provider {
Provider::from_id(provider_id).unwrap_or(match (profile_kind, adapter) {
(AgentProfileKind::Anthropic, _) => Provider::Anthropic,
(AgentProfileKind::Gemini, _) => Provider::Gemini,
(AgentProfileKind::OpenAi, "openai_compatible") => Provider::OpenAiCompatible,
(AgentProfileKind::OpenAi, _) => Provider::OpenAi,
})
}
/// Shared state for tracking file modifications from agent tool calls.
struct FileTracking {
/// Maps tool_call_id → file_path for in-flight write/edit calls.
@ -249,12 +295,15 @@ fn spawn_event_forwarder(
pub struct AgentApiBackend {
model: String,
provider: Provider,
provider_id: ProviderId,
profile_kind: AgentProfileKind,
fallback_chain: Vec<FallbackTarget>,
sessions: Mutex<HashMap<String, Session>>,
tool_env: Option<Arc<dyn ToolEnvProvider>>,
mcp_servers: Vec<McpServerSettings>,
source: Arc<dyn CredentialSource>,
steering_hub: Arc<SteeringHub>,
catalog: Arc<Catalog>,
}
impl AgentApiBackend {
@ -265,16 +314,46 @@ impl AgentApiBackend {
fallback_chain: Vec<FallbackTarget>,
source: Arc<dyn CredentialSource>,
steering_hub: Arc<SteeringHub>,
) -> Self {
let catalog = Arc::new(
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
.expect("default catalog should build"),
);
Self::new_with_catalog(
model,
provider,
provider.id(),
default_profile_kind(provider),
fallback_chain,
source,
steering_hub,
catalog,
)
}
#[must_use]
pub fn new_with_catalog(
model: String,
provider: Provider,
provider_id: ProviderId,
profile_kind: AgentProfileKind,
fallback_chain: Vec<FallbackTarget>,
source: Arc<dyn CredentialSource>,
steering_hub: Arc<SteeringHub>,
catalog: Arc<Catalog>,
) -> Self {
Self {
model,
provider,
provider_id,
profile_kind,
fallback_chain,
sessions: Mutex::new(HashMap::new()),
tool_env: None,
mcp_servers: Vec::new(),
source,
steering_hub,
catalog,
}
}
@ -312,6 +391,51 @@ impl AgentApiBackend {
self
}
fn resolve_provider_context(
&self,
model: &str,
provider_attr: Option<&str>,
) -> Result<ProviderContext, Error> {
let provider_id = if let Some(provider) = provider_attr {
let requested = ProviderId::from(provider);
self.catalog
.provider(&requested)
.ok_or_else(|| {
Error::Precondition(format!("Provider \"{provider}\" is not configured"))
})?
.id
.clone()
} else if let Some(model) = self.catalog.get(model) {
model.provider.clone()
} else {
self.provider_id.clone()
};
let Some(provider) = self.catalog.provider(&provider_id) else {
return Ok(ProviderContext {
provider: self.provider,
provider_id: self.provider_id.clone(),
profile_kind: self.profile_kind,
});
};
let profile_kind = adapter::get(&provider.adapter)
.map(|metadata| metadata.default_profile)
.ok_or_else(|| {
Error::Precondition(format!(
"Provider \"{provider_id}\" uses unknown adapter \"{}\"",
provider.adapter,
))
})?;
Ok(ProviderContext {
provider: profile_provider_for_catalog_provider(
&provider.id,
profile_kind,
&provider.adapter,
),
provider_id: provider.id.clone(),
profile_kind,
})
}
async fn create_session(
&self,
node: &Node,
@ -319,16 +443,14 @@ impl AgentApiBackend {
tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
) -> Result<Session, Error> {
let model = node.model().unwrap_or(&self.model);
let provider = node
.provider()
.and_then(|p| p.parse::<Provider>().ok())
.unwrap_or(self.provider);
let provider = self.resolve_provider_context(model, node.provider())?;
Self::create_session_for(
model,
provider,
node,
sandbox,
self.source.as_ref(),
Arc::clone(&self.catalog),
self.tool_env.as_ref(),
tool_hooks,
self.mcp_servers.clone(),
@ -338,19 +460,25 @@ impl AgentApiBackend {
async fn create_session_for(
model: &str,
provider: Provider,
provider: ProviderContext,
node: &Node,
sandbox: &Arc<dyn Sandbox>,
source: &dyn CredentialSource,
catalog: Arc<Catalog>,
tool_env: Option<&Arc<dyn ToolEnvProvider>>,
tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
mcp_servers: Vec<McpServerSettings>,
) -> Result<Session, Error> {
let client = Client::from_source(source)
let client = Client::from_source_with_catalog(source, catalog)
.await
.map_err(|e| Error::handler_with_source("Failed to create LLM client", &e))?;
let mut profile = build_profile(model, provider);
let mut profile = build_profile(
model,
provider.provider,
provider.provider_id.clone(),
provider.profile_kind,
);
let config = SessionOptions {
max_tokens: node.max_tokens(),
@ -369,21 +497,16 @@ impl AgentApiBackend {
// Build factory that creates child sessions WITHOUT subagent tools
let factory_client = client.clone();
let factory_model = model.to_string();
let factory_provider = provider.clone();
let factory_env = Arc::clone(sandbox);
let factory_tool_env = tool_env.cloned();
let factory: SessionFactory = Arc::new(move || {
let child_profile: Arc<dyn AgentProfile> = match provider {
Provider::OpenAi => Arc::new(OpenAiProfile::new(&factory_model)),
Provider::Kimi
| Provider::Zai
| Provider::Minimax
| Provider::Inception
| Provider::OpenAiCompatible => {
Arc::new(OpenAiProfile::new(&factory_model).with_provider(provider))
}
Provider::Gemini => Arc::new(GeminiProfile::new(&factory_model)),
Provider::Anthropic => Arc::new(AnthropicProfile::new(&factory_model)),
};
let child_profile: Arc<dyn AgentProfile> = Arc::from(build_profile(
&factory_model,
factory_provider.provider,
factory_provider.provider_id.clone(),
factory_provider.profile_kind,
));
let mut session = Session::new(
factory_client.clone(),
child_profile,
@ -435,7 +558,7 @@ impl AgentApiBackend {
stage_id: stage_id.clone(),
session_id: session.id().to_string(),
thread_id: thread_id.map(str::to_string),
provider: Some(session.provider().to_string()),
provider: Some(session.provider_id().to_string()),
model: Some(session.model().to_string()),
capabilities: vec![SessionCapability::Steer],
hub: Arc::clone(&self.steering_hub),
@ -483,21 +606,18 @@ impl CodergenBackend for AgentApiBackend {
let emitter = request.emitter;
let stage_scope = request.stage_scope;
let client = Client::from_source(self.source.as_ref())
.await
.map_err(|e| Error::handler_with_source("Failed to create LLM client", &e))?;
let client =
Client::from_source_with_catalog(self.source.as_ref(), Arc::clone(&self.catalog))
.await
.map_err(|e| Error::handler_with_source("Failed to create LLM client", &e))?;
let model = node.model().unwrap_or(&self.model);
let provider = node
.provider()
.map(String::from)
.or_else(|| Some(self.provider.to_string()));
let provider = self.resolve_provider_context(model, node.provider())?;
let provider_id = provider.provider_id.to_string();
let max_tokens = node.max_tokens().or_else(|| {
fabro_model::Catalog::builtin()
.get(model)
.and_then(|m| m.limits.max_output)
});
let max_tokens = node
.max_tokens()
.or_else(|| self.catalog.get(model).and_then(|m| m.limits.max_output));
let mut messages = Vec::new();
if let Some(sys) = system_prompt {
@ -508,7 +628,7 @@ impl CodergenBackend for AgentApiBackend {
let request = Request {
model: model.to_string(),
messages,
provider,
provider: Some(provider_id),
reasoning_effort: node.reasoning_effort().parse().ok(),
speed: node.speed().map(String::from),
tools: None,
@ -532,7 +652,7 @@ impl CodergenBackend for AgentApiBackend {
let result = client.complete(&request).await;
let default_provider = self.provider.to_string();
let default_provider = self.provider_id.to_string();
let (response, actual_model, actual_provider) = match result {
Ok(resp) => (
@ -568,7 +688,7 @@ impl CodergenBackend for AgentApiBackend {
);
let max_tokens = node.max_tokens().or_else(|| {
fabro_model::Catalog::builtin()
self.catalog
.get(&target.model)
.and_then(|m| m.limits.max_output)
});
@ -762,7 +882,7 @@ impl CodergenBackend for AgentApiBackend {
}
AgentApiErrorDisposition::FailoverEligible(sdk_err) => {
let error_msg = sdk_err.to_string();
let from_provider = self.provider.to_string();
let from_provider = self.provider_id.to_string();
let from_model = self.model.clone();
let mut last_err = Error::Llm(sdk_err);
@ -784,9 +904,10 @@ impl CodergenBackend for AgentApiBackend {
&stage_scope,
);
let target_provider: Provider = match target.provider.parse() {
Ok(p) => p,
Err(_) => continue,
let Ok(target_provider) =
self.resolve_provider_context(&target.model, Some(&target.provider))
else {
continue;
};
if cancel_token.is_cancelled() {
@ -798,6 +919,7 @@ impl CodergenBackend for AgentApiBackend {
node,
sandbox,
self.source.as_ref(),
Arc::clone(&self.catalog),
self.tool_env.as_ref(),
tool_hooks.clone(),
self.mcp_servers.clone(),
@ -995,7 +1117,7 @@ impl CompletionCoordinator for SteeringCompletionCoordinator {
mod tests {
use fabro_agent::subagent::SessionFactory;
use fabro_agent::{AgentProfile, ToolRegistry};
use fabro_auth::{AuthCredential, AuthDetails, VaultCredentialSource};
use fabro_auth::{AuthCredential, AuthDetails, EnvCredentialSource, VaultCredentialSource};
use fabro_llm::provider::{ProviderAdapter, StreamEventStream};
use fabro_llm::{Error as LlmError, ProviderErrorDetail, ProviderErrorKind};
use fabro_vault::{SecretType, Vault};
@ -1196,7 +1318,12 @@ mod tests {
#[test]
fn build_profile_can_register_subagent_tools() {
let mut profile = build_profile("claude-opus-4-6", Provider::Anthropic);
let mut profile = build_profile(
"claude-opus-4-6",
Provider::Anthropic,
Provider::Anthropic.id(),
AgentProfileKind::Anthropic,
);
let manager = Arc::new(TokioMutex::new(SubAgentManager::new(1)));
let factory: SessionFactory = Arc::new(|| {
panic!("factory should not be called in this test");
@ -1210,6 +1337,55 @@ mod tests {
assert!(names.contains(&"close_agent".to_string()));
}
#[test]
fn api_backend_resolves_custom_catalog_provider_profile() {
let settings: LlmCatalogSettings = toml::from_str(
r#"
[providers.venice]
adapter = "openai_compatible"
base_url = "https://api.venice.ai/api/v1"
credentials = ["env:VENICE_API_KEY"]
[models.venice-llama]
provider = "venice"
display_name = "Venice Llama"
family = "llama"
training = "2026-01"
default = true
[models.venice-llama.limits]
context_window = 131072
max_output = 8192
[models.venice-llama.features]
tools = true
vision = false
reasoning = false
effort = false
"#,
)
.unwrap();
let catalog = Arc::new(Catalog::from_builtin_with_overrides(&settings).unwrap());
let backend = AgentApiBackend::new_with_catalog(
"venice-llama".to_string(),
Provider::OpenAiCompatible,
ProviderId::from("venice"),
AgentProfileKind::OpenAi,
Vec::new(),
Arc::new(EnvCredentialSource::new()),
SteeringHub::for_tests(),
catalog,
);
let provider = backend
.resolve_provider_context("venice-llama", None)
.unwrap();
assert_eq!(provider.provider_id, ProviderId::from("venice"));
assert_eq!(provider.profile_kind, AgentProfileKind::OpenAi);
assert_eq!(provider.provider, Provider::OpenAiCompatible);
}
#[tokio::test]
async fn api_backend_uses_source_credentials() {
let dir = tempfile::tempdir().unwrap();
@ -1239,7 +1415,10 @@ mod tests {
SteeringHub::for_tests(),
);
let client = Client::from_source(backend.source.as_ref()).await.unwrap();
let client =
Client::from_source_with_catalog(backend.source.as_ref(), Arc::clone(&backend.catalog))
.await
.unwrap();
assert_eq!(client.provider_names(), vec!["anthropic"]);
}

View file

@ -585,6 +585,8 @@ mod tests {
use fabro_core::lifecycle::RunLifecycle;
use fabro_core::state::ExecutionState;
use fabro_graphviz::graph::types::{AttrValue, Edge, Graph, Node};
use fabro_model::Catalog;
use fabro_model::catalog::LlmCatalogSettings;
use fabro_store::{Database, EventEnvelope, RunDatabase, RunProjection};
use fabro_types::run_event::{MetadataSnapshotFailureKind, MetadataSnapshotPhase};
use fabro_types::{EventBody, RunBlobId, RunEvent, WorkflowSettings, fixtures};
@ -1202,6 +1204,10 @@ mod tests {
tokio_util::sync::CancellationToken::new(),
fabro_model::Provider::Anthropic,
Arc::new(fabro_auth::EnvCredentialSource::new()),
Arc::new(
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
.expect("default catalog should build"),
),
Arc::new(SandboxGitRuntime::new()),
Arc::clone(&lifecycle.metadata_runtime),
lifecycle.metadata_writer.clone(),

View file

@ -10,6 +10,7 @@ use std::sync::Arc;
use fabro_config::Storage;
use fabro_graphviz::graph::{AttrValue, Graph};
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::{Catalog, ProviderId};
use fabro_sandbox::SandboxProvider;
use fabro_store::Database;
@ -74,6 +75,7 @@ struct PersistCreateOptions {
fork_source_ref: Option<ForkSourceRef>,
provenance: Option<RunProvenance>,
configured_providers: Vec<ProviderId>,
catalog: Arc<Catalog>,
}
/// Resolve workflow inputs, normalize settings, and persist a run directory.
@ -81,6 +83,21 @@ pub async fn create(
store: &Database,
request: CreateRunInput,
storage_root: PathBuf,
) -> Result<CreatedRun, Error> {
let catalog = Arc::new(
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
.map_err(|err| Error::engine(format!("building default LLM catalog: {err}")))?,
);
Box::pin(create_with_catalog(store, request, storage_root, catalog)).await
}
/// Resolve workflow inputs, normalize settings using a caller-provided catalog,
/// and persist a run directory.
pub async fn create_with_catalog(
store: &Database,
request: CreateRunInput,
storage_root: PathBuf,
catalog: Arc<Catalog>,
) -> Result<CreatedRun, Error> {
let resolved = resolve_workflow(ResolveWorkflowInput {
workflow: request.workflow,
@ -144,6 +161,7 @@ pub async fn create(
fork_source_ref,
provenance,
configured_providers,
catalog,
},
current_dir,
file_resolver,
@ -425,12 +443,13 @@ fn persist_validated(
fork_source_ref,
provenance,
configured_providers,
catalog,
} = options;
let settings = materialize_run(
settings,
validated.graph(),
Catalog::builtin(),
catalog.as_ref(),
&configured_providers,
);

View file

@ -13,7 +13,9 @@ pub use archive::{
ArchiveOutcome, UnarchiveOutcome, archive, archived_rejection_message, ensure_not_archived,
unarchive,
};
pub use create::{CreateRunInput, CreatedRun, RenderMode, create, make_run_dir};
pub use create::{
CreateRunInput, CreatedRun, RenderMode, create, create_with_catalog, make_run_dir,
};
pub use fork::{ForkOutcome, ForkRunInput, ResolvedForkTarget, fork_run};
pub use resume::resume;
pub use rewind::{RewindInput, RewindOutcome, rewind};

View file

@ -3,10 +3,10 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use fabro_auth::configured_providers_from_process_env;
use fabro_auth::{CredentialSource, EnvCredentialSource, VaultCredentialSource};
use fabro_interview::{AutoApproveInterviewer, Interviewer};
use fabro_mcp::config::{McpServerSettings, McpTransport};
use fabro_model::{Catalog, FallbackTarget, Provider};
use fabro_model::{AgentProfileKind, Catalog, FallbackTarget, Provider, ProviderId, adapter};
use fabro_sandbox::config::{
DaytonaNetwork, DaytonaSnapshotSettings, DockerfileSource as SandboxDockerfileSource,
};
@ -79,6 +79,7 @@ struct RunSession {
workflow_bundle: Option<Arc<WorkflowBundle>>,
run_control: Option<Arc<RunControlState>>,
vault: Option<Arc<AsyncRwLock<Vault>>>,
catalog: Arc<Catalog>,
}
pub struct StartServices {
@ -96,6 +97,7 @@ pub struct StartServices {
/// sandbox env. Empty when github integration has no permissions.
pub github_permissions: HashMap<String, String>,
pub vault: Option<Arc<AsyncRwLock<Vault>>>,
pub catalog: Arc<Catalog>,
pub on_node: crate::OnNodeCallback,
pub registry_override: Option<Arc<HandlerRegistry>>,
}
@ -310,14 +312,11 @@ impl RunSession {
} else {
sandbox_provider
};
let configured = configured_providers_from_process_env(services.vault.as_ref()).await;
let catalog = Arc::clone(&services.catalog);
let configured =
configured_providers_for_start(services.vault.as_ref(), catalog.as_ref()).await;
let model = resolved.model.name.as_ref().map_or_else(
|| {
Catalog::builtin()
.default_for_configured_ids(&configured)
.id
.clone()
},
|| catalog.default_for_configured_ids(&configured).id.clone(),
InterpString::as_source,
);
let provider = resolved
@ -327,19 +326,41 @@ impl RunSession {
.map(InterpString::as_source)
.filter(|value| !value.is_empty());
let provider_enum: Provider = if let Some(value) = provider.as_deref() {
value.parse::<Provider>().map_err(|_| {
Error::Precondition(format!("Provider \"{value}\" is not configured"))
})?
let provider_id = if let Some(value) = provider.as_deref() {
let provider_id = ProviderId::from(value);
catalog
.provider(&provider_id)
.ok_or_else(|| {
Error::Precondition(format!("Provider \"{value}\" is not configured"))
})?
.id
.clone()
} else if let Some(model) = catalog.get(&model) {
model.provider.clone()
} else {
let configured = configured
.iter()
.filter_map(Provider::from_id)
.collect::<Vec<_>>();
Provider::default_for_configured(&configured)
catalog
.default_for_configured_ids(&configured)
.provider
.clone()
};
let fallback_chain = resolve_fallback_chain(provider_enum, &model, &resolved.model);
let catalog_provider = catalog.provider(&provider_id).ok_or_else(|| {
Error::Precondition(format!("Provider \"{provider_id}\" is not configured"))
})?;
let profile_kind = adapter::get(&catalog_provider.adapter)
.map(|metadata| metadata.default_profile)
.ok_or_else(|| {
Error::Precondition(format!(
"Provider \"{provider_id}\" uses unknown adapter \"{}\"",
catalog_provider.adapter,
))
})?;
let provider_enum = Provider::from_id(&provider_id).unwrap_or_else(|| {
profile_provider_for_custom_provider(profile_kind, &catalog_provider.adapter)
});
let fallback_chain =
resolve_fallback_chain(catalog.as_ref(), &provider_id, &model, &resolved.model);
let mcp_servers = resolved
.agent
.mcps
@ -416,6 +437,8 @@ impl RunSession {
llm: LlmSpec {
model: model.clone(),
provider: provider_enum,
provider_id: provider_id.clone(),
profile_kind,
fallback_chain,
mcp_servers,
dry_run: resolved.execution.mode == RunMode::DryRun,
@ -448,10 +471,34 @@ impl RunSession {
workflow_path,
workflow_bundle,
vault: services.vault,
catalog,
})
}
}
async fn configured_providers_for_start(
vault: Option<&Arc<AsyncRwLock<Vault>>>,
catalog: &Catalog,
) -> Vec<ProviderId> {
let source: Arc<dyn CredentialSource> = match vault {
Some(vault) => Arc::new(VaultCredentialSource::with_env_lookup(
Arc::clone(vault),
process_env_var,
)),
None => Arc::new(EnvCredentialSource::new()),
};
source.configured_providers_for_catalog(catalog).await
}
fn profile_provider_for_custom_provider(profile_kind: AgentProfileKind, adapter: &str) -> Provider {
match (profile_kind, adapter) {
(AgentProfileKind::Anthropic, _) => Provider::Anthropic,
(AgentProfileKind::Gemini, _) => Provider::Gemini,
(AgentProfileKind::OpenAi, "openai_compatible") => Provider::OpenAiCompatible,
(AgentProfileKind::OpenAi, _) => Provider::OpenAi,
}
}
fn resolve_interp(value: &InterpString) -> String {
value
.resolve(process_env_var)
@ -535,7 +582,8 @@ fn resolve_docker_config(settings: &ResolvedRunSettings) -> DockerSandboxOptions
}
fn resolve_fallback_chain(
provider: Provider,
catalog: &Catalog,
provider: &ProviderId,
model: &str,
settings: &ResolvedRunModelSettings,
) -> Vec<FallbackTarget> {
@ -556,7 +604,7 @@ fn resolve_fallback_chain(
.or_default()
.push(model_ref.to_string());
}
Catalog::builtin().build_fallback_chain(&provider.id(), model, &by_provider)
catalog.build_fallback_chain(provider, model, &by_provider)
}
fn runtime_mcp_server(settings: &ResolvedMcpServerSettings) -> McpServerSettings {
@ -768,6 +816,7 @@ impl RunSession {
llm: self.llm,
interviewer: self.interviewer,
steering_hub: Arc::clone(&self.steering_hub),
catalog: Arc::clone(&self.catalog),
lifecycle: self.lifecycle,
run_options,
workflow_path: self.workflow_path,
@ -1023,6 +1072,7 @@ mod tests {
use chrono::Utc;
use fabro_config::{RunCloneLayer, RunExecutionLayer, RunLayer, WorkflowSettingsBuilder};
use fabro_model::catalog::LlmCatalogSettings;
use fabro_store::Database;
use fabro_types::settings::run::RunMode;
use fabro_types::{WorkflowSettings, fixtures};
@ -1186,6 +1236,10 @@ mod tests {
github_app: None,
github_permissions: HashMap::new(),
vault: None,
catalog: Arc::new(
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
.expect("default catalog should build"),
),
on_node: None,
registry_override: Some(registry),
}

View file

@ -80,6 +80,15 @@ fn test_run_id(label: &str) -> RunId {
}
}
fn test_catalog() -> Arc<fabro_model::Catalog> {
Arc::new(
fabro_model::Catalog::from_builtin_with_overrides(
&fabro_model::catalog::LlmCatalogSettings::default(),
)
.expect("default catalog should build"),
)
}
fn test_emitter(label: &str) -> Emitter {
Emitter::new(test_run_id(label))
}
@ -239,12 +248,15 @@ async fn execute_test_run_with_options(
llm: LlmSpec {
model: "test-model".to_string(),
provider: fabro_llm::Provider::Anthropic,
provider_id: fabro_llm::Provider::Anthropic.id(),
profile_kind: fabro_model::AgentProfileKind::Anthropic,
fallback_chain: Vec::new(),
mcp_servers: Vec::new(),
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer::engine()),
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())),
catalog: test_catalog(),
lifecycle: LifecycleOptions {
setup_commands: vec![],
setup_command_timeout_ms: 1_000,
@ -300,6 +312,8 @@ async fn execute_runs_start_to_exit_and_returns_final_context() {
llm: LlmSpec {
model: "test-model".to_string(),
provider: fabro_llm::Provider::Anthropic,
provider_id: fabro_llm::Provider::Anthropic.id(),
profile_kind: fabro_model::AgentProfileKind::Anthropic,
fallback_chain: Vec::new(),
mcp_servers: Vec::new(),
dry_run: true,
@ -308,6 +322,7 @@ async fn execute_runs_start_to_exit_and_returns_final_context() {
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(test_emitter_arc(
"run-test",
))),
catalog: test_catalog(),
lifecycle: LifecycleOptions {
setup_commands: vec![],
setup_command_timeout_ms: 1_000,
@ -376,12 +391,15 @@ async fn run_with_lifecycle(
llm: LlmSpec {
model: "test-model".to_string(),
provider: fabro_llm::Provider::Anthropic,
provider_id: fabro_llm::Provider::Anthropic.id(),
profile_kind: fabro_model::AgentProfileKind::Anthropic,
fallback_chain: Vec::new(),
mcp_servers: Vec::new(),
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer::engine()),
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())),
catalog: test_catalog(),
lifecycle,
run_options,
workflow_path: None,

View file

@ -644,6 +644,8 @@ mod tests {
use async_trait::async_trait;
use bytes::Bytes;
use fabro_graphviz::graph::Graph;
use fabro_model::Catalog;
use fabro_model::catalog::LlmCatalogSettings;
use fabro_sandbox::test_support::MockSandbox;
use fabro_store::{Database, EventEnvelope, RunDatabase, RunProjection};
use fabro_types::run_event::{MetadataSnapshotFailureKind, MetadataSnapshotPhase};
@ -1027,6 +1029,10 @@ mod tests {
tokio_util::sync::CancellationToken::new(),
fabro_model::Provider::Anthropic,
Arc::new(fabro_auth::EnvCredentialSource::new()),
Arc::new(
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
.expect("default catalog should build"),
),
Arc::new(SandboxGitRuntime::new()),
metadata_runtime,
metadata_writer,
@ -1053,6 +1059,10 @@ mod tests {
tokio_util::sync::CancellationToken::new(),
fabro_model::Provider::Anthropic,
Arc::new(fabro_auth::EnvCredentialSource::new()),
Arc::new(
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
.expect("default catalog should build"),
),
Arc::new(SandboxGitRuntime::new()),
Arc::new(RunMetadataRuntime::new()),
None,

View file

@ -10,6 +10,7 @@ use fabro_auth::{
};
use fabro_graphviz::graph;
use fabro_hooks::{HookContext, HookDecision, HookEvent, HookRunner};
use fabro_model::Catalog;
use fabro_sandbox::{
GitSetupIntent, ReadBeforeWriteSandbox, SandboxEventCallback, SandboxSpec,
reconnect_for_run_with_callback,
@ -124,6 +125,7 @@ async fn build_registry(
github_token_refresh_managed: bool,
graph: &graph::Graph,
llm_source: Arc<dyn CredentialSource>,
catalog: Arc<Catalog>,
cli_resolver: Option<CredentialResolver>,
) -> Result<(Arc<HandlerRegistry>, bool), Error> {
let no_backend_interviewer = Arc::clone(&interviewer);
@ -150,19 +152,25 @@ async fn build_registry(
let build_llm_registry = || {
let model = spec.model.clone();
let provider = spec.provider;
let provider_id = spec.provider_id.clone();
let profile_kind = spec.profile_kind;
let fallback_chain = spec.fallback_chain.clone();
let mcp_servers = spec.mcp_servers.clone();
let llm_source_for_api = Arc::clone(&llm_source);
let catalog_for_api = Arc::clone(&catalog);
let steering_hub_for_api = Arc::clone(&steering_hub);
let tool_env_provider_for_backend = Arc::clone(&tool_env_provider);
Arc::new(default_registry(interviewer, move || {
let tool_env_provider = Arc::clone(&tool_env_provider_for_backend);
let api = AgentApiBackend::new(
let api = AgentApiBackend::new_with_catalog(
model.clone(),
provider,
provider_id.clone(),
profile_kind,
fallback_chain.clone(),
Arc::clone(&llm_source_for_api),
Arc::clone(&steering_hub_for_api),
Arc::clone(&catalog_for_api),
)
.with_tool_env_provider(tool_env_provider.clone())
.with_mcp_servers(mcp_servers.clone());
@ -188,7 +196,7 @@ async fn build_registry(
return Ok((build_llm_registry(), false));
}
match llm_source.resolve().await {
match llm_source.resolve_for_catalog(catalog.as_ref()).await {
Ok(result) if result.credentials.is_empty() => {
if graph_needs_llm {
let detail = (!result.auth_issues.is_empty()).then(|| {
@ -340,6 +348,7 @@ pub async fn initialize(
options.run_options.git = options.git.clone();
let llm_source = build_llm_source(options.vault.clone());
let catalog = Arc::clone(&options.catalog);
let cli_resolver = options.vault.clone().map(CredentialResolver::new);
let sandbox_git = Arc::new(SandboxGitRuntime::new());
let metadata_runtime = Arc::new(RunMetadataRuntime::new());
@ -350,6 +359,7 @@ pub async fn initialize(
Some(Arc::new(HookRunner::new(
options.hooks.clone(),
Arc::clone(&llm_source),
Arc::clone(&catalog),
)))
};
@ -502,6 +512,7 @@ pub async fn initialize(
github_token_refresh_managed,
&graph,
Arc::clone(&llm_source),
Arc::clone(&catalog),
cli_resolver,
)
.await?
@ -658,6 +669,7 @@ pub async fn initialize(
options.run_options.cancel_token.clone(),
options.llm.provider,
Arc::clone(&llm_source),
catalog,
sandbox_git,
metadata_runtime,
metadata_writer,
@ -702,6 +714,7 @@ mod tests {
use fabro_auth::{AuthCredential, AuthDetails};
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use fabro_interview::AutoApproveInterviewer;
use fabro_model::catalog::LlmCatalogSettings;
use fabro_sandbox::SandboxSpec;
use fabro_store::Database;
use fabro_types::{EventBody, RunEvent, RunId, WorkflowSettings, fixtures};
@ -721,6 +734,13 @@ mod tests {
fixtures::RUN_1
}
fn test_catalog() -> Arc<Catalog> {
Arc::new(
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
.expect("default catalog should build"),
)
}
fn memory_store() -> Arc<Database> {
Arc::new(Database::new(
Arc::new(InMemory::new()),
@ -862,12 +882,15 @@ mod tests {
llm: LlmSpec {
model: "test-model".to_string(),
provider: fabro_llm::Provider::Anthropic,
provider_id: fabro_llm::Provider::Anthropic.id(),
profile_kind: fabro_model::AgentProfileKind::Anthropic,
fallback_chain: Vec::new(),
mcp_servers: Vec::new(),
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer::engine()),
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())),
catalog: test_catalog(),
lifecycle: crate::run_options::LifecycleOptions {
setup_commands: vec![command.to_string()],
setup_command_timeout_ms: 1_000,
@ -921,12 +944,15 @@ mod tests {
llm: LlmSpec {
model: "test-model".to_string(),
provider: fabro_llm::Provider::Anthropic,
provider_id: fabro_llm::Provider::Anthropic.id(),
profile_kind: fabro_model::AgentProfileKind::Anthropic,
fallback_chain: Vec::new(),
mcp_servers: Vec::new(),
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer::engine()),
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())),
catalog: test_catalog(),
lifecycle: crate::run_options::LifecycleOptions {
setup_commands: vec![],
setup_command_timeout_ms: 1_000,
@ -1014,6 +1040,8 @@ mod tests {
&LlmSpec {
model: "claude-opus-4-6".to_string(),
provider: fabro_llm::Provider::Anthropic,
provider_id: fabro_llm::Provider::Anthropic.id(),
profile_kind: fabro_model::AgentProfileKind::Anthropic,
fallback_chain: Vec::new(),
mcp_servers: Vec::new(),
dry_run: false,
@ -1024,6 +1052,7 @@ mod tests {
false,
&graph,
Arc::new(VaultCredentialSource::new(Arc::clone(&vault))),
test_catalog(),
Some(CredentialResolver::new(vault)),
)
.await
@ -1129,12 +1158,15 @@ mod tests {
llm: LlmSpec {
model: "fake-acp".to_string(),
provider: fabro_llm::Provider::OpenAi,
provider_id: fabro_llm::Provider::OpenAi.id(),
profile_kind: fabro_model::AgentProfileKind::OpenAi,
fallback_chain: Vec::new(),
mcp_servers: Vec::new(),
dry_run: false,
},
interviewer: Arc::new(AutoApproveInterviewer::engine()),
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter)),
catalog: test_catalog(),
lifecycle: crate::run_options::LifecycleOptions {
setup_commands: Vec::new(),
setup_command_timeout_ms: 1_000,
@ -1225,12 +1257,15 @@ mod tests {
llm: LlmSpec {
model: "test-model".to_string(),
provider: fabro_llm::Provider::Anthropic,
provider_id: fabro_llm::Provider::Anthropic.id(),
profile_kind: fabro_model::AgentProfileKind::Anthropic,
fallback_chain: Vec::new(),
mcp_servers: Vec::new(),
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer::engine()),
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())),
catalog: test_catalog(),
lifecycle: crate::run_options::LifecycleOptions {
setup_commands: vec!["true".to_string()],
setup_command_timeout_ms: 1_000,
@ -1339,12 +1374,15 @@ mod tests {
llm: LlmSpec {
model: "test-model".to_string(),
provider: fabro_llm::Provider::Anthropic,
provider_id: fabro_llm::Provider::Anthropic.id(),
profile_kind: fabro_model::AgentProfileKind::Anthropic,
fallback_chain: Vec::new(),
mcp_servers: Vec::new(),
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer::engine()),
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())),
catalog: test_catalog(),
lifecycle: crate::run_options::LifecycleOptions {
setup_commands: vec!["sleep 5".to_string()],
setup_command_timeout_ms: 5_000,
@ -1402,12 +1440,15 @@ mod tests {
llm: LlmSpec {
model: "test-model".to_string(),
provider: fabro_llm::Provider::Anthropic,
provider_id: fabro_llm::Provider::Anthropic.id(),
profile_kind: fabro_model::AgentProfileKind::Anthropic,
fallback_chain: Vec::new(),
mcp_servers: Vec::new(),
dry_run: true,
},
interviewer: Arc::new(AutoApproveInterviewer::engine()),
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())),
catalog: test_catalog(),
lifecycle: crate::run_options::LifecycleOptions {
setup_commands: vec![],
setup_command_timeout_ms: 5_000,

View file

@ -6,6 +6,8 @@ use fabro_graphviz::parser;
use fabro_llm::client::Client;
use fabro_llm::generate::{GenerateParams, generate_object};
use fabro_model::Catalog;
#[cfg(test)]
use fabro_model::catalog::LlmCatalogSettings;
use fabro_store::RunProjection;
use fabro_types::PullRequestRecord;
use fabro_types::settings::run::MergeStrategy;
@ -66,8 +68,8 @@ const UNKNOWN_MODEL_CTX: usize = 200_000;
/// Resolve truncation caps based on the model's context window. Unknown
/// models use the baseline 200k context-window assumption.
fn truncation_caps(model: &str) -> TruncationCaps {
let ctx = Catalog::builtin()
fn truncation_caps(model: &str, catalog: &Catalog) -> TruncationCaps {
let ctx = catalog
.get(model)
.and_then(|m| usize::try_from(m.context_window()).ok())
.unwrap_or(UNKNOWN_MODEL_CTX);
@ -345,10 +347,11 @@ pub async fn build_pr_content(
model: &str,
run_store: &RunStoreHandle,
llm_source: &dyn CredentialSource,
catalog: Arc<Catalog>,
conclusion: Option<&Conclusion>,
run_state: Option<&RunProjection>,
) -> Result<PrContent, String> {
let client = Client::from_source(llm_source)
let client = Client::from_source_with_catalog(llm_source, Arc::clone(&catalog))
.await
.map_err(|e| format!("Failed to create LLM client: {e}"))?;
@ -357,6 +360,7 @@ pub async fn build_pr_content(
goal,
model,
run_store,
catalog.as_ref(),
conclusion,
run_state,
Arc::new(client),
@ -369,6 +373,7 @@ async fn build_pr_content_with_client(
goal: &str,
model: &str,
run_store: &RunStoreHandle,
catalog: &Catalog,
conclusion: Option<&Conclusion>,
run_state: Option<&RunProjection>,
client: Arc<Client>,
@ -392,7 +397,7 @@ async fn build_pr_content_with_client(
let run_spec = run_state.map(|state| state.spec.clone());
let dot_source = run_state.and_then(|state| state.spec.graph_source.clone());
let caps = truncation_caps(model);
let caps = truncation_caps(model, catalog);
let truncated_diff = truncate_chars(diff, caps.diff);
let prompt = if let Some(ref plan) = plan_text {
@ -462,6 +467,7 @@ pub struct OpenPullRequestRequest<'a> {
pub auto_merge: Option<AutoMergeOptions>,
pub run_store: &'a RunStoreHandle,
pub llm_source: &'a dyn CredentialSource,
pub catalog: Arc<Catalog>,
pub conclusion: Option<&'a Conclusion>,
pub run_state: Option<&'a RunProjection>,
}
@ -488,6 +494,7 @@ pub async fn maybe_open_pull_request(
req.model,
req.run_store,
req.llm_source,
Arc::clone(&req.catalog),
req.conclusion,
req.run_state,
)
@ -602,6 +609,7 @@ pub async fn pull_request(concluded: Concluded, options: &PullRequestOptions) ->
auto_merge,
run_store: &services.run_store,
llm_source: services.llm_source.as_ref(),
catalog: Arc::clone(&services.catalog),
conclusion: Some(&conclusion),
run_state: None,
})
@ -759,6 +767,13 @@ mod tests {
))
}
fn test_catalog() -> Arc<Catalog> {
Arc::new(
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
.expect("default catalog should build"),
)
}
fn explicit_client(provider_name: &str, text: &str) -> Arc<Client> {
let mut providers: HashMap<String, Arc<dyn ProviderAdapter>> = HashMap::new();
providers.insert(
@ -1082,6 +1097,7 @@ mod tests {
"Implement feature",
"mock-model",
&run_store.clone().into(),
Catalog::builtin(),
Some(&make_test_conclusion()),
None,
explicit_client(
@ -1149,6 +1165,7 @@ mod tests {
"Implement feature",
"mock-model",
&run_store.clone().into(),
Catalog::builtin(),
Some(&make_test_conclusion()),
None,
explicit_client(
@ -1240,6 +1257,7 @@ mod tests {
"Implement feature",
"mock-model",
&run_store.clone().into(),
Catalog::builtin(),
Some(&make_test_conclusion()),
None,
explicit_client(
@ -1264,6 +1282,7 @@ mod tests {
"Implement feature",
"gpt-5.4",
&run_store.clone().into(),
Catalog::builtin(),
Some(&make_test_conclusion()),
None,
explicit_client(
@ -1326,6 +1345,7 @@ mod tests {
"gpt-5.4",
&run_store_handle,
llm_source.as_ref(),
test_catalog(),
Some(&make_test_conclusion()),
None,
)
@ -1482,10 +1502,13 @@ mod tests {
plan: 100_000,
}
);
assert_eq!(truncation_caps("unknown-model"), TruncationCaps {
diff: 80_000,
plan: 20_000,
});
assert_eq!(
truncation_caps("unknown-model", Catalog::builtin()),
TruncationCaps {
diff: 80_000,
plan: 20_000,
}
);
}
#[tokio::test]
@ -1512,6 +1535,7 @@ mod tests {
auto_merge: None,
run_store: &run_store_handle,
llm_source: llm_source.as_ref(),
catalog: test_catalog(),
conclusion: None,
run_state: None,
})
@ -1602,6 +1626,7 @@ mod tests {
"Implement feature",
"mock-model",
&run_store.clone().into(),
Catalog::builtin(),
Some(&make_test_conclusion()),
None,
explicit_client("mock", &payload),
@ -1624,6 +1649,7 @@ mod tests {
"## Plan:",
"mock-model",
&run_store.clone().into(),
Catalog::builtin(),
Some(&make_test_conclusion()),
None,
explicit_client("mock", &payload),
@ -1706,6 +1732,7 @@ mod tests {
"Implement feature",
"mock-model",
&run_store.clone().into(),
Catalog::builtin(),
Some(&make_test_conclusion()),
None,
explicit_client("mock", &payload),
@ -1905,6 +1932,7 @@ mod tests {
auto_merge: None,
run_store: &harness.run_store,
llm_source: harness.llm_source.as_ref(),
catalog: test_catalog(),
conclusion: None,
run_state: None,
})
@ -1941,6 +1969,7 @@ mod tests {
auto_merge: None,
run_store: &harness.run_store,
llm_source: harness.llm_source.as_ref(),
catalog: test_catalog(),
conclusion: None,
run_state: None,
})

View file

@ -6,7 +6,7 @@ use fabro_graphviz::graph::Graph;
use fabro_interview::Interviewer;
use fabro_llm::Provider;
use fabro_mcp::config::McpServerSettings;
use fabro_model::FallbackTarget;
use fabro_model::{AgentProfileKind, Catalog, FallbackTarget, ProviderId};
use fabro_sandbox::SandboxSpec;
use fabro_types::RunId;
use fabro_types::settings::run::PullRequestSettings;
@ -220,6 +220,8 @@ impl Persisted {
pub struct LlmSpec {
pub model: String,
pub provider: Provider,
pub provider_id: ProviderId,
pub profile_kind: AgentProfileKind,
pub fallback_chain: Vec<FallbackTarget>,
pub mcp_servers: Vec<McpServerSettings>,
pub dry_run: bool,
@ -248,6 +250,7 @@ pub struct InitOptions {
pub llm: LlmSpec,
pub interviewer: Arc<dyn Interviewer>,
pub steering_hub: Arc<SteeringHub>,
pub catalog: Arc<Catalog>,
pub lifecycle: LifecycleOptions,
pub run_options: RunOptions,
pub workflow_path: Option<ManifestPath>,

View file

@ -10,9 +10,11 @@ use fabro_auth::CredentialSource;
#[cfg(test)]
use fabro_auth::ResolvedCredentials;
use fabro_hooks::{HookContext, HookDecision, HookRunner};
use fabro_model::Provider;
#[cfg(test)]
use fabro_model::ProviderId;
#[cfg(test)]
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::{Catalog, Provider};
use tokio_util::sync::CancellationToken;
use crate::ManifestPath;
@ -42,6 +44,7 @@ pub struct RunServices {
pub(crate) cancel_token: CancellationToken,
pub provider: Provider,
pub llm_source: Arc<dyn CredentialSource>,
pub catalog: Arc<Catalog>,
pub(crate) sandbox_git: Arc<SandboxGitRuntime>,
pub(crate) metadata_runtime: Arc<RunMetadataRuntime>,
pub(crate) metadata_writer: Option<RunMetadataWriterHandle>,
@ -57,6 +60,7 @@ impl RunServices {
cancel_token: CancellationToken,
provider: Provider,
llm_source: Arc<dyn CredentialSource>,
catalog: Arc<Catalog>,
sandbox_git: Arc<SandboxGitRuntime>,
metadata_runtime: Arc<RunMetadataRuntime>,
metadata_writer: Option<RunMetadataWriterHandle>,
@ -69,6 +73,7 @@ impl RunServices {
cancel_token,
provider,
llm_source,
catalog,
sandbox_git,
metadata_runtime,
metadata_writer,
@ -229,6 +234,10 @@ impl EngineServices {
CancellationToken::new(),
Provider::Anthropic,
Arc::new(StubCredentialSource),
Arc::new(
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
.expect("default catalog should build"),
),
Arc::new(SandboxGitRuntime::new()),
Arc::new(RunMetadataRuntime::new()),
None,

View file

@ -7,6 +7,8 @@ use std::time::Duration;
use fabro_agent::Sandbox;
use fabro_auth::{CredentialSource, EnvCredentialSource};
use fabro_graphviz::graph::Graph as GvGraph;
use fabro_model::Catalog;
use fabro_model::catalog::LlmCatalogSettings;
use fabro_store::{ArtifactStore, Database, RunProjection};
use object_store::local::LocalFileSystem;
@ -164,6 +166,10 @@ async fn initialized(
options
.llm_source
.unwrap_or_else(|| Arc::new(EnvCredentialSource::new())),
Arc::new(
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
.expect("default catalog should build"),
),
Arc::new(SandboxGitRuntime::new()),
Arc::new(RunMetadataRuntime::new()),
None,

View file

@ -1,4 +1,5 @@
use fabro_graphviz::graph::{AttrValue, Graph};
use fabro_model::Catalog;
use super::Transform;
use crate::error::Error;
@ -17,7 +18,7 @@ impl Transform for ModelResolutionTransform {
.and_then(AttrValue::as_str)
.map(String::from);
if let Some(model) = model {
if let Some(info) = fabro_model::Catalog::builtin().get(&model) {
if let Some(info) = Catalog::builtin().get(&model) {
let canonical_id = info.id.clone();
let provider = info.provider.to_string();
// Resolve alias to canonical model ID

View file

@ -32,6 +32,8 @@ use fabro_interview::{
QueueInterviewer, RecordingInterviewer,
};
use fabro_llm::provider::Provider;
use fabro_model::Catalog;
use fabro_model::catalog::LlmCatalogSettings;
use fabro_store::{ArtifactKey, ArtifactStore, Database};
use fabro_types::{CommandTermination, RunEvent, RunId, StageId, WorkflowSettings, parse_blob_ref};
use fabro_validate::{Severity, validate, validate_or_raise};
@ -61,6 +63,13 @@ use object_store::local::LocalFileSystem;
use tokio_util::sync::CancellationToken;
use ulid::Ulid;
fn default_catalog() -> Arc<Catalog> {
Arc::new(
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
.expect("default catalog should build"),
)
}
fn local_env() -> Arc<dyn fabro_agent::Sandbox> {
Arc::new(fabro_agent::LocalSandbox::new(
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
@ -6993,6 +7002,7 @@ async fn workflow_run_with_vault_only_openai_codex_builds_pr_body() {
"gpt-5.4",
&run_store_handle,
llm_source.as_ref(),
default_catalog(),
Some(&Conclusion {
timestamp: Utc::now(),
status: StageOutcome::Succeeded,
@ -7801,6 +7811,7 @@ fn hook_runner_from_defs(hooks: Vec<fabro_hooks::HookDefinition>) -> Arc<fabro_h
Arc::new(fabro_hooks::HookRunner::new(
fabro_hooks::HookSettings { hooks },
Arc::new(fabro_auth::EnvCredentialSource::new()),
default_catalog(),
))
}