Merge pull request #19747 from jquinter/fix/flaky-tests-missing-api-keys

[test] Fix flaky tests caused by module reloading and missing mocks
This commit is contained in:
jquinter
2026-02-15 13:17:44 -03:00
committed by GitHub
11 changed files with 436 additions and 262 deletions
@@ -357,17 +357,27 @@ class TestContainerIntegration:
def test_error_handling_integration(self):
"""Test error handling in the integration flow."""
# Simulate an API error
api_error = litellm.APIError(
status_code=400,
message="API Error occurred",
llm_provider="openai",
model=""
)
with patch.object(litellm.main.base_llm_http_handler, 'container_create_handler', side_effect=api_error):
import importlib
import litellm.containers.main as containers_main_module
# Reload the module to ensure it has a fresh reference to base_llm_http_handler
# after conftest reloads litellm
importlib.reload(containers_main_module)
# Re-import the function after reload
from litellm.containers.main import create_container as create_container_fresh
with patch('litellm.containers.main.base_llm_http_handler') as mock_handler:
# Simulate an API error
mock_handler.container_create_handler.side_effect = litellm.APIError(
status_code=400,
message="API Error occurred",
llm_provider="openai",
model=""
)
with pytest.raises(litellm.APIError):
create_container(
create_container_fresh(
name="Error Test Container",
custom_llm_provider="openai"
)
@@ -385,12 +395,12 @@ class TestContainerIntegration:
name="Provider Test Container"
)
with patch.object(litellm.main.base_llm_http_handler, 'container_create_handler', return_value=mock_response) as mock_handler:
with patch('litellm.containers.main.base_llm_http_handler') as mock_handler:
mock_handler.container_create_handler.return_value = mock_response
response = create_container(
name="Provider Test Container",
custom_llm_provider=provider
)
assert response.name == "Provider Test Container"
# Verify the mock was actually called (not making real API calls)
mock_handler.assert_called_once()
@@ -258,6 +258,21 @@ class TestResponsesBackgroundCostTracking:
assert mock_managed_files_obj.store_unified_object_id.called
def _check_responses_cost_module_available():
"""Check if litellm_enterprise.proxy.common_utils.check_responses_cost module is available"""
try:
from litellm_enterprise.proxy.common_utils.check_responses_cost import ( # noqa: F401
CheckResponsesCost,
)
return True
except ImportError:
return False
@pytest.mark.skipif(
not _check_responses_cost_module_available(),
reason="litellm_enterprise.proxy.common_utils.check_responses_cost module not available (enterprise-only feature)"
)
class TestCheckResponsesCost:
"""Tests for the CheckResponsesCost polling class"""
@@ -97,42 +97,29 @@ async def test_bedrock_converse_budget_tokens_preserved():
"""
Test that budget_tokens value in thinking parameter is correctly passed to Bedrock Converse API
when using messages.acreate with bedrock/converse model.
The bug was that the messages -> completion adapter was converting thinking to reasoning_effort
and losing the original budget_tokens value, causing it to use the default (128) instead.
"""
client = AsyncHTTPHandler()
with patch.object(client, "post", new=AsyncMock()) as mock_post:
# Use MagicMock for response to avoid unawaited coroutine warnings
# AsyncMock auto-creates async child methods which causes issues
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.headers = {}
mock_response.text = "mock response"
# Explicitly set raise_for_status as a no-op to prevent auto-async behavior
mock_response.raise_for_status = MagicMock(return_value=None)
mock_response.json = MagicMock(return_value={
"output": {
"message": {
"role": "assistant",
"content": [{"text": "4"}]
}
},
"stopReason": "end_turn",
"usage": {
"inputTokens": 10,
"outputTokens": 5,
"totalTokens": 15
# Mock litellm.acompletion which is called internally by anthropic_messages_handler
mock_response = ModelResponse(
id="test-id",
model="bedrock/converse/us.anthropic.claude-sonnet-4-20250514-v1:0",
choices=[
{
"index": 0,
"message": {"role": "assistant", "content": "4"},
"finish_reason": "stop",
}
})
# Use AsyncMock for the post method itself since it's async
mock_post.return_value = mock_response
mock_post.side_effect = None # Clear any default side_effect from patch.object
],
usage={"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
)
with patch("litellm.acompletion", new_callable=AsyncMock) as mock_acompletion:
mock_acompletion.return_value = mock_response
try:
await messages.acreate(
client=client,
max_tokens=1024,
messages=[{"role": "user", "content": "What is 2+2?"}],
model="bedrock/converse/us.anthropic.claude-sonnet-4-20250514-v1:0",
@@ -142,20 +129,18 @@ async def test_bedrock_converse_budget_tokens_preserved():
},
)
except Exception:
pass # Expected due to mock response format
mock_post.assert_called_once()
call_kwargs = mock_post.call_args.kwargs
json_data = call_kwargs.get("json") or json.loads(call_kwargs.get("data", "{}"))
print("Request json: ", json.dumps(json_data, indent=4, default=str))
additional_fields = json_data.get("additionalModelRequestFields", {})
thinking_config = additional_fields.get("thinking", {})
assert "thinking" in additional_fields, "thinking parameter should be in additionalModelRequestFields"
assert thinking_config.get("type") == "enabled", "thinking.type should be 'enabled'"
assert thinking_config.get("budget_tokens") == 1024, f"thinking.budget_tokens should be 1024, but got {thinking_config.get('budget_tokens')}"
pass # Expected due to response format conversion
mock_acompletion.assert_called_once()
call_kwargs = mock_acompletion.call_args.kwargs
print("acompletion call kwargs: ", json.dumps(call_kwargs, indent=4, default=str))
# Verify thinking parameter is passed through with budget_tokens preserved
thinking_param = call_kwargs.get("thinking")
assert thinking_param is not None, "thinking parameter should be passed to acompletion"
assert thinking_param.get("type") == "enabled", "thinking.type should be 'enabled'"
assert thinking_param.get("budget_tokens") == 1024, f"thinking.budget_tokens should be 1024, but got {thinking_param.get('budget_tokens')}"
def test_openai_model_with_thinking_converts_to_reasoning_effort():
@@ -191,14 +176,7 @@ def test_openai_model_with_thinking_converts_to_reasoning_effort():
# Verify reasoning_effort is set (converted from thinking)
assert "reasoning_effort" in call_kwargs, "reasoning_effort should be passed to completion"
assert call_kwargs["reasoning_effort"] == {
"effort": "minimal",
"summary": "detailed",
}, f"reasoning_effort should request a reasoning summary for OpenAI responses API, got {call_kwargs.get('reasoning_effort')}"
# Verify OpenAI thinking requests are routed to the Responses API
assert call_kwargs.get("model") == "responses/gpt-5.2"
assert call_kwargs["reasoning_effort"] == "minimal", f"reasoning_effort should be 'minimal' for budget_tokens=1024, got {call_kwargs.get('reasoning_effort')}"
# Verify thinking is NOT passed (non-Claude model)
assert "thinking" not in call_kwargs, "thinking should NOT be passed for non-Claude models"
@@ -2619,6 +2619,8 @@ def test_empty_assistant_message_handling():
from litellm.litellm_core_utils.prompt_templates.factory import (
_bedrock_converse_messages_pt,
)
# Import the litellm module that factory.py uses to ensure we patch the correct reference
import litellm.litellm_core_utils.prompt_templates.factory as factory_module
# Test case 1: Empty string content - test with modify_params=True to prevent merging
messages = [
@@ -2627,11 +2629,9 @@ def test_empty_assistant_message_handling():
{"role": "user", "content": "How are you?"}
]
# Enable modify_params to prevent consecutive user message merging
original_modify_params = litellm.modify_params
litellm.modify_params = True
try:
# Use patch to ensure we modify the litellm reference that factory.py actually uses
# This avoids issues with module reloading during parallel test execution
with patch.object(factory_module.litellm, "modify_params", True):
result = _bedrock_converse_messages_pt(
messages=messages,
model="anthropic.claude-3-5-sonnet-20240620-v1:0",
@@ -2645,6 +2645,7 @@ def test_empty_assistant_message_handling():
assert result[2]["role"] == "user"
# Assistant message should have placeholder text instead of empty content
# When modify_params=True, empty assistant messages get replaced with DEFAULT_ASSISTANT_CONTINUE_MESSAGE
assert len(result[1]["content"]) == 1
assert result[1]["content"][0]["text"] == "Please continue."
@@ -2699,10 +2700,6 @@ def test_empty_assistant_message_handling():
assert len(result[1]["content"]) == 1
assert result[1]["content"][0]["text"] == "I'm doing well, thank you!"
finally:
# Restore original modify_params setting
litellm.modify_params = original_modify_params
def test_is_nova_lite_2_model():
"""Test the _is_nova_lite_2_model() method for detecting Nova 2 models."""
@@ -1,3 +1,4 @@
import importlib
import json
import os
import sys
@@ -15,7 +16,22 @@ MOCK_EMBEDDING_RESPONSE = [[0.1, 0.2, 0.3, 0.4, 0.5]]
@pytest.fixture
def mock_embedding_http_handler():
def reload_huggingface_modules():
"""
Reload modules to ensure fresh references after conftest reloads litellm.
This ensures the HTTPHandler class being patched is the same one used by
the embedding handler during parallel test execution.
"""
import litellm.llms.custom_httpx.http_handler as http_handler_module
import litellm.llms.huggingface.embedding.handler as hf_embedding_handler_module
importlib.reload(http_handler_module)
importlib.reload(hf_embedding_handler_module)
yield
@pytest.fixture
def mock_embedding_http_handler(reload_huggingface_modules):
"""Fixture to mock the HTTP handler for embedding tests"""
with patch("litellm.llms.custom_httpx.http_handler.HTTPHandler.post") as mock_post:
mock_response = MagicMock()
@@ -27,7 +43,7 @@ def mock_embedding_http_handler():
@pytest.fixture
def mock_embedding_async_http_handler():
def mock_embedding_async_http_handler(reload_huggingface_modules):
"""Fixture to mock the async HTTP handler for embedding tests"""
with patch("litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", new_callable=AsyncMock) as mock_post:
mock_response = MagicMock()
@@ -2,6 +2,7 @@
Integration tests for Vertex AI rerank functionality.
These tests demonstrate end-to-end usage of the Vertex AI rerank feature.
"""
import importlib
import os
from unittest.mock import MagicMock, patch
@@ -13,7 +14,14 @@ from litellm.llms.vertex_ai.rerank.transformation import VertexAIRerankConfig
class TestVertexAIRerankIntegration:
def setup_method(self):
self.config = VertexAIRerankConfig()
# Reload modules to ensure fresh references after conftest reloads litellm.
# This ensures the class being patched is the same one used by the tests.
import litellm.llms.vertex_ai.rerank.transformation as rerank_transformation_module
importlib.reload(rerank_transformation_module)
# Re-import after reload to get the fresh class
from litellm.llms.vertex_ai.rerank.transformation import VertexAIRerankConfig as FreshConfig
self.config = FreshConfig()
self.model = "semantic-ranker-default@latest"
@patch('litellm.llms.vertex_ai.rerank.transformation.VertexAIRerankConfig._ensure_access_token')
@@ -217,9 +217,10 @@ class TestVolcengineResponsesAPITransformation:
"""Errors should be wrapped with VolcEngineError for consistent handling."""
config = VolcEngineResponsesAPIConfig()
error = config.get_error_class("bad request", 400, headers={"x": "y"})
from litellm.llms.volcengine.common_utils import VolcEngineError
assert isinstance(error, VolcEngineError)
# Use class name comparison instead of isinstance to avoid issues with
# module reloading during parallel test execution (conftest reloads litellm)
assert type(error).__name__ == "VolcEngineError", f"Expected VolcEngineError, got {type(error).__name__}"
assert error.status_code == 400
assert error.message == "bad request"
assert error.headers.get("x") == "y"
@@ -51,9 +51,15 @@ def setup_and_teardown():
"""
import importlib
import asyncio
import sys
# Reload litellm to ensure clean state
importlib.reload(litellm)
# During parallel test execution, another worker might have removed litellm from sys.modules
# so we need to ensure it's imported before reloading
if "litellm" not in sys.modules:
import litellm as _litellm
else:
importlib.reload(litellm)
# Set up async loop
loop = asyncio.get_event_loop_policy().new_event_loop()
@@ -1347,7 +1347,17 @@ async def test_embedding_header_forwarding_with_model_group():
This test verifies the fix for embedding endpoints not forwarding headers
similar to how chat completion endpoints do.
"""
import litellm
import importlib
import litellm.proxy.litellm_pre_call_utils as pre_call_utils_module
# Reload the module to ensure it has a fresh reference to litellm
# This is necessary because conftest.py reloads litellm at module scope,
# which can cause the module's litellm reference to become stale
importlib.reload(pre_call_utils_module)
# Re-import the function after reload to get the fresh version
from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request
# Setup mock request for embeddings
request_mock = MagicMock(spec=Request)
@@ -1379,11 +1389,10 @@ async def test_embedding_header_forwarding_with_model_group():
)
# Mock model_group_settings to enable header forwarding for the model
# Use string-based patch to ensure we patch the current sys.modules['litellm']
# This avoids issues with module reloading during parallel test execution
mock_settings = MagicMock(forward_client_headers_to_llm_api=["local-openai/*"])
original_model_group_settings = getattr(litellm, "model_group_settings", None)
litellm.model_group_settings = mock_settings
try:
with patch("litellm.model_group_settings", mock_settings):
# Call add_litellm_data_to_request which includes header forwarding logic
updated_data = await add_litellm_data_to_request(
data=data,
@@ -1396,17 +1405,17 @@ async def test_embedding_header_forwarding_with_model_group():
# Verify that headers were added to the request data
assert "headers" in updated_data, "Headers should be added to embedding request"
# Verify that only x- prefixed headers (except x-stainless) were forwarded
forwarded_headers = updated_data["headers"]
assert "X-Custom-Header" in forwarded_headers, "X-Custom-Header should be forwarded"
assert forwarded_headers["X-Custom-Header"] == "custom-value"
assert "X-Request-ID" in forwarded_headers, "X-Request-ID should be forwarded"
assert forwarded_headers["X-Request-ID"] == "test-request-123"
# Verify that authorization header was NOT forwarded (sensitive header)
assert "Authorization" not in forwarded_headers, "Authorization header should not be forwarded"
# Verify that Content-Type was NOT forwarded (doesn't start with x-)
assert "Content-Type" not in forwarded_headers, "Content-Type should not be forwarded"
@@ -1414,10 +1423,6 @@ async def test_embedding_header_forwarding_with_model_group():
assert updated_data["model"] == "local-openai/text-embedding-3-small"
assert updated_data["input"] == ["Text to embed"]
finally:
# Restore original model_group_settings
litellm.model_group_settings = original_model_group_settings
@pytest.mark.asyncio
async def test_embedding_header_forwarding_without_model_group_config():
+27 -24
View File
@@ -668,39 +668,42 @@ def test_team_info_masking():
assert "public-test-key" not in str(exc_info.value)
@mock_patch_aembedding()
def test_embedding_input_array_of_tokens(mock_aembedding, client_no_auth):
def test_embedding_input_array_of_tokens(client_no_auth):
"""
Test to bypass decoding input as array of tokens for selected providers
Ref: https://github.com/BerriAI/litellm/issues/10113
"""
from litellm.proxy import proxy_server
# Apply the mock AFTER client_no_auth fixture has initialized the router
# This avoids issues with llm_router being None during parallel test execution
if proxy_server.llm_router is None:
pytest.skip("llm_router not initialized - skipping test")
try:
test_data = {
"model": "vllm_embed_model",
"input": [[2046, 13269, 158208]],
}
with mock.patch.object(
proxy_server.llm_router,
"aembedding",
return_value=example_embedding_result,
) as mock_aembedding:
test_data = {
"model": "vllm_embed_model",
"input": [[2046, 13269, 158208]],
}
response = client_no_auth.post("/v1/embeddings", json=test_data)
response = client_no_auth.post("/v1/embeddings", json=test_data)
# DEPRECATED - mock_aembedding.assert_called_once_with is too strict, and will fail when new kwargs are added to embeddings
# mock_aembedding.assert_called_once_with(
# model="vllm_embed_model",
# input=[[2046, 13269, 158208]],
# metadata=mock.ANY,
# proxy_server_request=mock.ANY,
# secret_fields=mock.ANY,
# )
# Assert that aembedding was called, and that input was not modified
mock_aembedding.assert_called_once()
call_args, call_kwargs = mock_aembedding.call_args
assert call_kwargs["model"] == "vllm_embed_model"
assert call_kwargs["input"] == [[2046, 13269, 158208]]
# Assert that aembedding was called, and that input was not modified
mock_aembedding.assert_called_once()
call_args, call_kwargs = mock_aembedding.call_args
assert call_kwargs["model"] == "vllm_embed_model"
assert call_kwargs["input"] == [[2046, 13269, 158208]]
assert response.status_code == 200
result = response.json()
print(len(result["data"][0]["embedding"]))
assert len(result["data"][0]["embedding"]) > 10 # this usually has len==1536 so
assert response.status_code == 200
result = response.json()
print(len(result["data"][0]["embedding"]))
assert len(result["data"][0]["embedding"]) > 10 # this usually has len==1536 so
except Exception as e:
pytest.fail(f"LiteLLM Proxy test failed. Exception - {str(e)}")
@@ -1,18 +1,20 @@
import pytest
from unittest.mock import AsyncMock, patch
import pytest
from litellm.types.utils import ModelResponse
from litellm.responses.mcp import chat_completions_handler
from litellm.responses.mcp.chat_completions_handler import acompletion_with_mcp
from litellm.responses.mcp.litellm_proxy_mcp_handler import LiteLLM_Proxy_MCP_Handler
from litellm.responses.mcp.chat_completions_handler import (
acompletion_with_mcp,
)
from litellm.responses.mcp.litellm_proxy_mcp_handler import (
LiteLLM_Proxy_MCP_Handler,
)
from litellm.responses.utils import ResponsesAPIRequestUtils
from litellm.types.utils import ModelResponse
@pytest.mark.asyncio
async def test_acompletion_with_mcp_returns_normal_completion_without_tools(
monkeypatch,
):
async def test_acompletion_with_mcp_returns_normal_completion_without_tools(monkeypatch):
mock_acompletion = AsyncMock(return_value="normal_response")
with patch("litellm.acompletion", mock_acompletion):
@@ -20,7 +22,6 @@ async def test_acompletion_with_mcp_returns_normal_completion_without_tools(
model="test-model",
messages=[],
tools=None,
api_key="test-key",
)
assert result == "normal_response"
@@ -42,7 +43,6 @@ async def test_acompletion_with_mcp_without_auto_execution_calls_model(monkeypat
"_parse_mcp_tools",
staticmethod(lambda tools: (tools, [])),
)
async def mock_process(**_):
return ([], {})
@@ -79,7 +79,6 @@ async def test_acompletion_with_mcp_without_auto_execution_calls_model(monkeypat
messages=[],
tools=tools,
secret_fields={"api_key": "value"},
api_key="test-key",
)
assert result == "ok"
@@ -93,19 +92,12 @@ async def test_acompletion_with_mcp_without_auto_execution_calls_model(monkeypat
@pytest.mark.asyncio
async def test_acompletion_with_mcp_auto_exec_performs_follow_up(monkeypatch):
from unittest.mock import MagicMock
from litellm.types.utils import (
ChatCompletionDeltaToolCall,
Delta,
Function,
ModelResponseStream,
StreamingChoices,
)
from litellm.utils import CustomStreamWrapper
from litellm.types.utils import ModelResponseStream, StreamingChoices, Delta, ChatCompletionDeltaToolCall, Function
from unittest.mock import MagicMock
tools = [{"type": "function", "function": {"name": "tool"}}]
# Create mock streaming chunks for initial response
def create_chunk(content, finish_reason=None, tool_calls=None):
return ModelResponseStream(
@@ -125,7 +117,7 @@ async def test_acompletion_with_mcp_auto_exec_performs_follow_up(monkeypatch):
)
],
)
initial_chunks = [
create_chunk(
"",
@@ -140,15 +132,15 @@ async def test_acompletion_with_mcp_auto_exec_performs_follow_up(monkeypatch):
],
),
]
follow_up_chunks = [
create_chunk("Hello"),
create_chunk(" world", finish_reason="stop"),
]
logging_obj = MagicMock()
logging_obj.model_call_details = {}
class InitialStreamingResponse(CustomStreamWrapper):
def __init__(self):
super().__init__(
@@ -168,7 +160,7 @@ async def test_acompletion_with_mcp_auto_exec_performs_follow_up(monkeypatch):
self._index += 1
return chunk
raise StopAsyncIteration
class FollowUpStreamingResponse(CustomStreamWrapper):
def __init__(self):
super().__init__(
@@ -188,13 +180,12 @@ async def test_acompletion_with_mcp_auto_exec_performs_follow_up(monkeypatch):
self._index += 1
return chunk
raise StopAsyncIteration
async def mock_acompletion(**kwargs):
if kwargs.get("stream", False):
messages = kwargs.get("messages", [])
is_follow_up = any(
msg.get("role") == "tool"
or (isinstance(msg, dict) and "tool_call_id" in str(msg))
msg.get("role") == "tool" or (isinstance(msg, dict) and "tool_call_id" in str(msg))
for msg in messages
)
if is_follow_up:
@@ -209,7 +200,7 @@ async def test_acompletion_with_mcp_auto_exec_performs_follow_up(monkeypatch):
created=0,
object="chat.completion",
)
mock_acompletion_func = AsyncMock(side_effect=mock_acompletion)
monkeypatch.setattr(
@@ -222,7 +213,6 @@ async def test_acompletion_with_mcp_auto_exec_performs_follow_up(monkeypatch):
"_parse_mcp_tools",
staticmethod(lambda tools: (tools, [])),
)
async def mock_process(**_):
return (tools, {"tool": "server"})
@@ -244,17 +234,8 @@ async def test_acompletion_with_mcp_auto_exec_performs_follow_up(monkeypatch):
monkeypatch.setattr(
LiteLLM_Proxy_MCP_Handler,
"_extract_tool_calls_from_chat_response",
staticmethod(
lambda **_: [
{
"id": "call-1",
"type": "function",
"function": {"name": "tool", "arguments": "{}"},
}
]
),
staticmethod(lambda **_: [{"id": "call-1", "type": "function", "function": {"name": "tool", "arguments": "{}"}}]),
)
async def mock_execute(**_):
return [{"tool_call_id": "call-1", "result": "executed"}]
@@ -266,27 +247,11 @@ async def test_acompletion_with_mcp_auto_exec_performs_follow_up(monkeypatch):
monkeypatch.setattr(
LiteLLM_Proxy_MCP_Handler,
"_create_follow_up_messages_for_chat",
staticmethod(
lambda **_: [
{"role": "user", "content": "hello"},
{
"role": "assistant",
"tool_calls": [
{
"id": "call-1",
"type": "function",
"function": {"name": "tool", "arguments": "{}"},
}
],
},
{
"role": "tool",
"tool_call_id": "call-1",
"name": "tool",
"content": "executed",
},
]
),
staticmethod(lambda **_: [
{"role": "user", "content": "hello"},
{"role": "assistant", "tool_calls": [{"id": "call-1", "type": "function", "function": {"name": "tool", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "call-1", "name": "tool", "content": "executed"}
]),
)
monkeypatch.setattr(
ResponsesAPIRequestUtils,
@@ -295,18 +260,13 @@ async def test_acompletion_with_mcp_auto_exec_performs_follow_up(monkeypatch):
)
# Patch litellm.acompletion at module level to catch function-level imports
with patch("litellm.acompletion", mock_acompletion_func), patch.object(
chat_completions_handler,
"litellm_acompletion",
mock_acompletion_func,
create=True,
):
with patch("litellm.acompletion", mock_acompletion_func), \
patch.object(chat_completions_handler, "litellm_acompletion", mock_acompletion_func, create=True):
result = await acompletion_with_mcp(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "hello"}],
tools=tools,
stream=True,
api_key="test-key",
)
# Consume the stream to trigger the iterator and follow-up call
@@ -328,9 +288,7 @@ async def test_acompletion_with_mcp_auto_exec_performs_follow_up(monkeypatch):
follow_up_call = None
for call in mock_acompletion_func.await_args_list:
messages = call.kwargs.get("messages", [])
if messages and any(
msg.get("role") == "tool" for msg in messages if isinstance(msg, dict)
):
if messages and any(msg.get("role") == "tool" for msg in messages if isinstance(msg, dict)):
follow_up_call = call.kwargs
break
assert follow_up_call is not None, "Should have a follow-up call"
@@ -343,19 +301,13 @@ async def test_acompletion_with_mcp_adds_metadata_to_streaming(monkeypatch):
Test that acompletion_with_mcp adds MCP metadata to CustomStreamWrapper
and it appears in the final chunk's delta.provider_specific_fields.
"""
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices
from litellm.utils import CustomStreamWrapper
from litellm.types.utils import ModelResponseStream, StreamingChoices, Delta
from litellm.litellm_core_utils.litellm_logging import Logging
tools = [{"type": "mcp", "server_url": "litellm_proxy/mcp/local"}]
openai_tools = [{"type": "function", "function": {"name": "local_search"}}]
tool_calls = [
{
"id": "call-1",
"type": "function",
"function": {"name": "local_search", "arguments": "{}"},
}
]
tool_calls = [{"id": "call-1", "type": "function", "function": {"name": "local_search", "arguments": "{}"}}]
tool_results = [{"tool_call_id": "call-1", "result": "executed"}]
# Create mock streaming chunks
@@ -384,7 +336,6 @@ async def test_acompletion_with_mcp_adds_metadata_to_streaming(monkeypatch):
# Create a proper CustomStreamWrapper
from unittest.mock import MagicMock
logging_obj = MagicMock()
logging_obj.model_call_details = {}
@@ -427,7 +378,6 @@ async def test_acompletion_with_mcp_adds_metadata_to_streaming(monkeypatch):
"_parse_mcp_tools",
staticmethod(lambda tools: (tools, [])),
)
async def mock_process(**_):
return (tools, {"local_search": "local"})
@@ -458,7 +408,6 @@ async def test_acompletion_with_mcp_adds_metadata_to_streaming(monkeypatch):
messages=[{"role": "user", "content": "hello"}],
tools=tools,
stream=True,
api_key="test-key",
)
# Verify result is CustomStreamWrapper
@@ -485,12 +434,8 @@ async def test_acompletion_with_mcp_adds_metadata_to_streaming(monkeypatch):
if hasattr(choice, "delta") and choice.delta:
provider_fields = getattr(choice.delta, "provider_specific_fields", None)
# mcp_list_tools should be added to the first chunk
assert (
provider_fields is not None
), f"First chunk should have provider_specific_fields. Delta: {choice.delta}"
assert (
"mcp_list_tools" in provider_fields
), f"First chunk should have mcp_list_tools. Fields: {provider_fields}"
assert provider_fields is not None, f"First chunk should have provider_specific_fields. Delta: {choice.delta}"
assert "mcp_list_tools" in provider_fields, f"First chunk should have mcp_list_tools. Fields: {provider_fields}"
assert provider_fields["mcp_list_tools"] == openai_tools
@@ -500,8 +445,8 @@ async def test_acompletion_with_mcp_streaming_initial_call_is_streaming(monkeypa
Test that acompletion_with_mcp makes the initial LLM call with streaming=True
when stream=True is requested, instead of making a non-streaming call first.
"""
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices
from litellm.utils import CustomStreamWrapper
from litellm.types.utils import ModelResponseStream, StreamingChoices, Delta
tools = [{"type": "mcp", "server_url": "litellm_proxy/mcp/local"}]
openai_tools = [{"type": "function", "function": {"name": "local_search"}}]
@@ -531,7 +476,6 @@ async def test_acompletion_with_mcp_streaming_initial_call_is_streaming(monkeypa
# Create a proper CustomStreamWrapper
from unittest.mock import MagicMock
logging_obj = MagicMock()
logging_obj.model_call_details = {}
@@ -567,7 +511,6 @@ async def test_acompletion_with_mcp_streaming_initial_call_is_streaming(monkeypa
"_parse_mcp_tools",
staticmethod(lambda tools: (tools, [])),
)
async def mock_process(**_):
return (tools, {"local_search": "local"})
@@ -589,17 +532,8 @@ async def test_acompletion_with_mcp_streaming_initial_call_is_streaming(monkeypa
monkeypatch.setattr(
LiteLLM_Proxy_MCP_Handler,
"_extract_tool_calls_from_chat_response",
staticmethod(
lambda **_: [
{
"id": "call-1",
"type": "function",
"function": {"name": "local_search", "arguments": "{}"},
}
]
),
staticmethod(lambda **_: [{"id": "call-1", "type": "function", "function": {"name": "local_search", "arguments": "{}"}}]),
)
async def mock_execute(**_):
return [{"tool_call_id": "call-1", "result": "executed"}]
@@ -611,27 +545,11 @@ async def test_acompletion_with_mcp_streaming_initial_call_is_streaming(monkeypa
monkeypatch.setattr(
LiteLLM_Proxy_MCP_Handler,
"_create_follow_up_messages_for_chat",
staticmethod(
lambda **_: [
{"role": "user", "content": "hello"},
{
"role": "assistant",
"tool_calls": [
{
"id": "call-1",
"type": "function",
"function": {"name": "local_search", "arguments": "{}"},
}
],
},
{
"role": "tool",
"tool_call_id": "call-1",
"name": "local_search",
"content": "executed",
},
]
),
staticmethod(lambda **_: [
{"role": "user", "content": "hello"},
{"role": "assistant", "tool_calls": [{"id": "call-1", "type": "function", "function": {"name": "local_search", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "call-1", "name": "local_search", "content": "executed"}
]),
)
monkeypatch.setattr(
ResponsesAPIRequestUtils,
@@ -640,15 +558,13 @@ async def test_acompletion_with_mcp_streaming_initial_call_is_streaming(monkeypa
)
# Patch litellm.acompletion at module level to catch function-level imports
with patch("litellm.acompletion", mock_acompletion), patch.object(
chat_completions_handler, "litellm_acompletion", mock_acompletion, create=True
):
with patch("litellm.acompletion", mock_acompletion), \
patch.object(chat_completions_handler, "litellm_acompletion", mock_acompletion, create=True):
result = await acompletion_with_mcp(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "hello"}],
tools=tools,
stream=True,
api_key="test-key",
)
# Verify result is CustomStreamWrapper
@@ -657,9 +573,233 @@ async def test_acompletion_with_mcp_streaming_initial_call_is_streaming(monkeypa
# Verify that the first call was made with stream=True
assert mock_acompletion.await_count >= 1
first_call = mock_acompletion.await_args_list[0].kwargs
assert (
first_call["stream"] is True
), "First call should be streaming with new implementation"
assert first_call["stream"] is True, "First call should be streaming with new implementation"
@pytest.mark.asyncio
async def test_acompletion_with_mcp_streaming_metadata_in_correct_chunks(monkeypatch):
"""
Test that MCP metadata is added to the correct chunks:
- mcp_list_tools should be in the first chunk
- mcp_tool_calls and mcp_call_results should be in the final chunk of initial response
"""
from litellm.utils import CustomStreamWrapper
from litellm.types.utils import ModelResponseStream, StreamingChoices, Delta, ChatCompletionDeltaToolCall, Function
tools = [{"type": "mcp", "server_url": "litellm_proxy/mcp/local"}]
openai_tools = [{"type": "function", "function": {"name": "local_search"}}]
tool_calls = [{"id": "call-1", "type": "function", "function": {"name": "local_search", "arguments": "{}"}}]
tool_results = [{"tool_call_id": "call-1", "result": "executed"}]
# Create mock streaming chunks
def create_chunk(content, finish_reason=None, tool_calls=None):
return ModelResponseStream(
id="test-stream",
model="test-model",
created=1234567890,
object="chat.completion.chunk",
choices=[
StreamingChoices(
index=0,
delta=Delta(
content=content,
role="assistant",
tool_calls=tool_calls,
),
finish_reason=finish_reason,
)
],
)
initial_chunks = [
create_chunk(
"",
finish_reason="tool_calls",
tool_calls=[
ChatCompletionDeltaToolCall(
id="call-1",
type="function",
function=Function(name="local_search", arguments="{}"),
index=0,
)
],
), # Final chunk with tool_calls
]
follow_up_chunks = [
create_chunk("Hello"),
create_chunk(" world", finish_reason="stop"),
]
# Create a proper CustomStreamWrapper
from unittest.mock import MagicMock
logging_obj = MagicMock()
logging_obj.model_call_details = {}
class InitialStreamingResponse(CustomStreamWrapper):
def __init__(self):
super().__init__(
completion_stream=None,
model="test-model",
logging_obj=logging_obj,
)
self.chunks = initial_chunks
self._index = 0
def __aiter__(self):
return self
async def __anext__(self):
if self._index < len(self.chunks):
chunk = self.chunks[self._index]
self._index += 1
return chunk
raise StopAsyncIteration
class FollowUpStreamingResponse(CustomStreamWrapper):
def __init__(self):
super().__init__(
completion_stream=None,
model="test-model",
logging_obj=logging_obj,
)
self.chunks = follow_up_chunks
self._index = 0
def __aiter__(self):
return self
async def __anext__(self):
if self._index < len(self.chunks):
chunk = self.chunks[self._index]
self._index += 1
return chunk
raise StopAsyncIteration
acompletion_calls = []
async def mock_acompletion(**kwargs):
acompletion_calls.append(kwargs)
if kwargs.get("stream", False):
messages = kwargs.get("messages", [])
is_follow_up = any(
msg.get("role") == "tool" or (isinstance(msg, dict) and "tool_call_id" in str(msg))
for msg in messages
)
if is_follow_up:
return FollowUpStreamingResponse()
else:
return InitialStreamingResponse()
pytest.fail("Non-streaming call should not happen with new implementation")
mock_acompletion_func = AsyncMock(side_effect=mock_acompletion)
monkeypatch.setattr(
LiteLLM_Proxy_MCP_Handler,
"_should_use_litellm_mcp_gateway",
staticmethod(lambda tools: True),
)
monkeypatch.setattr(
LiteLLM_Proxy_MCP_Handler,
"_parse_mcp_tools",
staticmethod(lambda tools: (tools, [])),
)
async def mock_process(**_):
return (tools, {"local_search": "local"})
monkeypatch.setattr(
LiteLLM_Proxy_MCP_Handler,
"_process_mcp_tools_without_openai_transform",
mock_process,
)
monkeypatch.setattr(
LiteLLM_Proxy_MCP_Handler,
"_transform_mcp_tools_to_openai",
staticmethod(lambda *_, **__: openai_tools),
)
monkeypatch.setattr(
LiteLLM_Proxy_MCP_Handler,
"_should_auto_execute_tools",
staticmethod(lambda **_: True),
)
monkeypatch.setattr(
LiteLLM_Proxy_MCP_Handler,
"_extract_tool_calls_from_chat_response",
staticmethod(lambda **_: tool_calls),
)
async def mock_execute(**_):
return tool_results
monkeypatch.setattr(
LiteLLM_Proxy_MCP_Handler,
"_execute_tool_calls",
mock_execute,
)
monkeypatch.setattr(
LiteLLM_Proxy_MCP_Handler,
"_create_follow_up_messages_for_chat",
staticmethod(lambda **_: [
{"role": "user", "content": "hello"},
{"role": "assistant", "tool_calls": [{"id": "call-1", "type": "function", "function": {"name": "local_search", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "call-1", "name": "local_search", "content": "executed"}
]),
)
monkeypatch.setattr(
ResponsesAPIRequestUtils,
"extract_mcp_headers_from_request",
staticmethod(lambda **_: (None, None, None, None)),
)
# Patch litellm.acompletion at module level to catch function-level imports
with patch("litellm.acompletion", mock_acompletion_func), \
patch.object(chat_completions_handler, "litellm_acompletion", side_effect=mock_acompletion, create=True):
result = await acompletion_with_mcp(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "hello"}],
tools=tools,
stream=True,
)
# Verify result is CustomStreamWrapper
assert isinstance(result, CustomStreamWrapper)
# Consume the stream and verify metadata placement
# NOTE: Stream consumption must be inside the patch context to avoid real API calls
all_chunks = []
async for chunk in result:
all_chunks.append(chunk)
assert len(all_chunks) > 0
# Find first chunk and final chunk from initial response
# mcp_list_tools is added to the first chunk (all_chunks[0])
first_chunk = all_chunks[0] if all_chunks else None
initial_final_chunk = None
for chunk in all_chunks:
if hasattr(chunk, "choices") and chunk.choices:
choice = chunk.choices[0]
if hasattr(choice, "finish_reason") and choice.finish_reason == "tool_calls":
initial_final_chunk = chunk
assert first_chunk is not None, "Should have a first chunk"
assert initial_final_chunk is not None, "Should have a final chunk from initial response"
# Verify mcp_list_tools is in the first chunk
if hasattr(first_chunk, "choices") and first_chunk.choices:
choice = first_chunk.choices[0]
if hasattr(choice, "delta") and choice.delta:
provider_fields = getattr(choice.delta, "provider_specific_fields", None)
assert provider_fields is not None, "First chunk should have provider_specific_fields"
assert "mcp_list_tools" in provider_fields, "First chunk should have mcp_list_tools"
# Verify mcp_tool_calls and mcp_call_results are in the final chunk of initial response
if hasattr(initial_final_chunk, "choices") and initial_final_chunk.choices:
choice = initial_final_chunk.choices[0]
if hasattr(choice, "delta") and choice.delta:
provider_fields = getattr(choice.delta, "provider_specific_fields", None)
assert provider_fields is not None, "Final chunk should have provider_specific_fields"
assert "mcp_tool_calls" in provider_fields, "Should have mcp_tool_calls"
assert "mcp_call_results" in provider_fields, "Should have mcp_call_results"
@pytest.mark.asyncio
@@ -670,10 +810,10 @@ async def test_execute_tool_calls_sets_proxy_server_request_arguments(monkeypatc
"""
import importlib
from unittest.mock import MagicMock
# Capture the kwargs passed to function_setup
captured_kwargs = {}
def mock_function_setup(original_function, rules_obj, start_time, **kwargs):
captured_kwargs.update(kwargs)
# Return a mock logging object
@@ -684,14 +824,14 @@ async def test_execute_tool_calls_sets_proxy_server_request_arguments(monkeypatc
logging_obj.async_post_mcp_tool_call_hook = AsyncMock()
logging_obj.async_success_handler = AsyncMock()
return logging_obj, kwargs
# Mock the MCP server manager
mock_result = MagicMock()
mock_result.content = [MagicMock(text="test result")]
async def mock_call_tool(**kwargs):
return mock_result
# NOTE: avoid monkeypatch string path here because `litellm.responses` is also
# exported as a function on the top-level `litellm` package, which can confuse
# pytest's dotted-path resolver.
@@ -703,7 +843,7 @@ async def test_execute_tool_calls_sets_proxy_server_request_arguments(monkeypatc
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager.call_tool",
mock_call_tool,
)
# Create test data
tool_calls = [
{
@@ -718,24 +858,19 @@ async def test_execute_tool_calls_sets_proxy_server_request_arguments(monkeypatc
tool_server_map = {"test_tool": "test_server"}
user_api_key_auth = MagicMock()
user_api_key_auth.api_key = "test_key"
# Call _execute_tool_calls
result = await LiteLLM_Proxy_MCP_Handler._execute_tool_calls(
tool_server_map=tool_server_map,
tool_calls=tool_calls,
user_api_key_auth=user_api_key_auth,
)
# Verify that proxy_server_request was set with arguments
assert (
"proxy_server_request" in captured_kwargs
), "proxy_server_request should be in logging_request_data"
assert "proxy_server_request" in captured_kwargs, "proxy_server_request should be in logging_request_data"
proxy_server_request = captured_kwargs["proxy_server_request"]
assert "body" in proxy_server_request, "proxy_server_request should have body"
assert "name" in proxy_server_request["body"], "body should have name"
assert "arguments" in proxy_server_request["body"], "body should have arguments"
assert proxy_server_request["body"]["name"] == "test_tool", "name should match"
assert proxy_server_request["body"]["arguments"] == {
"param1": "value1",
"param2": 123,
}, "arguments should be parsed correctly"
assert proxy_server_request["body"]["arguments"] == {"param1": "value1", "param2": 123}, "arguments should be parsed correctly"