Skip to main content

gglib_core/request_pipeline/
messages.rs

1//! Stage 1–2: shaping the conversation itself.
2//!
3//! Both transforms rewrite the `messages` array and nothing else. They are
4//! paired in [`shape_messages`] because they are the contiguous run of
5//! message-level work in the pipeline — see [`super::apply`] for why the order
6//! within that run is fixed.
7
8use 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
15/// Apply every message-level transform, in order.
16///
17/// Returns `true` when `body` was modified. Callers holding the request as
18/// bytes use this to skip a re-serialization and forward the client's original
19/// payload untouched — which is not merely an optimisation for the proxy,
20/// where history truncation measures the payload in **wire bytes** and would
21/// otherwise measure a re-encoded body instead of the one the client sent.
22pub fn shape_messages(body: &mut Value, ctx: &ModelContext) -> bool {
23    // Evaluated eagerly, not short-circuited: coalescing must run even when
24    // the reasoning strip found nothing to do.
25    let stripped = strip_prior_reasoning(body);
26    let coalesced = coalesce_for_capabilities(body, ctx.capabilities);
27    stripped || coalesced
28}
29
30/// Stage 1 — scrub reasoning artefacts from prior assistant turns.
31///
32/// Thin wrapper over [`strip_thinking_debt`], which owns the scrub rules; this
33/// exists only to locate the `messages` array and report whether anything
34/// changed. Reasoning models pattern-match their own past `<think>` traces
35/// when these are left in the history.
36fn 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
48/// Stage 2 — merge consecutive same-role messages for strict-turn models.
49///
50/// Mistral-family models (and anything else carrying
51/// [`ModelCapabilities::REQUIRES_STRICT_TURNS`]) enforce user/assistant
52/// alternation inside their Jinja chat templates and raise a hard 500 when
53/// consecutive same-role messages arrive. IDEs and gateway extensions routinely
54/// send multi-turn context that violates this, so coalescing here is the
55/// correct fix rather than constraining callers.
56///
57/// [`transform_messages_for_capabilities`] is the single source of truth for
58/// the merging rules. Returns `false` — leaving `body` untouched — for any
59/// model that needs no rewriting and for any request whose `messages` array
60/// cannot be read.
61fn coalesce_for_capabilities(body: &mut Value, capabilities: ModelCapabilities) -> bool {
62    // Fast paths: this model needs no rewriting, or we know nothing about it.
63    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    // Deserialise only the fields `transform_messages_for_capabilities` needs.
83    // `ChatMessage.content` accepts both a plain JSON string and a JSON array of
84    // content-part objects (e.g. VSCode LLM Gateway sends array-form content per
85    // the OpenAI spec), and every other key rides along in `ChatMessage.extra`,
86    // so this round-trip is lossless.
87    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
126/// Diagnostics for tracking down oversized payloads: the byte cost of every
127/// non-message top-level field, and the content size of every message.
128///
129/// Gated on the log level as a whole because both loops serialise their way to
130/// a size — work the `debug!` macro would otherwise discard *after* paying for
131/// it on every strict-turn request.
132fn 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    // ── Stage 1 ───────────────────────────────────────────────────────────
170
171    /// The full scrub-rule matrix lives in `crate::normalize::history`; this
172    /// covers only the wiring.
173    #[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    // ── Stage 2 fast paths ────────────────────────────────────────────────
200    // Each must leave the body alone: the proxy relies on a `false` return to
201    // forward the client's original bytes.
202
203    #[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        // `role` must be a string; a number makes the whole array unreadable.
231        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    // ── Stage 2 proper ────────────────────────────────────────────────────
241
242    #[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    /// Both stages on one body: the strip must happen before the merge, or the
270    /// `<think>` block would be buried inside merged content.
271    #[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    /// Top-level fields are stage 4's business; stage 2 must not touch them.
286    #[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}