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.
This commit is contained in:
Pasqual Troncone 2026-09-04 13:12:14 +02:00
parent c8635ecc67
commit 89f1a43289
2 changed files with 61 additions and 11 deletions

View file

@ -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}

View file

@ -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"