main
py 517 lines 15.8 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
10 from tests.utils import RemoteOpenAIServer
11 from vllm.assets.base import VLLM_S3_BUCKET_URL
12 from vllm.multimodal.utils import encode_video_url, fetch_video
13 from vllm.platforms import current_platform
14
15 MODEL_NAME = "llava-hf/llava-onevision-qwen2-0.5b-ov-hf"
16 MAXIMUM_VIDEOS = 3
17
18 TEST_VIDEO_URLS = [
19 f"{VLLM_S3_BUCKET_URL}/multimodal_asset/slow_traffic_small.mp4",
20 f"{VLLM_S3_BUCKET_URL}/multimodal_asset/vtest.avi",
21 f"{VLLM_S3_BUCKET_URL}/multimodal_asset/Megamind.avi",
22 ]
23
24
25 @pytest.fixture(scope="module")
26 def server():
27 args = [
28 "--runner",
29 "generate",
30 "--max-model-len",
31 "32768",
32 "--max-num-seqs",
33 "2",
34 "--enforce-eager",
35 "--trust-remote-code",
36 "--limit-mm-per-prompt",
37 json.dumps({"video": MAXIMUM_VIDEOS}),
38 "--media-io-kwargs",
39 json.dumps({"video": {"num_frames": 32}}),
40 "--trust-request-mm-kwargs",
41 ]
42
43 # ROCm: Increase timeouts to handle potential network delays and slower
44 # video processing when downloading multiple videos from external sources
45 env_overrides = {}
46 if current_platform.is_rocm():
47 env_overrides = {
48 "VLLM_VIDEO_FETCH_TIMEOUT": "120",
49 "VLLM_ENGINE_ITERATION_TIMEOUT_S": "300",
50 }
51
52 with RemoteOpenAIServer(MODEL_NAME, args, env_dict=env_overrides) as remote_server:
53 yield remote_server
54
55
56 @pytest_asyncio.fixture
57 async def client(server):
58 async with server.get_async_client() as async_client:
59 yield async_client
60
61
62 @pytest.fixture(scope="session")
63 def url_encoded_video() -> dict[str, str]:
64 return {
65 video_url: encode_video_url(fetch_video(video_url)[0])
66 for video_url in TEST_VIDEO_URLS
67 }
68
69
70 def assert_non_empty_content(chat_completion, *, context: str = "") -> str:
71 """Assert the first choice has non-empty string content; return it."""
72 prefix = f"[{context}] " if context else ""
73 choice = chat_completion.choices[0]
74 content = choice.message.content
75
76 assert content is not None, (
77 f"{prefix}Expected non-None content but got None. "
78 f"finish_reason={choice.finish_reason!r}, "
79 f"full message={choice.message!r}, "
80 f"usage={chat_completion.usage!r}"
81 )
82 assert isinstance(content, str), (
83 f"{prefix}Expected str content, got {type(content).__name__}: {content!r}"
84 )
85 assert len(content) > 0, (
86 f"{prefix}Expected non-empty content but got empty string. "
87 f"finish_reason={choice.finish_reason!r}, "
88 f"full message={choice.message!r}, "
89 f"usage={chat_completion.usage!r}"
90 )
91 return content
92
93
94 def describe_video_messages(
95 video_url: str | None, *, extra_video_fields: dict | None = None
96 ) -> list[dict]:
97 """Build the system + user messages used by the completions-with-video
98 family of tests. *extra_video_fields* is merged into the top-level
99 video content block (for uuid / bad-key tests).
100
101 Pass ``None`` for *video_url* to omit media (uuid-backed cache hits).
102 """
103 video_block: dict = {
104 "type": "video_url",
105 "video_url": {} if video_url is None else {"url": video_url},
106 }
107 if extra_video_fields:
108 video_block.update(extra_video_fields)
109
110 return [
111 {"role": "system", "content": "You are a helpful assistant."},
112 {
113 "role": "user",
114 "content": [
115 {"type": "text", "text": "Describe this video."},
116 video_block,
117 ],
118 },
119 ]
120
121
122 async def complete_and_check(
123 client: openai.AsyncOpenAI,
124 model_name: str,
125 messages: list[dict],
126 *,
127 context: str,
128 max_completion_tokens: int = 50,
129 temperature: float = 0.0,
130 ) -> str:
131 """Run a chat completion and assert the output is non-empty."""
132 chat_completion = await client.chat.completions.create(
133 model=model_name,
134 messages=messages,
135 max_completion_tokens=max_completion_tokens,
136 temperature=temperature,
137 )
138 return assert_non_empty_content(chat_completion, context=context)
139
140
141 def dummy_messages_from_video_url(
142 video_urls: str | list[str],
143 content_text: str = "What's in this video?",
144 ):
145 if isinstance(video_urls, str):
146 video_urls = [video_urls]
147
148 return [
149 {
150 "role": "user",
151 "content": [
152 *(
153 {"type": "video_url", "video_url": {"url": video_url}}
154 for video_url in video_urls
155 ),
156 {"type": "text", "text": content_text},
157 ],
158 }
159 ]
160
161
162 @pytest.mark.asyncio
163 @pytest.mark.parametrize("model_name", [MODEL_NAME])
164 @pytest.mark.parametrize("video_url", TEST_VIDEO_URLS)
165 async def test_single_chat_session_video(
166 client: openai.AsyncOpenAI, model_name: str, video_url: str
167 ):
168 messages = dummy_messages_from_video_url(video_url)
169
170 # test single completion
171 chat_completion = await client.chat.completions.create(
172 model=model_name,
173 messages=messages,
174 max_completion_tokens=10,
175 logprobs=True,
176 temperature=0.0,
177 top_logprobs=5,
178 )
179 assert len(chat_completion.choices) == 1
180
181 choice = chat_completion.choices[0]
182 assert choice.finish_reason == "length"
183 assert chat_completion.usage == openai.types.CompletionUsage(
184 completion_tokens=10, prompt_tokens=6287, total_tokens=6297
185 )
186
187 message = choice.message
188 message = chat_completion.choices[0].message
189 assert message.content is not None and len(message.content) >= 10
190 assert message.role == "assistant"
191 messages.append({"role": "assistant", "content": message.content})
192
193 # test multi-turn dialogue
194 messages.append({"role": "user", "content": "express your result in json"})
195 chat_completion = await client.chat.completions.create(
196 model=model_name,
197 messages=messages,
198 max_completion_tokens=10,
199 )
200 message = chat_completion.choices[0].message
201 assert message.content is not None and len(message.content) >= 0
202
203
204 @pytest.mark.asyncio
205 @pytest.mark.parametrize("model_name", [MODEL_NAME])
206 @pytest.mark.parametrize("video_url", [TEST_VIDEO_URLS[0]])
207 async def test_request_media_io_kwargs_override_uses_fewer_video_frames(
208 client: openai.AsyncOpenAI, model_name: str, video_url: str
209 ):
210 messages = dummy_messages_from_video_url(video_url)
211
212 default_resp = await client.chat.completions.create(
213 model=model_name,
214 messages=messages,
215 max_completion_tokens=1,
216 temperature=0.0,
217 )
218 override_resp = await client.chat.completions.create(
219 model=model_name,
220 messages=messages,
221 max_completion_tokens=1,
222 temperature=0.0,
223 extra_body={
224 "media_io_kwargs": {
225 "video": {
226 "num_frames": 4,
227 }
228 }
229 },
230 )
231
232 assert default_resp.usage is not None
233 assert override_resp.usage is not None
234 assert override_resp.usage.prompt_tokens < default_resp.usage.prompt_tokens
235
236
237 @pytest.mark.asyncio
238 @pytest.mark.parametrize("model_name", [MODEL_NAME])
239 @pytest.mark.parametrize("video_url", [TEST_VIDEO_URLS[0]])
240 async def test_invalid_num_frames_request_recoverable(
241 client: openai.AsyncOpenAI, model_name: str, video_url: str
242 ):
243 messages = dummy_messages_from_video_url(video_url)
244
245 with pytest.raises((openai.BadRequestError, openai.APIStatusError)):
246 await client.chat.completions.create(
247 model=model_name,
248 messages=messages,
249 max_completion_tokens=1,
250 temperature=0.0,
251 extra_body={
252 "media_io_kwargs": {
253 "video": {
254 "num_frames": "invalid",
255 }
256 }
257 },
258 )
259
260 # Server should still handle subsequent requests after the failed one.
261 recovery_resp = await client.chat.completions.create(
262 model=model_name,
263 messages=messages,
264 max_completion_tokens=1,
265 temperature=0.0,
266 )
267 recovery_msg = recovery_resp.choices[0].message
268 assert recovery_msg.content is not None and len(recovery_msg.content) >= 0
269
270
271 @pytest.mark.asyncio
272 @pytest.mark.parametrize("model_name", [MODEL_NAME])
273 @pytest.mark.parametrize("video_url", TEST_VIDEO_URLS)
274 async def test_error_on_invalid_video_url_type(
275 client: openai.AsyncOpenAI, model_name: str, video_url: str
276 ):
277 messages = [
278 {
279 "role": "user",
280 "content": [
281 {"type": "video_url", "video_url": video_url},
282 {"type": "text", "text": "What's in this video?"},
283 ],
284 }
285 ]
286
287 # video_url should be a dict {"url": "some url"}, not directly a string
288 with pytest.raises(openai.BadRequestError):
289 _ = await client.chat.completions.create(
290 model=model_name,
291 messages=messages,
292 max_completion_tokens=10,
293 temperature=0.0,
294 )
295
296
297 @pytest.mark.asyncio
298 @pytest.mark.parametrize("model_name", [MODEL_NAME])
299 @pytest.mark.parametrize("video_url", TEST_VIDEO_URLS)
300 async def test_single_chat_session_video_beamsearch(
301 client: openai.AsyncOpenAI, model_name: str, video_url: str
302 ):
303 messages = dummy_messages_from_video_url(video_url)
304
305 chat_completion = await client.chat.completions.create(
306 model=model_name,
307 messages=messages,
308 n=2,
309 max_completion_tokens=10,
310 logprobs=True,
311 top_logprobs=5,
312 extra_body=dict(use_beam_search=True),
313 )
314 assert len(chat_completion.choices) == 2
315 assert (
316 chat_completion.choices[0].message.content
317 != chat_completion.choices[1].message.content
318 )
319
320
321 @pytest.mark.asyncio
322 @pytest.mark.parametrize("model_name", [MODEL_NAME])
323 @pytest.mark.parametrize("video_url", TEST_VIDEO_URLS)
324 async def test_single_chat_session_video_base64encoded(
325 client: openai.AsyncOpenAI,
326 model_name: str,
327 video_url: str,
328 url_encoded_video: dict[str, str],
329 ):
330 messages = dummy_messages_from_video_url(url_encoded_video[video_url])
331
332 # test single completion
333 chat_completion = await client.chat.completions.create(
334 model=model_name,
335 messages=messages,
336 max_completion_tokens=10,
337 logprobs=True,
338 temperature=0.0,
339 top_logprobs=5,
340 )
341 assert len(chat_completion.choices) == 1
342
343 choice = chat_completion.choices[0]
344 assert choice.finish_reason == "length"
345 assert chat_completion.usage == openai.types.CompletionUsage(
346 completion_tokens=10, prompt_tokens=6287, total_tokens=6297
347 )
348
349 message = choice.message
350 message = chat_completion.choices[0].message
351 assert message.content is not None and len(message.content) >= 10
352 assert message.role == "assistant"
353 messages.append({"role": "assistant", "content": message.content})
354
355 # test multi-turn dialogue
356 messages.append({"role": "user", "content": "express your result in json"})
357 chat_completion = await client.chat.completions.create(
358 model=model_name,
359 messages=messages,
360 max_completion_tokens=10,
361 temperature=0.0,
362 )
363 message = chat_completion.choices[0].message
364 assert message.content is not None and len(message.content) >= 0
365
366
367 @pytest.mark.asyncio
368 @pytest.mark.parametrize("model_name", [MODEL_NAME])
369 @pytest.mark.parametrize("video_url", TEST_VIDEO_URLS)
370 async def test_single_chat_session_video_base64encoded_beamsearch(
371 client: openai.AsyncOpenAI,
372 model_name: str,
373 video_url: str,
374 url_encoded_video: dict[str, str],
375 ):
376 messages = dummy_messages_from_video_url(url_encoded_video[video_url])
377
378 chat_completion = await client.chat.completions.create(
379 model=model_name,
380 messages=messages,
381 n=2,
382 max_completion_tokens=10,
383 extra_body=dict(use_beam_search=True),
384 )
385 assert len(chat_completion.choices) == 2
386 assert (
387 chat_completion.choices[0].message.content
388 != chat_completion.choices[1].message.content
389 )
390
391
392 @pytest.mark.asyncio
393 @pytest.mark.parametrize("model_name", [MODEL_NAME])
394 @pytest.mark.parametrize("video_url", TEST_VIDEO_URLS)
395 async def test_chat_streaming_video(
396 client: openai.AsyncOpenAI, model_name: str, video_url: str
397 ):
398 messages = dummy_messages_from_video_url(video_url)
399
400 # test single completion
401 chat_completion = await client.chat.completions.create(
402 model=model_name,
403 messages=messages,
404 max_completion_tokens=10,
405 temperature=0.0,
406 )
407 output = chat_completion.choices[0].message.content
408 stop_reason = chat_completion.choices[0].finish_reason
409
410 # test streaming
411 stream = await client.chat.completions.create(
412 model=model_name,
413 messages=messages,
414 max_completion_tokens=10,
415 temperature=0.0,
416 stream=True,
417 )
418 chunks: list[str] = []
419 finish_reason_count = 0
420 async for chunk in stream:
421 delta = chunk.choices[0].delta
422 if delta.role:
423 assert delta.role == "assistant"
424 if delta.content:
425 chunks.append(delta.content)
426 if chunk.choices[0].finish_reason is not None:
427 finish_reason_count += 1
428 # finish reason should only return in last block
429 assert finish_reason_count == 1
430 assert chunk.choices[0].finish_reason == stop_reason
431 assert delta.content
432 assert "".join(chunks) == output
433
434
435 @pytest.mark.asyncio
436 @pytest.mark.parametrize("model_name", [MODEL_NAME])
437 @pytest.mark.parametrize(
438 "video_urls", [TEST_VIDEO_URLS[:i] for i in range(2, len(TEST_VIDEO_URLS))]
439 )
440 @pytest.mark.flaky(
441 reruns=2,
442 reruns_delay=5,
443 condition=current_platform.is_rocm(),
444 )
445 async def test_multi_video_input(
446 client: openai.AsyncOpenAI, model_name: str, video_urls: list[str]
447 ):
448 messages = dummy_messages_from_video_url(video_urls)
449
450 if len(video_urls) > MAXIMUM_VIDEOS:
451 with pytest.raises(openai.BadRequestError): # test multi-video input
452 await client.chat.completions.create(
453 model=model_name,
454 messages=messages,
455 max_completion_tokens=10,
456 temperature=0.0,
457 )
458
459 # the server should still work afterwards
460 completion = await client.completions.create(
461 model=model_name,
462 prompt=[0, 0, 0, 0, 0],
463 max_tokens=5,
464 temperature=0.0,
465 )
466 completion = completion.choices[0].text
467 assert completion is not None and len(completion) >= 0
468 else:
469 chat_completion = await client.chat.completions.create(
470 model=model_name,
471 messages=messages,
472 max_completion_tokens=10,
473 temperature=0.0,
474 )
475 message = chat_completion.choices[0].message
476 assert message.content is not None and len(message.content) >= 0
477
478
479 @pytest.mark.asyncio
480 @pytest.mark.parametrize("model_name", [MODEL_NAME])
481 async def test_omitted_video_url_requires_cached_uuid(
482 client: openai.AsyncOpenAI,
483 model_name: str,
484 ):
485 video_uuid = "test-video-uuid"
486 video_url = TEST_VIDEO_URLS[0]
487
488 omitted_video_messages = describe_video_messages(
489 None,
490 extra_video_fields={"uuid": video_uuid},
491 )
492
493 with pytest.raises(
494 openai.BadRequestError,
495 match="Cache miss for video at index 0 but data is not provided",
496 ):
497 await client.chat.completions.create(
498 model=model_name,
499 messages=omitted_video_messages,
500 )
501
502 await complete_and_check(
503 client,
504 model_name,
505 describe_video_messages(
506 video_url,
507 extra_video_fields={"uuid": video_uuid},
508 ),
509 context="populate UUID cache",
510 )
511
512 await complete_and_check(
513 client,
514 model_name,
515 omitted_video_messages,
516 context="reuse cached video without URL",
517 )