mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-28 18:21:38 +00:00
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:
@@ -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"""
|
||||
|
||||
|
||||
+30
-52
@@ -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."""
|
||||
|
||||
+18
-2
@@ -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')
|
||||
|
||||
+3
-2
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user