mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
fix(anthropic): reject malformed tool choices with a 400
This commit is contained in:
parent
fb2a1450dd
commit
82bf4a682e
9 changed files with 139 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue