diff --git a/tests/entrypoints/openai/test_stop_token_ids.py b/tests/entrypoints/openai/test_stop_token_ids.py new file mode 100644 index 00000000000..74eba026ed9 --- /dev/null +++ b/tests/entrypoints/openai/test_stop_token_ids.py @@ -0,0 +1,160 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +""" +Unit tests for stop_token_ids propagation from default_sampling_params +to SamplingParams in ChatCompletionRequest and CompletionRequest. + +Regression test for https://github.com/vllm-project/vllm/issues/22519 +where gpt-oss model stop tokens (e.g., = 200012) were loaded into +default_sampling_params at server startup but silently discarded on every +request because to_sampling_params() never fell back to defaults. +""" + +import pytest + +from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionRequest, +) +from vllm.entrypoints.openai.completion.protocol import ( + CompletionRequest, +) + + +class TestChatCompletionStopTokenIds: + """Test stop_token_ids merging in ChatCompletionRequest.to_sampling_params().""" + + @pytest.fixture + def minimal_chat_request(self): + return ChatCompletionRequest( + model="test-model", + messages=[{"role": "user", "content": "hello"}], + ) + + def test_default_stop_token_ids_applied(self, minimal_chat_request): + """Server-default stop_token_ids are applied when client sends none.""" + default_sampling_params = { + "stop_token_ids": [200012, 200002], + } + + sampling_params = minimal_chat_request.to_sampling_params( + max_tokens=100, + default_sampling_params=default_sampling_params, + ) + + assert set(sampling_params.stop_token_ids) == {200012, 200002} + + def test_client_stop_token_ids_merged_with_defaults(self): + """Client-specified stop_token_ids are merged with server defaults.""" + request = ChatCompletionRequest( + model="test-model", + messages=[{"role": "user", "content": "hello"}], + stop_token_ids=[99999], + ) + default_sampling_params = { + "stop_token_ids": [200012, 200002], + } + + sampling_params = request.to_sampling_params( + max_tokens=100, + default_sampling_params=default_sampling_params, + ) + + assert set(sampling_params.stop_token_ids) == {200012, 200002, 99999} + assert sampling_params.stop_token_ids == [99999, 200012, 200002] + + def test_no_stop_token_ids_anywhere(self, minimal_chat_request): + """When neither client nor server specifies stop_token_ids, result is empty.""" + sampling_params = minimal_chat_request.to_sampling_params( + max_tokens=100, + default_sampling_params={}, + ) + + assert not sampling_params.stop_token_ids + + def test_only_client_stop_token_ids(self): + """Client stop_token_ids work when no server defaults exist.""" + request = ChatCompletionRequest( + model="test-model", + messages=[{"role": "user", "content": "hello"}], + stop_token_ids=[42, 43], + ) + + sampling_params = request.to_sampling_params( + max_tokens=100, + default_sampling_params={}, + ) + + assert set(sampling_params.stop_token_ids) == {42, 43} + + def test_duplicate_stop_token_ids_deduplicated(self): + """Overlapping stop_token_ids between client and server are deduplicated.""" + request = ChatCompletionRequest( + model="test-model", + messages=[{"role": "user", "content": "hello"}], + stop_token_ids=[200012, 55555], + ) + default_sampling_params = { + "stop_token_ids": [200012, 200002], + } + + sampling_params = request.to_sampling_params( + max_tokens=100, + default_sampling_params=default_sampling_params, + ) + + assert set(sampling_params.stop_token_ids) == {200012, 200002, 55555} + assert sampling_params.stop_token_ids == [200012, 55555, 200002] + assert len(sampling_params.stop_token_ids) == 3 + + +class TestCompletionStopTokenIds: + """Test stop_token_ids merging in CompletionRequest.to_sampling_params().""" + + @pytest.fixture + def minimal_completion_request(self): + return CompletionRequest( + model="test-model", + prompt="hello", + ) + + def test_default_stop_token_ids_applied(self, minimal_completion_request): + """Server-default stop_token_ids are applied when client sends none.""" + default_sampling_params = { + "stop_token_ids": [200012, 200002], + } + + sampling_params = minimal_completion_request.to_sampling_params( + max_tokens=100, + default_sampling_params=default_sampling_params, + ) + + assert set(sampling_params.stop_token_ids) == {200012, 200002} + + def test_client_stop_token_ids_merged_with_defaults(self): + """Client-specified stop_token_ids are merged with server defaults.""" + request = CompletionRequest( + model="test-model", + prompt="hello", + stop_token_ids=[99999], + ) + default_sampling_params = { + "stop_token_ids": [200012, 200002], + } + + sampling_params = request.to_sampling_params( + max_tokens=100, + default_sampling_params=default_sampling_params, + ) + + assert set(sampling_params.stop_token_ids) == {200012, 200002, 99999} + assert sampling_params.stop_token_ids == [99999, 200012, 200002] + + def test_no_stop_token_ids_anywhere(self, minimal_completion_request): + """When neither client nor server specifies stop_token_ids, result is empty.""" + sampling_params = minimal_completion_request.to_sampling_params( + max_tokens=100, + default_sampling_params={}, + ) + + assert not sampling_params.stop_token_ids diff --git a/vllm/entrypoints/openai/chat_completion/protocol.py b/vllm/entrypoints/openai/chat_completion/protocol.py index 36e467f6f32..5d5014616be 100644 --- a/vllm/entrypoints/openai/chat_completion/protocol.py +++ b/vllm/entrypoints/openai/chat_completion/protocol.py @@ -610,6 +610,18 @@ class ChatCompletionRequest(OpenAIBaseModel): "min_p", self._DEFAULT_SAMPLING_PARAMS["min_p"] ) + # Merge server-default stop_token_ids (e.g., model-specific tokens + # like for gpt-oss) with any request-specified ones + stop_token_ids = self.stop_token_ids + default_stop_ids = default_sampling_params.get("stop_token_ids") + if default_stop_ids: + if not stop_token_ids: + stop_token_ids = list(default_stop_ids) + else: + stop_token_ids = list( + dict.fromkeys([*stop_token_ids, *default_stop_ids]) + ) + prompt_logprobs = self.prompt_logprobs if prompt_logprobs is None and self.echo: prompt_logprobs = self.top_logprobs @@ -661,7 +673,7 @@ class ChatCompletionRequest(OpenAIBaseModel): min_p=min_p, seed=self.seed, stop=self.stop, - stop_token_ids=self.stop_token_ids, + stop_token_ids=stop_token_ids, logprobs=self.top_logprobs if self.logprobs else None, prompt_logprobs=prompt_logprobs, ignore_eos=self.ignore_eos, diff --git a/vllm/entrypoints/openai/completion/protocol.py b/vllm/entrypoints/openai/completion/protocol.py index b5b715b50bd..b96d4f3c0c7 100644 --- a/vllm/entrypoints/openai/completion/protocol.py +++ b/vllm/entrypoints/openai/completion/protocol.py @@ -288,6 +288,18 @@ class CompletionRequest(OpenAIBaseModel): "min_p", self._DEFAULT_SAMPLING_PARAMS["min_p"] ) + # Merge server-default stop_token_ids (e.g., model-specific tokens + # like for gpt-oss) with any request-specified ones + stop_token_ids = self.stop_token_ids + default_stop_ids = default_sampling_params.get("stop_token_ids") + if default_stop_ids: + if not stop_token_ids: + stop_token_ids = list(default_stop_ids) + else: + stop_token_ids = list( + dict.fromkeys([*stop_token_ids, *default_stop_ids]) + ) + prompt_logprobs = self.prompt_logprobs if prompt_logprobs is None and self.echo: prompt_logprobs = self.logprobs @@ -341,7 +353,7 @@ class CompletionRequest(OpenAIBaseModel): min_p=min_p, seed=self.seed, stop=self.stop, - stop_token_ids=self.stop_token_ids, + stop_token_ids=stop_token_ids, logprobs=self.logprobs, ignore_eos=self.ignore_eos, max_tokens=max_tokens if not echo_without_generation else 1,