A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 14, 05:30 UTC
[CI] Add end-to-end V1 min_tokens test coverage (#22495)
Signed-off-by: Arjun Reddy <189282188+arjunbreddy22@users.noreply.github.com> Co-authored-by: Arjun Reddy <189282188+arjunbreddy22@users.noreply.github.com>
Arjun Reddy committed
Aug 21, 2025 at 23:04 UTC
111692bb8ca3921941c5351a62a56f4310f7723b
1 file changed
+479
tests/v1/e2e/test_min_tokens.py
new
+479
@@ -0,0 +1,479 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""
4
+Comprehensive end-to-end tests for `min_tokens` in the V1 engine.
5
+
6
+Addresses #21950: verify and add CI coverage.
7
+
8
+Covers:
9
+1) Basic functionality
10
+2) Stop strings with `min_tokens` (bug #21987; fix in PR #22014)
11
+3) EOS behavior with `min_tokens` (potential logits-processor bug)
12
+4) Edge cases (min_tokens == max_tokens, min_tokens == 0)
13
+5) Multiple stop conditions
14
+"""
15
+
16
+import os
17
+from typing import Optional, Union
18
+
19
+import pytest
20
+
21
+from vllm import LLM, SamplingParams
22
+from vllm.outputs import RequestOutput
23
+
24
+# Test configuration
25
+TEST_MODEL = "facebook/opt-125m" # Small model for fast CI execution
26
+GREEDY = 0.0 # Deterministic generation for consistent testing
27
+
28
+
29
+class MinTokensTestCase:
30
+ """Data class for min_tokens test scenarios"""
31
+
32
+ def __init__(
33
+ self,
34
+ name: str,
35
+ min_tokens: int,
36
+ max_tokens: int,
37
+ stop: Optional[Union[str, list[str]]] = None,
38
+ expected_min_len: Optional[int] = None,
39
+ expected_exact_len: Optional[int] = None,
40
+ ):
41
+ self.name = name
42
+ self.min_tokens = min_tokens
43
+ self.max_tokens = max_tokens
44
+ self.stop = stop
45
+ self.expected_min_len = expected_min_len or min_tokens
46
+ self.expected_exact_len = expected_exact_len
47
+
48
+ def __str__(self):
49
+ return (f"{self.name}: min={self.min_tokens}, "
50
+ f"max={self.max_tokens}, stop={self.stop}")
51
+
52
+
53
+# Test scenarios covering all critical cases
54
+MIN_TOKENS_TEST_CASES = [
55
+ # === BASIC FUNCTIONALITY (should work) ===
56
+ MinTokensTestCase(name="basic_min_tokens_no_stop",
57
+ min_tokens=8,
58
+ max_tokens=20,
59
+ stop=None,
60
+ expected_min_len=8),
61
+ MinTokensTestCase(name="min_tokens_zero",
62
+ min_tokens=0,
63
+ max_tokens=10,
64
+ stop=None,
65
+ expected_min_len=0),
66
+ MinTokensTestCase(name="min_equals_max_no_stop",
67
+ min_tokens=15,
68
+ max_tokens=15,
69
+ stop=None,
70
+ expected_exact_len=15),
71
+
72
+ # === STOP STRINGS WITH MIN_TOKENS ===
73
+ # These tests expose the detokenizer bug where stop strings
74
+ # bypass min_tokens
75
+ # Using mathematically guaranteed approach with wide stop nets
76
+ pytest.param(
77
+ MinTokensTestCase(
78
+ name="min_tokens_with_comprehensive_stops",
79
+ min_tokens=5,
80
+ max_tokens=20,
81
+ stop=[
82
+ "a",
83
+ "e",
84
+ "i",
85
+ "o",
86
+ "u",
87
+ "t",
88
+ "n",
89
+ "s",
90
+ "r",
91
+ "l",
92
+ " ",
93
+ ],
94
+ expected_min_len=5,
95
+ ),
96
+ marks=pytest.mark.xfail(
97
+ reason=("Known bug #21987: stop strings bypass min_tokens "
98
+ "(fixed by PR #22014)"),
99
+ strict=False),
100
+ id="min_tokens_with_comprehensive_stops",
101
+ ),
102
+ pytest.param(
103
+ MinTokensTestCase(
104
+ name="min_tokens_with_simple_char_stop",
105
+ min_tokens=3,
106
+ max_tokens=15,
107
+ stop=["e", "a", " "],
108
+ expected_min_len=3,
109
+ ),
110
+ marks=pytest.mark.xfail(
111
+ reason=("Known bug #21987: stop strings bypass min_tokens "
112
+ "(fixed by PR #22014)"),
113
+ strict=False),
114
+ id="min_tokens_with_simple_char_stop",
115
+ ),
116
+
117
+ # === EOS TOKEN WITH MIN_TOKENS (potential LogitsProcessor bug) ===
118
+ # These test the MinTokensLogitsProcessor handling of EOS tokens
119
+ pytest.param(
120
+ MinTokensTestCase(
121
+ name="min_equals_max_eos_only",
122
+ min_tokens=20,
123
+ max_tokens=20,
124
+ stop=None, # Relies on default EOS token behavior
125
+ expected_exact_len=20,
126
+ ),
127
+ marks=pytest.mark.xfail(
128
+ reason=
129
+ ("Potential logits-processor bug: EOS tokens may bypass min_tokens"
130
+ ),
131
+ strict=False,
132
+ ),
133
+ id="min_equals_max_eos_only",
134
+ ),
135
+
136
+ # === EDGE CASES ===
137
+ MinTokensTestCase(name="large_min_tokens",
138
+ min_tokens=50,
139
+ max_tokens=60,
140
+ stop=None,
141
+ expected_min_len=50),
142
+ MinTokensTestCase(
143
+ name="min_tokens_with_empty_stop_list",
144
+ min_tokens=5,
145
+ max_tokens=15,
146
+ stop=[], # Empty stop list
147
+ expected_min_len=5),
148
+]
149
+
150
+
151
+@pytest.fixture(scope="module")
152
+def llm_v1():
153
+ """Create V1 LLM instance for testing"""
154
+ # Ensure V1 engine is used
155
+ os.environ["VLLM_USE_V1"] = "1"
156
+
157
+ llm = LLM(
158
+ model=TEST_MODEL,
159
+ tensor_parallel_size=1,
160
+ max_model_len=1024, # Small context for fast testing
161
+ enforce_eager=True, # Avoid graph compilation overhead
162
+ )
163
+ return llm
164
+
165
+
166
+def get_token_count(output: RequestOutput) -> int:
167
+ """Extract token count from LLM output"""
168
+ if not output.outputs:
169
+ return 0
170
+ return len(output.outputs[0].token_ids)
171
+
172
+
173
+def assert_min_tokens_satisfied(output: RequestOutput,
174
+ test_case: MinTokensTestCase) -> None:
175
+ """Assert that min_tokens requirement is satisfied"""
176
+ token_count = get_token_count(output)
177
+ stop_reason = (output.outputs[0].stop_reason
178
+ if output.outputs else "no output")
179
+
180
+ if test_case.expected_exact_len is not None:
181
+ # Exact length requirement
182
+ assert token_count == test_case.expected_exact_len, (
183
+ f"Expected exactly {test_case.expected_exact_len} tokens, "
184
+ f"got {token_count} tokens. "
185
+ f"Stop reason: {stop_reason}")
186
+ else:
187
+ # Minimum length requirement
188
+ assert token_count >= (test_case.expected_min_len or 0), (
189
+ f"Expected at least {test_case.expected_min_len} tokens, "
190
+ f"got {token_count} tokens. "
191
+ f"Stop reason: {stop_reason}")
192
+
193
+
194
+@pytest.mark.parametrize(
195
+ "test_case",
196
+ MIN_TOKENS_TEST_CASES,
197
+ ids=lambda tc: tc.name,
198
+)
199
+def test_min_tokens_comprehensive(llm_v1: LLM, test_case: MinTokensTestCase):
200
+ """
201
+ Comprehensive test for min_tokens functionality in V1 engine.
202
+
203
+ This test covers all critical scenarios for min_tokens:
204
+ - Basic functionality (should work)
205
+ - Stop strings with min_tokens (known bug)
206
+ - EOS tokens with min_tokens (potential bug)
207
+ - Edge cases
208
+
209
+ Args:
210
+ llm_v1: V1 LLM instance
211
+ test_case: Test scenario parameters
212
+ """
213
+ # Known failing cases are handled via param-level xfail marks above.
214
+
215
+ # Create sampling parameters
216
+ sampling_params = SamplingParams(
217
+ min_tokens=test_case.min_tokens,
218
+ max_tokens=test_case.max_tokens,
219
+ stop=test_case.stop,
220
+ temperature=GREEDY,
221
+ include_stop_str_in_output=True # Include stop strings for debugging
222
+ )
223
+
224
+ # Use simple prompt. Comprehensive stop lists should catch any generation
225
+ prompt = "Hello"
226
+
227
+ # Generate output
228
+ outputs = llm_v1.generate([prompt], sampling_params)
229
+
230
+ assert len(outputs) == 1, "Expected exactly one output"
231
+ output = outputs[0]
232
+
233
+ # Debug information
234
+ token_count = get_token_count(output)
235
+ generated_text = output.outputs[0].text if output.outputs else ""
236
+ stop_reason = output.outputs[0].stop_reason if output.outputs else "unknown"
237
+
238
+ print(f"\nTest: {test_case.name}")
239
+ print(f"Generated {token_count} tokens")
240
+ print(f"Stop reason: {stop_reason}")
241
+ print(f"Generated text: {repr(generated_text)}")
242
+ print(f"Expected min: {test_case.expected_min_len}")
243
+ if test_case.expected_exact_len:
244
+ print(f"Expected exact: {test_case.expected_exact_len}")
245
+
246
+ # Validate min_tokens requirement
247
+ assert_min_tokens_satisfied(output, test_case)
248
+
249
+
250
+def test_min_tokens_basic_functionality(llm_v1: LLM):
251
+ """
252
+ Test basic min_tokens functionality without stop conditions.
253
+
254
+ This is a baseline test that should always pass and validates
255
+ that min_tokens works correctly in the simple case.
256
+ """
257
+ sampling_params = SamplingParams(min_tokens=10,
258
+ max_tokens=20,
259
+ temperature=GREEDY)
260
+
261
+ prompt = "Once upon a time"
262
+ outputs = llm_v1.generate([prompt], sampling_params)
263
+
264
+ assert len(outputs) == 1
265
+ token_count = get_token_count(outputs[0])
266
+
267
+ assert token_count >= 10, f"Expected at least 10 tokens, got {token_count}"
268
+ assert token_count <= 20, f"Expected at most 20 tokens, got {token_count}"
269
+
270
+
271
+@pytest.mark.xfail(
272
+ reason=("Known bug #21987: stop strings bypass min_tokens "
273
+ "(fixed by PR #22014)"),
274
+ strict=False,
275
+)
276
+def test_min_tokens_stop_strings_bug(llm_v1: LLM):
277
+ """
278
+ Test the specific bug where stop strings bypass min_tokens.
279
+
280
+ This test specifically reproduces the bug Calvin is fixing in PR #22014.
281
+ It should fail until that fix is merged.
282
+
283
+ Strategy: Use guaranteed stop characters that will appear
284
+ in any generated text.
285
+ """
286
+ # If the bug is fixed upstream, this test will XPASS
287
+
288
+ sampling_params = SamplingParams(
289
+ min_tokens=15,
290
+ max_tokens=50,
291
+ # Common letter; likely appears early
292
+ stop=["e"],
293
+ temperature=GREEDY,
294
+ include_stop_str_in_output=True)
295
+
296
+ # Simple prompt that will generate text containing "e"
297
+ prompt = "The quick brown fox"
298
+ outputs = llm_v1.generate([prompt], sampling_params)
299
+
300
+ assert len(outputs) == 1
301
+ token_count = get_token_count(outputs[0])
302
+ generated_text = outputs[0].outputs[0].text if outputs[0].outputs else ""
303
+
304
+ # Debug info to understand what happened
305
+ print(f"Generated text: {repr(generated_text)}")
306
+ print(f"Token count: {token_count}")
307
+ print(f"Contains 'e': {'e' in generated_text}")
308
+
309
+ # This assertion should fail due to the bug - if stop string is found early,
310
+ # the model should still continue generating until min_tokens is reached
311
+ stop_reason = (outputs[0].outputs[0].stop_reason
312
+ if outputs[0].outputs else "no output")
313
+ assert token_count >= 15, ("Bug confirmed: "
314
+ f"{token_count} tokens < min_tokens=15. "
315
+ f"Reason: {stop_reason}. "
316
+ f"Text: {repr(generated_text)}")
317
+
318
+
319
+@pytest.mark.xfail(
320
+ reason=("Known bug #21987: stop strings bypass min_tokens "
321
+ "(fixed by PR #22014)"),
322
+ strict=False,
323
+)
324
+def test_min_tokens_stop_strings_guaranteed_early_trigger(llm_v1: LLM):
325
+ """
326
+ Guaranteed test for stop strings bypassing min_tokens bug.
327
+
328
+ Strategy: Use very low temperature and multiple common stop strings
329
+ to virtually guarantee early detection, combined with long min_tokens
330
+ to ensure the bug is exposed regardless of model behavior.
331
+ """
332
+ # If the bug is fixed upstream, this test will XPASS
333
+
334
+ sampling_params = SamplingParams(
335
+ min_tokens=50, # Set high min_tokens to ensure bug detection
336
+ max_tokens=200,
337
+ # Use multiple very common patterns - at least one will appear
338
+ stop=["e", "a", "i", "o", "u", " ", "t", "n", "s", "r"],
339
+ temperature=GREEDY,
340
+ include_stop_str_in_output=True)
341
+
342
+ # Simple prompt that will generate some text
343
+ prompt = "The cat"
344
+ outputs = llm_v1.generate([prompt], sampling_params)
345
+
346
+ assert len(outputs) == 1
347
+ token_count = get_token_count(outputs[0])
348
+ generated_text = outputs[0].outputs[0].text if outputs[0].outputs else ""
349
+ stop_reason = (outputs[0].outputs[0].stop_reason
350
+ if outputs[0].outputs else "unknown")
351
+
352
+ print(f"Generated text: {repr(generated_text)}")
353
+ print(f"Token count: {token_count}")
354
+ print(f"Stop reason: {stop_reason}")
355
+
356
+ # With the bug, this will fail because ANY of the common characters
357
+ # will trigger early termination before min_tokens=50 is reached
358
+ # It's virtually impossible to generate 50 tokens without hitting
359
+ # at least one of: e, a, i, o, u, space, t, n, s, r
360
+ finish_reason = (outputs[0].outputs[0].finish_reason
361
+ if outputs[0].outputs else "unknown")
362
+
363
+ print(f"Finish reason: {finish_reason}")
364
+
365
+ if finish_reason == "stop":
366
+ assert token_count >= 50, ("Bug confirmed: "
367
+ f"{token_count} tokens < min_tokens=50. "
368
+ f"Reason: {finish_reason}. "
369
+ f"Text: {repr(generated_text)}")
370
+
371
+
372
+@pytest.mark.xfail(
373
+ reason=(
374
+ "Potential logits-processor bug: EOS tokens may bypass min_tokens"),
375
+ strict=False,
376
+)
377
+def test_min_tokens_eos_behavior(llm_v1: LLM):
378
+ """
379
+ Verify EOS handling with and without min_tokens.
380
+
381
+ - Without min_tokens: expect early EOS -> finish_reason == "stop",
382
+ stop_reason is None, and generated tokens < max_tokens (25).
383
+ - With min_tokens: EOS should be blocked until min_tokens is reached
384
+ (finish_reason == "length"); verify that eos_token_id does not appear
385
+ in generated token_ids.
386
+ """
387
+ # tokenizer + eos id
388
+ tokenizer = llm_v1.get_tokenizer()
389
+ eos_token_id = tokenizer.eos_token_id
390
+
391
+ prompt = "Give a file extension."
392
+ max_toks = 32
393
+
394
+ # Case 1: WITHOUT min_tokens
395
+ sp_no_min = SamplingParams(
396
+ max_tokens=max_toks,
397
+ temperature=GREEDY,
398
+ )
399
+ out_no_min = llm_v1.generate([prompt], sp_no_min)
400
+ assert len(out_no_min) == 1
401
+ choice_no_min = out_no_min[0].outputs[0]
402
+
403
+ ids_no_min = choice_no_min.token_ids or []
404
+ finish_no_min = choice_no_min.finish_reason
405
+ stop_no_min = choice_no_min.stop_reason
406
+
407
+ print("[no-min] tokens=", len(ids_no_min), " finish=", finish_no_min,
408
+ " stop_reason=", stop_no_min)
409
+
410
+ assert finish_no_min == "stop", (
411
+ f"Expected finish_reason 'stop' without min_tokens, got {finish_no_min}"
412
+ )
413
+ assert stop_no_min is None, (
414
+ "For EOS-based stop (no user stop strings), stop_reason should be None."
415
+ )
416
+ assert len(ids_no_min) < max_toks, (
417
+ f"Expected early EOS with < {max_toks} tokens, got {len(ids_no_min)}")
418
+
419
+ # Case 2: WITH min_tokens
420
+ sp_with_min = SamplingParams(
421
+ min_tokens=max_toks,
422
+ max_tokens=max_toks,
423
+ temperature=GREEDY,
424
+ )
425
+ out_with_min = llm_v1.generate([prompt], sp_with_min)
426
+ assert len(out_with_min) == 1
427
+ choice_with_min = out_with_min[0].outputs[0]
428
+
429
+ ids_with_min = choice_with_min.token_ids or []
430
+ finish_with_min = choice_with_min.finish_reason
431
+ stop_with_min = choice_with_min.stop_reason
432
+
433
+ print("[with-min] tokens=", len(ids_with_min), " finish=", finish_with_min,
434
+ " stop_reason=", stop_with_min)
435
+
436
+ # Exact length reached; EOS should have been blocked
437
+ assert len(ids_with_min) == max_toks, (
438
+ f"Expected exactly {max_toks} tokens with min_tokens; "
439
+ f"got {len(ids_with_min)}")
440
+ assert finish_with_min == "length", (
441
+ f"Expected finish_reason 'length'; got {finish_with_min}")
442
+ assert eos_token_id not in ids_with_min, (
443
+ "EOS token id should not appear when min_tokens prevents early EOS.")
444
+
445
+
446
+def test_min_tokens_validation():
447
+ """
448
+ Test that SamplingParams correctly validates min_tokens parameters.
449
+
450
+ This tests the parameter validation logic in SamplingParams.
451
+ """
452
+ # Valid cases
453
+ SamplingParams(min_tokens=0, max_tokens=10)
454
+ SamplingParams(min_tokens=5, max_tokens=10)
455
+ SamplingParams(min_tokens=10, max_tokens=10)
456
+
457
+ # Invalid cases
458
+ with pytest.raises(
459
+ ValueError,
460
+ match="min_tokens must be greater than or equal to 0",
461
+ ):
462
+ SamplingParams(min_tokens=-1, max_tokens=10)
463
+
464
+ with pytest.raises(
465
+ ValueError,
466
+ match="min_tokens must be less than or equal to max_tokens",
467
+ ):
468
+ SamplingParams(min_tokens=15, max_tokens=10)
469
+
470
+
471
+if __name__ == "__main__":
472
+ """
473
+ Run tests locally for development.
474
+
475
+ Usage:
476
+ cd vllm/
477
+ VLLM_USE_V1=1 python -m pytest tests/v1/e2e/test_min_tokens.py -v
478
+ """
479
+ pytest.main([__file__, "-v"])