[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]