[Frontend][EPD] Use JSON arrays for multimodal metadata (#56090)
Signed-off-by: Tianyu Guo <guoty@inferact.ai>
Tianyu Guo committed
Sep 10, 2026 at 13:04 UTC
479611c0c6118f95d6028a337e69b4713c529843
6 files changed
+167
-62
examples/disaggregated/disaggregated_encoder/disagg_epd_proxy.py
+9
-16
@@ -22,7 +22,6 @@ from __future__ import annotations
22
import argparse
23
import asyncio
24
import hashlib
25
-import io
25
import itertools
26
import json
27
import logging
@@ -34,7 +33,6 @@ from collections.abc import AsyncIterator
33
from typing import Any
34
35
import aiohttp
37
-import pybase64 as base64
36
import uvicorn
37
from fastapi import FastAPI, HTTPException, Request
38
from fastapi.responses import JSONResponse, StreamingResponse
@@ -125,19 +123,6 @@ def content_uuid(item: dict) -> str:
123
return hashlib.sha256(payload.encode()).hexdigest()
124
125
128
-def _b64_tensor(values: list) -> str:
129
- import torch
130
-
131
- buf = io.BytesIO()
132
- flat = [v for item in values for v in (item if isinstance(item, list) else [item])]
133
- # Floats stay float64 so timestamp strings format exactly as the
134
- # encoder computed them.
135
- dtype = torch.float64 if any(isinstance(v, float) for v in flat) else None
136
- # Downstream stacks per item, so hand over a flat vector.
137
- torch.save(torch.tensor(flat, dtype=dtype), buf)
138
- return base64.b64encode(buf.getvalue()).decode()
139
-
140
-
126
def rewrite_for_decode(req_data: dict, item_meta: dict[int, dict]) -> dict:
127
"""Replace each media item with a metadata-only reference for the decoder.
128
@@ -172,7 +157,15 @@ def rewrite_for_decode(req_data: dict, item_meta: dict[int, dict]) -> dict:
157
# Whatever keys the encoder reported are the metadata its model
158
# declared as needed to size the placeholder range; the proxy does
159
# not need to know their names.
175
- metadata = {k: _b64_tensor(v) for k, v in meta.items()}
160
+ # Downstream stacks per item; keep the existing per-item vector shape.
161
+ metadata = {
162
+ k: [
163
+ x
164
+ for item in v
165
+ for x in (item if isinstance(item, list) else [item])
166
+ ]
167
+ for k, v in meta.items()
168
+ }
169
if not metadata or not item_uuid:
170
# Nothing to size the placeholder range with. A processor cache
171
# hit is not a cause on its own: with the default `lru` type the
tests/entrypoints/unit_tests/test_chat_utils.py
+46
-1
@@ -18,6 +18,8 @@ from vllm.entrypoints.chat_utils import (
18
MEDIA_CONNECTOR_REGISTRY,
19
AsyncMultiModalItemTracker,
20
ConversationMessage,
21
+ _load_embeds_dict,
22
+ _parse_metadata_array,
23
_postprocess_messages,
24
parse_chat_messages,
25
parse_chat_messages_async,
@@ -39,6 +41,45 @@ QWEN25OMNI_MODEL_ID = "Qwen/Qwen2.5-Omni-7B"
41
MISTRAL_MODEL_ID = "mistralai/Mistral-Small-3.1-24B-Instruct-2503"
42
43
44
+@pytest.mark.parametrize("values", [[1, 32, 48], [0.0, 0.12345678912345678]])
45
+def test_json_metadata_preserves_numeric_precision(values):
46
+ tensor = _parse_metadata_array("grid", values, {"grid"})
47
+ assert tensor.tolist() == values
48
+ assert tensor.dtype == (
49
+ torch.float64 if isinstance(values[0], float) else torch.int64
50
+ )
51
+
52
+
53
+@pytest.mark.parametrize(
54
+ "key,values",
55
+ [
56
+ ("image_embeds", [1, 2]),
57
+ ("grid", [True]),
58
+ ("grid", [float("nan")]),
59
+ ("grid", [float("inf")]),
60
+ ("grid", [[1, 2]]),
61
+ ("grid", [2**64]),
62
+ ],
63
+)
64
+def test_json_arrays_are_only_valid_numeric_metadata(key, values):
65
+ with pytest.raises(VLLMValidationError):
66
+ _parse_metadata_array(key, values, {"grid"})
67
+
68
+
69
+@pytest.mark.asyncio
70
+async def test_json_metadata_bypasses_tensor_deserialization():
71
+ from unittest.mock import AsyncMock
72
+
73
+ tensor = torch.ones(2, 3)
74
+ fetch = AsyncMock(return_value=tensor)
75
+ result = await _load_embeds_dict(
76
+ {"image_embeds": "legacy-base64", "image_grid_thw": [1, 2, 2]}, fetch
77
+ )
78
+ fetch.assert_awaited_once_with("legacy-base64")
79
+ assert result["image_embeds"] is tensor
80
+ assert result["image_grid_thw"] == [1, 2, 2]
81
+
82
+
83
@pytest.fixture(scope="function")
84
def kimi_k2_5_model_config():
85
return ModelConfig(
@@ -1355,8 +1396,10 @@ def test_parse_chat_messages_empty_dict_image_embeds(
1396
_assert_mm_uuids(mm_uuids, 1, expected_uuids=[None])
1397
1398
1399
+@pytest.mark.parametrize("json_metadata", [False, True])
1400
def test_parse_chat_messages_multiple_dict_image_embeds(
1401
qwen25omni_model_config_image_embeds,
1402
+ json_metadata,
1403
):
1404
"""Test that multiple dictionaries for image_embeds is handled without errors."""
1405
# Create two sample image embedding tensors
@@ -1374,7 +1417,9 @@ def test_parse_chat_messages_multiple_dict_image_embeds(
1417
"type": "image_embeds",
1418
"image_embeds": {
1419
"image_embeds": tensor2base64(embeds),
1377
- "image_grid_thw": tensor2base64(grid_thw),
1420
+ "image_grid_thw": grid_thw.tolist()
1421
+ if json_metadata
1422
+ else tensor2base64(grid_thw),
1423
},
1424
}
1425
for embeds, grid_thw in zip(
tests/utils_/test_collection_utils.py
+25
-1
@@ -2,7 +2,31 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
import pytest
4
5
-from vllm.utils.collection_utils import common_prefix, swap_dict_values
5
+from vllm.utils.collection_utils import (
6
+ common_prefix,
7
+ is_list_of_numbers,
8
+ swap_dict_values,
9
+)
10
+
11
+
12
+@pytest.mark.parametrize(
13
+ "value,expected",
14
+ [
15
+ ([], True),
16
+ ([1, -2, 2**1024], True),
17
+ ([1, 0.5], True),
18
+ ([1, True], False),
19
+ ([1, float("nan")], False),
20
+ ([1, float("inf")], False),
21
+ ([1, -float("inf")], False),
22
+ ([1, "2"], False),
23
+ ([[1]], False),
24
+ ((1, 2), False),
25
+ (None, False),
26
+ ],
27
+)
28
+def test_is_list_of_numbers_checks_all_finite_non_boolean_items(value, expected):
29
+ assert is_list_of_numbers(value) is expected
30
31
32
@pytest.mark.parametrize(
tests/v1/ec_connector/unit/test_epd_proxy_round_robin.py
+3
@@ -123,6 +123,9 @@ def test_decode_rewrite_preserves_engine_reported_ec_hash(proxy):
123
)
124
125
assert rewritten["messages"][0]["content"][0]["uuid"] == "proxy-uuid"
126
+ assert rewritten["messages"][0]["content"][0]["image_embeds"] == {
127
+ "image_grid_thw": [1, 2, 3]
128
+ }
129
assert rewritten["ec_transfer_params"]["ec_items"] == [
130
{"mm_hash": "engine-derived-hash", "transfer_id": "transfer"}
131
]
vllm/entrypoints/chat_utils.py
+76
-44
@@ -73,7 +73,7 @@ from vllm.renderers.embed_utils import (
73
)
74
from vllm.transformers_utils.processor import get_video_processor_cls_name
75
from vllm.utils import random_uuid
76
-from vllm.utils.collection_utils import is_list_of
76
+from vllm.utils.collection_utils import is_list_of, is_list_of_numbers
77
from vllm.utils.import_utils import LazyLoader
78
79
if TYPE_CHECKING:
@@ -146,12 +146,15 @@ class ChatCompletionContentPartAudioParam(TypedDict, total=False):
146
"""The type of the content part."""
147
148
149
+MultiModalEmbedsPayload: TypeAlias = str | dict[str, str | list[int | float]]
150
+
151
+
152
class ChatCompletionContentPartImageEmbedsParam(TypedDict, total=False):
150
- image_embeds: str | dict[str, str] | None
153
+ image_embeds: MultiModalEmbedsPayload | None
154
"""
155
The image embeddings. It can be either:
156
- A single base64 string.
154
- - A dictionary where each value is a base64 string.
157
+ - A dictionary of base64 tensors or numeric JSON metadata arrays.
158
"""
159
type: Required[Literal["image_embeds"]]
160
"""The type of the content part."""
@@ -163,11 +166,11 @@ class ChatCompletionContentPartImageEmbedsParam(TypedDict, total=False):
166
167
168
class ChatCompletionContentPartAudioEmbedsParam(TypedDict, total=False):
166
- audio_embeds: str | dict[str, str] | None
169
+ audio_embeds: MultiModalEmbedsPayload | None
170
"""
171
The audio embeddings. It can be either:
172
- A single base64 string representing a serialized torch tensor.
170
- - A dictionary where each value is a base64 string.
173
+ - A dictionary of base64 tensors or numeric JSON metadata arrays.
174
"""
175
type: Required[Literal["audio_embeds"]]
176
"""The type of the content part."""
@@ -179,11 +182,11 @@ class ChatCompletionContentPartAudioEmbedsParam(TypedDict, total=False):
182
183
184
class ChatCompletionContentPartVideoEmbedsParam(TypedDict, total=False):
182
- video_embeds: str | dict[str, str] | None
185
+ video_embeds: MultiModalEmbedsPayload | None
186
"""
187
The video embeddings. It can be either:
188
- A single base64 string representing a serialized torch tensor.
186
- - A dictionary where each value is a base64 string.
189
+ - A dictionary of base64 tensors or numeric JSON metadata arrays.
190
"""
191
type: Required[Literal["video_embeds"]]
192
"""The type of the content part."""
@@ -527,6 +530,27 @@ def _merge_embeds(
530
return data_merged
531
532
533
+async def _load_embeds_dict(
534
+ data: dict[str, str | list[int | float]],
535
+ fetch: Callable[[str], Awaitable["torch.Tensor"]],
536
+) -> dict[str, Any]:
537
+ encoded = {key: value for key, value in data.items() if isinstance(value, str)}
538
+ tensors = await asyncio.gather(*(fetch(value) for value in encoded.values()))
539
+ return {**data, **dict(zip(encoded, tensors))}
540
+
541
+
542
+def _parse_metadata_array(key: str, value: list, metadata_fields: set[str]):
543
+ if key not in metadata_fields:
544
+ raise VLLMValidationError(f"JSON arrays are only supported for metadata: {key}")
545
+ if not is_list_of_numbers(value):
546
+ raise VLLMValidationError(f"Metadata {key} must be a finite numeric array.")
547
+ dtype = torch.float64 if any(isinstance(v, float) for v in value) else torch.int64
548
+ try:
549
+ return torch.tensor(value, dtype=dtype)
550
+ except (ValueError, TypeError, OverflowError, RuntimeError) as error:
551
+ raise VLLMValidationError(f"Invalid metadata array: {key}") from error
552
+
553
+
554
def _get_embeds_data(
555
modality: str,
556
data_items: list[Any],
@@ -544,6 +568,18 @@ def _get_embeds_data(
568
return _merge_embeds(dict_items, mm_processor)[embeds_key]
569
570
if is_list_of(data_items, dict):
571
+ metadata_fields = mm_processor.info.data_parser.placeholder_metadata_fields(
572
+ modality
573
+ )
574
+ data_items = [
575
+ {
576
+ key: _parse_metadata_array(key, value, metadata_fields)
577
+ if isinstance(value, list)
578
+ else value
579
+ for key, value in item.items()
580
+ }
581
+ for item in data_items
582
+ ]
583
return _merge_embeds(data_items, mm_processor)
584
585
raise NotImplementedError(type(data_items))
@@ -946,7 +982,7 @@ class BaseMultiModalContentParser(ABC):
982
@abstractmethod
983
def parse_image_embeds(
984
self,
949
- image_embeds: str | dict[str, str] | None,
985
+ image_embeds: MultiModalEmbedsPayload | None,
986
uuid: str | None = None,
987
) -> None:
988
raise NotImplementedError
@@ -970,7 +1006,7 @@ class BaseMultiModalContentParser(ABC):
1006
@abstractmethod
1007
def parse_audio_embeds(
1008
self,
973
- audio_embeds: str | dict[str, str] | None,
1009
+ audio_embeds: MultiModalEmbedsPayload | None,
1010
uuid: str | None = None,
1011
) -> None:
1012
raise NotImplementedError
@@ -986,7 +1022,7 @@ class BaseMultiModalContentParser(ABC):
1022
@abstractmethod
1023
def parse_video_embeds(
1024
self,
989
- video_embeds: str | dict[str, str] | None,
1025
+ video_embeds: MultiModalEmbedsPayload | None,
1026
uuid: str | None = None,
1027
) -> None:
1028
raise NotImplementedError
@@ -1043,7 +1079,7 @@ class MultiModalContentParser(BaseMultiModalContentParser):
1079
1080
def parse_image_embeds(
1081
self,
1046
- image_embeds: str | dict[str, str] | None,
1082
+ image_embeds: MultiModalEmbedsPayload | None,
1083
uuid: str | None = None,
1084
) -> None:
1085
mm_config = self.model_config.get_multimodal_config()
@@ -1055,7 +1091,7 @@ class MultiModalContentParser(BaseMultiModalContentParser):
1091
1092
if isinstance(image_embeds, dict):
1093
embeds = {
1058
- k: self._connector.fetch_image_embedding(v)
1094
+ k: self._connector.fetch_image_embedding(v) if isinstance(v, str) else v
1095
for k, v in image_embeds.items()
1096
}
1097
placeholder = self._tracker.add("image_embeds", (embeds, uuid))
@@ -1071,7 +1107,7 @@ class MultiModalContentParser(BaseMultiModalContentParser):
1107
1108
def parse_audio_embeds(
1109
self,
1074
- audio_embeds: str | dict[str, str] | None,
1110
+ audio_embeds: MultiModalEmbedsPayload | None,
1111
uuid: str | None = None,
1112
) -> None:
1113
mm_config = self.model_config.get_multimodal_config()
@@ -1083,7 +1119,7 @@ class MultiModalContentParser(BaseMultiModalContentParser):
1119
1120
if isinstance(audio_embeds, dict):
1121
embeds = {
1086
- k: self._connector.fetch_audio_embedding(v)
1122
+ k: self._connector.fetch_audio_embedding(v) if isinstance(v, str) else v
1123
for k, v in audio_embeds.items()
1124
}
1125
placeholder = self._tracker.add("audio_embeds", (embeds, uuid))
@@ -1148,7 +1184,7 @@ class MultiModalContentParser(BaseMultiModalContentParser):
1184
1185
def parse_video_embeds(
1186
self,
1151
- video_embeds: str | dict[str, str] | None,
1187
+ video_embeds: MultiModalEmbedsPayload | None,
1188
uuid: str | None = None,
1189
) -> None:
1190
mm_config = self.model_config.get_multimodal_config()
@@ -1160,7 +1196,7 @@ class MultiModalContentParser(BaseMultiModalContentParser):
1196
1197
if isinstance(video_embeds, dict):
1198
embeds = {
1163
- k: self._connector.fetch_video_embedding(v)
1199
+ k: self._connector.fetch_video_embedding(v) if isinstance(v, str) else v
1200
for k, v in video_embeds.items()
1201
}
1202
placeholder = self._tracker.add("video_embeds", (embeds, uuid))
@@ -1242,7 +1278,7 @@ class AsyncMultiModalContentParser(BaseMultiModalContentParser):
1278
1279
def parse_image_embeds(
1280
self,
1245
- image_embeds: str | dict[str, str] | None,
1281
+ image_embeds: MultiModalEmbedsPayload | None,
1282
uuid: str | None = None,
1283
) -> None:
1284
mm_config = self.model_config.get_multimodal_config()
@@ -1260,17 +1296,13 @@ class AsyncMultiModalContentParser(BaseMultiModalContentParser):
1296
1297
async def _image_embeds_with_uuid_async(
1298
self,
1263
- image_embeds: str | dict[str, str] | None,
1299
+ image_embeds: MultiModalEmbedsPayload | None,
1300
uuid: str | None,
1301
):
1302
if isinstance(image_embeds, dict):
1267
- tensors = await asyncio.gather(
1268
- *(
1269
- self._connector.fetch_image_embedding_async(v)
1270
- for v in image_embeds.values()
1271
- )
1303
+ embeds = await _load_embeds_dict(
1304
+ image_embeds, self._connector.fetch_image_embedding_async
1305
)
1273
- embeds = dict(zip(image_embeds, tensors))
1306
elif isinstance(image_embeds, str):
1307
embeds = await self._connector.fetch_image_embedding_async(image_embeds)
1308
else:
@@ -1279,7 +1311,7 @@ class AsyncMultiModalContentParser(BaseMultiModalContentParser):
1311
1312
def parse_audio_embeds(
1313
self,
1282
- audio_embeds: str | dict[str, str] | None,
1314
+ audio_embeds: MultiModalEmbedsPayload | None,
1315
uuid: str | None = None,
1316
) -> None:
1317
mm_config = self.model_config.get_multimodal_config()
@@ -1297,17 +1329,13 @@ class AsyncMultiModalContentParser(BaseMultiModalContentParser):
1329
1330
async def _audio_embeds_with_uuid_async(
1331
self,
1300
- audio_embeds: str | dict[str, str] | None,
1332
+ audio_embeds: MultiModalEmbedsPayload | None,
1333
uuid: str | None,
1334
):
1335
if isinstance(audio_embeds, dict):
1304
- tensors = await asyncio.gather(
1305
- *(
1306
- self._connector.fetch_audio_embedding_async(v)
1307
- for v in audio_embeds.values()
1308
- )
1336
+ embeds = await _load_embeds_dict(
1337
+ audio_embeds, self._connector.fetch_audio_embedding_async
1338
)
1310
- embeds = dict(zip(audio_embeds, tensors))
1339
elif isinstance(audio_embeds, str):
1340
embeds = await self._connector.fetch_audio_embedding_async(audio_embeds)
1341
else:
@@ -1382,7 +1410,7 @@ class AsyncMultiModalContentParser(BaseMultiModalContentParser):
1410
1411
def parse_video_embeds(
1412
self,
1385
- video_embeds: str | dict[str, str] | None,
1413
+ video_embeds: MultiModalEmbedsPayload | None,
1414
uuid: str | None = None,
1415
) -> None:
1416
mm_config = self.model_config.get_multimodal_config()
@@ -1400,17 +1428,13 @@ class AsyncMultiModalContentParser(BaseMultiModalContentParser):
1428
1429
async def _video_embeds_with_uuid_async(
1430
self,
1403
- video_embeds: str | dict[str, str] | None,
1431
+ video_embeds: MultiModalEmbedsPayload | None,
1432
uuid: str | None,
1433
):
1434
if isinstance(video_embeds, dict):
1407
- tensors = await asyncio.gather(
1408
- *(
1409
- self._connector.fetch_video_embedding_async(v)
1410
- for v in video_embeds.values()
1411
- )
1435
+ embeds = await _load_embeds_dict(
1436
+ video_embeds, self._connector.fetch_video_embedding_async
1437
)
1413
- embeds = dict(zip(video_embeds, tensors))
1438
elif isinstance(video_embeds, str):
1439
embeds = await self._connector.fetch_video_embedding_async(video_embeds)
1440
else:
@@ -1611,7 +1635,9 @@ _AudioParser = TypeAdapter(ChatCompletionContentPartAudioParam).validate_python
1635
_VideoParser = TypeAdapter(ChatCompletionContentPartVideoParam).validate_python
1636
1637
_ResponsesInputImageParser = TypeAdapter(ResponseInputImageParam).validate_python
1614
-_ContentPart: TypeAlias = str | dict[str, str] | InputAudio | PILImage
1638
+_ContentPart: TypeAlias = (
1639
+ MultiModalEmbedsPayload | dict[str, str] | InputAudio | PILImage
1640
+)
1641
1642
# Define a mapping from part types to their corresponding parsing functions.
1643
MM_PARSER_MAP: dict[
@@ -1901,15 +1927,21 @@ def _parse_chat_message_content_part(
1927
mm_parser.parse_image(str_content, uuid)
1928
modality = "image"
1929
elif part_type == "image_embeds":
1904
- content = cast(str | dict[str, str], content) if content is not None else None
1930
+ content = (
1931
+ cast(MultiModalEmbedsPayload, content) if content is not None else None
1932
+ )
1933
mm_parser.parse_image_embeds(content, uuid)
1934
modality = "image"
1935
elif part_type == "audio_embeds":
1908
- content = cast(str | dict[str, str], content) if content is not None else None
1936
+ content = (
1937
+ cast(MultiModalEmbedsPayload, content) if content is not None else None
1938
+ )
1939
mm_parser.parse_audio_embeds(content, uuid)
1940
modality = "audio"
1941
elif part_type == "video_embeds":
1912
- content = cast(str | dict[str, str], content) if content is not None else None
1942
+ content = (
1943
+ cast(MultiModalEmbedsPayload, content) if content is not None else None
1944
+ )
1945
mm_parser.parse_video_embeds(content, uuid)
1946
modality = "video"
1947
elif part_type == "prompt_embeds":
vllm/utils/collection_utils.py
+8
@@ -6,6 +6,7 @@ Contains helpers that are applied to collections.
6
This is similar in concept to the `collections` module.
7
"""
8
9
+import math
10
from collections import defaultdict
11
from collections.abc import Callable, Generator, Hashable, Iterable, Mapping, Sequence
12
from typing import Generic, Literal, TypeVar
@@ -68,6 +69,13 @@ def is_list_of(
69
assert_never(check)
70
71
72
+def is_list_of_numbers(value: object) -> bool:
73
+ """Check every item is an int or finite float, excluding booleans."""
74
+ return isinstance(value, list) and all(
75
+ type(v) is int or (type(v) is float and math.isfinite(v)) for v in value
76
+ )
77
+
78
+
79
@overload
80
def common_prefix(items: Sequence[str]) -> str: ...
81