From ea1d65fe6df278678b909ca1e98dfb6fd206d8cc Mon Sep 17 00:00:00 2001 From: Chao-Ju Chen Date: Thu, 16 Jul 2026 17:28:02 +0800 Subject: [PATCH] [Rust Frontend] Add Seed-OSS tool parser (#47741) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Signed-off-by: RickyChen / 陳昭儒 --- rust/src/chat/src/lib.rs | 2 +- rust/src/chat/src/parser/tool/mod.rs | 10 +- rust/src/chat/src/parser/tool/tests.rs | 4 + rust/src/chat/tests/roundtrip.rs | 2 +- rust/src/parser/src/tool/mod.rs | 2 + rust/src/parser/src/tool/qwen_coder.rs | 77 ++++++-- rust/src/parser/src/tool/seed_oss.rs | 253 +++++++++++++++++++++++++ 7 files changed, 332 insertions(+), 18 deletions(-) create mode 100644 rust/src/parser/src/tool/seed_oss.rs diff --git a/rust/src/chat/src/lib.rs b/rust/src/chat/src/lib.rs index 7937afa130..3188657b90 100644 --- a/rust/src/chat/src/lib.rs +++ b/rust/src/chat/src/lib.rs @@ -294,7 +294,7 @@ mod tests { ) .unwrap_err(); - expect_test::expect!["tool parser `definitely_missing_tool_parser` is not registered (choose from: deepseek_v3, deepseek_v31, deepseek_v32, deepseek_v4, gemma4, glm45, glm47, granite4, hermes, hy_v3, inkling, internlm, kimi_k2, llama3_json, llama4_json, minimax_m2, minimax_m3, mistral, phi4_mini_json, qwen3_coder, qwen3_xml)"].assert_eq(&error.to_report_string()); + expect_test::expect!["tool parser `definitely_missing_tool_parser` is not registered (choose from: deepseek_v3, deepseek_v31, deepseek_v32, deepseek_v4, gemma4, glm45, glm47, granite4, hermes, hy_v3, inkling, internlm, kimi_k2, llama3_json, llama4_json, minimax_m2, minimax_m3, mistral, phi4_mini_json, qwen3_coder, qwen3_xml, seed_oss)"].assert_eq(&error.to_report_string()); } #[test] diff --git a/rust/src/chat/src/parser/tool/mod.rs b/rust/src/chat/src/parser/tool/mod.rs index d8ca1fac8d..78ccde8a4b 100644 --- a/rust/src/chat/src/parser/tool/mod.rs +++ b/rust/src/chat/src/parser/tool/mod.rs @@ -10,7 +10,7 @@ pub use vllm_parser::tool::{ Glm45MoeToolParser, Glm47MoeToolParser, Granite4ToolParser, HermesToolParser, HyV3ToolParser, Internlm2ToolParser, KimiK2ToolParser, Llama3JsonToolParser, MinimaxM2ToolParser, MinimaxM3ToolParser, MistralToolParser, Phi4MiniJsonToolParser, Qwen3CoderToolParser, - Qwen3XmlToolParser, ToolParser, ToolParserError, + Qwen3XmlToolParser, SeedOssToolParser, ToolParser, ToolParserError, }; use crate::parser::ParserFactory; @@ -41,6 +41,7 @@ pub mod names { pub const PHI4_MINI_JSON: &str = "phi4_mini_json"; pub const QWEN3_CODER: &str = "qwen3_coder"; pub const QWEN3_XML: &str = "qwen3_xml"; + pub const SEED_OSS: &str = "seed_oss"; } /// Constructor signature for one registered tool parser implementation. @@ -84,7 +85,8 @@ impl ToolParserFactory { .register_parser::(names::MISTRAL) .register_parser::(names::PHI4_MINI_JSON) .register_parser::(names::QWEN3_XML) - .register_parser::(names::QWEN3_CODER); + .register_parser::(names::QWEN3_CODER) + .register_parser::(names::SEED_OSS); factory .register_pattern("mistral-", names::MISTRAL) @@ -122,7 +124,9 @@ impl ToolParserFactory { .register_pattern("minimax-m3", names::MINIMAX_M3) .register_pattern("mm-m3", names::MINIMAX_M3) .register_pattern("minimax", names::MINIMAX_M2) - .register_pattern("mm-m2", names::MINIMAX_M2); + .register_pattern("mm-m2", names::MINIMAX_M2) + .register_pattern("seed-oss", names::SEED_OSS) + .register_pattern("seedoss", names::SEED_OSS); factory } diff --git a/rust/src/chat/src/parser/tool/tests.rs b/rust/src/chat/src/parser/tool/tests.rs index bb89ea38fe..c89a50b224 100644 --- a/rust/src/chat/src/parser/tool/tests.rs +++ b/rust/src/chat/src/parser/tool/tests.rs @@ -176,6 +176,10 @@ fn factory_new_resolves_default_patterns() { factory.resolve_name_for_model("org/mm-m2-base"), Some(names::MINIMAX_M2) ); + assert_eq!( + factory.resolve_name_for_model("ByteDance-Seed/Seed-OSS-36B-Instruct"), + Some(names::SEED_OSS) + ); // InternLM2 positive: both dashed and underscored versioned names route. assert_eq!( diff --git a/rust/src/chat/tests/roundtrip.rs b/rust/src/chat/tests/roundtrip.rs index e06bf6e5da..ff15aa929e 100644 --- a/rust/src/chat/tests/roundtrip.rs +++ b/rust/src/chat/tests/roundtrip.rs @@ -307,7 +307,7 @@ roundtrip_tests! { deepseek_v32 => [tool_call_mix], glm45 => [reasoning_and_content, tool_call_mix], glm47 => [reasoning_and_content, tool_call_mix], - seed_oss => [reasoning_and_content], + seed_oss => [reasoning_and_content, tool_call_mix], step3p5 => [reasoning_and_content], nemotron_v3 => [reasoning_and_content], gemma4 => [tool_call_mix], // Gemma4 strips reasoning in history if there's no tool call diff --git a/rust/src/parser/src/tool/mod.rs b/rust/src/parser/src/tool/mod.rs index 0ee72093b4..216d3dff4c 100644 --- a/rust/src/parser/src/tool/mod.rs +++ b/rust/src/parser/src/tool/mod.rs @@ -15,6 +15,7 @@ mod minimax_m2; mod minimax_m3; mod parameters; mod qwen_coder; +mod seed_oss; #[cfg(any(test, feature = "test-util"))] pub mod test_utils; use std::collections::{BTreeMap, btree_map}; @@ -32,6 +33,7 @@ pub use kimi_k2::KimiK2ToolParser; pub use minimax_m2::MinimaxM2ToolParser; pub use minimax_m3::MinimaxM3ToolParser; pub use qwen_coder::Qwen3CoderToolParser; +pub use seed_oss::SeedOssToolParser; use serde::{Deserialize, Serialize}; use serde_json::Value; pub use xgrammar_structural_tag::Model as StructuralTagModel; diff --git a/rust/src/parser/src/tool/qwen_coder.rs b/rust/src/parser/src/tool/qwen_coder.rs index f864984192..361de524e8 100644 --- a/rust/src/parser/src/tool/qwen_coder.rs +++ b/rust/src/parser/src/tool/qwen_coder.rs @@ -19,6 +19,28 @@ const FUNCTION_END: &str = ""; const PARAMETER_START: &str = "` / `` tags are always +/// byte-identical. Seed-OSS, for example, wraps the same body in +/// `` / ``. +#[derive(Debug, Clone, Copy)] +pub(crate) struct QwenCoderConfig { + /// Human-readable parser name used in error messages. + pub(crate) parser_name: &'static str, + /// Marker that opens a tool-call block. + pub(crate) tool_call_start: &'static str, + /// Marker that closes a tool-call block. + pub(crate) tool_call_end: &'static str, +} + +const QWEN_CODER_CONFIG: QwenCoderConfig = QwenCoderConfig { + parser_name: "Qwen Coder", + tool_call_start: TOOL_CALL_START, + tool_call_end: TOOL_CALL_END, +}; + type QwenCoderInput<'i> = Partial<&'i str>; #[derive(Debug, Clone, PartialEq, Eq)] @@ -60,16 +82,24 @@ pub struct Qwen3CoderToolParser { mode: QwenCoderMode, emitted_tool_count: usize, tool_parameters: ToolSchemas, + config: QwenCoderConfig, } impl Qwen3CoderToolParser { /// Create a Qwen Coder tool parser. fn new(tools: &[Tool]) -> Self { + Self::with_config(tools, QWEN_CODER_CONFIG) + } + + /// Create a parser for a model that reuses the Qwen Coder grammar with a + /// different tool-call wrapper (e.g. Seed-OSS). + pub(crate) fn with_config(tools: &[Tool], config: QwenCoderConfig) -> Self { Self { buffer: String::new(), mode: QwenCoderMode::Text, emitted_tool_count: 0, tool_parameters: ToolSchemas::from_tools(tools), + config, } } @@ -122,9 +152,10 @@ impl ToolParser for Qwen3CoderToolParser { fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> { self.buffer.push_str(chunk); + let config = self.config; while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| { - parse_next_qwen_coder_event(input, &mut self.mode) + parse_next_qwen_coder_event(input, &mut self.mode, config) })? { self.apply_event(event, output)?; self.buffer.drain(..consumed_len); @@ -137,9 +168,12 @@ impl ToolParser for Qwen3CoderToolParser { let mut output = ToolParserOutput::default(); if !self.buffer.is_empty() { if matches!(self.mode, QwenCoderMode::ToolCall { .. }) - || self.buffer.starts_with(TOOL_CALL_START) + || self.buffer.starts_with(self.config.tool_call_start) { - return Err(parsing_failed!("incomplete Qwen Coder tool call")); + return Err(parsing_failed!( + "incomplete {} tool call", + self.config.parser_name + )); } output.push_text(&self.buffer); } @@ -156,37 +190,54 @@ impl ToolParser for Qwen3CoderToolParser { fn parse_next_qwen_coder_event( input: &mut QwenCoderInput<'_>, mode: &mut QwenCoderMode, + config: QwenCoderConfig, ) -> ModalResult { match mode { - QwenCoderMode::Text => parse_text_event(input), - QwenCoderMode::ToolCall { end_marker_scan } => tool_call_event(input, end_marker_scan), + QwenCoderMode::Text => parse_text_event(input, config), + QwenCoderMode::ToolCall { end_marker_scan } => { + tool_call_event(input, end_marker_scan, config.tool_call_end) + } } } /// Parse a text-mode Qwen Coder event. -fn parse_text_event(input: &mut QwenCoderInput<'_>) -> ModalResult { - alt((tool_call_start_event, safe_text_event)).parse_next(input) +fn parse_text_event( + input: &mut QwenCoderInput<'_>, + config: QwenCoderConfig, +) -> ModalResult { + alt(( + |input: &mut QwenCoderInput<'_>| tool_call_start_event(input, config.tool_call_start), + |input: &mut QwenCoderInput<'_>| safe_text_event(input, config.tool_call_start), + )) + .parse_next(input) } /// Parse a Qwen Coder tool-call start marker. -fn tool_call_start_event(input: &mut QwenCoderInput<'_>) -> ModalResult { - literal(TOOL_CALL_START).value(QwenCoderEvent::ToolCallStart).parse_next(input) +fn tool_call_start_event( + input: &mut QwenCoderInput<'_>, + tool_call_start: &'static str, +) -> ModalResult { + literal(tool_call_start).value(QwenCoderEvent::ToolCallStart).parse_next(input) } /// Parse a safe text run before the next Qwen Coder marker. -fn safe_text_event(input: &mut QwenCoderInput<'_>) -> ModalResult { - safe_text_len(input, TOOL_CALL_START).map(|len| QwenCoderEvent::Text { len }) +fn safe_text_event( + input: &mut QwenCoderInput<'_>, + tool_call_start: &'static str, +) -> ModalResult { + safe_text_len(input, tool_call_start).map(|len| QwenCoderEvent::Text { len }) } /// Parse a complete Qwen Coder tool call. fn tool_call_event( input: &mut QwenCoderInput<'_>, end_marker_scan: &mut MarkerScanState, + tool_call_end: &'static str, ) -> ModalResult { let (body,) = seq!( _: ws0, - take_until_marker(TOOL_CALL_END, end_marker_scan), - _: literal(TOOL_CALL_END), + take_until_marker(tool_call_end, end_marker_scan), + _: literal(tool_call_end), ) .parse_next(input)?; diff --git a/rust/src/parser/src/tool/seed_oss.rs b/rust/src/parser/src/tool/seed_oss.rs new file mode 100644 index 0000000000..dae7c5b2c5 --- /dev/null +++ b/rust/src/parser/src/tool/seed_oss.rs @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +use super::qwen_coder::{Qwen3CoderToolParser, QwenCoderConfig}; +use crate::tool::{Result, Tool, ToolParser, ToolParserOutput}; + +const SEED_OSS_CONFIG: QwenCoderConfig = QwenCoderConfig { + parser_name: "Seed-OSS", + tool_call_start: "", + tool_call_end: "", +}; + +/// Tool parser for Seed-OSS XML-style tool calls. +/// +/// Example tool call content: +/// +/// ```text +/// +/// +/// 杭州 +/// +/// +/// ``` +/// +/// Seed-OSS shares the Qwen3 Coder grammar exactly; only the two tool-call +/// wrapper tokens differ (`` / ``). The inner +/// `` / `` grammar and schema-driven argument +/// conversion are byte-identical, so this delegates to a +/// [`Qwen3CoderToolParser`] configured with the Seed-OSS markers. This mirrors +/// Python `SeedOssParser(Qwen3Parser)` in `vllm/parser/seed_oss.py`. +/// +/// Structured-output tags are intentionally unsupported: the trait default +/// `structural_tag_model() -> None` matches Python `SeedOssEngineToolParser`, +/// which sets `structural_tag_model = None` (xgrammar has no Seed-OSS model). +/// +/// The `` wrapper tokens are added-vocabulary tokens +/// (`special = false`), not tokenizer special tokens, so they survive decoding +/// under the production default `skip_special_tokens = true`; the trait default +/// `preserve_special_tokens() == false` is therefore correct, matching the +/// sibling `SeedOssReasoningParser`, which relies on the same for ``. +pub struct SeedOssToolParser { + inner: Qwen3CoderToolParser, +} + +impl SeedOssToolParser { + /// Create a Seed-OSS tool parser. + fn new(tools: &[Tool]) -> Self { + Self { + inner: Qwen3CoderToolParser::with_config(tools, SEED_OSS_CONFIG), + } + } +} + +impl ToolParser for SeedOssToolParser { + fn create(tools: &[Tool]) -> Result> + where + Self: Sized + 'static, + { + Ok(Box::new(Self::new(tools))) + } + + fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> { + self.inner.parse_into(chunk, output) + } + + fn finish(&mut self) -> Result { + self.inner.finish() + } + + fn reset(&mut self) -> String { + self.inner.reset() + } +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + use thiserror_ext::AsReport; + + use super::SeedOssToolParser; + use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools}; + use crate::tool::{ToolParser, ToolParserOutput, ToolParserTestExt as _}; + + fn build_tool_call(function_name: &str, params: &[(&str, &str)]) -> String { + let params = params + .iter() + .map(|(name, value)| format!("{value}")) + .collect::>() + .join("\n"); + format!( + "\n\n{params}\n\n" + ) + } + + #[test] + fn seed_oss_parse_complete_without_tool_call_keeps_text() { + let mut parser = SeedOssToolParser::new(&test_tools()); + let output = parser.parse_complete("Hello, world!").unwrap(); + + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); + } + + #[test] + fn seed_oss_does_not_treat_plain_tool_call_marker_as_start() { + // The Qwen Coder wrapper `` must NOT trigger the Seed-OSS + // parser; only `` does. + let mut parser = SeedOssToolParser::new(&test_tools()); + let output = parser + .parse_complete("\n\n\n") + .unwrap(); + + assert_eq!( + output.normal_text(), + "\n\n\n" + ); + assert!(output.calls().is_empty()); + } + + #[test] + fn seed_oss_parse_complete_extracts_single_tool_call() { + let mut parser = SeedOssToolParser::new(&test_tools()); + let output = parser + .parse_complete(&build_tool_call( + "get_weather", + &[("location", "SF"), ("date", "2026-04-29")], + )) + .unwrap(); + + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!( + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), + json!({ "location": "SF", "date": "2026-04-29" }) + ); + } + + #[test] + fn seed_oss_parse_complete_preserves_prefix_text() { + let mut parser = SeedOssToolParser::new(&test_tools()); + let input = format!( + "Thinking... {}", + build_tool_call("get_weather", &[("location", "NYC")]) + ); + let output = parser.parse_complete(&input).unwrap(); + + assert_eq!(output.normal_text(), "Thinking... "); + assert_eq!(output.calls().len(), 1); + } + + #[test] + fn seed_oss_parse_complete_converts_schema_types() { + let mut parser = SeedOssToolParser::new(&test_tools()); + let output = parser + .parse_complete(&build_tool_call( + "convert", + &[ + ("whole", "5.0"), + ("flag", "true"), + ("payload", r#"{"nested":true}"#), + ("items", "[1,2]"), + ("empty", "42"), + ], + )) + .unwrap(); + + assert_eq!(output.calls().len(), 1); + assert_eq!( + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), + json!({ + "whole": 5.0, + "flag": true, + "payload": { "nested": true }, + "items": [1, 2], + "empty": "42", + }) + ); + } + + #[test] + fn seed_oss_streaming_extracts_multiple_tool_calls_in_order() { + let text = format!( + "{}\n{}", + build_tool_call("get_weather", &[("location", "SF")]), + build_tool_call("get_weather", &[("location", "NYC")]) + ); + let chunks = split_by_chars(&text, 7); + let mut parser = SeedOssToolParser::new(&test_tools()); + + let output = collect_stream(&mut parser, &chunks); + + assert_eq!(output.calls().len(), 2); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[1].tool_index, 1); + assert_eq!( + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), + json!({ "location": "SF" }) + ); + assert_eq!( + serde_json::from_str::(&output.calls()[1].arguments).unwrap(), + json!({ "location": "NYC" }) + ); + } + + #[test] + fn seed_oss_streaming_handles_end_marker_split_across_chunks() { + let mut parser = SeedOssToolParser::new(&test_tools()); + let mut output = ToolParserOutput::default(); + output.append( + parser + .parse_chunk( + "\n\ + \n\ + SF\n\ + \n\ + ").unwrap()); + output.append(parser.finish().unwrap()); + let output = output.coalesce(); + + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!( + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), + json!({ "location": "SF" }) + ); + } + + #[test] + fn seed_oss_finish_fails_incomplete_tool_call() { + let mut parser = SeedOssToolParser::new(&test_tools()); + parser + .parse_chunk( + "\n\nSF", + ) + .unwrap(); + + let error = parser.finish().unwrap_err(); + + assert_eq!( + error.to_report_string(), + "tool parser parsing failed: incomplete Seed-OSS tool call" + ); + } +}