mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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:
parent
6a242ee618
commit
c5d360fa9f
2 changed files with 109 additions and 0 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]))
|
||||
Loading…
Add table
Reference in a new issue