fix(mistral): drop output-only reasoning fields from input messages (#30884)

LiteLLM attaches reasoning_content and thinking_blocks to assistant
responses. Replaying those assistant turns verbatim forwarded the fields
back to Mistral, whose input schema forbids unknown keys, so the whole
request failed with a 422 extra_forbidden and reasoning models became
unusable across multiple turns.

Strip both fields from assistant messages before the request is built, in
a spot that runs ahead of the image/file branch so it applies on every
path. Fixes #30835

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Hemant K 2026-06-22 08:37:40 -04:00 committed by GitHub
parent 7fa04dbfd9
commit 37a2a32e43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 111 additions and 0 deletions

View file

@ -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:
"""

View file

@ -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]