diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index f9677e78ee7..5e1412ee86b 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -1022,6 +1022,20 @@ class ResponsesAPIRequestUtils: class ResponseAPILoggingUtils: + # Standard Responses usage keys mapped explicitly below; extras pass through to Usage. + _RESPONSE_API_USAGE_MAPPED_KEYS = frozenset( + { + "input_tokens", + "output_tokens", + "total_tokens", + "input_tokens_details", + "output_tokens_details", + "input_token_details", + "output_token_details", + "cost", # handled separately after Usage construction + } + ) + @staticmethod def _is_response_api_usage(usage: dict | ResponseAPIUsage) -> bool: """returns True if usage is from OpenAI Response API""" @@ -1031,6 +1045,33 @@ class ResponseAPILoggingUtils: return True return False + @staticmethod + def _extra_fields_from_response_api_usage( + usage_input: dict | ResponseAPIUsage, + response_api_usage: ResponseAPIUsage, + ) -> dict[str, Any]: + """ + Preserve provider/extension usage fields not part of the standard token mapping. + + ResponseAPIUsage allows extra attributes; without forwarding them, the rebuilt + chat Usage would drop fields needed for provider-specific cost tracking. + """ + extras: dict[str, Any] = {} + if isinstance(usage_input, dict): + for key, value in usage_input.items(): + if key not in ResponseAPILoggingUtils._RESPONSE_API_USAGE_MAPPED_KEYS and value is not None: + extras[key] = value + return extras + + model_extra = getattr(response_api_usage, "model_extra", None) or getattr( + response_api_usage, "__pydantic_extra__", None + ) + if isinstance(model_extra, dict): + for key, value in model_extra.items(): + if key not in ResponseAPILoggingUtils._RESPONSE_API_USAGE_MAPPED_KEYS and value is not None: + extras[key] = value + return extras + @staticmethod def _transform_response_api_usage_to_chat_usage( usage_input: dict | ResponseAPIUsage | None, @@ -1089,17 +1130,10 @@ class ResponseAPILoggingUtils: audio_tokens=getattr(output_tokens_details, "audio_tokens", None), ) - usage_kwargs: dict[str, Any] = {} - # Keep xAI tool billing fields; dropped if we only pass token fields below. - if isinstance(usage_input, dict): - if usage_input.get("server_side_tool_usage_details") is not None: - usage_kwargs["server_side_tool_usage_details"] = usage_input["server_side_tool_usage_details"] - else: - details = getattr( - response_api_usage, "server_side_tool_usage_details", None - ) - if details is not None: - usage_kwargs["server_side_tool_usage_details"] = details + usage_kwargs: Final = ResponseAPILoggingUtils._extra_fields_from_response_api_usage( + usage_input=usage_input, + response_api_usage=response_api_usage, + ) chat_usage: Final = Usage( prompt_tokens=prompt_tokens, diff --git a/tests/test_litellm/responses/test_responses_utils.py b/tests/test_litellm/responses/test_responses_utils.py index 0141cf5d96a..8785ff1da43 100644 --- a/tests/test_litellm/responses/test_responses_utils.py +++ b/tests/test_litellm/responses/test_responses_utils.py @@ -278,6 +278,23 @@ class TestResponseAPILoggingUtils: and result.prompt_tokens_details.cached_tokens == 2 ) + def test_transform_response_api_usage_preserves_extra_usage_fields(self): + """Non-standard usage keys pass through for provider cost tracking.""" + usage = { + "input_tokens": 10, + "output_tokens": 5, + "total_tokens": 15, + "server_side_tool_usage_details": {"web_search_calls": 2}, + "num_server_side_tools_used": 2, + } + result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage( + usage + ) + assert getattr(result, "server_side_tool_usage_details", None) == { + "web_search_calls": 2 + } + assert getattr(result, "num_server_side_tools_used", None) == 2 + def test_transform_response_api_usage_with_none_values(self): """Test transformation handles None values properly""" # Setup