diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index f6004616712..7c9255963b3 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -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 diff --git a/tests/llm_translation/test_optional_params.py b/tests/llm_translation/test_optional_params.py index 56f05580cb2..c643f380c80 100644 --- a/tests/llm_translation/test_optional_params.py +++ b/tests/llm_translation/test_optional_params.py @@ -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)] )