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: None,
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 next_reasoning_id: 0,
162 };
163 let stream = stream
164 .map(Some)
165 .chain(stream::once(async { None }))
166 .map(move |part| {
167 stream::iter(match part {
168 Some(part) => state.process(part),
169 None => state.finish_all(),
170 })
171 })
172 .flatten();
173 StreamResult {
174 stream: Box::pin(stream),
175 request,
176 response,
177 }
178 }
179}
180
181impl LanguageModelMiddleware for ExtractReasoning {
182 fn wrap_generate<'a>(
183 &'a self,
184 options: CallOptions,
185 next: GenerateNext<'a>,
186 _ctx: MiddlewareContext<'a>,
187 ) -> BoxFuture<'a, Result<GenerateResult, ProviderError>> {
188 Box::pin(async move { Ok(self.apply(next(options).await?)) })
189 }
190
191 fn wrap_stream<'a>(
192 &'a self,
193 options: CallOptions,
194 next: StreamNext<'a>,
195 _ctx: MiddlewareContext<'a>,
196 ) -> BoxFuture<'a, Result<StreamResult, ProviderError>> {
197 Box::pin(async move { Ok(self.apply_stream(next(options).await?)) })
198 }
199}
200
201struct Extraction {
202 is_first_reasoning: bool,
203 is_first_text: bool,
204 after_switch: bool,
205 is_reasoning: bool,
206 buffer: String,
207 reasoning_id: Option<PartId>,
208 text_id: PartId,
209 delayed_text_start: Option<StreamPart>,
210 text_started: bool,
211}
212
213impl Extraction {
214 fn new(id: PartId, start_with_reasoning: bool) -> Self {
215 Self {
216 is_first_reasoning: true,
217 is_first_text: true,
218 after_switch: false,
219 is_reasoning: start_with_reasoning,
220 buffer: String::new(),
221 reasoning_id: None,
222 text_id: id,
223 delayed_text_start: None,
224 text_started: false,
225 }
226 }
227
228 fn start_text(&mut self, out: &mut Vec<StreamPart>) {
229 if let Some(start) = self.delayed_text_start.take() {
230 self.text_started = true;
231 out.push(start);
232 }
233 }
234
235 fn start_reasoning(&mut self, counter: &mut u32, out: &mut Vec<StreamPart>) -> PartId {
236 self.reasoning_id
237 .get_or_insert_with(|| {
238 let id = PartId::new(format!("reasoning-{counter}"));
239 *counter += 1;
240 out.push(StreamPart::ReasoningStart {
241 id: id.clone(),
242 provider_metadata: None,
243 });
244 id
245 })
246 .clone()
247 }
248
249 fn end_reasoning(&mut self, counter: &mut u32, out: &mut Vec<StreamPart>) {
250 let id = self.start_reasoning(counter, out);
252 out.push(StreamPart::ReasoningEnd {
253 id,
254 provider_metadata: None,
255 });
256 self.reasoning_id = None;
257 }
258
259 fn finish(&mut self, separator: &str, counter: &mut u32, out: &mut Vec<StreamPart>) {
260 let buffer = std::mem::take(&mut self.buffer);
261 publish(self, &buffer, separator, counter, out);
262 if self.is_reasoning {
263 self.end_reasoning(counter, out);
264 }
265 self.start_text(out);
266 }
267}
268
269struct StreamState {
270 opening_tag: String,
271 closing_tag: String,
272 separator: String,
273 start_with_reasoning: bool,
274 extractions: HashMap<PartId, Extraction>,
275 next_reasoning_id: u32,
276}
277
278impl StreamState {
279 fn finish_all(&mut self) -> Vec<StreamPart> {
280 let mut out = Vec::new();
281 let mut extractions: Vec<_> = self.extractions.drain().collect();
282 extractions.sort_by(|(left, _), (right, _)| left.as_str().cmp(right.as_str()));
283 for (id, mut extraction) in extractions {
284 extraction.finish(&self.separator, &mut self.next_reasoning_id, &mut out);
285 if extraction.text_started {
286 out.push(StreamPart::TextEnd {
287 id,
288 provider_metadata: None,
289 });
290 }
291 }
292 out
293 }
294
295 fn process(&mut self, part: StreamPart) -> Vec<StreamPart> {
296 let mut out = Vec::new();
297 match part {
298 StreamPart::TextStart { ref id, .. } => {
300 let id = id.clone();
301 let extraction = self
302 .extractions
303 .entry(id.clone())
304 .or_insert_with(|| Extraction::new(id, self.start_with_reasoning));
305 extraction.delayed_text_start = Some(part);
306 }
307 StreamPart::TextEnd { ref id, .. } => {
308 if let Some(mut extraction) = self.extractions.remove(id) {
309 extraction.finish(&self.separator, &mut self.next_reasoning_id, &mut out);
310 }
311 out.push(part);
312 }
313 StreamPart::TextDelta { id, delta, .. } => {
314 self.process_delta(&id, &delta, &mut out);
315 }
316 StreamPart::Finish { .. } => {
317 out.extend(self.finish_all());
318 out.push(part);
319 }
320 other => out.push(other),
321 }
322 out
323 }
324
325 fn process_delta(&mut self, id: &PartId, delta: &str, out: &mut Vec<StreamPart>) {
326 let extraction = self
327 .extractions
328 .entry(id.clone())
329 .or_insert_with(|| Extraction::new(id.clone(), self.start_with_reasoning));
330 extraction.buffer.push_str(delta);
331 loop {
332 let next_tag = if extraction.is_reasoning {
333 self.closing_tag.as_str()
334 } else {
335 self.opening_tag.as_str()
336 };
337 let Some(start) = potential_start_index(&extraction.buffer, next_tag) else {
338 let buffer = std::mem::take(&mut extraction.buffer);
339 publish(
340 extraction,
341 &buffer,
342 &self.separator,
343 &mut self.next_reasoning_id,
344 out,
345 );
346 break;
347 };
348 let before = extraction.buffer[..start].to_owned();
349 publish(
350 extraction,
351 &before,
352 &self.separator,
353 &mut self.next_reasoning_id,
354 out,
355 );
356 let end = start + next_tag.len();
357 if end <= extraction.buffer.len() {
358 extraction.buffer = extraction.buffer[end..].to_owned();
359 if extraction.is_reasoning {
360 extraction.end_reasoning(&mut self.next_reasoning_id, out);
361 }
362 extraction.is_reasoning = !extraction.is_reasoning;
363 extraction.after_switch = true;
364 } else {
365 extraction.buffer = extraction.buffer[start..].to_owned();
366 break;
367 }
368 }
369 }
370}
371
372fn publish(
373 extraction: &mut Extraction,
374 text: &str,
375 separator: &str,
376 counter: &mut u32,
377 out: &mut Vec<StreamPart>,
378) {
379 if text.is_empty() {
380 return;
381 }
382 let continuing = if extraction.is_reasoning {
383 !extraction.is_first_reasoning
384 } else {
385 !extraction.is_first_text
386 };
387 let prefix = if extraction.after_switch && continuing {
388 separator
389 } else {
390 ""
391 };
392 if extraction.is_reasoning {
393 let id = extraction.start_reasoning(counter, out);
394 out.push(StreamPart::ReasoningDelta {
395 id,
396 delta: format!("{prefix}{text}"),
397 provider_metadata: None,
398 });
399 extraction.is_first_reasoning = false;
400 } else {
401 extraction.start_text(out);
402 out.push(StreamPart::TextDelta {
403 id: extraction.text_id.clone(),
404 delta: format!("{prefix}{text}"),
405 provider_metadata: None,
406 });
407 extraction.is_first_text = false;
408 }
409 extraction.after_switch = false;
410}
411
412fn potential_start_index(text: &str, needle: &str) -> Option<usize> {
415 if needle.is_empty() {
416 return None;
417 }
418 if let Some(index) = text.find(needle) {
419 return Some(index);
420 }
421 text.char_indices()
422 .rev()
423 .find(|(index, _)| needle.starts_with(&text[*index..]))
424 .map(|(index, _)| index)
425}