Carry real cache counts up instead of zeroing them on partial rows

cache_read_input_tokens and cache_creation_input_tokens are pydantic extras
on Usage, not declared fields, so filling them in created keys that were not
there before rather than replacing a None. Readers that test for presence
then took the new zero as authoritative: the spend log writer skipped its
own copy from prompt_tokens_details, turning a real cache read of 500 into
0, and the prometheus provider cache counters stopped incrementing.

Carry the prompt_tokens_details counts up before defaulting to zero, so a
partial row reports the same cache numbers a complete one does. Renamed the
helper to say what it now does.
This commit is contained in:
mateo-berri 2026-08-20 17:41:03 -07:00
parent a3b6762788
commit de7dcbbc67
4 changed files with 90 additions and 10 deletions

View file

@ -2326,7 +2326,7 @@ class CustomStreamWrapper:
return
if self.model:
partial_response.model = self.model
zero_fill_missing_cache_usage_fields(usage)
backfill_missing_cache_usage_fields(usage)
self.logging_obj.model_call_details["combined_usage_object"] = usage
self.logging_obj.model_call_details["response_cost"] = (
self.logging_obj._response_cost_calculator(result=partial_response) or 0.0
@ -2447,13 +2447,33 @@ class CustomStreamWrapper:
return chunk
def zero_fill_missing_cache_usage_fields(usage: Usage) -> None:
if getattr(usage, "cache_creation_input_tokens", None) is None:
usage.cache_creation_input_tokens = 0 # rebind-ok: in-place zero-fill is the contract
def _cache_token_count(details: PromptTokensDetailsWrapper | None, keys: tuple[str, ...]) -> int:
for key in keys:
value = getattr(details, key, None)
if isinstance(value, int) and not isinstance(value, bool) and value:
return value
return 0
def backfill_missing_cache_usage_fields(usage: Usage) -> None:
"""Give partial-stream usage the same cache fields a complete stream reports.
Carries OpenAI-style ``prompt_tokens_details`` counts up to the Anthropic-style
top-level keys, defaulting to zero. It must carry the real count rather than a
flat zero: downstream readers treat these keys as authoritative once present and
skip their own normalization, so a zero here would overwrite a real cache read.
"""
details: Final = usage.prompt_tokens_details
if getattr(usage, "cache_read_input_tokens", None) is None:
usage.cache_read_input_tokens = 0 # rebind-ok: in-place zero-fill is the contract
usage.cache_read_input_tokens = _cache_token_count( # rebind-ok: in-place backfill is the contract
details, ("cached_tokens",)
)
if getattr(usage, "cache_creation_input_tokens", None) is None:
usage.cache_creation_input_tokens = _cache_token_count( # rebind-ok: in-place backfill is the contract
details, ("cache_write_tokens", "cache_creation_tokens")
)
if usage.prompt_tokens_details is None:
usage.prompt_tokens_details = PromptTokensDetailsWrapper(cached_tokens=0) # rebind-ok: zero-fill in place
usage.prompt_tokens_details = PromptTokensDetailsWrapper(cached_tokens=0) # rebind-ok: backfill in place
_TokenDetails = TypeVar("_TokenDetails", PromptTokensDetailsWrapper, CompletionTokensDetailsWrapper)

View file

@ -44,7 +44,7 @@ from litellm.litellm_core_utils.llm_response_utils.get_headers import (
)
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.litellm_core_utils.streaming_handler import (
zero_fill_missing_cache_usage_fields,
backfill_missing_cache_usage_fields,
)
from litellm.proxy._types import ProxyException, UserAPIKeyAuth
from litellm.proxy.auth.auth_checks import can_key_call_resolved_model
@ -332,7 +332,7 @@ async def _bill_partial_streamed_spend_on_disconnect(request_data: dict, respons
partial_response.model = wrapper_model
partial_usage: Final = getattr(partial_response, "usage", None)
if isinstance(partial_usage, Usage):
zero_fill_missing_cache_usage_fields(partial_usage)
backfill_missing_cache_usage_fields(partial_usage)
try:
await logging_obj.dispatch_success_handlers(
partial_response,

View file

@ -3459,7 +3459,7 @@ def test_record_partial_usage_for_failure_counts_prompt_tokens_from_request_mess
assert stashed.prompt_tokens > 0
def test_record_partial_usage_for_failure_zero_fills_missing_cache_fields():
def test_record_partial_usage_for_failure_backfills_missing_cache_fields():
wrapper, logging_obj = _wrapper_with_partial_chunks(chunk_model="gpt-4o-mini")
wrapper._record_partial_usage_for_failure()
@ -3471,6 +3471,24 @@ def test_record_partial_usage_for_failure_zero_fills_missing_cache_fields():
assert stashed.prompt_tokens_details.cached_tokens == 0
def test_record_partial_usage_for_failure_carries_up_openai_style_cached_tokens():
recovered = Usage(
prompt_tokens=1000,
completion_tokens=10,
total_tokens=1010,
prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=500),
)
wrapper, logging_obj = _wrapper_with_partial_chunks(
chunk_model="gpt-4o-mini", usage=recovered
)
wrapper._record_partial_usage_for_failure()
stashed = logging_obj.model_call_details["combined_usage_object"]
assert stashed.cache_read_input_tokens == 500
assert stashed.cache_creation_input_tokens == 0
def test_record_partial_usage_for_failure_keeps_cache_values_recovered_from_chunks():
recovered = Usage(
prompt_tokens=40,

View file

@ -5555,7 +5555,7 @@ class TestStreamingClientDisconnectBilling:
assert standard_logging_object["response_cost"] > 0.0
@pytest.mark.asyncio
async def test_disconnect_billing_zero_fills_missing_cache_fields(self):
async def test_disconnect_billing_backfills_missing_cache_fields(self):
event = await self._bill_and_collect_success_event()
usage = event["response_obj"].usage
@ -5564,6 +5564,48 @@ class TestStreamingClientDisconnectBilling:
assert usage.prompt_tokens_details is not None
assert usage.prompt_tokens_details.cached_tokens == 0
@pytest.mark.asyncio
async def test_disconnect_billing_carries_up_openai_style_cached_tokens(self):
from litellm.types.utils import (
Delta,
ModelResponseStream,
PromptTokensDetailsWrapper,
StreamingChoices,
Usage,
)
def append_openai_style_cached_usage_chunk(response):
response.chunks.append(
ModelResponseStream(
id=response.chunks[0].id,
model="gpt-4o-mini",
object="chat.completion.chunk",
choices=[
StreamingChoices(
finish_reason=None,
index=0,
delta=Delta(content=" and more", role="assistant"),
)
],
usage=Usage(
prompt_tokens=1000,
completion_tokens=10,
total_tokens=1010,
prompt_tokens_details=PromptTokensDetailsWrapper(
cached_tokens=500
),
),
)
)
event = await self._bill_and_collect_success_event(
append_openai_style_cached_usage_chunk
)
usage = event["response_obj"].usage
assert getattr(usage, "cache_read_input_tokens", None) == 500
assert getattr(usage, "cache_creation_input_tokens", None) == 0
@pytest.mark.asyncio
async def test_disconnect_billing_keeps_cache_values_recovered_from_chunks(self):
from litellm.types.utils import (