diff --git a/litellm/llms/mistral/chat/transformation.py b/litellm/llms/mistral/chat/transformation.py index f1ad3708236..8d0cf993814 100644 --- a/litellm/llms/mistral/chat/transformation.py +++ b/litellm/llms/mistral/chat/transformation.py @@ -247,6 +247,8 @@ class MistralConfig(OpenAIGPTConfig): The above statement is not valid now. Need to plan to remove all the #1,2,3 Mistral API supports content as a list. """ + messages = [self._strip_output_only_fields(m) for m in messages] + ## 1. If 'image_url' or 'file' in content, then transform with base class and mistral-specific handling for m in messages: _content_block = m.get("content") @@ -409,6 +411,25 @@ class MistralConfig(OpenAIGPTConfig): return cleaned_tools + @classmethod + def _strip_output_only_fields(cls, message: AllMessageValues) -> AllMessageValues: + """ + ``reasoning_content`` and ``thinking_blocks`` are output-only fields that + LiteLLM attaches to assistant responses. Mistral's input schema forbids + unknown fields, so replaying them verbatim in a follow-up turn triggers a + 422 ``extra_forbidden``. Drop them before the request is sent. + """ + if message["role"] != "assistant": + return message + return cast( + AllMessageValues, + { + k: v + for k, v in message.items() + if k not in ("reasoning_content", "thinking_blocks") + }, + ) + @classmethod def _handle_name_in_message(cls, message: AllMessageValues) -> AllMessageValues: """ 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 3ee53bb46cd..7a3f372582f 100644 --- a/tests/test_litellm/llms/mistral/test_mistral_chat_transformation.py +++ b/tests/test_litellm/llms/mistral/test_mistral_chat_transformation.py @@ -719,3 +719,93 @@ class TestMistralFileHandling: # Check that file_ids are modified to match Mistral's expected format assert result[0]["content"][1]["file_id"] == "file-12345" # type: ignore assert result[0]["content"][2]["file_id"] == "file-67890" # type: ignore + + +class TestMistralStripsOutputOnlyFields: + """Mistral rejects unknown input fields with a 422 ``extra_forbidden``. + + LiteLLM attaches ``reasoning_content`` / ``thinking_blocks`` to assistant + responses, so replaying an assistant turn verbatim must not forward them. + Regression for https://github.com/BerriAI/litellm/issues/30835. + """ + + def test_assistant_reasoning_content_is_dropped(self): + messages = cast( + List[AllMessageValues], + [ + {"role": "user", "content": "Question?"}, + { + "role": "assistant", + "content": "Follow-up", + "reasoning_content": "Some internal reasoning text.", + "thinking_blocks": [ + {"type": "thinking", "thinking": "step", "signature": "mistral"} + ], + }, + ], + ) + + result = cast( + List[AllMessageValues], + MistralConfig()._transform_messages( + messages=messages, model="mistral-medium-3-5" + ), + ) + + assistant_message = result[-1] + assert "reasoning_content" not in assistant_message + assert "thinking_blocks" not in assistant_message + assert assistant_message["content"] == "Follow-up" + assert assistant_message["role"] == "assistant" + + def test_non_assistant_messages_are_untouched(self): + messages = cast( + List[AllMessageValues], + [{"role": "user", "content": "Question?", "reasoning_content": "noise"}], + ) + + result = cast( + List[AllMessageValues], + MistralConfig()._transform_messages( + messages=messages, model="mistral-medium-3-5" + ), + ) + + assert result[0].get("reasoning_content") == "noise" + + def test_reasoning_content_dropped_when_image_present(self): + """The image branch returns early, so stripping must run before it.""" + messages = cast( + List[AllMessageValues], + [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this"}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/cat.png"}, + }, + ], + }, + { + "role": "assistant", + "content": "A cat.", + "reasoning_content": "leaked reasoning", + }, + ], + ) + + with patch.object( + MistralConfig, + "_transform_messages_sync", + side_effect=lambda transformed, model: transformed, + ): + result = cast( + List[AllMessageValues], + MistralConfig()._transform_messages( + messages=messages, model="mistral-medium-3-5", is_async=False + ), + ) + + assert "reasoning_content" not in result[-1]