Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions litellm/llms/anthropic/common_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,49 @@ def strip_thinking_blocks_from_anthropic_messages_request_dict(
data.pop("thinking", None)


def strip_empty_text_blocks_from_anthropic_messages(
messages: List[Any],
) -> List[Any]:
"""
Return a new message list with empty or whitespace-only ``{"type": "text"}``
content blocks removed.

Anthropic's API rejects requests containing such blocks with
``"messages: text content blocks must be non-empty"``, but assistant
messages from Anthropic routinely arrive with ``{"type": "text", "text": ""}``
alongside ``tool_use`` blocks (see anthropics/anthropic-sdk-python#461).
Multi-turn tool-use clients (e.g. Claude Code) loop these prior responses
back as conversation history, which then causes the next request to 400
on the unified ``/v1/messages`` path. ``/v1/chat/completions`` already
handles this in ``anthropic_messages_pt``; this helper provides the
equivalent guarantee for the native Anthropic Messages path.

Messages whose content is a list and becomes empty after stripping are
omitted, matching :func:`strip_thinking_blocks_from_anthropic_messages`.
The caller's list and its content blocks are never mutated; modified
messages are returned as shallow copies with a fresh content list.
"""
out: List[Any] = []
for m in messages:
if not isinstance(m, dict) or not isinstance(m.get("content"), list):
out.append(m)
continue
content = m["content"]
filtered = [b for b in content if not _is_empty_text_block(b)]
if len(filtered) == len(content):
out.append(m)
elif filtered:
out.append({**m, "content": filtered})
return out


def _is_empty_text_block(block: Any) -> bool:
if not isinstance(block, dict) or block.get("type") != "text":
return False
text = block.get("text")
return not isinstance(text, str) or not text.strip()


def process_anthropic_headers(headers: Union[httpx.Headers, dict]) -> dict:
openai_headers = {}
if "anthropic-ratelimit-requests-limit" in headers:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@

import litellm
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.anthropic.common_utils import (
strip_empty_text_blocks_from_anthropic_messages,
)
from litellm.llms.base_llm.anthropic_messages.transformation import (
BaseAnthropicMessagesConfig,
)
Expand Down Expand Up @@ -188,8 +191,20 @@ async def anthropic_messages(
**kwargs,
) -> Union[AnthropicMessagesResponse, AsyncIterator]:
"""
Async: Make llm api request in Anthropic /messages API spec
Async: Make llm api request in Anthropic /messages API spec.

Runs the empty-text-block sanitizer before any backend dispatch.
"""
# Anthropic's API rejects requests containing empty / whitespace-only
# text content blocks with "messages: text content blocks must be
# non-empty". Multi-turn tool-use clients (e.g. Claude Code) routinely
# loop assistant responses that contain {"type": "text", "text": ""}
# alongside tool_use blocks back as conversation history, which then
# causes the next /v1/messages call to 400. /v1/chat/completions
# already handles this in anthropic_messages_pt; sanitize the native
# Anthropic Messages path here for the same guarantee. See #22930.
messages = strip_empty_text_blocks_from_anthropic_messages(messages)

original_stream = stream or kwargs.get(
"_websearch_interception_converted_stream", False
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,51 @@ def test_anthropic_experimental_pass_through_messages_handler_dynamic_api_key_an
assert mock_completion.call_args.kwargs["custom_key"] == "custom_value"


@pytest.mark.asyncio
async def test_anthropic_messages_sanitizes_empty_text_blocks_before_dispatch():
"""Regression test for #22930. The unified /v1/messages path must
strip empty text blocks before forwarding, otherwise Anthropic
returns 400 "text content blocks must be non-empty"."""
from litellm.llms.anthropic.experimental_pass_through.messages import handler

msgs = [
{
"role": "assistant",
"content": [
{"type": "text", "text": ""},
{"type": "tool_use", "id": "t", "name": "B", "input": {}},
],
}
]
captured = {}

def fake_handler(*args, **kwargs):
captured["messages"] = kwargs.get("messages")
return "stub"

fake_loop = MagicMock()
fake_loop.run_in_executor = lambda _e, func: _async_return(func())

with (
patch.object(handler, "anthropic_messages_handler", side_effect=fake_handler),
patch("asyncio.get_event_loop", return_value=fake_loop),
):
await handler.anthropic_messages(
max_tokens=100,
messages=msgs,
model="anthropic/claude-sonnet-4-5-20250929",
custom_llm_provider="anthropic",
api_key="k",
)

assert [b["type"] for b in captured["messages"][0]["content"]] == ["tool_use"]
assert len(msgs[0]["content"]) == 2 # caller untouched


async def _async_return(value):
return value


def test_anthropic_experimental_pass_through_messages_handler_custom_llm_provider():
"""
Test that litellm.completion is called when a custom LLM provider is given
Expand Down
98 changes: 98 additions & 0 deletions tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,104 @@ def test_strip_thinking_blocks_from_anthropic_messages_request_dict(self):
assert "thinking" not in data
assert data["messages"] == []

def test_strip_empty_text_blocks_from_anthropic_messages(self):
"""Covers #22930. The core regression scenario: an assistant message
with an empty text block alongside ``tool_use`` loses the empty block
and keeps the ``tool_use``; a whole message that reduces to no blocks
is dropped; whitespace-only text counts as empty; the caller's list
is never mutated."""
from litellm.llms.anthropic.common_utils import (
strip_empty_text_blocks_from_anthropic_messages,
)

tu = {"type": "tool_use", "id": "x", "name": "Bash", "input": {}}
msgs = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": [{"type": "text", "text": " \n "}, tu]},
{"role": "assistant", "content": [{"type": "text", "text": ""}]},
]
out = strip_empty_text_blocks_from_anthropic_messages(msgs)
assert len(out) == 2 and out[0] is msgs[0]
assert [b["type"] for b in out[1]["content"]] == ["tool_use"]
assert len(msgs[1]["content"]) == 2 # caller's content unchanged

def test_strip_empty_text_blocks_preserves_thinking_blocks(self):
from litellm.llms.anthropic.common_utils import (
strip_empty_text_blocks_from_anthropic_messages,
)

msgs = [
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "plan", "signature": "sig"},
{"type": "text", "text": ""},
],
}
]
out = strip_empty_text_blocks_from_anthropic_messages(msgs)
assert [b["type"] for b in out[0]["content"]] == ["thinking"]

def test_strip_empty_text_blocks_treats_null_text_as_empty(self):
from litellm.llms.anthropic.common_utils import (
strip_empty_text_blocks_from_anthropic_messages,
)

msgs = [
{
"role": "user",
"content": [
{"type": "text", "text": None},
{"type": "tool_result", "tool_use_id": "x", "content": "y"},
],
}
]
out = strip_empty_text_blocks_from_anthropic_messages(msgs)
assert [b["type"] for b in out[0]["content"]] == ["tool_result"]

def test_strip_empty_text_blocks_treats_missing_text_key_as_empty(self):
from litellm.llms.anthropic.common_utils import (
strip_empty_text_blocks_from_anthropic_messages,
)

msgs = [
{
"role": "user",
"content": [
{"type": "text"},
{"type": "tool_result", "tool_use_id": "x", "content": "y"},
],
}
]
out = strip_empty_text_blocks_from_anthropic_messages(msgs)
assert [b["type"] for b in out[0]["content"]] == ["tool_result"]

def test_strip_empty_text_blocks_leaves_non_empty_text_alone(self):
from litellm.llms.anthropic.common_utils import (
strip_empty_text_blocks_from_anthropic_messages,
)

msgs = [{"role": "assistant", "content": [{"type": "text", "text": "hi"}]}]
out = strip_empty_text_blocks_from_anthropic_messages(msgs)
assert out[0] is msgs[0] # untouched messages keep identity

def test_strip_empty_text_blocks_treats_non_string_text_value_as_empty(self):
from litellm.llms.anthropic.common_utils import (
strip_empty_text_blocks_from_anthropic_messages,
)

msgs = [
{
"role": "user",
"content": [
{"type": "text", "text": 123},
{"type": "tool_result", "tool_use_id": "x", "content": "y"},
],
}
]
out = strip_empty_text_blocks_from_anthropic_messages(msgs)
assert [b["type"] for b in out[0]["content"]] == ["tool_result"]

def test_anthropic_messages_config_http_retry_helpers(self):
import httpx

Expand Down
Loading