diff --git a/tests/entrypoints/cohere/test_serving_conversion.py b/tests/entrypoints/cohere/test_serving_conversion.py index e6696c26b4..7136b4acdd 100644 --- a/tests/entrypoints/cohere/test_serving_conversion.py +++ b/tests/entrypoints/cohere/test_serving_conversion.py @@ -511,7 +511,7 @@ class TestConvertToolMessage: # A list of text blocks maps 1-to-1 onto ``text`` content # parts, mirroring the shape ``_convert_user_message`` emits # for multi-part user content. vLLM's - # ``_parse_chat_message_content`` will collapse this back to + # ``parse_chat_message_content`` will collapse this back to # ``"\n".join(...)`` for the ``ConversationMessage`` handed to # renderers (see the tool-role branch in # ``vllm/entrypoints/chat_utils.py``), so downstream chat @@ -533,7 +533,7 @@ class TestConvertToolMessage: """Document blocks must not survive on the OpenAI-shaped tool message. - vLLM's shared ``_parse_chat_message_content_parts`` runs a + vLLM's shared ``parse_chat_message_content_parts`` runs a Pydantic ``ValidatorIterator`` over the OpenAI content-part union, which only knows ``text`` / ``image_url`` / etc. and rejects ``{"type": "document", ...}`` blocks outright. We @@ -588,7 +588,7 @@ class TestConvertToolMessage: def test_converted_tool_message_content_survives_openai_validation(self): """Regression for the reported crash. - ``vllm/entrypoints/chat_utils.py:_parse_chat_message_content_parts`` + ``vllm/renders/chat_message_content.py:parse_chat_message_content_parts`` runs a Pydantic ``ValidatorIterator`` over ``list[ChatCompletionContentPartParam]``, which is a discriminated union that only accepts ``text`` / ``image_url`` diff --git a/tests/entrypoints/unit_tests/test_chat_billing_header.py b/tests/entrypoints/unit_tests/test_chat_billing_header.py index 3ad6947597..1848aa568b 100644 --- a/tests/entrypoints/unit_tests/test_chat_billing_header.py +++ b/tests/entrypoints/unit_tests/test_chat_billing_header.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock import pytest -from vllm.renderers.chat_utils import _parse_chat_message_content +from vllm.renderers.chat_utils import parse_chat_message_content HEADER = ( "x-anthropic-billing-header: cc_version=2.1.220.300; " @@ -17,7 +17,7 @@ HEADER = ( def _parse(message, content_format="openai"): tracker = MagicMock() tracker.create_parser.return_value.mm_placeholder_storage.return_value = {} - return _parse_chat_message_content( + return parse_chat_message_content( message, tracker, content_format, interleave_strings=False ) diff --git a/tests/models/multimodal/processing/test_common.py b/tests/models/multimodal/processing/test_common.py index 820d655e90..667907c3e5 100644 --- a/tests/models/multimodal/processing/test_common.py +++ b/tests/models/multimodal/processing/test_common.py @@ -29,7 +29,7 @@ from vllm.multimodal.cache import MultiModalProcessorOnlyCache from vllm.multimodal.inputs import MultiModalFeatureSpec, batched_tensors_equal from vllm.multimodal.processing import BaseMultiModalProcessor, InputProcessingContext from vllm.platforms import current_platform -from vllm.renderers.chat_utils import _get_embeds_data +from vllm.renderers.chat_utils.item_tracker import _get_embeds_data from vllm.tokenizers import TokenizerLike, cached_tokenizer_from_config from vllm.utils.mistral import is_mistral_tokenizer diff --git a/tests/renderers/test_chat_utils.py b/tests/renderers/test_chat_utils.py index 47fe49a63e..877c4de74b 100644 --- a/tests/renderers/test_chat_utils.py +++ b/tests/renderers/test_chat_utils.py @@ -16,24 +16,24 @@ from vllm.assets.video import VideoAsset from vllm.config import ModelConfig from vllm.exceptions import VLLMValidationError from vllm.inputs import MultiModalDataDict, MultiModalUUIDDict +from vllm.multimodal.media import MEDIA_CONNECTOR_REGISTRY from vllm.multimodal.utils import ( encode_audio_url, encode_image_url, encode_video_url, ) from vllm.renderers.chat_utils import ( - MEDIA_CONNECTOR_REGISTRY, AsyncMultiModalItemTracker, ChatCompletionMessageParam, ConversationMessage, - _load_embeds_dict, - _parse_chat_message_content, - _parse_metadata_array, - _postprocess_messages, + parse_chat_message_content, parse_chat_messages, parse_chat_messages_async, validate_chat_template, ) +from vllm.renderers.chat_utils.chat_message import _postprocess_messages +from vllm.renderers.chat_utils.content_parser import _load_embeds_dict +from vllm.renderers.chat_utils.item_tracker import _parse_metadata_array from vllm.utils.serial_utils import tensor2base64 KIMI_K2_5_MODEL_ID = "moonshotai/Kimi-K2.5" @@ -3205,7 +3205,7 @@ def _parse_one(message) -> ConversationMessage: parser = tracker.create_parser.return_value parser.model_config.enable_prompt_embeds = False parser.mm_placeholder_storage.return_value = {} - result = _parse_chat_message_content( + result = parse_chat_message_content( message, tracker, content_format="string", interleave_strings=False ) assert len(result) == 1 diff --git a/tests/renderers/test_chat_utils_prompt_embeds.py b/tests/renderers/test_chat_utils_prompt_embeds.py index f26680793e..d6f1673ca7 100644 --- a/tests/renderers/test_chat_utils_prompt_embeds.py +++ b/tests/renderers/test_chat_utils_prompt_embeds.py @@ -18,15 +18,16 @@ from transformers import AutoTokenizer from vllm.exceptions import VLLMValidationError from vllm.renderers import TokenizeParams -from vllm.renderers.chat_utils import ( - _ENABLE_PROMPT_EMBEDS_ERROR, +from vllm.renderers.chat_utils import parse_chat_messages, parse_chat_messages_async +from vllm.renderers.chat_utils.chat_message import ( _PROMPT_EMBEDS_MISSING_DATA_ERROR, _RESERVED_PLACEHOLDER_IN_TEXT_ERROR, MM_PARSER_MAP, +) +from vllm.renderers.chat_utils.content_parser import ( + _ENABLE_PROMPT_EMBEDS_ERROR, MODALITY_PLACEHOLDERS_MAP, PROMPT_EMBEDS_PLACEHOLDER_TOKEN, - parse_chat_messages, - parse_chat_messages_async, ) from vllm.renderers.hf import ( _PROMPT_EMBEDS_PLACEHOLDER_SPAN_MISMATCH_ERROR, diff --git a/tests/renderers/test_warmup.py b/tests/renderers/test_warmup.py index 09fe047d56..0c847e5600 100644 --- a/tests/renderers/test_warmup.py +++ b/tests/renderers/test_warmup.py @@ -28,9 +28,9 @@ from unittest.mock import MagicMock, patch import pytest +from vllm.exceptions import ChatTemplateResolutionError from vllm.multimodal.processing import BaseMultiModalProcessor from vllm.renderers.base import BaseRenderer -from vllm.renderers.chat_utils import ChatTemplateResolutionError from vllm.renderers.params import ChatParams diff --git a/vllm/entrypoints/cohere/serving.py b/vllm/entrypoints/cohere/serving.py index 93f6d14a3b..4ce734a812 100644 --- a/vllm/entrypoints/cohere/serving.py +++ b/vllm/entrypoints/cohere/serving.py @@ -502,7 +502,7 @@ class CohereServingChatV2(OpenAIServingChat): # Map every content block to a plain ``{"type": "text", "text": # ...}`` part, mirroring the shape :meth:`_convert_user_message` # emits for multi-part user content. vLLM's - # ``_parse_chat_message_content`` collapses a text-only tool + # ``parse_chat_message_content`` collapses a text-only tool # part list back into ``"\n".join(...)`` on the way to the # renderer, so simple chat templates that expect string tool # content still work; keeping the list here just preserves the @@ -512,7 +512,7 @@ class CohereServingChatV2(OpenAIServingChat): # Tool message content uses ``ToolMessageV2Content``, a union of # ``TextToolContent`` and ``DocumentToolContent``. The latter # produces ``{"type": "document", "document": {...}}`` blocks, - # which vLLM's shared ``_parse_chat_message_content_parts`` in + # which vLLM's shared ``parse_chat_message_content_parts`` in # ``vllm/renderers/chat_utils.py`` rejects because the OpenAI # ``ChatCompletionContentPartParam`` union only knows text / # image / audio / etc. Emitting them anyway triggers a Pydantic diff --git a/vllm/entrypoints/pooling/embed/io_processor.py b/vllm/entrypoints/pooling/embed/io_processor.py index b37a009c92..4b2ad7a3b8 100644 --- a/vllm/entrypoints/pooling/embed/io_processor.py +++ b/vllm/entrypoints/pooling/embed/io_processor.py @@ -9,7 +9,6 @@ from openai.types.chat import ( ChatCompletionContentPartImageParam, ChatCompletionContentPartTextParam, ) -from openai.types.chat.chat_completion_content_part_image_param import ImageURL from vllm import PoolingParams from vllm.exceptions import VLLMValidationError @@ -21,6 +20,7 @@ from vllm.renderers.chat_utils import ( ChatCompletionContentPartParam, ChatCompletionMessageParam, CustomChatCompletionMessageParam, + ImageURL, ) from vllm.renderers.hf import resolve_chat_template from vllm.utils.collection_utils import chunk_list diff --git a/vllm/entrypoints/pooling/scoring/io_processor.py b/vllm/entrypoints/pooling/scoring/io_processor.py index 1e4c638968..9ab36a1395 100644 --- a/vllm/entrypoints/pooling/scoring/io_processor.py +++ b/vllm/entrypoints/pooling/scoring/io_processor.py @@ -7,9 +7,9 @@ from typing import Any, TypeAlias, cast import torch.nn.functional as F from vllm import PoolingParams, PoolingRequestOutput, TokensPrompt +from vllm.exceptions import ChatTemplateResolutionError from vllm.logger import init_logger from vllm.renderers import TokenizeParams -from vllm.renderers.chat_utils import ChatTemplateResolutionError from vllm.renderers.hf import safe_apply_chat_template from vllm.renderers.inputs.preprocess import ( extract_target_prompt, diff --git a/vllm/entrypoints/pooling/scoring/utils.py b/vllm/entrypoints/pooling/scoring/utils.py index 8d953cf6dd..b3617ae9c6 100644 --- a/vllm/entrypoints/pooling/scoring/utils.py +++ b/vllm/entrypoints/pooling/scoring/utils.py @@ -15,7 +15,7 @@ from vllm.renderers.chat_utils import ( ChatCompletionContentPartTextParam, ConversationMessage, MultiModalItemTracker, - _parse_chat_message_content_parts, + parse_chat_message_content_parts, ) from .typing import ( @@ -193,7 +193,7 @@ def _parse_score_content( mm_parser = mm_tracker.create_parser() - parse_res = _parse_chat_message_content_parts( + parse_res = parse_chat_message_content_parts( role=role, parts=parts, mm_tracker=mm_tracker, diff --git a/vllm/exceptions.py b/vllm/exceptions.py index 11ca13fe0b..85863c2fbf 100644 --- a/vllm/exceptions.py +++ b/vllm/exceptions.py @@ -62,6 +62,12 @@ class VLLMNotFoundError(VLLMClientError): pass +class ChatTemplateResolutionError(VLLMClientError): + """Raised when chat template resolution fails.""" + + pass + + class LoRAAdapterNotFoundError(VLLMNotFoundError): """Exception raised when a LoRA adapter is not found. diff --git a/vllm/renderers/base.py b/vllm/renderers/base.py index e04bfa64eb..7b0f4debaa 100644 --- a/vllm/renderers/base.py +++ b/vllm/renderers/base.py @@ -362,7 +362,7 @@ class BaseRenderer(ABC, Generic[_T]): no-op if the background warmup — or a prior reset_mm_cache that joined it — already ran it. """ - from vllm.renderers.chat_utils import ChatTemplateResolutionError + from vllm.exceptions import ChatTemplateResolutionError # prevent MM processor hangs with set_default_torch_num_threads(1): diff --git a/vllm/renderers/chat_utils.py b/vllm/renderers/chat_utils.py deleted file mode 100644 index 540564baab..0000000000 --- a/vllm/renderers/chat_utils.py +++ /dev/null @@ -1,2349 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -import asyncio -import json -import types -from abc import ABC, abstractmethod -from collections import Counter, defaultdict -from collections.abc import Awaitable, Callable, Iterable -from dataclasses import dataclass -from functools import cached_property, lru_cache, partial -from itertools import accumulate -from pathlib import Path -from typing import ( - TYPE_CHECKING, - Any, - Final, - Generic, - Literal, - Required, - TypeAlias, - TypeVar, - Union, - cast, - get_args, - get_origin, -) - -from openai.types.chat import ( - ChatCompletionAssistantMessageParam, - ChatCompletionContentPartImageParam, - ChatCompletionContentPartInputAudioParam, - ChatCompletionContentPartRefusalParam, - ChatCompletionContentPartTextParam, - ChatCompletionFunctionToolParam, - ChatCompletionMessageToolCallParam, - ChatCompletionToolMessageParam, -) -from openai.types.chat import ( - ChatCompletionContentPartParam as OpenAIChatCompletionContentPartParam, -) -from openai.types.chat import ( - ChatCompletionMessageParam as OpenAIChatCompletionMessageParam, -) -from openai.types.chat.chat_completion_content_part_input_audio_param import InputAudio -from openai.types.responses import ResponseInputImageParam -from openai_harmony import Message as OpenAIHarmonyMessage -from PIL import Image -from pydantic import BaseModel, ConfigDict, TypeAdapter - -# pydantic needs the TypedDict from typing_extensions -from typing_extensions import TypedDict, override - -from vllm import envs -from vllm.config import ModelConfig -from vllm.connections import MediaRequestDownloadBudget -from vllm.exceptions import VLLMValidationError -from vllm.inputs import MultiModalDataDict, MultiModalUUIDDict -from vllm.logger import init_logger -from vllm.model_executor.models import SupportsMultiModal -from vllm.multimodal import MULTIMODAL_REGISTRY -from vllm.multimodal.inputs import ( - MultiModalBatchedField, - MultiModalFlatField, - MultiModalSharedField, - VisionChunk, - VisionChunkImage, - VisionChunkVideo, -) -from vllm.multimodal.media import MEDIA_CONNECTOR_REGISTRY, MediaConnector -from vllm.multimodal.media.base import MediaDecodedBudget -from vllm.multimodal.processing import BaseMultiModalProcessor -from vllm.renderers.embed_utils import ( - safe_load_prompt_embeds, - safe_load_prompt_embeds_async, -) -from vllm.transformers_utils.processor import get_video_processor_cls_name -from vllm.utils import random_uuid -from vllm.utils.collection_utils import is_list_of, is_list_of_numbers -from vllm.utils.import_utils import LazyLoader - -if TYPE_CHECKING: - import torch - import transformers -else: - transformers = LazyLoader("transformers", globals(), "transformers") - torch = LazyLoader("torch", globals(), "torch") - -logger = init_logger(__name__) - - -class ChatTemplateResolutionError(ValueError): - """Raised when chat template resolution fails. - - This is a subclass of ValueError for backward compatibility with - existing exception handlers. - """ - - -MODALITY_PLACEHOLDERS_MAP = { - "image": "<##IMAGE##>", - "audio": "<##AUDIO##>", - "video": "<##VIDEO##>", - "prompt_embeds": "<##PROMPT_EMBEDS##>", -} - - -PROMPT_EMBEDS_PLACEHOLDER_TOKEN: Final[str] = "" -"""The special token used as a placeholder for each embedding -position during chat template rendering. - -Registered as an additional special token when `--enable-prompt-embeds` is set. -See `_ensure_prompt_embeds_placeholder_token` in `vllm/renderers/hf.py`. -""" - - -_REQUIRE_MM_PROCESSOR_ERROR: Final[str] = ( - "Resolving modality {modality!r} requires a multimodal processor " - "but none is available." -) - -_ENABLE_PROMPT_EMBEDS_ERROR: Final[str] = ( - "You must set `--enable-prompt-embeds` to input `prompt_embeds`" -) - -_PROMPT_EMBEDS_MISSING_DATA_ERROR: Final[str] = ( - "prompt_embeds content part requires a non-empty `data` field " - "with base64-encoded tensor bytes." -) - -_RESERVED_PLACEHOLDER_IN_TEXT_ERROR: Final[str] = ( - "Text content may not contain the reserved placeholder {token!r}. " - "This placeholder is used internally to mark `prompt_embeds` splice " - "positions in the tokenized prompt." -) - - -class AudioURL(TypedDict, total=False): - url: Required[str] - """ - Either a URL of the audio or a data URL with base64 encoded audio data. - """ - - -class ChatCompletionContentPartAudioParam(TypedDict, total=False): - audio_url: Required[AudioURL] - - type: Required[Literal["audio_url"]] - """The type of the content part.""" - - -MultiModalEmbedsPayload: TypeAlias = str | dict[str, str | list[int | float]] - - -class ChatCompletionContentPartImageEmbedsParam(TypedDict, total=False): - image_embeds: MultiModalEmbedsPayload | None - """ - The image embeddings. It can be either: - - A single base64 string. - - A dictionary of base64 tensors or numeric JSON metadata arrays. - """ - type: Required[Literal["image_embeds"]] - """The type of the content part.""" - uuid: str | None - """ - User-provided UUID of a media. User must guarantee that it is properly - generated and unique for different medias. - """ - - -class ChatCompletionContentPartAudioEmbedsParam(TypedDict, total=False): - audio_embeds: MultiModalEmbedsPayload | None - """ - The audio embeddings. It can be either: - - A single base64 string representing a serialized torch tensor. - - A dictionary of base64 tensors or numeric JSON metadata arrays. - """ - type: Required[Literal["audio_embeds"]] - """The type of the content part.""" - uuid: str | None - """ - User-provided UUID of a media. User must guarantee that it is properly - generated and unique for different medias. - """ - - -class ChatCompletionContentPartVideoEmbedsParam(TypedDict, total=False): - video_embeds: MultiModalEmbedsPayload | None - """ - The video embeddings. It can be either: - - A single base64 string representing a serialized torch tensor. - - A dictionary of base64 tensors or numeric JSON metadata arrays. - """ - type: Required[Literal["video_embeds"]] - """The type of the content part.""" - uuid: str | None - """ - User-provided UUID of a media. User must guarantee that it is properly - generated and unique for different medias. - """ - - -class ChatCompletionContentPartPromptEmbedsParam(TypedDict, total=False): - data: Required[str] - """ - Base64-encoded bytes of a serialized `torch.Tensor` of shape - `(num_tokens, hidden_size)`. The tensor's `dtype` and `hidden_size` must - match the model's input embedding layer. - """ - type: Required[Literal["prompt_embeds"]] - """The type of the content part.""" - - -class VideoURL(TypedDict, total=False): - url: Required[str] - """ - Either a URL of the video or a data URL with base64 encoded video data. - """ - - -class ChatCompletionContentPartVideoParam(TypedDict, total=False): - video_url: Required[VideoURL] - - type: Required[Literal["video_url"]] - """The type of the content part.""" - - -class PILImage(BaseModel): - """A PIL.Image.Image object.""" - - image_pil: Image.Image - model_config = ConfigDict(arbitrary_types_allowed=True) - - -class CustomChatCompletionContentPILImageParam(TypedDict, total=False): - """A simpler version of the param that only accepts a PIL image. - - Example: - { - "image_pil": ImageAsset('cherry_blossom').pil_image - } - - """ - - image_pil: PILImage | None - uuid: str | None - """ - User-provided UUID of a media. User must guarantee that it is properly - generated and unique for different medias. - """ - - -class CustomChatCompletionContentSimpleImageParam(TypedDict, total=False): - """A simpler version of the param that only accepts a plain image_url. - This is supported by OpenAI API, although it is not documented. - - Example: - { - "image_url": "https://example.com/image.jpg" - } - - """ - - image_url: str | None - uuid: str | None - """ - User-provided UUID of a media. User must guarantee that it is properly - generated and unique for different medias. - """ - - -class CustomChatCompletionContentSimpleAudioParam(TypedDict, total=False): - """A simpler version of the param that only accepts a plain audio_url. - - Example: - { - "audio_url": "https://example.com/audio.mp3" - } - - """ - - audio_url: str | None - - -class CustomChatCompletionContentSimpleVideoParam(TypedDict, total=False): - """A simpler version of the param that only accepts a plain audio_url. - - Example: - { - "video_url": "https://example.com/video.mp4" - } - - """ - - video_url: str | None - uuid: str | None - """ - User-provided UUID of a media. User must guarantee that it is properly - generated and unique for different medias. - """ - - -class CustomThinkCompletionContentParam(TypedDict, total=False): - """A Think Completion Content Param that accepts a plain text and a boolean. - - Example: - { - "thinking": "I am thinking about the answer", - "closed": True, - "type": "thinking" - } - - """ - - thinking: Required[str] - """The thinking content.""" - - closed: bool - """Whether the thinking is closed.""" - - type: Required[Literal["thinking"]] - """The thinking type.""" - - -class CustomChatCompletionContentToolReferenceParam(TypedDict, total=False): - """A tool reference content param that only accepts a plain tool name. - - Example: - { - "name": "get_weather", - "type": "tool_reference" - } - - """ - - name: str - """The name of the tool being referenced.""" - - type: Literal["tool_reference"] - """The content type.""" - - -ChatCompletionContentPartParam: TypeAlias = ( - OpenAIChatCompletionContentPartParam - | ChatCompletionContentPartAudioParam - | ChatCompletionContentPartInputAudioParam - | ChatCompletionContentPartVideoParam - | ChatCompletionContentPartRefusalParam - | CustomChatCompletionContentPILImageParam - | CustomChatCompletionContentSimpleImageParam - | ChatCompletionContentPartImageEmbedsParam - | ChatCompletionContentPartAudioEmbedsParam - | ChatCompletionContentPartVideoEmbedsParam - | ChatCompletionContentPartPromptEmbedsParam - | CustomChatCompletionContentSimpleAudioParam - | CustomChatCompletionContentSimpleVideoParam - | CustomChatCompletionContentToolReferenceParam - | str - | CustomThinkCompletionContentParam -) - - -class CustomChatCompletionMessageParam(TypedDict, total=False): - """Enables custom roles in the Chat Completion API.""" - - role: Required[str] - """The role of the message's author.""" - - content: str | list[ChatCompletionContentPartParam] - """The contents of the message.""" - - name: str - """An optional name for the participant. - - Provides the model information to differentiate between participants of the - same role. - """ - - tool_call_id: str | None - """Tool call that this message is responding to.""" - - tool_calls: list[ChatCompletionMessageToolCallParam] | None - """The tool calls generated by the model, such as function calls.""" - - reasoning: str | None - """The reasoning content for interleaved thinking.""" - - tools: list[ChatCompletionFunctionToolParam] | None - """The tools for developer role.""" - - task: str | None - """Model-specific task marker. Currently passed through for DeepSeek V4.""" - - -ChatCompletionMessageParam: TypeAlias = ( - OpenAIChatCompletionMessageParam - | CustomChatCompletionMessageParam - | OpenAIHarmonyMessage -) - - -# TODO: Make fields ReadOnly once mypy supports it -class ConversationMessage(TypedDict, total=False): - role: Required[str] - """The role of the message's author.""" - - content: str | None | list[dict[str, str]] - """The contents of the message""" - - tool_call_id: str | None - """Tool call that this message is responding to.""" - - name: str | None - """The name of the function to call""" - - tool_calls: list[ChatCompletionMessageToolCallParam] | None - """The tool calls generated by the model, such as function calls.""" - - reasoning: str | None - """The reasoning content for interleaved thinking.""" - - reasoning_content: str | None - """Deprecated: The reasoning content for interleaved thinking.""" - - tools: list[ChatCompletionFunctionToolParam] | None - """The tools for developer role.""" - - task: str | None - """Model-specific task marker. Currently passed through for DeepSeek V4.""" - - -# Passed in by user -ChatTemplateContentFormatOption = Literal["auto", "string", "openai"] - -# After resolving "auto" -ChatTemplateContentFormat = Literal["string", "openai"] - - -ModalityStr = Literal[ - "image", - "audio", - "video", - "image_embeds", - "audio_embeds", - "video_embeds", - "vision_chunk", - "prompt_embeds", -] -_T = TypeVar("_T") -_AsyncMultiModalItem: TypeAlias = Callable[[], Awaitable[tuple[object, str | None]]] - - -# Backward compatibility for single item input -class _BatchedSingleItemField(MultiModalSharedField): - pass - - -def _detect_field( - tensors: list[torch.Tensor], - mm_processor: BaseMultiModalProcessor, -): - first_item = tensors[0] - hidden_size = mm_processor.info.ctx.model_config.get_inputs_embeds_size() - - if ( - len(tensors) == 1 - and first_item.ndim == 3 - and first_item.shape[0] == 1 - and first_item.shape[-1] == hidden_size - ): - logger.warning( - "Batched multi-modal embedding inputs are deprecated for Chat API. " - "Please pass a separate content part for each multi-modal item." - ) - return _BatchedSingleItemField(batch_size=1) - - first_shape = first_item.shape - if all(t.shape == first_shape for t in tensors): - return MultiModalBatchedField() - - size_per_item = [len(tensor) for tensor in tensors] - slice_idxs = [0, *accumulate(size_per_item)] - slices = [ - (slice(slice_idxs[i], slice_idxs[i + 1]),) for i in range(len(size_per_item)) - ] - return MultiModalFlatField(slices=slices) - - -def _merge_embeds( - data_items: list[dict[str, "torch.Tensor"]], - mm_processor: BaseMultiModalProcessor, -): - if not data_items: - return {} - - first_keys = set(data_items[0].keys()) - if any(set(item.keys()) != first_keys for item in data_items[1:]): - raise VLLMValidationError( - "All dictionaries in the list of embeddings must have the same keys." - ) - - fields = { - key: _detect_field([item[key] for item in data_items], mm_processor) - for key in first_keys - } - data_merged = { - key: field._reduce_data([item[key] for item in data_items], pin_memory=False) - for key, field in fields.items() - } - - try: - # TODO: Support per-request mm_processor_kwargs - parsed_configs = mm_processor._get_mm_fields_config( - transformers.BatchFeature(data_merged), - {}, - ) - parsed_fields = {key: parsed_configs[key].field for key in first_keys} - keys_to_update = [ - key - for key in first_keys - if ( - fields[key] != parsed_fields[key] - and not isinstance(fields[key], _BatchedSingleItemField) - ) - ] - - for key in keys_to_update: - data_merged[key] = parsed_fields[key]._reduce_data( - [item[key] for item in data_items], pin_memory=False - ) - except Exception: - logger.exception( - "Error when parsing merged embeddings. " - "Falling back to auto-detected fields." - ) - - return data_merged - - -async def _load_embeds_dict( - data: dict[str, str | list[int | float]], - fetch: Callable[[str], Awaitable["torch.Tensor"]], -) -> dict[str, Any]: - encoded = {key: value for key, value in data.items() if isinstance(value, str)} - tensors = await asyncio.gather(*(fetch(value) for value in encoded.values())) - return {**data, **dict(zip(encoded, tensors))} - - -def _parse_metadata_array(key: str, value: list, metadata_fields: set[str]): - if key not in metadata_fields: - raise VLLMValidationError(f"JSON arrays are only supported for metadata: {key}") - if not is_list_of_numbers(value): - raise VLLMValidationError(f"Metadata {key} must be a finite numeric array.") - dtype = torch.float64 if any(isinstance(v, float) for v in value) else torch.int64 - try: - return torch.tensor(value, dtype=dtype) - except (ValueError, TypeError, OverflowError, RuntimeError) as error: - raise VLLMValidationError(f"Invalid metadata array: {key}") from error - - -def _get_embeds_data( - modality: str, - data_items: list[Any], - mm_processor: BaseMultiModalProcessor, -): - if len(data_items) == 0: - return data_items - - if all(item is None for item in data_items): - return data_items - - if is_list_of(data_items, torch.Tensor): - embeds_key = f"{modality}_embeds" - dict_items = [{embeds_key: item} for item in data_items] - return _merge_embeds(dict_items, mm_processor)[embeds_key] - - if is_list_of(data_items, dict): - metadata_fields = mm_processor.info.data_parser.placeholder_metadata_fields( - modality - ) - data_items = [ - { - key: _parse_metadata_array(key, value, metadata_fields) - if isinstance(value, list) - else value - for key, value in item.items() - } - for item in data_items - ] - return _merge_embeds(data_items, mm_processor) - - raise NotImplementedError(type(data_items)) - - -class BaseMultiModalItemTracker(ABC, Generic[_T]): - """Tracks multi-modal items in a given request and ensures that the number - of multi-modal items in a given request does not exceed the configured - maximum per prompt. - """ - - def __init__( - self, - model_config: ModelConfig, - media_io_kwargs: dict[str, dict[str, Any]] | None = None, - ): - super().__init__() - - self._model_config = model_config - self._media_io_kwargs = media_io_kwargs - - self._items_by_modality = defaultdict[str, list[_T]](list) - # Track original modality for each vision_chunk item (image or video) - self._modality_order = defaultdict[str, list[str]](list) - self.decoded_budget = MediaDecodedBudget() - self.download_budget = MediaRequestDownloadBudget() - - @cached_property - def use_unified_vision_chunk_modality(self) -> bool: - """Check if model uses unified vision_chunk modality for images/videos.""" - return getattr(self._model_config.hf_config, "use_unified_vision_chunk", False) - - @property - def model_config(self) -> ModelConfig: - return self._model_config - - @cached_property - def model_cls(self) -> type[SupportsMultiModal]: - from vllm.model_executor.model_loader import get_model_cls - - model_cls = get_model_cls(self.model_config) - return cast(type[SupportsMultiModal], model_cls) - - @property - def media_io_kwargs(self) -> dict[str, dict[str, Any]] | None: - return self._media_io_kwargs or ( - self._model_config.multimodal_config.media_io_kwargs - if self._model_config.multimodal_config - else None - ) - - @property - def allowed_local_media_path(self): - return self._model_config.allowed_local_media_path - - @property - def allowed_media_domains(self): - return self._model_config.allowed_media_domains - - @property - def mm_registry(self): - return MULTIMODAL_REGISTRY - - @cached_property - def mm_processor(self): - return self.mm_registry.create_processor(self.model_config) - - @property - def video_processor_name(self) -> str | None: - return get_video_processor_cls_name(self.model_config) - - def add(self, modality: ModalityStr, item: _T) -> str | None: - """Add a multi-modal item to the current prompt and returns the - placeholder string to use, if any. - - An optional uuid can be added which serves as a unique identifier of the - media. - - Note: - `prompt_embeds` bypass MM-processor validation because they are - pre-computed embeddings that do not go through any HF processor, encoder, - or model-specific placeholder logic. The corresponding placeholder string is - managed by the parser via `_add_placeholder`, so we return None here. - - """ - add_info = self._validate_add(modality) - if add_info is None: - self._items_by_modality["prompt_embeds"].append(item) - return None - - input_modality, original_modality, use_vision_chunk, num_items = add_info - - # Track original modality for vision_chunk items - if use_vision_chunk: - self._items_by_modality[input_modality].append(item) # type: ignore - self._modality_order["vision_chunk"].append(original_modality) - else: - self._items_by_modality[original_modality].append(item) - - return self.model_cls.get_placeholder_str(modality, num_items) - - def _validate_add(self, modality: ModalityStr) -> tuple[str, str, bool, int] | None: - """Validate that one more item of the modality can be tracked.""" - if modality == "prompt_embeds": - return None - - input_modality = modality.replace("_embeds", "") - original_modality = modality - use_vision_chunk = ( - self.use_unified_vision_chunk_modality - and original_modality in ["video", "image"] - ) - - # If use_unified_vision_chunk_modality is enabled, - # map image/video to vision_chunk - if use_vision_chunk: - # To avoid validation fail - # because models with use_unified_vision_chunk_modality=True - # will only accept vision_chunk modality. - input_modality = "vision_chunk" - num_items = len(self._items_by_modality[input_modality]) + 1 - else: - num_items = len(self._items_by_modality[original_modality]) + 1 - - mm_config = self.model_config.multimodal_config - if ( - mm_config is not None - and mm_config.enable_mm_embeds - and mm_config.get_limit_per_prompt(input_modality) == 0 - and original_modality.endswith("_embeds") - ): - # Skip validation: embeddings bypass limit when enable_mm_embeds=True - pass - else: - self.mm_processor.info.validate_num_items(input_modality, num_items) - - return input_modality, original_modality, use_vision_chunk, num_items - - @abstractmethod - def create_parser( - self, mm_processor_kwargs: dict[str, Any] | None = None - ) -> "BaseMultiModalContentParser": - raise NotImplementedError - - -def _resolve_vision_chunk_items( - vision_chunk_items: list[tuple[object, str | None]], - mm_processor: BaseMultiModalProcessor, - vision_chunks_modality_order: list[str], -): - # Process vision_chunk items - extract from (data, modality) tuples - # and convert to VisionChunk types with proper UUID handling - vision_chunks_uuids = [uuid for data, uuid in vision_chunk_items] - - assert len(vision_chunk_items) == len(vision_chunks_modality_order), ( - f"vision_chunk items ({len(vision_chunk_items)}) and " - f"modality_order ({len(vision_chunks_modality_order)}) must have same length" - ) - - processed_chunks: list[VisionChunk] = [] - video_idx = 0 - for inner_modality, (data, uuid) in zip( - vision_chunks_modality_order, vision_chunk_items - ): - if inner_modality == "image": - # Cast data to proper type for image - # Use .media (PIL.Image) directly to avoid redundant - # bytes→PIL conversion in media_processor - if hasattr(data, "media"): - image_data = data.media # type: ignore[union-attr] - processed_chunks.append( - VisionChunkImage(type="image", image=image_data, uuid=uuid) - ) - else: - processed_chunks.append(data) # type: ignore[arg-type] - elif inner_modality == "video": - # For video, we may need to split into chunks - # if processor supports it - # For now, just wrap as a video chunk placeholder - if hasattr(mm_processor, "split_video_chunks") and data is not None: - try: - video_uuid = uuid or random_uuid() - # video await result is (video_data, video_meta) tuple - if isinstance(data, tuple) and len(data) >= 1: - video_data = data[0] - else: - video_data = data - video_chunks = mm_processor.split_video_chunks(video_data) - for i, vc in enumerate(video_chunks): - processed_chunks.append( - VisionChunkVideo( - type="video_chunk", - video_chunk=vc["video_chunk"], - uuid=f"{video_uuid}-{i}", - video_idx=video_idx, - prompt=vc["prompt"], - ) - ) - video_idx += 1 - except Exception as e: - logger.warning("Failed to split video chunks: %s", e) - processed_chunks.append(data) # type: ignore[arg-type] - else: - processed_chunks.append(data) # type: ignore[arg-type] - return processed_chunks, vision_chunks_uuids - - -def _resolve_items( - items_by_modality: dict[str, list[tuple[object, str | None]]], - mm_processor: BaseMultiModalProcessor | None, - modality_order: dict[str, list[str]], -) -> tuple[MultiModalDataDict, MultiModalUUIDDict]: - """Materialize the tracker's per-modality items into `mm_data` / `mm_uuids`. - - Note: - `mm_processor` is `None` for text-only models (no registered HF - processor) whose only modality is `prompt_embeds`. Every other - modality requires a processor, enforced by the guard below. - - """ - if "image" in items_by_modality and "image_embeds" in items_by_modality: - raise VLLMValidationError( - "Mixing raw image and embedding inputs is not allowed", - parameter="image_embeds", - ) - if "audio" in items_by_modality and "audio_embeds" in items_by_modality: - raise VLLMValidationError( - "Mixing raw audio and embedding inputs is not allowed", - parameter="audio_embeds", - ) - if "video" in items_by_modality and "video_embeds" in items_by_modality: - raise VLLMValidationError( - "Mixing raw video and embedding inputs is not allowed", - parameter="video_embeds", - ) - # `prompt_embeds` bypasses HF MM processors. Every other modality requires one. - processor_modalities = items_by_modality.keys() - {"prompt_embeds"} - if processor_modalities and mm_processor is None: - raise RuntimeError( - _REQUIRE_MM_PROCESSOR_ERROR.format(modality=processor_modalities) - ) - - mm_data = {} - mm_uuids = {} - if "image_embeds" in items_by_modality: - assert mm_processor is not None - mm_data["image"] = _get_embeds_data( - "image", - [data for data, uuid in items_by_modality["image_embeds"]], - mm_processor, - ) - mm_uuids["image"] = [uuid for data, uuid in items_by_modality["image_embeds"]] - if "image" in items_by_modality: - mm_data["image"] = [data for data, uuid in items_by_modality["image"]] - mm_uuids["image"] = [uuid for data, uuid in items_by_modality["image"]] - if "audio_embeds" in items_by_modality: - assert mm_processor is not None - mm_data["audio"] = _get_embeds_data( - "audio", - [data for data, uuid in items_by_modality["audio_embeds"]], - mm_processor, - ) - mm_uuids["audio"] = [uuid for data, uuid in items_by_modality["audio_embeds"]] - if "audio" in items_by_modality: - mm_data["audio"] = [data for data, uuid in items_by_modality["audio"]] - mm_uuids["audio"] = [uuid for data, uuid in items_by_modality["audio"]] - if "video" in items_by_modality: - mm_data["video"] = [data for data, uuid in items_by_modality["video"]] - mm_uuids["video"] = [uuid for data, uuid in items_by_modality["video"]] - if "video_embeds" in items_by_modality: - assert mm_processor is not None - mm_data["video"] = _get_embeds_data( - "video", - [data for data, uuid in items_by_modality["video_embeds"]], - mm_processor, - ) - mm_uuids["video"] = [uuid for data, uuid in items_by_modality["video_embeds"]] - if "vision_chunk" in items_by_modality: - assert mm_processor is not None - # Process vision_chunk items - extract from (data, modality) tuples - # and convert to VisionChunk types with proper UUID handling - processed_chunks, vision_chunk_uuids = _resolve_vision_chunk_items( - items_by_modality["vision_chunk"], - mm_processor, - modality_order.get("vision_chunk", []), - ) - mm_data["vision_chunk"] = processed_chunks - mm_uuids["vision_chunk"] = vision_chunk_uuids - if "prompt_embeds" in items_by_modality: - mm_data["prompt_embeds"] = [ - data for data, _uuid in items_by_modality["prompt_embeds"] - ] - - return mm_data, mm_uuids - - -class MultiModalItemTracker(BaseMultiModalItemTracker[tuple[object, str | None]]): - def resolve_items( - self, - ) -> tuple[MultiModalDataDict | None, MultiModalUUIDDict | None]: - if not self._items_by_modality: - return None, None - - # Text-only models (`is_multimodal_model=False`) with inputs of - # modality `prompt_embeds` have no MM processor since `prompt_embeds` are - # pre-computed and require no processing, so we pass `None`. - mm_processor = ( - self.mm_processor if self._model_config.is_multimodal_model else None - ) - return _resolve_items( - dict(self._items_by_modality), - mm_processor, - self._modality_order, - ) - - def create_parser( - self, mm_processor_kwargs: dict[str, Any] | None = None - ) -> "BaseMultiModalContentParser": - return MultiModalContentParser(self, mm_processor_kwargs=mm_processor_kwargs) - - -class AsyncMultiModalItemTracker(BaseMultiModalItemTracker[_AsyncMultiModalItem]): - async def resolve_items( - self, - ) -> tuple[MultiModalDataDict | None, MultiModalUUIDDict | None]: - if not self._items_by_modality: - return None, None - - # Fetch all modalities together. Each tracked item is already an - # independent awaitable, and the async connector offloads blocking - # decode work, so waiting for one modality before starting the next - # needlessly adds their latency. - # Keep the original group and item order when rebuilding the result. - item_groups = list(self._items_by_modality.items()) - items = [item for _, group in item_groups for item in group] - results = await asyncio.gather( - *(item() for item in items), return_exceptions=True - ) - for result in results: - if isinstance(result, BaseException): - # Gathering with return_exceptions=True lets every task finish - # (or itself fail) before we raise, instead of abandoning - # still-in-flight fetches (real network/thread-pool work) the - # moment the first one fails. - raise result - - resolved_items_by_modality: dict[str, list[Any]] = {} - result_idx = 0 - for modality, group in item_groups: - next_result_idx = result_idx + len(group) - resolved_items_by_modality[modality] = results[result_idx:next_result_idx] - result_idx = next_result_idx - - mm_processor = ( - self.mm_processor if self._model_config.is_multimodal_model else None - ) - return _resolve_items( - resolved_items_by_modality, - mm_processor, - self._modality_order, - ) - - def create_parser( - self, mm_processor_kwargs: dict[str, Any] | None = None - ) -> "BaseMultiModalContentParser": - return AsyncMultiModalContentParser( - self, mm_processor_kwargs=mm_processor_kwargs - ) - - -class BaseMultiModalContentParser(ABC): - def __init__(self) -> None: - super().__init__() - - # stores model placeholders list with corresponding - # general MM placeholder: - # { - # "<##IMAGE##>": ["", "", ""], - # "<##AUDIO##>": ["