Skip to main content

llm_output_parser/
error.rs

1//! Error types and configuration for LLM output parsers.
2
3/// Options controlling parser behavior and safety limits.
4///
5/// All public parse functions accept `ParseOptions` (via `_with_trace` variants)
6/// to configure safety bounds and preprocessing behavior.
7///
8/// # Defaults
9///
10/// ```
11/// use llm_output_parser::ParseOptions;
12///
13/// let opts = ParseOptions::default();
14/// assert_eq!(opts.max_input_bytes, 2_097_152);
15/// assert_eq!(opts.max_nesting_depth, 64);
16/// assert_eq!(opts.max_repair_attempts, 3);
17/// assert!(opts.strip_think_tags);
18/// assert!(opts.allow_code_fences);
19/// ```
20#[derive(Debug, Clone)]
21pub struct ParseOptions {
22    /// Maximum input size in bytes. Inputs exceeding this return `ParseError::TooLarge`.
23    /// Default: 2,097,152 (2 MB).
24    pub max_input_bytes: usize,
25    /// Maximum JSON nesting depth tracked during bracket matching.
26    /// Inputs exceeding this return `ParseError::TooDeep`.
27    /// Default: 64.
28    pub max_nesting_depth: usize,
29    /// Maximum number of JSON repair attempts before giving up.
30    /// Default: 3.
31    pub max_repair_attempts: usize,
32    /// Whether to strip `<think>` and `<thinking>` blocks during preprocessing.
33    /// Default: true.
34    pub strip_think_tags: bool,
35    /// Whether to attempt extraction from markdown code fences.
36    /// Default: true.
37    pub allow_code_fences: bool,
38}
39
40impl Default for ParseOptions {
41    fn default() -> Self {
42        Self {
43            max_input_bytes: 2_097_152,
44            max_nesting_depth: 64,
45            max_repair_attempts: 3,
46            strip_think_tags: true,
47            allow_code_fences: true,
48        }
49    }
50}
51
52/// Diagnostic trace capturing the execution path of a parse operation.
53///
54/// Returned by `_with_trace` variants of parse functions. Records which
55/// strategies were attempted, whether repair was applied, and the byte span
56/// of the extracted content.
57#[derive(Debug, Clone, Default)]
58pub struct ParseTrace {
59    /// Names of strategies attempted, in order.
60    pub strategies_tried: Vec<&'static str>,
61    /// Whether JSON repair was applied to produce the final result.
62    pub repaired: bool,
63    /// Descriptions of repair actions applied (e.g., "remove_trailing_commas").
64    pub repair_actions: Vec<String>,
65    /// Byte offset span `(start, end)` of the extracted content within the
66    /// preprocessed input, if applicable.
67    pub extracted_span: Option<(usize, usize)>,
68    /// Non-fatal warnings encountered during parsing.
69    pub warnings: Vec<String>,
70}
71
72/// Errors returned by output parsers.
73#[derive(Debug, thiserror::Error)]
74pub enum ParseError {
75    /// The LLM response was empty or whitespace-only.
76    #[error("empty LLM response")]
77    EmptyResponse,
78
79    /// No parsing strategy could extract the expected format.
80    #[error("could not parse {expected_format} from LLM response: {text}")]
81    Unparseable {
82        /// The format the parser was trying to extract.
83        expected_format: &'static str,
84        /// A truncated copy of the cleaned LLM text (max 200 chars).
85        text: String,
86    },
87
88    /// JSON was extracted but failed to deserialize into the target type.
89    #[error("JSON deserialization failed: {reason}")]
90    DeserializationFailed {
91        /// The serde error message.
92        reason: String,
93        /// The raw JSON string that failed deserialization.
94        raw_json: String,
95    },
96
97    /// No valid choice from the provided options was found.
98    #[error("no valid choice found in response (valid: {valid:?})")]
99    NoMatchingChoice {
100        /// The list of choices that were searched for.
101        valid: Vec<String>,
102    },
103
104    /// No number found, or number was outside the expected range.
105    #[error("no valid number found in response")]
106    NoNumber,
107
108    /// Input exceeds `ParseOptions::max_input_bytes`.
109    #[error("input too large: {size} bytes exceeds limit of {limit} bytes")]
110    TooLarge {
111        /// Actual input size in bytes.
112        size: usize,
113        /// Configured limit in bytes.
114        limit: usize,
115    },
116
117    /// JSON nesting exceeds `ParseOptions::max_nesting_depth`.
118    #[error("nesting too deep: depth {depth} exceeds limit of {limit}")]
119    TooDeep {
120        /// Detected nesting depth.
121        depth: usize,
122        /// Configured limit.
123        limit: usize,
124    },
125}
126
127impl ParseError {
128    /// Stable string discriminant for programmatic matching.
129    pub fn kind(&self) -> &'static str {
130        match self {
131            Self::EmptyResponse => "empty_response",
132            Self::Unparseable { .. } => "unparseable",
133            Self::DeserializationFailed { .. } => "deserialization_failed",
134            Self::NoMatchingChoice { .. } => "no_matching_choice",
135            Self::NoNumber => "no_number",
136            Self::TooLarge { .. } => "too_large",
137            Self::TooDeep { .. } => "too_deep",
138        }
139    }
140}
141
142pub(crate) fn ensure_input_within_limits(
143    response: &str,
144    opts: &ParseOptions,
145) -> Result<(), ParseError> {
146    if response.len() > opts.max_input_bytes {
147        return Err(ParseError::TooLarge {
148            size: response.len(),
149            limit: opts.max_input_bytes,
150        });
151    }
152
153    Ok(())
154}
155
156pub(crate) fn record_extracted_span(trace: &mut ParseTrace, cleaned: &str, extracted: &str) {
157    if extracted.is_empty() {
158        return;
159    }
160
161    if let Some(start) = cleaned.find(extracted) {
162        trace.extracted_span = Some((start, start + extracted.len()));
163    }
164}
165
166/// Truncate a string to at most `max_len` characters, appending "..." if truncated.
167/// Uses char-wise iteration to preserve UTF-8 codepoint boundaries and prevent panics
168/// on multi-byte characters (em-dashes, CJK, emoji, etc.).
169#[allow(dead_code)]
170pub(crate) fn truncate(s: &str, max_len: usize) -> String {
171    let mut chars = s.chars();
172    if chars.by_ref().count() <= max_len {
173        s.to_string()
174    } else {
175        let truncated: String = s.chars().take(max_len).collect();
176        format!("{}...", truncated)
177    }
178}