mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
bd7e9c1997
commit
39e5b0c2d1
9 changed files with 335 additions and 62 deletions
|
|
@ -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_-]+$``
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue