From 50b379d85d2378e4719e8a21bb63e4883f2fdb1e Mon Sep 17 00:00:00 2001 From: Shreyansh Jain Date: Tue, 13 Oct 2026 16:18:31 +0530 Subject: [PATCH] fix(anthropic): return a 400 for an invalid /v1/messages tool_choice Both /v1/messages adapters took the client's tool_choice on trust. A dict with no type raised a bare KeyError on the chat-completions route, and a tag outside auto, any, tool and none raised a bare ValueError. Neither carries a status, so the Router retried them and a gateway in front reported a 500. The Responses adapter never raised at all, it fell through to "auto" and dropped the choice the client asked for. Validate tool_choice against AnthropicMessagesToolChoice once, in the helper module both adapters already import from, and raise litellm.BadRequestError naming the field the way the provider itself does --- .../messages/adapters/transformation.py | 29 ++++++++++--------- .../responses_adapters/transformation.py | 19 +++++++----- litellm/llms/anthropic/messages/utils.py | 29 +++++++++++++++++++ ...hropic_messages_adapters_transformation.py | 22 ++++++++++++++ .../test_responses_adapters_transformation.py | 20 +++++++++++++ 5 files changed, 97 insertions(+), 22 deletions(-) diff --git a/litellm/llms/anthropic/messages/adapters/transformation.py b/litellm/llms/anthropic/messages/adapters/transformation.py index 1ed552d46e3..6318df4b12d 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, + validated_anthropic_tool_choice, ) from litellm.responses.utils import auto_reasoning_summary @@ -781,20 +782,20 @@ class LiteLLMAnthropicMessagesAdapter: def translate_anthropic_tool_choice_to_openai( self, tool_choice: AnthropicMessagesToolChoice ) -> ChatCompletionToolChoiceValues: - if tool_choice["type"] == "any": - return "required" - elif tool_choice["type"] == "auto": - return "auto" - elif tool_choice["type"] == "tool": - # Truncate tool name if it exceeds OpenAI's 64-char limit - original_name: Final = tool_choice.get("name", "") - truncated_name: Final = truncate_tool_name(original_name) - tc_function_param: Final = ChatCompletionToolChoiceFunctionParam(name=truncated_name) - return ChatCompletionToolChoiceObjectParam(type="function", function=tc_function_param) - elif tool_choice["type"] == "none": - return "none" - else: - raise ValueError(f"Incompatible tool choice param submitted - {tool_choice}") + validated: Final = validated_anthropic_tool_choice(tool_choice) + match validated["type"]: + case "any": + return "required" + case "auto": + return "auto" + case "tool": + # Truncate tool name if it exceeds OpenAI's 64-char limit + original_name: Final = validated.get("name", "") + truncated_name: Final = truncate_tool_name(original_name) + tc_function_param: Final = ChatCompletionToolChoiceFunctionParam(name=truncated_name) + return ChatCompletionToolChoiceObjectParam(type="function", function=tc_function_param) + case "none": + return "none" def translate_anthropic_tools_to_openai( self, tools: list[AllAnthropicToolsValues], model: str | None = None diff --git a/litellm/llms/anthropic/messages/responses_adapters/transformation.py b/litellm/llms/anthropic/messages/responses_adapters/transformation.py index 1dd07c1739b..e7c198e4cc1 100644 --- a/litellm/llms/anthropic/messages/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/messages/responses_adapters/transformation.py @@ -29,6 +29,7 @@ from litellm.llms.anthropic.messages.utils import ( is_reasoning_auto_summary_enabled, json_copy_or_original, prompt_cache_key_from_user_id, + validated_anthropic_tool_choice, ) from litellm.types.llms.anthropic import ( AllAnthropicPassThroughMessageValues, @@ -496,14 +497,16 @@ class LiteLLMAnthropicToResponsesAPIAdapter: tool_choice: AnthropicMessagesToolChoice, ) -> str | dict[str, object]: """Convert Anthropic tool_choice to Responses API tool_choice.""" - tc_type: Final = tool_choice.get("type") - if tc_type == "any": - return "required" - elif tc_type == "tool": - return {"type": "function", "name": tool_choice.get("name", "")} - elif tc_type == "none": - return "none" - return "auto" + validated: Final = validated_anthropic_tool_choice(tool_choice) + match validated["type"]: + case "any": + return "required" + case "tool": + return {"type": "function", "name": validated.get("name", "")} + case "none": + return "none" + case "auto": + return "auto" @staticmethod def translate_context_management_to_responses_api( diff --git a/litellm/llms/anthropic/messages/utils.py b/litellm/llms/anthropic/messages/utils.py index 31619a04b74..84c13e76bad 100644 --- a/litellm/llms/anthropic/messages/utils.py +++ b/litellm/llms/anthropic/messages/utils.py @@ -5,11 +5,13 @@ from types import MappingProxyType from typing import TYPE_CHECKING, Final, TypeVar from pydantic import ConfigDict, TypeAdapter, ValidationError +from pydantic_core import ErrorDetails from typing_extensions import ReadOnly import litellm from litellm.constants import X_LITELLM_DISABLE_CALLBACKS 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 @@ -27,6 +29,33 @@ _EFFORT_DEGRADATION_CHAIN: Final[Mapping[str, tuple[str, ...]]] = MappingProxyTy ) _THINKING_OFF: Final = "none" +_ANTHROPIC_TOOL_CHOICE: Final = TypeAdapter(AnthropicMessagesToolChoice) + + +def _tool_choice_error(error: ErrorDetails) -> str: + field: Final = ".".join(map(str, error["loc"])) + return f"tool_choice.{field}: {error['msg']}" if field else f"tool_choice: {error['msg']}" + + +def validated_anthropic_tool_choice(tool_choice: object) -> AnthropicMessagesToolChoice: + """The client's ``tool_choice`` as the Messages API defines it, or a 400 naming what is wrong with it. + + Both ``/v1/messages`` adapters took the incoming dict on trust, so a missing or unknown ``type`` + either raised a bare ``KeyError``/``ValueError`` the Router then retried as a 500, or silently + became ``auto`` and dropped the client's choice. The provider answers the same request with a + 400, so every route through the adapters answers with one too. + """ + try: + return _ANTHROPIC_TOOL_CHOICE.validate_python(tool_choice) + except ValidationError as invalid: + detail: Final = "; ".join(_tool_choice_error(error) for error in invalid.errors()) + raise litellm.exceptions.BadRequestError( + message=detail, + model="", + llm_provider="anthropic", + ) from invalid + + _JsonValue: Final = TypeVar("_JsonValue") 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 4e4b89b764b..9df21348701 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 @@ -4049,6 +4049,28 @@ def test_translate_anthropic_tool_choice_none(): assert result == "none" +@pytest.mark.parametrize( + "tool_choice", + [ + {"name": "get_weather"}, + {"type": "web_search"}, + "auto", + ], +) +def test_translate_anthropic_tool_choice_invalid_is_a_400(tool_choice): + """ + Regression test for issue #46179. + + A tool_choice the Messages API itself rejects has to come back as a 400 naming the field it + came in on. Before, a missing type raised a bare KeyError and an unknown one a bare ValueError, + neither carrying a status, so the Router retried them and a gateway in front reported a 500. + """ + adapter = LiteLLMAnthropicMessagesAdapter() + + with pytest.raises(litellm.BadRequestError, match="tool_choice"): + adapter.translate_anthropic_tool_choice_to_openai(tool_choice) + + # --------------------------------------------------------------------------- # PolyfillResult integration tests # --------------------------------------------------------------------------- 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 f6fcb9e6f1e..80dd665e291 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 @@ -12,6 +12,7 @@ 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, @@ -947,6 +948,25 @@ class TestTranslateToolChoiceToResponsesAPI: result = _ADAPTER.translate_tool_choice_to_responses_api({"type": "tool", "name": "get_weather"}) assert result == {"type": "function", "name": "get_weather"} + @pytest.mark.parametrize( + "tool_choice", + [ + {"name": "get_weather"}, + {"type": "web_search"}, + "auto", + ], + ) + def test_invalid_tool_choice_is_a_400(self, tool_choice): + """ + Regression test for issue #46179. + + A tool_choice the Messages API itself rejects used to fall through to "auto" here, so the + client's choice was dropped and the request went upstream as a different one. It has to + come back as the same 400 the provider answers with. + """ + with pytest.raises(litellm.BadRequestError, match="tool_choice"): + _ADAPTER.translate_tool_choice_to_responses_api(tool_choice) + # --------------------------------------------------------------------------- # translate_thinking_to_reasoning