Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions litellm/llms/anthropic/common_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,33 @@ def _is_claude_4_7_model(model: str) -> bool:
)
)

@staticmethod
def _is_claude_4_8_model(model: str) -> bool:
"""Check if the model is a Claude 4.8 model (Opus 4.8)."""
model_lower = model.lower()
return any(
v in model_lower
for v in (
"opus-4-8",
"opus_4_8",
"opus-4.8",
"opus_4.8",
)
)

@staticmethod
def _is_claude_fable_5_model(model: str) -> bool:
"""Check if the model is a Claude Fable 5 model."""
model_lower = model.lower()
return any(
v in model_lower
for v in (
"fable-5",
"fable_5",
"fable.5",
)
)
Comment thread
mateo-berri marked this conversation as resolved.
Resolved
Comment thread
mateo-berri marked this conversation as resolved.
Resolved

@staticmethod
def _supports_sampling_params(model: str) -> bool:
"""Claude 4.7+ (Opus 4.7/4.8, Fable 5) removed sampling params: the API
Expand Down Expand Up @@ -406,16 +433,21 @@ def _is_adaptive_thinking_model(model: str) -> bool:
"""Claude 4.6+ models use adaptive thinking with ``output_config.effort``.

Driven by the ``supports_adaptive_thinking`` flag in the model map; the
4.6/4.7 name checks remain only as a fallback for provider-routed ids
whose map entries predate the flag.
4.6/4.7/4.8 and Fable 5 name checks remain only as a fallback for
provider-routed ids whose map entries predate the flag (e.g. an
``us.anthropic.claude-fable-5`` alias that is not a known LiteLLM
provider alias).
"""
if AnthropicModelInfo._supports_model_capability(
model, "supports_adaptive_thinking"
):
return True
return AnthropicModelInfo._is_claude_4_6_model(
model
) or AnthropicModelInfo._is_claude_4_7_model(model)
return (
AnthropicModelInfo._is_claude_4_6_model(model)
or AnthropicModelInfo._is_claude_4_7_model(model)
or AnthropicModelInfo._is_claude_4_8_model(model)
or AnthropicModelInfo._is_claude_fable_5_model(model)
)

def is_effort_used(
self, optional_params: Optional[dict], model: Optional[str] = None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,51 @@ def __init__(self, **kwargs):
BaseAnthropicMessagesConfig.__init__(self, **kwargs)
AmazonInvokeConfig.__init__(self, **kwargs)

@staticmethod
def _as_system_content_blocks(value: Any) -> list[Any]:
if value is None:
return []
if isinstance(value, list):
return list(value)
if isinstance(value, str):
return [{"type": "text", "text": value}]
return [value]

def _normalize_system_role_messages_for_bedrock(
self, anthropic_messages_request: dict
) -> None:
"""Bedrock Invoke rejects ``role: "system"`` entries inside ``messages`` on
some Claude aliases; Anthropic Messages carries that content in the
top-level ``system`` field. Move any such entries into ``system`` before
the Invoke request is built."""
messages = anthropic_messages_request.get("messages")
if not isinstance(messages, list):
return
system_role_messages = [
m for m in messages if isinstance(m, dict) and m.get("role") == "system"
]
if not system_role_messages:
return

anthropic_messages_request["messages"] = [
m
for m in messages
if not (isinstance(m, dict) and m.get("role") == "system")
]
system_content = [
block
for source in (
anthropic_messages_request.get("system"),
*(m.get("content") for m in system_role_messages),
)
for block in self._as_system_content_blocks(source)
]
filtered_system = self._filter_billing_headers_from_system(system_content)
if filtered_system:
anthropic_messages_request["system"] = filtered_system
else:
anthropic_messages_request.pop("system", None)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved

def validate_anthropic_messages_environment(
self,
headers: dict,
Expand Down Expand Up @@ -234,6 +279,9 @@ def _supports_extended_thinking_on_bedrock(self, model: str) -> bool:
"opus_4.7",
"opus-4-7",
"opus_4_7",
"fable-5",
"fable_5",
"fable.5",
]

return any(pattern in model_lower for pattern in supported_patterns)
Expand Down Expand Up @@ -270,14 +318,27 @@ def _ensure_thinking_for_clear_thinking_context_management(
if not self._supports_extended_thinking_on_bedrock(model):
return False

is_adaptive_thinking_model = AnthropicModelInfo._is_adaptive_thinking_model(
model
)

thinking = anthropic_messages_request.get("thinking")
if isinstance(thinking, dict):
t = thinking.get("type")
if t in ("enabled", "adaptive"):
if t == "adaptive":
return False
# ``disabled`` or unknown — replace with enabled so clear_thinking is valid
if t == "enabled" and not is_adaptive_thinking_model:
return False
if t == "enabled":
budget_tokens = self._resolve_clear_thinking_budget_tokens(
thinking.get("budget_tokens")
)
self._inject_adaptive_thinking_for_clear_thinking(
anthropic_messages_request, budget_tokens, model
)
return True
verbose_logger.debug(
"Bedrock clear_thinking_20251015: replacing thinking=%s with minimal enabled thinking",
"Bedrock clear_thinking_20251015: replacing thinking=%s with minimal thinking config",
thinking,
)

Expand All @@ -292,6 +353,12 @@ def _ensure_thinking_for_clear_thinking_context_management(
)
return False

if is_adaptive_thinking_model:
self._inject_adaptive_thinking_for_clear_thinking(
anthropic_messages_request, budget, model
)
return True

anthropic_messages_request["thinking"] = {
"type": "enabled",
"budget_tokens": budget,
Expand All @@ -302,6 +369,46 @@ def _ensure_thinking_for_clear_thinking_context_management(
)
return True

@staticmethod
def _resolve_clear_thinking_budget_tokens(budget_tokens: int | None) -> int:
"""Honor an explicit ``budget_tokens`` (including ``0``); only fall back to
the Bedrock minimum when the caller omitted it. A truthiness check would
wrongly treat an explicit ``0`` as missing."""
if budget_tokens is None:
return BEDROCK_MIN_THINKING_BUDGET_TOKENS
return int(budget_tokens)

@staticmethod
def _effort_from_thinking_budget(budget_tokens: int) -> str:
if budget_tokens >= 24000:
return "xhigh"
if budget_tokens >= 10000:
return "high"
if budget_tokens >= 5000:
return "medium"
return "low"
Comment thread
mateo-berri marked this conversation as resolved.
Resolved

def _inject_adaptive_thinking_for_clear_thinking(
self, anthropic_messages_request: dict, budget_tokens: int, model: str
) -> None:
"""Adaptive-thinking models (Opus 4.7/4.8, Fable 5) reject
``thinking.type=enabled`` on Bedrock. Use ``thinking.type=adaptive`` plus
an ``output_config.effort`` derived from the budget so ``clear_thinking``
stays valid without the legacy shape."""
output_config = anthropic_messages_request.get("output_config")
if not isinstance(output_config, dict):
output_config = {}
output_config.setdefault(
"effort", self._effort_from_thinking_budget(budget_tokens)
)
anthropic_messages_request["output_config"] = output_config
anthropic_messages_request["thinking"] = {"type": "adaptive"}
verbose_logger.debug(
"Bedrock clear_thinking_20251015: injected adaptive thinking with effort=%s for model=%s",
output_config.get("effort"),
model,
)

def _is_claude_opus_4_5(self, model: str) -> bool:
"""
Check if the model is Claude Opus 4.5.
Expand Down Expand Up @@ -584,6 +691,7 @@ def transform_anthropic_messages_request(
litellm_params=litellm_params,
headers=headers,
)
self._normalize_system_role_messages_for_bedrock(anthropic_messages_request)
#########################################################
############## BEDROCK Invoke SPECIFIC TRANSFORMATION ###
#########################################################
Expand Down
60 changes: 60 additions & 0 deletions tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1488,6 +1488,66 @@ def test_resolver_reads_flag_through_bedrock_invoke_prefix(
is True
)

@pytest.mark.parametrize(
"model",
[
"claude-fable-5",
"anthropic.claude-fable-5",
"us.anthropic.claude-fable-5",
"bedrock/invoke/us.anthropic.claude-fable-5",
"vertex_ai/claude-fable-5",
],
)
def test_adaptive_thinking_detected_for_fable_5(self, local_model_cost_map, model):
from litellm.llms.anthropic.common_utils import AnthropicModelInfo

assert AnthropicModelInfo._is_adaptive_thinking_model(model) is True

@pytest.mark.parametrize(
"model",
[
"claude-opus-4-8-some-future-suffix",
"us.anthropic.claude-fable-5-preview",
],
)
def test_adaptive_thinking_name_fallback_for_unmapped_aliases(self, model):
"""When the cost map has no entry (unknown provider alias), Opus 4.8 and
Fable 5 must still be detected as adaptive via the name fallback so the
Bedrock path does not emit the rejected legacy ``thinking.type=enabled``."""
import litellm
from litellm.llms.anthropic.common_utils import AnthropicModelInfo

assert model not in litellm.model_cost
assert AnthropicModelInfo._is_adaptive_thinking_model(model) is True

@pytest.mark.parametrize(
"model,expected",
[
("claude-opus-4-8", True),
("us.anthropic.claude-opus-4-8", True),
("claude-opus-4-7", False),
("claude-fable-5", False),
],
)
def test_is_claude_4_8_model(self, model, expected):
from litellm.llms.anthropic.common_utils import AnthropicModelInfo

assert AnthropicModelInfo._is_claude_4_8_model(model) is expected

@pytest.mark.parametrize(
"model,expected",
[
("claude-fable-5", True),
("us.anthropic.claude-fable-5", True),
("claude-opus-4-8", False),
("claude-sonnet-4-6", False),
],
)
def test_is_claude_fable_5_model(self, model, expected):
from litellm.llms.anthropic.common_utils import AnthropicModelInfo

assert AnthropicModelInfo._is_claude_fable_5_model(model) is expected

@pytest.mark.parametrize(
"model",
["claude-opus-4-5", "claude-3-7-sonnet", "claude-3-5-haiku-20241022"],
Expand Down
Loading
Loading