diff --git a/tests/tool_parsers/test_composed_tool_schema_grammar.py b/tests/tool_parsers/test_composed_tool_schema_grammar.py new file mode 100644 index 000000000000..36e2a2600728 --- /dev/null +++ b/tests/tool_parsers/test_composed_tool_schema_grammar.py @@ -0,0 +1,252 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Composed tool-call-or-schema grammar for tool_choice="auto" + response_format. + +Proves that composing a model's auto tool-call structural tag with a +response_format schema (https://github.com/vllm-project/vllm/issues/39929) +yields a grammar that xgrammar can compile and that both calls a tool and +constrains a non-tool final answer to the schema. +""" + +import json + +import pytest +import xgrammar as xgr +from xgrammar import StructuralTag +from xgrammar.testing import _is_grammar_accept_string + +from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionToolsParam, +) +from vllm.tool_parsers.structural_tag_registry import ( + compose_tool_call_or_schema, + get_composed_structural_tag, + get_model_structural_tag, +) + +RESPONSE_SCHEMA = { + "type": "object", + "properties": {"answer": {"type": "string"}}, + "required": ["answer"], + "additionalProperties": False, +} + +HERMES_TOOL_CALL = ( + '\n{"name": "get_weather", "arguments": {"city": "Dallas"}}\n' + "" +) +MINIMAX_TOOL_CALL = ( + "\n" + '\nDallas\n' + "\n" +) +SCHEMA_VALID_JSON = '{"answer": "sunny in Dallas"}' +SCHEMA_INVALID_KEY = '{"wrong": 1}' +SCHEMA_INVALID_TYPE = '{"answer": 5}' +FREE_TEXT = "The weather is sunny." +ANY_JSON = '{"anything": [1, 2, 3]}' + + +@pytest.fixture +def strict_tools() -> list[ChatCompletionToolsParam]: + return [ + ChatCompletionToolsParam( + type="function", + function={ + "name": "get_weather", + "strict": True, + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + }, + ) + ] + + +@pytest.fixture +def non_strict_tools() -> list[ChatCompletionToolsParam]: + return [ + ChatCompletionToolsParam( + type="function", + function={ + "name": "get_weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + }, + ) + ] + + +def _grammar(tag: StructuralTag) -> xgr.Grammar: + return xgr.Grammar.from_structural_tag(json.dumps(tag.model_dump())) + + +def _auto_tag(model: str, tools: list[ChatCompletionToolsParam]) -> StructuralTag: + tag = get_model_structural_tag( + model=model, + tools=tools, + tool_choice="auto", + reasoning=False, + ) + assert isinstance(tag, StructuralTag) + return tag + + +def test_compose_hermes_accepts_tool_call_and_schema( + strict_tools: list[ChatCompletionToolsParam], +): + composed = compose_tool_call_or_schema( + _auto_tag("hermes", strict_tools), RESPONSE_SCHEMA + ) + assert composed is not None + grammar = _grammar(composed) + + assert _is_grammar_accept_string(grammar, HERMES_TOOL_CALL) + assert _is_grammar_accept_string(grammar, SCHEMA_VALID_JSON) + assert not _is_grammar_accept_string(grammar, SCHEMA_INVALID_KEY) + assert not _is_grammar_accept_string(grammar, SCHEMA_INVALID_TYPE) + assert not _is_grammar_accept_string(grammar, FREE_TEXT) + + +def test_compose_minimax_accepts_tool_call_and_schema( + strict_tools: list[ChatCompletionToolsParam], +): + composed = compose_tool_call_or_schema( + _auto_tag("minimax", strict_tools), RESPONSE_SCHEMA + ) + assert composed is not None + grammar = _grammar(composed) + + assert _is_grammar_accept_string(grammar, MINIMAX_TOOL_CALL) + assert _is_grammar_accept_string(grammar, SCHEMA_VALID_JSON) + assert not _is_grammar_accept_string(grammar, SCHEMA_INVALID_KEY) + + +def test_compose_requires_tool_call_so_schema_branch_is_reachable( + strict_tools: list[ChatCompletionToolsParam], +): + """The schema branch is only enforced because the tool branch is forced to + require a tool call. A bare auto tag would match free text and make the + schema constraint vacuous.""" + auto_tag = _auto_tag("hermes", strict_tools) + + vacuous = _grammar(auto_tag) + assert _is_grammar_accept_string(vacuous, FREE_TEXT) + assert _is_grammar_accept_string(vacuous, SCHEMA_INVALID_KEY) + + composed = compose_tool_call_or_schema(auto_tag, RESPONSE_SCHEMA) + assert composed is not None + constrained = _grammar(composed) + assert not _is_grammar_accept_string(constrained, FREE_TEXT) + assert not _is_grammar_accept_string(constrained, SCHEMA_INVALID_KEY) + + +def test_compose_returns_none_for_non_triggered_format(): + """Formats that cannot require a tool call (e.g. a forced/required tag) + return None so callers fall back to dropping the response_format.""" + forced = get_model_structural_tag( + model="hermes", + tools=[ + ChatCompletionToolsParam( + type="function", + function={ + "name": "get_weather", + "parameters": {"type": "object", "properties": {}}, + }, + ) + ], + tool_choice="required", + reasoning=False, + ) + assert isinstance(forced, StructuralTag) + assert compose_tool_call_or_schema(forced, RESPONSE_SCHEMA) is None + + +def test_compose_json_object_accepts_any_json_answer( + strict_tools: list[ChatCompletionToolsParam], +): + """response_format: {"type": "json_object"} maps to json_schema=True, so + the answer branch accepts any well-formed JSON rather than a fixed shape. + """ + composed = compose_tool_call_or_schema(_auto_tag("hermes", strict_tools), True) + assert composed is not None + grammar = _grammar(composed) + + assert _is_grammar_accept_string(grammar, HERMES_TOOL_CALL) + assert _is_grammar_accept_string(grammar, SCHEMA_VALID_JSON) + assert _is_grammar_accept_string(grammar, ANY_JSON) + assert not _is_grammar_accept_string(grammar, FREE_TEXT) + + +@pytest.mark.parametrize("model", ["hermes", "minimax"]) +def test_get_composed_structural_tag_bypasses_strict_short_circuit( + model: str, non_strict_tools: list[ChatCompletionToolsParam] +): + """get_composed_structural_tag must compose even for non-strict tools: + get_model_structural_tag drops an auto request with non-strict tools + (see test_auto_tool_choice_skips_structural_tag_without_strict in + test_structural_tag_registry.py), but the whole point of this entry + point is to make non-strict auto tool calling work alongside a schema. + """ + bare_auto = get_model_structural_tag( + model=model, tools=non_strict_tools, tool_choice="auto", reasoning=False + ) + assert bare_auto is None + + composed = get_composed_structural_tag( + model=model, + tools=non_strict_tools, + tool_choice="auto", + response_format_schema=RESPONSE_SCHEMA, + ) + assert composed is not None + grammar = _grammar(composed) + assert _is_grammar_accept_string(grammar, SCHEMA_VALID_JSON) + assert not _is_grammar_accept_string(grammar, FREE_TEXT) + + +def test_get_composed_structural_tag_returns_none_for_non_composable_model( + non_strict_tools: list[ChatCompletionToolsParam], +): + """Models without a vLLM-owned registry builder (e.g. the opaque xgrammar + builtins) are out of scope for this PR; callers fall back unchanged.""" + assert ( + get_composed_structural_tag( + model="llama", + tools=non_strict_tools, + tool_choice="auto", + response_format_schema=RESPONSE_SCHEMA, + ) + is None + ) + + +def test_get_composed_structural_tag_returns_none_for_non_auto_tool_choice( + non_strict_tools: list[ChatCompletionToolsParam], +): + assert ( + get_composed_structural_tag( + model="hermes", + tools=non_strict_tools, + tool_choice="required", + response_format_schema=RESPONSE_SCHEMA, + ) + is None + ) + + +def test_get_composed_structural_tag_returns_none_without_tools(): + assert ( + get_composed_structural_tag( + model="hermes", + tools=None, + tool_choice="auto", + response_format_schema=RESPONSE_SCHEMA, + ) + is None + ) diff --git a/tests/tool_use/test_chat_completions.py b/tests/tool_use/test_chat_completions.py index e5bb475875ac..6cb4321f97dd 100644 --- a/tests/tool_use/test_chat_completions.py +++ b/tests/tool_use/test_chat_completions.py @@ -1,6 +1,8 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import json + import openai import pytest @@ -198,3 +200,63 @@ async def test_response_format_with_tool_choice_required( assert choice.finish_reason == "tool_calls" assert choice.message.tool_calls is not None assert len(choice.message.tool_calls) > 0 + + +def _tool_call_parser(server_config: ServerConfig) -> str | None: + arguments = server_config.get("arguments", []) + if "--tool-call-parser" not in arguments: + return None + return arguments[arguments.index("--tool-call-parser") + 1] + + +# Composed tool-call-or-schema grammar for tool_choice="auto" + response_format. +# See https://github.com/vllm-project/vllm/issues/39929. +@pytest.mark.asyncio +@pytest.mark.timeout(120) +async def test_response_format_with_tool_choice_auto( + client: openai.AsyncOpenAI, server_config: ServerConfig +): + """ + Test that combining response_format: json_schema with tool_choice: auto + doesn't crash the engine and, for models with a composable structural tag + (hermes, minimax), still calls the tool or answers within the schema + instead of silently dropping the response_format. + """ + models = await client.models.list() + model_name: str = models.data[0].id + + schema = { + "type": "object", + "properties": {"answer": {"type": "string"}}, + "required": ["answer"], + "additionalProperties": False, + } + + chat_completion = await client.chat.completions.create( + messages=ensure_system_prompt( + [{"role": "user", "content": "What is the weather in Dallas, Texas?"}], + server_config, + ), + temperature=0, + max_completion_tokens=150, + model=model_name, + tools=[WEATHER_TOOL], + tool_choice="auto", + response_format={ + "type": "json_schema", + "json_schema": {"name": "final_answer", "schema": schema}, + }, + ) + + choice = chat_completion.choices[0] + if _tool_call_parser(server_config) in ("hermes", "minimax_m2"): + # Either branch of the composed grammar is valid: a tool call, or a + # final answer constrained to the schema. + if choice.finish_reason == "tool_calls": + assert choice.message.tool_calls + else: + json.loads(choice.message.content) + else: + # Non-composable models fall back to unchanged behavior and must not + # crash the engine. + assert choice.finish_reason is not None diff --git a/tests/tool_use/test_tool_parser_adjust_request.py b/tests/tool_use/test_tool_parser_adjust_request.py new file mode 100644 index 000000000000..70fc2b870543 --- /dev/null +++ b/tests/tool_use/test_tool_parser_adjust_request.py @@ -0,0 +1,164 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Request-level wiring for composing an auto tool-call grammar with a +response_format schema (https://github.com/vllm-project/vllm/issues/39929). + +``DelegatingParser._apply_structural_tag`` decides whether a tool_choice="auto" +request that also sets a response_format gets its schema composed with the +tool-call grammar, via ``get_composed_structural_tag``. These tests drive that +decision through the real parser registry (``ParserManager.get_parser``), not +through the composition helpers directly, to prove the wiring itself. +""" + +from unittest.mock import MagicMock + +import pytest +import xgrammar as xgr +from openai.types.responses import ( + FunctionTool, + ResponseFormatTextJSONSchemaConfig, + ResponseTextConfig, +) +from xgrammar.testing import _is_grammar_accept_string + +from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionRequest, + ChatCompletionToolsParam, +) +from vllm.entrypoints.openai.responses.protocol import ResponsesRequest +from vllm.parser.parser_manager import ParserManager + +pytestmark = pytest.mark.cpu_test + +RESPONSE_SCHEMA = { + "type": "object", + "properties": {"answer": {"type": "string"}}, + "required": ["answer"], + "additionalProperties": False, +} + +NON_STRICT_WEATHER_TOOL = ChatCompletionToolsParam( + type="function", + function={ + "name": "get_weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + }, +) +NON_STRICT_WEATHER_RESPONSES_TOOL = FunctionTool( + type="function", + name="get_weather", + parameters={ + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, +) + +HERMES_TOOL_CALL = ( + '\n{"name": "get_weather", "arguments": {"city": "Dallas"}}\n' + "" +) +SCHEMA_VALID_JSON = '{"answer": "sunny in Dallas"}' +FREE_TEXT = "The weather is sunny." + + +def _parser(tool_parser_name: str): + parser_cls = ParserManager.get_parser( + tool_parser_name=tool_parser_name, enable_auto_tools=True + ) + assert parser_cls is not None + return parser_cls(MagicMock(), tools=[NON_STRICT_WEATHER_TOOL]) + + +def _chat_request(**overrides) -> ChatCompletionRequest: + fields = dict( + messages=[{"role": "user", "content": "What is the weather in Dallas?"}], + model="m", + tools=[NON_STRICT_WEATHER_TOOL], + tool_choice="auto", + response_format={ + "type": "json_schema", + "json_schema": {"name": "final_answer", "schema": RESPONSE_SCHEMA}, + }, + ) + fields.update(overrides) + return ChatCompletionRequest(**fields) + + +def test_composable_model_composes_tag_and_clears_response_format(): + """hermes is a composable model: an auto tool_choice request that also + sets response_format should compose the two into a single structural_tag + instead of dropping the schema.""" + parser = _parser("hermes") + request = _chat_request() + + out = parser.adjust_request(request) + + assert out.response_format is None + assert out.structured_outputs is not None + assert out.structured_outputs.structural_tag is not None + + grammar = xgr.Grammar.from_structural_tag(out.structured_outputs.structural_tag) + assert _is_grammar_accept_string(grammar, HERMES_TOOL_CALL) + assert _is_grammar_accept_string(grammar, SCHEMA_VALID_JSON) + assert not _is_grammar_accept_string(grammar, FREE_TEXT) + + +def test_composable_model_composes_responses_request_text_format(): + """The Responses API expresses response_format as text.format rather than + response_format, but the composition applies there too.""" + parser = _parser("hermes") + request = ResponsesRequest( + input="What is the weather in Dallas?", + model="m", + tools=[NON_STRICT_WEATHER_RESPONSES_TOOL], + tool_choice="auto", + text=ResponseTextConfig( + format=ResponseFormatTextJSONSchemaConfig( + type="json_schema", + name="final_answer", + schema=RESPONSE_SCHEMA, + ) + ), + ) + + out = parser.adjust_request(request) + + assert out.text is None + assert out.structured_outputs is not None + assert out.structured_outputs.structural_tag is not None + + grammar = xgr.Grammar.from_structural_tag(out.structured_outputs.structural_tag) + assert _is_grammar_accept_string(grammar, HERMES_TOOL_CALL) + assert _is_grammar_accept_string(grammar, SCHEMA_VALID_JSON) + assert not _is_grammar_accept_string(grammar, FREE_TEXT) + + +def test_non_composable_model_leaves_response_format_intact(): + """qwen3_coder only has an xgrammar builtin template, not a vLLM-owned + registry builder, so it is out of scope for this PR and falls back + unchanged rather than dropping the schema or crashing.""" + parser = _parser("qwen3_coder") + request = _chat_request() + + out = parser.adjust_request(request) + + assert out.response_format is not None + assert out.structured_outputs is None + + +def test_reasoning_active_falls_back_to_unchanged_behavior(): + """A think preamble would not match the schema answer branch, so an + active reasoning parser must skip composition entirely.""" + parser = _parser("hermes") + parser.reasoning_parser = MagicMock(adjust_request=lambda request: request) + request = _chat_request() + + out = parser.adjust_request(request) + + assert out.response_format is not None + assert out.structured_outputs is None diff --git a/vllm/parser/abstract_parser.py b/vllm/parser/abstract_parser.py index 639aec0fa992..59662af5ad98 100644 --- a/vllm/parser/abstract_parser.py +++ b/vllm/parser/abstract_parser.py @@ -7,6 +7,7 @@ from collections.abc import Sequence from dataclasses import dataclass, field from functools import cached_property +from typing import Any from openai.types.responses import ToolChoiceFunction from pydantic import TypeAdapter, ValidationError @@ -368,6 +369,35 @@ def parse_delta( """ +def _get_response_format_schema( + request: ChatCompletionRequest | ResponsesRequest, +) -> dict[str, Any] | bool | None: + """Read the schema off a request's response_format, chat or Responses shaped. + + Returns the JSON schema dict, ``True`` for a bare json_object (any JSON is + valid), or ``None`` when the request has no schema-bearing response_format. + """ + if isinstance(request, ResponsesRequest): + text_format = request.text.format if request.text is not None else None + if text_format is None: + return None + if text_format.type == "json_schema": + return text_format.schema_ + if text_format.type == "json_object": + return True + return None + + response_format = request.response_format + if response_format is None: + return None + if response_format.type == "json_object": + return True + if response_format.type == "json_schema": + json_schema = response_format.json_schema + return json_schema.json_schema if json_schema is not None else None + return None + + class DelegatingParser(Parser): """ A Parser implementation that delegates to separate ReasoningParser and @@ -521,6 +551,26 @@ def _apply_structural_tag( ): return request + # An auto tool_choice with a response_format wants both: the freedom + # to call a tool, and a final answer constrained to the schema. Only + # try this for non-reasoning requests, since a think preamble would + # not match the schema answer branch. + if request.tool_choice == "auto" and self._reasoning_parser is None: + response_format_schema = _get_response_format_schema(request) + if response_format_schema is not None: + from vllm.tool_parsers.structural_tag_registry import ( + get_composed_structural_tag, + ) + + composed_tag = get_composed_structural_tag( + model=self._tool_parser.structural_tag_model, + tools=request.tools, + tool_choice=request.tool_choice, + response_format_schema=response_format_schema, + ) + if composed_tag is not None: + return self._set_structural_tag(request, composed_tag) + need_tool_calling = ( request.tool_choice == "auto" or request.tool_choice == "required" @@ -539,6 +589,13 @@ def _apply_structural_tag( if structure_tag is None: return request + return self._set_structural_tag(request, structure_tag) + + def _set_structural_tag( + self, + request: ChatCompletionRequest | ResponsesRequest, + structure_tag: Any, + ) -> ChatCompletionRequest | ResponsesRequest: structural_tag = json.dumps(structure_tag.model_dump()) request.structured_outputs = StructuredOutputsParams( structural_tag=structural_tag, diff --git a/vllm/tool_parsers/structural_tag_registry.py b/vllm/tool_parsers/structural_tag_registry.py index 99c92f8f0a2e..8842d540583e 100644 --- a/vllm/tool_parsers/structural_tag_registry.py +++ b/vllm/tool_parsers/structural_tag_registry.py @@ -18,7 +18,9 @@ from xgrammar.structural_tag import ( AnyTextFormat, ConstStringFormat, + Format, JSONSchemaFormat, + OrFormat, SequenceFormat, TagFormat, TagsWithSeparatorFormat, @@ -95,6 +97,34 @@ def _any_tool_strict( return False +def _build_vllm_registry_tag( + model: str, + tools: Sequence[ChatCompletionToolsParam | ResponsesTool], + tool_choice: ToolChoice, + reasoning: bool, +) -> StructuralTag | None: + """Build a structural tag with a vLLM-owned registry builder. + + Returns ``None`` when ``model`` has no builder registered, in which case + callers fall back to the xgrammar builtin templates. + """ + if model not in _VLLM_STRUCTURAL_TAG_REGISTRY: + return None + + dumped_tools = [_dump_tool_for_xgrammar(tool) for tool in tools] + dumped_tool_choice = _dump_tool_choice_for_xgrammar(tool_choice) + function_tools, builtin_tools, simplified_tool_choice = normalize_tool_choice( + dumped_tools, + dumped_tool_choice, + ) + return _VLLM_STRUCTURAL_TAG_REGISTRY[model]( + function_tools, + builtin_tools, + simplified_tool_choice, + reasoning, + ) + + def get_model_structural_tag( model: str, tools: Sequence[ChatCompletionToolsParam | ResponsesTool] | None, @@ -109,25 +139,15 @@ def get_model_structural_tag( if tool_choice == "auto" and not _any_tool_strict(tools): return None - dumped_tools = [_dump_tool_for_xgrammar(tool) for tool in tools] - dumped_tool_choice = _dump_tool_choice_for_xgrammar(tool_choice) - if model in _VLLM_STRUCTURAL_TAG_REGISTRY: - function_tools, builtin_tools, simplified_tool_choice = normalize_tool_choice( - dumped_tools, - dumped_tool_choice, - ) - return _VLLM_STRUCTURAL_TAG_REGISTRY[model]( - function_tools, - builtin_tools, - simplified_tool_choice, - reasoning, - ) + return _build_vllm_registry_tag(model, tools, tool_choice, reasoning) if model not in XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS: supported = sorted(SUPPORTED_STRUCTURAL_TAG_MODELS) raise ValueError(f"Unknown format type: {model}, supported types: {supported}") + dumped_tools = [_dump_tool_for_xgrammar(tool) for tool in tools] + dumped_tool_choice = _dump_tool_choice_for_xgrammar(tool_choice) return get_xgrammar_model_structural_tag( model=model, tools=dumped_tools, @@ -136,6 +156,88 @@ def get_model_structural_tag( ) +def get_composed_structural_tag( + model: str, + tools: Sequence[ChatCompletionToolsParam | ResponsesTool] | None, + tool_choice: ToolChoice, + response_format_schema: dict[str, Any] | bool, +) -> StructuralTag | None: + """Build a structural tag that composes an auto tool call with a + response_format schema. + + This is the entry point for a ``tool_choice="auto"`` request that also + sets response_format: it wants both the freedom to call a tool and a + final answer constrained to the schema. Unlike ``get_model_structural_tag``, + this bypasses the strict-tools short circuit, since the whole point of this + path is to compose non-strict auto tool calling with a schema. Only the + vLLM-owned registry builders (hermes, minimax) can express the "at least + one tool call" branch that ``compose_tool_call_or_schema`` needs, so any + other model returns ``None`` and callers fall back to unchanged behavior. + + Args: + model: The tool parser's ``structural_tag_model`` key. + tools: The request's tools. + tool_choice: The request's tool_choice. Only ``"auto"`` composes. + response_format_schema: The response_format JSON schema (or ``True`` + for a bare json_object, i.e. any JSON). + + Returns: + The composed structural tag, or ``None`` when composition is not + possible for this model or tool_choice. + """ + if not tools or tool_choice != "auto": + return None + + tool_tag = _build_vllm_registry_tag(model, tools, tool_choice, reasoning=False) + if tool_tag is None: + return None + return compose_tool_call_or_schema(tool_tag, response_format_schema) + + +def compose_tool_call_or_schema( + tool_tag: StructuralTag, + json_schema: dict[str, Any] | bool, +) -> StructuralTag | None: + """Compose an auto tool-call structural tag with a response_format schema. + + A ``tool_choice="auto"`` request that also sets a response_format wants two + things at once: the freedom to call a tool, and a final answer constrained + to the schema. This builds an ``OrFormat`` whose first branch is the tool + call (forced to require at least one, so the schema branch is reachable) and + whose second branch is the schema-constrained answer. + + Args: + tool_tag: The auto tool-call structural tag from a registry builder. + json_schema: The response_format JSON schema (or ``True`` for any JSON). + + Returns: + The composed structural tag, or ``None`` when ``tool_tag`` cannot be + forced to require a tool call, in which case callers fall back to + dropping the response_format. + """ + tool_format = _require_tool_call(tool_tag.format) + if tool_format is None: + return None + return StructuralTag( + format=OrFormat( + elements=[tool_format, JSONSchemaFormat(json_schema=json_schema)] + ) + ) + + +def _require_tool_call(fmt: Format) -> Format | None: + """Force an auto tool-call format to require at least one tool call. + + Without this an auto ``TriggeredTagsFormat`` also matches free text with no + tool call, so an ``OrFormat`` schema branch would be unreachable and the + schema constraint vacuous. Returns ``None`` for formats that cannot express + this requirement. + """ + if isinstance(fmt, TriggeredTagsFormat) and fmt.tags: + return fmt.model_copy(update={"at_least_one": True}) + return None + + def _dump_tool_for_xgrammar( tool: ChatCompletionToolsParam | ResponsesTool, ) -> dict[str, Any]: