Skip to main content

llm_output_parser/
json.rs

1//! Typed JSON extraction from LLM responses.
2//!
3//! Provides [`parse_json`] for extracting typed structs and [`parse_json_value`]
4//! for untyped JSON extraction, using a multi-strategy pipeline that handles
5//! think blocks, markdown fences, bracket matching, and JSON repair.
6//!
7//! The `_with_trace` variants return a [`ParseTrace`] alongside the result
8//! for observability and debugging.
9
10use serde::de::DeserializeOwned;
11
12use crate::error::{
13    ensure_input_within_limits, record_extracted_span, truncate, ParseError, ParseOptions,
14    ParseTrace,
15};
16use crate::extract::{
17    extract_code_block, extract_code_block_for, find_bracketed_limited, preprocess_opts,
18};
19use crate::repair::try_repair_json;
20
21/// Parse an LLM response into a typed struct.
22///
23/// Strategies (in order):
24/// 1. Direct deserialize on preprocessed text
25/// 2. Extract from markdown code block (`` ```json ``)
26/// 3. Extract from any code block
27/// 4. Bracket-match a JSON object (`{...}`)
28/// 5. Bracket-match a JSON array (`[...]`)
29/// 6. Repair malformed JSON then retry strategies 1-5
30///
31/// # Errors
32///
33/// Returns [`ParseError::EmptyResponse`] if input is empty after preprocessing,
34/// [`ParseError::DeserializationFailed`] if JSON was found but doesn't match `T`.
35///
36/// # Examples
37///
38/// ```
39/// use serde::Deserialize;
40/// use llm_output_parser::parse_json;
41///
42/// #[derive(Deserialize, Debug, PartialEq)]
43/// struct Analysis {
44///     sentiment: String,
45///     confidence: f64,
46/// }
47///
48/// let response = r#"<think>analyzing...</think>{"sentiment": "positive", "confidence": 0.92}"#;
49/// let result: Analysis = parse_json(response).unwrap();
50/// assert_eq!(result.sentiment, "positive");
51/// ```
52pub fn parse_json<T: DeserializeOwned>(response: &str) -> Result<T, ParseError> {
53    let opts = ParseOptions::default();
54    let (result, _trace) = parse_json_with_trace::<T>(response, &opts)?;
55    Ok(result)
56}
57
58/// Parse into a `serde_json::Value` when you don't know the schema.
59///
60/// Uses the same strategy pipeline as [`parse_json`].
61pub fn parse_json_value(response: &str) -> Result<serde_json::Value, ParseError> {
62    parse_json(response)
63}
64
65/// Parse an LLM response into a typed struct, returning a diagnostic trace.
66///
67/// Identical to [`parse_json`] but accepts [`ParseOptions`] for safety limits
68/// and returns [`ParseTrace`] recording which strategies were attempted.
69///
70/// # Errors
71///
72/// Returns [`ParseError::TooLarge`] if input exceeds `opts.max_input_bytes`,
73/// [`ParseError::TooDeep`] if JSON nesting exceeds `opts.max_nesting_depth`.
74pub fn parse_json_with_trace<T: DeserializeOwned>(
75    response: &str,
76    opts: &ParseOptions,
77) -> Result<(T, ParseTrace), ParseError> {
78    ensure_input_within_limits(response, opts)?;
79
80    let mut trace = ParseTrace::default();
81    let (candidate, cleaned) = extract_json_candidate_traced(response, opts, &mut trace)?;
82
83    // Try deserializing the candidate
84    trace.strategies_tried.push("deserialize_candidate");
85    let deser_err = match serde_json::from_str::<T>(&candidate) {
86        Ok(val) => return Ok((val, trace)),
87        Err(e) => e.to_string(),
88    };
89
90    // Try repair on the candidate (bounded)
91    let mut repair_attempts = 0;
92    if repair_attempts < opts.max_repair_attempts {
93        trace.strategies_tried.push("repair_candidate");
94        repair_attempts += 1;
95        if let Some(repaired) = try_repair_json(&candidate) {
96            trace.repaired = true;
97            trace.repair_actions.push("repaired_candidate".to_string());
98            if let Ok(val) = serde_json::from_str::<T>(&repaired) {
99                return Ok((val, trace));
100            }
101        }
102    }
103
104    // Try repair on the full cleaned text if different from candidate
105    if candidate != cleaned && repair_attempts < opts.max_repair_attempts {
106        trace.strategies_tried.push("repair_cleaned");
107        repair_attempts += 1;
108        if let Some(repaired) = try_repair_json(&cleaned) {
109            trace.repaired = true;
110            trace.repair_actions.push("repaired_cleaned".to_string());
111            if let Ok(val) = serde_json::from_str::<T>(&repaired) {
112                return Ok((val, trace));
113            }
114        }
115    }
116
117    // Suppress unused-variable warning for future use
118    let _ = repair_attempts;
119
120    // All strategies exhausted
121    Err(ParseError::DeserializationFailed {
122        reason: deser_err,
123        raw_json: truncate(&candidate, 200),
124    })
125}
126
127/// Parse into a `serde_json::Value` with diagnostic trace.
128///
129/// See [`parse_json_with_trace`] for details.
130pub fn parse_json_value_with_trace(
131    response: &str,
132    opts: &ParseOptions,
133) -> Result<(serde_json::Value, ParseTrace), ParseError> {
134    parse_json_with_trace(response, opts)
135}
136
137/// Traced version of candidate extraction with safety limits.
138fn extract_json_candidate_traced(
139    response: &str,
140    opts: &ParseOptions,
141    trace: &mut ParseTrace,
142) -> Result<(String, String), ParseError> {
143    let trimmed = response.trim();
144
145    if trimmed.is_empty() {
146        return Err(ParseError::EmptyResponse);
147    }
148
149    let cleaned = preprocess_opts(trimmed, opts.strip_think_tags);
150
151    if cleaned.is_empty() {
152        return Err(ParseError::EmptyResponse);
153    }
154
155    // Strategy 1: Direct parse on cleaned text
156    trace.strategies_tried.push("direct_parse");
157    if serde_json::from_str::<serde_json::Value>(&cleaned).is_ok() {
158        trace.extracted_span = Some((0, cleaned.len()));
159        return Ok((cleaned.clone(), cleaned));
160    }
161
162    if opts.allow_code_fences {
163        // Strategy 2: Extract from ```json code block
164        trace.strategies_tried.push("json_code_block");
165        if let Some(content) = extract_code_block_for(&cleaned, "json") {
166            record_extracted_span(trace, &cleaned, content);
167            if serde_json::from_str::<serde_json::Value>(content).is_ok() {
168                return Ok((content.to_string(), cleaned));
169            }
170            // Even if not valid yet, this is a good candidate for repair
171            return Ok((content.to_string(), cleaned));
172        }
173
174        // Strategy 3: Extract from any code block
175        trace.strategies_tried.push("any_code_block");
176        if let Some((_lang, content)) = extract_code_block(&cleaned) {
177            // Check if it looks like JSON (starts with { or [)
178            let trimmed_content = content.trim();
179            if trimmed_content.starts_with('{') || trimmed_content.starts_with('[') {
180                record_extracted_span(trace, &cleaned, trimmed_content);
181                if serde_json::from_str::<serde_json::Value>(trimmed_content).is_ok() {
182                    return Ok((trimmed_content.to_string(), cleaned));
183                }
184                return Ok((trimmed_content.to_string(), cleaned));
185            }
186        }
187    }
188
189    // Strategy 4: Bracket-match a JSON object (with depth limit)
190    trace.strategies_tried.push("bracket_match_object");
191    if let Some(bracket_str) = find_bracketed_limited(&cleaned, '{', '}', opts.max_nesting_depth)? {
192        record_extracted_span(trace, &cleaned, bracket_str);
193        if serde_json::from_str::<serde_json::Value>(bracket_str).is_ok() {
194            return Ok((bracket_str.to_string(), cleaned));
195        }
196        return Ok((bracket_str.to_string(), cleaned));
197    }
198
199    // Strategy 5: Bracket-match a JSON array (with depth limit)
200    trace.strategies_tried.push("bracket_match_array");
201    if let Some(bracket_str) = find_bracketed_limited(&cleaned, '[', ']', opts.max_nesting_depth)? {
202        record_extracted_span(trace, &cleaned, bracket_str);
203        if serde_json::from_str::<serde_json::Value>(bracket_str).is_ok() {
204            return Ok((bracket_str.to_string(), cleaned));
205        }
206        return Ok((bracket_str.to_string(), cleaned));
207    }
208
209    // No candidate found — return cleaned text as the candidate for repair
210    Ok((cleaned.clone(), cleaned))
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use serde::Deserialize;
217
218    #[derive(Debug, Deserialize, PartialEq)]
219    struct Kv {
220        key: String,
221    }
222
223    #[derive(Debug, Deserialize, PartialEq)]
224    struct Sentiment {
225        sentiment: String,
226    }
227
228    #[derive(Debug, Deserialize, PartialEq)]
229    struct Outer {
230        outer: Inner,
231    }
232
233    #[derive(Debug, Deserialize, PartialEq)]
234    struct Inner {
235        inner: Vec<i32>,
236    }
237
238    #[test]
239    fn direct_json_object() {
240        let input = r#"{"key": "value"}"#;
241        let result: Kv = parse_json(input).unwrap();
242        assert_eq!(result.key, "value");
243    }
244
245    #[test]
246    fn direct_json_array() {
247        let input = "[1, 2, 3]";
248        let result: Vec<i32> = parse_json(input).unwrap();
249        assert_eq!(result, vec![1, 2, 3]);
250    }
251
252    #[test]
253    fn think_then_json() {
254        let input = r#"<think>analyzing</think>{"key": "value"}"#;
255        let result: Kv = parse_json(input).unwrap();
256        assert_eq!(result.key, "value");
257    }
258
259    #[test]
260    fn code_block_json() {
261        let input = "Here's the data:\n```json\n{\"key\": \"value\"}\n```";
262        let result: Kv = parse_json(input).unwrap();
263        assert_eq!(result.key, "value");
264    }
265
266    #[test]
267    fn bare_code_block() {
268        let input = "```\n{\"key\": \"value\"}\n```";
269        let result: Kv = parse_json(input).unwrap();
270        assert_eq!(result.key, "value");
271    }
272
273    #[test]
274    fn json_in_prose() {
275        let input = r#"The analysis is {"sentiment": "positive"} as shown."#;
276        let result: Sentiment = parse_json(input).unwrap();
277        assert_eq!(result.sentiment, "positive");
278    }
279
280    #[test]
281    fn nested_json() {
282        let input = r#"{"outer": {"inner": [1,2,3]}}"#;
283        let result: Outer = parse_json(input).unwrap();
284        assert_eq!(result.outer.inner, vec![1, 2, 3]);
285    }
286
287    #[test]
288    fn repaired_trailing_comma() {
289        let input = r#"{"key": "value",}"#;
290        let result: Kv = parse_json(input).unwrap();
291        assert_eq!(result.key, "value");
292    }
293
294    #[test]
295    fn repaired_single_quotes() {
296        let input = "{'key': 'value'}";
297        let result: Kv = parse_json(input).unwrap();
298        assert_eq!(result.key, "value");
299    }
300
301    #[test]
302    fn think_and_code_block() {
303        let input = "<think>hmm</think>\n```json\n{\"key\": \"value\"}\n```";
304        let result: Kv = parse_json(input).unwrap();
305        assert_eq!(result.key, "value");
306    }
307
308    #[test]
309    fn json_with_surrounding_text() {
310        let input = "Sure! Here's your result: {\"key\": \"value\"}\nHope that helps!";
311        let result: Kv = parse_json(input).unwrap();
312        assert_eq!(result.key, "value");
313    }
314
315    #[test]
316    fn parse_json_value_works() {
317        let input = r#"{"a": 1, "b": "two"}"#;
318        let val = parse_json_value(input).unwrap();
319        assert_eq!(val["a"], 1);
320        assert_eq!(val["b"], "two");
321    }
322
323    #[test]
324    fn empty_response_fails() {
325        let result: Result<Kv, _> = parse_json("");
326        assert!(result.is_err());
327    }
328
329    // -- Traced variant tests --
330
331    #[test]
332    fn parse_json_value_with_trace_direct() {
333        let input = r#"{"a": 1}"#;
334        let opts = ParseOptions::default();
335        let (val, trace) = parse_json_value_with_trace(input, &opts).unwrap();
336        assert_eq!(val["a"], 1);
337        assert!(trace.strategies_tried.contains(&"direct_parse"));
338        assert!(!trace.repaired);
339    }
340
341    #[test]
342    fn parse_json_with_trace_repair() {
343        let input = "{'key': 'value'}";
344        let opts = ParseOptions::default();
345        let (val, trace): (Kv, ParseTrace) = parse_json_with_trace(input, &opts).unwrap();
346        assert_eq!(val.key, "value");
347        assert!(trace.repaired);
348        assert!(!trace.repair_actions.is_empty());
349    }
350
351    #[test]
352    fn parse_json_with_trace_code_block() {
353        let input = "Here:\n```json\n{\"key\": \"value\"}\n```";
354        let opts = ParseOptions::default();
355        let (val, trace): (Kv, ParseTrace) = parse_json_with_trace(input, &opts).unwrap();
356        assert_eq!(val.key, "value");
357        assert!(trace.strategies_tried.contains(&"json_code_block"));
358    }
359
360    // -- Safety limit tests --
361
362    #[test]
363    fn too_large_input() {
364        let input = "x".repeat(100);
365        let opts = ParseOptions {
366            max_input_bytes: 50,
367            ..Default::default()
368        };
369        let result = parse_json_value_with_trace(&input, &opts);
370        match result {
371            Err(ParseError::TooLarge {
372                size: 100,
373                limit: 50,
374            }) => {}
375            other => panic!("expected TooLarge, got {other:?}"),
376        }
377    }
378
379    #[test]
380    fn too_large_exact_boundary() {
381        // Input at exactly the limit should succeed (not TooLarge)
382        let input = r#"{"a":1}"#;
383        let opts = ParseOptions {
384            max_input_bytes: input.len(),
385            ..Default::default()
386        };
387        let result = parse_json_value_with_trace(input, &opts);
388        assert!(result.is_ok());
389    }
390
391    #[test]
392    fn too_deep_nesting() {
393        // Wrap deeply nested JSON in prose so direct serde parse fails
394        // and bracket matching runs (which enforces depth limits)
395        let mut nested = String::new();
396        for _ in 0..5 {
397            nested.push_str(r#"{"a":"#);
398        }
399        nested.push('1');
400        for _ in 0..5 {
401            nested.push('}');
402        }
403        let input = format!("Here is the result: {nested}");
404        let opts = ParseOptions {
405            max_nesting_depth: 3,
406            ..Default::default()
407        };
408        let result = parse_json_value_with_trace(&input, &opts);
409        match result {
410            Err(ParseError::TooDeep { .. }) => {}
411            other => panic!("expected TooDeep, got {other:?}"),
412        }
413    }
414
415    #[test]
416    fn too_deep_exact_boundary() {
417        // Wrap in prose to force bracket matching; depth exactly at limit should succeed
418        let input = r#"Here: {"a": {"b": 1}}"#; // depth 2
419        let opts = ParseOptions {
420            max_nesting_depth: 2,
421            ..Default::default()
422        };
423        let result = parse_json_value_with_trace(input, &opts);
424        assert!(result.is_ok());
425    }
426
427    #[test]
428    fn strip_think_tags_option_disabled() {
429        let input = r#"<think>{"key": "wrong"}</think>{"key": "right"}"#;
430        let opts = ParseOptions {
431            strip_think_tags: false,
432            ..Default::default()
433        };
434        // With stripping disabled, the think tags stay and the first JSON wins
435        let (val, _trace) = parse_json_value_with_trace(input, &opts).unwrap();
436        // The bracket matcher will find the last {}, which is {"key": "right"}
437        assert_eq!(val["key"], "right");
438    }
439
440    #[test]
441    fn code_fences_option_disabled() {
442        // With code fences disabled, should still find JSON via bracket matching
443        let input = "```json\n{\"key\": \"fenced\"}\n```\n{\"key\": \"bare\"}";
444        let opts = ParseOptions {
445            allow_code_fences: false,
446            ..Default::default()
447        };
448        let (val, trace): (Kv, ParseTrace) = parse_json_with_trace(input, &opts).unwrap();
449        // Should NOT have tried code block strategies
450        assert!(!trace.strategies_tried.contains(&"json_code_block"));
451        assert!(!trace.strategies_tried.contains(&"any_code_block"));
452        assert_eq!(val.key, "bare");
453    }
454}