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 11, 00:00 UTC
[Misc] Introduce `encode_*_url` utility function (#31208)
Signed-off-by: DarkLight1337 <tlleungac@connect.ust.hk>
Cyrus Leung committed
Dec 23, 2025 at 21:45 UTC
bb62dda2c32ab2be79bea0d0e1a96b4926b6c63f
14 files changed
+134
-96
tests/entrypoints/openai/test_audio.py
+11
-5
@@ -8,7 +8,7 @@ import pytest
8
import pytest_asyncio
9
10
from vllm.assets.audio import AudioAsset
11
-from vllm.multimodal.utils import encode_audio_base64, fetch_audio
11
+from vllm.multimodal.utils import encode_audio_base64, encode_audio_url, fetch_audio
12
13
from ...utils import RemoteOpenAIServer
14
@@ -53,6 +53,14 @@ def base64_encoded_audio() -> dict[str, str]:
53
}
54
55
56
+@pytest.fixture(scope="session")
57
+def url_encoded_audio() -> dict[str, str]:
58
+ return {
59
+ audio_url: encode_audio_url(*fetch_audio(audio_url))
60
+ for audio_url in TEST_AUDIO_URLS
61
+ }
62
+
63
+
64
def dummy_messages_from_audio_url(
65
audio_urls: str | list[str],
66
content_text: str = "What's happening in this audio?",
@@ -149,11 +157,9 @@ async def test_single_chat_session_audio_base64encoded(
157
client: openai.AsyncOpenAI,
158
model_name: str,
159
audio_url: str,
152
- base64_encoded_audio: dict[str, str],
160
+ url_encoded_audio: dict[str, str],
161
):
154
- messages = dummy_messages_from_audio_url(
155
- f"data:audio/wav;base64,{base64_encoded_audio[audio_url]}"
156
- )
162
+ messages = dummy_messages_from_audio_url(url_encoded_audio[audio_url])
163
164
# test single completion
165
chat_completion = await client.chat.completions.create(
tests/entrypoints/openai/test_video.py
+7
-11
@@ -7,7 +7,7 @@ import openai
7
import pytest
8
import pytest_asyncio
9
10
-from vllm.multimodal.utils import encode_video_base64, fetch_video
10
+from vllm.multimodal.utils import encode_video_url, fetch_video
11
12
from ...utils import RemoteOpenAIServer
13
@@ -48,9 +48,9 @@ async def client(server):
48
49
50
@pytest.fixture(scope="session")
51
-def base64_encoded_video() -> dict[str, str]:
51
+def url_encoded_video() -> dict[str, str]:
52
return {
53
- video_url: encode_video_base64(fetch_video(video_url)[0])
53
+ video_url: encode_video_url(fetch_video(video_url)[0])
54
for video_url in TEST_VIDEO_URLS
55
}
56
@@ -175,11 +175,9 @@ async def test_single_chat_session_video_base64encoded(
175
client: openai.AsyncOpenAI,
176
model_name: str,
177
video_url: str,
178
- base64_encoded_video: dict[str, str],
178
+ url_encoded_video: dict[str, str],
179
):
180
- messages = dummy_messages_from_video_url(
181
- f"data:video/jpeg;base64,{base64_encoded_video[video_url]}"
182
- )
180
+ messages = dummy_messages_from_video_url(url_encoded_video[video_url])
181
182
# test single completion
183
chat_completion = await client.chat.completions.create(
@@ -223,11 +221,9 @@ async def test_single_chat_session_video_base64encoded_beamsearch(
221
client: openai.AsyncOpenAI,
222
model_name: str,
223
video_url: str,
226
- base64_encoded_video: dict[str, str],
224
+ url_encoded_video: dict[str, str],
225
):
228
- messages = dummy_messages_from_video_url(
229
- f"data:video/jpeg;base64,{base64_encoded_video[video_url]}"
230
- )
226
+ messages = dummy_messages_from_video_url(url_encoded_video[video_url])
227
228
chat_completion = await client.chat.completions.create(
229
model=model_name,
tests/entrypoints/openai/test_vision.py
+8
-12
@@ -9,7 +9,7 @@ import pytest_asyncio
9
from transformers import AutoProcessor
10
11
from vllm.multimodal.base import MediaWithBytes
12
-from vllm.multimodal.utils import encode_image_base64, fetch_image
12
+from vllm.multimodal.utils import encode_image_url, fetch_image
13
14
from ...utils import RemoteOpenAIServer
15
@@ -35,7 +35,7 @@ EXPECTED_MM_BEAM_SEARCH_RES = [
35
],
36
[
37
"The image shows a Venn diagram with three over",
38
- "The image shows a colorful Venn diagram with",
38
+ "The image displays a Venn diagram with three over",
39
],
40
[
41
"This image displays a gradient of colors ranging from",
@@ -70,11 +70,9 @@ async def client(server):
70
71
72
@pytest.fixture(scope="session")
73
-def base64_encoded_image(local_asset_server) -> dict[str, str]:
73
+def url_encoded_image(local_asset_server) -> dict[str, str]:
74
return {
75
- image_asset: encode_image_base64(
76
- local_asset_server.get_image_asset(image_asset)
77
- )
75
+ image_asset: encode_image_url(local_asset_server.get_image_asset(image_asset))
76
for image_asset in TEST_IMAGE_ASSETS
77
}
78
@@ -234,11 +232,11 @@ async def test_single_chat_session_image_base64encoded(
232
model_name: str,
233
raw_image_url: str,
234
image_url: str,
237
- base64_encoded_image: dict[str, str],
235
+ url_encoded_image: dict[str, str],
236
):
237
content_text = "What's in this image?"
238
messages = dummy_messages_from_image_url(
241
- f"data:image/jpeg;base64,{base64_encoded_image[raw_image_url]}",
239
+ url_encoded_image[raw_image_url],
240
content_text,
241
)
242
@@ -288,15 +286,13 @@ async def test_single_chat_session_image_base64encoded_beamsearch(
286
client: openai.AsyncOpenAI,
287
model_name: str,
288
image_idx: int,
291
- base64_encoded_image: dict[str, str],
289
+ url_encoded_image: dict[str, str],
290
):
291
# NOTE: This test also validates that we pass MM data through beam search
292
raw_image_url = TEST_IMAGE_ASSETS[image_idx]
293
expected_res = EXPECTED_MM_BEAM_SEARCH_RES[image_idx]
294
297
- messages = dummy_messages_from_image_url(
298
- f"data:image/jpeg;base64,{base64_encoded_image[raw_image_url]}"
299
- )
295
+ messages = dummy_messages_from_image_url(url_encoded_image[raw_image_url])
296
297
chat_completion = await client.chat.completions.create(
298
model=model_name,
tests/entrypoints/pooling/embed/test_online_vision.py
+1
-9
@@ -10,7 +10,7 @@ from transformers import AutoProcessor
10
from tests.utils import VLLM_PATH, RemoteOpenAIServer
11
from vllm.entrypoints.pooling.embed.protocol import EmbeddingResponse
12
from vllm.multimodal.base import MediaWithBytes
13
-from vllm.multimodal.utils import encode_image_base64, fetch_image
13
+from vllm.multimodal.utils import fetch_image
14
15
MODEL_NAME = "TIGER-Lab/VLM2Vec-Full"
16
MAXIMUM_IMAGES = 2
@@ -48,14 +48,6 @@ def server():
48
yield remote_server
49
50
51
-@pytest.fixture(scope="session")
52
-def base64_encoded_image(local_asset_server) -> dict[str, str]:
53
- return {
54
- image_url: encode_image_base64(local_asset_server.get_image_asset(image_url))
55
- for image_url in TEST_IMAGE_ASSETS
56
- }
57
-
58
-
51
def get_hf_prompt_tokens(model_name, content, image_url):
52
processor = AutoProcessor.from_pretrained(
53
model_name, trust_remote_code=True, num_crops=4
tests/entrypoints/test_chat_utils.py
+6
-9
@@ -25,9 +25,9 @@ from vllm.entrypoints.chat_utils import (
25
)
26
from vllm.multimodal import MultiModalDataDict, MultiModalUUIDDict
27
from vllm.multimodal.utils import (
28
- encode_audio_base64,
29
- encode_image_base64,
30
- encode_video_base64,
28
+ encode_audio_url,
29
+ encode_image_url,
30
+ encode_video_url,
31
)
32
from vllm.tokenizers import get_tokenizer
33
from vllm.tokenizers.mistral import MistralTokenizer
@@ -141,22 +141,19 @@ def mistral_model_config():
141
@pytest.fixture(scope="module")
142
def image_url():
143
image = ImageAsset("cherry_blossom")
144
- base64 = encode_image_base64(image.pil_image)
145
- return f"data:image/jpeg;base64,{base64}"
144
+ return encode_image_url(image.pil_image)
145
146
147
@pytest.fixture(scope="module")
148
def video_url():
149
video = VideoAsset("baby_reading", 1)
151
- base64 = encode_video_base64(video.np_ndarrays)
152
- return f"data:video/jpeg;base64,{base64}"
150
+ return encode_video_url(video.np_ndarrays)
151
152
153
@pytest.fixture(scope="module")
154
def audio_url():
155
audio = AudioAsset("mary_had_lamb")
158
- base64 = encode_audio_base64(*audio.audio_and_sample_rate)
159
- return f"data:audio/ogg;base64,{base64}"
156
+ return encode_audio_url(*audio.audio_and_sample_rate)
157
158
159
def _assert_mm_data_is_image_input(
tests/models/multimodal/generation/test_keye.py
+2
-5
@@ -8,7 +8,7 @@ from PIL.Image import Image
8
from transformers import AutoProcessor
9
10
from vllm import LLM, EngineArgs, SamplingParams
11
-from vllm.multimodal.utils import encode_image_base64
11
+from vllm.multimodal.utils import encode_image_url
12
13
MODEL_NAME = "Kwai-Keye/Keye-VL-8B-Preview"
14
@@ -31,10 +31,7 @@ def test_keye_vl(
31
question: str,
32
):
33
images = [asset.pil_image for asset in image_assets]
34
-
35
- image_urls = [
36
- f"data:image/jpeg;base64,{encode_image_base64(image)}" for image in images
37
- ]
34
+ image_urls = [encode_image_url(image) for image in images]
35
36
engine_args = EngineArgs(
37
model=MODEL_NAME,
tests/models/multimodal/generation/test_vit_backend_functionality.py
+4
-9
@@ -15,7 +15,7 @@ from transformers import AutoProcessor
15
16
from vllm import LLM, EngineArgs, SamplingParams
17
from vllm.attention.backends.registry import AttentionBackendEnum
18
-from vllm.multimodal.utils import encode_image_base64
18
+from vllm.multimodal.utils import encode_image_url
19
from vllm.multimodal.video import sample_frames_from_video
20
from vllm.platforms import current_platform
21
@@ -178,8 +178,7 @@ def build_dots_ocr_prompt(images, config):
178
"""Build Dots.OCR specific prompt with OCR instructions."""
179
# Use only stop_sign image for Dots.OCR
180
image = images[0] # Already filtered to stop_sign
181
-
182
- image_url = f"data:image/jpeg;base64,{encode_image_base64(image)}"
181
+ image_url = encode_image_url(image)
182
183
placeholders = [{"type": "image_url", "image_url": {"url": image_url}}]
184
messages = [
@@ -204,9 +203,7 @@ def build_processor_prompt(images, config):
203
config["model_name"], trust_remote_code=True
204
)
205
207
- image_urls = [
208
- f"data:image/jpeg;base64,{encode_image_base64(img)}" for img in images
209
- ]
206
+ image_urls = [encode_image_url(img) for img in images]
207
placeholders = [{"type": "image", "image": url} for url in image_urls]
208
messages = [
209
{
@@ -225,9 +222,7 @@ def build_processor_prompt(images, config):
222
223
def build_ovis_prompt(images, config):
224
"""Build Ovis2.5 specific prompt with custom format."""
228
- image_urls = [
229
- f"data:image/jpeg;base64,{encode_image_base64(img)}" for img in images
230
- ]
225
+ image_urls = [encode_image_url(img) for img in images]
226
227
placeholders = "\n".join(
228
f"Image-{i}: <image>\n" for i, _ in enumerate(image_urls, start=1)
tests/v1/ec_connector/integration/test_epd_correctness.py
+3
-7
@@ -31,7 +31,7 @@ import openai
31
import requests
32
33
from vllm.assets.image import ImageAsset
34
-from vllm.multimodal.utils import encode_image_base64
34
+from vllm.multimodal.utils import encode_image_url
35
36
MAX_OUTPUT_LEN = 256
37
@@ -49,9 +49,7 @@ SAMPLE_PROMPTS_MM: list[dict] = [
49
"content": [
50
{
51
"type": "image_url",
52
- "image_url": {
53
- "url": f"data:image;base64,{encode_image_base64(image_1)}"
54
- },
52
+ "image_url": {"url": encode_image_url(image_1)},
53
},
54
{"type": "text", "text": "What's in this image?"},
55
],
@@ -66,9 +64,7 @@ SAMPLE_PROMPTS_MM: list[dict] = [
64
"content": [
65
{
66
"type": "image_url",
69
- "image_url": {
70
- "url": f"data:image;base64,{encode_image_base64(image_2)}"
71
- },
67
+ "image_url": {"url": encode_image_url(image_2)},
68
},
69
{
70
"type": "image_url",
tests/v1/entrypoints/openai/serving_responses/test_image.py
+5
-5
@@ -8,7 +8,7 @@ import pytest
8
import pytest_asyncio
9
10
from tests.utils import RemoteOpenAIServer
11
-from vllm.multimodal.utils import encode_image_base64
11
+from vllm.multimodal.utils import encode_image_url
12
13
# Use a small vision model for testing
14
MODEL_NAME = "Qwen/Qwen2.5-VL-3B-Instruct"
@@ -52,9 +52,9 @@ async def client(image_server):
52
53
54
@pytest.fixture(scope="session")
55
-def base64_encoded_image(local_asset_server) -> dict[str, str]:
55
+def url_encoded_image(local_asset_server) -> dict[str, str]:
56
return {
57
- image_url: encode_image_base64(local_asset_server.get_image_asset(image_url))
57
+ image_url: encode_image_url(local_asset_server.get_image_asset(image_url))
58
for image_url in TEST_IMAGE_ASSETS
59
}
60
@@ -95,7 +95,7 @@ async def test_single_chat_session_image_base64encoded(
95
client: openai.AsyncOpenAI,
96
model_name: str,
97
raw_image_url: str,
98
- base64_encoded_image: dict[str, str],
98
+ url_encoded_image: dict[str, str],
99
):
100
content_text = "What's in this image?"
101
messages = [
@@ -104,7 +104,7 @@ async def test_single_chat_session_image_base64encoded(
104
"content": [
105
{
106
"type": "input_image",
107
- "image_url": f"data:image/jpeg;base64,{base64_encoded_image[raw_image_url]}", # noqa: E501
107
+ "image_url": url_encoded_image[raw_image_url],
108
"detail": "auto",
109
},
110
{"type": "input_text", "text": content_text},
tests/v1/kv_connector/unit/test_example_connector.py
+2
-2
@@ -9,7 +9,7 @@ from PIL import Image
9
from vllm import LLM, EngineArgs, SamplingParams
10
from vllm.assets.image import ImageAsset
11
from vllm.config import KVTransferConfig
12
-from vllm.multimodal.utils import encode_image_base64
12
+from vllm.multimodal.utils import encode_image_url
13
from vllm.platforms import current_platform
14
15
MODEL_NAME = "RedHatAI/Qwen2.5-VL-3B-Instruct-quantized.w8a8"
@@ -74,7 +74,7 @@ def process_prompt(processor, llm: LLM, question: str, image_urls: list[Image]):
74
placeholders = [
75
{
76
"type": "image_url",
77
- "image_url": {"url": f"data:image;base64,{encode_image_base64(image_pil)}"},
77
+ "image_url": {"url": encode_image_url(image_pil)},
78
}
79
for image_pil in image_urls
80
]
tests/v1/tpu/test_multimodal.py
+10
-15
@@ -4,7 +4,7 @@
4
import openai
5
import pytest
6
7
-from vllm.multimodal.utils import encode_image_base64
7
+from vllm.multimodal.utils import encode_image_url
8
from vllm.platforms import current_platform
9
10
from ...entrypoints.openai.test_vision import TEST_IMAGE_ASSETS
@@ -12,11 +12,9 @@ from ...utils import RemoteOpenAIServer
12
13
14
@pytest.fixture(scope="session")
15
-def base64_encoded_image(local_asset_server) -> dict[str, str]:
15
+def url_encoded_image(local_asset_server) -> dict[str, str]:
16
return {
17
- image_asset: encode_image_base64(
18
- local_asset_server.get_image_asset(image_asset)
19
- )
17
+ image_asset: encode_image_url(local_asset_server.get_image_asset(image_asset))
18
for image_asset in TEST_IMAGE_ASSETS
19
}
20
@@ -24,19 +22,16 @@ def base64_encoded_image(local_asset_server) -> dict[str, str]:
22
@pytest.mark.asyncio
23
@pytest.mark.skipif(not current_platform.is_tpu(), reason="This test needs a TPU")
24
@pytest.mark.parametrize("model_name", ["llava-hf/llava-1.5-7b-hf"])
27
-async def test_basic_vision(model_name: str, base64_encoded_image: dict[str, str]):
25
+async def test_basic_vision(model_name: str, url_encoded_image: dict[str, str]):
26
pytest.skip("Skip this test until it's fixed.")
27
30
- def whats_in_this_image_msg(b64):
28
+ def whats_in_this_image_msg(url):
29
return [
30
{
31
"role": "user",
32
"content": [
33
{"type": "text", "text": "What's in this image?"},
36
- {
37
- "type": "image_url",
38
- "image_url": {"url": f"data:image/jpeg;base64,{b64}"},
39
- },
34
+ {"type": "image_url", "image_url": {"url": url}},
35
],
36
}
37
]
@@ -63,14 +58,14 @@ async def test_basic_vision(model_name: str, base64_encoded_image: dict[str, str
58
59
# Other requests now should be much faster
60
for image_url in TEST_IMAGE_ASSETS:
66
- image_base64 = base64_encoded_image[image_url]
67
- chat_completion_from_base64 = await client.chat.completions.create(
61
+ image_url = url_encoded_image[image_url]
62
+ chat_completion_from_url = await client.chat.completions.create(
63
model=model_name,
69
- messages=whats_in_this_image_msg(image_base64),
64
+ messages=whats_in_this_image_msg(image_url),
65
max_completion_tokens=24,
66
temperature=0.0,
67
)
73
- result = chat_completion_from_base64
68
+ result = chat_completion_from_url
69
assert result
70
choice = result.choices[0]
71
assert choice.finish_reason == "length"
vllm/multimodal/audio.py
+7
-2
@@ -111,11 +111,16 @@ class AudioMediaIO(MediaIO[tuple[npt.NDArray, float]]):
111
def load_file(self, filepath: Path) -> tuple[npt.NDArray, float]:
112
return librosa.load(filepath, sr=None)
113
114
- def encode_base64(self, media: tuple[npt.NDArray, int]) -> str:
114
+ def encode_base64(
115
+ self,
116
+ media: tuple[npt.NDArray, int],
117
+ *,
118
+ audio_format: str = "WAV",
119
+ ) -> str:
120
audio, sr = media
121
122
with BytesIO() as buffer:
118
- soundfile.write(buffer, audio, sr, format="WAV")
123
+ soundfile.write(buffer, audio, sr, format=audio_format)
124
data = buffer.getvalue()
125
126
return base64.b64encode(data).decode("utf-8")
vllm/multimodal/image.py
+14
-1
@@ -8,8 +8,12 @@ import pybase64
8
import torch
9
from PIL import Image
10
11
+from vllm.logger import init_logger
12
+
13
from .base import MediaIO, MediaWithBytes
14
15
+logger = init_logger(__file__)
16
+
17
18
def rescale_image_size(
19
image: Image.Image, size_factor: float, transpose: int = -1
@@ -104,8 +108,17 @@ class ImageMediaIO(MediaIO[Image.Image]):
108
self,
109
media: Image.Image,
110
*,
107
- image_format: str = "JPEG",
111
+ image_format: str | None = None,
112
) -> str:
113
+ if image_format is None:
114
+ logger.warning_once(
115
+ "The default format of `ImageMediaIO.encode_base64` will be changed "
116
+ 'from "JPEG" to "PNG" in v0.15 to avoid lossy compression. '
117
+ "To continue using the old default, "
118
+ 'pass `format="JPEG"` explicitly to silence this warning.'
119
+ )
120
+ image_format = "JPEG"
121
+
122
image = media
123
124
with BytesIO() as buffer:
vllm/multimodal/utils.py
+54
-4
@@ -3,6 +3,7 @@
3
4
import asyncio
5
import atexit
6
+import mimetypes
7
from collections.abc import Generator, Set
8
from concurrent.futures import ThreadPoolExecutor
9
from itertools import groupby
@@ -357,17 +358,31 @@ class MediaConnector:
358
def encode_audio_base64(
359
audio: np.ndarray,
360
sampling_rate: int,
361
+ *,
362
+ format: str = "WAV",
363
) -> str:
364
"""Encode audio as base64."""
365
audio_io = AudioMediaIO()
363
- return audio_io.encode_base64((audio, sampling_rate))
366
+ return audio_io.encode_base64((audio, sampling_rate), audio_format=format)
367
+
368
+
369
+def encode_audio_url(
370
+ audio: np.ndarray,
371
+ sampling_rate: int,
372
+ *,
373
+ format: str = "WAV",
374
+) -> str:
375
+ """Encode audio as a data URL."""
376
+ audio_b64 = encode_audio_base64(audio, sampling_rate, format=format)
377
+ mimetype = mimetypes.types_map.get("." + format.lower(), "audio")
378
+ return f"data:{mimetype};base64,{audio_b64}"
379
380
381
def encode_image_base64(
382
image: Image.Image,
383
*,
384
image_mode: str = "RGB",
370
- format: str = "JPEG",
385
+ format: str | None = None,
386
) -> str:
387
"""
388
Encode a pillow image to base64 format.
@@ -378,10 +393,45 @@ def encode_image_base64(
393
return image_io.encode_base64(image, image_format=format)
394
395
381
-def encode_video_base64(frames: npt.NDArray) -> str:
396
+def encode_image_url(
397
+ image: Image.Image,
398
+ *,
399
+ image_mode: str = "RGB",
400
+ format: str = "PNG",
401
+) -> str:
402
+ """
403
+ Encode a pillow image as a data URL.
404
+
405
+ By default, the image is converted into RGB format before being encoded.
406
+ """
407
+ image_b64 = encode_image_base64(image, image_mode=image_mode, format=format)
408
+ mimetype = mimetypes.types_map.get("." + format.lower(), "image")
409
+ return f"data:{mimetype};base64,{image_b64}"
410
+
411
+
412
+def encode_video_base64(
413
+ frames: npt.NDArray,
414
+ *,
415
+ format: str = "JPEG",
416
+) -> str:
417
image_io = ImageMediaIO()
418
video_io = VideoMediaIO(image_io)
384
- return video_io.encode_base64(frames)
419
+ return video_io.encode_base64(frames, video_format=format)
420
+
421
+
422
+def encode_video_url(
423
+ frames: npt.NDArray,
424
+ *,
425
+ format: str = "JPEG",
426
+) -> str:
427
+ video_b64 = encode_video_base64(frames, format=format)
428
+
429
+ if format.lower() == "jpeg":
430
+ mimetype = "video/jpeg"
431
+ else:
432
+ mimetype = mimetypes.types_map.get("." + format.lower(), "video")
433
+
434
+ return f"data:{mimetype};base64,{video_b64}"
435
436
437
def argsort_mm_positions(