libs/partners/mistralai/tests/unit_tests/test_chat_models.py PYTHON 1,165 lines View on github.com → Search inside
1"""Test MistralAI Chat API wrapper."""23import os4from collections.abc import AsyncGenerator, Generator5from typing import TYPE_CHECKING, Any, cast6from unittest.mock import MagicMock, patch78import httpx9import pytest10from langchain_core.callbacks.base import BaseCallbackHandler11from langchain_core.exceptions import (12    ModelAPIError,13    ModelAuthenticationError,14    ModelError,15    ModelInvalidRequestError,16    ModelNotFoundError,17    ModelPermissionDeniedError,18    ModelRateLimitError,19)20from langchain_core.messages import (21    AIMessage,22    AIMessageChunk,23    BaseMessage,24    ChatMessage,25    HumanMessage,26    InvalidToolCall,27    SystemMessage,28    ToolCall,29    ToolMessage,30)31from pydantic import SecretStr3233if TYPE_CHECKING:34    from langchain_core.messages import content as types3536from langchain_mistralai._compat import _convert_to_v1_from_mistral37from langchain_mistralai.chat_models import (  # type: ignore[import]38    ChatMistralAI,39    _araise_on_error,40    _convert_chunk_to_message_chunk,41    _convert_message_to_mistral_chat_message,42    _convert_mistral_chat_message_to_message,43    _convert_tool_call_id_to_mistral_compatible,44    _format_message_content,45    _is_valid_mistral_tool_call_id,46    _raise_on_error,47    _sanitize_chat_completions_content,48)4950os.environ["MISTRAL_API_KEY"] = "foo"515253def _error_response(status_code: int) -> httpx.Response:54    """Build a response with the given status for the error handlers."""55    request = httpx.Request("POST", "https://api.mistral.ai/v1/chat/completions")56    return httpx.Response(status_code, request=request, content=b'{"message": "boom"}')575859def test_sanitize_chat_completions_text_blocks_strips_id() -> None:60    """LangChain auto-generated `id` on text blocks must not reach the wire.6162    Mistral's chat completions endpoint returns 422 with `extra_forbidden`63    on `messages[*].tool.content.list[...].text.id` if not stripped.64    """65    message = ToolMessage(66        content=[{"type": "text", "text": "foo", "id": "lc_abc123"}],67        tool_call_id="abc12345",68    )69    result = _convert_message_to_mistral_chat_message(message)70    assert result["content"] == [{"type": "text", "text": "foo"}]717273def test_sanitize_chat_completions_content_passthrough_string() -> None:74    assert _sanitize_chat_completions_content("hello") == "hello"757677def test_ai_message_reference_metadata_does_not_reach_wire() -> None:78    message = AIMessage(79        content=[80            {"type": "text", "text": "The answer is "},81            {"type": "text", "text": "42", "reference": {"reference_ids": [0]}},82            {"type": "text", "text": "."},83        ],84        response_metadata={"model_provider": "mistralai"},85    )8687    result = _convert_message_to_mistral_chat_message(message)88    assert result["content"] == [89        {"type": "text", "text": "The answer is "},90        {"type": "text", "text": "42"},91        {"type": "text", "text": "."},92    ]939495def test_v1_ai_message_reference_metadata_does_not_reach_wire() -> None:96    message = AIMessage(97        content=[98            {"type": "text", "text": "The answer is "},99            {"type": "text", "text": "42", "reference": {"reference_ids": [0]}},100            {"type": "text", "text": "."},101        ],102        response_metadata={"model_provider": "mistralai", "output_version": "v1"},103    )104105    result = _convert_message_to_mistral_chat_message(message)106    assert result["content"] == [107        {"type": "text", "text": "The answer is "},108        {"type": "text", "text": "42"},109        {"type": "text", "text": "."},110    ]111112113def test_mistralai_model_param() -> None:114    llm = ChatMistralAI(model="foo")  # type: ignore[call-arg]115    assert llm.model == "foo"116117118def test_mistralai_initialization() -> None:119    """Test ChatMistralAI initialization."""120    # Verify that ChatMistralAI can be initialized using a secret key provided121    # as a parameter rather than an environment variable.122    for model in [123        ChatMistralAI(model="test", mistral_api_key="test"),  # type: ignore[call-arg, call-arg]124        ChatMistralAI(model="test", api_key="test"),  # type: ignore[call-arg, arg-type]125    ]:126        assert cast("SecretStr", model.mistral_api_key).get_secret_value() == "test"127128129@pytest.mark.parametrize(130    ("model", "expected_url"),131    [132        (ChatMistralAI(model="test"), "https://api.mistral.ai/v1"),  # type: ignore[call-arg, arg-type]133        (ChatMistralAI(model="test", endpoint="baz"), "baz"),  # type: ignore[call-arg, arg-type]134    ],135)136def test_mistralai_initialization_baseurl(137    model: ChatMistralAI, expected_url: str138) -> None:139    """Test ChatMistralAI initialization."""140    # Verify that ChatMistralAI can be initialized providing endpoint, but also141    # with default142143    assert model.endpoint == expected_url144145146@pytest.mark.parametrize(147    "env_var_name",148    [149        ("MISTRAL_BASE_URL"),150    ],151)152def test_mistralai_initialization_baseurl_env(153    env_var_name: str, monkeypatch: pytest.MonkeyPatch154) -> None:155    """Test ChatMistralAI initialization."""156    # Verify that ChatMistralAI can be initialized using env variable157    monkeypatch.setenv(env_var_name, "boo")158    model = ChatMistralAI(model="test")  # type: ignore[call-arg]159    assert model.endpoint == "boo"160161162@pytest.mark.parametrize(163    ("message", "expected"),164    [165        (166            SystemMessage(content="Hello"),167            {"role": "system", "content": "Hello"},168        ),169        (170            HumanMessage(content="Hello"),171            {"role": "user", "content": "Hello"},172        ),173        (174            AIMessage(content="Hello"),175            {"role": "assistant", "content": "Hello"},176        ),177        (178            AIMessage(content="{", additional_kwargs={"prefix": True}),179            {"role": "assistant", "content": "{", "prefix": True},180        ),181        (182            ChatMessage(role="assistant", content="Hello"),183            {"role": "assistant", "content": "Hello"},184        ),185    ],186)187def test_convert_message_to_mistral_chat_message(188    message: BaseMessage, expected: dict189) -> None:190    result = _convert_message_to_mistral_chat_message(message)191    assert result == expected192193194@pytest.mark.parametrize(195    ("content", "expected"),196    [197        ("hello", "hello"),198        ("", ""),199        (None, None),200        ([], []),201    ],202)203def test_format_message_content_passthrough_non_list(204    content: Any, expected: Any205) -> None:206    """Strings, None, and empty lists pass through `_format_message_content`."""207    assert _format_message_content(content) == expected208209210@pytest.mark.parametrize(211    ("block", "expected"),212    [213        (214            {"type": "image", "url": "https://example.com/img.png"},215            {216                "type": "image_url",217                "image_url": {"url": "https://example.com/img.png"},218            },219        ),220        (221            {"type": "image", "base64": "abc123", "mime_type": "image/jpeg"},222            {223                "type": "image_url",224                "image_url": {"url": "data:image/jpeg;base64,abc123"},225            },226        ),227        (228            {229                "type": "image",230                "source_type": "url",231                "url": "https://example.com/v0.png",232            },233            {234                "type": "image_url",235                "image_url": {"url": "https://example.com/v0.png"},236            },237        ),238        (239            {240                "type": "image",241                "source_type": "base64",242                "data": "v0data",243                "mime_type": "image/png",244            },245            {246                "type": "image_url",247                "image_url": {"url": "data:image/png;base64,v0data"},248            },249        ),250    ],251)252def test_format_message_content_translates_image_blocks(253    block: dict, expected: dict254) -> None:255    """v0 and v1 canonical image blocks translate to Mistral's `image_url` shape."""256    assert _format_message_content([block]) == [expected]257258259@pytest.mark.parametrize(260    "block",261    [262        {"type": "text", "text": "hello"},263        {"type": "image_url", "image_url": {"url": "https://example.com/img.png"}},264        {"type": "image_url", "image_url": "https://example.com/img.png"},265    ],266)267def test_format_message_content_passthrough_known_blocks(block: dict) -> None:268    """Already-translated wire blocks and text blocks pass through unchanged."""269    assert _format_message_content([block]) == [block]270271272@pytest.mark.parametrize(273    "block_type",274    ["tool_use", "thinking", "reasoning_content", "document_url", "input_audio"],275)276def test_format_message_content_passes_unknown_blocks_through(block_type: str) -> None:277    """Non-canonical blocks pass through; the Mistral API validates them."""278    blocks = [279        {"type": "text", "text": "kept"},280        {"type": block_type, "data": "anything"},281    ]282    assert _format_message_content(blocks) == blocks283284285def test_format_message_content_preserves_order_for_mixed_blocks() -> None:286    """Multiple text + image blocks retain their order — vision prompts depend on it."""287    blocks: list[Any] = [288        {"type": "text", "text": "first"},289        {"type": "image", "url": "https://example.com/a.png"},290        {"type": "text", "text": "between"},291        {"type": "image", "base64": "xyz", "mime_type": "image/png"},292        "trailing string",293    ]294    expected = [295        {"type": "text", "text": "first"},296        {"type": "image_url", "image_url": {"url": "https://example.com/a.png"}},297        {"type": "text", "text": "between"},298        {"type": "image_url", "image_url": {"url": "data:image/png;base64,xyz"}},299        "trailing string",300    ]301    assert _format_message_content(blocks) == expected302303304def test_format_message_content_image_missing_mime_type_raises() -> None:305    """Base64 image without `mime_type` raises via the core translator."""306    with pytest.raises(ValueError, match="mime_type"):307        _format_message_content([{"type": "image", "base64": "abc"}])308309310@pytest.mark.parametrize(311    ("message", "expected"),312    [313        (314            HumanMessage(315                content=[316                    {"type": "text", "text": "What is in this image?"},317                    {"type": "image", "url": "https://example.com/img.png"},318                ]319            ),320            {321                "role": "user",322                "content": [323                    {"type": "text", "text": "What is in this image?"},324                    {325                        "type": "image_url",326                        "image_url": {"url": "https://example.com/img.png"},327                    },328                ],329            },330        ),331        (332            HumanMessage(333                content=[334                    {"type": "text", "text": "Describe this image."},335                    {336                        "type": "image",337                        "base64": "abc123",338                        "mime_type": "image/png",339                    },340                ]341            ),342            {343                "role": "user",344                "content": [345                    {"type": "text", "text": "Describe this image."},346                    {347                        "type": "image_url",348                        "image_url": {"url": "data:image/png;base64,abc123"},349                    },350                ],351            },352        ),353    ],354)355def test_convert_human_message_with_images(356    message: BaseMessage, expected: dict357) -> None:358    result = _convert_message_to_mistral_chat_message(message)359    assert result == expected360361362def test_convert_human_message_with_string_content_unchanged() -> None:363    """Plain string `HumanMessage` content is not wrapped or modified."""364    result = _convert_message_to_mistral_chat_message(HumanMessage(content="hi"))365    assert result == {"role": "user", "content": "hi"}366367368def _make_completion_response_from_token(token: str) -> dict:369    return {370        "id": "abc123",371        "model": "fake_model",372        "choices": [373            {374                "index": 0,375                "delta": {"content": token},376                "finish_reason": None,377            }378        ],379    }380381382def mock_chat_stream(*args: Any, **kwargs: Any) -> Generator:383    def it() -> Generator:384        for token in ["Hello", " how", " can", " I", " help", "?"]:385            yield _make_completion_response_from_token(token)386387    return it()388389390async def mock_chat_astream(*args: Any, **kwargs: Any) -> AsyncGenerator:391    async def it() -> AsyncGenerator:392        for token in ["Hello", " how", " can", " I", " help", "?"]:393            yield _make_completion_response_from_token(token)394395    return it()396397398class MyCustomHandler(BaseCallbackHandler):399    last_token: str = ""400401    def on_llm_new_token(402        self, token: str | list[str | dict[str, Any]], **kwargs: Any403    ) -> None:404        if isinstance(token, str):405            self.last_token = token406407408@patch(409    "langchain_mistralai.chat_models.ChatMistralAI.completion_with_retry",410    new=mock_chat_stream,411)412def test_stream_with_callback() -> None:413    callback = MyCustomHandler()414    chat = ChatMistralAI(callbacks=[callback])415    for token in chat.stream("Hello"):416        assert callback.last_token == token.content417418419@patch("langchain_mistralai.chat_models.acompletion_with_retry", new=mock_chat_astream)420async def test_astream_with_callback() -> None:421    callback = MyCustomHandler()422    chat = ChatMistralAI(callbacks=[callback])423    async for token in chat.astream("Hello"):424        assert callback.last_token == token.content425426427def test__convert_dict_to_message_tool_call() -> None:428    raw_tool_call = {429        "id": "ssAbar4Dr",430        "function": {431            "arguments": '{"name": "Sally", "hair_color": "green"}',432            "name": "GenerateUsername",433        },434    }435    message = {"role": "assistant", "content": "", "tool_calls": [raw_tool_call]}436    result = _convert_mistral_chat_message_to_message(message)437    expected_output = AIMessage(438        content="",439        additional_kwargs={"tool_calls": [raw_tool_call]},440        tool_calls=[441            ToolCall(442                name="GenerateUsername",443                args={"name": "Sally", "hair_color": "green"},444                id="ssAbar4Dr",445                type="tool_call",446            )447        ],448        response_metadata={"model_provider": "mistralai"},449    )450    assert result == expected_output451    assert _convert_message_to_mistral_chat_message(expected_output) == message452453    # Test malformed tool call454    raw_tool_calls = [455        {456            "id": "pL5rEGzxe",457            "function": {458                "arguments": '{"name": "Sally", "hair_color": "green"}',459                "name": "GenerateUsername",460            },461        },462        {463            "id": "ssAbar4Dr",464            "function": {465                "arguments": "oops",466                "name": "GenerateUsername",467            },468        },469    ]470    message = {"role": "assistant", "content": "", "tool_calls": raw_tool_calls}471    result = _convert_mistral_chat_message_to_message(message)472    expected_output = AIMessage(473        content="",474        additional_kwargs={"tool_calls": raw_tool_calls},475        invalid_tool_calls=[476            InvalidToolCall(477                name="GenerateUsername",478                args="oops",479                error="Function GenerateUsername arguments:\n\noops\n\nare not valid JSON. Received JSONDecodeError Expecting value: line 1 column 1 (char 0)\nFor troubleshooting, visit: https://docs.langchain.com/oss/python/langchain/errors/OUTPUT_PARSING_FAILURE ",  # noqa: E501480                id="ssAbar4Dr",481                type="invalid_tool_call",482            ),483        ],484        tool_calls=[485            ToolCall(486                name="GenerateUsername",487                args={"name": "Sally", "hair_color": "green"},488                id="pL5rEGzxe",489                type="tool_call",490            ),491        ],492        response_metadata={"model_provider": "mistralai"},493    )494    assert result == expected_output495    assert _convert_message_to_mistral_chat_message(expected_output) == message496497498def test__convert_dict_to_message_tool_call_with_null_content() -> None:499    raw_tool_call = {500        "id": "ssAbar4Dr",501        "function": {502            "arguments": '{"name": "Sally", "hair_color": "green"}',503            "name": "GenerateUsername",504        },505    }506    message = {"role": "assistant", "content": None, "tool_calls": [raw_tool_call]}507    result = _convert_mistral_chat_message_to_message(message)508    expected_output = AIMessage(509        content="",510        additional_kwargs={"tool_calls": [raw_tool_call]},511        tool_calls=[512            ToolCall(513                name="GenerateUsername",514                args={"name": "Sally", "hair_color": "green"},515                id="ssAbar4Dr",516                type="tool_call",517            )518        ],519        response_metadata={"model_provider": "mistralai"},520    )521    assert result == expected_output522523524def test__convert_dict_to_message_with_missing_content() -> None:525    raw_tool_call = {526        "id": "ssAbar4Dr",527        "function": {528            "arguments": '{"query": "test search"}',529            "name": "search",530        },531    }532    message = {"role": "assistant", "tool_calls": [raw_tool_call]}533    result = _convert_mistral_chat_message_to_message(message)534    expected_output = AIMessage(535        content="",536        additional_kwargs={"tool_calls": [raw_tool_call]},537        tool_calls=[538            ToolCall(539                name="search",540                args={"query": "test search"},541                id="ssAbar4Dr",542                type="tool_call",543            )544        ],545        response_metadata={"model_provider": "mistralai"},546    )547    assert result == expected_output548549550def test__convert_dict_to_message_with_citations() -> None:551    """Reference blocks normalized to text blocks with reference metadata."""552    cited_text = "the temperature is 20 degrees C"553    raw_content: list[str | dict] = [554        {"type": "text", "text": "According to the document, "},555        {"type": "reference", "reference_ids": [0], "text": cited_text},556        {"type": "text", "text": " on average."},557    ]558    message = {"role": "assistant", "content": raw_content}559    result = _convert_mistral_chat_message_to_message(message)560561    assert isinstance(result.content, list)562    content = result.content563    # The reference block is normalized to type="text" so .text includes it564    assert content[0] == {"type": "text", "text": "According to the document, "}565    assert isinstance(content[1], dict)566    block_1 = content[1]567    assert block_1["type"] == "text"568    assert block_1["text"] == cited_text569    assert block_1["reference"] == {"reference_ids": [0]}570    assert content[2] == {"type": "text", "text": " on average."}571    assert result.response_metadata["model_provider"] == "mistralai"572    assert "citations" not in result.response_metadata573574575def test__convert_dict_to_message_citations_text_accessor() -> None:576    """message.text includes cited spans from normalized reference blocks."""577    cited_text = "the temperature is 20 degrees C"578    raw_content: list[str | dict] = [579        {"type": "text", "text": "According to the document, "},580        {"type": "reference", "reference_ids": [0], "text": cited_text},581        {"type": "text", "text": " on average."},582    ]583    message = {"role": "assistant", "content": raw_content}584    result = _convert_mistral_chat_message_to_message(message)585586    # .text should include all visible text, including the cited span587    assert str(result.text) == (588        "According to the document, the temperature is 20 degrees C on average."589    )590591592def test__convert_dict_to_message_citations_to_content_blocks() -> None:593    """content_blocks translates reference metadata to TextContentBlock."""594    cited_text = "the temperature is 20 degrees C"595    raw_content: list[str | dict] = [596        {"type": "text", "text": "According to the document, "},597        {"type": "reference", "reference_ids": [0], "text": cited_text},598        {"type": "text", "text": " on average."},599    ]600    message = {"role": "assistant", "content": raw_content}601    result = _convert_mistral_chat_message_to_message(message)602603    assert isinstance(result, AIMessage)604    blocks = _convert_to_v1_from_mistral(result)605    assert len(blocks) == 3606607    # First block: plain text608    assert blocks[0]["type"] == "text"609    assert blocks[0]["text"] == "According to the document, "610611    # Second block: text with citation annotation612    block_1 = cast("types.TextContentBlock", blocks[1])613    assert block_1["type"] == "text"614    assert block_1["text"] == cited_text615    annotations = block_1["annotations"]616    assert len(annotations) == 1617    assert annotations[0]["type"] == "citation"618    assert "cited_text" not in annotations[0]619    assert annotations[0]["extras"]["reference_ids"] == [0]620621    # Third block: plain text622    assert blocks[2]["type"] == "text"623    assert blocks[2]["text"] == " on average."624625626def test_create_chat_result_with_citations() -> None:627    """Citations are normalized to text blocks with reference metadata in .content."""628    chat = ChatMistralAI()629    raw_citation = {"type": "reference", "reference_ids": [0], "text": "42"}630    raw_content: list[str | dict] = [631        {"type": "text", "text": "The answer is "},632        raw_citation,633        {"type": "text", "text": "."},634    ]635    response = {636        "choices": [637            {638                "message": {639                    "role": "assistant",640                    "content": raw_content,641                },642                "finish_reason": "stop",643            }644        ]645    }646647    result = chat._create_chat_result(response)648    message = result.generations[0].message649650    assert isinstance(message.content, list)651    content = message.content652    # The reference block is normalized; .text includes the cited span653    assert isinstance(content[1], dict)654    block_1 = content[1]655    assert block_1["type"] == "text"656    assert block_1["text"] == "42"657    assert block_1["reference"] == {"reference_ids": [0]}658    assert str(message.text) == "The answer is 42."659    assert "citations" not in message.response_metadata660661662def test__convert_chunk_to_message_chunk_with_citations() -> None:663    """Streaming reference blocks are normalized to text blocks in chunk .content."""664    raw_citation = {"type": "reference", "reference_ids": [0], "text": "42"}665    text_chunk = {666        "choices": [667            {668                "delta": {"role": "assistant", "content": "The answer is "},669                "finish_reason": None,670            }671        ],672    }673    reference_chunk = {674        "choices": [675            {676                "delta": {677                    "role": "assistant",678                    "content": [679                        dict(raw_citation),680                    ],681                },682                "finish_reason": "stop",683            }684        ],685        "model": "mistral-small-latest",686    }687688    result_1, index, index_type = _convert_chunk_to_message_chunk(689        text_chunk, AIMessageChunk, -1, "", None690    )691    result_2, _, _ = _convert_chunk_to_message_chunk(692        reference_chunk, AIMessageChunk, index, index_type, None693    )694695    assert isinstance(result_2, AIMessageChunk)696    # Reference block is normalized to type="text" with reference metadata697    assert result_2.content == [698        {"type": "text", "text": "42", "reference": {"reference_ids": [0]}, "index": 0},699    ]700    assert "citations" not in result_2.response_metadata701702    full = result_1 + result_2703    assert isinstance(full, AIMessageChunk)704    assert "citations" not in full.response_metadata705    assert full.response_metadata["finish_reason"] == "stop"706    # .text includes the cited span707    assert str(full.text) == "The answer is 42"708709710def test_citation_round_trip() -> None:711    """Round-trip through v1 preserves text and reference metadata."""712    from langchain_mistralai._compat import (713        _convert_from_v1_to_mistral,714        _convert_to_v1_from_mistral,715    )716717    # Start with normalized content (as produced by _convert_mistral_chat_message)718    original_content: list[str | dict] = [719        {"type": "text", "text": "The answer is "},720        {"type": "text", "text": "42", "reference": {"reference_ids": [0]}},721        {"type": "text", "text": "."},722    ]723    message = AIMessage(content=original_content)724    v1_blocks = _convert_to_v1_from_mistral(message)725    round_tripped = _convert_from_v1_to_mistral(v1_blocks, "mistralai")726727    # Should have exactly 3 blocks, no duplication of cited text728    assert len(round_tripped) == 3729    assert round_tripped[0] == {"type": "text", "text": "The answer is "}730    assert isinstance(round_tripped[1], dict)731    block_1 = round_tripped[1]732    assert block_1["type"] == "text"733    assert block_1["text"] == "42"734    assert block_1["reference"] == {"reference_ids": [0]}735    assert round_tripped[2] == {"type": "text", "text": "."}736737738def test_citation_round_trip_preserves_extra_fields() -> None:739    """Extra provider fields on reference metadata survive the round-trip."""740    from langchain_mistralai._compat import (741        _convert_from_v1_to_mistral,742        _convert_to_v1_from_mistral,743    )744745    original_content: list[str | dict] = [746        {"type": "text", "text": "cited span", "reference": {"reference_ids": [1, 2]}},747    ]748    message = AIMessage(content=original_content)749    v1_blocks = _convert_to_v1_from_mistral(message)750    round_tripped = _convert_from_v1_to_mistral(v1_blocks, "mistralai")751752    assert len(round_tripped) == 1753    assert isinstance(round_tripped[0], dict)754    block_0 = round_tripped[0]755    assert block_0["type"] == "text"756    assert block_0["text"] == "cited span"757    assert block_0["reference"] == {"reference_ids": [1, 2]}758759760def test_citation_round_trip_preserves_annotated_response_text() -> None:761    """Serializing citations preserves block text, not citation source excerpts."""762    from langchain_mistralai._compat import _convert_from_v1_to_mistral763764    content: list[types.ContentBlock] = [765        {766            "type": "text",767            "text": "The answer is 42.",768            "annotations": [769                {770                    "type": "citation",771                    "cited_text": "source excerpt mentioning 42",772                    "extras": {"reference_ids": [0]},773                }774            ],775        }776    ]777    round_tripped = _convert_from_v1_to_mistral(content, "mistralai")778779    assert len(round_tripped) == 1780    assert isinstance(round_tripped[0], dict)781    block = round_tripped[0]782    assert block["type"] == "text"783    assert block["text"] == "The answer is 42."784    assert block["reference"]["reference_ids"] == [0]785    assert block["reference"]["cited_text"] == "source excerpt mentioning 42"786787788def test_citation_streaming_v1_reference_gets_separate_index() -> None:789    """Reference chunks do not merge into surrounding v1 text block indexes."""790    text_chunk = {791        "choices": [792            {793                "delta": {"role": "assistant", "content": "The answer is "},794                "finish_reason": None,795            }796        ],797    }798    reference_chunk = {799        "choices": [800            {801                "delta": {802                    "role": "assistant",803                    "content": [804                        {"type": "reference", "reference_ids": [0], "text": "42"},805                    ],806                },807                "finish_reason": "stop",808            }809        ],810        "model": "mistral-small-latest",811    }812813    result_1, index, index_type = _convert_chunk_to_message_chunk(814        text_chunk, AIMessageChunk, -1, "", "v1"815    )816    result_2, _, _ = _convert_chunk_to_message_chunk(817        reference_chunk, AIMessageChunk, index, index_type, "v1"818    )819820    assert result_1.content == [{"type": "text", "text": "The answer is ", "index": 0}]821    assert result_2.content == [822        {"type": "text", "text": "42", "reference": {"reference_ids": [0]}, "index": 1},823    ]824825826def test_citation_streaming_accumulated_content() -> None:827    """Streaming chunks accumulate normalized text blocks in full.content."""828    raw_citation = {"type": "reference", "reference_ids": [0], "text": "42"}829    text_chunk = {830        "choices": [831            {832                "delta": {"role": "assistant", "content": "The answer is "},833                "finish_reason": None,834            }835        ],836    }837    reference_chunk = {838        "choices": [839            {840                "delta": {841                    "role": "assistant",842                    "content": [dict(raw_citation)],843                },844                "finish_reason": "stop",845            }846        ],847        "model": "mistral-small-latest",848    }849850    result_1, index, index_type = _convert_chunk_to_message_chunk(851        text_chunk, AIMessageChunk, -1, "", None852    )853    result_2, _, _ = _convert_chunk_to_message_chunk(854        reference_chunk, AIMessageChunk, index, index_type, None855    )856857    full = result_1 + result_2858    # full.content should contain both the text and the normalized reference block859    assert isinstance(full.content, list)860    assert any(861        isinstance(b, dict)862        and b.get("type") == "text"863        and b.get("text") == "42"864        and isinstance(ref := b.get("reference"), dict)865        and ref.get("reference_ids") == [0]866        for b in full.content867    )868869870def test_citation_index_not_in_extras() -> None:871    """Streaming index should not leak into citation extras."""872    from langchain_mistralai._compat import _convert_to_v1_from_mistral873874    content: list[str | dict] = [875        {"type": "text", "text": "42", "reference": {"reference_ids": [0]}, "index": 0},876    ]877    message = AIMessageChunk(content=content)878    blocks = _convert_to_v1_from_mistral(message)879    assert len(blocks) == 1880    block_0 = cast("types.TextContentBlock", blocks[0])881    annotation = block_0["annotations"][0]882    extras = annotation.get("extras", {})883    assert isinstance(extras, dict)884    assert "index" not in extras885886887def test_citation_no_text_in_reference() -> None:888    """A reference block with no text still converts without error."""889    from langchain_mistralai._compat import _convert_to_v1_from_mistral890891    content: list[str | dict] = [892        {"type": "text", "text": "", "reference": {"reference_ids": [0]}},893    ]894    message = AIMessage(content=content)895    blocks = _convert_to_v1_from_mistral(message)896    assert len(blocks) == 1897    assert blocks[0]["type"] == "text"898    assert blocks[0]["text"] == ""899    block_0 = cast("types.TextContentBlock", blocks[0])900    assert "cited_text" not in block_0["annotations"][0]901902903def test_citation_empty_reference_metadata_still_adds_annotation() -> None:904    """Presence of reference metadata is the signal, even if the metadata is empty."""905    from langchain_mistralai._compat import _convert_to_v1_from_mistral906907    message = AIMessage(content=[{"type": "text", "text": "42", "reference": {}}])908    blocks = _convert_to_v1_from_mistral(message)909910    block_0 = cast("types.TextContentBlock", blocks[0])911    assert block_0["annotations"] == [{"type": "citation"}]912913914def test_malformed_annotation_does_not_crash() -> None:915    """Malformed annotations are skipped, not raised."""916    from langchain_mistralai._compat import _convert_from_v1_to_mistral917918    content: list = [919        {920            "type": "text",921            "text": "hello",922            "annotations": [923                None,  # not a dict924                {"type": "unknown"},  # unrecognized type925                {"type": "citation", "cited_text": "cited"},  # valid926            ],927        }928    ]929    result = _convert_from_v1_to_mistral(content, "mistralai")930    # The valid citation produces a text block with reference metadata;931    # the text block is not appended because a reference was emitted.932    assert len(result) == 1933    assert isinstance(result[0], dict)934    block_0 = result[0]935    assert block_0["type"] == "text"936    assert block_0["text"] == "hello"937    assert "reference" in block_0938939940def test_custom_token_counting() -> None:941    def token_encoder(text: str) -> list[int]:942        return [1, 2, 3]943944    llm = ChatMistralAI(custom_get_token_ids=token_encoder)945    assert llm.get_token_ids("foo") == [1, 2, 3]946947948def test_tool_id_conversion() -> None:949    assert _is_valid_mistral_tool_call_id("ssAbar4Dr")950    assert not _is_valid_mistral_tool_call_id("abc123")951    assert not _is_valid_mistral_tool_call_id("call_JIIjI55tTipFFzpcP8re3BpM")952953    result_map = {954        "ssAbar4Dr": "ssAbar4Dr",955        "abc123": "pL5rEGzxe",956        "call_JIIjI55tTipFFzpcP8re3BpM": "8kxAQvoED",957    }958    for input_id, expected_output in result_map.items():959        assert _convert_tool_call_id_to_mistral_compatible(input_id) == expected_output960        assert _is_valid_mistral_tool_call_id(expected_output)961962963def test_extra_kwargs() -> None:964    # Check that foo is saved in extra_kwargs.965    with pytest.warns(UserWarning, match="foo is not default parameter"):966        llm = ChatMistralAI(model="my-model", foo=3, max_tokens=10)  # type: ignore[call-arg]967    assert llm.max_tokens == 10968    assert llm.model_kwargs == {"foo": 3}969970    # Test that if extra_kwargs are provided, they are added to it.971    with pytest.warns(UserWarning, match="foo is not default parameter"):972        llm = ChatMistralAI(model="my-model", foo=3, model_kwargs={"bar": 2})  # type: ignore[call-arg]973    assert llm.model_kwargs == {"foo": 3, "bar": 2}974975    # Test that if provided twice it errors976    with pytest.raises(ValueError):977        ChatMistralAI(model="my-model", foo=3, model_kwargs={"foo": 2})  # type: ignore[call-arg]978979980def test_stop_stored_as_field() -> None:981    """`stop` is a first-class field, not routed into `model_kwargs`."""982    llm = ChatMistralAI(model="my-model", stop=["END"])  # type: ignore[call-arg]983    assert llm.stop == ["END"]984    assert "stop" not in llm.model_kwargs985986987def test_create_message_dicts_sends_instance_stop() -> None:988    """Instance-level `stop` is forwarded to the request params."""989    llm = ChatMistralAI(model="my-model", stop=["END"])  # type: ignore[call-arg]990    _, params = llm._create_message_dicts([HumanMessage("hi")], None)991    assert params["stop"] == ["END"]992993994def test_create_message_dicts_per_call_stop_overrides_instance() -> None:995    """A per-call `stop` (including an empty list) overrides the instance value."""996    llm = ChatMistralAI(model="my-model", stop=["END"])  # type: ignore[call-arg]997    # A non-empty per-call value wins over the instance default.998    _, params = llm._create_message_dicts([HumanMessage("hi")], ["STOP"])999    assert params["stop"] == ["STOP"]10001001    # An explicit empty list overrides the instance default and is treated as1002    # "no stop sequences", so it is omitted from the request rather than sent1003    # as an empty array (which the API would reject).1004    _, params = llm._create_message_dicts([HumanMessage("hi")], [])1005    assert "stop" not in params100610071008def test_create_message_dicts_omits_stop_when_unset() -> None:1009    """No `stop` field and no per-call value means `stop` is not sent."""1010    llm = ChatMistralAI(model="my-model")  # type: ignore[call-arg]1011    _, params = llm._create_message_dicts([HumanMessage("hi")], None)1012    assert "stop" not in params101310141015def test_get_ls_params_stop_precedence() -> None:1016    """`_get_ls_params` records instance `stop` and lets a per-call value win."""1017    llm = ChatMistralAI(model="my-model", stop=["END"])  # type: ignore[call-arg]1018    assert llm._get_ls_params().get("ls_stop") == ["END"]1019    assert llm._get_ls_params(stop=["STOP"]).get("ls_stop") == ["STOP"]10201021    # Without an instance default and no per-call value, `ls_stop` is omitted.1022    llm_no_stop = ChatMistralAI(model="my-model")  # type: ignore[call-arg]1023    assert "ls_stop" not in llm_no_stop._get_ls_params()102410251026def test_retry_with_failure_then_success() -> None:1027    """Test retry mechanism works correctly when fiest request fails, second succeed."""1028    # Create a real ChatMistralAI instance1029    chat = ChatMistralAI(max_retries=3)10301031    # Set up the actual retry mechanism (not just mocking it)1032    # We'll track how many times the function is called1033    call_count = 010341035    def mock_post(*args: Any, **kwargs: Any) -> MagicMock:1036        nonlocal call_count1037        call_count += 110381039        if call_count == 1:1040            msg = "Connection error"1041            raise httpx.RequestError(msg, request=MagicMock())10421043        mock_response = MagicMock()1044        mock_response.status_code = 2001045        mock_response.json.return_value = {1046            "choices": [1047                {1048                    "message": {1049                        "role": "assistant",1050                        "content": "Hello!",1051                    },1052                    "finish_reason": "stop",1053                }1054            ],1055            "usage": {1056                "prompt_tokens": 1,1057                "completion_tokens": 1,1058                "total_tokens": 2,1059            },1060        }1061        return mock_response10621063    with patch.object(chat.client, "post", side_effect=mock_post):1064        result = chat.invoke("Hello")1065        assert result.content == "Hello!"1066        assert call_count == 2, f"Expected 2 calls, but got {call_count}"106710681069def test_no_duplicate_tool_calls_when_multiple_tools() -> None:1070    """1071    Tests whether the conversion of an AIMessage with more than one tool call1072    to a Mistral assistant message correctly returns each tool call exactly1073    once in the final payload.10741075    The current implementation uses a faulty for loop which produces N*N entries in the1076    final tool_calls array of the payload (and thus duplicates tool call ids).1077    """1078    msg = AIMessage(1079        content="",  # content should be blank when tool_calls are present1080        tool_calls=[1081            ToolCall(name="tool_a", args={"x": 1}, id="id_a", type="tool_call"),1082            ToolCall(name="tool_b", args={"y": 2}, id="id_b", type="tool_call"),1083        ],1084        response_metadata={"model_provider": "mistralai"},1085    )10861087    mistral_msg = _convert_message_to_mistral_chat_message(msg)10881089    assert mistral_msg["role"] == "assistant"1090    assert "tool_calls" in mistral_msg, "Expected tool_calls to be present."10911092    tool_calls = mistral_msg["tool_calls"]1093    # With the bug, this would be 4 (2x2); we expect exactly 2 entries.1094    assert len(tool_calls) == 2, f"Expected 2 tool calls, got {len(tool_calls)}"10951096    # Ensure there are no duplicate ids1097    ids = [tc.get("id") for tc in tool_calls if isinstance(tc, dict)]1098    assert len(ids) == 21099    assert len(set(ids)) == 2, f"Duplicate tool call IDs found: {ids}"110011011102def test_profile() -> None:1103    model = ChatMistralAI(model="mistral-large-latest")  # type: ignore[call-arg]1104    assert model.profile110511061107def test_metadata_versions() -> None:1108    """Test that metadata reports the correct version info."""1109    llm = ChatMistralAI(model="foo")  # type: ignore[call-arg]1110    assert llm.metadata is not None1111    versions = llm.metadata["lc_versions"]1112    assert "langchain-core" in versions1113    assert "langchain-mistralai" in versions111411151116@pytest.mark.parametrize(1117    ("status_code", "model_error_type", "is_retryable"),1118    [1119        (400, ModelInvalidRequestError, False),1120        (401, ModelAuthenticationError, False),1121        (403, ModelPermissionDeniedError, False),1122        (404, ModelNotFoundError, False),1123        (422, ModelInvalidRequestError, False),1124        (429, ModelRateLimitError, True),1125        (500, ModelAPIError, True),1126        (503, ModelAPIError, True),1127    ],1128)1129def test_error_classification(1130    status_code: int,1131    model_error_type: type[ModelError],1132    *,1133    is_retryable: bool,1134) -> None:1135    """Errors are raised as both `httpx.HTTPStatusError` and the LangChain type."""1136    response = _error_response(status_code)11371138    with pytest.raises(httpx.HTTPStatusError) as exc_info:1139        _raise_on_error(response)11401141    assert isinstance(exc_info.value, model_error_type)1142    assert exc_info.value.is_retryable is is_retryable1143    assert exc_info.value.response.status_code == status_code114411451146async def test_error_classification_async() -> None:1147    """The async response handler classifies errors the same way."""1148    with pytest.raises(httpx.HTTPStatusError) as exc_info:1149        await _araise_on_error(_error_response(429))11501151    assert isinstance(exc_info.value, ModelRateLimitError)115211531154def test_unclassified_status_stays_a_plain_status_error() -> None:1155    """Status codes outside the taxonomy keep the previous behavior."""1156    with pytest.raises(httpx.HTTPStatusError) as exc_info:1157        _raise_on_error(_error_response(409))11581159    assert not isinstance(exc_info.value, ModelError)116011611162def test_success_response_does_not_raise() -> None:1163    """A non-error response is left alone."""1164    _raise_on_error(_error_response(200))

Code quality findings 47

Ensure functions have docstrings for documentation
missing-docstring
def test_sanitize_chat_completions_content_passthrough_string() -> None:
Ensure functions have docstrings for documentation
missing-docstring
def test_ai_message_reference_metadata_does_not_reach_wire() -> None:
Ensure functions have docstrings for documentation
missing-docstring
def test_v1_ai_message_reference_metadata_does_not_reach_wire() -> None:
Ensure functions have docstrings for documentation
missing-docstring
def test_mistralai_model_param() -> None:
Ensure functions have docstrings for documentation
missing-docstring
def test_mistralai_initialization_baseurl(
Ensure functions have docstrings for documentation
missing-docstring
def test_mistralai_initialization_baseurl_env(
Ensure functions have docstrings for documentation
missing-docstring
def test_convert_message_to_mistral_chat_message(
Ensure functions have docstrings for documentation
missing-docstring
def test_format_message_content_passthrough_non_list(
Ensure functions have docstrings for documentation
missing-docstring
def test_format_message_content_translates_image_blocks(
Ensure functions have docstrings for documentation
missing-docstring
def test_convert_human_message_with_images(
Ensure functions have docstrings for documentation
missing-docstring
def mock_chat_stream(*args: Any, **kwargs: Any) -> Generator:
Ensure functions have docstrings for documentation
missing-docstring
def it() -> Generator:
Ensure functions have docstrings for documentation
missing-docstring
async def mock_chat_astream(*args: Any, **kwargs: Any) -> AsyncGenerator:
Ensure functions have docstrings for documentation
missing-docstring
async def it() -> AsyncGenerator:
Ensure functions have docstrings for documentation
missing-docstring
def on_llm_new_token(
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if isinstance(token, str):
Ensure functions have docstrings for documentation
missing-docstring
def test_stream_with_callback() -> None:
Ensure functions have docstrings for documentation
missing-docstring
async def test_astream_with_callback() -> None:
Ensure functions have docstrings for documentation
missing-docstring
def test__convert_dict_to_message_tool_call() -> None:
Ensure functions have docstrings for documentation
missing-docstring
def test__convert_dict_to_message_tool_call_with_null_content() -> None:
Ensure functions have docstrings for documentation
missing-docstring
def test__convert_dict_to_message_with_missing_content() -> None:
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
assert isinstance(result.content, list)
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
assert isinstance(content[1], dict)
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
assert isinstance(result, AIMessage)
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
assert isinstance(message.content, list)
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
assert isinstance(content[1], dict)
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
assert isinstance(result_2, AIMessageChunk)
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
assert isinstance(full, AIMessageChunk)
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
assert isinstance(round_tripped[1], dict)
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
assert isinstance(round_tripped[0], dict)
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
assert isinstance(round_tripped[0], dict)
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
assert isinstance(full.content, list)
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
isinstance(b, dict)
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
and isinstance(ref := b.get("reference"), dict)
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
assert isinstance(extras, dict)
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
assert isinstance(result[0], dict)
Ensure functions have docstrings for documentation
missing-docstring
def test_custom_token_counting() -> None:
Ensure functions have docstrings for documentation
missing-docstring
def token_encoder(text: str) -> list[int]:
Ensure functions have docstrings for documentation
missing-docstring
def test_tool_id_conversion() -> None:
Ensure functions have docstrings for documentation
missing-docstring
def test_extra_kwargs() -> None:
Ensure functions have docstrings for documentation
missing-docstring
def mock_post(*args: Any, **kwargs: Any) -> MagicMock:
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
ids = [tc.get("id") for tc in tool_calls if isinstance(tc, dict)]
Ensure functions have docstrings for documentation
missing-docstring
def test_profile() -> None:
Ensure functions have docstrings for documentation
missing-docstring
def test_error_classification(
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
assert isinstance(exc_info.value, model_error_type)
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
assert isinstance(exc_info.value, ModelRateLimitError)
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
assert not isinstance(exc_info.value, ModelError)

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.