[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>"));