[Frontend] add per-request timing `metrics` field to response body of Chat/Completions APIs (#46768)
Signed-off-by: Nicholas Edelman <nedelman@nvidia.com> Signed-off-by: Simon Mo <simon@inferact.ai> Co-authored-by: Simon Mo <simon@inferact.ai> Co-authored-by: GPT-5.5 <noreply@cursor.com>
nv-nedelman-1 committed
Jul 6, 2026 at 19:48 UTC
86db6c307073217599a993f1a801e3c204f2eb17
12 files changed
+565
-4
docs/features/per_request_metrics.md
new
+127
@@ -0,0 +1,127 @@
1
+# Per-Request Metrics
2
+
3
+vLLM can return per-request timing metrics directly in API responses.
4
+This is useful for billing, SLA monitoring, and latency analysis at the
5
+individual request level, as a complement to the server-aggregated Prometheus
6
+metrics exposed at `/metrics`.
7
+
8
+## Enabling
9
+
10
+Start the server with `--enable-per-request-metrics`:
11
+
12
+```bash
13
+vllm serve meta-llama/Llama-3.1-8B-Instruct --enable-per-request-metrics
14
+```
15
+
16
+When this flag is set, supported API responses include metrics for each
17
+attributable request.
18
+
19
+!!! note
20
+ At high concurrency, enabling per-request metrics computation may introduce
21
+ non-negligible CPU overhead. Benchmark your specific workload to evaluate the
22
+ impact before enabling in production.
23
+
24
+## Response Format
25
+
26
+When per-request metrics are enabled, the response includes a `metrics` object:
27
+
28
+```json
29
+{
30
+ "id": "chatcmpl-abc123",
31
+ "object": "chat.completion",
32
+ "model": "meta-llama/Llama-3.1-8B-Instruct",
33
+ "choices": [ ... ],
34
+ "usage": {
35
+ "prompt_tokens": 42,
36
+ "completion_tokens": 128,
37
+ "total_tokens": 170
38
+ },
39
+ "metrics": {
40
+ "time_to_first_token_ms": 85.2,
41
+ "generation_time_ms": 1240.5,
42
+ "queue_time_ms": 12.3,
43
+ "mean_itl_ms": 9.1,
44
+ "tokens_per_second": 103.2
45
+ }
46
+}
47
+```
48
+
49
+| Field | Description |
50
+| --- | --- |
51
+| `time_to_first_token_ms` | Time from when the request was scheduled until the first output token was generated (TTFT). |
52
+| `generation_time_ms` | Decode time: time from the first output token to the last output token. Excludes both queue wait and prefill/TTFT. |
53
+| `queue_time_ms` | Time the request spent waiting in the scheduler queue before processing began. |
54
+| `mean_itl_ms` | Mean inter-token latency (average time between successive output tokens) during the decode phase. `null` for single-token responses. |
55
+| `tokens_per_second` | Overall output token throughput: all generated tokens over the inference interval (scheduling to last output token). Unlike `generation_time_ms`, this includes the prefill phase, so it reflects end-to-end generation speed rather than pure decode speed. |
56
+
57
+All fields are `null` if the underlying timing data is not available for that
58
+request.
59
+
60
+!!! note
61
+ Timing metrics describe a single generation stream, so they are only
62
+ returned when the request maps to exactly one. They are suppressed (the
63
+ `metrics` object is `null`) for requests with `n > 1`, because the
64
+ underlying timing data reflects only one of the `n` sequences and cannot be
65
+ accurately attributed to the request as a whole. Token usage
66
+ (`prompt_tokens`, `completion_tokens`) remains accurate in these cases.
67
+ Per-request metrics also require server-side statistics logging, which is
68
+ on by default. vLLM rejects `--enable-per-request-metrics` when
69
+ `--disable-log-stats` is also set.
70
+
71
+## Example Request
72
+
73
+=== "Non-streaming"
74
+
75
+ ```python
76
+ from openai import OpenAI
77
+
78
+ client = OpenAI(base_url="http://localhost:8000/v1", api_key="token")
79
+
80
+ response = client.chat.completions.create(
81
+ model="meta-llama/Llama-3.1-8B-Instruct",
82
+ messages=[{"role": "user", "content": "What is the capital of France?"}],
83
+ )
84
+
85
+ print(response.usage)
86
+ print(response.model_extra.get("metrics"))
87
+ ```
88
+
89
+=== "Streaming"
90
+
91
+ In streaming responses, metrics are attached to the final usage chunk (the
92
+ chunk sent after all content chunks). That chunk is only emitted when usage
93
+ reporting is enabled with `stream_options.include_usage: true` or forced
94
+ server-side with `--enable-force-include-usage`. Without forced usage, a
95
+ streaming client must set `stream_options.include_usage: true` to receive
96
+ metrics.
97
+
98
+ ```python
99
+ from openai import OpenAI
100
+
101
+ client = OpenAI(base_url="http://localhost:8000/v1", api_key="token")
102
+
103
+ stream = client.chat.completions.create(
104
+ model="meta-llama/Llama-3.1-8B-Instruct",
105
+ messages=[{"role": "user", "content": "What is the capital of France?"}],
106
+ stream=True,
107
+ stream_options={"include_usage": True},
108
+ )
109
+
110
+ for chunk in stream:
111
+ if chunk.usage:
112
+ print("Usage:", chunk.usage)
113
+ print("Metrics:", chunk.model_extra.get("metrics"))
114
+ ```
115
+
116
+## Completions API
117
+
118
+Per-request metrics are also available on the `/v1/completions` endpoint using
119
+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
+## Relationship to Prometheus Metrics
124
+
125
+The `metrics` response field provides per-request values for a single request.
126
+The `/metrics` Prometheus endpoint exposes server-level histograms (e.g.
127
+`vllm:time_to_first_token_seconds`) that aggregate across all requests.
tests/entrypoints/openai/chat_completion/test_serving_chat.py
+173
@@ -2,6 +2,7 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
import asyncio
4
import json
5
+from collections.abc import AsyncIterator
6
from contextlib import suppress
7
from dataclasses import dataclass, field
8
from typing import Any
@@ -19,6 +20,7 @@ from tests.entrypoints.openai.utils import (
20
from tests.utils import RemoteOpenAIServer
21
from vllm._aiter_ops import is_aiter_found_and_supported
22
from vllm.config import MultiModalConfig
23
+from vllm.entrypoints.generate.base.serving import build_per_request_timing_metrics
24
from vllm.entrypoints.openai.chat_completion.protocol import (
25
ChatCompletionRequest,
26
ChatCompletionResponse,
@@ -50,9 +52,17 @@ from vllm.tokenizers import get_tokenizer
52
from vllm.tokenizers.mistral import MistralTokenizer
53
from vllm.tokenizers.registry import cached_tokenizer_from_config
54
from vllm.v1.engine.async_llm import AsyncLLM
55
+from vllm.v1.metrics.stats import RequestStateStats
56
57
GPT_OSS_MODEL_NAME = "openai/gpt-oss-20b"
58
GPT_OSS_SPECULATOR_NAME = "RedHatAI/gpt-oss-20b-speculator.eagle3"
59
+_PER_REQUEST_STATS = RequestStateStats(
60
+ queued_ts=1.0,
61
+ scheduled_ts=1.5,
62
+ first_token_ts=2.0,
63
+ last_token_ts=3.0,
64
+ num_generation_tokens=2,
65
+)
66
67
68
@pytest.fixture(scope="module")
@@ -608,6 +618,169 @@ def _build_serving_chat(
618
return serving_chat
619
620
621
+def _build_minimal_metrics_serving_chat(
622
+ enable_per_request_metrics: bool,
623
+ enable_force_include_usage: bool = False,
624
+) -> OpenAIServingChat:
625
+ serving = OpenAIServingChat.__new__(OpenAIServingChat)
626
+ serving.response_role = "assistant"
627
+ serving.parser_cls = None
628
+ serving.enable_auto_tools = False
629
+ serving.enable_prompt_tokens_details = False
630
+ serving.enable_log_outputs = False
631
+ serving.enable_log_deltas = False
632
+ serving.enable_force_include_usage = enable_force_include_usage
633
+ serving.request_logger = None
634
+ serving.system_fingerprint = None
635
+ serving.enable_per_request_metrics = enable_per_request_metrics
636
+ return serving
637
+
638
+
639
+def _make_metrics_request_output(
640
+ metrics: RequestStateStats | None = _PER_REQUEST_STATS,
641
+ token_ids: tuple[int, ...] = (100, 101),
642
+) -> RequestOutput:
643
+ return RequestOutput(
644
+ request_id="test-id",
645
+ prompt="Test prompt",
646
+ prompt_token_ids=[1, 2, 3],
647
+ prompt_logprobs=None,
648
+ outputs=[
649
+ CompletionOutput(
650
+ index=0,
651
+ text="Hello",
652
+ token_ids=list(token_ids),
653
+ cumulative_logprob=None,
654
+ logprobs=None,
655
+ finish_reason="stop",
656
+ )
657
+ ],
658
+ finished=True,
659
+ metrics=metrics,
660
+ )
661
+
662
+
663
+async def _single_request_output(
664
+ request_output: RequestOutput,
665
+) -> AsyncIterator[RequestOutput]:
666
+ yield request_output
667
+
668
+
669
+async def _collect_metrics_stream_chunks(
670
+ serving: OpenAIServingChat,
671
+ request: ChatCompletionRequest,
672
+) -> list[dict[str, Any]]:
673
+ chunks: list[dict[str, Any]] = []
674
+ async for line in serving.chat_completion_stream_generator(
675
+ request,
676
+ _single_request_output(_make_metrics_request_output()),
677
+ "chatcmpl-test-id",
678
+ "test-model",
679
+ conversation=[{"role": "user", "content": "Test"}],
680
+ tokenizer=MagicMock(),
681
+ request_metadata=RequestResponseMetadata(request_id="chatcmpl-test-id"),
682
+ ):
683
+ line = line.strip()
684
+ if not line.startswith("data: "):
685
+ continue
686
+ payload = line[len("data: ") :]
687
+ if payload != "[DONE]":
688
+ chunks.append(json.loads(payload))
689
+ return chunks
690
+
691
+
692
+def test_build_per_request_timing_metrics_valid_timestamps():
693
+ metrics = build_per_request_timing_metrics(
694
+ _PER_REQUEST_STATS, num_generation_tokens=10
695
+ )
696
+
697
+ assert metrics.time_to_first_token_ms == pytest.approx(500.0)
698
+ assert metrics.generation_time_ms == pytest.approx(1000.0)
699
+ assert metrics.queue_time_ms == pytest.approx(500.0)
700
+ assert metrics.mean_itl_ms == pytest.approx(1000.0 / 9, rel=1e-4)
701
+ assert metrics.tokens_per_second == pytest.approx(10.0 / 1.5, rel=1e-4)
702
+
703
+
704
+@pytest.mark.asyncio
705
+async def test_chat_per_request_metrics_follow_server_flag():
706
+ request = ChatCompletionRequest(
707
+ model="test-model",
708
+ messages=[{"role": "user", "content": "Test prompt"}],
709
+ max_tokens=10,
710
+ stream=False,
711
+ )
712
+ request_output = _make_metrics_request_output()
713
+
714
+ disabled_serving = _build_minimal_metrics_serving_chat(
715
+ enable_per_request_metrics=False
716
+ )
717
+ disabled_response = await disabled_serving.chat_completion_full_generator(
718
+ request,
719
+ _single_request_output(request_output),
720
+ "chatcmpl-test-id",
721
+ "test-model",
722
+ conversation=[{"role": "user", "content": "Test"}],
723
+ tokenizer=MagicMock(),
724
+ request_metadata=RequestResponseMetadata(request_id="chatcmpl-test-id"),
725
+ )
726
+ assert disabled_response.metrics is None
727
+
728
+ enabled_serving = _build_minimal_metrics_serving_chat(
729
+ enable_per_request_metrics=True
730
+ )
731
+ enabled_response = await enabled_serving.chat_completion_full_generator(
732
+ request,
733
+ _single_request_output(request_output),
734
+ "chatcmpl-test-id",
735
+ "test-model",
736
+ conversation=[{"role": "user", "content": "Test"}],
737
+ tokenizer=MagicMock(),
738
+ request_metadata=RequestResponseMetadata(request_id="chatcmpl-test-id"),
739
+ )
740
+ assert enabled_response.metrics is not None
741
+ assert enabled_response.metrics.time_to_first_token_ms == pytest.approx(500.0)
742
+
743
+
744
+@pytest.mark.asyncio
745
+async def test_chat_per_request_metrics_suppressed_for_n_greater_than_one():
746
+ serving = _build_minimal_metrics_serving_chat(enable_per_request_metrics=True)
747
+ response = await serving.chat_completion_full_generator(
748
+ ChatCompletionRequest(
749
+ model="test-model",
750
+ messages=[{"role": "user", "content": "Test prompt"}],
751
+ max_tokens=10,
752
+ stream=False,
753
+ n=2,
754
+ ),
755
+ _single_request_output(_make_metrics_request_output()),
756
+ "chatcmpl-test-id",
757
+ "test-model",
758
+ conversation=[{"role": "user", "content": "Test"}],
759
+ tokenizer=MagicMock(),
760
+ request_metadata=RequestResponseMetadata(request_id="chatcmpl-test-id"),
761
+ )
762
+ assert response.metrics is None
763
+
764
+
765
+@pytest.mark.asyncio
766
+async def test_chat_streaming_metrics_ride_on_usage_chunk():
767
+ serving = _build_minimal_metrics_serving_chat(enable_per_request_metrics=True)
768
+ chunks = await _collect_metrics_stream_chunks(
769
+ serving,
770
+ ChatCompletionRequest(
771
+ model="test-model",
772
+ messages=[{"role": "user", "content": "Test prompt"}],
773
+ max_tokens=10,
774
+ stream=True,
775
+ stream_options={"include_usage": True},
776
+ ),
777
+ )
778
+
779
+ usage_chunks = [chunk for chunk in chunks if chunk.get("usage")]
780
+ assert usage_chunks
781
+ assert usage_chunks[-1]["metrics"]["time_to_first_token_ms"] == pytest.approx(500.0)
782
+
783
+
784
@dataclass
785
class MockEngine:
786
model_config: MockModelConfig = field(default_factory=MockModelConfig)
tests/entrypoints/openai/completion/test_completion_error.py
+97
-1
@@ -11,7 +11,10 @@ from pydantic import ValidationError
11
from vllm.config.multimodal import MultiModalConfig
12
from vllm.entrypoints.openai.completion.protocol import CompletionRequest
13
from vllm.entrypoints.openai.completion.serving import OpenAIServingCompletion
14
-from vllm.entrypoints.openai.engine.protocol import GenerationError
14
+from vllm.entrypoints.openai.engine.protocol import (
15
+ GenerationError,
16
+ RequestResponseMetadata,
17
+)
18
from vllm.entrypoints.openai.models.protocol import BaseModelPath
19
from vllm.entrypoints.openai.models.serving import OpenAIServingModels
20
from vllm.entrypoints.scale_out.render.serving import ServingRender
@@ -20,9 +23,17 @@ from vllm.renderers.hf import HfRenderer
23
from vllm.renderers.online_renderer import OnlineRenderer
24
from vllm.tokenizers.registry import cached_tokenizer_from_config
25
from vllm.v1.engine.async_llm import AsyncLLM
26
+from vllm.v1.metrics.stats import RequestStateStats
27
28
MODEL_NAME = "openai-community/gpt2"
29
MODEL_NAME_SHORT = "gpt2"
30
+_PER_REQUEST_STATS = RequestStateStats(
31
+ queued_ts=1.0,
32
+ scheduled_ts=1.5,
33
+ first_token_ts=2.0,
34
+ last_token_ts=3.0,
35
+ num_generation_tokens=2,
36
+)
37
BASE_MODEL_PATHS = [
38
BaseModelPath(name=MODEL_NAME, model_path=MODEL_NAME),
39
BaseModelPath(name=MODEL_NAME_SHORT, model_path=MODEL_NAME_SHORT),
@@ -93,6 +104,39 @@ def _build_serving_completion(engine: AsyncLLM) -> OpenAIServingCompletion:
104
)
105
106
107
+def _build_minimal_metrics_serving_completion(
108
+ enable_per_request_metrics: bool,
109
+) -> OpenAIServingCompletion:
110
+ serving = OpenAIServingCompletion.__new__(OpenAIServingCompletion)
111
+ serving.enable_prompt_tokens_details = False
112
+ serving.system_fingerprint = None
113
+ serving.enable_per_request_metrics = enable_per_request_metrics
114
+ return serving
115
+
116
+
117
+def _make_metrics_request_output(
118
+ metrics: RequestStateStats | None = _PER_REQUEST_STATS,
119
+) -> RequestOutput:
120
+ return RequestOutput(
121
+ request_id="test-id",
122
+ prompt="Test prompt",
123
+ prompt_token_ids=[1, 2, 3],
124
+ prompt_logprobs=None,
125
+ outputs=[
126
+ CompletionOutput(
127
+ index=0,
128
+ text="Hello",
129
+ token_ids=[100, 101],
130
+ cumulative_logprob=None,
131
+ logprobs=None,
132
+ finish_reason="stop",
133
+ )
134
+ ],
135
+ finished=True,
136
+ metrics=metrics,
137
+ )
138
+
139
+
140
def _build_renderer(model_config: MockModelConfig):
141
return HfRenderer(
142
MockVllmConfig(model_config, parallel_config=MockParallelConfig()),
@@ -100,6 +144,58 @@ def _build_renderer(model_config: MockModelConfig):
144
)
145
146
147
+def test_completion_per_request_metrics_follow_server_flag():
148
+ request = CompletionRequest(model=MODEL_NAME, prompt="Test prompt", max_tokens=10)
149
+ request_output = _make_metrics_request_output()
150
+
151
+ disabled_serving = _build_minimal_metrics_serving_completion(
152
+ enable_per_request_metrics=False
153
+ )
154
+ disabled_response = disabled_serving.request_output_to_completion_response(
155
+ [request_output],
156
+ request,
157
+ "cmpl-test-id",
158
+ 0,
159
+ MODEL_NAME,
160
+ None,
161
+ RequestResponseMetadata(request_id="cmpl-test-id"),
162
+ )
163
+ assert disabled_response.metrics is None
164
+
165
+ enabled_serving = _build_minimal_metrics_serving_completion(
166
+ enable_per_request_metrics=True
167
+ )
168
+ enabled_response = enabled_serving.request_output_to_completion_response(
169
+ [request_output],
170
+ request,
171
+ "cmpl-test-id",
172
+ 0,
173
+ MODEL_NAME,
174
+ None,
175
+ RequestResponseMetadata(request_id="cmpl-test-id"),
176
+ )
177
+ assert enabled_response.metrics is not None
178
+ assert enabled_response.metrics.time_to_first_token_ms == pytest.approx(500.0)
179
+
180
+
181
+def test_completion_per_request_metrics_suppressed_for_multiple_prompts():
182
+ serving = _build_minimal_metrics_serving_completion(enable_per_request_metrics=True)
183
+ response = serving.request_output_to_completion_response(
184
+ [_make_metrics_request_output(), _make_metrics_request_output()],
185
+ CompletionRequest(
186
+ model=MODEL_NAME,
187
+ prompt=["Test prompt", "Another prompt"],
188
+ max_tokens=10,
189
+ ),
190
+ "cmpl-test-id",
191
+ 0,
192
+ MODEL_NAME,
193
+ None,
194
+ RequestResponseMetadata(request_id="cmpl-test-id"),
195
+ )
196
+ assert response.metrics is None
197
+
198
+
199
@pytest.mark.asyncio
200
async def test_completion_error_non_stream():
201
"""test finish_reason='error' returns 500 InternalServerError (non-streaming)"""
tests/entrypoints/openai/test_cli_args.py
+8
@@ -206,6 +206,14 @@ def test_chat_template_validation_for_sad_paths(serve_parser):
206
validate_parsed_serve_args(args)
207
208
209
+def test_per_request_metrics_requires_log_stats(serve_parser):
210
+ args = serve_parser.parse_args(
211
+ args=["--enable-per-request-metrics", "--disable-log-stats"]
212
+ )
213
+ with pytest.raises(ValueError):
214
+ validate_parsed_serve_args(args)
215
+
216
+
217
@pytest.mark.parametrize(
218
"cli_args, expected_middleware",
219
[
vllm/entrypoints/generate/api_router.py
+2
@@ -130,6 +130,7 @@ async def init_generate_state(
130
enable_force_include_usage=args.enable_force_include_usage,
131
enable_log_outputs=args.enable_log_outputs,
132
enable_log_deltas=args.enable_log_deltas,
133
+ enable_per_request_metrics=args.enable_per_request_metrics,
134
)
135
state.openai_serving_chat = (
136
OpenAIServingChat(**_chat_kwargs) if "generate" in supported_tasks else None
@@ -150,6 +151,7 @@ async def init_generate_state(
151
return_tokens_as_token_ids=args.return_tokens_as_token_ids,
152
enable_prompt_tokens_details=args.enable_prompt_tokens_details,
153
enable_force_include_usage=args.enable_force_include_usage,
154
+ enable_per_request_metrics=args.enable_per_request_metrics,
155
)
156
if "generate" in supported_tasks
157
else None
vllm/entrypoints/generate/base/serving.py
+57
@@ -18,6 +18,7 @@ from vllm.entrypoints.openai.completion.protocol import CompletionRequest
18
from vllm.entrypoints.openai.engine.protocol import (
19
ErrorResponse,
20
GenerationError,
21
+ PerRequestTimingMetrics,
22
)
23
from vllm.entrypoints.openai.models.serving import OpenAIServingModels
24
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
@@ -34,6 +35,7 @@ from vllm.tracing import (
35
extract_trace_headers,
36
log_tracing_disabled_warning,
37
)
38
+from vllm.v1.metrics.stats import RequestStateStats
39
40
logger = init_logger(__name__)
41
@@ -41,6 +43,61 @@ RequestT = TypeVar("RequestT", bound=AnyRequest)
43
_T = TypeVar("_T")
44
45
46
+def build_per_request_timing_metrics(
47
+ metrics: RequestStateStats | None,
48
+ num_generation_tokens: int,
49
+) -> PerRequestTimingMetrics:
50
+ """Build per-request timing metrics from ``RequestStateStats``.
51
+
52
+ ``generation_time_ms`` is the decode interval only (first output token to
53
+ last output token); it excludes both queue wait and prefill/TTFT.
54
+ ``tokens_per_second`` is overall output throughput: all generated tokens
55
+ over the inference interval (scheduling to last output token), so it counts
56
+ the prefill/TTFT phase and is not simply the reciprocal of ``mean_itl_ms``.
57
+ Each field is left ``None`` when the timestamps it depends on are
58
+ unavailable.
59
+ """
60
+ if metrics is None:
61
+ return PerRequestTimingMetrics()
62
+
63
+ queued_ts = metrics.queued_ts
64
+ scheduled_ts = metrics.scheduled_ts
65
+ first_token_ts = metrics.first_token_ts
66
+ last_token_ts = metrics.last_token_ts
67
+
68
+ time_to_first_token_ms: float | None = None
69
+ generation_time_ms: float | None = None
70
+ queue_time_ms: float | None = None
71
+ mean_itl_ms: float | None = None
72
+ tokens_per_second: float | None = None
73
+
74
+ if scheduled_ts > 0 and first_token_ts > 0:
75
+ time_to_first_token_ms = (first_token_ts - scheduled_ts) * 1000
76
+
77
+ if first_token_ts > 0 and last_token_ts > 0:
78
+ generation_time_ms = (last_token_ts - first_token_ts) * 1000
79
+
80
+ if queued_ts > 0 and scheduled_ts > 0:
81
+ queue_time_ms = (scheduled_ts - queued_ts) * 1000
82
+
83
+ if first_token_ts > 0 and last_token_ts > 0 and num_generation_tokens > 1:
84
+ decode_time = last_token_ts - first_token_ts
85
+ mean_itl_ms = decode_time / (num_generation_tokens - 1) * 1000
86
+
87
+ if scheduled_ts > 0 and last_token_ts > 0:
88
+ inference_time_ms = (last_token_ts - scheduled_ts) * 1000
89
+ if inference_time_ms > 0:
90
+ tokens_per_second = num_generation_tokens / inference_time_ms * 1000
91
+
92
+ return PerRequestTimingMetrics(
93
+ time_to_first_token_ms=time_to_first_token_ms,
94
+ generation_time_ms=generation_time_ms,
95
+ queue_time_ms=queue_time_ms,
96
+ mean_itl_ms=mean_itl_ms,
97
+ tokens_per_second=tokens_per_second,
98
+ )
99
+
100
+
101
@dataclass(kw_only=True)
102
class ServeContext(Generic[RequestT]):
103
request: RequestT
vllm/entrypoints/openai/chat_completion/protocol.py
+3
@@ -26,6 +26,7 @@ from vllm.entrypoints.openai.engine.protocol import (
26
FunctionDefinition,
27
LegacyStructuralTagResponseFormat,
28
OpenAIBaseModel,
29
+ PerRequestTimingMetrics,
30
StreamOptions,
31
StructuralTagResponseFormat,
32
ToolCall,
@@ -133,6 +134,7 @@ class ChatCompletionResponse(OpenAIBaseModel):
134
kv_transfer_params: dict[str, Any] | None = Field(
135
default=None, description="KVTransfer parameters."
136
)
137
+ metrics: PerRequestTimingMetrics | None = None
138
139
140
class ChatCompletionResponseStreamChoice(OpenAIBaseModel):
@@ -160,6 +162,7 @@ class ChatCompletionStreamResponse(OpenAIBaseModel):
162
# Rendered prompt text from chat templating (only set when
163
# ``return_prompt_text=True`` on the request); only sent on the first chunk.
164
prompt_text: str | None = None
165
+ metrics: PerRequestTimingMetrics | None = None
166
167
168
class ChatCompletionToolsParam(OpenAIBaseModel):
vllm/entrypoints/openai/chat_completion/serving.py
+35
@@ -22,6 +22,7 @@ from vllm.entrypoints.chat_utils import (
22
from vllm.entrypoints.generate.base.serving import (
23
GenerateBaseServing,
24
GenerationError,
25
+ build_per_request_timing_metrics,
26
clamp_prompt_logprobs,
27
format_token_id_placeholder,
28
)
@@ -41,6 +42,7 @@ from vllm.entrypoints.openai.engine.protocol import (
42
DeltaMessage,
43
ErrorResponse,
44
FunctionCall,
45
+ PerRequestTimingMetrics,
46
PromptTokenUsageInfo,
47
RequestResponseMetadata,
48
ToolCall,
@@ -123,6 +125,7 @@ class OpenAIServingChat(GenerateBaseServing):
125
enable_log_outputs: bool = False,
126
enable_log_deltas: bool = True,
127
default_chat_template_kwargs: dict[str, Any] | None = None,
128
+ enable_per_request_metrics: bool = False,
129
) -> None:
130
super().__init__(
131
engine_client=engine_client,
@@ -161,6 +164,7 @@ class OpenAIServingChat(GenerateBaseServing):
164
165
self.enable_prompt_tokens_details = enable_prompt_tokens_details
166
self.enable_force_include_usage = enable_force_include_usage
167
+ self.enable_per_request_metrics = enable_per_request_metrics
168
self.default_sampling_params = self.model_config.get_diff_sampling_param()
169
mc = self.model_config
170
self.override_max_tokens = (
@@ -461,8 +465,10 @@ class OpenAIServingChat(GenerateBaseServing):
465
stream_options, self.enable_force_include_usage
466
)
467
468
+ last_res: RequestOutput | None = None
469
try:
470
async for res in result_generator:
471
+ last_res = res
472
if res.prompt_token_ids is not None:
473
num_prompt_tokens = len(res.prompt_token_ids)
474
if res.encoder_prompt_token_ids is not None:
@@ -752,6 +758,21 @@ class OpenAIServingChat(GenerateBaseServing):
758
mm_token_counts,
759
)
760
761
+ # In streaming, metrics ride on this final usage chunk, which is
762
+ # only emitted when usage reporting is enabled (i.e.
763
+ # ``stream_options.include_usage=true`` or
764
+ # ``--enable-force-include-usage``).
765
+ stream_per_request_metrics: PerRequestTimingMetrics | None = None
766
+ if (
767
+ self.enable_per_request_metrics
768
+ # See note in chat_completion_full_generator: suppress for n>1.
769
+ and (request.n or 1) == 1
770
+ ):
771
+ last_metrics = last_res.metrics if last_res is not None else None
772
+ stream_per_request_metrics = build_per_request_timing_metrics(
773
+ last_metrics, completion_tokens
774
+ )
775
+
776
final_usage_chunk = ChatCompletionStreamResponse(
777
id=request_id,
778
object=chunk_object_type,
@@ -760,6 +781,7 @@ class OpenAIServingChat(GenerateBaseServing):
781
model=model_name,
782
usage=final_usage,
783
system_fingerprint=self.system_fingerprint,
784
+ metrics=stream_per_request_metrics,
785
)
786
final_usage_data = final_usage_chunk.model_dump_json(
787
exclude_unset=True, exclude_none=True
@@ -1003,6 +1025,18 @@ class OpenAIServingChat(GenerateBaseServing):
1025
1026
request_metadata.final_usage_info = usage
1027
1028
+ per_request_metrics: PerRequestTimingMetrics | None = None
1029
+ if (
1030
+ self.enable_per_request_metrics
1031
+ # Timing metrics describe a single generation stream. For n>1 the
1032
+ # returned stats belong to only one of the n sequences, so they
1033
+ # cannot be accurately attributed to the request; suppress instead.
1034
+ and (request.n or 1) == 1
1035
+ ):
1036
+ per_request_metrics = build_per_request_timing_metrics(
1037
+ final_res.metrics, num_generated_tokens
1038
+ )
1039
+
1040
# ``final_res.prompt`` is the rendered chat-templated prompt text
1041
prompt_text = final_res.prompt if request.return_prompt_text else None
1042
@@ -1019,6 +1053,7 @@ class OpenAIServingChat(GenerateBaseServing):
1053
),
1054
prompt_text=prompt_text,
1055
kv_transfer_params=final_res.kv_transfer_params,
1056
+ metrics=per_request_metrics,
1057
)
1058
1059
# Log complete response if output logging is enabled
vllm/entrypoints/openai/cli_args.py
+10
-3
@@ -25,12 +25,9 @@ from vllm.entrypoints.serve.utils.constants import (
25
H11_MAX_HEADER_COUNT_DEFAULT,
26
H11_MAX_INCOMPLETE_EVENT_SIZE_DEFAULT,
27
)
28
-from vllm.logger import init_logger
28
from vllm.tool_parsers import ToolParserManager
29
from vllm.utils.argparse_utils import FlexibleArgumentParser
30
32
-logger = init_logger(__name__)
33
-
31
32
class LoRAParserAction(argparse.Action):
33
def __call__(
@@ -134,6 +131,8 @@ class BaseFrontendArgs:
131
log. The default of None means unlimited."""
132
enable_prompt_tokens_details: bool = False
133
"""If set to True, enable prompt_tokens_details in usage."""
134
+ enable_per_request_metrics: bool = False
135
+ """If set to True, include per-request timing metrics in API responses."""
136
enable_server_load_tracking: bool = False
137
"""If set to True, enable tracking server_load_metrics in the app state."""
138
enable_force_include_usage: bool = False
@@ -398,6 +397,14 @@ def validate_parsed_serve_args(args: argparse.Namespace):
397
if args.enable_log_outputs and not args.enable_log_requests:
398
raise TypeError("Error: --enable-log-outputs requires --enable-log-requests")
399
400
+ if getattr(args, "enable_per_request_metrics", False) and getattr(
401
+ args, "disable_log_stats", False
402
+ ):
403
+ raise ValueError(
404
+ "Error: --enable-per-request-metrics requires engine statistics "
405
+ "logging; remove --disable-log-stats to enable per-request metrics."
406
+ )
407
+
408
if args.data_parallel_multi_port_external_lb:
409
from vllm.entrypoints.openai.dp_supervisor import (
410
validate_multi_port_external_lb_args,
vllm/entrypoints/openai/completion/protocol.py
+3
@@ -15,6 +15,7 @@ from vllm.entrypoints.openai.engine.protocol import (
15
AnyResponseFormat,
16
LegacyStructuralTagResponseFormat,
17
OpenAIBaseModel,
18
+ PerRequestTimingMetrics,
19
StreamOptions,
20
StructuralTagResponseFormat,
21
UsageInfo,
@@ -558,6 +559,7 @@ class CompletionResponse(OpenAIBaseModel):
559
kv_transfer_params: dict[str, Any] | None = Field(
560
default=None, description="KVTransfer parameters."
561
)
562
+ metrics: PerRequestTimingMetrics | None = None
563
564
565
class CompletionResponseStreamChoice(OpenAIBaseModel):
@@ -589,3 +591,4 @@ class CompletionStreamResponse(OpenAIBaseModel):
591
# Set only on the final chunk of a stream to mirror non-streaming responses
592
# without the per-chunk serialization overhead.
593
system_fingerprint: str | None = None
594
+ metrics: PerRequestTimingMetrics | None = None
vllm/entrypoints/openai/completion/serving.py
+42
@@ -16,6 +16,7 @@ from vllm.engine.protocol import EngineClient
16
from vllm.entrypoints.generate.base.serving import (
17
GenerateBaseServing,
18
GenerationError,
19
+ build_per_request_timing_metrics,
20
clamp_prompt_logprobs,
21
format_token_id_placeholder,
22
)
@@ -29,6 +30,7 @@ from vllm.entrypoints.openai.completion.protocol import (
30
)
31
from vllm.entrypoints.openai.engine.protocol import (
32
ErrorResponse,
33
+ PerRequestTimingMetrics,
34
PromptTokenUsageInfo,
35
RequestResponseMetadata,
36
UsageInfo,
@@ -61,6 +63,7 @@ class OpenAIServingCompletion(GenerateBaseServing):
63
return_tokens_as_token_ids: bool = False,
64
enable_prompt_tokens_details: bool = False,
65
enable_force_include_usage: bool = False,
66
+ enable_per_request_metrics: bool = False,
67
):
68
super().__init__(
69
engine_client=engine_client,
@@ -72,6 +75,7 @@ class OpenAIServingCompletion(GenerateBaseServing):
75
self.online_renderer = online_renderer
76
self.enable_prompt_tokens_details = enable_prompt_tokens_details
77
self.enable_force_include_usage = enable_force_include_usage
78
+ self.enable_per_request_metrics = enable_per_request_metrics
79
80
self.default_sampling_params = self.model_config.get_diff_sampling_param()
81
mc = self.model_config
@@ -300,8 +304,10 @@ class OpenAIServingCompletion(GenerateBaseServing):
304
stream_options, self.enable_force_include_usage
305
)
306
307
+ last_res: RequestOutput | None = None
308
try:
309
async for prompt_idx, res in result_generator:
310
+ last_res = res
311
prompt_token_ids = res.prompt_token_ids
312
prompt_logprobs = res.prompt_logprobs
313
@@ -448,6 +454,23 @@ class OpenAIServingCompletion(GenerateBaseServing):
454
)
455
456
if include_usage:
457
+ # In streaming, metrics ride on this final usage chunk, which is
458
+ # only emitted when usage reporting is enabled (i.e.
459
+ # ``stream_options.include_usage=true`` or
460
+ # ``--enable-force-include-usage``).
461
+ stream_per_request_metrics: PerRequestTimingMetrics | None = None
462
+ if (
463
+ self.enable_per_request_metrics
464
+ # See note in request_output_to_completion_response: suppress
465
+ # when not attributable to one stream (multi-prompt or n>1).
466
+ and num_prompts == 1
467
+ and (request.n or 1) == 1
468
+ ):
469
+ last_metrics = last_res.metrics if last_res is not None else None
470
+ stream_per_request_metrics = build_per_request_timing_metrics(
471
+ last_metrics, total_completion_tokens
472
+ )
473
+
474
final_usage_chunk = CompletionStreamResponse(
475
id=request_id,
476
created=created_time,
@@ -455,6 +478,7 @@ class OpenAIServingCompletion(GenerateBaseServing):
478
choices=[],
479
usage=final_usage_info,
480
system_fingerprint=self.system_fingerprint,
481
+ metrics=stream_per_request_metrics,
482
)
483
final_usage_data = final_usage_chunk.model_dump_json(
484
exclude_unset=False, exclude_none=True
@@ -589,6 +613,23 @@ class OpenAIServingCompletion(GenerateBaseServing):
613
)
614
615
request_metadata.final_usage_info = usage
616
+
617
+ per_request_metrics: PerRequestTimingMetrics | None = None
618
+ if (
619
+ self.enable_per_request_metrics
620
+ # Metrics describe a single generation stream, so suppress them when
621
+ # they cannot be attributed to one: multiple prompts (timestamps
622
+ # span prompts) or n>1 (stats belong to one of the n sequences).
623
+ and len(final_res_batch) == 1
624
+ and (request.n or 1) == 1
625
+ ):
626
+ last_metrics = (
627
+ last_final_res.metrics if last_final_res is not None else None
628
+ )
629
+ per_request_metrics = build_per_request_timing_metrics(
630
+ last_metrics, num_generated_tokens
631
+ )
632
+
633
if final_res_batch:
634
kv_transfer_params = final_res_batch[0].kv_transfer_params
635
return CompletionResponse(
@@ -599,6 +640,7 @@ class OpenAIServingCompletion(GenerateBaseServing):
640
usage=usage,
641
system_fingerprint=self.system_fingerprint,
642
kv_transfer_params=kv_transfer_params,
643
+ metrics=per_request_metrics,
644
)
645
646
def _create_completion_logprobs(
vllm/entrypoints/openai/engine/protocol.py
+8
@@ -115,6 +115,14 @@ class UsageInfo(OpenAIBaseModel):
115
prompt_tokens_details: PromptTokenUsageInfo | None = None
116
117
118
+class PerRequestTimingMetrics(OpenAIBaseModel):
119
+ time_to_first_token_ms: float | None = None
120
+ generation_time_ms: float | None = None
121
+ queue_time_ms: float | None = None
122
+ mean_itl_ms: float | None = None
123
+ tokens_per_second: float | None = None
124
+
125
+
126
class RequestResponseMetadata(BaseModel):
127
request_id: str
128
final_usage_info: UsageInfo | None = None