fix(logging): handle dict result.response in streaming /v1/responses

ResponseCompletedEvent allows extra fields, so the streaming-assembly code can
store a plain dict in .response. A bare .usage access raised AttributeError and
no LiteLLM_SpendLogs row was written at all. Read usage through the shape the
value actually has, and write it back the same way.

Signed-off-by: hclsys <chenglunhu@gmail.com>
This commit is contained in:
hclsys 2026-09-17 18:19:24 +08:00 • committed by Chenglun Hu
parent dddbaa803d
commit 34eb909d3b
2 changed files with 58 additions and 19 deletions

View file

@ -4757,27 +4757,35 @@ class Logging(LiteLLMLoggingBaseClass):
(ResponseCompletedEvent, ResponseIncompleteEvent, ResponseFailedEvent),
):
## return unified Usage object
if isinstance(result.response.usage, ResponseAPIUsage):
set_response_cost_in_hidden_params(
result.response,
provider_reported_usage_cost(
result.response.usage, self.model_call_details.get("custom_llm_provider")
),
)
transformed_usage: Final = ResponseAPILoggingUtils.transform_response_api_usage_to_chat_usage(
result.response.usage
)
# `result.response` may be a plain dict on the streaming /v1/responses
# spend-log path (#29913) because ResponseCompletedEvent allows extra
# fields; a bare `.usage` access raises AttributeError there and no
# spend row is written at all.
resp: Final = result.response
resp_dict: Final = (
cast(dict[str, object], resp) # cast-ok: the event allows extra fields, so a plain dict can land here
if isinstance(resp, dict)
else None
)
usage: Final = resp_dict.get("usage") if resp_dict is not None else getattr(resp, "usage", None)
if isinstance(usage, ResponseAPIUsage):
if resp_dict is None:
set_response_cost_in_hidden_params(
resp,
provider_reported_usage_cost(usage, self.model_call_details.get("custom_llm_provider")),
)
transformed_usage: Final = ResponseAPILoggingUtils.transform_response_api_usage_to_chat_usage(usage)
# Set as dict instead of Usage object so model_dump() serializes it correctly
setattr(
result.response,
"usage",
(
transformed_usage.model_dump()
if hasattr(transformed_usage, "model_dump")
else dict(transformed_usage)
),
new_usage: Final = (
transformed_usage.model_dump()
if hasattr(transformed_usage, "model_dump")
else dict(transformed_usage)
)
return result.response
if resp_dict is not None:
resp_dict["usage"] = new_usage
else:
setattr(resp, "usage", new_usage)
return resp
elif isinstance(result, InteractionsAPIStreamingResponse):
return self._assemble_completed_interaction_response(result)
else:

View file

@ -4855,6 +4855,37 @@ def test_get_assembled_streaming_response_returns_result_for_streaming():
assert assembled is result
def test_get_assembled_streaming_response_handles_dict_response():
"""ResponseCompletedEvent.response may hold a plain dict on the streaming
/v1/responses spend-log path (#29913). A bare `.usage` access raised
AttributeError there, so no LiteLLM_SpendLogs row was written at all."""
import datetime
from litellm.types.llms.openai import ResponseCompletedEvent
logging_obj = _make_logging_obj(stream=True)
event = ResponseCompletedEvent.model_construct(
type="response.completed",
response={
"id": "resp-dict-1",
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
},
)
assembled = logging_obj._get_assembled_streaming_response(
result=event,
start_time=datetime.datetime.now(),
end_time=datetime.datetime.now(),
is_async=False,
streaming_chunks=[],
)
assert assembled == {
"id": "resp-dict-1",
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
}
def test_streaming_success_handler_includes_vertex_ai_metadata_in_standard_logging():
"""Assembled streaming responses should include Vertex AI metadata in logging payload."""
import datetime