[Rust Frontend] Add seed_oss and step3p5 reasoning parsers (#44552)
Signed-off-by: yzhan1 <zhanyaoming2014@gmail.com>
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<MiniMaxM2ReasoningParser>(names::MINIMAX_M2)
|
||||
.register_parser::<NemotronV3ReasoningParser>(names::NEMOTRON_V3)
|
||||
.register_parser::<Qwen3ReasoningParser>(names::QWEN3)
|
||||
.register_parser::<Step3ReasoningParser>(names::STEP3);
|
||||
.register_parser::<SeedOssReasoningParser>(names::SEED_OSS)
|
||||
.register_parser::<Step3ReasoningParser>(names::STEP3)
|
||||
.register_parser::<Step3p5ReasoningParser>(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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -144,6 +144,30 @@ impl RoundtripCase {
|
||||
json_fmt: spaced_json_fmt(),
|
||||
}
|
||||
}
|
||||
|
||||
/// SeedOSS with `<seed:think>` / `</seed:think>` reasoning tags.
|
||||
fn seed_oss() -> Self {
|
||||
Self {
|
||||
model_id: "ByteDance-Seed/Seed-OSS-36B-Instruct",
|
||||
assistant_stop_suffix: "<seed:eos>",
|
||||
tool_call_parser: ParserSelection::Auto,
|
||||
reasoning_parser: ParserSelection::Auto,
|
||||
thinking_behavior: ThinkingBehavior::Always { value: true },
|
||||
json_fmt: compact_json_fmt(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Step-3.5 with `<think>` / `</think>` 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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 `<think>...</think>` parser.
|
||||
pub type DeepSeekV3ReasoningParser = Qwen3ReasoningParser;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
use vllm_tokenizer::DynTokenizer;
|
||||
|
||||
use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result};
|
||||
|
||||
/// Reasoning parser for SeedOSS models using `<seed:think>`/`</seed:think>`
|
||||
/// 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<Self> {
|
||||
Ok(Self {
|
||||
inner: DelimitedReasoningParser::new(
|
||||
tokenizer,
|
||||
"<seed:think>",
|
||||
"</seed:think>",
|
||||
false,
|
||||
)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ReasoningParser for SeedOssReasoningParser {
|
||||
fn create(tokenizer: DynTokenizer) -> Result<Box<dyn ReasoningParser>>
|
||||
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<ReasoningDelta> {
|
||||
Ok(self.inner.push(delta))
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> Result<ReasoningDelta> {
|
||||
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 reasoning</seed:think>answer").unwrap();
|
||||
assert_eq!(delta.reasoning, None);
|
||||
assert_eq!(
|
||||
delta.content.as_deref(),
|
||||
Some("implicit reasoning</seed:think>answer")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_up_prompt_start_boundary() {
|
||||
let tokenizer = Arc::new(FakeTokenizer);
|
||||
let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap();
|
||||
// Prompt prefills `<seed:think>` (id 10), opening reasoning before the stream.
|
||||
parser.initialize(&[10]).unwrap();
|
||||
|
||||
let delta = parser.push("reason</seed:think>answer").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 `</seed:think>` (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("<seed:think>reason</seed:think>answer").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 [
|
||||
"<seed:think>",
|
||||
"Some ",
|
||||
"reasoning ",
|
||||
"content",
|
||||
"</seed:think>",
|
||||
"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 `</seed:think>` arrives in two halves.
|
||||
let first = parser.push("reason</seed:").unwrap();
|
||||
assert_eq!(first.reasoning.as_deref(), Some("reason"));
|
||||
assert_eq!(first.content, None);
|
||||
|
||||
let second = parser.push("think>answer").unwrap();
|
||||
assert_eq!(second.reasoning, None);
|
||||
assert_eq!(second.content.as_deref(), Some("answer"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
use vllm_tokenizer::DynTokenizer;
|
||||
|
||||
use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result};
|
||||
|
||||
/// Reasoning parser for Step3p5 outputs.
|
||||
///
|
||||
/// Step3p5 uses standard `<think>`/`</think>` delimiters but emits a `\n`
|
||||
/// immediately before and/or after `</think>`. 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 `</think>`
|
||||
/// 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 `</think>` follows.
|
||||
pending_reasoning_newline: bool,
|
||||
/// Last push ended on `</think>` 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<Self> {
|
||||
Ok(Self {
|
||||
inner: DelimitedReasoningParser::new(tokenizer, "<think>", "</think>", false)?,
|
||||
pending_reasoning_newline: false,
|
||||
just_ended_reasoning: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Drop framing newlines around `</think>` and track held-newline state.
|
||||
fn process(
|
||||
&mut self,
|
||||
mut inner_delta: ReasoningDelta,
|
||||
was_in_reasoning: bool,
|
||||
now_in_reasoning: bool,
|
||||
) -> ReasoningDelta {
|
||||
// A `<think>...</think>` 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 `</think>`: drop it.
|
||||
self.pending_reasoning_newline = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Hold back a trailing reasoning `\n` until we know if `</think>` 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 `</think>`.
|
||||
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<Box<dyn ReasoningParser>>
|
||||
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<ReasoningDelta> {
|
||||
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<ReasoningDelta> {
|
||||
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 `<think>` (id 1), opening reasoning before the stream.
|
||||
parser.initialize(&[1]).unwrap();
|
||||
|
||||
let delta = parser.push("This is a reasoning section</think>This 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("<think>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
|
||||
// `</think>`; 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</think>\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("<think>reason\n</think>\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
|
||||
// `</think>` follows.
|
||||
let first = parser.push("<think>reason\n").unwrap();
|
||||
assert_eq!(first.reasoning.as_deref(), Some("reason"));
|
||||
assert_eq!(first.content, None);
|
||||
|
||||
// `</think>` arrives standalone; the held newline should be dropped.
|
||||
let second = parser.push("</think>").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("<think>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("<think>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("<think>line1\nline2</think>tail").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 `</think>` 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("<think>reason\n\n</think>answer").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
|
||||
// `</think>`; later deltas pass through untouched.
|
||||
let tokenizer = Arc::new(FakeTokenizer);
|
||||
let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap();
|
||||
|
||||
let first = parser.push("<think>reason</think>").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("<think>reason</think>tail").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("<think></think>answer").unwrap();
|
||||
assert_eq!(delta.reasoning, None);
|
||||
assert_eq!(delta.content.as_deref(), Some("answer"));
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<u32>> {
|
||||
@@ -32,6 +32,8 @@ impl Tokenizer for FakeTokenizer {
|
||||
"<|END_THINKING|>" => Some(4),
|
||||
"◁think▷" => Some(5),
|
||||
"◁/think▷" => Some(6),
|
||||
"<seed:think>" => Some(10),
|
||||
"</seed:think>" => Some(11),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user