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 async_trait::async_trait;
14use thiserror::Error;
15
16use crate::{Request, Response};
17
18/// A boxed client-specific error preserved as the source of a routed call failure.
19pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
20
21/// Failures a routed LLM client can surface to its caller.
22///
23/// The variants classify failures that routing hosts commonly need to handle,
24/// while boxed sources preserve implementation-specific detail. `General` is the
25/// escape hatch for failures that do not fit a shared category.
26#[non_exhaustive]
27#[derive(Debug, Error)]
28pub enum LlmClientError {
29    /// The request cannot be served as supplied.
30    #[error("invalid request: {message}")]
31    InvalidRequest {
32        /// Human-readable request validation failure.
33        message: String,
34    },
35
36    /// Decoding the inbound request failed in the translation engine.
37    #[error("request translation failed: {0}")]
38    RequestTranslation(String),
39
40    /// Encoding the request for the upstream failed in the translation engine.
41    #[error("outbound request encoding failed: {0}")]
42    RequestEncoding(String),
43
44    /// Decoding or encoding the response failed in the translation engine.
45    #[error("response translation failed: {0}")]
46    ResponseTranslation(String),
47
48    /// The client is not configured to serve the selected target.
49    #[error("client configuration error: {message}")]
50    Configuration {
51        /// Human-readable configuration failure.
52        message: String,
53    },
54
55    /// The upstream could not be reached or the request could not be sent.
56    #[error("upstream transport error: {source}")]
57    Transport {
58        /// Client-specific transport failure.
59        #[source]
60        source: BoxError,
61    },
62
63    /// The upstream request exceeded its timeout.
64    #[error("upstream request timed out: {source}")]
65    Timeout {
66        /// Client-specific timeout failure.
67        #[source]
68        source: BoxError,
69    },
70
71    /// The upstream rejected the request because it exceeds the model's context window.
72    #[error("context window exceeded for model {model}: {message}")]
73    ContextWindowExceeded {
74        /// Model whose context window was exceeded.
75        model: String,
76        /// Upstream error message.
77        message: String,
78    },
79
80    /// The upstream returned a non-success HTTP response.
81    #[error("upstream returned HTTP {status}: {body}")]
82    UpstreamHttp {
83        /// Upstream HTTP status code.
84        status: u16,
85        /// Raw upstream error body.
86        body: String,
87    },
88
89    /// The upstream returned a response the client could not decode.
90    #[error("invalid upstream response: {source}")]
91    InvalidResponse {
92        /// Client-specific decoding or validation failure.
93        #[source]
94        source: BoxError,
95    },
96
97    /// A call across a foreign-function boundary (e.g. a Python-implemented client)
98    /// failed. The boxed source is the foreign error itself.
99    #[error("foreign function interface error: {source}")]
100    Ffi {
101        /// Foreign-language failure, preserved verbatim.
102        #[source]
103        source: BoxError,
104    },
105
106    /// A string message. Useful in testing, but prefer adding variants over using this.
107    #[error("{0}")]
108    General(String),
109}
110
111/// Why routing replaced a selected target with another eligible target.
112#[derive(Clone, Copy, Debug, Eq, PartialEq)]
113pub enum RoutingFallbackReason {
114    /// The selected target rejected the request because its context window was too small.
115    ContextWindow,
116    /// The selected target was unavailable after its client retries finished.
117    Unavailable,
118}
119
120impl RoutingFallbackReason {
121    /// Stable value embedded in routing reasoning.
122    pub const fn as_str(self) -> &'static str {
123        match self {
124            Self::ContextWindow => "context_window",
125            Self::Unavailable => "unavailable",
126        }
127    }
128}
129
130/// A routing choice produced by an algorithm.
131#[derive(Clone, Debug)]
132pub struct Decision {
133    /// The model identifier selected for the call.
134    selected_model_id: String,
135    /// Why, for logs and traces.
136    reasoning: Option<String>,
137    /// True for an answer-generating call. False for classifier and judge calls.
138    is_answer_call: bool,
139}
140
141impl Decision {
142    /// Creates a decision and records whether its call produces the answer.
143    pub fn new(
144        selected_model_id: impl Into<String>,
145        reasoning: Option<String>,
146        is_answer_call: bool,
147    ) -> Self {
148        Self {
149            selected_model_id: selected_model_id.into(),
150            reasoning,
151            is_answer_call,
152        }
153    }
154
155    /// The model identifier selected for the call.
156    pub fn selected_model_id(&self) -> &str {
157        self.selected_model_id.as_str()
158    }
159
160    /// Why this decision was made.
161    pub fn reasoning(&self) -> Option<&str> {
162        self.reasoning.as_deref()
163    }
164
165    /// Whether this call generates an answer rather than a routing verdict.
166    pub fn is_answer_call(&self) -> bool {
167        self.is_answer_call
168    }
169}
170
171/// Performs the actual model call for a target. This is the one piece of I/O the
172/// library does not own — a host implements it over its own transport (HTTP SDK,
173/// in-process model, mock). It serves a call the stream consumer chose not to
174/// override, reached as a routed request's `default_client`.
175///
176/// # Concurrency
177///
178/// A client may be shared by many targets and concurrent algorithm runs. Calls may
179/// overlap, so implementations must synchronize mutable state internally and should
180/// not serialize requests unless their transport requires it.
181#[async_trait]
182pub trait RoutedLlmClient: Send + Sync {
183    /// Serve the model identified by
184    /// [`decision.selected_model_id()`](Decision::selected_model_id), resolving it to the
185    /// provider model this client calls.
186    /// `request.llm_request.model` is the agent's original name, carried through for
187    /// reference, not a call target.
188    async fn call(&self, request: Request, decision: Decision) -> Result<Response, LlmClientError>;
189}