fabro/lib/components/fabro-workflow/src/model_fallback.rs
2026-07-31 09:00:52 -04:00

573 lines
20 KiB
Rust

use std::collections::{BTreeMap, HashMap, HashSet};
use fabro_model::{
Catalog, FallbackTarget, Model, ModelSelectionError, ProviderId, ReasoningEffort,
};
use fabro_types::settings::{ModelRef, ResolvedModelRef};
use fabro_types::{RunNoticeCode, RunNoticeLevel};
use crate::Error;
/// Catalog-resolved fallback chains keyed by canonical requested model ID.
///
/// A chain is selected from the original request only. Targets never cause
/// another chain lookup.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ModelFallbackPolicy {
chains: BTreeMap<String, Vec<FallbackTarget>>,
}
impl ModelFallbackPolicy {
#[cfg(test)]
#[must_use]
pub fn new(chains: BTreeMap<String, Vec<FallbackTarget>>) -> Self {
Self { chains }
}
#[must_use]
pub fn chain_for<'a>(
&'a self,
catalog: &Catalog,
provider: &ProviderId,
model: &str,
) -> Option<&'a [FallbackTarget]> {
self.chain_for_canonical(&catalog.canonical_model_id(provider, model))
}
/// Look up a chain by an already-canonicalized requested model ID.
#[must_use]
pub fn chain_for_canonical(&self, canonical_model: &str) -> Option<&[FallbackTarget]> {
self.chains.get(canonical_model).map(Vec::as_slice)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &[FallbackTarget])> {
self.chains
.iter()
.map(|(model, chain)| (model.as_str(), chain.as_slice()))
}
#[must_use]
pub fn len(&self) -> usize {
self.chains.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.chains.is_empty()
}
}
/// Server-side result of canonicalizing and filtering configured fallback
/// chains.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ResolvedModelFallbacks {
pub policy: ModelFallbackPolicy,
pub notices: Vec<ModelFallbackNotice>,
}
/// Why a configured fallback candidate was removed from one model's chain.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ModelFallbackNotice {
ProviderUnconfigured {
requested_model: String,
reference: ModelRef,
provider: ProviderId,
},
NoConfiguredOffering {
requested_model: String,
reference: ModelRef,
providers: Vec<ProviderId>,
},
PrimaryNotInCatalog {
requested_model: String,
reference: ModelRef,
primary: FallbackTarget,
},
NoCompatibleModel {
requested_model: String,
reference: ModelRef,
provider: ProviderId,
},
Duplicate {
requested_model: String,
reference: ModelRef,
target: FallbackTarget,
},
NoNearbyReasoningLevel {
requested_model: String,
target: FallbackTarget,
requested_effort: ReasoningEffort,
},
ChainEmpty {
requested_model: String,
},
}
impl ModelFallbackNotice {
#[must_use]
pub fn code(&self) -> RunNoticeCode {
match self {
Self::ChainEmpty { .. } => RunNoticeCode::ModelFallbackChainEmpty,
Self::ProviderUnconfigured { .. }
| Self::NoConfiguredOffering { .. }
| Self::PrimaryNotInCatalog { .. }
| Self::NoCompatibleModel { .. }
| Self::Duplicate { .. }
| Self::NoNearbyReasoningLevel { .. } => RunNoticeCode::ModelFallbackSkipped,
}
}
#[must_use]
pub fn level(&self) -> RunNoticeLevel {
match self {
Self::Duplicate { .. } => RunNoticeLevel::Info,
Self::ProviderUnconfigured { .. }
| Self::NoConfiguredOffering { .. }
| Self::PrimaryNotInCatalog { .. }
| Self::NoCompatibleModel { .. }
| Self::NoNearbyReasoningLevel { .. }
| Self::ChainEmpty { .. } => RunNoticeLevel::Warn,
}
}
#[must_use]
pub fn message(&self) -> String {
match self {
Self::ProviderUnconfigured {
requested_model,
reference,
provider,
} => format!(
"Model fallback `{reference}` for requested model `{requested_model}` was skipped because provider `{provider}` is not configured."
),
Self::NoConfiguredOffering {
requested_model,
reference,
providers,
} => {
let providers = providers
.iter()
.map(ProviderId::to_string)
.collect::<Vec<_>>()
.join(", ");
format!(
"Model fallback `{reference}` for requested model `{requested_model}` was skipped because none of its providers are configured. It is offered by: {providers}."
)
}
Self::PrimaryNotInCatalog {
requested_model,
reference,
primary,
} => format!(
"Model fallback `{reference}` for requested model `{requested_model}` was skipped because `{primary}` is not in the catalog, so there is no capability profile to match against."
),
Self::NoCompatibleModel {
requested_model,
reference,
provider,
} => format!(
"Model fallback `{reference}` for requested model `{requested_model}` was skipped because provider `{provider}` has no compatible model."
),
Self::Duplicate {
requested_model,
reference,
target,
} => format!(
"Model fallback `{reference}` for requested model `{requested_model}` was skipped because target `{target}` already appears in that chain."
),
Self::NoNearbyReasoningLevel {
requested_model,
target,
requested_effort,
} => format!(
"Model fallback `{target}` for requested model `{requested_model}` was skipped because it has no reasoning level near `{requested_effort}`."
),
Self::ChainEmpty { requested_model } => format!(
"No usable model fallbacks remain for requested model `{requested_model}` after filtering its configured candidates."
),
}
}
}
/// Resolve every model-keyed fallback chain against the server's catalog and
/// configured-provider snapshot.
///
/// This function must stay at server-side call sites. Offline validation only
/// parses the raw table and cannot canonicalize model aliases.
pub fn resolve_model_fallbacks(
catalog: &Catalog,
configured_providers: &[ProviderId],
configured: &BTreeMap<String, Vec<ModelRef>>,
) -> Result<ResolvedModelFallbacks, Error> {
let eligible = configured_providers.iter().cloned().collect::<HashSet<_>>();
let mut resolved = ResolvedModelFallbacks::default();
let mut raw_key_by_canonical = HashMap::<String, String>::new();
for (raw_key, references) in configured {
require_bare_model_key(catalog, raw_key)?;
let selected =
catalog.resolve_selection_with_catalog_fallback(Some(raw_key), None, &eligible)?;
let requested_model = selected.model;
if let Some(previous) =
raw_key_by_canonical.insert(requested_model.clone(), raw_key.clone())
{
return Err(Error::Precondition(format!(
"`run.model.fallbacks` keys `{previous}` and `{raw_key}` both resolve to requested model `{requested_model}`"
)));
}
let primary = FallbackTarget::new(&selected.provider, &requested_model);
let primary_model = catalog.get_on_provider(&selected.provider, &requested_model);
let mut targets = Vec::new();
for model_ref in references {
let target = match resolve_fallback_candidate(
catalog,
&requested_model,
&primary,
primary_model,
&eligible,
model_ref,
)? {
FallbackCandidate::Skipped(notice) => {
resolved.notices.push(notice);
continue;
}
FallbackCandidate::Target(target) => target,
};
if targets.contains(&target) {
resolved.notices.push(ModelFallbackNotice::Duplicate {
requested_model: requested_model.clone(),
reference: model_ref.clone(),
target,
});
} else {
targets.push(target);
}
}
if targets.is_empty() {
resolved.notices.push(ModelFallbackNotice::ChainEmpty {
requested_model: requested_model.clone(),
});
}
resolved.policy.chains.insert(requested_model, targets);
}
Ok(resolved)
}
/// Reject chain keys that name a provider. Keys are requested-model selectors;
/// a provider-qualified key can never match a dispatch-time canonical model
/// ID, so it would be silently dead configuration.
fn require_bare_model_key(catalog: &Catalog, raw_key: &str) -> Result<(), Error> {
let reference: ModelRef = raw_key
.parse()
.map_err(|error| Error::Precondition(format!("`run.model.fallbacks` key: {error}")))?;
match reference.resolve(catalog) {
Ok(ResolvedModelRef::Model { provider: None, .. }) => Ok(()),
Ok(ResolvedModelRef::Model {
provider: Some(_),
selector,
}) => Err(Error::Precondition(format!(
"`run.model.fallbacks` keys name a requested model; use `{selector}` instead of `{raw_key}`"
))),
Ok(ResolvedModelRef::Provider(provider)) => Err(Error::Precondition(format!(
"`run.model.fallbacks` key `{raw_key}` names provider `{provider}`; keys must name a requested model"
))),
Err(ambiguous) => Err(Error::Precondition(format!(
"`run.model.fallbacks` key: {ambiguous}"
))),
}
}
enum FallbackCandidate {
Target(FallbackTarget),
Skipped(ModelFallbackNotice),
}
fn resolve_fallback_candidate(
catalog: &Catalog,
requested_model: &str,
primary: &FallbackTarget,
primary_model: Option<&Model>,
eligible: &HashSet<ProviderId>,
model_ref: &ModelRef,
) -> Result<FallbackCandidate, Error> {
let reference = model_ref.clone();
Ok(match model_ref.resolve(catalog)? {
ResolvedModelRef::Provider(provider_name) => {
let provider = catalog.provider_id(&provider_name)?;
if !eligible.contains(&provider) {
return Ok(FallbackCandidate::Skipped(
ModelFallbackNotice::ProviderUnconfigured {
requested_model: requested_model.to_string(),
reference,
provider,
},
));
}
let Some(primary_model) = primary_model else {
return Ok(FallbackCandidate::Skipped(
ModelFallbackNotice::PrimaryNotInCatalog {
requested_model: requested_model.to_string(),
reference,
primary: primary.clone(),
},
));
};
match catalog.closest(&provider, primary_model) {
Some(model) => FallbackCandidate::Target(FallbackTarget::new(provider, &model.id)),
None => FallbackCandidate::Skipped(ModelFallbackNotice::NoCompatibleModel {
requested_model: requested_model.to_string(),
reference,
provider,
}),
}
}
ResolvedModelRef::Model {
provider: Some(provider_name),
selector,
} => {
let provider = catalog.provider_id(&provider_name)?;
if !eligible.contains(&provider) {
return Ok(FallbackCandidate::Skipped(
ModelFallbackNotice::ProviderUnconfigured {
requested_model: requested_model.to_string(),
reference,
provider,
},
));
}
match catalog.resolve_on_provider(&provider, &selector) {
Ok(info) => {
FallbackCandidate::Target(FallbackTarget::new(&info.provider, &info.id))
}
Err(ModelSelectionError::UnknownSelectorOnProvider { .. }) => {
FallbackCandidate::Target(FallbackTarget::new(provider, selector))
}
Err(error) => return Err(error.into()),
}
}
ResolvedModelRef::Model {
provider: None,
selector,
} => match catalog.select(&selector, None, eligible) {
Ok(info) => FallbackCandidate::Target(FallbackTarget::new(&info.provider, &info.id)),
Err(ModelSelectionError::NoEligibleOffering { providers, .. }) => {
FallbackCandidate::Skipped(ModelFallbackNotice::NoConfiguredOffering {
requested_model: requested_model.to_string(),
reference,
providers,
})
}
Err(ModelSelectionError::UnknownSelector { .. }) => {
FallbackCandidate::Target(FallbackTarget::new(&primary.provider, selector))
}
Err(error) => return Err(error.into()),
},
})
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use fabro_model::{Catalog, FallbackTarget, ProviderId};
use super::{ModelFallbackNotice, resolve_model_fallbacks};
fn references(values: &[&str]) -> Vec<fabro_types::settings::ModelRef> {
values
.iter()
.map(|value| value.parse().expect("fixture reference should parse"))
.collect()
}
fn openrouter_catalog() -> Catalog {
let overrides = toml::from_str(
r"
[providers.openrouter]
enabled = true
",
)
.expect("catalog override should parse");
Catalog::from_builtin_with_overrides(&overrides).expect("catalog should build")
}
#[test]
fn canonicalizes_keys_and_keeps_each_chain_independent() {
let catalog = openrouter_catalog();
let eligible = [ProviderId::new("openrouter")];
let configured = BTreeMap::from([
("gpt-sol".to_string(), references(&["claude-opus"])),
(
"claude-fable".to_string(),
references(&["gpt-sol", "claude-opus"]),
),
]);
let resolved = resolve_model_fallbacks(&catalog, &eligible, &configured).unwrap();
assert_eq!(
resolved
.policy
.chain_for(&catalog, &ProviderId::new("openrouter"), "gpt-sol"),
Some([FallbackTarget::new("openrouter", "claude-opus-5")].as_slice())
);
assert_eq!(
resolved
.policy
.chain_for(&catalog, &ProviderId::new("openrouter"), "claude-fable"),
Some(
[
FallbackTarget::new("openrouter", "gpt-5.6-sol"),
FallbackTarget::new("openrouter", "claude-opus-5"),
]
.as_slice()
)
);
}
#[test]
fn rejects_aliases_that_define_the_same_requested_model_twice() {
let catalog = openrouter_catalog();
let eligible = [ProviderId::new("openrouter")];
let configured = BTreeMap::from([
("gpt-sol".to_string(), references(&["claude-opus"])),
("gpt-5.6-sol".to_string(), references(&["claude-fable"])),
]);
let error = resolve_model_fallbacks(&catalog, &eligible, &configured).unwrap_err();
assert!(
error
.to_string()
.contains("both resolve to requested model"),
"unexpected error: {error}"
);
}
#[test]
fn rejects_provider_qualified_keys() {
let catalog = openrouter_catalog();
let eligible = [ProviderId::new("openrouter")];
let configured = BTreeMap::from([(
"openrouter:gpt-sol".to_string(),
references(&["claude-opus"]),
)]);
let error = resolve_model_fallbacks(&catalog, &eligible, &configured).unwrap_err();
assert!(
error.to_string().contains("keys name a requested model"),
"unexpected error: {error}"
);
}
#[test]
fn skips_unconfigured_candidates_per_requested_model() {
let catalog = openrouter_catalog();
let eligible = [ProviderId::new("openrouter")];
let configured = BTreeMap::from([(
"kimi-k3".to_string(),
references(&["moonshot:kimi-k3", "openrouter:kimi-k3"]),
)]);
let resolved = resolve_model_fallbacks(&catalog, &eligible, &configured).unwrap();
assert_eq!(
resolved
.policy
.chain_for(&catalog, &ProviderId::new("openrouter"), "kimi-k3"),
Some([FallbackTarget::new("openrouter", "kimi-k3")].as_slice())
);
assert!(matches!(
resolved.notices.as_slice(),
[ModelFallbackNotice::ProviderUnconfigured {
requested_model,
provider,
..
}] if requested_model == "kimi-k3" && provider == &ProviderId::new("moonshot")
));
}
#[test]
fn resolves_the_requested_production_policy_as_independent_chains() {
let catalog = {
let overrides = toml::from_str(
r"
[providers.modal]
enabled = true
[providers.openrouter]
enabled = true
",
)
.expect("catalog override should parse");
Catalog::from_builtin_with_overrides(&overrides).expect("catalog should build")
};
let eligible = [
ProviderId::new("modal"),
ProviderId::new("moonshot"),
ProviderId::new("openrouter"),
];
let configured = BTreeMap::from([
(
"kimi-k3".to_string(),
references(&["moonshot:kimi-k3", "openrouter:kimi-k3", "claude-opus"]),
),
("glm-5.2".to_string(), references(&["gpt-sol"])),
("gpt-sol".to_string(), references(&["claude-opus"])),
("claude-opus".to_string(), references(&["gpt-sol"])),
("gpt-terra".to_string(), references(&["claude-opus"])),
("gpt-luna".to_string(), references(&["claude-sonnet"])),
(
"claude-fable".to_string(),
references(&["gpt-sol", "claude-opus"]),
),
]);
let resolved = resolve_model_fallbacks(&catalog, &eligible, &configured).unwrap();
assert!(resolved.notices.is_empty());
let chain = |model: &str| {
resolved
.policy
.chain_for(&catalog, &ProviderId::new("openrouter"), model)
.expect("requested model should have a chain")
};
assert_eq!(chain("kimi-k3"), [
FallbackTarget::new("moonshot", "kimi-k3"),
FallbackTarget::new("openrouter", "kimi-k3"),
FallbackTarget::new("openrouter", "claude-opus-5"),
]);
assert_eq!(chain("glm-5.2"), [FallbackTarget::new(
"openrouter",
"gpt-5.6-sol"
)]);
assert_eq!(chain("gpt-sol"), [FallbackTarget::new(
"openrouter",
"claude-opus-5"
)]);
assert_eq!(chain("claude-opus"), [FallbackTarget::new(
"openrouter",
"gpt-5.6-sol"
)]);
assert_eq!(chain("gpt-terra"), [FallbackTarget::new(
"openrouter",
"claude-opus-5"
)]);
assert_eq!(chain("gpt-luna"), [FallbackTarget::new(
"openrouter",
"claude-sonnet-5"
)]);
assert_eq!(chain("claude-fable"), [
FallbackTarget::new("openrouter", "gpt-5.6-sol"),
FallbackTarget::new("openrouter", "claude-opus-5"),
]);
}
}