main
py 3,198 lines 97.4 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import asyncio
5 import warnings
6 from collections.abc import Mapping
7 from typing import Literal
8 from unittest.mock import MagicMock
9
10 import pytest
11 import torch
12
13 from vllm.assets.audio import AudioAsset
14 from vllm.assets.image import ImageAsset
15 from vllm.assets.video import VideoAsset
16 from vllm.config import ModelConfig
17 from vllm.entrypoints.chat_utils import (
18 MEDIA_CONNECTOR_REGISTRY,
19 AsyncMultiModalItemTracker,
20 ChatCompletionMessageParam,
21 ConversationMessage,
22 _load_embeds_dict,
23 _parse_metadata_array,
24 _postprocess_messages,
25 parse_chat_messages,
26 parse_chat_messages_async,
27 validate_chat_template,
28 )
29 from vllm.exceptions import VLLMValidationError
30 from vllm.inputs import MultiModalDataDict, MultiModalUUIDDict
31 from vllm.multimodal.utils import (
32 encode_audio_url,
33 encode_image_url,
34 encode_video_url,
35 )
36 from vllm.utils.serial_utils import tensor2base64
37
38 KIMI_K2_5_MODEL_ID = "moonshotai/Kimi-K2.5"
39 PHI3V_MODEL_ID = "microsoft/Phi-3.5-vision-instruct"
40 QWEN2AUDIO_MODEL_ID = "Qwen/Qwen2-Audio-7B-Instruct"
41 QWEN25OMNI_MODEL_ID = "Qwen/Qwen2.5-Omni-7B"
42 MISTRAL_MODEL_ID = "mistralai/Mistral-Small-3.1-24B-Instruct-2503"
43
44
45 @pytest.mark.parametrize("values", [[1, 32, 48], [0.0, 0.12345678912345678]])
46 def test_json_metadata_preserves_numeric_precision(values):
47 tensor = _parse_metadata_array("grid", values, {"grid"})
48 assert tensor.tolist() == values
49 assert tensor.dtype == (
50 torch.float64 if isinstance(values[0], float) else torch.int64
51 )
52
53
54 @pytest.mark.parametrize(
55 "key,values",
56 [
57 ("image_embeds", [1, 2]),
58 ("grid", [True]),
59 ("grid", [float("nan")]),
60 ("grid", [float("inf")]),
61 ("grid", [[1, 2]]),
62 ("grid", [2**64]),
63 ],
64 )
65 def test_json_arrays_are_only_valid_numeric_metadata(key, values):
66 with pytest.raises(VLLMValidationError):
67 _parse_metadata_array(key, values, {"grid"})
68
69
70 @pytest.mark.asyncio
71 async def test_json_metadata_bypasses_tensor_deserialization():
72 from unittest.mock import AsyncMock
73
74 tensor = torch.ones(2, 3)
75 fetch = AsyncMock(return_value=tensor)
76 result = await _load_embeds_dict(
77 {"image_embeds": "legacy-base64", "image_grid_thw": [1, 2, 2]}, fetch
78 )
79 fetch.assert_awaited_once_with("legacy-base64")
80 assert result["image_embeds"] is tensor
81 assert result["image_grid_thw"] == [1, 2, 2]
82
83
84 @pytest.fixture(scope="function")
85 def kimi_k2_5_model_config():
86 return ModelConfig(
87 KIMI_K2_5_MODEL_ID,
88 runner="generate",
89 trust_remote_code=True,
90 limit_mm_per_prompt={
91 "image": 2,
92 },
93 )
94
95
96 @pytest.fixture(scope="function")
97 def phi3v_model_config():
98 return ModelConfig(
99 PHI3V_MODEL_ID,
100 runner="generate",
101 trust_remote_code=True,
102 limit_mm_per_prompt={
103 "image": 2,
104 },
105 )
106
107
108 @pytest.fixture(scope="function")
109 def phi3v_model_config_mm_interleaved():
110 return ModelConfig(
111 PHI3V_MODEL_ID,
112 runner="generate",
113 trust_remote_code=True,
114 interleave_mm_strings=True,
115 limit_mm_per_prompt={
116 "image": 2,
117 },
118 )
119
120
121 @pytest.fixture(scope="function")
122 def phi3v_model_config_image_embeds():
123 return ModelConfig(
124 PHI3V_MODEL_ID,
125 runner="generate",
126 trust_remote_code=True,
127 limit_mm_per_prompt={
128 "image": 2,
129 },
130 enable_mm_embeds=True,
131 )
132
133
134 @pytest.fixture(scope="function")
135 def qwen25omni_model_config_image_embeds():
136 return ModelConfig(
137 QWEN25OMNI_MODEL_ID,
138 runner="generate",
139 limit_mm_per_prompt={"image": 2},
140 enable_mm_embeds=True,
141 )
142
143
144 @pytest.fixture(scope="function")
145 def qwen25omni_model_config_video_embeds():
146 return ModelConfig(
147 QWEN25OMNI_MODEL_ID,
148 runner="generate",
149 limit_mm_per_prompt={"video": 2},
150 enable_mm_embeds=True,
151 )
152
153
154 @pytest.fixture(scope="function")
155 def qwen2_audio_model_config():
156 return ModelConfig(
157 QWEN2AUDIO_MODEL_ID,
158 runner="generate",
159 trust_remote_code=True,
160 limit_mm_per_prompt={
161 "audio": 1,
162 },
163 )
164
165
166 @pytest.fixture(scope="function")
167 def audio_embeds_model_config():
168 return ModelConfig(
169 QWEN2AUDIO_MODEL_ID,
170 runner="generate",
171 trust_remote_code=True,
172 limit_mm_per_prompt={
173 "audio": 2,
174 },
175 enable_mm_embeds=True,
176 )
177
178
179 @pytest.fixture(scope="function")
180 def qwen25omni_model_config_mm_interleaved():
181 return ModelConfig(
182 QWEN25OMNI_MODEL_ID,
183 runner="generate",
184 interleave_mm_strings=True,
185 limit_mm_per_prompt={
186 "image": 2,
187 "audio": 1,
188 "video": 1,
189 },
190 )
191
192
193 @pytest.fixture(scope="function")
194 def mistral_model_config():
195 return ModelConfig(
196 MISTRAL_MODEL_ID,
197 runner="generate",
198 limit_mm_per_prompt={
199 "image": 2,
200 },
201 )
202
203
204 @pytest.fixture(scope="module")
205 def image_url():
206 image = ImageAsset("cherry_blossom")
207 return encode_image_url(image.pil_image)
208
209
210 @pytest.fixture(scope="module")
211 def video_url():
212 video = VideoAsset("baby_reading", 1)
213 return encode_video_url(video.np_ndarrays)
214
215
216 @pytest.fixture(scope="module")
217 def audio_url():
218 audio = AudioAsset("mary_had_lamb")
219 return encode_audio_url(*audio.audio_and_sample_rate)
220
221
222 def _assert_mm_data_is_image_input(
223 mm_data: MultiModalDataDict | None,
224 image_count: int,
225 skipped_image_indices: list | None = None,
226 ) -> None:
227 assert mm_data is not None
228 assert set(mm_data.keys()) == {"image"}
229
230 image_data = mm_data.get("image")
231 assert image_data is not None
232
233 assert isinstance(image_data, list) and len(image_data) == image_count
234 if skipped_image_indices is not None:
235 for i in skipped_image_indices:
236 assert image_data[i] is None
237
238
239 def _assert_mm_data_is_vision_chunk_input(
240 mm_data: MultiModalDataDict | None,
241 vision_chunk_count: int,
242 ) -> None:
243 assert mm_data is not None
244 assert set(mm_data.keys()) == {"vision_chunk"}
245
246 vision_chunk_data = mm_data.get("vision_chunk")
247 assert vision_chunk_data is not None
248
249 assert (
250 isinstance(vision_chunk_data, list)
251 and len(vision_chunk_data) == vision_chunk_count
252 )
253
254
255 def _assert_mm_uuids(
256 mm_uuids: MultiModalUUIDDict | None,
257 media_count: int,
258 expected_uuids: list[str | None],
259 modality: str = "image",
260 ) -> None:
261 if len(expected_uuids) > 0:
262 assert mm_uuids is not None
263 assert modality in mm_uuids
264
265 image_uuids = mm_uuids.get(modality)
266 assert image_uuids is not None
267
268 assert isinstance(image_uuids, list) and len(image_uuids) == media_count
269
270 assert image_uuids == expected_uuids
271 else:
272 assert mm_uuids is None
273
274
275 ModalityType = Literal["image", "video", "audio"]
276 MultiModalDataCounts = Mapping[ModalityType, int]
277
278
279 def _assert_mm_data_inputs(
280 mm_data: MultiModalDataDict | None,
281 data_count: MultiModalDataCounts,
282 skipped_media_indices: dict[str, list] | None = None, # modality -> list[int]
283 ) -> None:
284 assert mm_data is not None
285 assert set(data_count.keys()) == (set(mm_data.keys()))
286
287 for modality, n in data_count.items():
288 modality_data = mm_data.get(modality)
289 assert modality_data is not None
290 assert isinstance(modality_data, list) and len(modality_data) == n
291
292 if skipped_media_indices is not None:
293 skipped_media_indices_for_modality = skipped_media_indices.get(modality)
294 assert skipped_media_indices_for_modality is not None
295 for i in skipped_media_indices_for_modality:
296 assert modality_data[i] is None
297
298
299 def test_parse_chat_messages_single_image(
300 phi3v_model_config,
301 image_url,
302 ):
303 conversation, mm_data, mm_uuids = parse_chat_messages(
304 [
305 {
306 "role": "user",
307 "content": [
308 {"type": "image_url", "image_url": {"url": image_url}},
309 {"type": "text", "text": "What's in the image?"},
310 ],
311 }
312 ],
313 phi3v_model_config,
314 content_format="string",
315 )
316
317 assert conversation == [
318 {"role": "user", "content": "<|image_1|>\nWhat's in the image?"}
319 ]
320 _assert_mm_data_is_image_input(mm_data, 1)
321 _assert_mm_uuids(mm_uuids, 1, expected_uuids=[None])
322
323
324 def test_parse_chat_messages_single_image_with_uuid(
325 phi3v_model_config,
326 image_url,
327 ):
328 image_uuid = str(hash(image_url))
329 conversation, mm_data, mm_uuids = parse_chat_messages(
330 [
331 {
332 "role": "user",
333 "content": [
334 {
335 "type": "image_url",
336 "image_url": {
337 "url": image_url,
338 },
339 "uuid": image_uuid,
340 },
341 {"type": "text", "text": "What's in the image?"},
342 ],
343 }
344 ],
345 phi3v_model_config,
346 content_format="string",
347 )
348
349 assert conversation == [
350 {"role": "user", "content": "<|image_1|>\nWhat's in the image?"}
351 ]
352 _assert_mm_data_is_image_input(mm_data, 1)
353 _assert_mm_uuids(mm_uuids, 1, expected_uuids=[image_uuid])
354
355
356 def test_parse_chat_messages_single_empty_image_with_uuid(
357 phi3v_model_config,
358 image_url,
359 ):
360 image_uuid = str(hash(image_url))
361 conversation, mm_data, mm_uuids = parse_chat_messages(
362 [
363 {
364 "role": "user",
365 "content": [
366 {
367 "type": "image_url",
368 "image_url": None,
369 "uuid": image_uuid,
370 },
371 {"type": "text", "text": "What's in the image?"},
372 ],
373 }
374 ],
375 phi3v_model_config,
376 content_format="string",
377 )
378
379 assert conversation == [
380 {"role": "user", "content": "<|image_1|>\nWhat's in the image?"}
381 ]
382 _assert_mm_data_is_image_input(mm_data, 1, skipped_image_indices=[0])
383 _assert_mm_uuids(mm_uuids, 1, expected_uuids=[image_uuid])
384
385
386 def test_parse_chat_messages_single_image_with_bad_uuid_format(
387 phi3v_model_config,
388 image_url,
389 ):
390 image_uuid = str(hash(image_url))
391 conversation, mm_data, mm_uuids = parse_chat_messages(
392 [
393 {
394 "role": "user",
395 "content": [
396 {
397 "type": "image_url",
398 "image_url": {
399 "url": image_url,
400 "uuid": image_uuid,
401 },
402 "bad_uuid_key": image_uuid,
403 },
404 {"type": "text", "text": "What's in the image?"},
405 ],
406 }
407 ],
408 phi3v_model_config,
409 content_format="string",
410 )
411
412 assert conversation == [
413 {"role": "user", "content": "<|image_1|>\nWhat's in the image?"}
414 ]
415 _assert_mm_data_is_image_input(mm_data, 1)
416 _assert_mm_uuids(mm_uuids, 1, expected_uuids=[None])
417
418
419 def test_parse_chat_messages_multiple_images_with_uuids(
420 phi3v_model_config,
421 image_url,
422 ):
423 image_uuid1 = "my_uuid_1"
424 image_uuid2 = "my_uuid_2"
425
426 conversation, mm_data, mm_uuids = parse_chat_messages(
427 [
428 {
429 "role": "user",
430 "content": [
431 {
432 "type": "image_url",
433 "image_url": {
434 "url": image_url,
435 },
436 "uuid": image_uuid1,
437 },
438 {
439 "type": "image_url",
440 "image_url": {
441 "url": image_url,
442 },
443 "uuid": image_uuid2,
444 },
445 {"type": "text", "text": "What's in the image?"},
446 ],
447 }
448 ],
449 phi3v_model_config,
450 content_format="string",
451 )
452
453 assert conversation == [
454 {
455 "role": "user",
456 "content": "<|image_1|>\n<|image_2|>\nWhat's in the image?",
457 }
458 ]
459 _assert_mm_data_is_image_input(mm_data, 2)
460 _assert_mm_uuids(mm_uuids, 2, expected_uuids=[image_uuid1, image_uuid2])
461
462
463 def test_parse_chat_messages_multiple_empty_images_with_uuids(
464 phi3v_model_config,
465 image_url,
466 ):
467 image_uuid1 = "my_uuid_1"
468 image_uuid2 = "my_uuid_2"
469
470 conversation, mm_data, mm_uuids = parse_chat_messages(
471 [
472 {
473 "role": "user",
474 "content": [
475 {
476 "type": "image_url",
477 "image_url": None,
478 "uuid": image_uuid1,
479 },
480 {
481 "type": "image_url",
482 "image_url": None,
483 "uuid": image_uuid2,
484 },
485 {"type": "text", "text": "What's in the image?"},
486 ],
487 }
488 ],
489 phi3v_model_config,
490 content_format="string",
491 )
492
493 assert conversation == [
494 {
495 "role": "user",
496 "content": "<|image_1|>\n<|image_2|>\nWhat's in the image?",
497 }
498 ]
499 _assert_mm_data_is_image_input(mm_data, 2, skipped_image_indices=[0, 1])
500 _assert_mm_uuids(mm_uuids, 2, expected_uuids=[image_uuid1, image_uuid2])
501
502
503 def test_parse_chat_messages_mixed_empty_images_with_uuids(
504 phi3v_model_config,
505 image_url,
506 ):
507 image_uuid1 = "my_uuid_1"
508 image_uuid2 = "my_uuid_2"
509
510 conversation, mm_data, mm_uuids = parse_chat_messages(
511 [
512 {
513 "role": "user",
514 "content": [
515 {
516 "type": "image_url",
517 "image_url": {
518 "url": image_url,
519 },
520 "uuid": image_uuid1,
521 },
522 {
523 "type": "image_url",
524 "image_url": None,
525 "uuid": image_uuid2,
526 },
527 {"type": "text", "text": "What's in the image?"},
528 ],
529 }
530 ],
531 phi3v_model_config,
532 content_format="string",
533 )
534
535 assert conversation == [
536 {
537 "role": "user",
538 "content": "<|image_1|>\n<|image_2|>\nWhat's in the image?",
539 }
540 ]
541 _assert_mm_data_is_image_input(mm_data, 2, skipped_image_indices=[1])
542 _assert_mm_uuids(mm_uuids, 2, expected_uuids=[image_uuid1, image_uuid2])
543
544
545 @pytest.mark.asyncio
546 async def test_parse_chat_messages_single_image_with_uuid_async(
547 phi3v_model_config,
548 image_url,
549 ):
550 image_uuid = str(hash(image_url))
551 conversation, mm_data, mm_uuids = await parse_chat_messages_async(
552 [
553 {
554 "role": "user",
555 "content": [
556 {
557 "type": "image_url",
558 "image_url": {"url": image_url},
559 "uuid": image_uuid,
560 },
561 {"type": "text", "text": "What's in the image?"},
562 ],
563 }
564 ],
565 phi3v_model_config,
566 content_format="string",
567 )
568
569 assert conversation == [
570 {"role": "user", "content": "<|image_1|>\nWhat's in the image?"}
571 ]
572 _assert_mm_data_is_image_input(mm_data, 1)
573 _assert_mm_uuids(mm_uuids, 1, expected_uuids=[image_uuid])
574
575
576 @pytest.mark.asyncio
577 async def test_parse_chat_messages_empty_image_with_uuid_async(
578 phi3v_model_config,
579 image_url,
580 ):
581 image_uuid = str(hash(image_url))
582 conversation, mm_data, mm_uuids = await parse_chat_messages_async(
583 [
584 {
585 "role": "user",
586 "content": [
587 {
588 "type": "image_url",
589 "image_url": None,
590 "uuid": image_uuid,
591 },
592 {"type": "text", "text": "What's in the image?"},
593 ],
594 }
595 ],
596 phi3v_model_config,
597 content_format="string",
598 )
599
600 assert conversation == [
601 {"role": "user", "content": "<|image_1|>\nWhat's in the image?"}
602 ]
603 _assert_mm_data_is_image_input(mm_data, 1, skipped_image_indices=[0])
604 _assert_mm_uuids(mm_uuids, 1, expected_uuids=[image_uuid])
605
606
607 @pytest.mark.asyncio
608 async def test_parse_chat_messages_multiple_images_with_uuids_async(
609 phi3v_model_config,
610 image_url,
611 ):
612 image_uuid1 = "my_uuid_1"
613 image_uuid2 = "my_uuid_2"
614
615 conversation, mm_data, mm_uuids = await parse_chat_messages_async(
616 [
617 {
618 "role": "user",
619 "content": [
620 {
621 "type": "image_url",
622 "image_url": {"url": image_url},
623 "uuid": image_uuid1,
624 },
625 {
626 "type": "image_pil",
627 "image_pil": ImageAsset("cherry_blossom").pil_image,
628 "uuid": image_uuid2,
629 },
630 {"type": "text", "text": "What's in these images?"},
631 ],
632 }
633 ],
634 phi3v_model_config,
635 content_format="string",
636 )
637
638 assert conversation == [
639 {
640 "role": "user",
641 "content": "<|image_1|>\n<|image_2|>\nWhat's in these images?",
642 }
643 ]
644 _assert_mm_data_is_image_input(mm_data, 2)
645 _assert_mm_uuids(mm_uuids, 2, expected_uuids=[image_uuid1, image_uuid2])
646
647
648 @pytest.mark.asyncio
649 async def test_parse_chat_messages_multiple_empty_images_with_uuids_async(
650 phi3v_model_config,
651 image_url,
652 ):
653 image_uuid1 = "my_uuid_1"
654 image_uuid2 = "my_uuid_2"
655
656 conversation, mm_data, mm_uuids = await parse_chat_messages_async(
657 [
658 {
659 "role": "user",
660 "content": [
661 {
662 "type": "image_url",
663 "image_url": None,
664 "uuid": image_uuid1,
665 },
666 {
667 "type": "image_pil",
668 "image_pil": None,
669 "uuid": image_uuid2,
670 },
671 {"type": "text", "text": "What's in these images?"},
672 ],
673 }
674 ],
675 phi3v_model_config,
676 content_format="string",
677 )
678
679 assert conversation == [
680 {
681 "role": "user",
682 "content": "<|image_1|>\n<|image_2|>\nWhat's in these images?",
683 }
684 ]
685 _assert_mm_data_is_image_input(mm_data, 2, skipped_image_indices=[0, 1])
686 _assert_mm_uuids(mm_uuids, 2, expected_uuids=[image_uuid1, image_uuid2])
687
688
689 @pytest.mark.asyncio
690 async def test_parse_chat_messages_multiple_images_with_partial_uuids_async(
691 phi3v_model_config,
692 image_url,
693 ):
694 image_uuid2 = "my_uuid_2"
695
696 conversation, mm_data, mm_uuids = await parse_chat_messages_async(
697 [
698 {
699 "role": "user",
700 "content": [
701 {
702 "type": "image_url",
703 "image_url": {"url": image_url},
704 },
705 {
706 "type": "image_pil",
707 "image_pil": ImageAsset("cherry_blossom").pil_image,
708 "uuid": image_uuid2,
709 },
710 {"type": "text", "text": "What's in these images?"},
711 ],
712 }
713 ],
714 phi3v_model_config,
715 content_format="string",
716 )
717
718 assert conversation == [
719 {
720 "role": "user",
721 "content": "<|image_1|>\n<|image_2|>\nWhat's in these images?",
722 }
723 ]
724 _assert_mm_data_is_image_input(mm_data, 2)
725 _assert_mm_uuids(mm_uuids, 2, expected_uuids=[None, image_uuid2])
726
727
728 def test_parse_chat_messages_empty_system(
729 mistral_model_config,
730 ):
731 # Test string format
732 conversation, _, _ = parse_chat_messages(
733 [
734 {"role": "system", "content": ""},
735 {
736 "role": "user",
737 "content": [{"type": "text", "text": "Who are you?"}],
738 },
739 ],
740 mistral_model_config,
741 content_format="string",
742 )
743 assert conversation == [
744 {"role": "system", "content": ""},
745 {"role": "user", "content": "Who are you?"},
746 ]
747
748 # Test openai format
749 conversation, _, _ = parse_chat_messages(
750 [
751 {"role": "system", "content": ""},
752 {
753 "role": "user",
754 "content": [{"type": "text", "text": "Who are you?"}],
755 },
756 ],
757 mistral_model_config,
758 content_format="openai",
759 )
760 assert conversation == [
761 {"role": "system", "content": [{"type": "text", "text": ""}]},
762 {"role": "user", "content": [{"type": "text", "text": "Who are you?"}]},
763 ]
764
765
766 @pytest.mark.asyncio
767 async def test_text_only_chat_does_not_initialize_media_connector(
768 mistral_model_config,
769 monkeypatch,
770 ):
771 load_connector = MagicMock()
772 monkeypatch.setattr(MEDIA_CONNECTOR_REGISTRY, "load", load_connector)
773 messages: list[ChatCompletionMessageParam] = [
774 {"role": "user", "content": "Who are you?"}
775 ]
776
777 parse_chat_messages(
778 messages,
779 mistral_model_config,
780 content_format="string",
781 )
782 await parse_chat_messages_async(
783 messages,
784 mistral_model_config,
785 content_format="string",
786 )
787
788 load_connector.assert_not_called()
789
790
791 @pytest.mark.asyncio
792 async def test_parse_chat_messages_single_image_async(
793 phi3v_model_config,
794 image_url,
795 ):
796 conversation, mm_data, mm_uuids = await parse_chat_messages_async(
797 [
798 {
799 "role": "user",
800 "content": [
801 {"type": "image_url", "image_url": {"url": image_url}},
802 {"type": "text", "text": "What's in the image?"},
803 ],
804 }
805 ],
806 phi3v_model_config,
807 content_format="string",
808 )
809
810 assert conversation == [
811 {"role": "user", "content": "<|image_1|>\nWhat's in the image?"}
812 ]
813 _assert_mm_data_is_image_input(mm_data, 1)
814 _assert_mm_uuids(mm_uuids, 1, expected_uuids=[None])
815
816
817 def test_parse_chat_messages_multiple_images(
818 phi3v_model_config,
819 image_url,
820 ):
821 conversation, mm_data, mm_uuids = parse_chat_messages(
822 [
823 {
824 "role": "user",
825 "content": [
826 {"type": "image_url", "image_url": {"url": image_url}},
827 {
828 "type": "image_pil",
829 "image_pil": ImageAsset("cherry_blossom").pil_image,
830 },
831 {"type": "text", "text": "What's in these images?"},
832 ],
833 }
834 ],
835 phi3v_model_config,
836 content_format="string",
837 )
838
839 assert conversation == [
840 {
841 "role": "user",
842 "content": "<|image_1|>\n<|image_2|>\nWhat's in these images?",
843 }
844 ]
845 _assert_mm_data_is_image_input(mm_data, 2)
846 _assert_mm_uuids(mm_uuids, 2, expected_uuids=[None, None])
847
848
849 def test_parse_chat_messages_empty_pil_image_with_uuid(
850 phi3v_model_config,
851 ):
852 uuid = "abcd"
853 conversation, mm_data, mm_uuids = parse_chat_messages(
854 [
855 {
856 "role": "user",
857 "content": [
858 {"type": "image_pil", "image_pil": None, "uuid": uuid},
859 {"type": "text", "text": "What's in this image?"},
860 ],
861 }
862 ],
863 phi3v_model_config,
864 content_format="string",
865 )
866
867 assert conversation == [
868 {
869 "role": "user",
870 "content": "<|image_1|>\nWhat's in this image?",
871 }
872 ]
873 _assert_mm_data_is_image_input(mm_data, 1, skipped_image_indices=[0])
874 _assert_mm_uuids(mm_uuids, 1, expected_uuids=[uuid])
875
876
877 def test_parse_chat_messages_empty_image_embeds_with_uuid(
878 phi3v_model_config_image_embeds,
879 ):
880 uuid = "abcd"
881 conversation, mm_data, mm_uuids = parse_chat_messages(
882 [
883 {
884 "role": "user",
885 "content": [
886 {"type": "image_embeds", "image_embeds": None, "uuid": uuid},
887 {"type": "text", "text": "What's in this image?"},
888 ],
889 }
890 ],
891 phi3v_model_config_image_embeds,
892 content_format="string",
893 )
894
895 assert conversation == [
896 {
897 "role": "user",
898 "content": "<|image_1|>\nWhat's in this image?",
899 }
900 ]
901
902 assert mm_data is not None
903 assert "image" in mm_data
904 assert isinstance(mm_data["image"], list)
905 assert len(mm_data["image"]) == 1
906 assert mm_data["image"][0] is None
907
908 _assert_mm_uuids(mm_uuids, 1, expected_uuids=[uuid])
909
910
911 def test_parse_chat_messages_empty_audio_embeds_with_uuid(
912 audio_embeds_model_config,
913 ):
914 """Test audio_embeds with UUID (no actual embeds data)."""
915 uuid = "test-audio-uuid-123"
916
917 conversation, mm_data, mm_uuids = parse_chat_messages(
918 [
919 {
920 "role": "user",
921 "content": [
922 {"type": "text", "text": "Describe this audio"},
923 {"type": "audio_embeds", "audio_embeds": None, "uuid": uuid},
924 ],
925 }
926 ],
927 audio_embeds_model_config,
928 content_format="string",
929 )
930
931 # Should have audio in mm_data as None (UUID provided)
932 assert mm_data is not None
933 assert "audio" in mm_data
934 assert isinstance(mm_data["audio"], list)
935 assert len(mm_data["audio"]) == 1
936 assert mm_data["audio"][0] is None
937
938 # UUID should be recorded
939 _assert_mm_uuids(mm_uuids, 1, modality="audio", expected_uuids=[uuid])
940
941
942 def test_parse_chat_messages_audio_embeds_with_string(
943 audio_embeds_model_config,
944 ):
945 """Test audio_embeds with base64 string embedding data."""
946 import torch
947
948 # Create a sample audio embedding tensor
949 hidden_size = audio_embeds_model_config.get_inputs_embeds_size()
950 audio_embedding = torch.randn(1, 128, hidden_size)
951
952 # Encode it as base64
953 base64_audio_embedding = tensor2base64(audio_embedding)
954
955 conversation, mm_data, mm_uuids = parse_chat_messages(
956 [
957 {
958 "role": "user",
959 "content": [
960 {"type": "text", "text": "Describe this audio"},
961 {
962 "type": "audio_embeds",
963 "audio_embeds": base64_audio_embedding,
964 },
965 ],
966 }
967 ],
968 audio_embeds_model_config,
969 content_format="string",
970 )
971
972 # Should have audio embedding in mm_data (single tensor, not a list)
973 assert mm_data is not None
974 assert "audio" in mm_data
975 assert isinstance(mm_data["audio"], torch.Tensor)
976 assert mm_data["audio"].shape == audio_embedding.shape
977 # No UUID provided
978 assert mm_uuids is not None
979 assert "audio" in mm_uuids
980 _assert_mm_uuids(mm_uuids, 1, modality="audio", expected_uuids=[None])
981
982
983 @pytest.mark.asyncio
984 async def test_parse_chat_messages_audio_embeds_async(
985 audio_embeds_model_config,
986 ):
987 """Test audio_embeds with async futures."""
988 import torch
989
990 # Create a sample audio embedding tensor
991 hidden_size = audio_embeds_model_config.get_inputs_embeds_size()
992 audio_embedding = torch.randn(1, 128, hidden_size)
993
994 # Encode it as base64
995 base64_audio_embedding = tensor2base64(audio_embedding)
996
997 conversation, mm_data, mm_uuids = await parse_chat_messages_async(
998 [
999 {
1000 "role": "user",
1001 "content": [
1002 {"type": "text", "text": "Describe this audio"},
1003 {
1004 "type": "audio_embeds",
1005 "audio_embeds": base64_audio_embedding,
1006 },
1007 ],
1008 }
1009 ],
1010 audio_embeds_model_config,
1011 content_format="string",
1012 )
1013
1014 # Should have audio embedding in mm_data (single tensor, not a list)
1015 assert mm_data is not None
1016 assert "audio" in mm_data
1017 assert isinstance(mm_data["audio"], torch.Tensor)
1018 assert mm_data["audio"].shape == audio_embedding.shape
1019 # No UUID provided
1020 assert mm_uuids is not None
1021 assert "audio" in mm_uuids
1022 _assert_mm_uuids(mm_uuids, 1, modality="audio", expected_uuids=[None])
1023
1024
1025 def test_parse_chat_messages_empty_video_embeds_with_uuid(
1026 qwen25omni_model_config_video_embeds,
1027 ):
1028 """Test video_embeds with UUID (no actual embeds data)."""
1029 uuid = "test-video-uuid-123"
1030
1031 conversation, mm_data, mm_uuids = parse_chat_messages(
1032 [
1033 {
1034 "role": "user",
1035 "content": [
1036 {"type": "text", "text": "Describe this video"},
1037 {"type": "video_embeds", "video_embeds": None, "uuid": uuid},
1038 ],
1039 }
1040 ],
1041 qwen25omni_model_config_video_embeds,
1042 content_format="string",
1043 )
1044
1045 # Should have video in mm_data as None (UUID provided)
1046 assert mm_data is not None
1047 assert "video" in mm_data
1048 assert isinstance(mm_data["video"], list)
1049 assert len(mm_data["video"]) == 1
1050 assert mm_data["video"][0] is None
1051
1052 # UUID should be recorded
1053 _assert_mm_uuids(mm_uuids, 1, modality="video", expected_uuids=[uuid])
1054
1055
1056 def test_parse_chat_messages_video_embeds_with_string(
1057 qwen25omni_model_config_video_embeds,
1058 ):
1059 """Test video_embeds with base64 string embedding data."""
1060 hidden_size = qwen25omni_model_config_video_embeds.get_inputs_embeds_size()
1061 video_embedding = torch.randn(1, 128, hidden_size)
1062
1063 base64_video_embedding = tensor2base64(video_embedding)
1064
1065 conversation, mm_data, mm_uuids = parse_chat_messages(
1066 [
1067 {
1068 "role": "user",
1069 "content": [
1070 {"type": "text", "text": "Describe this video"},
1071 {
1072 "type": "video_embeds",
1073 "video_embeds": base64_video_embedding,
1074 },
1075 ],
1076 }
1077 ],
1078 qwen25omni_model_config_video_embeds,
1079 content_format="string",
1080 )
1081
1082 # Should have video embedding in mm_data (single tensor, not a list)
1083 assert mm_data is not None
1084 assert "video" in mm_data
1085 assert isinstance(mm_data["video"], torch.Tensor)
1086 assert mm_data["video"].shape == video_embedding.shape
1087 # No UUID provided
1088 assert mm_uuids is not None
1089 assert "video" in mm_uuids
1090 _assert_mm_uuids(mm_uuids, 1, modality="video", expected_uuids=[None])
1091
1092
1093 @pytest.mark.asyncio
1094 async def test_parse_chat_messages_video_embeds_async(
1095 qwen25omni_model_config_video_embeds,
1096 ):
1097 """Test video_embeds with async futures."""
1098 hidden_size = qwen25omni_model_config_video_embeds.get_inputs_embeds_size()
1099 video_embedding = torch.randn(1, 128, hidden_size)
1100
1101 base64_video_embedding = tensor2base64(video_embedding)
1102
1103 conversation, mm_data, mm_uuids = await parse_chat_messages_async(
1104 [
1105 {
1106 "role": "user",
1107 "content": [
1108 {"type": "text", "text": "Describe this video"},
1109 {
1110 "type": "video_embeds",
1111 "video_embeds": base64_video_embedding,
1112 },
1113 ],
1114 }
1115 ],
1116 qwen25omni_model_config_video_embeds,
1117 content_format="string",
1118 )
1119
1120 # Should have video embedding in mm_data (single tensor, not a list)
1121 assert mm_data is not None
1122 assert "video" in mm_data
1123 assert isinstance(mm_data["video"], torch.Tensor)
1124 assert mm_data["video"].shape == video_embedding.shape
1125 # No UUID provided
1126 assert mm_uuids is not None
1127 assert "video" in mm_uuids
1128 _assert_mm_uuids(mm_uuids, 1, modality="video", expected_uuids=[None])
1129
1130
1131 def test_parse_chat_messages_video_and_video_embeds_mixed(
1132 qwen25omni_model_config_video_embeds,
1133 video_url,
1134 ):
1135 """Test that mixing raw video and video_embeds is rejected."""
1136 with pytest.raises(VLLMValidationError, match="Mixing raw video"):
1137 parse_chat_messages(
1138 [
1139 {
1140 "role": "user",
1141 "content": [
1142 {"type": "video_url", "video_url": {"url": video_url}},
1143 {"type": "video_embeds", "video_embeds": None, "uuid": "x"},
1144 ],
1145 }
1146 ],
1147 qwen25omni_model_config_video_embeds,
1148 content_format="string",
1149 )
1150
1151
1152 def test_parse_chat_messages_multiple_image_embeds(
1153 phi3v_model_config_image_embeds,
1154 ):
1155 """Test that multiple image_embeds in a single message are now supported.
1156
1157 This test validates the fix for the limitation that previously only allowed
1158 one message with {'type': 'image_embeds'}. Now multiple image embeddings
1159 can be provided in a single request, similar to regular images.
1160 """
1161 # Create two sample image embedding tensors
1162 hidden_size = phi3v_model_config_image_embeds.get_inputs_embeds_size()
1163 image_embedding_1 = torch.randn(256, hidden_size)
1164 image_embedding_2 = torch.randn(128, hidden_size)
1165
1166 # Encode them as base64 using the convenience function
1167 base64_image_embedding_1 = tensor2base64(image_embedding_1)
1168 base64_image_embedding_2 = tensor2base64(image_embedding_2)
1169
1170 conversation, mm_data, mm_uuids = parse_chat_messages(
1171 [
1172 {
1173 "role": "user",
1174 "content": [
1175 {
1176 "type": "image_embeds",
1177 "image_embeds": base64_image_embedding_1,
1178 },
1179 {
1180 "type": "image_embeds",
1181 "image_embeds": base64_image_embedding_2,
1182 },
1183 {"type": "text", "text": "Describe these two images."},
1184 ],
1185 }
1186 ],
1187 phi3v_model_config_image_embeds,
1188 content_format="string",
1189 )
1190
1191 # Verify conversation structure
1192 assert conversation == [
1193 {
1194 "role": "user",
1195 "content": "<|image_1|>\n<|image_2|>\nDescribe these two images.",
1196 }
1197 ]
1198
1199 # Verify mm_data contains a list of embeddings (not a single embedding)
1200 assert mm_data is not None
1201 assert "image" in mm_data
1202 assert isinstance(mm_data["image"], list)
1203 assert len(mm_data["image"]) == 2
1204
1205 # Verify each embedding has the correct shape
1206 assert isinstance(mm_data["image"][0], torch.Tensor)
1207 assert mm_data["image"][0].shape == image_embedding_1.shape
1208 assert isinstance(mm_data["image"][1], torch.Tensor)
1209 assert mm_data["image"][1].shape == image_embedding_2.shape
1210
1211 # Verify UUIDs (None since we didn't provide any)
1212 _assert_mm_uuids(mm_uuids, 2, expected_uuids=[None, None])
1213
1214
1215 def test_parse_chat_messages_multiple_image_embeds_with_uuids(
1216 phi3v_model_config_image_embeds,
1217 ):
1218 """Test multiple image_embeds with UUIDs.
1219
1220 This validates that UUIDs are properly tracked for multiple embeddings.
1221 """
1222 uuid1 = "image-uuid-1"
1223 uuid2 = "image-uuid-2"
1224
1225 conversation, mm_data, mm_uuids = parse_chat_messages(
1226 [
1227 {
1228 "role": "user",
1229 "content": [
1230 {
1231 "type": "image_embeds",
1232 "image_embeds": None,
1233 "uuid": uuid1,
1234 },
1235 {
1236 "type": "image_embeds",
1237 "image_embeds": None,
1238 "uuid": uuid2,
1239 },
1240 {"type": "text", "text": "Compare these images."},
1241 ],
1242 }
1243 ],
1244 phi3v_model_config_image_embeds,
1245 content_format="string",
1246 )
1247
1248 # Verify conversation structure
1249 assert conversation == [
1250 {
1251 "role": "user",
1252 "content": "<|image_1|>\n<|image_2|>\nCompare these images.",
1253 }
1254 ]
1255
1256 # Verify mm_data contains a list with None values (UUID references)
1257 assert mm_data is not None
1258 assert "image" in mm_data
1259 assert isinstance(mm_data["image"], list)
1260 assert len(mm_data["image"]) == 2
1261 assert mm_data["image"][0] is None
1262 assert mm_data["image"][1] is None
1263
1264 # Verify UUIDs are correctly tracked
1265 _assert_mm_uuids(mm_uuids, 2, expected_uuids=[uuid1, uuid2])
1266
1267
1268 @pytest.mark.asyncio
1269 async def test_parse_chat_messages_multiple_image_embeds_async(
1270 phi3v_model_config_image_embeds,
1271 ):
1272 """Test multiple image_embeds with async parsing.
1273
1274 This validates the AsyncMultiModalItemTracker also supports multiple embeddings.
1275 """
1276 # Create two sample image embedding tensors
1277 hidden_size = phi3v_model_config_image_embeds.get_inputs_embeds_size()
1278 image_embedding_1 = torch.randn(200, hidden_size)
1279 image_embedding_2 = torch.randn(150, hidden_size)
1280
1281 # Encode them as base64 using the convenience function
1282 base64_image_embedding_1 = tensor2base64(image_embedding_1)
1283 base64_image_embedding_2 = tensor2base64(image_embedding_2)
1284
1285 conversation, mm_data, mm_uuids = await parse_chat_messages_async(
1286 [
1287 {
1288 "role": "user",
1289 "content": [
1290 {
1291 "type": "image_embeds",
1292 "image_embeds": base64_image_embedding_1,
1293 },
1294 {
1295 "type": "image_embeds",
1296 "image_embeds": base64_image_embedding_2,
1297 },
1298 {"type": "text", "text": "What do these images show?"},
1299 ],
1300 }
1301 ],
1302 phi3v_model_config_image_embeds,
1303 content_format="string",
1304 )
1305
1306 # Verify conversation structure
1307 assert conversation == [
1308 {
1309 "role": "user",
1310 "content": "<|image_1|>\n<|image_2|>\nWhat do these images show?",
1311 }
1312 ]
1313
1314 # Await the future and verify mm_data
1315 assert mm_data is not None
1316 assert "image" in mm_data
1317 assert isinstance(mm_data["image"], list)
1318 assert len(mm_data["image"]) == 2
1319
1320 # Verify each embedding has the correct shape
1321 assert isinstance(mm_data["image"][0], torch.Tensor)
1322 assert mm_data["image"][0].shape == image_embedding_1.shape
1323 assert isinstance(mm_data["image"][1], torch.Tensor)
1324 assert mm_data["image"][1].shape == image_embedding_2.shape
1325
1326 # Verify UUIDs
1327 _assert_mm_uuids(mm_uuids, 2, expected_uuids=[None, None])
1328
1329
1330 @pytest.mark.asyncio
1331 async def test_parse_chat_messages_empty_image_embeds_with_uuid_async(
1332 phi3v_model_config_image_embeds,
1333 ):
1334 uuid = "abcd"
1335 conversation, mm_data, mm_uuids = await parse_chat_messages_async(
1336 [
1337 {
1338 "role": "user",
1339 "content": [
1340 {"type": "image_embeds", "image_embeds": None, "uuid": uuid},
1341 {"type": "text", "text": "What's in this image?"},
1342 ],
1343 }
1344 ],
1345 phi3v_model_config_image_embeds,
1346 content_format="string",
1347 )
1348
1349 assert conversation == [
1350 {
1351 "role": "user",
1352 "content": "<|image_1|>\nWhat's in this image?",
1353 }
1354 ]
1355 assert mm_data is not None
1356 assert "image" in mm_data
1357 assert isinstance(mm_data["image"], list)
1358 assert len(mm_data["image"]) == 1
1359 assert mm_data["image"][0] is None
1360
1361 _assert_mm_uuids(mm_uuids, 1, expected_uuids=[uuid])
1362
1363
1364 def test_parse_chat_messages_empty_dict_image_embeds(
1365 phi3v_model_config_image_embeds,
1366 ):
1367 """Test that empty dictionary for image_embeds is handled without errors."""
1368 conversation, mm_data, mm_uuids = parse_chat_messages(
1369 [
1370 {
1371 "role": "user",
1372 "content": [
1373 {"type": "image_embeds", "image_embeds": {}},
1374 {"type": "text", "text": "What's in this image?"},
1375 ],
1376 }
1377 ],
1378 phi3v_model_config_image_embeds,
1379 content_format="string",
1380 )
1381
1382 # Verify conversation structure
1383 assert conversation == [
1384 {
1385 "role": "user",
1386 "content": "<|image_1|>\nWhat's in this image?",
1387 }
1388 ]
1389
1390 # Verify mm_data contains an empty dictionary of embeddings
1391 assert mm_data is not None
1392 assert "image" in mm_data
1393 assert isinstance(mm_data["image"], dict)
1394 assert len(mm_data["image"]) == 0
1395
1396 # Verify UUIDs (None since we didn't provide any)
1397 _assert_mm_uuids(mm_uuids, 1, expected_uuids=[None])
1398
1399
1400 @pytest.mark.parametrize("json_metadata", [False, True])
1401 def test_parse_chat_messages_multiple_dict_image_embeds(
1402 qwen25omni_model_config_image_embeds,
1403 json_metadata,
1404 ):
1405 """Test that multiple dictionaries for image_embeds is handled without errors."""
1406 # Create two sample image embedding tensors
1407 batch_size = 2
1408 hidden_size = qwen25omni_model_config_image_embeds.get_inputs_embeds_size()
1409 image_embeds = torch.randn(batch_size * 220, hidden_size)
1410 image_grid_thw = torch.tensor([[1, 22, 40] for _ in range(batch_size)])
1411
1412 conversation, mm_data, mm_uuids = parse_chat_messages(
1413 [
1414 {
1415 "role": "user",
1416 "content": [
1417 {
1418 "type": "image_embeds",
1419 "image_embeds": {
1420 "image_embeds": tensor2base64(embeds),
1421 "image_grid_thw": grid_thw.tolist()
1422 if json_metadata
1423 else tensor2base64(grid_thw),
1424 },
1425 }
1426 for embeds, grid_thw in zip(
1427 image_embeds.chunk(batch_size), image_grid_thw
1428 )
1429 ]
1430 + [
1431 {"type": "text", "text": "Describe these two images."},
1432 ],
1433 }
1434 ],
1435 qwen25omni_model_config_image_embeds,
1436 content_format="string",
1437 )
1438
1439 # Verify conversation structure
1440 assert conversation == [
1441 {
1442 "role": "user",
1443 "content": "<|vision_start|><|IMAGE|><|vision_end|>\n"
1444 "<|vision_start|><|IMAGE|><|vision_end|>\nDescribe these two images.",
1445 }
1446 ]
1447
1448 # Verify mm_data contains a dictionary of multi-embeddings
1449 assert mm_data is not None
1450 assert "image" in mm_data
1451 assert isinstance(mm_data["image"], dict)
1452 assert len(mm_data["image"]) == batch_size
1453
1454 # Verify each embedding has the correct shape
1455 assert isinstance(mm_data["image"]["image_embeds"], torch.Tensor)
1456 assert mm_data["image"]["image_embeds"].shape == image_embeds.shape
1457 assert isinstance(mm_data["image"]["image_grid_thw"], torch.Tensor)
1458 assert mm_data["image"]["image_grid_thw"].shape == image_grid_thw.shape
1459
1460 # Verify UUIDs (None since we didn't provide any)
1461 _assert_mm_uuids(mm_uuids, batch_size, expected_uuids=[None, None])
1462
1463
1464 @pytest.mark.asyncio
1465 async def test_parse_chat_messages_multiple_images_async(
1466 phi3v_model_config,
1467 image_url,
1468 ):
1469 conversation, mm_data, mm_uuids = await parse_chat_messages_async(
1470 [
1471 {
1472 "role": "user",
1473 "content": [
1474 {"type": "image_url", "image_url": {"url": image_url}},
1475 {
1476 "type": "image_pil",
1477 "image_pil": ImageAsset("cherry_blossom").pil_image,
1478 },
1479 {"type": "text", "text": "What's in these images?"},
1480 ],
1481 }
1482 ],
1483 phi3v_model_config,
1484 content_format="string",
1485 )
1486
1487 assert conversation == [
1488 {
1489 "role": "user",
1490 "content": "<|image_1|>\n<|image_2|>\nWhat's in these images?",
1491 }
1492 ]
1493 _assert_mm_data_is_image_input(mm_data, 2)
1494 _assert_mm_uuids(mm_uuids, 2, expected_uuids=[None, None])
1495
1496
1497 def test_parse_chat_messages_placeholder_already_in_prompt(
1498 phi3v_model_config,
1499 image_url,
1500 ):
1501 conversation, mm_data, mm_uuids = parse_chat_messages(
1502 [
1503 {
1504 "role": "user",
1505 "content": [
1506 {"type": "image_url", "image_url": {"url": image_url}},
1507 {"type": "image_url", "image_url": {"url": image_url}},
1508 {
1509 "type": "text",
1510 "text": "What's in <|image_1|> and how does it compare to <|image_2|>?", # noqa: E501
1511 },
1512 ],
1513 }
1514 ],
1515 phi3v_model_config,
1516 content_format="string",
1517 )
1518 assert conversation == [
1519 {
1520 "role": "user",
1521 "content": "What's in <|image_1|> and how does it compare to <|image_2|>?",
1522 }
1523 ]
1524 _assert_mm_data_is_image_input(mm_data, 2)
1525 _assert_mm_uuids(mm_uuids, 2, expected_uuids=[None, None])
1526
1527
1528 def test_parse_chat_messages_placeholder_one_already_in_prompt(
1529 phi3v_model_config,
1530 image_url,
1531 ):
1532 conversation, mm_data, mm_uuids = parse_chat_messages(
1533 [
1534 {
1535 "role": "user",
1536 "content": [
1537 {"type": "image_url", "image_url": {"url": image_url}},
1538 {"type": "image_url", "image_url": {"url": image_url}},
1539 {
1540 "type": "text",
1541 "text": "What's in <|image_1|> and how does it compare to "
1542 "the other one?",
1543 },
1544 ],
1545 }
1546 ],
1547 phi3v_model_config,
1548 content_format="string",
1549 )
1550
1551 assert conversation == [
1552 {
1553 "role": "user",
1554 "content": "<|image_2|>\nWhat's in <|image_1|> and how does it compare to "
1555 "the other one?",
1556 }
1557 ]
1558 _assert_mm_data_is_image_input(mm_data, 2)
1559 _assert_mm_uuids(mm_uuids, 2, expected_uuids=[None, None])
1560
1561
1562 def test_parse_chat_messages_multiple_images_across_messages(
1563 phi3v_model_config,
1564 image_url,
1565 ):
1566 conversation, mm_data, mm_uuids = parse_chat_messages(
1567 [
1568 {
1569 "role": "user",
1570 "content": [
1571 {"type": "image_url", "image_url": {"url": image_url}},
1572 {"type": "text", "text": "What's in this image?"},
1573 ],
1574 },
1575 {"role": "assistant", "content": "Some stuff."},
1576 {
1577 "role": "user",
1578 "content": [
1579 {"type": "image_url", "image_url": {"url": image_url}},
1580 {"type": "text", "text": "What about this one?"},
1581 ],
1582 },
1583 ],
1584 phi3v_model_config,
1585 content_format="string",
1586 )
1587
1588 assert conversation == [
1589 {"role": "user", "content": "<|image_1|>\nWhat's in this image?"},
1590 {"role": "assistant", "content": "Some stuff."},
1591 {"role": "user", "content": "<|image_2|>\nWhat about this one?"},
1592 ]
1593 _assert_mm_data_is_image_input(mm_data, 2)
1594 _assert_mm_uuids(mm_uuids, 2, expected_uuids=[None, None])
1595
1596
1597 def test_parse_chat_messages_multiple_images_with_uuids_across_messages(
1598 phi3v_model_config,
1599 image_url,
1600 ):
1601 image_uuid = str(hash(image_url))
1602 conversation, mm_data, mm_uuids = parse_chat_messages(
1603 [
1604 {
1605 "role": "user",
1606 "content": [
1607 {
1608 "type": "image_url",
1609 "image_url": {"url": image_url},
1610 "uuid": image_uuid,
1611 },
1612 {"type": "text", "text": "What's in this image?"},
1613 ],
1614 },
1615 {"role": "assistant", "content": "Some stuff."},
1616 {
1617 "role": "user",
1618 "content": [
1619 {
1620 "type": "image_url",
1621 "image_url": {"url": image_url},
1622 "uuid": image_uuid,
1623 },
1624 {"type": "text", "text": "What about this one?"},
1625 ],
1626 },
1627 ],
1628 phi3v_model_config,
1629 content_format="string",
1630 )
1631
1632 assert conversation == [
1633 {"role": "user", "content": "<|image_1|>\nWhat's in this image?"},
1634 {"role": "assistant", "content": "Some stuff."},
1635 {"role": "user", "content": "<|image_2|>\nWhat about this one?"},
1636 ]
1637 _assert_mm_data_is_image_input(mm_data, 2)
1638 _assert_mm_uuids(mm_uuids, 2, expected_uuids=[image_uuid, image_uuid])
1639
1640
1641 def test_parse_chat_messages_context_text_format(
1642 phi3v_model_config,
1643 ):
1644 conversation, mm_data, mm_uuids = parse_chat_messages(
1645 [
1646 {
1647 "role": "user",
1648 "content": [{"type": "text", "text": "What's in this text?"}],
1649 },
1650 {"role": "assistant", "content": "Some stuff."},
1651 {"role": "user", "content": "What about this one?"},
1652 ],
1653 phi3v_model_config,
1654 content_format="openai",
1655 )
1656
1657 assert conversation == [
1658 {
1659 "role": "user",
1660 "content": [{"type": "text", "text": "What's in this text?"}],
1661 },
1662 {
1663 "role": "assistant",
1664 "content": [{"type": "text", "text": "Some stuff."}],
1665 },
1666 {
1667 "role": "user",
1668 "content": [{"type": "text", "text": "What about this one?"}],
1669 },
1670 ]
1671 assert mm_data is None
1672 assert mm_uuids is None
1673
1674
1675 def test_parse_chat_messages_openai_format_image_url(
1676 phi3v_model_config,
1677 image_url,
1678 ):
1679 content = [
1680 {"type": "image_url", "image_url": {"url": image_url}},
1681 {"type": "text", "text": "What's in the image?"},
1682 ]
1683 conversation, mm_data, mm_uuids = parse_chat_messages(
1684 [
1685 {
1686 "role": "user",
1687 "content": content,
1688 }
1689 ],
1690 phi3v_model_config,
1691 content_format="openai",
1692 )
1693
1694 assert conversation == [
1695 {
1696 "role": "user",
1697 "content": [
1698 {"type": "image"},
1699 {"type": "text", "text": "What's in the image?"},
1700 ],
1701 }
1702 ]
1703 _assert_mm_data_is_image_input(mm_data, 1)
1704 _assert_mm_uuids(mm_uuids, 1, expected_uuids=[None])
1705
1706
1707 def test_parse_chat_messages_rejects_too_many_images_in_one_message(
1708 phi3v_model_config,
1709 image_url,
1710 ):
1711 with warnings.catch_warnings():
1712 warnings.filterwarnings(
1713 "ignore",
1714 message="coroutine 'async_get_and_parse_image' was never awaited",
1715 )
1716 with pytest.raises(VLLMValidationError, match="At most"):
1717 parse_chat_messages(
1718 [
1719 {
1720 "role": "user",
1721 "content": [
1722 {
1723 "type": "image_url",
1724 "image_url": {"url": image_url},
1725 },
1726 {
1727 "type": "image_url",
1728 "image_url": {"url": image_url},
1729 },
1730 {
1731 "type": "image_url",
1732 "image_url": {"url": image_url},
1733 },
1734 {"type": "text", "text": "What's in these images?"},
1735 ],
1736 }
1737 ],
1738 phi3v_model_config,
1739 content_format="string",
1740 )
1741
1742
1743 def test_parse_chat_messages_rejects_too_many_images_across_messages(
1744 phi3v_model_config,
1745 image_url,
1746 ):
1747 with warnings.catch_warnings():
1748 warnings.filterwarnings(
1749 "ignore",
1750 message="coroutine 'async_get_and_parse_image' was never awaited",
1751 )
1752 with pytest.raises(VLLMValidationError, match="At most"):
1753 parse_chat_messages(
1754 [
1755 {
1756 "role": "user",
1757 "content": [
1758 {
1759 "type": "image_url",
1760 "image_url": {"url": image_url},
1761 },
1762 {"type": "text", "text": "What's in this image?"},
1763 ],
1764 },
1765 {"role": "assistant", "content": "Some stuff."},
1766 {
1767 "role": "user",
1768 "content": [
1769 {
1770 "type": "image_url",
1771 "image_url": {"url": image_url},
1772 },
1773 {
1774 "type": "image_url",
1775 "image_url": {"url": image_url},
1776 },
1777 {"type": "text", "text": "What about these two?"},
1778 ],
1779 },
1780 ],
1781 phi3v_model_config,
1782 content_format="string",
1783 )
1784
1785
1786 def test_parse_chat_messages_multiple_images_uncommon_input(
1787 phi3v_model_config,
1788 image_url,
1789 ):
1790 conversation, mm_data, mm_uuids = parse_chat_messages(
1791 [
1792 {
1793 "role": "user",
1794 "content": [
1795 "What's in these images?",
1796 {"image_url": image_url},
1797 {"image_url": image_url},
1798 ],
1799 }
1800 ],
1801 phi3v_model_config,
1802 content_format="string",
1803 )
1804
1805 assert conversation == [
1806 {
1807 "role": "user",
1808 "content": "<|image_1|>\n<|image_2|>\nWhat's in these images?",
1809 }
1810 ]
1811 _assert_mm_data_is_image_input(mm_data, 2)
1812 _assert_mm_uuids(mm_uuids, 2, expected_uuids=[None, None])
1813
1814
1815 def test_parse_chat_messages_multiple_images_interleave(
1816 phi3v_model_config_mm_interleaved,
1817 image_url,
1818 ):
1819 conversation, mm_data, mm_uuids = parse_chat_messages(
1820 [
1821 {
1822 "role": "user",
1823 "content": [
1824 {
1825 "type": "text",
1826 "text": "I need you to compare this image",
1827 },
1828 {"type": "image_url", "image_url": {"url": image_url}},
1829 {"type": "text", "text": "and this one"},
1830 {"type": "image_url", "image_url": {"url": image_url}},
1831 {"type": "text", "text": "Do they have differences?"},
1832 ],
1833 }
1834 ],
1835 phi3v_model_config_mm_interleaved,
1836 content_format="string",
1837 )
1838
1839 assert conversation == [
1840 {
1841 "role": "user",
1842 "content": "I need you to compare this image\n<|image_1|>\nand this one\n<|image_2|>\n" # noqa: E501
1843 "Do they have differences?",
1844 }
1845 ]
1846 _assert_mm_data_is_image_input(mm_data, 2)
1847 _assert_mm_uuids(mm_uuids, 2, expected_uuids=[None, None])
1848
1849
1850 def test_parse_chat_messages_interleave_placeholder_overcount_raises(
1851 phi3v_model_config_mm_interleaved,
1852 image_url,
1853 ):
1854 # Only one image is supplied, but the user's text also contains the internal
1855 # image sentinel, so the interleaved prompt references more image
1856 # placeholders than actual images. This must surface as a clear validation
1857 # error rather than an IndexError from popping an empty list.
1858 with pytest.raises(VLLMValidationError, match="placeholders in input prompt"):
1859 parse_chat_messages(
1860 [
1861 {
1862 "role": "user",
1863 "content": [
1864 {"type": "image_url", "image_url": {"url": image_url}},
1865 {"type": "text", "text": "<##IMAGE##>"},
1866 ],
1867 }
1868 ],
1869 phi3v_model_config_mm_interleaved,
1870 content_format="string",
1871 )
1872
1873
1874 @pytest.mark.asyncio
1875 async def test_parse_chat_messages_multiple_images_interleave_async(
1876 phi3v_model_config_mm_interleaved,
1877 image_url,
1878 ):
1879 conversation, mm_data, mm_uuids = await parse_chat_messages_async(
1880 [
1881 {
1882 "role": "user",
1883 "content": [
1884 {
1885 "type": "text",
1886 "text": "I need you to compare this image",
1887 },
1888 {"type": "image_url", "image_url": {"url": image_url}},
1889 {"type": "text", "text": "and this one"},
1890 {"type": "image_url", "image_url": {"url": image_url}},
1891 {"type": "text", "text": "Do they have differences?"},
1892 ],
1893 }
1894 ],
1895 phi3v_model_config_mm_interleaved,
1896 content_format="string",
1897 )
1898
1899 assert conversation == [
1900 {
1901 "role": "user",
1902 "content": "I need you to compare this image\n<|image_1|>\nand this one\n<|image_2|>\n" # noqa: E501
1903 "Do they have differences?",
1904 }
1905 ]
1906 _assert_mm_data_is_image_input(mm_data, 2)
1907 _assert_mm_uuids(mm_uuids, 2, expected_uuids=[None, None])
1908
1909
1910 @pytest.mark.asyncio
1911 async def test_parse_chat_messages_multiple_images_with_uuids_interleave_async(
1912 phi3v_model_config_mm_interleaved,
1913 image_url,
1914 ):
1915 image_uuid = str(hash(image_url))
1916 conversation, mm_data, mm_uuids = await parse_chat_messages_async(
1917 [
1918 {
1919 "role": "user",
1920 "content": [
1921 {
1922 "type": "text",
1923 "text": "I need you to compare this image",
1924 },
1925 {
1926 "type": "image_url",
1927 "image_url": {"url": image_url},
1928 "uuid": image_uuid,
1929 },
1930 {"type": "text", "text": "and this one"},
1931 {
1932 "type": "image_url",
1933 "image_url": {"url": image_url},
1934 "uuid": image_uuid,
1935 },
1936 {"type": "text", "text": "Do they have differences?"},
1937 ],
1938 }
1939 ],
1940 phi3v_model_config_mm_interleaved,
1941 content_format="string",
1942 )
1943
1944 assert conversation == [
1945 {
1946 "role": "user",
1947 "content": "I need you to compare this image\n<|image_1|>\nand this one\n<|image_2|>\n" # noqa: E501
1948 "Do they have differences?",
1949 }
1950 ]
1951 _assert_mm_data_is_image_input(mm_data, 2)
1952 _assert_mm_uuids(mm_uuids, 2, expected_uuids=[image_uuid, image_uuid])
1953
1954
1955 def test_parse_chat_messages_multiple_images_multiple_messages_interleave(
1956 phi3v_model_config_mm_interleaved,
1957 image_url,
1958 ):
1959 conversation, mm_data, mm_uuids = parse_chat_messages(
1960 [
1961 {
1962 "role": "user",
1963 "content": [
1964 {"type": "text", "text": "What's on this image?"},
1965 {"type": "image_url", "image_url": {"url": image_url}},
1966 {"type": "text", "text": "Be accurate."},
1967 ],
1968 },
1969 {"role": "assistant", "content": "Some stuff."},
1970 {
1971 "role": "user",
1972 "content": [
1973 {"type": "text", "text": "What's on this image?"},
1974 {"type": "image_url", "image_url": {"url": image_url}},
1975 ],
1976 },
1977 ],
1978 phi3v_model_config_mm_interleaved,
1979 content_format="string",
1980 )
1981
1982 assert conversation == [
1983 {
1984 "role": "user",
1985 "content": "What's on this image?\n<|image_1|>\nBe accurate.",
1986 },
1987 {"role": "assistant", "content": "Some stuff."},
1988 {"role": "user", "content": "What's on this image?\n<|image_2|>"},
1989 ]
1990 _assert_mm_data_is_image_input(mm_data, 2)
1991 _assert_mm_uuids(mm_uuids, 2, expected_uuids=[None, None])
1992
1993
1994 def test_parse_chat_messages_multiple_images_with_uuids_multiple_messages_interleave(
1995 phi3v_model_config_mm_interleaved,
1996 image_url,
1997 ):
1998 image_uuid = str(hash(image_url))
1999 conversation, mm_data, mm_uuids = parse_chat_messages(
2000 [
2001 {
2002 "role": "user",
2003 "content": [
2004 {"type": "text", "text": "What's on this image?"},
2005 {
2006 "type": "image_url",
2007 "image_url": {"url": image_url},
2008 "uuid": image_uuid,
2009 },
2010 {"type": "text", "text": "Be accurate."},
2011 ],
2012 },
2013 {"role": "assistant", "content": "Some stuff."},
2014 {
2015 "role": "user",
2016 "content": [
2017 {"type": "text", "text": "What's on this image?"},
2018 {
2019 "type": "image_url",
2020 "image_url": {"url": image_url},
2021 "uuid": image_uuid,
2022 },
2023 ],
2024 },
2025 ],
2026 phi3v_model_config_mm_interleaved,
2027 content_format="string",
2028 )
2029
2030 assert conversation == [
2031 {
2032 "role": "user",
2033 "content": "What's on this image?\n<|image_1|>\nBe accurate.",
2034 },
2035 {"role": "assistant", "content": "Some stuff."},
2036 {"role": "user", "content": "What's on this image?\n<|image_2|>"},
2037 ]
2038 _assert_mm_data_is_image_input(mm_data, 2)
2039 _assert_mm_uuids(mm_uuids, 2, expected_uuids=[image_uuid, image_uuid])
2040
2041
2042 def test_parse_chat_messages_multiple_modals_multiple_messages_interleave(
2043 qwen25omni_model_config_mm_interleaved,
2044 image_url,
2045 video_url,
2046 audio_url,
2047 ):
2048 conversation, mm_data, mm_uuids = parse_chat_messages(
2049 [
2050 {
2051 "role": "user",
2052 "content": [
2053 {"type": "text", "text": "What's on this image?"},
2054 {"type": "image_url", "image_url": {"url": image_url}},
2055 {"type": "text", "text": "Now listen to this audio"},
2056 {"type": "audio_url", "audio_url": {"url": audio_url}},
2057 ],
2058 },
2059 {"role": "assistant", "content": "Some stuff."},
2060 {
2061 "role": "user",
2062 "content": [
2063 {"type": "text", "text": "What's on this image?"},
2064 {"type": "image_url", "image_url": {"url": image_url}},
2065 {"type": "text", "text": "And what's in the video?"},
2066 {"type": "video_url", "video_url": {"url": video_url}},
2067 ],
2068 },
2069 ],
2070 qwen25omni_model_config_mm_interleaved,
2071 content_format="string",
2072 )
2073
2074 assert conversation == [
2075 {
2076 "role": "user",
2077 "content": "What's on this image?\n<|vision_start|><|IMAGE|><|vision_end|>"
2078 "\nNow listen to this audio\nAudio 1: <|audio_bos|><|AUDIO|><|audio_eos|>",
2079 },
2080 {"role": "assistant", "content": "Some stuff."},
2081 {
2082 "role": "user",
2083 "content": "What's on this image?\n<|vision_start|><|IMAGE|><|vision_end|>"
2084 "\nAnd what's in the video?\n<|vision_start|><|VIDEO|><|vision_end|>",
2085 },
2086 ]
2087
2088 _assert_mm_data_inputs(mm_data, {"image": 2, "video": 1, "audio": 1})
2089 _assert_mm_uuids(mm_uuids, 2, modality="image", expected_uuids=[None, None])
2090 _assert_mm_uuids(mm_uuids, 1, modality="video", expected_uuids=[None])
2091 _assert_mm_uuids(mm_uuids, 1, modality="audio", expected_uuids=[None])
2092
2093
2094 def test_parse_chat_messages_multiple_modals_with_uuids_multiple_messages_interleave(
2095 qwen25omni_model_config_mm_interleaved,
2096 image_url,
2097 video_url,
2098 audio_url,
2099 ):
2100 conversation, mm_data, mm_uuids = parse_chat_messages(
2101 [
2102 {
2103 "role": "user",
2104 "content": [
2105 {"type": "text", "text": "What's on this image?"},
2106 {
2107 "type": "image_url",
2108 "image_url": {"url": image_url},
2109 "uuid": "image_123",
2110 },
2111 {"type": "text", "text": "Now listen to this audio"},
2112 {
2113 "type": "audio_url",
2114 "audio_url": {"url": audio_url},
2115 "uuid": "audio_123",
2116 },
2117 ],
2118 },
2119 {"role": "assistant", "content": "Some stuff."},
2120 {
2121 "role": "user",
2122 "content": [
2123 {"type": "text", "text": "What's on this image?"},
2124 {
2125 "type": "image_url",
2126 "image_url": {"url": image_url},
2127 "uuid": "image_123",
2128 },
2129 {"type": "text", "text": "And what's in the video?"},
2130 {
2131 "type": "video_url",
2132 "video_url": {"url": video_url},
2133 "uuid": "video_123",
2134 },
2135 ],
2136 },
2137 ],
2138 qwen25omni_model_config_mm_interleaved,
2139 content_format="string",
2140 )
2141
2142 assert conversation == [
2143 {
2144 "role": "user",
2145 "content": "What's on this image?\n<|vision_start|><|IMAGE|><|vision_end|>"
2146 "\nNow listen to this audio\nAudio 1: <|audio_bos|><|AUDIO|><|audio_eos|>",
2147 },
2148 {"role": "assistant", "content": "Some stuff."},
2149 {
2150 "role": "user",
2151 "content": "What's on this image?\n<|vision_start|><|IMAGE|><|vision_end|>"
2152 "\nAnd what's in the video?\n<|vision_start|><|VIDEO|><|vision_end|>",
2153 },
2154 ]
2155
2156 _assert_mm_data_inputs(mm_data, {"image": 2, "video": 1, "audio": 1})
2157 _assert_mm_uuids(
2158 mm_uuids, 2, modality="image", expected_uuids=["image_123", "image_123"]
2159 )
2160 _assert_mm_uuids(mm_uuids, 1, modality="video", expected_uuids=["video_123"])
2161 _assert_mm_uuids(mm_uuids, 1, modality="audio", expected_uuids=["audio_123"])
2162
2163
2164 def test_parse_chat_messages_multiple_modals_with_uuids_multiple_empty_media_messages_interleave( # noqa: E501
2165 qwen25omni_model_config_mm_interleaved,
2166 image_url,
2167 video_url,
2168 audio_url,
2169 ):
2170 conversation, mm_data, mm_uuids = parse_chat_messages(
2171 [
2172 {
2173 "role": "user",
2174 "content": [
2175 {"type": "text", "text": "What's on this image?"},
2176 {
2177 "type": "image_url",
2178 "image_url": None,
2179 "uuid": "image_123",
2180 },
2181 {"type": "text", "text": "Now listen to this audio"},
2182 {
2183 "type": "audio_url",
2184 "audio_url": None,
2185 "uuid": "audio_123",
2186 },
2187 ],
2188 },
2189 {"role": "assistant", "content": "Some stuff."},
2190 {
2191 "role": "user",
2192 "content": [
2193 {"type": "text", "text": "What's on this image?"},
2194 {
2195 "type": "image_url",
2196 "image_url": None,
2197 "uuid": "image_123",
2198 },
2199 {"type": "text", "text": "And what's in the video?"},
2200 {
2201 "type": "video_url",
2202 "video_url": None,
2203 "uuid": "video_123",
2204 },
2205 ],
2206 },
2207 ],
2208 qwen25omni_model_config_mm_interleaved,
2209 content_format="string",
2210 )
2211
2212 assert conversation == [
2213 {
2214 "role": "user",
2215 "content": "What's on this image?\n<|vision_start|><|IMAGE|><|vision_end|>"
2216 "\nNow listen to this audio\nAudio 1: <|audio_bos|><|AUDIO|><|audio_eos|>",
2217 },
2218 {"role": "assistant", "content": "Some stuff."},
2219 {
2220 "role": "user",
2221 "content": "What's on this image?\n<|vision_start|><|IMAGE|><|vision_end|>"
2222 "\nAnd what's in the video?\n<|vision_start|><|VIDEO|><|vision_end|>",
2223 },
2224 ]
2225
2226 _assert_mm_data_inputs(
2227 mm_data,
2228 {"image": 2, "video": 1, "audio": 1},
2229 skipped_media_indices={"image": [0, 1], "video": [0], "audio": [0]},
2230 )
2231 _assert_mm_uuids(
2232 mm_uuids, 2, modality="image", expected_uuids=["image_123", "image_123"]
2233 )
2234 _assert_mm_uuids(mm_uuids, 1, modality="video", expected_uuids=["video_123"])
2235 _assert_mm_uuids(mm_uuids, 1, modality="audio", expected_uuids=["audio_123"])
2236
2237
2238 def test_parse_chat_messages_multiple_modals_with_partial_uuids_multiple_messages_interleave( # noqa: E501
2239 qwen25omni_model_config_mm_interleaved,
2240 image_url,
2241 video_url,
2242 audio_url,
2243 ):
2244 conversation, mm_data, mm_uuids = parse_chat_messages(
2245 [
2246 {
2247 "role": "user",
2248 "content": [
2249 {"type": "text", "text": "What's on this image?"},
2250 {
2251 "type": "image_url",
2252 "image_url": {"url": image_url},
2253 "uuid": "image_123",
2254 },
2255 {"type": "text", "text": "Now listen to this audio"},
2256 {"type": "audio_url", "audio_url": {"url": audio_url}},
2257 ],
2258 },
2259 {"role": "assistant", "content": "Some stuff."},
2260 {
2261 "role": "user",
2262 "content": [
2263 {"type": "text", "text": "What's on this image?"},
2264 {"type": "image_url", "image_url": {"url": image_url}},
2265 {"type": "text", "text": "And what's in the video?"},
2266 {
2267 "type": "video_url",
2268 "video_url": {"url": video_url},
2269 "uuid": "video_123",
2270 },
2271 ],
2272 },
2273 ],
2274 qwen25omni_model_config_mm_interleaved,
2275 content_format="string",
2276 )
2277
2278 assert conversation == [
2279 {
2280 "role": "user",
2281 "content": "What's on this image?\n<|vision_start|><|IMAGE|><|vision_end|>"
2282 "\nNow listen to this audio\nAudio 1: <|audio_bos|><|AUDIO|><|audio_eos|>",
2283 },
2284 {"role": "assistant", "content": "Some stuff."},
2285 {
2286 "role": "user",
2287 "content": "What's on this image?\n<|vision_start|><|IMAGE|><|vision_end|>"
2288 "\nAnd what's in the video?\n<|vision_start|><|VIDEO|><|vision_end|>",
2289 },
2290 ]
2291
2292 _assert_mm_data_inputs(mm_data, {"image": 2, "video": 1, "audio": 1})
2293 _assert_mm_uuids(mm_uuids, 2, modality="image", expected_uuids=["image_123", None])
2294 _assert_mm_uuids(mm_uuids, 1, modality="video", expected_uuids=["video_123"])
2295 _assert_mm_uuids(mm_uuids, 1, modality="audio", expected_uuids=[None])
2296
2297
2298 def test_parse_chat_messages_multiple_images_interleave_with_placeholders(
2299 phi3v_model_config_mm_interleaved,
2300 image_url,
2301 ):
2302 with pytest.raises(
2303 VLLMValidationError,
2304 match=r"Found more '<|image_1|>' placeholders in input prompt "
2305 "than actual multimodal data items.",
2306 ):
2307 parse_chat_messages(
2308 [
2309 {
2310 "role": "user",
2311 "content": [
2312 {"type": "image_url", "image_url": {"url": image_url}},
2313 {"type": "image_url", "image_url": {"url": image_url}},
2314 {
2315 "type": "text",
2316 "text": "I need you to compare this image\n<|image_1|>\nand this one\n<|image_2|>\n" # noqa: E501
2317 "Do they have differences?",
2318 },
2319 ],
2320 }
2321 ],
2322 phi3v_model_config_mm_interleaved,
2323 content_format="string",
2324 )
2325
2326
2327 def test_parse_chat_messages_include_thinking_chunk(mistral_model_config):
2328 messages = [
2329 {
2330 "role": "system",
2331 "content": [
2332 {"type": "text", "text": "You are a helpful assistant."},
2333 {
2334 "type": "thinking",
2335 "closed": True,
2336 "thinking": "Only return the answer when you are confident.",
2337 },
2338 ],
2339 },
2340 {"role": "user", "content": "What is 2+2?"},
2341 {
2342 "role": "assistant",
2343 "content": [
2344 {"type": "text", "text": "Let me think about it."},
2345 {"type": "thinking", "closed": True, "thinking": "2+2 = 4"},
2346 {
2347 "type": "text",
2348 "text": "The answer is 4.",
2349 },
2350 ],
2351 },
2352 ]
2353
2354 conversation_with_thinking, _, _ = parse_chat_messages(
2355 messages,
2356 mistral_model_config,
2357 content_format="openai",
2358 )
2359
2360 expected_conversation = [
2361 {
2362 "role": "system",
2363 "content": [
2364 {"type": "text", "text": "You are a helpful assistant."},
2365 {
2366 "type": "text",
2367 "text": "Only return the answer when you are confident.",
2368 },
2369 ],
2370 },
2371 {
2372 "role": "user",
2373 "content": [{"type": "text", "text": "What is 2+2?"}],
2374 },
2375 {
2376 "role": "assistant",
2377 "content": [
2378 {"type": "text", "text": "Let me think about it."},
2379 {"type": "text", "text": "2+2 = 4"},
2380 {"type": "text", "text": "The answer is 4."},
2381 ],
2382 },
2383 ]
2384
2385 assert conversation_with_thinking == expected_conversation
2386
2387
2388 @pytest.mark.parametrize("input_audio", [None, {}, {"data": "", "format": "wav"}])
2389 def test_parse_chat_messages_single_empty_audio_with_uuid(
2390 qwen2_audio_model_config,
2391 input_audio,
2392 ):
2393 audio_uuid = "abcd"
2394 conversation, mm_data, mm_uuids = parse_chat_messages(
2395 [
2396 {
2397 "role": "user",
2398 "content": [
2399 {
2400 "type": "input_audio",
2401 "input_audio": input_audio,
2402 "uuid": audio_uuid,
2403 },
2404 {"type": "text", "text": "What does the audio say?"},
2405 ],
2406 }
2407 ],
2408 qwen2_audio_model_config,
2409 content_format="string",
2410 )
2411
2412 assert conversation == [
2413 {
2414 "role": "user",
2415 "content": "Audio 1: <|audio_bos|><|AUDIO|><|audio_eos|>\nWhat does the "
2416 "audio say?",
2417 }
2418 ]
2419 _assert_mm_data_inputs(mm_data, {"audio": 1})
2420 _assert_mm_uuids(mm_uuids, 1, modality="audio", expected_uuids=[audio_uuid])
2421
2422
2423 @pytest.mark.asyncio
2424 @pytest.mark.parametrize("input_audio", [None, {}, {"data": "", "format": "wav"}])
2425 async def test_parse_chat_messages_single_empty_audio_with_uuid_async(
2426 qwen2_audio_model_config,
2427 input_audio,
2428 ):
2429 audio_uuid = "abcd"
2430 conversation, mm_data, mm_uuids = await parse_chat_messages_async(
2431 [
2432 {
2433 "role": "user",
2434 "content": [
2435 {
2436 "type": "input_audio",
2437 "input_audio": input_audio,
2438 "uuid": audio_uuid,
2439 },
2440 {"type": "text", "text": "What does the audio say?"},
2441 ],
2442 }
2443 ],
2444 qwen2_audio_model_config,
2445 content_format="string",
2446 )
2447
2448 assert conversation == [
2449 {
2450 "role": "user",
2451 "content": "Audio 1: <|audio_bos|><|AUDIO|><|audio_eos|>\nWhat does the "
2452 "audio say?",
2453 }
2454 ]
2455 _assert_mm_data_inputs(mm_data, {"audio": 1})
2456 _assert_mm_uuids(mm_uuids, 1, modality="audio", expected_uuids=[audio_uuid])
2457
2458
2459 def test_parse_chat_messages_image_vision_chunk(
2460 kimi_k2_5_model_config,
2461 image_url,
2462 ):
2463 messages: list[ChatCompletionMessageParam] = [
2464 {
2465 "role": "user",
2466 "content": [
2467 {"type": "text", "text": "Analyze this image."},
2468 {
2469 "type": "image_url",
2470 "image_url": {"url": image_url},
2471 },
2472 ],
2473 }
2474 ]
2475
2476 conversation, mm_data, mm_uuids = parse_chat_messages(
2477 messages,
2478 kimi_k2_5_model_config,
2479 content_format="string",
2480 )
2481
2482 placeholder = "<|media_begin|>image<|media_content|><|media_pad|><|media_end|>"
2483 expected_conversation = [
2484 {
2485 "role": "user",
2486 "content": f"{placeholder}\nAnalyze this image.",
2487 }
2488 ]
2489
2490 assert conversation == expected_conversation
2491 _assert_mm_data_is_vision_chunk_input(mm_data, 1)
2492 _assert_mm_uuids(mm_uuids, 1, expected_uuids=[None], modality="vision_chunk")
2493
2494
2495 def test_parse_chat_messages_video_vision_chunk(
2496 kimi_k2_5_model_config,
2497 video_url,
2498 ):
2499 messages: list[ChatCompletionMessageParam] = [
2500 {
2501 "role": "user",
2502 "content": [
2503 {"type": "text", "text": "Analyze this video."},
2504 {
2505 "type": "video_url",
2506 "video_url": {"url": video_url},
2507 },
2508 ],
2509 }
2510 ]
2511
2512 conversation, mm_data, mm_uuids = parse_chat_messages(
2513 messages,
2514 kimi_k2_5_model_config,
2515 content_format="string",
2516 )
2517
2518 placeholder = "<|kimi_k25_video_placeholder|>"
2519 expected_conversation = [
2520 {
2521 "role": "user",
2522 "content": f"{placeholder}\nAnalyze this video.",
2523 }
2524 ]
2525
2526 assert conversation == expected_conversation
2527 _assert_mm_data_is_vision_chunk_input(mm_data, 1)
2528 _assert_mm_uuids(mm_uuids, 1, expected_uuids=[None], modality="vision_chunk")
2529
2530
2531 def test_parse_chat_messages_image_vision_chunk_with_uuid(
2532 kimi_k2_5_model_config,
2533 image_url,
2534 ):
2535 image_uuid = "image_123"
2536 messages: list[ChatCompletionMessageParam] = [
2537 {
2538 "role": "user",
2539 "content": [
2540 {"type": "text", "text": "Analyze this image."},
2541 {
2542 "type": "image_url",
2543 "image_url": {"url": image_url},
2544 "uuid": image_uuid,
2545 },
2546 ],
2547 }
2548 ]
2549
2550 conversation, mm_data, mm_uuids = parse_chat_messages(
2551 messages,
2552 kimi_k2_5_model_config,
2553 content_format="string",
2554 )
2555
2556 placeholder = "<|media_begin|>image<|media_content|><|media_pad|><|media_end|>"
2557 expected_conversation = [
2558 {
2559 "role": "user",
2560 "content": f"{placeholder}\nAnalyze this image.",
2561 }
2562 ]
2563
2564 assert conversation == expected_conversation
2565 _assert_mm_data_is_vision_chunk_input(mm_data, 1)
2566 _assert_mm_uuids(mm_uuids, 1, expected_uuids=[image_uuid], modality="vision_chunk")
2567
2568
2569 def test_parse_chat_messages_video_vision_chunk_with_uuid(
2570 kimi_k2_5_model_config,
2571 video_url,
2572 ):
2573 video_uuid = "video_456"
2574 messages: list[ChatCompletionMessageParam] = [
2575 {
2576 "role": "user",
2577 "content": [
2578 {"type": "text", "text": "Analyze this video."},
2579 {
2580 "type": "video_url",
2581 "video_url": {"url": video_url},
2582 "uuid": video_uuid,
2583 },
2584 ],
2585 }
2586 ]
2587
2588 conversation, mm_data, mm_uuids = parse_chat_messages(
2589 messages,
2590 kimi_k2_5_model_config,
2591 content_format="string",
2592 )
2593
2594 placeholder = "<|kimi_k25_video_placeholder|>"
2595 expected_conversation = [
2596 {
2597 "role": "user",
2598 "content": f"{placeholder}\nAnalyze this video.",
2599 }
2600 ]
2601
2602 assert conversation == expected_conversation
2603 _assert_mm_data_is_vision_chunk_input(mm_data, 1)
2604 _assert_mm_uuids(mm_uuids, 1, expected_uuids=[video_uuid], modality="vision_chunk")
2605
2606
2607 def test_parse_chat_messages_mixed_vision_chunk(
2608 kimi_k2_5_model_config,
2609 image_url,
2610 video_url,
2611 ):
2612 messages: list[ChatCompletionMessageParam] = [
2613 {
2614 "role": "user",
2615 "content": [
2616 {"type": "text", "text": "Analyze this image and video."},
2617 {
2618 "type": "image_url",
2619 "image_url": {"url": image_url},
2620 },
2621 {
2622 "type": "video_url",
2623 "video_url": {"url": video_url},
2624 },
2625 ],
2626 }
2627 ]
2628
2629 conversation, mm_data, mm_uuids = parse_chat_messages(
2630 messages,
2631 kimi_k2_5_model_config,
2632 content_format="string",
2633 )
2634
2635 image_placeholder = (
2636 "<|media_begin|>image<|media_content|><|media_pad|><|media_end|>"
2637 )
2638 video_placeholder = "<|kimi_k25_video_placeholder|>"
2639 expected_conversation = [
2640 {
2641 "role": "user",
2642 "content": (
2643 f"{image_placeholder}\n{video_placeholder}\n"
2644 "Analyze this image and video."
2645 ),
2646 }
2647 ]
2648
2649 assert conversation == expected_conversation
2650 _assert_mm_data_is_vision_chunk_input(mm_data, 2)
2651 _assert_mm_uuids(mm_uuids, 2, expected_uuids=[None, None], modality="vision_chunk")
2652
2653
2654 def test_parse_chat_messages_mixed_vision_chunk_with_uuid(
2655 kimi_k2_5_model_config,
2656 image_url,
2657 video_url,
2658 ):
2659 image_uuid = "image_123"
2660 video_uuid = "video_456"
2661 messages: list[ChatCompletionMessageParam] = [
2662 {
2663 "role": "user",
2664 "content": [
2665 {"type": "text", "text": "Analyze this image and video."},
2666 {
2667 "type": "image_url",
2668 "image_url": {"url": image_url},
2669 "uuid": image_uuid,
2670 },
2671 {
2672 "type": "video_url",
2673 "video_url": {"url": video_url},
2674 "uuid": video_uuid,
2675 },
2676 ],
2677 }
2678 ]
2679
2680 conversation, mm_data, mm_uuids = parse_chat_messages(
2681 messages,
2682 kimi_k2_5_model_config,
2683 content_format="string",
2684 )
2685
2686 image_placeholder = (
2687 "<|media_begin|>image<|media_content|><|media_pad|><|media_end|>"
2688 )
2689 video_placeholder = "<|kimi_k25_video_placeholder|>"
2690 expected_conversation = [
2691 {
2692 "role": "user",
2693 "content": (
2694 f"{image_placeholder}\n{video_placeholder}\n"
2695 "Analyze this image and video."
2696 ),
2697 }
2698 ]
2699
2700 assert conversation == expected_conversation
2701 _assert_mm_data_is_vision_chunk_input(mm_data, 2)
2702 _assert_mm_uuids(
2703 mm_uuids, 2, expected_uuids=[image_uuid, video_uuid], modality="vision_chunk"
2704 )
2705
2706
2707 @pytest.mark.asyncio
2708 async def test_parse_chat_messages_mixed_vision_chunk_async(
2709 kimi_k2_5_model_config,
2710 image_url,
2711 video_url,
2712 ):
2713 messages: list[ChatCompletionMessageParam] = [
2714 {
2715 "role": "user",
2716 "content": [
2717 {"type": "text", "text": "Analyze this image and video."},
2718 {
2719 "type": "image_url",
2720 "image_url": {"url": image_url},
2721 },
2722 {
2723 "type": "video_url",
2724 "video_url": {"url": video_url},
2725 },
2726 ],
2727 }
2728 ]
2729
2730 conversation, mm_data, mm_uuids = await parse_chat_messages_async(
2731 messages,
2732 kimi_k2_5_model_config,
2733 content_format="string",
2734 )
2735
2736 image_placeholder = (
2737 "<|media_begin|>image<|media_content|><|media_pad|><|media_end|>"
2738 )
2739 video_placeholder = "<|kimi_k25_video_placeholder|>"
2740 expected_conversation = [
2741 {
2742 "role": "user",
2743 "content": (
2744 f"{image_placeholder}\n{video_placeholder}\n"
2745 "Analyze this image and video."
2746 ),
2747 }
2748 ]
2749
2750 assert conversation == expected_conversation
2751 _assert_mm_data_is_vision_chunk_input(mm_data, 2)
2752 _assert_mm_uuids(mm_uuids, 2, expected_uuids=[None, None], modality="vision_chunk")
2753
2754
2755 @pytest.mark.asyncio
2756 async def test_parse_chat_messages_mixed_vision_chunk_with_uuid_async(
2757 kimi_k2_5_model_config,
2758 image_url,
2759 video_url,
2760 ):
2761 image_uuid = "image_123"
2762 video_uuid = "video_456"
2763 messages: list[ChatCompletionMessageParam] = [
2764 {
2765 "role": "user",
2766 "content": [
2767 {"type": "text", "text": "Analyze this image and video."},
2768 {
2769 "type": "image_url",
2770 "image_url": {"url": image_url},
2771 "uuid": image_uuid,
2772 },
2773 {
2774 "type": "video_url",
2775 "video_url": {"url": video_url},
2776 "uuid": video_uuid,
2777 },
2778 ],
2779 }
2780 ]
2781
2782 conversation, mm_data, mm_uuids = await parse_chat_messages_async(
2783 messages,
2784 kimi_k2_5_model_config,
2785 content_format="string",
2786 )
2787
2788 image_placeholder = (
2789 "<|media_begin|>image<|media_content|><|media_pad|><|media_end|>"
2790 )
2791 video_placeholder = "<|kimi_k25_video_placeholder|>"
2792 expected_conversation = [
2793 {
2794 "role": "user",
2795 "content": (
2796 f"{image_placeholder}\n{video_placeholder}\n"
2797 "Analyze this image and video."
2798 ),
2799 }
2800 ]
2801
2802 assert conversation == expected_conversation
2803 _assert_mm_data_is_vision_chunk_input(mm_data, 2)
2804 _assert_mm_uuids(
2805 mm_uuids, 2, expected_uuids=[image_uuid, video_uuid], modality="vision_chunk"
2806 )
2807
2808
2809 @pytest.mark.asyncio
2810 async def test_parse_chat_messages_image_vision_chunk_async(
2811 kimi_k2_5_model_config,
2812 image_url,
2813 ):
2814 messages: list[ChatCompletionMessageParam] = [
2815 {
2816 "role": "user",
2817 "content": [
2818 {"type": "text", "text": "Analyze this image."},
2819 {
2820 "type": "image_url",
2821 "image_url": {"url": image_url},
2822 },
2823 ],
2824 }
2825 ]
2826
2827 conversation, mm_data, mm_uuids = await parse_chat_messages_async(
2828 messages,
2829 kimi_k2_5_model_config,
2830 content_format="string",
2831 )
2832
2833 placeholder = "<|media_begin|>image<|media_content|><|media_pad|><|media_end|>"
2834 expected_conversation = [
2835 {
2836 "role": "user",
2837 "content": f"{placeholder}\nAnalyze this image.",
2838 }
2839 ]
2840
2841 assert conversation == expected_conversation
2842 _assert_mm_data_is_vision_chunk_input(mm_data, 1)
2843 _assert_mm_uuids(mm_uuids, 1, expected_uuids=[None], modality="vision_chunk")
2844
2845
2846 @pytest.mark.asyncio
2847 async def test_parse_chat_messages_video_vision_chunk_async(
2848 kimi_k2_5_model_config,
2849 video_url,
2850 ):
2851 messages: list[ChatCompletionMessageParam] = [
2852 {
2853 "role": "user",
2854 "content": [
2855 {"type": "text", "text": "Analyze this video."},
2856 {
2857 "type": "video_url",
2858 "video_url": {"url": video_url},
2859 },
2860 ],
2861 }
2862 ]
2863
2864 conversation, mm_data, mm_uuids = await parse_chat_messages_async(
2865 messages,
2866 kimi_k2_5_model_config,
2867 content_format="string",
2868 )
2869
2870 placeholder = "<|kimi_k25_video_placeholder|>"
2871 expected_conversation = [
2872 {
2873 "role": "user",
2874 "content": f"{placeholder}\nAnalyze this video.",
2875 }
2876 ]
2877
2878 assert conversation == expected_conversation
2879 _assert_mm_data_is_vision_chunk_input(mm_data, 1)
2880 _assert_mm_uuids(mm_uuids, 1, expected_uuids=[None], modality="vision_chunk")
2881
2882
2883 @pytest.mark.asyncio
2884 async def test_parse_chat_messages_image_vision_chunk_with_uuid_async(
2885 kimi_k2_5_model_config,
2886 image_url,
2887 ):
2888 image_uuid = "image_123"
2889 messages: list[ChatCompletionMessageParam] = [
2890 {
2891 "role": "user",
2892 "content": [
2893 {"type": "text", "text": "Analyze this image."},
2894 {
2895 "type": "image_url",
2896 "image_url": {"url": image_url},
2897 "uuid": image_uuid,
2898 },
2899 ],
2900 }
2901 ]
2902
2903 conversation, mm_data, mm_uuids = await parse_chat_messages_async(
2904 messages,
2905 kimi_k2_5_model_config,
2906 content_format="string",
2907 )
2908
2909 placeholder = "<|media_begin|>image<|media_content|><|media_pad|><|media_end|>"
2910 expected_conversation = [
2911 {
2912 "role": "user",
2913 "content": f"{placeholder}\nAnalyze this image.",
2914 }
2915 ]
2916
2917 assert conversation == expected_conversation
2918 _assert_mm_data_is_vision_chunk_input(mm_data, 1)
2919 _assert_mm_uuids(mm_uuids, 1, expected_uuids=[image_uuid], modality="vision_chunk")
2920
2921
2922 @pytest.mark.asyncio
2923 async def test_parse_chat_messages_video_vision_chunk_with_uuid_async(
2924 kimi_k2_5_model_config,
2925 video_url,
2926 ):
2927 video_uuid = "video_456"
2928 messages: list[ChatCompletionMessageParam] = [
2929 {
2930 "role": "user",
2931 "content": [
2932 {"type": "text", "text": "Analyze this video."},
2933 {
2934 "type": "video_url",
2935 "video_url": {"url": video_url},
2936 "uuid": video_uuid,
2937 },
2938 ],
2939 }
2940 ]
2941
2942 conversation, mm_data, mm_uuids = await parse_chat_messages_async(
2943 messages,
2944 kimi_k2_5_model_config,
2945 content_format="string",
2946 )
2947
2948 placeholder = "<|kimi_k25_video_placeholder|>"
2949 expected_conversation = [
2950 {
2951 "role": "user",
2952 "content": f"{placeholder}\nAnalyze this video.",
2953 }
2954 ]
2955
2956 assert conversation == expected_conversation
2957 _assert_mm_data_is_vision_chunk_input(mm_data, 1)
2958 _assert_mm_uuids(mm_uuids, 1, expected_uuids=[video_uuid], modality="vision_chunk")
2959
2960
2961 def test_postprocess_messages_null_arguments_string():
2962 """arguments="null" must not reach the chat template as Python None.
2963
2964 json.loads("null") returns None, which causes Jinja2 templates that call
2965 tc.arguments.items() to raise 'None' has no attribute 'items'.
2966 The function should coerce it to {} instead.
2967 """
2968 messages: list[ConversationMessage] = [
2969 {
2970 "role": "assistant",
2971 "content": None,
2972 "tool_calls": [
2973 {
2974 "id": "call_1",
2975 "type": "function",
2976 "function": {"name": "get_current_time", "arguments": "null"},
2977 }
2978 ],
2979 }
2980 ]
2981 _postprocess_messages(messages)
2982 tool_calls = messages[0]["tool_calls"]
2983 assert tool_calls is not None
2984 assert tool_calls[0]["function"]["arguments"] == {}
2985
2986
2987 @pytest.mark.asyncio
2988 async def test_resolve_items_runs_modalities_concurrently_and_preserves_order():
2989 """Media fetches overlap while modality and item order are preserved."""
2990 active_fetches = 0
2991 max_active_fetches = 0
2992
2993 async def _fetch(name: str, delay: float):
2994 nonlocal active_fetches, max_active_fetches
2995 active_fetches += 1
2996 max_active_fetches = max(max_active_fetches, active_fetches)
2997 try:
2998 await asyncio.sleep(delay)
2999 return name, f"{name}-uuid"
3000 finally:
3001 active_fetches -= 1
3002
3003 tracker = AsyncMultiModalItemTracker(MagicMock(is_multimodal_model=True))
3004 tracker.__dict__["mm_processor"] = MagicMock()
3005 tracker._items_by_modality["video"] = [
3006 lambda: _fetch("video-0", 0.02),
3007 lambda: _fetch("video-1", 0.01),
3008 ]
3009 tracker._items_by_modality["image"] = [
3010 lambda: _fetch("image-0", 0.02),
3011 lambda: _fetch("image-1", 0.01),
3012 ]
3013 tracker._items_by_modality["audio"] = [
3014 lambda: _fetch("audio-0", 0.02),
3015 lambda: _fetch("audio-1", 0.01),
3016 ]
3017
3018 mm_data, mm_uuids = await tracker.resolve_items()
3019
3020 assert mm_data is not None
3021 assert mm_uuids is not None
3022 assert max_active_fetches == 6
3023 assert list(mm_data) == ["image", "audio", "video"]
3024 assert list(mm_uuids) == ["image", "audio", "video"]
3025 assert mm_data == {
3026 "image": ["image-0", "image-1"],
3027 "audio": ["audio-0", "audio-1"],
3028 "video": ["video-0", "video-1"],
3029 }
3030 assert mm_uuids == {
3031 "image": ["image-0-uuid", "image-1-uuid"],
3032 "audio": ["audio-0-uuid", "audio-1-uuid"],
3033 "video": ["video-0-uuid", "video-1-uuid"],
3034 }
3035
3036
3037 @pytest.mark.asyncio
3038 async def test_resolve_items_does_not_leak_tasks_on_partial_failure():
3039 """Regression test: one failing media fetch must not abandon the other
3040 still-in-flight fetches across modality batches.
3041
3042 Before the fix, `resolve_items` gathered per-modality fetches with plain
3043 `asyncio.gather`, so the first exception propagated immediately while
3044 sibling fetches (real network/thread-pool work in production) kept
3045 running detached, with nothing left to await or cancel them.
3046 """
3047
3048 async def _fetch(should_fail: bool, delay: float):
3049 if should_fail:
3050 await asyncio.sleep(0.01)
3051 raise ValueError("simulated fetch failure")
3052 await asyncio.sleep(delay)
3053 return ("decoded", None)
3054
3055 tracker = AsyncMultiModalItemTracker(MagicMock())
3056 tracker._items_by_modality["image"] = [
3057 lambda: _fetch(True, 0),
3058 lambda: _fetch(False, 0.2),
3059 lambda: _fetch(False, 0.2),
3060 ]
3061 tracker._items_by_modality["audio"] = [lambda: _fetch(False, 0.2)]
3062 tracker._items_by_modality["video"] = [lambda: _fetch(False, 0.2)]
3063
3064 tasks_before = asyncio.all_tasks()
3065 with pytest.raises(ValueError, match="simulated fetch failure"):
3066 await tracker.resolve_items()
3067
3068 leaked_tasks = asyncio.all_tasks() - tasks_before
3069 assert not leaked_tasks, (
3070 f"resolve_items left {len(leaked_tasks)} task(s) running after "
3071 f"raising: {leaked_tasks}"
3072 )
3073
3074
3075 def _assistant_tool_call(arguments, name="write"):
3076 return [
3077 {
3078 "role": "assistant",
3079 "content": "",
3080 "tool_calls": [
3081 {
3082 "id": "call_0",
3083 "type": "function",
3084 "function": {"name": name, "arguments": arguments},
3085 }
3086 ],
3087 }
3088 ]
3089
3090
3091 def _assistant_tool_calls(calls):
3092 return [
3093 {
3094 "role": "assistant",
3095 "content": "",
3096 "tool_calls": calls,
3097 }
3098 ]
3099
3100
3101 def test_tool_call_arguments_dict_passthrough(caplog):
3102 messages = _assistant_tool_call({"a": 1})
3103 _postprocess_messages(messages)
3104 args = messages[0]["tool_calls"][0]["function"]["arguments"]
3105 assert args == {"a": 1}
3106 assert not caplog.records
3107
3108
3109 def test_tool_call_arguments_valid_object_string(caplog):
3110 messages = _assistant_tool_call('{"a": 1}')
3111 _postprocess_messages(messages)
3112 args = messages[0]["tool_calls"][0]["function"]["arguments"]
3113 assert args == {"a": 1}
3114 assert not caplog.records
3115
3116
3117 def test_tool_call_arguments_malformed_json_small(caplog):
3118 bad = '{"cmd": "mkdir -p /tmp/mmadtest && cat > /tmp/mmadtest'
3119 messages = _assistant_tool_call(bad, name="exec")
3120 _postprocess_messages(messages)
3121 args = messages[0]["tool_calls"][0]["function"]["arguments"]
3122 assert args == {}
3123
3124 assert len(caplog.records) == 1
3125 assert "exec" in caplog.records[0].message
3126 assert "coercing to an empty object" in caplog.records[0].message
3127
3128
3129 def test_tool_call_arguments_malformed_json_large(caplog):
3130 bad = '{"filepath": "src/main.py", "contents": "' + ("x" * 18000)
3131 messages = _assistant_tool_call(bad, name="write")
3132 _postprocess_messages(messages)
3133 args = messages[0]["tool_calls"][0]["function"]["arguments"]
3134 assert args == {}
3135
3136 assert len(caplog.records) == 1
3137 assert "write" in caplog.records[0].message
3138 assert "coercing to an empty object" in caplog.records[0].message
3139
3140
3141 @pytest.mark.parametrize("non_obj", ["[]", "42", "true", '"hello"', "null"])
3142 def test_tool_call_arguments_valid_json_non_object(caplog, non_obj):
3143 messages = _assistant_tool_call(non_obj, name="bad_tool")
3144 _postprocess_messages(messages)
3145 args = messages[0]["tool_calls"][0]["function"]["arguments"]
3146 assert args == {}
3147
3148 if non_obj == "null":
3149 # null parses to None, so no warning is emitted according to requirements
3150 assert not caplog.records
3151 else:
3152 assert len(caplog.records) == 1
3153 assert "bad_tool" in caplog.records[0].message
3154 assert "not a JSON object" in caplog.records[0].message
3155
3156
3157 @pytest.mark.parametrize("missing", [None, ""])
3158 def test_tool_call_arguments_missing_or_empty(caplog, missing):
3159 messages = _assistant_tool_call(missing)
3160 if missing is None:
3161 del messages[0]["tool_calls"][0]["function"]["arguments"]
3162 _postprocess_messages(messages)
3163 args = messages[0]["tool_calls"][0]["function"]["arguments"]
3164 assert args == {}
3165 assert not caplog.records
3166
3167
3168 def test_tool_call_arguments_multiple_independent(caplog):
3169 calls = [
3170 {
3171 "id": "call_0",
3172 "type": "function",
3173 "function": {"name": "good", "arguments": '{"a": 1}'},
3174 },
3175 {
3176 "id": "call_1",
3177 "type": "function",
3178 "function": {"name": "bad", "arguments": '{"cmd": "'},
3179 },
3180 ]
3181 messages = _assistant_tool_calls(calls)
3182 _postprocess_messages(messages)
3183
3184 tool_calls = messages[0]["tool_calls"]
3185 assert tool_calls[0]["function"]["arguments"] == {"a": 1}
3186 assert tool_calls[1]["function"]["arguments"] == {}
3187
3188 assert len(caplog.records) == 1
3189 assert "bad" in caplog.records[0].message
3190
3191
3192 def test_validate_chat_template_rejects_invalid_type():
3193 """A non-str/Path chat_template is invalid user input."""
3194 with pytest.raises(
3195 VLLMValidationError, match="not a valid chat template type"
3196 ) as exc_info:
3197 validate_chat_template(123)
3198 assert exc_info.value.parameter == "chat_template"