Add vLLM v0.18.1 source tree with KV transfer abort fix

third_party/vllm/ now tracked in git for direct patch management.
Based on vLLM v0.18.1 release with one patch applied:

  vllm/v1/core/sched/scheduler.py:
    Replace fatal assert with graceful skip when KV transfer callback
    arrives for an already-aborted request during PD disaggregated serving.

Future vLLM modifications should be made directly in third_party/vllm/
and committed normally. The patches/ directory is kept as documentation
of what changed from upstream.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 00:30:38 +08:00
parent b6591950bc
commit 445e491123
4285 changed files with 1111303 additions and 1 deletions

View File

@@ -0,0 +1,363 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import json
import logging
from collections.abc import Callable
from typing import Any
import pytest
logger = logging.getLogger(__name__)
BASE_TEST_ENV = {
# The day vLLM said "hello world" on arxiv 🚀
"VLLM_SYSTEM_START_DATE": "2023-09-12",
}
DEFAULT_MAX_RETRIES = 3
@pytest.fixture
def pairs_of_event_types() -> dict[str, str]:
"""Links the 'done' event type with the corresponding 'start' event type.
This mapping should link all done <-> start events; if tests mean to
restrict the allowed events, they should filter this fixture to avoid
copy + paste errors in the mappings or unexpected KeyErrors due to missing
events.
"""
# fmt: off
event_pairs = {
"response.completed": "response.created",
"response.output_item.done": "response.output_item.added",
"response.content_part.done": "response.content_part.added",
"response.output_text.done": "response.output_text.delta",
"response.reasoning_text.done": "response.reasoning_text.delta",
"response.reasoning_part.done": "response.reasoning_part.added",
"response.mcp_call_arguments.done": "response.mcp_call_arguments.delta",
"response.mcp_call.completed": "response.mcp_call.in_progress",
"response.function_call_arguments.done": "response.function_call_arguments.delta", # noqa: E501
"response.code_interpreter_call_code.done": "response.code_interpreter_call_code.delta", # noqa: E501
"response.code_interpreter_call.completed": "response.code_interpreter_call.in_progress", # noqa: E501
"response.web_search_call.completed": "response.web_search_call.in_progress",
}
# fmt: on
return event_pairs
async def retry_for_tool_call(
client,
*,
model: str,
expected_tool_type: str,
max_retries: int = DEFAULT_MAX_RETRIES,
**create_kwargs: Any,
):
"""Call ``client.responses.create`` up to *max_retries* times, returning
the first response that contains an output item of *expected_tool_type*.
Returns the **last** response if none match so the caller's assertions
fire with a clear diagnostic.
"""
last_response = None
for attempt in range(max_retries):
response = await client.responses.create(model=model, **create_kwargs)
last_response = response
if any(
getattr(item, "type", None) == expected_tool_type
for item in response.output
):
return response
assert last_response is not None
return last_response
async def retry_streaming_for(
client,
*,
model: str,
validate_events: Callable[[list], bool],
max_retries: int = DEFAULT_MAX_RETRIES,
**create_kwargs: Any,
) -> list:
"""Call ``client.responses.create(stream=True)`` up to *max_retries*
times, returning the first event list where *validate_events* returns
``True``.
"""
last_events: list = []
for attempt in range(max_retries):
stream = await client.responses.create(
model=model, stream=True, **create_kwargs
)
events: list = []
async for event in stream:
events.append(event)
last_events = events
if validate_events(events):
return events
return last_events
def has_output_type(response, type_name: str) -> bool:
"""Return True if *response* has at least one output item of *type_name*."""
return any(getattr(item, "type", None) == type_name for item in response.output)
def events_contain_type(events: list, type_substring: str) -> bool:
"""Return True if any event's type contains *type_substring*."""
return any(type_substring in getattr(e, "type", "") for e in events)
def _validate_event_pairing(events: list, pairs_of_event_types: dict[str, str]) -> None:
"""Validate that streaming events are properly nested/paired.
Derives push/pop sets from *pairs_of_event_types* so that every
start/end pair in the dict is handled automatically.
"""
start_events = set(pairs_of_event_types.values())
end_events = set(pairs_of_event_types.keys())
stack: list[str] = []
for event in events:
etype = event.type
if etype in end_events:
expected_start = pairs_of_event_types[etype]
assert stack and stack[-1] == expected_start, (
f"Stack mismatch for {etype}: "
f"expected {expected_start}, "
f"got {stack[-1] if stack else '<empty>'}"
)
stack.pop()
elif etype in start_events:
# Consecutive deltas of the same type share a single stack slot.
if etype.endswith("delta") and stack and stack[-1] == etype:
continue
stack.append(etype)
# else: passthrough event (e.g. response.in_progress,
# web_search_call.searching, code_interpreter_call.interpreting)
assert len(stack) == 0, f"Unclosed events on stack: {stack}"
def _validate_event_ordering(events: list) -> None:
"""Validate that envelope events appear in the correct positions."""
assert len(events) >= 2, f"Expected at least 2 events, got {len(events)}"
# First event must be response.created
assert events[0].type == "response.created", (
f"First event must be response.created, got {events[0].type}"
)
# Last event must be response.completed
assert events[-1].type == "response.completed", (
f"Last event must be response.completed, got {events[-1].type}"
)
# response.in_progress, if present, must be the second event
in_progress_indices = [
i for i, e in enumerate(events) if e.type == "response.in_progress"
]
if in_progress_indices:
assert in_progress_indices == [1], (
f"response.in_progress must be the second event, "
f"found at indices {in_progress_indices}"
)
# Exactly one created and one completed
created_count = sum(1 for e in events if e.type == "response.created")
completed_count = sum(1 for e in events if e.type == "response.completed")
assert created_count == 1, (
f"Expected exactly 1 response.created, got {created_count}"
)
assert completed_count == 1, (
f"Expected exactly 1 response.completed, got {completed_count}"
)
def _validate_field_consistency(events: list) -> None:
"""Validate item_id, output_index, and content_index consistency.
Tracks the active output item established by ``output_item.added``
and verifies that all subsequent events for that item carry matching
identifiers until ``output_item.done`` closes it.
"""
_SESSION_EVENTS = {
"response.created",
"response.in_progress",
"response.completed",
}
active_item_id: str | None = None
active_output_index: int | None = None
last_output_index: int = -1
active_content_index: int | None = None
for event in events:
etype = event.type
if etype in _SESSION_EVENTS:
continue
# --- output_item.added: opens a new item ------------------
if etype == "response.output_item.added":
item = getattr(event, "item", None)
output_index = getattr(event, "output_index", None)
assert item is not None, "output_item.added must have an item"
item_id = getattr(item, "id", None)
assert item_id, "output_item.added item must have an id"
# output_index must be non-decreasing across items
if output_index is not None:
assert output_index >= last_output_index, (
f"output_index went backwards: {output_index} < {last_output_index}"
)
last_output_index = output_index
active_item_id = item_id
active_output_index = output_index
active_content_index = None
continue
# --- output_item.done: closes the active item -------------
if etype == "response.output_item.done":
item = getattr(event, "item", None)
output_index = getattr(event, "output_index", None)
assert item is not None, "output_item.done must have an item"
done_item_id = getattr(item, "id", None)
if active_item_id is not None and done_item_id:
assert done_item_id == active_item_id, (
f"output_item.done item.id mismatch: "
f"expected {active_item_id}, got {done_item_id}"
)
if active_output_index is not None and output_index is not None:
assert output_index == active_output_index, (
f"output_item.done output_index mismatch: "
f"expected {active_output_index}, got {output_index}"
)
active_item_id = None
active_output_index = None
active_content_index = None
continue
# --- content_part / reasoning_part added: sets content_index
if etype in (
"response.content_part.added",
"response.reasoning_part.added",
):
_assert_item_fields(event, etype, active_item_id, active_output_index)
active_content_index = getattr(event, "content_index", None)
continue
# --- all other item-level events --------------------------
_assert_item_fields(event, etype, active_item_id, active_output_index)
# content_index (only meaningful on events that carry it)
content_index = getattr(event, "content_index", None)
if content_index is not None and active_content_index is not None:
assert content_index == active_content_index, (
f"{etype} content_index mismatch: "
f"expected {active_content_index}, got {content_index}"
)
def _assert_item_fields(
event,
etype: str,
active_item_id: str | None,
active_output_index: int | None,
) -> None:
"""Check that *event*'s item_id and output_index match the active item."""
event_item_id = getattr(event, "item_id", None)
output_index = getattr(event, "output_index", None)
if active_item_id is not None and event_item_id is not None:
assert event_item_id == active_item_id, (
f"{etype} item_id mismatch: expected {active_item_id}, got {event_item_id}"
)
if active_output_index is not None and output_index is not None:
assert output_index == active_output_index, (
f"{etype} output_index mismatch: "
f"expected {active_output_index}, got {output_index}"
)
def validate_streaming_event_stack(
events: list, pairs_of_event_types: dict[str, str]
) -> None:
"""Validate streaming events: pairing, ordering, and field consistency.
Checks three aspects:
1. **Event pairing** — start/end events are properly nested
(stack-based matching derived from *pairs_of_event_types*).
2. **Event ordering** — envelope events (``created``,
``in_progress``, ``completed``) appear at the correct positions.
3. **Field consistency** — ``item_id``, ``output_index``, and
``content_index`` are consistent across related events within
each output item's lifecycle.
"""
_validate_event_pairing(events, pairs_of_event_types)
_validate_event_ordering(events)
_validate_field_consistency(events)
def log_response_diagnostics(
response,
*,
label: str = "Response Diagnostics",
) -> dict[str, Any]:
"""Extract and log diagnostic info from a Responses API response.
Logs reasoning, tool-call attempts, MCP items, and output types so
that CI output (``pytest -s`` or ``--log-cli-level=INFO``) gives
full visibility into model behaviour even on passing runs.
Returns the extracted data so callers can make additional assertions
if needed.
"""
reasoning_texts = [
text
for item in response.output
if getattr(item, "type", None) == "reasoning"
for content in getattr(item, "content", [])
if (text := getattr(content, "text", None))
]
tool_call_attempts = [
{
"recipient": msg.get("recipient"),
"channel": msg.get("channel"),
}
for msg in response.output_messages
if (msg.get("recipient") or "").startswith("python")
]
mcp_items = [
{
"name": getattr(item, "name", None),
"status": getattr(item, "status", None),
}
for item in response.output
if getattr(item, "type", None) == "mcp_call"
]
output_types = [getattr(o, "type", None) for o in response.output]
diagnostics = {
"model_attempted_tool_calls": bool(tool_call_attempts),
"tool_call_attempts": tool_call_attempts,
"mcp_items": mcp_items,
"reasoning": reasoning_texts,
"output_text": response.output_text,
"output_types": output_types,
}
logger.info(
"\n====== %s ======\n%s\n==============================",
label,
json.dumps(diagnostics, indent=2, default=str),
)
return diagnostics

View File

@@ -0,0 +1,62 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from http import HTTPStatus
from unittest.mock import MagicMock
import pytest
from vllm.entrypoints.openai.engine.serving import GenerationError, OpenAIServing
@pytest.mark.asyncio
async def test_raise_if_error_raises_generation_error():
"""test _raise_if_error raises GenerationError"""
# create a minimal OpenAIServing instance
mock_engine = MagicMock()
mock_engine.model_config = MagicMock()
mock_engine.model_config.max_model_len = 100
mock_models = MagicMock()
serving = OpenAIServing(
engine_client=mock_engine,
models=mock_models,
request_logger=None,
)
# test that error finish_reason raises GenerationError
with pytest.raises(GenerationError) as exc_info:
serving._raise_if_error("error", "test-request-id")
assert str(exc_info.value) == "Internal server error"
assert exc_info.value.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
# test that other finish_reasons don't raise
serving._raise_if_error("stop", "test-request-id") # should not raise
serving._raise_if_error("length", "test-request-id") # should not raise
serving._raise_if_error(None, "test-request-id") # should not raise
@pytest.mark.asyncio
async def test_convert_generation_error_to_streaming_response():
"""test _convert_generation_error_to_streaming_response output"""
mock_engine = MagicMock()
mock_engine.model_config = MagicMock()
mock_engine.model_config.max_model_len = 100
mock_models = MagicMock()
serving = OpenAIServing(
engine_client=mock_engine,
models=mock_models,
request_logger=None,
)
# create a GenerationError
gen_error = GenerationError("Internal server error")
# convert to streaming error response
error_json = serving._convert_generation_error_to_streaming_response(gen_error)
assert isinstance(error_json, str)
assert "Internal server error" in error_json
assert "InternalServerError" in error_json

View File

@@ -0,0 +1,330 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Test function call parsing in ResponsesRequest."""
import json
import pytest
from openai.types.responses import ResponseFunctionToolCall
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
def test_function_call_dict_converted_to_object():
"""Test that function_call dictionaries are correctly parsed into
ResponseFunctionToolCall objects."""
# Create a request with function_call as dict
request_data = {
"model": "gpt-oss",
"input": [
{
"type": "function_call",
"call_id": "fc_123",
"name": "get_weather",
"arguments": '{"location": "Boston", "unit": "celsius"}',
}
],
}
request = ResponsesRequest(**request_data)
# Verify the input item is now a ResponseFunctionToolCall object
assert len(request.input) == 1
assert isinstance(request.input[0], ResponseFunctionToolCall)
assert request.input[0].call_id == "fc_123"
assert request.input[0].name == "get_weather"
assert request.input[0].arguments == '{"location": "Boston", "unit": "celsius"}'
def test_direct_function_call_object_preservation():
"""Test that ResponseFunctionToolCall objects passed directly are preserved."""
# Create a request with ResponseFunctionToolCall object
function_call = ResponseFunctionToolCall(
type="function_call",
call_id="fc_456",
name="get_stock_price",
arguments='{"symbol": "AAPL"}',
)
request_data = {"model": "gpt-oss", "input": [function_call]}
request = ResponsesRequest(**request_data)
# Verify the object is preserved
assert len(request.input) == 1
assert request.input[0] is function_call
def test_mixed_input_types_with_function_calls():
"""Test parsing with mixed input types including function calls."""
request_data = {
"model": "gpt-oss",
"input": [
# Valid Message type
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "What's the weather?"}],
},
# Function call that should be parsed
{
"type": "function_call",
"call_id": "fc_789",
"name": "check_weather",
"arguments": '{"location": "NYC"}',
},
# Another function call
{
"type": "function_call",
"call_id": "fc_790",
"name": "get_time",
"arguments": "{}",
},
],
}
request = ResponsesRequest(**request_data)
# Verify mixed types are handled correctly
assert len(request.input) == 3
# First item should be validated as Message
assert request.input[0]["type"] == "message"
# Second item should be parsed to ResponseFunctionToolCall
assert isinstance(request.input[1], ResponseFunctionToolCall)
assert request.input[1].call_id == "fc_789"
assert request.input[1].name == "check_weather"
# Third item should also be parsed to ResponseFunctionToolCall
assert isinstance(request.input[2], ResponseFunctionToolCall)
assert request.input[2].call_id == "fc_790"
assert request.input[2].name == "get_time"
def test_function_call_with_complex_arguments():
"""Test parsing function calls with complex nested arguments."""
complex_args = {
"query": "weather forecast",
"filters": {
"location": {"city": "San Francisco", "state": "CA"},
"timeRange": {"start": "2024-01-01", "end": "2024-01-07"},
"metrics": ["temperature", "humidity", "precipitation"],
},
"options": {"format": "detailed", "includeAlerts": True},
}
request_data = {
"model": "gpt-oss",
"input": [
{
"type": "function_call",
"call_id": "fc_complex",
"name": "advanced_weather_query",
"arguments": json.dumps(complex_args),
}
],
}
request = ResponsesRequest(**request_data)
# Verify complex arguments are preserved correctly
assert len(request.input) == 1
assert isinstance(request.input[0], ResponseFunctionToolCall)
assert request.input[0].call_id == "fc_complex"
assert request.input[0].name == "advanced_weather_query"
# Parse the arguments back to verify they're intact
parsed_args = json.loads(request.input[0].arguments)
assert parsed_args == complex_args
def test_invalid_function_call_fallback():
"""Test that invalid function call dictionaries fall back gracefully."""
# Missing required field 'call_id'
request_data = {
"model": "gpt-oss",
"input": [
{"type": "function_call", "name": "incomplete_function", "arguments": "{}"}
],
}
# This should not raise an error during model creation
# The validator should keep the original dict and let Pydantic
# handle validation
with pytest.raises(ValueError):
# Pydantic should raise a validation error for the invalid structure
ResponsesRequest(**request_data)
def test_string_input_not_affected():
"""Test that string input is not affected by the validator."""
request_data = {"model": "gpt-oss", "input": "This is a simple string input"}
request = ResponsesRequest(**request_data)
# Verify string input remains unchanged
assert request.input == "This is a simple string input"
def test_empty_list_input():
"""Test that empty list input is handled correctly."""
request_data = {"model": "gpt-oss", "input": []}
request = ResponsesRequest(**request_data)
# Verify empty list is preserved
assert request.input == []
def test_function_call_output_not_affected():
"""Test that FunctionCallOutput is not affected by the function_call parsing."""
# Test with FunctionCallOutput as dict (should not be parsed)
request_data = {
"model": "gpt-oss",
"input": [
{
"type": "function_call_output",
"call_id": "fc_output_123",
"output": "The weather in Boston is 72°F and sunny.",
}
],
}
request = ResponsesRequest(**request_data)
# FunctionCallOutput should remain as dict (not converted to an object)
assert len(request.input) == 1
assert isinstance(request.input[0], dict)
assert request.input[0]["type"] == "function_call_output"
assert request.input[0]["call_id"] == "fc_output_123"
assert request.input[0]["output"] == "The weather in Boston is 72°F and sunny."
def test_mixed_function_call_and_output():
"""Test that function_call is parsed while function_call_output is preserved."""
request_data = {
"model": "gpt-oss",
"input": [
# This should be parsed to ResponseFunctionToolCall
{
"type": "function_call",
"call_id": "fc_call_456",
"name": "get_weather",
"arguments": '{"location": "NYC"}',
},
# This should remain as dict
{
"type": "function_call_output",
"call_id": "fc_call_456",
"output": "NYC weather is 68°F with light rain",
},
],
}
request = ResponsesRequest(**request_data)
assert len(request.input) == 2
# First item should be parsed to ResponseFunctionToolCall
assert isinstance(request.input[0], ResponseFunctionToolCall)
assert request.input[0].call_id == "fc_call_456"
assert request.input[0].name == "get_weather"
# Second item should remain as dict (FunctionCallOutput)
assert isinstance(request.input[1], dict)
assert request.input[1]["type"] == "function_call_output"
assert request.input[1]["call_id"] == "fc_call_456"
assert request.input[1]["output"] == "NYC weather is 68°F with light rain"
def test_function_call_validation_failure_logs_debug(caplog):
"""Test that validation failures are logged at debug level."""
from unittest.mock import patch
request_data = {
"model": "gpt-oss",
"input": [
{
"type": "function_call",
"name": "incomplete_function",
"arguments": "{}", # Missing call_id
}
],
}
# Mock the logger to verify debug was called
with patch("vllm.entrypoints.openai.responses.protocol.logger") as mock_logger:
with pytest.raises(ValueError):
ResponsesRequest(**request_data)
# Verify debug was called with expected message
mock_logger.debug.assert_called_once()
call_args = mock_logger.debug.call_args[0][0]
assert "Failed to parse function_call" in call_args
def test_validator_handles_iterator_input():
"""Test that validator can handle ValidatorIterator input (Pydantic internal)."""
# This test simulates when Pydantic passes a ValidatorIterator instead of a list
# This happened with complex nested structures containing reasoning + function_call
# Create test data that would normally be a list
test_input_items = [
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Test"}],
},
{
"type": "reasoning",
"id": "rs_1",
"summary": [{"type": "summary_text", "text": "Test reasoning"}],
"content": [{"type": "reasoning_text", "text": "Test content"}],
},
{
"type": "function_call",
"call_id": "call_1",
"name": "test_function",
"arguments": '{"test": "value"}',
"id": "fc_1",
},
]
# Mock data where input is an iterator (simulates Pydantic ValidatorIterator)
mock_data = {
"model": "test-model",
"input": iter(test_input_items), # Iterator instead of list
}
# This should NOT raise an error with the fixed validator
try:
request = ResponsesRequest(**mock_data)
# Verify the validator processed the data correctly
assert len(request.input) == 3
# Verify function_call was converted to ResponseFunctionToolCall object
function_call_item = None
for item in request.input:
if isinstance(item, ResponseFunctionToolCall):
function_call_item = item
break
assert function_call_item is not None
assert function_call_item.call_id == "call_1"
assert function_call_item.name == "test_function"
except Exception as e:
pytest.fail(f"Validator should handle iterator input, but failed with: {e}")
def test_validator_handles_empty_iterator():
"""Test validator handles empty iterator gracefully."""
mock_data = {
"model": "test-model",
"input": iter([]), # Empty iterator
}
request = ResponsesRequest(**mock_data)
assert request.input == []

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,463 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for vllm.entrypoints.openai.responses.harmony."""
from openai.types.responses import (
ResponseFunctionToolCall,
ResponseOutputMessage,
ResponseReasoningItem,
)
from openai.types.responses.response_output_item import McpCall
from openai_harmony import Author, Message, Role, TextContent
from vllm.entrypoints.openai.responses.harmony import (
harmony_to_response_output,
parser_state_to_response_output,
response_previous_input_to_harmony,
)
class TestResponsePreviousInputToHarmony:
"""
Tests for scenarios that are specific to the Responses API
response_previous_input_to_harmony function.
"""
def test_message_with_empty_content(self):
"""Test parsing message with empty string content."""
chat_msg = {
"role": "user",
"content": "",
}
messages = response_previous_input_to_harmony(chat_msg)
assert len(messages) == 1
assert messages[0].content[0].text == ""
def test_tool_message_with_string_content(self):
"""Test parsing tool message with string content."""
chat_msg = {
"role": "tool",
"name": "get_weather",
"content": "The weather in San Francisco is sunny, 72°F",
}
messages = response_previous_input_to_harmony(chat_msg)
assert len(messages) == 1
assert messages[0].author.role == Role.TOOL
assert messages[0].author.name == "functions.get_weather"
assert (
messages[0].content[0].text == "The weather in San Francisco is sunny, 72°F"
)
assert messages[0].channel == "commentary"
def test_tool_message_with_array_content(self):
"""Test parsing tool message with array content."""
chat_msg = {
"role": "tool",
"name": "search_results",
"content": [
{"type": "text", "text": "Result 1: "},
{"type": "text", "text": "Result 2: "},
{
"type": "image",
"url": "http://example.com/img.png",
}, # Should be ignored
{"type": "text", "text": "Result 3"},
],
}
messages = response_previous_input_to_harmony(chat_msg)
assert len(messages) == 1
assert messages[0].author.role == Role.TOOL
assert messages[0].author.name == "functions.search_results"
assert messages[0].content[0].text == "Result 1: Result 2: Result 3"
def test_tool_message_with_empty_content(self):
"""Test parsing tool message with None content."""
chat_msg = {
"role": "tool",
"name": "empty_tool",
"content": None,
}
messages = response_previous_input_to_harmony(chat_msg)
assert len(messages) == 1
assert messages[0].author.role == Role.TOOL
assert messages[0].author.name == "functions.empty_tool"
assert messages[0].content[0].text == ""
class TestHarmonyToResponseOutput:
"""Tests for harmony_to_response_output function."""
def test_commentary_with_no_recipient_creates_message(self):
"""Test that commentary with recipient=None (preambles) creates message items.
Per Harmony format, preambles are intended to be shown to end-users,
unlike analysis channel content which is hidden reasoning.
See: https://cookbook.openai.com/articles/openai-harmony
"""
message = Message.from_role_and_content(
Role.ASSISTANT, "I will now search for the weather information."
)
message = message.with_channel("commentary")
# recipient is None by default, representing a preamble
output_items = harmony_to_response_output(message)
assert len(output_items) == 1
assert isinstance(output_items[0], ResponseOutputMessage)
assert output_items[0].type == "message"
assert output_items[0].role == "assistant"
assert output_items[0].status == "completed"
assert len(output_items[0].content) == 1
assert output_items[0].content[0].type == "output_text"
assert (
output_items[0].content[0].text
== "I will now search for the weather information."
)
def test_commentary_with_function_recipient_creates_function_call(self):
"""Test commentary with recipient='functions.X' creates function calls."""
message = Message.from_role_and_content(
Role.ASSISTANT, '{"location": "San Francisco", "units": "celsius"}'
)
message = message.with_channel("commentary")
message = message.with_recipient("functions.get_weather")
output_items = harmony_to_response_output(message)
assert len(output_items) == 1
assert isinstance(output_items[0], ResponseFunctionToolCall)
assert output_items[0].type == "function_call"
assert output_items[0].name == "get_weather"
assert (
output_items[0].arguments
== '{"location": "San Francisco", "units": "celsius"}'
)
assert output_items[0].call_id.startswith("call_")
assert output_items[0].id.startswith("fc_")
def test_commentary_with_python_recipient_creates_reasoning(self):
"""Test that commentary with recipient='python' creates reasoning items."""
message = Message.from_role_and_content(
Role.ASSISTANT, "import numpy as np\nprint(np.array([1, 2, 3]))"
)
message = message.with_channel("commentary")
message = message.with_recipient("python")
output_items = harmony_to_response_output(message)
assert len(output_items) == 1
assert isinstance(output_items[0], ResponseReasoningItem)
assert output_items[0].type == "reasoning"
assert (
output_items[0].content[0].text
== "import numpy as np\nprint(np.array([1, 2, 3]))"
)
def test_commentary_with_browser_recipient_creates_reasoning(self):
"""Test that commentary with recipient='browser' creates reasoning items."""
message = Message.from_role_and_content(
Role.ASSISTANT, "Navigating to the specified URL"
)
message = message.with_channel("commentary")
message = message.with_recipient("browser")
output_items = harmony_to_response_output(message)
assert len(output_items) == 1
assert isinstance(output_items[0], ResponseReasoningItem)
assert output_items[0].type == "reasoning"
assert output_items[0].content[0].text == "Navigating to the specified URL"
def test_commentary_with_container_recipient_creates_reasoning(self):
"""Test that commentary with recipient='container' creates reasoning items."""
message = Message.from_role_and_content(
Role.ASSISTANT, "Running command in container"
)
message = message.with_channel("commentary")
message = message.with_recipient("container")
output_items = harmony_to_response_output(message)
assert len(output_items) == 1
assert isinstance(output_items[0], ResponseReasoningItem)
assert output_items[0].type == "reasoning"
assert output_items[0].content[0].text == "Running command in container"
def test_commentary_with_empty_content_and_no_recipient(self):
"""Test edge case: empty commentary with recipient=None."""
message = Message.from_role_and_content(Role.ASSISTANT, "")
message = message.with_channel("commentary")
output_items = harmony_to_response_output(message)
assert len(output_items) == 1
assert isinstance(output_items[0], ResponseOutputMessage)
assert output_items[0].content[0].text == ""
def test_commentary_with_multiple_contents_and_no_recipient(self):
"""Test multiple content items in commentary with no recipient."""
contents = [
TextContent(text="Step 1: Analyze the request"),
TextContent(text="Step 2: Prepare to call functions"),
]
message = Message.from_role_and_contents(Role.ASSISTANT, contents)
message = message.with_channel("commentary")
output_items = harmony_to_response_output(message)
# _parse_final_message returns single ResponseOutputMessage with
# multiple contents
assert len(output_items) == 1
assert isinstance(output_items[0], ResponseOutputMessage)
assert len(output_items[0].content) == 2
assert output_items[0].content[0].text == "Step 1: Analyze the request"
assert output_items[0].content[1].text == "Step 2: Prepare to call functions"
def test_commentary_with_multiple_function_calls(self):
"""Test multiple function calls in commentary channel."""
contents = [
TextContent(text='{"location": "San Francisco"}'),
TextContent(text='{"location": "New York"}'),
]
message = Message.from_role_and_contents(Role.ASSISTANT, contents)
message = message.with_channel("commentary")
message = message.with_recipient("functions.get_weather")
output_items = harmony_to_response_output(message)
assert len(output_items) == 2
assert all(isinstance(item, ResponseFunctionToolCall) for item in output_items)
assert output_items[0].name == "get_weather"
assert output_items[1].name == "get_weather"
assert output_items[0].arguments == '{"location": "San Francisco"}'
assert output_items[1].arguments == '{"location": "New York"}'
def test_commentary_with_unknown_recipient_creates_mcp_call(self):
"""Test that commentary with unknown recipient creates MCP call."""
message = Message.from_role_and_content(Role.ASSISTANT, '{"arg": "value"}')
message = message.with_channel("commentary")
message = message.with_recipient("custom_tool")
output_items = harmony_to_response_output(message)
assert len(output_items) == 1
assert isinstance(output_items[0], McpCall)
assert output_items[0].type == "mcp_call"
assert output_items[0].name == "custom_tool"
assert output_items[0].server_label == "custom_tool"
def test_analysis_channel_creates_reasoning(self):
"""Test that analysis channel creates reasoning items."""
message = Message.from_role_and_content(
Role.ASSISTANT, "Analyzing the problem step by step..."
)
message = message.with_channel("analysis")
output_items = harmony_to_response_output(message)
assert len(output_items) == 1
assert isinstance(output_items[0], ResponseReasoningItem)
assert output_items[0].type == "reasoning"
assert (
output_items[0].content[0].text == "Analyzing the problem step by step..."
)
def test_non_assistant_message_returns_empty(self):
"""Test that non-assistant messages return empty list.
Per the implementation, tool messages to assistant (e.g., search results)
are not included in final output to align with OpenAI behavior.
"""
message = Message.from_author_and_content(
Author.new(Role.TOOL, "functions.get_weather"),
"The weather is sunny, 72°F",
)
output_items = harmony_to_response_output(message)
assert len(output_items) == 0
def test_parse_mcp_call_basic() -> None:
"""Test that MCP calls are parsed with correct type and server_label."""
message = Message.from_role_and_content(Role.ASSISTANT, '{"path": "/tmp"}')
message = message.with_recipient("filesystem")
message = message.with_channel("commentary")
output_items = harmony_to_response_output(message)
assert len(output_items) == 1
assert isinstance(output_items[0], McpCall)
assert output_items[0].type == "mcp_call"
assert output_items[0].name == "filesystem"
assert output_items[0].server_label == "filesystem"
assert output_items[0].arguments == '{"path": "/tmp"}'
assert output_items[0].status == "completed"
def test_parse_mcp_call_dotted_recipient() -> None:
"""Test that dotted recipients extract the tool name correctly."""
message = Message.from_role_and_content(Role.ASSISTANT, '{"cmd": "ls"}')
message = message.with_recipient("repo_browser.list")
message = message.with_channel("commentary")
output_items = harmony_to_response_output(message)
assert len(output_items) == 1
assert isinstance(output_items[0], McpCall)
assert output_items[0].name == "list"
assert output_items[0].server_label == "repo_browser"
def test_mcp_vs_function_call() -> None:
"""Test that function calls are not parsed as MCP calls."""
func_message = Message.from_role_and_content(Role.ASSISTANT, '{"arg": "value"}')
func_message = func_message.with_recipient("functions.my_tool")
func_message = func_message.with_channel("commentary")
func_items = harmony_to_response_output(func_message)
assert len(func_items) == 1
assert not isinstance(func_items[0], McpCall)
assert func_items[0].type == "function_call"
def test_mcp_vs_builtin_tools() -> None:
"""Test that built-in tools (python, container) are not parsed as MCP calls."""
# Test python (built-in tool) - should be reasoning, not MCP
python_message = Message.from_role_and_content(Role.ASSISTANT, "print('hello')")
python_message = python_message.with_recipient("python")
python_message = python_message.with_channel("commentary")
python_items = harmony_to_response_output(python_message)
assert len(python_items) == 1
assert not isinstance(python_items[0], McpCall)
assert python_items[0].type == "reasoning"
def test_parser_state_to_response_output_commentary_channel() -> None:
"""Test parser_state_to_response_output with commentary
channel and various recipients."""
from unittest.mock import Mock
# Test 1: functions.* recipient -> should return function tool call
parser_func = Mock()
parser_func.current_content = '{"arg": "value"}'
parser_func.current_role = Role.ASSISTANT
parser_func.current_channel = "commentary"
parser_func.current_recipient = "functions.my_tool"
func_items = parser_state_to_response_output(parser_func)
assert len(func_items) == 1
assert not isinstance(func_items[0], McpCall)
assert func_items[0].type == "function_call"
assert func_items[0].name == "my_tool"
assert func_items[0].status == "in_progress"
# Test 2: MCP tool (not builtin) -> should return MCP call
parser_mcp = Mock()
parser_mcp.current_content = '{"path": "/tmp"}'
parser_mcp.current_role = Role.ASSISTANT
parser_mcp.current_channel = "commentary"
parser_mcp.current_recipient = "filesystem"
mcp_items = parser_state_to_response_output(parser_mcp)
assert len(mcp_items) == 1
assert isinstance(mcp_items[0], McpCall)
assert mcp_items[0].type == "mcp_call"
assert mcp_items[0].name == "filesystem"
assert mcp_items[0].server_label == "filesystem"
assert mcp_items[0].status == "in_progress"
# Test 3: Built-in tool (python)
# should NOT return MCP call, returns reasoning (internal tool interaction)
parser_builtin = Mock()
parser_builtin.current_content = "print('hello')"
parser_builtin.current_role = Role.ASSISTANT
parser_builtin.current_channel = "commentary"
parser_builtin.current_recipient = "python"
builtin_items = parser_state_to_response_output(parser_builtin)
# Built-in tools explicitly return reasoning
assert len(builtin_items) == 1
assert not isinstance(builtin_items[0], McpCall)
assert builtin_items[0].type == "reasoning"
# Test 4: No recipient (preamble) → should return message, not reasoning
parser_preamble = Mock()
parser_preamble.current_content = "I'll search for that information now."
parser_preamble.current_role = Role.ASSISTANT
parser_preamble.current_channel = "commentary"
parser_preamble.current_recipient = None
preamble_items = parser_state_to_response_output(parser_preamble)
assert len(preamble_items) == 1
assert isinstance(preamble_items[0], ResponseOutputMessage)
assert preamble_items[0].type == "message"
assert preamble_items[0].content[0].text == "I'll search for that information now."
assert preamble_items[0].status == "incomplete" # streaming
def test_parser_state_to_response_output_analysis_channel() -> None:
"""Test parser_state_to_response_output with analysis
channel and various recipients."""
from unittest.mock import Mock
# Test 1: functions.* recipient -> should return function tool call
parser_func = Mock()
parser_func.current_content = '{"arg": "value"}'
parser_func.current_role = Role.ASSISTANT
parser_func.current_channel = "analysis"
parser_func.current_recipient = "functions.my_tool"
func_items = parser_state_to_response_output(parser_func)
assert len(func_items) == 1
assert not isinstance(func_items[0], McpCall)
assert func_items[0].type == "function_call"
assert func_items[0].name == "my_tool"
assert func_items[0].status == "in_progress"
# Test 2: MCP tool (not builtin) -> should return MCP call
parser_mcp = Mock()
parser_mcp.current_content = '{"query": "test"}'
parser_mcp.current_role = Role.ASSISTANT
parser_mcp.current_channel = "analysis"
parser_mcp.current_recipient = "database"
mcp_items = parser_state_to_response_output(parser_mcp)
assert len(mcp_items) == 1
assert isinstance(mcp_items[0], McpCall)
assert mcp_items[0].type == "mcp_call"
assert mcp_items[0].name == "database"
assert mcp_items[0].server_label == "database"
assert mcp_items[0].status == "in_progress"
# Test 3: Built-in tool (container)
# should NOT return MCP call, falls through to reasoning
parser_builtin = Mock()
parser_builtin.current_content = "docker run"
parser_builtin.current_role = Role.ASSISTANT
parser_builtin.current_channel = "analysis"
parser_builtin.current_recipient = "container"
builtin_items = parser_state_to_response_output(parser_builtin)
# Should fall through to reasoning logic
assert len(builtin_items) == 1
assert not isinstance(builtin_items[0], McpCall)
assert builtin_items[0].type == "reasoning"

View File

@@ -0,0 +1,243 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Integration tests for MCP tool support in the Responses API."""
from __future__ import annotations
import pytest
import pytest_asyncio
from openai import OpenAI
from openai_harmony import ToolDescription, ToolNamespaceConfig
from vllm.entrypoints.mcp.tool_server import MCPToolServer
from ....utils import RemoteOpenAIServer
from .conftest import (
BASE_TEST_ENV,
events_contain_type,
log_response_diagnostics,
retry_for_tool_call,
retry_streaming_for,
validate_streaming_event_stack,
)
MODEL_NAME = "openai/gpt-oss-20b"
_BASE_SERVER_ARGS = [
"--enforce-eager",
"--tool-server",
"demo",
"--max_model_len",
"5000",
]
_PYTHON_TOOL_INSTRUCTION = (
"You must use the Python tool to execute code. Never simulate execution."
)
class TestMCPToolServerUnit:
"""Test MCPToolServer.get_tool_description filtering logic.
Note: The wildcard "*" is normalized to None by
_extract_allowed_tools_from_mcp_requests before reaching this layer,
so we only test None and specific tool filtering here.
See test_serving_responses.py for "*" normalization tests.
"""
def test_get_tool_description(self):
pytest.importorskip("mcp")
server = MCPToolServer()
tool1 = ToolDescription.new(
name="tool1", description="First", parameters={"type": "object"}
)
tool2 = ToolDescription.new(
name="tool2", description="Second", parameters={"type": "object"}
)
tool3 = ToolDescription.new(
name="tool3", description="Third", parameters={"type": "object"}
)
server.harmony_tool_descriptions = {
"test_server": ToolNamespaceConfig(
name="test_server",
description="test",
tools=[tool1, tool2, tool3],
)
}
# Nonexistent server
assert server.get_tool_description("nonexistent") is None
# None (no filter) - returns all tools
result = server.get_tool_description("test_server", allowed_tools=None)
assert len(result.tools) == 3
# Filter to specific tools
result = server.get_tool_description(
"test_server", allowed_tools=["tool1", "tool3"]
)
assert len(result.tools) == 2
assert result.tools[0].name == "tool1"
assert result.tools[1].name == "tool3"
# Single tool
result = server.get_tool_description("test_server", allowed_tools=["tool2"])
assert len(result.tools) == 1
assert result.tools[0].name == "tool2"
# No matching tools - returns None
result = server.get_tool_description(
"test_server", allowed_tools=["nonexistent"]
)
assert result is None
# Empty list - returns None
assert server.get_tool_description("test_server", allowed_tools=[]) is None
def test_builtin_tools_consistency(self):
"""MCP_BUILTIN_TOOLS must match BUILTIN_TOOL_TO_MCP_SERVER_LABEL values."""
from vllm.entrypoints.openai.parser.harmony_utils import (
BUILTIN_TOOL_TO_MCP_SERVER_LABEL,
MCP_BUILTIN_TOOLS,
)
assert set(BUILTIN_TOOL_TO_MCP_SERVER_LABEL.values()) == MCP_BUILTIN_TOOLS, (
f"MCP_BUILTIN_TOOLS {MCP_BUILTIN_TOOLS} does not match "
f"BUILTIN_TOOL_TO_MCP_SERVER_LABEL values "
f"{set(BUILTIN_TOOL_TO_MCP_SERVER_LABEL.values())}"
)
class TestMCPEnabled:
"""Tests that require MCP tools to be enabled via environment variable."""
@pytest.fixture(scope="class")
def mcp_enabled_server(self):
env_dict = {
**BASE_TEST_ENV,
"VLLM_ENABLE_RESPONSES_API_STORE": "1",
"PYTHON_EXECUTION_BACKEND": "dangerously_use_uv",
"VLLM_GPT_OSS_SYSTEM_TOOL_MCP_LABELS": ("code_interpreter,container"),
"VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS": "1",
}
with RemoteOpenAIServer(
MODEL_NAME, list(_BASE_SERVER_ARGS), env_dict=env_dict
) as remote_server:
yield remote_server
@pytest_asyncio.fixture
async def client(self, mcp_enabled_server):
async with mcp_enabled_server.get_async_client() as async_client:
yield async_client
@staticmethod
def _mcp_tools_payload(*, allowed_tools: list[str] | None = None) -> list[dict]:
tool: dict = {
"type": "mcp",
"server_label": "code_interpreter",
"server_url": "http://localhost:8888",
}
if allowed_tools is not None:
tool["allowed_tools"] = allowed_tools
return [tool]
@staticmethod
def _python_exec_input(code: str = "") -> str:
if not code:
code = "import random; print(random.randint(1, 1000000))"
return f"Execute the following code: {code}"
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
async def test_mcp_tool_env_flag_enabled(self, client: OpenAI, model_name: str):
response = await retry_for_tool_call(
client,
model=model_name,
expected_tool_type="mcp_call",
input=self._python_exec_input(),
instructions=_PYTHON_TOOL_INSTRUCTION,
tools=self._mcp_tools_payload(),
temperature=0.0,
extra_body={"enable_response_messages": True},
)
assert response.status == "completed"
log_response_diagnostics(response, label="MCP Enabled")
tool_call_found = False
tool_response_found = False
for message in response.output_messages:
recipient = message.get("recipient")
if recipient and recipient.startswith("python"):
tool_call_found = True
assert message.get("channel") == "commentary"
author = message.get("author", {})
if author.get("role") == "tool" and (author.get("name") or "").startswith(
"python"
):
tool_response_found = True
assert message.get("channel") == "commentary"
assert tool_call_found, (
f"No Python tool call found. "
f"Output types: "
f"{[getattr(o, 'type', None) for o in response.output]}"
)
assert tool_response_found, "No Python tool response found"
for message in response.input_messages:
assert message.get("author", {}).get("role") != "developer"
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
async def test_mcp_tool_with_allowed_tools_star(
self, client: OpenAI, model_name: str
):
response = await retry_for_tool_call(
client,
model=model_name,
expected_tool_type="mcp_call",
input=self._python_exec_input(),
instructions=_PYTHON_TOOL_INSTRUCTION,
tools=self._mcp_tools_payload(allowed_tools=["*"]),
temperature=0.0,
extra_body={"enable_response_messages": True},
)
assert response.status == "completed"
log_response_diagnostics(response, label="MCP Allowed Tools *")
tool_call_found = any(
(msg.get("recipient") or "").startswith("python")
for msg in response.output_messages
)
assert tool_call_found, (
f"No Python tool call with '*'. "
f"Output types: "
f"{[getattr(o, 'type', None) for o in response.output]}"
)
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
async def test_mcp_tool_calling_streaming_types(
self,
pairs_of_event_types: dict[str, str],
client: OpenAI,
model_name: str,
):
def _has_mcp_events(events: list) -> bool:
return events_contain_type(events, "mcp_call")
events = await retry_streaming_for(
client,
model=model_name,
validate_events=_has_mcp_events,
input=("What is 123 * 456? Use Python to calculate the result."),
tools=[{"type": "mcp", "server_label": "code_interpreter"}],
instructions=_PYTHON_TOOL_INSTRUCTION,
temperature=0.0,
)
validate_streaming_event_stack(events, pairs_of_event_types)

View File

@@ -0,0 +1,279 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import importlib.util
import json
import logging
import pytest
import pytest_asyncio
from openai import OpenAI
from ....utils import RemoteOpenAIServer
from .conftest import (
BASE_TEST_ENV,
has_output_type,
log_response_diagnostics,
retry_for_tool_call,
)
logger = logging.getLogger(__name__)
MODEL_NAME = "Qwen/Qwen3-8B"
_PYTHON_TOOL_INSTRUCTION = (
"You must use the Python tool to execute code. "
"Never simulate execution. You must print the final answer."
)
@pytest.fixture(scope="module")
def server():
assert importlib.util.find_spec("gpt_oss") is not None, (
"Harmony tests require gpt_oss package to be installed"
)
args = [
"--reasoning-parser",
"qwen3",
"--max_model_len",
"5000",
"--structured-outputs-config.backend",
"xgrammar",
"--enable-auto-tool-choice",
"--tool-call-parser",
"hermes",
"--tool-server",
"demo",
]
env_dict = {
**BASE_TEST_ENV,
"VLLM_ENABLE_RESPONSES_API_STORE": "1",
"VLLM_USE_EXPERIMENTAL_PARSER_CONTEXT": "1",
"PYTHON_EXECUTION_BACKEND": "dangerously_use_uv",
}
with RemoteOpenAIServer(MODEL_NAME, args, env_dict=env_dict) as remote_server:
yield remote_server
@pytest_asyncio.fixture
async def client(server):
async with server.get_async_client() as async_client:
yield async_client
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
async def test_basic(client: OpenAI, model_name: str):
response = await client.responses.create(
model=model_name,
input="What is 123 * 456?",
temperature=0.0,
)
assert response is not None
print("response: ", response)
assert response.status == "completed"
assert response.incomplete_details is None
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
async def test_reasoning_and_function_items(client: OpenAI, model_name: str):
response = await client.responses.create(
model=model_name,
input=[
{"type": "message", "content": "Hello.", "role": "user"},
{
"type": "reasoning",
"id": "lol",
"content": [
{
"type": "reasoning_text",
"text": "We need to respond: greeting.",
}
],
"summary": [],
},
{
"arguments": '{"location": "Paris", "unit": "celsius"}',
"call_id": "call_5f7b38f3b81e4b8380fd0ba74f3ca3ab",
"name": "get_weather",
"type": "function_call",
"id": "fc_4fe5d6fc5b6c4d6fa5f24cc80aa27f78",
"status": "completed",
},
{
"call_id": "call_5f7b38f3b81e4b8380fd0ba74f3ca3ab",
"id": "fc_4fe5d6fc5b6c4d6fa5f24cc80aa27f78",
"output": "The weather in Paris is 20 Celsius",
"status": "completed",
"type": "function_call_output",
},
],
temperature=0.0,
)
assert response is not None
assert response.status == "completed"
output_types = [getattr(o, "type", None) for o in response.output]
assert "reasoning" in output_types, (
f"Expected reasoning in output, got: {output_types}"
)
assert "message" in output_types, f"Expected message in output, got: {output_types}"
msg = next(o for o in response.output if o.type == "message")
assert type(msg.content[0].text) is str
def get_horoscope(sign):
return f"{sign}: Next Tuesday you will befriend a baby otter."
def call_function(name, args):
logger.info("Calling function %s with args %s", name, args)
if name == "get_horoscope":
return get_horoscope(**args)
raise ValueError(f"Unknown function: {name}")
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
async def test_function_call_first_turn(client: OpenAI, model_name: str):
tools = [
{
"type": "function",
"name": "get_horoscope",
"description": "Get today's horoscope for an astrological sign.",
"parameters": {
"type": "object",
"properties": {
"sign": {"type": "string"},
},
"required": ["sign"],
"additionalProperties": False,
},
"strict": True,
}
]
response = await retry_for_tool_call(
client,
model=model_name,
expected_tool_type="function_call",
input="What is the horoscope for Aquarius today?",
tools=tools,
temperature=0.0,
)
assert response is not None
assert response.status == "completed"
output_types = [getattr(o, "type", None) for o in response.output]
assert "reasoning" in output_types, (
f"Expected reasoning in output, got: {output_types}"
)
assert has_output_type(response, "function_call"), (
f"Expected function_call in output, got: {output_types}"
)
function_call = next(o for o in response.output if o.type == "function_call")
assert function_call.name == "get_horoscope"
assert function_call.call_id is not None
args = json.loads(function_call.arguments)
assert "sign" in args
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
async def test_mcp_tool_call(client: OpenAI, model_name: str):
"""MCP tool calling with code_interpreter.
The model may make one or more tool calls before producing a final
message. We validate server invariants (mcp_call items have correct
fields) with hard assertions. Output indices are never hardcoded
since the model can produce multiple tool-call rounds.
"""
# MCP + container init + code execution can be slow
client_with_timeout = client.with_options(timeout=client.timeout * 3)
response = await retry_for_tool_call(
client_with_timeout,
model=model_name,
expected_tool_type="mcp_call",
input=(
"What is 123 * 456? Use python to calculate the result. "
"Print the result with print()."
),
tools=[{"type": "code_interpreter", "container": {"type": "auto"}}],
instructions=_PYTHON_TOOL_INSTRUCTION,
temperature=0.0,
extra_body={"enable_response_messages": True},
)
assert response is not None
output_types = [getattr(o, "type", None) for o in response.output]
log_response_diagnostics(response, label="test_mcp_tool_call")
assert response.status == "completed", (
f"Response status={response.status} "
f"(details={getattr(response, 'incomplete_details', None)}). "
f"Output types: {output_types}."
)
assert "reasoning" in output_types, (
f"Expected reasoning in output, got: {output_types}"
)
assert "mcp_call" in output_types, (
f"Expected mcp_call in output, got: {output_types}"
)
# Every mcp_call item must have well-typed fields
for item in response.output:
if getattr(item, "type", None) == "mcp_call":
assert type(item.arguments) is str, (
f"mcp_call.arguments should be str, got {type(item.arguments)}"
)
assert type(item.output) is str, (
f"mcp_call.output should be str, got {type(item.output)}"
)
# The model may make 1+ tool-call rounds but must still produce
# a final message for a trivial calculation like 123 * 456.
message_outputs = [
o for o in response.output if getattr(o, "type", None) == "message"
]
assert message_outputs, (
f"Model did not produce a final message. Output types: {output_types}"
)
final_message = message_outputs[-1]
assert any(s in final_message.content[0].text for s in ("56088", "56,088")), (
f"Expected 56088 in final message, got: {final_message.content[0].text!r}"
)
# Validate raw input_messages / output_messages
assert len(response.input_messages) >= 1, "Expected at least 1 input message"
assert len(response.output_messages) >= 1, "Expected at least 1 output message"
assert any(
any(s in str(msg) for s in ("56088", "56,088"))
for msg in response.output_messages
), (
f"Expected 56088 in at least one output_message, "
f"got {len(response.output_messages)} messages"
)
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
async def test_max_tokens(client: OpenAI, model_name: str):
response = await client.responses.create(
model=model_name,
input="What is the first paragraph of Moby Dick?",
reasoning={"effort": "low"},
max_output_tokens=30,
temperature=0.0,
)
assert response is not None
assert response.status == "incomplete"
assert response.incomplete_details.reason == "max_output_tokens"

View File

@@ -0,0 +1,156 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for ResponsesRequest.to_sampling_params() parameter mapping."""
import pytest
import torch
from openai.types.responses.response_format_text_json_schema_config import (
ResponseFormatTextJSONSchemaConfig,
)
from pydantic import ValidationError
from vllm.entrypoints.openai.responses.protocol import (
ResponsesRequest,
ResponseTextConfig,
)
from vllm.sampling_params import StructuredOutputsParams
class TestResponsesRequestSamplingParams:
"""Test that ResponsesRequest correctly maps parameters to SamplingParams."""
def test_basic_sampling_params(self):
"""Test basic sampling parameters are correctly mapped."""
request = ResponsesRequest(
model="test-model",
input="test input",
temperature=0.8,
top_p=0.95,
top_k=50,
max_output_tokens=100,
)
sampling_params = request.to_sampling_params(default_max_tokens=1000)
assert sampling_params.temperature == 0.8
assert sampling_params.top_p == 0.95
assert sampling_params.top_k == 50
assert sampling_params.max_tokens == 100
def test_extra_sampling_params(self):
"""Test extra sampling parameters are correctly mapped."""
request = ResponsesRequest(
model="test-model",
input="test input",
repetition_penalty=1.2,
seed=42,
stop=["END", "STOP"],
ignore_eos=True,
vllm_xargs={"custom": "value"},
)
sampling_params = request.to_sampling_params(default_max_tokens=1000)
assert sampling_params.repetition_penalty == 1.2
assert sampling_params.seed == 42
assert sampling_params.stop == ["END", "STOP"]
assert sampling_params.ignore_eos is True
assert sampling_params.extra_args == {"custom": "value"}
def test_stop_string_conversion(self):
"""Test that single stop string is converted to list."""
request = ResponsesRequest(
model="test-model",
input="test input",
stop="STOP",
)
sampling_params = request.to_sampling_params(default_max_tokens=1000)
assert sampling_params.stop == ["STOP"]
def test_default_values(self):
"""Test default values for optional parameters."""
request = ResponsesRequest(
model="test-model",
input="test input",
)
sampling_params = request.to_sampling_params(default_max_tokens=1000)
assert sampling_params.repetition_penalty == 1.0 # None → 1.0
assert sampling_params.stop == [] # Empty list
assert sampling_params.extra_args == {} # Empty dict
def test_seed_bounds_validation(self):
"""Test that seed values outside torch.long bounds are rejected."""
# Test seed below minimum
with pytest.raises(ValidationError) as exc_info:
ResponsesRequest(
model="test-model",
input="test input",
seed=torch.iinfo(torch.long).min - 1,
)
assert "greater_than_equal" in str(exc_info.value).lower()
# Test seed above maximum
with pytest.raises(ValidationError) as exc_info:
ResponsesRequest(
model="test-model",
input="test input",
seed=torch.iinfo(torch.long).max + 1,
)
assert "less_than_equal" in str(exc_info.value).lower()
# Test valid seed at boundaries
request_min = ResponsesRequest(
model="test-model",
input="test input",
seed=torch.iinfo(torch.long).min,
)
assert request_min.seed == torch.iinfo(torch.long).min
request_max = ResponsesRequest(
model="test-model",
input="test input",
seed=torch.iinfo(torch.long).max,
)
assert request_max.seed == torch.iinfo(torch.long).max
def test_structured_outputs_passed_through(self):
"""Test that structured_outputs field is passed to SamplingParams."""
structured_outputs = StructuredOutputsParams(grammar="root ::= 'hello'")
request = ResponsesRequest(
model="test-model",
input="test input",
structured_outputs=structured_outputs,
)
sampling_params = request.to_sampling_params(default_max_tokens=1000)
assert sampling_params.structured_outputs is not None
assert sampling_params.structured_outputs.grammar == "root ::= 'hello'"
def test_structured_outputs_and_json_schema_conflict(self):
"""Test that specifying both structured_outputs and json_schema raises."""
structured_outputs = StructuredOutputsParams(grammar="root ::= 'hello'")
text_config = ResponseTextConfig()
text_config.format = ResponseFormatTextJSONSchemaConfig(
type="json_schema",
name="test",
schema={"type": "object"},
)
request = ResponsesRequest(
model="test-model",
input="test input",
structured_outputs=structured_outputs,
text=text_config,
)
with pytest.raises(ValueError) as exc_info:
request.to_sampling_params(default_max_tokens=1000)
assert "Cannot specify both structured_outputs and text.format" in str(
exc_info.value
)

View File

@@ -0,0 +1,295 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
import pytest_asyncio
from openai import OpenAI
from ....utils import RemoteOpenAIServer
from .conftest import validate_streaming_event_stack
MODEL_NAME = "Qwen/Qwen3-8B"
@pytest.fixture(scope="module")
def server():
from .conftest import BASE_TEST_ENV
args = ["--reasoning-parser", "qwen3", "--max_model_len", "5000"]
env_dict = {
**BASE_TEST_ENV,
"VLLM_ENABLE_RESPONSES_API_STORE": "1",
# uncomment for tool calling
# PYTHON_EXECUTION_BACKEND: "dangerously_use_uv",
}
with RemoteOpenAIServer(MODEL_NAME, args, env_dict=env_dict) as remote_server:
yield remote_server
@pytest_asyncio.fixture
async def client(server):
async with server.get_async_client() as async_client:
yield async_client
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
async def test_basic(client: OpenAI, model_name: str):
response = await client.responses.create(
model=model_name,
input="What is 123 * 456?",
)
assert response is not None
print("response: ", response)
assert response.status == "completed"
assert response.incomplete_details is None
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
async def test_enable_response_messages(client: OpenAI, model_name: str):
response = await client.responses.create(
model=model_name,
input="Hello?",
extra_body={"enable_response_messages": True},
)
assert response.status == "completed"
assert response.input_messages[0]["type"] == "raw_message_tokens"
assert type(response.input_messages[0]["message"]) is str
assert len(response.input_messages[0]["message"]) > 10
assert type(response.input_messages[0]["tokens"][0]) is int
assert type(response.output_messages[0]["message"]) is str
assert len(response.output_messages[0]["message"]) > 10
assert type(response.output_messages[0]["tokens"][0]) is int
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
async def test_reasoning_item(client: OpenAI, model_name: str):
response = await client.responses.create(
model=model_name,
input=[
{"type": "message", "content": "Hello.", "role": "user"},
{
"type": "reasoning",
"id": "lol",
"content": [
{
"type": "reasoning_text",
"text": "We need to respond: greeting.",
}
],
"summary": [],
},
],
temperature=0.0,
)
assert response is not None
assert response.status == "completed"
# make sure we get a reasoning and text output
assert response.output[0].type == "reasoning"
assert response.output[1].type == "message"
assert type(response.output[1].content[0].text) is str
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
async def test_streaming_output_consistency(client: OpenAI, model_name: str):
"""Test that streaming delta text matches the final response output_text.
This test verifies that when using streaming mode:
1. The concatenated text from all 'response.output_text.delta' events
2. Matches the 'output_text' in the final 'response.completed' event
"""
response = await client.responses.create(
model=model_name,
input="Say hello in one sentence.",
stream=True,
)
events = []
async for event in response:
events.append(event)
assert len(events) > 0
# Concatenate all delta text from streaming events
streaming_text = "".join(
event.delta for event in events if event.type == "response.output_text.delta"
)
# Get the final response from the last event
response_completed_event = events[-1]
assert response_completed_event.type == "response.completed"
assert response_completed_event.response.status == "completed"
# Get output_text from the final response
final_output_text = response_completed_event.response.output_text
# Verify final response has output
assert len(response_completed_event.response.output) > 0
# Verify streaming text matches final output_text
assert streaming_text == final_output_text, (
f"Streaming text does not match final output_text.\n"
f"Streaming: {streaming_text!r}\n"
f"Final: {final_output_text!r}"
)
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
async def test_streaming_logprobs(client: OpenAI, model_name: str):
"""Test that streaming with logprobs returns valid logprob data on
output_text.delta events and that top_logprobs has the requested count."""
response = await client.responses.create(
model=model_name,
input="Say hello.",
stream=True,
top_logprobs=3,
include=["message.output_text.logprobs"],
)
events = []
async for event in response:
events.append(event)
assert len(events) > 0
# Collect all output_text.delta events that carry logprobs
text_delta_events = [e for e in events if e.type == "response.output_text.delta"]
assert len(text_delta_events) > 0, "Expected at least one text delta event"
for delta_event in text_delta_events:
logprobs = delta_event.logprobs
assert logprobs is not None, "logprobs should be present on text delta events"
assert len(logprobs) > 0, "logprobs list should not be empty"
for lp in logprobs:
# Each logprob entry must have a token and a logprob value
assert lp.token is not None
assert isinstance(lp.logprob, float)
assert lp.logprob <= 0.0, f"logprob should be <= 0, got {lp.logprob}"
# top_logprobs should have up to 3 entries
assert lp.top_logprobs is not None
assert len(lp.top_logprobs) <= 3
for tl in lp.top_logprobs:
assert tl.token is not None
assert isinstance(tl.logprob, float)
# Verify that top_logprobs are actually populated, not always empty
all_top_logprobs = [
tl for e in text_delta_events for lp in e.logprobs for tl in lp.top_logprobs
]
assert len(all_top_logprobs) > 0, (
"Expected at least one top_logprobs entry across all delta events"
)
# Verify the completed event still has valid output
completed = events[-1]
assert completed.type == "response.completed"
assert completed.response.status == "completed"
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
async def test_streaming_reasoning_tokens_e2e(client: OpenAI, model_name: str):
"""Verify final usage includes reasoning_tokens in streaming mode."""
response = await client.responses.create(
model=model_name,
input="Compute 17 * 19 and explain briefly.",
reasoning={"effort": "low"},
temperature=0.0,
stream=True,
)
completed_event = None
async for event in response:
if event.type == "response.completed":
completed_event = event
assert completed_event is not None
assert completed_event.response.status == "completed"
assert completed_event.response.usage is not None
assert completed_event.response.usage.output_tokens_details is not None
assert completed_event.response.usage.output_tokens_details.reasoning_tokens > 0, (
"Expected reasoning_tokens > 0 for streamed Qwen3 response."
)
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
async def test_non_streaming_reasoning_tokens_e2e(client: OpenAI, model_name: str):
"""Verify usage includes reasoning_tokens in non-streaming mode."""
response = await client.responses.create(
model=model_name,
input="Compute 23 * 17 and explain briefly.",
reasoning={"effort": "low"},
temperature=0.0,
stream=False,
)
assert response is not None
assert response.status == "completed"
assert response.usage is not None
assert response.usage.output_tokens_details is not None
assert response.usage.output_tokens_details.reasoning_tokens > 0, (
"Expected reasoning_tokens > 0 for non-streamed Qwen3 response."
)
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
async def test_max_tokens(client: OpenAI, model_name: str):
response = await client.responses.create(
model=model_name,
input="What is the first paragraph of Moby Dick?",
reasoning={"effort": "low"},
max_output_tokens=30,
)
assert response is not None
assert response.status == "incomplete"
assert response.incomplete_details.reason == "max_output_tokens"
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
async def test_extra_sampling_params(client: OpenAI, model_name: str):
"""Test that extra sampling parameters are accepted and work."""
# Test with multiple sampling parameters - just verify they're accepted
response = await client.responses.create(
model=model_name,
input="Write a short sentence",
max_output_tokens=50,
temperature=0.7,
top_p=0.9,
extra_body={
"top_k": 40,
"repetition_penalty": 1.2,
"seed": 42,
},
)
# Verify request succeeded and parameters were accepted
assert response.status in ["completed", "incomplete"]
assert len(response.output) > 0
assert response.output[0].content[0].text # Has text output
@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
async def test_streaming_types(
pairs_of_event_types: dict[str, str], client: OpenAI, model_name: str
):
stream = await client.responses.create(
model=model_name,
input="tell me a story about a cat in 20 words",
reasoning={"effort": "low"},
tools=[],
stream=True,
background=False,
)
events = []
async for event in stream:
events.append(event)
validate_streaming_event_stack(events, pairs_of_event_types)