Skip to main content

gglib_core/normalize/
oneshot.rs

1//! One-shot dialect normalization for non-streaming responses.
2//!
3//! **Tier A — Compensation** ([ADR 0001]), derived rather than independent:
4//! this module is the non-streaming application of whatever parser
5//! [`super::registry::get_parser`] selects, so it exists exactly as long as
6//! that parser does.
7//!
8//! *Deletion criterion:* it is deleted together with the parser it drives —
9//! see [`super::parsers::delimited`] for that criterion. It has one of its
10//! own only if llama-server begins returning `message.tool_calls` for
11//! dialect models on the non-streaming path while still requiring gglib to
12//! parse the streaming one, which would be an odd upstream state but is the
13//! condition under which this module could go independently.
14//!
15//! [ADR 0001]: https://github.com/mmogr/gglib/blob/main/docs/adr/0001-runtime-capability-tiers.md
16//!
17//! The streaming path runs every response through a
18//! [`ToolCallParser`](super::parser::ToolCallParser) via
19//! [`super::stream::NormalizingStream`]; a `stream: false` request gets the
20//! same model, the same dialect, and — until this module — none of the
21//! normalization. A Qwen-XML tool call in a non-streaming reply reached the
22//! client as raw `<tool_call>` text.
23//!
24//! [`normalize_chat_completion_body`] closes that gap: it drives the exact
25//! same parser (one full-content push, then
26//! [`ToolCallParser::finish`](super::parser::ToolCallParser::finish)) over
27//! each choice's `message.content` and rewrites the body in place — content
28//! stripped of markup, extracted calls appended to `message.tool_calls` in
29//! the `OpenAI` non-streaming shape, reasoning routed to `reasoning_content`.
30//! Chunk-safety is trivially satisfied (the whole body is one chunk), so
31//! streaming and non-streaming responses cannot drift: there is one parser
32//! per dialect, chosen by the same [`super::registry::get_parser`].
33
34use serde_json::{Value, json};
35
36use super::error::NormalizationError;
37use super::parser::ParserOutput;
38use super::registry::get_parser;
39use crate::domain::dialect::DialectSpec;
40
41/// Normalize a complete (non-streaming) `chat.completion` response body in
42/// place, using the parser for the model's resolved `dialect`.
43///
44/// Only `message.content` strings are processed; a null, absent, or
45/// non-string content is left untouched, as is everything else in the body.
46/// For models with no dialect the parser is the identity passthrough and
47/// the body comes back byte-identical.
48///
49/// When markup is extracted:
50/// - `message.content` becomes the remaining text, or `null` when a tool
51///   call consumed all of it (the `OpenAI` shape for tool-call turns);
52/// - extracted calls are appended to `message.tool_calls`, `arguments`
53///   serialized to a compact JSON string exactly as the streaming encoder
54///   does;
55/// - reasoning captured by the parser is appended to
56///   `message.reasoning_content`;
57/// - a `finish_reason` of `stop`/null is upgraded to `"tool_calls"`.
58///
59/// Returns every [`NormalizationError`] the parser surfaced; the caller
60/// decides whether to log them or surface the raw bytes to the client, as
61/// the streaming path does.
62pub fn normalize_chat_completion_body(
63    body: &mut Value,
64    dialect: Option<&DialectSpec>,
65) -> Vec<NormalizationError> {
66    let mut all_errors = Vec::new();
67
68    let Some(choices) = body.get_mut("choices").and_then(Value::as_array_mut) else {
69        return all_errors;
70    };
71
72    for choice in choices {
73        let Some(message) = choice.get_mut("message") else {
74            continue;
75        };
76        let Some(content) = message.get("content").and_then(Value::as_str) else {
77            continue;
78        };
79        if content.is_empty() {
80            continue;
81        }
82
83        // Parsers are stream-stateful: one fresh parser per choice, fed the
84        // whole content as a single chunk, then flushed.
85        let mut parser = get_parser(dialect);
86        let mut out = parser.push_text(content);
87        let fin = parser.finish();
88        merge(&mut out, fin);
89
90        // Identity fast-path: nothing extracted, nothing failed, text
91        // unchanged — leave the message untouched rather than rebuilding it.
92        if out.tool_calls.is_empty()
93            && out.errors.is_empty()
94            && out.forward_reasoning.is_empty()
95            && out.forward_text == content
96        {
97            continue;
98        }
99
100        let extracted_calls = !out.tool_calls.is_empty();
101
102        message["content"] = if out.forward_text.is_empty() && extracted_calls {
103            Value::Null
104        } else {
105            Value::String(out.forward_text)
106        };
107
108        if !out.forward_reasoning.is_empty() {
109            let merged = match message.get("reasoning_content").and_then(Value::as_str) {
110                Some(existing) => format!("{existing}{}", out.forward_reasoning),
111                None => out.forward_reasoning,
112            };
113            message["reasoning_content"] = Value::String(merged);
114        }
115
116        if extracted_calls {
117            let rendered = out.tool_calls.into_iter().map(|tc| {
118                json!({
119                    "id": tc.id,
120                    "type": "function",
121                    "function": {
122                        "name": tc.name,
123                        // Compact JSON string, matching the streaming
124                        // encoder's `arguments.to_string()`.
125                        "arguments": tc.arguments.to_string(),
126                    },
127                })
128            });
129            match message.get_mut("tool_calls").and_then(Value::as_array_mut) {
130                Some(existing) => existing.extend(rendered),
131                None => message["tool_calls"] = Value::Array(rendered.collect()),
132            }
133
134            // llama-server reported how the *raw* text ended; with the markup
135            // rewritten into structured calls, `stop` misdescribes the turn
136            // and breaks clients that dispatch on finish_reason.
137            let finish = choice.get("finish_reason").and_then(Value::as_str);
138            if matches!(finish, None | Some("stop")) {
139                choice["finish_reason"] = Value::String("tool_calls".into());
140            }
141        }
142
143        all_errors.extend(out.errors);
144    }
145
146    all_errors
147}
148
149/// Fold a second [`ParserOutput`] (from `finish`) into the first.
150fn merge(into: &mut ParserOutput, from: ParserOutput) {
151    into.forward_text.push_str(&from.forward_text);
152    into.forward_reasoning.push_str(&from.forward_reasoning);
153    into.tool_calls.extend(from.tool_calls);
154    into.errors.extend(from.errors);
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::normalize::registry::dialect_for_tags;
161    use crate::normalize::tags::FORMAT_QWEN_XML;
162
163    fn qwen_dialect() -> Option<DialectSpec> {
164        // Resolved the same way production does for legacy tagged rows.
165        dialect_for_tags(&[FORMAT_QWEN_XML.to_owned()])
166    }
167
168    fn body_with_content(content: &str) -> Value {
169        json!({
170            "id": "chatcmpl-1",
171            "object": "chat.completion",
172            "choices": [{
173                "index": 0,
174                "message": { "role": "assistant", "content": content },
175                "finish_reason": "stop",
176            }],
177            "usage": { "prompt_tokens": 1, "completion_tokens": 1 },
178        })
179    }
180
181    /// The gap this module closes: a qwen-xml tool call in a non-streaming
182    /// body becomes structured `tool_calls`, not raw text.
183    #[test]
184    fn qwen_tool_call_markup_becomes_structured_tool_calls() {
185        let mut body = body_with_content(
186            r#"<tool_call>{"name":"read_file","arguments":{"path":"a.rs"}}</tool_call>"#,
187        );
188        let errors = normalize_chat_completion_body(&mut body, qwen_dialect().as_ref());
189
190        assert!(errors.is_empty(), "{errors:?}");
191        let message = &body["choices"][0]["message"];
192        assert_eq!(message["content"], Value::Null);
193        assert_eq!(message["tool_calls"][0]["type"], "function");
194        assert_eq!(message["tool_calls"][0]["function"]["name"], "read_file");
195        assert_eq!(
196            message["tool_calls"][0]["function"]["arguments"],
197            r#"{"path":"a.rs"}"#
198        );
199        assert_eq!(body["choices"][0]["finish_reason"], "tool_calls");
200    }
201
202    /// Text around the markup survives as content alongside the calls.
203    #[test]
204    fn surrounding_text_is_preserved() {
205        let mut body =
206            body_with_content(r#"On it. <tool_call>{"name":"ls","arguments":{}}</tool_call>"#);
207        let errors = normalize_chat_completion_body(&mut body, qwen_dialect().as_ref());
208
209        assert!(errors.is_empty());
210        let message = &body["choices"][0]["message"];
211        assert_eq!(message["content"], "On it. ");
212        assert_eq!(message["tool_calls"][0]["function"]["name"], "ls");
213    }
214
215    /// No recognised tag → identity: the body must come back untouched.
216    #[test]
217    fn untagged_model_is_passthrough() {
218        let original = body_with_content("<tool_call>not for us</tool_call>");
219        let mut body = original.clone();
220        let errors = normalize_chat_completion_body(&mut body, None);
221
222        assert!(errors.is_empty());
223        assert_eq!(body, original);
224    }
225
226    /// A tagged model whose reply has no markup is also untouched —
227    /// including its original `finish_reason`.
228    #[test]
229    fn plain_text_reply_is_untouched() {
230        let original = body_with_content("Just an answer.");
231        let mut body = original.clone();
232        let errors = normalize_chat_completion_body(&mut body, qwen_dialect().as_ref());
233
234        assert!(errors.is_empty());
235        assert_eq!(body, original);
236    }
237
238    /// Malformed markup surfaces as an error for the caller to handle, and
239    /// never silently vanishes.
240    #[test]
241    fn malformed_markup_surfaces_an_error() {
242        let mut body = body_with_content("<tool_call>{not json}</tool_call>");
243        let errors = normalize_chat_completion_body(&mut body, qwen_dialect().as_ref());
244
245        assert_eq!(errors.len(), 1);
246    }
247
248    /// Null content (already-structured tool-call responses from a --jinja
249    /// server) is left alone.
250    #[test]
251    fn null_content_is_skipped() {
252        let mut body = json!({
253            "choices": [{
254                "message": { "role": "assistant", "content": Value::Null,
255                             "tool_calls": [{"id": "x"}] },
256                "finish_reason": "tool_calls",
257            }],
258        });
259        let original = body.clone();
260        let errors = normalize_chat_completion_body(&mut body, qwen_dialect().as_ref());
261
262        assert!(errors.is_empty());
263        assert_eq!(body, original);
264    }
265}