gglib_core/normalize/
history.rs1use serde_json::Value;
36
37use crate::request_pipeline::for_each_text_mut;
38
39pub fn strip_thinking_debt(messages: &mut [Value]) -> usize {
47 let mut touched = 0usize;
48 for msg in messages.iter_mut() {
49 let Some(obj) = msg.as_object_mut() else {
50 continue;
51 };
52 let is_assistant = obj
53 .get("role")
54 .and_then(|r| r.as_str())
55 .is_some_and(|r| r == "assistant");
56 if !is_assistant {
57 continue;
58 }
59
60 let removed_reasoning = obj.remove("reasoning_content").is_some();
61 let stripped_inline = obj.get_mut("content").is_some_and(|content| {
62 let mut stripped = false;
63 for_each_text_mut(content, &mut |text| {
64 if let Some(new_text) = strip_think_blocks(text) {
65 *text = new_text;
66 stripped = true;
67 }
68 });
69 stripped
70 });
71
72 if removed_reasoning || stripped_inline {
73 touched += 1;
74 }
75 }
76 touched
77}
78
79fn strip_think_blocks(s: &str) -> Option<String> {
87 const OPEN: &str = "<think>";
88 const CLOSE: &str = "</think>";
89
90 if !s.contains(OPEN) {
91 return None;
92 }
93
94 let mut out = String::with_capacity(s.len());
95 let mut rest = s;
96 let mut changed = false;
97 while let Some(open_idx) = rest.find(OPEN) {
98 let after_open = &rest[open_idx + OPEN.len()..];
99 let Some(close_off) = after_open.find(CLOSE) else {
100 break;
102 };
103 out.push_str(&rest[..open_idx]);
104 rest = &after_open[close_off + CLOSE.len()..];
105 changed = true;
106 }
107 if !changed {
108 return None;
109 }
110 out.push_str(rest);
111 Some(out.trim().to_string())
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use serde_json::json;
118
119 fn msgs(v: Value) -> Vec<Value> {
120 match v {
121 Value::Array(a) => a,
122 other => panic!("expected array, got {other:?}"),
123 }
124 }
125
126 #[test]
127 fn strip_removes_reasoning_content_from_assistant_message() {
128 let mut m = msgs(json!([
129 {"role": "user", "content": "hi"},
130 {"role": "assistant", "content": "hello", "reasoning_content": "long ramble..."}
131 ]));
132 let touched = strip_thinking_debt(&mut m);
133 assert_eq!(touched, 1);
134 assert_eq!(m[1]["content"], "hello");
135 assert!(m[1].get("reasoning_content").is_none());
136 assert_eq!(m[0]["content"], "hi");
137 }
138
139 #[test]
140 fn strip_removes_inline_think_blocks_from_assistant_content() {
141 let mut m = msgs(json!([
142 {"role": "assistant", "content": "<think>secret\nplan</think>The answer is 42."}
143 ]));
144 let touched = strip_thinking_debt(&mut m);
145 assert_eq!(touched, 1);
146 assert_eq!(m[0]["content"], "The answer is 42.");
147 }
148
149 #[test]
150 fn strip_handles_multiple_think_blocks() {
151 let mut m = msgs(json!([
152 {"role": "assistant", "content": "<think>a</think>between<think>b</think>after"}
153 ]));
154 strip_thinking_debt(&mut m);
155 assert_eq!(m[0]["content"], "betweenafter");
156 }
157
158 #[test]
159 fn strip_leaves_unclosed_think_intact() {
160 let mut m = msgs(json!([
161 {"role": "assistant", "content": "<think>still going..."}
162 ]));
163 let touched = strip_thinking_debt(&mut m);
164 assert_eq!(touched, 0);
165 assert_eq!(m[0]["content"], "<think>still going...");
166 }
167
168 #[test]
169 fn strip_does_not_touch_user_or_system_or_tool_messages() {
170 let original = json!([
171 {"role": "system", "content": "<think>policy</think>be helpful", "reasoning_content": "x"},
172 {"role": "user", "content": "<think>ignore</think>question", "reasoning_content": "y"},
173 {"role": "tool", "content": "<think>tool</think>result", "tool_call_id": "c1", "reasoning_content": "z"}
174 ]);
175 let mut m = msgs(original.clone());
176 let touched = strip_thinking_debt(&mut m);
177 assert_eq!(touched, 0);
178 assert_eq!(Value::Array(m), original);
179 }
180
181 #[test]
182 fn strip_handles_empty_messages_array() {
183 let mut m: Vec<Value> = Vec::new();
184 let touched = strip_thinking_debt(&mut m);
185 assert_eq!(touched, 0);
186 assert!(m.is_empty());
187 }
188
189 #[test]
190 fn strip_skips_when_nothing_to_remove() {
191 let original = json!([
192 {"role": "assistant", "content": "plain answer"}
193 ]);
194 let mut m = msgs(original.clone());
195 let touched = strip_thinking_debt(&mut m);
196 assert_eq!(touched, 0);
197 assert_eq!(Value::Array(m), original);
198 }
199
200 #[test]
201 fn strip_removes_think_blocks_from_array_form_content() {
202 let mut m = msgs(json!([
208 {
209 "role": "assistant",
210 "content": [
211 {"type": "text", "text": "<think>x</think>hi"},
212 {"type": "image_url", "image_url": {"url": "data:,"}},
213 {"type": "text", "text": " <think>y</think> there "},
214 {"type": "text", "text": " plain "}
215 ],
216 "reasoning_content": "r"
217 }
218 ]));
219 let touched = strip_thinking_debt(&mut m);
220 assert_eq!(touched, 1);
221 assert!(m[0].get("reasoning_content").is_none());
222 assert_eq!(
223 m[0]["content"],
224 json!([
225 {"type": "text", "text": "hi"},
226 {"type": "image_url", "image_url": {"url": "data:,"}},
227 {"type": "text", "text": "there"},
228 {"type": "text", "text": " plain "}
229 ])
230 );
231 }
232
233 #[test]
234 fn strip_counts_an_array_form_message_only_when_a_block_went() {
235 let original = json!([
237 {"role": "assistant", "content": [{"type": "text", "text": "plain"}]}
238 ]);
239 let mut m = msgs(original.clone());
240 assert_eq!(strip_thinking_debt(&mut m), 0);
241 assert_eq!(Value::Array(m), original);
242 }
243
244 #[test]
245 fn strip_skips_non_object_messages() {
246 let mut m = vec![
248 Value::String("garbage".to_string()),
249 json!({
250 "role": "assistant",
251 "reasoning_content": "drop me"
252 }),
253 ];
254 let touched = strip_thinking_debt(&mut m);
255 assert_eq!(touched, 1);
256 assert!(m[1].get("reasoning_content").is_none());
257 }
258
259 #[test]
260 fn strip_handles_assistant_without_role_string() {
261 let mut m = msgs(json!([
263 {"role": 7, "content": "<think>x</think>y", "reasoning_content": "r"}
264 ]));
265 let touched = strip_thinking_debt(&mut m);
266 assert_eq!(touched, 0);
267 assert_eq!(m[0]["reasoning_content"], "r");
268 }
269
270 #[test]
271 fn strip_handles_assistant_with_only_inline_think() {
272 let mut m = msgs(json!([
274 {"role": "assistant", "content": "<think>a</think>b"}
275 ]));
276 let touched = strip_thinking_debt(&mut m);
277 assert_eq!(touched, 1);
278 assert_eq!(m[0]["content"], "b");
279 }
280}