From cbc83e3c0127ca4da08fc5636cc4c9d8e43f20fd Mon Sep 17 00:00:00 2001 From: Pasqual Troncone Date: Fri, 4 Sep 2026 16:30:48 +0200 Subject: [PATCH] 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)