From d90490f541e6869eb453cea2bf667c230e19ade6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 22:34:31 +0000 Subject: [PATCH] fix(mistral): translate file_data to document_url in chat messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: } (existing behaviour) Fixes #25531 --- litellm/llms/mistral/chat/transformation.py | 25 +++-- .../test_mistral_chat_transformation.py | 103 ++++++++++++++++-- 2 files changed, 110 insertions(+), 18 deletions(-) diff --git a/litellm/llms/mistral/chat/transformation.py b/litellm/llms/mistral/chat/transformation.py index 23fbe467fc8..a15f738940c 100644 --- a/litellm/llms/mistral/chat/transformation.py +++ b/litellm/llms/mistral/chat/transformation.py @@ -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": } """ 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 = [ 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 544788105d3..3af6da87897 100644 --- a/tests/test_litellm/llms/mistral/test_mistral_chat_transformation.py +++ b/tests/test_litellm/llms/mistral/test_mistral_chat_transformation.py @@ -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