main
py 678 lines 21.7 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import json
5
6 import openai
7 import pytest
8 import pytest_asyncio
9 from transformers import AutoProcessor
10
11 from tests.entrypoints.multimodal.conftest import TEST_IMAGE_ASSETS
12 from tests.utils import ROCM_EXTRA_ARGS, RemoteOpenAIServer
13 from vllm.multimodal.media import MediaWithBytes
14 from vllm.multimodal.utils import encode_image_url, fetch_image
15 from vllm.platforms import current_platform
16
17 MODEL_NAME = "microsoft/Phi-3.5-vision-instruct"
18 MAXIMUM_IMAGES = 2
19
20 # Required terms for beam search validation
21 # Each entry is a list of term groups - ALL groups must match
22 # Each group is a list of alternatives - at least ONE term in the group must appear
23 # This provides semantic validation while allowing wording variation
24 REQUIRED_BEAM_SEARCH_TERMS = [
25 # Boardwalk image: must have "boardwalk" AND ("wooden" or "wood")
26 [["boardwalk"], ["wooden", "wood"]],
27 # Parrots image: must have ("parrot" or "bird") AND "two"
28 [["parrot", "bird"], ["two"]],
29 # Venn diagram: must have "venn" AND "diagram"
30 [["venn"], ["diagram"]],
31 # Gradient image: must have "gradient" AND ("color" or "spectrum")
32 [["gradient"], ["color", "spectrum"]],
33 ]
34
35
36 def check_output_matches_terms(content: str, term_groups: list[list[str]]) -> bool:
37 """Check if content matches all required term groups.
38 Each term group requires at least one of its terms to be present.
39 All term groups must be satisfied.
40 """
41 content_lower = content.lower()
42 return all(
43 any(term.lower() in content_lower for term in group) for group in term_groups
44 )
45
46
47 def assert_non_empty_content(chat_completion, *, context: str = "") -> str:
48 """Assert the first choice has non-empty string content; return it.
49
50 Provides a detailed failure message including the full ChatCompletion
51 response so flaky / model-quality issues are easy to diagnose.
52 """
53 prefix = f"[{context}] " if context else ""
54 choice = chat_completion.choices[0]
55 content = choice.message.content
56
57 assert content is not None, (
58 f"{prefix}Expected non-None content but got None. "
59 f"finish_reason={choice.finish_reason!r}, "
60 f"full message={choice.message!r}, "
61 f"usage={chat_completion.usage!r}"
62 )
63 assert isinstance(content, str), (
64 f"{prefix}Expected str content, got {type(content).__name__}: {content!r}"
65 )
66 assert len(content) > 0, (
67 f"{prefix}Expected non-empty content but got empty string. "
68 f"finish_reason={choice.finish_reason!r}, "
69 f"full message={choice.message!r}, "
70 f"usage={chat_completion.usage!r}"
71 )
72 return content
73
74
75 @pytest.fixture(scope="module")
76 def server():
77 args = [
78 "--runner",
79 "generate",
80 "--max-model-len",
81 "2048",
82 "--max-num-seqs",
83 "5",
84 "--enforce-eager",
85 "--trust-remote-code",
86 "--limit-mm-per-prompt",
87 json.dumps({"image": MAXIMUM_IMAGES}),
88 *ROCM_EXTRA_ARGS,
89 ]
90
91 # ROCm: Increase timeouts to handle potential network delays and slower
92 # video processing when downloading multiple videos from external sources
93 env_overrides = {
94 **(
95 {
96 "VLLM_VIDEO_FETCH_TIMEOUT": "120",
97 "VLLM_ENGINE_ITERATION_TIMEOUT_S": "300",
98 }
99 if current_platform.is_rocm()
100 else {}
101 ),
102 }
103
104 with RemoteOpenAIServer(MODEL_NAME, args, env_dict=env_overrides) as remote_server:
105 yield remote_server
106
107
108 @pytest_asyncio.fixture
109 async def client(server):
110 async with server.get_async_client() as async_client:
111 yield async_client
112
113
114 @pytest.fixture(scope="session")
115 def url_encoded_image(local_asset_server) -> dict[str, str]:
116 return {
117 image_asset: encode_image_url(local_asset_server.get_image_asset(image_asset))
118 for image_asset in TEST_IMAGE_ASSETS
119 }
120
121
122 def dummy_messages_from_image_url(
123 image_urls: str | list[str],
124 content_text: str = "What's in this image?",
125 ):
126 if isinstance(image_urls, str):
127 image_urls = [image_urls]
128
129 return [
130 {
131 "role": "user",
132 "content": [
133 *(
134 {"type": "image_url", "image_url": {"url": image_url}}
135 for image_url in image_urls
136 ),
137 {"type": "text", "text": content_text},
138 ],
139 }
140 ]
141
142
143 def describe_image_messages(
144 image_url: str, *, extra_image_fields: dict | None = None
145 ) -> list[dict]:
146 """Build the system + user messages used by the completions-with-image
147 family of tests. *extra_image_fields* is merged into the top-level
148 image content block (for uuid / bad-key tests)."""
149 image_block: dict = {
150 "type": "image_url",
151 "image_url": {"url": image_url},
152 }
153 if extra_image_fields:
154 image_block.update(extra_image_fields)
155
156 return [
157 {"role": "system", "content": "You are a helpful assistant."},
158 {
159 "role": "user",
160 "content": [
161 {"type": "text", "text": "Describe this image."},
162 image_block,
163 ],
164 },
165 ]
166
167
168 async def complete_and_check(
169 client: openai.AsyncOpenAI,
170 model_name: str,
171 messages: list[dict],
172 *,
173 context: str,
174 max_completion_tokens: int = 50,
175 temperature: float = 0.0,
176 ) -> str:
177 """Run a chat completion and assert the output is non-empty.
178 Returns the content string."""
179 chat_completion = await client.chat.completions.create(
180 model=model_name,
181 messages=messages,
182 max_completion_tokens=max_completion_tokens,
183 temperature=temperature,
184 )
185 return assert_non_empty_content(chat_completion, context=context)
186
187
188 def get_hf_prompt_tokens(model_name, content, image_url):
189 processor = AutoProcessor.from_pretrained(
190 model_name, trust_remote_code=True, num_crops=4
191 )
192
193 placeholder = "<|image_1|>\n"
194 messages = [
195 {
196 "role": "user",
197 "content": f"{placeholder}{content}",
198 }
199 ]
200 image = fetch_image(image_url)
201 # Unwrap MediaWithBytes if present
202 if isinstance(image, MediaWithBytes):
203 image = image.media
204 images = [image]
205
206 prompt = processor.tokenizer.apply_chat_template(
207 messages, tokenize=False, add_generation_prompt=True
208 )
209 inputs = processor(prompt, images, return_tensors="pt")
210
211 return inputs.input_ids.shape[1]
212
213
214 @pytest.mark.asyncio
215 @pytest.mark.parametrize("model_name", [MODEL_NAME])
216 @pytest.mark.parametrize("image_url", TEST_IMAGE_ASSETS, indirect=True)
217 async def test_single_chat_session_image(
218 client: openai.AsyncOpenAI, model_name: str, image_url: str
219 ):
220 content_text = "What's in this image?"
221 messages = dummy_messages_from_image_url(image_url, content_text)
222
223 max_completion_tokens = 10
224 chat_completion = await client.chat.completions.create(
225 model=model_name,
226 messages=messages,
227 max_completion_tokens=max_completion_tokens,
228 logprobs=True,
229 temperature=0.0,
230 top_logprobs=5,
231 )
232 assert len(chat_completion.choices) == 1, (
233 f"Expected 1 choice, got {len(chat_completion.choices)}"
234 )
235
236 choice = chat_completion.choices[0]
237 assert choice.finish_reason == "length", (
238 f"Expected finish_reason='length' (capped at {max_completion_tokens} "
239 f"tokens), got {choice.finish_reason!r}. "
240 f"content={choice.message.content!r}"
241 )
242
243 hf_prompt_tokens = get_hf_prompt_tokens(model_name, content_text, image_url)
244 expected_usage = openai.types.CompletionUsage(
245 completion_tokens=max_completion_tokens,
246 prompt_tokens=hf_prompt_tokens,
247 total_tokens=hf_prompt_tokens + max_completion_tokens,
248 )
249 assert chat_completion.usage == expected_usage, (
250 f"Usage mismatch: got {chat_completion.usage!r}, expected {expected_usage!r}"
251 )
252
253 message = choice.message
254 assert message.content is not None and len(message.content) >= 10, (
255 f"Expected content with >=10 chars, got {message.content!r}"
256 )
257 assert message.role == "assistant", (
258 f"Expected role='assistant', got {message.role!r}"
259 )
260
261 messages.append({"role": "assistant", "content": message.content})
262
263 # test multi-turn dialogue
264 messages.append({"role": "user", "content": "express your result in json"})
265 await complete_and_check(
266 client,
267 model_name,
268 messages,
269 context=f"multi-turn follow-up for {image_url}",
270 max_completion_tokens=10,
271 )
272
273
274 @pytest.mark.asyncio
275 @pytest.mark.parametrize("model_name", [MODEL_NAME])
276 @pytest.mark.parametrize("image_url", TEST_IMAGE_ASSETS, indirect=True)
277 async def test_error_on_invalid_image_url_type(
278 client: openai.AsyncOpenAI, model_name: str, image_url: str
279 ):
280 content_text = "What's in this image?"
281 messages = [
282 {
283 "role": "user",
284 "content": [
285 {"type": "image_url", "image_url": image_url},
286 {"type": "text", "text": content_text},
287 ],
288 }
289 ]
290
291 # image_url should be a dict {"url": "some url"}, not directly a string
292 with pytest.raises(openai.BadRequestError):
293 await client.chat.completions.create(
294 model=model_name,
295 messages=messages,
296 max_completion_tokens=10,
297 temperature=0.0,
298 )
299
300
301 @pytest.mark.asyncio
302 @pytest.mark.parametrize("model_name", [MODEL_NAME])
303 @pytest.mark.parametrize("image_url", TEST_IMAGE_ASSETS, indirect=True)
304 async def test_single_chat_session_image_beamsearch(
305 client: openai.AsyncOpenAI, model_name: str, image_url: str
306 ):
307 content_text = "What's in this image?"
308 messages = dummy_messages_from_image_url(image_url, content_text)
309
310 chat_completion = await client.chat.completions.create(
311 model=model_name,
312 messages=messages,
313 n=2,
314 max_completion_tokens=10,
315 logprobs=True,
316 top_logprobs=5,
317 extra_body=dict(use_beam_search=True),
318 )
319 assert len(chat_completion.choices) == 2, (
320 f"Expected 2 beam search choices, got {len(chat_completion.choices)}"
321 )
322
323 content_0 = chat_completion.choices[0].message.content
324 content_1 = chat_completion.choices[1].message.content
325 assert content_0 != content_1, (
326 f"Beam search should produce different outputs for {image_url}, "
327 f"but both returned: {content_0!r}"
328 )
329
330
331 @pytest.mark.asyncio
332 @pytest.mark.parametrize("model_name", [MODEL_NAME])
333 @pytest.mark.parametrize("raw_image_url", TEST_IMAGE_ASSETS)
334 @pytest.mark.parametrize("image_url", TEST_IMAGE_ASSETS, indirect=True)
335 async def test_single_chat_session_image_base64encoded(
336 client: openai.AsyncOpenAI,
337 model_name: str,
338 raw_image_url: str,
339 image_url: str,
340 url_encoded_image: dict[str, str],
341 ):
342 content_text = "What's in this image?"
343 messages = dummy_messages_from_image_url(
344 url_encoded_image[raw_image_url],
345 content_text,
346 )
347
348 max_completion_tokens = 10
349 # test single completion
350 chat_completion = await client.chat.completions.create(
351 model=model_name,
352 messages=messages,
353 max_completion_tokens=max_completion_tokens,
354 logprobs=True,
355 temperature=0.0,
356 top_logprobs=5,
357 )
358 assert len(chat_completion.choices) == 1, (
359 f"Expected 1 choice, got {len(chat_completion.choices)}"
360 )
361
362 choice = chat_completion.choices[0]
363 assert choice.finish_reason == "length", (
364 f"Expected finish_reason='length', got {choice.finish_reason!r}. "
365 f"content={choice.message.content!r}"
366 )
367
368 hf_prompt_tokens = get_hf_prompt_tokens(model_name, content_text, image_url)
369 expected_usage = openai.types.CompletionUsage(
370 completion_tokens=max_completion_tokens,
371 prompt_tokens=hf_prompt_tokens,
372 total_tokens=hf_prompt_tokens + max_completion_tokens,
373 )
374 assert chat_completion.usage == expected_usage, (
375 f"Usage mismatch: got {chat_completion.usage!r}, expected {expected_usage!r}"
376 )
377
378 message = choice.message
379 assert message.content is not None and len(message.content) >= 10, (
380 f"Expected content with >=10 chars, got {message.content!r}"
381 )
382 assert message.role == "assistant", (
383 f"Expected role='assistant', got {message.role!r}"
384 )
385
386 messages.append({"role": "assistant", "content": message.content})
387
388 # test multi-turn dialogue
389 messages.append({"role": "user", "content": "express your result in json"})
390 await complete_and_check(
391 client,
392 model_name,
393 messages,
394 context=f"multi-turn base64 follow-up for {raw_image_url}",
395 max_completion_tokens=10,
396 temperature=0.0,
397 )
398
399
400 @pytest.mark.asyncio
401 @pytest.mark.parametrize("model_name", [MODEL_NAME])
402 @pytest.mark.parametrize("image_idx", list(range(len(TEST_IMAGE_ASSETS))))
403 async def test_single_chat_session_image_base64encoded_beamsearch(
404 client: openai.AsyncOpenAI,
405 model_name: str,
406 image_idx: int,
407 url_encoded_image: dict[str, str],
408 ):
409 # NOTE: This test validates that we pass MM data through beam search
410 raw_image_url = TEST_IMAGE_ASSETS[image_idx]
411 required_terms = REQUIRED_BEAM_SEARCH_TERMS[image_idx]
412
413 messages = dummy_messages_from_image_url(url_encoded_image[raw_image_url])
414
415 chat_completion = await client.chat.completions.create(
416 model=model_name,
417 messages=messages,
418 n=2,
419 max_completion_tokens=10,
420 temperature=0.0,
421 extra_body=dict(use_beam_search=True),
422 )
423 assert len(chat_completion.choices) == 2, (
424 f"Expected 2 beam search choices for image {image_idx} "
425 f"({raw_image_url}), got {len(chat_completion.choices)}"
426 )
427
428 # Verify beam search produces two different non-empty outputs
429 content_0 = chat_completion.choices[0].message.content
430 content_1 = chat_completion.choices[1].message.content
431
432 # Emit beam search outputs for debugging
433 print(
434 f"Beam search outputs for image {image_idx} ({raw_image_url}): "
435 f"Output 0: {content_0!r}, Output 1: {content_1!r}"
436 )
437
438 assert content_0, (
439 f"First beam output is empty for image {image_idx} ({raw_image_url}). "
440 f"finish_reason={chat_completion.choices[0].finish_reason!r}"
441 )
442 assert content_1, (
443 f"Second beam output is empty for image {image_idx} "
444 f"({raw_image_url}). "
445 f"finish_reason={chat_completion.choices[1].finish_reason!r}"
446 )
447 assert content_0 != content_1, (
448 f"Beam search produced identical outputs for image {image_idx} "
449 f"({raw_image_url}): {content_0!r}"
450 )
451
452 # Verify each output contains the required terms for this image
453 for i, content in enumerate([content_0, content_1]):
454 assert check_output_matches_terms(content, required_terms), (
455 f"Beam output {i} for image {image_idx} ({raw_image_url}) "
456 f"doesn't match required terms.\n"
457 f" content: {content!r}\n"
458 f" required (all groups, >=1 per group): {required_terms}"
459 )
460
461
462 @pytest.mark.asyncio
463 @pytest.mark.parametrize("model_name", [MODEL_NAME])
464 @pytest.mark.parametrize("image_url", TEST_IMAGE_ASSETS, indirect=True)
465 async def test_chat_streaming_image(
466 client: openai.AsyncOpenAI, model_name: str, image_url: str
467 ):
468 messages = dummy_messages_from_image_url(image_url)
469
470 # test single completion
471 chat_completion = await client.chat.completions.create(
472 model=model_name,
473 messages=messages,
474 max_completion_tokens=10,
475 temperature=0.0,
476 )
477 output = chat_completion.choices[0].message.content
478 stop_reason = chat_completion.choices[0].finish_reason
479
480 # test streaming
481 stream = await client.chat.completions.create(
482 model=model_name,
483 messages=messages,
484 max_completion_tokens=10,
485 temperature=0.0,
486 stream=True,
487 )
488 chunks: list[str] = []
489 finish_reason_count = 0
490 async for chunk in stream:
491 delta = chunk.choices[0].delta
492 if delta.role:
493 assert delta.role == "assistant", (
494 f"Expected role='assistant' in stream delta, got {delta.role!r}"
495 )
496 if delta.content:
497 chunks.append(delta.content)
498 if chunk.choices[0].finish_reason is not None:
499 finish_reason_count += 1
500 # finish reason should only return in last block
501 assert finish_reason_count == 1, (
502 f"Expected exactly 1 finish_reason across stream chunks, "
503 f"got {finish_reason_count}"
504 )
505 assert chunk.choices[0].finish_reason == stop_reason, (
506 f"Stream finish_reason={chunk.choices[0].finish_reason!r} "
507 f"doesn't match non-stream finish_reason={stop_reason!r}"
508 )
509
510 streamed_text = "".join(chunks)
511 assert streamed_text == output, (
512 f"Streamed output doesn't match non-streamed for {image_url}.\n"
513 f" streamed: {streamed_text!r}\n"
514 f" non-streamed: {output!r}"
515 )
516
517
518 @pytest.mark.asyncio
519 @pytest.mark.parametrize("model_name", [MODEL_NAME])
520 @pytest.mark.parametrize(
521 "image_urls",
522 [TEST_IMAGE_ASSETS[:i] for i in range(2, len(TEST_IMAGE_ASSETS))],
523 indirect=True,
524 )
525 async def test_multi_image_input(
526 client: openai.AsyncOpenAI, model_name: str, image_urls: list[str]
527 ):
528 messages = dummy_messages_from_image_url(image_urls)
529
530 if len(image_urls) > MAXIMUM_IMAGES:
531 with pytest.raises(openai.BadRequestError): # test multi-image input
532 await client.chat.completions.create(
533 model=model_name,
534 messages=messages,
535 max_completion_tokens=10,
536 temperature=0.0,
537 )
538
539 # the server should still work afterwards
540 completion = await client.completions.create(
541 model=model_name,
542 prompt=[0, 0, 0, 0, 0],
543 max_tokens=5,
544 temperature=0.0,
545 )
546 assert completion.choices[0].text is not None, (
547 "Server failed to produce output after rejecting over-limit "
548 "multi-image request"
549 )
550 else:
551 await complete_and_check(
552 client,
553 model_name,
554 messages,
555 context=f"multi-image input ({len(image_urls)} images)",
556 max_completion_tokens=10,
557 temperature=0.0,
558 )
559
560
561 @pytest.mark.asyncio
562 @pytest.mark.parametrize("model_name", [MODEL_NAME])
563 @pytest.mark.parametrize(
564 "image_urls",
565 [TEST_IMAGE_ASSETS[:i] for i in range(2, len(TEST_IMAGE_ASSETS))],
566 indirect=True,
567 )
568 async def test_completions_with_image(
569 client: openai.AsyncOpenAI,
570 model_name: str,
571 image_urls: list[str],
572 ):
573 for image_url in image_urls:
574 messages = describe_image_messages(image_url)
575 await complete_and_check(
576 client,
577 model_name,
578 messages,
579 context=f"completions_with_image url={image_url}",
580 )
581
582
583 @pytest.mark.asyncio
584 @pytest.mark.parametrize("model_name", [MODEL_NAME])
585 @pytest.mark.parametrize(
586 "image_urls",
587 [TEST_IMAGE_ASSETS[:i] for i in range(2, len(TEST_IMAGE_ASSETS))],
588 indirect=True,
589 )
590 async def test_completions_with_image_with_uuid(
591 client: openai.AsyncOpenAI,
592 model_name: str,
593 image_urls: list[str],
594 ):
595 for image_url in image_urls:
596 messages = describe_image_messages(
597 image_url,
598 extra_image_fields={"uuid": image_url},
599 )
600 await complete_and_check(
601 client,
602 model_name,
603 messages,
604 context=f"uuid first request url={image_url}",
605 )
606
607 cached_messages: list[dict] = [
608 {"role": "system", "content": "You are a helpful assistant."},
609 {
610 "role": "user",
611 "content": [
612 {"type": "text", "text": "Describe this image."},
613 {"type": "image_url", "image_url": {}, "uuid": image_url},
614 ],
615 },
616 ]
617 await complete_and_check(
618 client,
619 model_name,
620 cached_messages,
621 context=f"uuid cached (empty image) uuid={image_url}",
622 )
623
624
625 @pytest.mark.asyncio
626 @pytest.mark.parametrize("model_name", [MODEL_NAME])
627 async def test_completions_with_empty_image_with_uuid_without_cache_hit(
628 client: openai.AsyncOpenAI,
629 model_name: str,
630 ):
631 with pytest.raises(openai.BadRequestError):
632 await client.chat.completions.create(
633 messages=[
634 {"role": "system", "content": "You are a helpful assistant."},
635 {
636 "role": "user",
637 "content": [
638 {"type": "text", "text": "Describe this image."},
639 {
640 "type": "image_url",
641 "image_url": {},
642 "uuid": "uuid_not_previously_seen",
643 },
644 ],
645 },
646 ],
647 model=model_name,
648 )
649
650
651 @pytest.mark.asyncio
652 @pytest.mark.parametrize("model_name", [MODEL_NAME])
653 @pytest.mark.parametrize(
654 "image_urls",
655 [TEST_IMAGE_ASSETS[:i] for i in range(2, len(TEST_IMAGE_ASSETS))],
656 indirect=True,
657 )
658 async def test_completions_with_image_with_incorrect_uuid_format(
659 client: openai.AsyncOpenAI,
660 model_name: str,
661 image_urls: list[str],
662 ):
663 for image_url in image_urls:
664 messages = describe_image_messages(
665 image_url,
666 extra_image_fields={
667 "also_incorrect_uuid_key": image_url,
668 },
669 )
670 # Inject the bad key inside image_url dict too
671 messages[1]["content"][1]["image_url"]["incorrect_uuid_key"] = image_url
672
673 await complete_and_check(
674 client,
675 model_name,
676 messages,
677 context=f"incorrect uuid format url={image_url}",
678 )