pub(crate) fn split_openai_thinking_blocks(raw: &str) -> (String, String) {
if !raw.contains("<think>") {
return (raw.to_string(), String::new());
}
let mut visible = String::new();
let mut thinking = String::new();
let mut rest = raw;
loop {
if let Some(start) = rest.find("<think>") {
visible.push_str(&rest[..start]);
let after_tag = &rest[start + "<think>".len()..];
if let Some(end) = after_tag.find("</think>") {
thinking.push_str(&after_tag[..end]);
rest = &after_tag[end + "</think>".len()..];
} else {
thinking.push_str(after_tag);
break;
}
} else {
visible.push_str(rest);
break;
}
}
let visible = visible.trim_start_matches('\n').to_string();
(visible, thinking.trim().to_string())
}
#[derive(Default)]
pub(crate) struct ThinkingStreamSplitter {
in_thinking: bool,
carry: String,
pub thinking: String,
}
impl ThinkingStreamSplitter {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, delta: &str) -> String {
let combined = {
let mut s = std::mem::take(&mut self.carry);
s.push_str(delta);
s
};
let mut visible_out = String::new();
let mut cursor = 0usize;
let bytes = combined.as_bytes();
while cursor < bytes.len() {
if self.in_thinking {
if let Some(rel) = combined[cursor..].find("</think>") {
self.thinking.push_str(&combined[cursor..cursor + rel]);
cursor += rel + "</think>".len();
self.in_thinking = false;
} else {
let hold = "</think>".len() - 1;
let remaining = combined.len() - cursor;
if remaining <= hold {
self.carry.push_str(&combined[cursor..]);
} else {
let mut split = combined.len() - hold;
while split > cursor && !combined.is_char_boundary(split) {
split -= 1;
}
self.thinking.push_str(&combined[cursor..split]);
self.carry.push_str(&combined[split..]);
}
return visible_out;
}
} else {
if let Some(rel) = combined[cursor..].find("<think>") {
visible_out.push_str(&combined[cursor..cursor + rel]);
cursor += rel + "<think>".len();
self.in_thinking = true;
} else {
let hold = "<think>".len() - 1;
let remaining = combined.len() - cursor;
if remaining <= hold {
self.carry.push_str(&combined[cursor..]);
} else {
let mut split = combined.len() - hold;
while split > cursor && !combined.is_char_boundary(split) {
split -= 1;
}
visible_out.push_str(&combined[cursor..split]);
self.carry.push_str(&combined[split..]);
}
return visible_out;
}
}
}
visible_out
}
pub fn flush(&mut self) -> String {
let rest = std::mem::take(&mut self.carry);
if self.in_thinking {
self.thinking.push_str(&rest);
String::new()
} else {
rest
}
}
}
#[cfg(test)]
mod tests {
use super::{split_openai_thinking_blocks, ThinkingStreamSplitter};
#[test]
fn thinking_split_no_tags_returns_original() {
let (visible, thinking) = split_openai_thinking_blocks("just a plain response");
assert_eq!(visible, "just a plain response");
assert_eq!(thinking, "");
}
#[test]
fn thinking_split_single_block() {
let raw = "<think>step by step reasoning</think>\nThe answer is 42.";
let (visible, thinking) = split_openai_thinking_blocks(raw);
assert_eq!(visible, "The answer is 42.");
assert_eq!(thinking, "step by step reasoning");
}
#[test]
fn thinking_split_multiple_blocks() {
let raw = "<think>first</think>hello <think>second</think>world";
let (visible, thinking) = split_openai_thinking_blocks(raw);
assert_eq!(visible, "hello world");
assert_eq!(
thinking,
"first\nsecond".replace('\n', "")
);
assert!(!visible.contains("first"));
assert!(!visible.contains("second"));
}
#[test]
fn thinking_split_unclosed_block_captures_remainder() {
let raw = "<think>reasoning with no closing tag and then text";
let (visible, thinking) = split_openai_thinking_blocks(raw);
assert_eq!(visible, "");
assert!(thinking.contains("reasoning with no closing tag"));
}
#[test]
fn thinking_stream_splitter_handles_clean_boundaries() {
let mut s = ThinkingStreamSplitter::new();
let v1 = s.push("<think>");
let v2 = s.push("reasoning");
let v3 = s.push("</think>");
let v4 = s.push("visible answer");
let tail = s.flush();
assert_eq!(v1, "");
assert_eq!(v2, "");
assert_eq!(v3, "");
let combined = format!("{}{}{}{}{}", v1, v2, v3, v4, tail);
assert_eq!(combined, "visible answer");
assert_eq!(s.thinking, "reasoning");
}
#[test]
fn thinking_stream_splitter_handles_split_tags() {
let mut s = ThinkingStreamSplitter::new();
let v1 = s.push("<thi");
let v2 = s.push("nk>inside</thi");
let v3 = s.push("nk>after");
let tail = s.flush();
let combined = format!("{}{}{}{}", v1, v2, v3, tail);
assert_eq!(combined, "after");
assert_eq!(s.thinking, "inside");
}
#[test]
fn thinking_stream_splitter_passthrough_without_tags() {
let mut s = ThinkingStreamSplitter::new();
let v1 = s.push("hello ");
let v2 = s.push("world");
let tail = s.flush();
let combined = format!("{}{}{}", v1, v2, tail);
assert_eq!(combined, "hello world");
assert_eq!(s.thinking, "");
}
}