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:
0
third_party/vllm/tests/entrypoints/openai/responses/__init__.py
vendored
Normal file
0
third_party/vllm/tests/entrypoints/openai/responses/__init__.py
vendored
Normal file
363
third_party/vllm/tests/entrypoints/openai/responses/conftest.py
vendored
Normal file
363
third_party/vllm/tests/entrypoints/openai/responses/conftest.py
vendored
Normal 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
|
||||
62
third_party/vllm/tests/entrypoints/openai/responses/test_errors.py
vendored
Normal file
62
third_party/vllm/tests/entrypoints/openai/responses/test_errors.py
vendored
Normal 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
|
||||
330
third_party/vllm/tests/entrypoints/openai/responses/test_function_call_parsing.py
vendored
Normal file
330
third_party/vllm/tests/entrypoints/openai/responses/test_function_call_parsing.py
vendored
Normal 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 == []
|
||||
1248
third_party/vllm/tests/entrypoints/openai/responses/test_harmony.py
vendored
Normal file
1248
third_party/vllm/tests/entrypoints/openai/responses/test_harmony.py
vendored
Normal file
File diff suppressed because it is too large
Load Diff
463
third_party/vllm/tests/entrypoints/openai/responses/test_harmony_utils.py
vendored
Normal file
463
third_party/vllm/tests/entrypoints/openai/responses/test_harmony_utils.py
vendored
Normal 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"
|
||||
243
third_party/vllm/tests/entrypoints/openai/responses/test_mcp_tools.py
vendored
Normal file
243
third_party/vllm/tests/entrypoints/openai/responses/test_mcp_tools.py
vendored
Normal 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)
|
||||
279
third_party/vllm/tests/entrypoints/openai/responses/test_parsable_context.py
vendored
Normal file
279
third_party/vllm/tests/entrypoints/openai/responses/test_parsable_context.py
vendored
Normal 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"
|
||||
156
third_party/vllm/tests/entrypoints/openai/responses/test_sampling_params.py
vendored
Normal file
156
third_party/vllm/tests/entrypoints/openai/responses/test_sampling_params.py
vendored
Normal 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
|
||||
)
|
||||
295
third_party/vllm/tests/entrypoints/openai/responses/test_simple.py
vendored
Normal file
295
third_party/vllm/tests/entrypoints/openai/responses/test_simple.py
vendored
Normal 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)
|
||||
Reference in New Issue
Block a user