Skip to content

Commit fa68d9e

Browse files
committed
feat(routing): allow classifier target failover to be disabled
Signed-off-by: Todd Fisher <todd.fisher@gmail.com>
1 parent 8aca287 commit fa68d9e

5 files changed

Lines changed: 84 additions & 4 deletions

File tree

crates/libsy/src/algorithms/fall_through.rs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
//! private state value across turns with the same session ID. Requests without a session ID use
1414
//! unretained per-run state.
1515
//!
16-
//! The selected target is offered first, followed by every other configured target. The consumer
17-
//! may fall through that ordered candidate list when a model call fails.
16+
//! By default, the selected target is offered first, followed by every other configured target.
17+
//! The consumer may fall through that ordered candidate list when a model call fails. Callers may
18+
//! disable target failover when a routing decision must remain authoritative.
1819
1920
use std::{
2021
collections::{BTreeSet, HashMap},
@@ -97,6 +98,7 @@ pub struct FallThrough<S = ()> {
9798
classifiers: Vec<Arc<dyn Classifier<S>>>,
9899
targets: Vec<ModelId>,
99100
target_modalities: Option<TargetModalities>,
101+
target_failover: bool,
100102
session_states: Option<Arc<SessionStates<S>>>,
101103
cleanup_started: Once,
102104
}
@@ -111,6 +113,7 @@ impl FallThrough<()> {
111113
classifiers: Vec::new(),
112114
targets,
113115
target_modalities: None,
116+
target_failover: true,
114117
session_states: None,
115118
cleanup_started: Once::new(),
116119
}
@@ -130,6 +133,7 @@ where
130133
classifiers: Vec::new(),
131134
targets,
132135
target_modalities: None,
136+
target_failover: true,
133137
session_states: Some(Arc::new(Mutex::new(HashMap::new()))),
134138
cleanup_started: Once::new(),
135139
}
@@ -165,6 +169,12 @@ where
165169
self
166170
}
167171

172+
/// Controls whether a failed selected target may fall through to another route target.
173+
pub fn with_target_failover(mut self, enabled: bool) -> Self {
174+
self.target_failover = enabled;
175+
self
176+
}
177+
168178
/// Executes the processor/classifier/target-call sequence for wrappers and the trait entrypoint.
169179
pub(crate) async fn execute(&self, driver: Driver, request: Request) -> Result<Response> {
170180
self.start_cleanup_task();
@@ -232,6 +242,9 @@ where
232242

233243
/// The selected target first, then every other configured target as a fallback candidate.
234244
fn candidates(&self, target: &ModelId, eligible: Option<&[ModelId]>) -> Vec<ModelId> {
245+
if !self.target_failover {
246+
return vec![target.clone()];
247+
}
235248
let candidates = eligible.unwrap_or(&self.targets);
236249
std::iter::once(target.clone())
237250
.chain(
@@ -802,6 +815,27 @@ mod tests {
802815
Err(test_error("expected a CallModel step"))
803816
}
804817

818+
#[tokio::test]
819+
async fn target_failover_can_be_disabled() -> Result<()> {
820+
use futures::StreamExt;
821+
822+
let router = Arc::new(
823+
FallThrough::<()>::new(target_set(&["weak", "mid", "strong"]))
824+
.with_target_failover(false)
825+
.with_classifier(fixed(vec![score("mid", 0.9)])),
826+
);
827+
let stream = router.run_stream(request());
828+
tokio::pin!(stream);
829+
while let Some(step) = stream.next().await {
830+
if let crate::Step::CallModel(call) = step? {
831+
assert_eq!(call.models, target_set(&["mid"]));
832+
assert_eq!(call.request.llm_request.model.as_deref(), Some("mid"));
833+
return Ok(());
834+
}
835+
}
836+
Err(test_error("expected a CallModel step"))
837+
}
838+
805839
#[tokio::test]
806840
async fn argmax_picks_the_highest_confidence_target() -> Result<()> {
807841
let router = FallThrough::<()>::new(target_set(&["strong", "weak"]))

crates/libsy/src/algorithms/llm_class.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,12 @@ impl LlmTaskClassifier {
705705
self
706706
}
707707

708+
/// Controls whether a failed selected target may fall through to another classifier target.
709+
pub fn with_target_failover(mut self, enabled: bool) -> Self {
710+
self.route = self.route.with_target_failover(enabled);
711+
self
712+
}
713+
708714
fn build_capability(
709715
judge_target: ModelId,
710716
efficient_target: ModelId,

crates/switchyard-server/src/config.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,8 @@ enum RouteConfig {
468468
tool_calling: Option<bool>,
469469
#[serde(default)]
470470
reasoning: Option<bool>,
471+
#[serde(default = "enabled_by_default")]
472+
target_failover: bool,
471473
classifier_target: String,
472474
#[serde(default)]
473475
mode: Option<ClassifierMode>,
@@ -968,7 +970,9 @@ fn build_algorithm(
968970
Ok(Arc::new(algorithm))
969971
}
970972
RouteConfig::LlmClassifier {
971-
classifier_target, ..
973+
classifier_target,
974+
target_failover,
975+
..
972976
} => {
973977
let classifier = resolve_target_model_id(route_name, classifier_target, targets)?;
974978
let mode = config.classifier_mode(route_name)?;
@@ -1044,6 +1048,7 @@ fn build_algorithm(
10441048
.map_err(|error| {
10451049
ServerError::new(format!("llm_classifier route {route_name}: {error}"))
10461050
})?;
1051+
algorithm = algorithm.with_target_failover(*target_failover);
10471052
if let Some(target_modalities) = target_modalities {
10481053
algorithm = algorithm.with_target_modalities(target_modalities);
10491054
}
@@ -1111,6 +1116,10 @@ fn default_classifier_max_output_tokens() -> u64 {
11111116
TaskClassifierConfig::default().max_output_tokens
11121117
}
11131118

1119+
const fn enabled_by_default() -> bool {
1120+
true
1121+
}
1122+
11141123
/// Keys each configured system prompt by the target it belongs to.
11151124
fn tier_prompts(
11161125
capable: &str,

crates/switchyard-server/tests/server.rs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,10 @@ async fn upstream_chat(
113113

114114
let model = body["model"].as_str().unwrap_or("unknown").to_string();
115115
let prompt = body["messages"][0]["content"].as_str().unwrap_or("");
116-
if (model == "model/weak" && prompt == "unavailable") || prompt == "all-unavailable" {
116+
if (model == "model/weak" && prompt == "unavailable")
117+
|| (model == "model/premium" && prompt == "premium-unavailable")
118+
|| prompt == "all-unavailable"
119+
{
117120
return (
118121
StatusCode::SERVICE_UNAVAILABLE,
119122
Json(json!({"error": {"message": "upstream is unavailable"}})),
@@ -1086,6 +1089,7 @@ mode = "custom"
10861089
classifier_target = "classifier"
10871090
targets = ["weak", "middle", "strong", "premium"]
10881091
default_target = "strong"
1092+
target_failover = false
10891093
prompt = "CUSTOM MULTI TARGET"
10901094
response_schema = '''
10911095
{{
@@ -1137,6 +1141,32 @@ selector = "/decision/target"
11371141
);
11381142
}
11391143

1144+
let previous_call_count = upstream.calls.lock().await.len();
1145+
let response = send(
1146+
&app,
1147+
"POST",
1148+
"/v1/chat/completions",
1149+
Some(json!({
1150+
"model": "switchyard/custom",
1151+
"messages": [{"role": "user", "content": "premium-unavailable"}]
1152+
})),
1153+
)
1154+
.await?;
1155+
assert_eq!(response.status, StatusCode::SERVICE_UNAVAILABLE);
1156+
let calls = upstream.calls.lock().await;
1157+
let outage_models = calls[previous_call_count..]
1158+
.iter()
1159+
.map(|call| call["model"].as_str().unwrap_or(""))
1160+
.collect::<Vec<_>>();
1161+
assert_eq!(outage_models.first(), Some(&"model/classifier"));
1162+
assert!(outage_models.len() > 1);
1163+
assert!(
1164+
outage_models[1..]
1165+
.iter()
1166+
.all(|model| *model == "model/premium")
1167+
);
1168+
drop(calls);
1169+
11401170
let calls = upstream.calls.lock().await;
11411171
let judge_call = calls
11421172
.iter()

docs/reference/toml_schema.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ Runs one of three judge-backed modes: `capability`, `escalation`, or `custom`.
169169
|---|:---:|---|---|
170170
| `mode` | No | `capability` | Classifier behavior. Set it explicitly for new configurations. |
171171
| `classifier_target` | Yes || Target the judge is called through. Not a routing destination. |
172+
| `target_failover` | No | `true` | When false, a failed selected completion target returns its error instead of trying another route target. |
172173
| `max_output_tokens` | No | `4096` | Maximum completion tokens for the judge verdict. Must be at least `1`. |
173174
| `response_format_type` | No | `json_schema` | Structured-output mode for capability and escalation judges. Use `json_object` when the provider does not support JSON Schema; Switchyard adds the schema to the prompt and validates the verdict locally. Custom mode always uses its configured JSON Schema. |
174175

0 commit comments

Comments
 (0)