[Rust Frontend] Snapshot structural-tag grammars as readable outlines (#59393)

Signed-off-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Bugen Zhao committed Oct 1, 2026 at 13:09 UTC ac9126e58aa7bbab1856ba6593ba4d5003fea516
18 files changed +1291 -34
rust/src/chat/Cargo.toml
+1
@@ -52,6 +52,7 @@ tempfile.workspace = true
52 tokio.workspace = true
53 uuid.workspace = true
54 vllm-engine-core-client = { workspace = true, features = ["test-util"] }
55 +vllm-parser = { workspace = true, features = ["test-util"] }
56 vllm-tokenizer = { workspace = true, features = ["test-utils"] }
57 zeromq.workspace = true
58
rust/src/chat/tests/grammar_replay/DeepSeek-V3.2-Exp.txt new
+26
@@ -0,0 +1,26 @@
1 +# auto (from_token_zero)
2 +sequence
3 + tag `` text `</think>`
4 + triggered_tags [`<|DSML|function_calls>`] excluding [`<think>`, `</think>`]
5 + tag `<|DSML|function_calls>\n` .. `</|DSML|function_calls>`
6 + tags_with_separator `` at_least_one
7 + tag `<|DSML|invoke name="get_weather">\n` deepseek_xml({ location: string }) `</|DSML|invoke>\n`
8 + tag `<|DSML|invoke name="add">\n` deepseek_xml({ y: number, x: number, items: string[] }) `</|DSML|invoke>\n`
9 +
10 +# required (from_token_zero)
11 +sequence
12 + tag `` text `</think>`
13 + sequence
14 + `\n\n<|DSML|function_calls>\n`
15 + tags_with_separator `` at_least_one
16 + tag `<|DSML|invoke name="get_weather">\n` deepseek_xml({ location: string }) `</|DSML|invoke>\n`
17 + tag `<|DSML|invoke name="add">\n` deepseek_xml({ y: number, x: number, items: string[] }) `</|DSML|invoke>\n`
18 + `</|DSML|function_calls>`
19 +
20 +# named (from_token_zero)
21 +sequence
22 + tag `` text `</think>`
23 + sequence
24 + `\n\n<|DSML|function_calls>\n`
25 + tag `<|DSML|invoke name="get_weather">\n` deepseek_xml({ location: string }) `</|DSML|invoke>\n`
26 + `</|DSML|function_calls>`
rust/src/chat/tests/grammar_replay/DeepSeek-V4-Flash.txt new
+26
@@ -0,0 +1,26 @@
1 +# auto (from_token_zero)
2 +sequence
3 + tag `` text `</think>`
4 + triggered_tags [`<|DSML|tool_calls>`] excluding [`<think>`, `</think>`]
5 + tag `<|DSML|tool_calls>\n` .. `</|DSML|tool_calls>`
6 + tags_with_separator `` at_least_one
7 + tag `<|DSML|invoke name="get_weather">\n` deepseek_xml({ location: string }) `</|DSML|invoke>\n`
8 + tag `<|DSML|invoke name="add">\n` deepseek_xml({ y: number, x: number, items: string[] }) `</|DSML|invoke>\n`
9 +
10 +# required (from_token_zero)
11 +sequence
12 + tag `` text `</think>`
13 + sequence
14 + `\n\n<|DSML|tool_calls>\n`
15 + tags_with_separator `` at_least_one
16 + tag `<|DSML|invoke name="get_weather">\n` deepseek_xml({ location: string }) `</|DSML|invoke>\n`
17 + tag `<|DSML|invoke name="add">\n` deepseek_xml({ y: number, x: number, items: string[] }) `</|DSML|invoke>\n`
18 + `</|DSML|tool_calls>`
19 +
20 +# named (from_token_zero)
21 +sequence
22 + tag `` text `</think>`
23 + sequence
24 + `\n\n<|DSML|tool_calls>\n`
25 + tag `<|DSML|invoke name="get_weather">\n` deepseek_xml({ location: string }) `</|DSML|invoke>\n`
26 + `</|DSML|tool_calls>`
rust/src/chat/tests/grammar_replay/DeepSeek-V4.1-Flash.txt new
+26
@@ -0,0 +1,26 @@
1 +# auto (from_token_zero)
2 +sequence
3 + tag `` text `</think>`
4 + triggered_tags [`<|DSML| calls>`] excluding [`<think>`, `</think>`]
5 + tag `<|DSML| calls>\n` .. `</|DSML| calls>`
6 + tags_with_separator `` at_least_one
7 + tag `<|DSML| invoke name="get_weather">\n` deepseek_v4_1_xml({ location: string }) `</|DSML| invoke>\n`
8 + tag `<|DSML| invoke name="add">\n` deepseek_v4_1_xml({ y: number, x: number, items: string[] }) `</|DSML| invoke>\n`
9 +
10 +# required (from_token_zero)
11 +sequence
12 + tag `` text `</think>`
13 + sequence
14 + `\n\n<|DSML| calls>\n`
15 + tags_with_separator `` at_least_one
16 + tag `<|DSML| invoke name="get_weather">\n` deepseek_v4_1_xml({ location: string }) `</|DSML| invoke>\n`
17 + tag `<|DSML| invoke name="add">\n` deepseek_v4_1_xml({ y: number, x: number, items: string[] }) `</|DSML| invoke>\n`
18 + `</|DSML| calls>`
19 +
20 +# named (from_token_zero)
21 +sequence
22 + tag `` text `</think>`
23 + sequence
24 + `\n\n<|DSML| calls>\n`
25 + tag `<|DSML| invoke name="get_weather">\n` deepseek_v4_1_xml({ location: string }) `</|DSML| invoke>\n`
26 + `</|DSML| calls>`
rust/src/chat/tests/grammar_replay/GLM-4.7-Flash.txt new
+18
@@ -0,0 +1,18 @@
1 +# auto (from_token_zero)
2 +sequence
3 + tag `` text `</think>`
4 + triggered_tags [`<tool_call>`] excluding [`<think>`, `</think>`, `</tool_call>`, `<arg_key>`, `</arg_key>`, `<arg_value>`, `</arg_value>`]
5 + tag `<tool_call>get_weather` glm_xml({ location: string }) `</tool_call>`
6 + tag `<tool_call>add` glm_xml({ y: number, x: number, items: string[] }) `</tool_call>`
7 +
8 +# required (from_token_zero)
9 +sequence
10 + tag `` text `</think>`
11 + triggered_tags [`<tool_call>`] excluding [`<think>`, `</think>`, `</tool_call>`, `<arg_key>`, `</arg_key>`, `<arg_value>`, `</arg_value>`] at_least_one
12 + tag `<tool_call>get_weather` glm_xml({ location: string }) `</tool_call>`
13 + tag `<tool_call>add` glm_xml({ y: number, x: number, items: string[] }) `</tool_call>`
14 +
15 +# named (from_token_zero)
16 +sequence
17 + tag `` text `</think>`
18 + tag `<tool_call>get_weather` glm_xml({ location: string }) `</tool_call>`
rust/src/chat/tests/grammar_replay/GLM-5.2-FP8.txt new
+18
@@ -0,0 +1,18 @@
1 +# auto (from_token_zero)
2 +sequence
3 + tag `` text `</think>`
4 + triggered_tags [`<tool_call>`] excluding [`<think>`, `</think>`, `</tool_call>`, `<arg_key>`, `</arg_key>`, `<arg_value>`, `</arg_value>`]
5 + tag `<tool_call>get_weather` glm_xml({ location: string }) `</tool_call>`
6 + tag `<tool_call>add` glm_xml({ y: number, x: number, items: string[] }) `</tool_call>`
7 +
8 +# required (from_token_zero)
9 +sequence
10 + tag `` text `</think>`
11 + triggered_tags [`<tool_call>`] excluding [`<think>`, `</think>`, `</tool_call>`, `<arg_key>`, `</arg_key>`, `<arg_value>`, `</arg_value>`] at_least_one
12 + tag `<tool_call>get_weather` glm_xml({ location: string }) `</tool_call>`
13 + tag `<tool_call>add` glm_xml({ y: number, x: number, items: string[] }) `</tool_call>`
14 +
15 +# named (from_token_zero)
16 +sequence
17 + tag `` text `</think>`
18 + tag `<tool_call>get_weather` glm_xml({ location: string }) `</tool_call>`
rust/src/chat/tests/grammar_replay/Hy3.txt new
+112
@@ -0,0 +1,112 @@
1 +# auto (from_token_zero)
2 +sequence
3 + tag `` text `</think:opensource>`
4 + triggered_tags [`<tool_calls:opensource>`]
5 + tag `<tool_calls:opensource>\n` .. `\n</tool_calls:opensource>`
6 + tags_with_separator `\n` at_least_one
7 + tag `<tool_call:opensource>get_weather<tool_sep:opensource>\n` .. `</tool_call:opensource>`
8 + sequence
9 + sequence
10 + `<arg_key:opensource>`
11 + `location`
12 + `</arg_key:opensource>`
13 + `\n`
14 + `<arg_value:opensource>`
15 + text excluding [`<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>`]
16 + `</arg_value:opensource>`
17 + `\n`
18 + tag `<tool_call:opensource>add<tool_sep:opensource>\n` .. `</tool_call:opensource>`
19 + sequence
20 + sequence
21 + `<arg_key:opensource>`
22 + `y`
23 + `</arg_key:opensource>`
24 + `\n`
25 + `<arg_value:opensource>`
26 + text excluding [`<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>`]
27 + `</arg_value:opensource>`
28 + `\n`
29 + sequence
30 + `<arg_key:opensource>`
31 + `x`
32 + `</arg_key:opensource>`
33 + `\n`
34 + `<arg_value:opensource>`
35 + text excluding [`<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>`]
36 + `</arg_value:opensource>`
37 + `\n`
38 + sequence
39 + `<arg_key:opensource>`
40 + `items`
41 + `</arg_key:opensource>`
42 + `\n`
43 + `<arg_value:opensource>`
44 + text excluding [`<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>`]
45 + `</arg_value:opensource>`
46 + `\n`
47 +
48 +# required (from_token_zero)
49 +sequence
50 + tag `` text `</think:opensource>`
51 + sequence
52 + `<tool_calls:opensource>\n`
53 + tags_with_separator `\n` at_least_one
54 + tag `<tool_call:opensource>get_weather<tool_sep:opensource>\n` .. `</tool_call:opensource>`
55 + sequence
56 + sequence
57 + `<arg_key:opensource>`
58 + `location`
59 + `</arg_key:opensource>`
60 + `\n`
61 + `<arg_value:opensource>`
62 + text excluding [`<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>`]
63 + `</arg_value:opensource>`
64 + `\n`
65 + tag `<tool_call:opensource>add<tool_sep:opensource>\n` .. `</tool_call:opensource>`
66 + sequence
67 + sequence
68 + `<arg_key:opensource>`
69 + `y`
70 + `</arg_key:opensource>`
71 + `\n`
72 + `<arg_value:opensource>`
73 + text excluding [`<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>`]
74 + `</arg_value:opensource>`
75 + `\n`
76 + sequence
77 + `<arg_key:opensource>`
78 + `x`
79 + `</arg_key:opensource>`
80 + `\n`
81 + `<arg_value:opensource>`
82 + text excluding [`<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>`]
83 + `</arg_value:opensource>`
84 + `\n`
85 + sequence
86 + `<arg_key:opensource>`
87 + `items`
88 + `</arg_key:opensource>`
89 + `\n`
90 + `<arg_value:opensource>`
91 + text excluding [`<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>`]
92 + `</arg_value:opensource>`
93 + `\n`
94 + `\n</tool_calls:opensource>`
95 +
96 +# named (from_token_zero)
97 +sequence
98 + tag `` text `</think:opensource>`
99 + sequence
100 + `<tool_calls:opensource>\n`
101 + tag `<tool_call:opensource>get_weather<tool_sep:opensource>\n` .. `</tool_call:opensource>`
102 + sequence
103 + sequence
104 + `<arg_key:opensource>`
105 + `location`
106 + `</arg_key:opensource>`
107 + `\n`
108 + `<arg_value:opensource>`
109 + text excluding [`<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>`]
110 + `</arg_value:opensource>`
111 + `\n`
112 + `\n</tool_calls:opensource>`
rust/src/chat/tests/grammar_replay/Kimi-K2.5.txt new
+46
@@ -0,0 +1,46 @@
1 +# auto (from_token_zero)
2 +sequence
3 + tag `` text `</think>`
4 + triggered_tags [`<|tool_calls_section_begin|>`] excluding [`<think>`, `</think>`, `<|tool_call_begin|>`]
5 + tag `<|tool_calls_section_begin|>` .. `<|tool_calls_section_end|>`
6 + tags_with_separator `` at_least_one
7 + tag `<|tool_call_begin|>functions.get_weather:` .. `<|tool_call_end|>`
8 + sequence
9 + /\d+/
10 + `<|tool_call_argument_begin|>`
11 + json({ location: string })
12 + tag `<|tool_call_begin|>functions.add:` .. `<|tool_call_end|>`
13 + sequence
14 + /\d+/
15 + `<|tool_call_argument_begin|>`
16 + json({ y: number, x: number, items: string[] })
17 +
18 +# required (from_token_zero)
19 +sequence
20 + tag `` text `</think>`
21 + sequence
22 + `<|tool_calls_section_begin|>`
23 + tags_with_separator `` at_least_one
24 + tag `<|tool_call_begin|>functions.get_weather:` .. `<|tool_call_end|>`
25 + sequence
26 + /\d+/
27 + `<|tool_call_argument_begin|>`
28 + json({ location: string })
29 + tag `<|tool_call_begin|>functions.add:` .. `<|tool_call_end|>`
30 + sequence
31 + /\d+/
32 + `<|tool_call_argument_begin|>`
33 + json({ y: number, x: number, items: string[] })
34 + `<|tool_calls_section_end|>`
35 +
36 +# named (from_token_zero)
37 +sequence
38 + tag `` text `</think>`
39 + sequence
40 + `<|tool_calls_section_begin|>`
41 + tag `<|tool_call_begin|>functions.get_weather:` .. `<|tool_call_end|>`
42 + sequence
43 + /\d+/
44 + `<|tool_call_argument_begin|>`
45 + json({ location: string })
46 + `<|tool_calls_section_end|>`
rust/src/chat/tests/grammar_replay/Kimi-K3.txt new
+67
@@ -0,0 +1,67 @@
1 +# auto (final_output_only)
2 +sequence
3 + optional `<|open|>response<|sep|>`
4 + tag `` text excluding [`<|open|>`, `<|close|>`, `<|end_of_msg|>`] `<|close|>response<|sep|>`
5 + optional
6 + tag `<|open|>tools<|sep|>` .. `<|close|>tools<|sep|>`
7 + tags_with_separator `` at_least_one
8 + tag `<|open|>call tool="get_weather" index="` .. `<|close|>call<|sep|>`
9 + sequence
10 + /[1-9][0-9]*/
11 + `"<|sep|>`
12 + or
13 + plus tag `<|open|>argument key="location" type="string"<|sep|>` text excluding [`<|close|>argument<|sep|>`, `<|close|>call<|sep|>`] `<|close|>argument<|sep|>`
14 + tag `<|open|>json type="object"<|sep|>` json({ location: string }) `<|close|>json<|sep|>`
15 + tag `<|open|>call tool="add" index="` .. `<|close|>call<|sep|>`
16 + sequence
17 + /[1-9][0-9]*/
18 + `"<|sep|>`
19 + or
20 + plus
21 + or
22 + tag `<|open|>argument key="y" type="number"<|sep|>` json(number) `<|close|>argument<|sep|>`
23 + tag `<|open|>argument key="x" type="number"<|sep|>` json(number) `<|close|>argument<|sep|>`
24 + tag `<|open|>argument key="items" type="array"<|sep|>` json(string[]) `<|close|>argument<|sep|>`
25 + tag `<|open|>json type="object"<|sep|>` json({ y: number, x: number, items: string[] }) `<|close|>json<|sep|>`
26 + optional `<|close|>message<|sep|>`
27 +
28 +# required (final_output_only)
29 +sequence
30 + optional `<|open|>response<|sep|>`
31 + tag `` text excluding [`<|open|>`, `<|close|>`, `<|end_of_msg|>`] `<|close|>response<|sep|>`
32 + tag `<|open|>tools<|sep|>` .. `<|close|>tools<|sep|>`
33 + tags_with_separator `` at_least_one
34 + tag `<|open|>call tool="get_weather" index="` .. `<|close|>call<|sep|>`
35 + sequence
36 + /[1-9][0-9]*/
37 + `"<|sep|>`
38 + or
39 + plus tag `<|open|>argument key="location" type="string"<|sep|>` text excluding [`<|close|>argument<|sep|>`, `<|close|>call<|sep|>`] `<|close|>argument<|sep|>`
40 + tag `<|open|>json type="object"<|sep|>` json({ location: string }) `<|close|>json<|sep|>`
41 + tag `<|open|>call tool="add" index="` .. `<|close|>call<|sep|>`
42 + sequence
43 + /[1-9][0-9]*/
44 + `"<|sep|>`
45 + or
46 + plus
47 + or
48 + tag `<|open|>argument key="y" type="number"<|sep|>` json(number) `<|close|>argument<|sep|>`
49 + tag `<|open|>argument key="x" type="number"<|sep|>` json(number) `<|close|>argument<|sep|>`
50 + tag `<|open|>argument key="items" type="array"<|sep|>` json(string[]) `<|close|>argument<|sep|>`
51 + tag `<|open|>json type="object"<|sep|>` json({ y: number, x: number, items: string[] }) `<|close|>json<|sep|>`
52 + optional `<|close|>message<|sep|>`
53 +
54 +# named (final_output_only)
55 +sequence
56 + optional `<|open|>response<|sep|>`
57 + tag `` text excluding [`<|open|>`, `<|close|>`, `<|end_of_msg|>`] `<|close|>response<|sep|>`
58 + tag `<|open|>tools<|sep|>` .. `<|close|>tools<|sep|>`
59 + tags_with_separator `` at_least_one
60 + tag `<|open|>call tool="get_weather" index="` .. `<|close|>call<|sep|>`
61 + sequence
62 + /[1-9][0-9]*/
63 + `"<|sep|>`
64 + or
65 + plus tag `<|open|>argument key="location" type="string"<|sep|>` text excluding [`<|close|>argument<|sep|>`, `<|close|>call<|sep|>`] `<|close|>argument<|sep|>`
66 + tag `<|open|>json type="object"<|sep|>` json({ location: string }) `<|close|>json<|sep|>`
67 + optional `<|close|>message<|sep|>`
rust/src/chat/tests/grammar_replay/MiMo-V2.6-Flash-RL.txt new
+18
@@ -0,0 +1,18 @@
1 +# auto (from_token_zero)
2 +sequence
3 + optional tag `<think>` text `</think>`
4 + triggered_tags [`<tool_call>`] excluding [`<think>`, `</think>`, `</tool_call>`, `<function=`]
5 + tag `<tool_call><function=get_weather>` qwen_xml({ location: string }) `</function></tool_call>`
6 + tag `<tool_call><function=add>` qwen_xml({ y: number, x: number, items: string[] }) `</function></tool_call>`
7 +
8 +# required (from_token_zero)
9 +sequence
10 + optional tag `<think>` text `</think>`
11 + triggered_tags [`<tool_call>`] excluding [`<think>`, `</think>`, `</tool_call>`, `<function=`] at_least_one
12 + tag `<tool_call><function=get_weather>` qwen_xml({ location: string }) `</function></tool_call>`
13 + tag `<tool_call><function=add>` qwen_xml({ y: number, x: number, items: string[] }) `</function></tool_call>`
14 +
15 +# named (from_token_zero)
16 +sequence
17 + optional tag `<think>` text `</think>`
18 + tag `<tool_call><function=get_weather>` qwen_xml({ location: string }) `</function></tool_call>`
rust/src/chat/tests/grammar_replay/MiniMax-M2.5.txt new
+32
@@ -0,0 +1,32 @@
1 +# auto (from_token_zero)
2 +sequence
3 + sequence
4 + tag `` text `</think>`
5 + `\n\n`
6 + triggered_tags [`<minimax:tool_call>`] excluding [`<think>`, `</think>`]
7 + tag `<minimax:tool_call>\n` .. `</minimax:tool_call>`
8 + tags_with_separator `` at_least_one
9 + tag `<invoke name="get_weather">\n` minimax_xml({ location: string }) `</invoke>\n`
10 + tag `<invoke name="add">\n` minimax_xml({ y: number, x: number, items: string[] }) `</invoke>\n`
11 +
12 +# required (from_token_zero)
13 +sequence
14 + sequence
15 + tag `` text `</think>`
16 + `\n\n`
17 + sequence
18 + `\n<minimax:tool_call>\n`
19 + tags_with_separator `` at_least_one
20 + tag `<invoke name="get_weather">\n` minimax_xml({ location: string }) `</invoke>\n`
21 + tag `<invoke name="add">\n` minimax_xml({ y: number, x: number, items: string[] }) `</invoke>\n`
22 + `</minimax:tool_call>`
23 +
24 +# named (from_token_zero)
25 +sequence
26 + sequence
27 + tag `` text `</think>`
28 + `\n\n`
29 + sequence
30 + `\n<minimax:tool_call>\n`
31 + tag `<invoke name="get_weather">\n` minimax_xml({ location: string }) `</invoke>\n`
32 + `</minimax:tool_call>`
rust/src/chat/tests/grammar_replay/Qwen3-0.6B.txt new
+27
@@ -0,0 +1,27 @@
1 +# auto (from_token_zero)
2 +sequence
3 + optional
4 + sequence
5 + tag `<think>` text `</think>`
6 + `\n\n`
7 + triggered_tags [`<tool_call>`] excluding [`<think>`, `</think>`]
8 + tag `<tool_call>\n{"name": "get_weather", "arguments": ` json({ location: string }) `}\n</tool_call>`
9 + tag `<tool_call>\n{"name": "add", "arguments": ` json({ y: number, x: number, items: string[] }) `}\n</tool_call>`
10 +
11 +# required (from_token_zero)
12 +sequence
13 + optional
14 + sequence
15 + tag `<think>` text `</think>`
16 + `\n\n`
17 + triggered_tags [`<tool_call>`] excluding [`<think>`, `</think>`] at_least_one
18 + tag `<tool_call>\n{"name": "get_weather", "arguments": ` json({ location: string }) `}\n</tool_call>`
19 + tag `<tool_call>\n{"name": "add", "arguments": ` json({ y: number, x: number, items: string[] }) `}\n</tool_call>`
20 +
21 +# named (from_token_zero)
22 +sequence
23 + optional
24 + sequence
25 + tag `<think>` text `</think>`
26 + `\n\n`
27 + tag `<tool_call>\n{"name": "get_weather", "arguments": ` json({ location: string }) `}\n</tool_call>`
rust/src/chat/tests/grammar_replay/Qwen3.5-4B.txt new
+24
@@ -0,0 +1,24 @@
1 +# auto (from_token_zero)
2 +sequence
3 + sequence
4 + tag `` text `</think>`
5 + `\n\n`
6 + triggered_tags [`<tool_call>\n<function=`] excluding [`<think>`, `</think>`]
7 + tag `<tool_call>\n<function=get_weather>\n` qwen_xml({ location: string }) `\n</function>\n</tool_call>`
8 + tag `<tool_call>\n<function=add>\n` qwen_xml({ y: number, x: number, items: string[] }) `\n</function>\n</tool_call>`
9 +
10 +# required (from_token_zero)
11 +sequence
12 + sequence
13 + tag `` text `</think>`
14 + `\n\n`
15 + triggered_tags [`<tool_call>\n<function=`] excluding [`<think>`, `</think>`] at_least_one
16 + tag `<tool_call>\n<function=get_weather>\n` qwen_xml({ location: string }) `\n</function>\n</tool_call>`
17 + tag `<tool_call>\n<function=add>\n` qwen_xml({ y: number, x: number, items: string[] }) `\n</function>\n</tool_call>`
18 +
19 +# named (from_token_zero)
20 +sequence
21 + sequence
22 + tag `` text `</think>`
23 + `\n\n`
24 + tag `<tool_call>\n<function=get_weather>\n` qwen_xml({ location: string }) `\n</function>\n</tool_call>`
rust/src/chat/tests/roundtrip.rs
+27 -5
@@ -1150,6 +1150,7 @@ fn check_grammar_replay_file(
1150 ) -> Result<()> {
1151 let model_name = case.model_id.rsplit_once('/').map_or(case.model_id, |(_, name)| name);
1152 let path = grammar_replay_dir().join(format!("{model_name}.json"));
1153 + let outline_path = path.with_extension("txt");
1154 let results = results
1155 .into_iter()
1156 .filter_map(|(variant, result)| {
@@ -1157,11 +1158,13 @@ fn check_grammar_replay_file(
1158 })
1159 .collect::<Vec<_>>();
1160 if results.is_empty() {
1160 - ensure!(
1161 - !path.exists(),
1162 - "{} builds no output grammar, but grammar replay file {path:?} exists",
1163 - case.model_id
1164 - );
1161 + for path in [&path, &outline_path] {
1162 + ensure!(
1163 + !path.exists(),
1164 + "{} builds no output grammar, but grammar replay file {path:?} exists",
1165 + case.model_id
1166 + );
1167 + }
1168 return Ok(());
1169 }
1170
@@ -1205,6 +1208,25 @@ fn check_grammar_replay_file(
1208 );
1209 }
1210
1211 + // A readable outline of the same grammars, for review only.
1212 + let mut outline = String::new();
1213 + for (variant, case) in &cases {
1214 + if !outline.is_empty() {
1215 + outline.push('\n');
1216 + }
1217 + let variant = serde_json::to_value(variant)?;
1218 + let coverage = serde_json::to_value(case.grammar.coverage)?;
1219 + outline.push_str(&format!(
1220 + "# {} ({})\n",
1221 + variant.as_str().context("variant name")?,
1222 + coverage.as_str().context("coverage name")?,
1223 + ));
1224 + outline.push_str(&vllm_parser::output_grammar::test_utils::outline(
1225 + &case.grammar.format,
1226 + ));
1227 + }
1228 + expect_test::expect_file![outline_path].assert_eq(&outline);
1229 +
1230 let file = GrammarReplayFile {
1231 model_id: case.model_id,
1232 vocab_size: backends.text_backend.model_vocab_size(),
rust/src/parser/src/output_grammar.rs
+3
@@ -17,6 +17,9 @@ use xgrammar_structural_tag::{
17
18 use crate::tool::Tool;
19
20 +#[cfg(any(test, feature = "test-util"))]
21 +pub mod test_utils;
22 +
23 /// Result alias for output grammar construction.
24 pub type Result<T> = std::result::Result<T, OutputGrammarError>;
25
rust/src/parser/src/output_grammar/test_utils.rs new
+676
@@ -0,0 +1,676 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +//! Readable renderings of structural-tag formats for test snapshots.
5 +
6 +use std::fmt::Write;
7 +
8 +use serde_json::{Map, Value};
9 +use xgrammar_structural_tag::format::{EndBoundary, Format, TagBoundary, TagFormat, TokenValue};
10 +
11 +/// Render `format` as an indented outline, one node per line.
12 +///
13 +/// Each node starts with its format type, and composite nodes list their
14 +/// children one level deeper. Literal text is quoted in backticks with `\`,
15 +/// `` ` ``, and control characters escaped, so markers read as written. Other
16 +/// leaves are compact: `/regex/`, `text`, `json(SCHEMA)` or `STYLE(SCHEMA)`
17 +/// for styled schemas, and `token ID` or ``token `TEXT` ``. Lists such as
18 +/// exclusions and triggers are bracketed, and schemas read as types, see
19 +/// [`schema`]. A single leaf child of `optional`, `star`, `plus`, `repeat`,
20 +/// and dispatch rules stays on its parent's line, as does a tag's leaf
21 +/// content between its boundaries. Options that keep their defaults are
22 +/// omitted.
23 +pub fn outline(format: &Format) -> String {
24 + let mut out = String::new();
25 + write(&mut out, 0, format_node(format));
26 + out
27 +}
28 +
29 +/// A child line of an outline node.
30 +enum Child<'a> {
31 + Format(&'a Format),
32 + Tag(&'a TagFormat),
33 + Rule(String, &'a Format),
34 + Text(&'a str),
35 +}
36 +
37 +/// An outline node: its header line and children.
38 +struct Node<'a> {
39 + header: String,
40 + children: Vec<Child<'a>>,
41 + /// Whether a single leaf child joins the header line.
42 + inline_leaf: bool,
43 +}
44 +
45 +impl<'a> Node<'a> {
46 + fn leaf(header: String) -> Self {
47 + Self {
48 + header,
49 + children: vec![],
50 + inline_leaf: false,
51 + }
52 + }
53 +
54 + fn branch(header: impl Into<String>, children: Vec<Child<'a>>) -> Self {
55 + Self {
56 + header: header.into(),
57 + children,
58 + inline_leaf: false,
59 + }
60 + }
61 +
62 + fn wrapper(header: impl Into<String>, child: Child<'a>) -> Self {
63 + Self {
64 + header: header.into(),
65 + children: vec![child],
66 + inline_leaf: true,
67 + }
68 + }
69 +}
70 +
71 +fn write(out: &mut String, depth: usize, node: Node<'_>) {
72 + let Node {
73 + mut header,
74 + children,
75 + inline_leaf,
76 + } = node;
77 + let mut children = children.into_iter().map(self::node).collect::<Vec<_>>();
78 + if inline_leaf
79 + && let [child] = &children[..]
80 + && child.children.is_empty()
81 + {
82 + let _ = write!(header, " {}", child.header);
83 + children.clear();
84 + }
85 + let _ = writeln!(out, "{}{header}", " ".repeat(depth));
86 + for child in children {
87 + write(out, depth + 1, child);
88 + }
89 +}
90 +
91 +fn node(child: Child<'_>) -> Node<'_> {
92 + match child {
93 + Child::Format(format) => format_node(format),
94 + Child::Tag(tag) => tag_node(tag),
95 + Child::Rule(pattern, format) => {
96 + Node::wrapper(format!("on {pattern}"), Child::Format(format))
97 + }
98 + Child::Text(text) => Node::leaf(text.to_string()),
99 + }
100 +}
101 +
102 +fn format_node(format: &Format) -> Node<'_> {
103 + match format {
104 + Format::ConstString(format) => Node::leaf(quote(&format.value)),
105 + Format::Regex(format) => Node::leaf(format!("/{}/", format.pattern)),
106 + Format::AnyText(format) => {
107 + let mut header = "text".to_string();
108 + push_excluding(&mut header, format.excludes.iter().map(|text| quote(text)));
109 + push_limit(&mut header, "max_tokens", format.max_tokens);
110 + push_limit(&mut header, "max_chars", format.max_chars);
111 + Node::leaf(header)
112 + }
113 + Format::JsonSchema(format) => {
114 + let style = serde_json::to_value(format.style)
115 + .ok()
116 + .and_then(|style| style.as_str().map(str::to_string))
117 + .unwrap_or_else(|| format!("{:?}", format.style));
118 + let mut header = format!("{style}({})", schema(&format.json_schema));
119 + if format.any_order {
120 + header.push_str(" any_order");
121 + }
122 + if let Some(max) = format.max_whitespace_cnt {
123 + let _ = write!(header, " max_whitespace={max}");
124 + }
125 + push_excluding(&mut header, format.excludes.iter().map(|text| quote(text)));
126 + Node::leaf(header)
127 + }
128 + Format::Grammar(format) => {
129 + Node::branch("grammar", format.grammar.lines().map(Child::Text).collect())
130 + }
131 + Format::Token(format) => Node::leaf(format!("token {}", token(&format.token))),
132 + Format::ExcludeToken(format) => {
133 + let mut header = "token".to_string();
134 + push_excluding(&mut header, format.exclude_tokens.iter().map(token));
135 + Node::leaf(header)
136 + }
137 + Format::AnyTokens(format) => {
138 + let mut header = "tokens".to_string();
139 + push_excluding(&mut header, format.exclude_tokens.iter().map(token));
140 + push_limit(&mut header, "max_tokens", format.max_tokens);
141 + Node::leaf(header)
142 + }
143 + Format::Sequence(format) => Node::branch(
144 + "sequence",
145 + format.elements.iter().map(Child::Format).collect(),
146 + ),
147 + Format::Or(format) => {
148 + Node::branch("or", format.elements.iter().map(Child::Format).collect())
149 + }
150 + Format::Optional(format) => Node::wrapper("optional", Child::Format(&format.content)),
151 + Format::Plus(format) => Node::wrapper("plus", Child::Format(&format.content)),
152 + Format::Star(format) => Node::wrapper("star", Child::Format(&format.content)),
153 + Format::Repeat(format) => {
154 + let max = if format.max < 0 {
155 + String::new()
156 + } else {
157 + format.max.to_string()
158 + };
159 + Node::wrapper(
160 + format!("repeat {}..{max}", format.min),
161 + Child::Format(&format.content),
162 + )
163 + }
164 + Format::Tag(tag) => tag_node(tag),
165 + Format::TriggeredTags(format) => {
166 + let triggers = format.triggers.iter().map(|trigger| quote(trigger));
167 + let mut header = format!(
168 + "triggered_tags [{}]",
169 + triggers.collect::<Vec<_>>().join(", ")
170 + );
171 + push_excluding(&mut header, format.excludes.iter().map(|text| quote(text)));
172 + push_flags(&mut header, format.at_least_one, format.stop_after_first);
173 + Node::branch(header, format.tags.iter().map(Child::Tag).collect())
174 + }
175 + Format::TokenTriggeredTags(format) => {
176 + let triggers = format.trigger_tokens.iter().map(token);
177 + let mut header = format!(
178 + "token_triggered_tags [{}]",
179 + triggers.collect::<Vec<_>>().join(", ")
180 + );
181 + push_excluding(&mut header, format.exclude_tokens.iter().map(token));
182 + push_flags(&mut header, format.at_least_one, format.stop_after_first);
183 + Node::branch(header, format.tags.iter().map(Child::Tag).collect())
184 + }
185 + Format::TagsWithSeparator(format) => {
186 + let mut header = format!("tags_with_separator {}", quote(&format.separator));
187 + push_flags(&mut header, format.at_least_one, format.stop_after_first);
188 + Node::branch(header, format.tags.iter().map(Child::Tag).collect())
189 + }
190 + Format::Dispatch(format) => {
191 + let mut header = "dispatch".to_string();
192 + if !format.r#loop {
193 + header.push_str(" once");
194 + }
195 + push_excluding(&mut header, format.excludes.iter().map(|text| quote(text)));
196 + Node::branch(
197 + header,
198 + format
199 + .rules
200 + .iter()
201 + .map(|(pattern, format)| Child::Rule(quote(pattern), format))
202 + .collect(),
203 + )
204 + }
205 + Format::TokenDispatch(format) => {
206 + let mut header = "token_dispatch".to_string();
207 + if !format.r#loop {
208 + header.push_str(" once");
209 + }
210 + push_excluding(&mut header, format.exclude_tokens.iter().map(token));
211 + Node::branch(
212 + header,
213 + format
214 + .rules
215 + .iter()
216 + .map(|(trigger, format)| Child::Rule(token(trigger), format))
217 + .collect(),
218 + )
219 + }
220 + }
221 +}
222 +
223 +/// `tag BEGIN CONTENT END` for leaf content, or `tag BEGIN .. END` above the
224 +/// content's own node.
225 +fn tag_node(tag: &TagFormat) -> Node<'_> {
226 + let begin = match &tag.begin {
227 + TagBoundary::Text(text) => quote(text),
228 + TagBoundary::Token(boundary) => format!("token {}", token(&boundary.token)),
229 + };
230 + let end = match &tag.end {
231 + EndBoundary::Text(text) => quote(text),
232 + EndBoundary::Texts(texts) => {
233 + texts.iter().map(|text| quote(text)).collect::<Vec<_>>().join(" | ")
234 + }
235 + EndBoundary::Token(boundary) => format!("token {}", token(&boundary.token)),
236 + };
237 + let content = format_node(&tag.content);
238 + if content.children.is_empty() {
239 + Node::leaf(format!("tag {begin} {} {end}", content.header))
240 + } else {
241 + Node::branch(
242 + format!("tag {begin} .. {end}"),
243 + vec![Child::Format(&tag.content)],
244 + )
245 + }
246 +}
247 +
248 +/// Schema keywords that do not constrain the generated text.
249 +const ANNOTATIONS: &[&str] = &[
250 + "$comment",
251 + "$id",
252 + "$schema",
253 + "default",
254 + "deprecated",
255 + "description",
256 + "examples",
257 + "readOnly",
258 + "title",
259 + "writeOnly",
260 +];
261 +
262 +/// Render a JSON schema as a TypeScript-like type.
263 +///
264 +/// Objects are `{ key: T, optional?: T }`, with `...` for any additional
265 +/// property and `...: T` for additional properties under `T`. Arrays are
266 +/// `T[]`. Unions, type arrays, and enums join their options with `|`,
267 +/// `oneOf` and `allOf` keep their names, local references show the
268 +/// definition name, a `type` next to a reference intersects it as
269 +/// `NAME & T`, and `true` / `false` are `any` / `never`. Every other
270 +/// keyword follows the type as `(key=VALUE, ...)`, and definitions follow the
271 +/// root as `where NAME = T, ...`. Annotations such as `description` are
272 +/// dropped.
273 +pub fn schema(schema: &Value) -> String {
274 + typed(schema).text
275 +}
276 +
277 +/// A rendered type, and whether it is a top-level union or intersection that
278 +/// an operand position must parenthesize.
279 +struct Typed {
280 + text: String,
281 + compound: bool,
282 +}
283 +
284 +impl Typed {
285 + fn plain(text: impl Into<String>) -> Self {
286 + Self {
287 + text: text.into(),
288 + compound: false,
289 + }
290 + }
291 +
292 + fn compound(text: String) -> Self {
293 + Self {
294 + text,
295 + compound: true,
296 + }
297 + }
298 +
299 + fn union(options: Vec<String>) -> Self {
300 + Self {
301 + compound: options.len() > 1,
302 + text: options.join(" | "),
303 + }
304 + }
305 +
306 + /// The type as an operand of `&` or of a suffix such as `[]` or `(...)`.
307 + fn grouped(self) -> String {
308 + if self.compound {
309 + format!("({})", self.text)
310 + } else {
311 + self.text
312 + }
313 + }
314 +}
315 +
316 +fn typed(schema: &Value) -> Typed {
317 + let schema = match schema {
318 + Value::Bool(true) => return Typed::plain("any"),
319 + Value::Bool(false) => return Typed::plain("never"),
320 + Value::Object(schema) => schema,
321 + schema => return Typed::plain(schema.to_string()),
322 + };
323 + let mut rendered = SchemaType::new(schema).render();
324 + let definitions = ["$defs", "definitions"]
325 + .into_iter()
326 + .filter_map(|key| schema.get(key)?.as_object())
327 + .flatten()
328 + .map(|(name, definition)| format!("{name} = {}", self::schema(definition)))
329 + .collect::<Vec<_>>();
330 + if !definitions.is_empty() {
331 + rendered = Typed::plain(format!(
332 + "{} where {}",
333 + rendered.text,
334 + definitions.join(", ")
335 + ));
336 + }
337 + rendered
338 +}
339 +
340 +/// One schema object being rendered, with the keywords its type consumed.
341 +struct SchemaType<'a> {
342 + schema: &'a Map<String, Value>,
343 + consumed: Vec<&'static str>,
344 +}
345 +
346 +impl<'a> SchemaType<'a> {
347 + fn new(schema: &'a Map<String, Value>) -> Self {
348 + Self {
349 + schema,
350 + consumed: vec!["$defs", "definitions"],
351 + }
352 + }
353 +
354 + fn take(&mut self, key: &'static str) -> Option<&'a Value> {
355 + let value = self.schema.get(key)?;
356 + self.consumed.push(key);
357 + Some(value)
358 + }
359 +
360 + fn render(mut self) -> Typed {
361 + let mut rendered = self.body();
362 + let rest = self
363 + .schema
364 + .iter()
365 + .filter(|(key, _)| {
366 + !self.consumed.contains(&key.as_str()) && !ANNOTATIONS.contains(&key.as_str())
367 + })
368 + .map(|(key, value)| format!("{key}={value}"))
369 + .collect::<Vec<_>>();
370 + if !rest.is_empty() {
371 + rendered = Typed::plain(format!("{}({})", rendered.grouped(), rest.join(", ")));
372 + }
373 + rendered
374 + }
375 +
376 + fn body(&mut self) -> Typed {
377 + if let Some(Value::String(reference)) = self.take("$ref") {
378 + let name = ["#/$defs/", "#/definitions/"]
379 + .into_iter()
380 + .find_map(|prefix| reference.strip_prefix(prefix))
381 + .unwrap_or(reference);
382 + // Keywords next to `$ref` apply together with it.
383 + return match self.take("type") {
384 + Some(type_name) => {
385 + Typed::compound(format!("{name} & {}", type_names(type_name).grouped()))
386 + }
387 + None => Typed::plain(name),
388 + };
389 + }
390 + if let Some(value) = self.take("const") {
391 + self.take_redundant_type([value]);
392 + return Typed::plain(value.to_string());
393 + }
394 + if let Some(Value::Array(values)) = self.take("enum") {
395 + self.take_redundant_type(values);
396 + return Typed::union(values.iter().map(Value::to_string).collect());
397 + }
398 + if let Some(Value::Array(options)) = self.take("anyOf") {
399 + return Typed::union(options.iter().map(schema).collect());
400 + }
401 + for combinator in ["oneOf", "allOf"] {
402 + if let Some(Value::Array(options)) = self.take(combinator) {
403 + let options = options.iter().map(schema).collect::<Vec<_>>();
404 + return Typed::plain(format!("{combinator}({})", options.join(", ")));
405 + }
406 + }
407 + let is = |name: &str| self.schema.get("type").is_none_or(|type_name| type_name == name);
408 + if is("object")
409 + && (self.schema.contains_key("properties")
410 + || self.schema.contains_key("additionalProperties"))
411 + {
412 + self.take("type");
413 + return Typed::plain(self.object());
414 + }
415 + if is("array") && self.schema.contains_key("items") {
416 + self.take("type");
417 + let items = self.take("items").map_or_else(|| Typed::plain("any"), typed);
418 + return Typed::plain(format!("{}[]", items.grouped()));
419 + }
420 + match self.take("type") {
421 + Some(type_name) => type_names(type_name),
422 + None => Typed::plain("any"),
423 + }
424 + }
425 +
426 + /// Consume `type` when every literal already has that type.
427 + fn take_redundant_type<'v>(&mut self, values: impl IntoIterator<Item = &'v Value>) {
428 + let Some(Value::String(type_name)) = self.schema.get("type") else {
429 + return;
430 + };
431 + let matches = |value: &Value| match value {
432 + Value::String(_) => type_name == "string",
433 + Value::Number(number) => {
434 + type_name == "number" || (type_name == "integer" && !number.is_f64())
435 + }
436 + Value::Bool(_) => type_name == "boolean",
437 + Value::Null => type_name == "null",
438 + Value::Object(_) => type_name == "object",
439 + Value::Array(_) => type_name == "array",
440 + };
441 + if values.into_iter().all(matches) {
442 + self.take("type");
443 + }
444 + }
445 +
446 + fn object(&mut self) -> String {
447 + let required = self
448 + .take("required")
449 + .and_then(Value::as_array)
450 + .map(|required| required.iter().filter_map(Value::as_str).collect::<Vec<_>>())
451 + .unwrap_or_default();
452 + let properties = self.take("properties").and_then(Value::as_object);
453 + let mut fields = properties
454 + .into_iter()
455 + .flatten()
456 + .map(|(key, value)| {
457 + let optional = if required.contains(&key.as_str()) {
458 + ""
459 + } else {
460 + "?"
461 + };
462 + format!("{key}{optional}: {}", schema(value))
463 + })
464 + .collect::<Vec<_>>();
465 + // Required keys without a declared schema.
466 + fields.extend(
467 + required
468 + .iter()
469 + .filter(|key| properties.is_none_or(|properties| !properties.contains_key(**key)))
470 + .map(|key| format!("{key}: any")),
471 + );
472 + match self.take("additionalProperties") {
473 + Some(Value::Bool(false)) | None => {}
474 + Some(Value::Bool(true)) => fields.push("...".to_string()),
475 + Some(additional) => fields.push(format!("...: {}", schema(additional))),
476 + }
477 + if fields.is_empty() {
478 + "{}".to_string()
479 + } else {
480 + format!("{{ {} }}", fields.join(", "))
481 + }
482 + }
483 +}
484 +
485 +/// A `type` keyword's type names.
486 +fn type_names(type_name: &Value) -> Typed {
487 + match type_name {
488 + Value::String(name) if name == "array" => Typed::plain("any[]"),
489 + Value::String(name) => Typed::plain(name.clone()),
490 + Value::Array(names) => Typed::union(
491 + names
492 + .iter()
493 + .map(|name| name.as_str().map_or_else(|| name.to_string(), str::to_string))
494 + .collect(),
495 + ),
496 + type_name => Typed::plain(type_name.to_string()),
497 + }
498 +}
499 +
500 +fn quote(text: &str) -> String {
501 + let mut quoted = String::with_capacity(text.len() + 2);
502 + quoted.push('`');
503 + for char in text.chars() {
504 + match char {
505 + '\\' => quoted.push_str("\\\\"),
506 + '`' => quoted.push_str("\\`"),
507 + '\n' => quoted.push_str("\\n"),
508 + '\r' => quoted.push_str("\\r"),
509 + '\t' => quoted.push_str("\\t"),
510 + char if char.is_control() => {
511 + let _ = write!(quoted, "\\u{{{:x}}}", char as u32);
512 + }
513 + char => quoted.push(char),
514 + }
515 + }
516 + quoted.push('`');
517 + quoted
518 +}
519 +
520 +fn token(token: &TokenValue) -> String {
521 + match token {
522 + TokenValue::Id(id) => id.to_string(),
523 + TokenValue::Text(text) => quote(text),
524 + }
525 +}
526 +
527 +fn push_excluding(header: &mut String, excludes: impl Iterator<Item = String>) {
528 + let excludes = excludes.collect::<Vec<_>>();
529 + if !excludes.is_empty() {
530 + let _ = write!(header, " excluding [{}]", excludes.join(", "));
531 + }
532 +}
533 +
534 +fn push_limit(header: &mut String, name: &str, limit: Option<u32>) {
535 + if let Some(limit) = limit {
536 + let _ = write!(header, " {name}={limit}");
537 + }
538 +}
539 +
540 +fn push_flags(header: &mut String, at_least_one: bool, stop_after_first: bool) {
541 + if at_least_one {
542 + header.push_str(" at_least_one");
543 + }
544 + if stop_after_first {
545 + header.push_str(" stop_after_first");
546 + }
547 +}
548 +
549 +#[cfg(test)]
550 +mod tests {
551 + use expect_test::expect;
552 + use serde_json::json;
553 + use xgrammar_structural_tag::format::{
554 + AnyTokensFormat, DispatchFormat, ExcludeTokenFormat, GrammarFormat, JsonSchemaFormat,
555 + JsonSchemaStyle, TokenBoundary, TokenDispatchFormat, TokenFormat, TokenTriggeredTagsFormat,
556 + };
557 +
558 + use super::*;
559 +
560 + #[test]
561 + fn outline_renders_every_format_type() {
562 + let format = Format::sequence(vec![
563 + Format::Token(TokenFormat {
564 + token: TokenValue::Id(7),
565 + }),
566 + Format::ExcludeToken(ExcludeTokenFormat {
567 + exclude_tokens: vec![TokenValue::Id(1), TokenValue::Text("<eos>".to_string())],
568 + }),
569 + Format::AnyTokens(AnyTokensFormat {
570 + exclude_tokens: vec![TokenValue::Id(2)],
571 + max_tokens: Some(16),
572 + }),
573 + Format::Grammar(GrammarFormat {
574 + grammar: "root ::= a\na ::= \"x\"".to_string(),
575 + }),
576 + Format::repeat(Format::regex("[0-9]"), 1, -1),
577 + Format::JsonSchema(
578 + JsonSchemaFormat::new(json!({ "type": "string" }))
579 + .with_style(JsonSchemaStyle::KimiK3Xml)
580 + .with_any_order(true)
581 + .with_max_whitespace_cnt(Some(2))
582 + .with_excludes(&["</arg>"]),
583 + ),
584 + Format::TokenTriggeredTags(TokenTriggeredTagsFormat {
585 + trigger_tokens: vec![TokenValue::Id(9)],
586 + tags: vec![TagFormat::new(
587 + TokenBoundary::new(9),
588 + Format::optional(Format::const_string("`\t")),
589 + vec!["</a>", "</b>"],
590 + )],
591 + exclude_tokens: vec![],
592 + at_least_one: false,
593 + stop_after_first: true,
594 + }),
595 + Format::Dispatch(DispatchFormat {
596 + rules: vec![(
597 + "<call>".to_string(),
598 + Format::sequence(vec![Format::any_text(), Format::const_string("</call>")]),
599 + )],
600 + r#loop: false,
601 + excludes: vec!["<stop>".to_string()],
602 + }),
603 + Format::TokenDispatch(TokenDispatchFormat {
604 + rules: vec![(
605 + TokenValue::Id(3),
606 + Format::JsonSchema(JsonSchemaFormat::new(json!(true))),
607 + )],
608 + r#loop: true,
609 + exclude_tokens: vec![],
610 + }),
611 + ]);
612 +
613 + expect![[r#"
614 + sequence
615 + token 7
616 + token excluding [1, `<eos>`]
617 + tokens excluding [2] max_tokens=16
618 + grammar
619 + root ::= a
620 + a ::= "x"
621 + repeat 1.. /[0-9]/
622 + kimi_k3_xml(string) any_order max_whitespace=2 excluding [`</arg>`]
623 + token_triggered_tags [9] stop_after_first
624 + tag token 9 .. `</a>` | `</b>`
625 + optional `\`\t`
626 + dispatch once excluding [`<stop>`]
627 + on `<call>`
628 + sequence
629 + text
630 + `</call>`
631 + token_dispatch
632 + on 3 json(any)
633 + "#]]
634 + .assert_eq(&outline(&format));
635 + }
636 +
637 + #[test]
638 + fn schema_renders_common_shapes_as_types() {
639 + let rendered = schema(&json!({
640 + "$defs": { "place": { "type": "object", "properties": { "city": { "type": "string" } } } },
641 + "type": "object",
642 + "description": "ignored",
643 + "properties": {
644 + "location": { "type": "string" },
645 + "id": { "type": ["integer", "null"] },
646 + "unit": { "type": "string", "enum": ["celsius", "fahrenheit"] },
647 + "tags": { "type": "array", "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } },
648 + "place": { "$ref": "#/$defs/place" },
649 + "days": { "type": "integer", "minimum": 1 },
650 + "free": { "type": "object", "additionalProperties": true }
651 + },
652 + "required": ["location", "id"],
653 + "additionalProperties": false
654 + }));
655 +
656 + expect![[r#"{ location: string, id: integer | null, unit?: "celsius" | "fahrenheit", tags?: (string | number)[], place?: place, days?: integer(minimum=1), free?: { ... } } where place = { city?: string }"#]].assert_eq(&rendered);
657 + }
658 +
659 + #[test]
660 + fn schema_keeps_other_keywords_after_the_type() {
661 + let rendered = schema(&json!({
662 + "type": "object",
663 + "properties": {
664 + "code": { "type": "string", "pattern": "[A-Z]{3}" },
665 + "ids": { "type": "array", "items": { "type": ["integer", "null"] }, "maxItems": 3 },
666 + "place": { "$ref": "#/$defs/place", "type": "object" },
667 + "either": { "not": { "type": "null" } }
668 + },
669 + "required": ["code", "tenant"],
670 + "additionalProperties": { "type": "boolean" },
671 + "minProperties": 1
672 + }));
673 +
674 + expect![[r#"{ code: string(pattern="[A-Z]{3}"), ids?: (integer | null)[](maxItems=3), place?: place & object, either?: any(not={"type":"null"}), tenant: any, ...: boolean }(minProperties=1)"#]].assert_eq(&rendered);
675 + }
676 +}
rust/src/parser/src/tool/hy/structural_tag.rs
+122 -28
@@ -170,6 +170,7 @@ mod tests {
170 };
171
172 use super::HyStructuralTagBuilder;
173 + use crate::output_grammar::test_utils::outline;
174 use crate::tool::{HyDialect, HyToolMarkers};
175
176 fn tool(name: &str, parameters: Value) -> ToolParam {
@@ -219,7 +220,33 @@ mod tests {
220 ToolChoice::required(),
221 );
222
222 - expect![[r#"{"type":"structural_tag","format":{"type":"sequence","elements":[{"type":"const_string","value":"<tool_calls:opensource>\n"},{"type":"tags_with_separator","tags":[{"type":"tag","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>"],"max_tokens":null,"max_chars":null},{"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>"],"max_tokens":null,"max_chars":null},{"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());
223 + expect![[r#"
224 + sequence
225 + `<tool_calls:opensource>\n`
226 + tags_with_separator `\n` at_least_one
227 + tag `<tool_call:opensource>get_weather<tool_sep:opensource>\n` .. `</tool_call:opensource>`
228 + sequence
229 + sequence
230 + `<arg_key:opensource>`
231 + `city`
232 + `</arg_key:opensource>`
233 + `\n`
234 + `<arg_value:opensource>`
235 + text excluding [`<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>`]
236 + `</arg_value:opensource>`
237 + `\n`
238 + star
239 + sequence
240 + `<arg_key:opensource>`
241 + `days`
242 + `</arg_key:opensource>`
243 + `\n`
244 + `<arg_value:opensource>`
245 + text excluding [`<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>`]
246 + `</arg_value:opensource>`
247 + `\n`
248 + `\n</tool_calls:opensource>`
249 + "#]].assert_eq(&outline(&tag.format));
250 }
251
252 #[test]
@@ -238,7 +265,21 @@ mod tests {
265 ToolChoice::required(),
266 );
267
241 - expect![[r#"{"type":"structural_tag","format":{"type":"sequence","elements":[{"type":"const_string","value":"<tool_calls:opensource>"},{"type":"tags_with_separator","tags":[{"type":"tag","begin":"<tool_call:opensource>get_weather","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":"<arg_value:opensource>"},{"type":"any_text","excludes":["<tool_calls:opensource>","</tool_calls:opensource>","<tool_call:opensource>","</tool_call:opensource>","<arg_key:opensource>","</arg_key:opensource>","<arg_value:opensource>","</arg_value:opensource>"],"max_tokens":null,"max_chars":null},{"type":"const_string","value":"</arg_value:opensource>"}]}]},"end":"</tool_call:opensource>"}],"separator":"","at_least_one":true,"stop_after_first":false},{"type":"const_string","value":"</tool_calls:opensource>"}]}}"#]].assert_eq(&tag.to_json_string().unwrap());
268 + expect![[r#"
269 + sequence
270 + `<tool_calls:opensource>`
271 + tags_with_separator `` at_least_one
272 + tag `<tool_call:opensource>get_weather` .. `</tool_call:opensource>`
273 + sequence
274 + sequence
275 + `<arg_key:opensource>`
276 + `city`
277 + `</arg_key:opensource>`
278 + `<arg_value:opensource>`
279 + text excluding [`<tool_calls:opensource>`, `</tool_calls:opensource>`, `<tool_call:opensource>`, `</tool_call:opensource>`, `<arg_key:opensource>`, `</arg_key:opensource>`, `<arg_value:opensource>`, `</arg_value:opensource>`]
280 + `</arg_value:opensource>`
281 + `</tool_calls:opensource>`
282 + "#]].assert_eq(&outline(&tag.format));
283 }
284
285 #[test]
@@ -257,14 +298,34 @@ mod tests {
298 )],
299 ToolChoice::required(),
300 );
260 - let value = serde_json::to_value(tag).unwrap();
261 -
262 - 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>"],"max_tokens":null,"max_chars":null},{"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>"],"max_tokens":null,"max_chars":null},{"type":"const_string","value":"</arg_value>"},{"type":"const_string","value":"\n"}]}]}}]}"#]].assert_eq(
263 - &serde_json::to_string(
264 - &value["format"]["elements"][1]["tags"][0]["content"],
265 - )
266 - .unwrap(),
267 - );
301 + expect![[r#"
302 + sequence
303 + `<tool_calls>\n`
304 + tags_with_separator `\n` at_least_one
305 + tag `<tool_call>lookup<tool_sep>\n` .. `</tool_call>`
306 + sequence
307 + star
308 + or
309 + sequence
310 + `<arg_key>`
311 + `query`
312 + `</arg_key>`
313 + `\n`
314 + `<arg_value>`
315 + text excluding [`<tool_calls>`, `</tool_calls>`, `<tool_call>`, `</tool_call>`, `<tool_sep>`, `<arg_key>`, `</arg_key>`, `<arg_value>`, `</arg_value>`]
316 + `</arg_value>`
317 + `\n`
318 + sequence
319 + `<arg_key>`
320 + `limit`
321 + `</arg_key>`
322 + `\n`
323 + `<arg_value>`
324 + text excluding [`<tool_calls>`, `</tool_calls>`, `<tool_call>`, `</tool_call>`, `<tool_sep>`, `<arg_key>`, `</arg_key>`, `<arg_value>`, `</arg_value>`]
325 + `</arg_value>`
326 + `\n`
327 + `\n</tool_calls>`
328 + "#]].assert_eq(&outline(&tag.format));
329 }
330
331 #[test]
@@ -273,11 +334,24 @@ mod tests {
334 tool("search", json!({ "type": "object" })),
335 tool("lookup", json!({ "type": "object" })),
336 ];
276 - let auto = build("", &tools, ToolChoice::auto()).to_json_string().unwrap();
277 - let forced = build("", &tools, ToolChoice::function("lookup")).to_json_string().unwrap();
278 -
279 - expect![[r#"{"type":"structural_tag","format":{"type":"triggered_tags","triggers":["<tool_calls>"],"tags":[{"type":"tag","begin":"<tool_calls>\n","content":{"type":"tags_with_separator","tags":[{"type":"tag","begin":"<tool_call>search<tool_sep>\n","content":{"type":"any_text","excludes":[],"max_tokens":null,"max_chars":null},"end":"</tool_call>"},{"type":"tag","begin":"<tool_call>lookup<tool_sep>\n","content":{"type":"any_text","excludes":[],"max_tokens":null,"max_chars":null},"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);
280 - 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":[],"max_tokens":null,"max_chars":null},"end":"</tool_call>"},{"type":"const_string","value":"\n</tool_calls>"}]}}"#]].assert_eq(&forced);
337 + let auto = build("", &tools, ToolChoice::auto());
338 + let forced = build("", &tools, ToolChoice::function("lookup"));
339 +
340 + expect![[r#"
341 + triggered_tags [`<tool_calls>`]
342 + tag `<tool_calls>\n` .. `\n</tool_calls>`
343 + tags_with_separator `\n` at_least_one
344 + tag `<tool_call>search<tool_sep>\n` text `</tool_call>`
345 + tag `<tool_call>lookup<tool_sep>\n` text `</tool_call>`
346 + "#]]
347 + .assert_eq(&outline(&auto.format));
348 + expect![[r#"
349 + sequence
350 + `<tool_calls>\n`
351 + tag `<tool_call>lookup<tool_sep>\n` text `</tool_call>`
352 + `\n</tool_calls>`
353 + "#]]
354 + .assert_eq(&outline(&forced.format));
355 }
356
357 #[test]
@@ -291,11 +365,13 @@ mod tests {
365 )],
366 ToolChoice::function("get_current_date"),
367 );
294 - let value = serde_json::to_value(tag).unwrap();
295 -
296 - expect![[r#"{"type":"tag","begin":"<tool_call>get_current_date","content":{"type":"const_string","value":""},"end":"</tool_call>"}"#]].assert_eq(
297 - &serde_json::to_string(&value["format"]["elements"][1]).unwrap(),
298 - );
368 + expect![[r#"
369 + sequence
370 + `<tool_calls>`
371 + tag `<tool_call>get_current_date` `` `</tool_call>`
372 + `</tool_calls>`
373 + "#]]
374 + .assert_eq(&outline(&tag.format));
375 }
376
377 #[test]
@@ -312,13 +388,31 @@ mod tests {
388 )],
389 ToolChoice::required(),
390 );
315 - let value = serde_json::to_value(tag).unwrap();
316 -
317 - 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>"],"max_tokens":null,"max_chars":null},{"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>"],"max_tokens":null,"max_chars":null},{"type":"const_string","value":"</arg_value>"},{"type":"const_string","value":"\n"}]}]}"#]].assert_eq(
318 - &serde_json::to_string(
319 - &value["format"]["elements"][1]["tags"][0]["content"],
320 - )
321 - .unwrap(),
322 - );
391 + expect![[r#"
392 + sequence
393 + `<tool_calls>\n`
394 + tags_with_separator `\n` at_least_one
395 + tag `<tool_call>search<tool_sep>\n` .. `</tool_call>`
396 + sequence
397 + sequence
398 + `<arg_key>`
399 + `query`
400 + `</arg_key>`
401 + `\n`
402 + `<arg_value>`
403 + text excluding [`<tool_calls>`, `</tool_calls>`, `<tool_call>`, `</tool_call>`, `<tool_sep>`, `<arg_key>`, `</arg_key>`, `<arg_value>`, `</arg_value>`]
404 + `</arg_value>`
405 + `\n`
406 + sequence
407 + `<arg_key>`
408 + `tenant`
409 + `</arg_key>`
410 + `\n`
411 + `<arg_value>`
412 + text excluding [`<tool_calls>`, `</tool_calls>`, `<tool_call>`, `</tool_call>`, `<tool_sep>`, `<arg_key>`, `</arg_key>`, `<arg_value>`, `</arg_value>`]
413 + `</arg_value>`
414 + `\n`
415 + `\n</tool_calls>`
416 + "#]].assert_eq(&outline(&tag.format));
417 }
418 }
rust/src/parser/src/unified/kimi_k3/structural_tag.rs
+22 -1
@@ -364,6 +364,7 @@ mod tests {
364 };
365
366 use super::KimiK3StructuralTagBuilder;
367 + use crate::output_grammar::test_utils::outline;
368
369 fn tool(name: &str, parameters: serde_json::Value) -> ToolParam {
370 ToolParam::Function(FunctionToolParam::new(
@@ -395,7 +396,27 @@ mod tests {
396 )
397 .unwrap();
398
398 - expect![[r##"{"type":"structural_tag","format":{"type":"sequence","elements":[{"type":"optional","content":{"type":"const_string","value":"<|open|>response<|sep|>"}},{"type":"tag","begin":"","content":{"type":"any_text","excludes":["<|open|>","<|close|>","<|end_of_msg|>"],"max_tokens":null,"max_chars":null},"end":"<|close|>response<|sep|>"},{"type":"tag","begin":"<|open|>tools<|sep|>","content":{"type":"tags_with_separator","tags":[{"type":"tag","begin":"<|open|>call tool=\"get_weather\" index=\"","content":{"type":"sequence","elements":[{"type":"regex","pattern":"[1-9][0-9]*"},{"type":"const_string","value":"\"<|sep|>"},{"type":"or","elements":[{"type":"plus","content":{"type":"or","elements":[{"type":"tag","begin":"<|open|>argument key=\"unit\" type=\"string\"<|sep|>","content":{"type":"or","elements":[{"type":"const_string","value":"celsius"},{"type":"const_string","value":"fahrenheit"}]},"end":"<|close|>argument<|sep|>"},{"type":"tag","begin":"<|open|>argument key=\"place\" type=\"object\"<|sep|>","content":{"type":"json_schema","json_schema":{"$ref":"#/$defs/place","type":"object","$defs":{"place":{"type":"object","properties":{"city":{"type":"string"}}}}},"style":"json","any_order":false,"max_whitespace_cnt":null,"excludes":[]},"end":"<|close|>argument<|sep|>"}]}},{"type":"tag","begin":"<|open|>json type=\"object\"<|sep|>","content":{"type":"json_schema","json_schema":{"$defs":{"place":{"type":"object","properties":{"city":{"type":"string"}}}},"type":"object","properties":{"unit":{"type":"string","enum":["celsius","fahrenheit"]},"place":{"$ref":"#/$defs/place","type":"object"}},"required":["place"]},"style":"json","any_order":false,"max_whitespace_cnt":null,"excludes":[]},"end":"<|close|>json<|sep|>"}]}]},"end":"<|close|>call<|sep|>"}],"separator":"","at_least_one":true,"stop_after_first":false},"end":"<|close|>tools<|sep|>"},{"type":"optional","content":{"type":"const_string","value":"<|close|>message<|sep|>"}}]}}"##]].assert_eq(&tag.to_json_string().unwrap());
399 + expect![[r#"
400 + sequence
401 + optional `<|open|>response<|sep|>`
402 + tag `` text excluding [`<|open|>`, `<|close|>`, `<|end_of_msg|>`] `<|close|>response<|sep|>`
403 + tag `<|open|>tools<|sep|>` .. `<|close|>tools<|sep|>`
404 + tags_with_separator `` at_least_one
405 + tag `<|open|>call tool="get_weather" index="` .. `<|close|>call<|sep|>`
406 + sequence
407 + /[1-9][0-9]*/
408 + `"<|sep|>`
409 + or
410 + plus
411 + or
412 + tag `<|open|>argument key="unit" type="string"<|sep|>` .. `<|close|>argument<|sep|>`
413 + or
414 + `celsius`
415 + `fahrenheit`
416 + tag `<|open|>argument key="place" type="object"<|sep|>` json(place & object where place = { city?: string }) `<|close|>argument<|sep|>`
417 + tag `<|open|>json type="object"<|sep|>` json({ unit?: "celsius" | "fahrenheit", place: place & object } where place = { city?: string }) `<|close|>json<|sep|>`
418 + optional `<|close|>message<|sep|>`
419 + "#]].assert_eq(&outline(&tag.format));
420 }
421
422 #[test]