Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .buildkite/test_areas/rust_frontend.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions tests/tool_use/test_chat_completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import pytest

from .utils import (
MESSAGES_ASKING_FOR_TOOLS,
MESSAGES_WITHOUT_TOOLS,
SEED,
WEATHER_TOOL,
Expand Down Expand Up @@ -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
164 changes: 164 additions & 0 deletions tests/tool_use/test_tool_parser_adjust_request.py
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions vllm/tool_parsers/abstract_tool_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading