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