fix(wrapper): drop mutable call-kwargs tracker, assert exceptions at dispatch time

This commit is contained in:
1divyanshdev 2026-10-28 20:23:50 +05:30
parent ed795b10da
commit 67219b4f80
3 changed files with 17 additions and 10 deletions

View file

@ -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:

View file

@ -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 == []

View file

@ -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 == []