diff --git a/litellm/llms/bedrock/responses/transformation.py b/litellm/llms/bedrock/responses/transformation.py index 7cdd7b08f99..bf936399e83 100644 --- a/litellm/llms/bedrock/responses/transformation.py +++ b/litellm/llms/bedrock/responses/transformation.py @@ -34,7 +34,7 @@ inference profile, so the model is named ``us.openai.gpt-5.6-sol`` or """ import asyncio -from collections.abc import Awaitable, Callable, Mapping +from collections.abc import Awaitable, Callable, Collection, Mapping from types import MappingProxyType from typing import Final @@ -157,13 +157,13 @@ def inline_remote_image_urls( def _without_disabled_reasoning_effort( - params: Mapping[str, object], model: str, drop_params: bool + params: Mapping[str, object], model: str, drop_params: bool, declared_reasoning_efforts: Collection[str] ) -> dict[str, object]: # mutable-ok: becomes the map_openai_params return value reasoning: Final = params.get("reasoning") effort: Final = reasoning.get("effort") if isinstance(reasoning, Mapping) else None if not isinstance(reasoning, Mapping) or not isinstance(effort, str): return dict(params) - if not bedrock_reasoning_effort_disabled(model=model, effort=effort): + if effort in declared_reasoning_efforts or not bedrock_reasoning_effort_disabled(model=model, effort=effort): return dict(params) if not (drop_params or litellm.drop_params): raise litellm.UnsupportedParamsError( @@ -281,14 +281,16 @@ class BedrockOpenAIResponsesConfig(BaseAWSLLM, OpenAIResponsesAPIConfig): fake_stream=fake_stream, ) - def map_openai_params( + def map_openai_params_for_deployment( self, response_api_optional_params: ResponsesAPIOptionalRequestParams, model: str, drop_params: bool, - ) -> dict: # mutable-ok: signature fixed by the override contract - mapped: Final = super().map_openai_params( - response_api_optional_params=response_api_optional_params, model=model, drop_params=drop_params + *, + declared_reasoning_efforts: Collection[str], + ) -> Mapping[str, object]: + mapped: Final = super().map_openai_params_for_deployment( + response_api_optional_params, model, drop_params, declared_reasoning_efforts=declared_reasoning_efforts ) unsupported: Final = tuple(sorted(BEDROCK_RUNTIME_UNSUPPORTED_RESPONSE_PARAMS & mapped.keys())) if unsupported: @@ -297,7 +299,7 @@ class BedrockOpenAIResponsesConfig(BaseAWSLLM, OpenAIResponsesAPIConfig): unsupported, ) supported: Final[dict[str, object]] = {key: value for key, value in mapped.items() if key not in unsupported} - params: Final = _without_disabled_reasoning_effort(supported, model, drop_params) + params: Final = _without_disabled_reasoning_effort(supported, model, drop_params, declared_reasoning_efforts) tools: Final = params.get("tools") if not isinstance(tools, list): return params diff --git a/litellm/llms/bedrock_mantle/responses/transformation.py b/litellm/llms/bedrock_mantle/responses/transformation.py index 3532cfbd963..31cbbcad39a 100644 --- a/litellm/llms/bedrock_mantle/responses/transformation.py +++ b/litellm/llms/bedrock_mantle/responses/transformation.py @@ -15,7 +15,7 @@ role / access key / profile / web identity), signed via the shared BaseAWSLLM._sign_request after the request body is finalized. """ -from collections.abc import Mapping, Sequence +from collections.abc import Collection, Mapping, Sequence from typing import Final, cast # noqa: TID251 # map_openai_params returns the filtered params as a bare dict import httpx @@ -229,18 +229,23 @@ class BedrockMantleResponsesAPIConfig(BedrockMantleAuthMixin, OpenAIResponsesAPI def _model_map_lookup_name(model: str) -> str: return model.split("/")[-1].removeprefix("openai.") - def map_openai_params( + def map_openai_params_for_deployment( self, response_api_optional_params: ResponsesAPIOptionalRequestParams, model: str, drop_params: bool, - ) -> dict: + *, + declared_reasoning_efforts: Collection[str], + ) -> Mapping[str, object]: params: Final = self._handle_unsupported_reasoning_summary( self._handle_unsupported_service_tier( - super().map_openai_params( - response_api_optional_params=response_api_optional_params, - model=model, - drop_params=drop_params, + dict( + super().map_openai_params_for_deployment( + response_api_optional_params, + model, + drop_params, + declared_reasoning_efforts=declared_reasoning_efforts, + ) ), drop_params=drop_params, ), diff --git a/litellm/utils.py b/litellm/utils.py index 7498bbe89f6..6dd43a45582 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -4974,12 +4974,24 @@ def _declared_reasoning_efforts(model_info: Mapping[str, object] | None) -> tupl return deployment_reasoning_effort_levels(model_info) or () -_DROPPED_REASONING_PARAM_FIXES: Final = MappingProxyType( - { - "reasoning_effort": "If the deployment is a reasoning model, set model_info.base_model to its underlying model.", - "thinking": "If the deployment is a reasoning model, send reasoning_effort instead.", - } -) +_DROPPED_REASONING_PARAMS: Final = ("reasoning_effort", "thinking") + + +def _turns_reasoning_off(value: object) -> bool: + match value: + case "none" | {"effort": "none"} | {"type": "disabled"}: + return True + case _: + return False + + +def _is_mapped_non_reasoning_model(model: str, custom_llm_provider: str | None) -> bool: + info: Final = lookup_model_info_for_registration(model, custom_llm_provider) + return not isinstance(info, ModelInfoLookupMiss) and info.get("supports_reasoning") is not True + + +def _warns_on_dropped_reasoning(value: object, model: str, custom_llm_provider: str | None) -> bool: + return not (_turns_reasoning_off(value) and _is_mapped_non_reasoning_model(model, custom_llm_provider)) def get_optional_params( @@ -5069,26 +5081,31 @@ def get_optional_params( verbose_logger.info("\nLiteLLM completion() model= %s; provider = %s", model, custom_llm_provider) verbose_logger.debug("\nLiteLLM: Params passed to completion() %s", passed_params) verbose_logger.debug("\nLiteLLM: Non-Default params passed to completion() %s", non_default_params) - unsupported_params: Final[Mapping[str, object]] = { - k: non_default_params[k] - for k in non_default_params - if k not in supported_params and k not in PROVIDER_UNVALIDATED_PARAMS and not (k == "n" and n == 1) - } + unsupported_params: Final[MappingProxyType[str, object]] = MappingProxyType( + { + k: non_default_params[k] + for k in non_default_params + if k not in supported_params and k not in PROVIDER_UNVALIDATED_PARAMS and not (k == "n" and n == 1) + } + ) if unsupported_params: if litellm.drop_params is True or (drop_params is not None and drop_params is True): for k in unsupported_params: non_default_params.pop(k, None) - for reasoning_param, fix in _DROPPED_REASONING_PARAM_FIXES.items(): - if reasoning_param in unsupported_params: + for reasoning_param in _DROPPED_REASONING_PARAMS: + if reasoning_param in unsupported_params and _warns_on_dropped_reasoning( + unsupported_params[reasoning_param], base_model or model, custom_llm_provider + ): verbose_logger.warning( - "Dropping %s=%r for model=%r (drop_params=True) because %s does not support %s for it. %s", + "Dropping %s=%r for model=%r (drop_params=True) because %s does not support %s for it. " + "If the deployment is a reasoning model, set model_info.base_model to its underlying " + "model.", reasoning_param, unsupported_params[reasoning_param], model, custom_llm_provider, reasoning_param, - fix, ) else: raise UnsupportedParamsError( diff --git a/tests/unit/llms/bedrock/chat/test_converse_transformation.py b/tests/unit/llms/bedrock/chat/test_converse_transformation.py index f6344c0840d..798bebd4881 100644 --- a/tests/unit/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/unit/llms/bedrock/chat/test_converse_transformation.py @@ -984,7 +984,7 @@ def test_openai_gpt5_converse_rejects_effort_level_disabled_in_model_map( ) with caplog.at_level(logging.WARNING, logger="LiteLLM"): - optional_params = config.map_openai_params( + optional_params: Final = config.map_openai_params( non_default_params={"reasoning_effort": "minimal"}, optional_params={}, model=model, @@ -993,7 +993,7 @@ def test_openai_gpt5_converse_rejects_effort_level_disabled_in_model_map( _, additional_request_params, _, _ = config._prepare_request_params(optional_params, model) assert "reasoning" not in additional_request_params assert "thinking" not in additional_request_params - dropped = [r.getMessage() for r in caplog.records if "Dropping reasoning_effort='minimal'" in r.getMessage()] + dropped: Final = [r.getMessage() for r in caplog.records if "Dropping reasoning_effort='minimal'" in r.getMessage()] assert len(dropped) == 1 and model in dropped[0] diff --git a/tests/unit/llms/bedrock/responses/test_bedrock_openai_responses.py b/tests/unit/llms/bedrock/responses/test_bedrock_openai_responses.py index c0ea47b8008..ffc24c308cd 100644 --- a/tests/unit/llms/bedrock/responses/test_bedrock_openai_responses.py +++ b/tests/unit/llms/bedrock/responses/test_bedrock_openai_responses.py @@ -7,6 +7,7 @@ falls back to the Chat Completions bridge and rides Converse. import json import logging from importlib.resources import files +from typing import Final from unittest.mock import patch import pytest @@ -15,6 +16,7 @@ import litellm from litellm.llms.bedrock.common_utils import bedrock_supports_openai_responses from litellm.llms.bedrock.responses.transformation import BedrockOpenAIResponsesConfig from litellm.responses.file_search.emulated_handler import should_use_emulated_file_search +from litellm.responses.utils import ResponsesAPIRequestUtils from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import LlmProviders from litellm.utils import ProviderConfigManager @@ -341,7 +343,7 @@ class TestDisabledReasoningEffort: self, model: str, local_model_cost_map: None, caplog: pytest.LogCaptureFixture ) -> None: with caplog.at_level(logging.WARNING, logger="LiteLLM"): - params = _cfg().map_openai_params( + params: Final = _cfg().map_openai_params( response_api_optional_params={ "reasoning": {"effort": "minimal", "summary": "auto"}, "max_output_tokens": 64, @@ -350,7 +352,9 @@ class TestDisabledReasoningEffort: drop_params=True, ) assert params == {"reasoning": {"summary": "auto"}, "max_output_tokens": 64} - dropped = [r.getMessage() for r in caplog.records if "Dropping reasoning.effort='minimal'" in r.getMessage()] + dropped: Final = [ + r.getMessage() for r in caplog.records if "Dropping reasoning.effort='minimal'" in r.getMessage() + ] assert len(dropped) == 1 and model in dropped[0] def test_effort_only_reasoning_is_removed_when_dropped(self, local_model_cost_map): @@ -365,6 +369,17 @@ class TestDisabledReasoningEffort: ) assert params == {"reasoning": {"effort": "low"}} + @pytest.mark.parametrize("model", ["us.openai.gpt-5.6-luna", MODEL]) + def test_effort_level_the_deployment_declares_is_forwarded(self, model: str, local_model_cost_map: None) -> None: + params: Final = ResponsesAPIRequestUtils.get_optional_params_responses_api( + model=model, + responses_api_provider_config=_cfg(), + response_api_optional_params={"reasoning": {"effort": "minimal"}}, + drop_params=False, + declared_reasoning_efforts=("minimal", "high"), + ) + assert params["reasoning"] == {"effort": "minimal"} + def _never_fetch(url: str) -> str: raise AssertionError(f"unexpected sync fetch of {url}") diff --git a/tests/unit/llms/bedrock_mantle/test_bedrock_mantle_responses_transformation.py b/tests/unit/llms/bedrock_mantle/test_bedrock_mantle_responses_transformation.py index a2289167ad4..3657a0965b2 100644 --- a/tests/unit/llms/bedrock_mantle/test_bedrock_mantle_responses_transformation.py +++ b/tests/unit/llms/bedrock_mantle/test_bedrock_mantle_responses_transformation.py @@ -9,6 +9,7 @@ gate, the URL construction for both paths, and the shared Bearer auth. import copy import logging +from typing import Final import pytest from botocore.exceptions import ( @@ -21,6 +22,7 @@ import litellm from litellm.llms.bedrock_mantle.responses.transformation import ( BedrockMantleResponsesAPIConfig, ) +from litellm.responses.utils import ResponsesAPIRequestUtils from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import LlmProviders @@ -399,6 +401,16 @@ class TestBedrockMantleSamplingParams: assert ("top_p" in mapped) is survives assert ("temperature" in mapped) is survives + def test_effort_level_the_deployment_declares_is_forwarded(self, local_cost_map) -> None: + params: Final = ResponsesAPIRequestUtils.get_optional_params_responses_api( + model="openai.gpt-5.6-luna", + responses_api_provider_config=BedrockMantleResponsesAPIConfig(), + response_api_optional_params={"reasoning": {"effort": "minimal"}}, + drop_params=False, + declared_reasoning_efforts=("minimal", "high"), + ) + assert params["reasoning"] == {"effort": "minimal"} + def test_top_p_without_drop_params_raises_only_while_reasoning_is_active(self, local_cost_map): with pytest.raises(litellm.UnsupportedParamsError): BedrockMantleResponsesAPIConfig().map_openai_params( diff --git a/tests/unit/test_utils_get_optional_params.py b/tests/unit/test_utils_get_optional_params.py index 5cf59136b86..8b0610d0d50 100644 --- a/tests/unit/test_utils_get_optional_params.py +++ b/tests/unit/test_utils_get_optional_params.py @@ -2129,4 +2129,41 @@ def test_dropping_an_unsupported_thinking_logs_one_warning( record.getMessage() for record in caplog.records if "Dropping thinking" in record.getMessage() ] assert len(drop_warnings) == 1 - assert all(fragment in drop_warnings[0] for fragment in ("'disabled'", "terra-ptu", "send reasoning_effort")) + assert all(fragment in drop_warnings[0] for fragment in ("'disabled'", "terra-ptu", "model_info.base_model")) + + +@pytest.mark.parametrize( + ("reasoning_kwargs", "warns"), + [ + ({"reasoning_effort": "none"}, False), + ({"thinking": {"type": "disabled"}}, False), + ({"reasoning_effort": "high"}, True), + ({"thinking": {"type": "enabled", "budget_tokens": 1024}}, True), + ], +) +def test_reasoning_drop_warns_unless_a_mapped_non_reasoning_model_was_asked_for_none( + local_model_cost_map: None, + caplog: pytest.LogCaptureFixture, + reasoning_kwargs: dict[str, object], + warns: bool, +) -> None: + litellm.register_model( + { + "azure/plain-chat-test-deployment": { + "litellm_provider": "azure", + "mode": "chat", + "max_tokens": 1024, + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + "supports_reasoning": False, + } + } + ) + + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + optional_params: Final = get_optional_params( + model="plain-chat-test-deployment", custom_llm_provider="azure", drop_params=True, **reasoning_kwargs + ) + + assert not set(reasoning_kwargs) & set(optional_params) + assert any("Dropping" in record.getMessage() for record in caplog.records) is warns