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}