[Rust Frontend] Optimize SSE streaming hot path (#51321)
Co-authored-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: reidliu41 <reid201711@gmail.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Reid committed
Aug 29, 2026 at 04:35 UTC
74850f9f669085c07353a5673b1fb2f352480cda
8 files changed
+204
-196
rust/Cargo.toml
+1
-1
@@ -90,7 +90,7 @@ rlimit = "0.11.0"
90
rmp-serde = "1.3.1"
91
rmpv = { version = "1.3.1", features = ["with-serde"] }
92
rustc-hash = "1.1.0"
93
-serde = { version = "1.0.228", features = ["derive"] }
93
+serde = { version = "1.0.228", features = ["derive", "rc"] }
94
serde-json-fmt = "0.1.0"
95
serde_default = "0.2.0"
96
serde_json = { version = "1.0.145", features = ["preserve_order"] }
rust/src/server/src/routes/inference/generate.rs
+9
-7
@@ -395,16 +395,18 @@ async fn generate_sse_stream(
395
}
396
397
fn to_sse_event(chunk: &GenerateStreamResponse) -> Event {
398
- let payload = serde_json::to_string(chunk).expect("generate chunk must serialize to JSON");
399
- trace!(payload, "generate emitting chunk");
400
- Event::default().data(payload)
398
+ trace!(?chunk, "generate emitting chunk");
399
+ Event::default()
400
+ .json_data(chunk)
401
+ .expect("generate chunk must serialize to JSON")
402
}
403
404
fn to_error_sse_event(error: &ApiError) -> Event {
404
- let payload = serde_json::to_string(&error.to_error_response())
405
- .expect("ErrorResponse must serialize to JSON");
406
- trace!(payload, "generate emitting error");
407
- Event::default().data(payload)
405
+ let response = error.to_error_response();
406
+ trace!(?response, "generate emitting error");
407
+ Event::default()
408
+ .json_data(response)
409
+ .expect("ErrorResponse must serialize to JSON")
410
}
411
412
fn done_sse_event() -> Event {
rust/src/server/src/routes/openai/chat_completions.rs
+68
-106
@@ -39,7 +39,8 @@ use crate::routes::openai::utils::logprobs::{
39
decoded_logprobs_to_openai_chat, prompt_logprobs_to_maps,
40
};
41
use crate::routes::openai::utils::types::{
42
- ChatLogProbs, FunctionCallDelta, FunctionCallResponse, ToolCall, ToolCallDelta, Usage,
42
+ ChatLogProbs, FunctionCallDelta, FunctionCallResponse, StreamResponseEnvelope, ToolCall,
43
+ ToolCallDelta, Usage,
44
};
45
use crate::routes::openai::utils::usage::ContinuousUsage;
46
use crate::routes::openai::utils::validated_json::ValidatedJson;
@@ -270,6 +271,12 @@ async fn chat_completion_chunk_stream(
271
}: ResponseOptions,
272
mut y: TryYielder<ChatCompletionStreamResponse, ApiError>,
273
) -> Result<(), ApiError> {
274
+ let envelope = Arc::new(StreamResponseEnvelope::new(
275
+ request_id,
276
+ "chat.completion.chunk",
277
+ created,
278
+ response_model,
279
+ ));
280
let mut saw_tool_calls = false;
281
// `LogprobsDelta` is emitted after all chat events for one decoded update.
282
// If that update contains hidden reasoning, including delimiter-only block
@@ -303,7 +310,7 @@ async fn chat_completion_chunk_stream(
310
prompt_token_ids, ..
311
}) => {
312
continuous_usage.set_prompt_tokens(prompt_token_ids.len());
306
- let mut chunk = start_chunk(&request_id, &response_model, created);
313
+ let mut chunk = start_chunk(&envelope);
314
if return_token_ids {
315
chunk.prompt_token_ids = Some(prompt_token_ids.to_vec());
316
}
@@ -311,9 +318,7 @@ async fn chat_completion_chunk_stream(
318
// When echo=true, emit the last assistant message content as a delta chunk.
319
if let Some(echo_text) = &echo {
320
yield_chunk!(block_delta_chunk(
314
- &request_id,
315
- &response_model,
316
- created,
321
+ &envelope,
322
AssistantBlockKind::Text,
323
echo_text.clone(),
324
));
@@ -326,13 +331,7 @@ async fn chat_completion_chunk_stream(
331
if let Some(pending_chunk) = pending_chunk.as_mut() {
332
pending_chunk.push_block_delta(kind, delta);
333
} else {
329
- yield_chunk!(block_delta_chunk(
330
- &request_id,
331
- &response_model,
332
- created,
333
- kind,
334
- delta,
335
- ));
334
+ yield_chunk!(block_delta_chunk(&envelope, kind, delta));
335
}
336
} else {
337
suppress_current_update_metadata = true;
@@ -368,18 +367,11 @@ async fn chat_completion_chunk_stream(
367
if let Some(pending_chunk) = pending_chunk.as_mut() {
368
pending_chunk.logprobs = openai_logprobs;
369
pending_chunk.token_ids = openai_token_ids;
371
- if let Some(chunk) =
372
- pending_chunk.take_chunk(&request_id, &response_model, created)
373
- {
370
+ if let Some(chunk) = pending_chunk.take_chunk(&envelope) {
371
yield_chunk!(chunk);
372
}
373
} else if let Some(logprobs) = openai_logprobs {
377
- yield_chunk!(logprobs_only_chunk(
378
- &request_id,
379
- &response_model,
380
- created,
381
- logprobs,
382
- ));
374
+ yield_chunk!(logprobs_only_chunk(&envelope, logprobs));
375
}
376
}
377
Ok(ChatEvent::BlockStart { kind, .. }) => {
@@ -407,14 +399,7 @@ async fn chat_completion_chunk_stream(
399
if let Some(pending_chunk) = pending_chunk.as_mut() {
400
pending_chunk.push_tool_call_start(tool_index, id, name);
401
} else {
410
- yield_chunk!(tool_call_start_chunk(
411
- &request_id,
412
- &response_model,
413
- created,
414
- tool_index,
415
- id,
416
- name,
417
- ));
402
+ yield_chunk!(tool_call_start_chunk(&envelope, tool_index, id, name));
403
}
404
}
405
Ok(ChatEvent::ToolCallArgumentsDelta { index, delta }) => {
@@ -422,13 +407,7 @@ async fn chat_completion_chunk_stream(
407
if let Some(pending_chunk) = pending_chunk.as_mut() {
408
pending_chunk.push_tool_call_arguments(tool_index, delta);
409
} else {
425
- yield_chunk!(tool_call_arguments_chunk(
426
- &request_id,
427
- &response_model,
428
- created,
429
- tool_index,
430
- delta,
431
- ));
410
+ yield_chunk!(tool_call_arguments_chunk(&envelope, tool_index, delta));
411
}
412
}
413
Ok(ChatEvent::ToolCallEnd { .. }) => {
@@ -442,7 +421,7 @@ async fn chat_completion_chunk_stream(
421
if enable_log_requests {
422
info!(
423
stream = true,
445
- model = %response_model,
424
+ model = %envelope.model(),
425
prompt_tokens = final_usage.prompt_token_count,
426
output_tokens = final_usage.output_token_count,
427
finish_reason = finish_reason.as_str(),
@@ -456,16 +435,13 @@ async fn chat_completion_chunk_stream(
435
);
436
437
if let Some(pending_chunk) = pending_chunk.as_mut()
459
- && let Some(chunk) =
460
- pending_chunk.take_chunk(&request_id, &response_model, created)
438
+ && let Some(chunk) = pending_chunk.take_chunk(&envelope)
439
{
440
yield_chunk!(chunk);
441
}
442
443
match final_chunk(
466
- &request_id,
467
- &response_model,
468
- created,
444
+ &envelope,
445
finish_reason,
446
saw_tool_calls && !is_named_tool_choice,
447
) {
@@ -481,9 +457,7 @@ async fn chat_completion_chunk_stream(
457
458
if include_usage {
459
y.yield_ok(usage_chunk(
484
- &request_id,
485
- &response_model,
486
- created,
460
+ &envelope,
461
Usage::from_token_usage(final_usage, enable_prompt_tokens_details),
462
))
463
.await;
@@ -504,12 +478,10 @@ async fn chat_completion_chunk_stream(
478
}
479
480
fn usage_chunk(
507
- request_id: &str,
508
- response_model: &str,
509
- created: u64,
481
+ envelope: &Arc<StreamResponseEnvelope>,
482
usage: Usage,
483
) -> ChatCompletionStreamResponse {
512
- let mut chunk = ChatCompletionStreamResponse::new(request_id, response_model, created);
484
+ let mut chunk = ChatCompletionStreamResponse::new(envelope);
485
chunk.usage = Some(usage);
486
chunk
487
}
@@ -588,9 +560,7 @@ impl PendingChatChunk {
560
/// necessarily with a visible/chat-semantic delta.
561
fn take_chunk(
562
&mut self,
591
- request_id: &str,
592
- response_model: &str,
593
- created: u64,
563
+ envelope: &Arc<StreamResponseEnvelope>,
564
) -> Option<ChatCompletionStreamResponse> {
565
let has_delta = self.delta.content.is_some()
566
|| self.delta.reasoning.is_some()
@@ -601,7 +571,7 @@ impl PendingChatChunk {
571
return None;
572
}
573
604
- let mut chunk = ChatCompletionStreamResponse::new(request_id, response_model, created);
574
+ let mut chunk = ChatCompletionStreamResponse::new(envelope);
575
chunk.choices.push(ChatCompletionStreamChoice {
576
delta: self.take_delta(),
577
logprobs,
@@ -659,18 +629,19 @@ async fn chat_completion_sse_stream(
629
630
/// Serialize one OpenAI chunk payload into one SSE `data:` event.
631
fn to_sse_event(chunk: &ChatCompletionStreamResponse) -> Event {
662
- let payload =
663
- serde_json::to_string(chunk).expect("ChatCompletionStreamResponse must serialize to JSON");
664
- trace!(payload, "chat completion emitting chunk");
665
- Event::default().data(payload)
632
+ trace!(?chunk, "chat completion emitting chunk");
633
+ Event::default()
634
+ .json_data(chunk)
635
+ .expect("ChatCompletionStreamResponse must serialize to JSON")
636
}
637
638
/// Serialize one OpenAI error payload into one SSE `data:` event.
639
fn to_error_sse_event(error: &ApiError) -> Event {
670
- let payload = serde_json::to_string(&error.to_error_response())
671
- .expect("ErrorResponse must serialize to JSON");
672
- trace!(payload, "chat completion emitting error");
673
- Event::default().data(payload)
640
+ let response = error.to_error_response();
641
+ trace!(?response, "chat completion emitting error");
642
+ Event::default()
643
+ .json_data(response)
644
+ .expect("ErrorResponse must serialize to JSON")
645
}
646
647
/// Build the terminal OpenAI SSE sentinel event.
@@ -681,12 +652,8 @@ fn done_sse_event() -> Event {
652
653
/// Build the initial assistant-role SSE chunk required by the OpenAI streaming
654
/// protocol.
684
-fn start_chunk(
685
- request_id: &str,
686
- response_model: &str,
687
- created: u64,
688
-) -> ChatCompletionStreamResponse {
689
- let mut chunk = ChatCompletionStreamResponse::new(request_id, response_model, created);
655
+fn start_chunk(envelope: &Arc<StreamResponseEnvelope>) -> ChatCompletionStreamResponse {
656
+ let mut chunk = ChatCompletionStreamResponse::new(envelope);
657
chunk.choices.push(ChatCompletionStreamChoice {
658
delta: ChatMessageDelta {
659
role: Some(AssistantRole),
@@ -699,9 +666,7 @@ fn start_chunk(
666
667
/// Build one content-delta SSE chunk from one internal assistant block delta.
668
fn block_delta_chunk(
702
- request_id: &str,
703
- response_model: &str,
704
- created: u64,
669
+ envelope: &Arc<StreamResponseEnvelope>,
670
kind: AssistantBlockKind,
671
delta: String,
672
) -> ChatCompletionStreamResponse {
@@ -719,7 +684,7 @@ fn block_delta_chunk(
684
}
685
};
686
722
- let mut chunk = ChatCompletionStreamResponse::new(request_id, response_model, created);
687
+ let mut chunk = ChatCompletionStreamResponse::new(envelope);
688
chunk.choices.push(ChatCompletionStreamChoice {
689
delta,
690
..Default::default()
@@ -728,14 +693,12 @@ fn block_delta_chunk(
693
}
694
695
fn tool_call_start_chunk(
731
- request_id: &str,
732
- response_model: &str,
733
- created: u64,
696
+ envelope: &Arc<StreamResponseEnvelope>,
697
tool_index: u32,
698
id: String,
699
name: String,
700
) -> ChatCompletionStreamResponse {
738
- let mut chunk = ChatCompletionStreamResponse::new(request_id, response_model, created);
701
+ let mut chunk = ChatCompletionStreamResponse::new(envelope);
702
chunk.choices.push(ChatCompletionStreamChoice {
703
delta: ChatMessageDelta {
704
tool_calls: Some(vec![ToolCallDelta {
@@ -755,13 +718,11 @@ fn tool_call_start_chunk(
718
}
719
720
fn tool_call_arguments_chunk(
758
- request_id: &str,
759
- response_model: &str,
760
- created: u64,
721
+ envelope: &Arc<StreamResponseEnvelope>,
722
tool_index: u32,
723
delta: String,
724
) -> ChatCompletionStreamResponse {
764
- let mut chunk = ChatCompletionStreamResponse::new(request_id, response_model, created);
725
+ let mut chunk = ChatCompletionStreamResponse::new(envelope);
726
chunk.choices.push(ChatCompletionStreamChoice {
727
delta: ChatMessageDelta {
728
tool_calls: Some(vec![ToolCallDelta {
@@ -781,12 +742,10 @@ fn tool_call_arguments_chunk(
742
}
743
744
fn logprobs_only_chunk(
784
- request_id: &str,
785
- response_model: &str,
786
- created: u64,
745
+ envelope: &Arc<StreamResponseEnvelope>,
746
logprobs: ChatLogProbs,
747
) -> ChatCompletionStreamResponse {
789
- let mut chunk = ChatCompletionStreamResponse::new(request_id, response_model, created);
748
+ let mut chunk = ChatCompletionStreamResponse::new(envelope);
749
chunk.choices.push(ChatCompletionStreamChoice {
750
logprobs: Some(logprobs),
751
..Default::default()
@@ -796,9 +755,7 @@ fn logprobs_only_chunk(
755
756
/// Build the terminal SSE chunk carrying the OpenAI finish reason.
757
fn final_chunk(
799
- request_id: &str,
800
- response_model: &str,
801
- created: u64,
758
+ envelope: &Arc<StreamResponseEnvelope>,
759
finish_reason: FinishReason,
760
use_tool_calls_finish_reason: bool,
761
) -> Result<ChatCompletionStreamResponse, ApiError> {
@@ -811,7 +768,7 @@ fn final_chunk(
768
"chat stream finished"
769
);
770
814
- let mut chunk = ChatCompletionStreamResponse::new(request_id, response_model, created);
771
+ let mut chunk = ChatCompletionStreamResponse::new(envelope);
772
chunk.choices.push(ChatCompletionStreamChoice {
773
finish_reason: Some(finish_reason.to_string()),
774
stop_reason,
@@ -844,6 +801,8 @@ fn stop_reason_to_json(stop_reason: &StopReason) -> Value {
801
802
#[cfg(test)]
803
mod tests {
804
+ use std::sync::Arc;
805
+
806
use futures::{StreamExt as _, stream};
807
use serde_json::json;
808
use vllm_chat::{
@@ -853,19 +812,23 @@ mod tests {
812
use vllm_text::{DecodedLogprobs, DecodedPositionLogprobs, DecodedTokenLogprob};
813
814
use super::{
856
- ApiServerOptions, ResponseOptions, block_delta_chunk, chat_completion_chunk_stream,
857
- final_chunk,
815
+ ApiServerOptions, ResponseOptions, StreamResponseEnvelope, block_delta_chunk,
816
+ chat_completion_chunk_stream, final_chunk,
817
};
818
819
+ fn stream_envelope() -> Arc<StreamResponseEnvelope> {
820
+ Arc::new(StreamResponseEnvelope::new(
821
+ "chatcmpl-1".to_string(),
822
+ "chat.completion.chunk",
823
+ 1,
824
+ "model".to_string(),
825
+ ))
826
+ }
827
+
828
#[test]
829
fn text_chunk_uses_content_only_delta() {
862
- let chunk = block_delta_chunk(
863
- "chatcmpl-1",
864
- "model",
865
- 1,
866
- AssistantBlockKind::Text,
867
- "hello".to_string(),
868
- );
830
+ let envelope = stream_envelope();
831
+ let chunk = block_delta_chunk(&envelope, AssistantBlockKind::Text, "hello".to_string());
832
assert_eq!(chunk.choices[0].delta.role, None);
833
assert_eq!(chunk.choices[0].delta.content.as_deref(), Some("hello"));
834
assert_eq!(chunk.choices[0].delta.reasoning, None);
@@ -873,10 +836,9 @@ mod tests {
836
837
#[test]
838
fn reasoning_chunk_uses_reasoning_only_delta() {
839
+ let envelope = stream_envelope();
840
let chunk = block_delta_chunk(
877
- "chatcmpl-1",
878
- "model",
879
- 1,
841
+ &envelope,
842
AssistantBlockKind::Reasoning,
843
"thinking".to_string(),
844
);
@@ -890,10 +852,9 @@ mod tests {
852
853
#[test]
854
fn final_chunk_maps_stop_finish_reason_and_stop_reason() {
855
+ let envelope = stream_envelope();
856
let chunk = final_chunk(
894
- "chatcmpl-1",
895
- "model",
896
- 1,
857
+ &envelope,
858
FinishReason::Stop(Some(StopReason::Text("stop".to_string()))),
859
false,
860
)
@@ -905,7 +866,7 @@ mod tests {
866
867
#[test]
868
fn final_chunk_maps_length_finish_reason() {
908
- let chunk = final_chunk("chatcmpl-1", "model", 1, FinishReason::Length, false)
869
+ let chunk = final_chunk(&stream_envelope(), FinishReason::Length, false)
870
.expect("finish reason is valid");
871
872
assert_eq!(chunk.choices[0].finish_reason.as_deref(), Some("length"));
@@ -914,7 +875,7 @@ mod tests {
875
876
#[test]
877
fn final_chunk_maps_abort_finish_reason() {
917
- let chunk = final_chunk("chatcmpl-1", "model", 1, FinishReason::Abort, false)
878
+ let chunk = final_chunk(&stream_envelope(), FinishReason::Abort, false)
879
.expect("abort is a valid finish reason");
880
881
assert_eq!(chunk.choices[0].finish_reason.as_deref(), Some("abort"));
@@ -923,12 +884,12 @@ mod tests {
884
885
#[test]
886
fn final_chunk_rejects_error_finish_reason() {
926
- assert!(final_chunk("chatcmpl-1", "model", 1, FinishReason::Error, false).is_err());
887
+ assert!(final_chunk(&stream_envelope(), FinishReason::Error, false).is_err());
888
}
889
890
#[test]
891
fn final_chunk_maps_stop_to_tool_calls_when_tool_calls_were_streamed() {
931
- let chunk = final_chunk("chatcmpl-1", "model", 1, FinishReason::stop_eos(), true)
892
+ let chunk = final_chunk(&stream_envelope(), FinishReason::stop_eos(), true)
893
.expect("finish reason is valid");
894
895
assert_eq!(
@@ -1002,6 +963,7 @@ mod tests {
963
.expect("stream chunks");
964
965
assert_eq!(chunks.len(), 4);
966
+ assert!(chunks.windows(2).all(|pair| Arc::ptr_eq(&pair[0].envelope, &pair[1].envelope)));
967
assert_eq!(chunks[1].choices[0].delta.content.as_deref(), Some("hi"));
968
let logprobs = chunks[1].choices[0].logprobs.as_ref().expect("logprobs");
969
let content = logprobs.content.as_ref().expect("logprobs content");
rust/src/server/src/routes/openai/chat_completions/types.rs
+8
-12
@@ -3,6 +3,7 @@
3
4
use std::collections::HashMap;
5
use std::fmt;
6
+use std::sync::Arc;
7
8
use serde::{Deserialize, Serialize};
9
use serde_json::Value;
@@ -13,9 +14,9 @@ use vllm_engine_core_client::protocol::sampling::RepetitionDetectionParams;
14
15
use crate::routes::openai::utils::structured_outputs::ResponseFormat;
16
use crate::routes::openai::utils::types::{
16
- ChatLogProbs, ChatMessage, Normalizable, PromptLogprobs, StreamOptions, StringOrArray, Tool,
17
- ToolCall, ToolCallDelta, ToolChoice, Usage, default_true, deserialize_request_top_k,
18
- validate_messages, validate_stop, validate_top_p_value,
17
+ ChatLogProbs, ChatMessage, Normalizable, PromptLogprobs, StreamOptions, StreamResponseEnvelope,
18
+ StringOrArray, Tool, ToolCall, ToolCallDelta, ToolChoice, Usage, default_true,
19
+ deserialize_request_top_k, validate_messages, validate_stop, validate_top_p_value,
20
};
21
22
/// vLLM-compatible request type for the Chat Completions API.
@@ -383,10 +384,8 @@ pub(super) struct ChatCompletionMessage {
384
#[serde_with::skip_serializing_none]
385
#[derive(Debug, Clone, Serialize)]
386
pub(super) struct ChatCompletionStreamResponse {
386
- pub id: String,
387
- pub object: String,
388
- pub created: u64,
389
- pub model: String,
387
+ #[serde(flatten)]
388
+ pub envelope: Arc<StreamResponseEnvelope>,
389
pub choices: Vec<ChatCompletionStreamChoice>,
390
pub usage: Option<Usage>,
391
pub prompt_token_ids: Option<Vec<u32>>,
@@ -394,12 +393,9 @@ pub(super) struct ChatCompletionStreamResponse {
393
394
impl ChatCompletionStreamResponse {
395
/// Create a stream response with the standard envelope fields pre-filled.
397
- pub fn new(id: &str, model: &str, created: u64) -> Self {
396
+ pub fn new(envelope: &Arc<StreamResponseEnvelope>) -> Self {
397
Self {
399
- id: id.to_string(),
400
- object: "chat.completion.chunk".to_string(),
401
- created,
402
- model: model.to_string(),
398
+ envelope: Arc::clone(envelope),
399
choices: Vec::new(),
400
usage: None,
401
prompt_token_ids: None,
rust/src/server/src/routes/openai/completions.rs
+62
-59
@@ -33,7 +33,7 @@ use super::utils::logprobs::{
33
collected_logprobs_to_openai, decoded_logprobs_to_openai, decoded_prompt_logprobs_to_openai,
34
prompt_logprobs_to_maps, text_len,
35
};
36
-use super::utils::types::Usage;
36
+use super::utils::types::{StreamResponseEnvelope, Usage};
37
use crate::config::ApiServerOptions;
38
use crate::error::{ApiError, bail_server_error, server_error, text_submit_error};
39
use crate::lora::LoraModelResolution;
@@ -257,6 +257,12 @@ async fn completion_chunk_stream(
257
mut y: TryYielder<CompletionSseChunk, ApiError>,
258
) -> Result<(), ApiError> {
259
pin_mut!(stream);
260
+ let envelope = Arc::new(StreamResponseEnvelope::new(
261
+ request_id,
262
+ "text_completion",
263
+ created,
264
+ response_model,
265
+ ));
266
let mut visible_text_len = 0_u32;
267
let mut first_chunk = true;
268
let mut continuous_usage = ContinuousUsage::default();
@@ -292,13 +298,7 @@ async fn completion_chunk_stream(
298
} else {
299
None
300
};
295
- let mut chunk = delta_chunk(
296
- &request_id,
297
- &response_model,
298
- created,
299
- prompt.clone(),
300
- logprobs,
301
- );
301
+ let mut chunk = delta_chunk(&envelope, prompt.clone(), logprobs);
302
if return_token_ids && first_chunk {
303
if let Some(choice) = chunk.choices.first_mut() {
304
choice.prompt_token_ids = Some(prompt_token_ids.to_vec());
@@ -308,8 +308,7 @@ async fn completion_chunk_stream(
308
yield_chunk!(chunk);
309
} else if return_token_ids {
310
// Emit a chunk with prompt_token_ids in the first streaming response
311
- let mut chunk =
312
- delta_chunk(&request_id, &response_model, created, String::new(), None);
311
+ let mut chunk = delta_chunk(&envelope, String::new(), None);
312
if let Some(choice) = chunk.choices.first_mut() {
313
choice.prompt_token_ids = Some(prompt_token_ids.to_vec());
314
}
@@ -331,7 +330,7 @@ async fn completion_chunk_stream(
330
if enable_log_requests {
331
info!(
332
stream = true,
334
- model = %response_model,
333
+ model = %envelope.model(),
334
prompt_tokens = finished.usage.prompt_token_count,
335
output_tokens = finished.usage.output_token_count,
336
finish_reason = finished.finish_reason.as_str(),
@@ -342,19 +341,12 @@ async fn completion_chunk_stream(
341
finished.usage.prompt_token_count,
342
finished.usage.output_token_count,
343
);
345
- let final_chunk = final_chunk(
346
- &request_id,
347
- &response_model,
348
- created,
349
- finished.finish_reason,
350
- )?;
344
+ let final_chunk = final_chunk(&envelope, finished.finish_reason)?;
345
yield_chunk!(final_chunk);
346
347
if include_usage {
348
y.yield_ok(CompletionSseChunk::Usage(usage_chunk(
355
- &request_id,
356
- &response_model,
357
- created,
349
+ &envelope,
350
Usage::from_token_usage(
351
finished.usage,
352
enable_prompt_tokens_details,
@@ -380,7 +372,7 @@ async fn completion_chunk_stream(
372
} else {
373
None
374
};
383
- let mut chunk = delta_chunk(&request_id, &response_model, created, delta, logprobs);
375
+ let mut chunk = delta_chunk(&envelope, delta, logprobs);
376
let delta_token_count = token_ids.len();
377
continuous_usage.add_output_tokens(delta_token_count);
378
if return_token_ids && let Some(choice) = chunk.choices.first_mut() {
@@ -393,7 +385,7 @@ async fn completion_chunk_stream(
385
if enable_log_requests {
386
info!(
387
stream = true,
396
- model = %response_model,
388
+ model = %envelope.model(),
389
prompt_tokens = finished.usage.prompt_token_count,
390
output_tokens = finished.usage.output_token_count,
391
finish_reason = finished.finish_reason.as_str(),
@@ -404,19 +396,12 @@ async fn completion_chunk_stream(
396
finished.usage.prompt_token_count,
397
finished.usage.output_token_count,
398
);
407
- let final_chunk = final_chunk(
408
- &request_id,
409
- &response_model,
410
- created,
411
- finished.finish_reason,
412
- )?;
399
+ let final_chunk = final_chunk(&envelope, finished.finish_reason)?;
400
yield_chunk!(final_chunk);
401
402
if include_usage {
403
y.yield_ok(CompletionSseChunk::Usage(usage_chunk(
417
- &request_id,
418
- &response_model,
419
- created,
404
+ &envelope,
405
Usage::from_token_usage(finished.usage, enable_prompt_tokens_details),
406
)))
407
.await;
@@ -436,13 +421,11 @@ async fn completion_chunk_stream(
421
}
422
423
fn delta_chunk(
439
- request_id: &str,
440
- response_model: &str,
441
- created: u64,
424
+ envelope: &Arc<StreamResponseEnvelope>,
425
text: String,
426
logprobs: Option<LogProbs>,
427
) -> CompletionStreamResponse {
445
- let mut chunk = CompletionStreamResponse::new(request_id, response_model, created);
428
+ let mut chunk = CompletionStreamResponse::new(envelope);
429
chunk.choices.push(CompletionStreamChoice {
430
text,
431
logprobs,
@@ -452,15 +435,13 @@ fn delta_chunk(
435
}
436
437
fn final_chunk(
455
- request_id: &str,
456
- response_model: &str,
457
- created: u64,
438
+ envelope: &Arc<StreamResponseEnvelope>,
439
finish_reason: FinishReason,
440
) -> Result<CompletionStreamResponse, ApiError> {
441
let stop_reason = finish_reason.as_stop_reason().map(stop_reason_to_json);
442
let finish_reason = completion_finish_reason_to_openai(&finish_reason)?;
443
463
- let mut chunk = CompletionStreamResponse::new(request_id, response_model, created);
444
+ let mut chunk = CompletionStreamResponse::new(envelope);
445
chunk.choices.push(CompletionStreamChoice {
446
finish_reason: Some(finish_reason.to_string()),
447
stop_reason,
@@ -517,13 +498,8 @@ fn prompt_only_logprobs_to_openai(
498
))
499
}
500
520
-fn usage_chunk(
521
- request_id: &str,
522
- response_model: &str,
523
- created: u64,
524
- usage: Usage,
525
-) -> CompletionStreamResponse {
526
- let mut chunk = CompletionStreamResponse::new(request_id, response_model, created);
501
+fn usage_chunk(envelope: &Arc<StreamResponseEnvelope>, usage: Usage) -> CompletionStreamResponse {
502
+ let mut chunk = CompletionStreamResponse::new(envelope);
503
chunk.usage = Some(usage);
504
chunk
505
}
@@ -556,17 +532,19 @@ async fn completion_sse_stream(
532
533
/// Serialize one OpenAI chunk payload into one SSE `data:` event.
534
fn to_sse_event(chunk: &CompletionSseChunk) -> Event {
559
- let payload = serde_json::to_string(chunk).expect("completion chunk must serialize to JSON");
560
- trace!(payload, "completion emitting chunk");
561
- Event::default().data(payload)
535
+ trace!(?chunk, "completion emitting chunk");
536
+ Event::default()
537
+ .json_data(chunk)
538
+ .expect("completion chunk must serialize to JSON")
539
}
540
541
/// Serialize one OpenAI error payload into one SSE `data:` event.
542
fn to_error_sse_event(error: &ApiError) -> Event {
566
- let payload = serde_json::to_string(&error.to_error_response())
567
- .expect("ErrorResponse must serialize to JSON");
568
- trace!(payload, "completion emitting error");
569
- Event::default().data(payload)
543
+ let response = error.to_error_response();
544
+ trace!(?response, "completion emitting error");
545
+ Event::default()
546
+ .json_data(response)
547
+ .expect("ErrorResponse must serialize to JSON")
548
}
549
550
/// Build the terminal OpenAI SSE sentinel event.
@@ -577,6 +555,8 @@ fn done_sse_event() -> Event {
555
556
#[cfg(test)]
557
mod tests {
558
+ use std::sync::Arc;
559
+
560
use futures::{StreamExt as _, stream};
561
use itertools::Itertools as _;
562
use vllm_engine_core_client::protocol::output::StopReason;
@@ -586,13 +566,29 @@ mod tests {
566
};
567
568
use super::{
589
- ApiServerOptions, CompletionSseChunk, ResponseOptions, completion_chunk_stream, final_chunk,
569
+ ApiServerOptions, CompletionSseChunk, CompletionStreamResponse, ResponseOptions,
570
+ StreamResponseEnvelope, completion_chunk_stream, final_chunk,
571
};
572
573
+ fn stream_envelope() -> Arc<StreamResponseEnvelope> {
574
+ Arc::new(StreamResponseEnvelope::new(
575
+ "cmpl-1".to_string(),
576
+ "text_completion",
577
+ 1,
578
+ "model".to_string(),
579
+ ))
580
+ }
581
+
582
+ fn chunk_response(chunk: &CompletionSseChunk) -> &CompletionStreamResponse {
583
+ match chunk {
584
+ CompletionSseChunk::Chunk(response) | CompletionSseChunk::Usage(response) => response,
585
+ }
586
+ }
587
+
588
#[test]
589
fn final_chunk_maps_stop_finish_reason() {
594
- let chunk = final_chunk("cmpl-1", "model", 1, FinishReason::stop_eos())
595
- .expect("finish reason valid");
590
+ let chunk =
591
+ final_chunk(&stream_envelope(), FinishReason::stop_eos()).expect("finish reason valid");
592
assert_eq!(chunk.choices[0].finish_reason.as_deref(), Some("stop"));
593
assert_eq!(chunk.choices[0].text, "");
594
}
@@ -600,20 +596,20 @@ mod tests {
596
#[test]
597
fn final_chunk_maps_length_finish_reason() {
598
let chunk =
603
- final_chunk("cmpl-1", "model", 1, FinishReason::Length).expect("finish reason valid");
599
+ final_chunk(&stream_envelope(), FinishReason::Length).expect("finish reason valid");
600
assert_eq!(chunk.choices[0].finish_reason.as_deref(), Some("length"));
601
}
602
603
#[test]
604
fn final_chunk_maps_abort_finish_reason() {
605
let chunk =
610
- final_chunk("cmpl-1", "model", 1, FinishReason::Abort).expect("finish reason valid");
606
+ final_chunk(&stream_envelope(), FinishReason::Abort).expect("finish reason valid");
607
assert_eq!(chunk.choices[0].finish_reason.as_deref(), Some("abort"));
608
}
609
610
#[test]
611
fn final_chunk_rejects_error_finish_reason() {
616
- assert!(final_chunk("cmpl-1", "model", 1, FinishReason::Error).is_err());
612
+ assert!(final_chunk(&stream_envelope(), FinishReason::Error).is_err());
613
}
614
615
#[tokio::test]
@@ -702,6 +698,13 @@ mod tests {
698
699
let chunks: Vec<_> = chunks.into_iter().try_collect().expect("stream should succeed");
700
701
+ assert!(chunks.windows(2).all(|pair| {
702
+ Arc::ptr_eq(
703
+ &chunk_response(&pair[0]).envelope,
704
+ &chunk_response(&pair[1]).envelope,
705
+ )
706
+ }));
707
+
708
match &chunks[0] {
709
CompletionSseChunk::Chunk(chunk) => {
710
assert_eq!(chunk.choices[0].text, "h");
rust/src/server/src/routes/openai/completions/types.rs
+7
-11
@@ -2,6 +2,7 @@
2
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
use std::collections::HashMap;
5
+use std::sync::Arc;
6
7
use serde::{Deserialize, Serialize};
8
use serde_json::{Map, Value};
@@ -10,8 +11,8 @@ use vllm_engine_core_client::protocol::sampling::RepetitionDetectionParams;
11
use vllm_text::Prompt;
12
13
use crate::routes::openai::utils::types::{
13
- LogProbs, Normalizable, PromptLogprobs, StreamOptions, StringOrArray, Usage, default_true,
14
- deserialize_request_top_k, validate_stop,
14
+ LogProbs, Normalizable, PromptLogprobs, StreamOptions, StreamResponseEnvelope, StringOrArray,
15
+ Usage, default_true, deserialize_request_top_k, validate_stop,
16
};
17
18
/// Serde default for `CompletionRequest::max_tokens`, matching the Python vLLM
@@ -241,22 +242,17 @@ pub(super) struct CompletionChoice {
242
#[serde_with::skip_serializing_none]
243
#[derive(Debug, Clone, Serialize)]
244
pub(super) struct CompletionStreamResponse {
244
- pub id: String,
245
- pub object: String,
246
- pub created: u64,
247
- pub model: String,
245
+ #[serde(flatten)]
246
+ pub envelope: Arc<StreamResponseEnvelope>,
247
pub choices: Vec<CompletionStreamChoice>,
248
pub usage: Option<Usage>,
249
}
250
251
impl CompletionStreamResponse {
252
/// Create a stream response with the standard envelope fields pre-filled.
254
- pub fn new(id: &str, model: &str, created: u64) -> Self {
253
+ pub fn new(envelope: &Arc<StreamResponseEnvelope>) -> Self {
254
Self {
256
- id: id.to_string(),
257
- object: "text_completion".to_string(),
258
- created,
259
- model: model.to_string(),
255
+ envelope: Arc::clone(envelope),
256
choices: Vec::new(),
257
usage: None,
258
}
rust/src/server/src/routes/openai/utils/types.rs
+23
@@ -201,6 +201,29 @@ pub struct InputAudio {
201
// Streaming
202
// ============================================================================
203
204
+#[derive(Debug, Serialize)]
205
+pub(crate) struct StreamResponseEnvelope {
206
+ id: String,
207
+ object: &'static str,
208
+ created: u64,
209
+ model: String,
210
+}
211
+
212
+impl StreamResponseEnvelope {
213
+ pub(crate) fn new(id: String, object: &'static str, created: u64, model: String) -> Self {
214
+ Self {
215
+ id,
216
+ object,
217
+ created,
218
+ model,
219
+ }
220
+ }
221
+
222
+ pub(crate) fn model(&self) -> &str {
223
+ &self.model
224
+ }
225
+}
226
+
227
/// Mirrors the Python vLLM `StreamOptions` class.
228
#[derive(Debug, Clone, Deserialize, Serialize)]
229
pub struct StreamOptions {
rust/src/server/src/routes/tests.rs
+26
@@ -170,6 +170,22 @@ fn sse_json_payloads(text: &str) -> Vec<serde_json::Value> {
170
.collect()
171
}
172
173
+fn assert_shared_openai_stream_envelope(payloads: &[serde_json::Value], object: &str, model: &str) {
174
+ let first = payloads.first().expect("at least one SSE JSON payload");
175
+ assert!(first["id"].as_str().is_some_and(|id| !id.is_empty()));
176
+ assert!(first["created"].is_u64());
177
+ assert_eq!(first["object"], object);
178
+ assert_eq!(first["model"], model);
179
+
180
+ for payload in payloads {
181
+ assert_eq!(payload["id"], first["id"]);
182
+ assert_eq!(payload["object"], first["object"]);
183
+ assert_eq!(payload["created"], first["created"]);
184
+ assert_eq!(payload["model"], first["model"]);
185
+ assert!(payload.get("envelope").is_none());
186
+ }
187
+}
188
+
189
type TestFuture<'a> = Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
190
191
fn boxed_test_future<'a>(future: impl Future<Output = ()> + Send + 'a) -> TestFuture<'a> {
@@ -2717,6 +2733,11 @@ async fn happy_path_returns_sse_stream() {
2733
let text = String::from_utf8(body.to_vec()).expect("utf8 body");
2734
let after = METRICS.render().unwrap();
2735
2736
+ assert_shared_openai_stream_envelope(
2737
+ &sse_json_payloads(&text),
2738
+ "chat.completion.chunk",
2739
+ "Qwen/Qwen1.5-0.5B-Chat",
2740
+ );
2741
assert!(text.contains("\"role\":\"assistant\""), "{text}");
2742
assert!(text.starts_with("data: "), "{text}");
2743
assert_eq!(
@@ -4465,6 +4486,11 @@ async fn completions_happy_path_returns_sse_stream() {
4486
engine_task.await.expect("mock engine task");
4487
let text = String::from_utf8(body.to_vec()).expect("utf8 body");
4488
let payloads = sse_data_payloads(&text);
4489
+ assert_shared_openai_stream_envelope(
4490
+ &sse_json_payloads(&text),
4491
+ "text_completion",
4492
+ "Qwen/Qwen1.5-0.5B-Chat",
4493
+ );
4494
let usage_index = payloads
4495
.iter()
4496
.position(|payload| payload.contains("\"usage\":"))