From a46d18a2e0df2256987aee5ed4327fb5bf5523b4 Mon Sep 17 00:00:00 2001 From: TobiMayr Date: Wed, 8 Jul 2026 16:50:23 +0200 Subject: [PATCH] fix(logging): convert Usage object to dict in non-streaming /v1/responses transform _transform_usage_objects was setattr-ing a raw Usage Pydantic object instead of a dict when setting result.usage for non-streaming ResponsesAPIResponse results. Since ResponsesAPIResponse.usage is typed as ResponseAPIUsage (which lacks prompt_tokens/completion_tokens), model_dump() would silently drop those fields, corrupting token usage reporting for non-streaming /v1/responses calls. The standard_logging_payload branch already converted to dict correctly; this applies the same pattern to the result.usage assignment itself. Co-Authored-By: Claude Fable 5 --- litellm/litellm_core_utils/litellm_logging.py | 14 +++------ .../test_litellm_logging.py | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 78ff073fdcb..6e19ff2565e 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -2686,17 +2686,13 @@ class Logging(LiteLLMLoggingBaseClass): ) -> object: if isinstance(result, ResponsesAPIResponse): result = result.model_copy() - transformed_usage = ResponseAPILoggingUtils.transform_response_api_usage_to_chat_usage(result.usage) - setattr(result, "usage", transformed_usage) + chat_usage: Final = ResponseAPILoggingUtils.transform_response_api_usage_to_chat_usage(result.usage) + chat_usage_dict: Final = chat_usage.model_dump() if hasattr(chat_usage, "model_dump") else dict(chat_usage) + setattr(result, "usage", chat_usage_dict) if standard_logging_payload is not None: response_dict: Final = result.model_dump() if hasattr(result, "model_dump") else dict(result) - # Ensure usage is properly included with transformed chat format - if transformed_usage is not None: - response_dict["usage"] = ( - transformed_usage.model_dump() - if hasattr(transformed_usage, "model_dump") - else dict(transformed_usage) - ) + if chat_usage is not None: + response_dict["usage"] = chat_usage_dict standard_logging_payload["response"] = response_dict # rebind-ok: mirrors the transformed usage elif isinstance(result, TranscriptionResponse): from litellm.litellm_core_utils.llm_cost_calc.usage_object_transformation import ( diff --git a/tests/unit/litellm_core_utils/test_litellm_logging.py b/tests/unit/litellm_core_utils/test_litellm_logging.py index 981c43a9792..3f2f1855f06 100644 --- a/tests/unit/litellm_core_utils/test_litellm_logging.py +++ b/tests/unit/litellm_core_utils/test_litellm_logging.py @@ -6185,6 +6185,37 @@ def test_success_handler_computes_cost_for_dict_response(): assert logging_obj.model_call_details["response_cost"] == expected_cost +def test_transform_usage_objects_non_streaming_preserves_prompt_and_completion_tokens(): + response = ResponsesAPIResponse( + id="resp-test-001", + created_at=1700000000, + output=[], + usage=ResponseAPIUsage(input_tokens=100, output_tokens=50, total_tokens=150), + ) + + logging_obj = LitellmLogging( + model="gpt-5", + messages=[{"role": "user", "content": "Hello"}], + stream=False, + call_type="responses", + start_time=time.time(), + litellm_call_id="test-usage-preservation", + function_id="test-fn-usage", + ) + + result = logging_obj._transform_usage_objects(response) + + assert isinstance(result.usage, dict) + assert result.usage.get("prompt_tokens") == 100 + assert result.usage.get("completion_tokens") == 50 + assert result.usage.get("total_tokens") == 150 + + dumped_usage = result.model_dump().get("usage", {}) + assert dumped_usage.get("prompt_tokens") == 100 + assert dumped_usage.get("completion_tokens") == 50 + assert dumped_usage.get("total_tokens") == 150 + + def test_success_handler_preserves_precomputed_cost_for_dict_response(): """Precomputed response_cost on model_call_details must not be overwritten.""" logging_obj = _make_dict_logging_obj()