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]: