Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/libsy-llm-client/src/observability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ fn client_call_error_type(error: &LibsyError) -> Cow<'static, str> {

fn llm_client_error_type(error: &LlmClientError) -> Cow<'static, str> {
match error {
LlmClientError::RoutedCall { failure } => Cow::Borrowed(failure.class().stable_tag()),
LlmClientError::InvalidRequest { .. } => Cow::Borrowed("invalid_request"),
LlmClientError::RequestTranslation(_) => Cow::Borrowed("request_translation"),
LlmClientError::RequestEncoding(_) => Cow::Borrowed("request_encoding"),
Expand Down
189 changes: 186 additions & 3 deletions crates/libsy-llm-client/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ use http::StatusCode;
use parking_lot::Mutex;
use switchyard_libsy::{Algorithm, CallModel, LibsyError, Result, RoutingOutcome, drive};
use switchyard_protocol::{
LlmClientError, ModelId, Request, Response, RoutedLlmClient, RoutingFallbackReason,
LlmClientError, ModelId, ProviderTargetsExhaustedSummary, Request, Response, RoutedCallFailure,
RoutedCallFailureClass, RoutedLlmClient, RoutingDisposition, RoutingFallbackReason,
};
use switchyard_translation::prepare_request_for_target;

Expand Down Expand Up @@ -305,11 +306,17 @@ async fn call_one(
}

/// Whether a failed candidate is worth routing around.
///
/// A [`LlmClientError::RoutedCall`] is authoritative: the routing host already classified
/// the failure, so its [`RoutingDisposition`] decides whether another candidate may serve
/// the request and no status is re-interpreted here. The remaining variants keep the
/// legacy status-shaped inference for clients that do not classify their own failures.
fn fallback_reason(error: &LibsyError) -> Option<RoutingFallbackReason> {
let LibsyError::ClientCall { source, .. } = error else {
return None;
};
match source {
LlmClientError::RoutedCall { failure } => routed_call_fallback(failure),
LlmClientError::ContextWindowExceeded { .. } => Some(RoutingFallbackReason::ContextWindow),
LlmClientError::Transport { .. } | LlmClientError::Timeout { .. } => {
Some(RoutingFallbackReason::Unavailable)
Expand All @@ -326,6 +333,31 @@ fn fallback_reason(error: &LibsyError) -> Option<RoutingFallbackReason> {
}
}

/// Maps a host-classified failure to fallback policy, or `None` to surface it terminally.
///
/// The disposition alone decides whether routing advances. The class only names why, for
/// the reasoning published on the hop.
fn routed_call_fallback(failure: &RoutedCallFailure) -> Option<RoutingFallbackReason> {
match failure.disposition() {
RoutingDisposition::Stop => None,
RoutingDisposition::NextTarget => Some(match failure.class() {
RoutedCallFailureClass::ContextWindow => RoutingFallbackReason::ContextWindow,
// An exhaustion every one of whose real failures was an overflow is an overflow
// for the whole logical model, not target unavailability. Reporting it as
// `Unavailable` is the conflation this contract exists to remove, and it is
// what a caller would have to undo to know the request itself needs reshaping.
RoutedCallFailureClass::ProviderTargetsExhausted
if failure
.exhausted()
.is_some_and(ProviderTargetsExhaustedSummary::is_context_window_exhaustion) =>
{
RoutingFallbackReason::ContextWindow
}
_ => RoutingFallbackReason::Unavailable,
}),
}
}

/// Resolves a routed call's selected model to the client that serves it.
///
/// An algorithm routes among named targets; which provider each target lives on is the
Expand Down Expand Up @@ -447,8 +479,9 @@ mod tests {
use http::StatusCode;
use switchyard_libsy::{Driver, RoutingOutcome};
use switchyard_protocol::{
ContentBlock, LlmResponse, LlmResponseChunk, LlmResponseStreamEvent, completion_text,
text_request, text_response,
ContentBlock, LlmResponse, LlmResponseChunk, LlmResponseStreamEvent,
ProviderTargetsExhaustedSummary, RoutedFailureCount, completion_text, text_request,
text_response,
};
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
Expand Down Expand Up @@ -755,6 +788,156 @@ mod tests {
Ok(())
}

/// A host-classified failure follows its explicit disposition, so a status that the
/// legacy path would have treated as a fallback stops the route when the host says so.
#[test]
fn routed_call_fallback_follows_disposition_not_status() {
let error = |source| LibsyError::client_call("target", source);
let stop = RoutedCallFailure::new(
RoutedCallFailureClass::RateLimit,
RoutingDisposition::Stop,
None,
Some(429),
)
.expect("valid direct failure");
assert_eq!(
fallback_reason(&error(LlmClientError::RoutedCall { failure: stop })),
None
);

let advance = RoutedCallFailure::new(
RoutedCallFailureClass::ProviderRejected,
RoutingDisposition::NextTarget,
None,
Some(400),
)
.expect("valid direct failure");
assert_eq!(
fallback_reason(&error(LlmClientError::RoutedCall { failure: advance })),
Some(RoutingFallbackReason::Unavailable)
);
}

/// The class names why the hop happened: a context overflow is a request-shape
/// failure, every other advanceable class is target unavailability.
#[test]
fn routed_call_fallback_maps_class_to_reason() {
let error = |source| LibsyError::client_call("target", source);
let advanceable = |class| {
fallback_reason(&error(LlmClientError::RoutedCall {
failure: RoutedCallFailure::new(class, RoutingDisposition::NextTarget, None, None)
.expect("valid direct failure"),
}))
};

assert_eq!(
advanceable(RoutedCallFailureClass::ContextWindow),
Some(RoutingFallbackReason::ContextWindow)
);
for class in [
RoutedCallFailureClass::CircuitOpen,
RoutedCallFailureClass::TargetIncompatible,
RoutedCallFailureClass::Overloaded,
RoutedCallFailureClass::Transport,
RoutedCallFailureClass::AttemptTimeout,
] {
assert_eq!(
advanceable(class),
Some(RoutingFallbackReason::Unavailable),
"{} should report target unavailability",
class.stable_tag()
);
}
}

/// Provider exhaustion may still move to another candidate, and its bounded summary
/// survives the hop without routing inspecting a status.
#[test]
fn routed_call_fallback_advances_on_provider_exhaustion() {
let summary = ProviderTargetsExhaustedSummary::new(
1,
1,
vec![
RoutedFailureCount::new(RoutedCallFailureClass::CircuitOpen, 1),
RoutedFailureCount::new(RoutedCallFailureClass::RateLimit, 1),
],
None,
)
.expect("valid partition");
assert_eq!(
fallback_reason(&LibsyError::client_call(
"target",
LlmClientError::RoutedCall {
failure: RoutedCallFailure::targets_exhausted(summary),
},
)),
Some(RoutingFallbackReason::Unavailable)
);
}

/// An exhaustion whose every real failure was an overflow is an overflow for the whole
/// logical model, so the hop says so rather than reporting target unavailability. A
/// bypassed circuit is not a real failure and does not break that conclusion; a real
/// failure of any other class does, and an all-bypassed exhaustion proves nothing.
#[test]
fn routed_call_fallback_reports_an_all_overflow_exhaustion_as_context_window() {
let exhaustion = |attempted, bypassed, failures| {
let summary = ProviderTargetsExhaustedSummary::new(attempted, bypassed, failures, None)
.expect("valid partition");
fallback_reason(&LibsyError::client_call(
"target",
LlmClientError::RoutedCall {
failure: RoutedCallFailure::targets_exhausted(summary),
},
))
};

assert_eq!(
exhaustion(
2,
0,
vec![RoutedFailureCount::new(
RoutedCallFailureClass::ContextWindow,
2
)]
),
Some(RoutingFallbackReason::ContextWindow)
);
assert_eq!(
exhaustion(
1,
1,
vec![
RoutedFailureCount::new(RoutedCallFailureClass::CircuitOpen, 1),
RoutedFailureCount::new(RoutedCallFailureClass::ContextWindow, 1),
]
),
Some(RoutingFallbackReason::ContextWindow)
);
assert_eq!(
exhaustion(
2,
0,
vec![
RoutedFailureCount::new(RoutedCallFailureClass::ContextWindow, 1),
RoutedFailureCount::new(RoutedCallFailureClass::RateLimit, 1),
]
),
Some(RoutingFallbackReason::Unavailable)
);
assert_eq!(
exhaustion(
0,
2,
vec![RoutedFailureCount::new(
RoutedCallFailureClass::CircuitOpen,
2
)]
),
Some(RoutingFallbackReason::Unavailable)
);
}

#[test]
fn fallback_only_accepts_context_and_unavailable_failures() {
let error = |source| LibsyError::client_call("target", source);
Expand Down
100 changes: 99 additions & 1 deletion crates/libsy/src/algorithms/escalation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use std::sync::Arc;
use async_trait::async_trait;
use switchyard_protocol::{
AggLlmResponse, LlmClientError, LlmResponse, Message, ModelId, Request, Response, Role,
RoutedCallFailureClass, RoutingDisposition,
};

use super::util::classifier_contract::ClassifierContractConfig;
Expand Down Expand Up @@ -112,6 +113,21 @@ impl Classifier<State> for EscalationClassifier {
source: LlmClientError::ContextWindowExceeded { .. },
..
}) => return Ok((decisive(&self.capable), None)),
// A routing host classifies its own failures, so the same condition arrives as
// a typed class instead: an advanceable overflow, or a target that cannot serve
// this request's shape, is the escalation signal the legacy variant carried.
Err(LibsyError::ClientCall {
source: LlmClientError::RoutedCall { failure },
..
}) if failure.disposition() == RoutingDisposition::NextTarget
&& matches!(
failure.class(),
RoutedCallFailureClass::ContextWindow
| RoutedCallFailureClass::TargetIncompatible
) =>
{
return Ok((decisive(&self.capable), None));
}
Err(e) => return Err(e),
};
// The call resolves when its stream handle arrives; transport can still fail while
Expand Down Expand Up @@ -173,7 +189,7 @@ mod tests {
use parking_lot::Mutex;
use switchyard_protocol::{
ContentBlock, LlmClientError, LlmResponse, LlmResponseChunk, Metadata, Request, Response,
completion_text, text_request, text_response,
RoutedCallFailure, completion_text, text_request, text_response,
};

use super::*;
Expand Down Expand Up @@ -389,6 +405,88 @@ mod tests {
Ok(())
}

#[tokio::test]
async fn falls_back_to_capable_on_an_advanceable_routed_failure() -> Result<()> {
// A routing host returns its own classification instead of the legacy variant. Both
// advanceable request-shape classes must escalate to capable exactly as an overflow
// does; otherwise a staged route terminates instead of reaching the strong tier.
for class in [
RoutedCallFailureClass::ContextWindow,
RoutedCallFailureClass::TargetIncompatible,
] {
let serve = move |target: ModelId, _request: Request| async move {
match target.as_str() {
"efficient" => Err(LlmClientError::RoutedCall {
failure: RoutedCallFailure::new(
class,
RoutingDisposition::NextTarget,
None,
None,
)
.expect("valid direct failure"),
}),
"judge" => panic!("the judge must not be consulted when efficient escalates"),
_ => Ok(reply("capable answer")),
}
};

let (selected_model, response) =
test_drive(escalation_router()?, classify_request(), serve).await?;

assert_eq!(
selected_model,
"capable",
"{} should escalate to capable",
class.stable_tag()
);
assert_eq!(
response.llm_response.as_agg().map(completion_text),
Some("capable answer".to_string())
);
}
Ok(())
}

/// The disposition is authoritative: a host that says `Stop` must not be second-guessed
/// into an escalation, even for a class the `NextTarget` arm accepts.
#[tokio::test]
async fn surfaces_a_terminal_routed_failure() {
let serve = |target: ModelId, _request: Request| async move {
match target.as_str() {
"efficient" => Err(LlmClientError::RoutedCall {
failure: RoutedCallFailure::new(
RoutedCallFailureClass::ContextWindow,
RoutingDisposition::Stop,
None,
None,
)
.expect("valid direct failure"),
}),
other => panic!("{other} must not be called after a terminal failure"),
}
};

let Err(error) = test_drive(
escalation_router().expect("escalation router"),
classify_request(),
serve,
)
.await
else {
panic!("a Stop disposition is terminal for the route");
};
assert!(
matches!(
error,
LibsyError::ClientCall {
source: LlmClientError::RoutedCall { .. },
..
}
),
"expected the host's routed failure to surface, got {error:?}"
);
}

/// A transport failure while buffering efficient must bypass the judge and serve capable.
#[tokio::test]
async fn falls_back_when_efficient_stream_transport_fails() -> Result<()> {
Expand Down
1 change: 1 addition & 0 deletions crates/libsy/src/algorithms/util/llm_judge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ pub(crate) fn libsy_error_reason(error: &LibsyError) -> &'static str {
/// Returns a bounded reason from the error kind and HTTP status only.
fn client_error_reason(error: &LlmClientError) -> &'static str {
match error {
LlmClientError::RoutedCall { failure } => failure.class().stable_tag(),
LlmClientError::Timeout { .. } => "timeout",
LlmClientError::Transport { .. } => "transport",
LlmClientError::UpstreamHttp { status, .. } if status.is_server_error() => "upstream_5xx",
Expand Down
Loading