mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
fix(responses): stamp streamed usage cost when the provider usage arrives as a dict
Perplexity's Responses payload fails ResponsesAPIResponse validation on truncation "" and is kept as an unvalidated model, so its usage stays a plain dict and _stamp_responses_usage_cost raised AttributeError on every streamed completion once reasoning made the cost non-zero. Validate the dict into ResponseAPIUsage before stamping, keeping a provider-reported cost when it carries one. Resolves LIT-7391
This commit is contained in:
parent
6f3b3c7957
commit
96e46ba8ff
2 changed files with 66 additions and 2 deletions
|
|
@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, overload, runti
|
|||
|
||||
import httpx
|
||||
from openai._streaming import SSEDecoder
|
||||
from pydantic import ValidationError
|
||||
from typing_extensions import TypeIs
|
||||
|
||||
import litellm
|
||||
|
|
@ -544,7 +545,7 @@ class BaseResponsesAPIStreamingIterator:
|
|||
def _record_failed_response_usage(self, response_obj: ResponsesAPIResponse | None) -> None:
|
||||
if response_obj is None or self.logging_obj is None:
|
||||
return
|
||||
usage_obj: Final[ResponseAPIUsage | None] = getattr(response_obj, "usage", None)
|
||||
usage_obj: Final[ResponseAPIUsage | None] = _usage_as_model(getattr(response_obj, "usage", None))
|
||||
if usage_obj is None:
|
||||
return
|
||||
try:
|
||||
|
|
@ -1293,14 +1294,26 @@ def _add_text_like_part_events(
|
|||
)
|
||||
|
||||
|
||||
def _usage_as_model(usage: object) -> ResponseAPIUsage | None:
|
||||
if isinstance(usage, ResponseAPIUsage):
|
||||
return usage
|
||||
if not isinstance(usage, dict):
|
||||
return None
|
||||
try:
|
||||
return ResponseAPIUsage.model_validate(usage)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def _stamp_responses_usage_cost(
|
||||
response_obj: ResponsesAPIResponse | None, logging_obj: LiteLLMLoggingObj | None
|
||||
) -> None:
|
||||
if response_obj is None or logging_obj is None:
|
||||
return
|
||||
usage_obj: Final[ResponseAPIUsage | None] = getattr(response_obj, "usage", None)
|
||||
usage_obj: Final[ResponseAPIUsage | None] = _usage_as_model(getattr(response_obj, "usage", None))
|
||||
if usage_obj is None:
|
||||
return
|
||||
response_obj.usage = usage_obj # rebind-ok: the stamped cost has to ride on the response the client receives
|
||||
if isinstance(getattr(usage_obj, "cost", None), (int, float)):
|
||||
return
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -368,6 +368,57 @@ def test_stamp_responses_usage_cost_keeps_provider_reported_cost():
|
|||
logging_obj._response_cost_calculator.assert_not_called()
|
||||
|
||||
|
||||
def _unvalidated_response_with_dict_usage(usage: dict) -> ResponsesAPIResponse:
|
||||
return ResponsesAPIResponse.model_construct(
|
||||
id="resp_lit7391",
|
||||
created_at=int(datetime(2025, 1, 1).timestamp()),
|
||||
status="completed",
|
||||
model="perplexity/deepseek-v4-flash-0731",
|
||||
object="response",
|
||||
output=[],
|
||||
truncation="",
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
|
||||
def test_stamp_responses_usage_cost_keeps_provider_cost_from_dict_usage():
|
||||
from litellm.responses.streaming_iterator import _stamp_responses_usage_cost
|
||||
from litellm.types.llms.openai import ResponseAPIUsage
|
||||
|
||||
response = _unvalidated_response_with_dict_usage(
|
||||
{
|
||||
"input_tokens": 29,
|
||||
"output_tokens": 120,
|
||||
"output_tokens_details": {"reasoning_tokens": 117},
|
||||
"total_tokens": 149,
|
||||
"cost": {"currency": "USD", "input_cost": 0, "output_cost": 3e-05, "total_cost": 3e-05},
|
||||
}
|
||||
)
|
||||
logging_obj = Mock(spec=LiteLLMLoggingObj)
|
||||
|
||||
_stamp_responses_usage_cost(response, logging_obj)
|
||||
|
||||
assert isinstance(response.usage, ResponseAPIUsage)
|
||||
assert response.usage.cost == pytest.approx(3e-05)
|
||||
assert response.usage.output_tokens_details.reasoning_tokens == 117
|
||||
logging_obj._response_cost_calculator.assert_not_called()
|
||||
|
||||
|
||||
def test_stamp_responses_usage_cost_computes_cost_for_dict_usage_without_cost():
|
||||
from litellm.responses.streaming_iterator import _stamp_responses_usage_cost
|
||||
from litellm.types.llms.openai import ResponseAPIUsage
|
||||
|
||||
response = _unvalidated_response_with_dict_usage({"input_tokens": 29, "output_tokens": 120, "total_tokens": 149})
|
||||
logging_obj = Mock(spec=LiteLLMLoggingObj)
|
||||
logging_obj._response_cost_calculator.return_value = 0.000704
|
||||
|
||||
_stamp_responses_usage_cost(response, logging_obj)
|
||||
|
||||
assert isinstance(response.usage, ResponseAPIUsage)
|
||||
assert response.usage.cost == pytest.approx(0.000704)
|
||||
logging_obj._response_cost_calculator.assert_called_once_with(result=response)
|
||||
|
||||
|
||||
def test_stamp_responses_usage_cost_survives_calculator_failure():
|
||||
from litellm.responses.streaming_iterator import _stamp_responses_usage_cost
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue