fix(anthropic): reject malformed tool choices with a 400

This commit is contained in:
er-s-an 2026-10-13 17:32:09 +08:00
parent fb2a1450dd
commit 82bf4a682e
9 changed files with 139 additions and 8 deletions

View file

@ -647,7 +647,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
request_data["temperature"] = temperature
if thinking:
request_data["thinking"] = thinking
if tool_choice and (request_tools or not tools):
if tool_choice is not None and (request_tools or not tools):
request_data["tool_choice"] = tool_choice
if request_tools:
request_data["tools"] = request_tools

View file

@ -15,6 +15,7 @@ from litellm.llms.anthropic.messages.utils import (
is_reasoning_auto_summary_enabled,
json_copy_or_original,
prompt_cache_key_from_user_id,
validate_anthropic_tool_choice,
)
from litellm.responses.utils import auto_reasoning_summary
@ -803,6 +804,7 @@ class LiteLLMAnthropicMessagesAdapter:
def translate_anthropic_tool_choice_to_openai(
self, tool_choice: AnthropicMessagesToolChoice
) -> ChatCompletionToolChoiceValues:
validate_anthropic_tool_choice(tool_choice)
if tool_choice["type"] == "any":
return "required"
elif tool_choice["type"] == "auto":
@ -1099,7 +1101,7 @@ class LiteLLMAnthropicMessagesAdapter:
if "tool_choice" not in anthropic_message_request:
return
tool_choice: Final = anthropic_message_request["tool_choice"]
if not tool_choice:
if tool_choice is None:
return
new_kwargs["tool_choice"] = self.translate_anthropic_tool_choice_to_openai(
tool_choice=cast(AnthropicMessagesToolChoice, tool_choice)

View file

@ -316,7 +316,7 @@ def _build_responses_kwargs(
request_data["temperature"] = temperature
if thinking:
request_data["thinking"] = thinking
if tool_choice and (request_tools or not tools):
if tool_choice is not None and (request_tools or not tools):
request_data["tool_choice"] = tool_choice
if request_tools:
request_data["tools"] = list(request_tools)

View file

@ -30,6 +30,7 @@ from litellm.llms.anthropic.messages.utils import (
is_reasoning_auto_summary_enabled,
json_copy_or_original,
prompt_cache_key_from_user_id,
validate_anthropic_tool_choice,
)
from litellm.types.llms.anthropic import (
AllAnthropicPassThroughMessageValues,
@ -488,6 +489,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
tool_choice: AnthropicMessagesToolChoice,
) -> str | dict[str, object]:
"""Convert Anthropic tool_choice to Responses API tool_choice."""
validate_anthropic_tool_choice(tool_choice)
tc_type: Final = tool_choice.get("type")
if tc_type == "any":
return "required"
@ -655,7 +657,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
# tool_choice
tool_choice: Final = anthropic_request.get("tool_choice")
if tool_choice:
if tool_choice is not None:
responses_kwargs["tool_choice"] = self.translate_tool_choice_to_responses_api(
cast(AnthropicMessagesToolChoice, tool_choice)
)

View file

@ -9,7 +9,9 @@ from typing_extensions import ReadOnly
import litellm
from litellm.constants import X_LITELLM_DISABLE_CALLBACKS
from litellm.exceptions import BadRequestError
from litellm.litellm_core_utils.internal_call_metadata import sanitize_user_api_key_auth
from litellm.types.llms.anthropic import AnthropicMessagesToolChoice
from litellm.types.llms.base import LiteLLMBaseModel
from litellm.types.utils import TRUSTED_CALLBACK_VARS_FIELD, ModelInfo, StandardCallbackDynamicParams
@ -234,3 +236,21 @@ def normalize_reasoning_effort_value(
accepted_tiers: Final = tuple(level for level in supported if level != _THINKING_OFF)
return next((level for level in (*chain, *accepted_tiers) if level in supported), chain[-1])
def validate_anthropic_tool_choice(tool_choice: AnthropicMessagesToolChoice) -> None:
if "type" not in tool_choice:
raise BadRequestError(
message="tool_choice.type: Field required",
model="",
llm_provider="anthropic",
)
if tool_choice["type"] not in ("auto", "any", "tool", "none"):
raise BadRequestError(
message=(
f"tool_choice: Input tag {tool_choice['type']!r} found using 'type' "
"does not match any of the expected tags: 'auto', 'any', 'tool', 'none'"
),
model="",
llm_provider="anthropic",
)

View file

@ -3,6 +3,7 @@ import functools
import json
import logging
from collections.abc import Mapping
from functools import partial
from types import MappingProxyType
from typing import Any, Final, cast
@ -11,9 +12,6 @@ import pytest
import respx
import litellm
from litellm.litellm_core_utils.prompt_templates.common_utils import (
TOOL_RESULT_IMAGE_PLACEHOLDER,
encrypted_reasoning_signature,
@ -35,6 +33,8 @@ from litellm.llms.anthropic.messages.native.mid_conversation_system import (
from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig
from litellm.types.llms.anthropic import (
AnthopicMessagesAssistantMessageParam,
AnthropicMessagesRequest,
AnthropicMessagesToolChoice,
AnthropicMessagesUserMessageParam,
)
from litellm.types.llms.openai import ChatCompletionAssistantToolCall
@ -5527,3 +5527,37 @@ async def test_chat_bridge_tool_result_text_block_without_text_reaches_the_model
],
}
]
@pytest.mark.parametrize(
("tool_choice", "message"),
[
({}, "tool_choice.type: Field required"),
({"name": "get_weather"}, "tool_choice.type: Field required"),
(
{"type": "web_search"},
"tool_choice: Input tag 'web_search' found using 'type' "
"does not match any of the expected tags: 'auto', 'any', 'tool', 'none'",
),
],
)
@pytest.mark.parametrize("through_request", [False, True])
def test_invalid_tool_choice_is_bad_request(tool_choice: dict[str, str], message: str, through_request: bool) -> None:
request: Final = AnthropicMessagesRequest(
model="test-model",
max_tokens=128,
messages=[{"role": "user", "content": "Hello"}],
tool_choice=tool_choice,
)
translate: Final = (
partial(LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai, request)
if through_request
else partial(
LiteLLMAnthropicMessagesAdapter().translate_anthropic_tool_choice_to_openai,
cast(AnthropicMessagesToolChoice, tool_choice),
)
)
with pytest.raises(litellm.BadRequestError) as exc_info:
translate()
assert exc_info.value.status_code == 400
assert message in exc_info.value.message

View file

@ -375,3 +375,22 @@ def test_no_mcp_servers_needs_no_drop_params(monkeypatch: pytest.MonkeyPatch, mc
)
assert "mcp_servers" not in completion_kwargs
@pytest.mark.parametrize("tool_choice", [{}, {"name": "get_weather"}, {"type": "web_search"}])
@pytest.mark.asyncio
@pytest.mark.parametrize("stream", [False, True])
async def test_messages_rejects_invalid_tool_choice_before_provider_call(
tool_choice: dict[str, str], stream: bool
) -> None:
with pytest.raises(litellm.BadRequestError) as exc_info:
await litellm.anthropic_messages(
model="hosted_vllm/test-model",
max_tokens=8,
messages=[{"role": "user", "content": "Hello"}],
tool_choice=tool_choice,
stream=stream,
)
assert exc_info.value.status_code == 400
assert "tool_choice" in exc_info.value.message

View file

@ -1135,3 +1135,22 @@ async def test_the_sync_path_reports_its_edits_on_the_streamed_message_delta(
]
message_delta: Final = next(event for event in events if event["type"] == "message_delta")
assert message_delta["context_management"]["applied_edits"][0]["cleared_tool_uses"] == 2
@pytest.mark.parametrize("tool_choice", [{}, {"name": "get_weather"}, {"type": "web_search"}])
@pytest.mark.asyncio
@pytest.mark.parametrize("stream", [False, True])
async def test_messages_rejects_invalid_tool_choice_before_provider_call(
tool_choice: dict[str, str], stream: bool
) -> None:
with pytest.raises(litellm.BadRequestError) as exc_info:
await litellm.anthropic_messages(
model="openai/gpt-6.1-sol",
max_tokens=8,
messages=[{"role": "user", "content": "Hello"}],
tool_choice=tool_choice,
stream=stream,
)
assert exc_info.value.status_code == 400
assert "tool_choice" in exc_info.value.message

View file

@ -5,13 +5,15 @@ Tests for LiteLLMAnthropicToResponsesAPIAdapter
import json
import os
from typing import Any, Dict, Final, List
from functools import partial
from typing import Any, Dict, Final, List, cast
from unittest.mock import MagicMock
import pytest
from openai.types.responses import ResponseInputImageParam
from pydantic import TypeAdapter
import litellm
from litellm.constants import (
DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET,
DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET,
@ -29,6 +31,7 @@ from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfi
from litellm.types.llms.anthropic import (
AllAnthropicToolsValues,
AnthropicMessagesRequest,
AnthropicMessagesToolChoice,
)
from litellm.types.llms.openai import ResponseAPIUsage
@ -2431,3 +2434,35 @@ class TestPromptCacheBreakpointToResponses:
extra_kwargs={"prompt_cache_options": {"mode": "explicit"}},
)
assert kwargs["prompt_cache_options"] == {"mode": "explicit"}
@pytest.mark.parametrize(
("tool_choice", "message"),
[
({}, "tool_choice.type: Field required"),
({"name": "get_weather"}, "tool_choice.type: Field required"),
(
{"type": "web_search"},
"tool_choice: Input tag 'web_search' found using 'type' "
"does not match any of the expected tags: 'auto', 'any', 'tool', 'none'",
),
],
)
@pytest.mark.parametrize("through_request", [False, True])
def test_invalid_tool_choice_is_bad_request(tool_choice: dict[str, str], message: str, through_request: bool) -> None:
request: Final = AnthropicMessagesRequest(
model="test-model",
max_tokens=128,
messages=[{"role": "user", "content": "Hello"}],
tool_choice=tool_choice,
)
translate: Final = (
partial(_ADAPTER.translate_request, request)
if through_request
else partial(_ADAPTER.translate_tool_choice_to_responses_api, cast(AnthropicMessagesToolChoice, tool_choice))
)
with pytest.raises(litellm.BadRequestError) as exc_info:
translate()
assert exc_info.value.status_code == 400
assert message in exc_info.value.message