From cfb4ea91a3ea435d5cd0c5bc29bbf7fef28e4166 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Tue, 12 May 2026 19:58:14 -0400 Subject: [PATCH] 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. --- Cargo.lock | 2 + docs/internal/llm-client-resolution.md | 20 +- lib/crates/fabro-agent/src/agent_profile.rs | 5 +- lib/crates/fabro-agent/src/compaction.rs | 2 +- .../fabro-agent/src/profiles/anthropic.rs | 15 +- lib/crates/fabro-agent/src/profiles/gemini.rs | 22 +- lib/crates/fabro-agent/src/profiles/mod.rs | 9 +- lib/crates/fabro-agent/src/profiles/openai.rs | 15 +- lib/crates/fabro-agent/src/session.rs | 33 ++- lib/crates/fabro-auth/Cargo.toml | 1 + .../fabro-auth/src/credential_source.rs | 12 +- lib/crates/fabro-auth/src/env_source.rs | 232 +++++++++++++-- lib/crates/fabro-auth/src/resolve.rs | 207 +++++++++++-- lib/crates/fabro-auth/src/vault_source.rs | 29 +- .../fabro-cli/src/commands/run/runner.rs | 7 + lib/crates/fabro-hooks/src/bridge.rs | 2 + lib/crates/fabro-hooks/src/executor.rs | 40 ++- lib/crates/fabro-hooks/src/runner.rs | 31 +- lib/crates/fabro-llm/Cargo.toml | 1 + lib/crates/fabro-llm/src/adapter_registry.rs | 69 +++-- lib/crates/fabro-llm/src/client.rs | 232 ++++++++++++++- .../fabro-llm/src/providers/anthropic.rs | 34 ++- lib/crates/fabro-llm/src/providers/common.rs | 13 + lib/crates/fabro-llm/src/providers/gemini.rs | 58 +++- .../fabro-llm/src/providers/http_api.rs | 9 +- lib/crates/fabro-llm/src/providers/openai.rs | 72 ++++- .../src/providers/openai_compatible.rs | 92 +++++- lib/crates/fabro-server/src/run_manifest.rs | 5 +- lib/crates/fabro-server/src/server.rs | 8 +- .../fabro-server/src/server/handler/models.rs | 4 +- .../src/server/handler/pull_requests.rs | 14 +- .../fabro-server/src/server/handler/runs.rs | 9 +- lib/crates/fabro-server/src/server/tests.rs | 23 +- .../fabro-workflow/src/handler/llm/api.rs | 279 ++++++++++++++---- .../fabro-workflow/src/lifecycle/git.rs | 6 + .../fabro-workflow/src/operations/create.rs | 21 +- .../fabro-workflow/src/operations/mod.rs | 4 +- .../fabro-workflow/src/operations/start.rs | 96 ++++-- .../src/pipeline/execute/tests.rs | 18 ++ .../fabro-workflow/src/pipeline/finalize.rs | 10 + .../fabro-workflow/src/pipeline/initialize.rs | 45 ++- .../src/pipeline/pull_request.rs | 45 ++- .../fabro-workflow/src/pipeline/types.rs | 5 +- lib/crates/fabro-workflow/src/services.rs | 11 +- lib/crates/fabro-workflow/src/test_support.rs | 6 + .../src/transforms/model_resolution.rs | 3 +- .../fabro-workflow/tests/it/integration.rs | 11 + 47 files changed, 1614 insertions(+), 273 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 54c39c172..3762a99fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/docs/internal/llm-client-resolution.md b/docs/internal/llm-client-resolution.md index 22f58d6cd..5c0d8b8ca 100644 --- a/docs/internal/llm-client-resolution.md +++ b/docs/internal/llm-client-resolution.md @@ -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`, not `Client`. -- Call `fabro_llm::client::Client::from_source(&source).await?` at the point of use. +- Long-lived runtime contexts store `Arc` and `Arc`, 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`. -- 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`. +- 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. diff --git a/lib/crates/fabro-agent/src/agent_profile.rs b/lib/crates/fabro-agent/src/agent_profile.rs index 69d8418de..91f46e41c 100644 --- a/lib/crates/fabro-agent/src/agent_profile.rs +++ b/lib/crates/fabro-agent/src/agent_profile.rs @@ -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; diff --git a/lib/crates/fabro-agent/src/compaction.rs b/lib/crates/fabro-agent/src/compaction.rs index d7c49c552..31c4e886e 100644 --- a/lib/crates/fabro-agent/src/compaction.rs +++ b/lib/crates/fabro-agent/src/compaction.rs @@ -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, diff --git a/lib/crates/fabro-agent/src/profiles/anthropic.rs b/lib/crates/fabro-agent/src/profiles/anthropic.rs index 7992e6eb7..9e718ffa1 100644 --- a/lib/crates/fabro-agent/src/profiles/anthropic.rs +++ b/lib/crates/fabro-agent/src/profiles/anthropic.rs @@ -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 } diff --git a/lib/crates/fabro-agent/src/profiles/gemini.rs b/lib/crates/fabro-agent/src/profiles/gemini.rs index 85e75f1ea..93ef9c0a0 100644 --- a/lib/crates/fabro-agent/src/profiles/gemini.rs +++ b/lib/crates/fabro-agent/src/profiles/gemini.rs @@ -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 } diff --git a/lib/crates/fabro-agent/src/profiles/mod.rs b/lib/crates/fabro-agent/src/profiles/mod.rs index 717e22914..a4c068965 100644 --- a/lib/crates/fabro-agent/src/profiles/mod.rs +++ b/lib/crates/fabro-agent/src/profiles/mod.rs @@ -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 diff --git a/lib/crates/fabro-agent/src/profiles/openai.rs b/lib/crates/fabro-agent/src/profiles/openai.rs index eb5172c94..4f2f10166 100644 --- a/lib/crates/fabro-agent/src/profiles/openai.rs +++ b/lib/crates/fabro-agent/src/profiles/openai.rs @@ -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 } diff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs index 03ce3f866..9544c36ed 100644 --- a/lib/crates/fabro-agent/src/session.rs +++ b/lib/crates/fabro-agent/src/session.rs @@ -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, + provider_profile: Arc, + sandbox: Arc, + config: SessionOptions, + subagent_manager: Option>>, + ) -> Result { + 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) { 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::().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) diff --git a/lib/crates/fabro-auth/Cargo.toml b/lib/crates/fabro-auth/Cargo.toml index bc501eec9..cfa60907c 100644 --- a/lib/crates/fabro-auth/Cargo.toml +++ b/lib/crates/fabro-auth/Cargo.toml @@ -31,3 +31,4 @@ tokio.workspace = true httpmock = "0.8" tempfile = "3" tokio = { workspace = true, features = ["macros", "test-util"] } +toml.workspace = true diff --git a/lib/crates/fabro-auth/src/credential_source.rs b/lib/crates/fabro-auth/src/credential_source.rs index cf0489d7e..4a016b95b 100644 --- a/lib/crates/fabro-auth/src/credential_source.rs +++ b/lib/crates/fabro-auth/src/credential_source.rs @@ -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; async fn configured_providers(&self) -> Vec; + + async fn resolve_for_catalog(&self, catalog: &Catalog) -> anyhow::Result { + let _ = catalog; + self.resolve().await + } + + async fn configured_providers_for_catalog(&self, catalog: &Catalog) -> Vec { + let _ = catalog; + self.configured_providers().await + } } diff --git a/lib/crates/fabro-auth/src/env_source.rs b/lib/crates/fabro-auth/src/env_source.rs index 499a0573f..0d7a17e03 100644 --- a/lib/crates/fabro-auth/src/env_source.rs +++ b/lib/crates/fabro-auth/src/env_source.rs @@ -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 { + fn credential_for( + &self, + provider: &CatalogProvider, + ) -> Result, 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 { @@ -74,7 +102,7 @@ impl EnvCredentialSource { fn resolved_extra_headers( &self, provider: &CatalogProvider, - ) -> Option> { + ) -> Result, 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 { - 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 { + 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 { - Catalog::builtin() + self.configured_providers_for_catalog(Catalog::builtin()) + .await + } + + async fn configured_providers_for_catalog(&self, catalog: &Catalog) -> Vec { + 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(_))) + ); + } } diff --git a/lib/crates/fabro-auth/src/resolve.rs b/lib/crates/fabro-auth/src/resolve.rs index dd186a965..8b12ba2c0 100644 --- a/lib/crates/fabro-auth/src/resolve.rs +++ b/lib/crates/fabro-auth/src/resolve.rs @@ -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, pub extra_headers: HashMap, pub base_url: Option, 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, @@ -214,10 +221,18 @@ impl CredentialResolver { #[must_use] pub fn configured_providers(&self, vault: &Vault) -> Vec { - Catalog::builtin() + self.configured_providers_for_catalog(vault, Catalog::builtin()) + } + + pub fn configured_providers_for_catalog( + &self, + vault: &Vault, + catalog: &Catalog, + ) -> Vec { + 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 { + 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 { + fn provider_base_url_for_catalog( + &self, + vault: &Vault, + provider: &ProviderId, + catalog: &Catalog, + ) -> Option { 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, 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 { - 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 { + 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, + usage: CredentialUsage, + catalog: &Catalog, + ) -> Result { + 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, 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, diff --git a/lib/crates/fabro-auth/src/vault_source.rs b/lib/crates/fabro-auth/src/vault_source.rs index 95ae59d82..e61e156ad 100644 --- a/lib/crates/fabro-auth/src/vault_source.rs +++ b/lib/crates/fabro-auth/src/vault_source.rs @@ -42,17 +42,32 @@ impl std::fmt::Debug for VaultCredentialSource { #[async_trait] impl CredentialSource for VaultCredentialSource { async fn resolve(&self) -> anyhow::Result { + self.resolve_for_catalog(Catalog::builtin()).await + } + + async fn resolve_for_catalog(&self, catalog: &Catalog) -> anyhow::Result { 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 { + self.configured_providers_for_catalog(Catalog::builtin()) + .await + } + + async fn configured_providers_for_catalog(&self, catalog: &Catalog) -> Vec { let vault = self.vault.read().await; - self.resolver.configured_providers(&vault) + self.resolver + .configured_providers_for_catalog(&vault, catalog) } } diff --git a/lib/crates/fabro-cli/src/commands/run/runner.rs b/lib/crates/fabro-cli/src/commands/run/runner.rs index ca20d7a47..281e379d6 100644 --- a/lib/crates/fabro-cli/src/commands/run/runner.rs +++ b/lib/crates/fabro-cli/src/commands/run/runner.rs @@ -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, }; diff --git a/lib/crates/fabro-hooks/src/bridge.rs b/lib/crates/fabro-hooks/src/bridge.rs index a762a53d7..b00c5fc4d 100644 --- a/lib/crates/fabro-hooks/src/bridge.rs +++ b/lib/crates/fabro-hooks/src/bridge.rs @@ -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, _work_dir: Option<&Path>, _llm_source: &dyn fabro_auth::CredentialSource, + _catalog: Arc, ) -> HookResult { self.captured_contexts.lock().unwrap().push(context.clone()); HookResult { diff --git a/lib/crates/fabro-hooks/src/executor.rs b/lib/crates/fabro-hooks/src/executor.rs index acb877254..c2670b18a 100644 --- a/lib/crates/fabro-hooks/src/executor.rs +++ b/lib/crates/fabro-hooks/src/executor.rs @@ -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, work_dir: Option<&Path>, llm_source: &dyn CredentialSource, + catalog: Arc, ) -> 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, ) -> 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, env: &E, llm_source: &dyn CredentialSource, + catalog: Arc, ) -> 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, work_dir: Option<&Path>, llm_source: &dyn CredentialSource, + catalog: Arc, ) -> HookResult { use std::sync::OnceLock; static HTTP_CLIENTS: OnceLock = 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 { + 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; diff --git a/lib/crates/fabro-hooks/src/runner.rs b/lib/crates/fabro-hooks/src/runner.rs index 110b3d58c..5c001fda9 100644 --- a/lib/crates/fabro-hooks/src/runner.rs +++ b/lib/crates/fabro-hooks/src/runner.rs @@ -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, llm_source: Arc, + catalog: Arc, /// Pre-compiled regexes keyed by matcher pattern string. compiled_matchers: HashMap, } impl HookRunner { #[must_use] - pub fn new(config: HookSettings, llm_source: Arc) -> Self { + pub fn new( + config: HookSettings, + llm_source: Arc, + catalog: Arc, + ) -> 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, _work_dir: Option<&Path>, _llm_source: &dyn CredentialSource, + _catalog: Arc, ) -> HookResult { HookResult { hook_name: definition.name.clone(), @@ -272,6 +288,13 @@ mod tests { Arc::new(EnvCredentialSource::new()) } + fn test_catalog() -> Arc { + 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; diff --git a/lib/crates/fabro-llm/Cargo.toml b/lib/crates/fabro-llm/Cargo.toml index 04258b325..e2e04924c 100644 --- a/lib/crates/fabro-llm/Cargo.toml +++ b/lib/crates/fabro-llm/Cargo.toml @@ -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 } diff --git a/lib/crates/fabro-llm/src/adapter_registry.rs b/lib/crates/fabro-llm/src/adapter_registry.rs index fe2629864..37eb5f6f0 100644 --- a/lib/crates/fabro-llm/src/adapter_registry.rs +++ b/lib/crates/fabro-llm/src/adapter_registry.rs @@ -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, /// Provider base URL override. `None` means use the adapter's built-in /// default. pub base_url: Option, @@ -44,19 +45,21 @@ pub struct AdapterConfig { pub org_id: Option, /// OpenAI-only: project ID. pub project_id: Option, + pub catalog: Option>, } impl AdapterConfig { /// Construct a minimal config with just provider ID and auth header. pub fn new(provider_id: impl Into, 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; +fn auth_value_optional(auth_header: Option<&ApiKeyHeader>) -> Option { + 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 { } 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 { } 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()), diff --git a/lib/crates/fabro-llm/src/client.rs b/lib/crates/fabro-llm/src/client.rs index 5910d6c9a..723a77753 100644 --- a/lib/crates/fabro-llm/src/client.rs +++ b/lib/crates/fabro-llm/src/client.rs @@ -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>, default_provider: Option, middleware: Vec>, + catalog: Option>, } 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, + ) -> Result { + 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) -> Result { + 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, + catalog: Arc, + ) -> Result { 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, 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, } + fn catalog_with(overrides: &str) -> Arc { + 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 { @@ -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 { diff --git a/lib/crates/fabro-llm/src/providers/anthropic.rs b/lib/crates/fabro-llm/src/providers/anthropic.rs index f753ac1a9..93c48d28a 100644 --- a/lib/crates/fabro-llm/src/providers/anthropic.rs +++ b/lib/crates/fabro-llm/src/providers/anthropic.rs @@ -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>, } impl Adapter { #[must_use] pub fn new(api_key: impl Into) -> Self { + Self::new_optional_auth(Some(api_key.into())) + } + + #[must_use] + pub fn new_optional_auth(api_key: Option) -> 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) -> 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( diff --git a/lib/crates/fabro-llm/src/providers/common.rs b/lib/crates/fabro-llm/src/providers/common.rs index 7f7b84b51..1ae0caa44 100644 --- a/lib/crates/fabro-llm/src/providers/common.rs +++ b/lib/crates/fabro-llm/src/providers/common.rs @@ -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 diff --git a/lib/crates/fabro-llm/src/providers/gemini.rs b/lib/crates/fabro-llm/src/providers/gemini.rs index dcd33c24e..ad3fd3554 100644 --- a/lib/crates/fabro-llm/src/providers/gemini.rs +++ b/lib/crates/fabro-llm/src/providers/gemini.rs @@ -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>, } impl Adapter { #[must_use] pub fn new(api_key: impl Into) -> Self { + Self::new_optional_auth(Some(api_key.into())) + } + + #[must_use] + pub fn new_optional_auth(api_key: Option) -> 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) -> Self { + self.provider_name = name.into(); + self + } + #[must_use] pub fn with_base_url(mut self, base_url: impl Into) -> Self { self.http.base_url = base_url.into(); @@ -42,13 +60,21 @@ impl Adapter { pub fn with_default_headers(self, headers: std::collections::HashMap) -> Self { Self { http: self.http.with_default_headers(headers), + ..self } } + #[must_use] + pub fn with_catalog(mut self, catalog: Arc) -> 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 { @@ -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); } diff --git a/lib/crates/fabro-llm/src/providers/http_api.rs b/lib/crates/fabro-llm/src/providers/http_api.rs index 09e29e44e..6939acbc6 100644 --- a/lib/crates/fabro-llm/src/providers/http_api.rs +++ b/lib/crates/fabro-llm/src/providers/http_api.rs @@ -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, pub(crate) base_url: String, pub(crate) default_headers: HashMap, pub(crate) client: fabro_http::HttpClient, @@ -27,10 +27,15 @@ impl HttpApi { #[must_use] pub fn new(api_key: impl Into, base_url: impl Into) -> Self { + Self::new_optional(Some(api_key.into()), base_url) + } + + #[must_use] + pub fn new_optional(api_key: Option, base_url: impl Into) -> 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, diff --git a/lib/crates/fabro-llm/src/providers/openai.rs b/lib/crates/fabro-llm/src/providers/openai.rs index a34e7c91d..dc2ba73e5 100644 --- a/lib/crates/fabro-llm/src/providers/openai.rs +++ b/lib/crates/fabro-llm/src/providers/openai.rs @@ -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, project_id: Option, + provider_name: String, + catalog: Option>, /// 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) -> Self { + Self::new_optional_auth(Some(api_key.into())) + } + + #[must_use] + pub fn new_optional_auth(api_key: Option) -> 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) -> 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) -> 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 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 { @@ -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 diff --git a/lib/crates/fabro-llm/src/providers/openai_compatible.rs b/lib/crates/fabro-llm/src/providers/openai_compatible.rs index 11f3c40f8..add8f025c 100644 --- a/lib/crates/fabro-llm/src/providers/openai_compatible.rs +++ b/lib/crates/fabro-llm/src/providers/openai_compatible.rs @@ -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>, } impl Adapter { #[must_use] pub fn new(api_key: impl Into, base_url: impl Into) -> Self { + Self::new_optional_auth(Some(api_key.into()), base_url) + } + + #[must_use] + pub fn new_optional_auth(api_key: Option, base_url: impl Into) -> 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) -> 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.` /// fields can be merged into the request before sending. +#[cfg(test)] fn build_api_request( request: &Request, stream: Option, 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, + 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(); diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 3d47379f5..bd7d90357 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -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, diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index caed79573..2f8844406 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -700,7 +700,7 @@ impl AppState { } pub(crate) async fn resolve_llm_client(&self) -> anyhow::Result { - 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 { @@ -863,12 +863,13 @@ impl AppState { async fn resolve_llm_client_from_source( source: &dyn CredentialSource, + catalog: Arc, ) -> anyhow::Result { 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, run_id: RunId) { github_app, github_permissions, vault: Some(Arc::clone(&state.vault)), + catalog: state.catalog(), on_node: None, registry_override, }; diff --git a/lib/crates/fabro-server/src/server/handler/models.rs b/lib/crates/fabro-server/src/server/handler/models.rs index ceb39d558..b37366202 100644 --- a/lib/crates/fabro-server/src/server/handler/models.rs +++ b/lib/crates/fabro-server/src/server/handler/models.rs @@ -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 = 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() diff --git a/lib/crates/fabro-server/src/server/handler/pull_requests.rs b/lib/crates/fabro-server/src/server/handler/pull_requests.rs index bc65f4f35..c43010289 100644 --- a/lib/crates/fabro-server/src/server/handler/pull_requests.rs +++ b/lib/crates/fabro-server/src/server/handler/pull_requests.rs @@ -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), }; diff --git a/lib/crates/fabro-server/src/server/handler/runs.rs b/lib/crates/fabro-server/src/server/handler/runs.rs index 45892d28f..d7d108aa1 100644 --- a/lib/crates/fabro-server/src/server/handler/runs.rs +++ b/lib/crates/fabro-server/src/server/handler/runs.rs @@ -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 { diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index b072e7509..a5b967853 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -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 { + Arc::new( + Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) + .expect("default catalog should build"), + ) +} + fn test_app_with_scheduler(state: Arc) -> 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::>(); @@ -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] diff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs index 5270490f7..73040eaa2 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs @@ -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 { +fn build_profile( + model: &str, + provider: Provider, + provider_id: ProviderId, + profile_kind: AgentProfileKind, +) -> Box { + 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, sessions: Mutex>, tool_env: Option>, mcp_servers: Vec, source: Arc, steering_hub: Arc, + catalog: Arc, } impl AgentApiBackend { @@ -265,16 +314,46 @@ impl AgentApiBackend { fallback_chain: Vec, source: Arc, steering_hub: Arc, + ) -> 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, + source: Arc, + steering_hub: Arc, + catalog: Arc, ) -> 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 { + 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>, ) -> Result { let model = node.model().unwrap_or(&self.model); - let provider = node - .provider() - .and_then(|p| p.parse::().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, source: &dyn CredentialSource, + catalog: Arc, tool_env: Option<&Arc>, tool_hooks: Option>, mcp_servers: Vec, ) -> Result { - 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 = 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 = 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"]); } diff --git a/lib/crates/fabro-workflow/src/lifecycle/git.rs b/lib/crates/fabro-workflow/src/lifecycle/git.rs index a2a95c422..c6862c4b0 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/git.rs +++ b/lib/crates/fabro-workflow/src/lifecycle/git.rs @@ -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(), diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index a4febb472..5221cf6a6 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -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, provenance: Option, configured_providers: Vec, + catalog: Arc, } /// 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 { + 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, ) -> Result { 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, ); diff --git a/lib/crates/fabro-workflow/src/operations/mod.rs b/lib/crates/fabro-workflow/src/operations/mod.rs index 35df4e183..f241e293a 100644 --- a/lib/crates/fabro-workflow/src/operations/mod.rs +++ b/lib/crates/fabro-workflow/src/operations/mod.rs @@ -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}; diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index dc9b823c3..c1abdf69d 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -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>, run_control: Option>, vault: Option>>, + catalog: Arc, } pub struct StartServices { @@ -96,6 +97,7 @@ pub struct StartServices { /// sandbox env. Empty when github integration has no permissions. pub github_permissions: HashMap, pub vault: Option>>, + pub catalog: Arc, pub on_node: crate::OnNodeCallback, pub registry_override: Option>, } @@ -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::().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::>(); - 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>>, + catalog: &Catalog, +) -> Vec { + let source: Arc = 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 { @@ -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), } diff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs index 2f61c18a0..8196e29f4 100644 --- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs @@ -80,6 +80,15 @@ fn test_run_id(label: &str) -> RunId { } } +fn test_catalog() -> Arc { + 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, diff --git a/lib/crates/fabro-workflow/src/pipeline/finalize.rs b/lib/crates/fabro-workflow/src/pipeline/finalize.rs index 09dbbeb86..7d28954f5 100644 --- a/lib/crates/fabro-workflow/src/pipeline/finalize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/finalize.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index bd80072cd..f36149386 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -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, + catalog: Arc, cli_resolver: Option, ) -> Result<(Arc, 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 { + Arc::new( + Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) + .expect("default catalog should build"), + ) + } + fn memory_store() -> Arc { 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, diff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs index bf504e728..d55ee6a9d 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -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, conclusion: Option<&Conclusion>, run_state: Option<&RunProjection>, ) -> Result { - 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, @@ -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, pub run_store: &'a RunStoreHandle, pub llm_source: &'a dyn CredentialSource, + pub catalog: Arc, 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 { + Arc::new( + Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) + .expect("default catalog should build"), + ) + } + fn explicit_client(provider_name: &str, text: &str) -> Arc { let mut providers: HashMap> = 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, }) diff --git a/lib/crates/fabro-workflow/src/pipeline/types.rs b/lib/crates/fabro-workflow/src/pipeline/types.rs index 685bb07de..39531ff1e 100644 --- a/lib/crates/fabro-workflow/src/pipeline/types.rs +++ b/lib/crates/fabro-workflow/src/pipeline/types.rs @@ -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, pub mcp_servers: Vec, pub dry_run: bool, @@ -248,6 +250,7 @@ pub struct InitOptions { pub llm: LlmSpec, pub interviewer: Arc, pub steering_hub: Arc, + pub catalog: Arc, pub lifecycle: LifecycleOptions, pub run_options: RunOptions, pub workflow_path: Option, diff --git a/lib/crates/fabro-workflow/src/services.rs b/lib/crates/fabro-workflow/src/services.rs index 10f1ae6b6..c2d9a6d5c 100644 --- a/lib/crates/fabro-workflow/src/services.rs +++ b/lib/crates/fabro-workflow/src/services.rs @@ -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, + pub catalog: Arc, pub(crate) sandbox_git: Arc, pub(crate) metadata_runtime: Arc, pub(crate) metadata_writer: Option, @@ -57,6 +60,7 @@ impl RunServices { cancel_token: CancellationToken, provider: Provider, llm_source: Arc, + catalog: Arc, sandbox_git: Arc, metadata_runtime: Arc, metadata_writer: Option, @@ -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, diff --git a/lib/crates/fabro-workflow/src/test_support.rs b/lib/crates/fabro-workflow/src/test_support.rs index 2e03ee759..4c1647ae2 100644 --- a/lib/crates/fabro-workflow/src/test_support.rs +++ b/lib/crates/fabro-workflow/src/test_support.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/transforms/model_resolution.rs b/lib/crates/fabro-workflow/src/transforms/model_resolution.rs index 54c09bc75..59d9c5e65 100644 --- a/lib/crates/fabro-workflow/src/transforms/model_resolution.rs +++ b/lib/crates/fabro-workflow/src/transforms/model_resolution.rs @@ -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 diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index 67ca26fd8..832430e76 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/fabro-workflow/tests/it/integration.rs @@ -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 { + Arc::new( + Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) + .expect("default catalog should build"), + ) +} + fn local_env() -> Arc { 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) -> Arc