This commit is contained in:
simonp9 2026-10-29 17:28:47 -07:00 • committed by GitHub
commit 9403d7c5c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1143 additions and 116 deletions

View file

@ -32,6 +32,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.url_utils import async_safe_get, safe_get
from litellm.llms.custom_httpx.http_handler import HTTPHandler, get_async_httpx_client, header_value
from litellm.types.files import get_file_extension_from_mime_type
@ -5855,8 +5856,13 @@ def function_call_prompt(
llm_provider: str,
drop_params: bool,
) -> list[dict[str, object]]:
prompt_functions: Final = tools_without_hosted_mcp(
functions, model=model, route=llm_provider, drop_params=drop_params
)
if not prompt_functions:
return messages
header: Final = """To call a function, reply with JSON ONLY in this format {"name": "function_name", "arguments":{"argument_name": "argument_value"}}. Once a function result answers the request, reply to the user in plain text instead of calling a function again. The following functions are available to you:"""
function_prompt: Final = header + "".join(f"\n{function}\n" for function in functions)
function_prompt: Final = header + "".join(f"\n{function}\n" for function in prompt_functions)
if not any(_is_system_message(message) for message in messages):
return [*messages, {"role": "system", "content": function_prompt}]
return [

View file

@ -1,13 +1,16 @@
from collections.abc import Callable, Mapping
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
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 +165,137 @@ 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 _drop_params_hint(dropped: str) -> str:
return (
f"To drop {dropped} instead, set `litellm.drop_params=True` or for proxy:\n\n"
"`litellm_settings:\n drop_params: true`\n"
)
def _unrunnable_mcp_error(message: str, model: str, route: str) -> UnsupportedParamsError:
return UnsupportedParamsError(message=message, model=model, llm_provider=route, status_code=400)
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 _unrunnable_mcp_error(
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>"`. ' + _drop_params_hint("hosted MCP tools"),
model=model,
route=route,
)
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]
MCP_SERVERS_DROP_WARNING: Final = (
"Dropping mcp_servers %s and their mcp_toolset tools for model=%s on %s (drop_params=True) because LiteLLM "
"cannot pass mcp_servers to this route."
)
ROUTES_WITHOUT_MCP_SERVERS: Final = frozenset(
{"bedrock", "databricks", "deepseek", "gemini", "gigachat", "ollama", "ollama_chat", "vertex_ai", "vertex_ai_beta"}
)
def _mcp_server_name(server: object) -> str:
match server:
case {"name": str() as name}:
return name
case _:
return "unnamed"
def _mcp_server_names(mcp_servers: object) -> tuple[str, ...]:
if not mcp_servers:
return ()
if not isinstance(mcp_servers, list):
return ("unnamed",)
return tuple(map(_mcp_server_name, _TOOLS.validate_python(mcp_servers)))
def _is_mcp_toolset(tool: object) -> bool:
match tool:
case {"type": "mcp_toolset"}:
return True
case _:
return False
def drops_mcp_servers(request_kwargs: Mapping[str, object]) -> bool:
additional_drop_params: Final = request_kwargs.get("additional_drop_params")
return drops_unsupported_params(request_kwargs.get("drop_params")) or (
isinstance(additional_drop_params, list) and "mcp_servers" in additional_drop_params
)
def tools_without_mcp_servers(
mcp_servers: object, tools: Sequence[_ToolT], model: str, route: str, drop_params: bool
) -> Sequence[_ToolT]:
server_names: Final = _mcp_server_names(mcp_servers)
if not server_names:
return tools
named_servers: Final = ", ".join(f"`{name}`" for name in server_names)
if not drops_unsupported_params(drop_params):
raise _unrunnable_mcp_error(
f"LiteLLM cannot pass `mcp_servers` to {model} on {route}, so the MCP server {named_servers} would not "
'run. To have LiteLLM run it for this model, register it on the LiteLLM MCP gateway and send a `type: "mcp"` '
'tool with `server_url: "litellm_proxy/mcp/<server name>"`. ' + _drop_params_hint("`mcp_servers`"),
model=model,
route=route,
)
verbose_logger.warning(MCP_SERVERS_DROP_WARNING, named_servers, model, route)
return [tool for tool in tools if not _is_mcp_toolset(tool)]
_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, runs_hosted_mcp: Callable[[], bool] | None = None
) -> _ParamsT:
tools: Final = params.get("tools")
if not isinstance(tools, list) or not tools:
return params
sent_tools: Final = _TOOLS.validate_python(tools)
if all(_hosted_mcp_server_label(tool) is None for tool in sent_tools) or (
runs_hosted_mcp is not None and runs_hosted_mcp()
):
return params
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
dropped_keys: Final = ("tools",) if params.get("functions") else _TOOL_SELECTION_PARAMS
for key in dropped_keys:
_ = kept_params.pop(key, None)
return kept_params

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_BILLING_METADATA_PREFIX,
ANTHROPIC_MIN_THINKING_BUDGET_TOKENS,
@ -48,6 +49,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 tool_params_without_hosted_mcp
from litellm.llms.base_llm.base_utils import (
ResponseFormatSchema,
as_json_object,
@ -1233,6 +1235,11 @@ 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_param_with_openai_tool_search_mapped(
self, non_default_params: Mapping[str, object], model: str, drop_params: bool
) -> Mapping[str, object]:
@ -1821,7 +1828,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 = tool_params_without_hosted_mcp(
non_default_params,
model=model,
route=self._resolved_provider,
drop_params=drop_params,
runs_hosted_mcp=self.runs_hosted_mcp,
)
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
@ -1834,7 +1848,7 @@ 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":
@ -1848,8 +1862,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:
@ -1875,7 +1889,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:
@ -1911,7 +1925,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,
@ -2004,7 +2018,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

@ -15,6 +15,11 @@ from litellm._logging import verbose_logger
from litellm.litellm_core_utils.asyncify import run_async_function
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObject
from litellm.litellm_core_utils.tool_fields import (
ROUTES_WITHOUT_MCP_SERVERS,
drops_mcp_servers,
tools_without_mcp_servers,
)
from litellm.llms.anthropic.messages.adapters.transformation import (
AnthropicAdapter,
)
@ -608,6 +613,23 @@ class LiteLLMMessagesToCompletionTransformationHandler:
Logging as LiteLLMLoggingObject,
)
provider_kwargs: Final = extra_kwargs or {}
custom_llm_provider: Final = provider_kwargs.get("custom_llm_provider")
target_provider: Final = custom_llm_provider if isinstance(custom_llm_provider, str) else None
drop_params: Final = drops_unsupported_params(provider_kwargs.get("drop_params"))
strips_mcp_servers: Final = target_provider in ROUTES_WITHOUT_MCP_SERVERS
request_tools: Final = (
tools_without_mcp_servers(
provider_kwargs.get("mcp_servers"),
tools or (),
model=model,
route=target_provider,
drop_params=drops_mcp_servers(provider_kwargs),
)
if strips_mcp_servers
else tools
)
request_data: Final[dict[str, object]] = {
"model": model,
"messages": messages,
@ -624,10 +646,10 @@ class LiteLLMMessagesToCompletionTransformationHandler:
request_data["temperature"] = temperature
if thinking:
request_data["thinking"] = thinking
if tool_choice:
if tool_choice and (request_tools or not tools):
request_data["tool_choice"] = tool_choice
if tools:
request_data["tools"] = tools
if request_tools:
request_data["tools"] = request_tools
if top_k is not None:
request_data["top_k"] = top_k
if top_p is not None:
@ -645,14 +667,13 @@ class LiteLLMMessagesToCompletionTransformationHandler:
if "output_config" in extra_kwargs:
request_data["output_config"] = extra_kwargs["output_config"]
custom_llm_provider: Final = extra_kwargs.get("custom_llm_provider")
(
openai_request,
tool_name_mapping,
) = ANTHROPIC_ADAPTER.translate_completion_input_params_with_tool_mapping(
request_data,
custom_llm_provider=custom_llm_provider if isinstance(custom_llm_provider, str) else None,
drop_params=drops_unsupported_params(extra_kwargs.get("drop_params")),
custom_llm_provider=target_provider,
drop_params=drop_params,
)
if openai_request is None:
@ -680,7 +701,11 @@ class LiteLLMMessagesToCompletionTransformationHandler:
# Maintainability: when adding a new Anthropic-only request param to
# ``AnthropicMessagesRequestOptionalParams``, also extend
# ``ANTHROPIC_ONLY_REQUEST_KEYS`` here so it doesn't silently leak.
excluded_keys: Final = ANTHROPIC_ONLY_REQUEST_KEYS | CONVERTED_STREAM_KEYS | {"anthropic_messages"}
excluded_keys: Final = (
ANTHROPIC_ONLY_REQUEST_KEYS
| CONVERTED_STREAM_KEYS
| ({"anthropic_messages", "mcp_servers"} if strips_mcp_servers else {"anthropic_messages"})
)
# NOTE: extra_kwargs was already coerced from None to {} at the top of
# this method (line ~220). It is guaranteed to be a dict here.
for key, value in extra_kwargs.items():

View file

@ -20,6 +20,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
prompt_cache_breakpoint_for_wire,
with_prompt_cache_breakpoint,
)
from litellm.litellm_core_utils.tool_fields import drops_mcp_servers, tools_without_mcp_servers
from litellm.responses.utils import auto_reasoning_summary
from litellm.types.integrations.custom_logger import CONVERTED_STREAM_KEYS
from litellm.types.llms.anthropic import (
@ -162,6 +163,14 @@ def _build_responses_kwargs(
"""
forwarded_kwargs: Final = _forwarded_kwargs(extra_kwargs)
reject_malformed_tool_result_text(messages, model, forwarded_kwargs)
target_provider: Final = forwarded_kwargs.get("custom_llm_provider")
request_tools: Final = tools_without_mcp_servers(
forwarded_kwargs.get("mcp_servers"),
tools or (),
model=model,
route=target_provider if isinstance(target_provider, str) else "this route",
drop_params=drops_mcp_servers(forwarded_kwargs),
)
block_rewrite: Final = _prompt_cache_block_rewrite(model, forwarded_kwargs)
request_messages: Final[AnthropicRequestMessages] = (
[
@ -194,10 +203,10 @@ def _build_responses_kwargs(
request_data["temperature"] = temperature
if thinking:
request_data["thinking"] = thinking
if tool_choice:
if tool_choice and (request_tools or not tools):
request_data["tool_choice"] = tool_choice
if tools:
request_data["tools"] = tools
if request_tools:
request_data["tools"] = list(request_tools)
if top_p is not None:
request_data["top_p"] = top_p
if output_format:
@ -237,7 +246,7 @@ def _build_responses_kwargs(
responses_kwargs["stream"] = True
# Forward litellm-specific kwargs (api_key, api_base, logging obj, etc.)
excluded: Final = CONVERTED_STREAM_KEYS | {"anthropic_messages"}
excluded: Final = CONVERTED_STREAM_KEYS | {"anthropic_messages", "mcp_servers"}
for key, value in forwarded_kwargs.items():
if key == "litellm_logging_obj" and value is not None:
from litellm.litellm_core_utils.litellm_logging import (

View file

@ -50,6 +50,7 @@ from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import
message_field,
parts_of,
)
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,
@ -1058,20 +1059,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,
)
@ -1125,7 +1129,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":
@ -1151,7 +1155,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,
@ -1224,7 +1228,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)

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

@ -23,6 +23,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 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 +344,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",
@ -449,8 +450,13 @@ 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
@ -459,16 +465,16 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig):
mapped_params["tools"] = self._map_openai_to_dbrx_tool(
model=model, tools=mapped_params["tools"], drop_params=drop_params
)
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,
)
@ -486,8 +492,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,
@ -513,7 +519,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
)
gated_thinking: Final = strip_beta_gated_thinking(
mapped_params.get("thinking"), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # mapped_params is the base config's bare dict

View file

@ -10,6 +10,7 @@ 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 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
@ -21,17 +22,15 @@ SUPPORTED_REASONING_EFFORTS: Final = ("minimal", "low", "medium", "high", "xhigh
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,
@ -47,7 +46,12 @@ 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 = 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
@ -252,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[tuple[object, ...]] = tuple(tools)
def _is_function_tool(tool: object) -> bool:
return isinstance(tool, dict) and tool.get("type") == "function"
@ -276,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

@ -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 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
@ -169,7 +170,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":

View file

@ -4,7 +4,7 @@ from collections.abc import AsyncIterator, Iterator, Mapping
from typing import TYPE_CHECKING, Any, Final, cast
from httpx._models import Headers, Response
from pydantic import BaseModel
from pydantic import BaseModel, TypeAdapter
import litellm
from litellm._uuid import uuid
@ -14,6 +14,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
extract_images_from_message,
extract_reasoning_content,
)
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.llms.base_llm.base_model_iterator import BaseModelResponseIterator
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
@ -39,6 +40,8 @@ if TYPE_CHECKING:
else:
LiteLLMLoggingObj = Any
_REQUEST_PARAMS: Final = TypeAdapter(dict[str, object])
class OllamaChatConfig(BaseConfig):
"""
@ -151,7 +154,13 @@ class OllamaChatConfig(BaseConfig):
model: str,
drop_params: bool,
) -> dict:
for param, value in non_default_params.items():
request_params: Final = tool_params_without_hosted_mcp(
_REQUEST_PARAMS.validate_python(non_default_params),
model=model,
route="ollama_chat",
drop_params=drop_params,
)
for param, value in request_params.items():
if param == "max_tokens" or param == "max_completion_tokens":
optional_params["num_predict"] = value
if param == "stream":

View file

@ -1,5 +1,5 @@
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
from typing import TYPE_CHECKING, Final
from typing import TYPE_CHECKING, ClassVar, Final
import httpx
from pydantic import TypeAdapter
@ -159,6 +159,7 @@ class OpenCodeGoMessagesChatConfig(_GoReportedCostMixin, AnthropicConfig):
class OpenCodeGoGeminiChatConfig(_GoReportedCostMixin, GoogleAIStudioGeminiConfig):
go_usage_key: str = "usageMetadata"
_route: ClassVar[str] = "opencode_go"
@property
def supports_stream_param_in_request_body(self) -> bool:

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 tool_params_without_hosted_mcp
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
from litellm.llms.custom_httpx.http_handler import (
AsyncHTTPHandler,
@ -580,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
@ -1616,9 +1619,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":
@ -1682,7 +1688,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

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

@ -97,7 +97,11 @@ from litellm.litellm_core_utils.model_param_helper import peek_reasoning_summary
from litellm.litellm_core_utils.offline_mode import is_offline_mode
from litellm.litellm_core_utils.sensitive_data_masker import redact_credentials_in_payload
from litellm.litellm_core_utils.tokenizer import strip_special_tokens
from litellm.litellm_core_utils.tool_fields import MalformedField, resolve_deployment_tool_fields
from litellm.litellm_core_utils.tool_fields import (
MalformedField,
resolve_deployment_tool_fields,
tools_without_hosted_mcp,
)
from litellm.rust_bridge import tokenizer as tokenizer_dispatch
from litellm.rust_bridge.catalog import decision
from litellm.rust_bridge.configuration import Decision
@ -5178,7 +5182,12 @@ def remove_sensitive_keys_from_dict(d: dict) -> dict:
return d
def pre_process_optional_params(passed_params: dict, non_default_params: dict, custom_llm_provider: str) -> dict:
_TOOL_LIST: Final = TypeAdapter(list[object])
def pre_process_optional_params(
passed_params: dict, non_default_params: dict, custom_llm_provider: str, model: str, drop_params: bool | None
) -> dict:
"""For .completion(), preprocess optional params"""
optional_params: dict = {}
@ -5235,14 +5244,20 @@ def pre_process_optional_params(passed_params: dict, non_default_params: dict, c
and custom_llm_provider not in litellm.openai_compatible_providers
):
if custom_llm_provider == "ollama":
if "tools" in non_default_params:
sent_tools: Final = _TOOL_LIST.validate_python(non_default_params.pop("tools"))
non_default_params.pop("tool_choice", None) # causes ollama requests to hang
prompt_tools: Final = tools_without_hosted_mcp(
sent_tools, model=model, route=custom_llm_provider, drop_params=bool(drop_params)
)
if sent_tools and not prompt_tools:
return optional_params
optional_params["functions_unsupported_model"] = prompt_tools
elif "functions" in non_default_params:
optional_params["functions_unsupported_model"] = non_default_params.pop("functions")
# ollama actually supports json output
optional_params["format"] = "json"
litellm.add_function_to_prompt = True # so that main.py adds the function call to the prompt
if "tools" in non_default_params:
optional_params["functions_unsupported_model"] = non_default_params.pop("tools")
non_default_params.pop("tool_choice", None) # causes ollama requests to hang
elif "functions" in non_default_params:
optional_params["functions_unsupported_model"] = non_default_params.pop("functions")
elif litellm.add_function_to_prompt: # if user opts to add it to prompt instead
optional_params["functions_unsupported_model"] = non_default_params.pop(
"tools", non_default_params.pop("functions", None)
@ -5377,6 +5392,8 @@ def get_optional_params(
passed_params=passed_params,
non_default_params=non_default_params,
custom_llm_provider=custom_llm_provider,
model=model,
drop_params=drop_params,
)
def _check_valid_arg(supported_params: list[str]):

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 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"}}}
@ -185,3 +186,51 @@ 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)
_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

@ -9807,3 +9807,82 @@ 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], "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

@ -27,11 +27,14 @@ Tests cover (consolidating PRs #23706 and #22727):
import os
import sys
from collections.abc import Mapping
from typing import Final
from unittest.mock import MagicMock, patch
import pytest
import litellm
# Anchor sys.path to this file's location — not the working-directory-relative
# pattern Greptile flagged on PR #23706. Resolves correctly regardless of
# where pytest is invoked from.
@ -67,8 +70,8 @@ def _call_prepare(extra_kwargs, model="gpt-4o", output_format=None, **overrides)
system=None,
temperature=None,
thinking=None,
tool_choice=None,
tools=None,
tool_choice=overrides.get("tool_choice"),
tools=overrides.get("tools"),
top_k=None,
top_p=None,
output_format=output_format,
@ -263,3 +266,112 @@ async def test_messages_layer_converted_stream_flag_still_gets_a_message_back(co
)
assert response["content"][0]["text"] == "hi"
_DEEPWIKI_SERVER: Final = {"type": "url", "url": "https://mcp.deepwiki.com/mcp", "name": "deepwiki"}
@pytest.mark.parametrize(
("mcp_servers", "named"),
[
([_DEEPWIKI_SERVER], "`deepwiki`"),
([_DEEPWIKI_SERVER, {"type": "url", "url": "https://mcp.example.com/mcp"}], "`deepwiki`, `unnamed`"),
({"name": "deepwiki"}, "`unnamed`"),
],
ids=["named", "one-unnamed", "not-a-list"],
)
def test_mcp_servers_a_chat_route_cannot_run_answer_400_naming_each_server(
monkeypatch: pytest.MonkeyPatch, mcp_servers: object, named: str
) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
with pytest.raises(litellm.UnsupportedParamsError) as raised:
_call_prepare(
extra_kwargs={"mcp_servers": mcp_servers, "custom_llm_provider": "gemini"}, model="gemini-3.8-flash"
)
assert raised.value.status_code == 400
assert f"to gemini-3.8-flash on gemini, so the MCP server {named} would not run" in str(raised.value)
@pytest.mark.parametrize(
("route", "forwards"),
[("litellm_proxy", True), ("hosted_vllm", True), ("bedrock", False), ("vertex_ai", False)],
)
def test_mcp_servers_reach_routes_that_carry_them_and_answer_400_elsewhere(
monkeypatch: pytest.MonkeyPatch, route: str, forwards: bool
) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
extra_kwargs: Final = {"mcp_servers": [_DEEPWIKI_SERVER], "custom_llm_provider": route}
if not forwards:
with pytest.raises(litellm.UnsupportedParamsError, match=f"on {route}, so the MCP server `deepwiki`"):
_call_prepare(extra_kwargs=extra_kwargs, model="some-model")
return
completion_kwargs, _ = _call_prepare(extra_kwargs=extra_kwargs, model="some-model")
assert completion_kwargs["mcp_servers"] == [_DEEPWIKI_SERVER]
_DEEPWIKI_TOOLSET: Final = {"type": "mcp_toolset", "mcp_server_name": "deepwiki"}
_WEATHER_TOOL: Final = {
"name": "get_weather",
"description": "Get the weather for a city",
"input_schema": {"type": "object", "properties": {"city": {"type": "string"}}},
}
@pytest.mark.parametrize(
("tools", "kept_tool_names"),
[([_DEEPWIKI_TOOLSET, _WEATHER_TOOL], ["get_weather"]), ([_DEEPWIKI_TOOLSET], None)],
ids=["with-function", "toolset-only"],
)
def test_drop_params_drops_the_mcp_toolset_tools_of_dropped_mcp_servers(
monkeypatch: pytest.MonkeyPatch, tools: list[dict[str, object]], kept_tool_names: list[str] | None
) -> None:
monkeypatch.setattr(litellm, "drop_params", True)
completion_kwargs, _ = _call_prepare(
extra_kwargs={"mcp_servers": [_DEEPWIKI_SERVER], "custom_llm_provider": "gemini"},
model="gemini-3.8-flash",
tools=tools,
tool_choice={"type": "auto"},
)
sent_tools: Final = completion_kwargs.get("tools")
sent_names: Final = [tool["function"]["name"] for tool in sent_tools] if sent_tools else None
assert sent_names == kept_tool_names
assert ("tool_choice" in completion_kwargs) == bool(kept_tool_names)
_DROP_FORMS: Final[Mapping[str, Mapping[str, object]]] = {
"request": {"drop_params": True},
"global": {},
"additional_drop_params": {"additional_drop_params": ["mcp_servers"]},
}
@pytest.mark.parametrize("drop_params", list(_DROP_FORMS))
def test_drop_params_drops_mcp_servers_instead_of_forwarding_them(
monkeypatch: pytest.MonkeyPatch, drop_params: str
) -> None:
monkeypatch.setattr(litellm, "drop_params", drop_params == "global")
completion_kwargs, _ = _call_prepare(
extra_kwargs={
"mcp_servers": [_DEEPWIKI_SERVER],
"custom_llm_provider": "gemini",
**_DROP_FORMS[drop_params],
},
model="gemini-3.8-flash",
)
assert "mcp_servers" not in completion_kwargs
assert completion_kwargs["messages"] == MESSAGES
@pytest.mark.parametrize("mcp_servers", [None, []], ids=["absent", "empty"])
def test_no_mcp_servers_needs_no_drop_params(monkeypatch: pytest.MonkeyPatch, mcp_servers: object) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
completion_kwargs, _ = _call_prepare(
extra_kwargs={"mcp_servers": mcp_servers, "custom_llm_provider": "gemini"}, model="gemini-3.8-flash"
)
assert "mcp_servers" not in completion_kwargs

View file

@ -761,3 +761,54 @@ async def test_recomputed_cost_of_a_served_bridge_response_prices_the_reasoning_
assert logging_obj.cost_breakdown is not None
assert logging_obj.cost_breakdown["output_cost"] == pytest.approx(expected_output)
assert logging_obj.cost_breakdown["reasoning_cost"] == pytest.approx(expected_reasoning)
_DEEPWIKI_SERVER: Final = {"type": "url", "url": "https://mcp.deepwiki.com/mcp", "name": "deepwiki"}
def test_mcp_servers_the_responses_bridge_cannot_run_answer_400(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
with pytest.raises(litellm.UnsupportedParamsError) as rejected:
_build_responses_kwargs(
model="gpt-5.6",
extra_kwargs={**OPENAI_API, "mcp_servers": [_DEEPWIKI_SERVER]},
max_tokens=1024,
messages=MESSAGES,
)
assert rejected.value.status_code == 400
assert "to gpt-5.6 on openai, so the MCP server `deepwiki` would not run" in rejected.value.message
@pytest.mark.parametrize(
"drop_form",
[{"drop_params": True}, {"additional_drop_params": ["mcp_servers"]}],
ids=["drop_params", "additional_drop_params"],
)
def test_drop_params_keeps_mcp_servers_off_the_responses_request(
monkeypatch: pytest.MonkeyPatch, drop_form: Mapping[str, object]
) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
responses_kwargs: Final = _build_responses_kwargs(
model="gpt-5.6",
extra_kwargs={**OPENAI_API, "mcp_servers": [_DEEPWIKI_SERVER], **drop_form},
max_tokens=1024,
messages=MESSAGES,
)
assert "mcp_servers" not in responses_kwargs
assert responses_kwargs["input"]
def test_drop_params_drops_the_mcp_toolset_tools_of_dropped_mcp_servers(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
weather_tool: Final = {"name": "get_weather", "input_schema": {"type": "object", "properties": {}}}
responses_kwargs: Final = _build_responses_kwargs(
model="gpt-5.6",
extra_kwargs={**OPENAI_API, "mcp_servers": [_DEEPWIKI_SERVER], "drop_params": True},
max_tokens=1024,
messages=MESSAGES,
tools=[{"type": "mcp_toolset", "mcp_server_name": "deepwiki"}, weather_tool],
)
assert [tool["name"] for tool in responses_kwargs["tools"]] == ["get_weather"]

View file

@ -10800,6 +10800,70 @@ def test_a_deployment_with_a_batch_control_plane_endpoint_keeps_it_out_of_chat_r
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)
def _converse_bound_thinking_request(
headers: dict[str, str] | None, body_betas: tuple[str, ...] = (), drop_params: bool = True
) -> dict[str, object]:

View file

@ -2773,3 +2773,71 @@ 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], "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
def test_databricks_claude_still_sends_max_completion_tokens_as_max_tokens_when_dropping_a_hosted_mcp_tool(
monkeypatch,
):
monkeypatch.setattr(litellm, "drop_params", False)
mapped: Final = DatabricksConfig().map_openai_params(
non_default_params={"tools": [_HOSTED_MCP_TOOL, _WEATHER_FUNCTION_TOOL], "max_completion_tokens": 64},
optional_params={},
model="databricks-claude-opus-5-5",
drop_params=True,
)
assert mapped["max_tokens"] == 64
assert "max_completion_tokens" not in mapped
assert [tool["function"]["name"] for tool in mapped["tools"]] == ["get_weather"]

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], "tool_choice": "auto"},
optional_params={},
model="GigaChat-2-Max",
drop_params=True,
)
assert "functions" not in mapped
assert "function_call" not in mapped

View file

@ -5,6 +5,7 @@ import sys
from typing import Final, cast
import pytest
import respx
from pydantic import BaseModel
sys.path.insert(
@ -1114,3 +1115,69 @@ def test_get_complete_url_ignores_a_trailing_slash_on_api_base(api_base: str):
)
== "http://gateway.example.com/ollama/api/chat"
)
_DEEPWIKI_MCP_TOOL: Final = {
"type": "mcp",
"server_label": "deepwiki",
"server_url": "https://mcp.deepwiki.com/mcp",
"require_approval": "never",
}
_WEATHER_TOOL: Final = {
"type": "function",
"function": {"name": "get_weather", "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}},
}
def _ollama_chat_route(respx_mock: respx.MockRouter) -> respx.Route:
return respx_mock.post("http://ollama.test:11434/api/chat").respond(
json={
"model": "qwen3:0.6b",
"message": {"role": "assistant", "content": "Sunny"},
"done": True,
"prompt_eval_count": 5,
"eval_count": 3,
}
)
def test_ollama_chat_answers_400_for_a_hosted_mcp_tool_it_cannot_run(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
route: Final = _ollama_chat_route(respx_mock)
with pytest.raises(litellm.UnsupportedParamsError) as raised:
litellm.completion(
model="ollama_chat/qwen3:0.6b",
messages=[{"role": "user", "content": "weather in Paris?"}],
tools=[_DEEPWIKI_MCP_TOOL, _WEATHER_TOOL],
api_base="http://ollama.test:11434",
)
assert raised.value.status_code == 400
assert "qwen3:0.6b on ollama_chat cannot run the hosted MCP tool `deepwiki`" in str(raised.value)
assert not route.called
@pytest.mark.parametrize(
("tools", "sent_tools"),
[([_DEEPWIKI_MCP_TOOL, _WEATHER_TOOL], [_WEATHER_TOOL]), ([_DEEPWIKI_MCP_TOOL], None)],
ids=["beside-a-function", "alone"],
)
def test_ollama_chat_drop_params_sends_only_the_tools_ollama_can_call(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, tools: list[object], sent_tools: object
) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
route: Final = _ollama_chat_route(respx_mock)
response: Final = litellm.completion(
model="ollama_chat/qwen3:0.6b",
messages=[{"role": "user", "content": "weather in Paris?"}],
tools=tools,
api_base="http://ollama.test:11434",
drop_params=True,
)
assert json.loads(route.calls.last.request.content).get("tools") == sent_tools
assert response.choices[0].message.content == "Sunny"

View file

@ -990,3 +990,73 @@ def test_get_complete_url_ignores_a_trailing_slash_on_api_base(api_base: str):
)
== "http://gateway.example.com/ollama/api/generate"
)
_DEEPWIKI_MCP_TOOL: Final = {
"type": "mcp",
"server_label": "deepwiki",
"server_url": "https://mcp.deepwiki.com/mcp",
"require_approval": "never",
}
_WEATHER_TOOL: Final = {
"type": "function",
"function": {
"name": "get_weather",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}},
},
}
def _ollama_generate_route(respx_mock: respx.MockRouter) -> respx.Route:
return respx_mock.post("http://ollama.test:11434/api/generate").respond(
json={"model": "llama3.2", "response": "Sunny", "done": True, "prompt_eval_count": 5, "eval_count": 3}
)
def test_ollama_answers_400_for_a_hosted_mcp_tool_instead_of_pasting_it_into_the_prompt(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
monkeypatch.setattr(litellm, "add_function_to_prompt", False)
route: Final = _ollama_generate_route(respx_mock)
with pytest.raises(litellm.UnsupportedParamsError) as raised:
litellm.completion(
model="ollama/llama3.2",
messages=[{"role": "user", "content": "weather in Paris?"}],
tools=[_DEEPWIKI_MCP_TOOL, _WEATHER_TOOL],
api_base="http://ollama.test:11434",
)
assert raised.value.status_code == 400
assert "cannot run the hosted MCP tool `deepwiki`" in str(raised.value)
assert not route.called
@pytest.mark.parametrize(
("tools", "prompt_names_weather"),
[([_DEEPWIKI_MCP_TOOL, _WEATHER_TOOL], True), ([_DEEPWIKI_MCP_TOOL], False)],
ids=["beside-a-function", "alone"],
)
def test_ollama_drop_params_keeps_a_hosted_mcp_tool_out_of_the_prompt(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, tools: list[object], prompt_names_weather: bool
) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
monkeypatch.setattr(litellm, "add_function_to_prompt", False)
route: Final = _ollama_generate_route(respx_mock)
litellm.completion(
model="ollama/llama3.2",
messages=[{"role": "user", "content": "weather in Paris?"}],
tools=tools,
api_base="http://ollama.test:11434",
drop_params=True,
)
body: Final = json.loads(route.calls.last.request.content)
prompt: Final = body["prompt"]
assert (body.get("format") == "json") is prompt_names_weather
assert "deepwiki" not in prompt
assert ("get_weather" in prompt) is prompt_names_weather
assert prompt.startswith("### User:\nweather in Paris?")
assert prompt_names_weather or prompt == "### User:\nweather in Paris?\n\n"

View file

@ -354,6 +354,17 @@ def test_gemini_custom_base_normalizes_version_and_models(go_catalog: None, suff
assert response.choices[0].message.content == "hello"
def test_gemini_hosted_mcp_error_names_the_opencode_go_route(go_catalog: None) -> None:
with pytest.raises(litellm.UnsupportedParamsError, match="fixture-gemini on opencode_go cannot run"):
litellm.completion(
model="opencode_go/fixture-gemini",
api_key="fake-go-key",
api_base="https://gateway.example/google",
messages=[{"role": "user", "content": "hello"}],
tools=[{"type": "mcp", "server_label": "deepwiki", "server_url": "https://mcp.deepwiki.com/mcp"}],
)
def test_gemini_sync_stream_preserves_tool_calls_and_usage(go_catalog: None) -> None:
chunk: Final = {
"candidates": [

View file

@ -10691,6 +10691,90 @@ async def test_gemini_sends_a_boolean_for_a_gateway_default_written_as_text_or_n
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], "tool_choice": "required"},
optional_params={},
model="gemini-3.8-flash",
drop_params=True,
)
assert "tools" not in mapped
assert "tool_choice" not in mapped
@pytest.mark.parametrize(("config_class", "route"), _GEMINI_ROUTES)
def test_gemini_keeps_tool_choice_none_for_legacy_functions_after_dropping_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],
"functions": [_WEATHER_FUNCTION_TOOL["function"]],
"tool_choice": "none",
},
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 mapped["tool_choice"] == {"functionCallingConfig": {"mode": "NONE"}}
class _BodyInChunks(httpx.SyncByteStream, httpx.AsyncByteStream):
def __init__(self, chunks: tuple[bytes, ...]) -> None:
self._chunks: Final = chunks

View file

@ -7101,3 +7101,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)

View file

@ -207,6 +207,46 @@ describe("responses_api", () => {
expect(onTimingData).not.toHaveBeenCalled();
});
it.each([
[["__all__"], [], [{ server_label: "litellm", server_url: "litellm_proxy/mcp" }]],
[
["server-1"],
[{ server_id: "server-1", server_name: "my server" }],
[{ server_label: "my server", server_url: "litellm_proxy/mcp/my server" }],
],
])(
"sends MCP selection %j as gateway references the proxy runs for any model",
async (selected, servers, expected) => {
await makeOpenAIResponsesRequest(
messages,
mockUpdateTextUI,
"gemini-pro",
"test-token",
undefined, // tags
undefined, // signal
undefined, // onReasoningContent
undefined, // onTimingData
undefined, // onUsageData
undefined, // traceId
undefined, // vector_store_ids
undefined, // guardrails
undefined, // policies
selected,
undefined, // previousResponseId
undefined, // onResponseId
undefined, // onMCPEvent
undefined, // codeInterpreterEnabled
undefined, // onCodeInterpreterResult
undefined, // customBaseUrl
servers as never,
);
expect(mockResponsesCreate.mock.calls[0][0].tools).toEqual(
expected.map((tool) => ({ type: "mcp", require_approval: "never", ...tool })),
);
},
);
it("should report total latency in both streaming and non-streaming modes", async () => {
const onTotalLatency = vi.fn();
const callWithStreaming = (streamingEnabled: boolean) =>
@ -475,14 +515,14 @@ describe("responses_api", () => {
{
type: "mcp",
server_label: "Alpha",
server_url: "https://backend.example.com/mcp/Alpha",
server_url: "litellm_proxy/mcp/Alpha",
require_approval: "never",
allowed_tools: ["toolA"],
},
{
type: "mcp",
server_label: "Beta",
server_url: "https://backend.example.com/mcp/Beta",
server_url: "litellm_proxy/mcp/Beta",
require_approval: "never",
allowed_tools: ["toolB", "toolC"],
},

View file

@ -3,11 +3,12 @@ import openai from "openai";
import type { ResponseFunctionToolCall, ResponseInputItem } from "openai/resources/responses/responses";
import { MessageType } from "../chat_ui/types";
import { TokenUsage } from "../chat_ui/ResponseMetrics";
import { getGatewayBaseUrl, getProxyBaseUrl } from "@/components/networking";
import { getGatewayBaseUrl } from "@/components/networking";
import { toast } from "@/lib/toast";
import { extractPromptCacheTokens } from "@/utils/promptCacheUsage";
import { parseUsageCost } from "./usage_cost";
import { buildPlaygroundHeaders, type CustomHeaders } from "./request_headers";
import { buildMcpToolBlocks, type BuildMcpToolBlocksArgs } from "./mcp_tool_blocks";
import type { MCPEvent } from "@/components/mcp_tools/types";
import { MCPServer, MCPToolset } from "@/components/mcp_tools/types";
import {
@ -126,7 +127,6 @@ export async function makeOpenAIResponsesRequest(
}
const proxyBaseUrl = customBaseUrl || getGatewayBaseUrl();
const mcpBaseUrl = customBaseUrl || getProxyBaseUrl();
const headers = buildPlaygroundHeaders(tags, customHeaders);
const client = new openai.OpenAI({
@ -172,53 +172,13 @@ export async function makeOpenAIResponsesRequest(
});
// Build tools array
const tools: any[] = [];
// Add MCP servers if selected
if (selectedMCPServers && selectedMCPServers.length > 0) {
if (selectedMCPServers.includes("__all__")) {
// All MCP Servers selected
const allServersTool = {
type: "mcp",
server_label: "litellm",
server_url: `${mcpBaseUrl}/mcp`,
require_approval: "never",
};
tools.push(allServersTool);
} else {
// Individual servers/toolsets selected - create one entry per item
selectedMCPServers.forEach((serverId) => {
if (serverId.startsWith("toolset:")) {
// Toolset: same /{name}/mcp pattern as individual servers
const toolsetId = serverId.slice("toolset:".length);
const toolset = mcpToolsets?.find((t) => t.toolset_id === toolsetId);
const toolsetName = toolset?.toolset_name || toolsetId;
const toolsetTool = {
type: "mcp",
server_label: toolsetName,
server_url: `${mcpBaseUrl}/mcp/${encodeURIComponent(toolsetName)}`,
require_approval: "never",
};
tools.push(toolsetTool);
} else {
const server = mcpServers?.find((s) => s.server_id === serverId);
// Use server_name for both routing and labelling. server_name is the
// unique registered identifier; aliases can collide across servers.
const routeName = server?.server_name || serverId;
const allowedTools = mcpServerToolRestrictions?.[serverId] || [];
const serverTool = {
type: "mcp",
server_label: routeName, // unique per request — collisions cause silent tool-routing failures
server_url: `${mcpBaseUrl}/mcp/${encodeURIComponent(routeName)}`,
require_approval: "never",
...(allowedTools.length > 0 ? { allowed_tools: allowedTools } : {}),
};
tools.push(serverTool);
}
});
}
}
const mcpSelection: BuildMcpToolBlocksArgs = {
selectedMCPServers,
mcpServers,
mcpToolsets,
mcpServerToolRestrictions,
};
const tools: any[] = buildMcpToolBlocks(mcpSelection);
// Add code_interpreter tool if enabled (OpenAI auto-creates container)
if (codeInterpreterEnabled) {