[Rust Frontend] Build full-output grammars from initialized reasoning parsers (#57340)

Signed-off-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: Codex <noreply@openai.com>

Bugen Zhao committed Sep 22, 2026 at 14:11 UTC 496c6472cb70d5eb8681dadae238d160f5086454
12 files changed +452 -10
rust/src/parser/src/output_grammar.rs
+34 -3
@@ -133,6 +133,13 @@ pub enum OutputGrammarError {
133 /// A model-specific structural-tag builder rejected its inputs.
134 #[error("failed to build output grammar")]
135 Build(#[from] xgrammar_structural_tag::Error),
136 + /// A model-specific parser could not project a builder format to its
137 + /// visible-output language.
138 + #[error("unexpected output grammar shape from `{builder}` builder")]
139 + UnexpectedBuilderFormat {
140 + /// Builder whose output did not match the parser's known shape.
141 + builder: &'static str,
142 + },
143 }
144
145 /// Build the visible (post-reasoning) language from a crate structural-tag
@@ -149,6 +156,19 @@ pub(crate) fn visible_format_from_builder(
156 let Some(builder) = builder else {
157 return Ok(None);
158 };
159 +
160 + format_from_builder(
161 + builder,
162 + ctx,
163 + StructuralTagOptions::default().with_reasoning(false),
164 + )
165 +}
166 +
167 +fn format_from_builder(
168 + builder: &dyn StructuralTagBuilder,
169 + ctx: &OutputGrammarContext<'_>,
170 + options: StructuralTagOptions,
171 +) -> Result<Option<Format>> {
172 if !tool_grammar_applies(ctx) {
173 return Ok(None);
174 }
@@ -157,14 +177,25 @@ pub(crate) fn visible_format_from_builder(
177 builder,
178 &tool_params(ctx.tools, ctx.tool_strict_level),
179 ctx.tool_choice.clone(),
160 - StructuralTagOptions::default()
161 - .with_reasoning(false)
162 - .with_parallel_tool_calls(ctx.parallel_tool_calls),
180 + options.with_parallel_tool_calls(ctx.parallel_tool_calls),
181 )?;
182
183 Ok(Some(structural_tag.format))
184 }
185
186 +#[cfg(test)]
187 +pub(crate) fn full_format_from_builder_for_test(
188 + builder: &dyn StructuralTagBuilder,
189 + ctx: &OutputGrammarContext<'_>,
190 + reasoning: xgrammar_structural_tag::builders::ReasoningMode,
191 +) -> Result<Option<Format>> {
192 + format_from_builder(
193 + builder,
194 + ctx,
195 + StructuralTagOptions::default().with_reasoning(reasoning),
196 + )
197 +}
198 +
199 fn tool_params(tools: &[Tool], strict_level: ToolStrictLevel) -> Vec<ToolParam> {
200 tools
201 .iter()
rust/src/parser/src/reasoning/deepseek_r1.rs
+11
@@ -2,6 +2,9 @@
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 use vllm_tokenizer::{DecodedText, DynTokenizer};
5 +use xgrammar_structural_tag::format::Format;
6 +
7 +use crate::output_grammar::{self, OutputGrammarContext};
8
9 use super::{
10 DelimitedReasoningParser, DelimitedReasoningParserBuilder, ReasoningDelta, ReasoningParser,
@@ -36,6 +39,14 @@ impl ReasoningParser for DeepSeekR1ReasoningParser {
39 self.inner.initialize(prompt_token_ids)
40 }
41
42 + fn wrap_visible_format(
43 + &self,
44 + _ctx: &OutputGrammarContext<'_>,
45 + visible: &Format,
46 + ) -> output_grammar::Result<Option<Format>> {
47 + Ok(Some(self.inner.wrap_visible_format(visible)))
48 + }
49 +
50 fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta> {
51 Ok(self.inner.push(delta))
52 }
rust/src/parser/src/reasoning/deepseek_v3.rs
+11
@@ -2,6 +2,9 @@
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 use vllm_tokenizer::{DecodedText, DynTokenizer};
5 +use xgrammar_structural_tag::format::Format;
6 +
7 +use crate::output_grammar::{self, OutputGrammarContext};
8
9 use super::{
10 DelimitedReasoningParser, DelimitedReasoningParserBuilder, ReasoningDelta, ReasoningParser,
@@ -35,6 +38,14 @@ impl ReasoningParser for DeepSeekV3ReasoningParser {
38 self.inner.initialize(prompt_token_ids)
39 }
40
41 + fn wrap_visible_format(
42 + &self,
43 + _ctx: &OutputGrammarContext<'_>,
44 + visible: &Format,
45 + ) -> output_grammar::Result<Option<Format>> {
46 + Ok(Some(self.inner.wrap_visible_format(visible)))
47 + }
48 +
49 fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta> {
50 Ok(self.inner.push(delta))
51 }
rust/src/parser/src/reasoning/delimited.rs
+26
@@ -2,6 +2,7 @@
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 use vllm_tokenizer::{DecodedText, DynTokenizer, Tokenizer};
5 +use xgrammar_structural_tag::format::Format;
6
7 use super::{ReasoningDelta, ReasoningError, Result};
8 use crate::utils::partial_prefix_len;
@@ -148,6 +149,31 @@ impl DelimitedReasoningParser {
149 self.current_in_reasoning
150 }
151
152 + /// Wrap visible output from the initialized reasoning and framing state.
153 + pub(crate) fn wrap_visible_format(&self, visible: &Format) -> Format {
154 + let begin = if self.current_in_reasoning {
155 + "".to_string()
156 + } else {
157 + self.framed_start_token.clone()
158 + };
159 + let mut reasoning = Format::tag(begin, Format::any_text(), self.end_token.clone());
160 + if !self.after_end.is_empty() {
161 + reasoning = Format::sequence(vec![reasoning, Format::const_string(self.after_end)]);
162 + }
163 + if !self.current_in_reasoning {
164 + reasoning = Format::optional(reasoning);
165 + if !self.pending_after.is_empty() {
166 + return Format::sequence(vec![
167 + Format::const_string(self.pending_after),
168 + reasoning,
169 + visible.clone(),
170 + ]);
171 + }
172 + }
173 +
174 + Format::sequence(vec![reasoning, visible.clone()])
175 + }
176 +
177 /// Parse one decoded text delta and return its reasoning/content split.
178 pub(crate) fn push(&mut self, delta: DecodedText) -> ReasoningDelta {
179 self.buffer.append(delta);
rust/src/parser/src/reasoning/glm45.rs
+11
@@ -2,6 +2,9 @@
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 use vllm_tokenizer::{DecodedText, DynTokenizer};
5 +use xgrammar_structural_tag::format::Format;
6 +
7 +use crate::output_grammar::{self, OutputGrammarContext};
8
9 use super::{
10 DelimitedReasoningParser, DelimitedReasoningParserBuilder, ReasoningDelta, ReasoningParser,
@@ -38,6 +41,14 @@ impl ReasoningParser for Glm45ReasoningParser {
41 self.inner.initialize(prompt_token_ids)
42 }
43
44 + fn wrap_visible_format(
45 + &self,
46 + _ctx: &OutputGrammarContext<'_>,
47 + visible: &Format,
48 + ) -> output_grammar::Result<Option<Format>> {
49 + Ok(Some(self.inner.wrap_visible_format(visible)))
50 + }
51 +
52 fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta> {
53 Ok(self.inner.push(delta))
54 }
rust/src/parser/src/reasoning/hy.rs
+11
@@ -2,6 +2,9 @@
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 use vllm_tokenizer::{DecodedText, DynTokenizer};
5 +use xgrammar_structural_tag::format::Format;
6 +
7 +use crate::output_grammar::{self, OutputGrammarContext};
8
9 use super::{
10 DelimitedReasoningParser, DelimitedReasoningParserBuilder, ReasoningDelta, ReasoningError,
@@ -43,6 +46,14 @@ impl ReasoningParser for HyReasoningParser {
46 self.inner.initialize(prompt_token_ids)
47 }
48
49 + fn wrap_visible_format(
50 + &self,
51 + _ctx: &OutputGrammarContext<'_>,
52 + visible: &Format,
53 + ) -> output_grammar::Result<Option<Format>> {
54 + Ok(Some(self.inner.wrap_visible_format(visible)))
55 + }
56 +
57 fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta> {
58 Ok(self.inner.push(delta))
59 }
rust/src/parser/src/reasoning/qwen3.rs
+11
@@ -2,6 +2,9 @@
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 use vllm_tokenizer::{DecodedText, DynTokenizer};
5 +use xgrammar_structural_tag::format::Format;
6 +
7 +use crate::output_grammar::{self, OutputGrammarContext};
8
9 use super::{
10 DelimitedReasoningParser, DelimitedReasoningParserBuilder, ReasoningDelta, ReasoningParser,
@@ -40,6 +43,14 @@ impl ReasoningParser for Qwen3ReasoningParser {
43 self.inner.initialize(prompt_token_ids)
44 }
45
46 + fn wrap_visible_format(
47 + &self,
48 + _ctx: &OutputGrammarContext<'_>,
49 + visible: &Format,
50 + ) -> output_grammar::Result<Option<Format>> {
51 + Ok(Some(self.inner.wrap_visible_format(visible)))
52 + }
53 +
54 fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta> {
55 Ok(self.inner.push(delta))
56 }
rust/src/parser/src/reasoning/step3p5.rs
+11
@@ -2,6 +2,9 @@
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 use vllm_tokenizer::{DecodedText, DynTokenizer};
5 +use xgrammar_structural_tag::format::Format;
6 +
7 +use crate::output_grammar::{self, OutputGrammarContext};
8
9 use super::{
10 DelimitedReasoningParser, DelimitedReasoningParserBuilder, ReasoningDelta, ReasoningParser,
@@ -38,6 +41,14 @@ impl ReasoningParser for Step3p5ReasoningParser {
41 self.inner.initialize(prompt_token_ids)
42 }
43
44 + fn wrap_visible_format(
45 + &self,
46 + _ctx: &OutputGrammarContext<'_>,
47 + visible: &Format,
48 + ) -> output_grammar::Result<Option<Format>> {
49 + Ok(Some(self.inner.wrap_visible_format(visible)))
50 + }
51 +
52 fn push(&mut self, delta: DecodedText) -> Result<ReasoningDelta> {
53 Ok(self.inner.push(delta))
54 }
rust/src/parser/src/tool/minimax_m2.rs
+34
@@ -6,10 +6,12 @@ use winnow::combinator::{alt, delimited, eof, repeat, seq, terminated};
6 use winnow::prelude::*;
7 use winnow::stream::Partial;
8 use winnow::token::{literal, rest, take_until};
9 +use xgrammar_structural_tag::format::Format;
10
11 use super::parameters::ToolSchemas;
12 use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len_mul, take_until_marker};
13 use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput};
14 +use crate::output_grammar::{self, OutputGrammarContext, OutputGrammarError};
15 use crate::tool::{StructuralTagBuilder, Tool};
16
17 const TOOL_CALL_START: &str = "<minimax:tool_call>";
@@ -120,6 +122,38 @@ impl ToolParser for MinimaxM2ToolParser {
122 Some(xgrammar_structural_tag::Model::Minimax.builder())
123 }
124
125 + fn build_visible_format(
126 + &self,
127 + ctx: &OutputGrammarContext<'_>,
128 + ) -> output_grammar::Result<Option<Format>> {
129 + let Some(format) =
130 + output_grammar::visible_format_from_builder(self.structural_tag_builder(), ctx)?
131 + else {
132 + return Ok(None);
133 + };
134 +
135 + // The MiniMax builder's reasoning=false form retains the empty
136 + // thinking close and separator used by its chat template. Strip that
137 + // fixed prefix so the initialized reasoning parser can supply the
138 + // actual reasoning phase.
139 + let Format::Sequence(sequence) = format else {
140 + return Err(OutputGrammarError::UnexpectedBuilderFormat { builder: "minimax" });
141 + };
142 + let [
143 + Format::ConstString(empty_think),
144 + Format::ConstString(separator),
145 + visible,
146 + ] = sequence.elements.as_slice()
147 + else {
148 + return Err(OutputGrammarError::UnexpectedBuilderFormat { builder: "minimax" });
149 + };
150 + if empty_think.value != "\n</think>\n\n" || separator.value != "\n\n" {
151 + return Err(OutputGrammarError::UnexpectedBuilderFormat { builder: "minimax" });
152 + }
153 +
154 + Ok(Some(visible.clone()))
155 + }
156 +
157 fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
158 self.buffer.push_str(chunk);
159
rust/src/parser/src/unified/combined.rs
+248 -3
@@ -155,11 +155,26 @@ mod tests {
155 use std::sync::Arc;
156
157 use vllm_tokenizer::test_utils::TestTokenizer;
158 - use vllm_tokenizer::{DecodedText, TokenAnchor, TokenAttribution};
158 + use vllm_tokenizer::{DecodedText, DynTokenizer, TokenAnchor, TokenAttribution, Tokenizer};
159 + use xgrammar_structural_tag::ToolChoice;
160 + use xgrammar_structural_tag::builders::ReasoningMode;
161 + use xgrammar_structural_tag::format::Format;
162
163 use super::CombinedParser;
161 - use crate::reasoning::{Qwen3ReasoningParser, ReasoningDelta, ReasoningParser};
162 - use crate::tool::{Qwen3XmlToolParser, Tool, ToolParser};
164 + use crate::output_grammar::{
165 + GrammarCoverage, OutputGrammarContext, full_format_from_builder_for_test,
166 + };
167 + use crate::reasoning::{
168 + DeepSeekR1ReasoningParser, DeepSeekV3ReasoningParser, Glm45ReasoningParser,
169 + Glm47ReasoningParser, KimiK2ReasoningParser, MiniMaxM2ReasoningParser,
170 + NemotronV3ReasoningParser, Qwen3ReasoningParser, ReasoningDelta, ReasoningParser,
171 + Step3ReasoningParser,
172 + };
173 + use crate::tool::{
174 + DeepSeekV3ToolParser, DeepSeekV4ToolParser, DeepSeekV31ToolParser, DeepSeekV32ToolParser,
175 + Glm47MoeToolParser, KimiK2ToolParser, MinimaxM2ToolParser, Qwen3CoderToolParser,
176 + Qwen3XmlToolParser, Tool, ToolParser,
177 + };
178 use crate::unified::{UnifiedParser, UnifiedParserEvent, UnifiedParserOutput};
179
180 fn tokenizer() -> TestTokenizer {
@@ -191,6 +206,87 @@ mod tests {
206 output
207 }
208
209 + fn assert_builder_parity<R, T>(name: &str)
210 + where
211 + R: ReasoningParser + 'static,
212 + T: ToolParser + 'static,
213 + {
214 + // All builders' reasoning=true forms begin inside a reasoning block.
215 + // Auto starts outside reasoning and allows a generated reasoning block.
216 + for (mode, prompt) in [
217 + (ReasoningMode::Enabled, &[256][..]),
218 + (ReasoningMode::Auto, &[][..]),
219 + ] {
220 + for tool_choice in [
221 + ToolChoice::required(),
222 + ToolChoice::function("get_weather"),
223 + ToolChoice::auto(),
224 + ] {
225 + let mut tools = test_tools();
226 + if tool_choice == ToolChoice::auto() {
227 + tools[0].strict = Some(true);
228 + }
229 + let ctx = OutputGrammarContext {
230 + tools: &tools,
231 + tool_choice: &tool_choice,
232 + tool_strict_level: Default::default(),
233 + parallel_tool_calls: true,
234 + };
235 + let tool = T::create(&tools).unwrap();
236 + let expected = full_format_from_builder_for_test(
237 + tool.structural_tag_builder().unwrap(),
238 + &ctx,
239 + mode,
240 + )
241 + .unwrap()
242 + .unwrap();
243 + let reasoning = R::create(Arc::new(tokenizer()) as DynTokenizer).unwrap();
244 + let mut parser = CombinedParser::new(Some(reasoning), Some(tool));
245 + parser.initialize(prompt).unwrap();
246 + let actual = parser.build_output_grammar(&ctx).unwrap().unwrap();
247 +
248 + assert_eq!(
249 + actual.coverage,
250 + GrammarCoverage::FromTokenZero,
251 + "{name} {mode:?} {tool_choice:?}"
252 + );
253 + assert_eq!(
254 + normalize_builder_parity(actual.format),
255 + normalize_builder_parity(expected),
256 + "{name} {mode:?} {tool_choice:?}"
257 + );
258 + }
259 + }
260 + }
261 +
262 + fn normalize_builder_parity(format: Format) -> Format {
263 + match format {
264 + Format::AnyText(mut text) => {
265 + text.excludes.clear();
266 + Format::AnyText(text)
267 + }
268 + Format::Tag(mut tag) => {
269 + tag.content = Box::new(normalize_builder_parity(*tag.content));
270 + Format::Tag(tag)
271 + }
272 + Format::Optional(mut optional) => {
273 + optional.content = Box::new(normalize_builder_parity(*optional.content));
274 + Format::Optional(optional)
275 + }
276 + Format::Sequence(sequence) => {
277 + let mut elements = Vec::new();
278 + for element in sequence.elements {
279 + match normalize_builder_parity(element) {
280 + Format::Sequence(nested) => elements.extend(nested.elements),
281 + element => elements.push(element),
282 + }
283 + }
284 + Format::sequence(elements)
285 + }
286 + format => format,
287 + }
288 + }
289 +
290 struct PreserveReasoningParser;
291
292 impl ReasoningParser for PreserveReasoningParser {
@@ -401,4 +497,153 @@ mod tests {
497 parser = CombinedParser::new(None, Some(Box::new(PreserveToolParser)));
498 assert!(parser.preserve_special_tokens());
499 }
500 +
501 + #[test]
502 + fn split_parser_wrappers_match_builder_full_formats() {
503 + assert_builder_parity::<Qwen3ReasoningParser, Qwen3XmlToolParser>("qwen3");
504 + assert_builder_parity::<Qwen3ReasoningParser, Qwen3CoderToolParser>("qwen3.5");
505 + assert_builder_parity::<DeepSeekR1ReasoningParser, DeepSeekV3ToolParser>("deepseek-r1");
506 + assert_builder_parity::<DeepSeekV3ReasoningParser, DeepSeekV31ToolParser>("deepseek-v3.1");
507 + assert_builder_parity::<DeepSeekV3ReasoningParser, DeepSeekV32ToolParser>("deepseek-v3.2");
508 + assert_builder_parity::<DeepSeekV3ReasoningParser, DeepSeekV4ToolParser>("deepseek-v4");
509 + assert_builder_parity::<Glm47ReasoningParser, Glm47MoeToolParser>("glm-4.7");
510 + assert_builder_parity::<KimiK2ReasoningParser, KimiK2ToolParser>("kimi-k2");
511 + assert_builder_parity::<MiniMaxM2ReasoningParser, MinimaxM2ToolParser>("minimax-m2");
512 + }
513 +
514 + #[test]
515 + fn outside_reasoning_allows_optional_generated_reasoning() {
516 + let tools = test_tools();
517 + let tool_choice = ToolChoice::required();
518 + let ctx = OutputGrammarContext {
519 + tools: &tools,
520 + tool_choice: &tool_choice,
521 + tool_strict_level: Default::default(),
522 + parallel_tool_calls: true,
523 + };
524 + let reasoning = Qwen3ReasoningParser::create(Arc::new(tokenizer())).unwrap();
525 + let tool = Qwen3XmlToolParser::create(&tools).unwrap();
526 + let visible = tool.build_visible_format(&ctx).unwrap().unwrap();
527 + let mut parser = CombinedParser::new(Some(reasoning), Some(tool));
528 + parser.initialize(&[]).unwrap();
529 +
530 + let actual = parser.build_output_grammar(&ctx).unwrap().unwrap();
531 + let expected = Format::sequence(vec![
532 + Format::optional(Format::sequence(vec![
533 + Format::tag("<think>", Format::any_text(), "</think>"),
534 + Format::const_string("\n\n"),
535 + ])),
536 + visible,
537 + ]);
538 +
539 + assert_eq!(actual.coverage, GrammarCoverage::FromTokenZero);
540 + assert_eq!(actual.format, expected);
541 + }
542 +
543 + #[test]
544 + fn glm45_wrapper_keeps_only_generated_start_framing() {
545 + let tools = test_tools();
546 + let tool_choice = ToolChoice::required();
547 + let ctx = OutputGrammarContext {
548 + tools: &tools,
549 + tool_choice: &tool_choice,
550 + tool_strict_level: Default::default(),
551 + parallel_tool_calls: true,
552 + };
553 + let visible = Format::const_string("answer");
554 +
555 + // GLM-4.5/4.6 thinking prompts leave the whole reasoning opener to generation.
556 + for (prompt, begin) in [(vec![], "\n<think>"), (vec![256], "")] {
557 + let mut parser = Glm45ReasoningParser::new(Arc::new(tokenizer())).unwrap();
558 + parser.initialize(&prompt).unwrap();
559 + let reasoning = Format::sequence(vec![
560 + Format::tag(begin, Format::any_text(), "</think>"),
561 + Format::const_string("\n"),
562 + ]);
563 + let reasoning = if prompt.is_empty() {
564 + Format::optional(reasoning)
565 + } else {
566 + reasoning
567 + };
568 + assert_eq!(
569 + parser.wrap_visible_format(&ctx, &visible).unwrap(),
570 + Some(Format::sequence(vec![reasoning, visible.clone()])),
571 + "prompt {prompt:?}"
572 + );
573 + }
574 + }
575 +
576 + #[test]
577 + fn delimited_wrappers_use_model_specific_suffixes() {
578 + let tools = test_tools();
579 + let tool_choice = ToolChoice::required();
580 + let ctx = OutputGrammarContext {
581 + tools: &tools,
582 + tool_choice: &tool_choice,
583 + tool_strict_level: Default::default(),
584 + parallel_tool_calls: true,
585 + };
586 + let visible = Format::const_string("visible");
587 +
588 + let mut nemotron = NemotronV3ReasoningParser::new(Arc::new(tokenizer())).unwrap();
589 + nemotron.initialize(&[256]).unwrap();
590 + assert_eq!(
591 + nemotron.wrap_visible_format(&ctx, &visible).unwrap(),
592 + Some(Format::sequence(vec![
593 + Format::sequence(vec![
594 + Format::tag("", Format::any_text(), "</think>"),
595 + Format::const_string("\n"),
596 + ]),
597 + visible.clone(),
598 + ]))
599 + );
600 +
601 + let mut step3 = Step3ReasoningParser::new(Arc::new(tokenizer())).unwrap();
602 + step3.initialize(&[256]).unwrap();
603 + assert_eq!(
604 + step3.wrap_visible_format(&ctx, &visible).unwrap(),
605 + Some(Format::sequence(vec![
606 + Format::tag("", Format::any_text(), "</think>"),
607 + visible,
608 + ]))
609 + );
610 + }
611 +
612 + #[test]
613 + fn prompt_closed_reasoning_keeps_only_unconsumed_separator() {
614 + let tools = test_tools();
615 + let tool_choice = ToolChoice::required();
616 + let ctx = OutputGrammarContext {
617 + tools: &tools,
618 + tool_choice: &tool_choice,
619 + tool_strict_level: Default::default(),
620 + parallel_tool_calls: true,
621 + };
622 + let tokenizer = Arc::new(tokenizer());
623 + let reasoning = Qwen3ReasoningParser::create(tokenizer.clone()).unwrap();
624 + let tool = Qwen3XmlToolParser::create(&tools).unwrap();
625 + let mut parser = CombinedParser::new(Some(reasoning), Some(tool));
626 + parser.initialize(&[]).unwrap();
627 + let outside = parser.build_output_grammar(&ctx).unwrap().unwrap().format;
628 +
629 + for (prompt, remaining) in [
630 + ("</think>", "\n\n"),
631 + ("</think>\n", "\n"),
632 + ("</think>\n\n", ""),
633 + ] {
634 + parser.initialize(&tokenizer.encode(prompt, false).unwrap()).unwrap();
635 + let actual = parser.build_output_grammar(&ctx).unwrap().unwrap();
636 + let expected = if remaining.is_empty() {
637 + outside.clone()
638 + } else {
639 + Format::sequence(vec![Format::const_string(remaining), outside.clone()])
640 + };
641 + assert_eq!(actual.coverage, GrammarCoverage::FromTokenZero);
642 + assert_eq!(
643 + normalize_builder_parity(actual.format),
644 + normalize_builder_parity(expected),
645 + "{prompt:?}"
646 + );
647 + }
648 + }
649 }
rust/src/parser/src/unified/hy/hy_v3.rs
+38 -2
@@ -154,7 +154,10 @@ mod tests {
154 #[test]
155 fn structural_tag_uses_tokenizer_detected_suffix() {
156 let tools = tools();
157 - let parser = HyV3UnifiedParser::new(&tools, Arc::new(tokenizer())).unwrap();
157 + let tokenizer = Arc::new(tokenizer());
158 + let mut parser = HyV3UnifiedParser::new(&tools, tokenizer.clone()).unwrap();
159 + let prompt = tokenizer.encode("<think:opensource>", false).unwrap();
160 + parser.initialize(&prompt).unwrap();
161 let tag = parser
162 .build_output_grammar(&OutputGrammarContext {
163 tools: &tools,
@@ -164,11 +167,44 @@ mod tests {
167 })
168 .unwrap()
169 .unwrap();
167 - assert_eq!(tag.coverage, GrammarCoverage::FinalOutputOnly);
170 + assert_eq!(tag.coverage, GrammarCoverage::FromTokenZero);
171 let tag = StructuralTag::new(tag.format).to_json_string().unwrap();
172
173 + assert!(tag.contains("</think:opensource>"));
174 assert!(tag.contains("<tool_calls:opensource>"));
175 assert!(tag.contains("<arg_value:opensource>"));
176 assert!(!tag.contains("glm_xml"));
177 }
178 +
179 + #[test]
180 + fn grammar_root_follows_prompt_reasoning_state() {
181 + let tools = tools();
182 + let tokenizer = Arc::new(tokenizer());
183 + let mut parser = HyV3UnifiedParser::new(&tools, tokenizer.clone()).unwrap();
184 + for (prompt, inside) in [
185 + ("<think:opensource>", true),
186 + ("</think:opensource>", false),
187 + ("", false),
188 + ] {
189 + parser.initialize(&tokenizer.encode(prompt, false).unwrap()).unwrap();
190 + let grammar = parser
191 + .build_output_grammar(&OutputGrammarContext {
192 + tools: &tools,
193 + tool_choice: &ToolChoice::required(),
194 + tool_strict_level: Default::default(),
195 + parallel_tool_calls: true,
196 + })
197 + .unwrap()
198 + .unwrap();
199 + let value = serde_json::to_value(grammar.format).unwrap();
200 + let root = &value["elements"][0];
201 + if inside {
202 + assert_eq!(root["begin"], "");
203 + assert_eq!(root["end"], "</think:opensource>");
204 + } else {
205 + assert_eq!(root["type"], "optional");
206 + assert_eq!(root["content"]["begin"], "<think:opensource>");
207 + }
208 + }
209 + }
210 }
rust/src/parser/src/unified/hy/hy_v4.rs
+6 -2
@@ -167,7 +167,10 @@ mod tests {
167 #[test]
168 fn structural_tag_uses_compact_hy4_skeleton() {
169 let tools = tools();
170 - let parser = HyV4UnifiedParser::new(&tools, Arc::new(tokenizer())).unwrap();
170 + let tokenizer = Arc::new(tokenizer());
171 + let mut parser = HyV4UnifiedParser::new(&tools, tokenizer.clone()).unwrap();
172 + let prompt = tokenizer.encode("<think:opensource>", false).unwrap();
173 + parser.initialize(&prompt).unwrap();
174 let tag = parser
175 .build_output_grammar(&OutputGrammarContext {
176 tools: &tools,
@@ -177,10 +180,11 @@ mod tests {
180 })
181 .unwrap()
182 .unwrap();
180 - assert_eq!(tag.coverage, GrammarCoverage::FinalOutputOnly);
183 + assert_eq!(tag.coverage, GrammarCoverage::FromTokenZero);
184 let tag = StructuralTag::new(tag.format).to_json_string().unwrap();
185
186 assert!(tag.contains("<tool_calls:opensource>"));
187 + assert!(tag.contains("</think:opensource>"));
188 assert!(tag.contains("<tool_call:opensource>get_weather"));
189 assert!(tag.contains("<arg_key:opensource>"));
190 assert!(tag.contains("</tool_call:opensource>"));