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.
This commit is contained in:
Pasqual Troncone 2026-09-04 16:30:48 +02:00
parent 89f1a43289
commit cbc83e3c01
2 changed files with 53 additions and 27 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, 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":

View file

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