This commit is contained in:
Shreyansh Jain 2026-11-04 00:50:37 +00:00 • committed by GitHub
commit 199a2cf0c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 97 additions and 22 deletions

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,
validated_anthropic_tool_choice,
)
from litellm.responses.utils import auto_reasoning_summary
@ -803,20 +804,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

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,
validated_anthropic_tool_choice,
)
from litellm.types.llms.anthropic import (
AllAnthropicPassThroughMessageValues,
@ -488,14 +489,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(

View file

@ -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")

View file

@ -4108,6 +4108,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
# ---------------------------------------------------------------------------

View file

@ -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,
@ -948,6 +949,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