[Bugfix] Reject negative token ids as out-of-vocabulary (#51795)

Signed-off-by: Junhao Shen <junshen@nvidia.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Vadim Gimpelson <156319763+vadiklyutiy@users.noreply.github.com>

Junhao Shen committed Aug 14, 2026 at 09:07 UTC b216db3ed0f1262dc6b146a637b33159868433e9
2 files changed +21
tests/entrypoints/pooling/embed/test_online.py
+11
@@ -89,6 +89,17 @@ def hf_model(hf_runner):
89 yield hf_model
90
91
92 +@pytest.mark.asyncio
93 +@pytest.mark.parametrize("model_name", [MODEL_NAME])
94 +async def test_negative_token_ids(client: openai.AsyncOpenAI, model_name: str):
95 + # A negative token id is out of vocabulary just like an over-large one, but
96 + # is not caught by the upper-bound check. Unlike the OpenAI completion
97 + # schema, the pooling schema does not constrain token ids to be
98 + # non-negative, so the request reaches the shared engine-level validation.
99 + with pytest.raises(openai.BadRequestError, match=".*out of vocabulary.*"):
100 + await client.embeddings.create(model=model_name, input=[-1])
101 +
102 +
103 @pytest.mark.asyncio
104 @pytest.mark.parametrize("model_name", [MODEL_NAME])
105 async def test_basic(
vllm/v1/engine/input_processor.py
+10
@@ -483,6 +483,7 @@ class InputProcessor:
483
484 if prompt_ids and tokenizer is not None:
485 max_input_id = max(prompt_ids, default=0)
486 + min_input_id = min(prompt_ids, default=0)
487
488 # NOTE: tokenizer.max_token_id is the tokenizer’s vocab size while
489 # self.model_config.get_vocab_size() is the model’s vocab size.
@@ -495,6 +496,15 @@ class InputProcessor:
496 # Here we take the max of the two to determine if a token id is
497 # truly out-of-vocabulary.
498 model_vocab_size = model_config.get_vocab_size()
499 + # A negative id is out of vocabulary just like an over-large one,
500 + # but is not caught by the upper-bound check below. Reject it here
501 + # so it is not used as an embedding index downstream. This
502 + # validation path is shared by generate, embedding and pooling
503 + # requests, so the check covers all three.
504 + if min_input_id < 0:
505 + raise VLLMValidationError(
506 + f"Token id {min_input_id} is out of vocabulary"
507 + )
508 if max_input_id > max(tokenizer.max_token_id, model_vocab_size - 1):
509 raise VLLMValidationError(
510 f"Token id {max_input_id} is out of vocabulary"