diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 936d79b22d6..6f09ba1f45d 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -3161,21 +3161,23 @@ class Logging(LiteLLMLoggingBaseClass): (ResponseCompletedEvent, ResponseIncompleteEvent, ResponseFailedEvent), ): ## return unified Usage object - if isinstance(result.response.usage, ResponseAPIUsage): - transformed_usage = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage( - result.response.usage - ) + resp = result.response + usage = resp.get("usage") if isinstance(resp, dict) else resp.usage + if isinstance(usage, ResponseAPIUsage) or ( + isinstance(usage, dict) and ResponseAPILoggingUtils._is_response_api_usage(usage) + ): + transformed_usage = 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) - ), + transformed_usage_dict = ( + transformed_usage.model_dump() + if hasattr(transformed_usage, "model_dump") + else dict(transformed_usage) ) - return result.response + if isinstance(resp, dict): + resp["usage"] = transformed_usage_dict + else: + setattr(resp, "usage", transformed_usage_dict) + return resp else: return None diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index 0523ed7ecb1..75ab6ba157d 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -3707,3 +3707,43 @@ def test_set_cost_breakdown_stores_reasoning_cost(): cost_for_built_in_tools_cost_usd_dollar=0.0, ) assert "reasoning_cost" not in no_reasoning.cost_breakdown + + +def test_get_assembled_streaming_response_handles_dict_response(logging_obj): + """ + Regression test for streaming /v1/responses spend logging crash. + + In the async streaming Responses path, ResponseCompletedEvent.response is a + plain dict at runtime (the base models allow extra fields), so accessing + result.response.usage raised AttributeError and the success handler crashed + before writing a SpendLogs row. The assembly helper must accept a dict + response and transform its Responses-API usage into chat-completion usage. + """ + from datetime import datetime + + from litellm.types.llms.openai import ( + ResponseCompletedEvent, + ResponsesAPIStreamEvents, + ) + + event = ResponseCompletedEvent.model_construct( + type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED, + response={ + "id": "resp_dict", + "usage": {"input_tokens": 11, "output_tokens": 7, "total_tokens": 18}, + }, + ) + assert isinstance(event.response, dict) + + assembled = logging_obj._get_assembled_streaming_response( + result=event, + start_time=datetime.now(), + end_time=datetime.now(), + is_async=True, + streaming_chunks=[], + ) + + assert isinstance(assembled, dict) + assert assembled["usage"]["prompt_tokens"] == 11 + assert assembled["usage"]["completion_tokens"] == 7 + assert assembled["usage"]["total_tokens"] == 18