Problem
The fallback ladder in call_model_with_fallback recognises two reasons a target can fail a route:
RoutingFallbackReason::ContextWindow => evictions.record(identity, failed), // session-sticky
RoutingFallbackReason::Unavailable => target_unavailable(&request, failed), // request-local
There is a third case it does not model: a target that rejects the request as one it cannot serve at all — an unsupported modality, a tool schema it does not accept, a feature the model lacks. Providers return these as a 400, so today they fall through classify_fallback's _ => return None and surface to the caller as a hard error. The route has another target that could have served the turn and never tries it.
Proposal
A CapabilityRejected { model, message } variant on LlmClientError, detected at the same place ContextWindowExceeded is (a 400 whose body matches a backend-supplied predicate), and a third RoutingFallbackReason::Capability.
The routing question is which of the two existing behaviours it should share, and the answer is not the obvious one. A capability reject looks like an availability problem — the target refused the request — but it is a permanent property of that target for this conversation. Unavailable is request-local, so mapping it there re-probes the rejecting target on every subsequent turn, paying a guaranteed failure per turn for the rest of the session. It belongs with ContextWindow:
RoutingFallbackReason::ContextWindow | RoutingFallbackReason::Capability => {
evictions.record(identity, failed)
}
Both are "this target cannot serve this conversation", which is exactly what SessionEvictions records.
This distinction compiles either way and is invisible in a single-turn test, so it is worth pinning with a test that asserts the rejecting target is probed exactly once across three turns. I have verified that test fails if the variant is routed to Unavailable instead.
Scope
crates/protocol/src/client.rs — new LlmClientError variant and RoutingFallbackReason::Capability (as_str() → "capability"). This is a public-API addition, which is the main thing worth a design opinion before a PR.
crates/libsy-llm-client/src/{backend,client}.rs — detection, ordered after the overflow check because an oversized request is recoverable on the same target and a capability reject never is.
crates/libsy/src/core/algorithm.rs — classify_fallback mapping and the dispatch above.
crates/switchyard-server/src/lib.rs — error mapping.
Related: #298 introduced the reason-based ladder this extends; #273 touches the same fall_through.rs region.
Implementation is rebased onto current main and passing fmt/clippy -D warnings/test --workspace (including --test-threads=1). Raising it as an issue first rather than opening the PR, since the protocol addition deserves a design call.
Problem
The fallback ladder in
call_model_with_fallbackrecognises two reasons a target can fail a route:There is a third case it does not model: a target that rejects the request as one it cannot serve at all — an unsupported modality, a tool schema it does not accept, a feature the model lacks. Providers return these as a 400, so today they fall through
classify_fallback's_ => return Noneand surface to the caller as a hard error. The route has another target that could have served the turn and never tries it.Proposal
A
CapabilityRejected { model, message }variant onLlmClientError, detected at the same placeContextWindowExceededis (a 400 whose body matches a backend-supplied predicate), and a thirdRoutingFallbackReason::Capability.The routing question is which of the two existing behaviours it should share, and the answer is not the obvious one. A capability reject looks like an availability problem — the target refused the request — but it is a permanent property of that target for this conversation.
Unavailableis request-local, so mapping it there re-probes the rejecting target on every subsequent turn, paying a guaranteed failure per turn for the rest of the session. It belongs withContextWindow:Both are "this target cannot serve this conversation", which is exactly what
SessionEvictionsrecords.This distinction compiles either way and is invisible in a single-turn test, so it is worth pinning with a test that asserts the rejecting target is probed exactly once across three turns. I have verified that test fails if the variant is routed to
Unavailableinstead.Scope
crates/protocol/src/client.rs— newLlmClientErrorvariant andRoutingFallbackReason::Capability(as_str()→"capability"). This is a public-API addition, which is the main thing worth a design opinion before a PR.crates/libsy-llm-client/src/{backend,client}.rs— detection, ordered after the overflow check because an oversized request is recoverable on the same target and a capability reject never is.crates/libsy/src/core/algorithm.rs—classify_fallbackmapping and the dispatch above.crates/switchyard-server/src/lib.rs— error mapping.Related: #298 introduced the reason-based ladder this extends; #273 touches the same
fall_through.rsregion.Implementation is rebased onto current
mainand passingfmt/clippy -D warnings/test --workspace(including--test-threads=1). Raising it as an issue first rather than opening the PR, since theprotocoladdition deserves a design call.