Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions litellm/cost_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2275,6 +2275,28 @@ def _summable_prompt_token_fields(prompt_tokens_details: BaseModel) -> list[str]
return [attr for attr in field_names if attr != "cache_creation_tokens"]


def _accumulate_cached_tokens_details(
combined_details: "PromptTokensDetailsWrapper",
usage_details: "PromptTokensDetailsWrapper",
) -> None:
from litellm.types.utils import CachedTokensDetails

cached_details: Final = getattr(usage_details, "cached_tokens_details", None)
if not isinstance(cached_details, CachedTokensDetails):
return
if combined_details.cached_tokens_details is None:
combined_details.cached_tokens_details = CachedTokensDetails()
combined_cached_details: Final = combined_details.cached_tokens_details
Comment on lines +2286 to +2289

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Mutable aggregation and long lines

This helper mutates its parameter without justification, and several added lines exceed 120 characters, preventing the change from meeting required Python standards

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

for attr in CachedTokensDetails.model_fields:
new_cached_val = getattr(cached_details, attr, None)
if isinstance(new_cached_val, int):
setattr(
combined_cached_details,
attr,
(getattr(combined_cached_details, attr, 0) or 0) + new_cached_val,
)


class BaseTokenUsageProcessor:
@staticmethod
def combine_usage_objects(usage_objects: list[Usage]) -> Usage:
Expand Down Expand Up @@ -2324,6 +2346,8 @@ def combine_usage_objects(usage_objects: list[Usage]) -> Usage:
current_val + new_val,
)

_accumulate_cached_tokens_details(combined.prompt_tokens_details, usage.prompt_tokens_details)

# Handle nested completion_tokens_details
if hasattr(usage, "completion_tokens_details") and usage.completion_tokens_details:
if not hasattr(combined, "completion_tokens_details") or not combined.completion_tokens_details:
Expand Down
49 changes: 45 additions & 4 deletions litellm/litellm_core_utils/llm_cost_calc/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from types import MappingProxyType
from typing import Any, Final, Literal, TypedDict, cast

from typing_extensions import ReadOnly

import litellm
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.llm_cost_calc.tiered_pricing import (
Expand All @@ -15,6 +17,7 @@
)
from litellm.types.utils import (
CacheCreationTokenDetails,
CachedTokensDetails,
CallTypes,
CompletionTokensDetailsWrapper,
DataResidency,
Expand Down Expand Up @@ -543,10 +546,38 @@ def calculate_cache_writing_cost(
return total_cost


def _allocate_uncached_modalities(
uncached_budget: int,
audio_tokens: int,
image_tokens: int,
video_tokens: int,
cached_details: "CachedTokensDetails | None",
) -> tuple[int, int, int, int]:
"""Split an uncached prompt-token budget across modalities. When the provider reports
which modalities the cache covered (cached_tokens_details), subtract the cache from each
modality directly; otherwise fall back to filling audio, then image, then video."""
cached_audio: Final = (cached_details.audio_tokens or 0) if cached_details is not None else 0
cached_image: Final = (cached_details.image_tokens or 0) if cached_details is not None else 0
billable_audio: Final = (
min(max(audio_tokens - cached_audio, 0), uncached_budget)
if cached_details is not None
else min(audio_tokens, uncached_budget)
)
billable_image: Final = (
min(max(image_tokens - cached_image, 0), uncached_budget - billable_audio)
if cached_details is not None
else min(image_tokens, uncached_budget - billable_audio)
)
billable_video: Final = min(video_tokens, uncached_budget - billable_audio - billable_image)
billable_text: Final = uncached_budget - billable_audio - billable_image - billable_video
return billable_audio, billable_image, billable_video, billable_text


class PromptTokensDetailsResult(TypedDict):
cache_hit_tokens: int
cache_creation_tokens: int
cache_creation_token_details: CacheCreationTokenDetails | None
cached_tokens_details: ReadOnly[CachedTokensDetails | None]
text_tokens: int
audio_tokens: int
image_tokens: int
Expand Down Expand Up @@ -574,6 +605,10 @@ def parse_prompt_tokens_details(usage: Usage) -> PromptTokensDetailsResult:
)
or None
)
raw_cached_tokens_details: Final = getattr(usage.prompt_tokens_details, "cached_tokens_details", None)
cached_tokens_details: Final = (
raw_cached_tokens_details if isinstance(raw_cached_tokens_details, CachedTokensDetails) else None
)
text_tokens: Final = (
cast(int | None, getattr(usage.prompt_tokens_details, "text_tokens", None))
or 0 # default to prompt tokens, if this field is not set
Expand Down Expand Up @@ -608,6 +643,7 @@ def parse_prompt_tokens_details(usage: Usage) -> PromptTokensDetailsResult:
cache_hit_tokens=cache_hit_tokens,
cache_creation_tokens=cache_creation_tokens,
cache_creation_token_details=cache_creation_token_details,
cached_tokens_details=cached_tokens_details,
text_tokens=text_tokens,
audio_tokens=audio_tokens,
image_tokens=image_tokens,
Expand Down Expand Up @@ -885,6 +921,7 @@ def generic_cost_per_token(
cache_hit_tokens=0,
cache_creation_tokens=0,
cache_creation_token_details=None,
cached_tokens_details=None,
text_tokens=usage.prompt_tokens,
audio_tokens=0,
image_tokens=0,
Expand Down Expand Up @@ -917,13 +954,17 @@ def generic_cost_per_token(
# cached and per-modality counts are both subsets of prompt_tokens and may overlap, so a
# modality can only bill what the cache did not already cover or the overlap is billed twice
uncached_budget: Final = max(usage.prompt_tokens - cache_hit - cache_creation, 0)
billable_audio: Final = min(audio_tokens, uncached_budget)
billable_image: Final = min(image_tokens, uncached_budget - billable_audio)
billable_video: Final = min(video_tokens, uncached_budget - billable_audio - billable_image)
billable_audio, billable_image, billable_video, billable_text = _allocate_uncached_modalities(
uncached_budget=uncached_budget,
audio_tokens=audio_tokens,
image_tokens=image_tokens,
video_tokens=video_tokens,
cached_details=prompt_tokens_details["cached_tokens_details"],
)
prompt_tokens_details["audio_tokens"] = billable_audio
prompt_tokens_details["image_tokens"] = billable_image
prompt_tokens_details["video_tokens"] = billable_video
prompt_tokens_details["text_tokens"] = uncached_budget - billable_audio - billable_image - billable_video
prompt_tokens_details["text_tokens"] = billable_text
elif text_tokens == 0 and prompt_tokens_details["image_count"] == 0:
# Clamp to zero: inconsistent streaming usage
prompt_tokens_details["text_tokens"] = max(
Expand Down
3 changes: 3 additions & 0 deletions litellm/responses/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1128,6 +1128,9 @@ def _transform_response_api_usage_to_chat_usage(
text_tokens=getattr(response_api_usage.input_tokens_details, "text_tokens", None),
image_tokens=getattr(response_api_usage.input_tokens_details, "image_tokens", None),
cache_write_tokens=getattr(response_api_usage.input_tokens_details, "cache_write_tokens", None),
cached_tokens_details=getattr(
response_api_usage.input_tokens_details, "cached_tokens_details", None
),
)
completion_tokens_details: CompletionTokensDetailsWrapper | None = None
output_tokens_details: Final[OutputTokensDetails | None] = getattr(
Expand Down
9 changes: 9 additions & 0 deletions litellm/types/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1603,6 +1603,12 @@ class CacheCreationTokenDetails(BaseModel):
ephemeral_1h_input_tokens: int | None = None


class CachedTokensDetails(BaseModel):
text_tokens: int | None = None
audio_tokens: int | None = None
image_tokens: int | None = None


class PromptTokensDetailsWrapper(
SafeAttributeModel, PromptTokensDetails
): # extends with image generation fields (text_tokens, image_tokens)
Expand Down Expand Up @@ -1645,6 +1651,9 @@ class PromptTokensDetailsWrapper(
cache_creation_token_details: CacheCreationTokenDetails | None = None
"""Details of cache creation tokens sent to the model. Used for tracking 5m/1h cache creation tokens for Anthropic prompt caching."""

cached_tokens_details: CachedTokensDetails | None = None
"""Per-modality breakdown of cached input tokens. OpenAI Realtime API naming (input_token_details.cached_tokens_details)."""

def __setattr__(self, name: str, value: object) -> None:
super().__setattr__(name, value)
if name == "cache_write_tokens":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3946,3 +3946,70 @@ def tier(cost):
)

assert cost == expected_cost


def test_generic_cost_per_token_realtime_uses_cached_modality_split(_local_model_cost_map):
"""
Regression for LIT-6513: with a realtime GA cached_tokens_details split, uncached
tokens must be billed per the reported modalities. The previous audio-first
allocation billed uncached text tokens at the audio rate whenever the modality
details overlapped the cached count.
"""
from litellm.types.utils import CachedTokensDetails

usage = Usage(
prompt_tokens=3400,
completion_tokens=1100,
total_tokens=4500,
prompt_tokens_details=PromptTokensDetailsWrapper(
cached_tokens=2000,
text_tokens=1400,
audio_tokens=2000,
cached_tokens_details=CachedTokensDetails(text_tokens=500, audio_tokens=1500),
),
)

prompt_cost, _ = generic_cost_per_token(
model="gpt-realtime",
usage=usage,
custom_llm_provider="openai",
)

model_cost_map = litellm.model_cost["gpt-realtime"]
uncached_audio = 2000 - 1500
uncached_text = 3400 - 2000 - uncached_audio
expected_prompt_cost = (
uncached_text * model_cost_map["input_cost_per_token"]
+ uncached_audio * model_cost_map["input_cost_per_audio_token"]
+ 2000 * model_cost_map["cache_read_input_token_cost"]
)
assert round(prompt_cost, 10) == round(expected_prompt_cost, 10)


def test_generic_cost_per_token_realtime_without_cached_split_clamps_to_uncached_budget(_local_model_cost_map):
"""Without a cached_tokens_details split, overlapping modality details must still be
clamped so cached tokens are never also billed at the full modality rate."""
usage = Usage(
prompt_tokens=3400,
completion_tokens=1100,
total_tokens=4500,
prompt_tokens_details=PromptTokensDetailsWrapper(
cached_tokens=2000,
text_tokens=1400,
audio_tokens=2000,
),
)

prompt_cost, _ = generic_cost_per_token(
model="gpt-realtime",
usage=usage,
custom_llm_provider="openai",
)

model_cost_map = litellm.model_cost["gpt-realtime"]
uncached_budget = 3400 - 2000
expected_prompt_cost = (
uncached_budget * model_cost_map["input_cost_per_audio_token"]
+ 2000 * model_cost_map["cache_read_input_token_cost"]
)
assert round(prompt_cost, 10) == round(expected_prompt_cost, 10)
38 changes: 38 additions & 0 deletions tests/test_litellm/test_cost_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -3952,6 +3952,44 @@ def test_combine_usage_objects_sums_mirrored_cache_write_fields_once():
assert combined_pair.prompt_tokens_details.cache_creation_tokens == 100


def test_combine_usage_objects_sums_cached_tokens_details():
"""
Realtime GA usage reports a per-modality cached-token split in
input_token_details.cached_tokens_details. Combining per-response usage
across a session must sum that split, or the cost path loses which
modalities the cache covered and misallocates uncached tokens.
"""
from litellm.types.utils import CachedTokensDetails

first = Usage(
prompt_tokens=1000,
completion_tokens=100,
total_tokens=1100,
prompt_tokens_details=PromptTokensDetailsWrapper(
cached_tokens=400,
cached_tokens_details=CachedTokensDetails(text_tokens=100, audio_tokens=300),
),
)
second = Usage(
prompt_tokens=2000,
completion_tokens=200,
total_tokens=2200,
prompt_tokens_details=PromptTokensDetailsWrapper(
cached_tokens=900,
cached_tokens_details=CachedTokensDetails(text_tokens=200, audio_tokens=700),
),
)

combined = BaseTokenUsageProcessor.combine_usage_objects([first, second])

assert combined.prompt_tokens_details is not None
assert combined.prompt_tokens_details.cached_tokens == 1300
cached_details = combined.prompt_tokens_details.cached_tokens_details
assert cached_details is not None
assert cached_details.text_tokens == 300
assert cached_details.audio_tokens == 1000


def test_completion_cost_prices_anthropic_shaped_cache_read_tokens(_local_model_cost_map):
"""Regression: an Anthropic /v1/messages response reports cache reads as top-level
cache_read_input_tokens with input_tokens excluding them. Reading that usage as
Expand Down
Loading