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 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-10 09:26:40 -06:00
parent 3510d5081d
commit 3a998196ff
No known key found for this signature in database
34 changed files with 299 additions and 542 deletions

View file

@ -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),

View file

@ -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<ProviderId> {
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<ProviderId> {
}
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()))
}

View file

@ -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<Catalog> {
}
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),

View file

@ -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,

View file

@ -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)

View file

@ -765,7 +765,8 @@ mod tests {
}
fn test_provider_ids() -> Vec<ProviderId> {
fabro_llm::catalog::enabled_provider_ids(&fabro_llm::test_support::test_catalog())
fabro_llm::test_support::test_catalog()
.enabled_provider_ids()
.into_iter()
.collect()
}

View file

@ -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(

View file

@ -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::<HashSet<_>>();
catalog::default_for_ready(&self.catalog(), &ready)
self.catalog()
.default_offering_for(&provider_ids)
.map(|entry| entry.model.id().to_string())
};
AskFabroReadiness { default_model }

View file

@ -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());

View file

@ -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::<std::collections::HashSet<_>>();
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")

View file

@ -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::<std::collections::HashSet<_>>();
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,

View file

@ -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<ProviderId>,
) -> 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<ProviderId> {
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()),
)

View file

@ -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::<HashSet<_>>();
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

View file

@ -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()
}

View file

@ -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(),

View file

@ -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<ModelEntry<'_>> {
let catalog = self.catalog()?;
catalog::model_on_provider(catalog, self.provider_id().as_str(), self.model())
fn catalog_model(&self) -> Option<Offering<'_>> {
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(

View file

@ -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<Arc<dyn CredentialProvider>> {
@ -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<ProviderId> {
catalog.enabled_provider_ids().into_iter().collect()
}
fn test_catalog() -> Arc<Catalog> {
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")
);
}

View file

@ -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<Offering<'a>> {
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]

View file

@ -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<dyn AgentProfile> = AgentProfileBuilder::new(
default.agent_profile(),
catalog::offering_agent_profile(&default),
provider_id.clone(),
model.as_str(),
Arc::clone(&catalog),

View file

@ -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<ProviderId>) -> Vec<Model> {
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<ProviderId>) -> Vec<Provider> {
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),

View file

@ -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<Cost> {
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<ProviderId> {
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<ProviderId> {
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<ModelEntry<'_>> {
provider
.models()
.map(|model| ModelEntry { provider, model })
.collect()
}
/// Every model across listed providers, provider priority order.
#[must_use]
pub fn models(catalog: &Catalog) -> Vec<ModelEntry<'_>> {
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<ModelEntry<'a>> {
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<ModelEntry<'a>> {
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<ModelEntry<'a>> {
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<ModelEntry<'a>> {
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<ProviderId>,
) -> Option<ModelEntry<'a>> {
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<ProviderId>,
) -> Option<ModelEntry<'a>> {
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<AgentProfileKind> {
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<ModelEntry<'a>> {
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<ProviderId, Vec<ModelId>> {
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);
}
}

View file

@ -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;

View file

@ -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<ModelTestOutcome, ApiKeyProbeError> {
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));

View file

@ -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<ProviderId, ModelSelectionError> {
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<ModelEntry<'a>, ModelSelectionError> {
) -> Result<Offering<'a>, 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<ProviderId>,
) -> Result<ModelEntry<'a>, ModelSelectionError> {
) -> Result<Offering<'a>, 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<ProviderId>,
) -> Result<ModelEntry<'a>, ModelSelectionError> {
) -> Result<Offering<'a>, 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<ProviderId>) -> HashSet<ProviderId> {
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()
}

View file

@ -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<String>,
) -> Option<Diagnostic> {
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<String>,
) -> Option<Diagnostic> {
if catalog::is_provider_selector(catalog, provider) {
if catalog.enabled_provider(provider).is_some() {
return None;
}
let valid: Vec<String> = catalog::listed_providers(catalog)
let valid: Vec<String> = catalog
.listed_providers()
.iter()
.map(|provider| provider.id().to_string())
.collect();

View file

@ -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<ModelFallbackNotice>) {
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<u32> {
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))
})
}

View file

@ -51,10 +51,17 @@ pub(crate) fn resolve_provider_context(
provider_attr: Option<&str>,
) -> Result<ProviderContext, Error> {
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))

View file

@ -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<ProviderId>,
model_ref: &ModelRef,
) -> Result<FallbackCandidate, Error> {
@ -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()))
}

View file

@ -753,7 +753,8 @@ mod tests {
}
fn test_provider_ids() -> Vec<ProviderId> {
fabro_llm::catalog::enabled_provider_ids(&fabro_llm::test_support::test_catalog())
fabro_llm::test_support::test_catalog()
.enabled_provider_ids()
.into_iter()
.collect()
}

View file

@ -1435,7 +1435,8 @@ mod tests {
}
fn test_provider_ids() -> Vec<ProviderId> {
fabro_llm::catalog::enabled_provider_ids(&fabro_llm::test_support::test_catalog())
fabro_llm::test_support::test_catalog()
.enabled_provider_ids()
.into_iter()
.collect()
}

View file

@ -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<BilledModelUsage, Error> {
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))
}

View file

@ -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 {

View file

@ -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<ProviderId> {
if assume_ready {
catalog::enabled_provider_ids(catalog).into_iter().collect()
catalog.enabled_provider_ids().into_iter().collect()
} else {
configured_provider_ids
}

View file

@ -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<Catalog>) -> Self {
let eligible_providers = catalog::enabled_provider_ids(&catalog);
let eligible_providers = catalog.enabled_provider_ids().into_iter().collect();
Self {
catalog,
default_provider: None,