fix(tool-choice): refuse an allowed_tools entry whose name is not a string

An mcp or other non-function entry with a list or dict name passed the
parser and crashed when the allow list was hashed. The parser now treats
it as malformed on both layouts, so it answers the same 400 as an empty
allow list, or drops under drop_params.
This commit is contained in:
mateo-berri 2026-11-06 02:09:06 -08:00
parent be0bb7a57e
commit 2ac020c070
5 changed files with 51 additions and 8 deletions

View file

@ -92,6 +92,10 @@ def _has_name(selector: Mapping[str, object]) -> bool:
return isinstance(name, str) and name != ""
def _names_on_other_tool_types_are_strings(tools: Iterable[Mapping[str, object]]) -> bool:
return all(tool.get("type") in ("function", "custom") or "name" not in tool or _has_name(tool) for tool in tools)
def _chat_allowed_tool(tool: Mapping[str, object]) -> Mapping[str, object] | None:
tool_type: Final = tool.get("type")
if not isinstance(tool_type, str) or tool_type not in ("function", "custom"):
@ -120,9 +124,9 @@ def parse_allowed_tools_tool_choice(tool_choice: object) -> ChatAllowedToolsTool
return None
match parsed:
case ChatAllowedToolsToolChoice():
return parsed
return parsed if _names_on_other_tool_types_are_strings(parsed.allowed_tools.tools) else None
case ResponsesAllowedToolsToolChoice():
return _chat_layout(parsed)
return _chat_layout(parsed) if _names_on_other_tool_types_are_strings(parsed.tools) else None
def azure_api_version_takes_tool_choice(api_version: str) -> bool:

View file

@ -14,10 +14,20 @@ from litellm.litellm_core_utils.allowed_tools_tool_choice import (
parse_allowed_tools_tool_choice,
refuse_a_malformed_allowed_tools_tool_choice,
refuse_tool_calls_the_choice_excludes,
tool_calls_the_choice_excludes,
unenforced_allowed_tools_tool_choice,
responses_allowed_tools_tool_choice,
)
_CHOICES_WITH_A_NAME_THAT_IS_NOT_A_STRING: Final = (
{"type": "allowed_tools", "mode": "auto", "tools": [{"type": "mcp", "name": ["get_time"]}]},
{"type": "allowed_tools", "mode": "auto", "tools": [{"type": "mcp", "name": {"first": "get_time"}}]},
{"type": "allowed_tools", "mode": "auto", "tools": [{"type": "mcp", "name": 7}]},
{"type": "allowed_tools", "mode": "auto", "tools": [{"type": "mcp", "name": ""}]},
{"type": "allowed_tools", "allowed_tools": {"mode": "auto", "tools": [{"type": "mcp", "name": ["get_time"]}]}},
{"type": "allowed_tools", "allowed_tools": {"mode": "auto", "tools": [{"type": "mcp", "name": 7}]}},
)
_CHAT_LAYOUT: Final[dict[str, object]] = {
"type": "allowed_tools",
"allowed_tools": {
@ -245,6 +255,7 @@ def test_a_choice_the_provider_cannot_take_answers_400_without_drop_params():
[
{"type": "allowed_tools", "mode": "required", "tools": []},
{"type": "allowed_tools", "tools": [{"type": "function", "name": "get_time"}]},
*_CHOICES_WITH_A_NAME_THAT_IS_NOT_A_STRING,
],
)
def test_a_malformed_choice_is_refused(tool_choice: object):
@ -254,6 +265,11 @@ def test_a_malformed_choice_is_refused(tool_choice: object):
assert exc.value.status_code == 400
@pytest.mark.parametrize("tool_choice", _CHOICES_WITH_A_NAME_THAT_IS_NOT_A_STRING)
def test_a_name_that_is_not_a_string_lists_no_tool(tool_choice: object):
assert tool_calls_the_choice_excludes(tool_choice, ("get_time",)) == ("get_time",)
@pytest.mark.parametrize("tool_choice", [_FLAT_CHOICE, _CHAT_LAYOUT, "required", None])
def test_a_valid_choice_or_any_other_choice_is_not_called_malformed(tool_choice: object):
assert refuse_a_malformed_allowed_tools_tool_choice(tool_choice, "the-model") is None

View file

@ -1807,8 +1807,15 @@ def _anthropic_call_to_echo() -> httpx.Response:
@pytest.mark.asyncio
@pytest.mark.parametrize(
"tool_choice",
[
_ALLOWED_ONLY_ANOTHER_TOOL,
{"type": "allowed_tools", "mode": "auto", "tools": [{"type": "mcp", "name": ["echo"]}]},
],
)
async def test_a_dropped_choice_hands_back_an_mcp_call_it_excludes(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
tool_choice: dict[str, object], respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
@ -1824,7 +1831,7 @@ async def test_a_dropped_choice_hands_back_an_mcp_call_it_excludes(
model="anthropic/claude-haiku-4-5",
input="Call echo with PING",
tools=[dict(_ECHO_MCP_TOOL)],
tool_choice=_ALLOWED_ONLY_ANOTHER_TOOL,
tool_choice=tool_choice,
drop_params=True,
api_key="sk-ant-fake",
)

View file

@ -648,13 +648,23 @@ def test_completion_drop_params_drops_an_allowed_tools_tool_choice_it_cannot_sen
@pytest.mark.asyncio
async def test_a_malformed_allowed_tools_tool_choice_stays_a_400_on_mcp_gateway_tools(respx_mock: respx.MockRouter):
@pytest.mark.parametrize(
"tool_choice",
[
{"type": "allowed_tools", "mode": "required", "tools": []},
{"type": "allowed_tools", "mode": "auto", "tools": [{"type": "mcp", "name": ["get_time"]}]},
{"type": "allowed_tools", "allowed_tools": {"mode": "auto", "tools": [{"type": "mcp", "name": ["get_time"]}]}},
],
)
async def test_a_malformed_allowed_tools_tool_choice_stays_a_400_on_mcp_gateway_tools(
tool_choice: dict[str, object], respx_mock: respx.MockRouter
):
with pytest.raises(litellm.BadRequestError, match="Invalid tool choice") as exc:
await litellm.acompletion(
model="openai/gpt-5.5",
messages=[{"role": "user", "content": "What time is it?"}],
tools=[{"type": "mcp", "server_url": "litellm_proxy/mcp/demo", "require_approval": "never"}],
tool_choice={"type": "allowed_tools", "mode": "required", "tools": []},
tool_choice=tool_choice,
drop_params=True,
api_base="http://localhost:12346/v1",
api_key="fake_api_key",

View file

@ -336,14 +336,20 @@ def test_a_tool_the_choice_excludes_never_runs_when_the_model_ignored_the_choice
assert not _tools_run_before_the_refusal(_ALLOWED_WEATHER_ONLY, model="openai/gpt-5-mini")
def test_a_malformed_choice_is_refused_before_any_request(respx_mock: respx.MockRouter) -> None:
@pytest.mark.parametrize(
"tool_choice",
[_EMPTY_ALLOWED_TOOLS, {"type": "allowed_tools", "mode": "auto", "tools": [{"type": "mcp", "name": ["get_time"]}]}],
)
def test_a_malformed_choice_is_refused_before_any_request(
tool_choice: dict[str, object], respx_mock: respx.MockRouter
) -> None:
with pytest.raises(litellm.BadRequestError, match="Invalid tool choice"):
litellm.run_tool_loop(
model="openai/gpt-5-mini",
messages=[{"role": "user", "content": "hi"}],
tools=WEATHER_TOOLS,
execute_tool=_tool_result,
tool_choice=_EMPTY_ALLOWED_TOOLS,
tool_choice=tool_choice,
drop_params=True,
api_key="sk-test",
)