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/// A decision/trace object produced by an algorithm.
114///
115/// Carried as a trait object (not a generic parameter) so a stream consumer can
116/// inspect any algorithm's decision through this common interface without
117/// knowing the concrete type. `as_any` is the escape hatch for a consumer that
118/// *does* know the algo and wants to downcast to the concrete decision.
119pub trait Decision: Send + Sync {
120 /// The model this decision selected (e.g. the routed target's name).
121 fn selected_model(&self) -> &str;
122 /// Stable routing tier for the selected model, when the algorithm provides one.
123 fn routing_tier(&self) -> Option<&str> {
124 None
125 }
126 /// Whether this is the final call selected to serve the request.
127 fn is_routed_call(&self) -> bool {
128 true
129 }
130 /// A human-readable explanation of the decision, for logs and traces.
131 fn reasoning(&self) -> Option<&str>;
132 /// Downcast handle: a consumer that knows the algorithm can recover the
133 /// concrete decision type via `as_any().downcast_ref::<ConcreteDecision>()`.
134 fn as_any(&self) -> &dyn std::any::Any;
135}
136
137/// A minimal [`Decision`] implementation for one-off calls that don't belong to a
138/// named algorithm step — judge side calls, classifier side calls, etc.
139pub struct SimpleDecision {
140 /// Model or semantic target selected for the call.
141 pub selected_model: String,
142 /// Optional explanation recorded with the call.
143 pub reasoning: Option<String>,
144}
145
146impl Decision for SimpleDecision {
147 fn selected_model(&self) -> &str {
148 &self.selected_model
149 }
150
151 fn reasoning(&self) -> Option<&str> {
152 self.reasoning.as_deref()
153 }
154
155 fn as_any(&self) -> &dyn std::any::Any {
156 self
157 }
158}
159
160/// Performs the actual model call for a target. This is the one piece of I/O the
161/// library does not own — a host implements it over its own transport (HTTP SDK,
162/// in-process model, mock). It serves a call the stream consumer chose not to
163/// override, reached as a routed request's `default_client`.
164///
165/// # Concurrency
166///
167/// A client may be shared by many targets and concurrent algorithm runs. Calls may
168/// overlap, so implementations must synchronize mutable state internally and should
169/// not serialize requests unless their transport requires it.
170#[async_trait]
171pub trait RoutedLlmClient: Send + Sync {
172 /// Serve the call, returning the model's response. Call the model named by
173 /// [`decision.selected_model()`](Decision::selected_model) — the target the algorithm
174 /// routed to — mapping it to whatever provider model id this client hits.
175 /// `request.llm_request.model` is the agent's original name, carried through for
176 /// reference, not a call target. `ctx` carries the request's cross-cutting state.
177 async fn call(
178 &self,
179 ctx: Context,
180 request: Request,
181 decision: Arc<dyn Decision>,
182 ) -> Result<Response, LlmClientError>;
183
184 /// Whether this client can serve [`count_tokens`](Self::count_tokens) — i.e.
185 /// it has an Anthropic upstream. The default is `false`.
186 fn supports_count_tokens(&self) -> bool {
187 false
188 }
189
190 /// Count the tokens `request` would use — a **direct passthrough**, not a
191 /// routed call. Forwards `request` to this client's Anthropic
192 /// `/v1/messages/count_tokens` endpoint (model restamped to the upstream
193 /// target id) and returns the JSON verbatim. Token counting is a pre-flight
194 /// estimate with no routing decision, so unlike [`call`](Self::call) it
195 /// takes no [`Decision`]. The default errors; only an Anthropic-backed
196 /// client overrides it.
197 async fn count_tokens(&self, request: Request) -> Result<serde_json::Value, LlmClientError> {
198 let _ = request;
199 Err(LlmClientError::Configuration {
200 message: "count_tokens is not supported by this client".to_string(),
201 })
202 }
203}