fix(anthropic): drop and self-heal empty thinking blocks on /v1/messages (#38625)

* fix(anthropic): drop and self-heal empty thinking blocks on /v1/messages

* test(anthropic): pin early-signature carry across the blank thinking chunk skip
This commit is contained in:
tin-berri 2026-08-27 21:41:56 -07:00 committed by GitHub
parent bd7e9c1997
commit 39e5b0c2d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 335 additions and 62 deletions

View file

@ -974,19 +974,25 @@ def strip_advisor_blocks_from_messages(messages: list[Any], replace_with_text: b
return messages
def is_anthropic_invalid_thinking_signature_error(error_text: str) -> bool:
def is_anthropic_invalid_thinking_block_error(error_text: str) -> bool:
"""
Detect Anthropic 400 errors caused by missing or invalid thinking signatures.
Detect Anthropic 400 errors caused by invalid thinking blocks in replayed
history: a missing or invalid signature, or a block with empty thinking text.
Known error formats:
{"message":"messages.2.content.0.thinking.signature.str: Input should be a valid string"}
messages.N.content.M.thinking.signature.str: Input should be a valid string
messages.N.content.M: Invalid `signature` in `thinking` block
messages.N.content.M.thinking: each thinking block must contain thinking
"""
if not error_text:
return False
lower: Final = error_text.lower()
return "thinking" in lower and "signature" in lower and ("invalid" in lower or "valid string" in lower)
if "thinking" not in lower:
return False
if "signature" in lower and ("invalid" in lower or "valid string" in lower):
return True
return "must contain thinking" in lower
def strip_thinking_blocks_from_anthropic_messages(messages: list[Any]) -> list[Any]:
@ -1028,22 +1034,29 @@ def strip_thinking_blocks_from_anthropic_messages_request_dict(
data.pop("thinking", None)
def strip_empty_text_blocks_from_anthropic_messages(
def strip_empty_content_blocks_from_anthropic_messages(
messages: list[Any],
) -> list[Any]:
"""
Return a new message list with empty or whitespace-only ``{"type": "text"}``
content blocks removed.
and ``{"type": "thinking"}`` 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).
``"messages: text content blocks must be non-empty"`` and
``"messages.N.content.M.thinking: each thinking block must contain
thinking"`` respectively. Assistant messages routinely arrive with
``{"type": "text", "text": ""}`` alongside ``tool_use`` blocks (see
anthropics/anthropic-sdk-python#461), and a turn served by a
non-Anthropic reasoning model through the /v1/messages bridge can carry
``{"type": "thinking", "thinking": ""}`` when the model produced no
reasoning text (e.g. it went straight to parallel tool calls).
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.
``redacted_thinking`` blocks are never touched: they carry opaque
``data`` instead of thinking text.
Messages whose content is a list and becomes empty after stripping are
omitted, matching :func:`strip_thinking_blocks_from_anthropic_messages`.
@ -1056,7 +1069,7 @@ def strip_empty_text_blocks_from_anthropic_messages(
out.append(m)
continue
content = m["content"]
filtered = [b for b in content if not _is_empty_text_block(b)]
filtered = [b for b in content if not _is_empty_text_block(b) and not is_empty_thinking_block(b)]
if len(filtered) == len(content):
out.append(m)
elif filtered:
@ -1071,6 +1084,21 @@ def _is_empty_text_block(block: Any) -> bool:
return not isinstance(text, str) or not text.strip()
def is_empty_thinking_block(block: object) -> bool:
"""
True for a ``{"type": "thinking"}`` content block whose thinking text is
missing, not a string, or empty/whitespace-only after ``.strip()``.
Anthropic rejects such blocks with ``"each thinking block must contain
thinking"`` (whitespace-only included, verified live), regardless of any
signature they carry. ``redacted_thinking`` blocks are a different type
and always return False.
"""
if not isinstance(block, dict) or block.get("type") != "thinking":
return False
thinking: Final = block.get("thinking")
return not isinstance(thinking, str) or not thinking.strip()
def normalize_anthropic_tool_use_id(raw_id: str) -> str:
"""
Normalize a tool_use / tool_result id for Anthropic's ``^[a-zA-Z0-9_-]+$``

View file

@ -1029,6 +1029,8 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
@staticmethod
def _is_blank_delta(chunk: "ModelResponseStream") -> bool:
from litellm.llms.anthropic.common_utils import is_empty_thinking_block
choice: Final = chunk.choices[0]
if choice.finish_reason is not None:
return False
@ -1039,7 +1041,11 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
return False
if getattr(delta, "reasoning_content", None):
return False
if getattr(delta, "thinking_blocks", None):
# thinking_blocks whose entries are all empty (even if signed) must not
# open a block: the emitted {"type": "thinking", "thinking": ""} gets
# replayed as history and Anthropic rejects it (LIT-6357).
thinking_blocks: Final = getattr(delta, "thinking_blocks", None)
if thinking_blocks and any(isinstance(b, dict) and not is_empty_thinking_block(b) for b in thinking_blocks):
return False
return True

View file

@ -89,7 +89,10 @@ from litellm.litellm_core_utils.prompt_templates.factory import (
from litellm.litellm_core_utils.reasoning_effort_utils import (
reasoning_effort_from_thinking_budget,
)
from litellm.llms.anthropic.common_utils import normalize_anthropic_tool_use_id
from litellm.llms.anthropic.common_utils import (
is_empty_thinking_block,
normalize_anthropic_tool_use_id,
)
from litellm.llms.anthropic.experimental_pass_through.context_management import (
PolyfillResult,
)
@ -1264,6 +1267,8 @@ class LiteLLMAnthropicMessagesAdapter:
if hasattr(choice.message, "thinking_blocks") and choice.message.thinking_blocks:
for thinking_block in choice.message.thinking_blocks:
if thinking_block.get("type") == "thinking":
if is_empty_thinking_block(thinking_block):
continue
thinking_value = thinking_block.get("thinking", "")
signature_value = thinking_block.get("signature", "")
new_content.append(

View file

@ -17,7 +17,7 @@ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
from litellm.llms.anthropic.common_utils import (
flatten_unencrypted_web_search_results_in_anthropic_messages,
sanitize_tool_use_ids_in_anthropic_messages,
strip_empty_text_blocks_from_anthropic_messages,
strip_empty_content_blocks_from_anthropic_messages,
)
from litellm.llms.base_llm.anthropic_messages.transformation import (
BaseAnthropicMessagesConfig,
@ -242,17 +242,20 @@ async def anthropic_messages(
"""
Async: Make llm api request in Anthropic /messages API spec.
Runs the empty-text-block sanitizer before any backend dispatch.
Runs the empty-content-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)
# text content blocks ("messages: text content blocks must be
# non-empty") and empty thinking blocks ("each thinking block must
# contain thinking"). Multi-turn tool-use clients (e.g. Claude Code)
# routinely loop assistant responses that contain such blocks — an empty
# text block alongside tool_use, or an empty thinking block from a turn
# a non-Anthropic reasoning model served through the bridge — 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_content_blocks_from_anthropic_messages(messages)
# Replay of cross-provider tool history (e.g. kimi -> Anthropic) may carry
# ids like ``functions.Bash:0`` that violate Anthropic's id pattern.
messages = sanitize_tool_use_ids_in_anthropic_messages(messages)
@ -374,7 +377,7 @@ async def anthropic_messages(
api_base=api_base,
client=client,
custom_llm_provider=custom_llm_provider,
# messages were already empty-text-block sanitized at the top of this
# messages were already empty-content-block sanitized at the top of this
# function and are NOT reassigned before this dispatch, so the handler
# can skip its (otherwise redundant) second full-messages scan. Passed
# explicitly (not via **kwargs) so it only affects this direct
@ -451,7 +454,7 @@ def anthropic_messages_handler(
# ``_litellm_messages_presanitized`` to skip this redundant second
# full-messages scan. Pop it so it never leaks into provider params.
if not kwargs.pop("_litellm_messages_presanitized", False):
messages = strip_empty_text_blocks_from_anthropic_messages(messages)
messages = strip_empty_content_blocks_from_anthropic_messages(messages)
messages = sanitize_tool_use_ids_in_anthropic_messages(messages)
messages = flatten_unencrypted_web_search_results_in_anthropic_messages(messages)

View file

@ -159,20 +159,20 @@ class BaseAnthropicMessagesConfig(ABC):
and issue one more attempt (bounded by max_retry_on_anthropic_messages_http_error).
"""
from litellm.llms.anthropic.common_utils import (
is_anthropic_invalid_thinking_signature_error,
is_anthropic_invalid_thinking_block_error,
)
return e.response.status_code == 400 and is_anthropic_invalid_thinking_signature_error(e.response.text)
return e.response.status_code == 400 and is_anthropic_invalid_thinking_block_error(e.response.text)
def transform_anthropic_messages_request_on_http_error(self, e: httpx.HTTPStatusError, request_data: dict) -> dict:
"""
Mutates request_data in place when retrying after a recoverable HTTP error.
"""
from litellm.llms.anthropic.common_utils import (
is_anthropic_invalid_thinking_signature_error,
is_anthropic_invalid_thinking_block_error,
strip_thinking_blocks_from_anthropic_messages_request_dict,
)
if e.response.status_code == 400 and is_anthropic_invalid_thinking_signature_error(e.response.text):
if e.response.status_code == 400 and is_anthropic_invalid_thinking_block_error(e.response.text):
strip_thinking_blocks_from_anthropic_messages_request_dict(request_data)
return request_data

View file

@ -1013,6 +1013,35 @@ def test_translate_openai_content_to_anthropic_thinking_and_redacted_thinking():
assert result[1]["data"] == "REDACTED"
def test_translate_openai_content_to_anthropic_drops_empty_thinking_blocks():
"""LIT-6357 non-streaming producer half: a bridged reasoning model whose
thinking_blocks entry has empty or whitespace-only text (signed or not)
must not surface as {"type": "thinking", "thinking": ""} clients replay
it as history and Anthropic 400s with "each thinking block must contain
thinking". Non-empty thinking and redacted_thinking pass through."""
openai_choices = [
Choices(
message=Message(
role="assistant",
content="the answer",
thinking_blocks=[
{"type": "thinking", "thinking": "", "signature": "sig_abc"},
{"type": "thinking", "thinking": " \n "},
{"type": "thinking", "thinking": "real plan", "signature": "sigsig"},
{"type": "redacted_thinking", "data": "REDACTED"},
],
)
)
]
adapter = LiteLLMAnthropicMessagesAdapter()
result = adapter._translate_openai_content_to_anthropic(choices=openai_choices)
assert [b["type"] for b in result] == ["thinking", "redacted_thinking", "text"]
assert result[0]["thinking"] == "real plan"
assert result[1]["data"] == "REDACTED"
def test_translate_streaming_openai_chunk_to_anthropic_thinking_delta():
choices = [
StreamingChoices(

View file

@ -1027,3 +1027,141 @@ async def test_tool_block_start_flush_does_not_duplicate_or_drop_events(is_async
]
assert _input_json_deltas(events) == ['{"file_text":', ' "hello"}']
_assert_deltas_match_their_block_type(events)
def _thinking_block_starts(events: List[dict]) -> List[dict]:
return [
e["content_block"]
for e in events
if e.get("type") == "content_block_start" and e["content_block"].get("type") == "thinking"
]
def _empty_thinking_then_tool_chunks(thinking: str = "", signature: str = "") -> List[MagicMock]:
return [
_thinking_chunk(thinking, signature=signature),
_tool_chunk("call_paris", "get_weather", '{"city": "Paris"}'),
_make_chunk(Delta(content=None), finish_reason="tool_calls"),
]
@pytest.mark.parametrize("is_async", [False, True])
@pytest.mark.parametrize(
"thinking,signature",
[("", ""), (" \n\t ", ""), ("", "sig_abc")],
ids=["empty", "whitespace-only", "empty-but-signed"],
)
@pytest.mark.asyncio
async def test_contentless_thinking_chunk_opens_no_thinking_block(is_async: bool, thinking: str, signature: str):
"""LIT-6357 producer half: a reasoning model that goes straight to tool
calls streams a ``thinking_blocks`` entry with no real thinking text; the
wrapper used to open ``{"type": "thinking", "thinking": ""}`` for it and
close the block with no delta. Clients (Claude Code) replay that block as
history and Anthropic rejects the next tool-loop request with
"each thinking block must contain thinking" empty-but-signed included.
The contentless chunk must open nothing; the tool_use block must be
unaffected."""
chunks = _empty_thinking_then_tool_chunks(thinking, signature)
if is_async:
wrapper = AnthropicStreamWrapper(completion_stream=_AsyncStream(chunks), model="claude-x")
events = await _drain_async(wrapper)
else:
wrapper = AnthropicStreamWrapper(completion_stream=iter(chunks), model="claude-x")
events = _drain_sync(wrapper)
assert _thinking_block_starts(events) == []
assert _thinking_deltas(events) == []
tool_starts = [
e["content_block"]
for e in events
if e.get("type") == "content_block_start" and e["content_block"].get("type") == "tool_use"
]
assert [b["name"] for b in tool_starts] == ["get_weather"]
_assert_deltas_match_their_block_type(events)
@pytest.mark.parametrize("is_async", [False, True])
@pytest.mark.asyncio
async def test_empty_first_thinking_chunk_then_real_text_still_opens_one_block(is_async: bool):
"""The contentless-chunk skip must not eat a thinking stream whose first
chunk is empty but whose later chunks carry real text: exactly one thinking
block opens and the text flows into it."""
chunks = [
_thinking_chunk(""),
_thinking_chunk("Let me think"),
_thinking_chunk("", signature="sig123"),
_make_chunk(Delta(content="Hello")),
_make_chunk(Delta(content=None), finish_reason="stop"),
]
if is_async:
wrapper = AnthropicStreamWrapper(completion_stream=_AsyncStream(chunks), model="claude-x")
events = await _drain_async(wrapper)
else:
wrapper = AnthropicStreamWrapper(completion_stream=iter(chunks), model="claude-x")
events = _drain_sync(wrapper)
assert len(_thinking_block_starts(events)) == 1
assert _thinking_deltas(events) == ["Let me think"]
assert _signature_deltas(events) == ["sig123"]
assert _text_deltas(events) == ["Hello"]
_assert_deltas_match_their_block_type(events)
@pytest.mark.parametrize("is_async", [False, True])
@pytest.mark.asyncio
async def test_early_signature_on_blank_thinking_chunk_is_carried_to_the_opened_block(is_async: bool):
"""Pins that the blank-chunk skip does not lose an early signature: the
classifier captures the skipped chunk's signature into the pending block
start body, so when real thinking text follows, the opened block still
carries it. Guards the LIT-6357 blank-skip against regressing signature
replay."""
chunks = [
_thinking_chunk("", signature="sig_early"),
_thinking_chunk("Let me think"),
_make_chunk(Delta(content="Hello")),
_make_chunk(Delta(content=None), finish_reason="stop"),
]
if is_async:
wrapper = AnthropicStreamWrapper(completion_stream=_AsyncStream(chunks), model="claude-x")
events = await _drain_async(wrapper)
else:
wrapper = AnthropicStreamWrapper(completion_stream=iter(chunks), model="claude-x")
events = _drain_sync(wrapper)
starts = _thinking_block_starts(events)
assert len(starts) == 1
assert starts[0].get("signature") == "sig_early"
assert _thinking_deltas(events) == ["Let me think"]
assert _text_deltas(events) == ["Hello"]
_assert_deltas_match_their_block_type(events)
@pytest.mark.parametrize("is_async", [False, True])
@pytest.mark.asyncio
async def test_early_signature_discarded_when_first_block_is_not_thinking(is_async: bool):
"""An early signature from a skipped blank thinking chunk must not leak
into a text or tool_use first block, and must not resurrect an empty
thinking block on its own (an empty-but-signed block is exactly what
Anthropic rejects)."""
chunks = [
_thinking_chunk("", signature="sig_early"),
_make_chunk(Delta(content="Hello")),
_make_chunk(Delta(content=None), finish_reason="stop"),
]
if is_async:
wrapper = AnthropicStreamWrapper(completion_stream=_AsyncStream(chunks), model="claude-x")
events = await _drain_async(wrapper)
else:
wrapper = AnthropicStreamWrapper(completion_stream=iter(chunks), model="claude-x")
events = _drain_sync(wrapper)
assert _thinking_block_starts(events) == []
text_starts = [
e["content_block"]
for e in events
if e.get("type") == "content_block_start" and e["content_block"].get("type") == "text"
]
assert len(text_starts) == 1
assert "signature" not in text_starts[0]
assert _text_deltas(events) == ["Hello"]
_assert_deltas_match_their_block_type(events)

View file

@ -709,8 +709,8 @@ def test_handler_strips_when_no_presanitized_flag():
with patch.object(
handler,
"strip_empty_text_blocks_from_anthropic_messages",
wraps=handler.strip_empty_text_blocks_from_anthropic_messages,
"strip_empty_content_blocks_from_anthropic_messages",
wraps=handler.strip_empty_content_blocks_from_anthropic_messages,
) as spy:
result = handler.anthropic_messages_handler(
max_tokens=10,
@ -729,8 +729,8 @@ def test_handler_skips_strip_when_presanitized():
with patch.object(
handler,
"strip_empty_text_blocks_from_anthropic_messages",
wraps=handler.strip_empty_text_blocks_from_anthropic_messages,
"strip_empty_content_blocks_from_anthropic_messages",
wraps=handler.strip_empty_content_blocks_from_anthropic_messages,
) as spy:
result = handler.anthropic_messages_handler(
max_tokens=10,
@ -849,8 +849,8 @@ async def test_async_wrapper_sets_presanitized_and_sanitizes_once():
patch("asyncio.get_event_loop", return_value=fake_loop),
patch.object(
handler,
"strip_empty_text_blocks_from_anthropic_messages",
wraps=handler.strip_empty_text_blocks_from_anthropic_messages,
"strip_empty_content_blocks_from_anthropic_messages",
wraps=handler.strip_empty_content_blocks_from_anthropic_messages,
) as spy,
):
await handler.anthropic_messages(

View file

@ -1292,11 +1292,12 @@ class TestPassthroughAuthToken:
class TestAnthropicThinkingSignatureSelfHeal:
"""Helpers for retrying after invalid encrypted thinking signatures."""
"""Helpers for retrying after invalid thinking blocks in replayed history:
invalid encrypted signatures, and blocks with empty thinking text."""
def test_is_anthropic_invalid_thinking_signature_error_positive(self):
def test_is_anthropic_invalid_thinking_block_error_positive(self):
from litellm.llms.anthropic.common_utils import (
is_anthropic_invalid_thinking_signature_error,
is_anthropic_invalid_thinking_block_error,
)
raw = (
@ -1304,34 +1305,97 @@ class TestAnthropicThinkingSignatureSelfHeal:
'"message":"messages.3.content.3: Invalid `signature` in `thinking` block"},'
'"request_id":"req_011Ca2EtQDxp7x6RGUY2jVn9"}'
)
assert is_anthropic_invalid_thinking_signature_error(raw) is True
assert is_anthropic_invalid_thinking_block_error(raw) is True
def test_is_anthropic_invalid_thinking_signature_error_positive_bedrock(self):
def test_is_anthropic_invalid_thinking_block_error_positive_bedrock(self):
from litellm.llms.anthropic.common_utils import (
is_anthropic_invalid_thinking_signature_error,
is_anthropic_invalid_thinking_block_error,
)
# Real user-reported Bedrock scenario
raw = '{"message":"messages.2.content.0.thinking.signature.str: Input should be a valid string"}'
assert is_anthropic_invalid_thinking_signature_error(raw) is True
assert is_anthropic_invalid_thinking_block_error(raw) is True
def test_is_anthropic_invalid_thinking_signature_error_positive_vertex(self):
def test_is_anthropic_invalid_thinking_block_error_positive_vertex(self):
from litellm.llms.anthropic.common_utils import (
is_anthropic_invalid_thinking_signature_error,
is_anthropic_invalid_thinking_block_error,
)
raw = "messages.4.content.1.thinking.signature.str: Input should be a valid string"
assert is_anthropic_invalid_thinking_signature_error(raw) is True
assert is_anthropic_invalid_thinking_block_error(raw) is True
def test_is_anthropic_invalid_thinking_signature_error_negative(self):
def test_is_anthropic_invalid_thinking_block_error_negative(self):
from litellm.llms.anthropic.common_utils import (
is_anthropic_invalid_thinking_signature_error,
is_anthropic_invalid_thinking_block_error,
)
assert is_anthropic_invalid_thinking_signature_error("") is False
assert is_anthropic_invalid_thinking_signature_error("rate limit exceeded") is False
assert is_anthropic_invalid_thinking_signature_error("invalid_request_error: model not found") is False
assert is_anthropic_invalid_thinking_signature_error("thinking signature is malformed") is False
assert is_anthropic_invalid_thinking_block_error("") is False
assert is_anthropic_invalid_thinking_block_error("rate limit exceeded") is False
assert is_anthropic_invalid_thinking_block_error("invalid_request_error: model not found") is False
assert is_anthropic_invalid_thinking_block_error("thinking signature is malformed") is False
def test_is_anthropic_invalid_thinking_block_error_positive_empty_thinking(self):
"""LIT-6357: replayed history holding {"type": "thinking", "thinking": ""}
(produced when a non-Anthropic reasoning model's turn is bridged to the
Anthropic surface with no reasoning text) 400s with a message that names
no signature, so the pre-rename matcher missed it and the strip-and-retry
never fired. Raw string captured live on 2026-08-27."""
from litellm.llms.anthropic.common_utils import (
is_anthropic_invalid_thinking_block_error,
)
raw = (
'{"type":"error","error":{"type":"invalid_request_error",'
'"message":"messages.1.content.0.thinking: each thinking block must contain thinking"},'
'"request_id":"req_011CeUTxhJj2rTUkK61qtbJ8"}'
)
assert is_anthropic_invalid_thinking_block_error(raw) is True
def test_is_empty_thinking_block(self):
from litellm.llms.anthropic.common_utils import is_empty_thinking_block
assert is_empty_thinking_block({"type": "thinking", "thinking": ""}) is True
assert is_empty_thinking_block({"type": "thinking", "thinking": " \n\t "}) is True
assert is_empty_thinking_block({"type": "thinking", "thinking": None}) is True
assert is_empty_thinking_block({"type": "thinking"}) is True
assert is_empty_thinking_block({"type": "thinking", "thinking": "", "signature": "sig_abc"}) is True
assert is_empty_thinking_block({"type": "thinking", "thinking": "plan", "signature": "sig"}) is False
assert is_empty_thinking_block({"type": "redacted_thinking", "data": "opaque"}) is False
assert is_empty_thinking_block({"type": "text", "text": ""}) is False
assert is_empty_thinking_block("not a dict") is False
def test_strip_empty_content_blocks_drops_empty_thinking_blocks(self):
"""LIT-6357 ingestion half: an assistant tool-loop turn carrying an
empty (even signed) thinking block keeps its tool_use blocks and loses
the poison; whitespace-only counts as empty; a non-empty thinking block
and redacted_thinking are untouched."""
from litellm.llms.anthropic.common_utils import (
strip_empty_content_blocks_from_anthropic_messages,
)
tu = {"type": "tool_use", "id": "toolu_01A", "name": "get_weather", "input": {"city": "Paris"}}
msgs = [
{"role": "user", "content": "weather?"},
{
"role": "assistant",
"content": [{"type": "thinking", "thinking": "", "signature": "sig_abc"}, tu],
},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": " \n "},
{"type": "thinking", "thinking": "real plan", "signature": "sig"},
{"type": "redacted_thinking", "data": "opaque"},
],
},
{"role": "assistant", "content": [{"type": "thinking", "thinking": ""}]},
]
out = strip_empty_content_blocks_from_anthropic_messages(msgs)
assert len(out) == 3
assert [b["type"] for b in out[1]["content"]] == ["tool_use"]
assert [b["type"] for b in out[2]["content"]] == ["thinking", "redacted_thinking"]
assert out[2]["content"][0]["thinking"] == "real plan"
assert len(msgs[1]["content"]) == 2
def test_strip_thinking_blocks_from_anthropic_messages(self):
from litellm.llms.anthropic.common_utils import (
@ -1398,14 +1462,14 @@ class TestAnthropicThinkingSignatureSelfHeal:
assert "thinking" not in data
assert data["messages"] == []
def test_strip_empty_text_blocks_from_anthropic_messages(self):
def test_strip_empty_content_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,
strip_empty_content_blocks_from_anthropic_messages,
)
tu = {"type": "tool_use", "id": "x", "name": "Bash", "input": {}}
@ -1414,14 +1478,14 @@ class TestAnthropicThinkingSignatureSelfHeal:
{"role": "assistant", "content": [{"type": "text", "text": " \n "}, tu]},
{"role": "assistant", "content": [{"type": "text", "text": ""}]},
]
out = strip_empty_text_blocks_from_anthropic_messages(msgs)
out = strip_empty_content_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,
strip_empty_content_blocks_from_anthropic_messages,
)
msgs = [
@ -1433,12 +1497,12 @@ class TestAnthropicThinkingSignatureSelfHeal:
],
}
]
out = strip_empty_text_blocks_from_anthropic_messages(msgs)
out = strip_empty_content_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,
strip_empty_content_blocks_from_anthropic_messages,
)
msgs = [
@ -1450,12 +1514,12 @@ class TestAnthropicThinkingSignatureSelfHeal:
],
}
]
out = strip_empty_text_blocks_from_anthropic_messages(msgs)
out = strip_empty_content_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,
strip_empty_content_blocks_from_anthropic_messages,
)
msgs = [
@ -1467,21 +1531,21 @@ class TestAnthropicThinkingSignatureSelfHeal:
],
}
]
out = strip_empty_text_blocks_from_anthropic_messages(msgs)
out = strip_empty_content_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,
strip_empty_content_blocks_from_anthropic_messages,
)
msgs = [{"role": "assistant", "content": [{"type": "text", "text": "hi"}]}]
out = strip_empty_text_blocks_from_anthropic_messages(msgs)
out = strip_empty_content_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,
strip_empty_content_blocks_from_anthropic_messages,
)
msgs = [
@ -1493,7 +1557,7 @@ class TestAnthropicThinkingSignatureSelfHeal:
],
}
]
out = strip_empty_text_blocks_from_anthropic_messages(msgs)
out = strip_empty_content_blocks_from_anthropic_messages(msgs)
assert [b["type"] for b in out[0]["content"]] == ["tool_result"]
def test_flatten_unencrypted_web_search_results_keeps_snippet_evidence(self):