From 34eb909d3bf160e80a710a77d9e99ccfe52ba2e4 Mon Sep 17 00:00:00 2001 From: hclsys Date: Thu, 17 Sep 2026 18:19:24 +0800 Subject: [PATCH] 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 --- litellm/litellm_core_utils/litellm_logging.py | 46 +++++++++++-------- .../test_litellm_logging.py | 31 +++++++++++++ 2 files changed, 58 insertions(+), 19 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 9e0461e951d..0e775bbc23c 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -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: diff --git a/tests/unit/litellm_core_utils/test_litellm_logging.py b/tests/unit/litellm_core_utils/test_litellm_logging.py index 561ac9b2b33..131d859fd68 100644 --- a/tests/unit/litellm_core_utils/test_litellm_logging.py +++ b/tests/unit/litellm_core_utils/test_litellm_logging.py @@ -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