diff --git a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py index dcf2e0244d4..98085c08a5e 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py @@ -13,6 +13,8 @@ from typing import ( import httpx from litellm.anthropic_beta_headers_manager import filter_and_transform_beta_headers +from litellm.constants import BEDROCK_MIN_THINKING_BUDGET_TOKENS +from litellm.litellm_core_utils.litellm_logging import verbose_logger from litellm.llms.anthropic.common_utils import AnthropicModelInfo from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( AnthropicMessagesConfig, @@ -211,6 +213,70 @@ class AmazonAnthropicClaudeMessagesConfig( return any(pattern in model_lower for pattern in supported_patterns) + def _ensure_thinking_for_clear_thinking_context_management( + self, + anthropic_messages_request: Dict, + model: str, + ) -> bool: + """ + Bedrock rejects ``clear_thinking_20251015`` context-management edits unless + extended thinking is ``enabled`` or ``adaptive``. Claude Code often sends + context management without a top-level ``thinking`` field. + + When we detect that edit type on a model that supports extended thinking on + Bedrock, inject a minimal ``thinking`` config so the request succeeds. + + Returns: + True if ``thinking`` was added or upgraded for this fix (caller may + need to add the interleaved-thinking beta header). + """ + cm = anthropic_messages_request.get("context_management") + if not isinstance(cm, dict): + return False + edits = cm.get("edits") + if not isinstance(edits, list): + return False + needs_thinking = any( + isinstance(e, dict) and e.get("type") == "clear_thinking_20251015" + for e in edits + ) + if not needs_thinking: + return False + if not self._supports_extended_thinking_on_bedrock(model): + return False + + thinking = anthropic_messages_request.get("thinking") + if isinstance(thinking, dict): + t = thinking.get("type") + if t in ("enabled", "adaptive"): + return False + # ``disabled`` or unknown — replace with enabled so clear_thinking is valid + verbose_logger.debug( + "Bedrock clear_thinking_20251015: replacing thinking=%s with minimal enabled thinking", + thinking, + ) + + max_tokens = anthropic_messages_request.get("max_tokens") + budget = BEDROCK_MIN_THINKING_BUDGET_TOKENS + if isinstance(max_tokens, int) and max_tokens <= budget: + verbose_logger.warning( + "Bedrock clear_thinking_20251015: max_tokens=%s is not greater than " + "minimum thinking budget (%s); cannot inject thinking safely", + max_tokens, + budget, + ) + return False + + anthropic_messages_request["thinking"] = { + "type": "enabled", + "budget_tokens": budget, + } + verbose_logger.debug( + "Bedrock clear_thinking_20251015: injected thinking with budget_tokens=%s", + budget, + ) + return True + def _is_claude_opus_4_5(self, model: str) -> bool: """ Check if the model is Claude Opus 4.5. @@ -412,6 +478,13 @@ class AmazonAnthropicClaudeMessagesConfig( if "model" in anthropic_messages_request: anthropic_messages_request.pop("model", None) + injected_thinking_for_clear_thinking = ( + self._ensure_thinking_for_clear_thinking_context_management( + anthropic_messages_request=anthropic_messages_request, + model=model, + ) + ) + # 4. Remove `ttl` field from cache_control in messages (Bedrock doesn't support it for older models) self._remove_ttl_from_cache_control( anthropic_messages_request=anthropic_messages_request, model=model @@ -459,6 +532,9 @@ class AmazonAnthropicClaudeMessagesConfig( ) beta_set.update(auto_betas) + if injected_thinking_for_clear_thinking: + beta_set.add("interleaved-thinking-2025-05-14") + self._get_tool_search_beta_header_for_bedrock( model=model, tool_search_used=tool_search_used, diff --git a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py index ea208007cd6..eefbf4805ef 100644 --- a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py +++ b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py @@ -11,7 +11,12 @@ import pytest sys.path.insert(0, os.path.abspath("../../../../../..")) from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj -from litellm.llms.bedrock.common_utils import remove_custom_field_from_tools +from litellm.llms.bedrock.common_utils import ( + ensure_bedrock_anthropic_messages_tool_names, + normalize_tool_input_schema_types_for_bedrock_invoke, + remove_custom_field_from_tools, +) +from litellm.constants import BEDROCK_MIN_THINKING_BUDGET_TOKENS from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import ( AmazonAnthropicClaudeMessagesConfig, AmazonAnthropicClaudeMessagesStreamDecoder, @@ -292,6 +297,195 @@ def test_remove_custom_field_from_tools(): assert request4["tools"] is None +def test_normalize_tool_input_schema_types_for_bedrock_invoke(): + """ + Claude Code sends ``input_schema.type: \"custom\"`` for custom tools. + Bedrock Invoke rejects this; it requires JSON Schema ``type: \"object\"``. + """ + + request = { + "tools": [ + { + "name": "Agent", + "type": "custom", + "description": "subagent", + "input_schema": { + "type": "custom", + "additionalProperties": False, + "properties": { + "nested": {"type": "custom", "properties": {"x": {"type": "string"}}} + }, + "required": ["nested"], + }, + }, + { + "name": "Read", + "input_schema": {"type": "object", "properties": {}}, + }, + ] + } + + normalize_tool_input_schema_types_for_bedrock_invoke(request) + + agent_tool = request["tools"][0] + assert agent_tool["type"] == "custom" + assert agent_tool["input_schema"]["type"] == "object" + assert agent_tool["input_schema"]["properties"]["nested"]["type"] == "object" + assert request["tools"][1]["input_schema"]["type"] == "object" + + request2 = {"messages": []} + normalize_tool_input_schema_types_for_bedrock_invoke(request2) + assert request2 == {"messages": []} + + +def test_ensure_bedrock_anthropic_messages_tool_names(): + request = { + "tools": [ + {"input_schema": {"type": "object", "properties": {}}}, + {"name": "", "input_schema": {"type": "object", "properties": {}}}, + {"name": " ", "input_schema": {"type": "object", "properties": {}}}, + {"name": "KeepMe", "input_schema": {"type": "object", "properties": {}}}, + ] + } + ensure_bedrock_anthropic_messages_tool_names(request) + assert request["tools"][0]["name"] == "litellm_unnamed_tool_0" + assert request["tools"][1]["name"] == "litellm_unnamed_tool_1" + assert request["tools"][2]["name"] == "litellm_unnamed_tool_2" + assert request["tools"][3]["name"] == "KeepMe" + + +def test_bedrock_invoke_messages_transform_adds_name_when_tool_missing_name(): + """Bedrock requires tools.0.custom.name when the payload is schema-only.""" + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + optional_params = { + "max_tokens": 128, + "tools": [ + { + "input_schema": { + "type": "object", + "properties": {"questions": {"type": "array"}}, + "required": ["questions"], + }, + } + ], + "stream": False, + } + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-3-haiku-20240307-v1:0", + messages=[{"role": "user", "content": "hi"}], + anthropic_messages_optional_request_params=copy.deepcopy(optional_params), + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert result["tools"][0]["name"] == "litellm_unnamed_tool_0" + + +def test_bedrock_invoke_messages_injects_thinking_for_clear_thinking_context_management(): + """ + Bedrock requires extended thinking when ``clear_thinking_20251015`` appears in + ``context_management`` (Claude Code sends CM without ``thinking``). + """ + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + optional_params = { + "max_tokens": 32000, + "stream": False, + "context_management": { + "edits": [{"type": "clear_thinking_20251015", "keep": "all"}] + }, + } + result = cfg.transform_anthropic_messages_request( + model="global.anthropic.claude-sonnet-4-6-v1:0", + messages=[{"role": "user", "content": "hi"}], + anthropic_messages_optional_request_params=copy.deepcopy(optional_params), + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert result["thinking"]["type"] == "enabled" + assert result["thinking"]["budget_tokens"] == BEDROCK_MIN_THINKING_BUDGET_TOKENS + betas = result.get("anthropic_beta") or [] + assert "interleaved-thinking-2025-05-14" in betas + + +def test_bedrock_invoke_messages_skips_thinking_injection_when_already_enabled(): + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + optional_params = { + "max_tokens": 32000, + "stream": False, + "thinking": {"type": "enabled", "budget_tokens": 2048}, + "context_management": { + "edits": [{"type": "clear_thinking_20251015", "keep": "all"}] + }, + } + result = cfg.transform_anthropic_messages_request( + model="global.anthropic.claude-sonnet-4-6-v1:0", + messages=[{"role": "user", "content": "hi"}], + anthropic_messages_optional_request_params=copy.deepcopy(optional_params), + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert result["thinking"]["budget_tokens"] == 2048 + betas = result.get("anthropic_beta") or [] + assert "interleaved-thinking-2025-05-14" not in betas + + +def test_bedrock_invoke_messages_transform_converts_custom_tool_schema_type_to_object(): + """ + End-to-end: AmazonAnthropicClaudeMessagesConfig must emit Bedrock Invoke bodies + where every ``input_schema`` uses JSON Schema types (``object``), not Anthropic + ``type: \"custom\"`` (root and nested). + """ + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + tools = [ + { + "name": "Agent", + "type": "custom", + "description": "Subagent", + "input_schema": { + "type": "custom", + "additionalProperties": False, + "properties": { + "prompt": {"type": "string"}, + "nested": { + "type": "custom", + "properties": {"x": {"type": "string"}}, + "required": ["x"], + }, + }, + "required": ["prompt"], + }, + } + ] + optional_params = { + "max_tokens": 256, + "tools": copy.deepcopy(tools), + "stream": False, + } + messages = [{"role": "user", "content": "hi"}] + + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-3-haiku-20240307-v1:0", + messages=messages, + anthropic_messages_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert "tools" in result + schema = result["tools"][0]["input_schema"] + assert schema["type"] == "object" + assert schema["properties"]["nested"]["type"] == "object" + # Tool discriminator stays Anthropic-side; only input_schema is normalized + assert result["tools"][0]["type"] == "custom" + + def test_remove_scope_from_cache_control(): """Ensure scope field is removed from cache_control for Bedrock (not supported)."""