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//! Routed-call client contracts and shared error types.
5//!
6//! Hosts implement these traits to perform model calls. Keeping the contracts here
7//! lets clients depend on the protocol without pulling in libsy's orchestration.
8
9use async_trait::async_trait;
10use thiserror::Error;
11
12use crate::{DecisionRequest, DecisionResponse, ModelId, Request, Response};
13
14/// A boxed client-specific error preserved as the source of a routed call failure.
15pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
16
17/// Failures a routed LLM client can surface to its caller.
18///
19/// Boxed sources preserve client-specific errors; `General` keeps only a message.
20#[non_exhaustive]
21#[derive(Debug, Error)]
22pub enum LlmClientError {
23 /// The request cannot be served as supplied.
24 #[error("invalid request: {message}")]
25 InvalidRequest {
26 /// Human-readable request validation failure.
27 message: String,
28 },
29
30 /// Decoding the inbound request failed in the translation engine.
31 #[error("request translation failed: {0}")]
32 RequestTranslation(String),
33
34 /// Encoding the request for the upstream failed in the translation engine.
35 #[error("outbound request encoding failed: {0}")]
36 RequestEncoding(String),
37
38 /// Decoding or encoding the response failed in the translation engine.
39 #[error("response translation failed: {0}")]
40 ResponseTranslation(String),
41
42 /// The client is not configured to serve the selected target.
43 #[error("client configuration error: {message}")]
44 Configuration {
45 /// Human-readable configuration failure.
46 message: String,
47 },
48
49 /// The route cannot record another provider-owned response or conversation ID.
50 #[error(
51 "Responses state tracking reached its limit of {limit} IDs; no existing records were removed"
52 )]
53 ResponseStateLimitExceeded {
54 /// Maximum number of IDs retained by this route.
55 limit: usize,
56 },
57
58 /// Two configured models reported the same saved response or conversation ID.
59 #[error("Responses state ID is already recorded for another model; its owner was not changed")]
60 ResponseStateConflict,
61
62 /// The selected backend is temporarily unavailable. Another candidate may serve the request.
63 #[error("backend temporarily unavailable")]
64 TemporarilyUnavailable,
65
66 /// The upstream could not be reached or the request could not be sent.
67 #[error("upstream transport error: {source}")]
68 Transport {
69 /// Client-specific transport failure.
70 #[source]
71 source: BoxError,
72 },
73
74 /// The upstream request exceeded its timeout.
75 #[error("upstream request timed out: {source}")]
76 Timeout {
77 /// Client-specific timeout failure.
78 #[source]
79 source: BoxError,
80 },
81
82 /// The upstream rejected the request because it exceeds the model's context window.
83 #[error("context window exceeded for model {model}: {message}")]
84 ContextWindowExceeded {
85 /// Model whose context window was exceeded.
86 model: ModelId,
87 /// Upstream error message.
88 message: String,
89 },
90
91 /// The upstream returned a non-success HTTP response.
92 #[error("upstream returned HTTP {status}: {body}")]
93 UpstreamHttp {
94 /// Upstream HTTP status code.
95 status: http::StatusCode,
96 /// Raw upstream error body.
97 body: String,
98 },
99
100 /// The upstream returned a response the client could not decode.
101 #[error("invalid upstream response: {source}")]
102 InvalidResponse {
103 /// Client-specific decoding or validation failure.
104 #[source]
105 source: BoxError,
106 },
107
108 /// A native host failure that does not fit another category.
109 /// The default completion runner treats it as terminal.
110 #[error("host client error: {source}")]
111 Host {
112 /// Original error, retained for the host.
113 #[source]
114 source: BoxError,
115 },
116
117 /// A call across a foreign-function boundary (e.g. a Python-implemented client)
118 /// failed. The boxed source is the foreign error itself.
119 #[error("foreign function interface error: {source}")]
120 Ffi {
121 /// Foreign-language failure, preserved verbatim.
122 #[source]
123 source: BoxError,
124 },
125
126 /// A string-only failure. Useful in testing; prefer a typed variant when possible.
127 #[error("{0}")]
128 General(String),
129}
130
131/// Why routing replaced a selected target with another eligible target.
132#[derive(Clone, Copy, Debug, Eq, PartialEq)]
133pub enum RoutingFallbackReason {
134 /// The selected target rejected the request because its context window was too small.
135 ContextWindow,
136 /// The selected target was unavailable after its client retries finished.
137 Unavailable,
138}
139
140impl RoutingFallbackReason {
141 /// Stable value used when logging a routing fallback.
142 pub const fn as_str(self) -> &'static str {
143 match self {
144 Self::ContextWindow => "context_window",
145 Self::Unavailable => "unavailable",
146 }
147 }
148}
149
150/// Performs the actual model call for a target. This is the one piece of I/O the
151/// library does not own — a host implements it over its own transport (HTTP SDK,
152/// in-process model, mock). It serves a call the stream consumer chose not to
153/// override, reached as a routed request's `default_client`.
154///
155/// # Concurrency
156///
157/// A client may be shared by many targets and concurrent algorithm runs. Calls may
158/// overlap, so implementations must synchronize mutable state internally and should
159/// not serialize requests unless their transport requires it.
160#[async_trait]
161pub trait RoutedLlmClient: Send + Sync {
162 /// Make a request
163 async fn call(&self, request: Request) -> Result<Response, LlmClientError>;
164}
165
166/// Serves typed decision calls over a host-owned transport.
167/// Implementations may be shared across targets and called concurrently.
168#[async_trait]
169pub trait RoutedDecisionClient: Send + Sync {
170 /// Evaluate the request using its selected model.
171 async fn call(&self, request: DecisionRequest) -> Result<DecisionResponse, LlmClientError>;
172}