Merge pull request #684 from fabro-sh/feat/portable-model-fallbacks

feat(workflow): make model fallback chains portable
This commit is contained in:
Bryan Helmkamp 2026-07-30 06:42:53 -04:00 committed by GitHub
commit 2efcc72124
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 594 additions and 100 deletions

View file

@ -7,7 +7,7 @@ use fabro_auth::{CredentialSource, VaultCredentialSource};
use fabro_interview::{AutoApproveInterviewer, Interviewer};
use fabro_llm::client::Client as LlmClient;
use fabro_mcp::config::McpServerSettings;
use fabro_model::{Catalog, FallbackTarget, ModelSelectionError, ProviderId};
use fabro_model::{Catalog, FallbackTarget, Model, ModelSelectionError, ProviderId};
use fabro_sandbox::daytona::DaytonaConfig;
use fabro_sandbox::from_environment::{
daytona_config_from_environment, docker_config_from_environment_with_secrets,
@ -15,12 +15,12 @@ use fabro_sandbox::from_environment::{
};
use fabro_sandbox::{DockerSandboxOptions, SandboxSpec};
use fabro_static::EnvVars;
use fabro_types::settings::ResolvedModelRef;
use fabro_types::settings::run::{
ApprovalMode, McpServerSettings as ResolvedMcpServerSettings, PullRequestSettings,
ResolvedMcpEntry, RunMode, RunModelSettings as ResolvedRunModelSettings,
RunNamespace as ResolvedRunSettings, RunPrepareSettings as ResolvedRunPrepareSettings,
};
use fabro_types::settings::{ModelRef, ResolvedModelRef};
use fabro_types::{ManifestPath, RunId, RunRunnableSource, SandboxProviderKind};
use fabro_vault::Vault;
use tokio::runtime::Handle;
@ -32,7 +32,8 @@ use crate::artifact_upload::ArtifactSink;
use crate::context::Context;
use crate::error::{self, Error};
use crate::event::{
Emitter, Event, EventBody, RunEventLogger, RunEventSink, RunNoticeLevel, append_event_to_sink,
Emitter, Event, EventBody, RunEventLogger, RunEventSink, RunNoticeCode, RunNoticeLevel,
append_event_to_sink,
};
use crate::handler::HandlerRegistry;
use crate::outcome::{Outcome, StageOutcome};
@ -59,6 +60,7 @@ struct RunSession {
emitter: Arc<Emitter>,
sandbox: SandboxSpec,
llm: LlmSpec,
fallback_notices: Vec<ModelFallbackNotice>,
interviewer: Arc<dyn Interviewer>,
steering_hub: Arc<SteeringHub>,
on_node: crate::OnNodeCallback,
@ -87,9 +89,134 @@ struct RunSession {
}
struct ResolvedStartLlm {
model: String,
provider_id: ProviderId,
fallback_chain: Vec<FallbackTarget>,
model: String,
provider_id: ProviderId,
fallbacks: ResolvedFallbackChain,
}
#[derive(Debug, Default, PartialEq, Eq)]
struct ResolvedFallbackChain {
targets: Vec<FallbackTarget>,
notices: Vec<ModelFallbackNotice>,
}
/// Why one fallback candidate did not make it into the chain, or that the whole
/// chain came out empty. Resolution happens before the run's event sink is
/// wired up, so these are carried to [`RunSession::run`] and emitted there.
#[derive(Debug, PartialEq, Eq)]
enum ModelFallbackNotice {
ProviderUnconfigured {
reference: ModelRef,
provider: ProviderId,
},
NoConfiguredOffering {
reference: ModelRef,
providers: Vec<ProviderId>,
},
/// The candidate named a provider but no model, and the primary model is
/// not in the catalog, so there is nothing to match its capabilities to.
PrimaryNotInCatalog {
reference: ModelRef,
primary: FallbackTarget,
},
NoCompatibleModel {
reference: ModelRef,
provider: ProviderId,
},
MatchesPrimary {
reference: ModelRef,
target: FallbackTarget,
},
Duplicate {
reference: ModelRef,
target: FallbackTarget,
},
ChainEmpty,
}
impl ModelFallbackNotice {
fn code(&self) -> RunNoticeCode {
match self {
Self::ChainEmpty => RunNoticeCode::ModelFallbackChainEmpty,
Self::ProviderUnconfigured { .. }
| Self::NoConfiguredOffering { .. }
| Self::PrimaryNotInCatalog { .. }
| Self::NoCompatibleModel { .. }
| Self::MatchesPrimary { .. }
| Self::Duplicate { .. } => RunNoticeCode::ModelFallbackSkipped,
}
}
fn level(&self) -> RunNoticeLevel {
match self {
Self::MatchesPrimary { .. } | Self::Duplicate { .. } => RunNoticeLevel::Info,
Self::ProviderUnconfigured { .. }
| Self::NoConfiguredOffering { .. }
| Self::PrimaryNotInCatalog { .. }
| Self::NoCompatibleModel { .. }
| Self::ChainEmpty => RunNoticeLevel::Warn,
}
}
fn message(&self) -> String {
match self {
Self::ProviderUnconfigured {
reference,
provider,
} => {
format!(
"Model fallback `{reference}` was skipped because provider `{provider}` is not configured."
)
}
Self::NoConfiguredOffering {
reference,
providers,
} => {
let providers = providers
.iter()
.map(ProviderId::to_string)
.collect::<Vec<_>>()
.join(", ");
format!(
"Model fallback `{reference}` was skipped because none of its providers are configured. It is offered by: {providers}."
)
}
Self::PrimaryNotInCatalog { reference, primary } => {
format!(
"Model fallback `{reference}` was skipped because the primary model `{primary}` is not in the catalog, so there is no capability profile to match against."
)
}
Self::NoCompatibleModel {
reference,
provider,
} => {
format!(
"Model fallback `{reference}` was skipped because provider `{provider}` has no compatible model."
)
}
Self::MatchesPrimary { reference, target } => {
format!(
"Model fallback `{reference}` was skipped because it resolves to the primary target `{target}`."
)
}
Self::Duplicate { reference, target } => {
format!(
"Model fallback `{reference}` was skipped because target `{target}` already appears in the fallback chain."
)
}
Self::ChainEmpty => {
"No usable model fallbacks remain after filtering the configured fallback candidates."
.to_string()
}
}
}
/// Publish every notice on the run's event stream.
fn emit_all(notices: &[Self], emitter: &Emitter) {
for notice in notices {
emitter.notice(notice.level(), notice.code(), notice.message());
}
}
}
pub struct StartServices {
@ -477,11 +604,12 @@ impl RunSession {
llm: LlmSpec {
model: llm.model.clone(),
provider_id: llm.provider_id.clone(),
fallback_chain: llm.fallback_chain,
fallback_chain: llm.fallbacks.targets,
mcp_servers,
model_controls: resolved.model.controls.clone(),
dry_run: resolved.execution.mode == RunMode::DryRun,
},
fallback_notices: llm.fallbacks.notices,
interviewer,
steering_hub: services.steering_hub,
on_node: services.on_node,
@ -615,96 +743,174 @@ fn resolve_start_llm(
settings.model.provider.as_deref(),
false,
)?;
let fallback_chain =
let fallbacks =
resolve_fallback_chain(catalog, &provider_id, &model, &settings.model, &eligible)?;
Ok(ResolvedStartLlm {
model,
provider_id,
fallback_chain,
fallbacks,
})
}
/// Resolve fallback candidates against the configured provider snapshot.
///
/// Candidates that cannot be used in this environment — an unconfigured
/// provider, no compatible model, a target equal to the primary, or a duplicate
/// — are dropped, and each drop records a [`ModelFallbackNotice`] that the run
/// emits at startup. Remaining candidates keep their configured order.
///
/// A provider the catalog has never heard of is a different case: that is a
/// typo rather than an environment difference, so it fails the run instead of
/// being skipped. This is what keeps a chain portable without letting a
/// misspelled provider silently disappear.
fn resolve_fallback_chain(
catalog: &Catalog,
provider: &ProviderId,
model: &str,
settings: &ResolvedRunModelSettings,
eligible: &HashSet<ProviderId>,
) -> Result<Vec<FallbackTarget>, Error> {
) -> Result<ResolvedFallbackChain, Error> {
if settings.fallbacks.is_empty() {
return Ok(Vec::new());
return Ok(ResolvedFallbackChain::default());
}
let primary = catalog.get_on_provider(provider, model);
let mut chain = Vec::new();
let primary_model = catalog.get_on_provider(provider, model);
let primary = FallbackTarget::new(provider, model);
let mut resolution = ResolvedFallbackChain::default();
for model_ref in &settings.fallbacks {
match model_ref.resolve(catalog)? {
ResolvedModelRef::Provider(provider_name) => {
let provider_id = canonical_provider_id(catalog, &provider_name);
if !eligible.contains(&provider_id) {
return Err(ModelSelectionError::ProviderUnavailable {
provider: provider_id,
}
.into());
}
if let Some(model) =
primary.and_then(|reference| catalog.closest(&provider_id, reference))
{
chain.push(FallbackTarget {
provider: provider_id.to_string(),
model: model.id.to_string(),
});
}
}
ResolvedModelRef::Model {
provider: fallback_provider,
selector,
} => {
if let Some(provider) = fallback_provider {
let provider = canonical_provider_id(catalog, &provider);
if !eligible.contains(&provider) {
return Err(ModelSelectionError::ProviderUnavailable { provider }.into());
}
match catalog.resolve_on_provider(&provider, &selector) {
Ok(info) => chain.push(FallbackTarget {
provider: info.provider.to_string(),
model: info.id.to_string(),
}),
Err(ModelSelectionError::UnknownSelectorOnProvider { .. }) => {
chain.push(FallbackTarget {
provider: provider.to_string(),
model: selector,
});
}
Err(error) => return Err(error.into()),
}
} else {
match catalog.select(&selector, None, eligible) {
Ok(info) => chain.push(FallbackTarget {
provider: info.provider.to_string(),
model: info.id.to_string(),
}),
Err(ModelSelectionError::UnknownSelector { .. }) => {
chain.push(FallbackTarget {
provider: provider.to_string(),
model: selector,
});
}
Err(error) => return Err(error.into()),
}
}
let target = match resolve_fallback_candidate(
catalog,
&primary,
primary_model,
eligible,
model_ref,
)? {
FallbackCandidate::Skipped(notice) => {
resolution.notices.push(notice);
continue;
}
FallbackCandidate::Target(target) => target,
};
let reference = model_ref.clone();
if target == primary {
resolution
.notices
.push(ModelFallbackNotice::MatchesPrimary { reference, target });
} else if resolution.targets.contains(&target) {
resolution
.notices
.push(ModelFallbackNotice::Duplicate { reference, target });
} else {
resolution.targets.push(target);
}
}
Ok(chain)
if resolution.targets.is_empty() {
resolution.notices.push(ModelFallbackNotice::ChainEmpty);
}
Ok(resolution)
}
fn canonical_provider_id(catalog: &Catalog, provider_name: &str) -> ProviderId {
let provider_id = ProviderId::from(provider_name);
catalog
.provider(&provider_id)
.map_or(provider_id, |provider| provider.id.clone())
/// The outcome of resolving one fallback candidate: either a dispatchable
/// target or the reason the candidate cannot be used.
enum FallbackCandidate {
Target(FallbackTarget),
Skipped(ModelFallbackNotice),
}
/// Resolve one fallback reference against the configured provider snapshot.
///
/// `primary_model` is the primary's catalog entry, used to pick the closest
/// capability match when a candidate names a provider but no model. It is
/// `None` when the primary is itself a passthrough selector.
///
/// A selector the catalog does not know passes through verbatim so a model
/// newer than the catalog still dispatches. When the candidate named a
/// provider, it passes through on that provider; when it did not, it passes
/// through on the primary's provider, which means such a fallback gives no
/// cross-provider failover.
fn resolve_fallback_candidate(
catalog: &Catalog,
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 {
reference,
provider,
},
));
}
// Without a catalog entry for the primary there is no capability
// profile to match against, which is not the provider's fault.
let Some(primary_model) = primary_model else {
return Ok(FallbackCandidate::Skipped(
ModelFallbackNotice::PrimaryNotInCatalog {
reference,
primary: primary.clone(),
},
));
};
match catalog.closest(&provider, primary_model) {
Some(model) => FallbackCandidate::Target(FallbackTarget::new(provider, &model.id)),
None => FallbackCandidate::Skipped(ModelFallbackNotice::NoCompatibleModel {
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 {
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 {
reference,
providers,
})
}
Err(ModelSelectionError::UnknownSelector { .. }) => {
FallbackCandidate::Target(FallbackTarget::new(&primary.provider, selector))
}
Err(error) => return Err(error.into()),
},
})
}
/// Build the launch-time MCP config from resolved settings. Secret tokens in
@ -827,6 +1033,9 @@ impl RunSession {
let store_progress_logger = RunEventLogger::new(self.event_sink.clone());
store_progress_logger.register(self.emitter.as_ref());
// Emit after the logger is registered so the notices reach the run
// store, and before `run.started` so they read as launch-time context.
ModelFallbackNotice::emit_all(&self.fallback_notices, self.emitter.as_ref());
let init_options = InitOptions {
run_store: self.run_store.clone(),
@ -1257,6 +1466,19 @@ tools = true
vision = false
reasoning = false
[providers.openai.models."gpt-5.4-mini"]
display_name = "GPT-5.4 Mini"
family = "gpt-5"
aliases = ["mini"]
[providers.openai.models."gpt-5.4-mini".limits]
context_window = 1000
[providers.openai.models."gpt-5.4-mini".features]
tools = true
vision = false
reasoning = false
[providers.openrouter]
display_name = "OpenRouter"
adapter = "openai_compatible"
@ -1283,6 +1505,253 @@ reasoning = false
Catalog::from_settings(&settings).unwrap()
}
#[test]
fn resolve_start_llm_infers_primary_and_filters_global_fallbacks() {
let catalog = portable_model_catalog();
let mut settings = ResolvedRunSettings::default();
settings.model.name = Some("gpt-56-sol".to_string());
settings.model.fallbacks = vec![
"openai:gpt-56-sol".parse::<ModelRef>().unwrap(),
"openrouter:gpt-56-sol".parse::<ModelRef>().unwrap(),
"openrouter:openai/gpt-5.6-sol".parse::<ModelRef>().unwrap(),
];
let resolved = resolve_start_llm(
&catalog,
&[ProviderId::new("openrouter"), ProviderId::openai()],
&settings,
)
.unwrap();
assert_eq!(resolved.provider_id, ProviderId::openai());
assert_eq!(resolved.model, "gpt-5.6-sol");
assert_eq!(resolved.fallbacks.targets, vec![FallbackTarget {
provider: "openrouter".to_string(),
model: "gpt-5.6-sol".to_string(),
}]);
assert_eq!(resolved.fallbacks.notices, vec![
ModelFallbackNotice::MatchesPrimary {
reference: "openai:gpt-56-sol".parse().unwrap(),
target: FallbackTarget {
provider: "openai".to_string(),
model: "gpt-5.6-sol".to_string(),
},
},
ModelFallbackNotice::Duplicate {
reference: "openrouter:openai/gpt-5.6-sol".parse().unwrap(),
target: FallbackTarget {
provider: "openrouter".to_string(),
model: "gpt-5.6-sol".to_string(),
},
},
]);
}
#[test]
fn resolve_fallback_chain_skips_unconfigured_provider_and_preserves_order() {
let catalog = test_catalog();
let settings = ResolvedRunModelSettings {
fallbacks: vec![
"gemini".parse::<ModelRef>().unwrap(),
"gemini:unused".parse::<ModelRef>().unwrap(),
"openai:gpt-5.4-mini".parse::<ModelRef>().unwrap(),
"anthropic:claude-fable-5".parse::<ModelRef>().unwrap(),
],
..ResolvedRunModelSettings::default()
};
let resolution = resolve_fallback_chain(
catalog.as_ref(),
&ProviderId::anthropic(),
"claude-opus-4-6",
&settings,
&HashSet::from([ProviderId::anthropic(), ProviderId::openai()]),
)
.unwrap();
assert_eq!(resolution.targets, vec![
FallbackTarget {
provider: "openai".to_string(),
model: "gpt-5.4-mini".to_string(),
},
FallbackTarget {
provider: "anthropic".to_string(),
model: "claude-fable-5".to_string(),
},
]);
assert_eq!(resolution.notices, vec![
ModelFallbackNotice::ProviderUnconfigured {
reference: "gemini".parse().unwrap(),
provider: ProviderId::gemini(),
},
ModelFallbackNotice::ProviderUnconfigured {
reference: "gemini:unused".parse().unwrap(),
provider: ProviderId::gemini(),
},
]);
assert_eq!(resolution.notices[0].level(), RunNoticeLevel::Warn);
assert_eq!(
resolution.notices[0].code(),
RunNoticeCode::ModelFallbackSkipped
);
}
/// The resolver builds notices before the run's event sink exists, so this
/// covers the hand-off: each notice must reach the event stream as a
/// `run.notice` carrying its own level, code, and rendered message.
#[test]
fn fallback_notices_reach_the_event_stream() {
let emitter = Arc::new(Emitter::new(fixtures::RUN_1));
let captured = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&captured);
emitter.on_event(move |event| sink.lock().unwrap().push(event.clone()));
let notices = vec![
ModelFallbackNotice::ProviderUnconfigured {
reference: "gemini".parse().unwrap(),
provider: ProviderId::gemini(),
},
ModelFallbackNotice::MatchesPrimary {
reference: "openai:gpt-5.6-sol".parse().unwrap(),
target: FallbackTarget::new("openai", "gpt-5.6-sol"),
},
ModelFallbackNotice::ChainEmpty,
];
ModelFallbackNotice::emit_all(&notices, emitter.as_ref());
let events = captured.lock().unwrap();
let emitted = events
.iter()
.map(|event| match &event.body {
EventBody::RunNotice(props) => {
(props.level, props.code.clone(), props.message.clone())
}
other => panic!("expected run.notice body, got {other:?}"),
})
.collect::<Vec<_>>();
assert_eq!(emitted, vec![
(
RunNoticeLevel::Warn,
RunNoticeCode::ModelFallbackSkipped.to_string(),
"Model fallback `gemini` was skipped because provider `gemini` is not configured."
.to_string(),
),
(
RunNoticeLevel::Info,
RunNoticeCode::ModelFallbackSkipped.to_string(),
"Model fallback `openai:gpt-5.6-sol` was skipped because it resolves to the primary target `openai:gpt-5.6-sol`."
.to_string(),
),
(
RunNoticeLevel::Warn,
RunNoticeCode::ModelFallbackChainEmpty.to_string(),
"No usable model fallbacks remain after filtering the configured fallback candidates."
.to_string(),
),
]);
}
/// A provider-only fallback cannot be matched when the primary model is a
/// passthrough selector, because there is no capability profile to compare
/// against. The notice must name that cause rather than blaming the
/// provider, which may well have compatible models.
#[test]
fn resolve_fallback_chain_blames_missing_primary_not_the_fallback_provider() {
let catalog = portable_model_catalog();
let settings = ResolvedRunModelSettings {
fallbacks: vec!["openrouter".parse::<ModelRef>().unwrap()],
..ResolvedRunModelSettings::default()
};
let resolution = resolve_fallback_chain(
&catalog,
&ProviderId::openai(),
"gpt-5.9-not-in-catalog",
&settings,
&HashSet::from([ProviderId::openai(), ProviderId::new("openrouter")]),
)
.unwrap();
assert!(resolution.targets.is_empty());
assert_eq!(resolution.notices, vec![
ModelFallbackNotice::PrimaryNotInCatalog {
reference: "openrouter".parse().unwrap(),
primary: FallbackTarget::new("openai", "gpt-5.9-not-in-catalog"),
},
ModelFallbackNotice::ChainEmpty,
]);
let message = resolution.notices[0].message();
assert!(
message.contains("primary model `openai:gpt-5.9-not-in-catalog` is not in the catalog"),
"notice should name the missing primary: {message}"
);
}
#[test]
fn resolve_fallback_chain_skips_model_without_configured_offering() {
let catalog = portable_model_catalog();
let settings = ResolvedRunModelSettings {
fallbacks: vec!["mini".parse::<ModelRef>().unwrap()],
..ResolvedRunModelSettings::default()
};
let resolution = resolve_fallback_chain(
&catalog,
&ProviderId::new("openrouter"),
"gpt-5.6-sol",
&settings,
&HashSet::from([ProviderId::new("openrouter")]),
)
.unwrap();
assert!(resolution.targets.is_empty());
assert_eq!(resolution.notices, vec![
ModelFallbackNotice::NoConfiguredOffering {
reference: "mini".parse().unwrap(),
providers: vec![ProviderId::openai()],
},
ModelFallbackNotice::ChainEmpty,
]);
assert_eq!(
resolution.notices[1].code(),
RunNoticeCode::ModelFallbackChainEmpty
);
assert_eq!(resolution.notices[1].level(), RunNoticeLevel::Warn);
assert!(
resolution.notices[0]
.message()
.contains("offered by: openai"),
"notice should name the providers that offer the model: {}",
resolution.notices[0].message()
);
}
#[test]
fn resolve_fallback_chain_rejects_unknown_qualified_provider() {
let catalog = portable_model_catalog();
let settings = ResolvedRunModelSettings {
fallbacks: vec!["missing/model".parse::<ModelRef>().unwrap()],
..ResolvedRunModelSettings::default()
};
let error = resolve_fallback_chain(
&catalog,
&ProviderId::openai(),
"gpt-5.6-sol",
&settings,
&catalog.all_provider_ids(),
)
.unwrap_err();
assert!(matches!(
error,
Error::ModelSelection(ModelSelectionError::UnknownProvider { provider })
if provider == ProviderId::new("missing")
));
}
#[test]
fn resolve_fallback_chain_resolves_provider_fallbacks() {
let catalog = test_catalog();
@ -1300,7 +1769,7 @@ reasoning = false
)
.unwrap();
assert_eq!(chain, vec![FallbackTarget {
assert_eq!(chain.targets, vec![FallbackTarget {
provider: "openai".to_string(),
model: "gpt-5.5".to_string(),
}]);
@ -1323,7 +1792,7 @@ reasoning = false
)
.unwrap();
assert_eq!(chain, vec![FallbackTarget {
assert_eq!(chain.targets, vec![FallbackTarget {
provider: "openai".to_string(),
model: "gpt-5.4-mini".to_string(),
}]);
@ -1346,7 +1815,7 @@ reasoning = false
)
.unwrap();
assert_eq!(chain, vec![FallbackTarget {
assert_eq!(chain.targets, vec![FallbackTarget {
provider: "openrouter".to_string(),
model: "gpt-5.6-sol".to_string(),
}]);
@ -1369,7 +1838,7 @@ reasoning = false
)
.unwrap();
assert_eq!(chain, vec![FallbackTarget {
assert_eq!(chain.targets, vec![FallbackTarget {
provider: "openrouter".to_string(),
model: "gpt-5.6-sol".to_string(),
}]);
@ -1412,7 +1881,7 @@ enabled = true
.unwrap();
assert_eq!(
chain,
chain.targets,
vec![
FallbackTarget {
provider: "openrouter".to_string(),
@ -1448,7 +1917,7 @@ enabled = true
)
.unwrap();
assert_eq!(chain, vec![FallbackTarget {
assert_eq!(chain.targets, vec![FallbackTarget {
provider: ProviderId::openai().to_string(),
model: "future-model:latest".to_string(),
}]);
@ -1474,7 +1943,7 @@ enabled = true
)
.unwrap();
assert_eq!(chain, vec![
assert_eq!(chain.targets, vec![
FallbackTarget {
provider: "openai".to_string(),
model: "gpt-5.6-sol".to_string(),

View file

@ -405,6 +405,26 @@ pub struct FallbackTarget {
pub model: String,
}
impl FallbackTarget {
/// Build a target from anything that renders as a provider name and model
/// ID, so callers holding [`ProviderId`]/[`ModelId`] or bare passthrough
/// selectors all use one constructor.
pub fn new(provider: impl std::fmt::Display, model: impl std::fmt::Display) -> Self {
Self {
provider: provider.to_string(),
model: model.to_string(),
}
}
}
impl std::fmt::Display for FallbackTarget {
/// Renders as `provider:model`, matching the qualified form accepted by
/// model references.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}", self.provider, self.model)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CatalogProvider {
pub id: ProviderId,
@ -910,17 +930,30 @@ impl Catalog {
.and_then(|idx| self.models.get(*idx))
}
/// Look up a provider by ID or alias, failing when the catalog has no such
/// provider.
pub fn require_provider(
&self,
provider: &ProviderId,
) -> Result<&CatalogProvider, ModelSelectionError> {
self.provider(provider)
.ok_or_else(|| ModelSelectionError::UnknownProvider {
provider: provider.clone(),
})
}
/// Canonicalize a provider name or alias to its catalog ID.
pub fn provider_id(&self, name: &str) -> Result<ProviderId, ModelSelectionError> {
Ok(self.require_provider(&ProviderId::from(name))?.id.clone())
}
/// Resolve a canonical ID, alias, or API ID on exactly one provider.
pub fn resolve_on_provider(
&self,
provider: &ProviderId,
selector: &str,
) -> Result<&Model, ModelSelectionError> {
let provider =
self.provider(provider)
.ok_or_else(|| ModelSelectionError::UnknownProvider {
provider: provider.clone(),
})?;
let provider = self.require_provider(provider)?;
if let Some(model) = self.get_on_provider(&provider.id, selector) {
return Ok(model);
}
@ -951,11 +984,7 @@ impl Catalog {
.collect::<HashSet<_>>();
if let Some(explicit_provider) = explicit_provider {
let provider = self.provider(explicit_provider).ok_or_else(|| {
ModelSelectionError::UnknownProvider {
provider: explicit_provider.clone(),
}
})?;
let provider = self.require_provider(explicit_provider)?;
if !eligible.contains(&provider.id) {
return Err(ModelSelectionError::ProviderUnavailable {
provider: provider.id.clone(),
@ -1056,11 +1085,7 @@ impl Catalog {
provider: &ProviderId,
eligible_providers: &HashSet<ProviderId>,
) -> Result<ProviderId, ModelSelectionError> {
let provider =
self.provider(provider)
.ok_or_else(|| ModelSelectionError::UnknownProvider {
provider: provider.clone(),
})?;
let provider = self.require_provider(provider)?;
let ready = eligible_providers.iter().any(|eligible| {
self.provider(eligible)
.is_some_and(|eligible| eligible.id == provider.id)
@ -1482,10 +1507,8 @@ impl Catalog {
.iter()
.filter_map(|provider_str| {
let provider = ProviderId::from(provider_str.clone());
self.closest(&provider, reference).map(|m| FallbackTarget {
provider: provider_str.clone(),
model: m.id.to_string(),
})
self.closest(&provider, reference)
.map(|m| FallbackTarget::new(provider_str, &m.id))
})
.collect()
}

View file

@ -30,6 +30,8 @@ pub enum RunNoticeCode {
GitPushFailed,
GithubTokenFailed,
GithubTokenRefreshLimited,
ModelFallbackChainEmpty,
ModelFallbackSkipped,
PullRequestFailed,
SandboxCleanupFailed,
SandboxGitUnavailable,