[Bugfix] Propagate default stop_token_ids to per-request SamplingParams (#35076)

Signed-off-by: sriganesh123 <arjulasriganesh@gmail.com>

ganesh committed Jun 29, 2026 at 23:10 UTC fca432e60a8a1e1c02b7bb3b4ef4bcb136ddf513
3 files changed +186 -2
tests/entrypoints/openai/test_stop_token_ids.py new
+160
@@ -0,0 +1,160 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +"""
5 +Unit tests for stop_token_ids propagation from default_sampling_params
6 +to SamplingParams in ChatCompletionRequest and CompletionRequest.
7 +
8 +Regression test for https://github.com/vllm-project/vllm/issues/22519
9 +where gpt-oss model stop tokens (e.g., </call> = 200012) were loaded into
10 +default_sampling_params at server startup but silently discarded on every
11 +request because to_sampling_params() never fell back to defaults.
12 +"""
13 +
14 +import pytest
15 +
16 +from vllm.entrypoints.openai.chat_completion.protocol import (
17 + ChatCompletionRequest,
18 +)
19 +from vllm.entrypoints.openai.completion.protocol import (
20 + CompletionRequest,
21 +)
22 +
23 +
24 +class TestChatCompletionStopTokenIds:
25 + """Test stop_token_ids merging in ChatCompletionRequest.to_sampling_params()."""
26 +
27 + @pytest.fixture
28 + def minimal_chat_request(self):
29 + return ChatCompletionRequest(
30 + model="test-model",
31 + messages=[{"role": "user", "content": "hello"}],
32 + )
33 +
34 + def test_default_stop_token_ids_applied(self, minimal_chat_request):
35 + """Server-default stop_token_ids are applied when client sends none."""
36 + default_sampling_params = {
37 + "stop_token_ids": [200012, 200002],
38 + }
39 +
40 + sampling_params = minimal_chat_request.to_sampling_params(
41 + max_tokens=100,
42 + default_sampling_params=default_sampling_params,
43 + )
44 +
45 + assert set(sampling_params.stop_token_ids) == {200012, 200002}
46 +
47 + def test_client_stop_token_ids_merged_with_defaults(self):
48 + """Client-specified stop_token_ids are merged with server defaults."""
49 + request = ChatCompletionRequest(
50 + model="test-model",
51 + messages=[{"role": "user", "content": "hello"}],
52 + stop_token_ids=[99999],
53 + )
54 + default_sampling_params = {
55 + "stop_token_ids": [200012, 200002],
56 + }
57 +
58 + sampling_params = request.to_sampling_params(
59 + max_tokens=100,
60 + default_sampling_params=default_sampling_params,
61 + )
62 +
63 + assert set(sampling_params.stop_token_ids) == {200012, 200002, 99999}
64 + assert sampling_params.stop_token_ids == [99999, 200012, 200002]
65 +
66 + def test_no_stop_token_ids_anywhere(self, minimal_chat_request):
67 + """When neither client nor server specifies stop_token_ids, result is empty."""
68 + sampling_params = minimal_chat_request.to_sampling_params(
69 + max_tokens=100,
70 + default_sampling_params={},
71 + )
72 +
73 + assert not sampling_params.stop_token_ids
74 +
75 + def test_only_client_stop_token_ids(self):
76 + """Client stop_token_ids work when no server defaults exist."""
77 + request = ChatCompletionRequest(
78 + model="test-model",
79 + messages=[{"role": "user", "content": "hello"}],
80 + stop_token_ids=[42, 43],
81 + )
82 +
83 + sampling_params = request.to_sampling_params(
84 + max_tokens=100,
85 + default_sampling_params={},
86 + )
87 +
88 + assert set(sampling_params.stop_token_ids) == {42, 43}
89 +
90 + def test_duplicate_stop_token_ids_deduplicated(self):
91 + """Overlapping stop_token_ids between client and server are deduplicated."""
92 + request = ChatCompletionRequest(
93 + model="test-model",
94 + messages=[{"role": "user", "content": "hello"}],
95 + stop_token_ids=[200012, 55555],
96 + )
97 + default_sampling_params = {
98 + "stop_token_ids": [200012, 200002],
99 + }
100 +
101 + sampling_params = request.to_sampling_params(
102 + max_tokens=100,
103 + default_sampling_params=default_sampling_params,
104 + )
105 +
106 + assert set(sampling_params.stop_token_ids) == {200012, 200002, 55555}
107 + assert sampling_params.stop_token_ids == [200012, 55555, 200002]
108 + assert len(sampling_params.stop_token_ids) == 3
109 +
110 +
111 +class TestCompletionStopTokenIds:
112 + """Test stop_token_ids merging in CompletionRequest.to_sampling_params()."""
113 +
114 + @pytest.fixture
115 + def minimal_completion_request(self):
116 + return CompletionRequest(
117 + model="test-model",
118 + prompt="hello",
119 + )
120 +
121 + def test_default_stop_token_ids_applied(self, minimal_completion_request):
122 + """Server-default stop_token_ids are applied when client sends none."""
123 + default_sampling_params = {
124 + "stop_token_ids": [200012, 200002],
125 + }
126 +
127 + sampling_params = minimal_completion_request.to_sampling_params(
128 + max_tokens=100,
129 + default_sampling_params=default_sampling_params,
130 + )
131 +
132 + assert set(sampling_params.stop_token_ids) == {200012, 200002}
133 +
134 + def test_client_stop_token_ids_merged_with_defaults(self):
135 + """Client-specified stop_token_ids are merged with server defaults."""
136 + request = CompletionRequest(
137 + model="test-model",
138 + prompt="hello",
139 + stop_token_ids=[99999],
140 + )
141 + default_sampling_params = {
142 + "stop_token_ids": [200012, 200002],
143 + }
144 +
145 + sampling_params = request.to_sampling_params(
146 + max_tokens=100,
147 + default_sampling_params=default_sampling_params,
148 + )
149 +
150 + assert set(sampling_params.stop_token_ids) == {200012, 200002, 99999}
151 + assert sampling_params.stop_token_ids == [99999, 200012, 200002]
152 +
153 + def test_no_stop_token_ids_anywhere(self, minimal_completion_request):
154 + """When neither client nor server specifies stop_token_ids, result is empty."""
155 + sampling_params = minimal_completion_request.to_sampling_params(
156 + max_tokens=100,
157 + default_sampling_params={},
158 + )
159 +
160 + assert not sampling_params.stop_token_ids
vllm/entrypoints/openai/chat_completion/protocol.py
+13 -1
@@ -610,6 +610,18 @@ class ChatCompletionRequest(OpenAIBaseModel):
610 "min_p", self._DEFAULT_SAMPLING_PARAMS["min_p"]
611 )
612
613 + # Merge server-default stop_token_ids (e.g., model-specific tokens
614 + # like </call> for gpt-oss) with any request-specified ones
615 + stop_token_ids = self.stop_token_ids
616 + default_stop_ids = default_sampling_params.get("stop_token_ids")
617 + if default_stop_ids:
618 + if not stop_token_ids:
619 + stop_token_ids = list(default_stop_ids)
620 + else:
621 + stop_token_ids = list(
622 + dict.fromkeys([*stop_token_ids, *default_stop_ids])
623 + )
624 +
625 prompt_logprobs = self.prompt_logprobs
626 if prompt_logprobs is None and self.echo:
627 prompt_logprobs = self.top_logprobs
@@ -661,7 +673,7 @@ class ChatCompletionRequest(OpenAIBaseModel):
673 min_p=min_p,
674 seed=self.seed,
675 stop=self.stop,
664 - stop_token_ids=self.stop_token_ids,
676 + stop_token_ids=stop_token_ids,
677 logprobs=self.top_logprobs if self.logprobs else None,
678 prompt_logprobs=prompt_logprobs,
679 ignore_eos=self.ignore_eos,
vllm/entrypoints/openai/completion/protocol.py
+13 -1
@@ -288,6 +288,18 @@ class CompletionRequest(OpenAIBaseModel):
288 "min_p", self._DEFAULT_SAMPLING_PARAMS["min_p"]
289 )
290
291 + # Merge server-default stop_token_ids (e.g., model-specific tokens
292 + # like </call> for gpt-oss) with any request-specified ones
293 + stop_token_ids = self.stop_token_ids
294 + default_stop_ids = default_sampling_params.get("stop_token_ids")
295 + if default_stop_ids:
296 + if not stop_token_ids:
297 + stop_token_ids = list(default_stop_ids)
298 + else:
299 + stop_token_ids = list(
300 + dict.fromkeys([*stop_token_ids, *default_stop_ids])
301 + )
302 +
303 prompt_logprobs = self.prompt_logprobs
304 if prompt_logprobs is None and self.echo:
305 prompt_logprobs = self.logprobs
@@ -341,7 +353,7 @@ class CompletionRequest(OpenAIBaseModel):
353 min_p=min_p,
354 seed=self.seed,
355 stop=self.stop,
344 - stop_token_ids=self.stop_token_ids,
356 + stop_token_ids=stop_token_ids,
357 logprobs=self.logprobs,
358 ignore_eos=self.ignore_eos,
359 max_tokens=max_tokens if not echo_without_generation else 1,