99 lines
4.0 KiB
Diff
99 lines
4.0 KiB
Diff
--- 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/<provider>/<selector>/<effort>` serves hosted calls and
|
|
+/// `worker/<provider>/<selector>/<effort>` 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))
|