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:
mateo-berri 2026-09-09 16:06:55 -07:00
parent 6f3b3c7957
commit 96e46ba8ff
2 changed files with 66 additions and 2 deletions

View file

@ -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:

View file

@ -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