[Rust Frontend] Correctly parse whitespace framing in model output (#55417)

Signed-off-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: Codex <noreply@openai.com>

Bugen Zhao committed Sep 9, 2026 at 13:40 UTC fc6b6e1feb263c385be634566cef89dd4dcf1292
36 files changed +781 -316
rust/src/chat/src/lib.rs
+1 -1
@@ -416,6 +416,6 @@ mod tests {
416 )
417 .unwrap_err();
418
419 - expect_test::expect!["reasoning parser `definitely_missing_reasoning_parser` is not registered (choose from: cohere_cmd, deepseek_r1, deepseek_v3, deepseek_v4, gemma4, glm45, hy_v3, hy_v4, inkling, kimi, kimi_k2, kimi_k3, minimax_m2, minimax_m3, nemotron_v3, qwen3, seed_oss, step3, step3p5)"].assert_eq(&error.to_report_string());
419 + expect_test::expect!["reasoning parser `definitely_missing_reasoning_parser` is not registered (choose from: cohere_cmd, deepseek_r1, deepseek_v3, deepseek_v4, gemma4, glm45, glm47, hy_v3, hy_v4, inkling, kimi, kimi_k2, kimi_k3, minimax_m2, minimax_m3, nemotron_v3, qwen3, seed_oss, step3, step3p5)"].assert_eq(&error.to_report_string());
420 }
421 }
rust/src/chat/src/parser/reasoning/mod.rs
+8 -6
@@ -7,10 +7,10 @@ use std::sync::{Arc, LazyLock};
7
8 pub use vllm_parser::reasoning::{
9 CohereCmdReasoningParser, DeepSeekR1ReasoningParser, DeepSeekV3ReasoningParser,
10 - DeepSeekV4ReasoningParser, Glm45ReasoningParser, KimiK2ReasoningParser, KimiReasoningParser,
11 - MiniMaxM2ReasoningParser, MiniMaxM3ReasoningParser, NemotronV3ReasoningParser,
12 - Qwen3ReasoningParser, ReasoningDelta, ReasoningError, ReasoningParser, SeedOssReasoningParser,
13 - Step3ReasoningParser, Step3p5ReasoningParser,
10 + DeepSeekV4ReasoningParser, Glm45ReasoningParser, Glm47ReasoningParser, KimiK2ReasoningParser,
11 + KimiReasoningParser, MiniMaxM2ReasoningParser, MiniMaxM3ReasoningParser,
12 + NemotronV3ReasoningParser, Qwen3ReasoningParser, ReasoningDelta, ReasoningError,
13 + ReasoningParser, SeedOssReasoningParser, Step3ReasoningParser, Step3p5ReasoningParser,
14 };
15 use vllm_tokenizer::DynTokenizer;
16
@@ -25,6 +25,7 @@ pub mod names {
25 pub const GEMMA4: &str = "gemma4";
26 pub const INKLING: &str = "inkling";
27 pub const GLM45: &str = "glm45";
28 + pub const GLM47: &str = "glm47";
29 pub const HY_V3: &str = "hy_v3";
30 pub const HY_V4: &str = "hy_v4";
31 pub const KIMI: &str = "kimi";
@@ -69,6 +70,7 @@ impl ReasoningParserFactory {
70 .register_unified_dummy(names::GEMMA4)
71 .register_unified_dummy(names::INKLING)
72 .register_parser::<Glm45ReasoningParser>(names::GLM45)
73 + .register_parser::<Glm47ReasoningParser>(names::GLM47)
74 .register_unified_dummy(names::HY_V3)
75 .register_unified_dummy(names::HY_V4)
76 .register_parser::<KimiReasoningParser>(names::KIMI)
@@ -91,8 +93,8 @@ impl ReasoningParserFactory {
93 .register_pattern("gemma4", names::GEMMA4)
94 .register_pattern("qwq", names::DEEPSEEK_R1)
95 .register_pattern("qwen3", names::QWEN3)
94 - .register_pattern("glm-5", names::GLM45)
95 - .register_pattern("glm-4.7", names::GLM45)
96 + .register_pattern("glm-5", names::GLM47)
97 + .register_pattern("glm-4.7", names::GLM47)
98 .register_pattern("glm-4.6", names::GLM45)
99 .register_pattern("glm-4.5", names::GLM45)
100 .register_pattern("hy3", names::HY_V3)
rust/src/chat/src/parser/reasoning/tests.rs
+12 -1
@@ -25,7 +25,7 @@ fn factory_contains_and_lists_registered_parsers() {
25 }
26
27 #[test]
28 -fn factory_resolves_deepseek_v4_to_qwen3_alias() {
28 +fn factory_resolves_deepseek_v4() {
29 let factory = ReasoningParserFactory::new();
30 assert_eq!(
31 factory.resolve_name_for_model("deepseek-ai/DeepSeek-V4"),
@@ -112,3 +112,14 @@ fn factory_rejects_unknown_parser_names() {
112 };
113 assert!(error.to_string().contains("choose from"));
114 }
115 +
116 +#[test]
117 +fn factory_distinguishes_glm_reasoning_framing() {
118 + let factory = ReasoningParserFactory::new();
119 + for model in ["zai-org/GLM-4.5", "zai-org/GLM-4.6"] {
120 + assert_eq!(factory.resolve_name_for_model(model), Some(names::GLM45));
121 + }
122 + for model in ["zai-org/GLM-4.7-Flash", "zai-org/GLM-5.2-FP8"] {
123 + assert_eq!(factory.resolve_name_for_model(model), Some(names::GLM47));
124 + }
125 +}
rust/src/chat/tests/roundtrip.rs
+11 -11
@@ -445,10 +445,12 @@ async fn run_roundtrip_reasoning_and_content_inner(
445 let result = run_roundtrip(case, backends, &request, assistant).await?;
446
447 assert_eq!(
448 - result.parsed_message.reasoning().as_deref().map(str::trim),
449 - effective_thinking.then_some(expected_reasoning)
448 + result.parsed_message.reasoning().as_deref(),
449 + effective_thinking.then_some(expected_reasoning),
450 + "parsed message: {:#?}",
451 + result.parsed_message
452 );
451 - assert_eq!(result.parsed_message.text().trim(), expected_text);
453 + assert_eq!(result.parsed_message.text(), expected_text);
454 assert_eq!(result.parsed_message.tool_calls().count(), 0);
455
456 assert_eq!(
@@ -508,10 +510,12 @@ async fn run_roundtrip_tool_call_mix(
510 .await?;
511
512 assert_eq!(
511 - result.parsed_message.reasoning().as_deref().map(str::trim),
512 - Some(expected_reasoning)
513 + result.parsed_message.reasoning().as_deref(),
514 + Some(expected_reasoning),
515 + "parsed message: {:#?}",
516 + result.parsed_message
517 );
514 - assert_eq!(result.parsed_message.text().trim(), expected_text);
518 + assert_eq!(result.parsed_message.text(), expected_text);
519
520 let tool_calls = result.parsed_message.tool_calls().collect::<Vec<_>>();
521 assert_eq!(
@@ -714,11 +718,7 @@ async fn parse_completion(
718
719 while let Some(event) = events.next().await {
720 if let ChatEvent::Done { message, .. } = event? {
717 - // TODO: currently our parsers are not very strict about preserving or trimming
718 - // whitespace, so we trim here to avoid roundtrip failures due to
719 - // insignificant whitespace differences. However, this may hurt token-level
720 - // fidelity so we should consider improving them.
721 - return Ok(message.trim());
721 + return Ok(message);
722 }
723 }
724
rust/src/parser/src/reasoning/cohere_cmd.rs
+8 -5
@@ -3,7 +3,10 @@
3
4 use vllm_tokenizer::{DecodedText, DynTokenizer};
5
6 -use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result};
6 +use super::{
7 + DelimitedReasoningParser, DelimitedReasoningParserBuilder, ReasoningDelta, ReasoningParser,
8 + Result,
9 +};
10
11 /// Reasoning parser for Cohere Command models that use explicit START/END tags.
12 pub struct CohereCmdReasoningParser {
@@ -15,11 +18,12 @@ impl CohereCmdReasoningParser {
18 /// machine.
19 pub fn new(tokenizer: DynTokenizer) -> Result<Self> {
20 Ok(Self {
18 - inner: DelimitedReasoningParser::new(
21 + inner: DelimitedReasoningParserBuilder::new(
22 tokenizer,
23 "<|START_THINKING|>",
24 "<|END_THINKING|>",
22 - )?,
25 + )
26 + .build()?,
27 })
28 }
29 }
@@ -33,8 +37,7 @@ impl ReasoningParser for CohereCmdReasoningParser {
37 }
38
39 fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
36 - self.inner.initialize(prompt_token_ids);
37 - Ok(())
40 + self.inner.initialize(prompt_token_ids)
41 }
42
43 fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta> {
rust/src/parser/src/reasoning/deepseek_r1.rs
+9 -6
@@ -3,7 +3,10 @@
3
4 use vllm_tokenizer::{DecodedText, DynTokenizer};
5
6 -use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result};
6 +use super::{
7 + DelimitedReasoningParser, DelimitedReasoningParserBuilder, ReasoningDelta, ReasoningParser,
8 + Result,
9 +};
10
11 /// Reasoning parser for DeepSeek R1 style outputs.
12 pub struct DeepSeekR1ReasoningParser {
@@ -11,11 +14,12 @@ pub struct DeepSeekR1ReasoningParser {
14 }
15
16 impl DeepSeekR1ReasoningParser {
14 - /// Create a DeepSeek R1 parser backed by the shared delimited state
15 - /// machine.
17 + /// Create a DeepSeek R1 parser backed by the shared delimited state machine.
18 pub fn new(tokenizer: DynTokenizer) -> Result<Self> {
19 Ok(Self {
18 - inner: DelimitedReasoningParser::new(tokenizer, "<think>", "</think>")?,
20 + inner: DelimitedReasoningParserBuilder::new(tokenizer, "<think>", "</think>")
21 + .with_after_start("\n")
22 + .build()?,
23 })
24 }
25 }
@@ -29,8 +33,7 @@ impl ReasoningParser for DeepSeekR1ReasoningParser {
33 }
34
35 fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
32 - self.inner.initialize(prompt_token_ids);
33 - Ok(())
36 + self.inner.initialize(prompt_token_ids)
37 }
38
39 fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta> {
rust/src/parser/src/reasoning/deepseek_v3.rs new
+45
@@ -0,0 +1,45 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +use vllm_tokenizer::{DecodedText, DynTokenizer};
5 +
6 +use super::{
7 + DelimitedReasoningParser, DelimitedReasoningParserBuilder, ReasoningDelta, ReasoningParser,
8 + Result,
9 +};
10 +
11 +/// Reasoning parser for DeepSeek V3 style outputs.
12 +pub struct DeepSeekV3ReasoningParser {
13 + inner: DelimitedReasoningParser,
14 +}
15 +
16 +impl DeepSeekV3ReasoningParser {
17 + /// Create a DeepSeek V3 parser backed by the shared delimited state machine.
18 + pub fn new(tokenizer: DynTokenizer) -> Result<Self> {
19 + Ok(Self {
20 + inner: DelimitedReasoningParserBuilder::new(tokenizer, "<think>", "</think>")
21 + .build()?,
22 + })
23 + }
24 +}
25 +
26 +impl ReasoningParser for DeepSeekV3ReasoningParser {
27 + fn create(tokenizer: DynTokenizer) -> Result<Box<dyn ReasoningParser>>
28 + where
29 + Self: Sized + 'static,
30 + {
31 + Ok(Box::new(Self::new(tokenizer)?))
32 + }
33 +
34 + fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
35 + self.inner.initialize(prompt_token_ids)
36 + }
37 +
38 + fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta> {
39 + Ok(self.inner.push(delta))
40 + }
41 +
42 + fn finish(&mut self) -> Result<ReasoningDelta> {
43 + Ok(self.inner.finish())
44 + }
45 +}
rust/src/parser/src/reasoning/delimited.rs
+174 -99
@@ -4,6 +4,90 @@
4 use vllm_tokenizer::{DecodedText, DynTokenizer, Tokenizer};
5
6 use super::{ReasoningDelta, ReasoningError, Result};
7 +use crate::utils::partial_prefix_len;
8 +
9 +/// Build a delimited reasoning parser with fixed text at each boundary.
10 +pub(crate) struct DelimitedReasoningParserBuilder {
11 + tokenizer: DynTokenizer,
12 + start_token: String,
13 + end_token: String,
14 + before_start: &'static str,
15 + after_start: &'static str,
16 + before_end: &'static str,
17 + after_end: &'static str,
18 +}
19 +
20 +impl DelimitedReasoningParserBuilder {
21 + /// Configure the tokenizer and reasoning delimiters.
22 + pub(crate) fn new(
23 + tokenizer: DynTokenizer,
24 + start_token: impl Into<String>,
25 + end_token: impl Into<String>,
26 + ) -> Self {
27 + Self {
28 + tokenizer,
29 + start_token: start_token.into(),
30 + end_token: end_token.into(),
31 + before_start: "",
32 + after_start: "",
33 + before_end: "",
34 + after_end: "",
35 + }
36 + }
37 +
38 + /// Include a fixed prefix when it immediately precedes the start marker.
39 + pub(crate) fn with_before_start(mut self, before_start: &'static str) -> Self {
40 + self.before_start = before_start;
41 + self
42 + }
43 +
44 + /// Consume fixed text immediately after the start marker.
45 + pub(crate) fn with_after_start(mut self, after_start: &'static str) -> Self {
46 + self.after_start = after_start;
47 + self
48 + }
49 +
50 + /// Include a fixed prefix when it immediately precedes the end marker.
51 + pub(crate) fn with_before_end(mut self, before_end: &'static str) -> Self {
52 + self.before_end = before_end;
53 + self
54 + }
55 +
56 + /// Consume fixed text immediately after the end marker.
57 + pub(crate) fn with_after_end(mut self, after_end: &'static str) -> Self {
58 + self.after_end = after_end;
59 + self
60 + }
61 +
62 + /// Create one delimited parser state machine.
63 + pub(crate) fn build(self) -> Result<DelimitedReasoningParser> {
64 + let start_token_id = self.tokenizer.token_to_id(&self.start_token).ok_or_else(|| {
65 + ReasoningError::MissingToken {
66 + token: self.start_token.clone(),
67 + }
68 + })?;
69 + let end_token_id = self.tokenizer.token_to_id(&self.end_token).ok_or_else(|| {
70 + ReasoningError::MissingToken {
71 + token: self.end_token.clone(),
72 + }
73 + })?;
74 +
75 + Ok(DelimitedReasoningParser {
76 + tokenizer: self.tokenizer,
77 + framed_start_token: format!("{}{}", self.before_start, self.start_token),
78 + start_token: self.start_token,
79 + framed_end_token: format!("{}{}", self.before_end, self.end_token),
80 + end_token: self.end_token,
81 + start_token_id,
82 + end_token_id,
83 + after_start: self.after_start,
84 + after_end: self.after_end,
85 + current_in_reasoning: false,
86 + buffer: DecodedText::default(),
87 + pending_after: "",
88 + })
89 + }
90 +}
91
92 /// Shared incremental state machine for tag-delimited reasoning protocols.
93 ///
@@ -18,54 +102,45 @@ use super::{ReasoningDelta, ReasoningError, Result};
102 /// prefill different prompts.
103 pub(crate) struct DelimitedReasoningParser {
104 tokenizer: DynTokenizer,
21 - current_in_reasoning: bool,
22 - buffer: DecodedText,
105 start_token: String,
106 + framed_start_token: String,
107 end_token: String,
108 start_token_id: u32,
109 end_token_id: u32,
110 + after_start: &'static str,
111 + after_end: &'static str,
112 + framed_end_token: String,
113 +
114 + // Mutable state.
115 + current_in_reasoning: bool,
116 + buffer: DecodedText,
117 + pending_after: &'static str,
118 }
119
120 impl DelimitedReasoningParser {
30 - /// Create one delimited parser state machine.
31 - pub(crate) fn new(
32 - tokenizer: DynTokenizer,
33 - start_token: impl Into<String>,
34 - end_token: impl Into<String>,
35 - ) -> Result<Self> {
36 - let start_token = start_token.into();
37 - let end_token = end_token.into();
38 - let start_token_id =
39 - tokenizer
40 - .token_to_id(&start_token)
41 - .ok_or_else(|| ReasoningError::MissingToken {
42 - token: start_token.clone(),
43 - })?;
44 - let end_token_id =
45 - tokenizer.token_to_id(&end_token).ok_or_else(|| ReasoningError::MissingToken {
46 - token: end_token.clone(),
47 - })?;
48 -
49 - Ok(Self {
50 - tokenizer,
51 - current_in_reasoning: false,
52 - buffer: DecodedText::default(),
53 - start_token,
54 - end_token,
55 - start_token_id,
56 - end_token_id,
57 - })
58 - }
59 -
60 - /// Initialize the starting state from prompt token IDs.
61 - pub(crate) fn initialize(&mut self, prompt_token_ids: &[u32]) {
62 - self.current_in_reasoning = last_reasoning_boundary(
121 + /// Initialize the starting state and remaining framing from prompt tokens.
122 + pub(crate) fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
123 + self.buffer = DecodedText::default();
124 + self.current_in_reasoning = false;
125 + self.pending_after = "";
126 + if let Some((index, in_reasoning)) = last_reasoning_boundary_position(
127 prompt_token_ids,
128 self.start_token_id,
129 self.end_token_id,
130 self.tokenizer.as_ref(),
67 - )
68 - .unwrap_or(false);
131 + ) {
132 + self.current_in_reasoning = in_reasoning;
133 + self.pending_after = if in_reasoning {
134 + self.after_start
135 + } else {
136 + self.after_end
137 + };
138 + if !self.pending_after.is_empty() && index + 1 < prompt_token_ids.len() {
139 + let tail = self.tokenizer.decode(&prompt_token_ids[index + 1..], false)?;
140 + self.pending_after = self.pending_after.strip_prefix(&tail).unwrap_or("");
141 + }
142 + }
143 + Ok(())
144 }
145
146 /// Return whether the parser is currently inside a reasoning section.
@@ -76,84 +151,74 @@ impl DelimitedReasoningParser {
151 /// Parse one decoded text delta and return its reasoning/content split.
152 pub(crate) fn push(&mut self, delta: DecodedText) -> ReasoningDelta {
153 self.buffer.append(delta);
79 -
80 - let partial_suffix_len = self.partial_suffix_len(&self.buffer.text);
81 - let stable_len = self.buffer.text.len() - partial_suffix_len;
82 - let stable = self.buffer.drain_prefix(stable_len);
83 -
84 - self.parse_stable_text(stable)
154 + self.parse_buffer(false)
155 }
156
87 - /// Flush any buffered partial delimiter suffix at end of stream.
157 + /// Flush partial framing and delimiters as body text at end of stream.
158 pub(crate) fn finish(&mut self) -> ReasoningDelta {
89 - // `drain_prefix(text.len())` takes trailing zero-width tokens too.
90 - let stable = self.buffer.drain_prefix(self.buffer.text.len());
91 - self.parse_stable_text(stable)
159 + self.parse_buffer(true)
160 }
161
94 - /// Parse text that is known not to end with a partial delimiter suffix.
95 - ///
162 /// Reasoning and content pieces keep the attributions of the tokens that
163 /// produced them; delimiter marker spans are drained and dropped, keeping
164 /// marker tokens out of any count.
99 - fn parse_stable_text(&mut self, mut stable: DecodedText) -> ReasoningDelta {
165 + fn parse_buffer(&mut self, finishing: bool) -> ReasoningDelta {
166 let mut delta = ReasoningDelta::default();
101 -
102 - while !stable.text.is_empty() {
103 - if self.current_in_reasoning {
104 - if let Some(end_idx) = stable.text.find(&self.end_token) {
105 - delta.push_reasoning(stable.drain_prefix(end_idx));
106 - let _ = stable.drain_prefix(self.end_token.len());
107 - self.current_in_reasoning = false;
108 - } else {
109 - delta.push_reasoning(stable);
110 - return delta;
167 + loop {
168 + if !self.pending_after.is_empty() {
169 + if self.buffer.text.starts_with(self.pending_after) {
170 + let _ = self.buffer.drain_prefix(self.pending_after.len());
171 + } else if !finishing && self.pending_after.starts_with(&self.buffer.text) {
172 + break;
173 }
112 - } else if let Some(start_idx) = stable.text.find(&self.start_token) {
113 - delta.push_content(stable.drain_prefix(start_idx));
114 - let _ = stable.drain_prefix(self.start_token.len());
115 - self.current_in_reasoning = true;
116 - } else {
117 - delta.push_content(stable);
118 - return delta;
174 + // A mismatch preserves the candidate as body text.
175 + self.pending_after = "";
176 }
120 - }
177
122 - // A remainder with empty text may still carry zero-width tokens;
123 - // attribute them to the current state.
124 - if !stable.attributions.is_empty() {
125 - if self.current_in_reasoning {
126 - delta.push_reasoning(stable);
178 + let (marker, framed_marker) = if self.current_in_reasoning {
179 + (&self.end_token, &self.framed_end_token)
180 } else {
128 - delta.push_content(stable);
181 + (&self.start_token, &self.framed_start_token)
182 + };
183 + if let Some(index) = self.buffer.text.find(marker) {
184 + let before = &framed_marker[..framed_marker.len() - marker.len()];
185 + let body_len = if self.buffer.text[..index].ends_with(before) {
186 + index - before.len()
187 + } else {
188 + index
189 + };
190 + let boundary_len = index + marker.len() - body_len;
191 + let body = self.buffer.drain_prefix(body_len);
192 + self.push_body(&mut delta, body);
193 + let _ = self.buffer.drain_prefix(boundary_len);
194 + self.current_in_reasoning = !self.current_in_reasoning;
195 + self.pending_after = if self.current_in_reasoning {
196 + self.after_start
197 + } else {
198 + self.after_end
199 + };
200 + continue;
201 }
130 - }
202
203 + let keep_len = if finishing {
204 + 0
205 + } else {
206 + partial_prefix_len(&self.buffer.text, marker)
207 + .max(partial_prefix_len(&self.buffer.text, framed_marker))
208 + };
209 + let body = self.buffer.drain_prefix(self.buffer.text.len() - keep_len);
210 + self.push_body(&mut delta, body);
211 + break;
212 + }
213 delta
214 }
215
135 - /// Return the longest trailing suffix that could still complete a
136 - /// delimiter.
137 - fn partial_suffix_len(&self, text: &str) -> usize {
138 - let mut best = 0;
139 - for idx in text.char_indices().map(|(idx, _)| idx).skip(1) {
140 - let suffix = &text[idx..];
141 - if self.start_token.starts_with(suffix) && self.start_token != suffix {
142 - best = best.max(text.len() - idx);
143 - }
144 - if self.end_token.starts_with(suffix) && self.end_token != suffix {
145 - best = best.max(text.len() - idx);
146 - }
216 + fn push_body(&self, delta: &mut ReasoningDelta, body: DecodedText) {
217 + if self.current_in_reasoning {
218 + delta.push_reasoning(body);
219 + } else {
220 + delta.push_content(body);
221 }
148 -
149 - if self.start_token.starts_with(text) && self.start_token != text {
150 - best = best.max(text.len());
151 - }
152 - if self.end_token.starts_with(text) && self.end_token != text {
153 - best = best.max(text.len());
154 - }
155 -
156 - best
222 }
223 }
224
@@ -164,12 +229,22 @@ pub(crate) fn last_reasoning_boundary(
229 end_token_id: u32,
230 tokenizer: &dyn Tokenizer,
231 ) -> Option<bool> {
167 - for token_id in prompt_token_ids.iter().rev().copied() {
232 + last_reasoning_boundary_position(prompt_token_ids, start_token_id, end_token_id, tokenizer)
233 + .map(|(_, in_reasoning)| in_reasoning)
234 +}
235 +
236 +fn last_reasoning_boundary_position(
237 + prompt_token_ids: &[u32],
238 + start_token_id: u32,
239 + end_token_id: u32,
240 + tokenizer: &dyn Tokenizer,
241 +) -> Option<(usize, bool)> {
242 + for (index, &token_id) in prompt_token_ids.iter().enumerate().rev() {
243 if token_id == start_token_id {
169 - return Some(true);
244 + return Some((index, true));
245 }
246 if token_id == end_token_id {
172 - return Some(false);
247 + return Some((index, false));
248 }
249 if tokenizer.is_special_id(token_id) {
250 return None;
rust/src/parser/src/reasoning/glm45.rs new
+48
@@ -0,0 +1,48 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +use vllm_tokenizer::{DecodedText, DynTokenizer};
5 +
6 +use super::{
7 + DelimitedReasoningParser, DelimitedReasoningParserBuilder, ReasoningDelta, ReasoningParser,
8 + Result,
9 +};
10 +
11 +/// Reasoning parser for GLM-4.5/4.6 models using `<think>`/`</think>`
12 +/// delimiters.
13 +pub struct Glm45ReasoningParser {
14 + inner: DelimitedReasoningParser,
15 +}
16 +
17 +impl Glm45ReasoningParser {
18 + /// Create a GLM-4.5/4.6 parser backed by the shared delimited state machine.
19 + pub fn new(tokenizer: DynTokenizer) -> Result<Self> {
20 + Ok(Self {
21 + inner: DelimitedReasoningParserBuilder::new(tokenizer, "<think>", "</think>")
22 + .with_before_start("\n")
23 + .with_after_end("\n")
24 + .build()?,
25 + })
26 + }
27 +}
28 +
29 +impl ReasoningParser for Glm45ReasoningParser {
30 + fn create(tokenizer: DynTokenizer) -> Result<Box<dyn ReasoningParser>>
31 + where
32 + Self: Sized + 'static,
33 + {
34 + Ok(Box::new(Self::new(tokenizer)?))
35 + }
36 +
37 + fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
38 + self.inner.initialize(prompt_token_ids)
39 + }
40 +
41 + fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta> {
42 + Ok(self.inner.push(delta))
43 + }
44 +
45 + fn finish(&mut self) -> Result<ReasoningDelta> {
46 + Ok(self.inner.finish())
47 + }
48 +}
rust/src/parser/src/reasoning/hy.rs
+8 -5
@@ -3,7 +3,10 @@
3
4 use vllm_tokenizer::{DecodedText, DynTokenizer};
5
6 -use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningError, ReasoningParser, Result};
6 +use super::{
7 + DelimitedReasoningParser, DelimitedReasoningParserBuilder, ReasoningDelta, ReasoningError,
8 + ReasoningParser, Result,
9 +};
10
11 /// Internal HY reasoning stage used by the unified HY parsers.
12 pub(crate) struct HyReasoningParser {
@@ -14,11 +17,12 @@ impl HyReasoningParser {
17 /// Create a HY reasoning parser for the tokenizer-specific marker suffix.
18 pub(crate) fn new(tokenizer: DynTokenizer, suffix: &str) -> Result<Self> {
19 Ok(Self {
17 - inner: DelimitedReasoningParser::new(
20 + inner: DelimitedReasoningParserBuilder::new(
21 tokenizer,
22 format!("<think{suffix}>"),
23 format!("</think{suffix}>"),
21 - )?,
24 + )
25 + .build()?,
26 })
27 }
28 }
@@ -36,8 +40,7 @@ impl ReasoningParser for HyReasoningParser {
40 }
41
42 fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
39 - self.inner.initialize(prompt_token_ids);
40 - Ok(())
43 + self.inner.initialize(prompt_token_ids)
44 }
45
46 fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta> {
rust/src/parser/src/reasoning/kimi.rs
+7 -4
@@ -3,7 +3,10 @@
3
4 use vllm_tokenizer::{DecodedText, DynTokenizer};
5
6 -use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result};
6 +use super::{
7 + DelimitedReasoningParser, DelimitedReasoningParserBuilder, ReasoningDelta, ReasoningParser,
8 + Result,
9 +};
10
11 /// Reasoning parser for legacy Kimi models that use Unicode thinking tags.
12 pub struct KimiReasoningParser {
@@ -14,7 +17,8 @@ impl KimiReasoningParser {
17 /// Create a Kimi parser backed by the shared delimited state machine.
18 pub fn new(tokenizer: DynTokenizer) -> Result<Self> {
19 Ok(Self {
17 - inner: DelimitedReasoningParser::new(tokenizer, "◁think▷", "◁/think▷")?,
20 + inner: DelimitedReasoningParserBuilder::new(tokenizer, "◁think▷", "◁/think▷")
21 + .build()?,
22 })
23 }
24 }
@@ -28,8 +32,7 @@ impl ReasoningParser for KimiReasoningParser {
32 }
33
34 fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
31 - self.inner.initialize(prompt_token_ids);
32 - Ok(())
35 + self.inner.initialize(prompt_token_ids)
36 }
37
38 fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta> {
rust/src/parser/src/reasoning/minimax_m3.rs
+7 -3
@@ -3,7 +3,10 @@
3
4 use vllm_tokenizer::{DecodedText, DynTokenizer};
5
6 -use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result};
6 +use super::{
7 + DelimitedReasoningParser, DelimitedReasoningParserBuilder, ReasoningDelta, ReasoningParser,
8 + Result,
9 +};
10
11 const M3_THINK_START: &str = "<mm:think>";
12 const M3_THINK_END: &str = "</mm:think>";
@@ -27,7 +30,8 @@ impl MiniMaxM3ReasoningParser {
30 /// Create a MiniMax M3 parser backed by the shared delimited state machine.
31 pub fn new(tokenizer: DynTokenizer) -> Result<Self> {
32 Ok(Self {
30 - inner: DelimitedReasoningParser::new(tokenizer, M3_THINK_START, M3_THINK_END)?,
33 + inner: DelimitedReasoningParserBuilder::new(tokenizer, M3_THINK_START, M3_THINK_END)
34 + .build()?,
35 at_response_start: true,
36 leading_end_buffer: DecodedText::default(),
37 })
@@ -77,7 +81,7 @@ impl ReasoningParser for MiniMaxM3ReasoningParser {
81 }
82
83 fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
80 - self.inner.initialize(prompt_token_ids);
84 + self.inner.initialize(prompt_token_ids)?;
85 self.at_response_start = true;
86 self.leading_end_buffer.clear();
87 Ok(())
rust/src/parser/src/reasoning/mod.rs
+21 -14
@@ -19,7 +19,9 @@
19
20 mod cohere_cmd;
21 mod deepseek_r1;
22 +mod deepseek_v3;
23 mod delimited;
24 +mod glm45;
25 mod hy;
26 mod kimi;
27 mod minimax_m3;
@@ -32,7 +34,11 @@ use vllm_tokenizer::{DecodedText, DynTokenizer};
34
35 pub use self::cohere_cmd::CohereCmdReasoningParser;
36 pub use self::deepseek_r1::DeepSeekR1ReasoningParser;
35 -pub(crate) use self::delimited::{DelimitedReasoningParser, last_reasoning_boundary};
37 +pub use self::deepseek_v3::DeepSeekV3ReasoningParser;
38 +pub(crate) use self::delimited::{
39 + DelimitedReasoningParser, DelimitedReasoningParserBuilder, last_reasoning_boundary,
40 +};
41 +pub use self::glm45::Glm45ReasoningParser;
42 pub(crate) use self::hy::HyReasoningParser;
43 pub use self::kimi::KimiReasoningParser;
44 pub use self::minimax_m3::MiniMaxM3ReasoningParser;
@@ -40,22 +46,21 @@ pub use self::qwen3::Qwen3ReasoningParser;
46 pub use self::seed_oss::SeedOssReasoningParser;
47 pub use self::step3p5::Step3p5ReasoningParser;
48
43 -/// DeepSeek V3 currently shares the standard `<think>...</think>` parser.
44 -pub type DeepSeekV3ReasoningParser = Qwen3ReasoningParser;
45 -/// DeepSeek V4 currently shares the standard `<think>...</think>` parser.
46 -pub type DeepSeekV4ReasoningParser = Qwen3ReasoningParser;
47 -/// GLM45 currently shares the standard `<think>...</think>` parser.
48 -pub type Glm45ReasoningParser = Qwen3ReasoningParser;
49 -/// Kimi K2 currently shares the standard `<think>...</think>` parser.
49 +/// Nemotron V3 and Step3p5 frame all three reasoning boundaries with one newline.
50 +pub type NemotronV3ReasoningParser = Step3p5ReasoningParser;
51 +/// Step3 and DeepSeek R1 prefill a newline after the reasoning opener.
52 +pub type Step3ReasoningParser = DeepSeekR1ReasoningParser;
53 +
54 +/// DeepSeek V4 currently shares the DeepSeek V3 reasoning parser.
55 +pub type DeepSeekV4ReasoningParser = DeepSeekV3ReasoningParser;
56 +/// GLM-4.7 and GLM-5 use bare reasoning delimiters.
57 +pub type Glm47ReasoningParser = DeepSeekV3ReasoningParser;
58 +/// Kimi K2 currently shares the DeepSeek V3 reasoning parser.
59 // TODO: kimi k2 may implicitly end reasoning by starting a tool call section
60 // using <|tool_calls_section_begin|>, we should support that.
52 -pub type KimiK2ReasoningParser = Qwen3ReasoningParser;
53 -/// MiniMax M2 currently shares the standard `<think>...</think>` parser.
61 +pub type KimiK2ReasoningParser = DeepSeekV3ReasoningParser;
62 +/// MiniMax M2 currently shares the Qwen3 reasoning parser.
63 pub type MiniMaxM2ReasoningParser = Qwen3ReasoningParser;
55 -/// Nemotron V3 currently shares the standard `<think>...</think>` parser.
56 -pub type NemotronV3ReasoningParser = Qwen3ReasoningParser;
57 -/// Step3 currently shares the standard `<think>...</think>` parser.
58 -pub type Step3ReasoningParser = Qwen3ReasoningParser;
64
65 /// Result alias for reasoning parser operations.
66 pub type Result<T> = std::result::Result<T, ReasoningError>;
@@ -140,6 +145,8 @@ pub trait ReasoningParser: Send {
145 /// Errors produced while creating or running reasoning parsers.
146 #[derive(Debug, Error)]
147 pub enum ReasoningError {
148 + #[error(transparent)]
149 + Tokenizer(#[from] vllm_tokenizer::TokenizerError),
150 #[error("tokenizer is missing reasoning delimiter token `{token}`")]
151 MissingToken { token: String },
152 #[error(
rust/src/parser/src/reasoning/qwen3.rs
+11 -7
@@ -3,13 +3,14 @@
3
4 use vllm_tokenizer::{DecodedText, DynTokenizer};
5
6 -use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result};
6 +use super::{
7 + DelimitedReasoningParser, DelimitedReasoningParserBuilder, ReasoningDelta, ReasoningParser,
8 + Result,
9 +};
10
11 /// Reasoning parser for the Qwen3/Qwen3.5 family.
12 ///
10 -/// This parser uses standard `<think>...</think>` delimiters and defaults to
11 -/// waiting for an explicit start token when prompt initialization finds no
12 -/// reasoning boundary.
13 +/// This parser uses standard `<think>...</think>` delimiters.
14 pub struct Qwen3ReasoningParser {
15 inner: DelimitedReasoningParser,
16 }
@@ -18,7 +19,11 @@ impl Qwen3ReasoningParser {
19 /// Create a Qwen3 parser backed by the shared delimited state machine.
20 pub fn new(tokenizer: DynTokenizer) -> Result<Self> {
21 Ok(Self {
21 - inner: DelimitedReasoningParser::new(tokenizer, "<think>", "</think>")?,
22 + inner: DelimitedReasoningParserBuilder::new(tokenizer, "<think>", "</think>")
23 + .with_after_start("\n")
24 + .with_before_end("\n")
25 + .with_after_end("\n\n")
26 + .build()?,
27 })
28 }
29 }
@@ -32,8 +37,7 @@ impl ReasoningParser for Qwen3ReasoningParser {
37 }
38
39 fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
35 - self.inner.initialize(prompt_token_ids);
36 - Ok(())
40 + self.inner.initialize(prompt_token_ids)
41 }
42
43 fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta> {
rust/src/parser/src/reasoning/seed_oss.rs
+8 -4
@@ -3,7 +3,10 @@
3
4 use vllm_tokenizer::{DecodedText, DynTokenizer};
5
6 -use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result};
6 +use super::{
7 + DelimitedReasoningParser, DelimitedReasoningParserBuilder, ReasoningDelta, ReasoningParser,
8 + Result,
9 +};
10
11 /// Reasoning parser for SeedOSS models using `<seed:think>`/`</seed:think>`
12 /// delimiters.
@@ -15,7 +18,9 @@ impl SeedOssReasoningParser {
18 /// Create a SeedOSS parser backed by the shared delimited state machine.
19 pub fn new(tokenizer: DynTokenizer) -> Result<Self> {
20 Ok(Self {
18 - inner: DelimitedReasoningParser::new(tokenizer, "<seed:think>", "</seed:think>")?,
21 + inner: DelimitedReasoningParserBuilder::new(tokenizer, "<seed:think>", "</seed:think>")
22 + .with_after_end("\n")
23 + .build()?,
24 })
25 }
26 }
@@ -29,8 +34,7 @@ impl ReasoningParser for SeedOssReasoningParser {
34 }
35
36 fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
32 - self.inner.initialize(prompt_token_ids);
33 - Ok(())
37 + self.inner.initialize(prompt_token_ids)
38 }
39
40 fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta> {
rust/src/parser/src/reasoning/step3p5.rs
+15 -94
@@ -3,89 +3,27 @@
3
4 use vllm_tokenizer::{DecodedText, DynTokenizer};
5
6 -use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result};
7 -
8 -/// Reasoning parser for Step3p5 outputs.
9 -///
10 -/// Step3p5 uses standard `<think>`/`</think>` delimiters but emits a `\n`
11 -/// immediately before and/or after `</think>`. The parser drops these framing
12 -/// newlines on both sides of the boundary, holding a trailing `\n` from
13 -/// reasoning across pushes until either more reasoning text or `</think>`
14 -/// arrives, and dropping a leading `\n` from the first content delta after
15 -/// the boundary.
6 +use super::{
7 + DelimitedReasoningParser, DelimitedReasoningParserBuilder, ReasoningDelta, ReasoningParser,
8 + Result,
9 +};
10 +
11 +/// Reasoning parser for Step3p5 style outputs.
12 pub struct Step3p5ReasoningParser {
13 inner: DelimitedReasoningParser,
18 - /// `\n` at end of last reasoning delta, held in case `</think>` follows.
19 - pending_reasoning_newline: Option<DecodedText>,
20 - /// Last push ended on `</think>` without emitting content; the next
21 - /// content delta's leading `\n` should be dropped.
22 - just_ended_reasoning: bool,
14 }
15
16 impl Step3p5ReasoningParser {
17 /// Create a Step3p5 parser backed by the shared delimited state machine.
18 pub fn new(tokenizer: DynTokenizer) -> Result<Self> {
19 Ok(Self {
29 - inner: DelimitedReasoningParser::new(tokenizer, "<think>", "</think>")?,
30 - pending_reasoning_newline: None,
31 - just_ended_reasoning: false,
20 + inner: DelimitedReasoningParserBuilder::new(tokenizer, "<think>", "</think>")
21 + .with_after_start("\n")
22 + .with_before_end("\n")
23 + .with_after_end("\n")
24 + .build()?,
25 })
26 }
34 -
35 - /// Drop framing newlines around `</think>` and track held-newline state.
36 - fn process(
37 - &mut self,
38 - mut inner_delta: ReasoningDelta,
39 - was_in_reasoning: bool,
40 - now_in_reasoning: bool,
41 - ) -> ReasoningDelta {
42 - // A `<think>...</think>` round-trip in one push still counts as a
43 - // transition: the inner emits reasoning while ending in content mode.
44 - let transitioned =
45 - !now_in_reasoning && (was_in_reasoning || inner_delta.reasoning.is_some());
46 -
47 - // Replay or drop a previously-held trailing reasoning newline.
48 - if self.pending_reasoning_newline.is_some() {
49 - if let Some(reasoning) = inner_delta.reasoning.as_mut() {
50 - let mut held = self.pending_reasoning_newline.take().unwrap();
51 - held.append(std::mem::take(reasoning));
52 - *reasoning = held;
53 - } else if transitioned {
54 - // The held `\n` was the one right before `</think>`: drop it.
55 - self.pending_reasoning_newline = None;
56 - }
57 - }
58 -
59 - // Hold back a trailing reasoning `\n` until we know if `</think>` follows.
60 - if let Some(reasoning) = inner_delta.reasoning.as_mut()
61 - && reasoning.text.ends_with('\n')
62 - {
63 - let kept = reasoning.drain_prefix(reasoning.text.len() - 1);
64 - let newline = std::mem::replace(reasoning, kept);
65 - if !transitioned {
66 - self.pending_reasoning_newline = Some(newline);
67 - }
68 - }
69 -
70 - // Drop a leading `\n` of content emitted right after `</think>`.
71 - if let Some(content) = inner_delta.content.as_mut()
72 - && (transitioned || self.just_ended_reasoning)
73 - && content.text.starts_with('\n')
74 - {
75 - let _ = content.drain_prefix(1);
76 - }
77 -
78 - self.just_ended_reasoning = transitioned && inner_delta.content.is_none();
79 -
80 - if inner_delta.reasoning.as_ref().is_some_and(DecodedText::is_empty) {
81 - inner_delta.reasoning = None;
82 - }
83 - if inner_delta.content.as_ref().is_some_and(DecodedText::is_empty) {
84 - inner_delta.content = None;
85 - }
86 -
87 - inner_delta
88 - }
27 }
28
29 impl ReasoningParser for Step3p5ReasoningParser {
@@ -97,32 +35,15 @@ impl ReasoningParser for Step3p5ReasoningParser {
35 }
36
37 fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
100 - self.inner.initialize(prompt_token_ids);
101 - Ok(())
38 + self.inner.initialize(prompt_token_ids)
39 }
40
41 fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta> {
105 - let was = self.inner.in_reasoning();
106 - let inner_delta = self.inner.push(delta);
107 - let now = self.inner.in_reasoning();
108 - Ok(self.process(inner_delta, was, now))
42 + Ok(self.inner.push(delta))
43 }
44
45 fn finish(&mut self) -> Result<ReasoningDelta> {
112 - let was = self.inner.in_reasoning();
113 - let inner_delta = self.inner.finish();
114 - let now = self.inner.in_reasoning();
115 - let mut delta = self.process(inner_delta, was, now);
116 -
117 - // Emit a still-held newline rather than silently dropping it.
118 - if let Some(held) = self.pending_reasoning_newline.take() {
119 - match delta.reasoning.as_mut() {
120 - Some(existing) => existing.append(held),
121 - None => delta.reasoning = Some(held),
122 - }
123 - }
124 -
125 - Ok(delta)
46 + Ok(self.inner.finish())
47 }
48 }
49
@@ -181,7 +102,7 @@ mod tests {
102 // `</think>`; surrounding newlines remain part of reasoning/content.
103 let tokenizer = Arc::new(fake_tokenizer());
104 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
184 - parser.initialize(&[THINK_START_ID]).unwrap();
105 + parser.initialize(&[THINK_START_ID, u32::from(b'\n')]).unwrap();
106
107 let delta = push_str(
108 &mut parser,
rust/src/parser/src/reasoning/tests.rs
+190 -10
@@ -8,8 +8,9 @@ use vllm_tokenizer::{DecodedText, DynTokenizer, TokenAnchor, TokenAttribution};
8
9 use super::{
10 CohereCmdReasoningParser, DeepSeekR1ReasoningParser, DelimitedReasoningParser,
11 - KimiReasoningParser, MiniMaxM3ReasoningParser, Qwen3ReasoningParser, ReasoningDelta,
12 - ReasoningParser, Result, SeedOssReasoningParser, Step3p5ReasoningParser,
11 + DelimitedReasoningParserBuilder, KimiReasoningParser, MiniMaxM3ReasoningParser,
12 + Qwen3ReasoningParser, ReasoningDelta, ReasoningParser, Result, SeedOssReasoningParser,
13 + Step3p5ReasoningParser,
14 };
15
16 pub(crate) const THINK_START_ID: u32 = 256;
@@ -57,7 +58,9 @@ pub(crate) fn content_str(delta: &ReasoningDelta) -> Option<&str> {
58 #[test]
59 fn delimited_content_only_stream() {
60 let tokenizer = Arc::new(fake_tokenizer());
60 - let mut parser = DelimitedReasoningParser::new(tokenizer, "<think>", "</think>").unwrap();
61 + let mut parser = DelimitedReasoningParserBuilder::new(tokenizer, "<think>", "</think>")
62 + .build()
63 + .unwrap();
64
65 let delta = parser.push(DecodedText::unattributed("plain content"));
66 assert_eq!(content_str(&delta), Some("plain content"));
@@ -66,7 +69,9 @@ fn delimited_content_only_stream() {
69 #[test]
70 fn delimited_single_chunk_with_reasoning_and_content() {
71 let tokenizer = Arc::new(fake_tokenizer());
69 - let mut parser = DelimitedReasoningParser::new(tokenizer, "<think>", "</think>").unwrap();
72 + let mut parser = DelimitedReasoningParserBuilder::new(tokenizer, "<think>", "</think>")
73 + .build()
74 + .unwrap();
75
76 let delta = parser.push(DecodedText::unattributed("<think>reason</think>answer"));
77 assert_eq!(reasoning_str(&delta), Some("reason"));
@@ -76,7 +81,9 @@ fn delimited_single_chunk_with_reasoning_and_content() {
81 #[test]
82 fn delimited_partial_tokens_across_chunks() {
83 let tokenizer = Arc::new(fake_tokenizer());
79 - let mut parser = DelimitedReasoningParser::new(tokenizer, "<think>", "</think>").unwrap();
84 + let mut parser = DelimitedReasoningParserBuilder::new(tokenizer, "<think>", "</think>")
85 + .build()
86 + .unwrap();
87
88 assert!(parser.push(DecodedText::unattributed("<thi")).is_empty());
89 let delta = parser.push(DecodedText::unattributed("nk>reason</think>answer"));
@@ -87,8 +94,10 @@ fn delimited_partial_tokens_across_chunks() {
94 #[test]
95 fn delimited_finish_flushes_buffer() {
96 let tokenizer = Arc::new(fake_tokenizer());
90 - let mut parser = DelimitedReasoningParser::new(tokenizer, "<think>", "</think>").unwrap();
91 - parser.initialize(&[THINK_START_ID]);
97 + let mut parser = DelimitedReasoningParserBuilder::new(tokenizer, "<think>", "</think>")
98 + .build()
99 + .unwrap();
100 + parser.initialize(&[THINK_START_ID]).unwrap();
101
102 let delta = parser.push(DecodedText::unattributed("unfinished</thi"));
103 assert_eq!(reasoning_str(&delta), Some("unfinished"));
@@ -99,8 +108,10 @@ fn delimited_finish_flushes_buffer() {
108 #[test]
109 fn delimited_zero_width_only_piece_is_attributed_to_current_state() {
110 let tokenizer = Arc::new(fake_tokenizer());
102 - let mut parser = DelimitedReasoningParser::new(tokenizer, "<think>", "</think>").unwrap();
103 - parser.initialize(&[THINK_START_ID]);
111 + let mut parser = DelimitedReasoningParserBuilder::new(tokenizer, "<think>", "</think>")
112 + .build()
113 + .unwrap();
114 + parser.initialize(&[THINK_START_ID]).unwrap();
115
116 // A filtered special token produces a zero-width attribution with no text;
117 // it must survive as a reasoning piece rather than being dropped.
@@ -130,7 +141,9 @@ fn delimited_zero_width_only_piece_is_attributed_to_current_state() {
141 #[test]
142 fn delimited_marker_tokens_are_dropped_from_attributions() {
143 let tokenizer = Arc::new(fake_tokenizer());
133 - let mut parser = DelimitedReasoningParser::new(tokenizer, "<think>", "</think>").unwrap();
144 + let mut parser = DelimitedReasoningParserBuilder::new(tokenizer, "<think>", "</think>")
145 + .build()
146 + .unwrap();
147
148 let mut collected = CollectedAttributions::default();
149 for chunk in attributed_chunks(&[
@@ -283,3 +296,170 @@ pub(crate) fn collect_attributed(
296 collected.record(parser.finish().unwrap());
297 collected
298 }
299 +
300 +fn framed_parser() -> DelimitedReasoningParser {
301 + DelimitedReasoningParserBuilder::new(Arc::new(fake_tokenizer()), "<think>", "</think>")
302 + .with_after_start("\n")
303 + .with_before_end("\n")
304 + .with_after_end("\n\n")
305 + .build()
306 + .unwrap()
307 +}
308 +
309 +fn collect_framed(parser: &mut DelimitedReasoningParser, chunks: &[&str]) -> (String, String) {
310 + let mut result = (String::new(), String::new());
311 + for chunk in chunks.iter().copied().map(Some).chain(std::iter::once(None)) {
312 + let delta = match chunk {
313 + Some(chunk) => parser.push(DecodedText::unattributed(chunk)),
314 + None => parser.finish(),
315 + };
316 + if let Some(reasoning) = delta.reasoning {
317 + result.0.push_str(&reasoning.text);
318 + }
319 + if let Some(content) = delta.content {
320 + result.1.push_str(&content.text);
321 + }
322 + }
323 + result
324 +}
325 +
326 +#[test]
327 +fn delimited_framing_preserves_body_whitespace_at_every_split() {
328 + let wire = "<think>\n\n reason\t\n\n</think>\n\n\n answer\n";
329 + let expected = ("\n reason\t\n".to_string(), "\n answer\n".to_string());
330 + for split in 0..=wire.len() {
331 + let mut parser = framed_parser();
332 + assert_eq!(
333 + collect_framed(&mut parser, &[&wire[..split], &wire[split..]]),
334 + expected,
335 + "split at {split}"
336 + );
337 + }
338 + let chars = wire
339 + .as_bytes()
340 + .chunks(1)
341 + .map(|c| std::str::from_utf8(c).unwrap())
342 + .collect::<Vec<_>>();
343 + assert_eq!(collect_framed(&mut framed_parser(), &chars), expected);
344 +}
345 +
346 +#[test]
347 +fn delimited_partial_framing_is_preserved_on_mismatch_or_finish() {
348 + for (wire, reasoning, content) in [
349 + ("<think>reason</think>\nanswer", "reason", "\nanswer"),
350 + ("<think>\nreason\nmore\n", "reason\nmore\n", ""),
351 + ("<think>\nreason\n</thi", "reason\n</thi", ""),
352 + ("<think>\nreason\n</think>\n", "reason", "\n"),
353 + (" plain\n", "", " plain\n"),
354 + ] {
355 + for split in 0..=wire.len() {
356 + assert_eq!(
357 + collect_framed(&mut framed_parser(), &[&wire[..split], &wire[split..]]),
358 + (reasoning.to_string(), content.to_string()),
359 + "wire {wire:?}, split {split}"
360 + );
361 + }
362 + }
363 +}
364 +
365 +#[test]
366 +fn delimited_prompt_initialization_consumes_only_remaining_framing() {
367 + use vllm_tokenizer::Tokenizer;
368 + let tokenizer = fake_tokenizer();
369 + for (prompt, wire, reasoning, content) in [
370 + (
371 + "<think>",
372 + "\nreason\n</think>\n\nanswer",
373 + "reason",
374 + "answer",
375 + ),
376 + (
377 + "<think>\n",
378 + "\nreason\n</think>\n\nanswer",
379 + "\nreason",
380 + "answer",
381 + ),
382 + (
383 + "<think>\nprefill",
384 + "\nreason\n</think>\n\nanswer",
385 + "\nreason",
386 + "answer",
387 + ),
388 + ("</think>", "\n\n\nanswer", "", "\nanswer"),
389 + ("</think>\n", "\n\nanswer", "", "\nanswer"),
390 + ("</think>\n\n", "\nanswer", "", "\nanswer"),
391 + ("</think>\n\nprefill", "\nanswer", "", "\nanswer"),
392 + ] {
393 + let mut parser = framed_parser();
394 + parser.initialize(&tokenizer.encode(prompt, false).unwrap()).unwrap();
395 + assert_eq!(
396 + collect_framed(&mut parser, &[wire]),
397 + (reasoning.to_string(), content.to_string()),
398 + "prompt {prompt:?}"
399 + );
400 + }
401 +}
402 +
403 +#[test]
404 +fn delimited_framing_holds_only_boundary_candidates() {
405 + let mut parser = framed_parser();
406 + let first = parser.push(DecodedText::unattributed("<think>\nreason\n"));
407 + assert_eq!(reasoning_str(&first), Some("reason"));
408 + let more = parser.push(DecodedText::unattributed("more\n"));
409 + assert_eq!(reasoning_str(&more), Some("\nmore"));
410 + assert!(parser.push(DecodedText::unattributed("</think>\n")).is_empty());
411 + let answer = parser.push(DecodedText::unattributed("\n answer"));
412 + assert_eq!(content_str(&answer), Some(" answer"));
413 +}
414 +
415 +#[test]
416 +fn delimited_start_prefix_is_consumed_only_before_the_marker() {
417 + for wire in [
418 + "\n<think>reason</think>answer",
419 + "\n\n<think>reason</think>answer",
420 + ] {
421 + for split in 0..=wire.len() {
422 + let mut parser = DelimitedReasoningParserBuilder::new(
423 + Arc::new(fake_tokenizer()),
424 + "<think>",
425 + "</think>",
426 + )
427 + .with_before_start("\n")
428 + .build()
429 + .unwrap();
430 + let expected_content = if wire.starts_with("\n\n") {
431 + "\nanswer"
432 + } else {
433 + "answer"
434 + };
435 + assert_eq!(
436 + collect_framed(&mut parser, &[&wire[..split], &wire[split..]]),
437 + ("reason".to_string(), expected_content.to_string())
438 + );
439 + }
440 + }
441 +}
442 +
443 +#[test]
444 +fn delimited_framing_preserves_body_token_attributions() {
445 + let mut parser = framed_parser();
446 + let mut collected = CollectedAttributions::default();
447 + for chunk in attributed_chunks(&[
448 + Some("<think>"),
449 + Some("\n"),
450 + Some("reason"),
451 + None,
452 + Some("\n"),
453 + Some("</think>"),
454 + Some("\n"),
455 + Some("\n"),
456 + Some(" answer"),
457 + ]) {
458 + collected.record(parser.push(chunk));
459 + }
460 + collected.record(parser.finish());
461 + assert_eq!(collected.reasoning_text, "reason");
462 + assert_eq!(collected.content_text, " answer");
463 + assert_eq!(collected.reasoning_ids, [3, 4]);
464 + assert_eq!(collected.content_ids, [9]);
465 +}
rust/src/parser/src/tool/deepseek_dsml/deepseek_v32.rs
+9
@@ -71,6 +71,7 @@ mod tests {
71
72 use super::DeepSeekV32ToolParser;
73 use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools};
74 + use crate::tool::tests::assert_tool_framing_preserves_body_whitespace;
75 use crate::tool::{ToolParser, ToolParserTestExt as _};
76
77 fn build_tool_call(function_name: &str, params: &[(&str, &str)]) -> String {
@@ -445,4 +446,12 @@ mod tests {
446 assert_eq!(streamed.normal_text(), complete.normal_text());
447 assert_eq!(streamed.calls(), complete.calls());
448 }
449 +
450 + #[test]
451 + fn tool_framing_preserves_body_whitespace_across_chunk_boundaries() {
452 + assert_tool_framing_preserves_body_whitespace::<DeepSeekV32ToolParser>(
453 + "\n\n",
454 + "<|DSML|function_calls><|DSML|invoke name=\"get_weather\"></|DSML|invoke></|DSML|function_calls>",
455 + );
456 + }
457 }
rust/src/parser/src/tool/deepseek_dsml/deepseek_v4.rs
+9
@@ -73,6 +73,7 @@ mod tests {
73
74 use super::DeepSeekV4ToolParser;
75 use crate::tool::test_utils::{collect_stream, test_tools};
76 + use crate::tool::tests::assert_tool_framing_preserves_body_whitespace;
77 use crate::tool::{ToolParser, ToolParserTestExt as _};
78
79 fn build_tool_call(function_name: &str, params: &[(&str, &str)]) -> String {
@@ -145,4 +146,12 @@ mod tests {
146 json!({ "location": "Beijing" })
147 );
148 }
149 +
150 + #[test]
151 + fn tool_framing_preserves_body_whitespace_across_chunk_boundaries() {
152 + assert_tool_framing_preserves_body_whitespace::<DeepSeekV4ToolParser>(
153 + "\n\n",
154 + "<|DSML|tool_calls><|DSML|invoke name=\"get_weather\"></|DSML|invoke></|DSML|tool_calls>",
155 + );
156 + }
157 }
rust/src/parser/src/tool/deepseek_dsml/mod.rs
+15 -5
@@ -8,7 +8,7 @@ use winnow::stream::Partial;
8 use winnow::token::{literal, rest, take_until};
9
10 use super::parameters::ToolSchemas;
11 -use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len, take_until_marker};
11 +use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len_mul, take_until_marker};
12 use super::{Result, ToolCallDelta, ToolParserOutput};
13 use crate::tool::Tool;
14
@@ -28,16 +28,19 @@ type DsmlInput<'i> = Partial<&'i str>;
28 #[derive(Debug, Clone, Copy)]
29 struct DsmlTokens {
30 tool_calls_start: &'static str,
31 + framed_tool_calls_start: &'static str,
32 tool_calls_end: &'static str,
33 }
34
35 impl DsmlTokens {
36 const V32: Self = Self {
37 tool_calls_start: "<|DSML|function_calls>",
38 + framed_tool_calls_start: "\n\n<|DSML|function_calls>",
39 tool_calls_end: "</|DSML|function_calls>",
40 };
41 const V4: Self = Self {
42 tool_calls_start: "<|DSML|tool_calls>",
43 + framed_tool_calls_start: "\n\n<|DSML|tool_calls>",
44 tool_calls_end: "</|DSML|tool_calls>",
45 };
46 }
@@ -210,9 +213,12 @@ fn parse_tool_block_event(
213
214 /// Parse a DSML function-calls start marker.
215 fn tool_calls_start_event(input: &mut DsmlInput<'_>, tokens: DsmlTokens) -> ModalResult<DsmlEvent> {
213 - literal(tokens.tool_calls_start)
214 - .value(DsmlEvent::ToolCallsStart)
215 - .parse_next(input)
216 + alt((
217 + literal(tokens.framed_tool_calls_start),
218 + literal(tokens.tool_calls_start),
219 + ))
220 + .value(DsmlEvent::ToolCallsStart)
221 + .parse_next(input)
222 }
223
224 /// Parse a DSML function-calls end marker.
@@ -227,7 +233,11 @@ fn ignored_rest_event(input: &mut DsmlInput<'_>) -> ModalResult<DsmlEvent> {
233
234 /// Parse a safe text run before the next DSML marker.
235 fn safe_text_event(input: &mut DsmlInput<'_>, tokens: DsmlTokens) -> ModalResult<DsmlEvent> {
230 - safe_text_len(input, tokens.tool_calls_start).map(|len| DsmlEvent::Text { len })
236 + safe_text_len_mul(
237 + input,
238 + &[tokens.framed_tool_calls_start, tokens.tool_calls_start],
239 + )
240 + .map(|len| DsmlEvent::Text { len })
241 }
242
243 /// Parse a DSML invoke block.
rust/src/parser/src/tool/glm_xml/glm45_moe.rs
+14
@@ -45,3 +45,17 @@ impl ToolParser for Glm45MoeToolParser {
45 self.0.reset()
46 }
47 }
48 +
49 +#[cfg(test)]
50 +mod tests {
51 + use super::Glm45MoeToolParser;
52 + use crate::tool::tests::assert_tool_framing_preserves_body_whitespace;
53 +
54 + #[test]
55 + fn tool_framing_preserves_body_whitespace_across_chunk_boundaries() {
56 + assert_tool_framing_preserves_body_whitespace::<Glm45MoeToolParser>(
57 + "\n",
58 + "<tool_call>get_weather\n</tool_call>",
59 + );
60 + }
61 +}
rust/src/parser/src/tool/glm_xml/mod.rs
+18 -10
@@ -8,7 +8,7 @@ use winnow::stream::Partial;
8 use winnow::token::{literal, rest, take_until, take_while};
9
10 use super::parameters::ToolSchemas;
11 -use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len, take_until_marker};
11 +use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len_mul, take_until_marker};
12 use super::{Result, ToolCallDelta, ToolParserOutput};
13 use crate::tool::Tool;
14
@@ -19,6 +19,7 @@ pub use glm45_moe::Glm45MoeToolParser;
19 pub use glm47_moe::Glm47MoeToolParser;
20
21 const TOOL_CALL_START: &str = "<tool_call>";
22 +const FRAMED_TOOL_CALL_START: &str = "\n<tool_call>";
23 const TOOL_CALL_END: &str = "</tool_call>";
24 const ARG_KEY_START: &str = "<arg_key>";
25 const ARG_KEY_END: &str = "</arg_key>";
@@ -149,7 +150,7 @@ fn parse_next_glm_event(
150 separator: Separator,
151 ) -> ModalResult<GlmEvent> {
152 match mode {
152 - GlmMode::Text => parse_text_event(input),
153 + GlmMode::Text => parse_text_event(input, separator),
154 GlmMode::ToolCall { tool_call_end_scan } => {
155 tool_call_event(input, separator, tool_call_end_scan)
156 }
@@ -158,8 +159,20 @@ fn parse_next_glm_event(
159 }
160
161 /// Parse a text-mode GLM event.
161 -fn parse_text_event(input: &mut GlmInput<'_>) -> ModalResult<GlmEvent> {
162 - alt((tool_call_start_event, safe_text_event)).parse_next(input)
162 +fn parse_text_event(input: &mut GlmInput<'_>, separator: Separator) -> ModalResult<GlmEvent> {
163 + let framed_start = match separator {
164 + Separator::Newline => FRAMED_TOOL_CALL_START,
165 + Separator::Flexible => TOOL_CALL_START,
166 + };
167 + alt((
168 + literal(framed_start).value(GlmEvent::ToolCallStart),
169 + tool_call_start_event,
170 + |input: &mut GlmInput<'_>| {
171 + safe_text_len_mul(input, &[framed_start, TOOL_CALL_START])
172 + .map(|len| GlmEvent::Text { len })
173 + },
174 + ))
175 + .parse_next(input)
176 }
177
178 /// Parse a GLM tool-call start marker.
@@ -167,11 +180,6 @@ fn tool_call_start_event(input: &mut GlmInput<'_>) -> ModalResult<GlmEvent> {
180 literal(TOOL_CALL_START).value(GlmEvent::ToolCallStart).parse_next(input)
181 }
182
170 -/// Parse a safe text run before the next GLM marker.
171 -fn safe_text_event(input: &mut GlmInput<'_>) -> ModalResult<GlmEvent> {
172 - safe_text_len(input, TOOL_CALL_START).map(|len| GlmEvent::Text { len })
173 -}
174 -
183 /// Parse text after a completed GLM tool call.
184 fn after_tool_call_event(input: &mut GlmInput<'_>) -> ModalResult<GlmEvent> {
185 ws0.void().parse_next(input)?;
@@ -303,7 +311,7 @@ mod tests {
311
312 let output = parser.parse_complete(&output).unwrap();
313
306 - assert_eq!(output.normal_text(), "Let me search for that.\n");
314 + assert_eq!(output.normal_text(), "Let me search for that.");
315 assert_eq!(output.calls().len(), 1);
316 assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather"));
317 assert_eq!(
rust/src/parser/src/tool/json/granite4.rs
+1
@@ -222,6 +222,7 @@ fn header_event(input: &mut JsonToolInput<'_>) -> ModalResult<Granite4Event> {
222 const CONFIG: JsonToolCallConfig = JsonToolCallConfig {
223 parser_name: "Granite4",
224 start_marker: "",
225 + framed_start_marker: None,
226 end_marker: "",
227 marker_whitespace: JsonToolCallWhitespace::Optional,
228 delimiter: None,
rust/src/parser/src/tool/json/hermes.rs
+1
@@ -7,6 +7,7 @@ use crate::tool::{Result, StructuralTagBuilder, Tool, ToolParser, ToolParserOutp
7 const HERMES_CONFIG: JsonToolCallConfig = JsonToolCallConfig {
8 parser_name: "Hermes",
9 start_marker: "<tool_call>",
10 + framed_start_marker: None,
11 end_marker: "</tool_call>",
12 marker_whitespace: JsonToolCallWhitespace::Optional,
13 delimiter: None,
rust/src/parser/src/tool/json/internlm2.rs
+1
@@ -7,6 +7,7 @@ use crate::tool::{Result, Tool, ToolParser, ToolParserOutput};
7 const INTERNLM2_CONFIG: JsonToolCallConfig = JsonToolCallConfig {
8 parser_name: "InternLM2",
9 start_marker: "<|action_start|><|plugin|>",
10 + framed_start_marker: None,
11 end_marker: "<|action_end|>",
12 marker_whitespace: JsonToolCallWhitespace::Optional,
13 delimiter: None,
rust/src/parser/src/tool/json/llama.rs
+1
@@ -208,6 +208,7 @@ fn llama_tool_call_header_event(input: &mut JsonToolInput<'_>) -> ModalResult<Ll
208 const CONFIG: JsonToolCallConfig = JsonToolCallConfig {
209 parser_name: "Llama JSON",
210 start_marker: "",
211 + framed_start_marker: None,
212 end_marker: "",
213 marker_whitespace: JsonToolCallWhitespace::Optional,
214 delimiter: Some(";"),
rust/src/parser/src/tool/json/mistral.rs
+1
@@ -7,6 +7,7 @@ use crate::tool::{Result, Tool, ToolParser, ToolParserOutput};
7 const MISTRAL_CONFIG: JsonToolCallConfig = JsonToolCallConfig {
8 parser_name: "Mistral",
9 start_marker: "[TOOL_CALLS] [",
10 + framed_start_marker: None,
11 end_marker: "]",
12 marker_whitespace: JsonToolCallWhitespace::Optional,
13 delimiter: Some(","),
rust/src/parser/src/tool/json/mod.rs
+17 -3
@@ -27,7 +27,8 @@ use winnow::stream::{Partial, Stream};
27 use winnow::token::literal;
28
29 use super::utils::{
30 - JsonObjectScanState, json_str, parse_buffered_event, safe_text_len, take_json_object,
30 + JsonObjectScanState, json_str, parse_buffered_event, safe_text_len, safe_text_len_mul,
31 + take_json_object,
32 };
33 use super::{Result, ToolCallDelta, ToolParserOutput};
34
@@ -37,7 +38,12 @@ pub(crate) type JsonToolInput<'i> = Partial<&'i str>;
38 pub(crate) struct JsonToolCallConfig {
39 pub parser_name: &'static str,
40 pub start_marker: &'static str,
41 + /// Start marker with its fixed preceding framing, matched alongside the
42 + /// bare start marker.
43 + pub framed_start_marker: Option<&'static str>,
44 pub end_marker: &'static str,
45 + /// Whitespace inside the tool-call markers: after the start marker and
46 + /// before the end marker.
47 pub marker_whitespace: JsonToolCallWhitespace,
48 pub delimiter: Option<&'static str>,
49 pub name_key: &'static str,
@@ -211,7 +217,10 @@ fn tool_call_start_event(
217 config: JsonToolCallConfig,
218 ) -> ModalResult<JsonToolCallEvent> {
219 seq!(
214 - _: literal(config.start_marker),
220 + _: |input: &mut JsonToolInput<'_>| match config.framed_start_marker {
221 + Some(marker) => alt((literal(marker), literal(config.start_marker))).void().parse_next(input),
222 + None => literal(config.start_marker).void().parse_next(input),
223 + },
224 _: |input: &mut JsonToolInput<'_>| marker_whitespace(input, config),
225 )
226 .value(JsonToolCallEvent::ToolCallStart)
@@ -369,7 +378,11 @@ fn safe_text_event(
378 input: &mut JsonToolInput<'_>,
379 config: JsonToolCallConfig,
380 ) -> ModalResult<JsonToolCallEvent> {
372 - safe_text_len(input, config.start_marker).map(|len| JsonToolCallEvent::Text { len })
381 + match config.framed_start_marker {
382 + Some(marker) => safe_text_len_mul(input, &[marker, config.start_marker]),
383 + None => safe_text_len(input, config.start_marker),
384 + }
385 + .map(|len| JsonToolCallEvent::Text { len })
386 }
387
388 #[cfg(test)]
@@ -382,6 +395,7 @@ mod tests {
395 const DELIMITED_CONFIG: JsonToolCallConfig = JsonToolCallConfig {
396 parser_name: "Delimited JSON",
397 start_marker: "<tool_calls>",
398 + framed_start_marker: None,
399 end_marker: "</tool_calls>",
400 marker_whitespace: JsonToolCallWhitespace::Optional,
401 delimiter: Some("<"),
rust/src/parser/src/tool/json/phi4mini.rs
+1
@@ -7,6 +7,7 @@ use crate::tool::{Result, Tool, ToolParser, ToolParserOutput};
7 const PHI4MINI_CONFIG: JsonToolCallConfig = JsonToolCallConfig {
8 parser_name: "Phi4Mini",
9 start_marker: "functools[",
10 + framed_start_marker: None,
11 end_marker: "]",
12 marker_whitespace: JsonToolCallWhitespace::Optional,
13 delimiter: Some(","),
rust/src/parser/src/tool/json/qwen.rs
+11 -1
@@ -7,6 +7,7 @@ use crate::tool::{Result, StructuralTagBuilder, Tool, ToolParser, ToolParserOutp
7 const QWEN_XML_CONFIG: JsonToolCallConfig = JsonToolCallConfig {
8 parser_name: "Qwen XML",
9 start_marker: "<tool_call>",
10 + framed_start_marker: Some("\n<tool_call>"),
11 end_marker: "</tool_call>",
12 marker_whitespace: JsonToolCallWhitespace::Exact("\n"),
13 delimiter: None,
@@ -74,6 +75,7 @@ mod tests {
75
76 use super::Qwen3XmlToolParser;
77 use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools};
78 + use crate::tool::tests::assert_tool_framing_preserves_body_whitespace;
79 use crate::tool::{ToolParser, ToolParserOutput, ToolParserTestExt as _};
80
81 fn build_tool_call(function_name: &str, arguments: &str) -> String {
@@ -102,7 +104,7 @@ mod tests {
104 ))
105 .unwrap();
106
105 - assert_eq!(output.normal_text(), "Let me check.\n");
107 + assert_eq!(output.normal_text(), "Let me check.");
108 assert_eq!(output.calls().len(), 1);
109 assert_eq!(output.calls()[0].tool_index, 0);
110 assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather"));
@@ -287,4 +289,12 @@ mod tests {
289 expected `name`"#]]
290 .assert_eq(&error.to_report_string());
291 }
292 +
293 + #[test]
294 + fn tool_framing_preserves_body_whitespace_across_chunk_boundaries() {
295 + assert_tool_framing_preserves_body_whitespace::<Qwen3XmlToolParser>(
296 + "\n",
297 + "<tool_call>\n{\"name\":\"get_weather\",\"arguments\":{}}\n</tool_call>",
298 + );
299 + }
300 }
rust/src/parser/src/tool/minimax_m2.rs
+17 -4
@@ -8,11 +8,12 @@ use winnow::stream::Partial;
8 use winnow::token::{literal, rest, take_until};
9
10 use super::parameters::ToolSchemas;
11 -use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len, take_until_marker};
11 +use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len_mul, take_until_marker};
12 use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput};
13 use crate::tool::{StructuralTagBuilder, Tool};
14
15 const TOOL_CALL_START: &str = "<minimax:tool_call>";
16 +const FRAMED_TOOL_CALL_START: &str = "\n<minimax:tool_call>";
17 const TOOL_CALL_END: &str = "</minimax:tool_call>";
18 const INVOKE_START: &str = "<invoke";
19 const INVOKE_END: &str = "</invoke>";
@@ -173,12 +174,15 @@ fn parse_text_event(input: &mut MinimaxM2Input<'_>) -> ModalResult<MinimaxM2Even
174
175 /// Parse a MiniMax M2 tool-block start marker.
176 fn tool_block_start_event(input: &mut MinimaxM2Input<'_>) -> ModalResult<MinimaxM2Event> {
176 - literal(TOOL_CALL_START).value(MinimaxM2Event::ToolBlockStart).parse_next(input)
177 + alt((literal(FRAMED_TOOL_CALL_START), literal(TOOL_CALL_START)))
178 + .value(MinimaxM2Event::ToolBlockStart)
179 + .parse_next(input)
180 }
181
182 /// Parse a safe text run before the next MiniMax M2 marker.
183 fn safe_text_event(input: &mut MinimaxM2Input<'_>) -> ModalResult<MinimaxM2Event> {
181 - safe_text_len(input, TOOL_CALL_START).map(|len| MinimaxM2Event::Text { len })
184 + safe_text_len_mul(input, &[FRAMED_TOOL_CALL_START, TOOL_CALL_START])
185 + .map(|len| MinimaxM2Event::Text { len })
186 }
187
188 /// Parse one event inside a MiniMax M2 tool block.
@@ -277,6 +281,7 @@ mod tests {
281 use super::{MinimaxM2ToolParser, TOOL_CALL_END, TOOL_CALL_START, ToolParser};
282 use crate::tool::ToolParserTestExt as _;
283 use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools};
284 + use crate::tool::tests::assert_tool_framing_preserves_body_whitespace;
285
286 fn build_tool_block(invokes: &[(&str, Vec<(&str, &str)>)]) -> String {
287 let invokes = invokes
@@ -543,7 +548,7 @@ mod tests {
548 let mut parser = MinimaxM2ToolParser::new(&test_tools());
549 let result = collect_stream(&mut parser, &chunks);
550
546 - assert_eq!(result.normal_text(), "I will call the tools.\n");
551 + assert_eq!(result.normal_text(), "I will call the tools.");
552 assert_eq!(result.calls().len(), 2);
553 assert_eq!(result.calls()[0].tool_index, 0);
554 assert_eq!(result.calls()[0].name.as_deref(), Some("get_weather"));
@@ -599,4 +604,12 @@ mod tests {
604 expect![[r#"tool parser parsing failed: near "<bad></minimax:tool_call>": "#]]
605 .assert_eq(&error.to_report_string());
606 }
607 +
608 + #[test]
609 + fn tool_framing_preserves_body_whitespace_across_chunk_boundaries() {
610 + assert_tool_framing_preserves_body_whitespace::<MinimaxM2ToolParser>(
611 + "\n",
612 + "<minimax:tool_call><invoke name=\"get_weather\"></invoke></minimax:tool_call>",
613 + );
614 + }
615 }
rust/src/parser/src/tool/qwen_coder.rs
+29 -21
@@ -8,7 +8,7 @@ use winnow::stream::Partial;
8 use winnow::token::{literal, take_until};
9
10 use super::parameters::ToolSchemas;
11 -use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len, take_until_marker};
11 +use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len_mul, take_until_marker};
12 use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput};
13 use crate::tool::{StructuralTagBuilder, Tool};
14
@@ -31,6 +31,8 @@ pub(crate) struct QwenCoderConfig {
31 pub(crate) parser_name: &'static str,
32 /// Marker that opens a tool-call block.
33 pub(crate) tool_call_start: &'static str,
34 + pub(crate) first_tool_call_start: &'static str,
35 + pub(crate) next_tool_call_start: &'static str,
36 /// Marker that closes a tool-call block.
37 pub(crate) tool_call_end: &'static str,
38 }
@@ -38,6 +40,8 @@ pub(crate) struct QwenCoderConfig {
40 const QWEN_CODER_CONFIG: QwenCoderConfig = QwenCoderConfig {
41 parser_name: "Qwen Coder",
42 tool_call_start: TOOL_CALL_START,
43 + first_tool_call_start: "\n\n<tool_call>",
44 + next_tool_call_start: "\n<tool_call>",
45 tool_call_end: TOOL_CALL_END,
46 };
47
@@ -155,7 +159,7 @@ impl ToolParser for Qwen3CoderToolParser {
159 let config = self.config;
160
161 while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| {
158 - parse_next_qwen_coder_event(input, &mut self.mode, config)
162 + parse_next_qwen_coder_event(input, &mut self.mode, config, self.emitted_tool_count > 0)
163 })? {
164 self.apply_event(event, output)?;
165 self.buffer.drain(..consumed_len);
@@ -191,9 +195,10 @@ fn parse_next_qwen_coder_event(
195 input: &mut QwenCoderInput<'_>,
196 mode: &mut QwenCoderMode,
197 config: QwenCoderConfig,
198 + after_tool_call: bool,
199 ) -> ModalResult<QwenCoderEvent> {
200 match mode {
196 - QwenCoderMode::Text => parse_text_event(input, config),
201 + QwenCoderMode::Text => parse_text_event(input, config, after_tool_call),
202 QwenCoderMode::ToolCall { end_marker_scan } => {
203 tool_call_event(input, end_marker_scan, config.tool_call_end)
204 }
@@ -204,30 +209,24 @@ fn parse_next_qwen_coder_event(
209 fn parse_text_event(
210 input: &mut QwenCoderInput<'_>,
211 config: QwenCoderConfig,
212 + after_tool_call: bool,
213 ) -> ModalResult<QwenCoderEvent> {
214 + let framed_start = if after_tool_call {
215 + config.next_tool_call_start
216 + } else {
217 + config.first_tool_call_start
218 + };
219 alt((
209 - |input: &mut QwenCoderInput<'_>| tool_call_start_event(input, config.tool_call_start),
210 - |input: &mut QwenCoderInput<'_>| safe_text_event(input, config.tool_call_start),
220 + alt((literal(framed_start), literal(config.tool_call_start)))
221 + .value(QwenCoderEvent::ToolCallStart),
222 + |input: &mut QwenCoderInput<'_>| {
223 + safe_text_len_mul(input, &[framed_start, config.tool_call_start])
224 + .map(|len| QwenCoderEvent::Text { len })
225 + },
226 ))
227 .parse_next(input)
228 }
229
215 -/// Parse a Qwen Coder tool-call start marker.
216 -fn tool_call_start_event(
217 - input: &mut QwenCoderInput<'_>,
218 - tool_call_start: &'static str,
219 -) -> ModalResult<QwenCoderEvent> {
220 - literal(tool_call_start).value(QwenCoderEvent::ToolCallStart).parse_next(input)
221 -}
222 -
223 -/// Parse a safe text run before the next Qwen Coder marker.
224 -fn safe_text_event(
225 - input: &mut QwenCoderInput<'_>,
226 - tool_call_start: &'static str,
227 -) -> ModalResult<QwenCoderEvent> {
228 - safe_text_len(input, tool_call_start).map(|len| QwenCoderEvent::Text { len })
229 -}
230 -
230 /// Parse a complete Qwen Coder tool call.
231 fn tool_call_event(
232 input: &mut QwenCoderInput<'_>,
@@ -296,6 +295,7 @@ mod tests {
295
296 use super::{Qwen3CoderToolParser, ToolParser};
297 use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools};
298 + use crate::tool::tests::assert_tool_framing_preserves_body_whitespace;
299 use crate::tool::{ToolParserOutput, ToolParserTestExt as _};
300
301 fn build_tool_call(function_name: &str, params: &[(&str, &str)]) -> String {
@@ -766,4 +766,12 @@ mod tests {
766 json!({ "location": "Hangzhou" })
767 );
768 }
769 +
770 + #[test]
771 + fn tool_framing_preserves_body_whitespace_across_chunk_boundaries() {
772 + assert_tool_framing_preserves_body_whitespace::<Qwen3CoderToolParser>(
773 + "\n\n",
774 + "<tool_call>\n<function=get_weather>\n</function>\n</tool_call>",
775 + );
776 + }
777 }
rust/src/parser/src/tool/seed_oss.rs
+11
@@ -7,6 +7,8 @@ use crate::tool::{Result, Tool, ToolParser, ToolParserOutput};
7 const SEED_OSS_CONFIG: QwenCoderConfig = QwenCoderConfig {
8 parser_name: "Seed-OSS",
9 tool_call_start: "<seed:tool_call>",
10 + first_tool_call_start: "\n\n<seed:tool_call>",
11 + next_tool_call_start: "\n<seed:tool_call>",
12 tool_call_end: "</seed:tool_call>",
13 };
14
@@ -79,6 +81,7 @@ mod tests {
81
82 use super::SeedOssToolParser;
83 use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools};
84 + use crate::tool::tests::assert_tool_framing_preserves_body_whitespace;
85 use crate::tool::{ToolParser, ToolParserOutput, ToolParserTestExt as _};
86
87 fn build_tool_call(function_name: &str, params: &[(&str, &str)]) -> String {
@@ -250,4 +253,12 @@ mod tests {
253 "tool parser parsing failed: incomplete Seed-OSS tool call"
254 );
255 }
256 +
257 + #[test]
258 + fn tool_framing_preserves_body_whitespace_across_chunk_boundaries() {
259 + assert_tool_framing_preserves_body_whitespace::<SeedOssToolParser>(
260 + "\n\n",
261 + "<seed:tool_call>\n<function=get_weather>\n</function>\n</seed:tool_call>",
262 + );
263 + }
264 }
rust/src/parser/src/tool/tests.rs
+23
@@ -170,3 +170,26 @@ fn default_parse_complete_delegates_through_parse_chunk_and_finish() {
170 ]
171 );
172 }
173 +
174 +pub(super) fn assert_tool_framing_preserves_body_whitespace<P: ToolParser + 'static>(
175 + framing: &str,
176 + call: &str,
177 +) {
178 + use super::test_utils::{collect_stream, test_tools};
179 +
180 + let body = " before\n";
181 + let wire = format!("{body}{framing}{call}");
182 + for split in wire.char_indices().map(|(i, _)| i).chain(std::iter::once(wire.len())) {
183 + let mut parser = P::create(&test_tools()).unwrap();
184 + let output = collect_stream(parser.as_mut(), &[&wire[..split], &wire[split..]]);
185 + assert_eq!(output.normal_text(), body, "split {split}");
186 + assert_eq!(output.calls().len(), 1, "split {split}");
187 + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather"));
188 + }
189 + let plain = " no tool\n\n<to";
190 + let mut parser = P::create(&test_tools()).unwrap();
191 + assert_eq!(
192 + collect_stream(parser.as_mut(), &[plain]).normal_text(),
193 + plain
194 + );
195 +}
rust/src/parser/src/unified/gemma4.rs
+18 -2
@@ -20,6 +20,7 @@ use crate::utils::{incomplete, parse_buffered_event, partial_prefix_len, safe_te
20 const REASONING_START: &str = "<|channel>thought\n";
21 const CHANNEL_START: &str = "<|channel>";
22 const CHANNEL_END: &str = "<channel|>";
23 +const FRAMED_CHANNEL_END: &str = "\n<channel|>";
24 const TOOL_CALL_START: &str = "<|tool_call>";
25 const TOOL_CALL_END: &str = "<tool_call|>";
26 const STRING_DELIM: &str = "<|\"|>";
@@ -259,7 +260,9 @@ fn reasoning_start_event(input: &mut Gemma4Input<'_>) -> ModalResult<Gemma4Event
260
261 /// Parse a Gemma4 reasoning end marker.
262 fn reasoning_end_event(input: &mut Gemma4Input<'_>) -> ModalResult<Gemma4Event> {
262 - literal(CHANNEL_END).value(Gemma4Event::ReasoningEnd).parse_next(input)
263 + alt((literal(FRAMED_CHANNEL_END), literal(CHANNEL_END)))
264 + .value(Gemma4Event::ReasoningEnd)
265 + .parse_next(input)
266 }
267
268 /// Parse a Gemma4 tool-call start marker.
@@ -308,7 +311,8 @@ fn safe_text_event(input: &mut Gemma4Input<'_>) -> ModalResult<Gemma4Event> {
311
312 /// Parse a safe reasoning run before the next Gemma4 marker.
313 fn safe_reasoning_event(input: &mut Gemma4Input<'_>) -> ModalResult<Gemma4Event> {
311 - safe_text_len_mul(input, &[CHANNEL_END, TOOL_CALL_START]).map(|_| Gemma4Event::Reasoning)
314 + safe_text_len_mul(input, &[FRAMED_CHANNEL_END, CHANNEL_END, TOOL_CALL_START])
315 + .map(|_| Gemma4Event::Reasoning)
316 }
317
318 /// Parse raw Gemma4 arguments through the first end marker outside a Gemma string.
@@ -686,6 +690,18 @@ mod tests {
690 output.coalesce()
691 }
692
693 + #[test]
694 + fn gemma4_reasoning_framing_preserves_extra_newlines_at_every_split() {
695 + let wire = "<|channel>thought\n\n reason\n\n<channel|> answer\n";
696 + for split in 0..=wire.len() {
697 + let output = collect_stream(&[&wire[..split], &wire[split..]]);
698 + assert_eq!(output.reasoning_text(), "\n reason\n", "split {split}");
699 + assert_eq!(output.normal_text(), " answer\n", "split {split}");
700 + }
701 + let output = collect_stream(&["<|channel>thought\nreason\n<channel"]);
702 + assert_eq!(output.reasoning_text(), "reason\n<channel");
703 + }
704 +
705 fn first_call(output: &UnifiedParserOutput) -> ToolCallDelta {
706 (*output.calls().first().expect("expected one tool call")).clone()
707 }
rust/src/parser/src/unified/inkling.rs
+1
@@ -45,6 +45,7 @@ const BLOCK_END_MARKERS: &[&str] = &[END_MESSAGE, CONTENT_MODEL_END_SAMPLING];
45 const INKLING_TOOL_CONFIG: JsonToolCallConfig = JsonToolCallConfig {
46 parser_name: "Inkling",
47 start_marker: CONTENT_INVOKE_TOOL_JSON,
48 + framed_start_marker: None,
49 end_marker: END_MESSAGE,
50 marker_whitespace: JsonToolCallWhitespace::Optional,
51 delimiter: None,