Support non-strict GLM47 tool calls with EBNF constraints (#38890)

Co-authored-by: Xinyuan Tong <xinyuantong.cs@gmail.com>
This commit is contained in:
Yuxuan Zhang
2026-09-15 16:41:48 +08:00
committed by GitHub
co-authored by Xinyuan Tong
parent 2c0a70960c
commit 17ba2c2e7c
14 changed files with 834 additions and 22 deletions
@@ -3,6 +3,8 @@ import json
import unittest
import warnings
import xgrammar as xgr
from sglang.srt.entrypoints.openai.protocol import (
Function,
Tool,
@@ -14,6 +16,7 @@ from sglang.srt.function_call.core_types import StreamingParseResult
from sglang.srt.function_call.deepseekv3_detector import DeepSeekV3Detector
from sglang.srt.function_call.deepseekv4_detector import DeepSeekV4Detector
from sglang.srt.function_call.deepseekv32_detector import DeepSeekV32Detector
from sglang.srt.function_call.function_call_parser import FunctionCallParser
from sglang.srt.function_call.gemma4_detector import (
Gemma4Detector,
_parse_gemma4_args,
@@ -3671,6 +3674,19 @@ class TestGlm47MoeDetector(unittest.TestCase):
self.assertIsNone(self.detector.get_structural_tag(self.tools))
parser = FunctionCallParser(self.tools, "glm47")
self.assertEqual(
"full_assistant_ebnf",
parser.get_structure_constraint("required")[0],
)
strict_tools = [
tool.model_copy(
update={
"function": tool.function.model_copy(update={"strict": True})
}
)
for tool in self.tools
]
parser = FunctionCallParser(strict_tools, "glm47")
constraint = parser.get_structure_constraint("required")
self.assertIsNotNone(constraint)
@@ -3678,6 +3694,181 @@ class TestGlm47MoeDetector(unittest.TestCase):
_glm47_native_structural_tag_available.cache_clear()
class TestGlm47FullAssistantGrammar(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.compiler = xgr.GrammarCompiler(
xgr.TokenizerInfo(
[bytes([i]) for i in range(256)], vocab_type=xgr.VocabType.RAW
),
max_threads=1,
)
def _compile(self, parameters=None, choice="auto", parallel=True, thinking=False):
tools = [
Tool(type="function", function=Function(name=name, parameters=parameters))
for name in ("alpha", "beta")
]
parser = FunctionCallParser(tools, "glm47")
constraint = parser.get_structure_constraint(
choice, parallel_tool_calls=parallel, thinking_mode=thinking
)
self.assertIsNotNone(constraint)
return self.compiler.compile_grammar(xgr.Grammar.from_ebnf(constraint[1]))
def _accepts(self, grammar, text):
matcher = xgr.GrammarMatcher(grammar)
return matcher.accept_string(text) and matcher.is_completed()
def test_tool_choice_and_parallel_calls(self):
alpha = "<tool_call>alpha</tool_call>"
beta = "<tool_call>beta</tool_call>"
named = ToolChoice(function=ToolChoiceFuncName(name="alpha"))
for thinking in (False, True):
prefix = "analysis</think>" if thinking else ""
for parallel in (False, True):
for choice in ("auto", "required", named, "none"):
with self.subTest(
thinking=thinking, parallel=parallel, choice=choice
):
grammar = self._compile(
choice=choice, parallel=parallel, thinking=thinking
)
self.assertEqual(
self._accepts(grammar, prefix + "Hello"),
choice in ("auto", "none"),
)
self.assertEqual(
self._accepts(grammar, prefix + alpha), choice != "none"
)
self.assertEqual(
self._accepts(grammar, prefix + beta),
choice in ("auto", "required"),
)
self.assertEqual(
self._accepts(grammar, prefix + alpha * 2),
parallel and choice != "none",
)
def test_enum_json_types_and_boolean_schemas(self):
cases = [
({"enum": [1, 2]}, ["1", "2"], ["3"]),
({"enum": [True, False]}, ["true", "false"], ["True", "1"]),
(
{"type": ["string", "null"], "enum": ["ok", None]},
["ok", "null"],
["None", "bad"],
),
({"enum": [{"x": 1}, [True, None]]}, ['{"x": 1}', "[true, null]"], ["{}"]),
(True, ["anything"], []),
(False, ["anything"], []),
]
for schema, accepted, rejected in cases:
with self.subTest(schema=schema):
grammar = self._compile({"properties": {"p": schema}})
for values, expected in ((accepted, True), (rejected, False)):
for value in values:
text = f"<tool_call>alpha<arg_key>p</arg_key><arg_value>{value}</arg_value></tool_call>"
self.assertEqual(self._accepts(grammar, text), expected, text)
def test_composed_and_unresolved_schemas_allow_arguments(self):
schemas = [
{keyword: [{"properties": {"city": {"type": "string"}}}]}
for keyword in ("allOf", "anyOf", "oneOf")
]
schemas += [
{
"properties": {"country": {"type": "string"}},
"allOf": [{"properties": {"city": {"type": "string"}}}],
},
{
"$ref": "#/$defs/args",
"$defs": {"args": {"properties": {"city": {"type": "string"}}}},
},
{
"anyOf": [
{"properties": {"city": {"enum": [1]}}},
{"properties": {"city": {"enum": ["Paris"]}}},
]
},
]
for schema in schemas:
with self.subTest(schema=schema):
grammar = self._compile(schema)
arg = "<arg_key>city</arg_key><arg_value>Paris</arg_value>"
self.assertTrue(
self._accepts(grammar, f"<tool_call>alpha{arg}</tool_call>")
)
self.assertTrue(
self._accepts(grammar, f"<tool_call>alpha{arg}{arg}</tool_call>")
)
self.assertTrue(self._accepts(grammar, "<tool_call>alpha</tool_call>"))
def test_incomplete_composition_branches_allow_arguments(self):
city = {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
"additionalProperties": False,
}
country = {
"type": "object",
"properties": {"country": {"type": "string"}},
"required": ["country"],
"additionalProperties": False,
}
branches = [
{"$ref": "#/$defs/by_country"},
{"patternProperties": {"^country$": {"type": "string"}}},
{"properties": {"region": {"type": "string"}}, "allOf": [country]},
{"additionalProperties": {"type": "string"}},
True,
{},
{"properties": {}},
]
text = "<tool_call>alpha<arg_key>country</arg_key><arg_value>France</arg_value></tool_call>"
for branch in branches:
for nested in (False, True):
with self.subTest(branch=branch, nested=nested):
schema = {
"type": "object",
"anyOf": [
city,
{"allOf": [{"oneOf": [branch]}]} if nested else branch,
],
"$defs": {"by_country": country},
}
grammar = self._compile(schema, choice="required", parallel=False)
self.assertTrue(self._accepts(grammar, text))
self.assertFalse(
self._accepts(grammar, text.replace("</arg_key>", ""))
)
self.assertFalse(self._accepts(grammar, text + text))
def test_complete_compositions_restrict_argument_names(self):
schema = {
"allOf": [
{"properties": {"city": {"type": "string"}}},
{
"anyOf": [
{"oneOf": [{"properties": {"country": {"type": "string"}}}]}
]
},
]
}
grammar = self._compile(schema, choice="required", parallel=False)
for key, accepted in (("city", True), ("country", True), ("unknown", False)):
text = f"<tool_call>alpha<arg_key>{key}</arg_key><arg_value>Paris</arg_value></tool_call>"
self.assertEqual(self._accepts(grammar, text), accepted)
def test_escaped_property_names(self):
for key in ['a"b', "path\\name", "line\nbreak", "tab\tkey", "control\x01key"]:
with self.subTest(key=key):
grammar = self._compile({"properties": {key: {"type": "string"}}})
text = f"<tool_call>alpha<arg_key>{key}</arg_key><arg_value>v</arg_value></tool_call>"
self.assertTrue(self._accepts(grammar, text))
class TestLing3Detector(unittest.TestCase):
def setUp(self):
self.tools = [