[Rust Frontend] Add Seed-OSS tool parser (#47741)

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Signed-off-by: RickyChen / 陳昭儒 <ricky.chen@infinirc.com>
This commit is contained in:
Chao-Ju Chen
2026-07-16 17:28:02 +08:00
committed by GitHub
co-authored by mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
parent f44f3d6f79
commit ea1d65fe6d
7 changed files with 332 additions and 18 deletions
+1 -1
View File
@@ -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]
+7 -3
View File
@@ -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::<MistralToolParser>(names::MISTRAL)
.register_parser::<Phi4MiniJsonToolParser>(names::PHI4_MINI_JSON)
.register_parser::<Qwen3XmlToolParser>(names::QWEN3_XML)
.register_parser::<Qwen3CoderToolParser>(names::QWEN3_CODER);
.register_parser::<Qwen3CoderToolParser>(names::QWEN3_CODER)
.register_parser::<SeedOssToolParser>(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
}
+4
View File
@@ -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!(
+1 -1
View File
@@ -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
+2
View File
@@ -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;
+64 -13
View File
@@ -19,6 +19,28 @@ const FUNCTION_END: &str = "</function>";
const PARAMETER_START: &str = "<parameter=";
const PARAMETER_END: &str = "</parameter>";
/// Model-specific configuration for the shared Qwen Coder grammar.
///
/// Only the tool-call wrapper tokens vary across models that reuse this
/// grammar; the inner `<function=...>` / `<parameter=...>` tags are always
/// byte-identical. Seed-OSS, for example, wraps the same body in
/// `<seed:tool_call>` / `</seed:tool_call>`.
#[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<QwenCoderEvent> {
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<QwenCoderEvent> {
alt((tool_call_start_event, safe_text_event)).parse_next(input)
fn parse_text_event(
input: &mut QwenCoderInput<'_>,
config: QwenCoderConfig,
) -> ModalResult<QwenCoderEvent> {
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<QwenCoderEvent> {
literal(TOOL_CALL_START).value(QwenCoderEvent::ToolCallStart).parse_next(input)
fn tool_call_start_event(
input: &mut QwenCoderInput<'_>,
tool_call_start: &'static str,
) -> ModalResult<QwenCoderEvent> {
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<QwenCoderEvent> {
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<QwenCoderEvent> {
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<QwenCoderEvent> {
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)?;
+253
View File
@@ -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: "<seed:tool_call>",
tool_call_end: "</seed:tool_call>",
};
/// Tool parser for Seed-OSS XML-style tool calls.
///
/// Example tool call content:
///
/// ```text
/// <seed:tool_call>
/// <function=get_weather>
/// <parameter=location>杭州</parameter>
/// </function>
/// </seed:tool_call>
/// ```
///
/// Seed-OSS shares the Qwen3 Coder grammar exactly; only the two tool-call
/// wrapper tokens differ (`<seed:tool_call>` / `</seed:tool_call>`). The inner
/// `<function=...>` / `<parameter=...>` 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 `<seed:tool_call>` 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 `<seed:think>`.
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<Box<dyn ToolParser>>
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<ToolParserOutput> {
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!("<parameter={name}>{value}</parameter>"))
.collect::<Vec<_>>()
.join("\n");
format!(
"<seed:tool_call>\n<function={function_name}>\n{params}\n</function>\n</seed:tool_call>"
)
}
#[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 `<tool_call>` must NOT trigger the Seed-OSS
// parser; only `<seed:tool_call>` does.
let mut parser = SeedOssToolParser::new(&test_tools());
let output = parser
.parse_complete("<tool_call>\n<function=get_weather>\n</function>\n</tool_call>")
.unwrap();
assert_eq!(
output.normal_text(),
"<tool_call>\n<function=get_weather>\n</function>\n</tool_call>"
);
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::<Value>(&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::<Value>(&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::<Value>(&output.calls()[0].arguments).unwrap(),
json!({ "location": "SF" })
);
assert_eq!(
serde_json::from_str::<Value>(&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(
"<seed:tool_call>\n\
<function=get_weather>\n\
<parameter=location>SF</parameter>\n\
</function>\n\
</seed:tool",
)
.unwrap(),
);
assert!(output.normal_text().is_empty());
assert!(output.calls().is_empty());
output.append(parser.parse_chunk("_call>").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::<Value>(&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(
"<seed:tool_call>\n<function=get_weather>\n<parameter=location>SF</parameter>",
)
.unwrap();
let error = parser.finish().unwrap_err();
assert_eq!(
error.to_report_string(),
"tool parser parsing failed: incomplete Seed-OSS tool call"
);
}
}