mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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:
parent
669ddc12c7
commit
6a242ee618
3 changed files with 266 additions and 1 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
191
tests/test_litellm/llms/anthropic/test_normalize_system_role.py
Normal file
191
tests/test_litellm/llms/anthropic/test_normalize_system_role.py
Normal 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"]))
|
||||
Loading…
Add table
Reference in a new issue