[Frontend] Return prompt metadata from /inference/v1/generate (#53187)

aoshen02 committed Sep 17, 2026 at 06:29 UTC 0983aef8da9b25ae5be72a5567e571a8f3f82963
9 files changed +763 -40
rust/src/server/src/routes/inference/generate.rs
+268 -4
@@ -21,6 +21,7 @@ use thiserror_ext::AsReport as _;
21 use tracing::{error, info, trace};
22 use tracing_futures::Instrument as _;
23 use vllm_engine_core_client::protocol::logprobs::{Logprobs, PositionLogprobs};
24 +use vllm_engine_core_client::protocol::multimodal::MmFeatureSpec;
25 use vllm_llm::{
26 CollectedGenerateOutput, FinishReason, GenerateOutput, GenerateOutputStreamExt as _, TokenUsage,
27 };
@@ -28,7 +29,7 @@ use vllm_llm::{
29 use self::convert::{ResponseOptions, prepare_generate_request};
30 use self::types::{
31 GenerateLogprob, GenerateResponse, GenerateResponseChoice, GenerateResponseStreamChoice,
31 - GenerateStreamResponse,
32 + GenerateStreamResponse, MultiModalPlaceholders, PlaceholderRangeInfo,
33 };
34 pub(crate) use self::types::{GenerateRequest, GenerateSamplingParams};
35 pub(crate) use self::validate::validate_request_compat;
@@ -64,6 +65,11 @@ pub async fn generate(
65 } else {
66 None
67 };
68 + let mm_placeholders = body
69 + .return_token_ids
70 + .unwrap_or(false)
71 + .then(|| extract_mm_placeholders(mm_features.as_deref()))
72 + .flatten();
73
74 let prepared =
75 match prepare_generate_request(body, &lora_resolution, request_context, mm_features) {
@@ -98,6 +104,7 @@ pub async fn generate(
104 prepared.request_id,
105 api_server_options,
106 prepared.options,
107 + mm_placeholders,
108 );
109 let sse_stream = generate_sse_stream(chunk_stream).instrument(request_span);
110
@@ -120,6 +127,7 @@ pub async fn generate(
127 prepared.request_id,
128 api_server_options,
129 prepared.options,
130 + mm_placeholders,
131 ) {
132 Ok(response) => response,
133 Err(error) => return error.into_response(),
@@ -143,19 +151,26 @@ async fn generate_chunk_stream(
151 include_logprobs,
152 // Ignored: raw generate streaming has no prompt-logprobs wire shape.
153 include_prompt_logprobs: _,
154 + return_token_ids,
155 }: ResponseOptions,
156 + mut mm_placeholders: Option<MultiModalPlaceholders>,
157 mut y: TryYielder<GenerateStreamResponse, ApiError>,
158 ) -> Result<(), ApiError> {
159 pin_mut!(stream);
160 let mut prompt_tokens = None;
161 + let mut prompt_token_ids = None;
162 let mut usage = TokenUsage::default();
163
164 while let Some(next) = stream.next().await {
165 match next {
166 Ok(output) => {
156 - if prompt_tokens.is_none() {
157 - prompt_tokens =
158 - output.prompt_info.as_ref().map(|info| info.prompt_token_ids.len());
167 + if prompt_tokens.is_none()
168 + && let Some(info) = output.prompt_info.as_ref()
169 + {
170 + prompt_tokens = Some(info.prompt_token_ids.len());
171 + if return_token_ids {
172 + prompt_token_ids = Some(info.prompt_token_ids.to_vec());
173 + }
174 }
175 usage.prompt_token_count = prompt_tokens.unwrap_or_default();
176 usage.cached_token_count = usage.cached_token_count.max(output.cached_token_count);
@@ -195,6 +210,7 @@ async fn generate_chunk_stream(
210 None
211 };
212
213 + let prompt_token_ids = prompt_token_ids.take();
214 y.yield_ok(GenerateStreamResponse {
215 request_id: request_id.clone(),
216 choices: vec![GenerateResponseStreamChoice {
@@ -205,6 +221,8 @@ async fn generate_chunk_stream(
221 }],
222 usage: include_continuous_usage
223 .then(|| Usage::from_token_usage(usage, enable_prompt_tokens_details)),
224 + mm_placeholders: prompt_token_ids.as_ref().and_then(|_| mm_placeholders.take()),
225 + prompt_token_ids,
226 })
227 .await;
228 }
@@ -223,6 +241,8 @@ async fn generate_chunk_stream(
241 request_id,
242 choices: Vec::new(),
243 usage: Some(Usage::from_token_usage(usage, enable_prompt_tokens_details)),
244 + prompt_token_ids: None,
245 + mm_placeholders: None,
246 })
247 .await;
248 }
@@ -244,7 +264,9 @@ fn collect_generate(
264 include_continuous_usage: _,
265 include_logprobs,
266 include_prompt_logprobs,
267 + return_token_ids,
268 }: ResponseOptions,
269 + mm_placeholders: Option<MultiModalPlaceholders>,
270 ) -> Result<GenerateResponse, ApiError> {
271 let logprobs = if include_logprobs {
272 let logprobs = collected.logprobs.as_ref().ok_or_else(|| {
@@ -292,11 +314,34 @@ fn collect_generate(
314 token_ids: collected.token_ids,
315 }],
316 prompt_logprobs,
317 + prompt_token_ids: return_token_ids.then_some(collected.prompt_token_ids),
318 + mm_placeholders: return_token_ids.then_some(mm_placeholders).flatten(),
319 kv_transfer_params: collected.kv_transfer_params,
320 ec_transfer_params: collected.ec_transfer_params,
321 })
322 }
323
324 +fn extract_mm_placeholders(features: Option<&[MmFeatureSpec]>) -> Option<MultiModalPlaceholders> {
325 + let features = features?;
326 + if features.is_empty() {
327 + return None;
328 + }
329 +
330 + let mut placeholders = MultiModalPlaceholders::new();
331 + for feature in features {
332 + placeholders.entry(feature.modality.as_str().to_string()).or_default().push(
333 + PlaceholderRangeInfo {
334 + offset: feature.mm_position.offset,
335 + length: feature.mm_position.length,
336 + },
337 + );
338 + }
339 + for ranges in placeholders.values_mut() {
340 + ranges.sort_by_key(|range| range.offset);
341 + }
342 + Some(placeholders)
343 +}
344 +
345 fn raw_logprobs_to_openai_chat(logprobs: &Logprobs) -> Result<ChatLogProbs, ApiError> {
346 let content = logprobs
347 .positions
@@ -419,6 +464,7 @@ mod tests {
464 use std::sync::Arc;
465
466 use futures::{TryStreamExt as _, stream};
467 + use vllm_engine_core_client::protocol::multimodal::{MmModality, PlaceholderRange};
468 use vllm_llm::GeneratePromptInfo;
469
470 use super::*;
@@ -451,6 +497,13 @@ mod tests {
497 }),
498 ]);
499
500 + let mm_placeholders = MultiModalPlaceholders::from([(
501 + "image".to_string(),
502 + vec![PlaceholderRangeInfo {
503 + offset: 1,
504 + length: 4,
505 + }],
506 + )]);
507 let chunks: Vec<_> = generate_chunk_stream(
508 stream,
509 "raw-stream".to_string(),
@@ -461,14 +514,20 @@ mod tests {
514 ResponseOptions {
515 include_usage: true,
516 include_continuous_usage: true,
517 + return_token_ids: true,
518 ..Default::default()
519 },
520 + Some(mm_placeholders.clone()),
521 )
522 .try_collect()
523 .await
524 .expect("collect chunks");
525
526 assert_eq!(chunks.len(), 2);
527 + assert_eq!(chunks[0].prompt_token_ids, Some(vec![11, 22]));
528 + assert_eq!(chunks[0].mm_placeholders, Some(mm_placeholders));
529 + assert!(chunks[1].prompt_token_ids.is_none());
530 + assert!(chunks[1].mm_placeholders.is_none());
531 assert_eq!(
532 chunks[0].usage.as_ref().expect("chunk usage").prompt_tokens,
533 2
@@ -499,6 +558,209 @@ mod tests {
558 );
559 }
560
561 + fn stream_output(
562 + prompt_token_ids: Option<&[u32]>,
563 + token_ids: Vec<u32>,
564 + finish_reason: Option<FinishReason>,
565 + ) -> GenerateOutput {
566 + GenerateOutput {
567 + request_id: String::new(),
568 + prompt_info: prompt_token_ids.map(|ids| GeneratePromptInfo {
569 + prompt_token_ids: Arc::from(ids),
570 + prompt_logprobs: None,
571 + }),
572 + token_ids,
573 + logprobs: None,
574 + finish_reason,
575 + cached_token_count: 0,
576 + kv_transfer_params: None,
577 + ec_transfer_params: None,
578 + }
579 + }
580 +
581 + async fn collect_chunks(
582 + outputs: Vec<GenerateOutput>,
583 + return_token_ids: bool,
584 + mm_placeholders: Option<MultiModalPlaceholders>,
585 + ) -> Vec<GenerateStreamResponse> {
586 + generate_chunk_stream(
587 + stream::iter(outputs.into_iter().map(Ok)),
588 + "raw-stream".to_string(),
589 + ApiServerOptions::default(),
590 + ResponseOptions {
591 + return_token_ids,
592 + ..Default::default()
593 + },
594 + mm_placeholders,
595 + )
596 + .try_collect()
597 + .await
598 + .expect("collect chunks")
599 + }
600 +
601 + #[tokio::test]
602 + async fn generate_chunk_stream_omits_prompt_metadata_by_default() {
603 + let chunks = collect_chunks(
604 + vec![
605 + stream_output(Some(&[11, 22]), vec![33], None),
606 + stream_output(Some(&[11, 22]), vec![44], Some(FinishReason::stop_eos())),
607 + ],
608 + false,
609 + Some(MultiModalPlaceholders::from([(
610 + "image".to_string(),
611 + vec![PlaceholderRangeInfo {
612 + offset: 1,
613 + length: 4,
614 + }],
615 + )])),
616 + )
617 + .await;
618 +
619 + assert_eq!(chunks.len(), 2);
620 + assert!(chunks.iter().all(|c| c.prompt_token_ids.is_none()));
621 + assert!(chunks.iter().all(|c| c.mm_placeholders.is_none()));
622 + }
623 +
624 + #[tokio::test]
625 + async fn generate_chunk_stream_sends_prompt_metadata_on_first_emitted_chunk() {
626 + // An empty first output is skipped and must not consume the metadata.
627 + let chunks = collect_chunks(
628 + vec![
629 + stream_output(Some(&[11, 22]), Vec::new(), None),
630 + stream_output(Some(&[11, 22]), vec![33], None),
631 + stream_output(Some(&[11, 22]), vec![44], Some(FinishReason::stop_eos())),
632 + ],
633 + true,
634 + None,
635 + )
636 + .await;
637 +
638 + assert_eq!(chunks.len(), 2);
639 + assert_eq!(chunks[0].prompt_token_ids, Some(vec![11, 22]));
640 + assert!(chunks[0].mm_placeholders.is_none());
641 + assert!(chunks[1].prompt_token_ids.is_none());
642 + assert!(chunks[1].mm_placeholders.is_none());
643 + }
644 +
645 + #[test]
646 + fn collect_generate_omits_prompt_metadata_by_default() {
647 + let output = CollectedGenerateOutput {
648 + request_id: "raw-1".to_string(),
649 + prompt_logprobs: None,
650 + token_ids: vec![30],
651 + logprobs: None,
652 + finish_reason: FinishReason::stop_eos(),
653 + usage: TokenUsage {
654 + prompt_token_count: 2,
655 + output_token_count: 1,
656 + cached_token_count: 0,
657 + },
658 + kv_transfer_params: None,
659 + ec_transfer_params: None,
660 + prompt_token_ids: vec![10, 20],
661 + };
662 +
663 + let response = collect_generate(
664 + output,
665 + "raw-1".to_string(),
666 + ApiServerOptions::default(),
667 + ResponseOptions::default(),
668 + Some(MultiModalPlaceholders::from([(
669 + "image".to_string(),
670 + vec![PlaceholderRangeInfo {
671 + offset: 0,
672 + length: 1,
673 + }],
674 + )])),
675 + )
676 + .expect("response");
677 +
678 + assert!(response.prompt_token_ids.is_none());
679 + assert!(response.mm_placeholders.is_none());
680 + }
681 +
682 + #[test]
683 + fn extract_mm_placeholders_returns_none_without_features() {
684 + assert!(extract_mm_placeholders(None).is_none());
685 + assert!(extract_mm_placeholders(Some(&[])).is_none());
686 + }
687 +
688 + #[test]
689 + fn extract_mm_placeholders_groups_ranges_in_prompt_order() {
690 + let feature = |modality: MmModality, offset: usize, length: usize| MmFeatureSpec {
691 + data: None,
692 + modality,
693 + identifier: format!("{}-{offset}", modality.as_str()),
694 + mm_position: PlaceholderRange {
695 + offset,
696 + length,
697 + is_embed: None,
698 + },
699 + mm_hash: None,
700 + };
701 + let features = vec![
702 + feature(MmModality::Image, 8, 2),
703 + feature(MmModality::Audio, 4, 3),
704 + feature(MmModality::Image, 1, 5),
705 + ];
706 +
707 + let placeholders = extract_mm_placeholders(Some(&features)).expect("metadata");
708 +
709 + assert_eq!(
710 + placeholders["image"],
711 + vec![
712 + PlaceholderRangeInfo {
713 + offset: 1,
714 + length: 5,
715 + },
716 + PlaceholderRangeInfo {
717 + offset: 8,
718 + length: 2,
719 + },
720 + ]
721 + );
722 + assert_eq!(
723 + placeholders["audio"],
724 + vec![PlaceholderRangeInfo {
725 + offset: 4,
726 + length: 3,
727 + }]
728 + );
729 + }
730 +
731 + #[test]
732 + fn collect_generate_returns_prompt_token_ids_when_requested() {
733 + let output = CollectedGenerateOutput {
734 + request_id: "raw-1".to_string(),
735 + prompt_logprobs: None,
736 + token_ids: vec![30],
737 + logprobs: None,
738 + finish_reason: FinishReason::stop_eos(),
739 + usage: TokenUsage {
740 + prompt_token_count: 2,
741 + output_token_count: 1,
742 + cached_token_count: 0,
743 + },
744 + kv_transfer_params: None,
745 + ec_transfer_params: None,
746 + prompt_token_ids: vec![10, 20],
747 + };
748 +
749 + let response = collect_generate(
750 + output,
751 + "raw-1".to_string(),
752 + ApiServerOptions::default(),
753 + ResponseOptions {
754 + return_token_ids: true,
755 + ..Default::default()
756 + },
757 + None,
758 + )
759 + .expect("response");
760 +
761 + assert_eq!(response.prompt_token_ids, Some(vec![10, 20]));
762 + }
763 +
764 #[test]
765 fn collect_generate_maps_prompt_logprobs_for_single_token_prompt() {
766 let output_without_payload = |prompt_token_ids: Vec<u32>| CollectedGenerateOutput {
@@ -525,6 +787,7 @@ mod tests {
787 include_prompt_logprobs: true,
788 ..Default::default()
789 },
790 + None,
791 )
792 .expect("single-token prompt without payload maps to [None]");
793 let prompt_logprobs = response.prompt_logprobs.expect("prompt logprobs present");
@@ -539,6 +802,7 @@ mod tests {
802 include_prompt_logprobs: true,
803 ..Default::default()
804 },
805 + None,
806 )
807 .expect_err("multi-token prompt without payload is an engine failure");
808 }
rust/src/server/src/routes/inference/generate/convert.rs
+4
@@ -30,6 +30,8 @@ pub(super) struct ResponseOptions {
30 pub include_logprobs: bool,
31 /// Whether the caller requested top-level prompt logprobs.
32 pub include_prompt_logprobs: bool,
33 + /// Whether the caller requested final prompt token metadata.
34 + pub return_token_ids: bool,
35 }
36
37 /// Validate and lower one raw generate request into the internal
@@ -56,6 +58,7 @@ pub(super) fn prepare_generate_request(
58 .unwrap_or(false);
59 let include_logprobs = request.sampling_params.inner.logprobs.is_some();
60 let include_prompt_logprobs = request.sampling_params.inner.prompt_logprobs.is_some();
61 + let return_token_ids = request.return_token_ids.unwrap_or(false);
62 let mut sampling_params = request.sampling_params.inner;
63 sampling_params.vllm_xargs = merge_kv_transfer_params(
64 sampling_params.vllm_xargs,
@@ -93,6 +96,7 @@ pub(super) fn prepare_generate_request(
96 include_continuous_usage,
97 include_logprobs,
98 include_prompt_logprobs,
99 + return_token_ids,
100 },
101 })
102 }
rust/src/server/src/routes/inference/generate/types.rs
+13
@@ -46,6 +46,7 @@ pub struct GenerateRequest {
46 pub ec_transfer_params: Option<HashMap<String, Value>>,
47 /// Raw multimodal input; server resolves media. Mutually exclusive with `features`.
48 pub content_parts: Option<Vec<MediaContentPart>>,
49 + pub return_token_ids: Option<bool>,
50 #[serde(flatten)]
51 pub other: Map<String, Value>,
52 }
@@ -81,6 +82,8 @@ pub(super) struct GenerateStreamResponse {
82 pub request_id: String,
83 pub choices: Vec<GenerateResponseStreamChoice>,
84 pub usage: Option<Usage>,
85 + pub prompt_token_ids: Option<Vec<u32>>,
86 + pub mm_placeholders: Option<MultiModalPlaceholders>,
87 }
88
89 /// Mirrors the Python vLLM `GenerateResponse` class.
@@ -89,10 +92,20 @@ pub(super) struct GenerateResponse {
92 pub request_id: String,
93 pub choices: Vec<GenerateResponseChoice>,
94 pub prompt_logprobs: Option<Vec<Option<HashMap<u32, GenerateLogprob>>>>,
95 + pub prompt_token_ids: Option<Vec<u32>>,
96 + pub mm_placeholders: Option<MultiModalPlaceholders>,
97 pub kv_transfer_params: Option<Value>,
98 pub ec_transfer_params: Option<Value>,
99 }
100
101 +pub(super) type MultiModalPlaceholders = HashMap<String, Vec<PlaceholderRangeInfo>>;
102 +
103 +#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
104 +pub(super) struct PlaceholderRangeInfo {
105 + pub offset: usize,
106 + pub length: usize,
107 +}
108 +
109 /// Mirrors the Python vLLM `Logprob` class used in prompt-logprobs payloads.
110 #[derive(Debug, Clone, Serialize)]
111 pub(super) struct GenerateLogprob {
rust/src/server/src/routes/render.rs
+1
@@ -105,6 +105,7 @@ fn lower_render_request(
105 kv_transfer_params: None,
106 ec_transfer_params: None,
107 content_parts: None,
108 + return_token_ids: None,
109 other: Default::default(),
110 };
111 validate_generate_request(&request, &state.served_model_names)?;
tests/entrypoints/scale_out/token_in_token_out/test_generate_stream.py
+357
@@ -12,6 +12,9 @@ from vllm.config.multimodal import MultiModalConfig
12 from vllm.entrypoints.generate.base.protocol import StreamOptions
13 from vllm.entrypoints.openai.models.protocol import BaseModelPath
14 from vllm.entrypoints.openai.models.serving import OpenAIServingModels
15 +from vllm.entrypoints.scale_out.token_in_token_out.mm_features import (
16 + placeholder_ranges_from_engine_input,
17 +)
18 from vllm.entrypoints.scale_out.token_in_token_out.protocol import (
19 GenerateRequest,
20 GenerateResponse,
@@ -19,6 +22,7 @@ from vllm.entrypoints.scale_out.token_in_token_out.protocol import (
22 from vllm.entrypoints.scale_out.token_in_token_out.serving import ServingTokens
23 from vllm.exceptions import GenerationError
24 from vllm.logprobs import Logprob
25 +from vllm.multimodal.inputs import PlaceholderRange
26 from vllm.outputs import CompletionOutput, RequestOutput
27 from vllm.renderers import renderer_from_config
28 from vllm.renderers.online_renderer import OnlineRenderer
@@ -176,6 +180,29 @@ def _parse_sse_chunks(chunks: list[str]) -> list[Any]:
180 return parsed
181
182
183 +def test_placeholder_ranges_from_engine_input():
184 + engine_input: Any = {
185 + "type": "multimodal",
186 + "mm_placeholders": {
187 + "image": [
188 + PlaceholderRange(offset=1, length=5),
189 + PlaceholderRange(offset=8, length=2),
190 + ]
191 + },
192 + }
193 +
194 + placeholders = placeholder_ranges_from_engine_input(engine_input)
195 +
196 + assert placeholders is not None
197 + assert [item.model_dump() for item in placeholders["image"]] == [
198 + {"offset": 1, "length": 5},
199 + {"offset": 8, "length": 2},
200 + ]
201 +
202 + text_only: Any = {"type": "token", "prompt_token_ids": [1, 2, 3]}
203 + assert placeholder_ranges_from_engine_input(text_only) is None
204 +
205 +
206 @pytest.mark.asyncio
207 async def test_serve_tokens_skips_mm_cache_for_remote_engine_execution():
208 engine = _mock_engine()
@@ -294,6 +321,336 @@ async def test_stream_basic():
321 assert data_chunks[2]["choices"][0]["finish_reason"] == "stop"
322
323
324 +@pytest.mark.asyncio
325 +async def test_non_stream_returns_final_prompt_token_ids_when_requested():
326 + engine = _mock_engine()
327 + final_prompt_token_ids = [1, 2, 2, 2, 3]
328 +
329 + async def mock_generate(*args, **kwargs):
330 + yield _make_request_output(
331 + "req-1",
332 + token_ids=[10],
333 + finish_reason="stop",
334 + finished=True,
335 + prompt_token_ids=final_prompt_token_ids,
336 + )
337 +
338 + engine.generate = MagicMock(side_effect=mock_generate)
339 + serving = _build_serving_tokens(engine)
340 + request = GenerateRequest(
341 + token_ids=[1, 2, 3],
342 + sampling_params=SamplingParams(max_tokens=1),
343 + model=MODEL_NAME,
344 + return_token_ids=True,
345 + )
346 +
347 + response = await serving.serve_tokens(request)
348 +
349 + assert isinstance(response, GenerateResponse)
350 + assert response.prompt_token_ids == final_prompt_token_ids
351 +
352 +
353 +@pytest.mark.asyncio
354 +async def test_stream_returns_prompt_token_ids_only_once_when_requested():
355 + engine = _mock_engine()
356 + final_prompt_token_ids = [1, 2, 2, 2, 3]
357 +
358 + async def mock_generate(*args, **kwargs):
359 + yield _make_request_output(
360 + "req-1", token_ids=[10], prompt_token_ids=final_prompt_token_ids
361 + )
362 + yield _make_request_output(
363 + "req-1",
364 + token_ids=[20],
365 + finish_reason="stop",
366 + finished=True,
367 + prompt_token_ids=final_prompt_token_ids,
368 + )
369 +
370 + engine.generate = MagicMock(side_effect=mock_generate)
371 + serving = _build_serving_tokens(engine)
372 + request = GenerateRequest(
373 + token_ids=[1, 2, 3],
374 + sampling_params=SamplingParams(max_tokens=2),
375 + model=MODEL_NAME,
376 + stream=True,
377 + return_token_ids=True,
378 + )
379 +
380 + response = await serving.serve_tokens(request)
381 + parsed = _parse_sse_chunks([chunk async for chunk in response])
382 + data_chunks = [c for c in parsed if isinstance(c, dict) and c.get("choices")]
383 +
384 + assert data_chunks[0]["prompt_token_ids"] == final_prompt_token_ids
385 + assert "prompt_token_ids" not in data_chunks[1]
386 +
387 +
388 +MM_ENGINE_INPUT: Any = {
389 + "type": "multimodal",
390 + "prompt_token_ids": [1, 2, 2, 2, 3],
391 + "mm_placeholders": {"image": [PlaceholderRange(offset=1, length=3)]},
392 +}
393 +MM_PLACEHOLDERS = {"image": [{"offset": 1, "length": 3}]}
394 +
395 +
396 +def _build_mm_serving_tokens(engine: AsyncLLM) -> ServingTokens:
397 + serving = _build_serving_tokens(engine)
398 +
399 + async def _fake_preprocess(*args, **kwargs):
400 + return [MM_ENGINE_INPUT]
401 +
402 + serving.online_renderer.preprocess_completion = AsyncMock(
403 + side_effect=_fake_preprocess
404 + )
405 + return serving
406 +
407 +
408 +@pytest.mark.asyncio
409 +@pytest.mark.parametrize("return_token_ids", [False, True])
410 +async def test_non_stream_prompt_metadata_requires_return_token_ids(
411 + return_token_ids: bool,
412 +):
413 + engine = _mock_engine()
414 +
415 + async def mock_generate(*args, **kwargs):
416 + yield _make_request_output(
417 + "req-1", token_ids=[10], finish_reason="stop", finished=True
418 + )
419 +
420 + engine.generate = MagicMock(side_effect=mock_generate)
421 + serving = _build_mm_serving_tokens(engine)
422 + request = GenerateRequest(
423 + token_ids=[1, 2, 3],
424 + sampling_params=SamplingParams(max_tokens=1),
425 + model=MODEL_NAME,
426 + return_token_ids=return_token_ids,
427 + )
428 +
429 + response = await serving.serve_tokens(request)
430 +
431 + assert isinstance(response, GenerateResponse)
432 + if return_token_ids:
433 + assert response.prompt_token_ids == [1, 2, 3]
434 + assert response.model_dump()["mm_placeholders"] == MM_PLACEHOLDERS
435 + else:
436 + assert response.prompt_token_ids is None
437 + assert response.mm_placeholders is None
438 + assert request._response_mm_placeholders is None
439 +
440 +
441 +@pytest.mark.asyncio
442 +async def test_non_stream_text_only_prompt_has_no_mm_placeholders():
443 + engine = _mock_engine()
444 +
445 + async def mock_generate(*args, **kwargs):
446 + yield _make_request_output(
447 + "req-1", token_ids=[10], finish_reason="stop", finished=True
448 + )
449 +
450 + engine.generate = MagicMock(side_effect=mock_generate)
451 + serving = _build_serving_tokens(engine)
452 + request = GenerateRequest(
453 + token_ids=[1, 2, 3],
454 + sampling_params=SamplingParams(max_tokens=1),
455 + model=MODEL_NAME,
456 + return_token_ids=True,
457 + )
458 +
459 + response = await serving.serve_tokens(request)
460 +
461 + assert isinstance(response, GenerateResponse)
462 + assert response.prompt_token_ids == [1, 2, 3]
463 + assert response.mm_placeholders is None
464 +
465 +
466 +@pytest.mark.asyncio
467 +async def test_non_stream_encoder_decoder_omits_mm_placeholders():
468 + engine = _mock_engine()
469 + engine.model_config.is_encoder_decoder = True
470 +
471 + async def mock_generate(*args, **kwargs):
472 + yield _make_request_output(
473 + "req-1", token_ids=[10], finish_reason="stop", finished=True
474 + )
475 +
476 + engine.generate = MagicMock(side_effect=mock_generate)
477 + serving = _build_mm_serving_tokens(engine)
478 + request = GenerateRequest(
479 + token_ids=[1, 2, 3],
480 + sampling_params=SamplingParams(max_tokens=1),
481 + model=MODEL_NAME,
482 + return_token_ids=True,
483 + )
484 +
485 + response = await serving.serve_tokens(request)
486 +
487 + assert isinstance(response, GenerateResponse)
488 + assert response.prompt_token_ids == [1, 2, 3]
489 + assert response.mm_placeholders is None
490 +
491 +
492 +@pytest.mark.asyncio
493 +async def test_stream_prompt_metadata_lands_on_first_emitted_chunk():
494 + """An empty first output is skipped; metadata goes on the first real chunk,
495 + once, and never on the sibling chunk of the same iteration (n=2)."""
496 + engine = _mock_engine()
497 +
498 + def _two_outputs(tokens0: list[int], tokens1: list[int], **kwargs):
499 + res = _make_request_output("req-1", token_ids=tokens0, **kwargs)
500 + res.outputs.append(
501 + CompletionOutput(
502 + index=1,
503 + text="",
504 + token_ids=tokens1,
505 + cumulative_logprob=None,
506 + logprobs=None,
507 + finish_reason=kwargs.get("finish_reason"),
508 + )
509 + )
510 + return res
511 +
512 + async def mock_generate(*args, **kwargs):
513 + yield _two_outputs([], [])
514 + yield _two_outputs([10], [11])
515 + yield _two_outputs([20], [21], finish_reason="stop", finished=True)
516 +
517 + engine.generate = MagicMock(side_effect=mock_generate)
518 + serving = _build_mm_serving_tokens(engine)
519 + request = GenerateRequest(
520 + token_ids=[1, 2, 3],
521 + sampling_params=SamplingParams(max_tokens=2, n=2),
522 + model=MODEL_NAME,
523 + stream=True,
524 + return_token_ids=True,
525 + )
526 +
527 + response = await serving.serve_tokens(request)
528 + parsed = _parse_sse_chunks([chunk async for chunk in response])
529 + data_chunks = [c for c in parsed if isinstance(c, dict) and c.get("choices")]
530 +
531 + assert [c["choices"][0]["token_ids"] for c in data_chunks] == [
532 + [10],
533 + [11],
534 + [20],
535 + [21],
536 + ]
537 + assert data_chunks[0]["prompt_token_ids"] == [1, 2, 3]
538 + assert data_chunks[0]["mm_placeholders"] == MM_PLACEHOLDERS
539 + for chunk in data_chunks[1:]:
540 + assert "prompt_token_ids" not in chunk
541 + assert "mm_placeholders" not in chunk
542 +
543 +
544 +@pytest.mark.asyncio
545 +async def test_stream_omits_prompt_metadata_by_default():
546 + engine = _mock_engine()
547 +
548 + async def mock_generate(*args, **kwargs):
549 + yield _make_request_output(
550 + "req-1", token_ids=[10], finish_reason="stop", finished=True
551 + )
552 +
553 + engine.generate = MagicMock(side_effect=mock_generate)
554 + serving = _build_mm_serving_tokens(engine)
555 + request = GenerateRequest(
556 + token_ids=[1, 2, 3],
557 + sampling_params=SamplingParams(max_tokens=1),
558 + model=MODEL_NAME,
559 + stream=True,
560 + )
561 +
562 + response = await serving.serve_tokens(request)
563 + parsed = _parse_sse_chunks([chunk async for chunk in response])
564 + data_chunks = [c for c in parsed if isinstance(c, dict) and c.get("choices")]
565 +
566 + assert data_chunks
567 + for chunk in data_chunks:
568 + assert "prompt_token_ids" not in chunk
569 + assert "mm_placeholders" not in chunk
570 +
571 +
572 +@pytest.mark.asyncio
573 +async def test_stream_text_only_prompt_omits_mm_placeholders_key():
574 + engine = _mock_engine()
575 +
576 + async def mock_generate(*args, **kwargs):
577 + yield _make_request_output(
578 + "req-1", token_ids=[10], finish_reason="stop", finished=True
579 + )
580 +
581 + engine.generate = MagicMock(side_effect=mock_generate)
582 + serving = _build_serving_tokens(engine)
583 + request = GenerateRequest(
584 + token_ids=[1, 2, 3],
585 + sampling_params=SamplingParams(max_tokens=1),
586 + model=MODEL_NAME,
587 + stream=True,
588 + return_token_ids=True,
589 + )
590 +
591 + response = await serving.serve_tokens(request)
592 + parsed = _parse_sse_chunks([chunk async for chunk in response])
593 + data_chunks = [c for c in parsed if isinstance(c, dict) and c.get("choices")]
594 +
595 + assert data_chunks[0]["prompt_token_ids"] == [1, 2, 3]
596 + assert "mm_placeholders" not in data_chunks[0]
597 +
598 +
599 +@pytest.mark.asyncio
600 +async def test_stream_zero_token_completion_still_delivers_prompt_metadata():
601 + engine = _mock_engine()
602 +
603 + async def mock_generate(*args, **kwargs):
604 + yield _make_request_output(
605 + "req-1", token_ids=[], finish_reason="stop", finished=True
606 + )
607 +
608 + engine.generate = MagicMock(side_effect=mock_generate)
609 + serving = _build_mm_serving_tokens(engine)
610 + request = GenerateRequest(
611 + token_ids=[1, 2, 3],
612 + sampling_params=SamplingParams(max_tokens=1),
613 + model=MODEL_NAME,
614 + stream=True,
615 + return_token_ids=True,
616 + )
617 +
618 + response = await serving.serve_tokens(request)
619 + parsed = _parse_sse_chunks([chunk async for chunk in response])
620 + data_chunks = [c for c in parsed if isinstance(c, dict) and c.get("choices")]
621 +
622 + assert len(data_chunks) == 1
623 + assert data_chunks[0]["choices"][0]["token_ids"] == []
624 + assert data_chunks[0]["choices"][0]["finish_reason"] == "stop"
625 + assert data_chunks[0]["prompt_token_ids"] == [1, 2, 3]
626 + assert data_chunks[0]["mm_placeholders"] == MM_PLACEHOLDERS
627 +
628 +
629 +@pytest.mark.asyncio
630 +async def test_stream_zero_token_completion_emits_no_chunk_by_default():
631 + engine = _mock_engine()
632 +
633 + async def mock_generate(*args, **kwargs):
634 + yield _make_request_output(
635 + "req-1", token_ids=[], finish_reason="stop", finished=True
636 + )
637 +
638 + engine.generate = MagicMock(side_effect=mock_generate)
639 + serving = _build_serving_tokens(engine)
640 + request = GenerateRequest(
641 + token_ids=[1, 2, 3],
642 + sampling_params=SamplingParams(max_tokens=1),
643 + model=MODEL_NAME,
644 + stream=True,
645 + )
646 +
647 + response = await serving.serve_tokens(request)
648 + parsed = _parse_sse_chunks([chunk async for chunk in response])
649 +
650 + assert not [c for c in parsed if isinstance(c, dict) and c.get("choices")]
651 + assert parsed[-1] == "[DONE]"
652 +
653 +
654 @pytest.mark.asyncio
655 async def test_stream_error_mid_generation():
656 """finish_reason='error' mid-stream yields error chunk then [DONE]."""
tests/entrypoints/scale_out/token_in_token_out/test_serving_multimodal_tokens.py
+49 -25
@@ -154,11 +154,28 @@ async def test_render_to_generate_roundtrip(client, test_image):
154 )
155
156
157 -@pytest.mark.asyncio
158 -async def test_content_parts_generates_tokens(client, test_image):
159 - """content_parts with raw media should produce output tokens."""
160 - data_url = encode_image_url(test_image, format="PNG")
157 +def _collapse_placeholders(
158 + token_ids: list[int], mm_placeholders: dict[str, list[dict[str, int]]]
159 +) -> list[int]:
160 + """Undo placeholder expansion: each expanded range becomes one token.
161 +
162 + ``/generate`` with ``content_parts`` expects unexpanded placeholders and
163 + expands them itself, so the roundtrip must start from a collapsed prompt.
164 + """
165 + ranges = sorted(
166 + (r["offset"], r["length"]) for rs in mm_placeholders.values() for r in rs
167 + )
168 + collapsed: list[int] = []
169 + pos = 0
170 + for offset, length in ranges:
171 + collapsed.extend(token_ids[pos:offset])
172 + collapsed.append(token_ids[offset])
173 + pos = offset + length
174 + collapsed.extend(token_ids[pos:])
175 + return collapsed
176
177 +
178 +async def _render_image_prompt(client, data_url: str) -> dict:
179 render_resp = await client.post(
180 RENDER_ENDPOINT,
181 json={
@@ -175,14 +192,26 @@ async def test_content_parts_generates_tokens(client, test_image):
192 },
193 )
194 render_resp.raise_for_status()
178 - token_ids = render_resp.json()["token_ids"]
195 + return render_resp.json()
196 +
197 +
198 +@pytest.mark.asyncio
199 +async def test_content_parts_generates_tokens(client, test_image):
200 + """Unexpanded prompt + content_parts must expand exactly like /render."""
201 + data_url = encode_image_url(test_image, format="PNG")
202 + render_data = await _render_image_prompt(client, data_url)
203 + expanded = render_data["token_ids"]
204 + mm_placeholders = render_data["features"]["mm_placeholders"]
205 + unexpanded = _collapse_placeholders(expanded, mm_placeholders)
206 + assert len(unexpanded) < len(expanded)
207
208 gen_resp = await client.post(
209 GEN_ENDPOINT,
210 json={
183 - "token_ids": token_ids,
211 + "token_ids": unexpanded,
212 "content_parts": [{"type": "image_url", "url": data_url}],
213 "sampling_params": {"max_tokens": 10, "temperature": 0.0},
214 + "return_token_ids": True,
215 },
216 )
217 gen_resp.raise_for_status()
@@ -192,6 +221,8 @@ async def test_content_parts_generates_tokens(client, test_image):
221 choice = gen_data["choices"][0]
222 assert "token_ids" in choice
223 assert len(choice["token_ids"]) > 0
224 + assert gen_data["prompt_token_ids"] == expanded
225 + assert gen_data["mm_placeholders"] == mm_placeholders
226
227
228 @pytest.mark.asyncio
@@ -200,33 +231,20 @@ async def test_content_parts_streaming(client, test_image):
231 import json as json_mod
232
233 data_url = encode_image_url(test_image, format="PNG")
203 -
204 - render_resp = await client.post(
205 - RENDER_ENDPOINT,
206 - json={
207 - "model": MODEL_NAME,
208 - "messages": [
209 - {
210 - "role": "user",
211 - "content": [
212 - {"type": "image_url", "image_url": {"url": data_url}},
213 - {"type": "text", "text": "Describe this."},
214 - ],
215 - }
216 - ],
217 - },
218 - )
219 - render_resp.raise_for_status()
220 - token_ids = render_resp.json()["token_ids"]
234 + render_data = await _render_image_prompt(client, data_url)
235 + expanded = render_data["token_ids"]
236 + mm_placeholders = render_data["features"]["mm_placeholders"]
237 + unexpanded = _collapse_placeholders(expanded, mm_placeholders)
238
239 async with client.stream(
240 "POST",
241 GEN_ENDPOINT,
242 json={
226 - "token_ids": token_ids,
243 + "token_ids": unexpanded,
244 "content_parts": [{"type": "image_url", "url": data_url}],
245 "sampling_params": {"max_tokens": 8, "temperature": 0.0},
246 "stream": True,
247 + "return_token_ids": True,
248 },
249 ) as resp:
250 resp.raise_for_status()
@@ -236,3 +254,9 @@ async def test_content_parts_streaming(client, test_image):
254 chunks.append(json_mod.loads(line[6:]))
255
256 assert len(chunks) > 0
257 + prompt_metadata_chunks = [
258 + chunk for chunk in chunks if chunk.get("prompt_token_ids") is not None
259 + ]
260 + assert len(prompt_metadata_chunks) == 1
261 + assert prompt_metadata_chunks[0]["prompt_token_ids"] == expanded
262 + assert prompt_metadata_chunks[0]["mm_placeholders"] == mm_placeholders
vllm/entrypoints/scale_out/token_in_token_out/mm_features.py
+20 -8
@@ -117,6 +117,24 @@ def mm_kwargs_from_features(
117 return mm_kwargs
118
119
120 +def placeholder_ranges_from_engine_input(
121 + engine_input: EngineInput,
122 +) -> dict[str, list[PlaceholderRangeInfo]] | None:
123 + """Return per-modality placeholder ranges, or ``None`` for text-only prompts."""
124 + if engine_input.get("type") != "multimodal":
125 + return None
126 +
127 + raw_placeholders: MultiModalPlaceholders = cast(MultiModalInput, engine_input)[
128 + "mm_placeholders"
129 + ]
130 + return {
131 + modality: [
132 + PlaceholderRangeInfo(offset=p.offset, length=p.length) for p in ranges
133 + ]
134 + for modality, ranges in raw_placeholders.items()
135 + }
136 +
137 +
138 def extract_mm_features(
139 engine_input: EngineInput,
140 *,
@@ -134,14 +152,8 @@ def extract_mm_features(
152
153 mm_engine_input = cast(MultiModalInput, engine_input)
154 mm_hashes: MultiModalHashes = mm_engine_input["mm_hashes"]
137 - raw_placeholders: MultiModalPlaceholders = mm_engine_input["mm_placeholders"]
138 -
139 - mm_placeholders = {
140 - modality: [
141 - PlaceholderRangeInfo(offset=p.offset, length=p.length) for p in ranges
142 - ]
143 - for modality, ranges in raw_placeholders.items()
144 - }
155 + mm_placeholders = placeholder_ranges_from_engine_input(engine_input)
156 + assert mm_placeholders is not None
157
158 kwargs_data: dict[str, list[str | None]] | None = None
159 mm_metadata: dict[str, list[str | None]] | None = None
vllm/entrypoints/scale_out/token_in_token_out/protocol.py
+16
@@ -211,6 +211,15 @@ class GenerateRequest(BaseModel):
211
212 model: str | None = None
213
214 + return_token_ids: bool | None = Field(
215 + default=None,
216 + description=(
217 + "If true, return the final prompt token IDs after multimodal "
218 + "placeholder expansion, together with multimodal placeholder ranges. "
219 + "In streaming mode, this metadata is included only in the first chunk."
220 + ),
221 + )
222 +
223 stream: bool | None = False
224 stream_options: StreamOptions | None = None
225 cache_salt: str | None = Field(
@@ -255,6 +264,9 @@ class GenerateRequest(BaseModel):
264 # ``SamplingParams`` instance (e.g. from internal callers that have
265 # already resolved values), in which case all fields are considered set.
266 _sampling_params_provided_keys: set[str] | None = PrivateAttr(default=None)
267 + _response_mm_placeholders: dict[str, list[PlaceholderRangeInfo]] | None = (
268 + PrivateAttr(default=None)
269 + )
270
271 @model_validator(mode="wrap")
272 @classmethod
@@ -354,6 +366,8 @@ class GenerateStreamResponse(BaseModel):
366 )
367 choices: list[GenerateResponseStreamChoice]
368 usage: UsageInfo | None = Field(default=None)
369 + prompt_token_ids: list[int] | None = None
370 + mm_placeholders: dict[str, list[PlaceholderRangeInfo]] | None = None
371
372
373 class GenerateResponse(BaseModel):
@@ -370,6 +384,8 @@ class GenerateResponse(BaseModel):
384 choices: list[GenerateResponseChoice]
385 usage: UsageInfo | None = Field(default=None)
386 prompt_logprobs: list[dict[int, Logprob] | None] | None = None
387 + prompt_token_ids: list[int] | None = None
388 + mm_placeholders: dict[str, list[PlaceholderRangeInfo]] | None = None
389
390 kv_transfer_params: dict[str, Any] | None = Field(
391 default=None,
vllm/entrypoints/scale_out/token_in_token_out/serving.py
+35 -3
@@ -44,7 +44,10 @@ from vllm.sampling_params import RequestOutputKind, SamplingParams
44 from vllm.utils.collection_utils import as_list
45 from vllm.utils.serial_utils import numpy2base64
46
47 -from .mm_features import mm_kwargs_from_features
47 +from .mm_features import (
48 + mm_kwargs_from_features,
49 + placeholder_ranges_from_engine_input,
50 +)
51 from .protocol import (
52 GenerateRequest,
53 GenerateResponse,
@@ -195,6 +198,14 @@ class ServingTokens(GenerateBaseServing):
198 skip_mm_cache=True,
199 )
200
201 + # Offsets are relative to the decoder prompt, so they are not
202 + # meaningful for encoder-decoder models.
203 + request._response_mm_placeholders = (
204 + placeholder_ranges_from_engine_input(engine_input)
205 + if request.return_token_ids and not self.model_config.is_encoder_decoder
206 + else None
207 + )
208 +
209 # Schedule the request and get the result generator.
210 result_generator: AsyncGenerator[RequestOutput, None] | None = None
211
@@ -359,6 +370,10 @@ class ServingTokens(GenerateBaseServing):
370 choices=choices,
371 usage=usage,
372 prompt_logprobs=clamp_prompt_logprobs(final_res.prompt_logprobs),
373 + prompt_token_ids=(
374 + final_res.prompt_token_ids if request.return_token_ids else None
375 + ),
376 + mm_placeholders=request._response_mm_placeholders,
377 kv_transfer_params=final_res.kv_transfer_params,
378 ec_transfer_params=final_res.ec_transfer_params,
379 )
@@ -395,6 +410,7 @@ class ServingTokens(GenerateBaseServing):
410 num_prompt_tokens = 0
411 num_generated_tokens: list[int] = []
412 first_iteration = True
413 + prompt_token_ids: list[int] | None = None
414 num_cached_tokens = None
415 sampling_params: SamplingParams = request.sampling_params
416
@@ -407,6 +423,8 @@ class ServingTokens(GenerateBaseServing):
423 if first_iteration:
424 if res.prompt_token_ids is not None:
425 num_prompt_tokens = len(res.prompt_token_ids)
426 + if request.return_token_ids:
427 + prompt_token_ids = res.prompt_token_ids
428 if res.encoder_prompt_token_ids is not None:
429 num_prompt_tokens += len(res.encoder_prompt_token_ids)
430 num_cached_tokens = res.num_cached_tokens
@@ -421,7 +439,11 @@ class ServingTokens(GenerateBaseServing):
439 finish_reason = output.finish_reason
440 self._raise_if_error(finish_reason, request_id)
441
424 - if not delta_token_ids:
442 + # Still emit a terminal empty chunk while prompt metadata
443 + # is pending, so zero-token completions deliver it.
444 + if not delta_token_ids and (
445 + finish_reason is None or prompt_token_ids is None
446 + ):
447 continue
448
449 if sampling_params.logprobs is not None:
@@ -453,6 +475,10 @@ class ServingTokens(GenerateBaseServing):
475 )
476 ],
477 )
478 + if prompt_token_ids is not None:
479 + chunk.prompt_token_ids = prompt_token_ids
480 + chunk.mm_placeholders = request._response_mm_placeholders
481 + prompt_token_ids = None
482 if include_continuous_usage:
483 chunk.usage = UsageInfo(
484 prompt_tokens=num_prompt_tokens,
@@ -460,7 +486,13 @@ class ServingTokens(GenerateBaseServing):
486 total_tokens=(num_prompt_tokens + num_generated_tokens[i]),
487 )
488
463 - yield f"data: {chunk.model_dump_json()}\n\n"
489 + # Omit absent prompt metadata (Rust skips None fields).
490 + exclude = {
491 + name
492 + for name in ("prompt_token_ids", "mm_placeholders")
493 + if getattr(chunk, name) is None
494 + }
495 + yield f"data: {chunk.model_dump_json(exclude=exclude)}\n\n"
496
497 total_completion_tokens = sum(num_generated_tokens)
498 final_usage_info = UsageInfo(