diff --git a/tests/entrypoints/openai/chat_completion/test_logprob_token_ids_validation.py b/tests/entrypoints/openai/chat_completion/test_logprob_token_ids_validation.py new file mode 100644 index 000000000000..aaf2827e073c --- /dev/null +++ b/tests/entrypoints/openai/chat_completion/test_logprob_token_ids_validation.py @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: Apache-2.0 + +import pytest +from pydantic import ValidationError + +from vllm.entrypoints.openai.chat_completion.protocol import ( + BatchChatCompletionRequest, + ChatCompletionRequest, +) +from vllm.entrypoints.openai.completion.protocol import CompletionRequest +from vllm.exceptions import VLLMValidationError + +MODELS = [ChatCompletionRequest, CompletionRequest, BatchChatCompletionRequest] +BASE = { + ChatCompletionRequest: { + "model": "m", + "messages": [{"role": "user", "content": "hi"}], + }, + CompletionRequest: {"model": "m", "prompt": "hi"}, + BatchChatCompletionRequest: { + "messages": [[{"role": "user", "content": "hi"}]], + }, +} + + +@pytest.mark.parametrize("cls", MODELS) +@pytest.mark.parametrize("logprobs", [None, False, True]) +def test_rejects_empty_token_ids(cls, logprobs): + data = {**BASE[cls], "logprob_token_ids": []} + if logprobs is not None: + data["logprobs"] = logprobs + with pytest.raises(VLLMValidationError, match="must not be an empty list") as exc: + cls.model_validate(data) + assert exc.value.parameter == "logprob_token_ids" + + +@pytest.mark.parametrize("cls", MODELS) +def test_rejects_token_ids_without_logprobs(cls): + with pytest.raises( + VLLMValidationError, match="when using `logprob_token_ids`" + ) as exc: + cls.model_validate({**BASE[cls], "logprob_token_ids": [100]}) + assert exc.value.parameter == "logprob_token_ids" + + +@pytest.mark.parametrize("cls", MODELS) +@pytest.mark.parametrize("val", [123, "123"]) +def test_defers_non_list_to_pydantic(cls, val): + with pytest.raises(ValidationError): + cls.model_validate({**BASE[cls], "logprob_token_ids": val}) diff --git a/vllm/entrypoints/openai/chat_completion/protocol.py b/vllm/entrypoints/openai/chat_completion/protocol.py index 62a347ec42fb..744405ae273b 100644 --- a/vllm/entrypoints/openai/chat_completion/protocol.py +++ b/vllm/entrypoints/openai/chat_completion/protocol.py @@ -299,6 +299,7 @@ class ChatCompletionRequest(OpenAIBaseModel): prompt_logprobs: int | None = None logprob_token_ids: list[int] | None = Field( default=None, + min_length=1, description=( "Specific vocab token IDs to return logprobs for at each generated " "position, in addition to the sampled token. More efficient than " @@ -778,7 +779,7 @@ def to_sampling_params( else None ), prompt_logprobs=prompt_logprobs, - logprob_token_ids=self.logprob_token_ids or None, + logprob_token_ids=self.logprob_token_ids, ignore_eos=self.ignore_eos, max_tokens=max_tokens, min_tokens=self.min_tokens, @@ -851,17 +852,24 @@ def validate_stream_options(cls, data): def check_logprobs(cls, data): if not isinstance(data, dict): return data - if data.get("logprob_token_ids") and data.get("use_beam_search"): - raise VLLMValidationError( - "`logprob_token_ids` is not supported with beam search.", - parameter="logprob_token_ids", - ) - - if data.get("logprob_token_ids") and not data.get("logprobs"): - raise VLLMValidationError( - "when using `logprob_token_ids`, `logprobs` must be set to true.", - parameter="logprob_token_ids", - ) + if data.get("logprob_token_ids") is not None: + if not isinstance(data["logprob_token_ids"], list): + return data + if not data["logprob_token_ids"]: + raise VLLMValidationError( + "`logprob_token_ids` must not be an empty list.", + parameter="logprob_token_ids", + ) + if data.get("use_beam_search"): + raise VLLMValidationError( + "`logprob_token_ids` is not supported with beam search.", + parameter="logprob_token_ids", + ) + if not data.get("logprobs"): + raise VLLMValidationError( + "when using `logprob_token_ids`, `logprobs` must be set to true.", + parameter="logprob_token_ids", + ) # These fields are integers, but `mode="before"` runs on the raw # request data, so a non-numeric value (e.g. a JSON string) would @@ -1143,6 +1151,7 @@ class BatchChatCompletionRequest(OpenAIBaseModel): top_logprobs: TopLogprobsParam = 0 logprob_token_ids: list[int] | None = Field( default=None, + min_length=1, description=( "Specific vocab token IDs to return logprobs for at each generated " "position, in addition to the sampled token. Requires " @@ -1202,11 +1211,19 @@ def check_batch_mode(cls, data: Any) -> Any: "Please set `use_beam_search` to False.", parameter="use_beam_search", ) - if data.get("logprob_token_ids") and not data.get("logprobs"): - raise VLLMValidationError( - "when using `logprob_token_ids`, `logprobs` must be set to true.", - parameter="logprob_token_ids", - ) + if data.get("logprob_token_ids") is not None: + if not isinstance(data["logprob_token_ids"], list): + return data + if not data["logprob_token_ids"]: + raise VLLMValidationError( + "`logprob_token_ids` must not be an empty list.", + parameter="logprob_token_ids", + ) + if not data.get("logprobs"): + raise VLLMValidationError( + "when using `logprob_token_ids`, `logprobs` must be set to true.", + parameter="logprob_token_ids", + ) kv_transfer_params = data.get("kv_transfer_params") if ( isinstance(kv_transfer_params, dict) diff --git a/vllm/entrypoints/openai/completion/protocol.py b/vllm/entrypoints/openai/completion/protocol.py index 5a72d0e60528..52f39b848d50 100644 --- a/vllm/entrypoints/openai/completion/protocol.py +++ b/vllm/entrypoints/openai/completion/protocol.py @@ -99,6 +99,7 @@ class CompletionRequest(OpenAIBaseModel): prompt_logprobs: int | None = None logprob_token_ids: list[int] | None = Field( default=None, + min_length=1, description=( "Specific vocab token IDs to return logprobs for at each generated " "position, in addition to the sampled token. More efficient than " @@ -406,7 +407,7 @@ def to_sampling_params( max_tokens=max_tokens if not echo_without_generation else 1, min_tokens=self.min_tokens, prompt_logprobs=prompt_logprobs, - logprob_token_ids=self.logprob_token_ids or None, + logprob_token_ids=self.logprob_token_ids, skip_special_tokens=self.skip_special_tokens, spaces_between_special_tokens=self.spaces_between_special_tokens, include_stop_str_in_output=self.include_stop_str_in_output, @@ -514,11 +515,30 @@ def check_structured_outputs_count(cls, data): def check_logprobs(cls, data): if not isinstance(data, dict): return data - if data.get("logprob_token_ids") and data.get("use_beam_search"): - raise VLLMValidationError( - "`logprob_token_ids` is not supported with beam search.", - parameter="logprob_token_ids", - ) + if data.get("logprob_token_ids") is not None: + if not isinstance(data["logprob_token_ids"], list): + return data + if not data["logprob_token_ids"]: + raise VLLMValidationError( + "`logprob_token_ids` must not be an empty list.", + parameter="logprob_token_ids", + ) + if data.get("use_beam_search"): + raise VLLMValidationError( + "`logprob_token_ids` is not supported with beam search.", + parameter="logprob_token_ids", + ) + if data.get("echo") and data.get("max_tokens") == 0: + raise VLLMValidationError( + "`logprob_token_ids` is not supported when `echo=True` and " + "`max_tokens=0` because no output tokens are generated.", + parameter="logprob_token_ids", + ) + if data.get("logprobs") is None: + raise VLLMValidationError( + "when using `logprob_token_ids`, `logprobs` must be set.", + parameter="logprob_token_ids", + ) if ( data.get("use_beam_search") @@ -531,23 +551,6 @@ def check_logprobs(cls, data): parameter="echo", ) - if ( - data.get("logprob_token_ids") - and data.get("echo") - and data.get("max_tokens") == 0 - ): - raise VLLMValidationError( - "`logprob_token_ids` is not supported when `echo=True` and " - "`max_tokens=0` because no output tokens are generated.", - parameter="logprob_token_ids", - ) - - if data.get("logprob_token_ids") and data.get("logprobs") is None: - raise VLLMValidationError( - "when using `logprob_token_ids`, `logprobs` must be set.", - parameter="logprob_token_ids", - ) - # These fields are integers, but `mode="before"` runs on the raw # request data, so a non-numeric value (e.g. a JSON string) would # reach the comparisons below and raise TypeError -> HTTP 500. Reject