diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index d8c63b3680b..9ce138725fb 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -2023,16 +2023,24 @@ def _is_empty_text_block(block: object) -> bool: return not isinstance(text, str) or not text.strip() +def _survives_assistant_conversion(block: object) -> bool: + if not isinstance(block, dict): + return False + block_type = block.get("type") + if not isinstance(block_type, str) or block_type == "text": + return False + if block_type == "thinking": + return bool(block.get("thinking")) and not _is_unsignable_thinking_block(block) + return block_type == "server_tool_use" or block_type.endswith("_tool_result") + + def _carries_non_text_content(message: AllMessageValues) -> bool: if message.get("tool_calls"): return True content = message.get("content") if not isinstance(content, list): return False - return any( - isinstance(block, dict) and isinstance(block.get("type"), str) and block.get("type") != "text" - for block in content - ) + return any(_survives_assistant_conversion(block) for block in content) def _with_content(message: AllMessageValues, content: object, action: str) -> AllMessageValues: diff --git a/tests/test_litellm/llms/anthropic/test_message_sanitization.py b/tests/test_litellm/llms/anthropic/test_message_sanitization.py index e5e7f7a5d1f..9009f2d0799 100644 --- a/tests/test_litellm/llms/anthropic/test_message_sanitization.py +++ b/tests/test_litellm/llms/anthropic/test_message_sanitization.py @@ -502,6 +502,37 @@ class TestMessageSanitization: assert [b["type"] for b in sanitized["content"]] == ["thinking"] assert PLACEHOLDER not in json.dumps(sanitized) + @pytest.mark.parametrize( + "sibling_block", + [ + {"type": "image_url", "image_url": {"url": "data:image/png;base64,aGk="}}, + {"type": "thinking", "thinking": ""}, + {"type": "unknown_future_block"}, + ], + ids=["image_url", "unsignable_thinking", "unknown_type"], + ) + def test_empty_text_kept_when_sibling_block_would_not_survive(self, sibling_block): + """ + Dropping empty text is only safe when something else in the turn actually reaches + Anthropic. Blocks the assistant conversion discards (assistant-side images, + signature-less thinking, unrecognised types) would leave an empty turn that gets + dropped from the request entirely, so the placeholder has to stay. + """ + litellm.modify_params = True + + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": [{"type": "text", "text": " "}, sibling_block]}, + {"role": "user", "content": "and now?"}, + ] + + result = anthropic_messages_pt( + messages=messages, model="claude-haiku-4-5", llm_provider="anthropic" + ) + + assert [m["role"] for m in result] == ["user", "assistant", "user"] + assert PLACEHOLDER in json.dumps(result) + def test_assistant_text_preserved_alongside_tool_calls(self): """ Dropping empty text must not drop real text: an assistant turn that says something