pub(crate) const MARKER_PAIRS: &[(&str, &str)] =
&[("<think>", "</think>"), ("<|channel>thought", "<channel|>")];
pub fn split_reasoning(text: &str) -> (Option<String>, String) {
for (open, close) in MARKER_PAIRS {
if let Some(open_at) = text.find(open) {
let before = &text[..open_at];
let after_open = &text[open_at + open.len()..];
return match after_open.find(close) {
Some(close_at) => {
let reasoning = after_open[..close_at].trim().to_string();
let mut answer = String::from(before);
answer.push_str(after_open[close_at + close.len()..].trim_start());
(Some(reasoning), answer.trim().to_string())
}
None => (
Some(after_open.trim().to_string()),
before.trim().to_string(),
),
};
}
}
(None, text.to_string())
}
pub(crate) fn pending_marker(prompt: &str) -> Option<(&'static str, &'static str)> {
let trimmed = prompt.trim_end();
MARKER_PAIRS
.iter()
.find(|(open, _)| trimmed.ends_with(open))
.copied()
}
pub struct ReasoningBudget {
budget: usize,
buffer: String,
open_close: Option<(&'static str, &'static str)>,
closed: bool,
tokens_since_open: usize,
}
impl ReasoningBudget {
pub fn new(budget: usize) -> Self {
ReasoningBudget {
budget,
buffer: String::new(),
open_close: None,
closed: false,
tokens_since_open: 0,
}
}
pub(crate) fn seed_open(&mut self, pair: (&'static str, &'static str)) {
if !self.closed && self.open_close.is_none() {
self.open_close = Some(pair);
}
}
pub fn observe(&mut self, piece: &str) -> Option<&'static str> {
if self.closed {
return None;
}
self.buffer.push_str(piece);
if self.open_close.is_none() {
for pair in MARKER_PAIRS {
if self.buffer.contains(pair.0) {
self.open_close = Some(*pair);
break;
}
}
}
let (_, close) = self.open_close?;
if self.buffer.contains(close) {
self.closed = true;
return None;
}
self.tokens_since_open += 1;
if self.tokens_since_open > self.budget {
self.closed = true;
return Some(close);
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_markers_passes_through_unchanged() {
let (reasoning, answer) = split_reasoning("just a plain answer");
assert_eq!(reasoning, None);
assert_eq!(answer, "just a plain answer");
}
#[test]
fn extracts_qwen_style_think_tags() {
let (reasoning, answer) =
split_reasoning("<think>\nlet me work this out\n</think>\n\nthe answer is 4");
assert_eq!(reasoning.as_deref(), Some("let me work this out"));
assert_eq!(answer, "the answer is 4");
}
#[test]
fn extracts_gemma4_channel_style() {
let (reasoning, answer) =
split_reasoning("<|channel>thought\nhmm\n<channel|>final answer here");
assert_eq!(reasoning.as_deref(), Some("hmm"));
assert_eq!(answer, "final answer here");
}
#[test]
fn unclosed_span_is_all_reasoning() {
let (reasoning, answer) = split_reasoning("<think>\nstill thinking with no end in sight");
assert_eq!(
reasoning.as_deref(),
Some("still thinking with no end in sight")
);
assert_eq!(answer, "");
}
#[test]
fn budget_fires_once_after_threshold_tokens_inside_span() {
let mut budget = ReasoningBudget::new(3);
assert_eq!(budget.observe("<think>"), None);
assert_eq!(budget.observe("a"), None);
assert_eq!(budget.observe("b"), None);
assert_eq!(budget.observe("c"), Some("</think>"));
assert_eq!(budget.observe("d"), None);
}
#[test]
fn budget_does_not_fire_if_model_closes_span_itself() {
let mut budget = ReasoningBudget::new(100);
assert_eq!(budget.observe("<think>"), None);
assert_eq!(budget.observe("quick"), None);
assert_eq!(budget.observe("</think>"), None);
for _ in 0..200 {
assert_eq!(budget.observe("more text"), None);
}
}
#[test]
fn pending_marker_detects_qwen_style_generation_prompt() {
let prompt = "<|im_start|>user\nhi<|im_end|>\n<|im_start|>assistant\n<think>\n";
assert_eq!(pending_marker(prompt), Some(("<think>", "</think>")));
}
#[test]
fn pending_marker_is_none_when_thinking_disabled_and_already_closed() {
let prompt = "<|im_start|>assistant\n<think>\n\n</think>\n\n";
assert_eq!(pending_marker(prompt), None);
}
#[test]
fn pending_marker_is_none_for_gemma_style_prompt_that_leaves_marker_to_model() {
let prompt = "<|turn>model\n";
assert_eq!(pending_marker(prompt), None);
}
#[test]
fn pending_marker_is_none_for_plain_prompt() {
assert_eq!(pending_marker("<|im_start|>assistant\n"), None);
}
#[test]
fn budget_seed_open_lets_a_pending_span_count_from_the_first_token() {
let mut budget = ReasoningBudget::new(2);
budget.seed_open(("<think>", "</think>"));
assert_eq!(budget.observe("a"), None);
assert_eq!(budget.observe("b"), None);
assert_eq!(budget.observe("c"), Some("</think>"));
}
#[test]
fn budget_seed_open_is_a_noop_once_a_span_is_already_tracked() {
let mut budget = ReasoningBudget::new(100);
assert_eq!(budget.observe("<think>"), None);
budget.seed_open(("<|channel>thought", "<channel|>"));
assert_eq!(budget.observe("hmm"), None);
assert_eq!(budget.observe("</think>"), None);
}
#[test]
fn budget_ignores_generation_with_no_reasoning_span() {
let mut budget = ReasoningBudget::new(1);
for _ in 0..50 {
assert_eq!(budget.observe("no markers here "), None);
}
}
}