Skip to content

feat(libsy): evict targets that reject a request as unservable #345

Description

@gburachas

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.rsclassify_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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions