diff --git a/litellm/utils.py b/litellm/utils.py index 82bc1bbf6c1..f4d5714c315 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -2198,7 +2198,6 @@ def client(original_function): kwargs.pop("_is_litellm_internal_call", None) # discard if injected _is_litellm_internal_call: Final = is_internal_call.get() _deployment_call_end_time: datetime.datetime | None = None - uncached_call_kwargs: dict | None = None try: if logging_obj is None: @@ -2309,8 +2308,6 @@ def client(original_function): and _caching_handler_response.embedding_uncached_input is not None else kwargs ) - if call_kwargs is not kwargs: - uncached_call_kwargs = call_kwargs try: result = await original_function(*args, **call_kwargs) except Exception as deployment_error: @@ -2323,6 +2320,11 @@ def client(original_function): ) except BaseException: # noqa: S110, BLE001 # hook dispatch - including cancellation mid-await - must never replace the real deployment failure, so there is nothing to do with what it raises pass + if call_kwargs is not kwargs: + # The uncached-input dict is a wrapper-private copy of kwargs whose + # request never left this frame except as part of the failure + # traceback below, so its logging reference is released here. + call_kwargs.pop("litellm_logging_obj", None) raise end_time = datetime.datetime.now() @@ -2505,8 +2507,6 @@ def client(original_function): if logging_obj is not None and not _is_litellm_internal_call: logging_obj.model_call_details.pop("exception", None) kwargs.pop("litellm_logging_obj", None) - if uncached_call_kwargs is not None: - uncached_call_kwargs.pop("litellm_logging_obj", None) raise e finally: diff --git a/tests/unit/integrations/test_custom_callback_input.py b/tests/unit/integrations/test_custom_callback_input.py index c127dc811e3..02dbd4797ae 100644 --- a/tests/unit/integrations/test_custom_callback_input.py +++ b/tests/unit/integrations/test_custom_callback_input.py @@ -222,6 +222,9 @@ class _CallbackCapture(CustomLogger): self.async_success: Final = AsyncMock() self.sync_failure: Final = Mock() self.async_failure: Final = AsyncMock() + # Exception seen at dispatch time; litellm releases it from the call-details + # dict once the failure handlers have run (issue #48649). + self.async_failure_exceptions: Final = [] def log_success_event(self, kwargs, response_obj, start_time, end_time) -> None: self.sync_success(kwargs=kwargs, response_obj=response_obj) @@ -234,6 +237,7 @@ class _CallbackCapture(CustomLogger): self.sync_failure(kwargs=kwargs, response_obj=response_obj) async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time) -> None: + self.async_failure_exceptions.append(kwargs.get("exception")) await self.async_failure(kwargs=kwargs, response_obj=response_obj) @@ -501,7 +505,7 @@ async def test_async_embedding_failure_reaches_failure_callback( ) assert len(failure_events) == 1 assert failure_events[0]["model"] == "text-embedding-3-small" - assert isinstance(failure_events[0]["exception"], litellm.AuthenticationError) + assert isinstance(callback_capture.async_failure_exceptions[0], litellm.AuthenticationError) assert callback_capture.async_success.call_args_list == [] diff --git a/tests/unit/test_router/test_router_callback_hook_sequence.py b/tests/unit/test_router/test_router_callback_hook_sequence.py index a7c41964950..de894d92f27 100644 --- a/tests/unit/test_router/test_router_callback_hook_sequence.py +++ b/tests/unit/test_router/test_router_callback_hook_sequence.py @@ -85,6 +85,7 @@ class _HookEvent(NamedTuple): model: object kwargs: Mapping[str, object] response: object + exception: object def _router_context_problems(kwargs: Mapping[str, object]) -> tuple[str, ...]: @@ -156,7 +157,9 @@ class _HookRecorder(CustomLogger): if not _is_from_this_test(kwargs): return self.errors.extend(f"{state}: {problem}" for problem in problems) - self.events.append(_HookEvent(state, model, kwargs, response)) + # Snapshot the exception at dispatch time: litellm no longer keeps it on the + # call-details dict after the failure handlers have run (issue #48649). + self.events.append(_HookEvent(state, model, kwargs, response, kwargs.get("exception"))) if self.loop is not None: self.loop.call_soon_threadsafe(self._notify) @@ -344,7 +347,7 @@ async def test_router_chat_success_streaming_and_failure_fire_the_hooks_in_order failed: Final = recorder.events[6:] assert [event.state for event in failed] == ["sync_pre_api_call", "post_api_call", "async_failure"] assert failed[2].response is None - assert isinstance(failed[2].kwargs["exception"], litellm.AuthenticationError) + assert isinstance(failed[2].exception, litellm.AuthenticationError) assert recorder.errors == [] @@ -372,7 +375,7 @@ async def test_router_embedding_success_and_failure_fire_the_hooks_in_order( await recorder.until(_settled(2, posts=2)) assert recorder.states[3:] == ["sync_pre_api_call", "post_api_call", "async_failure"] assert recorder.events[5].response is None - assert isinstance(recorder.events[5].kwargs["exception"], litellm.AuthenticationError) + assert isinstance(recorder.events[5].exception, litellm.AuthenticationError) assert recorder.errors == [] @@ -425,7 +428,7 @@ async def test_router_fallback_fires_failure_then_success_hooks( ] assert [_model_group(event) for event in recorder.events] == ["primary-group"] * 3 + ["fallback-group"] * 3 assert _model_id(recorder.events[0]) != _model_id(recorder.events[3]) - assert isinstance(recorder.events[2].kwargs["exception"], litellm.AuthenticationError) + assert isinstance(recorder.events[2].exception, litellm.AuthenticationError) assert _completion(recorder.events[5]).choices[0].message.content == "hello" assert recorder.errors == []