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}