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 its shared error types.
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. It lives here — rather than in
8//! libsy's orchestration crate — so a client crate that depends only on the protocol
9//! can serve routed calls without pulling in the orchestrator.
10
11use async_trait::async_trait;
12use thiserror::Error;
13
14use crate::{ModelId, Request, Response};
15
16/// A boxed client-specific error preserved as the source of a routed call failure.
17pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
18
19/// Failures a routed LLM client can surface to its caller.
20///
21/// The variants classify failures that routing hosts commonly need to handle,
22/// while boxed sources preserve implementation-specific detail. `General` is the
23/// escape hatch for failures that do not fit a shared category.
24#[non_exhaustive]
25#[derive(Debug, Error)]
26pub enum LlmClientError {
27 /// The request cannot be served as supplied.
28 #[error("invalid request: {message}")]
29 InvalidRequest {
30 /// Human-readable request validation failure.
31 message: String,
32 },
33
34 /// Decoding the inbound request failed in the translation engine.
35 #[error("request translation failed: {0}")]
36 RequestTranslation(String),
37
38 /// Encoding the request for the upstream failed in the translation engine.
39 #[error("outbound request encoding failed: {0}")]
40 RequestEncoding(String),
41
42 /// Decoding or encoding the response failed in the translation engine.
43 #[error("response translation failed: {0}")]
44 ResponseTranslation(String),
45
46 /// The client is not configured to serve the selected target.
47 #[error("client configuration error: {message}")]
48 Configuration {
49 /// Human-readable configuration failure.
50 message: String,
51 },
52
53 /// The upstream could not be reached or the request could not be sent.
54 #[error("upstream transport error: {source}")]
55 Transport {
56 /// Client-specific transport failure.
57 #[source]
58 source: BoxError,
59 },
60
61 /// The upstream request exceeded its timeout.
62 #[error("upstream request timed out: {source}")]
63 Timeout {
64 /// Client-specific timeout failure.
65 #[source]
66 source: BoxError,
67 },
68
69 /// The upstream rejected the request because it exceeds the model's context window.
70 #[error("context window exceeded for model {model}: {message}")]
71 ContextWindowExceeded {
72 /// Model whose context window was exceeded.
73 model: ModelId,
74 /// Upstream error message.
75 message: String,
76 },
77
78 /// The upstream returned a non-success HTTP response.
79 #[error("upstream returned HTTP {status}: {body}")]
80 UpstreamHttp {
81 /// Upstream HTTP status code.
82 status: http::StatusCode,
83 /// Raw upstream error body.
84 body: String,
85 },
86
87 /// The upstream returned a response the client could not decode.
88 #[error("invalid upstream response: {source}")]
89 InvalidResponse {
90 /// Client-specific decoding or validation failure.
91 #[source]
92 source: BoxError,
93 },
94
95 /// A call across a foreign-function boundary (e.g. a Python-implemented client)
96 /// failed. The boxed source is the foreign error itself.
97 #[error("foreign function interface error: {source}")]
98 Ffi {
99 /// Foreign-language failure, preserved verbatim.
100 #[source]
101 source: BoxError,
102 },
103
104 /// A string message. Useful in testing, but prefer adding variants over using this.
105 #[error("{0}")]
106 General(String),
107}
108
109/// Why routing replaced a selected target with another eligible target.
110#[derive(Clone, Copy, Debug, Eq, PartialEq)]
111pub enum RoutingFallbackReason {
112 /// The selected target rejected the request because its context window was too small.
113 ContextWindow,
114 /// The selected target was unavailable after its client retries finished.
115 Unavailable,
116}
117
118impl RoutingFallbackReason {
119 /// Stable value used when logging a routing fallback.
120 pub const fn as_str(self) -> &'static str {
121 match self {
122 Self::ContextWindow => "context_window",
123 Self::Unavailable => "unavailable",
124 }
125 }
126}
127
128/// Performs the actual model call for a target. This is the one piece of I/O the
129/// library does not own — a host implements it over its own transport (HTTP SDK,
130/// in-process model, mock). It serves a call the stream consumer chose not to
131/// override, reached as a routed request's `default_client`.
132///
133/// # Concurrency
134///
135/// A client may be shared by many targets and concurrent algorithm runs. Calls may
136/// overlap, so implementations must synchronize mutable state internally and should
137/// not serialize requests unless their transport requires it.
138#[async_trait]
139pub trait RoutedLlmClient: Send + Sync {
140 /// Make a request
141 async fn call(&self, request: Request) -> Result<Response, LlmClientError>;
142}