mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
Merge 7111c8412a into 1d5ab42e14
This commit is contained in:
commit
1e3b6b893f
3 changed files with 373 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,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": <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
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
293
tests/test_litellm/llms/anthropic/test_normalize_system_role.py
Normal file
293
tests/test_litellm/llms/anthropic/test_normalize_system_role.py
Normal file
|
|
@ -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"]))
|
||||
Loading…
Add table
Reference in a new issue