main
py 556 lines 20.4 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 from dataclasses import dataclass, field
4 from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast
5
6 from vllm.exceptions import VLLMValidationError
7 from vllm.inputs import EmbedsPrompt, TextPrompt, TokensPrompt
8 from vllm.logger import init_logger
9 from vllm.multimodal.media.connector import merge_media_io_kwargs
10 from vllm.tokenizers import TokenizerLike
11 from vllm.utils.import_utils import LazyLoader
12
13 if TYPE_CHECKING:
14 import torch
15
16 from vllm.entrypoints.chat_utils import ChatTemplateContentFormatOption
17 else:
18 torch = LazyLoader("torch", globals(), "torch")
19
20 ChatTemplateContentFormatOption = object
21
22 logger = init_logger(__name__)
23
24
25 _S = TypeVar("_S", list[int], "torch.Tensor")
26
27 # Prompt keys whose entry i describes token i of the prompt, so they must be
28 # reduced with the same slice as the prompt tokens themselves. Anything added
29 # here is truncated by `TokenizeParams.apply_post_tokenization`.
30 _PARALLEL_TO_PROMPT_TOKENS = (
31 "prompt_token_offsets",
32 "prompt_is_token_ids",
33 )
34
35
36 def merge_kwargs(
37 defaults: dict[str, Any] | None,
38 overrides: dict[str, Any] | None,
39 /,
40 *,
41 unset_values: tuple[object, ...] = (None, "auto"),
42 ) -> dict[str, Any]:
43 if defaults is None:
44 defaults = {}
45 if overrides is None:
46 overrides = {}
47
48 return defaults | {k: v for k, v in overrides.items() if v not in unset_values}
49
50
51 def recursively_merge_kwargs(
52 defaults: dict[str, Any] | None,
53 overrides: dict[str, Any] | None,
54 /,
55 *,
56 unset_values: tuple[object, ...] = (None, "auto"),
57 ) -> dict[str, Any]:
58 if defaults is None:
59 defaults = {}
60 if overrides is None:
61 overrides = {}
62
63 merged = dict(defaults)
64
65 for k, v in overrides.items():
66 if v in unset_values:
67 continue
68
69 if k in merged and isinstance(merged[k], dict) and isinstance(v, dict):
70 merged[k] = recursively_merge_kwargs(
71 merged[k], v, unset_values=unset_values
72 )
73 else:
74 merged[k] = v
75
76 return merged
77
78
79 @dataclass(frozen=True)
80 class ChatParams:
81 """Configuration to control how to parse chat messages."""
82
83 chat_template: str | None = None
84 """The chat template to apply."""
85
86 chat_template_content_format: "ChatTemplateContentFormatOption" = "auto"
87 """The format of the chat template."""
88
89 chat_template_kwargs: dict[str, Any] = field(default_factory=dict)
90 """The kwargs to pass to the chat template."""
91
92 media_io_kwargs: dict[str, dict[str, Any]] | None = None
93 """Per-modality kwargs for media I/O (loading/decoding images, videos, etc.)."""
94
95 mm_processor_kwargs: dict[str, Any] | None = None
96 """The kwargs to pass to the multi-modal processor."""
97
98 tool_choice: Any | None = None
99 """Request-level tool choice for renderers that need API metadata."""
100
101 response_format: Any | None = None
102 """Request-level response format for renderers that need API metadata."""
103
104 def with_defaults(
105 self,
106 default_chat_template_kwargs: dict[str, Any] | None = None,
107 default_media_io_kwargs: dict[str, dict[str, Any]] | None = None,
108 default_mm_processor_kwargs: dict[str, Any] | None = None,
109 ):
110 if (
111 not default_chat_template_kwargs
112 and not default_media_io_kwargs
113 and not default_mm_processor_kwargs
114 ):
115 return self
116
117 return ChatParams(
118 chat_template=self.chat_template,
119 chat_template_content_format=self.chat_template_content_format,
120 chat_template_kwargs=merge_kwargs(
121 default_chat_template_kwargs,
122 self.chat_template_kwargs,
123 ),
124 media_io_kwargs=merge_media_io_kwargs(
125 default_media_io_kwargs,
126 self.media_io_kwargs,
127 ),
128 mm_processor_kwargs=recursively_merge_kwargs(
129 default_mm_processor_kwargs,
130 self.mm_processor_kwargs,
131 ),
132 tool_choice=self.tool_choice,
133 response_format=self.response_format,
134 )
135
136 def get_apply_chat_template_kwargs(self) -> dict[str, Any]:
137 """The arguments to pass to `tokenizer.apply_chat_template`."""
138 return merge_kwargs(
139 self.chat_template_kwargs,
140 dict(chat_template=self.chat_template, return_dict=False),
141 )
142
143
144 @dataclass(frozen=True)
145 class TokenizeParams:
146 """Configuration to control how prompts are tokenized."""
147
148 max_total_tokens: int | None
149 """
150 Maximum allowed number of input + output tokens.
151
152 Usually, this refers to the model's context length.
153 """
154
155 max_output_tokens: int = 0
156 """Maximum requested number of output tokens."""
157
158 pad_prompt_tokens: int | None = None
159 """
160 Number of tokens to pad to:
161 - `None` means no padding.
162 - `-1` maps to `max_input_tokens`.
163 """
164
165 truncate_prompt_tokens: int | None = None
166 """
167 Number of tokens to keep:
168 - `None` means no truncation.
169 - `-1` maps to `max_input_tokens`.
170 """
171
172 truncation_side: Literal["left", "right"] | None = None
173 """
174 Which side to truncate from when ``truncate_prompt_tokens`` is active:
175 - ``"right"`` keeps the first N tokens (truncate from the end).
176 - ``"left"`` keeps the last N tokens (truncate from the start).
177 - ``None`` falls back to the tokenizer default.
178 """
179
180 do_lower_case: bool = False
181 """Whether to normalize text to lower case before tokenization."""
182
183 add_special_tokens: bool = True
184 """Whether to add special tokens."""
185
186 return_token_offsets: bool = False
187 """If true, request char-level (start, end) offsets per token. Honored
188 only for Fast (Rust-backed) tokenizers with text input and no multimodal
189 data; otherwise silently ignored."""
190
191 needs_detokenization: bool = False
192 """
193 Whether the tokenized prompt needs to contain the original text.
194
195 Not to be confused with `SamplingParams.detokenize` which deals
196 with the output generated by the model.
197 """
198
199 max_total_tokens_param: str = "max_total_tokens"
200 """Override this to edit the message for validation errors."""
201
202 max_output_tokens_param: str = "max_output_tokens"
203 """Override this to edit the message for validation errors."""
204
205 truncate_prompt_tokens_param: str = "truncate_prompt_tokens"
206 """Override this to edit the message for validation errors."""
207
208 @property
209 def max_input_tokens(self) -> int | None:
210 """Maximum allowed number of input tokens."""
211 if self.max_total_tokens is None:
212 return None
213
214 return self.max_total_tokens - self.max_output_tokens
215
216 def __post_init__(self) -> None:
217 max_total_tokens = self.max_total_tokens
218 max_output_tokens = self.max_output_tokens
219 max_input_tokens = self.max_input_tokens
220 truncate_prompt_tokens = self.truncate_prompt_tokens
221
222 if self.truncation_side not in (None, "left", "right"):
223 raise VLLMValidationError(
224 "`truncation_side` must be either 'left' or 'right'.",
225 parameter="truncation_side",
226 value=self.truncation_side,
227 )
228
229 if (
230 max_output_tokens is not None
231 and max_total_tokens is not None
232 and max_output_tokens > max_total_tokens
233 ):
234 raise VLLMValidationError(
235 f"{self.max_output_tokens_param}={max_output_tokens} "
236 f"cannot be greater than "
237 f"{self.max_total_tokens_param}={max_total_tokens}. "
238 f"Please request fewer output tokens.",
239 parameter=self.max_output_tokens_param,
240 value=max_output_tokens,
241 )
242
243 if (
244 max_input_tokens is not None
245 and truncate_prompt_tokens is not None
246 and truncate_prompt_tokens > max_input_tokens
247 ):
248 raise VLLMValidationError(
249 f"{self.truncate_prompt_tokens_param}={truncate_prompt_tokens} "
250 f"cannot be greater than {self.max_total_tokens_param} - "
251 f"{self.max_output_tokens_param} = {max_input_tokens}. "
252 f"Please request a smaller truncation size.",
253 parameter=self.truncate_prompt_tokens_param,
254 value=truncate_prompt_tokens,
255 )
256
257 def with_kwargs(self, **tokenization_kwargs: Any):
258 max_length = tokenization_kwargs.pop("max_length", self.max_input_tokens)
259 pad_prompt_tokens = tokenization_kwargs.pop(
260 "pad_prompt_tokens", self.pad_prompt_tokens
261 )
262 truncate_prompt_tokens = tokenization_kwargs.pop(
263 "truncate_prompt_tokens", self.truncate_prompt_tokens
264 )
265 truncation_side = tokenization_kwargs.pop(
266 "truncation_side", self.truncation_side
267 )
268 do_lower_case = tokenization_kwargs.pop("do_lower_case", self.do_lower_case)
269 add_special_tokens = tokenization_kwargs.pop(
270 "add_special_tokens", self.add_special_tokens
271 )
272 needs_detokenization = tokenization_kwargs.pop(
273 "needs_detokenization", self.needs_detokenization
274 )
275
276 # https://huggingface.co/docs/transformers/en/pad_truncation
277 if padding := tokenization_kwargs.pop("padding", None):
278 if padding == "max_length":
279 pad_prompt_tokens = max_length
280 elif padding in (False, "do_not_pad"):
281 pad_prompt_tokens = None
282 else:
283 # To emit the below warning
284 tokenization_kwargs["padding"] = padding
285
286 if truncation := tokenization_kwargs.pop("truncation", None):
287 if truncation in (True, "longest_first"):
288 truncate_prompt_tokens = max_length
289 elif truncation in (False, "do_not_truncate"):
290 truncate_prompt_tokens = None
291 else:
292 # To emit the below warning
293 tokenization_kwargs["truncation"] = truncation
294
295 if tokenization_kwargs:
296 logger.warning(
297 "The following tokenization arguments are not supported "
298 "by vLLM Renderer and will be ignored: %s",
299 tokenization_kwargs,
300 )
301
302 max_total_tokens = self.max_total_tokens
303
304 return TokenizeParams(
305 max_total_tokens=max_total_tokens,
306 max_output_tokens=(
307 0
308 if max_total_tokens is None or max_length is None
309 else max_total_tokens - max_length
310 ),
311 pad_prompt_tokens=pad_prompt_tokens,
312 truncate_prompt_tokens=truncate_prompt_tokens,
313 truncation_side=truncation_side,
314 do_lower_case=do_lower_case,
315 add_special_tokens=add_special_tokens,
316 needs_detokenization=needs_detokenization,
317 )
318
319 def get_encode_kwargs(self) -> dict[str, Any]:
320 """The arguments to pass to `tokenizer.encode`."""
321 max_length = self.truncate_prompt_tokens
322 if max_length is not None and max_length < 0:
323 max_length = self.max_input_tokens
324 elif max_length is None and self.max_input_tokens is not None:
325 # This prevents tokenization from taking up more resources than necessary
326 # while still failing `self._token_len_check` as expected by users
327 max_length = self.max_input_tokens + 1
328
329 # Explicit truncation-side overrides require the full token sequence
330 # so we can slice from the requested side in _token_truncation.
331 # Disable tokenizer-level truncation because its default side may
332 # differ from the requested side. The defense against unbounded
333 # tokenization lives in _text_len_check (character-level pre-trim).
334 if self.truncation_side is not None and self.truncate_prompt_tokens is not None:
335 return dict(
336 truncation=False,
337 add_special_tokens=self.add_special_tokens,
338 )
339
340 return dict(
341 truncation=max_length is not None,
342 max_length=max_length,
343 add_special_tokens=self.add_special_tokens,
344 )
345
346 def _text_len_check(self, tokenizer: TokenizerLike | None, text: str) -> str:
347 """Apply length checks to prompt text if necessary."""
348 max_input_tokens = self.max_input_tokens
349 if max_input_tokens is None or tokenizer is None:
350 return text
351
352 max_input_chars = max_input_tokens * tokenizer.max_chars_per_token
353
354 if self.truncate_prompt_tokens is None:
355 if len(text) > max_input_chars:
356 raise VLLMValidationError(
357 f"This model's maximum context length is "
358 f"{self.max_total_tokens} tokens. However, you requested "
359 f"{self.max_output_tokens} output tokens and your prompt "
360 f"contains {len(text)} characters (more than "
361 f"{max_input_chars} characters, which is the upper bound "
362 f"for {max_input_tokens} input tokens). "
363 f"Please reduce the length of the input prompt or the "
364 f"number of requested output tokens.",
365 parameter="input_text",
366 value=len(text),
367 )
368 elif self.truncation_side is not None and len(text) > max_input_chars:
369 if self.truncation_side == "left":
370 text = text[-max_input_chars:]
371 else:
372 text = text[:max_input_chars]
373
374 return text
375
376 def _get_text_truncation_offset(
377 self, tokenizer: TokenizerLike | None, text: str
378 ) -> int:
379 """Return the number of source characters removed from the left.
380
381 ``_text_len_check`` pre-truncates long text before tokenization when
382 an explicit truncation side is requested. Fast-tokenizer offsets are
383 then relative to that shortened string, so callers need this prefix
384 length to map them back to the original prompt.
385 """
386 max_input_tokens = self.max_input_tokens
387 if (
388 max_input_tokens is None
389 or tokenizer is None
390 or self.truncate_prompt_tokens is None
391 or self.truncation_side != "left"
392 ):
393 return 0
394
395 max_input_chars = max_input_tokens * tokenizer.max_chars_per_token
396 if max_input_chars <= 0:
397 return 0
398
399 return max(len(text) - max_input_chars, 0)
400
401 def _text_lowercase(self, tokenizer: TokenizerLike | None, text: str) -> str:
402 """Apply lowercase to prompt text if necessary."""
403 return text.lower() if self.do_lower_case else text
404
405 def _validate_text(self, tokenizer: TokenizerLike | None, text: str) -> str:
406 """Apply all validators to prompt text."""
407 for validator in (
408 self._text_len_check,
409 self._text_lowercase,
410 ):
411 text = validator(tokenizer, text)
412
413 return text
414
415 def apply_pre_tokenization(
416 self,
417 tokenizer: TokenizerLike | None,
418 prompt: TextPrompt,
419 ) -> TextPrompt:
420 """Ensure that the prompt meets the requirements set out by this config.
421 If that is not possible, raise a `VLLMValidationError`.
422
423 This method is run before tokenization occurs.
424 """
425 prompt["prompt"] = self._validate_text(tokenizer, prompt["prompt"])
426
427 return prompt
428
429 def _token_padding(self, tokenizer: TokenizerLike | None, tokens: _S) -> _S:
430 """Apply padding to prompt tokens if necessary."""
431 pad_length = self.pad_prompt_tokens
432 if pad_length is not None and pad_length < 0:
433 pad_length = self.max_input_tokens
434
435 if pad_length is None or pad_length <= len(tokens):
436 return tokens
437
438 if tokenizer is None:
439 raise VLLMValidationError(
440 "Cannot pad tokens when `skip_tokenizer_init=True`",
441 parameter="pad_prompt_tokens",
442 )
443 if not isinstance(tokens, list):
444 raise VLLMValidationError(
445 "Cannot pad tokens for embedding inputs",
446 parameter="pad_prompt_tokens",
447 )
448
449 return tokens + [tokenizer.pad_token_id] * (pad_length - len(tokens))
450
451 def _truncation_slice(
452 self, tokenizer: TokenizerLike | None, length: int
453 ) -> slice | None:
454 """The slice truncation applies to a sequence of `length` tokens.
455
456 `None` means no truncation. Anything parallel to the prompt tokens
457 must be reduced with this same slice to stay aligned with them; list
458 such keys in `_PARALLEL_TO_PROMPT_TOKENS`.
459 """
460 max_length = self.truncate_prompt_tokens
461 if max_length is not None and max_length < 0:
462 max_length = self.max_input_tokens
463
464 if max_length is None or max_length >= length:
465 return None
466 if max_length == 0:
467 return slice(0, 0)
468
469 side = self.truncation_side or (
470 tokenizer.truncation_side if tokenizer is not None else None
471 )
472 if side == "left":
473 return slice(-max_length, None)
474
475 return slice(0, max_length)
476
477 def _token_truncation(self, tokenizer: TokenizerLike | None, tokens: _S) -> _S:
478 """Apply truncation to prompt tokens if necessary."""
479 truncation = self._truncation_slice(tokenizer, len(tokens))
480 if truncation is None:
481 return tokens
482
483 return tokens[truncation]
484
485 def _token_len_check(self, tokenizer: TokenizerLike | None, tokens: _S) -> _S:
486 """Apply length checks to prompt tokens if necessary."""
487 max_input_tokens = self.max_input_tokens
488 if max_input_tokens is None:
489 return tokens
490
491 if len(tokens) > max_input_tokens:
492 token_count = len(tokens)
493 # The tokenizer may have truncated the prompt to
494 # max_input_tokens + 1 (see get_encode_kwargs), so the
495 # actual prompt length could be larger.
496 qualifier = "at least " if token_count == max_input_tokens + 1 else ""
497 total = token_count + self.max_output_tokens
498 raise VLLMValidationError(
499 f"This model's maximum context length is "
500 f"{self.max_total_tokens} tokens. However, you requested "
501 f"{self.max_output_tokens} output tokens and your prompt "
502 f"contains {qualifier}{token_count} input tokens, "
503 f"for a total of {qualifier}{total} tokens. "
504 f"Please reduce the length of the input prompt or the "
505 f"number of requested output tokens.",
506 parameter="input_tokens",
507 value=token_count,
508 )
509
510 return tokens
511
512 def _validate_tokens(self, tokenizer: TokenizerLike | None, tokens: _S) -> _S:
513 """Apply all validators to a token sequence."""
514 # Truncation runs before padding, matching the Transformers pipeline
515 # these parameters are named after. Padding first would let a
516 # subsequent left-side truncation keep only the pad tokens it just
517 # appended, discarding the prompt entirely.
518 for validator in (
519 self._token_truncation,
520 self._token_padding,
521 self._token_len_check,
522 ):
523 tokens = validator(tokenizer, tokens)
524
525 return tokens
526
527 def apply_post_tokenization(
528 self,
529 tokenizer: TokenizerLike | None,
530 prompt: TokensPrompt | EmbedsPrompt,
531 ) -> TokensPrompt | EmbedsPrompt:
532 """Ensure that the prompt meets the requirements set out by this config.
533 If that is not possible, raise a `VLLMValidationError`.
534
535 This method is run after tokenization occurs.
536 """
537 if "prompt_token_ids" in prompt:
538 prompt["prompt_token_ids"] = self._validate_tokens( # type: ignore[typeddict-unknown-key]
539 tokenizer,
540 prompt["prompt_token_ids"], # type: ignore[typeddict-item]
541 )
542 if "prompt_embeds" in prompt:
543 prompt["prompt_embeds"] = self._validate_tokens( # type: ignore[typeddict-unknown-key]
544 tokenizer,
545 prompt["prompt_embeds"], # type: ignore[typeddict-item]
546 )
547 prompt_dict = cast(dict, prompt)
548 for key in _PARALLEL_TO_PROMPT_TOKENS:
549 parallel = prompt_dict.get(key)
550 if parallel is None:
551 continue
552 truncation = self._truncation_slice(tokenizer, len(parallel))
553 if truncation is not None:
554 prompt_dict[key] = parallel[truncation]
555
556 return prompt