From 270d23939e6eaeb331afc16d3394f26fd6f99f92 Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Sat, 22 Nov 2025 15:48:32 -0800 Subject: [PATCH] (fix) litellm_logging.py: fix mcp tool call response logging + (fix) responses_bridge: remove unmapped param error mid-stream - allows gpt-5 web search to work via responses api in `.completion()` (#16946) * fix: fix getting mcp servers * fix(litellm_logging.py): handle list objects for final response in standard logging payload Fixes issue where mcp tool call response wouldn't show up * fix(litellm_responses_transformation/): remove invalid item error for unmapped objects - breaks stream and there's no real value to this as outside of a few of them, not all can be mapped to chat completions resolves error for web search calls via chat completions to responses api --- .../transformation.py | 35 ++++---- litellm/litellm_core_utils/litellm_logging.py | 4 +- .../proxy/_experimental/mcp_server/server.py | 1 + .../out/api-reference/index.html | 2 +- .../proxy/_experimental/out/logs/index.html | 2 +- .../_experimental/out/model-hub/index.html | 2 +- .../out/model_hub_table/index.html | 2 +- .../out/models-and-endpoints/index.html | 2 +- .../out/organizations/index.html | 2 +- .../_experimental/out/playground/index.html | 2 +- .../proxy/_experimental/out/teams/index.html | 2 +- .../_experimental/out/test-key/index.html | 2 +- .../proxy/_experimental/out/usage/index.html | 2 +- .../proxy/_experimental/out/users/index.html | 2 +- .../_experimental/out/virtual-keys/index.html | 2 +- litellm/proxy/db/db_spend_update_writer.py | 28 +++---- .../spend_tracking/spend_tracking_utils.py | 36 ++++----- tests/llm_translation/test_openai.py | 79 ++++++++++++++----- .../test_litellm_logging.py | 73 +++++++++++++---- 19 files changed, 182 insertions(+), 98 deletions(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index f39d1bfb5f..bc16215393 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -32,6 +32,12 @@ from litellm.types.llms.openai import ( ResponsesAPIOptionalRequestParams, ResponsesAPIStreamEvents, ) +from litellm.types.utils import ( + Delta, + GenericStreamingChunk, + ModelResponseStream, + StreamingChoices, +) if TYPE_CHECKING: from openai.types.responses import ResponseInputImageParam @@ -46,7 +52,6 @@ if TYPE_CHECKING: ChatCompletionThinkingBlock, OpenAIMessageContentListBlock, ) - from litellm.types.utils import GenericStreamingChunk, ModelResponseStream class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): @@ -606,11 +611,13 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): ResponsesAPIOptionalRequestParams.__annotations__.keys() ) # Also include params we handle specially - supported_responses_api_params.update({ - "previous_response_id", - "reasoning_effort", # We map this to "reasoning" - }) - + supported_responses_api_params.update( + { + "previous_response_id", + "reasoning_effort", # We map this to "reasoning" + } + ) + # Extract supported params from extra_body and merge into optional_params extra_body_copy = extra_body.copy() for key, value in extra_body_copy.items(): @@ -620,14 +627,16 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): return optional_params - def _map_reasoning_effort(self, reasoning_effort: Union[str, Dict[str, Any]]) -> Optional[Reasoning]: + def _map_reasoning_effort( + self, reasoning_effort: Union[str, Dict[str, Any]] + ) -> Optional[Reasoning]: # If dict is passed, convert it directly to Reasoning object if isinstance(reasoning_effort, dict): return Reasoning(**reasoning_effort) # type: ignore[typeddict-item] # If string is passed, map without summary (default) if reasoning_effort == "none": - return Reasoning(effort="none") # type: ignore + return Reasoning(effort="none") # type: ignore elif reasoning_effort == "high": return Reasoning(effort="high") elif reasoning_effort == "medium": @@ -746,12 +755,6 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): finish_reason="", usage=None, ) - elif output_item.get("type") == "message": - pass - elif output_item.get("type") == "reasoning": - pass - else: - raise ValueError(f"Chat provider: Invalid output_item {output_item}") elif event_type == "response.function_call_arguments.delta": content_part: Optional[str] = parsed_chunk.get("delta", None) if content_part: @@ -813,10 +816,6 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): return GenericStreamingChunk( finish_reason="stop", is_finished=True, usage=None, text="" ) - elif output_item.get("type") == "reasoning": - pass - else: - raise ValueError(f"Chat provider: Invalid output_item {output_item}") elif event_type == "response.output_text.delta": # Content part added to output diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index c7521986c7..9c4a7e3876 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -4358,12 +4358,12 @@ class StandardLoggingPayloadSetup: """ Get final response object after redacting the message input/output from logging """ - if response_obj is not None: + if response_obj: final_response_obj: Optional[Union[dict, str, list]] = response_obj elif isinstance(init_response_obj, list) or isinstance(init_response_obj, str): final_response_obj = init_response_obj else: - final_response_obj = None + final_response_obj = {} modified_final_response_obj = redact_message_input_output_from_logging( model_call_details=kwargs, diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 8d63485be0..412a6de005 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -1286,6 +1286,7 @@ if MCP_AVAILABLE: # Allow modifying the MCP tool call response before it is returned to the user ######################################################### if litellm_logging_obj: + litellm_logging_obj.post_call(original_response=response) end_time = datetime.now() await litellm_logging_obj.async_post_mcp_tool_call_hook( kwargs=litellm_logging_obj.model_call_details, diff --git a/litellm/proxy/_experimental/out/api-reference/index.html b/litellm/proxy/_experimental/out/api-reference/index.html index a952840f80..64972eabd7 100644 --- a/litellm/proxy/_experimental/out/api-reference/index.html +++ b/litellm/proxy/_experimental/out/api-reference/index.html @@ -1 +1 @@ -LiteLLM Dashboard \ No newline at end of file +LiteLLM Dashboard diff --git a/litellm/proxy/_experimental/out/logs/index.html b/litellm/proxy/_experimental/out/logs/index.html index b2cfe0a9f8..bc7490da24 100644 --- a/litellm/proxy/_experimental/out/logs/index.html +++ b/litellm/proxy/_experimental/out/logs/index.html @@ -1 +1 @@ -LiteLLM Dashboard \ No newline at end of file +LiteLLM Dashboard diff --git a/litellm/proxy/_experimental/out/model-hub/index.html b/litellm/proxy/_experimental/out/model-hub/index.html index 26d700ea5a..26536b0fd8 100644 --- a/litellm/proxy/_experimental/out/model-hub/index.html +++ b/litellm/proxy/_experimental/out/model-hub/index.html @@ -1 +1 @@ -LiteLLM Dashboard \ No newline at end of file +LiteLLM Dashboard diff --git a/litellm/proxy/_experimental/out/model_hub_table/index.html b/litellm/proxy/_experimental/out/model_hub_table/index.html index 7b64e2cc68..87f6fa630c 100644 --- a/litellm/proxy/_experimental/out/model_hub_table/index.html +++ b/litellm/proxy/_experimental/out/model_hub_table/index.html @@ -1 +1 @@ -LiteLLM Dashboard \ No newline at end of file +LiteLLM Dashboard diff --git a/litellm/proxy/_experimental/out/models-and-endpoints/index.html b/litellm/proxy/_experimental/out/models-and-endpoints/index.html index 632e0f0998..82ab8d9fb0 100644 --- a/litellm/proxy/_experimental/out/models-and-endpoints/index.html +++ b/litellm/proxy/_experimental/out/models-and-endpoints/index.html @@ -1 +1 @@ -LiteLLM Dashboard \ No newline at end of file +LiteLLM Dashboard diff --git a/litellm/proxy/_experimental/out/organizations/index.html b/litellm/proxy/_experimental/out/organizations/index.html index 4fcbc04efd..fe4127e8d4 100644 --- a/litellm/proxy/_experimental/out/organizations/index.html +++ b/litellm/proxy/_experimental/out/organizations/index.html @@ -1 +1 @@ -LiteLLM Dashboard \ No newline at end of file +LiteLLM Dashboard diff --git a/litellm/proxy/_experimental/out/playground/index.html b/litellm/proxy/_experimental/out/playground/index.html index 9fa8b4870f..bb3c5a4e67 100644 --- a/litellm/proxy/_experimental/out/playground/index.html +++ b/litellm/proxy/_experimental/out/playground/index.html @@ -1 +1 @@ -LiteLLM Dashboard \ No newline at end of file +LiteLLM Dashboard diff --git a/litellm/proxy/_experimental/out/teams/index.html b/litellm/proxy/_experimental/out/teams/index.html index 5632088399..d97e99608b 100644 --- a/litellm/proxy/_experimental/out/teams/index.html +++ b/litellm/proxy/_experimental/out/teams/index.html @@ -1 +1 @@ -LiteLLM Dashboard \ No newline at end of file +LiteLLM Dashboard diff --git a/litellm/proxy/_experimental/out/test-key/index.html b/litellm/proxy/_experimental/out/test-key/index.html index a04cdd9800..f14a275065 100644 --- a/litellm/proxy/_experimental/out/test-key/index.html +++ b/litellm/proxy/_experimental/out/test-key/index.html @@ -1 +1 @@ -LiteLLM Dashboard \ No newline at end of file +LiteLLM Dashboard diff --git a/litellm/proxy/_experimental/out/usage/index.html b/litellm/proxy/_experimental/out/usage/index.html index 5e3e1bf628..4d21084d21 100644 --- a/litellm/proxy/_experimental/out/usage/index.html +++ b/litellm/proxy/_experimental/out/usage/index.html @@ -1 +1 @@ -LiteLLM Dashboard \ No newline at end of file +LiteLLM Dashboard diff --git a/litellm/proxy/_experimental/out/users/index.html b/litellm/proxy/_experimental/out/users/index.html index 7c93a185e9..13562e690b 100644 --- a/litellm/proxy/_experimental/out/users/index.html +++ b/litellm/proxy/_experimental/out/users/index.html @@ -1 +1 @@ -LiteLLM Dashboard \ No newline at end of file +LiteLLM Dashboard diff --git a/litellm/proxy/_experimental/out/virtual-keys/index.html b/litellm/proxy/_experimental/out/virtual-keys/index.html index 326cb1bfce..ada2ef3238 100644 --- a/litellm/proxy/_experimental/out/virtual-keys/index.html +++ b/litellm/proxy/_experimental/out/virtual-keys/index.html @@ -1 +1 @@ -LiteLLM Dashboard \ No newline at end of file +LiteLLM Dashboard diff --git a/litellm/proxy/db/db_spend_update_writer.py b/litellm/proxy/db/db_spend_update_writer.py index 06b5301424..187d095a5b 100644 --- a/litellm/proxy/db/db_spend_update_writer.py +++ b/litellm/proxy/db/db_spend_update_writer.py @@ -8,9 +8,9 @@ Module responsible for import asyncio import json import os +import random import time import traceback -import random from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Union, cast, overload @@ -342,7 +342,7 @@ class DBSpendUpdateWriter: ): """ Update spend for all tags in the request. - + Args: response_cost: Cost of the request request_tags: JSON string of tags list e.g. '["prod-tag", "test-tag"]' @@ -863,7 +863,7 @@ class DBSpendUpdateWriter: ): """ Helper function to update spend for any entity type (team, org, tag, etc). - + Args: entity_name: Name of entity for logging (e.g., "Team", "Org", "Tag") transactions: Dictionary of {entity_id: response_cost} @@ -875,9 +875,7 @@ class DBSpendUpdateWriter: """ from litellm.proxy.utils import _raise_failed_update_spend_exception - verbose_proxy_logger.debug( - f"{entity_name} Spend transactions: {transactions}" - ) + verbose_proxy_logger.debug(f"{entity_name} Spend transactions: {transactions}") if transactions is not None and len(transactions.keys()) > 0: for i in range(n_retry_times + 1): start_time = time.time() @@ -1062,17 +1060,19 @@ class DBSpendUpdateWriter: # Add cache-related fields if they exist if "cache_read_input_tokens" in transaction: - common_data[ - "cache_read_input_tokens" - ] = transaction.get("cache_read_input_tokens", 0) + common_data["cache_read_input_tokens"] = ( + transaction.get("cache_read_input_tokens", 0) + ) if "cache_creation_input_tokens" in transaction: - common_data[ - "cache_creation_input_tokens" - ] = transaction.get("cache_creation_input_tokens", 0) + common_data["cache_creation_input_tokens"] = ( + transaction.get("cache_creation_input_tokens", 0) + ) if entity_type == "tag" and "request_id" in transaction: - common_data["request_id"] = transaction.get("request_id") - + common_data["request_id"] = transaction.get( + "request_id" + ) + # Create update data structure update_data = { "prompt_tokens": { diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index 3f85de855d..08e5624885 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -95,9 +95,9 @@ def _get_spend_logs_metadata( clean_metadata["applied_guardrails"] = applied_guardrails clean_metadata["batch_models"] = batch_models clean_metadata["mcp_tool_call_metadata"] = mcp_tool_call_metadata - clean_metadata[ - "vector_store_request_metadata" - ] = _get_vector_store_request_for_spend_logs_payload(vector_store_request_metadata) + clean_metadata["vector_store_request_metadata"] = ( + _get_vector_store_request_for_spend_logs_payload(vector_store_request_metadata) + ) clean_metadata["guardrail_information"] = guardrail_information clean_metadata["usage_object"] = usage_object clean_metadata["model_map_information"] = model_map_information @@ -142,28 +142,26 @@ def get_spend_logs_id( return id -def _extract_usage_for_ocr_call( - response_obj: Any, response_obj_dict: dict -) -> dict: +def _extract_usage_for_ocr_call(response_obj: Any, response_obj_dict: dict) -> dict: """ Extract usage information for OCR/AOCR calls. - + OCR responses use usage_info (with pages_processed) instead of token-based usage. - + Args: response_obj: The raw response object (can be dict, BaseModel, or other) response_obj_dict: Dictionary representation of the response object - + Returns: A dict with prompt_tokens=0, completion_tokens=0, total_tokens=0, and pages_processed from usage_info. """ usage_info = None - + # Try to extract usage_info from dict if isinstance(response_obj_dict, dict) and "usage_info" in response_obj_dict: usage_info = response_obj_dict.get("usage_info") - + # Try to extract usage_info from object attributes if not found in dict if not usage_info and hasattr(response_obj, "usage_info"): usage_info = response_obj.usage_info @@ -171,7 +169,7 @@ def _extract_usage_for_ocr_call( usage_info = usage_info.model_dump() elif hasattr(usage_info, "__dict__"): usage_info = vars(usage_info) - + # For OCR, we track pages instead of tokens if usage_info is not None: # Handle dict or object with attributes @@ -193,7 +191,7 @@ def _extract_usage_for_ocr_call( "prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0, - "pages_processed": 0 + "pages_processed": 0, } else: return {} @@ -206,17 +204,18 @@ def get_logging_payload( # noqa: PLR0915 if kwargs is None: kwargs = {} - if response_obj is None or ( - not isinstance(response_obj, BaseModel) and not isinstance(response_obj, dict) - ): + + if response_obj is None: response_obj = {} + elif not isinstance(response_obj, BaseModel) and not isinstance(response_obj, dict): + response_obj = {"result": str(response_obj)} # standardize this function to be used across, s3, dynamoDB, langfuse logging litellm_params = kwargs.get("litellm_params", {}) metadata = get_litellm_metadata_from_kwargs(kwargs) completion_start_time = kwargs.get("completion_start_time", end_time) call_type = kwargs.get("call_type") cache_hit = kwargs.get("cache_hit", False) - + # Convert response_obj to dict first if isinstance(response_obj, dict): response_obj_dict = response_obj @@ -224,7 +223,7 @@ def get_logging_payload( # noqa: PLR0915 response_obj_dict = response_obj.model_dump() else: response_obj_dict = {} - + # Handle OCR responses which use usage_info instead of usage if call_type in ["ocr", "aocr"]: usage = _extract_usage_for_ocr_call(response_obj, response_obj_dict) @@ -658,6 +657,7 @@ def _get_response_for_spend_logs_payload( sanitized_wrapper = _sanitize_request_body_for_spend_logs_payload( {"response": response_obj} ) + sanitized_response = sanitized_wrapper.get("response", response_obj) if sanitized_response is None: diff --git a/tests/llm_translation/test_openai.py b/tests/llm_translation/test_openai.py index ba1d9e6ac2..10aab93051 100644 --- a/tests/llm_translation/test_openai.py +++ b/tests/llm_translation/test_openai.py @@ -348,7 +348,11 @@ def test_openai_image_generation_forwards_organization(mock_get_openai_client): return { "created": 123, "data": [{"url": "http://example.com/image.png"}], - "usage": {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + }, } return _Resp() @@ -797,7 +801,9 @@ async def test_openai_service_tier_parameter(): # Verify the request contains the service_tier parameter assert "service_tier" in request_body, "service_tier should be in request body" # Verify service_tier is correctly sent to the API - assert request_body["service_tier"] == "priority", "service_tier should be 'priority'" + assert ( + request_body["service_tier"] == "priority" + ), "service_tier should be 'priority'" def test_openai_service_tier_parameter_sync(): @@ -826,7 +832,9 @@ def test_openai_service_tier_parameter_sync(): # Verify the request contains the service_tier parameter assert "service_tier" in request_body, "service_tier should be in request body" # Verify service_tier is correctly sent to the API - assert request_body["service_tier"] == "priority", "service_tier should be 'priority'" + assert ( + request_body["service_tier"] == "priority" + ), "service_tier should be 'priority'" def test_gpt_5_reasoning_streaming(): @@ -1358,7 +1366,7 @@ async def test_streaming_tool_calls_with_n_greater_than_1(model): """ Test that the index field in a choice object is correctly populated when using streaming mode with n>1 and tool calls. - + Regression test for: https://github.com/BerriAI/litellm/issues/8977 """ tools = [ @@ -1386,7 +1394,7 @@ async def test_streaming_tool_calls_with_n_greater_than_1(model): }, } ] - + response = litellm.completion( model=model, messages=[ @@ -1399,20 +1407,30 @@ async def test_streaming_tool_calls_with_n_greater_than_1(model): stream=True, n=3, ) - + # Collect all chunks and their indices indices_seen = [] for chunk in response: - assert len(chunk.choices) == 1, "Each streaming chunk should have exactly 1 choice" - assert hasattr(chunk.choices[0], "index"), "Choice should have an index attribute" + assert ( + len(chunk.choices) == 1 + ), "Each streaming chunk should have exactly 1 choice" + assert hasattr( + chunk.choices[0], "index" + ), "Choice should have an index attribute" index = chunk.choices[0].index indices_seen.append(index) - + # Verify that we got chunks with different indices (0, 1, 2 for n=3) unique_indices = set(indices_seen) - assert unique_indices == {0, 1, 2}, f"Should have indices 0, 1, 2 for n=3, got {unique_indices}" - - print(f"✓ Test passed: streaming with n=3 and tool calls correctly populates index field") + assert unique_indices == { + 0, + 1, + 2, + }, f"Should have indices 0, 1, 2 for n=3, got {unique_indices}" + + print( + f"✓ Test passed: streaming with n=3 and tool calls correctly populates index field" + ) print(f" Indices seen: {indices_seen}") print(f" Unique indices: {unique_indices}") @@ -1436,19 +1454,42 @@ async def test_streaming_content_with_n_greater_than_1(model): n=2, max_tokens=10, ) - + # Collect all chunks and their indices indices_seen = [] for chunk in response: - assert len(chunk.choices) == 1, "Each streaming chunk should have exactly 1 choice" - assert hasattr(chunk.choices[0], "index"), "Choice should have an index attribute" + assert ( + len(chunk.choices) == 1 + ), "Each streaming chunk should have exactly 1 choice" + assert hasattr( + chunk.choices[0], "index" + ), "Choice should have an index attribute" index = chunk.choices[0].index indices_seen.append(index) - + # Verify that we got chunks with different indices (0, 1 for n=2) unique_indices = set(indices_seen) - assert unique_indices == {0, 1}, f"Should have indices 0, 1 for n=2, got {unique_indices}" - - print(f"✓ Test passed: streaming with n=2 and regular content correctly populates index field") + assert unique_indices == { + 0, + 1, + }, f"Should have indices 0, 1 for n=2, got {unique_indices}" + + print( + f"✓ Test passed: streaming with n=2 and regular content correctly populates index field" + ) print(f" Indices seen: {indices_seen}") print(f" Unique indices: {unique_indices}") + + +def test_gpt_5_web_search(): + response = litellm.completion( + model="openai/responses/gpt-5", + messages=[{"role": "user", "content": "get price of nvda"}], + stream=True, + temperature=1, + max_tokens=8192, + tools=[{"type": "web_search"}], + ) + + for chunk in response: + print("chunk: ", chunk) 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 64f3a652ba..477fd1396f 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -568,10 +568,11 @@ async def test_e2e_generate_cold_storage_object_key_successful(): start_time = datetime(2025, 1, 15, 10, 30, 45, 123456, timezone.utc) response_id = "chatcmpl-test-12345" team_alias = "test-team" - - with patch("litellm.cold_storage_custom_logger", return_value="s3"), \ - patch("litellm.integrations.s3.get_s3_object_key") as mock_get_s3_key: - + + with patch("litellm.cold_storage_custom_logger", return_value="s3"), patch( + "litellm.integrations.s3.get_s3_object_key" + ) as mock_get_s3_key: + # Mock the S3 object key generation to return a predictable result mock_get_s3_key.return_value = ( "2025-01-15/time-10-30-45-123456_chatcmpl-test-12345.json" @@ -613,11 +614,13 @@ async def test_e2e_generate_cold_storage_object_key_with_custom_logger_s3_path() # Create mock custom logger with s3_path mock_custom_logger = MagicMock() mock_custom_logger.s3_path = "storage" - - with patch("litellm.cold_storage_custom_logger", "s3_v2"), \ - patch("litellm.logging_callback_manager.get_active_custom_logger_for_callback_name") as mock_get_logger, \ - patch("litellm.integrations.s3.get_s3_object_key") as mock_get_s3_key: - + + with patch("litellm.cold_storage_custom_logger", "s3_v2"), patch( + "litellm.logging_callback_manager.get_active_custom_logger_for_callback_name" + ) as mock_get_logger, patch( + "litellm.integrations.s3.get_s3_object_key" + ) as mock_get_s3_key: + # Setup mocks mock_get_logger.return_value = mock_custom_logger mock_get_s3_key.return_value = ( @@ -663,11 +666,13 @@ async def test_e2e_generate_cold_storage_object_key_with_logger_no_s3_path(): # Create mock custom logger without s3_path mock_custom_logger = MagicMock() mock_custom_logger.s3_path = None # or could be missing attribute - - with patch("litellm.cold_storage_custom_logger", "s3_v2"), \ - patch("litellm.logging_callback_manager.get_active_custom_logger_for_callback_name") as mock_get_logger, \ - patch("litellm.integrations.s3.get_s3_object_key") as mock_get_s3_key: - + + with patch("litellm.cold_storage_custom_logger", "s3_v2"), patch( + "litellm.logging_callback_manager.get_active_custom_logger_for_callback_name" + ) as mock_get_logger, patch( + "litellm.integrations.s3.get_s3_object_key" + ) as mock_get_s3_key: + # Setup mocks mock_get_logger.return_value = mock_custom_logger mock_get_s3_key.return_value = ( @@ -708,7 +713,7 @@ async def test_e2e_generate_cold_storage_object_key_not_configured(): team_alias = "another-team" # Use patch to ensure test isolation - with patch.object(litellm, 'cold_storage_custom_logger', None): + with patch.object(litellm, "cold_storage_custom_logger", None): # Call the function result = StandardLoggingPayloadSetup._generate_cold_storage_object_key( start_time=start_time, response_id=response_id, team_alias=team_alias @@ -716,3 +721,41 @@ async def test_e2e_generate_cold_storage_object_key_not_configured(): # Verify the result is None when cold storage is not configured assert result is None + + +def test_get_final_response_obj_with_empty_response_obj_and_list_init(): + """ + Test get_final_response_obj when response_obj is empty dict and init_response_obj is a list. + + When response_obj is empty (falsy), the method should return init_response_obj if it's a list. + """ + from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup + + # Create test objects + class TestObject1: + def __init__(self): + self.name = "Object1" + + class TestObject2: + def __init__(self): + self.name = "Object2" + + obj1 = TestObject1() + obj2 = TestObject2() + + # Test case: empty response_obj, list init_response_obj + response_obj = {} + init_response_obj = [obj1, obj2] + kwargs = {} + + # Call the method + result = StandardLoggingPayloadSetup.get_final_response_obj( + response_obj=response_obj, init_response_obj=init_response_obj, kwargs=kwargs + ) + + # Verify the result + assert result == [obj1, obj2] + assert result is init_response_obj # Should be the exact same list object + assert len(result) == 2 + assert result[0].name == "Object1" + assert result[1].name == "Object2"