1use libsy::LibsyError;
7use strum_macros::IntoStaticStr;
8use switchyard_protocol::{LlmClientError, ModelId};
9
10use crate::RunnerError;
11
12#[non_exhaustive]
17#[derive(Clone, Copy, Debug, IntoStaticStr)]
18#[strum(serialize_all = "snake_case")]
19pub enum RouteErrorKind {
20 UpstreamHttp,
22 ContextWindowExceeded,
24 TemporarilyUnavailable,
26 Timeout,
28 Transport,
30 InvalidResponse,
32 RequestTranslation,
34 RequestEncoding,
36 ResponseTranslation,
38 InvalidRequest,
40 Configuration,
42 Algorithm,
44 Other,
46}
47
48impl RouteErrorKind {
49 pub fn as_str(self) -> &'static str {
51 self.into()
52 }
53}
54
55#[non_exhaustive]
57#[derive(Clone, Copy, Debug, IntoStaticStr)]
58#[strum(serialize_all = "snake_case")]
59pub enum RouteErrorPhase {
60 BeforeResponse,
62 DuringStream,
64}
65
66impl RouteErrorPhase {
67 pub fn as_str(self) -> &'static str {
69 self.into()
70 }
71}
72
73#[non_exhaustive]
75#[derive(Clone, Debug)]
76pub struct RouteErrorSummary {
77 pub kind: RouteErrorKind,
79 pub phase: RouteErrorPhase,
81 pub upstream_status: Option<u16>,
83 pub target: Option<ModelId>,
85}
86
87impl RunnerError {
88 pub fn execution_error_summary(&self) -> RouteErrorSummary {
90 match self {
91 Self::Algorithm(LibsyError::ClientCall { target, source }) => {
92 client_error_summary(source, RouteErrorPhase::BeforeResponse, Some(target))
93 }
94 Self::Client(source) => {
95 client_error_summary(source, RouteErrorPhase::BeforeResponse, None)
96 }
97 Self::Configuration { .. } => summary(
98 RouteErrorKind::Configuration,
99 RouteErrorPhase::BeforeResponse,
100 None,
101 None,
102 ),
103 Self::UnknownRouteModel(_)
104 | Self::IncompatibleCallerFormat(_)
105 | Self::AuxiliaryUnsupported => summary(
106 RouteErrorKind::InvalidRequest,
107 RouteErrorPhase::BeforeResponse,
108 None,
109 None,
110 ),
111 Self::Algorithm(_) => summary(
112 RouteErrorKind::Algorithm,
113 RouteErrorPhase::BeforeResponse,
114 None,
115 None,
116 ),
117 }
118 }
119}
120
121pub fn stream_error_summary(
125 error: &LlmClientError,
126 served_model: Option<&ModelId>,
127) -> RouteErrorSummary {
128 client_error_summary(error, RouteErrorPhase::DuringStream, served_model)
129}
130
131fn client_error_summary(
132 error: &LlmClientError,
133 phase: RouteErrorPhase,
134 target: Option<&ModelId>,
135) -> RouteErrorSummary {
136 let target = target.or(match error {
137 LlmClientError::ContextWindowExceeded { model, .. } => Some(model),
140 _ => None,
141 });
142 let (kind, upstream_status) = match error {
143 LlmClientError::UpstreamHttp { status, .. } => {
144 (RouteErrorKind::UpstreamHttp, Some(status.as_u16()))
145 }
146 LlmClientError::ContextWindowExceeded { .. } => {
147 (RouteErrorKind::ContextWindowExceeded, None)
148 }
149 LlmClientError::TemporarilyUnavailable => (RouteErrorKind::TemporarilyUnavailable, None),
150 LlmClientError::Timeout { .. } => (RouteErrorKind::Timeout, None),
151 LlmClientError::Transport { .. } => (RouteErrorKind::Transport, None),
152 LlmClientError::InvalidResponse { .. } => (RouteErrorKind::InvalidResponse, None),
153 LlmClientError::RequestTranslation(_) => (RouteErrorKind::RequestTranslation, None),
154 LlmClientError::RequestEncoding(_) => (RouteErrorKind::RequestEncoding, None),
155 LlmClientError::ResponseTranslation(_) => (RouteErrorKind::ResponseTranslation, None),
156 LlmClientError::InvalidRequest { .. } => (RouteErrorKind::InvalidRequest, None),
157 LlmClientError::Configuration { .. } => (RouteErrorKind::Configuration, None),
158 LlmClientError::Ffi { .. } | LlmClientError::General(_) => (RouteErrorKind::Other, None),
159 _ => (RouteErrorKind::Other, None),
160 };
161 summary(kind, phase, upstream_status, target)
162}
163
164fn summary(
165 kind: RouteErrorKind,
166 phase: RouteErrorPhase,
167 upstream_status: Option<u16>,
168 target: Option<&ModelId>,
169) -> RouteErrorSummary {
170 RouteErrorSummary {
171 kind,
172 phase,
173 upstream_status,
174 target: target.cloned(),
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 const SECRET: &str = "patient name is Jane Doe";
183
184 #[test]
185 fn execution_error_summary_keeps_http_status_and_target_without_body() {
186 let error = RunnerError::Algorithm(LibsyError::ClientCall {
187 target: ModelId::from("strong"),
188 source: LlmClientError::UpstreamHttp {
189 status: reqwest::StatusCode::SERVICE_UNAVAILABLE,
190 body: format!("upstream response: {SECRET}"),
191 },
192 });
193
194 let summary = error.execution_error_summary();
195
196 assert!(matches!(summary.kind, RouteErrorKind::UpstreamHttp));
197 assert!(matches!(summary.phase, RouteErrorPhase::BeforeResponse));
198 assert_eq!(summary.upstream_status, Some(503));
199 assert_eq!(summary.target.as_ref().map(ModelId::as_str), Some("strong"));
200 assert!(!format!("{summary:?}").contains(SECRET));
201 }
202
203 #[test]
204 fn execution_error_summary_reduces_untrusted_messages_to_kinds() {
205 let error = RunnerError::Algorithm(LibsyError::AlgorithmError {
206 message: SECRET.to_string(),
207 });
208
209 let summary = error.execution_error_summary();
210
211 assert!(matches!(summary.kind, RouteErrorKind::Algorithm));
212 assert_eq!(summary.target, None);
213 assert!(!format!("{summary:?}").contains(SECRET));
214 }
215
216 #[test]
217 fn stream_error_summary_preserves_served_target_without_source_text() {
218 let error = LlmClientError::Timeout {
219 source: std::io::Error::other(SECRET).into(),
220 };
221
222 let summary = stream_error_summary(&error, Some(&ModelId::from("fallback")));
223
224 assert!(matches!(summary.kind, RouteErrorKind::Timeout));
225 assert!(matches!(summary.phase, RouteErrorPhase::DuringStream));
226 assert_eq!(
227 summary.target.as_ref().map(ModelId::as_str),
228 Some("fallback")
229 );
230 assert!(!format!("{summary:?}").contains(SECRET));
231 }
232
233 #[test]
234 fn stream_error_summary_uses_context_window_model_without_a_served_target() {
235 let error = LlmClientError::ContextWindowExceeded {
236 model: ModelId::from("weak"),
237 message: SECRET.to_string(),
238 };
239
240 let summary = stream_error_summary(&error, None);
241
242 assert!(matches!(
243 summary.kind,
244 RouteErrorKind::ContextWindowExceeded
245 ));
246 assert!(matches!(summary.phase, RouteErrorPhase::DuringStream));
247 assert_eq!(summary.target.as_ref().map(ModelId::as_str), Some("weak"));
248 assert!(!format!("{summary:?}").contains(SECRET));
249 }
250
251 #[test]
252 fn client_error_kinds_have_stable_telemetry_values() {
253 let cases = vec![
254 (
255 LlmClientError::ContextWindowExceeded {
256 model: ModelId::from("weak"),
257 message: SECRET.to_string(),
258 },
259 "context_window_exceeded",
260 ),
261 (
262 LlmClientError::Transport {
263 source: std::io::Error::other(SECRET).into(),
264 },
265 "transport",
266 ),
267 (
268 LlmClientError::InvalidResponse {
269 source: std::io::Error::other(SECRET).into(),
270 },
271 "invalid_response",
272 ),
273 (
274 LlmClientError::RequestTranslation(SECRET.to_string()),
275 "request_translation",
276 ),
277 (
278 LlmClientError::RequestEncoding(SECRET.to_string()),
279 "request_encoding",
280 ),
281 (
282 LlmClientError::ResponseTranslation(SECRET.to_string()),
283 "response_translation",
284 ),
285 (
286 LlmClientError::Configuration {
287 message: SECRET.to_string(),
288 },
289 "configuration",
290 ),
291 (
292 LlmClientError::InvalidRequest {
293 message: SECRET.to_string(),
294 },
295 "invalid_request",
296 ),
297 (LlmClientError::General(SECRET.to_string()), "other"),
298 ];
299
300 for (error, value) in cases {
301 let summary = stream_error_summary(&error, None);
302 assert_eq!(summary.kind.as_str(), value);
303 assert!(!format!("{summary:?}").contains(SECRET));
304 }
305 }
306
307 #[test]
308 fn route_error_phases_have_stable_telemetry_values() {
309 assert_eq!(RouteErrorPhase::BeforeResponse.as_str(), "before_response");
310 assert_eq!(RouteErrorPhase::DuringStream.as_str(), "during_stream");
311 }
312
313 #[test]
314 fn direct_client_errors_do_not_claim_a_target() {
315 let error = RunnerError::Client(LlmClientError::Timeout {
316 source: std::io::Error::other(SECRET).into(),
317 });
318
319 let summary = error.execution_error_summary();
320
321 assert!(matches!(summary.kind, RouteErrorKind::Timeout));
322 assert!(matches!(summary.phase, RouteErrorPhase::BeforeResponse));
323 assert_eq!(summary.target, None);
324 assert!(!format!("{summary:?}").contains(SECRET));
325 }
326
327 #[test]
328 fn runner_request_and_configuration_errors_are_classified() {
329 let configuration = RunnerError::configuration(SECRET);
330 assert!(matches!(
331 configuration.execution_error_summary().kind,
332 RouteErrorKind::Configuration
333 ));
334
335 let unsupported = RunnerError::AuxiliaryUnsupported;
336 assert!(matches!(
337 unsupported.execution_error_summary().kind,
338 RouteErrorKind::InvalidRequest
339 ));
340 }
341}