refactor: unify model selection and legacy-model normalization

Consolidate duplicated resolution logic introduced by the provider-aware
model alias work:

- Add Catalog::resolve_selection (with SelectedModel and ready_provider)
  as the single home for the select -> passthrough -> default-fallback
  policy, replacing six hand-rolled copies across fabro-server,
  fabro-workflow, and fabro-llm.
- Move legacy [models] row resolution into a shared LegacyModelIndex and
  LegacyModelError in fabro-model; delete fabro-config's parallel
  implementation and its LlmNormalizationError enum, plus the now-unused
  builtin_* catalog helpers.
- Drop redundant client.resolve_request calls (and their full-request
  clones) from the completions and playground handlers.
- Remove the redundant resolve_provider_context round-trip in
  resolve_start_llm and make resolve_run_model return a ProviderId
  instead of a never-None Option.
- Replace the "<default model>" sentinel selector with a dedicated
  ModelSelectionError::NoDefaultModel variant.
- Add a CatalogRoute trait so provider adapters call
  self.api_model_id(...) instead of threading catalog/provider args.
- Delete the unused FromStr impl for ModelId; dedupe the CLI's
  id-or-alias predicate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-07-23 11:07:47 -04:00
parent 65cdf52061
commit 05e6507095
No known key found for this signature in database
23 changed files with 560 additions and 807 deletions

View file

@ -45,6 +45,10 @@ struct CompletedModelTest {
status: String,
}
fn model_matches_selector(model: &Model, selector: &str) -> bool {
model.id == selector || model.aliases.iter().any(|alias| alias == selector)
}
fn find_model_by_id_or_alias(
models: &[Model],
id: &str,
@ -54,7 +58,7 @@ fn find_model_by_id_or_alias(
.iter()
.find(|model| {
provider.is_none_or(|provider| &model.provider == provider)
&& (model.id == id || model.aliases.iter().any(|alias| alias == id))
&& model_matches_selector(model, id)
})
.cloned()
}
@ -275,10 +279,9 @@ async fn test_models_via_server(
if !json_output {
eprint!("Testing {model_id}...");
}
let has_configured_match = listed_models.iter().any(|model| {
model.configured
&& (model.id == model_id || model.aliases.iter().any(|alias| alias == model_id))
});
let has_configured_match = listed_models
.iter()
.any(|model| model.configured && model_matches_selector(model, model_id));
let result =
if requested_provider.is_none() && listed_info.is_some() && !has_configured_match {
None

View file

@ -25,7 +25,7 @@
//! Resolution against the static adapter registry happens in `fabro-model`
//! when the resolved [`Catalog`](fabro_model::Catalog) is built.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::collections::{BTreeMap, HashMap};
use fabro_model::catalog::deserialize_knowledge_cutoff;
use fabro_model::{
@ -160,90 +160,16 @@ pub struct ModelSettings {
pub costs: Option<ModelCostTable>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LlmNormalizationError {
BuiltinCatalog {
message: String,
},
RetiredModel {
identifier: String,
provider: ProviderId,
model: ModelId,
},
UnknownLegacyModel {
model: String,
},
AmbiguousLegacyModel {
model: String,
candidates: Vec<(ProviderId, ModelId)>,
},
AmbiguousProviderModel {
provider: ProviderId,
selector: String,
models: Vec<ModelId>,
},
DuplicateModelDefinition {
provider: ProviderId,
model: ModelId,
},
ScopedModelDeclaresProvider {
provider: ProviderId,
model: ModelId,
},
}
impl std::fmt::Display for LlmNormalizationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BuiltinCatalog { message } => {
write!(f, "failed to inspect the built-in model catalog: {message}")
}
Self::RetiredModel {
identifier,
provider,
model,
} => write!(
f,
"model identifier '{identifier}' has been retired; use '{provider}/{model}' instead"
),
Self::UnknownLegacyModel { model } => write!(
f,
"legacy model row '{model}' omits provider and does not match a unique known offering"
),
Self::AmbiguousLegacyModel { model, candidates } => write!(
f,
"legacy model row '{model}' omits provider and matches multiple offerings: {candidates:?}"
),
Self::AmbiguousProviderModel {
provider,
selector,
models,
} => write!(
f,
"legacy model selector '{selector}' is ambiguous on provider '{provider}': {models:?}"
),
Self::DuplicateModelDefinition { provider, model } => write!(
f,
"provider '{provider}' model '{model}' is defined through both provider-scoped and legacy top-level syntax"
),
Self::ScopedModelDeclaresProvider { provider, model } => write!(
f,
"provider-scoped model '{provider}/{model}' must not declare a provider field"
),
}
}
}
impl std::error::Error for LlmNormalizationError {}
impl LlmLayer {
/// Normalize the temporary legacy model table before this source is
/// combined with any other settings source.
pub(crate) fn normalize_legacy_models(&mut self) -> Result<(), LlmNormalizationError> {
/// combined with any other settings source. Resolution runs against this
/// layer's own providers plus the built-in catalog, because a single
/// source may reference built-in offerings that merge in later.
pub(crate) fn normalize_legacy_models(&mut self) -> Result<(), catalog::LegacyModelError> {
for (provider, settings) in self.providers.iter() {
for (model, settings) in settings.models.iter() {
if settings.provider.is_some() {
return Err(LlmNormalizationError::ScopedModelDeclaresProvider {
return Err(catalog::LegacyModelError::ScopedModelDeclaresProvider {
provider: ProviderId::new(provider.clone()),
model: ModelId::new(model.clone()),
});
@ -251,46 +177,45 @@ impl LlmLayer {
}
}
let mut legacy_models = std::mem::take(&mut self.models.0)
.into_iter()
.collect::<Vec<_>>();
let legacy_models = std::mem::take(&mut self.models.0);
if legacy_models.is_empty() {
return Ok(());
}
let mut legacy_models = legacy_models.into_iter().collect::<Vec<_>>();
legacy_models.sort_by(|(left, _), (right, _)| left.cmp(right));
for (legacy_id, mut settings) in legacy_models {
if let Some((provider, model)) = catalog::retired_model_replacement(&legacy_id) {
return Err(LlmNormalizationError::RetiredModel {
identifier: legacy_id,
provider,
model,
});
}
let mut index = catalog::LegacyModelIndex::default();
let mut provider_ids = self.providers.keys().cloned().collect::<Vec<_>>();
provider_ids.sort_unstable();
for provider_id in &provider_ids {
let settings = self
.providers
.get(provider_id)
.expect("provider ID came from provider map keys");
let mut model_ids = settings.models.keys().cloned().collect::<Vec<_>>();
model_ids.sort_unstable();
index.add_provider(
ProviderId::new(provider_id.clone()),
settings.aliases.clone().unwrap_or_default(),
model_ids.into_iter().map(|model_id| {
let model = settings
.models
.get(&model_id)
.expect("model ID came from model map keys");
let aliases = model.aliases.clone().unwrap_or_default();
(ModelId::new(model_id), aliases)
}),
);
}
let index = index.with_builtin()?;
for (legacy_id, mut settings) in legacy_models {
let explicit_provider = settings.provider.take();
let (provider, model) = if let Some(provider) = explicit_provider {
let provider = self.canonical_provider(&provider)?;
let model = self
.canonical_model_on_provider(&provider, &legacy_id)?
.unwrap_or_else(|| ModelId::new(legacy_id.clone()));
(provider, model)
} else {
let candidates = self.model_candidates(&legacy_id)?;
match candidates.as_slice() {
[(provider, model)] => (provider.clone(), model.clone()),
[] => {
return Err(LlmNormalizationError::UnknownLegacyModel { model: legacy_id });
}
_ => {
return Err(LlmNormalizationError::AmbiguousLegacyModel {
model: legacy_id,
candidates,
});
}
}
};
let (provider, model) = index.resolve(&legacy_id, explicit_provider.as_deref())?;
let provider_settings = self.providers.entry(provider.to_string()).or_default();
if provider_settings.models.contains_key(model.as_str()) {
return Err(LlmNormalizationError::DuplicateModelDefinition { provider, model });
return Err(catalog::LegacyModelError::DuplicateModel { provider, model });
}
provider_settings
.models
@ -298,139 +223,6 @@ impl LlmLayer {
}
Ok(())
}
fn canonical_provider(&self, selector: &str) -> Result<ProviderId, LlmNormalizationError> {
if self.providers.contains_key(selector) {
return Ok(ProviderId::new(selector));
}
let mut aliases = self
.providers
.iter()
.filter(|(_, settings)| {
settings
.aliases
.as_ref()
.is_some_and(|aliases| aliases.iter().any(|alias| alias == selector))
})
.map(|(provider, _)| ProviderId::new(provider.clone()))
.collect::<Vec<_>>();
aliases.sort();
if let Some(provider) = aliases.into_iter().next() {
return Ok(provider);
}
catalog::builtin_provider_id(selector)
.map_err(|error| normalization_catalog_error(&error))
.map(|provider| provider.unwrap_or_else(|| ProviderId::new(selector)))
}
fn canonical_model_on_provider(
&self,
provider: &ProviderId,
selector: &str,
) -> Result<Option<ModelId>, LlmNormalizationError> {
let current = self
.providers
.get(provider.as_str())
.map(|settings| &settings.models);
if current.is_some_and(|models| models.contains_key(selector)) {
return Ok(Some(ModelId::new(selector)));
}
let builtin_canonical = catalog::builtin_canonical_model_offerings(selector)
.map_err(|error| normalization_catalog_error(&error))?
.into_iter()
.find_map(|(candidate_provider, model)| {
(candidate_provider == *provider).then_some(model)
});
if builtin_canonical.is_some() {
return Ok(builtin_canonical);
}
let mut aliases = current
.into_iter()
.flat_map(|models| models.iter())
.filter(|(_, settings)| {
settings
.aliases
.as_ref()
.is_some_and(|aliases| aliases.iter().any(|alias| alias == selector))
})
.map(|(model, _)| ModelId::new(model.clone()))
.collect::<BTreeSet<_>>();
aliases.extend(
catalog::builtin_alias_model_offerings(selector)
.map_err(|error| normalization_catalog_error(&error))?
.into_iter()
.filter_map(|(candidate_provider, model)| {
(candidate_provider == *provider).then_some(model)
}),
);
match aliases.len() {
0 => Ok(None),
1 => Ok(aliases.into_iter().next()),
_ => Err(LlmNormalizationError::AmbiguousProviderModel {
provider: provider.clone(),
selector: selector.to_string(),
models: aliases.into_iter().collect(),
}),
}
}
fn model_candidates(
&self,
selector: &str,
) -> Result<Vec<(ProviderId, ModelId)>, LlmNormalizationError> {
let mut canonical = self
.providers
.iter()
.filter(|(_, settings)| settings.models.contains_key(selector))
.map(|(provider, _)| {
(
ProviderId::new(provider.clone()),
ModelId::new(selector.to_string()),
)
})
.collect::<BTreeSet<_>>();
canonical.extend(
catalog::builtin_canonical_model_offerings(selector)
.map_err(|error| normalization_catalog_error(&error))?,
);
if !canonical.is_empty() {
return Ok(canonical.into_iter().collect());
}
let mut aliases =
self.providers
.iter()
.flat_map(|(provider, settings)| {
settings
.models
.iter()
.filter(move |(_, settings)| {
settings.aliases.as_ref().is_some_and(|aliases| {
aliases.iter().any(|alias| alias == selector)
})
})
.map(move |(model, _)| {
(
ProviderId::new(provider.clone()),
ModelId::new(model.clone()),
)
})
})
.collect::<BTreeSet<_>>();
aliases.extend(
catalog::builtin_alias_model_offerings(selector)
.map_err(|error| normalization_catalog_error(&error))?,
);
Ok(aliases.into_iter().collect())
}
}
fn normalization_catalog_error(error: &catalog::CatalogBuildError) -> LlmNormalizationError {
LlmNormalizationError::BuiltinCatalog {
message: error.to_string(),
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]

View file

@ -21,9 +21,9 @@ pub use environment::{
EnvironmentNetworkLayer, EnvironmentResourcesLayer, RunEnvironmentLayer,
};
pub use llm::{
CostRates, CredentialRef, CredentialRefParseError, LlmLayer, LlmNormalizationError,
ModelControls, ModelCostTable, ModelFeatures as LlmModelFeatures,
ModelLimits as LlmModelLimits, ModelSettings, ProviderSettings, ReasoningEffortFeature,
CostRates, CredentialRef, CredentialRefParseError, LlmLayer, ModelControls, ModelCostTable,
ModelFeatures as LlmModelFeatures, ModelLimits as LlmModelLimits, ModelSettings,
ProviderSettings, ReasoningEffortFeature,
};
pub use log_filter::LogFilter;
pub use maps::{MergeMap, ReplaceMap, StickyMap};

View file

@ -46,19 +46,18 @@ pub use layers::{
CredentialRefParseError, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer,
EnvironmentLifecycleLayer, EnvironmentNetworkLayer, EnvironmentResourcesLayer, GitAuthorLayer,
GithubIntegrationLayer, HookAgentMarker, HookEntry, HookTlsMode, IntegrationWebhooksLayer,
InterviewProviderLayer, InterviewsLayer, LlmLayer, LlmModelFeatures, LlmModelLimits,
LlmNormalizationError, LogFilter, McpEntryLayer, MergeMap, ModelControls, ModelCostTable,
ModelRefOrSplice, ModelSettings, NotificationProviderLayer, NotificationRouteLayer,
ObjectStoreLocalLayer, ObjectStoreS3Layer, PrepareStep, ProjectLayer, ProviderSettings,
ReasoningEffortFeature, ReplaceMap, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer,
RunCloneLayer, RunEnvironmentLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer,
RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer, RunMetaBranchLayer,
RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer,
RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer, ServerAuthGithubLayer,
ServerAuthLayer, ServerIntegrationsLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer,
ServerSandboxLayer, ServerSandboxProviderLayer, ServerSandboxProvidersLayer,
ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer, SettingsLayer,
SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer,
InterviewProviderLayer, InterviewsLayer, LlmLayer, LlmModelFeatures, LlmModelLimits, LogFilter,
McpEntryLayer, MergeMap, ModelControls, ModelCostTable, ModelRefOrSplice, ModelSettings,
NotificationProviderLayer, NotificationRouteLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer,
PrepareStep, ProjectLayer, ProviderSettings, ReasoningEffortFeature, ReplaceMap, RunAgentLayer,
RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, RunEnvironmentLayer, RunExecutionLayer,
RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer,
RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer,
RunRunBranchLayer, RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer,
ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer,
ServerListenLayer, ServerLoggingLayer, ServerSandboxLayer, ServerSandboxProviderLayer,
ServerSandboxProvidersLayer, ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer,
ServerWebLayer, SettingsLayer, SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer,
};
pub use logging::{resolve_log_destination, resolve_log_destination_with_env};
pub use parse::ParseError;

View file

@ -1,7 +1,8 @@
use std::fmt;
use fabro_model::catalog::LegacyModelError;
use crate::SettingsLayer;
use crate::layers::LlmNormalizationError;
const CURRENT_VERSION: u32 = 1;
@ -31,7 +32,7 @@ const LEGACY_LLM_KEYS: &[&str] = &[
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
Toml(String),
LlmCatalog(LlmNormalizationError),
LlmCatalog(LegacyModelError),
Version(VersionError),
UnknownTopLevelKey {
key: String,
@ -325,7 +326,7 @@ provider = "openai"
assert!(matches!(
error,
ParseError::LlmCatalog(LlmNormalizationError::ScopedModelDeclaresProvider {
ParseError::LlmCatalog(LegacyModelError::ScopedModelDeclaresProvider {
provider,
model,
}) if provider.as_str() == "openai" && model.as_str() == "gpt-5.4"
@ -398,7 +399,7 @@ display_name = "Ambiguous"
assert!(matches!(
error,
ParseError::LlmCatalog(LlmNormalizationError::AmbiguousLegacyModel {
ParseError::LlmCatalog(LegacyModelError::AmbiguousModel {
model,
candidates,
}) if model == "gpt-5.6-sol" && candidates.len() >= 2
@ -420,7 +421,7 @@ display_name = "Legacy"
assert!(matches!(
error,
ParseError::LlmCatalog(LlmNormalizationError::DuplicateModelDefinition {
ParseError::LlmCatalog(LegacyModelError::DuplicateModel {
provider,
model,
}) if provider.as_str() == "openai" && model.as_str() == "gpt-5.4"
@ -438,7 +439,7 @@ provider = "openrouter"
assert!(matches!(
error,
ParseError::LlmCatalog(LlmNormalizationError::RetiredModel {
ParseError::LlmCatalog(LegacyModelError::Retired {
identifier,
provider,
model,

View file

@ -309,20 +309,19 @@ impl Client {
let eligible = self.eligible_provider_ids();
if let Some(explicit) = request.provider.as_deref() {
let explicit = ProviderId::new(explicit);
if let Some(catalog_provider) = catalog.provider(&explicit) {
if catalog.provider(&explicit).is_some() {
let selected = catalog
.resolve_selection(Some(&request.model), Some(&explicit), &eligible)
.map_err(selection_error)?;
let provider = self
.provider_adapter(catalog_provider.id.as_str())
.provider_adapter(selected.provider.as_str())
.ok_or_else(|| {
selection_error(ModelSelectionError::ProviderUnavailable {
provider: catalog_provider.id.clone(),
provider: selected.provider.clone(),
})
})?;
match catalog.resolve_on_provider(&catalog_provider.id, &request.model) {
Ok(model) => resolved.model = model.id.to_string(),
Err(ModelSelectionError::UnknownSelectorOnProvider { .. }) => {}
Err(error) => return Err(selection_error(error)),
}
resolved.provider = Some(catalog_provider.id.to_string());
resolved.model = selected.model;
resolved.provider = Some(selected.provider.into_inner());
return Ok(ResolvedRequest {
provider,
request: resolved,

View file

@ -7,7 +7,7 @@ use crate::codec::anthropic_messages::{AnthropicMessages, anthropic_option};
use crate::codec::{AnthropicVersion, Codec, CodecCtx, CodecParams, EncodedRequest};
use crate::error::Error;
use crate::provider::{self, ProviderAdapter, StreamEventStream};
use crate::providers::common::{self as common};
use crate::providers::common::{self as common, CatalogRoute};
use crate::token_count::{InputTokenCount, InputTokenCountMethod};
use crate::transport::{self, HttpTransport, SseFraming};
use crate::types::{AdapterTimeout, Request, Response, StreamEvent};
@ -118,11 +118,7 @@ impl Adapter {
request,
provider_name: &self.provider_name,
deployment_id,
model: common::catalog_model(
self.catalog.as_deref(),
&self.provider_name,
&request.model,
),
model: self.catalog_model(&request.model),
params,
}
}
@ -214,6 +210,16 @@ fn anthropic_thinking_type(provider_options: Option<&serde_json::Value>) -> Opti
.and_then(serde_json::Value::as_str)
}
impl common::CatalogRoute for Adapter {
fn catalog(&self) -> Option<&Catalog> {
self.catalog.as_deref()
}
fn provider_name(&self) -> &str {
&self.provider_name
}
}
#[async_trait::async_trait]
impl ProviderAdapter for Adapter {
fn name(&self) -> &str {
@ -232,11 +238,7 @@ impl ProviderAdapter for Adapter {
self.validate_request(request)?;
let resolved = self.resolve_request(request).await;
let codec = AnthropicMessages;
let deployment_id = common::api_model_id(
self.catalog.as_deref(),
&self.provider_name,
&resolved.model,
);
let deployment_id = self.api_model_id(&resolved.model);
let ctx = self.codec_ctx(&resolved, &deployment_id, &route.codec_params);
let Some(encoded) = codec.encode_count_tokens(&ctx).transpose()? else {
@ -272,11 +274,7 @@ impl ProviderAdapter for Adapter {
let resolved = self.resolve_request(request).await;
let codec = AnthropicMessages;
let deployment_id = common::api_model_id(
self.catalog.as_deref(),
&self.provider_name,
&resolved.model,
);
let deployment_id = self.api_model_id(&resolved.model);
let ctx = self.codec_ctx(&resolved, &deployment_id, &route.codec_params);
let encoded = codec.encode(&ctx, false)?;
@ -293,11 +291,7 @@ impl ProviderAdapter for Adapter {
let route = self.route_config();
let resolved = self.resolve_request(request).await;
let codec = AnthropicMessages;
let deployment_id = common::api_model_id(
self.catalog.as_deref(),
&self.provider_name,
&resolved.model,
);
let deployment_id = self.api_model_id(&resolved.model);
let ctx = self.codec_ctx(&resolved, &deployment_id, &route.codec_params);
let encoded = codec.encode(&ctx, true)?;
@ -323,8 +317,7 @@ impl ProviderAdapter for Adapter {
// Always-adaptive models reject manual enabled/disabled thinking
// configs at the API, so fail them locally with a clear message
// instead.
let model_info =
common::catalog_model(self.catalog.as_deref(), &self.provider_name, &request.model);
let model_info = self.catalog_model(&request.model);
if let Some(model) = model_info
.filter(|m| m.features.reasoning_effort == ReasoningEffortFeature::AlwaysAdaptive)
{

View file

@ -29,7 +29,7 @@ use crate::codec::bedrock_converse::BedrockConverse;
use crate::codec::{Codec, CodecCtx, CodecParams, EncodedRequest, RawEvent, StreamDecoder};
use crate::error::Error;
use crate::provider::{self, ProviderAdapter, StreamEventStream};
use crate::providers::common::{self as common};
use crate::providers::common::{self as common, CatalogRoute};
use crate::transport::{self, HttpTransport};
use crate::types::{AdapterTimeout, Request, Response, StreamEvent};
@ -169,11 +169,7 @@ impl Adapter {
request,
provider_name: &self.provider_name,
deployment_id,
model: common::catalog_model(
self.catalog.as_deref(),
&self.provider_name,
&request.model,
),
model: self.catalog_model(&request.model),
params,
}
}
@ -232,6 +228,16 @@ impl Adapter {
}
}
impl common::CatalogRoute for Adapter {
fn catalog(&self) -> Option<&Catalog> {
self.catalog.as_deref()
}
fn provider_name(&self) -> &str {
&self.provider_name
}
}
#[async_trait::async_trait]
impl ProviderAdapter for Adapter {
fn name(&self) -> &str {
@ -243,11 +249,7 @@ impl ProviderAdapter for Adapter {
let resolved = self.resolve_request(request).await;
let codec = BedrockConverse;
let deployment_id = common::api_model_id(
self.catalog.as_deref(),
&self.provider_name,
&resolved.model,
);
let deployment_id = self.api_model_id(&resolved.model);
let params = CodecParams::default();
let ctx = self.codec_ctx(&resolved, &deployment_id, &params);
@ -261,11 +263,7 @@ impl ProviderAdapter for Adapter {
let resolved = self.resolve_request(request).await;
let codec = BedrockConverse;
let deployment_id = common::api_model_id(
self.catalog.as_deref(),
&self.provider_name,
&resolved.model,
);
let deployment_id = self.api_model_id(&resolved.model);
let params = CodecParams::default();
let ctx = self.codec_ctx(&resolved, &deployment_id, &params);

View file

@ -20,6 +20,23 @@ pub fn api_model_id(catalog: Option<&Catalog>, provider: &str, model: &str) -> S
.map_or_else(|| model.to_string(), |settings| settings.api_id.clone())
}
/// Adapters that route models through an optional catalog scoped to one
/// provider name.
pub trait CatalogRoute {
fn catalog(&self) -> Option<&Catalog>;
fn provider_name(&self) -> &str;
/// Catalog offering for a canonical ID or alias on this provider.
fn catalog_model(&self, model: &str) -> Option<&Model> {
catalog_model(self.catalog(), self.provider_name(), model)
}
/// Identifier sent to the provider API for a model.
fn api_model_id(&self, model: &str) -> String {
api_model_id(self.catalog(), self.provider_name(), model)
}
}
/// Check if a URL string looks like a local file path.
#[must_use]
pub fn is_file_path(url: &str) -> bool {

View file

@ -9,7 +9,7 @@ use crate::error::Error;
use crate::provider::{
ProviderAdapter, StreamEventStream, validate_standard_speed, validate_tool_choice,
};
use crate::providers::common::{self as common};
use crate::providers::common::{self as common, CatalogRoute};
use crate::token_count::{InputTokenCount, InputTokenCountMethod};
use crate::transport::{self, HttpTransport, SseFraming};
use crate::types::{AdapterTimeout, Request, Response};
@ -102,11 +102,7 @@ impl Adapter {
request,
provider_name: &self.provider_name,
deployment_id,
model: common::catalog_model(
self.catalog.as_deref(),
&self.provider_name,
&request.model,
),
model: self.catalog_model(&request.model),
params,
}
}
@ -129,6 +125,16 @@ impl Adapter {
}
}
impl common::CatalogRoute for Adapter {
fn catalog(&self) -> Option<&Catalog> {
self.catalog.as_deref()
}
fn provider_name(&self) -> &str {
&self.provider_name
}
}
#[async_trait::async_trait]
impl ProviderAdapter for Adapter {
fn name(&self) -> &str {
@ -151,11 +157,7 @@ impl ProviderAdapter for Adapter {
let resolved = self.resolve_request(request).await;
let codec = GeminiGenerate;
let deployment_id = common::api_model_id(
self.catalog.as_deref(),
&self.provider_name,
&resolved.model,
);
let deployment_id = self.api_model_id(&resolved.model);
let params = CodecParams::default();
let ctx = self.codec_ctx(&resolved, &deployment_id, &params);
@ -185,11 +187,7 @@ impl ProviderAdapter for Adapter {
let resolved = self.resolve_request(request).await;
let codec = GeminiGenerate;
let deployment_id = common::api_model_id(
self.catalog.as_deref(),
&self.provider_name,
&resolved.model,
);
let deployment_id = self.api_model_id(&resolved.model);
let params = CodecParams::default();
let ctx = self.codec_ctx(&resolved, &deployment_id, &params);
@ -206,11 +204,7 @@ impl ProviderAdapter for Adapter {
let resolved = self.resolve_request(request).await;
let codec = GeminiGenerate;
let deployment_id = common::api_model_id(
self.catalog.as_deref(),
&self.provider_name,
&resolved.model,
);
let deployment_id = self.api_model_id(&resolved.model);
let params = CodecParams::default();
let ctx = self.codec_ctx(&resolved, &deployment_id, &params);

View file

@ -9,7 +9,7 @@ use crate::error::Error;
use crate::provider::{
ProviderAdapter, StreamEventStream, validate_standard_speed, validate_tool_choice,
};
use crate::providers::common::{self as common};
use crate::providers::common::{self as common, CatalogRoute};
use crate::token_count::{InputTokenCount, InputTokenCountMethod};
use crate::transport::{self, HttpTransport, SseFraming};
use crate::types::{AdapterTimeout, Request, Response, StreamEvent};
@ -131,11 +131,7 @@ impl Adapter {
request,
provider_name: &self.provider_name,
deployment_id,
model: common::catalog_model(
self.catalog.as_deref(),
&self.provider_name,
&request.model,
),
model: self.catalog_model(&request.model),
params,
}
}
@ -196,6 +192,16 @@ impl Adapter {
}
}
impl common::CatalogRoute for Adapter {
fn catalog(&self) -> Option<&Catalog> {
self.catalog.as_deref()
}
fn provider_name(&self) -> &str {
&self.provider_name
}
}
#[async_trait::async_trait]
impl ProviderAdapter for Adapter {
fn name(&self) -> &str {
@ -218,11 +224,7 @@ impl ProviderAdapter for Adapter {
let resolved = self.resolve_request(request).await;
let codec = OpenAiResponses;
let deployment_id = common::api_model_id(
self.catalog.as_deref(),
&self.provider_name,
&resolved.model,
);
let deployment_id = self.api_model_id(&resolved.model);
let params = self.codec_params();
let ctx = self.codec_ctx(&resolved, &deployment_id, &params);
@ -258,11 +260,7 @@ impl ProviderAdapter for Adapter {
let resolved = self.resolve_request(request).await;
let codec = OpenAiResponses;
let deployment_id = common::api_model_id(
self.catalog.as_deref(),
&self.provider_name,
&resolved.model,
);
let deployment_id = self.api_model_id(&resolved.model);
let params = self.codec_params();
let ctx = self.codec_ctx(&resolved, &deployment_id, &params);
@ -279,11 +277,7 @@ impl ProviderAdapter for Adapter {
let resolved = self.resolve_request(request).await;
let codec = OpenAiResponses;
let deployment_id = common::api_model_id(
self.catalog.as_deref(),
&self.provider_name,
&resolved.model,
);
let deployment_id = self.api_model_id(&resolved.model);
let params = self.codec_params();
let ctx = self.codec_ctx(&resolved, &deployment_id, &params);

View file

@ -8,7 +8,7 @@ use crate::error::Error;
use crate::provider::{
ProviderAdapter, StreamEventStream, validate_standard_speed, validate_tool_choice,
};
use crate::providers::common::{self as common};
use crate::providers::common::{self as common, CatalogRoute};
use crate::transport::{self, HttpTransport, SseFraming};
use crate::types::{AdapterTimeout, Request, Response};
@ -88,7 +88,7 @@ impl Adapter {
/// Resolve the wire model id (catalog `api_id`, falling back to the
/// requested model).
fn deployment_id(&self, request: &Request) -> String {
common::api_model_id(self.catalog.as_deref(), &self.provider_name, &request.model)
self.api_model_id(&request.model)
}
/// Build the borrowed codec context. `deployment_id` and `params` are
@ -103,11 +103,7 @@ impl Adapter {
request,
provider_name: &self.provider_name,
deployment_id,
model: common::catalog_model(
self.catalog.as_deref(),
&self.provider_name,
&request.model,
),
model: self.catalog_model(&request.model),
params,
}
}
@ -131,6 +127,16 @@ impl Adapter {
}
}
impl common::CatalogRoute for Adapter {
fn catalog(&self) -> Option<&Catalog> {
self.catalog.as_deref()
}
fn provider_name(&self) -> &str {
&self.provider_name
}
}
#[async_trait::async_trait]
impl ProviderAdapter for Adapter {
fn name(&self) -> &str {

View file

@ -544,33 +544,8 @@ pub enum CatalogBuildError {
first: ModelId,
second: ModelId,
},
#[error(
"provider '{provider}' model '{model}' is defined through both provider-scoped and legacy top-level syntax"
)]
DuplicateProviderModelDefinition {
provider: ProviderId,
model: ModelId,
},
#[error("provider-scoped model '{provider}/{model}' must not declare a provider field")]
ScopedModelDeclaresProvider {
provider: ProviderId,
model: ModelId,
},
#[error("legacy model row '{model}' omits provider and does not match a unique known offering")]
LegacyModelProviderUnknown { model: String },
#[error(
"legacy model row '{model}' omits provider and matches multiple providers: {providers:?}"
)]
LegacyModelProviderAmbiguous {
model: String,
providers: Vec<ProviderId>,
},
#[error("model identifier '{identifier}' has been retired; use '{provider}/{model}' instead")]
RetiredModelIdentifier {
identifier: String,
provider: ProviderId,
model: ModelId,
},
#[error(transparent)]
LegacyModel(#[from] LegacyModelError),
#[error("provider '{provider}' model '{model}' has an empty api_id")]
EmptyModelApiId {
provider: ProviderId,
@ -637,6 +612,10 @@ pub enum ModelSelectionError {
selector: String,
providers: Vec<ProviderId>,
},
#[error(
"no default model is available on an eligible provider; providers with defaults: {providers:?}"
)]
NoDefaultModel { providers: Vec<ProviderId> },
#[error("model identifier '{identifier}' has been retired; use '{provider}/{model}' instead")]
RetiredModelIdentifier {
identifier: String,
@ -645,6 +624,15 @@ pub enum ModelSelectionError {
},
}
/// One provider/model pair chosen by [`Catalog::resolve_selection`]. The
/// model is the canonical catalog ID when the selector matched an offering,
/// or the caller's selector passed through verbatim when it did not.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectedModel {
pub provider: ProviderId,
pub model: String,
}
/// Typed model catalog backed by a `Vec<Model>`.
///
/// Use [`Catalog::builtin()`] for the embedded settings-backed catalog.
@ -724,13 +712,14 @@ impl Catalog {
}
if let Some((replacement_provider, replacement_model)) =
retired_model_address(&model_id)
retired_model_replacement(&model_id)
{
return Err(CatalogBuildError::RetiredModelIdentifier {
return Err(LegacyModelError::Retired {
identifier: model_id,
provider: replacement_provider,
model: replacement_model,
});
}
.into());
}
let (model, resolved_settings) = build_model(&model_id, model_settings, provider)?;
@ -905,7 +894,8 @@ impl Catalog {
provider: &ProviderId,
selector: &str,
) -> Result<&Model, ModelSelectionError> {
if let Some((replacement_provider, replacement_model)) = retired_model_address(selector) {
if let Some((replacement_provider, replacement_model)) = retired_model_replacement(selector)
{
return Err(ModelSelectionError::RetiredModelIdentifier {
identifier: selector.to_string(),
provider: replacement_provider,
@ -937,7 +927,8 @@ impl Catalog {
explicit_provider: Option<&ProviderId>,
eligible_providers: &HashSet<ProviderId>,
) -> Result<&'a Model, ModelSelectionError> {
if let Some((replacement_provider, replacement_model)) = retired_model_address(selector) {
if let Some((replacement_provider, replacement_model)) = retired_model_replacement(selector)
{
return Err(ModelSelectionError::RetiredModelIdentifier {
identifier: selector.to_string(),
provider: replacement_provider,
@ -1042,10 +1033,82 @@ impl Catalog {
.collect::<Vec<_>>();
providers.sort();
providers.dedup();
Err(ModelSelectionError::NoEligibleOffering {
selector: "<default model>".to_string(),
providers,
})
Err(ModelSelectionError::NoDefaultModel { providers })
}
/// Canonicalize a provider ID or alias and require it to be in the
/// eligible snapshot.
pub fn ready_provider(
&self,
provider: &ProviderId,
eligible_providers: &HashSet<ProviderId>,
) -> Result<ProviderId, ModelSelectionError> {
let provider =
self.provider(provider)
.ok_or_else(|| ModelSelectionError::UnknownProvider {
provider: provider.clone(),
})?;
let ready = eligible_providers.iter().any(|eligible| {
self.provider(eligible)
.is_some_and(|eligible| eligible.id == provider.id)
});
if !ready {
return Err(ModelSelectionError::ProviderUnavailable {
provider: provider.id.clone(),
});
}
Ok(provider.id.clone())
}
/// Resolve an optional selector to one provider/model pair, applying the
/// passthrough policy shared by every dispatch boundary:
///
/// - A selector known to the catalog resolves to its canonical offering.
/// - An unknown selector pinned to a provider passes through verbatim on
/// that provider.
/// - An unqualified unknown selector passes through on the default
/// provider.
/// - No selector picks the default offering (of the pinned provider, when
/// one is given).
pub fn resolve_selection(
&self,
selector: Option<&str>,
explicit_provider: Option<&ProviderId>,
eligible_providers: &HashSet<ProviderId>,
) -> Result<SelectedModel, ModelSelectionError> {
let Some(selector) = selector else {
let eligible = match explicit_provider {
Some(provider) => {
HashSet::from([self.ready_provider(provider, eligible_providers)?])
}
None => eligible_providers.clone(),
};
let offering = self.select_default(&eligible)?;
return Ok(SelectedModel {
provider: offering.provider.clone(),
model: offering.id.to_string(),
});
};
match self.select(selector, explicit_provider, eligible_providers) {
Ok(offering) => Ok(SelectedModel {
provider: offering.provider.clone(),
model: offering.id.to_string(),
}),
Err(ModelSelectionError::UnknownSelectorOnProvider { provider, .. }) => {
Ok(SelectedModel {
provider,
model: selector.to_string(),
})
}
Err(ModelSelectionError::UnknownSelector { .. }) => {
let default = self.select_default(eligible_providers)?;
Ok(SelectedModel {
provider: default.provider.clone(),
model: selector.to_string(),
})
}
Err(error) => Err(error),
}
}
#[must_use]
@ -1411,52 +1474,24 @@ fn normalize_catalog_settings(
mut settings: LlmCatalogSettings,
known: Option<&LlmCatalogSettings>,
) -> Result<LlmCatalogSettings, CatalogBuildError> {
for (provider, settings) in &settings.providers {
for (model, settings) in &settings.models {
if settings.provider.is_some() {
return Err(CatalogBuildError::ScopedModelDeclaresProvider {
provider: ProviderId::new(provider.clone()),
model: ModelId::new(model.clone()),
});
}
}
}
reject_scoped_provider_fields(&settings)?;
let legacy_models = std::mem::take(&mut settings.models);
for (legacy_id, mut model_settings) in legacy_models {
if let Some((provider, model)) = retired_model_address(&legacy_id) {
return Err(CatalogBuildError::RetiredModelIdentifier {
identifier: legacy_id,
provider,
model,
});
}
if legacy_models.is_empty() {
return Ok(settings);
}
let mut legacy_models = legacy_models.into_iter().collect::<Vec<_>>();
legacy_models.sort_by(|(left, _), (right, _)| left.cmp(right));
let mut index = LegacyModelIndex::default();
index.add_settings(&settings);
if let Some(known) = known {
index.add_settings(known);
}
for (legacy_id, mut model_settings) in legacy_models {
let explicit_provider = model_settings.provider.take();
let (provider, model_id) = if let Some(provider) = explicit_provider {
let provider = canonical_settings_provider(&provider, &settings, known)
.unwrap_or_else(|| ProviderId::new(provider));
let model_id = canonical_settings_model(&provider, &legacy_id, &settings, known)
.unwrap_or_else(|| ModelId::new(legacy_id.clone()));
(provider, model_id)
} else {
let candidates = settings_model_candidates(&legacy_id, &settings, known);
match candidates.as_slice() {
[(provider, model)] => (provider.clone(), model.clone()),
[] => {
return Err(CatalogBuildError::LegacyModelProviderUnknown { model: legacy_id });
}
_ => {
return Err(CatalogBuildError::LegacyModelProviderAmbiguous {
model: legacy_id,
providers: candidates
.into_iter()
.map(|(provider, _)| provider)
.collect(),
});
}
}
};
let (provider, model_id) = index.resolve(&legacy_id, explicit_provider.as_deref())?;
if !settings.providers.contains_key(provider.as_str())
&& !known.is_some_and(|known| known.providers.contains_key(provider.as_str()))
@ -1469,10 +1504,11 @@ fn normalize_catalog_settings(
let provider_settings = settings.providers.entry(provider.to_string()).or_default();
if provider_settings.models.contains_key(model_id.as_str()) {
return Err(CatalogBuildError::DuplicateProviderModelDefinition {
return Err(LegacyModelError::DuplicateModel {
provider,
model: model_id,
});
}
.into());
}
provider_settings
.models
@ -1481,154 +1517,242 @@ fn normalize_catalog_settings(
Ok(settings)
}
fn canonical_settings_provider(
selector: &str,
settings: &LlmCatalogSettings,
known: Option<&LlmCatalogSettings>,
) -> Option<ProviderId> {
let providers = || {
settings
.providers
.iter()
.chain(known.into_iter().flat_map(|known| known.providers.iter()))
};
providers()
.find(|(id, _)| id.as_str() == selector)
.or_else(|| {
providers().find(|(_, provider)| {
provider
.aliases
.as_ref()
.is_some_and(|aliases| aliases.iter().any(|alias| alias == selector))
})
})
.map(|(id, _)| ProviderId::new(id.clone()))
}
fn canonical_settings_model(
provider: &ProviderId,
selector: &str,
settings: &LlmCatalogSettings,
known: Option<&LlmCatalogSettings>,
) -> Option<ModelId> {
let models = || {
settings
.providers
.get(provider.as_str())
.into_iter()
.chain(known.and_then(|known| known.providers.get(provider.as_str())))
.flat_map(|provider| provider.models.iter())
};
models()
.find(|(id, _)| id.as_str() == selector)
.or_else(|| {
models().find(|(_, model)| {
model
.aliases
.as_ref()
.is_some_and(|aliases| aliases.iter().any(|alias| alias == selector))
})
})
.map(|(id, _)| ModelId::new(id.clone()))
}
fn settings_model_candidates(
selector: &str,
settings: &LlmCatalogSettings,
known: Option<&LlmCatalogSettings>,
) -> Vec<(ProviderId, ModelId)> {
let collect = |matches: &dyn Fn(&str, &ModelCatalogSettings) -> bool| {
let mut candidates = BTreeSet::<(ProviderId, ModelId)>::new();
for (provider_id, provider) in settings
.providers
.iter()
.chain(known.into_iter().flat_map(|known| known.providers.iter()))
{
for (model_id, model) in &provider.models {
if matches(model_id, model) {
candidates.insert((
ProviderId::new(provider_id.clone()),
ModelId::new(model_id.clone()),
));
}
fn reject_scoped_provider_fields(settings: &LlmCatalogSettings) -> Result<(), LegacyModelError> {
for (provider, settings) in &settings.providers {
for (model, settings) in &settings.models {
if settings.provider.is_some() {
return Err(LegacyModelError::ScopedModelDeclaresProvider {
provider: ProviderId::new(provider.clone()),
model: ModelId::new(model.clone()),
});
}
}
candidates.into_iter().collect::<Vec<_>>()
};
let canonical = collect(&|model_id, _| model_id == selector);
if canonical.is_empty() {
collect(&|_, model| {
model
.aliases
.as_ref()
.is_some_and(|aliases| aliases.iter().any(|alias| alias == selector))
})
} else {
canonical
}
Ok(())
}
/// Return every built-in offering whose canonical model ID is `selector`.
///
/// This includes disabled providers because config compatibility
/// normalization happens before runtime availability is known.
pub fn builtin_canonical_model_offerings(
selector: &str,
) -> Result<Vec<(ProviderId, ModelId)>, CatalogBuildError> {
let settings = Catalog::builtin_settings()?;
Ok(settings
.providers
.iter()
.filter_map(|(provider, settings)| {
settings.models.get_key_value(selector).map(|(model, _)| {
(
ProviderId::new(provider.clone()),
ModelId::new(model.clone()),
)
})
})
.collect())
/// Failure to resolve a legacy top-level `[models.<id>]` row onto its
/// provider.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum LegacyModelError {
#[error("failed to inspect the built-in model catalog: {message}")]
BuiltinCatalog { message: String },
#[error("model identifier '{identifier}' has been retired; use '{provider}/{model}' instead")]
Retired {
identifier: String,
provider: ProviderId,
model: ModelId,
},
#[error("legacy model row '{model}' omits provider and does not match a unique known offering")]
UnknownModel { model: String },
#[error(
"legacy model row '{model}' omits provider and matches multiple offerings: {candidates:?}"
)]
AmbiguousModel {
model: String,
candidates: Vec<(ProviderId, ModelId)>,
},
#[error("legacy model selector '{selector}' is ambiguous on provider '{provider}': {models:?}")]
AmbiguousAlias {
provider: ProviderId,
selector: String,
models: Vec<ModelId>,
},
#[error("provider-scoped model '{provider}/{model}' must not declare a provider field")]
ScopedModelDeclaresProvider {
provider: ProviderId,
model: ModelId,
},
#[error(
"provider '{provider}' model '{model}' is defined through both provider-scoped and legacy top-level syntax"
)]
DuplicateModel {
provider: ProviderId,
model: ModelId,
},
}
/// Return every built-in offering that declares `selector` as an alias.
/// Identifier/alias view used to resolve legacy top-level `[models.<id>]`
/// rows onto their provider before provider-scoped settings merge.
///
/// This includes disabled providers because config compatibility
/// normalization happens before runtime availability is known.
pub fn builtin_alias_model_offerings(
selector: &str,
) -> Result<Vec<(ProviderId, ModelId)>, CatalogBuildError> {
let settings = Catalog::builtin_settings()?;
let mut offerings = settings
.providers
.iter()
.flat_map(|(provider, settings)| {
settings
.models
.iter()
.filter(move |(_, settings)| {
settings
.aliases
.as_ref()
.is_some_and(|aliases| aliases.iter().any(|alias| alias == selector))
})
.map(move |(model, _)| {
/// Both the settings-layer normalization in `fabro-config` and catalog-build
/// normalization here feed this index: local entries first, lower-precedence
/// known entries (e.g. the built-in catalog) after. Canonical IDs always win
/// over aliases; alias ties resolve to the first entry added.
#[derive(Debug, Default)]
pub struct LegacyModelIndex {
providers: Vec<LegacyProviderEntry>,
}
#[derive(Debug)]
struct LegacyProviderEntry {
id: ProviderId,
aliases: Vec<String>,
models: Vec<LegacyModelEntry>,
}
#[derive(Debug)]
struct LegacyModelEntry {
id: ModelId,
aliases: Vec<String>,
}
impl LegacyModelIndex {
pub fn add_provider(
&mut self,
id: ProviderId,
aliases: Vec<String>,
models: impl IntoIterator<Item = (ModelId, Vec<String>)>,
) {
self.providers.push(LegacyProviderEntry {
id,
aliases,
models: models
.into_iter()
.map(|(id, aliases)| LegacyModelEntry { id, aliases })
.collect(),
});
}
fn add_settings(&mut self, settings: &LlmCatalogSettings) {
let mut provider_ids = settings.providers.keys().collect::<Vec<_>>();
provider_ids.sort_unstable();
for provider_id in provider_ids {
let provider = &settings.providers[provider_id];
let mut model_ids = provider.models.keys().collect::<Vec<_>>();
model_ids.sort_unstable();
self.add_provider(
ProviderId::new(provider_id.clone()),
provider.aliases.clone().unwrap_or_default(),
model_ids.into_iter().map(|model_id| {
let model = &provider.models[model_id];
(
ProviderId::new(provider.clone()),
ModelId::new(model.clone()),
ModelId::new(model_id.clone()),
model.aliases.clone().unwrap_or_default(),
)
})
})
.collect::<Vec<_>>();
offerings.sort();
offerings.dedup();
Ok(offerings)
}
}),
);
}
}
/// Resolve a built-in provider ID or alias without filtering disabled rows.
pub fn builtin_provider_id(selector: &str) -> Result<Option<ProviderId>, CatalogBuildError> {
let settings = Catalog::builtin_settings()?;
Ok(canonical_settings_provider(selector, &settings, None))
/// Append the built-in catalog as the lowest-precedence tier. Includes
/// disabled providers because config compatibility normalization happens
/// before runtime availability is known.
pub fn with_builtin(mut self) -> Result<Self, LegacyModelError> {
let builtin =
Catalog::builtin_settings().map_err(|error| LegacyModelError::BuiltinCatalog {
message: error.to_string(),
})?;
self.add_settings(&builtin);
Ok(self)
}
/// Resolve one legacy row to its provider-scoped address. An unknown
/// explicit provider or model selector passes through verbatim; rows
/// without an explicit provider must match exactly one known offering.
pub fn resolve(
&self,
legacy_id: &str,
explicit_provider: Option<&str>,
) -> Result<(ProviderId, ModelId), LegacyModelError> {
if let Some((provider, model)) = retired_model_replacement(legacy_id) {
return Err(LegacyModelError::Retired {
identifier: legacy_id.to_string(),
provider,
model,
});
}
if let Some(explicit) = explicit_provider {
let provider = self
.canonical_provider(explicit)
.unwrap_or_else(|| ProviderId::new(explicit));
let model = self
.canonical_model_on(&provider, legacy_id)?
.unwrap_or_else(|| ModelId::new(legacy_id));
return Ok((provider, model));
}
let candidates = self.candidates(legacy_id);
match candidates.as_slice() {
[(provider, model)] => Ok((provider.clone(), model.clone())),
[] => Err(LegacyModelError::UnknownModel {
model: legacy_id.to_string(),
}),
_ => Err(LegacyModelError::AmbiguousModel {
model: legacy_id.to_string(),
candidates,
}),
}
}
fn canonical_provider(&self, selector: &str) -> Option<ProviderId> {
self.providers
.iter()
.find(|provider| provider.id.as_str() == selector)
.or_else(|| {
self.providers
.iter()
.find(|provider| provider.aliases.iter().any(|alias| alias == selector))
})
.map(|provider| provider.id.clone())
}
fn canonical_model_on(
&self,
provider: &ProviderId,
selector: &str,
) -> Result<Option<ModelId>, LegacyModelError> {
let models = || {
self.providers
.iter()
.filter(|entry| entry.id == *provider)
.flat_map(|entry| entry.models.iter())
};
if models().any(|model| model.id.as_str() == selector) {
return Ok(Some(ModelId::new(selector)));
}
let matches = models()
.filter(|model| model.aliases.iter().any(|alias| alias == selector))
.map(|model| model.id.clone())
.collect::<BTreeSet<_>>();
match matches.len() {
0 => Ok(None),
1 => Ok(matches.into_iter().next()),
_ => Err(LegacyModelError::AmbiguousAlias {
provider: provider.clone(),
selector: selector.to_string(),
models: matches.into_iter().collect(),
}),
}
}
fn candidates(&self, selector: &str) -> Vec<(ProviderId, ModelId)> {
let canonical = self
.providers
.iter()
.filter(|entry| {
entry
.models
.iter()
.any(|model| model.id.as_str() == selector)
})
.map(|entry| (entry.id.clone(), ModelId::new(selector)))
.collect::<BTreeSet<_>>();
if !canonical.is_empty() {
return canonical.into_iter().collect();
}
self.providers
.iter()
.flat_map(|entry| {
entry
.models
.iter()
.filter(|model| model.aliases.iter().any(|alias| alias == selector))
.map(|model| (entry.id.clone(), model.id.clone()))
})
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
}
/// Built-in catalog keys retired when provider API identifiers stopped being
@ -1718,10 +1842,6 @@ pub fn retired_model_replacement(identifier: &str) -> Option<(ProviderId, ModelI
.map(|(_, provider, model)| (ProviderId::new(*provider), ModelId::new(*model)))
}
fn retired_model_address(identifier: &str) -> Option<(ProviderId, ModelId)> {
retired_model_replacement(identifier)
}
fn merge_catalog_settings(
higher: LlmCatalogSettings,
mut fallback: LlmCatalogSettings,
@ -3676,8 +3796,10 @@ provider = "test"
assert!(matches!(
error,
CatalogBuildError::ScopedModelDeclaresProvider { provider, model }
if provider == ProviderId::new("test") && model == "one"
CatalogBuildError::LegacyModel(LegacyModelError::ScopedModelDeclaresProvider {
provider,
model,
}) if provider == ProviderId::new("test") && model == "one"
));
}

View file

@ -150,14 +150,6 @@ impl From<String> for ModelId {
}
}
impl FromStr for ModelId {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::from(s))
}
}
impl AsRef<str> for ModelId {
fn as_ref(&self) -> &str {
&self.0

View file

@ -19,7 +19,7 @@ pub use billing::{
};
pub use catalog::{
ApiKeyHeaderPolicy, BillingPolicy, Catalog, CredentialRef, CredentialRefParseError,
FallbackTarget, ModelSelectionError, ProviderAuthConfig,
FallbackTarget, ModelSelectionError, ProviderAuthConfig, SelectedModel,
};
pub use codec::CodecKind;
pub use ids::{ModelId, ProviderId};

View file

@ -53,7 +53,7 @@ async fn create_completion(
let (model_id, selected_provider) = match resolve_request_model(
catalog.as_ref(),
&client.provider_ids(),
req.model,
req.model.as_deref(),
req.provider,
) {
Ok(selection) => selection,
@ -114,20 +114,8 @@ async fn create_completion(
metadata: None,
provider_options: req.provider_options,
};
let request = match client.resolve_request(&request) {
Ok(request) => request,
Err(error) => return ApiError::bad_request(error.to_string()).into_response(),
};
let selected_provider = ProviderId::new(
request
.provider
.as_deref()
.expect("resolved LLM request always has a provider"),
);
let selected_model = request.model.clone();
info!(
model = %selected_model,
model = %model_id,
provider = %selected_provider,
"Completion request received"
);
@ -177,7 +165,7 @@ async fn create_completion(
let stop_reason = finish_reason_to_api_stop_reason(&response.finish_reason);
Json(CompletionResponse {
id: msg_id,
model: selected_model,
model: model_id,
provider: selected_provider,
message: response.message,
stop_reason,
@ -224,44 +212,11 @@ async fn create_completion(
pub(super) fn resolve_request_model(
catalog: &Catalog,
eligible: &HashSet<ProviderId>,
requested_model: Option<String>,
requested_model: Option<&str>,
explicit_provider: Option<String>,
) -> Result<(String, ProviderId), ModelSelectionError> {
let explicit_provider = explicit_provider.map(ProviderId::new);
if let Some(model) = requested_model {
return match catalog.select(&model, explicit_provider.as_ref(), eligible) {
Ok(offering) => Ok((offering.id.to_string(), offering.provider.clone())),
Err(ModelSelectionError::UnknownSelectorOnProvider { provider, .. }) => {
Ok((model, provider))
}
Err(ModelSelectionError::UnknownSelector { .. }) => {
let default = catalog.select_default(eligible)?;
Ok((model, default.provider.clone()))
}
Err(error) => Err(error),
};
}
let eligible = if let Some(requested_provider) = explicit_provider {
let provider = catalog.provider(&requested_provider).ok_or_else(|| {
ModelSelectionError::UnknownProvider {
provider: requested_provider.clone(),
}
})?;
let provider_is_ready = eligible.iter().any(|eligible_provider| {
catalog
.provider(eligible_provider)
.is_some_and(|eligible_provider| eligible_provider.id == provider.id)
});
if !provider_is_ready {
return Err(ModelSelectionError::ProviderUnavailable {
provider: provider.id.clone(),
});
}
HashSet::from([provider.id.clone()])
} else {
eligible.clone()
};
let default = catalog.select_default(&eligible)?;
Ok((default.id.to_string(), default.provider.clone()))
let selected =
catalog.resolve_selection(requested_model, explicit_provider.as_ref(), eligible)?;
Ok((selected.model, selected.provider))
}

View file

@ -272,7 +272,8 @@ fn model_selection_response(error: &ModelSelectionError) -> Response {
}
ModelSelectionError::RetiredModelIdentifier { .. }
| ModelSelectionError::ProviderUnavailable { .. }
| ModelSelectionError::NoEligibleOffering { .. } => {
| ModelSelectionError::NoEligibleOffering { .. }
| ModelSelectionError::NoDefaultModel { .. } => {
ApiError::bad_request(error.to_string()).into_response()
}
}

View file

@ -130,7 +130,7 @@ async fn create_playground_chat(
let (model_id, selected_provider) = match super::completions::resolve_request_model(
catalog.as_ref(),
&client.provider_ids(),
req.model,
req.model.as_deref(),
req.provider.map(|provider| provider.to_string()),
) {
Ok(selection) => selection,
@ -146,7 +146,7 @@ async fn create_playground_chat(
let request = LlmRequest {
model: model_id,
messages,
provider: Some(selected_provider.to_string()),
provider: Some(selected_provider.into_inner()),
tools: Some(playground_tools()),
tool_choice: Some(ToolChoice::Auto),
response_format: None,
@ -159,10 +159,6 @@ async fn create_playground_chat(
metadata: None,
provider_options: None,
};
let request = match client.resolve_request(&request) {
Ok(request) => request,
Err(error) => return ApiError::bad_request(error.to_string()).into_response(),
};
info!(
model = %request.model,
provider = request.provider.as_deref().unwrap_or(""),

View file

@ -805,37 +805,22 @@ fn selected_session_model(
.provider_ids()
.into_iter()
.collect::<std::collections::HashSet<_>>();
let (provider_id, model) = if let Some(model_id) = session.record.model.as_deref() {
match catalog.select(model_id, session.record.provider.as_ref(), &eligible) {
Ok(selected) => (selected.provider.clone(), selected.id.to_string()),
Err(ModelSelectionError::UnknownSelectorOnProvider { provider, .. }) => {
(provider, model_id.to_string())
}
Err(ModelSelectionError::UnknownSelector { .. })
if session.record.provider.is_none() =>
let record = &session.record;
let selected = catalog
.resolve_selection(record.model.as_deref(), record.provider.as_ref(), &eligible)
.map_err(|error| {
// A missing default with no provider pin means no LLM is
// configured at all; every other failure is about the requested
// model/provider.
if record.provider.is_none()
&& matches!(error, ModelSelectionError::NoDefaultModel { .. })
{
let selected = catalog
.select_default(&eligible)
.map_err(|error| AskFabroBuildError::LlmUnconfigured(error.to_string()))?;
(selected.provider.clone(), model_id.to_string())
AskFabroBuildError::LlmUnconfigured(error.to_string())
} else {
AskFabroBuildError::ModelUnavailable(error.to_string())
}
Err(error) => {
return Err(AskFabroBuildError::ModelUnavailable(error.to_string()));
}
}
} else {
let selected = if let Some(provider) = session.record.provider.as_ref() {
let provider_only = std::collections::HashSet::from([provider.clone()]);
catalog
.select_default(&provider_only)
.map_err(|error| AskFabroBuildError::ModelUnavailable(error.to_string()))?
} else {
catalog
.select_default(&eligible)
.map_err(|error| AskFabroBuildError::LlmUnconfigured(error.to_string()))?
};
(selected.provider.clone(), selected.id.to_string())
};
})?;
let (provider_id, model) = (selected.provider, selected.model);
let profile_kind = catalog
.effective_agent_profile(&provider_id, Some(&model))
.ok_or_else(|| {
@ -865,27 +850,10 @@ fn canonical_session_model(
})
.transpose()?;
let Some(requested) = requested else {
let eligible = if let Some(provider) = explicit_provider.as_ref() {
let provider_is_ready = eligible.iter().any(|eligible_provider| {
catalog
.provider(eligible_provider)
.is_some_and(|eligible_provider| eligible_provider.id == *provider)
});
if !provider_is_ready {
return Err(session_selection_error(
&ModelSelectionError::ProviderUnavailable {
provider: provider.clone(),
},
));
}
std::collections::HashSet::from([provider.clone()])
} else {
eligible.clone()
};
let model = catalog
.select_default(&eligible)
let selected = catalog
.resolve_selection(None, explicit_provider.as_ref(), eligible)
.map_err(|error| session_selection_error(&error))?;
return Ok((model.provider.clone(), model.id.to_string()));
return Ok((selected.provider, selected.model));
};
let requested = requested.trim();
if requested.is_empty() {
@ -942,19 +910,10 @@ fn canonical_session_model(
}
};
let provider = qualified_provider.as_ref().or(explicit_provider.as_ref());
match catalog.select(&model, provider, eligible) {
Ok(selected) => Ok((selected.provider.clone(), selected.id.to_string())),
Err(ModelSelectionError::UnknownSelectorOnProvider { provider, .. }) => {
Ok((provider, model))
}
Err(ModelSelectionError::UnknownSelector { .. }) if provider.is_none() => {
let selected = catalog
.select_default(eligible)
.map_err(|error| session_selection_error(&error))?;
Ok((selected.provider.clone(), model))
}
Err(error) => Err(session_selection_error(&error)),
}
let selected = catalog
.resolve_selection(Some(&model), provider, eligible)
.map_err(|error| session_selection_error(&error))?;
Ok((selected.provider, selected.model))
}
fn session_selection_error(error: &ModelSelectionError) -> ApiError {

View file

@ -3414,7 +3414,7 @@ async fn create_run_without_ready_llm_provider_rejects_implicit_model_selection(
assert!(
body["errors"][0]["detail"]
.as_str()
.is_some_and(|detail| detail.contains("no offering on an eligible provider")),
.is_some_and(|detail| detail.contains("no default model is available")),
"unexpected response: {body}"
);
assert!(state.runs.lock().expect("runs lock poisoned").is_empty());

View file

@ -35,7 +35,6 @@ use crate::event::{
Emitter, Event, EventBody, RunEventLogger, RunEventSink, RunNoticeLevel, append_event_to_sink,
};
use crate::handler::HandlerRegistry;
use crate::handler::llm::routing;
use crate::outcome::{Outcome, StageOutcome};
use crate::pipeline::{
self, FinalizeOptions, Finalized, InitOptions, LlmSpec, Persisted, PullRequestOptions,
@ -618,22 +617,12 @@ fn resolve_start_llm(
settings: &ResolvedRunSettings,
) -> Result<ResolvedStartLlm, Error> {
let eligible = configured.iter().cloned().collect::<HashSet<_>>();
let (model, provider) = resolve_run_model(
let (model, provider_id) = resolve_run_model(
catalog,
&eligible,
settings.model.name.as_deref(),
settings.model.provider.as_deref(),
)?;
let provider_id = ProviderId::new(
provider.expect("catalog-backed run model resolution always selects a provider"),
);
let provider_context = routing::resolve_provider_context(
catalog,
&provider_id,
&model,
Some(provider_id.as_str()),
)?;
let provider_id = provider_context.provider_id;
let fallback_chain =
resolve_fallback_chain(catalog, &provider_id, &model, &settings.model, &eligible)?;

View file

@ -34,7 +34,7 @@ pub fn materialize_run(
resolve_run_model(catalog, &eligible, model.as_deref(), provider.as_deref())?;
settings.run.model.name = Some(resolved_model);
settings.run.model.provider = resolved_provider;
settings.run.model.provider = Some(resolved_provider.into_inner());
let goal = graph.goal().to_string();
settings.run.goal = if goal.is_empty() {
@ -60,55 +60,10 @@ pub(crate) fn resolve_run_model(
eligible: &HashSet<ProviderId>,
model: Option<&str>,
provider: Option<&str>,
) -> Result<(String, Option<String>), ModelSelectionError> {
if let Some(provider) = provider.filter(|provider| !provider.is_empty()) {
let requested = ProviderId::new(provider);
let provider =
catalog
.provider(&requested)
.ok_or_else(|| ModelSelectionError::UnknownProvider {
provider: requested.clone(),
})?;
let canonical_provider = provider.id.clone();
let canonical_eligible = eligible.iter().any(|eligible| {
catalog
.provider(eligible)
.is_some_and(|provider| provider.id == canonical_provider)
});
if !canonical_eligible {
return Err(ModelSelectionError::ProviderUnavailable {
provider: canonical_provider,
});
}
if let Some(model) = model {
return match catalog.resolve_on_provider(&provider.id, model) {
Ok(offering) => Ok((offering.id.to_string(), Some(offering.provider.to_string()))),
Err(ModelSelectionError::UnknownSelectorOnProvider { .. }) => {
Ok((model.to_string(), Some(provider.id.to_string())))
}
Err(error) => Err(error),
};
}
let offering = catalog.default_for_provider(&provider.id).ok_or_else(|| {
ModelSelectionError::UnknownSelectorOnProvider {
selector: "<default model>".to_string(),
provider: provider.id.clone(),
}
})?;
return Ok((offering.id.to_string(), Some(offering.provider.to_string())));
}
if let Some(model) = model {
return match catalog.select(model, None, eligible) {
Ok(offering) => Ok((offering.id.to_string(), Some(offering.provider.to_string()))),
Err(ModelSelectionError::UnknownSelector { .. }) => {
let default = catalog.select_default(eligible)?;
Ok((model.to_string(), Some(default.provider.to_string())))
}
Err(error) => Err(error),
};
}
let default = catalog.select_default(eligible)?;
Ok((default.id.to_string(), Some(default.provider.to_string())))
) -> Result<(String, ProviderId), ModelSelectionError> {
let provider = provider
.filter(|provider| !provider.is_empty())
.map(ProviderId::new);
let selected = catalog.resolve_selection(model, provider.as_ref(), eligible)?;
Ok((selected.model, selected.provider))
}

View file

@ -2,7 +2,7 @@ use std::collections::HashSet;
use std::sync::Arc;
use fabro_graphviz::graph::{AttrValue, Graph};
use fabro_model::{Catalog, ModelSelectionError, ProviderId};
use fabro_model::{Catalog, ProviderId};
use super::Transform;
use crate::error::Error;
@ -46,24 +46,12 @@ impl ModelResolutionTransform {
model: &str,
explicit_provider: Option<&ProviderId>,
) -> Result<(String, ProviderId), Error> {
match self
.catalog
.select(model, explicit_provider, &self.eligible_providers)
{
Ok(info) => Ok((info.id.to_string(), info.provider.clone())),
Err(ModelSelectionError::UnknownSelectorOnProvider { provider, .. }) => {
Ok((model.to_string(), provider))
}
Err(ModelSelectionError::UnknownSelector { .. }) => {
let provider = self
.catalog
.select_default(&self.eligible_providers)?
.provider
.clone();
Ok((model.to_string(), provider))
}
Err(error) => Err(error.into()),
}
let selected = self.catalog.resolve_selection(
Some(model),
explicit_provider,
&self.eligible_providers,
)?;
Ok((selected.model, selected.provider))
}
}