[Rust Frontend] Add HY3 unified parser and local XGrammar structural-tag builder (#53054)

Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Bugen Zhao committed Aug 21, 2026 at 12:13 UTC df3b3422b495a33fd10d0b2f06c051144d6bb2c2
17 files changed +849 -86
rust/src/chat/src/lib.rs
+1 -1
@@ -415,6 +415,6 @@ mod tests {
415 )
416 .unwrap_err();
417
418 - expect_test::expect!["reasoning parser `definitely_missing_reasoning_parser` is not registered (choose from: cohere_cmd, deepseek_r1, deepseek_v3, deepseek_v4, gemma4, glm45, inkling, kimi, kimi_k2, kimi_k3, minimax_m2, minimax_m3, nemotron_v3, qwen3, seed_oss, step3, step3p5)"].assert_eq(&error.to_report_string());
418 + expect_test::expect!["reasoning parser `definitely_missing_reasoning_parser` is not registered (choose from: cohere_cmd, deepseek_r1, deepseek_v3, deepseek_v4, gemma4, glm45, hy_v3, inkling, kimi, kimi_k2, kimi_k3, minimax_m2, minimax_m3, nemotron_v3, qwen3, seed_oss, step3, step3p5)"].assert_eq(&error.to_report_string());
419 }
420 }
rust/src/chat/src/parser/reasoning/mod.rs
+3
@@ -25,6 +25,7 @@ pub mod names {
25 pub const GEMMA4: &str = "gemma4";
26 pub const INKLING: &str = "inkling";
27 pub const GLM45: &str = "glm45";
28 + pub const HY_V3: &str = "hy_v3";
29 pub const KIMI: &str = "kimi";
30 pub const KIMI_K2: &str = "kimi_k2";
31 pub const KIMI_K3: &str = "kimi_k3";
@@ -67,6 +68,7 @@ impl ReasoningParserFactory {
68 .register_unified_dummy(names::GEMMA4)
69 .register_unified_dummy(names::INKLING)
70 .register_parser::<Glm45ReasoningParser>(names::GLM45)
71 + .register_unified_dummy(names::HY_V3)
72 .register_parser::<KimiReasoningParser>(names::KIMI)
73 .register_parser::<KimiK2ReasoningParser>(names::KIMI_K2)
74 .register_unified_dummy(names::KIMI_K3)
@@ -91,6 +93,7 @@ impl ReasoningParserFactory {
93 .register_pattern("glm-4.7", names::GLM45)
94 .register_pattern("glm-4.6", names::GLM45)
95 .register_pattern("glm-4.5", names::GLM45)
96 + .register_pattern("hy3", names::HY_V3)
97 .register_pattern("kimi-k2", names::KIMI_K2)
98 .register_pattern("kimi", names::KIMI)
99 // step3p5 patterns must precede `step3`: substring matching would
rust/src/chat/src/parser/tool/mod.rs
+2 -3
@@ -7,7 +7,7 @@ use std::sync::{Arc, LazyLock};
7
8 pub use vllm_parser::tool::{
9 DeepSeekV3ToolParser, DeepSeekV4ToolParser, DeepSeekV31ToolParser, DeepSeekV32ToolParser,
10 - Glm45MoeToolParser, Glm47MoeToolParser, Granite4ToolParser, HermesToolParser, HyV3ToolParser,
10 + Glm45MoeToolParser, Glm47MoeToolParser, Granite4ToolParser, HermesToolParser,
11 Internlm2ToolParser, KimiK2ToolParser, Llama3JsonToolParser, MinimaxM2ToolParser,
12 MinimaxM3ToolParser, MistralToolParser, Phi4MiniJsonToolParser, Qwen3CoderToolParser,
13 Qwen3XmlToolParser, SeedOssToolParser, ToolParser, ToolParserError,
@@ -76,7 +76,7 @@ impl ToolParserFactory {
76 .register_unified_dummy(names::INKLING)
77 .register_parser::<Granite4ToolParser>(names::GRANITE4)
78 .register_parser::<HermesToolParser>(names::HERMES)
79 - .register_parser::<HyV3ToolParser>(names::HY_V3)
79 + .register_unified_dummy(names::HY_V3)
80 .register_parser::<Internlm2ToolParser>(names::INTERNLM)
81 .register_parser::<KimiK2ToolParser>(names::KIMI_K2)
82 .register_unified_dummy(names::KIMI_K3)
@@ -100,7 +100,6 @@ impl ToolParserFactory {
100 .register_pattern("qwen3", names::QWEN3_XML)
101 .register_pattern("hermes", names::HERMES)
102 .register_pattern("hy3", names::HY_V3)
103 - .register_pattern("hy_v3", names::HY_V3)
103 // Narrow to `internlm2` substring so it matches `internlm2-chat-7b`
104 // and `internlm2_5-7b-chat` but NOT `internlm-chat-7b` (InternLM v1,
105 // routes to Llama), `internlm3-*` (also Llama-architecture per
rust/src/chat/src/parser/unified.rs
+5 -1
@@ -6,7 +6,8 @@
6 use std::sync::LazyLock;
7
8 pub use vllm_parser::unified::{
9 - Gemma4UnifiedParser, InklingUnifiedParser, KimiK3UnifiedParser, UnifiedParser,
9 + Gemma4UnifiedParser, HyV3UnifiedParser, InklingUnifiedParser, KimiK3UnifiedParser,
10 + UnifiedParser,
11 };
12 use vllm_tokenizer::DynTokenizer;
13
@@ -16,6 +17,7 @@ use crate::request::ChatTool;
17 /// Canonical public names for registered unified parsers.
18 pub mod names {
19 pub const GEMMA4: &str = "gemma4";
20 + pub const HY_V3: &str = "hy_v3";
21 pub const INKLING: &str = "inkling";
22 pub const KIMI_K3: &str = "kimi_k3";
23 }
@@ -41,12 +43,14 @@ impl UnifiedParserFactory {
43 let mut factory = Self::default();
44
45 factory.register_parser::<Gemma4UnifiedParser>(names::GEMMA4);
46 + factory.register_parser::<HyV3UnifiedParser>(names::HY_V3);
47 factory.register_parser::<InklingUnifiedParser>(names::INKLING);
48 factory.register_parser::<KimiK3UnifiedParser>(names::KIMI_K3);
49
50 factory
51 .register_pattern("gemma-4", names::GEMMA4)
52 .register_pattern("gemma4", names::GEMMA4)
53 + .register_pattern("hy3", names::HY_V3)
54 .register_pattern("inkling", names::INKLING)
55 .register_pattern("kimi-k3", names::KIMI_K3)
56 .register_pattern("kimi_k3", names::KIMI_K3);
rust/src/chat/tests/roundtrip.rs
+60 -7
@@ -55,6 +55,12 @@ enum ThinkingBehavior {
55 Toggleable { default: bool },
56 /// The chat template always behaves as `value` for this fixture.
57 Always { value: bool },
58 + /// The chat template selects thinking mode through `reasoning_effort`.
59 + ReasoningEffort {
60 + default: bool,
61 + enabled: &'static str,
62 + disabled: &'static str,
63 + },
64 }
65
66 impl ThinkingBehavior {
@@ -62,6 +68,7 @@ impl ThinkingBehavior {
68 match self {
69 Self::Toggleable { default } => default,
70 Self::Always { value } => value,
71 + Self::ReasoningEffort { default, .. } => default,
72 }
73 }
74
@@ -76,6 +83,33 @@ impl ThinkingBehavior {
83 Some(value), // explicitly request the supported thinking behavior
84 None, // use default template behavior
85 ],
86 + Self::ReasoningEffort { .. } => vec![
87 + Some(true), // explicitly enable thinking
88 + Some(false), // explicitly disable thinking
89 + None, // use default template behavior
90 + ],
91 + }
92 + }
93 +
94 + fn apply(self, request: &mut ChatRequest, thinking: Option<bool>) {
95 + let Some(thinking) = thinking else {
96 + return;
97 + };
98 +
99 + match self {
100 + Self::Toggleable { .. } | Self::Always { .. } => {
101 + for key in ["thinking", "enable_thinking"] {
102 + request.chat_options.template_kwargs.insert(key.to_string(), thinking.into());
103 + }
104 + }
105 + Self::ReasoningEffort {
106 + enabled, disabled, ..
107 + } => {
108 + request.chat_options.template_kwargs.insert(
109 + "reasoning_effort".to_string(),
110 + if thinking { enabled } else { disabled }.into(),
111 + );
112 + }
113 }
114 }
115 }
@@ -242,6 +276,23 @@ impl RoundtripCase {
276 }
277 }
278
279 + /// HY3 suffixed reasoning/tool markers discovered from tokenizer added vocab.
280 + fn hy_v3() -> Self {
281 + Self {
282 + model_id: "tencent/Hy3",
283 + assistant_stop_suffix: "<|hy_eos:opensource|>",
284 + tool_call_parser: ParserSelection::Auto,
285 + reasoning_parser: ParserSelection::Auto,
286 + thinking_behavior: ThinkingBehavior::ReasoningEffort {
287 + default: false,
288 + enabled: "high",
289 + disabled: "no_think",
290 + },
291 + json_fmt: compact_json_fmt(),
292 + sort_json_keys: false,
293 + }
294 + }
295 +
296 /// SeedOSS with `<seed:think>` / `</seed:think>` reasoning tags.
297 fn seed_oss() -> Self {
298 Self {
@@ -345,6 +396,9 @@ roundtrip_tests! {
396 kimi_k25 => [tool_call_mix], // Kimi K2.5 strips reasoning in history
397 // K3 drops plain-assistant reasoning in history; tool-call turns keep it.
398 kimi_k3 => [tool_call_mix],
399 + // HY3's final plain-assistant history omits EOS; tool-call history keeps it
400 + // and exercises both stages of the tokenizer-derived unified parser.
401 + hy_v3 => [tool_call_mix],
402 gpt_oss => [tool_call_mix], // Harmony strips reasoning in history if there's no tool call
403 inkling => [reasoning_and_content, tool_call_mix],
404 }
@@ -370,6 +424,7 @@ async fn run_roundtrip_reasoning_and_content_inner(
424 vec![ChatMessage::text(ChatRole::User, "What is 2 + 2?")],
425 Vec::new(),
426 thinking,
427 + case.thinking_behavior,
428 );
429 let expected_reasoning = "Need compute 2 + 2 directly.";
430 let expected_text = "The answer is 4.";
@@ -417,6 +472,7 @@ async fn run_roundtrip_tool_call_mix(
472 )],
473 test_tools(),
474 Some(true), // always enable thinking in this fixture
475 + case.thinking_behavior,
476 );
477 let expected_reasoning = "Need call the weather and add tools.";
478 let expected_text = "I will call the tools.";
@@ -838,6 +894,7 @@ fn roundtrip_request(
894 messages: Vec<ChatMessage>,
895 tools: Vec<ChatTool>,
896 thinking: Option<bool>,
897 + thinking_behavior: ThinkingBehavior,
898 ) -> ChatRequest {
899 let tool_context = vllm_chat::ResolvedToolContext::new(&messages, tools, None, true)
900 .expect("tool context should resolve");
@@ -848,13 +905,9 @@ fn roundtrip_request(
905 ..ChatRequest::for_test()
906 };
907
851 - // Explicitly enable or disable thinking so that rendering and parsing the reasoning block is
852 - // exercised or skipped in the roundtrip. If unspecified, use the default template behavior.
853 - if let Some(thinking) = thinking {
854 - for key in ["thinking", "enable_thinking"] {
855 - request.chat_options.template_kwargs.insert(key.to_string(), thinking.into());
856 - }
857 - }
908 + // Explicitly enable or disable thinking using the controls understood by
909 + // this model's template. If unspecified, use the template default.
910 + thinking_behavior.apply(&mut request, thinking);
911
912 request
913 }
rust/src/parser/src/reasoning/delimited.rs
+13 -9
@@ -35,25 +35,29 @@ impl DelimitedReasoningParser {
35 /// start or end delimiter, that prompt boundary always wins.
36 pub(crate) fn new(
37 tokenizer: DynTokenizer,
38 - start_token: &'static str,
39 - end_token: &'static str,
38 + start_token: impl Into<String>,
39 + end_token: impl Into<String>,
40 default_in_reasoning: bool,
41 ) -> Result<Self> {
42 + let start_token = start_token.into();
43 + let end_token = end_token.into();
44 let start_token_id =
43 - tokenizer.token_to_id(start_token).ok_or_else(|| ReasoningError::MissingToken {
44 - token: start_token.to_string(),
45 - })?;
45 + tokenizer
46 + .token_to_id(&start_token)
47 + .ok_or_else(|| ReasoningError::MissingToken {
48 + token: start_token.clone(),
49 + })?;
50 let end_token_id =
47 - tokenizer.token_to_id(end_token).ok_or_else(|| ReasoningError::MissingToken {
48 - token: end_token.to_string(),
51 + tokenizer.token_to_id(&end_token).ok_or_else(|| ReasoningError::MissingToken {
52 + token: end_token.clone(),
53 })?;
54
55 Ok(Self {
56 tokenizer,
57 current_in_reasoning: default_in_reasoning,
58 buffer: String::new(),
55 - start_token: start_token.to_string(),
56 - end_token: end_token.to_string(),
59 + start_token,
60 + end_token,
61 start_token_id,
62 end_token_id,
63 default_in_reasoning,
rust/src/parser/src/reasoning/hy_v3.rs new
+51
@@ -0,0 +1,51 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +use vllm_tokenizer::DynTokenizer;
5 +
6 +use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningError, ReasoningParser, Result};
7 +
8 +/// Internal HY3 reasoning stage used by the unified HY3 parser.
9 +pub(crate) struct HyV3ReasoningParser {
10 + inner: DelimitedReasoningParser,
11 +}
12 +
13 +impl HyV3ReasoningParser {
14 + /// Create a HY3 reasoning parser for the tokenizer-specific marker suffix.
15 + pub(crate) fn new(tokenizer: DynTokenizer, suffix: &str) -> Result<Self> {
16 + Ok(Self {
17 + inner: DelimitedReasoningParser::new(
18 + tokenizer,
19 + format!("<think{suffix}>"),
20 + format!("</think{suffix}>"),
21 + false,
22 + )?,
23 + })
24 + }
25 +}
26 +
27 +impl ReasoningParser for HyV3ReasoningParser {
28 + // Suffix discovery belongs to `HyV3UnifiedParser` so its reasoning and
29 + // tool delimiters always use the same tokenizer-derived value.
30 + fn create(_tokenizer: DynTokenizer) -> Result<Box<dyn ReasoningParser>>
31 + where
32 + Self: Sized + 'static,
33 + {
34 + Err(ReasoningError::DummyUnifiedParser {
35 + name: "hy_v3".to_string(),
36 + })
37 + }
38 +
39 + fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
40 + self.inner.initialize(prompt_token_ids);
41 + Ok(())
42 + }
43 +
44 + fn push(&mut self, delta: &str) -> Result<ReasoningDelta> {
45 + Ok(self.inner.push(delta))
46 + }
47 +
48 + fn finish(&mut self) -> Result<ReasoningDelta> {
49 + Ok(self.inner.finish())
50 + }
51 +}
rust/src/parser/src/reasoning/mod.rs
+2
@@ -20,6 +20,7 @@
20 mod cohere_cmd;
21 mod deepseek_r1;
22 mod delimited;
23 +mod hy_v3;
24 mod kimi;
25 mod minimax_m3;
26 mod qwen3;
@@ -32,6 +33,7 @@ use vllm_tokenizer::DynTokenizer;
33 pub use self::cohere_cmd::CohereCmdReasoningParser;
34 pub use self::deepseek_r1::DeepSeekR1ReasoningParser;
35 pub(crate) use self::delimited::{DelimitedReasoningParser, last_reasoning_boundary};
36 +pub(crate) use self::hy_v3::HyV3ReasoningParser;
37 pub use self::kimi::KimiReasoningParser;
38 pub use self::minimax_m3::MiniMaxM3ReasoningParser;
39 pub use self::qwen3::Qwen3ReasoningParser;
rust/src/parser/src/tool/hy_v3.rs
+142 -62
@@ -1,26 +1,65 @@
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +mod structural_tag;
5 +
6 +use std::sync::Arc;
7 +
8 use winnow::ascii::multispace0 as ws0;
9 use winnow::combinator::{alt, delimited, eof, repeat, seq, terminated};
10 use winnow::prelude::*;
11 use winnow::stream::Partial;
12 use winnow::token::{literal, rest, take_until};
13
14 +use self::structural_tag::HyV3StructuralTagBuilder;
15 use super::parameters::ToolSchemas;
16 use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len, take_until_marker};
12 -use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput};
17 +use super::{Result, ToolCallDelta, ToolParser, ToolParserError, ToolParserOutput};
18 use crate::tool::{StructuralTagBuilder, Tool};
19
15 -const TOOL_CALLS_START: &str = "<tool_calls>";
16 -const TOOL_CALLS_END: &str = "</tool_calls>";
17 -const TOOL_CALL_START: &str = "<tool_call>";
18 -const TOOL_CALL_END: &str = "</tool_call>";
19 -const TOOL_SEP: &str = "<tool_sep>";
20 -const ARG_KEY_START: &str = "<arg_key>";
21 -const ARG_KEY_END: &str = "</arg_key>";
22 -const ARG_VALUE_START: &str = "<arg_value>";
23 -const ARG_VALUE_END: &str = "</arg_value>";
20 +#[derive(Debug, Clone, PartialEq, Eq)]
21 +pub(crate) struct HyV3ToolMarkers {
22 + tool_calls_start: String,
23 + tool_calls_end: String,
24 + tool_call_start: String,
25 + tool_call_end: String,
26 + tool_sep: String,
27 + arg_key_start: String,
28 + arg_key_end: String,
29 + arg_value_start: String,
30 + arg_value_end: String,
31 +}
32 +
33 +impl HyV3ToolMarkers {
34 + pub(crate) fn new(suffix: &str) -> Self {
35 + Self {
36 + tool_calls_start: format!("<tool_calls{suffix}>"),
37 + tool_calls_end: format!("</tool_calls{suffix}>"),
38 + tool_call_start: format!("<tool_call{suffix}>"),
39 + tool_call_end: format!("</tool_call{suffix}>"),
40 + tool_sep: format!("<tool_sep{suffix}>"),
41 + arg_key_start: format!("<arg_key{suffix}>"),
42 + arg_key_end: format!("</arg_key{suffix}>"),
43 + arg_value_start: format!("<arg_value{suffix}>"),
44 + arg_value_end: format!("</arg_value{suffix}>"),
45 + }
46 + }
47 +
48 + pub(crate) fn iter(&self) -> impl Iterator<Item = &str> {
49 + [
50 + self.tool_calls_start.as_str(),
51 + self.tool_calls_end.as_str(),
52 + self.tool_call_start.as_str(),
53 + self.tool_call_end.as_str(),
54 + self.tool_sep.as_str(),
55 + self.arg_key_start.as_str(),
56 + self.arg_key_end.as_str(),
57 + self.arg_value_start.as_str(),
58 + self.arg_value_end.as_str(),
59 + ]
60 + .into_iter()
61 + }
62 +}
63
64 type HyV3Input<'i> = Partial<&'i str>;
65
@@ -60,21 +99,26 @@ enum HyV3Event {
99 /// Arguments are emitted only after a full `<tool_call>` block is parsed.
100 /// HY3 marker tokens are added-vocabulary tokens rather than tokenizer special
101 /// tokens, so the default `preserve_special_tokens() == false` is sufficient.
63 -pub struct HyV3ToolParser {
102 +pub(crate) struct HyV3ToolParser {
103 buffer: String,
104 mode: HyV3Mode,
105 emitted_tool_count: usize,
106 tool_parameters: ToolSchemas,
107 + markers: Arc<HyV3ToolMarkers>,
108 + structural_tag_builder: HyV3StructuralTagBuilder,
109 }
110
111 impl HyV3ToolParser {
112 /// Create a HY3 tool parser.
72 - fn new(tools: &[Tool]) -> Self {
113 + pub(crate) fn new(tools: &[Tool], suffix: &str) -> Self {
114 + let markers = Arc::new(HyV3ToolMarkers::new(suffix));
115 Self {
116 buffer: String::new(),
117 mode: HyV3Mode::Text,
118 emitted_tool_count: 0,
119 tool_parameters: ToolSchemas::from_tools(tools),
120 + markers: Arc::clone(&markers),
121 + structural_tag_builder: HyV3StructuralTagBuilder::new(markers),
122 }
123 }
124
@@ -109,22 +153,26 @@ impl HyV3ToolParser {
153 }
154
155 impl ToolParser for HyV3ToolParser {
112 - fn create(tools: &[Tool]) -> Result<Box<dyn ToolParser>>
156 + // Suffix discovery belongs to `HyV3UnifiedParser` so its reasoning and
157 + // tool delimiters always use the same tokenizer-derived value.
158 + fn create(_tools: &[Tool]) -> Result<Box<dyn ToolParser>>
159 where
160 Self: Sized + 'static,
161 {
116 - Ok(Box::new(Self::new(tools)))
162 + Err(ToolParserError::DummyUnifiedParser {
163 + name: "hy_v3".to_string(),
164 + })
165 }
166
167 fn structural_tag_builder(&self) -> Option<&dyn StructuralTagBuilder> {
120 - Some(xgrammar_structural_tag::Model::HyV3.builder())
168 + Some(&self.structural_tag_builder)
169 }
170
171 fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
172 self.buffer.push_str(chunk);
173
174 while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| {
127 - parse_next_hy_v3_event(input, &mut self.mode)
175 + parse_next_hy_v3_event(input, &mut self.mode, &self.markers)
176 })? {
177 self.apply_event(event, output)?;
178 self.buffer.drain(..consumed_len);
@@ -155,62 +203,83 @@ impl ToolParser for HyV3ToolParser {
203 fn parse_next_hy_v3_event(
204 input: &mut HyV3Input<'_>,
205 mode: &mut HyV3Mode,
206 + markers: &HyV3ToolMarkers,
207 ) -> ModalResult<HyV3Event> {
208 match mode {
160 - HyV3Mode::Text => parse_text_event(input),
209 + HyV3Mode::Text => parse_text_event(input, markers),
210 HyV3Mode::ToolBlock { tool_call_end_scan } => {
162 - parse_tool_block_event(input, tool_call_end_scan)
211 + parse_tool_block_event(input, tool_call_end_scan, markers)
212 }
213 HyV3Mode::Done => ignored_rest_event(input),
214 }
215 }
216
217 /// Parse a text-mode HY3 event.
169 -fn parse_text_event(input: &mut HyV3Input<'_>) -> ModalResult<HyV3Event> {
170 - alt((tool_block_start_event, safe_text_event)).parse_next(input)
218 +fn parse_text_event(
219 + input: &mut HyV3Input<'_>,
220 + markers: &HyV3ToolMarkers,
221 +) -> ModalResult<HyV3Event> {
222 + alt((
223 + |input: &mut HyV3Input<'_>| tool_block_start_event(input, markers),
224 + |input: &mut HyV3Input<'_>| safe_text_event(input, markers),
225 + ))
226 + .parse_next(input)
227 }
228
229 /// Parse a HY3 tool-block start marker.
174 -fn tool_block_start_event(input: &mut HyV3Input<'_>) -> ModalResult<HyV3Event> {
175 - literal(TOOL_CALLS_START).value(HyV3Event::ToolBlockStart).parse_next(input)
230 +fn tool_block_start_event(
231 + input: &mut HyV3Input<'_>,
232 + markers: &HyV3ToolMarkers,
233 +) -> ModalResult<HyV3Event> {
234 + literal(markers.tool_calls_start.as_str())
235 + .value(HyV3Event::ToolBlockStart)
236 + .parse_next(input)
237 }
238
239 /// Parse a safe text run before the next HY3 marker.
179 -fn safe_text_event(input: &mut HyV3Input<'_>) -> ModalResult<HyV3Event> {
180 - safe_text_len(input, TOOL_CALLS_START).map(|len| HyV3Event::Text { len })
240 +fn safe_text_event(input: &mut HyV3Input<'_>, markers: &HyV3ToolMarkers) -> ModalResult<HyV3Event> {
241 + safe_text_len(input, &markers.tool_calls_start).map(|len| HyV3Event::Text { len })
242 }
243
244 /// Parse one event inside a HY3 tool block.
245 fn parse_tool_block_event(
246 input: &mut HyV3Input<'_>,
247 tool_call_end_scan: &mut MarkerScanState,
248 + markers: &HyV3ToolMarkers,
249 ) -> ModalResult<HyV3Event> {
188 - alt((tool_block_end_event, |input: &mut HyV3Input<'_>| {
189 - tool_call_event(input, tool_call_end_scan)
190 - }))
250 + alt((
251 + |input: &mut HyV3Input<'_>| tool_block_end_event(input, markers),
252 + |input: &mut HyV3Input<'_>| tool_call_event(input, tool_call_end_scan, markers),
253 + ))
254 .parse_next(input)
255 }
256
257 /// Parse a HY3 tool-block end marker.
195 -fn tool_block_end_event(input: &mut HyV3Input<'_>) -> ModalResult<HyV3Event> {
196 - (ws0, literal(TOOL_CALLS_END)).value(HyV3Event::ToolBlockEnd).parse_next(input)
258 +fn tool_block_end_event(
259 + input: &mut HyV3Input<'_>,
260 + markers: &HyV3ToolMarkers,
261 +) -> ModalResult<HyV3Event> {
262 + (ws0, literal(markers.tool_calls_end.as_str()))
263 + .value(HyV3Event::ToolBlockEnd)
264 + .parse_next(input)
265 }
266
267 /// Parse a complete HY3 tool-call block.
268 fn tool_call_event(
269 input: &mut HyV3Input<'_>,
270 tool_call_end_scan: &mut MarkerScanState,
271 + markers: &HyV3ToolMarkers,
272 ) -> ModalResult<HyV3Event> {
273 let (name, body) = seq!(
274 _: ws0,
206 - _: literal(TOOL_CALL_START),
207 - take_until(0.., TOOL_SEP),
208 - _: literal(TOOL_SEP),
209 - take_until_marker(TOOL_CALL_END, tool_call_end_scan),
210 - _: literal(TOOL_CALL_END),
275 + _: literal(markers.tool_call_start.as_str()),
276 + take_until(0.., markers.tool_sep.as_str()),
277 + _: literal(markers.tool_sep.as_str()),
278 + take_until_marker(markers.tool_call_end.as_str(), tool_call_end_scan),
279 + _: literal(markers.tool_call_end.as_str()),
280 )
281 .parse_next(input)?;
213 - let raw_params = parse_tool_call_params(body)?;
282 + let raw_params = parse_tool_call_params(body, markers)?;
283
284 Ok(HyV3Event::ToolCall {
285 name: name.trim().to_string(),
@@ -219,21 +288,32 @@ fn tool_call_event(
288 }
289
290 /// Parse all parameter blocks inside a complete HY3 tool call.
222 -fn parse_tool_call_params(tool_call_body: &str) -> ModalResult<Vec<(String, String)>> {
291 +fn parse_tool_call_params(
292 + tool_call_body: &str,
293 + markers: &HyV3ToolMarkers,
294 +) -> ModalResult<Vec<(String, String)>> {
295 let mut input = tool_call_body;
224 - delimited(ws0, repeat(0.., terminated(parameter, ws0)), eof).parse_next(&mut input)
296 + delimited(
297 + ws0,
298 + repeat(
299 + 0..,
300 + terminated(|input: &mut &str| parameter(input, markers), ws0),
301 + ),
302 + eof,
303 + )
304 + .parse_next(&mut input)
305 }
306
307 /// Parse a HY3 argument key/value block.
228 -fn parameter(input: &mut &str) -> ModalResult<(String, String)> {
308 +fn parameter(input: &mut &str, markers: &HyV3ToolMarkers) -> ModalResult<(String, String)> {
309 let (name, value) = seq!(
230 - _: literal(ARG_KEY_START),
231 - take_until(0.., ARG_KEY_END),
232 - _: literal(ARG_KEY_END),
310 + _: literal(markers.arg_key_start.as_str()),
311 + take_until(0.., markers.arg_key_end.as_str()),
312 + _: literal(markers.arg_key_end.as_str()),
313 _: ws0,
234 - _: literal(ARG_VALUE_START),
235 - take_until(0.., ARG_VALUE_END),
236 - _: literal(ARG_VALUE_END),
314 + _: literal(markers.arg_value_start.as_str()),
315 + take_until(0.., markers.arg_value_end.as_str()),
316 + _: literal(markers.arg_value_end.as_str()),
317 )
318 .parse_next(input)?;
319
@@ -274,14 +354,14 @@ mod tests {
354
355 #[test]
356 fn hy_v3_does_not_preserve_special_tokens() {
277 - let parser = HyV3ToolParser::new(&test_tools());
357 + let parser = HyV3ToolParser::new(&test_tools(), "");
358
359 assert!(!parser.preserve_special_tokens());
360 }
361
362 #[test]
363 fn hy_v3_parse_complete_without_tool_call_keeps_text() {
284 - let mut parser = HyV3ToolParser::new(&test_tools());
364 + let mut parser = HyV3ToolParser::new(&test_tools(), "");
365 let output = parser.parse_complete("This is a plain response.").unwrap();
366
367 assert_eq!(output.normal_text(), "This is a plain response.");
@@ -290,7 +370,7 @@ mod tests {
370
371 #[test]
372 fn hy_v3_parse_complete_extracts_zero_arg_inline_tool_call() {
293 - let mut parser = HyV3ToolParser::new(&test_tools());
373 + let mut parser = HyV3ToolParser::new(&test_tools(), "");
374 let output = parser
375 .parse_complete(
376 "<tool_calls><tool_call>get_current_date<tool_sep></tool_call></tool_calls>",
@@ -305,7 +385,7 @@ mod tests {
385
386 #[test]
387 fn hy_v3_parse_complete_extracts_zero_arg_newline_tool_call() {
308 - let mut parser = HyV3ToolParser::new(&test_tools());
388 + let mut parser = HyV3ToolParser::new(&test_tools(), "");
389 let output = parser
390 .parse_complete(
391 "<tool_calls>\n<tool_call>get_current_date<tool_sep>\n</tool_call>\n</tool_calls>",
@@ -318,7 +398,7 @@ mod tests {
398
399 #[test]
400 fn hy_v3_parse_complete_extracts_arguments_on_same_line() {
321 - let mut parser = HyV3ToolParser::new(&test_tools());
401 + let mut parser = HyV3ToolParser::new(&test_tools(), "");
402 let output = parser
403 .parse_complete(
404 "<tool_calls><tool_call>get_weather<tool_sep><arg_key>city</arg_key><arg_value>Beijing</arg_value><arg_key>date</arg_key><arg_value>2026-03-30</arg_value></tool_call></tool_calls>",
@@ -333,7 +413,7 @@ mod tests {
413
414 #[test]
415 fn hy_v3_parse_complete_extracts_arguments_with_newlines() {
336 - let mut parser = HyV3ToolParser::new(&test_tools());
416 + let mut parser = HyV3ToolParser::new(&test_tools(), "");
417 let output = parser
418 .parse_complete(&build_tool_calls(&[build_tool_call(
419 "get_weather",
@@ -349,7 +429,7 @@ mod tests {
429
430 #[test]
431 fn hy_v3_parse_complete_preserves_prefix_and_ignores_trailing_text() {
352 - let mut parser = HyV3ToolParser::new(&test_tools());
432 + let mut parser = HyV3ToolParser::new(&test_tools(), "");
433 let output = parser
434 .parse_complete(&format!(
435 "Checking.{} trailing text",
@@ -363,7 +443,7 @@ mod tests {
443
444 #[test]
445 fn hy_v3_parse_complete_extracts_multiple_tool_calls_in_one_block() {
366 - let mut parser = HyV3ToolParser::new(&test_tools());
446 + let mut parser = HyV3ToolParser::new(&test_tools(), "");
447 let output = parser
448 .parse_complete(&build_tool_calls(&[
449 build_tool_call(
@@ -406,7 +486,7 @@ mod tests {
486
487 #[test]
488 fn hy_v3_parse_complete_converts_schema_types() {
409 - let mut parser = HyV3ToolParser::new(&test_tools());
489 + let mut parser = HyV3ToolParser::new(&test_tools(), "");
490 let output = parser
491 .parse_complete(&build_tool_calls(&[build_tool_call(
492 "convert",
@@ -432,7 +512,7 @@ mod tests {
512
513 #[test]
514 fn hy_v3_streaming_without_tool_call_emits_text_incrementally() {
435 - let mut parser = HyV3ToolParser::new(&test_tools());
515 + let mut parser = HyV3ToolParser::new(&test_tools(), "");
516 let mut output = ToolParserOutput::default();
517
518 output.append(parser.parse_chunk("This is ").unwrap());
@@ -446,7 +526,7 @@ mod tests {
526
527 #[test]
528 fn hy_v3_streaming_extracts_zero_arg_tool_call() {
449 - let mut parser = HyV3ToolParser::new(&test_tools());
529 + let mut parser = HyV3ToolParser::new(&test_tools(), "");
530 let chunks = [
531 "<tool_calls>",
532 "\n<tool_call>",
@@ -465,7 +545,7 @@ mod tests {
545
546 #[test]
547 fn hy_v3_streaming_extracts_arguments() {
468 - let mut parser = HyV3ToolParser::new(&test_tools());
548 + let mut parser = HyV3ToolParser::new(&test_tools(), "");
549 let chunks = [
550 "<tool_calls>",
551 "\n<tool_call>",
@@ -491,7 +571,7 @@ mod tests {
571
572 #[test]
573 fn hy_v3_streaming_preserves_prefix_text() {
494 - let mut parser = HyV3ToolParser::new(&test_tools());
574 + let mut parser = HyV3ToolParser::new(&test_tools(), "");
575 let chunks = [
576 "Checking.",
577 "<tool_calls>",
@@ -521,7 +601,7 @@ mod tests {
601 ),
602 ]);
603 let chunks = split_by_chars(&input, 9);
524 - let mut parser = HyV3ToolParser::new(&test_tools());
604 + let mut parser = HyV3ToolParser::new(&test_tools(), "");
605
606 let output = collect_stream(&mut parser, &chunks);
607
@@ -537,7 +617,7 @@ mod tests {
617 build_tool_calls(&[build_tool_call("get_weather", &[("city", "Beijing")])])
618 );
619 let chunks = split_by_chars(&input, 5);
540 - let mut parser = HyV3ToolParser::new(&test_tools());
620 + let mut parser = HyV3ToolParser::new(&test_tools(), "");
621
622 let output = collect_stream(&mut parser, &chunks);
623
@@ -548,7 +628,7 @@ mod tests {
628
629 #[test]
630 fn hy_v3_streaming_does_not_emit_incomplete_tool_call() {
551 - let mut parser = HyV3ToolParser::new(&test_tools());
631 + let mut parser = HyV3ToolParser::new(&test_tools(), "");
632 let mut output = ToolParserOutput::default();
633
634 parser
@@ -564,7 +644,7 @@ mod tests {
644
645 #[test]
646 fn hy_v3_finish_fails_incomplete_tool_call() {
567 - let mut parser = HyV3ToolParser::new(&test_tools());
647 + let mut parser = HyV3ToolParser::new(&test_tools(), "");
648 parser.parse_chunk("<tool_calls><tool_call>get_weather<tool_sep>").unwrap();
649
650 let error = parser.finish().unwrap_err();
@@ -575,7 +655,7 @@ mod tests {
655
656 #[test]
657 fn hy_v3_malformed_tool_call_fails_fast() {
578 - let mut parser = HyV3ToolParser::new(&test_tools());
658 + let mut parser = HyV3ToolParser::new(&test_tools(), "");
659 let error = parser
660 .parse_complete(
661 "<tool_calls><tool_call>get_weather<tool_sep><arg_key>city</arg_key><arg_value>Beijing</tool_call></tool_calls>",
rust/src/parser/src/tool/hy_v3/structural_tag.rs new
+261
@@ -0,0 +1,261 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +//! Structural-tag grammar for HY3 XML-style tool calls.
5 +
6 +use std::collections::HashSet;
7 +use std::sync::Arc;
8 +
9 +use serde_json::Value;
10 +use xgrammar_structural_tag::Result;
11 +use xgrammar_structural_tag::builders::{StructuralTagBuilder, StructuralTagContext};
12 +use xgrammar_structural_tag::format::{Format, StructuralTag, TagFormat};
13 +use xgrammar_structural_tag::tool::{BuilderToolChoice, FunctionToolParam};
14 +
15 +use super::HyV3ToolMarkers;
16 +
17 +/// HY3 structural-tag builder using tokenizer-specific structural markers.
18 +#[derive(Debug, Clone)]
19 +pub(super) struct HyV3StructuralTagBuilder {
20 + markers: Arc<HyV3ToolMarkers>,
21 +}
22 +
23 +impl HyV3StructuralTagBuilder {
24 + pub(super) fn new(markers: Arc<HyV3ToolMarkers>) -> Self {
25 + Self { markers }
26 + }
27 +
28 + fn argument_pair(&self, key: &str) -> Format {
29 + let excludes = self.markers.iter().collect::<Vec<_>>();
30 + Format::sequence(vec![
31 + Format::const_string(&self.markers.arg_key_start),
32 + Format::const_string(key),
33 + Format::const_string(&self.markers.arg_key_end),
34 + Format::const_string("\n"),
35 + Format::const_string(&self.markers.arg_value_start),
36 + Format::any_text_excluding(&excludes),
37 + Format::const_string(&self.markers.arg_value_end),
38 + Format::const_string("\n"),
39 + ])
40 + }
41 +
42 + fn tool_call(&self, tool: &FunctionToolParam) -> TagFormat {
43 + let (required_keys, optional_keys) = argument_keys(tool.function.parameters.as_ref());
44 + let mut elements =
45 + required_keys.into_iter().map(|key| self.argument_pair(key)).collect::<Vec<_>>();
46 +
47 + if !optional_keys.is_empty() {
48 + let mut pairs =
49 + optional_keys.into_iter().map(|key| self.argument_pair(key)).collect::<Vec<_>>();
50 + let optional = if pairs.len() == 1 {
51 + pairs.pop().unwrap()
52 + } else {
53 + Format::or(pairs)
54 + };
55 + elements.push(Format::star(optional));
56 + }
57 +
58 + let content = if elements.is_empty() {
59 + Format::any_text()
60 + } else {
61 + Format::sequence(elements)
62 + };
63 + TagFormat::new(
64 + format!(
65 + "{}{}{}\n",
66 + self.markers.tool_call_start, tool.function.name, self.markers.tool_sep
67 + ),
68 + content,
69 + self.markers.tool_call_end.clone(),
70 + )
71 + }
72 +
73 + fn tool_calls(&self, tools: &[FunctionToolParam], choice: BuilderToolChoice) -> Format {
74 + let mut calls = tools.iter().map(|tool| self.tool_call(tool)).collect::<Vec<_>>();
75 + let begin = format!("{}\n", self.markers.tool_calls_start);
76 + let end = format!("\n{}", self.markers.tool_calls_end);
77 +
78 + match choice {
79 + BuilderToolChoice::Auto if calls.is_empty() => Format::any_text(),
80 + BuilderToolChoice::Auto => {
81 + let outer = TagFormat::new(
82 + begin,
83 + Format::tags_with_separator(calls, "\n", true, false),
84 + end,
85 + );
86 + Format::triggered_tags(&[&self.markers.tool_calls_start], vec![outer])
87 + }
88 + BuilderToolChoice::Forced => Format::sequence(vec![
89 + Format::const_string(begin),
90 + Format::Tag(calls.pop().unwrap()),
91 + Format::const_string(end),
92 + ]),
93 + BuilderToolChoice::Required => Format::sequence(vec![
94 + Format::const_string(begin),
95 + Format::tags_with_separator(calls, "\n", true, false),
96 + Format::const_string(end),
97 + ]),
98 + }
99 + }
100 +}
101 +
102 +impl StructuralTagBuilder for HyV3StructuralTagBuilder {
103 + fn build(&self, ctx: StructuralTagContext<'_>) -> Result<StructuralTag> {
104 + Ok(StructuralTag::new(
105 + self.tool_calls(ctx.function_tools, ctx.tool_choice),
106 + ))
107 + }
108 +}
109 +
110 +/// Split argument keys into required and optional declaration-order groups.
111 +fn argument_keys(parameters: Option<&Value>) -> (Vec<&str>, Vec<&str>) {
112 + let Some(parameters) = parameters.and_then(Value::as_object) else {
113 + return (Vec::new(), Vec::new());
114 + };
115 + let properties = parameters.get("properties").and_then(Value::as_object);
116 + let required = parameters
117 + .get("required")
118 + .and_then(Value::as_array)
119 + .into_iter()
120 + .flatten()
121 + .filter_map(Value::as_str)
122 + .collect::<Vec<_>>();
123 + let required_set = required.iter().copied().collect::<HashSet<_>>();
124 +
125 + let mut required_keys = properties
126 + .into_iter()
127 + .flat_map(|properties| properties.keys())
128 + .map(String::as_str)
129 + .filter(|key| required_set.contains(key))
130 + .collect::<Vec<_>>();
131 + required_keys.extend(
132 + required
133 + .into_iter()
134 + .filter(|key| properties.is_none_or(|properties| !properties.contains_key(*key))),
135 + );
136 + let optional_keys = properties
137 + .into_iter()
138 + .flat_map(|properties| properties.keys())
139 + .map(String::as_str)
140 + .filter(|key| !required_set.contains(key))
141 + .collect();
142 + (required_keys, optional_keys)
143 +}
144 +
145 +#[cfg(test)]
146 +mod tests {
147 + use expect_test::expect;
148 + use serde_json::{Value, json};
149 + use std::sync::Arc;
150 + use xgrammar_structural_tag::builders::StructuralTagOptions;
151 + use xgrammar_structural_tag::{
152 + FunctionDefinition, FunctionToolParam, ToolChoice, ToolParam, build_structural_tag,
153 + };
154 +
155 + use super::HyV3StructuralTagBuilder;
156 + use crate::tool::HyV3ToolMarkers;
157 +
158 + fn tool(name: &str, parameters: Value) -> ToolParam {
159 + ToolParam::Function(FunctionToolParam::new(
160 + FunctionDefinition::new(name).with_parameters(parameters),
161 + ))
162 + }
163 +
164 + fn build(
165 + suffix: &str,
166 + tools: &[ToolParam],
167 + choice: ToolChoice,
168 + ) -> xgrammar_structural_tag::format::StructuralTag {
169 + build_structural_tag(
170 + HyV3StructuralTagBuilder::new(Arc::new(HyV3ToolMarkers::new(suffix))),
171 + tools,
172 + choice,
173 + StructuralTagOptions::default().with_reasoning(false),
174 + )
175 + .unwrap()
176 + }
177 +
178 + #[test]
179 + fn required_uses_suffixed_hy3_skeleton_and_bounded_values() {
180 + let tag = build(
181 + ":opensource",
182 + &[tool(
183 + "get_weather",
184 + json!({
185 + "type": "object",
186 + "properties": {
187 + "city": { "type": "string" },
188 + "days": { "type": "integer" }
189 + },
190 + "required": ["city"]
191 + }),
192 + )],
193 + ToolChoice::required(),
194 + );
195 +
196 + expect![[r#"{"type":"structural_tag","format":{"type":"sequence","elements":[{"type":"const_string","value":"<tool_calls:opensource>\n"},{"type":"tags_with_separator","tags":[{"begin":"<tool_call:opensource>get_weather<tool_sep:opensource>\n","content":{"type":"sequence","elements":[{"type":"sequence","elements":[{"type":"const_string","value":"<arg_key:opensource>"},{"type":"const_string","value":"city"},{"type":"const_string","value":"</arg_key:opensource>"},{"type":"const_string","value":"\n"},{"type":"const_string","value":"<arg_value:opensource>"},{"type":"any_text","excludes":["<tool_calls:opensource>","</tool_calls:opensource>","<tool_call:opensource>","</tool_call:opensource>","<tool_sep:opensource>","<arg_key:opensource>","</arg_key:opensource>","<arg_value:opensource>","</arg_value:opensource>"]},{"type":"const_string","value":"</arg_value:opensource>"},{"type":"const_string","value":"\n"}]},{"type":"star","content":{"type":"sequence","elements":[{"type":"const_string","value":"<arg_key:opensource>"},{"type":"const_string","value":"days"},{"type":"const_string","value":"</arg_key:opensource>"},{"type":"const_string","value":"\n"},{"type":"const_string","value":"<arg_value:opensource>"},{"type":"any_text","excludes":["<tool_calls:opensource>","</tool_calls:opensource>","<tool_call:opensource>","</tool_call:opensource>","<tool_sep:opensource>","<arg_key:opensource>","</arg_key:opensource>","<arg_value:opensource>","</arg_value:opensource>"]},{"type":"const_string","value":"</arg_value:opensource>"},{"type":"const_string","value":"\n"}]}}]},"end":"</tool_call:opensource>"}],"separator":"\n","at_least_one":true,"stop_after_first":false},{"type":"const_string","value":"\n</tool_calls:opensource>"}]}}"#]].assert_eq(&tag.to_json_string().unwrap());
197 + }
198 +
199 + #[test]
200 + fn optional_only_schema_keeps_declared_key_alternatives() {
201 + let tag = build(
202 + "",
203 + &[tool(
204 + "lookup",
205 + json!({
206 + "type": "object",
207 + "properties": {
208 + "query": { "type": "string" },
209 + "limit": { "type": "integer" }
210 + }
211 + }),
212 + )],
213 + ToolChoice::required(),
214 + );
215 + let value = serde_json::to_value(tag).unwrap();
216 +
217 + expect![[r#"{"type":"sequence","elements":[{"type":"star","content":{"type":"or","elements":[{"type":"sequence","elements":[{"type":"const_string","value":"<arg_key>"},{"type":"const_string","value":"query"},{"type":"const_string","value":"</arg_key>"},{"type":"const_string","value":"\n"},{"type":"const_string","value":"<arg_value>"},{"type":"any_text","excludes":["<tool_calls>","</tool_calls>","<tool_call>","</tool_call>","<tool_sep>","<arg_key>","</arg_key>","<arg_value>","</arg_value>"]},{"type":"const_string","value":"</arg_value>"},{"type":"const_string","value":"\n"}]},{"type":"sequence","elements":[{"type":"const_string","value":"<arg_key>"},{"type":"const_string","value":"limit"},{"type":"const_string","value":"</arg_key>"},{"type":"const_string","value":"\n"},{"type":"const_string","value":"<arg_value>"},{"type":"any_text","excludes":["<tool_calls>","</tool_calls>","<tool_call>","</tool_call>","<tool_sep>","<arg_key>","</arg_key>","<arg_value>","</arg_value>"]},{"type":"const_string","value":"</arg_value>"},{"type":"const_string","value":"\n"}]}]}}]}"#]].assert_eq(
218 + &serde_json::to_string(
219 + &value["format"]["elements"][1]["tags"][0]["content"],
220 + )
221 + .unwrap(),
222 + );
223 + }
224 +
225 + #[test]
226 + fn auto_and_forced_preserve_tool_choice_shape() {
227 + let tools = [
228 + tool("search", json!({ "type": "object" })),
229 + tool("lookup", json!({ "type": "object" })),
230 + ];
231 + let auto = build("", &tools, ToolChoice::auto()).to_json_string().unwrap();
232 + let forced = build("", &tools, ToolChoice::function("lookup")).to_json_string().unwrap();
233 +
234 + expect![[r#"{"type":"structural_tag","format":{"type":"triggered_tags","triggers":["<tool_calls>"],"tags":[{"begin":"<tool_calls>\n","content":{"type":"tags_with_separator","tags":[{"begin":"<tool_call>search<tool_sep>\n","content":{"type":"any_text","excludes":[]},"end":"</tool_call>"},{"begin":"<tool_call>lookup<tool_sep>\n","content":{"type":"any_text","excludes":[]},"end":"</tool_call>"}],"separator":"\n","at_least_one":true,"stop_after_first":false},"end":"\n</tool_calls>"}],"at_least_one":false,"stop_after_first":false,"excludes":[]}}"#]].assert_eq(&auto);
235 + expect![[r#"{"type":"structural_tag","format":{"type":"sequence","elements":[{"type":"const_string","value":"<tool_calls>\n"},{"type":"tag","begin":"<tool_call>lookup<tool_sep>\n","content":{"type":"any_text","excludes":[]},"end":"</tool_call>"},{"type":"const_string","value":"\n</tool_calls>"}]}}"#]].assert_eq(&forced);
236 + }
237 +
238 + #[test]
239 + fn missing_required_property_is_still_emitted() {
240 + let tag = build(
241 + "",
242 + &[tool(
243 + "search",
244 + json!({
245 + "type": "object",
246 + "properties": { "query": { "type": "string" } },
247 + "required": ["query", "tenant"]
248 + }),
249 + )],
250 + ToolChoice::required(),
251 + );
252 + let value = serde_json::to_value(tag).unwrap();
253 +
254 + expect![[r#"{"type":"sequence","elements":[{"type":"sequence","elements":[{"type":"const_string","value":"<arg_key>"},{"type":"const_string","value":"query"},{"type":"const_string","value":"</arg_key>"},{"type":"const_string","value":"\n"},{"type":"const_string","value":"<arg_value>"},{"type":"any_text","excludes":["<tool_calls>","</tool_calls>","<tool_call>","</tool_call>","<tool_sep>","<arg_key>","</arg_key>","<arg_value>","</arg_value>"]},{"type":"const_string","value":"</arg_value>"},{"type":"const_string","value":"\n"}]},{"type":"sequence","elements":[{"type":"const_string","value":"<arg_key>"},{"type":"const_string","value":"tenant"},{"type":"const_string","value":"</arg_key>"},{"type":"const_string","value":"\n"},{"type":"const_string","value":"<arg_value>"},{"type":"any_text","excludes":["<tool_calls>","</tool_calls>","<tool_call>","</tool_call>","<tool_sep>","<arg_key>","</arg_key>","<arg_value>","</arg_value>"]},{"type":"const_string","value":"</arg_value>"},{"type":"const_string","value":"\n"}]}]}"#]].assert_eq(
255 + &serde_json::to_string(
256 + &value["format"]["elements"][1]["tags"][0]["content"],
257 + )
258 + .unwrap(),
259 + );
260 + }
261 +}
rust/src/parser/src/tool/mod.rs
+1 -1
@@ -24,7 +24,7 @@ pub use deepseek_dsml::{DeepSeekV4ToolParser, DeepSeekV32ToolParser};
24 pub use deepseek_json::{DeepSeekV3ToolParser, DeepSeekV31ToolParser};
25 pub use error::{Result, ToolParserError};
26 pub use glm_xml::{Glm45MoeToolParser, Glm47MoeToolParser};
27 -pub use hy_v3::HyV3ToolParser;
27 +pub(crate) use hy_v3::{HyV3ToolMarkers, HyV3ToolParser};
28 pub use json::{
29 Granite4ToolParser, HermesToolParser, Internlm2ToolParser, Llama3JsonToolParser,
30 MistralToolParser, Phi4MiniJsonToolParser, Qwen3XmlToolParser,
rust/src/parser/src/unified/hy_v3.rs new
+207
@@ -0,0 +1,207 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +use vllm_tokenizer::{DynTokenizer, Tokenizer};
5 +
6 +use super::{CombinedParser, Result, UnifiedParser, UnifiedParserOutput, token_id};
7 +use crate::reasoning::HyV3ReasoningParser;
8 +use crate::tool::{HyV3ToolMarkers, HyV3ToolParser, StructuralTagBuilder, Tool};
9 +
10 +const HY_V3_MARKER_STEMS: &[&str] = &[
11 + "think",
12 + "tool_calls",
13 + "tool_call",
14 + "tool_sep",
15 + "arg_key",
16 + "arg_value",
17 +];
18 +
19 +/// Unified reasoning and tool parser for HY3 output.
20 +pub struct HyV3UnifiedParser {
21 + inner: CombinedParser,
22 +}
23 +
24 +impl HyV3UnifiedParser {
25 + /// Create a HY3 parser using the suffix encoded in tokenizer added tokens.
26 + pub fn new(tools: &[Tool], tokenizer: DynTokenizer) -> Result<Self> {
27 + let suffix = detect_token_suffix(tokenizer.as_ref());
28 + let markers = HyV3ToolMarkers::new(&suffix);
29 + for marker in markers.iter() {
30 + token_id(tokenizer.as_ref(), marker)?;
31 + }
32 +
33 + let reasoning = HyV3ReasoningParser::new(tokenizer, &suffix)?;
34 + let tool = HyV3ToolParser::new(tools, &suffix);
35 + Ok(Self {
36 + inner: CombinedParser::new(Some(Box::new(reasoning)), Some(Box::new(tool))),
37 + })
38 + }
39 +}
40 +
41 +impl UnifiedParser for HyV3UnifiedParser {
42 + fn create(tools: &[Tool], tokenizer: DynTokenizer) -> Result<Box<dyn UnifiedParser>>
43 + where
44 + Self: Sized + 'static,
45 + {
46 + Self::new(tools, tokenizer).map(|parser| Box::new(parser) as Box<dyn UnifiedParser>)
47 + }
48 +
49 + fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
50 + self.inner.initialize(prompt_token_ids)
51 + }
52 +
53 + fn preserve_special_tokens(&self) -> bool {
54 + self.inner.preserve_special_tokens()
55 + }
56 +
57 + fn structural_tag_builder(&self) -> Option<&dyn StructuralTagBuilder> {
58 + self.inner.structural_tag_builder()
59 + }
60 +
61 + fn tool_call_id(&self, tool_index: usize) -> Option<&str> {
62 + self.inner.tool_call_id(tool_index)
63 + }
64 +
65 + fn parse_into(&mut self, delta: &str, output: &mut UnifiedParserOutput) -> Result<()> {
66 + self.inner.parse_into(delta, output)
67 + }
68 +
69 + fn finish(&mut self) -> Result<UnifiedParserOutput> {
70 + self.inner.finish()
71 + }
72 +
73 + fn reset(&mut self) -> String {
74 + self.inner.reset()
75 + }
76 +}
77 +
78 +/// Detect the HY3 structural-token suffix from tokenizer added vocabulary.
79 +fn detect_token_suffix(tokenizer: &dyn Tokenizer) -> String {
80 + tokenizer
81 + .added_vocab()
82 + .iter()
83 + .filter_map(|(token, id)| marker_suffix(token).map(|suffix| (*id, suffix)))
84 + .min_by_key(|(id, _)| *id)
85 + .map(|(_, suffix)| suffix.to_string())
86 + .unwrap_or_default()
87 +}
88 +
89 +/// Extract the suffix from one opening or closing HY3 structural token.
90 +fn marker_suffix(token: &str) -> Option<&str> {
91 + let body = token.strip_prefix('<')?.strip_suffix('>')?;
92 + let body = body.strip_prefix('/').unwrap_or(body);
93 +
94 + HY_V3_MARKER_STEMS.iter().find_map(|stem| {
95 + let suffix = body.strip_prefix(stem)?;
96 + (suffix.is_empty() || suffix.starts_with(':')).then_some(suffix)
97 + })
98 +}
99 +
100 +#[cfg(test)]
101 +mod tests {
102 + use std::sync::Arc;
103 +
104 + use serde_json::json;
105 + use vllm_tokenizer::{Tokenizer, test_utils::TestTokenizer};
106 + use xgrammar_structural_tag::builders::StructuralTagOptions;
107 + use xgrammar_structural_tag::{
108 + FunctionDefinition, FunctionToolParam, ToolChoice, ToolParam, build_structural_tag,
109 + };
110 +
111 + use super::{HyV3UnifiedParser, UnifiedParser};
112 + use crate::tool::Tool;
113 + use crate::unified::{UnifiedParserEvent, UnifiedParserOutput};
114 +
115 + fn tokenizer() -> TestTokenizer {
116 + [
117 + "<think:opensource>",
118 + "</think:opensource>",
119 + "<tool_calls:opensource>",
120 + "</tool_calls:opensource>",
121 + "<tool_call:opensource>",
122 + "</tool_call:opensource>",
123 + "<tool_sep:opensource>",
124 + "<arg_key:opensource>",
125 + "</arg_key:opensource>",
126 + "<arg_value:opensource>",
127 + "</arg_value:opensource>",
128 + ]
129 + .into_iter()
130 + .enumerate()
131 + .fold(TestTokenizer::new(), |tokenizer, (index, token)| {
132 + tokenizer.with_regular_token(token, 1000 + index as u32)
133 + })
134 + }
135 +
136 + fn tools() -> Vec<Tool> {
137 + vec![Tool {
138 + name: "get_weather".to_string(),
139 + description: None,
140 + parameters: json!({
141 + "type": "object",
142 + "properties": { "city": { "type": "string" } },
143 + }),
144 + strict: None,
145 + }]
146 + }
147 +
148 + #[test]
149 + fn parses_suffixed_reasoning_and_tool_call_as_one_stream() {
150 + let tokenizer = Arc::new(tokenizer());
151 + let think_start_id = tokenizer.token_to_id("<think:opensource>").unwrap();
152 + let mut parser = HyV3UnifiedParser::new(&tools(), tokenizer).unwrap();
153 + parser.initialize(&[think_start_id]).unwrap();
154 +
155 + let chunks = [
156 + "reasoning</think:open",
157 + "source>answer<tool_calls:opensource><tool_call:opensource>get_weather",
158 + "<tool_sep:opensource><arg_key:opensource>city</arg_key:opensource>",
159 + "<arg_value:opensource>Beijing</arg_value:opensource>",
160 + "</tool_call:opensource></tool_calls:opensource>",
161 + ];
162 + let mut output = UnifiedParserOutput::default();
163 + for chunk in chunks {
164 + parser.parse_into(chunk, &mut output).unwrap();
165 + }
166 + output.append(parser.finish().unwrap());
167 +
168 + assert_eq!(
169 + output.events,
170 + vec![
171 + UnifiedParserEvent::Reasoning("reasoning".to_string()),
172 + UnifiedParserEvent::Text("answer".to_string()),
173 + UnifiedParserEvent::ToolCall(crate::tool::ToolCallDelta {
174 + tool_index: 0,
175 + name: Some("get_weather".to_string()),
176 + arguments: r#"{"city":"Beijing"}"#.to_string(),
177 + }),
178 + ]
179 + );
180 + }
181 +
182 + #[test]
183 + fn structural_tag_uses_tokenizer_detected_suffix() {
184 + let parser = HyV3UnifiedParser::new(&tools(), Arc::new(tokenizer())).unwrap();
185 + let structural_tools = [ToolParam::Function(FunctionToolParam::new(
186 + FunctionDefinition::new("get_weather").with_parameters(json!({
187 + "type": "object",
188 + "properties": { "city": { "type": "string" } },
189 + "required": ["city"]
190 + })),
191 + ))];
192 +
193 + let tag = build_structural_tag(
194 + parser.structural_tag_builder().unwrap(),
195 + &structural_tools,
196 + ToolChoice::required(),
197 + StructuralTagOptions::default().with_reasoning(false),
198 + )
199 + .unwrap()
200 + .to_json_string()
201 + .unwrap();
202 +
203 + assert!(tag.contains("<tool_calls:opensource>"));
204 + assert!(tag.contains("<arg_value:opensource>"));
205 + assert!(!tag.contains("glm_xml"));
206 + }
207 +}
rust/src/parser/src/unified/mod.rs
+2
@@ -5,11 +5,13 @@
5
6 mod combined;
7 mod gemma4;
8 +mod hy_v3;
9 mod inkling;
10 mod kimi_k3;
11
12 pub use combined::CombinedParser;
13 pub use gemma4::Gemma4UnifiedParser;
14 +pub use hy_v3::HyV3UnifiedParser;
15 pub use inkling::InklingUnifiedParser;
16 pub use kimi_k3::{KimiK3StructuralTagBuilder, KimiK3UnifiedParser};
17 use thiserror::Error;
rust/src/text/src/backend/hf/config.rs
+38 -1
@@ -111,12 +111,32 @@ pub(super) struct GenerationConfig {
111 pub eos_token_id: Option<OneOrManyTokenIds>,
112 pub temperature: Option<f32>,
113 pub top_p: Option<f32>,
114 + #[serde(deserialize_with = "deserialize_top_k")]
115 pub top_k: Option<u32>,
116 pub min_p: Option<f32>,
117 pub repetition_penalty: Option<f32>,
118 pub max_new_tokens: Option<u32>,
119 }
120
121 +/// Deserialize vLLM-compatible `top_k` values from generation configs.
122 +///
123 +/// Both `-1` and `0` disable top-k sampling and become `None`; positive values
124 +/// are preserved.
125 +fn deserialize_top_k<'de, D>(deserializer: D) -> std::result::Result<Option<u32>, D::Error>
126 +where
127 + D: serde::Deserializer<'de>,
128 +{
129 + match Option::<i64>::deserialize(deserializer)? {
130 + None | Some(-1) | Some(0) => Ok(None),
131 + Some(value) if value > 0 => {
132 + u32::try_from(value).map(Some).map_err(serde::de::Error::custom)
133 + }
134 + Some(value) => Err(serde::de::Error::custom(format!(
135 + "top_k must be -1, 0, or a positive integer, got {value}"
136 + ))),
137 + }
138 +}
139 +
140 /// HF generation configs allow either one EOS id or a list of EOS ids.
141 #[derive(Debug, Clone, Deserialize)]
142 #[serde(untagged)]
@@ -294,7 +314,24 @@ where
314
315 #[cfg(test)]
316 mod tests {
297 - use super::ModelConfig;
317 + use super::{GenerationConfig, ModelConfig};
318 +
319 + #[test]
320 + fn generation_config_normalizes_vllm_top_k_values() {
321 + for (input, expected) in [
322 + (r#"{"top_k":null}"#, None),
323 + (r#"{"top_k":-1}"#, None),
324 + (r#"{"top_k":0}"#, None),
325 + (r#"{"top_k":20}"#, Some(20)),
326 + ] {
327 + let config: GenerationConfig = serde_json::from_str(input).unwrap();
328 + assert_eq!(config.top_k, expected, "input={input}");
329 + }
330 +
331 + for input in [r#"{"top_k":-2}"#, r#"{"top_k":4294967296}"#] {
332 + assert!(serde_json::from_str::<GenerationConfig>(input).is_err());
333 + }
334 + }
335
336 #[test]
337 fn model_config_detects_moe_from_named_expert_fields() {
rust/src/tokenizer/src/hf.rs
+33
@@ -149,10 +149,20 @@ fn encode_fastokens_ordinary(
149 pub struct HuggingFaceTokenizer {
150 backend: Backend,
151 special_token_ids: Arc<[u32]>,
152 + added_vocab: Box<[(String, u32)]>,
153 }
154
155 impl HuggingFaceTokenizer {
156 fn from_hf_backend(tokenizer: HfTokenizer) -> Self {
157 + let added_vocab = {
158 + let mut vocab: Vec<_> = tokenizer
159 + .get_added_tokens_decoder()
160 + .iter()
161 + .map(|(&id, token)| (token.content.clone(), id))
162 + .collect();
163 + vocab.sort_unstable_by_key(|(_, id)| *id);
164 + vocab.into_boxed_slice()
165 + };
166 let special_token_ids = {
167 let mut ids: Vec<u32> = tokenizer
168 .get_added_tokens_decoder()
@@ -167,10 +177,21 @@ impl HuggingFaceTokenizer {
177 Self {
178 backend: Backend::Hf(Box::new(tokenizer)),
179 special_token_ids,
180 + added_vocab,
181 }
182 }
183
184 fn from_fastokens_backend(tokenizer: FastokensTokenizer) -> Self {
185 + let added_vocab = {
186 + let mut vocab: Vec<_> = tokenizer
187 + .added_tokens()
188 + .into_iter()
189 + .flat_map(|added_tokens| added_tokens.iter())
190 + .map(|token| (token.content.to_string(), token.id))
191 + .collect();
192 + vocab.sort_unstable_by_key(|(_, id)| *id);
193 + vocab.into_boxed_slice()
194 + };
195 let special_token_ids = {
196 let mut ids: Vec<u32> = tokenizer
197 .added_tokens()
@@ -192,6 +213,7 @@ impl HuggingFaceTokenizer {
213 Self {
214 backend,
215 special_token_ids,
216 + added_vocab,
217 }
218 }
219
@@ -292,6 +314,10 @@ impl Tokenizer for HuggingFaceTokenizer {
314 }
315 }
316
317 + fn added_vocab(&self) -> &[(String, u32)] {
318 + &self.added_vocab
319 + }
320 +
321 fn is_special_id(&self, token_id: u32) -> bool {
322 self.special_token_ids.binary_search(&token_id).is_ok()
323 }
@@ -477,6 +503,13 @@ mod tests {
503 tokenizer.encode(SPECIAL_TOKEN, false).unwrap(),
504 vec![tokenizer.token_to_id(SPECIAL_TOKEN).unwrap()]
505 );
506 + assert_eq!(
507 + tokenizer.added_vocab(),
508 + vec![
509 + (REGULAR_TOKEN.to_string(), 256),
510 + (SPECIAL_TOKEN.to_string(), 257),
511 + ]
512 + );
513
514 for text in [
515 "",
rust/src/tokenizer/src/lib.rs
+9
@@ -39,6 +39,15 @@ pub trait Tokenizer: Send + Sync {
39 /// Convert one token ID into the tokenizer's raw token string.
40 fn id_to_token(&self, id: u32) -> Option<String>;
41
42 + /// Borrow tokenizer added-vocabulary entries as `(token, id)` pairs.
43 + ///
44 + /// Backends that do not expose the distinction between model vocabulary
45 + /// and added vocabulary return an empty list.
46 + // TODO: add support to all tokenizer backends
47 + fn added_vocab(&self) -> &[(String, u32)] {
48 + &[]
49 + }
50 +
51 /// Return the vocabulary size. Backends that cannot report it fall back to
52 /// `usize::MAX`, an effectively unbounded value used only by test stubs.
53 fn vocab_size(&self) -> usize {
rust/src/tokenizer/src/test_utils.rs
+19 -1
@@ -69,6 +69,7 @@ struct TestToken {
69 pub struct TestTokenizer {
70 token_to_id: BTreeMap<String, u32>,
71 id_to_token: BTreeMap<u32, TestToken>,
72 + added_vocab: Vec<(String, u32)>,
73 unknown_decode: UnknownDecode,
74 vocab_size: Option<usize>,
75 bos_token_id: Option<u32>,
@@ -86,6 +87,7 @@ impl TestTokenizer {
87 Self {
88 token_to_id: BTreeMap::new(),
89 id_to_token: BTreeMap::new(),
90 + added_vocab: Vec::new(),
91 unknown_decode: UnknownDecode::Error,
92 vocab_size: None,
93 bos_token_id: None,
@@ -154,9 +156,21 @@ impl TestTokenizer {
156 if self.token_to_id.insert(token.clone(), id).is_some() {
157 panic!("configured test token text {token:?} was registered more than once");
158 }
157 - if self.id_to_token.insert(id, TestToken { text: token, kind }).is_some() {
159 + if self
160 + .id_to_token
161 + .insert(
162 + id,
163 + TestToken {
164 + text: token.clone(),
165 + kind,
166 + },
167 + )
168 + .is_some()
169 + {
170 panic!("configured test token id {id} was registered more than once");
171 }
172 + self.added_vocab.push((token, id));
173 + self.added_vocab.sort_unstable_by_key(|(_, id)| *id);
174 }
175
176 fn byte_to_token(id: u32) -> Option<String> {
@@ -254,6 +268,10 @@ impl Tokenizer for TestTokenizer {
268 .or_else(|| Self::byte_to_token(id))
269 }
270
271 + fn added_vocab(&self) -> &[(String, u32)] {
272 + &self.added_vocab
273 + }
274 +
275 fn vocab_size(&self) -> usize {
276 self.vocab_size.unwrap_or_else(|| self.inferred_vocab_size())
277 }