Skip to content
Merged
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
40 changes: 31 additions & 9 deletions litellm/litellm_core_utils/streaming_chunk_builder_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ def _usage_chunk_calculation_helper(self, usage_chunk: Usage) -> dict:
cache_read_input_tokens: Optional[int] = None
completion_tokens_details: Optional[CompletionTokensDetails] = None
prompt_tokens_details: Optional[PromptTokensDetailsWrapper] = None
cost: Optional[float] = None

if "prompt_tokens" in usage_chunk:
prompt_tokens = usage_chunk.get("prompt_tokens", 0) or 0
Expand All @@ -476,6 +477,8 @@ def _usage_chunk_calculation_helper(self, usage_chunk: Usage) -> dict:
cache_creation_input_tokens = usage_chunk.get("cache_creation_input_tokens")
if "cache_read_input_tokens" in usage_chunk:
cache_read_input_tokens = usage_chunk.get("cache_read_input_tokens")
if "cost" in usage_chunk:
cost = usage_chunk.get("cost")
if hasattr(usage_chunk, "completion_tokens_details"):
if isinstance(usage_chunk.completion_tokens_details, dict):
completion_tokens_details = CompletionTokensDetails(**usage_chunk.completion_tokens_details)
Expand All @@ -494,6 +497,7 @@ def _usage_chunk_calculation_helper(self, usage_chunk: Usage) -> dict:
"cache_read_input_tokens": cache_read_input_tokens,
"completion_tokens_details": completion_tokens_details,
"prompt_tokens_details": prompt_tokens_details,
"cost": cost,
}

def count_reasoning_tokens(self, response: ModelResponse) -> Optional[int]:
Expand All @@ -512,6 +516,22 @@ def count_reasoning_tokens(self, response: ModelResponse) -> Optional[int]:

return reasoning_tokens

@staticmethod
def _extract_usage_chunk(chunk: dict[str, Any] | ModelResponse | ModelResponseStream) -> Usage | None:
usage_chunk: Usage | dict[str, Any] | None = None
if hasattr(chunk, "usage") and chunk.usage is not None:
usage_chunk = chunk.usage
elif "usage" in chunk:
usage_chunk = chunk["usage"]
elif (isinstance(chunk, ModelResponse) or isinstance(chunk, ModelResponseStream)) and hasattr(
chunk, "_hidden_params"
):
usage_chunk = chunk._hidden_params.get("usage", None)

if isinstance(usage_chunk, dict):
Comment thread
mateo-berri marked this conversation as resolved.
return Usage(**usage_chunk)
return usage_chunk

def _calculate_usage_per_chunk(
self,
chunks: List[Union[Dict[str, Any], ModelResponse]],
Expand Down Expand Up @@ -548,18 +568,12 @@ def _calculate_usage_per_chunk(
# is last-wins, so without preserving this separately the 1h breakdown is
# lost and 1h cache writes get billed at the 5m rate.
cache_creation_token_details: Optional[CacheCreationTokenDetails] = None
cost: Optional[float] = None

for chunk in chunks:
usage_chunk: Optional[Usage] = None
if "usage" in chunk:
usage_chunk = chunk["usage"]
elif (isinstance(chunk, ModelResponse) or isinstance(chunk, ModelResponseStream)) and hasattr(
chunk, "_hidden_params"
):
usage_chunk = chunk._hidden_params.get("usage", None)
usage_chunk = self._extract_usage_chunk(chunk)

if usage_chunk is not None:
if isinstance(usage_chunk, dict):
usage_chunk = Usage(**usage_chunk)
usage_chunk_dict = self._usage_chunk_calculation_helper(usage_chunk)
if usage_chunk_dict["prompt_tokens"] is not None and usage_chunk_dict["prompt_tokens"] > 0:
prompt_tokens = usage_chunk_dict["prompt_tokens"]
Expand Down Expand Up @@ -610,6 +624,9 @@ def _calculate_usage_per_chunk(
prompt_tokens_details, cache_creation_token_details
)

if usage_chunk_dict["cost"] is not None:
cost = usage_chunk_dict["cost"]

prompt_tokens_details = self._attach_cache_creation_token_details(
prompt_tokens_details, cache_creation_token_details
)
Expand All @@ -629,6 +646,7 @@ def _calculate_usage_per_chunk(
web_search_requests=web_search_requests,
completion_tokens_details=completion_tokens_details,
prompt_tokens_details=prompt_tokens_details,
cost=cost,
)

@staticmethod
Expand Down Expand Up @@ -727,6 +745,7 @@ def calculate_usage(
prompt_tokens_details: Optional[PromptTokensDetailsWrapper] = calculated_usage_per_chunk[
"prompt_tokens_details"
]
cost: Optional[float] = calculated_usage_per_chunk["cost"]

try:
returned_usage.prompt_tokens = prompt_tokens or token_counter(model=model, messages=messages)
Expand Down Expand Up @@ -784,6 +803,9 @@ def calculate_usage(
else:
returned_usage.prompt_tokens_details.web_search_requests = web_search_requests

if cost is not None:
setattr(returned_usage, "cost", cost)

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.

Low: Provider cost disclosure

usage.cost is serialized back to streaming clients when they request usage chunks, so an authenticated client can learn provider-reported per-request cost even when include_cost_in_streaming_usage is false. Keep the provider cost in hidden metadata for spend tracking, but only add it to the public Usage object when cost disclosure is explicitly enabled.

@mateo-berri mateo-berri Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The include_cost_in_streaming_usage flag gates a different thing: whether litellm injects its own calculated cost into streaming usage chunks. Every usage site (stream_chunk_builder in litellm/main.py, the responses streaming iterators, the pass-through cost injection) calls logging_obj._response_cost_calculator behind that flag. It has never governed passthrough of provider-reported cost, so there is no configuration boundary being crossed here

For provider-reported cost specifically, the non-streaming path already returns it to the same authenticated client today: litellm always requests usage: {include: true} from OpenRouter (see add_usage_parameter in litellm/llms/openrouter/chat/transformation.py), the raw body's usage.cost survives Usage(**response_object["usage"]) in convert_dict_to_response.py, and cost is a declared field on the Usage model. This PR brings streaming to parity with that existing non-streaming behavior, which also matches what OpenRouter's own API returns for the identical request. The value disclosed is the cost of the client's own request, not data belonging to another tenant or the proxy operator's margin

Spend tracking does not depend on the public field either way; the cost calculator reads _hidden_params["additional_headers"]["llm_provider-x-litellm-response-cost"], which this PR populates via _propagate_usage_cost_to_hidden_params. Suppressing usage.cost on streaming responses while non-streaming responses keep returning it would add an inconsistency without adding a boundary


# Return a new usage object with the new values

returned_usage = Usage(**returned_usage.model_dump())
Expand Down
59 changes: 50 additions & 9 deletions litellm/litellm_core_utils/streaming_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -962,10 +962,11 @@ def return_processed_chunk_logic( # noqa: C901
if self.custom_llm_provider == "bedrock" and "trace" in model_response:
return model_response

# Default - return StopIteration
if hasattr(model_response, "usage"):
self.chunks.append(model_response)
raise StopIteration
# Don't raise StopIteration here - some providers (like OpenRouter)
# send usage/cost data in chunks after the finish_reason chunk
if hasattr(model_response, "usage") and model_response.usage is not None:
return model_response
return
# flush any remaining holding chunk
if len(self.holding_chunk) > 0:
if model_response.choices[0].delta.content is None:
Expand Down Expand Up @@ -1474,12 +1475,16 @@ def chunk_creator(self, chunk: Any): # type: ignore

self.tool_call = True

if hasattr(chunk, "usage") and chunk.usage is not None:
model_response.usage = chunk.usage

## RETURN ARG
return self.return_processed_chunk_logic(
result = self.return_processed_chunk_logic(
completion_obj=completion_obj,
model_response=model_response, # type: ignore
response_obj=response_obj,
)
return result

except StopIteration:
raise StopIteration
Expand Down Expand Up @@ -1686,6 +1691,21 @@ def finish_reason_handler(self):
model_response.choices[0].finish_reason = "tool_calls"
return model_response

@staticmethod
def _propagate_usage_cost_to_hidden_params(
response: "ModelResponse",
) -> None:
"""
If the assembled response carries a provider-reported cost on
usage.cost, copy it into _hidden_params so litellm's cost
calculator uses it instead of a token-based estimate.
"""
_usage = getattr(response, "usage", None)
if _usage is not None and hasattr(_usage, "cost") and _usage.cost is not None:
if "additional_headers" not in response._hidden_params:
response._hidden_params["additional_headers"] = {}
response._hidden_params["additional_headers"]["llm_provider-x-litellm-response-cost"] = float(_usage.cost)

def __next__(self) -> "ModelResponseStream":
cache_hit = False
if self.custom_llm_provider is not None and self.custom_llm_provider == "cached_response":
Expand Down Expand Up @@ -1741,6 +1761,10 @@ def __next__(self) -> "ModelResponseStream":
# hasattr(response, "usage") is always True — must check
# `is not None` to avoid running this path on every chunk.
if getattr(response, "usage", None) is not None:
usage_to_preserve = response.usage
if usage_to_preserve:
response._hidden_params["usage"] = usage_to_preserve

obj_dict = response.model_dump()

if "usage" in obj_dict:
Expand Down Expand Up @@ -1789,6 +1813,8 @@ def __next__(self) -> "ModelResponseStream":

response = self.model_response_creator()
if complete_streaming_response is not None:
self._propagate_usage_cost_to_hidden_params(complete_streaming_response)

setattr(
response,
"usage",
Expand Down Expand Up @@ -1999,6 +2025,8 @@ async def __anext__(self) -> "ModelResponseStream":

response = self.model_response_creator()
if complete_streaming_response is not None:
self._propagate_usage_cost_to_hidden_params(complete_streaming_response)

setattr(
response,
"usage",
Expand Down Expand Up @@ -2228,19 +2256,32 @@ def calculate_total_usage(chunks: List[ModelResponse]) -> Usage:
"""Assume most recent usage chunk has total usage uptil then."""
prompt_tokens: int = 0
completion_tokens: int = 0
latest_usage_chunk = None

for chunk in chunks:
if "usage" in chunk and chunk["usage"] is not None:
if "prompt_tokens" in chunk["usage"]:
prompt_tokens = chunk["usage"].get("prompt_tokens", 0) or 0
if "completion_tokens" in chunk["usage"]:
completion_tokens = chunk["usage"].get("completion_tokens", 0) or 0
usage = chunk["usage"]
latest_usage_chunk = usage
if "prompt_tokens" in usage:
prompt_tokens = usage.get("prompt_tokens", 0) or 0
if "completion_tokens" in usage:
completion_tokens = usage.get("completion_tokens", 0) or 0

returned_usage_chunk = Usage(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=prompt_tokens + completion_tokens,
)

if latest_usage_chunk is not None:
latest_cost = (
latest_usage_chunk.get("cost")
if isinstance(latest_usage_chunk, dict)
else getattr(latest_usage_chunk, "cost", None)
)
if latest_cost is not None:
returned_usage_chunk.cost = latest_cost

return returned_usage_chunk


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ class UsagePerChunk(TypedDict):
web_search_requests: Optional[int]
completion_tokens_details: Optional[CompletionTokensDetails]
prompt_tokens_details: Optional[PromptTokensDetailsWrapper]
cost: Optional[float]
10 changes: 8 additions & 2 deletions litellm/types/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1795,14 +1795,17 @@ def __init__(
else:
created = created

usage_to_set = None
if "usage" in kwargs and kwargs["usage"] is not None:
if isinstance(kwargs["usage"], dict):
kwargs["usage"] = Usage(**kwargs["usage"])
usage_to_set = Usage(**kwargs["usage"])
kwargs["usage"] = usage_to_set
elif isinstance(kwargs["usage"], BaseModel):
dump = (
kwargs["usage"].model_dump() if hasattr(kwargs["usage"], "model_dump") else kwargs["usage"].dict()
)
kwargs["usage"] = Usage(**dump)
usage_to_set = Usage(**dump)
kwargs["usage"] = usage_to_set

kwargs["id"] = id
kwargs["created"] = created
Expand All @@ -1811,6 +1814,9 @@ def __init__(

super().__init__(**kwargs)

if usage_to_set is not None:
self.usage = usage_to_set

def __contains__(self, key):
# Define custom behavior for the 'in' operator
return hasattr(self, key)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -956,3 +956,39 @@ def test_stream_chunk_builder_propagates_vertex_ai_metadata_from_dict_chunks():
assert response.model_dump()["vertex_ai_grounding_metadata"] == [
{"webSearchQueries": ["test query"]}
]


def test_cost_field_in_usage_chunks():
chunk1_usage = Usage(completion_tokens=1, prompt_tokens=10, total_tokens=11)
chunk1 = ModelResponseStream(
id="chatcmpl-1",
created=1745513206,
model="openrouter/claude",
choices=[
StreamingChoices(finish_reason=None, index=0, delta=Delta(content="Hi"))
],
usage=chunk1_usage,
)

chunk2_usage = Usage(
completion_tokens=5, prompt_tokens=10, total_tokens=15, cost=0.00025
)
chunk2 = ModelResponseStream(
id="chatcmpl-1",
created=1745513207,
model="openrouter/claude",
choices=[
StreamingChoices(finish_reason="stop", index=0, delta=Delta(content=""))
],
usage=chunk2_usage,
)

processor = ChunkProcessor(chunks=[chunk1, chunk2])
usage = processor.calculate_usage(
chunks=[chunk1, chunk2], model="openrouter/claude", completion_output="Hi"
)

assert hasattr(usage, "cost")
assert usage.cost == 0.00025
assert usage.prompt_tokens == 10
assert usage.completion_tokens == 5
Loading
Loading