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 client owns overflow detection so callers receive one stable error
7//! classification across providers.
8
9use serde_json::Value;
10
11pub use switchyard_protocol::LlmClientError;
12
13/// Result alias for LLM client operations.
14pub type Result<T> = std::result::Result<T, LlmClientError>;
15
16/// Detects a context-overflow body using a provider-supplied structured check
17/// and a substring phrase list.
18///
19/// Parses the body once, runs the structured check (e.g. against `error.code`),
20/// then falls back to matching phrases against `error.message` (or `message`
21/// for a top-level `object: "error"`) or, when the body is not JSON, the raw body.
22/// Other JSON fields do not contribute to overflow detection.
23pub(crate) fn is_overflow_body<F>(body: &str, structured_check: F, phrases: &[&str]) -> bool
24where
25    F: Fn(&Value) -> bool,
26{
27    if let Ok(value) = serde_json::from_str::<Value>(body) {
28        if structured_check(&value) {
29            return true;
30        }
31        let error = value.get("error").or_else(|| {
32            (value.get("object").and_then(Value::as_str) == Some("error")).then_some(&value)
33        });
34        return error
35            .and_then(|err| err.get("message"))
36            .and_then(Value::as_str)
37            .is_some_and(|message| contains_any(message, phrases));
38    }
39    // Some upstream proxies return plain-text bodies; fall through to a string
40    // match on the raw body.
41    contains_any(body, phrases)
42}
43
44// Case-insensitive substring match of any phrase against the message.
45fn contains_any(message: &str, phrases: &[&str]) -> bool {
46    let lower = message.to_ascii_lowercase();
47    phrases.iter().any(|phrase| lower.contains(phrase))
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53
54    const PHRASES: &[&str] = &["context window", "too long"];
55
56    fn never(_value: &Value) -> bool {
57        false
58    }
59
60    #[test]
61    fn structured_check_short_circuits() {
62        let body = r#"{"error":{"code":"context_length_exceeded","message":"unrelated"}}"#;
63        let matched = is_overflow_body(
64            body,
65            |value| {
66                value
67                    .get("error")
68                    .and_then(|err| err.get("code"))
69                    .and_then(Value::as_str)
70                    == Some("context_length_exceeded")
71            },
72            &[],
73        );
74        assert!(matched);
75    }
76
77    #[test]
78    fn falls_back_to_message_phrase_match() {
79        let body = r#"{"error":{"message":"prompt too long"}}"#;
80        assert!(is_overflow_body(body, never, PHRASES));
81    }
82
83    #[test]
84    fn matches_plain_text_body() {
85        assert!(is_overflow_body(
86            "plain text mentioning context window",
87            never,
88            PHRASES
89        ));
90    }
91
92    #[test]
93    fn non_match_returns_false() {
94        let body = r#"{"error":{"message":"rate limit exceeded"}}"#;
95        assert!(!is_overflow_body(body, never, PHRASES));
96    }
97}