(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
This commit is contained in:
Krish Dholakia
2025-11-22 15:48:32 -08:00
committed by GitHub
parent c966c122ad
commit 270d23939e
19 changed files with 182 additions and 98 deletions
@@ -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
@@ -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,
@@ -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,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14 -14
View File
@@ -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": {
@@ -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:
+60 -19
View File
@@ -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)
@@ -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"