diff --git a/MCP_SSL_CHANGES_SUMMARY.md b/MCP_SSL_CHANGES_SUMMARY.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/experimental_mcp_client/client.py b/litellm/experimental_mcp_client/client.py index 081384b40d9..b10ddc9e812 100644 --- a/litellm/experimental_mcp_client/client.py +++ b/litellm/experimental_mcp_client/client.py @@ -5,8 +5,9 @@ LiteLLM Proxy uses this MCP Client to connnect to other MCP servers. import asyncio import base64 from datetime import timedelta -from typing import Dict, List, Optional, Union +from typing import Callable, Dict, List, Optional, Union +import httpx from mcp import ClientSession, StdioServerParameters from mcp.client.sse import sse_client from mcp.client.stdio import stdio_client @@ -17,6 +18,8 @@ from mcp.types import TextContent from mcp.types import Tool as MCPTool from litellm._logging import verbose_logger +from litellm.llms.custom_httpx.http_handler import get_ssl_configuration +from litellm.types.llms.custom_http import VerifyTypes from litellm.types.mcp import ( MCPAuth, MCPAuthType, @@ -48,6 +51,7 @@ class MCPClient: timeout: float = 60.0, stdio_config: Optional[MCPStdioConfig] = None, extra_headers: Optional[Dict[str, str]] = None, + ssl_verify: Optional[VerifyTypes] = None, ): self.server_url: str = server_url self.transport_type: MCPTransport = transport_type @@ -62,6 +66,7 @@ class MCPClient: self._task: Optional[asyncio.Task] = None self.stdio_config: Optional[MCPStdioConfig] = stdio_config self.extra_headers: Optional[Dict[str, str]] = extra_headers + self.ssl_verify: Optional[VerifyTypes] = ssl_verify # handle the basic auth value if provided if auth_value: self.update_auth_value(auth_value) @@ -104,10 +109,12 @@ class MCPClient: await self._session.initialize() elif self.transport_type == MCPTransport.sse: headers = self._get_auth_headers() + httpx_client_factory = self._create_httpx_client_factory() self._transport_ctx = sse_client( url=self.server_url, timeout=self.timeout, headers=headers, + httpx_client_factory=httpx_client_factory, ) self._transport = await self._transport_ctx.__aenter__() self._session_ctx = ClientSession( @@ -117,6 +124,7 @@ class MCPClient: await self._session.initialize() else: # http headers = self._get_auth_headers() + httpx_client_factory = self._create_httpx_client_factory() verbose_logger.debug( "litellm headers for streamablehttp_client: %s", headers ) @@ -124,6 +132,7 @@ class MCPClient: url=self.server_url, timeout=timedelta(seconds=self.timeout), headers=headers, + httpx_client_factory=httpx_client_factory, ) self._transport = await self._transport_ctx.__aenter__() self._session_ctx = ClientSession( @@ -215,6 +224,41 @@ class MCPClient: return headers + def _create_httpx_client_factory(self) -> Callable[..., httpx.AsyncClient]: + """ + Create a custom httpx client factory that uses LiteLLM's SSL configuration. + + This factory follows the same CA bundle path logic as http_handler.py: + 1. Check ssl_verify parameter (can be SSLContext, bool, or path to CA bundle) + 2. Check SSL_VERIFY environment variable + 3. Check SSL_CERT_FILE environment variable + 4. Fall back to certifi CA bundle + """ + + def factory( + *, + headers: Optional[Dict[str, str]] = None, + timeout: Optional[httpx.Timeout] = None, + auth: Optional[httpx.Auth] = None, + ) -> httpx.AsyncClient: + """Create an httpx.AsyncClient with LiteLLM's SSL configuration.""" + # Get unified SSL configuration using the same logic as http_handler.py + ssl_config = get_ssl_configuration(self.ssl_verify) + + verbose_logger.debug( + f"MCP client using SSL configuration: {type(ssl_config).__name__}" + ) + + return httpx.AsyncClient( + headers=headers, + timeout=timeout, + auth=auth, + verify=ssl_config, + follow_redirects=True, + ) + + return factory + async def list_tools(self) -> List[MCPTool]: """List available tools from the server.""" if not self._session: diff --git a/litellm/llms/azure/responses/transformation.py b/litellm/llms/azure/responses/transformation.py index 0050bd163d1..9cd5de46be9 100644 --- a/litellm/llms/azure/responses/transformation.py +++ b/litellm/llms/azure/responses/transformation.py @@ -1,6 +1,7 @@ from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Tuple import httpx +from openai.types.responses import ResponseReasoningItem from litellm._logging import verbose_logger from litellm.llms.azure.common_utils import BaseAzureLLM @@ -38,6 +39,50 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig): model = model.replace("o_series/", "") return model + def _handle_reasoning_item(self, item: Dict[str, Any]) -> Dict[str, Any]: + """ + Handle reasoning items specifically to filter out status=None using OpenAI's model. + Issue: https://github.com/BerriAI/litellm/issues/13484 + OpenAI API does not accept ReasoningItem(status=None), so we need to: + 1. Check if the item is a reasoning type + 2. Create a ResponseReasoningItem object with the item data + 3. Convert it back to dict with exclude_none=True to filter None values + """ + if item.get("type") == "reasoning": + try: + # Ensure required fields are present for ResponseReasoningItem + item_data = dict(item) + if "id" not in item_data: + item_data["id"] = f"reasoning_{hash(str(item_data))}" + if "summary" not in item_data: + item_data["summary"] = ( + item_data.get("reasoning_content", "")[:100] + "..." + if len(item_data.get("reasoning_content", "")) > 100 + else item_data.get("reasoning_content", "") + ) + + # Create ResponseReasoningItem object from the item data + reasoning_item = ResponseReasoningItem(**item_data) + + # Convert back to dict with exclude_none=True to exclude None fields + dict_reasoning_item = reasoning_item.model_dump(exclude_none=True) + dict_reasoning_item.pop("status", None) + + return dict_reasoning_item + except Exception as e: + verbose_logger.debug( + f"Failed to create ResponseReasoningItem, falling back to manual filtering: {e}" + ) + # Fallback: manually filter out known None fields + filtered_item = { + k: v + for k, v in item.items() + if v is not None + or k not in {"status", "content", "encrypted_content"} + } + return filtered_item + return item + def transform_responses_api_request( self, model: str, @@ -48,12 +93,13 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig): ) -> Dict: """No transform applied since inputs are in OpenAI spec already""" stripped_model_name = self.get_stripped_model_name(model) - return dict( - ResponsesAPIRequestParams( - model=stripped_model_name, - input=input, - **response_api_optional_request_params, - ) + + return super().transform_responses_api_request( + model=stripped_model_name, + input=input, + response_api_optional_request_params=response_api_optional_request_params, + litellm_params=litellm_params, + headers=headers, ) def get_complete_url( @@ -217,15 +263,15 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig): at the correct location (before any query parameters). """ from urllib.parse import urlparse, urlunparse - + # Parse the URL to separate its components parsed_url = urlparse(api_base) - + # Insert the response_id and /cancel at the end of the path component # Remove trailing slash if present to avoid double slashes path = parsed_url.path.rstrip("/") new_path = f"{path}/{response_id}/cancel" - + # Reconstruct the URL with all original components but with the modified path cancel_url = urlunparse( ( diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index 25078267571..3d0ec243e5c 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -1,12 +1,4 @@ -from typing import ( - TYPE_CHECKING, - Any, - Dict, - Optional, - Union, - cast, - get_type_hints, -) +from typing import TYPE_CHECKING, Any, Dict, Optional, Union, cast, get_type_hints import httpx from openai.types.responses import ResponseReasoningItem @@ -127,7 +119,6 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): 2. Create a ResponseReasoningItem object with the item data 3. Convert it back to dict with exclude_none=True to filter None values """ - verbose_logger.debug(f"Handling reasoning item: {item}") if item.get("type") == "reasoning": try: # Ensure required fields are present for ResponseReasoningItem diff --git a/litellm/proxy/_experimental/out/model_hub_table.html b/litellm/proxy/_experimental/out/model_hub_table/index.html similarity index 100% rename from litellm/proxy/_experimental/out/model_hub_table.html rename to litellm/proxy/_experimental/out/model_hub_table/index.html diff --git a/litellm/proxy/_experimental/out/onboarding.html b/litellm/proxy/_experimental/out/onboarding.html deleted file mode 100644 index 6171839cc6d..00000000000 --- a/litellm/proxy/_experimental/out/onboarding.html +++ /dev/null @@ -1 +0,0 @@ -LiteLLM Dashboard \ No newline at end of file diff --git a/litellm/proxy/_new_secret_config.yaml b/litellm/proxy/_new_secret_config.yaml index b7b30d36f99..52a6fc16ff1 100644 --- a/litellm/proxy/_new_secret_config.yaml +++ b/litellm/proxy/_new_secret_config.yaml @@ -1,33 +1,6 @@ model_list: - - model_name: openai/gpt-4o + - model_name: gpt-5-mini litellm_params: - model: openai/gpt-4o-mini - api_base: "https://webhook.site/2f385e05-00aa-402b-86d1-efc9261471a5" - api_key: dummy - - model_name: "byok-wildcard/*" - litellm_params: - model: openai/* - - model_name: xai-grok-3 - litellm_params: - model: xai/grok-3 - - model_name: hosted_vllm/whisper-v3 - litellm_params: - model: hosted_vllm/whisper-v3 - api_base: "https://webhook.site/2f385e05-00aa-402b-86d1-efc9261471a5" - api_key: dummy - -# mcp_servers: -# github_mcp: -# url: "https://api.githubcopilot.com/mcp" -# auth_type: oauth2 -# authorization_url: https://github.com/login/oauth/authorize -# token_url: https://github.com/login/oauth/access_token -# client_id: os.environ/GITHUB_OAUTH_CLIENT_ID -# client_secret: os.environ/GITHUB_OAUTH_CLIENT_SECRET -# scopes: ["public_repo", "user:email"] -# allowed_tools: ["list_tools"] -# # disallowed_tools: ["repo_delete"] - -litellm_settings: - callbacks: ["prometheus"] - custom_prometheus_metadata_labels: ["metadata.initiative", "metadata.business-unit"] \ No newline at end of file + model: azure/gpt-5-mini-2 + api_key: os.environ/AZURE_API_KEY_ALT + api_base: os.environ/AZURE_API_BASE_ALT diff --git a/tests/llm_responses_api_testing/test_azure_responses_api.py b/tests/llm_responses_api_testing/test_azure_responses_api.py index 692f6774ac4..6305137ff45 100644 --- a/tests/llm_responses_api_testing/test_azure_responses_api.py +++ b/tests/llm_responses_api_testing/test_azure_responses_api.py @@ -19,6 +19,7 @@ from litellm.types.llms.openai import ( from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from base_responses_api import BaseResponsesAPITest + class TestAzureResponsesAPITest(BaseResponsesAPITest): def get_base_completion_call_args(self): return { @@ -43,4 +44,55 @@ async def test_azure_responses_api_preview_api_version(): api_base=os.getenv("AZURE_RESPONSES_OPENAI_ENDPOINT"), api_key=os.getenv("AZURE_RESPONSES_OPENAI_API_KEY"), input="Hello, can you tell me a short joke?", - ) \ No newline at end of file + ) + + +@pytest.mark.asyncio +async def test_azure_responses_api_status_error(): + """ + Ensure new azure preview api version is working + """ + litellm._turn_on_debug() + + request_data = { + "model": "gpt-5-mini", + "input": [ + {"content": "tell me an interesting fact", "role": "user"}, + { + "id": "rs_0ab687487834d9df0068e462a1b2d88197aabbc832c9ba5316", + "summary": [], + "type": "reasoning", + "content": None, + "encrypted_content": None, + "status": "completed", + }, + { + "id": "msg_0ab687487834d9df0068e462a1df188197b74b1eef05102c18", + "content": [ + { + "annotations": [], + "text": "Octopuses have three hearts: two pump blood to the gills, while the third pumps it to the rest of the body. Even more unusual, their blood is blue because it uses the copper-containing protein hemocyanin to carry oxygen, which is more efficient than hemoglobin in cold, low-oxygen environments.", + "type": "output_text", + "logprobs": [], + } + ], + "role": "assistant", + "status": "completed", + "type": "message", + }, + {"role": "user", "content": "tell me another"}, + ], + "include": [], + "instructions": "You are a helpful assistant.", + "reasoning": {"effort": "minimal"}, + "stream": False, + "tools": [], + } + response = await litellm.aresponses( + model="azure/gpt-5-mini-2", + truncation="auto", + api_version="preview", + api_base=os.getenv("AZURE_GPT5_MINI_API_BASE"), + api_key=os.getenv("AZURE_GPT5_MINI_API_KEY"), + input=request_data["input"], + ) diff --git a/tests/test_litellm/experimental_mcp_client/test_mcp_client.py b/tests/test_litellm/experimental_mcp_client/test_mcp_client.py index cf1af288d56..9ecc10cafed 100644 --- a/tests/test_litellm/experimental_mcp_client/test_mcp_client.py +++ b/tests/test_litellm/experimental_mcp_client/test_mcp_client.py @@ -1,10 +1,13 @@ +import os +import ssl import sys from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest # Add the parent directory to the path so we can import litellm -sys.path.insert(0, '../../../') +sys.path.insert(0, "../../../") from litellm.experimental_mcp_client.client import MCPClient from litellm.types.mcp import MCPStdioConfig, MCPTransport @@ -16,57 +19,54 @@ class TestMCPClient: def test_mcp_client_stdio_init(self): """Test MCPClient initialization with stdio config""" stdio_config = MCPStdioConfig( - command="python", - args=["-m", "my_mcp_server"], - env={"DEBUG": "1"} + command="python", args=["-m", "my_mcp_server"], env={"DEBUG": "1"} ) - - client = MCPClient( - transport_type=MCPTransport.stdio, - stdio_config=stdio_config - ) - + + client = MCPClient(transport_type=MCPTransport.stdio, stdio_config=stdio_config) + assert client.transport_type == MCPTransport.stdio assert client.stdio_config == stdio_config - assert client.stdio_config["command"] == "python" - assert client.stdio_config["args"] == ["-m", "my_mcp_server"] + assert client.stdio_config is not None + assert client.stdio_config.get("command") == "python" + assert client.stdio_config.get("args") == ["-m", "my_mcp_server"] @pytest.mark.asyncio async def test_mcp_client_stdio_connect_error(self): """Test MCP client stdio connection error handling""" # Test missing stdio_config client = MCPClient(transport_type=MCPTransport.stdio) - - with pytest.raises(ValueError, match="stdio_config is required for stdio transport"): + + with pytest.raises( + ValueError, match="stdio_config is required for stdio transport" + ): await client.connect() @pytest.mark.asyncio - @patch('litellm.experimental_mcp_client.client.stdio_client') - @patch('litellm.experimental_mcp_client.client.ClientSession') - async def test_mcp_client_stdio_connect_success(self, mock_session, mock_stdio_client): + @patch("litellm.experimental_mcp_client.client.stdio_client") + @patch("litellm.experimental_mcp_client.client.ClientSession") + async def test_mcp_client_stdio_connect_success( + self, mock_session, mock_stdio_client + ): """Test successful stdio connection""" # Setup mocks mock_transport = (MagicMock(), MagicMock()) - mock_stdio_client.return_value.__aenter__ = AsyncMock(return_value=mock_transport) - + mock_stdio_client.return_value.__aenter__ = AsyncMock( + return_value=mock_transport + ) + mock_session_instance = MagicMock() mock_session_instance.__aenter__ = AsyncMock(return_value=mock_session_instance) mock_session_instance.initialize = AsyncMock() mock_session.return_value = mock_session_instance - + stdio_config = MCPStdioConfig( - command="python", - args=["-m", "my_mcp_server"], - env={"DEBUG": "1"} + command="python", args=["-m", "my_mcp_server"], env={"DEBUG": "1"} ) - - client = MCPClient( - transport_type=MCPTransport.stdio, - stdio_config=stdio_config - ) - + + client = MCPClient(transport_type=MCPTransport.stdio, stdio_config=stdio_config) + await client.connect() - + # Verify stdio_client was called with correct parameters mock_stdio_client.assert_called_once() call_args = mock_stdio_client.call_args[0][0] @@ -74,6 +74,162 @@ class TestMCPClient: assert call_args.args == ["-m", "my_mcp_server"] assert call_args.env == {"DEBUG": "1"} + @pytest.mark.asyncio + @patch("litellm.experimental_mcp_client.client.streamablehttp_client") + @patch.dict( + os.environ, + { + "SSL_CERT_FILE": "/path/to/custom/ca-bundle.pem", + "SSL_CERTIFICATE": "/path/to/client-cert.pem", + }, + ) + async def test_mcp_client_ssl_configuration_from_env( + self, mock_streamablehttp_client + ): + """Test that MCP client uses SSL configuration from environment variables""" + # Setup mocks + mock_transport = (MagicMock(), MagicMock()) + mock_streamablehttp_client.return_value.__aenter__ = AsyncMock( + return_value=mock_transport + ) + + # Mock the session + with patch( + "litellm.experimental_mcp_client.client.ClientSession" + ) as mock_session: + mock_session_instance = MagicMock() + mock_session_instance.__aenter__ = AsyncMock( + return_value=mock_session_instance + ) + mock_session_instance.initialize = AsyncMock() + mock_session.return_value = mock_session_instance + + client = MCPClient( + server_url="https://mcp-server.example.com", + transport_type=MCPTransport.http, + ) + + await client.connect() + + # Verify streamablehttp_client was called + mock_streamablehttp_client.assert_called_once() + call_kwargs = mock_streamablehttp_client.call_args[1] + + # Verify httpx_client_factory was passed + assert "httpx_client_factory" in call_kwargs + httpx_factory = call_kwargs["httpx_client_factory"] + + # Test the factory creates a client with proper SSL config + # When SSL_CERT_FILE is set, the factory should use get_ssl_configuration + test_client = httpx_factory(headers={"test": "header"}) + + # Verify the client was created successfully with SSL configuration + assert test_client is not None + assert isinstance(test_client, httpx.AsyncClient) + # Verify it has the expected properties + assert test_client.headers is not None + # Clean up + await test_client.aclose() + + @pytest.mark.asyncio + @patch("litellm.experimental_mcp_client.client.sse_client") + async def test_mcp_client_ssl_verify_parameter(self, mock_sse_client): + """Test that MCP client uses ssl_verify parameter when provided""" + # Setup mocks + mock_transport = (MagicMock(), MagicMock()) + mock_sse_client.return_value.__aenter__ = AsyncMock(return_value=mock_transport) + + # Mock the session + with patch( + "litellm.experimental_mcp_client.client.ClientSession" + ) as mock_session: + mock_session_instance = MagicMock() + mock_session_instance.__aenter__ = AsyncMock( + return_value=mock_session_instance + ) + mock_session_instance.initialize = AsyncMock() + mock_session.return_value = mock_session_instance + + # Test with ssl_verify=False + client = MCPClient( + server_url="https://mcp-server.example.com", + transport_type=MCPTransport.sse, + ssl_verify=False, + ) + + await client.connect() + + # Verify sse_client was called + mock_sse_client.assert_called_once() + call_kwargs = mock_sse_client.call_args[1] + + # Verify httpx_client_factory was passed + assert "httpx_client_factory" in call_kwargs + httpx_factory = call_kwargs["httpx_client_factory"] + + # Test the factory creates a client with SSL verification disabled + # When ssl_verify=False, the factory should disable SSL verification + test_client = httpx_factory(headers={"test": "header"}) + + # Verify the client was created successfully + assert test_client is not None + assert isinstance(test_client, httpx.AsyncClient) + # Verify it has the expected properties + assert test_client.headers is not None + # Clean up + await test_client.aclose() + + @pytest.mark.asyncio + @patch("litellm.experimental_mcp_client.client.streamablehttp_client") + async def test_mcp_client_ssl_verify_custom_path(self, mock_streamablehttp_client): + """Test that MCP client uses custom CA bundle path from ssl_verify parameter""" + # Setup mocks + mock_transport = (MagicMock(), MagicMock()) + mock_streamablehttp_client.return_value.__aenter__ = AsyncMock( + return_value=mock_transport + ) + + # Mock the session + with patch( + "litellm.experimental_mcp_client.client.ClientSession" + ) as mock_session: + mock_session_instance = MagicMock() + mock_session_instance.__aenter__ = AsyncMock( + return_value=mock_session_instance + ) + mock_session_instance.initialize = AsyncMock() + mock_session.return_value = mock_session_instance + + # Test with custom CA bundle path + custom_ca_path = "/custom/path/to/ca-bundle.pem" + client = MCPClient( + server_url="https://mcp-server.example.com", + transport_type=MCPTransport.http, + ssl_verify=custom_ca_path, + ) + + await client.connect() + + # Verify streamablehttp_client was called + mock_streamablehttp_client.assert_called_once() + call_kwargs = mock_streamablehttp_client.call_args[1] + + # Verify httpx_client_factory was passed + assert "httpx_client_factory" in call_kwargs + httpx_factory = call_kwargs["httpx_client_factory"] + + # Test the factory creates a client with custom CA bundle path + # When ssl_verify is a path, the factory should use that path for SSL verification + test_client = httpx_factory(headers={"test": "header"}) + + # Verify the client was created successfully + assert test_client is not None + assert isinstance(test_client, httpx.AsyncClient) + # Verify it has the expected properties + assert test_client.headers is not None + # Clean up + await test_client.aclose() + if __name__ == "__main__": - pytest.main([__file__]) \ No newline at end of file + pytest.main([__file__])