[Rust Frontend] Use token-attributed text in reasoning and unified parsers (#54884)

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Bugen Zhao committed Sep 4, 2026 at 04:05 UTC 21a22110404d98dcab5f51cd90e9acb0acc15e47
22 files changed +1012 -350
rust/src/chat/src/output/default/unified.rs
+13 -10
@@ -13,7 +13,7 @@ use futures::{StreamExt as _, pin_mut};
13 use thiserror_ext::AsReport;
14 use tracing::warn;
15 use vllm_parser::unified::{UnifiedParser, UnifiedParserEvent, UnifiedParserOutput};
16 -use vllm_text::output::{DecodedTextEvent, SampledDelta};
16 +use vllm_text::output::{DecodedText, DecodedTextEvent, SampledDelta};
17
18 use crate::Result;
19 use crate::error::Error;
@@ -63,14 +63,17 @@ impl UnifiedParserState {
63 }
64
65 /// Convert one decoded text delta into zero or more parsed assistant events.
66 - fn process_delta(&mut self, delta: String) -> Result<Vec<AssistantEvent>> {
66 + fn process_delta(&mut self, delta: DecodedText) -> Result<Vec<AssistantEvent>> {
67 if self.parser_failed {
68 self.open_call_index = None;
69 - return Ok(text_event(AssistantBlockKind::Text, delta).into_iter().collect());
69 + return Ok(text_event(AssistantBlockKind::Text, delta.text).into_iter().collect());
70 }
71
72 let mut output = UnifiedParserOutput::default();
73 - match self.parser.parse_into(&delta, &mut output) {
73 + // The parser consumes the delta; keep its text for the fallback path
74 + // that re-emits it as plain text.
75 + let fallback_text = delta.text.clone();
76 + match self.parser.parse_into(delta, &mut output) {
77 Ok(()) => {
78 let mut events = Vec::new();
79 self.process_parser_output(output, &mut events)?;
@@ -89,7 +92,7 @@ impl UnifiedParserState {
92
93 let recovered = self.parser.reset();
94 if recovered.is_empty() && events.is_empty() {
92 - push_text_delta(&mut events, AssistantBlockKind::Text, delta);
95 + push_text_delta(&mut events, AssistantBlockKind::Text, fallback_text);
96 } else {
97 push_text_delta(&mut events, AssistantBlockKind::Text, recovered);
98 }
@@ -137,7 +140,7 @@ impl UnifiedParserState {
140 }
141 UnifiedParserEvent::Reasoning(delta) => {
142 self.open_call_index = None;
140 - push_text_delta(events, AssistantBlockKind::Reasoning, delta);
143 + push_text_delta(events, AssistantBlockKind::Reasoning, delta.text);
144 }
145 UnifiedParserEvent::ToolCall(item) => {
146 self.process_tool_item(item, events)?;
@@ -245,7 +248,7 @@ pub(crate) async fn unified_event_stream(
248 },
249 finished,
250 } => {
248 - for next in state.process_delta(decoded.text)? {
251 + for next in state.process_delta(decoded)? {
252 y.yield_ok(next).await;
253 }
254 if logprobs.is_some() || !token_ids.is_empty() {
@@ -284,7 +287,7 @@ mod tests {
287 use vllm_parser::unified::{Gemma4UnifiedParser, UnifiedParserError, UnifiedParserOutput};
288 use vllm_tokenizer::test_utils::TestTokenizer;
289
287 - use super::unified_event_stream;
290 + use super::{DecodedText, unified_event_stream};
291 use crate::event::AssistantBlockKind;
292 use crate::output::AssistantEvent;
293
@@ -332,7 +335,7 @@ mod tests {
335
336 fn parse_into(
337 &mut self,
335 - _delta: &str,
338 + _delta: DecodedText,
339 output: &mut UnifiedParserOutput,
340 ) -> vllm_parser::unified::Result<()> {
341 match self.steps.pop_front().expect("unexpected parser call") {
@@ -418,7 +421,7 @@ mod tests {
421
422 fn reasoning(delta: &str) -> UnifiedParserOutput {
423 let mut output = UnifiedParserOutput::default();
421 - output.push_reasoning(delta.to_string());
424 + output.push_reasoning(DecodedText::unattributed(delta));
425 output
426 }
427
rust/src/parser/benches/utils/adapter.rs
+5 -2
@@ -9,7 +9,7 @@ use vllm_parser::tool::{
9 use vllm_parser::unified::{
10 UnifiedParser, UnifiedParserError, UnifiedParserEvent, UnifiedParserOutput,
11 };
12 -use vllm_tokenizer::Tokenizer;
12 +use vllm_tokenizer::{DecodedText, Tokenizer};
13
14 /// Tokenizer stub used by unified-parser benchmarks.
15 struct BenchTokenizer;
@@ -99,7 +99,10 @@ impl<T: UnifiedParser> ToolParser for UnifiedToolParserAdapter<T> {
99
100 fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
101 let mut unified_output = UnifiedParserOutput::default();
102 - let result = self.inner.parse_into(chunk, &mut unified_output).map_err(map_unified_error);
102 + let result = self
103 + .inner
104 + .parse_into(DecodedText::unattributed(chunk), &mut unified_output)
105 + .map_err(map_unified_error);
106 append_unified_output(unified_output, output)?;
107 result
108 }
rust/src/parser/src/reasoning/cohere_cmd.rs
+2 -2
@@ -1,7 +1,7 @@
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -use vllm_tokenizer::DynTokenizer;
4 +use vllm_tokenizer::{DecodedText, DynTokenizer};
5
6 use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result};
7
@@ -38,7 +38,7 @@ impl ReasoningParser for CohereCmdReasoningParser {
38 Ok(())
39 }
40
41 - fn push(&mut self, delta: &str) -> Result<ReasoningDelta> {
41 + fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta> {
42 Ok(self.inner.push(delta))
43 }
44
rust/src/parser/src/reasoning/deepseek_r1.rs
+2 -2
@@ -1,7 +1,7 @@
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -use vllm_tokenizer::DynTokenizer;
4 +use vllm_tokenizer::{DecodedText, DynTokenizer};
5
6 use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result};
7
@@ -37,7 +37,7 @@ impl ReasoningParser for DeepSeekR1ReasoningParser {
37 Ok(())
38 }
39
40 - fn push(&mut self, delta: &str) -> Result<ReasoningDelta> {
40 + fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta> {
41 Ok(self.inner.push(delta))
42 }
43
rust/src/parser/src/reasoning/delimited.rs
+36 -22
@@ -1,7 +1,7 @@
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -use vllm_tokenizer::{DynTokenizer, Tokenizer};
4 +use vllm_tokenizer::{DecodedText, DynTokenizer, Tokenizer};
5
6 use super::{ReasoningDelta, ReasoningError, Result};
7
@@ -19,7 +19,7 @@ use super::{ReasoningDelta, ReasoningError, Result};
19 pub(crate) struct DelimitedReasoningParser {
20 tokenizer: DynTokenizer,
21 current_in_reasoning: bool,
22 - buffer: String,
22 + buffer: DecodedText,
23 start_token: String,
24 end_token: String,
25 start_token_id: u32,
@@ -55,7 +55,7 @@ impl DelimitedReasoningParser {
55 Ok(Self {
56 tokenizer,
57 current_in_reasoning: default_in_reasoning,
58 - buffer: String::new(),
58 + buffer: DecodedText::default(),
59 start_token,
60 end_token,
61 start_token_id,
@@ -81,44 +81,58 @@ impl DelimitedReasoningParser {
81 }
82
83 /// Parse one decoded text delta and return its reasoning/content split.
84 - pub(crate) fn push(&mut self, delta: &str) -> ReasoningDelta {
85 - self.buffer.push_str(delta);
84 + pub(crate) fn push(&mut self, delta: DecodedText) -> ReasoningDelta {
85 + self.buffer.append(delta);
86
87 - let partial_suffix_len = self.partial_suffix_len(&self.buffer);
88 - let stable_len = self.buffer.len() - partial_suffix_len;
89 - let pending_suffix = self.buffer.split_off(stable_len);
90 - let stable_text = std::mem::replace(&mut self.buffer, pending_suffix);
87 + let partial_suffix_len = self.partial_suffix_len(&self.buffer.text);
88 + let stable_len = self.buffer.text.len() - partial_suffix_len;
89 + let stable = self.buffer.drain_prefix(stable_len);
90
92 - self.parse_stable_text(&stable_text)
91 + self.parse_stable_text(stable)
92 }
93
94 /// Flush any buffered partial delimiter suffix at end of stream.
95 pub(crate) fn finish(&mut self) -> ReasoningDelta {
97 - let stable_text = std::mem::take(&mut self.buffer);
98 - self.parse_stable_text(&stable_text)
96 + // `drain_prefix(text.len())` takes trailing zero-width tokens too.
97 + let stable = self.buffer.drain_prefix(self.buffer.text.len());
98 + self.parse_stable_text(stable)
99 }
100
101 /// Parse text that is known not to end with a partial delimiter suffix.
102 - fn parse_stable_text(&mut self, mut stable: &str) -> ReasoningDelta {
102 + ///
103 + /// Reasoning and content pieces keep the attributions of the tokens that
104 + /// produced them; delimiter marker spans are drained and dropped, keeping
105 + /// marker tokens out of any count.
106 + fn parse_stable_text(&mut self, mut stable: DecodedText) -> ReasoningDelta {
107 let mut delta = ReasoningDelta::default();
108
105 - while !stable.is_empty() {
109 + while !stable.text.is_empty() {
110 if self.current_in_reasoning {
107 - if let Some(end_idx) = stable.find(&self.end_token) {
108 - delta.push_reasoning(&stable[..end_idx]);
109 - stable = &stable[end_idx + self.end_token.len()..];
111 + if let Some(end_idx) = stable.text.find(&self.end_token) {
112 + delta.push_reasoning(stable.drain_prefix(end_idx));
113 + let _ = stable.drain_prefix(self.end_token.len());
114 self.current_in_reasoning = false;
115 } else {
116 delta.push_reasoning(stable);
113 - break;
117 + return delta;
118 }
115 - } else if let Some(start_idx) = stable.find(&self.start_token) {
116 - delta.push_content(&stable[..start_idx]);
117 - stable = &stable[start_idx + self.start_token.len()..];
119 + } else if let Some(start_idx) = stable.text.find(&self.start_token) {
120 + delta.push_content(stable.drain_prefix(start_idx));
121 + let _ = stable.drain_prefix(self.start_token.len());
122 self.current_in_reasoning = true;
123 } else {
124 delta.push_content(stable);
121 - break;
125 + return delta;
126 + }
127 + }
128 +
129 + // A remainder with empty text may still carry zero-width tokens;
130 + // attribute them to the current state.
131 + if !stable.attributions.is_empty() {
132 + if self.current_in_reasoning {
133 + delta.push_reasoning(stable);
134 + } else {
135 + delta.push_content(stable);
136 }
137 }
138
rust/src/parser/src/reasoning/hy.rs
+2 -2
@@ -1,7 +1,7 @@
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -use vllm_tokenizer::DynTokenizer;
4 +use vllm_tokenizer::{DecodedText, DynTokenizer};
5
6 use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningError, ReasoningParser, Result};
7
@@ -41,7 +41,7 @@ impl ReasoningParser for HyReasoningParser {
41 Ok(())
42 }
43
44 - fn push(&mut self, delta: &str) -> Result<ReasoningDelta> {
44 + fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta> {
45 Ok(self.inner.push(delta))
46 }
47
rust/src/parser/src/reasoning/kimi.rs
+2 -2
@@ -1,7 +1,7 @@
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -use vllm_tokenizer::DynTokenizer;
4 +use vllm_tokenizer::{DecodedText, DynTokenizer};
5
6 use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result};
7
@@ -32,7 +32,7 @@ impl ReasoningParser for KimiReasoningParser {
32 Ok(())
33 }
34
35 - fn push(&mut self, delta: &str) -> Result<ReasoningDelta> {
35 + fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta> {
36 Ok(self.inner.push(delta))
37 }
38
rust/src/parser/src/reasoning/minimax_m3.rs
+17 -18
@@ -1,7 +1,7 @@
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -use vllm_tokenizer::DynTokenizer;
4 +use vllm_tokenizer::{DecodedText, DynTokenizer};
5
6 use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result};
7
@@ -20,7 +20,7 @@ pub struct MiniMaxM3ReasoningParser {
20 at_response_start: bool,
21 /// Holds an initial suffix like `</mm` while it may still complete into the
22 /// leading closer on a later chunk.
23 - leading_end_buffer: String,
23 + leading_end_buffer: DecodedText,
24 }
25
26 impl MiniMaxM3ReasoningParser {
@@ -29,31 +29,30 @@ impl MiniMaxM3ReasoningParser {
29 Ok(Self {
30 inner: DelimitedReasoningParser::new(tokenizer, M3_THINK_START, M3_THINK_END, false)?,
31 at_response_start: true,
32 - leading_end_buffer: String::new(),
32 + leading_end_buffer: DecodedText::default(),
33 })
34 }
35
36 /// Drop a response-leading `</mm:think>` while preserving later unmatched
37 /// closers as ordinary content.
38 - fn push_inner(&mut self, delta: &str) -> ReasoningDelta {
38 + fn push_inner(&mut self, delta: DecodedText) -> ReasoningDelta {
39 if self.at_response_start && !self.inner.in_reasoning() {
40 - self.leading_end_buffer.push_str(delta);
41 - let buffered = std::mem::take(&mut self.leading_end_buffer);
40 + self.leading_end_buffer.append(delta);
41 + let mut buffered = self.leading_end_buffer.take();
42
43 - if buffered.is_empty() {
44 - return ReasoningDelta::default();
45 - }
46 - if let Some(rest) = buffered.strip_prefix(M3_THINK_END) {
43 + if buffered.text.starts_with(M3_THINK_END) {
44 self.at_response_start = false;
48 - return self.inner.push(rest);
45 + // The dropped marker span keeps its tokens out of any count.
46 + let _ = buffered.drain_prefix(M3_THINK_END.len());
47 + return self.inner.push(buffered);
48 }
50 - if M3_THINK_END.starts_with(buffered.as_str()) {
49 + if M3_THINK_END.starts_with(buffered.text.as_str()) {
50 self.leading_end_buffer = buffered;
51 return ReasoningDelta::default();
52 }
53
54 self.at_response_start = false;
56 - return self.inner.push(&buffered);
55 + return self.inner.push(buffered);
56 }
57
58 self.inner.push(delta)
@@ -62,10 +61,10 @@ impl MiniMaxM3ReasoningParser {
61
62 fn append_delta(target: &mut ReasoningDelta, delta: ReasoningDelta) {
63 if let Some(reasoning) = delta.reasoning {
65 - target.push_reasoning(&reasoning);
64 + target.push_reasoning(reasoning);
65 }
66 if let Some(content) = delta.content {
68 - target.push_content(&content);
67 + target.push_content(content);
68 }
69 }
70
@@ -84,16 +83,16 @@ impl ReasoningParser for MiniMaxM3ReasoningParser {
83 Ok(())
84 }
85
87 - fn push(&mut self, delta: &str) -> Result<ReasoningDelta> {
86 + fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta> {
87 Ok(self.push_inner(delta))
88 }
89
90 fn finish(&mut self) -> Result<ReasoningDelta> {
91 let mut delta = ReasoningDelta::default();
92 if !self.leading_end_buffer.is_empty() {
94 - let pending = std::mem::take(&mut self.leading_end_buffer);
93 + let pending = self.leading_end_buffer.take();
94 self.at_response_start = false;
96 - append_delta(&mut delta, self.inner.push(&pending));
95 + append_delta(&mut delta, self.inner.push(pending));
96 }
97 append_delta(&mut delta, self.inner.finish());
98 Ok(delta)
rust/src/parser/src/reasoning/mod.rs
+22 -12
@@ -28,7 +28,7 @@ mod seed_oss;
28 mod step3p5;
29
30 use thiserror::Error;
31 -use vllm_tokenizer::DynTokenizer;
31 +use vllm_tokenizer::{DecodedText, DynTokenizer};
32
33 pub use self::cohere_cmd::CohereCmdReasoningParser;
34 pub use self::deepseek_r1::DeepSeekR1ReasoningParser;
@@ -61,10 +61,14 @@ pub type Step3ReasoningParser = Qwen3ReasoningParser;
61 pub type Result<T> = std::result::Result<T, ReasoningError>;
62
63 /// One parsed streaming delta split into reasoning and visible content.
64 +///
65 +/// Each portion carries the attributions of the generated tokens that produced
66 +/// it, so downstream consumers can count reasoning tokens exactly. Marker
67 +/// spans are dropped by the parsers, keeping marker tokens out of any count.
68 #[derive(Debug, Default, Clone, PartialEq, Eq)]
69 pub struct ReasoningDelta {
66 - pub reasoning: Option<String>,
67 - pub content: Option<String>,
70 + pub reasoning: Option<DecodedText>,
71 + pub content: Option<DecodedText>,
72 }
73
74 impl ReasoningDelta {
@@ -74,24 +78,30 @@ impl ReasoningDelta {
78 }
79
80 /// Append text to the reasoning portion, creating it on first use.
77 - pub(crate) fn push_reasoning(&mut self, text: &str) {
78 - if text.is_empty() {
81 + ///
82 + /// A piece with empty text but non-empty attributions (zero-width tokens
83 + /// only) is still kept: the tokens are attributed to the current state.
84 + pub(crate) fn push_reasoning(&mut self, piece: DecodedText) {
85 + if piece.is_empty() {
86 return;
87 }
88 match &mut self.reasoning {
82 - Some(existing) => existing.push_str(text),
83 - None => self.reasoning = Some(text.to_string()),
89 + Some(existing) => existing.append(piece),
90 + None => self.reasoning = Some(piece),
91 }
92 }
93
94 /// Append text to the visible content portion, creating it on first use.
88 - pub(crate) fn push_content(&mut self, text: &str) {
89 - if text.is_empty() {
95 + ///
96 + /// A piece with empty text but non-empty attributions (zero-width tokens
97 + /// only) is still kept: the tokens are attributed to the current state.
98 + pub(crate) fn push_content(&mut self, piece: DecodedText) {
99 + if piece.is_empty() {
100 return;
101 }
102 match &mut self.content {
93 - Some(existing) => existing.push_str(text),
94 - None => self.content = Some(text.to_string()),
103 + Some(existing) => existing.append(piece),
104 + None => self.content = Some(piece),
105 }
106 }
107 }
@@ -119,7 +129,7 @@ pub trait ReasoningParser: Send {
129 }
130
131 /// Feed one decoded text delta into the parser.
122 - fn push(&mut self, delta: &str) -> Result<ReasoningDelta>;
132 + fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta>;
133
134 /// Flush any buffered partial delimiter state at end of stream.
135 fn finish(&mut self) -> Result<ReasoningDelta> {
rust/src/parser/src/reasoning/qwen3.rs
+2 -2
@@ -1,7 +1,7 @@
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -use vllm_tokenizer::DynTokenizer;
4 +use vllm_tokenizer::{DecodedText, DynTokenizer};
5
6 use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result};
7
@@ -36,7 +36,7 @@ impl ReasoningParser for Qwen3ReasoningParser {
36 Ok(())
37 }
38
39 - fn push(&mut self, delta: &str) -> Result<ReasoningDelta> {
39 + fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta> {
40 Ok(self.inner.push(delta))
41 }
42
rust/src/parser/src/reasoning/seed_oss.rs
+23 -20
@@ -1,7 +1,7 @@
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -use vllm_tokenizer::DynTokenizer;
4 +use vllm_tokenizer::{DecodedText, DynTokenizer};
5
6 use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result};
7
@@ -38,7 +38,7 @@ impl ReasoningParser for SeedOssReasoningParser {
38 Ok(())
39 }
40
41 - fn push(&mut self, delta: &str) -> Result<ReasoningDelta> {
41 + fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta> {
42 Ok(self.inner.push(delta))
43 }
44
@@ -53,17 +53,20 @@ mod tests {
53
54 use super::SeedOssReasoningParser;
55 use crate::reasoning::ReasoningParser;
56 - use crate::reasoning::tests::{SEED_THINK_END_ID, SEED_THINK_START_ID, fake_tokenizer};
56 + use crate::reasoning::tests::{
57 + SEED_THINK_END_ID, SEED_THINK_START_ID, content_str, fake_tokenizer, push_str,
58 + reasoning_str,
59 + };
60
61 #[test]
62 fn without_prompt_markers_expects_start_token() {
63 let tokenizer = Arc::new(fake_tokenizer());
64 let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap();
65
63 - let delta = parser.push("implicit reasoning</seed:think>answer").unwrap();
66 + let delta = push_str(&mut parser, "implicit reasoning</seed:think>answer");
67 assert_eq!(delta.reasoning, None);
68 assert_eq!(
66 - delta.content.as_deref(),
69 + content_str(&delta),
70 Some("implicit reasoning</seed:think>answer")
71 );
72 }
@@ -75,9 +78,9 @@ mod tests {
78 // Prompt prefills `<seed:think>`, opening reasoning before the stream.
79 parser.initialize(&[SEED_THINK_START_ID]).unwrap();
80
78 - let delta = parser.push("reason</seed:think>answer").unwrap();
79 - assert_eq!(delta.reasoning.as_deref(), Some("reason"));
80 - assert_eq!(delta.content.as_deref(), Some("answer"));
81 + let delta = push_str(&mut parser, "reason</seed:think>answer");
82 + assert_eq!(reasoning_str(&delta), Some("reason"));
83 + assert_eq!(content_str(&delta), Some("answer"));
84 }
85
86 #[test]
@@ -87,9 +90,9 @@ mod tests {
90 // Prompt already closed reasoning with `</seed:think>`.
91 parser.initialize(&[SEED_THINK_END_ID]).unwrap();
92
90 - let delta = parser.push("answer").unwrap();
93 + let delta = push_str(&mut parser, "answer");
94 assert_eq!(delta.reasoning, None);
92 - assert_eq!(delta.content.as_deref(), Some("answer"));
95 + assert_eq!(content_str(&delta), Some("answer"));
96 }
97
98 #[test]
@@ -98,9 +101,9 @@ mod tests {
101 let tokenizer = Arc::new(fake_tokenizer());
102 let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap();
103
101 - let delta = parser.push("<seed:think>reason</seed:think>answer").unwrap();
102 - assert_eq!(delta.reasoning.as_deref(), Some("reason"));
103 - assert_eq!(delta.content.as_deref(), Some("answer"));
104 + let delta = push_str(&mut parser, "<seed:think>reason</seed:think>answer");
105 + assert_eq!(reasoning_str(&delta), Some("reason"));
106 + assert_eq!(content_str(&delta), Some("answer"));
107 }
108
109 #[test]
@@ -121,12 +124,12 @@ mod tests {
124 "Final ",
125 "answer",
126 ] {
124 - let delta = parser.push(delta_str).unwrap();
127 + let delta = push_str(&mut parser, delta_str);
128 if let Some(r) = delta.reasoning {
126 - reasoning.push_str(&r);
129 + reasoning.push_str(&r.text);
130 }
131 if let Some(c) = delta.content {
129 - content.push_str(&c);
132 + content.push_str(&c.text);
133 }
134 }
135 assert_eq!(reasoning, "Some reasoning content");
@@ -140,12 +143,12 @@ mod tests {
143 parser.initialize(&[SEED_THINK_START_ID]).unwrap();
144
145 // Closing delimiter `</seed:think>` arrives in two halves.
143 - let first = parser.push("reason</seed:").unwrap();
144 - assert_eq!(first.reasoning.as_deref(), Some("reason"));
146 + let first = push_str(&mut parser, "reason</seed:");
147 + assert_eq!(reasoning_str(&first), Some("reason"));
148 assert_eq!(first.content, None);
149
147 - let second = parser.push("think>answer").unwrap();
150 + let second = push_str(&mut parser, "think>answer");
151 assert_eq!(second.reasoning, None);
149 - assert_eq!(second.content.as_deref(), Some("answer"));
152 + assert_eq!(content_str(&second), Some("answer"));
153 }
154 }
rust/src/parser/src/reasoning/step3p5.rs
+69 -65
@@ -1,7 +1,7 @@
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -use vllm_tokenizer::DynTokenizer;
4 +use vllm_tokenizer::{DecodedText, DynTokenizer};
5
6 use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result};
7
@@ -16,7 +16,7 @@ use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result};
16 pub struct Step3p5ReasoningParser {
17 inner: DelimitedReasoningParser,
18 /// `\n` at end of last reasoning delta, held in case `</think>` follows.
19 - pending_reasoning_newline: bool,
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,
@@ -27,7 +27,7 @@ impl Step3p5ReasoningParser {
27 pub fn new(tokenizer: DynTokenizer) -> Result<Self> {
28 Ok(Self {
29 inner: DelimitedReasoningParser::new(tokenizer, "<think>", "</think>", false)?,
30 - pending_reasoning_newline: false,
30 + pending_reasoning_newline: None,
31 just_ended_reasoning: false,
32 })
33 }
@@ -45,40 +45,42 @@ impl Step3p5ReasoningParser {
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 {
48 + if self.pending_reasoning_newline.is_some() {
49 if let Some(reasoning) = inner_delta.reasoning.as_mut() {
50 - reasoning.insert(0, '\n');
51 - self.pending_reasoning_newline = false;
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.
54 - self.pending_reasoning_newline = false;
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()
60 - && reasoning.ends_with('\n')
61 + && reasoning.text.ends_with('\n')
62 {
62 - reasoning.pop();
63 + let kept = reasoning.drain_prefix(reasoning.text.len() - 1);
64 + let newline = std::mem::replace(reasoning, kept);
65 if !transitioned {
64 - self.pending_reasoning_newline = true;
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)
71 - && content.starts_with('\n')
73 + && content.text.starts_with('\n')
74 {
73 - content.remove(0);
75 + let _ = content.drain_prefix(1);
76 }
77
78 self.just_ended_reasoning = transitioned && inner_delta.content.is_none();
79
78 - if inner_delta.reasoning.as_deref() == Some("") {
80 + if inner_delta.reasoning.as_ref().is_some_and(DecodedText::is_empty) {
81 inner_delta.reasoning = None;
82 }
81 - if inner_delta.content.as_deref() == Some("") {
83 + if inner_delta.content.as_ref().is_some_and(DecodedText::is_empty) {
84 inner_delta.content = None;
85 }
86
@@ -99,7 +101,7 @@ impl ReasoningParser for Step3p5ReasoningParser {
101 Ok(())
102 }
103
102 - fn push(&mut self, delta: &str) -> Result<ReasoningDelta> {
104 + 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();
@@ -113,12 +115,11 @@ impl ReasoningParser for Step3p5ReasoningParser {
115 let mut delta = self.process(inner_delta, was, now);
116
117 // Emit a still-held newline rather than silently dropping it.
116 - if self.pending_reasoning_newline {
118 + if let Some(held) = self.pending_reasoning_newline.take() {
119 match delta.reasoning.as_mut() {
118 - Some(existing) => existing.push('\n'),
119 - None => delta.reasoning = Some("\n".to_string()),
120 + Some(existing) => existing.append(held),
121 + None => delta.reasoning = Some(held),
122 }
121 - self.pending_reasoning_newline = false;
123 }
124
125 Ok(delta)
@@ -131,7 +132,9 @@ mod tests {
132
133 use super::Step3p5ReasoningParser;
134 use crate::reasoning::ReasoningParser;
134 - use crate::reasoning::tests::{THINK_START_ID, fake_tokenizer};
135 + use crate::reasoning::tests::{
136 + THINK_START_ID, content_str, fake_tokenizer, push_str, reasoning_str,
137 + };
138
139 #[test]
140 fn picks_up_prompt_start_boundary() {
@@ -140,12 +143,12 @@ mod tests {
143 // Prompt prefills `<think>`, opening reasoning before the stream.
144 parser.initialize(&[THINK_START_ID]).unwrap();
145
143 - let delta = parser.push("This is a reasoning section</think>This is the rest").unwrap();
144 - assert_eq!(
145 - delta.reasoning.as_deref(),
146 - Some("This is a reasoning section")
146 + let delta = push_str(
147 + &mut parser,
148 + "This is a reasoning section</think>This is the rest",
149 );
148 - assert_eq!(delta.content.as_deref(), Some("This is the rest"));
150 + assert_eq!(reasoning_str(&delta), Some("This is a reasoning section"));
151 + assert_eq!(content_str(&delta), Some("This is the rest"));
152 }
153
154 #[test]
@@ -153,8 +156,8 @@ mod tests {
156 let tokenizer = Arc::new(fake_tokenizer());
157 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
158
156 - let pushed = parser.push("<think>reason without end").unwrap();
157 - assert_eq!(pushed.reasoning.as_deref(), Some("reason without end"));
159 + let pushed = push_str(&mut parser, "<think>reason without end");
160 + assert_eq!(reasoning_str(&pushed), Some("reason without end"));
161 assert_eq!(pushed.content, None);
162
163 let flushed = parser.finish().unwrap();
@@ -166,7 +169,7 @@ mod tests {
169 let tokenizer = Arc::new(fake_tokenizer());
170 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
171
169 - let pushed = parser.push("").unwrap();
172 + let pushed = push_str(&mut parser, "");
173 assert!(pushed.is_empty());
174 let flushed = parser.finish().unwrap();
175 assert!(flushed.is_empty());
@@ -180,14 +183,15 @@ mod tests {
183 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
184 parser.initialize(&[THINK_START_ID]).unwrap();
185
183 - let delta = parser
184 - .push("\n This is a \n reasoning section\n\n\n</think>\n\nThis is the rest")
185 - .unwrap();
186 + let delta = push_str(
187 + &mut parser,
188 + "\n This is a \n reasoning section\n\n\n</think>\n\nThis is the rest",
189 + );
190 assert_eq!(
187 - delta.reasoning.as_deref(),
191 + reasoning_str(&delta),
192 Some("\n This is a \n reasoning section\n\n")
193 );
190 - assert_eq!(delta.content.as_deref(), Some("\nThis is the rest"));
194 + assert_eq!(content_str(&delta), Some("\nThis is the rest"));
195 }
196
197 #[test]
@@ -195,9 +199,9 @@ mod tests {
199 let tokenizer = Arc::new(fake_tokenizer());
200 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
201
198 - let delta = parser.push("<think>reason\n</think>\nanswer").unwrap();
199 - assert_eq!(delta.reasoning.as_deref(), Some("reason"));
200 - assert_eq!(delta.content.as_deref(), Some("answer"));
202 + let delta = push_str(&mut parser, "<think>reason\n</think>\nanswer");
203 + assert_eq!(reasoning_str(&delta), Some("reason"));
204 + assert_eq!(content_str(&delta), Some("answer"));
205 }
206
207 #[test]
@@ -207,18 +211,18 @@ mod tests {
211
212 // The trailing `\n` from the first push is held until we know whether
213 // `</think>` follows.
210 - let first = parser.push("<think>reason\n").unwrap();
211 - assert_eq!(first.reasoning.as_deref(), Some("reason"));
214 + let first = push_str(&mut parser, "<think>reason\n");
215 + assert_eq!(reasoning_str(&first), Some("reason"));
216 assert_eq!(first.content, None);
217
218 // `</think>` arrives standalone; the held newline should be dropped.
215 - let second = parser.push("</think>").unwrap();
219 + let second = push_str(&mut parser, "</think>");
220 assert!(second.is_empty());
221
222 // The leading newline of the first content delta is dropped.
219 - let third = parser.push("\nanswer").unwrap();
223 + let third = push_str(&mut parser, "\nanswer");
224 assert_eq!(third.reasoning, None);
221 - assert_eq!(third.content.as_deref(), Some("answer"));
225 + assert_eq!(content_str(&third), Some("answer"));
226 }
227
228 #[test]
@@ -226,11 +230,11 @@ mod tests {
230 let tokenizer = Arc::new(fake_tokenizer());
231 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
232
229 - let first = parser.push("<think>reason\n").unwrap();
230 - assert_eq!(first.reasoning.as_deref(), Some("reason"));
233 + let first = push_str(&mut parser, "<think>reason\n");
234 + assert_eq!(reasoning_str(&first), Some("reason"));
235
232 - let second = parser.push("more reason").unwrap();
233 - assert_eq!(second.reasoning.as_deref(), Some("\nmore reason"));
236 + let second = push_str(&mut parser, "more reason");
237 + assert_eq!(reasoning_str(&second), Some("\nmore reason"));
238 assert_eq!(second.content, None);
239 }
240
@@ -239,11 +243,11 @@ mod tests {
243 let tokenizer = Arc::new(fake_tokenizer());
244 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
245
242 - let first = parser.push("<think>reason\n").unwrap();
243 - assert_eq!(first.reasoning.as_deref(), Some("reason"));
246 + let first = push_str(&mut parser, "<think>reason\n");
247 + assert_eq!(reasoning_str(&first), Some("reason"));
248
249 let flushed = parser.finish().unwrap();
246 - assert_eq!(flushed.reasoning.as_deref(), Some("\n"));
250 + assert_eq!(reasoning_str(&flushed), Some("\n"));
251 assert_eq!(flushed.content, None);
252 }
253
@@ -252,9 +256,9 @@ mod tests {
256 let tokenizer = Arc::new(fake_tokenizer());
257 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
258
255 - let delta = parser.push("<think>line1\nline2</think>tail").unwrap();
256 - assert_eq!(delta.reasoning.as_deref(), Some("line1\nline2"));
257 - assert_eq!(delta.content.as_deref(), Some("tail"));
259 + let delta = push_str(&mut parser, "<think>line1\nline2</think>tail");
260 + assert_eq!(reasoning_str(&delta), Some("line1\nline2"));
261 + assert_eq!(content_str(&delta), Some("tail"));
262 }
263
264 #[test]
@@ -264,9 +268,9 @@ mod tests {
268 let tokenizer = Arc::new(fake_tokenizer());
269 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
270
267 - let delta = parser.push("<think>reason\n\n</think>answer").unwrap();
268 - assert_eq!(delta.reasoning.as_deref(), Some("reason\n"));
269 - assert_eq!(delta.content.as_deref(), Some("answer"));
271 + let delta = push_str(&mut parser, "<think>reason\n\n</think>answer");
272 + assert_eq!(reasoning_str(&delta), Some("reason\n"));
273 + assert_eq!(content_str(&delta), Some("answer"));
274 }
275
276 #[test]
@@ -276,18 +280,18 @@ mod tests {
280 let tokenizer = Arc::new(fake_tokenizer());
281 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
282
279 - let first = parser.push("<think>reason</think>").unwrap();
280 - assert_eq!(first.reasoning.as_deref(), Some("reason"));
283 + let first = push_str(&mut parser, "<think>reason</think>");
284 + assert_eq!(reasoning_str(&first), Some("reason"));
285 assert_eq!(first.content, None);
286
283 - let second = parser.push("\nfirst").unwrap();
287 + let second = push_str(&mut parser, "\nfirst");
288 assert_eq!(second.reasoning, None);
285 - assert_eq!(second.content.as_deref(), Some("first"));
289 + assert_eq!(content_str(&second), Some("first"));
290
291 // A `\n` arriving in a later content delta must NOT be dropped.
288 - let third = parser.push("\nsecond").unwrap();
292 + let third = push_str(&mut parser, "\nsecond");
293 assert_eq!(third.reasoning, None);
290 - assert_eq!(third.content.as_deref(), Some("\nsecond"));
294 + assert_eq!(content_str(&third), Some("\nsecond"));
295 }
296
297 #[test]
@@ -295,9 +299,9 @@ mod tests {
299 let tokenizer = Arc::new(fake_tokenizer());
300 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
301
298 - let delta = parser.push("<think>reason</think>tail").unwrap();
299 - assert_eq!(delta.reasoning.as_deref(), Some("reason"));
300 - assert_eq!(delta.content.as_deref(), Some("tail"));
302 + let delta = push_str(&mut parser, "<think>reason</think>tail");
303 + assert_eq!(reasoning_str(&delta), Some("reason"));
304 + assert_eq!(content_str(&delta), Some("tail"));
305 }
306
307 #[test]
@@ -305,8 +309,8 @@ mod tests {
309 let tokenizer = Arc::new(fake_tokenizer());
310 let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
311
308 - let delta = parser.push("<think></think>answer").unwrap();
312 + let delta = push_str(&mut parser, "<think></think>answer");
313 assert_eq!(delta.reasoning, None);
310 - assert_eq!(delta.content.as_deref(), Some("answer"));
314 + assert_eq!(content_str(&delta), Some("answer"));
315 }
316 }
rust/src/parser/src/reasoning/tests.rs
+254 -49
@@ -4,10 +4,12 @@
4 use std::sync::Arc;
5
6 use vllm_tokenizer::test_utils::TestTokenizer;
7 +use vllm_tokenizer::{DecodedText, DynTokenizer, TokenAnchor, TokenAttribution};
8
9 use super::{
9 - DeepSeekR1ReasoningParser, DelimitedReasoningParser, MiniMaxM3ReasoningParser,
10 - Qwen3ReasoningParser, ReasoningParser,
10 + CohereCmdReasoningParser, DeepSeekR1ReasoningParser, DelimitedReasoningParser,
11 + KimiReasoningParser, MiniMaxM3ReasoningParser, Qwen3ReasoningParser, ReasoningDelta,
12 + ReasoningParser, Result, SeedOssReasoningParser, Step3p5ReasoningParser,
13 };
14
15 pub(crate) const THINK_START_ID: u32 = 256;
@@ -37,16 +39,29 @@ pub(crate) fn fake_tokenizer() -> TestTokenizer {
39 .with_regular_token("</seed:think>", SEED_THINK_END_ID)
40 }
41
42 +/// Feed an unattributed text delta into a reasoning parser.
43 +pub(crate) fn push_str<P: ReasoningParser + ?Sized>(parser: &mut P, delta: &str) -> ReasoningDelta {
44 + parser.push(DecodedText::unattributed(delta)).unwrap()
45 +}
46 +
47 +/// Return the reasoning text of a delta, if any.
48 +pub(crate) fn reasoning_str(delta: &ReasoningDelta) -> Option<&str> {
49 + delta.reasoning.as_ref().map(|piece| piece.text.as_str())
50 +}
51 +
52 +/// Return the content text of a delta, if any.
53 +pub(crate) fn content_str(delta: &ReasoningDelta) -> Option<&str> {
54 + delta.content.as_ref().map(|piece| piece.text.as_str())
55 +}
56 +
57 #[test]
58 fn delimited_content_only_stream() {
59 let tokenizer = Arc::new(fake_tokenizer());
60 let mut parser =
61 DelimitedReasoningParser::new(tokenizer, "<think>", "</think>", false).unwrap();
62
46 - assert_eq!(
47 - parser.push("plain content").content.as_deref(),
48 - Some("plain content")
49 - );
63 + let delta = parser.push(DecodedText::unattributed("plain content"));
64 + assert_eq!(content_str(&delta), Some("plain content"));
65 }
66
67 #[test]
@@ -55,9 +70,9 @@ fn delimited_single_chunk_with_reasoning_and_content() {
70 let mut parser =
71 DelimitedReasoningParser::new(tokenizer, "<think>", "</think>", false).unwrap();
72
58 - let delta = parser.push("<think>reason</think>answer");
59 - assert_eq!(delta.reasoning.as_deref(), Some("reason"));
60 - assert_eq!(delta.content.as_deref(), Some("answer"));
73 + let delta = parser.push(DecodedText::unattributed("<think>reason</think>answer"));
74 + assert_eq!(reasoning_str(&delta), Some("reason"));
75 + assert_eq!(content_str(&delta), Some("answer"));
76 }
77
78 #[test]
@@ -66,10 +81,10 @@ fn delimited_partial_tokens_across_chunks() {
81 let mut parser =
82 DelimitedReasoningParser::new(tokenizer, "<think>", "</think>", false).unwrap();
83
69 - assert!(parser.push("<thi").is_empty());
70 - let delta = parser.push("nk>reason</think>answer");
71 - assert_eq!(delta.reasoning.as_deref(), Some("reason"));
72 - assert_eq!(delta.content.as_deref(), Some("answer"));
84 + assert!(parser.push(DecodedText::unattributed("<thi")).is_empty());
85 + let delta = parser.push(DecodedText::unattributed("nk>reason</think>answer"));
86 + assert_eq!(reasoning_str(&delta), Some("reason"));
87 + assert_eq!(content_str(&delta), Some("answer"));
88 }
89
90 #[test]
@@ -79,10 +94,10 @@ fn delimited_finish_flushes_buffer() {
94 DelimitedReasoningParser::new(tokenizer, "<think>", "</think>", false).unwrap();
95 parser.initialize(&[THINK_START_ID]);
96
82 - let delta = parser.push("unfinished</thi");
83 - assert_eq!(delta.reasoning.as_deref(), Some("unfinished"));
97 + let delta = parser.push(DecodedText::unattributed("unfinished</thi"));
98 + assert_eq!(reasoning_str(&delta), Some("unfinished"));
99 let final_delta = parser.finish();
85 - assert_eq!(final_delta.reasoning.as_deref(), Some("</thi"));
100 + assert_eq!(reasoning_str(&final_delta), Some("</thi"));
101 }
102
103 #[test]
@@ -90,9 +105,9 @@ fn qwen3_without_prompt_markers_expects_start_token() {
105 let tokenizer = Arc::new(fake_tokenizer());
106 let mut parser = Qwen3ReasoningParser::new(tokenizer).unwrap();
107
93 - let delta = parser.push("reason</think>answer").unwrap();
108 + let delta = push_str(&mut parser, "reason</think>answer");
109 assert_eq!(delta.reasoning, None);
95 - assert_eq!(delta.content.as_deref(), Some("reason</think>answer"));
110 + assert_eq!(content_str(&delta), Some("reason</think>answer"));
111 }
112
113 #[test]
@@ -101,9 +116,9 @@ fn qwen3_prompt_end_marker_starts_in_content() {
116 let mut parser = Qwen3ReasoningParser::new(tokenizer).unwrap();
117 parser.initialize(&[THINK_END_ID]).unwrap();
118
104 - let delta = parser.push("answer").unwrap();
119 + let delta = push_str(&mut parser, "answer");
120 assert_eq!(delta.reasoning, None);
106 - assert_eq!(delta.content.as_deref(), Some("answer"));
121 + assert_eq!(content_str(&delta), Some("answer"));
122 }
123
124 #[test]
@@ -111,15 +126,15 @@ fn qwen3_tolerates_old_and_new_formats() {
126 let tokenizer = Arc::new(fake_tokenizer());
127
128 let mut old_parser = Qwen3ReasoningParser::new(tokenizer.clone()).unwrap();
114 - let old = old_parser.push("<think>reason</think>answer").unwrap();
115 - assert_eq!(old.reasoning.as_deref(), Some("reason"));
116 - assert_eq!(old.content.as_deref(), Some("answer"));
129 + let old = push_str(&mut old_parser, "<think>reason</think>answer");
130 + assert_eq!(reasoning_str(&old), Some("reason"));
131 + assert_eq!(content_str(&old), Some("answer"));
132
133 let mut new_parser = Qwen3ReasoningParser::new(tokenizer).unwrap();
134 new_parser.initialize(&[THINK_START_ID]).unwrap();
120 - let new = new_parser.push("reason</think>answer").unwrap();
121 - assert_eq!(new.reasoning.as_deref(), Some("reason"));
122 - assert_eq!(new.content.as_deref(), Some("answer"));
135 + let new = push_str(&mut new_parser, "reason</think>answer");
136 + assert_eq!(reasoning_str(&new), Some("reason"));
137 + assert_eq!(content_str(&new), Some("answer"));
138 }
139
140 #[test]
@@ -129,9 +144,9 @@ fn qwen3_stops_scanning_at_last_special_token() {
144
145 parser.initialize(&[THINK_START_ID, SPECIAL_BOUNDARY_ID]).unwrap();
146
132 - let delta = parser.push("answer").unwrap();
147 + let delta = push_str(&mut parser, "answer");
148 assert_eq!(delta.reasoning, None);
134 - assert_eq!(delta.content.as_deref(), Some("answer"));
149 + assert_eq!(content_str(&delta), Some("answer"));
150 }
151
152 #[test]
@@ -139,9 +154,9 @@ fn deepseek_r1_defaults_to_reasoning_without_prompt_boundary() {
154 let tokenizer = Arc::new(fake_tokenizer());
155 let mut parser = DeepSeekR1ReasoningParser::new(tokenizer).unwrap();
156
142 - let delta = parser.push("reason</think>answer").unwrap();
143 - assert_eq!(delta.reasoning.as_deref(), Some("reason"));
144 - assert_eq!(delta.content.as_deref(), Some("answer"));
157 + let delta = push_str(&mut parser, "reason</think>answer");
158 + assert_eq!(reasoning_str(&delta), Some("reason"));
159 + assert_eq!(content_str(&delta), Some("answer"));
160 }
161
162 #[test]
@@ -151,9 +166,9 @@ fn deepseek_r1_stops_scanning_at_last_special_token() {
166
167 parser.initialize(&[THINK_END_ID, SPECIAL_BOUNDARY_ID]).unwrap();
168
154 - let delta = parser.push("reason</think>answer").unwrap();
155 - assert_eq!(delta.reasoning.as_deref(), Some("reason"));
156 - assert_eq!(delta.content.as_deref(), Some("answer"));
169 + let delta = push_str(&mut parser, "reason</think>answer");
170 + assert_eq!(reasoning_str(&delta), Some("reason"));
171 + assert_eq!(content_str(&delta), Some("answer"));
172 }
173
174 #[test]
@@ -161,9 +176,9 @@ fn minimax_m3_handles_explicit_think_delimiters() {
176 let tokenizer = Arc::new(fake_tokenizer());
177 let mut parser = MiniMaxM3ReasoningParser::new(tokenizer).unwrap();
178
164 - let delta = parser.push("<mm:think>reason</mm:think>answer").unwrap();
165 - assert_eq!(delta.reasoning.as_deref(), Some("reason"));
166 - assert_eq!(delta.content.as_deref(), Some("answer"));
179 + let delta = push_str(&mut parser, "<mm:think>reason</mm:think>answer");
180 + assert_eq!(reasoning_str(&delta), Some("reason"));
181 + assert_eq!(content_str(&delta), Some("answer"));
182 }
183
184 #[test]
@@ -171,9 +186,9 @@ fn minimax_m3_drops_leading_end_marker() {
186 let tokenizer = Arc::new(fake_tokenizer());
187 let mut parser = MiniMaxM3ReasoningParser::new(tokenizer).unwrap();
188
174 - let delta = parser.push("</mm:think>answer").unwrap();
189 + let delta = push_str(&mut parser, "</mm:think>answer");
190 assert_eq!(delta.reasoning, None);
176 - assert_eq!(delta.content.as_deref(), Some("answer"));
191 + assert_eq!(content_str(&delta), Some("answer"));
192 }
193
194 #[test]
@@ -181,9 +196,9 @@ fn minimax_m3_preserves_non_leading_end_marker() {
196 let tokenizer = Arc::new(fake_tokenizer());
197 let mut parser = MiniMaxM3ReasoningParser::new(tokenizer).unwrap();
198
184 - let delta = parser.push("XXX</mm:think>YYY").unwrap();
199 + let delta = push_str(&mut parser, "XXX</mm:think>YYY");
200 assert_eq!(delta.reasoning, None);
186 - assert_eq!(delta.content.as_deref(), Some("XXX</mm:think>YYY"));
201 + assert_eq!(content_str(&delta), Some("XXX</mm:think>YYY"));
202 }
203
204 #[test]
@@ -191,10 +206,10 @@ fn minimax_m3_drops_split_leading_end_marker() {
206 let tokenizer = Arc::new(fake_tokenizer());
207 let mut parser = MiniMaxM3ReasoningParser::new(tokenizer).unwrap();
208
194 - assert!(parser.push("</mm").unwrap().is_empty());
195 - let delta = parser.push(":think>answer").unwrap();
209 + assert!(push_str(&mut parser, "</mm").is_empty());
210 + let delta = push_str(&mut parser, ":think>answer");
211 assert_eq!(delta.reasoning, None);
197 - assert_eq!(delta.content.as_deref(), Some("answer"));
212 + assert_eq!(content_str(&delta), Some("answer"));
213 }
214
215 #[test]
@@ -203,9 +218,9 @@ fn minimax_m3_uses_prompt_prefilled_start_marker() {
218 let mut parser = MiniMaxM3ReasoningParser::new(tokenizer).unwrap();
219 parser.initialize(&[MM_THINK_START_ID]).unwrap();
220
206 - let delta = parser.push("reason</mm:think>answer").unwrap();
207 - assert_eq!(delta.reasoning.as_deref(), Some("reason"));
208 - assert_eq!(delta.content.as_deref(), Some("answer"));
221 + let delta = push_str(&mut parser, "reason</mm:think>answer");
222 + assert_eq!(reasoning_str(&delta), Some("reason"));
223 + assert_eq!(content_str(&delta), Some("answer"));
224 }
225
226 #[test]
@@ -214,7 +229,197 @@ fn minimax_m3_uses_prompt_prefilled_end_marker() {
229 let mut parser = MiniMaxM3ReasoningParser::new(tokenizer).unwrap();
230 parser.initialize(&[MM_THINK_END_ID]).unwrap();
231
217 - let delta = parser.push("answer").unwrap();
232 + let delta = push_str(&mut parser, "answer");
233 assert_eq!(delta.reasoning, None);
219 - assert_eq!(delta.content.as_deref(), Some("answer"));
234 + assert_eq!(content_str(&delta), Some("answer"));
235 +}
236 +
237 +#[test]
238 +fn delimited_zero_width_only_piece_is_attributed_to_current_state() {
239 + let tokenizer = Arc::new(fake_tokenizer());
240 + let mut parser =
241 + DelimitedReasoningParser::new(tokenizer, "<think>", "</think>", false).unwrap();
242 + parser.initialize(&[THINK_START_ID]);
243 +
244 + // A filtered special token produces a zero-width attribution with no text;
245 + // it must survive as a reasoning piece rather than being dropped.
246 + let delta = parser.push(DecodedText {
247 + text: String::new(),
248 + attributions: [TokenAttribution {
249 + token_id: 42,
250 + anchor: TokenAnchor::ZeroWidth { byte_offset: 0 },
251 + }]
252 + .into_iter()
253 + .collect(),
254 + });
255 + assert_eq!(reasoning_str(&delta), Some(""));
256 + assert_eq!(
257 + delta
258 + .reasoning
259 + .as_ref()
260 + .unwrap()
261 + .attributions
262 + .iter()
263 + .map(|attr| attr.token_id)
264 + .collect::<Vec<_>>(),
265 + [42]
266 + );
267 +}
268 +
269 +#[test]
270 +fn delimited_marker_tokens_are_dropped_from_attributions() {
271 + let tokenizer = Arc::new(fake_tokenizer());
272 + let mut parser =
273 + DelimitedReasoningParser::new(tokenizer, "<think>", "</think>", false).unwrap();
274 +
275 + let mut collected = CollectedAttributions::default();
276 + for chunk in attributed_chunks(&[
277 + Some("<think>"),
278 + Some("reason"),
279 + Some("</think>"),
280 + Some("answer"),
281 + ]) {
282 + collected.record(parser.push(chunk));
283 + }
284 + collected.record(parser.finish());
285 +
286 + assert_eq!(collected.reasoning_text, "reason");
287 + assert_eq!(collected.content_text, "answer");
288 + // The marker tokens (1 and 3) are dropped with their spans; only the
289 + // reasoning and content tokens remain attributed.
290 + assert_eq!(collected.reasoning_ids, [2]);
291 + assert_eq!(collected.content_ids, [4]);
292 +}
293 +
294 +#[test]
295 +fn reasoning_parsers_conserve_token_attributions() {
296 + type ParserCase = (
297 + fn(DynTokenizer) -> Result<Box<dyn ReasoningParser>>,
298 + u32,
299 + &'static str,
300 + );
301 +
302 + let parsers: Vec<ParserCase> = vec![
303 + (Qwen3ReasoningParser::create, THINK_START_ID, "qwen3"),
304 + (
305 + DeepSeekR1ReasoningParser::create,
306 + THINK_START_ID,
307 + "deepseek_r1",
308 + ),
309 + (KimiReasoningParser::create, MINIMAX_THINK_START_ID, "kimi"),
310 + (
311 + CohereCmdReasoningParser::create,
312 + START_THINKING_ID,
313 + "cohere_cmd",
314 + ),
315 + (
316 + SeedOssReasoningParser::create,
317 + SEED_THINK_START_ID,
318 + "seed_oss",
319 + ),
320 + (
321 + MiniMaxM3ReasoningParser::create,
322 + MM_THINK_START_ID,
323 + "minimax_m3",
324 + ),
325 + (Step3p5ReasoningParser::create, THINK_START_ID, "step3p5"),
326 + ];
327 +
328 + for (create, think_start_id, name) in parsers {
329 + let mut parser = create(Arc::new(fake_tokenizer())).unwrap();
330 + // Prompt prefills the start marker, opening reasoning before the stream.
331 + parser.initialize(&[think_start_id]).unwrap();
332 +
333 + // Script: reasoning text (1), a zero-width token inside reasoning (2),
334 + // the end marker (3), then visible content (4). The zero-width token
335 + // is attributed to reasoning, matching Python's current-state rule;
336 + // the marker token is dropped with its span.
337 + let end_marker = match name {
338 + "kimi" => "◁/think▷",
339 + "cohere_cmd" => "<|END_THINKING|>",
340 + "seed_oss" => "</seed:think>",
341 + "minimax_m3" => "</mm:think>",
342 + _ => "</think>",
343 + };
344 + let chunks = attributed_chunks(&[Some("reason"), None, Some(end_marker), Some("answer")]);
345 +
346 + let collected = collect_attributed(parser.as_mut(), chunks);
347 +
348 + assert_eq!(collected.reasoning_text, "reason", "{name}");
349 + assert_eq!(collected.content_text, "answer", "{name}");
350 + assert_eq!(collected.reasoning_ids, [1, 2], "{name}");
351 + assert_eq!(collected.content_ids, [4], "{name}");
352 + }
353 +}
354 +
355 +/// Build one chunk per piece, each carrying a single token attributed to it.
356 +///
357 +/// A `Some(text)` piece produces one token visibly anchored at the piece start;
358 +/// a `None` piece produces one zero-width token. Token IDs are 1-based
359 +/// positions in the script.
360 +pub(crate) fn attributed_chunks(pieces: &[Option<&str>]) -> Vec<DecodedText> {
361 + pieces
362 + .iter()
363 + .enumerate()
364 + .map(|(index, piece)| {
365 + let token_id = index as u32 + 1;
366 + match piece {
367 + Some(text) => DecodedText {
368 + text: (*text).to_string(),
369 + attributions: [TokenAttribution {
370 + token_id,
371 + anchor: TokenAnchor::Visible { byte_offset: 0 },
372 + }]
373 + .into_iter()
374 + .collect(),
375 + },
376 + None => DecodedText {
377 + text: String::new(),
378 + attributions: [TokenAttribution {
379 + token_id,
380 + anchor: TokenAnchor::ZeroWidth { byte_offset: 0 },
381 + }]
382 + .into_iter()
383 + .collect(),
384 + },
385 + }
386 + })
387 + .collect()
388 +}
389 +
390 +/// Token attributions and text collected from a stream of reasoning deltas.
391 +#[derive(Default)]
392 +pub(crate) struct CollectedAttributions {
393 + pub reasoning_ids: Vec<u32>,
394 + pub content_ids: Vec<u32>,
395 + pub reasoning_text: String,
396 + pub content_text: String,
397 +}
398 +
399 +impl CollectedAttributions {
400 + pub fn record(&mut self, delta: ReasoningDelta) {
401 + if let Some(reasoning) = delta.reasoning {
402 + self.reasoning_ids
403 + .extend(reasoning.attributions.iter().map(|attr| attr.token_id));
404 + self.reasoning_text.push_str(&reasoning.text);
405 + }
406 + if let Some(content) = delta.content {
407 + self.content_ids.extend(content.attributions.iter().map(|attr| attr.token_id));
408 + self.content_text.push_str(&content.text);
409 + }
410 + }
411 +}
412 +
413 +/// Push attributed chunks through a reasoning parser and collect what is
414 +/// attributed to reasoning and content pieces.
415 +pub(crate) fn collect_attributed(
416 + parser: &mut dyn ReasoningParser,
417 + chunks: Vec<DecodedText>,
418 +) -> CollectedAttributions {
419 + let mut collected = CollectedAttributions::default();
420 + for chunk in chunks {
421 + collected.record(parser.push(chunk).unwrap());
422 + }
423 + collected.record(parser.finish().unwrap());
424 + collected
425 }
rust/src/parser/src/unified/combined.rs
+74 -11
@@ -3,7 +3,7 @@
3
4 //! Adapter that combines reasoning and tool parsers.
5
6 -use vllm_tokenizer::DynTokenizer;
6 +use vllm_tokenizer::{DecodedText, DynTokenizer};
7
8 use super::{Result, UnifiedParser, UnifiedParserError, UnifiedParserOutput};
9 use crate::reasoning::ReasoningParser;
@@ -87,9 +87,9 @@ impl UnifiedParser for CombinedParser {
87 self.tool.as_ref().and_then(|parser| parser.tool_call_id(tool_index))
88 }
89
90 - fn parse_into(&mut self, delta: &str, output: &mut UnifiedParserOutput) -> Result<()> {
90 + fn parse_into(&mut self, delta: DecodedText, output: &mut UnifiedParserOutput) -> Result<()> {
91 let Some(reasoning) = self.reasoning.as_mut() else {
92 - return self.parse_tool(delta, output);
92 + return self.parse_tool(&delta.text, output);
93 };
94
95 let reasoning_delta = reasoning.push(delta)?;
@@ -97,7 +97,11 @@ impl UnifiedParser for CombinedParser {
97 output.push_reasoning(reasoning);
98 }
99 if let Some(content) = reasoning_delta.content {
100 - self.parse_tool(&content, output)?;
100 + // Content attributions stop at this boundary: the tool parser trait
101 + // consumes plain text.
102 + if !content.text.is_empty() {
103 + self.parse_tool(&content.text, output)?;
104 + }
105 }
106 Ok(())
107 }
@@ -109,8 +113,10 @@ impl UnifiedParser for CombinedParser {
113 if let Some(reasoning) = reasoning_delta.reasoning {
114 output.push_reasoning(reasoning);
115 }
112 - if let Some(content) = reasoning_delta.content {
113 - self.parse_tool(&content, &mut output)?;
116 + if let Some(content) = reasoning_delta.content
117 + && !content.text.is_empty()
118 + {
119 + self.parse_tool(&content.text, &mut output)?;
120 }
121 }
122 output.append(self.flush_tool()?);
@@ -127,6 +133,7 @@ mod tests {
133 use std::sync::Arc;
134
135 use vllm_tokenizer::test_utils::TestTokenizer;
136 + use vllm_tokenizer::{DecodedText, TokenAnchor, TokenAttribution};
137
138 use super::CombinedParser;
139 use crate::reasoning::{Qwen3ReasoningParser, ReasoningDelta, ReasoningParser};
@@ -156,7 +163,7 @@ mod tests {
163 fn collect(parser: &mut dyn UnifiedParser, chunks: &[&str]) -> UnifiedParserOutput {
164 let mut output = UnifiedParserOutput::default();
165 for chunk in chunks {
159 - parser.parse_into(chunk, &mut output).unwrap();
166 + parser.parse_into(DecodedText::unattributed(*chunk), &mut output).unwrap();
167 }
168 output.append(parser.finish().unwrap());
169 output
@@ -178,10 +185,10 @@ mod tests {
185 true
186 }
187
181 - fn push(&mut self, delta: &str) -> crate::reasoning::Result<ReasoningDelta> {
188 + fn push(&mut self, delta: DecodedText) -> crate::reasoning::Result<ReasoningDelta> {
189 Ok(ReasoningDelta {
190 reasoning: None,
184 - content: Some(delta.to_string()),
191 + content: Some(delta),
192 })
193 }
194 }
@@ -259,7 +266,63 @@ mod tests {
266 assert_eq!(
267 output.events,
268 vec![
262 - UnifiedParserEvent::Reasoning("work".to_string()),
269 + UnifiedParserEvent::Reasoning(DecodedText::unattributed("work")),
270 + UnifiedParserEvent::Text("answer".to_string()),
271 + ]
272 + );
273 + }
274 +
275 + #[test]
276 + fn combined_parser_reasoning_events_carry_token_attributions() {
277 + let tokenizer = Arc::new(tokenizer());
278 + let reasoning = Qwen3ReasoningParser::create(tokenizer).unwrap();
279 + let mut parser = CombinedParser::new(Some(reasoning), None);
280 +
281 + let chunk = |token_id: u32, text: &str| DecodedText {
282 + text: text.to_string(),
283 + attributions: [TokenAttribution {
284 + token_id,
285 + anchor: TokenAnchor::Visible { byte_offset: 0 },
286 + }]
287 + .into_iter()
288 + .collect(),
289 + };
290 +
291 + let mut output = UnifiedParserOutput::default();
292 + for (token_id, text) in [
293 + (1, "<think>"),
294 + (2, "reason"),
295 + (3, "</think>"),
296 + (4, "answer"),
297 + ] {
298 + parser.parse_into(chunk(token_id, text), &mut output).unwrap();
299 + }
300 + output.append(parser.finish().unwrap());
301 +
302 + // The reasoning tokens keep their attributions through the combined
303 + // parser; marker tokens (1 and 3) are dropped with their spans.
304 + let reasoning_ids: Vec<u32> = output
305 + .events
306 + .iter()
307 + .filter_map(|event| match event {
308 + UnifiedParserEvent::Reasoning(piece) => Some(piece),
309 + _ => None,
310 + })
311 + .flat_map(|piece| piece.attributions.iter().map(|attr| attr.token_id))
312 + .collect();
313 + assert_eq!(reasoning_ids, [2]);
314 + assert_eq!(
315 + output.events,
316 + vec![
317 + UnifiedParserEvent::Reasoning(DecodedText {
318 + text: "reason".to_string(),
319 + attributions: [TokenAttribution {
320 + token_id: 2,
321 + anchor: TokenAnchor::Visible { byte_offset: 0 },
322 + }]
323 + .into_iter()
324 + .collect(),
325 + }),
326 UnifiedParserEvent::Text("answer".to_string()),
327 ]
328 );
@@ -300,7 +363,7 @@ mod tests {
363 let mut parser = CombinedParser::new(None, Some(Box::new(PartialThenErrorToolParser)));
364 let mut output = UnifiedParserOutput::default();
365
303 - let error = parser.parse_into("bad", &mut output).unwrap_err();
366 + let error = parser.parse_into(DecodedText::unattributed("bad"), &mut output).unwrap_err();
367
368 assert!(matches!(error, crate::unified::UnifiedParserError::Tool(_)));
369 assert_eq!(
rust/src/parser/src/unified/gemma4.rs
+89 -35
@@ -2,7 +2,7 @@
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 use serde_json::{Map, Number, Value};
5 -use vllm_tokenizer::DynTokenizer;
5 +use vllm_tokenizer::{DecodedText, DynTokenizer};
6 use winnow::ascii::multispace0 as ws0;
7 use winnow::combinator::{alt, delimited, eof, opt, separated, seq, terminated};
8 use winnow::error::{ContextError, ErrMode, ModalResult};
@@ -29,8 +29,8 @@ type Gemma4Input<'i> = Partial<&'i str>;
29
30 #[derive(Debug, Clone, PartialEq)]
31 enum Gemma4Event {
32 - Text { len: usize },
33 - Reasoning { len: usize },
32 + Text,
33 + Reasoning,
34 ReasoningStart,
35 ReasoningEnd,
36 ToolCallStart,
@@ -69,7 +69,7 @@ enum Gemma4Mode {
69 ///
70 /// Arguments are emitted only after a full Gemma4 tool call is parsed.
71 pub struct Gemma4UnifiedParser {
72 - buffer: String,
72 + buffer: DecodedText,
73 mode: Gemma4Mode,
74 emitted_tool_count: usize,
75 tokenizer: DynTokenizer,
@@ -84,7 +84,7 @@ impl Gemma4UnifiedParser {
84 let channel_end_token_id = token_id(tokenizer.as_ref(), CHANNEL_END)?;
85
86 Ok(Self {
87 - buffer: String::new(),
87 + buffer: DecodedText::default(),
88 mode: Gemma4Mode::default(),
89 emitted_tool_count: 0,
90 channel_start_token_id,
@@ -93,14 +93,16 @@ impl Gemma4UnifiedParser {
93 })
94 }
95
96 - fn apply_event(&mut self, event: Gemma4Event, output: &mut UnifiedParserOutput) -> Result<()> {
96 + fn apply_event(
97 + &mut self,
98 + event: Gemma4Event,
99 + piece: DecodedText,
100 + output: &mut UnifiedParserOutput,
101 + ) -> Result<()> {
102 match event {
98 - Gemma4Event::Text { len: consumed_len } => {
99 - output.push_text(self.buffer[..consumed_len].to_string());
100 - }
101 - Gemma4Event::Reasoning { len: consumed_len } => {
102 - output.push_reasoning(self.buffer[..consumed_len].to_string());
103 - }
103 + Gemma4Event::Text => output.push_text(piece.text),
104 + Gemma4Event::Reasoning => output.push_reasoning(piece),
105 + // Marker and tool-syntax spans are drained and dropped with their tokens.
106 Gemma4Event::ReasoningStart => self.mode = Gemma4Mode::Reasoning,
107 Gemma4Event::ReasoningEnd => self.mode = Gemma4Mode::Text,
108 Gemma4Event::ToolCallStart => self.mode = Gemma4Mode::Header,
@@ -144,22 +146,17 @@ impl Gemma4UnifiedParser {
146 }
147
148 fn reset(&mut self) -> String {
149 + let buffer = self.buffer.take().text;
150 let raw = match std::mem::replace(&mut self.mode, Gemma4Mode::Text) {
148 - Gemma4Mode::Text => std::mem::take(&mut self.buffer),
151 + Gemma4Mode::Text => buffer,
152 Gemma4Mode::Reasoning => {
150 - format!("{}{}", REASONING_START, std::mem::take(&mut self.buffer))
153 + format!("{REASONING_START}{buffer}")
154 }
155 Gemma4Mode::Header => {
153 - format!("{}{}", TOOL_CALL_START, std::mem::take(&mut self.buffer))
156 + format!("{TOOL_CALL_START}{buffer}")
157 }
158 Gemma4Mode::ToolCall { name, .. } => {
156 - format!(
157 - "{}{}{}{{{}",
158 - TOOL_CALL_START,
159 - CALL_PREFIX,
160 - name,
161 - std::mem::take(&mut self.buffer)
162 - )
159 + format!("{TOOL_CALL_START}{CALL_PREFIX}{name}{{{buffer}")
160 }
161 };
162 self.mode = Gemma4Mode::Text;
@@ -187,16 +184,16 @@ impl UnifiedParser for Gemma4UnifiedParser {
184 true
185 }
186
190 - fn parse_into(&mut self, chunk: &str, output: &mut UnifiedParserOutput) -> Result<()> {
191 - self.buffer.push_str(chunk);
187 + fn parse_into(&mut self, delta: DecodedText, output: &mut UnifiedParserOutput) -> Result<()> {
188 + self.buffer.append(delta);
189
190 while let Some((event, consumed_len)) = {
194 - parse_buffered_event(&self.buffer, |input| {
191 + parse_buffered_event(&self.buffer.text, |input| {
192 parse_next_gemma4_event(input, &mut self.mode)
193 })?
194 } {
198 - self.apply_event(event, output)?;
199 - self.buffer.drain(..consumed_len);
195 + let piece = self.buffer.drain_prefix(consumed_len);
196 + self.apply_event(event, piece, output)?;
197 }
198
199 Ok(())
@@ -206,8 +203,8 @@ impl UnifiedParser for Gemma4UnifiedParser {
203 let mut output = UnifiedParserOutput::default();
204
205 match &self.mode {
209 - Gemma4Mode::Text => output.push_text(std::mem::take(&mut self.buffer)),
210 - Gemma4Mode::Reasoning => output.push_reasoning(std::mem::take(&mut self.buffer)),
206 + Gemma4Mode::Text => output.push_text(self.buffer.take().text),
207 + Gemma4Mode::Reasoning => output.push_reasoning(self.buffer.take()),
208 Gemma4Mode::Header | Gemma4Mode::ToolCall { .. } => {
209 return Err(parsing_failed!("incomplete Gemma4 tool call"));
210 }
@@ -306,14 +303,12 @@ fn gemma4_tool_name(input: &mut Gemma4Input<'_>) -> ModalResult<String> {
303
304 /// Parse a safe text run before the next Gemma4 marker.
305 fn safe_text_event(input: &mut Gemma4Input<'_>) -> ModalResult<Gemma4Event> {
309 - safe_text_len_mul(input, &[REASONING_START, TOOL_CALL_START])
310 - .map(|len| Gemma4Event::Text { len })
306 + safe_text_len_mul(input, &[REASONING_START, TOOL_CALL_START]).map(|_| Gemma4Event::Text)
307 }
308
309 /// Parse a safe reasoning run before the next Gemma4 marker.
310 fn safe_reasoning_event(input: &mut Gemma4Input<'_>) -> ModalResult<Gemma4Event> {
315 - safe_text_len_mul(input, &[CHANNEL_END, TOOL_CALL_START])
316 - .map(|len| Gemma4Event::Reasoning { len })
311 + safe_text_len_mul(input, &[CHANNEL_END, TOOL_CALL_START]).map(|_| Gemma4Event::Reasoning)
312 }
313
314 /// Parse raw Gemma4 arguments through the first end marker outside a Gemma string.
@@ -514,6 +509,7 @@ mod tests {
509 use serde_json::{Value, json};
510 use thiserror_ext::AsReport;
511 use vllm_tokenizer::test_utils::TestTokenizer;
512 + use vllm_tokenizer::{DecodedText, TokenAnchor, TokenAttribution};
513 use winnow::combinator::{eof, terminated};
514 use winnow::error::ErrMode;
515 use winnow::prelude::*;
@@ -545,7 +541,7 @@ mod tests {
541 impl UnifiedParserTestExt for Gemma4UnifiedParser {
542 fn parse_chunk(&mut self, chunk: &str) -> super::Result<UnifiedParserOutput> {
543 let mut output = UnifiedParserOutput::default();
548 - self.parse_into(chunk, &mut output)?;
544 + self.parse_into(DecodedText::unattributed(chunk), &mut output)?;
545 Ok(output)
546 }
547
@@ -578,7 +574,7 @@ mod tests {
574 self.events
575 .iter()
576 .filter_map(|event| match event {
581 - UnifiedParserEvent::Reasoning(text) => Some(text.as_str()),
577 + UnifiedParserEvent::Reasoning(text) => Some(text.text.as_str()),
578 UnifiedParserEvent::Text(_) | UnifiedParserEvent::ToolCall(_) => None,
579 })
580 .collect()
@@ -1116,4 +1112,62 @@ mod tests {
1112
1113 assert_eq!(raw, input);
1114 }
1115 +
1116 + #[test]
1117 + fn gemma4_reasoning_events_conserve_token_attributions() {
1118 + let mut parser = test_parser();
1119 +
1120 + let chunk = |token_id: u32, text: &str| DecodedText {
1121 + text: text.to_string(),
1122 + attributions: [TokenAttribution {
1123 + token_id,
1124 + anchor: TokenAnchor::Visible { byte_offset: 0 },
1125 + }]
1126 + .into_iter()
1127 + .collect(),
1128 + };
1129 + // A zero-width token inside the reasoning channel counts as reasoning,
1130 + // matching Python's current-state rule.
1131 + let zero_width = |token_id: u32| DecodedText {
1132 + text: String::new(),
1133 + attributions: [TokenAttribution {
1134 + token_id,
1135 + anchor: TokenAnchor::ZeroWidth { byte_offset: 0 },
1136 + }]
1137 + .into_iter()
1138 + .collect(),
1139 + };
1140 +
1141 + let mut output = UnifiedParserOutput::default();
1142 + for delta in [
1143 + chunk(1, "<|channel>thought\n"),
1144 + zero_width(2),
1145 + chunk(3, "reason"),
1146 + chunk(4, "<channel|>"),
1147 + chunk(5, "answer"),
1148 + chunk(6, "<|tool_call>"),
1149 + chunk(7, "call:get_weather{location:<|\"|>Paris<|\"|>}"),
1150 + chunk(8, "<tool_call|>"),
1151 + ] {
1152 + parser.parse_into(delta, &mut output).unwrap();
1153 + }
1154 + output.append(parser.finish().unwrap());
1155 +
1156 + // The reasoning tokens keep their attributions; marker and tool-syntax
1157 + // tokens (1, 4, 6, 7, 8) are dropped with their spans, and text/tool
1158 + // pieces carry no attributions.
1159 + let reasoning_ids: Vec<u32> = output
1160 + .events
1161 + .iter()
1162 + .filter_map(|event| match event {
1163 + UnifiedParserEvent::Reasoning(piece) => Some(piece),
1164 + _ => None,
1165 + })
1166 + .flat_map(|piece| piece.attributions.iter().map(|attr| attr.token_id))
1167 + .collect();
1168 + assert_eq!(reasoning_ids, [2, 3]);
1169 + assert_eq!(output.reasoning_text(), "reason");
1170 + assert_eq!(output.normal_text(), "answer");
1171 + assert_eq!(first_call(&output).name.as_deref(), Some("get_weather"));
1172 + }
1173 }
rust/src/parser/src/unified/hy/hy_v3.rs
+5 -5
@@ -1,7 +1,7 @@
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -use vllm_tokenizer::DynTokenizer;
4 +use vllm_tokenizer::{DecodedText, DynTokenizer};
5
6 use super::detect_hy_token_suffix;
7 use crate::reasoning::HyReasoningParser;
@@ -54,7 +54,7 @@ impl UnifiedParser for HyV3UnifiedParser {
54 self.inner.tool_call_id(tool_index)
55 }
56
57 - fn parse_into(&mut self, delta: &str, output: &mut UnifiedParserOutput) -> Result<()> {
57 + fn parse_into(&mut self, delta: DecodedText, output: &mut UnifiedParserOutput) -> Result<()> {
58 self.inner.parse_into(delta, output)
59 }
60
@@ -72,7 +72,7 @@ mod tests {
72 use std::sync::Arc;
73
74 use serde_json::json;
75 - use vllm_tokenizer::{Tokenizer, test_utils::TestTokenizer};
75 + use vllm_tokenizer::{DecodedText, Tokenizer, test_utils::TestTokenizer};
76 use xgrammar_structural_tag::builders::StructuralTagOptions;
77 use xgrammar_structural_tag::{
78 FunctionDefinition, FunctionToolParam, ToolChoice, ToolParam, build_structural_tag,
@@ -131,14 +131,14 @@ mod tests {
131 ];
132 let mut output = UnifiedParserOutput::default();
133 for chunk in chunks {
134 - parser.parse_into(chunk, &mut output).unwrap();
134 + parser.parse_into(DecodedText::unattributed(chunk), &mut output).unwrap();
135 }
136 output.append(parser.finish().unwrap());
137
138 assert_eq!(
139 output.events,
140 vec![
141 - UnifiedParserEvent::Reasoning("reasoning".to_string()),
141 + UnifiedParserEvent::Reasoning(DecodedText::unattributed("reasoning")),
142 UnifiedParserEvent::Text("answer".to_string()),
143 UnifiedParserEvent::ToolCall(crate::tool::ToolCallDelta {
144 tool_index: 0,
rust/src/parser/src/unified/hy/hy_v4.rs
+5 -5
@@ -1,7 +1,7 @@
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -use vllm_tokenizer::DynTokenizer;
4 +use vllm_tokenizer::{DecodedText, DynTokenizer};
5
6 use super::detect_hy_token_suffix;
7 use crate::reasoning::HyReasoningParser;
@@ -54,7 +54,7 @@ impl UnifiedParser for HyV4UnifiedParser {
54 self.inner.tool_call_id(tool_index)
55 }
56
57 - fn parse_into(&mut self, delta: &str, output: &mut UnifiedParserOutput) -> Result<()> {
57 + fn parse_into(&mut self, delta: DecodedText, output: &mut UnifiedParserOutput) -> Result<()> {
58 self.inner.parse_into(delta, output)
59 }
60
@@ -72,7 +72,7 @@ mod tests {
72 use std::sync::Arc;
73
74 use serde_json::json;
75 - use vllm_tokenizer::{Tokenizer, test_utils::TestTokenizer};
75 + use vllm_tokenizer::{DecodedText, Tokenizer, test_utils::TestTokenizer};
76 use xgrammar_structural_tag::builders::StructuralTagOptions;
77 use xgrammar_structural_tag::{
78 FunctionDefinition, FunctionToolParam, ToolChoice, ToolParam, build_structural_tag,
@@ -139,14 +139,14 @@ mod tests {
139 ];
140 let mut output = UnifiedParserOutput::default();
141 for chunk in chunks {
142 - parser.parse_into(chunk, &mut output).unwrap();
142 + parser.parse_into(DecodedText::unattributed(chunk), &mut output).unwrap();
143 }
144 output.append(parser.finish().unwrap());
145
146 assert_eq!(
147 output.events,
148 vec![
149 - UnifiedParserEvent::Reasoning("reasoning".to_string()),
149 + UnifiedParserEvent::Reasoning(DecodedText::unattributed("reasoning")),
150 UnifiedParserEvent::Text("answer".to_string()),
151 UnifiedParserEvent::ToolCall(crate::tool::ToolCallDelta {
152 tool_index: 0,
rust/src/parser/src/unified/inkling.rs
+87 -44
@@ -8,7 +8,7 @@ use winnow::prelude::*;
8 use winnow::stream::Partial;
9 use winnow::token::literal;
10
11 -use vllm_tokenizer::DynTokenizer;
11 +use vllm_tokenizer::{DecodedText, DynTokenizer};
12
13 use super::{Result, UnifiedParser, UnifiedParserOutput, token_id};
14 use crate::tool::json::{
@@ -56,15 +56,15 @@ type InklingInput<'i> = Partial<&'i str>;
56
57 #[derive(Debug, Clone, PartialEq, Eq)]
58 enum InklingEvent {
59 - Text { len: usize },
60 - Reasoning { len: usize },
59 + Text,
60 + Reasoning,
61 TextStart,
62 ReasoningStart,
63 MessageStart,
64 - Header { len: usize },
64 + Header,
65 ToolJsonStart,
66 ToolJsonHeader { name: String },
67 - ToolJsonArgs { len: usize, complete: bool },
67 + ToolJsonArgs { complete: bool },
68 BlockEnd,
69 }
70
@@ -73,7 +73,7 @@ enum InklingMode {
73 #[default]
74 Idle,
75 MessageHeader {
76 - pending: String,
76 + pending: DecodedText,
77 },
78 Text,
79 Reasoning,
@@ -86,7 +86,7 @@ enum InklingMode {
86
87 /// Unified parser for Inkling typed content blocks.
88 pub struct InklingUnifiedParser {
89 - buffer: String,
89 + buffer: DecodedText,
90 mode: InklingMode,
91 emitted_tool_count: usize,
92 active_tool_index: Option<usize>,
@@ -104,7 +104,7 @@ impl InklingUnifiedParser {
104 let content_thinking_token_id = token_id(tokenizer.as_ref(), CONTENT_THINKING)?;
105
106 Ok(Self {
107 - buffer: String::new(),
107 + buffer: DecodedText::default(),
108 mode: InklingMode::Idle,
109 emitted_tool_count: 0,
110 active_tool_index: None,
@@ -120,7 +120,7 @@ impl InklingUnifiedParser {
120 for token_id in prompt_token_ids.iter().rev().copied() {
121 if token_id == self.message_model_token_id {
122 self.mode = InklingMode::MessageHeader {
123 - pending: String::new(),
123 + pending: DecodedText::default(),
124 };
125 return;
126 }
@@ -138,24 +138,27 @@ impl InklingUnifiedParser {
138 }
139 }
140
141 - fn apply_event(&mut self, event: InklingEvent, output: &mut UnifiedParserOutput) -> Result<()> {
141 + fn apply_event(
142 + &mut self,
143 + event: InklingEvent,
144 + piece: DecodedText,
145 + output: &mut UnifiedParserOutput,
146 + ) -> Result<()> {
147 match event {
143 - InklingEvent::Text { len } => output.push_text(self.buffer[..len].to_string()),
144 - InklingEvent::Reasoning { len } => {
145 - output.push_reasoning(self.buffer[..len].to_string());
146 - }
148 + InklingEvent::Text => output.push_text(piece.text),
149 + InklingEvent::Reasoning => output.push_reasoning(piece),
150 InklingEvent::MessageStart => {
151 self.mode = InklingMode::MessageHeader {
149 - pending: String::new(),
152 + pending: DecodedText::default(),
153 };
154 }
152 - InklingEvent::Header { len } => {
155 + InklingEvent::Header => {
156 let InklingMode::MessageHeader { pending } = &mut self.mode else {
157 return Err(parsing_failed!(
158 "Inkling header text outside a message header"
159 ));
160 };
158 - pending.push_str(&self.buffer[..len]);
161 + pending.append(piece);
162 }
163 InklingEvent::TextStart => self.mode = InklingMode::Text,
164 InklingEvent::ReasoningStart => self.mode = InklingMode::Reasoning,
@@ -173,7 +176,7 @@ impl InklingUnifiedParser {
176 arguments: String::new(),
177 });
178 }
176 - InklingEvent::ToolJsonArgs { len, complete } => {
179 + InklingEvent::ToolJsonArgs { complete } => {
180 let Some(tool_index) = self.active_tool_index else {
181 return Err(parsing_failed!(
182 "Inkling arguments without an active tool call"
@@ -182,7 +185,7 @@ impl InklingUnifiedParser {
185 output.push_call(ToolCallDelta {
186 tool_index,
187 name: None,
185 - arguments: self.buffer[..len].to_string(),
188 + arguments: piece.text,
189 });
190 if complete {
191 self.mode = InklingMode::ToolJsonClose;
@@ -191,7 +194,7 @@ impl InklingUnifiedParser {
194 InklingEvent::BlockEnd => {
195 let mode = std::mem::take(&mut self.mode);
196 if let InklingMode::MessageHeader { pending } = mode {
194 - output.push_text(pending);
197 + output.push_text(pending.text);
198 }
199 self.active_tool_index = None;
200 }
@@ -201,12 +204,12 @@ impl InklingUnifiedParser {
204
205 fn reset(&mut self) -> String {
206 let mut uncommitted = match std::mem::take(&mut self.mode) {
204 - InklingMode::MessageHeader { pending } => pending,
207 + InklingMode::MessageHeader { pending } => pending.text,
208 _ => String::new(),
209 };
210 self.active_tool_index = None;
211 self.emitted_tool_count = 0;
209 - uncommitted.push_str(&std::mem::take(&mut self.buffer));
212 + uncommitted.push_str(&self.buffer.take().text);
213 uncommitted
214 }
215 }
@@ -231,14 +234,14 @@ impl UnifiedParser for InklingUnifiedParser {
234 true
235 }
236
234 - fn parse_into(&mut self, chunk: &str, output: &mut UnifiedParserOutput) -> Result<()> {
235 - self.buffer.push_str(chunk);
237 + fn parse_into(&mut self, delta: DecodedText, output: &mut UnifiedParserOutput) -> Result<()> {
238 + self.buffer.append(delta);
239
237 - while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| {
240 + while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer.text, |input| {
241 parse_next_inkling_event(input, &mut self.mode)
242 })? {
240 - self.apply_event(event, output)?;
241 - self.buffer.drain(..consumed_len);
243 + let piece = self.buffer.drain_prefix(consumed_len);
244 + self.apply_event(event, piece, output)?;
245 }
246
247 Ok(())
@@ -248,15 +251,12 @@ impl UnifiedParser for InklingUnifiedParser {
251 let mut output = UnifiedParserOutput::default();
252
253 match &mut self.mode {
251 - InklingMode::Idle | InklingMode::Text => {
252 - output.push_text(std::mem::take(&mut self.buffer))
253 - }
254 + InklingMode::Idle | InklingMode::Text => output.push_text(self.buffer.take().text),
255 InklingMode::MessageHeader { pending } => {
255 - pending.push_str(&self.buffer);
256 - self.buffer.clear();
257 - output.push_text(std::mem::take(pending));
256 + pending.append(self.buffer.take());
257 + output.push_text(std::mem::take(pending).text);
258 }
259 - InklingMode::Reasoning => output.push_reasoning(std::mem::take(&mut self.buffer)),
259 + InklingMode::Reasoning => output.push_reasoning(self.buffer.take()),
260 InklingMode::ToolJsonHeader
261 | InklingMode::ToolJsonArgs { .. }
262 | InklingMode::ToolJsonClose => {
@@ -367,22 +367,22 @@ fn block_end_event(input: &mut InklingInput<'_>) -> ModalResult<InklingEvent> {
367
368 /// Parse safe text while waiting for the next Inkling marker.
369 fn safe_idle_text_event(input: &mut InklingInput<'_>) -> ModalResult<InklingEvent> {
370 - safe_text_len_mul(input, IDLE_MARKERS).map(|len| InklingEvent::Text { len })
370 + safe_text_len_mul(input, IDLE_MARKERS).map(|_| InklingEvent::Text)
371 }
372
373 /// Parse safe header text before the next Inkling marker.
374 fn safe_header_event(input: &mut InklingInput<'_>) -> ModalResult<InklingEvent> {
375 - safe_text_len_mul(input, IDLE_MARKERS).map(|len| InklingEvent::Header { len })
375 + safe_text_len_mul(input, IDLE_MARKERS).map(|_| InklingEvent::Header)
376 }
377
378 /// Parse safe text before the end of a Inkling text block.
379 fn safe_text_event(input: &mut InklingInput<'_>) -> ModalResult<InklingEvent> {
380 - safe_text_len_mul(input, BLOCK_END_MARKERS).map(|len| InklingEvent::Text { len })
380 + safe_text_len_mul(input, BLOCK_END_MARKERS).map(|_| InklingEvent::Text)
381 }
382
383 /// Parse safe reasoning before the end of a Inkling reasoning block.
384 fn safe_reasoning_event(input: &mut InklingInput<'_>) -> ModalResult<InklingEvent> {
385 - safe_text_len_mul(input, BLOCK_END_MARKERS).map(|len| InklingEvent::Reasoning { len })
385 + safe_text_len_mul(input, BLOCK_END_MARKERS).map(|_| InklingEvent::Reasoning)
386 }
387
388 /// Parse a Inkling JSON tool-call header.
@@ -400,9 +400,8 @@ fn parse_tool_json_args_event(
400 input: &mut JsonToolInput<'_>,
401 json_scan: &mut JsonObjectScanState,
402 ) -> ModalResult<InklingEvent> {
403 - let len = take_json_object(input, json_scan)?;
403 + take_json_object(input, json_scan)?;
404 Ok(InklingEvent::ToolJsonArgs {
405 - len,
405 complete: json_scan.complete(),
406 })
407 }
@@ -423,11 +422,11 @@ fn parse_tool_json_close_event(input: &mut InklingInput<'_>) -> ModalResult<Inkl
422 mod tests {
423 use std::sync::Arc;
424
426 - use super::{CONTENT_TEXT, CONTENT_THINKING, InklingUnifiedParser, MESSAGE_MODEL};
425 + use super::{CONTENT_TEXT, CONTENT_THINKING, END_MESSAGE, InklingUnifiedParser, MESSAGE_MODEL};
426 use crate::tool::Tool;
427 use crate::unified::{UnifiedParser, UnifiedParserEvent, UnifiedParserOutput};
428 use thiserror_ext::AsReport;
430 - use vllm_tokenizer::Tokenizer;
429 + use vllm_tokenizer::{DecodedText, TokenAnchor, TokenAttribution, Tokenizer};
430
431 struct FakeTokenizer;
432
@@ -497,7 +496,7 @@ mod tests {
496 impl<T: UnifiedParser + ?Sized> UnifiedParserTestExt for T {
497 fn parse_chunk(&mut self, chunk: &str) -> super::Result<UnifiedParserOutput> {
498 let mut output = UnifiedParserOutput::default();
500 - self.parse_into(chunk, &mut output)?;
499 + self.parse_into(DecodedText::unattributed(chunk), &mut output)?;
500 Ok(output)
501 }
502
@@ -529,7 +528,7 @@ mod tests {
528 self.events
529 .iter()
530 .filter_map(|event| match event {
532 - UnifiedParserEvent::Reasoning(text) => Some(text.as_str()),
531 + UnifiedParserEvent::Reasoning(text) => Some(text.text.as_str()),
532 _ => None,
533 })
534 .collect()
@@ -587,6 +586,50 @@ mod tests {
586 assert!(output.calls().is_empty());
587 }
588
589 + #[test]
590 + fn inkling_reasoning_events_conserve_token_attributions() {
591 + let chunk = |token_id: u32, text: &str| DecodedText {
592 + text: text.to_string(),
593 + attributions: [TokenAttribution {
594 + token_id,
595 + anchor: TokenAnchor::Visible { byte_offset: 0 },
596 + }]
597 + .into_iter()
598 + .collect(),
599 + };
600 +
601 + let mut parser = test_parser();
602 + let mut output = UnifiedParserOutput::default();
603 + for (token_id, text) in [
604 + (1, CONTENT_THINKING),
605 + (2, "reason"),
606 + (3, END_MESSAGE),
607 + (4, MESSAGE_MODEL),
608 + (5, CONTENT_TEXT),
609 + (6, "answer"),
610 + (7, END_MESSAGE),
611 + ] {
612 + parser.parse_into(chunk(token_id, text), &mut output).unwrap();
613 + }
614 + output.append(parser.finish().unwrap());
615 +
616 + // The reasoning tokens keep their attributions; marker tokens
617 + // (1, 3, 4, 5, 7) are dropped with their spans, and text pieces
618 + // carry no attributions.
619 + let reasoning_ids: Vec<u32> = output
620 + .events
621 + .iter()
622 + .filter_map(|event| match event {
623 + UnifiedParserEvent::Reasoning(piece) => Some(piece),
624 + _ => None,
625 + })
626 + .flat_map(|piece| piece.attributions.iter().map(|attr| attr.token_id))
627 + .collect();
628 + assert_eq!(reasoning_ids, [2]);
629 + assert_eq!(output.reasoning_text(), "reason");
630 + assert_eq!(output.normal_text(), "answer");
631 + }
632 +
633 #[test]
634 fn inkling_streaming_holds_split_markers() {
635 let output = collect_stream(&[
rust/src/parser/src/unified/kimi_k3.rs
+31 -28
@@ -41,7 +41,7 @@ mod structural_tag;
41 pub use structural_tag::KimiK3StructuralTagBuilder;
42
43 use serde_json::{Map, Value};
44 -use vllm_tokenizer::DynTokenizer;
44 +use vllm_tokenizer::{DecodedText, DynTokenizer};
45 use winnow::ascii::{multispace0 as ws0, multispace1 as ws1};
46 use winnow::combinator::{alt, delimited, eof, preceded, repeat, seq, terminated};
47 use winnow::error::{ContextError, ErrMode, ModalResult, StrContext};
@@ -95,12 +95,8 @@ type KimiK3Input<'i> = Partial<&'i str>;
95
96 #[derive(Debug, Clone, PartialEq, Eq)]
97 enum KimiK3Event {
98 - Text {
99 - len: usize,
100 - },
101 - Reasoning {
102 - len: usize,
103 - },
98 + Text,
99 + Reasoning,
100 /// Structural noise consumed without emitting anything.
101 Skip,
102 ThinkOpen,
@@ -147,7 +143,7 @@ enum KimiK3Mode {
143
144 /// Unified parser for Kimi K3 XTML think / response / tools channels.
145 pub struct KimiK3UnifiedParser {
150 - buffer: String,
146 + buffer: DecodedText,
147 mode: KimiK3Mode,
148 /// Number of calls emitted in the current response.
149 emitted_call_count: usize,
@@ -163,7 +159,7 @@ impl KimiK3UnifiedParser {
159 let sep_token_id = token_id(tokenizer.as_ref(), SEP)?;
160
161 Ok(Self {
166 - buffer: String::new(),
162 + buffer: DecodedText::default(),
163 mode: KimiK3Mode::default(),
164 emitted_call_count: 0,
165 tokenizer,
@@ -203,12 +199,16 @@ impl KimiK3UnifiedParser {
199 };
200 }
201
206 - fn apply_event(&mut self, event: KimiK3Event, output: &mut UnifiedParserOutput) -> Result<()> {
202 + fn apply_event(
203 + &mut self,
204 + event: KimiK3Event,
205 + piece: DecodedText,
206 + output: &mut UnifiedParserOutput,
207 + ) -> Result<()> {
208 match event {
208 - KimiK3Event::Text { len } => output.push_text(self.buffer[..len].to_string()),
209 - KimiK3Event::Reasoning { len } => {
210 - output.push_reasoning(self.buffer[..len].to_string());
211 - }
209 + KimiK3Event::Text => output.push_text(piece.text),
210 + KimiK3Event::Reasoning => output.push_reasoning(piece),
211 + // Marker and noise spans are drained and dropped with their tokens.
212 KimiK3Event::Skip => {}
213 KimiK3Event::ThinkOpen => self.mode = KimiK3Mode::Reasoning,
214 KimiK3Event::ThinkClose => self.mode = KimiK3Mode::Idle,
@@ -252,7 +252,7 @@ impl KimiK3UnifiedParser {
252 fn reset_state(&mut self) -> String {
253 self.mode = KimiK3Mode::Idle;
254 self.emitted_call_count = 0;
255 - std::mem::take(&mut self.buffer)
255 + self.buffer.take().text
256 }
257 }
258
@@ -279,14 +279,14 @@ impl UnifiedParser for KimiK3UnifiedParser {
279 Some(&KIMI_K3_STRUCTURAL_TAG_BUILDER)
280 }
281
282 - fn parse_into(&mut self, chunk: &str, output: &mut UnifiedParserOutput) -> Result<()> {
283 - self.buffer.push_str(chunk);
282 + fn parse_into(&mut self, delta: DecodedText, output: &mut UnifiedParserOutput) -> Result<()> {
283 + self.buffer.append(delta);
284
285 - while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| {
285 + while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer.text, |input| {
286 parse_next_kimi_k3_event(input, &mut self.mode)
287 })? {
288 - self.apply_event(event, output)?;
289 - self.buffer.drain(..consumed_len);
288 + let piece = self.buffer.drain_prefix(consumed_len);
289 + self.apply_event(event, piece, output)?;
290 }
291
292 Ok(())
@@ -297,13 +297,15 @@ impl UnifiedParser for KimiK3UnifiedParser {
297
298 match &self.mode {
299 KimiK3Mode::Idle | KimiK3Mode::Response => {
300 - output.push_text(std::mem::take(&mut self.buffer));
300 + output.push_text(self.buffer.take().text);
301 + }
302 + KimiK3Mode::Reasoning => {
303 + output.push_reasoning(self.buffer.take());
304 }
302 - KimiK3Mode::Reasoning => output.push_reasoning(std::mem::take(&mut self.buffer)),
305 KimiK3Mode::Epilogue | KimiK3Mode::Done => self.buffer.clear(),
306 // A tools channel truncated between complete calls loses only its
307 // closing markers; keep the calls already emitted.
306 - KimiK3Mode::Tools if self.buffer.is_empty() => {}
308 + KimiK3Mode::Tools if self.buffer.text.is_empty() => {}
309 KimiK3Mode::Tools | KimiK3Mode::Call { .. } => {
310 return Err(parsing_failed!("incomplete Kimi K3 tool call"));
311 }
@@ -406,17 +408,17 @@ fn parse_done_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
408
409 /// Parse safe text while waiting for the next channel marker.
410 fn safe_idle_text_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
409 - safe_text_len_mul(input, IDLE_MARKERS).map(|len| KimiK3Event::Text { len })
411 + safe_text_len_mul(input, IDLE_MARKERS).map(|_| KimiK3Event::Text)
412 }
413
414 /// Parse safe reasoning before the think close marker.
415 fn safe_reasoning_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
414 - safe_text_len_mul(input, REASONING_MARKERS).map(|len| KimiK3Event::Reasoning { len })
416 + safe_text_len_mul(input, REASONING_MARKERS).map(|_| KimiK3Event::Reasoning)
417 }
418
419 /// Parse safe response text before the next channel marker.
420 fn safe_response_text_event(input: &mut KimiK3Input<'_>) -> ModalResult<KimiK3Event> {
419 - safe_text_len_mul(input, RESPONSE_MARKERS).map(|len| KimiK3Event::Text { len })
421 + safe_text_len_mul(input, RESPONSE_MARKERS).map(|_| KimiK3Event::Text)
422 }
423
424 /// Skip non-content noise after the response channel closed.
@@ -571,6 +573,7 @@ mod tests {
573
574 use serde_json::{Value, json};
575 use thiserror_ext::AsReport;
576 + use vllm_tokenizer::DecodedText;
577 use vllm_tokenizer::Tokenizer as _;
578 use vllm_tokenizer::test_utils::TestTokenizer;
579
@@ -604,7 +607,7 @@ mod tests {
607 impl UnifiedParserTestExt for KimiK3UnifiedParser {
608 fn parse_chunk(&mut self, chunk: &str) -> super::Result<UnifiedParserOutput> {
609 let mut output = UnifiedParserOutput::default();
607 - self.parse_into(chunk, &mut output)?;
610 + self.parse_into(DecodedText::unattributed(chunk), &mut output)?;
611 Ok(output)
612 }
613
@@ -636,7 +639,7 @@ mod tests {
639 self.events
640 .iter()
641 .filter_map(|event| match event {
639 - UnifiedParserEvent::Reasoning(text) => Some(text.as_str()),
642 + UnifiedParserEvent::Reasoning(text) => Some(text.text.as_str()),
643 _ => None,
644 })
645 .collect()
rust/src/parser/src/unified/mod.rs
+78 -13
@@ -16,7 +16,7 @@ pub use inkling::InklingUnifiedParser;
16 pub use kimi_k3::{KimiK3StructuralTagBuilder, KimiK3UnifiedParser};
17 use thiserror::Error;
18 use thiserror_ext::Macro;
19 -use vllm_tokenizer::DynTokenizer;
19 +use vllm_tokenizer::{DecodedText, DynTokenizer};
20
21 use crate::reasoning::ReasoningError;
22 use crate::tool::{
@@ -32,7 +32,11 @@ pub enum UnifiedParserEvent {
32 /// Normal assistant-visible text.
33 Text(String),
34 /// Reasoning text hidden from the normal content stream.
35 - Reasoning(String),
35 + ///
36 + /// Carries the attributions of the generated tokens that produced it, so
37 + /// downstream consumers can count reasoning tokens exactly. Marker spans
38 + /// are dropped by the parsers, keeping marker tokens out of any count.
39 + Reasoning(DecodedText),
40 /// A tool-call update extracted from visible assistant text.
41 ToolCall(ToolCallDelta),
42 }
@@ -58,15 +62,18 @@ impl UnifiedParserOutput {
62 }
63
64 /// Append one reasoning text event if `delta` is non-empty.
61 - pub fn push_reasoning(&mut self, delta: impl AsRef<str> + Into<String>) {
62 - if delta.as_ref().is_empty() {
65 + ///
66 + /// A piece with empty text but non-empty attributions (zero-width tokens
67 + /// only) is still kept: the tokens count as reasoning.
68 + pub fn push_reasoning(&mut self, delta: DecodedText) {
69 + if delta.is_empty() {
70 return;
71 }
72 if let Some(UnifiedParserEvent::Reasoning(last_text)) = self.events.last_mut() {
66 - last_text.push_str(delta.as_ref());
73 + last_text.append(delta);
74 return;
75 }
69 - self.events.push(UnifiedParserEvent::Reasoning(delta.into()));
76 + self.events.push(UnifiedParserEvent::Reasoning(delta));
77 }
78
79 /// Append one tool-call event.
@@ -98,6 +105,8 @@ impl UnifiedParserOutput {
105
106 #[cfg(test)]
107 mod tests {
108 + use vllm_tokenizer::{DecodedText, TokenAnchor, TokenAttribution};
109 +
110 use super::{UnifiedParserEvent, UnifiedParserOutput};
111 use crate::tool::ToolCallDelta;
112
@@ -107,8 +116,8 @@ mod tests {
116 output.push_text("hello");
117 output.push_text(" ");
118 output.push_text("world");
110 - output.push_reasoning("think");
111 - output.push_reasoning("ing");
119 + output.push_reasoning(DecodedText::unattributed("think"));
120 + output.push_reasoning(DecodedText::unattributed("ing"));
121 output.push_call(ToolCallDelta {
122 tool_index: 0,
123 name: Some("lookup".to_string()),
@@ -120,7 +129,7 @@ mod tests {
129 output.events,
130 vec![
131 UnifiedParserEvent::Text("hello world".to_string()),
123 - UnifiedParserEvent::Reasoning("thinking".to_string()),
132 + UnifiedParserEvent::Reasoning(DecodedText::unattributed("thinking")),
133 UnifiedParserEvent::ToolCall(ToolCallDelta {
134 tool_index: 0,
135 name: Some("lookup".to_string()),
@@ -139,11 +148,11 @@ mod tests {
148 let mut other = UnifiedParserOutput::default();
149 other.push_text(" ");
150 other.push_text("world");
142 - other.push_reasoning("think");
151 + other.push_reasoning(DecodedText::unattributed("think"));
152 output.append(other);
153
154 let mut after_reasoning = UnifiedParserOutput::default();
146 - after_reasoning.push_reasoning("ing");
155 + after_reasoning.push_reasoning(DecodedText::unattributed("ing"));
156 after_reasoning.push_text("!");
157 output.append(after_reasoning);
158
@@ -151,11 +160,67 @@ mod tests {
160 output.events,
161 vec![
162 UnifiedParserEvent::Text("hello world".to_string()),
154 - UnifiedParserEvent::Reasoning("thinking".to_string()),
163 + UnifiedParserEvent::Reasoning(DecodedText::unattributed("thinking")),
164 UnifiedParserEvent::Text("!".to_string()),
165 ]
166 );
167 }
168 +
169 + #[test]
170 + fn unified_parser_output_reasoning_coalescing_rebases_attributions() {
171 + let mut output = UnifiedParserOutput::default();
172 + output.push_reasoning(DecodedText {
173 + text: "think".to_string(),
174 + attributions: [TokenAttribution {
175 + token_id: 1,
176 + anchor: TokenAnchor::Visible { byte_offset: 0 },
177 + }]
178 + .into_iter()
179 + .collect(),
180 + });
181 + output.push_reasoning(DecodedText {
182 + text: "ing".to_string(),
183 + attributions: [TokenAttribution {
184 + token_id: 2,
185 + anchor: TokenAnchor::Visible { byte_offset: 0 },
186 + }]
187 + .into_iter()
188 + .collect(),
189 + });
190 + // A zero-width-token-only piece is kept: the tokens count as reasoning.
191 + output.push_reasoning(DecodedText {
192 + text: String::new(),
193 + attributions: [TokenAttribution {
194 + token_id: 3,
195 + anchor: TokenAnchor::ZeroWidth { byte_offset: 0 },
196 + }]
197 + .into_iter()
198 + .collect(),
199 + });
200 +
201 + assert_eq!(
202 + output.events,
203 + vec![UnifiedParserEvent::Reasoning(DecodedText {
204 + text: "thinking".to_string(),
205 + attributions: [
206 + TokenAttribution {
207 + token_id: 1,
208 + anchor: TokenAnchor::Visible { byte_offset: 0 },
209 + },
210 + TokenAttribution {
211 + token_id: 2,
212 + anchor: TokenAnchor::Visible { byte_offset: 5 },
213 + },
214 + TokenAttribution {
215 + token_id: 3,
216 + anchor: TokenAnchor::ZeroWidth { byte_offset: 8 },
217 + },
218 + ]
219 + .into_iter()
220 + .collect(),
221 + })]
222 + );
223 + }
224 }
225
226 /// Incremental parser that extracts reasoning and tool-call events from assistant output.
@@ -186,7 +251,7 @@ pub trait UnifiedParser: Send {
251 }
252
253 /// Feed one decoded text delta into the parser, appending committed output into `output`.
189 - fn parse_into(&mut self, delta: &str, output: &mut UnifiedParserOutput) -> Result<()>;
254 + fn parse_into(&mut self, delta: DecodedText, output: &mut UnifiedParserOutput) -> Result<()>;
255
256 /// Flush any buffered parser state at end of stream.
257 fn finish(&mut self) -> Result<UnifiedParserOutput> {
rust/src/tokenizer/src/incremental/attribution.rs
+55 -1
@@ -67,9 +67,63 @@ impl DecodedText {
67 }
68 }
69
70 + /// Return true when both the text and the attributions are empty.
71 + pub fn is_empty(&self) -> bool {
72 + self.text.is_empty() && self.attributions.is_empty()
73 + }
74 +
75 + /// Take the decoded text and attributions, leaving `self` empty.
76 + pub fn take(&mut self) -> Self {
77 + take(self)
78 + }
79 +
80 + /// Clear the text and attributions, keeping the allocations.
81 + pub fn clear(&mut self) {
82 + self.text.clear();
83 + self.attributions.clear();
84 + }
85 +
86 + /// Split off and return the prefix `[0, len)`; the suffix stays in `self`,
87 + /// with its token anchors rebased by `-len`.
88 + ///
89 + /// Anchors follow the first-byte ownership rule: a `Visible` token moves to
90 + /// the prefix when its first byte lies inside the prefix, so a token
91 + /// straddling the split belongs to the span holding its first byte. A
92 + /// `ZeroWidth` token moves to the prefix when its offset is `<= len`,
93 + /// matching the `take_ready` cutoff rule and letting
94 + /// `drain_prefix(text.len())` take trailing zero-width tokens. Relative
95 + /// order across anchor kinds is preserved.
96 + ///
97 + /// Panics if `len` is not a char boundary, via `String::drain`.
98 + #[must_use]
99 + pub fn drain_prefix(&mut self, len: usize) -> DecodedText {
100 + let text: String = self.text.drain(..len).collect();
101 + let len = offset_as_u32(len);
102 +
103 + let mut prefix = DecodedText {
104 + text,
105 + attributions: SmallVec::new(),
106 + };
107 + for attribution in take(&mut self.attributions) {
108 + let in_prefix = match attribution.anchor {
109 + TokenAnchor::Visible { byte_offset } => byte_offset < len,
110 + TokenAnchor::ZeroWidth { byte_offset } => byte_offset <= len,
111 + };
112 + if in_prefix {
113 + prefix.attributions.push(attribution);
114 + } else {
115 + self.attributions.push(TokenAttribution {
116 + token_id: attribution.token_id,
117 + anchor: attribution.anchor.offset_by(-i64::from(len)),
118 + });
119 + }
120 + }
121 + prefix
122 + }
123 +
124 /// Append another decoded fragment, rebasing its token anchors.
125 pub fn append(&mut self, other: Self) {
72 - if self.text.is_empty() && self.attributions.is_empty() {
126 + if self.is_empty() {
127 *self = other;
128 return;
129 }
rust/src/tokenizer/src/incremental/attribution/tests.rs
+139
@@ -169,6 +169,145 @@ fn decoded_text_append_preserves_zero_width_only_receiver() {
169 );
170 }
171
172 +#[test]
173 +fn decoded_text_drain_prefix_moves_anchors_before_boundary() {
174 + let mut dt = decoded("ab.cd", &[1, 2, 3], &[visible(0), visible(1), visible(3)]);
175 +
176 + let prefix = dt.drain_prefix(2);
177 +
178 + assert_eq!(prefix, decoded("ab", &[1, 2], &[visible(0), visible(1)]));
179 + assert_eq!(dt, decoded(".cd", &[3], &[visible(1)]));
180 +}
181 +
182 +#[test]
183 +fn decoded_text_drain_prefix_keeps_straddling_token_in_prefix() {
184 + // One token decodes to "ab"; a split inside it keeps the token in the
185 + // prefix, the span holding its first byte.
186 + let mut dt = decoded("ab", &[1], &[visible(0)]);
187 +
188 + let prefix = dt.drain_prefix(1);
189 +
190 + assert_eq!(prefix, decoded("a", &[1], &[visible(0)]));
191 + assert_eq!(dt, decoded("b", &[], &[]));
192 +}
193 +
194 +#[test]
195 +fn decoded_text_drain_prefix_visible_anchor_at_boundary_goes_to_suffix() {
196 + let mut dt = decoded("ab", &[1, 2], &[visible(0), visible(1)]);
197 +
198 + let prefix = dt.drain_prefix(1);
199 +
200 + assert_eq!(prefix, decoded("a", &[1], &[visible(0)]));
201 + assert_eq!(dt, decoded("b", &[2], &[visible(0)]));
202 +}
203 +
204 +#[test]
205 +fn decoded_text_drain_prefix_zero_width_at_boundary_goes_to_prefix() {
206 + let mut dt = decoded("ab", &[1, 2], &[zero_width(1), visible(1)]);
207 +
208 + let prefix = dt.drain_prefix(1);
209 +
210 + assert_eq!(prefix, decoded("a", &[1], &[zero_width(1)]));
211 + assert_eq!(dt, decoded("b", &[2], &[visible(0)]));
212 +}
213 +
214 +#[test]
215 +fn decoded_text_drain_prefix_shared_anchor_group_moves_together() {
216 + // A byte-fallback group jointly decoding one character shares one anchor;
217 + // the whole group stays on whichever side holds that first byte.
218 + let mut dt = decoded(
219 + "a你",
220 + &[1, 2, 3, 4],
221 + &[visible(0), visible(1), visible(1), visible(1)],
222 + );
223 +
224 + let prefix = dt.drain_prefix(1);
225 +
226 + assert_eq!(prefix, decoded("a", &[1], &[visible(0)]));
227 + assert_eq!(
228 + dt,
229 + decoded("你", &[2, 3, 4], &[visible(0), visible(0), visible(0)])
230 + );
231 +
232 + let prefix = dt.drain_prefix(3);
233 +
234 + assert_eq!(
235 + prefix,
236 + decoded("你", &[2, 3, 4], &[visible(0), visible(0), visible(0)])
237 + );
238 + assert_eq!(dt, decoded("", &[], &[]));
239 +}
240 +
241 +#[test]
242 +fn decoded_text_drain_prefix_zero_drains_leading_zero_width() {
243 + let mut dt = decoded("ab", &[1, 2], &[zero_width(0), visible(0)]);
244 +
245 + let prefix = dt.drain_prefix(0);
246 +
247 + assert_eq!(prefix, decoded("", &[1], &[zero_width(0)]));
248 + assert_eq!(dt, decoded("ab", &[2], &[visible(0)]));
249 +}
250 +
251 +#[test]
252 +fn decoded_text_drain_prefix_full_length_takes_trailing_zero_width() {
253 + let mut dt = decoded("ab", &[1, 2, 3], &[visible(0), visible(1), zero_width(2)]);
254 +
255 + let prefix = dt.drain_prefix(2);
256 +
257 + assert_eq!(
258 + prefix,
259 + decoded("ab", &[1, 2, 3], &[visible(0), visible(1), zero_width(2)])
260 + );
261 + assert_eq!(dt, decoded("", &[], &[]));
262 +}
263 +
264 +#[test]
265 +fn decoded_text_drain_prefix_preserves_order_across_anchor_kinds() {
266 + let mut dt = decoded(
267 + "ab",
268 + &[1, 2, 3, 4],
269 + &[visible(0), zero_width(1), visible(1), zero_width(2)],
270 + );
271 +
272 + let prefix = dt.drain_prefix(1);
273 +
274 + assert_eq!(prefix, decoded("a", &[1, 2], &[visible(0), zero_width(1)]));
275 + assert_eq!(dt, decoded("b", &[3, 4], &[visible(0), zero_width(1)]));
276 +}
277 +
278 +#[test]
279 +fn decoded_text_drain_prefix_then_append_reconstructs_original() {
280 + // ASCII, a byte-fallback group, and mid and trailing zero-width tokens.
281 + let original = decoded(
282 + "a你b",
283 + &[1, 2, 3, 4, 5, 6, 7],
284 + &[
285 + visible(0),
286 + visible(1),
287 + visible(1),
288 + visible(1),
289 + zero_width(4),
290 + visible(4),
291 + zero_width(5),
292 + ],
293 + );
294 +
295 + for len in [0, 1, 4, 5] {
296 + let mut rest = original.clone();
297 + let mut prefix = rest.drain_prefix(len);
298 + prefix.append(rest);
299 + assert_eq!(prefix, original, "split at byte {len}");
300 + }
301 +}
302 +
303 +#[test]
304 +#[should_panic(expected = "is_char_boundary")]
305 +fn decoded_text_drain_prefix_panics_off_char_boundary() {
306 + let mut dt = decoded("a你", &[1, 2], &[visible(0), visible(1)]);
307 +
308 + let _ = dt.drain_prefix(2);
309 +}
310 +
311 struct AttributionCase<'a> {
312 tokenizer: &'a dyn Tokenizer,
313 prompt_token_ids: Vec<u32>,