Files
vllm/tests/entrypoints/openai/test_responses_parser_unified.py
T

383 lines
12 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for ResponsesParser with the unified Parser interface.
These tests verify that ResponsesParser correctly delegates to the unified
Parser (via extract_response_outputs) instead of calling separate
ReasoningParser / ToolParser instances directly.
"""
from collections.abc import Sequence
from unittest.mock import MagicMock
import pytest
from vllm.entrypoints.openai.engine.protocol import (
DeltaMessage,
ExtractedToolCallInformation,
FunctionCall,
ToolCall,
)
from vllm.entrypoints.openai.parser.responses_parser import (
ResponsesParser,
get_responses_parser_for_simple_context,
)
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
from vllm.outputs import CompletionOutput
from vllm.parser.abstract_parser import DelegatingParser
pytestmark = pytest.mark.skip_global_cleanup
# ---------------------------------------------------------------------------
# Test parser stubs
# ---------------------------------------------------------------------------
class _NoOpParser(DelegatingParser):
"""Parser that extracts no reasoning and no tool calls."""
def is_reasoning_end(self, input_ids: list[int]) -> bool:
return False
def extract_content_ids(self, input_ids: list[int]) -> list[int]:
return input_ids
def extract_reasoning(self, model_output, request):
return None, model_output
def extract_reasoning_streaming(self, *args, **kwargs):
return None
def extract_tool_calls(self, model_output, request):
return ExtractedToolCallInformation(
tools_called=False, tool_calls=[], content=model_output
)
def extract_tool_calls_streaming(self, *args, **kwargs):
return None
def parse_delta(self, *args, **kwargs) -> DeltaMessage | None:
return None
class _ReasoningOnlyParser(DelegatingParser):
"""Parser that extracts reasoning but no tool calls."""
def is_reasoning_end(self, input_ids: list[int]) -> bool:
return False
def extract_content_ids(self, input_ids: list[int]) -> list[int]:
return input_ids
def extract_reasoning(self, model_output, request):
if "<think>" in model_output and "</think>" in model_output:
start = model_output.index("<think>") + len("<think>")
end = model_output.index("</think>")
reasoning = model_output[start:end]
content = model_output[end + len("</think>") :]
return reasoning, content.strip() or None
return None, model_output
def extract_reasoning_streaming(self, *args, **kwargs):
return None
def extract_tool_calls(self, model_output, request):
return ExtractedToolCallInformation(
tools_called=False, tool_calls=[], content=model_output
)
def extract_tool_calls_streaming(self, *args, **kwargs):
return None
def parse_delta(self, *args, **kwargs) -> DeltaMessage | None:
return None
class _StubToolParser:
"""Minimal tool parser stub that always returns a hardcoded tool call."""
supports_required_and_named = False
def __init__(self, tokenizer=None, tools=None):
pass
def extract_tool_calls(self, model_output, request):
return ExtractedToolCallInformation(
tools_called=True,
tool_calls=[
ToolCall(
id="call_123",
type="function",
function=FunctionCall(
name="get_weather",
arguments='{"location": "Paris"}',
),
)
],
content=None,
)
def extract_tool_calls_streaming(self, *args, **kwargs):
return None
def adjust_request(self, request):
return request
class _ToolCallingParser(DelegatingParser):
"""Parser that extracts a hardcoded tool call from any input."""
def __init__(self, tokenizer, *args, **kwargs):
super().__init__(tokenizer)
self._tool_parser = _StubToolParser()
def is_reasoning_end(self, input_ids: list[int]) -> bool:
return False
def extract_content_ids(self, input_ids: list[int]) -> list[int]:
return input_ids
def extract_reasoning(self, model_output, request):
return None, model_output
def extract_reasoning_streaming(self, *args, **kwargs):
return None
def extract_tool_calls_streaming(self, *args, **kwargs):
return None
def parse_delta(self, *args, **kwargs) -> DeltaMessage | None:
return None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_request(**overrides) -> ResponsesRequest:
defaults = {"model": "test-model", "input": "test"}
defaults.update(overrides)
return ResponsesRequest.model_validate(defaults)
def _make_output(
text: str = "Hello, world!",
token_ids: Sequence[int] = (1, 2, 3),
finish_reason: str = "stop",
) -> CompletionOutput:
return CompletionOutput(
index=0,
text=text,
token_ids=list(token_ids),
cumulative_logprob=None,
logprobs=None,
finish_reason=finish_reason,
)
def _make_parser(parser_cls, **overrides):
defaults = dict(
tokenizer=MagicMock(),
parser_cls=parser_cls,
response_messages=[],
request=_make_request(),
chat_template=None,
chat_template_content_format="auto",
)
defaults.update(overrides)
return ResponsesParser(**defaults)
# ---------------------------------------------------------------------------
# Tests: basic text passthrough
# ---------------------------------------------------------------------------
def test_process_text_with_parser():
"""Parser with no reasoning/tools returns a single message item."""
parser = _make_parser(_NoOpParser)
parser.process(_make_output(text="Hello!"))
assert len(parser.response_messages) == 1
msg = parser.response_messages[0]
assert msg.type == "message"
assert msg.content[0].text == "Hello!"
def test_process_text_without_parser():
"""parser_cls=None falls back to plain text wrapping."""
parser = _make_parser(None)
parser.process(_make_output(text="Hello!"))
assert len(parser.response_messages) == 1
msg = parser.response_messages[0]
assert msg.type == "message"
assert msg.content[0].text == "Hello!"
# ---------------------------------------------------------------------------
# Tests: empty / whitespace output
# ---------------------------------------------------------------------------
def test_process_empty_text_without_parser():
"""Empty text with no parser produces no output items."""
parser = _make_parser(None)
parser.process(_make_output(text=""))
assert len(parser.response_messages) == 0
def test_process_empty_text_with_parser():
"""Empty text with parser produces no output items."""
parser = _make_parser(_NoOpParser)
parser.process(_make_output(text=""))
assert len(parser.response_messages) == 0
# ---------------------------------------------------------------------------
# Tests: reasoning extraction
# ---------------------------------------------------------------------------
def test_process_extracts_reasoning():
"""Parser that finds reasoning produces both reasoning and message items."""
parser = _make_parser(_ReasoningOnlyParser)
parser.process(_make_output(text="<think>Let me check</think>The answer is 42"))
types = [m.type for m in parser.response_messages]
assert "reasoning" in types
assert "message" in types
reasoning_item = next(m for m in parser.response_messages if m.type == "reasoning")
assert reasoning_item.content[0].text == "Let me check"
message_item = next(m for m in parser.response_messages if m.type == "message")
assert message_item.content[0].text == "The answer is 42"
def test_process_reasoning_only_no_content():
"""When reasoning consumes all text, only a reasoning item is produced."""
parser = _make_parser(_ReasoningOnlyParser)
parser.process(_make_output(text="<think>Just thinking</think>"))
types = [m.type for m in parser.response_messages]
assert "reasoning" in types
assert "message" not in types
# ---------------------------------------------------------------------------
# Tests: tool call extraction
# ---------------------------------------------------------------------------
def test_process_extracts_tool_calls():
"""Parser that finds tool calls produces function_call items."""
request = _make_request(
tool_choice="auto",
tools=[
{
"type": "function",
"name": "get_weather",
"parameters": {"type": "object", "properties": {}},
}
],
)
parser = _make_parser(_ToolCallingParser, request=request, enable_auto_tools=True)
parser.process(_make_output(text="calling tool"))
types = [m.type for m in parser.response_messages]
assert "function_call" in types
tool_item = next(m for m in parser.response_messages if m.type == "function_call")
assert tool_item.name == "get_weather"
assert tool_item.arguments == '{"location": "Paris"}'
assert tool_item.status == "completed"
# ---------------------------------------------------------------------------
# Tests: finish_reason tracking
# ---------------------------------------------------------------------------
def test_finish_reason_tracked():
"""finish_reason from CompletionOutput is stored on the parser."""
parser = _make_parser(_NoOpParser)
assert parser.finish_reason is None
parser.process(_make_output(finish_reason="stop"))
assert parser.finish_reason == "stop"
parser.process(_make_output(finish_reason="length"))
assert parser.finish_reason == "length"
# ---------------------------------------------------------------------------
# Tests: multi-turn accumulation
# ---------------------------------------------------------------------------
def test_multi_turn_accumulation():
"""Multiple process() calls accumulate response_messages."""
parser = _make_parser(_NoOpParser)
parser.process(_make_output(text="First turn"))
parser.process(_make_output(text="Second turn"))
assert len(parser.response_messages) == 2
texts = [m.content[0].text for m in parser.response_messages]
assert texts == ["First turn", "Second turn"]
def test_num_init_messages_offset():
"""Initial messages are preserved and offset works correctly."""
init_messages = [MagicMock(type="message")]
parser = _make_parser(_NoOpParser, response_messages=init_messages)
assert parser.num_init_messages == 1
parser.process(_make_output(text="New output"))
assert len(parser.response_messages) == 2
items = parser.make_response_output_items_from_parsable_context()
assert len(items) == 1
assert items[0].type == "message"
# ---------------------------------------------------------------------------
# Tests: factory function
# ---------------------------------------------------------------------------
def test_factory_function_creates_parser():
"""get_responses_parser_for_simple_context returns a working parser."""
rp = get_responses_parser_for_simple_context(
tokenizer=MagicMock(),
parser_cls=_NoOpParser,
response_messages=[],
request=_make_request(),
chat_template=None,
chat_template_content_format="auto",
)
assert isinstance(rp, ResponsesParser)
rp.process(_make_output(text="Works!"))
assert len(rp.response_messages) == 1
def test_factory_function_none_parser():
"""Factory function works with parser_cls=None."""
rp = get_responses_parser_for_simple_context(
tokenizer=MagicMock(),
parser_cls=None,
response_messages=[],
request=_make_request(),
chat_template=None,
chat_template_content_format="auto",
)
assert isinstance(rp, ResponsesParser)
assert rp.parser_instance is None