From f79a6b5c3355a8d67b821a3401585f8f9e935316 Mon Sep 17 00:00:00 2001 From: Yuxuan Zhang Date: Sun, 14 Jun 2026 14:57:07 +0800 Subject: [PATCH] Support GLM-4.7 function calling via structural tags (#28149) Co-authored-by: Xinyuan Tong <115166877+JustinTong0323@users.noreply.github.com> Co-authored-by: Xinyuan Tong --- .../srt/function_call/glm47_moe_detector.py | 45 +++++++++++-- .../test_function_call_parser.py | 63 +++++++++++++++++++ 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/python/sglang/srt/function_call/glm47_moe_detector.py b/python/sglang/srt/function_call/glm47_moe_detector.py index 9bc11d770..d032ed643 100644 --- a/python/sglang/srt/function_call/glm47_moe_detector.py +++ b/python/sglang/srt/function_call/glm47_moe_detector.py @@ -3,10 +3,15 @@ import json import logging import re from enum import Enum -from typing import Any, Dict, List, Optional, Tuple +from functools import lru_cache +from typing import Any, Dict, List, Literal, Optional, Tuple, Union -from sglang.srt.entrypoints.openai.protocol import Tool -from sglang.srt.function_call.base_format_detector import BaseFormatDetector +from sglang.srt.entrypoints.openai.protocol import Tool, ToolChoice +from sglang.srt.function_call.base_format_detector import ( + BaseFormatDetector, + StructuralTag, + get_model_structural_tag, +) from sglang.srt.function_call.core_types import ( StreamingParseResult, ToolCallItem, @@ -17,6 +22,21 @@ from sglang.srt.function_call.utils import infer_type_from_json_schema logger = logging.getLogger(__name__) +@lru_cache(maxsize=1) +def _glm47_native_structural_tag_available() -> bool: + # "glm_4_7" is only registered in newer xgrammar, so the import can succeed + # while the model name stays unknown. Probe once and fall back if absent. + if get_model_structural_tag is None: + return False + try: + get_model_structural_tag( + model="glm_4_7", tools=[], tool_choice="auto", reasoning=False + ) + return True + except Exception: + return False + + class StreamState(str, Enum): """State machine states for XML to JSON streaming conversion.""" @@ -781,7 +801,22 @@ class Glm47MoeDetector(BaseFormatDetector): return arguments def supports_structural_tag(self) -> bool: - return False + return _glm47_native_structural_tag_available() + + def get_structural_tag( + self, + tools: Union[List[Tool], None] = None, + tool_choice: Union[ToolChoice, Literal["auto", "required"]] = "auto", + thinking_mode: bool = False, + ) -> Optional[StructuralTag]: + if not self.supports_structural_tag(): + return None + return super().get_structural_tag( + tools=tools, tool_choice=tool_choice, thinking_mode=thinking_mode + ) def structure_info(self) -> _GetInfoFunc: - raise NotImplementedError() + raise NotImplementedError + + def get_structural_tag_name(self) -> str: + return "glm_4_7" diff --git a/test/registered/unit/function_call/test_function_call_parser.py b/test/registered/unit/function_call/test_function_call_parser.py index 300f64f26..a8502b77d 100644 --- a/test/registered/unit/function_call/test_function_call_parser.py +++ b/test/registered/unit/function_call/test_function_call_parser.py @@ -3360,6 +3360,69 @@ class TestGlm47MoeDetector(unittest.TestCase): self.assertEqual(params["old_string"], " indented code") self.assertEqual(params["new_string"], " also indented") + def test_get_model_structural_tag(self): + """GLM-4.7/GLM-5 use xgrammar's native "glm_4_7" structural tag.""" + import xgrammar as xgr + + self.assertTrue(self.detector.supports_structural_tag()) + self.assertEqual(self.detector.get_structural_tag_name(), "glm_4_7") + + # thinking_mode=True keeps the reasoning prefix. + structural_tag = self.detector.get_structural_tag( + self.tools, thinking_mode=True + ) + self.assertIsInstance(structural_tag, xgr.StructuralTag) + self.assertIsInstance( + xgr.Grammar.from_structural_tag(structural_tag), xgr.Grammar + ) + serialized = structural_tag.model_dump_json() + self.assertIn("glm_xml", serialized) + self.assertIn("", serialized) + self.assertIn("", serialized) + + # thinking_mode=False drops the reasoning prefix (ReasonerGrammarBackend + # owns when --reasoning-parser is configured). + structural_tag = self.detector.get_structural_tag( + self.tools, thinking_mode=False + ) + self.assertIsInstance(structural_tag, xgr.StructuralTag) + self.assertIsInstance( + xgr.Grammar.from_structural_tag(structural_tag), xgr.Grammar + ) + self.assertNotEqual("sequence", structural_tag.model_dump()["format"]["type"]) + + # tool_choice="required" must still compile to a grammar. + structural_tag = self.detector.get_structural_tag( + self.tools, thinking_mode=True, tool_choice="required" + ) + self.assertIsInstance(structural_tag, xgr.StructuralTag) + self.assertIsInstance( + xgr.Grammar.from_structural_tag(structural_tag), xgr.Grammar + ) + + def test_required_tool_choice_falls_back_when_native_tag_is_unavailable(self): + from unittest.mock import patch + + from sglang.srt.function_call.function_call_parser import FunctionCallParser + from sglang.srt.function_call.glm47_moe_detector import ( + _glm47_native_structural_tag_available, + ) + + with patch( + "sglang.srt.function_call.glm47_moe_detector.get_model_structural_tag", + None, + ): + _glm47_native_structural_tag_available.cache_clear() + self.assertFalse(self.detector.supports_structural_tag()) + self.assertIsNone(self.detector.get_structural_tag(self.tools)) + + parser = FunctionCallParser(self.tools, "glm47") + constraint = parser.get_structure_constraint("required") + + self.assertIsNotNone(constraint) + self.assertEqual("json_schema", constraint[0]) + _glm47_native_structural_tag_available.cache_clear() + class TestJsonArrayParser(unittest.TestCase): def setUp(self):