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
}