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

@@ -337,30 +337,3 @@ class BaseFormatDetector(ABC):
A function that takes a tool name (str) and returns StructureInfo
"""
raise NotImplementedError()
@abstractmethod
def build_ebnf(self, tools: List[Tool]) -> str:
"""
Build an EBNF grammar for constrained generation of function calls.
This method generates an Extended Backus-Naur Form (EBNF) grammar that
constrains the model's output to valid function calls in this format.
The grammar should include all available tools and their parameter schemas.
Args:
tools: List of available tools/functions that can be called
Returns:
A string containing the EBNF grammar for this function call format
The EBNF grammar should:
- Define the overall structure of function calls in this format
- Include all tool names from the provided tools list
- Define valid JSON structures for function arguments
- Handle multiple function calls if the format supports them
Note:
Most implementations use EBNFComposer.build_ebnf() utility with
format-specific parameters rather than writing EBNF from scratch.
"""
raise NotImplementedError()

View File

@@ -11,7 +11,6 @@ from sglang.srt.function_call.core_types import (
ToolCallItem,
_GetInfoFunc,
)
from sglang.srt.function_call.ebnf_composer import EBNFComposer
from sglang.srt.function_call.utils import _is_complete_json
logger = logging.getLogger(__name__)
@@ -210,13 +209,3 @@ class DeepSeekV31Detector(BaseFormatDetector):
end="<tool▁call▁end>",
trigger="<tool▁call▁begin>" + name + "<tool▁sep>",
)
def build_ebnf(self, tools: List[Tool]):
return EBNFComposer.build_ebnf(
tools,
sequence_start_token=self.bot_token,
sequence_end_token=self.eot_token,
tool_call_separator="",
call_rule_fmt='"<tool▁call▁begin>{name}<tool▁sep>{arguments_rule}<tool▁call▁end>"',
function_format="json",
)

View File

@@ -11,7 +11,6 @@ from sglang.srt.function_call.core_types import (
ToolCallItem,
_GetInfoFunc,
)
from sglang.srt.function_call.ebnf_composer import EBNFComposer
from sglang.srt.function_call.utils import _is_complete_json
logger = logging.getLogger(__name__)
@@ -208,13 +207,3 @@ class DeepSeekV3Detector(BaseFormatDetector):
end="\n```<",
trigger=">" + name + "\n```json\n",
)
def build_ebnf(self, tools: List[Tool]):
return EBNFComposer.build_ebnf(
tools,
sequence_start_token=self.bot_token,
sequence_end_token=self.eot_token,
tool_call_separator="",
call_rule_fmt='"<tool▁call▁begin>function<tool▁sep>{name}\\n```json\\n"{arguments_rule}"\\n```<tool▁call▁end>"',
function_format="json",
)

View File

@@ -1,344 +0,0 @@
from typing import Any, Dict, Literal, Optional
class EBNFComposer:
# Adapted from https://xgrammar.mlc.ai/docs/how_to/ebnf_guided_generation.html#try-out-via-hf-transformers
# Shared primitive grammar rules used across all formats
BASE_PRIMITIVE_GRAMMAR = r"""
basic_string ::= (([\"] basic_string_1 [\"]))
basic_string_1 ::= "" | [^"\\\x00-\x1F] basic_string_1 | "\\" escape basic_string_1
escape ::= ["\\/bfnrt] | "u" [A-Fa-f0-9]{4}
basic_integer ::= ("0" | "-"? [1-9] [0-9]*) ".0"?
basic_number ::= ("0" | "-"? [1-9] [0-9]*) ("." [0-9]+)? ([eE] [+-]? [0-9]+)?
basic_array ::= "[" ("" | ws basic_any (ws "," ws basic_any)*) ws "]"
basic_object ::= "{" ("" | ws basic_string ws ":" ws basic_any ( ws "," ws basic_string ws ":" ws basic_any)*) ws "}"
ws ::= [ \n\t]*
"""
# Format-specific extensions
json_grammar_ebnf_str = (
r"""
json ::= basic_array | basic_object
basic_any ::= basic_number | basic_string | basic_boolean | basic_null | basic_array | basic_object
basic_boolean ::= "true" | "false"
basic_null ::= "null"
"""
+ BASE_PRIMITIVE_GRAMMAR
)
pythonic_grammar_ebnf_str = (
r"""
pythonic ::= basic_number | basic_string | basic_array | "True" | "False" | "None"
basic_any ::= basic_number | basic_string | basic_array | basic_object
basic_boolean ::= "True" | "False"
basic_null ::= "None"
"""
+ BASE_PRIMITIVE_GRAMMAR
)
xml_grammar_ebnf_str = (
r"""
xml ::= xml_element | xml_text
xml_element ::= basic_string | basic_number | basic_boolean | basic_null | basic_array | basic_object
xml_text ::= [^<>]*
basic_any ::= basic_number | basic_string | basic_boolean | basic_null | basic_array | basic_object
basic_boolean ::= "true" | "false"
basic_null ::= "null"
"""
+ BASE_PRIMITIVE_GRAMMAR
)
CALL_RULE_MAP = {
"pythonic": 'call_{name} ::= "{name}" "(" {arguments_rule} ")"',
"json": 'call_{name} ::= "{{" ws "\\"name\\"" ws ":" ws "\\"{name}\\"" ws "," ws "\\"arguments\\"" ws ":" ws {arguments_rule} ws "}}"',
"xml": 'call_{name} ::= "<function={name}>\\n" {arguments_rule} "\\n</function>"',
}
ARGUMENTS_RULE_MAP = {
"pythonic": "{arg_rules}",
"json": '"{{" ws {arg_rules} ws "}}"',
"xml": "{arg_rules}",
}
KEY_VALUE_RULE_MAP = {
"pythonic": '"{key}" "=" {valrule}',
"json": '"\\"{key}\\"" ws ":" ws {valrule}',
"xml": '"<parameter={key}>\\n" {valrule} "\\n</parameter>"',
}
# Base type mapping - most types are the same across formats
BASE_TYPE_MAPPING = {
"string": "basic_string",
"number": "basic_number",
"integer": "basic_number",
"boolean": "basic_boolean",
"null": "basic_null",
"array": "basic_array",
"object": "basic_object",
}
# Format-specific overrides for types that differ
FORMAT_TYPE_OVERRIDES = {
"pythonic": {
"boolean": '"True" | "False"',
"null": '"None"',
},
"xml": {
"string": "xml_text",
},
}
@staticmethod
def get_value_rule(
prop: dict, function_format: Literal["pythonic", "json", "xml"] = "json"
) -> str:
if "enum" in prop:
return EBNFComposer._handle_enum(prop, function_format)
if "type" in prop:
return EBNFComposer._handle_type(prop, function_format)
return function_format
@staticmethod
def _handle_enum(prop: dict, function_format: str) -> str:
"""Handle enum properties by formatting each value according to type and format."""
enum_values = prop["enum"]
prop_type = prop.get("type", "string")
def format_enum_val(v: Any) -> str:
if prop_type == "boolean":
if function_format == "json" or function_format == "xml":
return "true" if v else "false"
elif function_format == "pythonic":
return "True" if v else "False"
else:
return str(v) # fallback
if prop_type == "string":
if function_format == "xml":
return f'"{v}"'
else: # json or pythonic
return f'"\\"{v}\\""' # escape quote-wrapped string
# All other types (number, integer, etc.)
return str(v)
formatted_values = [format_enum_val(v) for v in enum_values]
enum_rule = " | ".join(formatted_values)
return f"({enum_rule})" if len(formatted_values) > 1 else enum_rule
@staticmethod
def get_type_mapping(function_format: str) -> Dict[str, str]:
"""Get the complete type mapping for a given format."""
mapping = EBNFComposer.BASE_TYPE_MAPPING.copy()
overrides = EBNFComposer.FORMAT_TYPE_OVERRIDES.get(function_format, {})
mapping.update({k: v for k, v in overrides.items() if v is not None})
return mapping
@staticmethod
def _handle_type(prop: dict, function_format: str) -> str:
"""Handle type properties using the appropriate type mapping."""
prop_type = prop["type"]
type_mapping = EBNFComposer.get_type_mapping(function_format)
if isinstance(prop_type, list):
type_rules = [
type_mapping.get(single_type, function_format)
for single_type in prop_type
]
return " | ".join(type_rules) if type_rules else function_format
return type_mapping.get(prop_type, function_format)
@staticmethod
def build_ebnf(
tools,
function_format: Literal["pythonic", "json", "xml"] = "json",
# Parameters for wrapping the entire sequence of tool calls
sequence_start_token: Optional[str] = None,
sequence_end_token: Optional[str] = None,
# Parameters for wrapping individual tool calls
individual_call_start_token: Optional[str] = None,
individual_call_end_token: Optional[str] = None,
# Parameter for separating multiple tool calls
tool_call_separator: Optional[str] = None,
call_rule_fmt: Optional[str] = None,
key_value_rule_fmt: Optional[str] = None,
key_value_separator: str = 'ws "," ws',
):
"""
Generalized EBNF builder for all detectors.
Args:
tools: List of Tool objects to generate EBNF grammar for
function_format: The format of function calls, either "pythonic" or "json"
sequence_start_token: Token that wraps the entire sequence of tool calls (start)
sequence_end_token: Token that wraps the entire sequence of tool calls (end)
individual_call_start_token: Token that wraps each individual tool call (start)
individual_call_end_token: Token that wraps each individual tool call (end)
tool_call_separator: The separator between multiple tool calls
call_rule_fmt: Optional custom format string for call_{name} rule. It should define each function call's format, with
the placeholders {name} for the function name and {arguments_rule} for the arguments rule. If None, a default
format based on function_format will be used.
key_value_rule_fmt: Optional custom format string for key-value pairs. It should define how each parameter is formatted,
with placeholders {key} for the parameter name and {valrule} for the value rule. If None, a default format
based on function_format will be used.
key_value_separator: Raw EBNF fragment inserted between key-value pairs.
This string is used verbatim (not auto-quoted). Pass:
- Quoted terminals when you need a literal token (e.g. '","' or '"\\n"').
- Raw/non-terminals when you need grammar tokens (e.g. 'ws "," ws').
"""
# =================================================================
# Step 1: Determine the root tool calls rule
# =================================================================
# Handle a single function call
if individual_call_start_token and individual_call_end_token:
function_call_unit = f'"{individual_call_start_token}" function_call "{individual_call_end_token}"'
else:
function_call_unit = "function_call"
# Handle multiple function calls with separators
if tool_call_separator is not None:
base_pattern = f'{function_call_unit} ( "{tool_call_separator}" {function_call_unit} )*'
else:
# Assume only support single function call
base_pattern = function_call_unit
# Apply sequence-level wrapping if needed
if sequence_start_token and sequence_end_token:
root_rule = (
f'"{sequence_start_token}" {base_pattern} "{sequence_end_token}"'
)
else:
root_rule = base_pattern
# =================================================================
# Step 2: Build the header rules
# =================================================================
ebnf_lines = [
f"root ::= {root_rule}",
"function_call ::= "
+ " | ".join([f"call_{tool.function.name}" for tool in tools]),
]
# =================================================================
# Step 3: Set up formatting templates
# =================================================================
call_template = (
f"call_{{name}} ::= {call_rule_fmt}"
if call_rule_fmt
else EBNFComposer.CALL_RULE_MAP[function_format]
)
args_template = EBNFComposer.ARGUMENTS_RULE_MAP[function_format]
key_value_template = (
key_value_rule_fmt
if key_value_rule_fmt
else EBNFComposer.KEY_VALUE_RULE_MAP[function_format]
)
# =================================================================
# Step 4: Build rules for each tool
# =================================================================
for tool in tools:
tool_name = tool.function.name
params = tool.function.parameters or {}
properties = params.get("properties", {})
required_props = set(params.get("required", []))
# The generated pattern ensures:
# 1. Required properties appear first, joined by commas
# 2. Optional properties are wrapped with comma included: ( "," ( "prop" : value )? )?
# 3. For multiple optional properties, we allow flexible ordering:
# - Each optional can be skipped entirely
# - They can appear in any combination
#
# Example patterns generated:
# - One required, one optional:
# "{" "location" ":" string ( "," ( "unit" ":" enum ) )? "}"
# Allows: {"location": "Paris"} or {"location": "Paris", "unit": "celsius"}
#
# - Multiple optional properties with flexible ordering:
# "{" "req" ":" string ( "," ( "opt1" ":" value ( "," "opt2" ":" value )? | "opt2" ":" value ) )? "}"
# Allows: {"req": "x"}, {"req": "x", "opt1": "y"}, {"req": "x", "opt2": "z"},
# {"req": "x", "opt1": "y", "opt2": "z"}
#
# - All optional properties with flexible ordering:
# "{" ( "opt1" ":" value ( "," "opt2" ":" value )? | "opt2" ":" value )? "}"
# Allows: {}, {"opt1": "x"}, {"opt2": "y"}, {"opt1": "x", "opt2": "y"}
prop_kv_pairs = {}
ordered_props = list(properties.keys())
for prop_name, prop_schema in properties.items():
value_rule = EBNFComposer.get_value_rule(prop_schema, function_format)
# Create key=value pair
pair = key_value_template.format(key=prop_name, valrule=value_rule)
prop_kv_pairs[prop_name] = pair
# Separate into required and optional while preserving order
required = [p for p in ordered_props if p in required_props]
optional = [p for p in ordered_props if p not in required_props]
# Build the combined rule
rule_parts = []
# Add required properties joined by commas
if required:
rule_parts.append(
f" {key_value_separator} ".join(prop_kv_pairs[k] for k in required)
)
# Add optional properties with flexible ordering
if optional:
# Build alternatives where any optional property can appear first
opt_alternatives = []
for i in range(len(optional)):
# Build pattern for optional[i] appearing first
opt_parts = []
for j in range(i, len(optional)):
if j == i:
opt_parts.append(prop_kv_pairs[optional[j]])
else:
opt_parts.append(
f" ( {key_value_separator} {prop_kv_pairs[optional[j]]} )?"
)
opt_alternatives.append("".join(opt_parts))
# Wrap with appropriate comma handling based on whether we have required properties
if required:
# Required properties exist, so optional group needs outer comma
rule_parts.append(f" ( {key_value_separator} ( ")
rule_parts.append(" | ".join(opt_alternatives))
rule_parts.append(" ) )?")
else:
# All properties are optional
rule_parts.append("( ")
rule_parts.append(" | ".join(opt_alternatives))
rule_parts.append(" )?")
combined_args = "".join(rule_parts)
arguments_rule = args_template.format(arg_rules=combined_args)
arguments_rule = arguments_rule or '""'
# Add the function call rule and its arguments rule
ebnf_lines.append(
call_template.format(
name=tool_name, arguments_rule=f"arguments_{tool_name}"
)
)
ebnf_lines.append(f"arguments_{tool_name} ::= {arguments_rule}")
# =================================================================
# Step 5: Add base grammar rules
# =================================================================
grammar_dict = {
"pythonic": EBNFComposer.pythonic_grammar_ebnf_str,
"json": EBNFComposer.json_grammar_ebnf_str,
"xml": EBNFComposer.xml_grammar_ebnf_str,
}
base_grammar = grammar_dict.get(
function_format, EBNFComposer.json_grammar_ebnf_str
)
ebnf_lines.append(base_grammar)
return "\n".join(ebnf_lines)

View File

@@ -195,41 +195,3 @@ class FunctionCallParser:
elif tool_choice == "required" or isinstance(tool_choice, ToolChoice):
json_schema = get_json_schema_constraint(self.tools, tool_choice)
return ("json_schema", json_schema)
def get_ebnf(
self, tool_choice: Union[ToolChoice, Literal["required"]]
) -> Optional[str]:
"""
Get the EBNF grammar for the specified tool choice.
Args:
tool_choice: The tool choice specification
Returns:
EBNF grammar string, or None if no valid tools found
Note:
If a specific function is requested but not found in available tools,
logs a warning and falls back to using all available tools for backward compatibility.
"""
filtered_tools = []
if isinstance(tool_choice, ToolChoice):
fn_name = tool_choice.function.name
filtered_tools = [t for t in self.tools if t.function.name == fn_name]
# Check if the requested function exists in available tools
if not filtered_tools:
available_functions = [t.function.name for t in self.tools]
logger.warning(
f"Function '{fn_name}' not found in available tools. "
f"Available functions: {available_functions}. "
f"Skipping tool choice."
)
# TODO: Return a 400 error instead of warning when adapter supports proper error handling
# For now, fall back to return None
return None
else:
filtered_tools = self.tools
return self.detector.build_ebnf(filtered_tools)

View File

@@ -7,7 +7,6 @@ from typing import List
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import StreamingParseResult, _GetInfoFunc
from sglang.srt.function_call.ebnf_composer import EBNFComposer
logger = logging.getLogger(__name__)
@@ -157,15 +156,3 @@ class Glm4MoeDetector(BaseFormatDetector):
def structure_info(self) -> _GetInfoFunc:
raise NotImplementedError()
def build_ebnf(self, tools: List[Tool]):
return EBNFComposer.build_ebnf(
tools,
individual_call_start_token=self.bot_token,
individual_call_end_token=self.eot_token,
tool_call_separator="\\n",
function_format="xml",
call_rule_fmt='"{name}" "\\n" ( {arguments_rule} "\\n" )?',
key_value_rule_fmt='"<arg_key>{key}</arg_key>" "\\n" "<arg_value>" {valrule} "</arg_value>"',
key_value_separator='"\\n"',
)

View File

@@ -239,6 +239,3 @@ class GptOssDetector(BaseFormatDetector):
def structure_info(self) -> _GetInfoFunc:
raise NotImplementedError("structure_info not used with HarmonyParser")
def build_ebnf(self, tools: List[Tool]) -> str:
raise NotImplementedError("build_ebnf not used with HarmonyParser")

View File

@@ -34,16 +34,6 @@ class JsonArrayParser(BaseFormatDetector):
"Detect and parse not supported for JSON schema constraints."
)
def build_ebnf(self, tools: List[Tool]) -> str:
"""
Build an EBNF grammar for constrained generation.
This is not used for JSON schema constraints as they are handled
by the constraint backends directly.
"""
raise NotImplementedError(
"EBNF generation is not supported for JSON schema constraints."
)
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:

View File

@@ -11,7 +11,6 @@ from sglang.srt.function_call.core_types import (
ToolCallItem,
_GetInfoFunc,
)
from sglang.srt.function_call.ebnf_composer import EBNFComposer
from sglang.srt.function_call.utils import _is_complete_json
logger = logging.getLogger(__name__)
@@ -238,21 +237,3 @@ class KimiK2Detector(BaseFormatDetector):
)
return get_info
def build_ebnf(self, tools: List[Tool]) -> str:
"""
Build EBNF grammar for KimiK2 tool call format.
NOTE: The call_rule_fmt uses [0-9]+ for the function index to allow the grammar
to accept any numeric index (0, 1, 2, etc.) for proper sequential indexing in
multiple function call scenarios, while still maintaining the correct KimiK2
format structure for constrained generation.
"""
return EBNFComposer.build_ebnf(
tools,
sequence_start_token=self.bot_token,
sequence_end_token=self.eot_token,
tool_call_separator="",
call_rule_fmt='"<|tool_call_begin|>functions.{name}:"[0-9]+"<|tool_call_argument_begin|>"{arguments_rule}"<|tool_call_end|>"',
function_format="json",
)

View File

@@ -9,7 +9,6 @@ from sglang.srt.function_call.core_types import (
StructureInfo,
_GetInfoFunc,
)
from sglang.srt.function_call.ebnf_composer import EBNFComposer
logger = logging.getLogger(__name__)
@@ -87,10 +86,3 @@ class Llama32Detector(BaseFormatDetector):
end="}",
trigger="<|python_tag|>",
)
def build_ebnf(self, tools: List[Tool]):
return EBNFComposer.build_ebnf(
tools,
function_format="json",
tool_call_separator=self.tool_call_separator,
)

View File

@@ -12,7 +12,6 @@ from sglang.srt.function_call.core_types import (
ToolCallItem,
_GetInfoFunc,
)
from sglang.srt.function_call.ebnf_composer import EBNFComposer
logger = logging.getLogger(__name__)
@@ -353,15 +352,3 @@ class MinimaxM2Detector(BaseFormatDetector):
def structure_info(self) -> _GetInfoFunc:
raise NotImplementedError
def build_ebnf(self, tools: List[Tool]):
return EBNFComposer.build_ebnf(
tools,
individual_call_start_token=self.tool_call_start_token.replace("\n", "\\n"),
individual_call_end_token=self.tool_call_end_token.replace("\n", "\\n"),
tool_call_separator="\\n",
function_format="xml",
call_rule_fmt='"<invoke name=\\"{name}\\">\\n" {arguments_rule} "\\n</invoke>"',
key_value_rule_fmt='"<parameter name=\\"{key}\\">\\n" {valrule} "\\n</parameter>"',
key_value_separator='"\\n"',
)

View File

@@ -10,7 +10,6 @@ from sglang.srt.function_call.core_types import (
StructureInfo,
_GetInfoFunc,
)
from sglang.srt.function_call.ebnf_composer import EBNFComposer
logger = logging.getLogger(__name__)
@@ -128,12 +127,3 @@ class MistralDetector(BaseFormatDetector):
end="}]",
trigger="[TOOL_CALLS]",
)
def build_ebnf(self, tools: List[Tool]):
return EBNFComposer.build_ebnf(
tools,
sequence_start_token=self.bot_token,
sequence_end_token=self.eot_token,
function_format="json",
tool_call_separator=self.tool_call_separator,
)

View File

@@ -2,7 +2,7 @@ import ast
import json
import logging
import re
from typing import List, Optional
from typing import List
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.environ import envs
@@ -12,7 +12,6 @@ from sglang.srt.function_call.core_types import (
ToolCallItem,
_GetInfoFunc,
)
from sglang.srt.function_call.ebnf_composer import EBNFComposer
logger = logging.getLogger(__name__)
@@ -223,12 +222,3 @@ class PythonicDetector(BaseFormatDetector):
def structure_info(self) -> _GetInfoFunc:
raise NotImplementedError
def build_ebnf(self, tools: List[Tool]) -> Optional[str]:
return EBNFComposer.build_ebnf(
tools,
sequence_start_token="[",
sequence_end_token="]",
tool_call_separator=",",
function_format="pythonic",
)

View File

@@ -10,7 +10,6 @@ from sglang.srt.function_call.core_types import (
StructureInfo,
_GetInfoFunc,
)
from sglang.srt.function_call.ebnf_composer import EBNFComposer
logger = logging.getLogger(__name__)
@@ -119,12 +118,3 @@ class Qwen25Detector(BaseFormatDetector):
end="}\n</tool_call>",
trigger="<tool_call>",
)
def build_ebnf(self, tools: List[Tool]):
return EBNFComposer.build_ebnf(
tools,
individual_call_start_token=self.bot_token.replace("\n", "\\n"),
individual_call_end_token=self.eot_token.replace("\n", "\\n"),
tool_call_separator="\\n",
function_format="json",
)

View File

@@ -13,7 +13,6 @@ from sglang.srt.function_call.core_types import (
ToolCallItem,
_GetInfoFunc,
)
from sglang.srt.function_call.ebnf_composer import EBNFComposer
logger = logging.getLogger(__name__)
@@ -352,15 +351,3 @@ class Qwen3CoderDetector(BaseFormatDetector):
def structure_info(self) -> _GetInfoFunc:
raise NotImplementedError
def build_ebnf(self, tools: List[Tool]):
return EBNFComposer.build_ebnf(
tools,
individual_call_start_token=self.tool_call_start_token.replace("\n", "\\n"),
individual_call_end_token=self.tool_call_end_token.replace("\n", "\\n"),
tool_call_separator="\\n",
function_format="xml",
call_rule_fmt='"<function={name}>\\n" {arguments_rule} "\\n</function>"',
key_value_rule_fmt='"<parameter={key}>\\n" {valrule} "\\n</parameter>"',
key_value_separator='"\\n"',
)

View File

@@ -11,7 +11,6 @@ from sglang.srt.function_call.core_types import (
ToolCallItem,
_GetInfoFunc,
)
from sglang.srt.function_call.ebnf_composer import EBNFComposer
logger = logging.getLogger(__name__)
@@ -406,31 +405,3 @@ class Step3Detector(BaseFormatDetector):
def structure_info(self) -> _GetInfoFunc:
raise NotImplementedError()
def build_ebnf(self, tools: List[Tool]) -> str:
"""
Build EBNF grammar for Step3 tool call format.
"""
# Custom call rule for steptml format
call_rule_fmt = (
'"function" "<tool_sep>" "<steptml:invoke name=\\"{name}\\">" '
'{arguments_rule} "</steptml:invoke>"'
)
# Custom key-value rule for steptml parameters
key_value_rule_fmt = (
'"<steptml:parameter name=\\"{key}\\">" {valrule} "</steptml:parameter>"'
)
return EBNFComposer.build_ebnf(
tools,
sequence_start_token=self.bot_token,
sequence_end_token=self.eot_token,
individual_call_start_token=self.tool_call_begin,
individual_call_end_token=self.tool_call_end,
tool_call_separator="",
function_format="xml",
call_rule_fmt=call_rule_fmt,
key_value_rule_fmt=key_value_rule_fmt,
key_value_separator="",
)

View File

@@ -222,58 +222,6 @@ class TestJsonSchemaConstraint(unittest.TestCase):
{"type": "object", "properties": {}},
)
def test_json_schema_vs_ebnf_constraint_generation(self):
"""Test direct comparison between JSON schema and EBNF constraint generation"""
# Test with specific tool choice
tool_choice = ToolChoice(
type="function", function=ToolChoiceFuncName(name="get_weather")
)
# Generate JSON schema constraint
json_schema = get_json_schema_constraint(self.tools, tool_choice)
self.assertIsNotNone(json_schema)
jsonschema.Draft202012Validator.check_schema(json_schema)
# Generate EBNF constraint using FunctionCallParser
parser = FunctionCallParser(
self.tools, "llama3"
) # Use a parser that supports EBNF
ebnf_constraint = parser.get_ebnf(tool_choice)
# Verify JSON schema constraint
self.assertEqual(json_schema["type"], "array")
self.assertEqual(json_schema["minItems"], 1)
self.assertEqual(json_schema["maxItems"], 1)
# Verify EBNF constraint
self.assertIsNotNone(ebnf_constraint)
self.assertIsInstance(ebnf_constraint, str)
self.assertIn("get_weather", ebnf_constraint)
# Test with required tool choice
required_json_schema = get_json_schema_constraint(self.tools, "required")
self.assertIsNotNone(required_json_schema)
jsonschema.Draft202012Validator.check_schema(required_json_schema)
required_ebnf_constraint = parser.get_ebnf("required")
# Verify required JSON schema constraint
self.assertEqual(required_json_schema["type"], "array")
self.assertEqual(required_json_schema["minItems"], 1)
self.assertIn("anyOf", required_json_schema["items"])
# Verify required EBNF constraint
self.assertIsNotNone(required_ebnf_constraint)
self.assertIsInstance(required_ebnf_constraint, str)
# Both should contain references to the available tools
tool_names = [tool.function.name for tool in self.tools]
for tool_name in tool_names:
self.assertIn(tool_name, required_ebnf_constraint)
def test_conflicting_defs_raises_valueerror(self):
"""Test that conflicting tool definitions raise ValueError with proper message"""
tools_with_conflicting_defs = [

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):