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

602 lines
22 KiB
Python

import importlib.metadata
from unittest import mock
import pytest
import mlflow
from mlflow.entities.trace_info import TraceInfo
from mlflow.environment_variables import MLFLOW_ENABLE_ASYNC_TRACE_LOGGING
from mlflow.exceptions import MlflowException
from mlflow.genai.evaluation.base import to_predict_fn
from mlflow.genai.utils.trace_utils import convert_predict_fn
from tests.tracing.helper import V2_TRACE_DICT
_DUMMY_CHAT_RESPONSE = {
"id": "1",
"object": "text_completion",
"created": "2021-10-01T00:00:00.000000Z",
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"content": "This is a response",
"role": "assistant",
},
"finish_reason": "length",
}
],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
},
}
@pytest.fixture
def mock_deploy_client():
with mock.patch("mlflow.deployments.get_deploy_client") as mock_get:
yield mock_get.return_value
# TODO: Remove this once OSS backend is migrated to V3.
@pytest.fixture
def mock_tracing_client(monkeypatch):
# Mock the TracingClient
with mock.patch("mlflow.tracing.export.mlflow_v3.TracingClient") as mock_get:
tracing_client = mock_get.return_value
tracing_client.tracking_uri = "databricks"
# Set up trace exporter to Databricks.
monkeypatch.setenv(MLFLOW_ENABLE_ASYNC_TRACE_LOGGING.name, "false")
mlflow.set_tracking_uri("databricks")
mlflow.tracing.enable() # Set up trace exporter again
yield tracing_client
def test_to_predict_fn_return_trace(sample_rag_trace, mock_deploy_client, mock_tracing_client):
mock_deploy_client.predict.return_value = {
**_DUMMY_CHAT_RESPONSE,
"databricks_output": {"trace": sample_rag_trace.to_dict()},
}
messages = [
{"content": "You are a helpful assistant.", "role": "system"},
{"content": "What is Spark?", "role": "user"},
]
predict_fn = to_predict_fn("endpoints:/chat")
response = predict_fn(messages=messages)
mock_deploy_client.predict.assert_called_once_with(
endpoint="chat",
inputs={
"messages": messages,
"databricks_options": {"return_trace": True},
},
)
assert response == _DUMMY_CHAT_RESPONSE # Response should not contain databricks_output
# Trace from endpoint (sample_rag_trace) should be copied to the current experiment
mock_tracing_client.start_trace.assert_called_once()
trace_info = mock_tracing_client.start_trace.call_args[0][0]
# Copied trace should have a new trace ID
assert trace_info.trace_id != sample_rag_trace.info.trace_id
assert trace_info.request_preview == '{"question": "query"}'
assert trace_info.response_preview == '"answer"'
trace_data = mock_tracing_client._upload_trace_data.call_args[0][1]
assert len(trace_data.spans) == 3
for old, new in zip(sample_rag_trace.data.spans, trace_data.spans):
assert old.name == new.name
assert old.inputs == new.inputs
assert old.outputs == new.outputs
assert old.start_time_ns == new.start_time_ns
assert old.end_time_ns == new.end_time_ns
assert old.parent_id == new.parent_id
assert old.span_id == new.span_id
mock_tracing_client._upload_trace_data.assert_called_once_with(mock.ANY, trace_data)
@pytest.mark.parametrize(
"databricks_output",
[
{},
{"databricks_output": {}},
{"databricks_output": {"trace": None}},
],
)
def test_to_predict_fn_does_not_return_trace(
databricks_output, mock_deploy_client, mock_tracing_client
):
mock_deploy_client.predict.return_value = _DUMMY_CHAT_RESPONSE | databricks_output
messages = [
{"content": "You are a helpful assistant.", "role": "system"},
{"content": "What is Spark?", "role": "user"},
]
predict_fn = to_predict_fn("endpoints:/chat")
response = predict_fn(messages=messages)
mock_deploy_client.predict.assert_called_once_with(
endpoint="chat",
inputs={
"messages": messages,
"databricks_options": {"return_trace": True},
},
)
assert response == _DUMMY_CHAT_RESPONSE # Response should not contain databricks_output
# Bare-minimum trace should be created when the endpoint does not return a trace
mock_tracing_client.start_trace.assert_called_once()
trace_info = mock_tracing_client.start_trace.call_args[0][0]
assert trace_info.request_preview == "What is Spark?"
trace_data = mock_tracing_client._upload_trace_data.call_args[0][1]
assert len(trace_data.spans) == 1
assert trace_data.spans[0].name == "predict"
def test_to_predict_fn_pass_tracing_check(
sample_rag_trace, mock_deploy_client, mock_tracing_client
):
"""
The function produced by to_predict_fn() is guaranteed to create a trace.
Therefore it should not be wrapped by @mlflow.trace by convert_predict_fn().
"""
mock_deploy_client.predict.side_effect = lambda **kwargs: {
**_DUMMY_CHAT_RESPONSE,
"databricks_output": {"trace": sample_rag_trace.to_dict()},
}
sample_input = {"messages": [{"role": "user", "content": "Hi"}]}
predict_fn = to_predict_fn("endpoints:/chat")
converted = convert_predict_fn(predict_fn, sample_input)
# The check should pass, the function should not be wrapped by @mlflow.trace
wrapped = hasattr(converted, "__wrapped__")
assert wrapped != predict_fn
# The function should not produce a trace during the check
mock_tracing_client.start_trace.assert_not_called()
# The function should produce a trace when invoked
converted(sample_input)
mock_tracing_client.start_trace.assert_called_once()
trace_info = mock_tracing_client.start_trace.call_args[0][0]
assert trace_info.request_preview == '{"question": "query"}'
assert trace_info.response_preview == '"answer"'
# The produced trace should be the one returned from the endpoint (sample_rag_trace)
trace_data = mock_tracing_client._upload_trace_data.call_args[0][1]
assert trace_data.spans[0].name == "rag"
assert trace_data.spans[0].inputs == {"question": "query"}
assert trace_data.spans[0].outputs == "answer"
def test_to_predict_fn_return_v2_trace(mock_deploy_client, mock_tracing_client):
mlflow.tracing.reset()
mock_deploy_client.predict.return_value = {
**_DUMMY_CHAT_RESPONSE,
"databricks_output": {"trace": V2_TRACE_DICT},
}
messages = [
{"content": "You are a helpful assistant.", "role": "system"},
{"content": "What is Spark?", "role": "user"},
]
predict_fn = to_predict_fn("endpoints:/chat")
response = predict_fn(messages=messages)
mock_deploy_client.predict.assert_called_once_with(
endpoint="chat",
inputs={
"messages": messages,
"databricks_options": {"return_trace": True},
},
)
assert response == _DUMMY_CHAT_RESPONSE # Response should not contain databricks_output
# Trace from endpoint (sample_rag_trace) should be copied to the current experiment
mock_tracing_client.start_trace.assert_called_once()
trace_info = mock_tracing_client.start_trace.call_args[0][0]
# Copied trace should have a new trace ID (and v3)
isinstance(trace_info, TraceInfo)
assert trace_info.trace_id != V2_TRACE_DICT["info"]["request_id"]
assert trace_info.request_preview == '{"x": 2, "y": 5}'
assert trace_info.response_preview == "8"
trace_data = mock_tracing_client._upload_trace_data.call_args[0][1]
assert len(trace_data.spans) == 2
assert trace_data.spans[0].name == "predict"
assert trace_data.spans[0].inputs == {"x": 2, "y": 5}
assert trace_data.spans[0].outputs == 8
mock_tracing_client._upload_trace_data.assert_called_once_with(mock.ANY, trace_data)
def test_to_predict_fn_should_not_pass_databricks_options_to_fmapi(
mock_deploy_client, mock_tracing_client
):
mock_deploy_client.get_endpoint.return_value = {
"endpoint_type": "FOUNDATION_MODEL_API",
}
mock_deploy_client.predict.return_value = _DUMMY_CHAT_RESPONSE
messages = [
{"content": "You are a helpful assistant.", "role": "system"},
{"content": "What is Spark?", "role": "user"},
]
predict_fn = to_predict_fn("endpoints:/foundation-model-api")
response = predict_fn(messages=messages)
mock_deploy_client.predict.assert_called_once_with(
endpoint="foundation-model-api",
inputs={"messages": messages},
)
assert response == _DUMMY_CHAT_RESPONSE # Response should not contain databricks_output
# Bare-minimum trace should be created when the endpoint does not return a trace
mock_tracing_client.start_trace.assert_called_once()
trace_info = mock_tracing_client.start_trace.call_args[0][0]
assert trace_info.request_preview == "What is Spark?"
trace_data = mock_tracing_client._upload_trace_data.call_args[0][1]
assert len(trace_data.spans) == 1
assert trace_data.spans[0].name == "predict"
def test_to_predict_fn_handles_trace_without_tags(
sample_rag_trace, mock_deploy_client, mock_tracing_client
):
# Create a trace dict without `tags` field
trace_dict = sample_rag_trace.to_dict()
trace_dict["info"].pop("tags", None) # Remove tags field entirely
mock_deploy_client.predict.return_value = {
**_DUMMY_CHAT_RESPONSE,
"databricks_output": {"trace": trace_dict},
}
messages = [
{"content": "You are a helpful assistant.", "role": "system"},
{"content": "What is Spark?", "role": "user"},
]
predict_fn = to_predict_fn("endpoints:/chat")
response = predict_fn(messages=messages)
mock_deploy_client.predict.assert_called_once_with(
endpoint="chat",
inputs={
"messages": messages,
"databricks_options": {"return_trace": True},
},
)
assert response == _DUMMY_CHAT_RESPONSE
# Trace should be copied successfully even without tags
mock_tracing_client.start_trace.assert_called_once()
trace_info = mock_tracing_client.start_trace.call_args[0][0]
assert trace_info.trace_id != sample_rag_trace.info.trace_id
assert trace_info.request_preview == '{"question": "query"}'
assert trace_info.response_preview == '"answer"'
trace_data = mock_tracing_client._upload_trace_data.call_args[0][1]
assert len(trace_data.spans) == 3
mock_tracing_client._upload_trace_data.assert_called_once_with(mock.ANY, trace_data)
def test_to_predict_fn_reuses_trace_in_dual_write_mode(
sample_rag_trace, mock_deploy_client, mock_tracing_client
):
"""
Test that when an endpoint logs traces to both inference table and MLflow experiment
(dual-write mode), the trace is reused instead of being re-logged.
This happens when MLFLOW_EXPERIMENT_ID env var is set in the serving endpoint.
"""
# Set up an experiment context
experiment_id = "test-experiment-123"
with mock.patch(
"mlflow.genai.evaluation.base._get_experiment_id", return_value=experiment_id
) as mock_get_experiment_id:
# Create a trace dict with experiment_id matching the current experiment
trace_dict = sample_rag_trace.to_dict()
trace_dict["info"]["trace_location"] = {
"mlflow_experiment": {"experiment_id": experiment_id}
}
mock_deploy_client.predict.return_value = {
**_DUMMY_CHAT_RESPONSE,
"databricks_output": {"trace": trace_dict},
}
messages = [
{"content": "You are a helpful assistant.", "role": "system"},
{"content": "What is Spark?", "role": "user"},
]
predict_fn = to_predict_fn("endpoints:/chat")
response = predict_fn(messages=messages)
mock_deploy_client.predict.assert_called_once_with(
endpoint="chat",
inputs={
"messages": messages,
"databricks_options": {"return_trace": True},
},
)
assert response == _DUMMY_CHAT_RESPONSE
# The trace should NOT be copied when it's already in the current experiment
mock_tracing_client.start_trace.assert_not_called()
mock_tracing_client._upload_trace_data.assert_not_called()
mock_get_experiment_id.assert_called_once()
# ========== Databricks Apps Tests ==========
def test_to_predict_fn_apps_uri_with_app_name(mock_tracing_client):
mock_app = mock.MagicMock()
mock_app.url = "https://agent-app-123.staging.aws.databricksapps.com"
mock_oauth_token = mock.MagicMock()
mock_oauth_token.access_token = "oauth-token-123"
mock_config = mock.MagicMock()
mock_config.oauth_token.return_value = mock_oauth_token
mock_workspace_client = mock.MagicMock()
mock_workspace_client.apps.get.return_value = mock_app
mock_workspace_client.config = mock_config
mock_response = mock.MagicMock()
mock_response.json.return_value = {"response": "test response"}
with (
mock.patch("databricks.sdk.WorkspaceClient", return_value=mock_workspace_client),
mock.patch(
"mlflow.utils.databricks_utils.http_request", return_value=mock_response
) as mock_http_request,
):
predict_fn = to_predict_fn("apps:/agent-app")
result = predict_fn(input=[{"role": "user", "content": "test"}])
# Verify SDK was called with correct app name
mock_workspace_client.apps.get.assert_called_once_with(name="agent-app")
# Verify http_request was called with correct parameters
mock_http_request.assert_called_once()
call_kwargs = mock_http_request.call_args[1]
assert call_kwargs["endpoint"] == "/invocations"
assert call_kwargs["method"] == "POST"
assert call_kwargs["json"] == {"input": [{"role": "user", "content": "test"}]}
# Verify host_creds has the OAuth token
host_creds = call_kwargs["host_creds"]
assert host_creds.host == "https://agent-app-123.staging.aws.databricksapps.com"
assert host_creds.token == "oauth-token-123"
assert result == {"response": "test response"}
def test_to_predict_fn_apps_not_found():
mock_workspace_client = mock.MagicMock()
mock_workspace_client.apps.get.side_effect = Exception("App not found: nonexistent-app")
with mock.patch("databricks.sdk.WorkspaceClient", return_value=mock_workspace_client):
with pytest.raises(MlflowException, match="Failed to get Databricks App"):
to_predict_fn("apps:/nonexistent-app")
def test_to_predict_fn_apps_no_url():
mock_app = mock.MagicMock()
mock_app.url = None # App exists but not deployed
mock_workspace_client = mock.MagicMock()
mock_workspace_client.apps.get.return_value = mock_app
with mock.patch("databricks.sdk.WorkspaceClient", return_value=mock_workspace_client):
with pytest.raises(MlflowException, match="does not have a URL"):
to_predict_fn("apps:/undeployed-app")
def test_to_predict_fn_apps_no_oauth_raises_error():
mock_app = mock.MagicMock()
mock_app.url = "https://my-app-123.staging.aws.databricksapps.com"
mock_config = mock.MagicMock()
# Simulate non-OAuth auth - oauth_token() raises Exception
mock_config.oauth_token.side_effect = Exception(
"OAuth tokens are not available for pat authentication"
)
mock_workspace_client = mock.MagicMock()
mock_workspace_client.apps.get.return_value = mock_app
mock_workspace_client.config = mock_config
with mock.patch("databricks.sdk.WorkspaceClient", return_value=mock_workspace_client):
predict_fn = to_predict_fn("apps:/my-app")
with pytest.raises(MlflowException, match="Databricks Apps require OAuth authentication"):
predict_fn(input=[{"role": "user", "content": "test"}])
def test_to_predict_fn_apps_old_sdk_version_error():
real_version = importlib.metadata.version
def mock_version(package):
if package == "databricks-sdk":
return "0.73.0"
return real_version(package)
with mock.patch("importlib.metadata.version", side_effect=mock_version):
with pytest.raises(MlflowException, match="databricks-sdk>=0.74.0"):
to_predict_fn("apps:/my-app")
def test_to_predict_fn_apps_http_error_handling():
mock_app = mock.MagicMock()
mock_app.url = "https://my-app-123.staging.aws.databricksapps.com"
mock_oauth_token = mock.MagicMock()
mock_oauth_token.access_token = "oauth-token"
mock_config = mock.MagicMock()
mock_config.oauth_token.return_value = mock_oauth_token
mock_workspace_client = mock.MagicMock()
mock_workspace_client.apps.get.return_value = mock_app
mock_workspace_client.config = mock_config
with (
mock.patch("databricks.sdk.WorkspaceClient", return_value=mock_workspace_client),
mock.patch("mlflow.utils.databricks_utils.http_request") as mock_http_request,
):
# http_request raises MlflowException on errors
mock_http_request.side_effect = MlflowException("Request failed: 403 Forbidden")
predict_fn = to_predict_fn("apps:/my-app")
with pytest.raises(MlflowException, match="Request failed"):
predict_fn(input=[{"role": "user", "content": "test"}])
def test_to_predict_fn_apps_payload_passthrough():
mock_app = mock.MagicMock()
mock_app.url = "https://my-app-123.staging.aws.databricksapps.com"
mock_oauth_token = mock.MagicMock()
mock_oauth_token.access_token = "oauth-token"
mock_config = mock.MagicMock()
mock_config.oauth_token.return_value = mock_oauth_token
mock_workspace_client = mock.MagicMock()
mock_workspace_client.apps.get.return_value = mock_app
mock_workspace_client.config = mock_config
mock_response = mock.MagicMock()
mock_response.json.return_value = {"output": "ok"}
with (
mock.patch("databricks.sdk.WorkspaceClient", return_value=mock_workspace_client),
mock.patch(
"mlflow.utils.databricks_utils.http_request", return_value=mock_response
) as mock_http_request,
):
predict_fn = to_predict_fn("apps:/my-app")
# Test 1: Standard format with input
predict_fn(input=[{"role": "user", "content": "test"}])
call_kwargs = mock_http_request.call_args[1]
assert call_kwargs["json"] == {"input": [{"role": "user", "content": "test"}]}
mock_http_request.reset_mock()
# Test 2: With custom_inputs
predict_fn(
input=[{"role": "user", "content": "test2"}],
custom_inputs={"session_id": "123"},
)
call_kwargs = mock_http_request.call_args[1]
assert call_kwargs["json"] == {
"input": [{"role": "user", "content": "test2"}],
"custom_inputs": {"session_id": "123"},
}
mock_http_request.reset_mock()
# Test 3: With stream parameter
predict_fn(input=[{"role": "user", "content": "test3"}], stream=True)
call_kwargs = mock_http_request.call_args[1]
assert call_kwargs["json"] == {
"input": [{"role": "user", "content": "test3"}],
"stream": True,
}
def test_to_predict_fn_apps_creates_trace():
mock_app = mock.MagicMock()
mock_app.url = "https://my-app-123.staging.aws.databricksapps.com"
mock_oauth_token = mock.MagicMock()
mock_oauth_token.access_token = "oauth-token"
mock_config = mock.MagicMock()
mock_config.oauth_token.return_value = mock_oauth_token
mock_workspace_client = mock.MagicMock()
mock_workspace_client.apps.get.return_value = mock_app
mock_workspace_client.config = mock_config
mock_response = mock.MagicMock()
mock_response.json.return_value = {"output": "test output"}
with (
mock.patch("databricks.sdk.WorkspaceClient", return_value=mock_workspace_client),
mock.patch("mlflow.utils.databricks_utils.http_request", return_value=mock_response),
):
predict_fn = to_predict_fn("apps:/my-app")
result = predict_fn(input=[{"role": "user", "content": "test"}])
# Verify trace was created
trace = mlflow.get_trace(mlflow.get_last_active_trace_id())
assert trace is not None
assert len(trace.data.spans) == 1
assert trace.data.spans[0].name == "predict"
assert result == {"output": "test output"}
def test_to_predict_fn_apps_invalid_uri():
with pytest.raises(ValueError, match="Invalid endpoint URI"):
to_predict_fn("invalid:/my-app")
def test_to_predict_fn_copies_trace_when_experiment_differs(
sample_rag_trace, mock_deploy_client, mock_tracing_client
):
"""
Test that when an endpoint returns a trace from a different experiment,
the trace is still copied to the current experiment.
"""
# Set up an experiment context
current_experiment_id = "current-experiment-123"
endpoint_experiment_id = "different-experiment-456"
with mock.patch(
"mlflow.genai.evaluation.base._get_experiment_id", return_value=current_experiment_id
) as mock_get_experiment_id:
# Create a trace dict with a different experiment_id
trace_dict = sample_rag_trace.to_dict()
trace_dict["info"]["trace_location"] = {
"mlflow_experiment": {"experiment_id": endpoint_experiment_id}
}
mock_deploy_client.predict.return_value = {
**_DUMMY_CHAT_RESPONSE,
"databricks_output": {"trace": trace_dict},
}
messages = [
{"content": "You are a helpful assistant.", "role": "system"},
{"content": "What is Spark?", "role": "user"},
]
predict_fn = to_predict_fn("endpoints:/chat")
response = predict_fn(messages=messages)
mock_deploy_client.predict.assert_called_once_with(
endpoint="chat",
inputs={
"messages": messages,
"databricks_options": {"return_trace": True},
},
)
assert response == _DUMMY_CHAT_RESPONSE
# The trace SHOULD be copied when experiments differ
mock_tracing_client.start_trace.assert_called_once()
trace_info = mock_tracing_client.start_trace.call_args[0][0]
# Copied trace should have a new trace ID
assert trace_info.trace_id != sample_rag_trace.info.trace_id
mock_tracing_client._upload_trace_data.assert_called_once()
mock_get_experiment_id.assert_called_once()