Merge pull request #23782 from voidborne-d/fix/map-system-message-list-content

This commit is contained in:
Krish Dholakia 2026-03-22 08:07:42 -07:00 committed by GitHub
commit c49ef4d28f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 126 additions and 31 deletions

View file

@ -86,6 +86,17 @@ DEFAULT_ASSISTANT_CONTINUE_MESSAGE = ChatCompletionAssistantMessage(
) # similar to autogen. Only used if `litellm.modify_params=True`.
def _get_content_as_str(content: Union[str, list, None]) -> str:
"""Extract text from content that may be a string, a list of content blocks, or None."""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
return convert_content_list_to_str({"role": "user", "content": content})
return ""
def map_system_message_pt(messages: list) -> list:
"""
Convert 'system' message to 'user' message if provider doesn't support 'system' role.
@ -100,6 +111,7 @@ def map_system_message_pt(messages: list) -> list:
new_messages = []
for i, m in enumerate(messages):
if m["role"] == "system":
system_text = _get_content_as_str(m["content"])
if i < len(messages) - 1: # Not the last message
next_m = messages[i + 1]
next_role = next_m["role"]
@ -107,13 +119,16 @@ def map_system_message_pt(messages: list) -> list:
next_role == "user" or next_role == "assistant"
): # Next message is a user or assistant message
# Merge system prompt into the next message
next_m["content"] = m["content"] + " " + next_m["content"]
# Copy to avoid mutating the caller's original dict
next_m = messages[i + 1] = {**next_m}
next_text = _get_content_as_str(next_m["content"])
next_m["content"] = " ".join(filter(None, [system_text, next_text]))
elif next_role == "system": # Next message is a system message
# Append a user message instead of the system message
new_message = {"role": "user", "content": m["content"]}
new_message = {"role": "user", "content": system_text}
new_messages.append(new_message)
else: # Last message
new_message = {"role": "user", "content": m["content"]}
new_message = {"role": "user", "content": system_text}
new_messages.append(new_message)
else: # Not a system message
new_messages.append(m)
@ -1393,10 +1408,10 @@ def convert_to_gemini_tool_call_invoke(
if tool_calls is not None:
for idx, tool in enumerate(tool_calls):
if "function" in tool:
gemini_function_call: Optional[
VertexFunctionCall
] = _gemini_tool_call_invoke_helper(
function_call_params=tool["function"]
gemini_function_call: Optional[VertexFunctionCall] = (
_gemini_tool_call_invoke_helper(
function_call_params=tool["function"]
)
)
if gemini_function_call is not None:
part_dict: VertexPartType = {
@ -1540,9 +1555,7 @@ def convert_to_gemini_tool_call_result( # noqa: PLR0915
file_data = (
file_content.get("file_data", "")
if isinstance(file_content, dict)
else file_content
if isinstance(file_content, str)
else ""
else file_content if isinstance(file_content, str) else ""
)
if file_data:
@ -2046,9 +2059,9 @@ def _sanitize_empty_text_content(
if isinstance(content, str):
if not content or not content.strip():
message = cast(AllMessageValues, dict(message)) # Make a copy
message[
"content"
] = "[System: Empty message content sanitised to satisfy protocol]"
message["content"] = (
"[System: Empty message content sanitised to satisfy protocol]"
)
verbose_logger.debug(
f"_sanitize_empty_text_content: Replaced empty text content in {message.get('role')} message"
)
@ -2388,9 +2401,9 @@ def anthropic_messages_pt( # noqa: PLR0915
# Convert ChatCompletionImageUrlObject to dict if needed
image_url_value = m["image_url"]
if isinstance(image_url_value, str):
image_url_input: Union[
str, dict[str, Any]
] = image_url_value
image_url_input: Union[str, dict[str, Any]] = (
image_url_value
)
else:
# ChatCompletionImageUrlObject or dict case - convert to dict
image_url_input = {
@ -2417,9 +2430,9 @@ def anthropic_messages_pt( # noqa: PLR0915
)
if "cache_control" in _content_element:
_anthropic_content_element[
"cache_control"
] = _content_element["cache_control"]
_anthropic_content_element["cache_control"] = (
_content_element["cache_control"]
)
user_content.append(_anthropic_content_element)
elif m.get("type", "") == "text":
m = cast(ChatCompletionTextObject, m)
@ -2479,9 +2492,9 @@ def anthropic_messages_pt( # noqa: PLR0915
)
if "cache_control" in _content_element:
_anthropic_content_text_element[
"cache_control"
] = _content_element["cache_control"]
_anthropic_content_text_element["cache_control"] = (
_content_element["cache_control"]
)
user_content.append(_anthropic_content_text_element)
@ -2614,9 +2627,9 @@ def anthropic_messages_pt( # noqa: PLR0915
original_content_element=dict(assistant_content_block),
)
if "cache_control" in _content_element:
_anthropic_text_content_element[
"cache_control"
] = _content_element["cache_control"]
_anthropic_text_content_element["cache_control"] = (
_content_element["cache_control"]
)
text_element = _anthropic_text_content_element
# Interleave: each thinking block precedes its server tool group.
@ -2776,9 +2789,9 @@ def anthropic_messages_pt( # noqa: PLR0915
)
if "cache_control" in _content_element:
_anthropic_text_content_element[
"cache_control"
] = _content_element["cache_control"]
_anthropic_text_content_element["cache_control"] = (
_content_element["cache_control"]
)
assistant_content.append(_anthropic_text_content_element)
@ -5220,9 +5233,7 @@ def default_response_schema_prompt(response_schema: dict) -> str:
prompt_str = """Use this JSON schema:
```json
{}
```""".format(
response_schema
)
```""".format(response_schema)
return prompt_str

View file

@ -55,6 +55,90 @@ def test_supports_system_message():
assert isinstance(response, litellm.ModelResponse)
def test_supports_system_message_list_content():
"""
Test map_system_message_pt when content is a list of content blocks
(e.g. from Anthropic pass-through endpoint).
Fixes: https://github.com/BerriAI/litellm/issues/23757
"""
# System message with list content (Anthropic format)
messages = [
{"role": "system", "content": [{"type": "text", "text": "You are helpful."}]},
{"role": "user", "content": [{"type": "text", "text": "Hello!"}]},
]
new_messages = map_system_message_pt(messages=messages)
assert len(new_messages) == 1
assert new_messages[0]["role"] == "user"
assert isinstance(new_messages[0]["content"], str)
assert "You are helpful." in new_messages[0]["content"]
assert "Hello!" in new_messages[0]["content"]
def test_supports_system_message_mixed_content():
"""
Test map_system_message_pt with mixed str and list content types.
"""
messages = [
{"role": "system", "content": "System prompt"},
{"role": "user", "content": [{"type": "text", "text": "User message"}]},
]
new_messages = map_system_message_pt(messages=messages)
assert len(new_messages) == 1
assert new_messages[0]["role"] == "user"
assert isinstance(new_messages[0]["content"], str)
assert "System prompt" in new_messages[0]["content"]
assert "User message" in new_messages[0]["content"]
def test_supports_system_message_list_content_last_message():
"""
Test map_system_message_pt when system message with list content is the last message.
"""
messages = [
{"role": "system", "content": [{"type": "text", "text": "Only system"}]},
]
new_messages = map_system_message_pt(messages=messages)
assert len(new_messages) == 1
assert new_messages[0]["role"] == "user"
assert new_messages[0]["content"] == "Only system"
def test_supports_system_message_none_content():
"""
Test map_system_message_pt when next message has content=None (e.g. assistant
tool-call messages). Should not produce the literal string 'None'.
"""
messages = [
{"role": "system", "content": "Be helpful."},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "1",
"type": "function",
"function": {"name": "f", "arguments": "{}"},
}
],
},
]
new_messages = map_system_message_pt(messages=messages)
assert len(new_messages) == 1
assert new_messages[0]["role"] == "assistant"
# content should start with system text, not contain literal "None"
assert "None" not in new_messages[0]["content"]
assert "Be helpful." in new_messages[0]["content"]
@pytest.mark.parametrize(
"stop_sequence, expected_count", [("\n", 0), (["\n"], 0), (["finish_reason"], 1)]
)