A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 12, 19:45 UTC
[Core] Add audio_embeds support to chat completions (#29059)
Signed-off-by: Jeremy Teboul <jeremyteboul@fb.com> Co-authored-by: Jeremy Teboul <jeremyteboul@fb.com>
jeremyteboul committed
Nov 20, 2025 at 19:39 UTC
0730414999343e722590ace615d5814c7e5b6827
5 files changed
+360
-3
docs/features/multimodal_inputs.md
+32
@@ -365,6 +365,8 @@ You must enable this feature via `enable_mm_embeds=True`.
365
The vLLM engine may crash if incorrect shape of embeddings is passed.
366
Only enable this flag for trusted users!
367
368
+#### Image Embeddings
369
+
370
??? code
371
372
```python
@@ -441,6 +443,36 @@ For Qwen2-VL and MiniCPM-V, we accept additional parameters alongside the embedd
443
print(generated_text)
444
```
445
446
+#### Audio Embeddings
447
+
448
+You can pass pre-computed audio embeddings similar to image embeddings:
449
+
450
+??? code
451
+
452
+ ```python
453
+ from vllm import LLM
454
+ import torch
455
+
456
+ # Enable audio embeddings support
457
+ llm = LLM(model="fixie-ai/ultravox-v0_5-llama-3_2-1b", enable_mm_embeds=True)
458
+
459
+ # Refer to the HuggingFace repo for the correct format to use
460
+ prompt = "USER: <audio>\nWhat is in this audio?\nASSISTANT:"
461
+
462
+ # Load pre-computed audio embeddings
463
+ # torch.Tensor of shape (1, audio_feature_size, hidden_size of LM)
464
+ audio_embeds = torch.load(...)
465
+
466
+ outputs = llm.generate({
467
+ "prompt": prompt,
468
+ "multi_modal_data": {"audio": audio_embeds},
469
+ })
470
+
471
+ for o in outputs:
472
+ generated_text = o.outputs[0].text
473
+ print(generated_text)
474
+ ```
475
+
476
## Online Serving
477
478
Our OpenAI-compatible server accepts multi-modal data via the [Chat Completions API](https://platform.openai.com/docs/api-reference/chat). Media inputs also support optional UUIDs users can provide to uniquely identify each media, which is used to cache the media results across requests.
tests/entrypoints/test_chat_utils.py
+145
@@ -103,6 +103,19 @@ def qwen2_audio_model_config():
103
)
104
105
106
+@pytest.fixture(scope="function")
107
+def audio_embeds_model_config():
108
+ return ModelConfig(
109
+ QWEN2AUDIO_MODEL_ID,
110
+ runner="generate",
111
+ trust_remote_code=True,
112
+ limit_mm_per_prompt={
113
+ "audio": 2,
114
+ },
115
+ enable_mm_embeds=True,
116
+ )
117
+
118
+
119
@pytest.fixture(scope="module")
120
def qwen2_audio_tokenizer():
121
return get_tokenizer(QWEN2AUDIO_MODEL_ID)
@@ -843,6 +856,138 @@ def test_parse_chat_messages_empty_image_embeds_with_uuid(
856
_assert_mm_uuids(mm_uuids, 1, expected_uuids=[uuid])
857
858
859
+def test_parse_chat_messages_empty_audio_embeds_with_uuid(
860
+ audio_embeds_model_config,
861
+ qwen2_audio_tokenizer,
862
+):
863
+ """Test audio_embeds with UUID (no actual embeds data)."""
864
+ uuid = "test-audio-uuid-123"
865
+
866
+ conversation, mm_data, mm_uuids = parse_chat_messages(
867
+ [
868
+ {
869
+ "role": "user",
870
+ "content": [
871
+ {"type": "text", "text": "Describe this audio"},
872
+ {"type": "audio_embeds", "audio_embeds": None, "uuid": uuid},
873
+ ],
874
+ }
875
+ ],
876
+ audio_embeds_model_config,
877
+ qwen2_audio_tokenizer,
878
+ content_format="string",
879
+ )
880
+
881
+ # Should have audio in mm_data as None (UUID provided)
882
+ assert mm_data is not None
883
+ assert "audio" in mm_data
884
+ assert mm_data["audio"] is None
885
+ # UUID should be recorded
886
+ assert mm_uuids is not None
887
+ assert "audio" in mm_uuids
888
+ _assert_mm_uuids(mm_uuids, 1, modality="audio", expected_uuids=[uuid])
889
+
890
+
891
+def test_parse_chat_messages_audio_embeds_with_string(
892
+ audio_embeds_model_config,
893
+ qwen2_audio_tokenizer,
894
+):
895
+ """Test audio_embeds with base64 string embedding data."""
896
+ import base64
897
+ import io
898
+
899
+ import torch
900
+
901
+ # Create a sample audio embedding tensor
902
+ audio_embedding = torch.randn(1, 128, 768)
903
+
904
+ # Encode it as base64
905
+ buffer = io.BytesIO()
906
+ torch.save(audio_embedding, buffer)
907
+ buffer.seek(0)
908
+ binary_data = buffer.read()
909
+ base64_audio_embedding = base64.b64encode(binary_data).decode("utf-8")
910
+
911
+ conversation, mm_data, mm_uuids = parse_chat_messages(
912
+ [
913
+ {
914
+ "role": "user",
915
+ "content": [
916
+ {"type": "text", "text": "Describe this audio"},
917
+ {
918
+ "type": "audio_embeds",
919
+ "audio_embeds": base64_audio_embedding,
920
+ },
921
+ ],
922
+ }
923
+ ],
924
+ audio_embeds_model_config,
925
+ qwen2_audio_tokenizer,
926
+ content_format="string",
927
+ )
928
+
929
+ # Should have audio embedding in mm_data (single tensor, not a list)
930
+ assert mm_data is not None
931
+ assert "audio" in mm_data
932
+ assert isinstance(mm_data["audio"], torch.Tensor)
933
+ assert mm_data["audio"].shape == audio_embedding.shape
934
+ # No UUID provided
935
+ assert mm_uuids is not None
936
+ assert "audio" in mm_uuids
937
+ _assert_mm_uuids(mm_uuids, 1, modality="audio", expected_uuids=[None])
938
+
939
+
940
+@pytest.mark.asyncio
941
+async def test_parse_chat_messages_audio_embeds_async(
942
+ audio_embeds_model_config,
943
+ qwen2_audio_tokenizer,
944
+):
945
+ """Test audio_embeds with async futures."""
946
+ import base64
947
+ import io
948
+
949
+ import torch
950
+
951
+ # Create a sample audio embedding tensor
952
+ audio_embedding = torch.randn(1, 128, 768)
953
+
954
+ # Encode it as base64
955
+ buffer = io.BytesIO()
956
+ torch.save(audio_embedding, buffer)
957
+ buffer.seek(0)
958
+ binary_data = buffer.read()
959
+ base64_audio_embedding = base64.b64encode(binary_data).decode("utf-8")
960
+
961
+ conversation, mm_future, mm_uuids = parse_chat_messages_futures(
962
+ [
963
+ {
964
+ "role": "user",
965
+ "content": [
966
+ {"type": "text", "text": "Describe this audio"},
967
+ {
968
+ "type": "audio_embeds",
969
+ "audio_embeds": base64_audio_embedding,
970
+ },
971
+ ],
972
+ }
973
+ ],
974
+ audio_embeds_model_config,
975
+ qwen2_audio_tokenizer,
976
+ content_format="string",
977
+ )
978
+
979
+ # Should have audio embedding in mm_data (single tensor, not a list)
980
+ mm_data = await mm_future
981
+ assert mm_data is not None
982
+ assert "audio" in mm_data
983
+ assert isinstance(mm_data["audio"], torch.Tensor)
984
+ assert mm_data["audio"].shape == audio_embedding.shape
985
+ # No UUID provided
986
+ assert mm_uuids is not None
987
+ assert "audio" in mm_uuids
988
+ _assert_mm_uuids(mm_uuids, 1, modality="audio", expected_uuids=[None])
989
+
990
+
991
@pytest.mark.asyncio
992
async def test_parse_chat_messages_empty_image_embeds_with_uuid_async(
993
phi3v_model_config_image_embeds,
vllm/entrypoints/chat_utils.py
+147
-2
@@ -94,6 +94,22 @@ class ChatCompletionContentPartImageEmbedsParam(TypedDict, total=False):
94
"""
95
96
97
+class ChatCompletionContentPartAudioEmbedsParam(TypedDict, total=False):
98
+ audio_embeds: str | dict[str, str] | None
99
+ """
100
+ The audio embeddings. It can be either:
101
+ - A single base64 string representing a serialized torch tensor.
102
+ - A dictionary where each value is a base64 string.
103
+ """
104
+ type: Required[Literal["audio_embeds"]]
105
+ """The type of the content part."""
106
+ uuid: str | None
107
+ """
108
+ User-provided UUID of a media. User must guarantee that it is properly
109
+ generated and unique for different medias.
110
+ """
111
+
112
+
113
class VideoURL(TypedDict, total=False):
114
url: Required[str]
115
"""
@@ -211,6 +227,7 @@ ChatCompletionContentPartParam: TypeAlias = (
227
| CustomChatCompletionContentPILImageParam
228
| CustomChatCompletionContentSimpleImageParam
229
| ChatCompletionContentPartImageEmbedsParam
230
+ | ChatCompletionContentPartAudioEmbedsParam
231
| CustomChatCompletionContentSimpleAudioParam
232
| CustomChatCompletionContentSimpleVideoParam
233
| str
@@ -599,7 +616,7 @@ def resolve_chat_template_content_format(
616
return detected_format
617
618
602
-ModalityStr = Literal["image", "audio", "video", "image_embeds"]
619
+ModalityStr = Literal["image", "audio", "video", "image_embeds", "audio_embeds"]
620
_T = TypeVar("_T")
621
622
@@ -684,6 +701,11 @@ class BaseMultiModalItemTracker(ABC, Generic[_T]):
701
mm_uuids["image"] = uuids_by_modality["image_embeds"]
702
if "image" in uuids_by_modality:
703
mm_uuids["image"] = uuids_by_modality["image"] # UUIDs of images
704
+ if "audio_embeds" in uuids_by_modality:
705
+ audio_embeds_uuids = uuids_by_modality["audio_embeds"]
706
+ if len(audio_embeds_uuids) > 1:
707
+ raise ValueError("Only one message can have {'type': 'audio_embeds'}")
708
+ mm_uuids["audio"] = uuids_by_modality["audio_embeds"]
709
if "audio" in uuids_by_modality:
710
mm_uuids["audio"] = uuids_by_modality["audio"] # UUIDs of audios
711
if "video" in uuids_by_modality:
@@ -703,6 +725,8 @@ class MultiModalItemTracker(BaseMultiModalItemTracker[object]):
725
items_by_modality = dict(self._items_by_modality)
726
if "image" in items_by_modality and "image_embeds" in items_by_modality:
727
raise ValueError("Mixing raw image and embedding inputs is not allowed")
728
+ if "audio" in items_by_modality and "audio_embeds" in items_by_modality:
729
+ raise ValueError("Mixing raw audio and embedding inputs is not allowed")
730
731
if "image_embeds" in items_by_modality:
732
image_embeds_lst = items_by_modality["image_embeds"]
@@ -711,6 +735,11 @@ class MultiModalItemTracker(BaseMultiModalItemTracker[object]):
735
mm_inputs["image"] = image_embeds_lst[0]
736
if "image" in items_by_modality:
737
mm_inputs["image"] = items_by_modality["image"] # A list of images
738
+ if "audio_embeds" in items_by_modality:
739
+ audio_embeds_lst = items_by_modality["audio_embeds"]
740
+ if len(audio_embeds_lst) > 1:
741
+ raise ValueError("Only one message can have {'type': 'audio_embeds'}")
742
+ mm_inputs["audio"] = audio_embeds_lst[0]
743
if "audio" in items_by_modality:
744
mm_inputs["audio"] = items_by_modality["audio"] # A list of audios
745
if "video" in items_by_modality:
@@ -738,6 +767,8 @@ class AsyncMultiModalItemTracker(BaseMultiModalItemTracker[Awaitable[object]]):
767
768
if "image" in items_by_modality and "image_embeds" in items_by_modality:
769
raise ValueError("Mixing raw image and embedding inputs is not allowed")
770
+ if "audio" in items_by_modality and "audio_embeds" in items_by_modality:
771
+ raise ValueError("Mixing raw audio and embedding inputs is not allowed")
772
773
if "image_embeds" in items_by_modality:
774
image_embeds_lst = items_by_modality["image_embeds"]
@@ -746,6 +777,11 @@ class AsyncMultiModalItemTracker(BaseMultiModalItemTracker[Awaitable[object]]):
777
mm_inputs["image"] = image_embeds_lst[0]
778
if "image" in items_by_modality:
779
mm_inputs["image"] = items_by_modality["image"] # A list of images
780
+ if "audio_embeds" in items_by_modality:
781
+ audio_embeds_lst = items_by_modality["audio_embeds"]
782
+ if len(audio_embeds_lst) > 1:
783
+ raise ValueError("Only one message can have {'type': 'audio_embeds'}")
784
+ mm_inputs["audio"] = audio_embeds_lst[0]
785
if "audio" in items_by_modality:
786
mm_inputs["audio"] = items_by_modality["audio"] # A list of audios
787
if "video" in items_by_modality:
@@ -804,6 +840,14 @@ class BaseMultiModalContentParser(ABC):
840
) -> None:
841
raise NotImplementedError
842
843
+ @abstractmethod
844
+ def parse_audio_embeds(
845
+ self,
846
+ audio_embeds: str | dict[str, str] | None,
847
+ uuid: str | None = None,
848
+ ) -> None:
849
+ raise NotImplementedError
850
+
851
@abstractmethod
852
def parse_video(self, video_url: str | None, uuid: str | None = None) -> None:
853
raise NotImplementedError
@@ -861,6 +905,31 @@ class MultiModalContentParser(BaseMultiModalContentParser):
905
906
self._add_placeholder("image", placeholder)
907
908
+ def parse_audio_embeds(
909
+ self,
910
+ audio_embeds: str | dict[str, str] | None,
911
+ uuid: str | None = None,
912
+ ) -> None:
913
+ mm_config = self.model_config.get_multimodal_config()
914
+ if not mm_config.enable_mm_embeds:
915
+ raise ValueError(
916
+ "You must set `--enable-mm-embeds` to input `audio_embeds`"
917
+ )
918
+
919
+ if isinstance(audio_embeds, dict):
920
+ embeds = {
921
+ k: self._connector.fetch_audio_embedding(v)
922
+ for k, v in audio_embeds.items()
923
+ }
924
+ placeholder = self._tracker.add("audio_embeds", embeds, uuid)
925
+ elif isinstance(audio_embeds, str):
926
+ embedding = self._connector.fetch_audio_embedding(audio_embeds)
927
+ placeholder = self._tracker.add("audio_embeds", embedding, uuid)
928
+ else:
929
+ placeholder = self._tracker.add("audio_embeds", None, uuid)
930
+
931
+ self._add_placeholder("audio", placeholder)
932
+
933
def parse_image_pil(
934
self, image_pil: Image.Image | None, uuid: str | None = None
935
) -> None:
@@ -950,6 +1019,67 @@ class AsyncMultiModalContentParser(BaseMultiModalContentParser):
1019
placeholder = self._tracker.add("image_embeds", future, uuid)
1020
self._add_placeholder("image", placeholder)
1021
1022
+ def parse_audio_embeds(
1023
+ self,
1024
+ audio_embeds: str | dict[str, str] | None,
1025
+ uuid: str | None = None,
1026
+ ) -> None:
1027
+ mm_config = self.model_config.get_multimodal_config()
1028
+ if not mm_config.enable_mm_embeds:
1029
+ raise ValueError(
1030
+ "You must set `--enable-mm-embeds` to input `audio_embeds`"
1031
+ )
1032
+
1033
+ logger.info(
1034
+ "🎵 Parsing audio_embeds: type=%s, uuid=%s, is_dict=%s, "
1035
+ "is_str=%s, is_none=%s",
1036
+ type(audio_embeds).__name__,
1037
+ uuid,
1038
+ isinstance(audio_embeds, dict),
1039
+ isinstance(audio_embeds, str),
1040
+ audio_embeds is None,
1041
+ )
1042
+
1043
+ future: asyncio.Future[str | dict[str, str] | None] = asyncio.Future()
1044
+
1045
+ if isinstance(audio_embeds, dict):
1046
+ logger.info(
1047
+ "🎵 Processing dict audio_embeds with %d entries",
1048
+ len(audio_embeds),
1049
+ )
1050
+ embeds = {
1051
+ k: self._connector.fetch_audio_embedding(v)
1052
+ for k, v in audio_embeds.items()
1053
+ }
1054
+ future.set_result(embeds)
1055
+ logger.info(
1056
+ "🎵 Successfully loaded %d audio embeddings from dict",
1057
+ len(embeds),
1058
+ )
1059
+
1060
+ if isinstance(audio_embeds, str):
1061
+ base64_size = len(audio_embeds)
1062
+ logger.info(
1063
+ "🎵 Processing base64 audio_embeds: %d chars (%.2f KB)",
1064
+ base64_size,
1065
+ base64_size / 1024,
1066
+ )
1067
+ embedding = self._connector.fetch_audio_embedding(audio_embeds)
1068
+ future.set_result(embedding)
1069
+ logger.info(
1070
+ "🎵 Successfully loaded audio embedding tensor: shape=%s, dtype=%s",
1071
+ embedding.shape,
1072
+ embedding.dtype,
1073
+ )
1074
+
1075
+ if audio_embeds is None:
1076
+ logger.info("🎵 Audio embeds is None (UUID-only reference)")
1077
+ future.set_result(None)
1078
+
1079
+ placeholder = self._tracker.add("audio_embeds", future, uuid)
1080
+ self._add_placeholder("audio", placeholder)
1081
+ logger.info("🎵 Added audio_embeds placeholder with uuid=%s", uuid)
1082
+
1083
def parse_image_pil(
1084
self, image_pil: Image.Image | None, uuid: str | None = None
1085
) -> None:
@@ -1132,6 +1262,7 @@ def _get_full_multimodal_text_prompt(
1262
# No need to validate using Pydantic again
1263
_TextParser = partial(cast, ChatCompletionContentPartTextParam)
1264
_ImageEmbedsParser = partial(cast, ChatCompletionContentPartImageEmbedsParam)
1265
+_AudioEmbedsParser = partial(cast, ChatCompletionContentPartAudioEmbedsParam)
1266
_InputAudioParser = partial(cast, ChatCompletionContentPartInputAudioParam)
1267
_RefusalParser = partial(cast, ChatCompletionContentPartRefusalParam)
1268
_PILImageParser = partial(cast, CustomChatCompletionContentPILImageParam)
@@ -1155,6 +1286,7 @@ MM_PARSER_MAP: dict[
1286
"input_image": lambda part: _ResponsesInputImageParser(part).get("image_url", None),
1287
"image_url": lambda part: _ImageParser(part).get("image_url", {}).get("url", None),
1288
"image_embeds": lambda part: _ImageEmbedsParser(part).get("image_embeds", None),
1289
+ "audio_embeds": lambda part: _AudioEmbedsParser(part).get("audio_embeds", None),
1290
"image_pil": lambda part: _PILImageParser(part).get("image_pil", None),
1291
"audio_url": lambda part: _AudioParser(part).get("audio_url", {}).get("url", None),
1292
"input_audio": lambda part: _InputAudioParser(part).get("input_audio", None),
@@ -1223,8 +1355,17 @@ def _parse_chat_message_content_mm_part(
1355
)
1356
image_embeds = image_params.get("image_embeds", None)
1357
return "image_embeds", image_embeds
1358
+ if "audio_embeds" in part:
1359
+ # "audio_embeds" could be None if UUID is provided.
1360
+ audio_params = cast( # type: ignore[assignment]
1361
+ ChatCompletionContentPartAudioEmbedsParam, part
1362
+ )
1363
+ audio_embeds = audio_params.get("audio_embeds", None)
1364
+ return "audio_embeds", audio_embeds
1365
if "audio_url" in part:
1227
- audio_params = cast(CustomChatCompletionContentSimpleAudioParam, part)
1366
+ audio_params = cast( # type: ignore[assignment]
1367
+ CustomChatCompletionContentSimpleAudioParam, part
1368
+ )
1369
audio_url = audio_params.get("audio_url", None)
1370
if isinstance(audio_url, dict):
1371
# Can potentially happen if user provides a uuid
@@ -1348,6 +1489,10 @@ def _parse_chat_message_content_part(
1489
content = cast(str | dict[str, str], content) if content is not None else None
1490
mm_parser.parse_image_embeds(content, uuid)
1491
modality = "image"
1492
+ elif part_type == "audio_embeds":
1493
+ content = cast(str | dict[str, str], content) if content is not None else None
1494
+ mm_parser.parse_audio_embeds(content, uuid)
1495
+ modality = "audio"
1496
elif part_type == "audio_url":
1497
str_content = cast(str, content)
1498
mm_parser.parse_audio(str_content, uuid)
vllm/multimodal/audio.py
+24
@@ -7,6 +7,8 @@ from typing import Literal
7
8
import numpy as np
9
import numpy.typing as npt
10
+import pybase64
11
+import torch
12
13
from vllm.utils.import_utils import PlaceholderModule
14
@@ -116,3 +118,25 @@ class AudioMediaIO(MediaIO[tuple[npt.NDArray, float]]):
118
data = buffer.getvalue()
119
120
return base64.b64encode(data).decode("utf-8")
121
+
122
+
123
+class AudioEmbeddingMediaIO(MediaIO[torch.Tensor]):
124
+ def __init__(self) -> None:
125
+ super().__init__()
126
+
127
+ def load_bytes(self, data: bytes) -> torch.Tensor:
128
+ buffer = BytesIO(data)
129
+ return torch.load(buffer, weights_only=True)
130
+
131
+ def load_base64(self, media_type: str, data: str) -> torch.Tensor:
132
+ return self.load_bytes(pybase64.b64decode(data, validate=True))
133
+
134
+ def load_file(self, filepath: Path) -> torch.Tensor:
135
+ return torch.load(filepath, weights_only=True)
136
+
137
+ def encode_base64(self, media: torch.Tensor) -> str:
138
+ buffer = BytesIO()
139
+ torch.save(media, buffer)
140
+ buffer.seek(0)
141
+ binary_data = buffer.read()
142
+ return pybase64.b64encode(binary_data).decode("utf-8")
vllm/multimodal/utils.py
+12
-1
@@ -22,7 +22,7 @@ from vllm.logger import init_logger
22
from vllm.utils.jsontree import json_map_leaves
23
from vllm.utils.registry import ExtensionManager
24
25
-from .audio import AudioMediaIO
25
+from .audio import AudioEmbeddingMediaIO, AudioMediaIO
26
from .base import MediaIO
27
from .image import ImageEmbeddingMediaIO, ImageMediaIO
28
from .video import VideoMediaIO
@@ -342,6 +342,17 @@ class MediaConnector:
342
343
return image_embedding_io.load_base64("", data)
344
345
+ def fetch_audio_embedding(
346
+ self,
347
+ data: str,
348
+ ) -> torch.Tensor:
349
+ """
350
+ Load audio embedding from a URL.
351
+ """
352
+ audio_embedding_io = AudioEmbeddingMediaIO()
353
+
354
+ return audio_embedding_io.load_base64("", data)
355
+
356
357
def encode_audio_base64(
358
audio: np.ndarray,