From cd710fe919048aaa30c3362d35a9a77671e953f2 Mon Sep 17 00:00:00 2001 From: voidborne-d Date: Mon, 16 Mar 2026 22:07:49 +0000 Subject: [PATCH 1/5] fix(factory): handle list content in map_system_message_pt Fixes #23757 When the Anthropic pass-through endpoint converts messages to OpenAI format, content fields can be lists of content blocks rather than plain strings. map_system_message_pt assumed string content and crashed with TypeError on concatenation. Add _get_content_as_str() helper that normalizes both str and list content to a string before merging, using the existing convert_content_list_to_str utility. Tests: 3 new test cases covering list content, mixed str/list, and list content as last message. --- .../prompt_templates/factory.py | 17 +++++- tests/llm_translation/test_optional_params.py | 55 +++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 47272b38ad6..38a785f51d9 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -86,6 +86,15 @@ DEFAULT_ASSISTANT_CONTINUE_MESSAGE = ChatCompletionAssistantMessage( ) # similar to autogen. Only used if `litellm.modify_params=True`. +def _get_content_as_str(content: Union[str, list]) -> str: + """Extract text from content that may be a string or a list of content blocks.""" + if isinstance(content, str): + return content + if isinstance(content, list): + return convert_content_list_to_str({"content": content}) + return str(content) + + def map_system_message_pt(messages: list) -> list: """ Convert 'system' message to 'user' message if provider doesn't support 'system' role. @@ -100,6 +109,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 +117,14 @@ 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"] + next_text = _get_content_as_str(next_m["content"]) + next_m["content"] = 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) diff --git a/tests/llm_translation/test_optional_params.py b/tests/llm_translation/test_optional_params.py index 56f05580cb2..565a6e644e7 100644 --- a/tests/llm_translation/test_optional_params.py +++ b/tests/llm_translation/test_optional_params.py @@ -55,6 +55,61 @@ 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" + + @pytest.mark.parametrize( "stop_sequence, expected_count", [("\n", 0), (["\n"], 0), (["finish_reason"], 1)] ) From e46501494f6f34cab2ef5c973cc9e54ffdf792ed Mon Sep 17 00:00:00 2001 From: voidborne-d Date: Tue, 17 Mar 2026 03:06:40 +0000 Subject: [PATCH 2/5] fix: add role key to satisfy AllMessageValues type contract --- litellm/litellm_core_utils/prompt_templates/factory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 38a785f51d9..ab9e7db343a 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -91,7 +91,7 @@ def _get_content_as_str(content: Union[str, list]) -> str: if isinstance(content, str): return content if isinstance(content, list): - return convert_content_list_to_str({"content": content}) + return convert_content_list_to_str({"role": "user", "content": content}) return str(content) From 1970157d939bd49ac69b08a4b04941a48ac3da60 Mon Sep 17 00:00:00 2001 From: voidborne-d Date: Tue, 17 Mar 2026 08:07:21 +0000 Subject: [PATCH 3/5] fix: handle None content in _get_content_as_str When content is None (e.g. assistant messages with only tool_calls), the function previously fell through to str(None) producing the literal string 'None'. Now returns empty string for None content. Added test case for this scenario. --- .../prompt_templates/factory.py | 6 ++++-- tests/llm_translation/test_optional_params.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index ab9e7db343a..99be6d02e2c 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -86,8 +86,10 @@ DEFAULT_ASSISTANT_CONTINUE_MESSAGE = ChatCompletionAssistantMessage( ) # similar to autogen. Only used if `litellm.modify_params=True`. -def _get_content_as_str(content: Union[str, list]) -> str: - """Extract text from content that may be a string or a list of content blocks.""" +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): diff --git a/tests/llm_translation/test_optional_params.py b/tests/llm_translation/test_optional_params.py index 565a6e644e7..16b4e5e2b82 100644 --- a/tests/llm_translation/test_optional_params.py +++ b/tests/llm_translation/test_optional_params.py @@ -110,6 +110,24 @@ def test_supports_system_message_list_content_last_message(): 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 + # 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)] ) From 1f573d048f0c2c33eb5a56a61e9864b6978ea910 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?d=20=F0=9F=94=B9?= <258577966+voidborne-d@users.noreply.github.com> Date: Sat, 21 Mar 2026 12:09:48 +0000 Subject: [PATCH 4/5] fix: copy dict before mutation + use filter(None, ...) to avoid trailing space Address review feedback: - Shallow-copy next_m before mutating to avoid side-effects on caller's dict - Use ' '.join(filter(None, ...)) to prevent trailing space when next_text is empty --- litellm/litellm_core_utils/prompt_templates/factory.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 99be6d02e2c..2f0678cf0fc 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -119,8 +119,12 @@ 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 + # 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"] = system_text + " " + next_text + 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": system_text} From 5c93fc2e2c62cb5d1a1e94a027453a0626112c73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?d=20=F0=9F=94=B9?= <258577966+voidborne-d@users.noreply.github.com> Date: Sat, 21 Mar 2026 12:53:31 +0000 Subject: [PATCH 5/5] fix: address review comments + black formatting - Return empty string instead of str(content) for unexpected types in _get_content_as_str - Add role assertion in test_supports_system_message_none_content - Apply black formatting to factory.py and test_optional_params.py --- .../prompt_templates/factory.py | 58 +++++++++---------- tests/llm_translation/test_optional_params.py | 13 ++++- 2 files changed, 38 insertions(+), 33 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 2f0678cf0fc..31b0a0aa8f4 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -94,7 +94,7 @@ def _get_content_as_str(content: Union[str, list, None]) -> str: return content if isinstance(content, list): return convert_content_list_to_str({"role": "user", "content": content}) - return str(content) + return "" def map_system_message_pt(messages: list) -> list: @@ -122,9 +122,7 @@ def map_system_message_pt(messages: list) -> list: # 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]) - ) + 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": system_text} @@ -1410,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 = { @@ -1557,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: @@ -2063,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" ) @@ -2405,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 = { @@ -2434,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) @@ -2474,9 +2470,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) @@ -2609,9 +2605,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. @@ -2771,9 +2767,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) @@ -5215,9 +5211,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 16b4e5e2b82..c643f380c80 100644 --- a/tests/llm_translation/test_optional_params.py +++ b/tests/llm_translation/test_optional_params.py @@ -117,12 +117,23 @@ def test_supports_system_message_none_content(): """ messages = [ {"role": "system", "content": "Be helpful."}, - {"role": "assistant", "content": None, "tool_calls": [{"id": "1", "type": "function", "function": {"name": "f", "arguments": "{}"}}]}, + { + "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"]