diff --git a/litellm/__init__.py b/litellm/__init__.py index a772aa6a05..77b3bcd879 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -1046,7 +1046,7 @@ from .llms.groq.chat.transformation import GroqChatConfig from .llms.voyage.embedding.transformation import VoyageEmbeddingConfig from .llms.infinity.embedding.transformation import InfinityEmbeddingConfig from .llms.azure_ai.chat.transformation import AzureAIStudioConfig -from .llms.mistral.mistral_chat_transformation import MistralConfig +from .llms.mistral.chat.transformation import MistralConfig from .llms.openai.responses.transformation import OpenAIResponsesAPIConfig from .llms.azure.responses.transformation import AzureOpenAIResponsesAPIConfig from .llms.openai.chat.o_series_transformation import ( diff --git a/litellm/constants.py b/litellm/constants.py index c883336274..eaec161ebe 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -397,7 +397,6 @@ openai_compatible_endpoints: List = [ openai_compatible_providers: List = [ "anyscale", - "mistral", "groq", "nvidia_nim", "cerebras", diff --git a/litellm/llms/mistral/chat.py b/litellm/llms/mistral/chat.py deleted file mode 100644 index fc454038f1..0000000000 --- a/litellm/llms/mistral/chat.py +++ /dev/null @@ -1,5 +0,0 @@ -""" -Calls handled in openai/ - -as mistral is an openai-compatible endpoint. -""" diff --git a/litellm/llms/mistral/mistral_chat_transformation.py b/litellm/llms/mistral/chat/transformation.py similarity index 99% rename from litellm/llms/mistral/mistral_chat_transformation.py rename to litellm/llms/mistral/chat/transformation.py index 3bff50a8fd..0441e75bee 100644 --- a/litellm/llms/mistral/mistral_chat_transformation.py +++ b/litellm/llms/mistral/chat/transformation.py @@ -174,7 +174,7 @@ class MistralConfig(OpenAIGPTConfig): def _get_openai_compatible_provider_info( self, api_base: Optional[str], api_key: Optional[str] - ) -> Tuple[Optional[str], Optional[str]]: + ) -> Tuple[str, Optional[str]]: # mistral is openai compatible, we just need to set this to custom_openai and have the api_base be https://api.mistral.ai api_base = ( api_base diff --git a/litellm/llms/mistral/embedding.py b/litellm/llms/mistral/embedding.py index fc454038f1..0aae35ad7f 100644 --- a/litellm/llms/mistral/embedding.py +++ b/litellm/llms/mistral/embedding.py @@ -1,5 +1,4 @@ """ Calls handled in openai/ - as mistral is an openai-compatible endpoint. -""" +""" \ No newline at end of file diff --git a/litellm/main.py b/litellm/main.py index 74aded98f3..dfd4e71094 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -1841,7 +1841,6 @@ def completion( # type: ignore # noqa: PLR0915 or custom_llm_provider == "sambanova" or custom_llm_provider == "volcengine" or custom_llm_provider == "anyscale" - or custom_llm_provider == "mistral" or custom_llm_provider == "openai" or custom_llm_provider == "together_ai" or custom_llm_provider == "nebius" @@ -1930,6 +1929,33 @@ def completion( # type: ignore # noqa: PLR0915 additional_args={"headers": headers}, ) + elif custom_llm_provider == "mistral": + api_key = api_key or litellm.api_key or get_secret("MISTRAL_API_KEY") + api_base = ( + api_base + or litellm.api_base + or get_secret("MISTRAL_API_BASE") + or "https://api.mistral.ai/v1" + ) + + response = base_llm_http_handler.completion( + model=model, + messages=messages, + api_base=api_base, + custom_llm_provider=custom_llm_provider, + model_response=model_response, + encoding=encoding, + logging_obj=logging, + optional_params=optional_params, + timeout=timeout, + litellm_params=litellm_params, + acompletion=acompletion, + stream=stream, + api_key=api_key, + headers=headers, + client=client, + provider_config=provider_config, + ) elif ( "replicate" in model or custom_llm_provider == "replicate" 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 6a61cef00a..3e4d3ddb30 100644 --- a/tests/test_litellm/llms/mistral/test_mistral_chat_transformation.py +++ b/tests/test_litellm/llms/mistral/test_mistral_chat_transformation.py @@ -8,7 +8,7 @@ sys.path.insert( 0, os.path.abspath("../..") ) # Adds the parent directory to the system path -from litellm.llms.mistral.mistral_chat_transformation import MistralConfig +from litellm.llms.mistral.chat.transformation import MistralConfig @pytest.mark.asyncio diff --git a/tests/test_litellm/llms/mistral/test_mistral_completion.py b/tests/test_litellm/llms/mistral/test_mistral_completion.py new file mode 100644 index 0000000000..2d9e20418d --- /dev/null +++ b/tests/test_litellm/llms/mistral/test_mistral_completion.py @@ -0,0 +1,160 @@ +import pytest +import litellm + + +@pytest.fixture(autouse=True) +def add_mistral_api_key_to_env(monkeypatch): + """Add Mistral API key to environment for testing.""" + monkeypatch.setenv("MISTRAL_API_KEY", "fake-mistral-api-key-12345") + + +@pytest.fixture +def mistral_api_response(): + """Mock response data for Mistral API calls.""" + return { + "id": "chatcmpl-mistral-123", + "object": "chat.completion", + "created": 1677652288, + "model": "mistral-medium-latest", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello from Mistral! How can I help you today?", + }, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 15, "total_tokens": 25}, + } + + +@pytest.fixture +def mistral_api_response_with_empty_content(): + """Mock response data for Mistral API calls with empty content that should be converted to None.""" + return { + "id": "chatcmpl-mistral-123", + "object": "chat.completion", + "created": 1677652288, + "model": "mistral-medium-latest", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "", # Empty string that should be converted to None + }, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 0, "total_tokens": 10}, + } + + +@pytest.mark.parametrize("sync_mode", [True, False]) +@pytest.mark.asyncio +async def test_mistral_basic_completion(sync_mode, respx_mock, mistral_api_response): + """Test basic Mistral completion functionality.""" + litellm.disable_aiohttp_transport = True + + model = "mistral/mistral-medium-latest" + messages = [{"role": "user", "content": "Hello, how are you?"}] + + # Mock the Mistral API endpoint + respx_mock.post("https://api.mistral.ai/v1/chat/completions").respond( + json=mistral_api_response + ) + + if sync_mode: + response = litellm.completion(model=model, messages=messages) + else: + response = await litellm.acompletion(model=model, messages=messages) + + # Verify response + assert response.choices[0].message.content == "Hello from Mistral! How can I help you today?" + assert response.model == "mistral-medium-latest" + assert response.usage.total_tokens == 25 + + +@pytest.mark.parametrize("sync_mode", [True, False]) +@pytest.mark.asyncio +async def test_mistral_transform_response_empty_content_conversion(sync_mode, respx_mock, mistral_api_response_with_empty_content): + """ + Test that Mistral's transform_response method is being called by verifying + the specific behavior of converting empty string content to None. + + This test verifies that the _handle_empty_content_response method in + MistralConfig.transform_response is being applied. + """ + litellm.disable_aiohttp_transport = True + + model = "mistral/mistral-medium-latest" + messages = [{"role": "user", "content": "Generate an empty response"}] + + # Mock the Mistral API endpoint with empty content + respx_mock.post("https://api.mistral.ai/v1/chat/completions").respond( + json=mistral_api_response_with_empty_content + ) + + if sync_mode: + response = litellm.completion(model=model, messages=messages) + else: + response = await litellm.acompletion(model=model, messages=messages) + + # Verify that the transform_response method was called by checking that + # empty string content was converted to None (Mistral-specific behavior) + assert response.choices[0].message.content is None + assert response.model == "mistral-medium-latest" + assert response.usage.total_tokens == 10 + + +@pytest.mark.parametrize("sync_mode", [True, False]) +@pytest.mark.asyncio +async def test_mistral_transform_request_name_field_removal(sync_mode, respx_mock, mistral_api_response): + """ + Test that Mistral's transform_request method is being called by verifying + the specific behavior of removing the 'name' field from non-tool messages. + + This test verifies that the _handle_name_in_message method in + MistralConfig._transform_messages is being applied. + """ + litellm.disable_aiohttp_transport = True + + model = "mistral/mistral-medium-latest" + # Include a message with 'name' field that should be removed for non-tool messages + messages = [ + {"role": "user", "content": "Hello", "name": "should_be_removed"}, + {"role": "assistant", "content": "Hi there!"}, + {"role": "user", "content": "How are you?"} + ] + + # Mock the Mistral API endpoint + respx_mock.post("https://api.mistral.ai/v1/chat/completions").respond( + json=mistral_api_response + ) + + if sync_mode: + response = litellm.completion(model=model, messages=messages) + else: + response = await litellm.acompletion(model=model, messages=messages) + + # Verify the response works (if transform_request wasn't called, the API would reject the request) + assert response.choices[0].message.content == "Hello from Mistral! How can I help you today?" + assert response.model == "mistral-medium-latest" + + # Verify that the request was made (if transform_request failed, this would fail) + assert len(respx_mock.calls) == 1 + + # Get the actual request that was made + request = respx_mock.calls[0].request + import json + request_data = json.loads(request.content.decode('utf-8')) + + # Verify that the 'name' field was removed from the user message + # (Mistral API only supports 'name' in tool messages) + user_message = request_data["messages"][0] + assert user_message["role"] == "user" + assert user_message["content"] == "Hello" + assert "name" not in user_message # The 'name' field should have been removed +