mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-24 00:51:19 +00:00
feat(llm): register providers from resolved catalog
Resolve LLM credentials and adapter registration through the runtime catalog so settings-defined providers can be used for requests. This keeps built-in behavior default-equivalent while supporting provider IDs, aliases, extra headers, header-only auth, base URLs, and provider API model IDs at the adapter boundary.
This commit is contained in:
parent
264cac3c64
commit
cfb4ea91a3
47 changed files with 1614 additions and 273 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@ This document defines how Fabro resolves LLM credentials and constructs `fabro-l
|
|||
## Core Rules
|
||||
|
||||
- `fabro_auth::CredentialSource` is the credential authority.
|
||||
- Long-lived runtime contexts store `Arc<dyn CredentialSource>`, not `Client`.
|
||||
- Call `fabro_llm::client::Client::from_source(&source).await?` at the point of use.
|
||||
- Long-lived runtime contexts store `Arc<dyn CredentialSource>` and `Arc<Catalog>`, not `Client`.
|
||||
- Call `fabro_llm::client::Client::from_source_with_catalog(&source, catalog).await?` at the point of use when runtime catalog settings are available.
|
||||
- `Client::from_source(&source).await?` is the built-in-catalog fallback for setup, tests, and standalone contexts that do not have resolved runtime catalog settings.
|
||||
- `GenerateParams::new(model, client)` always receives an explicit `Arc<Client>`.
|
||||
- When a caller needs diagnostics, call `source.resolve()` directly and consume both `credentials` and `auth_issues`.
|
||||
- When a caller needs diagnostics in runtime request-serving paths, call `source.resolve_for_catalog(catalog)` directly and consume both `credentials` and `auth_issues`.
|
||||
- Use `source.resolve()` only in built-in-catalog fallback contexts.
|
||||
- `EnvCredentialSource` is the env-backed source for env-only or no-vault contexts.
|
||||
- `VaultCredentialSource` is the normal source for vault-backed runtime contexts.
|
||||
|
||||
|
|
@ -16,17 +18,19 @@ This document defines how Fabro resolves LLM credentials and constructs `fabro-l
|
|||
|
||||
- Rebuilding a client from the source at point of use preserves OAuth refresh behavior on long-running processes.
|
||||
- Holding the source on contexts avoids process-global installs and cross-context leakage.
|
||||
- Threading the catalog into credential resolution keeps custom providers, aliases, header-only providers, and API model IDs consistent across auth, client registration, and request translation.
|
||||
- Requiring an explicit client on `GenerateParams` makes the old silent fallback bug unrepresentable.
|
||||
|
||||
## Application
|
||||
|
||||
- Workflow state lives on `RunServices.llm_source`.
|
||||
- Server state lives on `AppState.llm_source`.
|
||||
- Hooks and other long-lived executors receive a source and derive clients when they actually generate.
|
||||
- One-shot CLI commands may resolve a source locally, then derive a client once for that operation.
|
||||
- Workflow state lives on `RunServices.llm_source` and `RunServices.catalog`.
|
||||
- Server state lives on `AppState.llm_source` and `AppState.catalog()`.
|
||||
- Hooks and other long-lived executors receive a source plus catalog and derive clients when they actually generate.
|
||||
- One-shot CLI commands may resolve a source locally, then derive a client once for that operation. Use the built-in-catalog path only when those commands do not load runtime catalog settings.
|
||||
|
||||
## Enforcement
|
||||
|
||||
- Do not add new `Client::from_env`-style shortcuts in production paths.
|
||||
- Do not cache a long-lived `Client` where OAuth refresh or storage-dir rebinding matters.
|
||||
- Mirror [server-secrets-strategy.md](/Users/bhelmkamp/p/fabro-sh/fabro-6/docs-internal/server-secrets-strategy.md): production credential resolution should be explicit about where secrets come from and how they flow into subprocesses.
|
||||
- Do not route runtime request-serving paths through `Client::from_source` or `CredentialSource::resolve()` if they have an `Arc<Catalog>`.
|
||||
- Mirror [server-secrets-strategy.md](server-secrets-strategy.md): production credential resolution should be explicit about where secrets come from and how they flow into subprocesses.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use fabro_llm::types::{
|
|||
use fabro_llm::{Error as LlmError, retry};
|
||||
use fabro_mcp::config::{McpServerSettings, McpTransport};
|
||||
use fabro_mcp::connection_manager::McpConnectionManager;
|
||||
use fabro_model::{ModelRef, Provider, Speed};
|
||||
use fabro_model::{Catalog, ModelRef, Provider, Speed};
|
||||
use fabro_types::Principal;
|
||||
use futures::StreamExt;
|
||||
use tokio::sync::{Mutex as AsyncMutex, Notify, broadcast};
|
||||
|
|
@ -329,6 +329,24 @@ impl Session {
|
|||
))
|
||||
}
|
||||
|
||||
pub async fn from_source_with_catalog(
|
||||
source: &dyn CredentialSource,
|
||||
catalog: Arc<Catalog>,
|
||||
provider_profile: Arc<dyn AgentProfile>,
|
||||
sandbox: Arc<dyn Sandbox>,
|
||||
config: SessionOptions,
|
||||
subagent_manager: Option<Arc<AsyncMutex<SubAgentManager>>>,
|
||||
) -> Result<Self, LlmError> {
|
||||
let client = Client::from_source_with_catalog(source, catalog).await?;
|
||||
Ok(Self::new(
|
||||
client,
|
||||
provider_profile,
|
||||
sandbox,
|
||||
config,
|
||||
subagent_manager,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn set_tool_env_provider(&mut self, provider: Arc<dyn ToolEnvProvider>) {
|
||||
self.tool_env_provider = Some(provider);
|
||||
}
|
||||
|
|
@ -347,6 +365,11 @@ impl Session {
|
|||
self.provider_profile.provider()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn provider_id(&self) -> fabro_model::ProviderId {
|
||||
self.provider_profile.provider_id()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn model(&self) -> &str {
|
||||
self.provider_profile.model()
|
||||
|
|
@ -364,7 +387,7 @@ impl Session {
|
|||
|
||||
self.event_emitter
|
||||
.emit(self.id.clone(), AgentEvent::SessionStarted {
|
||||
provider: Some(self.provider_profile.provider().to_string()),
|
||||
provider: Some(self.provider_profile.provider_id().to_string()),
|
||||
model: Some(self.provider_profile.model().to_string()),
|
||||
});
|
||||
|
||||
|
|
@ -1125,7 +1148,7 @@ impl Session {
|
|||
// Call LLM (streaming) with retry for transient errors
|
||||
let retry_emitter = self.event_emitter.clone();
|
||||
let retry_session_id = self.id.clone();
|
||||
let retry_provider = self.provider_profile.provider().to_string();
|
||||
let retry_provider = self.provider_profile.provider_id().to_string();
|
||||
let retry_model = self.provider_profile.model().to_string();
|
||||
let retry_policy = RetryPolicy {
|
||||
max_retries: 3,
|
||||
|
|
@ -1327,7 +1350,7 @@ impl Session {
|
|||
.as_deref()
|
||||
.and_then(|value| value.parse::<Speed>().ok());
|
||||
let model = ModelRef {
|
||||
provider: self.provider_profile.provider().id(),
|
||||
provider: self.provider_profile.provider_id(),
|
||||
model_id: if response.model.is_empty() {
|
||||
self.provider_profile.model().to_string()
|
||||
} else {
|
||||
|
|
@ -1525,7 +1548,7 @@ impl Session {
|
|||
Request {
|
||||
model: self.provider_profile.model().to_string(),
|
||||
messages,
|
||||
provider: Some(self.provider_profile.provider().to_string()),
|
||||
provider: Some(self.provider_profile.provider_id().to_string()),
|
||||
tools: if has_tools { Some(tools) } else { None },
|
||||
tool_choice: if has_tools {
|
||||
Some(ToolChoice::Auto)
|
||||
|
|
|
|||
|
|
@ -31,3 +31,4 @@ tokio.workspace = true
|
|||
httpmock = "0.8"
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true, features = ["macros", "test-util"] }
|
||||
toml.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use async_trait::async_trait;
|
||||
use fabro_model::ProviderId;
|
||||
use fabro_model::{Catalog, ProviderId};
|
||||
|
||||
use crate::{ApiCredential, ResolveError};
|
||||
|
||||
|
|
@ -14,4 +14,14 @@ pub trait CredentialSource: Send + Sync {
|
|||
async fn resolve(&self) -> anyhow::Result<ResolvedCredentials>;
|
||||
|
||||
async fn configured_providers(&self) -> Vec<ProviderId>;
|
||||
|
||||
async fn resolve_for_catalog(&self, catalog: &Catalog) -> anyhow::Result<ResolvedCredentials> {
|
||||
let _ = catalog;
|
||||
self.resolve().await
|
||||
}
|
||||
|
||||
async fn configured_providers_for_catalog(&self, catalog: &Catalog) -> Vec<ProviderId> {
|
||||
let _ = catalog;
|
||||
self.configured_providers().await
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ use std::sync::Arc;
|
|||
|
||||
use async_trait::async_trait;
|
||||
use fabro_model::catalog::CatalogProvider;
|
||||
use fabro_model::{Catalog, CredentialRef, HeaderValueRef, Provider, ProviderId};
|
||||
use fabro_model::{Catalog, CredentialRef, HeaderValueRef, Provider, ProviderId, adapter};
|
||||
use fabro_static::EnvVars;
|
||||
|
||||
use crate::credential_source::{CredentialSource, ResolvedCredentials};
|
||||
use crate::{ApiCredential, EnvLookup};
|
||||
use crate::{ApiCredential, EnvLookup, ResolveError, build_api_key_header};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct EnvCredentialSource {
|
||||
|
|
@ -32,20 +32,48 @@ impl EnvCredentialSource {
|
|||
(self.env_lookup)(name)
|
||||
}
|
||||
|
||||
fn credential_for(&self, provider: &CatalogProvider) -> Option<ApiCredential> {
|
||||
fn credential_for(
|
||||
&self,
|
||||
provider: &CatalogProvider,
|
||||
) -> Result<Option<ApiCredential>, ResolveError> {
|
||||
let key = provider.credentials.iter().find_map(|credential_ref| {
|
||||
let CredentialRef::Env(name) = credential_ref else {
|
||||
return None;
|
||||
};
|
||||
self.lookup(name)
|
||||
})?;
|
||||
});
|
||||
|
||||
let mut cred = ApiCredential::from_api_key(provider.id.clone(), key);
|
||||
if key.is_none() && provider.credentials.is_empty() && provider.extra_headers.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let extra_headers = self.resolved_extra_headers(provider)?;
|
||||
|
||||
if key.is_none() && (!provider.credentials.is_empty() || extra_headers.is_empty()) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let auth_header = key.map(|key| {
|
||||
let policy = adapter::get(&provider.adapter)
|
||||
.map_or(fabro_model::ApiKeyHeaderPolicy::Bearer, |adapter| {
|
||||
adapter.api_key_header
|
||||
});
|
||||
build_api_key_header(policy, key)
|
||||
});
|
||||
|
||||
let mut cred = ApiCredential {
|
||||
provider: provider.id.clone(),
|
||||
auth_header,
|
||||
extra_headers,
|
||||
base_url: None,
|
||||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
};
|
||||
cred.base_url = self
|
||||
.env_base_url(&provider.id)
|
||||
.or_else(|| provider.base_url.clone());
|
||||
cred.extra_headers = self.resolved_extra_headers(provider)?;
|
||||
if provider.id == Provider::OpenAi.id() {
|
||||
if provider.id == Provider::OpenAi.id() && cred.auth_header.is_some() {
|
||||
cred.org_id = self.lookup(EnvVars::OPENAI_ORG_ID);
|
||||
cred.project_id = self.lookup(EnvVars::OPENAI_PROJECT_ID);
|
||||
if let Some(account_id) = self.lookup(EnvVars::CHATGPT_ACCOUNT_ID) {
|
||||
|
|
@ -57,7 +85,7 @@ impl EnvCredentialSource {
|
|||
.insert("originator".to_string(), "fabro".to_string());
|
||||
}
|
||||
}
|
||||
Some(cred)
|
||||
Ok(Some(cred))
|
||||
}
|
||||
|
||||
fn env_base_url(&self, provider: &ProviderId) -> Option<String> {
|
||||
|
|
@ -74,7 +102,7 @@ impl EnvCredentialSource {
|
|||
fn resolved_extra_headers(
|
||||
&self,
|
||||
provider: &CatalogProvider,
|
||||
) -> Option<std::collections::HashMap<String, String>> {
|
||||
) -> Result<std::collections::HashMap<String, String>, ResolveError> {
|
||||
provider
|
||||
.extra_headers
|
||||
.iter()
|
||||
|
|
@ -83,8 +111,9 @@ impl EnvCredentialSource {
|
|||
HeaderValueRef::Literal(value) => Some(value.clone()),
|
||||
HeaderValueRef::Env(name) => self.lookup(name),
|
||||
HeaderValueRef::Credential(_) => None,
|
||||
}?;
|
||||
Some((name.clone(), value))
|
||||
}
|
||||
.ok_or_else(|| ResolveError::NotConfigured(provider.id.clone()))?;
|
||||
Ok((name.clone(), value))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -106,20 +135,35 @@ impl Default for EnvCredentialSource {
|
|||
#[async_trait]
|
||||
impl CredentialSource for EnvCredentialSource {
|
||||
async fn resolve(&self) -> anyhow::Result<ResolvedCredentials> {
|
||||
let credentials = Catalog::builtin()
|
||||
.providers()
|
||||
.iter()
|
||||
.filter_map(|provider| self.credential_for(provider))
|
||||
.collect();
|
||||
self.resolve_for_catalog(Catalog::builtin()).await
|
||||
}
|
||||
|
||||
async fn resolve_for_catalog(&self, catalog: &Catalog) -> anyhow::Result<ResolvedCredentials> {
|
||||
let mut credentials = Vec::new();
|
||||
let mut auth_issues = Vec::new();
|
||||
|
||||
for provider in catalog.providers() {
|
||||
match self.credential_for(provider) {
|
||||
Ok(Some(credential)) => credentials.push(credential),
|
||||
Ok(None) => {}
|
||||
Err(ResolveError::NotConfigured(_)) if !provider.credentials.is_empty() => {}
|
||||
Err(err) => auth_issues.push((provider.id.clone(), err)),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ResolvedCredentials {
|
||||
credentials,
|
||||
auth_issues: Vec::new(),
|
||||
auth_issues,
|
||||
})
|
||||
}
|
||||
|
||||
async fn configured_providers(&self) -> Vec<ProviderId> {
|
||||
Catalog::builtin()
|
||||
self.configured_providers_for_catalog(Catalog::builtin())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn configured_providers_for_catalog(&self, catalog: &Catalog) -> Vec<ProviderId> {
|
||||
catalog
|
||||
.providers()
|
||||
.iter()
|
||||
.filter(|provider| {
|
||||
|
|
@ -129,6 +173,9 @@ impl CredentialSource for EnvCredentialSource {
|
|||
.any(|credential_ref| {
|
||||
matches!(credential_ref, CredentialRef::Env(name) if self.lookup(name).is_some())
|
||||
})
|
||||
|| (!provider.extra_headers.is_empty()
|
||||
&& provider.credentials.is_empty()
|
||||
&& self.resolved_extra_headers(provider).is_ok())
|
||||
})
|
||||
.map(|provider| provider.id.clone())
|
||||
.collect()
|
||||
|
|
@ -140,7 +187,8 @@ mod tests {
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_model::Provider;
|
||||
use fabro_model::catalog::LlmCatalogSettings;
|
||||
use fabro_model::{Catalog, Provider, ProviderId};
|
||||
|
||||
use super::EnvCredentialSource;
|
||||
use crate::CredentialSource;
|
||||
|
|
@ -153,6 +201,11 @@ mod tests {
|
|||
EnvCredentialSource::with_env_lookup(Arc::new(move |name| entries.get(name).cloned()))
|
||||
}
|
||||
|
||||
fn catalog_with(overrides: &str) -> Catalog {
|
||||
let settings: LlmCatalogSettings = toml::from_str(overrides).unwrap();
|
||||
Catalog::from_builtin_with_overrides(&settings).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_providers_reads_injected_env() {
|
||||
let source = test_source(&[("ANTHROPIC_API_KEY", "anthropic-key")]);
|
||||
|
|
@ -209,4 +262,145 @@ mod tests {
|
|||
Some("https://api.moonshot.ai/v1")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_for_catalog_registers_custom_env_backed_provider() {
|
||||
let catalog = catalog_with(
|
||||
r#"
|
||||
[providers.venice]
|
||||
display_name = "Venice"
|
||||
adapter = "openai_compatible"
|
||||
base_url = "https://api.venice.ai/api/v1"
|
||||
credentials = ["env:VENICE_API_KEY"]
|
||||
|
||||
[models."venice-large"]
|
||||
provider = "venice"
|
||||
display_name = "Venice Large"
|
||||
family = "venice"
|
||||
default = true
|
||||
|
||||
[models."venice-large".limits]
|
||||
context_window = 128000
|
||||
|
||||
[models."venice-large".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
effort = false
|
||||
"#,
|
||||
);
|
||||
let source = test_source(&[("VENICE_API_KEY", "venice-key")]);
|
||||
|
||||
let resolved = source.resolve_for_catalog(&catalog).await.unwrap();
|
||||
let credential = resolved
|
||||
.credentials
|
||||
.iter()
|
||||
.find(|credential| credential.provider == ProviderId::new("venice"))
|
||||
.expect("custom provider should resolve from the supplied catalog");
|
||||
|
||||
assert_eq!(
|
||||
credential.auth_header.as_ref().unwrap(),
|
||||
&crate::ApiKeyHeader::Bearer("venice-key".to_string(),)
|
||||
);
|
||||
assert_eq!(
|
||||
credential.base_url.as_deref(),
|
||||
Some("https://api.venice.ai/api/v1")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_for_catalog_registers_header_only_provider() {
|
||||
let catalog = catalog_with(
|
||||
r#"
|
||||
[providers.portkey]
|
||||
display_name = "Portkey Bedrock"
|
||||
adapter = "anthropic"
|
||||
base_url = "https://api.portkey.ai/v1"
|
||||
|
||||
[providers.portkey.extra_headers]
|
||||
x-portkey-api-key = { env = "PORTKEY_API_KEY" }
|
||||
x-portkey-provider = { literal = "@bedrock-prod" }
|
||||
|
||||
[models."portkey-claude"]
|
||||
provider = "portkey"
|
||||
display_name = "Portkey Claude"
|
||||
family = "claude"
|
||||
default = true
|
||||
|
||||
[models."portkey-claude".limits]
|
||||
context_window = 200000
|
||||
|
||||
[models."portkey-claude".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
effort = true
|
||||
"#,
|
||||
);
|
||||
let source = test_source(&[("PORTKEY_API_KEY", "pk-live")]);
|
||||
|
||||
let resolved = source.resolve_for_catalog(&catalog).await.unwrap();
|
||||
let credential = resolved
|
||||
.credentials
|
||||
.iter()
|
||||
.find(|credential| credential.provider == ProviderId::new("portkey"))
|
||||
.expect("header-only provider should register when all headers resolve");
|
||||
|
||||
assert!(credential.auth_header.is_none());
|
||||
assert_eq!(
|
||||
credential.extra_headers.get("x-portkey-api-key"),
|
||||
Some(&"pk-live".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
credential.extra_headers.get("x-portkey-provider"),
|
||||
Some(&"@bedrock-prod".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_for_catalog_reports_missing_required_header() {
|
||||
let catalog = catalog_with(
|
||||
r#"
|
||||
[providers.portkey]
|
||||
display_name = "Portkey Bedrock"
|
||||
adapter = "anthropic"
|
||||
base_url = "https://api.portkey.ai/v1"
|
||||
|
||||
[providers.portkey.extra_headers]
|
||||
x-portkey-api-key = { env = "PORTKEY_API_KEY" }
|
||||
|
||||
[models."portkey-claude"]
|
||||
provider = "portkey"
|
||||
display_name = "Portkey Claude"
|
||||
family = "claude"
|
||||
default = true
|
||||
|
||||
[models."portkey-claude".limits]
|
||||
context_window = 200000
|
||||
|
||||
[models."portkey-claude".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
effort = true
|
||||
"#,
|
||||
);
|
||||
let source = test_source(&[]);
|
||||
|
||||
let resolved = source.resolve_for_catalog(&catalog).await.unwrap();
|
||||
|
||||
assert!(
|
||||
!resolved
|
||||
.credentials
|
||||
.iter()
|
||||
.any(|credential| credential.provider == ProviderId::new("portkey"))
|
||||
);
|
||||
assert!(
|
||||
resolved
|
||||
.auth_issues
|
||||
.iter()
|
||||
.any(|(provider, issue)| provider == &ProviderId::new("portkey")
|
||||
&& matches!(issue, crate::ResolveError::NotConfigured(_)))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_model::catalog::CatalogProvider;
|
||||
use fabro_model::{
|
||||
ApiKeyHeaderPolicy, Catalog, CredentialRef, HeaderValueRef, Provider, ProviderId, adapter,
|
||||
};
|
||||
|
|
@ -32,7 +33,7 @@ pub enum CredentialUsage {
|
|||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ApiCredential {
|
||||
pub provider: ProviderId,
|
||||
pub auth_header: ApiKeyHeader,
|
||||
pub auth_header: Option<ApiKeyHeader>,
|
||||
pub extra_headers: HashMap<String, String>,
|
||||
pub base_url: Option<String>,
|
||||
pub codex_mode: bool,
|
||||
|
|
@ -51,7 +52,7 @@ impl ApiCredential {
|
|||
let auth_header = auth_header_for_provider(&provider, key);
|
||||
Self {
|
||||
provider,
|
||||
auth_header,
|
||||
auth_header: Some(auth_header),
|
||||
extra_headers: HashMap::new(),
|
||||
base_url: None,
|
||||
codex_mode: false,
|
||||
|
|
@ -86,6 +87,12 @@ fn auth_header_for_provider(provider: &ProviderId, key: String) -> ApiKeyHeader
|
|||
build_api_key_header(policy, key)
|
||||
}
|
||||
|
||||
fn auth_header_for_catalog_provider(provider: &CatalogProvider, key: String) -> ApiKeyHeader {
|
||||
let policy = adapter::get(&provider.adapter)
|
||||
.map_or(ApiKeyHeaderPolicy::Bearer, |adapter| adapter.api_key_header);
|
||||
build_api_key_header(policy, key)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CliCredential {
|
||||
pub env_vars: HashMap<String, String>,
|
||||
|
|
@ -214,10 +221,18 @@ impl CredentialResolver {
|
|||
|
||||
#[must_use]
|
||||
pub fn configured_providers(&self, vault: &Vault) -> Vec<ProviderId> {
|
||||
Catalog::builtin()
|
||||
self.configured_providers_for_catalog(vault, Catalog::builtin())
|
||||
}
|
||||
|
||||
pub fn configured_providers_for_catalog(
|
||||
&self,
|
||||
vault: &Vault,
|
||||
catalog: &Catalog,
|
||||
) -> Vec<ProviderId> {
|
||||
catalog
|
||||
.providers()
|
||||
.iter()
|
||||
.filter(|provider| self.has_credential_material(vault, &provider.id))
|
||||
.filter(|provider| self.has_credential_material(vault, provider, catalog))
|
||||
.map(|provider| provider.id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -252,16 +267,50 @@ impl CredentialResolver {
|
|||
Err(ResolveError::NotConfigured(provider.clone()))
|
||||
}
|
||||
|
||||
fn has_credential_material(&self, vault: &Vault, provider: &ProviderId) -> bool {
|
||||
Catalog::builtin().provider(provider).map_or_else(
|
||||
|| vault_get_credential(vault, provider.as_str()).is_some(),
|
||||
|catalog_provider| {
|
||||
catalog_provider.credentials.iter().any(|credential_ref| {
|
||||
self.credential_from_ref(vault, provider, credential_ref)
|
||||
.is_some()
|
||||
})
|
||||
},
|
||||
)
|
||||
fn find_credential_for_catalog(
|
||||
&self,
|
||||
vault: &Vault,
|
||||
provider: &CatalogProvider,
|
||||
usage: CredentialUsage,
|
||||
) -> Result<AuthCredential, ResolveError> {
|
||||
if provider.id == Provider::OpenAi.id()
|
||||
&& usage == CredentialUsage::CliAgent(CliAgentKind::Codex)
|
||||
{
|
||||
for credential_id in ["openai_codex", "openai"] {
|
||||
if let Some(credential) = vault_get_credential(vault, credential_id) {
|
||||
return Ok(credential);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for credential_ref in &provider.credentials {
|
||||
if let Some(credential) = self.credential_from_ref(vault, &provider.id, credential_ref)
|
||||
{
|
||||
return Ok(credential);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(credential) = vault_get_credential(vault, provider.id.as_str()) {
|
||||
return Ok(credential);
|
||||
}
|
||||
|
||||
Err(ResolveError::NotConfigured(provider.id.clone()))
|
||||
}
|
||||
|
||||
fn has_credential_material(
|
||||
&self,
|
||||
vault: &Vault,
|
||||
provider: &CatalogProvider,
|
||||
catalog: &Catalog,
|
||||
) -> bool {
|
||||
provider.credentials.iter().any(|credential_ref| {
|
||||
self.credential_from_ref(vault, &provider.id, credential_ref)
|
||||
.is_some()
|
||||
}) || (!provider.extra_headers.is_empty()
|
||||
&& provider.credentials.is_empty()
|
||||
&& self
|
||||
.resolved_extra_headers_for_catalog(vault, &provider.id, catalog)
|
||||
.is_ok())
|
||||
}
|
||||
|
||||
fn credential_from_ref(
|
||||
|
|
@ -286,7 +335,12 @@ impl CredentialResolver {
|
|||
(self.env_lookup)(name).or_else(|| vault.get(name).map(str::to_string))
|
||||
}
|
||||
|
||||
fn provider_base_url(&self, vault: &Vault, provider: &ProviderId) -> Option<String> {
|
||||
fn provider_base_url_for_catalog(
|
||||
&self,
|
||||
vault: &Vault,
|
||||
provider: &ProviderId,
|
||||
catalog: &Catalog,
|
||||
) -> Option<String> {
|
||||
let env_base_url = match Provider::from_id(provider) {
|
||||
Some(Provider::Anthropic) => {
|
||||
self.lookup_env_or_vault(vault, EnvVars::ANTHROPIC_BASE_URL)
|
||||
|
|
@ -300,18 +354,19 @@ impl CredentialResolver {
|
|||
}
|
||||
};
|
||||
env_base_url.or_else(|| {
|
||||
Catalog::builtin()
|
||||
catalog
|
||||
.provider(provider)
|
||||
.and_then(|provider| provider.base_url.clone())
|
||||
})
|
||||
}
|
||||
|
||||
fn resolved_extra_headers(
|
||||
fn resolved_extra_headers_for_catalog(
|
||||
&self,
|
||||
vault: &Vault,
|
||||
provider: &ProviderId,
|
||||
catalog: &Catalog,
|
||||
) -> Result<HashMap<String, String>, ResolveError> {
|
||||
let Some(catalog_provider) = Catalog::builtin().provider(provider) else {
|
||||
let Some(catalog_provider) = catalog.provider(provider) else {
|
||||
return Ok(HashMap::new());
|
||||
};
|
||||
catalog_provider
|
||||
|
|
@ -334,13 +389,34 @@ impl CredentialResolver {
|
|||
vault: &Vault,
|
||||
credential: &AuthCredential,
|
||||
) -> Result<ApiCredential, ResolveError> {
|
||||
let base_url = self.provider_base_url(vault, &credential.provider);
|
||||
self.to_api_credential_for_catalog(vault, credential, Catalog::builtin())
|
||||
}
|
||||
|
||||
fn to_api_credential_for_catalog(
|
||||
&self,
|
||||
vault: &Vault,
|
||||
credential: &AuthCredential,
|
||||
catalog: &Catalog,
|
||||
) -> Result<ApiCredential, ResolveError> {
|
||||
let base_url = self.provider_base_url_for_catalog(vault, &credential.provider, catalog);
|
||||
match &credential.details {
|
||||
AuthDetails::ApiKey { key } => {
|
||||
let mut cred =
|
||||
ApiCredential::from_api_key(credential.provider.clone(), key.clone());
|
||||
let auth_header = catalog.provider(&credential.provider).map_or_else(
|
||||
|| auth_header_for_provider(&credential.provider, key.clone()),
|
||||
|provider| auth_header_for_catalog_provider(provider, key.clone()),
|
||||
);
|
||||
let mut cred = ApiCredential {
|
||||
provider: credential.provider.clone(),
|
||||
auth_header: Some(auth_header),
|
||||
extra_headers: HashMap::new(),
|
||||
base_url: None,
|
||||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
};
|
||||
cred.base_url = base_url;
|
||||
cred.extra_headers = self.resolved_extra_headers(vault, &credential.provider)?;
|
||||
cred.extra_headers =
|
||||
self.resolved_extra_headers_for_catalog(vault, &credential.provider, catalog)?;
|
||||
if credential.provider == Provider::OpenAi.id() {
|
||||
cred.org_id = self.lookup_env_or_vault(vault, EnvVars::OPENAI_ORG_ID);
|
||||
cred.project_id = self.lookup_env_or_vault(vault, EnvVars::OPENAI_PROJECT_ID);
|
||||
|
|
@ -357,7 +433,7 @@ impl CredentialResolver {
|
|||
}
|
||||
Ok(ApiCredential {
|
||||
provider: credential.provider.clone(),
|
||||
auth_header: ApiKeyHeader::Bearer(tokens.access_token.clone()),
|
||||
auth_header: Some(ApiKeyHeader::Bearer(tokens.access_token.clone())),
|
||||
extra_headers,
|
||||
base_url: Some("https://chatgpt.com/backend-api/codex".to_string()),
|
||||
codex_mode: true,
|
||||
|
|
@ -368,6 +444,72 @@ impl CredentialResolver {
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn resolve_for_catalog(
|
||||
&self,
|
||||
provider: impl Into<ProviderId>,
|
||||
usage: CredentialUsage,
|
||||
catalog: &Catalog,
|
||||
) -> Result<ResolvedCredential, ResolveError> {
|
||||
let provider_id = provider.into();
|
||||
let Some(catalog_provider) = catalog.provider(&provider_id) else {
|
||||
return self.resolve(provider_id, usage).await;
|
||||
};
|
||||
let initial_credential = {
|
||||
let vault = self.vault.read().await;
|
||||
self.find_credential_for_catalog(&vault, catalog_provider, usage)?
|
||||
};
|
||||
|
||||
let credential = if initial_credential.needs_refresh() {
|
||||
let AuthDetails::CodexOAuth { tokens, .. } = &initial_credential.details else {
|
||||
unreachable!("only OAuth credentials can need refresh");
|
||||
};
|
||||
if tokens.refresh_token.is_none() {
|
||||
return Err(ResolveError::RefreshTokenMissing(provider_id.clone()));
|
||||
}
|
||||
|
||||
refresh_oauth_credential(&initial_credential)
|
||||
.await
|
||||
.map_err(|source| ResolveError::RefreshFailed {
|
||||
provider: provider_id.clone(),
|
||||
source,
|
||||
})?
|
||||
} else {
|
||||
initial_credential
|
||||
};
|
||||
|
||||
let vault = self.vault.read().await;
|
||||
match usage {
|
||||
CredentialUsage::ApiRequest => self
|
||||
.to_api_credential_for_catalog(&vault, &credential, catalog)
|
||||
.map(ResolvedCredential::Api),
|
||||
CredentialUsage::CliAgent(kind) => Ok(ResolvedCredential::Cli(
|
||||
Self::to_cli_credential(&credential, kind),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn header_only_api_credential_for_catalog(
|
||||
&self,
|
||||
provider: &CatalogProvider,
|
||||
catalog: &Catalog,
|
||||
) -> Result<Option<ApiCredential>, ResolveError> {
|
||||
if !provider.credentials.is_empty() || provider.extra_headers.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let vault = self.vault.read().await;
|
||||
let extra_headers =
|
||||
self.resolved_extra_headers_for_catalog(&vault, &provider.id, catalog)?;
|
||||
Ok(Some(ApiCredential {
|
||||
provider: provider.id.clone(),
|
||||
auth_header: None,
|
||||
extra_headers,
|
||||
base_url: provider.base_url.clone(),
|
||||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
}))
|
||||
}
|
||||
|
||||
fn to_cli_credential(credential: &AuthCredential, kind: CliAgentKind) -> CliCredential {
|
||||
let mut env_vars = HashMap::new();
|
||||
let provider = Provider::from_id(&credential.provider);
|
||||
|
|
@ -535,7 +677,7 @@ mod tests {
|
|||
};
|
||||
assert_eq!(
|
||||
api.auth_header,
|
||||
ApiKeyHeader::Bearer("vault-key".to_string())
|
||||
Some(ApiKeyHeader::Bearer("vault-key".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -564,7 +706,7 @@ mod tests {
|
|||
};
|
||||
assert_eq!(
|
||||
api.auth_header,
|
||||
ApiKeyHeader::Bearer("expired-access".to_string())
|
||||
Some(ApiKeyHeader::Bearer("expired-access".to_string()))
|
||||
);
|
||||
assert!(api.codex_mode);
|
||||
assert_eq!(
|
||||
|
|
@ -610,10 +752,13 @@ mod tests {
|
|||
panic!("expected api credential");
|
||||
};
|
||||
|
||||
assert_eq!(api.auth_header, ApiKeyHeader::Custom {
|
||||
name: "x-api-key".to_string(),
|
||||
value: "anthropic-key".to_string(),
|
||||
});
|
||||
assert_eq!(
|
||||
api.auth_header,
|
||||
Some(ApiKeyHeader::Custom {
|
||||
name: "x-api-key".to_string(),
|
||||
value: "anthropic-key".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -646,7 +791,7 @@ mod tests {
|
|||
};
|
||||
assert_eq!(
|
||||
api.auth_header,
|
||||
ApiKeyHeader::Bearer("compat-key".to_string())
|
||||
Some(ApiKeyHeader::Bearer("compat-key".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
api.base_url.as_deref(),
|
||||
|
|
@ -976,7 +1121,7 @@ mod tests {
|
|||
fn api_credential_debug_redacts_secret_material() {
|
||||
let credential = ApiCredential {
|
||||
provider: Provider::OpenAi.id(),
|
||||
auth_header: ApiKeyHeader::Bearer("sk-test".to_string()),
|
||||
auth_header: Some(ApiKeyHeader::Bearer("sk-test".to_string())),
|
||||
extra_headers: HashMap::new(),
|
||||
base_url: None,
|
||||
codex_mode: false,
|
||||
|
|
|
|||
|
|
@ -42,17 +42,32 @@ impl std::fmt::Debug for VaultCredentialSource {
|
|||
#[async_trait]
|
||||
impl CredentialSource for VaultCredentialSource {
|
||||
async fn resolve(&self) -> anyhow::Result<ResolvedCredentials> {
|
||||
self.resolve_for_catalog(Catalog::builtin()).await
|
||||
}
|
||||
|
||||
async fn resolve_for_catalog(&self, catalog: &Catalog) -> anyhow::Result<ResolvedCredentials> {
|
||||
let mut credentials = Vec::new();
|
||||
let mut auth_issues = Vec::new();
|
||||
|
||||
for provider in Catalog::builtin().providers() {
|
||||
for provider in catalog.providers() {
|
||||
match self
|
||||
.resolver
|
||||
.resolve(provider.id.clone(), CredentialUsage::ApiRequest)
|
||||
.resolve_for_catalog(provider.id.clone(), CredentialUsage::ApiRequest, catalog)
|
||||
.await
|
||||
{
|
||||
Ok(ResolvedCredential::Api(credential)) => credentials.push(credential),
|
||||
Ok(ResolvedCredential::Cli(_)) | Err(ResolveError::NotConfigured(_)) => {}
|
||||
Ok(ResolvedCredential::Cli(_)) => {}
|
||||
Err(ResolveError::NotConfigured(_)) => {
|
||||
match self
|
||||
.resolver
|
||||
.header_only_api_credential_for_catalog(provider, catalog)
|
||||
.await
|
||||
{
|
||||
Ok(Some(credential)) => credentials.push(credential),
|
||||
Ok(None) => {}
|
||||
Err(err) => auth_issues.push((provider.id.clone(), err)),
|
||||
}
|
||||
}
|
||||
Err(err) => auth_issues.push((provider.id.clone(), err)),
|
||||
}
|
||||
}
|
||||
|
|
@ -64,8 +79,14 @@ impl CredentialSource for VaultCredentialSource {
|
|||
}
|
||||
|
||||
async fn configured_providers(&self) -> Vec<ProviderId> {
|
||||
self.configured_providers_for_catalog(Catalog::builtin())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn configured_providers_for_catalog(&self, catalog: &Catalog) -> Vec<ProviderId> {
|
||||
let vault = self.vault.read().await;
|
||||
self.resolver.configured_providers(&vault)
|
||||
self.resolver
|
||||
.configured_providers_for_catalog(&vault, catalog)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ mod tests {
|
|||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use fabro_model::Catalog;
|
||||
use fabro_types::fixtures;
|
||||
|
||||
use super::*;
|
||||
|
|
@ -96,6 +97,7 @@ mod tests {
|
|||
_sandbox: Arc<dyn Sandbox>,
|
||||
_work_dir: Option<&Path>,
|
||||
_llm_source: &dyn fabro_auth::CredentialSource,
|
||||
_catalog: Arc<Catalog>,
|
||||
) -> HookResult {
|
||||
self.captured_contexts.lock().unwrap().push(context.clone());
|
||||
HookResult {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use fabro_auth::CredentialSource;
|
|||
use fabro_llm::client::Client as LlmClient;
|
||||
use fabro_llm::generate::{GenerateParams, generate_object};
|
||||
use fabro_llm::types::{Message, Request, ToolResult};
|
||||
use fabro_model::Catalog;
|
||||
use fabro_template::{TemplateContext, render as render_template};
|
||||
use fabro_types::settings::InterpString;
|
||||
use fabro_util::env::{Env, SystemEnv};
|
||||
|
|
@ -50,6 +51,7 @@ pub trait HookExecutor: Send + Sync {
|
|||
sandbox: Arc<dyn Sandbox>,
|
||||
work_dir: Option<&Path>,
|
||||
llm_source: &dyn CredentialSource,
|
||||
catalog: Arc<Catalog>,
|
||||
) -> HookResult;
|
||||
}
|
||||
|
||||
|
|
@ -253,9 +255,9 @@ impl HookExecutorImpl {
|
|||
}
|
||||
|
||||
/// Resolve a model alias (e.g. "haiku") to a concrete model ID.
|
||||
fn resolve_model(model: Option<&str>) -> String {
|
||||
fn resolve_model(model: Option<&str>, catalog: &Catalog) -> String {
|
||||
let model_id = model.unwrap_or("haiku");
|
||||
let model_info = fabro_model::Catalog::builtin().get(model_id);
|
||||
let model_info = catalog.get(model_id);
|
||||
model_info.map_or(model_id, |m| m.id.as_str()).to_string()
|
||||
}
|
||||
|
||||
|
|
@ -291,6 +293,7 @@ impl HookExecutorImpl {
|
|||
context: &HookContext,
|
||||
env: &E,
|
||||
llm_source: &dyn CredentialSource,
|
||||
catalog: Arc<Catalog>,
|
||||
) -> HookDecision
|
||||
where
|
||||
E: Env + Clone + Send + Sync + fmt::Debug + 'static,
|
||||
|
|
@ -300,11 +303,11 @@ impl HookExecutorImpl {
|
|||
return HookDecision::Proceed;
|
||||
};
|
||||
|
||||
let resolved_model = Self::resolve_model(model.as_deref());
|
||||
let resolved_model = Self::resolve_model(model.as_deref(), catalog.as_ref());
|
||||
let user_msg = Self::build_hook_user_message(&prompt, context);
|
||||
|
||||
Self::execute_llm_with_timeout(definition.timeout(), "prompt", || async move {
|
||||
let client = match LlmClient::from_source(llm_source).await {
|
||||
let client = match LlmClient::from_source_with_catalog(llm_source, catalog).await {
|
||||
Ok(client) => Arc::new(client),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "prompt hook client creation failed, proceeding");
|
||||
|
|
@ -354,6 +357,7 @@ impl HookExecutorImpl {
|
|||
sandbox: Arc<dyn Sandbox>,
|
||||
env: &E,
|
||||
llm_source: &dyn CredentialSource,
|
||||
catalog: Arc<Catalog>,
|
||||
) -> HookDecision
|
||||
where
|
||||
E: Env + Clone + Send + Sync + fmt::Debug + 'static,
|
||||
|
|
@ -363,11 +367,11 @@ impl HookExecutorImpl {
|
|||
return HookDecision::Proceed;
|
||||
};
|
||||
|
||||
let resolved_model = Self::resolve_model(model.as_deref());
|
||||
let resolved_model = Self::resolve_model(model.as_deref(), catalog.as_ref());
|
||||
let user_msg = Self::build_hook_user_message(&prompt, context);
|
||||
|
||||
Self::execute_llm_with_timeout(definition.timeout(), "agent", || async move {
|
||||
let client = match LlmClient::from_source(llm_source).await {
|
||||
let client = match LlmClient::from_source_with_catalog(llm_source, catalog).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "agent hook client creation failed, proceeding");
|
||||
|
|
@ -617,6 +621,7 @@ impl HookExecutor for HookExecutorImpl {
|
|||
sandbox: Arc<dyn Sandbox>,
|
||||
work_dir: Option<&Path>,
|
||||
llm_source: &dyn CredentialSource,
|
||||
catalog: Arc<Catalog>,
|
||||
) -> HookResult {
|
||||
use std::sync::OnceLock;
|
||||
static HTTP_CLIENTS: OnceLock<HttpClientCache> = OnceLock::new();
|
||||
|
|
@ -675,6 +680,7 @@ impl HookExecutor for HookExecutorImpl {
|
|||
context,
|
||||
&env,
|
||||
llm_source,
|
||||
Arc::clone(&catalog),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
|
@ -699,6 +705,7 @@ impl HookExecutor for HookExecutorImpl {
|
|||
sandbox,
|
||||
&env,
|
||||
llm_source,
|
||||
Arc::clone(&catalog),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
|
@ -719,6 +726,7 @@ impl HookExecutor for HookExecutorImpl {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_auth::{CredentialSource, EnvCredentialSource};
|
||||
use fabro_model::catalog::LlmCatalogSettings;
|
||||
use fabro_types::fixtures;
|
||||
use fabro_util::env::TestEnv;
|
||||
|
||||
|
|
@ -740,6 +748,10 @@ mod tests {
|
|||
Arc::new(EnvCredentialSource::new())
|
||||
}
|
||||
|
||||
fn test_catalog() -> Arc<Catalog> {
|
||||
Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap())
|
||||
}
|
||||
|
||||
fn test_http_client() -> fabro_http::HttpClient {
|
||||
HookExecutorImpl::build_http_client(TlsMode::Off)
|
||||
}
|
||||
|
|
@ -822,7 +834,7 @@ mod tests {
|
|||
let sandbox = make_sandbox();
|
||||
let source = test_llm_source();
|
||||
let result = executor
|
||||
.execute(&def, &ctx, sandbox, None, source.as_ref())
|
||||
.execute(&def, &ctx, sandbox, None, source.as_ref(), test_catalog())
|
||||
.await;
|
||||
assert_eq!(result.decision, HookDecision::Proceed);
|
||||
assert_eq!(result.hook_name.as_deref(), Some("test-hook"));
|
||||
|
|
@ -836,7 +848,7 @@ mod tests {
|
|||
let sandbox = make_sandbox();
|
||||
let source = test_llm_source();
|
||||
let result = executor
|
||||
.execute(&def, &ctx, sandbox, None, source.as_ref())
|
||||
.execute(&def, &ctx, sandbox, None, source.as_ref(), test_catalog())
|
||||
.await;
|
||||
assert!(matches!(result.decision, HookDecision::Block { .. }));
|
||||
}
|
||||
|
|
@ -849,7 +861,7 @@ mod tests {
|
|||
let sandbox = make_sandbox();
|
||||
let source = test_llm_source();
|
||||
let result = executor
|
||||
.execute(&def, &ctx, sandbox, None, source.as_ref())
|
||||
.execute(&def, &ctx, sandbox, None, source.as_ref(), test_catalog())
|
||||
.await;
|
||||
assert!(matches!(result.decision, HookDecision::Block { .. }));
|
||||
}
|
||||
|
|
@ -862,7 +874,7 @@ mod tests {
|
|||
let sandbox = make_sandbox();
|
||||
let source = test_llm_source();
|
||||
let result = executor
|
||||
.execute(&def, &ctx, sandbox, None, source.as_ref())
|
||||
.execute(&def, &ctx, sandbox, None, source.as_ref(), test_catalog())
|
||||
.await;
|
||||
assert_eq!(result.decision, HookDecision::Skip {
|
||||
reason: Some("test skip".into()),
|
||||
|
|
@ -879,7 +891,7 @@ mod tests {
|
|||
let sandbox = make_sandbox();
|
||||
let source = test_llm_source();
|
||||
let result = executor
|
||||
.execute(&def, &ctx, sandbox, None, source.as_ref())
|
||||
.execute(&def, &ctx, sandbox, None, source.as_ref(), test_catalog())
|
||||
.await;
|
||||
assert_eq!(result.decision, HookDecision::Proceed);
|
||||
}
|
||||
|
|
@ -901,7 +913,7 @@ mod tests {
|
|||
let sandbox = make_sandbox();
|
||||
let source = test_llm_source();
|
||||
let result = executor
|
||||
.execute(&def, &ctx, sandbox, None, source.as_ref())
|
||||
.execute(&def, &ctx, sandbox, None, source.as_ref(), test_catalog())
|
||||
.await;
|
||||
assert!(matches!(result.decision, HookDecision::Block { .. }));
|
||||
}
|
||||
|
|
@ -1296,7 +1308,7 @@ mod tests {
|
|||
let sandbox = make_sandbox();
|
||||
let source = test_llm_source();
|
||||
let result = executor
|
||||
.execute(&def, &ctx, sandbox, None, source.as_ref())
|
||||
.execute(&def, &ctx, sandbox, None, source.as_ref(), test_catalog())
|
||||
.await;
|
||||
|
||||
mock.assert_async().await;
|
||||
|
|
@ -1329,6 +1341,7 @@ mod tests {
|
|||
&make_context(),
|
||||
&test_env(&[]),
|
||||
test_llm_source().as_ref(),
|
||||
test_catalog(),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
|
@ -1346,6 +1359,7 @@ mod tests {
|
|||
make_sandbox(),
|
||||
&test_env(&[]),
|
||||
test_llm_source().as_ref(),
|
||||
test_catalog(),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ use fabro_agent::Sandbox;
|
|||
use fabro_auth::CredentialSource;
|
||||
#[cfg(test)]
|
||||
use fabro_auth::EnvCredentialSource;
|
||||
use fabro_model::Catalog;
|
||||
#[cfg(test)]
|
||||
use fabro_model::catalog::LlmCatalogSettings;
|
||||
|
||||
use crate::config::{HookDefinition, HookSettings};
|
||||
use crate::executor::{HookExecutor, HookExecutorImpl};
|
||||
|
|
@ -17,18 +20,24 @@ pub struct HookRunner {
|
|||
config: HookSettings,
|
||||
executor: Arc<dyn HookExecutor>,
|
||||
llm_source: Arc<dyn CredentialSource>,
|
||||
catalog: Arc<Catalog>,
|
||||
/// Pre-compiled regexes keyed by matcher pattern string.
|
||||
compiled_matchers: HashMap<String, regex::Regex>,
|
||||
}
|
||||
|
||||
impl HookRunner {
|
||||
#[must_use]
|
||||
pub fn new(config: HookSettings, llm_source: Arc<dyn CredentialSource>) -> Self {
|
||||
pub fn new(
|
||||
config: HookSettings,
|
||||
llm_source: Arc<dyn CredentialSource>,
|
||||
catalog: Arc<Catalog>,
|
||||
) -> Self {
|
||||
let compiled_matchers = Self::compile_matchers(&config);
|
||||
Self {
|
||||
config,
|
||||
executor: Arc::new(HookExecutorImpl),
|
||||
llm_source,
|
||||
catalog,
|
||||
compiled_matchers,
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +50,10 @@ impl HookRunner {
|
|||
config,
|
||||
executor,
|
||||
llm_source: Arc::new(EnvCredentialSource::new()),
|
||||
catalog: Arc::new(
|
||||
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
|
||||
.expect("default catalog should build"),
|
||||
),
|
||||
compiled_matchers,
|
||||
}
|
||||
}
|
||||
|
|
@ -152,6 +165,7 @@ impl HookRunner {
|
|||
sandbox.clone(),
|
||||
work_dir,
|
||||
self.llm_source.as_ref(),
|
||||
Arc::clone(&self.catalog),
|
||||
)
|
||||
.await;
|
||||
tracing::debug!(
|
||||
|
|
@ -206,6 +220,7 @@ impl HookRunner {
|
|||
sandbox.clone(),
|
||||
work_dir,
|
||||
self.llm_source.as_ref(),
|
||||
Arc::clone(&self.catalog),
|
||||
)
|
||||
.await;
|
||||
tracing::debug!(
|
||||
|
|
@ -249,6 +264,7 @@ mod tests {
|
|||
_sandbox: Arc<dyn Sandbox>,
|
||||
_work_dir: Option<&Path>,
|
||||
_llm_source: &dyn CredentialSource,
|
||||
_catalog: Arc<Catalog>,
|
||||
) -> HookResult {
|
||||
HookResult {
|
||||
hook_name: definition.name.clone(),
|
||||
|
|
@ -272,6 +288,13 @@ mod tests {
|
|||
Arc::new(EnvCredentialSource::new())
|
||||
}
|
||||
|
||||
fn test_catalog() -> Arc<Catalog> {
|
||||
Arc::new(
|
||||
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
|
||||
.expect("default catalog should build"),
|
||||
)
|
||||
}
|
||||
|
||||
fn make_hook(event: HookEvent, name: &str) -> HookDefinition {
|
||||
HookDefinition {
|
||||
name: Some(name.into()),
|
||||
|
|
@ -287,7 +310,7 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn no_hooks_returns_proceed() {
|
||||
let runner = HookRunner::new(HookSettings::default(), test_llm_source());
|
||||
let runner = HookRunner::new(HookSettings::default(), test_llm_source(), test_catalog());
|
||||
let ctx = make_context(HookEvent::RunStart);
|
||||
let sandbox = make_sandbox();
|
||||
let decision = runner.run(&ctx, sandbox.clone(), None).await;
|
||||
|
|
@ -452,7 +475,7 @@ mod tests {
|
|||
h
|
||||
}],
|
||||
};
|
||||
let runner = HookRunner::new(config, test_llm_source());
|
||||
let runner = HookRunner::new(config, test_llm_source(), test_catalog());
|
||||
let ctx = make_context(HookEvent::RunStart);
|
||||
let sandbox = make_sandbox();
|
||||
let decision = runner.run(&ctx, sandbox.clone(), None).await;
|
||||
|
|
@ -468,7 +491,7 @@ mod tests {
|
|||
h
|
||||
}],
|
||||
};
|
||||
let runner = HookRunner::new(config, test_llm_source());
|
||||
let runner = HookRunner::new(config, test_llm_source(), test_catalog());
|
||||
let ctx = make_context(HookEvent::RunStart);
|
||||
let sandbox = make_sandbox();
|
||||
let decision = runner.run(&ctx, sandbox.clone(), None).await;
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use std::collections::HashMap;
|
|||
use std::sync::Arc;
|
||||
|
||||
use fabro_auth::ApiKeyHeader;
|
||||
use fabro_model::Catalog;
|
||||
use fabro_model::adapter::{self as model_adapter, AdapterMetadata};
|
||||
|
||||
use crate::client::auth_value;
|
||||
|
|
@ -32,7 +33,7 @@ pub struct AdapterConfig {
|
|||
pub provider_id: String,
|
||||
/// Authentication header constructed by `fabro-auth` from the resolved
|
||||
/// credential and the adapter's [`fabro_model::ApiKeyHeaderPolicy`].
|
||||
pub auth_header: ApiKeyHeader,
|
||||
pub auth_header: Option<ApiKeyHeader>,
|
||||
/// Provider base URL override. `None` means use the adapter's built-in
|
||||
/// default.
|
||||
pub base_url: Option<String>,
|
||||
|
|
@ -44,19 +45,21 @@ pub struct AdapterConfig {
|
|||
pub org_id: Option<String>,
|
||||
/// OpenAI-only: project ID.
|
||||
pub project_id: Option<String>,
|
||||
pub catalog: Option<Arc<Catalog>>,
|
||||
}
|
||||
|
||||
impl AdapterConfig {
|
||||
/// Construct a minimal config with just provider ID and auth header.
|
||||
pub fn new(provider_id: impl Into<String>, auth_header: ApiKeyHeader) -> Self {
|
||||
Self {
|
||||
provider_id: provider_id.into(),
|
||||
auth_header,
|
||||
base_url: None,
|
||||
provider_id: provider_id.into(),
|
||||
auth_header: Some(auth_header),
|
||||
base_url: None,
|
||||
extra_headers: HashMap::new(),
|
||||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
catalog: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -69,14 +72,24 @@ impl AdapterConfig {
|
|||
/// rather than re-shaping every existing factory.
|
||||
pub type AdapterFactory = fn(AdapterConfig) -> Arc<dyn ProviderAdapter>;
|
||||
|
||||
fn auth_value_optional(auth_header: Option<&ApiKeyHeader>) -> Option<String> {
|
||||
auth_header.map(auth_value)
|
||||
}
|
||||
|
||||
fn build_anthropic_adapter(config: AdapterConfig) -> providers::AnthropicAdapter {
|
||||
let mut adapter = providers::AnthropicAdapter::new(auth_value(&config.auth_header));
|
||||
let mut adapter = providers::AnthropicAdapter::new_optional_auth(auth_value_optional(
|
||||
config.auth_header.as_ref(),
|
||||
))
|
||||
.with_name(config.provider_id.clone());
|
||||
if let Some(base_url) = config.base_url {
|
||||
adapter = adapter.with_base_url(base_url);
|
||||
}
|
||||
if !config.extra_headers.is_empty() {
|
||||
adapter = adapter.with_default_headers(config.extra_headers);
|
||||
}
|
||||
if let Some(catalog) = config.catalog {
|
||||
adapter = adapter.with_catalog(catalog);
|
||||
}
|
||||
adapter
|
||||
}
|
||||
|
||||
|
|
@ -85,7 +98,10 @@ fn build_anthropic(config: AdapterConfig) -> Arc<dyn ProviderAdapter> {
|
|||
}
|
||||
|
||||
fn build_openai_adapter(config: AdapterConfig) -> providers::OpenAiAdapter {
|
||||
let mut adapter = providers::OpenAiAdapter::new(auth_value(&config.auth_header));
|
||||
let mut adapter = providers::OpenAiAdapter::new_optional_auth(auth_value_optional(
|
||||
config.auth_header.as_ref(),
|
||||
))
|
||||
.with_name(config.provider_id.clone());
|
||||
if let Some(base_url) = config.base_url {
|
||||
adapter = adapter.with_base_url(base_url);
|
||||
}
|
||||
|
|
@ -101,6 +117,9 @@ fn build_openai_adapter(config: AdapterConfig) -> providers::OpenAiAdapter {
|
|||
if let Some(project_id) = config.project_id {
|
||||
adapter = adapter.with_project_id(project_id);
|
||||
}
|
||||
if let Some(catalog) = config.catalog {
|
||||
adapter = adapter.with_catalog(catalog);
|
||||
}
|
||||
adapter
|
||||
}
|
||||
|
||||
|
|
@ -109,13 +128,19 @@ fn build_openai(config: AdapterConfig) -> Arc<dyn ProviderAdapter> {
|
|||
}
|
||||
|
||||
fn build_gemini_adapter(config: AdapterConfig) -> providers::GeminiAdapter {
|
||||
let mut adapter = providers::GeminiAdapter::new(auth_value(&config.auth_header));
|
||||
let mut adapter = providers::GeminiAdapter::new_optional_auth(auth_value_optional(
|
||||
config.auth_header.as_ref(),
|
||||
))
|
||||
.with_name(config.provider_id.clone());
|
||||
if let Some(base_url) = config.base_url {
|
||||
adapter = adapter.with_base_url(base_url);
|
||||
}
|
||||
if !config.extra_headers.is_empty() {
|
||||
adapter = adapter.with_default_headers(config.extra_headers);
|
||||
}
|
||||
if let Some(catalog) = config.catalog {
|
||||
adapter = adapter.with_catalog(catalog);
|
||||
}
|
||||
adapter
|
||||
}
|
||||
|
||||
|
|
@ -132,12 +157,17 @@ fn build_openai_compatible_adapter(config: AdapterConfig) -> providers::OpenAiCo
|
|||
"openai_compatible adapter requires a base_url; resolve it from provider settings before \
|
||||
building AdapterConfig",
|
||||
);
|
||||
let mut adapter =
|
||||
providers::OpenAiCompatibleAdapter::new(auth_value(&config.auth_header), base_url)
|
||||
.with_name(config.provider_id);
|
||||
let mut adapter = providers::OpenAiCompatibleAdapter::new_optional_auth(
|
||||
auth_value_optional(config.auth_header.as_ref()),
|
||||
base_url,
|
||||
)
|
||||
.with_name(config.provider_id);
|
||||
if !config.extra_headers.is_empty() {
|
||||
adapter = adapter.with_default_headers(config.extra_headers);
|
||||
}
|
||||
if let Some(catalog) = config.catalog {
|
||||
adapter = adapter.with_catalog(catalog);
|
||||
}
|
||||
adapter
|
||||
}
|
||||
|
||||
|
|
@ -228,12 +258,13 @@ mod tests {
|
|||
fn openai_compatible_factory_uses_provider_id_for_name() {
|
||||
let config = AdapterConfig {
|
||||
provider_id: "kimi".to_string(),
|
||||
auth_header: ApiKeyHeader::Bearer("k".to_string()),
|
||||
auth_header: Some(ApiKeyHeader::Bearer("k".to_string())),
|
||||
base_url: Some("https://api.moonshot.ai/v1".to_string()),
|
||||
extra_headers: HashMap::new(),
|
||||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
catalog: None,
|
||||
};
|
||||
let adapter = factory_for("openai_compatible").unwrap()(config);
|
||||
assert_eq!(adapter.name(), "kimi");
|
||||
|
|
@ -243,7 +274,7 @@ mod tests {
|
|||
fn openai_compatible_factory_preserves_extra_headers() {
|
||||
let config = AdapterConfig {
|
||||
provider_id: "portkey".to_string(),
|
||||
auth_header: ApiKeyHeader::Bearer("unused-primary-key".to_string()),
|
||||
auth_header: Some(ApiKeyHeader::Bearer("unused-primary-key".to_string())),
|
||||
base_url: Some("https://api.portkey.ai/v1".to_string()),
|
||||
extra_headers: HashMap::from([
|
||||
(
|
||||
|
|
@ -258,6 +289,7 @@ mod tests {
|
|||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
catalog: None,
|
||||
};
|
||||
|
||||
let adapter = build_openai_compatible_adapter(config);
|
||||
|
|
@ -277,10 +309,10 @@ mod tests {
|
|||
fn anthropic_factory_preserves_extra_headers() {
|
||||
let config = AdapterConfig {
|
||||
provider_id: "anthropic-through-portkey".to_string(),
|
||||
auth_header: ApiKeyHeader::Custom {
|
||||
auth_header: Some(ApiKeyHeader::Custom {
|
||||
name: "x-api-key".to_string(),
|
||||
value: "unused-primary-key".to_string(),
|
||||
},
|
||||
}),
|
||||
base_url: Some("https://api.portkey.ai/v1".to_string()),
|
||||
extra_headers: HashMap::from([(
|
||||
"x-portkey-api-key".to_string(),
|
||||
|
|
@ -289,11 +321,12 @@ mod tests {
|
|||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
catalog: None,
|
||||
};
|
||||
|
||||
let adapter = build_anthropic_adapter(config);
|
||||
|
||||
assert_eq!(adapter.name(), "anthropic");
|
||||
assert_eq!(adapter.name(), "anthropic-through-portkey");
|
||||
assert_eq!(
|
||||
adapter.http.default_headers.get("x-portkey-api-key"),
|
||||
Some(&"resolved-portkey-key".to_string()),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ use std::collections::HashMap;
|
|||
use std::sync::Arc;
|
||||
|
||||
use fabro_auth::{ApiCredential, ApiKeyHeader, CredentialSource};
|
||||
use fabro_model::catalog::LlmCatalogSettings;
|
||||
use fabro_model::{Catalog, ProviderId};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::adapter_registry::{AdapterConfig, factory_for};
|
||||
|
|
@ -16,6 +18,7 @@ pub struct Client {
|
|||
providers: HashMap<String, Arc<dyn ProviderAdapter>>,
|
||||
default_provider: Option<String>,
|
||||
middleware: Vec<Arc<dyn Middleware>>,
|
||||
catalog: Option<Arc<Catalog>>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
|
|
@ -30,6 +33,7 @@ impl Client {
|
|||
providers,
|
||||
default_provider,
|
||||
middleware,
|
||||
catalog: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -47,21 +51,52 @@ impl Client {
|
|||
Self::from_credentials(resolved.credentials).await
|
||||
}
|
||||
|
||||
pub async fn from_source_with_catalog(
|
||||
source: &dyn CredentialSource,
|
||||
catalog: Arc<Catalog>,
|
||||
) -> Result<Self, Error> {
|
||||
let resolved =
|
||||
source
|
||||
.resolve_for_catalog(&catalog)
|
||||
.await
|
||||
.map_err(|err| Error::Configuration {
|
||||
message: format!("Failed to resolve LLM credentials: {err}"),
|
||||
source: None,
|
||||
})?;
|
||||
Self::from_credentials_with_catalog(resolved.credentials, catalog).await
|
||||
}
|
||||
|
||||
/// Create a Client from typed provider credentials.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error` if any provider adapter fails to initialize.
|
||||
pub async fn from_credentials(credentials: Vec<ApiCredential>) -> Result<Self, Error> {
|
||||
let catalog = Arc::new(
|
||||
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).map_err(
|
||||
|err| Error::Configuration {
|
||||
message: "Failed to build bootstrap LLM catalog".to_string(),
|
||||
source: Some(Arc::new(err)),
|
||||
},
|
||||
)?,
|
||||
);
|
||||
Self::from_credentials_with_catalog(credentials, catalog).await
|
||||
}
|
||||
|
||||
pub async fn from_credentials_with_catalog(
|
||||
credentials: Vec<ApiCredential>,
|
||||
catalog: Arc<Catalog>,
|
||||
) -> Result<Self, Error> {
|
||||
let mut client = Self {
|
||||
providers: HashMap::new(),
|
||||
default_provider: None,
|
||||
middleware: Vec::new(),
|
||||
catalog: Some(Arc::clone(&catalog)),
|
||||
};
|
||||
|
||||
for credential in credentials {
|
||||
let provider_id = credential.provider.clone();
|
||||
let Some(provider) = fabro_model::Catalog::builtin().provider(&provider_id) else {
|
||||
let Some(provider) = catalog.provider(&provider_id) else {
|
||||
return Err(Error::Configuration {
|
||||
message: format!(
|
||||
"Provider \"{provider_id}\" is not supported by credential-only registration"
|
||||
|
|
@ -87,6 +122,7 @@ impl Client {
|
|||
codex_mode: credential.codex_mode,
|
||||
org_id: credential.org_id,
|
||||
project_id: credential.project_id,
|
||||
catalog: Some(Arc::clone(&catalog)),
|
||||
});
|
||||
client.register_provider(adapter).await?;
|
||||
}
|
||||
|
|
@ -125,11 +161,23 @@ impl Client {
|
|||
self.middleware.push(mw);
|
||||
}
|
||||
|
||||
fn canonical_provider_name(&self, provider_name: &str) -> String {
|
||||
self.catalog
|
||||
.as_ref()
|
||||
.and_then(|catalog| catalog.provider(&ProviderId::new(provider_name)))
|
||||
.map_or_else(
|
||||
|| provider_name.to_string(),
|
||||
|provider| provider.id.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolve the provider for a request.
|
||||
fn resolve_provider(&self, request: &Request) -> Result<Arc<dyn ProviderAdapter>, Error> {
|
||||
let catalog_provider = fabro_model::Catalog::builtin()
|
||||
.get(&request.model)
|
||||
.map(|info| info.provider.to_string());
|
||||
let catalog_provider = self.catalog.as_ref().and_then(|catalog| {
|
||||
catalog
|
||||
.get(&request.model)
|
||||
.map(|info| info.provider.to_string())
|
||||
});
|
||||
|
||||
let provider_name = request
|
||||
.provider
|
||||
|
|
@ -140,9 +188,10 @@ impl Client {
|
|||
message: "No provider specified and no default provider set".into(),
|
||||
source: None,
|
||||
})?;
|
||||
let provider_name = self.canonical_provider_name(provider_name);
|
||||
|
||||
self.providers
|
||||
.get(provider_name)
|
||||
.get(&provider_name)
|
||||
.cloned()
|
||||
.ok_or_else(|| Error::Configuration {
|
||||
message: format!("Provider '{provider_name}' not registered"),
|
||||
|
|
@ -241,6 +290,11 @@ impl Client {
|
|||
#[must_use]
|
||||
pub fn has_provider(&self, name: &str) -> bool {
|
||||
self.providers.contains_key(name)
|
||||
|| self
|
||||
.catalog
|
||||
.as_ref()
|
||||
.and_then(|catalog| catalog.provider(&ProviderId::new(name)))
|
||||
.is_some_and(|provider| self.providers.contains_key(provider.id.as_str()))
|
||||
}
|
||||
|
||||
/// Get the default provider name.
|
||||
|
|
@ -260,6 +314,7 @@ pub(crate) fn auth_value(auth_header: &ApiKeyHeader) -> String {
|
|||
mod tests {
|
||||
use async_trait::async_trait;
|
||||
use fabro_auth::{CredentialSource, ResolvedCredentials};
|
||||
use fabro_model::catalog::LlmCatalogSettings;
|
||||
use futures::stream;
|
||||
|
||||
use super::*;
|
||||
|
|
@ -352,6 +407,11 @@ mod tests {
|
|||
credentials: Vec<ApiCredential>,
|
||||
}
|
||||
|
||||
fn catalog_with(overrides: &str) -> Arc<Catalog> {
|
||||
let settings: LlmCatalogSettings = toml::from_str(overrides).unwrap();
|
||||
Arc::new(Catalog::from_builtin_with_overrides(&settings).unwrap())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CredentialSource for StubSource {
|
||||
async fn resolve(&self) -> anyhow::Result<ResolvedCredentials> {
|
||||
|
|
@ -428,10 +488,10 @@ mod tests {
|
|||
let client = Client::from_credentials(vec![
|
||||
ApiCredential {
|
||||
provider: fabro_model::Provider::Anthropic.id(),
|
||||
auth_header: ApiKeyHeader::Custom {
|
||||
auth_header: Some(ApiKeyHeader::Custom {
|
||||
name: "x-api-key".to_string(),
|
||||
value: "anthropic-key".to_string(),
|
||||
},
|
||||
}),
|
||||
extra_headers: HashMap::new(),
|
||||
base_url: None,
|
||||
codex_mode: false,
|
||||
|
|
@ -440,7 +500,7 @@ mod tests {
|
|||
},
|
||||
ApiCredential {
|
||||
provider: fabro_model::Provider::OpenAi.id(),
|
||||
auth_header: ApiKeyHeader::Bearer("openai-key".to_string()),
|
||||
auth_header: Some(ApiKeyHeader::Bearer("openai-key".to_string())),
|
||||
extra_headers: HashMap::new(),
|
||||
base_url: None,
|
||||
codex_mode: false,
|
||||
|
|
@ -461,7 +521,7 @@ mod tests {
|
|||
async fn from_credentials_supports_openai_compatible_provider_constants() {
|
||||
let client = Client::from_credentials(vec![ApiCredential {
|
||||
provider: fabro_model::Provider::Kimi.id(),
|
||||
auth_header: ApiKeyHeader::Bearer("kimi-key".to_string()),
|
||||
auth_header: Some(ApiKeyHeader::Bearer("kimi-key".to_string())),
|
||||
extra_headers: HashMap::new(),
|
||||
base_url: None,
|
||||
codex_mode: false,
|
||||
|
|
@ -479,7 +539,7 @@ mod tests {
|
|||
async fn from_credentials_rejects_custom_provider_id_without_adapter() {
|
||||
let result = Client::from_credentials(vec![ApiCredential {
|
||||
provider: fabro_model::ProviderId::new("venice"),
|
||||
auth_header: ApiKeyHeader::Bearer("venice-key".to_string()),
|
||||
auth_header: Some(ApiKeyHeader::Bearer("venice-key".to_string())),
|
||||
extra_headers: HashMap::new(),
|
||||
base_url: None,
|
||||
codex_mode: false,
|
||||
|
|
@ -505,10 +565,10 @@ mod tests {
|
|||
let source = StubSource {
|
||||
credentials: vec![ApiCredential {
|
||||
provider: fabro_model::Provider::Anthropic.id(),
|
||||
auth_header: ApiKeyHeader::Custom {
|
||||
auth_header: Some(ApiKeyHeader::Custom {
|
||||
name: "x-api-key".to_string(),
|
||||
value: "anthropic-key".to_string(),
|
||||
},
|
||||
}),
|
||||
extra_headers: HashMap::new(),
|
||||
base_url: None,
|
||||
codex_mode: false,
|
||||
|
|
@ -522,6 +582,154 @@ mod tests {
|
|||
assert_eq!(client.provider_names(), vec!["anthropic"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_credentials_with_catalog_registers_custom_openai_compatible_provider() {
|
||||
let catalog = catalog_with(
|
||||
r#"
|
||||
[providers.venice]
|
||||
display_name = "Venice"
|
||||
adapter = "openai_compatible"
|
||||
base_url = "https://api.venice.ai/api/v1"
|
||||
credentials = ["env:VENICE_API_KEY"]
|
||||
aliases = ["venice-ai"]
|
||||
|
||||
[models."venice-large"]
|
||||
provider = "venice"
|
||||
display_name = "Venice Large"
|
||||
family = "venice"
|
||||
default = true
|
||||
|
||||
[models."venice-large".limits]
|
||||
context_window = 128000
|
||||
|
||||
[models."venice-large".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
effort = false
|
||||
"#,
|
||||
);
|
||||
|
||||
let client = Client::from_credentials_with_catalog(
|
||||
vec![ApiCredential {
|
||||
provider: fabro_model::ProviderId::new("venice"),
|
||||
auth_header: Some(ApiKeyHeader::Bearer("venice-key".to_string())),
|
||||
extra_headers: HashMap::new(),
|
||||
base_url: None,
|
||||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
}],
|
||||
Arc::clone(&catalog),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(client.provider_names(), vec!["venice"]);
|
||||
assert!(client.has_provider("venice"));
|
||||
assert!(client.has_provider("venice-ai"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_provider_accepts_catalog_provider_alias() {
|
||||
let catalog = catalog_with(
|
||||
r#"
|
||||
[providers.venice]
|
||||
display_name = "Venice"
|
||||
adapter = "openai_compatible"
|
||||
base_url = "https://api.venice.ai/api/v1"
|
||||
credentials = ["env:VENICE_API_KEY"]
|
||||
aliases = ["venice-ai"]
|
||||
|
||||
[models."venice-large"]
|
||||
provider = "venice"
|
||||
display_name = "Venice Large"
|
||||
family = "venice"
|
||||
default = true
|
||||
|
||||
[models."venice-large".limits]
|
||||
context_window = 128000
|
||||
|
||||
[models."venice-large".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
effort = false
|
||||
"#,
|
||||
);
|
||||
|
||||
let client = Client::from_credentials_with_catalog(
|
||||
vec![ApiCredential {
|
||||
provider: fabro_model::ProviderId::new("venice"),
|
||||
auth_header: Some(ApiKeyHeader::Bearer("venice-key".to_string())),
|
||||
extra_headers: HashMap::new(),
|
||||
base_url: None,
|
||||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
}],
|
||||
Arc::clone(&catalog),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut request = test_request();
|
||||
request.provider = Some("venice-ai".to_string());
|
||||
|
||||
let provider = client.resolve_provider(&request).unwrap();
|
||||
|
||||
assert_eq!(provider.name(), "venice");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_credentials_with_catalog_registers_header_only_provider() {
|
||||
let catalog = catalog_with(
|
||||
r#"
|
||||
[providers.portkey]
|
||||
display_name = "Portkey Bedrock"
|
||||
adapter = "anthropic"
|
||||
base_url = "https://api.portkey.ai/v1"
|
||||
|
||||
[providers.portkey.extra_headers]
|
||||
x-portkey-api-key = { literal = "pk-live" }
|
||||
|
||||
[models."portkey-claude"]
|
||||
provider = "portkey"
|
||||
display_name = "Portkey Claude"
|
||||
family = "claude"
|
||||
default = true
|
||||
|
||||
[models."portkey-claude".limits]
|
||||
context_window = 200000
|
||||
|
||||
[models."portkey-claude".features]
|
||||
tools = true
|
||||
vision = true
|
||||
reasoning = true
|
||||
effort = true
|
||||
"#,
|
||||
);
|
||||
|
||||
let client = Client::from_credentials_with_catalog(
|
||||
vec![ApiCredential {
|
||||
provider: fabro_model::ProviderId::new("portkey"),
|
||||
auth_header: None,
|
||||
extra_headers: HashMap::from([(
|
||||
"x-portkey-api-key".to_string(),
|
||||
"pk-live".to_string(),
|
||||
)]),
|
||||
base_url: None,
|
||||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
}],
|
||||
Arc::clone(&catalog),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(client.provider_names(), vec!["portkey"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_source_supports_empty_credentials() {
|
||||
let source = StubSource {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use fabro_model::Catalog;
|
||||
use futures::stream;
|
||||
|
||||
use crate::error::{Error, error_from_status_code};
|
||||
|
|
@ -18,14 +21,21 @@ use crate::types::{
|
|||
pub struct Adapter {
|
||||
pub(crate) http: super::http_api::HttpApi,
|
||||
provider_name: String,
|
||||
catalog: Option<Arc<Catalog>>,
|
||||
}
|
||||
|
||||
impl Adapter {
|
||||
#[must_use]
|
||||
pub fn new(api_key: impl Into<String>) -> Self {
|
||||
Self::new_optional_auth(Some(api_key.into()))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new_optional_auth(api_key: Option<String>) -> Self {
|
||||
Self {
|
||||
http: super::http_api::HttpApi::new(api_key, DEFAULT_BASE_URL),
|
||||
http: super::http_api::HttpApi::new_optional(api_key, DEFAULT_BASE_URL),
|
||||
provider_name: "anthropic".to_string(),
|
||||
catalog: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -49,6 +59,12 @@ impl Adapter {
|
|||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_catalog(mut self, catalog: Arc<Catalog>) -> Self {
|
||||
self.catalog = Some(catalog);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_timeout(self, timeout: AdapterTimeout) -> Self {
|
||||
Self {
|
||||
|
|
@ -1145,7 +1161,8 @@ async fn build_api_request(
|
|||
// Check whether this model supports the `output_config.effort` parameter.
|
||||
// Older reasoning models (e.g. claude-sonnet-4-5) need `thinking` with
|
||||
// `budget_tokens` instead.
|
||||
let model_info = fabro_model::Catalog::builtin().get(&request.model);
|
||||
let model_info = common::catalog_model(adapter.catalog.as_deref(), &request.model)
|
||||
.or_else(|| Catalog::builtin().get(&request.model));
|
||||
let supports_effort = model_info.is_none_or(|m| m.features.effort);
|
||||
|
||||
let mut resolved_max_tokens = request
|
||||
|
|
@ -1195,7 +1212,7 @@ async fn build_api_request(
|
|||
let is_fast = request.speed.as_deref() == Some("fast");
|
||||
|
||||
let api_request = ApiRequest {
|
||||
model: request.model.clone(),
|
||||
model: common::api_model_id(adapter.catalog.as_deref(), &request.model),
|
||||
messages: api_messages,
|
||||
max_tokens: resolved_max_tokens,
|
||||
system: system_value,
|
||||
|
|
@ -1219,9 +1236,10 @@ async fn build_api_request(
|
|||
}
|
||||
|
||||
if adapter.provider_name == "anthropic" {
|
||||
req_builder = req_builder
|
||||
.header("x-api-key", &adapter.http.api_key)
|
||||
.header("anthropic-version", "2023-06-01");
|
||||
if let Some(api_key) = &adapter.http.api_key {
|
||||
req_builder = req_builder.header("x-api-key", api_key);
|
||||
}
|
||||
req_builder = req_builder.header("anthropic-version", "2023-06-01");
|
||||
|
||||
let include_1m_context = model_info.is_some_and(|m| m.context_window() >= 1_000_000);
|
||||
if let Some(beta_str) = build_beta_header(
|
||||
|
|
@ -1232,8 +1250,8 @@ async fn build_api_request(
|
|||
) {
|
||||
req_builder = req_builder.header("anthropic-beta", beta_str);
|
||||
}
|
||||
} else {
|
||||
req_builder = req_builder.bearer_auth(&adapter.http.api_key);
|
||||
} else if let Some(api_key) = &adapter.http.api_key {
|
||||
req_builder = req_builder.bearer_auth(api_key);
|
||||
}
|
||||
|
||||
let req_builder = req_builder.json(&merge_provider_options(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use fabro_http::HeaderMap;
|
||||
use fabro_model::Catalog;
|
||||
use futures::stream;
|
||||
|
||||
use crate::error::{
|
||||
|
|
@ -22,16 +25,31 @@ const DEFAULT_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta
|
|||
/// Provider adapter for the Google Gemini `generateContent` API.
|
||||
pub struct Adapter {
|
||||
pub(crate) http: super::http_api::HttpApi,
|
||||
provider_name: String,
|
||||
catalog: Option<Arc<Catalog>>,
|
||||
}
|
||||
|
||||
impl Adapter {
|
||||
#[must_use]
|
||||
pub fn new(api_key: impl Into<String>) -> Self {
|
||||
Self::new_optional_auth(Some(api_key.into()))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new_optional_auth(api_key: Option<String>) -> Self {
|
||||
Self {
|
||||
http: super::http_api::HttpApi::new(api_key, DEFAULT_BASE_URL),
|
||||
http: super::http_api::HttpApi::new_optional(api_key, DEFAULT_BASE_URL),
|
||||
provider_name: "gemini".to_string(),
|
||||
catalog: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_name(mut self, name: impl Into<String>) -> Self {
|
||||
self.provider_name = name.into();
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||
self.http.base_url = base_url.into();
|
||||
|
|
@ -42,13 +60,21 @@ impl Adapter {
|
|||
pub fn with_default_headers(self, headers: std::collections::HashMap<String, String>) -> Self {
|
||||
Self {
|
||||
http: self.http.with_default_headers(headers),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_catalog(mut self, catalog: Arc<Catalog>) -> Self {
|
||||
self.catalog = Some(catalog);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_timeout(self, timeout: AdapterTimeout) -> Self {
|
||||
Self {
|
||||
http: self.http.with_timeout(timeout),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -894,8 +920,8 @@ impl SseStreamState {
|
|||
|
||||
#[async_trait::async_trait]
|
||||
impl ProviderAdapter for Adapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"gemini"
|
||||
fn name(&self) -> &str {
|
||||
&self.provider_name
|
||||
}
|
||||
|
||||
async fn complete(&self, request: &Request) -> Result<Response, Error> {
|
||||
|
|
@ -904,16 +930,16 @@ impl ProviderAdapter for Adapter {
|
|||
}
|
||||
let api_body = build_api_request(request).await;
|
||||
|
||||
let api_model = common::api_model_id(self.catalog.as_deref(), &request.model);
|
||||
let url = format!(
|
||||
"{}/models/{}:generateContent",
|
||||
self.http.base_url, request.model
|
||||
self.http.base_url, api_model
|
||||
);
|
||||
|
||||
let mut req = self
|
||||
.http
|
||||
.client
|
||||
.post(&url)
|
||||
.header("x-goog-api-key", &self.http.api_key);
|
||||
let mut req = self.http.client.post(&url);
|
||||
if let Some(api_key) = &self.http.api_key {
|
||||
req = req.header("x-goog-api-key", api_key);
|
||||
}
|
||||
for (key, value) in &self.http.default_headers {
|
||||
req = req.header(key, value);
|
||||
}
|
||||
|
|
@ -953,7 +979,7 @@ impl ProviderAdapter for Adapter {
|
|||
Ok(Response {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
model: request.model.clone(),
|
||||
provider: "gemini".to_string(),
|
||||
provider: self.provider_name.clone(),
|
||||
message: Message {
|
||||
role: Role::Assistant,
|
||||
content: content_parts,
|
||||
|
|
@ -974,16 +1000,16 @@ impl ProviderAdapter for Adapter {
|
|||
}
|
||||
let api_body = build_api_request(request).await;
|
||||
|
||||
let api_model = common::api_model_id(self.catalog.as_deref(), &request.model);
|
||||
let url = format!(
|
||||
"{}/models/{}:streamGenerateContent?alt=sse",
|
||||
self.http.base_url, request.model
|
||||
self.http.base_url, api_model
|
||||
);
|
||||
|
||||
let mut req = self
|
||||
.http
|
||||
.client
|
||||
.post(&url)
|
||||
.header("x-goog-api-key", &self.http.api_key);
|
||||
let mut req = self.http.client.post(&url);
|
||||
if let Some(api_key) = &self.http.api_key {
|
||||
req = req.header("x-goog-api-key", api_key);
|
||||
}
|
||||
for (key, value) in &self.http.default_headers {
|
||||
req = req.header(key, value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use crate::types::AdapterTimeout;
|
|||
/// configuration that every provider needs. Provider-specific fields live on
|
||||
/// the adapter struct itself.
|
||||
pub struct HttpApi {
|
||||
pub(crate) api_key: String,
|
||||
pub(crate) api_key: Option<String>,
|
||||
pub(crate) base_url: String,
|
||||
pub(crate) default_headers: HashMap<String, String>,
|
||||
pub(crate) client: fabro_http::HttpClient,
|
||||
|
|
@ -27,10 +27,15 @@ impl HttpApi {
|
|||
|
||||
#[must_use]
|
||||
pub fn new(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
|
||||
Self::new_optional(Some(api_key.into()), base_url)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new_optional(api_key: Option<String>, base_url: impl Into<String>) -> Self {
|
||||
let timeout = AdapterTimeout::default();
|
||||
let client = Self::build_client(timeout);
|
||||
Self {
|
||||
api_key: api_key.into(),
|
||||
api_key,
|
||||
base_url: base_url.into(),
|
||||
default_headers: HashMap::new(),
|
||||
client,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use fabro_model::Catalog;
|
||||
use futures::{StreamExt, stream};
|
||||
|
||||
use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind, error_from_status_code};
|
||||
|
|
@ -25,6 +28,8 @@ pub struct Adapter {
|
|||
pub(crate) http: super::http_api::HttpApi,
|
||||
org_id: Option<String>,
|
||||
project_id: Option<String>,
|
||||
provider_name: String,
|
||||
catalog: Option<Arc<Catalog>>,
|
||||
/// When true, always use streaming (required by the Codex endpoint).
|
||||
codex_mode: bool,
|
||||
}
|
||||
|
|
@ -32,14 +37,27 @@ pub struct Adapter {
|
|||
impl Adapter {
|
||||
#[must_use]
|
||||
pub fn new(api_key: impl Into<String>) -> Self {
|
||||
Self::new_optional_auth(Some(api_key.into()))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new_optional_auth(api_key: Option<String>) -> Self {
|
||||
Self {
|
||||
http: super::http_api::HttpApi::new(api_key, DEFAULT_BASE_URL),
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
codex_mode: false,
|
||||
http: super::http_api::HttpApi::new_optional(api_key, DEFAULT_BASE_URL),
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
provider_name: "openai".to_string(),
|
||||
catalog: None,
|
||||
codex_mode: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_name(mut self, name: impl Into<String>) -> Self {
|
||||
self.provider_name = name.into();
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_codex_mode(mut self) -> Self {
|
||||
self.codex_mode = true;
|
||||
|
|
@ -72,6 +90,12 @@ impl Adapter {
|
|||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_catalog(mut self, catalog: Arc<Catalog>) -> Self {
|
||||
self.catalog = Some(catalog);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_timeout(self, timeout: AdapterTimeout) -> Self {
|
||||
Self {
|
||||
|
|
@ -88,7 +112,9 @@ impl Adapter {
|
|||
for (key, value) in &self.http.default_headers {
|
||||
req = req.header(key, value);
|
||||
}
|
||||
req = req.bearer_auth(&self.http.api_key);
|
||||
if let Some(api_key) = &self.http.api_key {
|
||||
req = req.bearer_auth(api_key);
|
||||
}
|
||||
if let Some(org_id) = &self.org_id {
|
||||
req = req.header("OpenAI-Organization", org_id);
|
||||
}
|
||||
|
|
@ -471,7 +497,12 @@ fn translate_response_format(format: &ResponseFormat) -> Option<serde_json::Valu
|
|||
/// When `codex_mode` is true, unsupported fields (`temperature`,
|
||||
/// `max_output_tokens`, `top_p`) are omitted and empty instructions are sent as
|
||||
/// `""` (required by the Codex endpoint).
|
||||
async fn build_api_request(request: &Request, stream: bool, codex_mode: bool) -> ApiRequest {
|
||||
async fn build_api_request(
|
||||
request: &Request,
|
||||
stream: bool,
|
||||
codex_mode: bool,
|
||||
catalog: Option<&Catalog>,
|
||||
) -> ApiRequest {
|
||||
let (instructions, input) = translate_input(&request.messages).await;
|
||||
let api_tools = request.tools.as_ref().map(|t| translate_tools(t));
|
||||
let tool_choice = request.tool_choice.as_ref().map(translate_tool_choice);
|
||||
|
|
@ -493,7 +524,7 @@ async fn build_api_request(request: &Request, stream: bool, codex_mode: bool) ->
|
|||
};
|
||||
|
||||
ApiRequest {
|
||||
model: request.model.clone(),
|
||||
model: common::api_model_id(catalog, &request.model),
|
||||
input,
|
||||
instructions,
|
||||
temperature: if codex_mode {
|
||||
|
|
@ -520,12 +551,22 @@ async fn build_api_request(request: &Request, stream: bool, codex_mode: bool) ->
|
|||
|
||||
/// Serialize an `ApiRequest` to JSON and merge any `provider_options.openai`
|
||||
/// keys into it.
|
||||
#[cfg(test)]
|
||||
async fn build_request_body(
|
||||
request: &Request,
|
||||
stream: bool,
|
||||
codex_mode: bool,
|
||||
) -> serde_json::Value {
|
||||
let api_request = build_api_request(request, stream, codex_mode).await;
|
||||
build_request_body_with_catalog(request, stream, codex_mode, None).await
|
||||
}
|
||||
|
||||
async fn build_request_body_with_catalog(
|
||||
request: &Request,
|
||||
stream: bool,
|
||||
codex_mode: bool,
|
||||
catalog: Option<&Catalog>,
|
||||
) -> serde_json::Value {
|
||||
let api_request = build_api_request(request, stream, codex_mode, catalog).await;
|
||||
let mut body = serde_json::to_value(&api_request).unwrap_or_else(|_| serde_json::json!({}));
|
||||
|
||||
if let Some(openai_opts) = request
|
||||
|
|
@ -1024,8 +1065,8 @@ fn handle_response_completed(
|
|||
|
||||
#[async_trait::async_trait]
|
||||
impl ProviderAdapter for Adapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"openai"
|
||||
fn name(&self) -> &str {
|
||||
&self.provider_name
|
||||
}
|
||||
|
||||
async fn complete(&self, request: &Request) -> Result<Response, Error> {
|
||||
|
|
@ -1037,7 +1078,8 @@ impl ProviderAdapter for Adapter {
|
|||
if let Some(tc) = &request.tool_choice {
|
||||
validate_tool_choice(self, tc)?;
|
||||
}
|
||||
let request_body = build_request_body(request, false, false).await;
|
||||
let request_body =
|
||||
build_request_body_with_catalog(request, false, false, self.catalog.as_deref()).await;
|
||||
let url = format!("{}/responses", self.http.base_url);
|
||||
|
||||
let mut req = self.build_request(&url).json(&request_body);
|
||||
|
|
@ -1076,7 +1118,13 @@ impl ProviderAdapter for Adapter {
|
|||
if let Some(tc) = &request.tool_choice {
|
||||
validate_tool_choice(self, tc)?;
|
||||
}
|
||||
let request_body = build_request_body(request, true, self.codex_mode).await;
|
||||
let request_body = build_request_body_with_catalog(
|
||||
request,
|
||||
true,
|
||||
self.codex_mode,
|
||||
self.catalog.as_deref(),
|
||||
)
|
||||
.await;
|
||||
let url = format!("{}/responses", self.http.base_url);
|
||||
|
||||
let http_resp = self
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use fabro_model::Catalog;
|
||||
use futures::{StreamExt, stream};
|
||||
|
||||
use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind, error_from_status_code};
|
||||
use crate::provider::{ProviderAdapter, StreamEventStream, validate_tool_choice};
|
||||
use crate::providers::common::{
|
||||
parse_error_body, parse_rate_limit_headers, parse_retry_after, send_and_read_response,
|
||||
api_model_id, parse_error_body, parse_rate_limit_headers, parse_retry_after,
|
||||
send_and_read_response,
|
||||
};
|
||||
use crate::types::{
|
||||
AdapterTimeout, ContentPart, FinishReason, Message, RateLimitInfo, Request, Response,
|
||||
|
|
@ -21,14 +25,21 @@ use crate::types::{
|
|||
pub struct Adapter {
|
||||
pub(crate) http: super::http_api::HttpApi,
|
||||
provider_name: String,
|
||||
catalog: Option<Arc<Catalog>>,
|
||||
}
|
||||
|
||||
impl Adapter {
|
||||
#[must_use]
|
||||
pub fn new(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
|
||||
Self::new_optional_auth(Some(api_key.into()), base_url)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new_optional_auth(api_key: Option<String>, base_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
http: super::http_api::HttpApi::new(api_key, base_url),
|
||||
http: super::http_api::HttpApi::new_optional(api_key, base_url),
|
||||
provider_name: "openai-compatible".to_string(),
|
||||
catalog: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -46,6 +57,12 @@ impl Adapter {
|
|||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_catalog(mut self, catalog: Arc<Catalog>) -> Self {
|
||||
self.catalog = Some(catalog);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_timeout(self, timeout: AdapterTimeout) -> Self {
|
||||
Self {
|
||||
|
|
@ -61,7 +78,10 @@ impl Adapter {
|
|||
for (key, value) in &self.http.default_headers {
|
||||
req = req.header(key, value);
|
||||
}
|
||||
req.bearer_auth(&self.http.api_key)
|
||||
if let Some(api_key) = &self.http.api_key {
|
||||
req = req.bearer_auth(api_key);
|
||||
}
|
||||
req
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -393,10 +413,20 @@ fn translate_response_format(format: &ResponseFormat) -> serde_json::Value {
|
|||
///
|
||||
/// Returns a `serde_json::Value` so that `provider_options.<provider_name>`
|
||||
/// fields can be merged into the request before sending.
|
||||
#[cfg(test)]
|
||||
fn build_api_request(
|
||||
request: &Request,
|
||||
stream: Option<bool>,
|
||||
provider_name: &str,
|
||||
) -> serde_json::Value {
|
||||
build_api_request_with_catalog(request, stream, provider_name, None)
|
||||
}
|
||||
|
||||
fn build_api_request_with_catalog(
|
||||
request: &Request,
|
||||
stream: Option<bool>,
|
||||
provider_name: &str,
|
||||
catalog: Option<&Catalog>,
|
||||
) -> serde_json::Value {
|
||||
let chat_messages = translate_messages(&request.messages);
|
||||
let tools = request.tools.as_ref().map(|t| translate_tools(t));
|
||||
|
|
@ -407,7 +437,7 @@ fn build_api_request(
|
|||
.map(translate_response_format);
|
||||
|
||||
let api_request = ApiRequest {
|
||||
model: request.model.clone(),
|
||||
model: api_model_id(catalog, &request.model),
|
||||
messages: chat_messages,
|
||||
temperature: request.temperature,
|
||||
max_tokens: request.max_tokens,
|
||||
|
|
@ -460,7 +490,12 @@ impl ProviderAdapter for Adapter {
|
|||
if let Some(tc) = &request.tool_choice {
|
||||
validate_tool_choice(self, tc)?;
|
||||
}
|
||||
let api_body = build_api_request(request, None, &self.provider_name);
|
||||
let api_body = build_api_request_with_catalog(
|
||||
request,
|
||||
None,
|
||||
&self.provider_name,
|
||||
self.catalog.as_deref(),
|
||||
);
|
||||
let url = format!("{}/chat/completions", self.http.base_url);
|
||||
|
||||
let mut req = self.build_request(&url).json(&api_body);
|
||||
|
|
@ -538,7 +573,12 @@ impl ProviderAdapter for Adapter {
|
|||
if let Some(tc) = &request.tool_choice {
|
||||
validate_tool_choice(self, tc)?;
|
||||
}
|
||||
let api_body = build_api_request(request, Some(true), &self.provider_name);
|
||||
let api_body = build_api_request_with_catalog(
|
||||
request,
|
||||
Some(true),
|
||||
&self.provider_name,
|
||||
self.catalog.as_deref(),
|
||||
);
|
||||
let url = format!("{}/chat/completions", self.http.base_url);
|
||||
|
||||
let http_resp = self
|
||||
|
|
@ -913,6 +953,8 @@ impl StreamState {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_model::catalog::LlmCatalogSettings;
|
||||
|
||||
use super::*;
|
||||
use crate::types::{AudioData, DocumentData};
|
||||
|
||||
|
|
@ -1312,6 +1354,44 @@ mod tests {
|
|||
assert!(body.get("stream").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_api_id_is_used_for_provider_request_body() {
|
||||
let settings: LlmCatalogSettings = toml::from_str(
|
||||
r#"
|
||||
[providers.venice]
|
||||
display_name = "Venice"
|
||||
adapter = "openai_compatible"
|
||||
base_url = "https://api.venice.ai/api/v1"
|
||||
credentials = ["env:VENICE_API_KEY"]
|
||||
|
||||
[models."venice-large"]
|
||||
provider = "venice"
|
||||
api_id = "venice/model-large"
|
||||
display_name = "Venice Large"
|
||||
family = "venice"
|
||||
default = true
|
||||
|
||||
[models."venice-large".limits]
|
||||
context_window = 128000
|
||||
|
||||
[models."venice-large".features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
effort = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap();
|
||||
let mut request = minimal_request();
|
||||
request.model = "venice-large".to_string();
|
||||
|
||||
let body = build_api_request_with_catalog(&request, None, "venice", Some(&catalog));
|
||||
|
||||
assert_eq!(request.model, "venice-large");
|
||||
assert_eq!(body["model"], "venice/model-large");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_options_matching_name_merged() {
|
||||
let mut request = minimal_request();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -700,7 +700,7 @@ impl AppState {
|
|||
}
|
||||
|
||||
pub(crate) async fn resolve_llm_client(&self) -> anyhow::Result<LlmClientResult> {
|
||||
resolve_llm_client_from_source(self.llm_source.as_ref()).await
|
||||
resolve_llm_client_from_source(self.llm_source.as_ref(), self.catalog()).await
|
||||
}
|
||||
|
||||
pub(crate) fn vault_or_env(&self, name: &str) -> Option<String> {
|
||||
|
|
@ -863,12 +863,13 @@ impl AppState {
|
|||
|
||||
async fn resolve_llm_client_from_source(
|
||||
source: &dyn CredentialSource,
|
||||
catalog: Arc<Catalog>,
|
||||
) -> anyhow::Result<LlmClientResult> {
|
||||
let resolved = source
|
||||
.resolve()
|
||||
.resolve_for_catalog(catalog.as_ref())
|
||||
.await
|
||||
.context("resolving LLM credentials")?;
|
||||
let client = LlmClient::from_credentials(resolved.credentials)
|
||||
let client = LlmClient::from_credentials_with_catalog(resolved.credentials, catalog)
|
||||
.await
|
||||
.context("creating LLM client")?;
|
||||
|
||||
|
|
@ -3033,6 +3034,7 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
|
|||
github_app,
|
||||
github_permissions,
|
||||
vault: Some(Arc::clone(&state.vault)),
|
||||
catalog: state.catalog(),
|
||||
on_node: None,
|
||||
registry_override,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -40,14 +40,14 @@ async fn list_models(
|
|||
let query = params.query.as_ref().map(|value| value.to_lowercase());
|
||||
let limit = params.limit.clamp(1, 100) as usize;
|
||||
let offset = params.offset.min(MAX_PAGE_OFFSET) as usize;
|
||||
let catalog = state.catalog();
|
||||
let configured: HashSet<ProviderId> = state
|
||||
.llm_source
|
||||
.configured_providers()
|
||||
.configured_providers_for_catalog(catalog.as_ref())
|
||||
.await
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let catalog = state.catalog();
|
||||
let mut models = catalog
|
||||
.list(provider_id.as_ref())
|
||||
.into_iter()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ use fabro_interview::{
|
|||
};
|
||||
use fabro_llm::types::{Message as LlmMessage, Request as LlmRequest};
|
||||
use fabro_model::catalog::LlmCatalogSettings;
|
||||
use fabro_model::{ModelRef, Provider};
|
||||
use fabro_model::{Catalog, ModelRef, Provider};
|
||||
use fabro_types::settings::ServerAuthMethod;
|
||||
use fabro_types::{
|
||||
AttrValue, AuthMethod, CommandTermination, FailureCategory, FailureDetail, Graph,
|
||||
|
|
@ -93,6 +93,13 @@ fn spa_fixture_root() -> PathBuf {
|
|||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/spa")
|
||||
}
|
||||
|
||||
fn state_test_catalog() -> Arc<Catalog> {
|
||||
Arc::new(
|
||||
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
|
||||
.expect("default catalog should build"),
|
||||
)
|
||||
}
|
||||
|
||||
fn test_app_with_scheduler(state: Arc<AppState>) -> Router {
|
||||
spawn_scheduler(Arc::clone(&state));
|
||||
crate::test_support::build_test_router(state)
|
||||
|
|
@ -1227,7 +1234,8 @@ impl CredentialSource for FailingCredentialSource {
|
|||
|
||||
#[tokio::test]
|
||||
async fn resolve_llm_client_from_source_preserves_credential_source_chain() {
|
||||
let Err(err) = resolve_llm_client_from_source(&FailingCredentialSource).await else {
|
||||
let catalog = state_test_catalog();
|
||||
let Err(err) = resolve_llm_client_from_source(&FailingCredentialSource, catalog).await else {
|
||||
panic!("expected credential resolution to fail");
|
||||
};
|
||||
let chain = err.chain().map(ToString::to_string).collect::<Vec<_>>();
|
||||
|
|
@ -1264,9 +1272,14 @@ async fn llm_source_configured_providers_reads_openai_codex_from_vault() {
|
|||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(state.llm_source.configured_providers().await, vec![
|
||||
Provider::OpenAi.id()
|
||||
]);
|
||||
let catalog = state.catalog();
|
||||
assert_eq!(
|
||||
state
|
||||
.llm_source
|
||||
.configured_providers_for_catalog(catalog.as_ref())
|
||||
.await,
|
||||
vec![Provider::OpenAi.id()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ use fabro_graphviz::graph::Node;
|
|||
use fabro_llm::client::Client;
|
||||
use fabro_llm::types::{Message, Request, TokenCounts};
|
||||
use fabro_mcp::config::McpServerSettings;
|
||||
use fabro_model::{FallbackTarget, Provider};
|
||||
use fabro_model::catalog::LlmCatalogSettings;
|
||||
use fabro_model::{AgentProfileKind, Catalog, FallbackTarget, Provider, ProviderId, adapter};
|
||||
use fabro_types::{SessionCapability, StageId};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
use tokio::task::JoinHandle;
|
||||
|
|
@ -98,6 +99,13 @@ enum AgentApiErrorDisposition {
|
|||
Terminal(Error),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ProviderContext {
|
||||
provider: Provider,
|
||||
provider_id: ProviderId,
|
||||
profile_kind: AgentProfileKind,
|
||||
}
|
||||
|
||||
fn classify_agent_error(err: fabro_agent::Error, allow_failover: bool) -> AgentApiErrorDisposition {
|
||||
match err {
|
||||
fabro_agent::Error::Interrupted(fabro_agent::InterruptReason::Cancelled) => {
|
||||
|
|
@ -128,7 +136,7 @@ fn begin_session_lifecycle(
|
|||
emitter.emit(&Event::AgentSessionStarted {
|
||||
session_id: session.id().to_string(),
|
||||
parent_session_id,
|
||||
provider: Some(session.provider().to_string()),
|
||||
provider: Some(session.provider_id().to_string()),
|
||||
model: Some(session.model().to_string()),
|
||||
});
|
||||
}
|
||||
|
|
@ -150,19 +158,57 @@ fn discard_session(
|
|||
}
|
||||
}
|
||||
|
||||
fn build_profile(model: &str, provider: Provider) -> Box<dyn AgentProfile> {
|
||||
fn build_profile(
|
||||
model: &str,
|
||||
provider: Provider,
|
||||
provider_id: ProviderId,
|
||||
profile_kind: AgentProfileKind,
|
||||
) -> Box<dyn AgentProfile> {
|
||||
match profile_kind {
|
||||
AgentProfileKind::OpenAi => Box::new(
|
||||
OpenAiProfile::new(model)
|
||||
.with_provider(provider)
|
||||
.with_provider_id(provider_id),
|
||||
),
|
||||
AgentProfileKind::Gemini => Box::new(
|
||||
GeminiProfile::new(model)
|
||||
.with_provider(provider)
|
||||
.with_provider_id(provider_id),
|
||||
),
|
||||
AgentProfileKind::Anthropic => Box::new(
|
||||
AnthropicProfile::new(model)
|
||||
.with_provider(provider)
|
||||
.with_provider_id(provider_id),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_profile_kind(provider: Provider) -> AgentProfileKind {
|
||||
match provider {
|
||||
Provider::OpenAi => Box::new(OpenAiProfile::new(model)),
|
||||
Provider::Kimi
|
||||
Provider::Anthropic => AgentProfileKind::Anthropic,
|
||||
Provider::Gemini => AgentProfileKind::Gemini,
|
||||
Provider::OpenAi
|
||||
| Provider::Kimi
|
||||
| Provider::Zai
|
||||
| Provider::Minimax
|
||||
| Provider::Inception
|
||||
| Provider::OpenAiCompatible => Box::new(OpenAiProfile::new(model).with_provider(provider)),
|
||||
Provider::Gemini => Box::new(GeminiProfile::new(model)),
|
||||
Provider::Anthropic => Box::new(AnthropicProfile::new(model)),
|
||||
| Provider::OpenAiCompatible => AgentProfileKind::OpenAi,
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_provider_for_catalog_provider(
|
||||
provider_id: &ProviderId,
|
||||
profile_kind: AgentProfileKind,
|
||||
adapter: &str,
|
||||
) -> Provider {
|
||||
Provider::from_id(provider_id).unwrap_or(match (profile_kind, adapter) {
|
||||
(AgentProfileKind::Anthropic, _) => Provider::Anthropic,
|
||||
(AgentProfileKind::Gemini, _) => Provider::Gemini,
|
||||
(AgentProfileKind::OpenAi, "openai_compatible") => Provider::OpenAiCompatible,
|
||||
(AgentProfileKind::OpenAi, _) => Provider::OpenAi,
|
||||
})
|
||||
}
|
||||
|
||||
/// Shared state for tracking file modifications from agent tool calls.
|
||||
struct FileTracking {
|
||||
/// Maps tool_call_id → file_path for in-flight write/edit calls.
|
||||
|
|
@ -249,12 +295,15 @@ fn spawn_event_forwarder(
|
|||
pub struct AgentApiBackend {
|
||||
model: String,
|
||||
provider: Provider,
|
||||
provider_id: ProviderId,
|
||||
profile_kind: AgentProfileKind,
|
||||
fallback_chain: Vec<FallbackTarget>,
|
||||
sessions: Mutex<HashMap<String, Session>>,
|
||||
tool_env: Option<Arc<dyn ToolEnvProvider>>,
|
||||
mcp_servers: Vec<McpServerSettings>,
|
||||
source: Arc<dyn CredentialSource>,
|
||||
steering_hub: Arc<SteeringHub>,
|
||||
catalog: Arc<Catalog>,
|
||||
}
|
||||
|
||||
impl AgentApiBackend {
|
||||
|
|
@ -265,16 +314,46 @@ impl AgentApiBackend {
|
|||
fallback_chain: Vec<FallbackTarget>,
|
||||
source: Arc<dyn CredentialSource>,
|
||||
steering_hub: Arc<SteeringHub>,
|
||||
) -> Self {
|
||||
let catalog = Arc::new(
|
||||
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
|
||||
.expect("default catalog should build"),
|
||||
);
|
||||
Self::new_with_catalog(
|
||||
model,
|
||||
provider,
|
||||
provider.id(),
|
||||
default_profile_kind(provider),
|
||||
fallback_chain,
|
||||
source,
|
||||
steering_hub,
|
||||
catalog,
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new_with_catalog(
|
||||
model: String,
|
||||
provider: Provider,
|
||||
provider_id: ProviderId,
|
||||
profile_kind: AgentProfileKind,
|
||||
fallback_chain: Vec<FallbackTarget>,
|
||||
source: Arc<dyn CredentialSource>,
|
||||
steering_hub: Arc<SteeringHub>,
|
||||
catalog: Arc<Catalog>,
|
||||
) -> Self {
|
||||
Self {
|
||||
model,
|
||||
provider,
|
||||
provider_id,
|
||||
profile_kind,
|
||||
fallback_chain,
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
tool_env: None,
|
||||
mcp_servers: Vec::new(),
|
||||
source,
|
||||
steering_hub,
|
||||
catalog,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -312,6 +391,51 @@ impl AgentApiBackend {
|
|||
self
|
||||
}
|
||||
|
||||
fn resolve_provider_context(
|
||||
&self,
|
||||
model: &str,
|
||||
provider_attr: Option<&str>,
|
||||
) -> Result<ProviderContext, Error> {
|
||||
let provider_id = if let Some(provider) = provider_attr {
|
||||
let requested = ProviderId::from(provider);
|
||||
self.catalog
|
||||
.provider(&requested)
|
||||
.ok_or_else(|| {
|
||||
Error::Precondition(format!("Provider \"{provider}\" is not configured"))
|
||||
})?
|
||||
.id
|
||||
.clone()
|
||||
} else if let Some(model) = self.catalog.get(model) {
|
||||
model.provider.clone()
|
||||
} else {
|
||||
self.provider_id.clone()
|
||||
};
|
||||
let Some(provider) = self.catalog.provider(&provider_id) else {
|
||||
return Ok(ProviderContext {
|
||||
provider: self.provider,
|
||||
provider_id: self.provider_id.clone(),
|
||||
profile_kind: self.profile_kind,
|
||||
});
|
||||
};
|
||||
let profile_kind = adapter::get(&provider.adapter)
|
||||
.map(|metadata| metadata.default_profile)
|
||||
.ok_or_else(|| {
|
||||
Error::Precondition(format!(
|
||||
"Provider \"{provider_id}\" uses unknown adapter \"{}\"",
|
||||
provider.adapter,
|
||||
))
|
||||
})?;
|
||||
Ok(ProviderContext {
|
||||
provider: profile_provider_for_catalog_provider(
|
||||
&provider.id,
|
||||
profile_kind,
|
||||
&provider.adapter,
|
||||
),
|
||||
provider_id: provider.id.clone(),
|
||||
profile_kind,
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_session(
|
||||
&self,
|
||||
node: &Node,
|
||||
|
|
@ -319,16 +443,14 @@ impl AgentApiBackend {
|
|||
tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
|
||||
) -> Result<Session, Error> {
|
||||
let model = node.model().unwrap_or(&self.model);
|
||||
let provider = node
|
||||
.provider()
|
||||
.and_then(|p| p.parse::<Provider>().ok())
|
||||
.unwrap_or(self.provider);
|
||||
let provider = self.resolve_provider_context(model, node.provider())?;
|
||||
Self::create_session_for(
|
||||
model,
|
||||
provider,
|
||||
node,
|
||||
sandbox,
|
||||
self.source.as_ref(),
|
||||
Arc::clone(&self.catalog),
|
||||
self.tool_env.as_ref(),
|
||||
tool_hooks,
|
||||
self.mcp_servers.clone(),
|
||||
|
|
@ -338,19 +460,25 @@ impl AgentApiBackend {
|
|||
|
||||
async fn create_session_for(
|
||||
model: &str,
|
||||
provider: Provider,
|
||||
provider: ProviderContext,
|
||||
node: &Node,
|
||||
sandbox: &Arc<dyn Sandbox>,
|
||||
source: &dyn CredentialSource,
|
||||
catalog: Arc<Catalog>,
|
||||
tool_env: Option<&Arc<dyn ToolEnvProvider>>,
|
||||
tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
|
||||
mcp_servers: Vec<McpServerSettings>,
|
||||
) -> Result<Session, Error> {
|
||||
let client = Client::from_source(source)
|
||||
let client = Client::from_source_with_catalog(source, catalog)
|
||||
.await
|
||||
.map_err(|e| Error::handler_with_source("Failed to create LLM client", &e))?;
|
||||
|
||||
let mut profile = build_profile(model, provider);
|
||||
let mut profile = build_profile(
|
||||
model,
|
||||
provider.provider,
|
||||
provider.provider_id.clone(),
|
||||
provider.profile_kind,
|
||||
);
|
||||
|
||||
let config = SessionOptions {
|
||||
max_tokens: node.max_tokens(),
|
||||
|
|
@ -369,21 +497,16 @@ impl AgentApiBackend {
|
|||
// Build factory that creates child sessions WITHOUT subagent tools
|
||||
let factory_client = client.clone();
|
||||
let factory_model = model.to_string();
|
||||
let factory_provider = provider.clone();
|
||||
let factory_env = Arc::clone(sandbox);
|
||||
let factory_tool_env = tool_env.cloned();
|
||||
let factory: SessionFactory = Arc::new(move || {
|
||||
let child_profile: Arc<dyn AgentProfile> = match provider {
|
||||
Provider::OpenAi => Arc::new(OpenAiProfile::new(&factory_model)),
|
||||
Provider::Kimi
|
||||
| Provider::Zai
|
||||
| Provider::Minimax
|
||||
| Provider::Inception
|
||||
| Provider::OpenAiCompatible => {
|
||||
Arc::new(OpenAiProfile::new(&factory_model).with_provider(provider))
|
||||
}
|
||||
Provider::Gemini => Arc::new(GeminiProfile::new(&factory_model)),
|
||||
Provider::Anthropic => Arc::new(AnthropicProfile::new(&factory_model)),
|
||||
};
|
||||
let child_profile: Arc<dyn AgentProfile> = Arc::from(build_profile(
|
||||
&factory_model,
|
||||
factory_provider.provider,
|
||||
factory_provider.provider_id.clone(),
|
||||
factory_provider.profile_kind,
|
||||
));
|
||||
let mut session = Session::new(
|
||||
factory_client.clone(),
|
||||
child_profile,
|
||||
|
|
@ -435,7 +558,7 @@ impl AgentApiBackend {
|
|||
stage_id: stage_id.clone(),
|
||||
session_id: session.id().to_string(),
|
||||
thread_id: thread_id.map(str::to_string),
|
||||
provider: Some(session.provider().to_string()),
|
||||
provider: Some(session.provider_id().to_string()),
|
||||
model: Some(session.model().to_string()),
|
||||
capabilities: vec![SessionCapability::Steer],
|
||||
hub: Arc::clone(&self.steering_hub),
|
||||
|
|
@ -483,21 +606,18 @@ impl CodergenBackend for AgentApiBackend {
|
|||
let emitter = request.emitter;
|
||||
let stage_scope = request.stage_scope;
|
||||
|
||||
let client = Client::from_source(self.source.as_ref())
|
||||
.await
|
||||
.map_err(|e| Error::handler_with_source("Failed to create LLM client", &e))?;
|
||||
let client =
|
||||
Client::from_source_with_catalog(self.source.as_ref(), Arc::clone(&self.catalog))
|
||||
.await
|
||||
.map_err(|e| Error::handler_with_source("Failed to create LLM client", &e))?;
|
||||
|
||||
let model = node.model().unwrap_or(&self.model);
|
||||
let provider = node
|
||||
.provider()
|
||||
.map(String::from)
|
||||
.or_else(|| Some(self.provider.to_string()));
|
||||
let provider = self.resolve_provider_context(model, node.provider())?;
|
||||
let provider_id = provider.provider_id.to_string();
|
||||
|
||||
let max_tokens = node.max_tokens().or_else(|| {
|
||||
fabro_model::Catalog::builtin()
|
||||
.get(model)
|
||||
.and_then(|m| m.limits.max_output)
|
||||
});
|
||||
let max_tokens = node
|
||||
.max_tokens()
|
||||
.or_else(|| self.catalog.get(model).and_then(|m| m.limits.max_output));
|
||||
|
||||
let mut messages = Vec::new();
|
||||
if let Some(sys) = system_prompt {
|
||||
|
|
@ -508,7 +628,7 @@ impl CodergenBackend for AgentApiBackend {
|
|||
let request = Request {
|
||||
model: model.to_string(),
|
||||
messages,
|
||||
provider,
|
||||
provider: Some(provider_id),
|
||||
reasoning_effort: node.reasoning_effort().parse().ok(),
|
||||
speed: node.speed().map(String::from),
|
||||
tools: None,
|
||||
|
|
@ -532,7 +652,7 @@ impl CodergenBackend for AgentApiBackend {
|
|||
|
||||
let result = client.complete(&request).await;
|
||||
|
||||
let default_provider = self.provider.to_string();
|
||||
let default_provider = self.provider_id.to_string();
|
||||
|
||||
let (response, actual_model, actual_provider) = match result {
|
||||
Ok(resp) => (
|
||||
|
|
@ -568,7 +688,7 @@ impl CodergenBackend for AgentApiBackend {
|
|||
);
|
||||
|
||||
let max_tokens = node.max_tokens().or_else(|| {
|
||||
fabro_model::Catalog::builtin()
|
||||
self.catalog
|
||||
.get(&target.model)
|
||||
.and_then(|m| m.limits.max_output)
|
||||
});
|
||||
|
|
@ -762,7 +882,7 @@ impl CodergenBackend for AgentApiBackend {
|
|||
}
|
||||
AgentApiErrorDisposition::FailoverEligible(sdk_err) => {
|
||||
let error_msg = sdk_err.to_string();
|
||||
let from_provider = self.provider.to_string();
|
||||
let from_provider = self.provider_id.to_string();
|
||||
let from_model = self.model.clone();
|
||||
|
||||
let mut last_err = Error::Llm(sdk_err);
|
||||
|
|
@ -784,9 +904,10 @@ impl CodergenBackend for AgentApiBackend {
|
|||
&stage_scope,
|
||||
);
|
||||
|
||||
let target_provider: Provider = match target.provider.parse() {
|
||||
Ok(p) => p,
|
||||
Err(_) => continue,
|
||||
let Ok(target_provider) =
|
||||
self.resolve_provider_context(&target.model, Some(&target.provider))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if cancel_token.is_cancelled() {
|
||||
|
|
@ -798,6 +919,7 @@ impl CodergenBackend for AgentApiBackend {
|
|||
node,
|
||||
sandbox,
|
||||
self.source.as_ref(),
|
||||
Arc::clone(&self.catalog),
|
||||
self.tool_env.as_ref(),
|
||||
tool_hooks.clone(),
|
||||
self.mcp_servers.clone(),
|
||||
|
|
@ -995,7 +1117,7 @@ impl CompletionCoordinator for SteeringCompletionCoordinator {
|
|||
mod tests {
|
||||
use fabro_agent::subagent::SessionFactory;
|
||||
use fabro_agent::{AgentProfile, ToolRegistry};
|
||||
use fabro_auth::{AuthCredential, AuthDetails, VaultCredentialSource};
|
||||
use fabro_auth::{AuthCredential, AuthDetails, EnvCredentialSource, VaultCredentialSource};
|
||||
use fabro_llm::provider::{ProviderAdapter, StreamEventStream};
|
||||
use fabro_llm::{Error as LlmError, ProviderErrorDetail, ProviderErrorKind};
|
||||
use fabro_vault::{SecretType, Vault};
|
||||
|
|
@ -1196,7 +1318,12 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn build_profile_can_register_subagent_tools() {
|
||||
let mut profile = build_profile("claude-opus-4-6", Provider::Anthropic);
|
||||
let mut profile = build_profile(
|
||||
"claude-opus-4-6",
|
||||
Provider::Anthropic,
|
||||
Provider::Anthropic.id(),
|
||||
AgentProfileKind::Anthropic,
|
||||
);
|
||||
let manager = Arc::new(TokioMutex::new(SubAgentManager::new(1)));
|
||||
let factory: SessionFactory = Arc::new(|| {
|
||||
panic!("factory should not be called in this test");
|
||||
|
|
@ -1210,6 +1337,55 @@ mod tests {
|
|||
assert!(names.contains(&"close_agent".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_backend_resolves_custom_catalog_provider_profile() {
|
||||
let settings: LlmCatalogSettings = toml::from_str(
|
||||
r#"
|
||||
[providers.venice]
|
||||
adapter = "openai_compatible"
|
||||
base_url = "https://api.venice.ai/api/v1"
|
||||
credentials = ["env:VENICE_API_KEY"]
|
||||
|
||||
[models.venice-llama]
|
||||
provider = "venice"
|
||||
display_name = "Venice Llama"
|
||||
family = "llama"
|
||||
training = "2026-01"
|
||||
default = true
|
||||
|
||||
[models.venice-llama.limits]
|
||||
context_window = 131072
|
||||
max_output = 8192
|
||||
|
||||
[models.venice-llama.features]
|
||||
tools = true
|
||||
vision = false
|
||||
reasoning = false
|
||||
effort = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let catalog = Arc::new(Catalog::from_builtin_with_overrides(&settings).unwrap());
|
||||
let backend = AgentApiBackend::new_with_catalog(
|
||||
"venice-llama".to_string(),
|
||||
Provider::OpenAiCompatible,
|
||||
ProviderId::from("venice"),
|
||||
AgentProfileKind::OpenAi,
|
||||
Vec::new(),
|
||||
Arc::new(EnvCredentialSource::new()),
|
||||
SteeringHub::for_tests(),
|
||||
catalog,
|
||||
);
|
||||
|
||||
let provider = backend
|
||||
.resolve_provider_context("venice-llama", None)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(provider.provider_id, ProviderId::from("venice"));
|
||||
assert_eq!(provider.profile_kind, AgentProfileKind::OpenAi);
|
||||
assert_eq!(provider.provider, Provider::OpenAiCompatible);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_backend_uses_source_credentials() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
@ -1239,7 +1415,10 @@ mod tests {
|
|||
SteeringHub::for_tests(),
|
||||
);
|
||||
|
||||
let client = Client::from_source(backend.source.as_ref()).await.unwrap();
|
||||
let client =
|
||||
Client::from_source_with_catalog(backend.source.as_ref(), Arc::clone(&backend.catalog))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(client.provider_names(), vec!["anthropic"]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use std::sync::Arc;
|
|||
|
||||
use fabro_config::Storage;
|
||||
use fabro_graphviz::graph::{AttrValue, Graph};
|
||||
use fabro_model::catalog::LlmCatalogSettings;
|
||||
use fabro_model::{Catalog, ProviderId};
|
||||
use fabro_sandbox::SandboxProvider;
|
||||
use fabro_store::Database;
|
||||
|
|
@ -74,6 +75,7 @@ struct PersistCreateOptions {
|
|||
fork_source_ref: Option<ForkSourceRef>,
|
||||
provenance: Option<RunProvenance>,
|
||||
configured_providers: Vec<ProviderId>,
|
||||
catalog: Arc<Catalog>,
|
||||
}
|
||||
|
||||
/// Resolve workflow inputs, normalize settings, and persist a run directory.
|
||||
|
|
@ -81,6 +83,21 @@ pub async fn create(
|
|||
store: &Database,
|
||||
request: CreateRunInput,
|
||||
storage_root: PathBuf,
|
||||
) -> Result<CreatedRun, Error> {
|
||||
let catalog = Arc::new(
|
||||
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
|
||||
.map_err(|err| Error::engine(format!("building default LLM catalog: {err}")))?,
|
||||
);
|
||||
Box::pin(create_with_catalog(store, request, storage_root, catalog)).await
|
||||
}
|
||||
|
||||
/// Resolve workflow inputs, normalize settings using a caller-provided catalog,
|
||||
/// and persist a run directory.
|
||||
pub async fn create_with_catalog(
|
||||
store: &Database,
|
||||
request: CreateRunInput,
|
||||
storage_root: PathBuf,
|
||||
catalog: Arc<Catalog>,
|
||||
) -> Result<CreatedRun, Error> {
|
||||
let resolved = resolve_workflow(ResolveWorkflowInput {
|
||||
workflow: request.workflow,
|
||||
|
|
@ -144,6 +161,7 @@ pub async fn create(
|
|||
fork_source_ref,
|
||||
provenance,
|
||||
configured_providers,
|
||||
catalog,
|
||||
},
|
||||
current_dir,
|
||||
file_resolver,
|
||||
|
|
@ -425,12 +443,13 @@ fn persist_validated(
|
|||
fork_source_ref,
|
||||
provenance,
|
||||
configured_providers,
|
||||
catalog,
|
||||
} = options;
|
||||
|
||||
let settings = materialize_run(
|
||||
settings,
|
||||
validated.graph(),
|
||||
Catalog::builtin(),
|
||||
catalog.as_ref(),
|
||||
&configured_providers,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ use std::path::{Path, PathBuf};
|
|||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use fabro_auth::configured_providers_from_process_env;
|
||||
use fabro_auth::{CredentialSource, EnvCredentialSource, VaultCredentialSource};
|
||||
use fabro_interview::{AutoApproveInterviewer, Interviewer};
|
||||
use fabro_mcp::config::{McpServerSettings, McpTransport};
|
||||
use fabro_model::{Catalog, FallbackTarget, Provider};
|
||||
use fabro_model::{AgentProfileKind, Catalog, FallbackTarget, Provider, ProviderId, adapter};
|
||||
use fabro_sandbox::config::{
|
||||
DaytonaNetwork, DaytonaSnapshotSettings, DockerfileSource as SandboxDockerfileSource,
|
||||
};
|
||||
|
|
@ -79,6 +79,7 @@ struct RunSession {
|
|||
workflow_bundle: Option<Arc<WorkflowBundle>>,
|
||||
run_control: Option<Arc<RunControlState>>,
|
||||
vault: Option<Arc<AsyncRwLock<Vault>>>,
|
||||
catalog: Arc<Catalog>,
|
||||
}
|
||||
|
||||
pub struct StartServices {
|
||||
|
|
@ -96,6 +97,7 @@ pub struct StartServices {
|
|||
/// sandbox env. Empty when github integration has no permissions.
|
||||
pub github_permissions: HashMap<String, String>,
|
||||
pub vault: Option<Arc<AsyncRwLock<Vault>>>,
|
||||
pub catalog: Arc<Catalog>,
|
||||
pub on_node: crate::OnNodeCallback,
|
||||
pub registry_override: Option<Arc<HandlerRegistry>>,
|
||||
}
|
||||
|
|
@ -310,14 +312,11 @@ impl RunSession {
|
|||
} else {
|
||||
sandbox_provider
|
||||
};
|
||||
let configured = configured_providers_from_process_env(services.vault.as_ref()).await;
|
||||
let catalog = Arc::clone(&services.catalog);
|
||||
let configured =
|
||||
configured_providers_for_start(services.vault.as_ref(), catalog.as_ref()).await;
|
||||
let model = resolved.model.name.as_ref().map_or_else(
|
||||
|| {
|
||||
Catalog::builtin()
|
||||
.default_for_configured_ids(&configured)
|
||||
.id
|
||||
.clone()
|
||||
},
|
||||
|| catalog.default_for_configured_ids(&configured).id.clone(),
|
||||
InterpString::as_source,
|
||||
);
|
||||
let provider = resolved
|
||||
|
|
@ -327,19 +326,41 @@ impl RunSession {
|
|||
.map(InterpString::as_source)
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
let provider_enum: Provider = if let Some(value) = provider.as_deref() {
|
||||
value.parse::<Provider>().map_err(|_| {
|
||||
Error::Precondition(format!("Provider \"{value}\" is not configured"))
|
||||
})?
|
||||
let provider_id = if let Some(value) = provider.as_deref() {
|
||||
let provider_id = ProviderId::from(value);
|
||||
catalog
|
||||
.provider(&provider_id)
|
||||
.ok_or_else(|| {
|
||||
Error::Precondition(format!("Provider \"{value}\" is not configured"))
|
||||
})?
|
||||
.id
|
||||
.clone()
|
||||
} else if let Some(model) = catalog.get(&model) {
|
||||
model.provider.clone()
|
||||
} else {
|
||||
let configured = configured
|
||||
.iter()
|
||||
.filter_map(Provider::from_id)
|
||||
.collect::<Vec<_>>();
|
||||
Provider::default_for_configured(&configured)
|
||||
catalog
|
||||
.default_for_configured_ids(&configured)
|
||||
.provider
|
||||
.clone()
|
||||
};
|
||||
|
||||
let fallback_chain = resolve_fallback_chain(provider_enum, &model, &resolved.model);
|
||||
let catalog_provider = catalog.provider(&provider_id).ok_or_else(|| {
|
||||
Error::Precondition(format!("Provider \"{provider_id}\" is not configured"))
|
||||
})?;
|
||||
let profile_kind = adapter::get(&catalog_provider.adapter)
|
||||
.map(|metadata| metadata.default_profile)
|
||||
.ok_or_else(|| {
|
||||
Error::Precondition(format!(
|
||||
"Provider \"{provider_id}\" uses unknown adapter \"{}\"",
|
||||
catalog_provider.adapter,
|
||||
))
|
||||
})?;
|
||||
let provider_enum = Provider::from_id(&provider_id).unwrap_or_else(|| {
|
||||
profile_provider_for_custom_provider(profile_kind, &catalog_provider.adapter)
|
||||
});
|
||||
|
||||
let fallback_chain =
|
||||
resolve_fallback_chain(catalog.as_ref(), &provider_id, &model, &resolved.model);
|
||||
let mcp_servers = resolved
|
||||
.agent
|
||||
.mcps
|
||||
|
|
@ -416,6 +437,8 @@ impl RunSession {
|
|||
llm: LlmSpec {
|
||||
model: model.clone(),
|
||||
provider: provider_enum,
|
||||
provider_id: provider_id.clone(),
|
||||
profile_kind,
|
||||
fallback_chain,
|
||||
mcp_servers,
|
||||
dry_run: resolved.execution.mode == RunMode::DryRun,
|
||||
|
|
@ -448,10 +471,34 @@ impl RunSession {
|
|||
workflow_path,
|
||||
workflow_bundle,
|
||||
vault: services.vault,
|
||||
catalog,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn configured_providers_for_start(
|
||||
vault: Option<&Arc<AsyncRwLock<Vault>>>,
|
||||
catalog: &Catalog,
|
||||
) -> Vec<ProviderId> {
|
||||
let source: Arc<dyn CredentialSource> = match vault {
|
||||
Some(vault) => Arc::new(VaultCredentialSource::with_env_lookup(
|
||||
Arc::clone(vault),
|
||||
process_env_var,
|
||||
)),
|
||||
None => Arc::new(EnvCredentialSource::new()),
|
||||
};
|
||||
source.configured_providers_for_catalog(catalog).await
|
||||
}
|
||||
|
||||
fn profile_provider_for_custom_provider(profile_kind: AgentProfileKind, adapter: &str) -> Provider {
|
||||
match (profile_kind, adapter) {
|
||||
(AgentProfileKind::Anthropic, _) => Provider::Anthropic,
|
||||
(AgentProfileKind::Gemini, _) => Provider::Gemini,
|
||||
(AgentProfileKind::OpenAi, "openai_compatible") => Provider::OpenAiCompatible,
|
||||
(AgentProfileKind::OpenAi, _) => Provider::OpenAi,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_interp(value: &InterpString) -> String {
|
||||
value
|
||||
.resolve(process_env_var)
|
||||
|
|
@ -535,7 +582,8 @@ fn resolve_docker_config(settings: &ResolvedRunSettings) -> DockerSandboxOptions
|
|||
}
|
||||
|
||||
fn resolve_fallback_chain(
|
||||
provider: Provider,
|
||||
catalog: &Catalog,
|
||||
provider: &ProviderId,
|
||||
model: &str,
|
||||
settings: &ResolvedRunModelSettings,
|
||||
) -> Vec<FallbackTarget> {
|
||||
|
|
@ -556,7 +604,7 @@ fn resolve_fallback_chain(
|
|||
.or_default()
|
||||
.push(model_ref.to_string());
|
||||
}
|
||||
Catalog::builtin().build_fallback_chain(&provider.id(), model, &by_provider)
|
||||
catalog.build_fallback_chain(provider, model, &by_provider)
|
||||
}
|
||||
|
||||
fn runtime_mcp_server(settings: &ResolvedMcpServerSettings) -> McpServerSettings {
|
||||
|
|
@ -768,6 +816,7 @@ impl RunSession {
|
|||
llm: self.llm,
|
||||
interviewer: self.interviewer,
|
||||
steering_hub: Arc::clone(&self.steering_hub),
|
||||
catalog: Arc::clone(&self.catalog),
|
||||
lifecycle: self.lifecycle,
|
||||
run_options,
|
||||
workflow_path: self.workflow_path,
|
||||
|
|
@ -1023,6 +1072,7 @@ mod tests {
|
|||
|
||||
use chrono::Utc;
|
||||
use fabro_config::{RunCloneLayer, RunExecutionLayer, RunLayer, WorkflowSettingsBuilder};
|
||||
use fabro_model::catalog::LlmCatalogSettings;
|
||||
use fabro_store::Database;
|
||||
use fabro_types::settings::run::RunMode;
|
||||
use fabro_types::{WorkflowSettings, fixtures};
|
||||
|
|
@ -1186,6 +1236,10 @@ mod tests {
|
|||
github_app: None,
|
||||
github_permissions: HashMap::new(),
|
||||
vault: None,
|
||||
catalog: Arc::new(
|
||||
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
|
||||
.expect("default catalog should build"),
|
||||
),
|
||||
on_node: None,
|
||||
registry_override: Some(registry),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,15 @@ fn test_run_id(label: &str) -> RunId {
|
|||
}
|
||||
}
|
||||
|
||||
fn test_catalog() -> Arc<fabro_model::Catalog> {
|
||||
Arc::new(
|
||||
fabro_model::Catalog::from_builtin_with_overrides(
|
||||
&fabro_model::catalog::LlmCatalogSettings::default(),
|
||||
)
|
||||
.expect("default catalog should build"),
|
||||
)
|
||||
}
|
||||
|
||||
fn test_emitter(label: &str) -> Emitter {
|
||||
Emitter::new(test_run_id(label))
|
||||
}
|
||||
|
|
@ -239,12 +248,15 @@ async fn execute_test_run_with_options(
|
|||
llm: LlmSpec {
|
||||
model: "test-model".to_string(),
|
||||
provider: fabro_llm::Provider::Anthropic,
|
||||
provider_id: fabro_llm::Provider::Anthropic.id(),
|
||||
profile_kind: fabro_model::AgentProfileKind::Anthropic,
|
||||
fallback_chain: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
dry_run: true,
|
||||
},
|
||||
interviewer: Arc::new(AutoApproveInterviewer::engine()),
|
||||
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())),
|
||||
catalog: test_catalog(),
|
||||
lifecycle: LifecycleOptions {
|
||||
setup_commands: vec![],
|
||||
setup_command_timeout_ms: 1_000,
|
||||
|
|
@ -300,6 +312,8 @@ async fn execute_runs_start_to_exit_and_returns_final_context() {
|
|||
llm: LlmSpec {
|
||||
model: "test-model".to_string(),
|
||||
provider: fabro_llm::Provider::Anthropic,
|
||||
provider_id: fabro_llm::Provider::Anthropic.id(),
|
||||
profile_kind: fabro_model::AgentProfileKind::Anthropic,
|
||||
fallback_chain: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
dry_run: true,
|
||||
|
|
@ -308,6 +322,7 @@ async fn execute_runs_start_to_exit_and_returns_final_context() {
|
|||
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(test_emitter_arc(
|
||||
"run-test",
|
||||
))),
|
||||
catalog: test_catalog(),
|
||||
lifecycle: LifecycleOptions {
|
||||
setup_commands: vec![],
|
||||
setup_command_timeout_ms: 1_000,
|
||||
|
|
@ -376,12 +391,15 @@ async fn run_with_lifecycle(
|
|||
llm: LlmSpec {
|
||||
model: "test-model".to_string(),
|
||||
provider: fabro_llm::Provider::Anthropic,
|
||||
provider_id: fabro_llm::Provider::Anthropic.id(),
|
||||
profile_kind: fabro_model::AgentProfileKind::Anthropic,
|
||||
fallback_chain: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
dry_run: true,
|
||||
},
|
||||
interviewer: Arc::new(AutoApproveInterviewer::engine()),
|
||||
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())),
|
||||
catalog: test_catalog(),
|
||||
lifecycle,
|
||||
run_options,
|
||||
workflow_path: None,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use fabro_auth::{
|
|||
};
|
||||
use fabro_graphviz::graph;
|
||||
use fabro_hooks::{HookContext, HookDecision, HookEvent, HookRunner};
|
||||
use fabro_model::Catalog;
|
||||
use fabro_sandbox::{
|
||||
GitSetupIntent, ReadBeforeWriteSandbox, SandboxEventCallback, SandboxSpec,
|
||||
reconnect_for_run_with_callback,
|
||||
|
|
@ -124,6 +125,7 @@ async fn build_registry(
|
|||
github_token_refresh_managed: bool,
|
||||
graph: &graph::Graph,
|
||||
llm_source: Arc<dyn CredentialSource>,
|
||||
catalog: Arc<Catalog>,
|
||||
cli_resolver: Option<CredentialResolver>,
|
||||
) -> Result<(Arc<HandlerRegistry>, bool), Error> {
|
||||
let no_backend_interviewer = Arc::clone(&interviewer);
|
||||
|
|
@ -150,19 +152,25 @@ async fn build_registry(
|
|||
let build_llm_registry = || {
|
||||
let model = spec.model.clone();
|
||||
let provider = spec.provider;
|
||||
let provider_id = spec.provider_id.clone();
|
||||
let profile_kind = spec.profile_kind;
|
||||
let fallback_chain = spec.fallback_chain.clone();
|
||||
let mcp_servers = spec.mcp_servers.clone();
|
||||
let llm_source_for_api = Arc::clone(&llm_source);
|
||||
let catalog_for_api = Arc::clone(&catalog);
|
||||
let steering_hub_for_api = Arc::clone(&steering_hub);
|
||||
let tool_env_provider_for_backend = Arc::clone(&tool_env_provider);
|
||||
Arc::new(default_registry(interviewer, move || {
|
||||
let tool_env_provider = Arc::clone(&tool_env_provider_for_backend);
|
||||
let api = AgentApiBackend::new(
|
||||
let api = AgentApiBackend::new_with_catalog(
|
||||
model.clone(),
|
||||
provider,
|
||||
provider_id.clone(),
|
||||
profile_kind,
|
||||
fallback_chain.clone(),
|
||||
Arc::clone(&llm_source_for_api),
|
||||
Arc::clone(&steering_hub_for_api),
|
||||
Arc::clone(&catalog_for_api),
|
||||
)
|
||||
.with_tool_env_provider(tool_env_provider.clone())
|
||||
.with_mcp_servers(mcp_servers.clone());
|
||||
|
|
@ -188,7 +196,7 @@ async fn build_registry(
|
|||
return Ok((build_llm_registry(), false));
|
||||
}
|
||||
|
||||
match llm_source.resolve().await {
|
||||
match llm_source.resolve_for_catalog(catalog.as_ref()).await {
|
||||
Ok(result) if result.credentials.is_empty() => {
|
||||
if graph_needs_llm {
|
||||
let detail = (!result.auth_issues.is_empty()).then(|| {
|
||||
|
|
@ -340,6 +348,7 @@ pub async fn initialize(
|
|||
options.run_options.git = options.git.clone();
|
||||
|
||||
let llm_source = build_llm_source(options.vault.clone());
|
||||
let catalog = Arc::clone(&options.catalog);
|
||||
let cli_resolver = options.vault.clone().map(CredentialResolver::new);
|
||||
let sandbox_git = Arc::new(SandboxGitRuntime::new());
|
||||
let metadata_runtime = Arc::new(RunMetadataRuntime::new());
|
||||
|
|
@ -350,6 +359,7 @@ pub async fn initialize(
|
|||
Some(Arc::new(HookRunner::new(
|
||||
options.hooks.clone(),
|
||||
Arc::clone(&llm_source),
|
||||
Arc::clone(&catalog),
|
||||
)))
|
||||
};
|
||||
|
||||
|
|
@ -502,6 +512,7 @@ pub async fn initialize(
|
|||
github_token_refresh_managed,
|
||||
&graph,
|
||||
Arc::clone(&llm_source),
|
||||
Arc::clone(&catalog),
|
||||
cli_resolver,
|
||||
)
|
||||
.await?
|
||||
|
|
@ -658,6 +669,7 @@ pub async fn initialize(
|
|||
options.run_options.cancel_token.clone(),
|
||||
options.llm.provider,
|
||||
Arc::clone(&llm_source),
|
||||
catalog,
|
||||
sandbox_git,
|
||||
metadata_runtime,
|
||||
metadata_writer,
|
||||
|
|
@ -702,6 +714,7 @@ mod tests {
|
|||
use fabro_auth::{AuthCredential, AuthDetails};
|
||||
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
|
||||
use fabro_interview::AutoApproveInterviewer;
|
||||
use fabro_model::catalog::LlmCatalogSettings;
|
||||
use fabro_sandbox::SandboxSpec;
|
||||
use fabro_store::Database;
|
||||
use fabro_types::{EventBody, RunEvent, RunId, WorkflowSettings, fixtures};
|
||||
|
|
@ -721,6 +734,13 @@ mod tests {
|
|||
fixtures::RUN_1
|
||||
}
|
||||
|
||||
fn test_catalog() -> Arc<Catalog> {
|
||||
Arc::new(
|
||||
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
|
||||
.expect("default catalog should build"),
|
||||
)
|
||||
}
|
||||
|
||||
fn memory_store() -> Arc<Database> {
|
||||
Arc::new(Database::new(
|
||||
Arc::new(InMemory::new()),
|
||||
|
|
@ -862,12 +882,15 @@ mod tests {
|
|||
llm: LlmSpec {
|
||||
model: "test-model".to_string(),
|
||||
provider: fabro_llm::Provider::Anthropic,
|
||||
provider_id: fabro_llm::Provider::Anthropic.id(),
|
||||
profile_kind: fabro_model::AgentProfileKind::Anthropic,
|
||||
fallback_chain: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
dry_run: true,
|
||||
},
|
||||
interviewer: Arc::new(AutoApproveInterviewer::engine()),
|
||||
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())),
|
||||
catalog: test_catalog(),
|
||||
lifecycle: crate::run_options::LifecycleOptions {
|
||||
setup_commands: vec![command.to_string()],
|
||||
setup_command_timeout_ms: 1_000,
|
||||
|
|
@ -921,12 +944,15 @@ mod tests {
|
|||
llm: LlmSpec {
|
||||
model: "test-model".to_string(),
|
||||
provider: fabro_llm::Provider::Anthropic,
|
||||
provider_id: fabro_llm::Provider::Anthropic.id(),
|
||||
profile_kind: fabro_model::AgentProfileKind::Anthropic,
|
||||
fallback_chain: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
dry_run: true,
|
||||
},
|
||||
interviewer: Arc::new(AutoApproveInterviewer::engine()),
|
||||
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())),
|
||||
catalog: test_catalog(),
|
||||
lifecycle: crate::run_options::LifecycleOptions {
|
||||
setup_commands: vec![],
|
||||
setup_command_timeout_ms: 1_000,
|
||||
|
|
@ -1014,6 +1040,8 @@ mod tests {
|
|||
&LlmSpec {
|
||||
model: "claude-opus-4-6".to_string(),
|
||||
provider: fabro_llm::Provider::Anthropic,
|
||||
provider_id: fabro_llm::Provider::Anthropic.id(),
|
||||
profile_kind: fabro_model::AgentProfileKind::Anthropic,
|
||||
fallback_chain: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
dry_run: false,
|
||||
|
|
@ -1024,6 +1052,7 @@ mod tests {
|
|||
false,
|
||||
&graph,
|
||||
Arc::new(VaultCredentialSource::new(Arc::clone(&vault))),
|
||||
test_catalog(),
|
||||
Some(CredentialResolver::new(vault)),
|
||||
)
|
||||
.await
|
||||
|
|
@ -1129,12 +1158,15 @@ mod tests {
|
|||
llm: LlmSpec {
|
||||
model: "fake-acp".to_string(),
|
||||
provider: fabro_llm::Provider::OpenAi,
|
||||
provider_id: fabro_llm::Provider::OpenAi.id(),
|
||||
profile_kind: fabro_model::AgentProfileKind::OpenAi,
|
||||
fallback_chain: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
dry_run: false,
|
||||
},
|
||||
interviewer: Arc::new(AutoApproveInterviewer::engine()),
|
||||
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter)),
|
||||
catalog: test_catalog(),
|
||||
lifecycle: crate::run_options::LifecycleOptions {
|
||||
setup_commands: Vec::new(),
|
||||
setup_command_timeout_ms: 1_000,
|
||||
|
|
@ -1225,12 +1257,15 @@ mod tests {
|
|||
llm: LlmSpec {
|
||||
model: "test-model".to_string(),
|
||||
provider: fabro_llm::Provider::Anthropic,
|
||||
provider_id: fabro_llm::Provider::Anthropic.id(),
|
||||
profile_kind: fabro_model::AgentProfileKind::Anthropic,
|
||||
fallback_chain: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
dry_run: true,
|
||||
},
|
||||
interviewer: Arc::new(AutoApproveInterviewer::engine()),
|
||||
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())),
|
||||
catalog: test_catalog(),
|
||||
lifecycle: crate::run_options::LifecycleOptions {
|
||||
setup_commands: vec!["true".to_string()],
|
||||
setup_command_timeout_ms: 1_000,
|
||||
|
|
@ -1339,12 +1374,15 @@ mod tests {
|
|||
llm: LlmSpec {
|
||||
model: "test-model".to_string(),
|
||||
provider: fabro_llm::Provider::Anthropic,
|
||||
provider_id: fabro_llm::Provider::Anthropic.id(),
|
||||
profile_kind: fabro_model::AgentProfileKind::Anthropic,
|
||||
fallback_chain: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
dry_run: true,
|
||||
},
|
||||
interviewer: Arc::new(AutoApproveInterviewer::engine()),
|
||||
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())),
|
||||
catalog: test_catalog(),
|
||||
lifecycle: crate::run_options::LifecycleOptions {
|
||||
setup_commands: vec!["sleep 5".to_string()],
|
||||
setup_command_timeout_ms: 5_000,
|
||||
|
|
@ -1402,12 +1440,15 @@ mod tests {
|
|||
llm: LlmSpec {
|
||||
model: "test-model".to_string(),
|
||||
provider: fabro_llm::Provider::Anthropic,
|
||||
provider_id: fabro_llm::Provider::Anthropic.id(),
|
||||
profile_kind: fabro_model::AgentProfileKind::Anthropic,
|
||||
fallback_chain: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
dry_run: true,
|
||||
},
|
||||
interviewer: Arc::new(AutoApproveInterviewer::engine()),
|
||||
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter.clone())),
|
||||
catalog: test_catalog(),
|
||||
lifecycle: crate::run_options::LifecycleOptions {
|
||||
setup_commands: vec![],
|
||||
setup_command_timeout_ms: 5_000,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ use fabro_graphviz::parser;
|
|||
use fabro_llm::client::Client;
|
||||
use fabro_llm::generate::{GenerateParams, generate_object};
|
||||
use fabro_model::Catalog;
|
||||
#[cfg(test)]
|
||||
use fabro_model::catalog::LlmCatalogSettings;
|
||||
use fabro_store::RunProjection;
|
||||
use fabro_types::PullRequestRecord;
|
||||
use fabro_types::settings::run::MergeStrategy;
|
||||
|
|
@ -66,8 +68,8 @@ const UNKNOWN_MODEL_CTX: usize = 200_000;
|
|||
|
||||
/// Resolve truncation caps based on the model's context window. Unknown
|
||||
/// models use the baseline 200k context-window assumption.
|
||||
fn truncation_caps(model: &str) -> TruncationCaps {
|
||||
let ctx = Catalog::builtin()
|
||||
fn truncation_caps(model: &str, catalog: &Catalog) -> TruncationCaps {
|
||||
let ctx = catalog
|
||||
.get(model)
|
||||
.and_then(|m| usize::try_from(m.context_window()).ok())
|
||||
.unwrap_or(UNKNOWN_MODEL_CTX);
|
||||
|
|
@ -345,10 +347,11 @@ pub async fn build_pr_content(
|
|||
model: &str,
|
||||
run_store: &RunStoreHandle,
|
||||
llm_source: &dyn CredentialSource,
|
||||
catalog: Arc<Catalog>,
|
||||
conclusion: Option<&Conclusion>,
|
||||
run_state: Option<&RunProjection>,
|
||||
) -> Result<PrContent, String> {
|
||||
let client = Client::from_source(llm_source)
|
||||
let client = Client::from_source_with_catalog(llm_source, Arc::clone(&catalog))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create LLM client: {e}"))?;
|
||||
|
||||
|
|
@ -357,6 +360,7 @@ pub async fn build_pr_content(
|
|||
goal,
|
||||
model,
|
||||
run_store,
|
||||
catalog.as_ref(),
|
||||
conclusion,
|
||||
run_state,
|
||||
Arc::new(client),
|
||||
|
|
@ -369,6 +373,7 @@ async fn build_pr_content_with_client(
|
|||
goal: &str,
|
||||
model: &str,
|
||||
run_store: &RunStoreHandle,
|
||||
catalog: &Catalog,
|
||||
conclusion: Option<&Conclusion>,
|
||||
run_state: Option<&RunProjection>,
|
||||
client: Arc<Client>,
|
||||
|
|
@ -392,7 +397,7 @@ async fn build_pr_content_with_client(
|
|||
let run_spec = run_state.map(|state| state.spec.clone());
|
||||
let dot_source = run_state.and_then(|state| state.spec.graph_source.clone());
|
||||
|
||||
let caps = truncation_caps(model);
|
||||
let caps = truncation_caps(model, catalog);
|
||||
let truncated_diff = truncate_chars(diff, caps.diff);
|
||||
|
||||
let prompt = if let Some(ref plan) = plan_text {
|
||||
|
|
@ -462,6 +467,7 @@ pub struct OpenPullRequestRequest<'a> {
|
|||
pub auto_merge: Option<AutoMergeOptions>,
|
||||
pub run_store: &'a RunStoreHandle,
|
||||
pub llm_source: &'a dyn CredentialSource,
|
||||
pub catalog: Arc<Catalog>,
|
||||
pub conclusion: Option<&'a Conclusion>,
|
||||
pub run_state: Option<&'a RunProjection>,
|
||||
}
|
||||
|
|
@ -488,6 +494,7 @@ pub async fn maybe_open_pull_request(
|
|||
req.model,
|
||||
req.run_store,
|
||||
req.llm_source,
|
||||
Arc::clone(&req.catalog),
|
||||
req.conclusion,
|
||||
req.run_state,
|
||||
)
|
||||
|
|
@ -602,6 +609,7 @@ pub async fn pull_request(concluded: Concluded, options: &PullRequestOptions) ->
|
|||
auto_merge,
|
||||
run_store: &services.run_store,
|
||||
llm_source: services.llm_source.as_ref(),
|
||||
catalog: Arc::clone(&services.catalog),
|
||||
conclusion: Some(&conclusion),
|
||||
run_state: None,
|
||||
})
|
||||
|
|
@ -759,6 +767,13 @@ mod tests {
|
|||
))
|
||||
}
|
||||
|
||||
fn test_catalog() -> Arc<Catalog> {
|
||||
Arc::new(
|
||||
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
|
||||
.expect("default catalog should build"),
|
||||
)
|
||||
}
|
||||
|
||||
fn explicit_client(provider_name: &str, text: &str) -> Arc<Client> {
|
||||
let mut providers: HashMap<String, Arc<dyn ProviderAdapter>> = HashMap::new();
|
||||
providers.insert(
|
||||
|
|
@ -1082,6 +1097,7 @@ mod tests {
|
|||
"Implement feature",
|
||||
"mock-model",
|
||||
&run_store.clone().into(),
|
||||
Catalog::builtin(),
|
||||
Some(&make_test_conclusion()),
|
||||
None,
|
||||
explicit_client(
|
||||
|
|
@ -1149,6 +1165,7 @@ mod tests {
|
|||
"Implement feature",
|
||||
"mock-model",
|
||||
&run_store.clone().into(),
|
||||
Catalog::builtin(),
|
||||
Some(&make_test_conclusion()),
|
||||
None,
|
||||
explicit_client(
|
||||
|
|
@ -1240,6 +1257,7 @@ mod tests {
|
|||
"Implement feature",
|
||||
"mock-model",
|
||||
&run_store.clone().into(),
|
||||
Catalog::builtin(),
|
||||
Some(&make_test_conclusion()),
|
||||
None,
|
||||
explicit_client(
|
||||
|
|
@ -1264,6 +1282,7 @@ mod tests {
|
|||
"Implement feature",
|
||||
"gpt-5.4",
|
||||
&run_store.clone().into(),
|
||||
Catalog::builtin(),
|
||||
Some(&make_test_conclusion()),
|
||||
None,
|
||||
explicit_client(
|
||||
|
|
@ -1326,6 +1345,7 @@ mod tests {
|
|||
"gpt-5.4",
|
||||
&run_store_handle,
|
||||
llm_source.as_ref(),
|
||||
test_catalog(),
|
||||
Some(&make_test_conclusion()),
|
||||
None,
|
||||
)
|
||||
|
|
@ -1482,10 +1502,13 @@ mod tests {
|
|||
plan: 100_000,
|
||||
}
|
||||
);
|
||||
assert_eq!(truncation_caps("unknown-model"), TruncationCaps {
|
||||
diff: 80_000,
|
||||
plan: 20_000,
|
||||
});
|
||||
assert_eq!(
|
||||
truncation_caps("unknown-model", Catalog::builtin()),
|
||||
TruncationCaps {
|
||||
diff: 80_000,
|
||||
plan: 20_000,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -1512,6 +1535,7 @@ mod tests {
|
|||
auto_merge: None,
|
||||
run_store: &run_store_handle,
|
||||
llm_source: llm_source.as_ref(),
|
||||
catalog: test_catalog(),
|
||||
conclusion: None,
|
||||
run_state: None,
|
||||
})
|
||||
|
|
@ -1602,6 +1626,7 @@ mod tests {
|
|||
"Implement feature",
|
||||
"mock-model",
|
||||
&run_store.clone().into(),
|
||||
Catalog::builtin(),
|
||||
Some(&make_test_conclusion()),
|
||||
None,
|
||||
explicit_client("mock", &payload),
|
||||
|
|
@ -1624,6 +1649,7 @@ mod tests {
|
|||
"## Plan:",
|
||||
"mock-model",
|
||||
&run_store.clone().into(),
|
||||
Catalog::builtin(),
|
||||
Some(&make_test_conclusion()),
|
||||
None,
|
||||
explicit_client("mock", &payload),
|
||||
|
|
@ -1706,6 +1732,7 @@ mod tests {
|
|||
"Implement feature",
|
||||
"mock-model",
|
||||
&run_store.clone().into(),
|
||||
Catalog::builtin(),
|
||||
Some(&make_test_conclusion()),
|
||||
None,
|
||||
explicit_client("mock", &payload),
|
||||
|
|
@ -1905,6 +1932,7 @@ mod tests {
|
|||
auto_merge: None,
|
||||
run_store: &harness.run_store,
|
||||
llm_source: harness.llm_source.as_ref(),
|
||||
catalog: test_catalog(),
|
||||
conclusion: None,
|
||||
run_state: None,
|
||||
})
|
||||
|
|
@ -1941,6 +1969,7 @@ mod tests {
|
|||
auto_merge: None,
|
||||
run_store: &harness.run_store,
|
||||
llm_source: harness.llm_source.as_ref(),
|
||||
catalog: test_catalog(),
|
||||
conclusion: None,
|
||||
run_state: None,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use fabro_graphviz::graph::Graph;
|
|||
use fabro_interview::Interviewer;
|
||||
use fabro_llm::Provider;
|
||||
use fabro_mcp::config::McpServerSettings;
|
||||
use fabro_model::FallbackTarget;
|
||||
use fabro_model::{AgentProfileKind, Catalog, FallbackTarget, ProviderId};
|
||||
use fabro_sandbox::SandboxSpec;
|
||||
use fabro_types::RunId;
|
||||
use fabro_types::settings::run::PullRequestSettings;
|
||||
|
|
@ -220,6 +220,8 @@ impl Persisted {
|
|||
pub struct LlmSpec {
|
||||
pub model: String,
|
||||
pub provider: Provider,
|
||||
pub provider_id: ProviderId,
|
||||
pub profile_kind: AgentProfileKind,
|
||||
pub fallback_chain: Vec<FallbackTarget>,
|
||||
pub mcp_servers: Vec<McpServerSettings>,
|
||||
pub dry_run: bool,
|
||||
|
|
@ -248,6 +250,7 @@ pub struct InitOptions {
|
|||
pub llm: LlmSpec,
|
||||
pub interviewer: Arc<dyn Interviewer>,
|
||||
pub steering_hub: Arc<SteeringHub>,
|
||||
pub catalog: Arc<Catalog>,
|
||||
pub lifecycle: LifecycleOptions,
|
||||
pub run_options: RunOptions,
|
||||
pub workflow_path: Option<ManifestPath>,
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@ use fabro_auth::CredentialSource;
|
|||
#[cfg(test)]
|
||||
use fabro_auth::ResolvedCredentials;
|
||||
use fabro_hooks::{HookContext, HookDecision, HookRunner};
|
||||
use fabro_model::Provider;
|
||||
#[cfg(test)]
|
||||
use fabro_model::ProviderId;
|
||||
#[cfg(test)]
|
||||
use fabro_model::catalog::LlmCatalogSettings;
|
||||
use fabro_model::{Catalog, Provider};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::ManifestPath;
|
||||
|
|
@ -42,6 +44,7 @@ pub struct RunServices {
|
|||
pub(crate) cancel_token: CancellationToken,
|
||||
pub provider: Provider,
|
||||
pub llm_source: Arc<dyn CredentialSource>,
|
||||
pub catalog: Arc<Catalog>,
|
||||
pub(crate) sandbox_git: Arc<SandboxGitRuntime>,
|
||||
pub(crate) metadata_runtime: Arc<RunMetadataRuntime>,
|
||||
pub(crate) metadata_writer: Option<RunMetadataWriterHandle>,
|
||||
|
|
@ -57,6 +60,7 @@ impl RunServices {
|
|||
cancel_token: CancellationToken,
|
||||
provider: Provider,
|
||||
llm_source: Arc<dyn CredentialSource>,
|
||||
catalog: Arc<Catalog>,
|
||||
sandbox_git: Arc<SandboxGitRuntime>,
|
||||
metadata_runtime: Arc<RunMetadataRuntime>,
|
||||
metadata_writer: Option<RunMetadataWriterHandle>,
|
||||
|
|
@ -69,6 +73,7 @@ impl RunServices {
|
|||
cancel_token,
|
||||
provider,
|
||||
llm_source,
|
||||
catalog,
|
||||
sandbox_git,
|
||||
metadata_runtime,
|
||||
metadata_writer,
|
||||
|
|
@ -229,6 +234,10 @@ impl EngineServices {
|
|||
CancellationToken::new(),
|
||||
Provider::Anthropic,
|
||||
Arc::new(StubCredentialSource),
|
||||
Arc::new(
|
||||
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
|
||||
.expect("default catalog should build"),
|
||||
),
|
||||
Arc::new(SandboxGitRuntime::new()),
|
||||
Arc::new(RunMetadataRuntime::new()),
|
||||
None,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ use fabro_interview::{
|
|||
QueueInterviewer, RecordingInterviewer,
|
||||
};
|
||||
use fabro_llm::provider::Provider;
|
||||
use fabro_model::Catalog;
|
||||
use fabro_model::catalog::LlmCatalogSettings;
|
||||
use fabro_store::{ArtifactKey, ArtifactStore, Database};
|
||||
use fabro_types::{CommandTermination, RunEvent, RunId, StageId, WorkflowSettings, parse_blob_ref};
|
||||
use fabro_validate::{Severity, validate, validate_or_raise};
|
||||
|
|
@ -61,6 +63,13 @@ use object_store::local::LocalFileSystem;
|
|||
use tokio_util::sync::CancellationToken;
|
||||
use ulid::Ulid;
|
||||
|
||||
fn default_catalog() -> Arc<Catalog> {
|
||||
Arc::new(
|
||||
Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())
|
||||
.expect("default catalog should build"),
|
||||
)
|
||||
}
|
||||
|
||||
fn local_env() -> Arc<dyn fabro_agent::Sandbox> {
|
||||
Arc::new(fabro_agent::LocalSandbox::new(
|
||||
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
|
||||
|
|
@ -6993,6 +7002,7 @@ async fn workflow_run_with_vault_only_openai_codex_builds_pr_body() {
|
|||
"gpt-5.4",
|
||||
&run_store_handle,
|
||||
llm_source.as_ref(),
|
||||
default_catalog(),
|
||||
Some(&Conclusion {
|
||||
timestamp: Utc::now(),
|
||||
status: StageOutcome::Succeeded,
|
||||
|
|
@ -7801,6 +7811,7 @@ fn hook_runner_from_defs(hooks: Vec<fabro_hooks::HookDefinition>) -> Arc<fabro_h
|
|||
Arc::new(fabro_hooks::HookRunner::new(
|
||||
fabro_hooks::HookSettings { hooks },
|
||||
Arc::new(fabro_auth::EnvCredentialSource::new()),
|
||||
default_catalog(),
|
||||
))
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue