Drop FabroResolver in favor of the lithos resolver

lithos-llm now owns `enabled` and `stands_in_for` as core provider
fields, and its `CatalogResolver` refuses disabled providers and reroutes
a request to the provider standing in for an unavailable one. Fabro's
resolver re-implemented both from `metadata.fabro`, so it goes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-09 22:41:47 -06:00
parent 7d5f33ab16
commit df5c9d2c57
No known key found for this signature in database
3 changed files with 3 additions and 214 deletions

View file

@ -15,7 +15,6 @@ use lithos_llm::types::Error;
use crate::attachments::InlineLocalAttachments;
use crate::error::LlmError;
use crate::resolver::FabroResolver;
/// Default same-provider retry policy applied before visible output.
///
@ -206,7 +205,6 @@ pub async fn build_client(
}
let builder = Client::builder()
.catalog(catalog)
.resolver(FabroResolver)
.credentials_arc(lithos_credentials(source))
.enabled_providers(ready.iter().cloned());
let build = options.apply(builder).build()?;
@ -228,7 +226,6 @@ pub fn build_offline_client(
let ready: Vec<ProviderId> = options.adapter_providers().cloned().collect();
let builder = Client::builder()
.catalog(catalog)
.resolver(FabroResolver)
.enabled_providers(ready.iter().cloned());
let build = options.apply(builder).build()?;
Ok(FabroClient {

View file

@ -5,9 +5,9 @@
//!
//! - building the catalog from lithos built-ins, Fabro's policy layer, and the
//! operator `[llm]` overlay ([`catalog`]);
//! - enforcing Fabro policy (`enabled`, `small_default`, `probe`) at model
//! selection ([`resolver`]), and the same passthrough policy for selections
//! made before a request exists ([`selection`]);
//! - 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;
//! - constructing a client from a Fabro credential source ([`client`]);
//! - inlining local file attachments ([`attachments`]);
//! - normalizing readable reasoning into [`fabro_types::ReasoningOutput`]
@ -28,7 +28,6 @@ pub mod error;
pub mod gateway;
pub mod probe;
pub mod reasoning;
pub mod resolver;
pub mod selection;
pub mod structured;
#[cfg(any(test, feature = "test-support"))]
@ -50,5 +49,4 @@ pub use lithos_llm::types::{
pub use lithos_llm::{
adapter, catalog as lithos_catalog, credentials, estimate, middleware, types,
};
pub use resolver::FabroResolver;
pub use selection::{FallbackTarget, ModelSelectionError, SelectedModel};

View file

@ -1,206 +0,0 @@
//! Fabro's model resolver.
//!
//! lithos's [`CatalogResolver`] resolves selectors against the whole catalog.
//! Fabro layers policy on top: providers and models marked disabled in
//! `metadata.fabro` are unreachable through every selector shape (explicit
//! `provider/model`, alias, provider default, and the global `default`), and a
//! stand-in provider answers for the provider it stands in for when that
//! provider has no credentials of its own.
use std::collections::BTreeSet;
use fabro_types::catalog_policy;
use lithos_llm::catalog::{Catalog, CatalogProvider, ProviderId};
use lithos_llm::resolver::{
AvailableProviders, CatalogResolver, ModelResolver, ModelSelectionError, ResolvedRoute,
};
use lithos_llm::types::Request;
/// Rejects disabled providers and models after catalog resolution.
#[derive(Clone, Copy, Debug, Default)]
pub struct FabroResolver;
impl FabroResolver {
fn enabled_available(catalog: &Catalog, available: &AvailableProviders) -> AvailableProviders {
AvailableProviders::new(
available
.iter()
.filter(|id| {
catalog.provider_by_id(id).is_some_and(|provider| {
catalog_policy::provider_policy(provider).is_enabled()
})
})
.cloned()
.collect::<BTreeSet<_>>(),
)
}
/// The provider that stands in for `provider`, when one is available.
fn stand_in<'a>(
catalog: &'a Catalog,
provider: &ProviderId,
available: &AvailableProviders,
) -> Option<&'a CatalogProvider> {
catalog.providers().find(|candidate| {
available.contains(candidate.id())
&& catalog_policy::provider_policy(candidate)
.stands_in_for
.as_deref()
== Some(provider.as_str())
})
}
fn check_route(route: ResolvedRoute) -> Result<ResolvedRoute, ModelSelectionError> {
if !catalog_policy::provider_policy(route.provider()).is_enabled() {
return Err(ModelSelectionError::ProviderUnavailable {
provider: route.provider().id().clone(),
});
}
if !catalog_policy::model_policy(route.model()).is_enabled() {
return Err(ModelSelectionError::ModelNotFound {
selector: route.handle().to_string(),
});
}
Ok(route)
}
}
impl ModelResolver for FabroResolver {
fn resolve(
&self,
request: &Request,
catalog: &Catalog,
available: &AvailableProviders,
) -> Result<ResolvedRoute, ModelSelectionError> {
let available = Self::enabled_available(catalog, available);
match CatalogResolver.resolve(request, catalog, &available) {
Ok(route) => Self::check_route(route),
Err(ModelSelectionError::ProviderUnavailable { provider }) => {
let Some(stand_in) = Self::stand_in(catalog, &provider, &available) else {
return Err(ModelSelectionError::ProviderUnavailable { provider });
};
let selector = request.model();
let model_selector = selector
.split_once('/')
.map(|(_, model)| model)
.filter(|_| selector != provider.as_str());
let rerouted_selector = match model_selector {
Some(model) => format!("{}/{model}", stand_in.id()),
None => stand_in.id().to_string(),
};
let rerouted = request
.clone()
.into_builder()
.model(rerouted_selector)
.build()
.map_err(|_| ModelSelectionError::ProviderUnavailable {
provider: provider.clone(),
})?;
CatalogResolver
.resolve(&rerouted, catalog, &available)
.and_then(Self::check_route)
}
Err(error) => Err(error),
}
}
}
#[cfg(test)]
mod tests {
use lithos_llm::types::{Message, Role};
use super::*;
use crate::test_support::test_catalog;
fn request(model: &str) -> Request {
Request::builder()
.model(model)
.message(Message::text(Role::User, "hi"))
.build()
.unwrap()
}
fn resolve(
catalog: &Catalog,
model: &str,
available: &[&str],
) -> Result<String, ModelSelectionError> {
let available = AvailableProviders::new(available.iter().map(|id| ProviderId::new(*id)));
FabroResolver
.resolve(&request(model), catalog, &available)
.map(|route| route.handle().to_string())
}
#[test]
fn disabled_model_on_enabled_provider_stays_unreachable() {
let catalog = Catalog::builder()
.with_builtin()
.toml_layer("policy", crate::FABRO_POLICY_TOML)
.unwrap()
.toml_layer(
"test",
r#"
schema_version = 1
[providers.openai.models."gpt-5.4".metadata.fabro]
enabled = false
"#,
)
.unwrap()
.build()
.unwrap();
for selector in ["openai/gpt-5.4", "gpt-5.4", "codex"] {
let error = resolve(&catalog, selector, &["openai"]).unwrap_err();
assert!(
matches!(error, ModelSelectionError::ModelNotFound { .. }),
"{selector}: {error:?}"
);
}
assert_eq!(
resolve(&catalog, "gpt-5.4-mini", &["openai"]).unwrap(),
"openai/gpt-5.4-mini"
);
}
#[test]
fn disabled_provider_is_unavailable_even_when_credentialed() {
let catalog = test_catalog();
let error = resolve(&catalog, "bedrock/claude-sonnet-5", &["bedrock"]).unwrap_err();
assert!(matches!(
error,
ModelSelectionError::ProviderUnavailable { .. }
));
let error = resolve(&catalog, "default", &["bedrock"]).unwrap_err();
assert!(matches!(error, ModelSelectionError::NoDefaultModel));
}
#[test]
fn codex_stands_in_for_openai_without_an_api_key() {
let catalog = test_catalog();
assert_eq!(
resolve(&catalog, "openai/gpt-5.4-mini", &["openai-codex"]).unwrap(),
"openai-codex/gpt-5.4-mini"
);
assert_eq!(
resolve(&catalog, "openai", &["openai-codex"]).unwrap(),
"openai-codex/gpt-5.6-sol"
);
assert_eq!(
resolve(&catalog, "openai/gpt-5.4-mini", &["openai", "openai-codex"]).unwrap(),
"openai/gpt-5.4-mini",
"the real provider wins when it is ready"
);
}
#[test]
fn enabled_routes_resolve_like_lithos() {
let catalog = test_catalog();
assert_eq!(
resolve(&catalog, "sonnet", &["anthropic", "openai"]).unwrap(),
"anthropic/claude-sonnet-5"
);
assert_eq!(
resolve(&catalog, "default", &["openai"]).unwrap(),
"openai/gpt-5.6-sol"
);
}
}