1325 lines
43 KiB
Python
1325 lines
43 KiB
Python
import contextvars
|
|
from typing import Any, AsyncGenerator
|
|
from unittest.mock import AsyncMock, Mock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from mlflow.genai.agent_server import (
|
|
AgentServer,
|
|
get_invoke_function,
|
|
get_request_headers,
|
|
get_stream_function,
|
|
invoke,
|
|
set_request_headers,
|
|
stream,
|
|
)
|
|
from mlflow.genai.agent_server.validator import ResponsesAgentValidator
|
|
from mlflow.types.responses import (
|
|
ResponsesAgentRequest,
|
|
ResponsesAgentResponse,
|
|
ResponsesAgentStreamEvent,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_global_state():
|
|
"""Reset global state before each test to ensure test isolation."""
|
|
import mlflow.genai.agent_server.server
|
|
|
|
mlflow.genai.agent_server.server._invoke_function = None
|
|
mlflow.genai.agent_server.server._stream_function = None
|
|
|
|
|
|
async def responses_invoke(request: ResponsesAgentRequest) -> ResponsesAgentResponse:
|
|
return ResponsesAgentResponse(
|
|
output=[
|
|
{
|
|
"type": "message",
|
|
"id": "msg-123",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Hello from ResponsesAgent!"}],
|
|
}
|
|
]
|
|
)
|
|
|
|
|
|
async def responses_stream(
|
|
request: ResponsesAgentRequest,
|
|
) -> AsyncGenerator[ResponsesAgentStreamEvent, None]:
|
|
yield ResponsesAgentStreamEvent(
|
|
type="response.output_item.done",
|
|
item={
|
|
"type": "message",
|
|
"id": "msg-123",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Hello from ResponsesAgent stream!"}],
|
|
},
|
|
)
|
|
|
|
|
|
async def arbitrary_invoke(request: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"response": "Hello from ArbitraryDictAgent!",
|
|
"arbitrary_field": "custom_value",
|
|
"nested": {"data": "some nested content"},
|
|
}
|
|
|
|
|
|
async def arbitrary_stream(request: dict[str, Any]) -> AsyncGenerator[dict[str, Any], None]:
|
|
yield {"type": "custom_event", "data": "First chunk"}
|
|
yield {"type": "custom_event", "data": "Second chunk", "final": True}
|
|
|
|
|
|
def test_invoke_decorator_single_registration():
|
|
@invoke()
|
|
def my_invoke_function(request):
|
|
return {"result": "success"}
|
|
|
|
registered_function = get_invoke_function()
|
|
assert registered_function is not None
|
|
result = registered_function({"test": "request"})
|
|
assert result == {"result": "success"}
|
|
|
|
|
|
def test_stream_decorator_single_registration():
|
|
@stream()
|
|
async def my_stream_function(request):
|
|
yield {"delta": {"content": "hello"}}
|
|
|
|
registered_function = get_stream_function()
|
|
assert registered_function is not None
|
|
|
|
|
|
def test_multiple_invoke_registrations_raises_error():
|
|
@invoke()
|
|
def first_function(request):
|
|
return {"result": "first"}
|
|
|
|
with pytest.raises(ValueError, match="invoke decorator can only be used once"):
|
|
|
|
@invoke()
|
|
def second_function(request):
|
|
return {"result": "second"}
|
|
|
|
|
|
def test_multiple_stream_registrations_raises_error():
|
|
@stream()
|
|
def first_stream(request):
|
|
yield {"delta": {"content": "first"}}
|
|
|
|
with pytest.raises(ValueError, match="stream decorator can only be used once"):
|
|
|
|
@stream()
|
|
def second_stream(request):
|
|
yield {"delta": {"content": "second"}}
|
|
|
|
|
|
def test_get_invoke_function_returns_registered():
|
|
def my_function(request):
|
|
return {"test": "data"}
|
|
|
|
@invoke()
|
|
def registered_function(request):
|
|
return my_function(request)
|
|
|
|
result = get_invoke_function()
|
|
assert result is not None
|
|
test_result = result({"input": "test"})
|
|
assert test_result == {"test": "data"}
|
|
|
|
|
|
def test_decorator_preserves_function_metadata():
|
|
@invoke()
|
|
def function_with_metadata(request):
|
|
"""This is a test function with documentation."""
|
|
return {"result": "success"}
|
|
|
|
# Get the wrapper function
|
|
wrapper = get_invoke_function()
|
|
|
|
# Verify that functools.wraps preserved the metadata
|
|
assert wrapper.__name__ == "function_with_metadata"
|
|
assert wrapper.__doc__ == "This is a test function with documentation."
|
|
|
|
@stream()
|
|
async def stream_with_metadata(request):
|
|
"""This is a test stream function."""
|
|
yield {"delta": {"content": "hello"}}
|
|
|
|
stream_wrapper = get_stream_function()
|
|
assert stream_wrapper.__name__ == "stream_with_metadata"
|
|
assert stream_wrapper.__doc__ == "This is a test stream function."
|
|
|
|
|
|
def test_validator_request_dict_responses_agent():
|
|
validator_responses = ResponsesAgentValidator()
|
|
request_data = {
|
|
"input": [
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "Hello"}],
|
|
}
|
|
]
|
|
}
|
|
result = validator_responses.validate_and_convert_request(request_data)
|
|
assert isinstance(result, ResponsesAgentRequest)
|
|
|
|
|
|
def test_validator_invalid_request_dict_raises_error():
|
|
validator_responses = ResponsesAgentValidator()
|
|
invalid_data = {"invalid": "structure"}
|
|
|
|
with pytest.raises(ValueError, match="Invalid data for ResponsesAgentRequest"):
|
|
validator_responses.validate_and_convert_request(invalid_data)
|
|
|
|
|
|
def test_validator_none_type_returns_data_unchanged():
|
|
validator_responses = ResponsesAgentValidator()
|
|
request_data = {
|
|
"input": [
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "Hello"}],
|
|
}
|
|
]
|
|
}
|
|
result = validator_responses.validate_and_convert_request(request_data)
|
|
assert isinstance(result, ResponsesAgentRequest)
|
|
|
|
|
|
def test_validator_response_dict_format():
|
|
validator_responses = ResponsesAgentValidator()
|
|
response_dict = {
|
|
"output": [
|
|
{
|
|
"type": "message",
|
|
"id": "123",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Hello"}],
|
|
}
|
|
]
|
|
}
|
|
|
|
result = validator_responses.validate_and_convert_result(response_dict)
|
|
assert isinstance(result, dict)
|
|
assert result == response_dict
|
|
|
|
|
|
def test_validator_response_pydantic_format():
|
|
validator_responses = ResponsesAgentValidator()
|
|
response_pydantic = ResponsesAgentResponse(
|
|
output=[
|
|
{
|
|
"type": "message",
|
|
"id": "123",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Hello"}],
|
|
}
|
|
]
|
|
)
|
|
|
|
result = validator_responses.validate_and_convert_result(response_pydantic)
|
|
assert isinstance(result, dict)
|
|
assert "output" in result
|
|
|
|
|
|
def test_validator_response_dataclass_format():
|
|
validator_responses = ResponsesAgentValidator()
|
|
valid_response = ResponsesAgentResponse(
|
|
output=[
|
|
{
|
|
"type": "message",
|
|
"id": "123",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Hello"}],
|
|
}
|
|
]
|
|
)
|
|
result = validator_responses.validate_and_convert_result(valid_response)
|
|
assert isinstance(result, dict)
|
|
assert "output" in result
|
|
|
|
|
|
def test_validator_stream_response_formats():
|
|
validator_responses = ResponsesAgentValidator()
|
|
# Test streaming response validation for different agent types
|
|
stream_event = ResponsesAgentStreamEvent(
|
|
type="response.output_item.done",
|
|
item={
|
|
"type": "message",
|
|
"id": "123",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Hello"}],
|
|
},
|
|
)
|
|
|
|
result = validator_responses.validate_and_convert_result(stream_event, stream=True)
|
|
assert isinstance(result, dict)
|
|
|
|
|
|
def test_arbitrary_dict_agent_fails_responses_validation():
|
|
validator_responses = ResponsesAgentValidator()
|
|
arbitrary_response = {
|
|
"response": "Hello from ArbitraryDictAgent!",
|
|
"arbitrary_field": "custom_value",
|
|
"nested": {"data": "some nested content"},
|
|
}
|
|
|
|
# This should fail validation because it doesn't match ResponsesAgentResponse schema
|
|
with pytest.raises(ValueError, match="Invalid data for ResponsesAgentResponse"):
|
|
validator_responses.validate_and_convert_result(arbitrary_response)
|
|
|
|
|
|
def test_responses_agent_passes_validation():
|
|
validator_responses = ResponsesAgentValidator()
|
|
valid_response = {
|
|
"output": [
|
|
{
|
|
"type": "message",
|
|
"id": "123",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Hello"}],
|
|
}
|
|
]
|
|
}
|
|
|
|
# This should pass validation
|
|
result = validator_responses.validate_and_convert_result(valid_response)
|
|
assert isinstance(result, dict)
|
|
assert "output" in result
|
|
|
|
|
|
def test_agent_server_initialization():
|
|
server = AgentServer()
|
|
assert server.agent_type is None
|
|
assert server.validator is not None
|
|
assert server.app.title == "Agent Server"
|
|
|
|
|
|
def test_agent_server_with_agent_type():
|
|
server = AgentServer("ResponsesAgent")
|
|
assert server.agent_type == "ResponsesAgent"
|
|
|
|
|
|
def test_agent_server_routes_registration():
|
|
server = AgentServer()
|
|
routes = [route.path for route in server.app.routes]
|
|
assert "/invocations" in routes
|
|
assert "/health" in routes
|
|
|
|
|
|
def test_invocations_endpoint_malformed_json():
|
|
server = AgentServer()
|
|
client = TestClient(server.app)
|
|
|
|
response = client.post("/invocations", data="malformed json")
|
|
assert response.status_code == 400
|
|
response_json = response.json()
|
|
assert "Invalid JSON in request body" in response_json["detail"]
|
|
|
|
|
|
def test_invocations_endpoint_missing_invoke_function():
|
|
server = AgentServer()
|
|
client = TestClient(server.app)
|
|
|
|
response = client.post("/invocations", json={"test": "data"})
|
|
assert response.status_code == 500
|
|
response_json = response.json()
|
|
assert "No invoke function registered" in response_json["detail"]
|
|
|
|
|
|
def test_invocations_endpoint_validation_error():
|
|
server = AgentServer("ResponsesAgent")
|
|
client = TestClient(server.app)
|
|
|
|
# Send invalid request data for responses agent
|
|
invalid_data = {"invalid": "structure"}
|
|
response = client.post("/invocations", json=invalid_data)
|
|
assert response.status_code == 400
|
|
response_json = response.json()
|
|
assert "Invalid parameters for ResponsesAgent" in response_json["detail"]
|
|
|
|
|
|
def test_invocations_endpoint_success_invoke():
|
|
mock_span_instance = Mock()
|
|
mock_span_instance.__enter__ = Mock(return_value=mock_span_instance)
|
|
mock_span_instance.__exit__ = Mock(return_value=None)
|
|
mock_span_instance.trace_id = "test-trace-id"
|
|
with patch("mlflow.start_span", return_value=mock_span_instance) as mock_span:
|
|
|
|
@invoke()
|
|
def test_invoke(request):
|
|
return {
|
|
"output": [
|
|
{
|
|
"type": "message",
|
|
"id": "123",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Hello"}],
|
|
}
|
|
]
|
|
}
|
|
|
|
server = AgentServer("ResponsesAgent")
|
|
client = TestClient(server.app)
|
|
|
|
request_data = {
|
|
"input": [
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "Hello"}],
|
|
}
|
|
]
|
|
}
|
|
|
|
response = client.post("/invocations", json=request_data)
|
|
assert response.status_code == 200
|
|
response_json = response.json()
|
|
assert "output" in response_json
|
|
mock_span.assert_called_once()
|
|
|
|
|
|
def test_invocations_endpoint_success_stream():
|
|
mock_span_instance = Mock()
|
|
mock_span_instance.__enter__ = Mock(return_value=mock_span_instance)
|
|
mock_span_instance.__exit__ = Mock(return_value=None)
|
|
mock_span_instance.trace_id = "test-trace-id"
|
|
with patch("mlflow.start_span", return_value=mock_span_instance) as mock_span:
|
|
|
|
@stream()
|
|
def test_stream(request):
|
|
yield ResponsesAgentStreamEvent(
|
|
type="response.output_item.done",
|
|
item={
|
|
"type": "message",
|
|
"id": "123",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Hello"}],
|
|
},
|
|
)
|
|
|
|
server = AgentServer("ResponsesAgent")
|
|
client = TestClient(server.app)
|
|
|
|
request_data = {
|
|
"input": [
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "Hello"}],
|
|
}
|
|
],
|
|
"stream": True,
|
|
}
|
|
|
|
response = client.post("/invocations", json=request_data)
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
|
mock_span.assert_called_once()
|
|
|
|
|
|
def test_health_endpoint_returns_status():
|
|
server = AgentServer()
|
|
client = TestClient(server.app)
|
|
|
|
response = client.get("/health")
|
|
assert response.status_code == 200
|
|
response_json = response.json()
|
|
assert response_json["status"] == "healthy"
|
|
|
|
|
|
def test_request_headers_isolation():
|
|
# Test that headers are isolated between contexts
|
|
set_request_headers({"test": "value1"})
|
|
assert get_request_headers()["test"] == "value1"
|
|
|
|
# In a different context, headers should be independent
|
|
ctx = contextvars.copy_context()
|
|
|
|
def test_different_context():
|
|
set_request_headers({"test": "value2"})
|
|
return get_request_headers()["test"]
|
|
|
|
result = ctx.run(test_different_context)
|
|
assert result == "value2"
|
|
# Original context should be unchanged
|
|
assert get_request_headers()["test"] == "value1"
|
|
|
|
|
|
def test_tracing_span_creation():
|
|
mock_span_instance = Mock()
|
|
mock_span_instance.__enter__ = Mock(return_value=mock_span_instance)
|
|
mock_span_instance.__exit__ = Mock(return_value=None)
|
|
with patch("mlflow.start_span", return_value=mock_span_instance) as mock_span:
|
|
|
|
@invoke()
|
|
def test_function(request):
|
|
return {"result": "success"}
|
|
|
|
server = AgentServer()
|
|
client = TestClient(server.app)
|
|
|
|
client.post("/invocations", json={"test": "data"})
|
|
# Verify span was created with correct name
|
|
mock_span.assert_called_once_with(name="test_function")
|
|
|
|
|
|
def test_tracing_attributes_setting():
|
|
mock_span_instance = Mock()
|
|
mock_span_instance.__enter__ = Mock(return_value=mock_span_instance)
|
|
mock_span_instance.__exit__ = Mock(return_value=None)
|
|
with patch("mlflow.start_span", return_value=mock_span_instance) as mock_span:
|
|
|
|
@invoke()
|
|
def test_function(request):
|
|
return {
|
|
"output": [
|
|
{
|
|
"type": "message",
|
|
"id": "123",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Hello"}],
|
|
}
|
|
]
|
|
}
|
|
|
|
server = AgentServer("ResponsesAgent")
|
|
client = TestClient(server.app)
|
|
|
|
request_data = {
|
|
"input": [
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "Hello"}],
|
|
}
|
|
]
|
|
}
|
|
|
|
client.post("/invocations", json=request_data)
|
|
|
|
# Verify span was created (this is the main functionality we can reliably test)
|
|
mock_span.assert_called_once_with(name="test_function")
|
|
# Verify the span context manager was used
|
|
mock_span_instance.__enter__.assert_called_once()
|
|
mock_span_instance.__exit__.assert_called_once()
|
|
|
|
|
|
def test_chat_proxy_disabled_by_default():
|
|
server = AgentServer()
|
|
assert not hasattr(server, "proxy_client")
|
|
|
|
|
|
def test_chat_proxy_enabled():
|
|
server = AgentServer(enable_chat_proxy=True)
|
|
assert hasattr(server, "proxy_client")
|
|
assert server.proxy_client is not None
|
|
assert server.chat_proxy_timeout == 300.0
|
|
|
|
|
|
def test_chat_proxy_custom_timeout(monkeypatch):
|
|
monkeypatch.setenv("CHAT_PROXY_TIMEOUT_SECONDS", "60.0")
|
|
server = AgentServer(enable_chat_proxy=True)
|
|
assert server.proxy_client is not None
|
|
assert server.chat_proxy_timeout == 60.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_proxy_forwards_allowed_paths():
|
|
@invoke()
|
|
def test_invoke(request):
|
|
return {
|
|
"output": [
|
|
{
|
|
"type": "message",
|
|
"id": "123",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Hello"}],
|
|
}
|
|
]
|
|
}
|
|
|
|
server = AgentServer("ResponsesAgent", enable_chat_proxy=True)
|
|
client = TestClient(server.app)
|
|
|
|
mock_response = AsyncMock()
|
|
mock_response.status_code = 200
|
|
mock_response.headers = {"content-type": "application/json"}
|
|
mock_response.aread = AsyncMock(return_value=b'{"chat": "response"}')
|
|
mock_response.aclose = AsyncMock()
|
|
|
|
with (
|
|
patch.object(server.proxy_client, "build_request") as mock_build_request,
|
|
patch.object(server.proxy_client, "send", return_value=mock_response) as mock_send,
|
|
):
|
|
response = client.get("/assets/index.js")
|
|
assert response.status_code == 200
|
|
assert response.content == b'{"chat": "response"}'
|
|
mock_build_request.assert_called_once()
|
|
mock_send.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_proxy_does_not_forward_matched_routes():
|
|
@invoke()
|
|
def test_invoke(request):
|
|
return {
|
|
"output": [
|
|
{
|
|
"type": "message",
|
|
"id": "123",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Hello"}],
|
|
}
|
|
]
|
|
}
|
|
|
|
server = AgentServer("ResponsesAgent", enable_chat_proxy=True)
|
|
client = TestClient(server.app)
|
|
|
|
with (
|
|
patch.object(server.proxy_client, "build_request") as mock_build_request,
|
|
patch.object(server.proxy_client, "send"),
|
|
):
|
|
response = client.get("/health")
|
|
assert response.status_code == 200
|
|
assert response.json() == {"status": "healthy"}
|
|
mock_build_request.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_proxy_handles_connect_error():
|
|
server = AgentServer(enable_chat_proxy=True)
|
|
client = TestClient(server.app)
|
|
|
|
with (
|
|
patch.object(server.proxy_client, "build_request"),
|
|
patch.object(
|
|
server.proxy_client, "send", side_effect=httpx.ConnectError("Connection failed")
|
|
),
|
|
):
|
|
response = client.get("/")
|
|
assert response.status_code == 503
|
|
assert response.text == "Service unavailable"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_proxy_handles_general_error():
|
|
server = AgentServer(enable_chat_proxy=True)
|
|
client = TestClient(server.app)
|
|
|
|
with (
|
|
patch.object(server.proxy_client, "build_request"),
|
|
patch.object(server.proxy_client, "send", side_effect=Exception("Unexpected error")),
|
|
):
|
|
response = client.get("/")
|
|
assert response.status_code == 502
|
|
assert "Proxy error: Unexpected error" in response.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_proxy_forwards_post_requests_with_body():
|
|
server = AgentServer(enable_chat_proxy=True)
|
|
client = TestClient(server.app)
|
|
|
|
mock_response = AsyncMock()
|
|
mock_response.status_code = 200
|
|
mock_response.headers = {"content-type": "application/json"}
|
|
mock_response.aread = AsyncMock(return_value=b'{"result": "success"}')
|
|
mock_response.aclose = AsyncMock()
|
|
|
|
# POST to root path (allowed) to test body forwarding
|
|
with (
|
|
patch.object(server.proxy_client, "build_request") as mock_build_request,
|
|
patch.object(server.proxy_client, "send", return_value=mock_response) as mock_send,
|
|
):
|
|
response = client.post("/", json={"message": "hello"})
|
|
assert response.status_code == 200
|
|
assert response.content == b'{"result": "success"}'
|
|
|
|
mock_build_request.assert_called_once()
|
|
mock_send.assert_called_once()
|
|
call_args = mock_build_request.call_args
|
|
assert call_args.kwargs["method"] == "POST"
|
|
assert call_args.kwargs["content"] is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_proxy_respects_chat_app_port_env_var(monkeypatch):
|
|
monkeypatch.setenv("CHAT_APP_PORT", "8080")
|
|
server = AgentServer(enable_chat_proxy=True)
|
|
client = TestClient(server.app)
|
|
|
|
mock_response = AsyncMock()
|
|
mock_response.status_code = 200
|
|
mock_response.headers = {}
|
|
mock_response.aread = AsyncMock(return_value=b"test")
|
|
mock_response.aclose = AsyncMock()
|
|
|
|
with (
|
|
patch.object(server.proxy_client, "build_request") as mock_build_request,
|
|
patch.object(server.proxy_client, "send", return_value=mock_response) as mock_send,
|
|
):
|
|
client.get("/assets/test.js")
|
|
mock_build_request.assert_called_once()
|
|
mock_send.assert_called_once()
|
|
call_args = mock_build_request.call_args
|
|
assert call_args.kwargs["url"] == "http://localhost:8080/assets/test.js"
|
|
|
|
|
|
def test_responses_create_endpoint_invoke():
|
|
mock_span_instance = Mock()
|
|
mock_span_instance.__enter__ = Mock(return_value=mock_span_instance)
|
|
mock_span_instance.__exit__ = Mock(return_value=None)
|
|
with patch("mlflow.start_span", return_value=mock_span_instance) as mock_span:
|
|
|
|
@invoke()
|
|
def test_invoke(request):
|
|
return {
|
|
"output": [
|
|
{
|
|
"type": "message",
|
|
"id": "123",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Hello"}],
|
|
}
|
|
]
|
|
}
|
|
|
|
server = AgentServer("ResponsesAgent")
|
|
client = TestClient(server.app)
|
|
|
|
request_data = {
|
|
"input": [
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "Hello"}],
|
|
}
|
|
]
|
|
}
|
|
|
|
response = client.post("/responses", json=request_data)
|
|
assert response.status_code == 200
|
|
assert "output" in response.json()
|
|
mock_span.assert_called_once()
|
|
|
|
|
|
def test_responses_create_endpoint_stream():
|
|
mock_span_instance = Mock()
|
|
mock_span_instance.__enter__ = Mock(return_value=mock_span_instance)
|
|
mock_span_instance.__exit__ = Mock(return_value=None)
|
|
with patch("mlflow.start_span", return_value=mock_span_instance) as mock_span:
|
|
|
|
@stream()
|
|
def test_stream(request):
|
|
yield ResponsesAgentStreamEvent(
|
|
type="response.output_item.done",
|
|
item={
|
|
"type": "message",
|
|
"id": "123",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Hello"}],
|
|
},
|
|
)
|
|
|
|
server = AgentServer("ResponsesAgent")
|
|
client = TestClient(server.app)
|
|
|
|
request_data = {
|
|
"input": [
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "Hello"}],
|
|
}
|
|
],
|
|
"stream": True,
|
|
}
|
|
|
|
response = client.post("/responses", json=request_data)
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
|
mock_span.assert_called_once()
|
|
|
|
|
|
def test_responses_create_with_custom_inputs_and_context():
|
|
mock_span_instance = Mock()
|
|
mock_span_instance.__enter__ = Mock(return_value=mock_span_instance)
|
|
mock_span_instance.__exit__ = Mock(return_value=None)
|
|
with patch("mlflow.start_span", return_value=mock_span_instance) as mock_span:
|
|
|
|
@invoke()
|
|
def test_invoke(request):
|
|
assert request.custom_inputs == {"key": "value"}
|
|
assert request.context.user_id == "test-user"
|
|
return {
|
|
"output": [
|
|
{
|
|
"type": "message",
|
|
"id": "123",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Hello"}],
|
|
}
|
|
]
|
|
}
|
|
|
|
server = AgentServer("ResponsesAgent")
|
|
client = TestClient(server.app)
|
|
|
|
request_data = {
|
|
"input": [{"role": "user", "content": "Hello"}],
|
|
"custom_inputs": {"key": "value"},
|
|
"context": {"user_id": "test-user", "conversation_id": "conv-123"},
|
|
}
|
|
|
|
response = client.post("/responses", json=request_data)
|
|
assert response.status_code == 200
|
|
mock_span.assert_called_once()
|
|
|
|
|
|
def test_responses_create_validation_error():
|
|
server = AgentServer("ResponsesAgent")
|
|
client = TestClient(server.app)
|
|
|
|
invalid_data = {"invalid": "structure"}
|
|
response = client.post("/responses", json=invalid_data)
|
|
assert response.status_code == 400
|
|
assert "Invalid parameters for ResponsesAgent" in response.json()["detail"]
|
|
|
|
|
|
def test_responses_create_malformed_json():
|
|
server = AgentServer("ResponsesAgent")
|
|
client = TestClient(server.app)
|
|
|
|
response = client.post("/responses", data="malformed json")
|
|
assert response.status_code == 400
|
|
assert "Invalid JSON in request body" in response.json()["detail"]
|
|
|
|
|
|
def test_agent_info_endpoint_responses_agent():
|
|
import mlflow
|
|
|
|
server = AgentServer("ResponsesAgent")
|
|
client = TestClient(server.app)
|
|
|
|
response = client.get("/agent/info")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["name"] == "mlflow_agent_server"
|
|
assert data["use_case"] == "agent"
|
|
assert data["mlflow_version"] == mlflow.__version__
|
|
assert data["agent_api"] == "responses"
|
|
|
|
|
|
def test_agent_info_endpoint_no_agent_type():
|
|
import mlflow
|
|
|
|
server = AgentServer()
|
|
client = TestClient(server.app)
|
|
|
|
response = client.get("/agent/info")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["name"] == "mlflow_agent_server"
|
|
assert data["use_case"] == "agent"
|
|
assert data["mlflow_version"] == mlflow.__version__
|
|
assert "agent_api" not in data
|
|
|
|
|
|
def test_agent_info_endpoint_custom_app_name(monkeypatch):
|
|
import mlflow
|
|
|
|
monkeypatch.setenv("DATABRICKS_APP_NAME", "custom_agent")
|
|
server = AgentServer("ResponsesAgent")
|
|
client = TestClient(server.app)
|
|
|
|
response = client.get("/agent/info")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["name"] == "custom_agent"
|
|
assert data["use_case"] == "agent"
|
|
assert data["mlflow_version"] == mlflow.__version__
|
|
assert data["agent_api"] == "responses"
|
|
|
|
|
|
def test_agent_server_routes_registration_responses_agent():
|
|
server = AgentServer("ResponsesAgent")
|
|
routes = [route.path for route in server.app.routes]
|
|
assert "/invocations" in routes
|
|
assert "/responses" in routes
|
|
assert "/agent/info" in routes
|
|
assert "/health" in routes
|
|
|
|
|
|
def test_agent_server_routes_registration_no_responses_route():
|
|
server = AgentServer() # No agent_type
|
|
routes = [route.path for route in server.app.routes]
|
|
assert "/invocations" in routes
|
|
assert "/responses" not in routes # Should NOT be present
|
|
assert "/agent/info" in routes
|
|
assert "/health" in routes
|
|
|
|
|
|
def test_responses_not_available_for_non_responses_agent():
|
|
server = AgentServer() # No agent_type
|
|
client = TestClient(server.app)
|
|
|
|
request_data = {"input": [{"role": "user", "content": "Hello"}]}
|
|
|
|
response = client.post("/responses", json=request_data)
|
|
assert response.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"path", ["/", "/assets/index.js", "/api/session", "/favicon.ico", "/ping", "/chat/abc123"]
|
|
)
|
|
async def test_chat_proxy_forwards_allowlisted_paths(path):
|
|
server = AgentServer(enable_chat_proxy=True)
|
|
client = TestClient(server.app)
|
|
|
|
mock_response = AsyncMock()
|
|
mock_response.status_code = 200
|
|
mock_response.headers = {}
|
|
mock_response.aread = AsyncMock(return_value=b"response")
|
|
mock_response.aclose = AsyncMock()
|
|
|
|
with (
|
|
patch.object(server.proxy_client, "build_request") as mock_build_request,
|
|
patch.object(server.proxy_client, "send", return_value=mock_response) as mock_send,
|
|
):
|
|
response = client.get(path)
|
|
assert response.status_code == 200
|
|
mock_build_request.assert_called_once()
|
|
mock_send.assert_called_once()
|
|
assert mock_build_request.call_args.kwargs["url"] == f"http://localhost:3000{path}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"path",
|
|
["/some/random/path", "/admin", "/.env"],
|
|
)
|
|
async def test_chat_proxy_blocks_arbitrary_paths(path):
|
|
server = AgentServer(enable_chat_proxy=True)
|
|
client = TestClient(server.app)
|
|
|
|
with (
|
|
patch.object(server.proxy_client, "build_request") as mock_build_request,
|
|
patch.object(server.proxy_client, "send"),
|
|
):
|
|
response = client.get(path)
|
|
assert response.status_code == 404
|
|
assert response.text == "Not found"
|
|
mock_build_request.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"path",
|
|
["/assets/../.env", "/assets/../../etc/passwd", "/assets/../admin"],
|
|
)
|
|
async def test_chat_proxy_blocks_path_traversal_attempts(path):
|
|
server = AgentServer(enable_chat_proxy=True)
|
|
client = TestClient(server.app)
|
|
|
|
with (
|
|
patch.object(server.proxy_client, "build_request") as mock_build_request,
|
|
patch.object(server.proxy_client, "send"),
|
|
):
|
|
response = client.get(path)
|
|
assert response.status_code == 404
|
|
assert response.text == "Not found"
|
|
mock_build_request.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("exact_paths_env", "prefixes_env", "test_path"),
|
|
[
|
|
("/custom", "", "/custom"),
|
|
("", "/custom/", "/custom/file.js"),
|
|
("/a,/b", "/c/,/d/", "/a"),
|
|
("/a,/b", "/c/,/d/", "/d/nested"),
|
|
],
|
|
)
|
|
async def test_chat_proxy_forwards_additional_paths_from_env_vars(
|
|
exact_paths_env, prefixes_env, test_path, monkeypatch
|
|
):
|
|
if exact_paths_env:
|
|
monkeypatch.setenv("CHAT_PROXY_ALLOWED_EXACT_PATHS", exact_paths_env)
|
|
if prefixes_env:
|
|
monkeypatch.setenv("CHAT_PROXY_ALLOWED_PATH_PREFIXES", prefixes_env)
|
|
|
|
server = AgentServer(enable_chat_proxy=True)
|
|
client = TestClient(server.app)
|
|
|
|
mock_response = AsyncMock()
|
|
mock_response.status_code = 200
|
|
mock_response.headers = {}
|
|
mock_response.aread = AsyncMock(return_value=b"response")
|
|
mock_response.aclose = AsyncMock()
|
|
|
|
with (
|
|
patch.object(server.proxy_client, "build_request") as mock_build_request,
|
|
patch.object(server.proxy_client, "send", return_value=mock_response) as mock_send,
|
|
):
|
|
response = client.get(test_path)
|
|
assert response.status_code == 200
|
|
mock_build_request.assert_called_once()
|
|
mock_send.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("content_type", "status_code", "custom_headers"),
|
|
[
|
|
("text/event-stream", 200, {}),
|
|
("text/event-stream; charset=utf-8", 200, {}),
|
|
("text/event-stream", 500, {}),
|
|
("text/event-stream", 200, {"x-custom-header": "value", "cache-control": "no-cache"}),
|
|
],
|
|
)
|
|
async def test_chat_proxy_sse_streaming(content_type, status_code, custom_headers):
|
|
server = AgentServer(enable_chat_proxy=True)
|
|
client = TestClient(server.app)
|
|
|
|
chunks = [b"data: chunk1\n\n", b"data: chunk2\n\n"]
|
|
|
|
async def mock_aiter_bytes():
|
|
for chunk in chunks:
|
|
yield chunk
|
|
|
|
mock_response = AsyncMock()
|
|
mock_response.status_code = status_code
|
|
mock_response.headers = {"content-type": content_type, **custom_headers}
|
|
mock_response.aiter_bytes = mock_aiter_bytes
|
|
mock_response.aclose = AsyncMock()
|
|
|
|
with (
|
|
patch.object(server.proxy_client, "build_request") as mock_build_request,
|
|
patch.object(server.proxy_client, "send", return_value=mock_response) as mock_send,
|
|
):
|
|
response = client.get("/api/stream")
|
|
assert response.status_code == status_code
|
|
assert "text/event-stream" in response.headers["content-type"]
|
|
assert response.content == b"data: chunk1\n\ndata: chunk2\n\n"
|
|
mock_build_request.assert_called_once()
|
|
mock_response.aclose.assert_called_once()
|
|
assert mock_send.call_args.kwargs.get("stream") is True
|
|
for key, value in custom_headers.items():
|
|
assert response.headers[key] == value
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("content_type", "status_code", "custom_headers"),
|
|
[
|
|
("application/json", 200, {}),
|
|
("text/html", 201, {"x-request-id": "req-123"}),
|
|
("text/plain", 200, {}),
|
|
("application/octet-stream", 200, {}),
|
|
],
|
|
)
|
|
async def test_chat_proxy_non_sse_responses(content_type, status_code, custom_headers):
|
|
server = AgentServer(enable_chat_proxy=True)
|
|
client = TestClient(server.app)
|
|
|
|
mock_response = AsyncMock()
|
|
mock_response.status_code = status_code
|
|
mock_response.headers = {"content-type": content_type, **custom_headers}
|
|
mock_response.aread = AsyncMock(return_value=b"content")
|
|
mock_response.aclose = AsyncMock()
|
|
|
|
with (
|
|
patch.object(server.proxy_client, "build_request") as mock_build_request,
|
|
patch.object(server.proxy_client, "send", return_value=mock_response) as mock_send,
|
|
):
|
|
response = client.get("/")
|
|
assert response.status_code == status_code
|
|
assert response.content == b"content"
|
|
mock_build_request.assert_called_once()
|
|
mock_response.aread.assert_called_once()
|
|
mock_response.aclose.assert_called_once()
|
|
assert mock_send.call_args.kwargs.get("stream") is True
|
|
for key, value in custom_headers.items():
|
|
assert response.headers[key] == value
|
|
|
|
|
|
def test_return_trace_header_invoke_responses_agent():
|
|
mock_span_instance = Mock()
|
|
mock_span_instance.__enter__ = Mock(return_value=mock_span_instance)
|
|
mock_span_instance.__exit__ = Mock(return_value=None)
|
|
mock_span_instance.trace_id = "test-trace-id-123"
|
|
with patch("mlflow.start_span", return_value=mock_span_instance) as mock_span:
|
|
|
|
@invoke()
|
|
def test_invoke(request):
|
|
return {
|
|
"output": [
|
|
{
|
|
"type": "message",
|
|
"id": "123",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Hello"}],
|
|
}
|
|
]
|
|
}
|
|
|
|
server = AgentServer("ResponsesAgent")
|
|
client = TestClient(server.app)
|
|
|
|
request_data = {
|
|
"input": [
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "Hello"}],
|
|
}
|
|
]
|
|
}
|
|
|
|
response = client.post(
|
|
"/invocations",
|
|
json=request_data,
|
|
headers={"x-mlflow-return-trace-id": "true"},
|
|
)
|
|
assert response.status_code == 200
|
|
response_json = response.json()
|
|
assert "output" in response_json
|
|
assert response_json["metadata"] == {"trace_id": "test-trace-id-123"}
|
|
mock_span.assert_called_once()
|
|
|
|
|
|
def test_return_trace_header_invoke_responses_agent_without_header():
|
|
mock_span_instance = Mock()
|
|
mock_span_instance.__enter__ = Mock(return_value=mock_span_instance)
|
|
mock_span_instance.__exit__ = Mock(return_value=None)
|
|
mock_span_instance.trace_id = "test-trace-id-123"
|
|
with patch("mlflow.start_span", return_value=mock_span_instance) as mock_span:
|
|
|
|
@invoke()
|
|
def test_invoke(request):
|
|
return {
|
|
"output": [
|
|
{
|
|
"type": "message",
|
|
"id": "123",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Hello"}],
|
|
}
|
|
]
|
|
}
|
|
|
|
server = AgentServer("ResponsesAgent")
|
|
client = TestClient(server.app)
|
|
|
|
request_data = {
|
|
"input": [
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "Hello"}],
|
|
}
|
|
]
|
|
}
|
|
|
|
response = client.post("/invocations", json=request_data)
|
|
assert response.status_code == 200
|
|
response_json = response.json()
|
|
assert "output" in response_json
|
|
assert response_json.get("metadata") is None
|
|
mock_span.assert_called_once()
|
|
|
|
|
|
def test_return_trace_header_stream_responses_agent():
|
|
mock_span_instance = Mock()
|
|
mock_span_instance.__enter__ = Mock(return_value=mock_span_instance)
|
|
mock_span_instance.__exit__ = Mock(return_value=None)
|
|
mock_span_instance.trace_id = "test-trace-id-456"
|
|
with patch("mlflow.start_span", return_value=mock_span_instance) as mock_span:
|
|
|
|
@stream()
|
|
def test_stream(request):
|
|
yield ResponsesAgentStreamEvent(
|
|
type="response.output_item.done",
|
|
item={
|
|
"type": "message",
|
|
"id": "123",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Hello"}],
|
|
},
|
|
)
|
|
|
|
server = AgentServer("ResponsesAgent")
|
|
client = TestClient(server.app)
|
|
|
|
request_data = {
|
|
"input": [
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "Hello"}],
|
|
}
|
|
],
|
|
"stream": True,
|
|
}
|
|
|
|
response = client.post(
|
|
"/invocations",
|
|
json=request_data,
|
|
headers={"x-mlflow-return-trace-id": "true"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
|
|
|
content = response.text
|
|
assert 'data: {"trace_id": "test-trace-id-456"}' in content
|
|
assert "data: [DONE]" in content
|
|
mock_span.assert_called_once()
|
|
|
|
|
|
def test_return_trace_header_stream_responses_agent_without_header():
|
|
mock_span_instance = Mock()
|
|
mock_span_instance.__enter__ = Mock(return_value=mock_span_instance)
|
|
mock_span_instance.__exit__ = Mock(return_value=None)
|
|
mock_span_instance.trace_id = "test-trace-id-456"
|
|
with patch("mlflow.start_span", return_value=mock_span_instance) as mock_span:
|
|
|
|
@stream()
|
|
def test_stream(request):
|
|
yield ResponsesAgentStreamEvent(
|
|
type="response.output_item.done",
|
|
item={
|
|
"type": "message",
|
|
"id": "123",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Hello"}],
|
|
},
|
|
)
|
|
|
|
server = AgentServer("ResponsesAgent")
|
|
client = TestClient(server.app)
|
|
|
|
request_data = {
|
|
"input": [
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "Hello"}],
|
|
}
|
|
],
|
|
"stream": True,
|
|
}
|
|
|
|
response = client.post("/invocations", json=request_data)
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
|
|
|
content = response.text
|
|
assert "trace_id" not in content
|
|
assert "data: [DONE]" in content
|
|
mock_span.assert_called_once()
|
|
|
|
|
|
def test_return_trace_header_stream_non_responses_agent():
|
|
mock_span_instance = Mock()
|
|
mock_span_instance.__enter__ = Mock(return_value=mock_span_instance)
|
|
mock_span_instance.__exit__ = Mock(return_value=None)
|
|
mock_span_instance.trace_id = "test-trace-id-789"
|
|
with patch("mlflow.start_span", return_value=mock_span_instance) as mock_span:
|
|
|
|
@stream()
|
|
def test_stream(request):
|
|
yield {"type": "custom_event", "data": "chunk"}
|
|
|
|
server = AgentServer() # No agent_type (not ResponsesAgent)
|
|
client = TestClient(server.app)
|
|
|
|
request_data = {"input": "test", "stream": True}
|
|
|
|
response = client.post(
|
|
"/invocations",
|
|
json=request_data,
|
|
headers={"x-mlflow-return-trace-id": "true"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
content = response.text
|
|
# trace_id should NOT be included for non-ResponsesAgent even with header
|
|
assert "trace_id" not in content
|
|
assert "data: [DONE]" in content
|
|
mock_span.assert_called_once()
|
|
|
|
|
|
@pytest.mark.parametrize("header_value", ["true", "True", "TRUE", "tRuE"])
|
|
def test_return_trace_header_case_insensitive(header_value):
|
|
mock_span_instance = Mock()
|
|
mock_span_instance.__enter__ = Mock(return_value=mock_span_instance)
|
|
mock_span_instance.__exit__ = Mock(return_value=None)
|
|
mock_span_instance.trace_id = "test-trace-id-123"
|
|
with patch("mlflow.start_span", return_value=mock_span_instance) as mock_span:
|
|
|
|
@invoke()
|
|
def test_invoke(request):
|
|
return {
|
|
"output": [
|
|
{
|
|
"type": "message",
|
|
"id": "123",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Hello"}],
|
|
}
|
|
]
|
|
}
|
|
|
|
server = AgentServer("ResponsesAgent")
|
|
client = TestClient(server.app)
|
|
|
|
request_data = {
|
|
"input": [
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "Hello"}],
|
|
}
|
|
]
|
|
}
|
|
|
|
response = client.post(
|
|
"/invocations",
|
|
json=request_data,
|
|
headers={"x-mlflow-return-trace-id": header_value},
|
|
)
|
|
assert response.status_code == 200
|
|
response_json = response.json()
|
|
assert "output" in response_json
|
|
assert response_json["metadata"] == {"trace_id": "test-trace-id-123"}
|
|
mock_span.assert_called_once()
|