Skip to main content

switchyard_protocol/
client.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The routed-call server trait and the routing decision it carries.
5//!
6//! [`RoutedLlmClient`] is the one piece of I/O the protocol does not own: a host
7//! implements it to actually perform a model call. [`Decision`] is the routing
8//! decision that produced the call, carried alongside so the client and any
9//! observer can see which model was chosen and why. Both live here — rather than
10//! in libsy's orchestration crate — so a client crate that depends only on the
11//! protocol can serve routed calls without pulling in the orchestrator.
12
13use std::sync::Arc;
14
15use async_trait::async_trait;
16use thiserror::Error;
17
18use crate::{Context, Request, Response};
19
20/// A boxed client-specific error preserved as the source of a routed call failure.
21pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
22
23/// Failures a routed LLM client can surface to its caller.
24///
25/// The variants classify failures that routing hosts commonly need to handle,
26/// while boxed sources preserve implementation-specific detail. `General` is the
27/// escape hatch for failures that do not fit a shared category.
28#[non_exhaustive]
29#[derive(Debug, Error)]
30pub enum LlmClientError {
31    /// The request cannot be served as supplied.
32    #[error("invalid request: {message}")]
33    InvalidRequest {
34        /// Human-readable request validation failure.
35        message: String,
36    },
37
38    /// Decoding the inbound request failed in the translation engine.
39    #[error("request translation failed: {0}")]
40    RequestTranslation(String),
41
42    /// Encoding the request for the upstream failed in the translation engine.
43    #[error("outbound request encoding failed: {0}")]
44    RequestEncoding(String),
45
46    /// Decoding or encoding the response failed in the translation engine.
47    #[error("response translation failed: {0}")]
48    ResponseTranslation(String),
49
50    /// The client is not configured to serve the selected target.
51    #[error("client configuration error: {message}")]
52    Configuration {
53        /// Human-readable configuration failure.
54        message: String,
55    },
56
57    /// The upstream could not be reached or the request could not be sent.
58    #[error("upstream transport error: {source}")]
59    Transport {
60        /// Client-specific transport failure.
61        #[source]
62        source: BoxError,
63    },
64
65    /// The upstream request exceeded its timeout.
66    #[error("upstream request timed out: {source}")]
67    Timeout {
68        /// Client-specific timeout failure.
69        #[source]
70        source: BoxError,
71    },
72
73    /// The upstream rejected the request because it exceeds the model's context window.
74    #[error("context window exceeded for model {model}: {message}")]
75    ContextWindowExceeded {
76        /// Model whose context window was exceeded.
77        model: String,
78        /// Upstream error message.
79        message: String,
80    },
81
82    /// The upstream returned a non-success HTTP response.
83    #[error("upstream returned HTTP {status}: {body}")]
84    UpstreamHttp {
85        /// Upstream HTTP status code.
86        status: u16,
87        /// Raw upstream error body.
88        body: String,
89    },
90
91    /// The upstream returned a response the client could not decode.
92    #[error("invalid upstream response: {source}")]
93    InvalidResponse {
94        /// Client-specific decoding or validation failure.
95        #[source]
96        source: BoxError,
97    },
98
99    /// A call across a foreign-function boundary (e.g. a Python-implemented client)
100    /// failed. The boxed source is the foreign error itself.
101    #[error("foreign function interface error: {source}")]
102    Ffi {
103        /// Foreign-language failure, preserved verbatim.
104        #[source]
105        source: BoxError,
106    },
107
108    /// A string message. Useful in testing, but prefer adding variants over using this.
109    #[error("{0}")]
110    General(String),
111}
112
113/// Why routing replaced a selected target with another eligible target.
114#[derive(Clone, Copy, Debug, Eq, PartialEq)]
115pub enum RoutingFallbackReason {
116    /// The selected target rejected the request because its context window was too small.
117    ContextWindow,
118    /// The selected target was unavailable after its client retries finished.
119    Unavailable,
120}
121
122impl RoutingFallbackReason {
123    /// Stable value used by logs and statistics.
124    pub const fn as_str(self) -> &'static str {
125        match self {
126            Self::ContextWindow => "context_window",
127            Self::Unavailable => "unavailable",
128        }
129    }
130}
131
132/// A decision/trace object produced by an algorithm.
133///
134/// Carried as a trait object (not a generic parameter) so a stream consumer can
135/// inspect any algorithm's decision through this common interface without
136/// knowing the concrete type. `as_any` is the escape hatch for a consumer that
137/// *does* know the algo and wants to downcast to the concrete decision.
138pub trait Decision: Send + Sync {
139    /// The model this decision selected (e.g. the routed target's name).
140    fn selected_model(&self) -> &str;
141    /// Stable routing tier for the selected model, when the algorithm provides one.
142    fn routing_tier(&self) -> Option<&str> {
143        None
144    }
145    /// Whether this is the final call selected to serve the request.
146    fn is_routed_call(&self) -> bool {
147        true
148    }
149    /// Why this decision replaced an earlier selected target, when it did.
150    fn fallback_reason(&self) -> Option<RoutingFallbackReason> {
151        None
152    }
153    /// A human-readable explanation of the decision, for logs and traces.
154    fn reasoning(&self) -> Option<&str>;
155    /// Downcast handle: a consumer that knows the algorithm can recover the
156    /// concrete decision type via `as_any().downcast_ref::<ConcreteDecision>()`.
157    fn as_any(&self) -> &dyn std::any::Any;
158}
159
160/// A minimal [`Decision`] implementation for one-off calls that don't belong to a
161/// named algorithm step — judge side calls, classifier side calls, etc.
162pub struct SimpleDecision {
163    /// Model or semantic target selected for the call.
164    pub selected_model: String,
165    /// Optional explanation recorded with the call.
166    pub reasoning: Option<String>,
167}
168
169impl Decision for SimpleDecision {
170    fn selected_model(&self) -> &str {
171        &self.selected_model
172    }
173
174    fn reasoning(&self) -> Option<&str> {
175        self.reasoning.as_deref()
176    }
177
178    fn as_any(&self) -> &dyn std::any::Any {
179        self
180    }
181}
182
183/// Performs the actual model call for a target. This is the one piece of I/O the
184/// library does not own — a host implements it over its own transport (HTTP SDK,
185/// in-process model, mock). It serves a call the stream consumer chose not to
186/// override, reached as a routed request's `default_client`.
187///
188/// # Concurrency
189///
190/// A client may be shared by many targets and concurrent algorithm runs. Calls may
191/// overlap, so implementations must synchronize mutable state internally and should
192/// not serialize requests unless their transport requires it.
193#[async_trait]
194pub trait RoutedLlmClient: Send + Sync {
195    /// Serve the call, returning the model's response. Call the model named by
196    /// [`decision.selected_model()`](Decision::selected_model) — the target the algorithm
197    /// routed to — mapping it to whatever provider model id this client hits.
198    /// `request.llm_request.model` is the agent's original name, carried through for
199    /// reference, not a call target. `ctx` carries the request's cross-cutting state.
200    async fn call(
201        &self,
202        ctx: Context,
203        request: Request,
204        decision: Arc<dyn Decision>,
205    ) -> Result<Response, LlmClientError>;
206}