litellm/tests/litellm_utils_tests/test_logging_callback_manager.py
yuneng-jiang 6a0d03914c
test: drop the cwd-relative sys.path.insert calls from the test suite (#37802)
* test: drop the cwd-relative sys.path.insert calls from the test suite

TQ003 stands at 1,077 across 1,058 files, and 1,015 of them are the same shape:
sys.path.insert(0, os.path.abspath("../..")) and its deeper siblings. The
argument resolves against the working directory rather than the file, so from
the repo root, where every job runs pytest, it inserts the directory two levels
above the checkout. It has never pointed at litellm. The package is installed
into the environment anyway, which is what actually makes the import work, and
what the rule's message has said all along.

Removing them leaves 1,634 imports of sys and os with no remaining reference,
and those go too, except where another test module imports the name back out of
the file. The rest of TQ003 is 62 call sites that resolve against __file__ or a
variable, which are a different question and are left alone.

Collection is identical either way: 45,871 tests and the same 51 pre-existing
collection errors before and after, and ruff reports no new undefined name.

* test: drop the duplicate imports the sys.path sweep exposed to F811

* test(pre-call-utils): restore the os import the new bedrock tests need
2026-08-22 09:25:58 -07:00

424 lines
15 KiB
Python

import json
import os
import time
from datetime import datetime
from unittest.mock import AsyncMock, patch, MagicMock
import pytest
import litellm
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.logging_callback_manager import LoggingCallbackManager
from litellm.integrations.langfuse.langfuse_prompt_management import (
LangfusePromptManagement,
)
from litellm.integrations.opentelemetry import OpenTelemetry
# Test fixtures
@pytest.fixture
def callback_manager():
manager = LoggingCallbackManager()
# Reset callbacks before each test
manager._reset_all_callbacks()
return manager
@pytest.fixture
def mock_custom_logger():
class TestLogger(CustomLogger):
def log_success_event(self, kwargs, response_obj, start_time, end_time):
pass
return TestLogger()
# Test cases
def test_add_string_callback():
"""
Test adding a string callback to litellm.callbacks - only 1 instance of the string callback should be added
"""
manager = LoggingCallbackManager()
test_callback = "test_callback"
# Add string callback
manager.add_litellm_callback(test_callback)
assert test_callback in litellm.callbacks
# Test duplicate prevention
manager.add_litellm_callback(test_callback)
assert litellm.callbacks.count(test_callback) == 1
def test_duplicate_langfuse_logger_test():
manager = LoggingCallbackManager()
for _ in range(10):
langfuse_logger = LangfusePromptManagement()
manager.add_litellm_success_callback(langfuse_logger)
print("litellm.success_callback: ", litellm.success_callback)
assert len(litellm.success_callback) == 1
def test_duplicate_multiple_loggers_test():
manager = LoggingCallbackManager()
for _ in range(10):
langfuse_logger = LangfusePromptManagement()
otel_logger = OpenTelemetry()
manager.add_litellm_success_callback(langfuse_logger)
manager.add_litellm_success_callback(otel_logger)
print("litellm.success_callback: ", litellm.success_callback)
assert len(litellm.success_callback) == 2
# Check exactly one instance of each logger type
langfuse_count = sum(
1
for callback in litellm.success_callback
if isinstance(callback, LangfusePromptManagement)
)
otel_count = sum(
1
for callback in litellm.success_callback
if isinstance(callback, OpenTelemetry)
)
assert (
langfuse_count == 1
), "Should have exactly one LangfusePromptManagement instance"
assert otel_count == 1, "Should have exactly one OpenTelemetry instance"
def test_add_function_callback():
manager = LoggingCallbackManager()
def test_func(kwargs):
pass
# Add function callback
manager.add_litellm_callback(test_func)
assert test_func in litellm.callbacks
# Test duplicate prevention
manager.add_litellm_callback(test_func)
assert litellm.callbacks.count(test_func) == 1
def test_add_custom_logger(mock_custom_logger):
manager = LoggingCallbackManager()
# Add custom logger
manager.add_litellm_callback(mock_custom_logger)
assert mock_custom_logger in litellm.callbacks
def test_add_multiple_callback_types(mock_custom_logger):
manager = LoggingCallbackManager()
def test_func(kwargs):
pass
string_callback = "test_callback"
# Add different types of callbacks
manager.add_litellm_callback(string_callback)
manager.add_litellm_callback(test_func)
manager.add_litellm_callback(mock_custom_logger)
assert string_callback in litellm.callbacks
assert test_func in litellm.callbacks
assert mock_custom_logger in litellm.callbacks
assert len(litellm.callbacks) == 3
def test_success_failure_callbacks():
manager = LoggingCallbackManager()
success_callback = "success_callback"
failure_callback = "failure_callback"
# Add callbacks
manager.add_litellm_success_callback(success_callback)
manager.add_litellm_failure_callback(failure_callback)
assert success_callback in litellm.success_callback
assert failure_callback in litellm.failure_callback
def test_async_callbacks():
manager = LoggingCallbackManager()
async_success = "async_success"
async_failure = "async_failure"
# Add async callbacks
manager.add_litellm_async_success_callback(async_success)
manager.add_litellm_async_failure_callback(async_failure)
assert async_success in litellm._async_success_callback
assert async_failure in litellm._async_failure_callback
def test_remove_callback_from_list_by_object():
manager = LoggingCallbackManager()
# Reset all callbacks
manager._reset_all_callbacks()
def TestObject():
def __init__(self):
manager.add_litellm_callback(self.callback)
manager.add_litellm_success_callback(self.callback)
manager.add_litellm_failure_callback(self.callback)
manager.add_litellm_async_success_callback(self.callback)
manager.add_litellm_async_failure_callback(self.callback)
def callback(self):
pass
obj = TestObject()
manager.remove_callback_from_list_by_object(litellm.callbacks, obj)
manager.remove_callback_from_list_by_object(litellm.success_callback, obj)
manager.remove_callback_from_list_by_object(litellm.failure_callback, obj)
manager.remove_callback_from_list_by_object(litellm._async_success_callback, obj)
manager.remove_callback_from_list_by_object(litellm._async_failure_callback, obj)
# Verify all callback lists are empty
assert len(litellm.callbacks) == 0
assert len(litellm.success_callback) == 0
assert len(litellm.failure_callback) == 0
assert len(litellm._async_success_callback) == 0
assert len(litellm._async_failure_callback) == 0
def test_remove_callback_from_all_lists():
manager = LoggingCallbackManager()
manager._reset_all_callbacks()
class TestLogger(CustomLogger):
pass
obj = TestLogger()
manager.add_litellm_callback(obj)
manager.add_litellm_success_callback(obj)
manager.add_litellm_failure_callback(obj)
manager.add_litellm_async_success_callback(obj)
manager.add_litellm_async_failure_callback(obj)
manager.remove_callback_from_all_lists(obj)
assert obj not in litellm.callbacks
assert obj not in litellm.success_callback
assert obj not in litellm.failure_callback
assert obj not in litellm._async_success_callback
assert obj not in litellm._async_failure_callback
def test_reset_callbacks(callback_manager):
# Add various callbacks
callback_manager.add_litellm_callback("test")
callback_manager.add_litellm_success_callback("success")
callback_manager.add_litellm_failure_callback("failure")
callback_manager.add_litellm_async_success_callback("async_success")
callback_manager.add_litellm_async_failure_callback("async_failure")
# Reset all callbacks
callback_manager._reset_all_callbacks()
# Verify all callback lists are empty
assert len(litellm.callbacks) == 0
assert len(litellm.success_callback) == 0
assert len(litellm.failure_callback) == 0
assert len(litellm._async_success_callback) == 0
assert len(litellm._async_failure_callback) == 0
@pytest.mark.asyncio
async def test_slack_alerting_callback_registration(callback_manager):
"""
Test that litellm callbacks are correctly registered for slack alerting
when outage_alerts or region_outage_alerts are enabled
"""
from litellm.caching.caching import DualCache
from litellm.proxy.utils import ProxyLogging
from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting
from unittest.mock import patch
# Mock the async HTTP handler
with patch(
"litellm.integrations.SlackAlerting.slack_alerting.get_async_httpx_client"
) as mock_http:
mock_http.return_value = AsyncMock()
# Create a fresh ProxyLogging instance
proxy_logging = ProxyLogging(user_api_key_cache=DualCache())
# Test 1: No callbacks should be added when alerting is None
proxy_logging.update_values(
alerting=None, alert_types=["outage_alerts", "region_outage_alerts"]
)
assert len(litellm.callbacks) == 0
# Test 2: Callbacks should be added when slack alerting is enabled with outage alerts
proxy_logging.update_values(alerting=["slack"], alert_types=["outage_alerts"])
assert len(litellm.callbacks) == 1
assert isinstance(litellm.callbacks[0], SlackAlerting)
# Test 3: Callbacks should be added when slack alerting is enabled with region outage alerts
callback_manager._reset_all_callbacks() # Reset callbacks
proxy_logging.update_values(
alerting=["slack"], alert_types=["region_outage_alerts"]
)
assert len(litellm.callbacks) == 1
assert isinstance(litellm.callbacks[0], SlackAlerting)
# Test 4: No callbacks should be added for other alert types
callback_manager._reset_all_callbacks() # Reset callbacks
proxy_logging.update_values(
alerting=["slack"], alert_types=["budget_alerts"] # Some other alert type
)
assert len(litellm.callbacks) == 0
# Test 5: Both success and regular callbacks should be added
callback_manager._reset_all_callbacks() # Reset callbacks
proxy_logging.update_values(alerting=["slack"], alert_types=["outage_alerts"])
assert len(litellm.callbacks) == 1 # Regular callback for outage alerts
assert isinstance(litellm.callbacks[0], SlackAlerting)
# response_taking_too_long_callback is async, so it should be in the async success callback list
response_taking_too_long_callback = (
proxy_logging.slack_alerting_instance.response_taking_too_long_callback
)
assert len(litellm._async_success_callback) == 1
assert litellm._async_success_callback[0] == response_taking_too_long_callback
# Cleanup
callback_manager._reset_all_callbacks()
@pytest.mark.asyncio
async def test_generic_api_compatible_callbacks_json():
"""
Test that callbacks defined in generic_api_compatible_callbacks.json
are properly loaded and initialized by _add_custom_callback_generic_api_str
"""
from litellm.integrations.generic_api.generic_api_callback import GenericAPILogger
# Mock environment variable for SumoLogic webhook URL
test_sumologic_url = "https://collectors.sumologic.com/receiver/v1/http/test123"
with patch.dict(os.environ, {"SUMOLOGIC_WEBHOOK_URL": test_sumologic_url}):
# Test that sumologic callback is recognized from JSON file
result = LoggingCallbackManager._add_custom_callback_generic_api_str(
"sumologic"
)
# Verify a GenericAPILogger instance is returned
assert isinstance(
result, GenericAPILogger
), "Should return GenericAPILogger instance for sumologic callback"
# Verify the endpoint is correctly loaded from environment variable
assert (
result.endpoint == test_sumologic_url
), f"Endpoint should be {test_sumologic_url}"
# Verify headers only contain Content-Type (no Authorization for SumoLogic)
assert "Content-Type" in result.headers, "Should have Content-Type header"
assert (
result.headers["Content-Type"] == "application/json"
), "Content-Type should be application/json"
assert (
"Authorization" not in result.headers
), "Should not have Authorization header for SumoLogic"
@pytest.mark.asyncio
async def test_generic_api_compatible_callbacks_json_rubrik():
"""
Test the rubrik callback from generic_api_compatible_callbacks.json
which requires both API key and webhook URL
"""
from litellm.integrations.generic_api.generic_api_callback import GenericAPILogger
# Mock environment variables for Rubrik
test_rubrik_url = "https://webhook.site/test-rubrik"
test_rubrik_api_key = "sk-rubrik-test-key"
with patch.dict(
os.environ,
{"RUBRIK_WEBHOOK_URL": test_rubrik_url, "RUBRIK_API_KEY": test_rubrik_api_key},
):
# Test that rubrik callback is recognized from JSON file
result = LoggingCallbackManager._add_custom_callback_generic_api_str("rubrik")
# Verify a GenericAPILogger instance is returned
assert isinstance(
result, GenericAPILogger
), "Should return GenericAPILogger instance for rubrik callback"
# Verify the endpoint is correctly loaded
assert (
result.endpoint == test_rubrik_url
), f"Endpoint should be {test_rubrik_url}"
# Verify headers include Authorization with Bearer token
assert "Content-Type" in result.headers, "Should have Content-Type header"
assert (
"Authorization" in result.headers
), "Should have Authorization header for Rubrik"
assert (
result.headers["Authorization"] == f"Bearer {test_rubrik_api_key}"
), "Authorization should have correct API key"
# Verify event_types filter (rubrik only logs success events)
assert result.event_types == [
"llm_api_success"
], "Rubrik should only log success events"
def test_generic_api_compatible_callbacks_json_unknown_callback():
"""
Test that unknown callbacks (not in JSON or callback_settings) are returned unchanged
"""
# Test with a callback that doesn't exist in the JSON file
result = LoggingCallbackManager._add_custom_callback_generic_api_str(
"unknown_callback"
)
# Should return the string unchanged
assert result == "unknown_callback", "Unknown callback should be returned as-is"
assert isinstance(result, str), "Unknown callback should remain a string"
@pytest.mark.asyncio
async def test_generic_api_callback_settings_retry_config():
"""
Test that generic_api callback_settings are passed to GenericAPILogger.
"""
from litellm.integrations.generic_api.generic_api_callback import GenericAPILogger
from litellm.litellm_core_utils.logging_callback_manager import (
_generic_api_logger_cache,
)
callback_name = "test_generic_api_retry_config"
_generic_api_logger_cache.pop(callback_name, None)
litellm.callback_settings[callback_name] = {
"callback_type": "generic_api",
"endpoint": "https://example.com/api/logs",
"headers": {"Content-Type": "application/json"},
"max_retries": 2,
"retry_delay": 0.5,
"timeout": 3,
}
try:
result = LoggingCallbackManager._add_custom_callback_generic_api_str(
callback_name
)
assert isinstance(result, GenericAPILogger)
assert result.endpoint == "https://example.com/api/logs"
assert result.headers == {"Content-Type": "application/json"}
assert result.max_retries == 2
assert result.retry_delay == 0.5
assert result.timeout == 3
finally:
litellm.callback_settings.pop(callback_name, None)
_generic_api_logger_cache.pop(callback_name, None)