fix(security): bound tokenizer work when explicit truncation_side is set (#47007)

Signed-off-by: jperezde <jperezde@redhat.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Juan Pérez de Algaba committed Jun 30, 2026 at 17:08 UTC c231d1f2906ed3f9a995d84a6c7ba67e6914fb16
2 files changed +94 -9
tests/renderers/test_completions.py
+81
@@ -64,12 +64,14 @@ class DummyTokenizer:
64
65 def __post_init__(self) -> None:
66 self._captured_encode_kwargs: dict = {}
67 + self._captured_text_len: int = 0
68
69 def decode(self, tokens: list[int]):
70 return str(tokens)
71
72 def encode(self, text: str, **kwargs):
73 self._captured_encode_kwargs = kwargs
74 + self._captured_text_len = len(text)
75
76 in_length = len(text)
77 truncation = kwargs.get("truncation")
@@ -366,6 +368,85 @@ class TestRenderPrompt:
368 assert results[0]["prompt_token_ids"] == tokens
369 assert results[0]["prompt"] == "[1, 2, 3, 4]"
370
371 + def test_explicit_side_tokenizer_unbounded(self):
372 + renderer = _build_renderer(MockModelConfig())
373 +
374 + prompts = renderer.render_prompts(
375 + _preprocess_prompt(renderer.model_config, "x" * 500)
376 + )
377 + results = renderer.tokenize_prompts(
378 + prompts,
379 + TokenizeParams(
380 + max_total_tokens=100,
381 + truncate_prompt_tokens=4,
382 + truncation_side="left",
383 + ),
384 + )
385 +
386 + assert len(results) == 1
387 + assert len(results[0]["prompt_token_ids"]) == 4
388 +
389 + kwargs = renderer.tokenizer._captured_encode_kwargs
390 + assert kwargs["truncation"] is False
391 +
392 + def test_explicit_side_left_text(self):
393 + renderer = _build_renderer(MockModelConfig())
394 +
395 + prompts = renderer.render_prompts(
396 + _preprocess_prompt(renderer.model_config, "x" * 50)
397 + )
398 + results = renderer.tokenize_prompts(
399 + prompts,
400 + TokenizeParams(
401 + max_total_tokens=100,
402 + truncate_prompt_tokens=5,
403 + truncation_side="left",
404 + ),
405 + )
406 +
407 + assert len(results) == 1
408 + assert len(results[0]["prompt_token_ids"]) == 5
409 + assert results[0]["prompt_token_ids"] == list(range(45, 50))
410 +
411 + def test_explicit_side_right_text(self):
412 + renderer = _build_renderer(MockModelConfig())
413 +
414 + prompts = renderer.render_prompts(
415 + _preprocess_prompt(renderer.model_config, "x" * 50)
416 + )
417 + results = renderer.tokenize_prompts(
418 + prompts,
419 + TokenizeParams(
420 + max_total_tokens=100,
421 + truncate_prompt_tokens=5,
422 + truncation_side="right",
423 + ),
424 + )
425 +
426 + assert len(results) == 1
427 + assert len(results[0]["prompt_token_ids"]) == 5
428 + assert results[0]["prompt_token_ids"] == list(range(5))
429 +
430 + def test_explicit_side_text_pretokenization_guard(self):
431 + renderer = _build_renderer(MockModelConfig(), max_chars_per_token=1)
432 +
433 + prompts = renderer.render_prompts(
434 + _preprocess_prompt(renderer.model_config, "x" * 500)
435 + )
436 + results = renderer.tokenize_prompts(
437 + prompts,
438 + TokenizeParams(
439 + max_total_tokens=100,
440 + truncate_prompt_tokens=4,
441 + truncation_side="left",
442 + ),
443 + )
444 +
445 + assert len(results) == 1
446 + assert len(results[0]["prompt_token_ids"]) == 4
447 +
448 + assert renderer.tokenizer._captured_text_len <= 100
449 +
450
451 class TestRenderEmbedPrompt:
452 def _create_test_embed_bytes(self, tensor: torch.Tensor) -> bytes:
vllm/renderers/params.py
+13 -9
@@ -314,10 +314,11 @@ class TokenizeParams:
314 # while still failing `self._token_len_check` as expected by users
315 max_length = self.max_input_tokens + 1
316
317 - # Explicit truncation-side overrides require the full token sequence so
318 - # we can slice from the requested side in _token_truncation. Disable
319 - # tokenizer-level truncation because generation tokenizers default to
320 - # left truncation while callers may request right truncation.
317 + # Explicit truncation-side overrides require the full token sequence
318 + # so we can slice from the requested side in _token_truncation.
319 + # Disable tokenizer-level truncation because its default side may
320 + # differ from the requested side. The defense against unbounded
321 + # tokenization lives in _text_len_check (character-level pre-trim).
322 if self.truncation_side is not None and self.truncate_prompt_tokens is not None:
323 return dict(
324 truncation=False,
@@ -333,15 +334,13 @@ class TokenizeParams:
334 def _text_len_check(self, tokenizer: TokenizerLike | None, text: str) -> str:
335 """Apply length checks to prompt text if necessary."""
336 max_input_tokens = self.max_input_tokens
336 - if max_input_tokens is None:
337 + if max_input_tokens is None or tokenizer is None:
338 return text
339
339 - if self.truncate_prompt_tokens is None and tokenizer is not None:
340 - max_input_chars = max_input_tokens * tokenizer.max_chars_per_token
340 + max_input_chars = max_input_tokens * tokenizer.max_chars_per_token
341
342 + if self.truncate_prompt_tokens is None:
343 if len(text) > max_input_chars:
343 - # To save resources, fail the request outright without even
344 - # attempting tokenization
344 raise VLLMValidationError(
345 f"This model's maximum context length is "
346 f"{self.max_total_tokens} tokens. However, you requested "
@@ -354,6 +353,11 @@ class TokenizeParams:
353 parameter="input_text",
354 value=len(text),
355 )
356 + elif self.truncation_side is not None and len(text) > max_input_chars:
357 + if self.truncation_side == "left":
358 + text = text[-max_input_chars:]
359 + else:
360 + text = text[:max_input_chars]
361
362 return text
363