diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index da204855465d..4e73a63af2be 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -2308,7 +2308,17 @@ def success_handler(self, result=None, start_time=None, end_time=None, cache_hit isinstance(callback, CustomLogger) and is_sync_request and self.call_type - != CallTypes.pass_through.value # pass-through endpoints call async_log_success_event + not in ( + # both endpoints are always dispatched through the + # async wrapper, which already calls + # async_log_success_event. anthropic_messages + # (/v1/messages) sets no acompletion-style flag, so + # is_sync_request is True here and the sync pass would + # otherwise re-dispatch every CustomLogger, doubling + # OTEL spans + cost/success callbacks. + CallTypes.pass_through.value, + CallTypes.anthropic_messages.value, + ) ): # custom logger class if self.stream and complete_streaming_response is None: callback.log_stream_event( 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 41560c18d152..90942f00f832 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -3690,3 +3690,51 @@ def test_set_cost_breakdown_stores_reasoning_cost(): cost_for_built_in_tools_cost_usd_dollar=0.0, ) assert "reasoning_cost" not in no_reasoning.cost_breakdown + + +def test_success_handler_skips_sync_callbacks_for_anthropic_messages(logging_obj): + """anthropic_messages (/v1/messages) is always dispatched through the async + wrapper, so the sync success_handler pass must not re-dispatch CustomLogger + callbacks -- the async handler is the canonical path. + + Regression for the duplicate ``litellm_request`` OTEL spans + double + success/cost callbacks on non-streaming /v1/messages. Unlike acompletion, + anthropic_messages sets no async flag in litellm_params, so + _is_sync_litellm_request is True and the sync pass would otherwise fire. + """ + from litellm.integrations.custom_logger import CustomLogger + + class DummyLogger(CustomLogger): + pass + + logging_obj.stream = False + logging_obj.call_type = "anthropic_messages" + logging_obj.model_call_details["litellm_params"] = {} + logging_obj.litellm_params = {} + + dummy_logger = DummyLogger() + dummy_logger.log_success_event = MagicMock() + dummy_logger.log_stream_event = MagicMock() + + model_response = ModelResponse( + id="resp-123", + model="gpt-4o-mini", + choices=[ + { + "message": {"role": "assistant", "content": "hello"}, + "finish_reason": "stop", + "index": 0, + } + ], + usage={"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + ) + + with patch.object( + logging_obj, + "get_combined_callback_list", + return_value=[dummy_logger], + ): + logging_obj.success_handler(result=model_response) + + dummy_logger.log_success_event.assert_not_called() + dummy_logger.log_stream_event.assert_not_called()