Remove EBNF Composer (#13163)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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"',
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"',
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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"',
|
||||
)
|
||||
|
||||
@@ -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="",
|
||||
)
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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