mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
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.
This commit is contained in:
parent
f23b518618
commit
fd83156073
2 changed files with 43 additions and 4 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue