diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 82a7af64f9..517924fef1 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -4599,6 +4599,37 @@ class StandardLoggingPayloadSetup: raise ValueError(f"usage is required, got={usage} of type {type(usage)}") + @staticmethod + def get_usage_as_dict( + response_obj: Optional[dict], + combined_usage_object: Optional[Usage] = None, + ) -> dict: + """ + Like get_usage_from_response_obj but returns a plain dict, skipping + the Pydantic Usage construction on the hot path. + """ + _empty: dict = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} + if combined_usage_object is not None: + return combined_usage_object.model_dump() + if not response_obj: + return _empty + _raw = response_obj.get("usage", None) + if _raw is None: + return _empty + if isinstance(_raw, ResponseAPIUsage): + return ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage( + _raw + ).model_dump() + if isinstance(_raw, dict): + if ResponseAPILoggingUtils._is_response_api_usage(_raw): + return ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage( + _raw + ).model_dump() + return _raw + if isinstance(_raw, Usage): + return _raw.model_dump() + return _empty + @staticmethod def get_model_cost_information( base_model: Optional[str], @@ -5055,7 +5086,8 @@ def get_standard_logging_object_payload( completion_start_time = kwargs.get("completion_start_time", end_time) call_type = kwargs.get("call_type") cache_hit = kwargs.get("cache_hit", False) - usage = StandardLoggingPayloadSetup.get_usage_from_response_obj( + # Extract usage as a plain dict, avoiding Pydantic round-trip + usage_dict = StandardLoggingPayloadSetup.get_usage_as_dict( response_obj=response_obj, combined_usage_object=cast( Optional[Usage], kwargs.get("combined_usage_object") @@ -5102,7 +5134,7 @@ def get_standard_logging_object_payload( vector_store_request_metadata=kwargs.get( "vector_store_request_metadata", None ), - usage_object=usage.model_dump(), + usage_object=usage_dict, proxy_server_request=proxy_server_request, start_time=start_time, response_id=id, @@ -5188,9 +5220,9 @@ def get_standard_logging_object_payload( cache_key=clean_hidden_params["cache_key"], response_cost=response_cost, cost_breakdown=logging_obj.cost_breakdown, - total_tokens=usage.total_tokens, - prompt_tokens=usage.prompt_tokens, - completion_tokens=usage.completion_tokens, + total_tokens=usage_dict.get("total_tokens", 0), + prompt_tokens=usage_dict.get("prompt_tokens", 0), + completion_tokens=usage_dict.get("completion_tokens", 0), request_tags=request_tags, end_user=end_user_id or "", api_base=StandardLoggingPayloadSetup.strip_trailing_slash( diff --git a/tests/logging_callback_tests/test_standard_logging_payload.py b/tests/logging_callback_tests/test_standard_logging_payload.py index 53f555e765..b725d077e6 100644 --- a/tests/logging_callback_tests/test_standard_logging_payload.py +++ b/tests/logging_callback_tests/test_standard_logging_payload.py @@ -721,6 +721,94 @@ def test_cost_breakdown_missing_in_standard_logging_payload(): print("✅ Cost breakdown missing test passed!") +@pytest.mark.parametrize( + "use_combined_usage_object", + [False, True], + ids=["normal_usage_dict", "combined_usage_object"], +) +def test_usage_dict_roundtrip_in_payload(use_combined_usage_object): + """ + Regression test: verify that usage data flows correctly through + get_standard_logging_object_payload without unnecessary Pydantic round-trips. + + Checks: + - usage_object in StandardLoggingMetadata is a plain dict with correct token values + - prompt_tokens, completion_tokens, total_tokens on the payload match the usage dict + - Works for both normal usage dict path and combined_usage_object (realtime API) path + """ + from litellm.litellm_core_utils.litellm_logging import ( + get_standard_logging_object_payload, + Logging, + ) + from datetime import datetime + + logging_obj = Logging( + model="gpt-4o", + messages=[{"role": "user", "content": "Hi"}], + stream=False, + call_type="completion", + start_time=datetime.now(), + litellm_call_id="test-usage-roundtrip", + function_id="test-fn", + ) + + mock_response = { + "id": "chatcmpl-usage-test", + "object": "chat.completion", + "model": "gpt-4o", + "usage": { + "prompt_tokens": 42, + "completion_tokens": 58, + "total_tokens": 100, + }, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Hello!"}, + "finish_reason": "stop", + } + ], + } + + kwargs = { + "model": "gpt-4o", + "messages": [{"role": "user", "content": "Hi"}], + "response_cost": 0.01, + "custom_llm_provider": "openai", + } + + if use_combined_usage_object: + kwargs["combined_usage_object"] = Usage( + prompt_tokens=42, completion_tokens=58, total_tokens=100 + ) + + start_time = datetime.now() + end_time = datetime.now() + + payload = get_standard_logging_object_payload( + kwargs=kwargs, + init_response_obj=mock_response, + start_time=start_time, + end_time=end_time, + logging_obj=logging_obj, + status="success", + ) + + assert payload is not None + + # Top-level token fields must match + assert payload["prompt_tokens"] == 42 + assert payload["completion_tokens"] == 58 + assert payload["total_tokens"] == 100 + + # usage_object in metadata must be a plain dict (not a Pydantic model) + usage_obj = payload["metadata"]["usage_object"] + assert isinstance(usage_obj, dict) + assert usage_obj["prompt_tokens"] == 42 + assert usage_obj["completion_tokens"] == 58 + assert usage_obj["total_tokens"] == 100 + + def test_merge_litellm_metadata_basic(): """ Test that merge_litellm_metadata correctly merges metadata and litellm_metadata.