mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-19 18:19:21 +00:00
Merge pull request #23509 from michelligabriele/fix/pass-through-duplicate-failure-logs
fix(proxy): prevent duplicate callback logs for pass-through endpoint failures
This commit is contained in:
@@ -2956,7 +2956,10 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||
callback_func=callback,
|
||||
)
|
||||
if (
|
||||
isinstance(callback, CustomLogger) and is_sync_request
|
||||
isinstance(callback, CustomLogger)
|
||||
and is_sync_request
|
||||
and self.call_type
|
||||
!= CallTypes.pass_through.value
|
||||
): # custom logger class
|
||||
callback.log_failure_event(
|
||||
start_time=start_time,
|
||||
|
||||
@@ -961,6 +961,8 @@ async def pass_through_request( # noqa: PLR0915
|
||||
if kwargs:
|
||||
for key, value in kwargs.items():
|
||||
request_payload[key] = value
|
||||
if logging_obj is not None:
|
||||
request_payload["litellm_logging_obj"] = logging_obj
|
||||
|
||||
if (
|
||||
"model" not in request_payload
|
||||
@@ -1703,6 +1705,8 @@ async def websocket_passthrough_request( # noqa: PLR0915
|
||||
if kwargs:
|
||||
for key, value in kwargs.items():
|
||||
request_payload[key] = value
|
||||
if logging_obj is not None:
|
||||
request_payload["litellm_logging_obj"] = logging_obj
|
||||
|
||||
# Log the connection failure using the same pattern as HTTP
|
||||
await proxy_logging_obj.post_call_failure_hook(
|
||||
@@ -1729,6 +1733,8 @@ async def websocket_passthrough_request( # noqa: PLR0915
|
||||
if kwargs:
|
||||
for key, value in kwargs.items():
|
||||
request_payload[key] = value
|
||||
if logging_obj is not None:
|
||||
request_payload["litellm_logging_obj"] = logging_obj
|
||||
|
||||
# Log the unexpected error using the same pattern as HTTP
|
||||
await proxy_logging_obj.post_call_failure_hook(
|
||||
|
||||
+15
-4
@@ -1729,6 +1729,9 @@ class ProxyLogging:
|
||||
original_exception=original_exception,
|
||||
)
|
||||
|
||||
# Remove before callbacks iterate — not serialisable
|
||||
request_data.pop("litellm_logging_obj", None)
|
||||
|
||||
# Track the first HTTPException returned or raised by any callback
|
||||
transformed_exception: Optional[HTTPException] = None
|
||||
|
||||
@@ -1849,7 +1852,7 @@ class ProxyLogging:
|
||||
for k, v in request_data.items():
|
||||
if k in litellm_param_keys:
|
||||
_litellm_params[k] = v
|
||||
elif k != "model" and k != "user":
|
||||
elif k not in ("model", "user", "litellm_logging_obj"):
|
||||
_optional_params[k] = v
|
||||
|
||||
litellm_logging_obj.update_environment_variables(
|
||||
@@ -1865,15 +1868,23 @@ class ProxyLogging:
|
||||
):
|
||||
input = request_data["messages"]
|
||||
litellm_logging_obj.model_call_details["messages"] = input
|
||||
litellm_logging_obj.call_type = CallTypes.acompletion.value
|
||||
if litellm_logging_obj.call_type != CallTypes.pass_through.value:
|
||||
litellm_logging_obj.call_type = CallTypes.acompletion.value
|
||||
elif "prompt" in request_data and isinstance(request_data["prompt"], str):
|
||||
input = request_data["prompt"]
|
||||
litellm_logging_obj.model_call_details["prompt"] = input
|
||||
litellm_logging_obj.call_type = CallTypes.atext_completion.value
|
||||
if litellm_logging_obj.call_type != CallTypes.pass_through.value:
|
||||
litellm_logging_obj.call_type = CallTypes.atext_completion.value
|
||||
elif "input" in request_data and isinstance(request_data["input"], list):
|
||||
input = request_data["input"]
|
||||
litellm_logging_obj.model_call_details["input"] = input
|
||||
litellm_logging_obj.call_type = CallTypes.aembedding.value
|
||||
if litellm_logging_obj.call_type != CallTypes.pass_through.value:
|
||||
litellm_logging_obj.call_type = CallTypes.aembedding.value
|
||||
# Pass-through endpoints are logged via the callback loop's
|
||||
# async_post_call_failure_hook — skip pre_call and failure handlers.
|
||||
if litellm_logging_obj.call_type == CallTypes.pass_through.value:
|
||||
return
|
||||
|
||||
litellm_logging_obj.pre_call(
|
||||
input=input,
|
||||
api_key="",
|
||||
|
||||
@@ -2385,3 +2385,134 @@ async def test_during_call_hook_parallel_execution_with_error():
|
||||
assert "Guardrail violation detected!" in str(exc_info.value)
|
||||
finally:
|
||||
litellm.callbacks = original_callbacks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_logging_proxy_only_error_preserves_pass_through_call_type():
|
||||
"""Ensure _handle_logging_proxy_only_error does not overwrite call_type
|
||||
when the logging object is already marked as pass_through_endpoint.
|
||||
"""
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
from litellm.types.utils import CallTypes
|
||||
|
||||
logging_obj = Logging(
|
||||
model="unknown",
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
stream=False,
|
||||
call_type="pass_through_endpoint",
|
||||
start_time=datetime.now(),
|
||||
litellm_call_id="test-call-id",
|
||||
function_id="test-function-id",
|
||||
)
|
||||
|
||||
request_data = {
|
||||
"litellm_logging_obj": logging_obj,
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
"model": "claude-3-5-sonnet",
|
||||
}
|
||||
|
||||
cache = DualCache()
|
||||
proxy_logging = ProxyLogging(user_api_key_cache=cache)
|
||||
|
||||
with patch.object(logging_obj, "async_failure_handler", new_callable=AsyncMock):
|
||||
with patch.object(logging_obj, "failure_handler"):
|
||||
await proxy_logging._handle_logging_proxy_only_error(
|
||||
request_data=request_data,
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
api_key="test_key", token="test_token"
|
||||
),
|
||||
original_exception=Exception("test error"),
|
||||
)
|
||||
|
||||
assert logging_obj.call_type == CallTypes.pass_through.value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_litellm_logging_obj_excluded_from_optional_params():
|
||||
"""Ensure litellm_logging_obj is excluded from _optional_params to prevent
|
||||
circular references in model_call_details.
|
||||
"""
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
|
||||
logging_obj = Logging(
|
||||
model="unknown",
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
stream=False,
|
||||
call_type="pass_through_endpoint",
|
||||
start_time=datetime.now(),
|
||||
litellm_call_id="test-call-id",
|
||||
function_id="test-function-id",
|
||||
)
|
||||
|
||||
request_data = {
|
||||
"litellm_logging_obj": logging_obj,
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
"model": "claude-3-5-sonnet",
|
||||
}
|
||||
|
||||
cache = DualCache()
|
||||
proxy_logging = ProxyLogging(user_api_key_cache=cache)
|
||||
|
||||
with patch.object(logging_obj, "async_failure_handler", new_callable=AsyncMock):
|
||||
with patch.object(logging_obj, "failure_handler"):
|
||||
await proxy_logging._handle_logging_proxy_only_error(
|
||||
request_data=request_data,
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
api_key="test_key", token="test_token"
|
||||
),
|
||||
original_exception=Exception("test error"),
|
||||
)
|
||||
|
||||
assert "litellm_logging_obj" not in logging_obj.model_call_details
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_logging_proxy_only_error_skips_handlers_for_pass_through():
|
||||
"""Ensure _handle_logging_proxy_only_error skips async_failure_handler and
|
||||
failure_handler for pass-through endpoint errors, so only
|
||||
async_post_call_failure_hook fires (avoiding duplicate logs).
|
||||
|
||||
Regression test for duplicate Datadog/Arize logs on pass-through failures.
|
||||
"""
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
from litellm.types.utils import CallTypes
|
||||
|
||||
logging_obj = Logging(
|
||||
model="unknown",
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
stream=False,
|
||||
call_type="pass_through_endpoint",
|
||||
start_time=datetime.now(),
|
||||
litellm_call_id="test-call-id",
|
||||
function_id="test-function-id",
|
||||
)
|
||||
|
||||
cache = DualCache()
|
||||
proxy_logging = ProxyLogging(user_api_key_cache=cache)
|
||||
|
||||
request_data = {
|
||||
"litellm_logging_obj": logging_obj,
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
"model": "claude-3-5-sonnet",
|
||||
}
|
||||
|
||||
with patch.object(logging_obj, "async_failure_handler", new_callable=AsyncMock) as mock_async:
|
||||
with patch.object(logging_obj, "failure_handler") as mock_sync:
|
||||
await proxy_logging._handle_logging_proxy_only_error(
|
||||
request_data=request_data,
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
api_key="test_key", token="test_token"
|
||||
),
|
||||
original_exception=Exception("test error"),
|
||||
)
|
||||
|
||||
# Neither handler should fire for pass-through requests
|
||||
mock_async.assert_not_called()
|
||||
mock_sync.assert_not_called()
|
||||
assert logging_obj.call_type == CallTypes.pass_through.value
|
||||
|
||||
@@ -1969,3 +1969,68 @@ def test_function_setup_empty_metadata_falls_back_to_litellm_metadata():
|
||||
assert metadata is not None
|
||||
assert metadata.get("user_api_key_hash") == "sk-hashed-empty-test"
|
||||
assert metadata.get("user_api_key_team_id") == "team-empty-test"
|
||||
|
||||
|
||||
def test_failure_handler_skips_sync_callbacks_for_pass_through_requests(logging_obj):
|
||||
"""Ensure sync failure callbacks are skipped for pass-through endpoint requests.
|
||||
|
||||
Regression test for duplicate Datadog/Arize logs on pass-through endpoint failures.
|
||||
The async_failure_handler fires async_log_failure_event; the sync failure_handler
|
||||
must NOT also fire log_failure_event for pass-through requests.
|
||||
"""
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.types.utils import CallTypes
|
||||
|
||||
class DummyLogger(CustomLogger):
|
||||
pass
|
||||
|
||||
logging_obj.call_type = CallTypes.pass_through.value
|
||||
logging_obj.stream = False
|
||||
logging_obj.model_call_details["litellm_params"] = {}
|
||||
logging_obj.litellm_params = {}
|
||||
|
||||
dummy_logger = DummyLogger()
|
||||
dummy_logger.log_failure_event = MagicMock()
|
||||
|
||||
with patch.object(
|
||||
logging_obj,
|
||||
"get_combined_callback_list",
|
||||
return_value=[dummy_logger],
|
||||
):
|
||||
logging_obj.failure_handler(
|
||||
exception=Exception("test error"),
|
||||
traceback_exception="",
|
||||
)
|
||||
|
||||
dummy_logger.log_failure_event.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("call_type", ["completion", "acompletion"])
|
||||
def test_failure_handler_runs_sync_callbacks_for_non_pass_through_requests(
|
||||
logging_obj, call_type
|
||||
):
|
||||
"""Ensure sync failure callbacks still fire for normal (non-pass-through) requests."""
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
|
||||
class DummyLogger(CustomLogger):
|
||||
pass
|
||||
|
||||
logging_obj.call_type = call_type
|
||||
logging_obj.stream = False
|
||||
logging_obj.model_call_details["litellm_params"] = {}
|
||||
logging_obj.litellm_params = {}
|
||||
|
||||
dummy_logger = DummyLogger()
|
||||
dummy_logger.log_failure_event = MagicMock()
|
||||
|
||||
with patch.object(
|
||||
logging_obj,
|
||||
"get_combined_callback_list",
|
||||
return_value=[dummy_logger],
|
||||
):
|
||||
logging_obj.failure_handler(
|
||||
exception=Exception("test error"),
|
||||
traceback_exception="",
|
||||
)
|
||||
|
||||
dummy_logger.log_failure_event.assert_called_once()
|
||||
|
||||
Reference in New Issue
Block a user