Remove EBNF Composer (#13163)

This commit is contained in:
Tejesh Anand
2025-11-12 17:55:30 -08:00
committed by GitHub
parent 6d21392b0e
commit e42df37df0
18 changed files with 6 additions and 1081 deletions

View File

@@ -1,8 +1,6 @@
import json
import unittest
from xgrammar import GrammarCompiler, TokenizerInfo
from sglang.srt.entrypoints.openai.protocol import Function, Tool
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import StreamingParseResult
@@ -458,452 +456,6 @@ class TestMistralDetector(unittest.TestCase):
self.assertEqual(params["content"], "The answer is 42")
class TestEBNFGeneration(unittest.TestCase):
def setUp(self):
# Create sample tools for testing
self.tools = [
Tool(
type="function",
function=Function(
name="get_weather",
description="Get weather information",
parameters={
"properties": {
"location": {
"type": "string",
"description": "Location to get weather for",
},
"unit": {
"type": "string",
"description": "Temperature unit",
"enum": ["celsius", "fahrenheit"],
},
},
"required": ["location"],
},
),
),
Tool(
type="function",
function=Function(
name="search",
description="Search for information",
parameters={
"properties": {
"query": {
"type": "string",
"description": "Search query",
},
},
"required": ["query"],
},
),
),
Tool(
type="function",
function=Function(
name="empty_param_func",
description="Function with empty parameters",
parameters={
"properties": {},
"required": [],
},
),
),
]
self.tokenizer = get_tokenizer(DEFAULT_SMALL_MODEL_NAME_FOR_TEST)
tokenizer_info = TokenizerInfo.from_huggingface(self.tokenizer)
self.grammar_compiler = GrammarCompiler(tokenizer_info=tokenizer_info)
# Initialize all detectors
self.pythonic_detector = PythonicDetector()
self.deepseekv3_detector = DeepSeekV3Detector()
self.llama32_detector = Llama32Detector()
self.mistral_detector = MistralDetector()
self.qwen25_detector = Qwen25Detector()
self.qwen3_coder_detector = Qwen3CoderDetector()
self.kimik2_detector = KimiK2Detector()
self.glm45_detector = Glm4MoeDetector()
def test_pythonic_detector_ebnf(self):
"""Test that the PythonicDetector generates valid EBNF."""
ebnf = self.pythonic_detector.build_ebnf(self.tools)
self.assertIsNotNone(ebnf)
# Check that the EBNF contains expected patterns
self.assertIn('call_get_weather ::= "get_weather" "(" ', ebnf)
self.assertIn('"location" "=" basic_string', ebnf)
self.assertIn('( "unit" "=" ("\\"celsius\\"" | "\\"fahrenheit\\"") )', ebnf)
# Validate that the EBNF can be compiled by GrammarCompiler
try:
ctx = self.grammar_compiler.compile_grammar(ebnf)
self.assertIsNotNone(ctx, "EBNF should be valid and compile successfully")
except RuntimeError as e:
self.fail(f"Failed to compile EBNF: {e}")
def test_deepseekv3_detector_ebnf(self):
"""Test that the DeepSeekV3Detector generates valid EBNF."""
ebnf = self.deepseekv3_detector.build_ebnf(self.tools)
self.assertIsNotNone(ebnf)
# Check that the EBNF contains expected patterns
self.assertIn("<tool▁calls▁begin>", ebnf)
self.assertIn("<tool▁call▁begin>function<tool▁sep>get_weather", ebnf)
self.assertIn('\\"location\\"" ws ":" ws basic_string ', ebnf)
# Validate that the EBNF can be compiled by GrammarCompiler
try:
ctx = self.grammar_compiler.compile_grammar(ebnf)
self.assertIsNotNone(ctx, "EBNF should be valid and compile successfully")
except RuntimeError as e:
self.fail(f"Failed to compile EBNF: {e}")
def test_kimik2_detector_ebnf(self):
"""Test that the KimiK2Detector generates valid EBNF."""
ebnf = self.kimik2_detector.build_ebnf(self.tools)
self.assertIsNotNone(ebnf)
# Check that the EBNF contains expected patterns for KimiK2 format
self.assertIn("<|tool_calls_section_begin|>", ebnf)
self.assertIn("<|tool_calls_section_end|>", ebnf)
# Check for KimiK2-specific function call structure
self.assertIn("<|tool_call_begin|>functions.get_weather:", ebnf)
self.assertIn("<|tool_call_begin|>functions.search:", ebnf)
self.assertIn("<|tool_call_argument_begin|>", ebnf)
self.assertIn("<|tool_call_end|>", ebnf)
# Check that it uses the correct namespace.function format with numeric index pattern
self.assertIn("functions.get_weather:", ebnf)
self.assertIn("functions.search:", ebnf)
self.assertIn("[0-9]+", ebnf) # Numeric index pattern
# Validate that the EBNF can be compiled by GrammarCompiler
try:
ctx = self.grammar_compiler.compile_grammar(ebnf)
self.assertIsNotNone(ctx, "EBNF should be valid and compile successfully")
except RuntimeError as e:
self.fail(f"Failed to compile EBNF: {e}")
def test_llama32_detector_ebnf(self):
"""Test that the Llama32Detector generates valid EBNF."""
ebnf = self.llama32_detector.build_ebnf(self.tools)
self.assertIsNotNone(ebnf)
# Check that the EBNF contains expected patterns
self.assertIn('\\"name\\"" ws ":" ws "\\"get_weather\\"', ebnf)
self.assertIn('"\\"arguments\\"" ws ":"', ebnf)
# Validate that the EBNF can be compiled by GrammarCompiler
try:
ctx = self.grammar_compiler.compile_grammar(ebnf)
self.assertIsNotNone(ctx, "EBNF should be valid and compile successfully")
except RuntimeError as e:
self.fail(f"Failed to compile EBNF: {e}")
def test_mistral_detector_ebnf(self):
"""Test that the MistralDetector generates valid EBNF."""
ebnf = self.mistral_detector.build_ebnf(self.tools)
self.assertIsNotNone(ebnf)
# Check that the EBNF contains expected patterns
self.assertIn('"[TOOL_CALLS] ["', ebnf)
self.assertIn("call_get_weather | call_search", ebnf)
self.assertIn('"\\"arguments\\"" ws ":"', ebnf)
# Validate that the EBNF can be compiled by GrammarCompiler
try:
ctx = self.grammar_compiler.compile_grammar(ebnf)
self.assertIsNotNone(ctx, "EBNF should be valid and compile successfully")
except RuntimeError as e:
self.fail(f"Failed to compile EBNF: {e}")
def test_qwen25_detector_ebnf(self):
"""Test that the Qwen25Detector generates valid EBNF."""
ebnf = self.qwen25_detector.build_ebnf(self.tools)
self.assertIsNotNone(ebnf)
# Check that the EBNF contains expected patterns
self.assertIn("<tool_call>", ebnf)
self.assertIn('\\"name\\"" ws ":" ws "\\"get_weather\\"', ebnf)
self.assertIn('"\\"arguments\\"" ws ":"', ebnf)
# Validate that the EBNF can be compiled by GrammarCompiler
try:
ctx = self.grammar_compiler.compile_grammar(ebnf)
self.assertIsNotNone(ctx, "EBNF should be valid and compile successfully")
except RuntimeError as e:
self.fail(f"Failed to compile EBNF: {e}")
def test_glm45_detector_ebnf(self):
"""Test that the Glm4MoeDetector generates valid EBNF."""
ebnf = self.glm45_detector.build_ebnf(self.tools)
self.assertIsNotNone(ebnf)
# Check that the EBNF contains expected patterns for XML format
self.assertIn('"<tool_call>" function_call "</tool_call>"', ebnf)
self.assertIn('"get_weather" "\\n" ( arguments_get_weather "\\n" )?', ebnf)
self.assertIn(
'"<arg_key>location</arg_key>" "\\n" "<arg_value>" xml_text "</arg_value>" ( "\\n" ( "<arg_key>unit</arg_key>" "\\n" "<arg_value>" ("celsius" | "fahrenheit") "</arg_value>" ) )?',
ebnf,
)
self.assertIn('"search" "\\n" ( arguments_search "\\n" )?', ebnf)
self.assertIn(
'"<arg_key>query</arg_key>" "\\n" "<arg_value>" xml_text "</arg_value>"',
ebnf,
)
self.assertIn(
'"empty_param_func" "\\n" ( arguments_empty_param_func "\\n" )?', ebnf
)
self.assertIn('arguments_empty_param_func ::= ""', ebnf)
# Validate that the EBNF can be compiled by GrammarCompiler
try:
ctx = self.grammar_compiler.compile_grammar(ebnf)
self.assertIsNotNone(ctx, "EBNF should be valid and compile successfully")
except RuntimeError as e:
self.fail(f"Failed to compile EBNF: {e}")
def test_qwen3_coder_detector_ebnf(self):
"""Test that the Qwen3CoderDetector generates valid EBNF."""
ebnf = self.qwen3_coder_detector.build_ebnf(self.tools)
self.assertIsNotNone(ebnf)
# Check that the EBNF contains expected patterns for XML format
self.assertIn("<tool_call>", ebnf)
self.assertIn("</tool_call>", ebnf)
self.assertIn('"<function=get_weather>\\n"', ebnf)
self.assertIn('"\\n</function>"', ebnf)
self.assertIn('"<parameter=location>\\n"', ebnf)
self.assertIn('"\\n</parameter>"', ebnf)
# Check that it uses xml_text for string parameters
self.assertIn("xml_text", ebnf)
# Validate that the EBNF can be compiled by GrammarCompiler
try:
ctx = self.grammar_compiler.compile_grammar(ebnf)
self.assertIsNotNone(ctx, "EBNF should be valid and compile successfully")
except RuntimeError as e:
self.fail(f"Failed to compile EBNF: {e}")
def test_weather_function_optional_parameter_handling(self):
"""Test that weather function with optional unit parameter generates correct EBNF without trailing commas."""
# Create a weather tool with required location and optional unit
weather_tool = Tool(
type="function",
function=Function(
name="get_current_weather",
description="Get the current weather in a given location",
parameters={
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and state, e.g. San Francisco, CA",
},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["location"],
},
),
)
# Test all detectors with the weather tool
detectors = {
"pythonic": self.pythonic_detector,
"deepseekv3": self.deepseekv3_detector,
"llama32": self.llama32_detector,
"mistral": self.mistral_detector,
"qwen25": self.qwen25_detector,
}
for name, detector in detectors.items():
with self.subTest(detector=name):
ebnf = detector.build_ebnf([weather_tool])
self.assertIsNotNone(ebnf, f"{name} detector should generate EBNF")
# Check that the EBNF properly handles optional parameters
if name == "pythonic":
# Pythonic format: location="Paris" ( , ( unit=("celsius" | "fahrenheit") )?
self.assertIn('"location" "=" basic_string', ebnf)
# The comma should be inside the optional brackets for unit
self.assertIn('( ws "," ws ( "unit" "=" ', ebnf)
else:
# JSON format: "location": "Paris" ( , ( "unit": ("celsius" | "fahrenheit") )?
self.assertIn('"location\\"" ws ":" ws basic_string', ebnf)
# The comma should be part of the optional group
# This pattern ensures no trailing comma when unit is omitted
self.assertIn('( ws "," ws ( "\\"unit\\"" ws ":"', ebnf)
# Validate that the EBNF can be compiled
try:
ctx = self.grammar_compiler.compile_grammar(ebnf)
self.assertIsNotNone(
ctx, f"{name} EBNF should compile successfully"
)
except RuntimeError as e:
self.fail(f"Failed to compile {name} EBNF: {e}")
def test_multiple_optional_parameters_flexible_ordering(self):
"""Test that multiple optional parameters allow flexible ordering using llama.cpp approach."""
# Create a tool with one required and multiple optional parameters
test_tool = Tool(
type="function",
function=Function(
name="test_func",
description="Test function with multiple optional parameters",
parameters={
"type": "object",
"properties": {
"required_field": {"type": "string"},
"opt1": {"type": "number"},
"opt2": {"type": "boolean"},
"opt3": {"type": "string"},
},
"required": ["required_field"],
},
),
)
# Test JSON-based detectors (not pythonic)
json_detectors = {
"deepseekv3": self.deepseekv3_detector,
"llama32": self.llama32_detector,
"mistral": self.mistral_detector,
"qwen25": self.qwen25_detector,
}
for name, detector in json_detectors.items():
with self.subTest(detector=name):
ebnf = detector.build_ebnf([test_tool])
self.assertIsNotNone(ebnf, f"{name} detector should generate EBNF")
# Print the arguments rule for debugging
lines = ebnf.split("\n")
args_rule = None
for line in lines:
if line.startswith("arguments_test_func ::="):
args_rule = line
break
self.assertIsNotNone(
args_rule, f"{name} should have arguments_test_func rule"
)
# Check required field
self.assertIn('"required_field\\"" ws ":" ws basic_string', ebnf)
# Check the structure for optional parameters
# The pattern should be: required_field ( "," ( opt1 ... | opt2 ... | opt3 ... ) )?
# This allows flexible ordering where any optional can be first
# Check that optional parameters are in a group with comma
if args_rule: # Only check if args_rule was found
self.assertIn(
'( ws "," ws (',
args_rule,
f"{name} should have comma grouped with optional parameters",
)
# Check for the alternation pattern that allows flexible ordering
# Should contain patterns like: opt1 ... | opt2 ... | opt3
self.assertIn('"opt1\\"" ws ":" ws basic_number', args_rule)
self.assertIn('"opt2\\"" ws ":" ws basic_boolean', args_rule)
self.assertIn('"opt3\\"" ws ":" ws basic_string', args_rule)
# Check for alternation (|) which allows skipping optional parameters
self.assertIn(
"|",
args_rule,
f"{name} should use alternation for flexible optional ordering",
)
# Check that the pattern ends properly with closing braces
self.assertTrue(
args_rule.endswith('"}"'),
f"{name} arguments rule should end with closing brace",
)
# Validate compilation
try:
ctx = self.grammar_compiler.compile_grammar(ebnf)
self.assertIsNotNone(
ctx, f"{name} EBNF should compile successfully"
)
except RuntimeError as e:
self.fail(f"Failed to compile {name} EBNF: {e}")
def test_all_optional_parameters_ordering(self):
"""Test the behavior when ALL parameters are optional - verifies ordering constraints."""
# Create a tool with only optional parameters
all_optional_tool = Tool(
type="function",
function=Function(
name="optional_func",
description="Function with all optional parameters",
parameters={
"type": "object",
"properties": {
"opt1": {"type": "string"},
"opt2": {"type": "number"},
"opt3": {"type": "boolean"},
},
"required": [], # No required parameters
},
),
)
# Test JSON-based detectors
json_detectors = {
"deepseekv3": self.deepseekv3_detector,
"llama32": self.llama32_detector,
"mistral": self.mistral_detector,
"qwen25": self.qwen25_detector,
}
for name, detector in json_detectors.items():
with self.subTest(detector=name):
ebnf = detector.build_ebnf([all_optional_tool])
self.assertIsNotNone(ebnf, f"{name} detector should generate EBNF")
# Extract the arguments rule
lines = ebnf.split("\n")
args_rule = None
for line in lines:
if line.startswith("arguments_optional_func ::="):
args_rule = line
break
self.assertIsNotNone(
args_rule, f"{name} should have arguments_optional_func rule"
)
if args_rule:
# When all parameters are optional, the pattern now uses alternation:
# "{" ( opt1 ... | opt2 ... | opt3 ... )? "}"
# This allows flexible ordering where any optional can appear first
# Check the structure
self.assertIn('"opt1\\"" ws ":" ws basic_string', args_rule)
self.assertIn('"opt2\\"" ws ":" ws basic_number', args_rule)
self.assertIn('"opt3\\"" ws ":" ws basic_boolean', args_rule)
# The pattern SHOULD have alternation (|) for flexible ordering
self.assertIn(
"|",
args_rule,
f"{name} should use alternation for flexible ordering even when all properties are optional",
)
# Validate compilation
try:
ctx = self.grammar_compiler.compile_grammar(ebnf)
self.assertIsNotNone(
ctx, f"{name} EBNF should compile successfully"
)
except RuntimeError as e:
self.fail(f"Failed to compile {name} EBNF: {e}")
class TestBaseFormatDetector(unittest.TestCase):
"""Test buffer management and sequential tool index assignment in BaseFormatDetector."""
@@ -928,10 +480,6 @@ class TestBaseFormatDetector(unittest.TestCase):
# Not used in streaming tests
pass
def build_ebnf(self, tools):
# Not used in streaming tests
pass
self.detector = TestFormatDetector()
self.tools = [
Tool(
@@ -2339,13 +1887,11 @@ class TestJsonArrayParser(unittest.TestCase):
]
self.detector = JsonArrayParser()
def test_json_detector_ebnf(self):
"""Test that the JsonArrayParser returns NotImplementedError for EBNF."""
with self.assertRaises(NotImplementedError) as context:
self.detector.build_ebnf(self.tools)
self.assertIn(
"EBNF generation is not supported for JSON schema constraints",
str(context.exception),
def test_json_detector_has_no_ebnf(self):
"""JsonArrayParser no longer exposes EBNF generation helpers."""
self.assertFalse(
hasattr(self.detector, "build_ebnf"),
"JsonArrayParser should not expose EBNF helpers after cleanup",
)
def test_parse_streaming_increment_malformed_json(self):