main
py 935 lines 29.2 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 from dataclasses import dataclass, field
5 from typing import Any
6 from unittest.mock import AsyncMock, MagicMock
7
8 import pytest
9
10 from vllm.config.multimodal import MultiModalConfig
11 from vllm.entrypoints.generate.base.protocol import RequestResponseMetadata
12 from vllm.entrypoints.openai.completion.protocol import CompletionRequest
13 from vllm.entrypoints.openai.completion.serving import OpenAIServingCompletion
14 from vllm.entrypoints.openai.models.protocol import BaseModelPath
15 from vllm.entrypoints.openai.models.serving import OpenAIServingModels
16 from vllm.entrypoints.scale_out.render.serving import ServingRender
17 from vllm.entrypoints.serve.engine.protocol import ErrorResponse
18 from vllm.exceptions import GenerationError, VLLMValidationError
19 from vllm.outputs import CompletionOutput, RequestOutput
20 from vllm.renderers.deepseek_v4 import DeepseekV4Renderer
21 from vllm.renderers.hf import HfRenderer
22 from vllm.renderers.online_renderer import OnlineRenderer
23 from vllm.tokenizers.registry import cached_tokenizer_from_config
24 from vllm.v1.engine.async_llm import AsyncLLM
25 from vllm.v1.metrics.stats import RequestSpecDecodeMetrics, RequestStateStats
26
27 MODEL_NAME = "openai-community/gpt2"
28 MODEL_NAME_SHORT = "gpt2"
29 DEEPSEEK_V4_FIM_BEGIN = "<|fim▁begin|>"
30 DEEPSEEK_V4_FIM_HOLE = "<|fim▁hole|>"
31 DEEPSEEK_V4_FIM_END = "<|fim▁end|>"
32 _PER_REQUEST_STATS = RequestStateStats(
33 queued_ts=1.0,
34 scheduled_ts=1.5,
35 first_token_ts=2.0,
36 last_token_ts=3.0,
37 num_generation_tokens=2,
38 )
39 BASE_MODEL_PATHS = [
40 BaseModelPath(name=MODEL_NAME, model_path=MODEL_NAME),
41 BaseModelPath(name=MODEL_NAME_SHORT, model_path=MODEL_NAME_SHORT),
42 ]
43
44
45 @dataclass
46 class MockHFConfig:
47 model_type: str = "any"
48
49
50 @dataclass
51 class MockModelConfig:
52 task = "generate"
53 runner_type = "generate"
54 model = MODEL_NAME
55 tokenizer = MODEL_NAME
56 trust_remote_code = False
57 tokenizer_mode = "auto"
58 max_model_len = 100
59 revision = None
60 code_revision = None
61 tokenizer_revision = None
62 multimodal_config = MultiModalConfig()
63 hf_config = MockHFConfig()
64 logits_processors: list[str] | None = None
65 diff_sampling_param: dict | None = None
66 allowed_local_media_path: str = ""
67 allowed_media_domains: list[str] | None = None
68 encoder_config = None
69 generation_config: str = "auto"
70 media_io_kwargs: dict[str, dict[str, Any]] = field(default_factory=dict)
71 skip_tokenizer_init: bool = False
72 is_encoder_decoder: bool = False
73 is_multimodal_model: bool = False
74 supports_multimodal_inputs: bool = False
75 renderer_num_workers: int = 1
76
77 def get_diff_sampling_param(self):
78 return self.diff_sampling_param or {}
79
80
81 @dataclass
82 class MockParallelConfig:
83 _api_process_rank: int = 0
84
85
86 @dataclass
87 class MockVllmConfig:
88 model_config: MockModelConfig
89 parallel_config: MockParallelConfig
90
91
92 def _build_serving_completion(engine: AsyncLLM) -> OpenAIServingCompletion:
93 models = OpenAIServingModels(
94 engine_client=engine,
95 base_model_paths=BASE_MODEL_PATHS,
96 )
97 online_renderer = OnlineRenderer(
98 model_config=engine.model_config,
99 renderer=engine.renderer,
100 request_logger=None,
101 chat_template=None,
102 chat_template_content_format="auto",
103 )
104 return OpenAIServingCompletion(
105 engine,
106 models,
107 online_renderer=online_renderer,
108 request_logger=None,
109 )
110
111
112 def _build_minimal_metrics_serving_completion(
113 enable_per_request_metrics: bool,
114 ) -> OpenAIServingCompletion:
115 serving = OpenAIServingCompletion.__new__(OpenAIServingCompletion)
116 serving.enable_prompt_tokens_details = False
117 serving.system_fingerprint = None
118 serving.enable_per_request_metrics = enable_per_request_metrics
119 return serving
120
121
122 def _make_metrics_request_output(
123 metrics: RequestStateStats | None = _PER_REQUEST_STATS,
124 ) -> RequestOutput:
125 return RequestOutput(
126 request_id="test-id",
127 prompt="Test prompt",
128 prompt_token_ids=[1, 2, 3],
129 prompt_logprobs=None,
130 outputs=[
131 CompletionOutput(
132 index=0,
133 text="Hello",
134 token_ids=[100, 101],
135 cumulative_logprob=None,
136 logprobs=None,
137 finish_reason="stop",
138 )
139 ],
140 finished=True,
141 metrics=metrics,
142 )
143
144
145 def _build_renderer(model_config: MockModelConfig):
146 return HfRenderer(
147 MockVllmConfig(model_config, parallel_config=MockParallelConfig()),
148 cached_tokenizer_from_config(model_config),
149 )
150
151
152 def test_completion_per_request_metrics_follow_server_flag():
153 request = CompletionRequest(model=MODEL_NAME, prompt="Test prompt", max_tokens=10)
154 request_output = _make_metrics_request_output()
155
156 disabled_serving = _build_minimal_metrics_serving_completion(
157 enable_per_request_metrics=False
158 )
159 disabled_response = disabled_serving.request_output_to_completion_response(
160 [request_output],
161 request,
162 "cmpl-test-id",
163 0,
164 MODEL_NAME,
165 None,
166 RequestResponseMetadata(request_id="cmpl-test-id"),
167 )
168 assert disabled_response.metrics is None
169
170 enabled_serving = _build_minimal_metrics_serving_completion(
171 enable_per_request_metrics=True
172 )
173 enabled_response = enabled_serving.request_output_to_completion_response(
174 [request_output],
175 request,
176 "cmpl-test-id",
177 0,
178 MODEL_NAME,
179 None,
180 RequestResponseMetadata(request_id="cmpl-test-id"),
181 )
182 assert enabled_response.metrics is not None
183 assert enabled_response.metrics.time_to_first_token_ms == pytest.approx(500.0)
184
185
186 def test_completion_per_request_metrics_suppressed_for_multiple_prompts():
187 serving = _build_minimal_metrics_serving_completion(enable_per_request_metrics=True)
188 response = serving.request_output_to_completion_response(
189 [_make_metrics_request_output(), _make_metrics_request_output()],
190 CompletionRequest(
191 model=MODEL_NAME,
192 prompt=["Test prompt", "Another prompt"],
193 max_tokens=10,
194 ),
195 "cmpl-test-id",
196 0,
197 MODEL_NAME,
198 None,
199 RequestResponseMetadata(request_id="cmpl-test-id"),
200 )
201 assert response.metrics is None
202
203
204 def _spec_decode_metrics() -> RequestSpecDecodeMetrics:
205 # Two verify steps: accept 3 drafts, then 1 -> histogram [0, 1, 0, 1].
206 m = RequestSpecDecodeMetrics.new(num_spec_tokens=3)
207 m.observe(num_draft_tokens=3, num_accepted=3)
208 m.observe(num_draft_tokens=3, num_accepted=1)
209 return m
210
211
212 def _make_spec_decode_request_output(
213 num_seqs: int = 1, with_metrics: bool = True
214 ) -> RequestOutput:
215 outputs = [
216 CompletionOutput(
217 index=i,
218 text="Hello",
219 token_ids=[100, 101],
220 cumulative_logprob=None,
221 logprobs=None,
222 finish_reason="stop",
223 spec_decode_metrics=_spec_decode_metrics() if with_metrics else None,
224 )
225 for i in range(num_seqs)
226 ]
227 return RequestOutput(
228 request_id="test-id",
229 prompt="Test prompt",
230 prompt_token_ids=[1, 2, 3],
231 prompt_logprobs=None,
232 outputs=outputs,
233 finished=True,
234 metrics=None,
235 )
236
237
238 def _completion_response(serving, request, request_output):
239 return serving.request_output_to_completion_response(
240 [request_output],
241 request,
242 "cmpl-test-id",
243 0,
244 MODEL_NAME,
245 None,
246 RequestResponseMetadata(request_id="cmpl-test-id"),
247 )
248
249
250 def test_completion_spec_decode_metrics_present_for_single_sequence():
251 # Timing off, but the sequence carries acceptance metrics -> the metrics
252 # object is created just to hold metrics.speculative_decoding.
253 serving = _build_minimal_metrics_serving_completion(
254 enable_per_request_metrics=False
255 )
256 response = _completion_response(
257 serving,
258 CompletionRequest(model=MODEL_NAME, prompt="Test prompt", max_tokens=10),
259 _make_spec_decode_request_output(num_seqs=1),
260 )
261 assert response.metrics is not None
262 assert response.metrics.time_to_first_token_ms is None # timing not requested
263 spec = response.metrics.speculative_decoding
264 assert spec is not None
265 assert spec.acceptance_histogram == [0, 1, 0, 1] # dense, index j
266 assert spec.num_spec_steps == 2
267 assert spec.num_spec_tokens == 3
268 assert spec.mean_acceptance_length == pytest.approx(3.0) # 1 + (3 + 1) / 2
269
270
271 def test_completion_spec_decode_metrics_suppressed_for_n_gt_1():
272 # Per-request metrics can't be attributed to one of the n sequences.
273 serving = _build_minimal_metrics_serving_completion(
274 enable_per_request_metrics=False
275 )
276 response = _completion_response(
277 serving,
278 CompletionRequest(model=MODEL_NAME, prompt="Test prompt", n=2, max_tokens=10),
279 _make_spec_decode_request_output(num_seqs=2),
280 )
281 assert response.metrics is None
282
283
284 def test_completion_spec_decode_metrics_absent_when_not_collected():
285 # Flag off -> the sequence carries no acceptance metrics -> no metrics object.
286 serving = _build_minimal_metrics_serving_completion(
287 enable_per_request_metrics=False
288 )
289 response = _completion_response(
290 serving,
291 CompletionRequest(model=MODEL_NAME, prompt="Test prompt", max_tokens=10),
292 _make_spec_decode_request_output(num_seqs=1, with_metrics=False),
293 )
294 assert response.metrics is None
295
296
297 def test_completion_metrics_carries_both_timing_and_spec_decode():
298 serving = _build_minimal_metrics_serving_completion(enable_per_request_metrics=True)
299 request_output = _make_spec_decode_request_output(num_seqs=1)
300 request_output.metrics = _PER_REQUEST_STATS # timing source
301 response = _completion_response(
302 serving,
303 CompletionRequest(model=MODEL_NAME, prompt="Test prompt", max_tokens=10),
304 request_output,
305 )
306 assert response.metrics is not None
307 assert response.metrics.time_to_first_token_ms == pytest.approx(500.0)
308 assert response.metrics.speculative_decoding is not None
309 assert response.metrics.speculative_decoding.num_spec_steps == 2
310
311
312 def _build_online_renderer_for_completion(
313 model_config: MockModelConfig,
314 renderer: Any | None = None,
315 ) -> OnlineRenderer:
316 if renderer is None:
317 renderer = MagicMock()
318 renderer.render_completion_suffix.return_value = None
319
320 return OnlineRenderer(
321 model_config=model_config,
322 renderer=renderer,
323 request_logger=None,
324 chat_template=None,
325 chat_template_content_format="auto",
326 )
327
328
329 def _deepseek_v4_suffix_renderer(
330 model_config: MockModelConfig,
331 ) -> DeepseekV4Renderer:
332 return DeepseekV4Renderer(
333 MockVllmConfig(model_config, parallel_config=MockParallelConfig()),
334 None,
335 )
336
337
338 @pytest.mark.asyncio
339 @pytest.mark.skip_global_cleanup
340 async def test_completion_suffix_uses_renderer_capability():
341 model_config = MockModelConfig()
342 online_renderer = _build_online_renderer_for_completion(
343 model_config,
344 renderer=_deepseek_v4_suffix_renderer(model_config),
345 )
346 online_renderer.preprocess_completion = AsyncMock(return_value=[{"ok": True}])
347
348 request = CompletionRequest(
349 model=MODEL_NAME,
350 prompt="def fib(n):\n return ",
351 suffix="\n\nprint(fib(10))",
352 max_tokens=64,
353 )
354
355 result = await online_renderer.render_completion(request)
356
357 assert result == [{"ok": True}]
358 online_renderer.preprocess_completion.assert_awaited_once()
359 assert online_renderer.preprocess_completion.call_args.kwargs["prompt_input"] == (
360 f"{DEEPSEEK_V4_FIM_BEGIN}def fib(n):\n return "
361 f"{DEEPSEEK_V4_FIM_HOLE}\n\nprint(fib(10))"
362 f"{DEEPSEEK_V4_FIM_END}"
363 )
364
365
366 @pytest.mark.asyncio
367 @pytest.mark.skip_global_cleanup
368 async def test_completion_suffix_supports_text_prompt_list():
369 model_config = MockModelConfig()
370 online_renderer = _build_online_renderer_for_completion(
371 model_config,
372 renderer=_deepseek_v4_suffix_renderer(model_config),
373 )
374 online_renderer.preprocess_completion = AsyncMock(return_value=[{"ok": True}])
375
376 request = CompletionRequest(
377 model=MODEL_NAME,
378 prompt=["prefix A", "prefix B"],
379 suffix=" suffix",
380 )
381
382 result = await online_renderer.render_completion(request)
383
384 assert result == [{"ok": True}]
385 assert online_renderer.preprocess_completion.call_args.kwargs["prompt_input"] == [
386 f"{DEEPSEEK_V4_FIM_BEGIN}prefix A"
387 f"{DEEPSEEK_V4_FIM_HOLE} suffix{DEEPSEEK_V4_FIM_END}",
388 f"{DEEPSEEK_V4_FIM_BEGIN}prefix B"
389 f"{DEEPSEEK_V4_FIM_HOLE} suffix{DEEPSEEK_V4_FIM_END}",
390 ]
391
392
393 @pytest.mark.asyncio
394 @pytest.mark.skip_global_cleanup
395 async def test_completion_suffix_rejects_renderer_without_fim_support():
396 model_config = MockModelConfig()
397 online_renderer = _build_online_renderer_for_completion(model_config)
398 online_renderer.preprocess_completion = AsyncMock()
399
400 request = CompletionRequest(
401 model=MODEL_NAME,
402 prompt="prefix",
403 suffix="suffix",
404 )
405
406 result = await online_renderer.render_completion(request)
407
408 assert isinstance(result, ErrorResponse)
409 assert "FIM completion rendering" in result.error.message
410 online_renderer.preprocess_completion.assert_not_awaited()
411
412
413 @pytest.mark.asyncio
414 @pytest.mark.skip_global_cleanup
415 async def test_completion_suffix_rejects_echo():
416 model_config = MockModelConfig()
417 online_renderer = _build_online_renderer_for_completion(
418 model_config,
419 renderer=_deepseek_v4_suffix_renderer(model_config),
420 )
421 online_renderer.preprocess_completion = AsyncMock()
422
423 request = CompletionRequest(
424 model=MODEL_NAME,
425 prompt="prefix",
426 suffix="suffix",
427 echo=True,
428 )
429
430 result = await online_renderer.render_completion(request)
431
432 assert isinstance(result, ErrorResponse)
433 assert "Echo is unsupported with suffix" in result.error.message
434 online_renderer.preprocess_completion.assert_not_awaited()
435
436
437 @pytest.mark.asyncio
438 @pytest.mark.skip_global_cleanup
439 async def test_completion_suffix_rejects_prompt_embeds():
440 model_config = MockModelConfig()
441 online_renderer = _build_online_renderer_for_completion(
442 model_config,
443 renderer=_deepseek_v4_suffix_renderer(model_config),
444 )
445 online_renderer.preprocess_completion = AsyncMock()
446
447 request = CompletionRequest(
448 model=MODEL_NAME,
449 prompt="prefix",
450 suffix="suffix",
451 prompt_embeds=b"embeds",
452 )
453
454 result = await online_renderer.render_completion(request)
455
456 assert isinstance(result, ErrorResponse)
457 assert "suffix is not supported with prompt_embeds" in result.error.message
458 online_renderer.preprocess_completion.assert_not_awaited()
459
460
461 @pytest.mark.asyncio
462 @pytest.mark.skip_global_cleanup
463 @pytest.mark.parametrize("prompt", ([1, 2, 3], [[1, 2, 3]]))
464 async def test_completion_suffix_rejects_token_prompt(prompt):
465 model_config = MockModelConfig()
466 online_renderer = _build_online_renderer_for_completion(
467 model_config,
468 renderer=_deepseek_v4_suffix_renderer(model_config),
469 )
470 online_renderer.preprocess_completion = AsyncMock()
471
472 request = CompletionRequest(
473 model=MODEL_NAME,
474 prompt=prompt,
475 suffix="suffix",
476 )
477
478 result = await online_renderer.render_completion(request)
479
480 assert isinstance(result, ErrorResponse)
481 assert "requires text prompt input" in result.error.message
482 online_renderer.preprocess_completion.assert_not_awaited()
483
484
485 @pytest.mark.asyncio
486 async def test_completion_error_non_stream():
487 """Test finish_reason='error' returns 500 InternalServerError (non-streaming)."""
488 mock_engine = MagicMock(spec=AsyncLLM)
489 mock_engine.errored = False
490 mock_engine.model_config = MockModelConfig()
491 mock_engine.input_processor = MagicMock()
492 mock_engine.renderer = _build_renderer(mock_engine.model_config)
493
494 serving_completion = _build_serving_completion(mock_engine)
495
496 completion_output = CompletionOutput(
497 index=0,
498 text="",
499 token_ids=[],
500 cumulative_logprob=None,
501 logprobs=None,
502 finish_reason="error",
503 )
504
505 request_output = RequestOutput(
506 request_id="test-id",
507 prompt="Test prompt",
508 prompt_token_ids=[1, 2, 3],
509 prompt_logprobs=None,
510 outputs=[completion_output],
511 finished=True,
512 metrics=None,
513 lora_request=None,
514 encoder_prompt=None,
515 encoder_prompt_token_ids=None,
516 )
517
518 async def mock_generate(*args, **kwargs):
519 yield request_output
520
521 mock_engine.generate = MagicMock(side_effect=mock_generate)
522
523 request = CompletionRequest(
524 model=MODEL_NAME,
525 prompt="Test prompt",
526 max_tokens=10,
527 stream=False,
528 )
529
530 with pytest.raises(GenerationError):
531 await serving_completion.create_completion(request)
532
533
534 @pytest.mark.asyncio
535 async def test_openai_completion_keeps_mm_cache_for_engine_execution():
536 mock_engine = MagicMock(spec=AsyncLLM)
537 mock_engine.errored = False
538 mock_engine.model_config = MockModelConfig()
539 mock_engine.input_processor = MagicMock()
540 mock_engine.renderer = _build_renderer(mock_engine.model_config)
541
542 serving_completion = _build_serving_completion(mock_engine)
543 serving_completion.online_renderer.preprocess_completion = AsyncMock(
544 return_value=[{"prompt_token_ids": [1, 2, 3]}]
545 )
546
547 request = CompletionRequest(
548 model=MODEL_NAME,
549 prompt="Test prompt",
550 )
551
552 result = await serving_completion.render_completion_request(request)
553
554 assert isinstance(result, list)
555 assert (
556 serving_completion.online_renderer.preprocess_completion.call_args.kwargs[
557 "skip_mm_cache"
558 ]
559 is False
560 )
561
562
563 def _build_serving_render(engine: AsyncLLM) -> ServingRender:
564 models = OpenAIServingModels(
565 engine_client=engine,
566 base_model_paths=BASE_MODEL_PATHS,
567 )
568 online_renderer = OnlineRenderer(
569 model_config=engine.model_config,
570 renderer=engine.renderer,
571 request_logger=None,
572 chat_template=None,
573 chat_template_content_format="auto",
574 )
575
576 serving_render = ServingRender(models, online_renderer)
577
578 async def _fake_preprocess_chat(*args, **kwargs):
579 # return conversation, engine_inputs
580 return (
581 [{"role": "user", "content": "Test"}],
582 [{"prompt_token_ids": [1, 2, 3]}],
583 )
584
585 serving_render.online_renderer.preprocess_chat = AsyncMock(
586 side_effect=_fake_preprocess_chat
587 )
588 return serving_render
589
590
591 @pytest.mark.asyncio
592 async def test_renderer_only_completion_request_skips_mm_cache():
593 mock_engine = MagicMock(spec=AsyncLLM)
594 mock_engine.errored = False
595 mock_engine.model_config = MockModelConfig()
596 mock_engine.input_processor = MagicMock()
597 mock_engine.renderer = _build_renderer(mock_engine.model_config)
598
599 serving_render = _build_serving_render(mock_engine)
600
601 serving_render.online_renderer.preprocess_completion = AsyncMock(
602 return_value=[{"prompt_token_ids": [1, 2, 3]}]
603 )
604
605 request = CompletionRequest(
606 model=MODEL_NAME,
607 prompt="Test prompt",
608 )
609
610 result = await serving_render.render_completion_request(request)
611
612 assert isinstance(result, list)
613 assert (
614 serving_render.online_renderer.preprocess_completion.call_args.kwargs[
615 "skip_mm_cache"
616 ]
617 is True
618 )
619
620
621 @pytest.mark.asyncio
622 async def test_completion_error_stream():
623 """Test finish_reason='error' returns 500 InternalServerError (streaming)."""
624 mock_engine = MagicMock(spec=AsyncLLM)
625 mock_engine.errored = False
626 mock_engine.model_config = MockModelConfig()
627 mock_engine.input_processor = MagicMock()
628 mock_engine.renderer = _build_renderer(mock_engine.model_config)
629
630 serving_completion = _build_serving_completion(mock_engine)
631
632 completion_output_1 = CompletionOutput(
633 index=0,
634 text="Hello",
635 token_ids=[100],
636 cumulative_logprob=None,
637 logprobs=None,
638 finish_reason=None,
639 )
640
641 request_output_1 = RequestOutput(
642 request_id="test-id",
643 prompt="Test prompt",
644 prompt_token_ids=[1, 2, 3],
645 prompt_logprobs=None,
646 outputs=[completion_output_1],
647 finished=False,
648 metrics=None,
649 lora_request=None,
650 encoder_prompt=None,
651 encoder_prompt_token_ids=None,
652 )
653
654 completion_output_2 = CompletionOutput(
655 index=0,
656 text="Hello",
657 token_ids=[100],
658 cumulative_logprob=None,
659 logprobs=None,
660 finish_reason="error",
661 )
662
663 request_output_2 = RequestOutput(
664 request_id="test-id",
665 prompt="Test prompt",
666 prompt_token_ids=[1, 2, 3],
667 prompt_logprobs=None,
668 outputs=[completion_output_2],
669 finished=True,
670 metrics=None,
671 lora_request=None,
672 encoder_prompt=None,
673 encoder_prompt_token_ids=None,
674 )
675
676 async def mock_generate(*args, **kwargs):
677 yield request_output_1
678 yield request_output_2
679
680 mock_engine.generate = MagicMock(side_effect=mock_generate)
681
682 request = CompletionRequest(
683 model=MODEL_NAME,
684 prompt="Test prompt",
685 max_tokens=10,
686 stream=True,
687 )
688
689 response = await serving_completion.create_completion(request)
690
691 chunks = []
692 async for chunk in response:
693 chunks.append(chunk)
694
695 assert len(chunks) >= 2
696 assert any("Internal server error" in chunk for chunk in chunks), (
697 f"Expected error message in chunks: {chunks}"
698 )
699 assert chunks[-1] == "data: [DONE]\n\n"
700
701
702 def test_json_schema_response_format_missing_schema():
703 """When response_format type is 'json_schema' but the json_schema field
704 is not provided, request construction should raise a validation error
705 so the API returns 400 instead of 500."""
706 with pytest.raises(Exception, match="json_schema.*must be provided"):
707 CompletionRequest(
708 model=MODEL_NAME,
709 prompt="Test prompt",
710 max_tokens=10,
711 response_format={"type": "json_schema"},
712 )
713
714
715 @pytest.mark.parametrize("format_value", [None, {}])
716 def test_structural_tag_response_format_invalid(format_value):
717 """Malformed structural tags should be rejected during request validation."""
718 with pytest.raises(
719 VLLMValidationError,
720 match="Invalid response_format structural_tag",
721 ):
722 CompletionRequest(
723 model=MODEL_NAME,
724 prompt="Test prompt",
725 max_tokens=10,
726 response_format={"type": "structural_tag", "format": format_value},
727 )
728
729
730 @pytest.mark.parametrize("structural_tag", ["not json", ""])
731 def test_structured_outputs_structural_tag_invalid(structural_tag):
732 """Malformed direct structured_outputs structural tags should be rejected."""
733 with pytest.raises(
734 VLLMValidationError,
735 match="Invalid structured_outputs structural_tag",
736 ):
737 CompletionRequest(
738 model=MODEL_NAME,
739 prompt="Test prompt",
740 max_tokens=10,
741 structured_outputs={"structural_tag": structural_tag},
742 )
743
744
745 def test_negative_prompt_token_ids_nested():
746 """Negative token IDs in prompt (nested list) should raise validation error."""
747 with pytest.raises(Exception, match="greater than or equal to 0"):
748 CompletionRequest(
749 model=MODEL_NAME,
750 prompt=[[-1]],
751 max_tokens=10,
752 )
753
754
755 def test_negative_prompt_token_ids_flat():
756 """Negative token IDs in prompt (flat list) should raise validation error."""
757 with pytest.raises(Exception, match="greater than or equal to 0"):
758 CompletionRequest(
759 model=MODEL_NAME,
760 prompt=[-1],
761 max_tokens=10,
762 )
763
764
765 def test_logprobs_minus_one_allowed():
766 """logprobs=-1 means "return all logprobs". The sampling layer and the chat
767 top_logprobs / prompt_logprobs validators all accept -1, so the completion
768 logprobs validator must accept it too instead of rejecting it as negative."""
769 request = CompletionRequest(
770 model=MODEL_NAME,
771 prompt="Test prompt",
772 max_tokens=10,
773 logprobs=-1,
774 )
775 assert request.logprobs == -1
776
777
778 def test_logprobs_below_minus_one_rejected():
779 """Values more negative than -1 stay invalid."""
780 with pytest.raises(Exception, match="must be a positive value or -1"):
781 CompletionRequest(
782 model=MODEL_NAME,
783 prompt="Test prompt",
784 max_tokens=10,
785 logprobs=-2,
786 )
787
788
789 class TestCompletionPromptListLimit:
790 """Regression tests for CVE: unbounded prompt list fan-out."""
791
792 def test_scalar_prompt_allowed(self):
793 request = CompletionRequest(
794 model=MODEL_NAME,
795 prompt="hello",
796 max_tokens=1,
797 )
798 assert request.prompt == "hello"
799
800 def test_single_token_list_allowed(self):
801 request = CompletionRequest(
802 model=MODEL_NAME,
803 prompt=[1, 2, 3],
804 max_tokens=1,
805 )
806 assert request.prompt == [1, 2, 3]
807
808 def test_bounded_text_prompt_list_allowed(self, monkeypatch):
809 monkeypatch.setenv("VLLM_MAX_COMPLETION_PROMPTS", "10")
810 from vllm import envs
811
812 if hasattr(envs.__getattr__, "cache_clear"):
813 envs.__getattr__.cache_clear()
814
815 request = CompletionRequest(
816 model=MODEL_NAME,
817 prompt=["a", "b", "c"],
818 max_tokens=1,
819 )
820 assert request.prompt == ["a", "b", "c"]
821
822 def test_bounded_token_id_prompt_list_allowed(self, monkeypatch):
823 monkeypatch.setenv("VLLM_MAX_COMPLETION_PROMPTS", "10")
824 from vllm import envs
825
826 if hasattr(envs.__getattr__, "cache_clear"):
827 envs.__getattr__.cache_clear()
828
829 request = CompletionRequest(
830 model=MODEL_NAME,
831 prompt=[[1], [2], [3]],
832 max_tokens=1,
833 )
834 assert request.prompt == [[1], [2], [3]]
835
836 def test_oversized_text_prompt_list_rejected(self, monkeypatch):
837 monkeypatch.setenv("VLLM_MAX_COMPLETION_PROMPTS", "5")
838 from vllm import envs
839
840 if hasattr(envs.__getattr__, "cache_clear"):
841 envs.__getattr__.cache_clear()
842
843 with pytest.raises(
844 Exception, match="prompt list length 10 exceeds the maximum"
845 ):
846 CompletionRequest(
847 model=MODEL_NAME,
848 prompt=["x"] * 10,
849 max_tokens=1,
850 )
851
852 def test_oversized_token_id_prompt_list_rejected(self, monkeypatch):
853 monkeypatch.setenv("VLLM_MAX_COMPLETION_PROMPTS", "5")
854 from vllm import envs
855
856 if hasattr(envs.__getattr__, "cache_clear"):
857 envs.__getattr__.cache_clear()
858
859 with pytest.raises(
860 Exception, match="prompt list length 10 exceeds the maximum"
861 ):
862 CompletionRequest(
863 model=MODEL_NAME,
864 prompt=[[1]] * 10,
865 max_tokens=1,
866 )
867
868 def test_exact_limit_allowed(self, monkeypatch):
869 monkeypatch.setenv("VLLM_MAX_COMPLETION_PROMPTS", "5")
870 from vllm import envs
871
872 if hasattr(envs.__getattr__, "cache_clear"):
873 envs.__getattr__.cache_clear()
874
875 request = CompletionRequest(
876 model=MODEL_NAME,
877 prompt=["x"] * 5,
878 max_tokens=1,
879 )
880 assert len(request.prompt) == 5
881
882 def test_one_over_limit_rejected(self, monkeypatch):
883 monkeypatch.setenv("VLLM_MAX_COMPLETION_PROMPTS", "5")
884 from vllm import envs
885
886 if hasattr(envs.__getattr__, "cache_clear"):
887 envs.__getattr__.cache_clear()
888
889 with pytest.raises(Exception, match="prompt list length 6 exceeds the maximum"):
890 CompletionRequest(
891 model=MODEL_NAME,
892 prompt=["x"] * 6,
893 max_tokens=1,
894 )
895
896 def test_oversized_prompt_embeds_list_rejected(self, monkeypatch):
897 monkeypatch.setenv("VLLM_MAX_COMPLETION_PROMPTS", "5")
898 from vllm import envs
899
900 if hasattr(envs.__getattr__, "cache_clear"):
901 envs.__getattr__.cache_clear()
902
903 with pytest.raises(Exception, match="prompt_embeds list length 10 exceeds"):
904 CompletionRequest(
905 model=MODEL_NAME,
906 prompt_embeds=[b"\x00"] * 10,
907 max_tokens=1,
908 )
909
910 def test_bounded_prompt_embeds_list_allowed(self, monkeypatch):
911 monkeypatch.setenv("VLLM_MAX_COMPLETION_PROMPTS", "5")
912 from vllm import envs
913
914 if hasattr(envs.__getattr__, "cache_clear"):
915 envs.__getattr__.cache_clear()
916
917 request = CompletionRequest(
918 model=MODEL_NAME,
919 prompt_embeds=[b"\x00"] * 5,
920 max_tokens=1,
921 )
922 assert len(request.prompt_embeds) == 5
923
924
925 @pytest.mark.parametrize("field_name", ["prompt_logprobs", "logprobs"])
926 def test_non_numeric_logprobs_rejected(field_name):
927 """A non-numeric logprobs value must be a clean 400 validation error, not a
928 TypeError from the mode='before' comparison (which surfaces as HTTP 500)."""
929 with pytest.raises(VLLMValidationError, match=f"`{field_name}` must be an integer"):
930 CompletionRequest(
931 model=MODEL_NAME,
932 prompt="Test prompt",
933 max_tokens=10,
934 **{field_name: "2"},
935 )