From c7f7708d27593a4c5ecf44f9a00c4ec2ffd6a0b1 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 14 Apr 2026 09:39:39 +0530 Subject: [PATCH 1/3] feat(anthropic): retry /v1/messages after invalid thinking signature Strip thinking blocks from the request body and retry once when Anthropic returns an invalid thinking signature error (e.g. after credential or deployment change). Applies to all BaseAnthropicMessagesConfig providers (direct Anthropic, Bedrock, Vertex, Azure AI). Made-with: Cursor --- litellm/llms/anthropic/common_utils.py | 58 +++++++++ .../anthropic_messages/transformation.py | 37 ++++++ litellm/llms/custom_httpx/llm_http_handler.py | 70 +++++++++-- .../anthropic/test_anthropic_common_utils.py | 118 ++++++++++++++++++ 4 files changed, 272 insertions(+), 11 deletions(-) diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index a0da14bcc2b..4f7f3814e74 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -2,6 +2,7 @@ This file contains common utils for anthropic calls. """ +import copy from typing import Any, Dict, List, Optional, Union import httpx @@ -736,6 +737,63 @@ def strip_advisor_blocks_from_messages( return messages +def is_anthropic_invalid_thinking_signature_error(error_text: str) -> bool: + """ + Detect Anthropic 400 when encrypted thinking signatures in history do not match + the current deployment (e.g. user rotated API key or switched model endpoint). + + Example API message: + messages.N.content.M: Invalid `signature` in `thinking` block + """ + if not error_text: + return False + lower = error_text.lower() + return ( + "invalid" in lower + and "signature" in lower + and "thinking" in lower + and "block" in lower + ) + + +def strip_thinking_blocks_from_anthropic_messages(messages: List[Any]) -> List[Any]: + """ + Return a new message list with thinking / redacted_thinking content blocks removed + from each message. Used to recover from invalid thinking signatures on retry. + """ + out: List[Any] = [] + for m in messages: + if not isinstance(m, dict): + out.append(m) + continue + mm = copy.deepcopy(m) + content = mm.get("content") + if isinstance(content, list): + mm["content"] = [ + b + for b in content + if not ( + isinstance(b, dict) + and b.get("type") in ("thinking", "redacted_thinking") + ) + ] + out.append(mm) + return out + + +def strip_thinking_blocks_from_anthropic_messages_request_dict( + data: Dict[str, Any], +) -> None: + """ + Mutate an Anthropic Messages-style request dict: strip thinking blocks from + ``messages`` and remove the top-level ``thinking`` extended-thinking param. + """ + msgs = data.get("messages") + if isinstance(msgs, list): + data["messages"] = strip_thinking_blocks_from_anthropic_messages(msgs) + data.pop("thinking", None) + + def process_anthropic_headers(headers: Union[httpx.Headers, dict]) -> dict: openai_headers = {} if "anthropic-ratelimit-requests-limit" in headers: diff --git a/litellm/llms/base_llm/anthropic_messages/transformation.py b/litellm/llms/base_llm/anthropic_messages/transformation.py index fdad1633e8f..40063c0fb9a 100644 --- a/litellm/llms/base_llm/anthropic_messages/transformation.py +++ b/litellm/llms/base_llm/anthropic_messages/transformation.py @@ -120,3 +120,40 @@ class BaseAnthropicMessagesConfig(ABC): return BaseLLMException( message=error_message, status_code=status_code, headers=headers ) + + @property + def max_retry_on_anthropic_messages_http_error(self) -> int: + """ + Max HTTP attempts for /v1/messages when the handler may mutate the body and + retry (e.g. strip invalid encrypted thinking signatures after a deployment or + credential change). + """ + return 2 + + def should_retry_anthropic_messages_on_http_error( + self, e: httpx.HTTPStatusError, litellm_params: dict + ) -> bool: + """ + When True, async_anthropic_messages_handler will transform the request body + 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, + ) + + return is_anthropic_invalid_thinking_signature_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, + strip_thinking_blocks_from_anthropic_messages_request_dict, + ) + + if is_anthropic_invalid_thinking_signature_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 7a8820a8785..06f030fc2ce 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -1955,18 +1955,66 @@ class BaseLLMHTTPHandler: }, ) - try: - response = await async_httpx_client.post( - url=request_url, - headers=headers, - data=signed_json_body or json.dumps(request_body), - stream=stream or False, - logging_obj=logging_obj, - ) - response.raise_for_status() - except Exception as e: + max_anthropic_messages_http_attempts = max( + anthropic_messages_provider_config.max_retry_on_anthropic_messages_http_error, + 1, + ) + response: Optional[httpx.Response] = None + litellm_params_dict = dict(litellm_params) + for attempt_idx in range(max_anthropic_messages_http_attempts): + try: + response = await async_httpx_client.post( + url=request_url, + headers=headers, + data=signed_json_body or json.dumps(request_body), + stream=stream or False, + logging_obj=logging_obj, + ) + response.raise_for_status() + except httpx.HTTPStatusError as e: + hit_max_attempt = ( + attempt_idx + 1 == max_anthropic_messages_http_attempts + ) + should_retry = anthropic_messages_provider_config.should_retry_anthropic_messages_on_http_error( + e=e, litellm_params=litellm_params_dict + ) + if should_retry and not hit_max_attempt: + verbose_logger.debug( + "Retrying on HTTPStatusError (attempt %s/%s).", + attempt_idx + 2, + max_anthropic_messages_http_attempts, + ) + + request_body = anthropic_messages_provider_config.transform_anthropic_messages_request_on_http_error( + e=e, request_data=request_body + ) + headers, signed_json_body = ( + anthropic_messages_provider_config.sign_request( + headers=headers, + optional_params=dict(litellm_params), + request_data=request_body, + api_base=request_url, + api_key=api_key, + stream=stream, + fake_stream=False, + model=model, + ) + ) + logging_obj.model_call_details.update(request_body) + continue + raise self._handle_error( + e=e, provider_config=anthropic_messages_provider_config + ) + except Exception as e: + raise self._handle_error( + e=e, provider_config=anthropic_messages_provider_config + ) + break + + if response is None: raise self._handle_error( - e=e, provider_config=anthropic_messages_provider_config + e=ValueError("No response from Anthropic /v1/messages"), + provider_config=anthropic_messages_provider_config, ) # used for logging + cost tracking 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 22470b93540..ba344403912 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py @@ -1131,3 +1131,121 @@ class TestPassthroughAuthToken: ) assert url == "https://custom.example.com/v1/messages" + + +class TestAnthropicThinkingSignatureSelfHeal: + """Helpers for retrying after invalid encrypted thinking signatures.""" + + def test_is_anthropic_invalid_thinking_signature_error_positive(self): + from litellm.llms.anthropic.common_utils import ( + is_anthropic_invalid_thinking_signature_error, + ) + + raw = ( + '{"type":"error","error":{"type":"invalid_request_error",' + '"message":"messages.3.content.3: Invalid `signature` in `thinking` block"},' + '"request_id":"req_011Ca2EtQDxp7x6RGUY2jVn9"}' + ) + assert is_anthropic_invalid_thinking_signature_error(raw) is True + + def test_is_anthropic_invalid_thinking_signature_error_negative(self): + from litellm.llms.anthropic.common_utils import ( + is_anthropic_invalid_thinking_signature_error, + ) + + assert is_anthropic_invalid_thinking_signature_error("") is False + assert ( + is_anthropic_invalid_thinking_signature_error("rate limit exceeded") + is False + ) + + def test_strip_thinking_blocks_from_anthropic_messages(self): + from litellm.llms.anthropic.common_utils import ( + strip_thinking_blocks_from_anthropic_messages, + ) + + messages = [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "plan", "signature": "sig"}, + {"type": "text", "text": "hello"}, + ], + }, + ] + out = strip_thinking_blocks_from_anthropic_messages(messages) + assert len(out) == 2 + assert out[0] == messages[0] + assert len(out[1]["content"]) == 1 + assert out[1]["content"][0]["type"] == "text" + assert messages[1]["content"][0]["type"] == "thinking" + + def test_strip_thinking_blocks_from_anthropic_messages_request_dict(self): + from litellm.llms.anthropic.common_utils import ( + strip_thinking_blocks_from_anthropic_messages_request_dict, + ) + + data = { + "model": "claude-sonnet-4-20250514", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "x", + "signature": "y", + }, + ], + } + ], + "thinking": {"type": "enabled", "budget_tokens": 1024}, + } + strip_thinking_blocks_from_anthropic_messages_request_dict(data) + assert "thinking" not in data + assert data["messages"][0]["content"] == [] + + def test_anthropic_messages_config_http_retry_helpers(self): + import httpx + + from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, + ) + + config = AnthropicMessagesConfig() + assert config.max_retry_on_anthropic_messages_http_error == 2 + + req = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + err_text = ( + '{"type":"error","error":{"type":"invalid_request_error",' + '"message":"messages.3.content.3: Invalid `signature` in `thinking` block"},' + '"request_id":"req_011Ca2EtQDxp7x6RGUY2jVn9"}' + ) + resp = httpx.Response(400, request=req, text=err_text) + err = httpx.HTTPStatusError("bad", request=req, response=resp) + assert config.should_retry_anthropic_messages_on_http_error(err, {}) is True + + resp_bad = httpx.Response(400, request=req, text="rate limit exceeded") + err_bad = httpx.HTTPStatusError("bad", request=req, response=resp_bad) + assert config.should_retry_anthropic_messages_on_http_error(err_bad, {}) is False + + data = { + "model": "claude-sonnet-4-20250514", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "x", + "signature": "y", + }, + ], + } + ], + "thinking": {"type": "enabled", "budget_tokens": 1024}, + } + config.transform_anthropic_messages_request_on_http_error(err, data) + assert "thinking" not in data + assert data["messages"][0]["content"] == [] From 0f453cc59d928b85ef7223c8bd92c63be9c0b9b8 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 14 Apr 2026 10:00:27 +0530 Subject: [PATCH 2/3] Fic code qa --- litellm/llms/custom_httpx/llm_http_handler.py | 139 ++++++++++-------- 1 file changed, 79 insertions(+), 60 deletions(-) diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 06f030fc2ce..9155fbb4aca 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -1816,6 +1816,73 @@ class BaseLLMHTTPHandler: logging_obj=logging_obj, ) + async def _async_post_anthropic_messages_with_http_error_retry( + self, + async_httpx_client: AsyncHTTPHandler, + request_url: str, + headers: dict, + signed_json_body: Optional[bytes], + request_body: dict, + stream: bool, + logging_obj: LiteLLMLoggingObj, + provider_config: BaseAnthropicMessagesConfig, + litellm_params: GenericLiteLLMParams, + api_key: Optional[str], + model: str, + ) -> httpx.Response: + max_attempts = max(provider_config.max_retry_on_anthropic_messages_http_error, 1) + litellm_params_dict = dict(litellm_params) + optional_params_dict = dict(litellm_params) + response: Optional[httpx.Response] = None + for attempt_idx in range(max_attempts): + try: + response = await async_httpx_client.post( + url=request_url, + headers=headers, + data=signed_json_body or json.dumps(request_body), + stream=stream or False, + logging_obj=logging_obj, + ) + response.raise_for_status() + except httpx.HTTPStatusError as e: + hit_max_attempt = attempt_idx + 1 == max_attempts + should_retry = provider_config.should_retry_anthropic_messages_on_http_error( + e=e, litellm_params=litellm_params_dict + ) + if should_retry and not hit_max_attempt: + verbose_logger.debug( + "Anthropic /v1/messages: invalid thinking signature; " + "stripping thinking blocks and retrying (attempt %s/%s).", + attempt_idx + 2, + max_attempts, + ) + provider_config.transform_anthropic_messages_request_on_http_error( + e=e, request_data=request_body + ) + headers, signed_json_body = provider_config.sign_request( + headers=headers, + optional_params=optional_params_dict, + request_data=request_body, + api_base=request_url, + api_key=api_key, + stream=stream, + fake_stream=False, + model=model, + ) + logging_obj.model_call_details.update(request_body) + continue + raise self._handle_error(e=e, provider_config=provider_config) + except Exception as e: + raise self._handle_error(e=e, provider_config=provider_config) + break + + if response is None: + raise self._handle_error( + e=ValueError("No response from Anthropic /v1/messages"), + provider_config=provider_config, + ) + return response + async def async_anthropic_messages_handler( self, model: str, @@ -1955,67 +2022,19 @@ class BaseLLMHTTPHandler: }, ) - max_anthropic_messages_http_attempts = max( - anthropic_messages_provider_config.max_retry_on_anthropic_messages_http_error, - 1, + response = await self._async_post_anthropic_messages_with_http_error_retry( + async_httpx_client=async_httpx_client, + request_url=request_url, + headers=headers, + signed_json_body=signed_json_body, + request_body=request_body, + stream=stream or False, + logging_obj=logging_obj, + provider_config=anthropic_messages_provider_config, + litellm_params=litellm_params, + api_key=api_key, + model=model, ) - response: Optional[httpx.Response] = None - litellm_params_dict = dict(litellm_params) - for attempt_idx in range(max_anthropic_messages_http_attempts): - try: - response = await async_httpx_client.post( - url=request_url, - headers=headers, - data=signed_json_body or json.dumps(request_body), - stream=stream or False, - logging_obj=logging_obj, - ) - response.raise_for_status() - except httpx.HTTPStatusError as e: - hit_max_attempt = ( - attempt_idx + 1 == max_anthropic_messages_http_attempts - ) - should_retry = anthropic_messages_provider_config.should_retry_anthropic_messages_on_http_error( - e=e, litellm_params=litellm_params_dict - ) - if should_retry and not hit_max_attempt: - verbose_logger.debug( - "Retrying on HTTPStatusError (attempt %s/%s).", - attempt_idx + 2, - max_anthropic_messages_http_attempts, - ) - - request_body = anthropic_messages_provider_config.transform_anthropic_messages_request_on_http_error( - e=e, request_data=request_body - ) - headers, signed_json_body = ( - anthropic_messages_provider_config.sign_request( - headers=headers, - optional_params=dict(litellm_params), - request_data=request_body, - api_base=request_url, - api_key=api_key, - stream=stream, - fake_stream=False, - model=model, - ) - ) - logging_obj.model_call_details.update(request_body) - continue - raise self._handle_error( - e=e, provider_config=anthropic_messages_provider_config - ) - except Exception as e: - raise self._handle_error( - e=e, provider_config=anthropic_messages_provider_config - ) - break - - if response is None: - raise self._handle_error( - e=ValueError("No response from Anthropic /v1/messages"), - provider_config=anthropic_messages_provider_config, - ) # used for logging + cost tracking logging_obj.model_call_details["httpx_response"] = response From 5670f6c7d49bb2255d9a9460336b74e4ea2157be Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 14 Apr 2026 10:03:10 +0530 Subject: [PATCH 3/3] fix(anthropic): tighten thinking-signature retry (Greptile) - Omit messages whose list content is empty after stripping thinking blocks - Retry only on HTTP 400 plus invalid-signature body match - Return response inline from retry loop; drop unreachable None guard - Tests: thinking-only turn dropped, non-400 no retry Made-with: Cursor --- litellm/llms/anthropic/common_utils.py | 8 +++++- .../anthropic_messages/transformation.py | 8 ++++-- litellm/llms/custom_httpx/llm_http_handler.py | 12 +++------ .../anthropic/test_anthropic_common_utils.py | 26 +++++++++++++++++-- 4 files changed, 41 insertions(+), 13 deletions(-) diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 4f7f3814e74..3be5a0c816f 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -760,6 +760,9 @@ def strip_thinking_blocks_from_anthropic_messages(messages: List[Any]) -> List[A """ Return a new message list with thinking / redacted_thinking content blocks removed from each message. Used to recover from invalid thinking signatures on retry. + + Messages whose content is a list and becomes empty after stripping are omitted, + since Anthropic rejects empty content arrays. """ out: List[Any] = [] for m in messages: @@ -769,7 +772,7 @@ def strip_thinking_blocks_from_anthropic_messages(messages: List[Any]) -> List[A mm = copy.deepcopy(m) content = mm.get("content") if isinstance(content, list): - mm["content"] = [ + filtered = [ b for b in content if not ( @@ -777,6 +780,9 @@ def strip_thinking_blocks_from_anthropic_messages(messages: List[Any]) -> List[A and b.get("type") in ("thinking", "redacted_thinking") ) ] + if not filtered: + continue + mm["content"] = filtered out.append(mm) return out diff --git a/litellm/llms/base_llm/anthropic_messages/transformation.py b/litellm/llms/base_llm/anthropic_messages/transformation.py index 40063c0fb9a..733faca8532 100644 --- a/litellm/llms/base_llm/anthropic_messages/transformation.py +++ b/litellm/llms/base_llm/anthropic_messages/transformation.py @@ -141,7 +141,9 @@ class BaseAnthropicMessagesConfig(ABC): is_anthropic_invalid_thinking_signature_error, ) - return is_anthropic_invalid_thinking_signature_error(e.response.text) + return e.response.status_code == 400 and is_anthropic_invalid_thinking_signature_error( + e.response.text + ) def transform_anthropic_messages_request_on_http_error( self, e: httpx.HTTPStatusError, request_data: dict @@ -154,6 +156,8 @@ class BaseAnthropicMessagesConfig(ABC): strip_thinking_blocks_from_anthropic_messages_request_dict, ) - if is_anthropic_invalid_thinking_signature_error(e.response.text): + if e.response.status_code == 400 and is_anthropic_invalid_thinking_signature_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 9155fbb4aca..60efa45df62 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -1833,7 +1833,6 @@ class BaseLLMHTTPHandler: max_attempts = max(provider_config.max_retry_on_anthropic_messages_http_error, 1) litellm_params_dict = dict(litellm_params) optional_params_dict = dict(litellm_params) - response: Optional[httpx.Response] = None for attempt_idx in range(max_attempts): try: response = await async_httpx_client.post( @@ -1844,6 +1843,7 @@ class BaseLLMHTTPHandler: logging_obj=logging_obj, ) response.raise_for_status() + return response except httpx.HTTPStatusError as e: hit_max_attempt = attempt_idx + 1 == max_attempts should_retry = provider_config.should_retry_anthropic_messages_on_http_error( @@ -1874,14 +1874,10 @@ class BaseLLMHTTPHandler: raise self._handle_error(e=e, provider_config=provider_config) except Exception as e: raise self._handle_error(e=e, provider_config=provider_config) - break - if response is None: - raise self._handle_error( - e=ValueError("No response from Anthropic /v1/messages"), - provider_config=provider_config, - ) - return response + raise RuntimeError( + "unreachable: anthropic messages HTTP retry loop exited without return" + ) async def async_anthropic_messages_handler( self, 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 ba344403912..d48d7716a8e 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py @@ -1181,6 +1181,24 @@ class TestAnthropicThinkingSignatureSelfHeal: assert out[1]["content"][0]["type"] == "text" assert messages[1]["content"][0]["type"] == "thinking" + def test_strip_thinking_blocks_drops_message_when_only_thinking_blocks(self): + from litellm.llms.anthropic.common_utils import ( + strip_thinking_blocks_from_anthropic_messages, + ) + + messages = [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "plan", "signature": "sig"}, + ], + }, + ] + out = strip_thinking_blocks_from_anthropic_messages(messages) + assert len(out) == 1 + assert out[0]["role"] == "user" + def test_strip_thinking_blocks_from_anthropic_messages_request_dict(self): from litellm.llms.anthropic.common_utils import ( strip_thinking_blocks_from_anthropic_messages_request_dict, @@ -1204,7 +1222,7 @@ class TestAnthropicThinkingSignatureSelfHeal: } strip_thinking_blocks_from_anthropic_messages_request_dict(data) assert "thinking" not in data - assert data["messages"][0]["content"] == [] + assert data["messages"] == [] def test_anthropic_messages_config_http_retry_helpers(self): import httpx @@ -1230,6 +1248,10 @@ class TestAnthropicThinkingSignatureSelfHeal: err_bad = httpx.HTTPStatusError("bad", request=req, response=resp_bad) assert config.should_retry_anthropic_messages_on_http_error(err_bad, {}) is False + resp_500 = httpx.Response(500, request=req, text=err_text) + err_500 = httpx.HTTPStatusError("bad", request=req, response=resp_500) + assert config.should_retry_anthropic_messages_on_http_error(err_500, {}) is False + data = { "model": "claude-sonnet-4-20250514", "messages": [ @@ -1248,4 +1270,4 @@ class TestAnthropicThinkingSignatureSelfHeal: } config.transform_anthropic_messages_request_on_http_error(err, data) assert "thinking" not in data - assert data["messages"][0]["content"] == [] + assert data["messages"] == []