diff --git a/rust/src/chat/src/lib.rs b/rust/src/chat/src/lib.rs index ae366a4de0..4add5f0f9b 100644 --- a/rust/src/chat/src/lib.rs +++ b/rust/src/chat/src/lib.rs @@ -272,6 +272,6 @@ mod tests { ) .unwrap_err(); - 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()); + 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()); } } diff --git a/rust/src/chat/src/parser/reasoning/mod.rs b/rust/src/chat/src/parser/reasoning/mod.rs index 09111d7252..aa4d459643 100644 --- a/rust/src/chat/src/parser/reasoning/mod.rs +++ b/rust/src/chat/src/parser/reasoning/mod.rs @@ -6,7 +6,8 @@ pub use vllm_reasoning_parser::{ CohereCmdReasoningParser, DeepSeekR1ReasoningParser, DeepSeekV3ReasoningParser, DeepSeekV4ReasoningParser, Gemma4ReasoningParser, Glm45ReasoningParser, KimiK2ReasoningParser, KimiReasoningParser, MiniMaxM2ReasoningParser, NemotronV3ReasoningParser, Qwen3ReasoningParser, - ReasoningDelta, ReasoningError, ReasoningParser, Step3ReasoningParser, + ReasoningDelta, ReasoningError, ReasoningParser, SeedOssReasoningParser, Step3ReasoningParser, + Step3p5ReasoningParser, }; use vllm_tokenizer::DynTokenizer; @@ -25,7 +26,9 @@ pub mod names { pub const MINIMAX_M2: &str = "minimax_m2"; pub const NEMOTRON_V3: &str = "nemotron_v3"; pub const QWEN3: &str = "qwen3"; + pub const SEED_OSS: &str = "seed_oss"; pub const STEP3: &str = "step3"; + pub const STEP3P5: &str = "step3p5"; } /// Constructor signature for one registered reasoning parser implementation. @@ -61,7 +64,9 @@ impl ReasoningParserFactory { .register_parser::(names::MINIMAX_M2) .register_parser::(names::NEMOTRON_V3) .register_parser::(names::QWEN3) - .register_parser::(names::STEP3); + .register_parser::(names::SEED_OSS) + .register_parser::(names::STEP3) + .register_parser::(names::STEP3P5); factory .register_pattern("deepseek-r1", names::DEEPSEEK_R1) @@ -77,7 +82,14 @@ impl ReasoningParserFactory { .register_pattern("glm-4.5", names::GLM45) .register_pattern("kimi-k2", names::KIMI_K2) .register_pattern("kimi", names::KIMI) + // step3p5 patterns must precede `step3`: substring matching would + // otherwise route step3p5 IDs to step3. + .register_pattern("step-3p5", names::STEP3P5) + .register_pattern("step3p5", names::STEP3P5) + .register_pattern("step-3.5", names::STEP3P5) .register_pattern("step3", names::STEP3) + .register_pattern("seed-oss", names::SEED_OSS) + .register_pattern("seedoss", names::SEED_OSS) .register_pattern("minimax", names::MINIMAX_M2) .register_pattern("mm-m2", names::MINIMAX_M2) .register_pattern("cohere", names::COHERE_CMD) diff --git a/rust/src/chat/src/parser/reasoning/tests.rs b/rust/src/chat/src/parser/reasoning/tests.rs index 89b5f8e230..803926d16b 100644 --- a/rust/src/chat/src/parser/reasoning/tests.rs +++ b/rust/src/chat/src/parser/reasoning/tests.rs @@ -32,8 +32,12 @@ fn factory_contains_and_lists_registered_parsers() { let factory = ReasoningParserFactory::new(); assert!(factory.contains(names::QWEN3)); assert!(factory.contains(names::DEEPSEEK_V4)); + assert!(factory.contains(names::SEED_OSS)); + assert!(factory.contains(names::STEP3P5)); assert!(factory.list().contains(&names::QWEN3.to_string())); assert!(factory.list().contains(&names::DEEPSEEK_V4.to_string())); + assert!(factory.list().contains(&names::SEED_OSS.to_string())); + assert!(factory.list().contains(&names::STEP3P5.to_string())); } #[test] @@ -49,6 +53,41 @@ fn factory_resolves_deepseek_v4_to_qwen3_alias() { ); } +#[test] +fn factory_routes_step3p5_models_to_dedicated_parser() { + let factory = ReasoningParserFactory::new(); + // step3p5 patterns must beat the bare `step3` substring. + assert_eq!( + factory.resolve_name_for_model("step-3p5-instruct"), + Some(names::STEP3P5) + ); + assert_eq!( + factory.resolve_name_for_model("step3p5"), + Some(names::STEP3P5) + ); + assert_eq!( + factory.resolve_name_for_model("step-3.5-base"), + Some(names::STEP3P5) + ); + assert_eq!( + factory.resolve_name_for_model("step3-base"), + Some(names::STEP3) + ); +} + +#[test] +fn factory_routes_seed_oss_models() { + let factory = ReasoningParserFactory::new(); + assert_eq!( + factory.resolve_name_for_model("ByteDance-Seed/Seed-OSS-36B-Instruct"), + Some(names::SEED_OSS) + ); + assert_eq!( + factory.resolve_name_for_model("seedoss-7b"), + Some(names::SEED_OSS) + ); +} + #[test] fn factory_rejects_unknown_parser_names() { let tokenizer = Arc::new(FakeTokenizer); diff --git a/rust/src/chat/tests/roundtrip.rs b/rust/src/chat/tests/roundtrip.rs index 110114cdf9..3c8c96ce9f 100644 --- a/rust/src/chat/tests/roundtrip.rs +++ b/rust/src/chat/tests/roundtrip.rs @@ -144,6 +144,30 @@ impl RoundtripCase { json_fmt: spaced_json_fmt(), } } + + /// SeedOSS with `` / `` reasoning tags. + fn seed_oss() -> Self { + Self { + model_id: "ByteDance-Seed/Seed-OSS-36B-Instruct", + assistant_stop_suffix: "", + tool_call_parser: ParserSelection::Auto, + reasoning_parser: ParserSelection::Auto, + thinking_behavior: ThinkingBehavior::Always { value: true }, + json_fmt: compact_json_fmt(), + } + } + + /// Step-3.5 with `` / `` reasoning tags and newline trimming. + fn step3p5() -> Self { + Self { + model_id: "stepfun-ai/Step-3.5-Flash", + assistant_stop_suffix: "<|im_end|>\n", + tool_call_parser: ParserSelection::Auto, + reasoning_parser: ParserSelection::Auto, + thinking_behavior: ThinkingBehavior::Always { value: true }, + json_fmt: compact_json_fmt(), + } + } } macro_rules! roundtrip_tests { @@ -168,6 +192,8 @@ roundtrip_tests! { minimax_m25 => [reasoning_and_content, tool_call_mix], deepseek_v4 => [reasoning_and_content, tool_call_mix], glm47 => [reasoning_and_content, tool_call_mix], + seed_oss => [reasoning_and_content], + step3p5 => [reasoning_and_content], // Note: Kimi K2.5 strips the reasoning content in history. kimi_k25 => [tool_call_mix], diff --git a/rust/src/reasoning-parser/src/delimited.rs b/rust/src/reasoning-parser/src/delimited.rs index 485202e3e2..69b4db5f18 100644 --- a/rust/src/reasoning-parser/src/delimited.rs +++ b/rust/src/reasoning-parser/src/delimited.rs @@ -68,6 +68,11 @@ impl DelimitedReasoningParser { .unwrap_or(self.default_in_reasoning); } + /// Return whether the parser is currently inside a reasoning section. + pub(crate) fn in_reasoning(&self) -> bool { + self.current_in_reasoning + } + /// Parse one decoded text delta and return its reasoning/content split. pub(crate) fn push(&mut self, delta: &str) -> ReasoningDelta { self.buffer.push_str(delta); diff --git a/rust/src/reasoning-parser/src/lib.rs b/rust/src/reasoning-parser/src/lib.rs index 084168ab2f..f8f8d7c872 100644 --- a/rust/src/reasoning-parser/src/lib.rs +++ b/rust/src/reasoning-parser/src/lib.rs @@ -20,6 +20,8 @@ mod delimited; mod gemma4; mod kimi; mod qwen3; +mod seed_oss; +mod step3p5; use thiserror::Error; use vllm_tokenizer::DynTokenizer; @@ -30,6 +32,8 @@ pub(crate) use self::delimited::DelimitedReasoningParser; pub use self::gemma4::Gemma4ReasoningParser; pub use self::kimi::KimiReasoningParser; pub use self::qwen3::Qwen3ReasoningParser; +pub use self::seed_oss::SeedOssReasoningParser; +pub use self::step3p5::Step3p5ReasoningParser; /// DeepSeek V3 currently shares the standard `...` parser. pub type DeepSeekV3ReasoningParser = Qwen3ReasoningParser; diff --git a/rust/src/reasoning-parser/src/seed_oss.rs b/rust/src/reasoning-parser/src/seed_oss.rs new file mode 100644 index 0000000000..f514b43a89 --- /dev/null +++ b/rust/src/reasoning-parser/src/seed_oss.rs @@ -0,0 +1,147 @@ +use vllm_tokenizer::DynTokenizer; + +use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result}; + +/// Reasoning parser for SeedOSS models using ``/`` +/// delimiters. +pub struct SeedOssReasoningParser { + inner: DelimitedReasoningParser, +} + +impl SeedOssReasoningParser { + /// Create a SeedOSS parser backed by the shared delimited state machine. + pub fn new(tokenizer: DynTokenizer) -> Result { + Ok(Self { + inner: DelimitedReasoningParser::new( + tokenizer, + "", + "", + false, + )?, + }) + } +} + +impl ReasoningParser for SeedOssReasoningParser { + fn create(tokenizer: DynTokenizer) -> Result> + where + Self: Sized + 'static, + { + Ok(Box::new(Self::new(tokenizer)?)) + } + + fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> { + self.inner.initialize(prompt_token_ids); + Ok(()) + } + + fn push(&mut self, delta: &str) -> Result { + Ok(self.inner.push(delta)) + } + + fn finish(&mut self) -> Result { + Ok(self.inner.finish()) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::SeedOssReasoningParser; + use crate::{ReasoningParser, tests::FakeTokenizer}; + + #[test] + fn without_prompt_markers_expects_start_token() { + let tokenizer = Arc::new(FakeTokenizer); + let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap(); + + let delta = parser.push("implicit reasoninganswer").unwrap(); + assert_eq!(delta.reasoning, None); + assert_eq!( + delta.content.as_deref(), + Some("implicit reasoninganswer") + ); + } + + #[test] + fn picks_up_prompt_start_boundary() { + let tokenizer = Arc::new(FakeTokenizer); + let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap(); + // Prompt prefills `` (id 10), opening reasoning before the stream. + parser.initialize(&[10]).unwrap(); + + let delta = parser.push("reasonanswer").unwrap(); + assert_eq!(delta.reasoning.as_deref(), Some("reason")); + assert_eq!(delta.content.as_deref(), Some("answer")); + } + + #[test] + fn respects_prompt_end_boundary() { + let tokenizer = Arc::new(FakeTokenizer); + let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap(); + // Prompt already closed reasoning with `` (id 11). + parser.initialize(&[11]).unwrap(); + + let delta = parser.push("answer").unwrap(); + assert_eq!(delta.reasoning, None); + assert_eq!(delta.content.as_deref(), Some("answer")); + } + + #[test] + fn handles_explicit_start_token() { + // An explicit start delimiter must not leak into reasoning text. + let tokenizer = Arc::new(FakeTokenizer); + let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap(); + + let delta = parser.push("reasonanswer").unwrap(); + assert_eq!(delta.reasoning.as_deref(), Some("reason")); + assert_eq!(delta.content.as_deref(), Some("answer")); + } + + #[test] + fn streams_explicit_start_token_across_pushes() { + // Start token, reasoning body, end token, and content arrive in separate + // streaming deltas. + let tokenizer = Arc::new(FakeTokenizer); + let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap(); + + let mut reasoning = String::new(); + let mut content = String::new(); + for delta_str in [ + "", + "Some ", + "reasoning ", + "content", + "", + "Final ", + "answer", + ] { + let delta = parser.push(delta_str).unwrap(); + if let Some(r) = delta.reasoning { + reasoning.push_str(&r); + } + if let Some(c) = delta.content { + content.push_str(&c); + } + } + assert_eq!(reasoning, "Some reasoning content"); + assert_eq!(content, "Final answer"); + } + + #[test] + fn handles_partial_delimiters_across_pushes() { + let tokenizer = Arc::new(FakeTokenizer); + let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap(); + parser.initialize(&[10]).unwrap(); + + // Closing delimiter `` arrives in two halves. + let first = parser.push("reasonanswer").unwrap(); + assert_eq!(second.reasoning, None); + assert_eq!(second.content.as_deref(), Some("answer")); + } +} diff --git a/rust/src/reasoning-parser/src/step3p5.rs b/rust/src/reasoning-parser/src/step3p5.rs new file mode 100644 index 0000000000..e369531c92 --- /dev/null +++ b/rust/src/reasoning-parser/src/step3p5.rs @@ -0,0 +1,308 @@ +use vllm_tokenizer::DynTokenizer; + +use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result}; + +/// Reasoning parser for Step3p5 outputs. +/// +/// Step3p5 uses standard ``/`` delimiters but emits a `\n` +/// immediately before and/or after ``. The parser drops these framing +/// newlines on both sides of the boundary, holding a trailing `\n` from +/// reasoning across pushes until either more reasoning text or `` +/// arrives, and dropping a leading `\n` from the first content delta after +/// the boundary. +pub struct Step3p5ReasoningParser { + inner: DelimitedReasoningParser, + /// `\n` at end of last reasoning delta, held in case `` follows. + pending_reasoning_newline: bool, + /// Last push ended on `` without emitting content; the next + /// content delta's leading `\n` should be dropped. + just_ended_reasoning: bool, +} + +impl Step3p5ReasoningParser { + /// Create a Step3p5 parser backed by the shared delimited state machine. + pub fn new(tokenizer: DynTokenizer) -> Result { + Ok(Self { + inner: DelimitedReasoningParser::new(tokenizer, "", "", false)?, + pending_reasoning_newline: false, + just_ended_reasoning: false, + }) + } + + /// Drop framing newlines around `` and track held-newline state. + fn process( + &mut self, + mut inner_delta: ReasoningDelta, + was_in_reasoning: bool, + now_in_reasoning: bool, + ) -> ReasoningDelta { + // A `...` round-trip in one push still counts as a + // transition: the inner emits reasoning while ending in content mode. + let transitioned = + !now_in_reasoning && (was_in_reasoning || inner_delta.reasoning.is_some()); + + // Replay or drop a previously-held trailing reasoning newline. + if self.pending_reasoning_newline { + if let Some(reasoning) = inner_delta.reasoning.as_mut() { + reasoning.insert(0, '\n'); + self.pending_reasoning_newline = false; + } else if transitioned { + // The held `\n` was the one right before ``: drop it. + self.pending_reasoning_newline = false; + } + } + + // Hold back a trailing reasoning `\n` until we know if `` follows. + if let Some(reasoning) = inner_delta.reasoning.as_mut() + && reasoning.ends_with('\n') + { + reasoning.pop(); + if !transitioned { + self.pending_reasoning_newline = true; + } + } + + // Drop a leading `\n` of content emitted right after ``. + if let Some(content) = inner_delta.content.as_mut() + && (transitioned || self.just_ended_reasoning) + && content.starts_with('\n') + { + content.remove(0); + } + + self.just_ended_reasoning = transitioned && inner_delta.content.is_none(); + + if inner_delta.reasoning.as_deref() == Some("") { + inner_delta.reasoning = None; + } + if inner_delta.content.as_deref() == Some("") { + inner_delta.content = None; + } + + inner_delta + } +} + +impl ReasoningParser for Step3p5ReasoningParser { + fn create(tokenizer: DynTokenizer) -> Result> + where + Self: Sized + 'static, + { + Ok(Box::new(Self::new(tokenizer)?)) + } + + fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> { + self.inner.initialize(prompt_token_ids); + Ok(()) + } + + fn push(&mut self, delta: &str) -> Result { + let was = self.inner.in_reasoning(); + let inner_delta = self.inner.push(delta); + let now = self.inner.in_reasoning(); + Ok(self.process(inner_delta, was, now)) + } + + fn finish(&mut self) -> Result { + let was = self.inner.in_reasoning(); + let inner_delta = self.inner.finish(); + let now = self.inner.in_reasoning(); + let mut delta = self.process(inner_delta, was, now); + + // Emit a still-held newline rather than silently dropping it. + if self.pending_reasoning_newline { + match delta.reasoning.as_mut() { + Some(existing) => existing.push('\n'), + None => delta.reasoning = Some("\n".to_string()), + } + self.pending_reasoning_newline = false; + } + + Ok(delta) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::Step3p5ReasoningParser; + use crate::{ReasoningParser, tests::FakeTokenizer}; + + #[test] + fn picks_up_prompt_start_boundary() { + let tokenizer = Arc::new(FakeTokenizer); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + // Prompt prefills `` (id 1), opening reasoning before the stream. + parser.initialize(&[1]).unwrap(); + + let delta = parser.push("This is a reasoning sectionThis is the rest").unwrap(); + assert_eq!( + delta.reasoning.as_deref(), + Some("This is a reasoning section") + ); + assert_eq!(delta.content.as_deref(), Some("This is the rest")); + } + + #[test] + fn handles_unterminated_reasoning() { + let tokenizer = Arc::new(FakeTokenizer); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + + let pushed = parser.push("reason without end").unwrap(); + assert_eq!(pushed.reasoning.as_deref(), Some("reason without end")); + assert_eq!(pushed.content, None); + + let flushed = parser.finish().unwrap(); + assert!(flushed.is_empty()); + } + + #[test] + fn handles_empty_input() { + let tokenizer = Arc::new(FakeTokenizer); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + + let pushed = parser.push("").unwrap(); + assert!(pushed.is_empty()); + let flushed = parser.finish().unwrap(); + assert!(flushed.is_empty()); + } + + #[test] + fn complex_newline_pattern_trims_only_single_framing_newline_each_side() { + // Only the immediately-adjacent framing `\n` is dropped on each side of + // ``; surrounding newlines remain part of reasoning/content. + let tokenizer = Arc::new(FakeTokenizer); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + parser.initialize(&[1]).unwrap(); + + let delta = parser + .push("\n This is a \n reasoning section\n\n\n\n\nThis is the rest") + .unwrap(); + assert_eq!( + delta.reasoning.as_deref(), + Some("\n This is a \n reasoning section\n\n") + ); + assert_eq!(delta.content.as_deref(), Some("\nThis is the rest")); + } + + #[test] + fn drops_framing_newlines_in_single_push() { + let tokenizer = Arc::new(FakeTokenizer); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + + let delta = parser.push("reason\n\nanswer").unwrap(); + assert_eq!(delta.reasoning.as_deref(), Some("reason")); + assert_eq!(delta.content.as_deref(), Some("answer")); + } + + #[test] + fn drops_framing_newlines_across_pushes() { + let tokenizer = Arc::new(FakeTokenizer); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + + // The trailing `\n` from the first push is held until we know whether + // `` follows. + let first = parser.push("reason\n").unwrap(); + assert_eq!(first.reasoning.as_deref(), Some("reason")); + assert_eq!(first.content, None); + + // `` arrives standalone; the held newline should be dropped. + let second = parser.push("").unwrap(); + assert!(second.is_empty()); + + // The leading newline of the first content delta is dropped. + let third = parser.push("\nanswer").unwrap(); + assert_eq!(third.reasoning, None); + assert_eq!(third.content.as_deref(), Some("answer")); + } + + #[test] + fn replays_held_newline_when_more_reasoning_follows() { + let tokenizer = Arc::new(FakeTokenizer); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + + let first = parser.push("reason\n").unwrap(); + assert_eq!(first.reasoning.as_deref(), Some("reason")); + + let second = parser.push("more reason").unwrap(); + assert_eq!(second.reasoning.as_deref(), Some("\nmore reason")); + assert_eq!(second.content, None); + } + + #[test] + fn finish_flushes_held_newline_in_unterminated_stream() { + let tokenizer = Arc::new(FakeTokenizer); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + + let first = parser.push("reason\n").unwrap(); + assert_eq!(first.reasoning.as_deref(), Some("reason")); + + let flushed = parser.finish().unwrap(); + assert_eq!(flushed.reasoning.as_deref(), Some("\n")); + assert_eq!(flushed.content, None); + } + + #[test] + fn preserves_inner_newlines_in_reasoning() { + let tokenizer = Arc::new(FakeTokenizer); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + + let delta = parser.push("line1\nline2tail").unwrap(); + assert_eq!(delta.reasoning.as_deref(), Some("line1\nline2")); + assert_eq!(delta.content.as_deref(), Some("tail")); + } + + #[test] + fn trims_only_one_trailing_reasoning_newline() { + // Only the single framing newline immediately before `` is + // dropped; earlier newlines in the reasoning body are preserved. + let tokenizer = Arc::new(FakeTokenizer); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + + let delta = parser.push("reason\n\nanswer").unwrap(); + assert_eq!(delta.reasoning.as_deref(), Some("reason\n")); + assert_eq!(delta.content.as_deref(), Some("answer")); + } + + #[test] + fn drops_only_first_content_newline_after_transition() { + // The leading-`\n` drop applies only to the first content delta after + // ``; later deltas pass through untouched. + let tokenizer = Arc::new(FakeTokenizer); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + + let first = parser.push("reason").unwrap(); + assert_eq!(first.reasoning.as_deref(), Some("reason")); + assert_eq!(first.content, None); + + let second = parser.push("\nfirst").unwrap(); + assert_eq!(second.reasoning, None); + assert_eq!(second.content.as_deref(), Some("first")); + + // A `\n` arriving in a later content delta must NOT be dropped. + let third = parser.push("\nsecond").unwrap(); + assert_eq!(third.reasoning, None); + assert_eq!(third.content.as_deref(), Some("\nsecond")); + } + + #[test] + fn passes_through_clean_boundary_without_framing_newlines() { + let tokenizer = Arc::new(FakeTokenizer); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + + let delta = parser.push("reasontail").unwrap(); + assert_eq!(delta.reasoning.as_deref(), Some("reason")); + assert_eq!(delta.content.as_deref(), Some("tail")); + } + + #[test] + fn handles_empty_reasoning_section() { + let tokenizer = Arc::new(FakeTokenizer); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + + let delta = parser.push("answer").unwrap(); + assert_eq!(delta.reasoning, None); + assert_eq!(delta.content.as_deref(), Some("answer")); + } +} diff --git a/rust/src/reasoning-parser/src/tests.rs b/rust/src/reasoning-parser/src/tests.rs index da602d9fdd..7e33e0cfc1 100644 --- a/rust/src/reasoning-parser/src/tests.rs +++ b/rust/src/reasoning-parser/src/tests.rs @@ -6,7 +6,7 @@ use super::{ DeepSeekR1ReasoningParser, DelimitedReasoningParser, Qwen3ReasoningParser, ReasoningParser, }; -struct FakeTokenizer; +pub(crate) struct FakeTokenizer; impl Tokenizer for FakeTokenizer { fn encode(&self, text: &str, _add_special_tokens: bool) -> vllm_tokenizer::Result> { @@ -32,6 +32,8 @@ impl Tokenizer for FakeTokenizer { "<|END_THINKING|>" => Some(4), "◁think▷" => Some(5), "◁/think▷" => Some(6), + "" => Some(10), + "" => Some(11), _ => None, } }