@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 7a74f31d2e
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

[Rust Frontend] Add seed_oss and step3p5 reasoning parsers (#44552)

Signed-off-by: yzhan1 <zhanyaoming2014@gmail.com>

Yaoming Zhan committed Jun 9, 2026 at 23:01 UTC 7a74f31d2e92e477535b840f9d8ae70cfbcd7925
9 files changed +547 -4
rust/src/chat/src/lib.rs
+1 -1
@@ -272,6 +272,6 @@ mod tests {
272 )
273 .unwrap_err();
274
275 - expect_test::expect!["reasoning parser `definitely_missing_reasoning_parser` is not registered (choose from: cohere_cmd, deepseek_r1, deepseek_v3, deepseek_v4, gemma4, glm45, kimi, kimi_k2, minimax_m2, nemotron_v3, qwen3, step3)"].assert_eq(&error.to_report_string());
275 + expect_test::expect!["reasoning parser `definitely_missing_reasoning_parser` is not registered (choose from: cohere_cmd, deepseek_r1, deepseek_v3, deepseek_v4, gemma4, glm45, kimi, kimi_k2, minimax_m2, nemotron_v3, qwen3, seed_oss, step3, step3p5)"].assert_eq(&error.to_report_string());
276 }
277 }
rust/src/chat/src/parser/reasoning/mod.rs
+14 -2
@@ -6,7 +6,8 @@ pub use vllm_reasoning_parser::{
6 CohereCmdReasoningParser, DeepSeekR1ReasoningParser, DeepSeekV3ReasoningParser,
7 DeepSeekV4ReasoningParser, Gemma4ReasoningParser, Glm45ReasoningParser, KimiK2ReasoningParser,
8 KimiReasoningParser, MiniMaxM2ReasoningParser, NemotronV3ReasoningParser, Qwen3ReasoningParser,
9 - ReasoningDelta, ReasoningError, ReasoningParser, Step3ReasoningParser,
9 + ReasoningDelta, ReasoningError, ReasoningParser, SeedOssReasoningParser, Step3ReasoningParser,
10 + Step3p5ReasoningParser,
11 };
12 use vllm_tokenizer::DynTokenizer;
13
@@ -25,7 +26,9 @@ pub mod names {
26 pub const MINIMAX_M2: &str = "minimax_m2";
27 pub const NEMOTRON_V3: &str = "nemotron_v3";
28 pub const QWEN3: &str = "qwen3";
29 + pub const SEED_OSS: &str = "seed_oss";
30 pub const STEP3: &str = "step3";
31 + pub const STEP3P5: &str = "step3p5";
32 }
33
34 /// Constructor signature for one registered reasoning parser implementation.
@@ -61,7 +64,9 @@ impl ReasoningParserFactory {
64 .register_parser::<MiniMaxM2ReasoningParser>(names::MINIMAX_M2)
65 .register_parser::<NemotronV3ReasoningParser>(names::NEMOTRON_V3)
66 .register_parser::<Qwen3ReasoningParser>(names::QWEN3)
64 - .register_parser::<Step3ReasoningParser>(names::STEP3);
67 + .register_parser::<SeedOssReasoningParser>(names::SEED_OSS)
68 + .register_parser::<Step3ReasoningParser>(names::STEP3)
69 + .register_parser::<Step3p5ReasoningParser>(names::STEP3P5);
70
71 factory
72 .register_pattern("deepseek-r1", names::DEEPSEEK_R1)
@@ -77,7 +82,14 @@ impl ReasoningParserFactory {
82 .register_pattern("glm-4.5", names::GLM45)
83 .register_pattern("kimi-k2", names::KIMI_K2)
84 .register_pattern("kimi", names::KIMI)
85 + // step3p5 patterns must precede `step3`: substring matching would
86 + // otherwise route step3p5 IDs to step3.
87 + .register_pattern("step-3p5", names::STEP3P5)
88 + .register_pattern("step3p5", names::STEP3P5)
89 + .register_pattern("step-3.5", names::STEP3P5)
90 .register_pattern("step3", names::STEP3)
91 + .register_pattern("seed-oss", names::SEED_OSS)
92 + .register_pattern("seedoss", names::SEED_OSS)
93 .register_pattern("minimax", names::MINIMAX_M2)
94 .register_pattern("mm-m2", names::MINIMAX_M2)
95 .register_pattern("cohere", names::COHERE_CMD)
rust/src/chat/src/parser/reasoning/tests.rs
+39
@@ -32,8 +32,12 @@ fn factory_contains_and_lists_registered_parsers() {
32 let factory = ReasoningParserFactory::new();
33 assert!(factory.contains(names::QWEN3));
34 assert!(factory.contains(names::DEEPSEEK_V4));
35 + assert!(factory.contains(names::SEED_OSS));
36 + assert!(factory.contains(names::STEP3P5));
37 assert!(factory.list().contains(&names::QWEN3.to_string()));
38 assert!(factory.list().contains(&names::DEEPSEEK_V4.to_string()));
39 + assert!(factory.list().contains(&names::SEED_OSS.to_string()));
40 + assert!(factory.list().contains(&names::STEP3P5.to_string()));
41 }
42
43 #[test]
@@ -49,6 +53,41 @@ fn factory_resolves_deepseek_v4_to_qwen3_alias() {
53 );
54 }
55
56 +#[test]
57 +fn factory_routes_step3p5_models_to_dedicated_parser() {
58 + let factory = ReasoningParserFactory::new();
59 + // step3p5 patterns must beat the bare `step3` substring.
60 + assert_eq!(
61 + factory.resolve_name_for_model("step-3p5-instruct"),
62 + Some(names::STEP3P5)
63 + );
64 + assert_eq!(
65 + factory.resolve_name_for_model("step3p5"),
66 + Some(names::STEP3P5)
67 + );
68 + assert_eq!(
69 + factory.resolve_name_for_model("step-3.5-base"),
70 + Some(names::STEP3P5)
71 + );
72 + assert_eq!(
73 + factory.resolve_name_for_model("step3-base"),
74 + Some(names::STEP3)
75 + );
76 +}
77 +
78 +#[test]
79 +fn factory_routes_seed_oss_models() {
80 + let factory = ReasoningParserFactory::new();
81 + assert_eq!(
82 + factory.resolve_name_for_model("ByteDance-Seed/Seed-OSS-36B-Instruct"),
83 + Some(names::SEED_OSS)
84 + );
85 + assert_eq!(
86 + factory.resolve_name_for_model("seedoss-7b"),
87 + Some(names::SEED_OSS)
88 + );
89 +}
90 +
91 #[test]
92 fn factory_rejects_unknown_parser_names() {
93 let tokenizer = Arc::new(FakeTokenizer);
rust/src/chat/tests/roundtrip.rs
+26
@@ -144,6 +144,30 @@ impl RoundtripCase {
144 json_fmt: spaced_json_fmt(),
145 }
146 }
147 +
148 + /// SeedOSS with `<seed:think>` / `</seed:think>` reasoning tags.
149 + fn seed_oss() -> Self {
150 + Self {
151 + model_id: "ByteDance-Seed/Seed-OSS-36B-Instruct",
152 + assistant_stop_suffix: "<seed:eos>",
153 + tool_call_parser: ParserSelection::Auto,
154 + reasoning_parser: ParserSelection::Auto,
155 + thinking_behavior: ThinkingBehavior::Always { value: true },
156 + json_fmt: compact_json_fmt(),
157 + }
158 + }
159 +
160 + /// Step-3.5 with `<think>` / `</think>` reasoning tags and newline trimming.
161 + fn step3p5() -> Self {
162 + Self {
163 + model_id: "stepfun-ai/Step-3.5-Flash",
164 + assistant_stop_suffix: "<|im_end|>\n",
165 + tool_call_parser: ParserSelection::Auto,
166 + reasoning_parser: ParserSelection::Auto,
167 + thinking_behavior: ThinkingBehavior::Always { value: true },
168 + json_fmt: compact_json_fmt(),
169 + }
170 + }
171 }
172
173 macro_rules! roundtrip_tests {
@@ -168,6 +192,8 @@ roundtrip_tests! {
192 minimax_m25 => [reasoning_and_content, tool_call_mix],
193 deepseek_v4 => [reasoning_and_content, tool_call_mix],
194 glm47 => [reasoning_and_content, tool_call_mix],
195 + seed_oss => [reasoning_and_content],
196 + step3p5 => [reasoning_and_content],
197
198 // Note: Kimi K2.5 strips the reasoning content in history.
199 kimi_k25 => [tool_call_mix],
rust/src/reasoning-parser/src/delimited.rs
+5
@@ -68,6 +68,11 @@ impl DelimitedReasoningParser {
68 .unwrap_or(self.default_in_reasoning);
69 }
70
71 + /// Return whether the parser is currently inside a reasoning section.
72 + pub(crate) fn in_reasoning(&self) -> bool {
73 + self.current_in_reasoning
74 + }
75 +
76 /// Parse one decoded text delta and return its reasoning/content split.
77 pub(crate) fn push(&mut self, delta: &str) -> ReasoningDelta {
78 self.buffer.push_str(delta);
rust/src/reasoning-parser/src/lib.rs
+4
@@ -20,6 +20,8 @@ mod delimited;
20 mod gemma4;
21 mod kimi;
22 mod qwen3;
23 +mod seed_oss;
24 +mod step3p5;
25
26 use thiserror::Error;
27 use vllm_tokenizer::DynTokenizer;
@@ -30,6 +32,8 @@ pub(crate) use self::delimited::DelimitedReasoningParser;
32 pub use self::gemma4::Gemma4ReasoningParser;
33 pub use self::kimi::KimiReasoningParser;
34 pub use self::qwen3::Qwen3ReasoningParser;
35 +pub use self::seed_oss::SeedOssReasoningParser;
36 +pub use self::step3p5::Step3p5ReasoningParser;
37
38 /// DeepSeek V3 currently shares the standard `<think>...</think>` parser.
39 pub type DeepSeekV3ReasoningParser = Qwen3ReasoningParser;
rust/src/reasoning-parser/src/seed_oss.rs new
+147
@@ -0,0 +1,147 @@
1 +use vllm_tokenizer::DynTokenizer;
2 +
3 +use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result};
4 +
5 +/// Reasoning parser for SeedOSS models using `<seed:think>`/`</seed:think>`
6 +/// delimiters.
7 +pub struct SeedOssReasoningParser {
8 + inner: DelimitedReasoningParser,
9 +}
10 +
11 +impl SeedOssReasoningParser {
12 + /// Create a SeedOSS parser backed by the shared delimited state machine.
13 + pub fn new(tokenizer: DynTokenizer) -> Result<Self> {
14 + Ok(Self {
15 + inner: DelimitedReasoningParser::new(
16 + tokenizer,
17 + "<seed:think>",
18 + "</seed:think>",
19 + false,
20 + )?,
21 + })
22 + }
23 +}
24 +
25 +impl ReasoningParser for SeedOssReasoningParser {
26 + fn create(tokenizer: DynTokenizer) -> Result<Box<dyn ReasoningParser>>
27 + where
28 + Self: Sized + 'static,
29 + {
30 + Ok(Box::new(Self::new(tokenizer)?))
31 + }
32 +
33 + fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
34 + self.inner.initialize(prompt_token_ids);
35 + Ok(())
36 + }
37 +
38 + fn push(&mut self, delta: &str) -> Result<ReasoningDelta> {
39 + Ok(self.inner.push(delta))
40 + }
41 +
42 + fn finish(&mut self) -> Result<ReasoningDelta> {
43 + Ok(self.inner.finish())
44 + }
45 +}
46 +
47 +#[cfg(test)]
48 +mod tests {
49 + use std::sync::Arc;
50 +
51 + use super::SeedOssReasoningParser;
52 + use crate::{ReasoningParser, tests::FakeTokenizer};
53 +
54 + #[test]
55 + fn without_prompt_markers_expects_start_token() {
56 + let tokenizer = Arc::new(FakeTokenizer);
57 + let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap();
58 +
59 + let delta = parser.push("implicit reasoning</seed:think>answer").unwrap();
60 + assert_eq!(delta.reasoning, None);
61 + assert_eq!(
62 + delta.content.as_deref(),
63 + Some("implicit reasoning</seed:think>answer")
64 + );
65 + }
66 +
67 + #[test]
68 + fn picks_up_prompt_start_boundary() {
69 + let tokenizer = Arc::new(FakeTokenizer);
70 + let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap();
71 + // Prompt prefills `<seed:think>` (id 10), opening reasoning before the stream.
72 + parser.initialize(&[10]).unwrap();
73 +
74 + let delta = parser.push("reason</seed:think>answer").unwrap();
75 + assert_eq!(delta.reasoning.as_deref(), Some("reason"));
76 + assert_eq!(delta.content.as_deref(), Some("answer"));
77 + }
78 +
79 + #[test]
80 + fn respects_prompt_end_boundary() {
81 + let tokenizer = Arc::new(FakeTokenizer);
82 + let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap();
83 + // Prompt already closed reasoning with `</seed:think>` (id 11).
84 + parser.initialize(&[11]).unwrap();
85 +
86 + let delta = parser.push("answer").unwrap();
87 + assert_eq!(delta.reasoning, None);
88 + assert_eq!(delta.content.as_deref(), Some("answer"));
89 + }
90 +
91 + #[test]
92 + fn handles_explicit_start_token() {
93 + // An explicit start delimiter must not leak into reasoning text.
94 + let tokenizer = Arc::new(FakeTokenizer);
95 + let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap();
96 +
97 + let delta = parser.push("<seed:think>reason</seed:think>answer").unwrap();
98 + assert_eq!(delta.reasoning.as_deref(), Some("reason"));
99 + assert_eq!(delta.content.as_deref(), Some("answer"));
100 + }
101 +
102 + #[test]
103 + fn streams_explicit_start_token_across_pushes() {
104 + // Start token, reasoning body, end token, and content arrive in separate
105 + // streaming deltas.
106 + let tokenizer = Arc::new(FakeTokenizer);
107 + let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap();
108 +
109 + let mut reasoning = String::new();
110 + let mut content = String::new();
111 + for delta_str in [
112 + "<seed:think>",
113 + "Some ",
114 + "reasoning ",
115 + "content",
116 + "</seed:think>",
117 + "Final ",
118 + "answer",
119 + ] {
120 + let delta = parser.push(delta_str).unwrap();
121 + if let Some(r) = delta.reasoning {
122 + reasoning.push_str(&r);
123 + }
124 + if let Some(c) = delta.content {
125 + content.push_str(&c);
126 + }
127 + }
128 + assert_eq!(reasoning, "Some reasoning content");
129 + assert_eq!(content, "Final answer");
130 + }
131 +
132 + #[test]
133 + fn handles_partial_delimiters_across_pushes() {
134 + let tokenizer = Arc::new(FakeTokenizer);
135 + let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap();
136 + parser.initialize(&[10]).unwrap();
137 +
138 + // Closing delimiter `</seed:think>` arrives in two halves.
139 + let first = parser.push("reason</seed:").unwrap();
140 + assert_eq!(first.reasoning.as_deref(), Some("reason"));
141 + assert_eq!(first.content, None);
142 +
143 + let second = parser.push("think>answer").unwrap();
144 + assert_eq!(second.reasoning, None);
145 + assert_eq!(second.content.as_deref(), Some("answer"));
146 + }
147 +}
rust/src/reasoning-parser/src/step3p5.rs new
+308
@@ -0,0 +1,308 @@
1 +use vllm_tokenizer::DynTokenizer;
2 +
3 +use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result};
4 +
5 +/// Reasoning parser for Step3p5 outputs.
6 +///
7 +/// Step3p5 uses standard `<think>`/`</think>` delimiters but emits a `\n`
8 +/// immediately before and/or after `</think>`. The parser drops these framing
9 +/// newlines on both sides of the boundary, holding a trailing `\n` from
10 +/// reasoning across pushes until either more reasoning text or `</think>`
11 +/// arrives, and dropping a leading `\n` from the first content delta after
12 +/// the boundary.
13 +pub struct Step3p5ReasoningParser {
14 + inner: DelimitedReasoningParser,
15 + /// `\n` at end of last reasoning delta, held in case `</think>` follows.
16 + pending_reasoning_newline: bool,
17 + /// Last push ended on `</think>` without emitting content; the next
18 + /// content delta's leading `\n` should be dropped.
19 + just_ended_reasoning: bool,
20 +}
21 +
22 +impl Step3p5ReasoningParser {
23 + /// Create a Step3p5 parser backed by the shared delimited state machine.
24 + pub fn new(tokenizer: DynTokenizer) -> Result<Self> {
25 + Ok(Self {
26 + inner: DelimitedReasoningParser::new(tokenizer, "<think>", "</think>", false)?,
27 + pending_reasoning_newline: false,
28 + just_ended_reasoning: false,
29 + })
30 + }
31 +
32 + /// Drop framing newlines around `</think>` and track held-newline state.
33 + fn process(
34 + &mut self,
35 + mut inner_delta: ReasoningDelta,
36 + was_in_reasoning: bool,
37 + now_in_reasoning: bool,
38 + ) -> ReasoningDelta {
39 + // A `<think>...</think>` round-trip in one push still counts as a
40 + // transition: the inner emits reasoning while ending in content mode.
41 + let transitioned =
42 + !now_in_reasoning && (was_in_reasoning || inner_delta.reasoning.is_some());
43 +
44 + // Replay or drop a previously-held trailing reasoning newline.
45 + if self.pending_reasoning_newline {
46 + if let Some(reasoning) = inner_delta.reasoning.as_mut() {
47 + reasoning.insert(0, '\n');
48 + self.pending_reasoning_newline = false;
49 + } else if transitioned {
50 + // The held `\n` was the one right before `</think>`: drop it.
51 + self.pending_reasoning_newline = false;
52 + }
53 + }
54 +
55 + // Hold back a trailing reasoning `\n` until we know if `</think>` follows.
56 + if let Some(reasoning) = inner_delta.reasoning.as_mut()
57 + && reasoning.ends_with('\n')
58 + {
59 + reasoning.pop();
60 + if !transitioned {
61 + self.pending_reasoning_newline = true;
62 + }
63 + }
64 +
65 + // Drop a leading `\n` of content emitted right after `</think>`.
66 + if let Some(content) = inner_delta.content.as_mut()
67 + && (transitioned || self.just_ended_reasoning)
68 + && content.starts_with('\n')
69 + {
70 + content.remove(0);
71 + }
72 +
73 + self.just_ended_reasoning = transitioned && inner_delta.content.is_none();
74 +
75 + if inner_delta.reasoning.as_deref() == Some("") {
76 + inner_delta.reasoning = None;
77 + }
78 + if inner_delta.content.as_deref() == Some("") {
79 + inner_delta.content = None;
80 + }
81 +
82 + inner_delta
83 + }
84 +}
85 +
86 +impl ReasoningParser for Step3p5ReasoningParser {
87 + fn create(tokenizer: DynTokenizer) -> Result<Box<dyn ReasoningParser>>
88 + where
89 + Self: Sized + 'static,
90 + {
91 + Ok(Box::new(Self::new(tokenizer)?))
92 + }
93 +
94 + fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
95 + self.inner.initialize(prompt_token_ids);
96 + Ok(())
97 + }
98 +
99 + fn push(&mut self, delta: &str) -> Result<ReasoningDelta> {
100 + let was = self.inner.in_reasoning();
101 + let inner_delta = self.inner.push(delta);
102 + let now = self.inner.in_reasoning();
103 + Ok(self.process(inner_delta, was, now))
104 + }
105 +
106 + fn finish(&mut self) -> Result<ReasoningDelta> {
107 + let was = self.inner.in_reasoning();
108 + let inner_delta = self.inner.finish();
109 + let now = self.inner.in_reasoning();
110 + let mut delta = self.process(inner_delta, was, now);
111 +
112 + // Emit a still-held newline rather than silently dropping it.
113 + if self.pending_reasoning_newline {
114 + match delta.reasoning.as_mut() {
115 + Some(existing) => existing.push('\n'),
116 + None => delta.reasoning = Some("\n".to_string()),
117 + }
118 + self.pending_reasoning_newline = false;
119 + }
120 +
121 + Ok(delta)
122 + }
123 +}
124 +
125 +#[cfg(test)]
126 +mod tests {
127 + use std::sync::Arc;
128 +
129 + use super::Step3p5ReasoningParser;
130 + use crate::{ReasoningParser, tests::FakeTokenizer};
131 +
132 + #[test]
133 + fn picks_up_prompt_start_boundary() {
134 + let tokenizer = Arc::new(FakeTokenizer);
135 + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
136 + // Prompt prefills `<think>` (id 1), opening reasoning before the stream.
137 + parser.initialize(&[1]).unwrap();
138 +
139 + let delta = parser.push("This is a reasoning section</think>This is the rest").unwrap();
140 + assert_eq!(
141 + delta.reasoning.as_deref(),
142 + Some("This is a reasoning section")
143 + );
144 + assert_eq!(delta.content.as_deref(), Some("This is the rest"));
145 + }
146 +
147 + #[test]
148 + fn handles_unterminated_reasoning() {
149 + let tokenizer = Arc::new(FakeTokenizer);
150 + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
151 +
152 + let pushed = parser.push("<think>reason without end").unwrap();
153 + assert_eq!(pushed.reasoning.as_deref(), Some("reason without end"));
154 + assert_eq!(pushed.content, None);
155 +
156 + let flushed = parser.finish().unwrap();
157 + assert!(flushed.is_empty());
158 + }
159 +
160 + #[test]
161 + fn handles_empty_input() {
162 + let tokenizer = Arc::new(FakeTokenizer);
163 + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
164 +
165 + let pushed = parser.push("").unwrap();
166 + assert!(pushed.is_empty());
167 + let flushed = parser.finish().unwrap();
168 + assert!(flushed.is_empty());
169 + }
170 +
171 + #[test]
172 + fn complex_newline_pattern_trims_only_single_framing_newline_each_side() {
173 + // Only the immediately-adjacent framing `\n` is dropped on each side of
174 + // `</think>`; surrounding newlines remain part of reasoning/content.
175 + let tokenizer = Arc::new(FakeTokenizer);
176 + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
177 + parser.initialize(&[1]).unwrap();
178 +
179 + let delta = parser
180 + .push("\n This is a \n reasoning section\n\n\n</think>\n\nThis is the rest")
181 + .unwrap();
182 + assert_eq!(
183 + delta.reasoning.as_deref(),
184 + Some("\n This is a \n reasoning section\n\n")
185 + );
186 + assert_eq!(delta.content.as_deref(), Some("\nThis is the rest"));
187 + }
188 +
189 + #[test]
190 + fn drops_framing_newlines_in_single_push() {
191 + let tokenizer = Arc::new(FakeTokenizer);
192 + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
193 +
194 + let delta = parser.push("<think>reason\n</think>\nanswer").unwrap();
195 + assert_eq!(delta.reasoning.as_deref(), Some("reason"));
196 + assert_eq!(delta.content.as_deref(), Some("answer"));
197 + }
198 +
199 + #[test]
200 + fn drops_framing_newlines_across_pushes() {
201 + let tokenizer = Arc::new(FakeTokenizer);
202 + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
203 +
204 + // The trailing `\n` from the first push is held until we know whether
205 + // `</think>` follows.
206 + let first = parser.push("<think>reason\n").unwrap();
207 + assert_eq!(first.reasoning.as_deref(), Some("reason"));
208 + assert_eq!(first.content, None);
209 +
210 + // `</think>` arrives standalone; the held newline should be dropped.
211 + let second = parser.push("</think>").unwrap();
212 + assert!(second.is_empty());
213 +
214 + // The leading newline of the first content delta is dropped.
215 + let third = parser.push("\nanswer").unwrap();
216 + assert_eq!(third.reasoning, None);
217 + assert_eq!(third.content.as_deref(), Some("answer"));
218 + }
219 +
220 + #[test]
221 + fn replays_held_newline_when_more_reasoning_follows() {
222 + let tokenizer = Arc::new(FakeTokenizer);
223 + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
224 +
225 + let first = parser.push("<think>reason\n").unwrap();
226 + assert_eq!(first.reasoning.as_deref(), Some("reason"));
227 +
228 + let second = parser.push("more reason").unwrap();
229 + assert_eq!(second.reasoning.as_deref(), Some("\nmore reason"));
230 + assert_eq!(second.content, None);
231 + }
232 +
233 + #[test]
234 + fn finish_flushes_held_newline_in_unterminated_stream() {
235 + let tokenizer = Arc::new(FakeTokenizer);
236 + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
237 +
238 + let first = parser.push("<think>reason\n").unwrap();
239 + assert_eq!(first.reasoning.as_deref(), Some("reason"));
240 +
241 + let flushed = parser.finish().unwrap();
242 + assert_eq!(flushed.reasoning.as_deref(), Some("\n"));
243 + assert_eq!(flushed.content, None);
244 + }
245 +
246 + #[test]
247 + fn preserves_inner_newlines_in_reasoning() {
248 + let tokenizer = Arc::new(FakeTokenizer);
249 + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
250 +
251 + let delta = parser.push("<think>line1\nline2</think>tail").unwrap();
252 + assert_eq!(delta.reasoning.as_deref(), Some("line1\nline2"));
253 + assert_eq!(delta.content.as_deref(), Some("tail"));
254 + }
255 +
256 + #[test]
257 + fn trims_only_one_trailing_reasoning_newline() {
258 + // Only the single framing newline immediately before `</think>` is
259 + // dropped; earlier newlines in the reasoning body are preserved.
260 + let tokenizer = Arc::new(FakeTokenizer);
261 + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
262 +
263 + let delta = parser.push("<think>reason\n\n</think>answer").unwrap();
264 + assert_eq!(delta.reasoning.as_deref(), Some("reason\n"));
265 + assert_eq!(delta.content.as_deref(), Some("answer"));
266 + }
267 +
268 + #[test]
269 + fn drops_only_first_content_newline_after_transition() {
270 + // The leading-`\n` drop applies only to the first content delta after
271 + // `</think>`; later deltas pass through untouched.
272 + let tokenizer = Arc::new(FakeTokenizer);
273 + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
274 +
275 + let first = parser.push("<think>reason</think>").unwrap();
276 + assert_eq!(first.reasoning.as_deref(), Some("reason"));
277 + assert_eq!(first.content, None);
278 +
279 + let second = parser.push("\nfirst").unwrap();
280 + assert_eq!(second.reasoning, None);
281 + assert_eq!(second.content.as_deref(), Some("first"));
282 +
283 + // A `\n` arriving in a later content delta must NOT be dropped.
284 + let third = parser.push("\nsecond").unwrap();
285 + assert_eq!(third.reasoning, None);
286 + assert_eq!(third.content.as_deref(), Some("\nsecond"));
287 + }
288 +
289 + #[test]
290 + fn passes_through_clean_boundary_without_framing_newlines() {
291 + let tokenizer = Arc::new(FakeTokenizer);
292 + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
293 +
294 + let delta = parser.push("<think>reason</think>tail").unwrap();
295 + assert_eq!(delta.reasoning.as_deref(), Some("reason"));
296 + assert_eq!(delta.content.as_deref(), Some("tail"));
297 + }
298 +
299 + #[test]
300 + fn handles_empty_reasoning_section() {
301 + let tokenizer = Arc::new(FakeTokenizer);
302 + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
303 +
304 + let delta = parser.push("<think></think>answer").unwrap();
305 + assert_eq!(delta.reasoning, None);
306 + assert_eq!(delta.content.as_deref(), Some("answer"));
307 + }
308 +}
rust/src/reasoning-parser/src/tests.rs
+3 -1
@@ -6,7 +6,7 @@ use super::{
6 DeepSeekR1ReasoningParser, DelimitedReasoningParser, Qwen3ReasoningParser, ReasoningParser,
7 };
8
9 -struct FakeTokenizer;
9 +pub(crate) struct FakeTokenizer;
10
11 impl Tokenizer for FakeTokenizer {
12 fn encode(&self, text: &str, _add_special_tokens: bool) -> vllm_tokenizer::Result<Vec<u32>> {
@@ -32,6 +32,8 @@ impl Tokenizer for FakeTokenizer {
32 "<|END_THINKING|>" => Some(4),
33 "◁think▷" => Some(5),
34 "◁/think▷" => Some(6),
35 + "<seed:think>" => Some(10),
36 + "</seed:think>" => Some(11),
37 _ => None,
38 }
39 }