diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 36d17596873..e6733eeba0a 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -1778,16 +1778,13 @@ class Logging(LiteLLMLoggingBaseClass): 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) + transformed_usage_dict = ( + transformed_usage.model_dump() if hasattr(transformed_usage, "model_dump") else dict(transformed_usage) + ) + setattr(result, "usage", transformed_usage_dict) if (standard_logging_payload := self.model_call_details.get("standard_logging_object")) is not None: response_dict = 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) - ) + response_dict["usage"] = transformed_usage_dict standard_logging_payload["response"] = response_dict elif isinstance(result, TranscriptionResponse): from litellm.litellm_core_utils.llm_cost_calc.usage_object_transformation import ( 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 5bffda126fe..bcdeac9753d 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -3639,6 +3639,46 @@ def test_handle_anthropic_messages_response_logging_degrades_on_unparseable_resp assert result.usage.prompt_tokens == 4 # type: ignore[attr-defined] +def _responses_logging_obj(): + return LitellmLogging( + model="openai/gpt-4o", + messages=[{"role": "user", "content": "hi"}], + stream=False, + call_type="aresponses", + start_time=time.time(), + litellm_call_id="33688", + function_id="33688", + ) + + +def test_transform_usage_objects_keeps_prompt_and_completion_tokens_serializable(): + """Regression for #33688. _transform_usage_objects transforms a Responses API + usage object into the chat Usage shape, but it must store it as a plain dict on + result.usage. If it stores the Pydantic Usage object instead, result.model_dump() + serializes it under the declared ResponseAPIUsage schema and silently drops + prompt_tokens/completion_tokens (only total_tokens survives), so Prometheus, + custom callbacks, and SpendLogs lose those counts.""" + from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup + + logging_obj = _responses_logging_obj() + result = logging_obj._transform_usage_objects(result=_responses_api_response_with_text()) + + assert isinstance(result.usage, dict) + assert result.usage["prompt_tokens"] == 11 + assert result.usage["completion_tokens"] == 7 + assert result.usage["total_tokens"] == 18 + + dumped = result.model_dump() + assert dumped["usage"]["prompt_tokens"] == 11 + assert dumped["usage"]["completion_tokens"] == 7 + assert dumped["usage"]["total_tokens"] == 18 + + usage_dict = StandardLoggingPayloadSetup.get_usage_as_dict(response_obj=dumped) + assert usage_dict["prompt_tokens"] == 11 + assert usage_dict["completion_tokens"] == 7 + assert usage_dict["total_tokens"] == 18 + + class _SuccessCapturingLogger(CustomLogger): """Records the success payload. success_payload is populated only in async_log_success_event, so it stays None when the buggy no-op