diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 5741513903c..feb3c857641 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -3,7 +3,7 @@ This file contains common utils for anthropic calls. """ import copy -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Tuple, Union import httpx @@ -989,6 +989,70 @@ def _is_empty_text_block(block: Any) -> bool: return not isinstance(text, str) or not text.strip() +def normalize_system_role_in_anthropic_messages( + messages: List[Any], + system: Optional[Union[str, List[Any]]] = None, +) -> Tuple[List[Any], Optional[Union[str, List[Any]]]]: + """ + Promote any ``role == "system"`` entries in ``messages`` to the top-level + ``system`` parameter and return ``(messages, system)``. + + Anthropic's Messages API rejects requests that include ``system`` in + ``messages[]`` with ``"Unexpected role \\"system\\". The Messages API + accepts a top-level \\`system\\` parameter, not \\"system\\" as an input + message role."`` Many OpenAI-style clients send the system prompt as a + ``messages[0]`` entry instead of the top-level field, so we lift those + entries up to keep the request valid. + + The returned message list is a fresh list; the caller's list and message + dicts are never mutated. The existing ``system`` value (string or list of + content blocks) is preserved and prepended so the assistant sees both the + top-level prompt and any in-message system entries in their original order. + + Strings in role=system messages are appended as ``{"type": "text", + "text": }`` content blocks so the result is always a list when more + than one system source is concatenated. + """ + if not messages: + return messages, system + + promoted_blocks: List[Any] = [] + kept: List[Any] = [] + for m in messages: + if isinstance(m, dict) and m.get("role") == "system": + content = m.get("content") + if isinstance(content, str): + if content: + promoted_blocks.append({"type": "text", "text": content}) + elif isinstance(content, list): + for block in content: + if isinstance(block, dict): + promoted_blocks.append(block) + elif isinstance(block, str) and block: + promoted_blocks.append({"type": "text", "text": block}) + elif content is not None and not isinstance(content, str): + # Best-effort: stringify unknown content shapes so callers + # don't silently lose information. + promoted_blocks.append({"type": "text", "text": str(content)}) + else: + kept.append(m) + + if not promoted_blocks: + return kept, system + + if isinstance(system, str): + merged: List[Any] = [] + if system: + merged.append({"type": "text", "text": system}) + merged.extend(promoted_blocks) + return kept, merged + + if isinstance(system, list): + return kept, [*system, *promoted_blocks] + + return kept, promoted_blocks or system + + def process_anthropic_headers(headers: Union[httpx.Headers, dict]) -> dict: openai_headers = {} if "anthropic-ratelimit-requests-limit" in headers: diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py index a3ac465c463..8d9b4c63ec6 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py @@ -23,6 +23,7 @@ from typing import ( import litellm from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.anthropic.common_utils import ( + normalize_system_role_in_anthropic_messages, strip_empty_text_blocks_from_anthropic_messages, ) from litellm.llms.base_llm.anthropic_messages.transformation import ( @@ -215,6 +216,13 @@ async def anthropic_messages( # Anthropic Messages path here for the same guarantee. See #22930. 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 the request stays valid. See #30705. + messages, system = normalize_system_role_in_anthropic_messages(messages, system) + original_stream = stream or kwargs.get( "_websearch_interception_converted_stream", False ) @@ -397,6 +405,13 @@ 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) diff --git a/tests/test_litellm/llms/anthropic/test_normalize_system_role.py b/tests/test_litellm/llms/anthropic/test_normalize_system_role.py new file mode 100644 index 00000000000..fa9f6612472 --- /dev/null +++ b/tests/test_litellm/llms/anthropic/test_normalize_system_role.py @@ -0,0 +1,293 @@ +""" +Tests for normalize_system_role_in_anthropic_messages. + +Issue #30705: Anthropic /v1/messages rejects requests that include +role="system" inside messages[]. OpenAI-style clients routinely send the +system prompt as the first message; the helper promotes those entries to the +top-level `system` parameter so the request stays valid. +""" + +import pytest +import sys +import os + +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../..")) +) + +from litellm.llms.anthropic.common_utils import ( + normalize_system_role_in_anthropic_messages, +) + + +class TestNormalizeSystemRole: + def test_no_system_messages_returns_input_unchanged(self): + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + ] + new_messages, new_system = normalize_system_role_in_anthropic_messages( + messages, system=None + ) + assert new_messages == messages + assert new_system is None + + def test_promotes_string_system_message_to_top_level(self): + messages = [ + {"role": "system", "content": "you are a helpful assistant"}, + {"role": "user", "content": "hi"}, + ] + new_messages, new_system = normalize_system_role_in_anthropic_messages( + messages, system=None + ) + assert new_system == [{"type": "text", "text": "you are a helpful assistant"}] + assert new_messages == [{"role": "user", "content": "hi"}] + + def test_promotes_content_block_system_message(self): + messages = [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "be concise", + "cache_control": {"type": "ephemeral"}, + } + ], + }, + {"role": "user", "content": "hi"}, + ] + new_messages, new_system = normalize_system_role_in_anthropic_messages( + messages, system=None + ) + assert new_system == [ + { + "type": "text", + "text": "be concise", + "cache_control": {"type": "ephemeral"}, + } + ] + assert new_messages == [{"role": "user", "content": "hi"}] + + def test_merges_with_existing_string_system(self): + messages = [ + {"role": "system", "content": "second instruction"}, + {"role": "user", "content": "hi"}, + ] + new_messages, new_system = normalize_system_role_in_anthropic_messages( + messages, system="first instruction" + ) + assert new_system == [ + {"type": "text", "text": "first instruction"}, + {"type": "text", "text": "second instruction"}, + ] + assert new_messages == [{"role": "user", "content": "hi"}] + + def test_merges_with_existing_list_system(self): + messages = [ + {"role": "system", "content": "second instruction"}, + {"role": "user", "content": "hi"}, + ] + new_messages, new_system = normalize_system_role_in_anthropic_messages( + messages, + system=[{"type": "text", "text": "first instruction"}], + ) + assert new_system == [ + {"type": "text", "text": "first instruction"}, + {"type": "text", "text": "second instruction"}, + ] + assert new_messages == [{"role": "user", "content": "hi"}] + + def test_promotes_multiple_system_messages_in_order(self): + messages = [ + {"role": "system", "content": "first"}, + {"role": "user", "content": "hi"}, + {"role": "system", "content": "second"}, + {"role": "assistant", "content": "ok"}, + ] + new_messages, new_system = normalize_system_role_in_anthropic_messages( + messages, system=None + ) + assert new_system == [ + {"type": "text", "text": "first"}, + {"type": "text", "text": "second"}, + ] + assert new_messages == [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "ok"}, + ] + + def test_does_not_mutate_input_messages(self): + original = [ + {"role": "system", "content": "be helpful"}, + {"role": "user", "content": "hi"}, + ] + # Take a deep copy snapshot + snapshot = [ + {"role": "system", "content": "be helpful"}, + {"role": "user", "content": "hi"}, + ] + normalize_system_role_in_anthropic_messages(original, system=None) + assert original == snapshot + + def test_empty_string_system_message_skipped(self): + messages = [ + {"role": "system", "content": ""}, + {"role": "user", "content": "hi"}, + ] + new_messages, new_system = normalize_system_role_in_anthropic_messages( + messages, system=None + ) + assert new_messages == [{"role": "user", "content": "hi"}] + # Empty system messages are dropped without producing any top-level + # system blocks; the caller's original (None) is preserved. + assert new_system is None + + def test_empty_messages_list(self): + new_messages, new_system = normalize_system_role_in_anthropic_messages( + [], system="existing" + ) + assert new_messages == [] + assert new_system == "existing" + + def test_mixed_string_and_block_system_in_messages(self): + messages = [ + {"role": "system", "content": "first"}, + {"role": "user", "content": "hi"}, + { + "role": "system", + "content": [{"type": "text", "text": "second"}], + }, + ] + new_messages, new_system = normalize_system_role_in_anthropic_messages( + messages, system=None + ) + assert new_system == [ + {"type": "text", "text": "first"}, + {"type": "text", "text": "second"}, + ] + assert new_messages == [{"role": "user", "content": "hi"}] + + def test_invalid_message_shape_passed_through(self): + messages = [ + "not a dict", + {"role": "system", "content": "promoted"}, + None, + {"role": "user", "content": "hi"}, + ] + new_messages, new_system = normalize_system_role_in_anthropic_messages( + messages, system=None + ) + assert new_system == [{"type": "text", "text": "promoted"}] + # Non-dict and None entries are passed through unchanged. + assert new_messages[0] == "not a dict" + assert new_messages[1] is None + 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"]))