[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>

Chao-Ju Chen committed Jul 16, 2026 at 17:28 UTC ea1d65fe6df278678b909ca1e98dfb6fd206d8cc
7 files changed +332 -18
rust/src/chat/src/lib.rs
+1 -1
@@ -294,7 +294,7 @@ mod tests {
294 )
295 .unwrap_err();
296
297 - 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());
297 + 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());
298 }
299
300 #[test]
rust/src/chat/src/parser/tool/mod.rs
+7 -3
@@ -10,7 +10,7 @@ pub use vllm_parser::tool::{
10 Glm45MoeToolParser, Glm47MoeToolParser, Granite4ToolParser, HermesToolParser, HyV3ToolParser,
11 Internlm2ToolParser, KimiK2ToolParser, Llama3JsonToolParser, MinimaxM2ToolParser,
12 MinimaxM3ToolParser, MistralToolParser, Phi4MiniJsonToolParser, Qwen3CoderToolParser,
13 - Qwen3XmlToolParser, ToolParser, ToolParserError,
13 + Qwen3XmlToolParser, SeedOssToolParser, ToolParser, ToolParserError,
14 };
15
16 use crate::parser::ParserFactory;
@@ -41,6 +41,7 @@ pub mod names {
41 pub const PHI4_MINI_JSON: &str = "phi4_mini_json";
42 pub const QWEN3_CODER: &str = "qwen3_coder";
43 pub const QWEN3_XML: &str = "qwen3_xml";
44 + pub const SEED_OSS: &str = "seed_oss";
45 }
46
47 /// Constructor signature for one registered tool parser implementation.
@@ -84,7 +85,8 @@ impl ToolParserFactory {
85 .register_parser::<MistralToolParser>(names::MISTRAL)
86 .register_parser::<Phi4MiniJsonToolParser>(names::PHI4_MINI_JSON)
87 .register_parser::<Qwen3XmlToolParser>(names::QWEN3_XML)
87 - .register_parser::<Qwen3CoderToolParser>(names::QWEN3_CODER);
88 + .register_parser::<Qwen3CoderToolParser>(names::QWEN3_CODER)
89 + .register_parser::<SeedOssToolParser>(names::SEED_OSS);
90
91 factory
92 .register_pattern("mistral-", names::MISTRAL)
@@ -122,7 +124,9 @@ impl ToolParserFactory {
124 .register_pattern("minimax-m3", names::MINIMAX_M3)
125 .register_pattern("mm-m3", names::MINIMAX_M3)
126 .register_pattern("minimax", names::MINIMAX_M2)
125 - .register_pattern("mm-m2", names::MINIMAX_M2);
127 + .register_pattern("mm-m2", names::MINIMAX_M2)
128 + .register_pattern("seed-oss", names::SEED_OSS)
129 + .register_pattern("seedoss", names::SEED_OSS);
130
131 factory
132 }
rust/src/chat/src/parser/tool/tests.rs
+4
@@ -176,6 +176,10 @@ fn factory_new_resolves_default_patterns() {
176 factory.resolve_name_for_model("org/mm-m2-base"),
177 Some(names::MINIMAX_M2)
178 );
179 + assert_eq!(
180 + factory.resolve_name_for_model("ByteDance-Seed/Seed-OSS-36B-Instruct"),
181 + Some(names::SEED_OSS)
182 + );
183
184 // InternLM2 positive: both dashed and underscored versioned names route.
185 assert_eq!(
rust/src/chat/tests/roundtrip.rs
+1 -1
@@ -307,7 +307,7 @@ roundtrip_tests! {
307 deepseek_v32 => [tool_call_mix],
308 glm45 => [reasoning_and_content, tool_call_mix],
309 glm47 => [reasoning_and_content, tool_call_mix],
310 - seed_oss => [reasoning_and_content],
310 + seed_oss => [reasoning_and_content, tool_call_mix],
311 step3p5 => [reasoning_and_content],
312 nemotron_v3 => [reasoning_and_content],
313 gemma4 => [tool_call_mix], // Gemma4 strips reasoning in history if there's no tool call
rust/src/parser/src/tool/mod.rs
+2
@@ -15,6 +15,7 @@ mod minimax_m2;
15 mod minimax_m3;
16 mod parameters;
17 mod qwen_coder;
18 +mod seed_oss;
19 #[cfg(any(test, feature = "test-util"))]
20 pub mod test_utils;
21 use std::collections::{BTreeMap, btree_map};
@@ -32,6 +33,7 @@ pub use kimi_k2::KimiK2ToolParser;
33 pub use minimax_m2::MinimaxM2ToolParser;
34 pub use minimax_m3::MinimaxM3ToolParser;
35 pub use qwen_coder::Qwen3CoderToolParser;
36 +pub use seed_oss::SeedOssToolParser;
37 use serde::{Deserialize, Serialize};
38 use serde_json::Value;
39 pub use xgrammar_structural_tag::Model as StructuralTagModel;
rust/src/parser/src/tool/qwen_coder.rs
+64 -13
@@ -19,6 +19,28 @@ const FUNCTION_END: &str = "</function>";
19 const PARAMETER_START: &str = "<parameter=";
20 const PARAMETER_END: &str = "</parameter>";
21
22 +/// Model-specific configuration for the shared Qwen Coder grammar.
23 +///
24 +/// Only the tool-call wrapper tokens vary across models that reuse this
25 +/// grammar; the inner `<function=...>` / `<parameter=...>` tags are always
26 +/// byte-identical. Seed-OSS, for example, wraps the same body in
27 +/// `<seed:tool_call>` / `</seed:tool_call>`.
28 +#[derive(Debug, Clone, Copy)]
29 +pub(crate) struct QwenCoderConfig {
30 + /// Human-readable parser name used in error messages.
31 + pub(crate) parser_name: &'static str,
32 + /// Marker that opens a tool-call block.
33 + pub(crate) tool_call_start: &'static str,
34 + /// Marker that closes a tool-call block.
35 + pub(crate) tool_call_end: &'static str,
36 +}
37 +
38 +const QWEN_CODER_CONFIG: QwenCoderConfig = QwenCoderConfig {
39 + parser_name: "Qwen Coder",
40 + tool_call_start: TOOL_CALL_START,
41 + tool_call_end: TOOL_CALL_END,
42 +};
43 +
44 type QwenCoderInput<'i> = Partial<&'i str>;
45
46 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -60,16 +82,24 @@ pub struct Qwen3CoderToolParser {
82 mode: QwenCoderMode,
83 emitted_tool_count: usize,
84 tool_parameters: ToolSchemas,
85 + config: QwenCoderConfig,
86 }
87
88 impl Qwen3CoderToolParser {
89 /// Create a Qwen Coder tool parser.
90 fn new(tools: &[Tool]) -> Self {
91 + Self::with_config(tools, QWEN_CODER_CONFIG)
92 + }
93 +
94 + /// Create a parser for a model that reuses the Qwen Coder grammar with a
95 + /// different tool-call wrapper (e.g. Seed-OSS).
96 + pub(crate) fn with_config(tools: &[Tool], config: QwenCoderConfig) -> Self {
97 Self {
98 buffer: String::new(),
99 mode: QwenCoderMode::Text,
100 emitted_tool_count: 0,
101 tool_parameters: ToolSchemas::from_tools(tools),
102 + config,
103 }
104 }
105
@@ -122,9 +152,10 @@ impl ToolParser for Qwen3CoderToolParser {
152
153 fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
154 self.buffer.push_str(chunk);
155 + let config = self.config;
156
157 while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| {
127 - parse_next_qwen_coder_event(input, &mut self.mode)
158 + parse_next_qwen_coder_event(input, &mut self.mode, config)
159 })? {
160 self.apply_event(event, output)?;
161 self.buffer.drain(..consumed_len);
@@ -137,9 +168,12 @@ impl ToolParser for Qwen3CoderToolParser {
168 let mut output = ToolParserOutput::default();
169 if !self.buffer.is_empty() {
170 if matches!(self.mode, QwenCoderMode::ToolCall { .. })
140 - || self.buffer.starts_with(TOOL_CALL_START)
171 + || self.buffer.starts_with(self.config.tool_call_start)
172 {
142 - return Err(parsing_failed!("incomplete Qwen Coder tool call"));
173 + return Err(parsing_failed!(
174 + "incomplete {} tool call",
175 + self.config.parser_name
176 + ));
177 }
178 output.push_text(&self.buffer);
179 }
@@ -156,37 +190,54 @@ impl ToolParser for Qwen3CoderToolParser {
190 fn parse_next_qwen_coder_event(
191 input: &mut QwenCoderInput<'_>,
192 mode: &mut QwenCoderMode,
193 + config: QwenCoderConfig,
194 ) -> ModalResult<QwenCoderEvent> {
195 match mode {
161 - QwenCoderMode::Text => parse_text_event(input),
162 - QwenCoderMode::ToolCall { end_marker_scan } => tool_call_event(input, end_marker_scan),
196 + QwenCoderMode::Text => parse_text_event(input, config),
197 + QwenCoderMode::ToolCall { end_marker_scan } => {
198 + tool_call_event(input, end_marker_scan, config.tool_call_end)
199 + }
200 }
201 }
202
203 /// Parse a text-mode Qwen Coder event.
167 -fn parse_text_event(input: &mut QwenCoderInput<'_>) -> ModalResult<QwenCoderEvent> {
168 - alt((tool_call_start_event, safe_text_event)).parse_next(input)
204 +fn parse_text_event(
205 + input: &mut QwenCoderInput<'_>,
206 + config: QwenCoderConfig,
207 +) -> ModalResult<QwenCoderEvent> {
208 + alt((
209 + |input: &mut QwenCoderInput<'_>| tool_call_start_event(input, config.tool_call_start),
210 + |input: &mut QwenCoderInput<'_>| safe_text_event(input, config.tool_call_start),
211 + ))
212 + .parse_next(input)
213 }
214
215 /// Parse a Qwen Coder tool-call start marker.
172 -fn tool_call_start_event(input: &mut QwenCoderInput<'_>) -> ModalResult<QwenCoderEvent> {
173 - literal(TOOL_CALL_START).value(QwenCoderEvent::ToolCallStart).parse_next(input)
216 +fn tool_call_start_event(
217 + input: &mut QwenCoderInput<'_>,
218 + tool_call_start: &'static str,
219 +) -> ModalResult<QwenCoderEvent> {
220 + literal(tool_call_start).value(QwenCoderEvent::ToolCallStart).parse_next(input)
221 }
222
223 /// Parse a safe text run before the next Qwen Coder marker.
177 -fn safe_text_event(input: &mut QwenCoderInput<'_>) -> ModalResult<QwenCoderEvent> {
178 - safe_text_len(input, TOOL_CALL_START).map(|len| QwenCoderEvent::Text { len })
224 +fn safe_text_event(
225 + input: &mut QwenCoderInput<'_>,
226 + tool_call_start: &'static str,
227 +) -> ModalResult<QwenCoderEvent> {
228 + safe_text_len(input, tool_call_start).map(|len| QwenCoderEvent::Text { len })
229 }
230
231 /// Parse a complete Qwen Coder tool call.
232 fn tool_call_event(
233 input: &mut QwenCoderInput<'_>,
234 end_marker_scan: &mut MarkerScanState,
235 + tool_call_end: &'static str,
236 ) -> ModalResult<QwenCoderEvent> {
237 let (body,) = seq!(
238 _: ws0,
188 - take_until_marker(TOOL_CALL_END, end_marker_scan),
189 - _: literal(TOOL_CALL_END),
239 + take_until_marker(tool_call_end, end_marker_scan),
240 + _: literal(tool_call_end),
241 )
242 .parse_next(input)?;
243
rust/src/parser/src/tool/seed_oss.rs new
+253
@@ -0,0 +1,253 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +use super::qwen_coder::{Qwen3CoderToolParser, QwenCoderConfig};
5 +use crate::tool::{Result, Tool, ToolParser, ToolParserOutput};
6 +
7 +const SEED_OSS_CONFIG: QwenCoderConfig = QwenCoderConfig {
8 + parser_name: "Seed-OSS",
9 + tool_call_start: "<seed:tool_call>",
10 + tool_call_end: "</seed:tool_call>",
11 +};
12 +
13 +/// Tool parser for Seed-OSS XML-style tool calls.
14 +///
15 +/// Example tool call content:
16 +///
17 +/// ```text
18 +/// <seed:tool_call>
19 +/// <function=get_weather>
20 +/// <parameter=location>杭州</parameter>
21 +/// </function>
22 +/// </seed:tool_call>
23 +/// ```
24 +///
25 +/// Seed-OSS shares the Qwen3 Coder grammar exactly; only the two tool-call
26 +/// wrapper tokens differ (`<seed:tool_call>` / `</seed:tool_call>`). The inner
27 +/// `<function=...>` / `<parameter=...>` grammar and schema-driven argument
28 +/// conversion are byte-identical, so this delegates to a
29 +/// [`Qwen3CoderToolParser`] configured with the Seed-OSS markers. This mirrors
30 +/// Python `SeedOssParser(Qwen3Parser)` in `vllm/parser/seed_oss.py`.
31 +///
32 +/// Structured-output tags are intentionally unsupported: the trait default
33 +/// `structural_tag_model() -> None` matches Python `SeedOssEngineToolParser`,
34 +/// which sets `structural_tag_model = None` (xgrammar has no Seed-OSS model).
35 +///
36 +/// The `<seed:tool_call>` wrapper tokens are added-vocabulary tokens
37 +/// (`special = false`), not tokenizer special tokens, so they survive decoding
38 +/// under the production default `skip_special_tokens = true`; the trait default
39 +/// `preserve_special_tokens() == false` is therefore correct, matching the
40 +/// sibling `SeedOssReasoningParser`, which relies on the same for `<seed:think>`.
41 +pub struct SeedOssToolParser {
42 + inner: Qwen3CoderToolParser,
43 +}
44 +
45 +impl SeedOssToolParser {
46 + /// Create a Seed-OSS tool parser.
47 + fn new(tools: &[Tool]) -> Self {
48 + Self {
49 + inner: Qwen3CoderToolParser::with_config(tools, SEED_OSS_CONFIG),
50 + }
51 + }
52 +}
53 +
54 +impl ToolParser for SeedOssToolParser {
55 + fn create(tools: &[Tool]) -> Result<Box<dyn ToolParser>>
56 + where
57 + Self: Sized + 'static,
58 + {
59 + Ok(Box::new(Self::new(tools)))
60 + }
61 +
62 + fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
63 + self.inner.parse_into(chunk, output)
64 + }
65 +
66 + fn finish(&mut self) -> Result<ToolParserOutput> {
67 + self.inner.finish()
68 + }
69 +
70 + fn reset(&mut self) -> String {
71 + self.inner.reset()
72 + }
73 +}
74 +
75 +#[cfg(test)]
76 +mod tests {
77 + use serde_json::{Value, json};
78 + use thiserror_ext::AsReport;
79 +
80 + use super::SeedOssToolParser;
81 + use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools};
82 + use crate::tool::{ToolParser, ToolParserOutput, ToolParserTestExt as _};
83 +
84 + fn build_tool_call(function_name: &str, params: &[(&str, &str)]) -> String {
85 + let params = params
86 + .iter()
87 + .map(|(name, value)| format!("<parameter={name}>{value}</parameter>"))
88 + .collect::<Vec<_>>()
89 + .join("\n");
90 + format!(
91 + "<seed:tool_call>\n<function={function_name}>\n{params}\n</function>\n</seed:tool_call>"
92 + )
93 + }
94 +
95 + #[test]
96 + fn seed_oss_parse_complete_without_tool_call_keeps_text() {
97 + let mut parser = SeedOssToolParser::new(&test_tools());
98 + let output = parser.parse_complete("Hello, world!").unwrap();
99 +
100 + assert_eq!(output.normal_text(), "Hello, world!");
101 + assert!(output.calls().is_empty());
102 + }
103 +
104 + #[test]
105 + fn seed_oss_does_not_treat_plain_tool_call_marker_as_start() {
106 + // The Qwen Coder wrapper `<tool_call>` must NOT trigger the Seed-OSS
107 + // parser; only `<seed:tool_call>` does.
108 + let mut parser = SeedOssToolParser::new(&test_tools());
109 + let output = parser
110 + .parse_complete("<tool_call>\n<function=get_weather>\n</function>\n</tool_call>")
111 + .unwrap();
112 +
113 + assert_eq!(
114 + output.normal_text(),
115 + "<tool_call>\n<function=get_weather>\n</function>\n</tool_call>"
116 + );
117 + assert!(output.calls().is_empty());
118 + }
119 +
120 + #[test]
121 + fn seed_oss_parse_complete_extracts_single_tool_call() {
122 + let mut parser = SeedOssToolParser::new(&test_tools());
123 + let output = parser
124 + .parse_complete(&build_tool_call(
125 + "get_weather",
126 + &[("location", "SF"), ("date", "2026-04-29")],
127 + ))
128 + .unwrap();
129 +
130 + assert!(output.normal_text().is_empty());
131 + assert_eq!(output.calls().len(), 1);
132 + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather"));
133 + assert_eq!(
134 + serde_json::from_str::<Value>(&output.calls()[0].arguments).unwrap(),
135 + json!({ "location": "SF", "date": "2026-04-29" })
136 + );
137 + }
138 +
139 + #[test]
140 + fn seed_oss_parse_complete_preserves_prefix_text() {
141 + let mut parser = SeedOssToolParser::new(&test_tools());
142 + let input = format!(
143 + "Thinking... {}",
144 + build_tool_call("get_weather", &[("location", "NYC")])
145 + );
146 + let output = parser.parse_complete(&input).unwrap();
147 +
148 + assert_eq!(output.normal_text(), "Thinking... ");
149 + assert_eq!(output.calls().len(), 1);
150 + }
151 +
152 + #[test]
153 + fn seed_oss_parse_complete_converts_schema_types() {
154 + let mut parser = SeedOssToolParser::new(&test_tools());
155 + let output = parser
156 + .parse_complete(&build_tool_call(
157 + "convert",
158 + &[
159 + ("whole", "5.0"),
160 + ("flag", "true"),
161 + ("payload", r#"{"nested":true}"#),
162 + ("items", "[1,2]"),
163 + ("empty", "42"),
164 + ],
165 + ))
166 + .unwrap();
167 +
168 + assert_eq!(output.calls().len(), 1);
169 + assert_eq!(
170 + serde_json::from_str::<Value>(&output.calls()[0].arguments).unwrap(),
171 + json!({
172 + "whole": 5.0,
173 + "flag": true,
174 + "payload": { "nested": true },
175 + "items": [1, 2],
176 + "empty": "42",
177 + })
178 + );
179 + }
180 +
181 + #[test]
182 + fn seed_oss_streaming_extracts_multiple_tool_calls_in_order() {
183 + let text = format!(
184 + "{}\n{}",
185 + build_tool_call("get_weather", &[("location", "SF")]),
186 + build_tool_call("get_weather", &[("location", "NYC")])
187 + );
188 + let chunks = split_by_chars(&text, 7);
189 + let mut parser = SeedOssToolParser::new(&test_tools());
190 +
191 + let output = collect_stream(&mut parser, &chunks);
192 +
193 + assert_eq!(output.calls().len(), 2);
194 + assert_eq!(output.calls()[0].tool_index, 0);
195 + assert_eq!(output.calls()[1].tool_index, 1);
196 + assert_eq!(
197 + serde_json::from_str::<Value>(&output.calls()[0].arguments).unwrap(),
198 + json!({ "location": "SF" })
199 + );
200 + assert_eq!(
201 + serde_json::from_str::<Value>(&output.calls()[1].arguments).unwrap(),
202 + json!({ "location": "NYC" })
203 + );
204 + }
205 +
206 + #[test]
207 + fn seed_oss_streaming_handles_end_marker_split_across_chunks() {
208 + let mut parser = SeedOssToolParser::new(&test_tools());
209 + let mut output = ToolParserOutput::default();
210 + output.append(
211 + parser
212 + .parse_chunk(
213 + "<seed:tool_call>\n\
214 + <function=get_weather>\n\
215 + <parameter=location>SF</parameter>\n\
216 + </function>\n\
217 + </seed:tool",
218 + )
219 + .unwrap(),
220 + );
221 +
222 + assert!(output.normal_text().is_empty());
223 + assert!(output.calls().is_empty());
224 +
225 + output.append(parser.parse_chunk("_call>").unwrap());
226 + output.append(parser.finish().unwrap());
227 + let output = output.coalesce();
228 +
229 + assert_eq!(output.calls().len(), 1);
230 + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather"));
231 + assert_eq!(
232 + serde_json::from_str::<Value>(&output.calls()[0].arguments).unwrap(),
233 + json!({ "location": "SF" })
234 + );
235 + }
236 +
237 + #[test]
238 + fn seed_oss_finish_fails_incomplete_tool_call() {
239 + let mut parser = SeedOssToolParser::new(&test_tools());
240 + parser
241 + .parse_chunk(
242 + "<seed:tool_call>\n<function=get_weather>\n<parameter=location>SF</parameter>",
243 + )
244 + .unwrap();
245 +
246 + let error = parser.finish().unwrap_err();
247 +
248 + assert_eq!(
249 + error.to_report_string(),
250 + "tool parser parsing failed: incomplete Seed-OSS tool call"
251 + );
252 + }
253 +}