Remove EBNF Composer (#13163)
This commit is contained in:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user