2526 lines
78 KiB
Python
2526 lines
78 KiB
Python
import json
|
|
import time
|
|
from unittest import mock
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pandas as pd
|
|
import pytest
|
|
import sklearn.neighbors as knn
|
|
from click.testing import CliRunner
|
|
from fastapi import Request
|
|
|
|
import mlflow
|
|
from mlflow import MlflowClient
|
|
from mlflow.entities import (
|
|
EvaluationDataset,
|
|
Expectation,
|
|
Feedback,
|
|
GatewayEndpointModelConfig,
|
|
IssueSeverity,
|
|
IssueStatus,
|
|
Metric,
|
|
Param,
|
|
RunTag,
|
|
)
|
|
from mlflow.entities.assessment_source import AssessmentSource, AssessmentSourceType
|
|
from mlflow.entities.gateway_budget_policy import (
|
|
BudgetAction,
|
|
BudgetDuration,
|
|
BudgetDurationUnit,
|
|
BudgetTargetScope,
|
|
BudgetUnit,
|
|
)
|
|
from mlflow.entities.gateway_endpoint import GatewayModelLinkageType
|
|
from mlflow.entities.gateway_guardrail import GuardrailAction, GuardrailStage
|
|
from mlflow.entities.trace import Trace
|
|
from mlflow.entities.webhook import WebhookAction, WebhookEntity, WebhookEvent
|
|
from mlflow.gateway.cli import start
|
|
from mlflow.gateway.constants import MLFLOW_GATEWAY_CALLER_HEADER
|
|
from mlflow.gateway.schemas import chat
|
|
from mlflow.genai.datasets import create_dataset
|
|
from mlflow.genai.discovery.entities import _TriageResult
|
|
from mlflow.genai.discovery.pipeline import discover_issues
|
|
from mlflow.genai.judges import make_judge
|
|
from mlflow.genai.judges.base import AlignmentOptimizer
|
|
from mlflow.genai.scorers import scorer
|
|
from mlflow.genai.scorers.base import Scorer
|
|
from mlflow.genai.scorers.builtin_scorers import (
|
|
Completeness,
|
|
Guidelines,
|
|
RelevanceToQuery,
|
|
Safety,
|
|
UserFrustration,
|
|
)
|
|
from mlflow.genai.simulators import ConversationSimulator
|
|
from mlflow.pyfunc.model import (
|
|
ResponsesAgent,
|
|
ResponsesAgentRequest,
|
|
ResponsesAgentResponse,
|
|
)
|
|
from mlflow.server.gateway_api import chat_completions, invocations
|
|
from mlflow.store.tracking.gateway.entities import GatewayEndpointConfig, GatewayModelConfig
|
|
from mlflow.store.tracking.sqlalchemy_store import SqlAlchemyStore
|
|
from mlflow.telemetry.client import TelemetryClient
|
|
from mlflow.telemetry.events import (
|
|
AiCommandRunEvent,
|
|
AlignJudgeEvent,
|
|
AutologgingEvent,
|
|
CreateDatasetEvent,
|
|
CreateExperimentEvent,
|
|
CreateLoggedModelEvent,
|
|
CreateModelVersionEvent,
|
|
CreatePromptEvent,
|
|
CreateRegisteredModelEvent,
|
|
CreateRunEvent,
|
|
CreateWebhookEvent,
|
|
DiscoverIssuesEvent,
|
|
EvaluateEvent,
|
|
GatewayCreateBudgetPolicyEvent,
|
|
GatewayCreateEndpointEvent,
|
|
GatewayCreateGuardrailEvent,
|
|
GatewayCreateModelDefinitionEvent,
|
|
GatewayCreateSecretEvent,
|
|
GatewayDeleteBudgetPolicyEvent,
|
|
GatewayDeleteEndpointEvent,
|
|
GatewayDeleteGuardrailEvent,
|
|
GatewayDeleteSecretEvent,
|
|
GatewayGetEndpointEvent,
|
|
GatewayInvocationEvent,
|
|
GatewayListBudgetPoliciesEvent,
|
|
GatewayListEndpointsEvent,
|
|
GatewayListSecretsEvent,
|
|
GatewayStartEvent,
|
|
GatewayUpdateBudgetPolicyEvent,
|
|
GatewayUpdateEndpointEvent,
|
|
GatewayUpdateGuardrailEvent,
|
|
GatewayUpdateSecretEvent,
|
|
GenAIEvaluateEvent,
|
|
GetLoggedModelEvent,
|
|
GitModelVersioningEvent,
|
|
InvokeCustomJudgeModelEvent,
|
|
LoadPromptEvent,
|
|
LogAssessmentEvent,
|
|
LogBatchEvent,
|
|
LogDatasetEvent,
|
|
LogMetricEvent,
|
|
LogParamEvent,
|
|
MakeJudgeEvent,
|
|
McpRunEvent,
|
|
MergeRecordsEvent,
|
|
PromptOptimizationEvent,
|
|
ScorerCallEvent,
|
|
SimulateConversationEvent,
|
|
StartTraceEvent,
|
|
TracingContextPropagation,
|
|
TrackingServerStartEvent,
|
|
UpdateIssueEvent,
|
|
)
|
|
from mlflow.tracing.distributed import (
|
|
get_tracing_context_headers_for_http_request,
|
|
set_tracing_context_from_http_request_headers,
|
|
)
|
|
from mlflow.tracking.fluent import (
|
|
_create_dataset_input,
|
|
_create_logged_model,
|
|
_initialize_logged_model,
|
|
)
|
|
from mlflow.utils.os import is_windows
|
|
|
|
from tests.telemetry.helper_functions import validate_telemetry_record
|
|
|
|
|
|
class TestModel(mlflow.pyfunc.PythonModel):
|
|
def predict(self, model_input: list[str]) -> str:
|
|
return "test"
|
|
|
|
|
|
@pytest.fixture
|
|
def mlflow_client():
|
|
return MlflowClient()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def mock_get_telemetry_client(mock_telemetry_client: TelemetryClient):
|
|
with mock.patch(
|
|
"mlflow.telemetry.track.get_telemetry_client",
|
|
return_value=mock_telemetry_client,
|
|
):
|
|
yield
|
|
|
|
|
|
def test_create_logged_model(mock_requests, mock_telemetry_client: TelemetryClient):
|
|
event_name = CreateLoggedModelEvent.name
|
|
mlflow.create_external_model(name="model")
|
|
validate_telemetry_record(
|
|
mock_telemetry_client, mock_requests, event_name, {"flavor": "external"}
|
|
)
|
|
|
|
mlflow.initialize_logged_model(name="model", tags={"key": "value"})
|
|
validate_telemetry_record(
|
|
mock_telemetry_client, mock_requests, event_name, {"flavor": "initialize"}
|
|
)
|
|
|
|
_initialize_logged_model(name="model", flavor="keras")
|
|
validate_telemetry_record(mock_telemetry_client, mock_requests, event_name, {"flavor": "keras"})
|
|
|
|
mlflow.pyfunc.log_model(
|
|
name="model",
|
|
python_model=TestModel(),
|
|
)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
event_name,
|
|
{"flavor": "pyfunc.CustomPythonModel"},
|
|
)
|
|
|
|
mlflow.sklearn.log_model(
|
|
knn.KNeighborsClassifier(),
|
|
name="model",
|
|
)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
event_name,
|
|
{"flavor": "sklearn", "serialization_format": "skops"},
|
|
)
|
|
|
|
mlflow.sklearn.log_model(
|
|
knn.KNeighborsClassifier(),
|
|
name="model",
|
|
serialization_format=mlflow.sklearn.SERIALIZATION_FORMAT_PICKLE,
|
|
)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
event_name,
|
|
{"flavor": "sklearn", "serialization_format": "pickle"},
|
|
)
|
|
|
|
class SimpleResponsesAgent(ResponsesAgent):
|
|
def predict(self, request: ResponsesAgentRequest) -> ResponsesAgentResponse:
|
|
mock_response = {
|
|
"output": [
|
|
{
|
|
"type": "message",
|
|
"id": "1234",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "output_text",
|
|
"text": request.input[0].content,
|
|
}
|
|
],
|
|
}
|
|
],
|
|
}
|
|
return ResponsesAgentResponse(**mock_response)
|
|
|
|
mlflow.pyfunc.log_model(
|
|
name="model",
|
|
python_model=SimpleResponsesAgent(),
|
|
)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
event_name,
|
|
{"flavor": "pyfunc.ResponsesAgent"},
|
|
)
|
|
|
|
_create_logged_model(name="model", flavor="pyfunc", uses_uv=True)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
event_name,
|
|
{"flavor": "pyfunc", "uses_uv": True},
|
|
)
|
|
|
|
_create_logged_model(name="model", flavor="pyfunc", uses_uv=False)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
event_name,
|
|
{"flavor": "pyfunc"},
|
|
)
|
|
|
|
|
|
def test_create_experiment(mock_requests, mlflow_client, mock_telemetry_client: TelemetryClient):
|
|
event_name = CreateExperimentEvent.name
|
|
exp_id = mlflow.create_experiment(name="test_experiment")
|
|
validate_telemetry_record(
|
|
mock_telemetry_client, mock_requests, event_name, {"experiment_id": exp_id}
|
|
)
|
|
|
|
exp_id = mlflow_client.create_experiment(name="test_experiment1")
|
|
validate_telemetry_record(
|
|
mock_telemetry_client, mock_requests, event_name, {"experiment_id": exp_id}
|
|
)
|
|
|
|
|
|
def test_create_run(mock_requests, mlflow_client, mock_telemetry_client: TelemetryClient):
|
|
event_name = CreateRunEvent.name
|
|
exp_id = mlflow.create_experiment(name="test_experiment")
|
|
with mlflow.start_run(experiment_id=exp_id):
|
|
record = validate_telemetry_record(
|
|
mock_telemetry_client, mock_requests, event_name, check_params=False
|
|
)
|
|
assert json.loads(record["params"])["experiment_id"] == exp_id
|
|
|
|
mlflow_client.create_run(experiment_id=exp_id)
|
|
validate_telemetry_record(mock_telemetry_client, mock_requests, event_name, check_params=False)
|
|
|
|
exp_id = mlflow.create_experiment(name="test_experiment2")
|
|
mlflow.set_experiment(experiment_id=exp_id)
|
|
with mlflow.start_run():
|
|
record = validate_telemetry_record(
|
|
mock_telemetry_client, mock_requests, event_name, check_params=False
|
|
)
|
|
params = json.loads(record["params"])
|
|
assert params["mlflow_experiment_id"] == exp_id
|
|
|
|
|
|
def test_create_run_with_imports(mock_requests, mock_telemetry_client: TelemetryClient):
|
|
event_name = CreateRunEvent.name
|
|
import pyspark.ml # noqa: F401
|
|
|
|
with mlflow.start_run():
|
|
data = validate_telemetry_record(
|
|
mock_telemetry_client, mock_requests, event_name, check_params=False
|
|
)
|
|
assert "pyspark.ml" in json.loads(data["params"])["imports"]
|
|
|
|
|
|
def test_create_registered_model(
|
|
mock_requests, mlflow_client, mock_telemetry_client: TelemetryClient
|
|
):
|
|
event_name = CreateRegisteredModelEvent.name
|
|
mlflow_client.create_registered_model(name="test_model1")
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
event_name,
|
|
{"is_prompt": False},
|
|
)
|
|
|
|
mlflow.pyfunc.log_model(
|
|
name="model",
|
|
python_model=TestModel(),
|
|
registered_model_name="test_model",
|
|
)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
event_name,
|
|
{"is_prompt": False},
|
|
)
|
|
|
|
|
|
def test_create_model_version(mock_requests, mlflow_client, mock_telemetry_client: TelemetryClient):
|
|
event_name = CreateModelVersionEvent.name
|
|
mlflow_client.create_registered_model(name="test_model")
|
|
mlflow_client.create_model_version(
|
|
name="test_model", source="test_source", run_id="test_run_id"
|
|
)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
event_name,
|
|
{"is_prompt": False},
|
|
)
|
|
|
|
mlflow.pyfunc.log_model(
|
|
name="model",
|
|
python_model=TestModel(),
|
|
registered_model_name="test_model",
|
|
)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
event_name,
|
|
{"is_prompt": False},
|
|
)
|
|
|
|
mlflow.genai.register_prompt(
|
|
name="ai_assistant_prompt",
|
|
template="Respond to the user's message as a {{style}} AI. {{greeting}}",
|
|
commit_message="Initial version of AI assistant",
|
|
)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
event_name,
|
|
{"is_prompt": True},
|
|
)
|
|
|
|
|
|
def test_start_trace(mock_requests, mlflow_client, mock_telemetry_client: TelemetryClient):
|
|
event_name = StartTraceEvent.name
|
|
with mlflow.start_span(name="test_span"):
|
|
pass
|
|
validate_telemetry_record(mock_telemetry_client, mock_requests, event_name, check_params=False)
|
|
|
|
@mlflow.trace
|
|
def test_func():
|
|
pass
|
|
|
|
test_func()
|
|
validate_telemetry_record(mock_telemetry_client, mock_requests, event_name, check_params=False)
|
|
|
|
trace_id = mlflow_client.start_trace(name="test_trace").trace_id
|
|
mlflow_client.end_trace(trace_id=trace_id)
|
|
validate_telemetry_record(mock_telemetry_client, mock_requests, event_name, check_params=False)
|
|
|
|
import openai # noqa: F401
|
|
|
|
test_func()
|
|
data = validate_telemetry_record(
|
|
mock_telemetry_client, mock_requests, event_name, check_params=False
|
|
)
|
|
params = json.loads(data["params"])
|
|
assert "openai" in params["imports"]
|
|
assert params["format"] == "native"
|
|
|
|
|
|
def test_start_trace_genai_semconv(
|
|
mock_requests, monkeypatch, mock_telemetry_client: TelemetryClient
|
|
):
|
|
monkeypatch.setenv("MLFLOW_ENABLE_OTEL_GENAI_SEMCONV", "true")
|
|
event_name = StartTraceEvent.name
|
|
|
|
@mlflow.trace
|
|
def test_func():
|
|
pass
|
|
|
|
test_func()
|
|
data = validate_telemetry_record(
|
|
mock_telemetry_client, mock_requests, event_name, check_params=False
|
|
)
|
|
assert json.loads(data["params"])["format"] == "genai_semconv"
|
|
|
|
|
|
def test_create_prompt(mock_requests, mlflow_client, mock_telemetry_client: TelemetryClient):
|
|
mlflow_client.create_prompt(name="test_prompt")
|
|
validate_telemetry_record(mock_telemetry_client, mock_requests, CreatePromptEvent.name)
|
|
|
|
# OSS prompt registry uses create_registered_model with a special tag
|
|
mlflow.genai.register_prompt(
|
|
name="greeting_prompt",
|
|
template="Respond to the user's message as a {{style}} AI. {{greeting}}",
|
|
)
|
|
expected_params = {"is_prompt": True}
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
CreateRegisteredModelEvent.name,
|
|
expected_params,
|
|
)
|
|
|
|
|
|
def test_log_assessment(mock_requests, mock_telemetry_client: TelemetryClient):
|
|
with mlflow.start_span(name="test_span") as span:
|
|
feedback = Feedback(
|
|
name="faithfulness",
|
|
value=0.9,
|
|
rationale="The model is faithful to the input.",
|
|
metadata={"model": "gpt-4o-mini"},
|
|
)
|
|
|
|
mlflow.log_assessment(trace_id=span.trace_id, assessment=feedback)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
LogAssessmentEvent.name,
|
|
{"type": "feedback", "source_type": "CODE"},
|
|
)
|
|
mlflow.log_feedback(trace_id=span.trace_id, value=0.9, name="faithfulness")
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
LogAssessmentEvent.name,
|
|
{"type": "feedback", "source_type": "CODE"},
|
|
)
|
|
|
|
with mlflow.start_span(name="test_span2") as span:
|
|
expectation = Expectation(
|
|
name="expected_answer",
|
|
value="MLflow",
|
|
)
|
|
|
|
mlflow.log_assessment(trace_id=span.trace_id, assessment=expectation)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
LogAssessmentEvent.name,
|
|
{"type": "expectation", "source_type": "HUMAN"},
|
|
)
|
|
mlflow.log_expectation(trace_id=span.trace_id, value="MLflow", name="expected_answer")
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
LogAssessmentEvent.name,
|
|
{"type": "expectation", "source_type": "HUMAN"},
|
|
)
|
|
|
|
|
|
def test_evaluate(mock_requests, mock_telemetry_client: TelemetryClient):
|
|
mlflow.models.evaluate(
|
|
data=pd.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]}),
|
|
model=lambda x: x["x"] * 2,
|
|
extra_metrics=[mlflow.metrics.latency()],
|
|
)
|
|
validate_telemetry_record(mock_telemetry_client, mock_requests, EvaluateEvent.name)
|
|
|
|
|
|
def test_create_webhook(mock_requests, mock_telemetry_client: TelemetryClient):
|
|
client = MlflowClient()
|
|
client.create_webhook(
|
|
name="test_webhook",
|
|
url="https://example.com/webhook",
|
|
events=[WebhookEvent(WebhookEntity.MODEL_VERSION, WebhookAction.CREATED)],
|
|
)
|
|
expected_params = {"events": ["model_version.created"]}
|
|
validate_telemetry_record(
|
|
mock_telemetry_client, mock_requests, CreateWebhookEvent.name, expected_params
|
|
)
|
|
|
|
|
|
def test_genai_evaluate(mock_requests, mock_telemetry_client: TelemetryClient):
|
|
@mlflow.genai.scorer
|
|
def decorator_scorer():
|
|
return 1.0
|
|
|
|
instructions_judge = make_judge(
|
|
name="quality_judge",
|
|
instructions="Evaluate if {{ outputs }} is high quality",
|
|
model="openai:/gpt-4",
|
|
)
|
|
|
|
session_level_instruction_judge = make_judge(
|
|
name="conversation_quality",
|
|
instructions="Evaluate if the {{ conversation }} is engaging and coherent",
|
|
model="openai:/gpt-4",
|
|
)
|
|
|
|
guidelines_scorer = Guidelines(
|
|
name="politeness",
|
|
guidelines=["Be polite", "Be respectful"],
|
|
)
|
|
|
|
builtin_scorer = RelevanceToQuery(name="relevance_check")
|
|
|
|
session_level_builtin_scorer = UserFrustration(name="frustration_check")
|
|
|
|
data = [
|
|
{
|
|
"inputs": {"model_input": ["What is MLflow?"]},
|
|
"outputs": "MLflow is an open source platform.",
|
|
}
|
|
]
|
|
|
|
model = TestModel()
|
|
|
|
with (
|
|
mock.patch("mlflow.genai.judges.utils.invocation_utils.invoke_judge_model"),
|
|
mock.patch("mlflow.genai.judges.builtin.invoke_judge_model"),
|
|
mock.patch("mlflow.genai.judges.instructions_judge.invoke_judge_model"),
|
|
):
|
|
# Test with all scorer kinds and scopes, without predict_fn
|
|
mlflow.genai.evaluate(
|
|
data=data,
|
|
scorers=[
|
|
decorator_scorer,
|
|
instructions_judge,
|
|
session_level_instruction_judge,
|
|
guidelines_scorer,
|
|
builtin_scorer,
|
|
session_level_builtin_scorer,
|
|
],
|
|
)
|
|
|
|
expected_params = {
|
|
"predict_fn_provided": False,
|
|
"scorer_info": [
|
|
{
|
|
"class": "UserDefinedScorer",
|
|
"kind": "decorator",
|
|
"scope": "trace",
|
|
},
|
|
{
|
|
"class": "UserDefinedScorer",
|
|
"kind": "instructions",
|
|
"scope": "trace",
|
|
},
|
|
{
|
|
"class": "UserDefinedScorer",
|
|
"kind": "instructions",
|
|
"scope": "session",
|
|
},
|
|
{"class": "Guidelines", "kind": "guidelines", "scope": "trace"},
|
|
{"class": "RelevanceToQuery", "kind": "builtin", "scope": "trace"},
|
|
{"class": "UserFrustration", "kind": "builtin", "scope": "session"},
|
|
],
|
|
"eval_data_type": "list[dict]",
|
|
"eval_data_size": 1,
|
|
"eval_data_provided_fields": ["inputs", "outputs"],
|
|
}
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GenAIEvaluateEvent.name,
|
|
expected_params,
|
|
)
|
|
|
|
# Test with predict_fn
|
|
mlflow.genai.evaluate(
|
|
data=data,
|
|
scorers=[builtin_scorer, guidelines_scorer],
|
|
predict_fn=model.predict,
|
|
)
|
|
expected_params = {
|
|
"predict_fn_provided": True,
|
|
"scorer_info": [
|
|
{"class": "RelevanceToQuery", "kind": "builtin", "scope": "trace"},
|
|
{"class": "Guidelines", "kind": "guidelines", "scope": "trace"},
|
|
],
|
|
"eval_data_type": "list[dict]",
|
|
"eval_data_size": 1,
|
|
"eval_data_provided_fields": ["inputs", "outputs"],
|
|
}
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GenAIEvaluateEvent.name,
|
|
expected_params,
|
|
)
|
|
|
|
|
|
def test_genai_evaluate_telemetry_data_fields(
|
|
mock_requests, mock_telemetry_client: TelemetryClient
|
|
):
|
|
@mlflow.genai.scorer
|
|
def sample_scorer():
|
|
return 1.0
|
|
|
|
with mock.patch("mlflow.genai.judges.utils.invocation_utils.invoke_judge_model"):
|
|
# Test with list of dicts
|
|
data_list = [
|
|
{
|
|
"inputs": {"question": "Q1"},
|
|
"outputs": "A1",
|
|
"expectations": {"answer": "Expected1"},
|
|
},
|
|
{
|
|
"inputs": {"question": "Q2"},
|
|
"outputs": "A2",
|
|
"expectations": {"answer": "Expected2"},
|
|
},
|
|
]
|
|
mlflow.genai.evaluate(data=data_list, scorers=[sample_scorer])
|
|
expected_params = {
|
|
"predict_fn_provided": False,
|
|
"scorer_info": [
|
|
{
|
|
"class": "UserDefinedScorer",
|
|
"kind": "decorator",
|
|
"scope": "trace",
|
|
},
|
|
],
|
|
"eval_data_type": "list[dict]",
|
|
"eval_data_size": 2,
|
|
"eval_data_provided_fields": ["expectations", "inputs", "outputs"],
|
|
}
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GenAIEvaluateEvent.name,
|
|
expected_params,
|
|
)
|
|
|
|
# Test with pandas DataFrame
|
|
df_data = pd.DataFrame([
|
|
{"inputs": {"question": "Q1"}, "outputs": "A1"},
|
|
{"inputs": {"question": "Q2"}, "outputs": "A2"},
|
|
{"inputs": {"question": "Q3"}, "outputs": "A3"},
|
|
])
|
|
mlflow.genai.evaluate(data=df_data, scorers=[sample_scorer])
|
|
expected_params = {
|
|
"predict_fn_provided": False,
|
|
"scorer_info": [
|
|
{
|
|
"class": "UserDefinedScorer",
|
|
"kind": "decorator",
|
|
"scope": "trace",
|
|
},
|
|
],
|
|
"eval_data_type": "pd.DataFrame",
|
|
"eval_data_size": 3,
|
|
"eval_data_provided_fields": ["inputs", "outputs"],
|
|
}
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GenAIEvaluateEvent.name,
|
|
expected_params,
|
|
)
|
|
|
|
# Test with list of Traces
|
|
trace_ids = []
|
|
for i in range(2):
|
|
with mlflow.start_span(name=f"test_span_{i}") as span:
|
|
span.set_inputs({"question": f"Q{i}"})
|
|
span.set_outputs({"answer": f"A{i}"})
|
|
trace_ids.append(span.trace_id)
|
|
|
|
traces = [mlflow.get_trace(trace_id) for trace_id in trace_ids]
|
|
mlflow.genai.evaluate(data=traces, scorers=[sample_scorer])
|
|
expected_params = {
|
|
"predict_fn_provided": False,
|
|
"scorer_info": [
|
|
{
|
|
"class": "UserDefinedScorer",
|
|
"kind": "decorator",
|
|
"scope": "trace",
|
|
},
|
|
],
|
|
"eval_data_type": "list[Trace]",
|
|
"eval_data_size": 2,
|
|
"eval_data_provided_fields": ["inputs", "outputs", "trace"],
|
|
}
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GenAIEvaluateEvent.name,
|
|
expected_params,
|
|
)
|
|
|
|
# Test with EvaluationDataset
|
|
from mlflow.genai.datasets import create_dataset
|
|
|
|
dataset = create_dataset("test_dataset")
|
|
dataset_data = [
|
|
{
|
|
"inputs": {"question": "Q1"},
|
|
"outputs": "A1",
|
|
"expectations": {"answer": "Expected1"},
|
|
},
|
|
{
|
|
"inputs": {"question": "Q2"},
|
|
"outputs": "A2",
|
|
"expectations": {"answer": "Expected2"},
|
|
},
|
|
]
|
|
dataset.merge_records(dataset_data)
|
|
mlflow.genai.evaluate(data=dataset, scorers=[sample_scorer])
|
|
expected_params = {
|
|
"predict_fn_provided": False,
|
|
"scorer_info": [
|
|
{
|
|
"class": "UserDefinedScorer",
|
|
"kind": "decorator",
|
|
"scope": "trace",
|
|
},
|
|
],
|
|
"eval_data_type": "EvaluationDataset",
|
|
"eval_data_size": 2,
|
|
"eval_data_provided_fields": ["expectations", "inputs", "outputs"],
|
|
}
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GenAIEvaluateEvent.name,
|
|
expected_params,
|
|
)
|
|
|
|
|
|
def test_simulate_conversation(mock_requests, mock_telemetry_client: TelemetryClient):
|
|
simulator = ConversationSimulator(
|
|
test_cases=[
|
|
{"goal": "Learn about MLflow"},
|
|
{"goal": "Debug an issue"},
|
|
],
|
|
max_turns=2,
|
|
)
|
|
|
|
def mock_predict_fn(input, **kwargs):
|
|
return {"role": "assistant", "content": "Mock response"}
|
|
|
|
mock_trace = mock.Mock()
|
|
with (
|
|
mock.patch(
|
|
"mlflow.genai.simulators.simulator.invoke_model_without_tracing",
|
|
return_value="Mock user message",
|
|
),
|
|
mock.patch(
|
|
"mlflow.genai.simulators.simulator.ConversationSimulator._check_goal_achieved",
|
|
return_value=False,
|
|
),
|
|
mock.patch(
|
|
"mlflow.genai.simulators.simulator.mlflow.get_trace",
|
|
return_value=mock_trace,
|
|
),
|
|
):
|
|
result = simulator.simulate(predict_fn=mock_predict_fn)
|
|
|
|
assert len(result) == 2
|
|
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
SimulateConversationEvent.name,
|
|
{
|
|
"callsite": "conversation_simulator",
|
|
"simulated_conversation_info": [
|
|
{"turn_count": len(result[0])},
|
|
{"turn_count": len(result[1])},
|
|
],
|
|
},
|
|
)
|
|
|
|
|
|
def test_simulate_conversation_from_genai_evaluate(
|
|
mock_requests, mock_telemetry_client: TelemetryClient
|
|
):
|
|
simulator = ConversationSimulator(
|
|
test_cases=[
|
|
{"goal": "Learn about MLflow"},
|
|
],
|
|
max_turns=1,
|
|
)
|
|
|
|
def mock_predict_fn(input, **kwargs):
|
|
return {"role": "assistant", "content": "Mock response"}
|
|
|
|
@scorer
|
|
def simple_scorer(outputs) -> bool:
|
|
return len(outputs) > 0
|
|
|
|
with (
|
|
mock.patch(
|
|
"mlflow.genai.simulators.simulator.invoke_model_without_tracing",
|
|
return_value="Mock user message",
|
|
),
|
|
mock.patch(
|
|
"mlflow.genai.simulators.simulator.ConversationSimulator._check_goal_achieved",
|
|
return_value=True,
|
|
),
|
|
):
|
|
mlflow.genai.evaluate(data=simulator, predict_fn=mock_predict_fn, scorers=[simple_scorer])
|
|
|
|
mock_telemetry_client.flush()
|
|
|
|
simulate_events = [
|
|
record
|
|
for record in mock_requests
|
|
if record["data"]["event_name"] == SimulateConversationEvent.name
|
|
]
|
|
assert len(simulate_events) == 1
|
|
|
|
event_params = json.loads(simulate_events[0]["data"]["params"])
|
|
assert event_params == {
|
|
"callsite": "genai_evaluate",
|
|
"simulated_conversation_info": [{"turn_count": 1}],
|
|
}
|
|
|
|
|
|
def test_prompt_optimization(mock_requests, mock_telemetry_client: TelemetryClient):
|
|
from mlflow.genai.optimize import optimize_prompts
|
|
from mlflow.genai.optimize.optimizers import BasePromptOptimizer
|
|
from mlflow.genai.optimize.types import PromptOptimizerOutput
|
|
|
|
class MockAdapter(BasePromptOptimizer):
|
|
def __init__(self):
|
|
self.model_name = "openai:/gpt-4o-mini"
|
|
|
|
def optimize(self, eval_fn, train_data, target_prompts, enable_tracking):
|
|
return PromptOptimizerOutput(optimized_prompts=target_prompts)
|
|
|
|
sample_prompt = mlflow.genai.register_prompt(
|
|
name="test_prompt_for_adaptation",
|
|
template="Translate {{input_text}} to {{language}}",
|
|
)
|
|
|
|
sample_data = [
|
|
{"inputs": {"input_text": "Hello", "language": "Spanish"}, "outputs": "Hola"},
|
|
{"inputs": {"input_text": "World", "language": "French"}, "outputs": "Monde"},
|
|
]
|
|
|
|
@mlflow.genai.scorers.scorer
|
|
def exact_match_scorer(outputs, expectations):
|
|
return 1.0 if outputs == expectations["expected_response"] else 0.0
|
|
|
|
def predict_fn(input_text, language):
|
|
mlflow.genai.load_prompt(f"prompts:/{sample_prompt.name}/{sample_prompt.version}")
|
|
return "translated"
|
|
|
|
optimize_prompts(
|
|
predict_fn=predict_fn,
|
|
train_data=sample_data,
|
|
prompt_uris=[f"prompts:/{sample_prompt.name}/{sample_prompt.version}"],
|
|
optimizer=MockAdapter(),
|
|
scorers=[exact_match_scorer],
|
|
)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
PromptOptimizationEvent.name,
|
|
{
|
|
"optimizer_type": "MockAdapter",
|
|
"prompt_count": 1,
|
|
"scorer_count": 1,
|
|
"custom_aggregation": False,
|
|
},
|
|
)
|
|
|
|
|
|
def test_create_dataset(mock_requests, mock_telemetry_client: TelemetryClient):
|
|
with mock.patch("mlflow.tracking._tracking_service.utils._get_store") as mock_store:
|
|
mock_store_instance = mock.MagicMock()
|
|
mock_store.return_value = mock_store_instance
|
|
mock_store_instance.create_dataset.return_value = mock.MagicMock(
|
|
dataset_id="test-dataset-id", name="test_dataset", tags={"test": "value"}
|
|
)
|
|
|
|
create_dataset(name="test_dataset", tags={"test": "value"})
|
|
validate_telemetry_record(mock_telemetry_client, mock_requests, CreateDatasetEvent.name)
|
|
|
|
|
|
def test_merge_records(mock_requests, mock_telemetry_client: TelemetryClient):
|
|
with mock.patch("mlflow.tracking._tracking_service.utils._get_store") as mock_store:
|
|
mock_store_instance = mock.MagicMock()
|
|
mock_store.return_value = mock_store_instance
|
|
mock_store_instance.get_dataset.return_value = mock.MagicMock(dataset_id="test-id")
|
|
mock_store_instance.upsert_dataset_records.return_value = {
|
|
"inserted": 2,
|
|
"updated": 0,
|
|
}
|
|
|
|
evaluation_dataset = EvaluationDataset(
|
|
dataset_id="test-id",
|
|
name="test",
|
|
digest="digest",
|
|
created_time=123,
|
|
last_update_time=456,
|
|
)
|
|
|
|
records = [
|
|
{"inputs": {"q": "Q1"}, "expectations": {"a": "A1"}},
|
|
{"inputs": {"q": "Q2"}, "expectations": {"a": "A2"}},
|
|
]
|
|
evaluation_dataset.merge_records(records)
|
|
|
|
expected_params = {
|
|
"record_count": 2,
|
|
"input_type": "list[dict]",
|
|
"dataset_type": "trace",
|
|
}
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
MergeRecordsEvent.name,
|
|
expected_params,
|
|
)
|
|
|
|
|
|
def test_log_dataset(mock_requests, mock_telemetry_client: TelemetryClient):
|
|
with mlflow.start_run() as run:
|
|
dataset = mlflow.data.from_pandas(pd.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]}))
|
|
mlflow.log_input(dataset)
|
|
validate_telemetry_record(mock_telemetry_client, mock_requests, LogDatasetEvent.name)
|
|
|
|
mlflow.log_inputs(datasets=[dataset], contexts=["training"], tags_list=[None])
|
|
validate_telemetry_record(mock_telemetry_client, mock_requests, LogDatasetEvent.name)
|
|
|
|
client = MlflowClient()
|
|
client.log_inputs(run_id=run.info.run_id, datasets=[_create_dataset_input(dataset)])
|
|
validate_telemetry_record(mock_telemetry_client, mock_requests, LogDatasetEvent.name)
|
|
|
|
|
|
def test_log_metric(mock_requests, mock_telemetry_client: TelemetryClient):
|
|
with mlflow.start_run():
|
|
mlflow.log_metric("test_metric", 1.0)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
LogMetricEvent.name,
|
|
{"synchronous": True},
|
|
)
|
|
|
|
mlflow.log_metric("test_metric", 1.0, synchronous=False)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
LogMetricEvent.name,
|
|
{"synchronous": False},
|
|
)
|
|
|
|
client = MlflowClient()
|
|
client.log_metric(
|
|
run_id=mlflow.active_run().info.run_id,
|
|
key="test_metric",
|
|
value=1.0,
|
|
timestamp=int(time.time()),
|
|
step=0,
|
|
)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
LogMetricEvent.name,
|
|
{"synchronous": True},
|
|
)
|
|
|
|
client.log_metric(
|
|
run_id=mlflow.active_run().info.run_id,
|
|
key="test_metric",
|
|
value=1.0,
|
|
timestamp=int(time.time()),
|
|
step=0,
|
|
synchronous=False,
|
|
)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
LogMetricEvent.name,
|
|
{"synchronous": False},
|
|
)
|
|
|
|
|
|
def test_log_param(mock_requests, mock_telemetry_client: TelemetryClient):
|
|
with mlflow.start_run():
|
|
mlflow.log_param("test_param", "test_value")
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
LogParamEvent.name,
|
|
{"synchronous": True},
|
|
)
|
|
|
|
mlflow.log_param("test_param", "test_value", synchronous=False)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
LogParamEvent.name,
|
|
{"synchronous": False},
|
|
)
|
|
|
|
client = mlflow.MlflowClient()
|
|
client.log_param(
|
|
run_id=mlflow.active_run().info.run_id,
|
|
key="test_param",
|
|
value="test_value",
|
|
)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
LogParamEvent.name,
|
|
{"synchronous": True},
|
|
)
|
|
|
|
|
|
def test_log_batch(mock_requests, mock_telemetry_client: TelemetryClient):
|
|
with mlflow.start_run():
|
|
mlflow.log_params(params={"test_param": "test_value"})
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
LogBatchEvent.name,
|
|
{"metrics": False, "params": True, "tags": False, "synchronous": True},
|
|
)
|
|
|
|
mlflow.log_params(params={"test_param": "test_value"}, synchronous=False)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
LogBatchEvent.name,
|
|
{"metrics": False, "params": True, "tags": False, "synchronous": False},
|
|
)
|
|
|
|
mlflow.log_metrics(metrics={"test_metric": 1.0})
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
LogBatchEvent.name,
|
|
{"metrics": True, "params": False, "tags": False, "synchronous": True},
|
|
)
|
|
|
|
mlflow.log_metrics(metrics={"test_metric": 1.0}, synchronous=False)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
LogBatchEvent.name,
|
|
{"metrics": True, "params": False, "tags": False, "synchronous": False},
|
|
)
|
|
|
|
mlflow.set_tags(tags={"test_tag": "test_value"})
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
LogBatchEvent.name,
|
|
{"metrics": False, "params": False, "tags": True, "synchronous": True},
|
|
)
|
|
|
|
mlflow.set_tags(tags={"test_tag": "test_value"}, synchronous=False)
|
|
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
LogBatchEvent.name,
|
|
{"metrics": False, "params": False, "tags": True, "synchronous": False},
|
|
)
|
|
|
|
client = mlflow.MlflowClient()
|
|
client.log_batch(
|
|
run_id=mlflow.active_run().info.run_id,
|
|
metrics=[Metric(key="test_metric", value=1.0, timestamp=int(time.time()), step=0)],
|
|
params=[Param(key="test_param", value="test_value")],
|
|
tags=[RunTag(key="test_tag", value="test_value")],
|
|
)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
LogBatchEvent.name,
|
|
{"metrics": True, "params": True, "tags": True, "synchronous": True},
|
|
)
|
|
|
|
|
|
def test_get_logged_model(mock_requests, mock_telemetry_client: TelemetryClient, tmp_path):
|
|
model_info = mlflow.sklearn.log_model(
|
|
knn.KNeighborsClassifier(),
|
|
name="model",
|
|
)
|
|
mock_telemetry_client.flush()
|
|
|
|
mlflow.sklearn.load_model(model_info.model_uri)
|
|
data = validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GetLoggedModelEvent.name,
|
|
check_params=False,
|
|
)
|
|
assert "sklearn" in json.loads(data["params"])["imports"]
|
|
|
|
mlflow.pyfunc.load_model(model_info.model_uri)
|
|
data = validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GetLoggedModelEvent.name,
|
|
check_params=False,
|
|
)
|
|
|
|
model_def = """
|
|
import mlflow
|
|
from mlflow.models import set_model
|
|
|
|
class TestModel(mlflow.pyfunc.PythonModel):
|
|
def predict(self, context, model_input: list[str], params=None) -> list[str]:
|
|
return model_input
|
|
|
|
set_model(TestModel())
|
|
"""
|
|
model_path = tmp_path / "model.py"
|
|
model_path.write_text(model_def)
|
|
model_info = mlflow.pyfunc.log_model(
|
|
name="model",
|
|
python_model=model_path,
|
|
)
|
|
mock_telemetry_client.flush()
|
|
|
|
mlflow.pyfunc.load_model(model_info.model_uri)
|
|
data = validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GetLoggedModelEvent.name,
|
|
check_params=False,
|
|
)
|
|
|
|
# test load model after registry
|
|
mlflow.register_model(model_info.model_uri, name="test")
|
|
mock_telemetry_client.flush()
|
|
|
|
mlflow.pyfunc.load_model("models:/test/1")
|
|
data = validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GetLoggedModelEvent.name,
|
|
check_params=False,
|
|
)
|
|
|
|
|
|
def test_mcp_run(mock_requests, mock_telemetry_client: TelemetryClient):
|
|
from mlflow.mcp.cli import run
|
|
|
|
runner = CliRunner(catch_exceptions=False)
|
|
with mock.patch("mlflow.mcp.cli.run_server") as mock_run_server:
|
|
runner.invoke(run)
|
|
|
|
mock_run_server.assert_called_once()
|
|
mock_telemetry_client.flush()
|
|
validate_telemetry_record(mock_telemetry_client, mock_requests, McpRunEvent.name)
|
|
|
|
|
|
@pytest.mark.skipif(is_windows(), reason="Windows does not support gateway start")
|
|
def test_gateway_start(tmp_path, mock_requests, mock_telemetry_client: TelemetryClient):
|
|
config = tmp_path.joinpath("config.yml")
|
|
config.write_text(
|
|
"""
|
|
endpoints:
|
|
- name: test-endpoint
|
|
endpoint_type: llm/v1/completions
|
|
model:
|
|
provider: openai
|
|
name: gpt-3.5-turbo
|
|
config:
|
|
openai_api_key: test-key
|
|
"""
|
|
)
|
|
|
|
def assert_event_recorded_before_run_app(**kwargs):
|
|
mock_telemetry_client.flush()
|
|
validate_telemetry_record(mock_telemetry_client, mock_requests, GatewayStartEvent.name)
|
|
|
|
runner = CliRunner(catch_exceptions=False)
|
|
with mock.patch("mlflow.gateway.cli.run_app", side_effect=assert_event_recorded_before_run_app):
|
|
runner.invoke(start, ["--config-path", str(config)])
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("cli_args", "expected_params"),
|
|
[
|
|
(
|
|
["--backend-store-uri", "sqlite:///test.db"],
|
|
{
|
|
"auth_enabled": False,
|
|
"app_name": None,
|
|
"backend_store_type": "sqlite",
|
|
"serve_artifacts": True,
|
|
"artifacts_only": False,
|
|
"expose_prometheus": False,
|
|
"enable_workspaces": False,
|
|
"workers": None,
|
|
"dev": False,
|
|
},
|
|
),
|
|
(
|
|
["--backend-store-uri", "sqlite:///test.db", "--app-name", "basic-auth"],
|
|
{
|
|
"auth_enabled": True,
|
|
"app_name": "basic-auth",
|
|
"backend_store_type": "sqlite",
|
|
"serve_artifacts": True,
|
|
"artifacts_only": False,
|
|
"expose_prometheus": False,
|
|
"enable_workspaces": False,
|
|
"workers": None,
|
|
"dev": False,
|
|
},
|
|
),
|
|
(
|
|
[
|
|
"--backend-store-uri",
|
|
"sqlite:///test.db",
|
|
"--no-serve-artifacts",
|
|
"--expose-prometheus",
|
|
"/tmp/metrics",
|
|
"--enable-workspaces",
|
|
],
|
|
{
|
|
"auth_enabled": False,
|
|
"app_name": None,
|
|
"backend_store_type": "sqlite",
|
|
"serve_artifacts": False,
|
|
"artifacts_only": False,
|
|
"expose_prometheus": True,
|
|
"enable_workspaces": True,
|
|
"workers": None,
|
|
"dev": False,
|
|
},
|
|
),
|
|
],
|
|
)
|
|
def test_tracking_server_start(
|
|
tmp_path,
|
|
mock_requests,
|
|
mock_telemetry_client: TelemetryClient,
|
|
monkeypatch,
|
|
cli_args,
|
|
expected_params,
|
|
):
|
|
|
|
from mlflow.cli import server
|
|
|
|
# Isolate env vars that server() mutates so they don't leak into other tests
|
|
for key in (
|
|
"MLFLOW_ENABLE_WORKSPACES",
|
|
"MLFLOW_TRACE_ARCHIVAL_CONFIG",
|
|
"MLFLOW_WORKSPACE_STORE_URI",
|
|
"MLFLOW_SERVER_DISABLE_SECURITY_MIDDLEWARE",
|
|
"MLFLOW_SERVER_ALLOWED_HOSTS",
|
|
"MLFLOW_SERVER_CORS_ALLOWED_ORIGINS",
|
|
"MLFLOW_SERVER_X_FRAME_OPTIONS",
|
|
):
|
|
monkeypatch.delenv(key, raising=False)
|
|
|
|
def assert_event_recorded_before_run_server(**kwargs):
|
|
mock_telemetry_client.flush()
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
TrackingServerStartEvent.name,
|
|
expected_params,
|
|
)
|
|
|
|
runner = CliRunner(catch_exceptions=False)
|
|
with (
|
|
mock.patch(
|
|
"mlflow.server._run_server", side_effect=assert_event_recorded_before_run_server
|
|
),
|
|
mock.patch("mlflow.server.handlers.initialize_backend_stores"),
|
|
):
|
|
runner.invoke(server, cli_args)
|
|
|
|
|
|
def test_ai_command_run(mock_requests, mock_telemetry_client: TelemetryClient):
|
|
from mlflow.ai_commands import commands
|
|
|
|
runner = CliRunner(catch_exceptions=False)
|
|
# Test CLI context
|
|
with mock.patch("mlflow.ai_commands.get_command", return_value="---\ntest\n---\nTest command"):
|
|
result = runner.invoke(commands, ["run", "test_command"])
|
|
assert result.exit_code == 0
|
|
|
|
mock_telemetry_client.flush()
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
AiCommandRunEvent.name,
|
|
{"command_key": "test_command", "context": "cli"},
|
|
)
|
|
|
|
|
|
def test_git_model_versioning(mock_requests, mock_telemetry_client):
|
|
from mlflow.genai import enable_git_model_versioning
|
|
|
|
with enable_git_model_versioning():
|
|
pass
|
|
|
|
mock_telemetry_client.flush()
|
|
validate_telemetry_record(mock_telemetry_client, mock_requests, GitModelVersioningEvent.name)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("model_uri", "expected_provider"),
|
|
[
|
|
("databricks:/llama-3.1-70b", "databricks"),
|
|
("openai:/gpt-4o-mini", "openai"),
|
|
("endpoints:/my-endpoint", "endpoints"),
|
|
("anthropic:/claude-3-opus", "anthropic"),
|
|
],
|
|
)
|
|
def test_invoke_custom_judge_model(
|
|
mock_requests,
|
|
mock_telemetry_client: TelemetryClient,
|
|
model_uri,
|
|
expected_provider,
|
|
):
|
|
from mlflow.genai.judges.adapters.gateway_adapter import InvokeOutput
|
|
from mlflow.genai.judges.utils import invoke_judge_model
|
|
|
|
mock_response = json.dumps({"result": 0.8, "rationale": "Test rationale"})
|
|
|
|
mock_target = (
|
|
"mlflow.genai.judges.adapters.gateway_adapter._invoke_via_gateway"
|
|
if expected_provider == "endpoints"
|
|
else "mlflow.genai.judges.adapters.gateway_adapter.GatewayAdapter._invoke_and_handle_tools"
|
|
)
|
|
mock_return = (
|
|
mock_response
|
|
if expected_provider == "endpoints"
|
|
else InvokeOutput(
|
|
response=mock_response,
|
|
request_id=None,
|
|
num_prompt_tokens=None,
|
|
num_completion_tokens=None,
|
|
)
|
|
)
|
|
with mock.patch(mock_target, return_value=mock_return):
|
|
invoke_judge_model(
|
|
model_uri=model_uri,
|
|
prompt="Test prompt",
|
|
assessment_name="test_assessment",
|
|
)
|
|
|
|
expected_params = {"model_provider": expected_provider}
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
InvokeCustomJudgeModelEvent.name,
|
|
expected_params,
|
|
)
|
|
|
|
|
|
def test_make_judge(mock_requests, mock_telemetry_client: TelemetryClient):
|
|
make_judge(
|
|
name="test_judge",
|
|
instructions="Evaluate the {{ inputs }} and {{ outputs }}",
|
|
model="openai:/gpt-4",
|
|
feedback_value_type=str,
|
|
)
|
|
expected_params = {"model_provider": "openai"}
|
|
validate_telemetry_record(
|
|
mock_telemetry_client, mock_requests, MakeJudgeEvent.name, expected_params
|
|
)
|
|
|
|
make_judge(
|
|
name="test_judge",
|
|
instructions="Evaluate the {{ inputs }} and {{ outputs }}",
|
|
feedback_value_type=str,
|
|
)
|
|
expected_params = {"model_provider": None}
|
|
validate_telemetry_record(
|
|
mock_telemetry_client, mock_requests, MakeJudgeEvent.name, expected_params
|
|
)
|
|
|
|
|
|
def test_align_judge(mock_requests, mock_telemetry_client: TelemetryClient):
|
|
judge = make_judge(
|
|
name="test_judge",
|
|
instructions="Evaluate the {{ inputs }} and {{ outputs }}",
|
|
model="openai:/gpt-4",
|
|
feedback_value_type=str,
|
|
)
|
|
|
|
traces = [
|
|
mock.MagicMock(spec=Trace),
|
|
mock.MagicMock(spec=Trace),
|
|
]
|
|
|
|
class MockOptimizer(AlignmentOptimizer):
|
|
def align(self, judge, traces):
|
|
return judge
|
|
|
|
custom_optimizer = MockOptimizer()
|
|
judge.align(traces, optimizer=custom_optimizer)
|
|
|
|
expected_params = {"trace_count": 2, "optimizer_type": "MockOptimizer"}
|
|
validate_telemetry_record(
|
|
mock_telemetry_client, mock_requests, AlignJudgeEvent.name, expected_params
|
|
)
|
|
|
|
|
|
def test_discover_issues(mock_requests, mock_telemetry_client: TelemetryClient):
|
|
traces = [
|
|
mock.MagicMock(spec=Trace),
|
|
mock.MagicMock(spec=Trace),
|
|
mock.MagicMock(spec=Trace),
|
|
]
|
|
|
|
mock_triage_run_id = "abc123"
|
|
mock_eval_result = mock.MagicMock()
|
|
mock_eval_result.run_id = mock_triage_run_id
|
|
|
|
with (
|
|
patch("mlflow.genai.discovery.pipeline.get_session_id", return_value=None),
|
|
patch("mlflow.genai.discovery.pipeline.verify_scorer"),
|
|
patch(
|
|
"mlflow.genai.discovery.pipeline.mlflow.genai.evaluate",
|
|
return_value=mock_eval_result,
|
|
),
|
|
patch(
|
|
"mlflow.genai.discovery.pipeline.extract_failing_traces",
|
|
return_value=_TriageResult([], {}, {}),
|
|
),
|
|
patch("mlflow.genai.discovery.pipeline.mlflow.MlflowClient"),
|
|
patch("mlflow.genai.discovery.pipeline.mlflow.set_experiment"),
|
|
):
|
|
discover_issues(
|
|
traces=traces,
|
|
model="openai:/gpt-4",
|
|
categories=["hallucination", "accuracy"],
|
|
)
|
|
|
|
expected_params = {
|
|
"model": "openai:/gpt-4",
|
|
"trace_count": 3,
|
|
"categories": ["hallucination", "accuracy"],
|
|
"source_run_id": None,
|
|
"issue_count": 0,
|
|
"total_traces_analyzed": 3,
|
|
"total_cost_usd": None,
|
|
"triage_run_id": mock_triage_run_id,
|
|
}
|
|
validate_telemetry_record(
|
|
mock_telemetry_client, mock_requests, DiscoverIssuesEvent.name, expected_params
|
|
)
|
|
|
|
|
|
def test_autologging(mock_requests, mock_telemetry_client: TelemetryClient):
|
|
try:
|
|
mlflow.openai.autolog()
|
|
|
|
mlflow.autolog()
|
|
mock_telemetry_client.flush()
|
|
data = [record["data"] for record in mock_requests]
|
|
params = [event["params"] for event in data if event["event_name"] == AutologgingEvent.name]
|
|
assert (
|
|
json.dumps({
|
|
"flavor": mlflow.openai.FLAVOR_NAME,
|
|
"log_traces": True,
|
|
"disable": False,
|
|
})
|
|
in params
|
|
)
|
|
assert json.dumps({"flavor": "all", "log_traces": True, "disable": False}) in params
|
|
finally:
|
|
mlflow.autolog(disable=True)
|
|
|
|
|
|
def test_load_prompt(mock_requests, mock_telemetry_client: TelemetryClient):
|
|
# Register a prompt first
|
|
prompt = mlflow.genai.register_prompt(
|
|
name="test_prompt",
|
|
template="Hello {{name}}",
|
|
)
|
|
mock_telemetry_client.flush()
|
|
|
|
# Set an alias for testing
|
|
mlflow.genai.set_prompt_alias(name="test_prompt", version=prompt.version, alias="production")
|
|
|
|
# Test load_prompt with version (no alias)
|
|
mlflow.genai.load_prompt(name_or_uri="test_prompt", version=prompt.version)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
LoadPromptEvent.name,
|
|
{"uses_alias": False},
|
|
)
|
|
|
|
# Test load_prompt with URI and version (no alias)
|
|
mlflow.genai.load_prompt(name_or_uri=f"prompts:/test_prompt/{prompt.version}")
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
LoadPromptEvent.name,
|
|
{"uses_alias": False},
|
|
)
|
|
|
|
# Test load_prompt with alias
|
|
mlflow.genai.load_prompt(name_or_uri="prompts:/test_prompt@production")
|
|
validate_telemetry_record(
|
|
mock_telemetry_client, mock_requests, LoadPromptEvent.name, {"uses_alias": True}
|
|
)
|
|
|
|
# Test load_prompt with @latest (special alias)
|
|
mlflow.genai.load_prompt(name_or_uri="prompts:/test_prompt@latest")
|
|
validate_telemetry_record(
|
|
mock_telemetry_client, mock_requests, LoadPromptEvent.name, {"uses_alias": True}
|
|
)
|
|
|
|
|
|
def test_scorer_call_direct(mock_requests, mock_telemetry_client: TelemetryClient):
|
|
@scorer
|
|
def custom_scorer(outputs) -> bool:
|
|
return len(outputs) > 0
|
|
|
|
result = custom_scorer(outputs="test output")
|
|
assert result is True
|
|
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
ScorerCallEvent.name,
|
|
{
|
|
"scorer_class": "UserDefinedScorer",
|
|
"scorer_kind": "decorator",
|
|
"scope": "trace",
|
|
"callsite": "direct_scorer_call",
|
|
"has_feedback_error": False,
|
|
},
|
|
)
|
|
|
|
safety_scorer = Safety()
|
|
|
|
mock_feedback = Feedback(
|
|
name="test_feedback",
|
|
value="yes",
|
|
rationale="Test rationale",
|
|
)
|
|
|
|
with mock.patch(
|
|
"mlflow.genai.judges.builtin.invoke_judge_model",
|
|
return_value=mock_feedback,
|
|
):
|
|
safety_scorer(outputs="test output")
|
|
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
ScorerCallEvent.name,
|
|
{
|
|
"scorer_class": "Safety",
|
|
"scorer_kind": "builtin",
|
|
"scope": "trace",
|
|
"callsite": "direct_scorer_call",
|
|
"has_feedback_error": False,
|
|
},
|
|
)
|
|
|
|
mock_requests.clear()
|
|
|
|
guidelines_scorer = Guidelines(guidelines="The response must be in English")
|
|
with mock.patch(
|
|
"mlflow.genai.judges.builtin.invoke_judge_model",
|
|
return_value=mock_feedback,
|
|
):
|
|
guidelines_scorer(
|
|
inputs={"question": "What is MLflow?"}, outputs="MLflow is an ML platform"
|
|
)
|
|
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
ScorerCallEvent.name,
|
|
{
|
|
"scorer_class": "Guidelines",
|
|
"scorer_kind": "guidelines",
|
|
"scope": "trace",
|
|
"callsite": "direct_scorer_call",
|
|
"has_feedback_error": False,
|
|
},
|
|
)
|
|
|
|
mock_requests.clear()
|
|
|
|
class CustomClassScorer(Scorer):
|
|
name: str = "custom_class"
|
|
|
|
def __call__(self, *, outputs) -> bool:
|
|
return len(outputs) > 0
|
|
|
|
custom_class_scorer = CustomClassScorer()
|
|
result = custom_class_scorer(outputs="test output")
|
|
assert result is True
|
|
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
ScorerCallEvent.name,
|
|
{
|
|
"scorer_class": "UserDefinedScorer",
|
|
"scorer_kind": "class",
|
|
"scope": "trace",
|
|
"callsite": "direct_scorer_call",
|
|
"has_feedback_error": False,
|
|
},
|
|
)
|
|
|
|
|
|
def test_scorer_call_from_genai_evaluate(mock_requests, mock_telemetry_client: TelemetryClient):
|
|
@scorer
|
|
def simple_length_checker(outputs) -> bool:
|
|
return len(outputs) > 0
|
|
|
|
session_judge = make_judge(
|
|
name="conversation_quality",
|
|
instructions="Evaluate if the {{ conversation }} is engaging and coherent",
|
|
model="openai:/gpt-4",
|
|
)
|
|
|
|
# Create traces with session metadata for session-level scorer testing
|
|
@mlflow.trace(span_type=mlflow.entities.SpanType.CHAT_MODEL)
|
|
def model(question, session_id):
|
|
mlflow.update_current_trace(metadata={"mlflow.trace.session": session_id})
|
|
return f"Answer to: {question}"
|
|
|
|
model("What is MLflow?", session_id="test_session")
|
|
trace_1 = mlflow.get_trace(mlflow.get_last_active_trace_id())
|
|
|
|
model("How does MLflow work?", session_id="test_session")
|
|
trace_2 = mlflow.get_trace(mlflow.get_last_active_trace_id())
|
|
|
|
test_data = pd.DataFrame([
|
|
{
|
|
"trace": trace_1,
|
|
},
|
|
{
|
|
"trace": trace_2,
|
|
},
|
|
])
|
|
|
|
mock_feedback = Feedback(
|
|
name="test_feedback",
|
|
value="yes",
|
|
rationale="Test",
|
|
)
|
|
|
|
with mock.patch(
|
|
"mlflow.genai.judges.instructions_judge.invoke_judge_model",
|
|
return_value=mock_feedback,
|
|
):
|
|
mlflow.genai.evaluate(data=test_data, scorers=[simple_length_checker, session_judge])
|
|
|
|
mock_telemetry_client.flush()
|
|
|
|
scorer_call_events = [
|
|
record for record in mock_requests if record["data"]["event_name"] == ScorerCallEvent.name
|
|
]
|
|
|
|
# Should have 3 events: 2 response-level calls (one per trace)
|
|
# + 1 session-level call (one per session)
|
|
assert len(scorer_call_events) == 3
|
|
|
|
event_params = [json.loads(event["data"]["params"]) for event in scorer_call_events]
|
|
|
|
# Validate response-level scorer was called twice (once per trace)
|
|
response_level_events = [
|
|
params
|
|
for params in event_params
|
|
if params["scorer_class"] == "UserDefinedScorer"
|
|
and params["scorer_kind"] == "decorator"
|
|
and params["scope"] == "trace"
|
|
and params["callsite"] == "genai_evaluate"
|
|
and params["has_feedback_error"] is False
|
|
]
|
|
assert len(response_level_events) == 2
|
|
|
|
# Validate session-level scorer was called once (once per session)
|
|
session_level_events = [
|
|
params
|
|
for params in event_params
|
|
if params["scorer_class"] == "UserDefinedScorer"
|
|
and params["scorer_kind"] == "instructions"
|
|
and params["scope"] == "session"
|
|
and params["callsite"] == "genai_evaluate"
|
|
and params["has_feedback_error"] is False
|
|
]
|
|
assert len(session_level_events) == 1
|
|
|
|
mock_requests.clear()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("job_name", "expected_callsite"),
|
|
[
|
|
("run_online_trace_scorer", "online_scoring"),
|
|
("run_online_session_scorer", "online_scoring"),
|
|
# Counterexample: non-online-scoring job should be treated as direct call
|
|
("invoke_scorer", "direct_scorer_call"),
|
|
],
|
|
)
|
|
def test_scorer_call_online_scoring_callsite(
|
|
mock_requests, mock_telemetry_client: TelemetryClient, monkeypatch, job_name, expected_callsite
|
|
):
|
|
# Import here to avoid circular imports
|
|
from mlflow.server.jobs.utils import MLFLOW_SERVER_JOB_NAME_ENV_VAR
|
|
|
|
monkeypatch.setenv(MLFLOW_SERVER_JOB_NAME_ENV_VAR, job_name)
|
|
|
|
@scorer
|
|
def custom_scorer(outputs: str) -> bool:
|
|
return True
|
|
|
|
custom_scorer(outputs="test output")
|
|
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
ScorerCallEvent.name,
|
|
{
|
|
"scorer_class": "UserDefinedScorer",
|
|
"scorer_kind": "decorator",
|
|
"scope": "trace",
|
|
"callsite": expected_callsite,
|
|
"has_feedback_error": False,
|
|
},
|
|
)
|
|
|
|
|
|
def test_scorer_call_tracks_feedback_errors(mock_requests, mock_telemetry_client: TelemetryClient):
|
|
error_judge = make_judge(
|
|
name="quality_judge",
|
|
instructions="Evaluate if {{ outputs }} is high quality",
|
|
model="openai:/gpt-4",
|
|
)
|
|
|
|
error_feedback = Feedback(
|
|
name="quality_judge",
|
|
error="Model invocation failed",
|
|
source=AssessmentSource(
|
|
source_type=AssessmentSourceType.LLM_JUDGE, source_id="openai:/gpt-4"
|
|
),
|
|
)
|
|
with mock.patch(
|
|
"mlflow.genai.judges.instructions_judge.invoke_judge_model",
|
|
return_value=error_feedback,
|
|
):
|
|
result = error_judge(outputs="test output")
|
|
assert result.error is not None
|
|
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
ScorerCallEvent.name,
|
|
{
|
|
"scorer_class": "UserDefinedScorer",
|
|
"scorer_kind": "instructions",
|
|
"scope": "trace",
|
|
"callsite": "direct_scorer_call",
|
|
"has_feedback_error": True,
|
|
},
|
|
)
|
|
|
|
mock_requests.clear()
|
|
|
|
# Test Scorer returns list of Feedback with mixed errors
|
|
@scorer
|
|
def multi_feedback_scorer(outputs) -> list[Feedback]:
|
|
return [
|
|
Feedback(name="feedback1", value=1.0),
|
|
Feedback(name="feedback2", error=ValueError("Error in feedback 2")),
|
|
Feedback(name="feedback3", value=0.5),
|
|
]
|
|
|
|
multi_feedback_scorer(outputs="test")
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
ScorerCallEvent.name,
|
|
{
|
|
"scorer_class": "UserDefinedScorer",
|
|
"scorer_kind": "decorator",
|
|
"scope": "trace",
|
|
"callsite": "direct_scorer_call",
|
|
"has_feedback_error": True,
|
|
},
|
|
)
|
|
|
|
mock_requests.clear()
|
|
|
|
# Test Scorer returns primitive type (no Feedback error possible)
|
|
@scorer
|
|
def primitive_scorer(outputs) -> bool:
|
|
return True
|
|
|
|
primitive_scorer(outputs="test")
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
ScorerCallEvent.name,
|
|
{
|
|
"scorer_class": "UserDefinedScorer",
|
|
"scorer_kind": "decorator",
|
|
"scope": "trace",
|
|
"callsite": "direct_scorer_call",
|
|
"has_feedback_error": False,
|
|
},
|
|
)
|
|
|
|
|
|
def test_scorer_call_wrapped_builtin_scorer_direct(
|
|
mock_requests, mock_telemetry_client: TelemetryClient
|
|
):
|
|
completeness_scorer = Completeness()
|
|
|
|
mock_feedback = Feedback(
|
|
name="completeness",
|
|
value="yes",
|
|
rationale="Test rationale",
|
|
)
|
|
|
|
with mock.patch(
|
|
"mlflow.genai.judges.instructions_judge.invoke_judge_model",
|
|
return_value=mock_feedback,
|
|
):
|
|
completeness_scorer(inputs={"question": "What is MLflow?"}, outputs="MLflow is a platform")
|
|
|
|
mock_telemetry_client.flush()
|
|
|
|
# Verify exactly 1 scorer_call event was created
|
|
# (only top-level Completeness, not nested InstructionsJudge)
|
|
scorer_call_events = [
|
|
record for record in mock_requests if record["data"]["event_name"] == ScorerCallEvent.name
|
|
]
|
|
assert len(scorer_call_events) == 1, (
|
|
f"Expected 1 scorer call event for Completeness scorer (nested calls should be skipped), "
|
|
f"got {len(scorer_call_events)}"
|
|
)
|
|
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
ScorerCallEvent.name,
|
|
{
|
|
"scorer_class": "Completeness",
|
|
"scorer_kind": "builtin",
|
|
"scope": "trace",
|
|
"callsite": "direct_scorer_call",
|
|
"has_feedback_error": False,
|
|
},
|
|
)
|
|
|
|
|
|
def test_scorer_call_wrapped_builtin_scorer_from_genai_evaluate(
|
|
mock_requests, mock_telemetry_client: TelemetryClient
|
|
):
|
|
user_frustration_scorer = UserFrustration()
|
|
|
|
@mlflow.trace(span_type=mlflow.entities.SpanType.CHAT_MODEL)
|
|
def model(question, session_id):
|
|
mlflow.update_current_trace(metadata={"mlflow.trace.session": session_id})
|
|
return f"Answer to: {question}"
|
|
|
|
model("What is MLflow?", session_id="test_session")
|
|
trace_1 = mlflow.get_trace(mlflow.get_last_active_trace_id())
|
|
|
|
model("How does MLflow work?", session_id="test_session")
|
|
trace_2 = mlflow.get_trace(mlflow.get_last_active_trace_id())
|
|
|
|
test_data = pd.DataFrame([
|
|
{"trace": trace_1},
|
|
{"trace": trace_2},
|
|
])
|
|
|
|
mock_feedback = Feedback(
|
|
name="user_frustration",
|
|
value="no",
|
|
rationale="Test rationale",
|
|
)
|
|
|
|
with mock.patch(
|
|
"mlflow.genai.judges.instructions_judge.invoke_judge_model",
|
|
return_value=mock_feedback,
|
|
):
|
|
mlflow.genai.evaluate(data=test_data, scorers=[user_frustration_scorer])
|
|
|
|
mock_telemetry_client.flush()
|
|
|
|
# Verify exactly 1 scorer_call event was created for the session-level scorer
|
|
# (one call at the session level and no nested InstructionsJudge event)
|
|
scorer_call_events = [
|
|
record for record in mock_requests if record["data"]["event_name"] == ScorerCallEvent.name
|
|
]
|
|
assert len(scorer_call_events) == 1, (
|
|
f"Expected 1 scorer call event for UserFrustration scorer "
|
|
f"(nested calls should be skipped), got {len(scorer_call_events)}"
|
|
)
|
|
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
ScorerCallEvent.name,
|
|
{
|
|
"scorer_class": "UserFrustration",
|
|
"scorer_kind": "builtin",
|
|
"scope": "session",
|
|
"callsite": "genai_evaluate",
|
|
"has_feedback_error": False,
|
|
},
|
|
)
|
|
|
|
|
|
def test_gateway_crud_telemetry(mock_requests, mock_telemetry_client: TelemetryClient, tmp_path):
|
|
db_path = tmp_path / "mlflow.db"
|
|
store = SqlAlchemyStore(f"sqlite:///{db_path}", tmp_path.as_posix())
|
|
|
|
secret = store.create_gateway_secret(
|
|
secret_name="test-secret",
|
|
secret_value={"api_key": "test-api-key"},
|
|
provider="openai",
|
|
created_by="test-user",
|
|
)
|
|
|
|
model_def = store.create_gateway_model_definition(
|
|
name="test-model",
|
|
provider="openai",
|
|
model_name="gpt-4",
|
|
secret_id=secret.secret_id,
|
|
created_by="test-user",
|
|
)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GatewayCreateModelDefinitionEvent.name,
|
|
{"model_name": "gpt-4", "provider": "openai"},
|
|
)
|
|
|
|
model_config = GatewayEndpointModelConfig(
|
|
model_definition_id=model_def.model_definition_id,
|
|
linkage_type=GatewayModelLinkageType.PRIMARY,
|
|
weight=100,
|
|
)
|
|
endpoint = store.create_gateway_endpoint(
|
|
name="test-endpoint",
|
|
model_configs=[model_config],
|
|
created_by="test-user",
|
|
)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GatewayCreateEndpointEvent.name,
|
|
{
|
|
"has_fallback_config": False,
|
|
"routing_strategy": None,
|
|
"num_model_configs": 1,
|
|
"usage_tracking": True,
|
|
},
|
|
)
|
|
|
|
store.get_gateway_endpoint(endpoint_id=endpoint.endpoint_id)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GatewayGetEndpointEvent.name,
|
|
)
|
|
|
|
store.list_gateway_endpoints()
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GatewayListEndpointsEvent.name,
|
|
{"filter_by_provider": False},
|
|
)
|
|
|
|
store.list_gateway_endpoints(provider="openai")
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GatewayListEndpointsEvent.name,
|
|
{"filter_by_provider": True},
|
|
)
|
|
|
|
store.update_gateway_endpoint(
|
|
endpoint_id=endpoint.endpoint_id,
|
|
name="updated-endpoint",
|
|
)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GatewayUpdateEndpointEvent.name,
|
|
{
|
|
"has_fallback_config": False,
|
|
"routing_strategy": None,
|
|
"num_model_configs": None,
|
|
"usage_tracking": None,
|
|
},
|
|
)
|
|
|
|
store.delete_gateway_endpoint(endpoint_id=endpoint.endpoint_id)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GatewayDeleteEndpointEvent.name,
|
|
)
|
|
|
|
|
|
def test_gateway_secret_crud_telemetry(
|
|
mock_requests, mock_telemetry_client: TelemetryClient, tmp_path
|
|
):
|
|
db_path = tmp_path / "mlflow.db"
|
|
store = SqlAlchemyStore(f"sqlite:///{db_path}", tmp_path.as_posix())
|
|
|
|
secret = store.create_gateway_secret(
|
|
secret_name="test-secret",
|
|
secret_value={"api_key": "test-api-key"},
|
|
provider="openai",
|
|
created_by="test-user",
|
|
)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GatewayCreateSecretEvent.name,
|
|
{"provider": "openai"},
|
|
)
|
|
|
|
secret2 = store.create_gateway_secret(
|
|
secret_name="test-secret-2",
|
|
secret_value={"api_key": "test-api-key-2"},
|
|
created_by="test-user",
|
|
)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GatewayCreateSecretEvent.name,
|
|
{"provider": None},
|
|
)
|
|
|
|
store.list_secret_infos()
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GatewayListSecretsEvent.name,
|
|
{"filter_by_provider": False},
|
|
)
|
|
|
|
store.list_secret_infos(provider="openai")
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GatewayListSecretsEvent.name,
|
|
{"filter_by_provider": True},
|
|
)
|
|
|
|
store.update_gateway_secret(
|
|
secret_id=secret.secret_id,
|
|
secret_value={"api_key": "updated-api-key"},
|
|
updated_by="test-user",
|
|
)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GatewayUpdateSecretEvent.name,
|
|
)
|
|
|
|
store.delete_gateway_secret(secret_id=secret.secret_id)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GatewayDeleteSecretEvent.name,
|
|
)
|
|
|
|
store.delete_gateway_secret(secret_id=secret2.secret_id)
|
|
|
|
|
|
def test_gateway_budget_policy_crud_telemetry(
|
|
mock_requests, mock_telemetry_client: TelemetryClient, tmp_path
|
|
):
|
|
db_path = tmp_path / "mlflow.db"
|
|
store = SqlAlchemyStore(f"sqlite:///{db_path}", tmp_path.as_posix())
|
|
|
|
policy = store.create_budget_policy(
|
|
budget_unit=BudgetUnit.USD,
|
|
budget_amount=100.0,
|
|
duration=BudgetDuration(unit=BudgetDurationUnit.DAYS, value=30),
|
|
target_scope=BudgetTargetScope.GLOBAL,
|
|
budget_action=BudgetAction.ALERT,
|
|
created_by="test-user",
|
|
)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GatewayCreateBudgetPolicyEvent.name,
|
|
{
|
|
"budget_unit": "USD",
|
|
"duration_unit": "DAYS",
|
|
"target_scope": "GLOBAL",
|
|
"budget_action": "ALERT",
|
|
},
|
|
)
|
|
|
|
store.list_budget_policies()
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GatewayListBudgetPoliciesEvent.name,
|
|
)
|
|
|
|
store.update_budget_policy(
|
|
budget_policy_id=policy.budget_policy_id,
|
|
budget_amount=200.0,
|
|
)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GatewayUpdateBudgetPolicyEvent.name,
|
|
)
|
|
|
|
store.delete_budget_policy(budget_policy_id=policy.budget_policy_id)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GatewayDeleteBudgetPolicyEvent.name,
|
|
)
|
|
|
|
|
|
def test_gateway_guardrail_crud_telemetry(
|
|
mock_requests, mock_telemetry_client: TelemetryClient, tmp_path
|
|
):
|
|
db_path = tmp_path / "mlflow.db"
|
|
store = SqlAlchemyStore(f"sqlite:///{db_path}", tmp_path.as_posix())
|
|
|
|
secret = store.create_gateway_secret(
|
|
secret_name="test-secret",
|
|
secret_value={"api_key": "test-api-key"},
|
|
provider="openai",
|
|
created_by="test-user",
|
|
)
|
|
model_def = store.create_gateway_model_definition(
|
|
name="test-model",
|
|
provider="openai",
|
|
model_name="gpt-4",
|
|
secret_id=secret.secret_id,
|
|
created_by="test-user",
|
|
)
|
|
endpoint = store.create_gateway_endpoint(
|
|
name="test-endpoint",
|
|
model_configs=[
|
|
GatewayEndpointModelConfig(
|
|
model_definition_id=model_def.model_definition_id,
|
|
linkage_type=GatewayModelLinkageType.PRIMARY,
|
|
weight=100,
|
|
)
|
|
],
|
|
created_by="test-user",
|
|
usage_tracking=False,
|
|
)
|
|
scorer_experiment_id = store.create_experiment("guardrail-scorer-exp")
|
|
serialized_scorer = json.dumps({
|
|
"instructions_judge_pydantic_data": {
|
|
"model": "gateway:/test-endpoint",
|
|
"instructions": "Is this input safe?",
|
|
}
|
|
})
|
|
scorer = store.register_scorer(
|
|
experiment_id=scorer_experiment_id,
|
|
name="safety-judge",
|
|
serialized_scorer=serialized_scorer,
|
|
)
|
|
guardrail = store.create_gateway_guardrail(
|
|
name="guardrail-1",
|
|
scorer_id=scorer.scorer_id,
|
|
scorer_version=scorer.scorer_version,
|
|
stage=GuardrailStage.BEFORE,
|
|
action=GuardrailAction.VALIDATION,
|
|
created_by="test-user",
|
|
)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GatewayCreateGuardrailEvent.name,
|
|
{
|
|
"stage": "BEFORE",
|
|
"action": "VALIDATION",
|
|
},
|
|
)
|
|
|
|
# Guardrail update telemetry is emitted by endpoint guardrail config updates.
|
|
store.add_guardrail_to_endpoint(endpoint.endpoint_id, guardrail.guardrail_id, execution_order=1)
|
|
store.update_endpoint_guardrail_config(
|
|
endpoint_id=endpoint.endpoint_id,
|
|
guardrail_id=guardrail.guardrail_id,
|
|
execution_order=2,
|
|
)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GatewayUpdateGuardrailEvent.name,
|
|
{"stage": None, "action": None},
|
|
)
|
|
|
|
store.delete_gateway_guardrail(guardrail.guardrail_id)
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GatewayDeleteGuardrailEvent.name,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_invocation_telemetry(
|
|
mock_requests, mock_telemetry_client: TelemetryClient, tmp_path
|
|
):
|
|
db_path = tmp_path / "mlflow.db"
|
|
store = SqlAlchemyStore(f"sqlite:///{db_path}", tmp_path.as_posix())
|
|
|
|
secret = store.create_gateway_secret(
|
|
secret_name="test-secret",
|
|
secret_value={"api_key": "test-api-key"},
|
|
provider="openai",
|
|
created_by="test-user",
|
|
)
|
|
mock_telemetry_client.flush()
|
|
mock_requests.clear()
|
|
|
|
model_def = store.create_gateway_model_definition(
|
|
name="test-model",
|
|
provider="openai",
|
|
model_name="gpt-4",
|
|
secret_id=secret.secret_id,
|
|
created_by="test-user",
|
|
)
|
|
endpoint = store.create_gateway_endpoint(
|
|
name="test-endpoint",
|
|
model_configs=[
|
|
GatewayEndpointModelConfig(
|
|
model_definition_id=model_def.model_definition_id,
|
|
linkage_type=GatewayModelLinkageType.PRIMARY,
|
|
weight=100,
|
|
)
|
|
],
|
|
created_by="test-user",
|
|
)
|
|
mock_telemetry_client.flush()
|
|
mock_requests.clear()
|
|
|
|
mock_response = chat.ResponsePayload(
|
|
id="test-id",
|
|
object="chat.completion",
|
|
created=1234567890,
|
|
model="gpt-4",
|
|
choices=[
|
|
chat.Choice(
|
|
index=0,
|
|
message=chat.ResponseMessage(role="assistant", content="Hello!"),
|
|
finish_reason="stop",
|
|
)
|
|
],
|
|
usage=chat.ChatUsage(prompt_tokens=10, completion_tokens=5, total_tokens=15),
|
|
)
|
|
|
|
mock_model = GatewayModelConfig(
|
|
model_definition_id="test-model-def",
|
|
provider="openai",
|
|
model_name="gpt-4",
|
|
secret_value={"api_key": "test"},
|
|
linkage_type=GatewayModelLinkageType.PRIMARY,
|
|
)
|
|
|
|
# Test invocations endpoint (chat)
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.headers = {}
|
|
mock_request.json = AsyncMock(
|
|
return_value={
|
|
"messages": [{"role": "user", "content": "Hi"}],
|
|
"temperature": 0.7,
|
|
"stream": False,
|
|
}
|
|
)
|
|
|
|
with (
|
|
patch("mlflow.server.gateway_api._get_store", return_value=store),
|
|
patch(
|
|
"mlflow.server.gateway_api._create_provider_from_endpoint_name"
|
|
) as mock_create_provider,
|
|
):
|
|
mock_provider = MagicMock()
|
|
mock_provider.chat = AsyncMock(return_value=mock_response)
|
|
mock_endpoint_config = GatewayEndpointConfig(
|
|
endpoint_id=endpoint.endpoint_id,
|
|
endpoint_name=endpoint.name,
|
|
models=[mock_model],
|
|
)
|
|
mock_create_provider.return_value = (mock_provider, mock_endpoint_config)
|
|
|
|
await invocations(endpoint.name, mock_request)
|
|
|
|
data = validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GatewayInvocationEvent.name,
|
|
check_params=False,
|
|
)
|
|
params = json.loads(data["params"])
|
|
assert params["is_streaming"] is False
|
|
assert params["invocation_type"] == "mlflow_invocations"
|
|
assert params["has_traceparent"] is False
|
|
assert params["auth_enabled"] is False
|
|
assert params["endpoint_id"] == endpoint.endpoint_id
|
|
assert params["provider"] == "openai"
|
|
# Non-streaming includes timing fields
|
|
assert "provider_duration_ms" in params
|
|
assert "gateway_overhead_ms" in params
|
|
|
|
# Test chat_completions endpoint
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.headers = {}
|
|
mock_request.json = AsyncMock(
|
|
return_value={
|
|
"model": endpoint.name,
|
|
"messages": [{"role": "user", "content": "Hi"}],
|
|
"temperature": 0.7,
|
|
"stream": False,
|
|
}
|
|
)
|
|
|
|
with (
|
|
patch("mlflow.server.gateway_api._get_store", return_value=store),
|
|
patch(
|
|
"mlflow.server.gateway_api._create_provider_from_endpoint_name"
|
|
) as mock_create_provider,
|
|
):
|
|
mock_provider = MagicMock()
|
|
mock_provider.chat = AsyncMock(return_value=mock_response)
|
|
mock_endpoint_config = GatewayEndpointConfig(
|
|
endpoint_id=endpoint.endpoint_id,
|
|
endpoint_name=endpoint.name,
|
|
models=[mock_model],
|
|
)
|
|
mock_create_provider.return_value = (mock_provider, mock_endpoint_config)
|
|
|
|
await chat_completions(mock_request)
|
|
|
|
data = validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GatewayInvocationEvent.name,
|
|
check_params=False,
|
|
)
|
|
params = json.loads(data["params"])
|
|
assert params["is_streaming"] is False
|
|
assert params["invocation_type"] == "mlflow_chat_completions"
|
|
assert params["endpoint_id"] == endpoint.endpoint_id
|
|
assert params["provider"] == "openai"
|
|
|
|
# Test streaming invocation — timing fields should be absent
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.headers = {}
|
|
mock_request.json = AsyncMock(
|
|
return_value={
|
|
"model": endpoint.name,
|
|
"messages": [{"role": "user", "content": "Hi"}],
|
|
"stream": True,
|
|
}
|
|
)
|
|
|
|
async def mock_stream():
|
|
yield chat.StreamResponsePayload(
|
|
id="test-id",
|
|
object="chat.completion.chunk",
|
|
created=1234567890,
|
|
model="gpt-4",
|
|
choices=[
|
|
chat.StreamChoice(
|
|
index=0,
|
|
delta=chat.StreamDelta(role="assistant", content="Hello"),
|
|
finish_reason=None,
|
|
)
|
|
],
|
|
)
|
|
|
|
with (
|
|
patch("mlflow.server.gateway_api._get_store", return_value=store),
|
|
patch(
|
|
"mlflow.server.gateway_api._create_provider_from_endpoint_name"
|
|
) as mock_create_provider,
|
|
):
|
|
mock_provider = MagicMock()
|
|
mock_provider.chat_stream = MagicMock(return_value=mock_stream())
|
|
mock_endpoint_config = GatewayEndpointConfig(
|
|
endpoint_id=endpoint.endpoint_id,
|
|
endpoint_name=endpoint.name,
|
|
models=[mock_model],
|
|
)
|
|
mock_create_provider.return_value = (mock_provider, mock_endpoint_config)
|
|
|
|
await chat_completions(mock_request)
|
|
|
|
data = validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GatewayInvocationEvent.name,
|
|
check_params=False,
|
|
)
|
|
params = json.loads(data["params"])
|
|
assert params["is_streaming"] is True
|
|
assert params["invocation_type"] == "mlflow_chat_completions"
|
|
# Streaming responses should NOT include timing fields
|
|
assert "provider_duration_ms" not in params
|
|
assert "gateway_overhead_ms" not in params
|
|
|
|
# Test that caller header and traceparent are included in telemetry when present
|
|
mock_request = MagicMock(spec=Request)
|
|
mock_request.json = AsyncMock(
|
|
return_value={
|
|
"model": endpoint.name,
|
|
"messages": [{"role": "user", "content": "Hi"}],
|
|
"stream": False,
|
|
}
|
|
)
|
|
mock_request.headers = {MLFLOW_GATEWAY_CALLER_HEADER: "judge", "traceparent": "00-abc-def-01"}
|
|
|
|
mock_auth_module = MagicMock()
|
|
mock_auth_module.is_auth_enabled = MagicMock(return_value=True)
|
|
|
|
with (
|
|
patch("mlflow.server.gateway_api._get_store", return_value=store),
|
|
patch(
|
|
"mlflow.server.gateway_api._create_provider_from_endpoint_name"
|
|
) as mock_create_provider,
|
|
patch.dict("sys.modules", {"mlflow.server.auth": mock_auth_module}),
|
|
):
|
|
mock_provider = MagicMock()
|
|
mock_provider.chat = AsyncMock(return_value=mock_response)
|
|
mock_endpoint_config = GatewayEndpointConfig(
|
|
endpoint_id=endpoint.endpoint_id,
|
|
endpoint_name=endpoint.name,
|
|
models=[mock_model],
|
|
)
|
|
mock_create_provider.return_value = (mock_provider, mock_endpoint_config)
|
|
|
|
await chat_completions(mock_request)
|
|
|
|
data = validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
GatewayInvocationEvent.name,
|
|
check_params=False,
|
|
)
|
|
params = json.loads(data["params"])
|
|
assert params["is_streaming"] is False
|
|
assert params["invocation_type"] == "mlflow_chat_completions"
|
|
assert params["caller"] == "judge"
|
|
assert params["has_traceparent"] is True
|
|
assert params["auth_enabled"] is True
|
|
|
|
|
|
def test_tracing_context_propagation_get_and_set_success(
|
|
mock_requests, mock_telemetry_client: TelemetryClient
|
|
):
|
|
with mock.patch(
|
|
"mlflow.telemetry.track.get_telemetry_client", return_value=mock_telemetry_client
|
|
):
|
|
with mlflow.start_span("client span"):
|
|
headers = get_tracing_context_headers_for_http_request()
|
|
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
TracingContextPropagation.name,
|
|
)
|
|
|
|
with mock.patch(
|
|
"mlflow.telemetry.track.get_telemetry_client", return_value=mock_telemetry_client
|
|
):
|
|
with set_tracing_context_from_http_request_headers(headers):
|
|
with mlflow.start_span("server span"):
|
|
pass
|
|
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
TracingContextPropagation.name,
|
|
)
|
|
|
|
|
|
def test_update_issue_telemetry(mock_requests, mock_telemetry_client: TelemetryClient, db_uri):
|
|
store = SqlAlchemyStore(db_uri, "/tmp")
|
|
|
|
exp_id = store.create_experiment("test-exp")
|
|
issue = store.create_issue(
|
|
experiment_id=exp_id,
|
|
name="Original name",
|
|
description="Original description",
|
|
status=IssueStatus.PENDING,
|
|
)
|
|
mock_telemetry_client.flush()
|
|
mock_requests.clear()
|
|
|
|
store.update_issue(
|
|
issue_id=issue.issue_id,
|
|
status=IssueStatus.RESOLVED,
|
|
name="Updated name",
|
|
description="Updated description",
|
|
severity=IssueSeverity.HIGH,
|
|
)
|
|
|
|
validate_telemetry_record(
|
|
mock_telemetry_client,
|
|
mock_requests,
|
|
UpdateIssueEvent.name,
|
|
{
|
|
"status": "resolved",
|
|
"has_name": True,
|
|
"has_description": True,
|
|
"severity": "high",
|
|
"source_run_id": None,
|
|
},
|
|
)
|