From 217ff78ae7ebc2a77f0069a61364d13cc000dfc2 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 03:04:45 -0700 Subject: [PATCH] fix(mistral): read back files whose purpose Mistral never lets us upload as user_data --- litellm/llms/mistral/files/transformation.py | 12 +++++++----- .../files/test_mistral_files_transformation.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/litellm/llms/mistral/files/transformation.py b/litellm/llms/mistral/files/transformation.py index 88867ea4802..6edf188d247 100644 --- a/litellm/llms/mistral/files/transformation.py +++ b/litellm/llms/mistral/files/transformation.py @@ -3,7 +3,8 @@ Mistral Files API. Reference: https://docs.mistral.ai/api/#tag/files Mistral's file objects already carry the OpenAI field names (id, bytes, created_at, filename, purpose), so this config is URL routing, auth, and a purpose mapping: -Mistral only accepts ``fine-tune``, ``batch`` and ``ocr`` as upload purposes. +Mistral only accepts ``fine-tune``, ``batch`` and ``ocr`` as upload purposes, while files +other Mistral products created read back with purposes outside that set and map onto ``user_data``. """ import time @@ -34,9 +35,10 @@ from ..common_utils import get_mistral_api_base, get_mistral_auth_headers, mistr MistralFilePurpose: TypeAlias = Literal["fine-tune", "batch", "ocr"] -_OPENAI_PURPOSE_BY_MISTRAL: Final[Mapping[MistralFilePurpose, OpenAIFilesPurpose]] = MappingProxyType( +_OPENAI_PURPOSE_BY_MISTRAL: Final[Mapping[str, OpenAIFilesPurpose]] = MappingProxyType( {"fine-tune": "fine-tune", "batch": "batch", "ocr": "user_data"} ) +_OPENAI_PURPOSE_FOR_UNMAPPED: Final[OpenAIFilesPurpose] = "user_data" _MISTRAL_PURPOSE_BY_OPENAI: Final[Mapping[str, MistralFilePurpose]] = MappingProxyType( {"fine-tune": "fine-tune", "batch": "batch", "ocr": "ocr", "user_data": "ocr"} ) @@ -59,7 +61,7 @@ class MistralFile(BaseModel): bytes: int = 0 created_at: int | None = None filename: str = "" - purpose: MistralFilePurpose = "batch" + purpose: str = "batch" expires_at: int | None = None @@ -89,8 +91,8 @@ def _to_openai_file_object(file: MistralFile) -> OpenAIFileObject: ) -def _to_openai_purpose(purpose: MistralFilePurpose) -> OpenAIFilesPurpose: - return _OPENAI_PURPOSE_BY_MISTRAL[purpose] +def _to_openai_purpose(purpose: str) -> OpenAIFilesPurpose: + return _OPENAI_PURPOSE_BY_MISTRAL.get(purpose, _OPENAI_PURPOSE_FOR_UNMAPPED) def _to_mistral_purpose(purpose: str) -> MistralFilePurpose: diff --git a/tests/test_litellm/llms/mistral/files/test_mistral_files_transformation.py b/tests/test_litellm/llms/mistral/files/test_mistral_files_transformation.py index 5043cc583ef..303afe99a2c 100644 --- a/tests/test_litellm/llms/mistral/files/test_mistral_files_transformation.py +++ b/tests/test_litellm/llms/mistral/files/test_mistral_files_transformation.py @@ -158,6 +158,23 @@ def test_file_response_with_ocr_purpose_maps_onto_user_data(config): assert obj.expires_at == 1_800_000_000 +@pytest.mark.parametrize("purpose", ["playground", "audio", "code_interpreter"]) +def test_files_with_purposes_mistral_never_lets_us_upload_still_read_back(config, purpose): + """Regression: Mistral's live API returns purposes its upload endpoint rejects for files + other Mistral products created, and both the unfiltered list and a retrieve of such a file + used to fail validation, so one playground file 500'd ``GET /v1/files`` for the whole key.""" + retrieved = config.transform_retrieve_file_response( + raw_response=_response(_file(purpose=purpose)), logging_obj=None, litellm_params={} + ) + assert retrieved.purpose == "user_data" + listed = config.transform_list_files_response( + raw_response=_response({"data": [_file(purpose=purpose), _file(id="second")], "object": "list", "total": 2}), + logging_obj=None, + litellm_params={}, + ) + assert [(f.id, f.purpose) for f in listed] == [(FILE_ID, "user_data"), ("second", "batch")] + + @pytest.mark.parametrize( "method,suffix", [