fix(mistral): reject a tool_choice Mistral cannot take unless drop_params is set

This commit is contained in:
mateo-berri 2026-11-04 19:27:43 -08:00
parent 87cd93e85f
commit f8a9da87e0
2 changed files with 67 additions and 19 deletions

View file

@ -203,15 +203,31 @@ class MistralConfig(OpenAIGPTConfig):
match tool_choice:
case str(choice) if choice in ("auto", "none"):
return choice
case "required" | "any":
return "any"
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
return None
def _tool_choice_to_send(
self, model: str, tool_choice: object, drop_params: bool
) -> str | ChatCompletionToolChoiceObjectParam | None:
mapped: Final = self._map_tool_choice(tool_choice)
if mapped is not None or drop_params or litellm.drop_params is True:
return mapped
raise UnsupportedParamsError(
message=(
f"{self.custom_llm_provider} does not support tool_choice={tool_choice!r}, for model={model}, "
"since the Mistral API takes only 'auto', 'none', 'any', 'required', or "
"{'type': 'function', 'function': {'name': <tool name>}}. "
"To drop it, set `litellm.drop_params=True` or for proxy:\n\n`litellm_settings:\n drop_params: true`\n"
),
llm_provider=self.custom_llm_provider,
model=model,
)
@staticmethod
def _get_mistral_reasoning_system_prompt() -> str:
@ -266,8 +282,10 @@ class MistralConfig(OpenAIGPTConfig):
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
mapped_tool_choice := self._tool_choice_to_send(
model,
cast(object, value), # cast-ok: untyped request params, the match narrows it
drop_params,
)
)
is not None

View file

@ -2075,7 +2075,17 @@ class TestMistralToolChoice:
}
]
def _request_body(self, tool_choice: str | Mapping[str, object]) -> Mapping[str, object]:
UNUSABLE_TOOL_CHOICES: Final[Sequence[str | Mapping[str, object]]] = (
{"type": "function", "function": {}},
{"type": "function", "function": {"name": ""}},
{"type": "function", "function": {"name": 123}},
{"type": "function"},
"foo",
)
def _request_body(
self, tool_choice: str | Mapping[str, object], drop_params: bool = False
) -> Mapping[str, object]:
import litellm
optional_params: Final = litellm.get_optional_params(
@ -2083,6 +2093,7 @@ class TestMistralToolChoice:
custom_llm_provider="mistral",
tools=list(self.TOOLS),
tool_choice=tool_choice,
drop_params=drop_params,
)
return MistralConfig().transform_request(
model="mistral-large-latest",
@ -2097,6 +2108,24 @@ class TestMistralToolChoice:
assert body["tool_choice"] == {"type": "function", "function": {"name": "get_weather"}}
@pytest.mark.parametrize(
"custom_llm_provider, model",
[("mistral", "mistral-large-latest"), ("codestral", "codestral-latest"), ("vertex_ai", "mistral-small-2503")],
)
def test_named_function_is_kept_on_every_mistral_route(
self, local_model_cost_map: None, custom_llm_provider: str, model: str
) -> None:
import litellm
optional_params: Final = litellm.get_optional_params(
model=model,
custom_llm_provider=custom_llm_provider,
tools=list(self.TOOLS),
tool_choice={"type": "function", "function": {"name": "get_weather"}},
)
assert optional_params["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(
{
@ -2115,16 +2144,17 @@ class TestMistralToolChoice:
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]
@pytest.mark.parametrize("tool_choice", UNUSABLE_TOOL_CHOICES)
def test_unusable_tool_choice_is_rejected_without_drop_params(
self, local_model_cost_map: None, tool_choice: str | Mapping[str, object]
) -> None:
assert "tool_choice" not in self._request_body(tool_choice)
import litellm
with pytest.raises(litellm.UnsupportedParamsError, match="tool_choice"):
self._request_body(tool_choice)
@pytest.mark.parametrize("tool_choice", UNUSABLE_TOOL_CHOICES)
def test_unusable_tool_choice_is_dropped_with_drop_params(
self, local_model_cost_map: None, tool_choice: str | Mapping[str, object]
) -> None:
assert "tool_choice" not in self._request_body(tool_choice, drop_params=True)