gglib_core/normalize/error.rs
1//! Non-fatal error reporting from normalization parsers.
2//!
3//! Parsers in [`super::parsers`] never `Result`-fail at the trait level —
4//! a malformed dialect fragment is data, not an infrastructure problem.
5//! Instead, parsers attach a [`NormalizationError`] to their
6//! [`super::parser::ParserOutput`], and the surrounding stream wrapper
7//! surfaces those errors as
8//! `LlmStreamEvent::NormalizationError` events.
9//!
10//! Consumers are free to log, surface, or suppress these events. The proxy
11//! drops them on the wire (V1 contract); in-process consumers (Axum/Tauri)
12//! receive them for diagnostics.
13
14/// Discriminates the kind of malformation a parser detected.
15///
16/// Each variant carries enough context that a developer reading a log line
17/// can identify the offending bytes without rerunning the model.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum NormalizationErrorKind {
20 /// Found a complete `<tool_call>...</tool_call>` block but its body did
21 /// not parse as a JSON object with at least a `name` field.
22 ///
23 /// `raw` holds the body bytes between the open and close tags.
24 MalformedToolCallJson { raw: String },
25
26 /// The stream ended while we were still inside an open `<tool_call>`
27 /// tag. `partial` is the JSON body collected so far (which may be
28 /// empty if only the open tag was seen).
29 UnclosedToolCallTag { partial: String },
30
31 /// Found a complete `<tool_call>...</tool_call>` block whose body began
32 /// with `<function=` (the Qwen3/Hermes inner-XML dialect) but did not
33 /// match that dialect's `<function=NAME><parameter=KEY>VALUE</parameter>...</function>`
34 /// shape.
35 ///
36 /// `raw` holds the body bytes between the open and close tags.
37 MalformedFunctionXml { raw: String },
38}
39
40/// A non-fatal normalization issue surfaced from a parser.
41///
42/// `kind` carries the structured failure details; `raw` is a short snippet
43/// of the offending input suitable for log output.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct NormalizationError {
46 /// Structured detail about what went wrong.
47 pub kind: NormalizationErrorKind,
48 /// A short, human-readable excerpt of the offending input. Parsers
49 /// should keep this small (≲ 256 bytes) so it is safe to attach to a
50 /// stream event.
51 pub raw: String,
52}
53
54impl NormalizationError {
55 /// Construct a `MalformedToolCallJson` error with the body as `raw`.
56 #[must_use]
57 pub fn malformed_tool_call(body: impl Into<String>) -> Self {
58 let raw = body.into();
59 Self {
60 kind: NormalizationErrorKind::MalformedToolCallJson { raw: raw.clone() },
61 raw,
62 }
63 }
64
65 /// Construct an `UnclosedToolCallTag` error from a partial body.
66 #[must_use]
67 pub fn unclosed_tool_call(partial: impl Into<String>) -> Self {
68 let partial = partial.into();
69 Self {
70 kind: NormalizationErrorKind::UnclosedToolCallTag {
71 partial: partial.clone(),
72 },
73 raw: partial,
74 }
75 }
76
77 /// Construct a `MalformedFunctionXml` error with the body as `raw`.
78 #[must_use]
79 pub fn malformed_function_xml(body: impl Into<String>) -> Self {
80 let raw = body.into();
81 Self {
82 kind: NormalizationErrorKind::MalformedFunctionXml { raw: raw.clone() },
83 raw,
84 }
85 }
86}