diff --git a/litellm/llms/anthropic/messages/adapters/transformation.py b/litellm/llms/anthropic/messages/adapters/transformation.py index c81bb995a24..00946a8b946 100644 --- a/litellm/llms/anthropic/messages/adapters/transformation.py +++ b/litellm/llms/anthropic/messages/adapters/transformation.py @@ -20,6 +20,7 @@ from litellm.llms.anthropic.messages.utils import ( maps_bare_output_effort, prompt_cache_key_from_user_id, reasoning_off_level, + thinking_off_switch, ) from litellm.responses.utils import auto_reasoning_summary @@ -1270,15 +1271,16 @@ class LiteLLMAnthropicMessagesAdapter: reasoning_effort, cast(dict[str, object], thinking), model, custom_llm_provider ) ) + off_switch: Final = thinking_off_switch(anthropic_message_request) target_effort: Final = ( reasoning_off_level( model, custom_llm_provider, deployment_model_info=deployment_model_info, drop_params=drop_params, - between_tools=is_between_tools, + between_tools=off_switch == "between_tools", ) - if mirrors_responses_bridge and effort_sent == REASONING_EFFORT_OFF + if mirrors_responses_bridge and off_switch is not None and effort_sent == REASONING_EFFORT_OFF else effort_sent ) if target_effort is None: diff --git a/litellm/llms/anthropic/messages/responses_adapters/handler.py b/litellm/llms/anthropic/messages/responses_adapters/handler.py index 6cc5d2959da..4baaf3cc2b0 100644 --- a/litellm/llms/anthropic/messages/responses_adapters/handler.py +++ b/litellm/llms/anthropic/messages/responses_adapters/handler.py @@ -53,12 +53,14 @@ from ..convertible_blocks import reject_malformed_tool_result_text from ..safeguards import with_safeguard_results from ..utils import ( REASONING_EFFORT_OFF, + ThinkingOffSwitch, deployment_reasoning_efforts, litellm_logging_obj_from_kwargs, local_model_name, maps_bare_output_effort, normalize_reasoning_effort_value, reasoning_off_level, + thinking_off_switch, ) from .streaming_iterator import AnthropicResponsesStreamWrapper from .transformation import LiteLLMAnthropicToResponsesAPIAdapter @@ -270,19 +272,19 @@ def _reasoning_for_target( custom_llm_provider: str | None, deployment_model_info: object, drop_params: bool, - between_tools: bool, + off_switch: ThinkingOffSwitch | None, ) -> object: reasoning_params: Final = as_str_mapping(reasoning) effort: Final = reasoning_params.get("effort") if reasoning_params is not None else None if reasoning_params is None or not isinstance(effort, str): return reasoning - if effort == REASONING_EFFORT_OFF: + if effort == REASONING_EFFORT_OFF and off_switch is not None: off_level: Final = reasoning_off_level( model, custom_llm_provider, deployment_model_info=deployment_model_info, drop_params=drop_params, - between_tools=between_tools, + between_tools=off_switch == "between_tools", ) if off_level is None or _responses_api_refuses_reasoning_effort(model, custom_llm_provider): return None @@ -471,7 +473,7 @@ def _build_responses_kwargs( custom_llm_provider=provider, deployment_model_info=forwarded_kwargs.get("model_info"), drop_params=drop_params, - between_tools=isinstance(thinking, dict) and thinking.get("type") == "between_tools", + off_switch=thinking_off_switch(anthropic_request), ) if _maps_reasoning(thinking, model, provider, forwarded_kwargs) else None diff --git a/litellm/llms/anthropic/messages/utils.py b/litellm/llms/anthropic/messages/utils.py index 1bc1430731b..96aa077abea 100644 --- a/litellm/llms/anthropic/messages/utils.py +++ b/litellm/llms/anthropic/messages/utils.py @@ -3,7 +3,7 @@ import os from collections.abc import Iterator, Mapping, Sequence from itertools import chain from types import MappingProxyType -from typing import TYPE_CHECKING, Final, TypeVar +from typing import TYPE_CHECKING, Final, Literal, TypeVar from pydantic import ConfigDict, TypeAdapter, ValidationError from typing_extensions import ReadOnly @@ -31,6 +31,9 @@ _EFFORT_DEGRADATION_CHAIN: Final[Mapping[str, tuple[str, ...]]] = MappingProxyTy REASONING_EFFORT_OFF: Final = "none" RESPONSES_API_PROVIDERS: Final = frozenset({"openai"}) _LOWEST_REASONING_TIERS: Final = ("minimal", "low") +_UNCONFIRMED_MINIMAL_LOWEST_TIERS: Final = ("low",) + +ThinkingOffSwitch = Literal["disabled", "between_tools"] _JsonValue: Final = TypeVar("_JsonValue") @@ -232,6 +235,16 @@ def is_between_tool_calls(request: Mapping[str, object]) -> bool: return any(block.get("type") == "tool_result" for block in blocks) +def thinking_off_switch(request: Mapping[str, object]) -> ThinkingOffSwitch | None: + thinking: Final = as_str_mapping(request.get("thinking")) + thinking_type: Final = thinking.get("type") if thinking is not None else None + if thinking_type == "disabled": + return "disabled" + if thinking_type == "between_tools" and not is_between_tool_calls(request): + return "between_tools" + return None + + def _mapped_model_info(model: str, custom_llm_provider: str | None) -> ModelInfo | None: from litellm.utils import get_model_info @@ -282,10 +295,19 @@ def _declares(model_info: Mapping[str, object] | None, capability: str) -> bool: return model_info is not None and model_info.get(capability) is True -def _lowest_tier_instead_of_off(levels: tuple[str, ...] | None) -> str | None: +def _confirms_minimal(model_info: Mapping[str, object] | None) -> bool: + from litellm.litellm_core_utils.reasoning_effort_capability import declared_reasoning_efforts + + return model_info is not None and ( + declared_reasoning_efforts(model_info) is not None or _declares(model_info, "supports_minimal_reasoning_effort") + ) + + +def _lowest_tier_instead_of_off(levels: tuple[str, ...] | None, model_info: Mapping[str, object] | None) -> str | None: if not levels or REASONING_EFFORT_OFF in levels: return None - return next((tier for tier in _LOWEST_REASONING_TIERS if tier in levels), None) + tiers: Final = _LOWEST_REASONING_TIERS if _confirms_minimal(model_info) else _UNCONFIRMED_MINIMAL_LOWEST_TIERS + return next((tier for tier in tiers if tier in levels), None) def reasoning_off_level( @@ -300,7 +322,7 @@ def reasoning_off_level( levels: Final = _resolved_reasoning_efforts(model_info, model, custom_llm_provider) if levels == () or (between_tools and levels is None): return None - lowest_tier: Final = _lowest_tier_instead_of_off(levels) + lowest_tier: Final = _lowest_tier_instead_of_off(levels, model_info) if lowest_tier is not None: return lowest_tier if ( diff --git a/tests/unit/llms/anthropic/messages/adapters/test_handler_reasoning_effort_normalization.py b/tests/unit/llms/anthropic/messages/adapters/test_handler_reasoning_effort_normalization.py index 45bd0b96c8e..09b2de0642a 100644 --- a/tests/unit/llms/anthropic/messages/adapters/test_handler_reasoning_effort_normalization.py +++ b/tests/unit/llms/anthropic/messages/adapters/test_handler_reasoning_effort_normalization.py @@ -932,3 +932,107 @@ async def test_a_non_object_thinking_still_reaches_the_responses_bridge( ) assert sent["model"] == "gpt-5-mini" + + +_CLIENT_EFFORT_NONE: Final = [ + {"thinking": {"type": "adaptive"}, "output_config": {"effort": "none"}}, + {"output_config": {"effort": "none"}}, +] + + +class TestAClientEffortNoneFollowsTheUnsupportedValueRule: + @pytest.fixture(autouse=True) + def _httpx_transport(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + + @pytest.mark.asyncio + @pytest.mark.parametrize("messages_params", _CLIENT_EFFORT_NONE, ids=["adaptive", "bare"]) + @pytest.mark.parametrize( + "uses_chat_route, reasoning_key", [(True, "reasoning_effort"), (False, "reasoning")], ids=["chat-route", "responses-bridge"] + ) + async def test_drop_params_drops_it_on_a_model_that_refuses_none( + self, + respx_mock: respx.MockRouter, + local_model_cost_map: None, + monkeypatch: pytest.MonkeyPatch, + messages_params: Mapping[str, object], + uses_chat_route: bool, + reasoning_key: str, + ) -> None: + monkeypatch.setattr(litellm, "use_chat_completions_url_for_anthropic_messages", uses_chat_route) + + sent: Final = await _body_sent_upstream( + respx_mock, + {"model": "openai/gpt-5-mini", "api_key": "fake-key", "drop_params": True}, + {}, + rf"https://api\.openai\.com/v1/{'chat/completions' if uses_chat_route else 'responses'}", + _CHAT_COMPLETION if uses_chat_route else _RESPONSE, + **messages_params, + ) + + assert reasoning_key not in sent + + @pytest.mark.asyncio + @pytest.mark.parametrize("messages_params", _CLIENT_EFFORT_NONE, ids=["adaptive", "bare"]) + @pytest.mark.parametrize("uses_chat_route", [True, False], ids=["chat-route", "responses-bridge"]) + async def test_without_drop_params_it_answers_400( + self, + respx_mock: respx.MockRouter, + local_model_cost_map: None, + monkeypatch: pytest.MonkeyPatch, + messages_params: Mapping[str, object], + uses_chat_route: bool, + ) -> None: + monkeypatch.setattr(litellm, "use_chat_completions_url_for_anthropic_messages", uses_chat_route) + + with pytest.raises(litellm.UnsupportedParamsError, match="effort=none is not supported for gpt-5-mini"): + await _body_sent_upstream( + respx_mock, + {"model": "openai/gpt-5-mini", "api_key": "fake-key"}, + {}, + rf"https://api\.openai\.com/v1/{'chat/completions' if uses_chat_route else 'responses'}", + _CHAT_COMPLETION if uses_chat_route else _RESPONSE, + **messages_params, + ) + + +class TestTheOffSwitchUsesMinimalOnlyWhereTheDeploymentConfirmsIt: + @pytest.fixture(autouse=True) + def _httpx_transport(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "model_info, expected", + [ + ({"supports_none_reasoning_effort": False}, "low"), + ({"reasoning_effort_levels": ["minimal", "low", "medium", "high"]}, "minimal"), + ], + ids=["none-flag-only", "declared-levels"], + ) + @pytest.mark.parametrize("thinking", [{"type": "disabled"}, {"type": "between_tools"}], ids=["disabled", "between_tools"]) + @pytest.mark.parametrize("uses_chat_route", [True, False], ids=["chat-route", "responses-bridge"]) + @pytest.mark.parametrize("drop_params", [False, True], ids=["no-drop_params", "drop_params"]) + async def test_an_o3_deployment_gets_the_lowest_level_it_confirms( + self, + respx_mock: respx.MockRouter, + local_model_cost_map: None, + monkeypatch: pytest.MonkeyPatch, + model_info: Mapping[str, object], + expected: str, + thinking: Mapping[str, object], + uses_chat_route: bool, + drop_params: bool, + ) -> None: + monkeypatch.setattr(litellm, "use_chat_completions_url_for_anthropic_messages", uses_chat_route) + + sent: Final = await _body_sent_upstream( + respx_mock, + {"model": "openai/o3", "api_key": "fake-key", "drop_params": drop_params}, + model_info, + rf"https://api\.openai\.com/v1/{'chat/completions' if uses_chat_route else 'responses'}", + _CHAT_COMPLETION if uses_chat_route else _RESPONSE, + thinking=thinking, + ) + + assert (sent["reasoning_effort"] if uses_chat_route else sent["reasoning"]["effort"]) == expected