gglib_core/request_pipeline/
messages.rs1use serde_json::Value;
9use tracing::{Level, debug, enabled, warn};
10
11use super::ModelContext;
12use crate::domain::{ChatMessage, ModelCapabilities, transform_messages_for_capabilities};
13use crate::normalize::strip_thinking_debt;
14
15pub fn shape_messages(body: &mut Value, ctx: &ModelContext) -> bool {
23 let stripped = strip_prior_reasoning(body);
26 let coalesced = coalesce_for_capabilities(body, ctx.capabilities);
27 stripped || coalesced
28}
29
30fn strip_prior_reasoning(body: &mut Value) -> bool {
37 let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
38 return false;
39 };
40
41 let touched = strip_thinking_debt(messages);
42 if touched > 0 {
43 debug!(touched, "stripped prior reasoning from assistant messages");
44 }
45 touched > 0
46}
47
48fn coalesce_for_capabilities(body: &mut Value, capabilities: ModelCapabilities) -> bool {
62 let needs_nothing =
64 !capabilities.requires_strict_turns() && capabilities.supports_system_role();
65 if needs_nothing || capabilities.is_empty() {
66 return false;
67 }
68
69 debug!(
70 requires_strict_turns = capabilities.requires_strict_turns(),
71 supports_system_role = capabilities.supports_system_role(),
72 "coalesce: entering message transformation"
73 );
74
75 let Some(messages_raw) = body.get("messages").and_then(Value::as_array) else {
76 debug!("coalesce: no messages array found in request body");
77 return false;
78 };
79
80 let before_count = messages_raw.len();
81
82 let messages: Vec<ChatMessage> =
88 match serde_json::from_value(Value::Array(messages_raw.clone())) {
89 Ok(m) => m,
90 Err(e) => {
91 warn!(
92 error = %e,
93 before = before_count,
94 "coalesce: failed to deserialise messages as Vec<ChatMessage>; \
95 leaving the message array unchanged. \
96 This usually means a message field has an unexpected type."
97 );
98 return false;
99 }
100 };
101
102 log_payload_shape(body, &messages, before_count);
103
104 let transformed = transform_messages_for_capabilities(messages, capabilities);
105 let after_count = transformed.len();
106
107 debug!(
108 before = before_count,
109 after = after_count,
110 merged = before_count.saturating_sub(after_count),
111 "coalesce: transformation complete"
112 );
113
114 match serde_json::to_value(&transformed) {
115 Ok(new_messages) => {
116 body["messages"] = new_messages;
117 true
118 }
119 Err(e) => {
120 warn!(error = %e, "coalesce: failed to serialise transformed messages; leaving them unchanged");
121 false
122 }
123 }
124}
125
126fn log_payload_shape(body: &Value, messages: &[ChatMessage], before_count: usize) {
133 if !enabled!(Level::DEBUG) {
134 return;
135 }
136
137 for (key, val) in body.as_object().into_iter().flatten() {
138 if key != "messages" {
139 let approx_bytes = serde_json::to_vec(val).map_or(0, |v| v.len());
140 debug!(key, approx_bytes, "coalesce: top-level field size");
141 }
142 }
143
144 debug!(
145 before = before_count,
146 roles = ?messages.iter().map(|m| m.role.as_str()).collect::<Vec<_>>(),
147 "coalesce: parsed messages for transformation"
148 );
149 for (i, m) in messages.iter().enumerate() {
150 let content_bytes = m.content.as_ref().map_or(0, |c| {
151 c.as_str().map_or_else(|| format!("{c:?}").len(), str::len)
152 });
153 debug!(i, role = %m.role, content_bytes, "coalesce: message sizes");
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use serde_json::json;
161
162 fn ctx(capabilities: ModelCapabilities) -> ModelContext {
163 ModelContext {
164 capabilities,
165 ..ModelContext::passthrough()
166 }
167 }
168
169 #[test]
174 fn reasoning_is_stripped_and_reported() {
175 let mut body = json!({
176 "messages": [
177 {"role": "assistant", "content": "hello", "reasoning_content": "ramble"},
178 ]
179 });
180 assert!(shape_messages(&mut body, &ctx(ModelCapabilities::empty())));
181 assert!(body["messages"][0].get("reasoning_content").is_none());
182 }
183
184 #[test]
185 fn no_messages_array_reports_no_change() {
186 let mut body = json!({"model": "m"});
187 assert!(!shape_messages(&mut body, &ctx(ModelCapabilities::empty())));
188 assert_eq!(body, json!({"model": "m"}));
189 }
190
191 #[test]
192 fn clean_history_reports_no_change() {
193 let mut body = json!({"messages": [{"role": "user", "content": "hi"}]});
194 let before = body.clone();
195 assert!(!shape_messages(&mut body, &ctx(ModelCapabilities::empty())));
196 assert_eq!(body, before);
197 }
198
199 #[test]
204 fn unknown_capabilities_skip_coalescing() {
205 let mut body = json!({"messages": [
206 {"role": "user", "content": "one"},
207 {"role": "user", "content": "two"},
208 ]});
209 let before = body.clone();
210 assert!(!shape_messages(&mut body, &ctx(ModelCapabilities::empty())));
211 assert_eq!(body, before, "consecutive user messages must survive");
212 }
213
214 #[test]
215 fn system_role_without_strict_turns_skips_coalescing() {
216 let mut body = json!({"messages": [
217 {"role": "user", "content": "one"},
218 {"role": "user", "content": "two"},
219 ]});
220 let before = body.clone();
221 assert!(!shape_messages(
222 &mut body,
223 &ctx(ModelCapabilities::SUPPORTS_SYSTEM_ROLE)
224 ));
225 assert_eq!(body, before);
226 }
227
228 #[test]
229 fn undeserialisable_messages_are_left_alone() {
230 let mut body = json!({"messages": [{"role": 7, "content": "x"}]});
232 let before = body.clone();
233 assert!(!shape_messages(
234 &mut body,
235 &ctx(ModelCapabilities::REQUIRES_STRICT_TURNS)
236 ));
237 assert_eq!(body, before);
238 }
239
240 #[test]
243 fn strict_turns_merges_consecutive_same_role_messages() {
244 let mut body = json!({"messages": [
245 {"role": "user", "content": "one"},
246 {"role": "user", "content": "two"},
247 ]});
248 assert!(shape_messages(
249 &mut body,
250 &ctx(ModelCapabilities::REQUIRES_STRICT_TURNS)
251 ));
252 assert_eq!(body["messages"].as_array().unwrap().len(), 1);
253 assert_eq!(body["messages"][0]["content"], "one\n\ntwo");
254 }
255
256 #[test]
257 fn coalescing_preserves_tool_call_ids() {
258 let mut body = json!({"messages": [
259 {"role": "user", "content": "go"},
260 {"role": "tool", "tool_call_id": "call_1", "content": "result"},
261 ]});
262 assert!(shape_messages(
263 &mut body,
264 &ctx(ModelCapabilities::REQUIRES_STRICT_TURNS)
265 ));
266 assert_eq!(body["messages"][1]["tool_call_id"], "call_1");
267 }
268
269 #[test]
272 fn both_stages_apply_to_the_same_body() {
273 let mut body = json!({"messages": [
274 {"role": "assistant", "content": "<think>hidden</think>a"},
275 {"role": "assistant", "content": "b"},
276 ]});
277 assert!(shape_messages(
278 &mut body,
279 &ctx(ModelCapabilities::REQUIRES_STRICT_TURNS)
280 ));
281 assert_eq!(body["messages"].as_array().unwrap().len(), 1);
282 assert_eq!(body["messages"][0]["content"], "a\n\nb");
283 }
284
285 #[test]
287 fn non_message_fields_are_untouched() {
288 let mut body = json!({
289 "model": "m",
290 "anything_at_all": {"deep": [1, 2]},
291 "messages": [
292 {"role": "user", "content": "one"},
293 {"role": "user", "content": "two"},
294 ],
295 });
296 shape_messages(&mut body, &ctx(ModelCapabilities::REQUIRES_STRICT_TURNS));
297 assert_eq!(body["model"], "m");
298 assert_eq!(body["anything_at_all"], json!({"deep": [1, 2]}));
299 }
300}