mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
fix(bedrock, gemini, deepseek, databricks, gigachat, anthropic): answer 400 for a hosted MCP tool a route cannot run, drop it under drop_params
A `type: "mcp"` tool sent to a route that rebuilds tools for its provider (Bedrock Converse and Nova, Bedrock Invoke Claude, Vertex AI Claude, Vertex AI and Google AI Studio Gemini, DeepSeek, Databricks Claude, GigaChat) was dropped silently and the request still answered 200. These routes now answer UnsupportedParamsError (400) naming the model, route and server, and pointing to the LiteLLM MCP gateway. Under drop_params the tool is dropped with a warning, other tools are kept, and no empty tools field is sent. Anthropic, Azure AI Claude and Claude Platform on AWS keep mapping the tool to the MCP connector. Native Responses providers and raw OpenAI-compatible forwarders are unchanged.
This commit is contained in:
parent
a0c490ea54
commit
1876df5614
18 changed files with 547 additions and 15 deletions
|
|
@ -1,13 +1,15 @@
|
|||
from collections.abc import Callable, Mapping
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from functools import reduce
|
||||
from typing import Annotated, Final, TypeAlias
|
||||
from typing import Annotated, Final, TypeAlias, TypeVar
|
||||
|
||||
from pydantic import BeforeValidator, ConfigDict, Json, TypeAdapter, ValidationError
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.core_helpers import normalize_drop_params
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.exceptions import UnsupportedParamsError
|
||||
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params, normalize_drop_params
|
||||
|
||||
ROUTES_WITH_TOOLS: Final = frozenset(
|
||||
{
|
||||
|
|
@ -162,3 +164,42 @@ def resolve_deployment_tool_fields(
|
|||
if "proxy_server_request" not in kwargs or route not in ROUTES_WITH_TOOLS:
|
||||
return kwargs
|
||||
return resolve_tool_fields(kwargs, lambda: (drop_policy(kwargs),))
|
||||
|
||||
|
||||
_ToolT: Final = TypeVar("_ToolT")
|
||||
|
||||
HOSTED_MCP_DROP_WARNING: Final = (
|
||||
"Dropping the hosted MCP tool(s) %s for model=%s on %s (drop_params=True) because this route cannot run a "
|
||||
"hosted MCP tool."
|
||||
)
|
||||
|
||||
|
||||
def _hosted_mcp_server_label(tool: object) -> str | None:
|
||||
match tool:
|
||||
case {"type": "mcp", "server_label": str() as server_label}:
|
||||
return server_label
|
||||
case {"type": "mcp"}:
|
||||
return "unlabeled"
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
||||
def tools_without_hosted_mcp(tools: Sequence[_ToolT], model: str, route: str, drop_params: bool) -> Sequence[_ToolT]:
|
||||
server_labels: Final = tuple(label for label in map(_hosted_mcp_server_label, tools) if label is not None)
|
||||
if not server_labels:
|
||||
return tools
|
||||
named_servers: Final = ", ".join(f"`{label}`" for label in server_labels)
|
||||
if not drops_unsupported_params(drop_params):
|
||||
raise UnsupportedParamsError(
|
||||
message=(
|
||||
f'{model} on {route} cannot run the hosted MCP tool {named_servers} (`type: "mcp"`). To have LiteLLM '
|
||||
"run the MCP server for this model, register it on the LiteLLM MCP gateway and send "
|
||||
'`server_url: "litellm_proxy/mcp/<server name>"`. To drop hosted MCP tools instead, set '
|
||||
"`litellm.drop_params=True` or for proxy:\n\n`litellm_settings:\n drop_params: true`\n"
|
||||
),
|
||||
model=model,
|
||||
llm_provider=route,
|
||||
status_code=400,
|
||||
)
|
||||
verbose_logger.warning(HOSTED_MCP_DROP_WARNING, named_servers, model, route)
|
||||
return [tool for tool in tools if _hosted_mcp_server_label(tool) is None]
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
|
|||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
import litellm
|
||||
from litellm.anthropic_beta_headers_manager import get_unsupported_headers
|
||||
from litellm.constants import (
|
||||
ANTHROPIC_MIN_THINKING_BUDGET_TOKENS,
|
||||
ANTHROPIC_WEB_SEARCH_TOOL_MAX_USES,
|
||||
|
|
@ -47,6 +48,7 @@ from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import
|
|||
place_mid_conversation_system,
|
||||
split_leading_system_run,
|
||||
)
|
||||
from litellm.litellm_core_utils.tool_fields import tools_without_hosted_mcp
|
||||
from litellm.llms.base_llm.base_utils import (
|
||||
ResponseFormatSchema,
|
||||
as_json_object,
|
||||
|
|
@ -1232,6 +1234,20 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
*(tool for tool in tools[first_search_tool_index + 1 :] if not _is_openai_tool_search_tool(tool)),
|
||||
]
|
||||
|
||||
def runs_hosted_mcp(self) -> bool:
|
||||
return ANTHROPIC_BETA_HEADER_VALUES.MCP_CLIENT_2025_04_04.value not in get_unsupported_headers(
|
||||
self._resolved_provider
|
||||
)
|
||||
|
||||
def _tools_with_hosted_mcp_checked(
|
||||
self, tools: Sequence[object], model: str, drop_params: bool
|
||||
) -> Sequence[object]:
|
||||
if self.runs_hosted_mcp():
|
||||
return tools
|
||||
return tools_without_hosted_mcp(
|
||||
tools=tools, model=model, route=self._resolved_provider, drop_params=drop_params
|
||||
)
|
||||
|
||||
def _tools_param_with_openai_tool_search_mapped(
|
||||
self, non_default_params: Mapping[str, object], model: str, drop_params: bool
|
||||
) -> Mapping[str, object]:
|
||||
|
|
@ -1837,8 +1853,17 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
if param == "max_tokens" or param == "max_completion_tokens":
|
||||
optional_params["max_tokens"] = value if isinstance(value, int) else max(1, int(round(value)))
|
||||
elif param == "tools":
|
||||
checked_tools = self._tools_with_hosted_mcp_checked(
|
||||
tools=_TOOLS_ADAPTER.validate_python(value), model=model, drop_params=drop_params
|
||||
)
|
||||
if value and not checked_tools:
|
||||
continue
|
||||
anthropic_tools, mcp_servers = self._map_tools(
|
||||
list(self._tools_with_openai_tool_search_mapped(tools=value, model=model, drop_params=drop_params))
|
||||
list(
|
||||
self._tools_with_openai_tool_search_mapped(
|
||||
tools=checked_tools, model=model, drop_params=drop_params
|
||||
)
|
||||
)
|
||||
)
|
||||
optional_params = self._add_tools_to_optional_params(
|
||||
optional_params=optional_params, tools=anthropic_tools
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import
|
|||
message_field,
|
||||
parts_of,
|
||||
)
|
||||
from litellm.litellm_core_utils.tool_fields import tools_without_hosted_mcp
|
||||
from litellm.llms.anthropic.chat.transformation import (
|
||||
DROP_UNSUPPORTED_ADAPTIVE_THINKING_WARNING,
|
||||
DROP_UNSUPPORTED_OUTPUT_CONFIG_WARNING,
|
||||
|
|
@ -1113,7 +1114,12 @@ class AmazonConverseConfig(BaseConfig):
|
|||
list[OpenAIChatCompletionToolParam],
|
||||
list(
|
||||
tools_without_unsupported_tool_search(
|
||||
tools=_TOOLS_AS_RECEIVED.validate_python(value),
|
||||
tools=tools_without_hosted_mcp(
|
||||
tools=_TOOLS_AS_RECEIVED.validate_python(value),
|
||||
model=model,
|
||||
route="bedrock",
|
||||
drop_params=drop_params,
|
||||
),
|
||||
model=model,
|
||||
route="bedrock",
|
||||
drop_params=drop_params,
|
||||
|
|
@ -2056,8 +2062,8 @@ class AmazonConverseConfig(BaseConfig):
|
|||
break
|
||||
|
||||
bedrock_tool_config: ToolConfigBlock | None = None
|
||||
tool_choice_values: Final[ToolChoiceValuesBlock | None] = inference_params.pop("tool_choice", None)
|
||||
if len(bedrock_tools) > 0:
|
||||
tool_choice_values: Final[ToolChoiceValuesBlock] = inference_params.pop("tool_choice", None)
|
||||
bedrock_tool_config = ToolConfigBlock(
|
||||
tools=bedrock_tools,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ class BedrockClaudePlatformConfig(BedrockClaudePlatformMixin, AnthropicConfig):
|
|||
def should_strip_billing_metadata(self) -> bool:
|
||||
return True
|
||||
|
||||
def runs_hosted_mcp(self) -> bool:
|
||||
return True
|
||||
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: dict,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
|||
strip_name_from_message,
|
||||
strip_prefill_prefix_field,
|
||||
)
|
||||
from litellm.litellm_core_utils.tool_fields import tools_without_hosted_mcp
|
||||
from litellm.litellm_core_utils.url_utils import join_api_base
|
||||
from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator
|
||||
from litellm.llms.base_llm.base_utils import (
|
||||
|
|
@ -397,14 +398,19 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig):
|
|||
anthropic_tools, _ = self._map_tools(
|
||||
tools=list(
|
||||
tools_without_unsupported_tool_search(
|
||||
tools=_TOOLS_ADAPTER.validate_python(tools),
|
||||
tools=tools_without_hosted_mcp(
|
||||
tools=_TOOLS_ADAPTER.validate_python(tools),
|
||||
model=model,
|
||||
route="databricks",
|
||||
drop_params=drop_params,
|
||||
),
|
||||
model=model,
|
||||
route="databricks",
|
||||
drop_params=drop_params,
|
||||
remedy="",
|
||||
)
|
||||
)
|
||||
) # unclear how mcp tool calling on databricks works
|
||||
)
|
||||
databricks_tools: Final = [
|
||||
cast(DatabricksTool, self.convert_anthropic_tool_to_databricks_tool(tool)) for tool in anthropic_tools
|
||||
]
|
||||
|
|
@ -455,9 +461,13 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig):
|
|||
model=model, optional_params=mapped_params, custom_llm_provider="databricks", drop_params=drop_params
|
||||
)
|
||||
if "tools" in mapped_params:
|
||||
mapped_params["tools"] = self._map_openai_to_dbrx_tool(
|
||||
dbrx_tools: Final = self._map_openai_to_dbrx_tool(
|
||||
model=model, tools=mapped_params["tools"], drop_params=drop_params
|
||||
)
|
||||
if mapped_params["tools"] and not dbrx_tools:
|
||||
del mapped_params["tools"]
|
||||
else:
|
||||
mapped_params["tools"] = dbrx_tools
|
||||
if "max_completion_tokens" in non_default_params and replace_max_completion_tokens_with_max_tokens:
|
||||
mapped_params["max_tokens"] = non_default_params[
|
||||
"max_completion_tokens"
|
||||
|
|
|
|||
|
|
@ -5,11 +5,14 @@ Translates from OpenAI's `/v1/chat/completions` to DeepSeek's `/v1/chat/completi
|
|||
from collections.abc import Coroutine, Mapping, Sequence
|
||||
from typing import Final, Literal, cast, overload
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
convert_content_list_to_str,
|
||||
extract_search_results_text,
|
||||
)
|
||||
from litellm.litellm_core_utils.tool_fields import tools_without_hosted_mcp
|
||||
from litellm.litellm_core_utils.url_utils import ensure_api_route
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
|
|
@ -17,6 +20,8 @@ from litellm.utils import supports_reasoning, supports_vision
|
|||
|
||||
from ...openai.chat.gpt_transformation import OpenAIGPTConfig
|
||||
|
||||
_TOOLS_AS_SENT: Final = TypeAdapter(list[object])
|
||||
_TOOL_SELECTION_PARAMS: Final = frozenset({"tools", "tool_choice", "parallel_tool_calls"})
|
||||
SUPPORTED_REASONING_EFFORTS: Final = ("minimal", "low", "medium", "high", "xhigh", "max", "ultra")
|
||||
|
||||
|
||||
|
|
@ -47,7 +52,11 @@ class DeepSeekChatConfig(OpenAIGPTConfig):
|
|||
Reference: https://api-docs.deepseek.com/guides/thinking_mode
|
||||
"""
|
||||
# Let parent handle standard params first
|
||||
optional_params = super().map_openai_params(non_default_params, optional_params, model, drop_params)
|
||||
optional_params = {
|
||||
**self._without_hosted_mcp_tools(
|
||||
super().map_openai_params(non_default_params, optional_params, model, drop_params), model, drop_params
|
||||
)
|
||||
}
|
||||
|
||||
# Pop thinking/reasoning_effort from optional_params first (parent may have added them)
|
||||
# Then re-add only if valid for DeepSeek
|
||||
|
|
@ -234,6 +243,20 @@ class DeepSeekChatConfig(OpenAIGPTConfig):
|
|||
and thinking.get("type") == "enabled"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _without_hosted_mcp_tools(
|
||||
optional_params: Mapping[str, object], model: str, drop_params: bool
|
||||
) -> Mapping[str, object]:
|
||||
tools: Final = optional_params.get("tools")
|
||||
if not isinstance(tools, list) or not tools:
|
||||
return optional_params
|
||||
kept_tools: Final = tools_without_hosted_mcp(
|
||||
tools=_TOOLS_AS_SENT.validate_python(tools), model=model, route="deepseek", drop_params=drop_params
|
||||
)
|
||||
if kept_tools:
|
||||
return {**optional_params, "tools": list(kept_tools)}
|
||||
return {k: v for k, v in optional_params.items() if k not in _TOOL_SELECTION_PARAMS}
|
||||
|
||||
@staticmethod
|
||||
def _drop_unsupported_tools(optional_params: dict) -> dict:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from collections.abc import Mapping
|
||||
from typing import Final, cast
|
||||
from typing import ClassVar, Final, cast
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.prompt_templates.factory import (
|
||||
|
|
@ -49,6 +49,8 @@ class GoogleAIStudioGeminiConfig(VertexGeminiConfig):
|
|||
Note: Please make sure to modify the default parameters as required for your use case.
|
||||
"""
|
||||
|
||||
_route: ClassVar[str] = "gemini"
|
||||
|
||||
temperature: float | None = None
|
||||
max_output_tokens: int | None = None
|
||||
top_p: float | None = None
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import httpx
|
|||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.litellm_core_utils.tool_fields import tools_without_hosted_mcp
|
||||
from litellm.litellm_core_utils.url_utils import join_api_base
|
||||
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
|
||||
from litellm.llms.gigachat.utils import convert_usage, get_api_base
|
||||
|
|
@ -38,6 +39,7 @@ else:
|
|||
|
||||
_EMPTY_FUNCTION: Final[Mapping[str, object]] = MappingProxyType({})
|
||||
_JSON_VALUE: Final = TypeAdapter(object)
|
||||
_TOOLS_AS_SENT: Final = TypeAdapter(list[object])
|
||||
|
||||
|
||||
def is_valid_json(value: str) -> bool:
|
||||
|
|
@ -179,7 +181,14 @@ class GigaChatConfig(BaseConfig):
|
|||
elif param == "tools":
|
||||
# Convert tools to functions format
|
||||
if isinstance(value, Sequence):
|
||||
optional_params["functions"] = self._convert_tools_to_functions(value)
|
||||
gigachat_tools = tools_without_hosted_mcp(
|
||||
tools=_TOOLS_AS_SENT.validate_python(value),
|
||||
model=model,
|
||||
route="gigachat",
|
||||
drop_params=drop_params,
|
||||
)
|
||||
if gigachat_tools:
|
||||
optional_params["functions"] = self._convert_tools_to_functions(gigachat_tools)
|
||||
elif param == "tool_choice":
|
||||
# Map OpenAI tool_choice to GigaChat function_call
|
||||
if isinstance(value, (str, Mapping)):
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|||
from copy import deepcopy
|
||||
from functools import lru_cache, partial
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, Protocol, Union, cast, get_args
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, Optional, Protocol, Union, cast, get_args
|
||||
|
||||
import httpx
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
|
@ -34,6 +34,7 @@ from litellm.litellm_core_utils.json_fragment_accumulator import JSONFragmentAcc
|
|||
from litellm.litellm_core_utils.prompt_templates.factory import (
|
||||
encode_tool_call_id_with_signature,
|
||||
)
|
||||
from litellm.litellm_core_utils.tool_fields import tools_without_hosted_mcp
|
||||
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
AsyncHTTPHandler,
|
||||
|
|
@ -251,6 +252,7 @@ _GEMINI_SPEECH_CONFIG_KEY_MAP: Final[Mapping[str, str]] = MappingProxyType(
|
|||
|
||||
|
||||
_SPEECH_CONFIG_ITEMS: Final = TypeAdapter(list[object])
|
||||
_TOOLS_AS_SENT: Final = TypeAdapter(list[object])
|
||||
|
||||
|
||||
def _normalize_gemini_speech_config_item(value: Mapping[str, object]) -> Mapping[str, object]:
|
||||
|
|
@ -579,6 +581,8 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
|
|||
Note: Please make sure to modify the default parameters as required for your use case.
|
||||
"""
|
||||
|
||||
_route: ClassVar[str] = "vertex_ai"
|
||||
|
||||
temperature: float | None = None
|
||||
max_output_tokens: int | None = None
|
||||
top_p: float | None = None
|
||||
|
|
@ -1023,7 +1027,9 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
|
|||
|
||||
return googleSearch, googleSearchRetrieval, enterpriseWebSearch, urlContext
|
||||
|
||||
def _map_function(self, value: list[dict], optional_params: dict, send_integer_enums: bool = False) -> list[Tools]:
|
||||
def _map_function(
|
||||
self, value: Sequence[object], optional_params: dict, send_integer_enums: bool = False
|
||||
) -> list[Tools]:
|
||||
"""
|
||||
Map OpenAI-style tools/functions to Vertex AI format.
|
||||
|
||||
|
|
@ -1667,9 +1673,19 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
|
|||
elif param == "top_logprobs":
|
||||
optional_params["logprobs"] = value
|
||||
elif (param == "tools" or param == "functions") and isinstance(value, list) and value:
|
||||
gemini_tools = list(
|
||||
tools_without_hosted_mcp(
|
||||
tools=_TOOLS_AS_SENT.validate_python(value),
|
||||
model=model,
|
||||
route=self._route,
|
||||
drop_params=drop_params,
|
||||
)
|
||||
)
|
||||
if not gemini_tools:
|
||||
continue
|
||||
# Pass optional_params so _map_function can add toolConfig if needed
|
||||
mapped_tools = with_integer_enums_that_fit(
|
||||
partial(self._map_function, value, optional_params),
|
||||
partial(self._map_function, gemini_tools, optional_params),
|
||||
allow_integer_enums and self._is_gemini_3_or_newer(model),
|
||||
)
|
||||
optional_params = self._add_tools_to_optional_params(optional_params, mapped_tools)
|
||||
|
|
|
|||
|
|
@ -815,6 +815,7 @@ class ANTHROPIC_BETA_HEADER_VALUES(str, Enum):
|
|||
ADVISOR_TOOL_2026_03_01 = "advisor-tool-2026-03-01"
|
||||
PER_TURN_CONTROL_2026_07_01 = "per-turn-control-2026-07-01"
|
||||
DANGEROUS_TOOL_USE_2026_09_03 = "dangerous-tool-use-2026-09-03"
|
||||
MCP_CLIENT_2025_04_04 = "mcp-client-2025-04-04"
|
||||
|
||||
|
||||
# Tool search beta header constant (for Anthropic direct API and Microsoft Foundry)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import pytest
|
|||
import litellm
|
||||
from litellm import Router
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.litellm_core_utils.tool_fields import tools_without_hosted_mcp
|
||||
from litellm.utils import async_pre_call_deployment_hook
|
||||
|
||||
_GOOD_TOOL: Final = {"type": "function", "function": {"name": "get_weather", "parameters": {"type": "object"}}}
|
||||
|
|
@ -185,3 +186,18 @@ async def test_tools_core_accepts_reach_the_deployment(tools: object) -> None:
|
|||
sent: Final = await async_pre_call_deployment_hook(dict(_proxy_request("keeps", tools=tools)), "acompletion")
|
||||
|
||||
assert sent["tools"] is tools
|
||||
|
||||
|
||||
def test_a_hosted_mcp_error_names_every_server_and_marks_an_unlabeled_one(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
with pytest.raises(litellm.UnsupportedParamsError) as raised:
|
||||
tools_without_hosted_mcp(
|
||||
tools=[
|
||||
{"type": "mcp", "server_label": "deepwiki", "server_url": "https://mcp.deepwiki.com/mcp"},
|
||||
{"type": "mcp", "server_url": "https://mcp.example.com/mcp"},
|
||||
],
|
||||
model="gemini-3.8-flash",
|
||||
route="gemini",
|
||||
drop_params=False,
|
||||
)
|
||||
assert "the hosted MCP tool `deepwiki`, `unlabeled` " in str(raised.value)
|
||||
|
|
|
|||
|
|
@ -9804,3 +9804,81 @@ def test_transform_request_tool_config_point_without_tools_drops_the_key():
|
|||
assert "cache_control_injection_points" not in data
|
||||
assert "tools" not in data
|
||||
assert litellm_params["metadata"] == {}
|
||||
|
||||
|
||||
_HOSTED_MCP_TOOL: Final = {
|
||||
"type": "mcp",
|
||||
"server_label": "deepwiki",
|
||||
"server_url": "https://mcp.deepwiki.com/mcp",
|
||||
"require_approval": "never",
|
||||
}
|
||||
_WEATHER_FUNCTION_TOOL: Final = {
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "parameters": {"type": "object", "properties": {}}},
|
||||
}
|
||||
_ROUTES_WITHOUT_THE_MCP_CONNECTOR: Final = (
|
||||
pytest.param(AmazonAnthropicClaudeConfig, "bedrock", id="bedrock_invoke"),
|
||||
pytest.param(VertexAIAnthropicConfig, "vertex_ai", id="vertex_ai"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("config_class", [AnthropicConfig, AzureAnthropicConfig, BedrockClaudePlatformConfig])
|
||||
def test_routes_with_the_mcp_connector_send_a_hosted_mcp_tool_as_an_mcp_server(monkeypatch, config_class):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
mapped: Final = config_class().map_openai_params(
|
||||
non_default_params={"tools": [_WEATHER_FUNCTION_TOOL, _HOSTED_MCP_TOOL]},
|
||||
optional_params={},
|
||||
model="claude-haiku-5-5",
|
||||
drop_params=False,
|
||||
)
|
||||
assert [server["url"] for server in mapped["mcp_servers"]] == [_HOSTED_MCP_TOOL["server_url"]]
|
||||
assert [tool["name"] for tool in mapped["tools"]] == ["get_weather"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("config_class", "route"), _ROUTES_WITHOUT_THE_MCP_CONNECTOR)
|
||||
def test_a_hosted_mcp_tool_answers_400_on_claude_routes_without_the_mcp_connector(monkeypatch, config_class, route):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
with pytest.raises(litellm.utils.UnsupportedParamsError) as raised:
|
||||
config_class().map_openai_params(
|
||||
non_default_params={"tools": [_WEATHER_FUNCTION_TOOL, _HOSTED_MCP_TOOL]},
|
||||
optional_params={},
|
||||
model="claude-haiku-5-5",
|
||||
drop_params=False,
|
||||
)
|
||||
assert raised.value.status_code == 400
|
||||
assert f"claude-haiku-5-5 on {route} " in str(raised.value)
|
||||
assert "`deepwiki`" in str(raised.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("config_class", "route"), _ROUTES_WITHOUT_THE_MCP_CONNECTOR)
|
||||
def test_claude_routes_without_the_mcp_connector_drop_a_hosted_mcp_tool_under_drop_params(
|
||||
monkeypatch, caplog, config_class, route
|
||||
):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
with caplog.at_level("WARNING"):
|
||||
mapped: Final = config_class().map_openai_params(
|
||||
non_default_params={"tools": [_HOSTED_MCP_TOOL, _WEATHER_FUNCTION_TOOL]},
|
||||
optional_params={},
|
||||
model="claude-haiku-5-5",
|
||||
drop_params=True,
|
||||
)
|
||||
assert [tool["name"] for tool in mapped["tools"]] == ["get_weather"]
|
||||
assert "mcp_servers" not in mapped
|
||||
assert any(
|
||||
"`deepwiki`" in record.getMessage() and f" on {route} (" in record.getMessage() for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("config_class", "route"), _ROUTES_WITHOUT_THE_MCP_CONNECTOR)
|
||||
def test_claude_routes_without_the_mcp_connector_send_no_tools_when_drop_params_drops_every_one(
|
||||
monkeypatch, config_class, route
|
||||
):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
mapped: Final = config_class().map_openai_params(
|
||||
non_default_params={"tools": [_HOSTED_MCP_TOOL]},
|
||||
optional_params={},
|
||||
model="claude-haiku-5-5",
|
||||
drop_params=True,
|
||||
)
|
||||
assert "tools" not in mapped
|
||||
assert "mcp_servers" not in mapped
|
||||
|
|
|
|||
|
|
@ -10789,3 +10789,67 @@ def test_a_deployment_with_a_batch_control_plane_endpoint_keeps_it_out_of_chat_r
|
|||
|
||||
assert response.choices[0].message.content == "ok"
|
||||
assert "aws_bedrock_control_plane_endpoint" not in route.calls[0].request.content.decode()
|
||||
|
||||
|
||||
_HOSTED_MCP_TOOL: Final = {
|
||||
"type": "mcp",
|
||||
"server_label": "deepwiki",
|
||||
"server_url": "https://mcp.deepwiki.com/mcp",
|
||||
"require_approval": "never",
|
||||
}
|
||||
_WEATHER_FUNCTION_TOOL: Final = {
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "parameters": {"type": "object", "properties": {}}},
|
||||
}
|
||||
|
||||
|
||||
def _converse_request_for_tools(
|
||||
model: str, non_default_params: Mapping[str, object], drop_params: bool
|
||||
) -> Mapping[str, object]:
|
||||
config: Final = AmazonConverseConfig()
|
||||
return config._transform_request_helper(
|
||||
model=model,
|
||||
system_content_blocks=[],
|
||||
optional_params=config.map_openai_params(
|
||||
non_default_params=dict(non_default_params), optional_params={}, model=model, drop_params=drop_params
|
||||
),
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model", ["us.openai.gpt-oss-120b-1:0", "us.anthropic.claude-haiku-5-5", "us.amazon.nova-2-lite-v1:0"]
|
||||
)
|
||||
def test_a_hosted_mcp_tool_answers_400_on_converse_without_drop_params(monkeypatch, model):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
with pytest.raises(litellm.utils.UnsupportedParamsError) as raised:
|
||||
_converse_request_for_tools(model, {"tools": [_WEATHER_FUNCTION_TOOL, _HOSTED_MCP_TOOL]}, drop_params=False)
|
||||
assert raised.value.status_code == 400
|
||||
assert model in str(raised.value)
|
||||
assert "`deepwiki`" in str(raised.value)
|
||||
assert 'server_url: "litellm_proxy/mcp/<server name>"' in str(raised.value)
|
||||
assert "drop_params: true" in str(raised.value)
|
||||
|
||||
|
||||
def test_converse_drops_a_hosted_mcp_tool_under_drop_params_and_keeps_the_function_tool(monkeypatch, caplog):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
model: Final = "us.openai.gpt-oss-120b-1:0"
|
||||
with caplog.at_level("WARNING"):
|
||||
request: Final = _converse_request_for_tools(
|
||||
model, {"tools": [_HOSTED_MCP_TOOL, _WEATHER_FUNCTION_TOOL]}, drop_params=True
|
||||
)
|
||||
assert [tool["toolSpec"]["name"] for tool in request["toolConfig"]["tools"]] == ["get_weather"]
|
||||
assert any(
|
||||
"`deepwiki`" in record.getMessage() and f"model={model} on bedrock (" in record.getMessage()
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_converse_sends_no_tools_when_drop_params_drops_every_hosted_mcp_tool(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
request: Final = _converse_request_for_tools(
|
||||
"us.openai.gpt-oss-120b-1:0", {"tools": [_HOSTED_MCP_TOOL], "tool_choice": "auto"}, drop_params=True
|
||||
)
|
||||
assert "toolConfig" not in request
|
||||
assert "toolChoice" not in json.dumps(request)
|
||||
assert "tool_choice" not in json.dumps(request)
|
||||
|
|
|
|||
|
|
@ -2738,3 +2738,55 @@ def test_claude_json_schema_stream_split_across_json_tools_keeps_the_non_stream_
|
|||
choices: Final = _json_schema_stream_choices(respx_mock, call, split_stream)
|
||||
|
||||
assert [choice.finish_reason for choice in choices if choice.finish_reason] == [answer.finish_reason]
|
||||
|
||||
|
||||
_HOSTED_MCP_TOOL: Final = {
|
||||
"type": "mcp",
|
||||
"server_label": "deepwiki",
|
||||
"server_url": "https://mcp.deepwiki.com/mcp",
|
||||
"require_approval": "never",
|
||||
}
|
||||
_WEATHER_FUNCTION_TOOL: Final = {
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "parameters": {"type": "object", "properties": {}}},
|
||||
}
|
||||
|
||||
|
||||
def test_databricks_claude_answers_400_for_a_hosted_mcp_tool_without_drop_params(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
with pytest.raises(litellm.utils.UnsupportedParamsError) as raised:
|
||||
DatabricksConfig().map_openai_params(
|
||||
non_default_params={"tools": [_WEATHER_FUNCTION_TOOL, _HOSTED_MCP_TOOL]},
|
||||
optional_params={},
|
||||
model="databricks-claude-opus-5-5",
|
||||
drop_params=False,
|
||||
)
|
||||
assert raised.value.status_code == 400
|
||||
assert "databricks-claude-opus-5-5 on databricks " in str(raised.value)
|
||||
assert "`deepwiki`" in str(raised.value)
|
||||
|
||||
|
||||
def test_databricks_claude_drops_a_hosted_mcp_tool_under_drop_params_and_keeps_the_function_tool(monkeypatch, caplog):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
with caplog.at_level("WARNING"):
|
||||
mapped: Final = DatabricksConfig().map_openai_params(
|
||||
non_default_params={"tools": [_HOSTED_MCP_TOOL, _WEATHER_FUNCTION_TOOL]},
|
||||
optional_params={},
|
||||
model="databricks-claude-opus-5-5",
|
||||
drop_params=True,
|
||||
)
|
||||
assert [tool["function"]["name"] for tool in mapped["tools"]] == ["get_weather"]
|
||||
assert any(
|
||||
"`deepwiki`" in record.getMessage() and " on databricks (" in record.getMessage() for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_databricks_claude_sends_no_tools_when_drop_params_drops_every_hosted_mcp_tool(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
mapped: Final = DatabricksConfig().map_openai_params(
|
||||
non_default_params={"tools": [_HOSTED_MCP_TOOL]},
|
||||
optional_params={},
|
||||
model="databricks-claude-opus-5-5",
|
||||
drop_params=True,
|
||||
)
|
||||
assert "tools" not in mapped
|
||||
|
|
|
|||
|
|
@ -951,3 +951,48 @@ def test_get_complete_url_ignores_a_trailing_slash_on_api_base(api_base: str):
|
|||
)
|
||||
== "https://gateway.example.com/deepseek/chat/completions"
|
||||
)
|
||||
|
||||
|
||||
_HOSTED_MCP_TOOL: Final = {
|
||||
"type": "mcp",
|
||||
"server_label": "deepwiki",
|
||||
"server_url": "https://mcp.deepwiki.com/mcp",
|
||||
"require_approval": "never",
|
||||
}
|
||||
|
||||
|
||||
def test_a_hosted_mcp_tool_answers_400_on_deepseek_without_drop_params(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
with pytest.raises(litellm.utils.UnsupportedParamsError) as raised:
|
||||
DeepSeekChatConfig().map_openai_params(
|
||||
{"tools": [_function_tool("shell"), _HOSTED_MCP_TOOL]}, {}, "deepseek-v4-flash", False
|
||||
)
|
||||
assert raised.value.status_code == 400
|
||||
assert "deepseek-v4-flash on deepseek " in str(raised.value)
|
||||
assert "`deepwiki`" in str(raised.value)
|
||||
|
||||
|
||||
def test_deepseek_drops_a_hosted_mcp_tool_under_drop_params_and_keeps_the_function_tool(monkeypatch, caplog):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
with caplog.at_level("WARNING"):
|
||||
result: Final = DeepSeekChatConfig().map_openai_params(
|
||||
{"tools": [_HOSTED_MCP_TOOL, _function_tool("shell")], "tool_choice": "auto"}, {}, "deepseek-v4-flash", True
|
||||
)
|
||||
assert result["tools"] == [_function_tool("shell")]
|
||||
assert result["tool_choice"] == "auto"
|
||||
assert any(
|
||||
"`deepwiki`" in record.getMessage() and " on deepseek (" in record.getMessage() for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_deepseek_sends_no_tools_when_drop_params_drops_every_hosted_mcp_tool(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
result: Final = DeepSeekChatConfig().map_openai_params(
|
||||
{"tools": [_HOSTED_MCP_TOOL], "tool_choice": "required", "parallel_tool_calls": True},
|
||||
{},
|
||||
"deepseek-v4-flash",
|
||||
True,
|
||||
)
|
||||
assert "tools" not in result
|
||||
assert "tool_choice" not in result
|
||||
assert "parallel_tool_calls" not in result
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@ Run with: pytest tests/unit/llms/gigachat/chat/test_transformation.py -v
|
|||
|
||||
import asyncio
|
||||
import importlib
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.llms.gigachat.chat.transformation import GigaChatConfig
|
||||
from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome
|
||||
|
||||
|
||||
|
|
@ -575,3 +577,56 @@ _SCALAR_DEFAULTS = {
|
|||
"api_key": getattr(litellm, "api_key", None),
|
||||
"cohere_key": getattr(litellm, "cohere_key", None),
|
||||
}
|
||||
|
||||
|
||||
_HOSTED_MCP_TOOL: Final = {
|
||||
"type": "mcp",
|
||||
"server_label": "deepwiki",
|
||||
"server_url": "https://mcp.deepwiki.com/mcp",
|
||||
"require_approval": "never",
|
||||
}
|
||||
_WEATHER_FUNCTION_TOOL: Final = {
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "parameters": {"type": "object", "properties": {}}},
|
||||
}
|
||||
|
||||
|
||||
def test_a_hosted_mcp_tool_answers_400_on_gigachat_without_drop_params(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
with pytest.raises(litellm.utils.UnsupportedParamsError) as raised:
|
||||
GigaChatConfig().map_openai_params(
|
||||
non_default_params={"tools": [_WEATHER_FUNCTION_TOOL, _HOSTED_MCP_TOOL]},
|
||||
optional_params={},
|
||||
model="GigaChat-2-Max",
|
||||
drop_params=False,
|
||||
)
|
||||
assert raised.value.status_code == 400
|
||||
assert "GigaChat-2-Max on gigachat " in str(raised.value)
|
||||
assert "`deepwiki`" in str(raised.value)
|
||||
|
||||
|
||||
def test_gigachat_drops_a_hosted_mcp_tool_under_drop_params_and_keeps_the_function_tool(monkeypatch, caplog):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
with caplog.at_level("WARNING"):
|
||||
mapped: Final = GigaChatConfig().map_openai_params(
|
||||
non_default_params={"tools": [_HOSTED_MCP_TOOL, _WEATHER_FUNCTION_TOOL]},
|
||||
optional_params={},
|
||||
model="GigaChat-2-Max",
|
||||
drop_params=True,
|
||||
)
|
||||
assert [function["name"] for function in mapped["functions"]] == ["get_weather"]
|
||||
assert any(
|
||||
"`deepwiki`" in record.getMessage() and " on gigachat (" in record.getMessage() for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_gigachat_sends_no_functions_when_drop_params_drops_every_hosted_mcp_tool(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
mapped: Final = GigaChatConfig().map_openai_params(
|
||||
non_default_params={"tools": [_HOSTED_MCP_TOOL]},
|
||||
optional_params={},
|
||||
model="GigaChat-2-Max",
|
||||
drop_params=True,
|
||||
)
|
||||
assert "functions" not in mapped
|
||||
assert "tools" not in mapped
|
||||
|
|
|
|||
|
|
@ -10654,3 +10654,66 @@ async def test_gemini_sends_a_boolean_for_a_gateway_default_written_as_text_or_n
|
|||
sent: Final = await _sent_thinking_config("gemini-3.8-flash", {"reasoning_effort": "high"})
|
||||
|
||||
assert sent["includeThoughts"] is expected
|
||||
|
||||
|
||||
_HOSTED_MCP_TOOL: Final = {
|
||||
"type": "mcp",
|
||||
"server_label": "deepwiki",
|
||||
"server_url": "https://mcp.deepwiki.com/mcp",
|
||||
"require_approval": "never",
|
||||
}
|
||||
_WEATHER_FUNCTION_TOOL: Final = {
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "parameters": {"type": "object", "properties": {}}},
|
||||
}
|
||||
_GEMINI_ROUTES: Final = (
|
||||
pytest.param(VertexGeminiConfig, "vertex_ai", id="vertex_ai"),
|
||||
pytest.param(GoogleAIStudioGeminiConfig, "gemini", id="gemini"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("config_class", "route"), _GEMINI_ROUTES)
|
||||
def test_a_hosted_mcp_tool_answers_400_on_gemini_without_drop_params(monkeypatch, config_class, route):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
with pytest.raises(litellm.utils.UnsupportedParamsError) as raised:
|
||||
config_class().map_openai_params(
|
||||
non_default_params={"tools": [_WEATHER_FUNCTION_TOOL, _HOSTED_MCP_TOOL]},
|
||||
optional_params={},
|
||||
model="gemini-3.8-flash",
|
||||
drop_params=False,
|
||||
)
|
||||
assert raised.value.status_code == 400
|
||||
assert f"gemini-3.8-flash on {route} " in str(raised.value)
|
||||
assert "`deepwiki`" in str(raised.value)
|
||||
assert "litellm_proxy/mcp/" in str(raised.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("config_class", "route"), _GEMINI_ROUTES)
|
||||
def test_gemini_drops_a_hosted_mcp_tool_under_drop_params_and_keeps_the_function_tool(
|
||||
monkeypatch, caplog, config_class, route
|
||||
):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
with caplog.at_level("WARNING"):
|
||||
mapped: Final = config_class().map_openai_params(
|
||||
non_default_params={"tools": [_HOSTED_MCP_TOOL, _WEATHER_FUNCTION_TOOL]},
|
||||
optional_params={},
|
||||
model="gemini-3.8-flash",
|
||||
drop_params=True,
|
||||
)
|
||||
assert "deepwiki" not in json.dumps(mapped)
|
||||
assert "get_weather" in json.dumps(mapped["tools"])
|
||||
assert any(
|
||||
"`deepwiki`" in record.getMessage() and f" on {route} (" in record.getMessage() for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("config_class", "route"), _GEMINI_ROUTES)
|
||||
def test_gemini_sends_no_tools_when_drop_params_drops_every_hosted_mcp_tool(monkeypatch, config_class, route):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
mapped: Final = config_class().map_openai_params(
|
||||
non_default_params={"tools": [_HOSTED_MCP_TOOL]},
|
||||
optional_params={},
|
||||
model="gemini-3.8-flash",
|
||||
drop_params=True,
|
||||
)
|
||||
assert "tools" not in mapped
|
||||
|
|
|
|||
|
|
@ -7052,3 +7052,26 @@ async def test_bridge_partial_response_on_disconnect_bills_only_the_input_since_
|
|||
assert await _input_tokens_billed_on_disconnect(
|
||||
cast(ResponseInputParam, [*history_it_replaces, *since_compaction])
|
||||
) == await _input_tokens_billed_on_disconnect(since_compaction)
|
||||
|
||||
|
||||
def test_a_hosted_mcp_tool_sent_to_responses_on_bedrock_converse_answers_400(monkeypatch) -> None:
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
with pytest.raises(litellm.UnsupportedParamsError) as raised:
|
||||
litellm.responses(
|
||||
model="bedrock/converse/us.openai.gpt-oss-120b-1:0",
|
||||
input="What does the deepwiki MCP server say about litellm?",
|
||||
tools=[
|
||||
{
|
||||
"type": "mcp",
|
||||
"server_label": "deepwiki",
|
||||
"server_url": "https://mcp.deepwiki.com/mcp",
|
||||
"require_approval": "never",
|
||||
}
|
||||
],
|
||||
aws_access_key_id="AKIAHOSTEDMCP",
|
||||
aws_secret_access_key="hosted-mcp-secret",
|
||||
aws_region_name="us-east-1",
|
||||
)
|
||||
assert raised.value.status_code == 400
|
||||
assert "`deepwiki`" in str(raised.value)
|
||||
assert "litellm_proxy/mcp/" in str(raised.value)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue