--- a/src/algorithms/fall_through.rs +++ b/src/algorithms/fall_through.rs @@ -1016,0 +1017,43 @@ + async fn automatic_fallback_keeps_capability_and_effort_floor() -> Result<()> { + for (frontier, alternate, lower_role) in [ + ( + "route/codex/auto-frontier/xhigh", + "route/claude/auto-frontier/xhigh", + "route/codex/auto-advanced/xhigh", + ), + ( + "worker/codex/auto-frontier/xhigh", + "worker/claude/auto-frontier/xhigh", + "worker/codex/auto-advanced/xhigh", + ), + ] { + let router = FallThrough::<()>::new(target_set_with_overflow( + &[frontier, lower_role, alternate], + &[frontier], + )) + .with_classifier(fixed(vec![score(frontier, 1.0)])); + + let (model, _) = run(router).await?; + assert_eq!(model, alternate); + } + Ok(()) + } + + #[tokio::test] + async fn automatic_fallback_does_not_reset_its_floor_after_an_alternate_fails() { + let frontier = "route/codex/auto-frontier/xhigh"; + let alternate = "route/claude/auto-frontier/xhigh"; + let lower_role = "route/codex/auto-advanced/xhigh"; + let calls = Arc::new(Mutex::new(Vec::new())); + let router = FallThrough::<()>::new(counting_overflow_targets( + &[frontier, lower_role, alternate], + &[frontier, alternate], + Arc::clone(&calls), + )) + .with_classifier(fixed(vec![score(frontier, 1.0)])); + + assert!(run(router).await.is_err()); + assert_eq!(&*calls.lock(), &[frontier, alternate]); + } + + #[tokio::test] --- a/src/core/algorithm.rs +++ b/src/core/algorithm.rs @@ -396,2 +396,8 @@ - /// The named target, or the first one this request is not barred from when it has been - /// excluded (see [`Context::exclude_target`]). Errors if every target is excluded. + /// The named target, or the first compatible target not barred for this request. + /// + /// Automatic capability selectors have two independent components: the + /// stable selector (`auto-frontier`, for example) and the effort floor. + /// A provider outage may switch provider, but it must not turn that target + /// into an unrelated lower capability or effort merely because it appears + /// earlier in the route's complete candidate list. Non-AUTO target names + /// retain the existing first-eligible fallback behavior. @@ -402,0 +409,12 @@ + if let Some((scope, selector, effort)) = automatic_floor(name) { + return self + .targets + .iter() + .find(|candidate| { + !ctx.is_excluded(&candidate.semantic_name) + && automatic_floor(&candidate.semantic_name) + == Some((scope, selector, effort)) + }) + .cloned() + .ok_or(LibsyError::AllTargetsExcluded); + } @@ -408,0 +427,25 @@ +} + +/// Parse the Switchyard semantic names used by AUTO capability routes. +/// +/// `route///` serves hosted calls and +/// `worker///` serves durable workers. The provider +/// is deliberately excluded from this floor so infrastructure failover can use +/// the other provider without changing capability or reasoning effort. +fn automatic_floor(name: &str) -> Option<(&str, &str, &str)> { + let mut parts = name.split('/'); + let scope = parts.next()?; + let _provider = parts.next()?; + let selector = parts.next()?; + let effort = parts.next()?; + if parts.next().is_some() + || !matches!(scope, "route" | "worker") + || !matches!( + selector, + "auto" | "auto-economy" | "auto-balanced" | "auto-advanced" | "auto-frontier" + ) + || !matches!(effort, "low" | "medium" | "high" | "xhigh") + { + return None; + } + Some((scope, selector, effort))