From 487787ecae4e3830f696d73c8abd7d7f1b7548a1 Mon Sep 17 00:00:00 2001 From: Yashvardhan Didwania Date: Tue, 29 Sep 2026 14:52:23 +0530 Subject: [PATCH 1/4] fix(service_logger): don't run an event loop for sync service events nobody receives With no service callback configured, the sync service hooks still ran asyncio.run() after every call. Under gevent that loop is visible to every greenlet on the thread, and Django then rejects their database queries with SynchronousOnlyOperation. Skip it when there is nothing to receive the event. Fixes #43690 Co-Authored-By: Claude Opus 5.5 --- litellm/_service_logger.py | 14 +++++++++ tests/unit/test_service_logger.py | 50 +++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/litellm/_service_logger.py b/litellm/_service_logger.py index 1a5f46e9261..89c177b91a5 100644 --- a/litellm/_service_logger.py +++ b/litellm/_service_logger.py @@ -133,6 +133,16 @@ class ServiceLogging(CustomLogger): except Exception as e: verbose_logger.exception("Error emitting service event - %s", e) + def _has_service_event_observers(self) -> bool: + """Whether anything would receive a service event sent from a blocking caller. + + Sending one runs an event loop on the calling thread. Under gevent, every + greenlet on that thread then sees a running loop, and Django rejects their + database queries with ``SynchronousOnlyOperation``. So skip it when there is + no service callback (or test counter) to receive the event. + """ + return bool(litellm.service_callback) or self.mock_testing + @staticmethod def _dispatch_from_sync(hook: Callable[[], Coroutine[object, object, None]]) -> None: """Run an async service hook from a blocking caller, whatever event loop it holds. @@ -167,6 +177,8 @@ class ServiceLogging(CustomLogger): if self.mock_testing: self.mock_testing_sync_success_hook += 1 + if not self._has_service_event_observers(): + return self._dispatch_from_sync( lambda: self.async_service_success_hook( @@ -196,6 +208,8 @@ class ServiceLogging(CustomLogger): """ if self.mock_testing: self.mock_testing_sync_failure_hook += 1 + if not self._has_service_event_observers(): + return self._dispatch_from_sync( lambda: self.async_service_failure_hook( diff --git a/tests/unit/test_service_logger.py b/tests/unit/test_service_logger.py index de46403b64d..35dea3728f5 100644 --- a/tests/unit/test_service_logger.py +++ b/tests/unit/test_service_logger.py @@ -5,6 +5,9 @@ Regression test for KeyError: 'call_type' when async_log_success_event is called without call_type in kwargs (e.g. from batch polling callbacks). """ +import asyncio +import asyncio.events + import pytest from datetime import datetime from unittest.mock import AsyncMock, patch @@ -277,3 +280,50 @@ async def test_service_failure_span_not_duplicated_for_string_and_instance( s for s in exporter.get_finished_spans() if s.name == "postgres get_user_object" ] assert len(db_spans) == 1 + + +_SYNC_HOOK_CALLS = [ + pytest.param("service_success_hook", {}, id="success"), + pytest.param("service_failure_hook", {"error": RuntimeError("boom")}, id="failure"), +] + + +@pytest.fixture +def started_loops(monkeypatch) -> list[asyncio.AbstractEventLoop]: + """Event loops started during the test.""" + started: list[asyncio.AbstractEventLoop] = [] + original = asyncio.events._set_running_loop + + def recording_set_running_loop(loop): + if loop is not None: + started.append(loop) + original(loop) + + monkeypatch.setattr(asyncio.events, "_set_running_loop", recording_set_running_loop) + return started + + +@pytest.mark.parametrize("hook_name, extra_kwargs", _SYNC_HOOK_CALLS) +def test_sync_service_hook_starts_no_event_loop_without_callbacks(monkeypatch, started_loops, hook_name, extra_kwargs): + """With no service callback, nothing receives the event, so no loop should start.""" + monkeypatch.setattr(litellm, "service_callback", []) + + getattr(ServiceLogging(), hook_name)(service=ServiceTypes.REDIS, duration=0.1, call_type="test", **extra_kwargs) + + assert started_loops == [] + + +@pytest.mark.parametrize("hook_name, extra_kwargs", _SYNC_HOOK_CALLS) +def test_sync_service_hook_still_delivers_to_configured_callbacks( + monkeypatch, started_loops, hook_name, extra_kwargs +): + """With a service callback, the event is still delivered.""" + monkeypatch.setattr(litellm, "service_callback", ["datadog"]) + service_logger = ServiceLogging() + async_hook_name = f"async_{hook_name}" + + with patch.object(service_logger, async_hook_name, new_callable=AsyncMock) as mock_hook: + getattr(service_logger, hook_name)(service=ServiceTypes.REDIS, duration=0.1, call_type="test", **extra_kwargs) + + mock_hook.assert_awaited_once() + assert started_loops From fbd7a9285ec42083b7bc23e15bd8c68c4bcd7fa2 Mon Sep 17 00:00:00 2001 From: Yashvardhan Didwania Date: Tue, 29 Sep 2026 14:52:23 +0530 Subject: [PATCH 2/4] test(service_logger): check sync delivery through a real OTel logger The delivery test mocked litellm's own async hook, so it would pass even if the event never reached a callback. Register a real OpenTelemetry V2 logger and assert the exported span instead. Co-Authored-By: Claude Opus 5.5 --- tests/unit/test_service_logger.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/tests/unit/test_service_logger.py b/tests/unit/test_service_logger.py index 35dea3728f5..a0a175f9f05 100644 --- a/tests/unit/test_service_logger.py +++ b/tests/unit/test_service_logger.py @@ -315,15 +315,22 @@ def test_sync_service_hook_starts_no_event_loop_without_callbacks(monkeypatch, s @pytest.mark.parametrize("hook_name, extra_kwargs", _SYNC_HOOK_CALLS) def test_sync_service_hook_still_delivers_to_configured_callbacks( - monkeypatch, started_loops, hook_name, extra_kwargs + monkeypatch, hook_name, extra_kwargs ): - """With a service callback, the event is still delivered.""" - monkeypatch.setattr(litellm, "service_callback", ["datadog"]) - service_logger = ServiceLogging() - async_hook_name = f"async_{hook_name}" + """With a service callback, the event still reaches it: a real OTel logger exports the span.""" + from litellm.integrations.otel.model.spans import SpanRole - with patch.object(service_logger, async_hook_name, new_callable=AsyncMock) as mock_hook: - getattr(service_logger, hook_name)(service=ServiceTypes.REDIS, duration=0.1, call_type="test", **extra_kwargs) + v2_logger, exporter = _make_otel_v2_logger() + parent = v2_logger._emitter.start_span(SpanRole.PROXY_REQUEST, "POST /chat/completions") + monkeypatch.setattr(litellm, "service_callback", [v2_logger]) - mock_hook.assert_awaited_once() - assert started_loops + getattr(ServiceLogging(), hook_name)( + service=ServiceTypes.REDIS, + duration=0.1, + call_type="test", + parent_otel_span=parent, + **extra_kwargs, + ) + parent.end() + + assert "redis test" in [span.name for span in exporter.get_finished_spans()] From 076804cde0d94e60db9d2aef406b208329e0d3fa Mon Sep 17 00:00:00 2001 From: Yashvardhan Didwania Date: Thu, 1 Oct 2026 16:23:58 +0530 Subject: [PATCH 3/4] chore: re-run CI against current main main fixed the Interactions OpenAPI compliance test in #43958; this empty commit re-runs this PR's checks against it. Co-Authored-By: Claude Opus 5.5 From c886150d1bd16921c4743cf04c4159c8d6bb7b74 Mon Sep 17 00:00:00 2001 From: Yashvardhan Didwania Date: Thu, 1 Oct 2026 16:49:24 +0530 Subject: [PATCH 4/4] fix(service_logger): keep the observer check inside the basedpyright budget bool(litellm.service_callback) passed a partially-unknown list as an argument (+1 reportUnknownArgumentType, over the per-rule limit). A plain truthiness test is the same check without the new diagnostic. Co-Authored-By: Claude Opus 5.5 --- litellm/_service_logger.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/litellm/_service_logger.py b/litellm/_service_logger.py index 89c177b91a5..958b31a8c8c 100644 --- a/litellm/_service_logger.py +++ b/litellm/_service_logger.py @@ -141,7 +141,9 @@ class ServiceLogging(CustomLogger): database queries with ``SynchronousOnlyOperation``. So skip it when there is no service callback (or test counter) to receive the event. """ - return bool(litellm.service_callback) or self.mock_testing + if litellm.service_callback: + return True + return self.mock_testing @staticmethod def _dispatch_from_sync(hook: Callable[[], Coroutine[object, object, None]]) -> None: