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,