From 2776dec9cf547b9420a0eb4aa67382f8d20e8764 Mon Sep 17 00:00:00 2001 From: Charles Cheng Date: Wed, 24 Jun 2026 12:27:18 +0800 Subject: [PATCH] fix(logging): dedupe success logging for non-streaming anthropic_messages The /v1/messages (anthropic_messages) route is always dispatched through the async wrapper, which logs via async_success_handler. But unlike acompletion, anthropic_messages sets no async flag in litellm_params, so _is_sync_litellm_request returns True in the sync success_handler and it re-dispatches every CustomLogger. The result for a single non-streaming request was two litellm_request OTEL spans and double cost/success callbacks. Skip the sync CustomLogger pass for anthropic_messages, mirroring how pass_through is already handled, so logging runs once via the canonical async path -- matching acompletion behaviour. --- litellm/litellm_core_utils/litellm_logging.py | 12 ++++- .../test_litellm_logging.py | 48 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index da204855465..4e73a63af2b 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -2308,7 +2308,17 @@ class Logging(LiteLLMLoggingBaseClass): 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 41560c18d15..90942f00f83 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()