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
46 changes: 37 additions & 9 deletions litellm/llms/anthropic/common_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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`.
Expand All @@ -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:
Expand All @@ -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_-]+$``
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1029,6 +1029,8 @@ def _delta_has_content(processed_chunk: dict[str, Any]) -> bool:

@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
Expand All @@ -1039,7 +1041,11 @@ def _is_blank_delta(chunk: "ModelResponseStream") -> bool:
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
Comment on lines +1048 to 1049

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Signed chunks are dropped

When a stream starts with empty thinking and a signature, this check skips the chunk, dropping the signature needed for later replay

Knowledge Base Used:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The classifier captures the skipped chunk's signature into the pending block start, so it survives; new tests pin both carry and discard paths

return True

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,10 @@ def create_tool_name_mapping(
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,
)
Expand Down Expand Up @@ -1264,6 +1267,8 @@ def _translate_openai_content_to_anthropic(
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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
8 changes: 4 additions & 4 deletions litellm/llms/base_llm/anthropic_messages/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,20 +159,20 @@ def should_retry_anthropic_messages_on_http_error(self, e: httpx.HTTPStatusError
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
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading
Loading