From 3a998196ff768d1480ba151a5b1feb80aefcf63a Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 10 Sep 2026 09:26:40 -0600 Subject: [PATCH] Answer catalog questions with the lithos catalog queries fabro-llm's catalog module held some 250 lines of listing and picking helpers over lithos data: enabled and listed providers, model lookup by id, alias, or wire id, matches ranked as the resolver ranks, default and probe models, the small utility model across ready providers, the nearest model on another provider, and cost by handle. lithos-llm now answers all of those on `Catalog` and `CatalogProvider` through `Offering`, so the helpers and the `ModelEntry` wrapper go. What stays in Fabro's catalog module is its own: building the catalog from the operator overlay, and reading the agent harness and `reasoning_by_default` from the shared `metadata.agent` namespace. The passthrough selection policy in `selection.rs` keeps its rules and calls lithos for the lookups. Co-Authored-By: Claude Fable 5.1 --- lib/apps/fabro-cli/src/commands/exec.rs | 8 +- lib/apps/fabro-cli/src/commands/install.rs | 10 +- .../fabro-cli/src/shared/provider_auth.rs | 6 +- lib/apps/fabro-server/src/diagnostics.rs | 9 +- lib/apps/fabro-server/src/install.rs | 4 +- lib/apps/fabro-server/src/run_compiler.rs | 3 +- lib/apps/fabro-server/src/run_manifest.rs | 8 +- lib/apps/fabro-server/src/server.rs | 6 +- .../fabro-server/src/server/handler/models.rs | 8 +- .../src/server/handler/pull_requests.rs | 9 +- .../fabro-server/src/server/handler/runs.rs | 8 +- .../src/server/handler/sessions.rs | 31 +- lib/apps/fabro-server/src/server/tests.rs | 24 +- lib/apps/fabro-server/src/test_support.rs | 3 +- lib/apps/fabro-server/tests/it/api/runs.rs | 5 +- .../fabro-agent/src/agent_profile.rs | 13 +- lib/components/fabro-agent/src/cli.rs | 48 +- lib/components/fabro-agent/src/compaction.rs | 14 +- .../fabro-agent/tests/it/guardrails.rs | 6 +- lib/components/fabro-llm/src/api.rs | 21 +- lib/components/fabro-llm/src/catalog.rs | 428 +++--------------- lib/components/fabro-llm/src/lib.rs | 3 +- lib/components/fabro-llm/src/probe.rs | 7 +- lib/components/fabro-llm/src/selection.rs | 49 +- .../fabro-validate/src/rules/model_support.rs | 8 +- .../fabro-workflow/src/handler/llm/api.rs | 28 +- .../fabro-workflow/src/handler/llm/routing.rs | 23 +- .../fabro-workflow/src/model_fallback.rs | 31 +- .../fabro-workflow/src/operations/create.rs | 3 +- .../fabro-workflow/src/operations/start.rs | 3 +- lib/components/fabro-workflow/src/outcome.rs | 5 +- .../src/pipeline/pull_request.rs | 2 +- .../fabro-workflow/src/test_support.rs | 3 +- .../src/transforms/model_resolution.rs | 4 +- 34 files changed, 299 insertions(+), 542 deletions(-) diff --git a/lib/apps/fabro-cli/src/commands/exec.rs b/lib/apps/fabro-cli/src/commands/exec.rs index 1363f3185..535e94332 100644 --- a/lib/apps/fabro-cli/src/commands/exec.rs +++ b/lib/apps/fabro-cli/src/commands/exec.rs @@ -9,7 +9,7 @@ use fabro_agent::cli::{ }; use fabro_llm::gateway::{GatewayAdapter, GatewayError, GatewayTransport}; use fabro_llm::lithos_catalog::Catalog; -use fabro_llm::{ErrorFacts, ErrorKind, catalog}; +use fabro_llm::{ErrorFacts, ErrorKind}; use fabro_mcp::config::McpServerSettings; use fabro_types::ProviderId; use fabro_types::settings::cli::OutputFormat as SettingsOutputFormat; @@ -143,8 +143,10 @@ pub(crate) async fn execute(mut args: ExecArgs, ctx: &CommandContext) -> AnyResu .clone() .unwrap_or_else(|| "anthropic".to_string()); let catalog = ctx.catalog()?; - let provider_id = catalog::canonical_provider_id(&catalog, &provider_name) - .unwrap_or_else(|| ProviderId::new(provider_name.as_str())); + let provider_id = catalog.enabled_provider(&provider_name).map_or_else( + || ProviderId::new(provider_name.as_str()), + |provider| provider.id().clone(), + ); let server_client = server_client::connect_server_target(&target).await?; let adapter = Arc::new(GatewayAdapter::new(Box::new( ServerCompletionTransport::new(server_client), diff --git a/lib/apps/fabro-cli/src/commands/install.rs b/lib/apps/fabro-cli/src/commands/install.rs index f7817bf2f..11d88f15d 100644 --- a/lib/apps/fabro-cli/src/commands/install.rs +++ b/lib/apps/fabro-cli/src/commands/install.rs @@ -34,7 +34,6 @@ use fabro_install::{ restore_optional_file, rollback_dev_token_write, seed_environments_in_storage, write_github_app_settings, write_token_settings, }; -use fabro_llm::catalog; use fabro_llm::lithos_catalog::{Catalog, CatalogProvider}; use fabro_server::serve; use fabro_store::ArtifactStore; @@ -82,7 +81,8 @@ fn supports_install_api_key(provider: &CatalogProvider) -> bool { } fn install_llm_provider_ids(catalog: &Catalog) -> Vec { - catalog::listed_providers(catalog) + catalog + .listed_providers() .into_iter() .filter(|provider| supports_install_api_key(provider)) .map(|provider| provider.id().clone()) @@ -90,14 +90,16 @@ fn install_llm_provider_ids(catalog: &Catalog) -> Vec { } fn provider_env_var_label(provider: &ProviderId, catalog: &Catalog) -> String { - catalog::provider(catalog, provider.as_str()) + catalog + .enabled_provider(provider.as_str()) .map(|provider| fabro_auth::secret_names(provider).join(" / ")) .filter(|label| !label.is_empty()) .unwrap_or_else(|| "API_KEY".to_string()) } fn provider_vault_secret_name(provider: &ProviderId, catalog: &Catalog) -> String { - catalog::provider(catalog, provider.as_str()) + catalog + .enabled_provider(provider.as_str()) .and_then(fabro_auth::expected_secret_name) .unwrap_or_else(|| format!("{}_API_KEY", provider.to_string().to_uppercase())) } diff --git a/lib/apps/fabro-cli/src/shared/provider_auth.rs b/lib/apps/fabro-cli/src/shared/provider_auth.rs index 6de3d0652..f9a2872b3 100644 --- a/lib/apps/fabro-cli/src/shared/provider_auth.rs +++ b/lib/apps/fabro-cli/src/shared/provider_auth.rs @@ -18,7 +18,6 @@ use fabro_auth::{ AuthContextRequest, AuthContextResponse, AuthMethod, LoginResult, codex_oauth_config, strategy_for, }; -use fabro_llm::catalog; use fabro_llm::lithos_catalog::{Catalog, CatalogProvider}; use fabro_llm::probe::{self, ApiKeyProbeError, ModelTestStatus}; use fabro_types::{ProviderId, provider_ids}; @@ -59,7 +58,7 @@ fn default_catalog_for_provider_auth() -> Arc { } pub(crate) fn provider_display_name(provider: &ProviderId, catalog: &Catalog) -> String { - catalog::provider(catalog, provider.as_str()).map_or_else( + catalog.enabled_provider(provider.as_str()).map_or_else( || provider.to_string(), |provider| provider.display_name().to_string(), ) @@ -69,7 +68,8 @@ fn api_key_catalog_provider<'a>( provider: &ProviderId, catalog: &'a Catalog, ) -> Result<&'a CatalogProvider> { - let provider = catalog::provider(catalog, provider.as_str()) + let provider = catalog + .enabled_provider(provider.as_str()) .with_context(|| format!("provider '{provider}' is not configured in the model catalog"))?; anyhow::ensure!( fabro_auth::accepts_api_key(provider), diff --git a/lib/apps/fabro-server/src/diagnostics.rs b/lib/apps/fabro-server/src/diagnostics.rs index 0397c6c11..ff6edc66f 100644 --- a/lib/apps/fabro-server/src/diagnostics.rs +++ b/lib/apps/fabro-server/src/diagnostics.rs @@ -5,9 +5,9 @@ use std::time::Duration; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use fabro_http::Response; -use fabro_llm::lithos_catalog::Catalog; +use fabro_llm::Client; +use fabro_llm::lithos_catalog::{Catalog, CatalogProvider}; use fabro_llm::probe::{self, ModelTestStatus}; -use fabro_llm::{Client, catalog}; use fabro_redact::redact_string; use fabro_sandbox::{DockerSandboxProvider, daytona}; use fabro_static::EnvVars; @@ -249,7 +249,10 @@ async fn probe_single_provider( return provider_probe_error(provider, None, message, None); } - let Some(model) = catalog::probe_model(catalog, provider.as_str()) else { + let Some(model) = catalog + .enabled_provider(provider.as_str()) + .and_then(CatalogProvider::probe_offering) + else { return provider_probe_error( provider, None, diff --git a/lib/apps/fabro-server/src/install.rs b/lib/apps/fabro-server/src/install.rs index 63cc592b1..f91a28463 100644 --- a/lib/apps/fabro-server/src/install.rs +++ b/lib/apps/fabro-server/src/install.rs @@ -24,7 +24,6 @@ use fabro_install::{ write_github_app_settings, write_object_store_settings, write_sandbox_settings, write_token_settings, }; -use fabro_llm::catalog as llm_catalog; use fabro_llm::lithos_catalog::{Catalog, CatalogProvider}; use fabro_llm::probe::{self, ApiKeyProbeError, ModelTestStatus}; use fabro_sandbox::daytona; @@ -846,7 +845,8 @@ async fn put_install_llm( } fn install_catalog_provider(provider: &ProviderId) -> Result<&'static CatalogProvider, String> { - let catalog_provider = llm_catalog::provider(&INSTALL_CATALOG, provider.as_str()) + let catalog_provider = INSTALL_CATALOG + .enabled_provider(provider.as_str()) .ok_or_else(|| format!("provider '{provider}' is not configured in the model catalog"))?; if fabro_auth::accepts_api_key(catalog_provider) { Ok(catalog_provider) diff --git a/lib/apps/fabro-server/src/run_compiler.rs b/lib/apps/fabro-server/src/run_compiler.rs index cfcbe14d5..5e85741f3 100644 --- a/lib/apps/fabro-server/src/run_compiler.rs +++ b/lib/apps/fabro-server/src/run_compiler.rs @@ -765,7 +765,8 @@ mod tests { } fn test_provider_ids() -> Vec { - fabro_llm::catalog::enabled_provider_ids(&fabro_llm::test_support::test_catalog()) + fabro_llm::test_support::test_catalog() + .enabled_provider_ids() .into_iter() .collect() } diff --git a/lib/apps/fabro-server/src/run_manifest.rs b/lib/apps/fabro-server/src/run_manifest.rs index dcef70c8f..fcc9c42cc 100644 --- a/lib/apps/fabro-server/src/run_manifest.rs +++ b/lib/apps/fabro-server/src/run_manifest.rs @@ -14,9 +14,9 @@ use fabro_config::{ use fabro_github::token_source::{InstallationTokenSource, ResolvedToken, TokenSnapshot}; use fabro_graphviz::graph::{Graph, is_llm_handler_type}; use fabro_graphviz::render::apply_direction; +use fabro_llm::FabroClient; use fabro_llm::lithos_catalog::Catalog; use fabro_llm::probe::{self, ModelTestStatus}; -use fabro_llm::{FabroClient, catalog}; use fabro_sandbox::daytona::DaytonaConfig; use fabro_sandbox::from_environment::{ daytona_config_from_environment, docker_config_from_environment, @@ -1209,8 +1209,10 @@ async fn run_llm_check( } fn canonical_provider_id(catalog: &Catalog, provider_name: &str) -> ProviderId { - catalog::canonical_provider_id(catalog, provider_name) - .unwrap_or_else(|| ProviderId::new(provider_name)) + catalog.enabled_provider(provider_name).map_or_else( + || ProviderId::new(provider_name), + |provider| provider.id().clone(), + ) } async fn run_github_token_check( diff --git a/lib/apps/fabro-server/src/server.rs b/lib/apps/fabro-server/src/server.rs index 6911f9c88..da28e8865 100644 --- a/lib/apps/fabro-server/src/server.rs +++ b/lib/apps/fabro-server/src/server.rs @@ -59,7 +59,7 @@ use fabro_interview::{ }; use fabro_llm::credentials::CredentialProvider; use fabro_llm::lithos_catalog::Catalog; -use fabro_llm::{ClientOptions, FabroClient, catalog}; +use fabro_llm::{ClientOptions, FabroClient}; use fabro_mcp_store::McpServerStore; use fabro_redact::redact_jsonl_line; use fabro_sandbox::daytona::{self, DaytonaSandbox}; @@ -1446,8 +1446,8 @@ impl AppState { let default_model = if provider_ids.is_empty() { None } else { - let ready = provider_ids.iter().cloned().collect::>(); - catalog::default_for_ready(&self.catalog(), &ready) + self.catalog() + .default_offering_for(&provider_ids) .map(|entry| entry.model.id().to_string()) }; AskFabroReadiness { default_model } diff --git a/lib/apps/fabro-server/src/server/handler/models.rs b/lib/apps/fabro-server/src/server/handler/models.rs index 544721f6d..10f3ae110 100644 --- a/lib/apps/fabro-server/src/server/handler/models.rs +++ b/lib/apps/fabro-server/src/server/handler/models.rs @@ -4,7 +4,7 @@ use std::time::Duration; use fabro_llm::lithos_catalog::Catalog; use fabro_llm::probe::{self, ApiKeyProbeError, ModelTestStatus}; -use fabro_llm::{ModelSelectionError, api, catalog, selection}; +use fabro_llm::{ModelSelectionError, api, selection}; use fabro_redact::redact_string; use fabro_types::ReasoningEffort; @@ -59,8 +59,10 @@ async fn list_models( let catalog = state.catalog(); // An unknown provider filter matches nothing rather than erroring. let provider_id = params.provider.as_deref().map(|selector| { - catalog::canonical_provider_id(&catalog, selector) - .unwrap_or_else(|| ProviderId::new(selector)) + catalog.enabled_provider(selector).map_or_else( + || ProviderId::new(selector), + |provider| provider.id().clone(), + ) }); let query = params.query.as_ref().map(|value| value.to_lowercase()); diff --git a/lib/apps/fabro-server/src/server/handler/pull_requests.rs b/lib/apps/fabro-server/src/server/handler/pull_requests.rs index 46b088d76..50b473c6b 100644 --- a/lib/apps/fabro-server/src/server/handler/pull_requests.rs +++ b/lib/apps/fabro-server/src/server/handler/pull_requests.rs @@ -2,7 +2,6 @@ use std::sync::Arc; use std::time::Duration; use axum::http::{HeaderValue, header}; -use fabro_llm::catalog; use super::super::{ ApiError, AppState, CloseRunPullRequestResponse, CreateRunPullRequestRequest, IntoResponse, @@ -344,12 +343,8 @@ async fn create_run_pull_request( model } else { let catalog = state.catalog(); - let configured = state - .ready_llm_provider_ids() - .await - .into_iter() - .collect::>(); - match catalog::default_for_ready(&catalog, &configured) { + let configured = state.ready_llm_provider_ids().await; + match catalog.default_offering_for(&configured) { Some(entry) => entry.model.id().to_string(), None => { return ApiError::bad_request("no LLM model is available for PR generation") diff --git a/lib/apps/fabro-server/src/server/handler/runs.rs b/lib/apps/fabro-server/src/server/handler/runs.rs index 4b4f28612..1e52514ee 100644 --- a/lib/apps/fabro-server/src/server/handler/runs.rs +++ b/lib/apps/fabro-server/src/server/handler/runs.rs @@ -21,7 +21,7 @@ use fabro_api::types::{ use fabro_config::{CliLayer, RunLayer, Storage, project}; use fabro_environment::{DEFAULT_ENVIRONMENT_ID, EnvironmentId}; use fabro_interview::AnswerSubmission; -use fabro_llm::{Client as LlmClient, catalog}; +use fabro_llm::Client as LlmClient; use fabro_manifest::RunOverrideInput; use fabro_static::EnvVars; use fabro_store::{ @@ -953,11 +953,7 @@ async fn finalize_created_run( let workflow = run_title_generation::workflow_summary(&run_spec.graph); let run_inputs = run_spec.settings.run.inputs.clone(); let title_catalog = state.catalog(); - let ready = ready_provider_ids - .iter() - .cloned() - .collect::>(); - if let Some(title_model) = catalog::small_default_for_ready(&title_catalog, &ready) { + if let Some(title_model) = title_catalog.small_default_for(&ready_provider_ids) { spawn_generated_title_task(GeneratedTitleTask { state: Arc::clone(&state), run_id: created.run_id, diff --git a/lib/apps/fabro-server/src/server/handler/sessions.rs b/lib/apps/fabro-server/src/server/handler/sessions.rs index 35203846b..bce2e83fe 100644 --- a/lib/apps/fabro-server/src/server/handler/sessions.rs +++ b/lib/apps/fabro-server/src/server/handler/sessions.rs @@ -829,7 +829,7 @@ fn canonical_session_model( ) -> Result<(ProviderId, String), ApiError> { let explicit_provider = explicit_provider .map(|provider| { - catalog::canonical_provider_id(catalog, provider.as_str()).ok_or_else(|| { + enabled_provider_id(catalog, provider.as_str()).ok_or_else(|| { session_selection_error(&ModelSelectionError::UnknownProvider { provider: provider.to_string(), }) @@ -849,7 +849,10 @@ fn canonical_session_model( // An aggregator's wire id (`openai/gpt-5.6-sol` on OpenRouter) is matched // whole on a pinned provider before its prefix is read as a provider. if let Some(explicit) = explicit_provider.as_ref().filter(|p| eligible.contains(*p)) { - if let Some(entry) = catalog::model_on_provider(catalog, explicit.as_str(), requested) { + if let Some(entry) = catalog + .enabled_provider(explicit.as_str()) + .and_then(|provider| provider.offering(requested)) + { return Ok((explicit.clone(), entry.model.id().to_string())); } } @@ -859,7 +862,7 @@ fn canonical_session_model( .qualify(catalog); let (qualified_provider, selector) = match model_ref { SettingsModelRef::Qualified { provider, selector } => { - let provider = catalog::canonical_provider_id(catalog, &provider).ok_or_else(|| { + let provider = enabled_provider_id(catalog, &provider).ok_or_else(|| { session_selection_error(&ModelSelectionError::UnknownProvider { provider }) })?; // When the prefixed provider is not ready, the whole string may @@ -880,8 +883,8 @@ fn canonical_session_model( (Some(provider), selector) } SettingsModelRef::Bare(selector) => { - if explicit_provider.is_none() && catalog::is_provider_selector(catalog, &selector) { - let detail = if catalog::is_model_selector(catalog, &selector) { + if explicit_provider.is_none() && catalog.enabled_provider(&selector).is_some() { + let detail = if catalog.is_model_selector(&selector) { format!( "Session model reference '{selector}' is ambiguous between a provider and \ a model selector; supply `provider` or use `provider:model`." @@ -908,17 +911,25 @@ fn api_model_on_eligible( api_model: &str, eligible: &std::collections::HashSet, ) -> Option<(ProviderId, String)> { - catalog::enabled_providers(catalog) + catalog + .enabled_providers() .into_iter() .filter(|provider| eligible.contains(provider.id())) .find_map(|provider| { - catalog::provider_models(provider) - .into_iter() + provider + .offerings() .find(|model| model.model.api_model() == api_model) .map(|model| (provider.id().clone(), model.model.id().to_string())) }) } +/// The catalog id of an enabled provider named by id or alias. +fn enabled_provider_id(catalog: &Catalog, selector: &str) -> Option { + catalog + .enabled_provider(selector) + .map(|provider| provider.id().clone()) +} + fn session_selection_error(error: &ModelSelectionError) -> ApiError { ApiError::bad_request(error.to_string()) } @@ -1705,7 +1716,7 @@ enabled = true assert_eq!( canonical_session_model( &catalog, - &fabro_llm::catalog::enabled_provider_ids(&catalog), + &catalog.enabled_provider_ids().into_iter().collect(), Some("openrouter:gpt-56-sol"), None, ) @@ -1719,7 +1730,7 @@ enabled = true let catalog = portable_session_catalog(); let error = canonical_session_model( &catalog, - &fabro_llm::catalog::enabled_provider_ids(&catalog), + &catalog.enabled_provider_ids().into_iter().collect(), Some("openrouter:gpt-56-sol"), Some(&fabro_types::provider_ids::openai()), ) diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index e753cfe28..317451efa 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -9322,16 +9322,17 @@ async fn list_providers_marks_configured_per_provider_and_omits_secrets() { // `model_count` and `default_model` must reflect the catalog truth for // this exact provider, not merely be populated. let catalog = state_test_catalog(); - let expected_model_count = fabro_llm::catalog::provider_models( - fabro_llm::catalog::provider(&catalog, "anthropic").expect("anthropic should be listed"), - ) - .len(); + let anthropic_provider = catalog + .enabled_provider("anthropic") + .expect("anthropic should be listed"); + let expected_model_count = anthropic_provider.offerings().len(); assert_eq!( anthropic["model_count"].as_u64(), Some(expected_model_count as u64), "anthropic model_count should match the catalog" ); - let expected_default = fabro_llm::catalog::default_model(&catalog, "anthropic") + let expected_default = anthropic_provider + .default_offering() .expect("anthropic should have a catalog default model"); assert_eq!( anthropic["default_model"].as_str(), @@ -11387,12 +11388,13 @@ async fn pull_request_creation_returns_the_active_durable_request() { .await .into_iter() .collect::>(); - let expected_default_model = - fabro_llm::catalog::default_for_ready(&state.catalog(), &configured_provider_ids) - .expect("a ready provider should have a default model") - .model - .id() - .to_string(); + let expected_default_model = state + .catalog() + .default_offering_for(&configured_provider_ids) + .expect("a ready provider should have a default model") + .model + .id() + .to_string(); let request_body = json!({ "force": false, "model": null diff --git a/lib/apps/fabro-server/src/test_support.rs b/lib/apps/fabro-server/src/test_support.rs index 3b78dc2ef..b3743aaeb 100644 --- a/lib/apps/fabro-server/src/test_support.rs +++ b/lib/apps/fabro-server/src/test_support.rs @@ -18,7 +18,6 @@ use fabro_config::user::default_storage_dir; use fabro_config::{LlmLayer, RunLayer, ServerSettingsBuilder, Storage, envfile}; use fabro_db::DbPool; use fabro_interview::Interviewer; -use fabro_llm::catalog; use fabro_llm::lithos_catalog::Catalog; use fabro_sandbox::SandboxProviderRegistry; use fabro_static::EnvVars; @@ -67,7 +66,7 @@ pub(crate) fn test_run_materialization_provider_ids( let assume_ready = process_env_var(FABRO_TEST_ASSUME_LLM_READY) .is_some_and(|value| !matches!(value.as_str(), "" | "0" | "false" | "no")); if assume_ready { - catalog::enabled_provider_ids(catalog).into_iter().collect() + catalog.enabled_provider_ids().into_iter().collect() } else { ready_provider_ids.to_vec() } diff --git a/lib/apps/fabro-server/tests/it/api/runs.rs b/lib/apps/fabro-server/tests/it/api/runs.rs index c3167a7a7..05f887387 100644 --- a/lib/apps/fabro-server/tests/it/api/runs.rs +++ b/lib/apps/fabro-server/tests/it/api/runs.rs @@ -1,5 +1,6 @@ use axum::body::Body; use axum::http::{Request, StatusCode}; +use fabro_llm::lithos_catalog::CatalogProvider; use fabro_types::settings::run::EnvironmentProvider; use tower::ServiceExt; @@ -161,7 +162,9 @@ _version = 1 "sandbox_not_ready" ); let catalog = fabro_llm::test_support::test_catalog(); - let default_openai_model = fabro_llm::catalog::default_model(&catalog, "openai") + let default_openai_model = catalog + .enabled_provider("openai") + .and_then(CatalogProvider::default_offering) .expect("the built-in OpenAI provider should have a default model"); assert_eq!( created["ask_fabro"]["default_model"].as_str(), diff --git a/lib/components/fabro-agent/src/agent_profile.rs b/lib/components/fabro-agent/src/agent_profile.rs index a8021f886..52a9ae2e2 100644 --- a/lib/components/fabro-agent/src/agent_profile.rs +++ b/lib/components/fabro-agent/src/agent_profile.rs @@ -1,7 +1,7 @@ use std::sync::Arc; -use fabro_llm::catalog::{self, ModelEntry}; -use fabro_llm::lithos_catalog::Catalog; +use fabro_llm::catalog; +use fabro_llm::lithos_catalog::{Catalog, Offering}; use fabro_types::{AgentProfileKind, ProviderId, ToolDefinition}; use crate::profiles::EnvContext; @@ -44,9 +44,10 @@ pub trait AgentProfile: Send + Sync { } /// The catalog row for this profile's route, when the catalog knows it. - fn catalog_model(&self) -> Option> { - let catalog = self.catalog()?; - catalog::model_on_provider(catalog, self.provider_id().as_str(), self.model()) + fn catalog_model(&self) -> Option> { + self.catalog()? + .enabled_provider(self.provider_id().as_str())? + .offering(self.model()) } fn context_window_size(&self) -> usize { @@ -65,7 +66,7 @@ pub trait AgentProfile: Send + Sync { fn reasons_by_default(&self) -> bool { self.catalog_model() - .is_some_and(|entry| entry.reasons_by_default()) + .is_some_and(|entry| catalog::reasons_by_default(&entry)) } fn register_subagent_tools( diff --git a/lib/components/fabro-agent/src/cli.rs b/lib/components/fabro-agent/src/cli.rs index a677ceacf..51aa41db7 100644 --- a/lib/components/fabro-agent/src/cli.rs +++ b/lib/components/fabro-agent/src/cli.rs @@ -13,7 +13,7 @@ use fabro_auth::SqlVaultCredentialSource; use fabro_config::Storage; use fabro_config::user::default_storage_dir; use fabro_llm::credentials::CredentialProvider; -use fabro_llm::lithos_catalog::Catalog; +use fabro_llm::lithos_catalog::{Catalog, CatalogProvider}; use fabro_llm::middleware::{Call, Middleware, Next, Output}; use fabro_llm::{Client, ClientOptions, Error as LlmError, catalog}; use fabro_mcp::config::McpServerSettings; @@ -193,14 +193,18 @@ fn summarizer_model_id( catalog: &Catalog, selected_model: &str, ) -> ModelHandle { - let model = - catalog::small_default_for_ready(catalog, &std::iter::once(provider_id.clone()).collect()) - .filter(|entry| entry.provider.id() == provider_id) - .or_else(|| catalog::default_model(catalog, provider_id.as_str())) - .map_or_else( - || selected_model.to_string(), - |entry| entry.model.id().to_string(), - ); + let model = catalog + .small_default_for([provider_id]) + .filter(|entry| entry.provider.id() == provider_id) + .or_else(|| { + catalog + .enabled_provider(provider_id.as_str())? + .default_offering() + }) + .map_or_else( + || selected_model.to_string(), + |entry| entry.model.id().to_string(), + ); ModelHandle::new(provider_id.clone(), ModelId::new(model)) } @@ -227,12 +231,12 @@ fn resolve_provider_id( ) -> ProviderId { if args.provider.is_some() { let requested = parse_provider(args); - return catalog::canonical_provider_id(catalog, requested.as_str()).unwrap_or(requested); + return canonical_provider_id(catalog, &requested); } if let Some(model_id) = args.model.as_deref() { // A bare model selector picks the highest-priority eligible provider // offering it, matching how the client resolves the request. - let matches = catalog::models_matching(catalog, model_id); + let matches = catalog.offerings_matching(model_id); if let Some(entry) = matches .iter() .find(|entry| eligible_providers.contains(entry.provider.id())) @@ -242,7 +246,15 @@ fn resolve_provider_id( } } let requested = parse_provider(args); - catalog::canonical_provider_id(catalog, requested.as_str()).unwrap_or(requested) + canonical_provider_id(catalog, &requested) +} + +/// The catalog id for `requested`, resolving aliases; the request itself when +/// the catalog does not know it, so the error names what the caller typed. +fn canonical_provider_id(catalog: &Catalog, requested: &ProviderId) -> ProviderId { + catalog + .enabled_provider(requested.as_str()) + .map_or_else(|| requested.clone(), |provider| provider.id().clone()) } async fn standalone_llm_source() -> anyhow::Result> { @@ -528,7 +540,9 @@ async fn run_with_args_and_client_and_catalog_styled( let model = if let Some(model) = args.model.clone() { model } else { - catalog::default_model(&catalog, provider_id.as_str()) + catalog + .enabled_provider(provider_id.as_str()) + .and_then(CatalogProvider::default_offering) .map(|entry| entry.model.id().to_string()) .ok_or_else(|| { anyhow::anyhow!( @@ -943,6 +957,10 @@ mod tests { assert!(approval_fn("shell", &json!({})).is_ok()); } + fn enabled_ids(catalog: &Catalog) -> std::collections::HashSet { + catalog.enabled_provider_ids().into_iter().collect() + } + fn test_catalog() -> Arc { Arc::new(fabro_test_catalog()) } @@ -1035,7 +1053,7 @@ profile = "openai" let args = args_with(None, Some("acme-aws-claude")); assert_eq!( - resolve_provider_id(&catalog, &args, &catalog::enabled_provider_ids(&catalog)), + resolve_provider_id(&catalog, &args, &enabled_ids(&catalog)), ProviderId::new("acme-aws") ); } @@ -1046,7 +1064,7 @@ profile = "openai" let args = args_with(Some("br"), None); assert_eq!( - resolve_provider_id(&catalog, &args, &catalog::enabled_provider_ids(&catalog)), + resolve_provider_id(&catalog, &args, &enabled_ids(&catalog)), ProviderId::new("acme-aws") ); } diff --git a/lib/components/fabro-agent/src/compaction.rs b/lib/components/fabro-agent/src/compaction.rs index 168c908fe..5c36f2e2d 100644 --- a/lib/components/fabro-agent/src/compaction.rs +++ b/lib/components/fabro-agent/src/compaction.rs @@ -365,8 +365,8 @@ mod tests { use std::sync::Arc; use std::time::SystemTime; - use fabro_llm::catalog::model_on_provider; - use fabro_llm::lithos_catalog::Catalog; + use fabro_llm::catalog; + use fabro_llm::lithos_catalog::{Catalog, Offering}; use fabro_llm::test_support::test_catalog; use fabro_types::{TokenCounts, ToolCall, tool_result_from_json}; @@ -381,6 +381,14 @@ mod tests { test_catalog() } + fn model_on_provider<'a>( + catalog: &'a Catalog, + provider: &str, + id: &str, + ) -> Option> { + catalog.enabled_provider(provider)?.offering(id) + } + fn builtin_summary_max_tokens(catalog: &Catalog, provider: &str, id: &str) -> u32 { let entry = model_on_provider(catalog, provider, id) .unwrap_or_else(|| panic!("{provider}/{id} missing from the catalog")); @@ -388,7 +396,7 @@ mod tests { .model .limits() .map(|limits| u32::try_from(limits.max_output_tokens).unwrap_or(u32::MAX)); - summary_max_tokens(entry.reasons_by_default(), max_output) + summary_max_tokens(catalog::reasons_by_default(&entry), max_output) } #[test] diff --git a/lib/components/fabro-agent/tests/it/guardrails.rs b/lib/components/fabro-agent/tests/it/guardrails.rs index e84599131..1b43401ee 100644 --- a/lib/components/fabro-agent/tests/it/guardrails.rs +++ b/lib/components/fabro-agent/tests/it/guardrails.rs @@ -7,9 +7,9 @@ use fabro_llm::test_support::test_catalog; #[test] fn profile_context_window_matches_catalog_for_default_models() { let catalog = Arc::new(test_catalog()); - for provider in catalog::listed_providers(&catalog) { + for provider in catalog.listed_providers() { let provider_id = provider.id().clone(); - let Some(default) = catalog::default_model(&catalog, provider_id.as_str()) else { + let Some(default) = provider.default_offering() else { // Deployment-defined providers (LiteLLM, Modal, Ollama) carry no // built-in default model. continue; @@ -21,7 +21,7 @@ fn profile_context_window_matches_catalog_for_default_models() { ); let profile: Box = AgentProfileBuilder::new( - default.agent_profile(), + catalog::offering_agent_profile(&default), provider_id.clone(), model.as_str(), Arc::clone(&catalog), diff --git a/lib/components/fabro-llm/src/api.rs b/lib/components/fabro-llm/src/api.rs index e08f6ec7b..965b3046f 100644 --- a/lib/components/fabro-llm/src/api.rs +++ b/lib/components/fabro-llm/src/api.rs @@ -9,31 +9,32 @@ use fabro_types::{ Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, Provider, ProviderId, ReasoningEffort, }; -use lithos_llm::catalog::{Catalog, CatalogProvider}; - -use crate::catalog::{self, ModelEntry}; +use lithos_llm::catalog::{Catalog, CatalogProvider, Offering}; const USD_MICROS_PER_USD: f64 = 1_000_000.0; /// Every enabled model on every listed provider, provider priority order. #[must_use] pub fn models(catalog: &Catalog, configured: &HashSet) -> Vec { - catalog::models(catalog) - .iter() - .map(|entry| model_view(entry, configured.contains(entry.provider.id()))) + catalog + .listed_providers() + .into_iter() + .flat_map(CatalogProvider::offerings) + .map(|offering| model_view(&offering, configured.contains(offering.provider.id()))) .collect() } /// Every listed provider, priority order. #[must_use] pub fn providers(catalog: &Catalog, configured: &HashSet) -> Vec { - catalog::listed_providers(catalog) - .iter() + catalog + .listed_providers() + .into_iter() .map(|provider| provider_view(provider, configured.contains(provider.id()))) .collect() } -fn model_view(entry: &ModelEntry<'_>, configured: bool) -> Model { +fn model_view(entry: &Offering<'_>, configured: bool) -> Model { let model = entry.model; let capabilities = model.capabilities(); let pricing = model.pricing(); @@ -95,7 +96,7 @@ fn provider_view(provider: &CatalogProvider, configured: bool) -> Provider { api_key_url: provider.api_key_url().map(str::to_string), priority: provider.priority(), aliases: provider.aliases().to_vec(), - model_count: u32::try_from(catalog::provider_models(provider).len()).unwrap_or(u32::MAX), + model_count: u32::try_from(provider.offerings().len()).unwrap_or(u32::MAX), default_model: provider.default_model().map(str::to_string), configured, expected_secret_name: fabro_auth::expected_secret_name(provider), diff --git a/lib/components/fabro-llm/src/catalog.rs b/lib/components/fabro-llm/src/catalog.rs index a12b27c8a..5314ba346 100644 --- a/lib/components/fabro-llm/src/catalog.rs +++ b/lib/components/fabro-llm/src/catalog.rs @@ -1,18 +1,18 @@ -//! Catalog construction and the queries Fabro's dispatch boundaries share. +//! Catalog construction and the agent-profile reading that is Fabro's own. //! //! Layer order is fixed: lithos built-ins, then the operator's `[llm]` -//! overlay. Provider and model facts, `enabled`, `stands_in_for`, -//! `small_default`, and `probe` are lithos core fields. The agent harness a -//! model expects lives in the shared `metadata.agent` namespace, which Pebble -//! reads too. Every query here skips disabled providers. - -use std::collections::{BTreeMap, HashSet}; +//! overlay. Which providers are on, which model a selector names, and which +//! model to pick for a job are lithos questions, answered by +//! [`Catalog`] and [`CatalogProvider`] (`enabled_providers`, +//! `offerings_matching`, `default_offering_for`, and the rest). What stays +//! here is the coding harness a model expects, read from the shared +//! `metadata.agent` namespace that Pebble reads too. use fabro_config::LlmLayer; use fabro_static::EnvVars; -use fabro_types::{AgentProfileKind, Cost, ModelId, ModelRef, ProviderId, TokenCounts}; +use fabro_types::AgentProfileKind; +pub use lithos_llm::catalog::Offering; use lithos_llm::catalog::{Catalog, CatalogError, CatalogModel, CatalogProvider, Metadata}; -use lithos_llm::resolver::ResolvedRoute; use serde::Deserialize; /// The metadata namespace agent harnesses read. @@ -53,40 +53,6 @@ pub fn default_catalog() -> Catalog { build_catalog(&LlmLayer::default(), &|_| None).expect("the built-in catalog always builds") } -/// A model on the provider that offers it. -#[derive(Debug, Clone)] -pub struct ModelEntry<'a> { - pub provider: &'a CatalogProvider, - pub model: &'a CatalogModel, -} - -impl ModelEntry<'_> { - /// Whether requests to this model reason when no effort is requested. - /// - /// The catalog can state it outright under `metadata.agent`. Otherwise a - /// model that supports reasoning and takes named effort levels reasons by - /// default, while one that needs an explicit thinking budget does not. - #[must_use] - pub fn reasons_by_default(&self) -> bool { - agent_metadata(self.model.metadata()) - .reasoning_by_default - .or(agent_metadata(self.provider.metadata()).reasoning_by_default) - .unwrap_or_else(|| { - self.model.capabilities().reasoning().is_supported() - && self.model.protocol_options().reasoning_effort_levels - }) - } - - /// The agent harness this model runs under: the model's own answer, then - /// the provider's, then the profile implied by the provider's adapter. - #[must_use] - pub fn agent_profile(&self) -> AgentProfileKind { - agent_metadata(self.model.metadata()) - .profile - .unwrap_or_else(|| provider_agent_profile(self.provider)) - } -} - /// The `metadata.agent` namespace on a catalog entry. Malformed metadata /// falls back to the defaults; the lithos built-ins are validated in lithos. #[derive(Debug, Default, Deserialize)] @@ -104,6 +70,35 @@ fn agent_metadata(metadata: &Metadata) -> AgentMetadata { .unwrap_or_default() } +/// Whether requests to `offering` reason when no effort is requested. +/// +/// The catalog can state it outright under `metadata.agent`. Otherwise a +/// model that supports reasoning and takes named effort levels reasons by +/// default, while one that needs an explicit thinking budget does not. +#[must_use] +pub fn reasons_by_default(offering: &Offering<'_>) -> bool { + agent_metadata(offering.model.metadata()) + .reasoning_by_default + .or(agent_metadata(offering.provider.metadata()).reasoning_by_default) + .unwrap_or_else(|| { + offering.model.capabilities().reasoning().is_supported() + && offering.model.protocol_options().reasoning_effort_levels + }) +} + +/// The agent harness `offering` runs under: the model's own answer, then +/// the provider's, then the profile implied by the provider's adapter. +#[must_use] +pub fn offering_agent_profile(offering: &Offering<'_>) -> AgentProfileKind { + model_agent_profile(offering.provider, offering.model) +} + +fn model_agent_profile(provider: &CatalogProvider, model: &CatalogModel) -> AgentProfileKind { + agent_metadata(model.metadata()) + .profile + .unwrap_or_else(|| provider_agent_profile(provider)) +} + /// The agent profile a provider's models run under unless a model row says /// otherwise: the provider's `metadata.agent.profile`, else the profile /// implied by its wire protocol. @@ -117,260 +112,22 @@ fn provider_agent_profile(provider: &CatalogProvider) -> AgentProfileKind { }) } -/// Estimates the catalog cost of `usage` on `model`, when the catalog prices -/// that route. Passthrough models and unknown providers have no price. -#[must_use] -pub fn estimate_cost(catalog: &Catalog, model: &ModelRef, usage: TokenCounts) -> Option { - let entry = model_on_provider(catalog, model.provider.as_str(), model.model_id.as_str())?; - ResolvedRoute::try_new(entry.provider.clone(), entry.model.clone()) - .ok()? - .estimate_cost(usage, model.speed) -} - -/// Enabled providers, highest priority first, ties broken by id. -#[must_use] -pub fn enabled_providers(catalog: &Catalog) -> Vec<&CatalogProvider> { - let mut providers: Vec<_> = catalog - .providers() - .filter(|provider| provider.is_enabled()) - .collect(); - providers.sort_by(|left, right| { - right - .priority() - .cmp(&left.priority()) - .then_with(|| left.id().cmp(right.id())) - }); - providers -} - -/// Enabled providers that Fabro lists to operators. Stand-in providers such -/// as `openai-codex` route requests but are not offerings of their own. -#[must_use] -pub fn listed_providers(catalog: &Catalog) -> Vec<&CatalogProvider> { - enabled_providers(catalog) - .into_iter() - .filter(|provider| provider.stands_in_for().is_none()) - .collect() -} - -/// The ids of every enabled provider. -#[must_use] -pub fn enabled_provider_ids(catalog: &Catalog) -> HashSet { - enabled_providers(catalog) - .into_iter() - .map(|provider| provider.id().clone()) - .collect() -} - -/// Looks up an enabled provider by id or alias. -#[must_use] -pub fn provider<'a>(catalog: &'a Catalog, selector: &str) -> Option<&'a CatalogProvider> { - catalog - .provider(selector) - .ok() - .filter(|provider| provider.is_enabled()) -} - -/// Canonicalizes a provider id or alias to its catalog id, when enabled. -#[must_use] -pub fn canonical_provider_id(catalog: &Catalog, selector: &str) -> Option { - provider(catalog, selector).map(|provider| provider.id().clone()) -} - -/// The models of a provider, in catalog order. -#[must_use] -pub fn provider_models(provider: &CatalogProvider) -> Vec> { - provider - .models() - .map(|model| ModelEntry { provider, model }) - .collect() -} - -/// Every model across listed providers, provider priority order. -#[must_use] -pub fn models(catalog: &Catalog) -> Vec> { - listed_providers(catalog) - .into_iter() - .flat_map(provider_models) - .collect() -} - -/// Finds a model on an enabled provider by id, alias, or wire id. -#[must_use] -pub fn model_on_provider<'a>( - catalog: &'a Catalog, - provider_selector: &str, - model_selector: &str, -) -> Option> { - let provider = provider(catalog, provider_selector)?; - // lithos matches ids and aliases. The provider's wire id (an aggregator's - // `vendor/model`) is accepted too, so a selector copied from the - // provider's own listing lands on the catalog row instead of passing - // through unknown. - let model = provider.model(model_selector).or_else(|| { - provider - .models() - .find(|model| model.api_model() == model_selector) - })?; - Some(ModelEntry { provider, model }) -} - -/// Models matching `selector` by id or alias, ordered like lithos selection: -/// exact ids before aliases, then provider priority. -#[must_use] -pub fn models_matching<'a>(catalog: &'a Catalog, selector: &str) -> Vec> { - let mut matches: Vec<_> = enabled_providers(catalog) - .into_iter() - .flat_map(provider_models) - .filter(|entry| { - entry.model.id().as_str() == selector - || entry.model.aliases().iter().any(|alias| alias == selector) - }) - .collect(); - matches.sort_by_key(|entry| entry.model.id().as_str() != selector); - matches -} - -/// Whether `selector` names a model on any enabled provider. -#[must_use] -pub fn is_model_selector(catalog: &Catalog, selector: &str) -> bool { - !models_matching(catalog, selector).is_empty() -} - -/// Whether `selector` names an enabled provider. -#[must_use] -pub fn is_provider_selector(catalog: &Catalog, selector: &str) -> bool { - provider(catalog, selector).is_some() -} - -/// The default model of an enabled provider. -#[must_use] -pub fn default_model<'a>(catalog: &'a Catalog, provider_selector: &str) -> Option> { - let provider = provider(catalog, provider_selector)?; - let default = provider.default_model()?; - model_on_provider(catalog, provider.id().as_str(), default) -} - -/// The model Fabro probes a provider with: the `probe` model, else the -/// provider default. -#[must_use] -pub fn probe_model<'a>(catalog: &'a Catalog, provider_selector: &str) -> Option> { - let provider = provider(catalog, provider_selector)?; - provider_models(provider) - .into_iter() - .find(|entry| entry.model.is_probe()) - .or_else(|| default_model(catalog, provider_selector)) -} - -/// The default model across `ready` providers: the highest-priority ready -/// provider's default. Falls back to any enabled provider's default when no -/// provider is ready, so callers always have a model to name. -#[must_use] -pub fn default_for_ready<'a>( - catalog: &'a Catalog, - ready: &HashSet, -) -> Option> { - let providers = enabled_providers(catalog); - providers - .iter() - .filter(|provider| ready.contains(provider.id())) - .chain(providers.iter()) - .find_map(|provider| default_model(catalog, provider.id().as_str())) -} - -/// The small utility model across `ready` providers: the first -/// `small_default` model in provider priority order, else the ready default. -#[must_use] -pub fn small_default_for_ready<'a>( - catalog: &'a Catalog, - ready: &HashSet, -) -> Option> { - enabled_providers(catalog) - .into_iter() - .filter(|provider| ready.contains(provider.id())) - .flat_map(provider_models) - .find(|entry| entry.model.is_small_default()) - .or_else(|| default_for_ready(catalog, ready)) -} - -/// Canonicalizes a model selector to a catalog model id, preferring -/// `provider`'s offering. Unknown selectors pass through verbatim so -/// passthrough models keep their names. -#[must_use] -pub fn canonical_model_id(catalog: &Catalog, provider: &ProviderId, selector: &str) -> String { - model_on_provider(catalog, provider.as_str(), selector) - .map(|entry| entry.model.id().to_string()) - .or_else(|| { - models_matching(catalog, selector) - .first() - .map(|entry| entry.model.id().to_string()) - }) - .unwrap_or_else(|| selector.to_string()) -} - -/// The agent profile for a route. Unknown (passthrough) models take the -/// provider default. +/// The agent profile for a route on an enabled provider. Unknown +/// (passthrough) models take the provider default; a disabled or unknown +/// provider has none. #[must_use] pub fn agent_profile( catalog: &Catalog, provider_selector: &str, model_selector: Option<&str>, ) -> Option { - let provider = provider(catalog, provider_selector)?; - let model = model_selector.and_then(|selector| provider.model(selector)); - Some(match model { - Some(model) => ModelEntry { provider, model }.agent_profile(), - None => provider_agent_profile(provider), - }) -} - -/// The `target` provider's model closest to `reference` in capability and -/// input price, for provider-level fallbacks. -#[must_use] -pub fn closest_model<'a>( - catalog: &'a Catalog, - target: &str, - reference: &CatalogModel, -) -> Option> { - let target = provider(catalog, target)?; - let reference_caps = reference.capabilities(); - let reference_price = reference - .pricing() - .and_then(|pricing| pricing.input_usd_micros_per_million) - .unwrap_or(0); - provider_models(target) - .into_iter() - .filter(|entry| { - let caps = entry.model.capabilities(); - caps.tools().is_supported() == reference_caps.tools().is_supported() - && caps.images().is_supported() == reference_caps.images().is_supported() - && caps.reasoning().is_supported() == reference_caps.reasoning().is_supported() - }) - .min_by_key(|entry| { - let price = entry - .model - .pricing() - .and_then(|pricing| pricing.input_usd_micros_per_million) - .unwrap_or(0); - price.abs_diff(reference_price) - }) -} - -/// Model ids grouped by provider, for diagnostics and documentation. -#[must_use] -pub fn model_ids_by_provider(catalog: &Catalog) -> BTreeMap> { - listed_providers(catalog) - .into_iter() - .map(|provider| { - ( - provider.id().clone(), - provider_models(provider) - .into_iter() - .map(|entry| entry.model.id().clone()) - .collect(), - ) - }) - .collect() + let provider = catalog.enabled_provider(provider_selector)?; + Some( + match model_selector.and_then(|selector| provider.model(selector)) { + Some(model) => model_agent_profile(provider, model), + None => provider_agent_profile(provider), + }, + ) } #[cfg(test)] @@ -378,27 +135,6 @@ mod tests { use super::*; use crate::test_support::test_catalog; - #[test] - fn builtins_ship_fabro_defaults() { - let catalog = test_catalog(); - let ids: Vec<_> = enabled_providers(&catalog) - .iter() - .map(|provider| provider.id().to_string()) - .collect(); - assert_eq!(ids[0], "anthropic"); - assert!(ids.contains(&"openai".to_string())); - assert!( - !ids.contains(&"bedrock".to_string()), - "bedrock ships disabled" - ); - assert!( - !listed_providers(&catalog) - .iter() - .any(|provider| provider.id().as_str() == "openai-codex"), - "stand-in providers are not listed" - ); - } - #[test] fn operator_overlay_applies_last() { let overlay = LlmLayer( @@ -412,7 +148,7 @@ enabled = false .unwrap(), ); let catalog = build_catalog(&overlay, &|_| None).unwrap(); - assert!(provider(&catalog, "openai").is_none()); + assert!(catalog.enabled_provider("openai").is_none()); assert_eq!( catalog.provider("openai").unwrap().priority(), 500, @@ -433,61 +169,8 @@ enabled = false } #[test] - fn probe_and_small_default_follow_the_catalog() { + fn agent_profiles_follow_the_model_then_the_provider() { let catalog = test_catalog(); - assert_eq!( - probe_model(&catalog, "openai").unwrap().model.id().as_str(), - "gpt-5.4-mini" - ); - assert_eq!( - probe_model(&catalog, "anthropic") - .unwrap() - .model - .id() - .as_str(), - "claude-haiku-4.5" - ); - let ready = HashSet::from([ProviderId::new("openai")]); - assert_eq!( - small_default_for_ready(&catalog, &ready) - .unwrap() - .model - .id() - .as_str(), - "gpt-5.4-mini" - ); - assert_eq!( - default_for_ready(&catalog, &ready) - .unwrap() - .model - .id() - .as_str(), - "gpt-5.6-sol" - ); - assert_eq!( - default_for_ready(&catalog, &HashSet::new()) - .unwrap() - .provider - .id() - .as_str(), - "anthropic" - ); - } - - #[test] - fn selectors_resolve_aliases_and_canonical_ids() { - let catalog = test_catalog(); - assert!(is_model_selector(&catalog, "sonnet")); - assert!(is_model_selector(&catalog, "gpt-5.4-mini")); - assert!(!is_model_selector(&catalog, "nope")); - assert_eq!( - canonical_model_id(&catalog, &ProviderId::new("openai"), "codex"), - "gpt-5.4" - ); - assert_eq!( - canonical_model_id(&catalog, &ProviderId::new("openai"), "unknown-model"), - "unknown-model" - ); assert_eq!( agent_profile(&catalog, "openai", Some("gpt-5.6-sol")), Some(AgentProfileKind::Gpt56) @@ -523,12 +206,15 @@ enabled = false #[test] fn reasoning_by_default_reads_agent_metadata_then_capabilities() { let catalog = test_catalog(); - let kimi = model_on_provider(&catalog, "moonshot", "kimi-k2.5").unwrap(); - assert!(kimi.reasons_by_default(), "the catalog row says so"); - let sonnet = model_on_provider(&catalog, "anthropic", "claude-sonnet-4.5").unwrap(); + let moonshot = catalog.enabled_provider("moonshot").unwrap(); + let kimi = moonshot.offering("kimi-k2.5").unwrap(); + assert!(reasons_by_default(&kimi), "the catalog row says so"); + let anthropic = catalog.enabled_provider("anthropic").unwrap(); + let sonnet = anthropic.offering("claude-sonnet-4.5").unwrap(); assert!( - !sonnet.reasons_by_default(), + !reasons_by_default(&sonnet), "a thinking-budget model reasons only when asked" ); + assert_eq!(offering_agent_profile(&kimi), AgentProfileKind::Kimi); } } diff --git a/lib/components/fabro-llm/src/lib.rs b/lib/components/fabro-llm/src/lib.rs index 00e9b8c7d..4e325b1c1 100644 --- a/lib/components/fabro-llm/src/lib.rs +++ b/lib/components/fabro-llm/src/lib.rs @@ -4,8 +4,7 @@ //! the client. This crate adds what is specific to Fabro: //! //! - building the catalog from the lithos built-ins and the operator `[llm]` -//! overlay, and the catalog queries Fabro's dispatch boundaries share -//! ([`catalog`]); +//! overlay, and reading the agent harness a model expects ([`catalog`]); //! - Fabro's passthrough policy for selections made before a request exists //! ([`selection`]); at request time the lithos resolver enforces `enabled` //! and `stands_in_for` itself; diff --git a/lib/components/fabro-llm/src/probe.rs b/lib/components/fabro-llm/src/probe.rs index 58c5a1e51..510733c22 100644 --- a/lib/components/fabro-llm/src/probe.rs +++ b/lib/components/fabro-llm/src/probe.rs @@ -9,7 +9,6 @@ use lithos_llm::catalog::Catalog; use lithos_llm::client::{Client, ProbeOptions, ProbeOutcome}; use strum::IntoStaticStr; -use crate::catalog; use crate::client::{ClientOptions, LlmSetupError, build_client}; #[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)] @@ -104,13 +103,15 @@ pub async fn probe_provider_with_api_key( api_key: String, timeout: Duration, ) -> Result { - let catalog_provider = catalog::provider(&catalog, provider.as_str()) + let catalog_provider = catalog + .enabled_provider(provider.as_str()) .ok_or_else(|| ApiKeyProbeError::UnknownProvider(provider.to_string()))?; let provider_id = catalog_provider.id().clone(); if !fabro_auth::accepts_api_key(catalog_provider) { return Err(ApiKeyProbeError::NoApiKeyPath(provider_id)); } - let model = catalog::probe_model(&catalog, provider_id.as_str()) + let model = catalog_provider + .probe_offering() .ok_or_else(|| ApiKeyProbeError::NoProbeModel(provider_id.clone()))?; let selector = format!("{provider_id}/{}", model.model.id()); let source = Arc::new(ApiKeyCredentialSource::new(provider_id.clone(), api_key)); diff --git a/lib/components/fabro-llm/src/selection.rs b/lib/components/fabro-llm/src/selection.rs index 80c6d6fbd..65f26e9a0 100644 --- a/lib/components/fabro-llm/src/selection.rs +++ b/lib/components/fabro-llm/src/selection.rs @@ -20,11 +20,9 @@ use std::collections::HashSet; use std::fmt; use fabro_types::{ModelId, ProviderId}; -use lithos_llm::catalog::Catalog; +use lithos_llm::catalog::{Catalog, Offering}; use thiserror::Error; -use crate::catalog::{self, ModelEntry}; - /// A provider/model pair one of the selection functions chose. /// /// `model` is the canonical catalog id when the selector matched an offering, @@ -94,11 +92,12 @@ pub fn require_provider( catalog: &Catalog, selector: &str, ) -> Result { - catalog::canonical_provider_id(catalog, selector).ok_or_else(|| { - ModelSelectionError::UnknownProvider { + catalog + .enabled_provider(selector) + .map(|provider| provider.id().clone()) + .ok_or_else(|| ModelSelectionError::UnknownProvider { provider: selector.to_string(), - } - }) + }) } /// Canonicalizes a provider and requires it to be in the eligible set. @@ -120,14 +119,15 @@ pub fn resolve_on_provider<'a>( catalog: &'a Catalog, provider: &ProviderId, selector: &str, -) -> Result, ModelSelectionError> { +) -> Result, ModelSelectionError> { let provider = require_provider(catalog, provider.as_str())?; - catalog::model_on_provider(catalog, provider.as_str(), selector).ok_or( - ModelSelectionError::UnknownSelectorOnProvider { + catalog + .enabled_provider(provider.as_str()) + .and_then(|provider| provider.offering(selector)) + .ok_or(ModelSelectionError::UnknownSelectorOnProvider { selector: selector.to_string(), provider, - }, - ) + }) } /// Selects a catalog model for `selector`, requiring a real offering. @@ -140,7 +140,7 @@ pub fn select<'a>( selector: &str, explicit_provider: Option<&ProviderId>, eligible: &HashSet, -) -> Result, ModelSelectionError> { +) -> Result, ModelSelectionError> { if let Some(explicit) = explicit_provider { let provider = ready_provider(catalog, explicit, eligible)?; return resolve_on_provider(catalog, &provider, selector); @@ -149,12 +149,12 @@ pub fn select<'a>( // it at request time. A slash whose prefix is not a provider (an // aggregator's `vendor/model` api id) falls through to plain matching. if let Some((prefix, rest)) = selector.split_once('/') { - if let Some(provider) = catalog::canonical_provider_id(catalog, prefix) { - let provider = ready_provider(catalog, &provider, eligible)?; + if let Some(provider) = catalog.enabled_provider(prefix) { + let provider = ready_provider(catalog, provider.id(), eligible)?; return resolve_on_provider(catalog, &provider, rest); } } - let matches = catalog::models_matching(catalog, selector); + let matches = catalog.offerings_matching(selector); if matches.is_empty() { return Err(ModelSelectionError::UnknownSelector { selector: selector.to_string(), @@ -178,19 +178,21 @@ pub fn select<'a>( pub fn select_default<'a>( catalog: &'a Catalog, eligible: &HashSet, -) -> Result, ModelSelectionError> { +) -> Result, ModelSelectionError> { let eligible = canonical_eligible(catalog, eligible); - let providers_with_defaults: Vec<_> = catalog::enabled_providers(catalog) + let providers_with_defaults: Vec<_> = catalog + .enabled_providers() .into_iter() .filter_map(|provider| { - catalog::default_model(catalog, provider.id().as_str()) - .map(|model| (provider.id().clone(), model)) + provider + .default_offering() + .map(|offering| (provider.id().clone(), offering)) }) .collect(); providers_with_defaults .iter() .find(|(provider, _)| eligible.contains(provider)) - .map(|(_, model)| model.clone()) + .map(|(_, offering)| *offering) .ok_or_else(|| ModelSelectionError::NoDefaultModel { providers: providers_with_defaults .into_iter() @@ -258,7 +260,7 @@ pub fn resolve_selection_with_catalog_fallback( catalog, selector, explicit_provider, - &catalog::enabled_provider_ids(catalog), + &catalog.enabled_provider_ids().into_iter().collect(), ), result => result, } @@ -267,7 +269,8 @@ pub fn resolve_selection_with_catalog_fallback( fn canonical_eligible(catalog: &Catalog, eligible: &HashSet) -> HashSet { eligible .iter() - .filter_map(|id| catalog::canonical_provider_id(catalog, id.as_str())) + .filter_map(|id| catalog.enabled_provider(id.as_str())) + .map(|provider| provider.id().clone()) .collect() } diff --git a/lib/components/fabro-validate/src/rules/model_support.rs b/lib/components/fabro-validate/src/rules/model_support.rs index 21735ef5a..50c4e20d6 100644 --- a/lib/components/fabro-validate/src/rules/model_support.rs +++ b/lib/components/fabro-validate/src/rules/model_support.rs @@ -1,4 +1,3 @@ -use fabro_llm::catalog; use fabro_llm::lithos_catalog::Catalog; use crate::{Diagnostic, Severity}; @@ -10,7 +9,7 @@ pub(super) fn check_model_known( context: &str, node_id: Option, ) -> Option { - if catalog::is_model_selector(catalog, model) { + if catalog.is_model_selector(model) { return None; } Some(Diagnostic { @@ -34,10 +33,11 @@ pub(super) fn check_provider_known( context: &str, node_id: Option, ) -> Option { - if catalog::is_provider_selector(catalog, provider) { + if catalog.enabled_provider(provider).is_some() { return None; } - let valid: Vec = catalog::listed_providers(catalog) + let valid: Vec = catalog + .listed_providers() .iter() .map(|provider| provider.id().to_string()) .collect(); diff --git a/lib/components/fabro-workflow/src/handler/llm/api.rs b/lib/components/fabro-workflow/src/handler/llm/api.rs index 87cffd0fa..d254ca5f1 100644 --- a/lib/components/fabro-workflow/src/handler/llm/api.rs +++ b/lib/components/fabro-workflow/src/handler/llm/api.rs @@ -15,7 +15,7 @@ use fabro_llm::credentials::CredentialProvider; use fabro_llm::error::failover_eligible; use fabro_llm::lithos_catalog::Catalog; use fabro_llm::types::ResponseFormat; -use fabro_llm::{Client, ClientOptions, FallbackTarget, LlmError, Request, Response, catalog}; +use fabro_llm::{Client, ClientOptions, FallbackTarget, LlmError, Request, Response}; use fabro_mcp::config::McpServerSettings; use fabro_types::settings::run::RunModelControls; use fabro_types::{ @@ -40,7 +40,7 @@ use crate::context::WorkflowContext; use crate::context::keys::Fidelity; use crate::error::Error; use crate::event::{Emitter, Event, StageScope}; -use crate::model_fallback::{ModelFallbackNotice, ModelFallbackPolicy}; +use crate::model_fallback::{ModelFallbackNotice, ModelFallbackPolicy, canonical_model_id}; use crate::outcome::billed_model_usage_from_llm; use crate::services::FabroRunToolServices; use crate::steering_hub::{ActiveControlHandle, SteeringHub}; @@ -888,11 +888,11 @@ impl AgentApiBackend { let Some(requested_effort) = requested.reasoning_effort else { return FallbackControls::Usable(requested); }; - let Some(offering) = catalog::model_on_provider( - &self.catalog, - target.provider.as_str(), - target.model.as_str(), - ) else { + let Some(offering) = self + .catalog + .enabled_provider(target.provider.as_str()) + .and_then(|provider| provider.offering(target.model.as_str())) + else { // A catalog-unknown passthrough target has no advertised controls. // Preserve the request and let the provider validate it. return FallbackControls::Usable(requested); @@ -925,7 +925,7 @@ impl AgentApiBackend { provider: &ProviderId, requested_controls: EffectiveRequestControls, ) -> (FallbackPlan, Vec) { - let primary_model = catalog::canonical_model_id(&self.catalog, provider, model); + let primary_model = canonical_model_id(&self.catalog, provider, model); let original = LlmRoute { target: FallbackTarget::new(provider, &primary_model), controls: requested_controls, @@ -1371,13 +1371,11 @@ impl AgentApiBackend { fn route_max_tokens(&self, node: &Node, route: &LlmRoute) -> Option { node_max_output_tokens(node).or_else(|| { - catalog::model_on_provider( - &self.catalog, - route.target.provider.as_str(), - route.target.model.as_str(), - ) - .and_then(|entry| entry.model.limits()) - .map(|limits| u32::try_from(limits.max_output_tokens).unwrap_or(u32::MAX)) + self.catalog + .enabled_provider(route.target.provider.as_str()) + .and_then(|provider| provider.offering(route.target.model.as_str())) + .and_then(|entry| entry.model.limits()) + .map(|limits| u32::try_from(limits.max_output_tokens).unwrap_or(u32::MAX)) }) } diff --git a/lib/components/fabro-workflow/src/handler/llm/routing.rs b/lib/components/fabro-workflow/src/handler/llm/routing.rs index c3db8ae5d..8ce674008 100644 --- a/lib/components/fabro-workflow/src/handler/llm/routing.rs +++ b/lib/components/fabro-workflow/src/handler/llm/routing.rs @@ -51,10 +51,17 @@ pub(crate) fn resolve_provider_context( provider_attr: Option<&str>, ) -> Result { let provider_id = if let Some(provider) = provider_attr { - catalog::canonical_provider_id(catalog, provider).ok_or_else(|| { - Error::Precondition(format!("Provider \"{provider}\" is not configured")) - })? - } else if catalog::model_on_provider(catalog, default_provider_id.as_str(), model).is_some() { + catalog + .enabled_provider(provider) + .map(|found| found.id().clone()) + .ok_or_else(|| { + Error::Precondition(format!("Provider \"{provider}\" is not configured")) + })? + } else if catalog + .enabled_provider(default_provider_id.as_str()) + .and_then(|provider| provider.offering(model)) + .is_some() + { // The run's selected provider is a pin whenever it offers the model. default_provider_id.clone() } else { @@ -62,7 +69,7 @@ pub(crate) fn resolve_provider_context( catalog, model, None, - &catalog::enabled_provider_ids(catalog), + &catalog.enabled_provider_ids().into_iter().collect(), ) { Ok(entry) => entry.provider.id().clone(), Err(ModelSelectionError::UnknownSelector { .. }) => default_provider_id.clone(), @@ -70,8 +77,10 @@ pub(crate) fn resolve_provider_context( } }; - let provider_id = - catalog::canonical_provider_id(catalog, provider_id.as_str()).ok_or_else(|| { + let provider_id = catalog + .enabled_provider(provider_id.as_str()) + .map(|provider| provider.id().clone()) + .ok_or_else(|| { Error::Precondition(format!("Provider \"{provider_id}\" is not configured")) })?; let profile_kind = catalog::agent_profile(catalog, provider_id.as_str(), Some(model)) diff --git a/lib/components/fabro-workflow/src/model_fallback.rs b/lib/components/fabro-workflow/src/model_fallback.rs index 4a9d3c73f..b78e9711c 100644 --- a/lib/components/fabro-workflow/src/model_fallback.rs +++ b/lib/components/fabro-workflow/src/model_fallback.rs @@ -1,8 +1,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; -use fabro_llm::catalog::ModelEntry; -use fabro_llm::lithos_catalog::Catalog; -use fabro_llm::{FallbackTarget, ModelSelectionError, catalog, selection}; +use fabro_llm::lithos_catalog::{Catalog, Offering}; +use fabro_llm::{FallbackTarget, ModelSelectionError, selection}; use fabro_types::settings::{ModelRef, ResolvedModelRef}; use fabro_types::{ProviderId, ReasoningEffort, RunNoticeCode, RunNoticeLevel}; @@ -31,7 +30,7 @@ impl ModelFallbackPolicy { provider: &ProviderId, model: &str, ) -> Option<&'a [FallbackTarget]> { - self.chain_for_canonical(&catalog::canonical_model_id(catalog, provider, model)) + self.chain_for_canonical(&canonical_model_id(catalog, provider, model)) } /// Look up a chain by an already-canonicalized requested model ID. @@ -224,8 +223,9 @@ pub fn resolve_model_fallbacks( } let primary = FallbackTarget::new(&selected.provider, &requested_model); - let primary_model = - catalog::model_on_provider(catalog, selected.provider.as_str(), &requested_model); + let primary_model = catalog + .enabled_provider(selected.provider.as_str()) + .and_then(|provider| provider.offering(&requested_model)); let mut targets = Vec::new(); for model_ref in references { @@ -295,11 +295,23 @@ enum FallbackCandidate { Skipped(ModelFallbackNotice), } +/// The catalog id for `selector` on `provider`, else anywhere; the selector +/// itself for a passthrough model the catalog does not know. +pub(crate) fn canonical_model_id( + catalog: &Catalog, + provider: &ProviderId, + selector: &str, +) -> String { + catalog + .canonical_model_id(Some(provider), selector) + .map_or_else(|| selector.to_string(), ToString::to_string) +} + fn resolve_fallback_candidate( catalog: &Catalog, requested_model: &str, primary: &FallbackTarget, - primary_model: Option<&ModelEntry<'_>>, + primary_model: Option<&Offering<'_>>, eligible: &HashSet, model_ref: &ModelRef, ) -> Result { @@ -326,7 +338,10 @@ fn resolve_fallback_candidate( }, )); }; - match catalog::closest_model(catalog, provider.as_str(), primary_model.model) { + match catalog + .enabled_provider(provider.as_str()) + .and_then(|target| target.closest_offering(primary_model.model)) + { Some(entry) => { FallbackCandidate::Target(FallbackTarget::new(provider, entry.model.id())) } diff --git a/lib/components/fabro-workflow/src/operations/create.rs b/lib/components/fabro-workflow/src/operations/create.rs index c0cbe93bf..196100033 100644 --- a/lib/components/fabro-workflow/src/operations/create.rs +++ b/lib/components/fabro-workflow/src/operations/create.rs @@ -753,7 +753,8 @@ mod tests { } fn test_provider_ids() -> Vec { - fabro_llm::catalog::enabled_provider_ids(&fabro_llm::test_support::test_catalog()) + fabro_llm::test_support::test_catalog() + .enabled_provider_ids() .into_iter() .collect() } diff --git a/lib/components/fabro-workflow/src/operations/start.rs b/lib/components/fabro-workflow/src/operations/start.rs index 83e56fbcf..78f7691b6 100644 --- a/lib/components/fabro-workflow/src/operations/start.rs +++ b/lib/components/fabro-workflow/src/operations/start.rs @@ -1435,7 +1435,8 @@ mod tests { } fn test_provider_ids() -> Vec { - fabro_llm::catalog::enabled_provider_ids(&fabro_llm::test_support::test_catalog()) + fabro_llm::test_support::test_catalog() + .enabled_provider_ids() .into_iter() .collect() } diff --git a/lib/components/fabro-workflow/src/outcome.rs b/lib/components/fabro-workflow/src/outcome.rs index e41717ca5..2025bab23 100644 --- a/lib/components/fabro-workflow/src/outcome.rs +++ b/lib/components/fabro-workflow/src/outcome.rs @@ -1,7 +1,6 @@ pub use fabro_core::outcome::{ FailureCategory, FailureDetail, OutcomeMeta, StageOutcome, StageState, }; -use fabro_llm::catalog; use fabro_llm::lithos_catalog::Catalog; pub use fabro_types::BilledModelUsage; use fabro_types::{BilledTokenCounts, ModelRef, TokenCounts}; @@ -19,13 +18,13 @@ pub fn billed_model_usage_from_llm( model: &ModelRef, usage: TokenCounts, ) -> Result { - if catalog::provider(catalog, model.provider.as_str()).is_none() { + if catalog.enabled_provider(model.provider.as_str()).is_none() { return Err(Error::Precondition(format!( "Provider \"{}\" is not configured", model.provider ))); } - let cost = catalog::estimate_cost(catalog, model, usage); + let cost = catalog.estimate_cost(&model.handle(), usage, model.speed); Ok(BilledModelUsage::new(model.clone(), usage, cost)) } diff --git a/lib/components/fabro-workflow/src/pipeline/pull_request.rs b/lib/components/fabro-workflow/src/pipeline/pull_request.rs index d7990d5fe..1b60a3882 100644 --- a/lib/components/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/components/fabro-workflow/src/pipeline/pull_request.rs @@ -1485,7 +1485,7 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr assert_eq!( truncation_caps( "unknown-model", - &fabro_llm::catalog::enabled_provider_ids(&mock_catalog()), + &mock_catalog().enabled_provider_ids().into_iter().collect(), &mock_catalog(), ), TruncationCaps { diff --git a/lib/components/fabro-workflow/src/test_support.rs b/lib/components/fabro-workflow/src/test_support.rs index 57a39592f..47410b8db 100644 --- a/lib/components/fabro-workflow/src/test_support.rs +++ b/lib/components/fabro-workflow/src/test_support.rs @@ -8,7 +8,6 @@ use fabro_agent::Sandbox; use fabro_auth::test_support as auth_test_support; use fabro_graphviz::graph::Graph as GvGraph; use fabro_interview::AutoApproveInterviewer; -use fabro_llm::catalog; use fabro_llm::credentials::CredentialProvider; use fabro_llm::lithos_catalog::Catalog; use fabro_llm::test_support::test_catalog; @@ -40,7 +39,7 @@ pub(crate) fn test_configured_provider_ids( assume_ready: bool, ) -> Vec { if assume_ready { - catalog::enabled_provider_ids(catalog).into_iter().collect() + catalog.enabled_provider_ids().into_iter().collect() } else { configured_provider_ids } diff --git a/lib/components/fabro-workflow/src/transforms/model_resolution.rs b/lib/components/fabro-workflow/src/transforms/model_resolution.rs index 22f1cf33c..c60d38aa7 100644 --- a/lib/components/fabro-workflow/src/transforms/model_resolution.rs +++ b/lib/components/fabro-workflow/src/transforms/model_resolution.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use fabro_graphviz::graph::{AttrValue, Graph}; use fabro_llm::lithos_catalog::Catalog; -use fabro_llm::{catalog, selection}; +use fabro_llm::selection; use fabro_types::ProviderId; use super::Transform; @@ -21,7 +21,7 @@ pub struct ModelResolutionTransform { impl ModelResolutionTransform { #[must_use] pub fn new(catalog: Arc) -> Self { - let eligible_providers = catalog::enabled_provider_ids(&catalog); + let eligible_providers = catalog.enabled_provider_ids().into_iter().collect(); Self { catalog, default_provider: None,