ferrin_core/middleware/builtin/
extract_reasoning.rs1use std::collections::HashMap;
4
5use ferrin_spec::BoxFuture;
6use ferrin_spec::CallOptions;
7use ferrin_spec::Content;
8use ferrin_spec::PartId;
9use ferrin_spec::StreamPart;
10use ferrin_spec::StreamResult;
11use ferrin_spec::error::ProviderError;
12use ferrin_spec::language_model::GenerateResult;
13use futures_util::StreamExt;
14use futures_util::stream;
15use regex::Regex;
16
17use crate::middleware::GenerateNext;
18use crate::middleware::LanguageModelMiddleware;
19use crate::middleware::MiddlewareContext;
20use crate::middleware::StreamNext;
21
22#[derive(Debug, Clone)]
24pub struct ExtractReasoning {
25 opening_tag: String,
26 closing_tag: String,
27 separator: String,
28 start_with_reasoning: bool,
29 pattern: Regex,
30}
31
32#[must_use]
46pub fn extract_reasoning(tag_name: impl AsRef<str>) -> ExtractReasoning {
47 let tag_name = tag_name.as_ref();
48 let opening_tag = format!("<{tag_name}>");
49 let closing_tag = format!("</{tag_name}>");
50 let source = format!(
51 "(?s){}(.*?){}",
52 regex::escape(&opening_tag),
53 regex::escape(&closing_tag)
54 );
55 let pattern = match Regex::new(&source) {
56 Ok(pattern) => pattern,
57 Err(_) => unreachable!("escaped tags form a valid pattern"),
58 };
59 ExtractReasoning {
60 opening_tag,
61 closing_tag,
62 separator: "\n".to_owned(),
63 start_with_reasoning: false,
64 pattern,
65 }
66}
67
68impl ExtractReasoning {
69 #[must_use]
71 pub fn separator(mut self, separator: impl Into<String>) -> Self {
72 self.separator = separator.into();
73 self
74 }
75
76 #[must_use]
79 pub fn start_with_reasoning(mut self, start_with_reasoning: bool) -> Self {
80 self.start_with_reasoning = start_with_reasoning;
81 self
82 }
83
84 #[must_use]
86 pub fn apply(&self, mut result: GenerateResult) -> GenerateResult {
87 let mut transformed = Vec::with_capacity(result.content.len() + 1);
88 for part in result.content.drain(..) {
89 let Content::Text {
90 text,
91 provider_metadata,
92 } = part
93 else {
94 transformed.push(part);
95 continue;
96 };
97 let subject = if self.start_with_reasoning {
98 format!("{}{text}", self.opening_tag)
99 } else {
100 text.clone()
101 };
102 let matches: Vec<(std::ops::Range<usize>, String)> = self
103 .pattern
104 .captures_iter(&subject)
105 .filter_map(|captures| {
106 let whole = captures.get(0)?;
107 let inner = captures.get(1)?;
108 Some((whole.range(), inner.as_str().to_owned()))
109 })
110 .collect();
111 if matches.is_empty() {
112 transformed.push(Content::Text {
113 text,
114 provider_metadata,
115 });
116 continue;
117 }
118 let reasoning = matches
119 .iter()
120 .map(|(_, inner)| inner.as_str())
121 .collect::<Vec<_>>()
122 .join(&self.separator);
123 let mut remaining = subject;
124 for (range, _) in matches.iter().rev() {
125 let before = &remaining[..range.start];
126 let after = &remaining[range.end..];
127 let separator = if before.is_empty() || after.is_empty() {
128 ""
129 } else {
130 self.separator.as_str()
131 };
132 remaining = format!("{before}{separator}{after}");
133 }
134 transformed.push(Content::Reasoning {
135 text: reasoning,
136 provider_metadata: None,
137 });
138 transformed.push(Content::Text {
139 text: remaining,
140 provider_metadata,
141 });
142 }
143 result.content = transformed;
144 result
145 }
146
147 #[must_use]
149 pub fn apply_stream(&self, result: StreamResult) -> StreamResult {
150 let StreamResult {
151 stream,
152 request,
153 response,
154 } = result;
155 let mut state = StreamState {
156 opening_tag: self.opening_tag.clone(),
157 closing_tag: self.closing_tag.clone(),
158 separator: self.separator.clone(),
159 start_with_reasoning: self.start_with_reasoning,
160 extractions: HashMap::new(),
161 delayed_text_start: None,
162 };
163 let stream = stream
164 .map(move |part| stream::iter(state.process(part)))
165 .flatten();
166 StreamResult {
167 stream: Box::pin(stream),
168 request,
169 response,
170 }
171 }
172}
173
174impl LanguageModelMiddleware for ExtractReasoning {
175 fn wrap_generate<'a>(
176 &'a self,
177 options: CallOptions,
178 next: GenerateNext<'a>,
179 _ctx: MiddlewareContext<'a>,
180 ) -> BoxFuture<'a, Result<GenerateResult, ProviderError>> {
181 Box::pin(async move { Ok(self.apply(next(options).await?)) })
182 }
183
184 fn wrap_stream<'a>(
185 &'a self,
186 options: CallOptions,
187 next: StreamNext<'a>,
188 _ctx: MiddlewareContext<'a>,
189 ) -> BoxFuture<'a, Result<StreamResult, ProviderError>> {
190 Box::pin(async move { Ok(self.apply_stream(next(options).await?)) })
191 }
192}
193
194struct Extraction {
195 is_first_reasoning: bool,
196 is_first_text: bool,
197 after_switch: bool,
198 is_reasoning: bool,
199 buffer: String,
200 id_counter: u32,
201 text_id: PartId,
202}
203
204struct StreamState {
205 opening_tag: String,
206 closing_tag: String,
207 separator: String,
208 start_with_reasoning: bool,
209 extractions: HashMap<PartId, Extraction>,
210 delayed_text_start: Option<StreamPart>,
211}
212
213fn reasoning_id(counter: u32) -> PartId {
214 PartId::new(format!("reasoning-{counter}"))
215}
216
217impl StreamState {
218 fn process(&mut self, part: StreamPart) -> Vec<StreamPart> {
219 let mut out = Vec::new();
220 match part {
221 StreamPart::TextStart { .. } => {
223 self.delayed_text_start = Some(part);
224 }
225 StreamPart::TextEnd { .. } => {
226 out.extend(self.delayed_text_start.take());
227 out.push(part);
228 }
229 StreamPart::TextDelta { id, delta, .. } => {
230 self.process_delta(&id, &delta, &mut out);
231 }
232 other => out.push(other),
233 }
234 out
235 }
236
237 fn process_delta(&mut self, id: &PartId, delta: &str, out: &mut Vec<StreamPart>) {
238 let start_with_reasoning = self.start_with_reasoning;
239 let extraction = self
240 .extractions
241 .entry(id.clone())
242 .or_insert_with(|| Extraction {
243 is_first_reasoning: true,
244 is_first_text: true,
245 after_switch: false,
246 is_reasoning: start_with_reasoning,
247 buffer: String::new(),
248 id_counter: 0,
249 text_id: id.clone(),
250 });
251 extraction.buffer.push_str(delta);
252 loop {
253 let next_tag = if extraction.is_reasoning {
254 self.closing_tag.as_str()
255 } else {
256 self.opening_tag.as_str()
257 };
258 let Some(start) = potential_start_index(&extraction.buffer, next_tag) else {
259 let buffer = std::mem::take(&mut extraction.buffer);
260 publish(
261 extraction,
262 &buffer,
263 &self.separator,
264 &mut self.delayed_text_start,
265 out,
266 );
267 break;
268 };
269 let before = extraction.buffer[..start].to_owned();
270 publish(
271 extraction,
272 &before,
273 &self.separator,
274 &mut self.delayed_text_start,
275 out,
276 );
277 let end = start + next_tag.len();
278 if end <= extraction.buffer.len() {
279 extraction.buffer = extraction.buffer[end..].to_owned();
280 if extraction.is_reasoning {
281 if extraction.is_first_reasoning {
283 out.push(StreamPart::ReasoningStart {
284 id: reasoning_id(extraction.id_counter),
285 provider_metadata: None,
286 });
287 }
288 out.push(StreamPart::ReasoningEnd {
289 id: reasoning_id(extraction.id_counter),
290 provider_metadata: None,
291 });
292 extraction.id_counter += 1;
293 }
294 extraction.is_reasoning = !extraction.is_reasoning;
295 extraction.after_switch = true;
296 } else {
297 extraction.buffer = extraction.buffer[start..].to_owned();
298 break;
299 }
300 }
301 }
302}
303
304fn publish(
305 extraction: &mut Extraction,
306 text: &str,
307 separator: &str,
308 delayed_text_start: &mut Option<StreamPart>,
309 out: &mut Vec<StreamPart>,
310) {
311 if text.is_empty() {
312 return;
313 }
314 let continuing = if extraction.is_reasoning {
315 !extraction.is_first_reasoning
316 } else {
317 !extraction.is_first_text
318 };
319 let prefix = if extraction.after_switch && continuing {
320 separator
321 } else {
322 ""
323 };
324 if extraction.is_reasoning {
325 if extraction.after_switch || extraction.is_first_reasoning {
326 out.push(StreamPart::ReasoningStart {
327 id: reasoning_id(extraction.id_counter),
328 provider_metadata: None,
329 });
330 }
331 out.push(StreamPart::ReasoningDelta {
332 id: reasoning_id(extraction.id_counter),
333 delta: format!("{prefix}{text}"),
334 provider_metadata: None,
335 });
336 extraction.is_first_reasoning = false;
337 } else {
338 out.extend(delayed_text_start.take());
339 out.push(StreamPart::TextDelta {
340 id: extraction.text_id.clone(),
341 delta: format!("{prefix}{text}"),
342 provider_metadata: None,
343 });
344 extraction.is_first_text = false;
345 }
346 extraction.after_switch = false;
347}
348
349fn potential_start_index(text: &str, needle: &str) -> Option<usize> {
352 if needle.is_empty() {
353 return None;
354 }
355 if let Some(index) = text.find(needle) {
356 return Some(index);
357 }
358 text.char_indices()
359 .rev()
360 .find(|(index, _)| needle.starts_with(&text[*index..]))
361 .map(|(index, _)| index)
362}