A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 14, 05:30 UTC
[Feature][Response API] Add streaming support for non-harmony (#23741)
Signed-off-by: Kebe <mail@kebe7jun.com>
Kebe committed
Sep 4, 2025 at 18:49 UTC
8f423e5f43eca54c9b0e5583ab388fd6d5cc8672
3 files changed
+407
-77
tests/v1/entrypoints/openai/responses/test_basic.py
+16
@@ -2,6 +2,7 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
import openai # use the official client for correctness check
5
+import openai.types.responses as openai_responses_types
6
import pytest
7
8
@@ -86,3 +87,18 @@ async def test_logprobs(client: openai.AsyncOpenAI):
87
outputs = response.output
88
assert outputs[-1].content[-1].logprobs
89
assert len(outputs[-1].content[-1].logprobs[0].top_logprobs) == 5
90
+
91
+
92
+@pytest.mark.asyncio
93
+async def test_streaming(client: openai.AsyncOpenAI):
94
+ stream = await client.responses.create(
95
+ input="What is 13 * 24?",
96
+ stream=True,
97
+ )
98
+ events = [event async for event in stream]
99
+ assert isinstance(events[0], openai_responses_types.ResponseCreatedEvent)
100
+ assert any(
101
+ isinstance(event, openai_responses_types.ResponseTextDeltaEvent)
102
+ for event in events)
103
+ assert isinstance(events[-1],
104
+ openai_responses_types.ResponseCompletedEvent)
vllm/entrypoints/context.py
+10
@@ -49,9 +49,19 @@ class SimpleContext(ConversationContext):
49
50
def __init__(self):
51
self.last_output = None
52
+ self.num_prompt_tokens = 0
53
+ self.num_output_tokens = 0
54
+ self.num_cached_tokens = 0
55
+ # todo num_reasoning_tokens is not implemented yet.
56
+ self.num_reasoning_tokens = 0
57
58
def append_output(self, output) -> None:
59
self.last_output = output
60
+ if not isinstance(output, RequestOutput):
61
+ raise ValueError("SimpleContext only supports RequestOutput.")
62
+ self.num_prompt_tokens = len(output.prompt_token_ids or [])
63
+ self.num_cached_tokens = output.num_cached_tokens or 0
64
+ self.num_output_tokens += len(output.outputs[0].token_ids or [])
65
66
def need_builtin_tool_call(self) -> bool:
67
return False
vllm/entrypoints/openai/serving_responses.py
+381
-77
@@ -4,6 +4,7 @@
4
import asyncio
5
import json
6
import time
7
+import uuid
8
from collections import deque
9
from collections.abc import AsyncGenerator, AsyncIterator, Sequence
10
from contextlib import AsyncExitStack
@@ -25,7 +26,8 @@ from openai.types.responses import (ResponseCreatedEvent,
26
ResponseOutputMessage, ResponseOutputText,
27
ResponseReasoningItem,
28
ResponseReasoningTextDeltaEvent,
28
- ResponseReasoningTextDoneEvent)
29
+ ResponseReasoningTextDoneEvent,
30
+ response_text_delta_event)
31
from openai.types.responses.response_output_text import (Logprob,
32
LogprobTopLogprob)
33
# yapf: enable
@@ -47,7 +49,7 @@ from vllm.entrypoints.harmony_utils import (
49
from vllm.entrypoints.logger import RequestLogger
50
# yapf conflicts with isort for this block
51
# yapf: disable
50
-from vllm.entrypoints.openai.protocol import (ErrorResponse,
52
+from vllm.entrypoints.openai.protocol import (DeltaMessage, ErrorResponse,
53
InputTokensDetails,
54
OutputTokensDetails,
55
RequestResponseMetadata,
@@ -459,10 +461,6 @@ class OpenAIServingResponses(OpenAIServing):
461
assert isinstance(context, HarmonyContext)
462
output = self._make_response_output_items_with_harmony(context)
463
# TODO: these are all 0 for now!
462
- num_prompt_tokens = context.num_prompt_tokens
463
- num_generated_tokens = context.num_output_tokens
464
- num_cached_tokens = context.num_cached_tokens
465
- num_reasoning_tokens = context.num_reasoning_tokens
464
else:
465
assert isinstance(context, SimpleContext)
466
final_res = context.last_output
@@ -475,10 +473,11 @@ class OpenAIServingResponses(OpenAIServing):
473
474
# Calculate usage.
475
assert final_res.prompt_token_ids is not None
478
- num_prompt_tokens = len(final_res.prompt_token_ids)
479
- num_generated_tokens = len(final_output.token_ids)
480
- num_cached_tokens = final_res.num_cached_tokens
481
- num_reasoning_tokens = 0
476
+ assert isinstance(context, (SimpleContext, HarmonyContext))
477
+ num_prompt_tokens = context.num_prompt_tokens
478
+ num_generated_tokens = context.num_output_tokens
479
+ num_cached_tokens = context.num_cached_tokens
480
+ num_reasoning_tokens = context.num_reasoning_tokens
481
482
usage = ResponseUsage(
483
input_tokens=num_prompt_tokens,
@@ -553,6 +552,28 @@ class OpenAIServingResponses(OpenAIServing):
552
))
553
return out
554
555
+ def _create_stream_response_logprobs(
556
+ self,
557
+ token_ids: Sequence[int],
558
+ logprobs: Optional[SampleLogprobs],
559
+ tokenizer: AnyTokenizer,
560
+ top_logprobs: Optional[int] = None
561
+ ) -> list[response_text_delta_event.Logprob]:
562
+ lgs = self._create_response_logprobs(token_ids=token_ids,
563
+ logprobs=logprobs,
564
+ tokenizer=tokenizer,
565
+ top_logprobs=top_logprobs)
566
+ return [
567
+ response_text_delta_event.Logprob(
568
+ token=lg.token,
569
+ logprob=lg.logprob,
570
+ top_logprobs=[
571
+ response_text_delta_event.LogprobTopLogprob(
572
+ token=tl.token, logprob=tl.logprob)
573
+ for tl in lg.top_logprobs
574
+ ]) for lg in lgs
575
+ ]
576
+
577
def _make_response_output_items(
578
self,
579
request: ResponsesRequest,
@@ -912,7 +933,7 @@ class OpenAIServingResponses(OpenAIServing):
933
status_code=HTTPStatus.BAD_REQUEST,
934
)
935
915
- async def _process_streaming_events(
936
+ async def _process_simple_streaming_events(
937
self,
938
request: ResponsesRequest,
939
sampling_params: SamplingParams,
@@ -922,47 +943,292 @@ class OpenAIServingResponses(OpenAIServing):
943
tokenizer: AnyTokenizer,
944
request_metadata: RequestResponseMetadata,
945
created_time: int,
946
+ _send_event: Callable[[BaseModel], str],
947
) -> AsyncGenerator[str, None]:
926
- sequence_number = 0
927
-
928
- def _send_event(event: BaseModel):
929
- nonlocal sequence_number
930
- # Set sequence_number if the event has this attribute
931
- if hasattr(event, 'sequence_number'):
932
- event.sequence_number = sequence_number
933
- sequence_number += 1
934
- # Get event type from the event's type field if it exists
935
- event_type = getattr(event, 'type', 'unknown')
936
- return (f"event: {event_type}\n"
937
- f"data: {event.model_dump_json(indent=None)}\n\n")
948
+ current_content_index = 0
949
+ current_output_index = 0
950
+ current_item_id = ""
951
+ reasoning_parser = None
952
+ if self.reasoning_parser:
953
+ reasoning_parser = self.reasoning_parser(tokenizer)
954
+ previous_text = ""
955
+ previous_token_ids: list[int] = []
956
+ first_delta_sent = False
957
+ previous_delta_messages: list[DeltaMessage] = []
958
+ async for ctx in result_generator:
959
+ assert isinstance(ctx, SimpleContext)
960
+ if ctx.last_output is None:
961
+ continue
962
+ if ctx.last_output.outputs:
963
+ output = ctx.last_output.outputs[0]
964
+ if reasoning_parser:
965
+ delta_message = \
966
+ reasoning_parser.extract_reasoning_content_streaming(
967
+ previous_text=previous_text,
968
+ current_text=previous_text + output.text,
969
+ delta_text=output.text,
970
+ previous_token_ids=previous_token_ids,
971
+ current_token_ids=previous_token_ids +
972
+ output.token_ids,
973
+ delta_token_ids=output.token_ids,
974
+ )
975
+ else:
976
+ delta_message = DeltaMessage(content=output.text, )
977
+ previous_text += output.text
978
+ previous_token_ids += output.token_ids
979
+ if not delta_message:
980
+ continue
981
+ if not first_delta_sent:
982
+ current_item_id = str(uuid.uuid4())
983
+ if delta_message.reasoning_content:
984
+ yield _send_event(
985
+ openai_responses_types.
986
+ ResponseOutputItemAddedEvent(
987
+ type="response.output_item.added",
988
+ sequence_number=-1,
989
+ output_index=current_output_index,
990
+ item=openai_responses_types.
991
+ ResponseReasoningItem(
992
+ type="reasoning",
993
+ id=current_item_id,
994
+ summary=[],
995
+ status="in_progress",
996
+ ),
997
+ ))
998
+ else:
999
+ yield _send_event(
1000
+ openai_responses_types.
1001
+ ResponseOutputItemAddedEvent(
1002
+ type="response.output_item.added",
1003
+ sequence_number=-1,
1004
+ output_index=current_output_index,
1005
+ item=openai_responses_types.
1006
+ ResponseOutputMessage(
1007
+ id=current_item_id,
1008
+ type="message",
1009
+ role="assistant",
1010
+ content=[],
1011
+ status="in_progress",
1012
+ ),
1013
+ ))
1014
+ yield _send_event(
1015
+ openai_responses_types.ResponseContentPartAddedEvent(
1016
+ type="response.content_part.added",
1017
+ sequence_number=-1,
1018
+ output_index=current_output_index,
1019
+ item_id=current_item_id,
1020
+ content_index=current_content_index,
1021
+ part=openai_responses_types.ResponseOutputText(
1022
+ type="output_text",
1023
+ text="",
1024
+ annotations=[],
1025
+ logprobs=[],
1026
+ ),
1027
+ ))
1028
+ current_content_index += 1
1029
+ first_delta_sent = True
1030
+ # todo(kebe7jun) tool call support
1031
+
1032
+ # check delta message and previous delta message are
1033
+ # same as content or reasoning content
1034
+ if (previous_delta_messages
1035
+ and previous_delta_messages[-1].reasoning_content
1036
+ is not None and delta_message.content is not None):
1037
+ # from reasoning to normal content, send done
1038
+ # event for reasoning
1039
+ reason_content = ''.join(
1040
+ pm.reasoning_content for pm in previous_delta_messages
1041
+ if pm.reasoning_content is not None)
1042
+ yield _send_event(
1043
+ ResponseReasoningTextDoneEvent(
1044
+ type="response.reasoning_text.done",
1045
+ item_id=current_item_id,
1046
+ sequence_number=-1,
1047
+ output_index=current_output_index,
1048
+ content_index=current_content_index,
1049
+ text=reason_content,
1050
+ ))
1051
+ current_content_index = 0
1052
+ reasoning_item = ResponseReasoningItem(
1053
+ type="reasoning",
1054
+ content=[
1055
+ ResponseReasoningTextContent(
1056
+ text=reason_content,
1057
+ type="reasoning_text",
1058
+ ),
1059
+ ],
1060
+ status="completed",
1061
+ id=current_item_id,
1062
+ summary=[],
1063
+ )
1064
+ yield _send_event(
1065
+ ResponseOutputItemDoneEvent(
1066
+ type="response.output_item.done",
1067
+ sequence_number=-1,
1068
+ output_index=current_output_index,
1069
+ item=reasoning_item,
1070
+ ))
1071
+ yield _send_event(
1072
+ openai_responses_types.ResponseOutputItemAddedEvent(
1073
+ type="response.output_item.added",
1074
+ sequence_number=-1,
1075
+ output_index=current_output_index,
1076
+ item=openai_responses_types.ResponseOutputMessage(
1077
+ id=current_item_id,
1078
+ type="message",
1079
+ role="assistant",
1080
+ content=[],
1081
+ status="in_progress",
1082
+ ),
1083
+ ))
1084
+ current_output_index += 1
1085
+ current_item_id = str(uuid.uuid4())
1086
+ yield _send_event(
1087
+ openai_responses_types.ResponseContentPartAddedEvent(
1088
+ type="response.content_part.added",
1089
+ sequence_number=-1,
1090
+ output_index=current_output_index,
1091
+ item_id=current_item_id,
1092
+ content_index=current_content_index,
1093
+ part=openai_responses_types.ResponseOutputText(
1094
+ type="output_text",
1095
+ text="",
1096
+ annotations=[],
1097
+ logprobs=[],
1098
+ ),
1099
+ ))
1100
+ current_content_index += 1
1101
+ # reset previous delta messages
1102
+ previous_delta_messages = []
1103
1104
+ if delta_message.reasoning_content is not None:
1105
+ yield _send_event(
1106
+ ResponseReasoningTextDeltaEvent(
1107
+ type="response.reasoning_text.delta",
1108
+ sequence_number=-1,
1109
+ content_index=current_content_index,
1110
+ output_index=current_output_index,
1111
+ item_id=current_item_id,
1112
+ delta=delta_message.reasoning_content,
1113
+ ))
1114
+ elif delta_message.content is not None:
1115
+ yield _send_event(
1116
+ openai_responses_types.ResponseTextDeltaEvent(
1117
+ type="response.output_text.delta",
1118
+ sequence_number=-1,
1119
+ content_index=current_content_index,
1120
+ output_index=current_output_index,
1121
+ item_id=current_item_id,
1122
+ delta=delta_message.content,
1123
+ logprobs=self._create_stream_response_logprobs(
1124
+ token_ids=output.token_ids,
1125
+ logprobs=output.logprobs,
1126
+ tokenizer=tokenizer,
1127
+ top_logprobs=request.top_logprobs,
1128
+ ) if request.is_include_output_logprobs() else [],
1129
+ ))
1130
+ current_content_index += 1
1131
+
1132
+ previous_delta_messages.append(delta_message)
1133
+ if previous_delta_messages:
1134
+ if previous_delta_messages[-1].reasoning_content is not None:
1135
+ reason_content = ''.join(pm.reasoning_content
1136
+ for pm in previous_delta_messages
1137
+ if pm.reasoning_content is not None)
1138
+ yield _send_event(
1139
+ ResponseReasoningTextDoneEvent(
1140
+ type="response.reasoning_text.done",
1141
+ item_id=current_item_id,
1142
+ sequence_number=-1,
1143
+ output_index=current_output_index,
1144
+ content_index=current_content_index,
1145
+ text=reason_content,
1146
+ ))
1147
+ current_content_index += 1
1148
+ reasoning_item = ResponseReasoningItem(
1149
+ type="reasoning",
1150
+ content=[
1151
+ ResponseReasoningTextContent(
1152
+ text=reason_content,
1153
+ type="reasoning_text",
1154
+ ),
1155
+ ],
1156
+ status="completed",
1157
+ id=current_item_id,
1158
+ summary=[],
1159
+ )
1160
+ yield _send_event(
1161
+ ResponseOutputItemDoneEvent(
1162
+ type="response.output_item.done",
1163
+ sequence_number=-1,
1164
+ output_index=current_output_index,
1165
+ item=reasoning_item,
1166
+ ))
1167
+ elif previous_delta_messages[-1].content is not None:
1168
+ final_content = ''.join(pm.content
1169
+ for pm in previous_delta_messages
1170
+ if pm.content is not None)
1171
+ yield _send_event(
1172
+ openai_responses_types.ResponseTextDoneEvent(
1173
+ type="response.output_text.done",
1174
+ sequence_number=-1,
1175
+ output_index=current_output_index,
1176
+ content_index=current_content_index,
1177
+ text=final_content,
1178
+ logprobs=[],
1179
+ item_id=current_item_id,
1180
+ ))
1181
+ current_content_index += 1
1182
+ part = ResponseOutputText(
1183
+ text=final_content,
1184
+ type="output_text",
1185
+ annotations=[],
1186
+ )
1187
+ yield _send_event(
1188
+ openai_responses_types.ResponseContentPartDoneEvent(
1189
+ type="response.content_part.done",
1190
+ sequence_number=-1,
1191
+ item_id=current_item_id,
1192
+ output_index=current_output_index,
1193
+ content_index=current_content_index,
1194
+ part=part,
1195
+ ))
1196
+ current_content_index += 1
1197
+ item = ResponseOutputMessage(
1198
+ type="message",
1199
+ role="assistant",
1200
+ content=[
1201
+ part,
1202
+ ],
1203
+ status="completed",
1204
+ id=current_item_id,
1205
+ summary=[],
1206
+ )
1207
+ yield _send_event(
1208
+ ResponseOutputItemDoneEvent(
1209
+ type="response.output_item.done",
1210
+ sequence_number=-1,
1211
+ output_index=current_output_index,
1212
+ item=item,
1213
+ ))
1214
+
1215
+ async def _process_harmony_streaming_events(
1216
+ self,
1217
+ request: ResponsesRequest,
1218
+ sampling_params: SamplingParams,
1219
+ result_generator: AsyncIterator[Optional[ConversationContext]],
1220
+ context: ConversationContext,
1221
+ model_name: str,
1222
+ tokenizer: AnyTokenizer,
1223
+ request_metadata: RequestResponseMetadata,
1224
+ created_time: int,
1225
+ _send_event: Callable[[BaseModel], str],
1226
+ ) -> AsyncGenerator[str, None]:
1227
current_content_index = 0 # FIXME: this number is never changed
1228
current_output_index = 0
1229
current_item_id = "" # FIXME: this number is never changed
1230
sent_output_item_added = False
1231
944
- initial_response = ResponsesResponse.from_request(
945
- request,
946
- sampling_params,
947
- model_name=model_name,
948
- created_time=created_time,
949
- output=[],
950
- status="in_progress",
951
- usage=None,
952
- ).model_dump()
953
- yield _send_event(
954
- ResponseCreatedEvent(
955
- type="response.created",
956
- sequence_number=-1,
957
- response=initial_response,
958
- ))
959
- yield _send_event(
960
- ResponseInProgressEvent(
961
- type="response.in_progress",
962
- sequence_number=-1,
963
- response=initial_response,
964
- ))
965
-
1232
async for ctx in result_generator:
1233
1234
assert isinstance(ctx, StreamingHarmonyContext)
@@ -1312,29 +1578,6 @@ class OpenAIServingResponses(OpenAIServing):
1578
),
1579
))
1580
1315
- async def empty_async_generator():
1316
- # A hack to trick Python to think this is a generator but in fact
1317
- # it immediately returns.
1318
- if False:
1319
- yield
1320
-
1321
- final_response = await self.responses_full_generator(
1322
- request,
1323
- sampling_params,
1324
- empty_async_generator(),
1325
- context,
1326
- model_name,
1327
- tokenizer,
1328
- request_metadata,
1329
- created_time=created_time,
1330
- )
1331
- yield _send_event(
1332
- openai_responses_types.ResponseCompletedEvent(
1333
- type="response.completed",
1334
- sequence_number=-1,
1335
- response=final_response.model_dump(),
1336
- ))
1337
-
1581
async def responses_stream_generator(
1582
self,
1583
request: ResponsesRequest,
@@ -1349,16 +1592,77 @@ class OpenAIServingResponses(OpenAIServing):
1592
# TODO:
1593
# 1. Handle disconnect
1594
1352
- if not isinstance(context, StreamingHarmonyContext):
1353
- raise NotImplementedError(
1354
- "Streaming is not supported for responses API without Harmony."
1355
- )
1356
-
1595
created_time = created_time or int(time.time())
1596
1597
+ sequence_number = 0
1598
+
1599
+ def _send_event(event: BaseModel):
1600
+ nonlocal sequence_number
1601
+ # Set sequence_number if the event has this attribute
1602
+ if hasattr(event, 'sequence_number'):
1603
+ event.sequence_number = sequence_number
1604
+ sequence_number += 1
1605
+ # Get event type from the event's type field if it exists
1606
+ event_type = getattr(event, 'type', 'unknown')
1607
+ return (f"event: {event_type}\n"
1608
+ f"data: {event.model_dump_json(indent=None)}\n\n")
1609
+
1610
async with AsyncExitStack() as exit_stack:
1360
- await context.init_tool_sessions(self.tool_server, exit_stack)
1361
- async for event_data in self._process_streaming_events(
1362
- request, sampling_params, result_generator, context,
1363
- model_name, tokenizer, request_metadata, created_time):
1611
+ processer = None
1612
+ if self.use_harmony:
1613
+ await context.init_tool_sessions(self.tool_server, exit_stack)
1614
+ processer = self._process_harmony_streaming_events
1615
+ else:
1616
+ processer = self._process_simple_streaming_events
1617
+
1618
+ initial_response = ResponsesResponse.from_request(
1619
+ request,
1620
+ sampling_params,
1621
+ model_name=model_name,
1622
+ created_time=created_time,
1623
+ output=[],
1624
+ status="in_progress",
1625
+ usage=None,
1626
+ ).model_dump()
1627
+ yield _send_event(
1628
+ ResponseCreatedEvent(
1629
+ type="response.created",
1630
+ sequence_number=-1,
1631
+ response=initial_response,
1632
+ ))
1633
+ yield _send_event(
1634
+ ResponseInProgressEvent(
1635
+ type="response.in_progress",
1636
+ sequence_number=-1,
1637
+ response=initial_response,
1638
+ ))
1639
+
1640
+ async for event_data in processer(request, sampling_params,
1641
+ result_generator, context,
1642
+ model_name, tokenizer,
1643
+ request_metadata, created_time,
1644
+ _send_event):
1645
yield event_data
1646
+
1647
+ async def empty_async_generator():
1648
+ # A hack to trick Python to think this is a generator but
1649
+ # in fact it immediately returns.
1650
+ if False:
1651
+ yield
1652
+
1653
+ final_response = await self.responses_full_generator(
1654
+ request,
1655
+ sampling_params,
1656
+ empty_async_generator(),
1657
+ context,
1658
+ model_name,
1659
+ tokenizer,
1660
+ request_metadata,
1661
+ created_time=created_time,
1662
+ )
1663
+ yield _send_event(
1664
+ openai_responses_types.ResponseCompletedEvent(
1665
+ type="response.completed",
1666
+ sequence_number=-1,
1667
+ response=final_response.model_dump(),
1668
+ ))