fix(mistral): a named tool_choice is dropped, the model picks any tool

Mistral's chat API takes tool_choice as an enum or as an object naming
one function, {"type": "function", "function": {"name": ...}}. The
mapper only handled strings, so the object was skipped and the request
reached Mistral with no tool_choice at all.

The pin is now rebuilt from the function name alone, since Mistral
rejects fields it does not define. Enum mapping is unchanged, and an
object without a usable name is still left out.

Fixes #39736
This commit is contained in:
Pasqual Troncone 2026-11-03 22:47:32 +01:00
parent 883a2d1572
commit 87cd93e85f
2 changed files with 105 additions and 16 deletions

View file

@ -45,7 +45,13 @@ from litellm.types.llms.mistral import (
MistralThinkingBlock,
MistralToolCallMessage,
)
from litellm.types.llms.openai import AllMessageValues, ChatCompletionAssistantMessage, ChatCompletionTextObject
from litellm.types.llms.openai import (
AllMessageValues,
ChatCompletionAssistantMessage,
ChatCompletionTextObject,
ChatCompletionToolChoiceFunctionParam,
ChatCompletionToolChoiceObjectParam,
)
from litellm.types.utils import ModelResponse, ModelResponseStream
from litellm.utils import convert_to_model_response_object, supports_reasoning
@ -118,7 +124,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
@ -135,7 +141,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
@ -147,7 +153,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,
@ -192,13 +198,20 @@ class MistralConfig(OpenAIGPTConfig):
*(("prompt_cache_key",) if accepts_prompt_cache_key else ()),
]
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"
@staticmethod
def _map_tool_choice(tool_choice: object) -> str | ChatCompletionToolChoiceObjectParam | None:
match tool_choice:
case str(choice) if choice in ("auto", "none"):
return choice
case {"type": "function", "function": {"name": str(name)}} if name:
return ChatCompletionToolChoiceObjectParam(
type="function", function=ChatCompletionToolChoiceFunctionParam(name=name)
)
case str():
return "any"
case _:
pass
return None
@staticmethod
def _get_mistral_reasoning_system_prompt() -> str:
@ -250,8 +263,16 @@ class MistralConfig(OpenAIGPTConfig):
optional_params["top_p"] = value
if param == "stop":
optional_params["stop"] = value
if param == "tool_choice" and isinstance(value, str):
optional_params["tool_choice"] = self._map_tool_choice(tool_choice=value)
if (
param == "tool_choice"
and (
mapped_tool_choice := self._map_tool_choice(
tool_choice=cast(object, value) # cast-ok: untyped request params, the match narrows it
)
)
is not None
):
optional_params["tool_choice"] = mapped_tool_choice
if param == "seed":
optional_params["extra_body"] = {"random_seed": value}
if param == "response_format":

View file

@ -1,7 +1,7 @@
import copy
import json
import logging
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from typing import Final, List, cast
from unittest.mock import MagicMock, patch
from urllib.parse import urlparse
@ -18,7 +18,7 @@ from litellm.llms.mistral.chat.transformation import (
MistralConfig,
)
from litellm.llms.vertex_ai.vertex_ai_partner_models.mistral.transformation import VertexAIMistralConfig
from litellm.types.llms.openai import AllMessageValues
from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam
from litellm.types.utils import ModelResponse
from litellm.utils import supports_reasoning
@ -1690,7 +1690,9 @@ def test_mistral_request_drops_a_user_name_without_touching_the_callers_message(
def _warnings_naming(caplog: pytest.LogCaptureFixture, param: str) -> tuple[str, ...]:
return tuple(
r.getMessage() for r in caplog.records if r.levelno == logging.WARNING and f"Dropping {param}=" in r.getMessage()
r.getMessage()
for r in caplog.records
if r.levelno == logging.WARNING and f"Dropping {param}=" in r.getMessage()
)
@ -2060,3 +2062,69 @@ def test_prompt_cache_key_stays_unsupported_for_vertex_hosted_mistral(local_mode
model="mistral-medium-3", custom_llm_provider="vertex_ai", prompt_cache_key="s", drop_params=True
)
assert "prompt_cache_key" not in dropped
class TestMistralToolChoice:
"""Mistral takes tool_choice as an enum or as {"type": "function", "function": {"name": ...}}, per
https://docs.mistral.ai/api/endpoint/chat (checked 2026-11-03)"""
TOOLS: Final[Sequence[ChatCompletionToolParam]] = [
{
"type": "function",
"function": {"name": "get_weather", "parameters": {"type": "object", "properties": {}}},
}
]
def _request_body(self, tool_choice: str | Mapping[str, object]) -> Mapping[str, object]:
import litellm
optional_params: Final = litellm.get_optional_params(
model="mistral-large-latest",
custom_llm_provider="mistral",
tools=list(self.TOOLS),
tool_choice=tool_choice,
)
return MistralConfig().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, local_model_cost_map: None) -> None:
body: Final = self._request_body({"type": "function", "function": {"name": "get_weather"}})
assert body["tool_choice"] == {"type": "function", "function": {"name": "get_weather"}}
def test_keys_mistral_does_not_define_are_not_replayed(self, local_model_cost_map: None) -> None:
body: Final = self._request_body(
{
"type": "function",
"function": {"name": "get_weather", "strict": True},
"cache_control": {"type": "ephemeral"},
}
)
assert body["tool_choice"] == {"type": "function", "function": {"name": "get_weather"}}
@pytest.mark.parametrize(
"tool_choice, expected",
[("auto", "auto"), ("none", "none"), ("required", "any"), ("any", "any")],
)
def test_enum_tool_choice_is_mapped(self, local_model_cost_map: None, tool_choice: str, expected: str) -> None:
assert self._request_body(tool_choice)["tool_choice"] == expected
@pytest.mark.parametrize(
"tool_choice",
[
{"type": "function", "function": {}},
{"type": "function", "function": {"name": ""}},
{"type": "function", "function": {"name": 123}},
{"type": "function"},
],
)
def test_function_objects_without_a_usable_name_are_left_out(
self, local_model_cost_map: None, tool_choice: Mapping[str, object]
) -> None:
assert "tool_choice" not in self._request_body(tool_choice)