[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,