Files
2026-07-13 13:22:34 +08:00

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()