From 82f2590da8ab1c378ea9c176a282c107645ef93e Mon Sep 17 00:00:00 2001 From: Hareesh Date: Fri, 10 Oct 2025 17:36:00 +0200 Subject: [PATCH 1/4] feat: add support for OpenAI-style response format dictionaries in Mistral provider --- src/any_llm/providers/mistral/mistral.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/any_llm/providers/mistral/mistral.py b/src/any_llm/providers/mistral/mistral.py index 4ab7fa51..b81bde90 100644 --- a/src/any_llm/providers/mistral/mistral.py +++ b/src/any_llm/providers/mistral/mistral.py @@ -10,6 +10,7 @@ try: from mistralai import Mistral from mistralai.extra import response_format_from_pydantic_model + from mistralai.models.responseformat import ResponseFormat from .utils import ( _convert_models_list, @@ -115,12 +116,13 @@ async def _acompletion( if params.reasoning_effort == "auto": params.reasoning_effort = None - if ( - params.response_format is not None - and isinstance(params.response_format, type) - and issubclass(params.response_format, BaseModel) - ): - kwargs["response_format"] = response_format_from_pydantic_model(params.response_format) + if params.response_format is not None: + # Pydantic model + if isinstance(params.response_format, type) and issubclass(params.response_format, BaseModel): + kwargs["response_format"] = response_format_from_pydantic_model(params.response_format) + # Dictionary in OpenAI format + elif isinstance(params.response_format, dict): + kwargs["response_format"] = ResponseFormat.model_validate(params.response_format) completion_kwargs = self._convert_completion_params(params, **kwargs) From 0bc927baad61a61c10b410b6d52f49bfbcc7c31e Mon Sep 17 00:00:00 2001 From: Hareesh Date: Mon, 13 Oct 2025 11:29:37 +0200 Subject: [PATCH 2/4] Added unit test for flexible response_format --- tests/unit/providers/test_mistral_provider.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/unit/providers/test_mistral_provider.py b/tests/unit/providers/test_mistral_provider.py index 268fea59..cdf87c61 100644 --- a/tests/unit/providers/test_mistral_provider.py +++ b/tests/unit/providers/test_mistral_provider.py @@ -1,6 +1,11 @@ from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from pydantic import BaseModel from any_llm.providers.mistral.utils import _patch_messages +from any_llm.types.completion import CompletionParams def test_patch_messages_noop_when_no_tool_before_user() -> None: @@ -91,3 +96,45 @@ def test_patch_messages_with_multiple_valid_tool_calls() -> None: {"role": "assistant", "content": "OK"}, {"role": "user", "content": "u1"}, ] + + +@pytest.mark.asyncio +async def test_response_format_accepts_pydantic_and_openai_json_schema() -> None: + """Test that response_format accepts both Pydantic BaseModel and Openai json schema formats.""" + pytest.importorskip("mistralai") + from any_llm.providers.mistral.mistral import MistralProvider + + class StructuredOutput(BaseModel): + foo: str + bar: int + + with ( + patch("any_llm.providers.mistral.mistral.Mistral") as mocked_mistral, + patch("any_llm.providers.mistral.mistral.response_format_from_pydantic_model") as mocked_pydantic_converter, + patch("any_llm.providers.mistral.mistral.ResponseFormat") as mocked_response_format, + patch("any_llm.providers.mistral.mistral._create_mistral_completion_from_response") as mocked_converter, + ): + provider = MistralProvider(api_key="test-api-key") + + mock_response = Mock() + mocked_mistral.return_value.chat.complete_async = AsyncMock(return_value=mock_response) + mocked_converter.return_value = Mock() + + await provider._acompletion( + CompletionParams( + model_id="test-model", + messages=[{"role": "user", "content": "Hello"}], + response_format=StructuredOutput, # Test with Pydantic model + ), + ) + mocked_pydantic_converter.assert_called_once_with(StructuredOutput) + + dict_response_format = {"type": "json_object", "schema": StructuredOutput.model_json_schema()} + await provider._acompletion( + CompletionParams( + model_id="test-model", + messages=[{"role": "user", "content": "Hello"}], + response_format=dict_response_format, # Test with OpenAI json schema + ), + ) + mocked_response_format.model_validate.assert_called_once_with(dict_response_format) From c68482ed8ae5f6fbe501624f34039a8c15fca9dd Mon Sep 17 00:00:00 2001 From: Hareesh Date: Mon, 13 Oct 2025 16:31:43 +0200 Subject: [PATCH 3/4] Parameterize the test function --- tests/unit/providers/test_mistral_provider.py | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/tests/unit/providers/test_mistral_provider.py b/tests/unit/providers/test_mistral_provider.py index cdf87c61..9c7826a1 100644 --- a/tests/unit/providers/test_mistral_provider.py +++ b/tests/unit/providers/test_mistral_provider.py @@ -98,16 +98,25 @@ def test_patch_messages_with_multiple_valid_tool_calls() -> None: ] +class StructuredOutput(BaseModel): + foo: str + bar: int + + @pytest.mark.asyncio -async def test_response_format_accepts_pydantic_and_openai_json_schema() -> None: - """Test that response_format accepts both Pydantic BaseModel and Openai json schema formats.""" +@pytest.mark.parametrize( + ("response_format", "expected_converter"), + [ + (StructuredOutput, "response_format_from_pydantic_model"), + ({"type": "json_object", "schema": StructuredOutput.model_json_schema()}, "ResponseFormat"), + ], + ids=["pydantic_model", "openai_json_schema"], +) +async def test_response_format_conversion(response_format: Any, expected_converter: str) -> None: + """Test that response_format is properly converted for both Pydantic and dict formats.""" pytest.importorskip("mistralai") from any_llm.providers.mistral.mistral import MistralProvider - class StructuredOutput(BaseModel): - foo: str - bar: int - with ( patch("any_llm.providers.mistral.mistral.Mistral") as mocked_mistral, patch("any_llm.providers.mistral.mistral.response_format_from_pydantic_model") as mocked_pydantic_converter, @@ -124,17 +133,11 @@ class StructuredOutput(BaseModel): CompletionParams( model_id="test-model", messages=[{"role": "user", "content": "Hello"}], - response_format=StructuredOutput, # Test with Pydantic model + response_format=response_format, ), ) - mocked_pydantic_converter.assert_called_once_with(StructuredOutput) - dict_response_format = {"type": "json_object", "schema": StructuredOutput.model_json_schema()} - await provider._acompletion( - CompletionParams( - model_id="test-model", - messages=[{"role": "user", "content": "Hello"}], - response_format=dict_response_format, # Test with OpenAI json schema - ), - ) - mocked_response_format.model_validate.assert_called_once_with(dict_response_format) + if expected_converter == "response_format_from_pydantic_model": + mocked_pydantic_converter.assert_called_once_with(StructuredOutput) + elif expected_converter == "ResponseFormat": + mocked_response_format.model_validate.assert_called_once_with(response_format) From bb584e75340412af747ebe634800b1a9e0c40acf Mon Sep 17 00:00:00 2001 From: Hareesh Date: Mon, 13 Oct 2025 17:19:55 +0200 Subject: [PATCH 4/4] Not mocking mistral functions for response format conversions --- tests/unit/providers/test_mistral_provider.py | 53 +++++++++++++------ 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/tests/unit/providers/test_mistral_provider.py b/tests/unit/providers/test_mistral_provider.py index 9c7826a1..4acc8ca8 100644 --- a/tests/unit/providers/test_mistral_provider.py +++ b/tests/unit/providers/test_mistral_provider.py @@ -103,31 +103,38 @@ class StructuredOutput(BaseModel): bar: int +openai_json_schema = { + "type": "json_schema", + "json_schema": { + "name": "StructuredOutput", + "schema": {**StructuredOutput.model_json_schema(), "additionalProperties": False}, + "strict": True, + }, +} + + @pytest.mark.asyncio @pytest.mark.parametrize( - ("response_format", "expected_converter"), + "response_format", [ - (StructuredOutput, "response_format_from_pydantic_model"), - ({"type": "json_object", "schema": StructuredOutput.model_json_schema()}, "ResponseFormat"), + StructuredOutput, + openai_json_schema, ], ids=["pydantic_model", "openai_json_schema"], ) -async def test_response_format_conversion(response_format: Any, expected_converter: str) -> None: +async def test_response_format(response_format: Any) -> None: """Test that response_format is properly converted for both Pydantic and dict formats.""" - pytest.importorskip("mistralai") + mistralai = pytest.importorskip("mistralai") from any_llm.providers.mistral.mistral import MistralProvider with ( patch("any_llm.providers.mistral.mistral.Mistral") as mocked_mistral, - patch("any_llm.providers.mistral.mistral.response_format_from_pydantic_model") as mocked_pydantic_converter, - patch("any_llm.providers.mistral.mistral.ResponseFormat") as mocked_response_format, - patch("any_llm.providers.mistral.mistral._create_mistral_completion_from_response") as mocked_converter, + patch("any_llm.providers.mistral.mistral._create_mistral_completion_from_response") as mock_converter, ): provider = MistralProvider(api_key="test-api-key") - mock_response = Mock() - mocked_mistral.return_value.chat.complete_async = AsyncMock(return_value=mock_response) - mocked_converter.return_value = Mock() + mocked_mistral.return_value.chat.complete_async = AsyncMock(return_value=Mock()) + mock_converter.return_value = Mock() await provider._acompletion( CompletionParams( @@ -137,7 +144,23 @@ async def test_response_format_conversion(response_format: Any, expected_convert ), ) - if expected_converter == "response_format_from_pydantic_model": - mocked_pydantic_converter.assert_called_once_with(StructuredOutput) - elif expected_converter == "ResponseFormat": - mocked_response_format.model_validate.assert_called_once_with(response_format) + completion_call_kwargs = mocked_mistral.return_value.chat.complete_async.call_args[1] + assert "response_format" in completion_call_kwargs + + response_format_arg = completion_call_kwargs["response_format"] + assert isinstance(response_format_arg, mistralai.models.responseformat.ResponseFormat) + assert response_format_arg.type == "json_schema" + assert response_format_arg.json_schema.name == "StructuredOutput" + assert response_format_arg.json_schema.strict is True + + expected_schema = { + "properties": { + "foo": {"title": "Foo", "type": "string"}, + "bar": {"title": "Bar", "type": "integer"}, + }, + "required": ["foo", "bar"], + "title": "StructuredOutput", + "type": "object", + "additionalProperties": False, + } + assert response_format_arg.json_schema.schema_definition == expected_schema