[Benchmark] Add Responses API backend to vllm bench serve (#54628)
Signed-off-by: QHarshil <harshil_c@hotmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Chauncey <chaunceyjiang@gmail.com>
Harshil committed
Sep 27, 2026 at 22:04 UTC
af7f9488c2210d67e1033ecdc845b087ee7fe92b
5 files changed
+682
-2
docs/benchmarking/cli.md
+40
@@ -596,6 +596,46 @@ vllm bench serve \
596
--max-concurrency 512
597
```
598
599
+#### Responses API Benchmark
600
+
601
+The `openai-responses` backend benchmarks vLLM's
602
+[Responses API](../serving/online_serving/openai_compatible_server.md#responses-api)
603
+directly, instead of routing the same workload through `/v1/chat/completions`.
604
+
605
+```bash
606
+# Server
607
+vllm serve openai/gpt-oss-20b
608
+
609
+# Client
610
+vllm bench serve \
611
+ --backend openai-responses \
612
+ --endpoint /v1/responses \
613
+ --model openai/gpt-oss-20b \
614
+ --dataset-name random \
615
+ --random-input-len 1024 \
616
+ --random-output-len 1024 \
617
+ --num-prompts 200
618
+```
619
+
620
+Reasoning deltas (`response.reasoning_text.delta`) count towards TTFT and ITL,
621
+because the server is already decoding tokens when it emits them. Only output
622
+text (`response.output_text.delta`) is collected as the generated text, which
623
+matches how the `openai-chat` backend treats `DeltaMessage.reasoning`. End-to-end
624
+latency stops at the last token event, so `latency - ttft` equals `sum(itl)` and
625
+TPOT is comparable with the other endpoints. The input and output token counts
626
+come from the usage block on `response.completed`, which is still required: a
627
+stream that ends without a terminal event is reported as a failed request.
628
+
629
+The backend measures one streamed text-generation request per prompt. Built-in
630
+tools, MCP, and multi-turn state via `previous_response_id` are out of scope.
631
+All sampling parameter flags are supported except `--min-p`, which the
632
+Responses API does not accept.
633
+
634
+!!! warning
635
+ Do not pass `--extra-body '{"include_reasoning": false}'` when benchmarking
636
+ a reasoning model. The server still generates reasoning tokens but emits no
637
+ events for them, so the whole reasoning phase is absorbed into TTFT.
638
+
639
#### Running With Sampling Parameters
640
641
When using OpenAI-compatible backends such as `vllm`, optional sampling
tests/benchmarks/test_responses_request_func.py
new
+455
@@ -0,0 +1,455 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Tests for the ``openai-responses`` backend of ``vllm bench serve``.
4
+
5
+The Responses stream differs from Chat Completions in ways the benchmark client
6
+has to get right: every SSE message is labelled with an ``event:`` line,
7
+reasoning and output text arrive as separate token-bearing events, and a
8
+request ends with ``response.completed`` rather than with its last token. These
9
+tests drive the real request function against a local SSE server so the
10
+resulting latency and usage numbers are covered end to end.
11
+"""
12
+
13
+import asyncio
14
+import json
15
+from collections.abc import Sequence
16
+
17
+import aiohttp
18
+import pytest
19
+from aiohttp import web
20
+from aiohttp.test_utils import TestServer
21
+
22
+from vllm.benchmarks.lib.endpoint_request_func import (
23
+ ASYNC_REQUEST_FUNCS,
24
+ OPENAI_COMPATIBLE_BACKENDS,
25
+ RequestFuncInput,
26
+ RequestFuncOutput,
27
+ async_request_openai_responses,
28
+)
29
+
30
+# These tests never touch torch, so skip the GPU cleanup fixture.
31
+pytestmark = pytest.mark.skip_global_cleanup
32
+
33
+MODEL = "openai/gpt-oss-20b"
34
+
35
+# Gaps between scripted events. GAP_TOKEN is much larger than GAP_PREFILL so
36
+# that a time to first token measured at the wrong event is unambiguous.
37
+GAP_PREFILL = 0.15
38
+GAP_TOKEN = 0.5
39
+GAP_FINALIZE = 0.3
40
+
41
+
42
+def sse(event_type: str, payload: dict) -> bytes:
43
+ """Frame one event the way ``/v1/responses`` does, with an ``event:`` line.
44
+
45
+ ``ensure_ascii=False`` matches the server, whose pydantic
46
+ ``model_dump_json`` emits non-ASCII characters unescaped.
47
+ """
48
+ data = json.dumps({"type": event_type, **payload}, ensure_ascii=False)
49
+ return f"event: {event_type}\ndata: {data}\n\n".encode()
50
+
51
+
52
+def usage_event(
53
+ input_tokens: int,
54
+ output_tokens: int,
55
+ event_type: str = "response.completed",
56
+ status: str = "completed",
57
+):
58
+ return sse(
59
+ event_type,
60
+ {
61
+ "response": {
62
+ "status": status,
63
+ "usage": {
64
+ "input_tokens": input_tokens,
65
+ "output_tokens": output_tokens,
66
+ "total_tokens": input_tokens + output_tokens,
67
+ },
68
+ }
69
+ },
70
+ )
71
+
72
+
73
+def text_delta(delta: str) -> bytes:
74
+ return sse("response.output_text.delta", {"delta": delta})
75
+
76
+
77
+def reasoning_delta(delta: str) -> bytes:
78
+ return sse("response.reasoning_text.delta", {"delta": delta})
79
+
80
+
81
+# The metadata events the server sends before any token. None of these may
82
+# start the TTFT clock or contribute an ITL sample.
83
+PREAMBLE = (
84
+ sse("response.created", {"response": {"status": "in_progress"}})
85
+ + sse("response.in_progress", {"response": {"status": "in_progress"}})
86
+ + sse("response.output_item.added", {"output_index": 0})
87
+ + sse("response.reasoning_part.added", {"output_index": 0})
88
+ + sse("response.content_part.added", {"output_index": 0})
89
+)
90
+
91
+# The `.done` family the server sends after the last delta. These repeat the
92
+# accumulated text, so timing them would add bogus ITL samples and counting
93
+# their payload would duplicate the output.
94
+DONE_EVENTS = (
95
+ sse("response.reasoning_text.done", {"text": "The user asks "})
96
+ + sse("response.reasoning_part.done", {"output_index": 0})
97
+ + sse("response.output_text.done", {"text": "Rayleigh scattering."})
98
+ + sse("response.content_part.done", {"output_index": 0})
99
+ + sse("response.output_item.done", {"output_index": 0})
100
+)
101
+
102
+
103
+async def _serve(
104
+ script: Sequence[bytes | float],
105
+ *,
106
+ status: int = 200,
107
+ chunk_size: int | None = None,
108
+ seen: dict | None = None,
109
+ **request_kwargs,
110
+) -> RequestFuncOutput:
111
+ """Run the request function against a server replaying ``script``.
112
+
113
+ Args:
114
+ script: Byte strings to write, interleaved with floats to sleep for.
115
+ status: HTTP status to respond with. Non-200 skips the script.
116
+ chunk_size: If set, split every write into chunks of this many bytes to
117
+ emulate fragmented transport.
118
+ seen: If given, populated with the request payload.
119
+ request_kwargs: Extra fields for the ``RequestFuncInput``.
120
+
121
+ """
122
+
123
+ async def handler(request: web.Request) -> web.StreamResponse:
124
+ if seen is not None:
125
+ seen["payload"] = await request.json()
126
+ seen["headers"] = dict(request.headers)
127
+ if status != 200:
128
+ return web.Response(status=status, reason="Bad Request")
129
+
130
+ response = web.StreamResponse(headers={"Content-Type": "text/event-stream"})
131
+ await response.prepare(request)
132
+ for item in script:
133
+ if isinstance(item, float):
134
+ await asyncio.sleep(item)
135
+ elif chunk_size is None:
136
+ await response.write(item)
137
+ else:
138
+ for i in range(0, len(item), chunk_size):
139
+ await response.write(item[i : i + chunk_size])
140
+ await response.write_eof()
141
+ return response
142
+
143
+ app = web.Application()
144
+ app.router.add_post("/v1/responses", handler)
145
+ server = TestServer(app)
146
+ await server.start_server()
147
+ try:
148
+ request_input = RequestFuncInput(
149
+ prompt="Why is the sky blue?",
150
+ api_url=str(server.make_url("/v1/responses")),
151
+ prompt_len=5,
152
+ output_len=16,
153
+ model=MODEL,
154
+ **request_kwargs,
155
+ )
156
+ async with aiohttp.ClientSession() as session:
157
+ return await async_request_openai_responses(request_input, session)
158
+ finally:
159
+ await server.close()
160
+
161
+
162
+def run(script: Sequence[bytes | float], **kwargs) -> RequestFuncOutput:
163
+ return asyncio.run(_serve(script, **kwargs))
164
+
165
+
166
+def test_backend_is_registered_as_openai_compatible():
167
+ assert ASYNC_REQUEST_FUNCS["openai-responses"] is async_request_openai_responses
168
+ # Membership enables --ignore-eos on random datasets and the sampling
169
+ # parameter flags, all of which ResponsesRequest accepts.
170
+ assert "openai-responses" in OPENAI_COMPATIBLE_BACKENDS
171
+
172
+
173
+def test_request_payload_is_accepted_by_the_server_schema():
174
+ """Every field sent must be a real ResponsesRequest field.
175
+
176
+ This is what makes the backend safe to list in OPENAI_COMPATIBLE_BACKENDS:
177
+ the flags that membership enables have to reach the model, and the server
178
+ drops unknown fields without complaining.
179
+ """
180
+ from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
181
+
182
+ sampling = {
183
+ "temperature": 0.7,
184
+ "top_p": 0.9,
185
+ "top_k": 20,
186
+ "frequency_penalty": 0.2,
187
+ "presence_penalty": 0.1,
188
+ "repetition_penalty": 1.05,
189
+ }
190
+ seen: dict = {}
191
+ output = run(
192
+ [PREAMBLE, text_delta("hi"), usage_event(5, 1)],
193
+ seen=seen,
194
+ extra_body=sampling,
195
+ ignore_eos=True,
196
+ model_name="served-alias",
197
+ request_id="bench-7",
198
+ extra_headers={"x-team": "perf"},
199
+ )
200
+
201
+ assert output.success
202
+ # --served-model-name wins over --model, as on the other backends.
203
+ assert seen["payload"]["model"] == "served-alias"
204
+ # The route is behind validate_json_request, which 415s a missing type.
205
+ assert seen["headers"]["Content-Type"] == "application/json"
206
+ assert seen["headers"]["x-request-id"] == "bench-7"
207
+ assert seen["headers"]["x-team"] == "perf"
208
+ assert seen["payload"] == {
209
+ "model": "served-alias",
210
+ "input": "Why is the sky blue?",
211
+ "max_output_tokens": 16,
212
+ "stream": True,
213
+ "store": False,
214
+ "ignore_eos": True,
215
+ **sampling,
216
+ }
217
+ assert set(seen["payload"]) <= set(ResponsesRequest.model_fields)
218
+ ResponsesRequest.model_validate(seen["payload"])
219
+
220
+ # `vllm bench serve` rejects --min-p for this backend for the same reason.
221
+ assert "min_p" not in ResponsesRequest.model_fields
222
+
223
+
224
+def test_latency_accounting_matches_the_other_endpoints():
225
+ """`latency - ttft` must equal `sum(itl)`.
226
+
227
+ `serve.py` derives TPOT as `(latency - ttft) / (output_len - 1)`, which only
228
+ means average inter-token latency when that identity holds. #55508 fixed the
229
+ same accounting on the chat and audio backends; the trailing events after
230
+ the last token must not extend the request.
231
+ """
232
+ output = run(
233
+ [
234
+ PREAMBLE,
235
+ GAP_PREFILL / 2,
236
+ # A keep-alive comment sent during prefill is not a token.
237
+ b": keep-alive\n\n",
238
+ GAP_PREFILL / 2,
239
+ reasoning_delta("The user asks "),
240
+ GAP_TOKEN,
241
+ text_delta("Rayleigh"),
242
+ text_delta(" scattering."),
243
+ DONE_EVENTS,
244
+ GAP_FINALIZE,
245
+ usage_event(5, 8),
246
+ ]
247
+ )
248
+
249
+ assert output.success
250
+ # Reasoning is timed but not returned, matching how `openai-chat` treats
251
+ # `DeltaMessage.reasoning`.
252
+ assert output.generated_text == "Rayleigh scattering."
253
+
254
+ # Neither the response.created/in_progress/output_item.added preamble nor
255
+ # the keep-alive comment may start the clock, and the reasoning delta must,
256
+ # so TTFT lands on the prefill gap rather than near zero or out at the
257
+ # first output text delta.
258
+ assert GAP_PREFILL * 0.8 <= output.ttft < GAP_PREFILL + GAP_TOKEN * 0.5
259
+
260
+ # One ITL per token event after the first: the gap before "Rayleigh" and
261
+ # the back-to-back delta that follows it.
262
+ assert len(output.itl) == 2
263
+ assert output.itl[0] >= GAP_TOKEN * 0.5
264
+ assert output.itl[1] < GAP_TOKEN * 0.5
265
+
266
+ # The identity is exact, so no wall-clock margin is involved. The .done
267
+ # family and the GAP_FINALIZE wait before response.completed sit outside
268
+ # the request, exactly as the trailing usage chunk does on chat.
269
+ assert output.latency - output.ttft == pytest.approx(sum(output.itl), abs=1e-9)
270
+
271
+ assert output.start_time > 0.0
272
+
273
+
274
+def test_usage_is_taken_from_the_terminal_event():
275
+ output = run([PREAMBLE, text_delta("blue"), usage_event(17, 4)])
276
+
277
+ assert output.success
278
+ assert output.prompt_len == 17
279
+ assert output.output_tokens == 4
280
+
281
+
282
+@pytest.mark.parametrize("event_type", ["response.completed", "response.incomplete"])
283
+def test_incomplete_response_is_a_successful_request(event_type: str):
284
+ """Hitting max_output_tokens is the Responses form of finish_reason=length.
285
+
286
+ vLLM reports it as `response.completed` carrying `status: "incomplete"`.
287
+ The spec's dedicated `response.incomplete` event is accepted as well.
288
+ """
289
+ output = run(
290
+ [
291
+ PREAMBLE,
292
+ text_delta("truncated"),
293
+ usage_event(17, 16, event_type=event_type, status="incomplete"),
294
+ ]
295
+ )
296
+
297
+ assert output.success
298
+ assert output.generated_text == "truncated"
299
+ assert output.output_tokens == 16
300
+
301
+
302
+@pytest.mark.parametrize("chunk_size", [1, 3, 7, 64])
303
+def test_output_survives_transport_chunk_boundaries(chunk_size: int):
304
+ """Splits inside the event line, the JSON, or a multibyte character."""
305
+ text = " leading 中 trailing "
306
+ output = run(
307
+ [PREAMBLE, text_delta(text), usage_event(5, 6)],
308
+ chunk_size=chunk_size,
309
+ )
310
+
311
+ assert output.success
312
+ assert output.generated_text == text
313
+ assert output.output_tokens == 6
314
+
315
+
316
+@pytest.mark.parametrize(
317
+ "char", ["\u2028", "\u2029", "\u0085"], ids=["u2028", "u2029", "u0085"]
318
+)
319
+def test_unicode_line_separators_do_not_truncate_the_payload(char: str):
320
+ """The server emits these unescaped and they are legal inside a JSON string.
321
+
322
+ `str.splitlines()` treats them as line breaks, which would cut the `data:`
323
+ line short and fail the request on valid model output.
324
+ """
325
+ text = f"before{char}after"
326
+ output = run([PREAMBLE, text_delta(text), usage_event(5, 3)])
327
+
328
+ assert output.success, output.error
329
+ assert output.generated_text == text
330
+
331
+
332
+def test_empty_data_line_is_skipped():
333
+ """A bare `data:` line carries no JSON and must not fail the request."""
334
+ output = run([PREAMBLE, b"data:\n\n", text_delta("ok"), usage_event(5, 1)])
335
+
336
+ assert output.success, output.error
337
+ assert output.generated_text == "ok"
338
+
339
+
340
+def test_prompt_len_falls_back_when_usage_is_absent():
341
+ """A terminal event without usage keeps the locally computed prompt length."""
342
+ output = run(
343
+ [
344
+ PREAMBLE,
345
+ text_delta("hi"),
346
+ sse("response.completed", {"response": {"status": "completed"}}),
347
+ ]
348
+ )
349
+
350
+ assert output.success, output.error
351
+ assert output.prompt_len == 5
352
+
353
+
354
+def test_multi_line_data_payload_is_joined_with_newlines():
355
+ """The SSE spec allows one payload to span several `data:` lines."""
356
+ script: list[bytes | float] = [
357
+ PREAMBLE,
358
+ b"event: response.output_text.delta\n"
359
+ b'data: {"type": "response.output_text.delta",\n'
360
+ b'data: "delta": "ok"}\n\n',
361
+ usage_event(5, 1),
362
+ ]
363
+ output = run(script)
364
+
365
+ assert output.success, output.error
366
+ assert output.generated_text == "ok"
367
+
368
+
369
+def test_done_sentinel_is_skipped():
370
+ """Other Responses servers send `[DONE]`, but vLLM does not."""
371
+ output = run([PREAMBLE, text_delta("ok"), usage_event(5, 1), b"data: [DONE]\n\n"])
372
+
373
+ assert output.success, output.error
374
+ assert output.generated_text == "ok"
375
+
376
+
377
+def test_error_before_any_token_reports_the_server_error():
378
+ """The server's message must win over the generic no-TTFT message."""
379
+ output = run(
380
+ [PREAMBLE, b'event: error\ndata: {"type": "error", "message": "boom"}\n\n']
381
+ )
382
+
383
+ assert not output.success
384
+ assert "boom" in output.error
385
+
386
+
387
+def test_http_error_is_not_reported_as_success():
388
+ output = run([], status=400)
389
+
390
+ assert not output.success
391
+ assert output.error
392
+
393
+
394
+def test_stream_without_tokens_is_not_reported_as_success():
395
+ """A stream that finishes without generating anything has no TTFT."""
396
+ output = run([PREAMBLE, usage_event(5, 0)])
397
+
398
+ assert not output.success
399
+ assert "TTFT" in output.error
400
+
401
+
402
+def test_truncated_stream_is_not_reported_as_success():
403
+ """A stream cut short after a token has no usage and an understated E2EL."""
404
+ output = run([PREAMBLE, text_delta("partial")])
405
+
406
+ assert not output.success
407
+ assert "terminal event" in output.error
408
+
409
+
410
+def test_mid_stream_error_is_not_reported_as_success():
411
+ """An error payload mid-stream must not be counted as a success.
412
+
413
+ vLLM cannot emit this today: its streaming error path fails to validate
414
+ against the event union, so a generation failure truncates the stream
415
+ instead. Proxies and other Responses servers do send it.
416
+ """
417
+ output = run(
418
+ [
419
+ PREAMBLE,
420
+ text_delta("partial"),
421
+ b'event: error\ndata: {"error": {"message": "boom", "code": 500}}\n\n',
422
+ ]
423
+ )
424
+
425
+ assert not output.success
426
+ assert "boom" in output.error
427
+
428
+
429
+def test_data_only_framing_is_still_parsed():
430
+ """Some proxies drop the ``event:`` line and forward only ``data:``."""
431
+ script: list[bytes | float] = [
432
+ b'data: {"type": "response.output_text.delta", "delta": "ok"}\n\n',
433
+ usage_event(5, 1),
434
+ ]
435
+ output = run(script)
436
+
437
+ assert output.success
438
+ assert output.generated_text == "ok"
439
+
440
+
441
+def test_endpoint_path_is_validated():
442
+ request_input = RequestFuncInput(
443
+ prompt="hi",
444
+ api_url="http://localhost:8000/v1/chat/completions",
445
+ prompt_len=1,
446
+ output_len=1,
447
+ model=MODEL,
448
+ )
449
+
450
+ async def call() -> None:
451
+ async with aiohttp.ClientSession() as session:
452
+ await async_request_openai_responses(request_input, session)
453
+
454
+ with pytest.raises(ValueError, match="responses"):
455
+ asyncio.run(call())
vllm/benchmarks/datasets/datasets.py
+3
-1
@@ -2148,7 +2148,9 @@ def get_samples(
2148
dataset_path=args.dataset_path, disable_shuffle=args.disable_shuffle
2149
)
2150
# For the "sonnet" dataset, formatting depends on the backend.
2151
- if args.backend == "openai-chat":
2151
+ # Chat-style backends leave templating to the server; completions
2152
+ # backends need the prompt rendered client side.
2153
+ if args.backend in ("openai-chat", "openai-responses"):
2154
input_requests = sonnet_dataset.sample(
2155
num_requests=args.num_prompts,
2156
input_len=args.sonnet_input_len,
vllm/benchmarks/lib/endpoint_request_func.py
+179
-1
@@ -447,6 +447,178 @@ async def async_request_openai_chat_completions(
447
return output
448
449
450
+def _extract_sse_data(message: str) -> str | None:
451
+ """Extract the ``data`` payload of a single SSE message.
452
+
453
+ The Responses API stream labels every message with an ``event:`` line
454
+ before its ``data:`` line, so the payload cannot be recovered by
455
+ stripping a leading ``data: `` the way the Completions and Chat
456
+ Completions streams allow.
457
+
458
+ Args:
459
+ message: One complete SSE message, without its trailing blank line.
460
+
461
+ Returns:
462
+ The payload, with multi-line payloads joined by newlines as required
463
+ by the SSE spec, or None if the message carries no ``data:`` line.
464
+
465
+ """
466
+ # NOTE: split on "\n" rather than using splitlines(), which also breaks on
467
+ # U+2028, U+2029 and U+0085. Those are legal inside a JSON string and the
468
+ # server emits them unescaped, so splitlines() would truncate the payload.
469
+ data_lines = [
470
+ line.removeprefix("data:").removeprefix(" ")
471
+ for line in message.split("\n")
472
+ if line.startswith("data:")
473
+ ]
474
+ if not data_lines:
475
+ return None
476
+
477
+ return "\n".join(data_lines)
478
+
479
+
480
+# Responses events that carry generated tokens. Reasoning deltas are included
481
+# because the server is already decoding when it emits them, so excluding them
482
+# would report the whole reasoning phase as time to first token.
483
+_RESPONSES_TOKEN_DELTA_EVENTS = frozenset(
484
+ {
485
+ "response.output_text.delta",
486
+ "response.reasoning_text.delta",
487
+ }
488
+)
489
+
490
+# Responses events that end a request. Truncation at the output length limit
491
+# is a normal benchmark outcome: vLLM reports it as `response.completed` with
492
+# `status: "incomplete"`, while the spec also defines a dedicated
493
+# `response.incomplete` terminal event.
494
+_RESPONSES_TERMINAL_EVENTS = frozenset(
495
+ {
496
+ "response.completed",
497
+ "response.incomplete",
498
+ }
499
+)
500
+
501
+# The spec's failure terminals. vLLM does not emit them today, but
502
+# `vllm bench serve` can be pointed at other Responses servers.
503
+_RESPONSES_ERROR_EVENTS = frozenset({"error", "response.failed"})
504
+
505
+
506
+async def async_request_openai_responses(
507
+ request_func_input: RequestFuncInput,
508
+ session: aiohttp.ClientSession,
509
+ pbar: tqdm | None = None,
510
+) -> RequestFuncOutput:
511
+ api_url = request_func_input.api_url
512
+ _validate_api_url(api_url, "OpenAI Responses API", "responses")
513
+
514
+ payload = {
515
+ "model": request_func_input.model_name
516
+ if request_func_input.model_name
517
+ else request_func_input.model,
518
+ "input": request_func_input.prompt,
519
+ "max_output_tokens": request_func_input.output_len,
520
+ "stream": True,
521
+ # `store` defaults to true. A server started with
522
+ # VLLM_ENABLE_RESPONSES_API_STORE=1 keeps every stored response for
523
+ # the lifetime of the process, which would grow its heap over a long
524
+ # benchmark run.
525
+ "store": False,
526
+ }
527
+ _update_payload_common(payload, request_func_input)
528
+
529
+ headers = _get_headers("application/json")
530
+ _update_headers_common(headers, request_func_input)
531
+
532
+ output = RequestFuncOutput()
533
+ output.prompt_len = request_func_input.prompt_len
534
+
535
+ # Only output text is accumulated. Reasoning text is timed but not
536
+ # returned, matching how `openai-chat` handles `DeltaMessage.reasoning`.
537
+ generated_text = ""
538
+ stream_error: str | None = None
539
+ saw_terminal_event = False
540
+ first_chunk_received = False
541
+ st = time.perf_counter()
542
+ output.start_time = st
543
+ most_recent_timestamp = st
544
+ try:
545
+ async with session.post(url=api_url, json=payload, headers=headers) as response:
546
+ if response.status == 200:
547
+ handler = StreamedResponseHandler()
548
+
549
+ async for chunk_bytes in response.content.iter_any():
550
+ for message in handler.add_chunk(chunk_bytes):
551
+ # NOTE: `_extract_sse_data` returns None for SSE
552
+ # comments, which servers may send as keep-alives.
553
+ chunk = _extract_sse_data(message)
554
+ if not chunk or chunk == "[DONE]":
555
+ continue
556
+
557
+ timestamp = time.perf_counter()
558
+ data = json.loads(chunk)
559
+ event_type = data.get("type")
560
+
561
+ if event_type in _RESPONSES_TOKEN_DELTA_EVENTS:
562
+ # First token
563
+ if not first_chunk_received:
564
+ first_chunk_received = True
565
+ output.ttft = timestamp - st
566
+
567
+ # Decoding phase
568
+ else:
569
+ output.itl.append(timestamp - most_recent_timestamp)
570
+
571
+ if event_type == "response.output_text.delta":
572
+ generated_text += data.get("delta") or ""
573
+
574
+ # Only token events advance the end of the
575
+ # request, so `latency - ttft` stays equal to
576
+ # `sum(itl)` and TPOT means what serve.py says.
577
+ most_recent_timestamp = timestamp
578
+ elif event_type in _RESPONSES_TERMINAL_EVENTS:
579
+ saw_terminal_event = True
580
+ usage = (data.get("response") or {}).get("usage") or {}
581
+ if (n_output := usage.get("output_tokens")) is not None:
582
+ output.output_tokens = n_output
583
+ if (n_input := usage.get("input_tokens")) is not None:
584
+ output.prompt_len = n_input
585
+ elif event_type in _RESPONSES_ERROR_EVENTS or data.get("error"):
586
+ stream_error = json.dumps(data.get("error") or data)
587
+
588
+ output.generated_text = generated_text
589
+ output.latency = most_recent_timestamp - st
590
+ if stream_error is not None:
591
+ output.success = False
592
+ output.error = stream_error
593
+ elif not first_chunk_received:
594
+ output.success = False
595
+ output.error = (
596
+ "Never received a token delta to calculate TTFT."
597
+ "This response will be marked as failed!"
598
+ )
599
+ elif not saw_terminal_event:
600
+ # Without the terminal event the response is truncated and
601
+ # its usage block is missing.
602
+ output.success = False
603
+ output.error = (
604
+ "Stream ended without a terminal event."
605
+ "This response will be marked as failed!"
606
+ )
607
+ else:
608
+ output.success = True
609
+ else:
610
+ output.error = response.reason or ""
611
+ output.success = False
612
+ except Exception:
613
+ output.success = False
614
+ exc_info = sys.exc_info()
615
+ output.error = "".join(traceback.format_exception(*exc_info))
616
+
617
+ if pbar:
618
+ pbar.update(1)
619
+ return output
620
+
621
+
622
async def async_request_openai_audio(
623
request_func_input: RequestFuncInput,
624
session: aiohttp.ClientSession,
@@ -920,6 +1092,7 @@ ASYNC_REQUEST_FUNCS: dict[str, RequestFunc] = {
1092
"vllm": async_request_openai_completions,
1093
"openai": async_request_openai_completions,
1094
"openai-chat": async_request_openai_chat_completions,
1095
+ "openai-responses": async_request_openai_responses,
1096
"openai-audio": async_request_openai_audio,
1097
"openai-embeddings": async_request_openai_embeddings,
1098
"openai-embeddings-chat": async_request_openai_embeddings_chat,
@@ -947,5 +1120,10 @@ POOLING_BACKENDS = {
1120
OPENAI_COMPATIBLE_BACKENDS = [
1121
k
1122
for k, v in ASYNC_REQUEST_FUNCS.items()
950
- if v in (async_request_openai_completions, async_request_openai_chat_completions)
1123
+ if v
1124
+ in (
1125
+ async_request_openai_completions,
1126
+ async_request_openai_chat_completions,
1127
+ async_request_openai_responses,
1128
+ )
1129
]
vllm/benchmarks/serve.py
+5
@@ -2216,6 +2216,11 @@ async def main_async(args: argparse.Namespace) -> dict[str, Any]:
2216
"Sampling parameters are only supported by openai-compatible backends."
2217
)
2218
2219
+ # The Responses API accepts every sampling parameter above except
2220
+ # min_p, which it would silently drop as an unknown field.
2221
+ if args.backend == "openai-responses" and "min_p" in sampling_params:
2222
+ raise ValueError("--min-p is not supported by the Responses API.")
2223
+
2224
if "temperature" not in sampling_params:
2225
print(
2226
"WARNING: vllm bench serve no longer sets temperature==0 (greedy) "