[Bugfix][Frontend] Validate reused prompt token ids; render messages for media and echo (#55771)

Signed-off-by: Shijie Lyu <sjlyu@amazon.com> Co-authored-by: Shijie Lyu <sjlyu@amazon.com> Co-authored-by: Claude <noreply@anthropic.com>

Shijie Lyu committed Sep 28, 2026 at 19:36 UTC c6843d397cce339ee60ee134e484ce936fb5338d
6 files changed +239 -2
docs/features/disagg_prefill.md
+2
@@ -77,6 +77,8 @@ decode = client.chat.completions.create(
77 )
78 ```
79
80 +If `messages` has non-text content or `echo` is set, the ids are ignored and `messages` is rendered instead, so it must match the prefill request. Otherwise `kv_transfer_params["prompt_token_ids"]` must be a non-empty list of non-negative integers, or the request fails with HTTP 400, as it always does on `/v1/chat/completions/batch`.
81 +
82 ## Development
83
84 We implement disaggregated prefilling by running 2 vLLM instances. One for prefill (we call it prefill instance) and one for decode (we call it decode instance), and then use a connector to transfer the prefill KV caches and results from prefill instance to decode instance.
tests/entrypoints/openai/chat_completion/test_batched_chat_completions.py
+17
@@ -26,6 +26,7 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
26 BatchChatCompletionRequest,
27 )
28 from vllm.entrypoints.openai.models.serving import OpenAIServingModels
29 +from vllm.exceptions import VLLMValidationError
30 from vllm.outputs import CompletionOutput, RequestOutput
31 from vllm.renderers.online_renderer import OnlineRenderer
32 from vllm.v1.engine.async_llm import AsyncLLM
@@ -385,3 +386,19 @@ async def test_batched_harmony_response_format_uses_structural_tag() -> None:
386 assert len(calls) == 2
387 for call in calls:
388 assert call.args[1].structured_outputs.structural_tag is not None
389 +
390 +
391 +@pytest.mark.skip_global_cleanup
392 +def test_batch_rejects_kv_transfer_prompt_token_ids():
393 + """One pre-tokenized prompt cannot stand in for every conversation."""
394 + with pytest.raises(
395 + VLLMValidationError, match=r"parameter=kv_transfer_params\.prompt_token_ids"
396 + ):
397 + BatchChatCompletionRequest(
398 + model="test-model",
399 + messages=[
400 + [{"role": "user", "content": "first"}],
401 + [{"role": "user", "content": "second"}],
402 + ],
403 + kv_transfer_params={"prompt_token_ids": [10, 20, 30]},
404 + )
tests/entrypoints/openai/chat_completion/test_serving_chat.py
+138
@@ -595,6 +595,15 @@ def _build_online_renderer(
595 )
596
597
598 +def _build_mock_engine() -> MagicMock:
599 + mock_engine = MagicMock(spec=AsyncLLM)
600 + mock_engine.errored = False
601 + mock_engine.model_config = MockModelConfig()
602 + mock_engine.input_processor = MagicMock()
603 + mock_engine.renderer = _build_renderer(mock_engine.model_config)
604 + return mock_engine
605 +
606 +
607 def _build_serving_chat(
608 engine: AsyncLLM,
609 *,
@@ -2817,3 +2826,132 @@ def test_make_request_with_harmony_reuses_kv_transfer_prompt_token_ids():
2826 assert engine_input["prompt_token_ids"] == [10, 20, 30]
2827 # The reuse key is consumed and other kv_transfer_params are preserved.
2828 assert request.kv_transfer_params == {"do_remote_prefill": True}
2829 +
2830 +
2831 +@pytest.mark.parametrize("ids", [[-1], [1.5]])
2832 +def test_make_request_with_harmony_rejects_invalid_kv_transfer_prompt_token_ids(ids):
2833 + engine = MockEngine()
2834 + engine.model_config.hf_config = MockHFConfig(model_type="gpt_oss")
2835 + models = OpenAIServingModels(engine, BASE_MODEL_PATHS)
2836 + online_renderer = _build_online_renderer(engine, models.registry)
2837 +
2838 + request = ChatCompletionRequest(
2839 + model=MODEL_NAME,
2840 + messages=[{"role": "user", "content": "hi"}],
2841 + kv_transfer_params={"prompt_token_ids": ids},
2842 + )
2843 + with pytest.raises(VLLMValidationError, match="non-negative integers"):
2844 + online_renderer._make_request_with_harmony(request)
2845 +
2846 +
2847 +@pytest.mark.asyncio
2848 +@pytest.mark.parametrize("ids", [[], [-1], [1.5], [1.0], ["1"], [True], "abc", 5])
2849 +async def test_chat_kv_transfer_prompt_token_ids_rejects_invalid_ids(ids):
2850 + """``kv_transfer_params`` is untyped, so the ids are checked on reuse."""
2851 + serving_chat = _build_serving_chat(_build_mock_engine())
2852 +
2853 + request = ChatCompletionRequest(
2854 + model=MODEL_NAME,
2855 + messages=[{"role": "user", "content": "hi"}],
2856 + kv_transfer_params={"prompt_token_ids": ids},
2857 + )
2858 + with pytest.raises(VLLMValidationError, match="non-negative integers"):
2859 + await serving_chat.render_chat_request(request)
2860 +
2861 +
2862 +@pytest.mark.asyncio
2863 +async def test_chat_kv_transfer_prompt_token_ids_rejection_notifies_kv_connector():
2864 + """A render-time 400 on a decode request notifies the KV connector."""
2865 + mock_engine = _build_mock_engine()
2866 + mock_engine.notify_kv_transfer_request_rejected = AsyncMock()
2867 + serving_chat = _build_serving_chat(mock_engine)
2868 + serving_chat.has_kv_connector = True
2869 +
2870 + request = ChatCompletionRequest(
2871 + model=MODEL_NAME,
2872 + messages=[{"role": "user", "content": "hi"}],
2873 + kv_transfer_params={"prompt_token_ids": [1.5], "do_remote_prefill": True},
2874 + )
2875 + with pytest.raises(VLLMValidationError, match="non-negative integers"):
2876 + await serving_chat.create_chat_completion(request)
2877 +
2878 + mock_engine.notify_kv_transfer_request_rejected.assert_awaited_once_with(
2879 + request.request_id, {"do_remote_prefill": True}, data_parallel_rank=None
2880 + )
2881 +
2882 +
2883 +@pytest.mark.asyncio
2884 +@pytest.mark.parametrize("ids", [[10, 20, 30], [1.5]])
2885 +async def test_chat_kv_transfer_prompt_token_ids_ignored_with_echo(ids):
2886 + """``echo`` needs the rendered conversation, so the ids are not used."""
2887 + serving_chat = _build_serving_chat(_build_mock_engine())
2888 +
2889 + request = ChatCompletionRequest(
2890 + model=MODEL_NAME,
2891 + messages=[{"role": "user", "content": "hi"}],
2892 + kv_transfer_params={"prompt_token_ids": ids, "do_remote_prefill": True},
2893 + echo=True,
2894 + )
2895 + result = await serving_chat.render_chat_request(request)
2896 + assert not isinstance(result, ErrorResponse)
2897 +
2898 + conversation, engine_inputs = result
2899 + assert [msg["role"] for msg in conversation] == ["user"]
2900 + assert engine_inputs[0]["prompt_token_ids"] != ids
2901 + assert request.kv_transfer_params == {"do_remote_prefill": True}
2902 +
2903 +
2904 +@pytest.mark.parametrize(
2905 + "content",
2906 + [
2907 + [
2908 + {"type": "text", "text": "what is in this image?"},
2909 + {"type": "image_url", "image_url": {"url": "https://example.com/a.png"}},
2910 + ],
2911 + # A part written without a "type" is identified by its media key.
2912 + [{"image_url": "https://example.com/a.png"}],
2913 + # A media key counts even when the part claims to be text, which is
2914 + # how chat_utils reads a part carrying a "uuid".
2915 + [
2916 + {
2917 + "type": "text",
2918 + "text": "look",
2919 + "uuid": "u1",
2920 + "image_url": "https://example.com/a.png",
2921 + }
2922 + ],
2923 + [{"type": "hologram", "hologram": "https://a/b.holo"}],
2924 + [{"type": ["image_url"]}],
2925 + ],
2926 +)
2927 +def test_chat_kv_transfer_prompt_token_ids_ignored_with_non_text_content(content):
2928 + """The ids are dropped so that ``messages`` is rendered with its media."""
2929 + request = ChatCompletionRequest(
2930 + model=MODEL_NAME,
2931 + messages=[{"role": "user", "content": content}],
2932 + kv_transfer_params={
2933 + "prompt_token_ids": [10, 20, 30],
2934 + "do_remote_prefill": True,
2935 + },
2936 + )
2937 + assert request.kv_transfer_params == {"do_remote_prefill": True}
2938 +
2939 +
2940 +def test_chat_kv_transfer_prompt_token_ids_allows_text_only_parts():
2941 + """Text-bearing part types are not multimodal, so the ids are kept."""
2942 + request = ChatCompletionRequest(
2943 + model=MODEL_NAME,
2944 + messages=[
2945 + {"role": "user", "content": [{"type": "text", "text": "hi"}]},
2946 + {
2947 + "role": "assistant",
2948 + "content": [
2949 + {"type": "thinking", "thinking": "let me think"},
2950 + {"type": "refusal", "refusal": "no"},
2951 + ],
2952 + },
2953 + {"role": "user", "content": "plain string"},
2954 + ],
2955 + kv_transfer_params={"prompt_token_ids": [10, 20, 30]},
2956 + )
2957 + assert request.kv_transfer_params == {"prompt_token_ids": [10, 20, 30]}
vllm/entrypoints/chat_utils.py
+6 -1
@@ -1810,6 +1810,11 @@ PART_TYPES_TO_SKIP_NONE_CONTENT = (
1810 "refusal",
1811 )
1812
1813 +# Content part types parsed as text rather than multimodal data.
1814 +TEXT_PART_TYPES = frozenset(
1815 + {"text", "input_text", "output_text", "refusal", "thinking"}
1816 +)
1817 +
1818
1819 def _parse_chat_message_content_parts(
1820 role: str,
@@ -1904,7 +1909,7 @@ def _parse_chat_message_content_part(
1909 )
1910 return None
1911
1907 - if part_type in ("text", "input_text", "output_text", "refusal", "thinking"):
1912 + if part_type in TEXT_PART_TYPES:
1913 str_content = cast(str, content)
1914 _reject_reserved_placeholder_in_text(str_content, mm_parser.model_config)
1915 if wrap_dicts:
vllm/entrypoints/openai/chat_completion/protocol.py
+51
@@ -20,6 +20,8 @@ from pydantic import (
20
21 from vllm.config import ModelConfig
22 from vllm.entrypoints.chat_utils import (
23 + MM_PARSER_MAP,
24 + TEXT_PART_TYPES,
25 ChatCompletionMessageParam,
26 ChatTemplateContentFormatOption,
27 )
@@ -58,6 +60,11 @@ logger = init_logger(__name__)
60 _INT64_MIN = -(2**63)
61 _INT64_MAX = 2**63 - 1
62
63 +# Content part types that carry no multimodal data.
64 +_TEXT_CONTENT_PART_TYPES = TEXT_PART_TYPES | {"tool_reference"}
65 +# Keys that mark a content part as multimodal, whatever its ``type``.
66 +_MEDIA_CONTENT_PART_KEYS = frozenset(MM_PARSER_MAP) - _TEXT_CONTENT_PART_TYPES
67 +
68
69 class ChatMessage(OpenAIBaseModel):
70 role: str
@@ -976,6 +983,39 @@ class ChatCompletionRequest(OpenAIBaseModel):
983 )
984 return data
985
986 + @model_validator(mode="before")
987 + @classmethod
988 + def drop_prompt_token_ids_with_media(cls, data):
989 + # The forwarded ids would drop media in ``messages``, so ignore them. Runs
990 + # before validation, which can turn content into a one-shot iterator.
991 + if not isinstance(data, dict):
992 + return data
993 + kv_transfer_params = data.get("kv_transfer_params")
994 + messages = data.get("messages")
995 + if (
996 + not isinstance(kv_transfer_params, dict)
997 + or kv_transfer_params.get("prompt_token_ids") is None
998 + or not isinstance(messages, list)
999 + ):
1000 + return data
1001 + for msg in messages:
1002 + content = msg.get("content") if isinstance(msg, dict) else None
1003 + if not isinstance(content, list):
1004 + continue
1005 + for part in content:
1006 + if isinstance(part, dict) and (
1007 + any(key in part for key in _MEDIA_CONTENT_PART_KEYS)
1008 + or not isinstance(part_type := part.get("type", "text"), str)
1009 + or part_type not in _TEXT_CONTENT_PART_TYPES
1010 + ):
1011 + logger.debug(
1012 + "Ignoring kv_transfer_params['prompt_token_ids']: "
1013 + "messages have non-text content and are rendered instead."
1014 + )
1015 + kv_transfer_params.pop("prompt_token_ids")
1016 + return data
1017 + return data
1018 +
1019 @model_validator(mode="before")
1020 @classmethod
1021 def check_system_message_content_type(cls, data):
@@ -1117,6 +1157,17 @@ class BatchChatCompletionRequest(OpenAIBaseModel):
1157 "when using `logprob_token_ids`, `logprobs` must be set to true.",
1158 parameter="logprob_token_ids",
1159 )
1160 + kv_transfer_params = data.get("kv_transfer_params")
1161 + if (
1162 + isinstance(kv_transfer_params, dict)
1163 + and kv_transfer_params.get("prompt_token_ids") is not None
1164 + ):
1165 + raise VLLMValidationError(
1166 + "Batch chat completions do not support "
1167 + "`kv_transfer_params['prompt_token_ids']`: one pre-tokenized "
1168 + "prompt cannot serve several conversations.",
1169 + parameter="kv_transfer_params.prompt_token_ids",
1170 + )
1171 response_format = data.get("response_format")
1172 if response_format is not None:
1173 rf_type = (
vllm/renderers/online_renderer.py
+25 -1
@@ -107,11 +107,35 @@ def _reused_prompt_token_ids(request: Any) -> list[int] | None:
107 Disaggregated serving carries the prefill stage's ids in
108 ``kv_transfer_params`` so the decode stage can skip re-tokenizing. Removing
109 the key keeps the id list out of the engine's sampling metadata.
110 +
111 + Returns None without checking the ids when ``echo`` is set, since echo
112 + needs ``messages`` to be rendered. Otherwise raises VLLMValidationError if
113 + the ids are malformed.
114 """
115 kv = getattr(request, "kv_transfer_params", None)
116 if not isinstance(kv, dict):
117 return None
114 - return kv.pop("prompt_token_ids", None) or None
118 + ids = kv.pop("prompt_token_ids", None)
119 + if ids is None:
120 + return None
121 + if getattr(request, "echo", False):
122 + logger.debug(
123 + "Ignoring kv_transfer_params['prompt_token_ids']: "
124 + "echo is set, so messages are rendered instead."
125 + )
126 + return None
127 + # bool is an int subclass, hence the exact type check.
128 + if (
129 + not isinstance(ids, list)
130 + or not ids
131 + or any(type(x) is not int or x < 0 for x in ids)
132 + ):
133 + raise VLLMValidationError(
134 + "`kv_transfer_params['prompt_token_ids']` must be a non-empty list "
135 + "of non-negative integers.",
136 + parameter="kv_transfer_params.prompt_token_ids",
137 + )
138 + return ids
139
140
141 class OnlineRenderer: