diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 31131d722ab..af334c914d7 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -788,6 +788,49 @@ def is_anthropic_invalid_thinking_signature_error(error_text: str) -> bool: ) +def is_anthropic_thinking_blocks_modified_error(error_text: str) -> bool: + """ + Detect Anthropic 400 when thinking/redacted_thinking blocks in the assistant + history have been altered, reordered, or lost between turns. + + Clients that persist thinking blocks to disk and replay them (e.g. opencode, + Claude Code) routinely corrupt these blocks via JSON round-tripping, repair + routines, or concurrent writers from background sub-agents. Anthropic then + rejects the next call because thinking block signatures cover their original + byte content. See https://github.com/anthropics/claude-code/issues/22278 and + related opencode reports. + + Example API message: + messages.N.content.M: `thinking` or `redacted_thinking` blocks in the + latest assistant message cannot be modified. These blocks must remain as + they were in the original response. + """ + if not error_text: + return False + lower = error_text.lower() + return ( + "thinking" in lower + and "block" in lower + and ("cannot be modified" in lower or "must remain" in lower) + ) + + +def is_anthropic_recoverable_thinking_block_error(error_text: str) -> bool: + """ + True when an Anthropic 400 can be recovered by stripping all thinking and + redacted_thinking content blocks from prior assistant messages and retrying. + + Covers two known error patterns that share the same fix: + 1. Invalid `signature` in `thinking` block (signature mismatch after a + deployment / credential change). + 2. `thinking` or `redacted_thinking` blocks ... cannot be modified + (client-side corruption of stored thinking blocks). + """ + return is_anthropic_invalid_thinking_signature_error( + error_text + ) or is_anthropic_thinking_blocks_modified_error(error_text) + + def strip_thinking_blocks_from_anthropic_messages(messages: List[Any]) -> List[Any]: """ Return a new message list with thinking / redacted_thinking content blocks removed diff --git a/litellm/llms/base_llm/anthropic_messages/transformation.py b/litellm/llms/base_llm/anthropic_messages/transformation.py index 49aa563781f..ba76383e8c4 100644 --- a/litellm/llms/base_llm/anthropic_messages/transformation.py +++ b/litellm/llms/base_llm/anthropic_messages/transformation.py @@ -138,12 +138,12 @@ 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_recoverable_thinking_block_error, ) return ( e.response.status_code == 400 - and is_anthropic_invalid_thinking_signature_error(e.response.text) + and is_anthropic_recoverable_thinking_block_error(e.response.text) ) def transform_anthropic_messages_request_on_http_error( @@ -153,13 +153,13 @@ class BaseAnthropicMessagesConfig(ABC): 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_recoverable_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) + and is_anthropic_recoverable_thinking_block_error(e.response.text) ): strip_thinking_blocks_from_anthropic_messages_request_dict(request_data) return request_data diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 36f6b3903e1..16f41ad054c 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -1923,11 +1923,16 @@ class BaseLLMHTTPHandler: ) ) if should_retry and not hit_max_attempt: + _err_text = getattr(e.response, "text", None) + if not isinstance(_err_text, str): + _err_text = "" verbose_logger.debug( - "Anthropic /v1/messages: invalid thinking signature; " - "stripping thinking blocks and retrying (attempt %s/%s).", + "Anthropic /v1/messages: recoverable thinking-block error; " + "stripping thinking blocks and retrying (attempt %s/%s). " + "Cause: %s", attempt_idx + 2, max_attempts, + _err_text[:500], ) provider_config.transform_anthropic_messages_request_on_http_error( e=e, request_data=request_body diff --git a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py index d34b6ffc831..dd74b5fe276 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py @@ -1164,6 +1164,61 @@ class TestAnthropicThinkingSignatureSelfHeal: is False ) + def test_is_anthropic_thinking_blocks_modified_error_positive(self): + from litellm.llms.anthropic.common_utils import ( + is_anthropic_thinking_blocks_modified_error, + ) + + raw = ( + '{"type":"error","error":{"type":"invalid_request_error",' + '"message":"messages.1.content.488: `thinking` or `redacted_thinking` ' + "blocks in the latest assistant message cannot be modified. These " + 'blocks must remain as they were in the original response."},' + '"request_id":"req_011CXVnWn4RVUr9hksVHoruj"}' + ) + assert is_anthropic_thinking_blocks_modified_error(raw) is True + + # Variant that uses just "must remain" without "cannot be modified". + must_remain_only = ( + "messages.13.content.1: thinking blocks must remain as they were " + "in the original response." + ) + assert is_anthropic_thinking_blocks_modified_error(must_remain_only) is True + + def test_is_anthropic_thinking_blocks_modified_error_negative(self): + from litellm.llms.anthropic.common_utils import ( + is_anthropic_thinking_blocks_modified_error, + ) + + assert is_anthropic_thinking_blocks_modified_error("") is False + # The signature-mismatch error must not be misclassified here. + sig_error = "messages.3.content.3: Invalid `signature` in `thinking` block" + assert is_anthropic_thinking_blocks_modified_error(sig_error) is False + # Unrelated 400 must not match. + assert ( + is_anthropic_thinking_blocks_modified_error( + "messages: text content blocks must be non-empty" + ) + is False + ) + + def test_is_anthropic_recoverable_thinking_block_error_covers_both(self): + from litellm.llms.anthropic.common_utils import ( + is_anthropic_recoverable_thinking_block_error, + ) + + sig_error = "messages.3.content.3: Invalid `signature` in `thinking` block" + modified_error = ( + "messages.1.content.488: `thinking` or `redacted_thinking` blocks " + "in the latest assistant message cannot be modified." + ) + unrelated_error = "rate limit exceeded" + + assert is_anthropic_recoverable_thinking_block_error(sig_error) is True + assert is_anthropic_recoverable_thinking_block_error(modified_error) is True + assert is_anthropic_recoverable_thinking_block_error(unrelated_error) is False + assert is_anthropic_recoverable_thinking_block_error("") is False + def test_strip_thinking_blocks_from_anthropic_messages(self): from litellm.llms.anthropic.common_utils import ( strip_thinking_blocks_from_anthropic_messages, diff --git a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py index 07a61c9c104..3f806f5ac92 100644 --- a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py @@ -575,6 +575,117 @@ async def test_anthropic_post_falls_back_to_json_dumps_when_unsigned_none(): assert sent == _json.dumps(request_body) +def _make_anthropic_thinking_modified_400(error_message: str) -> httpx.HTTPStatusError: + """Build an httpx.HTTPStatusError that mimics Anthropic's 400 response.""" + import json as _json + + err_body = _json.dumps( + { + "type": "error", + "error": { + "type": "invalid_request_error", + "message": error_message, + }, + } + ) + response = Mock(spec=httpx.Response) + response.status_code = 400 + response.text = err_body + return httpx.HTTPStatusError("bad", request=Mock(), response=response) + + +@pytest.mark.asyncio +async def test_anthropic_post_recovers_from_modified_thinking_blocks(): + r"""Regression test for the opencode / Claude-Code 'thinking blocks ... cannot + be modified' 400 that bricks an extended-thinking session. + + Before the fix, ``AnthropicMessagesConfig.should_retry_anthropic_messages_on_http_error`` + only matched the ``Invalid \`signature\` in \`thinking\` block`` variant, so this + error bubbled up to the caller (e.g. opencode) and crashed the agent. The + fix recognises both patterns as recoverable: strip thinking / redacted_thinking + blocks from the request and retry once.""" + import json as _json + + from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, + ) + + handler = BaseLLMHTTPHandler() + request_body = { + "model": "claude-opus-4-5-20251101", + "messages": [ + {"role": "user", "content": "ping"}, + { + "role": "assistant", + "content": [ + # Two thinking blocks that look like the corrupted state the + # client persisted to disk -- they will be stripped on retry. + { + "type": "thinking", + "thinking": "older plan", + "signature": "sig-old", + }, + { + "type": "thinking", + "thinking": "newer plan", + "signature": "sig-new", + }, + {"type": "text", "text": "hello"}, + ], + }, + {"role": "user", "content": "again"}, + ], + "thinking": {"type": "enabled", "budget_tokens": 1024}, + } + + err_resp = Mock() + err_resp.raise_for_status = Mock( + side_effect=_make_anthropic_thinking_modified_400( + "messages.1.content.488: `thinking` or `redacted_thinking` blocks " + "in the latest assistant message cannot be modified. These blocks " + "must remain as they were in the original response." + ) + ) + ok_resp = Mock() + ok_resp.raise_for_status = Mock(return_value=None) + http_client = Mock() + http_client.post = AsyncMock(side_effect=[err_resp, ok_resp]) + + logging_obj = Mock() + logging_obj.model_call_details = {} + + await handler._async_post_anthropic_messages_with_http_error_retry( + async_httpx_client=http_client, + request_url="http://x/v1/messages", + headers={}, + signed_json_body=None, + request_body=request_body, + stream=False, + logging_obj=logging_obj, + provider_config=AnthropicMessagesConfig(), + litellm_params=GenericLiteLLMParams(), + api_key="k", + model="claude-opus-4-5-20251101", + ) + + assert http_client.post.await_count == 2 + retry_body = _json.loads(http_client.post.await_args_list[1].kwargs["data"]) + + # Retry must drop the extended-thinking config and strip thinking blocks + # from the persisted assistant message, while leaving the surrounding + # user messages and the assistant text content intact. + assert "thinking" not in retry_body + assistant_msg = retry_body["messages"][1] + assert assistant_msg["role"] == "assistant" + assert all( + block["type"] not in ("thinking", "redacted_thinking") + for block in assistant_msg["content"] + ) + assert any(block["type"] == "text" for block in assistant_msg["content"]) + assert retry_body["messages"][0]["content"] == "ping" + assert retry_body["messages"][-1]["content"] == "again" + + @pytest.mark.asyncio async def test_anthropic_post_retry_reserializes_mutated_body(): """On a retryable HTTP error the body is mutated + re-signed; the prebuilt