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.
This commit is contained in:
sanjibani 2026-06-18 09:50:30 +05:30
parent 669ddc12c7
commit 6a242ee618
3 changed files with 266 additions and 1 deletions

View file

@ -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": <str>}`` 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:

View file

@ -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
)

View file

@ -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"]))