[Frontend] Add per-request metrics to Responses API (#55084)
Signed-off-by: wxin <wxin@nvidia.com> Co-authored-by: Benjamin Chislett <bchislett@nvidia.com>
xinnywinne committed
Sep 16, 2026 at 15:52 UTC
fbf2c5e8be9754f31c4e0f549189fc9f3bdd213c
6 files changed
+184
-14
docs/features/per_request_metrics.md
+12
@@ -120,6 +120,18 @@ the same `metrics` response field. As with `n > 1`, metrics are omitted for
120
requests with multiple prompts, because the timing data cannot be attributed to
121
a single prompt's generation.
122
123
+## Responses API
124
+
125
+Per-request metrics are available on the `/v1/responses` endpoint using the
126
+common `metrics` object described above. Non-streaming responses include it at
127
+the top level. Streaming responses include it in the final response carried by
128
+the `response.completed` event; intermediate events do not include metrics.
129
+
130
+Metrics are omitted for Responses requests that perform multiple
131
+model-generation turns, such as built-in tool-call workflows, because the
132
+response retains timing data for only one generation turn while token usage is
133
+accumulated across all turns.
134
+
135
## Relationship to Prometheus Metrics
136
137
The `metrics` response field provides per-request values for a single request.
tests/entrypoints/openai/responses/test_serving_responses.py
+135
-12
@@ -48,6 +48,7 @@ from vllm.entrypoints.openai.responses.context import (
48
SimpleContext,
49
)
50
from vllm.entrypoints.openai.responses.protocol import (
51
+ ResponseCompletedEvent,
52
ResponseCreatedEvent,
53
ResponseRawMessageAndToken,
54
ResponsesRequest,
@@ -71,6 +72,7 @@ from vllm.renderers.online_renderer import (
72
_extract_allowed_tools_from_mcp_requests,
73
)
74
from vllm.sampling_params import SamplingParams
75
+from vllm.v1.metrics.stats import RequestStateStats
76
77
pytestmark = pytest.mark.skip_global_cleanup
78
@@ -795,6 +797,7 @@ class TestInitializeToolSessions:
797
pass
798
799
assert serving_responses_instance.engine_client.generate.call_count == 2
800
+ assert context.request_metrics_cover_all_generation_turns is False
801
followup_engine_input = (
802
serving_responses_instance.engine_client.generate.call_args_list[1].args[0]
803
)
@@ -1248,9 +1251,19 @@ class TestHarmonyPreambleStreaming:
1251
assert "response.output_item.done" in type_names
1252
1253
1251
-def _make_simple_context_with_output(text, token_ids, response_parser=None):
1252
- """Create a SimpleContext with a RequestOutput containing the given text."""
1253
- ctx = SimpleContext(response_parser=response_parser)
1254
+_PER_REQUEST_STATS = RequestStateStats(
1255
+ queued_ts=1.0,
1256
+ scheduled_ts=1.5,
1257
+ first_token_ts=2.0,
1258
+ last_token_ts=3.0,
1259
+)
1260
+
1261
+
1262
+def _make_request_output(
1263
+ text,
1264
+ token_ids,
1265
+ metrics: RequestStateStats | None = None,
1266
+):
1267
completion = CompletionOutput(
1268
index=0,
1269
text=text,
@@ -1260,7 +1273,7 @@ def _make_simple_context_with_output(text, token_ids, response_parser=None):
1273
finish_reason=None,
1274
stop_reason=None,
1275
)
1263
- req_output = RequestOutput(
1276
+ return RequestOutput(
1277
request_id="req",
1278
prompt="hi",
1279
prompt_token_ids=[7, 8],
@@ -1268,13 +1281,29 @@ def _make_simple_context_with_output(text, token_ids, response_parser=None):
1281
outputs=[completion],
1282
finished=False,
1283
num_cached_tokens=0,
1284
+ metrics=metrics,
1285
)
1286
+
1287
+
1288
+def _make_simple_context_with_output(
1289
+ text,
1290
+ token_ids,
1291
+ response_parser=None,
1292
+ metrics: RequestStateStats | None = None,
1293
+):
1294
+ """Create a SimpleContext with a RequestOutput containing the given text."""
1295
+ ctx = SimpleContext(response_parser=response_parser)
1296
+ req_output = _make_request_output(text, token_ids, metrics)
1297
ctx.append_output(req_output)
1298
+ ctx.request_metrics = metrics
1299
return ctx
1300
1301
1276
-def _make_serving_instance_with_reasoning():
1277
- """Create an OpenAIServingResponses with a mocked reasoning parser."""
1302
+def _make_serving_instance(
1303
+ *,
1304
+ reasoning_parser: str = "",
1305
+ enable_per_request_metrics: bool = False,
1306
+) -> OpenAIServingResponses:
1307
engine_client = MagicMock()
1308
model_config = MagicMock()
1309
model_config.max_model_len = 100
@@ -1285,18 +1314,112 @@ def _make_serving_instance_with_reasoning():
1314
engine_client.input_processor = MagicMock()
1315
engine_client.renderer = MagicMock()
1316
1288
- models = MagicMock()
1289
-
1290
- serving = OpenAIServingResponses(
1317
+ return OpenAIServingResponses(
1318
engine_client=engine_client,
1292
- models=models,
1319
+ models=MagicMock(),
1320
online_renderer=MagicMock(),
1321
request_logger=None,
1322
chat_template=None,
1323
chat_template_content_format="auto",
1297
- reasoning_parser="qwen3",
1324
+ reasoning_parser=reasoning_parser,
1325
+ enable_per_request_metrics=enable_per_request_metrics,
1326
+ )
1327
+
1328
+
1329
+async def _empty_context_generator():
1330
+ if False:
1331
+ yield
1332
+
1333
+
1334
+async def _make_full_metrics_response(
1335
+ enable_per_request_metrics: bool,
1336
+ request_metrics_cover_all_generation_turns: bool = True,
1337
+):
1338
+ serving = _make_serving_instance(
1339
+ enable_per_request_metrics=enable_per_request_metrics
1340
+ )
1341
+ request = ResponsesRequest(input="hi", tools=[], stream=False, store=False)
1342
+ sampling_params = SamplingParams(max_tokens=16)
1343
+ context = SimpleContext()
1344
+ context.request_metrics_cover_all_generation_turns = (
1345
+ request_metrics_cover_all_generation_turns
1346
+ )
1347
+
1348
+ async def generate(*args, **kwargs):
1349
+ yield _make_request_output("hello", [10, 20], _PER_REQUEST_STATS)
1350
+
1351
+ serving.engine_client.generate.side_effect = generate
1352
+ result_generator = serving._generate_with_builtin_tools(
1353
+ request_id=request.request_id,
1354
+ engine_input=tokens_input([7, 8]),
1355
+ sampling_params=sampling_params,
1356
+ context=context,
1357
)
1299
- return serving
1358
+ response = await serving.responses_full_generator(
1359
+ request=request,
1360
+ sampling_params=sampling_params,
1361
+ result_generator=result_generator,
1362
+ context=context,
1363
+ model_name="test-model",
1364
+ tokenizer=MagicMock(),
1365
+ request_metadata=RequestResponseMetadata(request_id="req"),
1366
+ )
1367
+ assert isinstance(response, ResponsesResponse)
1368
+ return response
1369
+
1370
+
1371
+@pytest.mark.asyncio
1372
+async def test_responses_per_request_metrics_follow_server_flag():
1373
+ disabled_response = await _make_full_metrics_response(False)
1374
+ assert disabled_response.metrics is None
1375
+ assert "metrics" not in disabled_response.model_dump(mode="json")
1376
+
1377
+ enabled_response = await _make_full_metrics_response(True)
1378
+ assert enabled_response.metrics is not None
1379
+ assert enabled_response.metrics.time_to_first_token_ms == pytest.approx(500.0)
1380
+
1381
+
1382
+@pytest.mark.asyncio
1383
+async def test_responses_streaming_metrics_only_on_completed_event():
1384
+ serving = _make_serving_instance(enable_per_request_metrics=True)
1385
+ request = ResponsesRequest(input="hi", tools=[], stream=True, store=False)
1386
+ context = _make_simple_context_with_output(
1387
+ "hello", [10, 20], metrics=_PER_REQUEST_STATS
1388
+ )
1389
+
1390
+ events = [
1391
+ event
1392
+ async for event in serving.responses_stream_generator(
1393
+ request=request,
1394
+ sampling_params=SamplingParams(max_tokens=16),
1395
+ result_generator=_empty_context_generator(),
1396
+ context=context,
1397
+ model_name="test-model",
1398
+ tokenizer=MagicMock(),
1399
+ request_metadata=RequestResponseMetadata(request_id="req"),
1400
+ )
1401
+ ]
1402
+
1403
+ for event in events[:-1]:
1404
+ assert "metrics" not in event.response.model_dump(mode="json")
1405
+ assert isinstance(events[-1], ResponseCompletedEvent)
1406
+ assert events[-1].response.metrics is not None
1407
+ assert events[-1].response.metrics.time_to_first_token_ms == pytest.approx(500.0)
1408
+
1409
+
1410
+@pytest.mark.asyncio
1411
+async def test_responses_metrics_suppressed_for_multiple_generation_turns():
1412
+ response = await _make_full_metrics_response(
1413
+ True, request_metrics_cover_all_generation_turns=False
1414
+ )
1415
+
1416
+ assert response.metrics is None
1417
+ assert "metrics" not in response.model_dump(mode="json")
1418
+
1419
+
1420
+def _make_serving_instance_with_reasoning():
1421
+ """Create an OpenAIServingResponses with a mocked reasoning parser."""
1422
+ return _make_serving_instance(reasoning_parser="qwen3")
1423
1424
1425
def _identity_increment(event):
vllm/entrypoints/generate/api_router.py
+1
@@ -116,6 +116,7 @@ async def init_generate_state(
116
reasoning_parser=args.structured_outputs_config.reasoning_parser,
117
enable_prompt_tokens_details=args.enable_prompt_tokens_details,
118
enable_force_include_usage=args.enable_force_include_usage,
119
+ enable_per_request_metrics=args.enable_per_request_metrics,
120
enable_log_outputs=args.enable_log_outputs,
121
default_chat_template_kwargs=default_chat_template_kwargs,
122
)
vllm/entrypoints/openai/responses/context.py
+7
@@ -45,6 +45,8 @@ from vllm.utils import random_uuid
45
if TYPE_CHECKING:
46
from mcp.client import ClientSession
47
48
+ from vllm.v1.metrics.stats import RequestStateStats
49
+
50
logger = logging.getLogger(__name__)
51
52
# This is currently needed as the tool type doesn't 1:1 match the
@@ -102,6 +104,11 @@ class TurnMetrics:
104
105
class ConversationContext(ABC):
106
response_parser: Parser | None = None
107
+ request_metrics: "RequestStateStats | None" = None
108
+ # Built-in tools can trigger additional model generations. In that case,
109
+ # the stored engine timestamps cover only one turn, while token usage is
110
+ # accumulated across all turns.
111
+ request_metrics_cover_all_generation_turns: bool = True
112
113
@abstractmethod
114
def append_output(self, output: RequestOutput) -> None:
vllm/entrypoints/openai/responses/protocol.py
+12
-1
@@ -60,7 +60,11 @@ from vllm.entrypoints.chat_utils import (
60
ChatCompletionMessageParam,
61
ChatTemplateContentFormatOption,
62
)
63
-from vllm.entrypoints.generate.base.protocol import StopParam, validate_cache_salt
63
+from vllm.entrypoints.generate.base.protocol import (
64
+ PerRequestMetrics,
65
+ StopParam,
66
+ validate_cache_salt,
67
+)
68
from vllm.entrypoints.serve.engine.protocol import OpenAIBaseModel
69
from vllm.exceptions import VLLMValidationError
70
from vllm.logger import init_logger
@@ -684,6 +688,11 @@ class ResponsesResponse(OpenAIBaseModel):
688
usage: ResponseUsage | None = None
689
user: str | None = None
690
691
+ # vLLM-specific per-request metrics. Omitted unless enabled server-side.
692
+ metrics: PerRequestMetrics | None = Field(
693
+ default=None, exclude_if=lambda value: value is None
694
+ )
695
+
696
presence_penalty: float | None = Field(
697
default=None,
698
ge=-2.0,
@@ -752,6 +761,7 @@ class ResponsesResponse(OpenAIBaseModel):
761
output: list[ResponseOutputItem],
762
status: ResponseStatus,
763
usage: ResponseUsage | None = None,
764
+ metrics: PerRequestMetrics | None = None,
765
input_messages: ResponseInputOutputMessage | None = None,
766
output_messages: ResponseInputOutputMessage | None = None,
767
kv_transfer_params: dict[str, Any] | None = None,
@@ -795,6 +805,7 @@ class ResponsesResponse(OpenAIBaseModel):
805
truncation=request.truncation,
806
user=request.user,
807
usage=usage,
808
+ metrics=metrics,
809
kv_transfer_params=kv_transfer_params,
810
ec_transfer_params=ec_transfer_params,
811
)
vllm/entrypoints/openai/responses/serving.py
+17
-1
@@ -30,7 +30,10 @@ from vllm.entrypoints.generate.base.protocol import (
30
DeltaMessage,
31
RequestResponseMetadata,
32
)
33
-from vllm.entrypoints.generate.base.serving import GenerateBaseServing
33
+from vllm.entrypoints.generate.base.serving import (
34
+ GenerateBaseServing,
35
+ build_per_request_timing_metrics,
36
+)
37
from vllm.entrypoints.mcp.tool_server import ToolServer
38
from vllm.entrypoints.openai.models.serving import OpenAIServingModels
39
from vllm.entrypoints.openai.responses.context import (
@@ -109,6 +112,7 @@ class OpenAIServingResponses(GenerateBaseServing):
112
tool_server: ToolServer | None = None,
113
enable_prompt_tokens_details: bool = False,
114
enable_force_include_usage: bool = False,
115
+ enable_per_request_metrics: bool = False,
116
enable_log_outputs: bool = False,
117
default_chat_template_kwargs: dict[str, Any] | None = None,
118
) -> None:
@@ -136,6 +140,7 @@ class OpenAIServingResponses(GenerateBaseServing):
140
)
141
self.enable_prompt_tokens_details = enable_prompt_tokens_details
142
self.enable_force_include_usage = enable_force_include_usage
143
+ self.enable_per_request_metrics = enable_per_request_metrics
144
145
self.default_sampling_params = self.model_config.get_diff_sampling_param()
146
mc = self.model_config
@@ -645,6 +650,7 @@ class OpenAIServingResponses(GenerateBaseServing):
650
)
651
652
async for res in generator:
653
+ context.request_metrics = res.metrics
654
context.append_output(res)
655
# NOTE(woosuk): The stop condition is handled by the engine.
656
yield context
@@ -654,6 +660,7 @@ class OpenAIServingResponses(GenerateBaseServing):
660
break
661
662
# Call the tool and update the context with the result.
663
+ context.request_metrics_cover_all_generation_turns = False
664
tool_output = await context.call_tool()
665
context.append_tool_output(tool_output)
666
@@ -857,6 +864,14 @@ class OpenAIServingResponses(GenerateBaseServing):
864
],
865
),
866
)
867
+ per_request_metrics = None
868
+ if (
869
+ self.enable_per_request_metrics
870
+ and context.request_metrics_cover_all_generation_turns
871
+ ):
872
+ per_request_metrics = build_per_request_timing_metrics(
873
+ context.request_metrics, num_generated_tokens
874
+ )
875
response = ResponsesResponse.from_request(
876
request,
877
sampling_params,
@@ -867,6 +882,7 @@ class OpenAIServingResponses(GenerateBaseServing):
882
output=output,
883
status=status,
884
usage=usage,
885
+ metrics=per_request_metrics,
886
kv_transfer_params=context.kv_transfer_params,
887
ec_transfer_params=context.ec_transfer_params,
888
)