[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>
This commit is contained in:
Bugen Zhao
2026-10-01 05:09:43 +00:00
committed by GitHub
co-authored by Claude Opus 5.5 mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
parent 36229e1337
commit ac9126e58a
18 changed files with 1290 additions and 33 deletions
+1
View File
@@ -52,6 +52,7 @@ tempfile.workspace = true
tokio.workspace = true
uuid.workspace = true
vllm-engine-core-client = { workspace = true, features = ["test-util"] }
vllm-parser = { workspace = true, features = ["test-util"] }
vllm-tokenizer = { workspace = true, features = ["test-utils"] }
zeromq.workspace = true
@@ -0,0 +1,26 @@
# auto (from_token_zero)
sequence
tag `` text `</think>`
triggered_tags [`<|DSML|function_calls>`] excluding [`<think>`, `</think>`]
tag `<|DSML|function_calls>\n` .. `</|DSML|function_calls>`
tags_with_separator `` at_least_one
tag `<|DSML|invoke name="get_weather">\n` deepseek_xml({ location: string }) `</|DSML|invoke>\n`
tag `<|DSML|invoke name="add">\n` deepseek_xml({ y: number, x: number, items: string[] }) `</|DSML|invoke>\n`
# required (from_token_zero)
sequence
tag `` text `</think>`
sequence
`\n\n<|DSML|function_calls>\n`
tags_with_separator `` at_least_one
tag `<|DSML|invoke name="get_weather">\n` deepseek_xml({ location: string }) `</|DSML|invoke>\n`
tag `<|DSML|invoke name="add">\n` deepseek_xml({ y: number, x: number, items: string[] }) `</|DSML|invoke>\n`
`</|DSML|function_calls>`
# named (from_token_zero)
sequence
tag `` text `</think>`
sequence
`\n\n<|DSML|function_calls>\n`
tag `<|DSML|invoke name="get_weather">\n` deepseek_xml({ location: string }) `</|DSML|invoke>\n`
`</|DSML|function_calls>`
@@ -0,0 +1,26 @@
# auto (from_token_zero)
sequence
tag `` text `</think>`
triggered_tags [`<|DSML|tool_calls>`] excluding [`<think>`, `</think>`]
tag `<|DSML|tool_calls>\n` .. `</|DSML|tool_calls>`
tags_with_separator `` at_least_one
tag `<|DSML|invoke name="get_weather">\n` deepseek_xml({ location: string }) `</|DSML|invoke>\n`
tag `<|DSML|invoke name="add">\n` deepseek_xml({ y: number, x: number, items: string[] }) `</|DSML|invoke>\n`
# required (from_token_zero)
sequence
tag `` text `</think>`
sequence
`\n\n<|DSML|tool_calls>\n`
tags_with_separator `` at_least_one
tag `<|DSML|invoke name="get_weather">\n` deepseek_xml({ location: string }) `</|DSML|invoke>\n`
tag `<|DSML|invoke name="add">\n` deepseek_xml({ y: number, x: number, items: string[] }) `</|DSML|invoke>\n`
`</|DSML|tool_calls>`
# named (from_token_zero)
sequence
tag `` text `</think>`
sequence
`\n\n<|DSML|tool_calls>\n`
tag `<|DSML|invoke name="get_weather">\n` deepseek_xml({ location: string }) `</|DSML|invoke>\n`
`</|DSML|tool_calls>`
@@ -0,0 +1,26 @@
# auto (from_token_zero)
sequence
tag `` text `</think>`
triggered_tags [`<|DSML| calls>`] excluding [`<think>`, `</think>`]
tag `<|DSML| calls>\n` .. `</|DSML| calls>`
tags_with_separator `` at_least_one
tag `<|DSML| invoke name="get_weather">\n` deepseek_v4_1_xml({ location: string }) `</|DSML| invoke>\n`
tag `<|DSML| invoke name="add">\n` deepseek_v4_1_xml({ y: number, x: number, items: string[] }) `</|DSML| invoke>\n`
# required (from_token_zero)
sequence
tag `` text `</think>`
sequence
`\n\n<|DSML| calls>\n`
tags_with_separator `` at_least_one
tag `<|DSML| invoke name="get_weather">\n` deepseek_v4_1_xml({ location: string }) `</|DSML| invoke>\n`
tag `<|DSML| invoke name="add">\n` deepseek_v4_1_xml({ y: number, x: number, items: string[] }) `</|DSML| invoke>\n`
`</|DSML| calls>`
# named (from_token_zero)
sequence
tag `` text `</think>`
sequence
`\n\n<|DSML| calls>\n`
tag `<|DSML| invoke name="get_weather">\n` deepseek_v4_1_xml({ location: string }) `</|DSML| invoke>\n`
`</|DSML| calls>`
@@ -0,0 +1,18 @@
# auto (from_token_zero)
sequence
tag `` text `</think>`
triggered_tags [`<tool_call>`] excluding [`<think>`, `</think>`, `</tool_call>`, `<arg_key>`, `</arg_key>`, `<arg_value>`, `</arg_value>`]
tag `<tool_call>get_weather` glm_xml({ location: string }) `</tool_call>`
tag `<tool_call>add` glm_xml({ y: number, x: number, items: string[] }) `</tool_call>`
# required (from_token_zero)
sequence
tag `` text `</think>`
triggered_tags [`<tool_call>`] excluding [`<think>`, `</think>`, `</tool_call>`, `<arg_key>`, `</arg_key>`, `<arg_value>`, `</arg_value>`] at_least_one
tag `<tool_call>get_weather` glm_xml({ location: string }) `</tool_call>`
tag `<tool_call>add` glm_xml({ y: number, x: number, items: string[] }) `</tool_call>`
# named (from_token_zero)
sequence
tag `` text `</think>`
tag `<tool_call>get_weather` glm_xml({ location: string }) `</tool_call>`
@@ -0,0 +1,18 @@
# auto (from_token_zero)
sequence
tag `` text `</think>`
triggered_tags [`<tool_call>`] excluding [`<think>`, `</think>`, `</tool_call>`, `<arg_key>`, `</arg_key>`, `<arg_value>`, `</arg_value>`]
tag `<tool_call>get_weather` glm_xml({ location: string }) `</tool_call>`
tag `<tool_call>add` glm_xml({ y: number, x: number, items: string[] }) `</tool_call>`
# required (from_token_zero)
sequence
tag `` text `</think>`
triggered_tags [`<tool_call>`] excluding [`<think>`, `</think>`, `</tool_call>`, `<arg_key>`, `</arg_key>`, `<arg_value>`, `</arg_value>`] at_least_one
tag `<tool_call>get_weather` glm_xml({ location: string }) `</tool_call>`
tag `<tool_call>add` glm_xml({ y: number, x: number, items: string[] }) `</tool_call>`
# named (from_token_zero)
sequence
tag `` text `</think>`
tag `<tool_call>get_weather` glm_xml({ location: string }) `</tool_call>`
+112
View File
@@ -0,0 +1,112 @@
# auto (from_token_zero)
sequence
tag `` text `</think:opensource>`
triggered_tags [`<tool_calls:opensource>`]
tag `<tool_calls:opensource>\n` .. `\n</tool_calls:opensource>`
tags_with_separator `\n` at_least_one
tag `<tool_call:opensource>get_weather<tool_sep:opensource>\n` .. `</tool_call:opensource>`
sequence
sequence
`<arg_key:opensource>`
`location`
`</arg_key:opensource>`
`\n`
`<arg_value:opensource>`
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>`]
`</arg_value:opensource>`
`\n`
tag `<tool_call:opensource>add<tool_sep:opensource>\n` .. `</tool_call:opensource>`
sequence
sequence
`<arg_key:opensource>`
`y`
`</arg_key:opensource>`
`\n`
`<arg_value:opensource>`
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>`]
`</arg_value:opensource>`
`\n`
sequence
`<arg_key:opensource>`
`x`
`</arg_key:opensource>`
`\n`
`<arg_value:opensource>`
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>`]
`</arg_value:opensource>`
`\n`
sequence
`<arg_key:opensource>`
`items`
`</arg_key:opensource>`
`\n`
`<arg_value:opensource>`
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>`]
`</arg_value:opensource>`
`\n`
# required (from_token_zero)
sequence
tag `` text `</think:opensource>`
sequence
`<tool_calls:opensource>\n`
tags_with_separator `\n` at_least_one
tag `<tool_call:opensource>get_weather<tool_sep:opensource>\n` .. `</tool_call:opensource>`
sequence
sequence
`<arg_key:opensource>`
`location`
`</arg_key:opensource>`
`\n`
`<arg_value:opensource>`
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>`]
`</arg_value:opensource>`
`\n`
tag `<tool_call:opensource>add<tool_sep:opensource>\n` .. `</tool_call:opensource>`
sequence
sequence
`<arg_key:opensource>`
`y`
`</arg_key:opensource>`
`\n`
`<arg_value:opensource>`
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>`]
`</arg_value:opensource>`
`\n`
sequence
`<arg_key:opensource>`
`x`
`</arg_key:opensource>`
`\n`
`<arg_value:opensource>`
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>`]
`</arg_value:opensource>`
`\n`
sequence
`<arg_key:opensource>`
`items`
`</arg_key:opensource>`
`\n`
`<arg_value:opensource>`
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>`]
`</arg_value:opensource>`
`\n`
`\n</tool_calls:opensource>`
# named (from_token_zero)
sequence
tag `` text `</think:opensource>`
sequence
`<tool_calls:opensource>\n`
tag `<tool_call:opensource>get_weather<tool_sep:opensource>\n` .. `</tool_call:opensource>`
sequence
sequence
`<arg_key:opensource>`
`location`
`</arg_key:opensource>`
`\n`
`<arg_value:opensource>`
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>`]
`</arg_value:opensource>`
`\n`
`\n</tool_calls:opensource>`
@@ -0,0 +1,46 @@
# auto (from_token_zero)
sequence
tag `` text `</think>`
triggered_tags [`<|tool_calls_section_begin|>`] excluding [`<think>`, `</think>`, `<|tool_call_begin|>`]
tag `<|tool_calls_section_begin|>` .. `<|tool_calls_section_end|>`
tags_with_separator `` at_least_one
tag `<|tool_call_begin|>functions.get_weather:` .. `<|tool_call_end|>`
sequence
/\d+/
`<|tool_call_argument_begin|>`
json({ location: string })
tag `<|tool_call_begin|>functions.add:` .. `<|tool_call_end|>`
sequence
/\d+/
`<|tool_call_argument_begin|>`
json({ y: number, x: number, items: string[] })
# required (from_token_zero)
sequence
tag `` text `</think>`
sequence
`<|tool_calls_section_begin|>`
tags_with_separator `` at_least_one
tag `<|tool_call_begin|>functions.get_weather:` .. `<|tool_call_end|>`
sequence
/\d+/
`<|tool_call_argument_begin|>`
json({ location: string })
tag `<|tool_call_begin|>functions.add:` .. `<|tool_call_end|>`
sequence
/\d+/
`<|tool_call_argument_begin|>`
json({ y: number, x: number, items: string[] })
`<|tool_calls_section_end|>`
# named (from_token_zero)
sequence
tag `` text `</think>`
sequence
`<|tool_calls_section_begin|>`
tag `<|tool_call_begin|>functions.get_weather:` .. `<|tool_call_end|>`
sequence
/\d+/
`<|tool_call_argument_begin|>`
json({ location: string })
`<|tool_calls_section_end|>`
@@ -0,0 +1,67 @@
# auto (final_output_only)
sequence
optional `<|open|>response<|sep|>`
tag `` text excluding [`<|open|>`, `<|close|>`, `<|end_of_msg|>`] `<|close|>response<|sep|>`
optional
tag `<|open|>tools<|sep|>` .. `<|close|>tools<|sep|>`
tags_with_separator `` at_least_one
tag `<|open|>call tool="get_weather" index="` .. `<|close|>call<|sep|>`
sequence
/[1-9][0-9]*/
`"<|sep|>`
or
plus tag `<|open|>argument key="location" type="string"<|sep|>` text excluding [`<|close|>argument<|sep|>`, `<|close|>call<|sep|>`] `<|close|>argument<|sep|>`
tag `<|open|>json type="object"<|sep|>` json({ location: string }) `<|close|>json<|sep|>`
tag `<|open|>call tool="add" index="` .. `<|close|>call<|sep|>`
sequence
/[1-9][0-9]*/
`"<|sep|>`
or
plus
or
tag `<|open|>argument key="y" type="number"<|sep|>` json(number) `<|close|>argument<|sep|>`
tag `<|open|>argument key="x" type="number"<|sep|>` json(number) `<|close|>argument<|sep|>`
tag `<|open|>argument key="items" type="array"<|sep|>` json(string[]) `<|close|>argument<|sep|>`
tag `<|open|>json type="object"<|sep|>` json({ y: number, x: number, items: string[] }) `<|close|>json<|sep|>`
optional `<|close|>message<|sep|>`
# required (final_output_only)
sequence
optional `<|open|>response<|sep|>`
tag `` text excluding [`<|open|>`, `<|close|>`, `<|end_of_msg|>`] `<|close|>response<|sep|>`
tag `<|open|>tools<|sep|>` .. `<|close|>tools<|sep|>`
tags_with_separator `` at_least_one
tag `<|open|>call tool="get_weather" index="` .. `<|close|>call<|sep|>`
sequence
/[1-9][0-9]*/
`"<|sep|>`
or
plus tag `<|open|>argument key="location" type="string"<|sep|>` text excluding [`<|close|>argument<|sep|>`, `<|close|>call<|sep|>`] `<|close|>argument<|sep|>`
tag `<|open|>json type="object"<|sep|>` json({ location: string }) `<|close|>json<|sep|>`
tag `<|open|>call tool="add" index="` .. `<|close|>call<|sep|>`
sequence
/[1-9][0-9]*/
`"<|sep|>`
or
plus
or
tag `<|open|>argument key="y" type="number"<|sep|>` json(number) `<|close|>argument<|sep|>`
tag `<|open|>argument key="x" type="number"<|sep|>` json(number) `<|close|>argument<|sep|>`
tag `<|open|>argument key="items" type="array"<|sep|>` json(string[]) `<|close|>argument<|sep|>`
tag `<|open|>json type="object"<|sep|>` json({ y: number, x: number, items: string[] }) `<|close|>json<|sep|>`
optional `<|close|>message<|sep|>`
# named (final_output_only)
sequence
optional `<|open|>response<|sep|>`
tag `` text excluding [`<|open|>`, `<|close|>`, `<|end_of_msg|>`] `<|close|>response<|sep|>`
tag `<|open|>tools<|sep|>` .. `<|close|>tools<|sep|>`
tags_with_separator `` at_least_one
tag `<|open|>call tool="get_weather" index="` .. `<|close|>call<|sep|>`
sequence
/[1-9][0-9]*/
`"<|sep|>`
or
plus tag `<|open|>argument key="location" type="string"<|sep|>` text excluding [`<|close|>argument<|sep|>`, `<|close|>call<|sep|>`] `<|close|>argument<|sep|>`
tag `<|open|>json type="object"<|sep|>` json({ location: string }) `<|close|>json<|sep|>`
optional `<|close|>message<|sep|>`
@@ -0,0 +1,18 @@
# auto (from_token_zero)
sequence
optional tag `<think>` text `</think>`
triggered_tags [`<tool_call>`] excluding [`<think>`, `</think>`, `</tool_call>`, `<function=`]
tag `<tool_call><function=get_weather>` qwen_xml({ location: string }) `</function></tool_call>`
tag `<tool_call><function=add>` qwen_xml({ y: number, x: number, items: string[] }) `</function></tool_call>`
# required (from_token_zero)
sequence
optional tag `<think>` text `</think>`
triggered_tags [`<tool_call>`] excluding [`<think>`, `</think>`, `</tool_call>`, `<function=`] at_least_one
tag `<tool_call><function=get_weather>` qwen_xml({ location: string }) `</function></tool_call>`
tag `<tool_call><function=add>` qwen_xml({ y: number, x: number, items: string[] }) `</function></tool_call>`
# named (from_token_zero)
sequence
optional tag `<think>` text `</think>`
tag `<tool_call><function=get_weather>` qwen_xml({ location: string }) `</function></tool_call>`
@@ -0,0 +1,32 @@
# auto (from_token_zero)
sequence
sequence
tag `` text `</think>`
`\n\n`
triggered_tags [`<minimax:tool_call>`] excluding [`<think>`, `</think>`]
tag `<minimax:tool_call>\n` .. `</minimax:tool_call>`
tags_with_separator `` at_least_one
tag `<invoke name="get_weather">\n` minimax_xml({ location: string }) `</invoke>\n`
tag `<invoke name="add">\n` minimax_xml({ y: number, x: number, items: string[] }) `</invoke>\n`
# required (from_token_zero)
sequence
sequence
tag `` text `</think>`
`\n\n`
sequence
`\n<minimax:tool_call>\n`
tags_with_separator `` at_least_one
tag `<invoke name="get_weather">\n` minimax_xml({ location: string }) `</invoke>\n`
tag `<invoke name="add">\n` minimax_xml({ y: number, x: number, items: string[] }) `</invoke>\n`
`</minimax:tool_call>`
# named (from_token_zero)
sequence
sequence
tag `` text `</think>`
`\n\n`
sequence
`\n<minimax:tool_call>\n`
tag `<invoke name="get_weather">\n` minimax_xml({ location: string }) `</invoke>\n`
`</minimax:tool_call>`
@@ -0,0 +1,27 @@
# auto (from_token_zero)
sequence
optional
sequence
tag `<think>` text `</think>`
`\n\n`
triggered_tags [`<tool_call>`] excluding [`<think>`, `</think>`]
tag `<tool_call>\n{"name": "get_weather", "arguments": ` json({ location: string }) `}\n</tool_call>`
tag `<tool_call>\n{"name": "add", "arguments": ` json({ y: number, x: number, items: string[] }) `}\n</tool_call>`
# required (from_token_zero)
sequence
optional
sequence
tag `<think>` text `</think>`
`\n\n`
triggered_tags [`<tool_call>`] excluding [`<think>`, `</think>`] at_least_one
tag `<tool_call>\n{"name": "get_weather", "arguments": ` json({ location: string }) `}\n</tool_call>`
tag `<tool_call>\n{"name": "add", "arguments": ` json({ y: number, x: number, items: string[] }) `}\n</tool_call>`
# named (from_token_zero)
sequence
optional
sequence
tag `<think>` text `</think>`
`\n\n`
tag `<tool_call>\n{"name": "get_weather", "arguments": ` json({ location: string }) `}\n</tool_call>`
@@ -0,0 +1,24 @@
# auto (from_token_zero)
sequence
sequence
tag `` text `</think>`
`\n\n`
triggered_tags [`<tool_call>\n<function=`] excluding [`<think>`, `</think>`]
tag `<tool_call>\n<function=get_weather>\n` qwen_xml({ location: string }) `\n</function>\n</tool_call>`
tag `<tool_call>\n<function=add>\n` qwen_xml({ y: number, x: number, items: string[] }) `\n</function>\n</tool_call>`
# required (from_token_zero)
sequence
sequence
tag `` text `</think>`
`\n\n`
triggered_tags [`<tool_call>\n<function=`] excluding [`<think>`, `</think>`] at_least_one
tag `<tool_call>\n<function=get_weather>\n` qwen_xml({ location: string }) `\n</function>\n</tool_call>`
tag `<tool_call>\n<function=add>\n` qwen_xml({ y: number, x: number, items: string[] }) `\n</function>\n</tool_call>`
# named (from_token_zero)
sequence
sequence
tag `` text `</think>`
`\n\n`
tag `<tool_call>\n<function=get_weather>\n` qwen_xml({ location: string }) `\n</function>\n</tool_call>`
+27 -5
View File
@@ -1150,6 +1150,7 @@ fn check_grammar_replay_file(
) -> Result<()> {
let model_name = case.model_id.rsplit_once('/').map_or(case.model_id, |(_, name)| name);
let path = grammar_replay_dir().join(format!("{model_name}.json"));
let outline_path = path.with_extension("txt");
let results = results
.into_iter()
.filter_map(|(variant, result)| {
@@ -1157,11 +1158,13 @@ fn check_grammar_replay_file(
})
.collect::<Vec<_>>();
if results.is_empty() {
ensure!(
!path.exists(),
"{} builds no output grammar, but grammar replay file {path:?} exists",
case.model_id
);
for path in [&path, &outline_path] {
ensure!(
!path.exists(),
"{} builds no output grammar, but grammar replay file {path:?} exists",
case.model_id
);
}
return Ok(());
}
@@ -1205,6 +1208,25 @@ fn check_grammar_replay_file(
);
}
// A readable outline of the same grammars, for review only.
let mut outline = String::new();
for (variant, case) in &cases {
if !outline.is_empty() {
outline.push('\n');
}
let variant = serde_json::to_value(variant)?;
let coverage = serde_json::to_value(case.grammar.coverage)?;
outline.push_str(&format!(
"# {} ({})\n",
variant.as_str().context("variant name")?,
coverage.as_str().context("coverage name")?,
));
outline.push_str(&vllm_parser::output_grammar::test_utils::outline(
&case.grammar.format,
));
}
expect_test::expect_file![outline_path].assert_eq(&outline);
let file = GrammarReplayFile {
model_id: case.model_id,
vocab_size: backends.text_backend.model_vocab_size(),
+3
View File
@@ -17,6 +17,9 @@ use xgrammar_structural_tag::{
use crate::tool::Tool;
#[cfg(any(test, feature = "test-util"))]
pub mod test_utils;
/// Result alias for output grammar construction.
pub type Result<T> = std::result::Result<T, OutputGrammarError>;
@@ -0,0 +1,676 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
//! Readable renderings of structural-tag formats for test snapshots.
use std::fmt::Write;
use serde_json::{Map, Value};
use xgrammar_structural_tag::format::{EndBoundary, Format, TagBoundary, TagFormat, TokenValue};
/// Render `format` as an indented outline, one node per line.
///
/// Each node starts with its format type, and composite nodes list their
/// children one level deeper. Literal text is quoted in backticks with `\`,
/// `` ` ``, and control characters escaped, so markers read as written. Other
/// leaves are compact: `/regex/`, `text`, `json(SCHEMA)` or `STYLE(SCHEMA)`
/// for styled schemas, and `token ID` or ``token `TEXT` ``. Lists such as
/// exclusions and triggers are bracketed, and schemas read as types, see
/// [`schema`]. A single leaf child of `optional`, `star`, `plus`, `repeat`,
/// and dispatch rules stays on its parent's line, as does a tag's leaf
/// content between its boundaries. Options that keep their defaults are
/// omitted.
pub fn outline(format: &Format) -> String {
let mut out = String::new();
write(&mut out, 0, format_node(format));
out
}
/// A child line of an outline node.
enum Child<'a> {
Format(&'a Format),
Tag(&'a TagFormat),
Rule(String, &'a Format),
Text(&'a str),
}
/// An outline node: its header line and children.
struct Node<'a> {
header: String,
children: Vec<Child<'a>>,
/// Whether a single leaf child joins the header line.
inline_leaf: bool,
}
impl<'a> Node<'a> {
fn leaf(header: String) -> Self {
Self {
header,
children: vec![],
inline_leaf: false,
}
}
fn branch(header: impl Into<String>, children: Vec<Child<'a>>) -> Self {
Self {
header: header.into(),
children,
inline_leaf: false,
}
}
fn wrapper(header: impl Into<String>, child: Child<'a>) -> Self {
Self {
header: header.into(),
children: vec![child],
inline_leaf: true,
}
}
}
fn write(out: &mut String, depth: usize, node: Node<'_>) {
let Node {
mut header,
children,
inline_leaf,
} = node;
let mut children = children.into_iter().map(self::node).collect::<Vec<_>>();
if inline_leaf
&& let [child] = &children[..]
&& child.children.is_empty()
{
let _ = write!(header, " {}", child.header);
children.clear();
}
let _ = writeln!(out, "{}{header}", " ".repeat(depth));
for child in children {
write(out, depth + 1, child);
}
}
fn node(child: Child<'_>) -> Node<'_> {
match child {
Child::Format(format) => format_node(format),
Child::Tag(tag) => tag_node(tag),
Child::Rule(pattern, format) => {
Node::wrapper(format!("on {pattern}"), Child::Format(format))
}
Child::Text(text) => Node::leaf(text.to_string()),
}
}
fn format_node(format: &Format) -> Node<'_> {
match format {
Format::ConstString(format) => Node::leaf(quote(&format.value)),
Format::Regex(format) => Node::leaf(format!("/{}/", format.pattern)),
Format::AnyText(format) => {
let mut header = "text".to_string();
push_excluding(&mut header, format.excludes.iter().map(|text| quote(text)));
push_limit(&mut header, "max_tokens", format.max_tokens);
push_limit(&mut header, "max_chars", format.max_chars);
Node::leaf(header)
}
Format::JsonSchema(format) => {
let style = serde_json::to_value(format.style)
.ok()
.and_then(|style| style.as_str().map(str::to_string))
.unwrap_or_else(|| format!("{:?}", format.style));
let mut header = format!("{style}({})", schema(&format.json_schema));
if format.any_order {
header.push_str(" any_order");
}
if let Some(max) = format.max_whitespace_cnt {
let _ = write!(header, " max_whitespace={max}");
}
push_excluding(&mut header, format.excludes.iter().map(|text| quote(text)));
Node::leaf(header)
}
Format::Grammar(format) => {
Node::branch("grammar", format.grammar.lines().map(Child::Text).collect())
}
Format::Token(format) => Node::leaf(format!("token {}", token(&format.token))),
Format::ExcludeToken(format) => {
let mut header = "token".to_string();
push_excluding(&mut header, format.exclude_tokens.iter().map(token));
Node::leaf(header)
}
Format::AnyTokens(format) => {
let mut header = "tokens".to_string();
push_excluding(&mut header, format.exclude_tokens.iter().map(token));
push_limit(&mut header, "max_tokens", format.max_tokens);
Node::leaf(header)
}
Format::Sequence(format) => Node::branch(
"sequence",
format.elements.iter().map(Child::Format).collect(),
),
Format::Or(format) => {
Node::branch("or", format.elements.iter().map(Child::Format).collect())
}
Format::Optional(format) => Node::wrapper("optional", Child::Format(&format.content)),
Format::Plus(format) => Node::wrapper("plus", Child::Format(&format.content)),
Format::Star(format) => Node::wrapper("star", Child::Format(&format.content)),
Format::Repeat(format) => {
let max = if format.max < 0 {
String::new()
} else {
format.max.to_string()
};
Node::wrapper(
format!("repeat {}..{max}", format.min),
Child::Format(&format.content),
)
}
Format::Tag(tag) => tag_node(tag),
Format::TriggeredTags(format) => {
let triggers = format.triggers.iter().map(|trigger| quote(trigger));
let mut header = format!(
"triggered_tags [{}]",
triggers.collect::<Vec<_>>().join(", ")
);
push_excluding(&mut header, format.excludes.iter().map(|text| quote(text)));
push_flags(&mut header, format.at_least_one, format.stop_after_first);
Node::branch(header, format.tags.iter().map(Child::Tag).collect())
}
Format::TokenTriggeredTags(format) => {
let triggers = format.trigger_tokens.iter().map(token);
let mut header = format!(
"token_triggered_tags [{}]",
triggers.collect::<Vec<_>>().join(", ")
);
push_excluding(&mut header, format.exclude_tokens.iter().map(token));
push_flags(&mut header, format.at_least_one, format.stop_after_first);
Node::branch(header, format.tags.iter().map(Child::Tag).collect())
}
Format::TagsWithSeparator(format) => {
let mut header = format!("tags_with_separator {}", quote(&format.separator));
push_flags(&mut header, format.at_least_one, format.stop_after_first);
Node::branch(header, format.tags.iter().map(Child::Tag).collect())
}
Format::Dispatch(format) => {
let mut header = "dispatch".to_string();
if !format.r#loop {
header.push_str(" once");
}
push_excluding(&mut header, format.excludes.iter().map(|text| quote(text)));
Node::branch(
header,
format
.rules
.iter()
.map(|(pattern, format)| Child::Rule(quote(pattern), format))
.collect(),
)
}
Format::TokenDispatch(format) => {
let mut header = "token_dispatch".to_string();
if !format.r#loop {
header.push_str(" once");
}
push_excluding(&mut header, format.exclude_tokens.iter().map(token));
Node::branch(
header,
format
.rules
.iter()
.map(|(trigger, format)| Child::Rule(token(trigger), format))
.collect(),
)
}
}
}
/// `tag BEGIN CONTENT END` for leaf content, or `tag BEGIN .. END` above the
/// content's own node.
fn tag_node(tag: &TagFormat) -> Node<'_> {
let begin = match &tag.begin {
TagBoundary::Text(text) => quote(text),
TagBoundary::Token(boundary) => format!("token {}", token(&boundary.token)),
};
let end = match &tag.end {
EndBoundary::Text(text) => quote(text),
EndBoundary::Texts(texts) => {
texts.iter().map(|text| quote(text)).collect::<Vec<_>>().join(" | ")
}
EndBoundary::Token(boundary) => format!("token {}", token(&boundary.token)),
};
let content = format_node(&tag.content);
if content.children.is_empty() {
Node::leaf(format!("tag {begin} {} {end}", content.header))
} else {
Node::branch(
format!("tag {begin} .. {end}"),
vec![Child::Format(&tag.content)],
)
}
}
/// Schema keywords that do not constrain the generated text.
const ANNOTATIONS: &[&str] = &[
"$comment",
"$id",
"$schema",
"default",
"deprecated",
"description",
"examples",
"readOnly",
"title",
"writeOnly",
];
/// Render a JSON schema as a TypeScript-like type.
///
/// Objects are `{ key: T, optional?: T }`, with `...` for any additional
/// property and `...: T` for additional properties under `T`. Arrays are
/// `T[]`. Unions, type arrays, and enums join their options with `|`,
/// `oneOf` and `allOf` keep their names, local references show the
/// definition name, a `type` next to a reference intersects it as
/// `NAME & T`, and `true` / `false` are `any` / `never`. Every other
/// keyword follows the type as `(key=VALUE, ...)`, and definitions follow the
/// root as `where NAME = T, ...`. Annotations such as `description` are
/// dropped.
pub fn schema(schema: &Value) -> String {
typed(schema).text
}
/// A rendered type, and whether it is a top-level union or intersection that
/// an operand position must parenthesize.
struct Typed {
text: String,
compound: bool,
}
impl Typed {
fn plain(text: impl Into<String>) -> Self {
Self {
text: text.into(),
compound: false,
}
}
fn compound(text: String) -> Self {
Self {
text,
compound: true,
}
}
fn union(options: Vec<String>) -> Self {
Self {
compound: options.len() > 1,
text: options.join(" | "),
}
}
/// The type as an operand of `&` or of a suffix such as `[]` or `(...)`.
fn grouped(self) -> String {
if self.compound {
format!("({})", self.text)
} else {
self.text
}
}
}
fn typed(schema: &Value) -> Typed {
let schema = match schema {
Value::Bool(true) => return Typed::plain("any"),
Value::Bool(false) => return Typed::plain("never"),
Value::Object(schema) => schema,
schema => return Typed::plain(schema.to_string()),
};
let mut rendered = SchemaType::new(schema).render();
let definitions = ["$defs", "definitions"]
.into_iter()
.filter_map(|key| schema.get(key)?.as_object())
.flatten()
.map(|(name, definition)| format!("{name} = {}", self::schema(definition)))
.collect::<Vec<_>>();
if !definitions.is_empty() {
rendered = Typed::plain(format!(
"{} where {}",
rendered.text,
definitions.join(", ")
));
}
rendered
}
/// One schema object being rendered, with the keywords its type consumed.
struct SchemaType<'a> {
schema: &'a Map<String, Value>,
consumed: Vec<&'static str>,
}
impl<'a> SchemaType<'a> {
fn new(schema: &'a Map<String, Value>) -> Self {
Self {
schema,
consumed: vec!["$defs", "definitions"],
}
}
fn take(&mut self, key: &'static str) -> Option<&'a Value> {
let value = self.schema.get(key)?;
self.consumed.push(key);
Some(value)
}
fn render(mut self) -> Typed {
let mut rendered = self.body();
let rest = self
.schema
.iter()
.filter(|(key, _)| {
!self.consumed.contains(&key.as_str()) && !ANNOTATIONS.contains(&key.as_str())
})
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>();
if !rest.is_empty() {
rendered = Typed::plain(format!("{}({})", rendered.grouped(), rest.join(", ")));
}
rendered
}
fn body(&mut self) -> Typed {
if let Some(Value::String(reference)) = self.take("$ref") {
let name = ["#/$defs/", "#/definitions/"]
.into_iter()
.find_map(|prefix| reference.strip_prefix(prefix))
.unwrap_or(reference);
// Keywords next to `$ref` apply together with it.
return match self.take("type") {
Some(type_name) => {
Typed::compound(format!("{name} & {}", type_names(type_name).grouped()))
}
None => Typed::plain(name),
};
}
if let Some(value) = self.take("const") {
self.take_redundant_type([value]);
return Typed::plain(value.to_string());
}
if let Some(Value::Array(values)) = self.take("enum") {
self.take_redundant_type(values);
return Typed::union(values.iter().map(Value::to_string).collect());
}
if let Some(Value::Array(options)) = self.take("anyOf") {
return Typed::union(options.iter().map(schema).collect());
}
for combinator in ["oneOf", "allOf"] {
if let Some(Value::Array(options)) = self.take(combinator) {
let options = options.iter().map(schema).collect::<Vec<_>>();
return Typed::plain(format!("{combinator}({})", options.join(", ")));
}
}
let is = |name: &str| self.schema.get("type").is_none_or(|type_name| type_name == name);
if is("object")
&& (self.schema.contains_key("properties")
|| self.schema.contains_key("additionalProperties"))
{
self.take("type");
return Typed::plain(self.object());
}
if is("array") && self.schema.contains_key("items") {
self.take("type");
let items = self.take("items").map_or_else(|| Typed::plain("any"), typed);
return Typed::plain(format!("{}[]", items.grouped()));
}
match self.take("type") {
Some(type_name) => type_names(type_name),
None => Typed::plain("any"),
}
}
/// Consume `type` when every literal already has that type.
fn take_redundant_type<'v>(&mut self, values: impl IntoIterator<Item = &'v Value>) {
let Some(Value::String(type_name)) = self.schema.get("type") else {
return;
};
let matches = |value: &Value| match value {
Value::String(_) => type_name == "string",
Value::Number(number) => {
type_name == "number" || (type_name == "integer" && !number.is_f64())
}
Value::Bool(_) => type_name == "boolean",
Value::Null => type_name == "null",
Value::Object(_) => type_name == "object",
Value::Array(_) => type_name == "array",
};
if values.into_iter().all(matches) {
self.take("type");
}
}
fn object(&mut self) -> String {
let required = self
.take("required")
.and_then(Value::as_array)
.map(|required| required.iter().filter_map(Value::as_str).collect::<Vec<_>>())
.unwrap_or_default();
let properties = self.take("properties").and_then(Value::as_object);
let mut fields = properties
.into_iter()
.flatten()
.map(|(key, value)| {
let optional = if required.contains(&key.as_str()) {
""
} else {
"?"
};
format!("{key}{optional}: {}", schema(value))
})
.collect::<Vec<_>>();
// Required keys without a declared schema.
fields.extend(
required
.iter()
.filter(|key| properties.is_none_or(|properties| !properties.contains_key(**key)))
.map(|key| format!("{key}: any")),
);
match self.take("additionalProperties") {
Some(Value::Bool(false)) | None => {}
Some(Value::Bool(true)) => fields.push("...".to_string()),
Some(additional) => fields.push(format!("...: {}", schema(additional))),
}
if fields.is_empty() {
"{}".to_string()
} else {
format!("{{ {} }}", fields.join(", "))
}
}
}
/// A `type` keyword's type names.
fn type_names(type_name: &Value) -> Typed {
match type_name {
Value::String(name) if name == "array" => Typed::plain("any[]"),
Value::String(name) => Typed::plain(name.clone()),
Value::Array(names) => Typed::union(
names
.iter()
.map(|name| name.as_str().map_or_else(|| name.to_string(), str::to_string))
.collect(),
),
type_name => Typed::plain(type_name.to_string()),
}
}
fn quote(text: &str) -> String {
let mut quoted = String::with_capacity(text.len() + 2);
quoted.push('`');
for char in text.chars() {
match char {
'\\' => quoted.push_str("\\\\"),
'`' => quoted.push_str("\\`"),
'\n' => quoted.push_str("\\n"),
'\r' => quoted.push_str("\\r"),
'\t' => quoted.push_str("\\t"),
char if char.is_control() => {
let _ = write!(quoted, "\\u{{{:x}}}", char as u32);
}
char => quoted.push(char),
}
}
quoted.push('`');
quoted
}
fn token(token: &TokenValue) -> String {
match token {
TokenValue::Id(id) => id.to_string(),
TokenValue::Text(text) => quote(text),
}
}
fn push_excluding(header: &mut String, excludes: impl Iterator<Item = String>) {
let excludes = excludes.collect::<Vec<_>>();
if !excludes.is_empty() {
let _ = write!(header, " excluding [{}]", excludes.join(", "));
}
}
fn push_limit(header: &mut String, name: &str, limit: Option<u32>) {
if let Some(limit) = limit {
let _ = write!(header, " {name}={limit}");
}
}
fn push_flags(header: &mut String, at_least_one: bool, stop_after_first: bool) {
if at_least_one {
header.push_str(" at_least_one");
}
if stop_after_first {
header.push_str(" stop_after_first");
}
}
#[cfg(test)]
mod tests {
use expect_test::expect;
use serde_json::json;
use xgrammar_structural_tag::format::{
AnyTokensFormat, DispatchFormat, ExcludeTokenFormat, GrammarFormat, JsonSchemaFormat,
JsonSchemaStyle, TokenBoundary, TokenDispatchFormat, TokenFormat, TokenTriggeredTagsFormat,
};
use super::*;
#[test]
fn outline_renders_every_format_type() {
let format = Format::sequence(vec![
Format::Token(TokenFormat {
token: TokenValue::Id(7),
}),
Format::ExcludeToken(ExcludeTokenFormat {
exclude_tokens: vec![TokenValue::Id(1), TokenValue::Text("<eos>".to_string())],
}),
Format::AnyTokens(AnyTokensFormat {
exclude_tokens: vec![TokenValue::Id(2)],
max_tokens: Some(16),
}),
Format::Grammar(GrammarFormat {
grammar: "root ::= a\na ::= \"x\"".to_string(),
}),
Format::repeat(Format::regex("[0-9]"), 1, -1),
Format::JsonSchema(
JsonSchemaFormat::new(json!({ "type": "string" }))
.with_style(JsonSchemaStyle::KimiK3Xml)
.with_any_order(true)
.with_max_whitespace_cnt(Some(2))
.with_excludes(&["</arg>"]),
),
Format::TokenTriggeredTags(TokenTriggeredTagsFormat {
trigger_tokens: vec![TokenValue::Id(9)],
tags: vec![TagFormat::new(
TokenBoundary::new(9),
Format::optional(Format::const_string("`\t")),
vec!["</a>", "</b>"],
)],
exclude_tokens: vec![],
at_least_one: false,
stop_after_first: true,
}),
Format::Dispatch(DispatchFormat {
rules: vec![(
"<call>".to_string(),
Format::sequence(vec![Format::any_text(), Format::const_string("</call>")]),
)],
r#loop: false,
excludes: vec!["<stop>".to_string()],
}),
Format::TokenDispatch(TokenDispatchFormat {
rules: vec![(
TokenValue::Id(3),
Format::JsonSchema(JsonSchemaFormat::new(json!(true))),
)],
r#loop: true,
exclude_tokens: vec![],
}),
]);
expect![[r#"
sequence
token 7
token excluding [1, `<eos>`]
tokens excluding [2] max_tokens=16
grammar
root ::= a
a ::= "x"
repeat 1.. /[0-9]/
kimi_k3_xml(string) any_order max_whitespace=2 excluding [`</arg>`]
token_triggered_tags [9] stop_after_first
tag token 9 .. `</a>` | `</b>`
optional `\`\t`
dispatch once excluding [`<stop>`]
on `<call>`
sequence
text
`</call>`
token_dispatch
on 3 json(any)
"#]]
.assert_eq(&outline(&format));
}
#[test]
fn schema_renders_common_shapes_as_types() {
let rendered = schema(&json!({
"$defs": { "place": { "type": "object", "properties": { "city": { "type": "string" } } } },
"type": "object",
"description": "ignored",
"properties": {
"location": { "type": "string" },
"id": { "type": ["integer", "null"] },
"unit": { "type": "string", "enum": ["celsius", "fahrenheit"] },
"tags": { "type": "array", "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } },
"place": { "$ref": "#/$defs/place" },
"days": { "type": "integer", "minimum": 1 },
"free": { "type": "object", "additionalProperties": true }
},
"required": ["location", "id"],
"additionalProperties": false
}));
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);
}
#[test]
fn schema_keeps_other_keywords_after_the_type() {
let rendered = schema(&json!({
"type": "object",
"properties": {
"code": { "type": "string", "pattern": "[A-Z]{3}" },
"ids": { "type": "array", "items": { "type": ["integer", "null"] }, "maxItems": 3 },
"place": { "$ref": "#/$defs/place", "type": "object" },
"either": { "not": { "type": "null" } }
},
"required": ["code", "tenant"],
"additionalProperties": { "type": "boolean" },
"minProperties": 1
}));
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);
}
}
+121 -27
View File
@@ -170,6 +170,7 @@ mod tests {
};
use super::HyStructuralTagBuilder;
use crate::output_grammar::test_utils::outline;
use crate::tool::{HyDialect, HyToolMarkers};
fn tool(name: &str, parameters: Value) -> ToolParam {
@@ -219,7 +220,33 @@ mod tests {
ToolChoice::required(),
);
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());
expect![[r#"
sequence
`<tool_calls:opensource>\n`
tags_with_separator `\n` at_least_one
tag `<tool_call:opensource>get_weather<tool_sep:opensource>\n` .. `</tool_call:opensource>`
sequence
sequence
`<arg_key:opensource>`
`city`
`</arg_key:opensource>`
`\n`
`<arg_value:opensource>`
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>`]
`</arg_value:opensource>`
`\n`
star
sequence
`<arg_key:opensource>`
`days`
`</arg_key:opensource>`
`\n`
`<arg_value:opensource>`
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>`]
`</arg_value:opensource>`
`\n`
`\n</tool_calls:opensource>`
"#]].assert_eq(&outline(&tag.format));
}
#[test]
@@ -238,7 +265,21 @@ mod tests {
ToolChoice::required(),
);
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());
expect![[r#"
sequence
`<tool_calls:opensource>`
tags_with_separator `` at_least_one
tag `<tool_call:opensource>get_weather` .. `</tool_call:opensource>`
sequence
sequence
`<arg_key:opensource>`
`city`
`</arg_key:opensource>`
`<arg_value:opensource>`
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>`]
`</arg_value:opensource>`
`</tool_calls:opensource>`
"#]].assert_eq(&outline(&tag.format));
}
#[test]
@@ -257,14 +298,34 @@ mod tests {
)],
ToolChoice::required(),
);
let value = serde_json::to_value(tag).unwrap();
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(
&serde_json::to_string(
&value["format"]["elements"][1]["tags"][0]["content"],
)
.unwrap(),
);
expect![[r#"
sequence
`<tool_calls>\n`
tags_with_separator `\n` at_least_one
tag `<tool_call>lookup<tool_sep>\n` .. `</tool_call>`
sequence
star
or
sequence
`<arg_key>`
`query`
`</arg_key>`
`\n`
`<arg_value>`
text excluding [`<tool_calls>`, `</tool_calls>`, `<tool_call>`, `</tool_call>`, `<tool_sep>`, `<arg_key>`, `</arg_key>`, `<arg_value>`, `</arg_value>`]
`</arg_value>`
`\n`
sequence
`<arg_key>`
`limit`
`</arg_key>`
`\n`
`<arg_value>`
text excluding [`<tool_calls>`, `</tool_calls>`, `<tool_call>`, `</tool_call>`, `<tool_sep>`, `<arg_key>`, `</arg_key>`, `<arg_value>`, `</arg_value>`]
`</arg_value>`
`\n`
`\n</tool_calls>`
"#]].assert_eq(&outline(&tag.format));
}
#[test]
@@ -273,11 +334,24 @@ mod tests {
tool("search", json!({ "type": "object" })),
tool("lookup", json!({ "type": "object" })),
];
let auto = build("", &tools, ToolChoice::auto()).to_json_string().unwrap();
let forced = build("", &tools, ToolChoice::function("lookup")).to_json_string().unwrap();
let auto = build("", &tools, ToolChoice::auto());
let forced = build("", &tools, ToolChoice::function("lookup"));
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);
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);
expect![[r#"
triggered_tags [`<tool_calls>`]
tag `<tool_calls>\n` .. `\n</tool_calls>`
tags_with_separator `\n` at_least_one
tag `<tool_call>search<tool_sep>\n` text `</tool_call>`
tag `<tool_call>lookup<tool_sep>\n` text `</tool_call>`
"#]]
.assert_eq(&outline(&auto.format));
expect![[r#"
sequence
`<tool_calls>\n`
tag `<tool_call>lookup<tool_sep>\n` text `</tool_call>`
`\n</tool_calls>`
"#]]
.assert_eq(&outline(&forced.format));
}
#[test]
@@ -291,11 +365,13 @@ mod tests {
)],
ToolChoice::function("get_current_date"),
);
let value = serde_json::to_value(tag).unwrap();
expect![[r#"{"type":"tag","begin":"<tool_call>get_current_date","content":{"type":"const_string","value":""},"end":"</tool_call>"}"#]].assert_eq(
&serde_json::to_string(&value["format"]["elements"][1]).unwrap(),
);
expect![[r#"
sequence
`<tool_calls>`
tag `<tool_call>get_current_date` `` `</tool_call>`
`</tool_calls>`
"#]]
.assert_eq(&outline(&tag.format));
}
#[test]
@@ -312,13 +388,31 @@ mod tests {
)],
ToolChoice::required(),
);
let value = serde_json::to_value(tag).unwrap();
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(
&serde_json::to_string(
&value["format"]["elements"][1]["tags"][0]["content"],
)
.unwrap(),
);
expect![[r#"
sequence
`<tool_calls>\n`
tags_with_separator `\n` at_least_one
tag `<tool_call>search<tool_sep>\n` .. `</tool_call>`
sequence
sequence
`<arg_key>`
`query`
`</arg_key>`
`\n`
`<arg_value>`
text excluding [`<tool_calls>`, `</tool_calls>`, `<tool_call>`, `</tool_call>`, `<tool_sep>`, `<arg_key>`, `</arg_key>`, `<arg_value>`, `</arg_value>`]
`</arg_value>`
`\n`
sequence
`<arg_key>`
`tenant`
`</arg_key>`
`\n`
`<arg_value>`
text excluding [`<tool_calls>`, `</tool_calls>`, `<tool_call>`, `</tool_call>`, `<tool_sep>`, `<arg_key>`, `</arg_key>`, `<arg_value>`, `</arg_value>`]
`</arg_value>`
`\n`
`\n</tool_calls>`
"#]].assert_eq(&outline(&tag.format));
}
}
@@ -364,6 +364,7 @@ mod tests {
};
use super::KimiK3StructuralTagBuilder;
use crate::output_grammar::test_utils::outline;
fn tool(name: &str, parameters: serde_json::Value) -> ToolParam {
ToolParam::Function(FunctionToolParam::new(
@@ -395,7 +396,27 @@ mod tests {
)
.unwrap();
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());
expect![[r#"
sequence
optional `<|open|>response<|sep|>`
tag `` text excluding [`<|open|>`, `<|close|>`, `<|end_of_msg|>`] `<|close|>response<|sep|>`
tag `<|open|>tools<|sep|>` .. `<|close|>tools<|sep|>`
tags_with_separator `` at_least_one
tag `<|open|>call tool="get_weather" index="` .. `<|close|>call<|sep|>`
sequence
/[1-9][0-9]*/
`"<|sep|>`
or
plus
or
tag `<|open|>argument key="unit" type="string"<|sep|>` .. `<|close|>argument<|sep|>`
or
`celsius`
`fahrenheit`
tag `<|open|>argument key="place" type="object"<|sep|>` json(place & object where place = { city?: string }) `<|close|>argument<|sep|>`
tag `<|open|>json type="object"<|sep|>` json({ unit?: "celsius" | "fahrenheit", place: place & object } where place = { city?: string }) `<|close|>json<|sep|>`
optional `<|close|>message<|sep|>`
"#]].assert_eq(&outline(&tag.format));
}
#[test]