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:
mateo-berri 2026-10-28 05:47:22 -07:00
parent a0c490ea54
commit 1876df5614
18 changed files with 547 additions and 15 deletions

View file

@ -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]

View file

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

View file

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

View file

@ -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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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