From 89f1a432892c12fdefdd92e924e3fb1d6d413a14 Mon Sep 17 00:00:00 2001 From: Pasqual Troncone Date: Fri, 4 Sep 2026 13:12:14 +0200 Subject: [PATCH 1/3] fix(mistral): a named tool_choice is dropped, the model picks any tool Mistral's tool_choice takes either the enum (none, auto, any, required) or an object naming one function, and that object is how a caller pins the model to a single tool. The param mapping only handled strings, so an object value was filtered out before it could be mapped and never reached the request body: the model got tools with no tool_choice and answered however it liked, silently ignoring the pin. Map the object through as well, keeping the enum mapping and the fall back to "any" for shapes Mistral does not document. The same mapping backs the codestral provider and the Mistral models on vertex_ai, so all three surfaces were affected. --- litellm/llms/mistral/chat/transformation.py | 19 +++---- .../test_mistral_chat_transformation.py | 53 ++++++++++++++++++- 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/litellm/llms/mistral/chat/transformation.py b/litellm/llms/mistral/chat/transformation.py index a76a8a3e98c..f9a8b5d986b 100644 --- a/litellm/llms/mistral/chat/transformation.py +++ b/litellm/llms/mistral/chat/transformation.py @@ -6,7 +6,7 @@ Why separate file? Make it easy to see how transformation works Docs - https://docs.mistral.ai/api/ """ -from collections.abc import AsyncIterator, Coroutine, Iterator +from collections.abc import AsyncIterator, Coroutine, Iterator, Mapping from typing import TYPE_CHECKING, Any, Final, Literal, cast, get_type_hints, overload import httpx @@ -107,13 +107,14 @@ class MistralConfig(OpenAIGPTConfig): return supported_params - def _map_tool_choice(self, tool_choice: str) -> str: - if tool_choice == "auto" or tool_choice == "none": - return tool_choice - elif tool_choice == "required": - return "any" - else: # openai 'tool_choice' object param not supported by Mistral API - return "any" + def _map_tool_choice(self, tool_choice: str | Mapping[str, object]) -> str | Mapping[str, object]: + match tool_choice: + case "auto" | "none": + return tool_choice + case {"type": "function", "function": {"name": str()}}: + return tool_choice + case _: + return "any" @staticmethod def _get_mistral_reasoning_system_prompt() -> str: @@ -165,7 +166,7 @@ class MistralConfig(OpenAIGPTConfig): optional_params["top_p"] = value if param == "stop": optional_params["stop"] = value - if param == "tool_choice" and isinstance(value, str): + if param == "tool_choice" and isinstance(value, (str, Mapping)): optional_params["tool_choice"] = self._map_tool_choice(tool_choice=value) if param == "seed": optional_params["extra_body"] = {"random_seed": value} 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 15694d9f218..a3d00322b03 100644 --- a/tests/test_litellm/llms/mistral/test_mistral_chat_transformation.py +++ b/tests/test_litellm/llms/mistral/test_mistral_chat_transformation.py @@ -1,10 +1,15 @@ -from typing import List, cast +from collections.abc import Mapping +from typing import Final, List, cast from unittest.mock import MagicMock, patch import pytest from litellm.litellm_core_utils.prompt_templates.common_utils import TOOL_RESULT_IMAGE_BOUNDARY -from litellm.types.llms.openai import AllMessageValues +from litellm.types.llms.openai import ( + AllMessageValues, + ChatCompletionToolChoiceObjectParam, + ChatCompletionToolParam, +) from litellm.llms.mistral.chat.transformation import ( @@ -844,3 +849,47 @@ def test_mistral_transform_request_hoists_tool_message_image(): {"type": "text", "text": TOOL_RESULT_IMAGE_BOUNDARY}, {"type": "image_url", "image_url": {"url": data_uri}}, ] + + +class TestMistralToolChoice: + """Mistral's tool_choice is either an enum or a named function object: https://docs.mistral.ai/api/""" + + NAMED_TOOL_CHOICE: Final[ChatCompletionToolChoiceObjectParam] = { + "type": "function", + "function": {"name": "get_weather"}, + } + TOOLS: Final[list[ChatCompletionToolParam]] = [ + { + "type": "function", + "function": {"name": "get_weather", "parameters": {"type": "object", "properties": {}}}, + } + ] + + def _transform(self, tool_choice: str | Mapping[str, object]) -> dict[str, object]: + config: Final = MistralConfig() + optional_params: Final = config.map_openai_params( + non_default_params={"tool_choice": tool_choice, "tools": self.TOOLS}, + optional_params={}, + model="mistral-large-latest", + drop_params=False, + ) + return config.transform_request( + model="mistral-large-latest", + messages=[{"role": "user", "content": "what is the weather in Madrid"}], + optional_params=optional_params, + litellm_params={}, + headers={}, + ) + + def test_named_function_is_forwarded_to_mistral(self) -> None: + assert self._transform(self.NAMED_TOOL_CHOICE)["tool_choice"] == self.NAMED_TOOL_CHOICE + + @pytest.mark.parametrize( + "tool_choice, expected", + [("auto", "auto"), ("none", "none"), ("required", "any")], + ) + def test_enum_tool_choice_is_mapped(self, tool_choice: str, expected: str) -> None: + assert self._transform(tool_choice)["tool_choice"] == expected + + def test_object_shapes_mistral_does_not_accept_fall_back_to_any(self) -> None: + assert self._transform({"type": "allowed_tools", "allowed_tools": {"mode": "auto"}})["tool_choice"] == "any" From cbc83e3c0127ca4da08fc5636cc4c9d8e43f20fd Mon Sep 17 00:00:00 2001 From: Pasqual Troncone Date: Fri, 4 Sep 2026 16:30:48 +0200 Subject: [PATCH 2/3] chore(mistral): rebuild the tool_choice pin and cover malformed shapes The previous commit forwarded the caller's tool_choice object by reference. Mistral's schema forbids unknown fields, so any extra key travelled with it and came back a 422, and an empty function name went out as an unusable pin the API rejects. Both shapes pass the central tool_choice validator, so callers can reach them. Rebuild the pin from the extracted name instead, keeping only the type and the function name, and require that name to be a non-empty string. Anything else is left out, which is what the provider did before this branch touched it. Tests now assert against a literal written apart from the input, so an in-place corruption of the pin cannot pass, and cover the reachable malformed shapes: no name, an empty name, and a name that is not a string. --- litellm/llms/mistral/chat/transformation.py | 35 ++++++++++----- .../test_mistral_chat_transformation.py | 45 ++++++++++++------- 2 files changed, 53 insertions(+), 27 deletions(-) diff --git a/litellm/llms/mistral/chat/transformation.py b/litellm/llms/mistral/chat/transformation.py index f9a8b5d986b..4f7ed21bc79 100644 --- a/litellm/llms/mistral/chat/transformation.py +++ b/litellm/llms/mistral/chat/transformation.py @@ -6,7 +6,7 @@ Why separate file? Make it easy to see how transformation works Docs - https://docs.mistral.ai/api/ """ -from collections.abc import AsyncIterator, Coroutine, Iterator, Mapping +from collections.abc import AsyncIterator, Coroutine, Iterator from typing import TYPE_CHECKING, Any, Final, Literal, cast, get_type_hints, overload import httpx @@ -22,7 +22,11 @@ from litellm.llms.openai.chat.gpt_transformation import ( ) from litellm.secret_managers.main import get_secret_str from litellm.types.llms.mistral import MistralThinkingBlock, MistralToolCallMessage -from litellm.types.llms.openai import AllMessageValues +from litellm.types.llms.openai import ( + AllMessageValues, + ChatCompletionToolChoiceFunctionParam, + ChatCompletionToolChoiceObjectParam, +) from litellm.types.utils import ModelResponse, ModelResponseStream from litellm.utils import convert_to_model_response_object @@ -44,7 +48,7 @@ class MistralConfig(OpenAIGPTConfig): - `tools` (list or null): A list of available tools for the model. Use this to specify functions for which the model can generate JSON inputs. - - `tool_choice` (string - 'auto'/'any'/'none' or null): Specifies if/how functions are called. If set to none the model won't call a function and will generate a message instead. If set to auto the model can choose to either generate a message or call a function. If set to any the model is forced to call a function. Default - 'auto'. + - `tool_choice` (string - 'auto'/'any'/'none', an object naming one function such as {"type": "function", "function": {"name": "my_function"}}, or null): Specifies if/how functions are called. If set to none the model won't call a function and will generate a message instead. If set to auto the model can choose to either generate a message or call a function. If set to any the model is forced to call a function. Default - 'auto'. - `stop` (string or array of strings): Stop generation if this token is detected. Or if one of these tokens is detected when providing an array @@ -59,7 +63,7 @@ class MistralConfig(OpenAIGPTConfig): top_p: int | None = None max_tokens: int | None = None tools: list | None = None - tool_choice: Literal["auto", "any", "none"] | None = None + tool_choice: Literal["auto", "any", "none"] | ChatCompletionToolChoiceObjectParam | None = None random_seed: int | None = None safe_prompt: bool | None = None response_format: dict | None = None @@ -71,7 +75,7 @@ class MistralConfig(OpenAIGPTConfig): top_p: int | None = None, max_tokens: int | None = None, tools: list | None = None, - tool_choice: Literal["auto", "any", "none"] | None = None, + tool_choice: Literal["auto", "any", "none"] | ChatCompletionToolChoiceObjectParam | None = None, random_seed: int | None = None, safe_prompt: bool | None = None, response_format: dict | None = None, @@ -107,14 +111,19 @@ class MistralConfig(OpenAIGPTConfig): return supported_params - def _map_tool_choice(self, tool_choice: str | Mapping[str, object]) -> str | Mapping[str, object]: + @staticmethod + def _map_tool_choice(tool_choice: str | dict[str, object]) -> str | ChatCompletionToolChoiceObjectParam | None: match tool_choice: case "auto" | "none": return tool_choice - case {"type": "function", "function": {"name": str()}}: - return tool_choice - case _: + case {"type": "function", "function": {"name": str(name)}} if name: + return ChatCompletionToolChoiceObjectParam( + type="function", function=ChatCompletionToolChoiceFunctionParam(name=name) + ) + case str(): return "any" + case _: + return None @staticmethod def _get_mistral_reasoning_system_prompt() -> str: @@ -166,8 +175,12 @@ class MistralConfig(OpenAIGPTConfig): optional_params["top_p"] = value if param == "stop": optional_params["stop"] = value - if param == "tool_choice" and isinstance(value, (str, Mapping)): - optional_params["tool_choice"] = self._map_tool_choice(tool_choice=value) + if ( + param == "tool_choice" + and isinstance(value, (str, dict)) + and (mapped_tool_choice := self._map_tool_choice(tool_choice=value)) is not None + ): + optional_params["tool_choice"] = mapped_tool_choice if param == "seed": optional_params["extra_body"] = {"random_seed": value} if param == "response_format": 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 a3d00322b03..bed93c7c0f2 100644 --- a/tests/test_litellm/llms/mistral/test_mistral_chat_transformation.py +++ b/tests/test_litellm/llms/mistral/test_mistral_chat_transformation.py @@ -1,15 +1,11 @@ -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from typing import Final, List, cast from unittest.mock import MagicMock, patch import pytest from litellm.litellm_core_utils.prompt_templates.common_utils import TOOL_RESULT_IMAGE_BOUNDARY -from litellm.types.llms.openai import ( - AllMessageValues, - ChatCompletionToolChoiceObjectParam, - ChatCompletionToolParam, -) +from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam from litellm.llms.mistral.chat.transformation import ( @@ -854,21 +850,17 @@ def test_mistral_transform_request_hoists_tool_message_image(): class TestMistralToolChoice: """Mistral's tool_choice is either an enum or a named function object: https://docs.mistral.ai/api/""" - NAMED_TOOL_CHOICE: Final[ChatCompletionToolChoiceObjectParam] = { - "type": "function", - "function": {"name": "get_weather"}, - } - TOOLS: Final[list[ChatCompletionToolParam]] = [ + TOOLS: Final[Sequence[ChatCompletionToolParam]] = [ { "type": "function", "function": {"name": "get_weather", "parameters": {"type": "object", "properties": {}}}, } ] - def _transform(self, tool_choice: str | Mapping[str, object]) -> dict[str, object]: + def _transform(self, tool_choice: str | Mapping[str, object]) -> Mapping[str, object]: config: Final = MistralConfig() optional_params: Final = config.map_openai_params( - non_default_params={"tool_choice": tool_choice, "tools": self.TOOLS}, + non_default_params={"tool_choice": tool_choice, "tools": list(self.TOOLS)}, optional_params={}, model="mistral-large-latest", drop_params=False, @@ -882,7 +874,20 @@ class TestMistralToolChoice: ) def test_named_function_is_forwarded_to_mistral(self) -> None: - assert self._transform(self.NAMED_TOOL_CHOICE)["tool_choice"] == self.NAMED_TOOL_CHOICE + request: Final = self._transform({"type": "function", "function": {"name": "get_weather"}}) + + assert request["tool_choice"] == {"type": "function", "function": {"name": "get_weather"}} + + def test_keys_mistral_forbids_are_not_replayed(self) -> None: + request: Final = self._transform( + { + "type": "function", + "function": {"name": "get_weather", "strict": True}, + "cache_control": {"type": "ephemeral"}, + } + ) + + assert request["tool_choice"] == {"type": "function", "function": {"name": "get_weather"}} @pytest.mark.parametrize( "tool_choice, expected", @@ -891,5 +896,13 @@ class TestMistralToolChoice: def test_enum_tool_choice_is_mapped(self, tool_choice: str, expected: str) -> None: assert self._transform(tool_choice)["tool_choice"] == expected - def test_object_shapes_mistral_does_not_accept_fall_back_to_any(self) -> None: - assert self._transform({"type": "allowed_tools", "allowed_tools": {"mode": "auto"}})["tool_choice"] == "any" + @pytest.mark.parametrize( + "tool_choice", + [ + {"type": "function", "function": {}}, + {"type": "function", "function": {"name": ""}}, + {"type": "function", "function": {"name": 123}}, + ], + ) + def test_function_objects_without_a_usable_name_are_left_out(self, tool_choice: Mapping[str, object]) -> None: + assert "tool_choice" not in self._transform(tool_choice) From 7987a1fae5aa008e9d16d5f78c11aca0a0df7fda Mon Sep 17 00:00:00 2001 From: Pasqual Troncone Date: Fri, 4 Sep 2026 16:46:14 +0200 Subject: [PATCH 3/3] chore(mistral): annotate the tool_choice parameter as a read-only mapping The type-discipline gate counts a `dict[...]` annotation as a mutable collection handed to a caller, and the parameter only ever reads its argument. The runtime check that decides what reaches the mapper stays `isinstance(value, (str, dict))`, so nothing but the annotation moves. --- litellm/llms/mistral/chat/transformation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/llms/mistral/chat/transformation.py b/litellm/llms/mistral/chat/transformation.py index 4f7ed21bc79..a33f170db71 100644 --- a/litellm/llms/mistral/chat/transformation.py +++ b/litellm/llms/mistral/chat/transformation.py @@ -6,7 +6,7 @@ Why separate file? Make it easy to see how transformation works Docs - https://docs.mistral.ai/api/ """ -from collections.abc import AsyncIterator, Coroutine, Iterator +from collections.abc import AsyncIterator, Coroutine, Iterator, Mapping from typing import TYPE_CHECKING, Any, Final, Literal, cast, get_type_hints, overload import httpx @@ -112,7 +112,7 @@ class MistralConfig(OpenAIGPTConfig): return supported_params @staticmethod - def _map_tool_choice(tool_choice: str | dict[str, object]) -> str | ChatCompletionToolChoiceObjectParam | None: + def _map_tool_choice(tool_choice: str | Mapping[str, object]) -> str | ChatCompletionToolChoiceObjectParam | None: match tool_choice: case "auto" | "none": return tool_choice