Merge pull request #41234 from BerriAI/litellm_invalid_tool_choice_400

fix(utils): reject an untranslatable tool_choice with a 400 instead of a 500
This commit is contained in:
Mateo Wang 2026-09-19 04:02:04 -07:00 committed by GitHub
commit c39ec34553
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 91 additions and 41 deletions

View file

@ -5108,7 +5108,7 @@ def completion(
messages = validate_and_fix_openai_messages(messages=messages)
tools = validate_and_fix_openai_tools(tools=tools)
# validate tool_choice
tool_choice = validate_chat_completion_tool_choice(tool_choice=tool_choice)
tool_choice = validate_chat_completion_tool_choice(tool_choice=tool_choice, model=model)
# validate optional params
stop = validate_openai_optional_params(stop=stop)
thinking = validate_and_fix_thinking_param(thinking=thinking)

View file

@ -8110,6 +8110,7 @@ def validate_chat_completion_user_messages(messages: list[AllMessageValues]):
def validate_chat_completion_tool_choice(
tool_choice: dict | str | None,
model: str = "",
) -> dict | str | None:
"""
Confirm the tool choice is passed in the OpenAI format.
@ -8125,12 +8126,19 @@ def validate_chat_completion_tool_choice(
# Standard OpenAI format: {"type": "function", "function": {...}}
if tool_choice.get("type") is None or tool_choice.get("function") is None:
raise Exception(
f"Invalid tool choice, tool_choice={tool_choice}. Please ensure tool_choice follows the OpenAI spec"
raise BadRequestError(
message=f"Invalid tool choice, tool_choice={tool_choice}. Please ensure tool_choice follows the OpenAI spec",
model=model,
llm_provider="",
)
return tool_choice
raise Exception(
f"Invalid tool choice, tool_choice={tool_choice}. Got={type(tool_choice)}. Expecting str, or dict. Please ensure tool_choice follows the OpenAI tool_choice spec"
raise BadRequestError(
message=(
f"Invalid tool choice, tool_choice={tool_choice}. Got={type(tool_choice)}. Expecting str, or dict. "
"Please ensure tool_choice follows the OpenAI tool_choice spec"
),
model=model,
llm_provider="",
)

View file

@ -1180,10 +1180,10 @@ def test_validate_chat_completion_tool_choice(tool_choice, expected_bool):
from litellm.utils import validate_chat_completion_tool_choice
if expected_bool:
validate_chat_completion_tool_choice(tool_choice=tool_choice)
validate_chat_completion_tool_choice(tool_choice=tool_choice, model="gpt-5.6-sol")
else:
with pytest.raises(Exception, match="Invalid tool choice"):
validate_chat_completion_tool_choice(tool_choice=tool_choice)
with pytest.raises(litellm.BadRequestError, match="Invalid tool choice"):
validate_chat_completion_tool_choice(tool_choice=tool_choice, model="gpt-5.6-sol")
def test_models_by_provider():

View file

@ -1,60 +1,74 @@
import re
from typing import Final
import pytest
import litellm
from litellm.utils import validate_chat_completion_tool_choice
MODEL: Final = "anthropic/claude-haiku-4-5"
def test_validate_tool_choice_none():
"""Test that None is returned as-is."""
result = validate_chat_completion_tool_choice(None)
result = validate_chat_completion_tool_choice(None, model=MODEL)
assert result is None
def test_validate_tool_choice_string():
"""Test that string values are returned as-is."""
assert validate_chat_completion_tool_choice("auto") == "auto"
assert validate_chat_completion_tool_choice("none") == "none"
assert validate_chat_completion_tool_choice("required") == "required"
assert validate_chat_completion_tool_choice("auto", model=MODEL) == "auto"
assert validate_chat_completion_tool_choice("none", model=MODEL) == "none"
assert validate_chat_completion_tool_choice("required", model=MODEL) == "required"
def test_validate_tool_choice_standard_dict():
"""Test standard OpenAI format with function."""
tool_choice = {"type": "function", "function": {"name": "my_function"}}
result = validate_chat_completion_tool_choice(tool_choice)
result = validate_chat_completion_tool_choice(tool_choice, model=MODEL)
assert result == tool_choice
def test_validate_tool_choice_cursor_format():
"""Cursor IDE format {"type": "auto"} is unwrapped to the bare string."""
assert validate_chat_completion_tool_choice({"type": "auto"}) == "auto"
assert validate_chat_completion_tool_choice({"type": "none"}) == "none"
assert validate_chat_completion_tool_choice({"type": "required"}) == "required"
assert validate_chat_completion_tool_choice({"type": "auto"}, model=MODEL) == "auto"
assert validate_chat_completion_tool_choice({"type": "none"}, model=MODEL) == "none"
assert validate_chat_completion_tool_choice({"type": "required"}, model=MODEL) == "required"
def test_validate_tool_choice_invalid_dict():
"""Test that invalid dict formats raise exceptions."""
# Missing both type and function
with pytest.raises(Exception, match='Invalid tool choice, tool_choice=\\{\\}\\. Please ensure') as exc_info:
validate_chat_completion_tool_choice({})
assert "Invalid tool choice" in str(exc_info.value)
# Invalid type value
with pytest.raises(Exception, match="Invalid tool choice, tool_choice=\\{'type': 'invalid'\\}\\.") as exc_info:
validate_chat_completion_tool_choice({"type": "invalid"})
assert "Invalid tool choice" in str(exc_info.value)
# Has type but missing function when type is "function"
with pytest.raises(Exception, match="Invalid tool choice, tool_choice=\\{'type': 'function'\\}\\.") as exc_info:
validate_chat_completion_tool_choice({"type": "function"})
assert "Invalid tool choice" in str(exc_info.value)
@pytest.mark.parametrize(
"tool_choice",
[
{},
{"type": "invalid"},
{"type": "function"},
{"name": "lookup_fruit"},
{"type": "file_search"},
],
)
def test_validate_tool_choice_invalid_dict_is_a_400(tool_choice):
"""A dict shape chat completions cannot carry is the caller's mistake: a 400 that names the field, never a 500."""
with pytest.raises(
litellm.BadRequestError, match=f"Invalid tool choice, tool_choice={re.escape(str(tool_choice))}\\. Please ensure"
) as exc_info:
validate_chat_completion_tool_choice(tool_choice, model=MODEL)
assert exc_info.value.status_code == 400
assert exc_info.value.model == MODEL
def test_validate_tool_choice_invalid_type():
"""Test that invalid types raise exceptions."""
with pytest.raises(Exception, match="<class 'int'>\\. Expecting str, or dict\\. Please ensure") as exc_info:
validate_chat_completion_tool_choice(123)
assert "Got=<class 'int'>" in str(exc_info.value)
@pytest.mark.parametrize("tool_choice", [123, []])
def test_validate_tool_choice_invalid_type_is_a_400(tool_choice):
"""A non-str, non-dict tool_choice is rejected as a 400 that names the type it got."""
with pytest.raises(
litellm.BadRequestError, match=f"Got={re.escape(str(type(tool_choice)))}\\. Expecting str, or dict\\."
) as exc_info:
validate_chat_completion_tool_choice(tool_choice, model=MODEL)
assert exc_info.value.status_code == 400
with pytest.raises(Exception, match="Invalid tool choice, tool_choice=\\[\\]\\. Got=<class 'list'>\\.") as exc_info:
validate_chat_completion_tool_choice([])
assert "Got=<class 'list'>" in str(exc_info.value)
def test_validate_tool_choice_without_model_is_still_a_400():
"""Callers that predate the model argument keep getting a 400, with an empty model on the error."""
with pytest.raises(litellm.BadRequestError, match="Invalid tool choice") as exc_info:
validate_chat_completion_tool_choice({"type": "bogus"})
assert exc_info.value.status_code == 400
assert exc_info.value.model == ""

View file

@ -5033,3 +5033,17 @@ def test_transform_chat_completion_response_incomplete_details():
assert result_existing.status == "incomplete"
assert result_existing.incomplete_details == existing_details
@pytest.mark.parametrize("stream", [True, False])
async def test_bridge_rejects_untranslatable_tool_choice_with_a_400(stream: bool):
with pytest.raises(litellm.BadRequestError) as exc_info:
await litellm.aresponses(
model="anthropic/claude-haiku-4-5",
input="Which fruit is red?",
tools=[{"type": "function", "name": "lookup_fruit", "parameters": {"type": "object"}}],
tool_choice={"type": "file_search"},
stream=stream,
api_key="sk-unused",
)
assert exc_info.value.status_code == 400
assert "tool_choice={'type': 'file_search'}" in str(exc_info.value)

View file

@ -3967,3 +3967,17 @@ def test_aiohttp_openai_warns_only_when_http2_enabled(
assert handler_completion.called
warned: Final = "aiohttp_openai/ always uses aiohttp" in caplog.text
assert warned is http2_on
@pytest.mark.parametrize("tool_choice", [{"type": "bogus"}, {"name": "lookup_fruit"}, {"type": "file_search"}])
def test_completion_rejects_untranslatable_tool_choice_with_a_400(tool_choice):
with pytest.raises(litellm.BadRequestError) as exc_info:
litellm.completion(
model="anthropic/claude-haiku-4-5",
messages=[{"role": "user", "content": "Which fruit is red?"}],
tools=[{"type": "function", "function": {"name": "lookup_fruit", "parameters": {"type": "object"}}}],
tool_choice=tool_choice,
api_key="sk-unused",
)
assert exc_info.value.status_code == 400
assert f"tool_choice={tool_choice}" in str(exc_info.value)