From 74ccfc50b40635fe98dd595467a27caf7c5c28c6 Mon Sep 17 00:00:00 2001 From: narmstrong-berri Date: Fri, 6 Nov 2026 06:12:16 +0000 Subject: [PATCH] fix(messages): let during_call guardrail edits reach the native request Python's messages handler yields to the event loop before it builds the provider body, so a during_call guardrail started next to the call masks the request first. The native path built its request without yielding, so masked text never reached the provider. The async native dispatch now yields once before calling the native hook. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013FDwtkaNTifWAP4W7JZavE --- litellm/messages/dispatch.py | 15 ++++++++++++++- .../messages/test_requests.py | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/litellm/messages/dispatch.py b/litellm/messages/dispatch.py index f95ec86ff26..1a54de543a8 100644 --- a/litellm/messages/dispatch.py +++ b/litellm/messages/dispatch.py @@ -1,3 +1,4 @@ +import asyncio import inspect from collections.abc import AsyncIterator, Awaitable, Callable, Coroutine, Iterator, Mapping from types import MappingProxyType @@ -190,6 +191,18 @@ def anthropic_messages_handler( ) +async def _native_amessages( + hook: Callable[[NativeCall], Awaitable[MessagesResult]], + call: NativeCall, + args: tuple[object, ...], + kwargs: Mapping[str, object], +) -> MessagesResult: + # Python's handler leaves the event loop (run_in_executor) before it builds the provider body, so + # tasks scheduled next to the call, such as the proxy's during_call guardrails, edit the request first + await asyncio.sleep(0) + return await native_call_hook(hook, call, args, kwargs) + + async def anthropic_messages(*args: object, **kwargs: object) -> MessagesResult: # kwargs-ok: public call shape python: Final = _PYTHON_AMESSAGES return await _ADISPATCH.arun( @@ -197,7 +210,7 @@ async def anthropic_messages(*args: object, **kwargs: object) -> MessagesResult: _FLAG.dropped(kwargs), python=python, binding=NATIVE_AMESSAGES, - native=native_call_hook, + native=_native_amessages, ) diff --git a/tests/test_litellm_rust/messages/test_requests.py b/tests/test_litellm_rust/messages/test_requests.py index 92031e72aee..42fc53d2c73 100644 --- a/tests/test_litellm_rust/messages/test_requests.py +++ b/tests/test_litellm_rust/messages/test_requests.py @@ -1,3 +1,4 @@ +import asyncio from typing import Final import pytest @@ -146,3 +147,20 @@ async def test_optional_positional_body_parameters_reach_the_wire(server: Record assert response["id"] == MESSAGES_RESPONSE["id"] body, _ = sent(server) assert (body["metadata"], body["system"], body["temperature"]) == (args[3], args[6], args[7]) + + +@pytest.mark.asyncio +async def test_in_place_edits_from_a_task_started_before_the_call_reach_the_wire(server: RecordingServer) -> None: + messages: Final = [{"role": "user", "content": "during about secret"}] + + async def mask() -> None: + messages[0]["content"] = "during about [REDACTED]" + + async def during_call() -> None: + await asyncio.gather(mask()) + + guardrail: Final = asyncio.create_task(during_call()) + await asyncio.gather(guardrail, call(server, messages=messages)) + + body, _ = sent(server) + assert body["messages"] == [{"role": "user", "content": "during about [REDACTED]"}]