diff --git a/tensorrt_llm/serve/openai_protocol.py b/tensorrt_llm/serve/openai_protocol.py index 7c2ab0812bf2..4650cdb71892 100644 --- a/tensorrt_llm/serve/openai_protocol.py +++ b/tensorrt_llm/serve/openai_protocol.py @@ -654,17 +654,30 @@ class CompletionRequest(OpenAIBaseModel): stream: Optional[bool] = False stream_options: Optional[StreamOptions] = None suffix: Optional[str] = None - temperature: Optional[float] = None - top_p: Optional[float] = None + temperature: Optional[float] = Field( + default=None, ge=0.0, description="Sampling temperature; must be >= 0.") + top_p: Optional[float] = Field( + default=None, + ge=0.0, + le=1.0, + description="Nucleus sampling probability; must be in [0, 1].") user: Optional[str] = None lora_request: Optional[LoRARequest] = None prompt_ignore_length: Optional[int] = 0 # doc: begin-completion-sampling-params use_beam_search: bool = False - top_k: int = 0 + top_k: int = Field( + default=0, + ge=0, + description="Top-k sampling; must be >= 0 (0 disables top-k).") top_p_min: float = 0.0 - min_p: float = 0.0 + min_p: float = Field( + default=0.0, + ge=0.0, + le=1.0, + description="Minimum token probability scaled by the top token's " + "probability; must be in [0, 1] (0 disables min-p).") repetition_penalty: float = 1.0 # Unset by default so the engine picks its own beam-search defaults, as it # does for requests coming through the Python API. Sending concrete values @@ -1092,8 +1105,13 @@ class ChatCompletionRequest(OpenAIBaseModel): stop: Optional[Union[str, List[str]]] = Field(default_factory=list) stream: Optional[bool] = False stream_options: Optional[StreamOptions] = None - temperature: Optional[float] = None - top_p: Optional[float] = None + temperature: Optional[float] = Field( + default=None, ge=0.0, description="Sampling temperature; must be >= 0.") + top_p: Optional[float] = Field( + default=None, + ge=0.0, + le=1.0, + description="Nucleus sampling probability; must be in [0, 1].") tools: Optional[List[ChatCompletionToolsParam]] = None tool_choice: Optional[Union[Literal["none", "auto", "required"], ChatCompletionNamedToolChoiceParam]] = "none" @@ -1121,9 +1139,17 @@ class ChatCompletionRequest(OpenAIBaseModel): # doc: begin-chat-completion-sampling-params best_of: Optional[int] = None use_beam_search: bool = False - top_k: int = 0 + top_k: int = Field( + default=0, + ge=0, + description="Top-k sampling; must be >= 0 (0 disables top-k).") top_p_min: float = 0.0 - min_p: float = 0.0 + min_p: float = Field( + default=0.0, + ge=0.0, + le=1.0, + description="Minimum token probability scaled by the top token's " + "probability; must be in [0, 1] (0 disables min-p).") repetition_penalty: float = 1.0 # Unset by default so the engine picks its own beam-search defaults, as it # does for requests coming through the Python API. Sending concrete values @@ -1623,12 +1649,17 @@ def _materialize_lazy_item_content(cls, value): "priority"] = "auto" store: Optional[bool] = True stream: Optional[bool] = False - temperature: Optional[float] = None + temperature: Optional[float] = Field( + default=None, ge=0.0, description="Sampling temperature; must be >= 0.") text: Optional[ResponseTextConfig] = None tool_choice: ToolChoice = "auto" tools: list[Tool] = Field(default_factory=list) top_logprobs: Optional[int] = 0 - top_p: Optional[float] = None + top_p: Optional[float] = Field( + default=None, + ge=0.0, + le=1.0, + description="Nucleus sampling probability; must be in [0, 1].") truncation: Optional[Literal["auto", "disabled"]] = "disabled" user: Optional[str] = None diff --git a/tests/integration/test_lists/test-db/l0_cpu.yml b/tests/integration/test_lists/test-db/l0_cpu.yml index a42393e3d02a..d4af717212d1 100644 --- a/tests/integration/test_lists/test-db/l0_cpu.yml +++ b/tests/integration/test_lists/test-db/l0_cpu.yml @@ -190,6 +190,7 @@ l0_cpu: - unittest/llmapi/apps/test_responses_usage.py - unittest/llmapi/apps/test_serving_extensions.py - unittest/llmapi/apps/test_tool_parsers.py + - unittest/llmapi/apps/test_openai_protocol_validation.py - unittest/llmapi/apps/test_web_search_shared.py - unittest/llmapi/test_bench_async.py - unittest/llmapi/test_config_overrides.py diff --git a/tests/unittest/llmapi/apps/test_openai_protocol_validation.py b/tests/unittest/llmapi/apps/test_openai_protocol_validation.py new file mode 100644 index 000000000000..146292f58914 --- /dev/null +++ b/tests/unittest/llmapi/apps/test_openai_protocol_validation.py @@ -0,0 +1,101 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Boundary validation for trtllm-serve request models (issue #6329). + +Invalid sampling parameters are rejected at the pydantic boundary with a +field-specific ValidationError (surfaced to clients as HTTP 400 by the +server's RequestValidationError handler). SamplingParams._validate already +rejects these values later with a 400; the benefit here is the earlier, +field-named error and schema-visible bounds, not a crash fix. +""" + +import pytest +from pydantic import ValidationError + +from tensorrt_llm.serve.openai_protocol import ( + ChatCompletionRequest, + CompletionRequest, + ResponsesRequest, +) + +# The CPU CI stage runs pytest with -m cpu_only and skips files without this marker. +pytestmark = pytest.mark.cpu_only + + +def _completion(**kw): + return CompletionRequest(model="m", prompt="hi", **kw) + + +def _chat(**kw): + return ChatCompletionRequest(model="m", messages=[{"role": "user", "content": "hi"}], **kw) + + +def _responses(**kw): + return ResponsesRequest(model="m", input="hi", **kw) + + +def _rejected_field(build, **kw): + """Return the set of top-level field names that failed validation.""" + with pytest.raises(ValidationError) as exc_info: + build(**kw) + return {err["loc"][0] for err in exc_info.value.errors()} + + +# top_k only exists on completion / chat requests. +BUILDERS_WITH_TOP_K = [_completion, _chat] +ALL_BUILDERS = [_completion, _chat, _responses] + + +@pytest.mark.parametrize("build", ALL_BUILDERS) +def test_valid_sampling_params_accepted(build): + # Boundary-valid values must pass unchanged. + req = build(temperature=0.0, top_p=1.0) + assert req.temperature == 0.0 + assert req.top_p == 1.0 + + +@pytest.mark.parametrize("build", ALL_BUILDERS) +def test_negative_temperature_rejected(build): + assert "temperature" in _rejected_field(build, temperature=-0.1) + + +@pytest.mark.parametrize("build", ALL_BUILDERS) +@pytest.mark.parametrize("bad_top_p", [-0.1, 1.1, 2.0]) +def test_out_of_range_top_p_rejected(build, bad_top_p): + assert "top_p" in _rejected_field(build, top_p=bad_top_p) + + +@pytest.mark.parametrize("build", BUILDERS_WITH_TOP_K) +@pytest.mark.parametrize("bad_min_p", [-0.1, 1.1]) +def test_out_of_range_min_p_rejected(build, bad_min_p): + assert "min_p" in _rejected_field(build, min_p=bad_min_p) + + +@pytest.mark.parametrize("build", BUILDERS_WITH_TOP_K) +def test_negative_top_k_rejected(build): + # top_k = -1 is vLLM's default; TRT-LLM's backend requires top_k >= 0, so + # reject it cleanly at the boundary rather than crashing. + assert "top_k" in _rejected_field(build, top_k=-1) + + +@pytest.mark.parametrize("build", BUILDERS_WITH_TOP_K) +@pytest.mark.parametrize("edge_min_p", [0.0, 1.0]) +def test_min_p_boundaries_accepted(build, edge_min_p): + assert build(min_p=edge_min_p).min_p == edge_min_p + + +@pytest.mark.parametrize("build", BUILDERS_WITH_TOP_K) +def test_top_k_zero_accepted(build): + assert build(top_k=0).top_k == 0