[Test] Split the chat_utils tests. (#61765)
Signed-off-by: wang.yuqi <yuqi.wang@daocloud.io>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm.assets.audio import AudioAsset
|
||||
from vllm.assets.image import ImageAsset
|
||||
from vllm.assets.video import VideoAsset
|
||||
from vllm.multimodal.utils import encode_audio_url, encode_image_url, encode_video_url
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def image_url():
|
||||
image = ImageAsset("cherry_blossom")
|
||||
return encode_image_url(image.pil_image)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def video_url():
|
||||
video = VideoAsset("baby_reading", 1)
|
||||
return encode_video_url(video.np_ndarrays)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def audio_url():
|
||||
audio = AudioAsset("mary_had_lamb")
|
||||
return encode_audio_url(*audio.audio_and_sample_rate)
|
||||
@@ -0,0 +1,51 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from vllm.renderers.chat_utils import ConversationMessage, parse_chat_message_content
|
||||
|
||||
|
||||
def _parse_one(message) -> ConversationMessage:
|
||||
# Plain-text content only reads these two tracker attributes.
|
||||
tracker = MagicMock()
|
||||
parser = tracker.create_parser.return_value
|
||||
parser.model_config.enable_prompt_embeds = False
|
||||
parser.mm_placeholder_storage.return_value = {}
|
||||
result = parse_chat_message_content(
|
||||
message, tracker, content_format="string", interleave_strings=False
|
||||
)
|
||||
assert len(result) == 1
|
||||
return result[0]
|
||||
|
||||
|
||||
def test_assistant_reasoning_is_mirrored_to_reasoning_content():
|
||||
"""``reasoning`` is mirrored to ``reasoning_content`` for legacy templates."""
|
||||
conv_msg = _parse_one(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "4",
|
||||
"reasoning": "2+2 equals 4",
|
||||
}
|
||||
)
|
||||
|
||||
assert conv_msg.get("reasoning") == "2+2 equals 4"
|
||||
assert conv_msg.get("reasoning_content") == "2+2 equals 4"
|
||||
|
||||
|
||||
def test_assistant_without_reasoning_sets_neither_field():
|
||||
"""No reasoning in, no reasoning keys out: no empty thinking block in templates."""
|
||||
conv_msg = _parse_one({"role": "assistant", "content": "Hi"})
|
||||
|
||||
assert "reasoning" not in conv_msg
|
||||
assert "reasoning_content" not in conv_msg
|
||||
|
||||
|
||||
def test_non_assistant_reasoning_is_not_mirrored():
|
||||
"""Only assistant turns carry reasoning; a user turn must not."""
|
||||
conv_msg = _parse_one(
|
||||
{"role": "user", "content": "Hi", "reasoning": "should be ignored"}
|
||||
)
|
||||
|
||||
assert "reasoning" not in conv_msg
|
||||
assert "reasoning_content" not in conv_msg
|
||||
@@ -0,0 +1,224 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import pytest
|
||||
|
||||
from vllm.config import ModelConfig
|
||||
from vllm.renderers.chat_utils import parse_chat_messages, parse_chat_messages_async
|
||||
from vllm.utils.serial_utils import tensor2base64
|
||||
|
||||
from .utils import (
|
||||
assert_mm_data_inputs,
|
||||
assert_mm_uuids,
|
||||
)
|
||||
|
||||
QWEN2AUDIO_MODEL_ID = "Qwen/Qwen2-Audio-7B-Instruct"
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def qwen2_audio_model_config():
|
||||
return ModelConfig(
|
||||
QWEN2AUDIO_MODEL_ID,
|
||||
runner="generate",
|
||||
trust_remote_code=True,
|
||||
limit_mm_per_prompt={
|
||||
"audio": 1,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def audio_embeds_model_config():
|
||||
return ModelConfig(
|
||||
QWEN2AUDIO_MODEL_ID,
|
||||
runner="generate",
|
||||
trust_remote_code=True,
|
||||
limit_mm_per_prompt={
|
||||
"audio": 2,
|
||||
},
|
||||
enable_mm_embeds=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("input_audio", [None, {}, {"data": "", "format": "wav"}])
|
||||
def test_parse_chat_messages_single_empty_audio_with_uuid(
|
||||
qwen2_audio_model_config,
|
||||
input_audio,
|
||||
):
|
||||
audio_uuid = "abcd"
|
||||
conversation, mm_data, mm_uuids = parse_chat_messages(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_audio",
|
||||
"input_audio": input_audio,
|
||||
"uuid": audio_uuid,
|
||||
},
|
||||
{"type": "text", "text": "What does the audio say?"},
|
||||
],
|
||||
}
|
||||
],
|
||||
qwen2_audio_model_config,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
assert conversation == [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Audio 1: <|audio_bos|><|AUDIO|><|audio_eos|>\nWhat does the "
|
||||
"audio say?",
|
||||
}
|
||||
]
|
||||
assert_mm_data_inputs(mm_data, {"audio": 1})
|
||||
assert_mm_uuids(mm_uuids, 1, modality="audio", expected_uuids=[audio_uuid])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("input_audio", [None, {}, {"data": "", "format": "wav"}])
|
||||
async def test_parse_chat_messages_single_empty_audio_with_uuid_async(
|
||||
qwen2_audio_model_config,
|
||||
input_audio,
|
||||
):
|
||||
audio_uuid = "abcd"
|
||||
conversation, mm_data, mm_uuids = await parse_chat_messages_async(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_audio",
|
||||
"input_audio": input_audio,
|
||||
"uuid": audio_uuid,
|
||||
},
|
||||
{"type": "text", "text": "What does the audio say?"},
|
||||
],
|
||||
}
|
||||
],
|
||||
qwen2_audio_model_config,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
assert conversation == [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Audio 1: <|audio_bos|><|AUDIO|><|audio_eos|>\nWhat does the "
|
||||
"audio say?",
|
||||
}
|
||||
]
|
||||
assert_mm_data_inputs(mm_data, {"audio": 1})
|
||||
assert_mm_uuids(mm_uuids, 1, modality="audio", expected_uuids=[audio_uuid])
|
||||
|
||||
|
||||
def test_parse_chat_messages_empty_audio_embeds_with_uuid(
|
||||
audio_embeds_model_config,
|
||||
):
|
||||
"""Test audio_embeds with UUID (no actual embeds data)."""
|
||||
uuid = "test-audio-uuid-123"
|
||||
|
||||
conversation, mm_data, mm_uuids = parse_chat_messages(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Describe this audio"},
|
||||
{"type": "audio_embeds", "audio_embeds": None, "uuid": uuid},
|
||||
],
|
||||
}
|
||||
],
|
||||
audio_embeds_model_config,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
# Should have audio in mm_data as None (UUID provided)
|
||||
assert mm_data is not None
|
||||
assert "audio" in mm_data
|
||||
assert isinstance(mm_data["audio"], list)
|
||||
assert len(mm_data["audio"]) == 1
|
||||
assert mm_data["audio"][0] is None
|
||||
|
||||
# UUID should be recorded
|
||||
assert_mm_uuids(mm_uuids, 1, modality="audio", expected_uuids=[uuid])
|
||||
|
||||
|
||||
def test_parse_chat_messages_audio_embeds_with_string(
|
||||
audio_embeds_model_config,
|
||||
):
|
||||
"""Test audio_embeds with base64 string embedding data."""
|
||||
import torch
|
||||
|
||||
# Create a sample audio embedding tensor
|
||||
hidden_size = audio_embeds_model_config.get_inputs_embeds_size()
|
||||
audio_embedding = torch.randn(1, 128, hidden_size)
|
||||
|
||||
# Encode it as base64
|
||||
base64_audio_embedding = tensor2base64(audio_embedding)
|
||||
|
||||
conversation, mm_data, mm_uuids = parse_chat_messages(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Describe this audio"},
|
||||
{
|
||||
"type": "audio_embeds",
|
||||
"audio_embeds": base64_audio_embedding,
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
audio_embeds_model_config,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
# Should have audio embedding in mm_data (single tensor, not a list)
|
||||
assert mm_data is not None
|
||||
assert "audio" in mm_data
|
||||
assert isinstance(mm_data["audio"], torch.Tensor)
|
||||
assert mm_data["audio"].shape == audio_embedding.shape
|
||||
# No UUID provided
|
||||
assert mm_uuids is not None
|
||||
assert "audio" in mm_uuids
|
||||
assert_mm_uuids(mm_uuids, 1, modality="audio", expected_uuids=[None])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_chat_messages_audio_embeds_async(
|
||||
audio_embeds_model_config,
|
||||
):
|
||||
"""Test audio_embeds with async futures."""
|
||||
import torch
|
||||
|
||||
# Create a sample audio embedding tensor
|
||||
hidden_size = audio_embeds_model_config.get_inputs_embeds_size()
|
||||
audio_embedding = torch.randn(1, 128, hidden_size)
|
||||
|
||||
# Encode it as base64
|
||||
base64_audio_embedding = tensor2base64(audio_embedding)
|
||||
|
||||
conversation, mm_data, mm_uuids = await parse_chat_messages_async(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Describe this audio"},
|
||||
{
|
||||
"type": "audio_embeds",
|
||||
"audio_embeds": base64_audio_embedding,
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
audio_embeds_model_config,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
# Should have audio embedding in mm_data (single tensor, not a list)
|
||||
assert mm_data is not None
|
||||
assert "audio" in mm_data
|
||||
assert isinstance(mm_data["audio"], torch.Tensor)
|
||||
assert mm_data["audio"].shape == audio_embedding.shape
|
||||
# No UUID provided
|
||||
assert mm_uuids is not None
|
||||
assert "audio" in mm_uuids
|
||||
assert_mm_uuids(mm_uuids, 1, modality="audio", expected_uuids=[None])
|
||||
@@ -0,0 +1,15 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import pytest
|
||||
|
||||
from vllm.exceptions import VLLMValidationError
|
||||
from vllm.renderers.chat_utils import validate_chat_template
|
||||
|
||||
|
||||
def test_validate_chat_template_rejects_invalid_type():
|
||||
"""A non-str/Path chat_template is invalid user input."""
|
||||
with pytest.raises(
|
||||
VLLMValidationError, match="not a valid chat template type"
|
||||
) as exc_info:
|
||||
validate_chat_template(123)
|
||||
assert exc_info.value.parameter == "chat_template"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm.renderers.chat_utils import AsyncMultiModalItemTracker
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_items_runs_modalities_concurrently_and_preserves_order():
|
||||
"""Media fetches overlap while modality and item order are preserved."""
|
||||
active_fetches = 0
|
||||
max_active_fetches = 0
|
||||
|
||||
async def _fetch(name: str, delay: float):
|
||||
nonlocal active_fetches, max_active_fetches
|
||||
active_fetches += 1
|
||||
max_active_fetches = max(max_active_fetches, active_fetches)
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
return name, f"{name}-uuid"
|
||||
finally:
|
||||
active_fetches -= 1
|
||||
|
||||
tracker = AsyncMultiModalItemTracker(MagicMock(is_multimodal_model=True))
|
||||
tracker.__dict__["mm_processor"] = MagicMock()
|
||||
tracker._items_by_modality["video"] = [
|
||||
lambda: _fetch("video-0", 0.02),
|
||||
lambda: _fetch("video-1", 0.01),
|
||||
]
|
||||
tracker._items_by_modality["image"] = [
|
||||
lambda: _fetch("image-0", 0.02),
|
||||
lambda: _fetch("image-1", 0.01),
|
||||
]
|
||||
tracker._items_by_modality["audio"] = [
|
||||
lambda: _fetch("audio-0", 0.02),
|
||||
lambda: _fetch("audio-1", 0.01),
|
||||
]
|
||||
|
||||
mm_data, mm_uuids = await tracker.resolve_items()
|
||||
|
||||
assert mm_data is not None
|
||||
assert mm_uuids is not None
|
||||
assert max_active_fetches == 6
|
||||
assert list(mm_data) == ["image", "audio", "video"]
|
||||
assert list(mm_uuids) == ["image", "audio", "video"]
|
||||
assert mm_data == {
|
||||
"image": ["image-0", "image-1"],
|
||||
"audio": ["audio-0", "audio-1"],
|
||||
"video": ["video-0", "video-1"],
|
||||
}
|
||||
assert mm_uuids == {
|
||||
"image": ["image-0-uuid", "image-1-uuid"],
|
||||
"audio": ["audio-0-uuid", "audio-1-uuid"],
|
||||
"video": ["video-0-uuid", "video-1-uuid"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_items_does_not_leak_tasks_on_partial_failure():
|
||||
"""Regression test: one failing media fetch must not abandon the other
|
||||
still-in-flight fetches across modality batches.
|
||||
|
||||
Before the fix, `resolve_items` gathered per-modality fetches with plain
|
||||
`asyncio.gather`, so the first exception propagated immediately while
|
||||
sibling fetches (real network/thread-pool work in production) kept
|
||||
running detached, with nothing left to await or cancel them.
|
||||
"""
|
||||
|
||||
async def _fetch(should_fail: bool, delay: float):
|
||||
if should_fail:
|
||||
await asyncio.sleep(0.01)
|
||||
raise ValueError("simulated fetch failure")
|
||||
await asyncio.sleep(delay)
|
||||
return ("decoded", None)
|
||||
|
||||
tracker = AsyncMultiModalItemTracker(MagicMock())
|
||||
tracker._items_by_modality["image"] = [
|
||||
lambda: _fetch(True, 0),
|
||||
lambda: _fetch(False, 0.2),
|
||||
lambda: _fetch(False, 0.2),
|
||||
]
|
||||
tracker._items_by_modality["audio"] = [lambda: _fetch(False, 0.2)]
|
||||
tracker._items_by_modality["video"] = [lambda: _fetch(False, 0.2)]
|
||||
|
||||
tasks_before = asyncio.all_tasks()
|
||||
with pytest.raises(ValueError, match="simulated fetch failure"):
|
||||
await tracker.resolve_items()
|
||||
|
||||
leaked_tasks = asyncio.all_tasks() - tasks_before
|
||||
assert not leaked_tasks, (
|
||||
f"resolve_items left {len(leaked_tasks)} task(s) running after "
|
||||
f"raising: {leaked_tasks}"
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm.exceptions import VLLMValidationError
|
||||
from vllm.renderers.chat_utils.content_parser import _load_embeds_dict
|
||||
from vllm.renderers.chat_utils.item_tracker import _parse_metadata_array
|
||||
|
||||
|
||||
@pytest.mark.parametrize("values", [[1, 32, 48], [0.0, 0.12345678912345678]])
|
||||
def test_json_metadata_preserves_numeric_precision(values):
|
||||
tensor = _parse_metadata_array("grid", values, {"grid"})
|
||||
assert tensor.tolist() == values
|
||||
assert tensor.dtype == (
|
||||
torch.float64 if isinstance(values[0], float) else torch.int64
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key,values",
|
||||
[
|
||||
("image_embeds", [1, 2]),
|
||||
("grid", [True]),
|
||||
("grid", [float("nan")]),
|
||||
("grid", [float("inf")]),
|
||||
("grid", [[1, 2]]),
|
||||
("grid", [2**64]),
|
||||
],
|
||||
)
|
||||
def test_json_arrays_are_only_valid_numeric_metadata(key, values):
|
||||
with pytest.raises(VLLMValidationError):
|
||||
_parse_metadata_array(key, values, {"grid"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_json_metadata_bypasses_tensor_deserialization():
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
tensor = torch.ones(2, 3)
|
||||
fetch = AsyncMock(return_value=tensor)
|
||||
result = await _load_embeds_dict(
|
||||
{"image_embeds": "legacy-base64", "image_grid_thw": [1, 2, 2]}, fetch
|
||||
)
|
||||
fetch.assert_awaited_once_with("legacy-base64")
|
||||
assert result["image_embeds"] is tensor
|
||||
assert result["image_grid_thw"] == [1, 2, 2]
|
||||
@@ -0,0 +1,150 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm.config import ModelConfig
|
||||
from vllm.multimodal.media import MEDIA_CONNECTOR_REGISTRY
|
||||
from vllm.renderers.chat_utils import (
|
||||
ChatCompletionMessageParam,
|
||||
parse_chat_messages,
|
||||
parse_chat_messages_async,
|
||||
)
|
||||
|
||||
MISTRAL_MODEL_ID = "mistralai/Mistral-Small-3.1-24B-Instruct-2503"
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mistral_model_config():
|
||||
return ModelConfig(
|
||||
MISTRAL_MODEL_ID,
|
||||
runner="generate",
|
||||
limit_mm_per_prompt={
|
||||
"image": 2,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_parse_chat_messages_empty_system(
|
||||
mistral_model_config,
|
||||
):
|
||||
# Test string format
|
||||
conversation, _, _ = parse_chat_messages(
|
||||
[
|
||||
{"role": "system", "content": ""},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Who are you?"}],
|
||||
},
|
||||
],
|
||||
mistral_model_config,
|
||||
content_format="string",
|
||||
)
|
||||
assert conversation == [
|
||||
{"role": "system", "content": ""},
|
||||
{"role": "user", "content": "Who are you?"},
|
||||
]
|
||||
|
||||
# Test openai format
|
||||
conversation, _, _ = parse_chat_messages(
|
||||
[
|
||||
{"role": "system", "content": ""},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Who are you?"}],
|
||||
},
|
||||
],
|
||||
mistral_model_config,
|
||||
content_format="openai",
|
||||
)
|
||||
assert conversation == [
|
||||
{"role": "system", "content": [{"type": "text", "text": ""}]},
|
||||
{"role": "user", "content": [{"type": "text", "text": "Who are you?"}]},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_only_chat_does_not_initialize_media_connector(
|
||||
mistral_model_config,
|
||||
monkeypatch,
|
||||
):
|
||||
load_connector = MagicMock()
|
||||
monkeypatch.setattr(MEDIA_CONNECTOR_REGISTRY, "load", load_connector)
|
||||
messages: list[ChatCompletionMessageParam] = [
|
||||
{"role": "user", "content": "Who are you?"}
|
||||
]
|
||||
|
||||
parse_chat_messages(
|
||||
messages,
|
||||
mistral_model_config,
|
||||
content_format="string",
|
||||
)
|
||||
await parse_chat_messages_async(
|
||||
messages,
|
||||
mistral_model_config,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
load_connector.assert_not_called()
|
||||
|
||||
|
||||
def test_parse_chat_messages_include_thinking_chunk(mistral_model_config):
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": [
|
||||
{"type": "text", "text": "You are a helpful assistant."},
|
||||
{
|
||||
"type": "thinking",
|
||||
"closed": True,
|
||||
"thinking": "Only return the answer when you are confident.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "What is 2+2?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "Let me think about it."},
|
||||
{"type": "thinking", "closed": True, "thinking": "2+2 = 4"},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "The answer is 4.",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
conversation_with_thinking, _, _ = parse_chat_messages(
|
||||
messages,
|
||||
mistral_model_config,
|
||||
content_format="openai",
|
||||
)
|
||||
|
||||
expected_conversation = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": [
|
||||
{"type": "text", "text": "You are a helpful assistant."},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Only return the answer when you are confident.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "What is 2+2?"}],
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "Let me think about it."},
|
||||
{"type": "text", "text": "2+2 = 4"},
|
||||
{"type": "text", "text": "The answer is 4."},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
assert conversation_with_thinking == expected_conversation
|
||||
@@ -0,0 +1,148 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm.renderers.chat_utils import ConversationMessage
|
||||
from vllm.renderers.chat_utils.chat_message import _postprocess_messages
|
||||
|
||||
|
||||
def _assistant_tool_call(arguments, name="write"):
|
||||
return [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_0",
|
||||
"type": "function",
|
||||
"function": {"name": name, "arguments": arguments},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _assistant_tool_calls(calls):
|
||||
return [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": calls,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_tool_call_arguments_dict_passthrough(caplog):
|
||||
messages = _assistant_tool_call({"a": 1})
|
||||
_postprocess_messages(messages)
|
||||
args = messages[0]["tool_calls"][0]["function"]["arguments"]
|
||||
assert args == {"a": 1}
|
||||
assert not caplog.records
|
||||
|
||||
|
||||
def test_tool_call_arguments_valid_object_string(caplog):
|
||||
messages = _assistant_tool_call('{"a": 1}')
|
||||
_postprocess_messages(messages)
|
||||
args = messages[0]["tool_calls"][0]["function"]["arguments"]
|
||||
assert args == {"a": 1}
|
||||
assert not caplog.records
|
||||
|
||||
|
||||
def test_tool_call_arguments_malformed_json_small(caplog):
|
||||
bad = '{"cmd": "mkdir -p /tmp/mmadtest && cat > /tmp/mmadtest'
|
||||
messages = _assistant_tool_call(bad, name="exec")
|
||||
_postprocess_messages(messages)
|
||||
args = messages[0]["tool_calls"][0]["function"]["arguments"]
|
||||
assert args == {}
|
||||
|
||||
assert len(caplog.records) == 1
|
||||
assert "coercing to an empty object" in caplog.records[0].message
|
||||
|
||||
|
||||
def test_tool_call_arguments_malformed_json_large(caplog):
|
||||
bad = '{"filepath": "src/main.py", "contents": "' + ("x" * 18000)
|
||||
messages = _assistant_tool_call(bad, name="write")
|
||||
_postprocess_messages(messages)
|
||||
args = messages[0]["tool_calls"][0]["function"]["arguments"]
|
||||
assert args == {}
|
||||
|
||||
assert len(caplog.records) == 1
|
||||
assert "coercing to an empty object" in caplog.records[0].message
|
||||
assert f"{len(bad)} chars" in caplog.records[0].message
|
||||
|
||||
|
||||
@pytest.mark.parametrize("non_obj", ["[]", "42", "true", '"hello"', "null"])
|
||||
def test_tool_call_arguments_valid_json_non_object(caplog, non_obj):
|
||||
messages = _assistant_tool_call(non_obj, name="bad_tool")
|
||||
_postprocess_messages(messages)
|
||||
args = messages[0]["tool_calls"][0]["function"]["arguments"]
|
||||
assert args == {}
|
||||
|
||||
if non_obj == "null":
|
||||
# null parses to None, so no warning is emitted according to requirements
|
||||
assert not caplog.records
|
||||
else:
|
||||
assert len(caplog.records) == 1
|
||||
assert "not a JSON object" in caplog.records[0].message
|
||||
|
||||
|
||||
@pytest.mark.parametrize("missing", [None, ""])
|
||||
def test_tool_call_arguments_missing_or_empty(caplog, missing):
|
||||
messages = _assistant_tool_call(missing)
|
||||
if missing is None:
|
||||
del messages[0]["tool_calls"][0]["function"]["arguments"]
|
||||
_postprocess_messages(messages)
|
||||
args = messages[0]["tool_calls"][0]["function"]["arguments"]
|
||||
assert args == {}
|
||||
assert not caplog.records
|
||||
|
||||
|
||||
def test_tool_call_arguments_multiple_independent(caplog):
|
||||
calls = [
|
||||
{
|
||||
"id": "call_0",
|
||||
"type": "function",
|
||||
"function": {"name": "good", "arguments": '{"a": 1}'},
|
||||
},
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "bad", "arguments": '{"cmd": "'},
|
||||
},
|
||||
]
|
||||
messages = _assistant_tool_calls(calls)
|
||||
_postprocess_messages(messages)
|
||||
|
||||
tool_calls = messages[0]["tool_calls"]
|
||||
assert tool_calls[0]["function"]["arguments"] == {"a": 1}
|
||||
assert tool_calls[1]["function"]["arguments"] == {}
|
||||
|
||||
assert len(caplog.records) == 1
|
||||
assert "coercing to an empty object" in caplog.records[0].message
|
||||
|
||||
|
||||
def test_postprocess_messages_null_arguments_string():
|
||||
"""arguments="null" must not reach the chat template as Python None.
|
||||
|
||||
json.loads("null") returns None, which causes Jinja2 templates that call
|
||||
tc.arguments.items() to raise 'None' has no attribute 'items'.
|
||||
The function should coerce it to {} instead.
|
||||
"""
|
||||
messages: list[ConversationMessage] = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "get_current_time", "arguments": "null"},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
_postprocess_messages(messages)
|
||||
tool_calls = messages[0]["tool_calls"]
|
||||
assert tool_calls is not None
|
||||
assert tool_calls[0]["function"]["arguments"] == {}
|
||||
@@ -0,0 +1,497 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm.config import ModelConfig
|
||||
from vllm.exceptions import VLLMValidationError
|
||||
from vllm.renderers.chat_utils import parse_chat_messages, parse_chat_messages_async
|
||||
from vllm.utils.serial_utils import tensor2base64
|
||||
|
||||
from .utils import (
|
||||
assert_mm_data_inputs,
|
||||
assert_mm_uuids,
|
||||
)
|
||||
|
||||
QWEN25OMNI_MODEL_ID = "Qwen/Qwen2.5-Omni-7B"
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def qwen25omni_model_config_image_embeds():
|
||||
return ModelConfig(
|
||||
QWEN25OMNI_MODEL_ID,
|
||||
runner="generate",
|
||||
limit_mm_per_prompt={"image": 2},
|
||||
enable_mm_embeds=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def qwen25omni_model_config_video_embeds():
|
||||
return ModelConfig(
|
||||
QWEN25OMNI_MODEL_ID,
|
||||
runner="generate",
|
||||
limit_mm_per_prompt={"video": 2},
|
||||
enable_mm_embeds=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def qwen25omni_model_config_mm_interleaved():
|
||||
return ModelConfig(
|
||||
QWEN25OMNI_MODEL_ID,
|
||||
runner="generate",
|
||||
interleave_mm_strings=True,
|
||||
limit_mm_per_prompt={
|
||||
"image": 2,
|
||||
"audio": 1,
|
||||
"video": 1,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_parse_chat_messages_empty_video_embeds_with_uuid(
|
||||
qwen25omni_model_config_video_embeds,
|
||||
):
|
||||
"""Test video_embeds with UUID (no actual embeds data)."""
|
||||
uuid = "test-video-uuid-123"
|
||||
|
||||
conversation, mm_data, mm_uuids = parse_chat_messages(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Describe this video"},
|
||||
{"type": "video_embeds", "video_embeds": None, "uuid": uuid},
|
||||
],
|
||||
}
|
||||
],
|
||||
qwen25omni_model_config_video_embeds,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
# Should have video in mm_data as None (UUID provided)
|
||||
assert mm_data is not None
|
||||
assert "video" in mm_data
|
||||
assert isinstance(mm_data["video"], list)
|
||||
assert len(mm_data["video"]) == 1
|
||||
assert mm_data["video"][0] is None
|
||||
|
||||
# UUID should be recorded
|
||||
assert_mm_uuids(mm_uuids, 1, modality="video", expected_uuids=[uuid])
|
||||
|
||||
|
||||
def test_parse_chat_messages_video_embeds_with_string(
|
||||
qwen25omni_model_config_video_embeds,
|
||||
):
|
||||
"""Test video_embeds with base64 string embedding data."""
|
||||
hidden_size = qwen25omni_model_config_video_embeds.get_inputs_embeds_size()
|
||||
video_embedding = torch.randn(1, 128, hidden_size)
|
||||
|
||||
base64_video_embedding = tensor2base64(video_embedding)
|
||||
|
||||
conversation, mm_data, mm_uuids = parse_chat_messages(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Describe this video"},
|
||||
{
|
||||
"type": "video_embeds",
|
||||
"video_embeds": base64_video_embedding,
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
qwen25omni_model_config_video_embeds,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
# Should have video embedding in mm_data (single tensor, not a list)
|
||||
assert mm_data is not None
|
||||
assert "video" in mm_data
|
||||
assert isinstance(mm_data["video"], torch.Tensor)
|
||||
assert mm_data["video"].shape == video_embedding.shape
|
||||
# No UUID provided
|
||||
assert mm_uuids is not None
|
||||
assert "video" in mm_uuids
|
||||
assert_mm_uuids(mm_uuids, 1, modality="video", expected_uuids=[None])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_chat_messages_video_embeds_async(
|
||||
qwen25omni_model_config_video_embeds,
|
||||
):
|
||||
"""Test video_embeds with async futures."""
|
||||
hidden_size = qwen25omni_model_config_video_embeds.get_inputs_embeds_size()
|
||||
video_embedding = torch.randn(1, 128, hidden_size)
|
||||
|
||||
base64_video_embedding = tensor2base64(video_embedding)
|
||||
|
||||
conversation, mm_data, mm_uuids = await parse_chat_messages_async(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Describe this video"},
|
||||
{
|
||||
"type": "video_embeds",
|
||||
"video_embeds": base64_video_embedding,
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
qwen25omni_model_config_video_embeds,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
# Should have video embedding in mm_data (single tensor, not a list)
|
||||
assert mm_data is not None
|
||||
assert "video" in mm_data
|
||||
assert isinstance(mm_data["video"], torch.Tensor)
|
||||
assert mm_data["video"].shape == video_embedding.shape
|
||||
# No UUID provided
|
||||
assert mm_uuids is not None
|
||||
assert "video" in mm_uuids
|
||||
assert_mm_uuids(mm_uuids, 1, modality="video", expected_uuids=[None])
|
||||
|
||||
|
||||
def test_parse_chat_messages_video_and_video_embeds_mixed(
|
||||
qwen25omni_model_config_video_embeds,
|
||||
video_url,
|
||||
):
|
||||
"""Test that mixing raw video and video_embeds is rejected."""
|
||||
with pytest.raises(VLLMValidationError, match="Mixing raw video"):
|
||||
parse_chat_messages(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "video_url", "video_url": {"url": video_url}},
|
||||
{"type": "video_embeds", "video_embeds": None, "uuid": "x"},
|
||||
],
|
||||
}
|
||||
],
|
||||
qwen25omni_model_config_video_embeds,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("json_metadata", [False, True])
|
||||
def test_parse_chat_messages_multiple_dict_image_embeds(
|
||||
qwen25omni_model_config_image_embeds,
|
||||
json_metadata,
|
||||
):
|
||||
"""Test that multiple dictionaries for image_embeds is handled without errors."""
|
||||
# Create two sample image embedding tensors
|
||||
batch_size = 2
|
||||
hidden_size = qwen25omni_model_config_image_embeds.get_inputs_embeds_size()
|
||||
image_embeds = torch.randn(batch_size * 220, hidden_size)
|
||||
image_grid_thw = torch.tensor([[1, 22, 40] for _ in range(batch_size)])
|
||||
|
||||
conversation, mm_data, mm_uuids = parse_chat_messages(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_embeds",
|
||||
"image_embeds": {
|
||||
"image_embeds": tensor2base64(embeds),
|
||||
"image_grid_thw": grid_thw.tolist()
|
||||
if json_metadata
|
||||
else tensor2base64(grid_thw),
|
||||
},
|
||||
}
|
||||
for embeds, grid_thw in zip(
|
||||
image_embeds.chunk(batch_size), image_grid_thw
|
||||
)
|
||||
]
|
||||
+ [
|
||||
{"type": "text", "text": "Describe these two images."},
|
||||
],
|
||||
}
|
||||
],
|
||||
qwen25omni_model_config_image_embeds,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
# Verify conversation structure
|
||||
assert conversation == [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "<|vision_start|><|IMAGE|><|vision_end|>\n"
|
||||
"<|vision_start|><|IMAGE|><|vision_end|>\nDescribe these two images.",
|
||||
}
|
||||
]
|
||||
|
||||
# Verify mm_data contains a dictionary of multi-embeddings
|
||||
assert mm_data is not None
|
||||
assert "image" in mm_data
|
||||
assert isinstance(mm_data["image"], dict)
|
||||
assert len(mm_data["image"]) == batch_size
|
||||
|
||||
# Verify each embedding has the correct shape
|
||||
assert isinstance(mm_data["image"]["image_embeds"], torch.Tensor)
|
||||
assert mm_data["image"]["image_embeds"].shape == image_embeds.shape
|
||||
assert isinstance(mm_data["image"]["image_grid_thw"], torch.Tensor)
|
||||
assert mm_data["image"]["image_grid_thw"].shape == image_grid_thw.shape
|
||||
|
||||
# Verify UUIDs (None since we didn't provide any)
|
||||
assert_mm_uuids(mm_uuids, batch_size, expected_uuids=[None, None])
|
||||
|
||||
|
||||
def test_parse_chat_messages_multiple_modals_multiple_messages_interleave(
|
||||
qwen25omni_model_config_mm_interleaved,
|
||||
image_url,
|
||||
video_url,
|
||||
audio_url,
|
||||
):
|
||||
conversation, mm_data, mm_uuids = parse_chat_messages(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What's on this image?"},
|
||||
{"type": "image_url", "image_url": {"url": image_url}},
|
||||
{"type": "text", "text": "Now listen to this audio"},
|
||||
{"type": "audio_url", "audio_url": {"url": audio_url}},
|
||||
],
|
||||
},
|
||||
{"role": "assistant", "content": "Some stuff."},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What's on this image?"},
|
||||
{"type": "image_url", "image_url": {"url": image_url}},
|
||||
{"type": "text", "text": "And what's in the video?"},
|
||||
{"type": "video_url", "video_url": {"url": video_url}},
|
||||
],
|
||||
},
|
||||
],
|
||||
qwen25omni_model_config_mm_interleaved,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
assert conversation == [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What's on this image?\n<|vision_start|><|IMAGE|><|vision_end|>"
|
||||
"\nNow listen to this audio\nAudio 1: <|audio_bos|><|AUDIO|><|audio_eos|>",
|
||||
},
|
||||
{"role": "assistant", "content": "Some stuff."},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What's on this image?\n<|vision_start|><|IMAGE|><|vision_end|>"
|
||||
"\nAnd what's in the video?\n<|vision_start|><|VIDEO|><|vision_end|>",
|
||||
},
|
||||
]
|
||||
|
||||
assert_mm_data_inputs(mm_data, {"image": 2, "video": 1, "audio": 1})
|
||||
assert_mm_uuids(mm_uuids, 2, modality="image", expected_uuids=[None, None])
|
||||
assert_mm_uuids(mm_uuids, 1, modality="video", expected_uuids=[None])
|
||||
assert_mm_uuids(mm_uuids, 1, modality="audio", expected_uuids=[None])
|
||||
|
||||
|
||||
def test_parse_chat_messages_multiple_modals_with_uuids_multiple_messages_interleave(
|
||||
qwen25omni_model_config_mm_interleaved,
|
||||
image_url,
|
||||
video_url,
|
||||
audio_url,
|
||||
):
|
||||
conversation, mm_data, mm_uuids = parse_chat_messages(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What's on this image?"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": image_url},
|
||||
"uuid": "image_123",
|
||||
},
|
||||
{"type": "text", "text": "Now listen to this audio"},
|
||||
{
|
||||
"type": "audio_url",
|
||||
"audio_url": {"url": audio_url},
|
||||
"uuid": "audio_123",
|
||||
},
|
||||
],
|
||||
},
|
||||
{"role": "assistant", "content": "Some stuff."},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What's on this image?"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": image_url},
|
||||
"uuid": "image_123",
|
||||
},
|
||||
{"type": "text", "text": "And what's in the video?"},
|
||||
{
|
||||
"type": "video_url",
|
||||
"video_url": {"url": video_url},
|
||||
"uuid": "video_123",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
qwen25omni_model_config_mm_interleaved,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
assert conversation == [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What's on this image?\n<|vision_start|><|IMAGE|><|vision_end|>"
|
||||
"\nNow listen to this audio\nAudio 1: <|audio_bos|><|AUDIO|><|audio_eos|>",
|
||||
},
|
||||
{"role": "assistant", "content": "Some stuff."},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What's on this image?\n<|vision_start|><|IMAGE|><|vision_end|>"
|
||||
"\nAnd what's in the video?\n<|vision_start|><|VIDEO|><|vision_end|>",
|
||||
},
|
||||
]
|
||||
|
||||
assert_mm_data_inputs(mm_data, {"image": 2, "video": 1, "audio": 1})
|
||||
assert_mm_uuids(
|
||||
mm_uuids, 2, modality="image", expected_uuids=["image_123", "image_123"]
|
||||
)
|
||||
assert_mm_uuids(mm_uuids, 1, modality="video", expected_uuids=["video_123"])
|
||||
assert_mm_uuids(mm_uuids, 1, modality="audio", expected_uuids=["audio_123"])
|
||||
|
||||
|
||||
def test_parse_chat_messages_multiple_modals_with_uuids_multiple_empty_media_messages_interleave( # noqa: E501
|
||||
qwen25omni_model_config_mm_interleaved,
|
||||
image_url,
|
||||
video_url,
|
||||
audio_url,
|
||||
):
|
||||
conversation, mm_data, mm_uuids = parse_chat_messages(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What's on this image?"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": None,
|
||||
"uuid": "image_123",
|
||||
},
|
||||
{"type": "text", "text": "Now listen to this audio"},
|
||||
{
|
||||
"type": "audio_url",
|
||||
"audio_url": None,
|
||||
"uuid": "audio_123",
|
||||
},
|
||||
],
|
||||
},
|
||||
{"role": "assistant", "content": "Some stuff."},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What's on this image?"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": None,
|
||||
"uuid": "image_123",
|
||||
},
|
||||
{"type": "text", "text": "And what's in the video?"},
|
||||
{
|
||||
"type": "video_url",
|
||||
"video_url": None,
|
||||
"uuid": "video_123",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
qwen25omni_model_config_mm_interleaved,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
assert conversation == [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What's on this image?\n<|vision_start|><|IMAGE|><|vision_end|>"
|
||||
"\nNow listen to this audio\nAudio 1: <|audio_bos|><|AUDIO|><|audio_eos|>",
|
||||
},
|
||||
{"role": "assistant", "content": "Some stuff."},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What's on this image?\n<|vision_start|><|IMAGE|><|vision_end|>"
|
||||
"\nAnd what's in the video?\n<|vision_start|><|VIDEO|><|vision_end|>",
|
||||
},
|
||||
]
|
||||
|
||||
assert_mm_data_inputs(
|
||||
mm_data,
|
||||
{"image": 2, "video": 1, "audio": 1},
|
||||
skipped_media_indices={"image": [0, 1], "video": [0], "audio": [0]},
|
||||
)
|
||||
assert_mm_uuids(
|
||||
mm_uuids, 2, modality="image", expected_uuids=["image_123", "image_123"]
|
||||
)
|
||||
assert_mm_uuids(mm_uuids, 1, modality="video", expected_uuids=["video_123"])
|
||||
assert_mm_uuids(mm_uuids, 1, modality="audio", expected_uuids=["audio_123"])
|
||||
|
||||
|
||||
def test_parse_chat_messages_multiple_modals_with_partial_uuids_multiple_messages_interleave( # noqa: E501
|
||||
qwen25omni_model_config_mm_interleaved,
|
||||
image_url,
|
||||
video_url,
|
||||
audio_url,
|
||||
):
|
||||
conversation, mm_data, mm_uuids = parse_chat_messages(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What's on this image?"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": image_url},
|
||||
"uuid": "image_123",
|
||||
},
|
||||
{"type": "text", "text": "Now listen to this audio"},
|
||||
{"type": "audio_url", "audio_url": {"url": audio_url}},
|
||||
],
|
||||
},
|
||||
{"role": "assistant", "content": "Some stuff."},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What's on this image?"},
|
||||
{"type": "image_url", "image_url": {"url": image_url}},
|
||||
{"type": "text", "text": "And what's in the video?"},
|
||||
{
|
||||
"type": "video_url",
|
||||
"video_url": {"url": video_url},
|
||||
"uuid": "video_123",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
qwen25omni_model_config_mm_interleaved,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
assert conversation == [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What's on this image?\n<|vision_start|><|IMAGE|><|vision_end|>"
|
||||
"\nNow listen to this audio\nAudio 1: <|audio_bos|><|AUDIO|><|audio_eos|>",
|
||||
},
|
||||
{"role": "assistant", "content": "Some stuff."},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What's on this image?\n<|vision_start|><|IMAGE|><|vision_end|>"
|
||||
"\nAnd what's in the video?\n<|vision_start|><|VIDEO|><|vision_end|>",
|
||||
},
|
||||
]
|
||||
|
||||
assert_mm_data_inputs(mm_data, {"image": 2, "video": 1, "audio": 1})
|
||||
assert_mm_uuids(mm_uuids, 2, modality="image", expected_uuids=["image_123", None])
|
||||
assert_mm_uuids(mm_uuids, 1, modality="video", expected_uuids=["video_123"])
|
||||
assert_mm_uuids(mm_uuids, 1, modality="audio", expected_uuids=[None])
|
||||
@@ -0,0 +1,546 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm.config import ModelConfig
|
||||
from vllm.inputs import MultiModalDataDict
|
||||
from vllm.renderers.chat_utils import (
|
||||
ChatCompletionMessageParam,
|
||||
parse_chat_messages,
|
||||
parse_chat_messages_async,
|
||||
)
|
||||
|
||||
from .utils import assert_mm_uuids
|
||||
|
||||
KIMI_K2_5_MODEL_ID = "moonshotai/Kimi-K2.5"
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def kimi_k2_5_model_config():
|
||||
return ModelConfig(
|
||||
KIMI_K2_5_MODEL_ID,
|
||||
runner="generate",
|
||||
trust_remote_code=True,
|
||||
limit_mm_per_prompt={
|
||||
"image": 2,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _assert_mm_data_is_vision_chunk_input(
|
||||
mm_data: MultiModalDataDict | None,
|
||||
vision_chunk_count: int,
|
||||
) -> None:
|
||||
assert mm_data is not None
|
||||
assert set(mm_data.keys()) == {"vision_chunk"}
|
||||
|
||||
vision_chunk_data = mm_data.get("vision_chunk")
|
||||
assert vision_chunk_data is not None
|
||||
|
||||
assert (
|
||||
isinstance(vision_chunk_data, list)
|
||||
and len(vision_chunk_data) == vision_chunk_count
|
||||
)
|
||||
|
||||
|
||||
def test_parse_chat_messages_image_vision_chunk(
|
||||
kimi_k2_5_model_config,
|
||||
image_url,
|
||||
):
|
||||
messages: list[ChatCompletionMessageParam] = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Analyze this image."},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": image_url},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
conversation, mm_data, mm_uuids = parse_chat_messages(
|
||||
messages,
|
||||
kimi_k2_5_model_config,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
placeholder = "<|media_begin|>image<|media_content|><|media_pad|><|media_end|>"
|
||||
expected_conversation = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"{placeholder}\nAnalyze this image.",
|
||||
}
|
||||
]
|
||||
|
||||
assert conversation == expected_conversation
|
||||
_assert_mm_data_is_vision_chunk_input(mm_data, 1)
|
||||
assert_mm_uuids(mm_uuids, 1, expected_uuids=[None], modality="vision_chunk")
|
||||
|
||||
|
||||
def test_parse_chat_messages_video_vision_chunk(
|
||||
kimi_k2_5_model_config,
|
||||
video_url,
|
||||
):
|
||||
messages: list[ChatCompletionMessageParam] = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Analyze this video."},
|
||||
{
|
||||
"type": "video_url",
|
||||
"video_url": {"url": video_url},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
conversation, mm_data, mm_uuids = parse_chat_messages(
|
||||
messages,
|
||||
kimi_k2_5_model_config,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
placeholder = "<|kimi_k25_video_placeholder|>"
|
||||
expected_conversation = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"{placeholder}\nAnalyze this video.",
|
||||
}
|
||||
]
|
||||
|
||||
assert conversation == expected_conversation
|
||||
_assert_mm_data_is_vision_chunk_input(mm_data, 1)
|
||||
assert_mm_uuids(mm_uuids, 1, expected_uuids=[None], modality="vision_chunk")
|
||||
|
||||
|
||||
def test_parse_chat_messages_image_vision_chunk_with_uuid(
|
||||
kimi_k2_5_model_config,
|
||||
image_url,
|
||||
):
|
||||
image_uuid = "image_123"
|
||||
messages: list[ChatCompletionMessageParam] = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Analyze this image."},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": image_url},
|
||||
"uuid": image_uuid,
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
conversation, mm_data, mm_uuids = parse_chat_messages(
|
||||
messages,
|
||||
kimi_k2_5_model_config,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
placeholder = "<|media_begin|>image<|media_content|><|media_pad|><|media_end|>"
|
||||
expected_conversation = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"{placeholder}\nAnalyze this image.",
|
||||
}
|
||||
]
|
||||
|
||||
assert conversation == expected_conversation
|
||||
_assert_mm_data_is_vision_chunk_input(mm_data, 1)
|
||||
assert_mm_uuids(mm_uuids, 1, expected_uuids=[image_uuid], modality="vision_chunk")
|
||||
|
||||
|
||||
def test_parse_chat_messages_video_vision_chunk_with_uuid(
|
||||
kimi_k2_5_model_config,
|
||||
video_url,
|
||||
):
|
||||
video_uuid = "video_456"
|
||||
messages: list[ChatCompletionMessageParam] = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Analyze this video."},
|
||||
{
|
||||
"type": "video_url",
|
||||
"video_url": {"url": video_url},
|
||||
"uuid": video_uuid,
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
conversation, mm_data, mm_uuids = parse_chat_messages(
|
||||
messages,
|
||||
kimi_k2_5_model_config,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
placeholder = "<|kimi_k25_video_placeholder|>"
|
||||
expected_conversation = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"{placeholder}\nAnalyze this video.",
|
||||
}
|
||||
]
|
||||
|
||||
assert conversation == expected_conversation
|
||||
_assert_mm_data_is_vision_chunk_input(mm_data, 1)
|
||||
assert_mm_uuids(mm_uuids, 1, expected_uuids=[video_uuid], modality="vision_chunk")
|
||||
|
||||
|
||||
def test_parse_chat_messages_mixed_vision_chunk(
|
||||
kimi_k2_5_model_config,
|
||||
image_url,
|
||||
video_url,
|
||||
):
|
||||
messages: list[ChatCompletionMessageParam] = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Analyze this image and video."},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": image_url},
|
||||
},
|
||||
{
|
||||
"type": "video_url",
|
||||
"video_url": {"url": video_url},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
conversation, mm_data, mm_uuids = parse_chat_messages(
|
||||
messages,
|
||||
kimi_k2_5_model_config,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
image_placeholder = (
|
||||
"<|media_begin|>image<|media_content|><|media_pad|><|media_end|>"
|
||||
)
|
||||
video_placeholder = "<|kimi_k25_video_placeholder|>"
|
||||
expected_conversation = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"{image_placeholder}\n{video_placeholder}\n"
|
||||
"Analyze this image and video."
|
||||
),
|
||||
}
|
||||
]
|
||||
|
||||
assert conversation == expected_conversation
|
||||
_assert_mm_data_is_vision_chunk_input(mm_data, 2)
|
||||
assert_mm_uuids(mm_uuids, 2, expected_uuids=[None, None], modality="vision_chunk")
|
||||
|
||||
|
||||
def test_parse_chat_messages_mixed_vision_chunk_with_uuid(
|
||||
kimi_k2_5_model_config,
|
||||
image_url,
|
||||
video_url,
|
||||
):
|
||||
image_uuid = "image_123"
|
||||
video_uuid = "video_456"
|
||||
messages: list[ChatCompletionMessageParam] = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Analyze this image and video."},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": image_url},
|
||||
"uuid": image_uuid,
|
||||
},
|
||||
{
|
||||
"type": "video_url",
|
||||
"video_url": {"url": video_url},
|
||||
"uuid": video_uuid,
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
conversation, mm_data, mm_uuids = parse_chat_messages(
|
||||
messages,
|
||||
kimi_k2_5_model_config,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
image_placeholder = (
|
||||
"<|media_begin|>image<|media_content|><|media_pad|><|media_end|>"
|
||||
)
|
||||
video_placeholder = "<|kimi_k25_video_placeholder|>"
|
||||
expected_conversation = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"{image_placeholder}\n{video_placeholder}\n"
|
||||
"Analyze this image and video."
|
||||
),
|
||||
}
|
||||
]
|
||||
|
||||
assert conversation == expected_conversation
|
||||
_assert_mm_data_is_vision_chunk_input(mm_data, 2)
|
||||
assert_mm_uuids(
|
||||
mm_uuids, 2, expected_uuids=[image_uuid, video_uuid], modality="vision_chunk"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_chat_messages_mixed_vision_chunk_async(
|
||||
kimi_k2_5_model_config,
|
||||
image_url,
|
||||
video_url,
|
||||
):
|
||||
messages: list[ChatCompletionMessageParam] = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Analyze this image and video."},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": image_url},
|
||||
},
|
||||
{
|
||||
"type": "video_url",
|
||||
"video_url": {"url": video_url},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
conversation, mm_data, mm_uuids = await parse_chat_messages_async(
|
||||
messages,
|
||||
kimi_k2_5_model_config,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
image_placeholder = (
|
||||
"<|media_begin|>image<|media_content|><|media_pad|><|media_end|>"
|
||||
)
|
||||
video_placeholder = "<|kimi_k25_video_placeholder|>"
|
||||
expected_conversation = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"{image_placeholder}\n{video_placeholder}\n"
|
||||
"Analyze this image and video."
|
||||
),
|
||||
}
|
||||
]
|
||||
|
||||
assert conversation == expected_conversation
|
||||
_assert_mm_data_is_vision_chunk_input(mm_data, 2)
|
||||
assert_mm_uuids(mm_uuids, 2, expected_uuids=[None, None], modality="vision_chunk")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_chat_messages_mixed_vision_chunk_with_uuid_async(
|
||||
kimi_k2_5_model_config,
|
||||
image_url,
|
||||
video_url,
|
||||
):
|
||||
image_uuid = "image_123"
|
||||
video_uuid = "video_456"
|
||||
messages: list[ChatCompletionMessageParam] = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Analyze this image and video."},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": image_url},
|
||||
"uuid": image_uuid,
|
||||
},
|
||||
{
|
||||
"type": "video_url",
|
||||
"video_url": {"url": video_url},
|
||||
"uuid": video_uuid,
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
conversation, mm_data, mm_uuids = await parse_chat_messages_async(
|
||||
messages,
|
||||
kimi_k2_5_model_config,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
image_placeholder = (
|
||||
"<|media_begin|>image<|media_content|><|media_pad|><|media_end|>"
|
||||
)
|
||||
video_placeholder = "<|kimi_k25_video_placeholder|>"
|
||||
expected_conversation = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"{image_placeholder}\n{video_placeholder}\n"
|
||||
"Analyze this image and video."
|
||||
),
|
||||
}
|
||||
]
|
||||
|
||||
assert conversation == expected_conversation
|
||||
_assert_mm_data_is_vision_chunk_input(mm_data, 2)
|
||||
assert_mm_uuids(
|
||||
mm_uuids, 2, expected_uuids=[image_uuid, video_uuid], modality="vision_chunk"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_chat_messages_image_vision_chunk_async(
|
||||
kimi_k2_5_model_config,
|
||||
image_url,
|
||||
):
|
||||
messages: list[ChatCompletionMessageParam] = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Analyze this image."},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": image_url},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
conversation, mm_data, mm_uuids = await parse_chat_messages_async(
|
||||
messages,
|
||||
kimi_k2_5_model_config,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
placeholder = "<|media_begin|>image<|media_content|><|media_pad|><|media_end|>"
|
||||
expected_conversation = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"{placeholder}\nAnalyze this image.",
|
||||
}
|
||||
]
|
||||
|
||||
assert conversation == expected_conversation
|
||||
_assert_mm_data_is_vision_chunk_input(mm_data, 1)
|
||||
assert_mm_uuids(mm_uuids, 1, expected_uuids=[None], modality="vision_chunk")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_chat_messages_video_vision_chunk_async(
|
||||
kimi_k2_5_model_config,
|
||||
video_url,
|
||||
):
|
||||
messages: list[ChatCompletionMessageParam] = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Analyze this video."},
|
||||
{
|
||||
"type": "video_url",
|
||||
"video_url": {"url": video_url},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
conversation, mm_data, mm_uuids = await parse_chat_messages_async(
|
||||
messages,
|
||||
kimi_k2_5_model_config,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
placeholder = "<|kimi_k25_video_placeholder|>"
|
||||
expected_conversation = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"{placeholder}\nAnalyze this video.",
|
||||
}
|
||||
]
|
||||
|
||||
assert conversation == expected_conversation
|
||||
_assert_mm_data_is_vision_chunk_input(mm_data, 1)
|
||||
assert_mm_uuids(mm_uuids, 1, expected_uuids=[None], modality="vision_chunk")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_chat_messages_image_vision_chunk_with_uuid_async(
|
||||
kimi_k2_5_model_config,
|
||||
image_url,
|
||||
):
|
||||
image_uuid = "image_123"
|
||||
messages: list[ChatCompletionMessageParam] = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Analyze this image."},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": image_url},
|
||||
"uuid": image_uuid,
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
conversation, mm_data, mm_uuids = await parse_chat_messages_async(
|
||||
messages,
|
||||
kimi_k2_5_model_config,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
placeholder = "<|media_begin|>image<|media_content|><|media_pad|><|media_end|>"
|
||||
expected_conversation = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"{placeholder}\nAnalyze this image.",
|
||||
}
|
||||
]
|
||||
|
||||
assert conversation == expected_conversation
|
||||
_assert_mm_data_is_vision_chunk_input(mm_data, 1)
|
||||
assert_mm_uuids(mm_uuids, 1, expected_uuids=[image_uuid], modality="vision_chunk")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_chat_messages_video_vision_chunk_with_uuid_async(
|
||||
kimi_k2_5_model_config,
|
||||
video_url,
|
||||
):
|
||||
video_uuid = "video_456"
|
||||
messages: list[ChatCompletionMessageParam] = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Analyze this video."},
|
||||
{
|
||||
"type": "video_url",
|
||||
"video_url": {"url": video_url},
|
||||
"uuid": video_uuid,
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
conversation, mm_data, mm_uuids = await parse_chat_messages_async(
|
||||
messages,
|
||||
kimi_k2_5_model_config,
|
||||
content_format="string",
|
||||
)
|
||||
|
||||
placeholder = "<|kimi_k25_video_placeholder|>"
|
||||
expected_conversation = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"{placeholder}\nAnalyze this video.",
|
||||
}
|
||||
]
|
||||
|
||||
assert conversation == expected_conversation
|
||||
_assert_mm_data_is_vision_chunk_input(mm_data, 1)
|
||||
assert_mm_uuids(mm_uuids, 1, expected_uuids=[video_uuid], modality="vision_chunk")
|
||||
@@ -0,0 +1,66 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
from collections.abc import Mapping
|
||||
from typing import Literal
|
||||
|
||||
from vllm.inputs import MultiModalDataDict, MultiModalUUIDDict
|
||||
|
||||
ModalityType = Literal["image", "video", "audio"]
|
||||
MultiModalDataCounts = Mapping[ModalityType, int]
|
||||
|
||||
|
||||
def assert_mm_uuids(
|
||||
mm_uuids: MultiModalUUIDDict | None,
|
||||
media_count: int,
|
||||
expected_uuids: list[str | None],
|
||||
modality: str = "image",
|
||||
) -> None:
|
||||
if len(expected_uuids) > 0:
|
||||
assert mm_uuids is not None
|
||||
assert modality in mm_uuids
|
||||
|
||||
image_uuids = mm_uuids.get(modality)
|
||||
assert image_uuids is not None
|
||||
|
||||
assert isinstance(image_uuids, list) and len(image_uuids) == media_count
|
||||
|
||||
assert image_uuids == expected_uuids
|
||||
else:
|
||||
assert mm_uuids is None
|
||||
|
||||
|
||||
def assert_mm_data_inputs(
|
||||
mm_data: MultiModalDataDict | None,
|
||||
data_count: MultiModalDataCounts,
|
||||
skipped_media_indices: dict[str, list] | None = None, # modality -> list[int]
|
||||
) -> None:
|
||||
assert mm_data is not None
|
||||
assert set(data_count.keys()) == (set(mm_data.keys()))
|
||||
|
||||
for modality, n in data_count.items():
|
||||
modality_data = mm_data.get(modality)
|
||||
assert modality_data is not None
|
||||
assert isinstance(modality_data, list) and len(modality_data) == n
|
||||
|
||||
if skipped_media_indices is not None:
|
||||
skipped_media_indices_for_modality = skipped_media_indices.get(modality)
|
||||
assert skipped_media_indices_for_modality is not None
|
||||
for i in skipped_media_indices_for_modality:
|
||||
assert modality_data[i] is None
|
||||
|
||||
|
||||
def assert_mm_data_is_image_input(
|
||||
mm_data: MultiModalDataDict | None,
|
||||
image_count: int,
|
||||
skipped_image_indices: list | None = None,
|
||||
) -> None:
|
||||
assert mm_data is not None
|
||||
assert set(mm_data.keys()) == {"image"}
|
||||
|
||||
image_data = mm_data.get("image")
|
||||
assert image_data is not None
|
||||
|
||||
assert isinstance(image_data, list) and len(image_data) == image_count
|
||||
if skipped_image_indices is not None:
|
||||
for i in skipped_image_indices:
|
||||
assert image_data[i] is None
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user