diff --git a/tests/tool_parsers/test_structural_tag_registry.py b/tests/tool_parsers/test_structural_tag_registry.py
index 50f8751f90..d5ab3e4f1a 100644
--- a/tests/tool_parsers/test_structural_tag_registry.py
+++ b/tests/tool_parsers/test_structural_tag_registry.py
@@ -3,11 +3,14 @@
import json
from types import SimpleNamespace
+from typing import Any
from unittest.mock import MagicMock
import pytest
from openai.types.responses import ToolChoiceFunction
+from openai.types.responses.tool_choice_allowed import ToolChoiceAllowed
from xgrammar import Grammar, StructuralTag
+from xgrammar import get_model_structural_tag as get_xgrammar_builtin_structural_tag
from xgrammar.testing import _is_grammar_accept_string
import vllm.envs as envs
@@ -1311,6 +1314,536 @@ def test_tool_strict_level_from_name():
ToolStrictLevel.from_name("off")
+# ---------------------------------------------------------------------------
+# GLM-4.7 structural tag: builtin content for strict tools, shallow otherwise
+# ---------------------------------------------------------------------------
+
+
+def _glm47_tools() -> list[ChatCompletionToolsParam]:
+ return [
+ ChatCompletionToolsParam(
+ type="function",
+ function={
+ "name": "get_weather",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "city": {"type": "string"},
+ "days": {"type": "integer"},
+ "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
+ },
+ "required": ["city"],
+ },
+ },
+ ),
+ ChatCompletionToolsParam(
+ type="function",
+ function={
+ "name": "run_command",
+ "parameters": {
+ "type": "object",
+ "properties": {"command": {"type": "string"}},
+ },
+ },
+ ),
+ ]
+
+
+def _glm47_arg(key: str, value: str) -> str:
+ return f"{key}{value}"
+
+
+def _glm47_call(name: str, *args: str) -> str:
+ return f"{name}{''.join(args)}"
+
+
+def _glm47_shallow_grammar(
+ tool_choice, tools=None, reasoning: bool = False, *, auto: bool = False
+):
+ # auto needs the FUNCTION level to lift the non-strict auto gate.
+ tag = get_model_structural_tag(
+ model="glm_4_7",
+ tools=tools if tools is not None else _glm47_tools(),
+ tool_choice=tool_choice,
+ reasoning=reasoning,
+ strict_level=ToolStrictLevel.FUNCTION if auto else ToolStrictLevel.AUTO,
+ )
+ assert isinstance(tag, StructuralTag)
+ return Grammar.from_structural_tag(tag)
+
+
+@pytest.mark.parametrize(
+ "body",
+ [
+ # plain text, no tool call
+ "Hello there",
+ # text followed by a call
+ "Sure." + _glm47_call("get_weather", _glm47_arg("city", "Paris")),
+ # call only, required arg present
+ _glm47_call("get_weather", _glm47_arg("city", "Paris")),
+ # all arguments omitted
+ _glm47_call("get_weather"),
+ # repeated argument
+ _glm47_call(
+ "get_weather", _glm47_arg("city", "Paris"), _glm47_arg("city", "Rome")
+ ),
+ # arguments in reverse declaration order
+ _glm47_call(
+ "get_weather", _glm47_arg("days", "3"), _glm47_arg("city", "Paris")
+ ),
+ # enum-constrained value
+ _glm47_call("get_weather", _glm47_arg("unit", "celsius")),
+ # string values with spaces and a bare '<'
+ _glm47_call("run_command", _glm47_arg("command", "grep 'ab")),
+ _glm47_call("get_weather", _glm47_arg("city", "ab")),
+ # integer-typed value given non-numeric text
+ _glm47_call("get_weather", _glm47_arg("days", "abc")),
+ # enum-typed value outside the enum
+ _glm47_call("get_weather", _glm47_arg("unit", "kelvin")),
+ # unterminated tool call
+ "get_weather" + _glm47_arg("city", "Paris"),
+ ],
+)
+def test_glm47_non_strict_auto_rejects_invalid(body: str):
+ grammar = _glm47_shallow_grammar("auto", auto=True)
+ assert not _is_grammar_accept_string(grammar, body)
+
+
+def test_glm47_non_strict_required_requires_at_least_one_call():
+ grammar = _glm47_shallow_grammar("required")
+
+ assert not _is_grammar_accept_string(grammar, "Just answering.")
+ assert _is_grammar_accept_string(
+ grammar, _glm47_call("get_weather", _glm47_arg("city", "Paris"))
+ )
+ assert _is_grammar_accept_string(
+ grammar,
+ _glm47_call("run_command", _glm47_arg("command", "ls"))
+ + _glm47_call("get_weather", _glm47_arg("city", "Paris")),
+ )
+
+
+def test_glm47_non_strict_forced_emits_single_named_call():
+ grammar = _glm47_shallow_grammar(
+ ChatCompletionNamedToolChoiceParam(
+ type="function",
+ function=ChatCompletionNamedFunction(name="get_weather"),
+ )
+ )
+
+ assert _is_grammar_accept_string(
+ grammar, _glm47_call("get_weather", _glm47_arg("city", "Paris"))
+ )
+ assert not _is_grammar_accept_string(grammar, "No call here")
+ assert not _is_grammar_accept_string(grammar, _glm47_call("run_command"))
+ assert not _is_grammar_accept_string(
+ grammar,
+ _glm47_call("get_weather", _glm47_arg("city", "Paris"))
+ + _glm47_call("run_command"),
+ )
+
+
+def test_glm47_non_strict_reasoning_gates_on_think_close():
+ grammar = _glm47_shallow_grammar("required", reasoning=True)
+ call = _glm47_call("get_weather", _glm47_arg("city", "Paris"))
+
+ assert _is_grammar_accept_string(grammar, "thinking..." + call)
+ assert not _is_grammar_accept_string(grammar, call)
+
+
+@pytest.mark.parametrize(
+ ("parameters", "key"),
+ [
+ (
+ {
+ "$ref": "#/$defs/args",
+ "$defs": {
+ "args": {
+ "type": "object",
+ "properties": {"city": {"type": "string"}},
+ }
+ },
+ },
+ "city",
+ ),
+ (
+ {
+ "anyOf": [
+ {"type": "object", "properties": {"city": {"type": "string"}}},
+ {"type": "null"},
+ ]
+ },
+ "city",
+ ),
+ (
+ {"allOf": [{"type": "object", "properties": {"city": {"type": "string"}}}]},
+ "city",
+ ),
+ ({"type": "object", "patternProperties": {"^arg": {"type": "string"}}}, "city"),
+ ({"type": "object"}, "city"),
+ ({}, "city"),
+ (
+ {
+ "type": "object",
+ "properties": {"city": {"type": "string"}},
+ "additionalProperties": True,
+ },
+ "undeclared_key",
+ ),
+ ],
+)
+def test_glm47_non_plain_object_schemas_keep_free_arguments(parameters, key):
+ # Schemas that are not a plain object with declared properties fall back
+ # to the builtin's non-strict envelope content, which accepts any key.
+ tools = [
+ ChatCompletionToolsParam(
+ type="function", function={"name": "f", "parameters": parameters}
+ )
+ ]
+ tag = get_model_structural_tag(
+ model="glm_4_7",
+ tools=tools,
+ tool_choice="required",
+ reasoning=False,
+ )
+ assert isinstance(tag, StructuralTag)
+ grammar = Grammar.from_structural_tag(tag)
+ call = _glm47_call("f", _glm47_arg(key, "Paris"))
+ assert _is_grammar_accept_string(grammar, call)
+
+
+def _glm47_strict_tools() -> list[ChatCompletionToolsParam]:
+ return [
+ ChatCompletionToolsParam(
+ type="function",
+ function={
+ "name": "get_weather",
+ "strict": True,
+ "description": "Get the current weather",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "city": {"type": "string"},
+ "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
+ },
+ "required": ["city"],
+ "additionalProperties": False,
+ },
+ },
+ ),
+ ChatCompletionToolsParam(
+ type="function",
+ function={
+ "name": "run_command",
+ "strict": True,
+ "parameters": {
+ "type": "object",
+ "properties": {"command": {"type": "string"}},
+ },
+ },
+ ),
+ ChatCompletionToolsParam(
+ type="function",
+ function={"name": "no_params", "strict": True, "parameters": None},
+ ),
+ ]
+
+
+@pytest.mark.parametrize(
+ ("tool_choice", "reasoning"),
+ [
+ ("auto", False),
+ ("required", False),
+ ("named", False),
+ ("required", True),
+ ("allowed", False),
+ ],
+)
+def test_glm47_strict_only_matches_xgrammar_builtin(tool_choice: str, reasoning: bool):
+ # Strict-only requests stay byte-identical to the xgrammar builtin.
+ strict_tools = _glm47_strict_tools()
+ dumped = [
+ {"type": "function", "function": t.function.model_dump(exclude_none=True)}
+ for t in strict_tools
+ ]
+ if tool_choice == "named":
+ choice = ChatCompletionNamedToolChoiceParam(
+ type="function",
+ function=ChatCompletionNamedFunction(name="run_command"),
+ )
+ builtin_choice: Any = {"type": "function", "function": {"name": "run_command"}}
+ elif tool_choice == "allowed":
+ choice = ToolChoiceAllowed(
+ type="allowed_tools",
+ mode="auto",
+ tools=[{"type": "function", "function": {"name": "get_weather"}}],
+ )
+ builtin_choice = {
+ "type": "allowed_tools",
+ "allowed_tools": {
+ "mode": "auto",
+ "tools": [{"type": "function", "function": {"name": "get_weather"}}],
+ },
+ }
+ else:
+ choice = tool_choice
+ builtin_choice = tool_choice
+
+ ours = get_model_structural_tag(
+ model="glm_4_7",
+ tools=strict_tools,
+ tool_choice=choice,
+ reasoning=reasoning,
+ )
+ expected = get_xgrammar_builtin_structural_tag(
+ model="glm_4_7",
+ tools=dumped,
+ tool_choice=builtin_choice,
+ reasoning=reasoning,
+ )
+
+ assert isinstance(ours, StructuralTag)
+ assert ours.model_dump() == expected.model_dump()
+
+
+def test_glm47_parameter_level_pins_schemas_like_the_builtin(
+ sample_tools: list[ChatCompletionToolsParam],
+):
+ # The operator floor overrides per-tool strictness: every tool is pinned.
+ ours = get_model_structural_tag(
+ model="glm_4_7",
+ tools=sample_tools,
+ tool_choice="required",
+ reasoning=False,
+ strict_level=ToolStrictLevel.PARAMETER,
+ )
+ expected = get_xgrammar_builtin_structural_tag(
+ model="glm_4_7",
+ tools=[
+ {
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "strict": True,
+ "parameters": sample_tools[0].function.parameters,
+ },
+ }
+ ],
+ tool_choice="required",
+ reasoning=False,
+ )
+
+ assert isinstance(ours, StructuralTag)
+ assert ours.model_dump() == expected.model_dump()
+
+
+def test_glm47_mixed_tools_dispatch_per_tool(
+ sample_tools_strict: list[ChatCompletionToolsParam],
+ sample_tools: list[ChatCompletionToolsParam],
+):
+ mixed = [sample_tools_strict[0], sample_tools[0]]
+ mixed[1] = mixed[1].model_copy(deep=True)
+ mixed[1].function.name = "run_command"
+
+ tag = get_model_structural_tag(
+ model="glm_4_7",
+ tools=mixed,
+ tool_choice="required",
+ reasoning=False,
+ )
+
+ assert isinstance(tag, StructuralTag)
+ tags = tag.model_dump()["format"]["tags"]
+ assert tags[0]["content"]["json_schema"] == (
+ sample_tools_strict[0].function.parameters
+ )
+ assert tags[1]["content"]["type"] != "json_schema"
+
+
+def _glm47_parser(
+ tools, monkeypatch: pytest.MonkeyPatch, *, strict_calling: bool = True
+) -> Glm47MoeModelToolParser:
+ monkeypatch.setattr(envs, "VLLM_ENFORCE_STRICT_TOOL_CALLING", strict_calling)
+ return Glm47MoeModelToolParser(MagicMock(), tools=tools)
+
+
+@pytest.mark.parametrize(
+ ("tool_choice", "tools"),
+ [("auto", "plain"), ("required", "plain"), ("required", "strict")],
+)
+def test_glm47_get_structural_tag_disabled_when_flag_off(
+ monkeypatch: pytest.MonkeyPatch,
+ sample_tools: list[ChatCompletionToolsParam],
+ sample_tools_strict: list[ChatCompletionToolsParam],
+ tool_choice: str,
+ tools: str,
+):
+ parser = _glm47_parser(sample_tools, monkeypatch, strict_calling=False)
+ request = ChatCompletionRequest(
+ messages=[],
+ model="m",
+ tools=sample_tools_strict if tools == "strict" else sample_tools,
+ tool_choice=tool_choice,
+ )
+
+ assert parser.get_structural_tag(request) is None
+
+
+def test_glm47_default_strict_level_constrains_auto(
+ monkeypatch: pytest.MonkeyPatch,
+ sample_tools: list[ChatCompletionToolsParam],
+):
+ # --tool-strict-level auto: the parser's FUNCTION default lifts the
+ # non-strict auto gate.
+ monkeypatch.setattr(envs, "VLLM_ENFORCE_STRICT_TOOL_CALLING", True)
+
+ class TestParser(DelegatingParser):
+ tool_parser_cls = Glm47MoeModelToolParser
+
+ request = ChatCompletionRequest(
+ messages=[],
+ model="m",
+ tools=[tool.model_dump(exclude_none=True) for tool in sample_tools],
+ tool_choice="auto",
+ )
+ parser = TestParser(MagicMock(), tools=sample_tools)
+ parser._reasoning_parser = MagicMock(adjust_request=lambda request: request)
+
+ out = parser.adjust_request(request)
+
+ assert out.structured_outputs is not None
+ assert out.structured_outputs.json is None
+ grammar = Grammar.from_structural_tag(out.structured_outputs.structural_tag)
+ call = (
+ "get_weathercity"
+ "Paris"
+ )
+ assert _is_grammar_accept_string(grammar, "Sure. " + call)
+ assert _is_grammar_accept_string(grammar, "Plain answer.")
+ assert not _is_grammar_accept_string(
+ grammar, "Sure. " + call.replace("city", "ctiy")
+ )
+
+
+def test_glm47_responses_request_attaches_shallow_tag(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ monkeypatch.setattr(envs, "VLLM_ENFORCE_STRICT_TOOL_CALLING", True)
+
+ class TestParser(DelegatingParser):
+ tool_parser_cls = Glm47MoeModelToolParser
+
+ request = ResponsesRequest.model_validate(
+ {
+ "input": "hi",
+ "tools": [
+ {
+ "type": "function",
+ "name": "get_weather",
+ "parameters": {
+ "type": "object",
+ "properties": {"city": {"type": "string"}},
+ },
+ }
+ ],
+ "tool_choice": "required",
+ }
+ )
+ parser = TestParser(MagicMock(), tools=None)
+ parser._reasoning_parser = MagicMock(adjust_request=lambda request: request)
+
+ out = parser.adjust_request(request)
+
+ assert out.structured_outputs is not None
+ assert out.structured_outputs.structural_tag is not None
+ assert out.text is None
+ grammar = Grammar.from_structural_tag(out.structured_outputs.structural_tag)
+ assert _is_grammar_accept_string(
+ grammar,
+ "get_weathercity"
+ "Paris",
+ )
+
+
+def test_glm47_operator_level_overrides_parser_default(
+ monkeypatch: pytest.MonkeyPatch,
+ sample_tools: list[ChatCompletionToolsParam],
+):
+ monkeypatch.setattr(envs, "VLLM_ENFORCE_STRICT_TOOL_CALLING", True)
+
+ class TestParser(DelegatingParser):
+ tool_parser_cls = Glm47MoeModelToolParser
+ tool_strict_level = ToolStrictLevel.PARAMETER
+
+ request = ChatCompletionRequest(
+ messages=[],
+ model="m",
+ tools=[tool.model_dump(exclude_none=True) for tool in sample_tools],
+ tool_choice="required",
+ )
+ parser = TestParser(MagicMock(), tools=sample_tools)
+ parser._reasoning_parser = MagicMock(adjust_request=lambda request: request)
+
+ out = parser.adjust_request(request)
+
+ assert out.structured_outputs is not None
+ content = json.loads(out.structured_outputs.structural_tag)["format"]["tags"][0][
+ "content"
+ ]
+ assert content["json_schema"] == sample_tools[0].function.parameters
+
+
+def test_glm47_required_with_non_strict_tools_attaches_shallow_tag(
+ monkeypatch: pytest.MonkeyPatch,
+ sample_tools: list[ChatCompletionToolsParam],
+):
+ monkeypatch.setattr(envs, "VLLM_ENFORCE_STRICT_TOOL_CALLING", True)
+
+ class TestParser(DelegatingParser):
+ tool_parser_cls = Glm47MoeModelToolParser
+
+ request = ChatCompletionRequest(
+ messages=[],
+ model="m",
+ tools=[tool.model_dump(exclude_none=True) for tool in sample_tools],
+ tool_choice="required",
+ )
+ parser = TestParser(MagicMock(), tools=sample_tools)
+ parser._reasoning_parser = MagicMock(adjust_request=lambda request: request)
+
+ out = parser.adjust_request(request)
+
+ assert out.structured_outputs is not None
+ assert out.structured_outputs.json is None
+ grammar = Grammar.from_structural_tag(out.structured_outputs.structural_tag)
+ call = (
+ "get_weathercity"
+ "Paris"
+ )
+ assert _is_grammar_accept_string(grammar, call)
+ assert not _is_grammar_accept_string(grammar, "It is sunny in Paris.")
+ assert not _is_grammar_accept_string(grammar, call.replace("city", "ctiy"))
+
+
@pytest.mark.parametrize("policy", ["auto", "required", "named"])
def test_mimo_strict_compact_xml(policy):
tools = [
diff --git a/vllm/parser/abstract_parser.py b/vllm/parser/abstract_parser.py
index eea957f90c..bee090e7f6 100644
--- a/vllm/parser/abstract_parser.py
+++ b/vllm/parser/abstract_parser.py
@@ -506,6 +506,11 @@ class DelegatingParser(Parser):
is_auto = request.tool_choice == "auto"
single_call = request.parallel_tool_calls is False
strict_level = self.tool_strict_level
+ if (
+ strict_level == ToolStrictLevel.AUTO
+ and tool_parser.default_tool_strict_level is not None
+ ):
+ strict_level = tool_parser.default_tool_strict_level
if single_call and not (is_auto and structured_outputs is not None):
# Limiting the call count needs the call envelope in the grammar.
# Auto with structured outputs keeps its format-only grammar, so the
diff --git a/vllm/tool_parsers/abstract_tool_parser.py b/vllm/tool_parsers/abstract_tool_parser.py
index bc5abea3a2..3d3f6c1f09 100644
--- a/vllm/tool_parsers/abstract_tool_parser.py
+++ b/vllm/tool_parsers/abstract_tool_parser.py
@@ -59,6 +59,10 @@ class ToolParser:
# xgrammar builtin structural tag model key. Subclasses set this when
# their parsed tool-call syntax matches a builtin xgrammar format.
structural_tag_model: str | None = None
+ # Strict level applied when the operator leaves ``--tool-strict-level``
+ # at auto. Operator flags override it, and
+ # ``VLLM_ENFORCE_STRICT_TOOL_CALLING=0`` still disables structural tags.
+ default_tool_strict_level: ToolStrictLevel | None = None
engine_based_streaming: bool = False
def __init_subclass__(cls, **kwargs: Any) -> None:
diff --git a/vllm/tool_parsers/glm47_moe_tool_parser.py b/vllm/tool_parsers/glm47_moe_tool_parser.py
index 70275a6ac0..6beb9185fb 100644
--- a/vllm/tool_parsers/glm47_moe_tool_parser.py
+++ b/vllm/tool_parsers/glm47_moe_tool_parser.py
@@ -4,8 +4,11 @@
from __future__ import annotations
from vllm.parser.engine.registered_adapters import Glm47MoeParserToolAdapter
+from vllm.tool_parsers.tool_strict_level import ToolStrictLevel
class Glm47MoeModelToolParser(Glm47MoeParserToolAdapter): # type: ignore[valid-type, misc]
supports_required_and_named = False
structural_tag_model = "glm_4_7"
+ # Constrain auto tool calls with the shallow tag by default.
+ default_tool_strict_level = ToolStrictLevel.FUNCTION
diff --git a/vllm/tool_parsers/structural_tag_registry.py b/vllm/tool_parsers/structural_tag_registry.py
index ef57e85fab..a1ee263dc2 100644
--- a/vllm/tool_parsers/structural_tag_registry.py
+++ b/vllm/tool_parsers/structural_tag_registry.py
@@ -1,6 +1,8 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+import json
from collections.abc import Callable, Sequence
from typing import Any, Literal, TypeAlias
@@ -71,12 +73,13 @@ XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset(
"mimo",
"qwen_3",
"deepseek_v3_2",
- "glm_4_7",
"deepseek_v4",
"deepseek_v4_1",
}
)
-VLLM_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset({"hermes", "hy_v4", "kimi_k3", "plamo3"})
+VLLM_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset(
+ {"glm_4_7", "hermes", "hy_v4", "kimi_k3", "plamo3"}
+)
SUPPORTED_STRUCTURAL_TAG_MODELS = (
XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS | VLLM_BUILTIN_STRUCTURAL_TAG_MODELS
)
@@ -903,3 +906,182 @@ def get_hy_v4_structural_tag(
# the ``...`` prefix.
prefix_tag = TagFormat(begin="", content=AnyTextFormat(), end=think_end)
return StructuralTag(format=SequenceFormat(elements=[prefix_tag, suffix_tag]))
+
+
+# ---------------------------------------------------------------------------
+# GLM-4.7 tool calls (NAMEKV)
+# ---------------------------------------------------------------------------
+# Assistant output after the reasoning gate (````):
+# []
+# NAMEK1V1...
+# Values are raw JSON scalars: strings verbatim (unquoted), other types as
+# bare JSON, matching how the chat template serializes tool-call history.
+
+_GLM_4_7_VALUE_EXCLUDES = [
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+]
+
+
+def _glm_4_7_value_format(prop: dict[str, Any]) -> Any:
+ if "enum" in prop:
+ values = [
+ ConstStringFormat(value=v if isinstance(v, str) else json.dumps(v))
+ for v in prop["enum"]
+ ]
+ return values[0] if len(values) == 1 else OrFormat(elements=values)
+ prop_type = prop.get("type")
+ if isinstance(prop_type, list):
+ formats = [
+ _glm_4_7_value_format({"type": t}) for t in prop_type if t != "string"
+ ]
+ if "string" in prop_type:
+ formats.append(AnyTextFormat(excludes=_GLM_4_7_VALUE_EXCLUDES))
+ return formats[0] if len(formats) == 1 else OrFormat(elements=formats)
+ if prop_type in (None, "string"):
+ return AnyTextFormat(excludes=_GLM_4_7_VALUE_EXCLUDES)
+ return JSONSchemaFormat(json_schema={"type": prop_type})
+
+
+def _glm_4_7_plain_properties(params: dict[str, Any]) -> dict[str, Any] | None:
+ """Return ``properties`` when the schema is a plain object schema."""
+ properties = params.get("properties")
+ if (
+ not isinstance(properties, dict)
+ or not properties
+ or params.get("additionalProperties") is True
+ or any(
+ key in params
+ for key in ("$ref", "allOf", "anyOf", "oneOf", "patternProperties")
+ )
+ ):
+ return None
+ return properties
+
+
+def _glm_4_7_tool_tag(tool: FunctionToolParam) -> TagFormat:
+ """Shallow tag for a non-strict tool: keys and basic value types pinned."""
+ properties = _glm_4_7_plain_properties(tool.function.parameters or {})
+ if properties is None:
+ return TagFormat(
+ begin=f"{tool.function.name}",
+ content=JSONSchemaFormat(
+ json_schema=True, style="glm_xml", any_order=False
+ ),
+ end="",
+ )
+ pairs: list[Any] = [
+ SequenceFormat(
+ elements=[
+ ConstStringFormat(value=f"{key}"),
+ _glm_4_7_value_format(prop),
+ ConstStringFormat(value=""),
+ ]
+ )
+ for key, prop in properties.items()
+ ]
+ content: Any = (
+ StarFormat(content=pairs[0] if len(pairs) == 1 else OrFormat(elements=pairs))
+ if pairs
+ else ConstStringFormat(value="")
+ )
+ return TagFormat(
+ begin=f"{tool.function.name}",
+ content=content,
+ end="",
+ )
+
+
+def _glm_4_7_strict_tool_tag(tool: FunctionToolParam) -> TagFormat:
+ """Schema-pinned tag matching the xgrammar builtin's strict content."""
+ return TagFormat(
+ begin=f"{tool.function.name}",
+ content=JSONSchemaFormat(
+ json_schema=tool.function.parameters
+ if tool.function.parameters is not None
+ else True,
+ style="glm_xml",
+ any_order=False,
+ ),
+ end="",
+ )
+
+
+@register_vllm_structural_tag("glm_4_7")
+def get_glm_4_7_structural_tag(
+ function_tools: list[FunctionToolParam],
+ builtin_tools: list[BuiltinToolParam],
+ tool_choice: Any,
+ reasoning: bool,
+ token_suffix: str = "",
+) -> StructuralTag:
+ """Build the GLM-4.7 structural tag, dispatching per tool on strictness.
+
+ Args:
+ function_tools: Normalized function tools with ``strict`` pinned by
+ ``resolve_tool_strictness``.
+ builtin_tools: Unused; GLM-4.7 has no builtin tools.
+ tool_choice: Simplified tool choice (``auto`` / ``required`` /
+ ``forced``).
+ reasoning: Whether the grammar also covers the reasoning phase.
+ token_suffix: Unused; GLM-4.7 structural tokens are fixed.
+
+ """
+ del builtin_tools, token_suffix
+
+ if function_tools and all(tool.function.strict for tool in function_tools):
+ # Strict-only requests stay byte-identical to the xgrammar builtin.
+ dumped_choice: Any = (
+ {"type": "function", "function": {"name": function_tools[0].function.name}}
+ if tool_choice == "forced"
+ else tool_choice
+ )
+ dumped_tools = []
+ for tool in function_tools:
+ function: dict[str, Any] = {"name": tool.function.name, "strict": True}
+ if tool.function.description is not None:
+ function["description"] = tool.function.description
+ if tool.function.parameters is not None:
+ function["parameters"] = tool.function.parameters
+ dumped_tools.append({"type": "function", "function": function})
+ return get_xgrammar_model_structural_tag(
+ model="glm_4_7",
+ tools=dumped_tools,
+ tool_choice=dumped_choice,
+ reasoning=reasoning,
+ )
+
+ tags = [
+ _glm_4_7_strict_tool_tag(tool)
+ if tool.function.strict
+ else _glm_4_7_tool_tag(tool)
+ for tool in function_tools
+ ]
+ if tool_choice == "auto":
+ # Keep the trigger itself out of the excludes so tag dispatch works.
+ suffix_tag: Any = TriggeredTagsFormat(
+ triggers=[""],
+ tags=tags,
+ excludes=[e for e in _GLM_4_7_VALUE_EXCLUDES if e != ""],
+ )
+ else:
+ suffix_tag = TagsWithSeparatorFormat(
+ tags=tags,
+ separator="",
+ at_least_one=True,
+ stop_after_first=tool_choice == "forced",
+ )
+ if reasoning:
+ prefix_tag = TagFormat(
+ begin="",
+ content=AnyTextFormat(excludes=_GLM_4_7_VALUE_EXCLUDES),
+ end="",
+ )
+ return StructuralTag(format=SequenceFormat(elements=[prefix_tag, suffix_tag]))
+ return StructuralTag(format=suffix_tag)