diff --git a/litellm/utils.py b/litellm/utils.py index a11c5500503..944bb61d5e7 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -558,10 +558,10 @@ def _custom_logger_class_exists_in_success_callbacks( e.g if `LangfusePromptManagement` is passed in, it will return True if an instance of `LangfusePromptManagement` exists in litellm.success_callback or litellm._async_success_callback Prevents double adding a custom logger callback to the litellm callbacks + + Matches on the exact class; an instance of a subclass does not count as registered """ - return any( - isinstance(cb, type(callback_class)) for cb in litellm.success_callback + litellm._async_success_callback - ) + return any(type(cb) is type(callback_class) for cb in litellm.success_callback + litellm._async_success_callback) def _custom_logger_class_exists_in_failure_callbacks( @@ -573,10 +573,10 @@ def _custom_logger_class_exists_in_failure_callbacks( e.g if `LangfusePromptManagement` is passed in, it will return True if an instance of `LangfusePromptManagement` exists in litellm.failure_callback or litellm._async_failure_callback Prevents double adding a custom logger callback to the litellm callbacks + + Matches on the exact class; an instance of a subclass does not count as registered """ - return any( - isinstance(cb, type(callback_class)) for cb in litellm.failure_callback + litellm._async_failure_callback - ) + return any(type(cb) is type(callback_class) for cb in litellm.failure_callback + litellm._async_failure_callback) def get_request_guardrails(kwargs: Dict[str, Any]) -> List[str]: @@ -766,7 +766,7 @@ def function_setup( llm_router=None, # type: ignore ) if callback is None or any( - isinstance(cb, type(callback)) for cb in litellm._async_success_callback + type(cb) is type(callback) for cb in litellm._async_success_callback ): # don't double add a callback continue if callback not in litellm.input_callback: diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index edc0cfed63e..b22e69f0942 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -4916,3 +4916,94 @@ def test_is_prompt_caching_valid_prompt_explicit_min_token_count_overrides_model is_prompt_caching_valid_prompt(model="claude-opus-4-8", messages=PROMPT_CACHE_MESSAGES, min_token_count=8192) is False ) + + +def test_custom_logger_guards_ignore_subclass_instances(monkeypatch: pytest.MonkeyPatch) -> None: + """Regression LIT-4392: the success/failure existence guards used isinstance, so a user + subclass of a built-in logger already promoted into the callback lists made the guard + report the built-in itself as registered and the configured logger was silently skipped. + The exact-class assertions must hold alongside the subclass assertions: the guards still + have to dedup a second instance of the same class, only a subclass must stop matching.""" + from litellm.integrations.custom_logger import CustomLogger + from litellm.utils import ( + _custom_logger_class_exists_in_failure_callbacks, + _custom_logger_class_exists_in_success_callbacks, + ) + + class BuiltinLogger(CustomLogger): + pass + + class UserSubclassLogger(BuiltinLogger): + pass + + builtin_instance = BuiltinLogger() + + monkeypatch.setattr(litellm, "success_callback", [UserSubclassLogger()]) + monkeypatch.setattr(litellm, "failure_callback", [UserSubclassLogger()]) + monkeypatch.setattr(litellm, "_async_success_callback", []) + monkeypatch.setattr(litellm, "_async_failure_callback", []) + assert _custom_logger_class_exists_in_success_callbacks(builtin_instance) is False + assert _custom_logger_class_exists_in_failure_callbacks(builtin_instance) is False + + monkeypatch.setattr(litellm, "success_callback", [BuiltinLogger()]) + monkeypatch.setattr(litellm, "failure_callback", [BuiltinLogger()]) + assert _custom_logger_class_exists_in_success_callbacks(builtin_instance) is True + assert _custom_logger_class_exists_in_failure_callbacks(builtin_instance) is True + + +@pytest.mark.asyncio +async def test_s3_v2_success_callback_registers_alongside_user_subclass( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression LIT-4392: with a user S3Logger subclass registered via litellm_settings.callbacks + and success_callback ["s3_v2"], the built-in s3_v2 logger was never added and S3 logs were + silently dropped while requests kept returning 200.""" + from litellm.integrations.s3_v2 import S3Logger + from litellm.utils import _add_custom_logger_callback_to_specific_event + + class UserS3Logger(S3Logger): + async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): + pass + + user_logger = UserS3Logger() + monkeypatch.setattr(litellm, "success_callback", [user_logger, "s3_v2"]) + monkeypatch.setattr(litellm, "_async_success_callback", [user_logger]) + monkeypatch.setattr(litellm, "failure_callback", []) + monkeypatch.setattr(litellm, "_async_failure_callback", []) + + _add_custom_logger_callback_to_specific_event("s3_v2", "success") + + assert any(type(cb) is S3Logger for cb in litellm.success_callback) + assert any(type(cb) is S3Logger for cb in litellm._async_success_callback) + assert "s3_v2" not in litellm.success_callback + assert user_logger in litellm.success_callback + + +@pytest.mark.asyncio +async def test_builtin_string_callback_registers_when_subclass_already_active( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression LIT-4392, litellm.callbacks path: the inline dedup in function_setup also + matched subclass instances, so a built-in name in litellm.callbacks was dropped whenever a + user subclass was already promoted into _async_success_callback.""" + from litellm.integrations.s3_v2 import S3Logger + + class UserS3Logger(S3Logger): + async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): + pass + + user_logger = UserS3Logger() + monkeypatch.setattr(litellm, "callbacks", ["s3_v2"]) + monkeypatch.setattr(litellm, "input_callback", []) + monkeypatch.setattr(litellm, "success_callback", [user_logger]) + monkeypatch.setattr(litellm, "failure_callback", []) + monkeypatch.setattr(litellm, "_async_success_callback", [user_logger]) + monkeypatch.setattr(litellm, "_async_failure_callback", []) + + await litellm.acompletion( + model="gpt-5.6", + messages=[{"role": "user", "content": "hi"}], + mock_response="ok", + ) + + assert any(type(cb) is S3Logger for cb in litellm._async_success_callback)