diff --git a/litellm/litellm_core_utils/reasoning_effort_utils.py b/litellm/litellm_core_utils/reasoning_effort_utils.py new file mode 100644 index 000000000000..5987392d070c --- /dev/null +++ b/litellm/litellm_core_utils/reasoning_effort_utils.py @@ -0,0 +1,26 @@ +from typing import Literal + +from litellm.constants import ( + DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, + DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET, + DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET, +) + +OpenAIStyleReasoningEffort = Literal["minimal", "low", "medium", "high"] + + +def reasoning_effort_from_thinking_budget( + budget_tokens: int, +) -> OpenAIStyleReasoningEffort: + """Bucket an Anthropic ``thinking.budget_tokens`` into an OpenAI-style + ``reasoning_effort`` using the shared ``DEFAULT_REASONING_EFFORT_*_THINKING_BUDGET`` + thresholds, so every backend that translates a budget into an effort label + reads the same numbers. + """ + if budget_tokens >= DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET: + return "high" + if budget_tokens >= DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET: + return "medium" + if budget_tokens >= DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET: + return "low" + return "minimal" diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 2b2264e6da97..b223aef1415e 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -229,10 +229,7 @@ def _build_anthropic_tool_name_maps( "Sonnet 4.6+, and Mythos Preview." ) -DROP_UNSUPPORTED_SPEED_WARNING = ( - "Dropping unsupported `speed` for model=%s " - "(drop_params=True). Fast mode is only supported on select Opus models." -) +DROP_UNSUPPORTED_SPEED_WARNING = "Dropping unsupported `speed` for model=%s (drop_params=True). Fast mode is only supported on select Opus models." class AnthropicConfig(AnthropicModelInfo, BaseConfig): @@ -352,8 +349,7 @@ def _supports_effort_level(model: str, level: str) -> bool: def _validate_effort_for_model(model: str, effort: Optional[str]) -> Optional[str]: """Return ``None`` if ``effort`` is allowed on ``model``, else an error message.""" if effort == "max" and not ( - AnthropicConfig._is_claude_4_6_model(model) - or AnthropicConfig._is_claude_4_7_model(model) + AnthropicConfig._is_adaptive_thinking_model(model) or AnthropicConfig._supports_effort_level(model, "max") ): return f"effort='max' is not supported by this model. Got model: {model}" @@ -471,8 +467,7 @@ def get_supported_openai_params(self, model: str): if ( "claude-3-7-sonnet" in model - or AnthropicConfig._is_claude_4_6_model(model) - or AnthropicConfig._is_claude_4_7_model(model) + or AnthropicConfig._is_adaptive_thinking_model(model) or supports_reasoning( model=model, custom_llm_provider=self.custom_llm_provider, @@ -2093,8 +2088,7 @@ def _apply_output_config( if effort is not None and effort not in valid_efforts: raise litellm.exceptions.BadRequestError( message=( - f"Invalid effort value: {effort!r}. Must be one of: " - f"'high', 'medium', 'low', 'xhigh', 'max'" + f"Invalid effort value: {effort!r}. Must be one of: 'high', 'medium', 'low', 'xhigh', 'max'" ), model=model, llm_provider=self.custom_llm_provider or "anthropic", diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index eaa762204f22..06b598c525bd 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -26,6 +26,23 @@ ) from litellm.types.llms.openai import AllMessageValues +_BEDROCK_VERSION_SUFFIX_RE = re.compile(r"-v\d+(?::\d+)?$") +_INFERENCE_PROFILE_MINOR_RE = re.compile(r":\d+$") +_DATED_RELEASE_SUFFIX_RE = re.compile(r"-\d{8}$") +_DOTTED_VERSION_RE = re.compile(r"(\d)\.(\d)") + + +def _strip_bedrock_id_suffixes(model: str) -> str: + """Reduce a full Bedrock model id to its base cost-map key by rewriting a + dotted family version then peeling a trailing ``-vN:rev`` and ``-YYYYMMDD`` + in that order, so the real ``--v1:0`` shape (e.g. + ``us.anthropic.claude-sonnet-4-6-20251101-v1:0``) resolves rather than only + the date or version in isolation.""" + return _DATED_RELEASE_SUFFIX_RE.sub( + "", + _BEDROCK_VERSION_SUFFIX_RE.sub("", _DOTTED_VERSION_RE.sub(r"\1-\2", model)), + ) + def is_anthropic_oauth_key(value: Optional[str]) -> bool: """Check if a value contains an Anthropic OAuth token (sk-ant-oat*).""" @@ -243,38 +260,6 @@ def is_input_examples_used(self, tools: Optional[List]) -> bool: return False - @staticmethod - def _is_claude_4_6_model(model: str) -> bool: - """Check if the model is a Claude 4.6 model (Opus 4.6 or Sonnet 4.6).""" - model_lower = model.lower() - return any( - v in model_lower - for v in ( - "opus-4-6", - "opus_4_6", - "opus-4.6", - "opus_4.6", - "sonnet-4-6", - "sonnet_4_6", - "sonnet-4.6", - "sonnet_4.6", - ) - ) - - @staticmethod - def _is_claude_4_7_model(model: str) -> bool: - """Check if the model is a Claude 4.7 model (Opus 4.7).""" - model_lower = model.lower() - return any( - v in model_lower - for v in ( - "opus-4-7", - "opus_4_7", - "opus-4.7", - "opus_4.7", - ) - ) - @staticmethod def _supports_sampling_params(model: str) -> bool: """Claude 4.7+ (Opus 4.7/4.8, Fable 5) removed sampling params: the API @@ -336,27 +321,42 @@ def _apply_sampling_param( @staticmethod def _model_map_lookup_candidates(model: str) -> List[str]: - """Model-map keys to try for ``model``, stripping bedrock/vertex - prefixes so a provider-routed Claude still resolves to its entry.""" - candidates = [model] - for prefix in ( + """Model-map keys to try for ``model``: the id itself, the same id with a + bedrock/vertex routing prefix removed, the Bedrock base model, and each of + those normalized by stripping a Bedrock version suffix (``-v1:0`` fully or + just the ``:0`` inference-profile minor), stripping a dated-release suffix + (``-20260205``), or rewriting a dotted family version to hyphens + (``4.6`` -> ``4-6``). Lets any reasonable alias (e.g. + ``bedrock/invoke/global.anthropic.claude-opus-4-7-v1:0``, + ``claude-sonnet-4-6-20260219`` or ``claude-sonnet-4.6``) resolve to its base + cost-map entry so the capability flag on that entry stays authoritative.""" + prefixes = ( "bedrock/converse/", "bedrock/invoke/", "bedrock/", "vertex_ai/", - ): - if model.startswith(prefix): - candidates.append(model[len(prefix) :]) + ) + deprefixed = tuple(model[len(p) :] for p in prefixes if model.startswith(p)) try: from litellm.llms.bedrock.common_utils import BedrockModelInfo base = BedrockModelInfo.get_base_model(model) - if base: - candidates.append(base) - candidates.append(f"bedrock/{base}") except Exception: - pass - return candidates + base = None + bedrock_base = (base, f"bedrock/{base}") if base else () + primary = (model, *deprefixed, *bedrock_base) + normalized = tuple( + stripped + for cand in primary + for stripped in ( + _BEDROCK_VERSION_SUFFIX_RE.sub("", cand), + _INFERENCE_PROFILE_MINOR_RE.sub("", cand), + _DATED_RELEASE_SUFFIX_RE.sub("", cand), + _DOTTED_VERSION_RE.sub(r"\1-\2", cand), + _strip_bedrock_id_suffixes(cand), + ) + ) + return list(dict.fromkeys((*primary, *normalized))) @staticmethod def _get_model_capability(model: str, key: str) -> Optional[bool]: @@ -403,19 +403,16 @@ def _supports_model_capability(model: str, key: str) -> bool: @staticmethod def _is_adaptive_thinking_model(model: str) -> bool: - """Claude 4.6+ models use adaptive thinking with ``output_config.effort``. + """Whether ``model`` uses adaptive thinking (``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. + Sourced solely from the model cost map's ``supports_adaptive_thinking`` flag, + resolved through provider prefixes. A model that resolves to no mapped entry + (an unmapped alias or a future release not yet in the map) is treated as + non-adaptive until a ``fallback_generalizations`` rule covers it. """ - if AnthropicModelInfo._supports_model_capability( + return 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) + ) def is_effort_used( self, optional_params: Optional[dict], model: Optional[str] = None diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 46564a565cec..dd8d0d8d50ec 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -76,6 +76,9 @@ def create_tool_name_mapping( from litellm.litellm_core_utils.prompt_templates.factory import ( THOUGHT_SIGNATURE_SEPARATOR, ) +from litellm.litellm_core_utils.reasoning_effort_utils import ( + reasoning_effort_from_thinking_budget, +) from litellm.llms.anthropic.common_utils import normalize_anthropic_tool_use_id from litellm.llms.anthropic.experimental_pass_through.context_management import ( PolyfillResult, @@ -716,11 +719,8 @@ def translate_anthropic_thinking_to_reasoning_effort( Anthropic thinking format: {'type': 'enabled'|'disabled', 'budget_tokens': int} OpenAI reasoning_effort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'default' - Mapping: - - budget_tokens >= 10000 -> 'high' - - budget_tokens >= 5000 -> 'medium' - - budget_tokens >= 2000 -> 'low' - - budget_tokens < 2000 -> 'minimal' + ``budget_tokens`` is bucketed via the shared + ``reasoning_effort_from_thinking_budget`` thresholds. """ if not isinstance(thinking, dict): return None @@ -730,15 +730,9 @@ def translate_anthropic_thinking_to_reasoning_effort( if thinking_type == "disabled": return None elif thinking_type == "enabled": - budget_tokens = thinking.get("budget_tokens", 0) - if budget_tokens >= 10000: - return "high" - elif budget_tokens >= 5000: - return "medium" - elif budget_tokens >= 2000: - return "low" - else: - return "minimal" + return reasoning_effort_from_thinking_budget( + thinking.get("budget_tokens", 0) + ) elif thinking_type == "adaptive": # Adaptive thinking: effort is controlled by output_config.effort, # not budget_tokens. Return a default; caller should override with diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index 07e8270b496c..fbe49ae41366 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -2,6 +2,11 @@ import httpx +from litellm.constants import ( + DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, + DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET, + DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET, +) from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.litellm_core_utils.litellm_logging import verbose_logger from litellm.llms.base_llm.anthropic_messages.transformation import ( @@ -248,11 +253,13 @@ def _translate_legacy_thinking_for_adaptive_model( return budget = int(thinking.get("budget_tokens") or 0) - if budget >= 24000 and AnthropicConfig._supports_effort_level(model, "xhigh"): + if budget >= DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET and ( + AnthropicConfig._supports_effort_level(model, "xhigh") + ): effort = "xhigh" - elif budget >= 10000: + elif budget >= DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET: effort = "high" - elif budget >= 5000: + elif budget >= DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET: effort = "medium" else: effort = "low" diff --git a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py index 4fb1ddf5c46d..95ab9b3a0fe9 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py @@ -8,6 +8,9 @@ import json from typing import Any, Dict, List, Optional, Union, cast +from litellm.litellm_core_utils.reasoning_effort_utils import ( + reasoning_effort_from_thinking_budget, +) from litellm.llms.anthropic.experimental_pass_through.utils import ( is_reasoning_auto_summary_enabled, ) @@ -257,11 +260,10 @@ def translate_thinking_to_reasoning( """ Convert Anthropic thinking param to Responses API reasoning param. - thinking.budget_tokens maps to reasoning effort: - >= 10000 -> high, >= 5000 -> medium, >= 2000 -> low, < 2000 -> minimal - - For adaptive thinking, uses output_config.effort if available, - otherwise defaults to medium. + ``thinking.budget_tokens`` is bucketed via the shared + ``reasoning_effort_from_thinking_budget`` thresholds. For adaptive + thinking, uses ``output_config.effort`` if available, otherwise defaults + to medium. """ if not isinstance(thinking, dict): return None @@ -274,15 +276,9 @@ def translate_thinking_to_reasoning( if isinstance(output_config, dict) and output_config.get("effort"): effort = output_config["effort"] elif thinking_type == "enabled": - budget = thinking.get("budget_tokens", 0) - if budget >= 10000: - effort = "high" - elif budget >= 5000: - effort = "medium" - elif budget >= 2000: - effort = "low" - else: - effort = "minimal" + effort = reasoning_effort_from_thinking_budget( + thinking.get("budget_tokens", 0) + ) else: return None 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 42c3bd517a91..3ce426edba76 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py @@ -14,7 +14,12 @@ import litellm from litellm.anthropic_beta_headers_manager import filter_and_transform_beta_headers -from litellm.constants import BEDROCK_MIN_THINKING_BUDGET_TOKENS +from litellm.constants import ( + BEDROCK_MIN_THINKING_BUDGET_TOKENS, + DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, + DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET, + DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET, +) from litellm.litellm_core_utils.litellm_logging import verbose_logger from litellm.llms.anthropic.chat.transformation import ( DROP_UNSUPPORTED_OUTPUT_CONFIG_WARNING, @@ -80,6 +85,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) + def validate_anthropic_messages_environment( self, headers: dict, @@ -195,8 +245,9 @@ def _supports_extended_thinking_on_bedrock(self, model: str) -> bool: """ Check if the model supports extended thinking beta headers on Bedrock. - On 3rd-party platforms (e.g., Amazon Bedrock), extended thinking is only - supported on: Claude Opus 4.5, Claude Opus 4.1, Opus 4, or Sonnet 4. + On 3rd-party platforms (e.g., Amazon Bedrock), extended thinking is supported + on the adaptive-thinking models (sourced from the cost map) plus the legacy + non-adaptive set: Claude Opus 4.5, Claude Opus 4.1, Opus 4, or Sonnet 4. Ref: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking @@ -206,10 +257,11 @@ def _supports_extended_thinking_on_bedrock(self, model: str) -> bool: Returns: True if the model supports extended thinking on Bedrock """ - model_lower = model.lower() + if AnthropicModelInfo._is_adaptive_thinking_model(model): + return True - # Supported models on Bedrock for extended thinking - supported_patterns = [ + model_lower = model.lower() + non_adaptive_patterns = [ "opus-4.5", "opus_4.5", "opus-4-5", @@ -222,21 +274,9 @@ def _supports_extended_thinking_on_bedrock(self, model: str) -> bool: "opus_4", # Opus 4 "sonnet-4", "sonnet_4", # Sonnet 4 - "sonnet-4.6", - "sonnet_4.6", - "sonnet-4-6", - "sonnet_4_6", - "opus-4.6", - "opus_4.6", - "opus-4-6", - "opus_4_6", - "opus-4.7", - "opus_4.7", - "opus-4-7", - "opus_4_7", ] - return any(pattern in model_lower for pattern in supported_patterns) + return any(pattern in model_lower for pattern in non_adaptive_patterns) def _ensure_thinking_for_clear_thinking_context_management( self, @@ -270,14 +310,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 + if t == "enabled" and not is_adaptive_thinking_model: return False - # ``disabled`` or unknown — replace with enabled so clear_thinking is valid + 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, ) @@ -292,6 +345,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, @@ -302,6 +361,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 >= DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET: + return "xhigh" + if budget_tokens >= DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET: + return "high" + if budget_tokens >= DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET: + return "medium" + return "low" + + 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. @@ -584,6 +683,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 ### ######################################################### diff --git a/litellm/llms/hosted_vllm/chat/transformation.py b/litellm/llms/hosted_vllm/chat/transformation.py index 7c42e6a9a002..fc7086548c8b 100644 --- a/litellm/llms/hosted_vllm/chat/transformation.py +++ b/litellm/llms/hosted_vllm/chat/transformation.py @@ -20,6 +20,9 @@ _get_image_mime_type_from_url, ) from litellm.litellm_core_utils.prompt_templates.factory import _parse_mime_type +from litellm.litellm_core_utils.reasoning_effort_utils import ( + reasoning_effort_from_thinking_budget, +) from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import ( AllMessageValues, @@ -115,15 +118,11 @@ def map_openai_params( if thinking is not None and isinstance(thinking, dict): if thinking.get("type") == "enabled": if "reasoning_effort" not in non_default_params: - budget_tokens = thinking.get("budget_tokens", 0) - if budget_tokens >= 10000: - non_default_params["reasoning_effort"] = "high" - elif budget_tokens >= 5000: - non_default_params["reasoning_effort"] = "medium" - elif budget_tokens >= 2000: - non_default_params["reasoning_effort"] = "low" - else: - non_default_params["reasoning_effort"] = "minimal" + non_default_params["reasoning_effort"] = ( + reasoning_effort_from_thinking_budget( + thinking.get("budget_tokens", 0) + ) + ) return super().map_openai_params( non_default_params, optional_params, model, drop_params diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 6ebac7efc8df..cf7b1b1033c0 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -999,6 +999,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1029,6 +1030,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1059,6 +1061,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1089,6 +1092,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1119,6 +1123,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1149,6 +1154,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1196,6 +1202,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1228,6 +1235,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1260,6 +1268,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1292,6 +1301,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1620,6 +1630,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1652,6 +1663,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -1681,6 +1693,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -1710,6 +1723,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -1739,6 +1753,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -1768,6 +1783,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -1797,6 +1813,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -2316,6 +2333,7 @@ "cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost_above_1hr": 1e-05, "cache_read_input_token_cost": 5e-07, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -2344,6 +2362,7 @@ "cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost_above_1hr": 1e-05, "cache_read_input_token_cost": 5e-07, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -2470,6 +2489,7 @@ "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.5e-05, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -19247,6 +19267,7 @@ "supported_endpoints": [ "/v1/chat/completions" ], + "supports_adaptive_thinking": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_vision": true @@ -28468,6 +28489,7 @@ "output_cost_per_token": 1.5e-05, "output_cost_per_token_above_200k_tokens": 2.25e-05, "source": "https://openrouter.ai/anthropic/claude-sonnet-4.6", + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -28506,6 +28528,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2.5e-05, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -28566,6 +28589,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2.5e-05, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -30539,6 +30563,7 @@ "perplexity/anthropic/claude-opus-4-6": { "litellm_provider": "perplexity", "mode": "responses", + "supports_adaptive_thinking": true, "supports_web_search": true, "supports_reasoning": false, "supports_function_calling": true, @@ -30547,6 +30572,7 @@ "perplexity/anthropic/claude-opus-4-7": { "litellm_provider": "perplexity", "mode": "responses", + "supports_adaptive_thinking": true, "supports_web_search": true, "supports_reasoning": false, "supports_function_calling": true, @@ -33722,6 +33748,7 @@ "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 2.5e-05, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -34955,6 +34982,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -34983,6 +35011,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -35011,6 +35040,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -35040,6 +35070,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -35211,6 +35242,7 @@ "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.5e-05, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -42601,6 +42633,7 @@ "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.5e-05, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 24d6e84fba7b..dd70c3c715e1 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -142,6 +142,7 @@ class ProviderSpecificModelInfo(TypedDict, total=False): supports_parallel_function_calling: Optional[bool] supports_web_search: Optional[bool] supports_reasoning: Optional[bool] + supports_adaptive_thinking: Optional[bool] supports_url_context: Optional[bool] supports_none_reasoning_effort: Optional[bool] supports_minimal_reasoning_effort: Optional[bool] diff --git a/litellm/utils.py b/litellm/utils.py index e0aa85754732..4c2489c6f886 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -6213,6 +6213,9 @@ def _get_model_info_helper( supports_web_search=_model_info.get("supports_web_search", None), supports_url_context=_model_info.get("supports_url_context", None), supports_reasoning=_model_info.get("supports_reasoning", None), + supports_adaptive_thinking=_model_info.get( + "supports_adaptive_thinking", None + ), supports_none_reasoning_effort=_model_info.get( "supports_none_reasoning_effort", None ), diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 5f3f22941473..19368072a497 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -999,6 +999,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1029,6 +1030,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1059,6 +1061,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1089,6 +1092,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1119,6 +1123,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1149,6 +1154,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1196,6 +1202,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1228,6 +1235,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1260,6 +1268,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1292,6 +1301,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1620,6 +1630,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1652,6 +1663,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -1681,6 +1693,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -1710,6 +1723,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -1739,6 +1753,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -1768,6 +1783,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -1797,6 +1813,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -2316,6 +2333,7 @@ "cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost_above_1hr": 1e-05, "cache_read_input_token_cost": 5e-07, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -2344,6 +2362,7 @@ "cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost_above_1hr": 1e-05, "cache_read_input_token_cost": 5e-07, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -2470,6 +2489,7 @@ "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.5e-05, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -19255,6 +19275,7 @@ "supported_endpoints": [ "/v1/chat/completions" ], + "supports_adaptive_thinking": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_vision": true @@ -28476,6 +28497,7 @@ "output_cost_per_token": 1.5e-05, "output_cost_per_token_above_200k_tokens": 2.25e-05, "source": "https://openrouter.ai/anthropic/claude-sonnet-4.6", + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -28514,6 +28536,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2.5e-05, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -28574,6 +28597,7 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2.5e-05, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -30547,6 +30571,7 @@ "perplexity/anthropic/claude-opus-4-6": { "litellm_provider": "perplexity", "mode": "responses", + "supports_adaptive_thinking": true, "supports_web_search": true, "supports_reasoning": false, "supports_function_calling": true, @@ -30555,6 +30580,7 @@ "perplexity/anthropic/claude-opus-4-7": { "litellm_provider": "perplexity", "mode": "responses", + "supports_adaptive_thinking": true, "supports_web_search": true, "supports_reasoning": false, "supports_function_calling": true, @@ -33770,6 +33796,7 @@ "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 2.5e-05, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -35003,6 +35030,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -35031,6 +35059,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -35059,6 +35088,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -35088,6 +35118,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -35259,6 +35290,7 @@ "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.5e-05, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -42811,6 +42843,7 @@ "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.5e-05, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, diff --git a/tests/llm_translation/base_llm_unit_tests.py b/tests/llm_translation/base_llm_unit_tests.py index a184798b5037..6d845f4b2f14 100644 --- a/tests/llm_translation/base_llm_unit_tests.py +++ b/tests/llm_translation/base_llm_unit_tests.py @@ -1458,9 +1458,11 @@ def test_reasoning_effort(self): reasoning_effort="high", ) # either accepts reasoning effort or thinking budget - assert "reasoning_effort" in optional_params or "4096" in json.dumps( - optional_params - ) + from litellm.constants import DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET + + assert "reasoning_effort" in optional_params or str( + DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET + ) in json.dumps(optional_params) try: litellm._turn_on_debug() @@ -1777,12 +1779,17 @@ def test_anthropic_reasoning_effort_thinking_translation(self): model=base_completion_call_args["model"] ) + from litellm.constants import DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET + optional_params = get_optional_params( model=base_completion_call_args.get("model"), custom_llm_provider=provider, reasoning_effort="high", ) - assert optional_params["thinking"] == {"type": "enabled", "budget_tokens": 4096} + assert optional_params["thinking"] == { + "type": "enabled", + "budget_tokens": DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, + } assert "reasoning_effort" not in optional_params diff --git a/tests/llm_translation/reasoning_effort_grid/grid_spec.py b/tests/llm_translation/reasoning_effort_grid/grid_spec.py index 9778e01eb975..1bb468d15ad1 100644 --- a/tests/llm_translation/reasoning_effort_grid/grid_spec.py +++ b/tests/llm_translation/reasoning_effort_grid/grid_spec.py @@ -1,6 +1,16 @@ from dataclasses import dataclass, field from typing import Dict, FrozenSet, List, Optional, Tuple +from litellm.constants import ( + ANTHROPIC_MIN_THINKING_BUDGET_TOKENS, + DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, + DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET, + DEFAULT_REASONING_EFFORT_MAX_THINKING_BUDGET, + DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET, + DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET, + DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET, +) + OMIT = object() @@ -44,12 +54,15 @@ def params(self) -> Dict[str, str]: ) _BUDGET_TOKENS: Dict[str, int] = { - "minimal": 1024, - "low": 1024, - "medium": 2048, - "high": 4096, - "xhigh": 8192, - "max": 16384, + "minimal": max( + DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET, + ANTHROPIC_MIN_THINKING_BUDGET_TOKENS, + ), + "low": DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET, + "medium": DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET, + "high": DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, + "xhigh": DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET, + "max": DEFAULT_REASONING_EFFORT_MAX_THINKING_BUDGET, } _ADAPTIVE_EFFORT_LABEL: Dict[str, str] = { @@ -71,6 +84,10 @@ def params(self) -> Dict[str, str]: _BAD_REQUEST_EFFORTS: FrozenSet[str] = frozenset({"disabled", "invalid", ""}) +# Live providers reject ``max_tokens <= thinking.budget_tokens``, so a budget-mode +# request must leave room above the largest 200-expected tier (``high``). +BUDGET_MODE_MAX_TOKENS: int = DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET * 2 + def _bedrock_clamps_effort(model: "ModelEntry", effort: str) -> bool: """Whether Bedrock will clamp ``effort`` down to ``bedrock_effort_ceiling``. @@ -89,7 +106,9 @@ def _bedrock_clamps_effort(model: "ModelEntry", effort: str) -> bool: def expected(model: ModelEntry, effort: str) -> CellExpectation: if effort in ("__omit__", "none"): if model.mode == "budget": - return CellExpectation(status=200, thinking_type=OMIT, max_tokens=8192) + return CellExpectation( + status=200, thinking_type=OMIT, max_tokens=BUDGET_MODE_MAX_TOKENS + ) return CellExpectation(status=200, thinking_type=OMIT) if effort in _BAD_REQUEST_EFFORTS: @@ -117,7 +136,7 @@ def expected(model: ModelEntry, effort: str) -> CellExpectation: status=200, thinking_type="enabled", thinking_budget_tokens=_BUDGET_TOKENS[effort], - max_tokens=8192, + max_tokens=BUDGET_MODE_MAX_TOKENS, ) diff --git a/tests/llm_translation/reasoning_effort_grid/test_reasoning_effort_grid.py b/tests/llm_translation/reasoning_effort_grid/test_reasoning_effort_grid.py index a5f16f928e51..304743b1f3cf 100644 --- a/tests/llm_translation/reasoning_effort_grid/test_reasoning_effort_grid.py +++ b/tests/llm_translation/reasoning_effort_grid/test_reasoning_effort_grid.py @@ -8,6 +8,7 @@ from litellm.exceptions import BadRequestError from .grid_spec import ( + BUDGET_MODE_MAX_TOKENS, OMIT, ROUTES, CellExpectation, @@ -28,7 +29,7 @@ def _required_env_missing(model: ModelEntry) -> Optional[str]: def _max_tokens_for(model: ModelEntry) -> int: - return 200 if model.mode == "adaptive" else 8192 + return 200 if model.mode == "adaptive" else BUDGET_MODE_MAX_TOKENS def _build_completion_kwargs(model: ModelEntry, effort: str) -> Dict[str, Any]: diff --git a/tests/llm_translation/test_optional_params.py b/tests/llm_translation/test_optional_params.py index 93acf0168337..9ebdb4b7e97c 100644 --- a/tests/llm_translation/test_optional_params.py +++ b/tests/llm_translation/test_optional_params.py @@ -1834,12 +1834,17 @@ def test_azure_response_format_param(): ], ) def test_anthropic_unified_reasoning_content(model, provider): + from litellm.constants import DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET + optional_params = get_optional_params( model=model, custom_llm_provider=provider, reasoning_effort="high", ) - assert optional_params["thinking"] == {"type": "enabled", "budget_tokens": 4096} + assert optional_params["thinking"] == { + "type": "enabled", + "budget_tokens": DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, + } def test_azure_response_format(monkeypatch): diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index b111b65e3afb..67eb5b121075 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -9,7 +9,14 @@ from unittest.mock import MagicMock, patch import litellm -from litellm.constants import RESPONSE_FORMAT_TOOL_NAME +from litellm.constants import ( + DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, + DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET, + DEFAULT_REASONING_EFFORT_MAX_THINKING_BUDGET, + DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET, + DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET, + RESPONSE_FORMAT_TOOL_NAME, +) from litellm.llms.anthropic.chat.transformation import AnthropicConfig from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( AnthropicMessagesConfig, @@ -2493,9 +2500,9 @@ def test_reasoning_effort_maps_to_budget_thinking_for_non_opus_4_6(): # ``minimal`` floors at ANTHROPIC_MIN_THINKING_BUDGET_TOKENS (1024). test_cases = [ - ("low", 1024), - ("medium", 2048), - ("high", 4096), + ("low", DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET), + ("medium", DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET), + ("high", DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET), ("minimal", 1024), ] @@ -2820,7 +2827,10 @@ def test_reasoning_effort_garbage_raises_bad_request(effort): @pytest.mark.parametrize( "effort,expected_budget", - [("xhigh", 8192), ("max", 16384)], + [ + ("xhigh", DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET), + ("max", DEFAULT_REASONING_EFFORT_MAX_THINKING_BUDGET), + ], ) def test_reasoning_effort_xhigh_max_maps_to_budget_on_budget_model( effort, expected_budget diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py index a58873f5387f..7bcaf07c5bb3 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py @@ -270,9 +270,9 @@ def test_openai_model_with_thinking_converts_to_reasoning(): "reasoning" in call_kwargs ), "reasoning should be passed to litellm.responses" - # budget_tokens=1024 -> effort="minimal" (< 2000 threshold) + # budget_tokens=1024 -> effort="low" (at the LOW budget threshold) # reasoning_auto_summary is False by default, so no summary key - expected_reasoning = {"effort": "minimal"} + expected_reasoning = {"effort": "low"} assert call_kwargs["reasoning"] == expected_reasoning, ( f"reasoning should be {expected_reasoning} for budget_tokens=1024, " f"got {call_kwargs.get('reasoning')}" @@ -316,7 +316,7 @@ def test_non_claude_model_converts_thinking_to_reasoning_effort(self): ) # reasoning_auto_summary is False by default, so no summary key - assert result == {"reasoning_effort": "minimal"} + assert result == {"reasoning_effort": "low"} assert "thinking" not in result assert "summary" not in str(result["reasoning_effort"]) @@ -336,7 +336,7 @@ def test_translate_thinking_for_model_summary_when_enabled(self): model="openai/gpt-5.2", ) assert result == { - "reasoning_effort": {"effort": "medium", "summary": "detailed"} + "reasoning_effort": {"effort": "high", "summary": "detailed"} } finally: litellm.reasoning_auto_summary = original @@ -549,7 +549,7 @@ def test_responses_adapter_preserves_summary(self): result = LiteLLMAnthropicToResponsesAPIAdapter.translate_thinking_to_reasoning( thinking ) - assert result == {"effort": "medium", "summary": "concise"} + assert result == {"effort": "high", "summary": "concise"} def test_responses_adapter_no_summary_by_default(self): """translate_thinking_to_reasoning should not include summary by default (opt-in).""" @@ -567,7 +567,7 @@ def test_responses_adapter_no_summary_by_default(self): thinking ) ) - assert result == {"effort": "medium"} + assert result == {"effort": "high"} assert result is not None and "summary" not in result finally: litellm.reasoning_auto_summary = original @@ -584,7 +584,7 @@ def test_translate_thinking_for_model_preserves_summary(self): model="openai/gpt-5.2", ) assert result == { - "reasoning_effort": {"effort": "medium", "summary": "concise"} + "reasoning_effort": {"effort": "high", "summary": "concise"} } diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_reasoning_effort_translation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_reasoning_effort_translation.py index 71755e6da3f4..e3f0bbbcc69c 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_reasoning_effort_translation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_reasoning_effort_translation.py @@ -3,6 +3,11 @@ import pytest import litellm +from litellm.constants import ( + DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, + DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET, + DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET, +) from litellm.llms.anthropic.common_utils import AnthropicError from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( AnthropicMessagesConfig, @@ -152,7 +157,7 @@ def test_reasoning_effort_unsupported_tier_raises_400_messages(model, bad_effort ], ) def test_bedrock_invoke_messages_clamps_effort_to_ceiling( - model, effort, expected_effort + local_model_cost_map, model, effort, expected_effort ): """Bedrock Invoke /v1/messages degrades effort to the model's ceiling. @@ -174,7 +179,7 @@ def test_bedrock_invoke_messages_clamps_effort_to_ceiling( assert result["thinking"]["type"] == "adaptive" -def test_bedrock_invoke_messages_rejects_xhigh_without_ceiling(): +def test_bedrock_invoke_messages_rejects_xhigh_without_ceiling(local_model_cost_map): """Sonnet 4.6 on Bedrock has no effort ceiling, so xhigh is still rejected.""" config = AmazonAnthropicClaudeMessagesConfig() optional_params = {"max_tokens": 1024, "reasoning_effort": "xhigh"} @@ -199,7 +204,9 @@ def test_bedrock_invoke_messages_rejects_xhigh_without_ceiling(): "bedrock/invoke/us.anthropic.claude-sonnet-4-6", ], ) -def test_reasoning_effort_max_accepted_on_sonnet_46_messages(model): +def test_reasoning_effort_max_accepted_on_sonnet_46_messages( + local_model_cost_map, model +): config = AnthropicMessagesConfig() optional_params = {"max_tokens": 1024, "reasoning_effort": "max"} @@ -269,11 +276,13 @@ def test_reasoning_effort_in_supported_params(): "bedrock/invoke/us.anthropic.claude-sonnet-4-6", "vertex_ai/claude-sonnet-4-6", "claude-opus-4-6", - "bedrock/invoke/us.anthropic.claude-opus-4-6", + "bedrock/invoke/us.anthropic.claude-opus-4-6-v1:0", "vertex_ai/claude-opus-4-6", ], ) -def test_legacy_thinking_high_budget_clamps_to_high_when_xhigh_unsupported(model): +def test_legacy_thinking_high_budget_clamps_to_high_when_xhigh_unsupported( + local_model_cost_map, model +): """Claude Code sends ``thinking.budget_tokens=31999``; Sonnet 4.6 and Opus 4.6 have no ``xhigh`` tier, so the translator must emit ``high`` rather than the provider-invalid ``xhigh`` (regression for issue #29282).""" @@ -352,13 +361,13 @@ def test_legacy_thinking_translates_to_adaptive_for_opus_48( @pytest.mark.parametrize( "budget_tokens,expected_effort", [ - (31999, "high"), - (24000, "high"), - (10000, "high"), - (9999, "medium"), - (5000, "medium"), - (4999, "low"), - (1024, "low"), + (DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET * 2, "high"), + (DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET, "high"), + (DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, "high"), + (DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET - 1, "medium"), + (DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET, "medium"), + (DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET - 1, "low"), + (1, "low"), ], ) def test_legacy_thinking_budget_buckets_on_sonnet_46(budget_tokens, expected_effort): diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py index 02b817cd334d..14e872485ecf 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py @@ -11,6 +11,11 @@ sys.path.insert(0, os.path.abspath("../../../../../../..")) +from litellm.constants import ( + DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, + DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET, + DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET, +) from litellm.llms.anthropic.experimental_pass_through.responses_adapters.transformation import ( LiteLLMAnthropicToResponsesAPIAdapter, ) @@ -628,14 +633,20 @@ def test_budget_above_threshold_high_effort(self): def test_budget_medium_effort(self): result = _ADAPTER.translate_thinking_to_reasoning( - {"type": "enabled", "budget_tokens": 7500} + { + "type": "enabled", + "budget_tokens": DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET, + } ) assert result == {"effort": "medium"} assert result is not None and "summary" not in result def test_budget_low_effort(self): result = _ADAPTER.translate_thinking_to_reasoning( - {"type": "enabled", "budget_tokens": 3000} + { + "type": "enabled", + "budget_tokens": DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET, + } ) assert result == {"effort": "low"} assert result is not None and "summary" not in result @@ -648,14 +659,28 @@ def test_budget_minimal_effort(self): assert result is not None and "summary" not in result def test_budget_at_exact_thresholds(self): + result_high = _ADAPTER.translate_thinking_to_reasoning( + { + "type": "enabled", + "budget_tokens": DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, + } + ) + assert result_high is not None + assert result_high["effort"] == "high" result_medium = _ADAPTER.translate_thinking_to_reasoning( - {"type": "enabled", "budget_tokens": 5000} + { + "type": "enabled", + "budget_tokens": DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET, + } ) assert result_medium is not None assert result_medium["effort"] == "medium" assert "summary" not in result_medium result_low = _ADAPTER.translate_thinking_to_reasoning( - {"type": "enabled", "budget_tokens": 2000} + { + "type": "enabled", + "budget_tokens": DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET, + } ) assert result_low is not None assert result_low["effort"] == "low" @@ -670,7 +695,7 @@ def test_non_dict_returns_none(self): assert result is None def test_missing_budget_defaults_to_minimal(self): - """Missing budget_tokens defaults to 0, which is < 2000 -> minimal.""" + """Missing budget_tokens defaults to 0, below the low threshold -> minimal.""" result = _ADAPTER.translate_thinking_to_reasoning({"type": "enabled"}) assert result == {"effort": "minimal"} assert result is not None and "summary" not in result @@ -698,7 +723,10 @@ def test_summary_added_when_env_var_set(self): litellm.reasoning_auto_summary = False os.environ["LITELLM_REASONING_AUTO_SUMMARY"] = "true" result = _ADAPTER.translate_thinking_to_reasoning( - {"type": "enabled", "budget_tokens": 5000} + { + "type": "enabled", + "budget_tokens": DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET, + } ) assert result == {"effort": "medium", "summary": "detailed"} finally: 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 03b72c504b8f..315e734ef1d4 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py @@ -1465,6 +1465,8 @@ class TestClaudeOpus48AdaptiveThinking: "bedrock/eu.anthropic.claude-opus-4-8", "vertex_ai/claude-opus-4-8", "azure_ai/claude-opus-4-8", + "anthropic.claude-opus-4-8-20251201-v1:0", + "bedrock/invoke/global.anthropic.claude-opus-4-8-20251201-v1:0", ], ) def test_adaptive_thinking_detected_for_opus_4_8(self, local_model_cost_map, model): @@ -1488,6 +1490,74 @@ 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-6", + "us.anthropic.claude-opus-4-7", + "bedrock/invoke/us.anthropic.claude-opus-4-7", + "bedrock/invoke/global.anthropic.claude-opus-4-7-v1:0", + "global.anthropic.claude-sonnet-4-6-v1:0", + "bedrock/invoke/us.anthropic.claude-opus-4-6-v1:0", + "anthropic.claude-opus-4-6-v1", + "bedrock/us.anthropic.claude-sonnet-4-6", + "us.anthropic.claude-sonnet-4-6", + "vertex_ai/claude-opus-4-6", + "azure_ai/claude-sonnet-4-6", + "claude-sonnet-4-6-20260219", + "us.anthropic.claude-sonnet-4-6-20251101-v1:0", + "bedrock/invoke/us.anthropic.claude-sonnet-4-6-20251101-v1:0", + "claude-sonnet-4.6", + ], + ) + def test_adaptive_thinking_detected_for_opus_4_6_4_7_and_sonnet_4_6( + self, local_model_cost_map, model + ): + """Opus 4.6/4.7 and Sonnet 4.6 carry the ``supports_adaptive_thinking`` flag, + so detection holds purely from the cost map with no name-based version + fallback. Each alias form the Bedrock/anthropic paths see resolves to a flagged + base entry through candidate normalization: provider/region prefixes, a + Bedrock ``-v1:0`` version suffix (stripped fully for 4.7/4.8 keys or to ``-v1`` + for the 4.6 key), a dated release suffix (``-20260219``), a combined + ``--v1:0`` suffix (the real Bedrock id shape), and a dotted family + version (``4.6`` -> ``4-6``).""" + from litellm.llms.anthropic.common_utils import AnthropicModelInfo + + assert AnthropicModelInfo._is_adaptive_thinking_model(model) is True + + @pytest.mark.parametrize( + "model", + [ + "claude-opus-4-9", + "claude-opus-4-8-some-future-suffix", + "us.anthropic.claude-fable-5-preview", + ], + ) + def test_unmapped_aliases_defer_to_cost_map(self, local_model_cost_map, model): + """Detection is sourced solely from the cost map flag: an alias absent from + the map (a future release or a preview suffix) is not adaptive until a + ``fallback_generalizations`` rule (PR #29718) covers it.""" + 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 False + @pytest.mark.parametrize( "model", ["claude-opus-4-5", "claude-3-7-sonnet", "claude-3-5-haiku-20241022"], 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 c92a9905229f..c3191f5b5cbd 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 @@ -18,13 +18,36 @@ normalize_tool_input_schema_types_for_bedrock_invoke, remove_custom_field_from_tools, ) -from litellm.constants import BEDROCK_MIN_THINKING_BUDGET_TOKENS +from litellm.constants import ( + BEDROCK_MIN_THINKING_BUDGET_TOKENS, + DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, + DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET, + DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET, +) from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import ( AmazonAnthropicClaudeMessagesConfig, AmazonAnthropicClaudeMessagesStreamDecoder, ) +@pytest.fixture +def local_model_cost_map(monkeypatch): + """Force the bundled backup cost map so adaptive-thinking detection reads this + branch's ``supports_adaptive_thinking`` flags, which the network-fetched + ``main`` copy lacks until merge.""" + import litellm + + original = litellm.model_cost + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + litellm.model_cost = litellm.get_model_cost_map(url="") + litellm.get_model_info.cache_clear() + try: + yield + finally: + litellm.model_cost = original + litellm.get_model_info.cache_clear() + + @pytest.mark.asyncio async def test_bedrock_sse_wrapper_encodes_dict_chunks(): """Verify that `bedrock_sse_wrapper` converts dictionary chunks to properly formatted Server-Sent Events and forwards non-dict chunks unchanged.""" @@ -388,7 +411,9 @@ def test_bedrock_invoke_messages_transform_adds_name_when_tool_missing_name(): assert result["tools"][0]["name"] == "litellm_unnamed_tool_0" -def test_bedrock_invoke_messages_skips_thinking_injection_when_already_enabled(): +def test_bedrock_invoke_messages_skips_thinking_injection_when_already_enabled( + local_model_cost_map, +): from litellm.types.router import GenericLiteLLMParams cfg = AmazonAnthropicClaudeMessagesConfig() @@ -624,9 +649,9 @@ def test_bedrock_messages_strips_output_config(): headers={}, ) - assert ( - "output_config" not in result - ), "output_config should be stripped for models that don't support it" + assert "output_config" not in result, ( + "output_config should be stripped for models that don't support it" + ) assert result.get("max_tokens") == 4096 @@ -659,9 +684,9 @@ def test_bedrock_messages_preserves_output_config_for_claude_4_6(): headers={}, ) - assert ( - "output_config" in result - ), "output_config should be preserved for supported models" + assert "output_config" in result, ( + "output_config should be preserved for supported models" + ) assert result["output_config"] == {"effort": "high"} assert result.get("max_tokens") == 4096 @@ -1038,7 +1063,7 @@ def test_bedrock_messages_drop_params_keeps_output_config_for_4_7(): ], ) def test_bedrock_messages_maps_reasoning_effort_for_adaptive_model( - reasoning_effort, expected_effort + local_model_cost_map, reasoning_effort, expected_effort ): """``reasoning_effort`` maps to ``thinking`` + ``output_config.effort`` on /v1/messages.""" from unittest.mock import patch @@ -1204,9 +1229,9 @@ def test_bedrock_messages_strips_context_management(): headers={}, ) - assert ( - "context_management" not in result - ), "context_management should be stripped — Bedrock Invoke rejects it" + assert "context_management" not in result, ( + "context_management should be stripped — Bedrock Invoke rejects it" + ) assert result.get("max_tokens") == 4096 @@ -1353,12 +1378,12 @@ def test_bedrock_messages_filters_user_provided_unsupported_beta_header(): ) betas = result.get("anthropic_beta") or [] - assert ( - "advisor-tool-2026-03-01" not in betas - ), "user-provided beta not in the Bedrock mapping must be dropped" - assert ( - "context-1m-2025-08-07" in betas - ), "user-provided beta that IS in the Bedrock mapping should survive" + assert "advisor-tool-2026-03-01" not in betas, ( + "user-provided beta not in the Bedrock mapping must be dropped" + ) + assert "context-1m-2025-08-07" in betas, ( + "user-provided beta that IS in the Bedrock mapping should survive" + ) def test_bedrock_messages_renames_user_provided_aliased_beta_header(): @@ -1383,12 +1408,12 @@ def test_bedrock_messages_renames_user_provided_aliased_beta_header(): ) betas = result.get("anthropic_beta") or [] - assert ( - "advanced-tool-use-2025-11-20" not in betas - ), "Anthropic-direct spelling should be rewritten, not forwarded verbatim" - assert ( - "tool-search-tool-2025-10-19" in betas - ), "user-provided beta should be renamed to the Bedrock-side spelling" + assert "advanced-tool-use-2025-11-20" not in betas, ( + "Anthropic-direct spelling should be rewritten, not forwarded verbatim" + ) + assert "tool-search-tool-2025-10-19" in betas, ( + "user-provided beta should be renamed to the Bedrock-side spelling" + ) @pytest.mark.asyncio @@ -1648,3 +1673,339 @@ async def _stream(): # type: ignore[return-type] custom_llm_provider="bedrock", ) assert cost == pytest.approx(0.052150725, rel=0, abs=1e-9) + + +@pytest.mark.parametrize( + "model", + [ + "us.anthropic.claude-opus-4-8", + "global.anthropic.claude-opus-4-7-v1:0", + "us.anthropic.claude-fable-5", + "global.anthropic.claude-fable-5", + ], +) +def test_bedrock_clear_thinking_injects_adaptive_with_effort_for_adaptive_models( + local_model_cost_map, model +): + """clear_thinking_20251015 without a top-level ``thinking`` field must inject + ``thinking.type=adaptive`` plus ``output_config.effort`` on adaptive-thinking + models (Opus 4.7/4.8, Fable 5). The legacy ``thinking.type=enabled`` shape is + rejected by Bedrock for these models (issue #29188). Detection is sourced from + the cost-map ``supports_adaptive_thinking`` flag; the versioned Bedrock id + (``-v1:0``) resolves to its suffix-less cost-map entry.""" + cfg = AmazonAnthropicClaudeMessagesConfig() + request = { + "max_tokens": 32000, + "context_management": { + "edits": [{"type": "clear_thinking_20251015", "keep": "all"}] + }, + } + + changed = cfg._ensure_thinking_for_clear_thinking_context_management( + anthropic_messages_request=request, model=model + ) + + assert changed is True + assert request["thinking"] == {"type": "adaptive"} + assert request["output_config"]["effort"] == "low" + + +def test_bedrock_clear_thinking_converts_legacy_enabled_budget_to_effort(): + """A legacy ``thinking.type=enabled`` with a budget on an adaptive model is + translated to ``adaptive`` + ``output_config.effort`` derived from the budget + rather than forwarded as the rejected ``enabled`` shape.""" + cfg = AmazonAnthropicClaudeMessagesConfig() + request = { + "max_tokens": 32000, + "thinking": { + "type": "enabled", + "budget_tokens": DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, + }, + "context_management": { + "edits": [{"type": "clear_thinking_20251015", "keep": "all"}] + }, + } + + changed = cfg._ensure_thinking_for_clear_thinking_context_management( + anthropic_messages_request=request, model="us.anthropic.claude-opus-4-8" + ) + + assert changed is True + assert request["thinking"] == {"type": "adaptive"} + assert request["output_config"]["effort"] == "high" + + +def test_resolve_clear_thinking_budget_tokens_honors_explicit_zero(): + """A truthiness guard would treat an explicit ``budget_tokens=0`` as missing + and silently substitute the Bedrock minimum. The resolver must honor ``0`` + and only fall back to the minimum when the caller omits the budget.""" + cfg = AmazonAnthropicClaudeMessagesConfig() + assert cfg._resolve_clear_thinking_budget_tokens(0) == 0 + assert ( + cfg._resolve_clear_thinking_budget_tokens(None) + == BEDROCK_MIN_THINKING_BUDGET_TOKENS + ) + assert cfg._resolve_clear_thinking_budget_tokens(12000) == 12000 + + +def test_bedrock_clear_thinking_keeps_enabled_for_non_adaptive_models(): + """Non-adaptive extended-thinking models (e.g. Opus 4.5) still use the legacy + ``thinking.type=enabled`` + ``budget_tokens`` shape, which they accept.""" + cfg = AmazonAnthropicClaudeMessagesConfig() + request = { + "max_tokens": 32000, + "context_management": { + "edits": [{"type": "clear_thinking_20251015", "keep": "all"}] + }, + } + + changed = cfg._ensure_thinking_for_clear_thinking_context_management( + anthropic_messages_request=request, model="anthropic.claude-opus-4-5-v1:0" + ) + + assert changed is True + assert request["thinking"] == { + "type": "enabled", + "budget_tokens": BEDROCK_MIN_THINKING_BUDGET_TOKENS, + } + assert "output_config" not in request + + +def test_bedrock_invoke_transform_emits_adaptive_thinking_for_opus_4_8(): + """End-to-end: a Claude Code clear_thinking payload (no ``thinking`` field) on + Opus 4.8 must produce a Bedrock Invoke body with adaptive thinking and an + effort, never ``thinking.type.enabled``.""" + 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="us.anthropic.claude-opus-4-8", + messages=[{"role": "user", "content": "hi"}], + anthropic_messages_optional_request_params=copy.deepcopy(optional_params), + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert result["thinking"] == {"type": "adaptive"} + assert result["output_config"]["effort"] == "low" + + +def test_bedrock_invoke_transform_normalizes_system_role_message_into_system(): + """Bedrock Invoke rejects ``role: "system"`` entries in ``messages`` on some + Claude aliases; they must be moved into the top-level ``system`` field + (Anthropic Messages carries that content there).""" + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + messages = [ + {"role": "system", "content": "You are a careful assistant."}, + {"role": "user", "content": "hi"}, + ] + + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-3-haiku-20240307-v1:0", + messages=copy.deepcopy(messages), + anthropic_messages_optional_request_params={"max_tokens": 256, "stream": False}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert all(m.get("role") != "system" for m in result["messages"]) + assert result["messages"] == [{"role": "user", "content": "hi"}] + assert result["system"] == [ + {"type": "text", "text": "You are a careful assistant."} + ] + + +def test_bedrock_invoke_transform_merges_system_role_into_existing_system(): + """A ``role: "system"`` message is appended to any pre-existing top-level + ``system`` content rather than replacing it.""" + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + messages = [ + {"role": "system", "content": "Follow the user's formatting."}, + {"role": "user", "content": "hi"}, + ] + + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-3-haiku-20240307-v1:0", + messages=copy.deepcopy(messages), + anthropic_messages_optional_request_params={ + "max_tokens": 256, + "stream": False, + "system": "Base system prompt.", + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert result["messages"] == [{"role": "user", "content": "hi"}] + assert result["system"] == [ + {"type": "text", "text": "Base system prompt."}, + {"type": "text", "text": "Follow the user's formatting."}, + ] + + +def test_bedrock_invoke_transform_merges_list_content_system_role_into_system(): + """A ``role: "system"`` message whose content is a list of content blocks is + merged block-by-block into the top-level ``system`` field, preserving any + pre-existing system blocks.""" + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + messages = [ + { + "role": "system", + "content": [ + {"type": "text", "text": "Be terse."}, + {"type": "text", "text": "Cite sources."}, + ], + }, + {"role": "user", "content": "hi"}, + ] + + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-3-haiku-20240307-v1:0", + messages=copy.deepcopy(messages), + anthropic_messages_optional_request_params={ + "max_tokens": 256, + "stream": False, + "system": [{"type": "text", "text": "Base."}], + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert result["messages"] == [{"role": "user", "content": "hi"}] + assert result["system"] == [ + {"type": "text", "text": "Base."}, + {"type": "text", "text": "Be terse."}, + {"type": "text", "text": "Cite sources."}, + ] + + +def test_as_system_content_blocks_handles_each_shape(): + """``_as_system_content_blocks`` normalizes every system shape: ``None`` -> empty, + a string -> a single text block, a list -> a shallow copy, and any other value + (e.g. a bare content-block dict) -> wrapped in a single-element list.""" + block = {"type": "text", "text": "x"} + assert AmazonAnthropicClaudeMessagesConfig._as_system_content_blocks(None) == [] + assert AmazonAnthropicClaudeMessagesConfig._as_system_content_blocks("hello") == [ + {"type": "text", "text": "hello"} + ] + blocks = [block] + out = AmazonAnthropicClaudeMessagesConfig._as_system_content_blocks(blocks) + assert out == blocks and out is not blocks + assert AmazonAnthropicClaudeMessagesConfig._as_system_content_blocks(block) == [ + block + ] + + +@pytest.mark.parametrize( + "budget_tokens,expected_effort", + [ + (0, "low"), + (DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET - 1, "low"), + (DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET, "medium"), + (DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET - 1, "medium"), + (DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, "high"), + (DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET - 1, "high"), + (DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET, "xhigh"), + (DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET * 2, "xhigh"), + ], +) +def test_effort_from_thinking_budget_tiers(budget_tokens, expected_effort): + """The budget -> effort mapping pins each tier boundary so a shifted threshold + is caught.""" + assert ( + AmazonAnthropicClaudeMessagesConfig._effort_from_thinking_budget(budget_tokens) + == expected_effort + ) + + +def test_inject_adaptive_thinking_preserves_existing_effort(): + """When the request already carries an ``output_config.effort`` we keep it + rather than overwriting with the budget-derived tier.""" + cfg = AmazonAnthropicClaudeMessagesConfig() + request = {"output_config": {"effort": "max", "other": "keep"}} + + cfg._inject_adaptive_thinking_for_clear_thinking( + request, budget_tokens=24000, model="us.anthropic.claude-fable-5" + ) + + assert request["thinking"] == {"type": "adaptive"} + assert request["output_config"] == {"effort": "max", "other": "keep"} + + +def test_bedrock_clear_thinking_noops_when_thinking_already_adaptive(): + """An incoming ``thinking.type=adaptive`` is already valid for clear_thinking; + the helper must leave the request untouched and report no change.""" + cfg = AmazonAnthropicClaudeMessagesConfig() + request = { + "max_tokens": 32000, + "thinking": {"type": "adaptive"}, + "context_management": { + "edits": [{"type": "clear_thinking_20251015", "keep": "all"}] + }, + } + + changed = cfg._ensure_thinking_for_clear_thinking_context_management( + anthropic_messages_request=request, model="us.anthropic.claude-fable-5" + ) + + assert changed is False + assert request["thinking"] == {"type": "adaptive"} + assert "output_config" not in request + + +def test_bedrock_clear_thinking_replaces_disabled_thinking_on_adaptive_model(): + """A ``thinking.type=disabled`` (or any non-enabled/adaptive shape) is invalid + for clear_thinking on Bedrock; it must be replaced with a usable config. On an + adaptive model that means ``thinking.type=adaptive`` + ``output_config.effort``.""" + cfg = AmazonAnthropicClaudeMessagesConfig() + request = { + "max_tokens": 32000, + "thinking": {"type": "disabled"}, + "context_management": { + "edits": [{"type": "clear_thinking_20251015", "keep": "all"}] + }, + } + + changed = cfg._ensure_thinking_for_clear_thinking_context_management( + anthropic_messages_request=request, model="us.anthropic.claude-fable-5" + ) + + assert changed is True + assert request["thinking"] == {"type": "adaptive"} + assert request["output_config"]["effort"] == "low" + + +def test_bedrock_clear_thinking_leaves_enabled_thinking_on_non_adaptive_model(): + """A caller-supplied ``thinking.type=enabled`` on a non-adaptive extended-thinking + model (Opus 4.5) is already accepted by Bedrock, so the helper leaves it as-is + and does not convert it to the adaptive shape.""" + cfg = AmazonAnthropicClaudeMessagesConfig() + request = { + "max_tokens": 32000, + "thinking": {"type": "enabled", "budget_tokens": 8000}, + "context_management": { + "edits": [{"type": "clear_thinking_20251015", "keep": "all"}] + }, + } + + changed = cfg._ensure_thinking_for_clear_thinking_context_management( + anthropic_messages_request=request, model="anthropic.claude-opus-4-5-v1:0" + ) + + assert changed is False + assert request["thinking"] == {"type": "enabled", "budget_tokens": 8000} + assert "output_config" not in request diff --git a/tests/test_litellm/llms/hosted_vllm/chat/test_hosted_vllm_chat_transformation.py b/tests/test_litellm/llms/hosted_vllm/chat/test_hosted_vllm_chat_transformation.py index 5c1f1dcb63d3..3ddb67b9f8dd 100644 --- a/tests/test_litellm/llms/hosted_vllm/chat/test_hosted_vllm_chat_transformation.py +++ b/tests/test_litellm/llms/hosted_vllm/chat/test_hosted_vllm_chat_transformation.py @@ -7,6 +7,10 @@ 0, os.path.abspath("../../../../..") ) # Adds the parent directory to the system path +from litellm.constants import ( + DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, + DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET, +) from litellm.llms.hosted_vllm.chat.transformation import HostedVLLMChatConfig @@ -135,9 +139,14 @@ def test_hosted_vllm_supports_thinking(): ) assert "thinking" in supported_params - # Test thinking with low budget_tokens -> "minimal" (for < 2000) + # Test thinking below the low threshold -> "minimal" optional_params = config.map_openai_params( - non_default_params={"thinking": {"type": "enabled", "budget_tokens": 1024}}, + non_default_params={ + "thinking": { + "type": "enabled", + "budget_tokens": DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET - 1, + } + }, optional_params={}, model="hosted_vllm/GLM-4.6-FP8", drop_params=False, @@ -147,7 +156,12 @@ def test_hosted_vllm_supports_thinking(): # Test thinking with high budget_tokens -> "high" optional_params = config.map_openai_params( - non_default_params={"thinking": {"type": "enabled", "budget_tokens": 15000}}, + non_default_params={ + "thinking": { + "type": "enabled", + "budget_tokens": DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, + } + }, optional_params={}, model="hosted_vllm/GLM-4.6-FP8", drop_params=False, @@ -157,7 +171,10 @@ def test_hosted_vllm_supports_thinking(): # Test that existing reasoning_effort is not overwritten optional_params = config.map_openai_params( non_default_params={ - "thinking": {"type": "enabled", "budget_tokens": 15000}, + "thinking": { + "type": "enabled", + "budget_tokens": DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, + }, "reasoning_effort": "low", }, optional_params={},