diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index cd5bb73171..31b9bad339 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -1534,12 +1534,16 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): f"Invalid effort value: {effort}. Must be one of: " f"'high', 'medium', 'low', 'xhigh', 'max'" ) - # ``max`` is Claude Opus 4.6 only (not Sonnet 4.6, not Opus 4.5/4.7). - # Keep this hardcoded so the error message is specific and stable. - if effort == "max" and not self._is_opus_4_6_model(model): + # ``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`` + # in the model map (same pattern as ``xhigh`` below). + if effort == "max" and not ( + self._is_opus_4_6_model(model) + or self._is_opus_4_7_model(model) + or self._supports_effort_level(model, "max") + ): raise ValueError( - f"effort='max' is only supported by Claude Opus 4.6. " - f"Got model: {model}" + f"effort='max' is not supported by this model. Got model: {model}" ) # ``xhigh`` is data-driven via ``supports_xhigh_reasoning_effort`` so # enabling it for a new model is a pure model-map change. diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py index d16f5afb45..829ce14d69 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py @@ -235,7 +235,7 @@ class LiteLLMMessagesToCompletionTransformationHandler: ): from litellm.types.utils import CallTypes - setattr(value, "call_type", CallTypes.completion.value) + setattr(value, "call_type", CallTypes.anthropic_messages.value) setattr( value, "stream_options", completion_kwargs.get("stream_options") ) diff --git a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/handler.py b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/handler.py index 5be16dcbf1..f8c827ab05 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/handler.py @@ -105,7 +105,7 @@ def _build_responses_kwargs( # Reclassify as acompletion so the success handler doesn't try to # validate the Responses API event as an AnthropicResponse. # (Mirrors the pattern used in LiteLLMMessagesToCompletionTransformationHandler.) - setattr(value, "call_type", CallTypes.acompletion.value) + setattr(value, "call_type", CallTypes.anthropic_messages.value) responses_kwargs[key] = value elif key not in excluded and key not in responses_kwargs and value is not None: responses_kwargs[key] = value diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 59b6729dcc..98811e8a01 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -9262,6 +9262,7 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, "provider_specific_entry": { "us": 1.1, @@ -9296,6 +9297,7 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, "provider_specific_entry": { "us": 1.1, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 6c4397a59c..a6b66cdb75 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -9276,6 +9276,7 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, "provider_specific_entry": { "us": 1.1, @@ -9310,6 +9311,7 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, "provider_specific_entry": { "us": 1.1, 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 a7f5f92ab0..e1fe4befb5 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 @@ -1654,7 +1654,7 @@ def test_max_effort_rejected_for_opus_45(): messages = [{"role": "user", "content": "Test"}] with pytest.raises( - ValueError, match="effort='max' is only supported by Claude Opus 4.6" + ValueError, match="effort='max' is not supported by this model" ): optional_params = {"output_config": {"effort": "max"}} config.transform_request( @@ -2213,12 +2213,12 @@ 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 (only Opus 4.6 supports max).""" + """Test that effort='max' is rejected for Sonnet 4.6 (Opus-only effort level).""" config = AnthropicConfig() messages = [{"role": "user", "content": "Test"}] with pytest.raises( - ValueError, match="effort='max' is only supported by Claude Opus 4.6" + ValueError, match="effort='max' is not supported by this model" ): config.transform_request( model="claude-sonnet-4-6-20260219", @@ -2245,6 +2245,22 @@ def test_max_effort_accepted_for_opus_46(): assert result["output_config"]["effort"] == "max" +def test_max_effort_accepted_for_opus_47(): + """Test that effort='max' works for Opus 4.7.""" + config = AnthropicConfig() + messages = [{"role": "user", "content": "Test"}] + + result = config.transform_request( + model="claude-opus-4-7", + messages=messages, + optional_params={"output_config": {"effort": "max"}}, + litellm_params={}, + headers={}, + ) + + assert result["output_config"]["effort"] == "max" + + def test_effort_beta_header_not_injected_for_46_models(): """ Test that is_effort_used returns False for Claude 4.6 models. diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index ac009df67b..1f232e410a 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -1749,6 +1749,95 @@ async def test_add_litellm_metadata_from_request_headers(): litellm.callbacks = original_callbacks +@pytest.mark.asyncio +async def test_anthropic_messages_standard_logging_object_matches_fixture(): + """ + Regression: /v1/messages calls routed to non-Anthropic providers should keep + call_type=anthropic_messages in standard logging payloads. + """ + litellm._turn_on_debug() + test_logger = TestCustomLogger() + original_callbacks = litellm.callbacks + litellm.callbacks = [test_logger] + + try: + data = { + "model": "gemini/gemini-2.5-flash", + "messages": [{"role": "user", "content": "Hi."}], + "stream": False, + "mock_response": "Hello! How can I help you today?", + "api_key": "fake-key", + "max_tokens": 4096, + } + + mock_request = MagicMock(spec=Request) + mock_request.headers = {"user-agent": "PostmanRuntime/7.53.0"} + mock_request.url.path = "/v1/messages" + mock_request.url = MagicMock() + mock_request.url.__str__.return_value = "http://localhost/v1/messages" + mock_request.method = "POST" + mock_request.query_params = {} + mock_request.client = MagicMock() + mock_request.client.host = "127.0.0.1" + + mock_fastapi_response = MagicMock(spec=Response) + mock_user_api_key_dict = UserAPIKeyAuth( + api_key="test-key", user_id="default_user_id" + ) + + mock_proxy_logging_obj = MagicMock(spec=ProxyLogging) + + async def mock_during_call_hook(*args, **kwargs): + return None + + async def mock_pre_call_hook(*args, **kwargs): + return data + + async def mock_post_call_success_hook(*args, **kwargs): + return kwargs.get("response", args[2] if len(args) > 2 else None) + + mock_proxy_logging_obj.during_call_hook = mock_during_call_hook + mock_proxy_logging_obj.pre_call_hook = mock_pre_call_hook + mock_proxy_logging_obj.post_call_success_hook = mock_post_call_success_hook + + processor = ProxyBaseLLMRequestProcessing(data=data) + await processor.base_process_llm_request( + request=mock_request, + fastapi_response=mock_fastapi_response, + user_api_key_dict=mock_user_api_key_dict, + route_type="anthropic_messages", + proxy_logging_obj=mock_proxy_logging_obj, + general_settings={}, + proxy_config=MagicMock(), + select_data_generator=None, + llm_router=None, + model="gemini/gemini-2.5-flash", + is_streaming_request=False, + ) + + await asyncio.sleep(3) + + assert test_logger.standard_logging_object is not None + actual = test_logger.standard_logging_object + + expected = { + "call_type": "anthropic_messages", + "status": "success", + "model": "gemini/gemini-2.5-flash", + } + + # Compare only stable fields from the saved proxy log snapshot. + actual_projection = { + "call_type": actual.get("call_type"), + "status": actual.get("status"), + "model": actual.get("model"), + } + assert actual_projection == expected + assert actual.get("call_type") == "anthropic_messages" + finally: + litellm.callbacks = original_callbacks + + def test_add_litellm_metadata_from_request_headers_x_litellm_trace_id_sets_chain_id(): """x-litellm-trace-id sets both metadata and top-level litellm_session_id/litellm_trace_id for call chaining.""" headers = {"x-litellm-trace-id": "foo"}