use crate::ParserResult;
use crate::ReasoningParser;
const START_TOKEN: &str = "<|channel>";
const END_TOKEN: &str = "<channel|>";
const THOUGHT_PREFIX: &str = "thought\n";
fn overlap(s: &str, delim: &str) -> usize {
let max = delim.len().min(s.len());
for i in (1..=max).rev() {
if !delim.is_char_boundary(i) || !s.is_char_boundary(s.len() - i) {
continue;
}
if s.ends_with(&delim[..i]) {
return i;
}
}
0
}
#[derive(Debug, Clone)]
pub struct Gemma4ReasoningParser {
buffer: String,
in_reasoning: bool,
prefix_resolved: bool,
reasoning_accum: String,
}
impl Gemma4ReasoningParser {
pub fn new() -> Self {
Self {
buffer: String::new(),
in_reasoning: false,
prefix_resolved: false,
reasoning_accum: String::new(),
}
}
fn reset_span(&mut self) {
self.in_reasoning = false;
self.prefix_resolved = false;
self.reasoning_accum.clear();
}
}
impl Default for Gemma4ReasoningParser {
fn default() -> Self {
Self::new()
}
}
fn strip_thought_prefix(text: &str) -> &str {
text.strip_prefix(THOUGHT_PREFIX).unwrap_or(text)
}
fn resolve_prefix<'a>(accum: &'a str, raw_reasoning: &'a str) -> (&'a str, bool) {
debug_assert!(
accum.ends_with(raw_reasoning),
"resolve_prefix precondition violated: raw_reasoning ({:?}) must be a suffix of accum ({:?})",
raw_reasoning,
accum,
);
if accum.starts_with(THOUGHT_PREFIX) {
let prev_len = accum.len() - raw_reasoning.len();
if prev_len >= THOUGHT_PREFIX.len() {
return (raw_reasoning, true);
}
let chars_of_prefix_in_delta = THOUGHT_PREFIX.len() - prev_len;
let stripped = &raw_reasoning[chars_of_prefix_in_delta.min(raw_reasoning.len())..];
if !stripped.is_empty() || accum.len() >= THOUGHT_PREFIX.len() {
return (stripped, true);
}
return ("", false);
}
if THOUGHT_PREFIX.starts_with(accum) {
return ("", false);
}
(accum, true)
}
impl ReasoningParser for Gemma4ReasoningParser {
fn detect_and_parse_reasoning(&mut self, text: &str, _token_ids: &[u32]) -> ParserResult {
if !text.contains(START_TOKEN) {
if let Some(e) = text.find(END_TOKEN) {
let reasoning_raw = &text[..e];
let post = &text[e + END_TOKEN.len()..];
let reasoning = strip_thought_prefix(reasoning_raw).to_string();
return ParserResult {
normal_text: post.to_string(),
reasoning_text: reasoning,
};
}
return ParserResult {
normal_text: text.to_string(),
reasoning_text: String::new(),
};
}
let mut normal = String::new();
let mut reasoning = String::new();
let mut cursor = 0;
loop {
let next_start = text[cursor..].find(START_TOKEN);
let stray_end = text[cursor..].find(END_TOKEN);
if let Some(e_rel) = stray_end
&& next_start.is_none_or(|s_rel| e_rel < s_rel)
{
let e = cursor + e_rel;
normal.push_str(&text[cursor..e]);
cursor = e + END_TOKEN.len();
continue;
}
let Some(start_rel) = next_start else {
break;
};
let start = cursor + start_rel;
normal.push_str(&text[cursor..start]);
let reasoning_start = start + START_TOKEN.len();
let Some(end_rel) = text[reasoning_start..].find(END_TOKEN) else {
reasoning.push_str(strip_thought_prefix(&text[reasoning_start..]));
return ParserResult {
normal_text: normal,
reasoning_text: reasoning,
};
};
let end = reasoning_start + end_rel;
reasoning.push_str(strip_thought_prefix(&text[reasoning_start..end]));
cursor = end + END_TOKEN.len();
}
normal.push_str(&text[cursor..]);
ParserResult {
normal_text: normal,
reasoning_text: reasoning,
}
}
fn parse_reasoning_streaming_incremental(
&mut self,
text: &str,
_token_ids: &[u32],
) -> ParserResult {
let mut work = std::mem::take(&mut self.buffer);
work.push_str(text);
let mut normal = String::new();
let mut reasoning_emit = String::new();
loop {
if !self.in_reasoning {
let next_start = work.find(START_TOKEN);
let stray_end = work.find(END_TOKEN);
if let Some(e_idx) = stray_end
&& next_start.is_none_or(|s_idx| e_idx < s_idx)
{
normal.push_str(&work[..e_idx]);
work = work[e_idx + END_TOKEN.len()..].to_string();
continue;
}
if let Some(idx) = next_start {
normal.push_str(&work[..idx]);
work = work[idx + START_TOKEN.len()..].to_string();
self.in_reasoning = true;
self.prefix_resolved = false;
self.reasoning_accum.clear();
continue;
}
let lap_start = overlap(&work, START_TOKEN);
let lap_end = overlap(&work, END_TOKEN);
let lap = lap_start.max(lap_end);
if lap > 0 {
let split = work.len() - lap;
normal.push_str(&work[..split]);
self.buffer = work[split..].to_string();
} else {
normal.push_str(&work);
self.buffer.clear();
}
break;
}
if let Some(idx) = work.find(END_TOKEN) {
let raw = &work[..idx];
self.reasoning_accum.push_str(raw);
if !self.prefix_resolved {
let (emit, resolved) = resolve_prefix(&self.reasoning_accum, raw);
if resolved {
reasoning_emit.push_str(emit);
self.prefix_resolved = true;
}
} else {
reasoning_emit.push_str(raw);
}
work = work[idx + END_TOKEN.len()..].to_string();
self.reset_span();
continue;
}
let lap = overlap(&work, END_TOKEN);
let split = work.len() - lap;
let raw = work[..split].to_string();
self.buffer = work[split..].to_string();
if !raw.is_empty() {
self.reasoning_accum.push_str(&raw);
if !self.prefix_resolved {
let (emit, resolved) = resolve_prefix(&self.reasoning_accum, &raw);
if resolved {
reasoning_emit.push_str(emit);
self.prefix_resolved = true;
}
} else {
reasoning_emit.push_str(&raw);
}
}
break;
}
ParserResult {
normal_text: normal,
reasoning_text: reasoning_emit,
}
}
fn finish_reasoning_stream(&mut self) -> ParserResult {
if self.buffer.is_empty() {
return ParserResult::default();
}
let buffered = std::mem::take(&mut self.buffer);
if !self.in_reasoning {
return ParserResult {
normal_text: buffered,
reasoning_text: String::new(),
};
}
let reasoning_text = if self.prefix_resolved {
buffered
} else {
self.reasoning_accum.push_str(&buffered);
let (emit, resolved) = resolve_prefix(&self.reasoning_accum, &buffered);
self.prefix_resolved = resolved;
emit.to_string()
};
self.reset_span();
ParserResult {
normal_text: String::new(),
reasoning_text,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test] fn detect_basic_thinking() {
let mut p = Gemma4ReasoningParser::new();
let r = p.detect_and_parse_reasoning(
"<|channel>thought\nstep one\nstep two<channel|>The answer is 42.",
&[],
);
assert_eq!(r.reasoning_text, "step one\nstep two");
assert_eq!(r.normal_text, "The answer is 42.");
}
#[test] fn detect_no_markers_passes_through() {
let mut p = Gemma4ReasoningParser::new();
let r = p.detect_and_parse_reasoning("just a plain answer", &[]);
assert_eq!(r.reasoning_text, "");
assert_eq!(r.normal_text, "just a plain answer");
}
#[test] fn detect_truncated_reasoning_open_only() {
let mut p = Gemma4ReasoningParser::new();
let r = p.detect_and_parse_reasoning("intro <|channel>thought\npartial", &[]);
assert_eq!(r.reasoning_text, "partial");
assert_eq!(r.normal_text, "intro ");
}
#[test] fn detect_text_before_and_after() {
let mut p = Gemma4ReasoningParser::new();
let r = p.detect_and_parse_reasoning(
"Hello. <|channel>thought\nrumination<channel|> Goodbye.",
&[],
);
assert_eq!(r.reasoning_text, "rumination");
assert_eq!(r.normal_text, "Hello. Goodbye.");
}
#[test] fn detect_dangling_end_marker_extracts_prefix_as_reasoning() {
let mut p = Gemma4ReasoningParser::new();
let r = p.detect_and_parse_reasoning("some thinking<channel|>final answer", &[]);
assert_eq!(r.reasoning_text, "some thinking");
assert_eq!(r.normal_text, "final answer");
}
#[test] fn detect_dangling_end_marker_strips_thought_prefix() {
let mut p = Gemma4ReasoningParser::new();
let r = p.detect_and_parse_reasoning("thought\nrumination<channel|>final answer", &[]);
assert_eq!(r.reasoning_text, "rumination");
assert_eq!(r.normal_text, "final answer");
}
#[test] fn detect_no_thought_prefix() {
let mut p = Gemma4ReasoningParser::new();
let r = p.detect_and_parse_reasoning(
"<|channel>raw reasoning without prefix<channel|>answer",
&[],
);
assert_eq!(r.reasoning_text, "raw reasoning without prefix");
assert_eq!(r.normal_text, "answer");
}
#[test] fn streaming_single_chunk() {
let mut p = Gemma4ReasoningParser::new();
let r = p.parse_reasoning_streaming_incremental(
"<|channel>thought\nrumination<channel|>final",
&[],
);
assert_eq!(r.reasoning_text, "rumination");
assert_eq!(r.normal_text, "final");
}
#[test] fn streaming_thought_prefix_split_across_deltas() {
let mut p = Gemma4ReasoningParser::new();
let chunks = [
"<|channel>",
"thou",
"ght\n",
"real reasoning here",
"<channel|>",
"the answer.",
];
let mut reasoning = String::new();
let mut normal = String::new();
for c in chunks {
let r = p.parse_reasoning_streaming_incremental(c, &[]);
reasoning.push_str(&r.reasoning_text);
normal.push_str(&r.normal_text);
}
assert_eq!(reasoning, "real reasoning here");
assert_eq!(normal, "the answer.");
}
#[test] fn streaming_start_marker_split() {
let mut p = Gemma4ReasoningParser::new();
let chunks = [
"intro ",
"<|chan", "nel>thought\n",
"rumination",
"<channel|>",
"outro",
];
let mut reasoning = String::new();
let mut normal = String::new();
for c in chunks {
let r = p.parse_reasoning_streaming_incremental(c, &[]);
reasoning.push_str(&r.reasoning_text);
normal.push_str(&r.normal_text);
}
assert_eq!(reasoning, "rumination");
assert_eq!(normal, "intro outro");
}
#[test] fn streaming_end_marker_split() {
let mut p = Gemma4ReasoningParser::new();
let chunks = [
"<|channel>thought\n",
"thinking",
"<chan", "nel|>",
"answer",
];
let mut reasoning = String::new();
let mut normal = String::new();
for c in chunks {
let r = p.parse_reasoning_streaming_incremental(c, &[]);
reasoning.push_str(&r.reasoning_text);
normal.push_str(&r.normal_text);
}
assert_eq!(reasoning, "thinking");
assert_eq!(normal, "answer");
}
#[test] fn streaming_no_thought_prefix_streaming() {
let mut p = Gemma4ReasoningParser::new();
let chunks = [
"<|channel>",
"raw stream of consciousness",
"<channel|>",
"answer",
];
let mut reasoning = String::new();
let mut normal = String::new();
for c in chunks {
let r = p.parse_reasoning_streaming_incremental(c, &[]);
reasoning.push_str(&r.reasoning_text);
normal.push_str(&r.normal_text);
}
assert_eq!(reasoning, "raw stream of consciousness");
assert_eq!(normal, "answer");
}
#[test] fn streaming_no_markers() {
let mut p = Gemma4ReasoningParser::new();
let r = p.parse_reasoning_streaming_incremental("plain text only", &[]);
assert_eq!(r.reasoning_text, "");
assert_eq!(r.normal_text, "plain text only");
}
#[test] fn detect_multiple_reasoning_spans() {
let mut p = Gemma4ReasoningParser::new();
let input =
"<|channel>thought\nfirst<channel|> middle <|channel>thought\nsecond<channel|> done";
let r = p.detect_and_parse_reasoning(input, &[]);
assert_eq!(r.reasoning_text, "firstsecond");
assert_eq!(r.normal_text, " middle done");
}
#[test] fn streaming_multiple_reasoning_spans() {
let mut p = Gemma4ReasoningParser::new();
let input =
"<|channel>thought\nfirst<channel|>answer1<|channel>thought\nsecond<channel|>answer2";
let r = p.parse_reasoning_streaming_incremental(input, &[]);
assert!(r.reasoning_text.contains("first"));
assert!(r.reasoning_text.contains("second"));
assert!(r.normal_text.contains("answer1"));
assert!(r.normal_text.contains("answer2"));
}
#[test] fn paired_reasoning_then_tool_call_non_streaming() {
let mut p = Gemma4ReasoningParser::new();
let input = concat!(
"<|channel>thought\nthinking about the request<channel|>",
"<|tool_call>call:get_weather{location:<|\"|>Tokyo<|\"|>}<tool_call|>",
);
let r = p.detect_and_parse_reasoning(input, &[]);
assert_eq!(r.reasoning_text, "thinking about the request");
assert_eq!(
r.normal_text, r#"<|tool_call>call:get_weather{location:<|"|>Tokyo<|"|>}<tool_call|>"#,
"tool-call markers must survive reasoning extraction",
);
}
}