fix(anthropic): also normalize role=system in sync messages handler

Followup to #30719. The async wrapper (anthropic_messages) already calls
normalize_system_role_in_anthropic_messages and then dispatches to
anthropic_messages_handler with _litellm_messages_presanitized=True.
But callers using litellm.messages.create go directly through
anthropic_messages_handler without going through the async wrapper, so
their role=system entries still hit Anthropic's 400.

Add the same normalize call next to the existing strip_empty_text_blocks
call in anthropic_messages_handler, behind the same _litellm_messages_presanitized
skip flag so the async wrapper doesn't redo the work.

Adds 2 tests covering: (a) sync handler invokes the normalizer when no
skip flag is passed, (b) sync handler skips the normalizer when the
async wrapper has already done it (skip flag set).
This commit is contained in:
sanjibani 2026-06-19 09:03:34 +05:30
parent 6a242ee618
commit c5d360fa9f
2 changed files with 109 additions and 0 deletions

View file

@ -405,6 +405,15 @@ def anthropic_messages_handler(
# full-messages scan. Pop it so it never leaks into provider params.
if not kwargs.pop("_litellm_messages_presanitized", False):
messages = strip_empty_text_blocks_from_anthropic_messages(messages)
# Anthropic's Messages API rejects role="system" entries inside
# messages[] with "Unexpected role \"system\". The Messages API accepts
# a top-level `system` parameter, not \"system\" as an input message
# role." OpenAI-style clients routinely send the system prompt as
# messages[0]; lift those up to the top-level `system` parameter so
# sync callers (litellm.messages.create) stay valid too. See #30705.
messages, system = normalize_system_role_in_anthropic_messages(
messages, system
)
metadata = validate_anthropic_api_metadata(metadata)

View file

@ -187,5 +187,105 @@ class TestNormalizeSystemRole:
assert new_messages[2] == {"role": "user", "content": "hi"}
class TestSyncPathNormalization:
"""
Verify the sync entry point ``anthropic_messages_handler`` also normalizes
role=system messages, not just the async ``anthropic_messages`` wrapper.
Regression for the Greptile review on #30719: the async wrapper applied
the normalizer before dispatching to the sync handler, but callers using
``litellm.messages.create`` go directly through the sync handler and were
still hitting Anthropic's 400 on role=system messages.
These tests assert the contract the handler enforces: any role=system
entry in messages is lifted to the top-level system parameter before the
request is built, regardless of which entry path was used.
"""
def test_sync_handler_invokes_normalizer(self):
"""
When a caller enters the sync ``anthropic_messages_handler`` with a
role=system message and no ``_litellm_messages_presanitized`` flag,
the handler must invoke ``normalize_system_role_in_anthropic_messages``
before dispatching. We spy on the helper via monkeypatching.
"""
from litellm.llms.anthropic.experimental_pass_through.messages import (
handler as handler_module,
)
calls = []
def spy_normalize(messages, system=None):
calls.append((list(messages), system))
# Delegate to the real helper so we exercise the same code path.
from litellm.llms.anthropic.common_utils import (
normalize_system_role_in_anthropic_messages as real_normalize,
)
return real_normalize(messages, system)
# Patch the symbol the handler imported.
original = handler_module.normalize_system_role_in_anthropic_messages
handler_module.normalize_system_role_in_anthropic_messages = spy_normalize
try:
handler_module.anthropic_messages_handler(
max_tokens=64,
messages=[
{"role": "system", "content": "be concise"},
{"role": "user", "content": "hi"},
],
model="claude-test",
)
except Exception:
# We don't care about the dispatch outcome; we only want to
# confirm normalization was invoked before the failure.
pass
finally:
handler_module.normalize_system_role_in_anthropic_messages = original
assert len(calls) >= 1, (
"sync handler did not invoke normalize_system_role_in_anthropic_messages"
)
def test_sync_handler_skips_normalizer_when_presanitized_flag_set(self):
"""
When the async wrapper dispatches to the sync handler it sets
``_litellm_messages_presanitized=True`` so the handler does NOT
normalize again (would be wasted work since messages and system are
already normalized and not reassigned before dispatch). This test
pins that contract.
"""
from litellm.llms.anthropic.experimental_pass_through.messages import (
handler as handler_module,
)
calls = []
def spy_normalize(messages, system=None): # pragma: no cover - patched at runtime
calls.append((list(messages), system))
from litellm.llms.anthropic.common_utils import (
normalize_system_role_in_anthropic_messages as real_normalize,
)
return real_normalize(messages, system)
original = handler_module.normalize_system_role_in_anthropic_messages
handler_module.normalize_system_role_in_anthropic_messages = spy_normalize
try:
handler_module.anthropic_messages_handler(
max_tokens=64,
messages=[{"role": "user", "content": "hi"}],
model="claude-test",
_litellm_messages_presanitized=True,
)
except Exception:
pass
finally:
handler_module.normalize_system_role_in_anthropic_messages = original
assert calls == [], (
"sync handler re-invoked the normalizer even though "
"_litellm_messages_presanitized=True was passed"
)
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))