mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
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:
parent
f2f2a91a29
commit
d90490f541
2 changed files with 110 additions and 18 deletions
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue