[Rust Frontend][gRPC] Add multimodal image inference (#50368)
Co-authored-by: Bugen Zhao <i@bugenzhao.com> Signed-off-by: Connor Carpenter <connorc@nvidia.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Connor Carpenter committed
Aug 4, 2026 at 02:34 UTC
e98a8774cba6f518a8de4433801cc32323a46071
12 files changed
+553
-69
rust/Cargo.lock
+1
@@ -5753,6 +5753,7 @@ dependencies = [
5753
"tracing",
5754
"tracing-futures",
5755
"tracing-subscriber",
5756
+ "url",
5757
"uuid",
5758
"validator",
5759
"vllm-chat",
rust/proto/inference.proto
+25
@@ -47,6 +47,9 @@ message GenerateRequest {
47
int32 priority = 12;
48
49
optional string session_id = 13;
50
+
51
+ // Multimodal inputs aligned with placeholder markers in token_ids.
52
+ repeated MediaItem media = 14;
53
}
54
55
message RandomSampling {
@@ -202,3 +205,25 @@ message CandidateTokenInfo {
205
message TokenIds {
206
repeated uint32 ids = 1;
207
}
208
+
209
+// ======================================================================================
210
+// Media
211
+// ======================================================================================
212
+
213
+enum Modality {
214
+ MODALITY_UNSPECIFIED = 0;
215
+ MODALITY_IMAGE = 1;
216
+ MODALITY_VIDEO = 2;
217
+ MODALITY_AUDIO = 3;
218
+}
219
+
220
+message MediaItem {
221
+ Modality modality = 1;
222
+ oneof source {
223
+ string url = 2; // http:// or https://
224
+ string data_uri = 3; // data:
225
+ bytes raw_bytes = 4;
226
+ }
227
+ string mime_type = 5;
228
+ string uuid = 6;
229
+}
rust/src/chat/src/lib.rs
+29
-1
@@ -21,6 +21,7 @@ pub use event::{
21
AssistantToolCall, ChatEvent,
22
};
23
use futures::{StreamExt, TryStreamExt as _};
24
+pub use llm_multimodal::MediaContentPart;
25
pub use output::{
26
ChatOutputProcessor, DefaultChatOutputProcessor, DynChatOutputProcessor,
27
HarmonyChatOutputProcessor,
@@ -41,6 +42,7 @@ pub use request::{
42
ChatToolChoice, GenerationPromptMode, ReasoningEffort, SamplingParams,
43
};
44
pub use stream::{ChatEventStream, ChatEventStreamTrait, CollectedAssistantMessage};
45
+pub use vllm_engine_core_client::protocol::multimodal::MmFeatures;
46
pub use vllm_llm::FinishReason;
47
48
mod backend;
@@ -55,7 +57,6 @@ mod stream;
57
58
use vllm_engine_core_client::EngineCoreClient;
59
use vllm_engine_core_client::protocol::dtype::ModelDtype;
58
-use vllm_engine_core_client::protocol::multimodal::MmFeatures;
60
use vllm_engine_core_client::protocol::request::ReasoningParserKwargs;
61
use vllm_llm::Llm;
62
use vllm_text::{Prompt, TextLlm, TextRequest};
@@ -136,6 +137,24 @@ impl ChatRequestProcessor {
137
}
138
}
139
140
+ /// Prepare media for an already-tokenized request.
141
+ async fn prepare_media(
142
+ &self,
143
+ media: Vec<MediaContentPart>,
144
+ token_ids: &mut Vec<u32>,
145
+ ) -> Result<Option<MmFeatures>> {
146
+ if media.is_empty() {
147
+ return Ok(None);
148
+ }
149
+ let info = self
150
+ .backend
151
+ .multimodal_model_info()
152
+ .ok_or(Error::UnsupportedMultimodalRenderer)?;
153
+ let model_dtype = self.model_dtype.ok_or(Error::UnsupportedMultimodalRenderer)?;
154
+ let features = info.prepare_multimodal(media, token_ids, model_dtype).await?;
155
+ Ok(Some(features))
156
+ }
157
+
158
/// Prepare one chat request without submitting it to an engine.
159
pub async fn prepare(
160
&self,
@@ -262,6 +281,15 @@ impl ChatLlm {
281
self.processor.backend.multimodal_model_info().is_some()
282
}
283
284
+ /// Prepare media for an already-tokenized request.
285
+ pub async fn prepare_media(
286
+ &self,
287
+ media: Vec<MediaContentPart>,
288
+ token_ids: &mut Vec<u32>,
289
+ ) -> Result<Option<MmFeatures>> {
290
+ self.processor.prepare_media(media, token_ids).await
291
+ }
292
+
293
/// Effective tool-call parser name for this model, if parsing is enabled.
294
pub fn tool_call_parser_name(&self) -> Option<&str> {
295
match &self.tool_call_parser {
rust/src/chat/src/multimodal.rs
+1
-1
@@ -696,7 +696,7 @@ impl MultimodalModelInfo {
696
/// `prompt_token_ids` is mutated in place because placeholder expansion
697
/// changes both the final prompt and the offsets recorded in
698
/// `PlaceholderRange`.
699
- async fn prepare_multimodal(
699
+ pub(crate) async fn prepare_multimodal(
700
&self,
701
media_parts: Vec<MediaContentPart>,
702
prompt_token_ids: &mut Vec<u32>,
rust/src/cmd/src/cli.rs
+1
-1
@@ -154,7 +154,7 @@ pub struct SharedRuntimeArgs {
154
#[arg(long, value_parser = clap::value_parser!(i32).range(-1..), allow_negative_numbers = true)]
155
#[serde(default)]
156
pub max_logprobs: Option<i32>,
157
- /// TCP port for the gRPC Generate service. When not set, no gRPC server is
157
+ /// TCP port for the gRPC Inference service. When not set, no gRPC server is
158
/// started.
159
#[arg(long)]
160
#[serde(default)]
rust/src/server/Cargo.toml
+1
@@ -42,6 +42,7 @@ tower-http.workspace = true
42
tracing.workspace = true
43
tracing-futures.workspace = true
44
tracing-subscriber.workspace = true
45
+url.workspace = true
46
uuid.workspace = true
47
validator.workspace = true
48
vllm-chat.workspace = true
rust/src/server/src/config.rs
+1
-1
@@ -207,7 +207,7 @@ pub struct Config {
207
/// When `true`, suppress periodic stats logging (throughput, queue depth,
208
/// cache usage).
209
pub disable_log_stats: bool,
210
- /// TCP port for the gRPC Generate service. When `None`, no gRPC server is
210
+ /// TCP port for the gRPC Inference service. When `None`, no gRPC server is
211
/// started.
212
pub grpc_port: Option<u16>,
213
/// Maximum time to wait for active HTTP/gRPC requests to drain on shutdown.
rust/src/server/src/grpc/convert.rs
+74
@@ -5,7 +5,9 @@
5
//! request/response types.
6
7
use tonic::Status;
8
+use url::Url;
9
use uuid::Uuid;
10
+use vllm_chat::MediaContentPart;
11
use vllm_engine_core_client::protocol::output::StopReason;
12
use vllm_engine_core_client::protocol::structured_outputs::StructuredOutputsParams;
13
use vllm_text::{
@@ -15,6 +17,78 @@ use vllm_text::{
17
18
use super::pb;
19
20
+pub fn media_parts_from_request(
21
+ media: Vec<pb::MediaItem>,
22
+) -> Result<Vec<MediaContentPart>, Status> {
23
+ let mut parts = Vec::with_capacity(media.len());
24
+ for (index, item) in media.into_iter().enumerate() {
25
+ match item.modality() {
26
+ pb::Modality::Image => {}
27
+ pb::Modality::Unspecified => {
28
+ return Err(Status::invalid_argument(format!(
29
+ "media[{index}].modality is required"
30
+ )));
31
+ }
32
+ other => {
33
+ return Err(Status::unimplemented(format!(
34
+ "media[{index}].modality {other:?} is not supported by the gRPC service"
35
+ )));
36
+ }
37
+ }
38
+ let uuid = (!item.uuid.is_empty()).then_some(item.uuid);
39
+ let mime_type = (!item.mime_type.is_empty()).then_some(item.mime_type);
40
+ let part = match item.source {
41
+ Some(pb::media_item::Source::Url(url)) => {
42
+ validate_media_uri(index, "url", &url, &["http", "https"])?;
43
+ MediaContentPart::ImageUrl {
44
+ url,
45
+ detail: None,
46
+ uuid,
47
+ }
48
+ }
49
+ Some(pb::media_item::Source::DataUri(uri)) => {
50
+ validate_media_uri(index, "data_uri", &uri, &["data"])?;
51
+ MediaContentPart::ImageUrl {
52
+ url: uri,
53
+ detail: None,
54
+ uuid,
55
+ }
56
+ }
57
+ Some(pb::media_item::Source::RawBytes(bytes)) => MediaContentPart::ImageData {
58
+ data: bytes,
59
+ mime_type,
60
+ uuid,
61
+ detail: None,
62
+ },
63
+ None => {
64
+ return Err(Status::invalid_argument(format!(
65
+ "media[{index}].source is required"
66
+ )));
67
+ }
68
+ };
69
+ parts.push(part);
70
+ }
71
+ Ok(parts)
72
+}
73
+
74
+fn validate_media_uri(
75
+ index: usize,
76
+ field: &str,
77
+ value: &str,
78
+ allowed_schemes: &[&str],
79
+) -> Result<(), Status> {
80
+ let uri = Url::parse(value).map_err(|_| {
81
+ Status::invalid_argument(format!("media[{index}].{field} is not a valid URI"))
82
+ })?;
83
+ if !allowed_schemes.contains(&uri.scheme()) {
84
+ return Err(Status::invalid_argument(format!(
85
+ "media[{index}].{field} must use the {} scheme",
86
+ allowed_schemes.join(" or ")
87
+ )));
88
+ }
89
+ Ok(())
90
+}
91
+
92
// ========================================================================================
93
// Request conversion
94
// ========================================================================================
rust/src/server/src/grpc/inference.rs
+189
-56
@@ -3,14 +3,18 @@
3
4
use std::pin::Pin;
5
use std::sync::Arc;
6
+use std::time::Instant;
7
8
use futures::{Stream, StreamExt as _};
9
use thiserror_ext::AsReport as _;
10
use tokio::sync::mpsc;
11
use tokio_stream::wrappers::ReceiverStream;
12
use tonic::{Request, Response, Status};
12
-use tracing::info;
13
-use vllm_text::{DecodedTextEvent, TextOutputStreamExt as _};
13
+use tracing::{Span, info, info_span, warn};
14
+use tracing_futures::Instrument as _;
15
+use uuid::Uuid;
16
+use vllm_llm::current_unix_timestamp_secs;
17
+use vllm_text::{DecodedTextEvent, Prompt, TextOutputStreamExt as _, TextRequest};
18
19
use super::convert::{self, ResponseOpts};
20
use super::{InferenceServer, pb};
@@ -23,10 +27,92 @@ pub struct InferenceServiceImpl {
27
state: Arc<AppState>,
28
}
29
30
+struct PreparedGrpcRequest {
31
+ text_request: TextRequest,
32
+ request_span: Span,
33
+ started_at: Instant,
34
+}
35
+
36
impl InferenceServiceImpl {
37
pub fn new(state: Arc<AppState>) -> Self {
38
Self { state }
39
}
40
+
41
+ async fn prepare_request(
42
+ &self,
43
+ mut proto_request: pb::GenerateRequest,
44
+ stream: bool,
45
+ rpc: &'static str,
46
+ ) -> Result<PreparedGrpcRequest, Status> {
47
+ let started_at = Instant::now();
48
+ let arrival_time = current_unix_timestamp_secs();
49
+ if proto_request.request_id.is_empty() {
50
+ proto_request.request_id = Uuid::new_v4().to_string();
51
+ }
52
+ let request_id = proto_request.request_id.clone();
53
+ let model = if proto_request.model.is_empty() {
54
+ self.state.primary_model_name().to_string()
55
+ } else {
56
+ proto_request.model.clone()
57
+ };
58
+ let media_count = proto_request.media.len();
59
+ let request_span = info_span!(
60
+ "grpc_inference",
61
+ %request_id,
62
+ rpc,
63
+ %model,
64
+ media_count,
65
+ );
66
+ info!(parent: &request_span, "gRPC inference request received");
67
+
68
+ let result = async {
69
+ let media = std::mem::take(&mut proto_request.media);
70
+ let mut text_request =
71
+ convert::to_text_request(proto_request, stream, self.state.served_model_names())?;
72
+ text_request.arrival_time = Some(arrival_time);
73
+
74
+ let media = convert::media_parts_from_request(media)?;
75
+ if !media.is_empty() {
76
+ let Prompt::TokenIds(mut token_ids) = text_request.prompt else {
77
+ return Err(Status::invalid_argument(
78
+ "multimodal gRPC requests must provide token_ids input",
79
+ ));
80
+ };
81
+ let mm_features = self
82
+ .state
83
+ .chat
84
+ .prepare_media(media, &mut token_ids)
85
+ .await
86
+ .map_err(|error| Status::internal(error.to_report_string()))?;
87
+ text_request.prompt = Prompt::TokenIds(token_ids);
88
+ text_request.mm_features = mm_features;
89
+ }
90
+
91
+ Ok(text_request)
92
+ }
93
+ .instrument(request_span.clone())
94
+ .await;
95
+
96
+ let text_request = match result {
97
+ Ok(text_request) => text_request,
98
+ Err(status) => {
99
+ warn!(
100
+ parent: &request_span,
101
+ grpc_code = ?status.code(),
102
+ elapsed_ms = started_at.elapsed().as_millis() as u64,
103
+ error = %status.message(),
104
+ "gRPC inference request preparation failed"
105
+ );
106
+ return Err(status);
107
+ }
108
+ };
109
+
110
+ Ok(PreparedGrpcRequest {
111
+ text_request,
112
+ request_span,
113
+ started_at,
114
+ })
115
+ }
116
}
117
118
#[tonic::async_trait]
@@ -41,16 +127,31 @@ impl pb::inference_server::Inference for InferenceServiceImpl {
127
) -> Result<Response<pb::GenerateResponse>, Status> {
128
let proto_req = request.into_inner();
129
let response_opts = ResponseOpts::from_proto(proto_req.response.as_ref());
44
- let text_request =
45
- convert::to_text_request(proto_req, false, self.state.served_model_names())?;
130
+ let PreparedGrpcRequest {
131
+ text_request,
132
+ request_span,
133
+ started_at,
134
+ } = self.prepare_request(proto_req, false, "Generate").await?;
135
47
- let request_id = text_request.request_id.clone();
48
- info!(%request_id, "grpc generate (unary)");
136
+ let stream = self
137
+ .state
138
+ .chat
139
+ .text()
140
+ .generate(text_request)
141
+ .instrument(request_span.clone())
142
+ .await
143
+ .map_err(|error| log_text_error(&request_span, started_at, "submission", error))?;
144
50
- let stream = self.state.chat.text().generate(text_request).await;
51
- let stream = stream.map_err(text_error_to_status)?;
52
-
53
- let collected = stream.collect_output().await.map_err(text_error_to_status)?;
145
+ let collected = stream
146
+ .collect_output()
147
+ .instrument(request_span.clone())
148
+ .await
149
+ .map_err(|error| log_text_error(&request_span, started_at, "collection", error))?;
150
+ info!(
151
+ parent: &request_span,
152
+ elapsed_ms = started_at.elapsed().as_millis() as u64,
153
+ "gRPC inference request completed"
154
+ );
155
156
// Build the single aggregated response.
157
let prompt_info = convert::to_prompt_info(
@@ -87,59 +188,73 @@ impl pb::inference_server::Inference for InferenceServiceImpl {
188
) -> Result<Response<Self::GenerateStreamStream>, Status> {
189
let proto_req = request.into_inner();
190
let response_opts = ResponseOpts::from_proto(proto_req.response.as_ref());
90
- let text_request =
91
- convert::to_text_request(proto_req, true, self.state.served_model_names())?;
92
-
93
- let request_id = text_request.request_id.clone();
94
- info!(%request_id, "grpc generate (stream)");
191
+ let PreparedGrpcRequest {
192
+ text_request,
193
+ request_span,
194
+ started_at,
195
+ } = self.prepare_request(proto_req, true, "GenerateStream").await?;
196
96
- let stream = self.state.chat.text().generate(text_request).await;
97
- let stream = stream.map_err(text_error_to_status)?;
197
+ let stream = self
198
+ .state
199
+ .chat
200
+ .text()
201
+ .generate(text_request)
202
+ .instrument(request_span.clone())
203
+ .await
204
+ .map_err(|error| log_text_error(&request_span, started_at, "submission", error))?;
205
206
let (tx, rx) = mpsc::channel(32);
207
101
- tokio::spawn(async move {
102
- futures::pin_mut!(stream);
103
- while let Some(event) = stream.next().await {
104
- let response = match event {
105
- Err(e) => Err(text_error_to_status(e)),
106
- Ok(DecodedTextEvent::Start {
107
- prompt_token_ids,
108
- prompt_logprobs,
109
- }) => {
110
- let prompt_info = convert::to_prompt_info(
111
- &prompt_token_ids,
112
- prompt_logprobs.as_ref(),
113
- &response_opts,
114
- );
115
- Ok(pb::GenerateResponse {
116
- prompt_info: Some(prompt_info),
117
- outputs: None,
118
- })
119
- }
120
- Ok(DecodedTextEvent::TextDelta {
121
- delta,
122
- token_ids,
123
- logprobs,
124
- finished,
125
- }) => Ok(pb::GenerateResponse {
126
- prompt_info: None,
127
- outputs: Some(convert::to_sequence_output(
128
- &delta,
129
- &token_ids,
130
- logprobs.as_ref(),
131
- finished.as_ref(),
132
- &response_opts,
133
- )),
134
- }),
135
- };
208
+ let task_span = request_span.clone();
209
+ tokio::spawn(
210
+ async move {
211
+ futures::pin_mut!(stream);
212
+ while let Some(event) = stream.next().await {
213
+ let response = match event {
214
+ Err(error) => Err(log_text_error(&task_span, started_at, "stream", error)),
215
+ Ok(DecodedTextEvent::Start {
216
+ prompt_token_ids,
217
+ prompt_logprobs,
218
+ }) => {
219
+ let prompt_info = convert::to_prompt_info(
220
+ &prompt_token_ids,
221
+ prompt_logprobs.as_ref(),
222
+ &response_opts,
223
+ );
224
+ Ok(pb::GenerateResponse {
225
+ prompt_info: Some(prompt_info),
226
+ outputs: None,
227
+ })
228
+ }
229
+ Ok(DecodedTextEvent::TextDelta {
230
+ delta,
231
+ token_ids,
232
+ logprobs,
233
+ finished,
234
+ }) => Ok(pb::GenerateResponse {
235
+ prompt_info: None,
236
+ outputs: Some(convert::to_sequence_output(
237
+ &delta,
238
+ &token_ids,
239
+ logprobs.as_ref(),
240
+ finished.as_ref(),
241
+ &response_opts,
242
+ )),
243
+ }),
244
+ };
245
137
- if tx.send(response).await.is_err() {
138
- // Client disconnected.
139
- break;
246
+ if tx.send(response).await.is_err() {
247
+ break;
248
+ }
249
}
250
+ info!(
251
+ parent: &task_span,
252
+ elapsed_ms = started_at.elapsed().as_millis() as u64,
253
+ "gRPC inference stream closed"
254
+ );
255
}
142
- });
256
+ .instrument(request_span),
257
+ );
258
259
let response_stream = ReceiverStream::new(rx);
260
Ok(Response::new(Box::pin(response_stream)))
@@ -154,3 +269,21 @@ fn text_error_to_status(error: vllm_text::Error) -> Status {
269
Status::internal(message)
270
}
271
}
272
+
273
+fn log_text_error(
274
+ request_span: &Span,
275
+ started_at: Instant,
276
+ phase: &'static str,
277
+ error: vllm_text::Error,
278
+) -> Status {
279
+ let status = text_error_to_status(error);
280
+ warn!(
281
+ parent: request_span,
282
+ phase,
283
+ grpc_code = ?status.code(),
284
+ elapsed_ms = started_at.elapsed().as_millis() as u64,
285
+ error = %status.message(),
286
+ "gRPC inference request failed"
287
+ );
288
+ status
289
+}
rust/src/server/src/grpc/tests.rs
+226
-5
@@ -1,6 +1,7 @@
1
// SPDX-License-Identifier: Apache-2.0
2
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+use std::fs;
5
use std::future::Future;
6
use std::io;
7
use std::pin::Pin;
@@ -204,6 +205,73 @@ impl ChatRenderer for FakeTextBackend {
205
}
206
}
207
208
+struct FakeMultimodalBackend {
209
+ model_info: vllm_chat::multimodal::MultimodalModelInfo,
210
+}
211
+
212
+impl TextBackend for FakeMultimodalBackend {
213
+ fn tokenizer(&self) -> DynTokenizer {
214
+ Arc::new(TestTokenizer::new().with_regular_token("<|image_pad|>", QWEN_IMAGE_TOKEN_ID))
215
+ }
216
+
217
+ fn model_id(&self) -> &str {
218
+ "test-model"
219
+ }
220
+}
221
+
222
+impl ChatBackend for FakeMultimodalBackend {
223
+ fn chat_renderer(&self) -> DynChatRenderer {
224
+ Arc::new(FakeTextBackend)
225
+ }
226
+
227
+ fn multimodal_model_info(&self) -> Option<&vllm_chat::multimodal::MultimodalModelInfo> {
228
+ Some(&self.model_info)
229
+ }
230
+
231
+ fn new_chat_output_processor(
232
+ &self,
233
+ request: &mut ChatRequest,
234
+ options: NewChatOutputProcessorOptions<'_>,
235
+ ) -> vllm_chat::Result<DynChatOutputProcessor> {
236
+ Ok(Box::new(DefaultChatOutputProcessor::new(
237
+ request,
238
+ self.model_id(),
239
+ self.tokenizer(),
240
+ options.tool_call_parser,
241
+ options.reasoning_parser,
242
+ )?))
243
+ }
244
+}
245
+
246
+const QWEN_IMAGE_TOKEN_ID: u32 = 151655;
247
+const TINY_PNG_DATA_URI: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
248
+
249
+fn multimodal_backend() -> Arc<dyn ChatTextBackend> {
250
+ let config_path = std::env::temp_dir().join(format!(
251
+ "vllm-grpc-qwen-config-{}.json",
252
+ uuid::Uuid::new_v4()
253
+ ));
254
+ fs::write(
255
+ &config_path,
256
+ r#"{"model_type":"qwen2_vl","image_token_id":151655}"#,
257
+ )
258
+ .expect("write qwen test config");
259
+ let model_info = vllm_chat::multimodal::MultimodalModelInfo::from_paths(
260
+ "qwen2-vl-test".to_string(),
261
+ Some("qwen2_vl".to_string()),
262
+ vllm_chat::multimodal::MultimodalConfigFiles {
263
+ config: Some(&config_path),
264
+ ..Default::default()
265
+ },
266
+ Arc::new(TestTokenizer::new().with_regular_token("<|image_pad|>", QWEN_IMAGE_TOKEN_ID)),
267
+ Default::default(),
268
+ )
269
+ .expect("load multimodal info")
270
+ .expect("qwen multimodal info is registered");
271
+ let _ = fs::remove_file(config_path);
272
+ Arc::new(FakeMultimodalBackend { model_info })
273
+}
274
+
275
/// Build the gRPC service + mock engine that serves a single request with the
276
/// given output specs. Shared by the plaintext and TLS server fixtures.
277
async fn setup_grpc_service(
@@ -215,6 +283,24 @@ async fn setup_grpc_service(
283
tokio::sync::watch::Receiver<bool>,
284
MockEngineTask,
285
) {
286
+ setup_grpc_service_with_backend(engine_id, output_specs, Arc::new(FakeTextBackend), |_| {})
287
+ .await
288
+}
289
+
290
+async fn setup_grpc_service_with_backend<F>(
291
+ engine_id: impl Into<EngineId>,
292
+ output_specs: Vec<(Vec<u32>, Option<EngineCoreFinishReason>)>,
293
+ backend: Arc<dyn ChatTextBackend>,
294
+ check_request: F,
295
+) -> (
296
+ InferenceServer<InferenceServiceImpl>,
297
+ ControlServer<ControlServiceImpl>,
298
+ tokio::sync::watch::Receiver<bool>,
299
+ MockEngineTask,
300
+)
301
+where
302
+ F: FnOnce(&EngineCoreRequest) + Send + 'static,
303
+{
304
let ipc = IpcNamespace::new().expect("create ipc namespace");
305
let handshake_address = ipc.handshake_endpoint();
306
let engine_id = engine_id.into();
@@ -227,6 +313,7 @@ async fn setup_grpc_service(
313
let add = recv_engine_message(dealer).await;
314
let request: EngineCoreRequest =
315
rmp_serde::from_slice(&add[1]).expect("decode request");
316
+ check_request(&request);
317
send_outputs(
318
push,
319
engine_outputs_for_request(&request.request_id, output_specs),
@@ -248,13 +335,11 @@ async fn setup_grpc_service(
335
.expect("connect client");
336
let engine_health = client.subscribe_health();
337
251
- let chat = ChatLlm::from_shared_backend(
252
- Llm::new(client),
253
- Arc::new(FakeTextBackend) as Arc<dyn ChatTextBackend>,
254
- );
338
+ let chat = ChatLlm::from_shared_backend(Llm::new(client), backend);
339
let state = Arc::new(AppState::new(vec!["test-model".to_string()], chat));
340
(
257
- InferenceServer::new(InferenceServiceImpl::new(state.clone())),
341
+ InferenceServer::new(InferenceServiceImpl::new(state.clone()))
342
+ .max_decoding_message_size(crate::DEFAULT_REQUEST_BODY_LIMIT_BYTES),
343
ControlServer::new(ControlServiceImpl::new(state)),
344
engine_health,
345
engine_task,
@@ -560,6 +645,142 @@ async fn unary_generate_with_token_ids_prompt() {
645
server_task.abort();
646
}
647
648
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
649
+#[serial]
650
+async fn unary_generate_prepares_multimodal_input_for_engine_core() {
651
+ let (inference_service, control_service, engine_health, engine_task) =
652
+ setup_grpc_service_with_backend(
653
+ b"engine-grpc-multimodal",
654
+ default_stream_output_specs(),
655
+ multimodal_backend(),
656
+ |request| {
657
+ let token_ids = request.prompt_token_ids.as_ref().expect("prompt token ids");
658
+ let features = request.mm_features.as_ref().expect("multimodal features");
659
+ assert_eq!(features.len(), 1);
660
+
661
+ let feature = &features[0];
662
+ assert_eq!(feature.modality, "image");
663
+ assert_eq!(feature.identifier, "image-1");
664
+ assert_eq!(feature.mm_position.offset, 1);
665
+ assert!(feature.mm_position.length > 1);
666
+ assert_eq!(token_ids.len(), feature.mm_position.length + 2);
667
+ assert_eq!(token_ids[0], 11);
668
+ assert_eq!(token_ids.last(), Some(&12));
669
+ assert!(
670
+ token_ids[feature.mm_position.offset
671
+ ..feature.mm_position.offset + feature.mm_position.length]
672
+ .iter()
673
+ .all(|token_id| *token_id == QWEN_IMAGE_TOKEN_ID)
674
+ );
675
+ },
676
+ )
677
+ .await;
678
+ let (channel, server_task) = start_grpc_test_server(
679
+ inference_service,
680
+ control_service,
681
+ engine_health,
682
+ tokio_util::sync::CancellationToken::new(),
683
+ )
684
+ .await;
685
+ let mut client = InferenceClient::new(channel);
686
+
687
+ client
688
+ .generate(pb::GenerateRequest {
689
+ request_id: "test-multimodal".to_string(),
690
+ model: "test-model".to_string(),
691
+ prompt: Some(pb::generate_request::Prompt::TokenIds(pb::TokenIds {
692
+ ids: vec![11, QWEN_IMAGE_TOKEN_ID, 12],
693
+ })),
694
+ media: vec![pb::MediaItem {
695
+ modality: pb::Modality::Image as i32,
696
+ source: Some(pb::media_item::Source::DataUri(
697
+ TINY_PNG_DATA_URI.to_string(),
698
+ )),
699
+ mime_type: String::new(),
700
+ uuid: "image-1".to_string(),
701
+ }],
702
+ stopping: Some(pb::StoppingCriteria {
703
+ max_new_tokens: 10,
704
+ ..Default::default()
705
+ }),
706
+ ..Default::default()
707
+ })
708
+ .await
709
+ .expect("multimodal generate");
710
+
711
+ engine_task.await.expect("mock engine task");
712
+ server_task.abort();
713
+}
714
+
715
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
716
+#[serial]
717
+async fn streaming_generate_rejects_text_prompt_with_media() {
718
+ let (mut client, server_task, _engine_task) = grpc_test_server(
719
+ b"engine-grpc-stream-media-text",
720
+ default_stream_output_specs(),
721
+ )
722
+ .await;
723
+
724
+ let status = client
725
+ .generate_stream(pb::GenerateRequest {
726
+ request_id: "test-stream-media-text".to_string(),
727
+ model: "test-model".to_string(),
728
+ prompt: Some(pb::generate_request::Prompt::Text(
729
+ "describe this".to_string(),
730
+ )),
731
+ media: vec![pb::MediaItem {
732
+ modality: pb::Modality::Image as i32,
733
+ source: Some(pb::media_item::Source::DataUri(
734
+ TINY_PNG_DATA_URI.to_string(),
735
+ )),
736
+ mime_type: String::new(),
737
+ uuid: "image-1".to_string(),
738
+ }],
739
+ ..Default::default()
740
+ })
741
+ .await
742
+ .expect_err("text prompts with media must be rejected");
743
+
744
+ assert_eq!(status.code(), tonic::Code::InvalidArgument);
745
+ assert_eq!(
746
+ status.message(),
747
+ "multimodal gRPC requests must provide token_ids input"
748
+ );
749
+ server_task.abort();
750
+}
751
+
752
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
753
+#[serial]
754
+async fn unary_generate_accepts_request_larger_than_tonic_default() {
755
+ let (mut client, server_task, _engine_task) =
756
+ grpc_test_server(b"engine-grpc-large-media", default_stream_output_specs()).await;
757
+
758
+ let status = client
759
+ .generate(pb::GenerateRequest {
760
+ request_id: "test-large-media".to_string(),
761
+ model: "test-model".to_string(),
762
+ prompt: Some(pb::generate_request::Prompt::Text(
763
+ "describe this".to_string(),
764
+ )),
765
+ media: vec![pb::MediaItem {
766
+ modality: pb::Modality::Image as i32,
767
+ source: Some(pb::media_item::Source::RawBytes(vec![0; 5 * 1024 * 1024])),
768
+ mime_type: "image/png".to_string(),
769
+ uuid: "image-1".to_string(),
770
+ }],
771
+ ..Default::default()
772
+ })
773
+ .await
774
+ .expect_err("text prompts with media must be rejected after decoding");
775
+
776
+ assert_eq!(status.code(), tonic::Code::InvalidArgument);
777
+ assert_eq!(
778
+ status.message(),
779
+ "multimodal gRPC requests must provide token_ids input"
780
+ );
781
+ server_task.abort();
782
+}
783
+
784
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
785
#[serial]
786
async fn unary_generate_returns_token_ids_when_requested() {
rust/src/server/src/lib.rs
+3
-1
@@ -59,6 +59,7 @@ const GRPC_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(7200);
59
/// How long the server waits for a keepalive PING reply before dropping the gRPC
60
/// connection. 20s matches the gRPC-core default.
61
const GRPC_KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(20);
62
+const DEFAULT_REQUEST_BODY_LIMIT_BYTES: usize = 32 * 1024 * 1024;
63
64
/// Resolve the public model names accepted by the frontend.
65
fn effective_served_model_names(model: &str, served_model_name: &[String]) -> Vec<String> {
@@ -215,7 +216,8 @@ where
216
let control_service =
217
grpc::ControlGrpcService::new(grpc::ControlServiceImpl::new(state.clone()));
218
let inference_service =
218
- grpc::InferenceGrpcService::new(grpc::InferenceServiceImpl::new(state.clone()));
219
+ grpc::InferenceGrpcService::new(grpc::InferenceServiceImpl::new(state.clone()))
220
+ .max_decoding_message_size(DEFAULT_REQUEST_BODY_LIMIT_BYTES);
221
let svc = TonicServer::builder()
222
.http2_keepalive_interval(Some(GRPC_KEEPALIVE_INTERVAL))
223
.http2_keepalive_timeout(Some(GRPC_KEEPALIVE_TIMEOUT))
rust/src/server/src/routes.rs
+2
-3
@@ -27,11 +27,10 @@ use axum::routing::{get, post};
27
use tower_http::trace::TraceLayer;
28
use tracing::warn;
29
30
+use crate::DEFAULT_REQUEST_BODY_LIMIT_BYTES;
31
use crate::middleware;
32
use crate::state::AppState;
33
33
-const DEFAULT_JSON_BODY_LIMIT_BYTES: usize = 32 * 1024 * 1024;
34
-
34
fn server_dev_mode_enabled() -> bool {
35
std::env::var("VLLM_SERVER_DEV_MODE")
36
.ok()
@@ -127,7 +126,7 @@ fn build_router_with_options(
126
let enable_api_key_auth = state.has_api_keys();
127
let mut router = router
128
.with_state(state.clone())
130
- .layer(DefaultBodyLimit::max(DEFAULT_JSON_BODY_LIMIT_BYTES))
129
+ .layer(DefaultBodyLimit::max(DEFAULT_REQUEST_BODY_LIMIT_BYTES))
130
.layer(middleware::request_runtime_layer(state.clone()))
131
.layer(from_fn_with_state(
132
state.clone(),