forked from Karylab-cklius/vllm
383 lines
12 KiB
Python
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
|