feat(bedrock): inject thinking for clear_thinking context_management on Messages API

Bedrock rejects clear_thinking_20251015 unless thinking is enabled or adaptive.
Inject minimal extended thinking and interleaved-thinking beta when Claude Code
sends context_management without thinking. Adds unit tests.

Made-with: Cursor
This commit is contained in:
Sameer Kankute 2026-04-16 09:06:57 +05:30
parent bdffc3ae62
commit 0e7d9f8cc8
No known key found for this signature in database
2 changed files with 271 additions and 1 deletions

View file

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

View file

@ -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)."""