From 9eea89f70074b2942a59b6c260faa4016e485b76 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:55:11 -0800 Subject: [PATCH] fix(mistral): reject reasoning_effort strings that are not a level, and leave "default" to Mistral A string such as "HIGH" or "auto" went to Mistral as sent and Mistral answered 422, even with drop_params. It now takes the same path as a number: dropped with a warning under drop_params, a 400 without it. "default", which litellm.completion lists as a value, sends no reasoning_effort so Mistral applies its own default. --- litellm/llms/mistral/chat/transformation.py | 12 +++-- .../test_mistral_chat_transformation.py | 47 ++++++++++++++++++- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/litellm/llms/mistral/chat/transformation.py b/litellm/llms/mistral/chat/transformation.py index 87860588f62..6d2e4d0430f 100644 --- a/litellm/llms/mistral/chat/transformation.py +++ b/litellm/llms/mistral/chat/transformation.py @@ -27,6 +27,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( strip_none_values_from_message, ) from litellm.litellm_core_utils.reasoning_effort_capability import ( + REASONING_EFFORT_ADVERTISEMENT_ORDER, declared_reasoning_efforts_for_model, nearest_declared_reasoning_effort, ) @@ -92,6 +93,8 @@ _MISTRAL_REQUEST_ROUTING: Final = TypeAdapter(MistralRequestRouting) _MISTRAL_CACHE_KEY_PARAMS: Final = TypeAdapter(MistralCacheKeyParams) _MISTRAL_REASONING_EFFORT_PARAMS: Final = TypeAdapter(MistralReasoningEffortParams) _MISTRAL_FILE_PART: Final = TypeAdapter(MistralFilePart) +_REASONING_LEVELS: Final = frozenset(REASONING_EFFORT_ADVERTISEMENT_ORDER) +_PROVIDER_DEFAULT_EFFORT: Final = "default" _NON_LEVEL_EFFORT_REASON: Final = ( "the Mistral API takes it only as a level such as 'high', so Mistral applies its default effort." ) @@ -294,13 +297,16 @@ class MistralConfig(OpenAIGPTConfig): ) -> str | None: requested_effort: Final = requested.get("reasoning_effort") level, summary = _reasoning_level_and_summary(requested_effort) + leaves_level_to_mistral: Final = level == _PROVIDER_DEFAULT_EFFORT sendable: Final = ( - _accepted_reasoning_effort(model, level, self.custom_llm_provider) if isinstance(level, str) else None + _accepted_reasoning_effort(model, level, self.custom_llm_provider) + if isinstance(level, str) and level in _REASONING_LEVELS + else None ) - if sendable is not None and summary is None: + if summary is None and (sendable is not None or leaves_level_to_mistral): return sendable if drop_params or litellm.drop_params is True: - if sendable is None and level is not None: + if sendable is None and level is not None and not leaves_level_to_mistral: warn_dropped_reasoning( "reasoning_effort", requested_effort, model, _NON_LEVEL_EFFORT_REASON, drop_params_gated=True ) diff --git a/tests/unit/llms/mistral/test_mistral_chat_transformation.py b/tests/unit/llms/mistral/test_mistral_chat_transformation.py index 3fc2affb31f..9153d3ab095 100644 --- a/tests/unit/llms/mistral/test_mistral_chat_transformation.py +++ b/tests/unit/llms/mistral/test_mistral_chat_transformation.py @@ -1556,7 +1556,9 @@ def test_non_magistral_reasoning_effort_is_sent_without_warning(caplog: pytest.L assert _warnings_naming(caplog, "reasoning_effort") == () -@pytest.mark.parametrize("reasoning_effort", [5, {"effort": 5}, {"effort": 5, "summary": "auto"}]) +@pytest.mark.parametrize( + "reasoning_effort", [5, {"effort": 5}, {"effort": 5, "summary": "auto"}, "HIGH", "auto", "", {"effort": "HIGH"}] +) def test_mistral_warns_once_when_drop_params_drops_a_level_it_cannot_send( caplog: pytest.LogCaptureFixture, reasoning_effort: object ) -> None: @@ -1575,7 +1577,10 @@ def test_mistral_warns_once_when_drop_params_drops_a_level_it_cannot_send( assert "(drop_params=True)" in warnings[0] -@pytest.mark.parametrize("reasoning_effort", [{"summary": "auto"}, {"effort": "high", "summary": "auto"}]) +@pytest.mark.parametrize( + "reasoning_effort", + [{"summary": "auto"}, {"effort": "high", "summary": "auto"}, {"effort": "default", "summary": "auto"}], +) def test_mistral_stays_quiet_under_drop_params_when_no_level_is_lost( caplog: pytest.LogCaptureFixture, reasoning_effort: object ) -> None: @@ -1590,6 +1595,44 @@ def test_mistral_stays_quiet_under_drop_params_when_no_level_is_lost( assert _warnings_naming(caplog, "reasoning_effort") == () +@pytest.mark.parametrize("reasoning_effort", ["HIGH", "auto", "", {"effort": "HIGH"}]) +def test_magistral_rejects_a_reasoning_effort_that_is_not_a_level( + local_model_cost_map: None, reasoning_effort: object +) -> None: + import litellm + + with pytest.raises(litellm.UnsupportedParamsError, match=r"\['reasoning_effort'\]"): + litellm.get_optional_params( + model="magistral-small-latest", + custom_llm_provider="mistral", + reasoning_effort=reasoning_effort, + ) + + +@pytest.mark.parametrize("drop_params", [False, True]) +@pytest.mark.parametrize("reasoning_effort", ["default", {"effort": "default"}]) +@pytest.mark.parametrize("model", ["magistral-small-latest", "mistral-medium-latest"]) +def test_default_reasoning_effort_leaves_the_level_to_mistral( + local_model_cost_map: None, + caplog: pytest.LogCaptureFixture, + model: str, + reasoning_effort: object, + drop_params: bool, +) -> None: + import litellm + + with caplog.at_level(logging.WARNING, logger=verbose_logger.name): + optional_params: Final = litellm.get_optional_params( + model=model, + custom_llm_provider="mistral", + reasoning_effort=reasoning_effort, + drop_params=drop_params, + ) + + assert "reasoning_effort" not in optional_params + assert _warnings_naming(caplog, "reasoning_effort") == () + + @pytest.mark.parametrize( ("model", "custom_llm_provider"), [