mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-01 02:02:20 +00:00
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:
parent
b6fd7f7746
commit
fbc209486e
5 changed files with 220 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue