diff --git a/litellm/llms/mistral/chat/transformation.py b/litellm/llms/mistral/chat/transformation.py index 0c95fd4df07..8259706d84c 100644 --- a/litellm/llms/mistral/chat/transformation.py +++ b/litellm/llms/mistral/chat/transformation.py @@ -6,7 +6,8 @@ Why separate file? Make it easy to see how transformation works Docs - https://docs.mistral.ai/api/ """ -from collections.abc import AsyncIterator, Coroutine, Iterator +from collections.abc import AsyncIterator, Coroutine, Iterator, Mapping +from types import MappingProxyType from typing import Any, Final, Literal, cast, get_type_hints, overload import httpx @@ -24,7 +25,7 @@ from litellm.secret_managers.main import get_secret_str from litellm.types.llms.mistral import MistralThinkingBlock, MistralToolCallMessage from litellm.types.llms.openai import AllMessageValues from litellm.types.utils import ModelResponse, ModelResponseStream -from litellm.utils import convert_to_model_response_object +from litellm.utils import convert_to_model_response_object, supports_reasoning class MistralConfig(OpenAIGPTConfig): @@ -98,12 +99,26 @@ class MistralConfig(OpenAIGPTConfig): "parallel_tool_calls", ] - # Add reasoning support for magistral models - if "magistral" in model.lower(): + if self._is_magistral_model(model): supported_params.extend(["thinking", "reasoning_effort"]) + elif self._supports_native_reasoning_effort(model): + supported_params.append("reasoning_effort") return supported_params + @staticmethod + def _is_magistral_model(model: str) -> bool: + return "magistral" in model.lower() + + @staticmethod + def _supports_native_reasoning_effort(model: str) -> bool: + """ + Mistral's hybrid reasoning models (mistral-medium-3-5 and newer) take ``reasoning_effort`` + directly on /chat/completions, so it is forwarded as-is instead of being emulated with the + magistral system prompt: https://docs.mistral.ai/capabilities/reasoning + """ + return supports_reasoning(model=model, custom_llm_provider="mistral") + def _map_tool_choice(self, tool_choice: str) -> str: if tool_choice == "auto" or tool_choice == "none": return tool_choice @@ -168,16 +183,19 @@ class MistralConfig(OpenAIGPTConfig): optional_params["extra_body"] = {"random_seed": value} if param == "response_format": optional_params["response_format"] = value - if param == "reasoning_effort" and "magistral" in model.lower(): - # Flag that we need to add reasoning system prompt - optional_params["_add_reasoning_prompt"] = True - if param == "thinking" and "magistral" in model.lower(): - # Flag that we need to add reasoning system prompt - optional_params["_add_reasoning_prompt"] = True + if param in ("reasoning_effort", "thinking"): + optional_params.update(self._map_reasoning_param(param=param, value=value, model=model)) if param == "parallel_tool_calls": optional_params["parallel_tool_calls"] = value return optional_params + def _map_reasoning_param(self, param: str, value: object, model: str) -> Mapping[str, object]: + if self._is_magistral_model(model): + return MappingProxyType({"_add_reasoning_prompt": True}) + if param == "reasoning_effort" and value is not None and self._supports_native_reasoning_effort(model): + return MappingProxyType({"reasoning_effort": value}) + return MappingProxyType({}) + def _get_openai_compatible_provider_info(self, api_base: str | None, api_key: str | None) -> tuple[str, str | None]: # mistral is openai compatible, we just need to set this to custom_openai and have the api_base be https://api.mistral.ai api_base = ( diff --git a/tests/test_litellm/llms/mistral/test_mistral_chat_transformation.py b/tests/test_litellm/llms/mistral/test_mistral_chat_transformation.py index 15694d9f218..ae72a74d3ae 100644 --- a/tests/test_litellm/llms/mistral/test_mistral_chat_transformation.py +++ b/tests/test_litellm/llms/mistral/test_mistral_chat_transformation.py @@ -307,6 +307,48 @@ class TestMistralReasoningSupport: assert len(result["messages"]) == 1 assert result["messages"][0]["role"] == "user" + @pytest.mark.parametrize( + "model", + ["mistral/mistral-medium-3-5", "mistral-medium-3-5", "mistral/mistral-medium-latest"], + ) + def test_native_reasoning_model_supports_reasoning_effort(self, model): + """Hybrid reasoning models take reasoning_effort natively, see https://github.com/BerriAI/litellm/issues/36407""" + supported_params = MistralConfig().get_supported_openai_params(model) + + assert "reasoning_effort" in supported_params + assert "thinking" not in supported_params + + def test_native_reasoning_effort_forwarded_to_request_body(self): + mistral_config = MistralConfig() + + optional_params = mistral_config.map_openai_params( + non_default_params={"reasoning_effort": "high"}, + optional_params={}, + model="mistral/mistral-medium-3-5", + drop_params=False, + ) + assert optional_params == {"reasoning_effort": "high"} + + request = mistral_config.transform_request( + model="mistral/mistral-medium-3-5", + messages=[{"role": "user", "content": "What is 15 * 7?"}], + optional_params=optional_params, + litellm_params={}, + headers={}, + ) + assert request["reasoning_effort"] == "high" + assert len(request["messages"]) == 1 + + def test_magistral_reasoning_effort_stays_prompt_based(self): + optional_params = MistralConfig().map_openai_params( + non_default_params={"reasoning_effort": "high"}, + optional_params={}, + model="mistral/magistral-medium-2506", + drop_params=False, + ) + + assert optional_params == {"_add_reasoning_prompt": True} + def test_case_insensitive_magistral_detection(self): """Test that magistral model detection is case-insensitive.""" mistral_config = MistralConfig()