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 14, 05:30 UTC
Extend renderer with embedding support and integrate completion endpoint (#24405)
Signed-off-by: sfeng33 <4florafeng@gmail.com>
Flora Feng committed
Sep 9, 2025 at 10:46 UTC
15cb047e25e58e79273567d3448563cf43bb39e7
9 files changed
+411
-310
tests/entrypoints/openai/test_prompt_validation.py
+9
-5
@@ -10,7 +10,7 @@ import pytest
10
import regex as re
11
import torch
12
13
-from vllm.entrypoints.openai.serving_engine import OpenAIServing
13
+from vllm.entrypoints.renderer import BaseRenderer
14
15
from ...utils import RemoteOpenAIServer
16
@@ -27,12 +27,16 @@ async def test_empty_prompt():
27
with RemoteOpenAIServer(model_name, server_args) as remote_server:
28
client = remote_server.get_async_client()
29
30
- with pytest.raises(openai.BadRequestError,
31
- match="decoder prompt cannot be empty"):
30
+ with pytest.raises(
31
+ openai.BadRequestError,
32
+ match=
33
+ "Either prompt or prompt_embeds must be provided and non-empty."
34
+ ):
35
await client.completions.create(model=model_name,
36
prompt="",
37
max_tokens=5,
35
- temperature=0.0)
38
+ temperature=0.0,
39
+ extra_body={"prompt_embeds": []})
40
41
42
@pytest.mark.asyncio
@@ -83,7 +87,7 @@ def test_load_prompt_embeds(dtype: torch.dtype, layout: torch.layout,
87
buffer.seek(0)
88
encoded_tensor = pybase64.b64encode(buffer.getvalue())
89
86
- loaded_prompt_embeds = OpenAIServing._load_prompt_embeds(encoded_tensor)
90
+ loaded_prompt_embeds = BaseRenderer.load_prompt_embeds(encoded_tensor)
91
assert len(loaded_prompt_embeds) == 1
92
loaded_tensor = loaded_prompt_embeds[0]["prompt_embeds"]
93
assert loaded_tensor.device.type == "cpu"
tests/entrypoints/test_renderer.py
+133
@@ -1,13 +1,17 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+import io
5
from dataclasses import dataclass
6
from typing import Optional
7
from unittest.mock import AsyncMock, MagicMock
8
9
+import pybase64
10
import pytest
11
+import torch
12
13
from vllm.entrypoints.renderer import CompletionRenderer
14
+from vllm.inputs.data import is_embeds_prompt
15
16
17
@dataclass
@@ -178,3 +182,132 @@ class TestRenderPrompt:
182
with pytest.raises(ValueError, match="No tokenizer available"):
183
await renderer_no_tokenizer.render_prompt(
184
prompt_or_prompts="Hello world", max_length=100)
185
+
186
+ @pytest.mark.asyncio
187
+ async def test_token_input_with_needs_detokenization(
188
+ self, renderer, mock_async_tokenizer):
189
+ # When needs_detokenization=True for token inputs, renderer should
190
+ # use the async tokenizer to decode and include the original text
191
+ # in the returned prompt object.
192
+ mock_async_tokenizer.decode = AsyncMock(return_value="decoded text")
193
+ renderer.async_tokenizer_pool[
194
+ renderer.tokenizer] = mock_async_tokenizer
195
+
196
+ tokens = [1, 2, 3, 4]
197
+ results = await renderer.render_prompt(
198
+ prompt_or_prompts=tokens,
199
+ needs_detokenization=True,
200
+ )
201
+
202
+ assert len(results) == 1
203
+ assert results[0]["prompt_token_ids"] == tokens
204
+ assert results[0]["prompt"] == "decoded text"
205
+ mock_async_tokenizer.decode.assert_awaited_once()
206
+
207
+
208
+class TestRenderEmbedPrompt:
209
+
210
+ def _create_test_embed_bytes(self, tensor: torch.Tensor) -> bytes:
211
+ """Helper to create base64-encoded tensor bytes"""
212
+ buffer = io.BytesIO()
213
+ torch.save(tensor, buffer)
214
+ buffer.seek(0)
215
+ return pybase64.b64encode(buffer.read())
216
+
217
+ @pytest.mark.asyncio
218
+ async def test_single_prompt_embed(self, renderer):
219
+ # Create a test tensor
220
+ test_tensor = torch.randn(10, 768, dtype=torch.float32)
221
+ embed_bytes = self._create_test_embed_bytes(test_tensor)
222
+
223
+ results = await renderer.render_prompt_and_embeds(
224
+ prompt_embeds=embed_bytes, cache_salt="test_salt")
225
+
226
+ assert len(results) == 1
227
+ assert is_embeds_prompt(results[0])
228
+ assert torch.allclose(results[0]["prompt_embeds"], test_tensor)
229
+ assert results[0]["cache_salt"] == "test_salt"
230
+
231
+ @pytest.mark.asyncio
232
+ async def test_multiple_prompt_embeds(self, renderer):
233
+ # Create multiple test tensors
234
+ test_tensors = [
235
+ torch.randn(8, 512, dtype=torch.float32),
236
+ torch.randn(12, 512, dtype=torch.float32),
237
+ ]
238
+ embed_bytes_list = [
239
+ self._create_test_embed_bytes(t) for t in test_tensors
240
+ ]
241
+
242
+ results = await renderer.render_prompt_and_embeds(
243
+ prompt_embeds=embed_bytes_list)
244
+
245
+ assert len(results) == 2
246
+ for i, result in enumerate(results):
247
+ assert is_embeds_prompt(result)
248
+ assert torch.allclose(result["prompt_embeds"], test_tensors[i])
249
+
250
+ @pytest.mark.asyncio
251
+ async def test_prompt_embed_truncation(self, renderer):
252
+ # Create tensor with more tokens than truncation limit
253
+ test_tensor = torch.randn(20, 768, dtype=torch.float32)
254
+ embed_bytes = self._create_test_embed_bytes(test_tensor)
255
+
256
+ results = await renderer.render_prompt_and_embeds(
257
+ prompt_embeds=embed_bytes, truncate_prompt_tokens=10)
258
+
259
+ assert len(results) == 1
260
+ # Should keep last 10 tokens
261
+ expected = test_tensor[-10:]
262
+ assert torch.allclose(results[0]["prompt_embeds"], expected)
263
+
264
+ @pytest.mark.asyncio
265
+ async def test_prompt_embed_different_dtypes(self, renderer):
266
+ # Test different supported dtypes
267
+ dtypes = [torch.float32, torch.float16, torch.bfloat16]
268
+
269
+ for dtype in dtypes:
270
+ test_tensor = torch.randn(5, 256, dtype=dtype)
271
+ embed_bytes = self._create_test_embed_bytes(test_tensor)
272
+
273
+ results = await renderer.render_prompt_and_embeds(
274
+ prompt_embeds=embed_bytes)
275
+
276
+ assert len(results) == 1
277
+ assert results[0]["prompt_embeds"].dtype == dtype
278
+
279
+ @pytest.mark.asyncio
280
+ async def test_prompt_embed_squeeze_batch_dim(self, renderer):
281
+ # Test tensor with batch dimension gets squeezed
282
+ test_tensor = torch.randn(1, 10, 768, dtype=torch.float32)
283
+ embed_bytes = self._create_test_embed_bytes(test_tensor)
284
+
285
+ results = await renderer.render_prompt_and_embeds(
286
+ prompt_embeds=embed_bytes)
287
+
288
+ assert len(results) == 1
289
+ # Should be squeezed to 2D
290
+ assert results[0]["prompt_embeds"].shape == (10, 768)
291
+
292
+ @pytest.mark.asyncio
293
+ async def test_both_prompts_and_embeds(self, renderer,
294
+ mock_async_tokenizer):
295
+ # Set up text tokenization
296
+ mock_async_tokenizer.return_value = MockTokenizerResult(
297
+ [101, 102, 103])
298
+ renderer.async_tokenizer_pool[
299
+ renderer.tokenizer] = mock_async_tokenizer
300
+
301
+ # Create embed
302
+ test_tensor = torch.randn(5, 256, dtype=torch.float32)
303
+ embed_bytes = self._create_test_embed_bytes(test_tensor)
304
+
305
+ results = await renderer.render_prompt_and_embeds(
306
+ prompt_or_prompts="Hello world", prompt_embeds=embed_bytes)
307
+
308
+ assert len(results) == 2
309
+ # First should be embed prompt
310
+ assert is_embeds_prompt(results[0])
311
+ # Second should be tokens prompt
312
+ assert "prompt_token_ids" in results[1]
313
+ assert results[1]["prompt_token_ids"] == [101, 102, 103]
tests/v1/entrypoints/openai/test_completion.py
+1
-1
@@ -686,7 +686,7 @@ async def test_invalid_grammar(client: openai.AsyncOpenAI, model_name: str):
686
async def test_completion_with_empty_prompt_embeds(
687
client: openai.AsyncOpenAI) -> None:
688
"""Test completion with empty prompt embeds."""
689
- payload: dict[str, list] = {"prompt_embeds": []}
689
+ payload: dict[str, object] = {"prompt": "Hello", "prompt_embeds": []}
690
headers: dict[str, str] = {"Content-Type": "application/json"}
691
# base_url = http://localhost:8000/v1/completions
692
response = requests.post(f"{client.base_url}completions",
vllm/entrypoints/openai/protocol.py
+13
-2
@@ -1270,9 +1270,20 @@ class CompletionRequest(OpenAIBaseModel):
1270
@model_validator(mode="before")
1271
@classmethod
1272
def validate_prompt_and_prompt_embeds(cls, data):
1273
- if data.get("prompt") is None and data.get("prompt_embeds") is None:
1273
+ prompt = data.get("prompt")
1274
+ prompt_embeds = data.get("prompt_embeds")
1275
+
1276
+ prompt_is_empty = (prompt is None
1277
+ or (isinstance(prompt, str) and prompt == ""))
1278
+ embeds_is_empty = (prompt_embeds is None
1279
+ or (isinstance(prompt_embeds, list)
1280
+ and len(prompt_embeds) == 0))
1281
+
1282
+ if prompt_is_empty and embeds_is_empty:
1283
raise ValueError(
1275
- "At least one of `prompt` or `prompt_embeds` must be set.")
1284
+ "Either prompt or prompt_embeds must be provided and non-empty."
1285
+ )
1286
+
1287
return data
1288
1289
@model_validator(mode="before")
vllm/entrypoints/openai/serving_completion.py
+28
-27
@@ -26,12 +26,8 @@ from vllm.entrypoints.openai.protocol import (CompletionLogProbs,
26
PromptTokenUsageInfo,
27
RequestResponseMetadata,
28
UsageInfo)
29
-from vllm.entrypoints.openai.serving_engine import (
30
- EmbedsPrompt as ServingEngineEmbedsPrompt)
29
from vllm.entrypoints.openai.serving_engine import (OpenAIServing,
32
- TextTokensPrompt,
33
- clamp_prompt_logprobs,
34
- is_text_tokens_prompt)
30
+ clamp_prompt_logprobs)
31
# yapf: enable
32
from vllm.entrypoints.openai.serving_models import OpenAIServingModels
33
from vllm.entrypoints.utils import get_max_tokens
@@ -132,12 +128,19 @@ class OpenAIServingCompletion(OpenAIServing):
128
else:
129
tokenizer = await self.engine_client.get_tokenizer(lora_request
130
)
135
-
136
- request_prompts, engine_prompts = await self._preprocess_completion(
137
- request,
138
- tokenizer,
139
- request.prompt,
131
+ renderer = self._get_renderer(tokenizer)
132
+ max_input_tokens_len = self.max_model_len - (request.max_tokens
133
+ or 0)
134
+
135
+ engine_prompts = await renderer.render_prompt_and_embeds(
136
+ prompt_or_prompts=request.prompt,
137
+ prompt_embeds=request.prompt_embeds,
138
+ max_length=max_input_tokens_len,
139
+ truncate_prompt_tokens=request.truncate_prompt_tokens,
140
add_special_tokens=request.add_special_tokens,
141
+ cache_salt=request.cache_salt,
142
+ needs_detokenization=bool(request.echo
143
+ and not request.return_token_ids),
144
)
145
except ValueError as e:
146
logger.exception("Error in preprocessing prompt inputs")
@@ -198,7 +201,7 @@ class OpenAIServingCompletion(OpenAIServing):
201
202
self._log_inputs(
203
request_id_item,
201
- request_prompts[i],
204
+ engine_prompt,
205
params=sampling_params,
206
lora_request=lora_request,
207
)
@@ -249,7 +252,7 @@ class OpenAIServingCompletion(OpenAIServing):
252
if stream:
253
return self.completion_stream_generator(
254
request,
252
- request_prompts,
255
+ engine_prompts,
256
result_generator,
257
request_id,
258
created_time,
@@ -273,11 +276,9 @@ class OpenAIServingCompletion(OpenAIServing):
276
# We did not pass it into vLLM engine to avoid being redundant
277
# with the inputs token IDs
278
if final_res.prompt is None:
276
- request_prompt = request_prompts[i]
277
- if is_text_tokens_prompt(request_prompt):
278
- final_res.prompt = request_prompt["prompt"]
279
- else:
280
- final_res.prompt = None
279
+ engine_prompt = engine_prompts[i]
280
+ final_res.prompt = None if is_embeds_prompt(
281
+ engine_prompt) else engine_prompt.get("prompt")
282
283
final_res_batch_checked = cast(list[RequestOutput],
284
final_res_batch)
@@ -313,8 +314,7 @@ class OpenAIServingCompletion(OpenAIServing):
314
async def completion_stream_generator(
315
self,
316
request: CompletionRequest,
316
- request_prompts: list[Union[TextTokensPrompt,
317
- ServingEngineEmbedsPrompt]],
317
+ engine_prompts: list[Union[TokensPrompt, EmbedsPrompt]],
318
result_generator: AsyncIterator[tuple[int, RequestOutput]],
319
request_id: str,
320
created_time: int,
@@ -350,14 +350,11 @@ class OpenAIServingCompletion(OpenAIServing):
350
num_cached_tokens = res.num_cached_tokens
351
first_iteration = False
352
353
- if res.prompt is not None:
354
- prompt_text = res.prompt
355
- else:
356
- request_prompt = request_prompts[prompt_idx]
357
- if is_text_tokens_prompt(request_prompt):
358
- prompt_text = request_prompt["prompt"]
359
- else:
360
- prompt_text = None
353
+ prompt_text = res.prompt
354
+ if prompt_text is None:
355
+ engine_prompt = engine_prompts[prompt_idx]
356
+ prompt_text = None if is_embeds_prompt(
357
+ engine_prompt) else engine_prompt.get("prompt")
358
359
# Prompt details are excluded from later streamed outputs
360
if prompt_token_ids is not None:
@@ -378,6 +375,8 @@ class OpenAIServingCompletion(OpenAIServing):
375
assert request.max_tokens is not None
376
if request.echo and not has_echoed[i]:
377
assert prompt_token_ids is not None
378
+ if request.return_token_ids:
379
+ prompt_text = ""
380
assert prompt_text is not None
381
if request.max_tokens == 0:
382
# only return the prompt
@@ -525,6 +524,8 @@ class OpenAIServingCompletion(OpenAIServing):
524
for output in final_res.outputs:
525
assert request.max_tokens is not None
526
if request.echo:
527
+ if request.return_token_ids:
528
+ prompt_text = ""
529
assert prompt_text is not None
530
if request.max_tokens == 0:
531
token_ids = prompt_token_ids
vllm/entrypoints/openai/serving_embedding.py
+2
-13
@@ -28,7 +28,6 @@ from vllm.entrypoints.openai.serving_engine import (EmbeddingServeContext,
28
TextTokensPrompt)
29
# yapf: enable
30
from vllm.entrypoints.openai.serving_models import OpenAIServingModels
31
-from vllm.inputs.data import EmbedsPrompt as EngineEmbedsPrompt
31
from vllm.inputs.data import TokensPrompt as EngineTokensPrompt
32
from vllm.logger import init_logger
33
from vllm.outputs import (EmbeddingOutput, EmbeddingRequestOutput,
@@ -290,7 +289,7 @@ class EmbeddingMixin(OpenAIServing):
289
async def _create_single_prompt_generator(
290
self,
291
ctx: EmbeddingServeContext,
293
- engine_prompt: Union[EngineTokensPrompt, EngineEmbedsPrompt],
292
+ engine_prompt: EngineTokensPrompt,
293
pooling_params: PoolingParams,
294
trace_headers: Optional[Mapping[str, str]],
295
prompt_index: int,
@@ -303,12 +302,6 @@ class EmbeddingMixin(OpenAIServing):
302
params=pooling_params,
303
lora_request=ctx.lora_request)
304
306
- # Mypy has an existing bug related to inferring the variance
307
- # of TypedDicts with `builtins.enumerate`:
308
- # https://github.com/python/mypy/issues/8586#issuecomment-2867698435
309
- engine_prompt = cast(Union[EngineTokensPrompt, EngineEmbedsPrompt],
310
- engine_prompt)
311
-
305
# Return the original generator without wrapping
306
return self.engine_client.encode(
307
engine_prompt,
@@ -375,12 +368,8 @@ class EmbeddingMixin(OpenAIServing):
368
continue
369
370
# Normal processing for short prompts or non-token prompts
378
- # Cast engine_prompt to the expected type for mypy
379
- engine_prompt_typed = cast(
380
- Union[EngineTokensPrompt, EngineEmbedsPrompt],
381
- engine_prompt)
371
generator = await self._create_single_prompt_generator(
383
- ctx, engine_prompt_typed, pooling_params, trace_headers, i)
372
+ ctx, engine_prompt, pooling_params, trace_headers, i)
373
generators.append(generator)
374
375
from vllm.utils import merge_async_iterators
vllm/entrypoints/openai/serving_engine.py
+2
-215
@@ -1,7 +1,5 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-import asyncio
4
-import io
3
import json
4
import sys
5
import time
@@ -9,10 +7,8 @@ import traceback
7
from collections.abc import AsyncGenerator, Iterable, Mapping, Sequence
8
from concurrent.futures import ThreadPoolExecutor
9
from http import HTTPStatus
12
-from typing import (Annotated, Any, Callable, ClassVar, Generic, Optional,
13
- TypeVar, Union, cast, overload)
10
+from typing import Any, Callable, ClassVar, Generic, Optional, TypeVar, Union
11
15
-import pybase64
12
import torch
13
from fastapi import Request
14
from pydantic import BaseModel, ConfigDict, Field
@@ -64,10 +60,8 @@ from vllm.entrypoints.openai.serving_models import OpenAIServingModels
60
from vllm.entrypoints.openai.tool_parsers import ToolParser
61
from vllm.entrypoints.renderer import BaseRenderer, CompletionRenderer
62
# yapf: enable
67
-from vllm.inputs.data import EmbedsPrompt as EngineEmbedsPrompt
63
from vllm.inputs.data import PromptType
64
from vllm.inputs.data import TokensPrompt as EngineTokensPrompt
70
-from vllm.inputs.parse import parse_and_batch_prompt
65
from vllm.logger import init_logger
66
from vllm.logprobs import Logprob, PromptLogprobs
67
from vllm.lora.request import LoRARequest
@@ -149,8 +143,7 @@ class RequestProcessingMixin(BaseModel):
143
"""
144
145
request_prompts: Optional[Sequence[RequestPrompt]] = []
152
- engine_prompts: Optional[Union[list[EngineTokensPrompt],
153
- list[EngineEmbedsPrompt]]] = []
146
+ engine_prompts: Optional[list[EngineTokensPrompt]] = []
147
148
model_config = ConfigDict(arbitrary_types_allowed=True)
149
@@ -368,13 +361,6 @@ class OpenAIServing:
361
for i, engine_prompt in enumerate(ctx.engine_prompts):
362
request_id_item = f"{ctx.request_id}-{i}"
363
371
- # Mypy has an existing bug related to inferring the variance of
372
- # TypedDicts with `builtins.enumerate`:
373
- # https://github.com/python/mypy/issues/8586#issuecomment-2867698435
374
- engine_prompt = cast(
375
- Union[EngineTokensPrompt, EngineEmbedsPrompt],
376
- engine_prompt)
377
-
364
self._log_inputs(
365
request_id_item,
366
engine_prompt,
@@ -737,170 +723,6 @@ class OpenAIServing:
723
tokenizer=tokenizer,
724
)
725
740
- async def _tokenize_prompt_input_or_inputs_async(
741
- self,
742
- request: AnyRequest,
743
- tokenizer: Optional[AnyTokenizer],
744
- input_or_inputs: Optional[Union[str, list[str], list[int],
745
- list[list[int]]]],
746
- add_special_tokens: bool = True,
747
- ) -> tuple[list[TextTokensPrompt], list[EmbedsPrompt]]:
748
- """
749
- Tokenize/detokenize depending on the input format.
750
-
751
- According to `OpenAI API <https://platform.openai.com/docs/api-reference/embeddings/create>`_
752
- , each input can be a string or array of tokens. Note that each request
753
- can pass one or more inputs.
754
- """
755
- inputs_embeds = list[EmbedsPrompt]()
756
- inputs_text = list[TextTokensPrompt]()
757
-
758
- truncate_prompt_tokens = getattr(request, "truncate_prompt_tokens",
759
- None)
760
-
761
- if (truncate_prompt_tokens or 0) < 0:
762
- truncate_prompt_tokens = self.max_model_len
763
-
764
- if (isinstance(request, CompletionRequest)
765
- and request.prompt_embeds is not None):
766
- inputs_embeds.extend(
767
- self._load_prompt_embeds(request.prompt_embeds,
768
- truncate_prompt_tokens))
769
-
770
- # Empty prompts are okay as long as there are prompt embeddings
771
- if input_or_inputs is None or (inputs_embeds
772
- and input_or_inputs == ""):
773
- return [], inputs_embeds
774
-
775
- # Although our type checking is based on mypy,
776
- # VSCode Pyright extension should still work properly
777
- # "is False" is required for Pyright to perform type narrowing
778
- # See: https://github.com/microsoft/pyright/issues/7672
779
-
780
- # Parse and batch the input prompts
781
- batch_inputs = parse_and_batch_prompt(input_or_inputs)
782
-
783
- # Process each input in the batch concurrently
784
- tasks = []
785
- for prompt_input in batch_inputs:
786
- if prompt_input["is_tokens"] is False:
787
- assert tokenizer is not None, (
788
- "Tokenizer is required for text prompts")
789
- task = self._normalize_prompt_text_to_input(
790
- request,
791
- prompt_input["content"],
792
- tokenizer=tokenizer,
793
- add_special_tokens=add_special_tokens,
794
- )
795
- else:
796
- task = self._normalize_prompt_tokens_to_input(
797
- request, prompt_input["content"], tokenizer=tokenizer)
798
- tasks.append(task)
799
-
800
- # Wait for all tokenization tasks to complete
801
- results = await asyncio.gather(*tasks)
802
- inputs_text.extend(results)
803
-
804
- return inputs_text, inputs_embeds
805
-
806
- @overload
807
- async def _preprocess_completion(
808
- self,
809
- request: Union[
810
- DetokenizeRequest,
811
- EmbeddingCompletionRequest,
812
- RerankRequest,
813
- ClassificationRequest,
814
- ScoreRequest,
815
- TokenizeCompletionRequest,
816
- ],
817
- tokenizer: Optional[AnyTokenizer],
818
- input_or_inputs: Union[str, list[str], list[int], list[list[int]]],
819
- add_special_tokens: bool = ...,
820
- ) -> tuple[list[TextTokensPrompt], list[EngineTokensPrompt]]:
821
- ...
822
-
823
- @overload
824
- async def _preprocess_completion(
825
- self,
826
- request: CompletionRequest,
827
- tokenizer: Optional[AnyTokenizer],
828
- input_or_inputs: Optional[Union[str, list[str], list[int],
829
- list[list[int]]]],
830
- add_special_tokens: bool = ...,
831
- ) -> tuple[
832
- list[Union[TextTokensPrompt, EmbedsPrompt]],
833
- list[Union[EngineTokensPrompt, EngineEmbedsPrompt]],
834
- ]:
835
- ...
836
-
837
- async def _preprocess_completion(
838
- self,
839
- request: CompletionLikeRequest,
840
- tokenizer: Optional[AnyTokenizer],
841
- input_or_inputs: Optional[Union[str, list[str], list[int],
842
- list[list[int]]]],
843
- add_special_tokens: bool = True,
844
- ) -> tuple[
845
- Union[list[TextTokensPrompt], list[Union[TextTokensPrompt,
846
- EmbedsPrompt]]],
847
- Union[
848
- list[EngineTokensPrompt],
849
- list[Union[EngineTokensPrompt, EngineEmbedsPrompt]],
850
- ],
851
- ]:
852
- if (not isinstance(request, CompletionRequest)
853
- and input_or_inputs is None):
854
- raise ValueError(
855
- "Prompt embeds with non-completion requests is not"
856
- " currently supported.")
857
-
858
- (
859
- request_prompts_text,
860
- request_prompts_embeds,
861
- ) = await self._tokenize_prompt_input_or_inputs_async(
862
- request,
863
- tokenizer,
864
- input_or_inputs,
865
- add_special_tokens=add_special_tokens,
866
- )
867
-
868
- engine_prompts_text = [
869
- EngineTokensPrompt(
870
- prompt_token_ids=request_prompt_text["prompt_token_ids"])
871
- for request_prompt_text in request_prompts_text
872
- ]
873
- cache_salt = (request.cache_salt if
874
- (hasattr(request, "cache_salt")
875
- and request.cache_salt is not None) else None)
876
- if cache_salt:
877
- for prompt_text in engine_prompts_text:
878
- prompt_text["cache_salt"] = cache_salt
879
-
880
- # This check is equivalent to simply checking if
881
- # `request_prompts_embeds` is empty, but it's difficult to propagate
882
- # overloads to the private helper functions to enable this check.
883
- # This overload is needed because only TextPrompts are allowed for
884
- # non-completion requests and if we don't add the overload here,
885
- # everywhere this function is used outside of serving_completion will
886
- # need logic asserting that only text prompts are in the request.
887
- if (not isinstance(request, CompletionRequest)
888
- and input_or_inputs is not None):
889
- return request_prompts_text, engine_prompts_text
890
-
891
- engine_prompts_embeds = [
892
- EngineEmbedsPrompt(
893
- prompt_embeds=request_prompt_embeds["prompt_embeds"])
894
- for request_prompt_embeds in request_prompts_embeds
895
- ]
896
- if cache_salt:
897
- for prompt_embed in engine_prompts_embeds:
898
- prompt_embed["cache_salt"] = cache_salt
899
-
900
- request_prompts = request_prompts_embeds + request_prompts_text
901
- engine_prompts = engine_prompts_embeds + engine_prompts_text
902
- return request_prompts, engine_prompts
903
-
726
async def _preprocess_chat(
727
self,
728
request: Union[ChatLikeRequest, ResponsesRequest],
@@ -1073,41 +895,6 @@ class OpenAIServing:
895
# OPTIMIZATION
896
priority = orig_priority - 1
897
1076
- @staticmethod
1077
- def _load_prompt_embeds(
1078
- prompt_embeds: Optional[Union[bytes, list[bytes]]],
1079
- truncate_prompt_tokens: Optional[Annotated[int, Field(ge=1)]] = None,
1080
- ) -> list[EmbedsPrompt]:
1081
-
1082
- def _load_and_validate_embed(embed: bytes) -> EmbedsPrompt:
1083
- tensor = torch.load(
1084
- io.BytesIO(pybase64.b64decode(embed, validate=True)),
1085
- weights_only=True,
1086
- map_location=torch.device("cpu"),
1087
- )
1088
- assert isinstance(tensor, torch.Tensor) and tensor.dtype in (
1089
- torch.float32,
1090
- torch.bfloat16,
1091
- torch.float16,
1092
- )
1093
- tensor = tensor.to_dense()
1094
- if tensor.dim() > 2:
1095
- tensor = tensor.squeeze(0)
1096
- assert tensor.dim() == 2
1097
- if truncate_prompt_tokens is not None:
1098
- tensor = tensor[-truncate_prompt_tokens:]
1099
- return {"prompt_embeds": tensor}
1100
-
1101
- if prompt_embeds:
1102
- if isinstance(prompt_embeds, list):
1103
- return [
1104
- _load_and_validate_embed(embed) for embed in prompt_embeds
1105
- ]
1106
- else:
1107
- return [_load_and_validate_embed(prompt_embeds)]
1108
- else:
1109
- return []
1110
-
898
def _log_inputs(
899
self,
900
request_id: str,
vllm/entrypoints/renderer.py
+220
-47
@@ -2,12 +2,16 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
import asyncio
5
+import io
6
from abc import ABC, abstractmethod
7
from typing import Annotated, Optional, Union
8
9
+import pybase64
10
+import torch
11
from pydantic import Field
12
13
from vllm.config import ModelConfig
14
+from vllm.inputs.data import EmbedsPrompt as EngineEmbedsPrompt
15
from vllm.inputs.data import TokensPrompt as EngineTokensPrompt
16
from vllm.inputs.parse import parse_and_batch_prompt
17
from vllm.transformers_utils.tokenizer import AnyTokenizer
@@ -49,37 +53,121 @@ class BaseRenderer(ABC):
53
truncate_prompt_tokens: Optional[Annotated[int, Field(ge=-1)]] = None,
54
add_special_tokens: Optional[bool] = True,
55
cache_salt: Optional[str] = None,
56
+ needs_detokenization: Optional[bool] = False,
57
) -> list[EngineTokensPrompt]:
58
"""
54
- Convert input prompts into tokenized format for engine processing.
55
-
56
- This is the core method that transforms various input formats into
57
- standardized TokensPrompt objects. Implementations should handle
58
- tokenization, special token insertion, truncation, and validation
59
- according to model requirements.
60
-
59
+ Convert text or token inputs into engine-ready TokensPrompt objects.
60
+
61
+ This method accepts text or token inputs and produces a
62
+ list of [`TokensPrompt`][vllm.inputs.data.TokensPrompt] objects
63
+ for the engine.
64
+
65
+ Args:
66
+ prompt_or_prompts: One of:
67
+ - ``str``: Single text prompt.
68
+ - ``list[str]``: Batch of text prompts.
69
+ - ``list[int]``: Single pre-tokenized sequence.
70
+ - ``list[list[int]]``: Batch of pre-tokenized sequences.
71
+ max_length: Maximum allowable total input token length. If provided,
72
+ token inputs longer than this raise ``ValueError``.
73
+ truncate_prompt_tokens: Number of tokens to keep. ``None`` means no
74
+ truncation. ``0`` yields an empty list (and skips embeds).
75
+ ``-1`` maps to ``model_config.max_model_len``.
76
+ add_special_tokens: Whether to add model-specific special tokens
77
+ during text tokenization.
78
+ cache_salt: Optional string to disambiguate prefix cache entries.
79
+ needs_detokenization: If True and ``prompt_or_prompts`` is token
80
+ input, detokenize IDs back to text for inclusion in outputs.
81
+
82
+ Returns:
83
+ list[EngineTokensPrompt]: Engine-ready token prompts.
84
+
85
+ Raises:
86
+ ValueError: If input formats are invalid or length limits exceeded.
87
+ """
88
+ raise NotImplementedError
89
+
90
+ @abstractmethod
91
+ async def render_prompt_and_embeds(
92
+ self,
93
+ prompt_or_prompts: Optional[Union[str, list[str], list[int],
94
+ list[list[int]]]] = None,
95
+ prompt_embeds: Optional[Union[bytes, list[bytes]]] = None,
96
+ max_length: Optional[int] = None,
97
+ truncate_prompt_tokens: Optional[Annotated[int, Field(ge=-1)]] = None,
98
+ add_special_tokens: Optional[bool] = True,
99
+ cache_salt: Optional[str] = None,
100
+ needs_detokenization: Optional[bool] = False,
101
+ ) -> list[Union[EngineTokensPrompt, EngineEmbedsPrompt]]:
102
+ """
103
+ Convert text/token and/or base64-encoded embeddings inputs into
104
+ engine-ready prompt objects.
105
+
106
+ At least one of ``prompt_or_prompts`` or ``prompt_embeds`` must be
107
+ provided and non-empty. If both are omitted or empty (e.g., empty
108
+ string and empty list), a ``ValueError`` is raised.
109
+
110
Args:
62
- prompt_or_prompts: Input data in various formats:
63
- - str: Single text prompt
64
- - list[str]: Batch of text prompts
65
- - list[int]: Pre-tokenized sequence
66
- - list[list[int]]: Batch of pre-tokenized sequences
67
- max_length: Maximum sequence length (endpoint-specific behavior)
68
- truncate_prompt_tokens: Truncate to last N tokens
69
- (None=no truncation, 0=empty)
70
- add_special_tokens: Add model-specific tokens (e.g., [CLS], [SEP])
71
- to text inputs
72
- cache_salt: Optional string to disambiguate cached prompts
73
-
111
+ prompt_or_prompts: Text or token inputs to include.
112
+ prompt_embeds: Base64-encoded bytes (or list thereof) containing a
113
+ torch-saved tensor to be used as prompt embeddings.
114
+ max_length: Maximum allowable total input token length. If provided,
115
+ inputs longer than this raise ``ValueError``.
116
+ truncate_prompt_tokens: Number of tokens/rows to keep from the end
117
+ of the sequence. ``-1`` maps to ``model_config.max_model_len``.
118
+ add_special_tokens: Whether to add model-specific special tokens
119
+ during text tokenization.
120
+ cache_salt: Optional string to disambiguate prefix cache entries.
121
+ needs_detokenization: If True and ``prompt_or_prompts`` is token
122
+ input, detokenize IDs back to text for inclusion in outputs.
123
+
124
Returns:
75
- list[EngineTokensPrompt]: Tokenized prompts ready for engine
76
- consumption
77
-
125
+ list[Union[EngineTokensPrompt, EngineEmbedsPrompt]]:
126
+ Engine-ready prompt objects.
127
+
128
Raises:
79
- ValueError: If input format is invalid or length limits exceeded
129
+ ValueError: If both ``prompt_or_prompts`` and ``prompt_embeds``
130
+ are omitted or empty (decoder prompt cannot be empty), or if
131
+ length limits are exceeded.
132
"""
133
raise NotImplementedError
134
135
+ @classmethod
136
+ def load_prompt_embeds(
137
+ cls,
138
+ prompt_embeds: Union[bytes, list[bytes]],
139
+ truncate_prompt_tokens: Optional[Annotated[int, Field(ge=0)]] = None,
140
+ cache_salt: Optional[str] = None,
141
+ ) -> list[EngineEmbedsPrompt]:
142
+ """Load and validate base64-encoded embeddings into prompt objects."""
143
+
144
+ def _load_and_validate_embed(embed: bytes) -> EngineEmbedsPrompt:
145
+ tensor = torch.load(
146
+ io.BytesIO(pybase64.b64decode(embed, validate=True)),
147
+ weights_only=True,
148
+ map_location=torch.device("cpu"),
149
+ )
150
+ assert isinstance(tensor, torch.Tensor) and tensor.dtype in (
151
+ torch.float32,
152
+ torch.bfloat16,
153
+ torch.float16,
154
+ )
155
+ tensor = tensor.to_dense()
156
+ if tensor.dim() > 2:
157
+ tensor = tensor.squeeze(0)
158
+ assert tensor.dim() == 2
159
+ if truncate_prompt_tokens is not None:
160
+ tensor = tensor[-truncate_prompt_tokens:]
161
+ embeds_prompt = EngineEmbedsPrompt(prompt_embeds=tensor)
162
+ if cache_salt is not None:
163
+ embeds_prompt["cache_salt"] = cache_salt
164
+ return embeds_prompt
165
+
166
+ if isinstance(prompt_embeds, list):
167
+ return [_load_and_validate_embed(embed) for embed in prompt_embeds]
168
+ else:
169
+ return [_load_and_validate_embed(prompt_embeds)]
170
+
171
172
class CompletionRenderer(BaseRenderer):
173
@@ -101,50 +189,110 @@ class CompletionRenderer(BaseRenderer):
189
truncate_prompt_tokens: Optional[Annotated[int, Field(ge=-1)]] = None,
190
add_special_tokens: Optional[bool] = True,
191
cache_salt: Optional[str] = None,
192
+ needs_detokenization: Optional[bool] = False,
193
) -> list[EngineTokensPrompt]:
194
"""Implementation of prompt rendering for completion-style requests.
195
196
Uses async tokenizer pooling for improved performance. See base class
197
for detailed parameter documentation.
198
"""
110
- if truncate_prompt_tokens is not None:
111
- if truncate_prompt_tokens == 0:
112
- return []
113
- if truncate_prompt_tokens < 0:
114
- truncate_prompt_tokens = self.model_config.max_model_len
115
- if max_length is not None and truncate_prompt_tokens > max_length:
116
- raise ValueError(
117
- f"truncate_prompt_tokens ({truncate_prompt_tokens}) "
118
- f"cannot be greater than max_length ({max_length}). "
119
- f"Please select a smaller truncation size.")
199
+ truncate_prompt_tokens = self._validate_and_normalize_truncate_tokens(
200
+ truncate_prompt_tokens, max_length)
201
+ if truncate_prompt_tokens == 0:
202
+ return []
203
204
# Parse and batch the input prompts
205
batch_inputs = parse_and_batch_prompt(prompt_or_prompts)
206
124
- rendered_prompts: list[EngineTokensPrompt] = []
125
- tokenize_tasks = []
207
+ tasks = []
208
for prompt_input in batch_inputs:
209
if prompt_input["is_tokens"] is True:
210
# Token input
129
- token_ids = self._maybe_apply_truncation(
130
- prompt_input["content"], truncate_prompt_tokens)
131
- rendered_prompts.append(
132
- self._create_tokens_prompt(token_ids, max_length,
133
- cache_salt))
211
+ detokenize_task = asyncio.create_task(
212
+ # Note: detokenization is needed when echo is enabled,
213
+ # where the input token IDs are decoded back to text.
214
+ self._maybe_detokenize(prompt_input["content"], max_length,
215
+ truncate_prompt_tokens, cache_salt,
216
+ needs_detokenization))
217
+ tasks.append(detokenize_task)
218
else:
219
# Text input
220
tokenize_task = asyncio.create_task(
221
self._tokenize(prompt_input["content"], max_length,
222
truncate_prompt_tokens, add_special_tokens,
223
cache_salt))
140
- tokenize_tasks.append(tokenize_task)
224
+ tasks.append(tokenize_task)
225
226
# Wait for all text tokenization to finish
143
- if tokenize_tasks:
144
- tokenized_text_prompts = await asyncio.gather(*tokenize_tasks)
145
- rendered_prompts.extend(tokenized_text_prompts)
227
+ if tasks:
228
+ tokenized_text_prompts = await asyncio.gather(*tasks)
229
+ return tokenized_text_prompts
230
147
- return rendered_prompts
231
+ return []
232
+
233
+ async def render_prompt_and_embeds(
234
+ self,
235
+ prompt_or_prompts: Optional[Union[str, list[str], list[int],
236
+ list[list[int]]]] = None,
237
+ prompt_embeds: Optional[Union[bytes, list[bytes]]] = None,
238
+ max_length: Optional[int] = None,
239
+ truncate_prompt_tokens: Optional[Annotated[int, Field(ge=-1)]] = None,
240
+ add_special_tokens: Optional[bool] = True,
241
+ cache_salt: Optional[str] = None,
242
+ needs_detokenization: Optional[bool] = False,
243
+ ) -> list[Union[EngineTokensPrompt, EngineEmbedsPrompt]]:
244
+ """
245
+ Render text/token prompts and/or precomputed embedding prompts. At
246
+ least one of `prompt_or_prompts` or `prompt_embeds` must be provided.
247
+ """
248
+ truncate_prompt_tokens = self._validate_and_normalize_truncate_tokens(
249
+ truncate_prompt_tokens, max_length)
250
+ if truncate_prompt_tokens == 0:
251
+ return []
252
+
253
+ rendered: list[Union[EngineTokensPrompt, EngineEmbedsPrompt]] = []
254
+
255
+ if prompt_embeds is not None:
256
+ rendered.extend(
257
+ self.load_prompt_embeds(prompt_embeds, truncate_prompt_tokens,
258
+ cache_salt))
259
+ if prompt_or_prompts is None or prompt_or_prompts == "":
260
+ return rendered
261
+
262
+ token_prompts = await self.render_prompt(
263
+ prompt_or_prompts=prompt_or_prompts,
264
+ max_length=max_length,
265
+ truncate_prompt_tokens=truncate_prompt_tokens,
266
+ add_special_tokens=add_special_tokens,
267
+ cache_salt=cache_salt,
268
+ needs_detokenization=needs_detokenization,
269
+ )
270
+ rendered.extend(token_prompts)
271
+
272
+ return rendered
273
+
274
+ def _validate_and_normalize_truncate_tokens(
275
+ self,
276
+ truncate_prompt_tokens: Optional[int],
277
+ max_length: Optional[int],
278
+ ) -> Optional[int]:
279
+ """Validate and normalize truncate_prompt_tokens parameter."""
280
+ if truncate_prompt_tokens is None:
281
+ return None
282
+
283
+ if truncate_prompt_tokens == 0:
284
+ return 0
285
+
286
+ if truncate_prompt_tokens < 0:
287
+ truncate_prompt_tokens = self.model_config.max_model_len
288
+
289
+ if max_length is not None and truncate_prompt_tokens > max_length:
290
+ raise ValueError(
291
+ f"truncate_prompt_tokens ({truncate_prompt_tokens}) "
292
+ f"cannot be greater than max_length ({max_length}). "
293
+ f"Please select a smaller truncation size.")
294
+
295
+ return truncate_prompt_tokens
296
297
def _maybe_apply_truncation(
298
self, token_ids: list[int],
@@ -186,7 +334,29 @@ class CompletionRenderer(BaseRenderer):
334
max_length=truncate_prompt_tokens)
335
336
return self._create_tokens_prompt(encoded.input_ids, max_length,
189
- cache_salt)
337
+ cache_salt, text)
338
+
339
+ async def _maybe_detokenize(
340
+ self,
341
+ token_ids: list[int],
342
+ max_length: Optional[int],
343
+ truncate_prompt_tokens: Optional[int],
344
+ cache_salt: Optional[str],
345
+ needs_detokenization: Optional[bool] = False,
346
+ ) -> EngineTokensPrompt:
347
+ """Optionally detokenize token IDs and build a tokens prompt."""
348
+ token_ids = self._maybe_apply_truncation(token_ids,
349
+ truncate_prompt_tokens)
350
+
351
+ prompt = None
352
+ if needs_detokenization is True:
353
+ async_tokenizer = self._get_async_tokenizer()
354
+ prompt = await async_tokenizer.decode(token_ids)
355
+
356
+ return self._create_tokens_prompt(token_ids=token_ids,
357
+ max_length=max_length,
358
+ cache_salt=cache_salt,
359
+ prompt=prompt)
360
361
def _get_async_tokenizer(self) -> AsyncMicrobatchTokenizer:
362
"""Get or create async tokenizer using shared pool."""
@@ -210,6 +380,7 @@ class CompletionRenderer(BaseRenderer):
380
token_ids: list[int],
381
max_length: Optional[int] = None,
382
cache_salt: Optional[str] = None,
383
+ prompt: Optional[str] = None,
384
) -> EngineTokensPrompt:
385
"""Create validated EngineTokensPrompt."""
386
if max_length is not None and len(token_ids) > max_length:
@@ -221,4 +392,6 @@ class CompletionRenderer(BaseRenderer):
392
tokens_prompt = EngineTokensPrompt(prompt_token_ids=token_ids)
393
if cache_salt is not None:
394
tokens_prompt["cache_salt"] = cache_salt
224
- return tokens_prompt
395
+ if prompt is not None:
396
+ tokens_prompt["prompt"] = prompt
397
+ return tokens_prompt
\ No newline at end of file
vllm/inputs/data.py
+3
@@ -52,6 +52,9 @@ class TokensPrompt(TypedDict):
52
prompt_token_ids: list[int]
53
"""A list of token IDs to pass to the model."""
54
55
+ prompt: NotRequired[str]
56
+ """The prompt text corresponding to the token IDs, if available."""
57
+
58
token_type_ids: NotRequired[list[int]]
59
"""A list of token type IDs to pass to the cross encoder model."""
60