#[derive(Debug, Clone)]
pub struct ParseOptions {
pub max_input_bytes: usize,
pub max_nesting_depth: usize,
pub max_repair_attempts: usize,
pub strip_think_tags: bool,
pub allow_code_fences: bool,
}
impl Default for ParseOptions {
fn default() -> Self {
Self {
max_input_bytes: 2_097_152,
max_nesting_depth: 64,
max_repair_attempts: 3,
strip_think_tags: true,
allow_code_fences: true,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ParseTrace {
pub strategies_tried: Vec<&'static str>,
pub repaired: bool,
pub repair_actions: Vec<String>,
pub extracted_span: Option<(usize, usize)>,
pub warnings: Vec<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum ParseError {
#[error("empty LLM response")]
EmptyResponse,
#[error("could not parse {expected_format} from LLM response: {text}")]
Unparseable {
expected_format: &'static str,
text: String,
},
#[error("JSON deserialization failed: {reason}")]
DeserializationFailed {
reason: String,
raw_json: String,
},
#[error("no valid choice found in response (valid: {valid:?})")]
NoMatchingChoice {
valid: Vec<String>,
},
#[error("no valid number found in response")]
NoNumber,
#[error("input too large: {size} bytes exceeds limit of {limit} bytes")]
TooLarge {
size: usize,
limit: usize,
},
#[error("nesting too deep: depth {depth} exceeds limit of {limit}")]
TooDeep {
depth: usize,
limit: usize,
},
}
impl ParseError {
pub fn kind(&self) -> &'static str {
match self {
Self::EmptyResponse => "empty_response",
Self::Unparseable { .. } => "unparseable",
Self::DeserializationFailed { .. } => "deserialization_failed",
Self::NoMatchingChoice { .. } => "no_matching_choice",
Self::NoNumber => "no_number",
Self::TooLarge { .. } => "too_large",
Self::TooDeep { .. } => "too_deep",
}
}
}
pub(crate) fn ensure_input_within_limits(
response: &str,
opts: &ParseOptions,
) -> Result<(), ParseError> {
if response.len() > opts.max_input_bytes {
return Err(ParseError::TooLarge {
size: response.len(),
limit: opts.max_input_bytes,
});
}
Ok(())
}
pub(crate) fn record_extracted_span(trace: &mut ParseTrace, cleaned: &str, extracted: &str) {
if extracted.is_empty() {
return;
}
if let Some(start) = cleaned.find(extracted) {
trace.extracted_span = Some((start, start + extracted.len()));
}
}
#[allow(dead_code)]
pub(crate) fn truncate(s: &str, max_len: usize) -> String {
let mut chars = s.chars();
if chars.by_ref().count() <= max_len {
s.to_string()
} else {
let truncated: String = s.chars().take(max_len).collect();
format!("{}...", truncated)
}
}