Skip to main content

switchyard_llm_client/
error.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Canonical client error re-export and shared context-window-overflow detection.
5//!
6//! The overflow detection is ported from
7//! `switchyard-components/src/backends/context_overflow.rs`; that helper is
8//! crate-private there and this crate cannot depend on `switchyard-components`,
9//! so the small, self-contained logic is vendored here.
10
11use serde_json::Value;
12
13pub use switchyard_protocol::LlmClientError;
14
15/// Result alias for LLM client operations.
16pub type Result<T> = std::result::Result<T, LlmClientError>;
17
18/// Detects a context-overflow body using a provider-supplied structured check
19/// and a substring phrase list.
20///
21/// Parses the body once, runs the structured check (e.g. against `error.code`),
22/// then falls back to matching phrases against `error.message` or — when the
23/// body is not JSON — the raw body. Centralizing the shape means each new
24/// provider-wrap of the canonical error is a one-line phrase entry, not a fork
25/// of the parsing logic.
26pub(crate) fn is_overflow_body<F>(body: &str, structured_check: F, phrases: &[&str]) -> bool
27where
28    F: Fn(&Value) -> bool,
29{
30    if let Ok(value) = serde_json::from_str::<Value>(body) {
31        if structured_check(&value) {
32            return true;
33        }
34        if let Some(message) = value
35            .get("error")
36            .and_then(|err| err.get("message"))
37            .and_then(Value::as_str)
38            && contains_any(message, phrases)
39        {
40            return true;
41        }
42    }
43    // Some upstream proxies return plain-text bodies; fall through to a string
44    // match on the raw body.
45    contains_any(body, phrases)
46}
47
48// Case-insensitive substring match of any phrase against the message.
49fn contains_any(message: &str, phrases: &[&str]) -> bool {
50    let lower = message.to_ascii_lowercase();
51    phrases.iter().any(|phrase| lower.contains(phrase))
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57
58    const PHRASES: &[&str] = &["context window", "too long"];
59
60    fn never(_value: &Value) -> bool {
61        false
62    }
63
64    #[test]
65    fn structured_check_short_circuits() {
66        let body = r#"{"error":{"code":"context_length_exceeded","message":"unrelated"}}"#;
67        let matched = is_overflow_body(
68            body,
69            |value| {
70                value
71                    .get("error")
72                    .and_then(|err| err.get("code"))
73                    .and_then(Value::as_str)
74                    == Some("context_length_exceeded")
75            },
76            &[],
77        );
78        assert!(matched);
79    }
80
81    #[test]
82    fn falls_back_to_message_phrase_match() {
83        let body = r#"{"error":{"message":"prompt too long"}}"#;
84        assert!(is_overflow_body(body, never, PHRASES));
85    }
86
87    #[test]
88    fn matches_plain_text_body() {
89        assert!(is_overflow_body(
90            "plain text mentioning context window",
91            never,
92            PHRASES
93        ));
94    }
95
96    #[test]
97    fn non_match_returns_false() {
98        let body = r#"{"error":{"message":"rate limit exceeded"}}"#;
99        assert!(!is_overflow_body(body, never, PHRASES));
100    }
101}