mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
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:
parent
7fa04dbfd9
commit
37a2a32e43
2 changed files with 111 additions and 0 deletions
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue