Files
wehub-resource-sync 7ce4c8e27e
pre-commit / pre-run-check (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:55:37 +08:00

364 lines
12 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for ParsableContext's parsing behavior.
These tests verify that ParsableContext correctly delegates to the unified
Parser (via parse) and properly builds response output items.
"""
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.responses.context import ParsableContext
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
from vllm.outputs import CompletionOutput, RequestOutput
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_request_output(
text: str = "Hello, world!",
token_ids: Sequence[int] = (1, 2, 3),
finish_reason: str = "stop",
) -> RequestOutput:
return RequestOutput(
request_id="test",
prompt=None,
prompt_token_ids=[],
prompt_logprobs=None,
outputs=[
CompletionOutput(
index=0,
text=text,
token_ids=list(token_ids),
cumulative_logprob=None,
logprobs=None,
finish_reason=finish_reason,
)
],
finished=True,
)
def _make_context(parser_cls, **overrides):
# ParsableContext no longer lazily builds a parser from ``parser_cls``;
# the caller (here, the serving layer in production) must supply one.
request = overrides.get("request", _make_request())
response_parser = overrides.pop("response_parser", None)
if response_parser is None and parser_cls is not None:
response_parser = parser_cls(MagicMock(), request.tools)
defaults = dict(
tokenizer=MagicMock(),
parser_cls=parser_cls,
response_parser=response_parser,
response_messages=[],
request=request,
available_tools=None,
chat_template=None,
chat_template_content_format="auto",
)
defaults.update(overrides)
return ParsableContext(**defaults)
# ---------------------------------------------------------------------------
# Tests: basic text passthrough
# ---------------------------------------------------------------------------
def test_process_text_with_parser():
"""Parser with no reasoning/tools returns a single message item."""
ctx = _make_context(_NoOpParser)
ctx.append_output(_make_request_output(text="Hello!"))
assert len(ctx.response_messages) == 1
msg = ctx.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."""
ctx = _make_context(None)
ctx.append_output(_make_request_output(text="Hello!"))
assert len(ctx.response_messages) == 1
msg = ctx.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."""
ctx = _make_context(None)
ctx.append_output(_make_request_output(text=""))
assert len(ctx.response_messages) == 0
def test_process_empty_text_with_parser():
"""Empty text with parser produces no output items."""
ctx = _make_context(_NoOpParser)
ctx.append_output(_make_request_output(text=""))
assert len(ctx.response_messages) == 0
# ---------------------------------------------------------------------------
# Tests: reasoning extraction
# ---------------------------------------------------------------------------
def test_process_extracts_reasoning():
"""Parser that finds reasoning produces both reasoning and message items."""
ctx = _make_context(_ReasoningOnlyParser)
ctx.append_output(
_make_request_output(text="<think>Let me check</think>The answer is 42")
)
types = [m.type for m in ctx.response_messages]
assert "reasoning" in types
assert "message" in types
reasoning_item = next(m for m in ctx.response_messages if m.type == "reasoning")
assert reasoning_item.content[0].text == "Let me check"
message_item = next(m for m in ctx.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."""
ctx = _make_context(_ReasoningOnlyParser)
ctx.append_output(_make_request_output(text="<think>Just thinking</think>"))
types = [m.type for m in ctx.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": {}},
}
],
)
ctx = _make_context(_ToolCallingParser, request=request, enable_auto_tools=True)
ctx.append_output(_make_request_output(text="calling tool"))
types = [m.type for m in ctx.response_messages]
assert "function_call" in types
tool_item = next(m for m in ctx.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 context."""
ctx = _make_context(_NoOpParser)
assert ctx.finish_reason is None
ctx.append_output(_make_request_output(finish_reason="stop"))
assert ctx.finish_reason == "stop"
ctx.append_output(_make_request_output(finish_reason="length"))
assert ctx.finish_reason == "length"
# ---------------------------------------------------------------------------
# Tests: multi-turn accumulation
# ---------------------------------------------------------------------------
def test_multi_turn_accumulation():
"""Multiple append_output() calls accumulate response_messages."""
ctx = _make_context(_NoOpParser)
ctx.append_output(_make_request_output(text="First turn"))
ctx.append_output(_make_request_output(text="Second turn"))
assert len(ctx.response_messages) == 2
texts = [m.content[0].text for m in ctx.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")]
ctx = _make_context(_NoOpParser, response_messages=init_messages)
assert ctx.num_init_messages == 1
ctx.append_output(_make_request_output(text="New output"))
assert len(ctx.response_messages) == 2
items = ctx.make_response_output_items()
assert len(items) == 1
assert items[0].type == "message"