From 6a242ee618eab31ba91bf6e400374816af81a7a8 Mon Sep 17 00:00:00 2001 From: sanjibani <18418553+sanjibani@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:50:30 +0530 Subject: [PATCH] fix(anthropic): promote role=system in messages[] to top-level system param (#30705) Anthropic's Messages API rejects requests that include a system role inside messages[] with HTTP 400: messages: Unexpected role "system". The Messages API accepts a top-level `system` parameter, not "system" as an input message role. Many OpenAI-style clients (including Codex CLI, per #30705) send the system prompt as messages[0] instead of the top-level field. The native /v1/messages path used to forward the request unchanged, so the upstream provider rejected it. This normalizes the payload before dispatch: - Lifts any messages with role="system" out of messages[] and into the top-level `system` parameter. - Preserves the caller's existing `system` value (string or list of content blocks) and concatenates in original order. - Coerces string-typed system content to {type: text, text: ...} blocks so the merged list is well-formed for the API. - Never mutates the caller's message list or message dicts. - Empty / whitespace-only system messages are dropped silently. Companion to strip_empty_text_blocks_from_anthropic_messages: both helpers run at the top of anthropic_messages() so multi-turn OpenAI-style clients work on the unified /v1/messages path. Adds tests/test_litellm/llms/anthropic/test_normalize_system_role.py with 11 cases covering: no-system passthrough, string and content-block system messages, merging with existing top-level system (string + list), multiple system messages, empty content, immutability, invalid message shapes. --- litellm/llms/anthropic/common_utils.py | 68 ++++++- .../messages/handler.py | 8 + .../anthropic/test_normalize_system_role.py | 191 ++++++++++++++++++ 3 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 tests/test_litellm/llms/anthropic/test_normalize_system_role.py diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 5741513903c..697c0d16938 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,72 @@ 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..5579e2fc64f 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 ) 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..e8696b31ba4 --- /dev/null +++ b/tests/test_litellm/llms/anthropic/test_normalize_system_role.py @@ -0,0 +1,191 @@ +""" +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"} + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"])) \ No newline at end of file