const OPEN: &str = "<think>";
const CLOSE: &str = "</think>";
#[must_use]
pub fn split_reasoning(content: &str) -> (String, Option<String>) {
if !content.contains(OPEN) {
return match content.find(CLOSE) {
Some(close) => {
let reasoning = content[..close].trim();
let clean = content[close + CLOSE.len()..].trim();
(clean.to_string(), non_empty(reasoning))
}
None => (content.to_string(), None), };
}
let mut clean = String::new();
let mut reasoning = String::new();
let mut rest = content;
while let Some(open) = rest.find(OPEN) {
clean.push_str(&rest[..open]);
let after = &rest[open + OPEN.len()..];
match after.find(CLOSE) {
Some(close) => {
reasoning.push_str(&after[..close]);
rest = &after[close + CLOSE.len()..];
}
None => {
reasoning.push_str(after);
rest = "";
break;
}
}
}
clean.push_str(rest);
(clean.trim().to_string(), non_empty(reasoning.trim()))
}
fn non_empty(s: &str) -> Option<String> {
if s.is_empty() {
None
} else {
Some(s.to_string())
}
}
#[derive(Debug, Default)]
pub struct ThinkFilter {
inside: bool,
implicit: bool,
buf: String,
}
impl ThinkFilter {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_leading_reasoning() -> Self {
Self {
inside: true,
implicit: true,
buf: String::new(),
}
}
pub fn feed(&mut self, token: &str) -> String {
self.feed_split(token).0
}
pub fn feed_split(&mut self, token: &str) -> (String, String) {
self.buf.push_str(token);
let mut clean = String::new();
let mut reasoning = String::new();
loop {
if self.inside {
match self.buf.find(CLOSE) {
Some(i) => {
reasoning.push_str(&self.buf[..i]);
self.buf.drain(..i + CLOSE.len());
self.inside = false; self.implicit = false; }
None => {
if self.implicit {
break;
}
let cut = safe_len(&self.buf, CLOSE);
reasoning.push_str(&self.buf[..cut]);
self.buf.drain(..cut);
break;
}
}
} else {
match self.buf.find(OPEN) {
Some(i) => {
clean.push_str(&self.buf[..i]);
self.buf.drain(..i + OPEN.len());
self.inside = true;
}
None => {
let cut = safe_len(&self.buf, OPEN);
clean.push_str(&self.buf[..cut]);
self.buf.drain(..cut);
break;
}
}
}
}
(clean, reasoning)
}
pub fn finish(&mut self) -> String {
let out = if self.inside && !self.implicit {
String::new()
} else {
std::mem::take(&mut self.buf)
};
self.buf.clear();
self.inside = false;
self.implicit = false;
out
}
}
#[must_use]
pub fn emits_leading_reasoning(model: &str) -> bool {
let m = model.to_ascii_lowercase();
["nemotron", "deepseek-r1", "qwen3"]
.iter()
.any(|fam| m.contains(fam))
}
fn safe_len(buf: &str, tag: &str) -> usize {
let max = buf.len().min(tag.len() - 1);
for k in (1..=max).rev() {
let start = buf.len() - k;
if buf.is_char_boundary(start) && tag.starts_with(&buf[start..]) {
return start;
}
}
buf.len()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_tags_is_untouched() {
let (c, r) = split_reasoning("just a normal reply");
assert_eq!(c, "just a normal reply");
assert!(r.is_none());
}
#[test]
fn strips_a_leading_paired_block() {
let (c, r) = split_reasoning("<think>let me think</think>\n\nThe answer is 42.");
assert_eq!(c, "The answer is 42.");
assert_eq!(r.as_deref(), Some("let me think"));
}
#[test]
fn strips_multiple_and_embedded_blocks() {
let (c, r) = split_reasoning("A<think>r1</think>B<think>r2</think>C");
assert_eq!(c, "ABC");
assert_eq!(r.as_deref(), Some("r1r2"));
}
#[test]
fn lone_leading_closer_is_reasoning() {
let (c, r) = split_reasoning("reasoning with no opener</think>the answer");
assert_eq!(c, "the answer");
assert_eq!(r.as_deref(), Some("reasoning with no opener"));
}
#[test]
fn unterminated_open_swallows_the_tail() {
let (c, r) = split_reasoning("partial answer<think>cut off mid-thought");
assert_eq!(c, "partial answer");
assert_eq!(r.as_deref(), Some("cut off mid-thought"));
}
#[test]
fn all_reasoning_yields_empty_content() {
let (c, r) = split_reasoning("<think>only thinking, no answer</think>");
assert_eq!(c, "");
assert_eq!(r.as_deref(), Some("only thinking, no answer"));
}
fn stream(content: &str, tokens: &[&str]) -> String {
let mut f = ThinkFilter::new();
let mut out = String::new();
for t in tokens {
out.push_str(&f.feed(t));
}
out.push_str(&f.finish());
let _ = content;
out
}
#[test]
fn streaming_suppresses_a_block_split_across_tokens() {
let tokens = [
"Here",
" <thi",
"nk>my ",
"reason",
"ing</thi",
"nk> is the ",
"answer",
];
assert_eq!(stream("", &tokens), "Here is the answer");
}
#[test]
fn streaming_char_by_char_matches_batch() {
let content = "<think>step one\nstep two</think>Final line.";
let toks: Vec<String> = content.chars().map(|c| c.to_string()).collect();
let refs: Vec<&str> = toks.iter().map(String::as_str).collect();
assert_eq!(stream(content, &refs), "Final line.");
}
#[test]
fn streaming_plain_text_is_verbatim() {
assert_eq!(stream("", &["no ", "tags ", "here"]), "no tags here");
}
#[test]
fn feed_split_captures_reasoning_across_token_boundaries() {
let tokens = [
"Here",
" <thi",
"nk>my ",
"reason",
"ing</thi",
"nk> is the ",
"answer",
];
let mut f = ThinkFilter::new();
let (mut clean, mut reasoning) = (String::new(), String::new());
for t in tokens {
let (c, r) = f.feed_split(t);
clean.push_str(&c);
reasoning.push_str(&r);
}
clean.push_str(&f.finish());
assert_eq!(clean, "Here is the answer");
assert_eq!(reasoning, "my reasoning");
let mut g = ThinkFilter::new();
assert_eq!(g.feed("a<think>b"), "a");
assert_eq!(g.feed("c</think>d"), "d");
}
#[test]
fn streaming_unterminated_block_is_dropped() {
assert_eq!(
stream("", &["answer ", "<think>still ", "thinking"]),
"answer "
);
}
fn stream_leading(tokens: &[&str]) -> (String, String) {
let mut f = ThinkFilter::with_leading_reasoning();
let (mut clean, mut reasoning) = (String::new(), String::new());
for t in tokens {
let (c, r) = f.feed_split(t);
clean.push_str(&c);
reasoning.push_str(&r);
}
clean.push_str(&f.finish());
(clean, reasoning)
}
#[test]
fn leading_reasoning_strips_lone_closer() {
let (clean, reasoning) = stream_leading(&["reasoning", "</think>", "the answer"]);
assert_eq!(clean, "the answer");
assert_eq!(reasoning, "reasoning");
}
#[test]
fn leading_reasoning_handles_closer_split_across_tokens() {
let (clean, reasoning) = stream_leading(&["reason", "ing</thi", "nk>the ", "answer"]);
assert_eq!(clean, "the answer");
assert_eq!(reasoning, "reasoning");
}
#[test]
fn leading_reasoning_without_closer_flushes_as_clean() {
let (clean, reasoning) = stream_leading(&["a plain ", "reply with ", "no tags"]);
assert_eq!(clean, "a plain reply with no tags");
assert_eq!(reasoning, "");
}
#[test]
fn leading_reasoning_tolerates_a_paired_block() {
let (clean, _r) = stream_leading(&["<think>r</think>", "answer"]);
assert_eq!(clean, "answer");
}
#[test]
fn emits_leading_reasoning_matches_known_families() {
assert!(emits_leading_reasoning("nemotron-3-nano:30b"));
assert!(emits_leading_reasoning("nemotron3:33b"));
assert!(emits_leading_reasoning("deepseek-r1:7b"));
assert!(emits_leading_reasoning("qwen3:8b"));
assert!(!emits_leading_reasoning("llama3:8b"));
assert!(!emits_leading_reasoning("qwen2.5-coder:7b"));
assert!(!emits_leading_reasoning("gpt-4o"));
}
}