From fd83156073b76f2a925c32a161f3def6811501b4 Mon Sep 17 00:00:00 2001 From: tbartik <22518863+tbartik@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:06:12 +0200 Subject: [PATCH] fix(anthropic): only drop empty text when a sibling block survives conversion _carries_non_text_content treated any non-text block as substantive, but the assistant list-content converter only keeps thinking (when signable), server_tool_use and *_tool_result blocks. For an assistant turn holding empty text next to a block it discards, such as an image_url or a signature-less thinking block, dropping the empty text left nothing behind and the whole turn disappeared from the request. The predicate now mirrors what the conversion actually keeps, so those turns retain the placeholder and survive. This also improves on the previous behaviour, where such a turn was dropped outright. --- .../prompt_templates/factory.py | 16 +++++++--- .../anthropic/test_message_sanitization.py | 31 +++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) 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