From 82bf4a682e03170167f9007ea4fc3186fe79ebdd Mon Sep 17 00:00:00 2001 From: er-s-an <92594860+er-s-an@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:32:09 +0800 Subject: [PATCH] fix(anthropic): reject malformed tool choices with a 400 --- .../anthropic/messages/adapters/handler.py | 2 +- .../messages/adapters/transformation.py | 4 +- .../messages/responses_adapters/handler.py | 2 +- .../responses_adapters/transformation.py | 4 +- litellm/llms/anthropic/messages/utils.py | 20 ++++++++++ ...hropic_messages_adapters_transformation.py | 40 +++++++++++++++++-- .../test_handler_output_config_passthrough.py | 19 +++++++++ .../test_responses_adapters_handler.py | 19 +++++++++ .../test_responses_adapters_transformation.py | 37 ++++++++++++++++- 9 files changed, 139 insertions(+), 8 deletions(-) diff --git a/litellm/llms/anthropic/messages/adapters/handler.py b/litellm/llms/anthropic/messages/adapters/handler.py index da120cad0e1..4b11203e8db 100644 --- a/litellm/llms/anthropic/messages/adapters/handler.py +++ b/litellm/llms/anthropic/messages/adapters/handler.py @@ -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 diff --git a/litellm/llms/anthropic/messages/adapters/transformation.py b/litellm/llms/anthropic/messages/adapters/transformation.py index afcd94c5b2c..76936a846f5 100644 --- a/litellm/llms/anthropic/messages/adapters/transformation.py +++ b/litellm/llms/anthropic/messages/adapters/transformation.py @@ -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) diff --git a/litellm/llms/anthropic/messages/responses_adapters/handler.py b/litellm/llms/anthropic/messages/responses_adapters/handler.py index ec09987837c..4f659846f3e 100644 --- a/litellm/llms/anthropic/messages/responses_adapters/handler.py +++ b/litellm/llms/anthropic/messages/responses_adapters/handler.py @@ -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) diff --git a/litellm/llms/anthropic/messages/responses_adapters/transformation.py b/litellm/llms/anthropic/messages/responses_adapters/transformation.py index 0630bef0311..699c09b1d95 100644 --- a/litellm/llms/anthropic/messages/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/messages/responses_adapters/transformation.py @@ -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) ) diff --git a/litellm/llms/anthropic/messages/utils.py b/litellm/llms/anthropic/messages/utils.py index 95c7914befa..44224140d49 100644 --- a/litellm/llms/anthropic/messages/utils.py +++ b/litellm/llms/anthropic/messages/utils.py @@ -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", + ) diff --git a/tests/unit/llms/anthropic/messages/adapters/test_anthropic_messages_adapters_transformation.py b/tests/unit/llms/anthropic/messages/adapters/test_anthropic_messages_adapters_transformation.py index cc366dcde4c..3dfea3b5501 100644 --- a/tests/unit/llms/anthropic/messages/adapters/test_anthropic_messages_adapters_transformation.py +++ b/tests/unit/llms/anthropic/messages/adapters/test_anthropic_messages_adapters_transformation.py @@ -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 diff --git a/tests/unit/llms/anthropic/messages/adapters/test_handler_output_config_passthrough.py b/tests/unit/llms/anthropic/messages/adapters/test_handler_output_config_passthrough.py index 12769e78329..6375440cb9a 100644 --- a/tests/unit/llms/anthropic/messages/adapters/test_handler_output_config_passthrough.py +++ b/tests/unit/llms/anthropic/messages/adapters/test_handler_output_config_passthrough.py @@ -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 diff --git a/tests/unit/llms/anthropic/messages/responses_adapters/test_responses_adapters_handler.py b/tests/unit/llms/anthropic/messages/responses_adapters/test_responses_adapters_handler.py index c06c9d721ac..1e451294576 100644 --- a/tests/unit/llms/anthropic/messages/responses_adapters/test_responses_adapters_handler.py +++ b/tests/unit/llms/anthropic/messages/responses_adapters/test_responses_adapters_handler.py @@ -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 diff --git a/tests/unit/llms/anthropic/messages/responses_adapters/test_responses_adapters_transformation.py b/tests/unit/llms/anthropic/messages/responses_adapters/test_responses_adapters_transformation.py index ece0cf5d423..2b8b7955083 100644 --- a/tests/unit/llms/anthropic/messages/responses_adapters/test_responses_adapters_transformation.py +++ b/tests/unit/llms/anthropic/messages/responses_adapters/test_responses_adapters_transformation.py @@ -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