From 35fad0abf367fa0bd0b46d82ce189f78fa5d37f2 Mon Sep 17 00:00:00 2001 From: ElDjeee Date: Tue, 7 Jul 2026 16:51:18 +0200 Subject: [PATCH] test(mistral): cover content-part flattening, get_models errors, and cost dispatch Codecov flagged 16 uncovered lines across the PR. Each gap was a real untested behavior rather than dead code, so this covers them with meaningful assertions instead of coverage filler: OpenAI content-part message lists must flatten to their text with non-text and malformed parts dropped, an assistant turn with content=None plus tool_calls (the OpenAI SDK shape) must map to a function.call entry without emitting an empty message entry, a message.output with null content must transform gracefully, get_models must raise cleanly without credentials and on non-2xx responses, the shared get_cost_for_web_search_request dispatcher must route provider mistral to the new cost calculator, and non-dict tool entries in a request body must not crash allowlist extraction or shadow real tools that need checking. The two mistral files codecov called out are now at 100% line coverage locally --- ...st_mistral_conversations_transformation.py | 66 +++++++++++++++++++ .../llms/mistral/test_mistral_common_utils.py | 11 ++++ .../mistral/test_mistral_cost_calculator.py | 15 +++++ .../proxy/test_tools_allowlist_enforcement.py | 16 +++++ 4 files changed, 108 insertions(+) diff --git a/tests/test_litellm/llms/mistral/conversations/test_mistral_conversations_transformation.py b/tests/test_litellm/llms/mistral/conversations/test_mistral_conversations_transformation.py index 3207a461ab9..d4136e66391 100644 --- a/tests/test_litellm/llms/mistral/conversations/test_mistral_conversations_transformation.py +++ b/tests/test_litellm/llms/mistral/conversations/test_mistral_conversations_transformation.py @@ -213,6 +213,60 @@ def test_transform_request_maps_random_seed(): assert body["completion_args"] == {"random_seed": 7} +def test_transform_request_flattens_content_part_lists(): + """OpenAI content-parts messages must flatten to their text; non-text parts + and malformed entries are dropped, not crashed on.""" + cfg = MistralConversationsConfig() + body = cfg.transform_request( + model="mistral-medium-latest", + messages=[ + {"role": "system", "content": [{"type": "text", "text": "Be brief."}]}, + { + "role": "user", + "content": [ + {"type": "text", "text": "What is "}, + {"type": "image_url", "image_url": {"url": "https://example.com/x.png"}}, + "not-a-dict", + {"type": "text", "text": "the weather?"}, + ], + }, + ], + optional_params={"web_search_options": {}}, + litellm_params={}, + headers={}, + ) + assert body["instructions"] == "Be brief." + assert body["inputs"] == [{"role": "user", "content": "What is the weather?"}] + + +def test_transform_request_assistant_null_content_with_tool_calls(): + """The OpenAI SDK emits assistant tool-call turns with content=None; that must + map to a function.call entry with no empty assistant message entry.""" + cfg = MistralConversationsConfig() + body = cfg.transform_request( + model="mistral-medium-latest", + messages=[ + {"role": "user", "content": "weather?"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{}"}} + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "18C"}, + ], + optional_params={"web_search_options": {}}, + litellm_params={}, + headers={}, + ) + assert body["inputs"] == [ + {"role": "user", "content": "weather?"}, + {"type": "function.call", "tool_call_id": "call_1", "name": "get_weather", "arguments": "{}"}, + {"type": "function.result", "tool_call_id": "call_1", "result": "18C"}, + ] + + def test_get_complete_url(): cfg = MistralConversationsConfig() assert ( @@ -347,6 +401,18 @@ def test_transform_response_without_message_output_is_graceful(): assert mr.choices[0].message.content is None +def test_transform_response_null_message_content_is_graceful(): + mr = _transform( + { + "conversation_id": "conv_1", + "outputs": [{"type": "message.output", "role": "assistant", "content": None}], + "usage": {"prompt_tokens": 1, "completion_tokens": 0, "total_tokens": 1}, + } + ) + assert mr.choices[0].message.content is None + assert getattr(mr.choices[0].message, "annotations", None) is None + + @pytest.mark.parametrize( "web_search_kwargs", [ diff --git a/tests/test_litellm/llms/mistral/test_mistral_common_utils.py b/tests/test_litellm/llms/mistral/test_mistral_common_utils.py index c6cc27369ba..dd44cc4147b 100644 --- a/tests/test_litellm/llms/mistral/test_mistral_common_utils.py +++ b/tests/test_litellm/llms/mistral/test_mistral_common_utils.py @@ -79,3 +79,14 @@ class TestMistralModelInfo: "mistral/mistral-medium-latest", "mistral/mistral-large-latest", ] + + def test_get_models_raises_without_api_key(self, monkeypatch): + monkeypatch.delenv("MISTRAL_API_KEY", raising=False) + with pytest.raises(ValueError, match="MISTRAL_API_BASE or MISTRAL_API_KEY"): + MistralModelInfo().get_models() + + def test_get_models_raises_on_http_error(self, respx_mock): + litellm.disable_aiohttp_transport = True + respx_mock.get("https://api.mistral.ai/v1/models").respond(status_code=500, text="boom") + with pytest.raises(Exception, match="Status code: 500"): + MistralModelInfo().get_models() diff --git a/tests/test_litellm/llms/mistral/test_mistral_cost_calculator.py b/tests/test_litellm/llms/mistral/test_mistral_cost_calculator.py index 4a5068c2d2e..a92793dac0d 100644 --- a/tests/test_litellm/llms/mistral/test_mistral_cost_calculator.py +++ b/tests/test_litellm/llms/mistral/test_mistral_cost_calculator.py @@ -36,6 +36,21 @@ def test_cost_per_web_search_request_no_details_is_zero(): assert cost_per_web_search_request(usage=usage, model_info=model_info) == 0.0 +def test_get_cost_for_web_search_request_dispatches_for_mistral(): + """The shared dispatcher must route provider 'mistral' to this cost calculator; + without the mistral branch the surcharge silently becomes None.""" + from litellm.llms import get_cost_for_web_search_request + + usage = Usage( + prompt_tokens=10, + completion_tokens=5, + total_tokens=15, + prompt_tokens_details=PromptTokensDetailsWrapper(web_search_requests=2), + ) + model_info = litellm.get_model_info("mistral/mistral-medium-latest") + assert get_cost_for_web_search_request("mistral", usage=usage, model_info=model_info) == pytest.approx(0.06) + + def _conversation_response(connectors: dict = None, execution_names: list = None) -> ModelResponse: """Build a transformed ModelResponse from a Conversations payload. diff --git a/tests/test_litellm/proxy/test_tools_allowlist_enforcement.py b/tests/test_litellm/proxy/test_tools_allowlist_enforcement.py index 69d7e00bca7..0cdfa56fbca 100644 --- a/tests/test_litellm/proxy/test_tools_allowlist_enforcement.py +++ b/tests/test_litellm/proxy/test_tools_allowlist_enforcement.py @@ -260,6 +260,22 @@ class TestCheckToolsAllowlist: assert exc_info.value.type == ProxyErrorTypes.tool_access_denied assert "web_search" in str(exc_info.value.message) + @pytest.mark.asyncio + async def test_non_dict_tool_entries_skipped_without_masking_real_tools(self): + """Malformed tool entries must not crash extraction or shadow the real + tools that still need allowlist checking.""" + token = _token(metadata={"allowed_tools": ["get_weather"]}) + body = {"tools": ["junk", 42, {"type": "web_search"}]} + with pytest.raises(ProxyException) as exc_info: + await check_tools_allowlist( + request_body=body, + valid_token=token, + team_object=None, + route="/v1/chat/completions", + ) + assert exc_info.value.type == ProxyErrorTypes.tool_access_denied + assert "web_search" in str(exc_info.value.message) + @pytest.mark.asyncio async def test_builtin_web_search_allowed_when_listed(self): token = _token(metadata={"allowed_tools": ["web_search"]})