chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Tests for include_reasoning suppression in the unified Parser interface.
|
||||
|
||||
Covers non-streaming (parser.parse() + build_response_output_items),
|
||||
streaming (parse_delta), and ParsableContext.append_output() paths.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
_STRICT_TOOL_CALLING_ENV = "VLLM_ENFORCE_STRICT_TOOL_CALLING"
|
||||
_STRICT_TOOL_CALLING_ENV_VALUE = os.environ.get(_STRICT_TOOL_CALLING_ENV)
|
||||
os.environ[_STRICT_TOOL_CALLING_ENV] = "0"
|
||||
|
||||
from vllm.entrypoints.openai.chat_completion.protocol import ( # noqa: E402
|
||||
ChatCompletionRequest,
|
||||
)
|
||||
from vllm.entrypoints.openai.engine.protocol import DeltaMessage # noqa: E402
|
||||
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest # noqa: E402
|
||||
from vllm.parser.abstract_parser import DelegatingParser # noqa: E402
|
||||
from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser # noqa: E402
|
||||
from vllm.tool_parsers.hermes_tool_parser import Hermes2ProToolParser # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def restore_strict_tool_calling_env():
|
||||
yield
|
||||
if _STRICT_TOOL_CALLING_ENV_VALUE is None:
|
||||
os.environ.pop(_STRICT_TOOL_CALLING_ENV, None)
|
||||
else:
|
||||
os.environ[_STRICT_TOOL_CALLING_ENV] = _STRICT_TOOL_CALLING_ENV_VALUE
|
||||
|
||||
|
||||
class ThinkReasoningParser(BaseThinkingReasoningParser):
|
||||
@property
|
||||
def start_token(self) -> str:
|
||||
return "<think>"
|
||||
|
||||
@property
|
||||
def end_token(self) -> str:
|
||||
return "</think>"
|
||||
|
||||
|
||||
MODEL_OUTPUT_REASONING_AND_CONTENT = (
|
||||
"<think>let me think about this</think>The answer is 42."
|
||||
)
|
||||
|
||||
MODEL_OUTPUT_REASONING_AND_TOOL = (
|
||||
"<think>I need to call a tool</think>"
|
||||
'<tool_call>\n{"name": "get_weather", '
|
||||
'"arguments": {"city": "Dallas"}}\n</tool_call>'
|
||||
)
|
||||
|
||||
MODEL_OUTPUT_CONTENT_ONLY = "The answer is 42."
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def tokenizer():
|
||||
from vllm.tokenizers import get_tokenizer
|
||||
|
||||
return get_tokenizer("Qwen/Qwen3-32B")
|
||||
|
||||
|
||||
def make_responses_request(**kwargs) -> ResponsesRequest:
|
||||
defaults = dict(model="test-model", input="test input")
|
||||
defaults.update(kwargs)
|
||||
return ResponsesRequest(**defaults)
|
||||
|
||||
|
||||
def make_chat_request(**kwargs) -> ChatCompletionRequest:
|
||||
defaults = dict(
|
||||
model="test-model",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return ChatCompletionRequest(**defaults)
|
||||
|
||||
|
||||
def make_parser(tokenizer, reasoning=False, tool=False):
|
||||
class TestParser(DelegatingParser):
|
||||
reasoning_parser_cls = ThinkReasoningParser if reasoning else None
|
||||
tool_parser_cls = Hermes2ProToolParser if tool else None
|
||||
|
||||
return TestParser(tokenizer)
|
||||
|
||||
|
||||
# ── Non-streaming: parser.parse() + build_response_output_items ──────
|
||||
|
||||
|
||||
def parse_and_build(parser, request, model_output, enable_auto_tools=False):
|
||||
"""Mirrors the non-streaming path in _make_response_output_items /
|
||||
ParsableContext.append_output(): parse → suppress reasoning → build items.
|
||||
"""
|
||||
from vllm.entrypoints.openai.responses.utils import (
|
||||
build_response_output_items,
|
||||
)
|
||||
|
||||
reasoning, content, tool_calls = parser.parse(
|
||||
model_output, request, enable_auto_tools=enable_auto_tools
|
||||
)
|
||||
if not request.include_reasoning:
|
||||
reasoning = None
|
||||
return build_response_output_items(
|
||||
reasoning=reasoning,
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
)
|
||||
|
||||
|
||||
class TestNonStreamingIncludeReasoning:
|
||||
def test_include_reasoning_true_has_reasoning_item(self, tokenizer):
|
||||
"""Default: reasoning items appear in output."""
|
||||
parser = make_parser(tokenizer, reasoning=True)
|
||||
request = make_responses_request(include_reasoning=True)
|
||||
|
||||
outputs = parse_and_build(parser, request, MODEL_OUTPUT_REASONING_AND_CONTENT)
|
||||
|
||||
types = [o.type for o in outputs]
|
||||
assert "reasoning" in types
|
||||
assert "message" in types
|
||||
|
||||
def test_include_reasoning_false_no_reasoning_item(self, tokenizer):
|
||||
"""Reasoning item is suppressed when include_reasoning=False."""
|
||||
parser = make_parser(tokenizer, reasoning=True)
|
||||
request = make_responses_request(include_reasoning=False)
|
||||
|
||||
outputs = parse_and_build(parser, request, MODEL_OUTPUT_REASONING_AND_CONTENT)
|
||||
|
||||
types = [o.type for o in outputs]
|
||||
assert "reasoning" not in types
|
||||
assert "message" in types
|
||||
assert outputs[0].content[0].text == "The answer is 42."
|
||||
|
||||
def test_include_reasoning_false_content_preserved(self, tokenizer):
|
||||
"""Content is extracted correctly even when reasoning is suppressed."""
|
||||
parser = make_parser(tokenizer, reasoning=True)
|
||||
request = make_responses_request(include_reasoning=False)
|
||||
|
||||
outputs = parse_and_build(parser, request, MODEL_OUTPUT_REASONING_AND_CONTENT)
|
||||
|
||||
message = next(o for o in outputs if o.type == "message")
|
||||
assert message.content[0].text == "The answer is 42."
|
||||
|
||||
def test_include_reasoning_false_tool_calls_preserved(self, tokenizer):
|
||||
"""Tool calls still work when reasoning is suppressed."""
|
||||
parser = make_parser(tokenizer, reasoning=True, tool=True)
|
||||
request = make_responses_request(
|
||||
include_reasoning=False,
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"name": "get_weather",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
outputs = parse_and_build(
|
||||
parser,
|
||||
request,
|
||||
MODEL_OUTPUT_REASONING_AND_TOOL,
|
||||
enable_auto_tools=True,
|
||||
)
|
||||
|
||||
types = [o.type for o in outputs]
|
||||
assert "reasoning" not in types
|
||||
assert "function_call" in types
|
||||
fc = next(o for o in outputs if o.type == "function_call")
|
||||
assert fc.name == "get_weather"
|
||||
assert json.loads(fc.arguments) == {"city": "Dallas"}
|
||||
|
||||
def test_no_reasoning_parser_include_false_is_noop(self, tokenizer):
|
||||
"""include_reasoning=False is harmless when no reasoning parser."""
|
||||
parser = make_parser(tokenizer, reasoning=False)
|
||||
request = make_responses_request(include_reasoning=False)
|
||||
|
||||
outputs = parse_and_build(parser, request, MODEL_OUTPUT_CONTENT_ONLY)
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert outputs[0].type == "message"
|
||||
assert outputs[0].content[0].text == MODEL_OUTPUT_CONTENT_ONLY
|
||||
|
||||
def test_default_include_reasoning_is_true(self, tokenizer):
|
||||
"""ResponsesRequest defaults to include_reasoning=True."""
|
||||
request = make_responses_request()
|
||||
assert request.include_reasoning is True
|
||||
|
||||
def test_include_reasoning_false_suppresses_all_reasoning(self, tokenizer):
|
||||
"""Reasoning is suppressed regardless of request type."""
|
||||
parser = make_parser(tokenizer, reasoning=True)
|
||||
request = make_responses_request(include_reasoning=False)
|
||||
|
||||
outputs = parse_and_build(parser, request, MODEL_OUTPUT_REASONING_AND_CONTENT)
|
||||
|
||||
assert all(o.type != "reasoning" for o in outputs)
|
||||
|
||||
|
||||
# ── Streaming: parse_delta ───────────────────────────────────────────
|
||||
|
||||
|
||||
def stream_text(parser, tokenizer, text, request, prompt_token_ids=None):
|
||||
token_ids = tokenizer.encode(text, add_special_tokens=False)
|
||||
results: list[DeltaMessage | None] = []
|
||||
for i, tid in enumerate(token_ids):
|
||||
delta_text = tokenizer.decode([tid])
|
||||
is_last = i == len(token_ids) - 1
|
||||
result = parser.parse_delta(
|
||||
delta_text,
|
||||
[tid],
|
||||
request,
|
||||
prompt_token_ids=prompt_token_ids,
|
||||
finished=is_last,
|
||||
)
|
||||
prompt_token_ids = None
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
|
||||
def collect_fields(results):
|
||||
all_reasoning = "".join(r.reasoning for r in results if r and r.reasoning)
|
||||
all_content = "".join(r.content for r in results if r and r.content)
|
||||
all_tool_calls = [tc for r in results if r and r.tool_calls for tc in r.tool_calls]
|
||||
return all_reasoning, all_content, all_tool_calls
|
||||
|
||||
|
||||
class TestParseDeltaIncludeReasoning:
|
||||
def test_streaming_include_true_emits_reasoning(self, tokenizer):
|
||||
"""With include_reasoning=True, reasoning deltas are emitted."""
|
||||
parser = make_parser(tokenizer, reasoning=True)
|
||||
request = make_responses_request(include_reasoning=True)
|
||||
|
||||
results = stream_text(
|
||||
parser,
|
||||
tokenizer,
|
||||
MODEL_OUTPUT_REASONING_AND_CONTENT,
|
||||
request,
|
||||
prompt_token_ids=[],
|
||||
)
|
||||
reasoning, content, _ = collect_fields(results)
|
||||
|
||||
assert "let me think about this" in reasoning
|
||||
assert "42" in content
|
||||
|
||||
def test_streaming_include_false_suppresses_reasoning(self, tokenizer):
|
||||
"""With include_reasoning=False, no reasoning deltas are emitted."""
|
||||
parser = make_parser(tokenizer, reasoning=True)
|
||||
request = make_responses_request(include_reasoning=False)
|
||||
|
||||
results = stream_text(
|
||||
parser,
|
||||
tokenizer,
|
||||
MODEL_OUTPUT_REASONING_AND_CONTENT,
|
||||
request,
|
||||
prompt_token_ids=[],
|
||||
)
|
||||
reasoning, content, _ = collect_fields(results)
|
||||
|
||||
assert reasoning == ""
|
||||
assert "42" in content
|
||||
|
||||
def test_streaming_include_false_content_still_works(self, tokenizer):
|
||||
"""Content is correctly extracted in streaming even with suppression."""
|
||||
parser = make_parser(tokenizer, reasoning=True)
|
||||
request = make_responses_request(include_reasoning=False)
|
||||
|
||||
results = stream_text(
|
||||
parser,
|
||||
tokenizer,
|
||||
MODEL_OUTPUT_REASONING_AND_CONTENT,
|
||||
request,
|
||||
prompt_token_ids=[],
|
||||
)
|
||||
_, content, _ = collect_fields(results)
|
||||
|
||||
assert "The answer is 42" in content
|
||||
|
||||
def test_streaming_include_false_tool_calls_preserved(self, tokenizer):
|
||||
"""Tool calls stream correctly when reasoning is suppressed."""
|
||||
parser = make_parser(tokenizer, reasoning=True, tool=True)
|
||||
request = make_responses_request(
|
||||
include_reasoning=False,
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"name": "get_weather",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
results = stream_text(
|
||||
parser,
|
||||
tokenizer,
|
||||
MODEL_OUTPUT_REASONING_AND_TOOL,
|
||||
request,
|
||||
prompt_token_ids=[],
|
||||
)
|
||||
reasoning, content, tool_calls = collect_fields(results)
|
||||
|
||||
assert reasoning == ""
|
||||
assert len(tool_calls) > 0
|
||||
assert tool_calls[0].function.name == "get_weather"
|
||||
tool_args = "".join(
|
||||
tc.function.arguments for tc in tool_calls if tc.function.arguments
|
||||
)
|
||||
assert json.loads(tool_args) == {"city": "Dallas"}
|
||||
|
||||
def test_streaming_no_reasoning_parser_include_false(self, tokenizer):
|
||||
"""No crash when reasoning parser absent and include_reasoning=False."""
|
||||
parser = make_parser(tokenizer, reasoning=False)
|
||||
request = make_responses_request(include_reasoning=False)
|
||||
|
||||
results = stream_text(
|
||||
parser,
|
||||
tokenizer,
|
||||
MODEL_OUTPUT_CONTENT_ONLY,
|
||||
request,
|
||||
prompt_token_ids=[],
|
||||
)
|
||||
reasoning, content, _ = collect_fields(results)
|
||||
|
||||
assert reasoning == ""
|
||||
assert "42" in content
|
||||
|
||||
def test_streaming_chat_completion_include_false(self, tokenizer):
|
||||
"""parse_delta also respects ChatCompletionRequest.include_reasoning."""
|
||||
parser = make_parser(tokenizer, reasoning=True)
|
||||
request = make_chat_request(include_reasoning=False)
|
||||
|
||||
results = stream_text(
|
||||
parser,
|
||||
tokenizer,
|
||||
MODEL_OUTPUT_REASONING_AND_CONTENT,
|
||||
request,
|
||||
prompt_token_ids=[],
|
||||
)
|
||||
reasoning, content, _ = collect_fields(results)
|
||||
|
||||
assert reasoning == ""
|
||||
assert "42" in content
|
||||
|
||||
def test_streaming_reasoning_only_deltas_become_none(self, tokenizer):
|
||||
"""Deltas that carry only reasoning become None (not empty)."""
|
||||
parser = make_parser(tokenizer, reasoning=True)
|
||||
request = make_responses_request(include_reasoning=False)
|
||||
|
||||
results = stream_text(
|
||||
parser,
|
||||
tokenizer,
|
||||
MODEL_OUTPUT_REASONING_AND_CONTENT,
|
||||
request,
|
||||
prompt_token_ids=[],
|
||||
)
|
||||
|
||||
for r in results:
|
||||
if r is not None:
|
||||
assert r.reasoning is None
|
||||
|
||||
|
||||
# ── ParsableContext.append_output() ───────────────────────────────────
|
||||
|
||||
|
||||
class TestParsableContextIncludeReasoning:
|
||||
def _make_context(self, tokenizer, request):
|
||||
from vllm.entrypoints.openai.responses.context import ParsableContext
|
||||
|
||||
class TestParser(DelegatingParser):
|
||||
reasoning_parser_cls = ThinkReasoningParser
|
||||
tool_parser_cls = None
|
||||
|
||||
return ParsableContext(
|
||||
tokenizer=tokenizer,
|
||||
parser_cls=TestParser,
|
||||
response_messages=[],
|
||||
request=request,
|
||||
available_tools=None,
|
||||
chat_template=None,
|
||||
chat_template_content_format="auto",
|
||||
)
|
||||
|
||||
def test_process_include_false_suppresses_reasoning(self, tokenizer):
|
||||
"""ParsableContext.process() suppresses reasoning items."""
|
||||
from vllm.outputs import CompletionOutput, RequestOutput
|
||||
|
||||
request = make_responses_request(include_reasoning=False)
|
||||
ctx = self._make_context(tokenizer, request)
|
||||
|
||||
output = RequestOutput(
|
||||
request_id="test",
|
||||
prompt=None,
|
||||
prompt_token_ids=[],
|
||||
prompt_logprobs=None,
|
||||
outputs=[
|
||||
CompletionOutput(
|
||||
index=0,
|
||||
text=MODEL_OUTPUT_REASONING_AND_CONTENT,
|
||||
token_ids=tokenizer.encode(
|
||||
MODEL_OUTPUT_REASONING_AND_CONTENT,
|
||||
add_special_tokens=False,
|
||||
),
|
||||
cumulative_logprob=None,
|
||||
logprobs=None,
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
finished=True,
|
||||
)
|
||||
|
||||
ctx.append_output(output)
|
||||
|
||||
types = [getattr(m, "type", None) for m in ctx.response_messages]
|
||||
assert "reasoning" not in types
|
||||
assert "message" in types
|
||||
|
||||
def test_process_include_true_has_reasoning(self, tokenizer):
|
||||
"""ParsableContext.process() includes reasoning by default."""
|
||||
from vllm.outputs import CompletionOutput, RequestOutput
|
||||
|
||||
request = make_responses_request(include_reasoning=True)
|
||||
ctx = self._make_context(tokenizer, request)
|
||||
|
||||
output = RequestOutput(
|
||||
request_id="test",
|
||||
prompt=None,
|
||||
prompt_token_ids=[],
|
||||
prompt_logprobs=None,
|
||||
outputs=[
|
||||
CompletionOutput(
|
||||
index=0,
|
||||
text=MODEL_OUTPUT_REASONING_AND_CONTENT,
|
||||
token_ids=tokenizer.encode(
|
||||
MODEL_OUTPUT_REASONING_AND_CONTENT,
|
||||
add_special_tokens=False,
|
||||
),
|
||||
cumulative_logprob=None,
|
||||
logprobs=None,
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
finished=True,
|
||||
)
|
||||
|
||||
ctx.append_output(output)
|
||||
|
||||
types = [getattr(m, "type", None) for m in ctx.response_messages]
|
||||
assert "reasoning" in types
|
||||
assert "message" in types
|
||||
Reference in New Issue
Block a user