[Frontend] Constrain non-strict GLM-4.7 tool calls with a shallow structural tag (#56403)

Signed-off-by: Shijin Zhang <75300765+Dovis01@users.noreply.github.com>
Co-authored-by: Flora Feng <4florafeng@gmail.com>
This commit is contained in:
Shijin Zhang
2026-10-02 14:31:53 -04:00
committed by GitHub
co-authored by Flora Feng
parent f700178f90
commit d1a974c140
5 changed files with 729 additions and 2 deletions
@@ -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"<arg_key>{key}</arg_key><arg_value>{value}</arg_value>"
def _glm47_call(name: str, *args: str) -> str:
return f"<tool_call>{name}{''.join(args)}</tool_call>"
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 'a<b' x.py")),
# second tool, two calls
_glm47_call("run_command", _glm47_arg("command", "ls -la"))
+ _glm47_call("get_weather", _glm47_arg("city", "Paris")),
],
)
def test_glm47_non_strict_auto_accepts_valid_tool_calls(body: str):
grammar = _glm47_shallow_grammar("auto", auto=True)
assert _is_grammar_accept_string(grammar, body)
@pytest.mark.parametrize(
"body",
[
# unknown tool name
_glm47_call("unknown_tool", _glm47_arg("city", "Paris")),
# undeclared argument key
_glm47_call("get_weather", _glm47_arg("zzz", "x")),
# structural marker inside a string value
_glm47_call("get_weather", _glm47_arg("city", "a<arg_key>b")),
_glm47_call("get_weather", _glm47_arg("city", "a</tool_call>b")),
# 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
"<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...</think>" + 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 = (
"<tool_call>get_weather<arg_key>city</arg_key>"
"<arg_value>Paris</arg_value></tool_call>"
)
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,
"<tool_call>get_weather<arg_key>city</arg_key>"
"<arg_value>Paris</arg_value></tool_call>",
)
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 = (
"<tool_call>get_weather<arg_key>city</arg_key>"
"<arg_value>Paris</arg_value></tool_call>"
)
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 = [
+5
View File
@@ -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
@@ -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:
@@ -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
+184 -2
View File
@@ -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 ``<think>...</think:SUF>`` prefix.
prefix_tag = TagFormat(begin="", content=AnyTextFormat(), end=think_end)
return StructuralTag(format=SequenceFormat(elements=[prefix_tag, suffix_tag]))
# ---------------------------------------------------------------------------
# GLM-4.7 tool calls (<tool_call>NAME<arg_key>K</arg_key><arg_value>V</arg_value>)
# ---------------------------------------------------------------------------
# Assistant output after the reasoning gate (``</think>``):
# [<text>]
# <tool_call>NAME<arg_key>K1</arg_key><arg_value>V1</arg_value>...</tool_call>
# 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 = [
"<think>",
"</think>",
"<tool_call>",
"</tool_call>",
"<arg_key>",
"</arg_key>",
"<arg_value>",
"</arg_value>",
]
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_call>{tool.function.name}",
content=JSONSchemaFormat(
json_schema=True, style="glm_xml", any_order=False
),
end="</tool_call>",
)
pairs: list[Any] = [
SequenceFormat(
elements=[
ConstStringFormat(value=f"<arg_key>{key}</arg_key><arg_value>"),
_glm_4_7_value_format(prop),
ConstStringFormat(value="</arg_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_call>{tool.function.name}",
content=content,
end="</tool_call>",
)
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_call>{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="</tool_call>",
)
@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=["<tool_call>"],
tags=tags,
excludes=[e for e in _GLM_4_7_VALUE_EXCLUDES if e != "<tool_call>"],
)
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="</think>",
)
return StructuralTag(format=SequenceFormat(elements=[prefix_tag, suffix_tag]))
return StructuralTag(format=suffix_tag)