main
py 606 lines 18.4 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, patch
7
8 import pytest
9
10 from vllm.config.multimodal import MultiModalConfig
11 from vllm.entrypoints.openai.chat_completion.protocol import (
12 BatchChatCompletionRequest,
13 ChatCompletionRequest,
14 )
15 from vllm.entrypoints.openai.chat_completion.serving import OpenAIServingChat
16 from vllm.entrypoints.openai.models.protocol import BaseModelPath
17 from vllm.entrypoints.openai.models.serving import OpenAIServingModels
18 from vllm.entrypoints.scale_out.render.serving import ServingRender
19 from vllm.exceptions import GenerationError, VLLMValidationError
20 from vllm.outputs import CompletionOutput, RequestOutput
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
26 MODEL_NAME = "openai-community/gpt2"
27 MODEL_NAME_SHORT = "gpt2"
28 BASE_MODEL_PATHS = [
29 BaseModelPath(name=MODEL_NAME, model_path=MODEL_NAME),
30 BaseModelPath(name=MODEL_NAME_SHORT, model_path=MODEL_NAME_SHORT),
31 ]
32
33
34 @dataclass
35 class MockHFConfig:
36 model_type: str = "any"
37
38
39 @dataclass
40 class MockModelConfig:
41 task = "generate"
42 runner_type = "generate"
43 model = MODEL_NAME
44 tokenizer = MODEL_NAME
45 trust_remote_code = False
46 tokenizer_mode = "auto"
47 max_model_len = 100
48 revision = None
49 code_revision = None
50 tokenizer_revision = None
51 multimodal_config = MultiModalConfig()
52 hf_config = MockHFConfig()
53 hf_text_config = MockHFConfig()
54 logits_processors: list[str] | None = None
55 diff_sampling_param: dict | None = None
56 allowed_local_media_path: str = ""
57 allowed_media_domains: list[str] | None = None
58 encoder_config = None
59 generation_config: str = "auto"
60 media_io_kwargs: dict[str, dict[str, Any]] = field(default_factory=dict)
61 skip_tokenizer_init: bool = False
62 is_encoder_decoder: bool = False
63 is_multimodal_model: bool = False
64 supports_multimodal_inputs: bool = False
65 renderer_num_workers: int = 1
66
67 def get_diff_sampling_param(self):
68 return self.diff_sampling_param or {}
69
70
71 @dataclass
72 class MockParallelConfig:
73 _api_process_rank: int = 0
74
75
76 @dataclass
77 class MockVllmConfig:
78 model_config: MockModelConfig
79 parallel_config: MockParallelConfig
80
81
82 def _build_renderer(model_config: MockModelConfig):
83 return HfRenderer(
84 MockVllmConfig(model_config, parallel_config=MockParallelConfig()),
85 cached_tokenizer_from_config(model_config),
86 )
87
88
89 def _build_serving_chat(engine: AsyncLLM) -> OpenAIServingChat:
90 models = OpenAIServingModels(
91 engine_client=engine,
92 base_model_paths=BASE_MODEL_PATHS,
93 )
94 online_renderer = OnlineRenderer(
95 model_config=engine.model_config,
96 renderer=engine.renderer,
97 request_logger=None,
98 chat_template=None,
99 chat_template_content_format="auto",
100 )
101
102 serving_chat = OpenAIServingChat(
103 engine,
104 models,
105 response_role="assistant",
106 online_renderer=online_renderer,
107 request_logger=None,
108 chat_template=None,
109 chat_template_content_format="auto",
110 )
111
112 async def _fake_preprocess_chat(*args, **kwargs):
113 # return conversation, engine_inputs
114 return (
115 [{"role": "user", "content": "Test"}],
116 [{"prompt_token_ids": [1, 2, 3]}],
117 )
118
119 serving_chat.online_renderer.preprocess_chat = AsyncMock(
120 side_effect=_fake_preprocess_chat
121 )
122 return serving_chat
123
124
125 @pytest.mark.asyncio
126 async def test_chat_error_non_stream():
127 """Test finish_reason='error' returns 500 InternalServerError (non-streaming)."""
128 mock_engine = MagicMock(spec=AsyncLLM)
129 mock_engine.errored = False
130 mock_engine.model_config = MockModelConfig()
131 mock_engine.input_processor = MagicMock()
132 mock_engine.renderer = _build_renderer(mock_engine.model_config)
133
134 serving_chat = _build_serving_chat(mock_engine)
135
136 completion_output = CompletionOutput(
137 index=0,
138 text="",
139 token_ids=[],
140 cumulative_logprob=None,
141 logprobs=None,
142 finish_reason="error",
143 )
144
145 request_output = RequestOutput(
146 request_id="test-id",
147 prompt="Test prompt",
148 prompt_token_ids=[1, 2, 3],
149 prompt_logprobs=None,
150 outputs=[completion_output],
151 finished=True,
152 metrics=None,
153 lora_request=None,
154 encoder_prompt=None,
155 encoder_prompt_token_ids=None,
156 )
157
158 async def mock_generate(*args, **kwargs):
159 yield request_output
160
161 mock_engine.generate = MagicMock(side_effect=mock_generate)
162
163 request = ChatCompletionRequest(
164 model=MODEL_NAME,
165 messages=[{"role": "user", "content": "Test prompt"}],
166 max_tokens=10,
167 stream=False,
168 )
169
170 with pytest.raises(GenerationError):
171 await serving_chat.create_chat_completion(request)
172
173
174 @pytest.mark.asyncio
175 async def test_openai_chat_keeps_mm_cache_for_engine_execution():
176 mock_engine = MagicMock(spec=AsyncLLM)
177 mock_engine.errored = False
178 mock_engine.model_config = MockModelConfig()
179 mock_engine.input_processor = MagicMock()
180 mock_engine.renderer = _build_renderer(mock_engine.model_config)
181
182 serving_chat = _build_serving_chat(mock_engine)
183
184 request = ChatCompletionRequest(
185 model=MODEL_NAME,
186 messages=[{"role": "user", "content": "Test prompt"}],
187 )
188
189 result = await serving_chat.render_chat_request(request)
190
191 assert isinstance(result, tuple)
192 assert (
193 serving_chat.online_renderer.preprocess_chat.call_args.kwargs["skip_mm_cache"]
194 is False
195 )
196
197
198 def _build_serving_render(engine: AsyncLLM) -> ServingRender:
199 models = OpenAIServingModels(
200 engine_client=engine,
201 base_model_paths=BASE_MODEL_PATHS,
202 )
203 online_renderer = OnlineRenderer(
204 model_config=engine.model_config,
205 renderer=engine.renderer,
206 request_logger=None,
207 chat_template=None,
208 chat_template_content_format="auto",
209 )
210
211 serving_render = ServingRender(models, online_renderer)
212
213 async def _fake_preprocess_chat(*args, **kwargs):
214 # return conversation, engine_inputs
215 return (
216 [{"role": "user", "content": "Test"}],
217 [{"prompt_token_ids": [1, 2, 3]}],
218 )
219
220 serving_render.online_renderer.preprocess_chat = AsyncMock(
221 side_effect=_fake_preprocess_chat
222 )
223 return serving_render
224
225
226 @pytest.mark.asyncio
227 async def test_renderer_only_chat_request_skips_mm_cache():
228 mock_engine = MagicMock(spec=AsyncLLM)
229 mock_engine.errored = False
230 mock_engine.model_config = MockModelConfig()
231 mock_engine.input_processor = MagicMock()
232 mock_engine.renderer = _build_renderer(mock_engine.model_config)
233
234 serving_render = _build_serving_render(mock_engine)
235
236 request = ChatCompletionRequest(
237 model=MODEL_NAME,
238 messages=[{"role": "user", "content": "Test prompt"}],
239 )
240
241 result = await serving_render.render_chat_request(request)
242
243 assert result.token_ids == [1, 2, 3]
244 assert (
245 serving_render.online_renderer.preprocess_chat.call_args.kwargs["skip_mm_cache"]
246 is True
247 )
248
249
250 @pytest.mark.asyncio
251 async def test_chat_error_stream():
252 """Test finish_reason='error' returns 500 InternalServerError (streaming)."""
253 mock_engine = MagicMock(spec=AsyncLLM)
254 mock_engine.errored = False
255 mock_engine.model_config = MockModelConfig()
256 mock_engine.input_processor = MagicMock()
257 mock_engine.renderer = _build_renderer(mock_engine.model_config)
258
259 serving_chat = _build_serving_chat(mock_engine)
260
261 completion_output_1 = CompletionOutput(
262 index=0,
263 text="Hello",
264 token_ids=[100],
265 cumulative_logprob=None,
266 logprobs=None,
267 finish_reason=None,
268 )
269
270 request_output_1 = RequestOutput(
271 request_id="test-id",
272 prompt="Test prompt",
273 prompt_token_ids=[1, 2, 3],
274 prompt_logprobs=None,
275 outputs=[completion_output_1],
276 finished=False,
277 metrics=None,
278 lora_request=None,
279 encoder_prompt=None,
280 encoder_prompt_token_ids=None,
281 )
282
283 completion_output_2 = CompletionOutput(
284 index=0,
285 text="Hello",
286 token_ids=[100],
287 cumulative_logprob=None,
288 logprobs=None,
289 finish_reason="error",
290 )
291
292 request_output_2 = RequestOutput(
293 request_id="test-id",
294 prompt="Test prompt",
295 prompt_token_ids=[1, 2, 3],
296 prompt_logprobs=None,
297 outputs=[completion_output_2],
298 finished=True,
299 metrics=None,
300 lora_request=None,
301 encoder_prompt=None,
302 encoder_prompt_token_ids=None,
303 )
304
305 async def mock_generate(*args, **kwargs):
306 yield request_output_1
307 yield request_output_2
308
309 mock_engine.generate = MagicMock(side_effect=mock_generate)
310
311 request = ChatCompletionRequest(
312 model=MODEL_NAME,
313 messages=[{"role": "user", "content": "Test prompt"}],
314 max_tokens=10,
315 stream=True,
316 )
317
318 response = await serving_chat.create_chat_completion(request)
319
320 chunks = []
321 async for chunk in response:
322 chunks.append(chunk)
323
324 assert len(chunks) >= 2
325 assert any("Internal server error" in chunk for chunk in chunks), (
326 f"Expected error message in chunks: {chunks}"
327 )
328 assert chunks[-1] == "data: [DONE]\n\n"
329
330
331 @pytest.mark.parametrize(
332 "image_content",
333 [
334 [{"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}],
335 [{"image_url": {"url": "https://example.com/image.jpg"}}],
336 ],
337 )
338 def test_system_message_warns_on_image(image_content):
339 """Test that system messages with image content trigger a warning."""
340 with patch(
341 "vllm.entrypoints.openai.chat_completion.protocol.logger"
342 ) as mock_logger:
343 ChatCompletionRequest(
344 model=MODEL_NAME,
345 messages=[
346 {
347 "role": "system",
348 "content": image_content,
349 }
350 ],
351 )
352
353 mock_logger.warning_once.assert_called()
354 call_args = str(mock_logger.warning_once.call_args)
355 assert "System messages should only contain text" in call_args
356 assert "image_url" in call_args
357
358
359 def test_system_message_accepts_text():
360 """Test that system messages can contain text content."""
361 # Should not raise an exception
362 request = ChatCompletionRequest(
363 model=MODEL_NAME,
364 messages=[
365 {"role": "system", "content": "You are a helpful assistant."},
366 ],
367 )
368 assert request.messages[0]["role"] == "system"
369
370
371 def test_system_message_accepts_text_array():
372 """Test that system messages can contain an array with text content."""
373 # Should not raise an exception
374 request = ChatCompletionRequest(
375 model=MODEL_NAME,
376 messages=[
377 {
378 "role": "system",
379 "content": [{"type": "text", "text": "You are a helpful assistant."}],
380 },
381 ],
382 )
383 assert request.messages[0]["role"] == "system"
384
385
386 def test_user_message_accepts_image():
387 """Test that user messages can still contain image content."""
388 # Should not raise an exception
389 request = ChatCompletionRequest(
390 model=MODEL_NAME,
391 messages=[
392 {
393 "role": "user",
394 "content": [
395 {"type": "text", "text": "What's in this image?"},
396 {
397 "type": "image_url",
398 "image_url": {"url": "https://example.com/image.jpg"},
399 },
400 ],
401 },
402 ],
403 )
404 assert request.messages[0]["role"] == "user"
405
406
407 @pytest.mark.parametrize(
408 "audio_content",
409 [
410 [
411 {
412 "type": "input_audio",
413 "input_audio": {"data": "base64data", "format": "wav"},
414 }
415 ],
416 [{"input_audio": {"data": "base64data", "format": "wav"}}],
417 ],
418 )
419 def test_system_message_warns_on_audio(audio_content):
420 """Test that system messages with audio content trigger a warning."""
421 with patch(
422 "vllm.entrypoints.openai.chat_completion.protocol.logger"
423 ) as mock_logger:
424 ChatCompletionRequest(
425 model=MODEL_NAME,
426 messages=[
427 {
428 "role": "system",
429 "content": audio_content,
430 }
431 ],
432 )
433
434 mock_logger.warning_once.assert_called()
435 call_args = str(mock_logger.warning_once.call_args)
436 assert "System messages should only contain text" in call_args
437 assert "input_audio" in call_args
438
439
440 @pytest.mark.parametrize(
441 "video_content",
442 [
443 [{"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}],
444 [{"video_url": {"url": "https://example.com/video.mp4"}}],
445 ],
446 )
447 def test_system_message_warns_on_video(video_content):
448 """Test that system messages with video content trigger a warning."""
449 with patch(
450 "vllm.entrypoints.openai.chat_completion.protocol.logger"
451 ) as mock_logger:
452 ChatCompletionRequest(
453 model=MODEL_NAME,
454 messages=[
455 {
456 "role": "system",
457 "content": video_content,
458 }
459 ],
460 )
461
462 mock_logger.warning_once.assert_called()
463 call_args = str(mock_logger.warning_once.call_args)
464 assert "System messages should only contain text" in call_args
465 assert "video_url" in call_args
466
467
468 def test_json_schema_response_format_missing_schema():
469 """When response_format type is 'json_schema' but the json_schema field
470 is not provided, request construction should raise a validation error
471 so the API returns 400 instead of 500."""
472 with pytest.raises(Exception, match="json_schema.*must be provided"):
473 ChatCompletionRequest(
474 model=MODEL_NAME,
475 messages=[{"role": "user", "content": "hello"}],
476 response_format={"type": "json_schema"},
477 )
478
479
480 @pytest.mark.asyncio
481 async def test_online_renderer_rejects_mm_processor_kwargs_by_default():
482 model_config = MockModelConfig()
483 model_config.multimodal_config = MultiModalConfig()
484 online_renderer = OnlineRenderer(
485 model_config=model_config,
486 renderer=MagicMock(),
487 request_logger=None,
488 chat_template=None,
489 chat_template_content_format="auto",
490 )
491
492 request = ChatCompletionRequest(
493 model=MODEL_NAME,
494 messages=[{"role": "user", "content": "hello"}],
495 mm_processor_kwargs={
496 "patch_size": 1,
497 "vision_min_num_patches": 3_000_000_000,
498 },
499 )
500
501 with pytest.raises(
502 VLLMValidationError,
503 match="Per-request mm_processor_kwargs are disabled",
504 ):
505 await online_renderer.preprocess_chat(
506 request,
507 request.messages,
508 default_template=None,
509 default_template_content_format="auto",
510 default_template_kwargs=None,
511 )
512
513
514 @pytest.mark.asyncio
515 async def test_online_renderer_rejects_media_io_kwargs_by_default():
516 model_config = MockModelConfig()
517 model_config.multimodal_config = MultiModalConfig()
518 online_renderer = OnlineRenderer(
519 model_config=model_config,
520 renderer=MagicMock(),
521 request_logger=None,
522 chat_template=None,
523 chat_template_content_format="auto",
524 )
525
526 request = ChatCompletionRequest(
527 model=MODEL_NAME,
528 messages=[{"role": "user", "content": "hello"}],
529 media_io_kwargs={"video": {"num_frames": 1_000_000}},
530 )
531
532 with pytest.raises(
533 VLLMValidationError,
534 match="Per-request media_io_kwargs are disabled",
535 ):
536 await online_renderer.preprocess_chat(
537 request,
538 request.messages,
539 default_template=None,
540 default_template_content_format="auto",
541 default_template_kwargs=None,
542 )
543
544
545 def test_trust_request_mm_kwargs_opt_in_allows_overrides():
546 from vllm.entrypoints.generate.base.protocol import validate_request_mm_kwargs
547
548 validate_request_mm_kwargs(
549 mm_processor_kwargs={"use_audio_in_video": True},
550 media_io_kwargs={"video": {"num_frames": 4}},
551 trust_request_mm_kwargs=True,
552 )
553
554
555 @pytest.mark.parametrize("format_value", [None, {}])
556 def test_structural_tag_response_format_invalid(format_value):
557 """Malformed structural tags should be rejected during request validation."""
558 with pytest.raises(
559 VLLMValidationError,
560 match="Invalid response_format structural_tag",
561 ):
562 ChatCompletionRequest(
563 model=MODEL_NAME,
564 messages=[{"role": "user", "content": "hello"}],
565 response_format={"type": "structural_tag", "format": format_value},
566 )
567
568
569 @pytest.mark.parametrize("format_value", [None, {}])
570 def test_batch_structural_tag_response_format_invalid(format_value):
571 """Batch chat should reject malformed structural tags at request parsing."""
572 with pytest.raises(
573 VLLMValidationError,
574 match="Invalid response_format structural_tag",
575 ):
576 BatchChatCompletionRequest(
577 model=MODEL_NAME,
578 messages=[[{"role": "user", "content": "hello"}]],
579 response_format={"type": "structural_tag", "format": format_value},
580 )
581
582
583 @pytest.mark.parametrize("structural_tag", ["not json", ""])
584 def test_structured_outputs_structural_tag_invalid(structural_tag):
585 """Malformed direct structured_outputs structural tags should be rejected."""
586 with pytest.raises(
587 VLLMValidationError,
588 match="Invalid structured_outputs structural_tag",
589 ):
590 ChatCompletionRequest(
591 model=MODEL_NAME,
592 messages=[{"role": "user", "content": "hello"}],
593 structured_outputs={"structural_tag": structural_tag},
594 )
595
596
597 @pytest.mark.parametrize("field_name", ["prompt_logprobs", "top_logprobs"])
598 def test_non_numeric_logprobs_rejected(field_name):
599 """A non-numeric logprobs value must be a clean 400 validation error, not a
600 TypeError from the mode='before' comparison (which surfaces as HTTP 500)."""
601 with pytest.raises(VLLMValidationError, match=f"`{field_name}` must be an integer"):
602 ChatCompletionRequest(
603 model=MODEL_NAME,
604 messages=[{"role": "user", "content": "hello"}],
605 **{field_name: "2"},
606 )