From c231d1f2906ed3f9a995d84a6c7ba67e6914fb16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20P=C3=A9rez=20de=20Algaba?= <124347725+jperezdealgaba@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:08:51 +0200 Subject: [PATCH] fix(security): bound tokenizer work when explicit truncation_side is set (#47007) Signed-off-by: jperezde Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- tests/renderers/test_completions.py | 81 +++++++++++++++++++++++++++++ vllm/renderers/params.py | 22 ++++---- 2 files changed, 94 insertions(+), 9 deletions(-) diff --git a/tests/renderers/test_completions.py b/tests/renderers/test_completions.py index 76e88f4213..d184eb8621 100644 --- a/tests/renderers/test_completions.py +++ b/tests/renderers/test_completions.py @@ -64,12 +64,14 @@ class DummyTokenizer: def __post_init__(self) -> None: self._captured_encode_kwargs: dict = {} + self._captured_text_len: int = 0 def decode(self, tokens: list[int]): return str(tokens) def encode(self, text: str, **kwargs): self._captured_encode_kwargs = kwargs + self._captured_text_len = len(text) in_length = len(text) truncation = kwargs.get("truncation") @@ -366,6 +368,85 @@ class TestRenderPrompt: assert results[0]["prompt_token_ids"] == tokens assert results[0]["prompt"] == "[1, 2, 3, 4]" + def test_explicit_side_tokenizer_unbounded(self): + renderer = _build_renderer(MockModelConfig()) + + prompts = renderer.render_prompts( + _preprocess_prompt(renderer.model_config, "x" * 500) + ) + results = renderer.tokenize_prompts( + prompts, + TokenizeParams( + max_total_tokens=100, + truncate_prompt_tokens=4, + truncation_side="left", + ), + ) + + assert len(results) == 1 + assert len(results[0]["prompt_token_ids"]) == 4 + + kwargs = renderer.tokenizer._captured_encode_kwargs + assert kwargs["truncation"] is False + + def test_explicit_side_left_text(self): + renderer = _build_renderer(MockModelConfig()) + + prompts = renderer.render_prompts( + _preprocess_prompt(renderer.model_config, "x" * 50) + ) + results = renderer.tokenize_prompts( + prompts, + TokenizeParams( + max_total_tokens=100, + truncate_prompt_tokens=5, + truncation_side="left", + ), + ) + + assert len(results) == 1 + assert len(results[0]["prompt_token_ids"]) == 5 + assert results[0]["prompt_token_ids"] == list(range(45, 50)) + + def test_explicit_side_right_text(self): + renderer = _build_renderer(MockModelConfig()) + + prompts = renderer.render_prompts( + _preprocess_prompt(renderer.model_config, "x" * 50) + ) + results = renderer.tokenize_prompts( + prompts, + TokenizeParams( + max_total_tokens=100, + truncate_prompt_tokens=5, + truncation_side="right", + ), + ) + + assert len(results) == 1 + assert len(results[0]["prompt_token_ids"]) == 5 + assert results[0]["prompt_token_ids"] == list(range(5)) + + def test_explicit_side_text_pretokenization_guard(self): + renderer = _build_renderer(MockModelConfig(), max_chars_per_token=1) + + prompts = renderer.render_prompts( + _preprocess_prompt(renderer.model_config, "x" * 500) + ) + results = renderer.tokenize_prompts( + prompts, + TokenizeParams( + max_total_tokens=100, + truncate_prompt_tokens=4, + truncation_side="left", + ), + ) + + assert len(results) == 1 + assert len(results[0]["prompt_token_ids"]) == 4 + + assert renderer.tokenizer._captured_text_len <= 100 + class TestRenderEmbedPrompt: def _create_test_embed_bytes(self, tensor: torch.Tensor) -> bytes: diff --git a/vllm/renderers/params.py b/vllm/renderers/params.py index 7e3670c738..a07a492306 100644 --- a/vllm/renderers/params.py +++ b/vllm/renderers/params.py @@ -314,10 +314,11 @@ class TokenizeParams: # while still failing `self._token_len_check` as expected by users max_length = self.max_input_tokens + 1 - # Explicit truncation-side overrides require the full token sequence so - # we can slice from the requested side in _token_truncation. Disable - # tokenizer-level truncation because generation tokenizers default to - # left truncation while callers may request right truncation. + # Explicit truncation-side overrides require the full token sequence + # so we can slice from the requested side in _token_truncation. + # Disable tokenizer-level truncation because its default side may + # differ from the requested side. The defense against unbounded + # tokenization lives in _text_len_check (character-level pre-trim). if self.truncation_side is not None and self.truncate_prompt_tokens is not None: return dict( truncation=False, @@ -333,15 +334,13 @@ class TokenizeParams: def _text_len_check(self, tokenizer: TokenizerLike | None, text: str) -> str: """Apply length checks to prompt text if necessary.""" max_input_tokens = self.max_input_tokens - if max_input_tokens is None: + if max_input_tokens is None or tokenizer is None: return text - if self.truncate_prompt_tokens is None and tokenizer is not None: - max_input_chars = max_input_tokens * tokenizer.max_chars_per_token + max_input_chars = max_input_tokens * tokenizer.max_chars_per_token + if self.truncate_prompt_tokens is None: if len(text) > max_input_chars: - # To save resources, fail the request outright without even - # attempting tokenization raise VLLMValidationError( f"This model's maximum context length is " f"{self.max_total_tokens} tokens. However, you requested " @@ -354,6 +353,11 @@ class TokenizeParams: parameter="input_text", value=len(text), ) + elif self.truncation_side is not None and len(text) > max_input_chars: + if self.truncation_side == "left": + text = text[-max_input_chars:] + else: + text = text[:max_input_chars] return text