From a54af4f59a1df0222dd0a150862f841a4092754b Mon Sep 17 00:00:00 2001 From: mchtech Date: Sat, 9 May 2026 11:12:39 +0800 Subject: [PATCH] fix(anthropic): fix clear_thinking retry body after thinking strip - restore top-level thinking when invalid-signature retry keeps clear_thinking_20251015 - keep the fix scoped to Anthropic Messages passthrough retry handling - avoid carrying manual budget_tokens into adaptive thinking retries - add behavior coverage for enabled, adaptive, disabled, missing, and non-adaptive cases --- .../messages/transformation.py | 42 +++++ .../anthropic/test_anthropic_common_utils.py | 155 ++++++++++++++++++ 2 files changed, 197 insertions(+) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index 35495d59610..a8fb9f09e1b 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -332,6 +332,48 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): ) return dict(anthropic_messages_request) + def transform_anthropic_messages_request_on_http_error( + self, e: httpx.HTTPStatusError, request_data: dict + ) -> dict: + # The base retry removes invalid historical thinking blocks and also drops + # top-level thinking. Keep the caller's original mode so clear_thinking + # retries for Claude 4.6+ can still satisfy Anthropic's request contract. + original_thinking = request_data.get("thinking") + request_data = super().transform_anthropic_messages_request_on_http_error( + e=e, request_data=request_data + ) + model = request_data.get("model") + context_management = request_data.get("context_management") + edits = ( + context_management.get("edits") + if isinstance(context_management, dict) + else None + ) + thinking = request_data.get("thinking") + has_active_thinking = isinstance(thinking, dict) and thinking.get("type") in ( + "enabled", + "adaptive", + ) + has_clear_thinking = isinstance(edits, list) and any( + isinstance(edit, dict) and edit.get("type") == "clear_thinking_20251015" + for edit in edits + ) + if ( + isinstance(model, str) + and isinstance(original_thinking, dict) + and original_thinking.get("type") in ("enabled", "adaptive") + and has_clear_thinking + and not has_active_thinking + and AnthropicModelInfo._is_adaptive_thinking_model(model) + ): + # clear_thinking edits still require active thinking on Claude 4.6+ retries. + restored_thinking = dict(original_thinking) + if restored_thinking.get("type") == "adaptive": + # Adaptive thinking chooses its budget server-side. + restored_thinking.pop("budget_tokens", None) + request_data["thinking"] = restored_thinking + return request_data + def transform_anthropic_messages_response( self, model: str, 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 2f57ce5d180..11d468422d5 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py @@ -1141,6 +1141,18 @@ class TestPassthroughAuthToken: class TestAnthropicThinkingSignatureSelfHeal: """Helpers for retrying after invalid encrypted thinking signatures.""" + def _invalid_thinking_signature_error(self): + import httpx + + 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) + return httpx.HTTPStatusError("bad", request=req, response=resp) + def test_is_anthropic_invalid_thinking_signature_error_positive(self): from litellm.llms.anthropic.common_utils import ( is_anthropic_invalid_thinking_signature_error, @@ -1229,6 +1241,149 @@ class TestAnthropicThinkingSignatureSelfHeal: assert "thinking" not in data assert data["messages"] == [] + def test_anthropic_messages_retry_restores_enabled_thinking_for_clear_thinking( + self, + ): + from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, + ) + + data = { + "model": "claude-sonnet-4-6", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "x", + "signature": "y", + }, + {"type": "text", "text": "answer"}, + ], + } + ], + "context_management": {"edits": [{"type": "clear_thinking_20251015"}]}, + "thinking": {"type": "enabled", "budget_tokens": 1024}, + } + + AnthropicMessagesConfig().transform_anthropic_messages_request_on_http_error( + self._invalid_thinking_signature_error(), data + ) + + assert data["thinking"] == {"type": "enabled", "budget_tokens": 1024} + assert data["messages"][0]["content"] == [{"type": "text", "text": "answer"}] + + def test_anthropic_messages_retry_restores_adaptive_thinking_without_budget( + self, + ): + from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, + ) + + data = { + "model": "claude-sonnet-4-6", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "redacted_thinking", + "data": "encrypted", + }, + {"type": "text", "text": "answer"}, + ], + } + ], + "context_management": {"edits": [{"type": "clear_thinking_20251015"}]}, + "thinking": {"type": "adaptive", "budget_tokens": 4096}, + } + + AnthropicMessagesConfig().transform_anthropic_messages_request_on_http_error( + self._invalid_thinking_signature_error(), data + ) + + assert data["thinking"] == {"type": "adaptive"} + assert data["messages"][0]["content"] == [{"type": "text", "text": "answer"}] + + def test_anthropic_messages_retry_does_not_restore_disabled_thinking( + self, + ): + from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, + ) + + data = { + "model": "claude-sonnet-4-6", + "messages": [{"role": "user", "content": "hi"}], + "context_management": {"edits": [{"type": "clear_thinking_20251015"}]}, + "thinking": {"type": "disabled"}, + } + + AnthropicMessagesConfig().transform_anthropic_messages_request_on_http_error( + self._invalid_thinking_signature_error(), data + ) + + assert "thinking" not in data + + def test_anthropic_messages_retry_does_not_restore_missing_thinking( + self, + ): + from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, + ) + + data = { + "model": "claude-sonnet-4-6", + "messages": [{"role": "user", "content": "hi"}], + "context_management": {"edits": [{"type": "clear_thinking_20251015"}]}, + } + + AnthropicMessagesConfig().transform_anthropic_messages_request_on_http_error( + self._invalid_thinking_signature_error(), data + ) + + assert "thinking" not in data + + def test_anthropic_messages_retry_does_not_restore_without_clear_thinking( + self, + ): + from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, + ) + + data = { + "model": "claude-sonnet-4-6", + "messages": [{"role": "user", "content": "hi"}], + "thinking": {"type": "adaptive"}, + } + + AnthropicMessagesConfig().transform_anthropic_messages_request_on_http_error( + self._invalid_thinking_signature_error(), data + ) + + assert "thinking" not in data + + def test_anthropic_messages_retry_does_not_restore_for_non_adaptive_model( + self, + ): + from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, + ) + + data = { + "model": "claude-sonnet-4-20250514", + "messages": [{"role": "user", "content": "hi"}], + "context_management": {"edits": [{"type": "clear_thinking_20251015"}]}, + "thinking": {"type": "enabled", "budget_tokens": 1024}, + } + + AnthropicMessagesConfig().transform_anthropic_messages_request_on_http_error( + self._invalid_thinking_signature_error(), data + ) + + assert "thinking" not in data + def test_anthropic_messages_config_http_retry_helpers(self): import httpx