diff --git a/tests/entrypoints/openai/chat_completion/test_non_object_body_validation.py b/tests/entrypoints/openai/chat_completion/test_non_object_body_validation.py index 7dba001861..0c7332f047 100644 --- a/tests/entrypoints/openai/chat_completion/test_non_object_body_validation.py +++ b/tests/entrypoints/openai/chat_completion/test_non_object_body_validation.py @@ -11,6 +11,7 @@ import pytest from pydantic import ValidationError from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest +from vllm.exceptions import VLLMValidationError @pytest.mark.parametrize( @@ -28,7 +29,7 @@ def test_chat_completion_request_rejects_non_object_body(payload): def test_chat_completion_request_cache_salt_still_validated_on_dict(): - with pytest.raises(ValidationError, match="cache_salt"): + with pytest.raises(VLLMValidationError, match="cache_salt"): ChatCompletionRequest.model_validate( { "model": "qwen", diff --git a/tests/entrypoints/unit_tests/test_non_object_body_validation.py b/tests/entrypoints/unit_tests/test_non_object_body_validation.py index a40b73e543..5bdc1d5787 100644 --- a/tests/entrypoints/unit_tests/test_non_object_body_validation.py +++ b/tests/entrypoints/unit_tests/test_non_object_body_validation.py @@ -13,11 +13,13 @@ request models whose before-validators were missing the same guard. import pytest from pydantic import ValidationError +from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest from vllm.entrypoints.openai.completion.protocol import CompletionRequest from vllm.entrypoints.openai.responses.protocol import ResponsesRequest from vllm.entrypoints.pooling.classify.protocol import ClassificationChatRequest from vllm.entrypoints.pooling.embed.protocol import EmbeddingChatRequest from vllm.entrypoints.pooling.pooling.protocol import PoolingChatRequest +from vllm.entrypoints.scale_out.token_in_token_out.protocol import GenerateRequest from vllm.entrypoints.serve.tokenize.protocol import TokenizeChatRequest from vllm.entrypoints.speech_to_text.transcription.protocol import TranscriptionRequest from vllm.entrypoints.speech_to_text.translation.protocol import TranslationRequest @@ -69,3 +71,33 @@ def test_tokenize_chat_request_still_validates_dict_bodies(): "add_generation_prompt": True, } ) + + +CACHE_SALT_MODELS = [ + (CompletionRequest, {"prompt": "hello"}), + (ChatCompletionRequest, {"messages": [{"role": "user", "content": "hello"}]}), + (ResponsesRequest, {"input": "hello"}), + (GenerateRequest, {"token_ids": [1], "sampling_params": {}}), +] + + +@pytest.mark.parametrize( + ("request_model", "payload"), + CACHE_SALT_MODELS, + ids=[m.__name__ for m, _ in CACHE_SALT_MODELS], +) +def test_request_models_reject_forbidden_cache_salt(request_model, payload): + with pytest.raises(VLLMValidationError, match="cache_salt"): + request_model.model_validate({**payload, "cache_salt": "/"}) + + +def test_request_models_reject_overlong_cache_salt(): + with pytest.raises(VLLMValidationError, match="cache_salt"): + CompletionRequest.model_validate({"prompt": "hello", "cache_salt": "a" * 129}) + + +def test_request_models_accept_safe_cache_salt(): + request = CompletionRequest.model_validate( + {"prompt": "hello", "cache_salt": "safe-cache_salt-123"} + ) + assert request.cache_salt == "safe-cache_salt-123" diff --git a/vllm/entrypoints/generate/base/protocol.py b/vllm/entrypoints/generate/base/protocol.py index cf9d807852..76dc5e47a1 100644 --- a/vllm/entrypoints/generate/base/protocol.py +++ b/vllm/entrypoints/generate/base/protocol.py @@ -29,6 +29,28 @@ StopParam: TypeAlias = ( str | Annotated[list[str], Field(max_length=envs.VLLM_MAX_STOP_STRINGS)] | None ) +_CACHE_SALT_FORBIDDEN_CHARS = frozenset("@/\\\x00") +_MAX_CACHE_SALT_LENGTH = 128 + + +def validate_cache_salt(cache_salt: object) -> None: + """Validate cache salts before they reach downstream cache backends.""" + if cache_salt is None: + return + if not isinstance(cache_salt, str) or not cache_salt: + raise VLLMValidationError( + "Parameter 'cache_salt' must be a non-empty string if provided.", + parameter="cache_salt", + ) + if len(cache_salt) > _MAX_CACHE_SALT_LENGTH or any( + char in _CACHE_SALT_FORBIDDEN_CHARS for char in cache_salt + ): + raise VLLMValidationError( + "Parameter 'cache_salt' must be at most 128 characters and must " + "not contain '@', '/', '\\\\', or NUL.", + parameter="cache_salt", + ) + class SpeculativeDecodingMetrics(OpenAIBaseModel): """Per-request speculative-decoding acceptance metrics. diff --git a/vllm/entrypoints/openai/chat_completion/protocol.py b/vllm/entrypoints/openai/chat_completion/protocol.py index 1a49206316..89c1579865 100644 --- a/vllm/entrypoints/openai/chat_completion/protocol.py +++ b/vllm/entrypoints/openai/chat_completion/protocol.py @@ -33,6 +33,7 @@ from vllm.entrypoints.generate.base.protocol import ( StreamOptions, ToolCall, structured_outputs_from_response_format, + validate_cache_salt, validate_structural_tag_response_format, validate_structured_outputs_structural_tag, ) @@ -519,6 +520,14 @@ class ChatCompletionRequest(OpenAIBaseModel): # --8<-- [end:chat-completion-extra-params] + @model_validator(mode="before") + @classmethod + def check_cache_salt_support(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + validate_cache_salt(data.get("cache_salt")) + return data + @model_validator(mode="before") @classmethod def _normalize_messages_before(cls, data: Any) -> Any: diff --git a/vllm/entrypoints/openai/completion/protocol.py b/vllm/entrypoints/openai/completion/protocol.py index 4edef74ea7..b8d66925c0 100644 --- a/vllm/entrypoints/openai/completion/protocol.py +++ b/vllm/entrypoints/openai/completion/protocol.py @@ -16,6 +16,7 @@ from vllm.entrypoints.generate.base.protocol import ( StopParam, StreamOptions, structured_outputs_from_response_format, + validate_cache_salt, validate_structural_tag_response_format, validate_structured_outputs_structural_tag, ) @@ -402,6 +403,14 @@ class CompletionRequest(OpenAIBaseModel): routed_experts_prompt_start=self.routed_experts_prompt_start, ) + @model_validator(mode="before") + @classmethod + def check_cache_salt_support(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + validate_cache_salt(data.get("cache_salt")) + return data + @model_validator(mode="before") @classmethod def normalize_null_max_tokens(cls, data): diff --git a/vllm/entrypoints/openai/responses/protocol.py b/vllm/entrypoints/openai/responses/protocol.py index 0363c01de3..790024fad3 100644 --- a/vllm/entrypoints/openai/responses/protocol.py +++ b/vllm/entrypoints/openai/responses/protocol.py @@ -60,7 +60,7 @@ from vllm.entrypoints.chat_utils import ( ChatCompletionMessageParam, ChatTemplateContentFormatOption, ) -from vllm.entrypoints.generate.base.protocol import StopParam +from vllm.entrypoints.generate.base.protocol import StopParam, validate_cache_salt from vllm.entrypoints.serve.engine.protocol import OpenAIBaseModel from vllm.exceptions import VLLMValidationError from vllm.logger import init_logger @@ -466,6 +466,14 @@ class ResponsesRequest(OpenAIBaseModel): and "message.output_text.logprobs" in self.include ) + @model_validator(mode="before") + @classmethod + def check_cache_salt_support(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + validate_cache_salt(data.get("cache_salt")) + return data + @model_validator(mode="before") @classmethod def validate_background(cls, data): diff --git a/vllm/entrypoints/pooling/base/protocol.py b/vllm/entrypoints/pooling/base/protocol.py index dc9e0b6be8..3de958fc1a 100644 --- a/vllm/entrypoints/pooling/base/protocol.py +++ b/vllm/entrypoints/pooling/base/protocol.py @@ -11,6 +11,7 @@ from vllm.entrypoints.chat_utils import ( ChatCompletionMessageParam, ChatTemplateContentFormatOption, ) +from vllm.entrypoints.generate.base.protocol import validate_cache_salt from vllm.entrypoints.serve.engine.protocol import OpenAIBaseModel from vllm.exceptions import VLLMValidationError from vllm.renderers import ChatParams, TokenizeParams, merge_kwargs @@ -95,6 +96,15 @@ class PoolingBasicRequestMixin(OpenAIBaseModel): ) # --8<-- [end:pooling-common-extra-params] + @model_validator(mode="before") + @classmethod + def check_cache_salt_support(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + + validate_cache_salt(data.get("cache_salt")) + return data + def _build_pooling_tok_params( self, model_config: ModelConfig, diff --git a/vllm/entrypoints/scale_out/token_in_token_out/protocol.py b/vllm/entrypoints/scale_out/token_in_token_out/protocol.py index 3b790d54d3..f279d8b0dc 100644 --- a/vllm/entrypoints/scale_out/token_in_token_out/protocol.py +++ b/vllm/entrypoints/scale_out/token_in_token_out/protocol.py @@ -11,7 +11,7 @@ from pydantic import ( ) from vllm.config import ModelConfig -from vllm.entrypoints.generate.base.protocol import StreamOptions +from vllm.entrypoints.generate.base.protocol import StreamOptions, validate_cache_salt from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionLogProbs, ChatCompletionRequest, @@ -177,6 +177,13 @@ class GenerateRequest(BaseModel): instance._sampling_params_provided_keys = provided return instance + @model_validator(mode="before") + @classmethod + def _validate_cache_salt(cls, data: Any) -> Any: + if isinstance(data, dict): + validate_cache_salt(data.get("cache_salt")) + return data + def is_sampling_param_provided(self, name: str) -> bool: """Whether the caller explicitly set ``sampling_params.``.