chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm.entrypoints.openai.engine.protocol import StreamOptions
|
||||
from vllm.entrypoints.serve.utils.api_utils import (
|
||||
get_max_tokens,
|
||||
sanitize_message,
|
||||
should_include_usage,
|
||||
)
|
||||
|
||||
|
||||
def test_sanitize_message():
|
||||
assert (
|
||||
sanitize_message("<_io.BytesIO object at 0x7a95e299e750>")
|
||||
== "<_io.BytesIO object>"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stream_options", "expected"),
|
||||
[
|
||||
(None, (True, True)),
|
||||
(StreamOptions(include_usage=False), (True, True)),
|
||||
(
|
||||
StreamOptions(include_usage=False, continuous_usage_stats=False),
|
||||
(True, True),
|
||||
),
|
||||
(
|
||||
StreamOptions(include_usage=True, continuous_usage_stats=False),
|
||||
(True, True),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_should_include_usage_force_enables_continuous_usage(stream_options, expected):
|
||||
assert should_include_usage(stream_options, True) == expected
|
||||
|
||||
|
||||
class TestGetMaxTokens:
|
||||
"""Tests for get_max_tokens() to ensure generation_config's max_tokens
|
||||
acts as a default when from model author, and as a ceiling when
|
||||
explicitly set by the user."""
|
||||
|
||||
def test_default_sampling_params_used_when_no_request_max_tokens(self):
|
||||
"""When user doesn't specify max_tokens, generation_config default
|
||||
should apply."""
|
||||
result = get_max_tokens(
|
||||
max_model_len=24000,
|
||||
max_tokens=None,
|
||||
input_length=100,
|
||||
default_sampling_params={"max_tokens": 2048},
|
||||
)
|
||||
assert result == 2048
|
||||
|
||||
def test_request_max_tokens_not_capped_by_default_sampling_params(self):
|
||||
"""When user specifies max_tokens in request, model author's
|
||||
generation_config max_tokens must NOT cap it (fixes #34005)."""
|
||||
result = get_max_tokens(
|
||||
max_model_len=24000,
|
||||
max_tokens=5000,
|
||||
input_length=100,
|
||||
default_sampling_params={"max_tokens": 2048},
|
||||
)
|
||||
assert result == 5000
|
||||
|
||||
def test_override_max_tokens_caps_request(self):
|
||||
"""When user explicitly sets max_tokens, it acts as a ceiling."""
|
||||
result = get_max_tokens(
|
||||
max_model_len=24000,
|
||||
max_tokens=5000,
|
||||
input_length=100,
|
||||
default_sampling_params={"max_tokens": 2048},
|
||||
override_max_tokens=2048,
|
||||
)
|
||||
assert result == 2048
|
||||
|
||||
def test_override_max_tokens_used_as_default(self):
|
||||
"""When no request max_tokens, override still applies as default."""
|
||||
result = get_max_tokens(
|
||||
max_model_len=24000,
|
||||
max_tokens=None,
|
||||
input_length=100,
|
||||
default_sampling_params={"max_tokens": 2048},
|
||||
override_max_tokens=2048,
|
||||
)
|
||||
assert result == 2048
|
||||
|
||||
def test_max_model_len_still_caps_output(self):
|
||||
"""max_model_len - input_length is always the hard ceiling."""
|
||||
result = get_max_tokens(
|
||||
max_model_len=3000,
|
||||
max_tokens=5000,
|
||||
input_length=100,
|
||||
default_sampling_params={"max_tokens": 2048},
|
||||
)
|
||||
assert result == 2900 # 3000 - 100
|
||||
|
||||
def test_request_max_tokens_smaller_than_default(self):
|
||||
"""When user explicitly requests fewer tokens than gen_config default,
|
||||
that should be respected."""
|
||||
result = get_max_tokens(
|
||||
max_model_len=24000,
|
||||
max_tokens=512,
|
||||
input_length=100,
|
||||
default_sampling_params={"max_tokens": 2048},
|
||||
)
|
||||
assert result == 512
|
||||
|
||||
def test_input_length_exceeds_max_model_len(self):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Input length .* exceeds model's maximum context length .*",
|
||||
):
|
||||
get_max_tokens(
|
||||
max_model_len=100,
|
||||
max_tokens=50,
|
||||
input_length=150,
|
||||
default_sampling_params={"max_tokens": 2048},
|
||||
)
|
||||
|
||||
|
||||
class TestSanitizeMessageFilePaths:
|
||||
"""sanitize_message should also strip file paths and traceback
|
||||
frames, not just memory addresses - see #31683."""
|
||||
|
||||
def test_strips_traceback_style_frame(self):
|
||||
msg = (
|
||||
"1 validation error:\n"
|
||||
" {'type': 'list_type', 'loc': ('body', 'messages')}\n"
|
||||
'\n File "/usr/local/lib/python3.12/dist-packages/vllm/'
|
||||
'entrypoints/serve/utils/api_utils.py", line 40, '
|
||||
"in create_chat_completion\n"
|
||||
" POST /v1/chat/completions"
|
||||
)
|
||||
result = sanitize_message(msg)
|
||||
assert "/usr/local/" not in result
|
||||
assert "api_utils.py" not in result
|
||||
assert "list_type" in result
|
||||
|
||||
def test_strips_arbitrary_absolute_path(self):
|
||||
result = sanitize_message("Error in /home/user/project/vllm/server.py")
|
||||
assert "/home/user" not in result
|
||||
|
||||
def test_strips_single_parent_container_path(self):
|
||||
"""Regression: /app/server.py and /workspace/server.py (common in
|
||||
container deployments) were missed by the original {2,} quantifier."""
|
||||
assert "/app/" not in sanitize_message("Error in /app/server.py")
|
||||
assert "/workspace/" not in sanitize_message("Error in /workspace/server.py")
|
||||
|
||||
def test_preserves_api_endpoint_paths(self):
|
||||
msg = "POST /v1/chat/completions failed"
|
||||
assert "/v1/chat/completions" in sanitize_message(msg)
|
||||
|
||||
def test_preserves_short_field_references(self):
|
||||
msg = "Invalid value for field 'body.messages'"
|
||||
assert sanitize_message(msg) == msg
|
||||
|
||||
def test_strips_both_address_and_path(self):
|
||||
msg = (
|
||||
"<Request at 0x7f123> failed at "
|
||||
"/usr/local/lib/python3.12/dist-packages/vllm/server.py"
|
||||
)
|
||||
result = sanitize_message(msg)
|
||||
assert "0x" not in result
|
||||
assert "/usr/local/" not in result
|
||||
@@ -0,0 +1,81 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Tests that error messages in Anthropic and speech-to-text entrypoints
|
||||
are sanitized to prevent memory address leakage.
|
||||
|
||||
Verifies the fix for the incomplete CVE-2026-22778 remediation where
|
||||
PIL repr addresses leaked via the Anthropic API router and the
|
||||
speech-to-text WebSocket paths.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm.entrypoints.serve.utils.api_utils import sanitize_message
|
||||
|
||||
|
||||
class TestSanitizeMessageCoversLeakPatterns:
|
||||
"""Ensure sanitize_message strips addresses from realistic exceptions."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
(
|
||||
"cannot identify image file <_io.BytesIO object at 0x7a95e299e750>",
|
||||
"cannot identify image file <_io.BytesIO object>",
|
||||
),
|
||||
(
|
||||
"cannot identify image file <_io.BytesIO object at 0x7f3c1a2b4d90>",
|
||||
"cannot identify image file <_io.BytesIO object>",
|
||||
),
|
||||
(
|
||||
"<PIL.PngImagePlugin.PngImageFile image mode=RGB "
|
||||
"size=8x8 at 0x7f3c1a2b4d90>",
|
||||
"<PIL.PngImagePlugin.PngImageFile image mode=RGB size=8x8>",
|
||||
),
|
||||
(
|
||||
"Error processing <_io.BytesIO object at 0xdeadbeef>: invalid header",
|
||||
"Error processing <_io.BytesIO object>: invalid header",
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"bytesio-standard",
|
||||
"bytesio-different-addr",
|
||||
"pil-image-repr",
|
||||
"mid-string-repr",
|
||||
],
|
||||
)
|
||||
def test_address_stripped(self, raw: str, expected: str):
|
||||
assert sanitize_message(raw) == expected
|
||||
|
||||
def test_safe_message_unchanged(self):
|
||||
msg = "Invalid request: missing 'messages' field"
|
||||
assert sanitize_message(msg) == msg
|
||||
|
||||
def test_multiple_addresses_stripped(self):
|
||||
raw = "<obj at 0xaaa> and <obj at 0xbbb>"
|
||||
result = sanitize_message(raw)
|
||||
assert "0x" not in result
|
||||
|
||||
|
||||
class TestAffectedModulesUseSanitize:
|
||||
"""Verify that affected modules call sanitize_message (source-level)."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"module",
|
||||
[
|
||||
"vllm.entrypoints.anthropic.api_router",
|
||||
"vllm.entrypoints.anthropic.serving",
|
||||
"vllm.entrypoints.speech_to_text.realtime.connection",
|
||||
],
|
||||
)
|
||||
def test_module_calls_sanitize_message(self, module: str):
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
spec = importlib.util.find_spec(module)
|
||||
assert spec is not None and spec.origin is not None, (
|
||||
f"Cannot locate module {module}"
|
||||
)
|
||||
source = Path(spec.origin).read_text()
|
||||
assert "sanitize_message" in source, f"{module} does not call sanitize_message"
|
||||
assert "import" in source and "sanitize_message" in source
|
||||
@@ -0,0 +1,76 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Unit tests for ``system_fingerprint`` construction."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm.entrypoints.serve.utils import fingerprint as fp
|
||||
|
||||
|
||||
def _cfg(tp=1, pp=1, dp=1, ep=False, digest="a3b21f94deadbeef"):
|
||||
c = SimpleNamespace(
|
||||
parallel_config=SimpleNamespace(
|
||||
tensor_parallel_size=tp,
|
||||
pipeline_parallel_size=pp,
|
||||
data_parallel_size=dp,
|
||||
enable_expert_parallel=ep,
|
||||
)
|
||||
)
|
||||
c.compute_hash = lambda: digest # type: ignore[attr-defined]
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset():
|
||||
fp.set_default_fingerprint_mode("full")
|
||||
yield
|
||||
fp.set_default_fingerprint_mode("full")
|
||||
|
||||
|
||||
def test_four_modes_produce_expected_shapes():
|
||||
from vllm import __version__ as v
|
||||
|
||||
cfg = _cfg(tp=8, ep=True)
|
||||
|
||||
assert fp.build_system_fingerprint(cfg, "full") == (f"vllm-{v}-tp8-ep-a3b21f94")
|
||||
assert fp.build_system_fingerprint(cfg, "hash") == f"vllm-{v}-a3b21f94"
|
||||
assert fp.build_system_fingerprint(cfg, "custom", "my-fp") == "my-fp"
|
||||
assert fp.build_system_fingerprint(cfg, "none") is None
|
||||
|
||||
|
||||
def test_full_mode_emits_only_non_trivial_parallelism():
|
||||
from vllm import __version__ as v
|
||||
|
||||
# Single-GPU: nothing between version and hash.
|
||||
assert fp.build_system_fingerprint(_cfg(), "full") == f"vllm-{v}-a3b21f94"
|
||||
# All parallelism axes.
|
||||
assert (
|
||||
fp.build_system_fingerprint(_cfg(tp=8, pp=2, dp=4, ep=True), "full")
|
||||
== f"vllm-{v}-tp8-pp2-dp4-ep-a3b21f94"
|
||||
)
|
||||
|
||||
|
||||
def test_get_respects_set_default():
|
||||
cfg = _cfg(tp=8)
|
||||
full = fp.get_system_fingerprint(cfg)
|
||||
assert full == fp.get_system_fingerprint(cfg)
|
||||
|
||||
fp.set_default_fingerprint_mode("hash")
|
||||
hashed = fp.get_system_fingerprint(cfg)
|
||||
assert hashed != full
|
||||
assert "tp8" not in hashed
|
||||
|
||||
fp.set_default_fingerprint_mode("custom", "deploy-42")
|
||||
assert fp.get_system_fingerprint(cfg) == "deploy-42"
|
||||
|
||||
fp.set_default_fingerprint_mode("none")
|
||||
assert fp.get_system_fingerprint(cfg) is None
|
||||
|
||||
|
||||
def test_compute_hash_failure_does_not_raise():
|
||||
cfg = _cfg()
|
||||
cfg.compute_hash = lambda: (_ for _ in ()).throw(RuntimeError("boom"))
|
||||
assert fp.build_system_fingerprint(cfg, "full").endswith("-nohash")
|
||||
assert fp.build_system_fingerprint(cfg, "hash").endswith("-nohash")
|
||||
@@ -0,0 +1,248 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from vllm.entrypoints.serve.utils.request_logger import RequestLogger
|
||||
|
||||
|
||||
def test_request_logger_log_outputs():
|
||||
"""Test the new log_outputs functionality."""
|
||||
# Create a mock logger to capture log calls
|
||||
mock_logger = MagicMock()
|
||||
|
||||
with patch("vllm.entrypoints.serve.utils.request_logger.logger", mock_logger):
|
||||
request_logger = RequestLogger(max_log_len=None)
|
||||
|
||||
# Test basic output logging
|
||||
request_logger.log_outputs(
|
||||
request_id="test-123",
|
||||
outputs="Hello, world!",
|
||||
output_token_ids=[1, 2, 3, 4],
|
||||
finish_reason="stop",
|
||||
is_streaming=False,
|
||||
delta=False,
|
||||
)
|
||||
|
||||
mock_logger.info.assert_called_once()
|
||||
call_args = mock_logger.info.call_args.args
|
||||
assert "Generated response %s%s" in call_args[0]
|
||||
assert call_args[1] == "test-123"
|
||||
assert call_args[3] == "Hello, world!"
|
||||
assert call_args[4] == [1, 2, 3, 4]
|
||||
assert call_args[5] == "stop"
|
||||
|
||||
|
||||
def test_request_logger_log_outputs_streaming_delta():
|
||||
"""Test log_outputs with streaming delta mode."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
with patch("vllm.entrypoints.serve.utils.request_logger.logger", mock_logger):
|
||||
request_logger = RequestLogger(max_log_len=None)
|
||||
|
||||
# Test streaming delta logging
|
||||
request_logger.log_outputs(
|
||||
request_id="test-456",
|
||||
outputs="Hello",
|
||||
output_token_ids=[1],
|
||||
finish_reason=None,
|
||||
is_streaming=True,
|
||||
delta=True,
|
||||
)
|
||||
|
||||
mock_logger.info.assert_called_once()
|
||||
call_args = mock_logger.info.call_args.args
|
||||
assert "Generated response %s%s" in call_args[0]
|
||||
assert call_args[1] == "test-456"
|
||||
assert call_args[2] == " (streaming delta)"
|
||||
assert call_args[3] == "Hello"
|
||||
assert call_args[4] == [1]
|
||||
assert call_args[5] is None
|
||||
|
||||
|
||||
def test_request_logger_log_outputs_streaming_complete():
|
||||
"""Test log_outputs with streaming complete mode."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
with patch("vllm.entrypoints.serve.utils.request_logger.logger", mock_logger):
|
||||
request_logger = RequestLogger(max_log_len=None)
|
||||
|
||||
# Test streaming complete logging
|
||||
request_logger.log_outputs(
|
||||
request_id="test-789",
|
||||
outputs="Complete response",
|
||||
output_token_ids=[1, 2, 3],
|
||||
finish_reason="length",
|
||||
is_streaming=True,
|
||||
delta=False,
|
||||
)
|
||||
|
||||
mock_logger.info.assert_called_once()
|
||||
call_args = mock_logger.info.call_args.args
|
||||
assert "Generated response %s%s" in call_args[0]
|
||||
assert call_args[1] == "test-789"
|
||||
assert call_args[2] == " (streaming complete)"
|
||||
assert call_args[3] == "Complete response"
|
||||
assert call_args[4] == [1, 2, 3]
|
||||
assert call_args[5] == "length"
|
||||
|
||||
|
||||
def test_request_logger_log_outputs_with_truncation():
|
||||
"""Test log_outputs respects max_log_len setting."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
with patch("vllm.entrypoints.serve.utils.request_logger.logger", mock_logger):
|
||||
# Set max_log_len to 10
|
||||
request_logger = RequestLogger(max_log_len=10)
|
||||
|
||||
# Test output truncation
|
||||
long_output = "This is a very long output that should be truncated"
|
||||
long_token_ids = list(range(20)) # 20 tokens
|
||||
|
||||
request_logger.log_outputs(
|
||||
request_id="test-truncate",
|
||||
outputs=long_output,
|
||||
output_token_ids=long_token_ids,
|
||||
finish_reason="stop",
|
||||
is_streaming=False,
|
||||
delta=False,
|
||||
)
|
||||
|
||||
mock_logger.info.assert_called_once()
|
||||
call_args = mock_logger.info.call_args
|
||||
|
||||
# Check that output was truncated to first 10 characters
|
||||
logged_output = call_args[0][3]
|
||||
assert logged_output == "This is a "
|
||||
assert len(logged_output) == 10
|
||||
|
||||
# Check that token IDs were truncated to first 10 tokens
|
||||
logged_token_ids = call_args[0][4]
|
||||
assert logged_token_ids == list(range(10))
|
||||
assert len(logged_token_ids) == 10
|
||||
|
||||
|
||||
def test_request_logger_log_outputs_none_values():
|
||||
"""Test log_outputs handles None values correctly."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
with patch("vllm.entrypoints.serve.utils.request_logger.logger", mock_logger):
|
||||
request_logger = RequestLogger(max_log_len=None)
|
||||
|
||||
# Test with None output_token_ids
|
||||
request_logger.log_outputs(
|
||||
request_id="test-none",
|
||||
outputs="Test output",
|
||||
output_token_ids=None,
|
||||
finish_reason="stop",
|
||||
is_streaming=False,
|
||||
delta=False,
|
||||
)
|
||||
|
||||
mock_logger.info.assert_called_once()
|
||||
call_args = mock_logger.info.call_args.args
|
||||
assert "Generated response %s%s" in call_args[0]
|
||||
assert call_args[1] == "test-none"
|
||||
assert call_args[3] == "Test output"
|
||||
assert call_args[4] is None
|
||||
assert call_args[5] == "stop"
|
||||
|
||||
|
||||
def test_request_logger_log_outputs_empty_output():
|
||||
"""Test log_outputs handles empty output correctly."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
with patch("vllm.entrypoints.serve.utils.request_logger.logger", mock_logger):
|
||||
request_logger = RequestLogger(max_log_len=5)
|
||||
|
||||
# Test with empty output
|
||||
request_logger.log_outputs(
|
||||
request_id="test-empty",
|
||||
outputs="",
|
||||
output_token_ids=[],
|
||||
finish_reason="stop",
|
||||
is_streaming=False,
|
||||
delta=False,
|
||||
)
|
||||
|
||||
mock_logger.info.assert_called_once()
|
||||
call_args = mock_logger.info.call_args.args
|
||||
assert "Generated response %s%s" in call_args[0]
|
||||
assert call_args[1] == "test-empty"
|
||||
assert call_args[3] == ""
|
||||
assert call_args[4] == []
|
||||
assert call_args[5] == "stop"
|
||||
|
||||
|
||||
def test_request_logger_log_outputs_integration():
|
||||
"""Test that log_outputs can be called alongside log_inputs."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
with patch("vllm.entrypoints.serve.utils.request_logger.logger", mock_logger):
|
||||
request_logger = RequestLogger(max_log_len=None)
|
||||
|
||||
# Test that both methods can be called without interference
|
||||
request_logger.log_inputs(
|
||||
request_id="test-integration",
|
||||
prompt="Test prompt",
|
||||
prompt_token_ids=[1, 2, 3],
|
||||
prompt_embeds=None,
|
||||
params=None,
|
||||
lora_request=None,
|
||||
)
|
||||
|
||||
request_logger.log_outputs(
|
||||
request_id="test-integration",
|
||||
outputs="Test output",
|
||||
output_token_ids=[4, 5, 6],
|
||||
finish_reason="stop",
|
||||
is_streaming=False,
|
||||
delta=False,
|
||||
)
|
||||
|
||||
# Should have been called twice - once for inputs, once for outputs
|
||||
assert mock_logger.info.call_count == 2
|
||||
|
||||
# Check that the calls were made with correct patterns
|
||||
input_call = mock_logger.info.call_args_list[0][0]
|
||||
output_call = mock_logger.info.call_args_list[1][0]
|
||||
|
||||
assert "Received request %s" in input_call[0]
|
||||
assert input_call[1] == "test-integration"
|
||||
|
||||
assert "Generated response %s%s" in output_call[0]
|
||||
assert output_call[1] == "test-integration"
|
||||
|
||||
|
||||
def test_streaming_complete_logs_full_text_content():
|
||||
"""Test that streaming complete logging includes
|
||||
full accumulated text, not just token count."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
with patch("vllm.entrypoints.serve.utils.request_logger.logger", mock_logger):
|
||||
request_logger = RequestLogger(max_log_len=None)
|
||||
|
||||
# Test with actual content instead of token count format
|
||||
full_response = "This is a complete response from streaming"
|
||||
request_logger.log_outputs(
|
||||
request_id="test-streaming-full-text",
|
||||
outputs=full_response,
|
||||
output_token_ids=None,
|
||||
finish_reason="streaming_complete",
|
||||
is_streaming=True,
|
||||
delta=False,
|
||||
)
|
||||
|
||||
mock_logger.info.assert_called_once()
|
||||
call_args = mock_logger.info.call_args.args
|
||||
|
||||
# Verify the logged output is the full text, not a token count format
|
||||
logged_output = call_args[3]
|
||||
assert logged_output == full_response
|
||||
assert "tokens>" not in logged_output
|
||||
assert "streaming_complete" not in logged_output
|
||||
|
||||
# Verify other parameters
|
||||
assert call_args[1] == "test-streaming-full-text"
|
||||
assert call_args[2] == " (streaming complete)"
|
||||
assert call_args[5] == "streaming_complete"
|
||||
@@ -0,0 +1,153 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Tests that validation_exception_handler populates the `param` field
|
||||
in its error response using the Pydantic error's `loc`, even when no
|
||||
custom VLLMValidationError context is present.
|
||||
|
||||
Previously, `param` was only populated for errors carrying a custom
|
||||
VLLMValidationError in their Pydantic `ctx`. Plain validation failures
|
||||
(missing fields, wrong types) left `param` as None, even though the
|
||||
field name was readily available from `error['loc']`.
|
||||
"""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
|
||||
from vllm.entrypoints.serve.utils.server_utils import (
|
||||
clean_loc_for_param,
|
||||
validation_exception_handler,
|
||||
)
|
||||
|
||||
|
||||
def _fake_request(log_error_stack: bool = False) -> SimpleNamespace:
|
||||
"""Minimal stand-in for a FastAPI Request - just enough for the
|
||||
handler to read req.app.state.args.log_error_stack."""
|
||||
return SimpleNamespace(
|
||||
app=SimpleNamespace(
|
||||
state=SimpleNamespace(args=SimpleNamespace(log_error_stack=log_error_stack))
|
||||
),
|
||||
state=SimpleNamespace(), # no request_metadata -> hasattr(...) is False
|
||||
)
|
||||
|
||||
|
||||
class TestValidationErrorParamFallback:
|
||||
"""Ensure `param` falls back to the Pydantic error's `loc` when no
|
||||
custom VLLMValidationError context is present."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error_type", "msg"),
|
||||
[
|
||||
("missing", "Field required"),
|
||||
("list_type", "Input should be a valid list"),
|
||||
],
|
||||
ids=["missing-field", "wrong-type"],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_param_falls_back_to_loc(self, error_type: str, msg: str):
|
||||
errors = [{"type": error_type, "loc": ("body", "messages"), "msg": msg}]
|
||||
exc = RequestValidationError(errors)
|
||||
|
||||
response = await validation_exception_handler(_fake_request(), exc)
|
||||
body = json.loads(response.body)
|
||||
|
||||
assert body["error"]["param"] == "body.messages"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_param_fallback_does_not_crash_on_non_dict_error(self):
|
||||
"""Schemathesis fuzzing found that errors[0] isn't always a dict.
|
||||
The fallback must not crash in that case - it should just leave
|
||||
param as None instead of raising."""
|
||||
exc = RequestValidationError(["some unexpected non-dict error"])
|
||||
|
||||
response = await validation_exception_handler(_fake_request(), exc)
|
||||
body = json.loads(response.body)
|
||||
|
||||
assert body["error"]["param"] is None
|
||||
|
||||
|
||||
class TestCleanLocForParam:
|
||||
"""Guards against PR #1's naive dot-joined `loc` fallback leaking
|
||||
Pydantic-internal wrapper/union-branch markers into `param`, e.g.
|
||||
'body.function-wrap[__log_extra_fields__()].prompt' instead of the
|
||||
clean 'body.prompt' an API consumer would recognize.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"loc,expected",
|
||||
[
|
||||
(("body", "prompt"), "body.prompt"),
|
||||
(("body", "messages", 2, "content"), "body.messages.2.content"),
|
||||
(
|
||||
("body", "function-wrap[__log_extra_fields__()]", "prompt"),
|
||||
"body.prompt",
|
||||
),
|
||||
(("body", "stop", "str"), "body.stop"),
|
||||
(("body", "stop", "list[str]"), "body.stop"),
|
||||
(
|
||||
("body", "prompt", "list[constrained-int]"),
|
||||
"body.prompt",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_strips_internal_markers(self, loc, expected):
|
||||
assert clean_loc_for_param(loc) == expected
|
||||
|
||||
def test_all_internal_falls_back_to_raw_join(self):
|
||||
"""If every loc segment looks internal, fall back to the raw
|
||||
dot-join rather than returning an empty string."""
|
||||
loc = ("function-wrap[__log_extra_fields__()]",)
|
||||
assert clean_loc_for_param(loc) == "function-wrap[__log_extra_fields__()]"
|
||||
|
||||
|
||||
class TestValidationErrorDoesNotLeakServerPaths:
|
||||
"""FastAPI sets endpoint_file/line/function/path on the exception
|
||||
during routing, not in the constructor - so we set them manually
|
||||
here to reproduce the real leak. See #31683."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_strips_endpoint_file_context(self):
|
||||
errors = [
|
||||
{
|
||||
"type": "list_type",
|
||||
"loc": ("body", "messages"),
|
||||
"msg": "Input should be a valid list",
|
||||
"input": "not-a-list",
|
||||
}
|
||||
]
|
||||
exc = RequestValidationError(errors)
|
||||
exc.endpoint_file = (
|
||||
"/usr/local/lib/python3.12/dist-packages/vllm/"
|
||||
"entrypoints/serve/utils/api_utils.py"
|
||||
)
|
||||
exc.endpoint_line = 40
|
||||
exc.endpoint_function = "create_chat_completion"
|
||||
exc.endpoint_path = "POST /v1/chat/completions"
|
||||
|
||||
response = await validation_exception_handler(_fake_request(), exc)
|
||||
body = json.loads(response.body)
|
||||
message = body["error"]["message"]
|
||||
|
||||
assert "/usr/local/" not in message
|
||||
assert "api_utils.py" not in message
|
||||
assert "create_chat_completion" not in message
|
||||
assert "list_type" in message
|
||||
assert "Input should be a valid list" in message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_strips_endpoint_path_only_variant(self):
|
||||
"""Covers the branch where only endpoint_path is set."""
|
||||
errors = [
|
||||
{"type": "missing", "loc": ("body", "messages"), "msg": "Field required"}
|
||||
]
|
||||
exc = RequestValidationError(errors)
|
||||
exc.endpoint_path = "POST /v1/chat/completions"
|
||||
|
||||
response = await validation_exception_handler(_fake_request(), exc)
|
||||
body = json.loads(response.body)
|
||||
message = body["error"]["message"]
|
||||
|
||||
assert "missing" in message
|
||||
assert "Field required" in message
|
||||
@@ -0,0 +1,102 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import asyncio
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from ssl import SSLContext
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm.entrypoints.serve.utils.ssl import SSLCertRefresher
|
||||
|
||||
|
||||
class MockSSLContext(SSLContext):
|
||||
def __init__(self):
|
||||
self.load_cert_chain_count = 0
|
||||
self.load_ca_count = 0
|
||||
|
||||
def load_cert_chain(
|
||||
self,
|
||||
certfile,
|
||||
keyfile=None,
|
||||
password=None,
|
||||
):
|
||||
self.load_cert_chain_count += 1
|
||||
|
||||
def load_verify_locations(
|
||||
self,
|
||||
cafile=None,
|
||||
capath=None,
|
||||
cadata=None,
|
||||
):
|
||||
self.load_ca_count += 1
|
||||
|
||||
|
||||
def create_file() -> str:
|
||||
with tempfile.NamedTemporaryFile(dir="/tmp", delete=False) as f:
|
||||
return f.name
|
||||
|
||||
|
||||
def touch_file(path: str) -> None:
|
||||
Path(path).touch()
|
||||
|
||||
|
||||
async def wait_for_counts(
|
||||
ssl_context: MockSSLContext,
|
||||
*,
|
||||
cert_chain_count: int,
|
||||
ca_count: int,
|
||||
timeout: float = 5.0,
|
||||
) -> None:
|
||||
deadline = asyncio.get_running_loop().time() + timeout
|
||||
while True:
|
||||
if (
|
||||
ssl_context.load_cert_chain_count >= cert_chain_count
|
||||
and ssl_context.load_ca_count >= ca_count
|
||||
):
|
||||
return
|
||||
|
||||
if asyncio.get_running_loop().time() >= deadline:
|
||||
assert ssl_context.load_cert_chain_count >= cert_chain_count
|
||||
assert ssl_context.load_ca_count >= ca_count
|
||||
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ssl_refresher():
|
||||
ssl_context = MockSSLContext()
|
||||
key_path = create_file()
|
||||
cert_path = create_file()
|
||||
ca_path = create_file()
|
||||
ssl_refresher = SSLCertRefresher(ssl_context, key_path, cert_path, ca_path)
|
||||
await asyncio.sleep(1)
|
||||
assert ssl_context.load_cert_chain_count == 0
|
||||
assert ssl_context.load_ca_count == 0
|
||||
|
||||
touch_file(key_path)
|
||||
await wait_for_counts(
|
||||
ssl_context,
|
||||
cert_chain_count=1,
|
||||
ca_count=0,
|
||||
)
|
||||
assert ssl_context.load_ca_count == 0
|
||||
|
||||
touch_file(cert_path)
|
||||
touch_file(ca_path)
|
||||
await wait_for_counts(
|
||||
ssl_context,
|
||||
cert_chain_count=2,
|
||||
ca_count=1,
|
||||
)
|
||||
|
||||
ssl_refresher.stop()
|
||||
await asyncio.sleep(0)
|
||||
cert_chain_count = ssl_context.load_cert_chain_count
|
||||
ca_count = ssl_context.load_ca_count
|
||||
|
||||
touch_file(cert_path)
|
||||
touch_file(ca_path)
|
||||
await asyncio.sleep(1)
|
||||
assert ssl_context.load_cert_chain_count == cert_chain_count
|
||||
assert ssl_context.load_ca_count == ca_count
|
||||
Reference in New Issue
Block a user