diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 02625605f37..4c981dd36b3 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -673,7 +673,9 @@ class LiteLLMAnthropicMessagesAdapter: Returns: Dict with either 'thinking' or 'reasoning_effort' key """ - if LiteLLMAnthropicMessagesAdapter.is_anthropic_claude_model(model): + if LiteLLMAnthropicMessagesAdapter.is_anthropic_claude_model( + model + ) or LiteLLMAnthropicMessagesAdapter.is_bedrock_arn_model(model): return {"thinking": thinking} else: reasoning_effort = LiteLLMAnthropicMessagesAdapter.translate_anthropic_thinking_to_reasoning_effort( @@ -965,7 +967,7 @@ class LiteLLMAnthropicMessagesAdapter: return model = new_kwargs.get("model", "") - if self.is_anthropic_claude_model(model): + if self.is_anthropic_claude_model(model) or self.is_bedrock_arn_model(model): new_kwargs["thinking"] = thinking # type: ignore return diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index fd2e02da8ea..9f18b669124 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -2001,16 +2001,11 @@ class BaseLLMHTTPHandler: custom_llm_provider=custom_llm_provider, ) - # Apply additional_drop_params for nested field removal - additional_drop_params = litellm_params.get("additional_drop_params") + additional_drop_params: list[str] = litellm_params.get("additional_drop_params") or [] if additional_drop_params: - from litellm.litellm_core_utils.dot_notation_indexing import ( - delete_nested_value, - is_nested_path, - ) + from litellm.litellm_core_utils.dot_notation_indexing import delete_nested_value - nested_paths = [p for p in additional_drop_params if is_nested_path(p)] - for path in nested_paths: + for path in additional_drop_params: anthropic_messages_optional_request_params = delete_nested_value( anthropic_messages_optional_request_params, path ) diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index 45820b9833f..f9c55db72b5 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -1508,6 +1508,44 @@ def test_cache_control_fix_does_not_broaden_claude_detection(): ) +def test_thinking_preserved_for_bedrock_arn_inference_profile(): + """ + Regression: opaque Bedrock Application Inference Profile ARNs hide the underlying + Claude model name, so on /v1/messages a `thinking` param must be preserved as + `thinking` (not rewritten to `reasoning_effort`). Otherwise `additional_drop_params: + ["thinking"]` runs after the rewrite and has nothing left to drop, and the Bedrock + Converse body re-expands reasoning_effort back into additionalModelRequestFields.thinking. + """ + adapter = LiteLLMAnthropicMessagesAdapter() + thinking = {"type": "enabled", "budget_tokens": 1024} + + new_kwargs = {"model": CACHE_CONTROL_BEDROCK_ARN_MODEL} + adapter._translate_thinking_to_openai(cast(Any, {"thinking": thinking}), cast(Any, new_kwargs)) + + assert new_kwargs["thinking"] == thinking + assert "reasoning_effort" not in new_kwargs + + assert LiteLLMAnthropicMessagesAdapter.translate_thinking_for_model(thinking, CACHE_CONTROL_BEDROCK_ARN_MODEL) == { + "thinking": thinking + } + + +def test_thinking_still_translated_to_reasoning_effort_for_non_claude_model(): + """ + The bedrock-ARN gate must not broaden to every model: a genuine non-Claude model + still has `thinking` converted to `reasoning_effort` so it does not hit an + UnsupportedParamsError downstream. + """ + adapter = LiteLLMAnthropicMessagesAdapter() + thinking = {"type": "enabled", "budget_tokens": 1024} + + new_kwargs = {"model": CACHE_CONTROL_NON_ANTHROPIC_MODEL} + adapter._translate_thinking_to_openai(cast(Any, {"thinking": thinking}), cast(Any, new_kwargs)) + + assert "thinking" not in new_kwargs + assert new_kwargs["reasoning_effort"] == "low" + + def test_cache_control_preserved_in_image_content_for_claude(): """Cache control should be preserved in image content for Claude models.""" anthropic_messages = [ diff --git a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py index d57d115fa4e..8c934f9c21e 100644 --- a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py @@ -645,6 +645,85 @@ async def test_async_anthropic_messages_handler_header_priority(): assert captured_headers["X-Provider-Only"] == "keep-this-too" +@pytest.mark.asyncio +async def test_async_anthropic_messages_handler_drops_top_level_and_nested_params(): + """ + Regression for LIT-3988 / GitHub #25931: on the /v1/messages path, + additional_drop_params must strip plain top-level keys (e.g. `thinking`, + `context_management`) before the provider transform runs, not only nested + dotted paths. Bedrock rejects these fields, so leaving them in produces a 400. + """ + handler = BaseLLMHTTPHandler() + + mock_config = Mock() + mock_config.validate_anthropic_messages_environment = Mock( + return_value=({"x-api-key": "test-key"}, "https://api.anthropic.com") + ) + + captured = {} + + def capture_transform(*args, **kwargs): + captured["optional_params"] = kwargs["anthropic_messages_optional_request_params"] + return {"model": "claude-opus-4-7", "messages": []} + + mock_config.transform_anthropic_messages_request = capture_transform + + mock_client = AsyncMock() + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "id": "msg_123", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "Hello!"}], + "model": "claude-opus-4-7", + "stop_reason": "end_turn", + } + mock_client.post = AsyncMock(return_value=mock_response) + + mock_logging_obj = Mock() + mock_logging_obj.update_from_kwargs = Mock() + mock_logging_obj.model_call_details = {} + mock_logging_obj.stream = False + + optional_params = { + "max_tokens": 1024, + "thinking": {"type": "enabled", "budget_tokens": 2048}, + "context_management": {"edits": [{"type": "clear_thinking_20251015"}]}, + "metadata": {"user_id": "u1", "drop_me": "x"}, + } + + with patch( + "litellm.litellm_core_utils.get_provider_specific_headers.ProviderSpecificHeaderUtils.get_provider_specific_headers" + ) as mock_provider_headers: + mock_provider_headers.return_value = None + try: + await handler.async_anthropic_messages_handler( + model="claude-opus-4-7", + messages=[{"role": "user", "content": "Hello"}], + anthropic_messages_provider_config=mock_config, + anthropic_messages_optional_request_params=optional_params, + custom_llm_provider="bedrock", + litellm_params=GenericLiteLLMParams( + additional_drop_params=[ + "thinking", + "context_management", + "metadata.drop_me", + ] + ), + logging_obj=mock_logging_obj, + client=mock_client, + ) + except Exception: + pass # drop runs before the mocked sign_request; the capture is what we assert on + + transformed = captured["optional_params"] + assert "thinking" not in transformed + assert "context_management" not in transformed + assert transformed["max_tokens"] == 1024 + assert transformed["metadata"] == {"user_id": "u1"} + + def test_google_genai_streaming_hidden_params_model_info_and_router_fallback(): logging_obj = Mock() logging_obj.get_router_model_id = Mock(return_value="router-model-id")