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
@@ -87,6 +87,7 @@ def _make_req(
req.sampling_params.json_schema = json_schema
req.sampling_params.regex = regex
req.sampling_params.ebnf = ebnf
req.sampling_params.ebnf_full_assistant = False
req.sampling_params.structural_tag = structural_tag
req.sampling_params.custom_params = custom_params
req.require_reasoning = False
@@ -201,11 +202,16 @@ class TestProcessReqWithGrammar(unittest.TestCase):
future = Future()
mgr.grammar_backend.get_cached_or_future_value.return_value = (future, False)
req = _make_req(ebnf="root ::= 'hello'")
result = mgr.process_req_with_grammar(req)
self.assertTrue(result)
self.assertEqual(req.grammar_key, ("ebnf", "root ::= 'hello'"))
for full_assistant, key_type in (
(False, "ebnf"),
(True, "full_assistant_ebnf"),
):
with self.subTest(full_assistant=full_assistant):
req = _make_req(ebnf='root ::= "hello"')
req.sampling_params.ebnf_full_assistant = full_assistant
result = mgr.process_req_with_grammar(req)
self.assertTrue(result)
self.assertEqual(req.grammar_key, (key_type, 'root ::= "hello"'))
def test_structural_tag_cache_miss(self):
mgr = self._make_mgr()
@@ -244,13 +244,21 @@ class TestReasonerGrammarBackend(unittest.TestCase):
enable_strict_thinking=True,
)
wrapped = reasoner._init_value_dispatch(("json", "{}"), reasoning=True)
self.assertIsInstance(wrapped, ReasonerGrammarObject)
wrapped.accept_token(10)
inner_grammar.accept_token.assert_not_called()
wrapped.accept_token(2)
wrapped.accept_token(42)
inner_grammar.accept_token.assert_called_once_with(42)
for key in (("json", "{}"), ("ebnf", 'root ::= "OK"')):
with self.subTest(key=key):
inner_grammar.reset_mock()
wrapped = reasoner._init_value_dispatch(key, reasoning=True)
self.assertIsInstance(wrapped, ReasonerGrammarObject)
wrapped.accept_token(10)
inner_grammar.accept_token.assert_not_called()
wrapped.accept_token(2)
wrapped.accept_token(42)
inner_grammar.accept_token.assert_called_once_with(42)
bare = reasoner._init_value_dispatch(
("full_assistant_ebnf", 'root ::= "OK"'), reasoning=True
)
self.assertIs(bare, inner_grammar)
def test_accepts_multi_token_think_start_marker(self):
"""think_start_token can be multi-token (e.g., GPT-OSS) since it's not used."""
@@ -13,6 +13,7 @@
# ==============================================================================
"""Tests for OpenAI API protocol models"""
import json
import unittest
from typing import List, Optional
@@ -116,6 +117,31 @@ class TestCompletionRequest(unittest.TestCase):
class TestChatCompletionRequest(unittest.TestCase):
"""Test ChatCompletionRequest protocol model"""
def test_full_assistant_ebnf_preserves_explicit_output_constraints(self):
constraint = ("full_assistant_ebnf", 'root ::= "generated"')
for explicit in (
{},
{"ebnf": 'root ::= "OK"'},
{"response_format": {"type": "json_object"}},
):
with self.subTest(explicit=explicit):
request = ChatCompletionRequest(
model="test",
messages=[{"role": "user", "content": "Hi"}],
tool_choice="required",
**explicit,
)
params = request.to_sampling_params([], {}, constraint)
self.assertEqual(params.get("ebnf_full_assistant", False), not explicit)
if "ebnf" in explicit:
self.assertEqual(params["ebnf"], explicit["ebnf"])
elif "response_format" in explicit:
self.assertEqual(
json.loads(params["json_schema"]), {"type": "object"}
)
else:
self.assertEqual(params["ebnf"], constraint[1])
def test_json_schema_strict_requires_json_boolean(self):
base_request = {
"model": "test-model",
@@ -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 = [