diff --git a/litellm/constants.py b/litellm/constants.py index 334ef8d48a..177fb024d6 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -399,6 +399,16 @@ BEDROCK_MAX_POLICY_SIZE = int(os.getenv("BEDROCK_MAX_POLICY_SIZE", 75)) BEDROCK_MIN_THINKING_BUDGET_TOKENS = int( os.getenv("BEDROCK_MIN_THINKING_BUDGET_TOKENS", 1024) ) +# Anthropic's Messages API rejects ``thinking.budget_tokens < 1024`` with a +# 400. ``reasoning_effort='minimal'`` historically mapped to 128 (the global +# default) which always 400'd against direct Anthropic, Azure AI Anthropic, +# Vertex AI Anthropic, and Bedrock Invoke. Use the provider minimum so +# ``minimal`` is a usable tier on all Anthropic-backed routes; Bedrock +# Converse already clamped to 1024 server-side, so this just unifies the +# behavior. +ANTHROPIC_MIN_THINKING_BUDGET_TOKENS = int( + os.getenv("ANTHROPIC_MIN_THINKING_BUDGET_TOKENS", 1024) +) REPLICATE_POLLING_DELAY_SECONDS = float( os.getenv("REPLICATE_POLLING_DELAY_SECONDS", 0.5) ) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 35624c93b3..7b07a96ca5 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -7,6 +7,7 @@ import httpx import litellm from litellm.constants import ( + ANTHROPIC_MIN_THINKING_BUDGET_TOKENS, ANTHROPIC_WEB_SEARCH_TOOL_MAX_USES, DEFAULT_ANTHROPIC_CHAT_MAX_TOKENS, DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, @@ -819,9 +820,16 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): budget_tokens=DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, ) elif reasoning_effort == "minimal": + # Anthropic Messages API rejects ``budget_tokens < 1024`` with a + # 400. Floor at the provider minimum so ``minimal`` is a usable + # tier on Anthropic / Azure AI Anthropic / Vertex AI Anthropic / + # Bedrock Invoke. Bedrock Converse already clamps server-side. return AnthropicThinkingParam( type="enabled", - budget_tokens=DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET, + budget_tokens=max( + DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET, + ANTHROPIC_MIN_THINKING_BUDGET_TOKENS, + ), ) else: raise ValueError(f"Unmapped reasoning effort: {reasoning_effort}") @@ -1088,9 +1096,21 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): elif param == "thinking": optional_params["thinking"] = value elif param == "reasoning_effort" and isinstance(value, str): - mapped_thinking = AnthropicConfig._map_reasoning_effort( - reasoning_effort=value, model=model - ) + # Wrap the ``ValueError`` ``_map_reasoning_effort`` raises on + # unmapped efforts (``disabled`` / ``invalid`` / ``""`` / + # ``xhigh``/``max`` on budget-mode Claude 4.5) into a clean + # 400 ``BadRequestError`` instead of letting it surface as + # 500. + try: + mapped_thinking = AnthropicConfig._map_reasoning_effort( + reasoning_effort=value, model=model + ) + except ValueError as e: + raise litellm.exceptions.BadRequestError( + message=str(e), + model=model, + llm_provider=self.custom_llm_provider or "anthropic", + ) if mapped_thinking is None: optional_params.pop("thinking", None) optional_params.pop("output_config", None) @@ -1526,18 +1546,31 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): def _apply_output_config( self, data: dict, model: str, optional_params: dict ) -> None: - """Validate and apply output_config to the request data.""" + """Validate and apply output_config to the request data. + + Validation errors raise ``BadRequestError`` (clean 400) so callers + passing ``effort="disabled"`` / ``effort=""`` / unsupported tiers + for the model see a client-side error rather than a 500. + """ if "output_config" not in optional_params: return output_config = optional_params.get("output_config") if not output_config or not isinstance(output_config, dict): return effort = output_config.get("effort") + # ``effort=""`` (empty string) and unmapped strings should be treated + # as invalid, not silently passed through. We use ``effort is not None`` + # here so empty string fails the membership check below. (The legacy + # ``if effort and ...`` short-circuit silently accepted ``""``.) valid_efforts = ["high", "medium", "low", "xhigh", "max"] - if effort and effort not in valid_efforts: - raise ValueError( - f"Invalid effort value: {effort}. Must be one of: " - f"'high', 'medium', 'low', 'xhigh', 'max'" + 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'" + ), + model=model, + llm_provider=self.custom_llm_provider or "anthropic", ) # ``max`` is for Opus 4.6+ output effort (not Sonnet 4.6, not Opus 4.5). # Accept known Opus 4.6/4.7 id patterns and/or ``supports_max_reasoning_effort`` @@ -1547,14 +1580,24 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): or self._is_opus_4_7_model(model) or self._supports_effort_level(model, "max") ): - raise ValueError( - f"effort='max' is not supported by this model. Got model: {model}" + raise litellm.exceptions.BadRequestError( + message=( + f"effort='max' is not supported by this model. " + f"Got model: {model}" + ), + model=model, + llm_provider=self.custom_llm_provider or "anthropic", ) # ``xhigh`` is data-driven via ``supports_xhigh_reasoning_effort`` so # enabling it for a new model is a pure model-map change. if effort == "xhigh" and not self._supports_effort_level(model, "xhigh"): - raise ValueError( - f"effort='xhigh' is not supported by this model. Got model: {model}" + raise litellm.exceptions.BadRequestError( + message=( + f"effort='xhigh' is not supported by this model. " + f"Got model: {model}" + ), + model=model, + llm_provider=self.custom_llm_provider or "anthropic", ) data["output_config"] = output_config diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 8b8500ac06..9e46a5f2ae 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -415,6 +415,15 @@ class AmazonConverseConfig(BaseConfig): - Nova 2 models: Transform to reasoningConfig structure - Other models (Anthropic, etc.): Convert to thinking parameter + For Claude 4.6 / 4.7 (adaptive thinking) the tier is carried via + ``output_config.effort`` rather than ``thinking.budget_tokens``. We + validate the effort with the same rules ``AnthropicConfig._apply_output_config`` + uses (low/medium/high/xhigh/max + per-model gating) and stage the + validated dict on ``optional_params["output_config"]`` so it rides + along to ``additionalModelRequestFields`` on the Anthropic-on-Bedrock + wire path. Without this the silent strip in ``_prepare_request_params`` + collapsed every adaptive tier to identical behavior. + Args: model: The model identifier reasoning_effort: The reasoning effort value @@ -448,14 +457,115 @@ class AmazonConverseConfig(BaseConfig): ) optional_params.update(reasoning_config) else: - # Anthropic and other models: convert to thinking parameter - mapped_thinking = AnthropicConfig._map_reasoning_effort( - reasoning_effort=reasoning_effort, model=model - ) + # Anthropic and other models: convert to thinking parameter. + # Wrap the ``ValueError`` ``_map_reasoning_effort`` raises on + # unmapped efforts (``disabled`` / ``invalid`` / ``""`` / + # ``xhigh``/``max`` on budget-mode Claude 4.5) into a clean 400 + # ``BadRequestError`` instead of letting it surface as 500. + try: + mapped_thinking = AnthropicConfig._map_reasoning_effort( + reasoning_effort=reasoning_effort, model=model + ) + except ValueError as e: + raise litellm.exceptions.BadRequestError( + message=str(e), + model=model, + llm_provider="bedrock_converse", + ) if mapped_thinking is None: optional_params.pop("thinking", None) + optional_params.pop("output_config", None) else: optional_params["thinking"] = mapped_thinking + # Adaptive-thinking models (Claude 4.6 / 4.7) take the tier + # via output_config.effort. Mirror the mapping used by + # AnthropicConfig.map_openai_params and apply the same + # validation rules so unmapped/garbage efforts surface as a + # 400 instead of being silently flattened on the wire. + if AnthropicConfig._is_claude_4_6_model( + model + ) or AnthropicConfig._is_claude_4_7_model(model): + effort_map = { + "low": "low", + "minimal": "low", + "medium": "medium", + "high": "high", + "xhigh": "xhigh", + "max": "max", + } + mapped_effort = effort_map.get(reasoning_effort, reasoning_effort) + self._validate_anthropic_adaptive_effort( + model=model, effort=mapped_effort + ) + optional_params["output_config"] = {"effort": mapped_effort} + + @staticmethod + def _supports_effort_level_on_bedrock(model: str, level: str) -> bool: + """Look up ``supports_{level}_reasoning_effort`` for a Bedrock-routed + model id directly in ``litellm.model_cost`` so the bedrock provider + prefix is irrelevant to the lookup. ``AnthropicConfig._supports_effort_level`` + hard-codes ``custom_llm_provider="anthropic"`` and returns False for + the same effort level on a Bedrock model id. + """ + try: + base_model = BedrockModelInfo.get_base_model(model) + for key in (model, base_model, f"bedrock/{base_model}"): + if key and key in litellm.model_cost: + if ( + litellm.model_cost[key].get( + f"supports_{level}_reasoning_effort" + ) + is True + ): + return True + except Exception: + pass + return False + + @staticmethod + def _validate_anthropic_adaptive_effort(model: str, effort: str) -> None: + """Validate ``output_config.effort`` for adaptive-thinking Claude 4.6/4.7 + on Bedrock. Raises ``BadRequestError`` (clean 400) instead of letting + a downstream ``ValueError`` surface as 500. + """ + valid_efforts = {"high", "medium", "low", "xhigh", "max"} + if effort not in valid_efforts: + raise litellm.exceptions.BadRequestError( + message=( + f"Invalid reasoning_effort/output_config.effort value: " + f"{effort!r}. Must be one of: 'low', 'medium', 'high', " + f"'xhigh', 'max', or 'none'." + ), + model=model, + llm_provider="bedrock_converse", + ) + if effort == "max" and not ( + AnthropicConfig._is_opus_4_6_model(model) + or AnthropicConfig._is_opus_4_7_model(model) + or AmazonConverseConfig._supports_effort_level_on_bedrock(model, "max") + ): + raise litellm.exceptions.BadRequestError( + message=( + f"effort='max' is not supported by this model. " + f"Got model: {model}" + ), + model=model, + llm_provider="bedrock_converse", + ) + if ( + effort == "xhigh" + and not AmazonConverseConfig._supports_effort_level_on_bedrock( + model, "xhigh" + ) + ): + raise litellm.exceptions.BadRequestError( + message=( + f"effort='xhigh' is not supported by this model. " + f"Got model: {model}" + ), + model=model, + llm_provider="bedrock_converse", + ) @staticmethod def _clamp_thinking_budget_tokens(optional_params: dict) -> None: @@ -1196,9 +1306,15 @@ class AmazonConverseConfig(BaseConfig): + supported_config_params ) inference_params.pop("json_mode", None) # used for handling json_schema - # Anthropic-only key. Bedrock expects `outputConfig` (camelCase) and - # will reject `output_config` if it leaks through pass-through routes. - inference_params.pop("output_config", None) + + # Anthropic-only ``output_config`` (snake_case) is the adaptive- + # thinking effort payload (e.g. ``{"effort": "max"}``) for Claude + # 4.6/4.7. On Bedrock Converse it must ride along inside + # ``additionalModelRequestFields`` so the model actually sees the + # tier; stripping it (the prior behavior) silently flattened every + # adaptive tier to identical thinking. Only the Bedrock-native + # ``outputConfig`` (camelCase) goes at the top level. + anthropic_output_config = inference_params.pop("output_config", None) # Extract requestMetadata before processing other parameters request_metadata = inference_params.pop("requestMetadata", None) @@ -1208,9 +1324,6 @@ class AmazonConverseConfig(BaseConfig): output_config: Optional[OutputConfigBlock] = inference_params.pop( "outputConfig", None ) - inference_params.pop( - "output_config", None - ) # Bedrock Converse doesn't support it # keep supported params in 'inference_params', and set all model-specific params in 'additional_request_params' additional_request_params = { @@ -1253,6 +1366,20 @@ class AmazonConverseConfig(BaseConfig): additional_request_params ) + # Re-attach the Anthropic ``output_config`` (e.g. adaptive thinking + # ``{"effort": "max"}``) onto additional_request_params for Anthropic + # Bedrock models so the wire request carries the requested tier. Other + # model families (Nova, GPT-OSS, ...) don't accept it; drop it for them. + if anthropic_output_config is not None and isinstance( + anthropic_output_config, dict + ): + base_model = BedrockModelInfo.get_base_model(model) + if base_model.startswith("anthropic"): + effort = anthropic_output_config.get("effort") + if effort is not None: + self._validate_anthropic_adaptive_effort(model=model, effort=effort) + additional_request_params["output_config"] = anthropic_output_config + return ( inference_params, additional_request_params, diff --git a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py index cff415d49e..7de05c977c 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py @@ -169,7 +169,11 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig): anthropic_request.pop("model", None) anthropic_request.pop("stream", None) anthropic_request.pop("output_format", None) - anthropic_request.pop("output_config", None) + # ``output_config`` (e.g. ``{"effort": "max"}``) is the adaptive-thinking + # tier payload for Claude 4.6 / 4.7. Bedrock Invoke accepts it for + # those models — stripping it (the prior behavior) silently flattened + # every adaptive tier on this route. Forward it; if the model rejects + # it the surfaced error is correct, vs. swallowing the user's knob. if "anthropic_version" not in anthropic_request: anthropic_request["anthropic_version"] = self.anthropic_version diff --git a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py index d450f7a463..714877457f 100644 --- a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py +++ b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py @@ -159,10 +159,11 @@ class VertexAIPartnerModelsAnthropicMessagesConfig(AnthropicMessagesConfig, Vert "model", None ) # do not pass model in request body to vertex ai - # Vertex AI Claude accepts ``output_config.format`` (structured outputs) - # and ``output_format``, but rejects ``output_config.effort`` with 400 - # "Extra inputs are not permitted". Sanitize in place so the supported - # bits flow through. + # Vertex AI Claude accepts ``output_config.format`` (structured outputs), + # ``output_format``, and ``output_config.effort`` (adaptive-thinking + # tier on Claude 4.6 / 4.7, verified end-to-end). The shared sanitize + # helper now no-ops for ``effort`` and remains the single hook for any + # future Vertex-only key drift. sanitize_vertex_anthropic_output_params(anthropic_messages_request) return anthropic_messages_request diff --git a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/output_params_utils.py b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/output_params_utils.py index 982d8edbf2..891c8e4d05 100644 --- a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/output_params_utils.py +++ b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/output_params_utils.py @@ -11,11 +11,12 @@ keeps the parent module's import surface narrow. """ # Keys inside ``output_config`` that Vertex AI Claude does not accept. -# Today only ``effort`` triggers "Extra inputs are not permitted"; add new -# entries here as Vertex parity drifts. Keep this list narrow — anything -# Vertex DOES accept (e.g. ``format`` for structured outputs) must be -# preserved so callers can rely on Anthropic-native features. -VERTEX_UNSUPPORTED_OUTPUT_CONFIG_KEYS: frozenset = frozenset({"effort"}) +# Vertex now accepts ``output_config.effort`` for the adaptive-thinking +# Claude 4.6 / 4.7 models on direct ``:rawPredict`` (verified end-to-end +# against ``us-east5`` for ``opus-4-6`` and ``global`` for ``opus-4-7``). +# Keep this set narrow and only add a key here once a 400 "Extra inputs are +# not permitted" is reproducible against the live Vertex endpoint. +VERTEX_UNSUPPORTED_OUTPUT_CONFIG_KEYS: frozenset = frozenset() def sanitize_vertex_anthropic_output_params(data: dict) -> None: diff --git a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/transformation.py b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/transformation.py index 914c7e92e5..48920402e2 100644 --- a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/transformation.py +++ b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/transformation.py @@ -106,11 +106,10 @@ class VertexAIAnthropicConfig(AnthropicConfig): data.pop("model", None) # vertex anthropic doesn't accept 'model' parameter - # Vertex AI Claude accepts ``output_config.format`` (structured outputs / - # JSON Schema) but NOT ``output_config.effort`` — sending ``effort`` to - # Vertex returns 400 "Extra inputs are not permitted". Sanitize in place: - # forward the structured-output bits, drop the unsupported keys. - # Same treatment for the legacy top-level ``output_format`` field. + # Sanitize ``output_config`` / ``output_format`` for Vertex parity. + # Vertex now accepts ``output_config.effort`` for adaptive-thinking Claude + # 4.6 / 4.7 models, so the helper is a no-op for ``effort``; it remains + # the single hook for future Vertex-only sanitization. sanitize_vertex_anthropic_output_params(data) tools = optional_params.get("tools") diff --git a/litellm/types/llms/bedrock.py b/litellm/types/llms/bedrock.py index 9ffb52ef88..72e509d178 100644 --- a/litellm/types/llms/bedrock.py +++ b/litellm/types/llms/bedrock.py @@ -1041,3 +1041,12 @@ class BedrockInvokeAnthropicMessagesRequest(TypedDict, total=False): # `metadata` is part of the common Anthropic Messages API shape. thinking: dict metadata: dict + + # ``output_config`` is the adaptive-thinking effort payload for + # Claude 4.6 / 4.7 (e.g. ``{"effort": "max"}``). Bedrock Invoke + # accepts it for these models when ``thinking={"type": "adaptive"}``. + # Without this field in the allowlist, the runtime filter in + # ``AmazonAnthropicClaudeMessagesConfig.transform_anthropic_messages_request`` + # silently drops it and every adaptive tier collapses to identical + # behavior on /v1/messages. + output_config: dict diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 76dcfc55c1..cc34446be0 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -9491,7 +9491,6 @@ "us": 1.1, "fast": 6.0 }, - "supports_max_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, "claude-opus-4-7-20260416": { @@ -9526,7 +9525,6 @@ "us": 1.1, "fast": 6.0 }, - "supports_max_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, "claude-sonnet-4-20250514": { 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 cbb9b21d62..0381b850d6 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 @@ -8,6 +8,7 @@ sys.path.insert( ) # Adds the parent directory to the system path from unittest.mock import MagicMock, patch +import litellm from litellm.constants import RESPONSE_FORMAT_TOOL_NAME from litellm.llms.anthropic.chat.transformation import AnthropicConfig from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( @@ -1631,8 +1632,10 @@ def test_effort_validation(): ) assert result["output_config"]["effort"] == effort - # Invalid value should raise error - with pytest.raises(ValueError, match="Invalid effort value"): + # Invalid value should raise BadRequestError (clean 400, not a 500). + with pytest.raises( + litellm.exceptions.BadRequestError, match="Invalid effort value" + ): optional_params = {"output_config": {"effort": "invalid"}} config.transform_request( model="claude-opus-4-5-20251101", @@ -1682,12 +1685,18 @@ def test_effort_validation_with_opus_46(): def test_max_effort_rejected_for_opus_45(): - """Test that effort='max' is rejected when using Claude Opus 4.5.""" + """Test that effort='max' is rejected when using Claude Opus 4.5. + + Surfaces as a clean 400 BadRequestError, not a 500 ValueError. + """ config = AnthropicConfig() messages = [{"role": "user", "content": "Test"}] - with pytest.raises(ValueError, match="effort='max' is not supported by this model"): + with pytest.raises( + litellm.exceptions.BadRequestError, + match="effort='max' is not supported by this model", + ): optional_params = {"output_config": {"effort": "max"}} config.transform_request( model="claude-opus-4-5-20251101", @@ -2153,12 +2162,14 @@ def test_reasoning_effort_maps_to_budget_thinking_for_non_opus_4_6(): """ config = AnthropicConfig() - # Test with Claude Sonnet 4.5 (non-Opus 4.6 model) + # Test with Claude Sonnet 4.5 (non-Opus 4.6 model). + # ``minimal`` floors at the Anthropic provider minimum (1024) because + # Anthropic / Azure / Vertex / Bedrock Invoke 400 below that. test_cases = [ ("low", 1024), # DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET ("medium", 2048), # DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET ("high", 4096), # DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET - ("minimal", 128), # DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET + ("minimal", 1024), # ANTHROPIC_MIN_THINKING_BUDGET_TOKENS (provider floor) ] for effort, expected_budget in test_cases: @@ -2245,11 +2256,17 @@ def test_reasoning_effort_does_not_set_output_config_for_older_models(): def test_max_effort_rejected_for_sonnet_46(): - """Test that effort='max' is rejected for Sonnet 4.6 (Opus-only effort level).""" + """Test that effort='max' is rejected for Sonnet 4.6 (Opus-only effort level). + + Surfaces as a clean 400 BadRequestError, not a 500 ValueError. + """ config = AnthropicConfig() messages = [{"role": "user", "content": "Test"}] - with pytest.raises(ValueError, match="effort='max' is not supported by this model"): + with pytest.raises( + litellm.exceptions.BadRequestError, + match="effort='max' is not supported by this model", + ): config.transform_request( model="claude-sonnet-4-6-20260219", messages=messages, @@ -2335,6 +2352,81 @@ def test_reasoning_effort_none_omits_thinking_and_output_config(model): assert "output_config" not in result +@pytest.mark.parametrize( + "effort", + ["disabled", "invalid", ""], +) +def test_reasoning_effort_garbage_raises_bad_request(effort): + """Unmapped / garbage / empty-string reasoning_effort surfaces as a clean + 400 ``BadRequestError`` instead of letting ``ValueError`` propagate as 500. + """ + config = AnthropicConfig() + + with pytest.raises(litellm.exceptions.BadRequestError): + config.map_openai_params( + non_default_params={"reasoning_effort": effort}, + optional_params={}, + model="claude-sonnet-4-5-20250929", + drop_params=False, + ) + + +@pytest.mark.parametrize( + "effort", + ["xhigh", "max"], +) +def test_reasoning_effort_unsupported_tier_on_budget_model_raises_bad_request( + effort, +): + """``xhigh`` / ``max`` aren't defined for budget-mode (4.5) Claude models; + surface as a clean 400 instead of 500. + """ + config = AnthropicConfig() + + with pytest.raises(litellm.exceptions.BadRequestError): + config.map_openai_params( + non_default_params={"reasoning_effort": effort}, + optional_params={}, + model="claude-sonnet-4-5-20250929", + drop_params=False, + ) + + +def test_output_config_effort_empty_string_raises_bad_request(): + """``output_config={"effort": ""}`` must be rejected with a 400 — the + legacy ``if effort and ...`` short-circuit silently let it pass + through (verified end-to-end on the QA sweep for PR #27039). + """ + config = AnthropicConfig() + + with pytest.raises(litellm.exceptions.BadRequestError, match="Invalid effort"): + config.transform_request( + model="claude-opus-4-7", + messages=[{"role": "user", "content": "hi"}], + optional_params={"output_config": {"effort": ""}, "max_tokens": 32}, + litellm_params={}, + headers={}, + ) + + +def test_reasoning_effort_minimal_floors_at_anthropic_provider_minimum(): + """Anthropic Messages API rejects ``budget_tokens < 1024``. ``minimal`` + must floor at the provider minimum so it's a usable tier on direct + Anthropic / Azure AI Anthropic / Vertex AI Anthropic / Bedrock Invoke. + """ + config = AnthropicConfig() + + result = config.map_openai_params( + non_default_params={"reasoning_effort": "minimal"}, + optional_params={}, + model="claude-sonnet-4-5-20250929", + drop_params=False, + ) + + assert result["thinking"]["type"] == "enabled" + assert result["thinking"]["budget_tokens"] >= 1024 + + def test_effort_beta_header_still_injected_for_older_models(): """ Test that is_effort_used still returns True for pre-4.6 models diff --git a/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py b/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py index 80d6d26e7e..8689a05e8e 100644 --- a/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py @@ -406,36 +406,37 @@ def test_opus_4_5_model_detection(): # f"computer-use beta should be kept, got: {anthropic_beta}" -def test_output_config_removed_from_bedrock_chat_invoke_request(): +def test_output_config_forwarded_for_bedrock_chat_invoke_request(): """ - Test that output_config parameter is stripped from Bedrock Chat Invoke requests. + Bedrock Invoke (chat/completions route) must forward + ``output_config`` for Anthropic adaptive-thinking models. The earlier + behavior stripped it unconditionally, which silently flattened every + adaptive tier (``low``/``medium``/``high``/``xhigh``/``max``) to identical + behavior on the wire. - Bedrock Invoke API doesn't support the output_config parameter (Anthropic-only). - Ensures the chat/invoke path mirrors the messages/invoke path fix. - - Fixes: https://github.com/BerriAI/litellm/issues/22797 + The wire QA at https://github.com/BerriAI/litellm/pull/27039 showed + ``thinking.type: adaptive`` was forwarded but ``output_config.effort`` + was always missing, even though direct curls to Anthropic's Bedrock + Invoke endpoint accept it. """ config = AmazonAnthropicClaudeConfig() messages = [{"role": "user", "content": "test"}] - # Inject output_config into optional_params (simulates Anthropic SDK forwarding it) optional_params = { "max_tokens": 100, "output_config": {"effort": "high"}, } result = config.transform_request( - model="anthropic.claude-sonnet-4-20250514-v1:0", + model="anthropic.claude-opus-4-7", messages=messages, optional_params=optional_params, litellm_params={}, headers={}, ) - assert ( - "output_config" not in result - ), f"output_config should be stripped for Bedrock Chat Invoke, got keys: {list(result.keys())}" + assert result.get("output_config") == {"effort": "high"} # Verify normal params survive assert result["max_tokens"] == 100 diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py index 5bb51ac619..01dbc81fbb 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -310,6 +310,111 @@ def test_reasoning_effort_none_omits_thinking_for_anthropic_converse(model): assert "thinking" not in optional_params +@pytest.mark.parametrize( + "model,effort,expected_effort", + [ + ("bedrock/converse/us.anthropic.claude-opus-4-7", "low", "low"), + ("bedrock/converse/us.anthropic.claude-opus-4-7", "medium", "medium"), + ("bedrock/converse/us.anthropic.claude-opus-4-7", "high", "high"), + ("bedrock/converse/us.anthropic.claude-opus-4-7", "xhigh", "xhigh"), + ("bedrock/converse/us.anthropic.claude-opus-4-7", "max", "max"), + ("bedrock/converse/us.anthropic.claude-opus-4-6-v1", "max", "max"), + ("bedrock/converse/us.anthropic.claude-sonnet-4-6", "high", "high"), + ("bedrock/converse/us.anthropic.claude-sonnet-4-6", "minimal", "low"), + ], +) +def test_reasoning_effort_sets_output_config_for_adaptive_models_converse( + model, effort, expected_effort +): + """Adaptive-thinking Claude 4.6 / 4.7 on Bedrock Converse must carry the + requested tier via ``output_config.effort``. The prior strip silently + flattened every adaptive tier on the wire (verified in the PR #27039 + QA sweep).""" + config = AmazonConverseConfig() + + optional_params = config.map_openai_params( + non_default_params={"reasoning_effort": effort}, + optional_params={}, + model=model, + drop_params=False, + ) + + assert optional_params["thinking"]["type"] == "adaptive" + assert optional_params["output_config"] == {"effort": expected_effort} + + +@pytest.mark.parametrize( + "model", + [ + "bedrock/converse/us.anthropic.claude-opus-4-7", + "bedrock/converse/us.anthropic.claude-opus-4-6-v1", + "bedrock/converse/us.anthropic.claude-sonnet-4-6", + ], +) +def test_output_config_effort_forwarded_into_additional_request_fields(model): + """``output_config`` must ride along inside ``additionalModelRequestFields`` + so the Anthropic-on-Bedrock wire request actually carries the effort + tier. The prior ``inference_params.pop("output_config")`` dropped it + on the floor for every adaptive tier.""" + config = AmazonConverseConfig() + messages = [{"role": "user", "content": "hi"}] + + result = config._transform_request( + model=model, + messages=messages, + optional_params={ + "maxTokens": 256, + "thinking": {"type": "adaptive"}, + "output_config": {"effort": "high"}, + }, + litellm_params={}, + headers={}, + ) + + additional = result.get("additionalModelRequestFields", {}) + assert additional.get("output_config") == {"effort": "high"} + + +@pytest.mark.parametrize( + "effort", + ["disabled", "invalid", ""], +) +def test_reasoning_effort_garbage_raises_bad_request_converse(effort): + """Garbage / empty-string reasoning_effort on Bedrock Converse Anthropic + must surface as a clean 400 ``BadRequestError`` instead of 500. The + earlier ``ValueError`` from ``_map_reasoning_effort`` propagated up as + a generic 500 in the proxy and ate the request.""" + config = AmazonConverseConfig() + + with pytest.raises(litellm.exceptions.BadRequestError): + config.map_openai_params( + non_default_params={"reasoning_effort": effort}, + optional_params={}, + model="bedrock/converse/us.anthropic.claude-opus-4-7", + drop_params=False, + ) + + +def test_output_config_effort_unsupported_max_on_sonnet_46_raises_bad_request(): + """``effort='max'`` is Opus-only. On Sonnet 4.6 the explicit-output_config + path must surface a 400 (matching the chat-completion validation), not + silently forward an unsupported tier to Bedrock.""" + config = AmazonConverseConfig() + messages = [{"role": "user", "content": "hi"}] + + with pytest.raises(litellm.exceptions.BadRequestError): + config._transform_request( + model="bedrock/converse/us.anthropic.claude-sonnet-4-6", + messages=messages, + optional_params={ + "maxTokens": 256, + "output_config": {"effort": "max"}, + }, + litellm_params={}, + headers={}, + ) + + def test_get_supported_openai_params(): config = AmazonConverseConfig() supported_params = config.get_supported_openai_params( 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 93d56d4cd0..725bc9503b 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 @@ -592,12 +592,16 @@ def test_remove_scope_from_cache_control(): assert request["messages"][0]["content"][0]["cache_control"]["type"] == "ephemeral" -def test_bedrock_messages_strips_output_config(): +def test_bedrock_messages_forwards_output_config(): """ - Ensure output_config is stripped from the request before sending to - Bedrock Invoke, which doesn't support this Anthropic-specific parameter. + ``output_config`` is the adaptive-thinking effort payload for Claude + 4.6 / 4.7 (e.g. ``{"effort": "max"}``). Bedrock Invoke accepts it for + those models — the prior behavior of stripping it silently flattened + every adaptive tier on /v1/messages so ``low`` / ``medium`` / ``high`` / + ``xhigh`` / ``max`` all collapsed to identical thinking with no tier + differentiation. - Regression test for: https://github.com/BerriAI/litellm/issues/22797 + Regression coverage for the QA bug listed on PR #27039. """ from litellm.types.router import GenericLiteLLMParams @@ -611,25 +615,24 @@ def test_bedrock_messages_strips_output_config(): } result = cfg.transform_anthropic_messages_request( - model="anthropic.claude-3-haiku-20240307-v1:0", + model="anthropic.claude-opus-4-7", messages=messages, anthropic_messages_optional_request_params=optional_params, litellm_params=GenericLiteLLMParams(), headers={}, ) - assert ( - "output_config" not in result - ), "output_config should be stripped — Bedrock Invoke rejects it" + assert result.get("output_config") == {"effort": "high"} # Other params should be preserved assert result.get("max_tokens") == 4096 -def test_bedrock_messages_strips_output_config_with_output_format(): +def test_bedrock_messages_forwards_output_config_with_output_format(): """ - When both output_config and output_format are present, both should be - stripped (output_format is converted to inline schema, output_config - is simply dropped). + When both output_config and output_format are present, output_format is + converted to inline schema (Bedrock Invoke doesn't accept output_format + natively), and output_config is forwarded for Claude 4.6/4.7 adaptive + thinking. """ from litellm.types.router import GenericLiteLLMParams @@ -648,14 +651,14 @@ def test_bedrock_messages_strips_output_config_with_output_format(): } result = cfg.transform_anthropic_messages_request( - model="anthropic.claude-3-haiku-20240307-v1:0", + model="anthropic.claude-opus-4-7", messages=messages, anthropic_messages_optional_request_params=optional_params, litellm_params=GenericLiteLLMParams(), headers={}, ) - assert "output_config" not in result + assert result.get("output_config") == {"effort": "low"} assert "output_format" not in result @@ -728,13 +731,18 @@ def test_bedrock_messages_allowlist_filters_anthropic_only_fields(): "mcp_servers", "container", "inference_geo", - "output_config", "context_management", "model", "stream", ): assert bad not in result, f"{bad} should be stripped by the allowlist" + # ``output_config`` rides along — Bedrock Invoke accepts it for Claude + # 4.6/4.7 adaptive thinking and stripping it silently flattens every + # adaptive tier. (Bedrock will reject it for non-adaptive models, which + # is the correct behavior — surface the model error rather than swallow + # the knob.) + assert result.get("output_config") == {"effort": "low"} # Supported fields pass through. assert result["max_tokens"] == 4096 assert result["temperature"] == 0.5 @@ -882,7 +890,10 @@ async def test_promote_message_start_cache_when_message_stop_omits_cache_fields( "delta": {"stop_reason": "end_turn", "stop_sequence": None}, "usage": {"input_tokens": 10, "output_tokens": 181}, } - yield {"type": "message_stop", "usage": {"input_tokens": 10, "output_tokens": 181}} + yield { + "type": "message_stop", + "usage": {"input_tokens": 10, "output_tokens": 181}, + } merged: list[dict] = [] async for chunk in cfg._promote_message_stop_usage(_stream()): @@ -936,7 +947,11 @@ async def test_unified_bedrock_messages_cache_on_start_only_never_negative_cost( }, }, } - yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}} + yield { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + } yield { "type": "content_block_delta", "index": 0, @@ -948,7 +963,10 @@ async def test_unified_bedrock_messages_cache_on_start_only_never_negative_cost( "delta": {"stop_reason": "end_turn", "stop_sequence": None}, "usage": {"output_tokens": 181, "input_tokens": 10}, } - yield {"type": "message_stop", "usage": {"input_tokens": 10, "output_tokens": 181}} + yield { + "type": "message_stop", + "usage": {"input_tokens": 10, "output_tokens": 181}, + } logging_obj = LiteLLMLoggingObj( model="bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0", diff --git a/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py b/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py index d0be476d72..0a5ba12f18 100644 --- a/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py @@ -498,11 +498,14 @@ def test_vertex_ai_partner_models_anthropic_remove_prompt_caching_scope_beta_hea ), "Header should be removed if no supported values remain" -def test_vertex_ai_anthropic_output_config_effort_only_dropped(): +def test_vertex_ai_anthropic_output_config_effort_only_forwarded(): """ - ``output_config`` containing only ``effort`` (an Anthropic-only key Vertex - rejects with "Extra inputs are not permitted") is dropped entirely so the - request body has no empty dict. + Vertex AI Claude 4.6 / 4.7 accept ``output_config.effort`` on direct + ``:rawPredict`` (verified end-to-end against ``us-east5`` for + ``claude-opus-4-6`` and ``global`` for ``claude-opus-4-7``). The earlier + strip silently flattened every adaptive tier on Vertex, so ``low`` / + ``medium`` / ``high`` / ``xhigh`` / ``max`` all produced identical + thinking with no tier differentiation. """ config = VertexAIAnthropicConfig() @@ -515,16 +518,14 @@ def test_vertex_ai_anthropic_output_config_effort_only_dropped(): } result = config.transform_request( - model="claude-3-5-sonnet-20241022", + model="claude-opus-4-6", messages=messages, optional_params=optional_params, litellm_params={}, headers=headers, ) - assert ( - "output_config" not in result - ), "output_config containing only effort must be dropped" + assert result["output_config"] == {"effort": "high"} assert result["max_tokens"] == 1024 assert "messages" in result @@ -566,13 +567,15 @@ def test_vertex_ai_anthropic_output_config_format_passes_through(): assert result["output_config"] == output_config -def test_vertex_ai_anthropic_output_config_format_plus_effort_strips_only_effort(): +def test_vertex_ai_anthropic_output_config_format_plus_effort_preserved(): """ - Greptile P1 on PR #23396: when ``output_config`` contains BOTH ``format`` - and ``effort``, the prior conditional-passthrough logic forwarded the - full dict including the unsupported ``effort`` key, reproducing the - 400 error the fix was meant to resolve. Only ``effort`` (and any future - Vertex-unsupported keys) should be filtered; ``format`` must survive. + Vertex AI Claude 4.6 / 4.7 accept ``output_config.effort`` on direct + ``:rawPredict`` (verified end-to-end against ``us-east5`` for + ``claude-opus-4-6`` and ``global`` for ``claude-opus-4-7``). Since the + strip was unjustified, ``effort`` must now ride along with ``format``. + + We use a Claude 4.6 model id here because ``_apply_output_config`` only + accepts ``effort`` on adaptive-thinking 4.6/4.7 model ids. """ config = VertexAIAnthropicConfig() messages = [{"role": "user", "content": "Return a person object."}] @@ -591,7 +594,7 @@ def test_vertex_ai_anthropic_output_config_format_plus_effort_strips_only_effort optional_params = {"max_tokens": 1024, "output_config": output_config} result = config.transform_request( - model="claude-3-5-sonnet-20241022", + model="claude-opus-4-6", messages=messages, optional_params=optional_params, litellm_params={}, @@ -599,9 +602,7 @@ def test_vertex_ai_anthropic_output_config_format_plus_effort_strips_only_effort ) assert "output_config" in result - assert ( - "effort" not in result["output_config"] - ), "effort must be stripped — Vertex returns 400 on unknown keys" + assert result["output_config"]["effort"] == "high" assert result["output_config"]["format"] == output_config["format"] @@ -623,14 +624,14 @@ def test_vertex_ai_anthropic_output_config_non_dict_dropped(): assert "output_config" not in result -def test_vertex_ai_anthropic_output_format_preserved_output_config_effort_dropped(): +def test_vertex_ai_anthropic_output_format_and_output_config_effort_preserved(): """ - When the request carries both ``output_format`` (top-level structured - outputs) AND an ``output_config`` whose only useful key for Vertex is - ``effort``: ``output_format`` must be forwarded (Vertex accepts it), - while ``output_config`` is dropped because Vertex returns 400 on - ``effort``. This replaces the old "drop both" behavior, which was the - silent strip the bug report flagged. + Vertex AI Claude 4.6 / 4.7 accept ``output_config.effort`` on direct + ``:rawPredict`` (verified end-to-end). When both ``output_format`` and + ``output_config: {effort}`` are present, both must be forwarded — the + earlier ``effort`` strip caused silent loss of the requested adaptive + thinking tier on Vertex routes (``low``/``medium``/``high``/``xhigh``/``max`` + all collapsed to identical adaptive thinking with no tier differentiation). """ config = VertexAIAnthropicConfig() messages = [{"role": "user", "content": "Extract structured data"}] @@ -653,7 +654,7 @@ def test_vertex_ai_anthropic_output_format_preserved_output_config_effort_droppe } test_data = { - "model": "claude-3-5-sonnet-20241022", + "model": "claude-opus-4-6", "messages": messages, "max_tokens": 2048, "output_format": output_format, @@ -671,7 +672,7 @@ def test_vertex_ai_anthropic_output_format_preserved_output_config_effort_droppe try: result = config.transform_request( - model="claude-3-5-sonnet-20241022", + model="claude-opus-4-6", messages=messages, optional_params=optional_params, litellm_params={}, @@ -680,9 +681,8 @@ def test_vertex_ai_anthropic_output_format_preserved_output_config_effort_droppe # output_format flows through unchanged — Vertex AI Claude accepts it. assert result["output_format"] == output_format - # output_config containing only ``effort`` is dropped to avoid the - # 400 "Extra inputs are not permitted" the silent strip used to mask. - assert "output_config" not in result + # output_config.effort now flows through (Vertex accepts it on 4.6/4.7). + assert result["output_config"] == {"effort": "high"} assert result["max_tokens"] == 2048 assert "model" not in result, "model is still stripped (Vertex routes by URL)" finally: @@ -702,10 +702,10 @@ def test_sanitize_vertex_anthropic_output_params_unit(): sanitize_vertex_anthropic_output_params(data) assert data == {"max_tokens": 8} - # Effort-only → dropped entirely. + # Effort-only → preserved (Vertex 4.6/4.7 accept it on rawPredict). data = {"output_config": {"effort": "high"}} sanitize_vertex_anthropic_output_params(data) - assert "output_config" not in data + assert data["output_config"] == {"effort": "high"} # Format-only → preserved unchanged. fmt = {"format": {"type": "json_schema", "schema": {"type": "object"}}} @@ -713,10 +713,10 @@ def test_sanitize_vertex_anthropic_output_params_unit(): sanitize_vertex_anthropic_output_params(data) assert data["output_config"] == fmt - # Mixed → effort filtered, format kept. + # Mixed → both effort and format kept (no current Vertex-unsupported keys). data = {"output_config": {"format": fmt["format"], "effort": "high"}} sanitize_vertex_anthropic_output_params(data) - assert data["output_config"] == fmt + assert data["output_config"] == {"format": fmt["format"], "effort": "high"} # Non-dict → dropped defensively. data = {"output_config": "garbage"}