From 66890bd79b09e41d71966a3740804e69ff4d37f3 Mon Sep 17 00:00:00 2001 From: pablopupo <145598901+pablopupo@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:30:16 -0400 Subject: [PATCH] [Bugfix] Clear response format constraints when tool_choice is auto Fixes #39929. With tools present and tool_choice auto, unset, or null, a response format constraint boxed decoding into plain JSON so the model could never emit tool-call tokens. Clear response_format on ChatCompletionRequest and the text.format analog on ResponsesRequest in ToolParser.adjust_request for the auto case, keeping both for tool_choice none. Adds parser-level regression tests for both request types and an E2E test mirroring the existing required-variant test, excluded from the Rust frontend job the same way that variant is. Signed-off-by: pablopupo <145598901+pablopupo@users.noreply.github.com> --- .buildkite/test_areas/rust_frontend.yaml | 2 +- tests/tool_use/test_chat_completions.py | 40 +++++ .../test_tool_parser_adjust_request.py | 164 ++++++++++++++++++ vllm/tool_parsers/abstract_tool_parser.py | 16 ++ 4 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 tests/tool_use/test_tool_parser_adjust_request.py diff --git a/.buildkite/test_areas/rust_frontend.yaml b/.buildkite/test_areas/rust_frontend.yaml index adb27c4a049b..285b02da87ca 100644 --- a/.buildkite/test_areas/rust_frontend.yaml +++ b/.buildkite/test_areas/rust_frontend.yaml @@ -85,7 +85,7 @@ steps: commands: - export VLLM_USE_RUST_FRONTEND=1 - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -v -s tool_use --ignore=tool_use/mistral --models llama3.2 -k "not test_response_format_with_tool_choice_required and not test_parallel_tool_calls_false and not test_tool_call_and_choice" + - pytest -v -s tool_use --ignore=tool_use/mistral --models llama3.2 -k "not test_response_format_with_tool_choice_required and not test_response_format_with_tool_choice_auto and not test_parallel_tool_calls_false and not test_tool_call_and_choice" - label: Rust Frontend Distributed timeout_in_minutes: 30 diff --git a/tests/tool_use/test_chat_completions.py b/tests/tool_use/test_chat_completions.py index e5bb475875ac..c21207eb9094 100644 --- a/tests/tool_use/test_chat_completions.py +++ b/tests/tool_use/test_chat_completions.py @@ -5,6 +5,7 @@ import pytest from .utils import ( + MESSAGES_ASKING_FOR_TOOLS, MESSAGES_WITHOUT_TOOLS, SEED, WEATHER_TOOL, @@ -198,3 +199,42 @@ 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 + + +# Regression test for https://github.com/vllm-project/vllm/issues/39929 +# response_format: json_object suppressed tool calls when tool_choice: auto +@pytest.mark.asyncio +@pytest.mark.timeout(120) +async def test_response_format_with_tool_choice_auto( + client: openai.AsyncOpenAI, server_config: ServerConfig +): + """ + Test that tool calls still work when response_format: json_object is + combined with tool_choice: auto. + + Before the fix, response_format constrained decoding to plain JSON, so + the model could never emit tool-call tokens and answered with raw JSON + content instead of calling the tool. + """ + models = await client.models.list() + model_name: str = models.data[0].id + + # Same fixture and seed as the auto-choice tests in test_tool_calls.py, + # since calling the tool is a free model decision here, not a forced one + chat_completion = await client.chat.completions.create( + messages=ensure_system_prompt(MESSAGES_ASKING_FOR_TOOLS, server_config), + temperature=0, + max_completion_tokens=150, + model=model_name, + tools=[WEATHER_TOOL], + tool_choice="auto", + response_format={"type": "json_object"}, + seed=SEED, + ) + + # response_format is dropped for auto, leaving the model free to call + # the weather tool for a weather question + choice = chat_completion.choices[0] + assert choice.finish_reason == "tool_calls" + assert choice.message.tool_calls is not None + assert len(choice.message.tool_calls) > 0 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..6cfdce3d298a --- /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 +"""Regression tests for response_format handling in ToolParser.adjust_request. + +With tool_choice="auto" no schema is derived from the tools, so a +user-supplied response_format used to stay on the request and constrain +decoding to plain JSON, which prevented the model from ever emitting +tool-call tokens (https://github.com/vllm-project/vllm/issues/39929). +""" + +from __future__ import annotations + +from typing import Any + +from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest +from vllm.entrypoints.openai.responses.protocol import ResponsesRequest +from vllm.tool_parsers.abstract_tool_parser import ToolParser + +WEATHER_TOOL = { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get current weather for a city", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + }, +} + +WEATHER_TOOL_RESPONSES = { + "type": "function", + "name": "get_weather", + "description": "Get current weather for a city", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + "strict": True, +} + + +def _build_request(**overrides: Any) -> ChatCompletionRequest: + data: dict[str, Any] = { + "model": "test-model", + "messages": [{"role": "user", "content": "What is the weather in Dallas?"}], + "tools": [WEATHER_TOOL], + "response_format": {"type": "json_object"}, + **overrides, + } + return ChatCompletionRequest.model_validate(data) + + +def _build_responses_request(**overrides: Any) -> ResponsesRequest: + data: dict[str, Any] = { + "model": "test-model", + "input": [{"role": "user", "content": "What is the weather in Dallas?"}], + "tools": [WEATHER_TOOL_RESPONSES], + "text": {"format": {"type": "json_object"}}, + **overrides, + } + return ResponsesRequest.model_validate(data) + + +def _adjust( + request: ChatCompletionRequest | ResponsesRequest, +) -> ChatCompletionRequest | ResponsesRequest: + parser = ToolParser.__new__(ToolParser) + return ToolParser.adjust_request(parser, request) + + +def test_auto_clears_response_format() -> None: + """auto: response_format must be cleared so tool-call tokens stay + reachable, and no structured output constraint is added.""" + request = _build_request(tool_choice="auto") + + _adjust(request) + + assert request.response_format is None + assert request.structured_outputs is None + + +def test_unset_tool_choice_clears_response_format() -> None: + """Unset tool_choice with tools present defaults to auto during request + validation and must be treated the same.""" + request = _build_request() + assert request.tool_choice == "auto", ( + "Precondition: unset tool_choice defaults to auto when tools are set" + ) + + _adjust(request) + + assert request.response_format is None + + +def test_null_tool_choice_clears_response_format() -> None: + """An explicit null tool_choice passes validation untouched and must be + treated like auto.""" + request = _build_request(tool_choice=None) + assert request.tool_choice is None + + _adjust(request) + + assert request.response_format is None + + +def test_none_tool_choice_preserves_response_format() -> None: + """none: the caller asked for a formatted reply with tool calling + disabled, so response_format must survive.""" + request = _build_request(tool_choice="none") + + _adjust(request) + + assert request.response_format is not None + assert request.response_format.type == "json_object" + + +def test_required_overrides_response_format_with_tool_schema() -> None: + """required: the schema derived from the tools replaces response_format, + same as before this fix (#32006).""" + request = _build_request(tool_choice="required") + + _adjust(request) + + assert request.response_format is None + assert request.structured_outputs is not None + assert request.structured_outputs.json is not None + + +def test_no_tools_preserves_response_format() -> None: + """Without tools adjust_request returns early and response_format is + untouched.""" + request = _build_request(tools=None, tool_choice=None) + + _adjust(request) + + assert request.response_format is not None + assert request.response_format.type == "json_object" + + +def test_responses_auto_clears_text_format() -> None: + """Responses API: text.format is the response_format analog and must be + dropped for auto so tool-call tokens stay reachable.""" + request = _build_responses_request() + assert request.tool_choice == "auto", ( + "Precondition: ResponsesRequest.tool_choice defaults to auto" + ) + assert request.text is not None and request.text.format is not None + + _adjust(request) + + assert request.text is None or request.text.format is None + + +def test_responses_none_tool_choice_preserves_text_format() -> None: + """Responses API: tool_choice none keeps the caller's text format.""" + request = _build_responses_request(tool_choice="none") + + _adjust(request) + + assert request.text is not None + assert request.text.format is not None diff --git a/vllm/tool_parsers/abstract_tool_parser.py b/vllm/tool_parsers/abstract_tool_parser.py index a1c4cf1ffaeb..db7044601f4c 100644 --- a/vllm/tool_parsers/abstract_tool_parser.py +++ b/vllm/tool_parsers/abstract_tool_parser.py @@ -162,6 +162,22 @@ def adjust_request( strict=True, ) ) + elif request.tool_choice in ("auto", None): + # tool_choice "auto" (or unset, which defaults to auto when tools + # are provided) must leave the model free to emit tool-call + # tokens, which a response format constraint would prevent. + # tool_choice "none" keeps the response format since tools are + # disabled anyway. + if isinstance(request, ChatCompletionRequest): + request.response_format = None + if ( + isinstance(request, ResponsesRequest) + and request.text is not None + and request.text.format is not None + ): + # Single-shot copy for the same Pydantic v2 reason as above, + # keeping unrelated text settings intact + request.text = request.text.model_copy(update={"format": None}) return request