[Front-end] [Messages] Populate `num_cache_creation_tokens` (#48535)
Signed-off-by: Yifan Zong <yzong@redhat.com>
yzong-rh committed
Jul 18, 2026 at 13:04 UTC
a287eb163fb6f8f007a4a78411fb54c8dde64cc7
15 files changed
+201
-127
rust/src/engine-core-client/src/protocol/stats.rs
+3
@@ -103,6 +103,9 @@ pub struct PrefillStats {
103
/// Tokens to be prefilled from external KV transfer.
104
#[serde(default)]
105
pub num_external_cached_tokens: u32,
106
+ /// Prompt tokens newly admitted into the local prefix cache.
107
+ #[serde(default)]
108
+ pub num_cache_creation_tokens: u32,
109
}
110
111
/// Stats for debugging the metrics calculation.
rust/src/llm/src/request_metrics.rs
+1
@@ -375,6 +375,7 @@ mod tests {
375
num_cached_tokens: 4,
376
num_local_cached_tokens: 4,
377
num_external_cached_tokens: 0,
378
+ ..Default::default()
379
}),
380
..Default::default()
381
},
tests/entrypoints/anthropic/test_anthropic_messages_conversion.py
+25
-60
@@ -23,7 +23,6 @@ from vllm.entrypoints.anthropic.protocol import (
23
from vllm.entrypoints.anthropic.serving import (
24
AnthropicServingMessages,
25
_build_anthropic_usage,
26
- _get_cached_tokens,
26
)
27
from vllm.entrypoints.openai.chat_completion.protocol import (
28
ChatCompletionResponse,
@@ -668,42 +667,6 @@ class TestThinkingBlockConversion:
667
# ======================================================================
668
669
671
-class TestGetCachedTokens:
672
- """Tests for _get_cached_tokens helper."""
673
-
674
- def test_none_usage(self):
675
- assert _get_cached_tokens(None) is None
676
-
677
- def test_no_prompt_tokens_details(self):
678
- usage = UsageInfo(prompt_tokens=100, completion_tokens=10)
679
- assert _get_cached_tokens(usage) is None
680
-
681
- def test_cached_tokens_present(self):
682
- usage = UsageInfo(
683
- prompt_tokens=100,
684
- completion_tokens=10,
685
- prompt_tokens_details=PromptTokenUsageInfo(cached_tokens=80),
686
- )
687
- assert _get_cached_tokens(usage) == 80
688
-
689
- def test_cached_tokens_zero(self):
690
- """Zero cached tokens should return 0, not None."""
691
- usage = UsageInfo(
692
- prompt_tokens=100,
693
- completion_tokens=10,
694
- prompt_tokens_details=PromptTokenUsageInfo(cached_tokens=0),
695
- )
696
- assert _get_cached_tokens(usage) == 0
697
-
698
- def test_cached_tokens_none_in_details(self):
699
- usage = UsageInfo(
700
- prompt_tokens=100,
701
- completion_tokens=10,
702
- prompt_tokens_details=PromptTokenUsageInfo(cached_tokens=None),
703
- )
704
- assert _get_cached_tokens(usage) is None
705
-
706
-
670
class TestBuildAnthropicUsage:
671
"""Tests for _build_anthropic_usage helper.
672
@@ -711,36 +674,32 @@ class TestBuildAnthropicUsage:
674
vLLM's prompt_tokens is the total.
675
"""
676
714
- def test_no_cache_info(self):
715
- """When cache info is unavailable, return raw prompt_tokens."""
716
- result = _build_anthropic_usage(100, 10, None)
717
- assert result.input_tokens == 100
718
- assert result.output_tokens == 10
719
- assert result.cache_read_input_tokens is None
720
- assert result.cache_creation_input_tokens is None
721
-
677
def test_cache_hit(self):
678
"""When cache is hit, input_tokens excludes cached tokens."""
679
usage = UsageInfo(
680
prompt_tokens=100,
681
completion_tokens=10,
727
- prompt_tokens_details=PromptTokenUsageInfo(cached_tokens=80),
682
+ prompt_tokens_details=PromptTokenUsageInfo(
683
+ cached_tokens=80, created_cache_tokens=10
684
+ ),
685
)
729
- result = _build_anthropic_usage(100, 10, usage)
730
- assert result.input_tokens == 20 # 100 - 80
686
+ result = _build_anthropic_usage(usage)
687
+ assert result.input_tokens == 10 # 100 - 80 - 10
688
assert result.output_tokens == 10
689
assert result.cache_read_input_tokens == 80
733
- assert result.cache_creation_input_tokens == 0
690
+ assert result.cache_creation_input_tokens == 10
691
692
def test_zero_cached_tokens(self):
693
"""Zero cached tokens should still set cache_creation to 0."""
694
usage = UsageInfo(
695
prompt_tokens=100,
696
completion_tokens=10,
740
- prompt_tokens_details=PromptTokenUsageInfo(cached_tokens=0),
697
+ prompt_tokens_details=PromptTokenUsageInfo(
698
+ cached_tokens=0, created_cache_tokens=0
699
+ ),
700
)
742
- result = _build_anthropic_usage(100, 10, usage)
743
- assert result.input_tokens == 100 # 100 - 0
701
+ result = _build_anthropic_usage(usage)
702
+ assert result.input_tokens == 100 # 100 - 0 - 0
703
assert result.cache_read_input_tokens == 0
704
assert result.cache_creation_input_tokens == 0
705
@@ -749,9 +708,11 @@ class TestBuildAnthropicUsage:
708
usage = UsageInfo(
709
prompt_tokens=100,
710
completion_tokens=10,
752
- prompt_tokens_details=PromptTokenUsageInfo(cached_tokens=100),
711
+ prompt_tokens_details=PromptTokenUsageInfo(
712
+ cached_tokens=100, created_cache_tokens=0
713
+ ),
714
)
754
- result = _build_anthropic_usage(100, 10, usage)
715
+ result = _build_anthropic_usage(usage)
716
assert result.input_tokens == 0
717
assert result.cache_read_input_tokens == 100
718
assert result.cache_creation_input_tokens == 0
@@ -759,7 +720,7 @@ class TestBuildAnthropicUsage:
720
def test_no_prompt_tokens_details(self):
721
"""UsageInfo without prompt_tokens_details returns no cache info."""
722
usage = UsageInfo(prompt_tokens=100, completion_tokens=10)
762
- result = _build_anthropic_usage(100, 10, usage)
723
+ result = _build_anthropic_usage(usage)
724
assert result.input_tokens == 100
725
assert result.cache_read_input_tokens is None
726
assert result.cache_creation_input_tokens is None
@@ -1241,7 +1202,9 @@ class TestStreamingCacheUsageSemantics:
1202
prompt_tokens=100,
1203
completion_tokens=5,
1204
total_tokens=105,
1244
- prompt_tokens_details=PromptTokenUsageInfo(cached_tokens=80),
1205
+ prompt_tokens_details=PromptTokenUsageInfo(
1206
+ cached_tokens=80, created_cache_tokens=10
1207
+ ),
1208
),
1209
)
1210
yield "data: [DONE]"
@@ -1263,9 +1226,9 @@ class TestStreamingCacheUsageSemantics:
1226
delta_usage = next(
1227
data["usage"] for ev, data in events if ev == "message_delta"
1228
)
1266
- assert delta_usage["input_tokens"] == 20 # 100 - 80
1229
+ assert delta_usage["input_tokens"] == 10 # 100 - 80 - 10
1230
assert delta_usage["cache_read_input_tokens"] == 80
1268
- assert delta_usage["cache_creation_input_tokens"] == 0
1231
+ assert delta_usage["cache_creation_input_tokens"] == 10
1232
1233
@pytest.mark.asyncio
1234
async def test_streaming_no_cache_hit(self):
@@ -1284,7 +1247,9 @@ class TestStreamingCacheUsageSemantics:
1247
prompt_tokens=50,
1248
completion_tokens=5,
1249
total_tokens=55,
1287
- prompt_tokens_details=PromptTokenUsageInfo(cached_tokens=0),
1250
+ prompt_tokens_details=PromptTokenUsageInfo(
1251
+ cached_tokens=0, created_cache_tokens=0
1252
+ ),
1253
),
1254
)
1255
yield "data: [DONE]"
@@ -1302,7 +1267,7 @@ class TestStreamingCacheUsageSemantics:
1267
assert start_usage["input_tokens"] == 50
1268
assert "cache_read_input_tokens" not in start_usage
1269
assert "cache_creation_input_tokens" not in start_usage
1305
- assert delta_usage["input_tokens"] == 50 # 50 - 0
1270
+ assert delta_usage["input_tokens"] == 50 # 50 - 0 - 0
1271
assert delta_usage["cache_read_input_tokens"] == 0
1272
assert delta_usage["cache_creation_input_tokens"] == 0
1273
tests/entrypoints/anthropic/test_messages.py
+52
@@ -18,6 +18,7 @@ def server():
18
"--max-model-len",
19
"2048",
20
"--enforce-eager",
21
+ "--enable-prompt-tokens-details",
22
"--enable-auto-tool-choice",
23
"--tool-call-parser",
24
"hermes",
@@ -191,3 +192,54 @@ async def test_anthropic_structured_output(client: anthropic.AsyncAnthropic):
192
json_obj = json.loads(response.content[0].text)
193
for key in ["name", "email", "plan_interest", "demo_requested"]:
194
assert key in json_obj, f"Missing key in output: {key}"
195
+
196
+
197
+@pytest.mark.asyncio
198
+async def test_anthropic_streaming_cache_usage(client: anthropic.AsyncAnthropic):
199
+ async def get_stream_usage(resp):
200
+ prompt_tokens = None
201
+ usage = None
202
+ async for chunk in resp:
203
+ if (
204
+ chunk.type == "message_start"
205
+ and chunk.message is not None
206
+ and chunk.message.usage is not None
207
+ ):
208
+ prompt_tokens = chunk.message.usage.input_tokens
209
+ elif chunk.type == "message_delta" and chunk.usage is not None:
210
+ usage = chunk.usage
211
+
212
+ assert usage is not None
213
+ assert usage.input_tokens >= 0
214
+ assert usage.output_tokens >= 0
215
+ cache_created = usage.cache_creation_input_tokens
216
+ cache_read = usage.cache_read_input_tokens
217
+ assert cache_read is not None
218
+ assert cache_created is not None
219
+ assert cache_created >= 0
220
+ assert cache_read >= 0
221
+ assert prompt_tokens == usage.input_tokens + cache_created + cache_read
222
+ return usage
223
+
224
+ request = dict(
225
+ model="claude-3-7-sonnet-latest",
226
+ max_tokens=1,
227
+ temperature=0.0,
228
+ messages=[
229
+ {
230
+ "role": "user",
231
+ "content": "Cache coverage sentinel. " * 256
232
+ + "Answer with exactly one word: ok.",
233
+ }
234
+ ],
235
+ stream=True,
236
+ )
237
+
238
+ cold_usage = await get_stream_usage(await client.messages.create(**request))
239
+ assert cold_usage.cache_read_input_tokens == 0
240
+ assert cold_usage.cache_creation_input_tokens is not None
241
+ assert cold_usage.cache_creation_input_tokens > 0
242
+
243
+ warm_usage = await get_stream_usage(await client.messages.create(**request))
244
+ assert warm_usage.cache_read_input_tokens is not None
245
+ assert warm_usage.cache_read_input_tokens > 0
tests/entrypoints/openai/chat_completion/test_serving_chat.py
+12
-5
@@ -831,16 +831,23 @@ def test_mm_prompt_tokens_details():
831
assert counts == {"image": 600, "video": 1200}
832
833
# Gated off, or nothing to report -> no details.
834
- assert _make_prompt_tokens_details(False, 5, counts) is None
835
- assert _make_prompt_tokens_details(True, None, None) is None
834
+ assert _make_prompt_tokens_details(False, 5, 0, counts) is None
835
+ assert _make_prompt_tokens_details(True, None, None, None) is None
836
837
# Zero cached_tokens is still reported (not None), matching the cached-only
838
# behavior; multimodal counts ride alongside even when cached_tokens is None.
839
- assert _make_prompt_tokens_details(True, 0, None).cached_tokens == 0
840
- details = _make_prompt_tokens_details(True, None, counts)
839
+ details = _make_prompt_tokens_details(True, 0, 0, None)
840
+ assert details.cached_tokens == 0
841
+ assert details.created_cache_tokens == 0
842
+ assert details.multimodal_tokens is None
843
+ details = _make_prompt_tokens_details(True, None, None, counts)
844
assert details.cached_tokens is None
845
+ assert details.created_cache_tokens is None
846
+ assert details.multimodal_tokens == {"image": 600, "video": 1200}
847
+ details = _make_prompt_tokens_details(True, 3, 0, counts)
848
+ assert details.cached_tokens == 3
849
+ assert details.created_cache_tokens == 0
850
assert details.multimodal_tokens == {"image": 600, "video": 1200}
843
- assert _make_prompt_tokens_details(True, 3, counts).cached_tokens == 3
851
852
853
@pytest.mark.asyncio
tests/v1/core/test_async_scheduler.py
+1
@@ -280,6 +280,7 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
280
scheduler.waiting = Mock()
281
scheduler.kv_cache_manager = Mock()
282
scheduler.kv_cache_manager.take_events.return_value = None
283
+ scheduler.kv_cache_manager.estimate_cached_tokens.return_value = 0
284
scheduler.kv_event_publisher = Mock()
285
scheduler.finished_req_ids = set()
286
scheduler.finished_req_ids_dict = None
tests/v1/core/test_scheduler.py
+1
@@ -3036,6 +3036,7 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
3036
scheduler.waiting = Mock()
3037
scheduler.kv_cache_manager = Mock()
3038
scheduler.kv_cache_manager.take_events.return_value = None
3039
+ scheduler.kv_cache_manager.estimate_cached_tokens.return_value = 0
3040
scheduler.kv_event_publisher = Mock()
3041
scheduler.finished_req_ids = set()
3042
scheduler.finished_req_ids_dict = None
vllm/entrypoints/anthropic/serving.py
+29
-51
@@ -53,47 +53,43 @@ from vllm.renderers.online_renderer import OnlineRenderer
53
logger = logging.getLogger(__name__)
54
55
56
-def _get_cached_tokens(usage: UsageInfo | None) -> int | None:
57
- """Extract cached token count from OpenAI UsageInfo."""
58
- if usage is None or usage.prompt_tokens_details is None:
59
- return None
60
- return usage.prompt_tokens_details.cached_tokens
61
-
62
-
56
def _build_anthropic_usage(
64
- prompt_tokens: int,
65
- completion_tokens: int | None,
57
usage: UsageInfo | None,
58
) -> AnthropicUsage:
68
- """Build an AnthropicUsage from OpenAI-style token counts.
59
+ """Build an AnthropicUsage from UsageInfo.
60
61
Anthropic defines ``total_input == input_tokens + cache_read +
62
cache_creation``. vLLM's ``prompt_tokens`` is the total, so
72
- ``input_tokens = prompt_tokens - cached_tokens``.
63
+ ``input_tokens = prompt_tokens - cache_read - cache_creation``.
64
74
- OpenAI usage only exposes ``cached_tokens`` (hits); there is no
75
- cache-creation analog, so ``cache_creation_input_tokens`` is ``0``
76
- when cache info is present. When cache info is absent (e.g.
77
- ``--enable-prompt-tokens-details`` off, or a streaming chunk that
78
- hasn't carried it yet), cache fields are left **unset** so
79
- ``exclude_unset=True`` serialization omits them entirely.
65
+ Cache fields are taken from ``UsageInfo.prompt_tokens_details``.
66
+ When cache info is absent (e.g. ``--enable-prompt-tokens-details``
67
+ off, or a streaming chunk that hasn't carried it yet), cache fields
68
+ are left **unset** so ``exclude_unset=True`` serialization omits them
69
+ entirely.
70
71
``completion_tokens`` follows ``UsageInfo`` and may be ``None`` on
72
intermediate stream chunks; we coerce to ``0`` for the wire format.
73
"""
84
- output_tokens = completion_tokens or 0
85
- cached = _get_cached_tokens(usage)
86
- if cached is not None:
87
- return AnthropicUsage(
88
- input_tokens=prompt_tokens - cached,
89
- output_tokens=output_tokens,
90
- cache_read_input_tokens=cached,
91
- cache_creation_input_tokens=0,
92
- )
93
- return AnthropicUsage(
94
- input_tokens=prompt_tokens,
95
- output_tokens=output_tokens,
96
- )
74
+ kwargs = {}
75
+ if usage is None:
76
+ kwargs["input_tokens"] = 0
77
+ kwargs["output_tokens"] = 0
78
+ else:
79
+ kwargs["output_tokens"] = usage.completion_tokens or 0
80
+ input_tokens = usage.prompt_tokens
81
+
82
+ if (details := usage.prompt_tokens_details) is not None:
83
+ if (cache_read := details.cached_tokens) is not None:
84
+ input_tokens -= cache_read
85
+ kwargs["cache_read_input_tokens"] = cache_read
86
+
87
+ if (cache_creation := details.created_cache_tokens) is not None:
88
+ input_tokens -= cache_creation
89
+ kwargs["cache_creation_input_tokens"] = cache_creation
90
+
91
+ kwargs["input_tokens"] = max(0, input_tokens)
92
+ return AnthropicUsage(**kwargs)
93
94
95
def wrap_data_with_event(data: str, event: str):
@@ -625,11 +621,7 @@ class AnthropicServingMessages(OpenAIServingChat):
621
id=generator.id,
622
content=[],
623
model=generator.model,
628
- usage=_build_anthropic_usage(
629
- generator.usage.prompt_tokens,
630
- generator.usage.completion_tokens,
631
- generator.usage,
632
- ),
624
+ usage=_build_anthropic_usage(generator.usage),
625
kv_transfer_params=generator.kv_transfer_params,
626
ec_transfer_params=generator.ec_transfer_params,
627
)
@@ -816,13 +808,7 @@ class AnthropicServingMessages(OpenAIServingChat):
808
model=origin_chunk.model,
809
stop_reason=None,
810
stop_sequence=None,
819
- usage=_build_anthropic_usage(
820
- origin_chunk.usage.prompt_tokens
821
- if origin_chunk.usage
822
- else 0,
823
- 0,
824
- origin_chunk.usage,
825
- ),
811
+ usage=_build_anthropic_usage(origin_chunk.usage),
812
),
813
)
814
first_item = False
@@ -840,15 +826,7 @@ class AnthropicServingMessages(OpenAIServingChat):
826
chunk = AnthropicStreamEvent(
827
type="message_delta",
828
delta=AnthropicDelta(stop_reason=stop_reason),
843
- usage=_build_anthropic_usage(
844
- origin_chunk.usage.prompt_tokens
845
- if origin_chunk.usage
846
- else 0,
847
- origin_chunk.usage.completion_tokens
848
- if origin_chunk.usage
849
- else 0,
850
- origin_chunk.usage,
851
- ),
829
+ usage=_build_anthropic_usage(origin_chunk.usage),
830
)
831
data = chunk.model_dump_json(exclude_unset=True)
832
yield wrap_data_with_event(data, "message_delta")
vllm/entrypoints/openai/chat_completion/serving.py
+11
-1
@@ -90,15 +90,21 @@ def _get_mm_token_counts(engine_input: EngineInput) -> dict[str, int]:
90
def _make_prompt_tokens_details(
91
enable_prompt_tokens_details: bool,
92
num_cached_tokens: int | None,
93
+ num_cache_creation_tokens: int | None,
94
mm_token_counts: dict[str, int] | None,
95
) -> PromptTokenUsageInfo | None:
96
"""Build ``prompt_tokens_details`` from cached + multimodal token counts."""
97
if not enable_prompt_tokens_details:
98
return None
98
- if num_cached_tokens is None and not mm_token_counts:
99
+ if (
100
+ num_cached_tokens is None
101
+ and num_cache_creation_tokens is None
102
+ and not mm_token_counts
103
+ ):
104
return None
105
return PromptTokenUsageInfo(
106
cached_tokens=num_cached_tokens,
107
+ created_cache_tokens=num_cache_creation_tokens,
108
multimodal_tokens=mm_token_counts or None,
109
)
110
@@ -427,6 +433,7 @@ class OpenAIServingChat(GenerateBaseServing):
433
finish_reason_sent = [False] * num_choices
434
num_prompt_tokens = 0
435
num_cached_tokens = None
436
+ num_cache_creation_tokens = None
437
tools_streamed = [False] * num_choices
438
439
if isinstance(request.tool_choice, ChatCompletionNamedToolChoiceParam):
@@ -479,6 +486,7 @@ class OpenAIServingChat(GenerateBaseServing):
486
# response (by the try...catch).
487
if first_iteration:
488
num_cached_tokens = res.num_cached_tokens
489
+ num_cache_creation_tokens = res.num_cache_creation_tokens
490
# Send first response for each request.n (index) with
491
# the role
492
role = self.get_chat_request_role(request)
@@ -756,6 +764,7 @@ class OpenAIServingChat(GenerateBaseServing):
764
final_usage.prompt_tokens_details = _make_prompt_tokens_details(
765
self.enable_prompt_tokens_details,
766
num_cached_tokens,
767
+ num_cache_creation_tokens,
768
mm_token_counts,
769
)
770
@@ -1030,6 +1039,7 @@ class OpenAIServingChat(GenerateBaseServing):
1039
usage.prompt_tokens_details = _make_prompt_tokens_details(
1040
self.enable_prompt_tokens_details,
1041
final_res.num_cached_tokens,
1042
+ final_res.num_cache_creation_tokens,
1043
mm_token_counts,
1044
)
1045
vllm/entrypoints/openai/engine/protocol.py
+1
@@ -101,6 +101,7 @@ class ModelList(OpenAIBaseModel):
101
102
class PromptTokenUsageInfo(OpenAIBaseModel):
103
cached_tokens: int | None = None
104
+ created_cache_tokens: int | None = None
105
multimodal_tokens: dict[str, int] | None = None
106
"""Prompt tokens contributed by each input modality, keyed by modality name
107
(e.g. `image`, `audio`, `video`). A breakdown of the multimodal
vllm/outputs.py
+6
-1
@@ -103,6 +103,8 @@ class RequestOutput:
103
encoder_prompt_token_ids: The token IDs of the encoder prompt.
104
None if decoder-only.
105
num_cached_tokens: The number of tokens with prefix cache hit.
106
+ num_cache_creation_tokens: Prompt tokens currently counted as local
107
+ prefix-cache writes for this request.
108
kv_transfer_params: The params for remote K/V transfer.
109
ec_transfer_params: The params for remote encoder-cache transfer.
110
"""
@@ -120,6 +122,7 @@ class RequestOutput:
122
encoder_prompt: str | None = None,
123
encoder_prompt_token_ids: list[int] | None = None,
124
num_cached_tokens: int | None = None,
125
+ num_cache_creation_tokens: int | None = None,
126
*,
127
kv_transfer_params: dict[str, Any] | None = None,
128
ec_transfer_params: dict[str, Any] | None = None,
@@ -142,6 +145,7 @@ class RequestOutput:
145
self.encoder_prompt = encoder_prompt
146
self.encoder_prompt_token_ids = encoder_prompt_token_ids
147
self.num_cached_tokens = num_cached_tokens
148
+ self.num_cache_creation_tokens = num_cache_creation_tokens
149
self.kv_transfer_params = kv_transfer_params
150
self.ec_transfer_params = ec_transfer_params
151
@@ -188,7 +192,8 @@ class RequestOutput:
192
f"finished={self.finished}, "
193
f"metrics={self.metrics}, "
194
f"lora_request={self.lora_request}, "
191
- f"num_cached_tokens={self.num_cached_tokens})"
195
+ f"num_cached_tokens={self.num_cached_tokens}, "
196
+ f"num_cache_creation_tokens={self.num_cache_creation_tokens})"
197
)
198
199
vllm/v1/core/kv_cache_manager.py
+29
@@ -657,6 +657,35 @@ class KVCacheManager:
657
clipped_block_ids.append(ids[:num_valid_blocks])
658
return tuple(clipped_block_ids)
659
660
+ def estimate_cached_tokens(self, request: Request) -> int:
661
+ """Estimate the number of tokens cached by the request."""
662
+ cached_tokens: int | None = None
663
+ for group, blocks in zip(
664
+ self.kv_cache_config.kv_cache_groups,
665
+ self.get_blocks(request.request_id).blocks,
666
+ ):
667
+ if isinstance(
668
+ group.kv_cache_spec,
669
+ (CrossAttentionSpec, EncoderOnlyAttentionSpec),
670
+ ):
671
+ # Cross-attention and encoder-only groups are not prefix cached.
672
+ continue
673
+
674
+ group_cached_tokens = 0
675
+ for block in blocks:
676
+ group_cached_tokens = max(
677
+ group_cached_tokens,
678
+ block.block_hash_num_tokens or 0,
679
+ )
680
+
681
+ cached_tokens = (
682
+ group_cached_tokens
683
+ if cached_tokens is None
684
+ else min(cached_tokens, group_cached_tokens)
685
+ )
686
+
687
+ return cached_tokens or 0
688
+
689
def cache_blocks(self, request: Request, num_computed_tokens: int) -> None:
690
"""Cache the blocks for the request, if enabled.
691
vllm/v1/core/sched/scheduler.py
+17
-9
@@ -799,7 +799,10 @@ class Scheduler(SchedulerInterface):
799
continue
800
801
# Track first scheduled prefill, not post-preemption repeat prefills
802
- if request.prefill_stats is not None:
802
+ if (
803
+ request.prefill_stats is not None
804
+ and request.num_preemptions <= 0
805
+ ):
806
assert num_computed_tokens <= request.num_prompt_tokens
807
request.prefill_stats.set(
808
num_prompt_tokens=request.num_prompt_tokens,
@@ -1708,6 +1711,7 @@ class Scheduler(SchedulerInterface):
1711
pooler_output = pooler_outputs[req_index] if pooler_outputs else None
1712
kv_transfer_params = None
1713
ec_transfer_params = None
1714
+ prefill_stats = None
1715
status_before_stop = request.status
1716
num_output_tokens_before = len(request._output_token_ids)
1717
@@ -1787,6 +1791,16 @@ class Scheduler(SchedulerInterface):
1791
# Normal decode / re-prefill: token(s) at the END.
1792
routed_experts = routing_data[end - len(new_token_ids) : end]
1793
1794
+ should_emit_output = bool(
1795
+ new_token_ids or pooler_output is not None or stopped
1796
+ )
1797
+ if should_emit_output:
1798
+ prefill_stats = request.take_prefill_stats()
1799
+ if prefill_stats is not None:
1800
+ prefill_stats.finalize(
1801
+ self.kv_cache_manager.estimate_cached_tokens(request)
1802
+ )
1803
+
1804
finish_reason = None
1805
if stopped:
1806
# Capture finish_reason BEFORE _handle_stopped_request, which may
@@ -1814,13 +1828,7 @@ class Scheduler(SchedulerInterface):
1828
1829
# Get prompt logprobs for this request.
1830
prompt_logprobs_tensors = prompt_logprobs_dict.get(req_id)
1817
- if (
1818
- new_token_ids
1819
- or pooler_output is not None
1820
- or kv_transfer_params
1821
- or ec_transfer_params
1822
- or stopped
1823
- ):
1831
+ if should_emit_output:
1832
# Add EngineCoreOutput for this Request.
1833
outputs[request.client_index].append(
1834
EngineCoreOutput(
@@ -1832,7 +1840,7 @@ class Scheduler(SchedulerInterface):
1840
pooling_output=pooler_output,
1841
stop_reason=request.stop_reason,
1842
events=request.take_events(),
1835
- prefill_stats=request.take_prefill_stats(),
1843
+ prefill_stats=prefill_stats,
1844
kv_transfer_params=kv_transfer_params,
1845
ec_transfer_params=ec_transfer_params,
1846
trace_headers=request.trace_headers,
vllm/v1/engine/output_processor.py
+5
@@ -172,6 +172,7 @@ class RequestState:
172
self.is_prefilling = True
173
self.queue = queue
174
self.num_cached_tokens = 0
175
+ self.num_cache_creation_tokens = 0
176
177
self.stats = RequestStateStats(arrival_time=arrival_time) if log_stats else None
178
@@ -377,6 +378,7 @@ class RequestState:
378
kv_transfer_params=kv_transfer_params,
379
ec_transfer_params=ec_transfer_params,
380
num_cached_tokens=self.num_cached_tokens,
381
+ num_cache_creation_tokens=self.num_cache_creation_tokens,
382
metrics=self.stats,
383
)
384
@@ -639,6 +641,9 @@ class OutputProcessor:
641
req_state.num_cached_tokens = (
642
engine_core_output.prefill_stats.num_cached_tokens
643
)
644
+ req_state.num_cache_creation_tokens = (
645
+ engine_core_output.prefill_stats.num_cache_creation_tokens
646
+ )
647
req_state.is_prefilling = False
648
649
if pooling_output is None:
vllm/v1/metrics/stats.py
+8
@@ -265,6 +265,7 @@ class PrefillStats:
265
num_cached_tokens: Tokens to be prefilled without actual compute work.
266
num_local_cached_tokens: Tokens to be prefilled from local prefix cache.
267
num_external_cached_tokens: Tokens to be prefilled from external KV transfer.
268
+ num_cache_creation_tokens: Tokens computed and written to the prefix cache.
269
"""
270
271
num_prompt_tokens: int = 0
@@ -272,6 +273,7 @@ class PrefillStats:
273
num_cached_tokens: int = 0
274
num_local_cached_tokens: int = 0
275
num_external_cached_tokens: int = 0
276
+ num_cache_creation_tokens: int = 0
277
278
def set(
279
self,
@@ -288,6 +290,12 @@ class PrefillStats:
290
self.num_local_cached_tokens = num_local_cached_tokens
291
self.num_external_cached_tokens = num_external_cached_tokens
292
293
+ def finalize(self, num_cached_tokens: int) -> None:
294
+ assert num_cached_tokens >= 0
295
+ self.num_cache_creation_tokens = max(
296
+ 0, min(num_cached_tokens, self.num_prompt_tokens) - self.num_cached_tokens
297
+ )
298
+
299
300
@dataclass
301
class PromptTokenStats: