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)