fix(mistral): translate file_data to document_url in chat messages

When a user sends an OpenAI-format 'type: file' content block with
file_data (a base64 data URI) to a Mistral backend, LiteLLM was
passing the payload through untranslated.  Mistral rejects it because
it only accepts 'type: document_url'.

_handle_message_with_file now handles two cases:
- file_data present → {type: document_url, document_url: }
- file_id present   → {type: file, file_id: <id>}  (existing behaviour)

Fixes #25531
This commit is contained in:
Claude 2026-04-10 22:34:31 +00:00
parent f2f2a91a29
commit d90490f541
2 changed files with 110 additions and 18 deletions

View file

@ -303,21 +303,28 @@ class MistralConfig(OpenAIGPTConfig):
self, messages: List[AllMessageValues]
) -> List[AllMessageValues]:
"""
Mistral API supports only 'file_id' in message content with type 'file'.
Translate OpenAI 'file' content blocks to Mistral-native formats:
- file_data (base64 data URI) {"type": "document_url", "document_url": }
- file_id (uploaded file ID) {"type": "file", "file_id": <id>}
"""
for m in messages:
_content_block = m.get("content")
if _content_block and isinstance(_content_block, list):
if any(c.get("type") == "file" for c in _content_block):
# If file content is present, we get file_id from 'file' attribute of content block
# then replace 'file' with 'file_id' and assign the value of 'file_id' attribute to it.
file_contents = [
c for c in _content_block if c.get("type") == "file"
]
for file_content in file_contents:
file_id = file_content.get("file", {}).get("file_id")
if file_id:
# Replace 'file' with 'file_id'
file_obj = file_content.get("file", {})
file_data = file_obj.get("file_data")
file_id = file_obj.get("file_id")
if file_data:
# Mistral does not accept 'file_data'; translate to document_url
file_content["type"] = "document_url" # type: ignore
file_content["document_url"] = file_data # type: ignore
file_content.pop("file", None)
elif file_id:
# Replace 'file' wrapper with flat 'file_id' key
file_content["file_id"] = file_id # type: ignore
file_content.pop("file", None)
return messages
@ -344,9 +351,9 @@ class MistralConfig(OpenAIGPTConfig):
# Handle both string and list content, preserving original format
if isinstance(existing_content, str):
# String content - prepend reasoning prompt
new_content: Union[
str, list
] = f"{reasoning_prompt}\n\n{existing_content}"
new_content: Union[str, list] = (
f"{reasoning_prompt}\n\n{existing_content}"
)
elif isinstance(existing_content, list):
# List content - prepend reasoning prompt as text block
new_content = [

View file

@ -649,9 +649,10 @@ class TestMistralEmptyContentHandling:
message = {"role": "assistant", "content": "Hello"}
assert MistralConfig._is_empty_assistant_message(message) is False
class TestMistralFileHandling:
"""Test suite for Mistral file handling functionality."""
def test_handle_file_message_with_file_id(self):
"""Test that file messages with file_id are handled correctly."""
mistral_config = MistralConfig()
@ -660,8 +661,8 @@ class TestMistralFileHandling:
"role": "user",
"content": [
{"type": "text", "text": "Please review this file."},
{"type": "file", "file": {"file_id": "file-12345"}}
]
{"type": "file", "file": {"file_id": "file-12345"}},
],
}
]
casted_message = cast(list[AllMessageValues], messages)
@ -674,7 +675,7 @@ class TestMistralFileHandling:
# Check that file type is preserved
assert result[0]["content"][1]["type"] == "file"
# Check that file_id is modified to match Mistral's expected format
assert result[0]["content"][1]["file_id"] == "file-12345" # type: ignore
assert result[0]["content"][1]["file_id"] == "file-12345" # type: ignore
def test_handle_file_message_without_file_id(self):
"""Test that file messages without file_id are ignored."""
@ -682,9 +683,7 @@ class TestMistralFileHandling:
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Please review this file."}
]
"content": [{"type": "text", "text": "Please review this file."}],
}
]
casted_message = cast(list[AllMessageValues], messages)
@ -703,8 +702,8 @@ class TestMistralFileHandling:
"content": [
{"type": "text", "text": "Please review these files."},
{"type": "file", "file": {"file_id": "file-12345"}},
{"type": "file", "file": {"file_id": "file-67890"}}
]
{"type": "file", "file": {"file_id": "file-67890"}},
],
}
]
casted_message = cast(list[AllMessageValues], messages)
@ -720,3 +719,89 @@ 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
def test_handle_file_message_with_file_data_converts_to_document_url(self):
"""Test that file_data (base64 URI) is translated to Mistral's document_url format."""
mistral_config = MistralConfig()
file_data = "data:application/pdf;base64,JVBERi0xLjQK"
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "What does this PDF contain?"},
{
"type": "file",
"file": {
"filename": "test.pdf",
"file_data": file_data,
},
},
],
}
]
casted_messages = cast(list[AllMessageValues], messages)
result = mistral_config._handle_message_with_file(casted_messages)
assert len(result) == 1
content = result[0]["content"]
assert isinstance(content, list)
assert len(content) == 2
assert content[0] == {"type": "text", "text": "What does this PDF contain?"}
assert content[1]["type"] == "document_url" # type: ignore
assert content[1]["document_url"] == file_data # type: ignore
assert "file" not in content[1]
def test_handle_file_message_file_data_takes_priority_over_file_id(self):
"""Test that file_data is preferred over file_id when both are present."""
mistral_config = MistralConfig()
file_data = "data:application/pdf;base64,JVBERi0xLjQK"
messages = [
{
"role": "user",
"content": [
{
"type": "file",
"file": {
"file_id": "file-12345",
"file_data": file_data,
},
}
],
}
]
casted_messages = cast(list[AllMessageValues], messages)
result = mistral_config._handle_message_with_file(casted_messages)
content = result[0]["content"]
assert isinstance(content, list)
assert content[0]["type"] == "document_url" # type: ignore
assert content[0]["document_url"] == file_data # type: ignore
def test_transform_messages_sync_translates_file_data_to_document_url(self):
"""Test the full sync transform pipeline translates file_data end-to-end."""
mistral_config = MistralConfig()
file_data = "data:application/pdf;base64,JVBERi0xLjQK"
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Summarise this PDF."},
{
"type": "file",
"file": {"filename": "doc.pdf", "file_data": file_data},
},
],
}
]
casted_messages = cast(list[AllMessageValues], messages)
result = mistral_config._transform_messages_sync(
casted_messages, model="mistral-small-latest"
)
user_content = result[0]["content"]
assert isinstance(user_content, list)
file_block = next(
(c for c in user_content if c.get("type") == "document_url"), None
)
assert file_block is not None
assert file_block["document_url"] == file_data # type: ignore