fix(mcp): check hosted MCP tools once per request and keep base tool_choice handling

Each route now runs one request-level step before its param mapping. When the drop leaves no tools,
the request sends no tools, tool_choice or parallel_tool_calls. Converse and GigaChat go back to their
base tool_choice and empty-tools handling.
This commit is contained in:
mateo-berri 2026-10-28 15:59:23 -07:00
parent 9a3604f56d
commit cb9e031571
12 changed files with 140 additions and 132 deletions

View file

@ -1,4 +1,5 @@
from collections.abc import Callable, Mapping, Sequence
from copy import copy
from dataclasses import dataclass
from functools import reduce
from typing import Annotated, Final, TypeAlias, TypeVar
@ -203,3 +204,24 @@ def tools_without_hosted_mcp(tools: Sequence[_ToolT], model: str, route: str, dr
)
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]
_TOOL_SELECTION_PARAMS: Final = frozenset({"tools", "tool_choice", "parallel_tool_calls"})
_ParamsT: Final = TypeVar("_ParamsT", bound=dict[str, object])
def tool_params_without_hosted_mcp(params: _ParamsT, model: str, route: str, drop_params: bool) -> _ParamsT:
tools: Final = params.get("tools")
if not isinstance(tools, list) or not tools:
return params
sent_tools: Final = _TOOLS.validate_python(tools)
kept_tools: Final = tools_without_hosted_mcp(tools=sent_tools, model=model, route=route, drop_params=drop_params)
if len(kept_tools) == len(sent_tools):
return params
kept_params: Final = copy(params)
if kept_tools:
kept_params["tools"] = list(kept_tools)
return kept_params
for key in _TOOL_SELECTION_PARAMS:
_ = kept_params.pop(key, None)
return kept_params

View file

@ -48,7 +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.litellm_core_utils.tool_fields import tool_params_without_hosted_mcp
from litellm.llms.base_llm.base_utils import (
ResponseFormatSchema,
as_json_object,
@ -1239,15 +1239,6 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
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]:
@ -1836,7 +1827,14 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
model: str,
drop_params: bool,
) -> dict:
is_thinking_enabled: Final = self.is_thinking_enabled(non_default_params=non_default_params)
request_params: Final = (
non_default_params
if self.runs_hosted_mcp()
else tool_params_without_hosted_mcp(
non_default_params, model=model, route=self._resolved_provider, drop_params=drop_params
)
)
is_thinking_enabled: Final = self.is_thinking_enabled(non_default_params=request_params)
# NB: ``map_openai_params`` deliberately does NOT sanitize tool names
# here. Names are the *original* OpenAI names at this stage, and must
@ -1849,21 +1847,12 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
# via ``data = {**optional_params}`` and would 400 with
# ``Extra inputs are not permitted``).
for param, value in non_default_params.items():
for param, value in request_params.items():
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=checked_tools, model=model, drop_params=drop_params
)
)
list(self._tools_with_openai_tool_search_mapped(tools=value, model=model, drop_params=drop_params))
)
optional_params = self._add_tools_to_optional_params(
optional_params=optional_params, tools=anthropic_tools
@ -1872,8 +1861,8 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
optional_params["mcp_servers"] = mcp_servers
elif param == "tool_choice" or param == "parallel_tool_calls":
_tool_choice: AnthropicMessagesToolChoice | None = self._map_tool_choice(
tool_choice=non_default_params.get("tool_choice"),
parallel_tool_use=non_default_params.get("parallel_tool_calls"),
tool_choice=request_params.get("tool_choice"),
parallel_tool_use=request_params.get("parallel_tool_calls"),
)
if _tool_choice is not None:
@ -1899,7 +1888,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
if self.uses_native_structured_output_for_schema(
model,
self._extract_json_schema_from_response_format(value),
REQUEST_PARAMS.validate_python(non_default_params),
REQUEST_PARAMS.validate_python(request_params),
):
_output_format = self.map_response_format_to_anthropic_output_format(value)
if _output_format is not None:
@ -1935,7 +1924,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
# `thinking={type: enabled, budget_tokens}` interface a
# pre-4.6 model actually supports instead of forwarding a
# shape the model will reject.
max_tokens = non_default_params.get("max_completion_tokens") or non_default_params.get("max_tokens")
max_tokens = request_params.get("max_completion_tokens") or request_params.get("max_tokens")
legacy_thinking = AnthropicConfig._map_reasoning_effort(
reasoning_effort="medium",
model=model,
@ -2028,7 +2017,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
## handle thinking tokens
self.update_optional_params_with_thinking_tokens(
non_default_params=non_default_params, optional_params=optional_params
non_default_params=request_params, optional_params=optional_params
)
return optional_params

View file

@ -48,7 +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.litellm_core_utils.tool_fields import tool_params_without_hosted_mcp
from litellm.llms.anthropic.chat.transformation import (
DROP_UNSUPPORTED_ADAPTIVE_THINKING_WARNING,
DROP_UNSUPPORTED_OUTPUT_CONFIG_WARNING,
@ -1056,20 +1056,23 @@ class AmazonConverseConfig(BaseConfig):
drop_params: bool,
model_info: Mapping[str, object] | None = None,
) -> dict:
is_thinking_enabled: Final = self.is_thinking_enabled(non_default_params)
request_params: Final = tool_params_without_hosted_mcp(
non_default_params, model=model, route="bedrock", drop_params=drop_params
)
is_thinking_enabled: Final = self.is_thinking_enabled(request_params)
base_model: Final = BedrockModelInfo.get_base_model(model)
drop_thinking_param: Final = self._is_deepseek_model(model=model, base_model=base_model)
drop_reasoning_effort_param: Final = self._model_rejects_reasoning_effort_param(
model=model, base_model=base_model
)
for param, value in non_default_params.items():
for param, value in request_params.items():
if param == "response_format" and isinstance(value, dict):
optional_params = self._translate_response_format_param(
value=value,
model=model,
optional_params=optional_params,
non_default_params=non_default_params,
non_default_params=request_params,
is_thinking_enabled=is_thinking_enabled,
model_info=model_info,
)
@ -1114,12 +1117,7 @@ class AmazonConverseConfig(BaseConfig):
list[OpenAIChatCompletionToolParam],
list(
tools_without_unsupported_tool_search(
tools=tools_without_hosted_mcp(
tools=_TOOLS_AS_RECEIVED.validate_python(value),
model=model,
route="bedrock",
drop_params=drop_params,
),
tools=_TOOLS_AS_RECEIVED.validate_python(value),
model=model,
route="bedrock",
drop_params=drop_params,
@ -1128,7 +1126,7 @@ class AmazonConverseConfig(BaseConfig):
),
),
model=model,
non_default_params=non_default_params,
non_default_params=request_params,
optional_params=optional_params,
)
if param == "tool_choice":
@ -1154,7 +1152,7 @@ class AmazonConverseConfig(BaseConfig):
and value.get("type") == "adaptive"
and not AnthropicConfig.is_adaptive_thinking_model(model, "bedrock")
):
max_tokens = non_default_params.get("max_completion_tokens") or non_default_params.get("max_tokens")
max_tokens = request_params.get("max_completion_tokens") or request_params.get("max_tokens")
legacy_thinking = AnthropicConfig.map_reasoning_effort(
reasoning_effort="medium",
model=model,
@ -1231,7 +1229,7 @@ class AmazonConverseConfig(BaseConfig):
# Nova 2 handles token budgeting differently through reasoningConfig
if "gpt-oss" not in model and not self._is_nova_2_model(model):
self.update_optional_params_with_thinking_tokens(
non_default_params=non_default_params, optional_params=optional_params
non_default_params=request_params, optional_params=optional_params
)
final_is_thinking_enabled: Final = self.is_thinking_enabled(optional_params)
@ -2062,8 +2060,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

@ -22,7 +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.tool_fields import tool_params_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 (
@ -343,7 +343,7 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig):
use_ai_gateway: Final = self.uses_ai_gateway(model)
return join_api_base(self._get_api_base(api_base, use_ai_gateway=use_ai_gateway), "chat/completions")
def get_supported_openai_params(self, model: str | None = None) -> list:
def get_supported_openai_params(self, model: str | None = None) -> list[str]:
return [
"stream",
"stop",
@ -398,19 +398,14 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig):
anthropic_tools, _ = self._map_tools(
tools=list(
tools_without_unsupported_tool_search(
tools=tools_without_hosted_mcp(
tools=_TOOLS_ADAPTER.validate_python(tools),
model=model,
route="databricks",
drop_params=drop_params,
),
tools=_TOOLS_ADAPTER.validate_python(tools),
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
]
@ -454,30 +449,31 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig):
drop_params: bool,
replace_max_completion_tokens_with_max_tokens: bool = True,
) -> dict:
is_thinking_enabled: Final = self.is_thinking_enabled(non_default_params)
mapped_params: Final = super().map_openai_params(non_default_params, optional_params, model, drop_params)
request_params: Final = (
tool_params_without_hosted_mcp(non_default_params, model=model, route="databricks", drop_params=drop_params)
if "claude" in model
else non_default_params
)
is_thinking_enabled: Final = self.is_thinking_enabled(request_params)
mapped_params: Final = super().map_openai_params(request_params, optional_params, model, drop_params)
if "claude" in model:
AnthropicConfig.translate_legacy_thinking_for_adaptive_model(
model=model, optional_params=mapped_params, custom_llm_provider="databricks", drop_params=drop_params
)
if "tools" in mapped_params:
dbrx_tools: Final = self._map_openai_to_dbrx_tool(
mapped_params["tools"] = 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[
if "max_completion_tokens" in request_params and replace_max_completion_tokens_with_max_tokens:
mapped_params["max_tokens"] = request_params[
"max_completion_tokens"
] # most openai-compatible providers support 'max_tokens' not 'max_completion_tokens'
mapped_params.pop("max_completion_tokens", None)
if "response_format" in non_default_params and "claude" in model:
if "response_format" in request_params and "claude" in model:
_tool: Final = self.map_response_format_to_databricks_tool(
model,
non_default_params["response_format"],
request_params["response_format"],
mapped_params,
is_thinking_enabled,
)
@ -495,8 +491,8 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig):
"response_format", None
) # unsupported for claude models - if json_schema -> convert to tool call
if "reasoning_effort" in non_default_params and self._uses_anthropic_thinking_param(model):
reasoning_effort_value: Final = non_default_params.get("reasoning_effort")
if "reasoning_effort" in request_params and self._uses_anthropic_thinking_param(model):
reasoning_effort_value: Final = request_params.get("reasoning_effort")
mapped_thinking: Final = AnthropicConfig._map_reasoning_effort(
reasoning_effort=reasoning_effort_value,
model=model,
@ -522,7 +518,7 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig):
optional_params.pop("reasoning_effort", None)
## handle thinking tokens
self.update_optional_params_with_thinking_tokens(
non_default_params=non_default_params, optional_params=mapped_params
non_default_params=request_params, optional_params=mapped_params
)
return mapped_params

View file

@ -5,14 +5,12 @@ 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.tool_fields import tool_params_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
@ -20,23 +18,19 @@ 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")
class DeepSeekChatConfig(OpenAIGPTConfig):
def get_supported_openai_params(self, model: str) -> list:
def get_supported_openai_params(self, model: str) -> list[str]:
"""
DeepSeek reasoner models support thinking parameter.
"""
params: Final = super().get_supported_openai_params(model)
params.extend(["thinking", "reasoning_effort"])
return params
return [*super().get_supported_openai_params(model), "thinking", "reasoning_effort"]
def map_openai_params(
self,
non_default_params: dict,
non_default_params: dict[str, object],
optional_params: dict,
model: str,
drop_params: bool,
@ -52,11 +46,12 @@ class DeepSeekChatConfig(OpenAIGPTConfig):
Reference: https://api-docs.deepseek.com/guides/thinking_mode
"""
# Let parent handle standard params first
optional_params = {
**self._without_hosted_mcp_tools(
super().map_openai_params(non_default_params, optional_params, model, drop_params), model, drop_params
)
}
optional_params = super().map_openai_params(
tool_params_without_hosted_mcp(non_default_params, model=model, route="deepseek", drop_params=drop_params),
optional_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
@ -243,20 +238,6 @@ 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:
"""
@ -275,6 +256,7 @@ class DeepSeekChatConfig(OpenAIGPTConfig):
tools: Final = optional_params.get("tools")
if not isinstance(tools, list) or not tools:
return optional_params
sent_tools: Final[list[object]] = tools
def _is_function_tool(tool: object) -> bool:
return isinstance(tool, dict) and tool.get("type") == "function"
@ -299,14 +281,14 @@ class DeepSeekChatConfig(OpenAIGPTConfig):
name: Final = function.get("name")
return isinstance(name, str) and name in function_tool_names
function_tools: Final = [tool for tool in tools if _is_function_tool(tool)]
if len(function_tools) == len(tools):
function_tools: Final = [tool for tool in sent_tools if _is_function_tool(tool)]
if len(function_tools) == len(sent_tools):
return optional_params
dropped_types: Final = sorted(
{
str(tool.get("type")) if isinstance(tool, dict) else type(tool).__name__
for tool in tools
for tool in sent_tools
if not _is_function_tool(tool)
}
)

View file

@ -17,7 +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.tool_fields import tool_params_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
@ -39,7 +39,6 @@ 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:
@ -162,7 +161,10 @@ class GigaChatConfig(BaseConfig):
drop_params: bool,
) -> dict: # mutable-ok: base class contract returns dict
"""Map OpenAI parameters to GigaChat parameters."""
for param, value in non_default_params.items():
request_params: Final = tool_params_without_hosted_mcp(
dict(non_default_params), model=model, route="gigachat", drop_params=drop_params
)
for param, value in request_params.items():
if param == "stream":
optional_params["stream"] = value
elif param == "temperature":
@ -181,14 +183,7 @@ class GigaChatConfig(BaseConfig):
elif param == "tools":
# Convert tools to functions format
if isinstance(value, Sequence):
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)
optional_params["functions"] = self._convert_tools_to_functions(value)
elif param == "tool_choice":
# Map OpenAI tool_choice to GigaChat function_call
if isinstance(value, (str, Mapping)):

View file

@ -34,7 +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.litellm_core_utils.tool_fields import tool_params_without_hosted_mcp
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
from litellm.llms.custom_httpx.http_handler import (
AsyncHTTPHandler,
@ -252,7 +252,6 @@ _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]:
@ -1027,9 +1026,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
return googleSearch, googleSearchRetrieval, enterpriseWebSearch, urlContext
def _map_function(
self, value: Sequence[object], optional_params: dict, send_integer_enums: bool = False
) -> list[Tools]:
def _map_function(self, value: list[dict], optional_params: dict, send_integer_enums: bool = False) -> list[Tools]:
"""
Map OpenAI-style tools/functions to Vertex AI format.
@ -1621,9 +1618,12 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
drop_params: bool,
allow_integer_enums: bool = True,
) -> dict:
self._apply_include_server_side_tool_invocations(non_default_params, optional_params)
request_params: Final = tool_params_without_hosted_mcp(
non_default_params, model=model, route=self._route, drop_params=drop_params
)
self._apply_include_server_side_tool_invocations(request_params, optional_params)
supports_sampling_params: Final = self.supports_sampling_params(model)
for param, value in non_default_params.items():
for param, value in request_params.items():
if param in ("temperature", "top_p", "top_k") and not supports_sampling_params:
continue
if param == "temperature":
@ -1673,19 +1673,9 @@ 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, gemini_tools, optional_params),
partial(self._map_function, value, 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)
@ -1697,7 +1687,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
if _tool_choice_value is not None:
optional_params["tool_choice"] = _tool_choice_value
elif param == "parallel_tool_calls":
tools_list = non_default_params.get("tools", non_default_params.get("functions"))
tools_list = request_params.get("tools", request_params.get("functions"))
num_tools = len(tools_list) if isinstance(tools_list, list) else 0
# Gemini does not support parallel_tool_calls=False with multiple
# tools. Drop the param instead of failing — Responses API clients

View file

@ -7,7 +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.litellm_core_utils.tool_fields import tool_params_without_hosted_mcp, 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"}}}
@ -201,3 +201,36 @@ def test_a_hosted_mcp_error_names_every_server_and_marks_an_unlabeled_one(monkey
drop_params=False,
)
assert "the hosted MCP tool `deepwiki`, `unlabeled` " in str(raised.value)
_HOSTED_MCP_TOOL: Final = {"type": "mcp", "server_label": "deepwiki", "server_url": "https://mcp.deepwiki.com/mcp"}
def test_dropping_a_hosted_mcp_tool_keeps_the_other_tool_params_and_leaves_the_caller_dict_alone() -> None:
tool_choice: Final = {"type": "function", "function": {"name": "get_weather"}}
sent: Final = {
"tools": [_HOSTED_MCP_TOOL, _GOOD_TOOL],
"tool_choice": tool_choice,
"parallel_tool_calls": False,
"temperature": 0.2,
}
params: Final = tool_params_without_hosted_mcp(sent, model="deepseek-v4-flash", route="deepseek", drop_params=True)
assert params == {
"tools": [_GOOD_TOOL],
"tool_choice": tool_choice,
"parallel_tool_calls": False,
"temperature": 0.2,
}
assert sent["tools"] == [_HOSTED_MCP_TOOL, _GOOD_TOOL]
def test_dropping_every_hosted_mcp_tool_clears_the_tool_selection_params() -> None:
params: Final = tool_params_without_hosted_mcp(
{"tools": [_HOSTED_MCP_TOOL], "tool_choice": "required", "parallel_tool_calls": True, "temperature": 0.2},
model="deepseek-v4-flash",
route="deepseek",
drop_params=True,
)
assert params == {"temperature": 0.2}

View file

@ -9878,10 +9878,11 @@ def test_claude_routes_without_the_mcp_connector_send_no_tools_when_drop_params_
):
monkeypatch.setattr(litellm, "drop_params", False)
mapped: Final = config_class().map_openai_params(
non_default_params={"tools": [_HOSTED_MCP_TOOL]},
non_default_params={"tools": [_HOSTED_MCP_TOOL], "tool_choice": "required"},
optional_params={},
model="claude-haiku-5-5",
drop_params=True,
)
assert "tools" not in mapped
assert "mcp_servers" not in mapped
assert "tool_choice" not in mapped

View file

@ -2784,9 +2784,10 @@ def test_databricks_claude_drops_a_hosted_mcp_tool_under_drop_params_and_keeps_t
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]},
non_default_params={"tools": [_HOSTED_MCP_TOOL], "tool_choice": "required"},
optional_params={},
model="databricks-claude-opus-5-5",
drop_params=True,
)
assert "tools" not in mapped
assert "tool_choice" not in mapped

View file

@ -623,10 +623,10 @@ def test_gigachat_drops_a_hosted_mcp_tool_under_drop_params_and_keeps_the_functi
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]},
non_default_params={"tools": [_HOSTED_MCP_TOOL], "tool_choice": "auto"},
optional_params={},
model="GigaChat-2-Max",
drop_params=True,
)
assert "functions" not in mapped
assert "tools" not in mapped
assert "function_call" not in mapped

View file

@ -10712,9 +10712,10 @@ def test_gemini_drops_a_hosted_mcp_tool_under_drop_params_and_keeps_the_function
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]},
non_default_params={"tools": [_HOSTED_MCP_TOOL], "tool_choice": "required"},
optional_params={},
model="gemini-3.8-flash",
drop_params=True,
)
assert "tools" not in mapped
assert "tool_choice" not in mapped