main
py 1,367 lines 50.6 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import asyncio
4 import time
5 import weakref
6 from abc import ABC, abstractmethod
7 from collections.abc import Mapping, Sequence
8 from concurrent.futures import Executor, Future, ThreadPoolExecutor
9 from contextlib import ExitStack
10 from dataclasses import replace
11 from functools import cached_property
12 from typing import TYPE_CHECKING, Any, Generic, overload
13
14 from typing_extensions import TypeVar
15
16 from vllm.exceptions import VLLMValidationError
17 from vllm.inputs import (
18 EmbedsInput,
19 EmbedsPrompt,
20 EncoderDecoderInput,
21 EngineInput,
22 MultiModalDataDict,
23 MultiModalInput,
24 MultiModalUUIDDict,
25 SingletonInput,
26 TextPrompt,
27 TokensInput,
28 TokensPrompt,
29 build_enc_dec_input,
30 embeds_input,
31 tokens_input,
32 )
33 from vllm.logger import init_logger
34 from vllm.multimodal import MULTIMODAL_REGISTRY as mm_registry
35 from vllm.multimodal.cache import (
36 BaseMultiModalProcessorCache,
37 processor_cache_from_config,
38 processor_only_cache_from_config,
39 )
40 from vllm.multimodal.gpu_ipc_memory import maybe_init_mm_gpu_ipc_pool
41 from vllm.multimodal.parse import (
42 MultiModalDataItems,
43 MultiModalUUIDItems,
44 parse_mm_uuids,
45 )
46 from vllm.multimodal.processing import BaseMultiModalProcessor
47 from vllm.multimodal.processing import ProcessorInputs as MMProcessorInputs
48 from vllm.multimodal.registry import MultiModalTimingRegistry
49 from vllm.tokenizers import TokenizerLike
50 from vllm.utils.async_utils import make_async
51 from vllm.utils.counter import AtomicCounter
52 from vllm.utils.torch_utils import set_default_torch_num_threads
53 from vllm.v1.metrics.stats import MultiModalCacheStats
54
55 from .embed_utils import safe_load_prompt_embeds
56 from .inputs import (
57 DictPrompt,
58 EncoderDecoderDictPrompt,
59 EncoderDecoderTokPrompt,
60 SingletonDictPrompt,
61 SingletonTokPrompt,
62 TokPrompt,
63 )
64 from .inputs.preprocess import extract_target_prompt
65 from .params import ChatParams, TokenizeParams
66
67 if TYPE_CHECKING:
68 from vllm.config import VllmConfig
69 from vllm.entrypoints.chat_utils import (
70 ChatCompletionMessageParam,
71 ConversationMessage,
72 )
73
74 logger = init_logger(__name__)
75
76
77 _T = TypeVar("_T", bound=TokenizerLike, default=TokenizerLike)
78
79
80 class _SwappableExecutor(Executor):
81 """Executor whose inner pool can be replaced without changing identity.
82
83 ``make_async`` captures the executor object at wrap time. Replacing
84 ``self._executor`` with a new ``ThreadPoolExecutor`` would leave those
85 wrappers (tokenize, decode, pooling, derender) bound to a shutdown pool.
86 """
87
88 def __init__(self, max_workers: int) -> None:
89 super().__init__()
90 self._max_workers = max_workers
91 self._inner = ThreadPoolExecutor(max_workers=max_workers)
92
93 def submit(self, fn, /, *args, **kwargs):
94 return self._inner.submit(fn, *args, **kwargs)
95
96 def shutdown(self, wait: bool = True, *, cancel_futures: bool = False) -> None:
97 self._inner.shutdown(wait=wait, cancel_futures=cancel_futures)
98
99 def replace_inner(self) -> None:
100 old = self._inner
101 old.shutdown(wait=False)
102 self._inner = ThreadPoolExecutor(max_workers=self._max_workers)
103
104
105 class BaseRenderer(ABC, Generic[_T]):
106 def __init__(self, config: "VllmConfig", tokenizer: _T | None) -> None:
107 super().__init__()
108
109 self.config = config
110 self.model_config = config.model_config
111 self.api_process_rank = config.parallel_config._api_process_rank
112
113 self._resources = ExitStack()
114 self._resources.callback(logger.debug, f"[shutdown] {self.__class__.__name__}")
115 self._finalizer = weakref.finalize(self, self._resources.close)
116
117 self.tokenizer = tokenizer
118
119 # Background multimodal warmup task (a future returned by
120 # _mm_executor) so it overlaps engine-core initialization while
121 # staying serialized with the serving path through the same
122 # single-worker executor. None when no background warmup has been
123 # launched (or it has already been joined). Routing the warmup through
124 # _mm_executor (max_workers=1) guarantees it can never run
125 # concurrently with a serving request inside the numba workqueue
126 # parallel region, which would otherwise hit numba's
127 # "Concurrent access has been detected" fatal abort (see
128 # vllm/utils/jit_monitor.py, which forces the workqueue layer).
129 # Joined by the first of reset_mm_cache / warmup / shutdown so the
130 # mm_processor_cache is also never touched concurrently by the warmup
131 # and the serving path.
132 self._mm_warmup_future: Future[None] | None = None
133 # Whether warmup_mm has already run. Survives _join_mm_warmup (which
134 # clears the thread pointer), so warmup() can tell that the background
135 # warmup already ran even after reset_mm_cache joined it first, and
136 # avoid running warmup_mm a second time.
137 self._mm_warmup_done: bool = False
138
139 # Thread pool executor for blocking tokenizer operations. The
140 # multimodal processor receives a deep-copied tokenizer (see #36557)
141 # so it is safe to run tokenization and MM preprocessing concurrently.
142 pool_workers = config.model_config.renderer_num_workers
143 self._executor = _SwappableExecutor(max_workers=pool_workers)
144 self._resources.callback(self._executor.shutdown, wait=False)
145
146 # Separate single-worker executor so tokenization never queues behind
147 # MM preprocessing; must stay single-worker per #38418 (P0/P1 order).
148 self._mm_executor = ThreadPoolExecutor(max_workers=1)
149 self._resources.callback(self._mm_executor.shutdown, wait=False)
150
151 # Offload tokenization to the thread pool. The sync
152 # ``_tokenize_prompt`` already encapsulates the unified ``__call__``
153 # path and char-offset extraction, so the async variant is just it
154 # offloaded (mirrors ``_process_multimodal_async`` below).
155 self._tokenize_prompt_async = make_async(
156 self._tokenize_prompt, executor=self._executor
157 )
158 self._async_tokenizer_decode = make_async(self._decode, executor=self._executor)
159
160 self.mm_processor: BaseMultiModalProcessor | None = None
161
162 self._clear_mm_cache_async = make_async(
163 self.clear_mm_cache, executor=self._mm_executor
164 )
165 self._process_multimodal_async = make_async(
166 self._process_multimodal, executor=self._mm_executor
167 )
168 self._safe_load_prompt_embeds_async = make_async(
169 safe_load_prompt_embeds, executor=self._executor
170 )
171
172 self._mm_processor_cache: BaseMultiModalProcessorCache | None = None
173
174 # A second processor with its own processor-only cache.
175 # Used by the tokenize endpoint so that tokenize-only
176 # requests don't pollute the sender cache.
177 self._mm_processor_only_cache: BaseMultiModalProcessorCache | None = None
178
179 self._mm_cache_stats: MultiModalCacheStats | None = None
180
181 if config.model_config.supports_multimodal_inputs:
182 # Install the process-global GPU memory pool used to gate
183 # frontend GPU-side multimodal decoding (no-op when the budget
184 # is 0). Lives in the API-server process only.
185 mm_config = config.model_config.multimodal_config
186 if mm_config is not None:
187 maybe_init_mm_gpu_ipc_pool(
188 mm_config.mm_ipc_gpu_memory_gb,
189 config.parallel_config._api_process_count,
190 )
191
192 with set_default_torch_num_threads():
193 self.mm_processor = mm_registry.create_processor(
194 config.model_config,
195 tokenizer=self.tokenizer,
196 )
197
198 self._mm_processor_cache = processor_cache_from_config(config)
199 self._mm_processor_only_cache = processor_only_cache_from_config(config)
200
201 # This is used to generate internal request ID for MM processing
202 # It has no relation to the request ID for engine core
203 self._mm_req_counter = AtomicCounter()
204 self._mm_timing_registry = MultiModalTimingRegistry(
205 config.observability_config
206 )
207
208 if self._mm_processor_cache:
209 self._mm_cache_stats = MultiModalCacheStats()
210 self._resources.callback(self._mm_processor_cache.close)
211 if self._mm_processor_only_cache:
212 self._resources.callback(self._mm_processor_only_cache.close)
213
214 def get_tokenizer(self) -> _T:
215 tokenizer = self.tokenizer
216 if tokenizer is None:
217 raise ValueError("Tokenizer not available when `skip_tokenizer_init=True`")
218
219 return tokenizer
220
221 def render_completion_suffix(self, prompt: str, suffix: str) -> str | None:
222 """Render OpenAI completion suffix input when the renderer supports FIM."""
223 return None
224
225 def _decode(self, *args, **kwargs):
226 return self.get_tokenizer().decode(*args, **kwargs)
227
228 def get_mm_processor(self) -> "BaseMultiModalProcessor":
229 if self.mm_processor is None:
230 raise ValueError("Multi-modal processor not available for text-only models")
231
232 return self.mm_processor
233
234 @property
235 def mm_processor_cache(self) -> BaseMultiModalProcessorCache | None:
236 return self._mm_processor_cache
237
238 def stat_mm_cache(self) -> MultiModalCacheStats | None:
239 mm_cache_stats = self._mm_cache_stats
240 if mm_cache_stats is None:
241 return None
242
243 self._mm_cache_stats = MultiModalCacheStats()
244
245 return mm_cache_stats
246
247 def update_mm_cache_stats(self) -> None:
248 mm_processor_cache = self._mm_processor_cache
249 mm_cache_stats = self._mm_cache_stats
250
251 if mm_processor_cache and mm_cache_stats:
252 delta = mm_processor_cache.make_stats(delta=True)
253 mm_cache_stats.record(delta.total, delta.hits)
254
255 def clear_mm_cache(self) -> None:
256 mm_processor_cache = self._mm_processor_cache
257 if mm_processor_cache is not None:
258 mm_processor_cache.clear_cache()
259
260 if self._mm_cache_stats is not None:
261 self._mm_cache_stats.reset = True
262
263 def _warmup_mm_processor(
264 self,
265 processor: BaseMultiModalProcessor,
266 cache: BaseMultiModalProcessorCache | None,
267 *,
268 log_prefix: str,
269 ) -> None:
270 mm_limits = {k: v for k, v in processor.info.allowed_mm_limits.items() if v > 0}
271
272 start_time = time.perf_counter()
273 _ = processor.get_dummy_mm_inputs(
274 dict.fromkeys(mm_limits, 1),
275 cache=cache,
276 scheduler_config=self.config.scheduler_config,
277 )
278
279 elapsed = time.perf_counter() - start_time
280 logger.info("%s warmup completed in %.3fs", log_prefix, elapsed)
281
282 def warmup_mm(self) -> None:
283 # Run only the multimodal warmup (mm + readonly mm), serially.
284 # Idempotent: runs at most once. A background warmup
285 # (start_mm_warmup_in_background) may already have run it, and
286 # reset_mm_cache may have joined that background warmup task before
287 # warmup() reaches this point — the _mm_warmup_done flag survives the
288 # join (the future pointer does not), so warmup() can still tell it
289 # already ran.
290 if self._mm_warmup_done:
291 return
292 try:
293 # prevent MM processor hangs
294 with set_default_torch_num_threads(1):
295 if self.mm_processor:
296 mm_processor_only_cache = self._mm_processor_only_cache
297
298 try:
299 logger.debug("Warming up multi-modal processing...")
300 self._warmup_mm_processor(
301 self.mm_processor,
302 mm_processor_only_cache,
303 log_prefix="Multi-modal",
304 )
305 except Exception:
306 logger.warning("Multi-modal warmup failed")
307 finally:
308 if mm_processor_only_cache:
309 mm_processor_only_cache.clear_cache()
310 finally:
311 self._mm_warmup_done = True
312
313 def start_mm_warmup_in_background(self) -> None:
314 # Launch the multimodal warmup as a task on the single-worker
315 # _mm_executor so it overlaps with engine-core initialization
316 # (model loading) while staying serialized with the serving path.
317 #
318 # Why the same executor as serving (not a separate Thread):
319 # _process_multimodal runs on _mm_executor (max_workers=1) and
320 # enters the numba workqueue parallel region (e.g. Kimi K2.5 vision
321 # preprocessing via kimi_k25_vision_fused). A separate warmup Thread
322 # would run concurrently with that single serving worker, so both
323 # threads would enter the numba workqueue parallel region at the same
324 # time and trip numba's "Concurrent access has been detected" ->
325 # "Fatal Python error: Aborted" (see vllm/utils/jit_monitor.py, which
326 # forces the non-threadsafe workqueue layer). Submitting the warmup to
327 # the same single-worker executor makes warmup and serving
328 # physically incapable of overlapping on the numba threading layer,
329 # while still overlapping at the process level with the (forked)
330 # engine-core init in the main thread.
331 #
332 # The warmup task is joined by the first of: reset_mm_cache, warmup, or
333 # shutdown. This also guarantees the frontend mm_processor_cache —
334 # which is not designed for concurrent access (its get_and_update must
335 # stay ordered with the engine-core cache) — is never touched
336 # concurrently by the warmup and the serving path.
337 if self.mm_processor is None:
338 return # text-only model: nothing to warm up
339 if self._mm_warmup_future is not None:
340 return # already launched
341 self._mm_warmup_future = self._mm_executor.submit(self.warmup_mm)
342
343 def _join_mm_warmup(self) -> None:
344 # Wait for the background MM warmup task to finish, if one was started.
345 # Called from the main / event-loop thread (reset_mm_cache, warmup,
346 # shutdown) — never from inside the _mm_executor worker — so
347 # waiting on the future cannot deadlock.
348 future = getattr(self, "_mm_warmup_future", None)
349 if future is not None:
350 future.result()
351 self._mm_warmup_future = None
352
353 def warmup(self, chat_params: ChatParams) -> None:
354 """Warm up this renderer to avoid first-request latency.
355
356 For chat requests:
357 - Jinja2 template compilation
358
359 If a background multimodal warmup was launched (to overlap
360 engine-core init) it is joined first so it is not still touching the
361 mm_processor_cache. warmup_mm is idempotent, so the call below is a
362 no-op if the background warmup — or a prior reset_mm_cache that joined
363 it — already ran it.
364 """
365 from vllm.entrypoints.chat_utils import ChatTemplateResolutionError
366
367 # prevent MM processor hangs
368 with set_default_torch_num_threads(1):
369 try:
370 logger.debug("Warming up chat template processing...")
371 start_time = time.perf_counter()
372
373 self.render_chat([[{"role": "user", "content": "warmup"}]], chat_params)
374
375 elapsed = time.perf_counter() - start_time
376 logger.debug("Chat template warmup completed in %.3fs", elapsed)
377 except ChatTemplateResolutionError:
378 logger.debug("This model does not support chat template.")
379 except Exception:
380 logger.warning("Chat template warmup failed", exc_info=True)
381
382 if self._mm_warmup_future is not None:
383 # Join a background MM warmup launched to overlap engine-core init.
384 self._join_mm_warmup()
385 # Idempotent: no-op if the background warmup (or a prior reset_mm_cache)
386 # already ran the MM warmup.
387 self.warmup_mm()
388
389 async def clear_mm_cache_async(self) -> None:
390 """Serialize clear_mm_cache through the multimodal executor to avoid
391 races with concurrent process_inputs on the mm_processor_cache."""
392 await self._clear_mm_cache_async()
393
394 def shutdown(self) -> None:
395 # Wait for any background MM warmup to finish first: it runs on the
396 # _mm_executor and touches the mm_processor_cache, so it must be
397 # quiescent before _resources.close() tears down the executors and
398 # the cache (both are registered as resources in __init__).
399 self._join_mm_warmup()
400 self._resources.close()
401
402 def get_bos_token_id(self) -> int | None:
403 if self.tokenizer is None:
404 logger.warning_once(
405 "Using None for BOS token id because tokenizer is not initialized"
406 )
407 return None
408
409 return self.tokenizer.bos_token_id
410
411 def get_eos_token_id(self) -> int | None:
412 if self.tokenizer is None:
413 logger.warning_once(
414 "Using None for EOS token id because tokenizer is not initialized"
415 )
416 return None
417
418 return self.tokenizer.eos_token_id
419
420 def validate_token_ids(
421 self, token_ids: Sequence[int], *, parameter: str | None = None
422 ) -> None:
423 """Raise VLLMValidationError if a token id is out of vocabulary.
424
425 Skipped when the tokenizer is not initialized.
426 """
427 tokenizer = self.tokenizer
428 if not token_ids or tokenizer is None:
429 return
430
431 max_input_id = max(token_ids)
432 min_input_id = min(token_ids)
433
434 # NOTE: tokenizer.max_token_id is the tokenizer’s vocab size while
435 # self.model_config.get_vocab_size() is the model’s vocab size.
436 # For Qwen3 models, the language model has extra tokens that do
437 # not exist in the tokenizer, and vice versa for multimodal
438 # placeholder tokens in some multimodal models.
439 # See https://github.com/QwenLM/Qwen3/issues/29#issuecomment-1933720399 # noqa: E501
440 # and https://github.com/vllm-project/vllm/pull/22471#discussion_r2312251421 # noqa: E501
441
442 # Here we take the max of the two to determine if a token id is
443 # truly out-of-vocabulary.
444 model_vocab_size = self.model_config.get_vocab_size()
445 # A negative id is out of vocabulary just like an over-large one,
446 # but is not caught by the upper-bound check below. Reject it here
447 # so it is not used as an embedding index downstream.
448 if min_input_id < 0:
449 raise VLLMValidationError(
450 f"Token id {min_input_id} is out of vocabulary", parameter=parameter
451 )
452 if max_input_id > max(tokenizer.max_token_id, model_vocab_size - 1):
453 raise VLLMValidationError(
454 f"Token id {max_input_id} is out of vocabulary", parameter=parameter
455 )
456
457 def get_dec_start_token_id(self) -> int:
458 """Obtain the decoder start token id employed by an encoder/decoder model,
459 raising an error if it is not available.
460 """
461 dec_start_token_id = getattr(
462 self.model_config.hf_config, "decoder_start_token_id", None
463 )
464
465 if dec_start_token_id is None:
466 logger.warning_once(
467 "Falling back on <BOS> for decoder start token id "
468 "because decoder start token id is not available."
469 )
470 dec_start_token_id = self.get_bos_token_id()
471
472 if dec_start_token_id is None:
473 raise RuntimeError("Cannot find decoder start token id or <BOS>")
474
475 return dec_start_token_id
476
477 @cached_property
478 def default_cmpl_tok_params(self) -> TokenizeParams:
479 mm_processor = self.mm_processor
480 if mm_processor is not None:
481 return mm_processor.info.default_tok_params
482
483 model_config = self.model_config
484 encoder_config = model_config.encoder_config or {}
485
486 return TokenizeParams(
487 max_total_tokens=model_config.max_model_len,
488 do_lower_case=encoder_config.get("do_lower_case", False),
489 add_special_tokens=True,
490 )
491
492 @cached_property
493 def default_chat_tok_params(self) -> TokenizeParams:
494 mm_processor = self.mm_processor
495 if mm_processor is not None:
496 return mm_processor.info.default_tok_params
497
498 model_config = self.model_config
499 encoder_config = model_config.encoder_config or {}
500
501 return TokenizeParams(
502 max_total_tokens=model_config.max_model_len,
503 do_lower_case=encoder_config.get("do_lower_case", False),
504 add_special_tokens=False,
505 )
506
507 # Step 1: Convert raw inputs to prompts
508 def render_prompt(
509 self,
510 prompt: DictPrompt | bytes,
511 ) -> DictPrompt:
512 if isinstance(prompt, bytes):
513 embeds = safe_load_prompt_embeds(self.model_config, prompt)
514 prompt = EmbedsPrompt(prompt_embeds=embeds)
515
516 return prompt
517
518 def render_prompts(
519 self,
520 prompts: Sequence[DictPrompt | bytes],
521 ) -> list[DictPrompt]:
522 if len(prompts) == 0:
523 raise ValueError("You must pass at least one prompt")
524
525 return [self.render_prompt(prompt) for prompt in prompts]
526
527 async def _render_prompt_async(
528 self,
529 prompt: DictPrompt | bytes,
530 ) -> DictPrompt:
531 if isinstance(prompt, bytes):
532 embeds = await self._safe_load_prompt_embeds_async(
533 self.model_config, prompt
534 )
535 return EmbedsPrompt(prompt_embeds=embeds)
536
537 return prompt
538
539 async def render_prompts_async(
540 self,
541 prompts: Sequence[DictPrompt | bytes],
542 ) -> list[DictPrompt]:
543 if len(prompts) == 0:
544 raise ValueError("You must pass at least one prompt")
545
546 return await asyncio.gather(
547 *(self._render_prompt_async(prompt) for prompt in prompts)
548 )
549
550 @abstractmethod
551 def render_messages(
552 self,
553 messages: list["ChatCompletionMessageParam"],
554 params: ChatParams,
555 ) -> tuple[list["ConversationMessage"], DictPrompt]:
556 raise NotImplementedError
557
558 async def render_messages_async(
559 self,
560 messages: list["ChatCompletionMessageParam"],
561 params: ChatParams,
562 ) -> tuple[list["ConversationMessage"], DictPrompt]:
563 return self.render_messages(messages, params)
564
565 # Step 2: Tokenize prompts if necessary
566 def _can_produce_offsets(self) -> bool:
567 """Whether this renderer's tokenizer can emit char-level offsets.
568
569 Defaults to False; only renderers backed by an HF fast tokenizer
570 (see ``HfRenderer``) can produce ``offset_mapping``.
571 """
572 return False
573
574 def _wants_offsets(
575 self,
576 prompt: "TextPrompt",
577 params: "TokenizeParams",
578 ) -> bool:
579 return (
580 params.return_token_offsets
581 and self._can_produce_offsets()
582 and not prompt.get("multi_modal_data")
583 and not prompt.get("multi_modal_uuids")
584 )
585
586 @staticmethod
587 def _build_tokens_prompt(
588 token_ids: Sequence[int],
589 prompt: "TextPrompt",
590 *,
591 offset_mapping: Sequence[tuple[int, int]] | None = None,
592 ) -> "TokensPrompt":
593 """Build a TokensPrompt from already-extracted token ids.
594
595 ``offset_mapping`` is the per-token ``(start, end)`` sequence from
596 a BatchEncoding; pass it only when offsets were requested, and it
597 is attached as ``prompt_token_offsets``.
598 """
599 if offset_mapping is not None:
600 return TokensPrompt(
601 prompt_token_ids=list(token_ids),
602 prompt_token_offsets=[(int(s), int(e)) for s, e in offset_mapping],
603 **prompt,
604 )
605 return TokensPrompt(prompt_token_ids=list(token_ids), **prompt)
606
607 @staticmethod
608 def _apply_prompt_char_offset(
609 prompt: TokensPrompt, char_offset: int
610 ) -> TokensPrompt:
611 """Map token offsets back to the original prompt after a text pre-trim."""
612 if char_offset == 0:
613 return prompt
614
615 offsets = prompt.get("prompt_token_offsets")
616 if offsets is not None:
617 prompt["prompt_token_offsets"] = [
618 # Fast tokenizers use (0, 0) for special tokens, which are
619 # not character spans in the source prompt.
620 (start, end)
621 if (start, end) == (0, 0)
622 else (start + char_offset, end + char_offset)
623 for start, end in offsets
624 ]
625
626 return prompt
627
628 def _tokenize_prompt(
629 self,
630 prompt: TextPrompt,
631 params: TokenizeParams,
632 ) -> TokensPrompt:
633 tokenizer = self.get_tokenizer()
634 want_offsets = self._wants_offsets(prompt, params)
635 kwargs = params.get_encode_kwargs()
636 if want_offsets:
637 kwargs = {**kwargs, "return_offsets_mapping": True}
638 encoding = tokenizer(prompt["prompt"], **kwargs)
639 return self._build_tokens_prompt(
640 encoding["input_ids"],
641 prompt,
642 offset_mapping=encoding["offset_mapping"] if want_offsets else None,
643 )
644
645 def _detokenize_prompt(self, prompt: TokensPrompt) -> TokensPrompt:
646 tokenizer = self.get_tokenizer()
647 prompt["prompt"] = tokenizer.decode(prompt["prompt_token_ids"])
648
649 return prompt
650
651 async def _detokenize_prompt_async(self, prompt: TokensPrompt) -> TokensPrompt:
652 prompt["prompt"] = await self._async_tokenizer_decode(
653 prompt["prompt_token_ids"]
654 )
655
656 return prompt
657
658 @overload
659 def _tokenize_singleton_prompt(
660 self,
661 prompt: TextPrompt | TokensPrompt,
662 params: TokenizeParams,
663 ) -> TokensPrompt: ...
664
665 @overload
666 def _tokenize_singleton_prompt( # type: ignore[misc]
667 self,
668 prompt: EmbedsPrompt,
669 params: TokenizeParams,
670 ) -> EmbedsPrompt: ...
671
672 def _tokenize_singleton_prompt(
673 self,
674 prompt: SingletonDictPrompt,
675 params: TokenizeParams,
676 ) -> SingletonTokPrompt:
677 if "prompt_token_ids" not in prompt and "prompt_embeds" not in prompt:
678 if not isinstance(prompt.get("prompt"), str):
679 raise TypeError(
680 "Expected prompt['prompt'] to be a string before tokenization; "
681 "use 'prompt_token_ids' for token ID inputs"
682 )
683 prompt_char_offset = params._get_text_truncation_offset(
684 self.tokenizer, prompt["prompt"]
685 )
686 prompt = params.apply_pre_tokenization(self.tokenizer, prompt) # type: ignore[arg-type]
687 prompt = self._tokenize_prompt(prompt, params)
688 prompt = self._apply_prompt_char_offset(prompt, prompt_char_offset)
689
690 if params.needs_detokenization and "prompt" not in prompt:
691 if "prompt_token_ids" not in prompt:
692 raise RuntimeError("Cannot run detokenization on embeddings")
693
694 prompt = self._detokenize_prompt(prompt) # type: ignore[arg-type]
695
696 return params.apply_post_tokenization(self.tokenizer, prompt) # type: ignore[arg-type]
697
698 @overload
699 async def _tokenize_singleton_prompt_async(
700 self,
701 prompt: TextPrompt | TokensPrompt,
702 params: TokenizeParams,
703 ) -> TokensPrompt: ...
704
705 @overload
706 async def _tokenize_singleton_prompt_async( # type: ignore[misc]
707 self,
708 prompt: EmbedsPrompt,
709 params: TokenizeParams,
710 ) -> EmbedsPrompt: ...
711
712 async def _tokenize_singleton_prompt_async(
713 self,
714 prompt: SingletonDictPrompt,
715 params: TokenizeParams,
716 ) -> SingletonTokPrompt:
717 if "prompt_token_ids" not in prompt and "prompt_embeds" not in prompt:
718 if not isinstance(prompt.get("prompt"), str):
719 raise TypeError(
720 "Expected prompt['prompt'] to be a string before tokenization; "
721 "use 'prompt_token_ids' for token ID inputs"
722 )
723 prompt_char_offset = params._get_text_truncation_offset(
724 self.tokenizer, prompt["prompt"]
725 )
726 prompt = params.apply_pre_tokenization(self.tokenizer, prompt) # type: ignore[arg-type]
727 prompt = await self._tokenize_prompt_async(prompt, params)
728 prompt = self._apply_prompt_char_offset(prompt, prompt_char_offset)
729
730 if params.needs_detokenization and "prompt" not in prompt:
731 if "prompt_token_ids" not in prompt:
732 raise RuntimeError("Cannot run detokenization on embeddings")
733
734 prompt = await self._detokenize_prompt_async(prompt) # type: ignore[arg-type]
735
736 return params.apply_post_tokenization(self.tokenizer, prompt) # type: ignore[arg-type]
737
738 def _tokenize_enc_dec_prompt(
739 self,
740 prompt: EncoderDecoderDictPrompt,
741 params: TokenizeParams,
742 ) -> EncoderDecoderTokPrompt:
743 enc_prompt, dec_prompt = (
744 self._tokenize_singleton_prompt(prompt["encoder_prompt"], params),
745 (
746 None
747 if prompt["decoder_prompt"] is None
748 else self._tokenize_singleton_prompt(prompt["decoder_prompt"], params)
749 ),
750 )
751
752 return EncoderDecoderTokPrompt(
753 encoder_prompt=enc_prompt,
754 decoder_prompt=dec_prompt,
755 )
756
757 async def _tokenize_enc_dec_prompt_async(
758 self,
759 prompt: EncoderDecoderDictPrompt,
760 params: TokenizeParams,
761 ) -> EncoderDecoderTokPrompt:
762 enc_prompt, dec_prompt = await asyncio.gather(
763 self._tokenize_singleton_prompt_async(prompt["encoder_prompt"], params),
764 (
765 asyncio.sleep(0)
766 if prompt["decoder_prompt"] is None
767 else self._tokenize_singleton_prompt_async(
768 prompt["decoder_prompt"], params
769 )
770 ),
771 )
772
773 return EncoderDecoderTokPrompt(
774 encoder_prompt=enc_prompt,
775 decoder_prompt=dec_prompt,
776 )
777
778 def tokenize_prompt(
779 self,
780 prompt: DictPrompt,
781 params: TokenizeParams,
782 ) -> TokPrompt:
783 if "encoder_prompt" in prompt:
784 return self._tokenize_enc_dec_prompt(prompt, params) # type: ignore[arg-type]
785
786 return self._tokenize_singleton_prompt(prompt, params)
787
788 def tokenize_prompts(
789 self,
790 prompts: Sequence[DictPrompt],
791 params: TokenizeParams,
792 ) -> list[TokPrompt]:
793 return [self.tokenize_prompt(prompt, params) for prompt in prompts]
794
795 async def tokenize_prompt_async(
796 self,
797 prompt: DictPrompt,
798 params: TokenizeParams,
799 ) -> TokPrompt:
800 if "encoder_prompt" in prompt:
801 return await self._tokenize_enc_dec_prompt_async(prompt, params) # type: ignore[arg-type]
802
803 return await self._tokenize_singleton_prompt_async(prompt, params)
804
805 async def tokenize_prompts_async(
806 self,
807 prompts: Sequence[DictPrompt],
808 params: TokenizeParams,
809 ) -> list[TokPrompt]:
810 return await asyncio.gather(
811 *(self.tokenize_prompt_async(prompt, params) for prompt in prompts)
812 )
813
814 # Step 3: Add extra keys to the prompts
815 def _apply_prompt_extras(
816 self,
817 prompts: Sequence[TokPrompt],
818 prompt_extras: dict[str, Any] | None,
819 ):
820 if not prompt_extras:
821 return
822
823 for prompt in prompts:
824 target_prompt = extract_target_prompt(self.model_config, prompt)
825 target_prompt.update(prompt_extras) # type: ignore[arg-type]
826
827 # Step 4: Convert to engine inputs
828 def _validate_mm_uuids(
829 self,
830 mm_data: MultiModalDataDict,
831 mm_data_items: MultiModalDataItems,
832 mm_uuid_items: MultiModalUUIDItems,
833 ) -> None:
834 # NOTE: Keys corresponding to `None` in `mm_data` don't appear in
835 # `mm_data_items`
836 modalities = mm_data.keys() | mm_uuid_items.keys()
837
838 for modality in modalities:
839 data_items = mm_data_items.get(modality)
840 uuid_items = mm_uuid_items.get(modality)
841
842 if data_items is None:
843 if uuid_items is None:
844 raise ValueError(
845 f"multi_modal_data[{modality!r}] is empty but "
846 f"multi_modal_uuids[{modality!r}] is missing."
847 )
848
849 elif uuid_items is not None:
850 if len(data_items) != len(uuid_items):
851 raise ValueError(
852 f"If given, multi_modal_uuids[{modality!r}] must have "
853 f"same length as multi_modal_data[{modality!r}], but "
854 f"got {len(uuid_items)} vs {len(data_items)}."
855 )
856
857 for i, item in enumerate(data_items):
858 if item is None and uuid_items[i] is None:
859 raise ValueError(
860 f"multi_modal_data[{modality!r}][{i}] is empty but "
861 f"multi_modal_uuids[{modality!r}][{i}] is missing."
862 )
863
864 def _process_mm_uuids(
865 self,
866 mm_data: MultiModalDataDict,
867 mm_data_items: MultiModalDataItems,
868 mm_uuid_items: MultiModalUUIDItems,
869 mm_req_id: str,
870 ) -> MultiModalUUIDItems:
871 model_config = self.model_config
872
873 # Skip content hashing when both caches are disabled, but preserve
874 # explicit UUIDs used to identify embeddings across EC instances.
875 if (
876 model_config.multimodal_config
877 and model_config.multimodal_config.mm_processor_cache_gb == 0
878 and not self.config.cache_config.enable_prefix_caching
879 ):
880 mm_uuid_items = dict(mm_uuid_items)
881 for modality, data_count in mm_data_items.get_all_counts().items():
882 uuids = mm_uuid_items.get(modality)
883 if uuids is None:
884 uuids = [None] * data_count
885 mm_uuid_items[modality] = [
886 uuid if uuid is not None else f"{mm_req_id}-{modality}-{i}"
887 for i, uuid in enumerate(uuids)
888 ]
889
890 self._validate_mm_uuids(mm_data, mm_data_items, mm_uuid_items)
891
892 return mm_uuid_items
893
894 def _process_multimodal(
895 self,
896 prompt: list[int],
897 mm_data: MultiModalDataDict,
898 mm_uuids: MultiModalUUIDDict | None,
899 mm_processor_kwargs: Mapping[str, object] | None,
900 media_io_kwargs: Mapping[str, Mapping[str, object]] | None = None,
901 *,
902 skip_mm_cache: bool = False,
903 ) -> "MultiModalInput":
904 mm_processor = self.get_mm_processor()
905
906 mm_req_id = f"renderer{self.api_process_rank}-mm-{self._mm_req_counter.inc(1)}"
907
908 mm_data_items = mm_processor.info.parse_mm_data(mm_data)
909 mm_uuid_items = parse_mm_uuids(mm_uuids)
910
911 mm_uuid_items = self._process_mm_uuids(
912 mm_data, mm_data_items, mm_uuid_items, mm_req_id
913 )
914
915 mm_processor_inputs = MMProcessorInputs(
916 prompt,
917 mm_data_items,
918 mm_uuid_items,
919 hf_processor_mm_kwargs=mm_processor_kwargs or {},
920 media_io_kwargs=media_io_kwargs or {},
921 cache=(
922 self._mm_processor_only_cache
923 if skip_mm_cache
924 else self._mm_processor_cache
925 ),
926 )
927 mm_timing_ctx = self._mm_timing_registry.get(mm_req_id)
928
929 with set_default_torch_num_threads():
930 mm_inputs = mm_processor.apply(mm_processor_inputs, mm_timing_ctx)
931
932 self.update_mm_cache_stats()
933
934 return mm_inputs
935
936 def _process_tokens(
937 self,
938 prompt: TokensPrompt,
939 *,
940 skip_mm_cache: bool = False,
941 ) -> TokensInput | MultiModalInput:
942 """Process token inputs, with multimodal preprocessing offloaded
943 to the shared thread pool in the async variant.
944 """
945 prompt_token_ids = prompt["prompt_token_ids"]
946
947 engine_input: TokensInput | MultiModalInput
948 if multi_modal_data := prompt.get("multi_modal_data"):
949 engine_input = self._process_multimodal(
950 prompt_token_ids,
951 multi_modal_data,
952 mm_processor_kwargs=prompt.get("mm_processor_kwargs"),
953 mm_uuids=prompt.get("multi_modal_uuids"),
954 media_io_kwargs=prompt.get("media_io_kwargs"),
955 skip_mm_cache=skip_mm_cache,
956 )
957 else:
958 engine_input = tokens_input(prompt_token_ids)
959
960 if prompt_text := prompt.get("prompt"):
961 engine_input["prompt"] = prompt_text
962 if cache_salt := prompt.get("cache_salt"):
963 engine_input["cache_salt"] = cache_salt
964 # Narrow the union — `prompt_token_offsets` is only on TokensInput.
965 if engine_input["type"] == "token" and (
966 (offsets := prompt.get("prompt_token_offsets")) is not None
967 ):
968 engine_input["prompt_token_offsets"] = offsets
969
970 return engine_input
971
972 def _process_embeds(self, prompt: EmbedsPrompt) -> EmbedsInput:
973 if not self.model_config.enable_prompt_embeds:
974 raise ValueError(
975 "You must set `--enable-prompt-embeds` to input `prompt_embeds`."
976 )
977
978 prompt_embeds = prompt["prompt_embeds"]
979
980 # prompt_embeds must be (seq_len, hidden_size), but if the user
981 # passes in a batch of size 1, i.e. (1, seq_len, hidden_size),
982 # we can unambiguously process the intent by squeezing the batch
983 # dimension.
984 if prompt_embeds.ndim == 3:
985 prompt_embeds = prompt_embeds.squeeze(dim=0)
986
987 if prompt_embeds.ndim != 2:
988 raise ValueError("prompt_embeds must be of shape (seq_len, hidden_size).")
989
990 # Tensors must be on CPU for serialization between processes
991 # in the MsgpackEncoder. Casting to CPU here ensures that there is no
992 # hidden device transfer in the critical path of generation.
993 prompt_embeds = prompt_embeds.cpu()
994
995 return embeds_input(
996 prompt_embeds=prompt_embeds,
997 cache_salt=prompt.get("cache_salt"),
998 prompt_token_ids=prompt.get("prompt_token_ids"),
999 is_token_ids=prompt.get("prompt_is_token_ids"),
1000 )
1001
1002 async def _process_tokens_async(
1003 self,
1004 prompt: TokensPrompt,
1005 *,
1006 skip_mm_cache: bool = False,
1007 ) -> TokensInput | MultiModalInput:
1008 prompt_token_ids = prompt["prompt_token_ids"]
1009
1010 engine_input: TokensInput | MultiModalInput
1011 if multi_modal_data := prompt.get("multi_modal_data"):
1012 engine_input = await self._process_multimodal_async(
1013 prompt_token_ids,
1014 multi_modal_data,
1015 mm_processor_kwargs=prompt.get("mm_processor_kwargs"),
1016 mm_uuids=prompt.get("multi_modal_uuids"),
1017 media_io_kwargs=prompt.get("media_io_kwargs"),
1018 skip_mm_cache=skip_mm_cache,
1019 )
1020 else:
1021 engine_input = tokens_input(prompt_token_ids)
1022
1023 if prompt_text := prompt.get("prompt"):
1024 engine_input["prompt"] = prompt_text
1025 if cache_salt := prompt.get("cache_salt"):
1026 engine_input["cache_salt"] = cache_salt
1027 # Narrow the union — `prompt_token_offsets` is only on TokensInput.
1028 if engine_input["type"] == "token" and (
1029 (offsets := prompt.get("prompt_token_offsets")) is not None
1030 ):
1031 engine_input["prompt_token_offsets"] = offsets
1032
1033 return engine_input
1034
1035 def _truncate_expanded_prompt(
1036 self,
1037 engine_input: TokensInput | MultiModalInput,
1038 params: TokenizeParams | None,
1039 ) -> None:
1040 """Re-apply `truncate_prompt_tokens` once placeholders are expanded.
1041
1042 `TokenizeParams.apply_post_tokenization` truncates the prompt before
1043 multimodal expansion, so a single placeholder token can expand into
1044 many and push the result back over the requested bound.
1045
1046 Multimodal spans are never cut: a request whose spans would not all
1047 survive intact is rejected, matching the post-expansion rejection in
1048 `vllm.v1.engine.input_processor._validate_model_input`.
1049
1050 Only multimodal inputs can grow after `apply_post_tokenization`, so
1051 everything else returns immediately.
1052 """
1053 if engine_input["type"] != "multimodal":
1054 return
1055
1056 if params is None or params.truncate_prompt_tokens is None:
1057 return
1058
1059 prompt_token_ids = engine_input.get("prompt_token_ids")
1060 if prompt_token_ids is None:
1061 return
1062
1063 num_tokens = len(prompt_token_ids)
1064 truncation = params._truncation_slice(self.tokenizer, num_tokens)
1065 if truncation is None:
1066 return
1067
1068 start, stop, _ = truncation.indices(num_tokens)
1069
1070 mm_placeholders = engine_input["mm_placeholders"]
1071 for modality, positions in mm_placeholders.items():
1072 for position in positions:
1073 end = position.offset + position.length
1074 if position.offset >= start and end <= stop:
1075 continue
1076 raise VLLMValidationError(
1077 f"truncate_prompt_tokens={params.truncate_prompt_tokens} "
1078 f"would split or drop a(n) {modality} item, which spans "
1079 f"tokens {position.offset} to {end} of the "
1080 f"{num_tokens}-token prompt. Multimodal placeholders "
1081 f"expand after truncation is applied, so the limit must "
1082 f"leave every multimodal item intact. Please raise "
1083 f"truncate_prompt_tokens or shorten the multimodal input.",
1084 parameter="truncate_prompt_tokens",
1085 value=params.truncate_prompt_tokens,
1086 )
1087
1088 if start:
1089 engine_input["mm_placeholders"] = {
1090 modality: [
1091 replace(position, offset=position.offset - start)
1092 for position in positions
1093 ]
1094 for modality, positions in mm_placeholders.items()
1095 }
1096
1097 engine_input["prompt_token_ids"] = prompt_token_ids[truncation]
1098
1099 def _process_singleton(
1100 self,
1101 prompt: SingletonTokPrompt,
1102 *,
1103 skip_mm_cache: bool = False,
1104 tok_params: TokenizeParams | None = None,
1105 ) -> SingletonInput:
1106 if "prompt_embeds" in prompt:
1107 return self._process_embeds(prompt) # type: ignore[arg-type]
1108
1109 engine_input = self._process_tokens( # type: ignore[arg-type]
1110 prompt, # type: ignore[arg-type]
1111 skip_mm_cache=skip_mm_cache,
1112 )
1113 self._truncate_expanded_prompt(engine_input, tok_params)
1114 return engine_input
1115
1116 async def _process_singleton_async(
1117 self,
1118 prompt: SingletonTokPrompt,
1119 *,
1120 skip_mm_cache: bool = False,
1121 tok_params: TokenizeParams | None = None,
1122 ) -> SingletonInput:
1123 if "prompt_embeds" in prompt:
1124 return self._process_embeds(prompt) # type: ignore[arg-type]
1125
1126 engine_input = await self._process_tokens_async( # type: ignore[arg-type]
1127 prompt, # type: ignore[arg-type]
1128 skip_mm_cache=skip_mm_cache,
1129 )
1130 self._truncate_expanded_prompt(engine_input, tok_params)
1131 return engine_input
1132
1133 def _get_skip_decoder_start_token(self) -> bool:
1134 """Whether the multimodal processor supplies a complete decoder prefix."""
1135 if self.mm_processor is not None:
1136 from vllm.multimodal.processing import EncDecMultiModalProcessor
1137
1138 if isinstance(self.mm_processor, EncDecMultiModalProcessor):
1139 return self.mm_processor.skip_decoder_start_token
1140 return False
1141
1142 def _process_enc_dec(
1143 self,
1144 prompt: EncoderDecoderTokPrompt,
1145 *,
1146 skip_mm_cache: bool = False,
1147 ) -> EncoderDecoderInput:
1148 enc_prompt = prompt["encoder_prompt"]
1149 dec_prompt = prompt["decoder_prompt"]
1150
1151 return build_enc_dec_input(
1152 encoder_input=self._process_singleton(
1153 enc_prompt, skip_mm_cache=skip_mm_cache
1154 ),
1155 decoder_input=(
1156 None
1157 if dec_prompt is None
1158 else self._process_singleton(dec_prompt, skip_mm_cache=skip_mm_cache)
1159 ),
1160 decoder_start_token_id=self.get_dec_start_token_id(),
1161 skip_decoder_start_token=self._get_skip_decoder_start_token(),
1162 )
1163
1164 async def _process_enc_dec_async(
1165 self,
1166 prompt: EncoderDecoderTokPrompt,
1167 *,
1168 skip_mm_cache: bool = False,
1169 ) -> EncoderDecoderInput:
1170 enc_prompt = prompt["encoder_prompt"]
1171 dec_prompt = prompt["decoder_prompt"]
1172
1173 encoder_input, decoder_input = await asyncio.gather(
1174 self._process_singleton_async(enc_prompt, skip_mm_cache=skip_mm_cache),
1175 (
1176 asyncio.sleep(0)
1177 if dec_prompt is None
1178 else self._process_singleton_async(
1179 dec_prompt, skip_mm_cache=skip_mm_cache
1180 )
1181 ),
1182 )
1183
1184 return build_enc_dec_input(
1185 encoder_input=encoder_input,
1186 decoder_input=decoder_input,
1187 decoder_start_token_id=self.get_dec_start_token_id(),
1188 skip_decoder_start_token=self._get_skip_decoder_start_token(),
1189 )
1190
1191 def process_for_engine(
1192 self,
1193 prompt: TokPrompt,
1194 arrival_time: float,
1195 *,
1196 skip_mm_cache: bool = False,
1197 tok_params: TokenizeParams | None = None,
1198 ) -> EngineInput:
1199 engine_input: EngineInput
1200 if "encoder_prompt" in prompt:
1201 engine_input = self._process_enc_dec(prompt, skip_mm_cache=skip_mm_cache) # type: ignore[arg-type]
1202 else:
1203 engine_input = self._process_singleton(
1204 prompt, skip_mm_cache=skip_mm_cache, tok_params=tok_params
1205 )
1206
1207 engine_input["arrival_time"] = arrival_time
1208
1209 return engine_input
1210
1211 async def process_for_engine_async(
1212 self,
1213 prompt: TokPrompt,
1214 arrival_time: float,
1215 *,
1216 skip_mm_cache: bool = False,
1217 tok_params: TokenizeParams | None = None,
1218 ) -> EngineInput:
1219 engine_input: EngineInput
1220 if "encoder_prompt" in prompt:
1221 engine_input = await self._process_enc_dec_async(
1222 prompt, # type: ignore[arg-type]
1223 skip_mm_cache=skip_mm_cache,
1224 )
1225 else:
1226 engine_input = await self._process_singleton_async(
1227 prompt, skip_mm_cache=skip_mm_cache, tok_params=tok_params
1228 )
1229
1230 engine_input["arrival_time"] = arrival_time
1231
1232 return engine_input
1233
1234 # Top-level methods
1235 def render_cmpl(
1236 self,
1237 prompts: Sequence[DictPrompt | bytes],
1238 tok_params: TokenizeParams | None = None,
1239 *,
1240 prompt_extras: dict[str, Any] | None = None,
1241 skip_mm_cache: bool = False,
1242 ):
1243 arrival_time = time.time()
1244
1245 if tok_params is None:
1246 tok_params = self.default_cmpl_tok_params
1247
1248 dict_prompts = self.render_prompts(prompts)
1249 tok_prompts = self.tokenize_prompts(dict_prompts, tok_params)
1250
1251 self._apply_prompt_extras(tok_prompts, prompt_extras)
1252
1253 return [
1254 self.process_for_engine(
1255 prompt, arrival_time, skip_mm_cache=skip_mm_cache, tok_params=tok_params
1256 )
1257 for prompt in tok_prompts
1258 ]
1259
1260 async def render_cmpl_async(
1261 self,
1262 prompts: Sequence[DictPrompt | bytes],
1263 tok_params: TokenizeParams | None = None,
1264 *,
1265 prompt_extras: dict[str, Any] | None = None,
1266 skip_mm_cache: bool = False,
1267 ):
1268 arrival_time = time.time()
1269
1270 if tok_params is None:
1271 tok_params = self.default_cmpl_tok_params
1272
1273 dict_prompts = await self.render_prompts_async(prompts)
1274 tok_prompts = await self.tokenize_prompts_async(dict_prompts, tok_params)
1275
1276 self._apply_prompt_extras(tok_prompts, prompt_extras)
1277
1278 return await asyncio.gather(
1279 *(
1280 self.process_for_engine_async(
1281 p, arrival_time, skip_mm_cache=skip_mm_cache, tok_params=tok_params
1282 )
1283 for p in tok_prompts
1284 )
1285 )
1286
1287 def render_chat(
1288 self,
1289 conversations: Sequence[list["ChatCompletionMessageParam"]],
1290 chat_params: ChatParams,
1291 tok_params: TokenizeParams | None = None,
1292 *,
1293 prompt_extras: dict[str, Any] | None = None,
1294 skip_mm_cache: bool = False,
1295 ):
1296 arrival_time = time.time()
1297
1298 if tok_params is None:
1299 tok_params = self.default_chat_tok_params
1300
1301 rendered = [
1302 self.render_messages(conversation, chat_params)
1303 for conversation in conversations
1304 ]
1305
1306 out_conversations = list[list["ConversationMessage"]]()
1307 dict_prompts = list[DictPrompt]()
1308 for conv, prompt in rendered:
1309 out_conversations.append(conv)
1310 dict_prompts.append(prompt)
1311
1312 tok_prompts = self.tokenize_prompts(dict_prompts, tok_params)
1313
1314 prompt_extras = dict(prompt_extras or {})
1315 prompt_extras["media_io_kwargs"] = chat_params.media_io_kwargs or {}
1316 self._apply_prompt_extras(tok_prompts, prompt_extras)
1317
1318 eng_prompts = [
1319 self.process_for_engine(
1320 prompt, arrival_time, skip_mm_cache=skip_mm_cache, tok_params=tok_params
1321 )
1322 for prompt in tok_prompts
1323 ]
1324
1325 return out_conversations, eng_prompts
1326
1327 async def render_chat_async(
1328 self,
1329 conversations: Sequence[list["ChatCompletionMessageParam"]],
1330 chat_params: ChatParams,
1331 tok_params: TokenizeParams | None = None,
1332 *,
1333 prompt_extras: dict[str, Any] | None = None,
1334 skip_mm_cache: bool = False,
1335 ):
1336 arrival_time = time.time()
1337
1338 if tok_params is None:
1339 tok_params = self.default_chat_tok_params
1340
1341 rendered = [
1342 self.render_messages_async(conversation, chat_params)
1343 for conversation in conversations
1344 ]
1345
1346 out_conversations = list[list["ConversationMessage"]]()
1347 dict_prompts = list[DictPrompt]()
1348 for conv, prompt in await asyncio.gather(*rendered):
1349 out_conversations.append(conv)
1350 dict_prompts.append(prompt)
1351
1352 tok_prompts = await self.tokenize_prompts_async(dict_prompts, tok_params)
1353
1354 prompt_extras = dict(prompt_extras or {})
1355 prompt_extras["media_io_kwargs"] = chat_params.media_io_kwargs or {}
1356 self._apply_prompt_extras(tok_prompts, prompt_extras)
1357
1358 eng_prompts = await asyncio.gather(
1359 *(
1360 self.process_for_engine_async(
1361 p, arrival_time, skip_mm_cache=skip_mm_cache, tok_params=tok_params
1362 )
1363 for p in tok_prompts
1364 )
1365 )
1366
1367 return out_conversations, eng_prompts