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