use crate::tool_calling::inkling::find_complete_tool_call_end;
use crate::tool_calling::inkling::tokens::{
END_MESSAGE, END_SAMPLING, INVOKE as CONTENT_INVOKE, MESSAGE_MODEL,
};
use crate::{ParserResult, ReasoningParser};
const CONTENT_THINKING: &str = "<|content_thinking|>";
const CONTENT_TEXT: &str = "<|content_text|>";
const CONTENT_IMAGE: &str = "<|content_image|>";
const CONTENT_AUDIO: &str = "<|content_audio_input|>";
const ALL_MARKERS: [&str; 8] = [
MESSAGE_MODEL,
CONTENT_THINKING,
CONTENT_TEXT,
CONTENT_INVOKE,
END_MESSAGE,
END_SAMPLING,
CONTENT_IMAGE,
CONTENT_AUDIO,
];
const CONTENT_MARKERS: [&str; 5] = [
CONTENT_THINKING,
CONTENT_TEXT,
CONTENT_INVOKE,
CONTENT_IMAGE,
CONTENT_AUDIO,
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
Primed,
Idle,
InReasoning,
InContent,
InToolBlock,
InDiscard,
}
#[derive(Debug, Clone)]
pub struct InklingReasoningParser {
buffer: String,
state: State,
}
impl InklingReasoningParser {
pub fn new() -> Self {
Self {
buffer: String::new(),
state: State::Primed,
}
}
}
impl Default for InklingReasoningParser {
fn default() -> Self {
Self::new()
}
}
fn overlap(s: &str, delim: &str) -> usize {
let max = delim.len().min(s.len());
(1..=max)
.rev()
.find(|&i| s.ends_with(&delim[..i]))
.unwrap_or(0)
}
fn max_partial_marker_suffix(s: &str) -> usize {
ALL_MARKERS.iter().map(|m| overlap(s, m)).max().unwrap_or(0)
}
fn is_partial_leading_marker(s: &str) -> bool {
!s.is_empty()
&& ALL_MARKERS
.iter()
.any(|m| m.len() > s.len() && m.starts_with(s))
}
fn flush_ambiguous_marker_prefix(s: &str) -> String {
match s {
"<" | "<|" => s.to_string(),
_ => String::new(),
}
}
fn find_earliest(s: &str, markers: &[&str]) -> Option<(usize, usize)> {
markers
.iter()
.filter_map(|m| s.find(m).map(|i| (i, m.len())))
.min_by_key(|&(i, _)| i)
}
fn strip_framing(s: &str) -> String {
let mut out = s.to_string();
for marker in ALL_MARKERS {
if out.contains(marker) {
out = out.replace(marker, "");
}
}
out
}
fn content_type_undecided(rem: &str) -> bool {
if rem.is_empty() {
return true;
}
CONTENT_MARKERS
.iter()
.any(|t| t.len() > rem.len() && t.starts_with(rem))
}
fn has_header_before_invoke(s: &str) -> bool {
match s.find(MESSAGE_MODEL) {
Some(header) => s.find(CONTENT_INVOKE).is_none_or(|invoke| header < invoke),
None => false,
}
}
impl InklingReasoningParser {
fn run(&mut self, reasoning: &mut String, normal: &mut String) {
loop {
match self.state {
State::Primed => {
if self.buffer.is_empty() {
break;
}
if self.buffer.starts_with(MESSAGE_MODEL) {
self.state = State::Idle;
} else if CONTENT_MARKERS.iter().any(|m| self.buffer.starts_with(m)) {
self.buffer.insert_str(0, MESSAGE_MODEL);
self.state = State::Idle;
} else if has_header_before_invoke(&self.buffer) {
self.state = State::Idle;
} else if self.buffer.contains(CONTENT_INVOKE) {
self.buffer.insert_str(0, MESSAGE_MODEL);
self.state = State::Idle;
} else {
break;
}
}
State::Idle => {
if let Some(pos) = self.buffer.find(MESSAGE_MODEL) {
normal.push_str(&strip_framing(&self.buffer[..pos]));
let rem_start = pos + MESSAGE_MODEL.len();
let rem = &self.buffer[rem_start..];
if rem.starts_with(CONTENT_THINKING) {
self.buffer = self.buffer[rem_start + CONTENT_THINKING.len()..].into();
self.state = State::InReasoning;
} else if rem.starts_with(CONTENT_TEXT) {
self.buffer = self.buffer[rem_start + CONTENT_TEXT.len()..].into();
self.state = State::InContent;
} else if rem.starts_with(CONTENT_IMAGE) {
self.buffer = self.buffer[rem_start + CONTENT_IMAGE.len()..].into();
self.state = State::InDiscard;
} else if rem.starts_with(CONTENT_AUDIO) {
self.buffer = self.buffer[rem_start + CONTENT_AUDIO.len()..].into();
self.state = State::InDiscard;
} else if content_type_undecided(rem) {
self.buffer = self.buffer[pos..].into();
break;
} else {
self.buffer = self.buffer[pos..].into();
self.state = State::InToolBlock;
}
} else {
let hold = max_partial_marker_suffix(&self.buffer);
let split = self.buffer.len() - hold;
normal.push_str(&strip_framing(&self.buffer[..split]));
self.buffer = self.buffer[split..].into();
break;
}
}
State::InReasoning | State::InContent => {
let sink = if self.state == State::InReasoning {
&mut *reasoning
} else {
&mut *normal
};
if let Some((idx, mlen)) =
find_earliest(&self.buffer, &[END_MESSAGE, END_SAMPLING])
{
sink.push_str(&self.buffer[..idx]);
self.buffer = self.buffer[idx + mlen..].into();
self.state = State::Idle;
} else {
let hold = overlap(&self.buffer, END_MESSAGE)
.max(overlap(&self.buffer, END_SAMPLING));
let split = self.buffer.len() - hold;
sink.push_str(&self.buffer[..split]);
self.buffer = self.buffer[split..].into();
break;
}
}
State::InToolBlock => {
if let Some(upto) = find_complete_tool_call_end(&self.buffer) {
normal.push_str(&self.buffer[..upto]);
self.buffer = self.buffer[upto..].into();
self.state = State::Idle;
} else {
break;
}
}
State::InDiscard => {
if let Some(idx) = self.buffer.find(END_MESSAGE) {
self.buffer = self.buffer[idx + END_MESSAGE.len()..].into();
self.state = State::Idle;
} else {
let hold = overlap(&self.buffer, END_MESSAGE);
let split = self.buffer.len() - hold;
self.buffer = self.buffer[split..].into();
break;
}
}
}
}
}
}
impl ReasoningParser for InklingReasoningParser {
fn detect_and_parse_reasoning(&mut self, text: &str, _token_ids: &[u32]) -> ParserResult {
self.buffer.clear();
self.state = State::Primed;
let mut reasoning = String::new();
let mut normal = String::new();
self.buffer.push_str(text);
self.run(&mut reasoning, &mut normal);
let flush = self.finish_reasoning_stream();
reasoning.push_str(&flush.reasoning_text);
normal.push_str(&flush.normal_text);
self.buffer.clear();
self.state = State::Primed;
ParserResult {
reasoning_text: reasoning.trim().to_string(),
normal_text: normal.trim().to_string(),
}
}
fn parse_reasoning_streaming_incremental(
&mut self,
text: &str,
_token_ids: &[u32],
) -> ParserResult {
self.buffer.push_str(text);
let mut reasoning = String::new();
let mut normal = String::new();
self.run(&mut reasoning, &mut normal);
ParserResult {
reasoning_text: reasoning,
normal_text: normal,
}
}
fn finish_reasoning_stream(&mut self) -> ParserResult {
if self.buffer.is_empty() {
self.state = State::Primed;
return ParserResult::default();
}
let buffered = std::mem::take(&mut self.buffer);
let result = match self.state {
State::Primed => {
if is_partial_leading_marker(&buffered) {
ParserResult::default()
} else {
ParserResult {
normal_text: strip_framing(&buffered),
reasoning_text: String::new(),
}
}
}
State::InReasoning => ParserResult {
reasoning_text: flush_ambiguous_marker_prefix(&buffered),
normal_text: String::new(),
},
State::InContent => ParserResult {
normal_text: flush_ambiguous_marker_prefix(&buffered),
reasoning_text: String::new(),
},
State::InToolBlock => ParserResult {
normal_text: buffered,
reasoning_text: String::new(),
},
State::InDiscard => ParserResult::default(),
State::Idle => ParserResult {
normal_text: flush_ambiguous_marker_prefix(&buffered),
reasoning_text: String::new(),
},
};
self.state = State::Primed;
result
}
}
#[cfg(test)]
mod tests {
use super::*;
const REASONING_ANSWER: &str = "<|message_model|><|content_thinking|>The user asks a simple arithmetic question. 2+2=4.<|end_message|><|message_model|><|content_text|>2 + 2 = 4.<|end_message|><|content_model_end_sampling|>";
const REASONING_TOOL: &str = r#"<|message_model|><|content_thinking|>I should call the weather tool for Paris.<|end_message|><|message_model|>get_weather<|content_invoke_tool_json|>{"name":"get_weather","args":{"location":"Paris","unit":"celsius"}}<|end_message|><|content_model_end_sampling|>"#;
fn assert_no_framing_leak(s: &str) {
for marker in ALL_MARKERS {
assert!(
!s.contains(marker),
"framing token {marker:?} leaked into {s:?}"
);
}
}
#[test]
fn batch_reasoning_and_answer_split() {
let mut parser = InklingReasoningParser::new();
let result = parser.detect_and_parse_reasoning(REASONING_ANSWER, &[]);
assert_eq!(
result.reasoning_text,
"The user asks a simple arithmetic question. 2+2=4."
);
assert_eq!(result.normal_text, "2 + 2 = 4.");
assert_no_framing_leak(&result.reasoning_text);
assert_no_framing_leak(&result.normal_text);
}
#[test]
fn batch_reasoning_only() {
let mut parser = InklingReasoningParser::new();
let result = parser.detect_and_parse_reasoning(
"<|message_model|><|content_thinking|>thinking<|end_message|>",
&[],
);
assert_eq!(result.reasoning_text, "thinking");
assert_eq!(result.normal_text, "");
}
#[test]
fn batch_content_only() {
let mut parser = InklingReasoningParser::new();
let result = parser.detect_and_parse_reasoning(
"<|message_model|><|content_text|>2 + 2 = 4.<|end_message|>",
&[],
);
assert_eq!(result.reasoning_text, "");
assert_eq!(result.normal_text, "2 + 2 = 4.");
}
#[test]
fn batch_preserves_tool_block_verbatim() {
let mut parser = InklingReasoningParser::new();
let result = parser.detect_and_parse_reasoning(REASONING_TOOL, &[]);
assert_eq!(
result.reasoning_text,
"I should call the weather tool for Paris."
);
assert_eq!(
result.normal_text,
r#"<|message_model|>get_weather<|content_invoke_tool_json|>{"name":"get_weather","args":{"location":"Paris","unit":"celsius"}}<|end_message|>"#
);
assert!(!result.normal_text.contains(END_SAMPLING));
assert_no_framing_leak(&result.reasoning_text);
}
#[test]
fn batch_plain_text_no_markers() {
let mut parser = InklingReasoningParser::new();
let result = parser.detect_and_parse_reasoning("plain answer", &[]);
assert_eq!(result.reasoning_text, "");
assert_eq!(result.normal_text, "plain answer");
}
#[test]
fn batch_truncated_reasoning_block() {
let mut parser = InklingReasoningParser::new();
let result = parser.detect_and_parse_reasoning(
"<|message_model|><|content_thinking|>partial reasoning",
&[],
);
assert_eq!(result.reasoning_text, "partial reasoning");
assert_eq!(result.normal_text, "");
}
fn run_stream(chunks: &[&str]) -> (String, String) {
let mut parser = InklingReasoningParser::new();
let (mut reasoning, mut normal) = (String::new(), String::new());
for chunk in chunks {
let r = parser.parse_reasoning_streaming_incremental(chunk, &[]);
reasoning.push_str(&r.reasoning_text);
normal.push_str(&r.normal_text);
}
let f = parser.finish_reasoning_stream();
reasoning.push_str(&f.reasoning_text);
normal.push_str(&f.normal_text);
(reasoning, normal)
}
#[test]
fn streaming_matches_batch_for_reasoning_answer() {
let (reasoning, normal) = run_stream(&[REASONING_ANSWER]);
assert_eq!(
reasoning,
"The user asks a simple arithmetic question. 2+2=4."
);
assert_eq!(normal, "2 + 2 = 4.");
}
#[test]
fn streaming_reasoning_split_across_chunks() {
let (reasoning, normal) = run_stream(&[
"<|message_model|><|content_thinking|>rea",
"son<|end_message|><|message_model|><|content_text|>ans",
"wer<|end_message|>",
]);
assert_eq!(reasoning, "reason");
assert_eq!(normal, "answer");
}
#[test]
fn streaming_start_marker_split_across_chunks() {
let (reasoning, normal) = run_stream(&[
"<|message_model|><|content_thin",
"king|>reason<|end_message|>",
]);
assert_eq!(reasoning, "reason");
assert_eq!(normal, "");
assert_no_framing_leak(&reasoning);
assert_no_framing_leak(&normal);
}
#[test]
fn streaming_end_marker_split_across_chunks() {
let (reasoning, normal) = run_stream(&[
"<|message_model|><|content_thinking|>reason<|end_mes",
"sage|><|message_model|><|content_text|>answer<|end_message|>",
]);
assert_eq!(reasoning, "reason");
assert_eq!(normal, "answer");
}
#[test]
fn streaming_preserves_tool_block_verbatim() {
let (reasoning, normal) = run_stream(&[REASONING_TOOL]);
assert_eq!(reasoning, "I should call the weather tool for Paris.");
assert_eq!(
normal,
r#"<|message_model|>get_weather<|content_invoke_tool_json|>{"name":"get_weather","args":{"location":"Paris","unit":"celsius"}}<|end_message|>"#
);
}
#[test]
fn batch_headerless_reasoning_routes_to_reasoning() {
let mut parser = InklingReasoningParser::new();
let result =
parser.detect_and_parse_reasoning("<|content_thinking|>reason<|end_message|>", &[]);
assert_eq!(result.reasoning_text, "reason");
assert_eq!(result.normal_text, "");
assert_no_framing_leak(&result.normal_text);
}
#[test]
fn batch_headerless_content_routes_to_normal() {
let mut parser = InklingReasoningParser::new();
let result =
parser.detect_and_parse_reasoning("<|content_text|>answer<|end_message|>", &[]);
assert_eq!(result.reasoning_text, "");
assert_eq!(result.normal_text, "answer");
}
#[test]
fn batch_headerless_tool_block_reconstructs_header() {
let mut parser = InklingReasoningParser::new();
let result = parser.detect_and_parse_reasoning(
r#"get_weather<|content_invoke_tool_json|>{"name":"get_weather","args":{"location":"SF"}}<|end_message|>"#,
&[],
);
assert_eq!(result.reasoning_text, "");
assert_eq!(
result.normal_text,
r#"<|message_model|>get_weather<|content_invoke_tool_json|>{"name":"get_weather","args":{"location":"SF"}}<|end_message|>"#
);
}
#[test]
fn streaming_headerless_tool_block_reconstructs_header() {
let (reasoning, normal) = run_stream(&[
"get",
"_weather",
"<|content_invoke_tool_json|>",
r#"{"name":"get_weather","args":{"location":"SF"}}"#,
"<|end_message|>",
]);
assert_eq!(reasoning, "");
assert_eq!(
normal,
r#"<|message_model|>get_weather<|content_invoke_tool_json|>{"name":"get_weather","args":{"location":"SF"}}<|end_message|>"#
);
}
#[test]
fn streaming_headerless_reasoning_then_content() {
let (reasoning, normal) = run_stream(&[
"<|content_thinking|>rea",
"son<|end_message|><|message_model|><|content_text|>ans",
"wer<|end_message|>",
]);
assert_eq!(reasoning, "reason");
assert_eq!(normal, "answer");
}
#[test]
fn streaming_headerless_plain_text_stays_clean() {
let (reasoning, normal) = run_stream(&["plain ", "answer"]);
assert_eq!(reasoning, "");
assert_eq!(normal, "plain answer");
}
#[test]
fn streaming_headerless_content_routes_to_normal() {
let (reasoning, normal) =
run_stream(&["<|content_te", "xt|>ans", "wer<|end_mess", "age|>"]);
assert_eq!(reasoning, "");
assert_eq!(normal, "answer");
}
#[test]
fn streaming_partial_marker_first_chunk_holds_then_reclassifies() {
let (reasoning, normal) = run_stream(&["<|cont", "ent_thinking|>reason<|end_message|>"]);
assert_eq!(reasoning, "reason");
assert_eq!(normal, "");
}
#[test]
fn batch_image_block_does_not_leak_into_normal_text() {
let mut parser = InklingReasoningParser::new();
let result = parser.detect_and_parse_reasoning(
"<|message_model|><|content_image|>IMGDATA<|end_message|><|message_model|><|content_text|>done<|end_message|>",
&[],
);
assert_eq!(result.normal_text, "done");
assert_eq!(result.reasoning_text, "");
assert!(!result.normal_text.contains("IMGDATA"));
assert_no_framing_leak(&result.normal_text);
}
#[test]
fn batch_audio_block_does_not_leak_into_normal_text() {
let mut parser = InklingReasoningParser::new();
let result = parser.detect_and_parse_reasoning(
"<|message_model|><|content_audio_input|>AUDIOBYTES<|end_message|><|message_model|><|content_text|>hi<|end_message|>",
&[],
);
assert_eq!(result.normal_text, "hi");
assert_eq!(result.reasoning_text, "");
assert!(!result.normal_text.contains("AUDIOBYTES"));
assert_no_framing_leak(&result.normal_text);
}
#[test]
fn streaming_image_block_split_does_not_leak() {
let (reasoning, normal) = run_stream(&[
"<|message_model|><|content_ima",
"ge|>IMG<|end_mess",
"age|><|message_model|><|content_text|>ok<|end_message|>",
]);
assert_eq!(normal, "ok");
assert_eq!(reasoning, "");
assert!(!normal.contains("IMG"));
assert_no_framing_leak(&normal);
}
#[test]
fn batch_prose_before_first_header_is_kept_clean() {
let mut parser = InklingReasoningParser::new();
let result = parser.detect_and_parse_reasoning(
"Hello. <|message_model|><|content_thinking|>think<|end_message|><|message_model|><|content_text|>done<|end_message|>",
&[],
);
assert_eq!(result.normal_text, "Hello. done");
assert_eq!(result.reasoning_text, "think");
assert_no_framing_leak(&result.normal_text);
assert_no_framing_leak(&result.reasoning_text);
}
#[test]
fn streaming_prose_before_first_header_is_kept_clean() {
let (reasoning, normal) = run_stream(&[
"Hello. <|message_model|><|content_thinking|>th",
"ink<|end_message|>",
]);
assert_eq!(normal, "Hello. ");
assert_eq!(reasoning, "think");
assert_no_framing_leak(&normal);
assert_no_framing_leak(&reasoning);
}
#[test]
fn batch_prose_before_first_tool_header_routes_without_synthetic_header() {
let block = r#"<|message_model|>get_weather<|content_invoke_tool_json|>{"name":"get_weather","args":{"location":"Paris"}}<|end_message|>"#;
let mut parser = InklingReasoningParser::new();
let result = parser.detect_and_parse_reasoning(&format!("Hello. {block}"), &[]);
assert_eq!(result.reasoning_text, "");
assert_eq!(result.normal_text, format!("Hello. {block}"));
}
#[test]
fn streaming_trailing_partial_angle_bracket_is_preserved_as_content() {
let (reasoning, normal) = run_stream(&["<|content_text|>ans<|end_message|>", "<|"]);
assert_eq!(normal, "ans<|");
assert_eq!(reasoning, "");
}
#[test]
fn streaming_trailing_truncated_header_fragment_is_dropped() {
let (reasoning, normal) =
run_stream(&["<|content_text|>ans<|end_message|>", "<|content_th"]);
assert_eq!(normal, "ans");
assert_eq!(reasoning, "");
assert_no_framing_leak(&normal);
}
#[test]
fn streaming_truncated_end_marker_is_dropped_from_open_blocks() {
let (reasoning, normal) = run_stream(&["<|content_thinking|>reason<|end_mes"]);
assert_eq!(reasoning, "reason");
assert_eq!(normal, "");
let (reasoning, normal) = run_stream(&["<|content_text|>answer<|end_mes"]);
assert_eq!(reasoning, "");
assert_eq!(normal, "answer");
}
#[test]
fn streaming_ambiguous_short_prefix_is_preserved_inside_open_block() {
let (reasoning, normal) = run_stream(&["<|content_text|>answer<|"]);
assert_eq!(reasoning, "");
assert_eq!(normal, "answer<|");
}
#[test]
fn streaming_parser_can_be_reused_after_finish() {
let mut parser = InklingReasoningParser::new();
let first = parser
.parse_reasoning_streaming_incremental("<|content_text|>first<|end_message|>", &[]);
assert_eq!(first.normal_text, "first");
let finished = parser.finish_reasoning_stream();
assert_eq!(finished.reasoning_text, "");
assert_eq!(finished.normal_text, "");
let second = parser.parse_reasoning_streaming_incremental(
"<|content_thinking|>second<|end_message|>",
&[],
);
assert_eq!(second.reasoning_text, "second");
assert_eq!(second.normal_text, "");
}
#[test]
fn streaming_matches_batch_at_every_single_split_boundary() {
let cases = [
REASONING_ANSWER,
REASONING_TOOL,
"<|content_thinking|>reason<|end_message|><|message_model|><|content_text|>answer<|end_message|>",
r#"<|message_model|>echo<|content_invoke_tool_json|>{"name":"echo","args":{"text":"a<|end_message|>b"}}<|end_message|>"#,
"<|message_model|><|content_image|>IMG<|end_message|><|message_model|><|content_text|>done<|end_message|>",
];
for input in cases {
let mut batch_parser = InklingReasoningParser::new();
let expected = batch_parser.detect_and_parse_reasoning(input, &[]);
for split in input
.char_indices()
.map(|(idx, _)| idx)
.chain(std::iter::once(input.len()))
{
let (reasoning, normal) = run_stream(&[&input[..split], &input[split..]]);
assert_eq!(
(reasoning, normal),
(
expected.reasoning_text.clone(),
expected.normal_text.clone()
),
"batch/stream mismatch at byte {split} for {input:?}"
);
}
}
}
#[test]
fn tool_marker_literal_inside_json_survives_reasoning_stage() {
let block = r#"<|message_model|>echo<|content_invoke_tool_json|>{"name":"echo","args":{"text":"a<|end_message|>b"}}<|end_message|>"#;
let mut parser = InklingReasoningParser::new();
let batch = parser.detect_and_parse_reasoning(block, &[]);
assert_eq!(batch.reasoning_text, "");
assert_eq!(batch.normal_text, block);
let (reasoning, normal) = run_stream(&[
r#"<|message_model|>echo<|content_invoke_tool_json|>{"name":"echo","args":{"text":"a<|end_message|>"#,
r#"b"}}<|end_message|>"#,
]);
assert_eq!(reasoning, "");
assert_eq!(normal, block);
}
#[test]
fn header_marker_literal_inside_headerless_tool_json_does_not_retarget_routing() {
let input = r#"echo<|content_invoke_tool_json|>{"name":"echo","args":{"text":"a<|message_model|>b"}}<|end_message|>"#;
let expected = format!("{MESSAGE_MODEL}{input}");
let mut parser = InklingReasoningParser::new();
let result = parser.detect_and_parse_reasoning(input, &[]);
assert_eq!(result.reasoning_text, "");
assert_eq!(result.normal_text, expected);
}
#[test]
fn batch_empty_and_whitespace_stay_clean() {
let mut parser = InklingReasoningParser::new();
let empty = parser.detect_and_parse_reasoning("", &[]);
assert_eq!(empty.reasoning_text, "");
assert_eq!(empty.normal_text, "");
let ws = parser.detect_and_parse_reasoning(" ", &[]);
assert_eq!(ws.reasoning_text, "");
assert_eq!(ws.normal_text, "");
}
#[test]
fn dangling_end_marker_is_stripped_without_an_open_block() {
let mut parser = InklingReasoningParser::new();
let result = parser.detect_and_parse_reasoning(END_MESSAGE, &[]);
assert_eq!(result.reasoning_text, "");
assert_eq!(result.normal_text, "");
}
}