fix(anthropic): self-heal opencode 'thinking blocks cannot be modified' 400

Anthropic returns a 400 when clients replay thinking / redacted_thinking
blocks that have been mutated (reordered, truncated, JSON-roundtripped,
or stripped of fields).  Clients that persist these blocks to disk and
replay them across sessions (opencode, Claude Code, ...) routinely hit
this because their history-repair routines or JSONL writers corrupt the
opaque blob that Anthropic validates byte-for-byte.

LiteLLM already self-heals one variant of this error: 'Invalid signature
in thinking block'.  The strip-and-retry path is identical for the other
variant -- the latest assistant message's thinking blocks 'cannot be
modified' -- but the matcher only recognised the first phrasing, so this
400 bubbled up to the caller and crashed the agent loop.

Recognise both phrasings as recoverable: strip thinking blocks from the
request, drop the top-level 'thinking' param, and retry once.  Add a
shared 'is_anthropic_recoverable_thinking_block_error' helper used by
'should_retry_anthropic_messages_on_http_error' and
'transform_anthropic_messages_request_on_http_error' so both predicates
stay in lock-step.

Tests cover the new detector, the combined helper, and an end-to-end
retry-loop regression that drives the real AnthropicMessagesConfig
against a mocked 400 carrying the new error message and asserts that
attempt 2 ships a sanitized body.

Co-authored-by: ishaan-berri <ishaan-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-05-27 00:56:47 +00:00
parent b6fd7f7746
commit fbc209486e
No known key found for this signature in database
5 changed files with 220 additions and 6 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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